From 5a8e05596f859c8daea447e8cf7dfa91594ab6e7a6ce1dc7625fb7ef594b1b29 Mon Sep 17 00:00:00 2001 From: wlfb Date: Mon, 28 Sep 2026 21:58:54 +0200 Subject: [PATCH] docs: Plan Ausbaustufe 12 (6 Tasks) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../plans/2026-09-28-ausbaustufe-12.md | 1068 +++++++++++++++++ 1 file changed, 1068 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-28-ausbaustufe-12.md diff --git a/docs/superpowers/plans/2026-09-28-ausbaustufe-12.md b/docs/superpowers/plans/2026-09-28-ausbaustufe-12.md new file mode 100644 index 0000000..d61fd3d --- /dev/null +++ b/docs/superpowers/plans/2026-09-28-ausbaustufe-12.md @@ -0,0 +1,1068 @@ +# Ausbaustufe 12: System-Präfix + Doppelzählungs-Warnung — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Namen mit Präfix „System-" sind für Tool-Serien reserviert (Kreditraten-Serie heißt „System-Kreditraten"); das Tool erkennt mögliche Doppelzählungen Kredit ↔ wiederkehrender Posten, blockiert das Speichern mit 409 und erlaubt es erst nach expliziter Bestätigung in einem Warnfenster; die Planung-Seite markiert bestehende Doppelzählungen dauerhaft. + +**Architecture:** `app/reserved.py` ist die einzige Quelle für den Präfix. `app/services/double_count.py` enthält die reine Erkennungslogik (Decimal, Wertobjekte). `routers/planning.py` prüft bei POST/PATCH von Posten und Krediten und antwortet mit strukturiertem 409, außer die Anfrage trägt `doppelzaehlung_bestaetigt: true`. `static/doppelzaehlung.js` fängt dieses 409 im globalen htmx-Fehlerhandler ab, zeigt einen `` und sendet das Formular nach Bestätigung erneut mit Flag. + +**Tech Stack:** FastAPI, Pydantic v2, SQLAlchemy 2.0, Jinja2, htmx 2, Vanilla-JS (ES5, ``), Node-Tests per Podman. + +**Spec:** `docs/superpowers/specs/2026-09-28-ausbaustufe-12-design.md` + +## Global Constraints + +- **DATENSCHUTZ:** `tests/fixtures/*` = echte Kontodaten, nie lesen/committen/zitieren. Tests nur mit künstlichen Werten; keine Live-Beträge/-Namen in Commits, Ledger, Chat. +- **Geld = `decimal.Decimal`**, auch in der Toleranzrechnung (Ausnahme: reine Anzeige-Formatierung im Browser-JS). +- **Deutsch** für GUI-Texte, Meldungen, Kommentare, Commit-Messages; Datum TT.MM.JJJJ. +- **Präfix:** `SYSTEM_PREFIX = "System-"`; Prüfung `strip().casefold().startswith("system-")`; 422-Meldung exakt „Namen, die mit »System-« beginnen, sind für vom Tool erzeugte Einträge reserviert". +- **Toleranz:** `TOLERANZ_PROZENT = Decimal("3")`, `TOLERANZ_MIN = Decimal("1.00")`; Konflikt wenn `abs(abs(betrag) − rate) ≤ max(TOLERANZ_MIN, rate × 3 / 100)`. +- **409-Code:** `"doppelzaehlung_kredit"`; Anfragefeld `doppelzaehlung_bestaetigt: bool = False` nur in Request-Modellen, nie in `RecurringOut`/`LoanOut`, nie persistiert. +- **UX-Regel:** Formulare bleiben sichtbar; Warnung per `` mit „Abbrechen" (autofocus) und „Trotzdem speichern"; alle anderen Fehler weiter per bestehendem `alert`. +- **Test-Suite grün:** `cd finance && .venv/bin/python -m pytest -q` — Basis **260 passed, 3 skipped**; jede Task erhöht die Zahl. +- **JS-Tests** laufen auf dem Host nicht (kein node); Gate per `podman run … docker.io/library/node:22-alpine` mit jsdom, Dateien explizit übergeben (kein Verzeichnis-Argument). +- **Fable-Testagent-Gate je Task** (CLAUDE.md), Implementer committet, Fable prüft danach. +- **Release v0.12.0** (`finance/VERSION`), Backup + Redeploy + Live-Smoke erst nach dem Whole-Branch-Review. + +## Review Focus + +1. **Reine Namensänderung** an einem bereits (bestätigt) doppelten Posten/Kredit darf **nicht** erneut 409 liefern — Test in Task 3 (`test_patch_nur_name_bei_bestehendem_konflikt_ohne_flag`). +2. **„Abbrechen"** im Warnfenster darf nichts speichern und kein Flag setzen; Eingaben bleiben — DOM-Test in Task 4 (`Abbrechen speichert nicht`). +3. **Flag darf nicht „kleben":** nach dem bestätigten Request muss `data-doppelzaehlung-bestaetigt` wieder entfernt sein, sonst würde ein späteres Speichern desselben Formulars die Warnung stillschweigend umgehen — DOM-Test in Task 4 (`Flag nach Request entfernt`). +4. **Toleranzgrenzen exakt** (Decimal, inkl. Mindesttoleranz 1,00 € und endfällig mit 0 % Zins → nie Konflikt) — Tests in Task 2. +5. **„Vorschlag übernehmen"** muss denselben Warnweg nehmen (POST `/api/recurring` über json-form) — Template-Test in Task 4 (Formular ist `hx-ext="json-form"` + `hx-post="/api/recurring"`), API-Test in Task 3 deckt den Endpunkt. + +--- + +### Task 1: Reservierter Präfix „System-" + +**Files:** +- Create: `finance/app/reserved.py` +- Modify: `finance/app/models/views.py` (Konstante `RESERVED_SCENARIO_NAME`) +- Modify: `finance/app/services/planning_chart.py` (Konstante `KREDITRATEN`, Docstring) +- Modify: `finance/app/routers/scenarios.py` (`_check_reserved_name` ersetzen) +- Modify: `finance/app/routers/categories.py` (`create_category`, `patch_category`) +- Create: `finance/tests/test_reserved.py` + +**Interfaces:** +- Produces: `app.reserved.SYSTEM_PREFIX: str`, `app.reserved.RESERVED_MESSAGE: str`, `app.reserved.is_reserved_name(name: str) -> bool`, `app.reserved.check_reserved_name(name: str | None) -> None` (wirft `HTTPException(422, RESERVED_MESSAGE)`). + +- [ ] **Step 1: Failing Tests** — `finance/tests/test_reserved.py`: + +```python +import pytest + +from app.models.views import RESERVED_SCENARIO_NAME +from app.reserved import SYSTEM_PREFIX, is_reserved_name +from app.services.planning_chart import KREDITRATEN + +H = {"Authorization": "Bearer test-key"} + + +def test_systemnamen_leiten_sich_vom_praefix_ab(): + assert SYSTEM_PREFIX == "System-" + assert RESERVED_SCENARIO_NAME == "System-Ist" + assert KREDITRATEN == "System-Kreditraten" + + +@pytest.mark.parametrize("name,expected", [ + ("System-X", True), ("system-kreditraten", True), (" SYSTEM-Ist ", True), + ("Kreditraten", False), ("Systemkosten", False), ("Ist-System", False), + ("System", False), +]) +def test_is_reserved_name(name, expected): + assert is_reserved_name(name) is expected + + +@pytest.mark.parametrize("name", ["System-X", "system-kreditraten", " SYSTEM-Ist "]) +def test_kategorie_post_reserviert(client, name): + r = client.post("/api/categories", headers=H, json={"name": name}) + assert r.status_code == 422 + assert r.json()["detail"] == ("Namen, die mit »System-« beginnen, sind für " + "vom Tool erzeugte Einträge reserviert") + + +def test_kategorie_patch_reserviert(client): + cat = client.post("/api/categories", headers=H, json={"name": "Normal"}).json() + r = client.patch(f"/api/categories/{cat['id']}", headers=H, json={"name": "System-Test"}) + assert r.status_code == 422 + names = [c["name"] for c in client.get("/api/categories", headers=H).json()] + assert "Normal" in names and "System-Test" not in names + + +@pytest.mark.parametrize("name", ["Kreditraten", "Systemkosten", "Ist-System"]) +def test_kategorie_normale_namen_erlaubt(client, name): + assert client.post("/api/categories", headers=H, json={"name": name}).status_code == 201 + + +@pytest.mark.parametrize("name", ["System-Plan", "system-x"]) +def test_szenario_praefix_reserviert(client, name): + r = client.post("/api/scenarios", headers=H, json={"name": name}) + assert r.status_code == 422 + assert "reserviert" in r.json()["detail"] +``` + +- [ ] **Step 2: Tests laufen lassen — müssen fehlschlagen** + +Run: `cd finance && .venv/bin/python -m pytest -q tests/test_reserved.py` +Expected: FAIL (`ModuleNotFoundError: app.reserved`). + +- [ ] **Step 3: `finance/app/reserved.py`** + +```python +"""Reservierte Namen (Ausbaustufe 12). + +Alles, was mit SYSTEM_PREFIX beginnt, gehört dem Tool: synthetische Serien +wie „System-Ist" (v_projection) oder „System-Kreditraten" (Planungs-Panel). +Kategorien und Szenarien dürfen solche Namen nicht tragen, sonst verschmelzen +sie in Grafana mit den Tool-Serien. +""" +from fastapi import HTTPException + +SYSTEM_PREFIX = "System-" +RESERVED_MESSAGE = (f"Namen, die mit »{SYSTEM_PREFIX}« beginnen, sind für " + "vom Tool erzeugte Einträge reserviert") + + +def is_reserved_name(name: str) -> bool: + """True, wenn name (ohne Randleerzeichen, Groß/klein egal) mit dem + System-Präfix beginnt.""" + return name.strip().casefold().startswith(SYSTEM_PREFIX.casefold()) + + +def check_reserved_name(name: str | None) -> None: + """None (Feld nicht gesetzt) -> keine Prüfung; reserviert -> 422.""" + if name is not None and is_reserved_name(name): + raise HTTPException(422, RESERVED_MESSAGE) +``` + +- [ ] **Step 4: Konstanten ableiten** + +`finance/app/models/views.py`: Import `from app.reserved import SYSTEM_PREFIX` und +```python +RESERVED_SCENARIO_NAME = f"{SYSTEM_PREFIX}Ist" +``` +(Kommentar darüber anpassen: „Für Szenarien reserviert über den System-Präfix, siehe app/reserved.py".) + +`finance/app/services/planning_chart.py`: Import `from app.reserved import SYSTEM_PREFIX`, `KREDITRATEN = f"{SYSTEM_PREFIX}Kreditraten"`; im Modul-Docstring „Kreditraten" → „System-Kreditraten". + +- [ ] **Step 5: Router** + +`finance/app/routers/scenarios.py`: Import `RESERVED_SCENARIO_NAME` und die Funktion `_check_reserved_name` entfernen; `from app.reserved import check_reserved_name`; die beiden Aufrufe `_check_reserved_name(...)` → `check_reserved_name(...)` (gleiche Stellen: erste Zeile `create_scenario`, nach `model_dump` in `patch_scenario`). + +`finance/app/routers/categories.py`: `from app.reserved import check_reserved_name`; als erste Zeile in `create_category` und `patch_category`: `check_reserved_name(data.name)`. + +- [ ] **Step 6: Tests grün + Gesamtsuite** + +Run: `cd finance && .venv/bin/python -m pytest -q` +Expected: 260 + 17 = **277 passed, 3 skipped** (bestehende System-Ist-Tests in `test_crud_api.py` bleiben grün, ihre Meldung enthält „reserviert"). + +- [ ] **Step 7: Fable-Gate** — Faktencheck: Präfix nur in `reserved.py` als Literal (`grep -rn '"System-' app/` → nur `reserved.py`), View-SQL rendert weiterhin `'System-Ist'`, alle Namens-Schreibwege für Kategorien/Szenarien geprüft, keine Bestandsdaten betroffen. + +- [ ] **Step 8: Commit** + +```bash +git add finance/app/reserved.py finance/app/models/views.py finance/app/services/planning_chart.py \ + finance/app/routers/scenarios.py finance/app/routers/categories.py finance/tests/test_reserved.py +git commit -m "feat: Präfix System- für Tool-Serien reserviert, Kreditraten-Serie heißt System-Kreditraten (A12-T1)" +``` + +--- + +### Task 2: Erkennungslogik `double_count.py` + +**Files:** +- Create: `finance/app/services/double_count.py` +- Create: `finance/tests/test_double_count.py` + +**Interfaces:** +- Consumes: `app.engine.loans.annuity_payment`, `app.engine.loans.add_months`. +- Produces (Task 3/4 nutzen exakt diese Namen): + - `CODE = "doppelzaehlung_kredit"`, `TOLERANZ_PROZENT`, `TOLERANZ_MIN` + - `@dataclass(frozen=True) PostenDaten(id, name, amount, rhythm, start_date, end_date)` + - `@dataclass(frozen=True) KreditDaten(id, name, principal, annual_rate_pct, term_months, payout_date, repayment_type)` + - `@dataclass(frozen=True) Konflikt(posten_id, posten_name, posten_betrag, kredit_id, kredit_name, kredit_rate)` mit Property `paar -> (posten_id, kredit_id)` + - `posten_aus(item: RecurringItem) -> PostenDaten`, `kredit_aus(loan: Loan) -> KreditDaten` + - `regular_rate(k: KreditDaten) -> Decimal | None`, `rate_period(k: KreditDaten) -> tuple[date, date]` + - `find_conflict(p: PostenDaten, k: KreditDaten) -> Konflikt | None` + - `conflicts_for_recurring(session, p: PostenDaten) -> list[Konflikt]` + - `conflicts_for_loan(session, k: KreditDaten) -> list[Konflikt]` + - `all_conflicts(session) -> list[Konflikt]` (sortiert nach Posten-, dann Kreditname) +- Abweichung ggü. Spec: `exclude_id` entfällt — die Prüfung ist typübergreifend (Posten gegen Kredite), ein Datensatz kann nicht mit sich selbst kollidieren. + +- [ ] **Step 1: Failing Tests** — `finance/tests/test_double_count.py`: + +```python +from datetime import date +from decimal import Decimal + +import pytest + +from app.models.tables import Loan, RecurringItem +from app.services.double_count import (KreditDaten, PostenDaten, all_conflicts, + conflicts_for_loan, conflicts_for_recurring, + find_conflict, rate_period, regular_rate) + +D = Decimal +# 1.200 € zu 0 % auf 12 Monate, Auszahlung 15.01.2026 -> Rate 100,00; +# erste Rate 15.02.2026, letzte 15.01.2027. +K100 = KreditDaten(1, "Auto", D("1200.00"), D("0"), 12, date(2026, 1, 15), "annuity") + + +def P(amount, rhythm="monthly", start=None, end=None, name="Rate"): + return PostenDaten(7, name, D(amount), rhythm, start, end) + + +def test_regular_rate_und_zeitraum(): + assert regular_rate(K100) == D("100.00") + assert rate_period(K100) == (date(2026, 2, 15), date(2027, 1, 15)) + annu = KreditDaten(2, "A", D("5000.00"), D("6.0"), 48, date(2026, 1, 1), "annuity") + assert regular_rate(annu) == D("117.43") + bullet = KreditDaten(3, "B", D("12000.00"), D("6.0"), 24, date(2026, 1, 1), "bullet") + assert regular_rate(bullet) == D("60.00") + bullet0 = KreditDaten(4, "B0", D("12000.00"), D("0"), 24, date(2026, 1, 1), "bullet") + assert regular_rate(bullet0) is None + + +@pytest.mark.parametrize("amount,hit", [ + ("-100.00", True), ("-103.00", True), ("-103.01", False), + ("-97.00", True), ("-96.99", False), +]) +def test_toleranz_drei_prozent(amount, hit): + assert (find_conflict(P(amount), K100) is not None) is hit + + +@pytest.mark.parametrize("amount,hit", [("-21.00", True), ("-21.01", False), ("-19.00", True)]) +def test_mindesttoleranz_ein_euro(amount, hit): + k20 = KreditDaten(5, "Klein", D("240.00"), D("0"), 12, date(2026, 1, 15), "annuity") + assert (find_conflict(P(amount), k20) is not None) is hit + + +def test_endfaellig_zinsrate_und_nullzins(): + bullet = KreditDaten(3, "B", D("12000.00"), D("6.0"), 24, date(2026, 1, 1), "bullet") + assert find_conflict(P("-60.00"), bullet) is not None + bullet0 = KreditDaten(4, "B0", D("12000.00"), D("0"), 24, date(2026, 1, 1), "bullet") + assert find_conflict(P("-60.00"), bullet0) is None + assert find_conflict(P("-0.50"), bullet0) is None + + +def test_nur_monatliche_ausgaben(): + assert find_conflict(P("-100.00", rhythm="quarterly"), K100) is None + assert find_conflict(P("100.00"), K100) is None + + +@pytest.mark.parametrize("start,end,hit", [ + (None, date(2026, 2, 14), False), (None, date(2026, 2, 15), True), + (date(2027, 1, 16), None, False), (date(2027, 1, 15), None, True), + (None, None, True), (date(2025, 1, 1), date(2030, 1, 1), True), +]) +def test_zeitraeume(start, end, hit): + assert (find_conflict(P("-100.00", start=start, end=end), K100) is not None) is hit + + +def test_konflikt_inhalt(): + k = find_conflict(P("-101.50", name="Rate Auto"), K100) + assert (k.posten_id, k.posten_name, k.posten_betrag) == (7, "Rate Auto", D("-101.50")) + assert (k.kredit_id, k.kredit_name, k.kredit_rate) == (1, "Auto", D("100.00")) + assert k.paar == (7, 1) + + +def _seed(db): + db.add_all([ + Loan(name="Auto", principal=D("1200.00"), annual_rate_pct=D("0"), term_months=12, + payout_date=date(2026, 1, 15), repayment_type="annuity"), + Loan(name="Möbel", principal=D("240.00"), annual_rate_pct=D("0"), term_months=12, + payout_date=date(2026, 1, 15), repayment_type="annuity"), + RecurringItem(name="Rate Auto", amount=D("-100.00"), rhythm="monthly", due_day=15), + RecurringItem(name="Rate Möbel", amount=D("-20.00"), rhythm="monthly", due_day=15), + RecurringItem(name="Miete", amount=D("-600.00"), rhythm="monthly", due_day=1), + ]) + db.commit() + + +def test_all_conflicts_findet_paare_sortiert(db): + _seed(db) + assert [(k.posten_name, k.kredit_name) for k in all_conflicts(db)] == [ + ("Rate Auto", "Auto"), ("Rate Möbel", "Möbel")] + + +def test_conflicts_for_recurring_und_loan(db): + _seed(db) + neu = PostenDaten(None, "Neu", D("-99.00"), "monthly", None, None) + assert [k.kredit_name for k in conflicts_for_recurring(db, neu)] == ["Auto"] + kredit = KreditDaten(None, "Neu", D("7200.00"), D("0"), 12, date(2026, 1, 1), "annuity") + assert [k.posten_name for k in conflicts_for_loan(db, kredit)] == ["Miete"] +``` + +- [ ] **Step 2: Tests laufen lassen — müssen fehlschlagen** + +Run: `cd finance && .venv/bin/python -m pytest -q tests/test_double_count.py` +Expected: FAIL (`ModuleNotFoundError`). + +- [ ] **Step 3: `finance/app/services/double_count.py`** + +```python +"""Doppelzählungs-Erkennung Kredit ↔ wiederkehrender Posten (Ausbaustufe 12). + +Ein monatlicher Ausgabe-Posten gilt als vermutlich doppelt zu einem Kredit, +wenn sein Betrag innerhalb der Toleranz der regulären Monatsrate liegt und +sich die Zeiträume überschneiden. Bewusst kein Namensvergleich +(„Autokredit" vs. „Rate VW Bank" ist nicht zuverlässig zuordenbar). +Reine Logik auf Wertobjekten, damit auch noch nicht gespeicherte bzw. per +PATCH veränderte Zustände geprüft werden können. Alle Beträge Decimal. +""" +from dataclasses import dataclass +from datetime import date +from decimal import ROUND_HALF_UP, Decimal + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.engine.loans import add_months, annuity_payment +from app.models.tables import Loan, RecurringItem + +CODE = "doppelzaehlung_kredit" +TOLERANZ_PROZENT = Decimal("3") +TOLERANZ_MIN = Decimal("1.00") +CENT = Decimal("0.01") + + +@dataclass(frozen=True) +class PostenDaten: + id: int | None + name: str + amount: Decimal + rhythm: str + start_date: date | None + end_date: date | None + + +@dataclass(frozen=True) +class KreditDaten: + id: int | None + name: str + principal: Decimal + annual_rate_pct: Decimal + term_months: int + payout_date: date + repayment_type: str + + +@dataclass(frozen=True) +class Konflikt: + posten_id: int | None + posten_name: str + posten_betrag: Decimal + kredit_id: int | None + kredit_name: str + kredit_rate: Decimal + + @property + def paar(self) -> tuple[int | None, int | None]: + return (self.posten_id, self.kredit_id) + + +def posten_aus(item: RecurringItem) -> PostenDaten: + return PostenDaten(item.id, item.name, Decimal(item.amount), item.rhythm, + item.start_date, item.end_date) + + +def kredit_aus(loan: Loan) -> KreditDaten: + return KreditDaten(loan.id, loan.name, Decimal(loan.principal), + Decimal(loan.annual_rate_pct), loan.term_months, + loan.payout_date, loan.repayment_type) + + +def regular_rate(k: KreditDaten) -> Decimal | None: + """Reguläre Monatsrate (positiv): Annuität bzw. bei endfälligen Krediten + die monatlichen Zinsen. Ohne reguläre Rate (0 % endfällig, Laufzeit <= 0) + -> None, dann nie Konflikt. Die Schlusszahlung wird nicht betrachtet.""" + if k.term_months <= 0: + return None + if k.repayment_type == "bullet": + if k.annual_rate_pct == 0: + return None + return (k.principal * k.annual_rate_pct / Decimal(100) / Decimal(12) + ).quantize(CENT, ROUND_HALF_UP) + return annuity_payment(k.principal, k.annual_rate_pct, k.term_months) + + +def rate_period(k: KreditDaten) -> tuple[date, date]: + """Erste und letzte Rate, identisch zu engine/loans.loan_schedule.""" + return add_months(k.payout_date, 1), add_months(k.payout_date, k.term_months) + + +def find_conflict(p: PostenDaten, k: KreditDaten) -> Konflikt | None: + if p.rhythm != "monthly" or p.amount >= 0: + return None + rate = regular_rate(k) + if rate is None or rate <= 0: + return None + toleranz = max(TOLERANZ_MIN, rate * TOLERANZ_PROZENT / Decimal(100)) + if abs(-p.amount - rate) > toleranz: + return None + erste, letzte = rate_period(k) + if p.start_date is not None and p.start_date > letzte: + return None + if p.end_date is not None and p.end_date < erste: + return None + return Konflikt(p.id, p.name, p.amount, k.id, k.name, rate) + + +def conflicts_for_recurring(session: Session, p: PostenDaten) -> list[Konflikt]: + loans = session.execute(select(Loan).order_by(Loan.name, Loan.id)).scalars() + return [c for c in (find_conflict(p, kredit_aus(l)) for l in loans) if c] + + +def conflicts_for_loan(session: Session, k: KreditDaten) -> list[Konflikt]: + items = session.execute(select(RecurringItem) + .order_by(RecurringItem.name, RecurringItem.id)).scalars() + return [c for c in (find_conflict(posten_aus(r), k) for r in items) if c] + + +def all_conflicts(session: Session) -> list[Konflikt]: + kredite = [kredit_aus(l) for l in session.execute( + select(Loan).order_by(Loan.name, Loan.id)).scalars()] + out: list[Konflikt] = [] + for r in session.execute(select(RecurringItem) + .order_by(RecurringItem.name, RecurringItem.id)).scalars(): + p = posten_aus(r) + out.extend(c for c in (find_conflict(p, k) for k in kredite) if c) + return out +``` + +- [ ] **Step 4: Tests grün + Gesamtsuite** + +Run: `cd finance && .venv/bin/python -m pytest -q` +Expected: 277 + 20 = **297 passed, 3 skipped** (1 + 5 + 3 + 1 + 1 + 6 + 1 + 1 + 1). + +- [ ] **Step 5: Fable-Gate** — unabhängige Handrechnung: je ein Annuitätenkredit mit Zins (eigene Werte), endfällig mit Zins, Grenzfälle der Toleranz auf den Cent, Zeitraum-Grenzen an erster/letzter Rate (inkl. Monatsende-Clamp, z.B. Auszahlung 31.01.) gegen `find_conflict`; Decimal-Durchgängigkeit (kein float). + +- [ ] **Step 6: Commit** + +```bash +git add finance/app/services/double_count.py finance/tests/test_double_count.py +git commit -m "feat: Erkennung möglicher Doppelzählung Kredit/Fixposten (A12-T2)" +``` + +--- + +### Task 3: API — 409 bei Doppelzählung, Bestätigungsfeld + +**Files:** +- Modify: `finance/app/routers/planning.py` (Schemas, `create_recurring`, `patch_recurring`, `create_loan`, `patch_loan`) +- Create: `finance/tests/test_double_count_api.py` + +**Interfaces:** +- Consumes (Task 2): `CODE`, `PostenDaten`, `KreditDaten`, `Konflikt`, `posten_aus`, `kredit_aus`, `conflicts_for_recurring`, `conflicts_for_loan`. +- Produces: 409-Body `{"detail": {"code": "doppelzaehlung_kredit", "meldung": str, "konflikte": [{"posten", "posten_betrag", "kredit", "kredit_rate"}]}}` (Beträge als Strings, Punkt-Dezimal, 2 Nachkommastellen). Request-Feld `doppelzaehlung_bestaetigt` (Task 4 sendet es). + +- [ ] **Step 1: Failing Tests** — `finance/tests/test_double_count_api.py`: + +```python +from decimal import Decimal + +H = {"Authorization": "Bearer test-key"} +KREDIT = {"name": "Auto", "principal": "1200.00", "annual_rate_pct": "0", + "term_months": 12, "payout_date": "2026-01-15", "repayment_type": "annuity"} +POSTEN = {"name": "Rate Auto", "amount": "-100.00", "rhythm": "monthly", "due_day": 15} + + +def _count(client, path): + return len(client.get(path, headers=H).json()) + + +def test_posten_zu_bestehendem_kredit_409(client): + assert client.post("/api/loans", headers=H, json=KREDIT).status_code == 201 + r = client.post("/api/recurring", headers=H, json=POSTEN) + assert r.status_code == 409 + d = r.json()["detail"] + assert d["code"] == "doppelzaehlung_kredit" + assert "doppelt" in d["meldung"] + assert d["konflikte"] == [{"posten": "Rate Auto", "posten_betrag": "-100.00", + "kredit": "Auto", "kredit_rate": "100.00"}] + assert _count(client, "/api/recurring") == 0 + + +def test_posten_mit_bestaetigung_gespeichert(client): + client.post("/api/loans", headers=H, json=KREDIT) + r = client.post("/api/recurring", headers=H, + json={**POSTEN, "doppelzaehlung_bestaetigt": True}) + assert r.status_code == 201 + assert "doppelzaehlung_bestaetigt" not in r.json() + assert _count(client, "/api/recurring") == 1 + + +def test_kredit_zu_bestehendem_posten_409_und_bestaetigung(client): + client.post("/api/recurring", headers=H, json=POSTEN) + r = client.post("/api/loans", headers=H, json=KREDIT) + assert r.status_code == 409 + assert r.json()["detail"]["konflikte"][0]["posten"] == "Rate Auto" + assert _count(client, "/api/loans") == 0 + r = client.post("/api/loans", headers=H, json={**KREDIT, "doppelzaehlung_bestaetigt": True}) + assert r.status_code == 201 + assert "doppelzaehlung_bestaetigt" not in r.json() + + +def test_ohne_konflikt_unveraendert(client): + client.post("/api/loans", headers=H, json=KREDIT) + r = client.post("/api/recurring", headers=H, json={**POSTEN, "amount": "-50.00"}) + assert r.status_code == 201 + + +def test_patch_posten_erzeugt_konflikt_409_ohne_aenderung(client): + client.post("/api/loans", headers=H, json=KREDIT) + item = client.post("/api/recurring", headers=H, json={**POSTEN, "amount": "-50.00"}).json() + r = client.patch(f"/api/recurring/{item['id']}", headers=H, json={"amount": "-100.00"}) + assert r.status_code == 409 + stored = client.get("/api/recurring", headers=H).json()[0] + assert Decimal(stored["amount"]) == Decimal("-50.00") + r = client.patch(f"/api/recurring/{item['id']}", headers=H, + json={"amount": "-100.00", "doppelzaehlung_bestaetigt": True}) + assert r.status_code == 200 + assert Decimal(r.json()["amount"]) == Decimal("-100.00") + + +def test_patch_nur_name_bei_bestehendem_konflikt_ohne_flag(client): + client.post("/api/loans", headers=H, json=KREDIT) + item = client.post("/api/recurring", headers=H, + json={**POSTEN, "doppelzaehlung_bestaetigt": True}).json() + r = client.patch(f"/api/recurring/{item['id']}", headers=H, json={"name": "Rate Auto neu"}) + assert r.status_code == 200 + loan = client.get("/api/loans", headers=H).json()[0] + r = client.patch(f"/api/loans/{loan['id']}", headers=H, json={"name": "Auto neu"}) + assert r.status_code == 200 + + +def test_patch_kredit_erzeugt_konflikt_409(client): + client.post("/api/recurring", headers=H, json=POSTEN) + loan = client.post("/api/loans", headers=H, json={**KREDIT, "principal": "2400.00"}).json() + r = client.patch(f"/api/loans/{loan['id']}", headers=H, json={"principal": "1200.00"}) + assert r.status_code == 409 + assert Decimal(client.get("/api/loans", headers=H).json()[0]["principal"]) == Decimal("2400.00") + r = client.patch(f"/api/loans/{loan['id']}", headers=H, + json={"principal": "1200.00", "doppelzaehlung_bestaetigt": True}) + assert r.status_code == 200 + + +def test_vorschlag_uebernehmen_nutzt_denselben_endpunkt(client): + # „Vorschlag übernehmen" sendet dieselben Felder (inkl. start_date/category_id=null). + client.post("/api/loans", headers=H, json=KREDIT) + r = client.post("/api/recurring", headers=H, json={ + **POSTEN, "start_date": None, "category_id": None}) + assert r.status_code == 409 +``` + +- [ ] **Step 2: Tests laufen lassen — müssen fehlschlagen** + +Run: `cd finance && .venv/bin/python -m pytest -q tests/test_double_count_api.py` +Expected: FAIL (201 statt 409). + +- [ ] **Step 3: Implementierung in `routers/planning.py`** + +Imports: +```python +from app.services.double_count import (CODE as DOPPELZAEHLUNG_CODE, Konflikt, + KreditDaten, PostenDaten, + conflicts_for_loan, conflicts_for_recurring, + kredit_aus, posten_aus) +``` + +Schemas (nach den bestehenden Klassen): +```python +class RecurringCreate(RecurringIn): + # Nur Anfrage: bestätigt eine erkannte Doppelzählung (nicht gespeichert). + doppelzaehlung_bestaetigt: bool = False +``` +`RecurringPatch` bekommt `doppelzaehlung_bestaetigt: bool = False`. +```python +class LoanCreate(LoanIn): + doppelzaehlung_bestaetigt: bool = False +``` +`LoanPatch` bekommt `doppelzaehlung_bestaetigt: bool = False`. + +Helper (vor den Recurring-Routen): +```python +_CENT = Decimal("0.01") +_FLAG = "doppelzaehlung_bestaetigt" + + +def _doppelzaehlung(konflikte: list[Konflikt]) -> HTTPException: + """409 mit strukturierter Erklärung; die GUI zeigt daraus das Warnfenster.""" + return HTTPException(409, detail={ + "code": DOPPELZAEHLUNG_CODE, + "meldung": ("Der Betrag entspricht vermutlich einer Kreditrate, die bereits " + "über „Kredite“ eingeplant ist (oder umgekehrt). Beide zusammen " + "zählen die Rate doppelt – Planung und Szenarien wären zu " + "pessimistisch. Nur speichern, wenn das wirklich gewollt ist."), + "konflikte": [{"posten": k.posten_name, + "posten_betrag": str(k.posten_betrag.quantize(_CENT)), + "kredit": k.kredit_name, + "kredit_rate": str(k.kredit_rate.quantize(_CENT))} + for k in konflikte], + }) +``` + +`create_recurring(data: RecurringCreate, ...)`: +```python + _check_category(session, data.category_id) + fields = data.model_dump(exclude={_FLAG}) + if not data.doppelzaehlung_bestaetigt: + konflikte = conflicts_for_recurring(session, PostenDaten( + None, fields["name"], fields["amount"], fields["rhythm"], + fields["start_date"], fields["end_date"])) + if konflikte: + raise _doppelzaehlung(konflikte) + item = RecurringItem(**fields) + ... +``` + +`patch_recurring`: `fields = data.model_dump(exclude_unset=True, exclude={_FLAG})`; nach dem 404-Check und vor dem `setattr`-Loop: +```python + vorher = {k.paar for k in conflicts_for_recurring(session, posten_aus(item))} +``` +nach der bestehenden Ende-vor-Start-Prüfung, vor `session.commit()`: +```python + if not data.doppelzaehlung_bestaetigt: + neu = [k for k in conflicts_for_recurring(session, posten_aus(item)) + if k.paar not in vorher] + if neu: + session.rollback() + raise _doppelzaehlung(neu) +``` + +`create_loan(data: LoanCreate, ...)`: +```python + fields = data.model_dump(exclude={_FLAG}) + if not data.doppelzaehlung_bestaetigt: + konflikte = conflicts_for_loan(session, KreditDaten( + None, fields["name"], fields["principal"], fields["annual_rate_pct"], + fields["term_months"], fields["payout_date"], fields["repayment_type"])) + if konflikte: + raise _doppelzaehlung(konflikte) + loan = Loan(**fields) + ... +``` + +`patch_loan`: analog `patch_recurring` mit `kredit_aus(loan)` / `conflicts_for_loan`, `model_dump(exclude_unset=True, exclude={_FLAG})`. + +Hinweis: Bei neu angelegten Datensätzen ist `posten_id`/`kredit_id` in der Konflikt-Antwort `None` — die Antwort enthält bewusst nur Namen und Beträge. + +- [ ] **Step 4: Tests grün + Gesamtsuite** + +Run: `cd finance && .venv/bin/python -m pytest -q` +Expected: 297 + 8 = **305 passed, 3 skipped**; alle bestehenden Planning-/Szenario-Tests grün (z.B. `test_loan_in_scenario_keeps_balance_positive`: Miete −600 vs. Rate 117,43 → kein Konflikt). + +- [ ] **Step 5: Fable-Gate** — Live-Approximation: lokaler uvicorn gegen Wegwerf-SQLite oder Wegwerf-Postgres (`FB_DATABASE_URL`, `alembic upgrade head`), curl-Roundtrips aller vier Schreibwege mit/ohne Flag, reine Umbenennung, Konflikt-Paar-Logik bei PATCH, Antwortschemata ohne Flag-Feld, 409-Body exakt; danach Aufräumen. + +- [ ] **Step 6: Commit** + +```bash +git add finance/app/routers/planning.py finance/tests/test_double_count_api.py +git commit -m "feat: API meldet mögliche Doppelzählung mit 409, Speichern nur mit Bestätigung (A12-T3)" +``` + +--- + +### Task 4: GUI — Warnfenster + dauerhafter Hinweis + +**Files:** +- Create: `finance/app/static/doppelzaehlung.js` +- Create: `finance/tests/js/doppelzaehlung.test.js`, `finance/tests/js/doppelzaehlung.dom.test.js` +- Modify: `finance/app/templates/base.html` (Script, Dialog, Fehlerhandler, json-form-Flag) +- Modify: `finance/app/routers/gui.py` (`planung_page`) +- Modify: `finance/app/templates/planning.html` (Namens-Zellen Posten/Kredite) +- Modify: `finance/app/static/style.css` (`.warn-hint`, Dialog) +- Test: `finance/tests/test_gui.py` + +**Interfaces:** +- Consumes: 409-Body aus Task 3; `all_conflicts` aus Task 2; `tests/test_sort_js.py` sammelt `tests/js/*.test.js` automatisch (keine Änderung nötig). +- Produces (JS, `window.FBDoppel` bzw. `module.exports`): `parseDetail(xhr) -> detail|null`, `konfliktText(k) -> string`, `handle(evt) -> bool`, `bestaetigen(dlg)`, `abbrechen(dlg)`, `init(doc)`. + +- [ ] **Step 1: Failing JS-Unit-Tests** — `finance/tests/js/doppelzaehlung.test.js`: + +```js +'use strict'; +const test = require('node:test'); +const assert = require('node:assert'); +const D = require('../../app/static/doppelzaehlung.js'); + +const body = { detail: { code: 'doppelzaehlung_kredit', meldung: 'M', + konflikte: [{ posten: 'Rate Auto', posten_betrag: '-1234.50', kredit: 'Auto', kredit_rate: '1234.50' }] } }; + +test('parseDetail erkennt nur 409 mit Code', () => { + assert.equal(D.parseDetail({ status: 409, responseText: JSON.stringify(body) }).code, 'doppelzaehlung_kredit'); + assert.equal(D.parseDetail({ status: 409, responseText: '{"detail":"Kategorie existiert bereits"}' }), null); + assert.equal(D.parseDetail({ status: 422, responseText: JSON.stringify(body) }), null); + assert.equal(D.parseDetail({ status: 409, responseText: 'kein json' }), null); + assert.equal(D.parseDetail(null), null); +}); + +test('konfliktText deutsch formatiert', () => { + assert.equal(D.konfliktText(body.detail.konflikte[0]), + 'Posten „Rate Auto“ (-1.234,50 €) ↔ Kredit „Auto“ (Rate 1.234,50 €)'); +}); +``` + +- [ ] **Step 2: Failing DOM-Tests** — `finance/tests/js/doppelzaehlung.dom.test.js`: + +```js +'use strict'; +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); + +let JSDOM = null; +try { ({ JSDOM } = require('jsdom')); } catch (e) { /* jsdom fehlt -> skip */ } +const SRC = fs.readFileSync(path.join(__dirname, '../../app/static/doppelzaehlung.js'), 'utf8'); + +const HTML = ` +
+ +

    + + +
    `; +const BODY = JSON.stringify({ detail: { code: 'doppelzaehlung_kredit', meldung: 'Erklärung', + konflikte: [{ posten: 'Rate Auto', posten_betrag: '-100.00', kredit: 'Auto', kredit_rate: '100.00' }] } }); + +function load() { + const dom = new JSDOM(`${HTML}`, { runScripts: 'outside-only' }); + const w = dom.window; + const calls = []; + w.htmx = { trigger: (el, name) => calls.push([el.id, name]) }; + const dlg = w.document.getElementById('doppelzaehlung-dialog'); + dlg.showModal = function () { this.setAttribute('open', ''); }; + dlg.close = function () { this.removeAttribute('open'); }; + w.eval(SRC); + w.FBDoppel.init(w.document); + const form = w.document.getElementById('f'); + const evt = { detail: { xhr: { status: 409, responseText: BODY }, elt: form } }; + return { w, dlg, form, evt, calls }; +} + +test('409 öffnet Dialog mit Erklärung und Konfliktliste', { skip: !JSDOM }, () => { + const { w, dlg, evt } = load(); + assert.equal(w.FBDoppel.handle(evt), true); + assert.ok(dlg.hasAttribute('open')); + assert.equal(dlg.querySelector('.dz-meldung').textContent, 'Erklärung'); + assert.equal(dlg.querySelectorAll('.dz-konflikte li').length, 1); +}); + +test('andere Fehler werden nicht abgefangen', { skip: !JSDOM }, () => { + const { w, form } = load(); + assert.equal(w.FBDoppel.handle({ detail: { xhr: { status: 422, responseText: '{}' }, elt: form } }), false); +}); + +test('Abbrechen speichert nicht', { skip: !JSDOM }, () => { + const { w, dlg, form, evt, calls } = load(); + w.FBDoppel.handle(evt); + w.document.getElementById('dz-abbrechen').click(); + assert.ok(!dlg.hasAttribute('open')); + assert.equal(calls.length, 0); + assert.ok(!form.hasAttribute('data-doppelzaehlung-bestaetigt')); + assert.equal(form.querySelector('input').value, 'Rate Auto'); +}); + +test('Trotzdem speichern sendet erneut mit Flag', { skip: !JSDOM }, () => { + const { w, dlg, form, evt, calls } = load(); + w.FBDoppel.handle(evt); + w.document.getElementById('dz-bestaetigen').click(); + assert.ok(!dlg.hasAttribute('open')); + assert.equal(form.getAttribute('data-doppelzaehlung-bestaetigt'), '1'); + assert.deepEqual(calls, [['f', 'submit']]); +}); + +test('Flag nach Request entfernt', { skip: !JSDOM }, () => { + const { w, form, evt } = load(); + w.FBDoppel.handle(evt); + w.document.getElementById('dz-bestaetigen').click(); + const e = new w.CustomEvent('htmx:afterRequest', { bubbles: true, detail: { elt: form } }); + form.dispatchEvent(e); + assert.ok(!form.hasAttribute('data-doppelzaehlung-bestaetigt')); +}); +``` + +- [ ] **Step 3: Failing Python-Tests** — an `finance/tests/test_gui.py` anhängen: + +```python +def test_base_hat_doppelzaehlungs_dialog(client): + client.post("/login", data={"username": "admin", "password": "geheim"}) + html = client.get("/").text + assert '' in html + assert '' in html + assert 'id="dz-abbrechen" autofocus' in html + assert ">Trotzdem speichern" in html + assert "FBDoppel.handle(e)" in html + assert "doppelzaehlung_bestaetigt = true" in html + + +def test_planung_zeigt_doppelzaehlungs_hinweis(client, db): + client.post("/login", data={"username": "admin", "password": "geheim"}) + db.add_all([ + Loan(name="Auto", principal=Decimal("1200.00"), annual_rate_pct=Decimal("0"), + term_months=12, payout_date=date(2026, 1, 15), repayment_type="annuity"), + RecurringItem(name="Rate Auto", amount=Decimal("-100.00"), rhythm="monthly", due_day=15), + RecurringItem(name="Miete", amount=Decimal("-600.00"), rhythm="monthly", due_day=1), + ]) + db.commit() + html = client.get("/planung").text + assert "⚠ zählt evtl. doppelt mit Kredit „Auto“" in html + assert "⚠ zählt evtl. doppelt mit Posten „Rate Auto“" in html + assert html.count("⚠ zählt evtl. doppelt") == 2 + assert '' in html + assert '' in html + + +def test_planung_ohne_konflikt_kein_hinweis(client, db): + client.post("/login", data={"username": "admin", "password": "geheim"}) + db.add(RecurringItem(name="Miete", amount=Decimal("-600.00"), rhythm="monthly", due_day=1)) + db.commit() + assert "zählt evtl. doppelt" not in client.get("/planung").text + + +def test_vorschlag_formular_nutzt_json_form_post(client): + # Review Focus 5: „Vorschlag übernehmen" läuft über denselben Warnweg. + import re + from pathlib import Path + src = Path("app/templates/planning.html").read_text(encoding="utf-8") + assert re.search(r'class="inline-form" hx-ext="json-form" hx-post="/api/recurring"', src) +``` + +(`Loan`, `RecurringItem`, `Decimal`, `date` sind in `test_gui.py` bereits importiert — sonst ergänzen.) + +- [ ] **Step 4: Tests laufen lassen — müssen fehlschlagen** + +Run: `cd finance && .venv/bin/python -m pytest -q tests/test_gui.py -k "doppelzaehlung or vorschlag_formular"` +Expected: FAIL (Dialog/Hinweis fehlen; `test_vorschlag_formular_nutzt_json_form_post` darf bereits grün sein). + +- [ ] **Step 5: `finance/app/static/doppelzaehlung.js`** + +```js +/* Warnfenster bei möglicher Doppelzählung Kredit ↔ Fixposten (Ausbaustufe 12). + * + * Die API antwortet mit 409 und detail.code "doppelzaehlung_kredit", wenn ein + * Posten vermutlich einer Kreditrate entspricht. handle() fängt genau diesen + * Fall im globalen htmx:responseError-Handler ab (alle anderen Fehler laufen + * weiter über den alert) und zeigt den . „Trotzdem speichern" markiert + * das auslösende Formular (data-doppelzaehlung-bestaetigt="1") und sendet es + * erneut; die json-form-Extension ergänzt dann doppelzaehlung_bestaetigt=true. + * Nach jedem Request wird die Markierung entfernt, damit ein späteres + * Speichern die Warnung nicht stillschweigend umgeht. + */ +(function () { + 'use strict'; + + var CODE = 'doppelzaehlung_kredit'; + var ATTR = 'data-doppelzaehlung-bestaetigt'; + var fmt = new Intl.NumberFormat('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + + function parseDetail(xhr) { + if (!xhr || xhr.status !== 409) { return null; } + var body; + try { body = JSON.parse(xhr.responseText); } catch (e) { return null; } + var d = body && body.detail; + return d && d.code === CODE ? d : null; + } + + function eur(s) { return fmt.format(parseFloat(s)) + ' €'; } + + function konfliktText(k) { + return 'Posten „' + k.posten + '“ (' + eur(k.posten_betrag) + ') ↔ Kredit „' + + k.kredit + '“ (Rate ' + eur(k.kredit_rate) + ')'; + } + + function handle(evt) { + var d = parseDetail(evt && evt.detail && evt.detail.xhr); + var elt = evt && evt.detail && evt.detail.elt; + var form = elt && elt.closest ? elt.closest('form') : null; + if (!d || !form) { return false; } + var doc = form.ownerDocument; + var dlg = doc.getElementById('doppelzaehlung-dialog'); + if (!dlg) { return false; } + dlg.querySelector('.dz-meldung').textContent = d.meldung; + var list = dlg.querySelector('.dz-konflikte'); + list.textContent = ''; + (d.konflikte || []).forEach(function (k) { + var li = doc.createElement('li'); + li.textContent = konfliktText(k); + list.appendChild(li); + }); + dlg.fbForm = form; + dlg.showModal(); + return true; + } + + function abbrechen(dlg) { + dlg.fbForm = null; + dlg.close(); + } + + function bestaetigen(dlg) { + var form = dlg.fbForm; + dlg.fbForm = null; + dlg.close(); + if (!form) { return; } + form.setAttribute(ATTR, '1'); + form.ownerDocument.defaultView.htmx.trigger(form, 'submit'); + } + + function init(doc) { + var dlg = doc.getElementById('doppelzaehlung-dialog'); + if (!dlg) { return; } + doc.getElementById('dz-abbrechen').addEventListener('click', function () { abbrechen(dlg); }); + doc.getElementById('dz-bestaetigen').addEventListener('click', function () { bestaetigen(dlg); }); + doc.body.addEventListener('htmx:afterRequest', function (e) { + var elt = e.detail && e.detail.elt; + var form = elt && elt.closest ? elt.closest('form') : null; + if (form && form.getAttribute(ATTR) === '1') { + // Erst nach dem Request mit Flag entfernen: der 409-Request selbst + // hat die Markierung nie gesetzt. + form.removeAttribute(ATTR); + } + }); + } + + var api = { parseDetail: parseDetail, konfliktText: konfliktText, handle: handle, + abbrechen: abbrechen, bestaetigen: bestaetigen, init: init }; + if (typeof module !== 'undefined' && module.exports) { module.exports = api; } + if (typeof window !== 'undefined') { window.FBDoppel = api; } +})(); +``` + +- [ ] **Step 6: `base.html`** + +Nach ``: +```html + +``` +Direkt nach ``: +```html + +

    Mögliche Doppelzählung

    +

    +
      +

      Trotzdem speichern?

      + + +
      +``` +Fehlerhandler ersetzen: +```js + document.body.addEventListener('htmx:responseError', function (e) { + // Mögliche Doppelzählung Kredit/Fixposten: eigenes Warnfenster statt alert. + if (window.FBDoppel && window.FBDoppel.handle(e)) { return; } + alert('Fehler: ' + (e.detail.xhr.responseText || e.detail.xhr.status)); + }); +``` +In `encodeParameters` der json-form-Extension, direkt vor `xhr.overrideMimeType(...)`: +```js + // Bestätigte Doppelzählung (Warnfenster „Trotzdem speichern"): + if (elt.getAttribute && elt.getAttribute('data-doppelzaehlung-bestaetigt') === '1') { + out.doppelzaehlung_bestaetigt = true; + } +``` +Am Ende des Inline-Scripts (nach `toggleEdit`): +```js + if (window.FBDoppel) { window.FBDoppel.init(document); } +``` + +- [ ] **Step 7: Dauerhafter Hinweis** + +`routers/gui.py`: Import `from app.services.double_count import all_conflicts`; in `planung_page` vor dem `TemplateResponse`: +```python + doppelt_posten: dict[int, list[str]] = {} + doppelt_kredit: dict[int, list[str]] = {} + for k in all_conflicts(session): + doppelt_posten.setdefault(k.posten_id, []).append(k.kredit_name) + doppelt_kredit.setdefault(k.kredit_id, []).append(k.posten_name) +``` +und im Kontext `"doppelt_posten": doppelt_posten, "doppelt_kredit": doppelt_kredit,`. + +`planning.html`, Posten-Namenszelle (`{{ r.name }}` in `rec-row`): +```html + {{ r.name }}{% for kn in doppelt_posten.get(r.id, []) %}⚠ zählt evtl. doppelt mit Kredit „{{ kn }}“{% endfor %} +``` +Kredit-Namenszelle (`{{ l.name }}` in `loan-row`): +```html + {{ l.name }}{% for pn in doppelt_kredit.get(l.id, []) %}⚠ zählt evtl. doppelt mit Posten „{{ pn }}“{% endfor %} +``` + +`style.css` anhängen: +```css +/* Doppelzählung Kredit/Fixposten (Ausbaustufe 12). */ +.warn-hint { + display: block; + font-size: 0.8em; + color: #b45309; +} +#doppelzaehlung-dialog { + max-width: 32rem; +} +``` + +- [ ] **Step 8: Tests grün + Gesamtsuite** + +Run: `cd finance && .venv/bin/python -m pytest -q` +Expected: 305 + 4 = **309 passed, 3 skipped** (JS-Wrapper skippt auf dem Host). + +- [ ] **Step 9: Fable-Gate** — JS per Podman, Dateien explizit: +```bash +cd finance && podman run --rm -v "$PWD":/w:ro -w /w docker.io/library/node:22-alpine \ + sh -c 'npm i --silent --no-save --prefix /tmp/j jsdom >/dev/null 2>&1; NODE_PATH=/tmp/j/node_modules node --test tests/js/doppelzaehlung.test.js tests/js/doppelzaehlung.dom.test.js tests/js/sort.test.js tests/js/sort.dom.test.js' +``` +Erwartet: alle grün, DOM-Tests nicht geskippt. Zusätzlich: gerenderte `/planung` + echtes `htmx.min.js` + `doppelzaehlung.js` in jsdom mit gemocktem XHR (409-Antwort, danach 201) — Formular-Submit → Dialog → „Trotzdem speichern" → zweiter Request trägt `doppelzaehlung_bestaetigt: true`, dritter Submit ohne Flag. Bestehende Fehler (z.B. 422) weiter per `alert`. + +- [ ] **Step 10: Commit** + +```bash +git add finance/app/static/doppelzaehlung.js finance/app/static/style.css finance/app/templates/base.html \ + finance/app/templates/planning.html finance/app/routers/gui.py finance/tests/js/doppelzaehlung.test.js \ + finance/tests/js/doppelzaehlung.dom.test.js finance/tests/test_gui.py +git commit -m "feat: Warnfenster und Planungs-Hinweis bei möglicher Doppelzählung (A12-T4)" +``` + +--- + +### Task 5: Doku, Beratungsumgebung, Release-Commit v0.12.0 + +**Files:** +- Modify: `finance/app/templates/hilfe.html` +- Modify: `docs/ARCHITEKTUR.md` +- Modify: `finance/VERSION` +- Modify (anderes Repo): `/home/wlfb/fb/.claude/skills/finanz-api/SKILL.md` + +- [ ] **Step 1: `hilfe.html`** — im Konzepte-Abschnitt zwei kurze Absätze: (a) **Reservierte Namen:** Kategorien/Szenarien dürfen nicht mit „System-" beginnen; Tool-Serien heißen „System-Ist" und „System-Kreditraten". (b) **Doppelzählungs-Warnung:** was geprüft wird (monatlicher Ausgabe-Posten ≈ reguläre Kreditrate, ±3 %, mind. 1 €, überlappender Zeitraum), Warnfenster mit „Trotzdem speichern", dauerhafter ⚠-Hinweis in der Planung; Kreditraten am besten nur über „Kredite" pflegen. Die bestehende Nennung „Kreditraten" im Grafana-Absatz auf „System-Kreditraten" ändern. + +- [ ] **Step 2: `docs/ARCHITEKTUR.md`** — Komponenten-Diagramm: Knoten für `reserved.py` (`check_reserved_name`) und `services/double_count.py` (`all_conflicts`) mit Kanten von `planning.py`/`categories.py`/`scenarios.py` bzw. `gui.py`; Abschnitt 4 kurz: Präfix-Regel, Doppelzählungs-Check (409 `doppelzaehlung_kredit`, Feld `doppelzaehlung_bestaetigt`, Warnfenster `static/doppelzaehlung.js`). Nur Namen, die im Code existieren. + +- [ ] **Step 3: Version** — `finance/VERSION` → `0.12.0`; Suite grün. + +- [ ] **Step 4: Commit (Repo `~/bin`)** + +```bash +git add finance/app/templates/hilfe.html docs/ARCHITEKTUR.md finance/VERSION +git commit -m "chore: Release v0.12.0 (System-Präfix, Doppelzählungs-Warnung)" +``` + +- [ ] **Step 5: Beratungsumgebung** — in `/home/wlfb/fb/.claude/skills/finanz-api/SKILL.md`: + - Abschnitt „Wiederkehrende Posten" und „Kredite": Absatz „**Doppelzählungs-Schutz:** `POST`/`PATCH` antworten mit `409` und `detail.code == "doppelzaehlung_kredit"` (plus `meldung`, `konflikte`), wenn ein monatlicher Ausgabe-Posten vermutlich einer Kreditrate entspricht. Dann **den Nutzer fragen**; nur nach ausdrücklicher Zustimmung erneut mit `"doppelzaehlung_bestaetigt": true` senden. Besser: Kreditraten nur über `/api/loans` pflegen." + - Abschnitte „Szenarien" (und Kategorien, falls beschrieben): „Namen mit Präfix `System-` sind reserviert (422)." + - Commit im Repo `/home/wlfb/fb`: `git -C /home/wlfb/fb add .claude/skills/finanz-api/SKILL.md && git -C /home/wlfb/fb commit -m "docs: finanz-api – Doppelzählungs-Schutz (409) und reservierter Präfix System- (Tool v0.12.0)"` (mit Co-Authored-By-Zeile). Nicht pushen. + +- [ ] **Step 6: Fable-Gate** — Faktencheck aller Doku-Aussagen gegen Code (Namen, Toleranz, Code, Feldname, Meldung), Mermaid valide, fb-Skill-Text korrekt und mit Rückfrage-Regel. + +--- + +### Task 6: Backup, Redeploy, Live-Smoke (nach Whole-Branch-Review) + +- [ ] **Step 1: Backup** — `./backup_finance_pod.sh` (vor Redeploy). +- [ ] **Step 2: Redeploy** — `./create_pod_finance.sh`; `/api/version` (mit Bearer) → `0.12.0`; `alembic current` unverändert `d5e8b3c7a410` (keine Migration). +- [ ] **Step 3: Live-Smoke** (nur Zählwerte/Booleans, DATENSCHUTZ): + - Seiten `/`, `/buchungen`, `/salden`, `/import`, `/planung`, `/szenarien`, `/admin`, `/hilfe` → 200; `/static/doppelzaehlung.js` → 200. + - `POST /api/categories {"name":"System-Test"}` → 422; `POST /api/scenarios {"name":"System-Test"}` → 422. + - Wegwerf-Kredit `SMOKE-A12-K` (1.200 / 0 % / 12 Monate) anlegen → 201; Wegwerf-Posten `SMOKE-A12-P` −100 monatlich → 409 mit `code`; mit Flag → 201; `/planung` zeigt genau für diese beiden den ⚠-Hinweis (grep-Zählung der SMOKE-Namen im Hinweis); beide wieder löschen (204); danach keine SMOKE-Reste. + - `planning_monthly`: keine Kategorie `Kreditraten` (alt) mehr; `System-Kreditraten` genau dann, wenn Kredite existieren. + - Bestandszahlen (Buchungen, Konten, Kategorien, Szenarien, Kredite, wiederkehrende/Einmalposten) vor = nach. +- [ ] **Step 4: Fable-Release-Gate** (read-only Live-Verifikation) + Ledger-Eintrag „AUSBAUSTUFE 12 ABGESCHLOSSEN" in `.superpowers/sdd/progress.md`; Plan-Haken; Commit `docs: A12 Plan-Haken`.