12 KiB
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 (
- [x]) 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 alsDecimal-Quotienten). date.today()nur an EINER Stelle (Parametertoday: date | None = Noneder 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 Keysname, amount (Decimal), rhythm, due_day, start_date (date|None), category_id, hinweis (str);SuggestionOutmit denselben Feldern (start_date: date | None = None,hinweis: str = ""). Task 2 rendert genau diese Felder. -
Step 1: Failing Tests — in
tests/test_planning_api.pydie drei bestehenden Suggestion-Tests ERSETZEN/ERWEITERN durch die Spec-Fälle (Helper zum Anlegen synthetischer Buchungen schreiben;dedup_hasheindeutig,status="confirmed"; ein Account genügt;today=date(2026, 7, 20)in alle Aufrufe injizieren):
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.pygemäß Spec-Abschnitt „Algorithmus" (8 Schritte, KonstantenWINDOW_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_FACTOR7/4 alsFractionoder Tage-Vergleich ganzzahlig, Cluster 35 %, Merge 25 %, Bestand 10 % — alle Toleranzvergleiche alsDecimal). 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, Hauptfunktionsuggest_recurring(session, today=None). Deutsche Docstrings/Kommentare zur Begründung der Toleranzen. -
Step 3:
SuggestionOuterweitern —routers/planning.py:
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,/planungladen):
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 %}<span class="muted">{{ s.hinweis }}</span>{% endif %}; Übernahme-Formular: hidden inputs unverändert plus<input type="hidden" name="start_date" value="{{ s.start_date.isoformat() if s.start_date else '' }}">(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/suggestionsgegen 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).