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, ], ]; } }