diff --git a/backend/app/services/uebertraege.py b/backend/app/services/uebertraege.py index 7be0b83..eb6fbac 100644 --- a/backend/app/services/uebertraege.py +++ b/backend/app/services/uebertraege.py @@ -310,6 +310,17 @@ def finde(db: Session, user: User, nur_vorschlaege: bool = False) -> dict: verbraucht: set[int] = set() verknuepft = 0 vorschlaege: list[tuple[Transaction, Transaction]] = [] + # Jedes Paar steht in ``kandidaten`` zweimal, einmal je Richtung. Was + # verknüpft wird, fällt über ``verbraucht`` heraus – ein Vorschlag aber + # nicht, und so kam dieselbe Zahlung zweimal zum Verbinden angeboten. + vorgeschlagen: set[tuple[int, int]] = set() + + def schlage_vor(a: Transaction, b: Transaction) -> None: + paar = (min(a.id, b.id), max(a.id, b.id)) + if paar in vorgeschlagen: + return + vorgeschlagen.add(paar) + vorschlaege.append((a, b)) for a_id in sorted(kandidaten): if a_id in verbraucht: @@ -321,17 +332,17 @@ def finde(db: Session, user: User, nur_vorschlaege: bool = False) -> dict: gegen = _eindeutig(a, offen, kandidaten, verbraucht) if gegen is None: if offen: - vorschlaege.append((a, offen[0])) + schlage_vor(a, offen[0]) continue # Automatisch nur ohne Kategorie und mit Hinweis – siehe _hat_hinweis. if a.category_id is not None or gegen.category_id is not None: - vorschlaege.append((a, gegen)) + schlage_vor(a, gegen) continue if not _hat_hinweis(a, gegen, bezeichner, typen): - vorschlaege.append((a, gegen)) + schlage_vor(a, gegen) continue if nur_vorschlaege: - vorschlaege.append((a, gegen)) + schlage_vor(a, gegen) continue verbinde(db, user, a, gegen, automatisch=True) verbraucht.update((a.id, gegen.id)) diff --git a/backend/scripts/erzeuge_ios_fixture.py b/backend/scripts/erzeuge_ios_fixture.py index ae3f71b..1bb3639 100644 --- a/backend/scripts/erzeuge_ios_fixture.py +++ b/backend/scripts/erzeuge_ios_fixture.py @@ -91,6 +91,37 @@ def _seed(c: TestClient) -> None: params={"planned_cents": 50000}) c.post(f"/api/subscriptions/{abo['id']}/buchungen/{kauf['id']}") + # --- Arbeitsliste ----------------------------------------------------- + # Ein Eingangseintrag entsteht nur ueber die Schnellerfassung; von Hand + # legt man keinen an. Also eine Marke ausstellen und damit buchen. + marke = c.post("/api/inbox/tokens", + json={"name": "Fixture", "default_account_id": giro["id"]}).json() + kopf = {"X-Certain-Token": marke["token"]} + c.post("/api/quick/booking", headers=kopf, + json={"amount": "12.90", "payee": "Baeckerei", "date": heute, + "external_id": "fixture-1"}) + + # Zwei gleich aussehende Buchungen: gleiches Konto, gleicher Tag, + # gleicher krummer Betrag. Genau das meldet /dubletten. + for _ in range(2): + c.post("/api/transactions", json={ + "type": "expense", "date": heute, "account_id": giro["id"], + "amount_cents": 1043, "payee": "Kiosk", "category_id": ausgabe["id"]}) + + # Ein Abgang und ein Zugang ueber verschiedene Konten, gleicher Betrag, + # unverknuepft - daraus macht /uebertraege/vorschlaege einen Vorschlag. + # Beide Konten muessen dieselbe Waehrung haben: 75 CHF und 75 EUR sind + # nicht derselbe Betrag, nur dieselbe Zahl (siehe _kandidaten). + zweitgiro = c.post("/api/accounts", json={ + "name": "Zweitkonto", "type": "girokonto", "currency": "EUR", + "opening_balance_cents": 0}).json() + c.post("/api/transactions", json={ + "type": "expense", "date": heute, "account_id": giro["id"], + "amount_cents": 7500, "payee": "Dauerauftrag"}) + c.post("/api/transactions", json={ + "type": "income", "date": heute, "account_id": zweitgiro["id"], + "amount_cents": 7500, "payee": "Dauerauftrag"}) + PFADE = { "version": "/api/version", @@ -108,6 +139,9 @@ PFADE = { "budgetfortschritt": "/api/dashboard/budget-progress", "einnahmenAusgaben": "/api/dashboard/income-expense", "letzteBuchungen": "/api/dashboard/recent", + "eingang": "/api/inbox", + "dubletten": "/api/transactions/dubletten", + "uebertragsvorschlaege": "/api/uebertraege/vorschlaege", } diff --git a/backend/tests/test_uebertraege.py b/backend/tests/test_uebertraege.py index 8450d78..b5f8774 100644 --- a/backend/tests/test_uebertraege.py +++ b/backend/tests/test_uebertraege.py @@ -354,3 +354,30 @@ def test_vorschlag_auch_wenn_die_gegenseite_tage_spaeter_steht(admin): # Bestaetigen kann der Nutzer es trotzdem. assert admin.post("/api/uebertraege/verbinden", json={"a_id": raus["id"], "b_id": rein["id"]}).status_code == 200 + + +def test_ein_paar_wird_nur_einmal_vorgeschlagen(admin): + """Jedes Paar steht intern zweimal, einmal je Richtung. + + Ohne Entdopplung saehe der Nutzer dieselbe Zahlung zweimal zum Verbinden + angeboten - und wer das zweite anklickt, bekaeme den Fehler "bereits + verknuepft" fuer etwas, das er gerade selbst verknuepft hat. + """ + import secrets + + betrag = 100000 + secrets.randbelow(800000) + a = admin.post("/api/accounts", json={ + "name": f"A {secrets.token_hex(3)}", "currency": "EUR"}).json() + b = admin.post("/api/accounts", json={ + "name": f"B {secrets.token_hex(3)}", "currency": "EUR"}).json() + raus = admin.post("/api/transactions", json={ + "type": "expense", "date": "2026-07-14", "account_id": a["id"], + "amount_cents": betrag, "payee": "Umbuchung"}).json() + rein = admin.post("/api/transactions", json={ + "type": "income", "date": "2026-07-14", "account_id": b["id"], + "amount_cents": betrag, "payee": "Umbuchung"}).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 len(meins) == 1, f"einmal vorschlagen, nicht {len(meins)}-mal"