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