Finanzmanager/tests/43_EchtzeitueberweisungTest.php
fds2610-bot 290ab86c26 Fix #89: Echtzeitüberweisung scheitert an SEPA-Format und VOP/TAN-Kombination
SepaXml erzeugt jetzt zusätzlich pain.001.001.09 (Dt-Element für den
Ausführungstermin, BICFI statt BIC, UETR je Zahlung) für Banken, die das
ältere pain.001.001.03 nicht mehr führen. FinTsUeberweisung fängt außerdem
die UnsupportedException ab, die php-fints wirft, wenn die Bank
Namensabgleich-Volltreffer und TAN-Anfrage in einem Antwortsatz kombiniert,
und lässt den vorhandenen automatischen Volltreffer-Namensabgleich den Rest
erledigen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 22:54:58 +02:00

209 lines
7.9 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 Finanzmanager\Database;
use Finanzmanager\Models\Bankprotokoll;
use Finanzmanager\Models\Bankzugang;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Zahlungsauftrag;
use Finanzmanager\Services\FinTsUeberweisung;
use Finanzmanager\Services\SepaXml;
/**
* Echtzeitüberweisung (SEPA Instant Credit Transfer).
*
* SEPA behandelt sie als eigenes Verfahren: In der pain.001-Nachricht steht die
* Zahlungsart INST, über FinTS geht sie als HKIPZ hinaus, und einen
* Ausführungstermin kennt sie nicht. Geprüft werden das Merkmal am Auftrag, die
* Nachricht und dass ein Sendeversuch als Echtzeitüberweisung protokolliert
* wird. Wie in Test 42 geht der Versuch an eine Adresse auf dem eigenen
* Rechner, auf der niemand lauscht.
*/
class EchtzeitueberweisungTest extends TestCase
{
protected string $testName = 'Test 43: Echtzeitüberweisung';
private int $giro = 0;
private int $zugang = 0;
public function run(): void
{
$this->mandantAnlegen();
$this->stammdaten();
$this->dasMerkmalWirdGespeichert();
$this->einTerminVertraegtSichNichtMitEchtzeit();
$this->dieNachrichtTraegtDieZahlungsartInst();
$this->nurDasIsoFormatKenntEchtzeit();
$this->derSendeversuchNenntDieEchtzeitueberweisung();
}
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,
]);
}
/** @param array<string,mixed> $daten */
private function auftrag(array $daten = []): int
{
return Zahlungsauftrag::anlegen(array_merge([
'konto_id' => $this->giro,
'zugang_id' => $this->zugang,
'empfaenger' => 'Handwerker Schnell',
'iban' => 'DE02120300000000202051',
'betrag' => 250.0,
'echtzeit' => true,
], $daten));
}
private function dasMerkmalWirdGespeichert(): void
{
$id = $this->auftrag();
$this->assertEquals(1, (int)Zahlungsauftrag::find($id)['echtzeit'], 'Die Echtzeitüberweisung ist vermerkt');
$gewoehnlich = $this->auftrag(['echtzeit' => false]);
$this->assertEquals(0, (int)Zahlungsauftrag::find($gewoehnlich)['echtzeit'], 'Ohne Häkchen bleibt es eine gewöhnliche Überweisung');
Zahlungsauftrag::speichern($id, [
'konto_id' => $this->giro,
'empfaenger' => 'Handwerker Schnell',
'iban' => 'DE02120300000000202051',
'betrag' => 250.0,
]);
$this->assertEquals(0, (int)Zahlungsauftrag::find($id)['echtzeit'], 'Abwählen beim Bearbeiten wirkt');
}
private function einTerminVertraegtSichNichtMitEchtzeit(): void
{
$abgewiesen = false;
try {
$this->auftrag(['ausfuehrung_am' => date('Y-m-d', strtotime('+7 days'))]);
} catch (\InvalidArgumentException) {
$abgewiesen = true;
}
$this->assert($abgewiesen, 'Eine Echtzeitüberweisung mit Termin in der Zukunft wird abgewiesen');
$heute = $this->auftrag(['ausfuehrung_am' => date('Y-m-d')]);
$this->assertNull(
Zahlungsauftrag::find($heute)['ausfuehrung_am'],
'Der heutige Tag ist „sofort“ – der Termin fällt weg'
);
}
private function dieNachrichtTraegtDieZahlungsartInst(): void
{
$auftrag = [
'absender_name' => 'Max Mustermann',
'absender_iban' => 'DE02120300000000202051',
'empfaenger_name' => 'Handwerker Schnell',
'empfaenger_iban' => 'DE02500105170137075030',
'betrag' => 250.0,
'verwendungszweck' => 'Rechnung 4711',
// Ein Termin wird bei Echtzeit nicht übernommen.
'ausfuehrung_am' => '2099-09-05',
'echtzeit' => true,
];
$pfad = static function (string $xml): DOMXPath {
$dokument = new DOMDocument();
$dokument->loadXML($xml);
$xpath = new DOMXPath($dokument);
$xpath->registerNamespace('p', SepaXml::SCHEMA_PAIN_001_001_03);
return $xpath;
};
$xpath = $pfad(SepaXml::ueberweisung($auftrag));
$this->assertEquals(
SepaXml::ECHTZEIT,
$xpath->evaluate('string(//p:PmtInf/p:PmtTpInf/p:LclInstrm/p:Cd)'),
'Die Zahlungsart INST steht in der Nachricht'
);
$this->assertEquals(
'LclInstrm',
$xpath->evaluate('local-name(//p:PmtTpInf/p:SvcLvl/following-sibling::*[1])'),
'Die Zahlungsart folgt unmittelbar auf die Service-Ebene, wie das Schema es verlangt'
);
$this->assertEquals(
SepaXml::SOFORT,
$xpath->evaluate('string(//p:PmtInf/p:ReqdExctnDt)'),
'Eine Echtzeitüberweisung geht immer sofort hinaus'
);
$auftrag['echtzeit'] = false;
$this->assertEquals(
0.0,
$pfad(SepaXml::ueberweisung($auftrag))->evaluate('count(//p:LclInstrm)'),
'Eine gewöhnliche Überweisung trägt keine Zahlungsart'
);
}
private function nurDasIsoFormatKenntEchtzeit(): void
{
$this->assertNull(
SepaXml::schemaWaehlen([SepaXml::SCHEMA_PAIN_001_003_03], true),
'Führt die Bank nur das DK-Format, gibt es keine Echtzeitüberweisung'
);
$this->assertEquals(
SepaXml::SCHEMA_PAIN_001_001_03,
SepaXml::schemaWaehlen([SepaXml::SCHEMA_PAIN_001_003_03, SepaXml::SCHEMA_PAIN_001_001_03 . '_GBIC_4'], true),
'Sonst wird das ISO-Format genommen'
);
$this->assertEquals(
SepaXml::SCHEMA_PAIN_001_001_09,
SepaXml::schemaWaehlen([SepaXml::SCHEMA_PAIN_001_001_03, SepaXml::SCHEMA_PAIN_001_001_09], true),
'Führt die Bank beide ISO-Fassungen, gewinnt für Echtzeit ebenfalls die neuere'
);
$abgewiesen = false;
try {
SepaXml::ueberweisung([
'absender_name' => 'Max Mustermann',
'absender_iban' => 'DE02120300000000202051',
'empfaenger_name' => 'Handwerker Schnell',
'empfaenger_iban' => 'DE02500105170137075030',
'betrag' => 1.0,
'echtzeit' => true,
], SepaXml::SCHEMA_PAIN_001_003_03);
} catch (\InvalidArgumentException) {
$abgewiesen = true;
}
$this->assert($abgewiesen, 'Eine Echtzeitüberweisung im DK-Format wird nicht erzeugt');
}
private function derSendeversuchNenntDieEchtzeitueberweisung(): void
{
// Auf dem eigenen Rechner lauscht niemand – der Versuch scheitert sofort.
Database::execute(
'UPDATE bank_zugaenge SET fints_url = ? WHERE id = ?',
['http://127.0.0.1:9/fints', $this->zugang]
);
$id = $this->auftrag();
$ergebnis = FinTsUeberweisung::senden($id);
FinTsUeberweisung::abbrechen();
$this->assertEquals('fehler', $ergebnis['status'], 'Ohne erreichbare Bank scheitert das Senden');
$this->assertEquals('fehler', Zahlungsauftrag::find($id)['status'], 'Der Auftrag bleibt als fehlgeschlagen stehen');
$eintraege = Bankprotokoll::fuerAuftrag($id);
$this->assert(
$eintraege !== [] && str_contains((string)$eintraege[0]['protokoll'], 'Echtzeitüberweisung'),
'Das Übermittlungsprotokoll nennt die Echtzeitüberweisung'
);
}
}