feat: Zeilenfilter für Auszüge mit mehreren Pockets + Gebühren-Richtung

Der Revolut-Auszug enthält ALLE Produkte in einer Datei – im CHF-Export stehen
533 Giro-Zeilen und 255 Zeilen des Ersparten, jedes mit eigenem Kontostand-
Verlauf. Ohne Trennung landeten beide auf demselben Konto und die Pocket-
Bewegungen zählten doppelt.

- Import-Vorlage kennt einen Zeilenfilter („nur Zeilen aus Spalte X mit Wert Y"),
  Migration 0012; damit bekommt jedes Pocket sein eigenes Konto
- Gebührenzeilen (Betrag 0.00) können Belastung ODER Erstattung sein – beide
  sehen identisch aus. Die Richtung kommt jetzt aus der Kontostand-Spalte;
  ohne sie bleibt die Belastung der Normalfall
- Tests für beides; 90 grün

Nachgerechnet an den echten Auszügen: Raiffeisen 223.37, Revolut CHF 61.88,
Erspartes 6.71, Revolut EUR 0.68 – alle vier stimmen zeilengenau mit der
Kontostand-Spalte überein.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-21 09:01:07 +02:00
co-authored by Claude Opus 4.8
parent 595662e515
commit 4a6f7a0814
9 changed files with 190 additions and 7 deletions
+79
View File
@@ -406,3 +406,82 @@ def test_expense_is_not_swallowed_by_incoming_transfer(admin):
admin.post("/api/imports/commit", json={"account_id": ziel["id"], "filename": "z.csv", "rows": rows})
# 100.00 + 50.00 (Transfer) - 50.00 (Einkauf) = 100.00
assert admin.get(f"/api/accounts/{ziel['id']}").json()["balance_cents"] == 10000
# --- Zeilenfilter: mehrere Pockets in einer Datei ---------------------------
MULTI_POCKET_CSV = (
"Art,Produkt,Beginn,Abschluss,Beschreibung,Betrag,Gebühr,Währung,Status,Kontostand\n"
"Einzahlung,Giro,2026-01-01 10:00:00,2026-01-01 10:00:00,Von Bank,100.00,0.00,CHF,ABGESCHLOSSEN,100.00\n"
"Transfer,Erspartes,2026-01-02 10:00:00,2026-01-02 10:00:00,Ins Erspartes,50.00,0.00,CHF,ABGESCHLOSSEN,50.00\n"
"Kartenbezahlung,Giro,2026-01-03 10:00:00,2026-01-03 10:00:00,Coop,-20.00,0.00,CHF,ABGESCHLOSSEN,80.00\n"
"Zinsen,Erspartes,2026-01-04 10:00:00,2026-01-04 10:00:00,Zins,1.00,0.00,CHF,ABGESCHLOSSEN,51.00\n"
)
POCKET_MAP = json.dumps({"date": 3, "amount": 5, "payee": 4, "fee": 6,
"currency": 7, "state": 8, "balance": 9})
def _preview_filtered(admin, account_id, fcol=None, fval=None):
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": POCKET_MAP,
}
if fcol is not None:
data["filter_column"] = str(fcol)
data["filter_value"] = fval
return admin.post(
"/api/imports/preview",
files={"file": ("p.csv", io.BytesIO(MULTI_POCKET_CSV.encode("utf-8")), "text/csv")},
data=data,
).json()
def test_row_filter_splits_pockets(admin):
"""Ohne Filter landen fremde Pocket-Zeilen im Konto und der Saldo-Abgleich
schlägt an; mit Filter stimmt jedes Konto für sich."""
giro = _account(admin, name="Pocket Giro", currency="CHF")
spar = _account(admin, name="Pocket Spar", currency="CHF")
ohne = _preview_filtered(admin, giro["id"])
assert len(ohne["rows"]) == 4
assert ohne["reconciliation"]["ok"] is False, "vermischte Ströme müssen auffallen"
mit = _preview_filtered(admin, giro["id"], 1, "Giro")
assert len(mit["rows"]) == 2
assert mit["reconciliation"]["ok"] is True
assert mit["reconciliation"]["opening_balance_cents"] == 0
admin.post("/api/imports/commit", json={"account_id": giro["id"], "filename": "p.csv", "rows": mit["rows"]})
assert admin.get(f"/api/accounts/{giro['id']}").json()["balance_cents"] == 8000
ers = _preview_filtered(admin, spar["id"], 1, "Erspartes")
assert len(ers["rows"]) == 2
assert ers["reconciliation"]["ok"] is True
admin.post("/api/imports/commit", json={"account_id": spar["id"], "filename": "p.csv", "rows": ers["rows"]})
assert admin.get(f"/api/accounts/{spar['id']}").json()["balance_cents"] == 5100
REFUND_CSV = (
"Art,Produkt,Beginn,Abschluss,Beschreibung,Betrag,Gebühr,Währung,Status,Kontostand\n"
"Einzahlung,Giro,2026-01-01 10:00:00,2026-01-01 10:00:00,Von Bank,100.00,0.00,CHF,ABGESCHLOSSEN,100.00\n"
"Belastung,Giro,2026-01-02 10:00:00,2026-01-02 10:00:00,Premium Abo-Gebühr,0.00,10.99,CHF,ABGESCHLOSSEN,89.01\n"
"Erstattung,Giro,2026-01-03 10:00:00,2026-01-03 10:00:00,Erstattung Abo-Kündigung,0.00,10.99,CHF,ABGESCHLOSSEN,100.00\n"
)
def test_fee_row_direction_from_balance(admin):
"""Gebührenzeilen haben in beide Richtungen Betrag 0.00 und dieselbe Gebühr –
Belastung und Erstattung unterscheidet nur die Kontostand-Spalte."""
acc = _account(admin, name="Gebuehren", currency="CHF")
rows = admin.post(
"/api/imports/preview",
files={"file": ("f.csv", io.BytesIO(REFUND_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": POCKET_MAP,
},
).json()
assert rows["rows"][1]["amount_cents"] == -1099, "Gebühr belastet"
assert rows["rows"][2]["amount_cents"] == 1099, "Gebühr erstattet"
assert rows["reconciliation"]["ok"] is True