diff --git a/Changes.md b/Changes.md index 02cbebf..e04fc31 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,34 @@ # Ä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) Die Überweisung wurde bisher immer als `pain.001.001.03` eingereicht. Banken, die nur diff --git a/composer.json b/composer.json index fb168e6..c184d12 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ ], "require": { "php": ">=8.3", - "nemiah/php-fints": "4.1.1" + "nemiah/php-fints": "4.1.3" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index 0ec6fa7..ffc2103 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c9c68471574e1ff2ea7b78c3d71cdfd3", + "content-hash": "2ed8c4171d96c85182d0ca57fb9c0a87", "packages": [ { "name": "nemiah/php-fints", - "version": "v4.1.1", + "version": "v4.1.3", "source": { "type": "git", "url": "https://codeberg.org/fds2610-bot/phpFinTS.git", - "reference": "6ebd8e0ec1287dac384d404ca6311ba8169ee545" + "reference": "76e3c648e88c2e5608044595d5920ba3ffee70a5" }, "dist": { "type": "zip", - "url": "https://codeberg.org/api/v1/repos/fds2610-bot/phpFinTS/archive/6ebd8e0ec1287dac384d404ca6311ba8169ee545.zip", - "reference": "6ebd8e0ec1287dac384d404ca6311ba8169ee545", + "url": "https://codeberg.org/api/v1/repos/fds2610-bot/phpFinTS/archive/76e3c648e88c2e5608044595d5920ba3ffee70a5.zip", + "reference": "76e3c648e88c2e5608044595d5920ba3ffee70a5", "shasum": "" }, "require": { @@ -67,10 +67,10 @@ "description": "PHP Library for the protocols fints and hbci", "homepage": "https://codeberg.org/fds2610-bot/phpFinTS", "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" }, - "time": "2026-08-11T18:48:38+02:00" + "time": "2026-09-01T19:22:02+02:00" } ], "packages-dev": [], diff --git a/docs/banking.md b/docs/banking.md index f15e414..2317b75 100644 --- a/docs/banking.md +++ b/docs/banking.md @@ -19,7 +19,7 @@ passende Zugangsdaten noch passende Konten hätte. Verwendet wird `nemiah/php-fints`, aber nicht von Packagist, sondern als **Fork auf 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 `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, 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 Beim Anlegen einer FinTS-Verbindung fehlt in aller Regel genau eine Angabe: der diff --git a/docs/nebenmodule.md b/docs/nebenmodule.md index 3793d04..7509111 100644 --- a/docs/nebenmodule.md +++ b/docs/nebenmodule.md @@ -133,10 +133,16 @@ Terminüberweisung; sie erscheint dann im Finanzkalender. ### SEPA-XML von Hand -php-fints erwartet die fertige **pain.001.001.03**-Nachricht und erzeugt sie nicht. Die -üblichen Erzeuger wären zwei weitere Abhängigkeiten für ein Dokument von sechzig Zeilen – +php-fints erwartet die fertige **pain.001**-Nachricht und erzeugt sie nicht. Die üblichen +Erzeuger wären zwei weitere Abhängigkeiten für ein Dokument von sechzig Zeilen – `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. 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. @@ -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 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 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, zu kleine Rate), Zahlungsaufträge (Buchung erst nach Annahme, Unveränderlichkeit, 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. diff --git a/public/index.php b/public/index.php index a104e6e..312d930 100644 --- a/public/index.php +++ b/public/index.php @@ -251,6 +251,8 @@ $router->post('/zahlungsverkehr/neu', fn() => (new ZahlungsverkehrController())- $router->get('/zahlungsverkehr/tan', fn() => (new ZahlungsverkehrController())->tanFormular()); $router->post('/zahlungsverkehr/tan', fn() => (new ZahlungsverkehrController())->tanEinreichen()); $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->post('/zahlungsverkehr/{id}', fn(array $p) => (new ZahlungsverkehrController())->speichern($p)); diff --git a/src/Controllers/ZahlungsverkehrController.php b/src/Controllers/ZahlungsverkehrController.php index 581026e..5cd9d7c 100644 --- a/src/Controllers/ZahlungsverkehrController.php +++ b/src/Controllers/ZahlungsverkehrController.php @@ -184,6 +184,30 @@ class ZahlungsverkehrController extends BaseController $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 { $this->verlangeAnmeldung(); @@ -196,6 +220,15 @@ class ZahlungsverkehrController extends BaseController /** @param array $ergebnis */ 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') { $this->render('zahlungsverkehr/tan.php', [ 'titel' => 'Überweisung freigeben', diff --git a/src/Services/FinTsUeberweisung.php b/src/Services/FinTsUeberweisung.php index fea0129..d8322af 100644 --- a/src/Services/FinTsUeberweisung.php +++ b/src/Services/FinTsUeberweisung.php @@ -10,6 +10,8 @@ use Fhp\BaseAction; use Fhp\FinTs; use Fhp\Model\SEPAAccount; use Fhp\Model\TanRequestChallengeImage; +use Fhp\Model\VopConfirmationRequest; +use Fhp\Model\VopVerificationResult; use Fhp\Options\Credentials; use Fhp\Options\FinTsOptions; 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 * Behauptung – und eine, die man nach einer abgelehnten TAN wieder einsammeln * 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 { @@ -39,6 +47,11 @@ class FinTsUeberweisung private const PRODUKT = 'F399A6377D4D60E0A222D677A'; 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 $zugang mit entschlüsselter PIN * @param array $zustand @@ -53,7 +66,7 @@ class FinTsUeberweisung /** * Startet das Absenden. * - * @return array{status: string, meldung: string, tan?: array} + * @return array{status: string, meldung: string, tan?: array, vop?: array} */ public static function senden(int $auftragId): array { @@ -86,7 +99,7 @@ class FinTsUeberweisung /** * Setzt nach der TAN-Eingabe fort. * - * @return array{status: string, meldung: string, tan?: array} + * @return array{status: string, meldung: string, tan?: array, vop?: 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)); } + /** + * 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, vop?: array} + */ + 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|null */ + public static function offeneVopAnfrage(): ?array + { + $zustand = self::zustandLesen(); + + return $zustand === null ? null : ($zustand['vop'] ?? null); + } + public static function wartetAufTan(): bool { return self::zustandLesen() !== null; @@ -125,7 +169,7 @@ class FinTsUeberweisung // ── Ablauf ─────────────────────────────────────────────────────────────── /** @return array */ - private function lauf(?string $tan): array + private function lauf(?string $tan, bool $vopBestaetigt = false): array { $auftrag = Zahlungsauftrag::find($this->auftragId); if ($auftrag === null) { @@ -140,6 +184,18 @@ class FinTsUeberweisung } 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. if ($tan !== null && isset($this->zustand['aktion'])) { $aktion = unserialize((string)$this->zustand['aktion'], ['allowed_classes' => true]); @@ -154,7 +210,7 @@ class FinTsUeberweisung $fints->submitTan($aktion, $tan); if ($this->zustand['schritt'] === 'senden') { - return $this->angenommen($fints); + return $this->nachDemSenden($fints, $aktion, true); } $this->zustand['schritt'] = $this->zustand['schritt'] === 'anmelden' ? 'konten' : 'senden'; @@ -207,11 +263,7 @@ class FinTsUeberweisung $aktion = SendSEPATransfer::create($sepa, $xml); $fints->execute($aktion); - if ($aktion->needsTan()) { - return $this->tanNoetig($fints, $aktion, 'Überweisung', 'senden'); - } - - return $this->angenommen($fints); + return $this->nachDemSenden($fints, $aktion); } } catch (\Throwable $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 + */ + 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 $auftrag * @return array @@ -298,6 +407,92 @@ class FinTsUeberweisung return ['status' => 'erfolg', 'meldung' => 'Die Überweisung wurde an die Bank übergeben.']; } + /** + * Der Namensabgleich weicht ab – der Nutzer muss entscheiden. + * + * @return array + */ + 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 */ private function tanNoetig(FinTs $fints, BaseAction $aktion, string $bezeichnung, string $schritt): array { diff --git a/src/Services/SepaXml.php b/src/Services/SepaXml.php index 4707025..1b3dae8 100644 --- a/src/Services/SepaXml.php +++ b/src/Services/SepaXml.php @@ -27,6 +27,18 @@ class SepaXml /** 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]; + /** + * 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{ * 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); $zeitpunkt = date('Y-m-d\TH:i:s'); - // Ohne Termin führt die Bank sofort aus. Der Wert muss trotzdem - // dastehen; das Format verlangt ihn. - $ausfuehrung = (string)($auftrag['ausfuehrung_am'] ?? '') ?: date('Y-m-d'); + $ausfuehrung = self::ausfuehrungstag((string)($auftrag['ausfuehrung_am'] ?? '')); $waehrung = strtoupper((string)($auftrag['waehrung'] ?? 'EUR')); $zweck = self::text((string)($auftrag['verwendungszweck'] ?? ''), 140); @@ -159,6 +169,22 @@ class SepaXml 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. * diff --git a/src/Views/zahlungsverkehr/vop.php b/src/Views/zahlungsverkehr/vop.php new file mode 100644 index 0000000..09a1e17 --- /dev/null +++ b/src/Views/zahlungsverkehr/vop.php @@ -0,0 +1,90 @@ + $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'; +?> + +
+
+

Namensabgleich prüfen

+

+
+ +

+ +
+ +
So lautet der Empfänger im Auftrag
+
+ + + +
So lautet er bei der Bank des Empfängers
+
+ + + +
Empfänger-IBAN
+
+ +
+ + +

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

+ + + +

Begründung der Bank:

+ + + +

+ + + +

Die Bank hält das Ergebnis bis vor.

+ + +
+ +
+ +
+
+ +
+ +
+ +
+
+ +

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

+
+ + diff --git a/tests/39_SepaSchemaTest.php b/tests/39_SepaSchemaTest.php index c527a0a..0e21453 100644 --- a/tests/39_SepaSchemaTest.php +++ b/tests/39_SepaSchemaTest.php @@ -28,6 +28,49 @@ class SepaSchemaTest extends TestCase $this->unbekannteFormateFuehrenZuNull(); $this->dkFormatIstSchemakonform(); $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), '' . SepaXml::SOFORT . ''), + '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), '' . $auftrag['ausfuehrung_am'] . ''), + 'Ein Termin in der Zukunft steht unverändert drin' + ); } private function schemaFolgtDenAngabenDerBank(): void @@ -131,7 +174,7 @@ class SepaSchemaTest extends TestCase 'betrag' => 123.45, 'waehrung' => 'EUR', 'verwendungszweck' => 'Abschlag Strom & Wasser 09/2026', - 'ausfuehrung_am' => '2026-09-05', + 'ausfuehrung_am' => '2099-09-05', ], 'ohne BIC' => [ 'absender_name' => 'Max Mustermann', diff --git a/tests/40_NamensabgleichTest.php b/tests/40_NamensabgleichTest.php new file mode 100644 index 0000000..aa5a8ce --- /dev/null +++ b/tests/40_NamensabgleichTest.php @@ -0,0 +1,86 @@ +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 nicht überein.
Bitte prüfen.' + . '

Wichtiger Hinweis: Die Überweisung wird ohne Korrektur ausgeführt.

'; + + $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
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'); + } +}