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:
Guido
2026-08-21 06:58:33 +02:00
co-authored by Claude Opus 4.8
parent 4520c7318f
commit 5a747fbef4
6 changed files with 175 additions and 7 deletions
+6 -1
View File
@@ -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 ------------------------------------------------------
+66 -2
View File
@@ -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))
+50
View File
@@ -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