Finanzmanager/src/Services/FinTsUeberweisung.php
fds2610-bot 79f617025b 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
2026-09-01 19:58:12 +02:00

599 lines
22 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Fhp\Action\GetSEPAAccounts;
use Fhp\Action\SendSEPATransfer;
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;
use Finanzmanager\Auth;
use Finanzmanager\Models\Bankzugang;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Zahlungsauftrag;
/**
* Sendet einen Zahlungsauftrag über FinTS ab.
*
* Aufgebaut wie `FinTsAbruf`, nur kürzer: anmelden, Konto suchen, Überweisung
* einreichen. Eine TAN ist hier die Regel und nicht die Ausnahme – für
* Überweisungen verlangt sie jede Bank. Der Dialog wird deshalb genauso über
* die Sitzung fortgesetzt.
*
* **Erst die Bank, dann die Buchung.** Die Buchung auf dem Auftragskonto
* 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
{
public const SITZUNG = 'fints_ueberweisung';
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
*/
private function __construct(
private array $zugang,
private int $auftragId,
private array $zustand
) {
}
/**
* Startet das Absenden.
*
* @return array{status: string, meldung: string, tan?: array<string,mixed>, vop?: array<string,mixed>}
*/
public static function senden(int $auftragId): array
{
$auftrag = Zahlungsauftrag::find($auftragId);
if ($auftrag === null) {
return ['status' => 'fehler', 'meldung' => 'Den Zahlungsauftrag gibt es nicht.'];
}
if ($auftrag['status'] === 'gesendet') {
return ['status' => 'fehler', 'meldung' => 'Dieser Auftrag wurde bereits gesendet.'];
}
$zugangId = (int)($auftrag['zugang_id'] ?? 0);
if ($zugangId === 0) {
return [
'status' => 'fehler',
'meldung' => 'Dem Auftrag ist keine Bankverbindung zugeordnet.',
];
}
$zugang = Bankzugang::zugangsdaten($zugangId);
if ($zugang === null) {
return ['status' => 'fehler', 'meldung' => 'Die Bankverbindung gibt es nicht.'];
}
self::vergessen();
return (new self($zugang, $auftragId, ['schritt' => 'anmelden']))->lauf(null);
}
/**
* Setzt nach der TAN-Eingabe fort.
*
* @return array{status: string, meldung: string, tan?: array<string,mixed>, vop?: array<string,mixed>}
*/
public static function tanEinreichen(string $tan): array
{
$zustand = self::zustandLesen();
if ($zustand === null) {
return ['status' => 'fehler', 'meldung' => 'Es wartet keine Überweisung auf eine Freigabe.'];
}
$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(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;
}
/** @return array<string,mixed>|null */
public static function offeneTanAnfrage(): ?array
{
$zustand = self::zustandLesen();
return $zustand === null ? null : ($zustand['tan'] ?? null);
}
public static function abbrechen(): void
{
self::vergessen();
}
// ── Ablauf ───────────────────────────────────────────────────────────────
/** @return array<string,mixed> */
private function lauf(?string $tan, bool $vopBestaetigt = false): array
{
$auftrag = Zahlungsauftrag::find($this->auftragId);
if ($auftrag === null) {
self::vergessen();
return ['status' => 'fehler', 'meldung' => 'Den Zahlungsauftrag gibt es nicht mehr.'];
}
try {
$fints = $this->verbindung();
} catch (\Throwable $e) {
return $this->fehler('Verbindung fehlgeschlagen: ' . $e->getMessage());
}
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]);
if (!$aktion instanceof BaseAction) {
return $this->fehler('Der wartende Vorgang ist nicht mehr lesbar.');
}
if ($tan === '') {
return $this->tanNoetig($fints, $aktion, 'Überweisung', $this->zustand['schritt']);
}
$fints->submitTan($aktion, $tan);
if ($this->zustand['schritt'] === 'senden') {
return $this->nachDemSenden($fints, $aktion, true);
}
$this->zustand['schritt'] = $this->zustand['schritt'] === 'anmelden' ? 'konten' : 'senden';
unset($this->zustand['aktion'], $this->zustand['tan']);
}
if ($this->zustand['schritt'] === 'anmelden') {
$anmeldung = $fints->login();
if ($anmeldung->needsTan()) {
return $this->tanNoetig($fints, $anmeldung, 'Anmeldung', 'anmelden');
}
$this->zustand['schritt'] = 'konten';
}
if ($this->zustand['schritt'] === 'konten' || !isset($this->zustand['sepa'])) {
$konten = GetSEPAAccounts::create();
$fints->execute($konten);
if ($konten->needsTan()) {
return $this->tanNoetig($fints, $konten, 'Kontenabfrage', 'konten');
}
$sepa = $this->passendesKonto($konten->getAccounts(), (int)$auftrag['konto_id']);
if ($sepa === null) {
return $this->fehler(
'Die Bank führt kein Konto mit der IBAN des Auftragskontos. '
. 'Bitte die IBAN im Konto prüfen.'
);
}
$this->zustand['sepa'] = serialize($sepa);
$this->zustand['schritt'] = 'senden';
}
if ($this->zustand['schritt'] === 'senden') {
$sepa = unserialize((string)$this->zustand['sepa'], ['allowed_classes' => [SEPAAccount::class]]);
if (!$sepa instanceof SEPAAccount) {
return $this->fehler('Das Auftragskonto ist nicht mehr lesbar. Bitte erneut senden.');
}
$formate = $this->sepaFormate($fints);
$schema = SepaXml::schemaWaehlen($formate);
if ($schema === null) {
return $this->fehler(
'Die Bank nimmt nur SEPA-Formate an, die der Finanzmanager nicht erzeugt: '
. implode(', ', $formate)
);
}
$xml = SepaXml::ueberweisung($this->auftragsdaten($auftrag, $sepa), $schema);
$aktion = SendSEPATransfer::create($sepa, $xml);
$fints->execute($aktion);
return $this->nachDemSenden($fints, $aktion);
}
} catch (\Throwable $e) {
DebugLog::fehler($e);
return $this->fehler($e->getMessage());
}
return $this->fehler('Der Auftrag konnte nicht abgeschlossen werden.');
}
/**
* Die SEPA-Datenformate, die die Bank annimmt.
*
* Sie stehen in den Bankparameterdaten (Segment HISPAS) und liegen nach der
* Anmeldung vor. Ältere Häuser führen ausschließlich das DK-Format
* pain.001.003.03 und weisen alles andere schon beim Einreichen ab.
*
* @return list<string>
*/
private function sepaFormate(FinTs $fints): array
{
try {
$hispas = $fints->getBpd()->getLatestSupportedParameters('HISPAS');
} catch (\Throwable) {
return [];
}
return $hispas instanceof HISPAS
? array_values($hispas->getParameter()->getUnterstuetzteSEPADatenformate())
: [];
}
/**
* 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>
*/
private function auftragsdaten(array $auftrag, SEPAAccount $sepa): array
{
$konto = Konto::find((int)$auftrag['konto_id']);
return [
'absender_name' => (string)(Einstellung::get('mandant_name') ?: ($konto['name'] ?? 'Auftraggeber')),
'absender_iban' => (string)($sepa->getIban() ?: ($konto['iban'] ?? '')),
'absender_bic' => (string)($sepa->getBic() ?? ''),
'empfaenger_name' => (string)$auftrag['empfaenger'],
'empfaenger_iban' => (string)$auftrag['iban'],
'empfaenger_bic' => (string)$auftrag['bic'],
'betrag' => (float)$auftrag['betrag'],
'waehrung' => (string)$auftrag['waehrung'],
'verwendungszweck' => (string)$auftrag['verwendungszweck'],
'ausfuehrung_am' => $auftrag['ausfuehrung_am'],
];
}
/**
* @param list<SEPAAccount> $konten
*/
private function passendesKonto(array $konten, int $kontoId): ?SEPAAccount
{
$konto = Konto::find($kontoId);
$iban = strtoupper(str_replace(' ', '', (string)($konto['iban'] ?? '')));
foreach ($konten as $kandidat) {
if ($iban !== '' && strtoupper((string)$kandidat->getIban()) === $iban) {
return $kandidat;
}
}
// Ohne IBAN am Konto: Hat die Bank nur ein Konto, ist es eindeutig.
return $iban === '' && count($konten) === 1 ? $konten[0] : null;
}
/** @return array<string,mixed> */
private function angenommen(FinTs $fints): array
{
Zahlungsauftrag::alsGesendetVermerken($this->auftragId, 'Von der Bank angenommen.');
self::vergessen();
try {
$fints->close();
} catch (\Throwable) {
// Der Dialog ist zu Ende; ein Fehler beim Abmelden ändert nichts.
}
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
{
$verfahren = $fints->getSelectedTanMode();
$anfrage = $aktion->getTanRequest();
$tan = [
'bezeichnung' => $bezeichnung,
'hinweis' => $anfrage?->getChallenge() ?? '',
'medium' => $anfrage?->getTanMediumName() ?? '',
'entkoppelt' => $verfahren !== null && $verfahren->isDecoupled(),
'bild_typ' => null,
'bild_daten' => null,
];
if (!$tan['entkoppelt'] && $anfrage?->getChallengeHhdUc()) {
try {
$bild = new TanRequestChallengeImage($anfrage->getChallengeHhdUc());
$tan['bild_typ'] = $bild->getMimeType();
$tan['bild_daten'] = base64_encode($bild->getData());
} catch (\Throwable) {
// Kein Bild – dann eben ohne.
}
}
self::zustandSchreiben([
'schritt' => $schritt,
'aktion' => serialize($aktion),
'dialog' => $fints->persist(),
'sepa' => $this->zustand['sepa'] ?? null,
'tan' => $tan,
'zugang_id' => (int)$this->zugang['id'],
'auftrag_id' => $this->auftragId,
]);
return [
'status' => 'tan',
'meldung' => 'Die Bank verlangt eine Freigabe.',
'tan' => $tan,
];
}
/** @return array{status: string, meldung: string} */
private function fehler(string $meldung): array
{
Zahlungsauftrag::alsFehlerVermerken($this->auftragId, $meldung);
self::vergessen();
return ['status' => 'fehler', 'meldung' => $meldung];
}
private function verbindung(): FinTs
{
$optionen = new FinTsOptions();
$optionen->url = (string)$this->zugang['fints_url'];
$optionen->bankCode = (string)$this->zugang['blz'];
$optionen->productName = Einstellung::get('banking_produkt_id') ?: self::PRODUKT;
$optionen->productVersion = self::PRODUKT_VERSION;
$fints = FinTs::new(
$optionen,
Credentials::create(
(string)$this->zugang['benutzerkennung'],
(string)$this->zugang['pin']
),
$this->zustand['dialog'] ?? null
);
$fints->setLogger(new DebugLogAdapter());
// Dieselbe Wahl wie beim Umsatzabruf – eine Überweisung soll nicht
// plötzlich ein anderes Verfahren verlangen als der Abruf.
if (!isset($this->zustand['dialog'])) {
FinTsVerfahren::anwenden(
$fints,
$this->zugang,
static fn(string $zeile) => DebugLog::schreibe('fints', $zeile)
);
}
return $fints;
}
/** @return array<string,mixed>|null */
private static function zustandLesen(): ?array
{
Auth::startSession();
$zustand = $_SESSION[self::SITZUNG] ?? null;
return is_array($zustand) ? $zustand : null;
}
/** @param array<string,mixed> $zustand */
private static function zustandSchreiben(array $zustand): void
{
Auth::startSession();
$_SESSION[self::SITZUNG] = $zustand;
}
private static function vergessen(): void
{
Auth::startSession();
unset($_SESSION[self::SITZUNG]);
}
}