diff --git a/backend/app/services/csv_import.py b/backend/app/services/csv_import.py index 0e3e42f..85746e5 100644 --- a/backend/app/services/csv_import.py +++ b/backend/app/services/csv_import.py @@ -61,7 +61,8 @@ PRESETS: list[dict] = [ "amount_mode": "signed", # Spalten: 0=Typ, 1=Produkt, 2=Start, 3=Abgeschlossen, 4=Beschreibung, # 5=Betrag, 6=Gebühr, 7=Währung, 8=Status, 9=Saldo. - "column_map": {"date": 3, "amount": 5, "payee": 4, + "column_map": {"date": 3, "amount": 5, "payee": 4, "fee": 6, + "currency": 7, "state": 8, "purpose": None, "counterparty_iban": None}, }, ] @@ -251,12 +252,20 @@ def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict else: amount = parse_amount(cell("amount"), template.decimal_sep) + # Getrennte Gebührenspalte (z. B. Revolut) mindert den Betrag – eine Gebühr + # ist immer ein Kostenposten, unabhängig vom Vorzeichen in der Datei. + fee = parse_amount(cell("fee"), template.decimal_sep) + if amount is not None and fee: + amount -= abs(fee) + out.append({ "date": d.isoformat() if d else None, "amount_cents": amount, "payee": cell("payee") or None, "purpose": cell("purpose") or None, "counterparty_iban": normalize_iban(cell("counterparty_iban")) or None, + "state": (cell("state").strip().upper() or None), + "row_currency": (cell("currency").strip().upper() or None), }) return out @@ -316,7 +325,19 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict iban = cand.get("counterparty_iban") d = date.fromisoformat(d_iso) if d_iso else None - valid = d is not None and amount is not None and amount != 0 + # Nur abgeschlossene Buchungen zählen. Revolut liefert auch stornierte + # (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") + # 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() + + valid = ( + d is not None and amount is not None and amount != 0 + and state_ok and currency_ok + ) ttype = "expense" if (amount or 0) < 0 else "income" category_id: int | None = None matched_rule_id: int | None = None @@ -364,6 +385,12 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict "purpose": purpose or None, "counterparty_iban": iban or None, "valid": valid, + "skip_reason": ( + None if valid or d is None or amount is None + else ("state" if not state_ok else "currency" if not currency_ok else None) + ), + "state": state or None, + "row_currency": row_cur or None, "type": ttype, "category_id": category_id, "matched_rule_id": matched_rule_id, diff --git a/backend/tests/test_import.py b/backend/tests/test_import.py index d685910..fcca782 100644 --- a/backend/tests/test_import.py +++ b/backend/tests/test_import.py @@ -118,3 +118,56 @@ def test_reimport_same_file_is_deduped(admin): 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 diff --git a/web/src/locales/de.json b/web/src/locales/de.json index ed15a9f..eb88af0 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -323,7 +323,13 @@ "undoConfirm": "Alle Buchungen aus {{file}} wieder entfernen?", "undone": "rückgängig gemacht", "needFile": "Bitte zuerst eine CSV-Datei wählen.", - "needFileAccount": "Bitte Konto und Datei wählen." + "needFileAccount": "Bitte Konto und Datei wählen.", + "colFee": "Gebühr", + "colCurrency": "Währung", + "colState": "Status", + "extraColsHint": "Gebühr wird vom Betrag abgezogen. Zeilen mit Status ungleich COMPLETED (z. B. storniert) und Zeilen in einer anderen Währung als das Zielkonto werden nicht gebucht.", + "skipState": "storniert", + "skipCurrency": "andere Währung" }, "password": { "show": "Passwort anzeigen", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 51ce99a..da8c523 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -323,7 +323,13 @@ "undoConfirm": "Remove all transactions from {{file}}?", "undone": "undone", "needFile": "Please choose a CSV file first.", - "needFileAccount": "Please choose an account and a file." + "needFileAccount": "Please choose an account and a file.", + "colFee": "Fee", + "colCurrency": "Currency", + "colState": "State", + "extraColsHint": "The fee is deducted from the amount. Rows whose state is not COMPLETED (e.g. reverted) and rows in a currency other than the target account are not imported.", + "skipState": "reverted", + "skipCurrency": "other currency" }, "password": { "show": "Show password", diff --git a/web/src/pages/Import.jsx b/web/src/pages/Import.jsx index 63f4e10..815fb80 100644 --- a/web/src/pages/Import.jsx +++ b/web/src/pages/Import.jsx @@ -248,6 +248,12 @@ export default function Import() { {r.payee || r.purpose || ""} {r.is_duplicate && {t("import.duplicate")}} {r.is_counterpart && {t("import.counterpart")}} + {r.skip_reason === "state" && ( + {r.state || t("import.skipState")} + )} + {r.skip_reason === "currency" && ( + {r.row_currency || t("import.skipCurrency")} + )} ), }, @@ -484,14 +490,22 @@ export default function Import() { {t("import.moreColumns")}

{t("import.ibanHint")}

- + {[ + ["counterparty_iban", t("import.colIban")], + ["fee", t("import.colFee")], + ["currency", t("import.colCurrency")], + ["state", t("import.colState")], + ].map(([field, label]) => ( + + ))}
+

{t("import.extraColsHint")}