Finanzmanager/src/Services/FinTsVerfahren.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

289 lines
11 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\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;
}
}