Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
49 KiB
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üfungstrip().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 wennabs(abs(betrag) − rate) ≤ max(TOLERANZ_MIN, rate × 3 / 100). - 409-Code:
"doppelzaehlung_kredit"; Anfragefelddoppelzaehlung_bestaetigt: bool = Falsenur in Request-Modellen, nie inRecurringOut/LoanOut, nie persistiert. - UX-Regel: Formulare bleiben sichtbar; Warnung per
<dialog>mit „Abbrechen" (autofocus) und „Trotzdem speichern"; alle anderen Fehler weiter per bestehendemalert. - 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-alpinemit 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
- 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). - „Abbrechen" im Warnfenster darf nichts speichern und kein Flag setzen; Eingaben bleiben — DOM-Test in Task 4 (
Abbrechen speichert nicht). - Flag darf nicht „kleben": nach dem bestätigten Request muss
data-doppelzaehlung-bestaetigtwieder entfernt sein, sonst würde ein späteres Speichern desselben Formulars die Warnung stillschweigend umgehen — DOM-Test in Task 4 (Flag nach Request entfernt). - Toleranzgrenzen exakt (Decimal, inkl. Mindesttoleranz 1,00 € und endfällig mit 0 % Zins → nie Konflikt) — Tests in Task 2.
- „Vorschlag übernehmen" muss denselben Warnweg nehmen (POST
/api/recurringüber json-form) — Template-Test in Task 4 (Formular isthx-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(KonstanteRESERVED_SCENARIO_NAME) - Modify:
finance/app/services/planning_chart.py(KonstanteKREDITRATEN, Docstring) - Modify:
finance/app/routers/scenarios.py(_check_reserved_nameersetzen) - 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(wirftHTTPException(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.pyals Literal (grep -rn '"System-' app/→ nurreserved.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 Propertypaar -> (posten_id, kredit_id)posten_aus(item: RecurringItem) -> PostenDaten,kredit_aus(loan: Loan) -> KreditDatenregular_rate(k: KreditDaten) -> Decimal | None,rate_period(k: KreditDaten) -> tuple[date, date]find_conflict(p: PostenDaten, k: KreditDaten) -> Konflikt | Noneconflicts_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_identfä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-Felddoppelzaehlung_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_conflictsaus Task 2;tests/test_sort_js.pysammelttests/js/*.test.jsautomatisch (keine Änderung nötig). -
Produces (JS,
window.FBDoppelbzw.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.pyanhä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ürreserved.py(check_reserved_name) undservices/double_count.py(all_conflicts) mit Kanten vonplanning.py/categories.py/scenarios.pybzw.gui.py; Abschnitt 4 kurz: Präfix-Regel, Doppelzählungs-Check (409doppelzaehlung_kredit, Felddoppelzaehlung_bestaetigt, Warnfensterstatic/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/PATCHantworten mit409unddetail.code == "doppelzaehlung_kredit"(plusmeldung,konflikte), wenn ein monatlicher Ausgabe-Posten vermutlich einer Kreditrate entspricht. Dann den Nutzer fragen; nur nach ausdrücklicher Zustimmung erneut mit"doppelzaehlung_bestaetigt": truesenden. Besser: Kreditraten nur über/api/loanspflegen." - 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.
- Abschnitt „Wiederkehrende Posten" und „Kredite": Absatz „Doppelzählungs-Schutz:
-
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 currentunverändertd5e8b3c7a410(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-PostenSMOKE-A12-P−100 monatlich → 409 mitcode; mit Flag → 201;/planungzeigt 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 KategorieKreditraten(alt) mehr;System-Kreditratengenau dann, wenn Kredite existieren.- Bestandszahlen (Buchungen, Konten, Kategorien, Szenarien, Kredite, wiederkehrende/Einmalposten) vor = nach.
- Seiten
- Step 4: Fable-Release-Gate (read-only Live-Verifikation) + Ledger-Eintrag „AUSBAUSTUFE 12 ABGESCHLOSSEN" in
.superpowers/sdd/progress.md; Plan-Haken; Commitdocs: A12 Plan-Haken.