From 3444f8f6ecb5d936d21739c0e4e103e2b5bcb52a Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 1 Sep 2026 18:22:04 +0200 Subject: [PATCH] =?UTF-8?q?Fix=20#54:=20=C3=9Cberweisung=20im=20SEPA-Forma?= =?UTF-8?q?t=20der=20Bank?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Nachricht wurde fest als pain.001.001.03 erzeugt. Banken, die nur das ältere DK-Format pain.001.003.03 führen, weisen die Überweisung damit schon beim Einreichen ab, noch vor der TAN. FinTsUeberweisung liest die unterstützten Formate nach der Anmeldung aus dem Segment HISPAS; SepaXml::schemaWaehlen() bestimmt daraus den Namensraum – bevorzugt ISO, sonst DK. Für die Einzelüberweisung sind beide Fassungen im Aufbau gleich, es genügt der andere Namensraum. Das DK-Schema liegt der Testsuite bei, damit die Nachricht wirklich dagegen geprüft wird. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013iURiWYZJQYVGQNXFFGyWu --- Changes.md | 17 ++ src/Services/FinTsUeberweisung.php | 34 ++- src/Services/SepaXml.php | 45 ++- tests/39_SepaSchemaTest.php | 147 +++++++++ tests/schemata/pain.001.003.03.xsd | 474 +++++++++++++++++++++++++++++ 5 files changed, 711 insertions(+), 6 deletions(-) create mode 100644 tests/39_SepaSchemaTest.php create mode 100644 tests/schemata/pain.001.003.03.xsd diff --git a/Changes.md b/Changes.md index 9335ef8..02cbebf 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,22 @@ # Änderungen +## Zahlungsverkehr: SEPA-Format der Bank (01.09.2026) + +Die Überweisung wurde bisher immer als `pain.001.001.03` eingereicht. Banken, die nur +das ältere DK-Format `pain.001.003.03` führen, weisen sie damit ab, noch bevor eine TAN +angefordert wird ("The bank does not support the XML schema …"). + +`FinTsUeberweisung` liest die unterstützten Formate jetzt nach der Anmeldung aus den +Bankparameterdaten (Segment HISPAS) und `SepaXml::schemaWaehlen()` bestimmt daraus den +Namensraum der Nachricht – bevorzugt das ISO-Format, sonst das DK-Format. Der Zusatz +`_GBIC_x`, den manche Banken anhängen, stört den Vergleich nicht. Meldet die Bank +ausschließlich Formate, die der Finanzmanager nicht erzeugt, nennt die Fehlermeldung sie +jetzt beim Namen, statt die Bibliothek durchschlagen zu lassen. + +Für die Einzelüberweisung sind beide Fassungen im Aufbau gleich; es genügt der andere +Namensraum. `tests/schemata/pain.001.003.03.xsd` (DFÜ-Abkommen Anlage 3, Version 2.7) liegt +der Testsuite bei, damit die erzeugte Nachricht wirklich gegen das Schema geprüft wird. + ## Adressverwaltung in der Verwaltung, Typeahead im Zahlungsverkehr (31.08.2026) Das Adressbuch (bisher nur über die Verträge-Kachel erreichbar) bekommt in der Verwaltung diff --git a/src/Services/FinTsUeberweisung.php b/src/Services/FinTsUeberweisung.php index 27091b0..fea0129 100644 --- a/src/Services/FinTsUeberweisung.php +++ b/src/Services/FinTsUeberweisung.php @@ -12,6 +12,7 @@ use Fhp\Model\SEPAAccount; use Fhp\Model\TanRequestChallengeImage; use Fhp\Options\Credentials; use Fhp\Options\FinTsOptions; +use Fhp\Segment\SPA\HISPAS; use Finanzmanager\Auth; use Finanzmanager\Models\Bankzugang; use Finanzmanager\Models\Einstellung; @@ -193,7 +194,16 @@ class FinTsUeberweisung return $this->fehler('Das Auftragskonto ist nicht mehr lesbar. Bitte erneut senden.'); } - $xml = SepaXml::ueberweisung($this->auftragsdaten($auftrag, $sepa)); + $formate = $this->sepaFormate($fints); + $schema = SepaXml::schemaWaehlen($formate); + if ($schema === null) { + return $this->fehler( + 'Die Bank nimmt nur SEPA-Formate an, die der Finanzmanager nicht erzeugt: ' + . implode(', ', $formate) + ); + } + + $xml = SepaXml::ueberweisung($this->auftragsdaten($auftrag, $sepa), $schema); $aktion = SendSEPATransfer::create($sepa, $xml); $fints->execute($aktion); @@ -211,6 +221,28 @@ class FinTsUeberweisung return $this->fehler('Der Auftrag konnte nicht abgeschlossen werden.'); } + /** + * Die SEPA-Datenformate, die die Bank annimmt. + * + * Sie stehen in den Bankparameterdaten (Segment HISPAS) und liegen nach der + * Anmeldung vor. Ältere Häuser führen ausschließlich das DK-Format + * pain.001.003.03 und weisen alles andere schon beim Einreichen ab. + * + * @return list + */ + private function sepaFormate(FinTs $fints): array + { + try { + $hispas = $fints->getBpd()->getLatestSupportedParameters('HISPAS'); + } catch (\Throwable) { + return []; + } + + return $hispas instanceof HISPAS + ? array_values($hispas->getParameter()->getUnterstuetzteSEPADatenformate()) + : []; + } + /** * @param array $auftrag * @return array diff --git a/src/Services/SepaXml.php b/src/Services/SepaXml.php index a1c66bc..4707025 100644 --- a/src/Services/SepaXml.php +++ b/src/Services/SepaXml.php @@ -13,12 +13,19 @@ namespace Finanzmanager\Services; * Einzelüberweisung, um die es hier geht, ist das Format überschaubar und in der * Spezifikation der Deutschen Kreditwirtschaft vollständig beschrieben. * - * Erzeugt wird **pain.001.001.03** – die Fassung, die die deutschen Banken für - * FinTS-Überweisungen erwarten. + * Erzeugt wird **pain.001.001.03** oder – wenn die Bank nur das ältere + * DK-Format führt – **pain.001.003.03**. Für die Einzelüberweisung sind beide + * Fassungen im Aufbau gleich (Reihenfolge und Pflichtfelder stimmen überein), + * sie unterscheiden sich nur im Namensraum. Welches Format die Bank annimmt, + * steht im FinTS-Segment HISPAS – siehe `Services\FinTsUeberweisung`. */ class SepaXml { - private const SCHEMA = 'urn:iso:std:iso:20022:tech:xsd:pain.001.001.03'; + public const SCHEMA_PAIN_001_001_03 = 'urn:iso:std:iso:20022:tech:xsd:pain.001.001.03'; + public const SCHEMA_PAIN_001_003_03 = 'urn:iso:std:iso:20022:tech:xsd:pain.001.003.03'; + + /** Die Formate, die hier erzeugt werden – in der Reihenfolge des Vorzugs. */ + private const SCHEMATA = [self::SCHEMA_PAIN_001_001_03, self::SCHEMA_PAIN_001_003_03]; /** * @param array{ @@ -27,8 +34,9 @@ class SepaXml * betrag: float, waehrung?: string, verwendungszweck?: string, * ausfuehrung_am?: ?string * } $auftrag + * @param string $schema Namensraum der Nachricht, siehe `schemaWaehlen()`. */ - public static function ueberweisung(array $auftrag): string + public static function ueberweisung(array $auftrag, string $schema = self::SCHEMA_PAIN_001_001_03): string { $betrag = number_format(abs((float)$auftrag['betrag']), 2, '.', ''); if ((float)$betrag <= 0.0) { @@ -50,7 +58,7 @@ class SepaXml $dokument = new \DOMDocument('1.0', 'UTF-8'); $dokument->formatOutput = true; - $wurzel = $dokument->createElementNS(self::SCHEMA, 'Document'); + $wurzel = $dokument->createElementNS($schema, 'Document'); $dokument->appendChild($wurzel); $nachricht = $dokument->createElement('CstmrCdtTrfInitn'); @@ -151,6 +159,33 @@ class SepaXml return (string)$dokument->saveXML(); } + /** + * Das Format, das zu den von der Bank gemeldeten SEPA-Datenformaten passt. + * + * Die Banken melden ihre Formate in HISPAS, manche mit dem Zusatz `_GBIC_4` + * – deshalb der Präfixvergleich. Meldet die Bank nichts, bleibt es beim + * Vorzugsformat; meldet sie nur Formate, die hier nicht erzeugt werden, + * kommt `null` zurück. + * + * @param list $unterstuetzt + */ + public static function schemaWaehlen(array $unterstuetzt): ?string + { + if ($unterstuetzt === []) { + return self::SCHEMA_PAIN_001_001_03; + } + + foreach (self::SCHEMATA as $schema) { + foreach ($unterstuetzt as $format) { + if (str_starts_with(trim($format), $schema)) { + return $schema; + } + } + } + + return null; + } + private static function kind(\DOMDocument $dokument, \DOMElement $eltern, string $name, string $wert): void { // Über createTextNode statt als zweites Argument von createElement: diff --git a/tests/39_SepaSchemaTest.php b/tests/39_SepaSchemaTest.php new file mode 100644 index 0000000..c527a0a --- /dev/null +++ b/tests/39_SepaSchemaTest.php @@ -0,0 +1,147 @@ +schemaFolgtDenAngabenDerBank(); + $this->unbekannteFormateFuehrenZuNull(); + $this->dkFormatIstSchemakonform(); + $this->dasFormatStehtImNamensraum(); + } + + 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' + ); + } + + private function unbekannteFormateFuehrenZuNull(): void + { + $this->assertNull( + SepaXml::schemaWaehlen(['urn:iso:std:iso:20022:tech:xsd:pain.001.001.09']), + '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' + ); + } + + /** @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' => '2026-09-05', + ], + 'ohne BIC' => [ + 'absender_name' => 'Max Mustermann', + 'absender_iban' => 'DE02120300000000202051', + 'empfaenger_name' => 'Stadtwerke', + 'empfaenger_iban' => 'DE02500105170137075030', + 'betrag' => 1.0, + 'verwendungszweck' => '', + 'ausfuehrung_am' => null, + ], + ]; + } +} diff --git a/tests/schemata/pain.001.003.03.xsd b/tests/schemata/pain.001.003.03.xsd new file mode 100644 index 0000000..a90ed99 --- /dev/null +++ b/tests/schemata/pain.001.003.03.xsd @@ -0,0 +1,474 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + If used, it is recommended to be used at ‘Payment Information’ level and not at ‘Credit Transfer Transaction Information’ level. + + + + + + It is recommended that this element be specified at ‘Payment Information’ level. + + + + + This data element may be present either at ‘Payment Information’ or at ‘Credit Transfer Transaction Information’ level. + + + + + + + + + + + + + + + + If a Creditor Reference contains a check digit, the receiving bank is not required to validate this. +If the receiving bank validates the check digit and if this validation fails, the bank may continue its processing and send the transaction to the next party in the chain. +RF Creditor Reference may be used (ISO 11649). + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Either ‘BIC or BEI’ or one +occurrence of ‘Other’ is allowed. + + + + + Either ‘Date and Place of Birth’ or one occurrence of ‘Other’ is allowed. + + + + + + + + + + ‘Name’ is limited to 70 characters +in length. + + + + + + + + + + ‘Name’ is limited to 70 characters +in length. + + + + + + + + + + + + + + + + + + Only ‘TRF’ is allowed. + + + + + If present and contains ‘true’, batch booking is requested. If present and contains ‘false’, booking per transaction is requested. If element is not present, pre-agreed customer-to-bank conditions apply. + + + + + + + If used, it is recommended to be used only at ‘Payment Information’ level and not at Credit Transfer Transaction Information’ level. +When Instruction Priority is to be used, ‘Payment Type Information’ must be present at ‘Payment Information’ level. + + + + + + + + + This data element may be present either at ‘Payment Information’ or at ‘Credit Transfer Transaction Information’ level. + + + + + It is recommended that this element be specified at ‘Payment Information’ level. + + + + + + + + + + + + + + + If present, pre-agreed customer-to-bank conditions apply. + + + + + + Depending on the agreement between the Originator and the Originator Bank, ‘Category Purpose’ may be forwarded to the Beneficiary Bank. + + + + + + + + + + Depending on the agreement between the Originator and the Originator Bank, ‘Category Purpose’ may be forwarded to the Beneficiary Bank. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Only codes from the ISO 20022 ExternalPurposeCode list are allowed. + + + + + + + + + + + + + + + + + + + + + + When present, the receiving bank is not obliged to validate the the reference information. + + + + + + + + + +