- 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>
848 lines
32 KiB
PHP
848 lines
32 KiB
PHP
<?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]);
|
||
}
|
||
}
|