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(
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.
side_index: dict, verbraucht: set, account: Account, d: date, amount: int,
waehrung: dict, nur_konto: int | None,
):
"""Sucht einen bereits gebuchten Transfer, dessen Gegenseite diese Zeile 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."""
Gesucht wird über die Seite des importierten Kontos – die Zeile braucht also
selbst keine Transfer-Regel. Genau das ist der Empfangs-Auszug: Im CHF-Auszug
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)
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:
unscharf = None
for eintrag in side_index.get(account.id, []):
txn_id, t_date, erwartet, other_id = eintrag
if txn_id in verbraucht or abs((t_date - d).days) > COUNTERPART_DAYS:
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
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
cross = waehrung.get(other_id, account.currency) != account.currency
if cross and erwartet and abs(absamt - erwartet) <= erwartet * COUNTERPART_TOLERANCE:
unscharf = unscharf or eintrag
return unscharf
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
# 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 (
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_id, t_date, t_amt, t_to_amt if t_to_amt is not None else t_amt)
)
gutschrift = 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(
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
# 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
if d is not None and amount is not None and not is_dup:
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
# 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({
"index": i,
"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,
# 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 and not braucht_ziel,
"needs_target": braucht_ziel,
})
return out