# 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 (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": "", "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 `` 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 `⚠ zählt evtl. doppelt mit Kredit „Y"` 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.