diff --git a/backend/app/routers/bank.py b/backend/app/routers/bank.py index e000b29..47516c7 100644 --- a/backend/app/routers/bank.py +++ b/backend/app/routers/bank.py @@ -1,5 +1,7 @@ """Bankkonten über PSD2 verbinden und abrufen.""" +import logging + from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session @@ -16,6 +18,8 @@ from ..schemas import ( from ..services import bank_sync, geheimnis from ..services.bank_api import BankFehler +log = logging.getLogger(__name__) + router = APIRouter(prefix="/bank", tags=["bank"]) @@ -222,9 +226,25 @@ def set_link_account(link_id: int, account_id: int, db: Session = Depends(get_db f"{verknuepfung.currency}, das Konto '{konto.name}' führt " f"{konto.currency}."), ) + # Ein Konto darf nur von EINEM Bankkonto gefuellt werden. Sonst holen zwei + # Zugaenge dieselben Umsaetze – mit verschiedenen Kennungen, also ohne dass + # der Duplikatschutz greift. Das passiert schnell, wenn dieselbe Bank + # zweimal verbunden ist: Enable Banking fuehrt sie je Land getrennt. + geloest = ( + db.query(BankAccountLink) + .filter(BankAccountLink.owner_id == user.id, + BankAccountLink.account_id == konto.id, + BankAccountLink.id != verknuepfung.id) + .all() + ) + for andere in geloest: + andere.account_id = None verknuepfung.account_id = konto.id db.commit() db.refresh(verknuepfung) + if geloest: + log.info("Zuordnung von %s auf %s uebernommen, %d andere geloest", + verknuepfung.remote_uid, konto.name, len(geloest)) return verknuepfung diff --git a/backend/app/services/bank_sync.py b/backend/app/services/bank_sync.py index 2485576..6e4e3c3 100644 --- a/backend/app/services/bank_sync.py +++ b/backend/app/services/bank_sync.py @@ -532,21 +532,29 @@ 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. +def _fremde_buchungen(db: Session, user: User, konto: Account, seit: date, + remote_uid: str) -> dict[tuple[str, int], list]: + """Buchungen des Kontos, die NICHT aus DIESEM Bankkonto stammen. 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. + + Ausgenommen sind nur Buchungen aus DEMSELBEN Bankkonto: Dort entscheidet + die Kennung der Bank, und die ist exakt – zwei Aufrundungen ueber 1.00 + duerfen sich nicht gegenseitig verschlucken. Buchungen aus einem ANDEREN + Bankzugang zaehlen dagegen mit. Genau das passiert, wenn dieselbe Bank + zweimal verbunden ist (Enable Banking vergibt je Land eigene Kennungen): + Dieselbe Zahlung kaeme sonst ueber beide Zugaenge herein. """ - aus_bank = { + aus_diesem_konto = { 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), + InboxItem.external_id.startswith(f"{remote_uid}:"), ) } je_schluessel: dict[tuple[str, int], list] = {} @@ -556,7 +564,7 @@ def _fremde_buchungen(db: Session, user: User, konto: Account, Transaction.type != TxnType.transfer, Transaction.date >= seit - timedelta(days=csv_import.INBOX_TAGE), ): - if txn.id in aus_bank: + if txn.id in aus_diesem_konto: continue je_schluessel.setdefault((txn.type.value, txn.amount_cents), []).append(txn) return je_schluessel @@ -610,7 +618,8 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection, 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 = _fremde_buchungen(db, user, konto, seit, + verknuepfung.remote_uid) fremde_verbraucht: set[int] = set() for umsatz in client.umsaetze(verknuepfung.remote_uid, seit): geholt += 1 diff --git a/backend/scripts/raeume_doppelte_bankumsaetze.py b/backend/scripts/raeume_doppelte_bankumsaetze.py index 127ee59..a983420 100755 --- a/backend/scripts/raeume_doppelte_bankumsaetze.py +++ b/backend/scripts/raeume_doppelte_bankumsaetze.py @@ -1,6 +1,15 @@ #!/usr/bin/env python3 """Findet Bank-Umsätze, die eine schon vorhandene Buchung wiederholen. +Zwei Fälle: + + Bank gegen Datei Ein Abruf über einen längeren Zeitraum greift in Monate + zurück, die schon per CSV eingelesen sind. + Bank gegen Bank Dieselbe Bank ist zweimal verbunden (Enable Banking führt + sie je Land getrennt und vergibt eigene Kennungen). Jeder + Zugang meldet dieselbe Wirklichkeit – die Fassung eines + Zugangs bleibt stehen. + 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. @@ -40,14 +49,17 @@ def geld(cents): 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 - """) -} +# Welche Buchungen kamen von der Bank – und ueber WELCHES Bankkonto? Die +# Kennung steht vorn in external_id (":"). Sie unterscheidet +# zwei Zugaenge zu derselben Bank, die Enable Banking je Land getrennt fuehrt. +von_bank = {} +uid_von = {} +for r in con.execute(""" + SELECT id, transaction_id, external_id FROM inbox_items + WHERE source = 'bank' AND transaction_id IS NOT NULL +"""): + von_bank[r["transaction_id"]] = r["id"] + uid_von[r["transaction_id"]] = (r["external_id"] or "").split(":")[0] alle = list(con.execute(""" SELECT id, account_id, type, date, amount_cents, payee, description, category_id @@ -81,6 +93,33 @@ for t in alle: verbraucht.add(passend["id"]) funde.append((t, passend)) +# --- Zweiter Durchgang: dieselbe Zahlung ueber ZWEI Bankzugaenge ---------- +# Wer dieselbe Bank zweimal verbindet (etwa Revolut ueber DE und ueber LT), +# bekommt fuer dieselbe Zahlung zwei verschiedene Kennungen – der +# Duplikatschutz kann sie deshalb nicht als dieselbe erkennen. Jeder Zugang +# meldet dieselbe Wirklichkeit, also bleibt die Fassung EINES Zugangs stehen. +nach_gruppe: dict[tuple, list] = {} +for t in alle: + if t["id"] not in von_bank: + continue + nach_gruppe.setdefault( + (t["account_id"], t["type"], t["date"], t["amount_cents"]), []).append(t) + +for gruppe in nach_gruppe.values(): + je_uid: dict[str, list] = {} + for t in gruppe: + je_uid.setdefault(uid_von.get(t["id"], ""), []).append(t) + if len(je_uid) < 2: + continue # nur ein Zugang – nichts zu entscheiden + # Der Zugang mit den meisten Zeilen beschreibt die Wirklichkeit am + # vollstaendigsten; bei Gleichstand der aeltere (kleinere Buchungs-ID). + behalten = max(je_uid.values(), key=lambda zeilen: (len(zeilen), -zeilen[0]["id"])) + for zeilen in je_uid.values(): + if zeilen is behalten: + continue + for t in zeilen: + funde.append((t, behalten[0])) + if not funde: print("Nichts gefunden – kein Bank-Umsatz wiederholt eine vorhandene Buchung.") sys.exit(0) diff --git a/backend/tests/test_bank.py b/backend/tests/test_bank.py index 535db85..d5c3f0b 100644 --- a/backend/tests/test_bank.py +++ b/backend/tests/test_bank.py @@ -1157,3 +1157,64 @@ def test_uebergangene_konten_werden_gezeigt_nicht_nur_gezaehlt(admin, fake_bank) # Und zwar mitsamt dem, was ankam – sonst raet man, was ihnen fehlt. namen = {k.get("name") for k in daten["uebergangen"]} assert namen == {"Sparen", "Steuern"} + + +def test_ein_konto_wird_nur_von_einem_bankkonto_gefuellt(admin, fake_bank): + """Sonst holen zwei Zugaenge dieselben Umsaetze – mit verschiedenen + Kennungen, also ohne dass der Duplikatschutz greift. Genau das passiert, + wenn dieselbe Bank zweimal verbunden ist: Enable Banking fuehrt sie je + Land getrennt und vergibt eigene Kennungen.""" + _app(admin) + konto = _konto(admin, name=f"Doppelt gefuellt {secrets.token_hex(3)}", currency="EUR") + + links = [] + for land in ("DE", "LT"): + uid = f"acc-{secrets.token_hex(4)}" + fake_bank(konten=[{"uid": uid, "currency": "EUR"}]) + state = admin.post("/api/bank/connect", + json={"aspsp_name": f"Revolut-{land}-{secrets.token_hex(3)}", + "country": land}).json()["state"] + admin.post(f"/api/bank/callback?state={state}&code=guter-code") + links.append([v for v in admin.get("/api/bank/links").json() + if v["remote_uid"] == uid][0]) + + admin.post(f"/api/bank/links/{links[0]['id']}/account/{konto['id']}") + admin.post(f"/api/bank/links/{links[1]['id']}/account/{konto['id']}") + + zugeordnet = [v for v in admin.get("/api/bank/links").json() + if v["account_id"] == konto["id"]] + assert len(zugeordnet) == 1, "zwei Bankkonten fuellen dasselbe Konto" + assert zugeordnet[0]["id"] == links[1]["id"], "die neuere Zuordnung gewinnt" + + +def test_zweiter_bankzugang_bucht_dieselbe_zahlung_nicht_noch_einmal(admin, fake_bank): + """Auch wenn die Zuordnung von Hand geaendert wurde: Eine Zahlung, die + ueber den einen Zugang schon gebucht ist, darf ueber den anderen nicht + ein zweites Mal hereinkommen.""" + _app(admin) + heute = date.today().isoformat() + konto = _konto(admin, name=f"Zwei Zugaenge {secrets.token_hex(3)}", currency="EUR") + salden = [{"balance_type": "CLBD", + "balance_amount": {"amount": "0.00", "currency": "EUR"}}] + + def verbinde(land): + uid = f"acc-{secrets.token_hex(4)}" + fake_bank(konten=[{"uid": uid, "currency": "EUR"}], salden=salden, + umsaetze=[dict(UMSATZ, entry_reference=f"ref-{land}", + booking_date=heute)]) + state = admin.post("/api/bank/connect", + json={"aspsp_name": f"Zwei-{land}-{secrets.token_hex(3)}", + "country": land}).json()["state"] + admin.post(f"/api/bank/callback?state={state}&code=guter-code") + return [v for v in admin.get("/api/bank/links").json() if v["remote_uid"] == uid][0] + + eins, zwei = verbinde("DE"), verbinde("LT") + admin.post(f"/api/bank/links/{eins['id']}/account/{konto['id']}") + admin.post(f"/api/bank/links/{eins['id']}/sync") + assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == -1490 + + # Jetzt derselbe Umsatz ueber den zweiten Zugang – andere Kennung. + admin.post(f"/api/bank/links/{zwei['id']}/account/{konto['id']}") + r = admin.post(f"/api/bank/links/{zwei['id']}/sync").json() + assert r["doppelt"] == 1, r + assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == -1490