Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1069 lines
49 KiB
Markdown
1069 lines
49 KiB
Markdown
# 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)`).
|
||
|
||
- [x] **Step 1: Failing Tests** — `finance/tests/test_reserved.py`:
|
||
|
||
```python
|
||
import pytest
|
||
|
||
from app.models.views import RESERVED_SCENARIO_NAME
|
||
from app.reserved import SYSTEM_PREFIX, is_reserved_name
|
||
from app.services.planning_chart import KREDITRATEN
|
||
|
||
H = {"Authorization": "Bearer test-key"}
|
||
|
||
|
||
def test_systemnamen_leiten_sich_vom_praefix_ab():
|
||
assert SYSTEM_PREFIX == "System-"
|
||
assert RESERVED_SCENARIO_NAME == "System-Ist"
|
||
assert KREDITRATEN == "System-Kreditraten"
|
||
|
||
|
||
@pytest.mark.parametrize("name,expected", [
|
||
("System-X", True), ("system-kreditraten", True), (" SYSTEM-Ist ", True),
|
||
("Kreditraten", False), ("Systemkosten", False), ("Ist-System", False),
|
||
("System", False),
|
||
])
|
||
def test_is_reserved_name(name, expected):
|
||
assert is_reserved_name(name) is expected
|
||
|
||
|
||
@pytest.mark.parametrize("name", ["System-X", "system-kreditraten", " SYSTEM-Ist "])
|
||
def test_kategorie_post_reserviert(client, name):
|
||
r = client.post("/api/categories", headers=H, json={"name": name})
|
||
assert r.status_code == 422
|
||
assert r.json()["detail"] == ("Namen, die mit »System-« beginnen, sind für "
|
||
"vom Tool erzeugte Einträge reserviert")
|
||
|
||
|
||
def test_kategorie_patch_reserviert(client):
|
||
cat = client.post("/api/categories", headers=H, json={"name": "Normal"}).json()
|
||
r = client.patch(f"/api/categories/{cat['id']}", headers=H, json={"name": "System-Test"})
|
||
assert r.status_code == 422
|
||
names = [c["name"] for c in client.get("/api/categories", headers=H).json()]
|
||
assert "Normal" in names and "System-Test" not in names
|
||
|
||
|
||
@pytest.mark.parametrize("name", ["Kreditraten", "Systemkosten", "Ist-System"])
|
||
def test_kategorie_normale_namen_erlaubt(client, name):
|
||
assert client.post("/api/categories", headers=H, json={"name": name}).status_code == 201
|
||
|
||
|
||
@pytest.mark.parametrize("name", ["System-Plan", "system-x"])
|
||
def test_szenario_praefix_reserviert(client, name):
|
||
r = client.post("/api/scenarios", headers=H, json={"name": name})
|
||
assert r.status_code == 422
|
||
assert "reserviert" in r.json()["detail"]
|
||
```
|
||
|
||
- [x] **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`).
|
||
|
||
- [x] **Step 3: `finance/app/reserved.py`**
|
||
|
||
```python
|
||
"""Reservierte Namen (Ausbaustufe 12).
|
||
|
||
Alles, was mit SYSTEM_PREFIX beginnt, gehört dem Tool: synthetische Serien
|
||
wie „System-Ist" (v_projection) oder „System-Kreditraten" (Planungs-Panel).
|
||
Kategorien und Szenarien dürfen solche Namen nicht tragen, sonst verschmelzen
|
||
sie in Grafana mit den Tool-Serien.
|
||
"""
|
||
from fastapi import HTTPException
|
||
|
||
SYSTEM_PREFIX = "System-"
|
||
RESERVED_MESSAGE = (f"Namen, die mit »{SYSTEM_PREFIX}« beginnen, sind für "
|
||
"vom Tool erzeugte Einträge reserviert")
|
||
|
||
|
||
def is_reserved_name(name: str) -> bool:
|
||
"""True, wenn name (ohne Randleerzeichen, Groß/klein egal) mit dem
|
||
System-Präfix beginnt."""
|
||
return name.strip().casefold().startswith(SYSTEM_PREFIX.casefold())
|
||
|
||
|
||
def check_reserved_name(name: str | None) -> None:
|
||
"""None (Feld nicht gesetzt) -> keine Prüfung; reserviert -> 422."""
|
||
if name is not None and is_reserved_name(name):
|
||
raise HTTPException(422, RESERVED_MESSAGE)
|
||
```
|
||
|
||
- [x] **Step 4: Konstanten ableiten**
|
||
|
||
`finance/app/models/views.py`: Import `from app.reserved import SYSTEM_PREFIX` und
|
||
```python
|
||
RESERVED_SCENARIO_NAME = f"{SYSTEM_PREFIX}Ist"
|
||
```
|
||
(Kommentar darüber anpassen: „Für Szenarien reserviert über den System-Präfix, siehe app/reserved.py".)
|
||
|
||
`finance/app/services/planning_chart.py`: Import `from app.reserved import SYSTEM_PREFIX`, `KREDITRATEN = f"{SYSTEM_PREFIX}Kreditraten"`; im Modul-Docstring „Kreditraten" → „System-Kreditraten".
|
||
|
||
- [x] **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)`.
|
||
|
||
- [x] **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").
|
||
|
||
- [x] **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.
|
||
|
||
- [x] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add finance/app/reserved.py finance/app/models/views.py finance/app/services/planning_chart.py \
|
||
finance/app/routers/scenarios.py finance/app/routers/categories.py finance/tests/test_reserved.py
|
||
git commit -m "feat: Präfix System- für Tool-Serien reserviert, Kreditraten-Serie heißt System-Kreditraten (A12-T1)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Erkennungslogik `double_count.py`
|
||
|
||
**Files:**
|
||
- Create: `finance/app/services/double_count.py`
|
||
- Create: `finance/tests/test_double_count.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `app.engine.loans.annuity_payment`, `app.engine.loans.add_months`.
|
||
- Produces (Task 3/4 nutzen exakt diese Namen):
|
||
- `CODE = "doppelzaehlung_kredit"`, `TOLERANZ_PROZENT`, `TOLERANZ_MIN`
|
||
- `@dataclass(frozen=True) PostenDaten(id, name, amount, rhythm, start_date, end_date)`
|
||
- `@dataclass(frozen=True) KreditDaten(id, name, principal, annual_rate_pct, term_months, payout_date, repayment_type)`
|
||
- `@dataclass(frozen=True) Konflikt(posten_id, posten_name, posten_betrag, kredit_id, kredit_name, kredit_rate)` mit Property `paar -> (posten_id, kredit_id)`
|
||
- `posten_aus(item: RecurringItem) -> PostenDaten`, `kredit_aus(loan: Loan) -> KreditDaten`
|
||
- `regular_rate(k: KreditDaten) -> Decimal | None`, `rate_period(k: KreditDaten) -> tuple[date, date]`
|
||
- `find_conflict(p: PostenDaten, k: KreditDaten) -> Konflikt | None`
|
||
- `conflicts_for_recurring(session, p: PostenDaten) -> list[Konflikt]`
|
||
- `conflicts_for_loan(session, k: KreditDaten) -> list[Konflikt]`
|
||
- `all_conflicts(session) -> list[Konflikt]` (sortiert nach Posten-, dann Kreditname)
|
||
- Abweichung ggü. Spec: `exclude_id` entfällt — die Prüfung ist typübergreifend (Posten gegen Kredite), ein Datensatz kann nicht mit sich selbst kollidieren.
|
||
|
||
- [x] **Step 1: Failing Tests** — `finance/tests/test_double_count.py`:
|
||
|
||
```python
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
|
||
import pytest
|
||
|
||
from app.models.tables import Loan, RecurringItem
|
||
from app.services.double_count import (KreditDaten, PostenDaten, all_conflicts,
|
||
conflicts_for_loan, conflicts_for_recurring,
|
||
find_conflict, rate_period, regular_rate)
|
||
|
||
D = Decimal
|
||
# 1.200 € zu 0 % auf 12 Monate, Auszahlung 15.01.2026 -> Rate 100,00;
|
||
# erste Rate 15.02.2026, letzte 15.01.2027.
|
||
K100 = KreditDaten(1, "Auto", D("1200.00"), D("0"), 12, date(2026, 1, 15), "annuity")
|
||
|
||
|
||
def P(amount, rhythm="monthly", start=None, end=None, name="Rate"):
|
||
return PostenDaten(7, name, D(amount), rhythm, start, end)
|
||
|
||
|
||
def test_regular_rate_und_zeitraum():
|
||
assert regular_rate(K100) == D("100.00")
|
||
assert rate_period(K100) == (date(2026, 2, 15), date(2027, 1, 15))
|
||
annu = KreditDaten(2, "A", D("5000.00"), D("6.0"), 48, date(2026, 1, 1), "annuity")
|
||
assert regular_rate(annu) == D("117.43")
|
||
bullet = KreditDaten(3, "B", D("12000.00"), D("6.0"), 24, date(2026, 1, 1), "bullet")
|
||
assert regular_rate(bullet) == D("60.00")
|
||
bullet0 = KreditDaten(4, "B0", D("12000.00"), D("0"), 24, date(2026, 1, 1), "bullet")
|
||
assert regular_rate(bullet0) is None
|
||
|
||
|
||
@pytest.mark.parametrize("amount,hit", [
|
||
("-100.00", True), ("-103.00", True), ("-103.01", False),
|
||
("-97.00", True), ("-96.99", False),
|
||
])
|
||
def test_toleranz_drei_prozent(amount, hit):
|
||
assert (find_conflict(P(amount), K100) is not None) is hit
|
||
|
||
|
||
@pytest.mark.parametrize("amount,hit", [("-21.00", True), ("-21.01", False), ("-19.00", True)])
|
||
def test_mindesttoleranz_ein_euro(amount, hit):
|
||
k20 = KreditDaten(5, "Klein", D("240.00"), D("0"), 12, date(2026, 1, 15), "annuity")
|
||
assert (find_conflict(P(amount), k20) is not None) is hit
|
||
|
||
|
||
def test_endfaellig_zinsrate_und_nullzins():
|
||
bullet = KreditDaten(3, "B", D("12000.00"), D("6.0"), 24, date(2026, 1, 1), "bullet")
|
||
assert find_conflict(P("-60.00"), bullet) is not None
|
||
bullet0 = KreditDaten(4, "B0", D("12000.00"), D("0"), 24, date(2026, 1, 1), "bullet")
|
||
assert find_conflict(P("-60.00"), bullet0) is None
|
||
assert find_conflict(P("-0.50"), bullet0) is None
|
||
|
||
|
||
def test_nur_monatliche_ausgaben():
|
||
assert find_conflict(P("-100.00", rhythm="quarterly"), K100) is None
|
||
assert find_conflict(P("100.00"), K100) is None
|
||
|
||
|
||
@pytest.mark.parametrize("start,end,hit", [
|
||
(None, date(2026, 2, 14), False), (None, date(2026, 2, 15), True),
|
||
(date(2027, 1, 16), None, False), (date(2027, 1, 15), None, True),
|
||
(None, None, True), (date(2025, 1, 1), date(2030, 1, 1), True),
|
||
])
|
||
def test_zeitraeume(start, end, hit):
|
||
assert (find_conflict(P("-100.00", start=start, end=end), K100) is not None) is hit
|
||
|
||
|
||
def test_konflikt_inhalt():
|
||
k = find_conflict(P("-101.50", name="Rate Auto"), K100)
|
||
assert (k.posten_id, k.posten_name, k.posten_betrag) == (7, "Rate Auto", D("-101.50"))
|
||
assert (k.kredit_id, k.kredit_name, k.kredit_rate) == (1, "Auto", D("100.00"))
|
||
assert k.paar == (7, 1)
|
||
|
||
|
||
def _seed(db):
|
||
db.add_all([
|
||
Loan(name="Auto", principal=D("1200.00"), annual_rate_pct=D("0"), term_months=12,
|
||
payout_date=date(2026, 1, 15), repayment_type="annuity"),
|
||
Loan(name="Möbel", principal=D("240.00"), annual_rate_pct=D("0"), term_months=12,
|
||
payout_date=date(2026, 1, 15), repayment_type="annuity"),
|
||
RecurringItem(name="Rate Auto", amount=D("-100.00"), rhythm="monthly", due_day=15),
|
||
RecurringItem(name="Rate Möbel", amount=D("-20.00"), rhythm="monthly", due_day=15),
|
||
RecurringItem(name="Miete", amount=D("-600.00"), rhythm="monthly", due_day=1),
|
||
])
|
||
db.commit()
|
||
|
||
|
||
def test_all_conflicts_findet_paare_sortiert(db):
|
||
_seed(db)
|
||
assert [(k.posten_name, k.kredit_name) for k in all_conflicts(db)] == [
|
||
("Rate Auto", "Auto"), ("Rate Möbel", "Möbel")]
|
||
|
||
|
||
def test_conflicts_for_recurring_und_loan(db):
|
||
_seed(db)
|
||
neu = PostenDaten(None, "Neu", D("-99.00"), "monthly", None, None)
|
||
assert [k.kredit_name for k in conflicts_for_recurring(db, neu)] == ["Auto"]
|
||
kredit = KreditDaten(None, "Neu", D("7200.00"), D("0"), 12, date(2026, 1, 1), "annuity")
|
||
assert [k.posten_name for k in conflicts_for_loan(db, kredit)] == ["Miete"]
|
||
```
|
||
|
||
- [x] **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`).
|
||
|
||
- [x] **Step 3: `finance/app/services/double_count.py`**
|
||
|
||
```python
|
||
"""Doppelzählungs-Erkennung Kredit ↔ wiederkehrender Posten (Ausbaustufe 12).
|
||
|
||
Ein monatlicher Ausgabe-Posten gilt als vermutlich doppelt zu einem Kredit,
|
||
wenn sein Betrag innerhalb der Toleranz der regulären Monatsrate liegt und
|
||
sich die Zeiträume überschneiden. Bewusst kein Namensvergleich
|
||
(„Autokredit" vs. „Rate VW Bank" ist nicht zuverlässig zuordenbar).
|
||
Reine Logik auf Wertobjekten, damit auch noch nicht gespeicherte bzw. per
|
||
PATCH veränderte Zustände geprüft werden können. Alle Beträge Decimal.
|
||
"""
|
||
from dataclasses import dataclass
|
||
from datetime import date
|
||
from decimal import ROUND_HALF_UP, Decimal
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.engine.loans import add_months, annuity_payment
|
||
from app.models.tables import Loan, RecurringItem
|
||
|
||
CODE = "doppelzaehlung_kredit"
|
||
TOLERANZ_PROZENT = Decimal("3")
|
||
TOLERANZ_MIN = Decimal("1.00")
|
||
CENT = Decimal("0.01")
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PostenDaten:
|
||
id: int | None
|
||
name: str
|
||
amount: Decimal
|
||
rhythm: str
|
||
start_date: date | None
|
||
end_date: date | None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class KreditDaten:
|
||
id: int | None
|
||
name: str
|
||
principal: Decimal
|
||
annual_rate_pct: Decimal
|
||
term_months: int
|
||
payout_date: date
|
||
repayment_type: str
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Konflikt:
|
||
posten_id: int | None
|
||
posten_name: str
|
||
posten_betrag: Decimal
|
||
kredit_id: int | None
|
||
kredit_name: str
|
||
kredit_rate: Decimal
|
||
|
||
@property
|
||
def paar(self) -> tuple[int | None, int | None]:
|
||
return (self.posten_id, self.kredit_id)
|
||
|
||
|
||
def posten_aus(item: RecurringItem) -> PostenDaten:
|
||
return PostenDaten(item.id, item.name, Decimal(item.amount), item.rhythm,
|
||
item.start_date, item.end_date)
|
||
|
||
|
||
def kredit_aus(loan: Loan) -> KreditDaten:
|
||
return KreditDaten(loan.id, loan.name, Decimal(loan.principal),
|
||
Decimal(loan.annual_rate_pct), loan.term_months,
|
||
loan.payout_date, loan.repayment_type)
|
||
|
||
|
||
def regular_rate(k: KreditDaten) -> Decimal | None:
|
||
"""Reguläre Monatsrate (positiv): Annuität bzw. bei endfälligen Krediten
|
||
die monatlichen Zinsen. Ohne reguläre Rate (0 % endfällig, Laufzeit <= 0)
|
||
-> None, dann nie Konflikt. Die Schlusszahlung wird nicht betrachtet."""
|
||
if k.term_months <= 0:
|
||
return None
|
||
if k.repayment_type == "bullet":
|
||
if k.annual_rate_pct == 0:
|
||
return None
|
||
return (k.principal * k.annual_rate_pct / Decimal(100) / Decimal(12)
|
||
).quantize(CENT, ROUND_HALF_UP)
|
||
return annuity_payment(k.principal, k.annual_rate_pct, k.term_months)
|
||
|
||
|
||
def rate_period(k: KreditDaten) -> tuple[date, date]:
|
||
"""Erste und letzte Rate, identisch zu engine/loans.loan_schedule."""
|
||
return add_months(k.payout_date, 1), add_months(k.payout_date, k.term_months)
|
||
|
||
|
||
def find_conflict(p: PostenDaten, k: KreditDaten) -> Konflikt | None:
|
||
if p.rhythm != "monthly" or p.amount >= 0:
|
||
return None
|
||
rate = regular_rate(k)
|
||
if rate is None or rate <= 0:
|
||
return None
|
||
toleranz = max(TOLERANZ_MIN, rate * TOLERANZ_PROZENT / Decimal(100))
|
||
if abs(-p.amount - rate) > toleranz:
|
||
return None
|
||
erste, letzte = rate_period(k)
|
||
if p.start_date is not None and p.start_date > letzte:
|
||
return None
|
||
if p.end_date is not None and p.end_date < erste:
|
||
return None
|
||
return Konflikt(p.id, p.name, p.amount, k.id, k.name, rate)
|
||
|
||
|
||
def conflicts_for_recurring(session: Session, p: PostenDaten) -> list[Konflikt]:
|
||
loans = session.execute(select(Loan).order_by(Loan.name, Loan.id)).scalars()
|
||
return [c for c in (find_conflict(p, kredit_aus(l)) for l in loans) if c]
|
||
|
||
|
||
def conflicts_for_loan(session: Session, k: KreditDaten) -> list[Konflikt]:
|
||
items = session.execute(select(RecurringItem)
|
||
.order_by(RecurringItem.name, RecurringItem.id)).scalars()
|
||
return [c for c in (find_conflict(posten_aus(r), k) for r in items) if c]
|
||
|
||
|
||
def all_conflicts(session: Session) -> list[Konflikt]:
|
||
kredite = [kredit_aus(l) for l in session.execute(
|
||
select(Loan).order_by(Loan.name, Loan.id)).scalars()]
|
||
out: list[Konflikt] = []
|
||
for r in session.execute(select(RecurringItem)
|
||
.order_by(RecurringItem.name, RecurringItem.id)).scalars():
|
||
p = posten_aus(r)
|
||
out.extend(c for c in (find_conflict(p, k) for k in kredite) if c)
|
||
return out
|
||
```
|
||
|
||
- [x] **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).
|
||
|
||
- [x] **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).
|
||
|
||
- [x] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add finance/app/services/double_count.py finance/tests/test_double_count.py
|
||
git commit -m "feat: Erkennung möglicher Doppelzählung Kredit/Fixposten (A12-T2)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: API — 409 bei Doppelzählung, Bestätigungsfeld
|
||
|
||
**Files:**
|
||
- Modify: `finance/app/routers/planning.py` (Schemas, `create_recurring`, `patch_recurring`, `create_loan`, `patch_loan`)
|
||
- Create: `finance/tests/test_double_count_api.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes (Task 2): `CODE`, `PostenDaten`, `KreditDaten`, `Konflikt`, `posten_aus`, `kredit_aus`, `conflicts_for_recurring`, `conflicts_for_loan`.
|
||
- Produces: 409-Body `{"detail": {"code": "doppelzaehlung_kredit", "meldung": str, "konflikte": [{"posten", "posten_betrag", "kredit", "kredit_rate"}]}}` (Beträge als Strings, Punkt-Dezimal, 2 Nachkommastellen). Request-Feld `doppelzaehlung_bestaetigt` (Task 4 sendet es).
|
||
|
||
- [x] **Step 1: Failing Tests** — `finance/tests/test_double_count_api.py`:
|
||
|
||
```python
|
||
from decimal import Decimal
|
||
|
||
H = {"Authorization": "Bearer test-key"}
|
||
KREDIT = {"name": "Auto", "principal": "1200.00", "annual_rate_pct": "0",
|
||
"term_months": 12, "payout_date": "2026-01-15", "repayment_type": "annuity"}
|
||
POSTEN = {"name": "Rate Auto", "amount": "-100.00", "rhythm": "monthly", "due_day": 15}
|
||
|
||
|
||
def _count(client, path):
|
||
return len(client.get(path, headers=H).json())
|
||
|
||
|
||
def test_posten_zu_bestehendem_kredit_409(client):
|
||
assert client.post("/api/loans", headers=H, json=KREDIT).status_code == 201
|
||
r = client.post("/api/recurring", headers=H, json=POSTEN)
|
||
assert r.status_code == 409
|
||
d = r.json()["detail"]
|
||
assert d["code"] == "doppelzaehlung_kredit"
|
||
assert "doppelt" in d["meldung"]
|
||
assert d["konflikte"] == [{"posten": "Rate Auto", "posten_betrag": "-100.00",
|
||
"kredit": "Auto", "kredit_rate": "100.00"}]
|
||
assert _count(client, "/api/recurring") == 0
|
||
|
||
|
||
def test_posten_mit_bestaetigung_gespeichert(client):
|
||
client.post("/api/loans", headers=H, json=KREDIT)
|
||
r = client.post("/api/recurring", headers=H,
|
||
json={**POSTEN, "doppelzaehlung_bestaetigt": True})
|
||
assert r.status_code == 201
|
||
assert "doppelzaehlung_bestaetigt" not in r.json()
|
||
assert _count(client, "/api/recurring") == 1
|
||
|
||
|
||
def test_kredit_zu_bestehendem_posten_409_und_bestaetigung(client):
|
||
client.post("/api/recurring", headers=H, json=POSTEN)
|
||
r = client.post("/api/loans", headers=H, json=KREDIT)
|
||
assert r.status_code == 409
|
||
assert r.json()["detail"]["konflikte"][0]["posten"] == "Rate Auto"
|
||
assert _count(client, "/api/loans") == 0
|
||
r = client.post("/api/loans", headers=H, json={**KREDIT, "doppelzaehlung_bestaetigt": True})
|
||
assert r.status_code == 201
|
||
assert "doppelzaehlung_bestaetigt" not in r.json()
|
||
|
||
|
||
def test_ohne_konflikt_unveraendert(client):
|
||
client.post("/api/loans", headers=H, json=KREDIT)
|
||
r = client.post("/api/recurring", headers=H, json={**POSTEN, "amount": "-50.00"})
|
||
assert r.status_code == 201
|
||
|
||
|
||
def test_patch_posten_erzeugt_konflikt_409_ohne_aenderung(client):
|
||
client.post("/api/loans", headers=H, json=KREDIT)
|
||
item = client.post("/api/recurring", headers=H, json={**POSTEN, "amount": "-50.00"}).json()
|
||
r = client.patch(f"/api/recurring/{item['id']}", headers=H, json={"amount": "-100.00"})
|
||
assert r.status_code == 409
|
||
stored = client.get("/api/recurring", headers=H).json()[0]
|
||
assert Decimal(stored["amount"]) == Decimal("-50.00")
|
||
r = client.patch(f"/api/recurring/{item['id']}", headers=H,
|
||
json={"amount": "-100.00", "doppelzaehlung_bestaetigt": True})
|
||
assert r.status_code == 200
|
||
assert Decimal(r.json()["amount"]) == Decimal("-100.00")
|
||
|
||
|
||
def test_patch_nur_name_bei_bestehendem_konflikt_ohne_flag(client):
|
||
client.post("/api/loans", headers=H, json=KREDIT)
|
||
item = client.post("/api/recurring", headers=H,
|
||
json={**POSTEN, "doppelzaehlung_bestaetigt": True}).json()
|
||
r = client.patch(f"/api/recurring/{item['id']}", headers=H, json={"name": "Rate Auto neu"})
|
||
assert r.status_code == 200
|
||
loan = client.get("/api/loans", headers=H).json()[0]
|
||
r = client.patch(f"/api/loans/{loan['id']}", headers=H, json={"name": "Auto neu"})
|
||
assert r.status_code == 200
|
||
|
||
|
||
def test_patch_kredit_erzeugt_konflikt_409(client):
|
||
client.post("/api/recurring", headers=H, json=POSTEN)
|
||
loan = client.post("/api/loans", headers=H, json={**KREDIT, "principal": "2400.00"}).json()
|
||
r = client.patch(f"/api/loans/{loan['id']}", headers=H, json={"principal": "1200.00"})
|
||
assert r.status_code == 409
|
||
assert Decimal(client.get("/api/loans", headers=H).json()[0]["principal"]) == Decimal("2400.00")
|
||
r = client.patch(f"/api/loans/{loan['id']}", headers=H,
|
||
json={"principal": "1200.00", "doppelzaehlung_bestaetigt": True})
|
||
assert r.status_code == 200
|
||
|
||
|
||
def test_vorschlag_uebernehmen_nutzt_denselben_endpunkt(client):
|
||
# „Vorschlag übernehmen" sendet dieselben Felder (inkl. start_date/category_id=null).
|
||
client.post("/api/loans", headers=H, json=KREDIT)
|
||
r = client.post("/api/recurring", headers=H, json={
|
||
**POSTEN, "start_date": None, "category_id": None})
|
||
assert r.status_code == 409
|
||
```
|
||
|
||
- [x] **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).
|
||
|
||
- [x] **Step 3: Implementierung in `routers/planning.py`**
|
||
|
||
Imports:
|
||
```python
|
||
from app.services.double_count import (CODE as DOPPELZAEHLUNG_CODE, Konflikt,
|
||
KreditDaten, PostenDaten,
|
||
conflicts_for_loan, conflicts_for_recurring,
|
||
kredit_aus, posten_aus)
|
||
```
|
||
|
||
Schemas (nach den bestehenden Klassen):
|
||
```python
|
||
class RecurringCreate(RecurringIn):
|
||
# Nur Anfrage: bestätigt eine erkannte Doppelzählung (nicht gespeichert).
|
||
doppelzaehlung_bestaetigt: bool = False
|
||
```
|
||
`RecurringPatch` bekommt `doppelzaehlung_bestaetigt: bool = False`.
|
||
```python
|
||
class LoanCreate(LoanIn):
|
||
doppelzaehlung_bestaetigt: bool = False
|
||
```
|
||
`LoanPatch` bekommt `doppelzaehlung_bestaetigt: bool = False`.
|
||
|
||
Helper (vor den Recurring-Routen):
|
||
```python
|
||
_CENT = Decimal("0.01")
|
||
_FLAG = "doppelzaehlung_bestaetigt"
|
||
|
||
|
||
def _doppelzaehlung(konflikte: list[Konflikt]) -> HTTPException:
|
||
"""409 mit strukturierter Erklärung; die GUI zeigt daraus das Warnfenster."""
|
||
return HTTPException(409, detail={
|
||
"code": DOPPELZAEHLUNG_CODE,
|
||
"meldung": ("Der Betrag entspricht vermutlich einer Kreditrate, die bereits "
|
||
"über „Kredite“ eingeplant ist (oder umgekehrt). Beide zusammen "
|
||
"zählen die Rate doppelt – Planung und Szenarien wären zu "
|
||
"pessimistisch. Nur speichern, wenn das wirklich gewollt ist."),
|
||
"konflikte": [{"posten": k.posten_name,
|
||
"posten_betrag": str(k.posten_betrag.quantize(_CENT)),
|
||
"kredit": k.kredit_name,
|
||
"kredit_rate": str(k.kredit_rate.quantize(_CENT))}
|
||
for k in konflikte],
|
||
})
|
||
```
|
||
|
||
`create_recurring(data: RecurringCreate, ...)`:
|
||
```python
|
||
_check_category(session, data.category_id)
|
||
fields = data.model_dump(exclude={_FLAG})
|
||
if not data.doppelzaehlung_bestaetigt:
|
||
konflikte = conflicts_for_recurring(session, PostenDaten(
|
||
None, fields["name"], fields["amount"], fields["rhythm"],
|
||
fields["start_date"], fields["end_date"]))
|
||
if konflikte:
|
||
raise _doppelzaehlung(konflikte)
|
||
item = RecurringItem(**fields)
|
||
...
|
||
```
|
||
|
||
`patch_recurring`: `fields = data.model_dump(exclude_unset=True, exclude={_FLAG})`; nach dem 404-Check und vor dem `setattr`-Loop:
|
||
```python
|
||
vorher = {k.paar for k in conflicts_for_recurring(session, posten_aus(item))}
|
||
```
|
||
nach der bestehenden Ende-vor-Start-Prüfung, vor `session.commit()`:
|
||
```python
|
||
if not data.doppelzaehlung_bestaetigt:
|
||
neu = [k for k in conflicts_for_recurring(session, posten_aus(item))
|
||
if k.paar not in vorher]
|
||
if neu:
|
||
session.rollback()
|
||
raise _doppelzaehlung(neu)
|
||
```
|
||
|
||
`create_loan(data: LoanCreate, ...)`:
|
||
```python
|
||
fields = data.model_dump(exclude={_FLAG})
|
||
if not data.doppelzaehlung_bestaetigt:
|
||
konflikte = conflicts_for_loan(session, KreditDaten(
|
||
None, fields["name"], fields["principal"], fields["annual_rate_pct"],
|
||
fields["term_months"], fields["payout_date"], fields["repayment_type"]))
|
||
if konflikte:
|
||
raise _doppelzaehlung(konflikte)
|
||
loan = Loan(**fields)
|
||
...
|
||
```
|
||
|
||
`patch_loan`: analog `patch_recurring` mit `kredit_aus(loan)` / `conflicts_for_loan`, `model_dump(exclude_unset=True, exclude={_FLAG})`.
|
||
|
||
Hinweis: Bei neu angelegten Datensätzen ist `posten_id`/`kredit_id` in der Konflikt-Antwort `None` — die Antwort enthält bewusst nur Namen und Beträge.
|
||
|
||
- [x] **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).
|
||
|
||
- [x] **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.
|
||
|
||
- [x] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add finance/app/routers/planning.py finance/tests/test_double_count_api.py
|
||
git commit -m "feat: API meldet mögliche Doppelzählung mit 409, Speichern nur mit Bestätigung (A12-T3)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: GUI — Warnfenster + dauerhafter Hinweis
|
||
|
||
**Files:**
|
||
- Create: `finance/app/static/doppelzaehlung.js`
|
||
- Create: `finance/tests/js/doppelzaehlung.test.js`, `finance/tests/js/doppelzaehlung.dom.test.js`
|
||
- Modify: `finance/app/templates/base.html` (Script, Dialog, Fehlerhandler, json-form-Flag)
|
||
- Modify: `finance/app/routers/gui.py` (`planung_page`)
|
||
- Modify: `finance/app/templates/planning.html` (Namens-Zellen Posten/Kredite)
|
||
- Modify: `finance/app/static/style.css` (`.warn-hint`, Dialog)
|
||
- Test: `finance/tests/test_gui.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: 409-Body aus Task 3; `all_conflicts` aus Task 2; `tests/test_sort_js.py` sammelt `tests/js/*.test.js` automatisch (keine Änderung nötig).
|
||
- Produces (JS, `window.FBDoppel` bzw. `module.exports`): `parseDetail(xhr) -> detail|null`, `konfliktText(k) -> string`, `handle(evt) -> bool`, `bestaetigen(dlg)`, `abbrechen(dlg)`, `init(doc)`.
|
||
|
||
- [x] **Step 1: Failing JS-Unit-Tests** — `finance/tests/js/doppelzaehlung.test.js`:
|
||
|
||
```js
|
||
'use strict';
|
||
const test = require('node:test');
|
||
const assert = require('node:assert');
|
||
const D = require('../../app/static/doppelzaehlung.js');
|
||
|
||
const body = { detail: { code: 'doppelzaehlung_kredit', meldung: 'M',
|
||
konflikte: [{ posten: 'Rate Auto', posten_betrag: '-1234.50', kredit: 'Auto', kredit_rate: '1234.50' }] } };
|
||
|
||
test('parseDetail erkennt nur 409 mit Code', () => {
|
||
assert.equal(D.parseDetail({ status: 409, responseText: JSON.stringify(body) }).code, 'doppelzaehlung_kredit');
|
||
assert.equal(D.parseDetail({ status: 409, responseText: '{"detail":"Kategorie existiert bereits"}' }), null);
|
||
assert.equal(D.parseDetail({ status: 422, responseText: JSON.stringify(body) }), null);
|
||
assert.equal(D.parseDetail({ status: 409, responseText: 'kein json' }), null);
|
||
assert.equal(D.parseDetail(null), null);
|
||
});
|
||
|
||
test('konfliktText deutsch formatiert', () => {
|
||
assert.equal(D.konfliktText(body.detail.konflikte[0]),
|
||
'Posten „Rate Auto“ (-1.234,50 €) ↔ Kredit „Auto“ (Rate 1.234,50 €)');
|
||
});
|
||
```
|
||
|
||
- [x] **Step 2: Failing DOM-Tests** — `finance/tests/js/doppelzaehlung.dom.test.js`:
|
||
|
||
```js
|
||
'use strict';
|
||
const test = require('node:test');
|
||
const assert = require('node:assert');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
|
||
let JSDOM = null;
|
||
try { ({ JSDOM } = require('jsdom')); } catch (e) { /* jsdom fehlt -> skip */ }
|
||
const SRC = fs.readFileSync(path.join(__dirname, '../../app/static/doppelzaehlung.js'), 'utf8');
|
||
|
||
const HTML = `
|
||
<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'));
|
||
});
|
||
```
|
||
|
||
- [x] **Step 3: Failing Python-Tests** — an `finance/tests/test_gui.py` anhängen:
|
||
|
||
```python
|
||
def test_base_hat_doppelzaehlungs_dialog(client):
|
||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||
html = client.get("/").text
|
||
assert '<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.)
|
||
|
||
- [x] **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).
|
||
|
||
- [x] **Step 5: `finance/app/static/doppelzaehlung.js`**
|
||
|
||
```js
|
||
/* Warnfenster bei möglicher Doppelzählung Kredit ↔ Fixposten (Ausbaustufe 12).
|
||
*
|
||
* Die API antwortet mit 409 und detail.code "doppelzaehlung_kredit", wenn ein
|
||
* Posten vermutlich einer Kreditrate entspricht. handle() fängt genau diesen
|
||
* Fall im globalen htmx:responseError-Handler ab (alle anderen Fehler laufen
|
||
* weiter über den alert) und zeigt den <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; }
|
||
})();
|
||
```
|
||
|
||
- [x] **Step 6: `base.html`**
|
||
|
||
Nach `<script src="/static/sort.js"></script>`:
|
||
```html
|
||
<script src="/static/doppelzaehlung.js"></script>
|
||
```
|
||
Direkt nach `</main>`:
|
||
```html
|
||
<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:
|
||
```js
|
||
document.body.addEventListener('htmx:responseError', function (e) {
|
||
// Mögliche Doppelzählung Kredit/Fixposten: eigenes Warnfenster statt alert.
|
||
if (window.FBDoppel && window.FBDoppel.handle(e)) { return; }
|
||
alert('Fehler: ' + (e.detail.xhr.responseText || e.detail.xhr.status));
|
||
});
|
||
```
|
||
In `encodeParameters` der json-form-Extension, direkt vor `xhr.overrideMimeType(...)`:
|
||
```js
|
||
// Bestätigte Doppelzählung (Warnfenster „Trotzdem speichern"):
|
||
if (elt.getAttribute && elt.getAttribute('data-doppelzaehlung-bestaetigt') === '1') {
|
||
out.doppelzaehlung_bestaetigt = true;
|
||
}
|
||
```
|
||
Am Ende des Inline-Scripts (nach `toggleEdit`):
|
||
```js
|
||
if (window.FBDoppel) { window.FBDoppel.init(document); }
|
||
```
|
||
|
||
- [x] **Step 7: Dauerhafter Hinweis**
|
||
|
||
`routers/gui.py`: Import `from app.services.double_count import all_conflicts`; in `planung_page` vor dem `TemplateResponse`:
|
||
```python
|
||
doppelt_posten: dict[int, list[str]] = {}
|
||
doppelt_kredit: dict[int, list[str]] = {}
|
||
for k in all_conflicts(session):
|
||
doppelt_posten.setdefault(k.posten_id, []).append(k.kredit_name)
|
||
doppelt_kredit.setdefault(k.kredit_id, []).append(k.posten_name)
|
||
```
|
||
und im Kontext `"doppelt_posten": doppelt_posten, "doppelt_kredit": doppelt_kredit,`.
|
||
|
||
`planning.html`, Posten-Namenszelle (`<td>{{ r.name }}</td>` in `rec-row`):
|
||
```html
|
||
<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`):
|
||
```html
|
||
<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:
|
||
```css
|
||
/* Doppelzählung Kredit/Fixposten (Ausbaustufe 12). */
|
||
.warn-hint {
|
||
display: block;
|
||
font-size: 0.8em;
|
||
color: #b45309;
|
||
}
|
||
#doppelzaehlung-dialog {
|
||
max-width: 32rem;
|
||
}
|
||
```
|
||
|
||
- [x] **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).
|
||
|
||
- [x] **Step 9: Fable-Gate** — JS per Podman, Dateien explizit:
|
||
```bash
|
||
cd finance && podman run --rm -v "$PWD":/w:ro -w /w docker.io/library/node:22-alpine \
|
||
sh -c 'npm i --silent --no-save --prefix /tmp/j jsdom >/dev/null 2>&1; NODE_PATH=/tmp/j/node_modules node --test tests/js/doppelzaehlung.test.js tests/js/doppelzaehlung.dom.test.js tests/js/sort.test.js tests/js/sort.dom.test.js'
|
||
```
|
||
Erwartet: alle grün, DOM-Tests nicht geskippt. Zusätzlich: gerenderte `/planung` + echtes `htmx.min.js` + `doppelzaehlung.js` in jsdom mit gemocktem XHR (409-Antwort, danach 201) — Formular-Submit → Dialog → „Trotzdem speichern" → zweiter Request trägt `doppelzaehlung_bestaetigt: true`, dritter Submit ohne Flag. Bestehende Fehler (z.B. 422) weiter per `alert`.
|
||
|
||
- [x] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add finance/app/static/doppelzaehlung.js finance/app/static/style.css finance/app/templates/base.html \
|
||
finance/app/templates/planning.html finance/app/routers/gui.py finance/tests/js/doppelzaehlung.test.js \
|
||
finance/tests/js/doppelzaehlung.dom.test.js finance/tests/test_gui.py
|
||
git commit -m "feat: Warnfenster und Planungs-Hinweis bei möglicher Doppelzählung (A12-T4)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Doku, Beratungsumgebung, Release-Commit v0.12.0
|
||
|
||
**Files:**
|
||
- Modify: `finance/app/templates/hilfe.html`
|
||
- Modify: `docs/ARCHITEKTUR.md`
|
||
- Modify: `finance/VERSION`
|
||
- Modify (anderes Repo): `/home/wlfb/fb/.claude/skills/finanz-api/SKILL.md`
|
||
|
||
- [x] **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.
|
||
|
||
- [x] **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.
|
||
|
||
- [x] **Step 3: Version** — `finance/VERSION` → `0.12.0`; Suite grün.
|
||
|
||
- [x] **Step 4: Commit (Repo `~/bin`)**
|
||
|
||
```bash
|
||
git add finance/app/templates/hilfe.html docs/ARCHITEKTUR.md finance/VERSION
|
||
git commit -m "chore: Release v0.12.0 (System-Präfix, Doppelzählungs-Warnung)"
|
||
```
|
||
|
||
- [x] **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.
|
||
|
||
- [x] **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)
|
||
|
||
- [x] **Step 1: Backup** — `./backup_finance_pod.sh` (vor Redeploy).
|
||
- [x] **Step 2: Redeploy** — `./create_pod_finance.sh`; `/api/version` (mit Bearer) → `0.12.0`; `alembic current` unverändert `d5e8b3c7a410` (keine Migration).
|
||
- [x] **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.
|
||
- [x] **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`.
|