` * (`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 $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) ); } }