Files
bin/docs/superpowers/specs/2026-09-29-ausbaustufe-13-design.md
T
2026-09-29 13:27:58 +02:00

15 KiB
Raw Blame History

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.js sortierbar.
  • 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_cashflows liefert nur (Datum, Betrag) — ohne Name/Kategorie/Art, daher für die Aufteilung nicht verwendbar. Modifikator-Logik steckt in den privaten Helfern _modified und _effective_end.
  • services/projection_service.py::run_projection lädt Fixposten (include_recurring), globale Einmalposten (include_planned), ScenarioPlannedItems (immer, ohne Kategorie), Szenario-Kredite (ScenarioLoan) und Modifikatoren inline — keine wiederverwendbare Ladefunktion.
  • services/planning_chart.py rechnet die reine Planung (alle Kredite, keine Modifikatoren) mit Kategorie KREDITRATEN = "System-Kreditraten" und UNKATEGORISIERT = "unkategorisiert".
  • services/double_count.py::all_conflicts liefert Konflikt (posten_id, kredit_id, Namen, Beträge) über alle Kredite.
  • static/sort.js sortiert nur <tbody>; <tfoot> bleibt stehen.
  • htmx ist in base.html eingebunden.

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_date leer oder ≤ Monatsende und effektives Ende (effective_end: eigenes end_date bzw. früheres Ende aus ende-Modifikator) leer oder ≥ Monatsanfang.
  • Betrag = modified_amount(amount, category_id, id, modifiers); None (Modifikator remove) ⇒ Posten entfällt.
  • Monatsbetrag = Betrag × Faktor (monthly 1, quarterly 1/3, yearly 1/12), gerundet.
  • kind = "recurring", rhythm gesetzt.

Einmalposten (nur wenn filters.planned):

  • Posten mit due in M, voller Betrag nach modified_amount(amount, category_id, None, modifiers).
  • Bei Szenario: globale PlannedItems nur mit include_planned, ScenarioPlannedItems immer (Kategorie None ⇒ „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 = Summe payment aller Raten mit due in 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, wenn income ≥ costs und income > 0; sonst costs (Unterdeckung oder keine Einkünfte).
  • pie_values: Kategorienkosten, plus „Frei verfügbar" = free, wenn pie_basis == income und free > 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_share werden 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-d im Einheitskreis-Koordinatensystem, large-arc-Flag korrekt ab 180°), members (bei „Übrige": die zusammengefassten Labels).
  • Genau ein Segment ⇒ path = None, Template zeichnet <circle>.
  • Winkelrechnung darf float verwenden (reine Geometrie, keine Beträge); Beträge und Anteile bleiben Decimal.

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:

  1. 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.
  2. Kennzahlen: Einkünfte · Kosten · Frei verfügbar / Unterdeckung (rot).
  3. 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 Konflikt aus all_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."
  4. Tabelle (sort.js, clientseitig): Posten · Kategorie · Art („Fixposten (monatlich|quartalsweise|jährlich)", „Einmalposten", „Kredit") · Monatsbetrag (vorzeichenbehaftet, neg-Klasse wie in planning.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 €".
  5. Haupttorte (SVG) mit Legende (Label, Betrag, %). Jedes Segment hat <title> (Label, Betrag, %). „Frei verfügbar" in eigener, klar unterscheidbarer Farbe. Farbpalette per dataviz-Skill festgelegt, hell/dunkel lesbar. Darunter Text „Für Kategorie-Aufteilung auf Segment klicken".
  6. Drill-down-Container #aufteilung-kategorie: leer, oder bei gesetztem kategorie bereits die zweite Torte.

Drill-down:

  • Kostensegmente und ihre Legendeneinträge sind Links href="/aufteilung?<aktuelle Auswahl>&kategorie=<Name>" mit hx-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/kategorie enthält sie zusätzlich mit hx-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/kategorie nimmt 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.