Files
bin/docs/superpowers/plans/2026-09-28-ausbaustufe-12.md
rdmsandClaude Opus 5.5 b904709d0b docs: A12 Plan-Haken gesetzt
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 07:52:17 +02:00

49 KiB
Raw Permalink Blame History

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 <dialog> und sendet das Formular nach Bestätigung erneut mit Flag.

Tech Stack: FastAPI, Pydantic v2, SQLAlchemy 2.0, Jinja2, htmx 2, Vanilla-JS (ES5, <dialog>), 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 <dialog> 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:

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
"""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

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

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:

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
"""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

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:

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:

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):

class RecurringCreate(RecurringIn):
    # Nur Anfrage: bestätigt eine erkannte Doppelzählung (nicht gespeichert).
    doppelzaehlung_bestaetigt: bool = False

RecurringPatch bekommt doppelzaehlung_bestaetigt: bool = False.

class LoanCreate(LoanIn):
    doppelzaehlung_bestaetigt: bool = False

LoanPatch bekommt doppelzaehlung_bestaetigt: bool = False.

Helper (vor den Recurring-Routen):

_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, ...):

    _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:

    vorher = {k.paar for k in conflicts_for_recurring(session, posten_aus(item))}

nach der bestehenden Ende-vor-Start-Prüfung, vor session.commit():

    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, ...):

    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

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:

'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:
'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 = `
<form id="f" hx-post="/api/recurring"><input name="name" value="Rate Auto"></form>
<dialog id="doppelzaehlung-dialog">
  <p class="dz-meldung"></p><ul class="dz-konflikte"></ul>
  <button type="button" id="dz-abbrechen">Abbrechen</button>
  <button type="button" id="dz-bestaetigen">Trotzdem speichern</button>
</dialog>`;
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(`<!doctype html><body>${HTML}</body>`, { 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:
def test_base_hat_doppelzaehlungs_dialog(client):
    client.post("/login", data={"username": "admin", "password": "geheim"})
    html = client.get("/").text
    assert '<script src="/static/doppelzaehlung.js"></script>' in html
    assert '<dialog id="doppelzaehlung-dialog">' in html
    assert 'id="dz-abbrechen" autofocus' in html
    assert ">Trotzdem speichern</button>" 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 '<td data-sort="Rate Auto">' in html
    assert '<td data-sort="Auto">' 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
/* 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 <dialog>. „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 <script src="/static/sort.js"></script>:

  <script src="/static/doppelzaehlung.js"></script>

Direkt nach </main>:

  <dialog id="doppelzaehlung-dialog">
    <h2>Mögliche Doppelzählung</h2>
    <p class="dz-meldung"></p>
    <ul class="dz-konflikte"></ul>
    <p>Trotzdem speichern?</p>
    <button type="button" id="dz-abbrechen" autofocus>Abbrechen</button>
    <button type="button" id="dz-bestaetigen">Trotzdem speichern</button>
  </dialog>

Fehlerhandler ersetzen:

    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(...):

        // 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):

    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:

    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 (<td>{{ r.name }}</td> in rec-row):

        <td data-sort="{{ r.name }}">{{ r.name }}{% for kn in doppelt_posten.get(r.id, []) %}<span class="warn-hint">⚠ zählt evtl. doppelt mit Kredit „{{ kn }}“</span>{% endfor %}</td>

Kredit-Namenszelle (<td>{{ l.name }}</td> in loan-row):

        <td data-sort="{{ l.name }}">{{ l.name }}{% for pn in doppelt_kredit.get(l.id, []) %}<span class="warn-hint">⚠ zählt evtl. doppelt mit Posten „{{ pn }}“</span>{% endfor %}</td>

style.css anhängen:

/* 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:
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
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)

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.