diff --git a/backend/app/models.py b/backend/app/models.py index d07b85e..d824c0d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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) diff --git a/backend/app/routers/imports.py b/backend/app/routers/imports.py index 7f3f9c9..37dc7a0 100644 --- a/backend/app/routers/imports.py +++ b/backend/app/routers/imports.py @@ -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 { diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 527db17..158e28d 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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): diff --git a/backend/app/services/csv_import.py b/backend/app/services/csv_import.py index 5e3d78b..1f3caf3 100644 --- a/backend/app/services/csv_import.py +++ b/backend/app/services/csv_import.py @@ -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 diff --git a/backend/migrations/versions/0012_import_row_filter.py b/backend/migrations/versions/0012_import_row_filter.py new file mode 100644 index 0000000..bb5b52a --- /dev/null +++ b/backend/migrations/versions/0012_import_row_filter.py @@ -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") diff --git a/backend/tests/test_import.py b/backend/tests/test_import.py index 3702d0c..23f1886 100644 --- a/backend/tests/test_import.py +++ b/backend/tests/test_import.py @@ -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 diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 1494ab9..96bdd0f 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -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", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 75897c7..f9efc3e 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -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", diff --git a/web/src/pages/Import.jsx b/web/src/pages/Import.jsx index 21a8db2..fbd7c8f 100644 --- a/web/src/pages/Import.jsx +++ b/web/src/pages/Import.jsx @@ -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() { ))}

{t("import.extraColsHint")}

+
+ + +
+

{t("import.filterHint")}