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:
parent
f388d04c05
commit
4bd7ffed1b
14 changed files with 740 additions and 104 deletions
16
Changes.md
16
Changes.md
|
|
@ -59,6 +59,22 @@ Damit ist der geplante Funktionsumfang beisammen: Von der Kontenübersicht bis z
|
||||||
Knopfdruck nach, statt zu warten.
|
Knopfdruck nach, statt zu warten.
|
||||||
- Umsätze, die vor einer TAN-Unterbrechung geschrieben wurden, bleiben erhalten.
|
- 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**
|
**Abgleich statt Direktbuchung**
|
||||||
- Abgerufene Umsätze landen in `bank_umsaetze` und werden nicht sofort gebucht. Die
|
- Abgerufene Umsätze landen in `bank_umsaetze` und werden nicht sofort gebucht. Die
|
||||||
Vorschlagsliste kennt drei Wege: mit einer erfassten Buchung **verbinden**, als neue
|
Vorschlagsliste kennt drei Wege: mit einer erfassten Buchung **verbinden**, als neue
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,9 @@ Haushalt wird beides parallel genutzt.
|
||||||
Gruppen.
|
Gruppen.
|
||||||
* **Cockpit** – konfigurierbare Kacheln: Vermögensentwicklung, Einnahmen/Ausgaben je
|
* **Cockpit** – konfigurierbare Kacheln: Vermögensentwicklung, Einnahmen/Ausgaben je
|
||||||
Monat, Ausgaben nach Kategorie, Kontostände, offene Vorgänge.
|
Monat, Ausgaben nach Kategorie, Kontostände, offene Vorgänge.
|
||||||
* **Online-Banking** – FinTS-Abruf mit TAN-Dialog über mehrere Requests, Abgleich der
|
* **Online-Banking** – FinTS-Abruf mit TAN-Dialog über mehrere Requests, TAN-Verfahren bei
|
||||||
Umsätze gegen vorhandene Buchungen, Regeln für die automatische Kategorisierung.
|
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,
|
* **Wertpapiere** – Depotbestände mit Einstand und Bewertung, Depotbuchungen, Kursabruf,
|
||||||
Veräußerungsgeschäfte und Erträge.
|
Veräußerungsgeschäfte und Erträge.
|
||||||
* **Nebenmodule** – Buchungsvorlagen, regelmäßige Buchungen mit Fälligkeitslauf und
|
* **Nebenmodule** – Buchungsvorlagen, regelmäßige Buchungen mit Fälligkeitslauf und
|
||||||
|
|
|
||||||
|
|
@ -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
|
fragt die Anwendung auf Knopfdruck nach (`checkDecoupledSubmission`) – steht die Freigabe
|
||||||
noch aus, bleibt die Maske stehen.
|
noch aus, bleibt die Maske stehen.
|
||||||
|
|
||||||
Verfahren und TAN-Medium werden beim ersten Abruf gewählt und am Zugang gespeichert, damit
|
### Das Verfahren wählen
|
||||||
die Bank nicht jedes Mal danach fragt.
|
|
||||||
|
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
|
### 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
|
Restausgleich, Abweisung ungültiger Ausdrücke, Abgleich gegen vorhandene Buchungen samt
|
||||||
Toleranz sowie Übernehmen, Verwerfen und Zurücksetzen.
|
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.
|
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,
|
Geprüft wurde er gegen den Fehlerpfad: Eine unerreichbare Adresse bricht sauber ab,
|
||||||
protokolliert und meldet verständlich.
|
protokolliert und meldet verständlich.
|
||||||
|
|
|
||||||
9
migrations/009_tan_verfahren.sql
Normal file
9
migrations/009_tan_verfahren.sql
Normal 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;
|
||||||
|
|
@ -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}', fn(array $p) => (new BankingController())->speichern($p));
|
||||||
$router->post('/banking/{id}/abrufen', fn(array $p) => (new BankingController())->abrufen($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->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->get('/verwaltung/import', fn() => (new ImportController())->index());
|
||||||
$router->post('/verwaltung/import/hochladen', fn() => (new ImportController())->hochladen());
|
$router->post('/verwaltung/import/hochladen', fn() => (new ImportController())->hochladen());
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Finanzmanager\Controllers;
|
namespace Finanzmanager\Controllers;
|
||||||
|
|
||||||
|
use Finanzmanager\Auth;
|
||||||
use Finanzmanager\Models\AbgleichRegel;
|
use Finanzmanager\Models\AbgleichRegel;
|
||||||
use Finanzmanager\Models\Bankabruf;
|
use Finanzmanager\Models\Bankabruf;
|
||||||
use Finanzmanager\Models\Bankumsatz;
|
use Finanzmanager\Models\Bankumsatz;
|
||||||
|
|
@ -13,6 +14,7 @@ use Finanzmanager\Models\Klasse;
|
||||||
use Finanzmanager\Models\Konto;
|
use Finanzmanager\Models\Konto;
|
||||||
use Finanzmanager\Services\Crypto;
|
use Finanzmanager\Services\Crypto;
|
||||||
use Finanzmanager\Services\FinTsAbruf;
|
use Finanzmanager\Services\FinTsAbruf;
|
||||||
|
use Finanzmanager\Services\FinTsVerfahren;
|
||||||
use Finanzmanager\View;
|
use Finanzmanager\View;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -25,6 +27,9 @@ use Finanzmanager\View;
|
||||||
*/
|
*/
|
||||||
class BankingController extends BaseController
|
class BankingController extends BaseController
|
||||||
{
|
{
|
||||||
|
/** Schlüssel der abgefragten TAN-Verfahren in der Sitzung. */
|
||||||
|
private const SITZUNG_VERFAHREN = 'banking_verfahren';
|
||||||
|
|
||||||
// ── Bankverbindungen ─────────────────────────────────────────────────────
|
// ── Bankverbindungen ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
public function index(): void
|
public function index(): void
|
||||||
|
|
@ -119,6 +124,97 @@ class BankingController extends BaseController
|
||||||
View::redirect('/banking', 'Bankverbindung gelöscht.');
|
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 ────────────────────────────────────────────────────────────────
|
// ── Abruf ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** @param array<string,string> $params */
|
/** @param array<string,string> $params */
|
||||||
|
|
|
||||||
|
|
@ -74,12 +74,12 @@ class Bankzugang
|
||||||
Database::execute(
|
Database::execute(
|
||||||
'INSERT INTO bank_zugaenge
|
'INSERT INTO bank_zugaenge
|
||||||
(benutzer_id, name, bankname, blz, fints_url, benutzerkennung, pin,
|
(benutzer_id, name, bankname, blz, fints_url, benutzerkennung, pin,
|
||||||
tan_verfahren_id, tan_medium, abrufzeitraum, aktiv)
|
tan_verfahren_id, tan_verfahren_name, tan_medium, abrufzeitraum, aktiv)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
[
|
[
|
||||||
Mandant::id(), $felder['name'], $felder['bankname'], $felder['blz'],
|
Mandant::id(), $felder['name'], $felder['bankname'], $felder['blz'],
|
||||||
$felder['fints_url'], $felder['benutzerkennung'], $felder['pin'],
|
$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'],
|
$felder['abrufzeitraum'], $felder['aktiv'],
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
@ -105,15 +105,25 @@ class Bankzugang
|
||||||
$felder['pin'] = $bisher['pin'];
|
$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(
|
Database::execute(
|
||||||
'UPDATE bank_zugaenge SET name = ?, bankname = ?, blz = ?, fints_url = ?,
|
'UPDATE bank_zugaenge SET name = ?, bankname = ?, blz = ?, fints_url = ?,
|
||||||
benutzerkennung = ?, pin = ?, tan_verfahren_id = ?, tan_medium = ?,
|
benutzerkennung = ?, pin = ?, tan_verfahren_id = ?, tan_verfahren_name = ?,
|
||||||
abrufzeitraum = ?, aktiv = ?
|
tan_medium = ?, abrufzeitraum = ?, aktiv = ?
|
||||||
WHERE id = ? AND benutzer_id = ?',
|
WHERE id = ? AND benutzer_id = ?',
|
||||||
[
|
[
|
||||||
$felder['name'], $felder['bankname'], $felder['blz'], $felder['fints_url'],
|
$felder['name'], $felder['bankname'], $felder['blz'], $felder['fints_url'],
|
||||||
$felder['benutzerkennung'], $felder['pin'], $felder['tan_verfahren_id'],
|
$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(),
|
$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(
|
Database::execute(
|
||||||
'UPDATE bank_zugaenge SET tan_verfahren_id = ?, tan_medium = ? WHERE id = ? AND benutzer_id = ?',
|
'UPDATE bank_zugaenge
|
||||||
[$verfahren, Crypto::verschluessle($medium), $id, Mandant::id()]
|
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),
|
'benutzerkennung' => mb_substr(trim((string)($daten['benutzerkennung'] ?? '')), 0, 128),
|
||||||
'pin' => $pin,
|
'pin' => $pin,
|
||||||
'tan_verfahren_id' => $verfahren,
|
'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'] ?? ''))),
|
'tan_medium' => Crypto::verschluessle(trim((string)($daten['tan_medium'] ?? ''))),
|
||||||
'abrufzeitraum' => array_key_exists($zeitraum, self::ZEITRAEUME) ? $zeitraum : 'letzter_abruf',
|
'abrufzeitraum' => array_key_exists($zeitraum, self::ZEITRAEUME) ? $zeitraum : 'letzter_abruf',
|
||||||
'aktiv' => !empty($daten['aktiv']) ? 1 : 0,
|
'aktiv' => !empty($daten['aktiv']) ? 1 : 0,
|
||||||
|
|
|
||||||
|
|
@ -520,67 +520,15 @@ class FinTsAbruf
|
||||||
$fints = FinTs::new($optionen, $zugangsdaten, $this->zustand['dialog'] ?? null);
|
$fints = FinTs::new($optionen, $zugangsdaten, $this->zustand['dialog'] ?? null);
|
||||||
$fints->setLogger(new DebugLogAdapter());
|
$fints->setLogger(new DebugLogAdapter());
|
||||||
|
|
||||||
// Beim ersten Lauf steht noch kein Verfahren fest. Gespeichert wird die
|
// Beim ersten Lauf steht das Verfahren noch nicht im Dialog – es kommt
|
||||||
// Wahl am Zugang, damit die Bank nicht bei jedem Abruf danach fragt.
|
// aus der Einstellung des Zugangs (siehe `Services\FinTsVerfahren`).
|
||||||
if (!isset($this->zustand['dialog'])) {
|
if (!isset($this->zustand['dialog'])) {
|
||||||
$this->waehleTanVerfahren($fints);
|
FinTsVerfahren::anwenden($fints, $this->zugang, fn(string $zeile) => $this->notiere($zeile));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $fints;
|
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.
|
* Die Konten dieses Zugangs, die abgerufen werden sollen.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -335,42 +335,14 @@ class FinTsUeberweisung
|
||||||
);
|
);
|
||||||
$fints->setLogger(new DebugLogAdapter());
|
$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'])) {
|
if (!isset($this->zustand['dialog'])) {
|
||||||
$verfahren = $fints->getTanModes();
|
FinTsVerfahren::anwenden(
|
||||||
if ($verfahren === []) {
|
$fints,
|
||||||
throw new \RuntimeException('Die Bank meldet keine TAN-Verfahren.');
|
$this->zugang,
|
||||||
}
|
static fn(string $zeile) => DebugLog::schreibe('fints', $zeile)
|
||||||
|
);
|
||||||
$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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $fints;
|
return $fints;
|
||||||
|
|
|
||||||
289
src/Services/FinTsVerfahren.php
Normal file
289
src/Services/FinTsVerfahren.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -74,6 +74,29 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
aktiv
|
aktiv
|
||||||
</label>
|
</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): ?>
|
<?php if (!$neu): ?>
|
||||||
<h3>Konten bei dieser Bank</h3>
|
<h3>Konten bei dieser Bank</h3>
|
||||||
<?php if ($konten === []): ?>
|
<?php if ($konten === []): ?>
|
||||||
|
|
|
||||||
115
src/Views/banking/verfahren.php
Normal file
115
src/Views/banking/verfahren.php
Normal 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'; ?>
|
||||||
|
|
@ -11,6 +11,7 @@ use Finanzmanager\Models\Einstellung;
|
||||||
use Finanzmanager\Models\Kategorie;
|
use Finanzmanager\Models\Kategorie;
|
||||||
use Finanzmanager\Models\Konto;
|
use Finanzmanager\Models\Konto;
|
||||||
use Finanzmanager\Services\Crypto;
|
use Finanzmanager\Services\Crypto;
|
||||||
|
use Finanzmanager\Services\FinTsVerfahren;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Online-Banking ohne Bank: Geprüft wird alles, was um den FinTS-Dialog herum
|
* 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->verschluesselungSchuetztDieZugangsdaten();
|
||||||
$this->zugangHaeltPinBeimSpeichern();
|
$this->zugangHaeltPinBeimSpeichern();
|
||||||
|
$this->tanVerfahrenLaesstSichWaehlen();
|
||||||
|
$this->tanVerfahrenUeberstehtDasSpeichern();
|
||||||
$this->zeitraumRichtetSichNachDemLetztenAbruf();
|
$this->zeitraumRichtetSichNachDemLetztenAbruf();
|
||||||
$this->umsaetzeKommenNurEinmalHerein();
|
$this->umsaetzeKommenNurEinmalHerein();
|
||||||
$this->regelnGreifenNachPrioritaet();
|
$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
|
private function zeitraumRichtetSichNachDemLetztenAbruf(): void
|
||||||
{
|
{
|
||||||
$heute = new \DateTimeImmutable('2026-08-11');
|
$heute = new \DateTimeImmutable('2026-08-11');
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,16 @@ test.describe('Module der Phasen 4 bis 7', () => {
|
||||||
await expect(page.locator('#pin')).toHaveValue('')
|
await expect(page.locator('#pin')).toHaveValue('')
|
||||||
await expect(page.locator('#pin')).toHaveAttribute('placeholder', /unverändert/)
|
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())
|
page.once('dialog', dialog => dialog.accept())
|
||||||
await page.click('button:has-text("Bankverbindung löschen")')
|
await page.click('button:has-text("Bankverbindung löschen")')
|
||||||
await expect(page.locator('.meldung')).toContainText('gelöscht')
|
await expect(page.locator('.meldung')).toContainText('gelöscht')
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue