Merge pull request 'Fix #56: Namensabgleich vor der Überweisung' (#57) from fds2610-bot/Finanzmanager:main into main

Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/57
This commit is contained in:
fds2610 2026-09-01 20:07:44 +02:00
commit e425c76975
12 changed files with 591 additions and 24 deletions

View file

@ -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

View file

@ -11,7 +11,7 @@
],
"require": {
"php": ">=8.3",
"nemiah/php-fints": "4.1.1"
"nemiah/php-fints": "4.1.3"
},
"autoload": {
"psr-4": {

14
composer.lock generated
View file

@ -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": [],

View file

@ -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

View file

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

View file

@ -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));

View file

@ -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<string,mixed> $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',

View file

@ -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<string,mixed> $zugang mit entschlüsselter PIN
* @param array<string,mixed> $zustand
@ -53,7 +66,7 @@ class FinTsUeberweisung
/**
* 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
{
@ -86,7 +99,7 @@ class FinTsUeberweisung
/**
* 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
{
@ -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<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
{
return self::zustandLesen() !== null;
@ -125,7 +169,7 @@ class FinTsUeberweisung
// ── Ablauf ───────────────────────────────────────────────────────────────
/** @return array<string,mixed> */
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<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
* @return array<string,mixed>
@ -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<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> */
private function tanNoetig(FinTs $fints, BaseAction $aktion, string $bezeichnung, string $schritt): array
{

View file

@ -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.
*

View 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'; ?>

View file

@ -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), '<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
@ -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',

View 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&uuml;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');
}
}