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), '' . SepaXml::SOFORT . ''),
'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), '' . $auftrag['ausfuehrung_am'] . ''),
'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 `
`, 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> */
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,
],
];
}
}