Finanzmanager/src/Services/FinTsUeberweisung.php
fds2610-bot e95fc7302d Fix #117: Nicht freigegebene Überweisungen nachvollziehbar machen
- Status „wartet auf Freigabe“, solange die Bank auf TAN/Namensabgleich wartet
- Abbruch der Freigabe (auch durch neuen Sendeversuch) im Übermittlungsprotokoll
  als „abgebrochen“ festhalten, Auftrag als fehlgeschlagen vermerken
- Aufträge mit Übermittlungsprotokoll nur stornieren, nicht löschen
- Migration 025, PHP-Test 57, E2E-Spec zahlungsfreigabe

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:01:24 +02:00

848 lines
32 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\IPZ\HIIPZSv1;
use Fhp\Segment\IPZ\HIIPZSv2;
use Fhp\Segment\SPA\HISPAS;
use Fhp\UnsupportedException;
use Finanzmanager\Auth;
use Finanzmanager\Models\Bankprotokoll;
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. Meldet die Bank Volltreffer und TAN-Anfrage in einem Rutsch,
* kennt php-fints diese Kombination noch nicht und wirft eine rohe Ausnahme
* (`istBekannteVopTanLuecke()`) – die wird hier abgefangen, statt den Auftrag
* mit ihrem englischen Text scheitern zu lassen.
*
* **Echtzeitüberweisung.** Ist sie am Auftrag gewählt, geht der Auftrag als
* eigener Geschäftsvorfall HKIPZ hinaus statt als HKCCS – so will es das
* SEPA-Verfahren. Bietet die Bank das nicht an, scheitert der Auftrag mit einer
* klaren Meldung und bleibt stehen. Eine stille Umwandlung in eine gewöhnliche
* Überweisung erlauben wir der Bank nicht: Wer Echtzeit wählt, braucht das Geld
* sofort beim Empfänger und soll erfahren, wenn das nicht klappt.
*/
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;
/** Schneidet den Dialog dieses Durchlaufs für das Übermittlungsprotokoll mit. */
private BankProtokollLogger $mitschnitt;
/**
* @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
) {
$this->mitschnitt = new BankProtokollLogger();
}
/**
* 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.'];
}
// Wartet noch ein anderer Durchlauf auf seine Freigabe, verfällt er jetzt –
// das soll im Protokoll stehen und nicht stillschweigend passieren.
self::abbrechen('Abgelöst durch einen neuen Sendeversuch, bevor die Freigabe erteilt war.');
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;
}
/** Der Auftrag, dessen Freigabe in dieser Sitzung gerade aussteht. */
public static function wartenderAuftrag(): ?int
{
$auftragId = (int)(self::zustandLesen()['auftrag_id'] ?? 0);
return $auftragId === 0 ? null : $auftragId;
}
/** @return array<string,mixed>|null */
public static function offeneTanAnfrage(): ?array
{
$zustand = self::zustandLesen();
return $zustand === null ? null : ($zustand['tan'] ?? null);
}
/**
* Verwirft eine ausstehende Freigabe.
*
* Die Bank hat den Auftrag dann zwar gesehen und eine TAN angefordert, führt
* ihn ohne Freigabe aber nicht aus. Genau das muss sich später nachweisen
* lassen – deshalb ein Eintrag im Übermittlungsprotokoll und eine Meldung am
* Auftrag, statt den Zustand nur aus der Sitzung zu löschen.
*/
public static function abbrechen(string $grund = 'Vom Nutzer abgebrochen.'): void
{
$zustand = self::zustandLesen();
self::vergessen();
$auftragId = (int)($zustand['auftrag_id'] ?? 0);
if ($auftragId === 0) {
return;
}
$meldung = 'Freigabe nicht erteilt – keine TAN eingereicht, die Bank führt den Auftrag nicht aus. '
. $grund;
try {
$mitschnitt = new BankProtokollLogger();
$mitschnitt->vermerke(sprintf(
'Ausstehender Schritt: %s',
($zustand['schritt'] ?? '') === 'vop' ? 'Namensabgleich bestätigen' : 'TAN eingeben'
));
$mitschnitt->vermerke('Ergebnis: ' . $meldung);
Bankprotokoll::speichern(
(int)($zustand['zugang_id'] ?? 0),
'ueberweisung',
'abgebrochen',
$meldung,
$mitschnitt->text(),
$auftragId
);
Zahlungsauftrag::alsFehlerVermerken($auftragId, $meldung);
} catch (\Throwable $e) {
DebugLog::fehler($e);
}
}
// ── Ablauf ───────────────────────────────────────────────────────────────
/**
* Ein Durchlauf samt Übermittlungsprotokoll: Was zwischen Finanzmanager und
* Bank gesendet und empfangen wurde, wird gespeichert – ob es klappt oder
* nicht.
*
* @return array<string,mixed>
*/
private function lauf(?string $tan, bool $vopBestaetigt = false): array
{
$auftrag = Zahlungsauftrag::find($this->auftragId);
if ($auftrag !== null) {
$this->mitschnitt->vermerke(sprintf(
'%s %d: %s, IBAN %s, %s %s, Ausführung %s',
self::istEchtzeit($auftrag) ? 'Echtzeitüberweisung' : 'Überweisung',
$this->auftragId,
$auftrag['empfaenger'],
$auftrag['iban'],
number_format((float)$auftrag['betrag'], 2, ',', '.'),
$auftrag['waehrung'],
$auftrag['ausfuehrung_am'] ?? 'sofort'
));
}
$ergebnis = $this->durchlauf($tan, $vopBestaetigt);
$status = match ($ergebnis['status']) {
'erfolg' => 'erfolg',
'tan', 'vop' => 'offen',
default => 'fehler',
};
$this->mitschnitt->vermerke('Ergebnis: ' . $ergebnis['meldung']);
if ($status === 'offen') {
Zahlungsauftrag::alsWartendVermerken($this->auftragId, (string)$ergebnis['meldung']);
}
try {
Bankprotokoll::speichern(
(int)$this->zugang['id'],
'ueberweisung',
$status,
(string)$ergebnis['meldung'],
$this->mitschnitt->text(),
$this->auftragId
);
} catch (\Throwable $e) {
// Ein Fehler im Protokoll darf die Überweisung nicht verdecken.
DebugLog::fehler($e);
}
return $ergebnis;
}
/** @return array<string,mixed> */
private function durchlauf(?string $tan, bool $vopBestaetigt): 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 = self::passendesKonto(
$konten->getAccounts(),
(int)$auftrag['konto_id'],
(int)$this->zugang['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.');
}
$echtzeit = self::istEchtzeit($auftrag);
if ($echtzeit && $this->echtzeitParameter($fints) === null) {
return $this->fehler(
'Die Bank bietet über FinTS keine Echtzeitüberweisung an. '
. 'Bitte die Echtzeitüberweisung am Auftrag abwählen und erneut senden.'
);
}
$formate = $echtzeit ? $this->echtzeitFormate($fints) : $this->sepaFormate($fints);
$schema = SepaXml::schemaWaehlen($formate, $echtzeit);
if ($schema === null) {
return $this->fehler(
($echtzeit ? 'Für Echtzeitüberweisungen nimmt die Bank' : 'Die Bank nimmt')
. ' nur SEPA-Formate an, die der Finanzmanager nicht erzeugt: '
. implode(', ', $formate)
);
}
$xml = SepaXml::ueberweisung($this->auftragsdaten($auftrag, $sepa), $schema);
$aktion = $echtzeit
// Keine Umwandlung in eine gewöhnliche Überweisung – siehe oben.
? FinTsEchtzeitUeberweisung::create($sepa, $xml, false)
: SendSEPATransfer::create($sepa, $xml);
try {
$fints->execute($aktion);
} catch (UnsupportedException $e) {
if (!self::istBekannteVopTanLuecke($e)) {
throw $e;
}
// Der Auftrag hat Namensabgleich und TAN-Anfrage trotz der
// Ausnahme schon erhalten (siehe unten) – ganz normal weiter.
}
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())
: [];
}
/**
* Die Bankparameter der Echtzeitüberweisung (Segment HIIPZS) – oder `null`,
* wenn die Bank sie über FinTS nicht anbietet.
*/
private function echtzeitParameter(FinTs $fints): HIIPZSv1|HIIPZSv2|null
{
try {
$hiipzs = $fints->getBpd()->getLatestSupportedParameters('HIIPZS');
} catch (\Throwable) {
return null;
}
return $hiipzs instanceof HIIPZSv1 || $hiipzs instanceof HIIPZSv2 ? $hiipzs : null;
}
/**
* Die SEPA-Datenformate für Echtzeitüberweisungen. Nennt die Bank dafür
* keine eigenen, gelten die allgemeinen aus HISPAS.
*
* @return list<string>
*/
private function echtzeitFormate(FinTs $fints): array
{
$formate = $this->echtzeitParameter($fints)?->parameter->getUnterstuetzteSEPADatenformate();
return $formate === null || $formate === []
? $this->sepaFormate($fints)
: array_values($formate);
}
/** @param array<string,mixed> $auftrag */
private static function istEchtzeit(array $auftrag): bool
{
return (int)($auftrag['echtzeit'] ?? 0) === 1;
}
/**
* 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, $aktion);
}
/**
* Ob eine Ausnahme die bekannte Lücke von php-fints (Stand v4.1.4) ist:
* Bestätigt die Bank den Namensabgleich mit einem Volltreffer und verlangt
* im selben Antwortsatz schon die TAN, kennt die Bibliothek diese
* Kombination nicht und wirft eine rohe `UnsupportedException` – obwohl sie
* dem Vorgang (`BaseAction::setTanRequest()` und
* `::setVopConfirmationRequest()`) beide Angaben schon mitgegeben hat, bevor
* sie wirft. `nachDemSenden()` bestätigt einen Volltreffer ohnehin
* automatisch (siehe oben) – die Ausnahme muss also nur abgefangen werden,
* damit ihr englischer Text nicht als Fehlermeldung beim Nutzer landet.
*/
private static function istBekannteVopTanLuecke(UnsupportedException $e): bool
{
return $e->getMessage() === 'Combined VOP match confirmation and TAN request';
}
/**
* 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'],
'echtzeit' => self::istEchtzeit($auftrag),
];
}
/**
* Das Konto bei der Bank, das zum Auftragskonto gehört.
*
* Maßgeblich ist dieselbe Zuordnung, über die auch der Umsatzabruf läuft:
* die Zeile in `bank_konten`, die der Nutzer dem Konto zugewiesen hat. Dort
* steht die IBAN, die die Bank selbst gemeldet hat – und die Kontonummer für
* die Häuser, die keine IBAN liefern. Die IBAN am Konto selbst ist optional
* und bleibt in der Kontenverwaltung oft leer; wer sie gepflegt hat, kommt
* damit trotzdem zum Ziel.
*
* @param list<SEPAAccount> $konten die Konten, die die Bank gemeldet hat
*/
public static function passendesKonto(array $konten, int $kontoId, int $zugangId): ?SEPAAccount
{
$ibans = [];
$kontonummern = [];
foreach (Bankzugang::konten($zugangId) as $bankkonto) {
if ((int)($bankkonto['konto_id'] ?? 0) !== $kontoId) {
continue;
}
$ibans[] = self::normiert((string)($bankkonto['iban'] ?? ''));
$kontonummern[] = trim((string)($bankkonto['kontonummer'] ?? ''));
}
$konto = Konto::find($kontoId);
$ibans[] = self::normiert((string)($konto['iban'] ?? ''));
$ibans = array_values(array_filter($ibans));
$kontonummern = array_values(array_filter($kontonummern));
foreach ($konten as $kandidat) {
if (in_array(self::normiert((string)$kandidat->getIban()), $ibans, true)) {
return $kandidat;
}
}
// Ohne IBAN – etwa bei PayPal oder einem Kreditkartenkonto – bleibt die
// Kontonummer.
foreach ($konten as $kandidat) {
if ($kontonummern !== [] && in_array(trim((string)$kandidat->getAccountNumber()), $kontonummern, true)) {
return $kandidat;
}
}
// Ist nichts zugeordnet und die Bank führt nur ein Konto, ist es eindeutig.
return $ibans === [] && $kontonummern === [] && count($konten) === 1 ? $konten[0] : null;
}
private static function normiert(string $iban): string
{
return strtoupper(str_replace(' ', '', trim($iban)));
}
/** @return array<string,mixed> */
private function angenommen(FinTs $fints, ?BaseAction $aktion = null): array
{
$auftrag = Zahlungsauftrag::find($this->auftragId);
$echtzeit = $auftrag !== null && self::istEchtzeit($auftrag);
// Die Bank hat die TAN angenommen, den Ausgang aber noch nicht gemeldet.
// Der Auftrag gilt als gesendet – ein erneutes Senden wäre eine
// Doppelzahlung –, der Nutzer prüft den Umsatz.
$statusOffen = $aktion instanceof FinTsEchtzeitUeberweisung && $aktion->istStatusOffen();
Zahlungsauftrag::alsGesendetVermerken(
$this->auftragId,
$statusOffen
? 'Die Bank hat die Echtzeitüberweisung entgegengenommen, den Ausgang aber noch nicht bestätigt.'
: ($echtzeit ? 'Von der Bank als Echtzeitüberweisung angenommen.' : 'Von der Bank angenommen.')
);
self::vergessen();
try {
$fints->close();
} catch (\Throwable) {
// Der Dialog ist zu Ende; ein Fehler beim Abmelden ändert nichts.
}
if ($statusOffen) {
return [
'status' => 'erfolg',
'meldung' => 'Die Bank hat die Echtzeitüberweisung entgegengenommen, den Ausgang aber noch nicht bestätigt. '
. 'Bitte im Kontoumsatz prüfen, ob sie ausgeführt wurde, und nicht erneut senden.',
];
}
return [
'status' => 'erfolg',
'meldung' => $echtzeit
? 'Die Echtzeitüberweisung wurde an die Bank übergeben.'
: '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.
*/
public static function vopHinweis(string $html): string
{
return BankFreitext::klartext($html);
}
/**
* 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' => StartcodeErkennung::hervorheben(
$anfrage?->getChallenge() ?? '',
(string)($this->zugang['startcode_muster'] ?? '')
),
'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($this->mitschnitt);
// 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]);
}
}