# Ausbaustufe 9 Implementation Plan — Vorschlags-Algorithmus v2 (v0.9.0) > **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:** `suggest_recurring` erkennt monatliche/vierteljährliche/jährliche Serien mit letztem Betrag, Aktiv-Check, Betrags-Clustern, Umfirmierungs-Merge und robustem Bestandsabgleich; GUI zeigt Rhythmus/Start/Hinweis; Release v0.9.0 mit Live-Gate gegen die echten Daten. **Architecture:** Vollständiger Rewrite von `app/services/suggestions.py` (reine Session-in/dict-out-Funktion, Parameter als Modul-Konstanten); `SuggestionOut`-Erweiterung in `routers/planning.py`; Template-Anpassung der Vorschlags-Tabelle. Kein Datenmodell-/Migrationsbedarf. **Tech Stack:** SQLAlchemy 2, Pydantic v2, Jinja2, pytest (synthetische Daten). **Spec:** `docs/superpowers/specs/2026-07-20-vorschlags-algorithmus-v2-design.md` — die dortigen Abschnitte „Algorithmus" (8 Schritte, Konstanten) und „Tests" sind bindend und Teil dieses Plans. ## Global Constraints - Beträge `Decimal` (keine float-Arithmetik, auch nicht in Toleranzvergleichen — relative Differenzen als `Decimal`-Quotienten). - `date.today()` nur an EINER Stelle (Parameter `today: date | None = None` der Hauptfunktion, Default heute) — Tests injizieren ein festes Datum. - Tests ausschließlich mit synthetischen Daten (DATENSCHUTZ: keine echten Namen/Beträge aus der Live-DB in Tests/Commits). - GUI deutsch, TT.MM.JJJJ, `|eur`, `|de_label`; API Punkt-Dezimal. - Fable-Testagent-Gate je Task VOR Commit; Ledger-Eintrag je Task. - Testlauf: `cd /home/wlfb/bin/finance && .venv/bin/python -m pytest -q` — Basis 187 passed, muss grün bleiben (drei bestehende Suggestion-Tests DÜRFEN an die neue Semantik angepasst werden, siehe Task 1 Step 4). --- ### Task 1: Algorithmus-Rewrite + API-Schema **Files:** - Rewrite: `app/services/suggestions.py` - Modify: `app/routers/planning.py` (`SuggestionOut`) - Modify: `tests/test_planning_api.py` **Interfaces:** - Produces: `suggest_recurring(session, today: date | None = None) -> list[dict]` mit Keys `name, amount (Decimal), rhythm, due_day, start_date (date|None), category_id, hinweis (str)`; `SuggestionOut` mit denselben Feldern (`start_date: date | None = None`, `hinweis: str = ""`). Task 2 rendert genau diese Felder. - [ ] **Step 1: Failing Tests** — in `tests/test_planning_api.py` die drei bestehenden Suggestion-Tests ERSETZEN/ERWEITERN durch die Spec-Fälle (Helper zum Anlegen synthetischer Buchungen schreiben; `dedup_hash` eindeutig, `status="confirmed"`; ein Account genügt; `today=date(2026, 7, 20)` in alle Aufrufe injizieren): ```python from datetime import date from decimal import Decimal def _tx(db, acc_id, d, amount, cp, cat=None): db.add(Transaction(account_id=acc_id, booking_date=d, amount=Decimal(amount), purpose="p", counterparty=cp, category_id=cat, status="confirmed", dedup_hash=f"h-{cp}-{d}-{amount}")) TODAY = date(2026, 7, 20) def test_suggest_letzter_betrag_bei_preiserhoehung(db): acc = _acc(db) # Helper: Account anlegen, gibt id zurueck for d, a in [(date(2026, 3, 1), "-190.65"), (date(2026, 4, 1), "-202.94"), (date(2026, 5, 4), "-202.94"), (date(2026, 6, 1), "-202.94"), (date(2026, 7, 1), "-202.94")]: _tx(db, acc, d, a, "Entis Lebensversicherung AG") db.commit() out = suggest_recurring(db, today=TODAY) assert len(out) == 1 s = out[0] assert s["amount"] == Decimal("-202.94") and s["rhythm"] == "monthly" assert s["due_day"] == 1 and s["start_date"] is None def test_suggest_quartal_mit_phase(db): acc = _acc(db) for d in [date(2025, 9, 15), date(2025, 12, 15), date(2026, 3, 16), date(2026, 6, 15)]: _tx(db, acc, d, "-55.08", "Rundfunk ARD ZDF") db.commit() out = suggest_recurring(db, today=TODAY) assert len(out) == 1 assert out[0]["rhythm"] == "quarterly" assert out[0]["start_date"] == date(2026, 6, 15) and out[0]["due_day"] == 15 def test_suggest_jahr_mit_zwei_belegen(db): acc = _acc(db) for d, a in [(date(2025, 6, 16), "-409.92"), (date(2026, 6, 16), "-467.33")]: _tx(db, acc, d, a, "Kraftfahrer-Schutz e.V.") db.commit() out = suggest_recurring(db, today=TODAY) assert len(out) == 1 assert out[0]["rhythm"] == "yearly" and out[0]["amount"] == Decimal("-467.33") assert out[0]["start_date"] == date(2026, 6, 16) assert "409.92" in out[0]["hinweis"] # Betrag zuletzt gestiegen def test_suggest_tote_serie_kein_vorschlag(db): acc = _acc(db) for m in (9, 10, 11, 12): _tx(db, acc, date(2025, m, 1), "-35.00", "WWK Alt") db.commit() assert suggest_recurring(db, today=TODAY) == [] def test_suggest_umfirmierung_merge(db): acc = _acc(db) for m in (11, 12): _tx(db, acc, date(2025, m, 1), "-190.65", "Heidelberger Leben") for m in (1, 2, 3): _tx(db, acc, date(2026, m, 2), "-190.65", "Entis Lebensversicherung") for m in (4, 5, 6, 7): _tx(db, acc, date(2026, m, 1), "-202.94", "Entis Lebensversicherung") db.commit() out = suggest_recurring(db, today=TODAY) assert len(out) == 1 assert "Entis" in out[0]["name"] and out[0]["amount"] == Decimal("-202.94") def test_suggest_bestandsabgleich_trotz_preisdrift(db): acc = _acc(db) db.add(RecurringItem(name="Entis Lebensversicherung AG", amount=Decimal("-190.65"), rhythm="monthly", due_day=1)) for m in (4, 5, 6, 7): _tx(db, acc, date(2026, m, 1), "-202.94", "Entis Lebensversicherung AG") db.commit() assert suggest_recurring(db, today=TODAY) == [] # Namens-Match schlaegt an def test_suggest_zwei_vertraege_getrennt(db): acc = _acc(db) for m in (4, 5, 6, 7): _tx(db, acc, date(2026, m, 1), "-346.23", "Heidelberger LV") _tx(db, acc, date(2026, m, 2), "-145.21", "Heidelberger LV") db.commit() out = suggest_recurring(db, today=TODAY) assert len(out) == 2 assert {s["amount"] for s in out} == {Decimal("-346.23"), Decimal("-145.21")} ``` (`_acc`-Helper analog bestehender Tests; `RecurringItem`/`Transaction`-Importe existieren.) Die drei Alt-Tests (`three_consecutive_months_with_year_wrap`, `two_months_no_suggestion`, `excludes_existing_recurring_item`) an die neue Signatur/Semantik anpassen: feste `today`-Injektion; Daten ggf. ins Fenster schieben; der Exclusion-Test bleibt inhaltlich gültig (Name-Match). Run: `.venv/bin/python -m pytest tests/test_planning_api.py -q` → neue Tests FAIL - [ ] **Step 2: Rewrite `app/services/suggestions.py`** gemäß Spec-Abschnitt „Algorithmus" (8 Schritte, Konstanten `WINDOW_DAYS=460`, Rhythmus-Tabelle monthly 25-36/≥3, quarterly 80-105/≥3, yearly 330-400/≥2, `STEP_DAYS={"monthly":30,"quarterly":91,"yearly":365}`, `ACTIVITY_FACTOR` 7/4 als `Fraction` oder Tage-Vergleich ganzzahlig, Cluster 35 %, Merge 25 %, Bestand 10 % — alle Toleranzvergleiche als `Decimal`). Struktur: `_norm`, `_rel_diff`, `_amount_clusters` (greedy gegen letztes Mitglied, gleiches Vorzeichen), `_classify` (Median der Abstände), Merge-Pass je Konto über alle Serien, `_covered_by_existing`, Hauptfunktion `suggest_recurring(session, today=None)`. Deutsche Docstrings/Kommentare zur Begründung der Toleranzen. - [ ] **Step 3: `SuggestionOut` erweitern** — `routers/planning.py`: ```python class SuggestionOut(BaseModel): name: str amount: Decimal rhythm: str due_day: int start_date: date | None = None category_id: int | None = None hinweis: str = "" ``` - [ ] **Step 4: Tests + Suite grün** — `.venv/bin/python -m pytest -q` → PASS (Alt-Test-Anpassungen im Report begründen). - [ ] **Step 5: Fable-Testagent-Abnahme** (Faktencheck: Toleranz-Arithmetik Decimal-rein; Aktiv-Check-Grenzen; Merge-Bedingungen; keine `date.today()`-Streuung; Alt-Test-Anpassungen berechtigt). Erst nach VERIFIED weiter. - [ ] **Step 6: Commit** — `git add finance/app/services/suggestions.py finance/app/routers/planning.py finance/tests/test_planning_api.py && git commit -m "feat: Vorschlags-Algorithmus v2 (Rhythmen, letzter Betrag, Aktiv-Check, Merge)"` --- ### Task 2: GUI — Rhythmus/Start/Hinweis in der Vorschlags-Tabelle **Files:** - Modify: `app/templates/planning.html` (Fieldset „Vorschläge aus Buchungen") - Modify: `tests/test_gui.py` **Interfaces:** - Consumes: `SuggestionOut`-Felder aus Task 1; Filter `|eur`/`|de_label`. - [ ] **Step 1: Failing GUI-Test** — in `tests/test_gui.py` (synthetische Serie seeden, `/planung` laden): ```python def test_vorschlaege_zeigen_rhythmus_und_start(client, db): client.post("/login", data={"username": "admin", "password": "geheim"}) acc = Account(bank="dkb", iban="DE-SUG-1", name="S", type="giro") db.add(acc) db.flush() for d in (date(2025, 9, 15), date(2025, 12, 15), date(2026, 3, 16), date(2026, 6, 15)): db.add(Transaction(account_id=acc.id, booking_date=d, amount=Decimal("-55.08"), purpose="p", counterparty="Rundfunk Synth", status="confirmed", dedup_hash=f"sug-{d}")) db.commit() r = client.get("/planung").text assert "vierteljährlich" in r # de_label des Rhythmus assert "15.06.2026" in r # Start-Spalte TT.MM.JJJJ assert 'name="start_date"' in r # hidden input der Uebernahme ``` WICHTIG: Der Test hängt von `date.today()` der App ab (Aktiv-Check!) — Serie so legen, dass sie um den echten Testlauf-Zeitpunkt herum aktiv ist, oder (besser) `suggest_recurring` in `planung_page` unverändert lassen und den Test mit relativen Daten um `date.today()` konstruieren (letzte Buchung ≤ 45 Tage vor heute, Quartalsschritte rückwärts). Die Variante mit relativen Daten umsetzen; die obigen Fixdaten sind als Muster zu verstehen und auf `date.today()`-relative Werte umzustellen (inkl. erwartetem Start-String via `.strftime('%d.%m.%Y')`). - [ ] **Step 2: Template** — Vorschlags-Tabelle: Kopf `Name | Betrag | Rhythmus | Fälligkeitstag | Start | (Aktion)`; Zellen `{{ s.rhythm|de_label }}`, `{{ s.start_date.strftime('%d.%m.%Y') if s.start_date else '–' }}`; Betrag-Zelle ergänzt `{% if s.hinweis %}{{ s.hinweis }}{% endif %}`; Übernahme-Formular: hidden inputs unverändert plus `` (json-form macht leer → null). Hinweistext unter dem Fieldset: „Erkannt werden monatliche, vierteljährliche und jährliche Serien; Betrag = jeweils letzte Buchung." - [ ] **Step 3: Suite grün**; **Step 4: Fable-Abnahme** (Live-Approximation: Rendering + Übernahme-Roundtrip eines Quartals-Vorschlags inkl. start_date); **Step 5: Commit** `feat: Vorschlaege mit Rhythmus, Start und Hinweis`. --- ### Task 3: Release v0.9.0 + Live-Gate gegen echte Daten - [ ] **Step 1:** Suite final; `VERSION` → 0.9.0; Commit; `./create_pod_finance.sh`; `/api/version` == 0.9.0. - [ ] **Step 2: Fable-Release-Gate (LIVE, lesend):** `GET /api/recurring/suggestions` gegen die echte DB. Prüfen: (a) KEINER der bestehenden ~41 Fixposten wird erneut vorgeschlagen (Bestandsabgleich wirkt, auch bei gedrifteten Beträgen); (b) keine bekannten toten Serien (gelöschte PayPal-−4,99-Serie, ausgelaufene WWK-−35-Police) im Ergebnis; (c) verbleibende Vorschläge einzeln gegen die Buchungen plausibilisieren (echte aktive Serie? korrekte Werte?). Ergebnisliste NUR im Chat/Bericht, nie committen. Bei Fehlklassifikationen: Befund zurück an Task 1 (Toleranzen), Fix + Re-Gate. - [ ] **Step 3:** Ledger (generisch) + Plan-Häkchen + Push; Kandidatenliste dem Nutzer berichten. --- ## Self-Review - Spec-Abdeckung: Algorithmus/Schema → Task 1; GUI → Task 2; Release/Live-Gate → Task 3. Testfälle der Spec vollständig in Task 1 Step 1 kodiert. - Platzhalter: Task 1 Step 2 verweist bewusst auf den bindenden Spec-Abschnitt (8 nummerierte Schritte + Konstanten) statt den vollen Code zu duplizieren; alle Schnittstellen/Konstanten sind exakt benannt. - Typ-Konsistenz: `suggest_recurring(session, today)`-Signatur = Testaufrufe; `SuggestionOut`-Felder = Template-Zugriffe (`s.rhythm`, `s.start_date`, `s.hinweis`).