diff --git a/Changes.md b/Changes.md index 2e23dc7..87dd03c 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,27 @@ # Änderungen +## Splitbuchungen in den Abgleichregeln (13.08.2026) + +Eine Regel für den Abgleich kann jetzt eine **Splitbuchung** vorgeben: mehrere Kategorien mit +einem Anteil in Prozent. Greift die Regel, wird der Umsatz in diesem Verhältnis aufgeteilt – +bei jedem Betrag neu gerechnet, sodass eine Nebenkostenabrechnung über 300 € und eine über +450 € nach demselben Schlüssel gebucht werden. + +Das Schema lag bereits im Modell und in der Datenbank, war aber über die Oberfläche nicht +erreichbar; die Dokumentation beschrieb ein Feld, das es nirgends zu sehen gab. + +- Unter jeder Regel klappt *Splitbuchung* die Zeilen auf: Kategorie, Klasse, Notiz, Anteil. + Dasselbe Formular steht bei *Neue Regel* (`src/Views/banking/regelsplits.php`, in beiden + Ansichten eingebunden). Zugeklappt, solange eine Regel keine Splitzeilen hat. +- Die Anteile gelten **zueinander**, nicht absolut: 60 zu 30 sind zwei Drittel zu einem + Drittel. Bisher hätte die letzte Zeile als Rest 40 statt der eingetragenen 30 Prozent + bekommen – ein Fehler, der in der fertigen Buchung nicht mehr zu sehen gewesen wäre. + Deshalb ist der Anteil auch nicht länger auf 1 gedeckelt. +- Im Abgleichdialog zeigt der Vorschlag die errechneten Zeilen mit ihren Beträgen und schickt + sie mit. Zuvor gingen sie beim Knopf *Als neue Buchung* verloren: Das Formular kannte nur + Kategorie und Klasse, sodass von Hand übernommene Umsätze ungeteilt im Kontoblatt landeten. + Der automatische Lauf war davon nie betroffen. + ## Kurse von Yahoo Finanzen (13.08.2026) Dritter Anbieter im Kursabruf: **Yahoo Finanzen**, ohne Schlüssel und ohne Tageskontingent. diff --git a/docs/banking.md b/docs/banking.md index f25ba31..e0ce944 100644 --- a/docs/banking.md +++ b/docs/banking.md @@ -251,9 +251,30 @@ Eine Regel prüft ein Muster gegen Empfänger und/oder Verwendungszweck: so. * **Betragsgrenzen** rechnen mit Vorzeichen: „von −60 bis −40“ trifft Lastschriften zwischen 40 und 60 Euro. -* **Wirkung**: Kategorie, Klasse, Gegenkonto (macht daraus eine Umbuchung) oder ein festes - **Split-Schema** in Prozent. Die letzte Splitzeile bekommt den Rest, damit die Summe auf - den Cent zum Buchungsbetrag passt. +* **Wirkung**: Kategorie, Klasse, Gegenkonto (macht daraus eine Umbuchung) oder eine + **Splitbuchung**. + +### Splitbuchung in einer Regel + +Unter jeder Regel klappt *Splitbuchung* die Zeilen auf: je Zeile eine Kategorie, wahlweise +Klasse und Notiz, dazu ein Anteil in Prozent. Greift die Regel, entsteht keine Buchung mit +einer Kategorie, sondern eine Splitbuchung mit diesen Zeilen. + +Die Anteile gelten **zueinander**, nicht als feste Beträge – darin liegt der ganze Zweck: +Dieselbe Regel trifft bei jeder Rechnungshöhe dasselbe Verhältnis. Eine Nebenkostenabrechnung +über 300 € wird bei 60 zu 30 in 200 € und 100 € geteilt, eine über 450 € in 300 € und 150 €. +Ob dort 60 und 30 steht oder 2 und 1, ändert nichts. + +Dass die Anteile aufeinander bezogen werden und nicht absolut gelten, ist Absicht: Sonst +bekäme die letzte Zeile als Rest plötzlich 40 statt der eingetragenen 30 Prozent, und der +Fehler wäre in der fertigen Buchung nicht mehr zu sehen. Die letzte Zeile bekommt weiterhin +den Rundungsrest, damit die Summe auf den Cent zum Buchungsbetrag passt – bei einem Drittel +zu zwei Dritteln ginge sonst ein Cent verloren, und die Buchung ließe sich nicht speichern. + +Eine Zeile ohne Kategorie oder ohne Anteil fällt weg; ohne jede Splitzeile bucht die Regel +wie zuvor auf ihre Kategorie. Im Abgleichdialog zeigt der Vorschlag die errechneten Zeilen +mit ihren Beträgen an und übernimmt sie unverändert – geändert wird eine Aufteilung im +Kontoblatt, wo die ganze Buchung samt Summenprüfung vorliegt. Die **erste passende Regel gewinnt**, sortiert nach Priorität (kleinere Zahl zuerst). Kein Sammeln über mehrere Regeln: Wenn zwei verschiedene Kategorien setzen wollten, wäre am Ende diff --git a/public/static/app.css b/public/static/app.css index 0acf57d..dfda714 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -1688,6 +1688,40 @@ table.bericht .wertlink:hover { .regeltabelle select, .regeltabelle input[type="text"] { max-width: 12rem; } +/* Die Splitzeilen einer Regel hängen unter ihrer Regel und sind zugeklappt, + solange keine da sind – sonst stünde unter jeder Regel eine leere Tabelle. */ +.regelsplitzeile > td { + padding: 0 .4rem .6rem; + border-top: 0; +} + +.regelsplitzeile summary { + cursor: pointer; + font-size: .85rem; + color: var(--text-gedaempft); +} + +.regelsplitzeile .splittabelle { margin-top: .5rem; } +.regelsplitzeile .feldhinweis { margin-bottom: 0; } + +/* Die Aufteilung, die eine Regel vorschlägt: reine Anzeige, die Felder daneben + sind versteckt. */ +.vorschlagssplits { + flex: 1 1 100%; + margin: 0; + padding: 0; + list-style: none; + font-size: .85rem; +} + +.vorschlagssplits li { + display: flex; + gap: .5rem; + justify-content: space-between; + max-width: 28rem; + padding: .1rem 0; +} + /* ── Wertpapiere ─────────────────────────────────────────────────────────── */ /* Die Kennzahlen über der Depotübersicht: Wert, Einstand, Gewinn, Erträge – diff --git a/src/Controllers/BankingController.php b/src/Controllers/BankingController.php index 293adfb..dc6c81e 100644 --- a/src/Controllers/BankingController.php +++ b/src/Controllers/BankingController.php @@ -685,11 +685,50 @@ class BankingController extends BaseController 'kategorie_id' => $this->post('kategorie_id'), 'klasse_id' => $this->post('klasse_id'), 'gegenkonto_id' => $this->post('gegenkonto_id'), + 'splits' => $this->regelsplits(), 'prioritaet' => $this->post('prioritaet', '100'), 'aktiv' => isset($_POST['aktiv']) ? 1 : 0, ]; } + /** + * Das Split-Schema einer Regel aus dem Formular. + * + * Eingegeben wird in Prozent, gespeichert wird als Anteil: Die Regel soll ja + * gerade **nicht** an einem Betrag hängen, sondern bei jeder Rechnungshöhe + * dasselbe Verhältnis treffen. + * + * @return list> + */ + private function regelsplits(): array + { + $roh = $_POST['split'] ?? []; + if (!is_array($roh)) { + return []; + } + + $splits = []; + foreach ($roh as $zeile) { + if (!is_array($zeile)) { + continue; + } + + $prozent = KontenController::zahl((string)($zeile['prozent'] ?? '')); + if (($zeile['kategorie_id'] ?? '') === '' || $prozent <= 0.0) { + continue; + } + + $splits[] = [ + 'kategorie_id' => (int)$zeile['kategorie_id'], + 'klasse_id' => ($zeile['klasse_id'] ?? '') === '' ? null : (int)$zeile['klasse_id'], + 'anteil' => $prozent / 100, + 'notiz' => (string)($zeile['notiz'] ?? ''), + ]; + } + + return $splits; + } + /** * Die Zuordnung Bankkonto → eigenes Konto aus dem Formular. * diff --git a/src/Models/AbgleichRegel.php b/src/Models/AbgleichRegel.php index 2351701..cf6ea49 100644 --- a/src/Models/AbgleichRegel.php +++ b/src/Models/AbgleichRegel.php @@ -154,6 +154,11 @@ class AbgleichRegel * Prozentschema in Beträge umgerechnet – dieselbe Regel passt so auf * Rechnungen jeder Höhe. * + * Die Anteile gelten dabei **zueinander**, nicht absolut: Sie werden auf + * ihre eigene Summe bezogen. Wer 60 zu 30 einträgt, meint zwei Drittel zu + * einem Drittel – bei absoluter Rechnung bekäme die letzte Zeile als Rest + * plötzlich 40 statt 30 Prozent, und niemand fände den Fehler wieder. + * * @param array $regel * @param array $umsatz * @return array{kategorie_id: ?int, klasse_id: ?int, gegenkonto_id: ?int, splits: list>} @@ -164,11 +169,18 @@ class AbgleichRegel $splits = []; $schema = json_decode((string)($regel['splits'] ?? ''), true); - if (is_array($schema) && $schema !== []) { + $schema = is_array($schema) ? $schema : []; + + $gesamt = 0.0; + foreach ($schema as $zeile) { + $gesamt += max(0.0, (float)($zeile['anteil'] ?? 0)); + } + + if ($gesamt > 0.0) { $summe = 0.0; foreach ($schema as $nummer => $zeile) { - $anteil = (float)($zeile['anteil'] ?? 0); - $teil = round($betrag * $anteil, 2); + $anteil = max(0.0, (float)($zeile['anteil'] ?? 0)); + $teil = round($betrag * $anteil / $gesamt, 2); // Die letzte Zeile bekommt den Rest, damit die Summe der Splits // auf den Cent zum Buchungsbetrag passt – bei 1/3 zu 2/3 ginge @@ -196,6 +208,37 @@ class AbgleichRegel ]; } + /** + * Das gespeicherte Schema zurück in Formularzeilen – Anteile als Prozent, + * denn in Prozent wird auch eingegeben. + * + * @param array $regel + * @return list> + */ + public static function splitzeilen(array $regel): array + { + $schema = json_decode((string)($regel['splits'] ?? ''), true); + if (!is_array($schema)) { + return []; + } + + $zeilen = []; + foreach ($schema as $zeile) { + if (!is_array($zeile)) { + continue; + } + + $zeilen[] = [ + 'kategorie_id' => (int)($zeile['kategorie_id'] ?? 0) ?: null, + 'klasse_id' => (int)($zeile['klasse_id'] ?? 0) ?: null, + 'prozent' => round((float)($zeile['anteil'] ?? 0) * 100, 2), + 'notiz' => (string)($zeile['notiz'] ?? ''), + ]; + } + + return $zeilen; + } + public static function zaehleTreffer(int $id): void { Database::execute('UPDATE abgleich_regeln SET treffer = treffer + 1 WHERE id = ?', [$id]); @@ -312,8 +355,12 @@ class AbgleichRegel } /** - * Bringt ein Split-Schema in Form: je Zeile eine Kategorie und ein Anteil - * zwischen 0 und 1. Zeilen ohne Kategorie oder ohne Anteil fallen weg. + * Bringt ein Split-Schema in Form: je Zeile eine Kategorie und ein Anteil. + * Zeilen ohne Kategorie oder ohne Anteil fallen weg. + * + * Der Anteil wird nicht auf 1 gedeckelt: Er gilt im Verhältnis zu den + * übrigen Zeilen (siehe `anwenden()`), 100 Prozent sind also keine Grenze, + * sondern nur der bequemste Weg, ein Verhältnis aufzuschreiben. * * @param array $roh */ @@ -334,7 +381,7 @@ class AbgleichRegel $zeilen[] = [ 'kategorie_id' => $kategorie, 'klasse_id' => (int)($zeile['klasse_id'] ?? 0) ?: null, - 'anteil' => round(min($anteil, 1.0), 4), + 'anteil' => round($anteil, 4), 'notiz' => mb_substr(trim((string)($zeile['notiz'] ?? '')), 0, 255), ]; } diff --git a/src/Views/banking/regeln.php b/src/Views/banking/regeln.php index a839bcd..98a8938 100644 --- a/src/Views/banking/regeln.php +++ b/src/Views/banking/regeln.php @@ -22,8 +22,8 @@ include __DIR__ . '/../layout/kopf.php';

Eine Regel prüft ein Muster gegen Empfänger und Verwendungszweck und hängt bei einem - Treffer Kategorie, Klasse oder ein Gegenkonto an. Die erste passende Regel gewinnt – - sortiert nach Priorität, kleinere Zahl zuerst. + Treffer Kategorie, Klasse, ein Gegenkonto oder eine Splitbuchung an. Die erste passende + Regel gewinnt – sortiert nach Priorität, kleinere Zahl zuerst.

@@ -122,6 +122,23 @@ include __DIR__ . '/../layout/kopf.php'; class="knopf klein gefahr">Löschen + + + + + > + + Splitbuchung + + ( Zeilen) + + + + + + + @@ -189,6 +206,10 @@ include __DIR__ . '/../layout/kopf.php'; +

Splitbuchung (statt einer einzelnen Kategorie)

+ + + diff --git a/src/Views/banking/regelsplits.php b/src/Views/banking/regelsplits.php new file mode 100644 index 0000000..9ee11f9 --- /dev/null +++ b/src/Views/banking/regelsplits.php @@ -0,0 +1,77 @@ +> $splitZeilen + * @var string $splitForm Kennung des zugehörigen Formulars; leer, wenn die + * Felder ohnehin im umgebenden Formular stehen + * @var list> $kategorien + * @var list> $klassen + */ + +// Ein paar leere Zeilen mehr, als belegt sind: So lässt sich eine Kategorie +// nachtragen, ohne dass ein Knopf und ein Stück JavaScript dafür nötig wären. +$anzahl = max(3, count($splitZeilen) + 2); +for ($i = count($splitZeilen); $i < $anzahl; $i++) { + $splitZeilen[] = ['kategorie_id' => null, 'klasse_id' => null, 'prozent' => '', 'notiz' => '']; +} + +$fuerFormular = $splitForm === '' ? '' : ' form="' . View::h($splitForm) . '"'; +?> + + + + + + + + + $zeile): ?> + + + + + + + + +
KategorieKlasseNotizAnteil %
+ name="split[][kategorie_id]"> + + + + + + + name="split[][klasse_id]"> + + + + + + + name="split[][notiz]" + value=""> + + + name="split[][prozent]" + value=""> +
+ +

+ Die Anteile gelten zueinander, nicht als feste Beträge: Bei jedem Buchungsbetrag + werden die Kategorien im selben Verhältnis bebucht. Ob dort 60 und 40 steht oder + 3 und 2, macht deshalb keinen Unterschied. Die letzte Zeile bekommt den Rest, damit + die Summe auf den Cent zum Buchungsbetrag passt. Eine Zeile ohne Kategorie oder ohne + Anteil fällt weg; ohne jede Splitzeile bucht die Regel wie bisher auf ihre Kategorie. +

diff --git a/src/Views/banking/umsaetze.php b/src/Views/banking/umsaetze.php index 5104890..114603e 100644 --- a/src/Views/banking/umsaetze.php +++ b/src/Views/banking/umsaetze.php @@ -17,6 +17,13 @@ use Finanzmanager\View; * @var string $csrfToken */ +// Für die Splitzeilen einer Regel: Die Vorschläge tragen nur Kennungen, hier +// soll der Pfad der Kategorie stehen. +$kategoriepfad = []; +foreach ($kategorien as $eintrag) { + $kategoriepfad[(int)$eintrag['id']] = (string)$eintrag['pfad']; +} + include __DIR__ . '/../layout/kopf.php'; ?> @@ -116,28 +123,54 @@ include __DIR__ . '/../layout/kopf.php'; - + + + +
    + $zeile): ?> +
  • + + + + + + + + +
  • + +
+ + - + + diff --git a/tests/16_BankingTest.php b/tests/16_BankingTest.php index 744d849..fdb4627 100644 --- a/tests/16_BankingTest.php +++ b/tests/16_BankingTest.php @@ -43,9 +43,11 @@ class BankingTest extends TestCase $this->umsaetzeKommenNurEinmalHerein(); $this->regelnGreifenNachPrioritaet(); $this->regelnRechnenSplitsAus(); + $this->splitsHaltenDasVerhaeltnis(); $this->ungueltigesMusterWirdAbgewiesen(); $this->abgleichFindetVorhandeneBuchung(); $this->uebernehmenErzeugtEineBuchung(); + $this->regelmitSplitsErzeugtEineSplitbuchung(); $this->verwerfenUndZuruecksetzen(); $this->abrufprotokollHaeltDenVerlauf(); } @@ -529,6 +531,55 @@ class BankingTest extends TestCase AbgleichRegel::loeschen($id); } + /** + * Der eigentliche Zweck des Schemas: Dieselbe Regel trifft bei jedem + * Buchungsbetrag dasselbe Verhältnis. Und die Anteile gelten zueinander – + * 60 zu 30 sind zwei Drittel zu einem Drittel, nicht 60 Prozent und ein + * Rest von 40. + */ + private function splitsHaltenDasVerhaeltnis(): void + { + $id = AbgleichRegel::anlegen([ + 'name' => 'Nebenkosten geteilt', + 'feld' => 'empfaenger', + 'vergleich' => 'enthaelt', + 'muster' => 'Hausverwaltung', + 'splits' => [ + ['kategorie_id' => $this->miete, 'anteil' => 0.60], + ['kategorie_id' => $this->strom, 'anteil' => 0.30], + ], + 'aktiv' => 1, + ]); + + $regel = AbgleichRegel::find($id); + + $klein = AbgleichRegel::anwenden($regel, ['empfaenger' => 'Hausverwaltung', 'betrag' => -300.00]); + $this->assertEquals(-200.00, $klein['splits'][0]['betrag'], 'Zwei Drittel von 300'); + $this->assertEquals(-100.00, $klein['splits'][1]['betrag'], 'Ein Drittel von 300'); + + // Derselbe Schlüssel bei anderer Rechnungshöhe: Das Verhältnis bleibt. + $gross = AbgleichRegel::anwenden($regel, ['empfaenger' => 'Hausverwaltung', 'betrag' => -450.00]); + $this->assertEquals(-300.00, $gross['splits'][0]['betrag'], 'Zwei Drittel von 450'); + $this->assertEquals(-150.00, $gross['splits'][1]['betrag'], 'Ein Drittel von 450'); + + // Auch ein Guthaben – eine Rückzahlung der Nebenkosten – wird geteilt. + $zurueck = AbgleichRegel::anwenden($regel, ['empfaenger' => 'Hausverwaltung', 'betrag' => 90.00]); + $this->assertEquals(60.00, $zurueck['splits'][0]['betrag'], 'Erstattung nach demselben Schlüssel'); + $this->assertEquals( + 90.00, + round(array_sum(array_column($zurueck['splits'], 'betrag')), 2), + 'Auch hier ergeben die Zeilen den Buchungsbetrag' + ); + + // Was die Oberfläche zum Bearbeiten zurückbekommt, ist wieder Prozent. + $zeilen = AbgleichRegel::splitzeilen($regel); + $this->assertEquals(2, count($zeilen), 'Zwei Zeilen für das Formular'); + $this->assertEquals(60.0, $zeilen[0]['prozent'], 'Der Anteil steht als Prozentwert'); + $this->assertEquals($this->strom, $zeilen[1]['kategorie_id'], 'Mit seiner Kategorie'); + + AbgleichRegel::loeschen($id); + } + private function ungueltigesMusterWirdAbgewiesen(): void { $this->assert( @@ -678,6 +729,54 @@ class BankingTest extends TestCase ); } + /** + * Der ganze Weg: Eine Regel mit Splitschema trifft einen offenen Umsatz, und + * was der Vorschlag ausrechnet, wird auch so gebucht. Der Abgleichdialog + * reicht genau diese Zeilen weiter – tat er das nicht, landete der Umsatz + * ungeteilt im Kontoblatt, ohne dass jemand etwas davon merkte. + */ + private function regelmitSplitsErzeugtEineSplitbuchung(): void + { + $regelId = AbgleichRegel::anlegen([ + 'name' => 'Abschlag geteilt', + 'feld' => 'empfaenger', + 'vergleich' => 'enthaelt', + 'muster' => 'Stadtwerke Nord', + 'splits' => [ + ['kategorie_id' => $this->strom, 'anteil' => 0.75], + ['kategorie_id' => $this->miete, 'anteil' => 0.25], + ], + 'prioritaet' => 1, + 'aktiv' => 1, + ]); + + $umsatzId = $this->umsatzAnlegen([ + 'konto_id' => $this->giro, 'buchungstag' => '2026-08-07', 'betrag' => -240.00, + 'empfaenger' => 'Stadtwerke Nord AG', 'verwendungszweck' => 'Abschlag August', + ]); + + $umsatz = Bankumsatz::find($umsatzId); + $vorschlag = Bankumsatz::vorschlag($umsatz); + + $this->assertEquals('uebernehmen', $vorschlag['art'], 'Ohne passende Buchung wird übernommen'); + $this->assertNotEmpty($vorschlag['regel'], 'Die Regel greift'); + $this->assertEquals(2, count($vorschlag['zuordnung']['splits']), 'Der Vorschlag bringt zwei Zeilen mit'); + + $buchungId = Bankumsatz::uebernehmen($umsatzId, $vorschlag['zuordnung']); + $buchung = Buchung::find($buchungId); + + $this->assertEquals(1, (int)$buchung['gesplittet'], 'Es entsteht eine Splitbuchung'); + $this->assertNull($buchung['kategorie_id'], 'Die Zuordnung steckt in den Zeilen, nicht in der Buchung'); + + $splits = Buchung::splits($buchungId); + $this->assertEquals(2, count($splits), 'Zwei Splitzeilen im Kontoblatt'); + $this->assertEquals(-180.00, (float)$splits[0]['betrag'], 'Drei Viertel von 240'); + $this->assertEquals(-60.00, (float)$splits[1]['betrag'], 'Ein Viertel von 240'); + $this->assertEquals($this->strom, (int)$splits[0]['kategorie_id'], 'Mit der Kategorie aus der Regel'); + + AbgleichRegel::loeschen($regelId); + } + private function verwerfenUndZuruecksetzen(): void { $id = $this->umsatzAnlegen([