fix: Status-Prüfung beim Import ist sprachunabhängig

Die neue Statusprüfung ließ nur „COMPLETED" durch. Deutsche Bank-Exporte
schreiben aber „ABGESCHLOSSEN", wodurch keine einzige Zeile mehr buchbar war.

Statt einer Positivliste jetzt eine Sperrliste (storniert/ausstehend/abgelehnt/
fehlgeschlagen, jeweils deutsch und englisch); unbekannte Status werden gebucht,
damit keine echten Buchungen still verloren gehen.

Test mit echtem deutschem Revolut-Export inkl. reiner Gebührenzeile
(Betrag 0.00, Gebühr 10.99): Endsaldo trifft die Kontostand-Spalte. 81 grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-21 06:41:32 +02:00
co-authored by Claude Opus 4.8
parent d725f5cf57
commit 4520c7318f
2 changed files with 68 additions and 1 deletions
+21 -1
View File
@@ -211,6 +211,26 @@ def _guess_target_account(text_idents: list[tuple[str, int]], payee: str, purpos
return best[1] if best else None
# Status-Werte, die eine Buchung NICHT wirksam werden lassen. Bewusst als
# Sperrliste (nicht Positivliste): Bank-Exporte sind lokalisiert – „COMPLETED"
# heißt auf Deutsch „ABGESCHLOSSEN". Unbekannte Werte werden gebucht, damit
# keine echten Buchungen still verloren gehen.
UNBOOKABLE_STATES = (
"PENDING", "AUSSTEHEND", "SCHWEBEND",
"REVERTED", "RUECKGAENGIG", "RÜCKGÄNGIG", "STORNIERT", "STORNO",
"DECLINED", "ABGELEHNT",
"FAILED", "FEHLGESCHLAGEN",
"CANCELLED", "CANCELED", "ABGEBROCHEN",
)
def state_is_bookable(state: str | None) -> bool:
s = (state or "").strip().upper()
if not s:
return True
return not any(bad in s for bad in UNBOOKABLE_STATES)
# Fenster, in dem eine Buchung als Gegenseite eines bestehenden Transfers gilt
# (Buchungs-/Valutadatum können ein paar Tage auseinanderliegen).
COUNTERPART_DAYS = 5
@@ -329,7 +349,7 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
# (REVERTED) und schwebende (PENDING) Zeilen mit – die dürfen den Saldo
# nicht verändern.
state = (cand.get("state") or "").upper()
state_ok = state in ("", "COMPLETED")
state_ok = state_is_bookable(state)
# Zeile in fremder Währung (Revolut-Export enthält alle Währungs-Pockets).
row_cur = (cand.get("row_currency") or "").upper()
currency_ok = not row_cur or row_cur == account.currency.upper()
+47
View File
@@ -171,3 +171,50 @@ def test_revolut_fee_state_and_currency(admin):
# 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