From 860e71d1e773639fc14284f4027cc0ea35131232 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 29 Sep 2026 23:55:18 +0200 Subject: [PATCH] Fix #95: Splitzeilen bleiben bei falscher Summe erhalten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eine falsche Splitsumme zeigte zwar die Fehlermeldung, leitete aber auf eine frische Buchungsmaske weiter und riss dabei alle gerade erfassten Splitzeilen (und übrigen Feldwerte) heraus, obwohl in der Datenbank nichts geändert wurde. anlegen()/speichern() zeichnen die Maske bei einem Validierungsfehler jetzt direkt mit den zuletzt abgeschickten Werten neu, statt wegzuleiten. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 3 + Changes.md | 17 +++++ src/Controllers/BuchungenController.php | 31 +++++++-- src/View.php | 5 +- src/Views/buchungen/formular.php | 89 ++++++++++++++++++++----- tests/e2e/specs/buchungen.spec.js | 47 +++++++++++++ 6 files changed, 168 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aae8dea..9ffff5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). ### Fixed +- Buchungsmaske: Eine falsche Splitsumme zeigte zwar die Fehlermeldung, riss aber über die + Weiterleitung zur leeren Maske alle gerade erfassten Splitzeilen heraus; jetzt bleibt die + Maske mit den eigenen Eingaben stehen, bis die Summe stimmt (#95) - 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 b570754..9abfcab 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,22 @@ # Änderungen +## Buchungsmaske: Splitzeilen bleiben bei falscher Summe erhalten (29.09.2026) + +- Scheiterte das Speichern einer Buchung an der Summenprüfung der Splitzeilen (Fehlermeldung + „Die Splitzeilen ergeben …, die Buchung lautet über …“), leitete `BuchungenController` bisher + mit einer Flash-Meldung auf eine frische Maske weiter – die gerade erfassten Splitzeilen (und + alle anderen Feldwerte) waren damit weg, obwohl in der Datenbank nichts geändert wurde: Die + Validierung läuft schon vor der Transaktion, es geht nichts kaputt, nur die Eingabe. +- `BuchungenController::anlegen()`/`speichern()` zeichnen die Maske jetzt bei einem + Validierungsfehler direkt neu, mit den zuletzt abgeschickten Werten (`$_POST`) statt der + gespeicherten – die Fehlermeldung erscheint wie gewohnt über `View::flash()`, die + eigenen Eingaben bleiben stehen und lassen sich korrigieren. `View::render()` erlaubt dafür + jetzt, dass ein Aufruf zusätzlich zur vorgemerkten Flash-Meldung eine eigene mitgibt, statt + sie zu überschreiben. +- Neu: `tests/e2e/specs/buchungen.spec.js` prüft, dass eine unstimmige Splitsumme die Eingaben + (Datum, Betrag, Empfänger, beide Splitzeilen) unverändert lässt und sich nach einer Korrektur + wie gewohnt speichern lässt. + ## Splitbuchungen: Summenzeile zeigt den Restbetrag, Speichern nur bei Null (29.09.2026) - Die Summenzeile der Splitbuchungen (Buchungsmaske und Splitdialog im Kontoblatt) zeigte diff --git a/src/Controllers/BuchungenController.php b/src/Controllers/BuchungenController.php index 67ef088..59d0cb7 100644 --- a/src/Controllers/BuchungenController.php +++ b/src/Controllers/BuchungenController.php @@ -118,7 +118,11 @@ class BuchungenController extends BaseController [$daten, $splits] = $this->formulardaten($kontoId); $id = Buchung::anlegen($daten, $splits); } catch (\Throwable $e) { - View::redirect('/konten/' . $kontoId . '/buchung', $e->getMessage(), 'fehler'); + // Keine Weiterleitung: Die schickte den Browser auf eine neue, + // leere Maske und riss damit die gerade eingegebenen Splitzeilen + // heraus. Stattdessen zeichnet dieselbe Anfrage die Maske mit den + // eigenen Eingaben neu (Fix #95). + $this->maske(Konto::find($kontoId), null, $_POST, $e->getMessage()); return; } @@ -166,7 +170,9 @@ class BuchungenController extends BaseController [$daten, $splits] = $this->formulardaten((int)$buchung['konto_id']); Buchung::speichern($id, $daten, $splits); } catch (\Throwable $e) { - View::redirect('/buchungen/' . $id, $e->getMessage(), 'fehler'); + // Wie in anlegen(): die Maske bleibt mit den eigenen Eingaben + // stehen, statt sie über eine Weiterleitung zu verlieren (Fix #95). + $this->maske(Konto::find((int)$buchung['konto_id']), $buchung, $_POST, $e->getMessage()); return; } @@ -582,23 +588,36 @@ class BuchungenController extends BaseController return $regelId === null ? $ziel : $ziel . '?neue_regel=' . $regelId; } - /** @param array|null $konto */ - private function maske(?array $konto, ?array $buchung): void + /** + * @param array|null $konto + * @param array|null $buchung + * @param array|null $eingaben die zuletzt abgeschickten + * Formularwerte ($_POST), sobald das Speichern gerade an einer + * Validierung gescheitert ist – die Maske zeigt dann diese statt der + * gespeicherten Werte, damit nichts verloren geht (Fix #95). + */ + private function maske(?array $konto, ?array $buchung, ?array $eingaben = null, string $fehler = ''): void { if ($konto === null) { $this->nichtGefunden('/konten'); return; } - $this->render('buchungen/formular.php', [ + $daten = [ 'titel' => $buchung === null ? 'Neue Buchung' : 'Buchung bearbeiten', 'konto' => $konto, 'buchung' => $buchung, 'splits' => $buchung === null ? [] : Buchung::splits((int)$buchung['id']), + 'eingaben' => $eingaben, 'kategorien' => Kategorie::baumFlach(), 'klassen' => Klasse::alle(), 'konten' => Konto::alle(), - ]); + ]; + if ($fehler !== '') { + $daten['flash'] = [['typ' => 'fehler', 'meldung' => $fehler]]; + } + + $this->render('buchungen/formular.php', $daten); } /** diff --git a/src/View.php b/src/View.php index cdb171d..63fb638 100644 --- a/src/View.php +++ b/src/View.php @@ -22,7 +22,10 @@ class View public function render(string $template, array $daten = []): void { - $daten['flash'] = $this->flash; + // Eine Meldung, die der Aufrufer selbst mitgibt (statt über die Session), + // zeigt sich zusätzlich zu vorgemerkten – etwa ein Validierungsfehler, der + // die Maske ohne Weiterleitung neu zeichnet und dabei die Eingaben behält. + $daten['flash'] = array_merge($this->flash, $daten['flash'] ?? []); $daten['aktuellerPfad'] = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); extract($daten, EXTR_SKIP); diff --git a/src/Views/buchungen/formular.php b/src/Views/buchungen/formular.php index 53681a0..054d2bf 100644 --- a/src/Views/buchungen/formular.php +++ b/src/Views/buchungen/formular.php @@ -5,14 +5,17 @@ use Finanzmanager\View; /** @var array $konto */ /** @var array|null $buchung */ /** @var list> $splits */ +/** @var array|null $eingaben die zuletzt abgeschickten Formularwerte + * ($_POST), sobald das Speichern gerade an einer Validierung gescheitert ist */ /** @var list> $kategorien */ /** @var list> $klassen */ /** @var list> $konten */ /** @var string $csrfToken */ -$neu = $buchung === null; -$ziel = $neu ? '/konten/' . (int)$konto['id'] . '/buchung' : '/buchungen/' . (int)$buchung['id']; -$betrag = $neu ? 0.0 : (float)$buchung['betrag']; +$neu = $buchung === null; +$ziel = $neu ? '/konten/' . (int)$konto['id'] . '/buchung' : '/buchungen/' . (int)$buchung['id']; +$betrag = $neu ? 0.0 : (float)$buchung['betrag']; +$eingaben = $eingaben ?? null; // Die Art bestimmt nur noch die Richtung. Ob die Buchung eine Umbuchung ist, // steht im Feld Kategorie / Gegenkonto. @@ -29,6 +32,32 @@ $zuordnungswert = static function (?array $zeile): string { return ''; }; +// Die Werte, die die Felder zeigen: normalerweise die gespeicherte Buchung +// (oder eine leere Maske), nach einem gescheiterten Speichern aber die zuletzt +// abgeschickten Eingaben – unverändert, auch wenn sie noch fehlerhaft sind +// (Fix #95). Roh übernommen statt neu formatiert, damit z. B. ein angefangener +// Betrag wie „12,“ stehen bleibt, wie ihn die Person zuletzt getippt hat. +$datumWert = $neu ? date('Y-m-d') : substr((string)$buchung['datum'], 0, 10); +$betragWert = $neu ? '' : View::betrag(abs($betrag), false); +$empfaengerWert = (string)($buchung['empfaenger'] ?? ''); +$zuordnungWert = $zuordnungswert($buchung); +$klasseWert = (int)($buchung['klasse_id'] ?? 0); +$buchungstextWert = (string)($buchung['buchungstext'] ?? ''); +$nummerWert = (string)($buchung['nummer'] ?? ''); +$abgeglichenWert = !empty($buchung['abgeglichen']); + +if ($eingaben !== null) { + $art = ($eingaben['art'] ?? '') === 'einnahme' ? 'einnahme' : 'ausgabe'; + $datumWert = (string)($eingaben['datum'] ?? ''); + $betragWert = (string)($eingaben['betrag'] ?? ''); + $empfaengerWert = (string)($eingaben['empfaenger'] ?? ''); + $zuordnungWert = (string)($eingaben['zuordnung'] ?? ''); + $klasseWert = (int)($eingaben['klasse_id'] ?? 0); + $buchungstextWert = (string)($eingaben['buchungstext'] ?? ''); + $nummerWert = (string)($eingaben['nummer'] ?? ''); + $abgeglichenWert = isset($eingaben['abgeglichen']); +} + $eigenesKonto = (int)$konto['id']; $mitSplit = false; $mitDepot = true; @@ -42,10 +71,37 @@ $kontozusatz = trim((string)($konto['iban'] ?? '')) !== '' trim((string)($konto['kontonummer'] ?? '')), ]))); +// Jede Zeile auf dieselbe Form bringen (Zuordnung als fertiger Auswahlwert, +// Betrag als anzuzeigender Text) – so braucht es unten nur eine Fallunterscheidung, +// egal ob die Zeilen aus der Datenbank oder aus gescheiterten Eingaben stammen. +if ($eingaben !== null) { + $splitPosten = is_array($eingaben['split'] ?? null) ? $eingaben['split'] : []; + $splitZeilen = []; + foreach ($splitPosten as $zeile) { + if (!is_array($zeile)) { + continue; + } + $splitZeilen[] = [ + 'zuordnung' => (string)($zeile['zuordnung'] ?? ''), + 'klasse_id' => $zeile['klasse_id'] ?? null, + 'notiz' => (string)($zeile['notiz'] ?? ''), + 'betrag' => (string)($zeile['betrag'] ?? ''), + ]; + } +} else { + $splitZeilen = array_map(static function (array $zeile) use ($zuordnungswert): array { + return [ + 'zuordnung' => $zuordnungswert($zeile), + 'klasse_id' => $zeile['klasse_id'] ?? null, + 'notiz' => (string)($zeile['notiz'] ?? ''), + 'betrag' => ($zeile['betrag'] ?? '') === '' ? '' : View::betrag($zeile['betrag'], false), + ]; + }, $splits); +} + // Immer ein paar leere Splitzeilen anbieten; weitere fügt der Knopf hinzu. -$splitZeilen = $splits; for ($i = count($splitZeilen); $i < 4; $i++) { - $splitZeilen[] = ['kategorie_id' => null, 'klasse_id' => null, 'betrag' => '', 'notiz' => '']; + $splitZeilen[] = ['zuordnung' => '', 'klasse_id' => null, 'betrag' => '', 'notiz' => '']; } include __DIR__ . '/../layout/kopf.php'; @@ -89,25 +145,25 @@ include __DIR__ . '/../layout/kopf.php';
+ value="">
+ value="">
+ value="">
@@ -118,7 +174,7 @@ include __DIR__ . '/../layout/kopf.php'; @@ -128,18 +184,18 @@ include __DIR__ . '/../layout/kopf.php';
+ value="">
- +
@@ -179,7 +235,7 @@ include __DIR__ . '/../layout/kopf.php'; - + @@ -194,11 +250,10 @@ include __DIR__ . '/../layout/kopf.php'; - + + name="split[][betrag]" value=""> diff --git a/tests/e2e/specs/buchungen.spec.js b/tests/e2e/specs/buchungen.spec.js index 464046d..cb99409 100644 --- a/tests/e2e/specs/buchungen.spec.js +++ b/tests/e2e/specs/buchungen.spec.js @@ -381,6 +381,53 @@ test.describe('Buchungen', () => { expect(titel).toContain(`${KATEGORIE}: -97,98`) }) + test('Falsche Splitsumme behält die eingegebenen Splitzeilen (Fix #95)', async ({ page }) => { + await page.goto(`/konten/${giroId}/buchung`) + + await page.fill('#datum', '2026-06-01') + await page.fill('#betrag', '100') + await page.fill('#empfaenger', `Falschsumme ${MARKE}`) + + await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE }) + await page.fill('input[name="split[0][notiz]"]', 'Erster Teil') + await page.fill('input[name="split[0][betrag]"]', '-30') + + await page.selectOption('select[name="split[1][zuordnung]"]', { label: KATEGORIE }) + await page.fill('input[name="split[1][notiz]"]', 'Zweiter Teil') + await page.fill('input[name="split[1][betrag]"]', '-50') + + const zuordnungWert = await page.locator('select[name="split[0][zuordnung]"]').inputValue() + + // -30 und -50 ergeben nicht die -100 der Buchung: buchungsmaske.js sperrt + // den Knopf deshalb schon clientseitig. Das Formular geht hier trotzdem + // direkt ab, um die serverseitige Prüfung zu erreichen – wie es auch ohne + // JavaScript oder bei einer manipulierten Anfrage passieren würde. + await page.locator('.buchungsmaske').evaluate((formular) => formular.submit()) + + await expect(page.locator('.meldung-fehler')).toContainText('Splitzeilen ergeben') + + // Der Kern von Fix #95: Nichts muss neu erfasst werden, alle Eingaben + // stehen nach der Fehlermeldung unverändert da. + await expect(page.locator('#datum')).toHaveValue('2026-06-01') + await expect(page.locator('#betrag')).toHaveValue('100') + await expect(page.locator('#empfaenger')).toHaveValue(`Falschsumme ${MARKE}`) + await expect(page.locator('select[name="split[0][zuordnung]"]')).toHaveValue(zuordnungWert) + await expect(page.locator('input[name="split[0][notiz]"]')).toHaveValue('Erster Teil') + await expect(page.locator('input[name="split[0][betrag]"]')).toHaveValue('-30') + await expect(page.locator('select[name="split[1][zuordnung]"]')).toHaveValue(zuordnungWert) + await expect(page.locator('input[name="split[1][notiz]"]')).toHaveValue('Zweiter Teil') + await expect(page.locator('input[name="split[1][betrag]"]')).toHaveValue('-50') + + // Nach einer Korrektur lässt sich die Buchung wie gewohnt speichern. + await page.fill('input[name="split[1][betrag]"]', '-70') + await page.click('.buchungsmaske button[type="submit"]') + await expect(page.locator('.meldung-erfolg')).toBeVisible() + + const zeile = page.locator('tr', { hasText: `Falschsumme ${MARKE}` }) + await expect(zeile).toContainText('Splitbuchung') + await expect(zeile).toContainText('-100,00') + }) + test('Splitzeilen und direkte Zuordnung sperren sich gegenseitig', async ({ page }) => { await page.goto(`/konten/${giroId}/buchung`) const kategorie = page.locator('#zuordnung')