Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
15 KiB
Design — Ausbaustufe 13: Seite „Aufteilung" (Monatskosten je Kategorie) (v0.13.0)
Status: Design im Chat vom Nutzer freigegeben (2026-09-29), Spec zur Durchsicht. Anlass: In der Beratung soll für einen gewählten Monat anschaulich werden, welche monatlichen Kosten je Kategorie anfallen und welcher Anteil der Einkünfte danach frei verfügbar bleibt — für die reine Planung ebenso wie für ein Szenario.
Nutzerentscheidungen (2026-09-29)
- Neuer Menüpunkt „Aufteilung" (zwischen „Szenarien" und „Admin").
- Datenbasis immer Planung, nie Ist-Buchungen. Auch vergangene Monate
werden aus den damals gültigen Planungsdaten berechnet (Fixposten,
Einmalposten, Kredite). Die Auswahl heißt deshalb „System-Plan"
(nicht „System-Ist" — das bleibt echten Buchungen vorbehalten). Der Name
fällt unter den reservierten Präfix
System-(Ausbaustufe 12), kann also nicht mit einem echten Szenario kollidieren. - Szenarien sind nur für den laufenden und künftige Monate wählbar; „System-Plan" für jeden Monat.
- Fixposten normalisiert auf den Monat: monatlich ×1, quartalsweise ×⅓, jährlich ×1/12.
- Einmalposten mit vollem Betrag, nur im Monat ihrer Fälligkeit (keine Verteilung über 12 Monate).
- Filter „Fixposten", „Einmalposten", „Kredite" wirken auf Einnahmen und Ausgaben gleichermaßen.
- Tabelle flach: eine Zeile je Posten mit Kategorie-Spalte, per
sort.jssortierbar. - Tortendiagramm direkt auf der Seite (serverseitiges SVG), kein Grafana-Panel.
- Torte = Einkünfte: Kostensegmente je Kategorie + Segment „Frei verfügbar". Bei Unterdeckung wechselt die Basis auf die Gesamtkosten (mit deutlichem Hinweis); die Tabelle bleibt bei „% der Einkünfte".
- „Frei verfügbar" als Tabellenzeile im
<tfoot>(bei Unterdeckung „Unterdeckung", rot). Darunter „Gesamte Einkünfte". - Kategorien < 3 % der Tortenbasis werden nur in der Torte zu „Übrige" zusammengefasst.
- Endfällige Kredite: Die Schlussrate enthält die volle Tilgung und erscheint im letzten Monat voll (konsequent zu „Einmalposten voll").
- Drill-down: Klick auf ein Kostensegment (oder dessen Legendeneintrag) zeigt unter der Haupttorte eine zweite Torte mit allen Posten dieser Kategorie. Hinweistext „Für Kategorie-Aufteilung auf Segment klicken".
- Version v0.13.0 (neue Seite).
Befund (Ist-Zustand, v0.12.0)
engine/scenario.py:build_cashflowsliefert nur(Datum, Betrag)— ohne Name/Kategorie/Art, daher für die Aufteilung nicht verwendbar. Modifikator-Logik steckt in den privaten Helfern_modifiedund_effective_end.services/projection_service.py::run_projectionlädt Fixposten (include_recurring), globale Einmalposten (include_planned),ScenarioPlannedItems (immer, ohne Kategorie), Szenario-Kredite (ScenarioLoan) und Modifikatoren inline — keine wiederverwendbare Ladefunktion.services/planning_chart.pyrechnet die reine Planung (alle Kredite, keine Modifikatoren) mit KategorieKREDITRATEN = "System-Kreditraten"undUNKATEGORISIERT = "unkategorisiert".services/double_count.py::all_conflictsliefertKonflikt(posten_id,kredit_id, Namen, Beträge) über alle Kredite.static/sort.jssortiert nur<tbody>;<tfoot>bleibt stehen.- htmx ist in
base.htmleingebunden.
Architektur
Kernachse bleibt routers → services → engine. Keine Schemaänderung, keine
Migration, keine View, Grafana unverändert.
| Einheit | Aufgabe |
|---|---|
engine/scenario.py |
_modified → öffentlich modified_amount, _effective_end → effective_end (reine Umbenennung, build_cashflows nutzt die neuen Namen). |
engine/breakdown.py (neu, rein) |
BreakdownRow (Dataclass: name, category, kind, rhythm | None, amount: Decimal, vorzeichenbehaftet), Filters (Dataclass: recurring, planned, loans: bool), breakdown_rows(...), summarize(rows), category_rows(rows, kategorie, small). |
engine/pie.py (neu, rein) |
pie_slices(values, min_share) → Segmente mit Label, Betrag, Anteil, Start-/Endwinkel, SVG-Pfad d; fasst Anteile < min_share zu „Übrige" zusammen; Sonderfall ein Segment = Vollkreis. |
services/projection_service.py |
Neu load_scenario_inputs(session, scenario) → ScenarioInputs (Fixposten, Einmalposten inkl. Szenario-Einmalposten, Kredite als (Name, Tilgungsplan, Auszahlung), Modifikatoren). run_projection nutzt sie; Verhalten unverändert. |
services/breakdown.py (neu) |
compute_breakdown(session, month, scenario_id | None, filters) → Breakdown. None = System-Plan: alle RecurringItem, alle PlannedItem, alle Loan, keine Modifikatoren, keine Szenario-Einmalposten. Sonst load_scenario_inputs. Setzt Kategorie-IDs in Namen um. Ermittelt Doppelzählungs-Hinweise (siehe unten). |
routers/gui.py |
aufteilung_page (GET /aufteilung) und aufteilung_kategorie_fragment (GET /aufteilung/kategorie). |
templates/aufteilung.html, templates/_aufteilung_torte.html, templates/_aufteilung_kategorie.html (neu) |
Seite, Haupttorte (Partial) bzw. Fragment der zweiten Torte. |
templates/base.html |
Menüpunkt „Aufteilung". |
templates/hilfe.html |
Abschnitt „Aufteilung". |
static/style.css |
Layout Tabelle/Torte (nebeneinander, schmal untereinander), Tortenfarben als Tokens (hell/dunkel). |
Rechenregeln (engine/breakdown.py)
Monat M = erster bis letzter Kalendertag. Alle Beträge Decimal; jede Zeile
wird auf Cent gerundet (ROUND_HALF_UP), Summen entstehen aus den
gerundeten Zeilen.
Fixposten (nur wenn filters.recurring; bei Szenario zusätzlich nur mit
include_recurring — das erledigt load_scenario_inputs):
- Zählt, wenn
start_dateleer oder ≤ Monatsende und effektives Ende (effective_end: eigenesend_datebzw. früheres Ende ausende-Modifikator) leer oder ≥ Monatsanfang. - Betrag =
modified_amount(amount, category_id, id, modifiers);None(Modifikatorremove) ⇒ Posten entfällt. - Monatsbetrag = Betrag × Faktor (
monthly1,quarterly1/3,yearly1/12), gerundet. kind = "recurring",rhythmgesetzt.
Einmalposten (nur wenn filters.planned):
- Posten mit
duein M, voller Betrag nachmodified_amount(amount, category_id, None, modifiers). - Bei Szenario: globale
PlannedItems nur mitinclude_planned,ScenarioPlannedItems immer (KategorieNone⇒ „unkategorisiert"). kind = "planned".
Kredite (nur wenn filters.loans):
- System-Plan: alle Kredite; Szenario: nur zugeordnete (
ScenarioLoan). - Eine Zeile je Kredit: Name = Kreditname, Kategorie =
KREDITRATEN(„System-Kreditraten"), Betrag = Summepaymentaller Raten mitduein M (bereits negativ). Keine Rate in M ⇒ keine Zeile. - Auszahlung zählt nicht. Endfällige Schlussrate zählt voll.
kind = "loan".
Allgemein:
- Zeilen mit Betrag 0 entfallen.
- Kategorie-Name:
Category.name, sonst „unkategorisiert" (planning_chart.UNKATEGORISIERT).
summarize(rows) → Summary:
income= Σ positive Beträge;costs= Σ |negative Beträge|;free = income − costs(negativ ⇒ Unterdeckung).share(row)= |Betrag| ÷income× 100, eine Nachkommastelle (ROUND_HALF_UP), nur für Kostenzeilen;income == 0⇒None(Anzeige „–").category_costs: Kosten je Kategorie (positiv), absteigend.pie_basis:income, wennincome ≥ costsundincome > 0; sonstcosts(Unterdeckung oder keine Einkünfte).pie_values: Kategorienkosten, plus „Frei verfügbar" =free, wennpie_basis == incomeundfree > 0.
category_rows(rows, kategorie, small): Kostenzeilen der Kategorie;
für kategorie == "Übrige" die Kostenzeilen aller Kategorien in small
(die in der Haupttorte zu „Übrige" zusammengefassten). Einnahmezeilen
erscheinen nie in einer Torte.
Torte (engine/pie.py)
pie_slices(values: list[tuple[str, Decimal]], min_share=Decimal("0.03")): Anteil = Wert ÷ Summe; Werte mit Anteil <min_sharewerden zu einem Segment „Übrige" zusammengefasst (nur wenn mindestens zwei betroffen sind — ein einzelnes kleines Segment bleibt, sonst hieße es sinnlos „Übrige"). „Frei verfügbar" wird nie zusammengefasst.- Rückgabe je Segment:
label,value,share,path(SVG-dim Einheitskreis-Koordinatensystem,large-arc-Flag korrekt ab 180°),members(bei „Übrige": die zusammengefassten Labels). - Genau ein Segment ⇒
path = None, Template zeichnet<circle>. - Winkelrechnung darf
floatverwenden (reine Geometrie, keine Beträge); Beträge und Anteile bleibenDecimal.
Seite /aufteilung
Query-Parameter: monat=JJJJ-MM (Default laufender Monat),
szenario=<id> (leer = System-Plan), fixposten=1, einmal=1,
kredite=1, gesendet=1 (unterscheidet Erstaufruf — alle Filter an — von
„alle Häkchen entfernt"), kategorie=<Name> (optional, Drill-down).
Aufbau von oben nach unten:
- Formular (GET, immer sichtbar):
<input type="month">,<select>„System-Plan" + alle Szenarien (alphabetisch), drei Checkboxen, Button „Anzeigen". Gewählter Monat zusätzlich als MM.JJJJ angezeigt. - Kennzahlen: Einkünfte · Kosten · Frei verfügbar / Unterdeckung (rot).
- Hinweise (inline):
- Fehler: ungültiger Monat, unbekanntes Szenario, Szenario + Monat vor dem laufenden Monat („Szenarien sind erst ab dem laufenden Monat wählbar. Für vergangene Monate „System-Plan" wählen.") ⇒ keine Tabelle/Torte, Formular mit der Auswahl bleibt.
- ⚠ Doppelzählung: je
Konfliktausall_conflicts, dessen Fixposten und Kredit beide als Zeile in der aktuellen Aufteilung stehen: „⚠ Fixposten „X" zählt evtl. doppelt mit Kredit „Y"". - Unterdeckung: „Kosten übersteigen die Einkünfte um X € — das Diagramm zeigt die Aufteilung der Gesamtkosten."
- Keine Zeilen: „Für diese Auswahl gibt es keine Posten."
- Tabelle (
sort.js, clientseitig): Posten · Kategorie · Art („Fixposten (monatlich|quartalsweise|jährlich)", „Einmalposten", „Kredit") · Monatsbetrag (vorzeichenbehaftet,neg-Klasse wie inplanning.html) · % der Einkünfte (leer bei Einnahmen, „–" bei Einkünften 0). Anfangssortierung: Monatsbetrag aufsteigend (größte Kosten oben).<tfoot>: Zeile „Frei verfügbar" bzw. „Unterdeckung" (rot) mit Betrag und Anteil. Direkt unter der Tabelle: „Gesamte Einkünfte: X €". - Haupttorte (SVG) mit Legende (Label, Betrag, %). Jedes Segment hat
<title>(Label, Betrag, %). „Frei verfügbar" in eigener, klar unterscheidbarer Farbe. Farbpalette perdataviz-Skill festgelegt, hell/dunkel lesbar. Darunter Text „Für Kategorie-Aufteilung auf Segment klicken". - Drill-down-Container
#aufteilung-kategorie: leer, oder bei gesetztemkategoriebereits die zweite Torte.
Drill-down:
- Kostensegmente und ihre Legendeneinträge sind Links
href="/aufteilung?<aktuelle Auswahl>&kategorie=<Name>"mithx-get="/aufteilung/kategorie?<…>",hx-target="#aufteilung-kategorie",hx-push-url="<href>". Ohne JS funktioniert der Link als Seitenaufruf. - „Frei verfügbar" ist kein Link.
- Gewähltes Segment in der Haupttorte hervorgehoben (Klasse
gewaehlt, Umriss). Die Haupttorte liegt in einem eigenen Partial_aufteilung_torte.html(Container#aufteilung-torte); das Fragment-Response von/aufteilung/kategorieenthält sie zusätzlich mithx-swap-oob="true"und markiertem Segment. Kein eigenes JS. - Zweite Torte: Überschrift „: X € / Monat"; Segmente = Posten
(
category_rows), Basis = Kategoriekosten, 3-%-Regel wie oben; Legende: Posten, Monatsbetrag, % der Kategorie, % der Einkünfte. - Unbekannte oder leere Kategorie (z. B. alte URL, Filter geändert): Hinweis „Kategorie „X" hat in dieser Auswahl keine Kosten." statt Fehler.
GET /aufteilung/kategorienimmt dieselben Parameter, validiert identisch und liefert nur_aufteilung_kategorie.html(Fehler als Hinweis im Fragment, Status 200).
Beide Routen: Depends(gui_session), kein _safe_refresh (nichts
Abgeleitetes zu schreiben — reine Berechnung beim Aufruf).
Tests
| Datei | Prüft |
|---|---|
tests/test_scenario_engine.py |
Bestehende Tests grün nach Umbenennung modified_amount/effective_end. |
tests/test_breakdown.py (neu, ohne DB) |
Normalisierung monatlich/quartalsweise/jährlich inkl. Rundung; Laufzeitgrenzen (Start am Monatsletzten, Ende am Monatsersten, Ende im Vormonat); ende-Modifikator; remove/percent/absolute; Einmalposten in/außerhalb M; Annuitätenrate; endfällige Zins-/Schlussrate; Auszahlung zählt nicht; Filter wirken auf Einnahmen; 0-€-Zeilen entfallen; summarize (Anteile, Unterdeckung, Einkünfte 0, pie_basis, „Frei verfügbar" nur ohne Unterdeckung); category_rows inkl. „Übrige" und Kategorie ohne Kosten. |
tests/test_pie.py (neu) |
Summe der Winkel = 360°; large-arc-Flag; „Übrige" < 3 % (nur ab zwei Segmenten); „Frei verfügbar" nie zusammengefasst; Einzelsegment ⇒ path is None; leere Eingabe ⇒ leere Liste. |
tests/test_projection.py / test_projection_refresh.py |
Grün nach Refactoring load_scenario_inputs (Verhaltensgleichheit). |
tests/test_breakdown_service.py (neu, DB wie bestehende Service-Tests) |
System-Plan: alle Kredite, keine Modifikatoren/Szenario-Einmalposten; Szenario: nur zugeordnete Kredite, include_recurring/include_planned, ScenarioPlannedItem als „unkategorisiert"; Kategorienamen; Doppelzählungs-Hinweis nur, wenn beide Zeilen enthalten. |
tests/test_gui.py |
Menüpunkt; Default-Auswahl; gesendet=1 ohne Filter ⇒ „keine Posten"; <tfoot>-Zeile; Unterdeckungs-Hinweis; Szenario + Vormonat, ungültiger Monat, unbekanntes Szenario ⇒ Hinweis inline, Formular vorhanden, Status 200; Drill-down-Fragment; Seite mit kategorie enthält zweite Torte; unbekannte Kategorie ⇒ Hinweis; „Frei verfügbar" ohne Link. |
tests/test_sort_templates.py |
Tabelle der Aufteilung ist sortierbar, <tfoot> außerhalb <tbody>. |
Testdaten sind synthetisch (DATENSCHUTZ: keine Werte aus Fixtures oder Live-DB).
Abschluss
docs/ARCHITEKTUR.md: neue Module im Komponenten-Diagramm, Konzept- absatz „Aufteilung (Ausbaustufe 13)".hilfe.html: Abschnitt „Aufteilung".finance/VERSION→0.13.0,./create_pod_finance.sh, Live-Check.- Fable-Testagent-Gate je Task; letzter Task als Live-Test gegen den laufenden Pod (inkl. Klick-Drill-down im Browser).
- Ledger-Einträge in
.superpowers/sdd/progress.md.
Nicht Teil dieser Ausbaustufe (YAGNI)
- Grafana-Panel / Snapshot-Tabelle.
- Ist-Buchungen als Datenbasis.
- Verteilung von Einmalposten über mehrere Monate.
- Export (CSV/PDF) der Aufteilung.