feat: Vorschlags-Algorithmus v2 (Rhythmen, letzter Betrag, Aktiv-Check, Merge)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 21:20:04 +02:00
parent e002d8205f
commit 28d1267f0c
3 changed files with 481 additions and 64 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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