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
+6
View File
@@ -504,6 +504,12 @@ class ImportTemplate(Base):
# JSON: Feld -> 0-basierter Spaltenindex (oder null). Felder: date, time,
# amount, debit, credit, currency, payee, purpose, counterparty_iban.
column_map: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
# Zeilenfilter: nur Zeilen übernehmen, in denen Spalte `filter_column` den
# Wert `filter_value` hat. Revolut legt alle Pockets („Giro", „Erspartes" …)
# mit je eigenem Saldo-Verlauf in EINE Datei – ohne Filter landete alles auf
# demselben Konto und zählte doppelt.
filter_column: Mapped[int | None] = mapped_column(Integer, nullable=True)
filter_value: Mapped[str | None] = mapped_column(String(120), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+5
View File
@@ -26,6 +26,7 @@ def _to_out(tpl: ImportTemplate) -> ImportTemplateOut:
date_format=tpl.date_format, encoding=tpl.encoding, has_header=tpl.has_header,
skip_rows=tpl.skip_rows, amount_mode=tpl.amount_mode,
column_map=json.loads(tpl.column_map or "{}"),
filter_column=tpl.filter_column, filter_value=tpl.filter_value,
)
@@ -51,6 +52,7 @@ def create_template(payload: ImportTemplateCreate, db: Session = Depends(get_db)
decimal_sep=payload.decimal_sep, date_format=payload.date_format, encoding=payload.encoding,
has_header=payload.has_header, skip_rows=payload.skip_rows, amount_mode=payload.amount_mode,
column_map=json.dumps(payload.column_map),
filter_column=payload.filter_column, filter_value=payload.filter_value,
)
db.add(tpl)
db.commit()
@@ -112,6 +114,8 @@ async def preview(
skip_rows: int = Form(0),
amount_mode: str = Form("signed"),
column_map: str = Form("{}"),
filter_column: int | None = Form(None),
filter_value: str | None = Form(None),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict:
@@ -121,6 +125,7 @@ async def preview(
user.id, delimiter=delimiter, decimal_sep=decimal_sep, date_format=date_format,
encoding=encoding, has_header=has_header, skip_rows=skip_rows,
amount_mode=AmountMode(amount_mode), column_map=column_map,
filter_column=filter_column, filter_value=filter_value,
)
rows = csv_import.build_preview(db, user, account, tpl, data)
return {
+6
View File
@@ -371,6 +371,8 @@ class ImportTemplateCreate(BaseModel):
skip_rows: int = 0
amount_mode: AmountMode = AmountMode.signed
column_map: dict[str, int | None] = Field(default_factory=dict)
filter_column: int | None = None
filter_value: str | None = None
class ImportTemplateUpdate(BaseModel):
@@ -383,6 +385,8 @@ class ImportTemplateUpdate(BaseModel):
skip_rows: int | None = None
amount_mode: AmountMode | None = None
column_map: dict[str, int | None] | None = None
filter_column: int | None = None
filter_value: str | None = None
class ImportTemplateOut(BaseModel):
@@ -396,6 +400,8 @@ class ImportTemplateOut(BaseModel):
skip_rows: int
amount_mode: AmountMode
column_map: dict[str, int | None]
filter_column: int | None = None
filter_value: str | None = None
class ImportBatchOut(BaseModel):
+28 -4
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:
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
@@ -0,0 +1,29 @@
"""Import-Vorlagen: Zeilenfilter je Spaltenwert.
Revolut exportiert alle Pockets („Giro", „Erspartes", „Anlagekonto" …) in EINE
Datei – jedes mit eigenem Saldo-Verlauf. Ohne Filter landeten alle Zeilen auf
demselben Konto und die Pocket-Bewegungen zählten doppelt.
Revision ID: 0012
Revises: 0011
Create Date: 2026-08-21
"""
from alembic import op
import sqlalchemy as sa
revision = "0012"
down_revision = "0011"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("import_templates") as batch:
batch.add_column(sa.Column("filter_column", sa.Integer(), nullable=True))
batch.add_column(sa.Column("filter_value", sa.String(length=120), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("import_templates") as batch:
batch.drop_column("filter_value")
batch.drop_column("filter_column")
+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
+6 -1
View File
@@ -335,7 +335,12 @@
"reconDiff": "Saldo-Abgleich weicht ab. Laut Datei: {{file}} · errechnet: {{app}} · Differenz: {{diff}}.",
"reconFirst": "Erste Abweichung am {{date}} bei „{{payee}}“: Bank {{expected}}, erkannt {{actual}}.",
"reconOpening": "Anfangssaldo laut Datei: {{value}} (trage ihn beim Konto ein, damit der Stand stimmt).",
"needsTarget": "Gegenkonto wählen"
"needsTarget": "Gegenkonto wählen",
"filterColumn": "Nur Zeilen aus Spalte",
"filterValue": "mit dem Wert",
"filterValuePlaceholder": "z. B. Giro",
"filterNone": "— kein Filter —",
"filterHint": "Revolut legt alle Pockets (Giro, Erspartes, Anlagekonto …) in EINE Datei, jedes mit eigenem Kontostand. Filtere hier auf ein Produkt und lege für die anderen eigene Konten an – sonst zählen die Pocket-Bewegungen doppelt."
},
"password": {
"show": "Passwort anzeigen",
+6 -1
View File
@@ -335,7 +335,12 @@
"reconDiff": "Balance check failed. Per file: {{file}} · computed: {{app}} · difference: {{diff}}.",
"reconFirst": "First mismatch on {{date}} at “{{payee}}”: bank {{expected}}, detected {{actual}}.",
"reconOpening": "Opening balance per file: {{value}} (enter it on the account so the balance matches).",
"needsTarget": "pick account"
"needsTarget": "pick account",
"filterColumn": "Only rows from column",
"filterValue": "with value",
"filterValuePlaceholder": "e.g. Current",
"filterNone": "— no filter —",
"filterHint": "Revolut puts every pocket (Current, Savings, Investments …) into ONE file, each with its own balance. Filter to one product here and create separate accounts for the others – otherwise pocket movements are counted twice."
},
"password": {
"show": "Show password",
+24
View File
@@ -33,6 +33,8 @@ function defaultConfig() {
skip_rows: 0,
amount_mode: "signed",
column_map: {},
filter_column: null,
filter_value: "",
};
}
@@ -91,6 +93,7 @@ export default function Import() {
delimiter: src.delimiter, decimal_sep: src.decimal_sep, date_format: src.date_format,
encoding: src.encoding, has_header: src.has_header, skip_rows: src.skip_rows,
amount_mode: src.amount_mode, column_map: src.column_map || {},
filter_column: src.filter_column ?? null, filter_value: src.filter_value || "",
});
}
// Ein zusammengeführtes Vorlagen-Dropdown: Werte typ-getaggt "preset:i" / "tpl:id".
@@ -135,6 +138,10 @@ export default function Import() {
fd.append("date_format", config.date_format);
fd.append("amount_mode", config.amount_mode);
fd.append("column_map", JSON.stringify(config.column_map));
if (config.filter_column != null && config.filter_value) {
fd.append("filter_column", String(config.filter_column));
fd.append("filter_value", config.filter_value);
}
const res = await api.importPreview(fd);
setRows(res.rows.map((r) => ({ ...r })));
setRecon(res.reconciliation || null);
@@ -517,6 +524,23 @@ export default function Import() {
))}
</div>
<p className="muted small">{t("import.extraColsHint")}</p>
<div className="map-grid">
<label>
<span>{t("import.filterColumn")}</span>
<select value={config.filter_column ?? ""}
onChange={(e) => setCfg({ filter_column: e.target.value === "" ? null : Number(e.target.value) })}>
<option value="">{t("import.filterNone")}</option>
{colOptions.map((c) => <option key={c.i} value={c.i}>{c.label}</option>)}
</select>
</label>
<label>
<span>{t("import.filterValue")}</span>
<input value={config.filter_value}
placeholder={t("import.filterValuePlaceholder")}
onChange={(e) => setCfg({ filter_value: e.target.value })} />
</label>
</div>
<p className="muted small">{t("import.filterHint")}</p>
</details>
<div className="field-inline" style={{ marginTop: "var(--sp-3)" }}>