diff --git a/backend/app/services/csv_import.py b/backend/app/services/csv_import.py index 8cd6bc8..b040557 100644 --- a/backend/app/services/csv_import.py +++ b/backend/app/services/csv_import.py @@ -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 diff --git a/backend/tests/test_import.py b/backend/tests/test_import.py index 6bff5ed..20a9e30 100644 --- a/backend/tests/test_import.py +++ b/backend/tests/test_import.py @@ -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) 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", - 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. 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}) 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"]}) + # 2) EUR-Auszug: OHNE weitere Regel – die Gegenseite wird über den bereits + # gebuchten Transfer erkannt, obwohl der Betrag in EUR ein anderer ist. 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" @@ -348,3 +347,24 @@ def test_currency_exchange_pairs_across_statements(admin): 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 + + +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 diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 3b81ba8..1494ab9 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -334,7 +334,8 @@ "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}}.", "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": { "show": "Passwort anzeigen", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 638faca..75897c7 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -334,7 +334,8 @@ "reconOk": "Balance check passes: all {{n}} steps match the file's balance column.", "reconDiff": "Balance check failed. Per file: {{file}} · computed: {{app}} · difference: {{diff}}.", "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": { "show": "Show password", diff --git a/web/src/pages/Import.jsx b/web/src/pages/Import.jsx index a8ff8c7..21a8db2 100644 --- a/web/src/pages/Import.jsx +++ b/web/src/pages/Import.jsx @@ -257,6 +257,7 @@ export default function Import() { {r.payee || r.purpose || ""} {r.is_duplicate && {t("import.duplicate")}} {r.is_counterpart && {t("import.counterpart")}} + {r.needs_target && {t("import.needsTarget")}} {r.skip_reason === "state" && ( {r.state || t("import.skipState")} )}