From 4bd7ffed1b498fcf5bd1fda4ff1a4f421db8d7d1 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Wed, 12 Aug 2026 08:07:02 +0200 Subject: [PATCH] =?UTF-8?q?Online-Banking:=20TAN-Verfahren=20bei=20der=20B?= =?UTF-8?q?ank=20abfragen=20und=20w=C3=A4hlen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Changes.md | 16 ++ README.md | 5 +- docs/banking.md | 37 +++- migrations/009_tan_verfahren.sql | 9 + public/index.php | 4 + src/Controllers/BankingController.php | 96 +++++++++ src/Models/Bankzugang.php | 43 +++- src/Services/FinTsAbruf.php | 58 +----- src/Services/FinTsUeberweisung.php | 42 +--- src/Services/FinTsVerfahren.php | 289 ++++++++++++++++++++++++++ src/Views/banking/formular.php | 23 ++ src/Views/banking/verfahren.php | 115 ++++++++++ tests/16_BankingTest.php | 97 +++++++++ tests/e2e/specs/module.spec.js | 10 + 14 files changed, 740 insertions(+), 104 deletions(-) create mode 100644 migrations/009_tan_verfahren.sql create mode 100644 src/Services/FinTsVerfahren.php create mode 100644 src/Views/banking/verfahren.php diff --git a/Changes.md b/Changes.md index c4634fe..1823aef 100644 --- a/Changes.md +++ b/Changes.md @@ -59,6 +59,22 @@ Damit ist der geplante Funktionsumfang beisammen: Von der Kontenübersicht bis z Knopfdruck nach, statt zu warten. - Umsätze, die vor einer TAN-Unterbrechung geschrieben wurden, bleiben erhalten. +**Wahl des TAN-Verfahrens** (`Services\FinTsVerfahren`, Migration `009_tan_verfahren.sql`) +- Eigene Seite am Zugang (`/banking/{id}/verfahren`): Der Knopf *Verfahren bei der Bank + abfragen* holt die Liste über `getTanModes()` – das kommt ohne Anmeldung aus und verlangt + selbst keine TAN. TAN-Medien werden nur für die Verfahren geholt, die eines brauchen. +- Abgefragt statt eingetippt: Die Kennungen sind bankspezifisch, 913 heißt nicht überall + chipTAN QR. Der Name wird mitgespeichert (`tan_verfahren_name`), damit das Formular ihn + ohne Bankverbindung zeigen kann. +- Beim Übernehmen wird gegen die abgefragte Liste geprüft; ein Verfahren ohne Medienzwang + bekommt kein Medium angehängt, weil manche Banken den Dialog sonst ablehnen. +- Ohne Wahl entscheidet weiterhin das erste gemeldete Verfahren – diese Notwahl wird + festgehalten, eine ausdrückliche Wahl dagegen **nie überschrieben**. +- Behoben: Das Speichern des Zugangsformulars setzte Verfahren und TAN-Medium zurück, weil + beide Felder darin nicht vorkommen. Sie werden jetzt wie die PIN unter Vorbehalt gestellt. +- Abruf und Überweisung benutzen dieselbe Wahl; die doppelte Auswahllogik in + `FinTsUeberweisung` ist entfallen. + **Abgleich statt Direktbuchung** - Abgerufene Umsätze landen in `bank_umsaetze` und werden nicht sofort gebucht. Die Vorschlagsliste kennt drei Wege: mit einer erfassten Buchung **verbinden**, als neue diff --git a/README.md b/README.md index a187bbe..a8054af 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,9 @@ Haushalt wird beides parallel genutzt. Gruppen. * **Cockpit** – konfigurierbare Kacheln: Vermögensentwicklung, Einnahmen/Ausgaben je Monat, Ausgaben nach Kategorie, Kontostände, offene Vorgänge. -* **Online-Banking** – FinTS-Abruf mit TAN-Dialog über mehrere Requests, Abgleich der - Umsätze gegen vorhandene Buchungen, Regeln für die automatische Kategorisierung. +* **Online-Banking** – FinTS-Abruf mit TAN-Dialog über mehrere Requests, TAN-Verfahren bei + der Bank abfragen und wählen, Abgleich der Umsätze gegen vorhandene Buchungen, Regeln für + die automatische Kategorisierung. * **Wertpapiere** – Depotbestände mit Einstand und Bewertung, Depotbuchungen, Kursabruf, Veräußerungsgeschäfte und Erträge. * **Nebenmodule** – Buchungsvorlagen, regelmäßige Buchungen mit Fälligkeitslauf und diff --git a/docs/banking.md b/docs/banking.md index e349ac4..6857ca9 100644 --- a/docs/banking.md +++ b/docs/banking.md @@ -58,8 +58,35 @@ ausdrücklich aus. Sie wird bei jedem Request neu aus der Datenbank geholt und e fragt die Anwendung auf Knopfdruck nach (`checkDecoupledSubmission`) – steht die Freigabe noch aus, bleibt die Maske stehen. -Verfahren und TAN-Medium werden beim ersten Abruf gewählt und am Zugang gespeichert, damit -die Bank nicht jedes Mal danach fragt. +### Das Verfahren wählen + +Fast jede Bank bietet mehrere Verfahren an. Welches gilt, steht am Zugang unter +*TAN-Verfahren einstellen* (`/banking/{id}/verfahren`). + +**Abgefragt, nicht eingetippt.** Die Kennungen sind bankspezifisch – 913 heißt nicht +überall chipTAN QR –, und niemand soll sie in einer PDF der Bank suchen müssen. Der Knopf +*Verfahren bei der Bank abfragen* holt die Liste über `getTanModes()`. Das kommt ohne +Anmeldung aus (die Bibliothek braucht dafür nur die Bankparameterdaten) und verlangt +deshalb selbst keine TAN. + +`getTanMedia()` wird nur für die Verfahren aufgerufen, die ein Medium brauchen: Jeder +Aufruf ist ein eigener Dialog mit der Bank. + +Die abgefragte Liste liegt in der Sitzung, nicht in der Datenbank – sie ist eine +Momentaufnahme. Beim Übernehmen wird gegen sie geprüft: Eine Kennung, die die Bank nicht +angeboten hat, wäre beim nächsten Abruf wirkungslos. + +**Ohne Wahl** nimmt der erste Abruf das erste Verfahren, das die Bank meldet, und hält es +fest; sonst stünde im Formular nichts. Eine **ausdrückliche** Wahl wird dagegen nie +überschrieben: Bietet die Bank sie einmal nicht an, sagt das Protokoll das und die +Einstellung bleibt stehen. + +Das Verfahren steht bewusst nicht im Zugangsformular – dort ließe es sich nur als Zahl +eintragen. Das Formular fasst es deshalb auch nicht an: Ohne diesen Vorbehalt setzte jedes +Speichern die Wahl zurück. + +Abruf und Überweisung benutzen dieselbe Wahl (`Services\FinTsVerfahren::anwenden()`) – eine +Überweisung soll nicht plötzlich ein anderes Verfahren verlangen als der Abruf. ### Konten @@ -140,6 +167,12 @@ Abrufzeiträume, Dublettenerkennung, Regeln nach Priorität und Feld, Splitschem Restausgleich, Abweisung ungültiger Ausdrücke, Abgleich gegen vorhandene Buchungen samt Toleranz sowie Übernehmen, Verwerfen und Zurücksetzen. +Zur Verfahrenswahl geprüft: Kennung, Name und verschlüsseltes Medium werden gespeichert, +unbekannte Kennungen und Medien abgewiesen, ein Verfahren ohne Medienzwang bekommt keines +angehängt, und das Speichern des Zugangsformulars lässt die Wahl stehen. Die Abfrage bei der +Bank steckt hinter derselben Grenze wie der Dialog – geprüft wird, was mit ihrem Ergebnis +geschieht. + Der FinTS-Dialog selbst braucht eine echte Bankverbindung und lässt sich nicht nachstellen. Geprüft wurde er gegen den Fehlerpfad: Eine unerreichbare Adresse bricht sauber ab, protokolliert und meldet verständlich. diff --git a/migrations/009_tan_verfahren.sql b/migrations/009_tan_verfahren.sql new file mode 100644 index 0000000..c225e7d --- /dev/null +++ b/migrations/009_tan_verfahren.sql @@ -0,0 +1,9 @@ +-- Das gewählte TAN-Verfahren mit Namen führen. +-- +-- Die ID allein (etwa 913) sagt niemandem etwas, und ohne Verbindung zur Bank +-- lässt sie sich nicht auflösen. Der Name wird deshalb beim Abfragen mit +-- gespeichert, damit das Formular „chipTAN QR (913)“ zeigen kann, statt einer +-- nackten Zahl. + +ALTER TABLE bank_zugaenge + ADD COLUMN IF NOT EXISTS tan_verfahren_name VARCHAR(128) NOT NULL DEFAULT '' AFTER tan_verfahren_id; diff --git a/public/index.php b/public/index.php index a296280..1cad387 100644 --- a/public/index.php +++ b/public/index.php @@ -279,6 +279,10 @@ $router->get('/banking/{id}', fn(array $p) => (new BankingController() $router->post('/banking/{id}', fn(array $p) => (new BankingController())->speichern($p)); $router->post('/banking/{id}/abrufen', fn(array $p) => (new BankingController())->abrufen($p)); $router->post('/banking/{id}/loeschen', fn(array $p) => (new BankingController())->loeschen($p)); +$router->get('/banking/{id}/verfahren', fn(array $p) => (new BankingController())->verfahren($p)); +$router->post('/banking/{id}/verfahren', fn(array $p) => (new BankingController())->verfahrenWaehlen($p)); +$router->post('/banking/{id}/verfahren/abfragen', + fn(array $p) => (new BankingController())->verfahrenAbfragen($p)); $router->get('/verwaltung/import', fn() => (new ImportController())->index()); $router->post('/verwaltung/import/hochladen', fn() => (new ImportController())->hochladen()); diff --git a/src/Controllers/BankingController.php b/src/Controllers/BankingController.php index 594ee40..00a6695 100644 --- a/src/Controllers/BankingController.php +++ b/src/Controllers/BankingController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Finanzmanager\Controllers; +use Finanzmanager\Auth; use Finanzmanager\Models\AbgleichRegel; use Finanzmanager\Models\Bankabruf; use Finanzmanager\Models\Bankumsatz; @@ -13,6 +14,7 @@ use Finanzmanager\Models\Klasse; use Finanzmanager\Models\Konto; use Finanzmanager\Services\Crypto; use Finanzmanager\Services\FinTsAbruf; +use Finanzmanager\Services\FinTsVerfahren; use Finanzmanager\View; /** @@ -25,6 +27,9 @@ use Finanzmanager\View; */ class BankingController extends BaseController { + /** Schlüssel der abgefragten TAN-Verfahren in der Sitzung. */ + private const SITZUNG_VERFAHREN = 'banking_verfahren'; + // ── Bankverbindungen ───────────────────────────────────────────────────── public function index(): void @@ -119,6 +124,97 @@ class BankingController extends BaseController View::redirect('/banking', 'Bankverbindung gelöscht.'); } + // ── TAN-Verfahren ──────────────────────────────────────────────────────── + + /** + * Die abgefragte Verfahrensliste. Sie liegt in der Sitzung, damit ein + * erneutes Anzeigen der Seite – etwa nach einer Fehlermeldung – nicht jedes + * Mal wieder die Bank behelligt. + * + * @param array $params + */ + public function verfahren(array $params): void + { + $this->verlangeAnmeldung(); + + $id = (int)$params['id']; + $zugang = Bankzugang::find($id); + if ($zugang === null) { + View::redirect('/banking', 'Die Bankverbindung gibt es nicht.', 'fehler'); + return; + } + + $this->render('banking/verfahren.php', [ + 'titel' => 'TAN-Verfahren – ' . $zugang['name'], + 'zugang' => $zugang, + 'medium' => Crypto::entschluessle((string)($zugang['tan_medium'] ?? '')), + 'verfahren' => $this->gemerkteVerfahren($id), + ]); + } + + /** @param array $params */ + public function verfahrenAbfragen(array $params): void + { + $this->verlangeAnmeldung(); + + $id = (int)$params['id']; + $ergebnis = FinTsVerfahren::abfragen($id); + + Auth::startSession(); + $_SESSION[self::SITZUNG_VERFAHREN] = [ + 'zugang_id' => $id, + 'liste' => $ergebnis['verfahren'], + ]; + + View::redirect( + '/banking/' . $id . '/verfahren', + $ergebnis['meldung'], + $ergebnis['status'] === 'fehler' ? 'fehler' : 'erfolg' + ); + } + + /** @param array $params */ + public function verfahrenWaehlen(array $params): void + { + $this->verlangeAnmeldung(); + + $id = (int)$params['id']; + $verfahren = (int)$this->post('verfahren_id'); + + try { + FinTsVerfahren::uebernehmen( + $id, + $verfahren, + // Das Medium des angekreuzten Verfahrens, nicht das irgendeiner + // anderen Zeile. + $this->post('tan_medium_' . $verfahren), + $this->gemerkteVerfahren($id) + ); + } catch (\Throwable $e) { + View::redirect('/banking/' . $id . '/verfahren', $e->getMessage(), 'fehler'); + return; + } + + View::redirect('/banking/' . $id, 'TAN-Verfahren übernommen.'); + } + + /** + * Die zuletzt abgefragte Liste – aber nur die zu diesem Zugang. + * + * @return list> + */ + private function gemerkteVerfahren(int $zugangId): array + { + Auth::startSession(); + $gemerkt = $_SESSION[self::SITZUNG_VERFAHREN] ?? null; + + if (!is_array($gemerkt) || (int)($gemerkt['zugang_id'] ?? 0) !== $zugangId) { + return []; + } + + return is_array($gemerkt['liste'] ?? null) ? $gemerkt['liste'] : []; + } + // ── Abruf ──────────────────────────────────────────────────────────────── /** @param array $params */ diff --git a/src/Models/Bankzugang.php b/src/Models/Bankzugang.php index 6c7a9d1..806c2c0 100644 --- a/src/Models/Bankzugang.php +++ b/src/Models/Bankzugang.php @@ -74,12 +74,12 @@ class Bankzugang Database::execute( 'INSERT INTO bank_zugaenge (benutzer_id, name, bankname, blz, fints_url, benutzerkennung, pin, - tan_verfahren_id, tan_medium, abrufzeitraum, aktiv) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + tan_verfahren_id, tan_verfahren_name, tan_medium, abrufzeitraum, aktiv) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ Mandant::id(), $felder['name'], $felder['bankname'], $felder['blz'], $felder['fints_url'], $felder['benutzerkennung'], $felder['pin'], - $felder['tan_verfahren_id'], $felder['tan_medium'], + $felder['tan_verfahren_id'], $felder['tan_verfahren_name'], $felder['tan_medium'], $felder['abrufzeitraum'], $felder['aktiv'], ] ); @@ -105,15 +105,25 @@ class Bankzugang $felder['pin'] = $bisher['pin']; } + // Dasselbe für das TAN-Verfahren: Es steht nicht im Zugangsformular, + // sondern wird eigens abgefragt. Ohne diesen Vorbehalt setzte jedes + // Speichern die Wahl zurück. + foreach (['tan_verfahren_id', 'tan_verfahren_name', 'tan_medium'] as $feld) { + if (!array_key_exists($feld, $daten)) { + $felder[$feld] = $bisher[$feld]; + } + } + Database::execute( 'UPDATE bank_zugaenge SET name = ?, bankname = ?, blz = ?, fints_url = ?, - benutzerkennung = ?, pin = ?, tan_verfahren_id = ?, tan_medium = ?, - abrufzeitraum = ?, aktiv = ? + benutzerkennung = ?, pin = ?, tan_verfahren_id = ?, tan_verfahren_name = ?, + tan_medium = ?, abrufzeitraum = ?, aktiv = ? WHERE id = ? AND benutzer_id = ?', [ $felder['name'], $felder['bankname'], $felder['blz'], $felder['fints_url'], $felder['benutzerkennung'], $felder['pin'], $felder['tan_verfahren_id'], - $felder['tan_medium'], $felder['abrufzeitraum'], $felder['aktiv'], + $felder['tan_verfahren_name'], $felder['tan_medium'], + $felder['abrufzeitraum'], $felder['aktiv'], $id, Mandant::id(), ] ); @@ -132,11 +142,23 @@ class Bankzugang ); } - public static function merkeTanVerfahren(int $id, ?int $verfahren, string $medium = ''): void - { + public static function merkeTanVerfahren( + int $id, + ?int $verfahren, + string $medium = '', + string $bezeichnung = '' + ): void { Database::execute( - 'UPDATE bank_zugaenge SET tan_verfahren_id = ?, tan_medium = ? WHERE id = ? AND benutzer_id = ?', - [$verfahren, Crypto::verschluessle($medium), $id, Mandant::id()] + 'UPDATE bank_zugaenge + SET tan_verfahren_id = ?, tan_verfahren_name = ?, tan_medium = ? + WHERE id = ? AND benutzer_id = ?', + [ + $verfahren, + mb_substr($bezeichnung, 0, 128), + Crypto::verschluessle($medium), + $id, + Mandant::id(), + ] ); } @@ -210,6 +232,7 @@ class Bankzugang 'benutzerkennung' => mb_substr(trim((string)($daten['benutzerkennung'] ?? '')), 0, 128), 'pin' => $pin, 'tan_verfahren_id' => $verfahren, + 'tan_verfahren_name' => mb_substr(trim((string)($daten['tan_verfahren_name'] ?? '')), 0, 128), 'tan_medium' => Crypto::verschluessle(trim((string)($daten['tan_medium'] ?? ''))), 'abrufzeitraum' => array_key_exists($zeitraum, self::ZEITRAEUME) ? $zeitraum : 'letzter_abruf', 'aktiv' => !empty($daten['aktiv']) ? 1 : 0, diff --git a/src/Services/FinTsAbruf.php b/src/Services/FinTsAbruf.php index 37476cb..03f60a8 100644 --- a/src/Services/FinTsAbruf.php +++ b/src/Services/FinTsAbruf.php @@ -520,67 +520,15 @@ class FinTsAbruf $fints = FinTs::new($optionen, $zugangsdaten, $this->zustand['dialog'] ?? null); $fints->setLogger(new DebugLogAdapter()); - // Beim ersten Lauf steht noch kein Verfahren fest. Gespeichert wird die - // Wahl am Zugang, damit die Bank nicht bei jedem Abruf danach fragt. + // 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'])) { - $this->waehleTanVerfahren($fints); + FinTsVerfahren::anwenden($fints, $this->zugang, fn(string $zeile) => $this->notiere($zeile)); } return $fints; } - private function waehleTanVerfahren(FinTs $fints): void - { - $verfahren = $fints->getTanModes(); - if ($verfahren === []) { - throw new \RuntimeException('Die Bank meldet keine TAN-Verfahren.'); - } - - $gewuenscht = $this->zugang['tan_verfahren_id'] === null - ? null - : (int)$this->zugang['tan_verfahren_id']; - - $gewaehlt = null; - foreach ($verfahren as $modus) { - if ($gewuenscht !== null && $modus->getId() === $gewuenscht) { - $gewaehlt = $modus; - break; - } - } - $gewaehlt ??= reset($verfahren); - - $medium = null; - $mediumName = ''; - if ($gewaehlt->needsTanMedium()) { - $medien = $fints->getTanMedia($gewaehlt); - if ($medien === []) { - throw new \RuntimeException( - 'Das Verfahren „' . $gewaehlt->getName() . '“ braucht ein TAN-Medium, ' - . 'die Bank nennt aber keines.' - ); - } - - $gespeichert = (string)$this->zugang['tan_medium']; - foreach ($medien as $kandidat) { - if ($gespeichert !== '' && $kandidat->getName() === $gespeichert) { - $medium = $kandidat; - break; - } - } - $medium ??= reset($medien); - $mediumName = (string)$medium->getName(); - } - - $fints->selectTanMode($gewaehlt, $medium); - - $this->notiere('TAN-Verfahren: ' . $gewaehlt->getName() - . ($mediumName !== '' ? ' (' . $mediumName . ')' : '')); - - if ($gewuenscht !== $gewaehlt->getId() || $mediumName !== (string)$this->zugang['tan_medium']) { - Bankzugang::merkeTanVerfahren((int)$this->zugang['id'], $gewaehlt->getId(), $mediumName); - } - } - /** * Die Konten dieses Zugangs, die abgerufen werden sollen. * diff --git a/src/Services/FinTsUeberweisung.php b/src/Services/FinTsUeberweisung.php index 7d05939..27091b0 100644 --- a/src/Services/FinTsUeberweisung.php +++ b/src/Services/FinTsUeberweisung.php @@ -335,42 +335,14 @@ class FinTsUeberweisung ); $fints->setLogger(new DebugLogAdapter()); + // Dieselbe Wahl wie beim Umsatzabruf – eine Überweisung soll nicht + // plötzlich ein anderes Verfahren verlangen als der Abruf. if (!isset($this->zustand['dialog'])) { - $verfahren = $fints->getTanModes(); - if ($verfahren === []) { - throw new \RuntimeException('Die Bank meldet keine TAN-Verfahren.'); - } - - $gewuenscht = $this->zugang['tan_verfahren_id'] === null - ? null : (int)$this->zugang['tan_verfahren_id']; - - $gewaehlt = null; - foreach ($verfahren as $modus) { - if ($gewuenscht !== null && $modus->getId() === $gewuenscht) { - $gewaehlt = $modus; - break; - } - } - $gewaehlt ??= reset($verfahren); - - $medium = null; - if ($gewaehlt->needsTanMedium()) { - $medien = $fints->getTanMedia($gewaehlt); - if ($medien === []) { - throw new \RuntimeException('Das TAN-Verfahren braucht ein Medium, die Bank nennt keines.'); - } - - $gespeichert = (string)$this->zugang['tan_medium']; - foreach ($medien as $kandidat) { - if ($gespeichert !== '' && $kandidat->getName() === $gespeichert) { - $medium = $kandidat; - break; - } - } - $medium ??= reset($medien); - } - - $fints->selectTanMode($gewaehlt, $medium); + FinTsVerfahren::anwenden( + $fints, + $this->zugang, + static fn(string $zeile) => DebugLog::schreibe('fints', $zeile) + ); } return $fints; diff --git a/src/Services/FinTsVerfahren.php b/src/Services/FinTsVerfahren.php new file mode 100644 index 0000000..5e6c9c2 --- /dev/null +++ b/src/Services/FinTsVerfahren.php @@ -0,0 +1,289 @@ +, + * hinweis: string}>} + */ + public static function abfragen(int $zugangId): array + { + $zugang = Bankzugang::zugangsdaten($zugangId); + if ($zugang === null) { + return ['status' => 'fehler', 'meldung' => 'Den Bankzugang gibt es nicht.', 'verfahren' => []]; + } + + foreach (['blz' => 'Bankleitzahl', 'fints_url' => 'FinTS-Adresse', + 'benutzerkennung' => 'Benutzerkennung', 'pin' => 'PIN'] as $feld => $name) { + if (trim((string)$zugang[$feld]) === '') { + return [ + 'status' => 'fehler', + 'meldung' => 'Es fehlt: ' . $name . '. Ohne vollständige Zugangsdaten ' + . 'lässt sich die Bank nicht fragen.', + 'verfahren' => [], + ]; + } + } + + try { + $fints = self::verbindung($zugang); + $modi = $fints->getTanModes(); + } catch (\Throwable $e) { + DebugLog::fehler($e); + + return [ + 'status' => 'fehler', + 'meldung' => 'Die Bank antwortet nicht wie erwartet: ' . $e->getMessage(), + 'verfahren' => [], + ]; + } + + if ($modi === []) { + return [ + 'status' => 'fehler', + 'meldung' => 'Die Bank meldet keine TAN-Verfahren für diese Benutzerkennung.', + 'verfahren' => [], + ]; + } + + $verfahren = []; + foreach ($modi as $modus) { + $verfahren[] = self::beschreibe($fints, $modus); + } + + try { + $fints->close(); + } catch (\Throwable) { + // Der Dialog ist ohnehin am Ende; ein Fehler beim Abmelden ändert + // an der Liste nichts. + } + + return [ + 'status' => 'erfolg', + 'meldung' => count($verfahren) === 1 + ? 'Die Bank bietet ein Verfahren an.' + : sprintf('Die Bank bietet %d Verfahren an.', count($verfahren)), + 'verfahren' => $verfahren, + ]; + } + + /** + * Übernimmt die Wahl des Nutzers. + * + * Geprüft wird gegen die Liste, die gerade abgefragt wurde: Eine ID, die die + * Bank nicht angeboten hat, würde beim nächsten Abruf stillschweigend + * verworfen – dann lieber jetzt eine Meldung. + * + * @param list> $bekannt Ergebnis von abfragen()['verfahren'] + */ + public static function uebernehmen(int $zugangId, int $verfahrenId, string $medium, array $bekannt): void + { + $treffer = null; + foreach ($bekannt as $eintrag) { + if ((int)$eintrag['id'] === $verfahrenId) { + $treffer = $eintrag; + break; + } + } + + if ($treffer === null) { + throw new \InvalidArgumentException( + 'Dieses Verfahren steht nicht in der abgefragten Liste. Bitte neu abfragen.' + ); + } + + $medien = array_map('strval', $treffer['medien'] ?? []); + + if ($treffer['medium_noetig'] === true) { + if ($medium === '') { + throw new \InvalidArgumentException( + 'Das Verfahren „' . $treffer['name'] . '“ braucht ein TAN-Medium.' + ); + } + if ($medien !== [] && !in_array($medium, $medien, true)) { + throw new \InvalidArgumentException('Dieses TAN-Medium hat die Bank nicht genannt.'); + } + } else { + // Ein Medium ohne Not gespeichert würde die Bibliothek beim nächsten + // Dialog mitschicken – manche Banken lehnen das ab. + $medium = ''; + } + + Bankzugang::merkeTanVerfahren($zugangId, $verfahrenId, $medium, (string)$treffer['name']); + } + + /** + * Stellt das Verfahren für einen laufenden Dialog ein. + * + * Wird von `FinTsAbruf` und `FinTsUeberweisung` benutzt, damit beide dieselbe + * Wahl treffen. Ist am Zugang nichts hinterlegt, entscheidet das erste + * gemeldete Verfahren – und diese Notwahl wird gespeichert, damit sie im + * Formular sichtbar ist. Eine **ausdrückliche** Wahl wird dagegen nie + * überschrieben: Bietet die Bank sie gerade nicht an, sagt das Protokoll das, + * und die Einstellung bleibt stehen. + * + * @param array $zugang Zugang mit entschlüsseltem TAN-Medium + * @param callable(string):void $notiere Protokollzeile schreiben + */ + public static function anwenden(FinTs $fints, array $zugang, callable $notiere): void + { + $modi = $fints->getTanModes(); + if ($modi === []) { + throw new \RuntimeException('Die Bank meldet keine TAN-Verfahren.'); + } + + $gewuenscht = $zugang['tan_verfahren_id'] === null ? null : (int)$zugang['tan_verfahren_id']; + + $gewaehlt = null; + foreach ($modi as $modus) { + if ($gewuenscht !== null && $modus->getId() === $gewuenscht) { + $gewaehlt = $modus; + break; + } + } + + $abweichung = $gewuenscht !== null && $gewaehlt === null; + $gewaehlt ??= reset($modi); + + if ($abweichung) { + $notiere(sprintf( + 'Das eingestellte TAN-Verfahren (%s) bietet die Bank gerade nicht an – es wird %s benutzt.', + (string)$zugang['tan_verfahren_name'] !== '' + ? $zugang['tan_verfahren_name'] . ', ID ' . $gewuenscht + : 'ID ' . $gewuenscht, + $gewaehlt->getName() + )); + } + + $medium = null; + $mediumName = ''; + if ($gewaehlt->needsTanMedium()) { + $medien = $fints->getTanMedia($gewaehlt); + if ($medien === []) { + throw new \RuntimeException( + 'Das Verfahren „' . $gewaehlt->getName() . '“ braucht ein TAN-Medium, ' + . 'die Bank nennt aber keines.' + ); + } + + $gespeichert = (string)($zugang['tan_medium'] ?? ''); + foreach ($medien as $kandidat) { + if ($gespeichert !== '' && $kandidat->getName() === $gespeichert) { + $medium = $kandidat; + break; + } + } + $medium ??= reset($medien); + $mediumName = (string)$medium->getName(); + } + + $fints->selectTanMode($gewaehlt, $medium); + + $notiere('TAN-Verfahren: ' . $gewaehlt->getName() + . ($mediumName !== '' ? ' (' . $mediumName . ')' : '')); + + // Nur die Notwahl wird festgehalten – eine ausdrückliche bleibt, wie sie ist. + if ($gewuenscht === null) { + Bankzugang::merkeTanVerfahren( + (int)$zugang['id'], + $gewaehlt->getId(), + $mediumName, + $gewaehlt->getName() + ); + } + } + + // ── Innereien ──────────────────────────────────────────────────────────── + + /** + * @return array{id: int, name: string, entkoppelt: bool, medium_noetig: bool, + * medien: list, hinweis: string} + */ + private static function beschreibe(FinTs $fints, TanMode $modus): array + { + $medien = []; + $hinweis = ''; + + if ($modus->needsTanMedium()) { + try { + foreach ($fints->getTanMedia($modus) as $medium) { + $medien[] = (string)$medium->getName(); + } + } catch (\Throwable $e) { + // Ein Verfahren, dessen Medien die Bank nicht herausgibt, bleibt + // in der Liste – der Nutzer soll sehen, dass es existiert. + $hinweis = 'Medien nicht abrufbar: ' . $e->getMessage(); + } + } + + if ($modus->isDecoupled()) { + $hinweis = $hinweis === '' + ? 'Freigabe in der Banking-App, keine TAN einzugeben.' + : $hinweis; + } + + return [ + 'id' => $modus->getId(), + 'name' => $modus->getName(), + 'entkoppelt' => $modus->isDecoupled(), + 'medium_noetig' => $modus->needsTanMedium(), + 'medien' => $medien, + 'hinweis' => $hinweis, + ]; + } + + /** @param array $zugang */ + private static function verbindung(array $zugang): FinTs + { + $optionen = new FinTsOptions(); + $optionen->url = (string)$zugang['fints_url']; + $optionen->bankCode = (string)$zugang['blz']; + $optionen->productName = Einstellung::get('banking_produkt_id') ?: self::PRODUKT; + $optionen->productVersion = self::PRODUKT_VERSION; + + $fints = FinTs::new( + $optionen, + Credentials::create((string)$zugang['benutzerkennung'], (string)$zugang['pin']) + ); + $fints->setLogger(new DebugLogAdapter()); + + return $fints; + } +} diff --git a/src/Views/banking/formular.php b/src/Views/banking/formular.php index 05b04a4..4afb985 100644 --- a/src/Views/banking/formular.php +++ b/src/Views/banking/formular.php @@ -74,6 +74,29 @@ include __DIR__ . '/../layout/kopf.php'; aktiv + +

TAN-Verfahren

+

+ + + Noch nicht gewählt – beim nächsten Abruf entscheidet das erste Verfahren, + das die Bank meldet. + + + + + + Kennung + +

+

+ Die Wahl steht auf einer eigenen Seite, weil dafür die Bank gefragt werden muss: + TAN-Verfahren einstellen. +

+ +

Konten bei dieser Bank

diff --git a/src/Views/banking/verfahren.php b/src/Views/banking/verfahren.php new file mode 100644 index 0000000..b30ce04 --- /dev/null +++ b/src/Views/banking/verfahren.php @@ -0,0 +1,115 @@ + $zugang + * @var string $medium Das gespeicherte TAN-Medium im Klartext + * @var list, hinweis:string}> $verfahren + * @var string $csrfToken + */ + +$gewaehlt = $zugang['tan_verfahren_id'] === null ? null : (int)$zugang['tan_verfahren_id']; + +include __DIR__ . '/../layout/kopf.php'; +?> + + + +

+ Welche Verfahren zur Verfügung stehen, weiß nur die Bank – die Kennungen sind + von Haus zu Haus verschieden. Die Abfrage kommt ohne Anmeldung aus und verlangt + deshalb selbst keine TAN. +

+ +

Zurzeit eingestellt

+ +

+ Noch nichts gewählt. Beim nächsten Abruf nimmt der Finanzmanager das erste + Verfahren, das die Bank meldet, und merkt es sich – das muss nicht das sein, + mit dem du sonst arbeitest. +

+ +

+ + + + Kennung +

+ + + +

+ Noch keine Liste abgefragt. Der Knopf oben holt sie – dafür müssen + Bankleitzahl, FinTS-Adresse, Benutzerkennung und PIN gespeichert sein. +

+ +

Von der Bank angeboten

+
+ + + + + + + + + + + + + + + + +
WahlVerfahrenKennungTAN-Medium
+ > + + + + + + + + keines nötig + + von der Bank nicht genannt + + + + +
+ +
+ +
+
+ + + diff --git a/tests/16_BankingTest.php b/tests/16_BankingTest.php index 77b748e..523641a 100644 --- a/tests/16_BankingTest.php +++ b/tests/16_BankingTest.php @@ -11,6 +11,7 @@ use Finanzmanager\Models\Einstellung; use Finanzmanager\Models\Kategorie; use Finanzmanager\Models\Konto; use Finanzmanager\Services\Crypto; +use Finanzmanager\Services\FinTsVerfahren; /** * Online-Banking ohne Bank: Geprüft wird alles, was um den FinTS-Dialog herum @@ -35,6 +36,8 @@ class BankingTest extends TestCase $this->verschluesselungSchuetztDieZugangsdaten(); $this->zugangHaeltPinBeimSpeichern(); + $this->tanVerfahrenLaesstSichWaehlen(); + $this->tanVerfahrenUeberstehtDasSpeichern(); $this->zeitraumRichtetSichNachDemLetztenAbruf(); $this->umsaetzeKommenNurEinmalHerein(); $this->regelnGreifenNachPrioritaet(); @@ -174,6 +177,100 @@ class BankingTest extends TestCase ]); } + /** + * Die Wahl des TAN-Verfahrens. Die Abfrage bei der Bank lässt sich hier + * nicht nachstellen – geprüft wird, was mit ihrem Ergebnis geschieht. + */ + private function tanVerfahrenLaesstSichWaehlen(): void + { + // So sieht eine abgefragte Liste aus. + $angebot = [ + ['id' => 913, 'name' => 'chipTAN QR', 'entkoppelt' => false, + 'medium_noetig' => true, 'medien' => ['Karte 1', 'Karte 2'], 'hinweis' => ''], + ['id' => 921, 'name' => 'pushTAN 2.0', 'entkoppelt' => true, + 'medium_noetig' => false, 'medien' => [], 'hinweis' => ''], + ]; + + FinTsVerfahren::uebernehmen($this->zugang, 913, 'Karte 2', $angebot); + + $zugang = Bankzugang::zugangsdaten($this->zugang); + $this->assertEquals(913, (int)$zugang['tan_verfahren_id'], 'Die Kennung ist gespeichert'); + $this->assertEquals('chipTAN QR', $zugang['tan_verfahren_name'], 'Der Name steht daneben'); + $this->assertEquals('Karte 2', $zugang['tan_medium'], 'Das gewählte Medium ist gespeichert'); + + $roh = Bankzugang::find($this->zugang); + $this->assert( + !str_contains((string)$roh['tan_medium'], 'Karte 2'), + 'Das TAN-Medium steht verschlüsselt in der Datenbank' + ); + + // Ein Verfahren, das die Bank nicht angeboten hat, wäre beim nächsten + // Abruf wirkungslos – also gleich abweisen. + $this->assert( + $this->wirftFehler(fn() => FinTsVerfahren::uebernehmen($this->zugang, 942, '', $angebot)), + 'Eine unbekannte Verfahrenskennung wird abgewiesen' + ); + + $this->assert( + $this->wirftFehler(fn() => FinTsVerfahren::uebernehmen($this->zugang, 913, '', $angebot)), + 'Ein Verfahren mit Medienzwang braucht ein Medium' + ); + + $this->assert( + $this->wirftFehler(fn() => FinTsVerfahren::uebernehmen($this->zugang, 913, 'Karte 9', $angebot)), + 'Ein Medium, das die Bank nicht genannt hat, wird abgewiesen' + ); + + // Braucht das Verfahren kein Medium, wird ein mitgeschicktes verworfen: + // Manche Banken lehnen den Dialog ab, wenn eins dabei ist. + FinTsVerfahren::uebernehmen($this->zugang, 921, 'Karte 2', $angebot); + $zugang = Bankzugang::zugangsdaten($this->zugang); + $this->assertEquals(921, (int)$zugang['tan_verfahren_id'], 'Das entkoppelte Verfahren ist gewählt'); + $this->assertEquals('', $zugang['tan_medium'], 'Ohne Medienzwang bleibt das Medium leer'); + + // Ohne vollständige Zugangsdaten wird die Bank gar nicht erst gefragt. + $ergebnis = FinTsVerfahren::abfragen(0); + $this->assertEquals('fehler', $ergebnis['status'], 'Ein unbekannter Zugang liefert einen Fehler'); + $this->assertEquals([], $ergebnis['verfahren'], 'Und keine Liste'); + + FinTsVerfahren::uebernehmen($this->zugang, 913, 'Karte 1', $angebot); + } + + /** + * Das Verfahren steht nicht im Zugangsformular. Ohne Vorbehalt setzte jedes + * Speichern die Wahl zurück – und der nächste Abruf verlangte wieder das + * falsche Verfahren. + */ + private function tanVerfahrenUeberstehtDasSpeichern(): void + { + Bankzugang::speichern($this->zugang, [ + 'name' => 'Testbank', + 'blz' => '12030000', + 'fints_url' => 'https://banking.example.org/fints', + 'benutzerkennung' => 'testkunde', + 'pin' => '', + 'abrufzeitraum' => 'letzter_abruf', + 'aktiv' => 1, + ]); + + $zugang = Bankzugang::zugangsdaten($this->zugang); + $this->assertEquals(913, (int)$zugang['tan_verfahren_id'], 'Das Speichern lässt die Kennung stehen'); + $this->assertEquals('chipTAN QR', $zugang['tan_verfahren_name'], 'Und den Namen'); + $this->assertEquals('Karte 1', $zugang['tan_medium'], 'Und das Medium'); + } + + /** Hilfsmittel: Wirft der Aufruf eine Ausnahme? */ + private function wirftFehler(callable $aufruf): bool + { + try { + $aufruf(); + } catch (\Throwable) { + return true; + } + + return false; + } + private function zeitraumRichtetSichNachDemLetztenAbruf(): void { $heute = new \DateTimeImmutable('2026-08-11'); diff --git a/tests/e2e/specs/module.spec.js b/tests/e2e/specs/module.spec.js index 7ae8220..5e6b596 100644 --- a/tests/e2e/specs/module.spec.js +++ b/tests/e2e/specs/module.spec.js @@ -103,6 +103,16 @@ test.describe('Module der Phasen 4 bis 7', () => { await expect(page.locator('#pin')).toHaveValue('') await expect(page.locator('#pin')).toHaveAttribute('placeholder', /unverändert/) + // Das TAN-Verfahren steht auf einer eigenen Seite. Ohne Abfrage bei der + // Bank ist dort nichts gewählt – abgefragt wird hier nicht, das bräuchte + // eine echte Bankverbindung. + await page.click('a:has-text("TAN-Verfahren einstellen")') + await expect(page.locator('h1')).toContainText('TAN-Verfahren') + await expect(page.locator('.platzhalter').first()).toContainText('Noch nichts gewählt') + await expect(page.locator('button:has-text("Verfahren bei der Bank abfragen")')).toHaveCount(1) + + await page.click('a:has-text("Zurück zur Bankverbindung")') + page.once('dialog', dialog => dialog.accept()) await page.click('button:has-text("Bankverbindung löschen")') await expect(page.locator('.meldung')).toContainText('gelöscht')