Files
bin/docs/superpowers/specs/2026-09-28-ausbaustufe-12-design.md

12 KiB
Raw Permalink Blame History

Design — Ausbaustufe 12: System-Präfix + Doppelzählungs-Warnung Kredit/Fixposten (v0.12.0)

Status: Design im Chat vom Nutzer freigegeben (2026-09-28), Spec zur Durchsicht. Anlass: (1) Eine echte Kategorie „Kreditraten" würde im Grafana-Panel „Planung nach Kategorie" mit der synthetischen Kreditraten-Serie verschmelzen. (2) Wird eine Kreditrate sowohl als wiederkehrender Posten als auch als Kredit gepflegt, zählt sie in Planung und Projektion doppelt — das passiert leicht versehentlich, ein Hilfetext allein schützt nicht.

Nutzerentscheidungen (2026-09-28)

  • Reservierter Präfix „System-" für alle vom Tool erzeugten Serien. Kategorie- und Szenarionamen mit diesem Präfix werden abgelehnt. Kreditraten-Serie heißt künftig „System-Kreditraten"; „System-Ist" fällt unter dieselbe Regel.
  • „unkategorisiert" bleibt unverändert (Verschmelzen mit einer echten gleichnamigen Kategorie ist semantisch harmlos; Beschriftung bleibt gleich zum Ist-Diagramm „Monat nach Kategorie").
  • Aktiver Doppelzählungs-Check statt Hilfevermerk: Speichern wird bei Verdacht blockiert und ist nur nach expliziter Bestätigung in einem Warnfenster möglich.
  • Toleranz Betragsvergleich: ±3 %, mindestens 1,00 €.
  • Dauerhafter Hinweis auf der Planung-Seite an betroffenen Posten und Krediten (nicht blockierend).
  • Version v0.12.0 (neues API-Verhalten).

Befund (Ist-Zustand, v0.11.0)

  • RESERVED_SCENARIO_NAME = "System-Ist" in models/views.py; routers/scenarios.py::_check_reserved_name prüft nur diesen einen Namen.
  • services/planning_chart.py: KREDITRATEN = "Kreditraten".
  • routers/categories.py: create_category/patch_category ohne Namensreservierung.
  • routers/planning.py: create_recurring/patch_recurring, create_loan/patch_loan ohne Querprüfung. Schemas: RecurringIn (Basis von RecurringOut), RecurringPatch, LoanIn (Basis von LoanOut), LoanPatch.
  • GUI-Formulare gegen diese Endpunkte (alle hx-ext="json-form", planning.html): Fixposten anlegen (hx-post /api/recurring), bearbeiten (hx-patch /api/recurring/{id}), „Vorschlag übernehmen" (hx-post /api/recurring), Kredit anlegen (hx-post /api/loans), bearbeiten (hx-patch /api/loans/{id}).
  • Fehleranzeige global in base.html: htmx:responseError → alert('Fehler: ' + responseText).
  • Live-Bestand geprüft (2026-09-28): keine Kategorie/kein Szenario beginnt mit „System-", keine Kategorie heißt „Kreditraten"/„unkategorisiert"; 0 Kredite.
  • Beratungsumgebung /home/wlfb/fb: Skill finanz-api dokumentiert POST /api/recurring und POST /api/loans, Skill finanzberatung legt Kredite per API an → vom neuen 409-Verhalten betroffen.

1. Reservierter Präfix „System-"

Neues Modul app/reserved.py (einzige Quelle):

SYSTEM_PREFIX = "System-"

def is_reserved_name(name: str) -> bool:
    """True, wenn name (strip + casefold) mit SYSTEM_PREFIX beginnt."""

def check_reserved_name(name: str | None) -> None:
    """None -> nichts; reserviert -> HTTPException(422, <Meldung>)."""

Meldung (422): „Namen, die mit »System-« beginnen, sind für vom Tool erzeugte Einträge reserviert".

  • models/views.py: RESERVED_SCENARIO_NAME = f"{SYSTEM_PREFIX}Ist" (Wert unverändert „System-Ist", View-SQL unverändert).
  • services/planning_chart.py: KREDITRATEN = f"{SYSTEM_PREFIX}Kreditraten".
  • routers/scenarios.py: _check_reserved_name entfällt, stattdessen check_reserved_name in create_scenario und patch_scenario (gleiche Stellen wie bisher).
  • routers/categories.py: check_reserved_name(data.name) als erste Zeile in create_category und patch_category.
  • Grafana-Panel 5 zeigt ab dem nächsten Seitenaufruf „System-Kreditraten" (Tabelle planning_monthly wird ohnehin komplett neu geschrieben — keine Migration nötig).

Tests: Kategorien und Szenarien je POST/PATCH mit „System-X", „system-kreditraten", „ SYSTEM-Ist " → 422 mit Meldung; „Kreditraten", „Systemkosten", „Ist-System" → erlaubt (201/200). Planning-Chart-Tests nutzen die Konstante (Wert „System-Kreditraten").

2. Doppelzählungs-Erkennung (app/services/double_count.py)

Reine Logik ohne HTTP-Abhängigkeit.

TOLERANZ_PROZENT = Decimal("3")
TOLERANZ_MIN = Decimal("1.00")
CODE = "doppelzaehlung_kredit"

@dataclass(frozen=True)
class Konflikt:
    posten_id: int | None      # None = Posten noch nicht gespeichert
    posten_name: str
    posten_betrag: Decimal     # negativ
    kredit_id: int | None      # None = Kredit noch nicht gespeichert
    kredit_name: str
    kredit_rate: Decimal       # positiv, reguläre Monatsrate

def regular_rate(principal, annual_rate_pct, term_months, repayment_type) -> Decimal | None
def rate_period(payout_date, term_months) -> tuple[date, date]
def is_conflict(posten, kredit) -> bool
def conflicts_for_recurring(session, posten, exclude_id=None) -> list[Konflikt]
def conflicts_for_loan(session, kredit, exclude_id=None) -> list[Konflikt]
def all_conflicts(session) -> list[Konflikt]

posten/kredit sind einfache Wertobjekte (Name, Betrag bzw. Kreditdaten, Rhythmus, Start/Ende) — so lassen sich auch noch nicht gespeicherte bzw. per PATCH veränderte Zustände prüfen.

Reguläre Monatsrate (regular_rate, positiv, auf Cent gerundet):

  • annuity: engine/loans.py::annuity_payment(principal, rate, term).
  • bullet: monatliche Zinsen principal × rate / 100 / 12 (ROUND_HALF_UP). Bei Zinssatz 0 → None (keine reguläre Rate → nie Konflikt).
  • Schlusszahlung endfälliger Kredite wird nicht betrachtet.

Ratenzeitraum (rate_period): erste Rate add_months(payout_date, 1), letzte Rate add_months(payout_date, term_months) (identisch zu loan_schedule).

Konflikt (is_conflict), wenn ALLE gelten:

  1. Posten-Rhythmus monthly und Posten-Betrag < 0;
  2. Kredit hat eine reguläre Rate r (nicht None);
  3. abs(abs(betrag) − r) ≤ max(TOLERANZ_MIN, r × TOLERANZ_PROZENT / 100);
  4. Zeiträume überschneiden sich: (start is None or start ≤ letzte_rate) and (ende is None or ende ≥ erste_rate).

Kein Namensvergleich (unzuverlässig). Bekannte Grenze: weicht der Posten stärker als die Toleranz ab (z.B. Rate inkl. Versicherung), wird nichts erkannt. Alle Beträge Decimal.

Tests (Handrechnung, künstliche Werte): Annuität exakt / knapp innerhalb / knapp außerhalb der 3 %-Grenze; Mindesttoleranz 1,00 € bei kleiner Rate; endfällig (Zinsrate) inkl. Zinssatz 0 → kein Konflikt; Rhythmus quarterly → kein Konflikt; positiver Betrag → kein Konflikt; Zeiträume disjunkt (Posten endet vor erster Rate / startet nach letzter Rate) → kein Konflikt; offene Start/Ende → Konflikt; exclude_id schließt den bearbeiteten Datensatz selbst aus; all_conflicts findet mehrere Paare.

3. API-Verhalten (routers/planning.py)

  • Neues Anfragefeld doppelzaehlung_bestaetigt: bool = False — nur in den Request-Modellen (RecurringCreate(RecurringIn), RecurringPatch, LoanCreate(LoanIn), LoanPatch), nicht in RecurringOut/LoanOut, nicht persistiert, beim Anlegen der ORM-Objekte aus model_dump ausgeschlossen.
  • POST /api/recurring bzw. /api/loans: vor dem Speichern conflicts_for_recurring/conflicts_for_loan für den neuen Datensatz; Treffer und Flag nicht gesetzt → 409 (nichts gespeichert).
  • PATCH /api/recurring/{id} bzw. /api/loans/{id}: Konflikte vor und nach Anwendung der Änderung ermitteln (bezogen auf diesen Datensatz); 409 nur, wenn nach der Änderung ein Konflikt-Paar existiert, das vorher nicht existierte (ein bereits bestätigter Konflikt fragt bei reiner Namensänderung nicht erneut). Bei 409 session.rollback().
  • 409-Antwort (HTTPException(409, detail={...})):
    {"detail": {"code": "doppelzaehlung_kredit",
                "meldung": "<deutscher Erklärtext>",
                "konflikte": [{"posten": "...", "posten_betrag": "-312.50",
                               "kredit": "...", "kredit_rate": "312.50"}]}}
    
    Beträge als Strings mit Punkt-Dezimal (API-Konvention).
  • Flag true → Speichern trotz Konflikt (201/200 wie bisher).
  • Andere Endpunkte unverändert (Einmalposten, Szenario-Zuordnung).

Tests: je Endpunkt (POST/PATCH × Posten/Kredit) Konflikt → 409 mit code, konflikte und nichts gespeichert/geändert; mit Flag → 201/200 und gespeichert; ohne Konflikt → unverändert 201/200; PATCH einer reinen Namensänderung bei bestehendem Konflikt → 200 ohne Flag; RecurringOut/ LoanOut enthalten kein doppelzaehlung_bestaetigt; bestehende Tests bleiben grün.

4. GUI: Warnfenster (base.html)

  • Ein <dialog id="doppelzaehlung-dialog"> im Basis-Layout mit Titel „Mögliche Doppelzählung", Erklärtext (meldung), Liste der Konflikte („Posten „X" (−312,50 €) ↔ Kredit „Y" (Rate 312,50 €)", Beträge deutsch formatiert) und Warnsatz („Beide zusammen zählen die Rate doppelt — Planung und Szenarien wären zu pessimistisch."). Buttons: „Abbrechen" (autofocus, schließt ohne Speichern) und „Trotzdem speichern".
  • htmx:responseError-Handler: Status 409 und JSON-detail.code === "doppelzaehlung_kredit" → Dialog füllen + showModal(), kein alert. Alle anderen Fehler wie bisher per alert.
  • „Trotzdem speichern": setzt am auslösenden Formular data-doppelzaehlung-bestaetigt="1" und löst es erneut aus (htmx.trigger(form, 'submit')); die json-form-Extension ergänzt bei gesetztem Attribut doppelzaehlung_bestaetigt: true; nach dem Request (htmx:afterRequest) wird das Attribut wieder entfernt.
  • Gilt automatisch für alle fünf Formulare (Anlegen/Bearbeiten Posten, Vorschlag übernehmen, Anlegen/Bearbeiten Kredit).
  • UX-Regel: Formular bleibt sichtbar; Abbrechen lässt Eingaben stehen.

5. Dauerhafter Hinweis (Planung-Seite)

  • routers/gui.py::planung_page ermittelt all_conflicts(session) und übergibt je Posten-ID bzw. Kredit-ID die Gegenseite(n).
  • planning.html: in der Namens-Zelle betroffener Posten/Kredite ein <span class="warn-hint">⚠ zählt evtl. doppelt mit Kredit „Y"</span> bzw. „… mit Posten „X"". Die Namens-Zelle bekommt data-sort="{{ name }}", damit der Hinweis die Sortierung nicht beeinflusst.
  • CSS .warn-hint dezent (kleine Schrift, Warnfarbe), nicht blockierend.

Tests: GUI /planung mit einem Konflikt-Paar zeigt den Hinweis an Posten und Kredit; ohne Konflikt kein Hinweis; data-sort der Namenszellen enthält nur den Namen.

6. Doku / Beratungsumgebung / Release

  • hilfe.html: kurzer Absatz „Reserviert: Namen mit »System-«" und „Doppelzählungs-Warnung Kredit/Fixposten" (was geprüft wird, Toleranz, Bestätigung, Hinweis in der Planung).
  • docs/ARCHITEKTUR.md: reserved.py, double_count.py (Diagrammnamen müssen im Code existieren).
  • /home/wlfb/fb/.claude/skills/finanz-api/SKILL.md (eigenes Repo, eigener Commit): Abschnitte „Wiederkehrende Posten" und „Kredite" um die 409-Antwort doppelzaehlung_kredit und das Feld doppelzaehlung_bestaetigt ergänzen, mit der Regel: Flag nur nach ausdrücklicher Rückfrage beim Nutzer setzen; Reservierung „System-" für Kategorien/Szenarien erwähnen.
  • finance/VERSION 0.11.0 → 0.12.0; Backup, Redeploy, Live-Smoke (Wegwerf-Posten/-Kredit mit Konflikt → 409, mit Flag → 201, wieder löschen; Kategorie „System-Test" → 422; Panel 5 zeigt „System-Kreditraten" nur wenn Kredite existieren; Bestandszahlen unverändert).

Abgrenzung

  • Keine Prüfung von Einmalposten (z.B. Schlusszahlung endfällig).
  • Keine Prüfung bei Szenario-Kredit-Zuordnung.
  • Kein Namensabgleich Posten↔Kredit.
  • Keine Umbenennung von „unkategorisiert".
  • Keine Migration (keine Schemaänderung).

Rahmenregeln

  • DATENSCHUTZ: Tests nur mit künstlichen Werten; keine Live-Beträge/-Namen in Commits, Ledger, Chat.
  • Decimal statt float für alle Beträge (auch Toleranzrechnung).
  • Deutsch für GUI-Texte, Meldungen, Commits; Datum TT.MM.JJJJ.
  • UX-Regel: Bedienelemente sichtbar; Warnung per Dialog mit expliziter Bestätigung, sonst bestehender Fehler-Alert.
  • Fable-Testagent-Gate je Plan-Task.