fix: Bankabruf verschluckte Umsaetze mit wiederkehrendem Betrag

Der Eingangs-Abgleich vergleicht Betrag und Datum mit ein paar Tagen
Spielraum – bewusst unscharf, damit eine per Kurzbefehl erfasste Zahlung nicht
ein zweites Mal ankommt. Er merkt sich dabei, welchen Eintrag er schon
verbraucht hat.

Nur ruft der Bankabruf ihn JE ZEILE einzeln auf, und damit begann diese Menge
jedes Mal von vorn: Derselbe Eintrag liess sich beliebig oft verbrauchen. Bei
Revolut-Aufrundungen – dreimal 1.00 in drei Tagen – kam so nur die erste an,
und der Saldo wich ab, ohne dass irgendwo etwas stand.

Zwei Aenderungen:

- Der Aufrufer kann die Menge jetzt ueber mehrere Aufrufe mitfuehren; der
  Bankabruf haelt eine je Konto.
- Gegen andere BANK-Eintraege wird gar nicht mehr unscharf verglichen. Zwei
  Umsaetze derselben Quelle sind nie Dubletten voneinander – dort entscheidet
  die Kennung der Bank, und die ist exakt.

Dazu ein Weg zurueck: Ein verschluckter Umsatz hinterliess keinen Eintrag, der
naechste Abruf haette ihn also wieder geholt – nur liegt er dann vor dem
Fenster des letzten Abrufs. "Aelteres nachholen" gibt es jetzt auch je Konto,
wie beim Postfach.

Nachgestellt und als Test festgehalten: dreimal 1.00 in drei Tagen, alle drei
kommen an, Saldo -3.00.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-27 16:45:38 +02:00
co-authored by Claude Opus 5
parent f2c1ab7dec
commit b4c90f5221
8 changed files with 131 additions and 26 deletions
+6 -2
View File
@@ -128,7 +128,7 @@ def sync(connection_id: int, db: Session = Depends(get_db),
@router.post("/links/{link_id}/sync") @router.post("/links/{link_id}/sync")
def sync_link(link_id: int, db: Session = Depends(get_db), def sync_link(link_id: int, tage: int | None = None, db: Session = Depends(get_db),
user: User = Depends(get_current_user)) -> dict: user: User = Depends(get_current_user)) -> dict:
"""Nur die Umsätze DIESES Kontos holen. """Nur die Umsätze DIESES Kontos holen.
@@ -138,7 +138,11 @@ def sync_link(link_id: int, db: Session = Depends(get_db),
verknuepfung = get_owned(db, BankAccountLink, link_id, user) verknuepfung = get_owned(db, BankAccountLink, link_id, user)
verbindung = get_owned(db, BankConnection, verknuepfung.connection_id, user) verbindung = get_owned(db, BankConnection, verknuepfung.connection_id, user)
try: try:
return bank_sync.hole_umsaetze(db, user, verbindung, nur_link_id=verknuepfung.id) if tage is not None and not 1 <= tage <= 365:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail="Der Zeitraum muss zwischen 1 und 365 Tagen liegen.")
return bank_sync.hole_umsaetze(db, user, verbindung,
nur_link_id=verknuepfung.id, tage=tage)
except BankFehler as e: except BankFehler as e:
verbindung.last_error = str(e)[:500] verbindung.last_error = str(e)[:500]
db.commit() db.commit()
+21 -7
View File
@@ -485,7 +485,7 @@ def _ist_gebucht(umsatz: dict) -> bool | None:
def _startdatum(db: Session, user: User, konto: Account, def _startdatum(db: Session, user: User, konto: Account,
verknuepfung: BankAccountLink) -> date: verknuepfung: BankAccountLink, tage: int | None = None) -> date:
"""Ab welchem Tag Umsaetze DIESES Kontos geholt werden. """Ab welchem Tag Umsaetze DIESES Kontos geholt werden.
Beim ERSTEN Abruf ab dem Tag nach der neuesten vorhandenen Buchung des Beim ERSTEN Abruf ab dem Tag nach der neuesten vorhandenen Buchung des
@@ -501,6 +501,10 @@ def _startdatum(db: Session, user: User, konto: Account,
ein Einzelabruf das Startdatum der uebrigen Konten mit vor, und deren ein Einzelabruf das Startdatum der uebrigen Konten mit vor, und deren
Umsaetze aus dem uebersprungenen Zeitraum kaemen nie an. Umsaetze aus dem uebersprungenen Zeitraum kaemen nie an.
""" """
if tage is not None:
# Uebergeht den letzten Abruf. Noetig, wenn etwas nicht angekommen ist:
# Sonst laege es fuer immer vor dem Fenster, obwohl die Bank es haette.
return date.today() - timedelta(days=tage)
if verknuepfung.last_sync_at: if verknuepfung.last_sync_at:
return verknuepfung.last_sync_at.date() - timedelta(days=3) return verknuepfung.last_sync_at.date() - timedelta(days=3)
frueheste = date.today() - timedelta(days=ERSTER_ABRUF_TAGE) frueheste = date.today() - timedelta(days=ERSTER_ABRUF_TAGE)
@@ -515,7 +519,7 @@ def _startdatum(db: Session, user: User, konto: Account,
def hole_umsaetze(db: Session, user: User, verbindung: BankConnection, def hole_umsaetze(db: Session, user: User, verbindung: BankConnection,
nur_link_id: int | None = None) -> dict: nur_link_id: int | None = None, tage: int | None = None) -> dict:
"""Umsätze der zugeordneten Konten dieser Verbindung in den Eingang holen. """Umsätze der zugeordneten Konten dieser Verbindung in den Eingang holen.
Mit ``nur_link_id`` nur das eine Konto – samt seinem Saldo. Ein Abruf Mit ``nur_link_id`` nur das eine Konto – samt seinem Saldo. Ein Abruf
@@ -541,8 +545,10 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection,
continue continue
# Je Konto eigen: Das eine ist bis gestern per CSV gepflegt, das andere # Je Konto eigen: Das eine ist bis gestern per CSV gepflegt, das andere
# ganz neu. # ganz neu.
seit = _startdatum(db, user, konto, verknuepfung) seit = _startdatum(db, user, konto, verknuepfung, tage)
ab = seit if ab is None else min(ab, seit) ab = seit if ab is None else min(ab, seit)
# Je Konto EINE Menge verbrauchter Eingangs-Eintraege.
verbraucht: set[int] = set()
for umsatz in client.umsaetze(verknuepfung.remote_uid, seit): for umsatz in client.umsaetze(verknuepfung.remote_uid, seit):
geholt += 1 geholt += 1
zustand = _ist_gebucht(umsatz) zustand = _ist_gebucht(umsatz)
@@ -577,10 +583,18 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection,
# Durch dieselbe Erkennung wie CSV und Kurzbefehl: Schlagwortregeln # Durch dieselbe Erkennung wie CSV und Kurzbefehl: Schlagwortregeln
# schlagen die Kategorie vor, der Eingangs-Abgleich verhindert, dass # schlagen die Kategorie vor, der Eingangs-Abgleich verhindert, dass
# eine bereits erfasste Zahlung ein zweites Mal auftaucht. # eine bereits erfasste Zahlung ein zweites Mal auftaucht.
erkannt = csv_import.detect_rows(db, user, konto, [{ erkannt = csv_import.detect_rows(
"date": tag.isoformat(), "amount_cents": cents, db, user, konto,
"payee": payee, "purpose": zweck, [{"date": tag.isoformat(), "amount_cents": cents,
}]) "payee": payee, "purpose": zweck}],
# Gegen andere BANK-Umsaetze nicht unscharf vergleichen: Dafuer
# gibt es die Kennung der Bank, und die ist exakt. Sonst
# verschluckt eine Aufrundung ueber 1.00 die naechste.
eingang_ohne=InboxSource.bank,
# Und ueber alle Zeilen dieses Kontos hinweg dieselbe Menge,
# denn hier wird je Zeile einzeln aufgerufen.
verbraucht=verbraucht,
)
zeile = erkannt[0] if erkannt else {} zeile = erkannt[0] if erkannt else {}
if zeile.get("is_duplicate"): if zeile.get("is_duplicate"):
continue continue
+16 -6
View File
@@ -17,6 +17,7 @@ from ..models import (
Account, Account,
AccountIdentifier, AccountIdentifier,
InboxItem, InboxItem,
InboxSource,
InboxStatus, InboxStatus,
AmountMode, AmountMode,
BudgetTreatment, BudgetTreatment,
@@ -663,7 +664,9 @@ def finde_eingangs_treffer(
return beste[1] if beste else None return beste[1] if beste else None
def detect_rows(db: Session, user: User, account: Account, candidates: list[dict]) -> list[dict]: def detect_rows(db: Session, user: User, account: Account, candidates: list[dict], *,
eingang_ohne: InboxSource | None = None,
verbraucht: set[int] | None = None) -> list[dict]:
"""Reichert Kandidaten an: Typ, Auto-Kategorie, Duplikat. """Reichert Kandidaten an: Typ, Auto-Kategorie, Duplikat.
Wird von CSV- UND Beleg-Import genutzt (gleiche Prüf-/Buchungslogik). Wird von CSV- UND Beleg-Import genutzt (gleiche Prüf-/Buchungslogik).
@@ -701,17 +704,24 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
# wortgleich mit dem des Auszugs, und das Buchungsdatum der Bank liegt oft # wortgleich mit dem des Auszugs, und das Buchungsdatum der Bank liegt oft
# ein bis zwei Tage später. Deshalb wird zusätzlich unscharf verglichen – # ein bis zwei Tage später. Deshalb wird zusätzlich unscharf verglichen –
# gleicher Betrag, Datum in der Nähe. # gleicher Betrag, Datum in der Nähe.
eingang = [ frage = db.query(
(eid, edatum, ebetrag)
for eid, edatum, ebetrag in db.query(
InboxItem.id, InboxItem.date, InboxItem.amount_cents InboxItem.id, InboxItem.date, InboxItem.amount_cents
).filter( ).filter(
InboxItem.owner_id == user.id, InboxItem.owner_id == user.id,
InboxItem.account_id == account.id, InboxItem.account_id == account.id,
InboxItem.status.in_((InboxStatus.booked, InboxStatus.discarded)), InboxItem.status.in_((InboxStatus.booked, InboxStatus.discarded)),
) )
] if eingang_ohne is not None:
eingang_verbraucht: set[int] = set() # Zwei Umsätze DERSELBEN Quelle sind nie Dubletten voneinander – dort
# entscheidet die Kennung der Quelle, und die ist exakt. Ohne diese
# Ausnahme verschluckt eine Zahlung über 1.00 die nächste über 1.00
# drei Tage später, und der Saldo weicht ab.
frage = frage.filter(InboxItem.source != eingang_ohne)
eingang = [(eid, edatum, ebetrag) for eid, edatum, ebetrag in frage]
# Der Aufrufer kann die Menge über mehrere Aufrufe hinweg mitführen: Der
# Bankabruf ruft je Zeile einzeln auf, und ohne gemeinsame Menge liesse
# sich derselbe Eintrag beliebig oft verbrauchen.
eingang_verbraucht: set[int] = verbraucht if verbraucht is not None else set()
seen: Counter = Counter() seen: Counter = Counter()
out: list[dict] = [] out: list[dict] = []
+67
View File
@@ -986,3 +986,70 @@ def test_geloeschte_buchung_blockiert_auch_den_csv_import(admin, fake_bank):
db.close() db.close()
assert zeilen[0]["is_duplicate"] is True assert zeilen[0]["is_duplicate"] is True
assert zeilen[0]["duplicate_reason"] == "inbox" assert zeilen[0]["duplicate_reason"] == "inbox"
def test_zwei_gleiche_betraege_kurz_hintereinander_kommen_beide_an(admin, fake_bank):
"""Revolut-Aufrundungen sind oft mehrfach derselbe Betrag in wenigen Tagen.
Der Eingangs-Abgleich vergleicht nur Betrag und Datum (±3 Tage) – wenn er
denselben Eintrag mehrfach verbraucht, faellt die zweite Zahlung weg und
der Saldo weicht ab."""
_app(admin)
heute = date.today()
uid = f"acc-{secrets.token_hex(4)}"
umsaetze = [
{"entry_reference": f"tx-{secrets.token_hex(4)}",
"booking_date": (heute - timedelta(days=tage)).isoformat(),
"transaction_amount": {"amount": "1.00", "currency": "CHF"},
"credit_debit_indicator": "DBIT",
"creditor": {"name": "To CHF MB:8f33c0aa"}}
for tage in (2, 1, 0)
]
fake_bank(konten=[{"uid": uid, "currency": "CHF"}], umsaetze=umsaetze)
state = admin.post("/api/bank/connect",
json={"aspsp_name": f"Rundung-{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"Rundung {secrets.token_hex(3)}", currency="CHF")
admin.post(f"/api/bank/links/{link['id']}/account/{konto['id']}")
r = admin.post(f"/api/bank/links/{link['id']}/sync").json()
assert r["geholt"] == 3
assert r["neu"] == 3, f"nur {r['neu']} von 3 angekommen"
assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == -300
def test_verschluckter_umsatz_laesst_sich_nachholen(admin, fake_bank):
"""Wurde etwas faelschlich als Dublette uebersprungen, entstand kein
Eingangs-Eintrag – der naechste Abruf haette es also wieder geholt. Nur
liegt es dann meist vor dem Fenster des letzten Abrufs."""
_app(admin)
alt = (date.today() - timedelta(days=40)).isoformat()
uid = f"acc-{secrets.token_hex(4)}"
gegenstelle = fake_bank(
konten=[{"uid": uid, "currency": "EUR"}],
umsaetze=[dict(UMSATZ, entry_reference=f"tx-{secrets.token_hex(4)}",
booking_date=alt)])
state = admin.post("/api/bank/connect",
json={"aspsp_name": f"Nachhol-{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"Nachhol {secrets.token_hex(3)}", currency="EUR")
# Eine vorhandene Buchung schiebt das Startdatum nach vorn.
admin.post("/api/transactions", json={
"type": "expense", "date": date.today().isoformat(), "account_id": konto["id"],
"amount_cents": 100, "payee": "schon da"})
admin.post(f"/api/bank/links/{link['id']}/account/{konto['id']}")
# Der normale Abruf greift nicht weit genug zurueck.
assert admin.post(f"/api/bank/links/{link['id']}/sync").json()["neu"] == 0
assert gegenstelle.date_from > alt
# Mit Zeitraum schon.
r = admin.post(f"/api/bank/links/{link['id']}/sync", params={"tage": 90}).json()
assert r["neu"] == 1, r
assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == -100 - 1490
assert admin.post(f"/api/bank/links/{link['id']}/sync",
params={"tage": 400}).status_code == 400
+3 -1
View File
@@ -243,7 +243,9 @@ export const api = {
request(`/bank/connections/${id}/salden`, { method: "POST" }), request(`/bank/connections/${id}/salden`, { method: "POST" }),
// Nur dieses eine Konto abrufen – ein Abruf bucht, und wer ein Konto holt, // Nur dieses eine Konto abrufen – ein Abruf bucht, und wer ein Konto holt,
// will das andere nicht angefasst haben. // will das andere nicht angefasst haben.
syncBankLink: (linkId) => request(`/bank/links/${linkId}/sync`, { method: "POST" }), syncBankLink: (linkId, tage) =>
request(`/bank/links/${linkId}/sync${tage ? `?tage=${tage}` : ""}`,
{ method: "POST" }),
// Den Saldo der Bank als richtig uebernehmen – die Differenz wandert in // Den Saldo der Bank als richtig uebernehmen – die Differenz wandert in
// den Anfangsbestand, die Buchungen bleiben unangetastet. // den Anfangsbestand, die Buchungen bleiben unangetastet.
adoptBankBalance: (accountId) => adoptBankBalance: (accountId) =>
+2 -1
View File
@@ -650,7 +650,8 @@
"poll": "Automatisch holen", "poll": "Automatisch holen",
"pollUnit": "Minuten (0 = aus)", "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.", "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." "pollSaved": "Takt gespeichert.",
"syncOlder": "Älteres nachholen"
}, },
"bankReturn": { "bankReturn": {
"title": "Rückkehr von der Bank", "title": "Rückkehr von der Bank",
+2 -1
View File
@@ -650,7 +650,8 @@
"poll": "Fetch automatically", "poll": "Fetch automatically",
"pollUnit": "minutes (0 = off)", "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.", "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." "pollSaved": "Interval saved.",
"syncOlder": "Catch up on older"
}, },
"bankReturn": { "bankReturn": {
"title": "Back from your bank", "title": "Back from your bank",
+8 -2
View File
@@ -122,10 +122,10 @@ export default function Bankanbindung() {
return teile.join(" "); return teile.join(" ");
} }
async function kontoAbrufen(l) { async function kontoAbrufen(l, tage) {
setBusy(true); setBusy(true);
try { try {
const r = await api.syncBankLink(l.id); const r = await api.syncBankLink(l.id, tage);
toast(r.ohne_zuordnung ? t("bank.noMapping") : meldung(r)); toast(r.ohne_zuordnung ? t("bank.noMapping") : meldung(r));
laden(); laden();
} catch (e) { } catch (e) {
@@ -307,6 +307,12 @@ export default function Bankanbindung() {
onClick={() => kontoAbrufen(l)}> onClick={() => kontoAbrufen(l)}>
{t("bank.syncOne")} {t("bank.syncOne")}
</button> </button>
{/* Fehlt etwas, liegt es meist vor dem Fenster des
letzten Abrufs und käme sonst nie wieder. */}
<button className="btn sm" disabled={busy}
onClick={() => kontoAbrufen(l, 90)}>
{t("bank.syncOlder")}
</button>
<span className="muted small"> <span className="muted small">
{l.last_sync_at {l.last_sync_at
? t("bank.lastSync", { when: formatDate(l.last_sync_at) }) ? t("bank.lastSync", { when: formatDate(l.last_sync_at) })