diff --git a/backend/app/services/uebertraege.py b/backend/app/services/uebertraege.py index b823ce4..7be0b83 100644 --- a/backend/app/services/uebertraege.py +++ b/backend/app/services/uebertraege.py @@ -41,7 +41,7 @@ from ..models import ( User, ) from . import currency -from .csv_import import normalize_text +from .csv_import import INBOX_TAGE, normalize_text # Wörter, die eine Bewegung zwischen eigenen Konten ankündigen. Ein Treffer # allein verknüpft noch nichts – er erlaubt nur, dass es ohne Rückfrage @@ -146,8 +146,19 @@ def _hat_hinweis(a: Transaction, b: Transaction, bezeichner: dict[int, list[str] return False -def _kandidaten(db: Session, user: User) -> dict[int, list[Transaction]]: - """Zu jeder unverknüpften Buchung die möglichen Gegenstücke.""" +def _kandidaten(db: Session, user: User, + fenster: int = 0) -> dict[int, list[Transaction]]: + """Zu jeder unverknüpften Buchung die möglichen Gegenstücke. + + ``fenster`` ist der erlaubte Abstand in Tagen. 0 heisst derselbe Tag – so + eng muss es bleiben, wo von selbst verknüpft wird. + + Für reine VORSCHLÄGE darf es weiter sein, denn dort entscheidet der Nutzer. + Sonst klafft eine Lücke: Die Übertrags-Prüfung sucht die Gegenseite über + ±INBOX_TAGE und schickt den Nutzer zum Verbinden – die Vorschlagsliste aber + kannte nur den gleichen Tag und bot dieses Paar nie an. Die Bank bucht die + Gegenseite regelmässig ein bis drei Tage später. + """ verknuepft = set() for a, b in db.query(TransferLink.from_txn_id, TransferLink.to_txn_id).filter( TransferLink.owner_id == user.id @@ -169,18 +180,23 @@ def _kandidaten(db: Session, user: User) -> dict[int, list[Transaction]]: if t.id not in verknuepft ] - # Nach Tag und Betrag bündeln: Nur wer beides teilt, kommt überhaupt infrage. - nach_schluessel: dict[tuple, list[Transaction]] = defaultdict(list) + # Nach Betrag bündeln, der Tag wird danach geprüft – nur so lässt sich ein + # Fenster zulassen. Innerhalb eines Bündels nach Datum sortiert, damit die + # Suche abbrechen kann, sobald der Abstand zu gross wird. + nach_betrag: dict[int, list[Transaction]] = defaultdict(list) for t in buchungen: - nach_schluessel[(t.date, t.amount_cents)].append(t) + nach_betrag[t.amount_cents].append(t) kandidaten: dict[int, list[Transaction]] = defaultdict(list) - for gruppe in nach_schluessel.values(): + for gruppe in nach_betrag.values(): if len(gruppe) < 2: continue - for a in gruppe: - for b in gruppe: - if a.id == b.id or a.account_id == b.account_id: + gruppe.sort(key=lambda t: (t.date, t.id)) + for i, a in enumerate(gruppe): + for b in gruppe[i + 1:]: + if (b.date - a.date).days > fenster: + break # sortiert: alles Weitere liegt noch ferner + if a.account_id == b.account_id: continue if a.type == b.type: continue # eine Ausgabe, eine Einnahme @@ -190,11 +206,19 @@ def _kandidaten(db: Session, user: User) -> dict[int, list[Transaction]]: if tuple(sorted((a.id, b.id))) in ignoriert: continue kandidaten[a.id].append(b) + kandidaten[b.id].append(a) - # Währungswechsel: gleicher Tag, aber unterschiedliche Beträge. Hier - # entscheidet der EZB-Kurs, ob die beiden zueinander passen können. + # Währungswechsel: unterschiedliche Beträge. Hier entscheidet der EZB-Kurs, + # ob die beiden zueinander passen können. kandidaten = _ergaenze_waehrungswechsel(db, user, buchungen, waehrung, - ignoriert, kandidaten) + ignoriert, kandidaten, fenster) + + # Das nächstliegende Datum zuerst: Wo mehrere passen, ist der gleiche Tag + # das wahrscheinlichere Paar als der drei Tage später. + datum_je_id = {t.id: t.date for t in buchungen} + for a_id, liste in kandidaten.items(): + a_datum = datum_je_id[a_id] + liste.sort(key=lambda b, d=a_datum: (abs((b.date - d).days), b.id)) return kandidaten @@ -210,24 +234,28 @@ def _passt_ueber_kurs(db: Session, a: Transaction, b: Transaction, return abs(erwartet - b.amount_cents) <= erwartet * FX_TOLERANZ -def _ergaenze_waehrungswechsel(db, user, buchungen, waehrung, ignoriert, kandidaten): - nach_tag: dict = defaultdict(list) - for t in buchungen: - nach_tag[t.date].append(t) - for gruppe in nach_tag.values(): - for a in gruppe: - for b in gruppe: - if a.id == b.id or a.account_id == b.account_id or a.type == b.type: - continue - va, vb = waehrung.get(a.account_id), waehrung.get(b.account_id) - if not va or not vb or va == vb: - continue # gleiche Währung ist oben schon erledigt - if a.amount_cents == b.amount_cents: - continue # zufällig gleiche Zahl, andere Währung - if tuple(sorted((a.id, b.id))) in ignoriert: - continue - if _passt_ueber_kurs(db, a, b, va, vb): - kandidaten[a.id].append(b) +def _ergaenze_waehrungswechsel(db, user, buchungen, waehrung, ignoriert, kandidaten, + fenster: int = 0): + nach_datum = sorted(buchungen, key=lambda t: (t.date, t.id)) + for i, a in enumerate(nach_datum): + for b in nach_datum[i + 1:]: + if (b.date - a.date).days > fenster: + break # sortiert: alles Weitere liegt noch ferner + if a.account_id == b.account_id or a.type == b.type: + continue + va, vb = waehrung.get(a.account_id), waehrung.get(b.account_id) + if not va or not vb or va == vb: + continue # gleiche Währung ist oben schon erledigt + if a.amount_cents == b.amount_cents: + continue # zufällig gleiche Zahl, andere Währung + if tuple(sorted((a.id, b.id))) in ignoriert: + continue + # Die Umrechnung hat eine Richtung – beide prüfen, sonst hinge es + # davon ab, welche der beiden zufaellig zuerst einsortiert war. + if _passt_ueber_kurs(db, a, b, va, vb): + kandidaten[a.id].append(b) + if _passt_ueber_kurs(db, b, a, vb, va): + kandidaten[b.id].append(a) return kandidaten @@ -267,7 +295,9 @@ def finde(db: Session, user: User, nur_vorschlaege: bool = False) -> dict: Vorschlag zurück. Das braucht jede Ansicht, die bloss fragen will („ist das ein Übertrag?"), ohne beim Hinsehen schon etwas zu verändern. """ - kandidaten = _kandidaten(db, user) + # Von selbst verknüpft wird nur am gleichen Tag. Vorschläge dürfen weiter + # greifen – sie ändern nichts, bis der Nutzer zustimmt. + kandidaten = _kandidaten(db, user, INBOX_TAGE if nur_vorschlaege else 0) bezeichner = _kontobezeichner(db, user) typen = _kontotypen(db, user) nach_id = {} diff --git a/backend/tests/test_uebertraege.py b/backend/tests/test_uebertraege.py index adb92eb..8450d78 100644 --- a/backend/tests/test_uebertraege.py +++ b/backend/tests/test_uebertraege.py @@ -309,3 +309,48 @@ def test_vorschlaege_veraendern_nichts(admin): # Und es ist wirklich keine Verknuepfung entstanden. links = admin.get("/api/transactions/links").json() assert str(raus["id"]) not in links and str(rein["id"]) not in links + + +def _konto_chf(admin, name): + import secrets + return admin.post("/api/accounts", json={ + "name": f"{name} {secrets.token_hex(3)}", "type": "girokonto", + "currency": "CHF", "opening_balance_cents": 0}).json() + + +def test_vorschlag_auch_wenn_die_gegenseite_tage_spaeter_steht(admin): + """Die Bank bucht die Gegenseite ein bis drei Tage spaeter. Die + Uebertrags-Pruefung sucht sie deshalb ueber ein Fenster und schickt den + Nutzer zum Verbinden – die Vorschlagsliste kannte aber nur den gleichen + Tag und bot das Paar nie an. Dann steht er vor einer Meldung, der er nicht + folgen kann.""" + import secrets + + quelle = _konto_chf(admin, "Revolut") + pocket = _konto_chf(admin, "Sparen Pocket") + betrag = 62 + secrets.randbelow(900) + text = f"Sparen {secrets.token_hex(4)}" + + raus = admin.post("/api/transactions", json={ + "type": "expense", "date": "2026-05-27", "account_id": quelle["id"], + "amount_cents": betrag, "payee": text}).json() + rein = admin.post("/api/transactions", json={ + "type": "income", "date": "2026-05-29", "account_id": pocket["id"], + "amount_cents": betrag, "payee": text}).json() + + paare = admin.get("/api/uebertraege/vorschlaege").json()["vorschlaege"] + meins = [p for p in paare + if {p["a"]["id"], p["b"]["id"]} == {raus["id"], rein["id"]}] + assert meins, "zwei Tage Abstand muessen als Vorschlag erscheinen" + + # Von SELBST verknuepft wird weiter nur am gleichen Tag: Ein Vorschlag + # fragt, das automatische Verknuepfen entscheidet – und das bleibt eng. + admin.post("/api/uebertraege/suchen") + links = admin.get("/api/uebertraege").json() + assert not [l for l in links + if {l["from_txn_id"], l["to_txn_id"]} == {raus["id"], rein["id"]}], \ + "ueber Tage hinweg darf nur vorgeschlagen, nicht selbst verknuepft werden" + + # Bestaetigen kann der Nutzer es trotzdem. + assert admin.post("/api/uebertraege/verbinden", + json={"a_id": raus["id"], "b_id": rein["id"]}).status_code == 200