*/ private array $protokoll = []; private FinTs $fints; /** @param array $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, * neu: int, uebersprungen: int, tan?: array} */ 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, * 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 { $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'] = '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 */ 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 $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()); $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|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|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> */ 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.'); } /** @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. } } return self::ergebnis( $status, $meldung, $this->protokoll, (int)$this->zustand['neu'], (int)$this->zustand['uebersprungen'], $tan ); } /** * @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]); } }