Der Revolut-Export führt "2026-01-06 23:11:14" in der Datumsspalte. Wählte man
im Dropdown JJJJ-MM-TT, scheiterte das Parsen an der Uhrzeit – jede Zeile hatte
kein Datum und galt als ungültig ("137 Zeilen erkannt, 0 werden gebucht"),
obwohl die Auswahl richtig aussah.
- parse_date schneidet eine überzählige Uhrzeit ab und kommt auch damit klar,
wenn das Format eine Uhrzeit erwartet, der Wert aber keine hat (ISO-T ebenso)
- Dropdown um die Varianten mit Uhrzeit ergänzt, damit die Bank-Vorlagen ihren
echten Eintrag zeigen statt eines leeren Feldes
- Test: reines Datumsformat auf Datei mit Uhrzeit -> alle Zeilen erkannt, 84 grün
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
292 lines
13 KiB
Python
292 lines
13 KiB
Python
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
|
||
|
||
|
||
def test_date_format_tolerates_time(admin):
|
||
"""Wählt man ein reines Datumsformat, obwohl die Spalte eine Uhrzeit enthält,
|
||
werden die Zeilen trotzdem erkannt (früher: alles ungültig)."""
|
||
acc = _account(admin, name="Datum Zeit", currency="CHF")
|
||
nur_datum = json.dumps({
|
||
"date": 3, "amount": 5, "payee": 4, "fee": 6, "currency": 7, "state": 8, "balance": 9,
|
||
})
|
||
res = 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", # ohne Uhrzeit, Datei hat aber welche
|
||
"encoding": "utf-8", "has_header": "true", "skip_rows": "0",
|
||
"amount_mode": "signed", "column_map": nur_datum,
|
||
},
|
||
).json()
|
||
assert all(r["date"] for r in res["rows"]), "Datum trotz Uhrzeit erkannt"
|
||
assert res["reconciliation"]["ok"] is True
|