feat: Doppelt gebuchte Uebertraege auffindbar machen

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 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-27 11:28:12 +02:00
co-authored by Claude Opus 5
parent 6cd1c100c0
commit 0cd0aab90d
2 changed files with 127 additions and 0 deletions
+107
View File
@@ -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.""")
+20
View File
@@ -433,3 +433,23 @@ def test_uebertrag_nimmt_den_kurs_der_bank_auch_nachtraeglich(admin):
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
assert r.json()["to_amount_cents"] == 42532, "der Betrag der Bank, nicht der gerechnete" 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 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"