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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user