Finanzmanager/src/Services/FinTsAbruf.php
fds2610-bot 1cdb59b68b Fix #115: TAN-Anfrage – Startcode hervorheben
Nennt die Bank bei der TAN-Abfrage einen Startcode für den TAN-Generator,
geht er im Fließtext leicht unter. StartcodeErkennung erkennt ihn über
einen pro Bankverbindung konfigurierbaren Beispielsatz (Ziffernfolge =
Platzhalter für den Code) und zeigt den Fund fett, zentriert und in
eigener Zeile. BankFreitext löst dafür die bisherige Klartext-Aufbereitung
aus FinTsUeberweisung::vopHinweis() heraus, damit TAN-Anfrage und
VoP-Rückmeldung dieselbe HTML-zu-Klartext-Umwandlung nutzen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-04 20:21:56 +02:00

978 lines
36 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Fhp\Action\GetBalance;
use Fhp\Action\GetDepotAufstellung;
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\Bankinstitut;
use Finanzmanager\Models\Bankprotokoll;
use Finanzmanager\Models\Bankumsatz;
use Finanzmanager\Models\Bankzugang;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Konto;
/**
* 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.
*
* Im Modus `konten` endet der Lauf nach der Kontenabfrage. Das ist der dritte
* Einrichtungsschritt: erst zuordnen, dann Umsätze holen – dieselbe
* Zustandsmaschine, dieselbe TAN-Behandlung, nur kürzer.
*
* Im Modus `depot` folgt auf die Anmeldung die Depotaufstellung (HKWPD) je
* Depot, das die Bank in den Kontostammdaten (UPD) führt. Ein Depot hat keine
* IBAN und fehlt deshalb in der Kontenabfrage (HKSPA). Der Modus läuft bewusst
* getrennt vom Umsatzabruf: andere Daten, anderer Rhythmus, eigene TAN.
*
* Ist `banking_automatisch_buchen` eingeschaltet, bucht `Bankabgleich` am Ende
* eines vollen Laufs, was ein Konto hat (siehe dort).
*/
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;
/** Schneidet den Dialog dieses Durchlaufs für das Übermittlungsprotokoll mit. */
private BankProtokollLogger $mitschnitt;
/** @param array<string,mixed> $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<string>,
* neu: int, uebersprungen: int, tan?: array<string,mixed>}
*/
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<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
{
$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<string,mixed> */
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<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'] = $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<string,mixed>
*/
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<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());
// 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<string,mixed>
*/
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<array{kontonummer: string, unterkonto: string, name: string}>
*/
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<string,string> $depot
* @return array<string,mixed>|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<string,string> $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<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' => 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<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'] = $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<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.');
// 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<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.
}
}
$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<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]);
}
}