Finanzmanager/src/Services/SepaXml.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

332 lines
15 KiB
PHP
Raw 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);
namespace Finanzmanager\Services;
/**
* Erzeugt die pain.001-Nachricht einer SEPA-Überweisung.
*
* **Warum selbst geschrieben.** php-fints erwartet die fertige XML-Nachricht und
* erzeugt sie nicht; die üblichen Erzeuger (sephpa, php-sepa-xml) wären zwei
* weitere Abhängigkeiten für ein Dokument von sechzig Zeilen. Für die eine
* 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**, das neuere **pain.001.001.09** oder – wenn
* die Bank nur das ältere DK-Format führt – **pain.001.003.03**. Für die
* Einzelüberweisung sind alle drei Fassungen im Aufbau fast gleich; welches
* Format die Bank annimmt, steht im FinTS-Segment HISPAS – siehe
* `Services\FinTsUeberweisung`.
*
* **pain.001.001.09** unterscheidet sich von .001.03 an drei Stellen, die hier
* wirklich vorkommen: Der Ausführungstermin steht in einem eigenen `<Dt>`
* (`ReqdExctnDt/Dt` statt eines nackten Datums), die Bankkennung heißt `BICFI`
* statt `BIC`, und die Zahlung trägt eine `UETR` (eine UUID, die eine
* Echtzeitüberweisung durch alle beteiligten Systeme verfolgbar macht – das
* SEPA-Instant-Regelwerk verlangt sie seit November 2019). Die Bibliothek
* `nemiah/php-fints` liest schon länger beide Fassungen (siehe `CAMT.php`:
* `$finInstn->BICFI ?? $finInstn->BIC` und `SendSEPATransfer.php`:
* `$pmtInfo->ReqdExctnDt->Dt ?? $pmtInfo->ReqdExctnDt`), erzeugt sie aber
* nicht – das holt diese Klasse nach.
*
* **Echtzeitüberweisung.** SEPA behandelt sie als eigenes Verfahren (SCT Inst):
* In der Nachricht steht zusätzlich die Zahlungsart `LclInstrm/Cd = INST`, und
* die Bank bekommt sie über einen eigenen Geschäftsvorfall (HKIPZ). Das ältere
* DK-Format pain.001.003.03 kennt die Zahlungsart nicht – eine
* Echtzeitüberweisung gibt es deshalb nur im ISO-Format (pain.001.001.03 oder
* pain.001.001.09).
*/
class SepaXml
{
public const SCHEMA_PAIN_001_001_03 = 'urn:iso:std:iso:20022:tech:xsd:pain.001.001.03';
public const SCHEMA_PAIN_001_001_09 = 'urn:iso:std:iso:20022:tech:xsd:pain.001.001.09';
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_09, self::SCHEMA_PAIN_001_001_03, self::SCHEMA_PAIN_001_003_03,
];
/** Die Formate, in denen sich eine Echtzeitüberweisung ausdrücken lässt. */
private const SCHEMATA_ECHTZEIT = [self::SCHEMA_PAIN_001_001_09, self::SCHEMA_PAIN_001_001_03];
/** Zahlungsart (Local Instrument) der Echtzeitüberweisung. */
public const ECHTZEIT = 'INST';
/**
* Das Ausführungsdatum für „sofort“.
*
* Das Format verlangt ein Datum, auch wenn keins gewünscht ist. Die Deutsche
* Kreditwirtschaft hat dafür den 01.01.1999 vorgesehen; erst daran erkennt
* die Bank eine gewöhnliche Überweisung (HKCCS). Steht dort ein echtes
* Datum – und sei es das von heute –, wird daraus eine terminierte
* Überweisung (HKCSE), und die weist die Bank mit „Die minimale Vorlaufzeit
* beträgt 1 Tage“ zurück.
*/
public const SOFORT = '1999-01-01';
/**
* @param array{
* absender_name: string, absender_iban: string, absender_bic?: string,
* empfaenger_name: string, empfaenger_iban: string, empfaenger_bic?: string,
* betrag: float, waehrung?: string, verwendungszweck?: string,
* ausfuehrung_am?: ?string, echtzeit?: bool
* } $auftrag
* @param string $schema Namensraum der Nachricht, siehe `schemaWaehlen()`.
*/
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) {
throw new \InvalidArgumentException('Der Betrag muss größer als null sein.');
}
$echtzeit = !empty($auftrag['echtzeit']);
if ($echtzeit && !in_array($schema, self::SCHEMATA_ECHTZEIT, true)) {
throw new \InvalidArgumentException(
'Eine Echtzeitüberweisung gibt es nur im ISO-Format (pain.001.001.03 oder pain.001.001.09).'
);
}
// pain.001.001.09 kennt drei Neuerungen gegenüber .001.03, die hier vorkommen –
// siehe Klassenkommentar.
$neuesSchema = $schema === self::SCHEMA_PAIN_001_001_09;
// Eindeutige Kennungen: Die Bank lehnt eine Nachricht ab, deren Kennung
// sie schon kennt.
$kennung = 'FM' . date('YmdHis') . substr(bin2hex(random_bytes(4)), 0, 6);
$zeitpunkt = date('Y-m-d\TH:i:s');
// Eine Echtzeitüberweisung geht immer sofort hinaus.
$ausfuehrung = $echtzeit
? self::SOFORT
: self::ausfuehrungstag((string)($auftrag['ausfuehrung_am'] ?? ''));
$waehrung = strtoupper((string)($auftrag['waehrung'] ?? 'EUR'));
$zweck = self::text((string)($auftrag['verwendungszweck'] ?? ''), 140);
$dokument = new \DOMDocument('1.0', 'UTF-8');
$dokument->formatOutput = true;
$wurzel = $dokument->createElementNS($schema, 'Document');
$dokument->appendChild($wurzel);
$nachricht = $dokument->createElement('CstmrCdtTrfInitn');
$wurzel->appendChild($nachricht);
// ── Kopf ────────────────────────────────────────────────────────────
$kopf = $dokument->createElement('GrpHdr');
$nachricht->appendChild($kopf);
self::kind($dokument, $kopf, 'MsgId', $kennung);
self::kind($dokument, $kopf, 'CreDtTm', $zeitpunkt);
self::kind($dokument, $kopf, 'NbOfTxs', '1');
self::kind($dokument, $kopf, 'CtrlSum', $betrag);
$auftraggeber = $dokument->createElement('InitgPty');
$kopf->appendChild($auftraggeber);
self::kind($dokument, $auftraggeber, 'Nm', self::text((string)$auftrag['absender_name'], 70));
// ── Sammler (eine Zahlung) ──────────────────────────────────────────
$sammler = $dokument->createElement('PmtInf');
$nachricht->appendChild($sammler);
self::kind($dokument, $sammler, 'PmtInfId', $kennung);
self::kind($dokument, $sammler, 'PmtMtd', 'TRF');
self::kind($dokument, $sammler, 'BtchBookg', 'false');
self::kind($dokument, $sammler, 'NbOfTxs', '1');
self::kind($dokument, $sammler, 'CtrlSum', $betrag);
$art = $dokument->createElement('PmtTpInf');
$sammler->appendChild($art);
$ebene = $dokument->createElement('SvcLvl');
$art->appendChild($ebene);
self::kind($dokument, $ebene, 'Cd', 'SEPA');
if ($echtzeit) {
$zahlungsart = $dokument->createElement('LclInstrm');
$art->appendChild($zahlungsart);
self::kind($dokument, $zahlungsart, 'Cd', self::ECHTZEIT);
}
if ($neuesSchema) {
$termin = $dokument->createElement('ReqdExctnDt');
$sammler->appendChild($termin);
self::kind($dokument, $termin, 'Dt', $ausfuehrung);
} else {
self::kind($dokument, $sammler, 'ReqdExctnDt', $ausfuehrung);
}
$schuldner = $dokument->createElement('Dbtr');
$sammler->appendChild($schuldner);
self::kind($dokument, $schuldner, 'Nm', self::text((string)$auftrag['absender_name'], 70));
$schuldnerKonto = $dokument->createElement('DbtrAcct');
$sammler->appendChild($schuldnerKonto);
$kennungA = $dokument->createElement('Id');
$schuldnerKonto->appendChild($kennungA);
self::kind($dokument, $kennungA, 'IBAN', self::iban((string)$auftrag['absender_iban']));
$schuldnerBank = $dokument->createElement('DbtrAgt');
$sammler->appendChild($schuldnerBank);
$bankKennung = $dokument->createElement('FinInstnId');
$schuldnerBank->appendChild($bankKennung);
// Ohne BIC gilt „Other/NOTPROVIDED“ – im SEPA-Raum ist die IBAN genug.
// Seit pain.001.001.09 heißt das Element BICFI statt BIC.
$bicElement = $neuesSchema ? 'BICFI' : 'BIC';
if (($auftrag['absender_bic'] ?? '') !== '') {
self::kind($dokument, $bankKennung, $bicElement, strtoupper(trim((string)$auftrag['absender_bic'])));
} else {
$anders = $dokument->createElement('Othr');
$bankKennung->appendChild($anders);
self::kind($dokument, $anders, 'Id', 'NOTPROVIDED');
}
self::kind($dokument, $sammler, 'ChrgBr', 'SLEV');
// ── Die Zahlung ─────────────────────────────────────────────────────
$zahlung = $dokument->createElement('CdtTrfTxInf');
$sammler->appendChild($zahlung);
$zahlungsId = $dokument->createElement('PmtId');
$zahlung->appendChild($zahlungsId);
self::kind($dokument, $zahlungsId, 'EndToEndId', 'NOTPROVIDED');
if ($neuesSchema) {
self::kind($dokument, $zahlungsId, 'UETR', self::uetr());
}
$summe = $dokument->createElement('Amt');
$zahlung->appendChild($summe);
$wert = $dokument->createElement('InstdAmt', $betrag);
$wert->setAttribute('Ccy', $waehrung);
$summe->appendChild($wert);
if (($auftrag['empfaenger_bic'] ?? '') !== '') {
$empfaengerBank = $dokument->createElement('CdtrAgt');
$zahlung->appendChild($empfaengerBank);
$empfaengerBankId = $dokument->createElement('FinInstnId');
$empfaengerBank->appendChild($empfaengerBankId);
self::kind($dokument, $empfaengerBankId, $bicElement, strtoupper(trim((string)$auftrag['empfaenger_bic'])));
}
$empfaenger = $dokument->createElement('Cdtr');
$zahlung->appendChild($empfaenger);
self::kind($dokument, $empfaenger, 'Nm', self::text((string)$auftrag['empfaenger_name'], 70));
$empfaengerKonto = $dokument->createElement('CdtrAcct');
$zahlung->appendChild($empfaengerKonto);
$empfaengerId = $dokument->createElement('Id');
$empfaengerKonto->appendChild($empfaengerId);
self::kind($dokument, $empfaengerId, 'IBAN', self::iban((string)$auftrag['empfaenger_iban']));
if ($zweck !== '') {
$verwendung = $dokument->createElement('RmtInf');
$zahlung->appendChild($verwendung);
self::kind($dokument, $verwendung, 'Ustrd', $zweck);
}
return (string)$dokument->saveXML();
}
/**
* Der Tag, an dem die Bank ausführen soll.
*
* Ohne Termin – und ebenso bei einem Termin, der heute oder schon vorbei ist –
* ist „sofort“ gemeint: Dann steht das Platzhalterdatum `SOFORT` in der
* Nachricht. Ein Termin in der Zukunft bleibt stehen und macht daraus eine
* terminierte Überweisung.
*/
public static function ausfuehrungstag(string $termin, ?string $heute = null): string
{
$termin = trim($termin);
$heute = $heute ?? date('Y-m-d');
return $termin === '' || $termin <= $heute ? self::SOFORT : $termin;
}
/**
* 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 pain.001.001.03; meldet sie nur Formate, die hier nicht
* erzeugt werden, kommt `null` zurück. Führt die Bank sowohl .001.03 als
* auch das neuere .001.09, gewinnt .001.09.
*
* Für eine Echtzeitüberweisung kommt nur das ISO-Format infrage; führt die
* Bank ausschließlich das DK-Format, ist das Ergebnis ebenfalls `null`.
*
* @param list<string> $unterstuetzt
*/
public static function schemaWaehlen(array $unterstuetzt, bool $echtzeit = false): ?string
{
if ($unterstuetzt === []) {
return self::SCHEMA_PAIN_001_001_03;
}
foreach ($echtzeit ? self::SCHEMATA_ECHTZEIT : 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:
// Letzteres maskiert kaufmännische Und-Zeichen nicht.
$element = $dokument->createElement($name);
$element->appendChild($dokument->createTextNode($wert));
$eltern->appendChild($element);
}
/**
* SEPA lässt nur einen eingeschränkten Zeichensatz zu. Umlaute werden
* umschrieben, alles Übrige fällt weg – sonst weist die Bank den Auftrag
* zurück, und zwar erst nach dem Absenden.
*/
public static function text(string $text, int $laenge): string
{
$ersatz = [
'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'Ä' => 'Ae', 'Ö' => 'Oe', 'Ü' => 'Ue',
'ß' => 'ss', 'é' => 'e', 'è' => 'e', 'ê' => 'e', 'á' => 'a', 'à' => 'a',
'â' => 'a', 'ç' => 'c', 'ñ' => 'n', 'ô' => 'o', 'ù' => 'u', 'û' => 'u',
];
$text = strtr($text, $ersatz);
$text = preg_replace("/[^A-Za-z0-9\\/\\-?:().,'+ ]/", ' ', $text) ?? '';
$text = trim(preg_replace('/\s+/', ' ', $text) ?? '');
return mb_substr($text, 0, $laenge);
}
private static function iban(string $iban): string
{
return strtoupper(str_replace(' ', '', $iban));
}
/**
* Eine UETR (Unique End-to-end Transaction Reference): eine UUID Version 4
* nach RFC 4122, mit der eine Echtzeitüberweisung durch alle beteiligten
* Systeme hindurch verfolgbar bleibt. Seit pain.001.001.09 gehört sie in
* jede Zahlung dieses Formats.
*/
private static function uetr(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
$hex = bin2hex($bytes);
return sprintf(
'%s-%s-%s-%s-%s',
substr($hex, 0, 8),
substr($hex, 8, 4),
substr($hex, 12, 4),
substr($hex, 16, 4),
substr($hex, 20, 12)
);
}
}