feat: Doppelte Uebertraege reparieren, nicht nur melden
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 <noreply@anthropic.com>
This commit is contained in:
+160
@@ -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.")
|
||||
Reference in New Issue
Block a user