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"