diff --git a/backend/app/services/bank_sync.py b/backend/app/services/bank_sync.py index 51f3bf0..bd6eab1 100644 --- a/backend/app/services/bank_sync.py +++ b/backend/app/services/bank_sync.py @@ -32,6 +32,7 @@ from ..models import ( InboxSource, InboxStatus, Transaction, + TxnType, User, ) from . import csv_import, eingang, geheimnis @@ -524,6 +525,51 @@ def _startdatum(db: Session, user: User, konto: Account, return min(heute, max(frueheste, letzte + timedelta(days=1))) +def _fremde_buchungen(db: Session, user: User, konto: Account, + seit: date) -> dict[tuple[str, int], list]: + """Buchungen des Kontos, die NICHT von der Bank kamen, nach Art und Betrag. + + Ein Abruf ueber einen laengeren Zeitraum greift in Monate zurueck, die + schon per CSV eingelesen sind. Der Duplikatschutz erkennt das nicht: Sein + Schluessel enthaelt den Empfaengertext, und der lautet bei der Bank anders + als im Auszug. Ohne diese Pruefung stuende die Zahlung zweimal da. + """ + aus_bank = { + txn_id + for (txn_id,) in db.query(InboxItem.transaction_id).filter( + InboxItem.owner_id == user.id, + InboxItem.source == InboxSource.bank, + InboxItem.transaction_id.isnot(None), + ) + } + je_schluessel: dict[tuple[str, int], list] = {} + for txn in db.query(Transaction).filter( + Transaction.owner_id == user.id, + Transaction.account_id == konto.id, + Transaction.type != TxnType.transfer, + Transaction.date >= seit - timedelta(days=csv_import.INBOX_TAGE), + ): + if txn.id in aus_bank: + continue + je_schluessel.setdefault((txn.type.value, txn.amount_cents), []).append(txn) + return je_schluessel + + +def _findet_fremde(fremde: dict, verbraucht: set[int], cents: int, tag: date): + """Passende eigene Buchung – jede erklaert hoechstens EINEN Bank-Umsatz. + + Ohne die Menge verschluckte eine Kaffeezahlung alle weiteren desselben + Betrags in der Umgebung. + """ + art = "expense" if cents < 0 else "income" + for txn in fremde.get((art, abs(cents)), []): + if txn.id in verbraucht: + continue + if abs((txn.date - tag).days) <= csv_import.INBOX_TAGE: + return txn + return None + + def hole_umsaetze(db: Session, user: User, verbindung: BankConnection, nur_link_id: int | None = None, tage: int | None = None) -> dict: """Umsätze der zugeordneten Konten dieser Verbindung in den Eingang holen. @@ -543,7 +589,7 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection, konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)} - geholt = neu = gebucht = vorgemerkt = 0 + geholt = neu = gebucht = vorgemerkt = doppelt = 0 ab: date | None = None for verknuepfung in verknuepfungen: konto = konten.get(verknuepfung.account_id) @@ -553,8 +599,12 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection, # ganz neu. seit = _startdatum(db, user, konto, verknuepfung, tage) ab = seit if ab is None else min(ab, seit) - # Je Konto EINE Menge verbrauchter Eingangs-Eintraege. + # Je Konto EINE Menge verbrauchter Eingangs-Eintraege ... verbraucht: set[int] = set() + # ... und eine fuer schon vorhandene, nicht von der Bank stammende + # Buchungen. Beide muessen ueber alle Zeilen des Kontos halten. + fremde = _fremde_buchungen(db, user, konto, seit) + fremde_verbraucht: set[int] = set() for umsatz in client.umsaetze(verknuepfung.remote_uid, seit): geholt += 1 zustand = _ist_gebucht(umsatz) @@ -605,6 +655,14 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection, if zeile.get("is_duplicate"): continue + # Steht die Zahlung schon da, weil sie frueher per Datei oder von + # Hand erfasst wurde? Dann ist der Bank-Umsatz dieselbe Bewegung. + schon_da = _findet_fremde(fremde, fremde_verbraucht, cents, tag) + if schon_da is not None: + fremde_verbraucht.add(schon_da.id) + doppelt += 1 + continue + waehrung = ((umsatz.get("transaction_amount") or {}).get("currency") or konto.currency) kurs, gegen_betrag, gegen_waehrung = _umrechnung(umsatz, waehrung) @@ -648,6 +706,7 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection, return { "uebertraege": verknuepft, "geholt": geholt, "neu": neu, "gebucht": gebucht, "vorgemerkt": vorgemerkt, + "doppelt": doppelt, "saldi": saldi, "ab": ab.isoformat() if ab else None, "ohne_zuordnung": False, } diff --git a/backend/scripts/raeume_doppelte_bankumsaetze.py b/backend/scripts/raeume_doppelte_bankumsaetze.py new file mode 100755 index 0000000..127ee59 --- /dev/null +++ b/backend/scripts/raeume_doppelte_bankumsaetze.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Findet Bank-Umsätze, die eine schon vorhandene Buchung wiederholen. + +Ein Abruf über einen längeren Zeitraum greift in Monate zurück, die schon per +CSV eingelesen sind. Der Duplikatschutz erkennt das nicht: Sein Schlüssel +enthält den Empfängertext, und der lautet bei der Bank anders als im Auszug. +Die Zahlung steht danach zweimal da. + +Gesucht wird deshalb unscharf: gleiches Konto, gleiche Richtung, gleicher +Betrag, Datum ±3 Tage. Jede vorhandene Buchung kann dabei nur EINE +Bank-Buchung erklären – sonst verschluckt eine Kaffeezahlung alle anderen +desselben Betrags. + +Weggeräumt wird immer die von der BANK, nie deine eigene: Deine trägt die +Kategorie, die du vergeben hast. Sie wird nicht gelöscht, sondern verworfen – +so holt sie kein späterer Abruf wieder herein. + +Das Skript ändert ohne ``--wirklich`` nichts. Mit ``--wirklich`` legt es zuerst +eine Kopie der Datenbank an. + +Aufruf: raeume_doppelte_bankumsaetze.py [pfad-zur-db] [--wirklich] +""" +import sqlite3 +import sys +from datetime import date, datetime, timedelta + +TAGE = 3 + +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")} + +# Welche Buchungen kamen von der Bank? Nur die kommen als Dublette in Frage. +von_bank = { + r["transaction_id"]: r["id"] + for r in con.execute(""" + SELECT id, transaction_id FROM inbox_items + WHERE source = 'bank' AND transaction_id IS NOT NULL + """) +} + +alle = list(con.execute(""" + SELECT id, account_id, type, date, amount_cents, payee, description, category_id + FROM transactions + WHERE type != 'transfer' + ORDER BY date, id +""")) + +# Eigene Buchungen je (Konto, Richtung, Betrag) – daraus wird gleich geschöpft. +eigene: dict[tuple, list] = {} +for t in alle: + if t["id"] in von_bank: + continue + eigene.setdefault((t["account_id"], t["type"], t["amount_cents"]), []).append(t) + +verbraucht: set[int] = set() +funde = [] +for t in alle: + if t["id"] not in von_bank: + continue + schluessel = (t["account_id"], t["type"], t["amount_cents"]) + tag = date.fromisoformat(t["date"]) + passend = None + for kandidat in eigene.get(schluessel, []): + if kandidat["id"] in verbraucht: + continue + if abs((date.fromisoformat(kandidat["date"]) - tag).days) <= TAGE: + passend = kandidat + break + if passend is not None: + verbraucht.add(passend["id"]) + funde.append((t, passend)) + +if not funde: + print("Nichts gefunden – kein Bank-Umsatz wiederholt eine vorhandene Buchung.") + sys.exit(0) + +print(f"{len(funde)} Bank-Umsätze wiederholen eine vorhandene Buchung.\n") +je_konto: dict[int, int] = {} +for bank, eigen in funde: + konto = konten.get(bank["account_id"], {"name": "?", "currency": ""}) + zeichen = -1 if bank["type"] == "expense" else 1 + print(f" {bank['date']} {konto['name']:<22} {geld(bank['amount_cents']):>10} " + f"{(bank['payee'] or bank['description'] or '')[:38]}") + print(f" wiederholt #{eigen['id']} vom {eigen['date']}: " + f"{(eigen['payee'] or eigen['description'] or '')[:38]}") + je_konto[bank["account_id"]] = je_konto.get(bank["account_id"], 0) + zeichen * bank["amount_cents"] + +print("\nDie Konten liegen dadurch daneben:") +for konto_id, cents in sorted(je_konto.items()): + konto = konten.get(konto_id, {"name": "?", "currency": ""}) + richtung = "zu niedrig" if cents < 0 else "zu hoch" + print(f" {konto['name']:<24} {geld(abs(cents))} {konto['currency']} {richtung}") + +if not WIRKLICH: + print(f"\nNur angesehen, nichts geändert. Zum Ausführen:\n" + f" {sys.argv[0]} {DB} --wirklich") + sys.exit(0) + +kopie = f"{DB}.vor-aufraeumen-{datetime.now().strftime('%Y%m%d-%H%M%S')}" +with sqlite3.connect(kopie) as ziel: + con.backup(ziel) +print(f"\nKopie der Datenbank: {kopie}") + +for bank, _ in funde: + # Verwerfen statt loeschen: Der Eintrag bleibt als Sperre stehen, damit + # kein spaeterer Abruf denselben Umsatz noch einmal hereinholt. + con.execute("""UPDATE inbox_items SET status = 'discarded', transaction_id = NULL + WHERE id = ?""", (von_bank[bank["id"]],)) + con.execute("DELETE FROM transactions WHERE id = ?", (bank["id"],)) +con.commit() +print(f"{len(funde)} doppelte Bank-Buchungen entfernt.\n" + "Sieh dir jetzt die Kontenseite an – dort muss bei den angebundenen\n" + "Konten 'Saldo stimmt mit der Bank überein' stehen.") diff --git a/backend/tests/test_bank.py b/backend/tests/test_bank.py index ef43bba..104e83e 100644 --- a/backend/tests/test_bank.py +++ b/backend/tests/test_bank.py @@ -1085,3 +1085,47 @@ def test_startdatum_liegt_nie_in_der_zukunft(admin, fake_bank): assert gegenstelle.date_from <= heute.isoformat(), \ f"date_from liegt in der Zukunft: {gegenstelle.date_from}" assert r.json()["ab"] == heute.isoformat() + + +def test_abruf_ueber_schon_eingelesene_monate_bucht_nicht_doppelt(admin, fake_bank): + """'Aelteres nachholen' greift in Monate zurueck, die schon per CSV + eingelesen sind. Der Schluessel des Duplikatschutzes enthaelt den + Empfaengertext, und der lautet bei der Bank anders – ohne eigene Pruefung + stuende die Zahlung zweimal da.""" + _app(admin) + vor_wochen = date.today() - timedelta(days=30) + uid = f"acc-{secrets.token_hex(4)}" + fake_bank( + konten=[{"uid": uid, "currency": "CHF"}], + umsaetze=[ + {"entry_reference": f"tx-{secrets.token_hex(4)}", + "booking_date": vor_wochen.isoformat(), + "transaction_amount": {"amount": "35.00", "currency": "CHF"}, + "credit_debit_indicator": "DBIT", + "creditor": {"name": "Coop 6141"}}, + # Und ein Umsatz, den es noch NICHT gibt – der muss ankommen. + {"entry_reference": f"tx-{secrets.token_hex(4)}", + "booking_date": vor_wochen.isoformat(), + "transaction_amount": {"amount": "7.77", "currency": "CHF"}, + "credit_debit_indicator": "DBIT", + "creditor": {"name": "Neu und echt"}}, + ]) + state = admin.post("/api/bank/connect", + json={"aspsp_name": f"Ueberlapp-{secrets.token_hex(3)}", + "country": "CH"}).json()["state"] + admin.post(f"/api/bank/callback?state={state}&code=guter-code") + link = [v for v in admin.get("/api/bank/links").json() if v["remote_uid"] == uid][0] + konto = _konto(admin, name=f"Ueberlapp {secrets.token_hex(3)}", currency="CHF") + admin.post(f"/api/bank/links/{link['id']}/account/{konto['id']}") + + # So sieht der schon eingelesene Auszug aus – anderer Text, ein Tag daneben. + admin.post("/api/transactions", json={ + "type": "expense", "date": (vor_wochen + timedelta(days=1)).isoformat(), + "account_id": konto["id"], "amount_cents": 3500, + "payee": "COOP-6141 KILLWANGEN"}) + + r = admin.post(f"/api/bank/links/{link['id']}/sync", params={"tage": 90}).json() + assert r["doppelt"] == 1, r + assert r["neu"] == 1, "der echte Umsatz muss ankommen" + # 35.00 einmal (aus der CSV) plus 7.77 neu – nicht 35.00 zweimal. + assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == -4277 diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 1198937..f2b7b52 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -651,7 +651,8 @@ "pollUnit": "Minuten (0 = aus)", "pollHint": "Certain holt die Umsätze dann von selbst und bucht sie. 0 schaltet das ab; weniger als 15 Minuten lassen die Banken nicht zu.", "pollSaved": "Takt gespeichert.", - "syncOlder": "Älteres nachholen" + "syncOlder": "Älteres nachholen", + "syncedDuplicate": "{{n}} standen schon da (aus Datei oder von Hand) und wurden übersprungen." }, "bankReturn": { "title": "Rückkehr von der Bank", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index a6937c5..1f0ccd4 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -651,7 +651,8 @@ "pollUnit": "minutes (0 = off)", "pollHint": "Certain then fetches and books transactions on its own. 0 turns this off; banks do not allow less than 15 minutes.", "pollSaved": "Interval saved.", - "syncOlder": "Catch up on older" + "syncOlder": "Catch up on older", + "syncedDuplicate": "{{n}} were already there (from a file or entered by hand) and were skipped." }, "bankReturn": { "title": "Back from your bank", diff --git a/web/src/pages/settings/Bankanbindung.jsx b/web/src/pages/settings/Bankanbindung.jsx index 183815c..7889937 100644 --- a/web/src/pages/settings/Bankanbindung.jsx +++ b/web/src/pages/settings/Bankanbindung.jsx @@ -118,6 +118,9 @@ export default function Bankanbindung() { // weiss, warum. const teile = [t("bank.synced", { gebucht: r.gebucht, ab: formatDate(r.ab) })]; if (r.vorgemerkt > 0) teile.push(t("bank.syncedPending", { n: r.vorgemerkt })); + // Uebersprungene nicht verschweigen: Sonst sieht ein Abruf ueber schon + // eingelesene Monate aus, als haette er nichts gefunden. + if (r.doppelt > 0) teile.push(t("bank.syncedDuplicate", { n: r.doppelt })); if (r.uebertraege > 0) teile.push(t("bank.syncedTransfers", { n: r.uebertraege })); return teile.join(" "); }