From ca63fef5c41687dacc31f6a6d2be2655585490b6 Mon Sep 17 00:00:00 2001 From: Guido Date: Thu, 27 Aug 2026 11:52:36 +0200 Subject: [PATCH] feat: Doppelte Uebertraege reparieren, nicht nur melden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das Pruefskript liess die Arbeit beim Nutzer, obwohl die Entscheidung aus der Datenbank folgt. Das Reparaturskript trifft sie und fuehrt sie aus: Beide Konten angebunden Die Bank liefert beide Seiten selbst. Der Uebertrag wird in die Buchung zurueckverwandelt, aus der er entstand – WELCHE das war, sagt der Eingangs- Eintrag, der auf ihn zeigt. Danach stehen beide Seiten wieder da und lassen sich verbinden. Gegenkonto nicht abgerufen Der Uebertrag ist richtig, die zusaetzliche Buchung ueberzaehlig und wird geloescht. Ihr Eingangs-Eintrag wird verworfen statt geloescht, sonst braechte ihn der naechste Abruf wieder. Ohne --wirklich passiert nichts, es wird nur gezeigt. Mit --wirklich legt es zuerst eine Kopie der Datenbank an, ueber die SQLite-Sicherung und nicht als Dateikopie – sonst fehlte das WAL. Uebergangen statt geraten wird alles Uneindeutige: von Hand angelegte Uebertraege, solche ueber zwei Waehrungen, nur einseitig angebundene Paare und Uebertraege mit Dubletten auf BEIDEN Seiten. An nachgestellten Daten geprueft: Aus Lohnkonto -1'575 / Revolut 2'250 / Pocket 450 wird -1'575 / 1'125 / 450 – also genau das, was tatsaechlich passiert ist. Co-Authored-By: Claude Opus 5 --- .../scripts/repariere_doppelte_uebertraege.py | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100755 backend/scripts/repariere_doppelte_uebertraege.py diff --git a/backend/scripts/repariere_doppelte_uebertraege.py b/backend/scripts/repariere_doppelte_uebertraege.py new file mode 100755 index 0000000..73a81b9 --- /dev/null +++ b/backend/scripts/repariere_doppelte_uebertraege.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Räumt Überträge auf, deren Gegenseite zusätzlich als Buchung dasteht. + +Welche der beiden Zeilen falsch ist, hängt daran, ob Certain das Gegenkonto +selbst abholt – und das steht in der Datenbank: + + Beide Konten angebunden Die Bank liefert beide Seiten. Der ÜBERTRAG ist + falsch; er wird in die Buchung zurückverwandelt, + aus der er entstand. Beide Seiten stehen danach + wieder da und lassen sich verbinden. + + Gegenkonto nicht abgerufen Der Übertrag ist richtig. Die zusätzliche + Buchung ist überzählig und wird gelöscht. + +Ohne ``--wirklich`` wird nur gezeigt, was passieren würde. Mit ``--wirklich`` +legt das Skript ZUERST eine Kopie der Datenbank an und sagt, wo sie liegt. + +Aufruf: repariere_doppelte_uebertraege.py [pfad-zur-db] [--wirklich] +""" +import sqlite3 +import sys +from datetime import date, datetime, timedelta + +TAGE = 3 +FX_SPIELRAUM = 0.025 + +argumente = [a for a in sys.argv[1:] if not a.startswith("--")] +WIRKLICH = "--wirklich" in sys.argv +DB = argumente[0] if argumente else "/var/lib/certain-finance/app.db" + +con = sqlite3.connect(DB) +con.row_factory = sqlite3.Row + + +def geld(cents): + return f"{cents / 100:,.2f}".replace(",", "'") + + +konten = {r["id"]: r for r in con.execute("SELECT id, name, currency FROM accounts")} +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() + +plan, offen = [], [] +# Je Uebertrag hoechstens EIN Eingriff. Findet sich auf beiden Seiten eine +# Dublette, waeren "zurueckverwandeln" und "Buchung loeschen" widerspruechlich – +# dann lieber melden als raten. +behandelt = set() + +for u in con.execute(""" + SELECT id, date, account_id, to_account_id, amount_cents, to_amount_cents + FROM transactions WHERE type = 'transfer' ORDER BY date, id +"""): + tag = date.fromisoformat(u["date"]) + gleiche_waehrung = (konten[u["account_id"]]["currency"] + == konten[u["to_account_id"]]["currency"] + if u["account_id"] in konten and u["to_account_id"] in konten + else True) + + 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 + spielraum = 0 if gleiche_waehrung else max(1, round(betrag * FX_SPIELRAUM)) + for k in con.execute(""" + SELECT id, date, amount_cents, payee FROM transactions + WHERE account_id = ? AND type = ? AND date BETWEEN ? AND ? AND id != ? + """, (konto_id, richtung, (tag - timedelta(days=TAGE)).isoformat(), + (tag + timedelta(days=TAGE)).isoformat(), u["id"])): + if abs(k["amount_cents"] - betrag) > spielraum: + continue + + if u["id"] in behandelt: + offen.append(f"Übertrag #{u['id']}: passende Buchungen auf BEIDEN " + f"Seiten – bitte selbst ansehen") + continue + behandelt.add(u["id"]) + + a, b = konten.get(u["account_id"]), konten.get(u["to_account_id"]) + kopf = (f"Übertrag #{u['id']} {u['date']} {a['name']} → {b['name']} " + f"{geld(u['amount_cents'])}\n" + f" daneben: Buchung #{k['id']} auf '{konten[konto_id]['name']}' " + f"{geld(k['amount_cents'])}") + + if u["account_id"] in angebunden and u["to_account_id"] in angebunden: + # Der Uebertrag entstand aus einer echten Buchung. Welche das + # war, sagt der Eingangs-Eintrag, der auf ihn zeigt. + quelle = con.execute(""" + SELECT id, account_id, amount_cents FROM inbox_items + WHERE transaction_id = ? AND source != 'transfer' LIMIT 1 + """, (u["id"],)).fetchone() + if quelle is None: + offen.append(kopf + "\n → von Hand angelegt, ich kann nicht " + "wissen, wie er vorher aussah") + continue + if konten[quelle["account_id"]]["currency"] != a["currency"]: + offen.append(kopf + "\n → über zwei Währungen, der " + "Ursprungsbetrag ist nicht mehr eindeutig") + continue + plan.append(("zurueck", u, k, quelle, kopf)) + elif (u["to_account_id"] if konto_id == u["account_id"] + else u["account_id"]) not in angebunden: + plan.append(("loeschen", u, k, None, kopf)) + else: + offen.append(kopf + "\n → nur ein Konto angebunden, bitte selbst ansehen") + +if not plan and not offen: + print("Nichts zu tun – kein Übertrag hat eine zusätzlich gebuchte Gegenseite.") + sys.exit(0) + +for art, u, k, quelle, kopf in plan: + print(kopf) + if art == "zurueck": + typ = "Ausgabe" if quelle["amount_cents"] < 0 else "Einnahme" + print(f" → beide Konten angebunden: Übertrag #{u['id']} wird wieder zur " + f"{typ} auf '{konten[quelle['account_id']]['name']}'\n") + else: + print(f" → Gegenkonto wird nicht abgerufen: Buchung #{k['id']} wird " + f"gelöscht\n") +for text in offen: + print(text + "\n") + +if not WIRKLICH: + print(f"Nur angesehen, nichts geändert. Zum Ausführen:\n" + f" {sys.argv[0]} {DB} --wirklich") + sys.exit(0) + +kopie = f"{DB}.vor-reparatur-{datetime.now().strftime('%Y%m%d-%H%M%S')}" +with sqlite3.connect(kopie) as ziel: + con.backup(ziel) # nimmt auch das WAL mit, anders als ein Dateikopie +print(f"Kopie der Datenbank: {kopie}\n") + +for art, u, k, quelle, _ in plan: + if art == "zurueck": + typ = "expense" if quelle["amount_cents"] < 0 else "income" + con.execute(""" + UPDATE transactions + SET type = ?, account_id = ?, amount_cents = ?, + to_account_id = NULL, to_amount_cents = NULL, fx_rate = NULL + WHERE id = ? + """, (typ, quelle["account_id"], abs(quelle["amount_cents"]), u["id"])) + # Die Gegenseite, die Certain fuer den Uebertrag angelegt hatte, ist + # damit gegenstandslos. + con.execute("DELETE FROM inbox_items WHERE transaction_id = ? AND source = 'transfer'", + (u["id"],)) + print(f"Übertrag #{u['id']} zurückverwandelt.") + else: + # Eingangs-Eintrag verwerfen statt loeschen: Sonst brächte ihn der + # nächste Abruf oder Auszug wieder herein. + con.execute("""UPDATE inbox_items SET status = 'discarded', transaction_id = NULL + WHERE transaction_id = ?""", (k["id"],)) + con.execute("DELETE FROM transactions WHERE id = ?", (k["id"],)) + print(f"Buchung #{k['id']} gelöscht.") +con.commit() +print("\nFertig. Sieh dir jetzt die Kontenseite an – dort muss bei den " + "angebundenen Konten\n'Saldo stimmt mit der Bank überein' stehen.")