feat: Gegenseite eines Übertrags braucht keine eigene Transfer-Regel

Bisher musste die Regel auf BEIDEN Auszügen ein fremdes Gegenkonto liefern. Beim
Empfangs-Auszug zeigt sie aber auf das Konto, das gerade importiert wird – damit
blieb die Zeile ohne Gegenkonto und wurde still als Einnahme gebucht.

- Gegenbuchungen werden über die Konto-Seite bestehender Transfers gesucht, nicht
  über das Konto-Paar. Der Empfangs-Auszug braucht damit keine Regel mehr; eine
  Regel auf der abgehenden Seite genügt für beide Dateien.
- Trifft eine Transfer-Regel, ohne dass ein Gegenkonto feststeht, wird die Zeile
  nicht mehr als Einnahme/Ausgabe gebucht, sondern in der Prüfansicht mit
  „Gegenkonto wählen" markiert – sonst zählt die Bewegung je nach Import-
  Reihenfolge doppelt.
- Tests für beide Reihenfolgen; 86 grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-21 08:20:13 +02:00
co-authored by Claude Opus 4.8
parent cb4f3a8909
commit 62eab06bdc
5 changed files with 69 additions and 33 deletions
+39 -26
View File
@@ -265,27 +265,31 @@ COUNTERPART_TOLERANCE = 0.10
def _find_counterpart( def _find_counterpart(
transfer_index: dict, account_id: int, other_id: int, d: date, amount: int, side_index: dict, verbraucht: set, account: Account, d: date, amount: int,
cross_currency: bool, waehrung: dict, nur_konto: int | None,
) -> tuple | None: ):
"""Sucht einen bereits gebuchten Transfer, zu dem diese Zeile die Gegenseite ist. """Sucht einen bereits gebuchten Transfer, dessen Gegenseite diese Zeile ist.
Bei gleicher Währung müssen die Beträge übereinstimmen. Bei einem Umtausch Gesucht wird über die Seite des importierten Kontos – die Zeile braucht also
(z. B. Revolut CHF -> EUR) stehen zwei verschiedene Beträge gegenüber; dann selbst keine Transfer-Regel. Genau das ist der Empfangs-Auszug: Im CHF-Auszug
zählen Konto-Paar, Zeitfenster und ein plausibler Betrag.""" entsteht der Transfer, im EUR-Auszug taucht dieselbe Bewegung als Eingang auf.
Bei einem Umtausch stehen zwei Währungen und damit zwei Beträge gegenüber;
dann zählt ein plausibler Betrag statt eines exakten."""
absamt = abs(amount) absamt = abs(amount)
for eintrag in transfer_index.get(frozenset((account_id, other_id)), []): unscharf = None
_txn_id, t_date, amt, to_amt = eintrag for eintrag in side_index.get(account.id, []):
if abs((t_date - d).days) > COUNTERPART_DAYS: txn_id, t_date, erwartet, other_id = eintrag
if txn_id in verbraucht or abs((t_date - d).days) > COUNTERPART_DAYS:
continue continue
if absamt in (amt, to_amt): # Hat eine Regel ein Gegenkonto gesetzt, muss es dazu passen.
if nur_konto is not None and other_id != nur_konto:
continue
if absamt == erwartet:
return eintrag return eintrag
if cross_currency: cross = waehrung.get(other_id, account.currency) != account.currency
# Die geschätzte Gegenseite ist der Vergleichswert. if cross and erwartet and abs(absamt - erwartet) <= erwartet * COUNTERPART_TOLERANCE:
for schaetzung in (to_amt, amt): unscharf = unscharf or eintrag
if schaetzung and abs(absamt - schaetzung) <= schaetzung * COUNTERPART_TOLERANCE: return unscharf
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]:
@@ -367,16 +371,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]] = {} # Je Konto: welcher Betrag dort aus einem bestehenden Transfer erwartet wird.
side_index: dict[int, list[tuple]] = {}
for t_id, 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.id, Transaction.account_id, Transaction.to_account_id, db.query(Transaction.id, Transaction.account_id, Transaction.to_account_id,
Transaction.date, 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( gutschrift = 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) side_index.setdefault(t_acc, []).append((t_id, t_date, t_amt, t_to))
) side_index.setdefault(t_to, []).append((t_id, t_date, gutschrift, t_acc))
verbraucht: set[int] = set()
waehrung_je_konto: dict[int, str] = dict( waehrung_je_konto: dict[int, str] = dict(
db.query(Account.id, Account.currency).filter(Account.owner_id == user.id) db.query(Account.id, Account.currency).filter(Account.owner_id == user.id)
) )
@@ -440,14 +446,20 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
# der tatsächliche Betrag dieser Zeile korrigiert aber die Schätzung der # der tatsächliche Betrag dieser Zeile korrigiert aber die Schätzung der
# anderen Seite (beim Umtausch rechneten wir dort mit dem EZB-Kurs). # anderen Seite (beim Umtausch rechneten wir dort mit dem EZB-Kurs).
gegenbuchung = None gegenbuchung = None
if (ttype == "transfer" and to_account_id is not None if d is not None and amount is not None and not is_dup:
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( gegenbuchung = _find_counterpart(
transfer_index, account.id, to_account_id, d, amount, cross side_index, verbraucht, account, d, amount, waehrung_je_konto, to_account_id
) )
if gegenbuchung is not None:
verbraucht.add(gegenbuchung[0])
ttype = "transfer" # in der Prüfansicht als Transfer zeigen
to_account_id = to_account_id or gegenbuchung[3]
is_counterpart = gegenbuchung is not None is_counterpart = gegenbuchung is not None
# Transfer-Regel ohne erkennbares Gegenkonto: NICHT still als Einnahme/
# Ausgabe buchen, sondern in der Prüfansicht das Konto abfragen.
braucht_ziel = ttype == "transfer" and to_account_id is None
out.append({ out.append({
"index": i, "index": i,
"date": d.isoformat() if d else None, "date": d.isoformat() if d else None,
@@ -472,7 +484,8 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
"is_counterpart": is_counterpart, "is_counterpart": is_counterpart,
# Damit commit_import die geschätzte Gegenseite auf den echten Wert setzt. # Damit commit_import die geschätzte Gegenseite auf den echten Wert setzt.
"counterpart_txn_id": gegenbuchung[0] if gegenbuchung else None, "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 and not braucht_ziel,
"needs_target": braucht_ziel,
}) })
return out return out
+25 -5
View File
@@ -321,9 +321,9 @@ def test_currency_exchange_pairs_across_statements(admin):
chf = _account(admin, name="Rev CHF X", currency="CHF", opening_balance_cents=50000) 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) eur = _account(admin, name="Rev EUR X", currency="EUR", opening_balance_cents=0)
# Regeln: „umgetauscht" ist ein Transfer zum jeweils anderen Konto. # EINE Regel genügt: „Umgetauscht in EUR" -> Gegenkonto ist das EUR-Konto.
admin.post("/api/categories/transfer-keywords", admin.post("/api/categories/transfer-keywords",
json={"word": "umgetauscht", "target_account_id": eur["id"]}) json={"word": "umgetauscht in eur", "target_account_id": eur["id"]})
# 1) CHF-Auszug: Umtausch geht als Transfer CHF -> EUR raus. # 1) CHF-Auszug: Umtausch geht als Transfer CHF -> EUR raus.
rows = _rev_preview(admin, chf["id"], CHF_UMTAUSCH) rows = _rev_preview(admin, chf["id"], CHF_UMTAUSCH)
@@ -333,9 +333,8 @@ def test_currency_exchange_pairs_across_statements(admin):
admin.post("/api/imports/commit", json={"account_id": chf["id"], "filename": "chf.csv", "rows": rows}) 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 assert admin.get(f"/api/accounts/{chf['id']}").json()["balance_cents"] == 40000
# 2) EUR-Auszug: dieselbe Bewegung – trotz anderem Betrag als Gegenbuchung erkannt. # 2) EUR-Auszug: OHNE weitere Regel – die Gegenseite wird über den bereits
admin.post("/api/categories/transfer-keywords", # gebuchten Transfer erkannt, obwohl der Betrag in EUR ein anderer ist.
json={"word": "umgetauscht", "target_account_id": chf["id"]})
rows2 = _rev_preview(admin, eur["id"], EUR_UMTAUSCH) rows2 = _rev_preview(admin, eur["id"], EUR_UMTAUSCH)
assert rows2[0]["type"] == "transfer" assert rows2[0]["type"] == "transfer"
assert rows2[0]["is_counterpart"] is True, "Umtausch-Gegenseite muss erkannt werden" assert rows2[0]["is_counterpart"] is True, "Umtausch-Gegenseite muss erkannt werden"
@@ -348,3 +347,24 @@ def test_currency_exchange_pairs_across_statements(admin):
assert admin.get(f"/api/accounts/{eur['id']}").json()["balance_cents"] == 10642 assert admin.get(f"/api/accounts/{eur['id']}").json()["balance_cents"] == 10642
# CHF-Seite bleibt unverändert. # CHF-Seite bleibt unverändert.
assert admin.get(f"/api/accounts/{chf['id']}").json()["balance_cents"] == 40000 assert admin.get(f"/api/accounts/{chf['id']}").json()["balance_cents"] == 40000
def test_exchange_arrival_without_source_asks_instead_of_booking(admin):
"""Wird der Empfangs-Auszug zuerst eingelesen, ist die Quelle noch unbekannt.
Die Zeile darf dann NICHT still als Einnahme durchgehen, sondern fragt das
Konto ab – sonst zählt die Bewegung später doppelt."""
chf = _account(admin, name="Rev CHF Y", currency="CHF", opening_balance_cents=50000)
eur = _account(admin, name="Rev EUR Y", currency="EUR")
admin.post("/api/categories/transfer-keywords",
json={"word": "umgetauscht in eur", "target_account_id": eur["id"]})
rows = _rev_preview(admin, eur["id"], EUR_UMTAUSCH)
assert rows[0]["type"] == "transfer"
assert rows[0]["to_account_id"] is None
assert rows[0]["needs_target"] is True
assert rows[0]["include"] is False, "ohne Gegenkonto nicht automatisch buchen"
r = admin.post("/api/imports/commit", json={"account_id": eur["id"], "filename": "e.csv", "rows": rows})
assert r.json()["imported_count"] == 0
assert admin.get(f"/api/accounts/{eur['id']}").json()["balance_cents"] == 0
assert chf # Quellkonto bleibt unberührt
+2 -1
View File
@@ -334,7 +334,8 @@
"reconOk": "Saldo-Abgleich stimmt: alle {{n}} Schritte passen zur Kontostand-Spalte der Datei.", "reconOk": "Saldo-Abgleich stimmt: alle {{n}} Schritte passen zur Kontostand-Spalte der Datei.",
"reconDiff": "Saldo-Abgleich weicht ab. Laut Datei: {{file}} · errechnet: {{app}} · Differenz: {{diff}}.", "reconDiff": "Saldo-Abgleich weicht ab. Laut Datei: {{file}} · errechnet: {{app}} · Differenz: {{diff}}.",
"reconFirst": "Erste Abweichung am {{date}} bei „{{payee}}“: Bank {{expected}}, erkannt {{actual}}.", "reconFirst": "Erste Abweichung am {{date}} bei „{{payee}}“: Bank {{expected}}, erkannt {{actual}}.",
"reconOpening": "Anfangssaldo laut Datei: {{value}} (trage ihn beim Konto ein, damit der Stand stimmt)." "reconOpening": "Anfangssaldo laut Datei: {{value}} (trage ihn beim Konto ein, damit der Stand stimmt).",
"needsTarget": "Gegenkonto wählen"
}, },
"password": { "password": {
"show": "Passwort anzeigen", "show": "Passwort anzeigen",
+2 -1
View File
@@ -334,7 +334,8 @@
"reconOk": "Balance check passes: all {{n}} steps match the file's balance column.", "reconOk": "Balance check passes: all {{n}} steps match the file's balance column.",
"reconDiff": "Balance check failed. Per file: {{file}} · computed: {{app}} · difference: {{diff}}.", "reconDiff": "Balance check failed. Per file: {{file}} · computed: {{app}} · difference: {{diff}}.",
"reconFirst": "First mismatch on {{date}} at “{{payee}}”: bank {{expected}}, detected {{actual}}.", "reconFirst": "First mismatch on {{date}} at “{{payee}}”: bank {{expected}}, detected {{actual}}.",
"reconOpening": "Opening balance per file: {{value}} (enter it on the account so the balance matches)." "reconOpening": "Opening balance per file: {{value}} (enter it on the account so the balance matches).",
"needsTarget": "pick account"
}, },
"password": { "password": {
"show": "Show password", "show": "Show password",
+1
View File
@@ -257,6 +257,7 @@ export default function Import() {
{r.payee || r.purpose || ""} {r.payee || r.purpose || ""}
{r.is_duplicate && <span className="badge" style={{ marginLeft: 6 }}>{t("import.duplicate")}</span>} {r.is_duplicate && <span className="badge" style={{ marginLeft: 6 }}>{t("import.duplicate")}</span>}
{r.is_counterpart && <span className="badge" style={{ marginLeft: 6 }}>{t("import.counterpart")}</span>} {r.is_counterpart && <span className="badge" style={{ marginLeft: 6 }}>{t("import.counterpart")}</span>}
{r.needs_target && <span className="badge" style={{ marginLeft: 6 }}>{t("import.needsTarget")}</span>}
{r.skip_reason === "state" && ( {r.skip_reason === "state" && (
<span className="badge" style={{ marginLeft: 6 }}>{r.state || t("import.skipState")}</span> <span className="badge" style={{ marginLeft: 6 }}>{r.state || t("import.skipState")}</span>
)} )}