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:
@@ -257,15 +257,35 @@ def state_is_bookable(state: str | None) -> bool:
|
|||||||
COUNTERPART_DAYS = 5
|
COUNTERPART_DAYS = 5
|
||||||
|
|
||||||
|
|
||||||
def _is_counterpart(transfer_index: dict, account_id: int, other_id: int, d: date, amount: int) -> bool:
|
# Wie weit der tatsächlich gutgeschriebene Betrag vom geschätzten abweichen darf,
|
||||||
"""True, wenn schon ein Transfer zwischen denselben Konten existiert, dessen
|
# damit er noch als dieselbe Bewegung gilt. Bei einem Währungstausch rechnen wir
|
||||||
Betrag (Soll- oder Haben-Seite) zu dieser Buchung passt – dann ist es die
|
# mit dem EZB-Kurs, die Bank nutzt ihren eigenen – ein paar Prozent Unterschied
|
||||||
bereits gebuchte Gegenseite und darf nicht erneut zählen."""
|
# 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)
|
absamt = abs(amount)
|
||||||
for t_date, amt, to_amt in transfer_index.get(frozenset((account_id, other_id)), []):
|
for eintrag in transfer_index.get(frozenset((account_id, other_id)), []):
|
||||||
if abs((t_date - d).days) <= COUNTERPART_DAYS and absamt in (amt, to_amt):
|
_txn_id, t_date, amt, to_amt = eintrag
|
||||||
return True
|
if abs((t_date - d).days) > COUNTERPART_DAYS:
|
||||||
return False
|
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]:
|
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
|
# Bestehende Transfers je Konto-Paar: erkennt die Gegenbuchung, wenn beide
|
||||||
# Auszüge eines Übertrags importiert werden (sonst zählt die Bewegung doppelt).
|
# Auszüge eines Übertrags importiert werden (sonst zählt die Bewegung doppelt).
|
||||||
transfer_index: dict[frozenset, list[tuple]] = {}
|
transfer_index: dict[frozenset, list[tuple]] = {}
|
||||||
for t_acc, t_to, t_date, t_amt, t_to_amt in (
|
for t_id, t_acc, t_to, t_date, t_amt, t_to_amt in (
|
||||||
db.query(Transaction.account_id, Transaction.to_account_id, Transaction.date,
|
db.query(Transaction.id, Transaction.account_id, Transaction.to_account_id,
|
||||||
Transaction.amount_cents, Transaction.to_amount_cents)
|
Transaction.date, Transaction.amount_cents, Transaction.to_amount_cents)
|
||||||
.filter(Transaction.owner_id == user.id, Transaction.type == TxnType.transfer,
|
.filter(Transaction.owner_id == user.id, Transaction.type == TxnType.transfer,
|
||||||
Transaction.to_account_id.isnot(None))
|
Transaction.to_account_id.isnot(None))
|
||||||
):
|
):
|
||||||
transfer_index.setdefault(frozenset((t_acc, t_to)), []).append(
|
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()
|
seen: Counter = Counter()
|
||||||
|
|
||||||
out: list[dict] = []
|
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
|
is_dup = seen[h] < existing[h] # nur so viele Duplikate wie schon in der DB
|
||||||
seen[h] += 1
|
seen[h] += 1
|
||||||
|
|
||||||
# Gegenbuchung eines bereits gebuchten Transfers? Dann nicht erneut buchen.
|
# Gegenbuchung eines bereits gebuchten Transfers? Dann nicht erneut buchen –
|
||||||
is_counterpart = (
|
# der tatsächliche Betrag dieser Zeile korrigiert aber die Schätzung der
|
||||||
ttype == "transfer" and to_account_id is not None
|
# anderen Seite (beim Umtausch rechneten wir dort mit dem EZB-Kurs).
|
||||||
and d is not None and amount is not None
|
gegenbuchung = None
|
||||||
and _is_counterpart(transfer_index, account.id, to_account_id, d, amount)
|
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({
|
out.append({
|
||||||
"index": i,
|
"index": i,
|
||||||
@@ -442,6 +470,8 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
|
|||||||
"import_hash": h,
|
"import_hash": h,
|
||||||
"is_duplicate": is_dup,
|
"is_duplicate": is_dup,
|
||||||
"is_counterpart": is_counterpart,
|
"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,
|
"include": valid and not is_dup and not is_counterpart,
|
||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
@@ -633,6 +663,34 @@ def _track_and_learn(db: Session, user: User, row: dict) -> None:
|
|||||||
_learn_rule(db, user, row)
|
_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:
|
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)}
|
accounts = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
|
||||||
batch = ImportBatch(
|
batch = ImportBatch(
|
||||||
@@ -644,8 +702,14 @@ def commit_import(db: Session, user: User, account: Account, template_id: int |
|
|||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
imported = 0
|
imported = 0
|
||||||
|
korrigiert = 0
|
||||||
for row in rows:
|
for row in rows:
|
||||||
if not row.get("include", True):
|
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
|
continue
|
||||||
txn = _make_transaction(db, user, account, accounts, row, batch.id)
|
txn = _make_transaction(db, user, account, accounts, row, batch.id)
|
||||||
if txn is None:
|
if txn is None:
|
||||||
|
|||||||
@@ -289,3 +289,62 @@ def test_date_format_tolerates_time(admin):
|
|||||||
).json()
|
).json()
|
||||||
assert all(r["date"] for r in res["rows"]), "Datum trotz Uhrzeit erkannt"
|
assert all(r["date"] for r in res["rows"]), "Datum trotz Uhrzeit erkannt"
|
||||||
assert res["reconciliation"]["ok"] is True
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user