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
+29 -5
View File
@@ -144,7 +144,18 @@ def read_rows(data: bytes, template: ImportTemplate) -> list[list[str]]:
reader = csv.reader(io.StringIO(text), delimiter=delim)
rows = [r for r in reader if any((c or "").strip() for c in r)]
start = template.skip_rows + (1 if template.has_header else 0)
return rows[start:]
rows = rows[start:]
# Zeilenfilter: Revolut legt alle Pockets in eine Datei, jedes mit eigenem
# Saldo-Verlauf. Ohne Filter landet alles auf demselben Konto.
spalte = template.filter_column
wert = (template.filter_value or "").strip().lower()
if spalte is not None and wert:
rows = [
r for r in rows
if spalte < len(r) and (r[spalte] or "").strip().lower() == wert
]
return rows
def preview_raw(data: bytes, template: ImportTemplate, limit: int = 8) -> dict:
@@ -306,6 +317,7 @@ def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict
return idx if isinstance(idx, int) and idx >= 0 else None
out: list[dict] = []
vorheriger_saldo: int | None = None
for r in read_rows(data, template):
def cell(field: str) -> str:
c = col(field)
@@ -322,11 +334,23 @@ 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.
# Getrennte Gebührenspalte (z. B. Revolut) mindert den Betrag.
fee = parse_amount(cell("fee"), template.decimal_sep)
balance = parse_amount(cell("balance"), template.decimal_sep)
if amount is not None and fee:
amount -= abs(fee)
if amount == 0:
# Reine Gebührenzeile: Ob belastet oder erstattet („Erstattung für
# Transaktion"), steht nicht im Betrag – beide Male 0.00 bei
# gleicher Gebühr. Die Saldo-Spalte weiß es; ohne sie ist eine
# Belastung der Normalfall.
delta = (balance - vorheriger_saldo) if (
balance is not None and vorheriger_saldo is not None
) else None
amount = delta if delta is not None else -abs(fee)
else:
amount -= abs(fee)
if balance is not None:
vorheriger_saldo = balance
out.append({
"date": d.isoformat() if d else None,
@@ -337,7 +361,7 @@ def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict
"state": (cell("state").strip().upper() or None),
"row_currency": (cell("currency").strip().upper() or None),
# Laufender Kontostand aus der Datei – Grundlage für den Saldo-Abgleich.
"balance_cents": parse_amount(cell("balance"), template.decimal_sep),
"balance_cents": balance,
})
return out