diff --git a/CHANGELOG.md b/CHANGELOG.md index f8b280d..9ffff5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,15 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). - Regeln: Die Splittabellen zeigen die Spalte „Anteil“ ohne Prozentzeichen und darunter die laufend berechnete Summe der Anteile +- Buchungsmaske und Splitdialog im Kontoblatt: Die Summenzeile der Splitbuchungen zeigt den + noch offenen Restbetrag zum Buchungswert statt der Summe der Zeilen; „Speichern“ bleibt + gesperrt, solange Splitzeilen befüllt sind und der Restbetrag nicht Null ist (#96) ### 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 3308071..9abfcab 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,54 @@ # Ä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 + bisher die Summe der erfassten Zeilen, mit „(Rest X)“ als Zusatz. Jetzt steht dort direkt + der noch offene Restbetrag zum Buchungswert (`Buchungswert - Summe der Zeilen`) – die + gemeinsame Rechenfunktion `FmBetrag.splitsumme()` in `betrag.js` liefert ihn beiden Masken. +- „Speichern“ (bzw. „Splitbuchung speichern“) bleibt gesperrt, solange mindestens eine + Splitzeile einen Betrag trägt und der Restbetrag nicht Null ist. Ohne Splitzeilen – egal ob + unbelegt oder mit direkter Kategorie/Gegenkonto zugeordnet – bleibt der Knopf wie bisher an; + das Model prüft die Summe der Splitzeilen ohnehin schon serverseitig. +- Der bisherige E2E-Test, der eine unstimmige Splitsumme über eine serverseitige Fehlermeldung + prüfte, prüft jetzt die neue Sperre im Browser; die serverseitige Prüfung selbst deckt + weiterhin Test 09 (PHP) ab. + +## Regel direkt aus einer Buchung anlegen (29.09.2026) + +- Neuer Toggle „Regel anlegen“ in der Buchungsmaske, standardmäßig aus; das bisherige + Verhalten bleibt unverändert, solange er aus ist. +- Ist er beim Speichern an, entsteht aus Empfänger (ersatzweise Buchungstext), Kategorie, + Klasse, Gegenkonto und Splits der Buchung eine neue Regel (`AbgleichRegel::ausBuchung()`). + Ohne Empfänger und Buchungstext gibt es kein sinnvolles Muster – dann bleibt es beim + gewöhnlichen Speichern ohne Regel. +- Das Kontoblatt zeigt die neue Regel danach in einem modalen Dialog (`regeldialog.js`, + natives `` wie beim Splitdialog) zum Prüfen und Anpassen – mit denselben Feldern + wie „Neue Regel“ in den Bankregeln (ausgelagert nach `regelfelder.php`). +- „Abbruch“ löscht die gerade erst angelegte Regel wieder (über das bestehende + Lösch-Formular der Regelliste, nur mit Rücksprung zum Kontoblatt statt zur Regelliste); + die Buchung bleibt gespeichert. Die Escape-Taste verhält sich genauso, statt den Dialog + nur zu schließen und eine verwaiste Regel zurückzulassen. +- „Speichern“ übernimmt Änderungen und lässt die Regel in der Liste stehen. +- Neu: Test 51 (PHP) und `tests/e2e/specs/regelausbuchung.spec.js`. + ## Umbuchungen: beide Seiten finden zueinander (29.09.2026) - Eine Umbuchung erscheint auf zwei Kontoauszügen, wurde aber nicht als eine Buchung erkannt: diff --git a/docs/banking.md b/docs/banking.md index e4958aa..6ec9b9a 100644 --- a/docs/banking.md +++ b/docs/banking.md @@ -474,6 +474,20 @@ Abruf zum Stehen. Eingegeben wird ohne Trennzeichen: `Miete|Nebenkosten`, nicht Der Zähler *Treffer* zeigt, welche Regeln im Alltag überhaupt greifen. +### Regel direkt aus einer Buchung anlegen + +In der Buchungsmaske schaltet der Toggle *Regel anlegen* (standardmäßig aus) eine +Kurzstrecke frei: Beim Speichern entsteht aus Empfänger (ersatzweise Buchungstext), +Kategorie, Klasse, Gegenkonto und einer eventuellen Splitbuchung eine neue Regel +(`AbgleichRegel::ausBuchung()`) – Feld *Empfänger*, Vergleich *enthält*, das Muster ist der +Empfängertext. Fehlen sowohl Empfänger als auch Buchungstext, gibt es kein sinnvolles Muster +und keine Regel entsteht; die Buchung wird trotzdem ganz normal gespeichert. + +Das Kontoblatt zeigt die neue Regel danach in einem modalen Dialog mit denselben Feldern wie +*Neue Regel* oben auf dieser Seite. *Abbruch* (auch über Escape) löscht die gerade erst +angelegte Regel wieder – die Buchung bleibt gespeichert, keine ungewollte Regel bleibt +zurück. *Speichern* übernimmt Änderungen und belässt die Regel in der Liste. + ## Protokoll und Diagnose Jeder Abruf schreibt eine Zeile nach `bank_abrufe` mit Status, Zahlen und Verlauf – ohne das diff --git a/public/static/app.css b/public/static/app.css index 88770c0..6ad60c7 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -1320,11 +1320,16 @@ table.splittabelle input.rechts { text-align: right; } .formatname { font-weight: 600; font-size: 1.05rem; } /* Der Wertpapierdialog trägt ein Formular und wird dadurch länger als der - Splitdialog; bei kleinen Fenstern scrollt er. */ + Splitdialog; bei kleinen Fenstern scrollt er. Der Regeldialog übernimmt + dieselben Regelfelder wie die Regelliste – aus demselben Grund dieselbe + Bauart. */ .wertpapierdialog { max-height: 88vh; overflow-y: auto; } .wertpapierdialog .formular { max-width: none; } .wertpapierdialog h2 { margin: 0 0 .3rem; font-size: 1.05rem; } +.regeldialog { max-height: 88vh; overflow-y: auto; } +.regeldialog .formular { max-width: none; } + /* Die Kostentabelle: Betrag rechts, Kategorie als stiller Hinweis daneben. */ table.kostentabelle { margin-top: .6rem; } table.kostentabelle td { padding: .25rem .4rem; } diff --git a/public/static/betrag.js b/public/static/betrag.js index d6e08e7..dfb7ca3 100644 --- a/public/static/betrag.js +++ b/public/static/betrag.js @@ -31,7 +31,9 @@ } /* Die Summe der Splitzeilen unter `felder` gegen `ziel` – das Ergebnis geht - * so in die Anzeige, wie beide Masken es brauchen. */ + * so in die Anzeige, wie beide Masken es brauchen. Angezeigt wird der noch + * offene Restbetrag (ziel - summe), nicht die Summe der Zeilen selbst: Nur + * so sieht man auf einen Blick, was noch fehlt. */ function splitsumme(felder, ziel) { let summe = 0; let belegt = false; @@ -50,7 +52,7 @@ belegt: belegt, summe: summe, rest: rest, - text: belegt ? formatiere(summe) + (rest === 0 ? '' : ' (Rest ' + formatiere(rest) + ')') : '0,00' + text: formatiere(rest) }; } diff --git a/public/static/buchungsmaske.js b/public/static/buchungsmaske.js index ae14dbd..99510cb 100644 --- a/public/static/buchungsmaske.js +++ b/public/static/buchungsmaske.js @@ -1,6 +1,10 @@ /* Buchungsmaske: Restbetrag der Splitzeilen mitrechnen und direkte Zuordnung * und Splitbuchung gegeneinander sperren. * + * Solange Splitzeilen befüllt sind, aber noch ein Restbetrag zum + * Buchungswert offen ist, bleibt „Speichern“ gesperrt – das Model wiese die + * Buchung sonst ohnehin ab, hier spart es die Rundreise zum Server. + * * Nur Komfort – die Maske kommt fertig vom Server. Ohne JavaScript bleibt alles * bedienbar: Die Splitsumme prüft ohnehin das Model. * @@ -26,6 +30,7 @@ const summenfeld = document.getElementById('splitsumme'); const betragsfeld = document.getElementById('betrag'); + const speichernknopf = maske.querySelector('button[type="submit"]'); function rechneSumme() { if (!summenfeld) { @@ -46,6 +51,13 @@ summenfeld.textContent = stand.text; summenfeld.classList.toggle('negativ', stand.belegt && stand.rest !== 0); + + // Gesperrt nur, solange tatsächlich Splitzeilen mit Beträgen stehen und + // deren Summe den Buchungswert verfehlt. Eine direkte Zuordnung (oder + // gar keine Eingabe) deckt den ganzen Betrag stets ab. + if (speichernknopf) { + speichernknopf.disabled = stand.belegt && stand.rest !== 0; + } } const direktfelder = ['zuordnung', 'klasse_id'] diff --git a/public/static/kontoblatt.js b/public/static/kontoblatt.js index fe3e5cf..3a79f72 100644 --- a/public/static/kontoblatt.js +++ b/public/static/kontoblatt.js @@ -335,6 +335,7 @@ const zeilenkasten = document.getElementById('splitdialogzeilen'); const summenfeld = document.getElementById('splitdialogsumme'); const loeschknopf = document.getElementById('splitloeschen'); + const speichernknopf = dialog.querySelector('button[type="submit"].primaer'); const vorlage = zeilenkasten.innerHTML; let buchungsbetrag = 0; @@ -342,10 +343,16 @@ return Array.from(zeilenkasten.querySelectorAll('.splitbetrag')); } + // Gesperrt nur, solange Beträge stehen, deren Summe den Buchungswert + // verfehlt. Bleiben alle Zeilen leer, hebt „Splitbuchung speichern“ die + // Aufteilung auf – das bleibt erlaubt. function rechneSumme() { const stand = window.FmBetrag.splitsumme(felder(), buchungsbetrag); summenfeld.textContent = stand.text; summenfeld.classList.toggle('negativ', stand.belegt && stand.rest !== 0); + if (speichernknopf) { + speichernknopf.disabled = stand.belegt && stand.rest !== 0; + } } /* Eine weitere Zeile: die letzte kopieren, leeren und neu durchnummerieren. diff --git a/public/static/regeldialog.js b/public/static/regeldialog.js new file mode 100644 index 0000000..5e57b92 --- /dev/null +++ b/public/static/regeldialog.js @@ -0,0 +1,27 @@ +/* Regeldialog: öffnet sich von selbst, wenn die Buchungsmaske mit gesetztem + * Toggle „Regel anlegen“ gespeichert hat. + * + * Die Regel steht zu diesem Zeitpunkt schon in der Datenbank – der Dialog + * zeigt sie nur zum Prüfen. Ein Abbruch über die Escape-Taste darf deshalb + * nicht bloß den Dialog schließen, sonst bliebe die Regel ungewollt stehen + * (Fix #94). Er löst stattdessen denselben Knopf aus wie ein Klick auf + * „Abbruch“. + */ + +(function () { + 'use strict'; + + const dialog = document.getElementById('regeldialog'); + const formular = document.getElementById('regelformular'); + const abbrechen = document.getElementById('regelabbrechen'); + if (!dialog || !formular || !abbrechen) { + return; + } + + dialog.showModal(); + + dialog.addEventListener('cancel', function (ereignis) { + ereignis.preventDefault(); + formular.requestSubmit(abbrechen); + }); +})(); diff --git a/src/Controllers/BankingController.php b/src/Controllers/BankingController.php index ee00bc1..a9eb329 100644 --- a/src/Controllers/BankingController.php +++ b/src/Controllers/BankingController.php @@ -941,11 +941,11 @@ class BankingController extends BaseController try { AbgleichRegel::speichern((int)$params['id'], $this->regeldaten()); } catch (\Throwable $e) { - View::redirect('/banking/regeln', $e->getMessage(), 'fehler'); + View::redirect($this->regelZiel(), $e->getMessage(), 'fehler'); return; } - View::redirect('/banking/regeln', 'Regel gespeichert.'); + View::redirect($this->regelZiel(), 'Regel gespeichert.'); } /** @param array $params */ @@ -955,7 +955,24 @@ class BankingController extends BaseController AbgleichRegel::loeschen((int)$params['id']); - View::redirect('/banking/regeln', 'Regel gelöscht.'); + View::redirect($this->regelZiel(), 'Regel gelöscht.'); + } + + /** + * Wohin es nach dem Speichern oder Löschen einer Regel geht: normalerweise + * die Regelliste. Der Regeldialog der Buchungsmaske trägt hier aber das + * Konto ein, von dem er kam – die Regel entstand aus einer Buchung, zu deren + * Kontoblatt soll es danach zurückgehen, ohne dass diese Ansicht selbst + * etwas von der Buchung wissen müsste. + */ + private function regelZiel(): string + { + $kontoId = (int)$this->post('rueckziel_konto_id'); + if ($kontoId > 0 && Konto::find($kontoId) !== null) { + return '/konten/' . $kontoId; + } + + return '/banking/regeln'; } /** diff --git a/src/Controllers/BuchungenController.php b/src/Controllers/BuchungenController.php index d2ce1ff..59d0cb7 100644 --- a/src/Controllers/BuchungenController.php +++ b/src/Controllers/BuchungenController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Finanzmanager\Controllers; +use Finanzmanager\Models\AbgleichRegel; use Finanzmanager\Models\Buchung; use Finanzmanager\Models\Depotbuchung; use Finanzmanager\Models\Einstellung; @@ -82,6 +83,9 @@ class BuchungenController extends BaseController // Die Abgleichspalte erscheint nur, wenn es ein Bargeld-Tool gibt – // sonst stünde dort in jeder Zeile derselbe graue Kreis. 'bargeld' => BargeldAbgleich::eingerichtet(), + // Kommt die Seite vom Toggle „Regel anlegen“ in der Buchungsmaske, + // steht hier die frisch erzeugte Regel zum Prüfen im Dialog. + 'neueRegel' => AbgleichRegel::find((int)$this->query('neue_regel')), ]); } @@ -114,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; } @@ -126,7 +134,10 @@ class BuchungenController extends BaseController $abgleich = BargeldAbgleich::pruefe($id); - View::redirect('/konten/' . $kontoId, 'Buchung angelegt.' . BargeldAbgleich::rueckmeldung($abgleich)); + View::redirect( + $this->kontoblattZiel($kontoId, $id), + 'Buchung angelegt.' . BargeldAbgleich::rueckmeldung($abgleich) + ); } /** @param array $params */ @@ -159,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; } @@ -175,7 +188,7 @@ class BuchungenController extends BaseController $abgleich = BargeldAbgleich::pruefe($id); View::redirect( - '/konten/' . $buchung['konto_id'], + $this->kontoblattZiel((int)$buchung['konto_id'], $id), 'Buchung gespeichert.' . BargeldAbgleich::rueckmeldung($abgleich) ); } @@ -557,23 +570,54 @@ class BuchungenController extends BaseController return '/konten/' . $kontoId . ($query === [] ? '' : '?' . http_build_query($query)); } - /** @param array|null $konto */ - private function maske(?array $konto, ?array $buchung): void + /** + * Ziel nach dem Speichern einer Buchung: das Kontoblatt – mit angehängter + * neuer Regel, wenn der Toggle „Regel anlegen“ gesetzt war. Ohne Muster + * (kein Empfänger, kein Buchungstext) entsteht keine Regel; das Ziel bleibt + * dann das gewöhnliche. + */ + private function kontoblattZiel(int $kontoId, int $buchungId): string + { + $ziel = '/konten/' . $kontoId; + + if (!isset($_POST['regel_anlegen'])) { + return $ziel; + } + + $regelId = AbgleichRegel::ausBuchung($buchungId); + return $regelId === null ? $ziel : $ziel . '?neue_regel=' . $regelId; + } + + /** + * @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/Models/AbgleichRegel.php b/src/Models/AbgleichRegel.php index cf6ea49..05d7bfa 100644 --- a/src/Models/AbgleichRegel.php +++ b/src/Models/AbgleichRegel.php @@ -100,6 +100,69 @@ class AbgleichRegel Database::execute('DELETE FROM abgleich_regeln WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]); } + /** + * Baut aus einer bestehenden Buchung eine neue Regel – der Ausgangspunkt für + * die Buchungsmaske, die eine Buchung direkt in eine Regel verwandeln kann. + * Das Muster kommt vom Empfänger, ersatzweise vom Buchungstext; ist beides + * leer, gibt es kein sinnvolles Muster und keine Regel entsteht. + * + * @return int|null Die id der neuen Regel, oder null ohne Muster + */ + public static function ausBuchung(int $buchungId): ?int + { + $buchung = Buchung::find($buchungId); + if ($buchung === null) { + throw new \RuntimeException('Die Buchung gibt es nicht.'); + } + + $empfaenger = trim((string)($buchung['empfaenger'] ?? '')); + $zweck = trim((string)($buchung['buchungstext'] ?? '')); + + if ($empfaenger !== '') { + $feld = 'empfaenger'; + } elseif ($zweck !== '') { + $feld = 'verwendungszweck'; + } else { + return null; + } + + $splitzeilen = array_values(array_filter( + Buchung::splits($buchungId), + static fn(array $zeile): bool => (int)($zeile['kategorie_id'] ?? 0) !== 0 + )); + + // Anteile am Buchungsbetrag statt roher Beträge: So zeigt der Dialog + // gleich sinnvolle Prozentwerte statt der Centbeträge der Buchung – + // splitzeilen() liest den Anteil als Bruchteil von 1. + $gesamt = array_sum(array_map( + static fn(array $zeile): float => abs((float)$zeile['betrag']), + $splitzeilen + )); + + $splits = []; + foreach ($splitzeilen as $zeile) { + $splits[] = [ + 'kategorie_id' => (int)$zeile['kategorie_id'], + 'klasse_id' => $zeile['klasse_id'], + 'anteil' => $gesamt > 0.0 ? round(abs((float)$zeile['betrag']) / $gesamt, 4) : 0, + 'notiz' => (string)($zeile['notiz'] ?? ''), + ]; + } + + return self::anlegen([ + 'name' => $empfaenger !== '' ? $empfaenger : $zweck, + 'feld' => $feld, + 'vergleich' => 'enthaelt', + 'muster' => $empfaenger !== '' ? $empfaenger : $zweck, + 'kategorie_id' => $buchung['kategorie_id'], + 'klasse_id' => $buchung['klasse_id'], + 'gegenkonto_id' => $buchung['gegenkonto_id'], + 'splits' => $splits === [] ? null : $splits, + 'prioritaet' => 100, + 'aktiv' => 1, + ]); + } + /** * Die erste Regel, die auf den Umsatz passt. * 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/banking/regelfelder.php b/src/Views/banking/regelfelder.php new file mode 100644 index 0000000..c98dc7e --- /dev/null +++ b/src/Views/banking/regelfelder.php @@ -0,0 +1,112 @@ + $regel Vorbelegung; leeres Array für eine neue Regel + * @var list> $kategorien + * @var list> $klassen + * @var list> $konten + * @var string $idPrefix Vorsatz für die Feld-ids, damit mehrere solcher + * Formulare auf einer Seite nicht kollidieren + */ + +$id = static fn(string $name): string => $idPrefix . $name; + +$betragsfeld = static function (?string $feld) use ($regel): string { + $wert = $regel[$feld] ?? null; + return $wert === null || $wert === '' ? '' : View::h(View::betrag((float)$wert, false)); +}; +?> + + + + + + + + + + + + +

+ Groß- und Kleinschreibung spielt keine Rolle. Bei regulärer Ausdruck ohne + Trennzeichen eingeben, also Miete|Nebenkosten statt /Miete/. +

+ + +
+ + +
+

+ Mit Vorzeichen: Ausgaben sind negativ, „von −60 bis −40“ trifft Lastschriften + zwischen 40 und 60 Euro. +

+ + + + + + + + + + +

Splitbuchung (statt einer einzelnen Kategorie)

+ + + + + + + diff --git a/src/Views/banking/regeln.php b/src/Views/banking/regeln.php index f8a4129..3c33a53 100644 --- a/src/Views/banking/regeln.php +++ b/src/Views/banking/regeln.php @@ -151,74 +151,8 @@ include __DIR__ . '/../layout/kopf.php';

Neue Regel

- - - - - - - - - - - -

- Groß- und Kleinschreibung spielt keine Rolle. Bei regulärer Ausdruck ohne - Trennzeichen eingeben, also Miete|Nebenkosten statt /Miete/. -

- - -
- - -
-

- Mit Vorzeichen: Ausgaben sind negativ, „von −60 bis −40“ trifft Lastschriften - zwischen 40 und 60 Euro. -

- - - - - - - - - - -

Splitbuchung (statt einer einzelnen Kategorie)

- - - - - - - + 1]; $idPrefix = 'neu_'; ?> +
diff --git a/src/Views/buchungen/formular.php b/src/Views/buchungen/formular.php index a5c71d2..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,22 +184,30 @@ include __DIR__ . '/../layout/kopf.php';
+ value="">
- +
+
+ +
+ - + @@ -186,18 +250,17 @@ include __DIR__ . '/../layout/kopf.php'; - + + name="split[][betrag]" value=""> - Summe der Zeilen / Restbetrag + Restbetrag 0,00 diff --git a/src/Views/buchungen/kontoblatt.php b/src/Views/buchungen/kontoblatt.php index fce03ad..2dc500a 100644 --- a/src/Views/buchungen/kontoblatt.php +++ b/src/Views/buchungen/kontoblatt.php @@ -16,6 +16,7 @@ use Finanzmanager\View; /** @var list> $konten */ /** @var array>> $splits */ /** @var bool $bargeld Ist ein Bargeld-Tool eingerichtet? */ +/** @var array|null $neueRegel Aus der Buchungsmaske erzeugte Regel, zum Prüfen im Dialog */ $kategorieNamen = []; foreach ($kategorien as $k) { @@ -506,7 +507,7 @@ include __DIR__ . '/../layout/kopf.php'; - Summe der Zeilen / Restbetrag + Restbetrag 0,00 @@ -584,4 +585,35 @@ include __DIR__ . '/../layout/kopf.php'; + + + +
+ + +

Regel anlegen

+

+ Aus dieser Buchung wurde diese Regel erzeugt. Vor dem Speichern lässt sie + sich noch anpassen; „Abbruch“ verwirft sie wieder. +

+ + + + +
+ + +
+
+
+ + + + diff --git a/tests/51_RegelAusBuchungTest.php b/tests/51_RegelAusBuchungTest.php new file mode 100644 index 0000000..fc231c0 --- /dev/null +++ b/tests/51_RegelAusBuchungTest.php @@ -0,0 +1,161 @@ +mandantAnlegen(); + $this->stammdaten(); + + $this->uebernimmtEmpfaengerKategorieUndKlasse(); + $this->weichtOhneEmpfaengerAufDenBuchungstextAus(); + $this->ohneEmpfaengerUndBuchungstextEntstehtKeineRegel(); + $this->splitbuchungBekommtVerhaeltnisgleicheAnteile(); + $this->unbekannteBuchungWirdAbgewiesen(); + } + + private function stammdaten(): void + { + $this->giro = Konto::anlegen([ + 'name' => 'Regeltest Giro', 'kontoart' => 'bank', 'anfangssaldo' => 1000, 'aktiv' => 1, + ]); + $this->strom = Kategorie::anlegen(['name' => 'Regeltest Strom', 'art' => 'ausgabe']); + $this->versicherung = Kategorie::anlegen(['name' => 'Regeltest Versicherung', 'art' => 'ausgabe']); + $this->klasse = Klasse::anlegen('Regeltest Privat'); + } + + private function uebernimmtEmpfaengerKategorieUndKlasse(): void + { + $buchungId = Buchung::anlegen([ + 'konto_id' => $this->giro, + 'datum' => '2026-05-01', + 'betrag' => -42.50, + 'empfaenger' => 'Stadtwerke Strom', + 'buchungstext' => 'Abschlag Mai', + 'kategorie_id' => $this->strom, + 'klasse_id' => $this->klasse, + ]); + + $regelId = AbgleichRegel::ausBuchung($buchungId); + $this->assertNotEmpty($regelId, 'Die Regel wird angelegt'); + + $regel = AbgleichRegel::find((int)$regelId); + $this->assertNotEmpty($regel, 'Sie lässt sich wiederfinden'); + $this->assertEquals('empfaenger', $regel['feld'], 'Geprüft wird der Empfänger'); + $this->assertEquals('Stadtwerke Strom', $regel['muster'], 'Das Muster ist der Empfänger'); + $this->assertEquals('enthaelt', $regel['vergleich'], 'Der Vergleich ist „enthält“'); + $this->assertEquals($this->strom, (int)$regel['kategorie_id'], 'Die Kategorie wird übernommen'); + $this->assertEquals($this->klasse, (int)$regel['klasse_id'], 'Die Klasse wird übernommen'); + $this->assertEquals(1, (int)$regel['aktiv'], 'Die Regel ist gleich aktiv'); + + // Und sie taucht in der Liste der bestehenden Regeln auf – ergänzt, nicht + // ersetzt. + $gefunden = false; + foreach (AbgleichRegel::alle() as $eintrag) { + $gefunden = $gefunden || (int)$eintrag['id'] === (int)$regelId; + } + $this->assert($gefunden, 'Die neue Regel steht in der Liste der bestehenden Regeln'); + + // Und sie trifft tatsächlich auf einen passenden Umsatz. + $this->assert( + AbgleichRegel::passt($regel, ['betrag' => -42.50, 'empfaenger' => 'Stadtwerke Strom AG']), + 'Die Regel trifft einen ähnlichen Umsatz' + ); + } + + private function weichtOhneEmpfaengerAufDenBuchungstextAus(): void + { + $buchungId = Buchung::anlegen([ + 'konto_id' => $this->giro, + 'datum' => '2026-05-02', + 'betrag' => -15.00, + 'buchungstext' => 'Parkgebühr Innenstadt', + ]); + + $regelId = AbgleichRegel::ausBuchung($buchungId); + $regel = AbgleichRegel::find((int)$regelId); + + $this->assertEquals('verwendungszweck', $regel['feld'], 'Ohne Empfänger zählt der Buchungstext'); + $this->assertEquals('Parkgebühr Innenstadt', $regel['muster'], 'Das Muster ist der Buchungstext'); + } + + private function ohneEmpfaengerUndBuchungstextEntstehtKeineRegel(): void + { + $buchungId = Buchung::anlegen([ + 'konto_id' => $this->giro, + 'datum' => '2026-05-03', + 'betrag' => -5.00, + ]); + + $this->assertNull(AbgleichRegel::ausBuchung($buchungId), 'Ohne Muster entsteht keine Regel'); + } + + private function splitbuchungBekommtVerhaeltnisgleicheAnteile(): void + { + $buchungId = Buchung::anlegen( + [ + 'konto_id' => $this->giro, + 'datum' => '2026-05-04', + 'betrag' => -80.00, + 'empfaenger' => 'Sammelrechnung Versicherung', + ], + [ + ['kategorie_id' => $this->versicherung, 'betrag' => -50.00, 'notiz' => 'Kfz'], + ['kategorie_id' => $this->versicherung, 'betrag' => -30.00, 'notiz' => 'Haftpflicht'], + ] + ); + + $regelId = AbgleichRegel::ausBuchung($buchungId); + $regel = AbgleichRegel::find((int)$regelId); + + $zeilen = AbgleichRegel::splitzeilen($regel); + $this->assertEquals(2, count($zeilen), 'Beide Splitzeilen kommen in der Regel an'); + $this->assertEquals(62.5, $zeilen[0]['prozent'], 'Anteil der ersten Zeile im selben Verhältnis wie die Buchung'); + $this->assertEquals(37.5, $zeilen[1]['prozent'], 'Anteil der zweiten Zeile ebenso'); + + // anwenden() rechnet die Anteile auf jeden Buchungsbetrag um – hier zur + // Probe auf einen doppelt so hohen Umsatz. + $angewendet = AbgleichRegel::anwenden($regel, ['betrag' => -160.00]); + $this->assertEquals(-100.0, $angewendet['splits'][0]['betrag'], 'Erste Zeile bleibt im Verhältnis 62,5 %'); + $this->assertEquals(-60.0, $angewendet['splits'][1]['betrag'], 'Zweite Zeile bleibt im Verhältnis 37,5 %'); + } + + private function unbekannteBuchungWirdAbgewiesen(): void + { + $this->assert( + $this->wirftFehler(fn() => AbgleichRegel::ausBuchung(PHP_INT_MAX)), + 'Eine nicht vorhandene Buchung wird abgewiesen' + ); + } + + /** Hilfsmittel: Wirft der Aufruf eine Ausnahme? */ + private function wirftFehler(callable $aufruf): bool + { + try { + $aufruf(); + } catch (\Throwable) { + return true; + } + + return false; + } +} diff --git a/tests/e2e/specs/buchungen.spec.js b/tests/e2e/specs/buchungen.spec.js index 86e5f3e..cb99409 100644 --- a/tests/e2e/specs/buchungen.spec.js +++ b/tests/e2e/specs/buchungen.spec.js @@ -286,16 +286,25 @@ test.describe('Buchungen', () => { await expect(dialog).toBeVisible() await expect(dialog).toContainText('-90,00') + const dialogSpeichern = dialog.locator('button[type="submit"].primaer') + await dialog.locator('select[name="split[0][zuordnung]"]').selectOption({ label: KATEGORIE }) await dialog.locator('input[name="split[0][betrag]"]').fill('-50') + + // Solange der Restbetrag offen ist, zeigt die Summenzeile ihn an und + // „Splitbuchung speichern“ bleibt gesperrt. + await expect(page.locator('#splitdialogsumme')).toHaveText('-40,00') + await expect(dialogSpeichern).toBeDisabled() + await dialog.locator('select[name="split[1][zuordnung]"]').selectOption({ label: `[${SPAR}]` }) await dialog.locator('input[name="split[1][betrag]"]').fill('-40') // Der Dialog rechnet den Restbetrag mit; er muss aufgehen. - await expect(page.locator('#splitdialogsumme')).not.toContainText('Rest') + await expect(page.locator('#splitdialogsumme')).toHaveText('0,00') + await expect(dialogSpeichern).toBeEnabled() // Der Dialog hat zwei Submit-Knöpfe: Speichern und Löschen. - await dialog.locator('button[type="submit"].primaer').click() + await dialogSpeichern.click() await expect(page.locator('.meldung-erfolg').last()).toContainText('Splitbuchung') await expect(page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` }).first()) @@ -372,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') @@ -494,21 +550,35 @@ test.describe('Buchungen', () => { .not.toHaveAttribute('title') }) - test('Splitzeilen, die nicht aufgehen, werden abgewiesen', async ({ page }) => { + test('Splitzeilen, die nicht aufgehen, sperren das Speichern', async ({ page }) => { await page.goto(`/konten/${giroId}/buchung`) + const speichern = page.locator('.buchungsmaske button[type="submit"]') await page.fill('#datum', '2026-05-03') await page.fill('#betrag', '100') await page.fill('#empfaenger', `Krumm ${MARKE}`) + + // Ohne jede Splitzeile bleibt der Button an – die Buchung ist noch keine + // Splitbuchung, auch wenn die Summenzeile schon den vollen Betrag zeigt. + await expect(page.locator('#splitsumme')).toHaveText('-100,00') + await expect(speichern).toBeEnabled() + await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE }) await page.fill('input[name="split[0][betrag]"]', '-60') - await expect(page.locator('#splitsumme')).toContainText('Rest') + // Die Summenzeile zeigt den noch offenen Restbetrag, nicht die Summe der + // Zeilen – und „Speichern“ ist gesperrt, solange er nicht Null ist. + await expect(page.locator('#splitsumme')).toHaveText('-40,00') + await expect(speichern).toBeDisabled() - await page.click('.buchungsmaske button[type="submit"]') + await page.selectOption('select[name="split[1][zuordnung]"]', { label: KATEGORIE }) + await page.fill('input[name="split[1][betrag]"]', '-40') - await expect(page.locator('.meldung-fehler')).toBeVisible() - await expect(page.locator('.meldung-fehler')).toContainText('Splitzeilen') + await expect(page.locator('#splitsumme')).toHaveText('0,00') + await expect(speichern).toBeEnabled() + + await speichern.click() + await expect(page.locator('.meldung-erfolg')).toBeVisible() }) test('Kontoblatt lässt sich durchsuchen', async ({ page }) => { diff --git a/tests/e2e/specs/regelausbuchung.spec.js b/tests/e2e/specs/regelausbuchung.spec.js new file mode 100644 index 0000000..e5d1a1b --- /dev/null +++ b/tests/e2e/specs/regelausbuchung.spec.js @@ -0,0 +1,138 @@ +// @ts-check +const { test, expect } = require('@playwright/test') + +const BENUTZER = process.env.E2E_USER || 'e2e' +const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort' + +const MARKE = Date.now().toString().slice(-6) +const GIRO = `E2E RegelBuchung ${MARKE}` +const KATEGORIE = `E2E RegelBuchung Kategorie ${MARKE}` + +async function anmelden(page) { + await page.goto('/login') + await page.fill('#benutzername', BENUTZER) + await page.fill('#passwort', PASSWORT) + await page.click('button[type="submit"]') + await expect(page).toHaveURL(/\/$/) +} + +// Fix #94: Aus der Buchungsmaske heraus lässt sich mit dem Toggle +// „Regel anlegen“ direkt eine Regel aus der gerade gespeicherten Buchung +// erzeugen; ein Dialog zeigt sie danach zum Prüfen. +test.describe('Regel aus einer Buchung anlegen', () => { + let giroId = null + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + await anmelden(page) + + await page.goto('/konten/neu') + await page.fill('#name', GIRO) + await page.fill('#anfangssaldo', '1.000,00') + await page.click('.formular button[type="submit"]') + await expect(page).toHaveURL(/\/konten\/\d+\/bearbeiten/) + giroId = page.url().match(/\/konten\/(\d+)\//)[1] + + await page.goto('/verwaltung/kategorien/neu') + await page.fill('#name', KATEGORIE) + await page.selectOption('#art', 'ausgabe') + await page.click('.formular button[type="submit"]') + + await page.close() + }) + + test.beforeEach(async ({ page }) => { + await anmelden(page) + }) + + test('Toggle ist standardmäßig aus – ohne ihn ändert sich nichts am bisherigen Ablauf', async ({ page }) => { + await page.goto(`/konten/${giroId}/buchung`) + await expect(page.locator('input[name="regel_anlegen"]')).not.toBeChecked() + + await page.fill('#datum', '2026-05-01') + await page.fill('#betrag', '12,00') + await page.fill('#empfaenger', `Ohne Regel ${MARKE}`) + await page.click('.buchungsmaske button[type="submit"]') + + await expect(page.locator('.meldung-erfolg')).toBeVisible() + await expect(page.locator('#regeldialog')).toHaveCount(0) + }) + + test('Regel anlegen öffnet den Dialog mit den Buchungsdaten; Abbruch verwirft die Regel wieder', async ({ page }) => { + const empfaenger = `Abbruchregel ${MARKE}` + + await page.goto(`/konten/${giroId}/buchung`) + await page.fill('#datum', '2026-05-02') + await page.fill('#betrag', '20,00') + await page.fill('#empfaenger', empfaenger) + await page.selectOption('#zuordnung', { label: KATEGORIE }) + await page.check('input[name="regel_anlegen"]') + await page.click('.buchungsmaske button[type="submit"]') + + await expect(page.locator('.meldung-erfolg')).toBeVisible() + + const dialog = page.locator('#regeldialog') + await expect(dialog).toBeVisible() + await expect(dialog.locator('input[name="name"]')).toHaveValue(empfaenger) + await expect(dialog.locator('input[name="muster"]')).toHaveValue(empfaenger) + await expect(dialog.locator('select[name="kategorie_id"]')).toHaveValue(/^\d+$/) + + await dialog.getByRole('button', { name: 'Abbruch' }).click() + + await expect(page).toHaveURL(new RegExp(`/konten/${giroId}$`)) + await expect(page.locator('#regeldialog')).toHaveCount(0) + + // Es darf keine neu angelegte Regel zurückbleiben. Der Name steht im + // Wert eines Eingabefelds, nicht im Text der Zeile – toContainText + // träfe ihn also gar nicht erst. + await page.goto('/banking/regeln') + await expect(page.locator(`input[name="name"][value="${empfaenger}"]`)).toHaveCount(0) + }) + + test('Speichern im Dialog übernimmt Änderungen und behält die Regel', async ({ page }) => { + const empfaenger = `Speicherregel ${MARKE}` + const angepasstesMuster = `Speicherregel ${MARKE} angepasst` + + await page.goto(`/konten/${giroId}/buchung`) + await page.fill('#datum', '2026-05-03') + await page.fill('#betrag', '30,00') + await page.fill('#empfaenger', empfaenger) + await page.selectOption('#zuordnung', { label: KATEGORIE }) + await page.check('input[name="regel_anlegen"]') + await page.click('.buchungsmaske button[type="submit"]') + + const dialog = page.locator('#regeldialog') + await expect(dialog).toBeVisible() + + await dialog.locator('input[name="muster"]').fill(angepasstesMuster) + await dialog.getByRole('button', { name: 'Speichern' }).click() + + await expect(page).toHaveURL(new RegExp(`/konten/${giroId}$`)) + await expect(page.locator('#regeldialog')).toHaveCount(0) + + // Der Name blieb unverändert; über ihn findet sich die Zeile wieder – + // das Muster steckt wie der Name im Wert eines Eingabefelds. + await page.goto('/banking/regeln') + const zeile = page.locator('tr').filter({ has: page.locator(`input[name="name"][value="${empfaenger}"]`) }) + await expect(zeile).toBeVisible() + await expect(zeile.locator('input[name="muster"]')).toHaveValue(angepasstesMuster) + }) + + test('Escape im Dialog verwirft die Regel wie ein Klick auf Abbruch', async ({ page }) => { + const empfaenger = `Escaperegel ${MARKE}` + + await page.goto(`/konten/${giroId}/buchung`) + await page.fill('#datum', '2026-05-04') + await page.fill('#betrag', '40,00') + await page.fill('#empfaenger', empfaenger) + await page.check('input[name="regel_anlegen"]') + await page.click('.buchungsmaske button[type="submit"]') + + await expect(page.locator('#regeldialog')).toBeVisible() + await page.keyboard.press('Escape') + + await expect(page).toHaveURL(new RegExp(`/konten/${giroId}$`)) + await page.goto('/banking/regeln') + await expect(page.locator(`input[name="name"][value="${empfaenger}"]`)).toHaveCount(0) + }) +})