Der gesamte FinTS-Dialog (gesendete und empfangene Meldungen) wird je Durchlauf in bank_protokolle gespeichert, im Erfolgs- wie im Fehlerfall. Im Zahlungsverkehr öffnet der Status das Protokoll der Überweisung als modalen Dialog; unter Online-Banking › Protokoll gibt es das Gesamtprotokoll mit Filter nach Bank, Datum und Freitext. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
692 lines
26 KiB
PHP
692 lines
26 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\SPA\HISPAS;
|
||
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.
|
||
*/
|
||
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.'];
|
||
}
|
||
|
||
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 ───────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* 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(
|
||
'Überweisung %d: %s, IBAN %s, %s %s, Ausführung %s',
|
||
$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']);
|
||
|
||
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.');
|
||
}
|
||
|
||
$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'],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 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): 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($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]);
|
||
}
|
||
}
|