Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
12 KiB
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"inmodels/views.py;routers/scenarios.py::_check_reserved_nameprüft nur diesen einen Namen.services/planning_chart.py:KREDITRATEN = "Kreditraten".routers/categories.py:create_category/patch_categoryohne Namensreservierung.routers/planning.py:create_recurring/patch_recurring,create_loan/patch_loanohne Querprüfung. Schemas:RecurringIn(Basis vonRecurringOut),RecurringPatch,LoanIn(Basis vonLoanOut),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: Skillfinanz-apidokumentiertPOST /api/recurringundPOST /api/loans, Skillfinanzberatunglegt 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_nameentfällt, stattdessencheck_reserved_nameincreate_scenarioundpatch_scenario(gleiche Stellen wie bisher).routers/categories.py:check_reserved_name(data.name)als erste Zeile increate_categoryundpatch_category.- Grafana-Panel 5 zeigt ab dem nächsten Seitenaufruf „System-Kreditraten"
(Tabelle
planning_monthlywird 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 Zinsenprincipal × 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:
- Posten-Rhythmus
monthlyund Posten-Betrag< 0; - Kredit hat eine reguläre Rate
r(nichtNone); abs(abs(betrag) − r) ≤ max(TOLERANZ_MIN, r × TOLERANZ_PROZENT / 100);- 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 inRecurringOut/LoanOut, nicht persistiert, beim Anlegen der ORM-Objekte ausmodel_dumpausgeschlossen. - POST
/api/recurringbzw./api/loans: vor dem Speichernconflicts_for_recurring/conflicts_for_loanfü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 409session.rollback(). - 409-Antwort (
HTTPException(409, detail={...})):Beträge als Strings mit Punkt-Dezimal (API-Konvention).{"detail": {"code": "doppelzaehlung_kredit", "meldung": "<deutscher Erklärtext>", "konflikte": [{"posten": "...", "posten_betrag": "-312.50", "kredit": "...", "kredit_rate": "312.50"}]}} - 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(), keinalert. Alle anderen Fehler wie bisher peralert.- „Trotzdem speichern": setzt am auslösenden Formular
data-doppelzaehlung-bestaetigt="1"und löst es erneut aus (htmx.trigger(form, 'submit')); diejson-form-Extension ergänzt bei gesetztem Attributdoppelzaehlung_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_pageermitteltall_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 bekommtdata-sort="{{ name }}", damit der Hinweis die Sortierung nicht beeinflusst.- CSS
.warn-hintdezent (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-Antwortdoppelzaehlung_kreditund das Felddoppelzaehlung_bestaetigtergänzen, mit der Regel: Flag nur nach ausdrücklicher Rückfrage beim Nutzer setzen; Reservierung „System-" für Kategorien/Szenarien erwähnen.finance/VERSION0.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.