Files
certain/backend/tests/test_import.py
T
GuidoandClaude Opus 4.8 d725f5cf57 fix: Revolut-Import wertet Gebühr, Status und Währung aus
Das Revolut-Preset kannte die Spalten Gebühr/Währung/Status laut Kommentar,
der Parser hat sie aber nie gelesen. Folgen: Gebühren fehlten im Saldo,
stornierte (REVERTED) und schwebende Zeilen wurden mitgebucht, und Zeilen
fremder Währungs-Pockets landeten 1:1 im Konto – der Saldo lief dadurch zu hoch.

- Gebührenspalte mindert den Betrag (immer als Kosten)
- nur Zeilen mit Status COMPLETED (oder ohne Statusspalte) sind buchbar
- Zeilen in anderer Währung als das Zielkonto werden nicht gebucht
- Grund wird in der Prüfansicht als Badge angezeigt (storniert / Währung)
- Gebühr/Währung/Status in der Spaltenzuordnung wählbar; Revolut-Preset ergänzt
- Test mit echtem Revolut-Format (Gebühr, Storno, Fremdwährung), 80 grün

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 06:30:22 +02:00

174 lines
6.8 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