diff --git a/backend/app/routers/imports.py b/backend/app/routers/imports.py index 73fe43a..7f3f9c9 100644 --- a/backend/app/routers/imports.py +++ b/backend/app/routers/imports.py @@ -123,7 +123,12 @@ async def preview( amount_mode=AmountMode(amount_mode), column_map=column_map, ) rows = csv_import.build_preview(db, user, account, tpl, data) - return {"rows": rows, "filename": file.filename or ""} + return { + "rows": rows, + "filename": file.filename or "", + # Abgleich gegen die Saldo-Spalte der Datei (falls zugeordnet). + "reconciliation": csv_import.reconcile(rows), + } # --- Verbuchen / Undo ------------------------------------------------------ diff --git a/backend/app/services/csv_import.py b/backend/app/services/csv_import.py index 56903bc..d888609 100644 --- a/backend/app/services/csv_import.py +++ b/backend/app/services/csv_import.py @@ -47,7 +47,7 @@ PRESETS: list[dict] = [ "skip_rows": 0, "amount_mode": "signed", # Spalten: 0=eigene IBAN, 1=Datum, 2=Text, 3=Betrag, 4=Saldo, 5=Valuta. - "column_map": {"date": 1, "amount": 3, "payee": 2, + "column_map": {"date": 1, "amount": 3, "payee": 2, "balance": 4, "purpose": None, "counterparty_iban": None}, }, { @@ -62,7 +62,7 @@ PRESETS: list[dict] = [ # Spalten: 0=Typ, 1=Produkt, 2=Start, 3=Abgeschlossen, 4=Beschreibung, # 5=Betrag, 6=Gebühr, 7=Währung, 8=Status, 9=Saldo. "column_map": {"date": 3, "amount": 5, "payee": 4, "fee": 6, - "currency": 7, "state": 8, + "currency": 7, "state": 8, "balance": 9, "purpose": None, "counterparty_iban": None}, }, ] @@ -286,6 +286,8 @@ def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict "counterparty_iban": normalize_iban(cell("counterparty_iban")) or None, "state": (cell("state").strip().upper() or None), "row_currency": (cell("currency").strip().upper() or None), + # Laufender Kontostand aus der Datei – Grundlage für den Saldo-Abgleich. + "balance_cents": parse_amount(cell("balance"), template.decimal_sep), }) return out @@ -411,6 +413,7 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict ), "state": state or None, "row_currency": row_cur or None, + "balance_cents": cand.get("balance_cents"), "type": ttype, "category_id": category_id, "matched_rule_id": matched_rule_id, @@ -423,6 +426,67 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict return out +def reconcile(rows: list[dict]) -> dict: + """Gleicht die erkannten Beträge gegen die Saldo-Spalte der Datei ab. + + Die Saldo-Spalte ist der Schiedsrichter: Sie sagt für jede Zeile, wie sich der + Kontostand tatsächlich verändert hat. Weicht unser Betrag ab, stimmt die + Spaltenzuordnung oder die Gebühren-/Vorzeichen-Behandlung nicht.""" + # Zeilen anderer Währung gehören zu einem anderen Saldo-Verlauf (Revolut legt + # alle Pockets in eine Datei) – die würden die Kette zerreißen. + seq = [ + r for r in rows + if r.get("balance_cents") is not None and r.get("skip_reason") != "currency" + ] + if len(seq) < 2: + return {"available": False} + + def gebucht(r: dict) -> int: + """Betrag, der tatsächlich ins Konto fließt – 0 für nicht gebuchte Zeilen + (storniert, ungültig). Nur so fällt auf, wenn die Bank etwas verbucht hat, + wir aber nichts (z. B. nicht zugeordnete Gebührenspalte).""" + return (r.get("amount_cents") or 0) if r.get("valid") else 0 + + # Manche Banken exportieren neueste Buchung zuerst – für den Abgleich drehen. + dated = [r["date"] for r in seq if r.get("date")] + if len(dated) >= 2 and dated[0] > dated[-1]: + seq = list(reversed(seq)) + + opening = seq[0]["balance_cents"] - gebucht(seq[0]) + final = seq[-1]["balance_cents"] + computed = opening + sum(gebucht(r) for r in seq) + + first_mismatch = None + mismatches = 0 + prev = seq[0]["balance_cents"] + for r in seq[1:]: + expected = r["balance_cents"] - prev # was die Bank verbucht hat + actual = gebucht(r) # was wir tatsächlich buchen + if expected != actual: + mismatches += 1 + if first_mismatch is None: + first_mismatch = { + "index": r.get("index"), + "date": r.get("date"), + "payee": r.get("payee") or r.get("purpose"), + "expected_cents": expected, + "actual_cents": actual, + } + prev = r["balance_cents"] + + return { + "available": True, + "opening_balance_cents": opening, + "final_balance_cents": final, + "computed_final_cents": computed, + "difference_cents": final - computed, + "checked": len(seq) - 1, + "mismatches": mismatches, + "ok": final == computed and mismatches == 0, + "first_mismatch": first_mismatch, + } + + def build_preview(db: Session, user: User, account: Account, template: ImportTemplate, data: bytes) -> list[dict]: return detect_rows(db, user, account, parse_file_to_candidates(data, template)) diff --git a/backend/tests/test_import.py b/backend/tests/test_import.py index 93600d2..4d6e894 100644 --- a/backend/tests/test_import.py +++ b/backend/tests/test_import.py @@ -218,3 +218,53 @@ def test_revolut_german_export(admin): # 0.23 + 100 + 150 - 100 - 31.90 - 118 + 150 - 50 - 10.99 = 89.34 (= Kontostand-Spalte) bal = admin.get(f"/api/accounts/{acc['id']}").json()["balance_cents"] assert bal == 8934, bal + + +# --- Saldo-Abgleich gegen die Kontostand-Spalte --------------------------- + +def _preview_full(admin, account_id, csv_text, colmap): + return admin.post( + "/api/imports/preview", + files={"file": ("r.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": colmap, + }, + ).json() + + +REVOLUT_MAP_BAL = json.dumps({ + "date": 3, "amount": 5, "payee": 4, "fee": 6, "currency": 7, "state": 8, "balance": 9, +}) + + +def test_reconciliation_ok(admin): + """Stimmiger Auszug: Abgleich meldet ok und den Anfangssaldo aus der Datei.""" + acc = _account(admin, name="Rec OK", currency="CHF") + res = _preview_full(admin, acc["id"], REVOLUT_DE_CSV, REVOLUT_MAP_BAL) + rec = res["reconciliation"] + assert rec["available"] is True + assert rec["ok"] is True, rec + assert rec["mismatches"] == 0 + # Anfangssaldo laut Datei: 100.23 - 100.00 = 0.23 + assert rec["opening_balance_cents"] == 23 + assert rec["final_balance_cents"] == 8934 + assert rec["computed_final_cents"] == 8934 + assert rec["difference_cents"] == 0 + + +def test_reconciliation_detects_wrong_amount(admin): + """Wird die Gebühr NICHT verrechnet, zeigt der Abgleich genau die Zeile.""" + acc = _account(admin, name="Rec Bad", currency="CHF") + ohne_gebuehr = json.dumps({ + "date": 3, "amount": 5, "payee": 4, "currency": 7, "state": 8, "balance": 9, + }) # fee bewusst nicht zugeordnet + res = _preview_full(admin, acc["id"], REVOLUT_DE_CSV, ohne_gebuehr) + rec = res["reconciliation"] + assert rec["ok"] is False + assert rec["difference_cents"] == -1099 # 10.99 fehlen + mm = rec["first_mismatch"] + assert mm["payee"] == "Premium Abo-Gebühr" + assert mm["expected_cents"] == -1099 # laut Kontostand-Spalte + assert mm["actual_cents"] == 0 # ohne Gebührenspalte erkannt diff --git a/web/src/locales/de.json b/web/src/locales/de.json index eb88af0..3b81ba8 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -329,7 +329,12 @@ "colState": "Status", "extraColsHint": "Gebühr wird vom Betrag abgezogen. Zeilen mit Status ungleich COMPLETED (z. B. storniert) und Zeilen in einer anderen Währung als das Zielkonto werden nicht gebucht.", "skipState": "storniert", - "skipCurrency": "andere Währung" + "skipCurrency": "andere Währung", + "colBalance": "Kontostand (laufend)", + "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)." }, "password": { "show": "Passwort anzeigen", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index da8c523..638faca 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -329,7 +329,12 @@ "colState": "State", "extraColsHint": "The fee is deducted from the amount. Rows whose state is not COMPLETED (e.g. reverted) and rows in a currency other than the target account are not imported.", "skipState": "reverted", - "skipCurrency": "other currency" + "skipCurrency": "other currency", + "colBalance": "Balance (running)", + "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)." }, "password": { "show": "Show password", diff --git a/web/src/pages/Import.jsx b/web/src/pages/Import.jsx index 815fb80..a8cfc83 100644 --- a/web/src/pages/Import.jsx +++ b/web/src/pages/Import.jsx @@ -49,6 +49,7 @@ export default function Import() { const [config, setConfig] = useState(defaultConfig()); const [raw, setRaw] = useState(null); const [rows, setRows] = useState(null); + const [recon, setRecon] = useState(null); const [docText, setDocText] = useState(""); const [docPages, setDocPages] = useState([]); const [busy, setBusy] = useState(false); @@ -114,7 +115,7 @@ export default function Import() { setBusy(true); try { setRaw(await api.importRaw(baseForm())); - setRows(null); + setRows(null); setRecon(null); } catch (e) { toast(e.message, "error"); } finally { setBusy(false); } } @@ -130,6 +131,7 @@ export default function Import() { fd.append("column_map", JSON.stringify(config.column_map)); const res = await api.importPreview(fd); setRows(res.rows.map((r) => ({ ...r }))); + setRecon(res.reconciliation || null); } catch (e) { toast(e.message, "error"); } finally { setBusy(false); } } @@ -142,6 +144,7 @@ export default function Import() { fd.append("account_id", accountId); const res = await api.documentPreview(fd); setRows(res.rows.map((r) => ({ ...r }))); + setRecon(null); setDocText(res.text || ""); setDocPages(res.pages || []); } catch (e) { toast(e.message, "error"); } finally { setBusy(false); } @@ -157,7 +160,7 @@ export default function Import() { rows, }); toast(t("import.imported", { n: batch.imported_count })); - setRows(null); setRaw(null); setFile(null); setDocText(""); setDocPages([]); + setRows(null); setRecon(null); setRaw(null); setFile(null); setDocText(""); setDocPages([]); if (fileRef.current) fileRef.current.value = ""; loadStamm(); } catch (e) { toast(e.message, "error"); } finally { setBusy(false); } @@ -495,6 +498,7 @@ export default function Import() { ["fee", t("import.colFee")], ["currency", t("import.colCurrency")], ["state", t("import.colState")], + ["balance", t("import.colBalance")], ].map(([field, label]) => (