Finanzmanager/src/Services/FinTsAbruf.php
fds2610-bot 4bd7ffed1b Online-Banking: TAN-Verfahren bei der Bank abfragen und wählen
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>
2026-08-12 08:07:02 +02:00

687 lines
24 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\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]);
}
}