fix: Ein Umsatz unter neuer Kennung kam ein zweites Mal herein

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 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-30 13:51:50 +02:00
co-authored by Claude Opus 5
parent 67a32bdda9
commit 1509695760
2 changed files with 55 additions and 5 deletions
+16 -5
View File
@@ -651,20 +651,31 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection,
# Eingang und niemand weiss, warum. # Eingang und niemand weiss, warum.
vorgemerkt += 1 vorgemerkt += 1
continue continue
kennung = (umsatz.get("entry_reference") or umsatz.get("transaction_id") # ALLE Kennungen, die der Umsatz mitbringt – nicht nur die erste.
or umsatz.get("uid")) # Liefert die Bank denselben Umsatz spaeter unter einer anderen
if not kennung: # (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 continue # ohne Kennung kein Schutz vor Doppelabruf
kennung = f"{verknuepfung.remote_uid}:{kennung}"[:255]
schon = ( schon = (
db.query(InboxItem.id) db.query(InboxItem.id)
.filter(InboxItem.owner_id == user.id, .filter(InboxItem.owner_id == user.id,
InboxItem.source == InboxSource.bank, InboxItem.source == InboxSource.bank,
InboxItem.external_id == kennung) InboxItem.external_id.in_(kennungen))
.first() .first()
) )
if schon is not None: if schon is not None:
continue continue
kennung = kennungen[0]
cents = _betrag_cents(umsatz) cents = _betrag_cents(umsatz)
tag = _datum(umsatz) tag = _datum(umsatz)
+39
View File
@@ -1307,3 +1307,42 @@ def test_zu_knapper_takt_wird_gebremst():
# Nach sechs Stunden schon. # Nach sechs Stunden schon.
knapp.last_sync_at = jetzt - timedelta(hours=7) knapp.last_sync_at = jetzt - timedelta(hours=7)
assert bank_sync.faellig(knapp, jetzt) is True 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