Merge pull request 'Fix #94: Regel direkt aus einer Buchung anlegen' (#97) from fds2610-bot/Finanzmanager:main into main

Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/97
This commit is contained in:
fds2610 2026-09-30 06:28:18 +02:00
commit 19c64dc594
19 changed files with 868 additions and 109 deletions

View file

@ -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 - Regeln: Die Splittabellen zeigen die Spalte „Anteil“ ohne Prozentzeichen und darunter die
laufend berechnete Summe der Anteile 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 ### 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,54 @@
# Ä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)
- 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 `<dialog>` 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) ## Umbuchungen: beide Seiten finden zueinander (29.09.2026)
- Eine Umbuchung erscheint auf zwei Kontoauszügen, wurde aber nicht als eine Buchung erkannt: - Eine Umbuchung erscheint auf zwei Kontoauszügen, wurde aber nicht als eine Buchung erkannt:

View file

@ -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. 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 ## Protokoll und Diagnose
Jeder Abruf schreibt eine Zeile nach `bank_abrufe` mit Status, Zahlen und Verlauf – ohne das Jeder Abruf schreibt eine Zeile nach `bank_abrufe` mit Status, Zahlen und Verlauf – ohne das

View file

@ -1320,11 +1320,16 @@ table.splittabelle input.rechts { text-align: right; }
.formatname { font-weight: 600; font-size: 1.05rem; } .formatname { font-weight: 600; font-size: 1.05rem; }
/* Der Wertpapierdialog trägt ein Formular und wird dadurch länger als der /* 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 { max-height: 88vh; overflow-y: auto; }
.wertpapierdialog .formular { max-width: none; } .wertpapierdialog .formular { max-width: none; }
.wertpapierdialog h2 { margin: 0 0 .3rem; font-size: 1.05rem; } .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. */ /* Die Kostentabelle: Betrag rechts, Kategorie als stiller Hinweis daneben. */
table.kostentabelle { margin-top: .6rem; } table.kostentabelle { margin-top: .6rem; }
table.kostentabelle td { padding: .25rem .4rem; } table.kostentabelle td { padding: .25rem .4rem; }

View file

@ -31,7 +31,9 @@
} }
/* Die Summe der Splitzeilen unter `felder` gegen `ziel` – das Ergebnis geht /* 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) { function splitsumme(felder, ziel) {
let summe = 0; let summe = 0;
let belegt = false; let belegt = false;
@ -50,7 +52,7 @@
belegt: belegt, belegt: belegt,
summe: summe, summe: summe,
rest: rest, rest: rest,
text: belegt ? formatiere(summe) + (rest === 0 ? '' : ' (Rest ' + formatiere(rest) + ')') : '0,00' text: formatiere(rest)
}; };
} }

View file

@ -1,6 +1,10 @@
/* Buchungsmaske: Restbetrag der Splitzeilen mitrechnen und direkte Zuordnung /* Buchungsmaske: Restbetrag der Splitzeilen mitrechnen und direkte Zuordnung
* und Splitbuchung gegeneinander sperren. * 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 * Nur Komfort – die Maske kommt fertig vom Server. Ohne JavaScript bleibt alles
* bedienbar: Die Splitsumme prüft ohnehin das Model. * bedienbar: Die Splitsumme prüft ohnehin das Model.
* *
@ -26,6 +30,7 @@
const summenfeld = document.getElementById('splitsumme'); const summenfeld = document.getElementById('splitsumme');
const betragsfeld = document.getElementById('betrag'); const betragsfeld = document.getElementById('betrag');
const speichernknopf = maske.querySelector('button[type="submit"]');
function rechneSumme() { function rechneSumme() {
if (!summenfeld) { if (!summenfeld) {
@ -46,6 +51,13 @@
summenfeld.textContent = stand.text; summenfeld.textContent = stand.text;
summenfeld.classList.toggle('negativ', stand.belegt && stand.rest !== 0); 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'] const direktfelder = ['zuordnung', 'klasse_id']

View file

@ -335,6 +335,7 @@
const zeilenkasten = document.getElementById('splitdialogzeilen'); const zeilenkasten = document.getElementById('splitdialogzeilen');
const summenfeld = document.getElementById('splitdialogsumme'); const summenfeld = document.getElementById('splitdialogsumme');
const loeschknopf = document.getElementById('splitloeschen'); const loeschknopf = document.getElementById('splitloeschen');
const speichernknopf = dialog.querySelector('button[type="submit"].primaer');
const vorlage = zeilenkasten.innerHTML; const vorlage = zeilenkasten.innerHTML;
let buchungsbetrag = 0; let buchungsbetrag = 0;
@ -342,10 +343,16 @@
return Array.from(zeilenkasten.querySelectorAll('.splitbetrag')); 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() { function rechneSumme() {
const stand = window.FmBetrag.splitsumme(felder(), buchungsbetrag); const stand = window.FmBetrag.splitsumme(felder(), buchungsbetrag);
summenfeld.textContent = stand.text; summenfeld.textContent = stand.text;
summenfeld.classList.toggle('negativ', stand.belegt && stand.rest !== 0); 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. /* Eine weitere Zeile: die letzte kopieren, leeren und neu durchnummerieren.

View file

@ -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);
});
})();

View file

@ -941,11 +941,11 @@ class BankingController extends BaseController
try { try {
AbgleichRegel::speichern((int)$params['id'], $this->regeldaten()); AbgleichRegel::speichern((int)$params['id'], $this->regeldaten());
} catch (\Throwable $e) { } catch (\Throwable $e) {
View::redirect('/banking/regeln', $e->getMessage(), 'fehler'); View::redirect($this->regelZiel(), $e->getMessage(), 'fehler');
return; return;
} }
View::redirect('/banking/regeln', 'Regel gespeichert.'); View::redirect($this->regelZiel(), 'Regel gespeichert.');
} }
/** @param array<string,string> $params */ /** @param array<string,string> $params */
@ -955,7 +955,24 @@ class BankingController extends BaseController
AbgleichRegel::loeschen((int)$params['id']); 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';
} }
/** /**

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Finanzmanager\Controllers; namespace Finanzmanager\Controllers;
use Finanzmanager\Models\AbgleichRegel;
use Finanzmanager\Models\Buchung; use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Depotbuchung; use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Einstellung; use Finanzmanager\Models\Einstellung;
@ -82,6 +83,9 @@ class BuchungenController extends BaseController
// Die Abgleichspalte erscheint nur, wenn es ein Bargeld-Tool gibt – // Die Abgleichspalte erscheint nur, wenn es ein Bargeld-Tool gibt –
// sonst stünde dort in jeder Zeile derselbe graue Kreis. // sonst stünde dort in jeder Zeile derselbe graue Kreis.
'bargeld' => BargeldAbgleich::eingerichtet(), '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); [$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;
} }
@ -126,7 +134,10 @@ class BuchungenController extends BaseController
$abgleich = BargeldAbgleich::pruefe($id); $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<string,string> $params */ /** @param array<string,string> $params */
@ -159,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;
} }
@ -175,7 +188,7 @@ class BuchungenController extends BaseController
$abgleich = BargeldAbgleich::pruefe($id); $abgleich = BargeldAbgleich::pruefe($id);
View::redirect( View::redirect(
'/konten/' . $buchung['konto_id'], $this->kontoblattZiel((int)$buchung['konto_id'], $id),
'Buchung gespeichert.' . BargeldAbgleich::rueckmeldung($abgleich) 'Buchung gespeichert.' . BargeldAbgleich::rueckmeldung($abgleich)
); );
} }
@ -557,23 +570,54 @@ class BuchungenController extends BaseController
return '/konten/' . $kontoId . ($query === [] ? '' : '?' . http_build_query($query)); return '/konten/' . $kontoId . ($query === [] ? '' : '?' . http_build_query($query));
} }
/** @param array<string,mixed>|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<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

@ -100,6 +100,69 @@ class AbgleichRegel
Database::execute('DELETE FROM abgleich_regeln WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]); 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. * Die erste Regel, die auf den Umsatz passt.
* *

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

@ -0,0 +1,112 @@
<?php
use Finanzmanager\Models\AbgleichRegel;
use Finanzmanager\View;
/**
* Die Eingabefelder einer Regel – einmal für die neue Regel in regeln.php,
* einmal für den Regeldialog, den die Buchungsmaske nach dem Speichern öffnet.
* Erwartet vom Aufrufer:
*
* @var array<string,mixed> $regel Vorbelegung; leeres Array für eine neue Regel
* @var list<array<string,mixed>> $kategorien
* @var list<array<string,mixed>> $klassen
* @var list<array<string,mixed>> $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));
};
?>
<label for="<?= $id('name') ?>">Name</label>
<input type="text" id="<?= $id('name') ?>" name="name" required placeholder="z. B. Stadtwerke Strom"
value="<?= View::h((string)($regel['name'] ?? '')) ?>">
<label for="<?= $id('feld') ?>">Prüfen auf</label>
<select id="<?= $id('feld') ?>" name="feld">
<?php foreach (AbgleichRegel::FELDER as $k => $v): ?>
<option value="<?= View::h($k) ?>" <?= ($regel['feld'] ?? 'beides') === $k ? 'selected' : '' ?>>
<?= View::h($v) ?>
</option>
<?php endforeach; ?>
</select>
<label for="<?= $id('vergleich') ?>">Vergleich</label>
<select id="<?= $id('vergleich') ?>" name="vergleich">
<?php foreach (AbgleichRegel::VERGLEICHE as $k => $v): ?>
<option value="<?= View::h($k) ?>" <?= ($regel['vergleich'] ?? 'enthaelt') === $k ? 'selected' : '' ?>>
<?= View::h($v) ?>
</option>
<?php endforeach; ?>
</select>
<label for="<?= $id('muster') ?>">Muster</label>
<input type="text" id="<?= $id('muster') ?>" name="muster" required
value="<?= View::h((string)($regel['muster'] ?? '')) ?>">
<p class="feldhinweis">
Groß- und Kleinschreibung spielt keine Rolle. Bei <em>regulärer Ausdruck</em> ohne
Trennzeichen eingeben, also <code>Miete|Nebenkosten</code> statt <code>/Miete/</code>.
</p>
<label for="<?= $id('betrag_von') ?>">Betrag von / bis <span class="optional">(optional)</span></label>
<div class="feldpaar">
<input type="text" id="<?= $id('betrag_von') ?>" name="betrag_von" inputmode="decimal"
placeholder="z. B. -60" value="<?= $betragsfeld('betrag_von') ?>">
<input type="text" name="betrag_bis" inputmode="decimal"
placeholder="z. B. -40" value="<?= $betragsfeld('betrag_bis') ?>">
</div>
<p class="feldhinweis">
Mit Vorzeichen: Ausgaben sind negativ, „von −60 bis −40“ trifft Lastschriften
zwischen 40 und 60 Euro.
</p>
<label for="<?= $id('kategorie_id') ?>">Kategorie</label>
<select id="<?= $id('kategorie_id') ?>" name="kategorie_id">
<option value="">– keine –</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"
<?= (int)($regel['kategorie_id'] ?? 0) === (int)$kategorie['id'] ? 'selected' : '' ?>>
<?= View::h($kategorie['pfad']) ?>
</option>
<?php endforeach; ?>
</select>
<label for="<?= $id('klasse_id') ?>">Klasse</label>
<select id="<?= $id('klasse_id') ?>" name="klasse_id">
<option value="">– keine –</option>
<?php foreach ($klassen as $klasse): ?>
<option value="<?= (int)$klasse['id'] ?>"
<?= (int)($regel['klasse_id'] ?? 0) === (int)$klasse['id'] ? 'selected' : '' ?>>
<?= View::h($klasse['name']) ?>
</option>
<?php endforeach; ?>
</select>
<label for="<?= $id('gegenkonto_id') ?>">Gegenkonto <span class="optional">(macht daraus eine Umbuchung)</span></label>
<select id="<?= $id('gegenkonto_id') ?>" name="gegenkonto_id">
<option value="">– keines –</option>
<?php foreach ($konten as $konto): ?>
<option value="<?= (int)$konto['id'] ?>"
<?= (int)($regel['gegenkonto_id'] ?? 0) === (int)$konto['id'] ? 'selected' : '' ?>>
<?= View::h($konto['name']) ?>
</option>
<?php endforeach; ?>
</select>
<h4>Splitbuchung <span class="optional">(statt einer einzelnen Kategorie)</span></h4>
<?php $splitZeilen = AbgleichRegel::splitzeilen($regel); $splitForm = ''; ?>
<?php include __DIR__ . '/regelsplits.php'; ?>
<label for="<?= $id('prioritaet') ?>">Priorität</label>
<input type="number" id="<?= $id('prioritaet') ?>" name="prioritaet" value="<?= (int)($regel['prioritaet'] ?? 100) ?>">
<label class="ankreuzfeld">
<input type="checkbox" name="aktiv" value="1" <?= (int)($regel['aktiv'] ?? 1) === 1 ? 'checked' : '' ?>>
aktiv
</label>

View file

@ -151,74 +151,8 @@ include __DIR__ . '/../layout/kopf.php';
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>"> <input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<h3>Neue Regel</h3> <h3>Neue Regel</h3>
<label for="name">Name</label> <?php $regel = ['aktiv' => 1]; $idPrefix = 'neu_'; ?>
<input type="text" id="name" name="name" required placeholder="z. B. Stadtwerke Strom"> <?php include __DIR__ . '/regelfelder.php'; ?>
<label for="feld">Prüfen auf</label>
<select id="feld" name="feld">
<?php foreach (AbgleichRegel::FELDER as $k => $v): ?>
<option value="<?= View::h($k) ?>"><?= View::h($v) ?></option>
<?php endforeach; ?>
</select>
<label for="vergleich">Vergleich</label>
<select id="vergleich" name="vergleich">
<?php foreach (AbgleichRegel::VERGLEICHE as $k => $v): ?>
<option value="<?= View::h($k) ?>"><?= View::h($v) ?></option>
<?php endforeach; ?>
</select>
<label for="muster">Muster</label>
<input type="text" id="muster" name="muster" required>
<p class="feldhinweis">
Groß- und Kleinschreibung spielt keine Rolle. Bei <em>regulärer Ausdruck</em> ohne
Trennzeichen eingeben, also <code>Miete|Nebenkosten</code> statt <code>/Miete/</code>.
</p>
<label for="betrag_von">Betrag von / bis <span class="optional">(optional)</span></label>
<div class="feldpaar">
<input type="text" id="betrag_von" name="betrag_von" inputmode="decimal" placeholder="z. B. -60">
<input type="text" name="betrag_bis" inputmode="decimal" placeholder="z. B. -40">
</div>
<p class="feldhinweis">
Mit Vorzeichen: Ausgaben sind negativ, „von −60 bis −40“ trifft Lastschriften
zwischen 40 und 60 Euro.
</p>
<label for="neu_kategorie">Kategorie</label>
<select id="neu_kategorie" name="kategorie_id">
<option value="">– keine –</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"><?= View::h($kategorie['pfad']) ?></option>
<?php endforeach; ?>
</select>
<label for="neu_klasse">Klasse</label>
<select id="neu_klasse" name="klasse_id">
<option value="">– keine –</option>
<?php foreach ($klassen as $klasse): ?>
<option value="<?= (int)$klasse['id'] ?>"><?= View::h($klasse['name']) ?></option>
<?php endforeach; ?>
</select>
<label for="neu_gegenkonto">Gegenkonto <span class="optional">(macht daraus eine Umbuchung)</span></label>
<select id="neu_gegenkonto" name="gegenkonto_id">
<option value="">– keines –</option>
<?php foreach ($konten as $konto): ?>
<option value="<?= (int)$konto['id'] ?>"><?= View::h($konto['name']) ?></option>
<?php endforeach; ?>
</select>
<h4>Splitbuchung <span class="optional">(statt einer einzelnen Kategorie)</span></h4>
<?php $splitZeilen = []; $splitForm = ''; ?>
<?php include __DIR__ . '/regelsplits.php'; ?>
<label for="prioritaet">Priorität</label>
<input type="number" id="prioritaet" name="prioritaet" value="100">
<label class="ankreuzfeld">
<input type="checkbox" name="aktiv" value="1" checked> aktiv
</label>
<div class="knopfleiste"> <div class="knopfleiste">
<button type="submit" class="knopf primaer">Regel anlegen</button> <button type="submit" class="knopf primaer">Regel anlegen</button>

View file

@ -5,6 +5,8 @@ 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 */
@ -13,6 +15,7 @@ use Finanzmanager\View;
$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,22 +184,30 @@ 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>
<div class="feld ganz">
<label class="ankreuzfeld"
title="Erzeugt beim Speichern zusätzlich eine Regel für den automatischen Abgleich aus dieser Buchung. Der nächste Bildschirm zeigt die neue Regel zum Prüfen an.">
<input type="checkbox" name="regel_anlegen" value="1">
Regel anlegen
</label>
</div>
<?php /* Direkte Zuordnung und Splitzeilen schließen sich aus: buchungsmaske.js <?php /* Direkte Zuordnung und Splitzeilen schließen sich aus: buchungsmaske.js
sperrt jeweils die andere Seite, sobald eine davon belegt ist. Der sperrt jeweils die andere Seite, sobald eine davon belegt ist. Der
Knopf rechts leert alle Splitzeilen und gibt Kategorie und Klasse Knopf rechts leert alle Splitzeilen und gibt Kategorie und Klasse
@ -171,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>
@ -186,18 +250,17 @@ 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; ?>
</tbody> </tbody>
<tfoot> <tfoot>
<tr> <tr>
<td colspan="3" class="rechts">Summe der Zeilen / Restbetrag</td> <td colspan="3" class="rechts">Restbetrag</td>
<td class="rechts"><output id="splitsumme" class="betrag">0,00</output></td> <td class="rechts"><output id="splitsumme" class="betrag">0,00</output></td>
</tr> </tr>
</tfoot> </tfoot>

View file

@ -16,6 +16,7 @@ use Finanzmanager\View;
/** @var list<array<string,mixed>> $konten */ /** @var list<array<string,mixed>> $konten */
/** @var array<int, list<array<string,mixed>>> $splits */ /** @var array<int, list<array<string,mixed>>> $splits */
/** @var bool $bargeld Ist ein Bargeld-Tool eingerichtet? */ /** @var bool $bargeld Ist ein Bargeld-Tool eingerichtet? */
/** @var array<string,mixed>|null $neueRegel Aus der Buchungsmaske erzeugte Regel, zum Prüfen im Dialog */
$kategorieNamen = []; $kategorieNamen = [];
foreach ($kategorien as $k) { foreach ($kategorien as $k) {
@ -506,7 +507,7 @@ include __DIR__ . '/../layout/kopf.php';
</tbody> </tbody>
<tfoot> <tfoot>
<tr> <tr>
<td colspan="3" class="rechts">Summe der Zeilen / Restbetrag</td> <td colspan="3" class="rechts">Restbetrag</td>
<td class="rechts"><output id="splitdialogsumme" class="betrag">0,00</output></td> <td class="rechts"><output id="splitdialogsumme" class="betrag">0,00</output></td>
</tr> </tr>
</tfoot> </tfoot>
@ -584,4 +585,35 @@ include __DIR__ . '/../layout/kopf.php';
<script src="/static/kontoblatt.js" defer></script> <script src="/static/kontoblatt.js" defer></script>
<?php endif; ?> <?php endif; ?>
<?php if ($neueRegel !== null): ?>
<?php /* Öffnet sich automatisch (regeldialog.js) nach dem Speichern einer
Buchung mit gesetztem Toggle „Regel anlegen“. „Abbruch“ entfernt die
gerade erst angelegte Regel wieder – über dasselbe Löschformular wie
die Regelliste, nur mit Rücksprung hierher statt dorthin. */ ?>
<dialog id="regeldialog" class="splitdialog regeldialog">
<form method="post" action="/banking/regeln/<?= (int)$neueRegel['id'] ?>" id="regelformular" class="formular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="rueckziel_konto_id" value="<?= (int)$konto['id'] ?>">
<h2>Regel anlegen</h2>
<p class="feldhinweis">
Aus dieser Buchung wurde diese Regel erzeugt. Vor dem Speichern lässt sie
sich noch anpassen; „Abbruch“ verwirft sie wieder.
</p>
<?php $regel = $neueRegel; $idPrefix = 'regeldialog_'; ?>
<?php include __DIR__ . '/../banking/regelfelder.php'; ?>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Speichern</button>
<button type="submit" class="knopf" id="regelabbrechen"
formaction="/banking/regeln/<?= (int)$neueRegel['id'] ?>/loeschen" formnovalidate>
Abbruch
</button>
</div>
</form>
</dialog>
<script src="/static/regeldialog.js" defer></script>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?> <?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\AbgleichRegel;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
/**
* Fix #94: Regel direkt aus einer Buchung anlegen – die Buchungsmaske
* erzeugt dafür `AbgleichRegel::ausBuchung()`.
*/
class RegelAusBuchungTest extends TestCase
{
protected string $testName = 'Test 51: Regel aus einer Buchung anlegen';
private int $giro = 0;
private int $strom = 0;
private int $versicherung = 0;
private int $klasse = 0;
public function run(): void
{
$this->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;
}
}

View file

@ -286,16 +286,25 @@ test.describe('Buchungen', () => {
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible()
await expect(dialog).toContainText('-90,00') 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('select[name="split[0][zuordnung]"]').selectOption({ label: KATEGORIE })
await dialog.locator('input[name="split[0][betrag]"]').fill('-50') 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('select[name="split[1][zuordnung]"]').selectOption({ label: `[${SPAR}]` })
await dialog.locator('input[name="split[1][betrag]"]').fill('-40') await dialog.locator('input[name="split[1][betrag]"]').fill('-40')
// Der Dialog rechnet den Restbetrag mit; er muss aufgehen. // 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. // 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('.meldung-erfolg').last()).toContainText('Splitbuchung')
await expect(page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` }).first()) await expect(page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` }).first())
@ -372,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')
@ -494,21 +550,35 @@ test.describe('Buchungen', () => {
.not.toHaveAttribute('title') .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`) await page.goto(`/konten/${giroId}/buchung`)
const speichern = page.locator('.buchungsmaske button[type="submit"]')
await page.fill('#datum', '2026-05-03') await page.fill('#datum', '2026-05-03')
await page.fill('#betrag', '100') await page.fill('#betrag', '100')
await page.fill('#empfaenger', `Krumm ${MARKE}`) 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.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE })
await page.fill('input[name="split[0][betrag]"]', '-60') 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('#splitsumme')).toHaveText('0,00')
await expect(page.locator('.meldung-fehler')).toContainText('Splitzeilen') await expect(speichern).toBeEnabled()
await speichern.click()
await expect(page.locator('.meldung-erfolg')).toBeVisible()
}) })
test('Kontoblatt lässt sich durchsuchen', async ({ page }) => { test('Kontoblatt lässt sich durchsuchen', async ({ page }) => {

View file

@ -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)
})
})