Fix #56: Namensabgleich vor der Überweisung
Seit Oktober 2025 gleicht die Bank vor der Ausführung den Empfängernamen mit dem Kontoinhaber ab (Verification of Payee). Der Finanzmanager kannte diese Stufe nicht, die Überweisung blieb stehen. FinTsUeberweisung wartet jetzt auf das Ergebnis, solange die Bank es braucht. Stimmt der Name überein, wird selbst bestätigt und direkt zur TAN gegangen – eine Rückfrage ohne Erkenntnis erspart das dem Nutzer. Weicht der Name ab, ist er nur nahezu gleich oder konnte die Bank nicht prüfen, kommt die neue Maske zahlungsverkehr/vop.php: Ergebnis in einem deutschen Satz, Aufklärungstext der Bank als Klartext, Empfänger und IBAN aus dem Auftrag und – sofern die Bank ihn nennt – der bei ihr hinterlegte Kontoinhaber. Ohne Ausführungstermin steht jetzt das Platzhalterdatum 1999-01-01 in der Nachricht. Mit dem heutigen Datum wurde daraus eine terminierte Überweisung (HKCSE statt HKCCS), die die Bank mit "Die minimale Vorlaufzeit beträgt 1 Tage" zurückwies. Der FinTS-Fork steht auf v4.1.3 mit zwei Eingriffen: Die schrittweise Lieferung des Prüfberichts wird erst abgelehnt, wenn sie wirklich eintritt, und der beim Close Match gemeldete Kontoinhaber wird an die Anwendung durchgereicht. Beide liegen zusätzlich auf eigenen Branches für PRs an das Upstream-Projekt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013iURiWYZJQYVGQNXFFGyWu
This commit is contained in:
parent
8dc5afd8ba
commit
79f617025b
12 changed files with 591 additions and 24 deletions
29
Changes.md
29
Changes.md
|
|
@ -1,5 +1,34 @@
|
||||||
# Änderungen
|
# Änderungen
|
||||||
|
|
||||||
|
## Zahlungsverkehr: Namensabgleich vor der Überweisung (01.09.2026)
|
||||||
|
|
||||||
|
Seit Oktober 2025 gleicht die Bank vor der Ausführung den Empfängernamen mit dem
|
||||||
|
Kontoinhaber ab (Verification of Payee). Der Finanzmanager kannte diese Stufe nicht, die
|
||||||
|
Überweisung blieb stehen.
|
||||||
|
|
||||||
|
`FinTsUeberweisung` behandelt den Ablauf jetzt vollständig: Auf das Ergebnis wird gewartet,
|
||||||
|
solange die Bank es braucht; **stimmt der Name überein, bestätigt der Finanzmanager selbst**
|
||||||
|
und geht direkt zur TAN. Weicht der Name ab, ist er nur nahezu gleich oder konnte die Bank
|
||||||
|
nicht prüfen, kommt die neue Maske `zahlungsverkehr/vop.php` – mit dem Ergebnis in einem
|
||||||
|
deutschen Satz, dem Aufklärungstext der Bank als Klartext (ihr HTML wird abgeräumt) und der
|
||||||
|
Wahl zwischen *Trotzdem senden* und *Abbrechen*. Beim Abbrechen bleibt der Auftrag Entwurf.
|
||||||
|
|
||||||
|
Stimmt der Name nur **nahezu** überein, zeigt die Maske neben dem angegebenen Empfänger
|
||||||
|
auch den bei der Bank hinterlegten Kontoinhaber – die Angabe, die die Rückfrage überhaupt
|
||||||
|
beantwortbar macht. Weicht der Name ganz ab, nennt die Bank ihn nicht; das steht dann als
|
||||||
|
Erklärung in der Maske.
|
||||||
|
|
||||||
|
Dazu gehören zwei Eingriffe im FinTS-Fork (`v4.1.3`): Meldet eine Bank die schrittweise
|
||||||
|
Lieferung des Prüfberichts (`Art der Lieferung Payment Status Report` = `S`, etwa
|
||||||
|
comdirect), lehnte php-fints jede Überweisung ab. Der Parameter beschreibt laut
|
||||||
|
Spezifikation aber nur, wie die Bank den Bericht aufteilt, *wenn* sie ihn über mehrere
|
||||||
|
Runden liefert – bei einer Einzelüberweisung passiert das nie. Der Fork prüft ihn deshalb
|
||||||
|
erst, wenn tatsächlich eine Teillieferung eintrifft. Und der beim Close Match gemeldete
|
||||||
|
Kontoinhaber wurde zwar eingelesen, aber nicht an die Anwendung durchgereicht; dafür gibt
|
||||||
|
es jetzt `VopConfirmationRequest::getDifferingPayeeName()`. Beide Commits liegen zusätzlich
|
||||||
|
auf eigenen Branches (`vop-stepwise`, `vop-close-match-name`), damit daraus PRs an das
|
||||||
|
Upstream-Projekt werden können.
|
||||||
|
|
||||||
## Zahlungsverkehr: SEPA-Format der Bank (01.09.2026)
|
## Zahlungsverkehr: SEPA-Format der Bank (01.09.2026)
|
||||||
|
|
||||||
Die Überweisung wurde bisher immer als `pain.001.001.03` eingereicht. Banken, die nur
|
Die Überweisung wurde bisher immer als `pain.001.001.03` eingereicht. Banken, die nur
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.3",
|
"php": ">=8.3",
|
||||||
"nemiah/php-fints": "4.1.1"
|
"nemiah/php-fints": "4.1.3"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
|
|
|
||||||
14
composer.lock
generated
14
composer.lock
generated
|
|
@ -4,20 +4,20 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "c9c68471574e1ff2ea7b78c3d71cdfd3",
|
"content-hash": "2ed8c4171d96c85182d0ca57fb9c0a87",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "nemiah/php-fints",
|
"name": "nemiah/php-fints",
|
||||||
"version": "v4.1.1",
|
"version": "v4.1.3",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://codeberg.org/fds2610-bot/phpFinTS.git",
|
"url": "https://codeberg.org/fds2610-bot/phpFinTS.git",
|
||||||
"reference": "6ebd8e0ec1287dac384d404ca6311ba8169ee545"
|
"reference": "76e3c648e88c2e5608044595d5920ba3ffee70a5"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://codeberg.org/api/v1/repos/fds2610-bot/phpFinTS/archive/6ebd8e0ec1287dac384d404ca6311ba8169ee545.zip",
|
"url": "https://codeberg.org/api/v1/repos/fds2610-bot/phpFinTS/archive/76e3c648e88c2e5608044595d5920ba3ffee70a5.zip",
|
||||||
"reference": "6ebd8e0ec1287dac384d404ca6311ba8169ee545",
|
"reference": "76e3c648e88c2e5608044595d5920ba3ffee70a5",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
|
|
@ -67,10 +67,10 @@
|
||||||
"description": "PHP Library for the protocols fints and hbci",
|
"description": "PHP Library for the protocols fints and hbci",
|
||||||
"homepage": "https://codeberg.org/fds2610-bot/phpFinTS",
|
"homepage": "https://codeberg.org/fds2610-bot/phpFinTS",
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://codeberg.org/fds2610-bot/phpFinTS/tag/v4.1.1",
|
"source": "https://codeberg.org/fds2610-bot/phpFinTS/tag/v4.1.3",
|
||||||
"issues": "https://codeberg.org/fds2610-bot/phpFinTS/issues"
|
"issues": "https://codeberg.org/fds2610-bot/phpFinTS/issues"
|
||||||
},
|
},
|
||||||
"time": "2026-08-11T18:48:38+02:00"
|
"time": "2026-09-01T19:22:02+02:00"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"packages-dev": [],
|
"packages-dev": [],
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ passende Zugangsdaten noch passende Konten hätte.
|
||||||
|
|
||||||
Verwendet wird `nemiah/php-fints`, aber nicht von Packagist, sondern als **Fork auf
|
Verwendet wird `nemiah/php-fints`, aber nicht von Packagist, sondern als **Fork auf
|
||||||
Codeberg** (`fds2610-bot/phpFinTS`, eingebunden über ein VCS-Repository in der
|
Codeberg** (`fds2610-bot/phpFinTS`, eingebunden über ein VCS-Repository in der
|
||||||
`composer.json`, Version fest auf `4.1.1` gepinnt).
|
`composer.json`, Version fest auf `4.1.3` gepinnt).
|
||||||
|
|
||||||
Im Fork ist `psr/log` **direkt eingebaut**: Die acht Interface-Dateien liegen unter
|
Im Fork ist `psr/log` **direkt eingebaut**: Die acht Interface-Dateien liegen unter
|
||||||
`psr-log/Psr/Log/` und werden über einen eigenen Autoload-Eintrag geladen, der
|
`psr-log/Psr/Log/` und werden über einen eigenen Autoload-Eintrag geladen, der
|
||||||
|
|
@ -30,6 +30,26 @@ Kontrolle.
|
||||||
Beim Nachziehen eines neuen Upstream-Standes muss dieser Eingriff wiederholt werden; wie,
|
Beim Nachziehen eines neuen Upstream-Standes muss dieser Eingriff wiederholt werden; wie,
|
||||||
steht in `psr-log/README.md` des Forks.
|
steht in `psr-log/README.md` des Forks.
|
||||||
|
|
||||||
|
### Eigene Änderungen am Protokoll
|
||||||
|
|
||||||
|
Dazu kommt ein Eingriff am **Namensabgleich** (Verification of Payee): Meldet eine Bank in
|
||||||
|
den Bankparametern die schrittweise Lieferung des Prüfberichts (`Art der Lieferung Payment
|
||||||
|
Status Report` = `S`, etwa comdirect), lehnte php-fints jede Überweisung ab. Der Parameter
|
||||||
|
beschreibt aber nur, wie die Bank den Bericht aufteilt, *wenn* sie ihn über mehrere Runden
|
||||||
|
liefert – bei einer Einzelüberweisung passiert das nie. Der Fork prüft ihn deshalb erst,
|
||||||
|
wenn tatsächlich eine Teillieferung eintrifft.
|
||||||
|
|
||||||
|
Dazu kommt ein zweiter: Stimmt der Empfängername nur **nahezu** überein (Close Match),
|
||||||
|
nennt die Bank den bei ihr hinterlegten Namen als Entscheidungshilfe. php-fints las ihn
|
||||||
|
zwar aus dem Segment, reichte ihn aber nicht an die Anwendung durch – ausgerechnet die
|
||||||
|
Angabe, die die Rückfrage überhaupt beantwortbar macht. Sie steht jetzt als
|
||||||
|
`VopConfirmationRequest::getDifferingPayeeName()` zur Verfügung.
|
||||||
|
|
||||||
|
Jeder Eingriff liegt als eigener Commit vor, zusätzlich auf einem eigenen Branch über
|
||||||
|
`master` (ohne den psr/log-Commit), damit daraus je ein PR an das Upstream-Projekt werden
|
||||||
|
kann: `vop-stepwise` und `vop-close-match-name`. Der Stand, den der Finanzmanager
|
||||||
|
verwendet, ist der Branch `finanzmanager` mit dem Tag `v4.1.3`.
|
||||||
|
|
||||||
## Das Bankleitzahlen-Verzeichnis
|
## Das Bankleitzahlen-Verzeichnis
|
||||||
|
|
||||||
Beim Anlegen einer FinTS-Verbindung fehlt in aller Regel genau eine Angabe: der
|
Beim Anlegen einer FinTS-Verbindung fehlt in aller Regel genau eine Angabe: der
|
||||||
|
|
|
||||||
|
|
@ -133,10 +133,16 @@ Terminüberweisung; sie erscheint dann im Finanzkalender.
|
||||||
|
|
||||||
### SEPA-XML von Hand
|
### SEPA-XML von Hand
|
||||||
|
|
||||||
php-fints erwartet die fertige **pain.001.001.03**-Nachricht und erzeugt sie nicht. Die
|
php-fints erwartet die fertige **pain.001**-Nachricht und erzeugt sie nicht. Die üblichen
|
||||||
üblichen Erzeuger wären zwei weitere Abhängigkeiten für ein Dokument von sechzig Zeilen –
|
Erzeuger wären zwei weitere Abhängigkeiten für ein Dokument von sechzig Zeilen –
|
||||||
`Services\SepaXml` schreibt es deshalb selbst.
|
`Services\SepaXml` schreibt es deshalb selbst.
|
||||||
|
|
||||||
|
**Welches Format, sagt die Bank.** Die einen nehmen das ISO-Format `pain.001.001.03`, die
|
||||||
|
anderen nur das ältere DK-Format `pain.001.003.03` – und weisen alles andere schon beim
|
||||||
|
Einreichen ab. Welche Formate eine Bank führt, steht in den Bankparametern (Segment
|
||||||
|
HISPAS); `SepaXml::schemaWaehlen()` sucht daraus das passende aus, ISO bevorzugt. Für die
|
||||||
|
Einzelüberweisung sind beide Fassungen im Aufbau gleich, es genügt der andere Namensraum.
|
||||||
|
|
||||||
Der **SEPA-Zeichenvorrat** ist eng: `a–z A–Z 0–9 / - ? : ( ) . , ' +` und Leerzeichen.
|
Der **SEPA-Zeichenvorrat** ist eng: `a–z A–Z 0–9 / - ? : ( ) . , ' +` und Leerzeichen.
|
||||||
Umlaute werden umgeschrieben (ä → ae), alles Übrige fällt weg. Ohne diese Umschrift weist
|
Umlaute werden umgeschrieben (ä → ae), alles Übrige fällt weg. Ohne diese Umschrift weist
|
||||||
die Bank den Auftrag zurück – und zwar erst nach dem Absenden.
|
die Bank den Auftrag zurück – und zwar erst nach dem Absenden.
|
||||||
|
|
@ -144,6 +150,36 @@ die Bank den Auftrag zurück – und zwar erst nach dem Absenden.
|
||||||
Der Verwendungszweck endet bei 140 Zeichen; das ist das SEPA-Maß, alles darüber würde die
|
Der Verwendungszweck endet bei 140 Zeichen; das ist das SEPA-Maß, alles darüber würde die
|
||||||
Bank ohnehin abschneiden.
|
Bank ohnehin abschneiden.
|
||||||
|
|
||||||
|
### Namensabgleich (Verification of Payee)
|
||||||
|
|
||||||
|
Seit Oktober 2025 gleicht die Bank vor der Ausführung den Empfängernamen mit dem
|
||||||
|
Kontoinhaber ab. Der Ablauf ist eine Stufe vor der TAN:
|
||||||
|
|
||||||
|
1. Der Auftrag geht zusammen mit dem Prüfauftrag (Segment HKVPP) hinaus.
|
||||||
|
2. Braucht die Bank für den Abgleich einen Moment, wird nachgefragt, bis das Ergebnis da
|
||||||
|
ist – wie lange sie braucht, sagt sie selbst.
|
||||||
|
3. **Stimmt der Name überein, bestätigt der Finanzmanager selbst** und geht direkt zur
|
||||||
|
TAN. Eine Rückfrage ohne Erkenntnis erspart das dem Nutzer.
|
||||||
|
4. **Weicht der Name ab**, ist er nur nahezu gleich oder konnte die Bank nicht prüfen,
|
||||||
|
kommt die Maske `zahlungsverkehr/vop.php`: das Ergebnis in einem deutschen Satz, der
|
||||||
|
Aufklärungstext der Bank als Klartext (ihr HTML wird abgeräumt), Empfänger und IBAN aus
|
||||||
|
dem Auftrag, und die Wahl zwischen *Trotzdem senden* und *Abbrechen*.
|
||||||
|
|
||||||
|
Stimmt der Name nur **nahezu** überein, nennt die Bank den bei ihr hinterlegten
|
||||||
|
Kontoinhaber – er steht dann in der Maske neben dem angegebenen Namen. Weicht der Name
|
||||||
|
ganz ab, bleibt er geheim: Sonst ließen sich über beliebige IBANs Kontoinhaber ausspähen.
|
||||||
|
Die Spezifikation erlaubt die Angabe ausdrücklich nur beim Close Match.
|
||||||
|
|
||||||
|
Wer trotz Abweichung sendet, verliert den Erstattungsanspruch – deshalb steht das auch in
|
||||||
|
der Maske. Beim Abbrechen bleibt der Auftrag als Entwurf stehen, der Empfängername lässt
|
||||||
|
sich korrigieren und der Auftrag erneut senden.
|
||||||
|
|
||||||
|
Meldet die Bank die **schrittweise Lieferung** des Prüfberichts (BPD-Parameter „Art der
|
||||||
|
Lieferung Payment Status Report" = `S`, etwa bei comdirect), betrifft das nur sehr große
|
||||||
|
Sammler, bei denen die Bank den Bericht über mehrere Runden schickt. php-fints lehnte
|
||||||
|
solche Banken bisher pauschal ab; der Fork auf Codeberg prüft das erst, wenn wirklich eine
|
||||||
|
Teillieferung kommt (siehe `docs/banking.md`).
|
||||||
|
|
||||||
### TAN
|
### TAN
|
||||||
|
|
||||||
Für Überweisungen verlangt jede Bank eine TAN. Der Dialog wird genauso über die Sitzung
|
Für Überweisungen verlangt jede Bank eine TAN. Der Dialog wird genauso über die Sitzung
|
||||||
|
|
@ -161,3 +197,10 @@ IBAN-Prüfung mit gültigen und falschen Nummern, Kündigungsfristen und Jahresb
|
||||||
Sparfortschritt aus dem Konto, Tilgungsplan (Annuität, letzte Rate, Sondertilgung,
|
Sparfortschritt aus dem Konto, Tilgungsplan (Annuität, letzte Rate, Sondertilgung,
|
||||||
zu kleine Rate), Zahlungsaufträge (Buchung erst nach Annahme, Unveränderlichkeit,
|
zu kleine Rate), Zahlungsaufträge (Buchung erst nach Annahme, Unveränderlichkeit,
|
||||||
Eingabeprüfungen), SEPA-XML (Schema, Zeichensatz, Wohlgeformtheit) und der Finanzkalender.
|
Eingabeprüfungen), SEPA-XML (Schema, Zeichensatz, Wohlgeformtheit) und der Finanzkalender.
|
||||||
|
|
||||||
|
`tests/39_SepaSchemaTest.php` – Formatwahl nach den Angaben der Bank; die erzeugte
|
||||||
|
Nachricht wird gegen das beiliegende DK-Schema `tests/schemata/pain.001.003.03.xsd`
|
||||||
|
validiert.
|
||||||
|
|
||||||
|
`tests/40_NamensabgleichTest.php` – deutsche Sätze zu jedem Prüfergebnis, Aufbereitung des
|
||||||
|
Banktexts und die Absicherung, dass ohne wartenden Vorgang nichts bestätigt werden kann.
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,8 @@ $router->post('/zahlungsverkehr/neu', fn() => (new ZahlungsverkehrController())-
|
||||||
$router->get('/zahlungsverkehr/tan', fn() => (new ZahlungsverkehrController())->tanFormular());
|
$router->get('/zahlungsverkehr/tan', fn() => (new ZahlungsverkehrController())->tanFormular());
|
||||||
$router->post('/zahlungsverkehr/tan', fn() => (new ZahlungsverkehrController())->tanEinreichen());
|
$router->post('/zahlungsverkehr/tan', fn() => (new ZahlungsverkehrController())->tanEinreichen());
|
||||||
$router->post('/zahlungsverkehr/tan/abbrechen', fn() => (new ZahlungsverkehrController())->tanAbbrechen());
|
$router->post('/zahlungsverkehr/tan/abbrechen', fn() => (new ZahlungsverkehrController())->tanAbbrechen());
|
||||||
|
$router->get('/zahlungsverkehr/vop', fn() => (new ZahlungsverkehrController())->vopFormular());
|
||||||
|
$router->post('/zahlungsverkehr/vop', fn() => (new ZahlungsverkehrController())->vopBestaetigen());
|
||||||
|
|
||||||
$router->get('/zahlungsverkehr/{id}', fn(array $p) => (new ZahlungsverkehrController())->bearbeiten($p));
|
$router->get('/zahlungsverkehr/{id}', fn(array $p) => (new ZahlungsverkehrController())->bearbeiten($p));
|
||||||
$router->post('/zahlungsverkehr/{id}', fn(array $p) => (new ZahlungsverkehrController())->speichern($p));
|
$router->post('/zahlungsverkehr/{id}', fn(array $p) => (new ZahlungsverkehrController())->speichern($p));
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,30 @@ class ZahlungsverkehrController extends BaseController
|
||||||
$this->zeige(FinTsUeberweisung::tanEinreichen($this->post('tan')));
|
$this->zeige(FinTsUeberweisung::tanEinreichen($this->post('tan')));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function vopFormular(): void
|
||||||
|
{
|
||||||
|
$this->verlangeAnmeldung();
|
||||||
|
|
||||||
|
$vop = FinTsUeberweisung::offeneVopAnfrage();
|
||||||
|
if ($vop === null) {
|
||||||
|
View::redirect('/zahlungsverkehr', 'Es wartet kein Namensabgleich auf eine Bestätigung.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->render('zahlungsverkehr/vop.php', [
|
||||||
|
'titel' => 'Namensabgleich prüfen',
|
||||||
|
'vop' => $vop,
|
||||||
|
'meldung' => 'Die Bank meldet eine Abweichung beim Namensabgleich.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function vopBestaetigen(): void
|
||||||
|
{
|
||||||
|
$this->verlangeAnmeldung();
|
||||||
|
|
||||||
|
$this->zeige(FinTsUeberweisung::vopBestaetigen());
|
||||||
|
}
|
||||||
|
|
||||||
public function tanAbbrechen(): void
|
public function tanAbbrechen(): void
|
||||||
{
|
{
|
||||||
$this->verlangeAnmeldung();
|
$this->verlangeAnmeldung();
|
||||||
|
|
@ -196,6 +220,15 @@ class ZahlungsverkehrController extends BaseController
|
||||||
/** @param array<string,mixed> $ergebnis */
|
/** @param array<string,mixed> $ergebnis */
|
||||||
private function zeige(array $ergebnis): void
|
private function zeige(array $ergebnis): void
|
||||||
{
|
{
|
||||||
|
if ($ergebnis['status'] === 'vop') {
|
||||||
|
$this->render('zahlungsverkehr/vop.php', [
|
||||||
|
'titel' => 'Namensabgleich prüfen',
|
||||||
|
'vop' => $ergebnis['vop'] ?? [],
|
||||||
|
'meldung' => $ergebnis['meldung'],
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($ergebnis['status'] === 'tan') {
|
if ($ergebnis['status'] === 'tan') {
|
||||||
$this->render('zahlungsverkehr/tan.php', [
|
$this->render('zahlungsverkehr/tan.php', [
|
||||||
'titel' => 'Überweisung freigeben',
|
'titel' => 'Überweisung freigeben',
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ use Fhp\BaseAction;
|
||||||
use Fhp\FinTs;
|
use Fhp\FinTs;
|
||||||
use Fhp\Model\SEPAAccount;
|
use Fhp\Model\SEPAAccount;
|
||||||
use Fhp\Model\TanRequestChallengeImage;
|
use Fhp\Model\TanRequestChallengeImage;
|
||||||
|
use Fhp\Model\VopConfirmationRequest;
|
||||||
|
use Fhp\Model\VopVerificationResult;
|
||||||
use Fhp\Options\Credentials;
|
use Fhp\Options\Credentials;
|
||||||
use Fhp\Options\FinTsOptions;
|
use Fhp\Options\FinTsOptions;
|
||||||
use Fhp\Segment\SPA\HISPAS;
|
use Fhp\Segment\SPA\HISPAS;
|
||||||
|
|
@ -31,6 +33,12 @@ use Finanzmanager\Models\Zahlungsauftrag;
|
||||||
* entsteht erst, wenn die Bank den Auftrag angenommen hat. Vorher wäre sie eine
|
* entsteht erst, wenn die Bank den Auftrag angenommen hat. Vorher wäre sie eine
|
||||||
* Behauptung – und eine, die man nach einer abgelehnten TAN wieder einsammeln
|
* Behauptung – und eine, die man nach einer abgelehnten TAN wieder einsammeln
|
||||||
* müsste.
|
* müsste.
|
||||||
|
*
|
||||||
|
* **Namensabgleich.** Seit Oktober 2025 gleicht die Bank vor der Ausführung den
|
||||||
|
* Empfängernamen mit dem Kontoinhaber ab (Verification of Payee). Stimmt beides
|
||||||
|
* überein, geht es ohne Rückfrage weiter zur TAN. Weicht der Name ab oder konnte
|
||||||
|
* die Bank nicht prüfen, wird zuerst gefragt – mit dem Text, den die Bank dazu
|
||||||
|
* mitliefert.
|
||||||
*/
|
*/
|
||||||
class FinTsUeberweisung
|
class FinTsUeberweisung
|
||||||
{
|
{
|
||||||
|
|
@ -39,6 +47,11 @@ class FinTsUeberweisung
|
||||||
private const PRODUKT = 'F399A6377D4D60E0A222D677A';
|
private const PRODUKT = 'F399A6377D4D60E0A222D677A';
|
||||||
private const PRODUKT_VERSION = '1.0';
|
private const PRODUKT_VERSION = '1.0';
|
||||||
|
|
||||||
|
/** So oft fragen wir die Bank nach dem Ergebnis des Namensabgleichs … */
|
||||||
|
private const POLLING_VERSUCHE = 10;
|
||||||
|
/** … und so lange warten wir dazwischen höchstens (Sekunden). */
|
||||||
|
private const POLLING_WARTEZEIT = 6;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string,mixed> $zugang mit entschlüsselter PIN
|
* @param array<string,mixed> $zugang mit entschlüsselter PIN
|
||||||
* @param array<string,mixed> $zustand
|
* @param array<string,mixed> $zustand
|
||||||
|
|
@ -53,7 +66,7 @@ class FinTsUeberweisung
|
||||||
/**
|
/**
|
||||||
* Startet das Absenden.
|
* Startet das Absenden.
|
||||||
*
|
*
|
||||||
* @return array{status: string, meldung: string, tan?: array<string,mixed>}
|
* @return array{status: string, meldung: string, tan?: array<string,mixed>, vop?: array<string,mixed>}
|
||||||
*/
|
*/
|
||||||
public static function senden(int $auftragId): array
|
public static function senden(int $auftragId): array
|
||||||
{
|
{
|
||||||
|
|
@ -86,7 +99,7 @@ class FinTsUeberweisung
|
||||||
/**
|
/**
|
||||||
* Setzt nach der TAN-Eingabe fort.
|
* Setzt nach der TAN-Eingabe fort.
|
||||||
*
|
*
|
||||||
* @return array{status: string, meldung: string, tan?: array<string,mixed>}
|
* @return array{status: string, meldung: string, tan?: array<string,mixed>, vop?: array<string,mixed>}
|
||||||
*/
|
*/
|
||||||
public static function tanEinreichen(string $tan): array
|
public static function tanEinreichen(string $tan): array
|
||||||
{
|
{
|
||||||
|
|
@ -104,6 +117,37 @@ class FinTsUeberweisung
|
||||||
return (new self($zugang, (int)$zustand['auftrag_id'], $zustand))->lauf(trim($tan));
|
return (new self($zugang, (int)$zustand['auftrag_id'], $zustand))->lauf(trim($tan));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt fort, nachdem der Nutzer den abweichenden Namensabgleich bestätigt
|
||||||
|
* hat: Die Bank bekommt die Freigabe und führt den Auftrag anschließend
|
||||||
|
* genauso aus wie ohne Abweichung – die TAN kommt danach.
|
||||||
|
*
|
||||||
|
* @return array{status: string, meldung: string, tan?: array<string,mixed>, vop?: array<string,mixed>}
|
||||||
|
*/
|
||||||
|
public static function vopBestaetigen(): array
|
||||||
|
{
|
||||||
|
$zustand = self::zustandLesen();
|
||||||
|
if ($zustand === null || ($zustand['schritt'] ?? '') !== 'vop') {
|
||||||
|
return ['status' => 'fehler', 'meldung' => 'Es wartet kein Namensabgleich auf eine Bestätigung.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$zugang = Bankzugang::zugangsdaten((int)$zustand['zugang_id']);
|
||||||
|
if ($zugang === null) {
|
||||||
|
self::vergessen();
|
||||||
|
return ['status' => 'fehler', 'meldung' => 'Die Bankverbindung gibt es nicht mehr.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (new self($zugang, (int)$zustand['auftrag_id'], $zustand))->lauf(null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string,mixed>|null */
|
||||||
|
public static function offeneVopAnfrage(): ?array
|
||||||
|
{
|
||||||
|
$zustand = self::zustandLesen();
|
||||||
|
|
||||||
|
return $zustand === null ? null : ($zustand['vop'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
public static function wartetAufTan(): bool
|
public static function wartetAufTan(): bool
|
||||||
{
|
{
|
||||||
return self::zustandLesen() !== null;
|
return self::zustandLesen() !== null;
|
||||||
|
|
@ -125,7 +169,7 @@ class FinTsUeberweisung
|
||||||
// ── Ablauf ───────────────────────────────────────────────────────────────
|
// ── Ablauf ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** @return array<string,mixed> */
|
/** @return array<string,mixed> */
|
||||||
private function lauf(?string $tan): array
|
private function lauf(?string $tan, bool $vopBestaetigt = false): array
|
||||||
{
|
{
|
||||||
$auftrag = Zahlungsauftrag::find($this->auftragId);
|
$auftrag = Zahlungsauftrag::find($this->auftragId);
|
||||||
if ($auftrag === null) {
|
if ($auftrag === null) {
|
||||||
|
|
@ -140,6 +184,18 @@ class FinTsUeberweisung
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Der Nutzer hat den abweichenden Namensabgleich bestätigt.
|
||||||
|
if ($vopBestaetigt) {
|
||||||
|
$aktion = $this->wartendeAktion();
|
||||||
|
if ($aktion === null) {
|
||||||
|
return $this->fehler('Der wartende Vorgang ist nicht mehr lesbar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$fints->confirmVop($aktion);
|
||||||
|
|
||||||
|
return $this->nachDemSenden($fints, $aktion, true);
|
||||||
|
}
|
||||||
|
|
||||||
// Eine wartende Aktion zuerst abschließen.
|
// Eine wartende Aktion zuerst abschließen.
|
||||||
if ($tan !== null && isset($this->zustand['aktion'])) {
|
if ($tan !== null && isset($this->zustand['aktion'])) {
|
||||||
$aktion = unserialize((string)$this->zustand['aktion'], ['allowed_classes' => true]);
|
$aktion = unserialize((string)$this->zustand['aktion'], ['allowed_classes' => true]);
|
||||||
|
|
@ -154,7 +210,7 @@ class FinTsUeberweisung
|
||||||
$fints->submitTan($aktion, $tan);
|
$fints->submitTan($aktion, $tan);
|
||||||
|
|
||||||
if ($this->zustand['schritt'] === 'senden') {
|
if ($this->zustand['schritt'] === 'senden') {
|
||||||
return $this->angenommen($fints);
|
return $this->nachDemSenden($fints, $aktion, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->zustand['schritt'] = $this->zustand['schritt'] === 'anmelden' ? 'konten' : 'senden';
|
$this->zustand['schritt'] = $this->zustand['schritt'] === 'anmelden' ? 'konten' : 'senden';
|
||||||
|
|
@ -207,11 +263,7 @@ class FinTsUeberweisung
|
||||||
$aktion = SendSEPATransfer::create($sepa, $xml);
|
$aktion = SendSEPATransfer::create($sepa, $xml);
|
||||||
$fints->execute($aktion);
|
$fints->execute($aktion);
|
||||||
|
|
||||||
if ($aktion->needsTan()) {
|
return $this->nachDemSenden($fints, $aktion);
|
||||||
return $this->tanNoetig($fints, $aktion, 'Überweisung', 'senden');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->angenommen($fints);
|
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
DebugLog::fehler($e);
|
DebugLog::fehler($e);
|
||||||
|
|
@ -243,6 +295,63 @@ class FinTsUeberweisung
|
||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was nach dem Einreichen des Auftrags noch zu tun ist: auf den
|
||||||
|
* Namensabgleich warten, ihn bestätigen lassen, die TAN einholen.
|
||||||
|
*
|
||||||
|
* @param bool $vopErledigt Ob die VOP-Bestätigung schon gegeben wurde – sie
|
||||||
|
* darf nicht zweimal automatisch erfolgen.
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function nachDemSenden(FinTs $fints, BaseAction $aktion, bool $vopErledigt = false): array
|
||||||
|
{
|
||||||
|
// Der Namensabgleich läuft bei der Bank; sie nennt selbst, wie lange sie
|
||||||
|
// dafür braucht. Nach einer Minute geben wir auf – der Auftrag bleibt
|
||||||
|
// dann Entwurf und lässt sich erneut senden.
|
||||||
|
for ($versuch = 0; $aktion->needsPollingWait() && $versuch < self::POLLING_VERSUCHE; $versuch++) {
|
||||||
|
$wartezeit = $aktion->getPollingInfo()?->getNextAttemptInSeconds() ?? 2;
|
||||||
|
sleep(max(1, min($wartezeit, self::POLLING_WARTEZEIT)));
|
||||||
|
$fints->pollAction($aktion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($aktion->needsPollingWait()) {
|
||||||
|
return $this->fehler('Die Bank hat den Namensabgleich nicht rechtzeitig abgeschlossen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($aktion->needsVopConfirmation()) {
|
||||||
|
$anfrage = $aktion->getVopConfirmationRequest();
|
||||||
|
|
||||||
|
// Stimmt der Name, ist die Rückfrage nur ein Klick ohne Erkenntnis –
|
||||||
|
// die Bank will die Bestätigung trotzdem sehen.
|
||||||
|
if (!$vopErledigt && $anfrage?->getVerificationResult() === VopVerificationResult::CompletedFullMatch) {
|
||||||
|
DebugLog::schreibe('fints', 'Namensabgleich ohne Abweichung – automatisch bestätigt.');
|
||||||
|
$fints->confirmVop($aktion);
|
||||||
|
|
||||||
|
return $this->nachDemSenden($fints, $aktion, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($anfrage !== null) {
|
||||||
|
return $this->vopNoetig($fints, $aktion, $anfrage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($aktion->needsTan()) {
|
||||||
|
return $this->tanNoetig($fints, $aktion, 'Überweisung', 'senden');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->angenommen($fints);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Vorgang, der in der Sitzung auf die Fortsetzung wartet.
|
||||||
|
*/
|
||||||
|
private function wartendeAktion(): ?BaseAction
|
||||||
|
{
|
||||||
|
$aktion = unserialize((string)($this->zustand['aktion'] ?? ''), ['allowed_classes' => true]);
|
||||||
|
|
||||||
|
return $aktion instanceof BaseAction ? $aktion : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string,mixed> $auftrag
|
* @param array<string,mixed> $auftrag
|
||||||
* @return array<string,mixed>
|
* @return array<string,mixed>
|
||||||
|
|
@ -298,6 +407,92 @@ class FinTsUeberweisung
|
||||||
return ['status' => 'erfolg', 'meldung' => 'Die Überweisung wurde an die Bank übergeben.'];
|
return ['status' => 'erfolg', 'meldung' => 'Die Überweisung wurde an die Bank übergeben.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Namensabgleich weicht ab – der Nutzer muss entscheiden.
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function vopNoetig(FinTs $fints, BaseAction $aktion, VopConfirmationRequest $anfrage): array
|
||||||
|
{
|
||||||
|
$auftrag = Zahlungsauftrag::find($this->auftragId);
|
||||||
|
|
||||||
|
$vop = [
|
||||||
|
'ergebnis' => (string)($anfrage->getVerificationResult() ?? ''),
|
||||||
|
'urteil' => self::vopUrteil($anfrage->getVerificationResult()),
|
||||||
|
'grund' => (string)($anfrage->getVerificationNotApplicableReason() ?? ''),
|
||||||
|
'text' => self::vopHinweis((string)($anfrage->getInformationForUser() ?? '')),
|
||||||
|
'gueltig_bis' => $anfrage->getExpiration()?->format('d.m.Y H:i'),
|
||||||
|
// Den hinterlegten Kontoinhaber nennt die Bank nur, wenn der Name
|
||||||
|
// nahezu übereinstimmt – als Entscheidungshilfe. Weicht er ganz ab,
|
||||||
|
// bleibt er geheim; sonst ließen sich über beliebige IBANs
|
||||||
|
// Kontoinhaber ausspähen.
|
||||||
|
'hinterlegt' => (string)($anfrage->getDifferingPayeeName() ?? ''),
|
||||||
|
'empfaenger' => (string)($auftrag['empfaenger'] ?? ''),
|
||||||
|
'iban' => (string)($auftrag['iban'] ?? ''),
|
||||||
|
];
|
||||||
|
|
||||||
|
DebugLog::schreibe('fints', 'Namensabgleich mit Abweichung: ' . $vop['ergebnis']);
|
||||||
|
|
||||||
|
self::zustandSchreiben([
|
||||||
|
'schritt' => 'vop',
|
||||||
|
'aktion' => serialize($aktion),
|
||||||
|
'dialog' => $fints->persist(),
|
||||||
|
'sepa' => $this->zustand['sepa'] ?? null,
|
||||||
|
'vop' => $vop,
|
||||||
|
'zugang_id' => (int)$this->zugang['id'],
|
||||||
|
'auftrag_id' => $this->auftragId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'vop',
|
||||||
|
'meldung' => 'Die Bank meldet eine Abweichung beim Namensabgleich.',
|
||||||
|
'vop' => $vop,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Hinweistext der Bank als Klartext.
|
||||||
|
*
|
||||||
|
* Die Banken liefern hier HTML. Übernommen werden nur die Absätze als
|
||||||
|
* Zeilenumbrüche; die Auszeichnung fliegt weg – fremdes Markup gehört nicht
|
||||||
|
* ungeprüft in die eigene Seite.
|
||||||
|
*/
|
||||||
|
public static function vopHinweis(string $html): string
|
||||||
|
{
|
||||||
|
if (trim($html) === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = preg_replace('#<(br|/p|/div|/li|/h[1-6])[^>]*>#i', "\n", $html) ?? $html;
|
||||||
|
$text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
$text = preg_replace('/[ \t]+/', ' ', $text) ?? $text;
|
||||||
|
$text = preg_replace("/\n{3,}/", "\n\n", $text) ?? $text;
|
||||||
|
|
||||||
|
return trim(implode("\n", array_map('trim', explode("\n", $text))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Prüfergebnis der Bank in einem Satz – die englischen Codes der
|
||||||
|
* Bibliothek sagen der Partnerin nichts.
|
||||||
|
*/
|
||||||
|
public static function vopUrteil(?string $ergebnis): string
|
||||||
|
{
|
||||||
|
return match ($ergebnis) {
|
||||||
|
VopVerificationResult::CompletedFullMatch =>
|
||||||
|
'Der Empfängername stimmt mit dem Kontoinhaber überein.',
|
||||||
|
VopVerificationResult::CompletedCloseMatch =>
|
||||||
|
'Der Empfängername stimmt nur nahezu mit dem Kontoinhaber überein.',
|
||||||
|
VopVerificationResult::CompletedNoMatch =>
|
||||||
|
'Der Empfängername stimmt nicht mit dem Kontoinhaber überein.',
|
||||||
|
VopVerificationResult::CompletedPartialMatch =>
|
||||||
|
'Nicht jeder Empfänger konnte bestätigt werden.',
|
||||||
|
VopVerificationResult::NotApplicable =>
|
||||||
|
'Die Bank konnte den Empfängernamen nicht prüfen.',
|
||||||
|
default =>
|
||||||
|
'Die Bank hat kein eindeutiges Ergebnis zum Empfängernamen geliefert.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array<string,mixed> */
|
/** @return array<string,mixed> */
|
||||||
private function tanNoetig(FinTs $fints, BaseAction $aktion, string $bezeichnung, string $schritt): array
|
private function tanNoetig(FinTs $fints, BaseAction $aktion, string $bezeichnung, string $schritt): array
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,18 @@ class SepaXml
|
||||||
/** Die Formate, die hier erzeugt werden – in der Reihenfolge des Vorzugs. */
|
/** Die Formate, die hier erzeugt werden – in der Reihenfolge des Vorzugs. */
|
||||||
private const SCHEMATA = [self::SCHEMA_PAIN_001_001_03, self::SCHEMA_PAIN_001_003_03];
|
private const SCHEMATA = [self::SCHEMA_PAIN_001_001_03, self::SCHEMA_PAIN_001_003_03];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Ausführungsdatum für „sofort“.
|
||||||
|
*
|
||||||
|
* Das Format verlangt ein Datum, auch wenn keins gewünscht ist. Die Deutsche
|
||||||
|
* Kreditwirtschaft hat dafür den 01.01.1999 vorgesehen; erst daran erkennt
|
||||||
|
* die Bank eine gewöhnliche Überweisung (HKCCS). Steht dort ein echtes
|
||||||
|
* Datum – und sei es das von heute –, wird daraus eine terminierte
|
||||||
|
* Überweisung (HKCSE), und die weist die Bank mit „Die minimale Vorlaufzeit
|
||||||
|
* beträgt 1 Tage“ zurück.
|
||||||
|
*/
|
||||||
|
public const SOFORT = '1999-01-01';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{
|
* @param array{
|
||||||
* absender_name: string, absender_iban: string, absender_bic?: string,
|
* absender_name: string, absender_iban: string, absender_bic?: string,
|
||||||
|
|
@ -48,9 +60,7 @@ class SepaXml
|
||||||
$kennung = 'FM' . date('YmdHis') . substr(bin2hex(random_bytes(4)), 0, 6);
|
$kennung = 'FM' . date('YmdHis') . substr(bin2hex(random_bytes(4)), 0, 6);
|
||||||
$zeitpunkt = date('Y-m-d\TH:i:s');
|
$zeitpunkt = date('Y-m-d\TH:i:s');
|
||||||
|
|
||||||
// Ohne Termin führt die Bank sofort aus. Der Wert muss trotzdem
|
$ausfuehrung = self::ausfuehrungstag((string)($auftrag['ausfuehrung_am'] ?? ''));
|
||||||
// dastehen; das Format verlangt ihn.
|
|
||||||
$ausfuehrung = (string)($auftrag['ausfuehrung_am'] ?? '') ?: date('Y-m-d');
|
|
||||||
|
|
||||||
$waehrung = strtoupper((string)($auftrag['waehrung'] ?? 'EUR'));
|
$waehrung = strtoupper((string)($auftrag['waehrung'] ?? 'EUR'));
|
||||||
$zweck = self::text((string)($auftrag['verwendungszweck'] ?? ''), 140);
|
$zweck = self::text((string)($auftrag['verwendungszweck'] ?? ''), 140);
|
||||||
|
|
@ -159,6 +169,22 @@ class SepaXml
|
||||||
return (string)$dokument->saveXML();
|
return (string)$dokument->saveXML();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Tag, an dem die Bank ausführen soll.
|
||||||
|
*
|
||||||
|
* Ohne Termin – und ebenso bei einem Termin, der heute oder schon vorbei ist –
|
||||||
|
* ist „sofort“ gemeint: Dann steht das Platzhalterdatum `SOFORT` in der
|
||||||
|
* Nachricht. Ein Termin in der Zukunft bleibt stehen und macht daraus eine
|
||||||
|
* terminierte Überweisung.
|
||||||
|
*/
|
||||||
|
public static function ausfuehrungstag(string $termin, ?string $heute = null): string
|
||||||
|
{
|
||||||
|
$termin = trim($termin);
|
||||||
|
$heute = $heute ?? date('Y-m-d');
|
||||||
|
|
||||||
|
return $termin === '' || $termin <= $heute ? self::SOFORT : $termin;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Das Format, das zu den von der Bank gemeldeten SEPA-Datenformaten passt.
|
* Das Format, das zu den von der Bank gemeldeten SEPA-Datenformaten passt.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
90
src/Views/zahlungsverkehr/vop.php
Normal file
90
src/Views/zahlungsverkehr/vop.php
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Finanzmanager\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Namensabgleich der Bank weicht ab (Verification of Payee).
|
||||||
|
*
|
||||||
|
* Die Bank hat den Empfängernamen mit dem Kontoinhaber verglichen und meldet
|
||||||
|
* eine Abweichung. Hier steht die Entscheidung: trotzdem senden oder abbrechen
|
||||||
|
* und den Empfänger korrigieren. Der Text der Bank wird wortgleich
|
||||||
|
* wiedergegeben – ohne die Auszeichnungen, die sie mitschickt.
|
||||||
|
*
|
||||||
|
* @var array<string,mixed> $vop
|
||||||
|
* @var string $meldung
|
||||||
|
* @var string $csrfToken
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Der Text kommt bereits als Klartext aus `FinTsUeberweisung::vopHinweis()`.
|
||||||
|
$banktext = (string)($vop['text'] ?? '');
|
||||||
|
|
||||||
|
include __DIR__ . '/../layout/kopf.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="kachel einzeln">
|
||||||
|
<header>
|
||||||
|
<h2>Namensabgleich prüfen</h2>
|
||||||
|
<p class="kachelhinweis"><?= View::h($vop['urteil'] ?? '') ?></p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p class="hinweis"><?= View::h($meldung) ?></p>
|
||||||
|
|
||||||
|
<dl class="merkmale">
|
||||||
|
<?php if (($vop['empfaenger'] ?? '') !== ''): ?>
|
||||||
|
<dt>So lautet der Empfänger im Auftrag</dt>
|
||||||
|
<dd><?= View::h($vop['empfaenger']) ?></dd>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (($vop['hinterlegt'] ?? '') !== ''): ?>
|
||||||
|
<dt>So lautet er bei der Bank des Empfängers</dt>
|
||||||
|
<dd><?= View::h($vop['hinterlegt']) ?></dd>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (($vop['iban'] ?? '') !== ''): ?>
|
||||||
|
<dt>Empfänger-IBAN</dt>
|
||||||
|
<dd><?= View::h($vop['iban']) ?></dd>
|
||||||
|
<?php endif; ?>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<?php if (($vop['hinterlegt'] ?? '') === ''): ?>
|
||||||
|
<p class="feldhinweis">
|
||||||
|
Den Namen, der bei der Bank des Empfängers hinterlegt ist, nennt die Bank nur,
|
||||||
|
wenn er dem angegebenen nahezu entspricht. Weicht er ganz ab, bleibt er geheim –
|
||||||
|
sonst ließen sich über beliebige IBANs Kontoinhaber ausspähen.
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (($vop['grund'] ?? '') !== ''): ?>
|
||||||
|
<p class="feldhinweis">Begründung der Bank: <?= View::h($vop['grund']) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($banktext !== ''): ?>
|
||||||
|
<p class="tanhinweis"><?= nl2br(View::h($banktext)) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (($vop['gueltig_bis'] ?? null) !== null): ?>
|
||||||
|
<p class="feldhinweis">Die Bank hält das Ergebnis bis <?= View::h($vop['gueltig_bis']) ?> vor.</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="post" action="/zahlungsverkehr/vop" class="formular">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||||
|
<div class="knopfleiste">
|
||||||
|
<button type="submit" class="knopf primaer">Trotzdem senden</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="/zahlungsverkehr/tan/abbrechen" class="formular">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||||
|
<div class="knopfleiste">
|
||||||
|
<button type="submit" class="knopf">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="feldhinweis">
|
||||||
|
Beim Abbrechen bleibt der Auftrag als Entwurf stehen; der Empfängername lässt sich
|
||||||
|
dann korrigieren und der Auftrag erneut senden. Wird trotz Abweichung gesendet,
|
||||||
|
haftet die Bank nicht, wenn das Geld beim falschen Empfänger landet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||||
|
|
@ -28,6 +28,49 @@ class SepaSchemaTest extends TestCase
|
||||||
$this->unbekannteFormateFuehrenZuNull();
|
$this->unbekannteFormateFuehrenZuNull();
|
||||||
$this->dkFormatIstSchemakonform();
|
$this->dkFormatIstSchemakonform();
|
||||||
$this->dasFormatStehtImNamensraum();
|
$this->dasFormatStehtImNamensraum();
|
||||||
|
$this->ohneTerminWirdSofortUeberwiesen();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ohne Termin muss das Platzhalterdatum der Deutschen Kreditwirtschaft in
|
||||||
|
* der Nachricht stehen. Steht dort ein echtes Datum, macht die Bibliothek
|
||||||
|
* daraus eine terminierte Überweisung (HKCSE statt HKCCS) – und die Bank
|
||||||
|
* weist sie zurück: „Die minimale Vorlaufzeit beträgt 1 Tage.“
|
||||||
|
*/
|
||||||
|
private function ohneTerminWirdSofortUeberwiesen(): void
|
||||||
|
{
|
||||||
|
$this->assertEquals(
|
||||||
|
SepaXml::SOFORT,
|
||||||
|
SepaXml::ausfuehrungstag('', '2026-09-01'),
|
||||||
|
'Ohne Termin gilt sofort'
|
||||||
|
);
|
||||||
|
$this->assertEquals(
|
||||||
|
SepaXml::SOFORT,
|
||||||
|
SepaXml::ausfuehrungstag('2026-09-01', '2026-09-01'),
|
||||||
|
'Der heutige Tag ist ebenfalls sofort – sonst verlangt die Bank Vorlaufzeit'
|
||||||
|
);
|
||||||
|
$this->assertEquals(
|
||||||
|
SepaXml::SOFORT,
|
||||||
|
SepaXml::ausfuehrungstag('2026-08-30', '2026-09-01'),
|
||||||
|
'Ein verstrichener Termin auch'
|
||||||
|
);
|
||||||
|
$this->assertEquals(
|
||||||
|
'2026-09-15',
|
||||||
|
SepaXml::ausfuehrungstag('2026-09-15', '2026-09-01'),
|
||||||
|
'Ein Termin in der Zukunft bleibt stehen'
|
||||||
|
);
|
||||||
|
|
||||||
|
$auftrag = $this->auftraege()['ohne BIC'];
|
||||||
|
$this->assert(
|
||||||
|
str_contains(SepaXml::ueberweisung($auftrag), '<ReqdExctnDt>' . SepaXml::SOFORT . '</ReqdExctnDt>'),
|
||||||
|
'Und das Platzhalterdatum steht auch wirklich in der Nachricht'
|
||||||
|
);
|
||||||
|
|
||||||
|
$auftrag['ausfuehrung_am'] = date('Y-m-d', strtotime('+30 days'));
|
||||||
|
$this->assert(
|
||||||
|
str_contains(SepaXml::ueberweisung($auftrag), '<ReqdExctnDt>' . $auftrag['ausfuehrung_am'] . '</ReqdExctnDt>'),
|
||||||
|
'Ein Termin in der Zukunft steht unverändert drin'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function schemaFolgtDenAngabenDerBank(): void
|
private function schemaFolgtDenAngabenDerBank(): void
|
||||||
|
|
@ -131,7 +174,7 @@ class SepaSchemaTest extends TestCase
|
||||||
'betrag' => 123.45,
|
'betrag' => 123.45,
|
||||||
'waehrung' => 'EUR',
|
'waehrung' => 'EUR',
|
||||||
'verwendungszweck' => 'Abschlag Strom & Wasser 09/2026',
|
'verwendungszweck' => 'Abschlag Strom & Wasser 09/2026',
|
||||||
'ausfuehrung_am' => '2026-09-05',
|
'ausfuehrung_am' => '2099-09-05',
|
||||||
],
|
],
|
||||||
'ohne BIC' => [
|
'ohne BIC' => [
|
||||||
'absender_name' => 'Max Mustermann',
|
'absender_name' => 'Max Mustermann',
|
||||||
|
|
|
||||||
86
tests/40_NamensabgleichTest.php
Normal file
86
tests/40_NamensabgleichTest.php
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Fhp\Model\VopVerificationResult;
|
||||||
|
use Finanzmanager\Services\FinTsUeberweisung;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Namensabgleich vor der Überweisung (Verification of Payee).
|
||||||
|
*
|
||||||
|
* Seit Oktober 2025 gleicht die Bank den Empfängernamen mit dem Kontoinhaber
|
||||||
|
* ab. Stimmt beides überein, bestätigt der Finanzmanager selbst und geht direkt
|
||||||
|
* zur TAN; weicht der Name ab, wird gefragt. Geprüft wird hier, was sich ohne
|
||||||
|
* Bankverbindung prüfen lässt: die Aufbereitung dessen, was die Bank meldet,
|
||||||
|
* und dass ohne wartenden Vorgang niemand etwas bestätigen kann.
|
||||||
|
*/
|
||||||
|
class NamensabgleichTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 40: Namensabgleich vor der Überweisung';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->jedesErgebnisHatEinenDeutschenSatz();
|
||||||
|
$this->derBanktextWirdKlartext();
|
||||||
|
$this->ohneWartendenVorgangGehtNichts();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function jedesErgebnisHatEinenDeutschenSatz(): void
|
||||||
|
{
|
||||||
|
$ergebnisse = [
|
||||||
|
VopVerificationResult::CompletedFullMatch,
|
||||||
|
VopVerificationResult::CompletedCloseMatch,
|
||||||
|
VopVerificationResult::CompletedNoMatch,
|
||||||
|
VopVerificationResult::CompletedPartialMatch,
|
||||||
|
VopVerificationResult::NotApplicable,
|
||||||
|
];
|
||||||
|
|
||||||
|
$saetze = [];
|
||||||
|
foreach ($ergebnisse as $ergebnis) {
|
||||||
|
$satz = FinTsUeberweisung::vopUrteil($ergebnis);
|
||||||
|
$this->assertNotEmpty($satz, 'Ergebnis ' . $ergebnis . ' hat einen Satz');
|
||||||
|
$this->assert(
|
||||||
|
!str_contains($satz, 'Completed') && !str_contains($satz, 'Match'),
|
||||||
|
'Ergebnis ' . $ergebnis . ' steht auf Deutsch da'
|
||||||
|
);
|
||||||
|
$saetze[] = $satz;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertEquals(count($ergebnisse), count(array_unique($saetze)), 'Jedes Ergebnis hat seinen eigenen Satz');
|
||||||
|
|
||||||
|
// Auch ein unbekanntes oder fehlendes Ergebnis darf den Dialog nicht leer lassen.
|
||||||
|
$this->assertNotEmpty(FinTsUeberweisung::vopUrteil(null), 'Ohne Ergebnis steht trotzdem ein Satz da');
|
||||||
|
$this->assertNotEmpty(FinTsUeberweisung::vopUrteil('Unbekannt'), 'Unbekanntes Ergebnis ebenso');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function derBanktextWirdKlartext(): void
|
||||||
|
{
|
||||||
|
// So liefern die Banken den Aufklärungstext: HTML mit Auszeichnung.
|
||||||
|
$html = 'Der Name stimmt <b>nicht</b> überein.<br>Bitte prüfen.'
|
||||||
|
. '<p><b>Wichtiger Hinweis:</b> Die Überweisung wird ohne Korrektur ausgeführt.</p>';
|
||||||
|
|
||||||
|
$text = FinTsUeberweisung::vopHinweis($html);
|
||||||
|
|
||||||
|
$this->assert(!str_contains($text, '<'), 'Die Auszeichnung ist weg');
|
||||||
|
$this->assert(str_contains($text, 'Der Name stimmt nicht überein.'), 'Der Satz bleibt vollständig');
|
||||||
|
$this->assert(str_contains($text, 'Bitte prüfen.'), 'Entities werden aufgelöst');
|
||||||
|
$this->assert(str_contains($text, "überein.\nBitte prüfen."), 'Aus <br> wird ein Zeilenumbruch');
|
||||||
|
$this->assert(
|
||||||
|
str_contains($text, 'Wichtiger Hinweis: Die Überweisung wird ohne Korrektur ausgeführt.'),
|
||||||
|
'Auch der letzte Absatz steht da'
|
||||||
|
);
|
||||||
|
$this->assert(!str_contains($text, "\n\n\n"), 'Keine Leerzeilenwüsten');
|
||||||
|
|
||||||
|
$this->assertEquals('', FinTsUeberweisung::vopHinweis(''), 'Ohne Text bleibt es leer');
|
||||||
|
$this->assertEquals('', FinTsUeberweisung::vopHinweis(' '), 'Leerraum zählt als kein Text');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ohneWartendenVorgangGehtNichts(): void
|
||||||
|
{
|
||||||
|
$this->assertNull(FinTsUeberweisung::offeneVopAnfrage(), 'Ohne Vorgang wartet kein Namensabgleich');
|
||||||
|
|
||||||
|
$ergebnis = FinTsUeberweisung::vopBestaetigen();
|
||||||
|
$this->assertEquals('fehler', $ergebnis['status'], 'Ohne Vorgang lässt sich nichts bestätigen');
|
||||||
|
$this->assertNotEmpty($ergebnis['meldung'], 'Und es gibt eine Erklärung dazu');
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue