From 4520c7318f1e4ed88cff5e15ae59f619b0a1c0d7 Mon Sep 17 00:00:00 2001 From: Guido Date: Fri, 21 Aug 2026 06:41:32 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Status-Pr=C3=BCfung=20beim=20Import=20is?= =?UTF-8?q?t=20sprachunabh=C3=A4ngig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/services/csv_import.py | 22 +++++++++++++- backend/tests/test_import.py | 47 ++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/backend/app/services/csv_import.py b/backend/app/services/csv_import.py index 85746e5..56903bc 100644 --- a/backend/app/services/csv_import.py +++ b/backend/app/services/csv_import.py @@ -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() diff --git a/backend/tests/test_import.py b/backend/tests/test_import.py index fcca782..93600d2 100644 --- a/backend/tests/test_import.py +++ b/backend/tests/test_import.py @@ -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