Files
certain/backend/scripts/pruefe_doppelte_uebertraege.py
GuidoandClaude Opus 5 0288b2be9f feat: Pruefskript sagt, WELCHE der beiden Zeilen weg muss
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 <noreply@anthropic.com>
2026-08-27 11:40:38 +02:00

127 lines
5.7 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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")}
# 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("""
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, 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")
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'}")
# 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": ""})
richtung = "zu hoch" if cents > 0 else "zu niedrig"
print(f" {konto['name']:<28} {geld(abs(cents), konto['currency'])} {richtung}")
print("""
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.""")