From 15096957605fc1393cb1d9bd23928ecdf206e85f Mon Sep 17 00:00:00 2001 From: Guido Date: Sun, 30 Aug 2026 13:51:50 +0200 Subject: [PATCH] fix: Ein Umsatz unter neuer Kennung kam ein zweites Mal herein MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Bankabruf nahm die ERSTE Kennung, die ein Umsatz mitbringt – meist die Referenz der Bank. Vergibt die Bank sie beim Buchen neu (aus vorgemerkt wird gebucht), ist es fuer Certain ein anderer Umsatz, und dieselbe Zahlung steht zweimal da. Genau so sind zwei Mietbuchungen ueber 288 Euro am selben Tag entstanden. Zwei Aenderungen: - Geprueft wird gegen ALLE Kennungen des Umsatzes, nicht nur gegen eine. Damit treffen auch Eintraege, die frueher unter einer anderen abgelegt wurden. - Gespeichert wird die bestaendigste: die eigene Kennung der Schnittstelle bleibt gleich, wenn die Bank ihre Referenz austauscht. Das ist eine von vier Stellen, an denen Certain Dubletten erkennt – neben dem Import-Schluessel, dem unscharfen Eingangs-Abgleich und dem Ueberschneidungsschutz. Diese vier gehoeren zusammengefuehrt; das ist ein eigener Umbau und nicht Teil dieser Korrektur. Co-Authored-By: Claude Opus 5 --- backend/app/services/bank_sync.py | 21 +++++++++++++---- backend/tests/test_bank.py | 39 +++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/backend/app/services/bank_sync.py b/backend/app/services/bank_sync.py index b2c181e..06c3906 100644 --- a/backend/app/services/bank_sync.py +++ b/backend/app/services/bank_sync.py @@ -651,20 +651,31 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection, # Eingang und niemand weiss, warum. vorgemerkt += 1 continue - kennung = (umsatz.get("entry_reference") or umsatz.get("transaction_id") - or umsatz.get("uid")) - if not kennung: + # ALLE Kennungen, die der Umsatz mitbringt – nicht nur die erste. + # Liefert die Bank denselben Umsatz spaeter unter einer anderen + # (etwa weil aus vorgemerkt gebucht wurde), waeren es sonst zwei. + kennungen = [ + f"{verknuepfung.remote_uid}:{wert}"[:255] + # Reihenfolge nach Bestaendigkeit: Die eigene Kennung der + # Schnittstelle bleibt gleich, wenn die Bank ihre Referenz beim + # Buchen neu vergibt. Gespeichert wird die erste – geprueft + # wird gegen alle, damit auch aeltere Eintraege noch treffen. + for feld in ("transaction_id", "internal_transaction_id", + "entry_reference", "uid") + if (wert := umsatz.get(feld)) + ] + if not kennungen: continue # ohne Kennung kein Schutz vor Doppelabruf - kennung = f"{verknuepfung.remote_uid}:{kennung}"[:255] schon = ( db.query(InboxItem.id) .filter(InboxItem.owner_id == user.id, InboxItem.source == InboxSource.bank, - InboxItem.external_id == kennung) + InboxItem.external_id.in_(kennungen)) .first() ) if schon is not None: continue + kennung = kennungen[0] cents = _betrag_cents(umsatz) tag = _datum(umsatz) diff --git a/backend/tests/test_bank.py b/backend/tests/test_bank.py index 6e1bfe7..6f79b13 100644 --- a/backend/tests/test_bank.py +++ b/backend/tests/test_bank.py @@ -1307,3 +1307,42 @@ def test_zu_knapper_takt_wird_gebremst(): # Nach sechs Stunden schon. knapp.last_sync_at = jetzt - timedelta(hours=7) assert bank_sync.faellig(knapp, jetzt) is True + + +def test_umsatz_unter_anderer_kennung_kommt_nicht_zweimal(admin, fake_bank): + """Liefert die Bank denselben Umsatz spaeter unter einer anderen Kennung – + etwa weil aus vorgemerkt gebucht wurde –, waeren es fuer Certain zwei. + Geprueft wird deshalb gegen ALLE Kennungen, die er mitbringt.""" + _app(admin) + heute = date.today().isoformat() + uid = f"acc-{secrets.token_hex(4)}" + stabil = f"tx-{secrets.token_hex(5)}" + salden = [{"balance_type": "CLBD", + "balance_amount": {"amount": "0.00", "currency": "EUR"}}] + + def umsatz(referenz): + return {"entry_reference": referenz, "transaction_id": stabil, + "booking_date": heute, + "transaction_amount": {"amount": "288.00", "currency": "EUR"}, + "credit_debit_indicator": "DBIT", + "creditor": {"name": "Noemi Koenig"}} + + fake_bank(konten=[{"uid": uid, "currency": "EUR"}], salden=salden, + umsaetze=[umsatz("ref-vorgemerkt")]) + state = admin.post("/api/bank/connect", + json={"aspsp_name": f"Kennung-{secrets.token_hex(3)}", + "country": "DE"}).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"Kennung {secrets.token_hex(3)}", currency="EUR") + admin.post(f"/api/bank/links/{link['id']}/account/{konto['id']}") + + assert admin.post(f"/api/bank/links/{link['id']}/sync").json()["neu"] == 1 + assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == -28800 + + # Zweiter Abruf: andere entry_reference, dieselbe transaction_id. + fake_bank(konten=[{"uid": uid, "currency": "EUR"}], salden=salden, + umsaetze=[umsatz("ref-gebucht")]) + zweiter = admin.post(f"/api/bank/links/{link['id']}/sync").json() + assert zweiter["neu"] == 0, "unter neuer Referenz noch einmal gebucht" + assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == -28800