Fix #95: Splitzeilen bleiben bei falscher Summe erhalten

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 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-09-29 23:55:18 +02:00
parent 619e427a30
commit 860e71d1e7
6 changed files with 168 additions and 24 deletions

View file

@ -16,6 +16,9 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
### Fixed ### 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; - 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ß ein Zusammentreffen von Namensabgleich-Volltreffer und TAN-Anfrage in einer Antwort ließ
auch die gewöhnliche Überweisung mit einer rohen Bibliotheksmeldung scheitern (#89) auch die gewöhnliche Überweisung mit einer rohen Bibliotheksmeldung scheitern (#89)

View file

@ -1,5 +1,22 @@
# Änderungen # Ä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) ## Splitbuchungen: Summenzeile zeigt den Restbetrag, Speichern nur bei Null (29.09.2026)
- Die Summenzeile der Splitbuchungen (Buchungsmaske und Splitdialog im Kontoblatt) zeigte - Die Summenzeile der Splitbuchungen (Buchungsmaske und Splitdialog im Kontoblatt) zeigte

View file

@ -118,7 +118,11 @@ class BuchungenController extends BaseController
[$daten, $splits] = $this->formulardaten($kontoId); [$daten, $splits] = $this->formulardaten($kontoId);
$id = Buchung::anlegen($daten, $splits); $id = Buchung::anlegen($daten, $splits);
} catch (\Throwable $e) { } 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; return;
} }
@ -166,7 +170,9 @@ class BuchungenController extends BaseController
[$daten, $splits] = $this->formulardaten((int)$buchung['konto_id']); [$daten, $splits] = $this->formulardaten((int)$buchung['konto_id']);
Buchung::speichern($id, $daten, $splits); Buchung::speichern($id, $daten, $splits);
} catch (\Throwable $e) { } 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; return;
} }
@ -582,23 +588,36 @@ class BuchungenController extends BaseController
return $regelId === null ? $ziel : $ziel . '?neue_regel=' . $regelId; return $regelId === null ? $ziel : $ziel . '?neue_regel=' . $regelId;
} }
/** @param array<string,mixed>|null $konto */ /**
private function maske(?array $konto, ?array $buchung): void * @param array<string,mixed>|null $konto
* @param array<string,mixed>|null $buchung
* @param array<string,mixed>|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) { if ($konto === null) {
$this->nichtGefunden('/konten'); $this->nichtGefunden('/konten');
return; return;
} }
$this->render('buchungen/formular.php', [ $daten = [
'titel' => $buchung === null ? 'Neue Buchung' : 'Buchung bearbeiten', 'titel' => $buchung === null ? 'Neue Buchung' : 'Buchung bearbeiten',
'konto' => $konto, 'konto' => $konto,
'buchung' => $buchung, 'buchung' => $buchung,
'splits' => $buchung === null ? [] : Buchung::splits((int)$buchung['id']), 'splits' => $buchung === null ? [] : Buchung::splits((int)$buchung['id']),
'eingaben' => $eingaben,
'kategorien' => Kategorie::baumFlach(), 'kategorien' => Kategorie::baumFlach(),
'klassen' => Klasse::alle(), 'klassen' => Klasse::alle(),
'konten' => Konto::alle(), 'konten' => Konto::alle(),
]); ];
if ($fehler !== '') {
$daten['flash'] = [['typ' => 'fehler', 'meldung' => $fehler]];
}
$this->render('buchungen/formular.php', $daten);
} }
/** /**

View file

@ -22,7 +22,10 @@ class View
public function render(string $template, array $daten = []): void 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); $daten['aktuellerPfad'] = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
extract($daten, EXTR_SKIP); extract($daten, EXTR_SKIP);

View file

@ -5,14 +5,17 @@ use Finanzmanager\View;
/** @var array<string,mixed> $konto */ /** @var array<string,mixed> $konto */
/** @var array<string,mixed>|null $buchung */ /** @var array<string,mixed>|null $buchung */
/** @var list<array<string,mixed>> $splits */ /** @var list<array<string,mixed>> $splits */
/** @var array<string,mixed>|null $eingaben die zuletzt abgeschickten Formularwerte
* ($_POST), sobald das Speichern gerade an einer Validierung gescheitert ist */
/** @var list<array<string,mixed>> $kategorien */ /** @var list<array<string,mixed>> $kategorien */
/** @var list<array<string,mixed>> $klassen */ /** @var list<array<string,mixed>> $klassen */
/** @var list<array<string,mixed>> $konten */ /** @var list<array<string,mixed>> $konten */
/** @var string $csrfToken */ /** @var string $csrfToken */
$neu = $buchung === null; $neu = $buchung === null;
$ziel = $neu ? '/konten/' . (int)$konto['id'] . '/buchung' : '/buchungen/' . (int)$buchung['id']; $ziel = $neu ? '/konten/' . (int)$konto['id'] . '/buchung' : '/buchungen/' . (int)$buchung['id'];
$betrag = $neu ? 0.0 : (float)$buchung['betrag']; $betrag = $neu ? 0.0 : (float)$buchung['betrag'];
$eingaben = $eingaben ?? null;
// Die Art bestimmt nur noch die Richtung. Ob die Buchung eine Umbuchung ist, // Die Art bestimmt nur noch die Richtung. Ob die Buchung eine Umbuchung ist,
// steht im Feld Kategorie / Gegenkonto. // steht im Feld Kategorie / Gegenkonto.
@ -29,6 +32,32 @@ $zuordnungswert = static function (?array $zeile): string {
return ''; 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']; $eigenesKonto = (int)$konto['id'];
$mitSplit = false; $mitSplit = false;
$mitDepot = true; $mitDepot = true;
@ -42,10 +71,37 @@ $kontozusatz = trim((string)($konto['iban'] ?? '')) !== ''
trim((string)($konto['kontonummer'] ?? '')), 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. // Immer ein paar leere Splitzeilen anbieten; weitere fügt der Knopf hinzu.
$splitZeilen = $splits;
for ($i = count($splitZeilen); $i < 4; $i++) { 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'; include __DIR__ . '/../layout/kopf.php';
@ -89,25 +145,25 @@ include __DIR__ . '/../layout/kopf.php';
<div class="feld"> <div class="feld">
<label for="datum">Datum</label> <label for="datum">Datum</label>
<input type="date" id="datum" name="datum" required <input type="date" id="datum" name="datum" required
value="<?= View::h($neu ? date('Y-m-d') : substr((string)$buchung['datum'], 0, 10)) ?>"> value="<?= View::h($datumWert) ?>">
</div> </div>
<div class="feld"> <div class="feld">
<label for="betrag" title="Immer positiv eingeben – die Richtung bestimmt die Art.">Betrag</label> <label for="betrag" title="Immer positiv eingeben – die Richtung bestimmt die Art.">Betrag</label>
<input type="text" id="betrag" name="betrag" inputmode="decimal" required <input type="text" id="betrag" name="betrag" inputmode="decimal" required
value="<?= View::h($neu ? '' : View::betrag(abs($betrag), false)) ?>"> value="<?= View::h($betragWert) ?>">
</div> </div>
<div class="feld"> <div class="feld">
<label for="empfaenger">Empfänger</label> <label for="empfaenger">Empfänger</label>
<input type="text" id="empfaenger" name="empfaenger" <input type="text" id="empfaenger" name="empfaenger"
value="<?= View::h((string)($buchung['empfaenger'] ?? '')) ?>"> value="<?= View::h($empfaengerWert) ?>">
</div> </div>
<div class="feld"> <div class="feld">
<label for="zuordnung" title="Unter den Kategorien stehen die eigenen Konten in eckigen Klammern. Eines davon macht die Buchung zur Umbuchung: Sie wird einmal gespeichert und erscheint auf beiden Kontoblättern – auf dem Gegenkonto mit umgekehrtem Vorzeichen.">Kategorie / Gegenkonto</label> <label for="zuordnung" title="Unter den Kategorien stehen die eigenen Konten in eckigen Klammern. Eines davon macht die Buchung zur Umbuchung: Sie wird einmal gespeichert und erscheint auf beiden Kontoblättern – auf dem Gegenkonto mit umgekehrtem Vorzeichen.">Kategorie / Gegenkonto</label>
<select id="zuordnung" name="zuordnung"> <select id="zuordnung" name="zuordnung">
<?php $gewaehlt = $zuordnungswert($buchung); ?> <?php $gewaehlt = $zuordnungWert; ?>
<?php include __DIR__ . '/zuordnungsliste.php'; ?> <?php include __DIR__ . '/zuordnungsliste.php'; ?>
</select> </select>
</div> </div>
@ -118,7 +174,7 @@ include __DIR__ . '/../layout/kopf.php';
<option value="">— keine —</option> <option value="">— keine —</option>
<?php foreach ($klassen as $k): ?> <?php foreach ($klassen as $k): ?>
<option value="<?= (int)$k['id'] ?>" <option value="<?= (int)$k['id'] ?>"
<?= (int)($buchung['klasse_id'] ?? 0) === (int)$k['id'] ? 'selected' : '' ?>> <?= $klasseWert === (int)$k['id'] ? 'selected' : '' ?>>
<?= View::h($k['name']) ?> <?= View::h($k['name']) ?>
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
@ -128,18 +184,18 @@ include __DIR__ . '/../layout/kopf.php';
<div class="feld"> <div class="feld">
<label for="buchungstext">Buchungstext</label> <label for="buchungstext">Buchungstext</label>
<input type="text" id="buchungstext" name="buchungstext" <input type="text" id="buchungstext" name="buchungstext"
value="<?= View::h((string)($buchung['buchungstext'] ?? '')) ?>"> value="<?= View::h($buchungstextWert) ?>">
</div> </div>
<div class="feld"> <div class="feld">
<label for="nummer">Nummer <span class="optional">(optional)</span></label> <label for="nummer">Nummer <span class="optional">(optional)</span></label>
<input type="text" id="nummer" name="nummer" value="<?= View::h((string)($buchung['nummer'] ?? '')) ?>"> <input type="text" id="nummer" name="nummer" value="<?= View::h($nummerWert) ?>">
</div> </div>
<div class="feld ganz"> <div class="feld ganz">
<label class="ankreuzfeld"> <label class="ankreuzfeld">
<input type="checkbox" name="abgeglichen" value="1" <input type="checkbox" name="abgeglichen" value="1"
<?= !empty($buchung['abgeglichen']) ? 'checked' : '' ?>> <?= $abgeglichenWert ? 'checked' : '' ?>>
Abgeglichen Abgeglichen
</label> </label>
</div> </div>
@ -179,7 +235,7 @@ include __DIR__ . '/../layout/kopf.php';
<?php /* In einer Splitzeile ist das Depot genau das, was <?php /* In einer Splitzeile ist das Depot genau das, was
es zu sein hat: ein Gegenkonto. Die Zeile steht es zu sein hat: ein Gegenkonto. Die Zeile steht
ja schon für einen bestimmten Betrag. */ ?> ja schon für einen bestimmten Betrag. */ ?>
<?php $gewaehlt = $zuordnungswert($zeile); $mitDepot = false; ?> <?php $gewaehlt = $zeile['zuordnung']; $mitDepot = false; ?>
<?php include __DIR__ . '/zuordnungsliste.php'; ?> <?php include __DIR__ . '/zuordnungsliste.php'; ?>
</select> </select>
</td> </td>
@ -194,11 +250,10 @@ include __DIR__ . '/../layout/kopf.php';
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</td> </td>
<td><input type="text" name="split[<?= $nr ?>][notiz]" value="<?= View::h((string)($zeile['notiz'] ?? '')) ?>"></td> <td><input type="text" name="split[<?= $nr ?>][notiz]" value="<?= View::h($zeile['notiz']) ?>"></td>
<td> <td>
<input type="text" class="splitbetrag rechts" inputmode="decimal" <input type="text" class="splitbetrag rechts" inputmode="decimal"
name="split[<?= $nr ?>][betrag]" name="split[<?= $nr ?>][betrag]" value="<?= View::h($zeile['betrag']) ?>">
value="<?= $zeile['betrag'] === '' ? '' : View::h(View::betrag($zeile['betrag'], false)) ?>">
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>

View file

@ -381,6 +381,53 @@ test.describe('Buchungen', () => {
expect(titel).toContain(`${KATEGORIE}: -97,98`) 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 }) => { test('Splitzeilen und direkte Zuordnung sperren sich gegenseitig', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`) await page.goto(`/konten/${giroId}/buchung`)
const kategorie = page.locator('#zuordnung') const kategorie = page.locator('#zuordnung')