Die Tagessummen sagen nur, dass etwas fehlt. Jetzt werden die Betraege je Tag als Menge verglichen: was im Auszug steht und nicht in Certain, und was Certain zusaetzlich hat – Letzteres mit Art und Text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
151 lines
5.7 KiB
Python
Executable File
151 lines
5.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Vergleicht die gebuchten Daten Tag für Tag mit dem Original-Auszug.
|
||
|
||
Stimmt ein Kontostand nicht, sagt das nur DASS etwas fehlt. Dieses Skript sagt
|
||
WO: Es rechnet je Tag zusammen, was im Auszug steht und was in der Datenbank
|
||
gelandet ist, und listet die Tage auf, an denen beides auseinandergeht.
|
||
|
||
Aufruf (Spalten heissen wie in der Kopfzeile der Datei):
|
||
pruefe_gegen_auszug.py --konto 2 --datum "Datum des Abschlusses" --betrag Betrag \
|
||
--gebuehr "Gebühr" --status Status --filter "Produkt=Giro" auszug.csv [db]
|
||
"""
|
||
import argparse
|
||
import csv
|
||
import sqlite3
|
||
from collections import Counter, defaultdict
|
||
from datetime import date
|
||
|
||
NICHT_GEBUCHT = {"PENDING", "AUSSTEHEND", "REVERTED", "STORNIERT", "DECLINED",
|
||
"ABGELEHNT", "FAILED", "FEHLGESCHLAGEN", "CANCELLED"}
|
||
|
||
p = argparse.ArgumentParser()
|
||
p.add_argument("datei")
|
||
p.add_argument("db", nargs="?", default="/var/lib/certain-finance/app.db")
|
||
p.add_argument("--konto", type=int, required=True)
|
||
p.add_argument("--datum", required=True)
|
||
p.add_argument("--betrag", required=True)
|
||
p.add_argument("--gebuehr")
|
||
p.add_argument("--status")
|
||
p.add_argument("--filter", dest="filt")
|
||
p.add_argument("--trenner", default=",")
|
||
a = p.parse_args()
|
||
|
||
|
||
def zahl(s) -> int:
|
||
"""Betragstext -> Cent. Verträgt '1'234.56', '1.234,56' und leere Felder."""
|
||
s = str(s or "").strip().replace("'", "").replace(" ", "")
|
||
if not s:
|
||
return 0
|
||
if "," in s and "." in s:
|
||
s = s.replace(".", "").replace(",", ".") if s.rindex(",") > s.rindex(".") else s.replace(",", "")
|
||
elif "," in s:
|
||
s = s.replace(",", ".")
|
||
try:
|
||
return round(float(s) * 100)
|
||
except ValueError:
|
||
return 0
|
||
|
||
|
||
def tag(s) -> date | None:
|
||
s = str(s or "").strip()[:10]
|
||
for f in ("%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y", "%m/%d/%Y"):
|
||
try:
|
||
return date.strptime(s, f) if False else __import__("datetime").datetime.strptime(s, f).date()
|
||
except ValueError:
|
||
continue
|
||
return None
|
||
|
||
|
||
# --- Auszug einlesen ---------------------------------------------------------
|
||
fs, fv = (a.filt.split("=", 1) if a.filt else (None, None))
|
||
je_tag_datei: dict[date, int] = defaultdict(int)
|
||
zeilen_datei: dict[date, list] = defaultdict(list)
|
||
zeilen = 0
|
||
with open(a.datei, encoding="utf-8-sig", newline="") as f:
|
||
for r in csv.DictReader(f, delimiter=a.trenner):
|
||
if fs and (r.get(fs) or "").strip() != fv:
|
||
continue
|
||
if a.status and (r.get(a.status) or "").strip().upper() in NICHT_GEBUCHT:
|
||
continue
|
||
d = tag(r.get(a.datum))
|
||
if d is None:
|
||
continue
|
||
betrag = zahl(r.get(a.betrag)) - zahl(r.get(a.gebuehr)) if a.gebuehr else zahl(r.get(a.betrag))
|
||
je_tag_datei[d] += betrag
|
||
zeilen_datei[d].append(betrag)
|
||
zeilen += 1
|
||
|
||
# --- Datenbank ---------------------------------------------------------------
|
||
con = sqlite3.connect(f"file:{a.db}?mode=ro", uri=True)
|
||
je_tag_db: dict[date, int] = defaultdict(int)
|
||
zeilen_db: dict[date, list] = defaultdict(list)
|
||
for d, typ, betrag, ziel, zielbetrag, payee, besch in con.execute(
|
||
"SELECT date, type, amount_cents, to_account_id, to_amount_cents, "
|
||
"COALESCE(payee,''), COALESCE(description,'') FROM transactions "
|
||
"WHERE account_id = ? OR to_account_id = ?", (a.konto, a.konto)):
|
||
dd = tag(d)
|
||
if dd is None:
|
||
continue
|
||
if typ == "income":
|
||
wert = betrag
|
||
elif typ == "expense":
|
||
wert = -betrag
|
||
elif ziel == a.konto:
|
||
wert = zielbetrag if zielbetrag is not None else betrag
|
||
else:
|
||
wert = -betrag
|
||
je_tag_db[dd] += wert
|
||
zeilen_db[dd].append((wert, typ, (payee + " " + besch).strip()))
|
||
|
||
|
||
def geld(c):
|
||
return f"{c / 100:,.2f}".replace(",", "'")
|
||
|
||
|
||
print(f"Auszug: {a.datei} ({zeilen} gebuchte Zeilen)")
|
||
print(f"Konto {a.konto} in {a.db}\n")
|
||
print(f"Summe Auszug: {geld(sum(je_tag_datei.values())):>12}")
|
||
print(f"Summe Datenbank: {geld(sum(je_tag_db.values())):>12}")
|
||
print(f"Unterschied: {geld(sum(je_tag_db.values()) - sum(je_tag_datei.values())):>12}\n")
|
||
|
||
print("=== Monate mit Abweichung ===")
|
||
mon_d, mon_b = defaultdict(int), defaultdict(int)
|
||
for d, v in je_tag_datei.items():
|
||
mon_d[d.strftime("%Y-%m")] += v
|
||
for d, v in je_tag_db.items():
|
||
mon_b[d.strftime("%Y-%m")] += v
|
||
for m in sorted(set(mon_d) | set(mon_b)):
|
||
if mon_d[m] != mon_b[m]:
|
||
print(f" {m} Auszug {geld(mon_d[m]):>11} DB {geld(mon_b[m]):>11} "
|
||
f"Differenz {geld(mon_b[m] - mon_d[m]):>10}")
|
||
|
||
print("\n=== Tage mit Abweichung (die 25 grössten) ===")
|
||
tage = [(d, je_tag_db[d] - je_tag_datei[d]) for d in set(je_tag_datei) | set(je_tag_db)
|
||
if je_tag_db[d] != je_tag_datei[d]]
|
||
tage.sort(key=lambda t: -abs(t[1]))
|
||
for d, diff in tage[:25]:
|
||
print(f" {d} Auszug {geld(je_tag_datei[d]):>10} DB {geld(je_tag_db[d]):>10} "
|
||
f"Differenz {geld(diff):>10}")
|
||
if not tage:
|
||
print(" keine – der Auszug ist vollständig und korrekt gebucht.")
|
||
|
||
|
||
print("\n=== Welche Buchungen fehlen bzw. sind zu viel ===")
|
||
print("(Vergleich der einzelnen Betraege je Tag; der Auszug enthaelt keine Texte)")
|
||
for d, diff in tage[:10]:
|
||
aus, dbs = Counter(zeilen_datei[d]), Counter(w for w, _, _ in zeilen_db[d])
|
||
fehlt = sorted((aus - dbs).elements())
|
||
zuviel = sorted((dbs - aus).elements())
|
||
if not fehlt and not zuviel:
|
||
continue
|
||
print(f"\n {d} (Differenz {geld(diff)})")
|
||
if fehlt:
|
||
print(f" fehlt in Certain: {', '.join(geld(v) for v in fehlt)}")
|
||
if zuviel:
|
||
print(" zu viel in Certain:")
|
||
gezeigt = Counter()
|
||
for w, typ, text in sorted(zeilen_db[d], key=lambda x: x[0]):
|
||
if gezeigt[w] < Counter(zuviel)[w]:
|
||
gezeigt[w] += 1
|
||
print(f" {geld(w):>10} {typ:<9} {text[:52]}")
|