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>
284 lines
11 KiB
PHP
284 lines
11 KiB
PHP
<?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,
|
||
],
|
||
];
|
||
}
|
||
}
|