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