fix: Revolut-Import wertet Gebühr, Status und Währung aus
Das Revolut-Preset kannte die Spalten Gebühr/Währung/Status laut Kommentar, der Parser hat sie aber nie gelesen. Folgen: Gebühren fehlten im Saldo, stornierte (REVERTED) und schwebende Zeilen wurden mitgebucht, und Zeilen fremder Währungs-Pockets landeten 1:1 im Konto – der Saldo lief dadurch zu hoch. - Gebührenspalte mindert den Betrag (immer als Kosten) - nur Zeilen mit Status COMPLETED (oder ohne Statusspalte) sind buchbar - Zeilen in anderer Währung als das Zielkonto werden nicht gebucht - Grund wird in der Prüfansicht als Badge angezeigt (storniert / Währung) - Gebühr/Währung/Status in der Spaltenzuordnung wählbar; Revolut-Preset ergänzt - Test mit echtem Revolut-Format (Gebühr, Storno, Fremdwährung), 80 grün Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -61,7 +61,8 @@ PRESETS: list[dict] = [
|
|||||||
"amount_mode": "signed",
|
"amount_mode": "signed",
|
||||||
# Spalten: 0=Typ, 1=Produkt, 2=Start, 3=Abgeschlossen, 4=Beschreibung,
|
# Spalten: 0=Typ, 1=Produkt, 2=Start, 3=Abgeschlossen, 4=Beschreibung,
|
||||||
# 5=Betrag, 6=Gebühr, 7=Währung, 8=Status, 9=Saldo.
|
# 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},
|
"purpose": None, "counterparty_iban": None},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -251,12 +252,20 @@ def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict
|
|||||||
else:
|
else:
|
||||||
amount = parse_amount(cell("amount"), template.decimal_sep)
|
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({
|
out.append({
|
||||||
"date": d.isoformat() if d else None,
|
"date": d.isoformat() if d else None,
|
||||||
"amount_cents": amount,
|
"amount_cents": amount,
|
||||||
"payee": cell("payee") or None,
|
"payee": cell("payee") or None,
|
||||||
"purpose": cell("purpose") or None,
|
"purpose": cell("purpose") or None,
|
||||||
"counterparty_iban": normalize_iban(cell("counterparty_iban")) 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
|
return out
|
||||||
|
|
||||||
@@ -316,7 +325,19 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
|
|||||||
iban = cand.get("counterparty_iban")
|
iban = cand.get("counterparty_iban")
|
||||||
d = date.fromisoformat(d_iso) if d_iso else None
|
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"
|
ttype = "expense" if (amount or 0) < 0 else "income"
|
||||||
category_id: int | None = None
|
category_id: int | None = None
|
||||||
matched_rule_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,
|
"purpose": purpose or None,
|
||||||
"counterparty_iban": iban or None,
|
"counterparty_iban": iban or None,
|
||||||
"valid": valid,
|
"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,
|
"type": ttype,
|
||||||
"category_id": category_id,
|
"category_id": category_id,
|
||||||
"matched_rule_id": matched_rule_id,
|
"matched_rule_id": matched_rule_id,
|
||||||
|
|||||||
@@ -118,3 +118,56 @@ def test_reimport_same_file_is_deduped(admin):
|
|||||||
again = _preview(admin, giro["id"]).json()["rows"]
|
again = _preview(admin, giro["id"]).json()["rows"]
|
||||||
assert all(r["is_duplicate"] for r in again)
|
assert all(r["is_duplicate"] for r in again)
|
||||||
assert sum(1 for r in again if r["include"]) == 0
|
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
|
||||||
|
|||||||
@@ -323,7 +323,13 @@
|
|||||||
"undoConfirm": "Alle Buchungen aus {{file}} wieder entfernen?",
|
"undoConfirm": "Alle Buchungen aus {{file}} wieder entfernen?",
|
||||||
"undone": "rückgängig gemacht",
|
"undone": "rückgängig gemacht",
|
||||||
"needFile": "Bitte zuerst eine CSV-Datei wählen.",
|
"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": {
|
"password": {
|
||||||
"show": "Passwort anzeigen",
|
"show": "Passwort anzeigen",
|
||||||
|
|||||||
@@ -323,7 +323,13 @@
|
|||||||
"undoConfirm": "Remove all transactions from {{file}}?",
|
"undoConfirm": "Remove all transactions from {{file}}?",
|
||||||
"undone": "undone",
|
"undone": "undone",
|
||||||
"needFile": "Please choose a CSV file first.",
|
"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": {
|
"password": {
|
||||||
"show": "Show password",
|
"show": "Show password",
|
||||||
|
|||||||
@@ -248,6 +248,12 @@ export default function Import() {
|
|||||||
{r.payee || r.purpose || ""}
|
{r.payee || r.purpose || ""}
|
||||||
{r.is_duplicate && <span className="badge" style={{ marginLeft: 6 }}>{t("import.duplicate")}</span>}
|
{r.is_duplicate && <span className="badge" style={{ marginLeft: 6 }}>{t("import.duplicate")}</span>}
|
||||||
{r.is_counterpart && <span className="badge" style={{ marginLeft: 6 }}>{t("import.counterpart")}</span>}
|
{r.is_counterpart && <span className="badge" style={{ marginLeft: 6 }}>{t("import.counterpart")}</span>}
|
||||||
|
{r.skip_reason === "state" && (
|
||||||
|
<span className="badge" style={{ marginLeft: 6 }}>{r.state || t("import.skipState")}</span>
|
||||||
|
)}
|
||||||
|
{r.skip_reason === "currency" && (
|
||||||
|
<span className="badge" style={{ marginLeft: 6 }}>{r.row_currency || t("import.skipCurrency")}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -484,14 +490,22 @@ export default function Import() {
|
|||||||
<summary className="muted small">{t("import.moreColumns")}</summary>
|
<summary className="muted small">{t("import.moreColumns")}</summary>
|
||||||
<p className="muted small">{t("import.ibanHint")}</p>
|
<p className="muted small">{t("import.ibanHint")}</p>
|
||||||
<div className="map-grid">
|
<div className="map-grid">
|
||||||
<label>
|
{[
|
||||||
<span>{t("import.colIban")}</span>
|
["counterparty_iban", t("import.colIban")],
|
||||||
<select value={config.column_map.counterparty_iban ?? ""} onChange={(e) => setCol("counterparty_iban", e.target.value)}>
|
["fee", t("import.colFee")],
|
||||||
|
["currency", t("import.colCurrency")],
|
||||||
|
["state", t("import.colState")],
|
||||||
|
].map(([field, label]) => (
|
||||||
|
<label key={field}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<select value={config.column_map[field] ?? ""} onChange={(e) => setCol(field, e.target.value)}>
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
{colOptions.map((c) => <option key={c.i} value={c.i}>{c.label}</option>)}
|
{colOptions.map((c) => <option key={c.i} value={c.i}>{c.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<p className="muted small">{t("import.extraColsHint")}</p>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<div className="field-inline" style={{ marginTop: "var(--sp-3)" }}>
|
<div className="field-inline" style={{ marginTop: "var(--sp-3)" }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user