Fix #113: Echtzeitüberweisung – offenen Status im Hintergrund bei der Bank nachfragen
Meldet die Bank nach der TAN 3045 (noch keine Rückmeldung zum Status), fragt ein Hintergrundprozess nach der Mindestwartezeit aus HIIPSS per HKIPS nach (höchstens fünfmal). Ausgeführt wird vermerkt, abgelehnt entfernt die Buchung; ein verbundener Kontoumsatz klärt den Status ebenfalls. php-fints-Fork v4.1.7 bringt HKIPS/HIIPS/HIIPSS mit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e95fc7302d
commit
8b245ad769
15 changed files with 800 additions and 83 deletions
10
Changes.md
10
Changes.md
|
|
@ -1,5 +1,15 @@
|
|||
# Änderungen
|
||||
|
||||
## Echtzeitüberweisung: Status bei der Bank nachfragen (06.10.2026)
|
||||
|
||||
- Anlass (#113): Bei offenem Status (Rückmeldung 3045) blieb nur der Blick in den Kontoumsatz.
|
||||
- php-fints-Fork v4.1.7: `SendSEPARealtimeTransfer` liest Auftragsidentifikation und Status aus HIIPZ und wirft bei 3045 nicht mehr; neue Action `GetSEPARealtimeTransferStatus` (HKIPS/HIIPS/HIIPSS) samt Mindestwartezeit.
|
||||
- Nach 3045 startet ein Hintergrundprozess (`scripts/echtzeit-status.php`), der nach der Mindestwartezeit der Bank (comdirect 99 s, DKB 5 s) nachfragt, höchstens fünfmal. Der Browser wartet nicht; die Liste zeigt „Ausgang offen“, bis das Ergebnis am Auftrag steht. Fällige Abfragen stößt die Zahlungsverkehrsliste nach einem Container-Neustart erneut an.
|
||||
- Ausgeführt (Status 4/7) wird am Auftrag vermerkt; abgelehnt (2/6/8/9) entfernt die Buchung, der Auftrag steht auf „fehlgeschlagen“ und lässt sich erneut senden. Ein beim Abruf verbundener Kontoumsatz klärt den offenen Status ebenfalls. Verlangt die Bank für die Abfrage eine TAN, wird sie eingestellt.
|
||||
- Im Echtbetrieb bestätigt (comdirect, 06.10.2026): HKIPS ohne TAN, Antwort HIIPS mit Status 7.
|
||||
- `Services/FinTsEchtzeitUeberweisung` entfällt, Migration `026_echtzeit_status.sql`.
|
||||
- Tests: `tests/54_EchtzeitStatusOffenTest.php` (erweitert), `tests/e2e/specs/echtzeitstatus.spec.js`; E2E-Grundbestand enthält eine Echtzeitüberweisung mit offenem Status.
|
||||
|
||||
## Zahlungsverkehr: nicht freigegebene Überweisungen nachvollziehbar (06.10.2026)
|
||||
|
||||
- Anlass (#117): Die comdirect begründete zwei nicht ausgeführte Überweisungen damit, dass eine TAN angefordert, aber nicht verwendet wurde – im Finanzmanager war das nicht nachprüfbar.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
],
|
||||
"require": {
|
||||
"php": ">=8.3",
|
||||
"nemiah/php-fints": "4.1.6"
|
||||
"nemiah/php-fints": "4.1.7"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
|
|
|||
14
composer.lock
generated
14
composer.lock
generated
|
|
@ -4,20 +4,20 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "5e64ec317d119fcc93feebff48ea2d2b",
|
||||
"content-hash": "7a51c13f9789237d55e4cf90c2fc2638",
|
||||
"packages": [
|
||||
{
|
||||
"name": "nemiah/php-fints",
|
||||
"version": "v4.1.6",
|
||||
"version": "v4.1.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://codeberg.org/fds2610-bot/phpFinTS.git",
|
||||
"reference": "4d5315a83162a70eb00dfc0bf348f077bcd65318"
|
||||
"reference": "31e332654e57dda5a38667004fe69fdb6cabf309"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://codeberg.org/api/v1/repos/fds2610-bot/phpFinTS/archive/4d5315a83162a70eb00dfc0bf348f077bcd65318.zip",
|
||||
"reference": "4d5315a83162a70eb00dfc0bf348f077bcd65318",
|
||||
"url": "https://codeberg.org/api/v1/repos/fds2610-bot/phpFinTS/archive/31e332654e57dda5a38667004fe69fdb6cabf309.zip",
|
||||
"reference": "31e332654e57dda5a38667004fe69fdb6cabf309",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -67,10 +67,10 @@
|
|||
"description": "PHP Library for the protocols fints and hbci",
|
||||
"homepage": "https://codeberg.org/fds2610-bot/phpFinTS",
|
||||
"support": {
|
||||
"source": "https://codeberg.org/fds2610-bot/phpFinTS/tag/v4.1.6",
|
||||
"source": "https://codeberg.org/fds2610-bot/phpFinTS/tag/v4.1.7",
|
||||
"issues": "https://codeberg.org/fds2610-bot/phpFinTS/issues"
|
||||
},
|
||||
"time": "2026-09-30T16:46:26+02:00"
|
||||
"time": "2026-10-06T15:23:29+02:00"
|
||||
}
|
||||
],
|
||||
"packages-dev": [],
|
||||
|
|
|
|||
|
|
@ -142,6 +142,23 @@ Segment HIIPZS in den Bankparametern), scheitert der Auftrag mit einer klaren Me
|
|||
Eine Umwandlung in eine gewöhnliche Überweisung erlaubt der Finanzmanager der Bank
|
||||
ausdrücklich nicht – wer Echtzeit wählt, soll erfahren, wenn es nicht klappt.
|
||||
|
||||
**Ausgang offen (3045).** Antwortet die Bank des Empfängers nicht rechtzeitig, quittiert
|
||||
die eigene Bank die TAN mit 3045 („noch keine Rückmeldung zum Status“) und nennt in HIIPZ
|
||||
eine Auftragsidentifikation. Der Auftrag liegt dann bei der Bank: Er gilt als gesendet und
|
||||
wird gebucht – erneutes Senden wäre eine Doppelzahlung. Den Ausgang fragt der Finanzmanager
|
||||
mit **HKIPS** nach (php-fints-Fork ab v4.1.7, `GetSEPARealtimeTransferStatus`). Die Bank
|
||||
nennt dafür in HIIPSS eine Mindestwartezeit (DKB 5 s, comdirect 99 s). Die Nachfrage läuft
|
||||
in einem eigenen Hintergrundprozess (`scripts/echtzeit-status.php <benutzer> <auftrag>`):
|
||||
Er wartet die Mindestwartezeit ab und fragt höchstens fünfmal; eine Sperrdatei je Auftrag
|
||||
verhindert doppelte Läufe. Bis dahin steht der Auftrag in der Liste mit „Ausgang offen“.
|
||||
Verlangt die Bank für die Abfrage eine TAN oder bleibt der Ausgang offen, wird die
|
||||
automatische Abfrage eingestellt (`status_abfrage_ab` leer). Nach einem Neustart des
|
||||
Containers stößt die Zahlungsverkehrsliste fällige Abfragen erneut an.
|
||||
Status 4 und 7 gelten als ausgeführt, 2, 6, 8 und 9 als abgelehnt – dann verschwindet die
|
||||
Buchung wieder und der Auftrag lässt sich korrigieren und erneut senden. Wird beim
|
||||
Umsatzabruf ein Umsatz mit der Buchung verbunden, ist der Ausgang ebenfalls geklärt.
|
||||
Spalten: `bank_auftrag_id`, `echtzeit_status`, `status_abfrage_ab` (Migration 026).
|
||||
|
||||
**Welches Konto bei der Bank gemeint ist**, entscheidet dieselbe Zuordnung wie beim
|
||||
Umsatzabruf: die Zeile in `bank_konten`, die dem Konto zugewiesen ist (IBAN, ersatzweise
|
||||
Kontonummer). Die IBAN am Konto selbst ist optional und wird nur herangezogen, wenn keine
|
||||
|
|
|
|||
17
migrations/026_echtzeit_status.sql
Normal file
17
migrations/026_echtzeit_status.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
-- Echtzeitüberweisung: Status nachfragen, wenn die Bank ihn offen lässt (#113).
|
||||
--
|
||||
-- Antwortet die Bank des Empfängers nicht rechtzeitig, quittiert die eigene Bank
|
||||
-- die TAN mit 3045 („noch keine Rückmeldung zum Status“). Der Auftrag liegt bei
|
||||
-- der Bank und ist gebucht, sein Ausgang aber unbekannt. Mit der
|
||||
-- Auftragsidentifikation aus HIIPZ lässt er sich später über HKIPS nachfragen.
|
||||
--
|
||||
-- echtzeit_status: NULL = nichts offen; offen / ausgefuehrt / abgelehnt
|
||||
-- status_abfrage_ab: frühester Zeitpunkt der nächsten Nachfrage (Mindestwartezeit
|
||||
-- der Bank aus HIIPSS)
|
||||
--
|
||||
-- Idempotent über IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE zahlungsauftraege
|
||||
ADD COLUMN IF NOT EXISTS bank_auftrag_id VARCHAR(99) NULL AFTER buchung_id,
|
||||
ADD COLUMN IF NOT EXISTS echtzeit_status ENUM('offen','ausgefuehrt','abgelehnt') NULL AFTER bank_auftrag_id,
|
||||
ADD COLUMN IF NOT EXISTS status_abfrage_ab DATETIME NULL AFTER echtzeit_status;
|
||||
74
scripts/echtzeit-status.php
Normal file
74
scripts/echtzeit-status.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Fragt den Status einer Echtzeitüberweisung im Hintergrund nach (#113).
|
||||
*
|
||||
* Meldet die Bank nach dem Senden 3045 („noch keine Rückmeldung zum Status“),
|
||||
* startet `FinTsUeberweisung::hintergrundStarten()` dieses Skript losgelöst vom
|
||||
* Browser:
|
||||
*
|
||||
* php scripts/echtzeit-status.php <benutzer_id> <auftrag_id>
|
||||
*
|
||||
* Es wartet jeweils die Mindestwartezeit der Bank ab und fragt nach – höchstens
|
||||
* VERSUCHE Mal. Bleibt der Ausgang offen, stellt es die automatische Abfrage
|
||||
* ein; dann klärt ihn der nächste Umsatzabruf. Eine Sperrdatei je Auftrag
|
||||
* verhindert, dass zwei Prozesse gleichzeitig dieselbe Überweisung abfragen.
|
||||
*/
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
use Finanzmanager\Config;
|
||||
use Finanzmanager\Mandant;
|
||||
use Finanzmanager\Models\Einstellung;
|
||||
use Finanzmanager\Models\Zahlungsauftrag;
|
||||
use Finanzmanager\Services\DebugLog;
|
||||
use Finanzmanager\Services\FinTsUeberweisung;
|
||||
|
||||
const VERSUCHE = 5;
|
||||
|
||||
Config::load();
|
||||
|
||||
$benutzerId = (int)($argv[1] ?? 0);
|
||||
$auftragId = (int)($argv[2] ?? 0);
|
||||
if ($benutzerId === 0 || $auftragId === 0) {
|
||||
fwrite(STDERR, "Aufruf: php scripts/echtzeit-status.php <benutzer_id> <auftrag_id>\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$sperre = fopen(sys_get_temp_dir() . '/finanzmanager-echtzeit-' . $auftragId . '.lock', 'c');
|
||||
if ($sperre === false || !flock($sperre, LOCK_EX | LOCK_NB)) {
|
||||
exit(0); // Ein anderer Prozess fragt diesen Auftrag schon ab.
|
||||
}
|
||||
|
||||
Mandant::setze($benutzerId);
|
||||
DebugLog::setAktiv(Einstellung::getBool('debug_log_aktiv', (bool)Config::get('debug_log', false)));
|
||||
|
||||
for ($versuch = 1; $versuch <= VERSUCHE; $versuch++) {
|
||||
$auftrag = Zahlungsauftrag::find($auftragId);
|
||||
$sekunden = $auftrag === null ? null : Zahlungsauftrag::sekundenBisStatusabfrage($auftrag);
|
||||
if ($sekunden === null) {
|
||||
exit(0); // Geklärt, eingestellt oder nicht (mehr) abfragbar.
|
||||
}
|
||||
|
||||
if ($sekunden > 0) {
|
||||
sleep($sekunden);
|
||||
}
|
||||
|
||||
try {
|
||||
$ergebnis = FinTsUeberweisung::statusImHintergrund($auftragId);
|
||||
DebugLog::schreibe('fints', sprintf('Statusabfrage %d/%d für Auftrag %d: %s', $versuch, VERSUCHE, $auftragId, $ergebnis['meldung']));
|
||||
} catch (\Throwable $e) {
|
||||
DebugLog::fehler($e);
|
||||
}
|
||||
}
|
||||
|
||||
Zahlungsauftrag::echtzeitAbfrageEinstellen(
|
||||
$auftragId,
|
||||
sprintf(
|
||||
'Die Bank hat den Ausgang der Echtzeitüberweisung auch nach %d Statusabfragen nicht bestätigt. '
|
||||
. 'Er klärt sich beim nächsten Umsatzabruf. Nicht erneut senden – das wäre eine Doppelzahlung.',
|
||||
VERSUCHE
|
||||
)
|
||||
);
|
||||
|
|
@ -25,6 +25,12 @@ class ZahlungsverkehrController extends BaseController
|
|||
|
||||
$status = $this->query('status');
|
||||
|
||||
// Fällige Statusabfragen offener Echtzeitüberweisungen wieder anstoßen –
|
||||
// ein Neustart des Containers beendet den Hintergrundprozess.
|
||||
foreach (Zahlungsauftrag::faelligeStatusabfragen() as $auftragId) {
|
||||
FinTsUeberweisung::hintergrundStarten($auftragId);
|
||||
}
|
||||
|
||||
$this->render('zahlungsverkehr/index.php', [
|
||||
'titel' => 'Zahlungsverkehr',
|
||||
'auftraege' => Zahlungsauftrag::alle($status),
|
||||
|
|
|
|||
|
|
@ -761,6 +761,10 @@ class Bankumsatz
|
|||
(string)($umsatz['gegen_bankname'] ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
// War der Ausgang einer Echtzeitüberweisung noch offen, klärt ihn der
|
||||
// Umsatz: Das Geld ist abgegangen.
|
||||
Zahlungsauftrag::echtzeitMitUmsatzAbgleichen($buchungId);
|
||||
}
|
||||
|
||||
public static function verwerfen(int $id): void
|
||||
|
|
|
|||
|
|
@ -94,7 +94,8 @@ class Zahlungsauftrag
|
|||
Database::execute(
|
||||
'UPDATE zahlungsauftraege SET konto_id = ?, zugang_id = ?, empfaenger = ?, iban = ?,
|
||||
bic = ?, betrag = ?, waehrung = ?, verwendungszweck = ?, ausfuehrung_am = ?,
|
||||
echtzeit = ?, kategorie_id = ?, klasse_id = ?, status = \'entwurf\', meldung = NULL
|
||||
echtzeit = ?, kategorie_id = ?, klasse_id = ?, status = \'entwurf\', meldung = NULL,
|
||||
bank_auftrag_id = NULL, echtzeit_status = NULL, status_abfrage_ab = NULL
|
||||
WHERE id = ? AND benutzer_id = ?',
|
||||
[
|
||||
$f['konto_id'], $f['zugang_id'], $f['empfaenger'], $f['iban'], $f['bic'],
|
||||
|
|
@ -159,7 +160,8 @@ class Zahlungsauftrag
|
|||
|
||||
Database::execute(
|
||||
"UPDATE zahlungsauftraege
|
||||
SET status = 'gesendet', gesendet_am = NOW(), meldung = ?, buchung_id = ?
|
||||
SET status = 'gesendet', gesendet_am = NOW(), meldung = ?, buchung_id = ?,
|
||||
bank_auftrag_id = NULL, echtzeit_status = NULL, status_abfrage_ab = NULL
|
||||
WHERE id = ? AND benutzer_id = ?",
|
||||
[mb_substr($meldung, 0, 2000), $buchungId, $id, Mandant::id()]
|
||||
);
|
||||
|
|
@ -199,6 +201,130 @@ class Zahlungsauftrag
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Bank hat die Echtzeitüberweisung entgegengenommen, kennt ihren Ausgang
|
||||
* aber noch nicht (Rückmeldung 3045). Der Auftrag bleibt gesendet und
|
||||
* gebucht; mit der Auftragsidentifikation der Bank lässt sich der Status
|
||||
* nachfragen – frühestens nach der Mindestwartezeit der Bank.
|
||||
*/
|
||||
public static function echtzeitOffenVermerken(int $id, ?string $bankAuftragId, int $wartezeit, string $meldung): void
|
||||
{
|
||||
Database::execute(
|
||||
"UPDATE zahlungsauftraege
|
||||
SET echtzeit_status = 'offen', bank_auftrag_id = COALESCE(?, bank_auftrag_id),
|
||||
status_abfrage_ab = ?, meldung = ?
|
||||
WHERE id = ? AND benutzer_id = ? AND status = 'gesendet'",
|
||||
// In PHP gerechnet wie der Vergleich in sekundenBisStatusabfrage() –
|
||||
// so stört keine abweichende Zeitzone der Datenbank.
|
||||
[$bankAuftragId, date('Y-m-d H:i:s', time() + max(0, $wartezeit)), mb_substr($meldung, 0, 2000), $id, Mandant::id()]
|
||||
);
|
||||
}
|
||||
|
||||
/** Die Bank bestätigt die Ausführung – oder der Umsatz ist schon da. */
|
||||
public static function echtzeitAusgefuehrtVermerken(int $id, string $meldung): void
|
||||
{
|
||||
Database::execute(
|
||||
"UPDATE zahlungsauftraege
|
||||
SET echtzeit_status = 'ausgefuehrt', status_abfrage_ab = NULL, meldung = ?
|
||||
WHERE id = ? AND benutzer_id = ? AND echtzeit_status = 'offen'",
|
||||
[mb_substr($meldung, 0, 2000), $id, Mandant::id()]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Bank meldet die Echtzeitüberweisung als abgelehnt oder fehlgeschlagen.
|
||||
* Das Geld ist nicht abgegangen: Die Buchung verschwindet wieder, und der
|
||||
* Auftrag lässt sich korrigieren und erneut senden.
|
||||
*/
|
||||
public static function echtzeitAbgelehntVermerken(int $id, string $meldung): void
|
||||
{
|
||||
$auftrag = self::find($id);
|
||||
if ($auftrag === null || $auftrag['echtzeit_status'] !== 'offen') {
|
||||
return;
|
||||
}
|
||||
|
||||
$buchungId = $auftrag['buchung_id'] === null ? null : (int)$auftrag['buchung_id'];
|
||||
if ($buchungId !== null) {
|
||||
try {
|
||||
Buchung::loeschen($buchungId);
|
||||
$buchungId = null;
|
||||
} catch (\Throwable $e) {
|
||||
$meldung .= ' Die Buchung ließ sich nicht entfernen (' . $e->getMessage() . ') – bitte von Hand löschen.';
|
||||
}
|
||||
}
|
||||
|
||||
Database::execute(
|
||||
"UPDATE zahlungsauftraege
|
||||
SET status = 'fehler', echtzeit_status = 'abgelehnt', status_abfrage_ab = NULL,
|
||||
buchung_id = ?, meldung = ?
|
||||
WHERE id = ? AND benutzer_id = ?",
|
||||
[$buchungId, mb_substr($meldung, 0, 2000), $id, Mandant::id()]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein abgerufener Umsatz wurde mit der Buchung einer Echtzeitüberweisung
|
||||
* verbunden, deren Status noch offen war: Das Geld ist abgegangen.
|
||||
*/
|
||||
public static function echtzeitMitUmsatzAbgleichen(int $buchungId): void
|
||||
{
|
||||
Database::execute(
|
||||
"UPDATE zahlungsauftraege
|
||||
SET echtzeit_status = 'ausgefuehrt', status_abfrage_ab = NULL,
|
||||
meldung = 'Die Echtzeitüberweisung ist im Kontoumsatz angekommen.'
|
||||
WHERE buchung_id = ? AND benutzer_id = ? AND echtzeit_status = 'offen'",
|
||||
[$buchungId, Mandant::id()]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keine weitere automatische Statusabfrage: Der Ausgang bleibt offen, bis
|
||||
* ihn der Kontoumsatz klärt.
|
||||
*/
|
||||
public static function echtzeitAbfrageEinstellen(int $id, string $meldung): void
|
||||
{
|
||||
Database::execute(
|
||||
"UPDATE zahlungsauftraege SET status_abfrage_ab = NULL, meldung = ?
|
||||
WHERE id = ? AND benutzer_id = ? AND echtzeit_status = 'offen'",
|
||||
[mb_substr($meldung, 0, 2000), $id, Mandant::id()]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Offene Echtzeitüberweisungen, deren Statusabfrage fällig ist – etwa weil
|
||||
* ein Neustart des Containers den Hintergrundprozess beendet hat.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public static function faelligeStatusabfragen(): array
|
||||
{
|
||||
return array_map('intval', array_column(Database::fetchAll(
|
||||
"SELECT id FROM zahlungsauftraege
|
||||
WHERE benutzer_id = ? AND echtzeit_status = 'offen'
|
||||
AND bank_auftrag_id IS NOT NULL AND status_abfrage_ab IS NOT NULL
|
||||
AND status_abfrage_ab <= ?",
|
||||
[Mandant::id(), date('Y-m-d H:i:s')]
|
||||
), 'id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ob der Status einer offenen Echtzeitüberweisung schon wieder nachgefragt
|
||||
* werden darf. Liefert die Sekunden bis dahin, 0 = jetzt, null = nichts offen
|
||||
* oder die automatische Abfrage eingestellt.
|
||||
*
|
||||
* @param array<string,mixed> $auftrag
|
||||
*/
|
||||
public static function sekundenBisStatusabfrage(array $auftrag): ?int
|
||||
{
|
||||
if (($auftrag['echtzeit_status'] ?? null) !== 'offen' || ($auftrag['bank_auftrag_id'] ?? '') === '') {
|
||||
return null;
|
||||
}
|
||||
// Ohne Zeitpunkt ist die automatische Abfrage eingestellt.
|
||||
$ab = strtotime((string)($auftrag['status_abfrage_ab'] ?? ''));
|
||||
|
||||
return $ab === false ? null : max(0, $ab - time());
|
||||
}
|
||||
|
||||
public static function anzahlOffen(): int
|
||||
{
|
||||
return (int)Database::fetchValue(
|
||||
|
|
|
|||
|
|
@ -161,13 +161,28 @@ class E2EGrundbestand
|
|||
Bankprotokoll::speichern($zugang, 'ueberweisung', 'offen', 'Die Bank verlangt eine Freigabe.', '> HKTAN', $auftrag);
|
||||
Zahlungsauftrag::alsWartendVermerken($auftrag, 'Die Bank verlangt eine Freigabe.');
|
||||
|
||||
// Eine Echtzeitüberweisung, deren Ausgang die Bank offen ließ (#113).
|
||||
// Die Statusabfrage ist erst in 30 Tagen fällig – die Liste stößt
|
||||
// also keinen Hintergrundprozess an, und keine Bank wird gefragt.
|
||||
$echtzeit = Zahlungsauftrag::anlegen([
|
||||
'konto_id' => $konten[0],
|
||||
'zugang_id' => $zugang,
|
||||
'empfaenger' => self::PRAEFIX . ' Verlag',
|
||||
'iban' => 'DE26380601863001400028',
|
||||
'betrag' => 12.34,
|
||||
'echtzeit' => 1,
|
||||
]);
|
||||
Zahlungsauftrag::alsGesendetVermerken($echtzeit, 'Entgegengenommen.');
|
||||
Zahlungsauftrag::echtzeitOffenVermerken($echtzeit, 'E2E00000000000000000000000000001', 30 * 86400, 'Status offen.');
|
||||
$buchungen++;
|
||||
|
||||
return [
|
||||
'konten' => count($konten) + 1,
|
||||
'kategorien' => count($kategorien),
|
||||
'klassen' => 1,
|
||||
'wertpapiere' => 1,
|
||||
'buchungen' => $buchungen,
|
||||
'zahlungsauftraege' => 1,
|
||||
'zahlungsauftraege' => 2,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Finanzmanager\Services;
|
||||
|
||||
use Fhp\Action\SendSEPARealtimeTransfer;
|
||||
use Fhp\Protocol\Message;
|
||||
use Fhp\Protocol\UnexpectedResponseException;
|
||||
|
||||
/**
|
||||
* Echtzeitüberweisung, die eine noch offene Statusmeldung der Bank übersteht.
|
||||
*
|
||||
* Antwortet die Gegenstelle nicht rechtzeitig, quittiert die Bank die TAN mit
|
||||
* 3045 („noch keine Rückmeldung zum Status“) statt mit „entgegengenommen“ oder
|
||||
* „ausgeführt“. php-fints wirft dann eine Ausnahme, obwohl der Auftrag bei der
|
||||
* Bank liegt. Hier bleibt nur das Merkmal `statusOffen` zurück.
|
||||
*/
|
||||
class FinTsEchtzeitUeberweisung extends SendSEPARealtimeTransfer
|
||||
{
|
||||
/** Rückmeldecode der Bank: Status der Echtzeitüberweisung noch unbekannt. */
|
||||
public const STATUS_OFFEN = 3045;
|
||||
|
||||
private bool $statusOffen = false;
|
||||
|
||||
public function istStatusOffen(): bool
|
||||
{
|
||||
return $this->statusOffen;
|
||||
}
|
||||
|
||||
public function processResponse(Message $response)
|
||||
{
|
||||
try {
|
||||
parent::processResponse($response);
|
||||
} catch (UnexpectedResponseException $e) {
|
||||
if ($response->findRueckmeldungen(self::STATUS_OFFEN) === []) {
|
||||
throw $e;
|
||||
}
|
||||
$this->statusOffen = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ declare(strict_types=1);
|
|||
namespace Finanzmanager\Services;
|
||||
|
||||
use Fhp\Action\GetSEPAAccounts;
|
||||
use Fhp\Action\GetSEPARealtimeTransferStatus;
|
||||
use Fhp\Action\SendSEPARealtimeTransfer;
|
||||
use Fhp\Action\SendSEPATransfer;
|
||||
use Fhp\BaseAction;
|
||||
use Fhp\FinTs;
|
||||
|
|
@ -19,6 +21,7 @@ use Fhp\Segment\IPZ\HIIPZSv2;
|
|||
use Fhp\Segment\SPA\HISPAS;
|
||||
use Fhp\UnsupportedException;
|
||||
use Finanzmanager\Auth;
|
||||
use Finanzmanager\Mandant;
|
||||
use Finanzmanager\Models\Bankprotokoll;
|
||||
use Finanzmanager\Models\Bankzugang;
|
||||
use Finanzmanager\Models\Einstellung;
|
||||
|
|
@ -53,6 +56,15 @@ use Finanzmanager\Models\Zahlungsauftrag;
|
|||
* 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.
|
||||
*
|
||||
* **Status offen.** Antwortet die Bank des Empfängers nicht rechtzeitig,
|
||||
* quittiert die eigene Bank die TAN mit 3045: Der Auftrag liegt bei ihr, sein
|
||||
* Ausgang ist offen. Er gilt als gesendet und wird gebucht – ein erneutes Senden
|
||||
* wäre eine Doppelzahlung. Den Status fragen wir mit HKIPS nach. Die Bank nennt
|
||||
* dafür eine Mindestwartezeit (HIIPSS). Die Nachfrage läuft in einem eigenen
|
||||
* Hintergrundprozess (`scripts/echtzeit-status.php`), damit niemand vor dem
|
||||
* Browser warten muss; das Ergebnis steht danach am Auftrag. Lehnt die Bank ab,
|
||||
* verschwindet die Buchung wieder.
|
||||
*/
|
||||
class FinTsUeberweisung
|
||||
{
|
||||
|
|
@ -66,6 +78,27 @@ class FinTsUeberweisung
|
|||
/** … und so lange warten wir dazwischen höchstens (Sekunden). */
|
||||
private const POLLING_WARTEZEIT = 6;
|
||||
|
||||
/** Das Skript, das den offenen Status im Hintergrund nachfragt. */
|
||||
private const STATUS_SKRIPT = __DIR__ . '/../../scripts/echtzeit-status.php';
|
||||
|
||||
/**
|
||||
* Status SEPA-Auftrag (HIIPZ/HIIPS) in Worten. 4 und 7 heißen ausgeführt,
|
||||
* 2, 6, 8 und 9 abgelehnt; der Rest ist noch unterwegs.
|
||||
*/
|
||||
public const ECHTZEIT_STATUS = [
|
||||
1 => 'in Terminierung',
|
||||
2 => 'abgelehnt von der ersten Inkassostelle',
|
||||
3 => 'in Bearbeitung',
|
||||
4 => 'beim Empfänger verarbeitet, Buchung veranlasst',
|
||||
5 => 'Rückgabe (R-Transaktion) veranlasst',
|
||||
6 => 'Auftrag fehlgeschlagen',
|
||||
7 => 'ausgeführt, Geld für den Empfänger verfügbar',
|
||||
8 => 'abgelehnt durch die eigene Bank',
|
||||
9 => 'abgelehnt durch die Bank des Empfängers',
|
||||
];
|
||||
private const ECHTZEIT_AUSGEFUEHRT = [4, 7];
|
||||
private const ECHTZEIT_ABGELEHNT = [2, 6, 8, 9];
|
||||
|
||||
/** Schneidet den Dialog dieses Durchlaufs für das Übermittlungsprotokoll mit. */
|
||||
private BankProtokollLogger $mitschnitt;
|
||||
|
||||
|
|
@ -116,6 +149,60 @@ class FinTsUeberweisung
|
|||
return (new self($zugang, $auftragId, ['schritt' => 'anmelden']))->lauf(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragt bei der Bank nach, ob eine Echtzeitüberweisung mit offenem Status
|
||||
* inzwischen ausgeführt wurde – ein eigener Dialog, ohne Sitzung und ohne
|
||||
* Nutzer davor. Aufgerufen vom Hintergrundprozess (`scripts/echtzeit-status.php`).
|
||||
* Verlangt die Bank dafür eine TAN, gibt der Prozess auf; der Ausgang klärt
|
||||
* sich dann über den Kontoumsatz.
|
||||
*
|
||||
* @return array{status: string, meldung: string}
|
||||
*/
|
||||
public static function statusImHintergrund(int $auftragId): array
|
||||
{
|
||||
$auftrag = Zahlungsauftrag::find($auftragId);
|
||||
if ($auftrag === null) {
|
||||
return ['status' => 'fehler', 'meldung' => 'Den Zahlungsauftrag gibt es nicht.'];
|
||||
}
|
||||
|
||||
$sekunden = Zahlungsauftrag::sekundenBisStatusabfrage($auftrag);
|
||||
if ($sekunden === null) {
|
||||
return ['status' => 'fehler', 'meldung' => 'Bei diesem Auftrag ist kein Status offen.'];
|
||||
}
|
||||
if ($sekunden > 0) {
|
||||
return [
|
||||
'status' => 'fehler',
|
||||
'meldung' => sprintf('Die Bank beantwortet die Statusabfrage frühestens in %d Sekunden.', $sekunden),
|
||||
];
|
||||
}
|
||||
|
||||
$zugang = Bankzugang::zugangsdaten((int)($auftrag['zugang_id'] ?? 0));
|
||||
if ($zugang === null) {
|
||||
return ['status' => 'fehler', 'meldung' => 'Die Bankverbindung gibt es nicht.'];
|
||||
}
|
||||
|
||||
return (new self($zugang, $auftragId, ['schritt' => 'anmelden', 'modus' => 'status', 'hintergrund' => true]))
|
||||
->lauf(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet den Hintergrundprozess, der den offenen Status nachfragt. Er läuft
|
||||
* losgelöst von dieser Anfrage weiter; eine Sperre im Skript verhindert,
|
||||
* dass zwei Prozesse denselben Auftrag abfragen.
|
||||
*/
|
||||
public static function hintergrundStarten(int $auftragId): void
|
||||
{
|
||||
$befehl = sprintf(
|
||||
'%s %s %d %d > /dev/null 2>&1 &',
|
||||
escapeshellarg(PHP_BINDIR . '/php'),
|
||||
escapeshellarg(self::STATUS_SKRIPT),
|
||||
Mandant::id(),
|
||||
$auftragId
|
||||
);
|
||||
DebugLog::schreibe('fints', 'Statusabfrage im Hintergrund gestartet für Auftrag ' . $auftragId);
|
||||
exec($befehl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt nach der TAN-Eingabe fort.
|
||||
*
|
||||
|
|
@ -246,7 +333,8 @@ class FinTsUeberweisung
|
|||
$auftrag = Zahlungsauftrag::find($this->auftragId);
|
||||
if ($auftrag !== null) {
|
||||
$this->mitschnitt->vermerke(sprintf(
|
||||
'%s %d: %s, IBAN %s, %s %s, Ausführung %s',
|
||||
'%s%s %d: %s, IBAN %s, %s %s, Ausführung %s',
|
||||
$this->istStatusabfrage() ? 'Statusabfrage ' : '',
|
||||
self::istEchtzeit($auftrag) ? 'Echtzeitüberweisung' : 'Überweisung',
|
||||
$this->auftragId,
|
||||
$auftrag['empfaenger'],
|
||||
|
|
@ -292,7 +380,7 @@ class FinTsUeberweisung
|
|||
{
|
||||
$auftrag = Zahlungsauftrag::find($this->auftragId);
|
||||
if ($auftrag === null) {
|
||||
self::vergessen();
|
||||
$this->sitzungVergessen();
|
||||
return ['status' => 'fehler', 'meldung' => 'Den Zahlungsauftrag gibt es nicht mehr.'];
|
||||
}
|
||||
|
||||
|
|
@ -373,6 +461,10 @@ class FinTsUeberweisung
|
|||
return $this->fehler('Das Auftragskonto ist nicht mehr lesbar. Bitte erneut senden.');
|
||||
}
|
||||
|
||||
if ($this->istStatusabfrage()) {
|
||||
return $this->statusNachfragen($fints, $sepa, (string)$auftrag['bank_auftrag_id']);
|
||||
}
|
||||
|
||||
$echtzeit = self::istEchtzeit($auftrag);
|
||||
if ($echtzeit && $this->echtzeitParameter($fints) === null) {
|
||||
return $this->fehler(
|
||||
|
|
@ -394,7 +486,7 @@ class FinTsUeberweisung
|
|||
$xml = SepaXml::ueberweisung($this->auftragsdaten($auftrag, $sepa), $schema);
|
||||
$aktion = $echtzeit
|
||||
// Keine Umwandlung in eine gewöhnliche Überweisung – siehe oben.
|
||||
? FinTsEchtzeitUeberweisung::create($sepa, $xml, false)
|
||||
? SendSEPARealtimeTransfer::create($sepa, $xml, false)
|
||||
: SendSEPATransfer::create($sepa, $xml);
|
||||
|
||||
try {
|
||||
|
|
@ -628,13 +720,18 @@ class FinTsUeberweisung
|
|||
/** @return array<string,mixed> */
|
||||
private function angenommen(FinTs $fints, ?BaseAction $aktion = null): array
|
||||
{
|
||||
// Eine Statusabfrage, für die die Bank ausnahmsweise eine TAN wollte.
|
||||
if ($aktion instanceof GetSEPARealtimeTransferStatus) {
|
||||
return $this->abschliessen($fints, $this->statusVermerken($fints, $aktion));
|
||||
}
|
||||
|
||||
$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();
|
||||
// Doppelzahlung.
|
||||
$statusOffen = $aktion instanceof SendSEPARealtimeTransfer && $aktion->isStatusPending();
|
||||
|
||||
Zahlungsauftrag::alsGesendetVermerken(
|
||||
$this->auftragId,
|
||||
|
|
@ -642,7 +739,36 @@ class FinTsUeberweisung
|
|||
? '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();
|
||||
|
||||
if ($statusOffen) {
|
||||
$bankAuftragId = (string)$aktion->getOrderId();
|
||||
$this->mitschnitt->vermerke('Status offen (3045), Auftragsidentifikation der Bank: ' . ($bankAuftragId ?: '–'));
|
||||
|
||||
$ergebnis = $this->statusUnbekannt($bankAuftragId, $this->mindestwartezeit($fints));
|
||||
if (Zahlungsauftrag::sekundenBisStatusabfrage(Zahlungsauftrag::find($this->auftragId) ?? []) !== null) {
|
||||
self::hintergrundStarten($this->auftragId);
|
||||
}
|
||||
|
||||
return $this->abschliessen($fints, $ergebnis);
|
||||
}
|
||||
|
||||
return $this->abschliessen($fints, [
|
||||
'status' => 'erfolg',
|
||||
'meldung' => $echtzeit
|
||||
? 'Die Echtzeitüberweisung wurde an die Bank übergeben.'
|
||||
: 'Die Überweisung wurde an die Bank übergeben.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Beendet den Dialog und gibt das Ergebnis zurück.
|
||||
*
|
||||
* @param array<string,mixed> $ergebnis
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function abschliessen(FinTs $fints, array $ergebnis): array
|
||||
{
|
||||
$this->sitzungVergessen();
|
||||
|
||||
try {
|
||||
$fints->close();
|
||||
|
|
@ -650,20 +776,132 @@ class FinTsUeberweisung
|
|||
// 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 $ergebnis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragt den Status einer Echtzeitüberweisung einmal bei der Bank nach (HKIPS).
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function statusNachfragen(FinTs $fints, SEPAAccount $sepa, string $bankAuftragId): array
|
||||
{
|
||||
$wartezeit = $this->mindestwartezeit($fints);
|
||||
if ($bankAuftragId === '' || $wartezeit === null) {
|
||||
// Ohne Auftragsidentifikation oder ohne HKIPS bei der Bank bleibt
|
||||
// nur der Blick in den Kontoumsatz.
|
||||
return $this->abschliessen($fints, $this->statusUnbekannt(null, null));
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'erfolg',
|
||||
'meldung' => $echtzeit
|
||||
? 'Die Echtzeitüberweisung wurde an die Bank übergeben.'
|
||||
: 'Die Überweisung wurde an die Bank übergeben.',
|
||||
];
|
||||
try {
|
||||
$abfrage = GetSEPARealtimeTransferStatus::create($sepa, $bankAuftragId);
|
||||
$fints->execute($abfrage);
|
||||
if ($abfrage->needsTan()) {
|
||||
return $this->tanNoetig($fints, $abfrage, 'Statusabfrage', 'senden');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Die Überweisung selbst ist gesendet und gebucht – ein Fehler bei der
|
||||
// Nachfrage darf sie nicht als gescheitert erscheinen lassen.
|
||||
DebugLog::fehler($e);
|
||||
$this->mitschnitt->vermerke('Statusabfrage fehlgeschlagen: ' . $e->getMessage());
|
||||
$ergebnis = $this->statusUnbekannt($bankAuftragId, $wartezeit);
|
||||
|
||||
return $this->abschliessen($fints, [
|
||||
'status' => 'fehler',
|
||||
'meldung' => 'Die Statusabfrage ist fehlgeschlagen (' . $e->getMessage() . '). ' . $ergebnis['meldung'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->abschliessen($fints, $this->statusVermerken($fints, $abfrage, $bankAuftragId));
|
||||
}
|
||||
|
||||
/** Die Mindestwartezeit der Bank vor einer Statusabfrage – `null`, wenn sie HKIPS nicht anbietet. */
|
||||
private function mindestwartezeit(FinTs $fints): ?int
|
||||
{
|
||||
try {
|
||||
return GetSEPARealtimeTransferStatus::getMinimumWaitingTime($fints->getBpd());
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wertet die Antwort auf die Statusabfrage aus und vermerkt sie am Auftrag.
|
||||
*
|
||||
* @return array{status: string, meldung: string}
|
||||
*/
|
||||
private function statusVermerken(FinTs $fints, GetSEPARealtimeTransferStatus $abfrage, ?string $bankAuftragId = null): array
|
||||
{
|
||||
$status = $abfrage->isStatusPending() ? null : $abfrage->getStatus();
|
||||
$text = $status === null ? null : (self::ECHTZEIT_STATUS[$status] ?? 'Status ' . $status);
|
||||
$this->mitschnitt->vermerke('Status der Echtzeitüberweisung: ' . ($text ?? 'weiterhin offen'));
|
||||
|
||||
if (in_array($status, self::ECHTZEIT_AUSGEFUEHRT, true)) {
|
||||
$meldung = 'Die Bank bestätigt die Echtzeitüberweisung: ' . $text . '.';
|
||||
Zahlungsauftrag::echtzeitAusgefuehrtVermerken($this->auftragId, $meldung);
|
||||
|
||||
return ['status' => 'erfolg', 'meldung' => $meldung];
|
||||
}
|
||||
|
||||
if (in_array($status, self::ECHTZEIT_ABGELEHNT, true)) {
|
||||
$meldung = 'Die Bank meldet die Echtzeitüberweisung als nicht ausgeführt: ' . $text
|
||||
. '. Die Buchung wurde entfernt, der Auftrag kann korrigiert und erneut gesendet werden.';
|
||||
Zahlungsauftrag::echtzeitAbgelehntVermerken($this->auftragId, $meldung);
|
||||
|
||||
return ['status' => 'fehler', 'meldung' => $meldung];
|
||||
}
|
||||
|
||||
return $this->statusUnbekannt(
|
||||
$bankAuftragId ?? (string)(Zahlungsauftrag::find($this->auftragId)['bank_auftrag_id'] ?? ''),
|
||||
$this->mindestwartezeit($fints),
|
||||
$text
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Ausgang ist weiter offen. Mit Auftragsidentifikation lässt er sich
|
||||
* später nachfragen, ohne nur noch über den Kontoumsatz.
|
||||
*
|
||||
* @return array{status: string, meldung: string}
|
||||
*/
|
||||
private function statusUnbekannt(?string $bankAuftragId, ?int $wartezeit, ?string $zwischenstand = null): array
|
||||
{
|
||||
$abfragbar = $bankAuftragId !== null && $bankAuftragId !== '' && $wartezeit !== null;
|
||||
$meldung = 'Die Bank hat die Echtzeitüberweisung entgegengenommen, den Ausgang aber noch nicht bestätigt'
|
||||
. ($zwischenstand !== null ? ' (' . $zwischenstand . ')' : '') . '. '
|
||||
. ($abfragbar
|
||||
? 'Der Finanzmanager fragt den Status im Hintergrund nach; '
|
||||
. 'kommt der Umsatz beim nächsten Abruf, klärt sich das ebenfalls. '
|
||||
: 'Bitte im Kontoumsatz prüfen, ob sie ausgeführt wurde. ')
|
||||
. 'Nicht erneut senden – das wäre eine Doppelzahlung.';
|
||||
|
||||
Zahlungsauftrag::echtzeitOffenVermerken(
|
||||
$this->auftragId,
|
||||
$abfragbar ? $bankAuftragId : null,
|
||||
max(0, (int)$wartezeit),
|
||||
$meldung
|
||||
);
|
||||
|
||||
return ['status' => 'erfolg', 'meldung' => $meldung];
|
||||
}
|
||||
|
||||
private function istStatusabfrage(): bool
|
||||
{
|
||||
return ($this->zustand['modus'] ?? '') === 'status';
|
||||
}
|
||||
|
||||
/** Läuft der Dialog im Hintergrundprozess – ohne Sitzung, ohne Nutzer für eine TAN? */
|
||||
private function imHintergrund(): bool
|
||||
{
|
||||
return ($this->zustand['hintergrund'] ?? false) === true;
|
||||
}
|
||||
|
||||
/** Der Hintergrundprozess hat keine Sitzung, deren Zustand er räumen müsste. */
|
||||
private function sitzungVergessen(): void
|
||||
{
|
||||
if (!$this->imHintergrund()) {
|
||||
self::vergessen();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -697,6 +935,7 @@ class FinTsUeberweisung
|
|||
'aktion' => serialize($aktion),
|
||||
'dialog' => $fints->persist(),
|
||||
'sepa' => $this->zustand['sepa'] ?? null,
|
||||
'modus' => $this->zustand['modus'] ?? null,
|
||||
'vop' => $vop,
|
||||
'zugang_id' => (int)$this->zugang['id'],
|
||||
'auftrag_id' => $this->auftragId,
|
||||
|
|
@ -742,6 +981,19 @@ class FinTsUeberweisung
|
|||
/** @return array<string,mixed> */
|
||||
private function tanNoetig(FinTs $fints, BaseAction $aktion, string $bezeichnung, string $schritt): array
|
||||
{
|
||||
// Im Hintergrund ist niemand da, der eine TAN eingibt.
|
||||
if ($this->imHintergrund()) {
|
||||
$meldung = 'Die Bank verlangt für die Statusabfrage eine Freigabe – automatisch nicht möglich. '
|
||||
. 'Der Ausgang klärt sich beim nächsten Umsatzabruf. Nicht erneut senden.';
|
||||
Zahlungsauftrag::echtzeitAbfrageEinstellen($this->auftragId, $meldung);
|
||||
try {
|
||||
$fints->close();
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
return ['status' => 'fehler', 'meldung' => $meldung];
|
||||
}
|
||||
|
||||
$verfahren = $fints->getSelectedTanMode();
|
||||
$anfrage = $aktion->getTanRequest();
|
||||
|
||||
|
|
@ -772,6 +1024,7 @@ class FinTsUeberweisung
|
|||
'aktion' => serialize($aktion),
|
||||
'dialog' => $fints->persist(),
|
||||
'sepa' => $this->zustand['sepa'] ?? null,
|
||||
'modus' => $this->zustand['modus'] ?? null,
|
||||
'tan' => $tan,
|
||||
'zugang_id' => (int)$this->zugang['id'],
|
||||
'auftrag_id' => $this->auftragId,
|
||||
|
|
@ -788,7 +1041,7 @@ class FinTsUeberweisung
|
|||
private function fehler(string $meldung): array
|
||||
{
|
||||
Zahlungsauftrag::alsFehlerVermerken($this->auftragId, $meldung);
|
||||
self::vergessen();
|
||||
$this->sitzungVergessen();
|
||||
|
||||
return ['status' => 'fehler', 'meldung' => $meldung];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,6 +97,15 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</span>
|
||||
<?php elseif (($auftrag['meldung'] ?? '') !== '' && $auftrag['status'] === 'fehler'): ?>
|
||||
<span class="nebentext"><?= View::h($auftrag['meldung']) ?></span>
|
||||
<?php elseif ($auftrag['status'] === 'gesendet' && ($auftrag['echtzeit_status'] ?? null) === 'offen'): ?>
|
||||
<span class="marke marke-freigabe" id="echtzeit-offen-<?= (int)$auftrag['id'] ?>">Ausgang offen</span>
|
||||
<span class="nebentext" id="echtzeit-hinweis-<?= (int)$auftrag['id'] ?>">
|
||||
<?= ($auftrag['status_abfrage_ab'] ?? null) !== null
|
||||
? 'Die Bank hat die Ausführung noch nicht bestätigt; der Status wird im Hintergrund abgefragt. Nicht erneut senden.'
|
||||
: View::h($auftrag['meldung'] ?? 'Die Bank hat die Ausführung noch nicht bestätigt. Nicht erneut senden.') ?>
|
||||
</span>
|
||||
<?php elseif ($auftrag['status'] === 'gesendet' && ($auftrag['echtzeit_status'] ?? null) === 'ausgefuehrt'): ?>
|
||||
<span class="nebentext"><?= View::h($auftrag['meldung'] ?? 'Ausführung bestätigt.') ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="rechts nowrap">
|
||||
|
|
|
|||
|
|
@ -2,32 +2,223 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Fhp\Action\GetSEPARealtimeTransferStatus;
|
||||
use Fhp\Action\SendSEPARealtimeTransfer;
|
||||
use Fhp\Model\SEPAAccount;
|
||||
use Fhp\Protocol\Message;
|
||||
use Finanzmanager\Services\FinTsEchtzeitUeberweisung;
|
||||
use Finanzmanager\Database;
|
||||
use Finanzmanager\Models\Bankumsatz;
|
||||
use Finanzmanager\Models\Bankzugang;
|
||||
use Finanzmanager\Models\Buchung;
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\Models\Zahlungsauftrag;
|
||||
use Finanzmanager\Services\FinTsUeberweisung;
|
||||
|
||||
/**
|
||||
* Echtzeitüberweisung mit offenem Status (Rückmeldung 3045).
|
||||
* Echtzeitüberweisung mit offenem Status (Rückmeldung 3045, #113).
|
||||
*
|
||||
* Die Antwort stammt aus einem echten Lauf: Die Bank nimmt die TAN an, meldet
|
||||
* aber nur „noch keine Rückmeldung zum Status“. Das ist kein Fehler – der
|
||||
* Auftrag liegt bei der Bank.
|
||||
* Die erste Antwort stammt aus einem echten Lauf: Die Bank nimmt die TAN an,
|
||||
* meldet aber nur „noch keine Rückmeldung zum Status“. Das ist kein Fehler – der
|
||||
* Auftrag liegt bei der Bank, und mit der Auftragsidentifikation aus HIIPZ lässt
|
||||
* sich der Status später über HKIPS nachfragen. Geprüft werden die Bibliothek,
|
||||
* die Vermerke am Auftrag und der Abgleich über den Kontoumsatz. Es verlässt
|
||||
* nichts den Container.
|
||||
*/
|
||||
class EchtzeitStatusOffenTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 54: Echtzeitüberweisung – Status offen';
|
||||
|
||||
private const ANTWORT = <<<'FINTS'
|
||||
private const ANTWORT_OFFEN = <<<'FINTS'
|
||||
HNHBK:1:3+000000000620+300+YWPfOjdl96ABAACrZvYFQwSCCgQA+5+YWPfOjdl96ABAACrZvYFQwSCCgQA:5'HNVSK:998:3+PIN:2+998+1+2::6lrfOjdl96ABAACrZvYFQwSCCgQA+1:20261001:143605+2:2:13:@8@00000000:5:1+280:20041111:PRIVATE_:V:0:0+0'HNVSD:999:1+@374@HNSHK:2:4+PIN:2+902+7079006+1+1+2::6lrfOjdl96ABAACrZvYFQwSCCgQA+1+1:20261001:143605+1:999:1+6:10:19+280:20041111:PRIVATE_:S:0:0'HIRMG:3:2+3060::Bitte beachten Sie die enthaltenen Warnungen/Hinweise.'HIRMS:4:2:3+3045::Wir haben leider noch keine Rückmeldung zum Status ihrer Überweisung.'HITAN:5:6:3+2++47017475'HIIPZ:6:1:3+C6592DB8C24247C493F1B27377EC63DA'HNSHA:7:2+7079006''HNHBS:8:1+5'
|
||||
FINTS;
|
||||
|
||||
/** Antwort auf HKIPS nach Spezifikation: ausgeführt, Geld beim Empfänger. */
|
||||
private const ANTWORT_STATUS = <<<'FINTS'
|
||||
HNHBK:1:3+000000000000+300+YWPfOjdl96ABAACrZvYFQwSCCgQA+6+YWPfOjdl96ABAACrZvYFQwSCCgQA:6'HIRMG:2:2+0010::Nachricht entgegengenommen.'HIRMS:3:2:3+0020::Geld für den Empfänger verfügbar'HIIPS:4:1:3+DE02120300000000202051:BYLADEM1001+++C6592DB8C24247C493F1B27377EC63DA++7'HNHBS:5:1+6'
|
||||
FINTS;
|
||||
|
||||
private int $giro = 0;
|
||||
private int $zugang = 0;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$antwort = Message::parse(mb_convert_encoding(self::ANTWORT, 'ISO-8859-1', 'UTF-8'));
|
||||
$aktion = new FinTsEchtzeitUeberweisung();
|
||||
$this->bibliothekErkenntDenOffenenStatus();
|
||||
$this->bibliothekLiestDieStatusantwort();
|
||||
|
||||
$this->assert(!$aktion->istStatusOffen(), 'Ohne Antwort ist der Status nicht offen');
|
||||
$this->mandantAnlegen();
|
||||
$this->stammdaten();
|
||||
|
||||
$aktion->processResponse($antwort);
|
||||
$this->assert($aktion->istStatusOffen(), 'Rückmeldung 3045 wird als „Status offen“ erkannt, nicht als Fehler');
|
||||
$this->offenerStatusWirdVermerkt();
|
||||
$this->statusabfrageWartetDieMindestwartezeitAb();
|
||||
$this->faelligeAbfragenWerdenGefunden();
|
||||
$this->ausgefuehrtWirdVermerkt();
|
||||
$this->abgelehntEntferntDieBuchung();
|
||||
$this->umsatzKlaertDenOffenenStatus();
|
||||
$this->einNeuerSendeversuchLoeschtDenAltenStatus();
|
||||
}
|
||||
|
||||
private static function nachricht(string $roh): Message
|
||||
{
|
||||
return Message::parse(mb_convert_encoding($roh, 'ISO-8859-1', 'UTF-8'));
|
||||
}
|
||||
|
||||
private function bibliothekErkenntDenOffenenStatus(): void
|
||||
{
|
||||
$aktion = SendSEPARealtimeTransfer::create(new SEPAAccount(), '<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.09"/>');
|
||||
$aktion->processResponse(self::nachricht(self::ANTWORT_OFFEN));
|
||||
|
||||
$this->assert($aktion->isStatusPending(), 'Rückmeldung 3045 gilt als „Status offen“, nicht als Fehler');
|
||||
$this->assertEquals('C6592DB8C24247C493F1B27377EC63DA', $aktion->getOrderId(), 'Auftragsidentifikation aus HIIPZ');
|
||||
$this->assertNull($aktion->getStatus(), 'Ohne Statusangabe in HIIPZ kein Status');
|
||||
}
|
||||
|
||||
private function bibliothekLiestDieStatusantwort(): void
|
||||
{
|
||||
$abfrage = GetSEPARealtimeTransferStatus::create(new SEPAAccount(), 'C6592DB8C24247C493F1B27377EC63DA');
|
||||
$abfrage->processResponse(self::nachricht(self::ANTWORT_STATUS));
|
||||
|
||||
$this->assert(!$abfrage->isStatusPending(), 'Mit Statusangabe ist nichts mehr offen');
|
||||
$this->assertEquals(7, $abfrage->getStatus(), 'Status 7 aus HIIPS gelesen');
|
||||
$this->assert($abfrage->isExecuted(), 'Status 7 heißt ausgeführt');
|
||||
}
|
||||
|
||||
private function stammdaten(): void
|
||||
{
|
||||
$this->giro = Konto::anlegen([
|
||||
'name' => 'Echtzeit-Giro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||||
]);
|
||||
|
||||
$this->zugang = Bankzugang::anlegen([
|
||||
'name' => 'Echtzeit-Bank',
|
||||
'bankname' => 'Echtzeit-Bank AG',
|
||||
'blz' => '12030000',
|
||||
'fints_url' => 'https://banking.example.org/fints',
|
||||
'benutzerkennung' => 'testkunde',
|
||||
'pin' => 'geheim1234',
|
||||
'abrufzeitraum' => 'letzter_abruf',
|
||||
'aktiv' => 1,
|
||||
]);
|
||||
|
||||
// Auf dem eigenen Rechner lauscht niemand – ein echter Versuch scheitert sofort.
|
||||
Database::execute(
|
||||
'UPDATE bank_zugaenge SET fints_url = ? WHERE id = ?',
|
||||
['http://127.0.0.1:9/fints', $this->zugang]
|
||||
);
|
||||
}
|
||||
|
||||
/** Ein gesendeter Auftrag mit offenem Status, frühestens in `$wartezeit` Sekunden abfragbar. */
|
||||
private function offenerAuftrag(int $wartezeit = 0): int
|
||||
{
|
||||
$id = Zahlungsauftrag::anlegen([
|
||||
'konto_id' => $this->giro,
|
||||
'zugang_id' => $this->zugang,
|
||||
'empfaenger' => 'Rheinwerk Verlag GmbH',
|
||||
'iban' => 'DE26380601863001400028',
|
||||
'betrag' => 260.61,
|
||||
'echtzeit' => 1,
|
||||
]);
|
||||
Zahlungsauftrag::alsGesendetVermerken($id, 'Entgegengenommen.');
|
||||
Zahlungsauftrag::echtzeitOffenVermerken($id, 'C6592DB8C24247C493F1B27377EC63DA', $wartezeit, 'Status offen.');
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
private function offenerStatusWirdVermerkt(): void
|
||||
{
|
||||
$id = $this->offenerAuftrag(99);
|
||||
$auftrag = Zahlungsauftrag::find($id);
|
||||
|
||||
$this->assertEquals('gesendet', $auftrag['status'], 'Auftrag bleibt gesendet – erneutes Senden wäre eine Doppelzahlung');
|
||||
$this->assertNotEmpty($auftrag['buchung_id'], 'Auftrag ist gebucht');
|
||||
$this->assertEquals('offen', $auftrag['echtzeit_status'], 'Echtzeit-Status offen');
|
||||
$this->assertEquals('C6592DB8C24247C493F1B27377EC63DA', $auftrag['bank_auftrag_id'], 'Auftragsidentifikation gespeichert');
|
||||
|
||||
$sekunden = Zahlungsauftrag::sekundenBisStatusabfrage($auftrag);
|
||||
$this->assert($sekunden !== null && $sekunden >= 97 && $sekunden <= 99, 'Mindestwartezeit der Bank (99 s) gilt bis zur nächsten Abfrage');
|
||||
}
|
||||
|
||||
private function statusabfrageWartetDieMindestwartezeitAb(): void
|
||||
{
|
||||
$ergebnis = FinTsUeberweisung::statusImHintergrund($this->offenerAuftrag(99));
|
||||
$this->assertEquals('fehler', $ergebnis['status'], 'Vor Ablauf der Mindestwartezeit keine Abfrage');
|
||||
$this->assert(str_contains($ergebnis['meldung'], 'frühestens'), 'Meldung nennt die Wartezeit');
|
||||
|
||||
$ohne = Zahlungsauftrag::anlegen([
|
||||
'konto_id' => $this->giro, 'zugang_id' => $this->zugang, 'empfaenger' => 'Niemand',
|
||||
'iban' => 'DE26380601863001400028', 'betrag' => 1, 'echtzeit' => 1,
|
||||
]);
|
||||
$this->assertNull(Zahlungsauftrag::sekundenBisStatusabfrage(Zahlungsauftrag::find($ohne)), 'Ohne offenen Status keine Abfrage');
|
||||
$this->assertEquals('fehler', FinTsUeberweisung::statusImHintergrund($ohne)['status'], 'Statusabfrage ohne offenen Status wird abgewiesen');
|
||||
}
|
||||
|
||||
private function faelligeAbfragenWerdenGefunden(): void
|
||||
{
|
||||
$faellig = $this->offenerAuftrag(0);
|
||||
$spaeter = $this->offenerAuftrag(99);
|
||||
$ids = Zahlungsauftrag::faelligeStatusabfragen();
|
||||
|
||||
$this->assert(in_array($faellig, $ids, true), 'Fällige Statusabfrage wird gefunden (Neustart nach Container-Neustart)');
|
||||
$this->assert(!in_array($spaeter, $ids, true), 'Noch nicht fällige Statusabfrage bleibt außen vor');
|
||||
|
||||
Zahlungsauftrag::echtzeitAbfrageEinstellen($faellig, 'Eingestellt.');
|
||||
$auftrag = Zahlungsauftrag::find($faellig);
|
||||
$this->assertEquals('offen', $auftrag['echtzeit_status'], 'Eingestellte Abfrage lässt den Ausgang offen');
|
||||
$this->assertNull(Zahlungsauftrag::sekundenBisStatusabfrage($auftrag), 'Eingestellte Abfrage wird nicht mehr angestoßen');
|
||||
$this->assert(!in_array($faellig, Zahlungsauftrag::faelligeStatusabfragen(), true), 'Eingestellte Abfrage ist nicht mehr fällig');
|
||||
}
|
||||
|
||||
private function ausgefuehrtWirdVermerkt(): void
|
||||
{
|
||||
$id = $this->offenerAuftrag();
|
||||
Zahlungsauftrag::echtzeitAusgefuehrtVermerken($id, 'Bestätigt.');
|
||||
$auftrag = Zahlungsauftrag::find($id);
|
||||
|
||||
$this->assertEquals('ausgefuehrt', $auftrag['echtzeit_status'], 'Ausführung vermerkt');
|
||||
$this->assertEquals('gesendet', $auftrag['status'], 'Auftrag bleibt gesendet');
|
||||
$this->assertNull(Zahlungsauftrag::sekundenBisStatusabfrage($auftrag), 'Danach keine Statusabfrage mehr');
|
||||
}
|
||||
|
||||
private function abgelehntEntferntDieBuchung(): void
|
||||
{
|
||||
$id = $this->offenerAuftrag();
|
||||
$buchungId = (int)Zahlungsauftrag::find($id)['buchung_id'];
|
||||
|
||||
Zahlungsauftrag::echtzeitAbgelehntVermerken($id, 'Abgelehnt.');
|
||||
$auftrag = Zahlungsauftrag::find($id);
|
||||
|
||||
$this->assertEquals('fehler', $auftrag['status'], 'Abgelehnter Auftrag lässt sich erneut senden');
|
||||
$this->assertEquals('abgelehnt', $auftrag['echtzeit_status'], 'Ablehnung vermerkt');
|
||||
$this->assertNull($auftrag['buchung_id'], 'Buchungsverweis entfernt');
|
||||
$this->assertNull(Buchung::find($buchungId), 'Buchung entfernt – das Geld ist nicht abgegangen');
|
||||
}
|
||||
|
||||
private function umsatzKlaertDenOffenenStatus(): void
|
||||
{
|
||||
$id = $this->offenerAuftrag();
|
||||
$buchungId = (int)Zahlungsauftrag::find($id)['buchung_id'];
|
||||
|
||||
$umsatzId = Bankumsatz::anlegen([
|
||||
'zugang_id' => $this->zugang,
|
||||
'konto_id' => $this->giro,
|
||||
'extern_id' => 'echtzeit-test-' . $id,
|
||||
'buchungstag' => date('Y-m-d'),
|
||||
'betrag' => -260.61,
|
||||
'empfaenger' => 'Rheinwerk Verlag GmbH',
|
||||
'verwendungszweck' => 'ECHTZEIT-UEBERWEISUNG',
|
||||
]);
|
||||
Bankumsatz::verbinden((int)$umsatzId, $buchungId);
|
||||
|
||||
$this->assertEquals('ausgefuehrt', Zahlungsauftrag::find($id)['echtzeit_status'], 'Verbundener Umsatz klärt den offenen Status');
|
||||
}
|
||||
|
||||
private function einNeuerSendeversuchLoeschtDenAltenStatus(): void
|
||||
{
|
||||
$id = $this->offenerAuftrag();
|
||||
Zahlungsauftrag::echtzeitAbgelehntVermerken($id, 'Abgelehnt.');
|
||||
Zahlungsauftrag::alsGesendetVermerken($id, 'Diesmal angenommen.');
|
||||
$auftrag = Zahlungsauftrag::find($id);
|
||||
|
||||
$this->assertNull($auftrag['echtzeit_status'], 'Erneutes Senden setzt den Echtzeit-Status zurück');
|
||||
$this->assertNull($auftrag['bank_auftrag_id'], 'Erneutes Senden vergisst die alte Auftragsidentifikation');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
37
tests/e2e/specs/echtzeitstatus.spec.js
Normal file
37
tests/e2e/specs/echtzeitstatus.spec.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// @ts-check
|
||||
const { test, expect } = require('@playwright/test')
|
||||
|
||||
const BENUTZER = process.env.E2E_USER || 'e2e'
|
||||
const PASSWORT = process.env.E2E_PASSWORT || process.env.E2E_PASSWORD || 'e2e-testpasswort'
|
||||
|
||||
// Der Grundbestand (E2EGrundbestand) legt eine Echtzeitüberweisung an, deren
|
||||
// Ausgang die Bank offen ließ (Rückmeldung 3045, #113). Die Statusabfrage läuft
|
||||
// im Hintergrund und ist erst in 30 Tagen fällig – es geht keine Anfrage an
|
||||
// eine Bank.
|
||||
const EMPFAENGER = 'E2E Grundbestand Verlag'
|
||||
|
||||
async function anmelden(page) {
|
||||
await page.goto('/login')
|
||||
await page.fill('#benutzername', BENUTZER)
|
||||
await page.fill('#passwort', PASSWORT)
|
||||
await page.click('button[type="submit"]')
|
||||
await expect(page).toHaveURL(/\/$/)
|
||||
}
|
||||
|
||||
test.describe('Zahlungsverkehr: Echtzeitüberweisung mit offenem Status', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await anmelden(page)
|
||||
})
|
||||
|
||||
test('Liste zeigt „Ausgang offen“ mit Hinweis auf die Abfrage im Hintergrund, ohne Knopf', async ({ page }) => {
|
||||
await page.goto('/zahlungsverkehr?status=gesendet')
|
||||
const zeile = page.locator('table.liste tr', { hasText: EMPFAENGER })
|
||||
await expect(zeile).toHaveCount(1)
|
||||
|
||||
await expect(zeile.locator('[id^="echtzeit-offen-"]')).toHaveText('Ausgang offen')
|
||||
await expect(zeile.locator('[id^="echtzeit-hinweis-"]')).toContainText('im Hintergrund abgefragt')
|
||||
await expect(zeile.locator('[id^="echtzeit-hinweis-"]')).toContainText('Nicht erneut senden')
|
||||
// Gesendet: kein Senden-Knopf, und die Abfrage braucht keinen Knopf.
|
||||
await expect(zeile.getByRole('button')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue