From 4000082df449494b2f776b9c191022be52f7b071 Mon Sep 17 00:00:00 2001 From: Guido Date: Thu, 27 Aug 2026 21:39:57 +0200 Subject: [PATCH] fix: Dieselbe Zahlung ueber zwei Bankzugaenge kam doppelt herein MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wer dieselbe Bank zweimal verbindet, bekommt fuer dieselbe Zahlung zwei verschiedene Kennungen – Enable Banking fuehrt eine Bank je Land getrennt. Genau das passiert bei Revolut: Ueber den DE-Eintrag kommen drei Konten, ueber den LT-Eintrag zusaetzlich die Pockets, also verbindet man beide. Zeigen dann zwei Bankkonten auf dasselbe Konto in Certain, holt jeder Zugang dieselben Umsaetze, und der Duplikatschutz kann sie nicht als dieselben erkennen. Drei Aenderungen: - Ein Konto in Certain wird nur noch von EINEM Bankkonto gefuellt. Ordnet man ein zweites zu, wird die alte Zuordnung geloest. Sie ist ohnehin nie richtig, und stillschweigend doppelt zu buchen ist schlimmer als eine geloeste Zuordnung. - Die Ausnahme im Ueberschneidungsschutz gilt nur noch fuer DASSELBE Bankkonto. Dort entscheidet die Kennung der Bank, und die ist exakt – zwei Aufrundungen ueber 1.00 duerfen sich nicht verschlucken. Buchungen aus einem ANDEREN Zugang zaehlen jetzt mit. - Das Aufraeumskript kennt den Fall: Findet es in einer Gruppe (Konto, Art, Tag, Betrag) Buchungen aus zwei Zugaengen, bleibt die Fassung EINES Zugangs stehen. Aufrundungen aus einem einzigen Zugang bleiben unangetastet, auch wenn sie gleich aussehen. An nachgestellten Daten geprueft: Aus -579.50 wird -290.50; die drei gleichen Aufrundungen aus einem Zugang bleiben alle stehen. Co-Authored-By: Claude Opus 5 --- backend/app/routers/bank.py | 20 ++++++ backend/app/services/bank_sync.py | 21 +++++-- .../scripts/raeume_doppelte_bankumsaetze.py | 55 ++++++++++++++--- backend/tests/test_bank.py | 61 +++++++++++++++++++ 4 files changed, 143 insertions(+), 14 deletions(-) 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