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>
332 lines
15 KiB
PHP
332 lines
15 KiB
PHP
<?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)
|
||
);
|
||
}
|
||
}
|