diff --git a/backend/scripts/pruefe_doppelte_uebertraege.py b/backend/scripts/pruefe_doppelte_uebertraege.py new file mode 100755 index 0000000..d3a61bd --- /dev/null +++ b/backend/scripts/pruefe_doppelte_uebertraege.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Findet Überträge, deren Gegenseite zusätzlich als eigene Buchung dasteht. + +Ein Kontoübertrag bewegt Geld in EINER Buchung. Liefert die Bank dieselbe +Bewegung zusätzlich als Gutschrift oder Belastung, steht das Geld auf dem +betroffenen Konto zweimal. Genau das ist passiert, solange Certain beim +Anlegen eines Übertrags nicht nachgesehen hat, ob die Gegenseite schon +gebucht ist. + +Das Skript ÄNDERT NICHTS. Es öffnet die Datenbank nur lesend und sagt, was +zu prüfen ist – aufräumen tust du von Hand, damit nichts Richtiges mit +weggeräumt wird. + +Aufruf: pruefe_doppelte_uebertraege.py [pfad-zur-db] +""" +import sqlite3 +import sys +from datetime import date, timedelta + +# So viele Tage darf die Gegenbuchung abweichen. Banken buchen eine Bewegung +# oft ein bis zwei Tage nach dem Auslösen – dieselbe Spanne wie beim Import. +TAGE = 3 +# Über Währungen hinweg ist der erwartete Betrag gerechnet, der echte kommt +# von der Bank. Ohne Spielraum fände man diese Fälle nie. +FX_SPIELRAUM = 0.025 + +DB = sys.argv[1] if len(sys.argv) > 1 else "/var/lib/certain-finance/app.db" +con = sqlite3.connect(f"file:{DB}?mode=ro", uri=True) +con.row_factory = sqlite3.Row + + +def geld(cents, waehrung=""): + return f"{cents / 100:,.2f}".replace(",", "'") + (f" {waehrung}" if waehrung else "") + + +konten = {r["id"]: r for r in con.execute("SELECT id, name, currency FROM accounts")} +verdacht = [] + +for u in con.execute(""" + SELECT id, date, account_id, to_account_id, amount_cents, to_amount_cents, + payee, description + FROM transactions + WHERE type = 'transfer' + ORDER BY date, id +"""): + tag = date.fromisoformat(u["date"]) + von, bis = tag - timedelta(days=TAGE), tag + timedelta(days=TAGE) + + # Beide Seiten prüfen: Die überzählige Buchung kann auf dem Abgangs- wie + # auf dem Zugangskonto liegen, je nachdem, welche Seite umgewidmet wurde. + for konto_id, betrag, richtung in ( + (u["account_id"], u["amount_cents"], "expense"), + (u["to_account_id"], u["to_amount_cents"] or u["amount_cents"], "income"), + ): + if konto_id is None or not betrag: + continue + gleiche_waehrung = (konten.get(u["account_id"], {})["currency"] + == konten.get(u["to_account_id"], {})["currency"] + if u["to_account_id"] in konten and u["account_id"] in konten + else True) + spielraum = 0 if gleiche_waehrung else max(1, round(betrag * FX_SPIELRAUM)) + for k in con.execute(""" + SELECT id, date, amount_cents, payee, description, category_id + FROM transactions + 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)) + +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: + konto = konten.get(konto_id, {"name": "?", "currency": ""}) + a = konten.get(u["account_id"], {"name": "?"}) + b = konten.get(u["to_account_id"], {"name": "?"}) + 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()): + konto = konten.get(konto_id, {"name": "?", "currency": ""}) + print(f" {konto['name']:<28} {geld(cents, konto['currency'])}") + +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. + +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_inbox.py b/backend/tests/test_inbox.py index d3219e2..b42d38c 100644 --- a/backend/tests/test_inbox.py +++ b/backend/tests/test_inbox.py @@ -433,3 +433,23 @@ def test_uebertrag_nimmt_den_kurs_der_bank_auch_nachtraeglich(admin): assert r.status_code == 200, r.text assert r.json()["to_amount_cents"] == 42532, "der Betrag der Bank, nicht der gerechnete" assert admin.get(f"/api/accounts/{eur['id']}").json()["balance_cents"] == 42532 + + +def test_lohnzahlung_auf_zwei_konten_wird_abgefangen(admin): + """Genau der gemeldete Fall: Die Gutschrift steht schon auf dem Zielkonto, + die Belastung soll zum Uebertrag werden. Ohne Sperre stuenden 1'575 zweimal + auf dem Zielkonto.""" + lohn = _konto(admin, name=f"Raiffeisen Lohn {secrets.token_hex(3)}", currency="CHF") + revolut = _konto(admin, name=f"Revolut CHF {secrets.token_hex(3)}", currency="CHF") + raus = admin.post("/api/transactions", json={ + "type": "expense", "date": "2026-08-25", "account_id": lohn["id"], + "amount_cents": 157500, "payee": "Guido Schmit"}).json() + admin.post("/api/transactions", json={ + "type": "income", "date": "2026-08-25", "account_id": revolut["id"], + "amount_cents": 157500, "payee": "GUIDO SCHMIT"}) + + r = admin.post(f"/api/transactions/{raus['id']}/transfer", + json={"to_account_id": revolut["id"]}) + assert r.status_code == 409, r.text + assert admin.get(f"/api/accounts/{revolut['id']}").json()["balance_cents"] == 157500, \ + "das Geld darf nicht zweimal dastehen"