Finanzmanager/tests/39_SepaSchemaTest.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

284 lines
11 KiB
PHP
Raw Permalink 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\Services\SepaXml;
/**
* Die SEPA-Nachricht im Format, das die Bank annimmt.
*
* Nicht jede Bank führt das ISO-Format pain.001.001.03. Etliche Häuser nehmen
* ausschließlich das ältere DK-Format pain.001.003.03 an, andere melden nur
* noch das neuere pain.001.001.09 – und weisen alles andere schon beim
* Einreichen ab ("The bank does not support the XML schema …"). Welche
* Formate eine Bank führt, meldet sie im Segment HISPAS.
*
* Das DK-Schema liegt als `tests/schemata/pain.001.003.03.xsd` bei, damit die
* erzeugte Nachricht hier wirklich geprüft wird und nicht nur auf Textbausteine
* hin abgeklopft. Für pain.001.001.09 gibt es kein XSD in diesem Repository;
* geprüft werden hier die drei Stellen, an denen sich das Format von .001.03
* unterscheidet (siehe `SepaXml`-Klassenkommentar).
*/
class SepaSchemaTest extends TestCase
{
protected string $testName = 'Test 39: SEPA-Schema der Überweisung';
private const XSD = __DIR__ . '/schemata/pain.001.003.03.xsd';
public function run(): void
{
$this->schemaFolgtDenAngabenDerBank();
$this->unbekannteFormateFuehrenZuNull();
$this->dkFormatIstSchemakonform();
$this->dasFormatStehtImNamensraum();
$this->ohneTerminWirdSofortUeberwiesen();
$this->pain09TraegtDieNeuerungen();
}
/**
* Ohne Termin muss das Platzhalterdatum der Deutschen Kreditwirtschaft in
* der Nachricht stehen. Steht dort ein echtes Datum, macht die Bibliothek
* daraus eine terminierte Überweisung (HKCSE statt HKCCS) – und die Bank
* weist sie zurück: „Die minimale Vorlaufzeit beträgt 1 Tage.“
*/
private function ohneTerminWirdSofortUeberwiesen(): void
{
$this->assertEquals(
SepaXml::SOFORT,
SepaXml::ausfuehrungstag('', '2026-09-01'),
'Ohne Termin gilt sofort'
);
$this->assertEquals(
SepaXml::SOFORT,
SepaXml::ausfuehrungstag('2026-09-01', '2026-09-01'),
'Der heutige Tag ist ebenfalls sofort – sonst verlangt die Bank Vorlaufzeit'
);
$this->assertEquals(
SepaXml::SOFORT,
SepaXml::ausfuehrungstag('2026-08-30', '2026-09-01'),
'Ein verstrichener Termin auch'
);
$this->assertEquals(
'2026-09-15',
SepaXml::ausfuehrungstag('2026-09-15', '2026-09-01'),
'Ein Termin in der Zukunft bleibt stehen'
);
$auftrag = $this->auftraege()['ohne BIC'];
$this->assert(
str_contains(SepaXml::ueberweisung($auftrag), '<ReqdExctnDt>' . SepaXml::SOFORT . '</ReqdExctnDt>'),
'Und das Platzhalterdatum steht auch wirklich in der Nachricht'
);
$auftrag['ausfuehrung_am'] = date('Y-m-d', strtotime('+30 days'));
$this->assert(
str_contains(SepaXml::ueberweisung($auftrag), '<ReqdExctnDt>' . $auftrag['ausfuehrung_am'] . '</ReqdExctnDt>'),
'Ein Termin in der Zukunft steht unverändert drin'
);
}
private function schemaFolgtDenAngabenDerBank(): void
{
$this->assertEquals(
SepaXml::SCHEMA_PAIN_001_001_03,
SepaXml::schemaWaehlen([]),
'Ohne Angabe der Bank bleibt es beim Vorzugsformat'
);
$this->assertEquals(
SepaXml::SCHEMA_PAIN_001_003_03,
SepaXml::schemaWaehlen([SepaXml::SCHEMA_PAIN_001_003_03, SepaXml::SCHEMA_PAIN_001_003_03]),
'Führt die Bank nur das DK-Format, wird es genommen'
);
$this->assertEquals(
SepaXml::SCHEMA_PAIN_001_001_03,
SepaXml::schemaWaehlen([SepaXml::SCHEMA_PAIN_001_003_03, SepaXml::SCHEMA_PAIN_001_001_03]),
'Bei mehreren Formaten hat das ISO-Format Vorrang'
);
// Manche Banken hängen dem Namensraum eine Fassung des Deutschen
// Kreditgewerbes an – der Vergleich muss das aushalten.
$this->assertEquals(
SepaXml::SCHEMA_PAIN_001_001_03,
SepaXml::schemaWaehlen([SepaXml::SCHEMA_PAIN_001_001_03 . '_GBIC_4']),
'Der Zusatz _GBIC_4 stört den Vergleich nicht'
);
$this->assertEquals(
SepaXml::SCHEMA_PAIN_001_001_09,
SepaXml::schemaWaehlen([SepaXml::SCHEMA_PAIN_001_001_09]),
'Meldet die Bank nur das neuere ISO-Format, wird das erzeugt'
);
$this->assertEquals(
SepaXml::SCHEMA_PAIN_001_001_09,
SepaXml::schemaWaehlen([SepaXml::SCHEMA_PAIN_001_001_03, SepaXml::SCHEMA_PAIN_001_001_09]),
'Führt die Bank beide ISO-Fassungen, gewinnt die neuere'
);
}
private function unbekannteFormateFuehrenZuNull(): void
{
$this->assertNull(
SepaXml::schemaWaehlen(['urn:iso:std:iso:20022:tech:xsd:pain.001.001.99']),
'Führt die Bank nur Formate, die hier niemand erzeugt, kommt null zurück'
);
}
private function dkFormatIstSchemakonform(): void
{
foreach ($this->auftraege() as $bezeichnung => $auftrag) {
$xml = SepaXml::ueberweisung($auftrag, SepaXml::SCHEMA_PAIN_001_003_03);
$dokument = new DOMDocument();
$dokument->loadXML($xml);
$vorher = libxml_use_internal_errors(true);
$gueltig = $dokument->schemaValidate(self::XSD);
$meldungen = array_map(
static fn(LibXMLError $fehler): string => trim($fehler->message),
libxml_get_errors()
);
libxml_clear_errors();
libxml_use_internal_errors($vorher);
$this->assert(
$gueltig,
'pain.001.003.03 (' . $bezeichnung . ') ist schemakonform' . ($gueltig ? '' : ': ' . implode(' | ', $meldungen))
);
}
}
private function dasFormatStehtImNamensraum(): void
{
$auftrag = $this->auftraege()['mit BIC'];
$iso = SepaXml::ueberweisung($auftrag, SepaXml::SCHEMA_PAIN_001_001_03);
$dk = SepaXml::ueberweisung($auftrag, SepaXml::SCHEMA_PAIN_001_003_03);
$this->assertEquals(
SepaXml::SCHEMA_PAIN_001_001_03,
(string)(simplexml_load_string($iso)?->getDocNamespaces()[''] ?? ''),
'Der Namensraum des ISO-Formats'
);
$this->assertEquals(
SepaXml::SCHEMA_PAIN_001_003_03,
(string)(simplexml_load_string($dk)?->getDocNamespaces()[''] ?? ''),
'Der Namensraum des DK-Formats'
);
// Ohne ausdrückliches Format bleibt es beim ISO-Format – der Aufruf aus
// dem Zahlungsverkehr setzt es sonst still um.
$this->assert(
str_contains(SepaXml::ueberweisung($auftrag), 'pain.001.001.03'),
'Ohne Angabe wird weiterhin pain.001.001.03 erzeugt'
);
}
/**
* pain.001.001.09 unterscheidet sich von .001.03 an drei Stellen (siehe
* `SepaXml`-Klassenkommentar): der Ausführungstermin steht in `<Dt>`, die
* Bankkennung heißt `BICFI`, und die Zahlung trägt eine `UETR`.
*/
private function pain09TraegtDieNeuerungen(): void
{
$pfad = static function (string $xml): DOMXPath {
$dokument = new DOMDocument();
$dokument->loadXML($xml);
$xpath = new DOMXPath($dokument);
$xpath->registerNamespace('p', SepaXml::SCHEMA_PAIN_001_001_09);
return $xpath;
};
$mitBic = $pfad(SepaXml::ueberweisung($this->auftraege()['mit BIC'], SepaXml::SCHEMA_PAIN_001_001_09));
$this->assertEquals(
'2099-09-05',
$mitBic->evaluate('string(//p:PmtInf/p:ReqdExctnDt/p:Dt)'),
'Der Ausführungstermin steht in einem eigenen Dt-Element'
);
$this->assertEquals(
0.0,
$mitBic->evaluate("count(//p:PmtInf/p:ReqdExctnDt/text()[normalize-space(.) != ''])"),
'Und nicht mehr als nackter Text direkt in ReqdExctnDt – nur noch das Dt-Kind'
);
$this->assertEquals(
'BYLADEM1001',
$mitBic->evaluate('string(//p:DbtrAgt/p:FinInstnId/p:BICFI)'),
'Die Bankkennung des Schuldners heißt BICFI'
);
$this->assertEquals(
'INGDDEFFXXX',
$mitBic->evaluate('string(//p:CdtrAgt/p:FinInstnId/p:BICFI)'),
'Ebenso die des Empfängers'
);
$this->assertEquals(
0.0,
$mitBic->evaluate('count(//p:FinInstnId/p:BIC)'),
'Das alte Element BIC kommt in diesem Format nicht mehr vor'
);
$uetr = $mitBic->evaluate('string(//p:PmtId/p:UETR)');
$this->assert(
(bool)preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', $uetr),
'Die UETR ist eine UUID Version 4'
);
$zweiteUetr = $pfad(SepaXml::ueberweisung($this->auftraege()['mit BIC'], SepaXml::SCHEMA_PAIN_001_001_09))
->evaluate('string(//p:PmtId/p:UETR)');
$this->assert($uetr !== $zweiteUetr, 'Jede Nachricht bekommt ihre eigene UETR');
// Ohne BIC bleibt es bei der bekannten Ersatzkennung – nur eben unter
// dem neuen Namensraum.
$ohneBic = $pfad(SepaXml::ueberweisung($this->auftraege()['ohne BIC'], SepaXml::SCHEMA_PAIN_001_001_09));
$this->assertEquals(
'NOTPROVIDED',
$ohneBic->evaluate('string(//p:DbtrAgt/p:FinInstnId/p:Othr/p:Id)'),
'Ohne BIC steht weiterhin die Ersatzkennung, nicht ein leeres BICFI'
);
// Eine Echtzeitüberweisung im neuen Format trägt beide Merkmale zugleich.
$echtzeit = $this->auftraege()['ohne BIC'];
$echtzeit['echtzeit'] = true;
$echtzeitPfad = $pfad(SepaXml::ueberweisung($echtzeit, SepaXml::SCHEMA_PAIN_001_001_09));
$this->assertEquals(
SepaXml::ECHTZEIT,
$echtzeitPfad->evaluate('string(//p:PmtTpInf/p:LclInstrm/p:Cd)'),
'Echtzeit und pain.001.001.09 vertragen sich (Zahlungsart INST)'
);
$this->assertEquals(
SepaXml::SOFORT,
$echtzeitPfad->evaluate('string(//p:ReqdExctnDt/p:Dt)'),
'Und weiterhin sofort'
);
}
/** @return array<string,array<string,mixed>> */
private function auftraege(): array
{
return [
'mit BIC' => [
'absender_name' => 'Max Mustermann',
'absender_iban' => 'DE02 1203 0000 0000 2020 51',
'absender_bic' => 'BYLADEM1001',
'empfaenger_name' => 'Stadtwerke Größenwahn',
'empfaenger_iban' => 'DE02500105170137075030',
'empfaenger_bic' => 'INGDDEFFXXX',
'betrag' => 123.45,
'waehrung' => 'EUR',
'verwendungszweck' => 'Abschlag Strom & Wasser 09/2026',
'ausfuehrung_am' => '2099-09-05',
],
'ohne BIC' => [
'absender_name' => 'Max Mustermann',
'absender_iban' => 'DE02120300000000202051',
'empfaenger_name' => 'Stadtwerke',
'empfaenger_iban' => 'DE02500105170137075030',
'betrag' => 1.0,
'verwendungszweck' => '',
'ausfuehrung_am' => null,
],
];
}
}