Files
certain/backend/tests/test_import.py
T
GuidoandClaude Opus 4.8 5a747fbef4 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>
2026-08-21 06:58:33 +02:00

271 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import io
import json
def _account(admin, **kw):
body = {"name": "Giro", "type": "girokonto", "currency": "EUR", "opening_balance_cents": 0}
body.update(kw)
r = admin.post("/api/accounts", json=body)
assert r.status_code == 201, r.text
return r.json()
def _leb(admin):
for g in admin.get("/api/categories").json():
for c in g["categories"]:
if c["name"] == "Lebensmittel":
return c["id"]
raise AssertionError("Lebensmittel fehlt")
CSV = (
"Datum;Betrag;Empfaenger;Zweck;GegenIBAN\n"
"15.03.2026;-50,00;REWE;Einkauf;\n"
"25.03.2026;3.000,00;Arbeitgeber;Gehalt;\n"
"15.03.2026;-50,00;REWE;Einkauf;\n" # gleich aussehend, aber echte 2. Buchung
"20.03.2026;-200,00;Sparen;Ueberweisung;CH9300762011623852957\n" # interner Transfer
)
COLMAP = json.dumps({"date": 0, "amount": 1, "payee": 2, "purpose": 3, "counterparty_iban": 4})
def _preview(admin, account_id):
return admin.post(
"/api/imports/preview",
files={"file": ("konto.csv", io.BytesIO(CSV.encode("utf-8")), "text/csv")},
data={
"account_id": str(account_id),
"delimiter": ";", "decimal_sep": ",", "date_format": "%d.%m.%Y",
"encoding": "utf-8", "has_header": "true", "skip_rows": "0",
"amount_mode": "signed", "column_map": COLMAP,
},
)
def test_preview_parsing_and_detection(admin):
giro = _account(admin, name="Giro Import")
# Zweites Konto mit passender IBAN für die Transfer-Erkennung.
spar = _account(admin, name="Spar", type="sparkonto", currency="EUR",
identifiers=[{"kind": "iban", "value": "CH93 0076 2011 6238 5295 7"}])
r = _preview(admin, giro["id"])
assert r.status_code == 200, r.text
rows = r.json()["rows"]
assert len(rows) == 4
# Zeile 0: Ausgabe 50 EUR
assert rows[0]["type"] == "expense"
assert rows[0]["amount_cents"] == -5000
assert rows[0]["is_duplicate"] is False
# Zeile 1: Einnahme 3000 (Tausenderpunkt korrekt)
assert rows[1]["type"] == "income"
assert rows[1]["amount_cents"] == 300000
# Zeile 2: gleich aussehend wie Zeile 0, aber im selben Auszug KEIN Duplikat.
assert rows[2]["is_duplicate"] is False
assert rows[2]["include"] is True
# Zeile 3: interner Transfer (IBAN passt zu 'spar')
assert rows[3]["type"] == "transfer"
assert rows[3]["to_account_id"] == spar["id"]
def test_commit_and_undo_and_learning(admin):
giro = _account(admin, name="Giro C")
leb = _leb(admin)
rows = _preview(admin, giro["id"]).json()["rows"]
# Nutzer kategorisiert die erste (REWE) als Lebensmittel -> soll gelernt werden.
for row in rows:
if row["payee"] == "REWE" and not row["is_duplicate"]:
row["category_id"] = leb
r = admin.post("/api/imports/commit", json={
"account_id": giro["id"], "filename": "konto.csv", "rows": rows,
})
assert r.status_code == 200, r.text
batch = r.json()
# Beide REWE + Einnahme + Transfer = 4 gebucht (nichts fälschlich als Duplikat verworfen).
assert batch["imported_count"] == 4
# Saldo: 0 + 3000 - 50 - 50 - 200(Transfer raus) = 2700,00
bal = admin.get(f"/api/accounts/{giro['id']}").json()["balance_cents"]
assert bal == 270000, bal
# Regel gelernt: erneute Vorschau schlägt für REWE Lebensmittel vor.
rows2 = _preview(admin, giro["id"]).json()["rows"]
rewe = next(r for r in rows2 if r["payee"] == "REWE")
assert rewe["category_id"] == leb
# Undo entfernt die Buchungen dieses Batches wieder.
r = admin.delete(f"/api/imports/batches/{batch['id']}")
assert r.status_code == 200
assert r.json()["deleted"] == 4
bal2 = admin.get(f"/api/accounts/{giro['id']}").json()["balance_cents"]
assert bal2 == 0, bal2
def test_reimport_same_file_is_deduped(admin):
"""Erneuter Import derselben Datei erkennt ALLE Zeilen als Duplikat –
auch die zwei gleich aussehenden REWE-Buchungen (Multiset-Zählung)."""
giro = _account(admin, name="Giro Re")
_account(admin, name="Spar Re", type="sparkonto", currency="EUR",
identifiers=[{"kind": "iban", "value": "CH93 0076 2011 6238 5295 7"}])
rows = _preview(admin, giro["id"]).json()["rows"]
r = admin.post("/api/imports/commit", json={"account_id": giro["id"], "filename": "k.csv", "rows": rows})
assert r.json()["imported_count"] == 4
# Zweiter Vorschau-Lauf derselben Datei: nichts mehr buchbar.
again = _preview(admin, giro["id"]).json()["rows"]
assert all(r["is_duplicate"] for r in again)
assert sum(1 for r in again if r["include"]) == 0
# --- Revolut-Besonderheiten: Gebühr, Storno, Fremdwährung ------------------
REVOLUT_CSV = (
"Type,Product,Started Date,Completed Date,Description,Amount,Fee,Currency,State,Balance\n"
"TOPUP,Current,2026-01-06 10:00:00,2026-01-06 10:00:00,Von Raiffeisen,200.00,0.00,CHF,COMPLETED,300.23\n"
"CARD_PAYMENT,Current,2026-01-07 10:00:00,2026-01-07 10:00:00,Coop,-20.00,0.50,CHF,COMPLETED,279.73\n"
"CARD_PAYMENT,Current,2026-01-08 10:00:00,2026-01-08 10:00:00,Storno Laden,-15.00,0.00,CHF,REVERTED,279.73\n"
"CARD_PAYMENT,Current,2026-01-09 10:00:00,2026-01-09 10:00:00,Amazon DE,-10.00,0.00,EUR,COMPLETED,90.00\n"
)
REVOLUT_MAP = json.dumps({
"date": 3, "amount": 5, "payee": 4, "fee": 6, "currency": 7, "state": 8,
})
def _preview_revolut(admin, account_id):
return admin.post(
"/api/imports/preview",
files={"file": ("revolut.csv", io.BytesIO(REVOLUT_CSV.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": REVOLUT_MAP,
},
).json()["rows"]
def test_revolut_fee_state_and_currency(admin):
acc = _account(admin, name="Revolut CHF", currency="CHF", opening_balance_cents=10023)
rows = _preview_revolut(admin, acc["id"])
assert len(rows) == 4
# Aufladung ohne Gebühr.
assert rows[0]["amount_cents"] == 20000
# Gebühr 0.50 mindert die Ausgabe: -20.00 - 0.50 = -20.50
assert rows[1]["amount_cents"] == -2050
# Stornierte Buchung zählt nicht.
assert rows[2]["valid"] is False and rows[2]["include"] is False
assert rows[2]["skip_reason"] == "state"
# Fremdwährungs-Zeile gehört nicht in das CHF-Konto.
assert rows[3]["valid"] is False and rows[3]["include"] is False
assert rows[3]["skip_reason"] == "currency"
r = admin.post("/api/imports/commit", json={
"account_id": acc["id"], "filename": "revolut.csv", "rows": rows,
})
assert r.json()["imported_count"] == 2
# 100.23 + 200.00 - 20.50 = 279.73 (= Balance-Spalte der Bank)
bal = admin.get(f"/api/accounts/{acc['id']}").json()["balance_cents"]
assert bal == 27973, bal
# Deutscher Revolut-Export: Status heißt „ABGESCHLOSSEN", Gebühren stehen in
# einer eigenen Spalte (bei Abo-Gebühren ist der Betrag sogar 0.00).
REVOLUT_DE_CSV = (
"Art,Produkt,Datum des Beginns,Datum des Abschlusses,Beschreibung,Betrag,Gebühr,Währung,Status,Kontostand\n"
"Einzahlung,Giro,2026-01-06 23:04:52,2026-01-06 23:05:01,Einzahlung von *5264,100.00,0.00,CHF,ABGESCHLOSSEN,100.23\n"
"Einzahlung,Giro,2026-01-06 23:08:15,2026-01-06 23:08:36,Einzahlung von *5264,150.00,0.00,CHF,ABGESCHLOSSEN,250.23\n"
"Umtausch,Giro,2026-01-06 23:08:58,2026-01-06 23:08:58,Revolut Ltd,-100.00,0.00,CHF,ABGESCHLOSSEN,150.23\n"
"Transfer,Giro,2026-01-06 23:09:35,2026-01-06 23:09:35,Auf Anlagekonto,-31.90,0.00,CHF,ABGESCHLOSSEN,118.33\n"
"Transfer,Giro,2026-01-06 23:10:22,2026-01-06 23:10:22,Auf Anlagekonto,-118.00,0.00,CHF,ABGESCHLOSSEN,0.33\n"
"Einzahlung,Giro,2026-01-08 15:52:44,2026-01-08 15:53:25,Einzahlung von *4469,150.00,0.00,CHF,ABGESCHLOSSEN,150.33\n"
"Transfer,Giro,2026-01-09 08:34:11,2026-01-09 08:34:12,Auf Anlagekonto,-50.00,0.00,CHF,ABGESCHLOSSEN,100.33\n"
"Belastung,Giro,2026-01-11 03:17:01,2026-01-11 03:17:01,Premium Abo-Gebühr,0.00,10.99,CHF,ABGESCHLOSSEN,89.34\n"
"Kartenzahlung,Giro,2026-01-12 10:00:00,2026-01-12 10:00:00,Storno Laden,-25.00,0.00,CHF,STORNIERT,89.34\n"
)
def test_revolut_german_export(admin):
"""Deutscher Export: ABGESCHLOSSEN ist buchbar, STORNIERT nicht, und die
Gebührenspalte wird auch dann verrechnet, wenn der Betrag 0.00 ist."""
acc = _account(admin, name="Revolut DE", currency="CHF", opening_balance_cents=23)
rows = admin.post(
"/api/imports/preview",
files={"file": ("r.csv", io.BytesIO(REVOLUT_DE_CSV.encode("utf-8")), "text/csv")},
data={
"account_id": str(acc["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": REVOLUT_MAP,
},
).json()["rows"]
assert len(rows) == 9
# ABGESCHLOSSEN wird gebucht (früher scheiterte das an der COMPLETED-Prüfung).
assert [r["include"] for r in rows[:8]] == [True] * 8
# Reine Gebührenzeile: Betrag 0.00, Gebühr 10.99 -> -10.99
assert rows[7]["amount_cents"] == -1099
# STORNIERT bleibt außen vor.
assert rows[8]["include"] is False and rows[8]["skip_reason"] == "state"
r = admin.post("/api/imports/commit", json={
"account_id": acc["id"], "filename": "r.csv", "rows": rows,
})
assert r.json()["imported_count"] == 8
# 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