From 0cd0aab90dc24d9495e719a7d7bae42174926e80 Mon Sep 17 00:00:00 2001 From: Guido Date: Thu, 27 Aug 2026 11:28:12 +0200 Subject: [PATCH] feat: Doppelt gebuchte Uebertraege auffindbar machen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solange beim Anlegen eines Uebertrags nicht geprueft wurde, ob die Gegenseite schon gebucht ist, konnte dieselbe Bewegung zweimal auf einem Konto landen. Das ist seit dem vorigen Commit unterbunden – die Daten von vorher bleiben aber, wie sie sind. Das Skript findet sie: Zu jedem Uebertrag sieht es auf BEIDEN Konten nach, ob dort zusaetzlich eine passende Buchung steht (gleiche Tage, passender Betrag, ueber Waehrungen hinweg mit Spielraum), und rechnet zusammen, wieviel dann zu viel dasteht. Es aendert NICHTS und oeffnet die Datenbank nur lesend. Aufraeumen bleibt Sache des Nutzers, denn welche der beiden Zeilen falsch ist, haengt daran, ob Certain das Gegenkonto selbst abholt – das kann das Skript nicht wissen, und ein Aufraeumskript hat hier schon einmal Richtiges mit weggeraeumt. Beide Faelle stehen als Anleitung dabei. Dazu ein Regressionstest fuer genau den gemeldeten Fall: Gutschrift steht schon auf dem Zielkonto, die Belastung soll zum Uebertrag werden – das wird jetzt abgefangen, und der Saldo des Zielkontos bleibt unveraendert. Co-Authored-By: Claude Opus 5 --- .../scripts/pruefe_doppelte_uebertraege.py | 107 ++++++++++++++++++ backend/tests/test_inbox.py | 20 ++++ 2 files changed, 127 insertions(+) create mode 100755 backend/scripts/pruefe_doppelte_uebertraege.py 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"