Seit Oktober 2025 gleicht die Bank vor der Ausführung den Empfängernamen mit dem Kontoinhaber ab (Verification of Payee). Der Finanzmanager kannte diese Stufe nicht, die Überweisung blieb stehen. FinTsUeberweisung wartet jetzt auf das Ergebnis, solange die Bank es braucht. Stimmt der Name überein, wird selbst bestätigt und direkt zur TAN gegangen – eine Rückfrage ohne Erkenntnis erspart das dem Nutzer. Weicht der Name ab, ist er nur nahezu gleich oder konnte die Bank nicht prüfen, kommt die neue Maske zahlungsverkehr/vop.php: Ergebnis in einem deutschen Satz, Aufklärungstext der Bank als Klartext, Empfänger und IBAN aus dem Auftrag und – sofern die Bank ihn nennt – der bei ihr hinterlegte Kontoinhaber. Ohne Ausführungstermin steht jetzt das Platzhalterdatum 1999-01-01 in der Nachricht. Mit dem heutigen Datum wurde daraus eine terminierte Überweisung (HKCSE statt HKCCS), die die Bank mit "Die minimale Vorlaufzeit beträgt 1 Tage" zurückwies. Der FinTS-Fork steht auf v4.1.3 mit zwei Eingriffen: Die schrittweise Lieferung des Prüfberichts wird erst abgelehnt, wenn sie wirklich eintritt, und der beim Close Match gemeldete Kontoinhaber wird an die Anwendung durchgereicht. Beide liegen zusätzlich auf eigenen Branches für PRs an das Upstream-Projekt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013iURiWYZJQYVGQNXFFGyWu
190 lines
7.1 KiB
PHP
190 lines
7.1 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 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.
|
||
*/
|
||
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();
|
||
}
|
||
|
||
/**
|
||
* 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'
|
||
);
|
||
}
|
||
|
||
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<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,
|
||
],
|
||
];
|
||
}
|
||
}
|