feat: Währungstausch wird über beide Auszüge als ein Transfer erkannt

Revolut tauscht Geld zwischen Währungskonten; im CHF-Auszug steht -100.00 CHF,
im EUR-Auszug +106.42 EUR. Die Gegenbuchungs-Erkennung verglich exakte Beträge
und griff deshalb nie – die Bewegung wurde zweimal gezählt.

- _find_counterpart erkennt die Gegenseite bei unterschiedlichen Währungen über
  Konto-Paar, Zeitfenster und einen plausiblen Betrag (10 % Toleranz, weil wir
  mit dem EZB-Kurs schätzen und die Bank ihren eigenen nutzt)
- die Gegenbuchung nennt den TATSÄCHLICHEN Betrag: er ersetzt beim Buchen die
  Schätzung im bereits gebuchten Transfer, damit beide Salden exakt stimmen
- Test über beide Auszüge: ein Transfer, keine Doppelzählung, EUR-Seite exakt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-21 08:13:59 +02:00
co-authored by Claude Opus 4.8
parent d97e08e913
commit cb4f3a8909
2 changed files with 141 additions and 18 deletions
+82 -18
View File
@@ -257,15 +257,35 @@ def state_is_bookable(state: str | None) -> bool:
COUNTERPART_DAYS = 5
def _is_counterpart(transfer_index: dict, account_id: int, other_id: int, d: date, amount: int) -> bool:
"""True, wenn schon ein Transfer zwischen denselben Konten existiert, dessen
Betrag (Soll- oder Haben-Seite) zu dieser Buchung passt – dann ist es die
bereits gebuchte Gegenseite und darf nicht erneut zählen."""
# Wie weit der tatsächlich gutgeschriebene Betrag vom geschätzten abweichen darf,
# damit er noch als dieselbe Bewegung gilt. Bei einem Währungstausch rechnen wir
# mit dem EZB-Kurs, die Bank nutzt ihren eigenen – ein paar Prozent Unterschied
# sind normal, eine andere Buchung liegt praktisch nie so nah.
COUNTERPART_TOLERANCE = 0.10
def _find_counterpart(
transfer_index: dict, account_id: int, other_id: int, d: date, amount: int,
cross_currency: bool,
) -> tuple | None:
"""Sucht einen bereits gebuchten Transfer, zu dem diese Zeile die Gegenseite ist.
Bei gleicher Währung müssen die Beträge übereinstimmen. Bei einem Umtausch
(z. B. Revolut CHF -> EUR) stehen zwei verschiedene Beträge gegenüber; dann
zählen Konto-Paar, Zeitfenster und ein plausibler Betrag."""
absamt = abs(amount)
for t_date, amt, to_amt in transfer_index.get(frozenset((account_id, other_id)), []):
if abs((t_date - d).days) <= COUNTERPART_DAYS and absamt in (amt, to_amt):
return True
return False
for eintrag in transfer_index.get(frozenset((account_id, other_id)), []):
_txn_id, t_date, amt, to_amt = eintrag
if abs((t_date - d).days) > COUNTERPART_DAYS:
continue
if absamt in (amt, to_amt):
return eintrag
if cross_currency:
# Die geschätzte Gegenseite ist der Vergleichswert.
for schaetzung in (to_amt, amt):
if schaetzung and abs(absamt - schaetzung) <= schaetzung * COUNTERPART_TOLERANCE:
return eintrag
return None
def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict]:
@@ -348,15 +368,18 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
# Bestehende Transfers je Konto-Paar: erkennt die Gegenbuchung, wenn beide
# Auszüge eines Übertrags importiert werden (sonst zählt die Bewegung doppelt).
transfer_index: dict[frozenset, list[tuple]] = {}
for t_acc, t_to, t_date, t_amt, t_to_amt in (
db.query(Transaction.account_id, Transaction.to_account_id, Transaction.date,
Transaction.amount_cents, Transaction.to_amount_cents)
for t_id, t_acc, t_to, t_date, t_amt, t_to_amt in (
db.query(Transaction.id, Transaction.account_id, Transaction.to_account_id,
Transaction.date, Transaction.amount_cents, Transaction.to_amount_cents)
.filter(Transaction.owner_id == user.id, Transaction.type == TxnType.transfer,
Transaction.to_account_id.isnot(None))
):
transfer_index.setdefault(frozenset((t_acc, t_to)), []).append(
(t_date, t_amt, t_to_amt if t_to_amt is not None else t_amt)
(t_id, t_date, t_amt, t_to_amt if t_to_amt is not None else t_amt)
)
waehrung_je_konto: dict[int, str] = dict(
db.query(Account.id, Account.currency).filter(Account.owner_id == user.id)
)
seen: Counter = Counter()
out: list[dict] = []
@@ -413,12 +436,17 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
is_dup = seen[h] < existing[h] # nur so viele Duplikate wie schon in der DB
seen[h] += 1
# Gegenbuchung eines bereits gebuchten Transfers? Dann nicht erneut buchen.
is_counterpart = (
ttype == "transfer" and to_account_id is not None
and d is not None and amount is not None
and _is_counterpart(transfer_index, account.id, to_account_id, d, amount)
)
# Gegenbuchung eines bereits gebuchten Transfers? Dann nicht erneut buchen –
# der tatsächliche Betrag dieser Zeile korrigiert aber die Schätzung der
# anderen Seite (beim Umtausch rechneten wir dort mit dem EZB-Kurs).
gegenbuchung = None
if (ttype == "transfer" and to_account_id is not None
and d is not None and amount is not None):
cross = waehrung_je_konto.get(to_account_id, account.currency) != account.currency
gegenbuchung = _find_counterpart(
transfer_index, account.id, to_account_id, d, amount, cross
)
is_counterpart = gegenbuchung is not None
out.append({
"index": i,
@@ -442,6 +470,8 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
"import_hash": h,
"is_duplicate": is_dup,
"is_counterpart": is_counterpart,
# Damit commit_import die geschätzte Gegenseite auf den echten Wert setzt.
"counterpart_txn_id": gegenbuchung[0] if gegenbuchung else None,
"include": valid and not is_dup and not is_counterpart,
})
return out
@@ -633,6 +663,34 @@ def _track_and_learn(db: Session, user: User, row: dict) -> None:
_learn_rule(db, user, row)
def _apply_counterpart_amount(db: Session, user: User, account: Account, row: dict) -> bool:
"""Trägt den tatsächlichen Betrag einer Umtausch-Gegenbuchung in den bereits
gebuchten Transfer ein. Diese Zeile stammt aus dem Auszug der anderen Seite und
nennt exakt, was dort angekommen bzw. abgegangen ist."""
txn_id = row.get("counterpart_txn_id")
amount = row.get("amount_cents")
if not txn_id or not amount or not row.get("is_counterpart"):
return False
txn = db.get(Transaction, txn_id)
if txn is None or txn.owner_id != user.id or txn.type != TxnType.transfer:
return False
absamt = abs(amount)
if txn.to_account_id == account.id: # hier ist das Geld angekommen
if txn.to_amount_cents == absamt:
return False
txn.to_amount_cents = absamt
elif txn.account_id == account.id: # hier ist es abgegangen
if txn.amount_cents == absamt:
return False
txn.amount_cents = absamt
txn.amount_eur_cents = currency.compute_amount_eur_cents(
db, absamt, account.currency, txn.date
)
else:
return False
return True
def commit_import(db: Session, user: User, account: Account, template_id: int | None, filename: str, rows: list[dict]) -> ImportBatch:
accounts = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
batch = ImportBatch(
@@ -644,8 +702,14 @@ def commit_import(db: Session, user: User, account: Account, template_id: int |
db.flush()
imported = 0
korrigiert = 0
for row in rows:
if not row.get("include", True):
# Gegenbuchung eines Umtauschs: nicht buchen, aber den echten Betrag
# dieser Seite in den bestehenden Transfer eintragen. Sonst bliebe dort
# die EZB-Schätzung stehen und der Saldo wiche um die Kursdifferenz ab.
if _apply_counterpart_amount(db, user, account, row):
korrigiert += 1
continue
txn = _make_transaction(db, user, account, accounts, row, batch.id)
if txn is None: