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>
This commit is contained in:
fds2610-bot 2026-08-12 08:07:02 +02:00
parent f388d04c05
commit 4bd7ffed1b
14 changed files with 740 additions and 104 deletions

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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;

View file

@ -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());

View file

@ -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<string,string> $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<string,string> $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<string,string> $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<array<string,mixed>>
*/
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<string,string> $params */

View file

@ -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,

View file

@ -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.
*

View file

@ -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;

View file

@ -0,0 +1,289 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Fhp\FinTs;
use Fhp\Model\TanMode;
use Fhp\Options\Credentials;
use Fhp\Options\FinTsOptions;
use Finanzmanager\Models\Bankzugang;
use Finanzmanager\Models\Einstellung;
/**
* Das TAN-Verfahren eines Bankzugangs: bei der Bank erfragen, auswählen und für
* die folgenden Dialoge einstellen.
*
* **Warum überhaupt eine Wahl.** Die meisten Banken bieten mehrere Verfahren an
* – chipTAN QR, photoTAN, pushTAN. Ohne Wahl nimmt der Code das erste, das die
* Bank meldet, und das ist selten das, was der Nutzer eingerichtet hat. Wer per
* chipTAN arbeitet, käme über eine App-Freigabe nicht weiter.
*
* **Warum abgefragt und nicht eingetippt.** Die Kennungen sind bankspezifisch
* (913 heißt nicht überall chipTAN QR). `getTanModes()` kommt ohne Anmeldung
* aus – die Bibliothek holt dafür nur die Bankparameterdaten –, deshalb lässt
* sich die Liste anzeigen, ohne dass eine TAN fällig wird.
*
* `getTanMedia()` wird nur für die Verfahren aufgerufen, die ein Medium
* brauchen: Jeder Aufruf ist ein eigener Dialog mit der Bank, und für die
* übrigen Verfahren wäre er verschwendet.
*/
class FinTsVerfahren
{
/** Produktkennung gegenüber der Bank – dieselbe wie beim Abruf. */
private const PRODUKT = 'F399A6377D4D60E0A222D677A';
private const PRODUKT_VERSION = '1.0';
/**
* Fragt die Bank, welche TAN-Verfahren dieser Zugang benutzen darf.
*
* @return array{status: string, meldung: string,
* verfahren: list<array{id: int, name: string, entkoppelt: bool,
* medium_noetig: bool, medien: list<string>,
* 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<array<string,mixed>> $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<string,mixed> $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<string>, 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<string,mixed> $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;
}
}

View file

@ -74,6 +74,29 @@ include __DIR__ . '/../layout/kopf.php';
aktiv
</label>
<?php if (!$neu): ?>
<h3>TAN-Verfahren</h3>
<p>
<?php if ($zugang['tan_verfahren_id'] === null): ?>
<span class="nebentext">
Noch nicht gewählt – beim nächsten Abruf entscheidet das erste Verfahren,
das die Bank meldet.
</span>
<?php else: ?>
<strong>
<?= View::h(($zugang['tan_verfahren_name'] ?? '') !== ''
? $zugang['tan_verfahren_name']
: 'Verfahren ' . (int)$zugang['tan_verfahren_id']) ?>
</strong>
<span class="nebentext">Kennung <?= (int)$zugang['tan_verfahren_id'] ?></span>
<?php endif; ?>
</p>
<p class="feldhinweis">
Die Wahl steht auf einer eigenen Seite, weil dafür die Bank gefragt werden muss:
<a href="/banking/<?= (int)$zugang['id'] ?>/verfahren">TAN-Verfahren einstellen</a>.
</p>
<?php endif; ?>
<?php if (!$neu): ?>
<h3>Konten bei dieser Bank</h3>
<?php if ($konten === []): ?>

View file

@ -0,0 +1,115 @@
<?php
use Finanzmanager\View;
/**
* Die Wahl des TAN-Verfahrens für einen Bankzugang.
*
* @var array<string,mixed> $zugang
* @var string $medium Das gespeicherte TAN-Medium im Klartext
* @var list<array{id:int, name:string, entkoppelt:bool, medium_noetig:bool,
* medien:list<string>, hinweis:string}> $verfahren
* @var string $csrfToken
*/
$gewaehlt = $zugang['tan_verfahren_id'] === null ? null : (int)$zugang['tan_verfahren_id'];
include __DIR__ . '/../layout/kopf.php';
?>
<div class="seitenaktionen">
<a href="/banking/<?= (int)$zugang['id'] ?>" class="knopf">Zurück zur Bankverbindung</a>
<form method="post" action="/banking/<?= (int)$zugang['id'] ?>/verfahren/abfragen" class="inline">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" class="knopf primaer">
<?= $verfahren === [] ? 'Verfahren bei der Bank abfragen' : 'Erneut abfragen' ?>
</button>
</form>
</div>
<p class="feldhinweis">
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.
</p>
<h3>Zurzeit eingestellt</h3>
<?php if ($gewaehlt === null): ?>
<p class="platzhalter">
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.
</p>
<?php else: ?>
<p>
<strong>
<?= View::h(($zugang['tan_verfahren_name'] ?? '') !== ''
? $zugang['tan_verfahren_name'] : 'Verfahren ' . $gewaehlt) ?>
</strong>
<span class="nebentext">Kennung <?= $gewaehlt ?></span>
</p>
<?php endif; ?>
<?php if ($verfahren === []): ?>
<p class="platzhalter">
Noch keine Liste abgefragt. Der Knopf oben holt sie – dafür müssen
Bankleitzahl, FinTS-Adresse, Benutzerkennung und PIN gespeichert sein.
</p>
<?php else: ?>
<h3>Von der Bank angeboten</h3>
<form method="post" action="/banking/<?= (int)$zugang['id'] ?>/verfahren" class="formular breit">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<table class="liste">
<thead>
<tr><th>Wahl</th><th>Verfahren</th><th>Kennung</th><th>TAN-Medium</th></tr>
</thead>
<tbody>
<?php foreach ($verfahren as $eintrag): ?>
<tr>
<td>
<input type="radio" name="verfahren_id" id="verfahren-<?= (int)$eintrag['id'] ?>"
value="<?= (int)$eintrag['id'] ?>"
<?= $gewaehlt === (int)$eintrag['id'] ? 'checked' : '' ?>>
</td>
<td>
<label for="verfahren-<?= (int)$eintrag['id'] ?>">
<?= View::h($eintrag['name']) ?>
</label>
<?php if ($eintrag['hinweis'] !== ''): ?>
<span class="nebentext"><?= View::h($eintrag['hinweis']) ?></span>
<?php endif; ?>
</td>
<td><?= (int)$eintrag['id'] ?></td>
<td>
<?php if (!$eintrag['medium_noetig']): ?>
<span class="nebentext">keines nötig</span>
<?php elseif ($eintrag['medien'] === []): ?>
<span class="nebentext">von der Bank nicht genannt</span>
<?php else: ?>
<?php /* Je Verfahren ein eigenes Feld: Mit einem
gemeinsamen Namen gewänne immer die letzte
Zeile, gleich welches Verfahren angekreuzt
ist. */ ?>
<select name="tan_medium_<?= (int)$eintrag['id'] ?>">
<?php foreach ($eintrag['medien'] as $angebot): ?>
<option value="<?= View::h($angebot) ?>"
<?= $medium === $angebot ? 'selected' : '' ?>>
<?= View::h($angebot) ?>
</option>
<?php endforeach; ?>
</select>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Verfahren übernehmen</button>
</div>
</form>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -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');

View file

@ -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')