diff --git a/finance/app/routers/planning.py b/finance/app/routers/planning.py index 0a4eac4..f84841c 100644 --- a/finance/app/routers/planning.py +++ b/finance/app/routers/planning.py @@ -56,7 +56,9 @@ class SuggestionOut(BaseModel): amount: Decimal rhythm: str due_day: int + start_date: date | None = None category_id: int | None = None + hinweis: str = "" def _check_category(session: Session, category_id: int | None) -> None: diff --git a/finance/app/services/suggestions.py b/finance/app/services/suggestions.py index debaca5..68d482a 100644 --- a/finance/app/services/suggestions.py +++ b/finance/app/services/suggestions.py @@ -1,5 +1,25 @@ +"""Vorschlagsalgorithmus fuer wiederkehrende Buchungen (Ausbaustufe 9, v2). + +Ersetzt die reine exakte-Betrags-Gruppierung (v1) durch: Empfaenger-Cluster +mit Toleranz (haelt Preisdrift in einer Serie zusammen, trennt aber parallele +Vertraege desselben Anbieters), Rhythmus-Erkennung ueber den Median der +Buchungsabstaende (monatlich/vierteljaehrlich/jaehrlich statt nur monatlich), +einen Aktiv-Check (keine "Leichen"-Serien) sowie einen Merge-Pass fuer +Umfirmierungen (Anbieter aendert den Namen, die Serie laeuft inhaltlich +weiter). Bindende Spec: +docs/superpowers/specs/2026-07-20-vorschlags-algorithmus-v2-design.md. + +Alle Betrags-Toleranzvergleiche verwenden ausschliesslich `Decimal` +(CLAUDE.md: "Decimal, nicht float" - Rundungsfehler bei Geldbetraegen sind +inakzeptabel). Tage-Vergleiche (Rhythmus, Aktiv-Check, Merge-Luecke) sind +ganzzahlige Tage-Arithmetik, niemals float/Decimal-Bruchteile von Tagen. +""" +from __future__ import annotations + import statistics -from collections import Counter, defaultdict +from collections import Counter +from dataclasses import dataclass +from datetime import date, timedelta from decimal import Decimal from sqlalchemy import select @@ -7,46 +27,268 @@ from sqlalchemy.orm import Session from app.models.tables import RecurringItem, Transaction +# Betrachtungsfenster (Schritt 1): ~15 Monate. Muss mindestens die zwei +# Belege einer jaehrlichen Serie (bis zu 400 Tage auseinander) plus etwas +# Puffer fuer Cluster-/Merge-Bildung abdecken. +WINDOW_DAYS = 460 -def _max_consecutive_months(months: list[tuple[int, int]]) -> int: - if not months: - return 0 - best = current = 1 - for prev, cur in zip(months, months[1:]): - prev_idx = prev[0] * 12 + prev[1] - cur_idx = cur[0] * 12 + cur[1] - current = current + 1 if cur_idx == prev_idx + 1 else 1 - best = max(best, current) - return best +# Rhythmus-Tabelle: (min_tage, max_tage, mindestbelege) je Rhythmus. Der +# Median der Buchungsabstaende einer Serie muss ins Intervall fallen, UND es +# muessen mindestens so viele Buchungen vorliegen (ein einzelner Zufallstreffer +# mit "passendem" Abstand soll nicht als Serie gelten). +RHYTHMS: dict[str, tuple[int, int, int]] = { + "monthly": (25, 36, 3), + "quarterly": (80, 105, 3), + "yearly": (330, 400, 2), +} + +# Nominelle Schrittweite je Rhythmus in Tagen - Referenzwert fuer Aktiv-Check +# und Merge-Luecken-Fenster (Schritt 5/6). +STEP_DAYS: dict[str, int] = {"monthly": 30, "quarterly": 91, "yearly": 365} + +# Aktiv-Check (Schritt 5): die letzte Buchung darf hoechstens das 1,75-fache +# der Rhythmus-Schrittweite zurueckliegen, sonst gilt die Serie als beendet +# ("Leiche") und wird nicht vorgeschlagen. Als Fraction 7/4 ausgedrueckt und +# ganzzahlig verglichen (delta_tage * 4 <= schrittweite * 7), damit keine +# Gleitkomma-Rundung ueber "aktiv"/"inaktiv" entscheidet. +ACTIVITY_FACTOR_NUM = 7 +ACTIVITY_FACTOR_DEN = 4 + +# Relative Toleranzen (immer als Decimal verglichen, nie float): +CLUSTER_TOL = Decimal("0.35") # Schritt 3: Betrags-Cluster (haelt Preisdrift zusammen) +MERGE_TOL = Decimal("0.25") # Schritt 6: Umfirmierungs-Merge ueber Gruppengrenzen +BESTAND_TOL = Decimal("0.10") # Schritt 8: Bestandsabgleich gegen RecurringItem + +# Merge-Luecke (Schritt 6): die Zeit zwischen dem Ende von Serie A und dem +# Beginn von Serie B muss zwischen dem 0,4- und 1,6-fachen der +# Rhythmus-Schrittweite liegen (als ganzzahlige Bruchvergleiche, aus +# demselben Grund wie beim Aktiv-Check). +MERGE_GAP_MIN_NUM, MERGE_GAP_MIN_DEN = 4, 10 # 0.4 +MERGE_GAP_MAX_NUM, MERGE_GAP_MAX_DEN = 16, 10 # 1.6 +MERGE_DUE_DAY_TOL = 3 # Schritt 6: Faelligkeitstag-Toleranz in Tagen +BESTAND_DUE_DAY_TOL = 2 # Schritt 8: Faelligkeitstag-Toleranz in Tagen -def suggest_recurring(session: Session) -> list[dict]: +def _norm(name: str) -> str: + """Normalisiert einen Empfaenger-Namen fuer Gruppen- und + Substring-Vergleich: Bankexporte schreiben denselben Empfaenger nicht + einheitlich (Gross-/Kleinschreibung, mehrfache Leerzeichen), das ist fuer + die Erkennung irrelevant.""" + return " ".join(name.split()).casefold() + + +def _rel_diff(a: Decimal, b: Decimal) -> Decimal: + """Relative Differenz von Betrag a zur Referenz b (immer >= 0), als + Decimal. b=0 kommt praktisch nicht vor (eine Nullbuchung bildet keine + erkennbare Serie); fuer diesen Sonderfall gilt "keine Aehnlichkeit".""" + if b == 0: + return Decimal("Infinity") if a != 0 else Decimal("0") + return abs(a - b) / abs(b) + + +@dataclass +class _Series: + """Eine erkannte Serie: chronologisch sortierte Buchungen eines + Betrags-Clusters mit zugeordnetem Rhythmus.""" + items: list[Transaction] + rhythm: str + + @property + def first(self) -> Transaction: + return self.items[0] + + @property + def last(self) -> Transaction: + return self.items[-1] + + +def _amount_clusters(items: list[Transaction]) -> list[list[Transaction]]: + """Schritt 3: teilt chronologisch sortierte Buchungen einer + Empfaenger-Gruppe in Betrags-Cluster. Eine Buchung haengt sich an das + Cluster, dessen zuletzt aufgenommenes Mitglied gleiches Vorzeichen und + eine relative Differenz <= CLUSTER_TOL hat (greedy, erstes passendes + Cluster gewinnt) - das haelt eine langsam driftende Serie (Preiserhoehung) + zusammen, trennt aber parallele Vertraege mit deutlich anderem Betrag.""" + clusters: list[list[Transaction]] = [] + for t in items: + for cluster in clusters: + last = cluster[-1] + same_sign = (t.amount > 0) == (last.amount > 0) + if same_sign and _rel_diff(Decimal(t.amount), Decimal(last.amount)) <= CLUSTER_TOL: + cluster.append(t) + break + else: + clusters.append([t]) + return clusters + + +def _classify(dates: list[date]) -> str | None: + """Schritt 4: bestimmt den Rhythmus einer Serie ueber den Median der + Buchungsabstaende (robust gegen einzelne Ausreisser, z.B. + Wochenend-/Feiertagsverschiebung einer einzelnen Buchung).""" + if len(dates) < 2: + return None + gaps = [(b - a).days for a, b in zip(dates, dates[1:])] + median_gap = statistics.median(gaps) + for rhythm, (lo, hi, min_belege) in RHYTHMS.items(): + if len(dates) >= min_belege and lo <= median_gap <= hi: + return rhythm + return None + + +def _merge_gap_ok(gap_days: int, step: int) -> bool: + """Schritt 6: Luecke zwischen Serienende und -beginn im Fenster + [0,4; 1,6] * Schrittweite (ganzzahliger Bruchvergleich, keine Rundung).""" + return (gap_days * MERGE_GAP_MIN_DEN >= MERGE_GAP_MIN_NUM * step + and gap_days * MERGE_GAP_MAX_DEN <= MERGE_GAP_MAX_NUM * step) + + +def _mergeable(a: _Series, b: _Series) -> bool: + """Prueft die Umfirmierungs-Merge-Bedingungen aus Schritt 6 fuer ein + Paar (A endet, B beginnt danach): gleicher Rhythmus, plausible Luecke, + Faelligkeitstag nah beieinander (Transitionspunkte: letzte Buchung von A + gegen erste Buchung von B), Betrag nicht sprunghaft veraendert.""" + if a.rhythm != b.rhythm: + return False + if a.last.booking_date >= b.first.booking_date: + return False + step = STEP_DAYS[a.rhythm] + gap = (b.first.booking_date - a.last.booking_date).days + if not _merge_gap_ok(gap, step): + return False + if abs(a.last.booking_date.day - b.first.booking_date.day) > MERGE_DUE_DAY_TOL: + return False + same_sign = (a.last.amount > 0) == (b.first.amount > 0) + if not same_sign: + return False + return _rel_diff(Decimal(b.first.amount), Decimal(a.last.amount)) <= MERGE_TOL + + +def _try_merge(series_list: list[_Series]) -> list[_Series]: + """Schritt 6: fasst Serien desselben Kontos ueber Gruppengrenzen + (unterschiedlicher normalisierter Empfaenger-Name, z.B. nach einer + Umfirmierung) zusammen, solange `_mergeable` zutrifft. Laeuft iterativ + bis zum Fixpunkt, damit eine bereits gemergte Serie mit einer weiteren, + noch juengeren Serie erneut zusammengefasst werden kann (z.B. zwei + Umbenennungen hintereinander).""" + series_list = list(series_list) + changed = True + while changed: + changed = False + for i, a in enumerate(series_list): + for j, b in enumerate(series_list): + if i == j or not _mergeable(a, b): + continue + merged = _Series( + items=sorted(a.items + b.items, key=lambda t: t.booking_date), + rhythm=a.rhythm, + ) + series_list = [s for k, s in enumerate(series_list) if k not in (i, j)] + series_list.append(merged) + changed = True + break + if changed: + break + return series_list + + +def _covered_by_existing(cand_name: str, cand_amount: Decimal, rhythm: str, due_day: int, + existing: list[RecurringItem]) -> bool: + """Schritt 8 (Bestandsabgleich): ein Vorschlag entfaellt, wenn er bereits + als Fixposten gepflegt ist - entweder ueber einen Namens-Substring-Match + (normalisiert, in beide Richtungen: sowohl Kurz- als auch + Langschreibweisen kommen in der Praxis in beiden Datenquellen vor) oder + ueber Rhythmus + Faelligkeitstag + Betrag innerhalb enger Toleranz (falls + der Fixposten unter einem ganz anderen Namen gepflegt wurde).""" + cand_norm = _norm(cand_name) + for item in existing: + item_norm = _norm(item.name) + if cand_norm in item_norm or item_norm in cand_norm: + return True + if (item.rhythm == rhythm + and abs(item.due_day - due_day) <= BESTAND_DUE_DAY_TOL + and _rel_diff(cand_amount, Decimal(item.amount)) <= BESTAND_TOL): + return True + return False + + +def suggest_recurring(session: Session, today: date | None = None) -> list[dict]: + """Ermittelt Vorschlaege fuer wiederkehrende Posten aus bestaetigten + Buchungen der letzten WINDOW_DAYS Tage. `today` ist ausschliesslich zu + Testzwecken injizierbar (deterministischer Aktiv-Check) - im + Produktivbetrieb liefert der Default `date.today()`. Reihenfolge der + Schritte gemaess Spec, mit einer bewussten Umstellung gegenueber der + Nummerierung dort: der Aktiv-Check (Schritt 5) laeuft NACH dem + Umfirmierungs-Merge (Schritt 6) auf der ggf. gemergten Serie - sonst + wuerde eine per Umfirmierung fortgesetzte Serie an ihrem alten, + laengst inaktiven Teil scheitern, bevor der Merge sie retten kann.""" + if today is None: + today = date.today() + cutoff = today - timedelta(days=WINDOW_DAYS) + + # Schritt 1: Datenbasis. txs = session.execute( - select(Transaction).where(Transaction.status == "confirmed") + select(Transaction) + .where(Transaction.status == "confirmed", Transaction.booking_date >= cutoff) ).scalars().all() - groups: dict[tuple, list[Transaction]] = defaultdict(list) - for t in txs: - groups[(t.account_id, t.counterparty, t.amount)].append(t) - existing = {(r.name, Decimal(r.amount)) - for r in session.execute(select(RecurringItem)).scalars()} + # Schritt 2: Gruppierung je (Konto, normalisierter Empfaenger). + groups: dict[tuple[int, str], list[Transaction]] = {} + for t in txs: + groups.setdefault((t.account_id, _norm(t.counterparty)), []).append(t) + + # Schritt 3+4: je Gruppe Betrags-Cluster bilden und Rhythmus klassifizieren. + series_by_account: dict[int, list[_Series]] = {} + for (account_id, _name_norm), items in groups.items(): + items_sorted = sorted(items, key=lambda t: t.booking_date) + for cluster in _amount_clusters(items_sorted): + rhythm = _classify([t.booking_date for t in cluster]) + if rhythm is None: + continue + series_by_account.setdefault(account_id, []).append( + _Series(items=cluster, rhythm=rhythm)) + + existing = list(session.execute(select(RecurringItem)).scalars()) suggestions: list[dict] = [] - for (_account_id, counterparty, amount), items in groups.items(): - months = sorted({(t.booking_date.year, t.booking_date.month) for t in items}) - if _max_consecutive_months(months) < 3: - continue - name = counterparty - if (name, Decimal(amount)) in existing: - continue - due_day = int(statistics.median(sorted(t.booking_date.day for t in items))) - cat_counts = Counter(t.category_id for t in items if t.category_id is not None) - category_id = cat_counts.most_common(1)[0][0] if cat_counts else None - suggestions.append({ - "name": name, - "amount": Decimal(amount), - "rhythm": "monthly", - "due_day": due_day, - "category_id": category_id, - }) + for series_list in series_by_account.values(): + # Schritt 6: Umfirmierungs-Merge ueber Gruppengrenzen, je Konto. + for s in _try_merge(series_list): + # Schritt 5: Aktiv-Check auf der (ggf. gemergten) finalen Serie. + step = STEP_DAYS[s.rhythm] + delta_tage = (today - s.last.booking_date).days + if delta_tage * ACTIVITY_FACTOR_DEN > step * ACTIVITY_FACTOR_NUM: + continue + + # Schritt 7: Vorschlagswerte aus der neuesten Buchung. + last = s.last + name = last.counterparty + amount = Decimal(last.amount) + due_day = last.booking_date.day + start_date = last.booking_date if s.rhythm in ("quarterly", "yearly") else None + cat_counts = Counter(t.category_id for t in s.items if t.category_id is not None) + category_id = cat_counts.most_common(1)[0][0] if cat_counts else None + + hinweis = "" + if len(s.items) >= 2: + previous = Decimal(s.items[-2].amount) + # "Gestiegen" bezieht sich auf den Betragswert (Ausgaben sind + # negativ: gestiegen heisst betragsmaessig groesser, also + # abs(neu) > abs(alt)), nicht auf das Vorzeichen. + if abs(amount) > abs(previous): + hinweis = f"Betrag zuletzt gestiegen (vorher {abs(previous)})" + + # Schritt 8: Bestandsabgleich. + if _covered_by_existing(name, amount, s.rhythm, due_day, existing): + continue + + suggestions.append({ + "name": name, + "amount": amount, + "rhythm": s.rhythm, + "due_day": due_day, + "start_date": start_date, + "category_id": category_id, + "hinweis": hinweis, + }) return suggestions diff --git a/finance/tests/test_planning_api.py b/finance/tests/test_planning_api.py index 26501d0..db924e4 100644 --- a/finance/tests/test_planning_api.py +++ b/finance/tests/test_planning_api.py @@ -1,8 +1,13 @@ from datetime import date from decimal import Decimal +from itertools import count from app.models.tables import Account, Category, RecurringItem, Transaction -from app.services.suggestions import suggest_recurring +from app.services.suggestions import _Series, _try_merge, suggest_recurring + +# Feste Vergleichs-"heute" fuer alle Vorschlags-Tests (Ausbaustufe 9): macht +# den Aktiv-Check deterministisch, ohne echtes date.today() im Testlauf. +TODAY = date(2026, 7, 20) H = {"Authorization": "Bearer test-key"} @@ -72,42 +77,60 @@ def test_loan_in_scenario_keeps_balance_positive(client, db): assert Decimal(body["low_point_balance"]) > Decimal("0") -def _tx(acc, d, amount, counterparty, dedup, category_id=None): - return Transaction(account_id=acc.id, booking_date=d, amount=Decimal(amount), - purpose="", counterparty=counterparty, status="confirmed", - dedup_hash=dedup, category_id=category_id) +_iban_seq = count(1) + + +def _acc(db) -> int: + """Legt ein Konto an und gibt dessen id zurueck. IBAN ist je Aufruf + eindeutig (falls ein Test mehrere Konten braucht), Praefix "DE" plus + laufende Nummer reicht dafuer aus.""" + acc = Account(bank="dkb", iban=f"DE{next(_iban_seq):032d}"[:34], name="G", type="giro") + db.add(acc) + db.flush() + return acc.id + + +def _tx(db, acc_id, d, amount, cp, cat=None): + """Legt eine bestaetigte Buchung an. dedup_hash aus den Nutzdaten + abgeleitet reicht fuer Testzwecke (muss nur innerhalb eines Tests + eindeutig sein).""" + 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}")) def test_suggest_recurring_three_consecutive_months_with_year_wrap(db): - acc = Account(bank="dkb", iban="DE01", name="G", type="giro") - db.add(acc) - db.flush() + acc = _acc(db) cat = Category(name="Miete") db.add(cat) db.flush() # Dez 2025 -> Jan 2026 -> Feb 2026: 3 aufeinanderfolgende Monate ueber den - # Jahreswechsel hinweg (prueft die Monats-Linearisierung y*12+m). - db.add(_tx(acc, date(2025, 12, 1), "-600.00", "Vermieter", "h1", cat.id)) - db.add(_tx(acc, date(2026, 1, 15), "-600.00", "Vermieter", "h2", cat.id)) - db.add(_tx(acc, date(2026, 2, 28), "-600.00", "Vermieter", "h3", cat.id)) + # Jahreswechsel hinweg, gleichmaessiger 31-Tage-Abstand (Median im + # monatlichen Fenster 25-36 Tage; die alte Jahreswechsel-Pruefung galt der + # Monats-Linearisierung, die es im neuen Tage-basierten Median-Ansatz + # nicht mehr braucht). + _tx(db, acc, date(2025, 12, 1), "-600.00", "Vermieter", cat.id) + _tx(db, acc, date(2026, 1, 1), "-600.00", "Vermieter", cat.id) + _tx(db, acc, date(2026, 2, 1), "-600.00", "Vermieter", cat.id) db.commit() - out = suggest_recurring(db) + out = suggest_recurring(db, today=date(2026, 2, 10)) - # Median der Tage [1, 15, 28] = 15; Betrag unveraendert uebernommen. + # due_day = Tag der NEUESTEN Buchung (nicht mehr Median, Spec Schritt 7); + # Betrag unveraendert, keine Preissteigerung -> hinweis leer. assert out == [{"name": "Vermieter", "amount": Decimal("-600.00"), - "rhythm": "monthly", "due_day": 15, "category_id": cat.id}] + "rhythm": "monthly", "due_day": 1, "start_date": None, + "category_id": cat.id, "hinweis": ""}] def test_suggest_recurring_two_months_no_suggestion(db): - acc = Account(bank="dkb", iban="DE01", name="G", type="giro") - db.add(acc) - db.flush() - db.add(_tx(acc, date(2026, 3, 10), "-50.00", "Zweimonatig", "h1")) - db.add(_tx(acc, date(2026, 4, 10), "-50.00", "Zweimonatig", "h2")) + acc = _acc(db) + _tx(db, acc, date(2026, 3, 10), "-50.00", "Zweimonatig") + _tx(db, acc, date(2026, 4, 10), "-50.00", "Zweimonatig") db.commit() - assert suggest_recurring(db) == [] + # Nur 2 Belege: Mindestbelege fuer monthly (>=3) nicht erreicht. + assert suggest_recurring(db, today=date(2026, 4, 20)) == [] def test_recurring_ende_vor_start_wird_abgelehnt(client): @@ -135,15 +158,165 @@ def test_recurring_start_ende_roundtrip_und_patch_validierung(client): def test_suggest_recurring_excludes_existing_recurring_item(db): - acc = Account(bank="dkb", iban="DE01", name="G", type="giro") - db.add(acc) - db.flush() - db.add(_tx(acc, date(2026, 1, 5), "-30.00", "Streaming", "h1")) - db.add(_tx(acc, date(2026, 2, 5), "-30.00", "Streaming", "h2")) - db.add(_tx(acc, date(2026, 3, 5), "-30.00", "Streaming", "h3")) + acc = _acc(db) + _tx(db, acc, date(2026, 1, 5), "-30.00", "Streaming") + _tx(db, acc, date(2026, 2, 5), "-30.00", "Streaming") + _tx(db, acc, date(2026, 3, 5), "-30.00", "Streaming") db.add(RecurringItem(name="Streaming", amount=Decimal("-30.00"), rhythm="monthly", due_day=5)) db.commit() - # Gleicher Name + Betrag wie ein bereits vorhandenes RecurringItem -> ausgelassen. - assert suggest_recurring(db) == [] + # Gleicher Name wie ein bereits vorhandenes RecurringItem -> Bestandsabgleich + # (Schritt 8, Namens-Match) greift, unabhaengig vom Betrag. + assert suggest_recurring(db, today=date(2026, 3, 20)) == [] + + +# --------------------------------------------------- Ausbaustufe 9: Algorithmus v2 +# Synthetische Faelle aus der Spec (siehe +# docs/superpowers/specs/2026-07-20-vorschlags-algorithmus-v2-design.md). + +def test_suggest_letzter_betrag_bei_preiserhoehung(db): + acc = _acc(db) + 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")} + + +def test_try_merge_kombiniert_serien_ueber_gruppengrenzen(): + # Direkter, isolierter Test der Merge-Mechanik (Schritt 6): siehe Report + # fuer den rechnerischen Nachweis, dass ein End-to-End-Szenario, in dem + # die ALTE und die NEUE Serie GLEICHZEITIG unabhaengig voneinander den + # Aktiv-Check bestehen, fuer keinen der drei Rhythmen innerhalb von + # WINDOW_DAYS=460 konstruierbar ist (die alte Serie ist beim Aktiv-Check + # immer laengst "tot", sobald die neue genug eigene Belege hat, bzw. bei + # yearly passt die noetige Gesamtspanne nicht ins Fenster). Deshalb hier + # `_try_merge` direkt gegen zwei synthetische `_Series` geprueft, ganz ohne + # DB/Fenster/Aktiv-Check-Interaktion. + a = _Series(items=[ + Transaction(booking_date=date(2026, 1, 3), amount=Decimal("-50.00"), + counterparty="Alte Firma GmbH", category_id=None), + Transaction(booking_date=date(2026, 2, 3), amount=Decimal("-50.00"), + counterparty="Alte Firma GmbH", category_id=None), + Transaction(booking_date=date(2026, 3, 3), amount=Decimal("-50.00"), + counterparty="Alte Firma GmbH", category_id=None), + ], rhythm="monthly") + b = _Series(items=[ + Transaction(booking_date=date(2026, 4, 5), amount=Decimal("-52.00"), + counterparty="Neue Firma GmbH", category_id=None), + Transaction(booking_date=date(2026, 5, 5), amount=Decimal("-52.00"), + counterparty="Neue Firma GmbH", category_id=None), + Transaction(booking_date=date(2026, 6, 5), amount=Decimal("-52.00"), + counterparty="Neue Firma GmbH", category_id=None), + ], rhythm="monthly") + + merged = _try_merge([a, b]) + + # Luecke A-Ende->B-Anfang = 33 Tage (in [12,48]), Faelligkeitstag 3 vs 5 + # (Differenz 2 <= 3), Betrag +4% (<=25%) -> Bedingungen erfuellt, genau + # EINE kombinierte Serie mit allen 6 Buchungen, juengste zuerst. + assert len(merged) == 1 + assert len(merged[0].items) == 6 + assert merged[0].last.counterparty == "Neue Firma GmbH" + + +def test_suggest_umfirmierung_merge_verschiebt_kategorie_mehrheit(db): + # End-to-End-Nachweis, dass Schritt 6 tatsaechlich in `suggest_recurring` + # verdrahtet ist: da die Anzahl der Vorschlaege sich (bewiesenermassen, + # siehe Report) end-to-end NICHT als Diskriminator eignet (die alte Serie + # faellt so oder so per Aktiv-Check heraus), wird hier die + # Kategorie-Mehrheit als Diskriminator genutzt - die haengt direkt davon + # ab, ob die Buchungen der alten Serie ueber den Merge in die Zaehlung + # eingehen. Alte Serie: 4 Buchungen Kategorie A. Neue Serie: 3 Buchungen + # Kategorie B. Ohne Merge zaehlen nur die 3 B-Buchungen (Mehrheit B). Mit + # Merge kommen die 4 A-Buchungen dazu und kippen die Mehrheit auf A. + acc = _acc(db) + cat_a = Category(name="Alt-Kategorie") + cat_b = Category(name="Neu-Kategorie") + db.add(cat_a) + db.add(cat_b) + db.flush() + for d in [date(2026, 1, 3), date(2026, 2, 3), date(2026, 3, 3), date(2026, 4, 3)]: + _tx(db, acc, d, "-50.00", "Alte Firma GmbH", cat_a.id) + for d in [date(2026, 5, 5), date(2026, 6, 5), date(2026, 7, 5)]: + _tx(db, acc, d, "-52.00", "Neue Firma GmbH", cat_b.id) + db.commit() + + out = suggest_recurring(db, today=date(2026, 7, 20)) + + assert len(out) == 1 + assert out[0]["name"] == "Neue Firma GmbH" and out[0]["amount"] == Decimal("-52.00") + # Kategorie-Mehrheit kippt durch den Merge von B (3) auf A (4): + assert out[0]["category_id"] == cat_a.id