From cb4f3a89091f4ea215fb08c6421de0aed441e4ff Mon Sep 17 00:00:00 2001 From: Guido Date: Fri, 21 Aug 2026 08:13:59 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20W=C3=A4hrungstausch=20wird=20=C3=BCber?= =?UTF-8?q?=20beide=20Ausz=C3=BCge=20als=20ein=20Transfer=20erkannt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/services/csv_import.py | 100 +++++++++++++++++++++++------ backend/tests/test_import.py | 59 +++++++++++++++++ 2 files changed, 141 insertions(+), 18 deletions(-) diff --git a/backend/app/services/csv_import.py b/backend/app/services/csv_import.py index e1fdacd..8cd6bc8 100644 --- a/backend/app/services/csv_import.py +++ b/backend/app/services/csv_import.py @@ -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: diff --git a/backend/tests/test_import.py b/backend/tests/test_import.py index 72fe015..6bff5ed 100644 --- a/backend/tests/test_import.py +++ b/backend/tests/test_import.py @@ -289,3 +289,62 @@ def test_date_format_tolerates_time(admin): ).json() assert all(r["date"] for r in res["rows"]), "Datum trotz Uhrzeit erkannt" assert res["reconciliation"]["ok"] is True + + +# --- Währungstausch: beide Auszüge ergeben EINEN Transfer ------------------- + +def _rev_preview(admin, account_id, csv_text): + return admin.post( + "/api/imports/preview", + files={"file": ("x.csv", io.BytesIO(csv_text.encode("utf-8")), "text/csv")}, + data={ + "account_id": str(account_id), "delimiter": ",", "decimal_sep": ".", + "date_format": "%Y-%m-%d %H:%M:%S", "encoding": "utf-8", "has_header": "true", + "skip_rows": "0", "amount_mode": "signed", + "column_map": json.dumps({"date": 3, "amount": 5, "payee": 4, "fee": 6, + "currency": 7, "state": 8, "balance": 9}), + }, + ).json()["rows"] + + +CHF_UMTAUSCH = ( + "Art,Produkt,Beginn,Abschluss,Beschreibung,Betrag,Gebühr,Währung,Status,Kontostand\n" + "Umtausch,Giro,2026-02-01 10:00:00,2026-02-01 10:00:00,Umgetauscht in EUR,-100.00,0.00,CHF,ABGESCHLOSSEN,400.00\n" +) +EUR_UMTAUSCH = ( + "Art,Produkt,Beginn,Abschluss,Beschreibung,Betrag,Gebühr,Währung,Status,Kontostand\n" + "Umtausch,Giro,2026-02-01 10:00:00,2026-02-01 10:00:00,Umgetauscht in EUR,106.42,0.00,EUR,ABGESCHLOSSEN,106.42\n" +) + + +def test_currency_exchange_pairs_across_statements(admin): + chf = _account(admin, name="Rev CHF X", currency="CHF", opening_balance_cents=50000) + eur = _account(admin, name="Rev EUR X", currency="EUR", opening_balance_cents=0) + + # Regeln: „umgetauscht" ist ein Transfer zum jeweils anderen Konto. + admin.post("/api/categories/transfer-keywords", + json={"word": "umgetauscht", "target_account_id": eur["id"]}) + + # 1) CHF-Auszug: Umtausch geht als Transfer CHF -> EUR raus. + rows = _rev_preview(admin, chf["id"], CHF_UMTAUSCH) + assert rows[0]["type"] == "transfer" + assert rows[0]["to_account_id"] == eur["id"] + assert rows[0]["is_counterpart"] is False + admin.post("/api/imports/commit", json={"account_id": chf["id"], "filename": "chf.csv", "rows": rows}) + assert admin.get(f"/api/accounts/{chf['id']}").json()["balance_cents"] == 40000 + + # 2) EUR-Auszug: dieselbe Bewegung – trotz anderem Betrag als Gegenbuchung erkannt. + admin.post("/api/categories/transfer-keywords", + json={"word": "umgetauscht", "target_account_id": chf["id"]}) + rows2 = _rev_preview(admin, eur["id"], EUR_UMTAUSCH) + assert rows2[0]["type"] == "transfer" + assert rows2[0]["is_counterpart"] is True, "Umtausch-Gegenseite muss erkannt werden" + assert rows2[0]["include"] is False, "darf nicht doppelt zählen" + + r = admin.post("/api/imports/commit", json={"account_id": eur["id"], "filename": "eur.csv", "rows": rows2}) + assert r.json()["imported_count"] == 0 + + # Der echte Eingang korrigiert die EZB-Schätzung: EUR-Konto steht auf 106.42. + assert admin.get(f"/api/accounts/{eur['id']}").json()["balance_cents"] == 10642 + # CHF-Seite bleibt unverändert. + assert admin.get(f"/api/accounts/{chf['id']}").json()["balance_cents"] == 40000