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 -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)" }}>