From 0288b2be9fa780224ae447115234d0b6bdceded5 Mon Sep 17 00:00:00 2001 From: Guido Date: Thu, 27 Aug 2026 11:40:38 +0200 Subject: [PATCH] feat: Pruefskript sagt, WELCHE der beiden Zeilen weg muss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Es liess die Entscheidung beim Nutzer, obwohl die Datenbank sie hergibt: In bank_account_links steht, welche Konten Certain selbst abholt. Sind beide angebunden, liefert die Bank beide Seiten und der Uebertrag ist falsch. Wird das Gegenkonto nicht abgerufen, ist der Uebertrag richtig und die zusaetzliche Buchung ueberzaehlig. Das steht jetzt je Fall dabei. Ausserdem war die Zusammenfassung irrefuehrend: Sie addierte Betraege in beide Richtungen. Eine zusaetzliche Gutschrift macht das Konto zu hoch, eine zusaetzliche Belastung zu niedrig – aus '2'026 zu viel' werden so '1'125 zu hoch'. Dazu zwei Tests zu der Frage, ob der naechste Abruf alles wieder anlegt: Nein. Der Umsatz wird an der Kennung der Bank wiedererkannt, unabhaengig davon, was aus der Buchung geworden ist, und ein spaeterer CSV-Auszug faellt ueber den Eingangs-Abgleich. Aufraeumen haelt also. Co-Authored-By: Claude Opus 5 --- .../scripts/pruefe_doppelte_uebertraege.py | 55 ++++++++++++------ backend/tests/test_bank.py | 58 +++++++++++++++++++ 2 files changed, 95 insertions(+), 18 deletions(-) diff --git a/backend/scripts/pruefe_doppelte_uebertraege.py b/backend/scripts/pruefe_doppelte_uebertraege.py index d3a61bd..807c1cd 100755 --- a/backend/scripts/pruefe_doppelte_uebertraege.py +++ b/backend/scripts/pruefe_doppelte_uebertraege.py @@ -34,6 +34,15 @@ def geld(cents, waehrung=""): konten = {r["id"]: r for r in con.execute("SELECT id, name, currency FROM accounts")} + +# Welche Konten holt Certain selbst von der Bank? Daran haengt, welche der +# beiden Zeilen die falsche ist – und das muss niemand raten. +try: + angebunden = {r[0] for r in con.execute( + "SELECT account_id FROM bank_account_links WHERE account_id IS NOT NULL")} +except sqlite3.OperationalError: + angebunden = set() # aeltere Datenbank ohne Bankanbindung + verdacht = [] for u in con.execute(""" @@ -65,43 +74,53 @@ for u in con.execute(""" WHERE account_id = ? AND type = ? AND date BETWEEN ? AND ? AND id != ? """, (konto_id, richtung, von.isoformat(), bis.isoformat(), u["id"])): if abs(k["amount_cents"] - betrag) <= spielraum: - verdacht.append((u, konto_id, betrag, k)) + verdacht.append((u, konto_id, betrag, k, richtung)) if not verdacht: print("Nichts gefunden – kein Übertrag hat eine zusätzlich gebuchte Gegenseite.") sys.exit(0) print(f"{len(verdacht)} Stelle(n) zu prüfen.\n") -summe = {} -for u, konto_id, betrag, k in verdacht: +abweichung = {} +for u, konto_id, betrag, k, richtung in verdacht: konto = konten.get(konto_id, {"name": "?", "currency": ""}) a = konten.get(u["account_id"], {"name": "?"}) b = konten.get(u["to_account_id"], {"name": "?"}) + gegen_id = u["to_account_id"] if konto_id == u["account_id"] else u["account_id"] + print(f"Übertrag #{u['id']} {u['date']} {a['name']} → {b['name']} " f"{geld(u['amount_cents'])}") print(f" {u['payee'] or u['description'] or 'ohne Text'}") print(f" daneben auf '{konto['name']}': Buchung #{k['id']} {k['date']} " f"{geld(k['amount_cents'], konto['currency'])}") print(f" {k['payee'] or k['description'] or 'ohne Text'}") - print(f" → dasselbe Geld zweimal auf '{konto['name']}'\n") - summe[konto_id] = summe.get(konto_id, 0) + k["amount_cents"] -print("Zu viel auf den Konten, wenn sich der Verdacht bestätigt:") -for konto_id, cents in sorted(summe.items()): + # Das Vorzeichen entscheidet, in welche Richtung das Konto danebenliegt. + vorzeichen = 1 if richtung == "income" else -1 + zu = "zu hoch" if vorzeichen > 0 else "zu niedrig" + print(f" → '{konto['name']}' ist um {geld(k['amount_cents'])} {zu}") + + if u["account_id"] in angebunden and u["to_account_id"] in angebunden: + print(f" → BEIDE Konten sind angebunden: Die Bank liefert beide Seiten " + f"selbst.\n Falsch ist der ÜBERTRAG #{u['id']} – löschen. Danach " + f"'{a['name']}' neu abrufen,\n dann verbindet Certain die beiden.") + elif gegen_id not in angebunden: + gegen = konten.get(gegen_id, {"name": "?"}) + print(f" → '{gegen['name']}' wird NICHT abgerufen: Der Übertrag ist richtig.\n" + f" Überzählig ist Buchung #{k['id']} – die kann weg.") + else: + print(f" → Nur ein Konto ist angebunden. Sieh dir das Paar von Hand an.") + print() + abweichung[konto_id] = abweichung.get(konto_id, 0) + vorzeichen * k["amount_cents"] + +print("Wenn sich der Verdacht bestätigt, liegen die Konten so daneben:") +for konto_id, cents in sorted(abweichung.items()): konto = konten.get(konto_id, {"name": "?", "currency": ""}) - print(f" {konto['name']:<28} {geld(cents, konto['currency'])}") + richtung = "zu hoch" if cents > 0 else "zu niedrig" + print(f" {konto['name']:<28} {geld(abs(cents), konto['currency'])} {richtung}") print(""" -So räumst du auf – je Paar EINE der beiden Zeilen, nicht beide: - - Beide Seiten kommen von der Bank? Dann sind BEIDE Buchungen richtig, und - falsch ist nur der Übertrag. Lösch ihn und lass die zwei Buchungen von - Certain verbinden ("Zu erledigen" → Mögliche Überträge). Die Belastung, aus - der der Übertrag entstand, holst du mit einem erneuten Abruf des Kontos - zurück. - - Das Gegenkonto holt Certain NICHT selbst ab? Dann ist der Übertrag richtig - und die zusätzliche Buchung überzählig – die kann weg. +Lösch je Paar nur EINE der beiden Zeilen – welche, steht oben. Im Zweifel: Kontenseite ansehen. Steht dort "Saldo stimmt mit der Bank überein", ist das Konto in Ordnung, egal was hier steht.""") diff --git a/backend/tests/test_bank.py b/backend/tests/test_bank.py index 34aec3d..9e7242f 100644 --- a/backend/tests/test_bank.py +++ b/backend/tests/test_bank.py @@ -928,3 +928,61 @@ def test_ohne_angabe_wird_nichts_geraten(admin, fake_bank): if e["payee"] == "SPEEDEE RESTAURATIONS"][0] assert eintrag["fx_rate"] is None assert eintrag["counter_amount_cents"] is None + + +def test_geloeschte_buchung_kommt_beim_naechsten_abruf_nicht_zurueck(admin, fake_bank): + """Die Frage beim Aufraeumen: Legt der naechste Abruf alles wieder an? + + Nein – der Umsatz wird an der Kennung der Bank wiedererkannt, unabhaengig + davon, was aus der Buchung geworden ist. Das ist Absicht: Wer etwas + weggeworfen hat, will es nicht beim naechsten Abruf zurueck. + """ + heute = date.today().isoformat() + verbindung, link, _ = _verbunden(admin, fake_bank, + umsaetze=[dict(UMSATZ, booking_date=heute)]) + konto = _konto(admin, name=f"Aufraeumen {secrets.token_hex(3)}", currency="EUR") + admin.post(f"/api/bank/links/{link['id']}/account/{konto['id']}") + + admin.post(f"/api/bank/connections/{verbindung}/sync") + eintrag = [e for e in admin.get("/api/inbox").json() + if e["payee"] == "SPEEDEE RESTAURATIONS"][0] + assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == -1490 + + # So raeumt man auf: die Buchung loeschen. + assert admin.delete(f"/api/transactions/{eintrag['transaction_id']}").status_code == 204 + assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == 0 + + # Und jetzt derselbe Umsatz noch einmal von der Bank. + zweiter = admin.post(f"/api/bank/connections/{verbindung}/sync").json() + assert zweiter["geholt"] >= 1, "der Umsatz kam wieder an" + assert zweiter["neu"] == 0, "wurde aber nicht erneut angelegt" + assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == 0 + + +def test_geloeschte_buchung_blockiert_auch_den_csv_import(admin, fake_bank): + """Der andere Weg herein: Ein spaeterer Auszug darf sie auch nicht bringen.""" + heute = date.today() + verbindung, link, _ = _verbunden( + admin, fake_bank, umsaetze=[dict(UMSATZ, booking_date=heute.isoformat())]) + konto = _konto(admin, name=f"CSV-Schutz {secrets.token_hex(3)}", currency="EUR") + admin.post(f"/api/bank/links/{link['id']}/account/{konto['id']}") + admin.post(f"/api/bank/connections/{verbindung}/sync") + eintrag = [e for e in admin.get("/api/inbox").json() + if e["payee"] == "SPEEDEE RESTAURATIONS"][0] + admin.delete(f"/api/transactions/{eintrag['transaction_id']}") + + from app.database import SessionLocal + from app.models import Account, User + from app.services import csv_import + + db = SessionLocal() + try: + nutzer = db.query(User).order_by(User.id).first() + acc = db.get(Account, konto["id"]) + zeilen = csv_import.detect_rows(db, nutzer, acc, [{ + "date": heute.isoformat(), "amount_cents": -1490, + "payee": "SPEEDEE RESTAURATIONS ZUERICH", "purpose": None}]) + finally: + db.close() + assert zeilen[0]["is_duplicate"] is True + assert zeilen[0]["duplicate_reason"] == "inbox"