Finanzmanager/tests/54_EchtzeitStatusOffenTest.php
fds2610-bot 8b245ad769 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>
2026-10-06 15:48:38 +02:00

224 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

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

<?php
declare(strict_types=1);
use Fhp\Action\GetSEPARealtimeTransferStatus;
use Fhp\Action\SendSEPARealtimeTransfer;
use Fhp\Model\SEPAAccount;
use Fhp\Protocol\Message;
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, #113).
*
* 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_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
{
$this->bibliothekErkenntDenOffenenStatus();
$this->bibliothekLiestDieStatusantwort();
$this->mandantAnlegen();
$this->stammdaten();
$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');
}
}