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