feat: Kreditkarten-Auszuege lesen sich von selbst richtig herum

Auf einem Kartenauszug ist +6,00 eine AUSGABE und -10,00 die Zahlung der
Rechnung. Certain kannte diese Leserichtung nicht. Der scheinbare Ausweg
"Ein Betrag (immer Ausgabe)" machte auch die Rechnungszahlung und jede
Gutschrift zur Ausgabe.

Neuer Betragsmodus "signed_inverted". Er dreht Betrag UND Saldospalte:
Ein Auszug, der einen Einkauf als +6 fuehrt, schreibt auch den Saldo so –
die Schuld steigt. Wer nur den Betrag dreht, bekommt bei jeder einzelnen
Zeile eine Abweichung gemeldet, und die einzige Kontrollinstanz des
Imports wird wertlos. Dazu gibt es jetzt einen Test.

Waehlen muss den Modus niemand: /imports/detect erkennt jetzt auch das
KONTO – erkenne_konto konnte das laengst, benutzt hat es nur der
Telefon-Kurzbefehl. Ist das erkannte Konto eine Kreditkarte, wird die
Leserichtung mitgesetzt. Eng gefasst: nur bei einer Betragsspalte, nur
aus "signed" heraus, nur bei sicher erkanntem Konto. Und nie still – in
der Pruefansicht steht, dass umgekehrt gelesen wird, und wo man es
aendert.

Drei Dinge, die dabei auffielen:

- Der Abgleich rechnete mit dem Vorzeichen, gebucht wird aber nach Art
  (abs(Betrag) + Ausgabe/Einnahme). Wer eine Zeile umstellte, pruefte
  eine Buchung, die so nie entsteht.
- Dieselbe Luecke in der Vorschau: Die Betragsspalte faerbte nach dem
  Rohvorzeichen. Ein Umstellen aenderte sichtbar nichts.
- Auf Kartenauszuegen heisst die Empfaengerspalte "Haendler", und viele
  fuehren die eigene Kontonummer in jeder Zeile mit. Die gewann die
  Spaltenwahl ueber die Textlaenge – heraus kam eine Liste, in der bei
  jeder Buchung dasselbe stand.

Neu ausserdem eine Summenzeile je Richtung und eine Sammelaktion: Ist
eine Datei durchgehend verkehrt herum gelesen, sind alle Zeilen
betroffen, und bei 150 Positionen ist Einzelkorrektur keine Moeglichkeit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-30 20:44:06 +02:00
co-authored by Claude Opus 5
parent 38697f828e
commit 98760a4652
7 changed files with 608 additions and 20 deletions
+5
View File
@@ -436,6 +436,7 @@
"skipRows": "Zeilen überspringen",
"hasHeader": "Erste Zeile ist Kopfzeile",
"modeSigned": "Ein Betrag (mit Vorzeichen)",
"modeSignedInverted": "Eine Spalte, + ist eine Ausgabe (Kreditkarte)",
"modeDebitCredit": "Getrennte Soll-/Haben-Spalten",
"modeAbsExpense": "Ein Betrag (immer Ausgabe)",
"readFile": "Datei einlesen",
@@ -453,6 +454,8 @@
"rawHint": "Nur die ersten Zeilen – sie dienen nur der Spaltenzuordnung.",
"makePreviewNote": "„Vorschau erstellen“ zeigt danach ALLE Buchungen zum Prüfen und Buchen.",
"summary": "{{total}} Zeilen erkannt · {{n}} werden gebucht (Duplikate/ungültige ausgenommen).",
"setTypeForAll": "Alle gewählten Zeilen:",
"flipAll": "Richtung umdrehen",
"noneValid": "Keine gültige Buchung erkannt. Prüfe Datumsformat, Dezimaltrenner und Spaltenzuordnung – am einfachsten oben die passende Bank-Vorlage wählen.",
"commit": "{{n}} Buchungen übernehmen",
"imported": "{{n}} Buchungen importiert.",
@@ -489,6 +492,8 @@
"removeEntry": "Eintrag entfernen",
"removeEntryConfirm": "Den Verlaufs-Eintrag „{{file}}“ entfernen? Die Buchungen sind bereits zurückgenommen.",
"reconMixedHint": "Enthält die Datei mehrere Konten oder Produkte? Revolut legt Giro, Erspartes und Anlagekonto in EINE Datei, jedes mit eigener Kontostand-Spalte. Dann greift der Abgleich erst, wenn du oben den Zeilenfilter setzt (z. B. Produkt = Giro) und die übrigen Produkte einzeln einliest.",
"reconCardHint": "Bei Kreditkarten steht + oft für eine Ausgabe. Stimmt die Leserichtung? Unter „Format“ lässt sie sich umstellen.",
"cardSignNote": "Kreditkarte: + im Auszug wird als Ausgabe gelesen, − als Zahlung der Rechnung. Umschaltbar unter „Format“.",
"filterTitle": "Diese Datei enthält mehrere Konten",
"filterValueOption": "{{value}} ({{count}} Zeilen)",
"removeAllUndone": "Rückgängig gemachte entfernen",
+5
View File
@@ -436,6 +436,7 @@
"skipRows": "Skip rows",
"hasHeader": "First row is a header",
"modeSigned": "Single amount (signed)",
"modeSignedInverted": "Single column, + is an expense (credit card)",
"modeDebitCredit": "Separate debit/credit columns",
"modeAbsExpense": "Single amount (always expense)",
"readFile": "Read file",
@@ -453,6 +454,8 @@
"rawHint": "Only the first rows – just for mapping the columns.",
"makePreviewNote": "“Build preview” then shows ALL transactions to review and import.",
"summary": "{{total}} rows detected · {{n}} will be imported (duplicates/invalid excluded).",
"setTypeForAll": "All selected rows:",
"flipAll": "Flip direction",
"noneValid": "No valid transaction detected. Check date format, decimal separator and column mapping – easiest is to pick the matching bank preset above.",
"commit": "Import {{n}} transactions",
"imported": "Imported {{n}} transactions.",
@@ -489,6 +492,8 @@
"removeEntry": "Remove entry",
"removeEntryConfirm": "Remove the history entry “{{file}}”? Its transactions have already been undone.",
"reconMixedHint": "Does the file hold several accounts or products? Revolut puts current, savings and investment into ONE file, each with its own balance column. The check only works once you set the row filter above (e.g. Product = Current) and import the other products separately.",
"reconCardHint": "On credit cards, + often means an expense. Is the reading direction right? You can switch it under “Format”.",
"cardSignNote": "Credit card: + in the statement is read as an expense, − as a payment of the bill. Switchable under “Format”.",
"filterTitle": "This file holds several accounts",
"filterValueOption": "{{value}} ({{count}} rows)",
"removeAllUndone": "Remove undone entries",
+122 -11
View File
@@ -25,6 +25,18 @@ const DATE_FORMATS = [
{ v: "%d.%m.%Y %H:%M:%S", l: "TT.MM.JJJJ SS:MM:SS" },
];
// Verlaesst das Geld dieses Konto (-1) oder kommt es an (+1)?
//
// Bei Ausgabe/Einnahme entscheidet die ART, nicht das Vorzeichen des
// Rohbetrags: Gebucht wird am Ende abs(Betrag) plus Art (_make_transaction),
// und der Saldo-Abgleich rechnet ebenso. Bei einem Uebertrag steht die
// Richtung weiter im Vorzeichen – negativ heisst, es geht hier weg.
function richtung(r) {
if (r.type === "expense") return -1;
if (r.type === "income") return 1;
return (r.amount_cents || 0) < 0 ? -1 : 1;
}
function defaultConfig() {
return {
delimiter: ";",
@@ -253,13 +265,25 @@ export default function Import() {
// Format aus der Datei ableiten, damit niemand Spalten von Hand zuordnet.
// Das war die haeufigste Fehlerquelle: falsches Datumsformat = keine Zeile
// gueltig, falscher Dezimaltrenner = Betraege um Faktor 100 daneben.
async function formatErkennen(f) {
//
// `kontoUebernehmen` ist nur fuer Dateien vom Telefon aus: Dort hat der
// Nutzer das Konto im Kurzbefehl schon ausdruecklich gewaehlt, und eine
// ausdrueckliche Wahl schlaegt eine erkannte.
async function formatErkennen(f, kontoUebernehmen = true) {
setErkannt(null);
try {
const daten = new FormData();
daten.append("file", f);
const r = await api.detectFormat(daten);
setErkannt(r);
// Das Konto ist die gefaehrlichste Entscheidung des Imports – ein Auszug
// im falschen Konto verfaelscht zwei Salden. Die Liste steht hier
// ausserdem auf dem ERSTEN Konto vorbelegt; ein erkanntes Konto muss
// diese Vorbelegung also ueberschreiben duerfen. Erkannt wird nur, wenn
// es eindeutig ist (erkenne_konto raet nicht).
if (kontoUebernehmen && r.konto?.account_id) {
setAccountId(String(r.konto.account_id));
}
if (r.ok && r.config) {
const c = r.config;
setCfg({
@@ -286,7 +310,7 @@ export default function Import() {
setRows(null);
setMode("csv");
const datei = new File([blob], u.filename, { type: "text/csv" });
formatErkennen(datei);
formatErkennen(datei, false);
toast(t("import.phoneFileLoaded", { name: u.filename }));
} catch (e) {
toast(e.message, "error");
@@ -325,8 +349,42 @@ export default function Import() {
const includeCount = rows ? rows.filter((r) => r.include).length : 0;
// Summe je Richtung ueber die Zeilen, die wirklich gebucht werden.
//
// Eine gespiegelte Datei sieht man daran auf einen Blick: Ein Kartenauszug
// mit 3.000 Einnahmen und 200 Ausgaben ist verkehrt herum gelesen. Zeile
// fuer Zeile faellt das bei 150 Positionen niemandem auf.
const summen = useMemo(() => {
const s = { aus: 0, ein: 0 };
for (const r of rows || []) {
if (!r.include || !r.valid) continue;
const betrag = Math.abs(r.amount_cents || 0);
if (richtung(r) < 0) s.aus += betrag;
else s.ein += betrag;
}
return s;
}, [rows]);
// Aktualisiert eine Zeile über ihren stabilen Erkennungs-Index (die Anzeige in
// der Tabelle kann sortiert/gefiltert sein, der Index bleibt gleich).
// Nur was gebucht wird: Ungueltige und abgewaehlte Zeilen bleiben, wie sie
// sind – sie landen ohnehin nirgends.
function typFuerAlleSetzen(typ) {
setRows((rs) => rs.map((r) => (r.include && r.valid ? { ...r, type: typ } : r)));
}
// Der haeufigere Fall als „alles Ausgabe": Die Datei ist stimmig, nur
// andersherum gelesen. Uebertraege bleiben unberuehrt – deren Richtung
// steckt im Vorzeichen, nicht in der Art.
function richtungUmdrehen() {
setRows((rs) => rs.map((r) => {
if (!r.include || !r.valid) return r;
if (r.type === "expense") return { ...r, type: "income" };
if (r.type === "income") return { ...r, type: "expense" };
return r;
}));
}
function updateRow(rowIndex, patch) {
setRows((rs) => rs.map((r) => (r.index === rowIndex ? { ...r, ...patch } : r)));
}
@@ -349,13 +407,23 @@ export default function Import() {
: <span className="amount neg">{t("import.badDate")}</span>),
},
{
// Richtung aus `type`, nicht aus dem Vorzeichen des Rohbetrags.
//
// Gebucht wird am Ende abs(Betrag) plus Art (_make_transaction). Wer
// eine Zeile von Ausgabe auf Einnahme stellte, sah hier bisher KEINE
// Aenderung – die Liste zeigte etwas anderes als das, was gebucht wird.
// Derselbe Fehler steckte einmal in Money.jsx.
key: "amount", header: t("transactions.amount"), align: "num", width: 120,
sortValue: (r) => r.amount_cents || 0,
render: (r) => (
<span className={`amount ${(r.amount_cents || 0) < 0 ? "neg" : "pos"}`}>
{formatMoney(Math.abs(r.amount_cents || 0), acc?.currency || "EUR")}
</span>
),
sortValue: (r) => richtung(r) * Math.abs(r.amount_cents || 0),
render: (r) => {
const raus = richtung(r) < 0;
return (
<span className={`amount ${raus ? "neg" : "pos"}`}>
{raus ? "−" : "+"}
{formatMoney(Math.abs(r.amount_cents || 0), acc?.currency || "EUR")}
</span>
);
},
},
{
key: "type", header: t("import.typeCol"), width: 128,
@@ -581,6 +649,7 @@ export default function Import() {
<span>{t("import.amountMode")}</span>
<select value={config.amount_mode} onChange={(e) => setCfg({ amount_mode: e.target.value })}>
<option value="signed">{t("import.modeSigned")}</option>
<option value="signed_inverted">{t("import.modeSignedInverted")}</option>
<option value="debit_credit">{t("import.modeDebitCredit")}</option>
<option value="abs_expense">{t("import.modeAbsExpense")}</option>
</select>
@@ -759,7 +828,27 @@ export default function Import() {
<Icon name="download" size={15} /> {t("import.commit", { n: includeCount })}
</button>
</div>
<p className="muted small">{t("import.summary", { total: rows.length, n: includeCount })}</p>
<p className="muted small">
{t("import.summary", { total: rows.length, n: includeCount })}
{" · "}
<span className="amount neg">
−{formatMoney(summen.aus, acc?.currency || "EUR")}
</span>
{" / "}
<span className="amount pos">
+{formatMoney(summen.ein, acc?.currency || "EUR")}
</span>
</p>
{/* Eine stille Vorzeichen-Umkehr waere schlimmer als gar keine: Wer
nicht weiss, dass umgekehrt gelesen wird, haelt die Liste fuer
falsch statt fuer anders herum. Deshalb steht es hier – und dabei,
wo man es aendert. */}
{config.amount_mode === "signed_inverted" && (
<div className="alert" style={{ marginBottom: "var(--sp-3)" }}>
<Icon name="credit-card" size={16} />
<span>{t("import.cardSignNote")}</span>
</div>
)}
{rows.length > 0 && includeCount === 0 && (
<div className="alert error" style={{ marginBottom: "var(--sp-3)" }}>
<Icon name="alert" size={16} />
@@ -791,8 +880,13 @@ export default function Import() {
})}
</div>
)}
{/* Häufigste Ursache: mehrere Konten in einer Datei, kein Zeilenfilter. */}
{config.filter_column == null && (
{/* Häufigste Ursache: mehrere Konten in einer Datei, kein
Zeilenfilter. Bei einer Kreditkarte aber nicht: Dort ist
die Leserichtung die naheliegende Erklaerung, und der
Revolut-Hinweis fuehrte nur von der Ursache weg. */}
{acc?.type === "kreditkarte" ? (
<div className="muted small">{t("import.reconCardHint")}</div>
) : config.filter_column == null && (
<div className="muted small">{t("import.reconMixedHint")}</div>
)}
</>
@@ -805,6 +899,23 @@ export default function Import() {
</div>
</div>
)}
{/* Ist eine Datei durchgehend verkehrt herum gelesen, sind ALLE
Zeilen betroffen. Einzeln umzustellen ist bei 150 Positionen
keine Moeglichkeit, sondern eine Zumutung. */}
<div className="field-inline" style={{ marginBottom: "var(--sp-2)" }}>
<span className="muted small">{t("import.setTypeForAll")}</span>
<button type="button" className="btn sm"
onClick={() => typFuerAlleSetzen("expense")}>
{t("transactions.expense")}
</button>
<button type="button" className="btn sm"
onClick={() => typFuerAlleSetzen("income")}>
{t("transactions.income")}
</button>
<button type="button" className="btn sm" onClick={richtungUmdrehen}>
{t("import.flipAll")}
</button>
</div>
<DataTable
id="import-review"
rows={rows}