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