Bisher entschied das erste Verfahren, das die Bank meldet – wer chipTAN nutzt, kam über eine App-Freigabe nicht weiter. Die Wahl steht jetzt auf einer eigenen Seite am Zugang. Abgefragt statt eingetippt: Die Kennungen sind bankspezifisch, und getTanModes() kommt ohne Anmeldung aus, verlangt also selbst keine TAN. Behoben: Das Speichern des Zugangsformulars setzte Verfahren und TAN-Medium zurück, weil beide Felder darin nicht vorkommen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
687 lines
24 KiB
PHP
687 lines
24 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Finanzmanager\Services;
|
||
|
||
use Fhp\Action\GetBalance;
|
||
use Fhp\Action\GetSEPAAccounts;
|
||
use Fhp\Action\GetStatementOfAccount;
|
||
use Fhp\BaseAction;
|
||
use Fhp\FinTs;
|
||
use Fhp\Model\SEPAAccount;
|
||
use Fhp\Model\StatementOfAccount\Transaction;
|
||
use Fhp\Model\TanRequestChallengeImage;
|
||
use Fhp\Options\Credentials;
|
||
use Fhp\Options\FinTsOptions;
|
||
use Finanzmanager\Auth;
|
||
use Finanzmanager\Models\AbgleichRegel;
|
||
use Finanzmanager\Models\Bankabruf;
|
||
use Finanzmanager\Models\Bankumsatz;
|
||
use Finanzmanager\Models\Bankzugang;
|
||
use Finanzmanager\Models\Einstellung;
|
||
|
||
/**
|
||
* Der Umsatzabruf über FinTS.
|
||
*
|
||
* **Warum das über mehrere Requests läuft.** Fast jede Bank verlangt beim Login
|
||
* eine TAN – schon für den reinen Lesezugriff. Zwischen Anforderung und Eingabe
|
||
* liegt eine Interaktion des Nutzers, also mindestens ein weiterer Request. Die
|
||
* Bibliothek sieht dafür `FinTs::persist()` und das Serialisieren der laufenden
|
||
* Aktion vor; beides wird hier in der Sitzung abgelegt und beim nächsten Request
|
||
* wieder geladen. Ein Warteschleifen-Prozess, der die TAN abwartet, käme mit
|
||
* php-fpm nicht weit.
|
||
*
|
||
* **Was in der Sitzung liegt.** Der persistierte Dialog (er enthält keine PIN –
|
||
* die Bibliothek schließt sie ausdrücklich aus), die serialisierte Aktion und
|
||
* der Punkt, an dem weiterzumachen ist. Die PIN selbst wird bei jedem Request
|
||
* neu aus der Datenbank geholt und entschlüsselt.
|
||
*
|
||
* **Der Ablauf** ist eine kleine Zustandsmaschine:
|
||
*
|
||
* anmelden → konten → umsaetze (je Konto) → fertig
|
||
*
|
||
* Jeder Schritt kann eine TAN verlangen. Dann wird der Zustand gesichert und der
|
||
* Aufrufer bekommt `tan`; nach `tanEinreichen()` läuft es an derselben Stelle
|
||
* weiter. Umsätze, die vorher schon geschrieben wurden, bleiben in der Datenbank
|
||
* – ein Abbruch mittendrin verliert also keine Arbeit.
|
||
*/
|
||
class FinTsAbruf
|
||
{
|
||
/** Schlüssel des Abrufzustands in der Sitzung. */
|
||
public const SITZUNG = 'fints_abruf';
|
||
|
||
/** Produktkennung gegenüber der Bank. Ohne eigene Registrierung die von phpFinTS. */
|
||
private const PRODUKT = 'F399A6377D4D60E0A222D677A';
|
||
private const PRODUKT_VERSION = '1.0';
|
||
|
||
/** @var list<string> */
|
||
private array $protokoll = [];
|
||
|
||
private FinTs $fints;
|
||
|
||
/** @param array<string,mixed> $zugang mit entschlüsselter PIN */
|
||
private function __construct(
|
||
private array $zugang,
|
||
private int $abrufId,
|
||
private array $zustand
|
||
) {
|
||
}
|
||
|
||
/**
|
||
* Startet einen Abruf.
|
||
*
|
||
* @return array{status: string, meldung: string, protokoll: list<string>,
|
||
* neu: int, uebersprungen: int, tan?: array<string,mixed>}
|
||
*/
|
||
public static function starten(int $zugangId): array
|
||
{
|
||
$zugang = Bankzugang::zugangsdaten($zugangId);
|
||
if ($zugang === null) {
|
||
return self::ergebnis('fehler', 'Den Bankzugang gibt es nicht.', []);
|
||
}
|
||
|
||
foreach (['blz' => 'Bankleitzahl', 'fints_url' => 'FinTS-Adresse',
|
||
'benutzerkennung' => 'Benutzerkennung', 'pin' => 'PIN'] as $feld => $name) {
|
||
if (trim((string)$zugang[$feld]) === '') {
|
||
return self::ergebnis('fehler', 'Es fehlt: ' . $name . '.', []);
|
||
}
|
||
}
|
||
|
||
self::vergessen();
|
||
|
||
$abruf = new self($zugang, Bankabruf::beginnen($zugangId), [
|
||
'schritt' => 'anmelden',
|
||
'konto_nummer' => 0,
|
||
'neu' => 0,
|
||
'uebersprungen' => 0,
|
||
'protokoll' => [],
|
||
]);
|
||
|
||
return $abruf->lauf(null);
|
||
}
|
||
|
||
/**
|
||
* Setzt einen Abruf fort, der auf eine TAN wartete.
|
||
*
|
||
* @return array{status: string, meldung: string, protokoll: list<string>,
|
||
* neu: int, uebersprungen: int, tan?: array<string,mixed>}
|
||
*/
|
||
public static function tanEinreichen(string $tan): array
|
||
{
|
||
$zustand = self::zustandLesen();
|
||
if ($zustand === null) {
|
||
return self::ergebnis('fehler', 'Es wartet kein Abruf auf eine TAN.', []);
|
||
}
|
||
|
||
$zugang = Bankzugang::zugangsdaten((int)$zustand['zugang_id']);
|
||
if ($zugang === null) {
|
||
self::vergessen();
|
||
return self::ergebnis('fehler', 'Den Bankzugang gibt es nicht mehr.', []);
|
||
}
|
||
|
||
$abruf = new self($zugang, (int)$zustand['abruf_id'], $zustand);
|
||
|
||
return $abruf->lauf(trim($tan));
|
||
}
|
||
|
||
/** Wartet gerade ein Abruf auf eine TAN? */
|
||
public static function wartetAufTan(): bool
|
||
{
|
||
return self::zustandLesen() !== null;
|
||
}
|
||
|
||
/** @return array<string,mixed>|null Die Angaben für die TAN-Maske. */
|
||
public static function offeneTanAnfrage(): ?array
|
||
{
|
||
$zustand = self::zustandLesen();
|
||
|
||
return $zustand === null ? null : ($zustand['tan'] ?? null);
|
||
}
|
||
|
||
public static function abbrechen(): void
|
||
{
|
||
$zustand = self::zustandLesen();
|
||
if ($zustand !== null) {
|
||
Bankabruf::abschliessen(
|
||
(int)$zustand['abruf_id'],
|
||
'abgebrochen',
|
||
(int)$zustand['neu'],
|
||
(int)$zustand['uebersprungen'],
|
||
'Vom Nutzer abgebrochen.',
|
||
$zustand['protokoll'] ?? []
|
||
);
|
||
}
|
||
|
||
self::vergessen();
|
||
}
|
||
|
||
// ── Ablauf ───────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Ein Durchlauf: Verbindung herstellen (oder wiederherstellen), gegebenenfalls
|
||
* die TAN einreichen und dann so weit laufen, wie es ohne Rückfrage geht.
|
||
*
|
||
* @return array<string,mixed>
|
||
*/
|
||
private function lauf(?string $tan): array
|
||
{
|
||
$this->protokoll = $this->zustand['protokoll'] ?? [];
|
||
|
||
try {
|
||
$this->fints = $this->verbindung();
|
||
} catch (\Throwable $e) {
|
||
return $this->beenden('fehler', 'Verbindung fehlgeschlagen: ' . $e->getMessage());
|
||
}
|
||
|
||
// Eine wartende Aktion zuerst abschließen – erst danach geht es im
|
||
// Ablauf weiter.
|
||
if ($tan !== null && isset($this->zustand['aktion'])) {
|
||
$antwort = $this->tanAbschliessen($tan);
|
||
if ($antwort !== null) {
|
||
return $antwort;
|
||
}
|
||
}
|
||
|
||
try {
|
||
return $this->abarbeiten();
|
||
} catch (\Throwable $e) {
|
||
DebugLog::fehler($e);
|
||
return $this->beenden('fehler', $e->getMessage());
|
||
}
|
||
}
|
||
|
||
/** @return array<string,mixed> */
|
||
private function abarbeiten(): array
|
||
{
|
||
if ($this->zustand['schritt'] === 'anmelden') {
|
||
$anmeldung = $this->fints->login();
|
||
$wartet = $this->pruefeTan($anmeldung, 'Anmeldung', 'anmelden');
|
||
if ($wartet !== null) {
|
||
return $wartet;
|
||
}
|
||
|
||
$this->notiere('Angemeldet bei ' . ($this->zugang['bankname'] ?: $this->zugang['name']) . '.');
|
||
$this->zustand['schritt'] = 'konten';
|
||
}
|
||
|
||
if ($this->zustand['schritt'] === 'konten') {
|
||
$aktion = GetSEPAAccounts::create();
|
||
$this->fints->execute($aktion);
|
||
|
||
$wartet = $this->pruefeTan($aktion, 'Kontenabfrage', 'konten');
|
||
if ($wartet !== null) {
|
||
return $wartet;
|
||
}
|
||
|
||
$this->merkeKonten($aktion->getAccounts());
|
||
$this->zustand['schritt'] = 'umsaetze';
|
||
}
|
||
|
||
return $this->umsaetzeAbrufen();
|
||
}
|
||
|
||
/**
|
||
* Läuft die Konten des Zugangs durch. Der Index steht im Zustand: Verlangt
|
||
* ein Konto eine TAN, geht es danach genau dort weiter.
|
||
*
|
||
* @return array<string,mixed>
|
||
*/
|
||
private function umsaetzeAbrufen(): array
|
||
{
|
||
$konten = $this->abzurufendeKonten();
|
||
[$von, $beschreibung] = Bankzugang::startdatum(
|
||
(string)$this->zugang['abrufzeitraum'],
|
||
$this->zugang['zuletzt_am'] === null ? null : (string)$this->zugang['zuletzt_am']
|
||
);
|
||
|
||
if ($this->zustand['konto_nummer'] === 0) {
|
||
$this->notiere('Zeitraum: ' . $beschreibung . ' (ab ' . $von->format('d.m.Y') . ').');
|
||
}
|
||
|
||
$regeln = Einstellung::getBool('banking_regeln_beim_abruf', true) ? AbgleichRegel::alle(true) : [];
|
||
|
||
for ($nummer = (int)$this->zustand['konto_nummer']; $nummer < count($konten); $nummer++) {
|
||
$this->zustand['konto_nummer'] = $nummer;
|
||
$bankkonto = $konten[$nummer];
|
||
|
||
$sepa = $this->sepaKonto($bankkonto);
|
||
if ($sepa === null) {
|
||
$this->notiere('Übersprungen: ' . ($bankkonto['iban'] ?: $bankkonto['kontonummer'])
|
||
. ' – die Bank führt dieses Konto nicht mehr.');
|
||
continue;
|
||
}
|
||
|
||
$aktion = GetStatementOfAccount::create(
|
||
$sepa,
|
||
\DateTime::createFromImmutable($von),
|
||
new \DateTime('today'),
|
||
false,
|
||
true
|
||
);
|
||
$this->fints->execute($aktion);
|
||
|
||
$wartet = $this->pruefeTan($aktion, 'Umsätze ' . ($bankkonto['iban'] ?: $bankkonto['kontonummer']), 'umsaetze');
|
||
if ($wartet !== null) {
|
||
return $wartet;
|
||
}
|
||
|
||
$this->schreibeUmsaetze($bankkonto, $aktion->getStatement()->getStatements(), $regeln);
|
||
$this->saldoHolen($sepa, (int)$bankkonto['id']);
|
||
}
|
||
|
||
Bankzugang::merkeAbruf((int)$this->zugang['id']);
|
||
|
||
return $this->beenden('erfolg', sprintf(
|
||
'%d neue Umsätze, %d bereits bekannt.',
|
||
$this->zustand['neu'],
|
||
$this->zustand['uebersprungen']
|
||
));
|
||
}
|
||
|
||
/**
|
||
* Schreibt die Umsätze eines Kontos weg.
|
||
*
|
||
* @param array<string,mixed> $bankkonto
|
||
* @param list<mixed> $auszuege
|
||
* @param list<array<string,mixed>> $regeln
|
||
*/
|
||
private function schreibeUmsaetze(array $bankkonto, array $auszuege, array $regeln): void
|
||
{
|
||
$neu = 0;
|
||
$alt = 0;
|
||
|
||
foreach ($auszuege as $auszug) {
|
||
foreach ($auszug->getTransactions() as $umsatz) {
|
||
/** @var Transaction $umsatz */
|
||
$buchungstag = $umsatz->getBookingDate() ?: $umsatz->getValutaDate() ?: new \DateTime();
|
||
$valuta = $umsatz->getValutaDate() ?: $buchungstag;
|
||
|
||
// Die Bibliothek liefert den Betrag ohne Vorzeichen; die Richtung
|
||
// steht in getCreditDebit().
|
||
$betrag = round(
|
||
(float)$umsatz->getAmount() * ($umsatz->getCreditDebit() === Transaction::CD_DEBIT ? -1 : 1),
|
||
2
|
||
);
|
||
|
||
$zweck = trim($umsatz->getMainDescription());
|
||
$empfaenger = trim($umsatz->getName());
|
||
|
||
$externId = Bankumsatz::externId(
|
||
(string)($bankkonto['iban'] ?: $bankkonto['kontonummer']),
|
||
$buchungstag->format('Y-m-d'),
|
||
$betrag,
|
||
$zweck,
|
||
$empfaenger
|
||
);
|
||
|
||
$id = Bankumsatz::anlegen([
|
||
'zugang_id' => (int)$this->zugang['id'],
|
||
'konto_id' => $bankkonto['konto_id'],
|
||
'extern_id' => $externId,
|
||
'buchungstag' => $buchungstag->format('Y-m-d'),
|
||
'wertstellung' => $valuta->format('Y-m-d'),
|
||
'betrag' => $betrag,
|
||
'empfaenger' => $empfaenger,
|
||
'verwendungszweck' => $zweck,
|
||
'buchungsart' => trim($umsatz->getBookingText()),
|
||
'roh' => json_encode([
|
||
'iban' => $bankkonto['iban'],
|
||
'eref' => $umsatz->getEndToEndID(),
|
||
'code' => $umsatz->getBookingCode(),
|
||
'name' => $empfaenger,
|
||
'zweck' => $zweck,
|
||
], JSON_UNESCAPED_UNICODE),
|
||
]);
|
||
|
||
if ($id === null) {
|
||
$alt++;
|
||
continue;
|
||
}
|
||
|
||
$neu++;
|
||
|
||
// Die Regel zählt ihren Treffer schon beim Abruf: Die Liste der
|
||
// Regeln zeigt damit, welche im Alltag überhaupt greifen.
|
||
if ($regeln !== []) {
|
||
$regel = AbgleichRegel::passendeRegel(
|
||
['empfaenger' => $empfaenger, 'verwendungszweck' => $zweck, 'betrag' => $betrag],
|
||
$regeln
|
||
);
|
||
if ($regel !== null) {
|
||
AbgleichRegel::zaehleTreffer((int)$regel['id']);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
$this->zustand['neu'] += $neu;
|
||
$this->zustand['uebersprungen'] += $alt;
|
||
|
||
$this->notiere(sprintf(
|
||
'%s: %d neu, %d bereits bekannt.',
|
||
$bankkonto['iban'] ?: $bankkonto['kontonummer'],
|
||
$neu,
|
||
$alt
|
||
));
|
||
}
|
||
|
||
private function saldoHolen(SEPAAccount $sepa, int $bankKontoId): void
|
||
{
|
||
try {
|
||
$aktion = GetBalance::create($sepa);
|
||
$this->fints->execute($aktion);
|
||
|
||
// Für den Saldo wird keine TAN erfragt: Er ist eine Zugabe, und eine
|
||
// zweite TAN-Runde nur für eine Zahl wäre dem Nutzer nicht zu erklären.
|
||
if ($aktion->needsTan()) {
|
||
$this->notiere(' Saldo übersprungen – die Bank verlangt dafür eine eigene TAN.');
|
||
return;
|
||
}
|
||
|
||
$saldo = null;
|
||
foreach ($aktion->getBalances() as $stand) {
|
||
$saldo = ($saldo ?? 0.0) + $stand->getGebuchterSaldo()->getAmount();
|
||
}
|
||
|
||
if ($saldo !== null) {
|
||
Bankzugang::merkeSaldo($bankKontoId, round($saldo, 2));
|
||
$this->notiere(' Kontostand: ' . number_format($saldo, 2, ',', '.') . ' €');
|
||
}
|
||
} catch (\Throwable $e) {
|
||
$this->notiere(' Kontostand nicht abrufbar: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
// ── TAN ──────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Verlangt die Aktion eine TAN, wird der Zustand gesichert und der Aufrufer
|
||
* bekommt die Angaben für die Eingabemaske.
|
||
*
|
||
* @return array<string,mixed>|null null, wenn es ohne TAN weitergeht
|
||
*/
|
||
private function pruefeTan(BaseAction $aktion, string $bezeichnung, string $schritt): ?array
|
||
{
|
||
if (!$aktion->needsTan()) {
|
||
return null;
|
||
}
|
||
|
||
$verfahren = $this->fints->getSelectedTanMode();
|
||
$anfrage = $aktion->getTanRequest();
|
||
|
||
// Entkoppelte Verfahren (pushTAN-App): Die Freigabe passiert im Gerät des
|
||
// Nutzers, wir fragen nur nach. Der Nutzer drückt in der Maske auf
|
||
// „Freigabe prüfen“, statt eine Nummer abzutippen.
|
||
$entkoppelt = $verfahren !== null && $verfahren->isDecoupled();
|
||
|
||
$tan = [
|
||
'bezeichnung' => $bezeichnung,
|
||
'hinweis' => $anfrage?->getChallenge() ?? '',
|
||
'medium' => $anfrage?->getTanMediumName() ?? '',
|
||
'entkoppelt' => $entkoppelt,
|
||
'bild_typ' => null,
|
||
'bild_daten' => null,
|
||
];
|
||
|
||
if (!$entkoppelt && $anfrage?->getChallengeHhdUc()) {
|
||
try {
|
||
$bild = new TanRequestChallengeImage($anfrage->getChallengeHhdUc());
|
||
$tan['bild_typ'] = $bild->getMimeType();
|
||
$tan['bild_daten'] = base64_encode($bild->getData());
|
||
} catch (\Throwable) {
|
||
// Kein photoTAN-Bild (etwa chipTAN-Flicker) – dann eben ohne.
|
||
}
|
||
}
|
||
|
||
$this->notiere('TAN erforderlich: ' . $bezeichnung . '.');
|
||
|
||
$this->zustand['schritt'] = $schritt;
|
||
$this->zustand['tan'] = $tan;
|
||
$this->zustand['aktion'] = serialize($aktion);
|
||
|
||
return $this->beenden('tan', 'Die Bank verlangt eine Freigabe.', $tan);
|
||
}
|
||
|
||
/**
|
||
* Reicht die TAN für die wartende Aktion nach.
|
||
*
|
||
* @return array<string,mixed>|null Fehlerergebnis, oder null bei Erfolg
|
||
*/
|
||
private function tanAbschliessen(string $tan): ?array
|
||
{
|
||
$aktion = unserialize((string)$this->zustand['aktion'], ['allowed_classes' => true]);
|
||
if (!$aktion instanceof BaseAction) {
|
||
return $this->beenden('fehler', 'Der wartende Vorgang ist nicht mehr lesbar. Bitte neu abrufen.');
|
||
}
|
||
|
||
$entkoppelt = ($this->zustand['tan']['entkoppelt'] ?? false) === true;
|
||
|
||
try {
|
||
if ($entkoppelt) {
|
||
// Kein Warten im Request: Ein Klick, eine Nachfrage. Steht die
|
||
// Freigabe noch aus, bleibt die Maske stehen.
|
||
if (!$this->fints->checkDecoupledSubmission($aktion)) {
|
||
$this->zustand['aktion'] = serialize($aktion);
|
||
return $this->beenden(
|
||
'tan',
|
||
'Noch keine Freigabe eingegangen. Im Banking-Gerät bestätigen und erneut prüfen.',
|
||
$this->zustand['tan']
|
||
);
|
||
}
|
||
} else {
|
||
if ($tan === '') {
|
||
return $this->beenden('tan', 'Bitte die TAN eingeben.', $this->zustand['tan']);
|
||
}
|
||
$this->fints->submitTan($aktion, $tan);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
return $this->beenden('fehler', 'Die Freigabe wurde abgelehnt: ' . $e->getMessage());
|
||
}
|
||
|
||
$this->notiere('Freigabe angenommen.');
|
||
unset($this->zustand['aktion'], $this->zustand['tan']);
|
||
|
||
// Die Aktion war der letzte Schritt vor dem Weitermachen: Ihr Ergebnis
|
||
// wird gebraucht, wenn es die Kontenliste war.
|
||
if ($this->zustand['schritt'] === 'konten' && $aktion instanceof GetSEPAAccounts) {
|
||
$this->merkeKonten($aktion->getAccounts());
|
||
$this->zustand['schritt'] = 'umsaetze';
|
||
} elseif ($this->zustand['schritt'] === 'anmelden') {
|
||
$this->zustand['schritt'] = 'konten';
|
||
} elseif ($this->zustand['schritt'] === 'umsaetze' && $aktion instanceof GetStatementOfAccount) {
|
||
$konten = $this->abzurufendeKonten();
|
||
$konto = $konten[(int)$this->zustand['konto_nummer']] ?? null;
|
||
if ($konto !== null) {
|
||
$regeln = Einstellung::getBool('banking_regeln_beim_abruf', true) ? AbgleichRegel::alle(true) : [];
|
||
$this->schreibeUmsaetze($konto, $aktion->getStatement()->getStatements(), $regeln);
|
||
$this->zustand['konto_nummer'] = (int)$this->zustand['konto_nummer'] + 1;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// ── Verbindung und Zustand ───────────────────────────────────────────────
|
||
|
||
private function verbindung(): FinTs
|
||
{
|
||
$optionen = new FinTsOptions();
|
||
$optionen->url = (string)$this->zugang['fints_url'];
|
||
$optionen->bankCode = (string)$this->zugang['blz'];
|
||
$optionen->productName = Einstellung::get('banking_produkt_id') ?: self::PRODUKT;
|
||
$optionen->productVersion = self::PRODUKT_VERSION;
|
||
|
||
$zugangsdaten = Credentials::create(
|
||
(string)$this->zugang['benutzerkennung'],
|
||
(string)$this->zugang['pin']
|
||
);
|
||
|
||
$fints = FinTs::new($optionen, $zugangsdaten, $this->zustand['dialog'] ?? null);
|
||
$fints->setLogger(new DebugLogAdapter());
|
||
|
||
// Beim ersten Lauf steht das Verfahren noch nicht im Dialog – es kommt
|
||
// aus der Einstellung des Zugangs (siehe `Services\FinTsVerfahren`).
|
||
if (!isset($this->zustand['dialog'])) {
|
||
FinTsVerfahren::anwenden($fints, $this->zugang, fn(string $zeile) => $this->notiere($zeile));
|
||
}
|
||
|
||
return $fints;
|
||
}
|
||
|
||
/**
|
||
* Die Konten dieses Zugangs, die abgerufen werden sollen.
|
||
*
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
private function abzurufendeKonten(): array
|
||
{
|
||
return array_values(array_filter(
|
||
Bankzugang::konten((int)$this->zugang['id']),
|
||
static fn(array $konto): bool => (int)$konto['abrufen'] === 1
|
||
));
|
||
}
|
||
|
||
/** @param list<SEPAAccount> $konten */
|
||
private function merkeKonten(array $konten): void
|
||
{
|
||
$this->zustand['sepa'] = [];
|
||
|
||
foreach ($konten as $konto) {
|
||
$eintrag = Bankzugang::findeOderLegeKontoAn(
|
||
(int)$this->zugang['id'],
|
||
(string)$konto->getIban(),
|
||
(string)$konto->getAccountNumber(),
|
||
(string)$konto->getIban() ?: (string)$konto->getAccountNumber()
|
||
);
|
||
|
||
// Die SEPAAccount-Objekte werden für den nächsten Request gebraucht,
|
||
// wenn eine TAN dazwischenkommt – sie sind schlichte Wertobjekte und
|
||
// lassen sich gefahrlos serialisieren.
|
||
$this->zustand['sepa'][self::kennung($eintrag)] = serialize($konto);
|
||
}
|
||
|
||
$this->notiere(count($konten) . ' Konto/Konten bei der Bank gefunden.');
|
||
}
|
||
|
||
/** @param array<string,mixed> $bankkonto */
|
||
private function sepaKonto(array $bankkonto): ?SEPAAccount
|
||
{
|
||
$roh = $this->zustand['sepa'][self::kennung($bankkonto)] ?? null;
|
||
if (!is_string($roh)) {
|
||
return null;
|
||
}
|
||
|
||
$konto = unserialize($roh, ['allowed_classes' => [SEPAAccount::class]]);
|
||
|
||
return $konto instanceof SEPAAccount ? $konto : null;
|
||
}
|
||
|
||
/** @param array<string,mixed> $konto */
|
||
private static function kennung(array $konto): string
|
||
{
|
||
return strtoupper((string)($konto['iban'] ?? '')) . '|' . (string)($konto['kontonummer'] ?? '');
|
||
}
|
||
|
||
private function notiere(string $zeile): void
|
||
{
|
||
$this->protokoll[] = date('H:i:s') . ' ' . $zeile;
|
||
DebugLog::schreibe('fints', $zeile);
|
||
}
|
||
|
||
/**
|
||
* Beendet den Durchlauf: Protokoll wegschreiben und den Zustand entweder
|
||
* sichern (wenn eine TAN aussteht) oder vergessen.
|
||
*
|
||
* @param array<string,mixed>|null $tan
|
||
* @return array<string,mixed>
|
||
*/
|
||
private function beenden(string $status, string $meldung, ?array $tan = null): array
|
||
{
|
||
$this->protokoll[] = date('H:i:s') . ' ' . $meldung;
|
||
$this->zustand['protokoll'] = $this->protokoll;
|
||
|
||
Bankabruf::abschliessen(
|
||
$this->abrufId,
|
||
$status === 'tan' ? 'tan_offen' : $status,
|
||
(int)$this->zustand['neu'],
|
||
(int)$this->zustand['uebersprungen'],
|
||
$meldung,
|
||
$this->protokoll
|
||
);
|
||
|
||
if ($status === 'tan') {
|
||
$this->zustand['dialog'] = $this->fints->persist();
|
||
$this->zustand['zugang_id'] = (int)$this->zugang['id'];
|
||
$this->zustand['abruf_id'] = $this->abrufId;
|
||
self::zustandSchreiben($this->zustand);
|
||
} else {
|
||
self::vergessen();
|
||
try {
|
||
$this->fints->close();
|
||
} catch (\Throwable) {
|
||
// Der Dialog ist ohnehin zu Ende; ein Fehler beim Abmelden
|
||
// ändert am Ergebnis nichts.
|
||
}
|
||
}
|
||
|
||
return self::ergebnis(
|
||
$status,
|
||
$meldung,
|
||
$this->protokoll,
|
||
(int)$this->zustand['neu'],
|
||
(int)$this->zustand['uebersprungen'],
|
||
$tan
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $protokoll
|
||
* @param array<string,mixed>|null $tan
|
||
* @return array<string,mixed>
|
||
*/
|
||
private static function ergebnis(
|
||
string $status,
|
||
string $meldung,
|
||
array $protokoll,
|
||
int $neu = 0,
|
||
int $uebersprungen = 0,
|
||
?array $tan = null
|
||
): array {
|
||
$ergebnis = [
|
||
'status' => $status,
|
||
'meldung' => $meldung,
|
||
'protokoll' => $protokoll,
|
||
'neu' => $neu,
|
||
'uebersprungen' => $uebersprungen,
|
||
];
|
||
|
||
if ($tan !== null) {
|
||
$ergebnis['tan'] = $tan;
|
||
}
|
||
|
||
return $ergebnis;
|
||
}
|
||
|
||
/** @return array<string,mixed>|null */
|
||
private static function zustandLesen(): ?array
|
||
{
|
||
Auth::startSession();
|
||
$zustand = $_SESSION[self::SITZUNG] ?? null;
|
||
|
||
return is_array($zustand) ? $zustand : null;
|
||
}
|
||
|
||
/** @param array<string,mixed> $zustand */
|
||
private static function zustandSchreiben(array $zustand): void
|
||
{
|
||
Auth::startSession();
|
||
$_SESSION[self::SITZUNG] = $zustand;
|
||
}
|
||
|
||
private static function vergessen(): void
|
||
{
|
||
Auth::startSession();
|
||
unset($_SESSION[self::SITZUNG]);
|
||
}
|
||
}
|