feat: Saldo-Abgleich beim CSV-Import gegen die Kontostand-Spalte

Bisher merkte man einen Importfehler erst am falschen Endsaldo, ohne Hinweis auf
die Ursache. Enthält der Auszug eine laufende Kontostand-Spalte, gleicht der
Import jetzt jede Zeile dagegen ab: Die Spalte sagt, wie sich der Saldo
tatsächlich verändert hat, unsere Erkennung muss dazu passen.

- neue Spaltenzuordnung "Kontostand (laufend)"; Presets Raiffeisen/Revolut ergänzt
- Prüfansicht zeigt: stimmt überein ODER Differenz + erste abweichende Zeile
  (Datum, Empfänger, Betrag laut Bank vs. erkannt)
- meldet zusätzlich den Anfangssaldo laut Datei – häufigste Fehlerquelle
- rechnet mit dem tatsächlich gebuchten Betrag, damit auch nicht gebuchte Zeilen
  auffallen (z. B. nicht zugeordnete Gebührenspalte); Fremdwährungs-Zeilen
  gehören zu einem anderen Saldo-Verlauf und bleiben außen vor
- erkennt absteigend sortierte Auszüge
- Tests: stimmiger Auszug (ok) und fehlende Gebührenspalte (zeigt genau die
  Zeile). 83 grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-21 06:58:33 +02:00
co-authored by Claude Opus 4.8
parent 4520c7318f
commit 5a747fbef4
6 changed files with 175 additions and 7 deletions
+6 -1
View File
@@ -329,7 +329,12 @@
"colState": "Status",
"extraColsHint": "Gebühr wird vom Betrag abgezogen. Zeilen mit Status ungleich COMPLETED (z. B. storniert) und Zeilen in einer anderen Währung als das Zielkonto werden nicht gebucht.",
"skipState": "storniert",
"skipCurrency": "andere Währung"
"skipCurrency": "andere Währung",
"colBalance": "Kontostand (laufend)",
"reconOk": "Saldo-Abgleich stimmt: alle {{n}} Schritte passen zur Kontostand-Spalte der Datei.",
"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)."
},
"password": {
"show": "Passwort anzeigen",
+6 -1
View File
@@ -329,7 +329,12 @@
"colState": "State",
"extraColsHint": "The fee is deducted from the amount. Rows whose state is not COMPLETED (e.g. reverted) and rows in a currency other than the target account are not imported.",
"skipState": "reverted",
"skipCurrency": "other currency"
"skipCurrency": "other currency",
"colBalance": "Balance (running)",
"reconOk": "Balance check passes: all {{n}} steps match the file's balance column.",
"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)."
},
"password": {
"show": "Show password",
+41 -2
View File
@@ -49,6 +49,7 @@ export default function Import() {
const [config, setConfig] = useState(defaultConfig());
const [raw, setRaw] = useState(null);
const [rows, setRows] = useState(null);
const [recon, setRecon] = useState(null);
const [docText, setDocText] = useState("");
const [docPages, setDocPages] = useState([]);
const [busy, setBusy] = useState(false);
@@ -114,7 +115,7 @@ export default function Import() {
setBusy(true);
try {
setRaw(await api.importRaw(baseForm()));
setRows(null);
setRows(null); setRecon(null);
} catch (e) { toast(e.message, "error"); } finally { setBusy(false); }
}
@@ -130,6 +131,7 @@ export default function Import() {
fd.append("column_map", JSON.stringify(config.column_map));
const res = await api.importPreview(fd);
setRows(res.rows.map((r) => ({ ...r })));
setRecon(res.reconciliation || null);
} catch (e) { toast(e.message, "error"); } finally { setBusy(false); }
}
@@ -142,6 +144,7 @@ export default function Import() {
fd.append("account_id", accountId);
const res = await api.documentPreview(fd);
setRows(res.rows.map((r) => ({ ...r })));
setRecon(null);
setDocText(res.text || "");
setDocPages(res.pages || []);
} catch (e) { toast(e.message, "error"); } finally { setBusy(false); }
@@ -157,7 +160,7 @@ export default function Import() {
rows,
});
toast(t("import.imported", { n: batch.imported_count }));
setRows(null); setRaw(null); setFile(null); setDocText(""); setDocPages([]);
setRows(null); setRecon(null); setRaw(null); setFile(null); setDocText(""); setDocPages([]);
if (fileRef.current) fileRef.current.value = "";
loadStamm();
} catch (e) { toast(e.message, "error"); } finally { setBusy(false); }
@@ -495,6 +498,7 @@ export default function Import() {
["fee", t("import.colFee")],
["currency", t("import.colCurrency")],
["state", t("import.colState")],
["balance", t("import.colBalance")],
].map(([field, label]) => (
<label key={field}>
<span>{label}</span>
@@ -537,6 +541,41 @@ export default function Import() {
<span>{t("import.noneValid")}</span>
</div>
)}
{recon?.available && (
<div className={`alert ${recon.ok ? "ok" : "error"}`} style={{ marginBottom: "var(--sp-3)" }}>
<Icon name={recon.ok ? "check" : "alert"} size={16} />
<div>
{recon.ok ? (
<span>{t("import.reconOk", { n: recon.checked })}</span>
) : (
<>
<div>
{t("import.reconDiff", {
file: formatMoney(recon.final_balance_cents, acc?.currency || "EUR"),
app: formatMoney(recon.computed_final_cents, acc?.currency || "EUR"),
diff: formatMoney(recon.difference_cents, acc?.currency || "EUR"),
})}
</div>
{recon.first_mismatch && (
<div className="muted small">
{t("import.reconFirst", {
date: recon.first_mismatch.date ? formatDate(recon.first_mismatch.date) : "?",
payee: recon.first_mismatch.payee || "—",
expected: formatMoney(recon.first_mismatch.expected_cents, acc?.currency || "EUR"),
actual: formatMoney(recon.first_mismatch.actual_cents, acc?.currency || "EUR"),
})}
</div>
)}
</>
)}
<div className="muted small">
{t("import.reconOpening", {
value: formatMoney(recon.opening_balance_cents, acc?.currency || "EUR"),
})}
</div>
</div>
</div>
)}
<DataTable
id="import-review"
rows={rows}