fix: Abruf ueber schon eingelesene Monate buchte alles doppelt

"Aelteres nachholen" greift 90 Tage zurueck – mitten in Monate, die schon per
CSV eingelesen sind. Genau davor schuetzt sonst das Startdatum, und der Knopf
haengt es aus. Der Duplikatschutz faengt das nicht: Sein Schluessel enthaelt
den Empfaengertext, und der lautet bei der Bank anders als im Auszug. Ergebnis:
jede Zahlung des Zeitraums zweimal.

Das war mein Fehler – ich habe den Knopf gebaut UND empfohlen, ohne die
Sicherung zu ersetzen, die er aushebelt.

Der Abruf prueft jetzt selbst: Gibt es auf dem Konto schon eine Buchung
gleicher Richtung und gleichen Betrags im Umkreis von drei Tagen, die NICHT
von der Bank kam, ist der Umsatz dieselbe Bewegung und wird uebersprungen.
Jede vorhandene Buchung erklaert dabei hoechstens einen Umsatz – sonst
verschluckte eine Kaffeezahlung alle weiteren desselben Betrags. Die Zahl der
uebersprungenen steht in der Rueckmeldung.

Fuer den bereits angerichteten Schaden gibt es
scripts/raeume_doppelte_bankumsaetze.py: Es findet Bank-Umsaetze, die eine
vorhandene Buchung wiederholen, und entfernt IMMER die von der Bank – die
eigene traegt die Kategorie des Nutzers. Sie wird verworfen, nicht geloescht,
damit kein spaeterer Abruf sie wiederholt. Ohne --wirklich passiert nichts;
mit --wirklich wird zuerst eine Kopie der Datenbank angelegt.

An nachgestellten Daten geprueft: Aus -95.80 wird -48.40, die eigenen
Buchungen samt Kategorie bleiben, der echte neue Umsatz bleibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-27 20:55:51 +02:00
co-authored by Claude Opus 5
parent 12ea391e9f
commit 385352974c
6 changed files with 236 additions and 4 deletions
+61 -2
View File
@@ -32,6 +32,7 @@ from ..models import (
InboxSource,
InboxStatus,
Transaction,
TxnType,
User,
)
from . import csv_import, eingang, geheimnis
@@ -524,6 +525,51 @@ 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.
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.
"""
aus_bank = {
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),
)
}
je_schluessel: dict[tuple[str, int], list] = {}
for txn in db.query(Transaction).filter(
Transaction.owner_id == user.id,
Transaction.account_id == konto.id,
Transaction.type != TxnType.transfer,
Transaction.date >= seit - timedelta(days=csv_import.INBOX_TAGE),
):
if txn.id in aus_bank:
continue
je_schluessel.setdefault((txn.type.value, txn.amount_cents), []).append(txn)
return je_schluessel
def _findet_fremde(fremde: dict, verbraucht: set[int], cents: int, tag: date):
"""Passende eigene Buchung – jede erklaert hoechstens EINEN Bank-Umsatz.
Ohne die Menge verschluckte eine Kaffeezahlung alle weiteren desselben
Betrags in der Umgebung.
"""
art = "expense" if cents < 0 else "income"
for txn in fremde.get((art, abs(cents)), []):
if txn.id in verbraucht:
continue
if abs((txn.date - tag).days) <= csv_import.INBOX_TAGE:
return txn
return None
def hole_umsaetze(db: Session, user: User, verbindung: BankConnection,
nur_link_id: int | None = None, tage: int | None = None) -> dict:
"""Umsätze der zugeordneten Konten dieser Verbindung in den Eingang holen.
@@ -543,7 +589,7 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection,
konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
geholt = neu = gebucht = vorgemerkt = 0
geholt = neu = gebucht = vorgemerkt = doppelt = 0
ab: date | None = None
for verknuepfung in verknuepfungen:
konto = konten.get(verknuepfung.account_id)
@@ -553,8 +599,12 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection,
# ganz neu.
seit = _startdatum(db, user, konto, verknuepfung, tage)
ab = seit if ab is None else min(ab, seit)
# Je Konto EINE Menge verbrauchter Eingangs-Eintraege.
# Je Konto EINE Menge verbrauchter Eingangs-Eintraege ...
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_verbraucht: set[int] = set()
for umsatz in client.umsaetze(verknuepfung.remote_uid, seit):
geholt += 1
zustand = _ist_gebucht(umsatz)
@@ -605,6 +655,14 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection,
if zeile.get("is_duplicate"):
continue
# Steht die Zahlung schon da, weil sie frueher per Datei oder von
# Hand erfasst wurde? Dann ist der Bank-Umsatz dieselbe Bewegung.
schon_da = _findet_fremde(fremde, fremde_verbraucht, cents, tag)
if schon_da is not None:
fremde_verbraucht.add(schon_da.id)
doppelt += 1
continue
waehrung = ((umsatz.get("transaction_amount") or {}).get("currency")
or konto.currency)
kurs, gegen_betrag, gegen_waehrung = _umrechnung(umsatz, waehrung)
@@ -648,6 +706,7 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection,
return {
"uebertraege": verknuepft,
"geholt": geholt, "neu": neu, "gebucht": gebucht, "vorgemerkt": vorgemerkt,
"doppelt": doppelt,
"saldi": saldi, "ab": ab.isoformat() if ab else None, "ohne_zuordnung": False,
}