feat: Saldo-Abgleich beim CSV-Import gegen die Kontostand-Spalte
Bisher merkte man einen Importfehler erst am falschen Endsaldo, ohne Hinweis auf die Ursache. Enthält der Auszug eine laufende Kontostand-Spalte, gleicht der Import jetzt jede Zeile dagegen ab: Die Spalte sagt, wie sich der Saldo tatsächlich verändert hat, unsere Erkennung muss dazu passen. - neue Spaltenzuordnung "Kontostand (laufend)"; Presets Raiffeisen/Revolut ergänzt - Prüfansicht zeigt: stimmt überein ODER Differenz + erste abweichende Zeile (Datum, Empfänger, Betrag laut Bank vs. erkannt) - meldet zusätzlich den Anfangssaldo laut Datei – häufigste Fehlerquelle - rechnet mit dem tatsächlich gebuchten Betrag, damit auch nicht gebuchte Zeilen auffallen (z. B. nicht zugeordnete Gebührenspalte); Fremdwährungs-Zeilen gehören zu einem anderen Saldo-Verlauf und bleiben außen vor - erkennt absteigend sortierte Auszüge - Tests: stimmiger Auszug (ok) und fehlende Gebührenspalte (zeigt genau die Zeile). 83 grün. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -123,7 +123,12 @@ async def preview(
|
|||||||
amount_mode=AmountMode(amount_mode), column_map=column_map,
|
amount_mode=AmountMode(amount_mode), column_map=column_map,
|
||||||
)
|
)
|
||||||
rows = csv_import.build_preview(db, user, account, tpl, data)
|
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 ------------------------------------------------------
|
# --- Verbuchen / Undo ------------------------------------------------------
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ PRESETS: list[dict] = [
|
|||||||
"skip_rows": 0,
|
"skip_rows": 0,
|
||||||
"amount_mode": "signed",
|
"amount_mode": "signed",
|
||||||
# Spalten: 0=eigene IBAN, 1=Datum, 2=Text, 3=Betrag, 4=Saldo, 5=Valuta.
|
# 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},
|
"purpose": None, "counterparty_iban": None},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -62,7 +62,7 @@ PRESETS: list[dict] = [
|
|||||||
# Spalten: 0=Typ, 1=Produkt, 2=Start, 3=Abgeschlossen, 4=Beschreibung,
|
# Spalten: 0=Typ, 1=Produkt, 2=Start, 3=Abgeschlossen, 4=Beschreibung,
|
||||||
# 5=Betrag, 6=Gebühr, 7=Währung, 8=Status, 9=Saldo.
|
# 5=Betrag, 6=Gebühr, 7=Währung, 8=Status, 9=Saldo.
|
||||||
"column_map": {"date": 3, "amount": 5, "payee": 4, "fee": 6,
|
"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},
|
"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,
|
"counterparty_iban": normalize_iban(cell("counterparty_iban")) or None,
|
||||||
"state": (cell("state").strip().upper() or None),
|
"state": (cell("state").strip().upper() or None),
|
||||||
"row_currency": (cell("currency").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
|
return out
|
||||||
|
|
||||||
@@ -411,6 +413,7 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
|
|||||||
),
|
),
|
||||||
"state": state or None,
|
"state": state or None,
|
||||||
"row_currency": row_cur or None,
|
"row_currency": row_cur or None,
|
||||||
|
"balance_cents": cand.get("balance_cents"),
|
||||||
"type": ttype,
|
"type": ttype,
|
||||||
"category_id": category_id,
|
"category_id": category_id,
|
||||||
"matched_rule_id": matched_rule_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
|
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]:
|
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))
|
return detect_rows(db, user, account, parse_file_to_candidates(data, template))
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
# 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"]
|
bal = admin.get(f"/api/accounts/{acc['id']}").json()["balance_cents"]
|
||||||
assert bal == 8934, bal
|
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
|
||||||
|
|||||||
@@ -329,7 +329,12 @@
|
|||||||
"colState": "Status",
|
"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.",
|
"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",
|
"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": {
|
"password": {
|
||||||
"show": "Passwort anzeigen",
|
"show": "Passwort anzeigen",
|
||||||
|
|||||||
@@ -329,7 +329,12 @@
|
|||||||
"colState": "State",
|
"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.",
|
"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",
|
"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": {
|
"password": {
|
||||||
"show": "Show password",
|
"show": "Show password",
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export default function Import() {
|
|||||||
const [config, setConfig] = useState(defaultConfig());
|
const [config, setConfig] = useState(defaultConfig());
|
||||||
const [raw, setRaw] = useState(null);
|
const [raw, setRaw] = useState(null);
|
||||||
const [rows, setRows] = useState(null);
|
const [rows, setRows] = useState(null);
|
||||||
|
const [recon, setRecon] = useState(null);
|
||||||
const [docText, setDocText] = useState("");
|
const [docText, setDocText] = useState("");
|
||||||
const [docPages, setDocPages] = useState([]);
|
const [docPages, setDocPages] = useState([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -114,7 +115,7 @@ export default function Import() {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
setRaw(await api.importRaw(baseForm()));
|
setRaw(await api.importRaw(baseForm()));
|
||||||
setRows(null);
|
setRows(null); setRecon(null);
|
||||||
} catch (e) { toast(e.message, "error"); } finally { setBusy(false); }
|
} 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));
|
fd.append("column_map", JSON.stringify(config.column_map));
|
||||||
const res = await api.importPreview(fd);
|
const res = await api.importPreview(fd);
|
||||||
setRows(res.rows.map((r) => ({ ...r })));
|
setRows(res.rows.map((r) => ({ ...r })));
|
||||||
|
setRecon(res.reconciliation || null);
|
||||||
} catch (e) { toast(e.message, "error"); } finally { setBusy(false); }
|
} catch (e) { toast(e.message, "error"); } finally { setBusy(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +144,7 @@ export default function Import() {
|
|||||||
fd.append("account_id", accountId);
|
fd.append("account_id", accountId);
|
||||||
const res = await api.documentPreview(fd);
|
const res = await api.documentPreview(fd);
|
||||||
setRows(res.rows.map((r) => ({ ...r })));
|
setRows(res.rows.map((r) => ({ ...r })));
|
||||||
|
setRecon(null);
|
||||||
setDocText(res.text || "");
|
setDocText(res.text || "");
|
||||||
setDocPages(res.pages || []);
|
setDocPages(res.pages || []);
|
||||||
} catch (e) { toast(e.message, "error"); } finally { setBusy(false); }
|
} catch (e) { toast(e.message, "error"); } finally { setBusy(false); }
|
||||||
@@ -157,7 +160,7 @@ export default function Import() {
|
|||||||
rows,
|
rows,
|
||||||
});
|
});
|
||||||
toast(t("import.imported", { n: batch.imported_count }));
|
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 = "";
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
loadStamm();
|
loadStamm();
|
||||||
} catch (e) { toast(e.message, "error"); } finally { setBusy(false); }
|
} catch (e) { toast(e.message, "error"); } finally { setBusy(false); }
|
||||||
@@ -495,6 +498,7 @@ export default function Import() {
|
|||||||
["fee", t("import.colFee")],
|
["fee", t("import.colFee")],
|
||||||
["currency", t("import.colCurrency")],
|
["currency", t("import.colCurrency")],
|
||||||
["state", t("import.colState")],
|
["state", t("import.colState")],
|
||||||
|
["balance", t("import.colBalance")],
|
||||||
].map(([field, label]) => (
|
].map(([field, label]) => (
|
||||||
<label key={field}>
|
<label key={field}>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
@@ -537,6 +541,41 @@ export default function Import() {
|
|||||||
<span>{t("import.noneValid")}</span>
|
<span>{t("import.noneValid")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{recon?.available && (
|
||||||
|
<div className={`alert ${recon.ok ? "ok" : "error"}`} style={{ marginBottom: "var(--sp-3)" }}>
|
||||||
|
<Icon name={recon.ok ? "check" : "alert"} size={16} />
|
||||||
|
<div>
|
||||||
|
{recon.ok ? (
|
||||||
|
<span>{t("import.reconOk", { n: recon.checked })}</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
{t("import.reconDiff", {
|
||||||
|
file: formatMoney(recon.final_balance_cents, acc?.currency || "EUR"),
|
||||||
|
app: formatMoney(recon.computed_final_cents, acc?.currency || "EUR"),
|
||||||
|
diff: formatMoney(recon.difference_cents, acc?.currency || "EUR"),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{recon.first_mismatch && (
|
||||||
|
<div className="muted small">
|
||||||
|
{t("import.reconFirst", {
|
||||||
|
date: recon.first_mismatch.date ? formatDate(recon.first_mismatch.date) : "?",
|
||||||
|
payee: recon.first_mismatch.payee || "—",
|
||||||
|
expected: formatMoney(recon.first_mismatch.expected_cents, acc?.currency || "EUR"),
|
||||||
|
actual: formatMoney(recon.first_mismatch.actual_cents, acc?.currency || "EUR"),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="muted small">
|
||||||
|
{t("import.reconOpening", {
|
||||||
|
value: formatMoney(recon.opening_balance_cents, acc?.currency || "EUR"),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<DataTable
|
<DataTable
|
||||||
id="import-review"
|
id="import-review"
|
||||||
rows={rows}
|
rows={rows}
|
||||||
|
|||||||
Reference in New Issue
Block a user