*/ private array $protokoll = []; private FinTs $fints; /** Schneidet den Dialog dieses Durchlaufs für das Übermittlungsprotokoll mit. */ private BankProtokollLogger $mitschnitt; /** @param array $zugang mit entschlüsselter PIN */ private function __construct( private array $zugang, private int $abrufId, private array $zustand ) { $this->mitschnitt = new BankProtokollLogger(); } /** * Startet einen Abruf. * * `$modus` entscheidet, wie weit er läuft: `voll` ruft die Umsätze ab, * `konten` hält nach der Kontenabfrage an. Der kurze Lauf ist der dritte * Einrichtungsschritt – dort sollen erst die Konten zugeordnet werden, bevor * Umsätze hereinkommen, die sonst ohne Konto dastünden. `depot` holt statt * der Umsätze die Depotaufstellung. * * `$stichtag` gilt nur für diesen Lauf und geht dem Abrufzeitraum des Zugangs * vor. Er liegt im Zustand, nicht in der Datenbank: Nach einer TAN-Runde * läuft der Abruf im nächsten Request weiter und muss dort denselben Zeitraum * holen wie beim Start. * * @return array{status: string, meldung: string, protokoll: list, * neu: int, uebersprungen: int, tan?: array} */ public static function starten( int $zugangId, string $modus = 'voll', bool $einrichtung = false, ?string $stichtag = null ): 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 . '.', []); } } $stichtag = trim((string)$stichtag); if ($stichtag !== '' && Bankzugang::stichtagDatum($stichtag) === null) { return self::ergebnis('fehler', 'Der Stichtag ist kein gültiges Datum.', []); } self::vergessen(); $abruf = new self($zugang, Bankabruf::beginnen($zugangId), [ 'schritt' => 'anmelden', 'modus' => in_array($modus, ['konten', 'depot'], true) ? $modus : 'voll', 'einrichtung' => $einrichtung, 'stichtag' => $stichtag, '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, * neu: int, uebersprungen: int, tan?: array} */ 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|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 */ private function lauf(?string $tan): array { $ergebnis = $this->durchlauf($tan); $this->mitschnitt->vermerke('Ergebnis: ' . $ergebnis['meldung']); try { Bankprotokoll::speichern( (int)$this->zugang['id'], 'abruf', match ($ergebnis['status']) { 'erfolg' => 'erfolg', 'tan' => 'offen', default => 'fehler', }, (string)$ergebnis['meldung'], $this->mitschnitt->text() ); } catch (\Throwable $e) { // Ein Fehler im Protokoll darf den Abruf nicht verdecken. DebugLog::fehler($e); } return $ergebnis; } /** @return array */ private function durchlauf(?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 */ 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'] = $this->naechsterSchrittNachAnmeldung(); } if ($this->zustand['schritt'] === 'depot') { return $this->depotsAbrufen(); } 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'; } // Der kurze Lauf endet hier: Die Konten stehen fest, zugeordnet werden // sie auf der Seite, von der aus er gestartet wurde. if (($this->zustand['modus'] ?? 'voll') === 'konten') { return $this->beenden('erfolg', sprintf( '%d Konto/Konten bei der Bank gefunden.', count(Bankzugang::konten((int)$this->zugang['id'])) )); } 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 */ private function umsaetzeAbrufen(): array { $konten = $this->abzurufendeKonten(); [$von, $beschreibung] = Bankzugang::abrufBeginn( $this->zugang, (string)($this->zustand['stichtag'] ?? '') ); 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']); $meldung = sprintf( '%d neue Umsätze, %d bereits bekannt.', $this->zustand['neu'], $this->zustand['uebersprungen'] ); // Erst am Ende, nicht je Konto: So sieht der Lauf alle Umsätze auf // einmal und kann jeden mit der Buchung verbinden, die zu ihm passt. $gebucht = Bankabgleich::automatisch(); if ($gebucht !== null) { $bericht = Bankabgleich::bericht($gebucht); $this->notiere($bericht); $meldung .= ' ' . $bericht; } return $this->beenden('erfolg', $meldung); } /** * Schreibt die Umsätze eines Kontos weg. * * @param array $bankkonto * @param list $auszuege * @param list> $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()); // Die Kontonummer der Gegenseite – bei CAMT ihre IBAN, bei // MT940 die alte Kontonummer; die Bankleitzahl bzw. BIC daneben // löst sich sofort gegen das Institutsverzeichnis auf, statt bei // jeder Anzeige im Kontoblatt neu nachzuschlagen. $gegenIban = trim($umsatz->getAccountNumber()); $gegenBankname = (string)( Bankinstitut::findeNachBicOderBlz($umsatz->getBankCode())['institut'] ?? '' ); $externId = Bankumsatz::externId( (int)$bankkonto['id'], $buchungstag->format('Y-m-d'), $betrag, $zweck, $empfaenger ); $id = Bankumsatz::anlegen([ 'zugang_id' => (int)$this->zugang['id'], 'konto_id' => $bankkonto['konto_id'], 'bank_konto_id' => (int)$bankkonto['id'], 'extern_id' => $externId, 'buchungstag' => $buchungstag->format('Y-m-d'), 'wertstellung' => $valuta->format('Y-m-d'), 'betrag' => $betrag, 'empfaenger' => $empfaenger, 'verwendungszweck' => $zweck, 'gegen_iban' => $gegenIban, 'gegen_bankname' => $gegenBankname, '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()); } } // ── Depot ──────────────────────────────────────────────────────────────── private function naechsterSchrittNachAnmeldung(): string { return ($this->zustand['modus'] ?? 'voll') === 'depot' ? 'depot' : 'konten'; } /** * Holt die Depotaufstellung jedes Depots, das die Bank in den * Kontostammdaten führt. Der Index steht im Zustand – verlangt ein Depot * eine TAN, geht es danach genau dort weiter. * * @return array */ private function depotsAbrufen(): array { if (!isset($this->zustand['depots'])) { $this->zustand['depots'] = $this->depotkonten(); $this->zustand['depot_nummer'] = 0; $this->zustand['depot_geholt'] = 0; } $depots = $this->zustand['depots']; if ($depots === []) { return $this->beenden('fehler', 'Die Bank meldet für diesen Zugang kein Depot mit Abrufmöglichkeit.'); } for ($nummer = (int)$this->zustand['depot_nummer']; $nummer < count($depots); $nummer++) { $this->zustand['depot_nummer'] = $nummer; $depot = $depots[$nummer]; if ($this->eigenesDepot($depot) === null) { $this->notiere('Übersprungen: Depot ' . $depot['kontonummer'] . ' ist keinem Depot zugeordnet.'); continue; } $sepa = new SEPAAccount(); $sepa->setAccountNumber((string)$depot['kontonummer']); $sepa->setSubAccount($depot['unterkonto'] !== '' ? (string)$depot['unterkonto'] : null); $sepa->setBlz((string)$this->zugang['blz']); $aktion = GetDepotAufstellung::create($sepa); $this->fints->execute($aktion); $wartet = $this->pruefeTan($aktion, 'Depot ' . $depot['kontonummer'], 'depot'); if ($wartet !== null) { return $wartet; } $this->schreibeDepot($depot, $aktion); } if ((int)$this->zustand['depot_geholt'] === 0) { return $this->beenden( 'fehler', 'Das Depot ' . implode(' / ', array_column($depots, 'kontonummer')) . ' ist noch keinem Depot im Finanzmanager zugeordnet. Bitte unter Online-Banking → Konten zuordnen zuordnen und dann erneut abrufen.' ); } return $this->beenden('erfolg', sprintf( 'Depotbestand abgerufen: %d Positionen in %d Depot(s).', $this->zustand['neu'], $this->zustand['depot_geholt'] )); } /** * Die Depots aus den Kontostammdaten der Bank (UPD, `HIUPD`): Kontoart * 30–39 oder ein erlaubter Geschäftsvorfall `HKWPD`. Die Bibliothek reicht die * UPD nicht nach außen; sie steht nach der Anmeldung im FinTs-Objekt. * * @return list */ private function depotkonten(): array { $upd = (new \ReflectionProperty(FinTs::class, 'upd'))->getValue($this->fints); if ($upd === null) { $this->notiere('Die Bank hat keine Kontostammdaten (UPD) geliefert.'); return []; } $depots = []; foreach ($upd->hiupd as $hiupd) { $verbindung = $hiupd->getKontoverbindung(); $art = $hiupd->getKontoart(); $abrufbar = false; foreach ($hiupd->getErlaubteGeschaeftsvorfaelle() as $erlaubt) { $abrufbar = $abrufbar || $erlaubt->getGeschaeftsvorfall() === 'HKWPD'; } $istDepot = ($art !== null && $art >= 30 && $art <= 39) || $abrufbar; if ($verbindung === null || !$istDepot) { continue; } $nummer = (string)$verbindung->getAccountNumber(); if (!$abrufbar) { $this->notiere('Depot ' . $nummer . ' übersprungen – die Bank erlaubt dafür keine Depotaufstellung.'); continue; } $depots[] = [ 'kontonummer' => $nummer, 'unterkonto' => (string)$verbindung->unterkontomerkmal, 'name' => trim((string)$hiupd->getKontoproduktbezeichnung()), ]; $this->notiere('Depot bei der Bank: ' . $nummer . ' (' . trim((string)$hiupd->getName1()) . ').'); } return $depots; } /** * Das Depot im Finanzmanager, dem das Depot der Bank zugeordnet ist. * * @param array $depot * @return array|null */ private function eigenesDepot(array $depot): ?array { $bankkonto = Bankzugang::findeOderLegeDepotAn((int)$this->zugang['id'], $depot['kontonummer'], $depot['name']); $kontoId = (int)($bankkonto['konto_id'] ?? 0); return $kontoId > 0 ? Konto::find($kontoId) : null; } /** @param array $depot */ private function schreibeDepot(array $depot, GetDepotAufstellung $aktion): void { $konto = $this->eigenesDepot($depot); if ($konto === null) { return; } $ergebnis = Depotabgleich::uebernehmen((int)$konto['id'], $aktion->getStatement()->getHoldings()); $this->zustand['neu'] += $ergebnis['positionen']; $this->zustand['depot_geholt'] = (int)($this->zustand['depot_geholt'] ?? 0) + 1; $this->zustand['depot_nummer'] = (int)$this->zustand['depot_nummer'] + 1; $this->notiere(sprintf( '%s: %d Positionen, Depotwert laut Bank %s €, %d Kurse übernommen.', $konto['name'], $ergebnis['positionen'], number_format($aktion->getDepotWert(), 2, ',', '.'), $ergebnis['kurse'] )); } // ── TAN ────────────────────────────────────────────────────────────────── /** * Verlangt die Aktion eine TAN, wird der Zustand gesichert und der Aufrufer * bekommt die Angaben für die Eingabemaske. * * @return array|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' => StartcodeErkennung::hervorheben( $anfrage?->getChallenge() ?? '', (string)($this->zugang['startcode_muster'] ?? '') ), '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|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'] = $this->naechsterSchrittNachAnmeldung(); } elseif ($this->zustand['schritt'] === 'depot' && $aktion instanceof GetDepotAufstellung) { $depot = $this->zustand['depots'][(int)$this->zustand['depot_nummer']] ?? null; if ($depot !== null) { $this->schreibeDepot($depot, $aktion); } } 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($this->mitschnitt); // 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> */ 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 $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.'); // Depots haben keine IBAN und stehen nicht in dieser Liste, sondern in // den Kontostammdaten. Sie werden hier gleich mit vermerkt, damit sie // sich zuordnen lassen – ohne zusätzliche TAN. try { $this->merkeDepots(); } catch (\Throwable $e) { $this->notiere('Depots nicht auslesbar: ' . $e->getMessage()); } } private function merkeDepots(): void { foreach ($this->depotkonten() as $depot) { Bankzugang::findeOderLegeDepotAn( (int)$this->zugang['id'], $depot['kontonummer'], $depot['name'] !== '' ? $depot['name'] : 'Depot' ); } } /** @param array $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 $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|null $tan * @return array */ 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. } } $ergebnis = self::ergebnis( $status, $meldung, $this->protokoll, (int)$this->zustand['neu'], (int)$this->zustand['uebersprungen'], $tan ); // Wohin es danach geht, weiß nur der Abruf selbst: Nach einer TAN-Runde // kennt der Aufrufer weder den Zugang noch den Modus. $ergebnis['zugang_id'] = (int)$this->zugang['id']; $ergebnis['modus'] = (string)($this->zustand['modus'] ?? 'voll'); $ergebnis['einrichtung'] = ($this->zustand['einrichtung'] ?? false) === true; return $ergebnis; } /** * @param list $protokoll * @param array|null $tan * @return array */ 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|null */ private static function zustandLesen(): ?array { Auth::startSession(); $zustand = $_SESSION[self::SITZUNG] ?? null; return is_array($zustand) ? $zustand : null; } /** @param array $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]); } }