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

250 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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):
```python
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.
```python
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={...})`):
```json
{"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.