Merge pull request 'Fix #77: Splitbuchung und direkte Zuordnung sperren sich gegenseitig' (#90) from fds2610-bot/Finanzmanager:main into main

Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/90
This commit is contained in:
fds2610 2026-09-29 11:08:22 +02:00
commit 0b75dd1fa0
12 changed files with 416 additions and 24 deletions

13
CHANGELOG.md Normal file
View file

@ -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)

View file

@ -1,5 +1,43 @@
# Änderungen # Ä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) ## Kontoblatt: Klassen der Splitzeilen in der Klassenspalte (28.09.2026)
Die Klassenspalte des Kontoblatts zeigte bei einer Splitbuchung nur die Klasse der Buchung Die Klassenspalte des Kontoblatts zeigte bei einer Splitbuchung nur die Klasse der Buchung

View file

@ -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 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. 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 Der Knopf *Splitbuchung löschen* im Dialog schickt `POST /buchungen/{id}/splitbuchung` mit
`aktion=loeschen`; der Controller ruft dann `Buchung::splitsErsetzen($id, [])`, ganz gleich `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 – was in den Feldern steht. Gelöscht werden die Zeilen samt Zuordnung, nicht die Buchung –

View file

@ -1248,6 +1248,11 @@ table.splittabelle input.rechts { text-align: right; }
.buchungsmaske [hidden] { display: none; } .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 <dialog> und nicht als eigene Seite: /* Der Splitdialog des Kontoblatts. Als <dialog> und nicht als eigene Seite:
Die Aufteilung gehört zur Zeile, aus der sie geöffnet wurde. */ Die Aufteilung gehört zur Zeile, aus der sie geöffnet wurde. */
.splitdialog { .splitdialog {
@ -1425,6 +1430,7 @@ table.liste th.bargeldspalte {
halbe Zeile zu breit sind. */ halbe Zeile zu breit sind. */
.formular.zweispaltig > input[type="hidden"], .formular.zweispaltig > input[type="hidden"],
.formular.zweispaltig > h3, .formular.zweispaltig > h3,
.formular.zweispaltig > .splitkopf,
.formular.zweispaltig > p, .formular.zweispaltig > p,
.formular.zweispaltig > table, .formular.zweispaltig > table,
.formular.zweispaltig > .knopfleiste, .formular.zweispaltig > .knopfleiste,

View file

@ -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 * Nur Komfort – die Maske kommt fertig vom Server. Ohne JavaScript bleibt alles
* bedienbar: Die Splitsumme prüft ohnehin das Model. * 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 * Kategorie und Gegenkonto stehen in einem Feld, deshalb gibt es hier nichts
* mehr ein- und auszublenden; die Art bestimmt nur noch die Richtung. * mehr ein- und auszublenden; die Art bestimmt nur noch die Richtung.
*/ */
@ -40,13 +48,57 @@
summenfeld.classList.toggle('negativ', stand.belegt && stand.rest !== 0); 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); art.addEventListener('change', rechneSumme);
maske.addEventListener('input', function (ereignis) { maske.addEventListener('input', function (ereignis) {
if (ereignis.target.classList.contains('splitbetrag') || ereignis.target.id === 'betrag') { if (ereignis.target.classList.contains('splitbetrag') || ereignis.target.id === 'betrag') {
rechneSumme(); rechneSumme();
} }
sperrenAktualisieren();
}); });
maske.addEventListener('change', sperrenAktualisieren);
if (leerenknopf) {
leerenknopf.addEventListener('click', splitsLeeren);
}
sperrenAktualisieren();
rechneSumme(); rechneSumme();
})(); })();

View file

@ -18,6 +18,7 @@ use Fhp\Options\FinTsOptions;
use Fhp\Segment\IPZ\HIIPZSv1; use Fhp\Segment\IPZ\HIIPZSv1;
use Fhp\Segment\IPZ\HIIPZSv2; use Fhp\Segment\IPZ\HIIPZSv2;
use Fhp\Segment\SPA\HISPAS; use Fhp\Segment\SPA\HISPAS;
use Fhp\UnsupportedException;
use Finanzmanager\Auth; use Finanzmanager\Auth;
use Finanzmanager\Models\Bankprotokoll; use Finanzmanager\Models\Bankprotokoll;
use Finanzmanager\Models\Bankzugang; use Finanzmanager\Models\Bankzugang;
@ -42,7 +43,10 @@ use Finanzmanager\Models\Zahlungsauftrag;
* Empfängernamen mit dem Kontoinhaber ab (Verification of Payee). Stimmt beides * 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 * ü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 * 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 * **Echtzeitüberweisung.** Ist sie am Auftrag gewählt, geht der Auftrag als
* eigener Geschäftsvorfall HKIPZ hinaus statt als HKCCS – so will es das * 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. // Keine Umwandlung in eine gewöhnliche Überweisung – siehe oben.
? SendSEPARealtimeTransfer::create($sepa, $xml, false) ? SendSEPARealtimeTransfer::create($sepa, $xml, false)
: SendSEPATransfer::create($sepa, $xml); : SendSEPATransfer::create($sepa, $xml);
try {
$fints->execute($aktion); $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); return $this->nachDemSenden($fints, $aktion);
} }
@ -458,6 +471,22 @@ class FinTsUeberweisung
return $this->angenommen($fints); 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. * Der Vorgang, der in der Sitzung auf die Fortsetzung wartet.
*/ */

View file

@ -13,28 +13,43 @@ namespace Finanzmanager\Services;
* Einzelüberweisung, um die es hier geht, ist das Format überschaubar und in der * Einzelüberweisung, um die es hier geht, ist das Format überschaubar und in der
* Spezifikation der Deutschen Kreditwirtschaft vollständig beschrieben. * Spezifikation der Deutschen Kreditwirtschaft vollständig beschrieben.
* *
* Erzeugt wird **pain.001.001.03** oder – wenn die Bank nur das ältere * Erzeugt wird **pain.001.001.03**, das neuere **pain.001.001.09** oder – wenn
* DK-Format führt – **pain.001.003.03**. Für die Einzelüberweisung sind beide * die Bank nur das ältere DK-Format führt – **pain.001.003.03**. Für die
* Fassungen im Aufbau gleich (Reihenfolge und Pflichtfelder stimmen überein), * Einzelüberweisung sind alle drei Fassungen im Aufbau fast gleich; welches
* sie unterscheiden sich nur im Namensraum. Welches Format die Bank annimmt, * Format die Bank annimmt, steht im FinTS-Segment HISPAS – siehe
* steht im FinTS-Segment HISPAS – siehe `Services\FinTsUeberweisung`. * `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): * **Echtzeitüberweisung.** SEPA behandelt sie als eigenes Verfahren (SCT Inst):
* In der Nachricht steht zusätzlich die Zahlungsart `LclInstrm/Cd = INST`, und * In der Nachricht steht zusätzlich die Zahlungsart `LclInstrm/Cd = INST`, und
* die Bank bekommt sie über einen eigenen Geschäftsvorfall (HKIPZ). Das ältere * die Bank bekommt sie über einen eigenen Geschäftsvorfall (HKIPZ). Das ältere
* DK-Format pain.001.003.03 kennt die Zahlungsart nicht – eine * 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 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_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'; 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. */ /** 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. */ /** 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. */ /** Zahlungsart (Local Instrument) der Echtzeitüberweisung. */
public const ECHTZEIT = 'INST'; public const ECHTZEIT = 'INST';
@ -69,9 +84,15 @@ class SepaXml
$echtzeit = !empty($auftrag['echtzeit']); $echtzeit = !empty($auftrag['echtzeit']);
if ($echtzeit && !in_array($schema, self::SCHEMATA_ECHTZEIT, true)) { 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 // Eindeutige Kennungen: Die Bank lehnt eine Nachricht ab, deren Kennung
// sie schon kennt. // sie schon kennt.
$kennung = 'FM' . date('YmdHis') . substr(bin2hex(random_bytes(4)), 0, 6); $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, $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); self::kind($dokument, $sammler, 'ReqdExctnDt', $ausfuehrung);
}
$schuldner = $dokument->createElement('Dbtr'); $schuldner = $dokument->createElement('Dbtr');
$sammler->appendChild($schuldner); $sammler->appendChild($schuldner);
@ -143,8 +170,10 @@ class SepaXml
$bankKennung = $dokument->createElement('FinInstnId'); $bankKennung = $dokument->createElement('FinInstnId');
$schuldnerBank->appendChild($bankKennung); $schuldnerBank->appendChild($bankKennung);
// Ohne BIC gilt „Other/NOTPROVIDED“ – im SEPA-Raum ist die IBAN genug. // 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'] ?? '') !== '') { 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 { } else {
$anders = $dokument->createElement('Othr'); $anders = $dokument->createElement('Othr');
$bankKennung->appendChild($anders); $bankKennung->appendChild($anders);
@ -160,6 +189,9 @@ class SepaXml
$zahlungsId = $dokument->createElement('PmtId'); $zahlungsId = $dokument->createElement('PmtId');
$zahlung->appendChild($zahlungsId); $zahlung->appendChild($zahlungsId);
self::kind($dokument, $zahlungsId, 'EndToEndId', 'NOTPROVIDED'); self::kind($dokument, $zahlungsId, 'EndToEndId', 'NOTPROVIDED');
if ($neuesSchema) {
self::kind($dokument, $zahlungsId, 'UETR', self::uetr());
}
$summe = $dokument->createElement('Amt'); $summe = $dokument->createElement('Amt');
$zahlung->appendChild($summe); $zahlung->appendChild($summe);
@ -172,7 +204,7 @@ class SepaXml
$zahlung->appendChild($empfaengerBank); $zahlung->appendChild($empfaengerBank);
$empfaengerBankId = $dokument->createElement('FinInstnId'); $empfaengerBankId = $dokument->createElement('FinInstnId');
$empfaengerBank->appendChild($empfaengerBankId); $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'); $empfaenger = $dokument->createElement('Cdtr');
@ -215,8 +247,9 @@ class SepaXml
* *
* Die Banken melden ihre Formate in HISPAS, manche mit dem Zusatz `_GBIC_4` * Die Banken melden ihre Formate in HISPAS, manche mit dem Zusatz `_GBIC_4`
* – deshalb der Präfixvergleich. Meldet die Bank nichts, bleibt es beim * – deshalb der Präfixvergleich. Meldet die Bank nichts, bleibt es beim
* Vorzugsformat; meldet sie nur Formate, die hier nicht erzeugt werden, * Vorzugsformat pain.001.001.03; meldet sie nur Formate, die hier nicht
* kommt `null` zurück. * 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 * 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`. * Bank ausschließlich das DK-Format, ist das Ergebnis ebenfalls `null`.
@ -273,4 +306,27 @@ class SepaXml
{ {
return strtoupper(str_replace(' ', '', $iban)); 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)
);
}
} }

View file

@ -144,8 +144,18 @@ include __DIR__ . '/../layout/kopf.php';
</label> </label>
</div> </div>
<h3><span title="Sobald hier Beträge stehen, gilt die Zuordnung aus den Zeilen; Kategorie und Gegenkonto oben bleiben dann außen vor. Die Summe muss dem Buchungsbetrag entsprechen.">Splitbuchung</span> <?php /* Direkte Zuordnung und Splitzeilen schließen sich aus: buchungsmaske.js
sperrt jeweils die andere Seite, sobald eine davon belegt ist. Der
Knopf rechts leert alle Splitzeilen und gibt Kategorie und Klasse
wieder frei. */ ?>
<div class="splitkopf">
<h3><span title="Sobald hier etwas eingetragen ist, gilt die Zuordnung aus den Zeilen; Kategorie / Gegenkonto und Klasse oben sind dann gesperrt. Umgekehrt sperrt eine Kategorie oder Klasse oben die Splitzeilen. Die Summe muss dem Buchungsbetrag entsprechen.">Splitbuchung</span>
<span class="optional">(optional)</span></h3> <span class="optional">(optional)</span></h3>
<button type="button" class="knopf klein gefahr" id="splitsleeren" hidden
title="Leert alle Splitzeilen und gibt Kategorie / Gegenkonto und Klasse wieder frei.">
Splitbuchungen löschen
</button>
</div>
<table class="liste splittabelle"> <table class="liste splittabelle">
<thead> <thead>

View file

@ -8,13 +8,16 @@ use Finanzmanager\Services\SepaXml;
* Die SEPA-Nachricht im Format, das die Bank annimmt. * 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 * 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 * ausschließlich das ältere DK-Format pain.001.003.03 an, andere melden nur
* andere schon beim Einreichen ab ("The bank does not support the XML schema * noch das neuere pain.001.001.09 – und weisen alles andere schon beim
* …"). Welche Formate eine Bank führt, meldet sie im Segment HISPAS. * 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 * 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 * 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 class SepaSchemaTest extends TestCase
{ {
@ -29,6 +32,7 @@ class SepaSchemaTest extends TestCase
$this->dkFormatIstSchemakonform(); $this->dkFormatIstSchemakonform();
$this->dasFormatStehtImNamensraum(); $this->dasFormatStehtImNamensraum();
$this->ohneTerminWirdSofortUeberwiesen(); $this->ohneTerminWirdSofortUeberwiesen();
$this->pain09TraegtDieNeuerungen();
} }
/** /**
@ -100,12 +104,23 @@ class SepaSchemaTest extends TestCase
SepaXml::schemaWaehlen([SepaXml::SCHEMA_PAIN_001_001_03 . '_GBIC_4']), SepaXml::schemaWaehlen([SepaXml::SCHEMA_PAIN_001_001_03 . '_GBIC_4']),
'Der Zusatz _GBIC_4 stört den Vergleich nicht' '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 private function unbekannteFormateFuehrenZuNull(): void
{ {
$this->assertNull( $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' '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 `<Dt>`, 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<string,array<string,mixed>> */ /** @return array<string,array<string,mixed>> */
private function auftraege(): array private function auftraege(): array
{ {

View file

@ -3,6 +3,7 @@
declare(strict_types=1); declare(strict_types=1);
use Fhp\Model\VopVerificationResult; use Fhp\Model\VopVerificationResult;
use Fhp\UnsupportedException;
use Finanzmanager\Services\FinTsUeberweisung; use Finanzmanager\Services\FinTsUeberweisung;
/** /**
@ -23,6 +24,7 @@ class NamensabgleichTest extends TestCase
$this->jedesErgebnisHatEinenDeutschenSatz(); $this->jedesErgebnisHatEinenDeutschenSatz();
$this->derBanktextWirdKlartext(); $this->derBanktextWirdKlartext();
$this->ohneWartendenVorgangGehtNichts(); $this->ohneWartendenVorgangGehtNichts();
$this->dieBekannteVopTanLueckeWirdErkannt();
} }
private function jedesErgebnisHatEinenDeutschenSatz(): void 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->assertEquals('fehler', $ergebnis['status'], 'Ohne Vorgang lässt sich nichts bestätigen');
$this->assertNotEmpty($ergebnis['meldung'], 'Und es gibt eine Erklärung dazu'); $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'
);
}
} }

View file

@ -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), SepaXml::schemaWaehlen([SepaXml::SCHEMA_PAIN_001_003_03, SepaXml::SCHEMA_PAIN_001_001_03 . '_GBIC_4'], true),
'Sonst wird das ISO-Format genommen' '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; $abgewiesen = false;
try { try {

View file

@ -372,6 +372,61 @@ test.describe('Buchungen', () => {
expect(titel).toContain(`${KATEGORIE}: -97,98`) 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 }) => { test('Klassen der Splitzeilen stehen in der Klassenspalte', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`) await page.goto(`/konten/${giroId}/buchung`)
await page.fill('#datum', '2026-05-03') await page.fill('#datum', '2026-05-03')