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:
commit
0b75dd1fa0
12 changed files with 416 additions and 24 deletions
13
CHANGELOG.md
Normal file
13
CHANGELOG.md
Normal 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)
|
||||
38
Changes.md
38
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
|
||||
|
|
|
|||
|
|
@ -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 –
|
||||
|
|
|
|||
|
|
@ -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 <dialog> 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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 `<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.
|
||||
* 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,8 +144,18 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</label>
|
||||
</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>
|
||||
<span class="optional">(optional)</span></h3>
|
||||
<?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>
|
||||
<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">
|
||||
<thead>
|
||||
|
|
|
|||
|
|
@ -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 `<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>> */
|
||||
private function auftraege(): array
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue