Der gesamte FinTS-Dialog (gesendete und empfangene Meldungen) wird je Durchlauf in bank_protokolle gespeichert, im Erfolgs- wie im Fehlerfall. Im Zahlungsverkehr öffnet der Status das Protokoll der Überweisung als modalen Dialog; unter Online-Banking › Protokoll gibt es das Gesamtprotokoll mit Filter nach Bank, Datum und Freitext. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
321 lines
12 KiB
PHP
321 lines
12 KiB
PHP
<?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\Bankprotokoll;
|
||
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
|
||
{
|
||
$mitschnitt = new BankProtokollLogger();
|
||
$ergebnis = self::abfrageDurchfuehren($zugangId, $mitschnitt);
|
||
|
||
if (Bankzugang::zugangsdaten($zugangId) !== null) {
|
||
$mitschnitt->vermerke('Ergebnis: ' . $ergebnis['meldung']);
|
||
try {
|
||
Bankprotokoll::speichern(
|
||
$zugangId,
|
||
'verfahren',
|
||
$ergebnis['status'] === 'erfolg' ? 'erfolg' : 'fehler',
|
||
$ergebnis['meldung'],
|
||
$mitschnitt->text()
|
||
);
|
||
} catch (\Throwable $e) {
|
||
// Ein Fehler im Protokoll darf die Abfrage nicht verdecken.
|
||
DebugLog::fehler($e);
|
||
}
|
||
}
|
||
|
||
return $ergebnis;
|
||
}
|
||
|
||
/** @return array<string,mixed> */
|
||
private static function abfrageDurchfuehren(int $zugangId, BankProtokollLogger $mitschnitt): 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, $mitschnitt);
|
||
$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, BankProtokollLogger $mitschnitt): 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;
|
||
|
||
// Kürzer als die Vorgabe der Bibliothek (15/30): Die Abfrage hängt an
|
||
// einem Seitenaufruf, und eine Bank, die nicht antwortet, soll die Seite
|
||
// nicht minutenlang aufhalten.
|
||
$optionen->timeoutConnect = 8;
|
||
$optionen->timeoutResponse = 20;
|
||
|
||
$fints = FinTs::new(
|
||
$optionen,
|
||
Credentials::create((string)$zugang['benutzerkennung'], (string)$zugang['pin'])
|
||
);
|
||
$fints->setLogger($mitschnitt);
|
||
|
||
return $fints;
|
||
}
|
||
}
|