diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..944f9e8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +Alle wesentlichen Änderungen an diesem Projekt werden hier festgehalten. + +Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). + +## [Unreleased] + +### Fixed + +- Echtzeitüberweisung scheiterte an Banken, die nur das ISO-Format pain.001.001.09 führen; + ein Zusammentreffen von Namensabgleich-Volltreffer und TAN-Anfrage in einer Antwort ließ + auch die gewöhnliche Überweisung mit einer rohen Bibliotheksmeldung scheitern (#89) diff --git a/Changes.md b/Changes.md index 97538f8..c16df84 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,43 @@ # Änderungen +## Zahlungsverkehr: Echtzeitüberweisung scheiterte am SEPA-Format der Bank (28.09.2026) + +Eine Echtzeitüberweisung schlug mit „Für Echtzeitüberweisungen nimmt die Bank nur +SEPA-Formate an, die der Finanzmanager nicht erzeugt: …pain.001.001.09…“ fehl; ohne das +Echtzeit-Häkchen kam stattdessen die rohe Bibliotheksmeldung „Combined VOP match +confirmation and TAN request“ (#89). Zwei unabhängige Ursachen: + +- `SepaXml` erzeugte ausschließlich pain.001.001.03 und das DK-Format pain.001.003.03. + Banken, die inzwischen nur noch das neuere ISO-Format **pain.001.001.09** führen, wiesen + jede Überweisung schon vor der TAN ab. `SepaXml` erzeugt es jetzt mit: Der + Ausführungstermin steht in einem eigenen `Dt`-Element (`ReqdExctnDt/Dt`), die + Bankkennung heißt `BICFI` statt `BIC`, und jede Zahlung trägt eine `UETR` (eine UUID, die + das SEPA-Instant-Regelwerk seit November 2019 verlangt und die für eine + Echtzeitüberweisung ohnehin nötig ist). `SepaXml::schemaWaehlen()` bevorzugt es, wenn die + Bank es meldet, vor dem älteren pain.001.001.03. +- Bestätigt die Bank den Namensabgleich mit einem Volltreffer und verlangt im selben + Antwortsatz schon die TAN, kennt `nemiah/php-fints` (Stand v4.1.4) diese Kombination noch + nicht und wirft dafür eine rohe `UnsupportedException` – obwohl der Vorgang beide + Angaben zu diesem Zeitpunkt schon erhalten hat. `FinTsUeberweisung` fängt genau diese + Ausnahme jetzt ab und lässt den ohnehin schon vorhandenen automatischen + Volltreffer-Namensabgleich (siehe unten) den Rest erledigen, statt den Auftrag mit der + englischen Bibliotheksmeldung scheitern zu lassen. + +Erweiterte Tests in `tests/39_SepaSchemaTest.php` und `tests/43_EchtzeitueberweisungTest.php` +für das neue Format, neuer Test in `tests/40_NamensabgleichTest.php` für die abgefangene +Ausnahme. + +## Buchungsmaske: Splitbuchung und direkte Zuordnung sperren sich gegenseitig (28.09.2026) + +Splitzeilen und die Felder *Kategorie / Gegenkonto* und *Klasse* ließen sich in der +Buchungsmaske gleichzeitig ausfüllen (#77). + +- Sobald in einer Splitzeile etwas eingetragen ist, sind *Kategorie / Gegenkonto* und + *Klasse* sofort gesperrt; eine Kategorie oder Klasse sperrt umgekehrt alle Splitzeilen. +- Neuer Knopf *Splitbuchungen löschen* rechts über der Splittabelle: leert alle + Splitzeilen und gibt Kategorie und Klasse wieder frei. +- E2E-Prüfung in `buchungen.spec.js`. + ## Kontoblatt: Klassen der Splitzeilen in der Klassenspalte (28.09.2026) Die Klassenspalte des Kontoblatts zeigte bei einer Splitbuchung nur die Klasse der Buchung diff --git a/docs/buchungen.md b/docs/buchungen.md index fe2680a..76fb69e 100644 --- a/docs/buchungen.md +++ b/docs/buchungen.md @@ -185,6 +185,14 @@ deutsche Zahlenformat steht damit einmal im Browser und einmal in PHP, nicht dre Splitzeile darf ein Gegenkonto tragen; dann erscheint auf jenem Konto nur der Betrag dieser Zeile. Auf das eigene Konto darf sie nicht zeigen – die Buchung zählte dort doppelt. +In der **Buchungsmaske** sperren sich beide Wege gegenseitig (`public/static/buchungsmaske.js`): +Sobald in einer Splitzeile irgendetwas steht, sind *Kategorie / Gegenkonto* und *Klasse* +gesperrt; steht dort umgekehrt eine Kategorie, ein Gegenkonto oder eine Klasse, sind alle +Splitzeilen gesperrt. Gesperrte Felder schickt der Browser nicht mit. Stehen beim Öffnen +beide Seiten voll, gilt wie im Model die Splitbuchung. Der Knopf *Splitbuchungen löschen* +rechts über der Splittabelle erscheint, sobald eine Splitzeile belegt ist; er leert alle +Zeilen und gibt die direkten Felder wieder frei. Gespeichert wird erst mit dem Formular. + Der Knopf *Splitbuchung löschen* im Dialog schickt `POST /buchungen/{id}/splitbuchung` mit `aktion=loeschen`; der Controller ruft dann `Buchung::splitsErsetzen($id, [])`, ganz gleich was in den Feldern steht. Gelöscht werden die Zeilen samt Zuordnung, nicht die Buchung – diff --git a/public/static/app.css b/public/static/app.css index 8b26b8c..67f78e1 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -1248,6 +1248,11 @@ table.splittabelle input.rechts { text-align: right; } .buchungsmaske [hidden] { display: none; } +/* Überschrift der Splittabelle mit dem Knopf „Splitbuchungen löschen“ rechts. */ +.buchungsmaske .splitkopf { display: flex; align-items: center; gap: .6rem; } +.buchungsmaske .splitkopf h3 { margin: 0; } +.buchungsmaske .splitkopf .knopf { margin-left: auto; } + /* Der Splitdialog des Kontoblatts. Als und nicht als eigene Seite: Die Aufteilung gehört zur Zeile, aus der sie geöffnet wurde. */ .splitdialog { @@ -1425,6 +1430,7 @@ table.liste th.bargeldspalte { halbe Zeile zu breit sind. */ .formular.zweispaltig > input[type="hidden"], .formular.zweispaltig > h3, +.formular.zweispaltig > .splitkopf, .formular.zweispaltig > p, .formular.zweispaltig > table, .formular.zweispaltig > .knopfleiste, diff --git a/public/static/buchungsmaske.js b/public/static/buchungsmaske.js index f06c32d..ae14dbd 100644 --- a/public/static/buchungsmaske.js +++ b/public/static/buchungsmaske.js @@ -1,8 +1,16 @@ -/* Buchungsmaske: Restbetrag der Splitzeilen mitrechnen. +/* Buchungsmaske: Restbetrag der Splitzeilen mitrechnen und direkte Zuordnung + * und Splitbuchung gegeneinander sperren. * * Nur Komfort – die Maske kommt fertig vom Server. Ohne JavaScript bleibt alles * bedienbar: Die Splitsumme prüft ohnehin das Model. * + * Eine Buchung ist entweder direkt zugeordnet (Kategorie / Gegenkonto, Klasse) + * oder eine Splitbuchung. Sobald in einer Splitzeile etwas steht, sind die + * beiden Felder oben gesperrt; steht oben eine Kategorie oder Klasse, sind es + * die Splitzeilen. Gesperrte Felder schickt der Browser nicht mit – was dort + * noch stehen mag, geht also nicht in die Buchung ein. Der Knopf + * „Splitbuchungen löschen“ leert alle Splitzeilen und gibt oben wieder frei. + * * Kategorie und Gegenkonto stehen in einem Feld, deshalb gibt es hier nichts * mehr ein- und auszublenden; die Art bestimmt nur noch die Richtung. */ @@ -40,13 +48,57 @@ summenfeld.classList.toggle('negativ', stand.belegt && stand.rest !== 0); } + const direktfelder = ['zuordnung', 'klasse_id'] + .map(function (id) { return document.getElementById(id); }) + .filter(Boolean); + const splitkasten = document.getElementById('splitzeilen'); + const leerenknopf = document.getElementById('splitsleeren'); + + function splitfelder() { + return splitkasten ? Array.from(splitkasten.querySelectorAll('input, select')) : []; + } + + function belegt(felder) { + return felder.some(function (feld) { return feld.value.trim() !== ''; }); + } + + function sperrenAktualisieren() { + const splits = splitfelder(); + const splitBelegt = belegt(splits); + // Stehen beim Öffnen beide Seiten voll (ältere Daten), gilt wie im + // Model die Splitbuchung. + const direktBelegt = !splitBelegt && belegt(direktfelder); + + direktfelder.forEach(function (feld) { feld.disabled = splitBelegt; }); + splits.forEach(function (feld) { feld.disabled = direktBelegt; }); + + if (leerenknopf) { + leerenknopf.hidden = !splitBelegt; + } + } + + function splitsLeeren() { + splitfelder().forEach(function (feld) { + feld.value = ''; + }); + sperrenAktualisieren(); + rechneSumme(); + } + art.addEventListener('change', rechneSumme); maske.addEventListener('input', function (ereignis) { if (ereignis.target.classList.contains('splitbetrag') || ereignis.target.id === 'betrag') { rechneSumme(); } + sperrenAktualisieren(); }); + maske.addEventListener('change', sperrenAktualisieren); + if (leerenknopf) { + leerenknopf.addEventListener('click', splitsLeeren); + } + + sperrenAktualisieren(); rechneSumme(); })(); diff --git a/src/Services/FinTsUeberweisung.php b/src/Services/FinTsUeberweisung.php index 416a728..939ac97 100644 --- a/src/Services/FinTsUeberweisung.php +++ b/src/Services/FinTsUeberweisung.php @@ -18,6 +18,7 @@ use Fhp\Options\FinTsOptions; use Fhp\Segment\IPZ\HIIPZSv1; use Fhp\Segment\IPZ\HIIPZSv2; use Fhp\Segment\SPA\HISPAS; +use Fhp\UnsupportedException; use Finanzmanager\Auth; use Finanzmanager\Models\Bankprotokoll; use Finanzmanager\Models\Bankzugang; @@ -42,7 +43,10 @@ use Finanzmanager\Models\Zahlungsauftrag; * Empfängernamen mit dem Kontoinhaber ab (Verification of Payee). Stimmt beides * überein, geht es ohne Rückfrage weiter zur TAN. Weicht der Name ab oder konnte * die Bank nicht prüfen, wird zuerst gefragt – mit dem Text, den die Bank dazu - * mitliefert. + * mitliefert. Meldet die Bank Volltreffer und TAN-Anfrage in einem Rutsch, + * kennt php-fints diese Kombination noch nicht und wirft eine rohe Ausnahme + * (`istBekannteVopTanLuecke()`) – die wird hier abgefangen, statt den Auftrag + * mit ihrem englischen Text scheitern zu lassen. * * **Echtzeitüberweisung.** Ist sie am Auftrag gewählt, geht der Auftrag als * eigener Geschäftsvorfall HKIPZ hinaus statt als HKCCS – so will es das @@ -341,7 +345,16 @@ class FinTsUeberweisung // Keine Umwandlung in eine gewöhnliche Überweisung – siehe oben. ? SendSEPARealtimeTransfer::create($sepa, $xml, false) : SendSEPATransfer::create($sepa, $xml); - $fints->execute($aktion); + + try { + $fints->execute($aktion); + } catch (UnsupportedException $e) { + if (!self::istBekannteVopTanLuecke($e)) { + throw $e; + } + // Der Auftrag hat Namensabgleich und TAN-Anfrage trotz der + // Ausnahme schon erhalten (siehe unten) – ganz normal weiter. + } return $this->nachDemSenden($fints, $aktion); } @@ -458,6 +471,22 @@ class FinTsUeberweisung return $this->angenommen($fints); } + /** + * Ob eine Ausnahme die bekannte Lücke von php-fints (Stand v4.1.4) ist: + * Bestätigt die Bank den Namensabgleich mit einem Volltreffer und verlangt + * im selben Antwortsatz schon die TAN, kennt die Bibliothek diese + * Kombination nicht und wirft eine rohe `UnsupportedException` – obwohl sie + * dem Vorgang (`BaseAction::setTanRequest()` und + * `::setVopConfirmationRequest()`) beide Angaben schon mitgegeben hat, bevor + * sie wirft. `nachDemSenden()` bestätigt einen Volltreffer ohnehin + * automatisch (siehe oben) – die Ausnahme muss also nur abgefangen werden, + * damit ihr englischer Text nicht als Fehlermeldung beim Nutzer landet. + */ + private static function istBekannteVopTanLuecke(UnsupportedException $e): bool + { + return $e->getMessage() === 'Combined VOP match confirmation and TAN request'; + } + /** * Der Vorgang, der in der Sitzung auf die Fortsetzung wartet. */ diff --git a/src/Services/SepaXml.php b/src/Services/SepaXml.php index deb5906..96f5df9 100644 --- a/src/Services/SepaXml.php +++ b/src/Services/SepaXml.php @@ -13,28 +13,43 @@ 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** 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`. + * 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 `
` + * (`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. + * 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_03, self::SCHEMA_PAIN_001_003_03]; + 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_03]; + 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'; @@ -69,9 +84,15 @@ class SepaXml $echtzeit = !empty($auftrag['echtzeit']); if ($echtzeit && !in_array($schema, self::SCHEMATA_ECHTZEIT, true)) { - throw new \InvalidArgumentException('Eine Echtzeitüberweisung gibt es nur im Format pain.001.001.03.'); + 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); @@ -126,7 +147,13 @@ class SepaXml self::kind($dokument, $zahlungsart, 'Cd', self::ECHTZEIT); } - self::kind($dokument, $sammler, 'ReqdExctnDt', $ausfuehrung); + 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); @@ -143,8 +170,10 @@ class SepaXml $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, 'BIC', strtoupper(trim((string)$auftrag['absender_bic']))); + self::kind($dokument, $bankKennung, $bicElement, strtoupper(trim((string)$auftrag['absender_bic']))); } else { $anders = $dokument->createElement('Othr'); $bankKennung->appendChild($anders); @@ -160,6 +189,9 @@ class SepaXml $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); @@ -172,7 +204,7 @@ class SepaXml $zahlung->appendChild($empfaengerBank); $empfaengerBankId = $dokument->createElement('FinInstnId'); $empfaengerBank->appendChild($empfaengerBankId); - self::kind($dokument, $empfaengerBankId, 'BIC', strtoupper(trim((string)$auftrag['empfaenger_bic']))); + self::kind($dokument, $empfaengerBankId, $bicElement, strtoupper(trim((string)$auftrag['empfaenger_bic']))); } $empfaenger = $dokument->createElement('Cdtr'); @@ -215,8 +247,9 @@ class SepaXml * * 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. + * 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`. @@ -273,4 +306,27 @@ class SepaXml { 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) + ); + } } diff --git a/src/Views/buchungen/formular.php b/src/Views/buchungen/formular.php index 5db3270..a5c71d2 100644 --- a/src/Views/buchungen/formular.php +++ b/src/Views/buchungen/formular.php @@ -144,8 +144,18 @@ include __DIR__ . '/../layout/kopf.php'; -

Splitbuchung - (optional)

+ +
+

Splitbuchung + (optional)

+ +
diff --git a/tests/39_SepaSchemaTest.php b/tests/39_SepaSchemaTest.php index 0e21453..b5b6159 100644 --- a/tests/39_SepaSchemaTest.php +++ b/tests/39_SepaSchemaTest.php @@ -8,13 +8,16 @@ 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. + * ausschließlich das ältere DK-Format pain.001.003.03 an, andere melden nur + * noch das neuere pain.001.001.09 – 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. + * hin abgeklopft. Für pain.001.001.09 gibt es kein XSD in diesem Repository; + * geprüft werden hier die drei Stellen, an denen sich das Format von .001.03 + * unterscheidet (siehe `SepaXml`-Klassenkommentar). */ class SepaSchemaTest extends TestCase { @@ -29,6 +32,7 @@ class SepaSchemaTest extends TestCase $this->dkFormatIstSchemakonform(); $this->dasFormatStehtImNamensraum(); $this->ohneTerminWirdSofortUeberwiesen(); + $this->pain09TraegtDieNeuerungen(); } /** @@ -100,12 +104,23 @@ class SepaSchemaTest extends TestCase SepaXml::schemaWaehlen([SepaXml::SCHEMA_PAIN_001_001_03 . '_GBIC_4']), 'Der Zusatz _GBIC_4 stört den Vergleich nicht' ); + + $this->assertEquals( + SepaXml::SCHEMA_PAIN_001_001_09, + SepaXml::schemaWaehlen([SepaXml::SCHEMA_PAIN_001_001_09]), + 'Meldet die Bank nur das neuere ISO-Format, wird das erzeugt' + ); + $this->assertEquals( + SepaXml::SCHEMA_PAIN_001_001_09, + SepaXml::schemaWaehlen([SepaXml::SCHEMA_PAIN_001_001_03, SepaXml::SCHEMA_PAIN_001_001_09]), + 'Führt die Bank beide ISO-Fassungen, gewinnt die neuere' + ); } private function unbekannteFormateFuehrenZuNull(): void { $this->assertNull( - SepaXml::schemaWaehlen(['urn:iso:std:iso:20022:tech:xsd:pain.001.001.09']), + SepaXml::schemaWaehlen(['urn:iso:std:iso:20022:tech:xsd:pain.001.001.99']), 'Führt die Bank nur Formate, die hier niemand erzeugt, kommt null zurück' ); } @@ -160,6 +175,85 @@ class SepaSchemaTest extends TestCase ); } + /** + * pain.001.001.09 unterscheidet sich von .001.03 an drei Stellen (siehe + * `SepaXml`-Klassenkommentar): der Ausführungstermin steht in `
`, die + * Bankkennung heißt `BICFI`, und die Zahlung trägt eine `UETR`. + */ + private function pain09TraegtDieNeuerungen(): void + { + $pfad = static function (string $xml): DOMXPath { + $dokument = new DOMDocument(); + $dokument->loadXML($xml); + $xpath = new DOMXPath($dokument); + $xpath->registerNamespace('p', SepaXml::SCHEMA_PAIN_001_001_09); + + return $xpath; + }; + + $mitBic = $pfad(SepaXml::ueberweisung($this->auftraege()['mit BIC'], SepaXml::SCHEMA_PAIN_001_001_09)); + + $this->assertEquals( + '2099-09-05', + $mitBic->evaluate('string(//p:PmtInf/p:ReqdExctnDt/p:Dt)'), + 'Der Ausführungstermin steht in einem eigenen Dt-Element' + ); + $this->assertEquals( + 0.0, + $mitBic->evaluate("count(//p:PmtInf/p:ReqdExctnDt/text()[normalize-space(.) != ''])"), + 'Und nicht mehr als nackter Text direkt in ReqdExctnDt – nur noch das Dt-Kind' + ); + $this->assertEquals( + 'BYLADEM1001', + $mitBic->evaluate('string(//p:DbtrAgt/p:FinInstnId/p:BICFI)'), + 'Die Bankkennung des Schuldners heißt BICFI' + ); + $this->assertEquals( + 'INGDDEFFXXX', + $mitBic->evaluate('string(//p:CdtrAgt/p:FinInstnId/p:BICFI)'), + 'Ebenso die des Empfängers' + ); + $this->assertEquals( + 0.0, + $mitBic->evaluate('count(//p:FinInstnId/p:BIC)'), + 'Das alte Element BIC kommt in diesem Format nicht mehr vor' + ); + + $uetr = $mitBic->evaluate('string(//p:PmtId/p:UETR)'); + $this->assert( + (bool)preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', $uetr), + 'Die UETR ist eine UUID Version 4' + ); + + $zweiteUetr = $pfad(SepaXml::ueberweisung($this->auftraege()['mit BIC'], SepaXml::SCHEMA_PAIN_001_001_09)) + ->evaluate('string(//p:PmtId/p:UETR)'); + $this->assert($uetr !== $zweiteUetr, 'Jede Nachricht bekommt ihre eigene UETR'); + + // Ohne BIC bleibt es bei der bekannten Ersatzkennung – nur eben unter + // dem neuen Namensraum. + $ohneBic = $pfad(SepaXml::ueberweisung($this->auftraege()['ohne BIC'], SepaXml::SCHEMA_PAIN_001_001_09)); + $this->assertEquals( + 'NOTPROVIDED', + $ohneBic->evaluate('string(//p:DbtrAgt/p:FinInstnId/p:Othr/p:Id)'), + 'Ohne BIC steht weiterhin die Ersatzkennung, nicht ein leeres BICFI' + ); + + // Eine Echtzeitüberweisung im neuen Format trägt beide Merkmale zugleich. + $echtzeit = $this->auftraege()['ohne BIC']; + $echtzeit['echtzeit'] = true; + $echtzeitPfad = $pfad(SepaXml::ueberweisung($echtzeit, SepaXml::SCHEMA_PAIN_001_001_09)); + $this->assertEquals( + SepaXml::ECHTZEIT, + $echtzeitPfad->evaluate('string(//p:PmtTpInf/p:LclInstrm/p:Cd)'), + 'Echtzeit und pain.001.001.09 vertragen sich (Zahlungsart INST)' + ); + $this->assertEquals( + SepaXml::SOFORT, + $echtzeitPfad->evaluate('string(//p:ReqdExctnDt/p:Dt)'), + 'Und weiterhin sofort' + ); + } + /** @return array> */ private function auftraege(): array { diff --git a/tests/40_NamensabgleichTest.php b/tests/40_NamensabgleichTest.php index aa5a8ce..571635e 100644 --- a/tests/40_NamensabgleichTest.php +++ b/tests/40_NamensabgleichTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Fhp\Model\VopVerificationResult; +use Fhp\UnsupportedException; use Finanzmanager\Services\FinTsUeberweisung; /** @@ -23,6 +24,7 @@ class NamensabgleichTest extends TestCase $this->jedesErgebnisHatEinenDeutschenSatz(); $this->derBanktextWirdKlartext(); $this->ohneWartendenVorgangGehtNichts(); + $this->dieBekannteVopTanLueckeWirdErkannt(); } private function jedesErgebnisHatEinenDeutschenSatz(): void @@ -83,4 +85,28 @@ class NamensabgleichTest extends TestCase $this->assertEquals('fehler', $ergebnis['status'], 'Ohne Vorgang lässt sich nichts bestätigen'); $this->assertNotEmpty($ergebnis['meldung'], 'Und es gibt eine Erklärung dazu'); } + + /** + * Meldet die Bank Namensabgleich (Volltreffer) und TAN-Anfrage im selben + * Antwortsatz, wirft php-fints (Stand v4.1.4) dafür eine rohe + * `UnsupportedException`, statt die Kombination zu behandeln – obwohl der + * Vorgang beide Angaben zu diesem Zeitpunkt schon trägt. Ungefangen landete + * ihr englischer Text als Fehlermeldung beim Nutzer (Fix #89). Geprüft wird + * hier die Erkennung selbst; der volle Ablauf (automatische Bestätigung, + * anschließende TAN-Anfrage) lässt sich ohne Bankverbindung nicht auslösen. + */ + private function dieBekannteVopTanLueckeWirdErkannt(): void + { + $istLuecke = new ReflectionMethod(FinTsUeberweisung::class, 'istBekannteVopTanLuecke'); + $istLuecke->setAccessible(true); + + $this->assert( + $istLuecke->invoke(null, new UnsupportedException('Combined VOP match confirmation and TAN request')), + 'Die bekannte Meldung wird erkannt' + ); + $this->assert( + !$istLuecke->invoke(null, new UnsupportedException('The bank does not support the XML schema x')), + 'Eine andere UnsupportedException der Bibliothek wird nicht mit ihr verwechselt' + ); + } } diff --git a/tests/43_EchtzeitueberweisungTest.php b/tests/43_EchtzeitueberweisungTest.php index bee5dca..2f12b5a 100644 --- a/tests/43_EchtzeitueberweisungTest.php +++ b/tests/43_EchtzeitueberweisungTest.php @@ -163,6 +163,11 @@ class EchtzeitueberweisungTest extends TestCase SepaXml::schemaWaehlen([SepaXml::SCHEMA_PAIN_001_003_03, SepaXml::SCHEMA_PAIN_001_001_03 . '_GBIC_4'], true), 'Sonst wird das ISO-Format genommen' ); + $this->assertEquals( + SepaXml::SCHEMA_PAIN_001_001_09, + SepaXml::schemaWaehlen([SepaXml::SCHEMA_PAIN_001_001_03, SepaXml::SCHEMA_PAIN_001_001_09], true), + 'Führt die Bank beide ISO-Fassungen, gewinnt für Echtzeit ebenfalls die neuere' + ); $abgewiesen = false; try { diff --git a/tests/e2e/specs/buchungen.spec.js b/tests/e2e/specs/buchungen.spec.js index b5da638..86e5f3e 100644 --- a/tests/e2e/specs/buchungen.spec.js +++ b/tests/e2e/specs/buchungen.spec.js @@ -372,6 +372,61 @@ test.describe('Buchungen', () => { expect(titel).toContain(`${KATEGORIE}: -97,98`) }) + test('Splitzeilen und direkte Zuordnung sperren sich gegenseitig', async ({ page }) => { + await page.goto(`/konten/${giroId}/buchung`) + const kategorie = page.locator('#zuordnung') + const klasse = page.locator('#klasse_id') + const splitZuordnung = page.locator('select[name="split[0][zuordnung]"]') + const splitBetrag = page.locator('input[name="split[1][betrag]"]') + const leeren = page.locator('#splitsleeren') + + // Leere Maske: alles offen, nichts zu löschen. + await expect(kategorie).toBeEnabled() + await expect(splitBetrag).toBeEnabled() + await expect(leeren).toBeHidden() + + // Eine Eingabe in den Splitzeilen sperrt Kategorie und Klasse sofort. + await page.fill('input[name="split[0][notiz]"]', 'Teil') + await expect(kategorie).toBeDisabled() + await expect(klasse).toBeDisabled() + await expect(leeren).toBeVisible() + + // Der Knopf leert alle Zeilen und gibt oben wieder frei. + await splitZuordnung.selectOption({ label: KATEGORIE }) + await splitBetrag.fill('-10') + await leeren.click() + await expect(page.locator('input[name="split[0][notiz]"]')).toHaveValue('') + await expect(splitZuordnung).toHaveValue('') + await expect(splitBetrag).toHaveValue('') + await expect(kategorie).toBeEnabled() + await expect(klasse).toBeEnabled() + await expect(leeren).toBeHidden() + + // Umgekehrt: Eine Kategorie oben sperrt die Splitzeilen … + await kategorie.selectOption({ label: KATEGORIE }) + await expect(splitZuordnung).toBeDisabled() + await expect(splitBetrag).toBeDisabled() + await kategorie.selectOption('') + await expect(splitBetrag).toBeEnabled() + + // … und eine Klasse ebenso. + await klasse.selectOption({ label: KLASSE }) + await expect(splitZuordnung).toBeDisabled() + await klasse.selectOption('') + await expect(splitZuordnung).toBeEnabled() + }) + + test('Bestehende Splitbuchung öffnet mit gesperrter Kategorie', async ({ page }) => { + const zeile = page.locator('tr', { hasText: `Versicherung ${MARKE}` }) + await page.goto(`/konten/${giroId}`) + const bearbeiten = await zeile.locator('a[href^="/buchungen/"]').first().getAttribute('href') + await page.goto(bearbeiten) + + await expect(page.locator('#zuordnung')).toBeDisabled() + await expect(page.locator('#klasse_id')).toBeDisabled() + await expect(page.locator('#splitsleeren')).toBeVisible() + }) + test('Klassen der Splitzeilen stehen in der Klassenspalte', async ({ page }) => { await page.goto(`/konten/${giroId}/buchung`) await page.fill('#datum', '2026-05-03')