Files
certain/web/src/pages/Accounts.jsx
T
GuidoandClaude Opus 5 f2c1ab7dec feat: Regeln nachtraeglich auf Buchungen ohne Kategorie anwenden
Regeln greifen beim Import. Alles, was vorher hereinkam – oder hereinkam, bevor
es die Regel gab –, bleibt liegen. Bei einer Bankanbindung passiert genau das
dauernd: Die Umsaetze kommen von selbst, die Regel entsteht spaeter. Die
Revolut-Aufrundungen aufs Sparen-Pocket sind der Fall, an dem es aufgefallen
ist.

Der Knopf steht bei der Transfer-Erkennung und zeigt erst, was passieren
wuerde. Uebertrags-Regeln laufen durch denselben Dienst wie jeder andere
Uebertrag – er lehnt ab, wo die Gegenseite schon gebucht ist, und solche
Buchungen bleiben unangetastet und werden mit Grund gemeldet.

Ohne festes Zielkonto wird es aus dem Buchungstext erraten, aber nur bei GENAU
einem Treffer und nur an Wortgrenzen: "Euro" darf nicht "PayPal Europe"
treffen. Zwei Moeglichkeiten heisst raten, und dann passiert nichts.

Angefasst werden nur Buchungen OHNE Kategorie – sonst ueberschriebe der Knopf
stillschweigend, was von Hand zugeordnet wurde. Ein Uebertrag ist kein Kandidat
mehr, ein zweiter Klick verschiebt das Geld also nicht erneut.

Zwei Dinge fielen beim Testen auf und sind mit behoben:

- Ein fehlgeschlagener Uebertrag verwarf per rollback() auch alles, was im
  selben Durchlauf schon gelungen war. Jetzt haelt ein Rettungspunkt je
  Uebertrag den Schaden bei dem einen.
- test_neutral_category nahm VORHANDENE Kategorien und stellte eine davon auf
  "nicht einrechnen". Benutzten andere Buchungen sie, fielen die mit heraus und
  die Rechnung ging um deren Summe daneben – ohne Bezug zum Gepruefften. Der
  Test legt sich seine Kategorien jetzt selbst an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:02:04 +02:00

482 lines
19 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api";
import { useSettings } from "../settings";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import Icon from "../components/Icon";
import IconChip from "../components/IconChip";
import Money from "../components/Money";
import Modal from "../components/Modal";
import IconPicker, { ACCOUNT_ICONS } from "../components/IconPicker";
import { centsToInput, formatDate, formatMoney, parseAmountToCents } from "../format";
const ACCOUNT_TYPES = ["girokonto", "sparkonto", "bargeld", "kreditkarte", "depot", "sonstiges"];
const TYPE_ICON = {
girokonto: "landmark",
sparkonto: "piggy-bank",
bargeld: "wallet",
kreditkarte: "credit-card",
depot: "trending-up",
sonstiges: "wallet",
};
function emptyForm() {
return {
name: "",
type: "girokonto",
currency: "EUR",
icon: "landmark",
color: "#3f51b5",
opening_balance: "",
opening_date: "",
identifiers: [],
};
}
// Globale Transfer-Schlagwörter: Text im Buchungstext -> Transfer. Das Zielkonto
// wird über die Erkennungs-Texte je Konto (Identifikator-Art „Text") erraten.
function TransferKeywords({ accounts }) {
const { t } = useTranslation();
const toast = useToast();
const [words, setWords] = useState([]);
const [input, setInput] = useState("");
const [amount, setAmount] = useState("");
const [target, setTarget] = useState("");
const [busy, setBusy] = useState(false);
// Was beim nachträglichen Anwenden übersprungen wurde, samt Grund.
const [uebersprungen, setUebersprungen] = useState([]);
const confirm = useConfirm();
const accName = (id) => accounts.find((a) => a.id === id)?.name;
// Regeln greifen beim Import. Alles, was vorher hereinkam – oder bevor es die
// Regel gab –, liegt noch ohne Kategorie da; bei einer Bankanbindung dauernd.
// Erst zeigen, dann tun: Ein Übertrag verschiebt Geld.
async function nachtraeglichAnwenden() {
setBusy(true);
try {
const vor = await api.applyRules(true);
if (!vor.kategorien && !vor.uebertraege) {
toast(t("categories.applyNothing"));
setUebersprungen(vor.uebersprungen || []);
return;
}
if (!(await confirm({
title: t("categories.applyRules"),
message: t("categories.applyPreview", {
kategorien: vor.kategorien, uebertraege: vor.uebertraege,
}),
confirmLabel: t("categories.applyRules"),
}))) return;
const r = await api.applyRules(false);
toast(t("categories.applyDone", {
kategorien: r.kategorien, uebertraege: r.uebertraege,
}));
setUebersprungen(r.uebersprungen || []);
} catch (err) {
toast(err.message, "error");
} finally {
setBusy(false);
}
}
async function laden() {
try { setWords(await api.listTransferKeywords()); }
catch (err) { toast(err.message, "error"); }
}
useEffect(() => { laden(); }, []);
async function hinzufuegen() {
const w = input.trim();
if (!w) return;
setBusy(true);
try {
const body = { word: w };
const c = parseAmountToCents(amount);
if (c) body.amount_cents = c;
if (target) body.target_account_id = Number(target);
await api.addTransferKeyword(body);
setInput("");
setAmount("");
setTarget("");
await laden();
} catch (err) {
toast(err.message, "error");
} finally {
setBusy(false);
}
}
async function entfernen(id) {
try { await api.deleteKeyword(id); setWords((ws) => ws.filter((x) => x.id !== id)); }
catch (err) { toast(err.message, "error"); }
}
return (
<div className="card" style={{ marginTop: "var(--sp-4)" }}>
<h2>{t("transferRules.title")}</h2>
<p className="muted small">{t("transferRules.hint")}</p>
<div className="kw-add">
<input value={input} placeholder={t("transferRules.wordPlaceholder")}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }} />
<input value={amount} inputMode="decimal" placeholder={t("categories.keywordAmount")}
style={{ flex: "0 0 110px", minWidth: 0, marginTop: 0 }}
onChange={(e) => setAmount(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }} />
<select value={target} onChange={(e) => setTarget(e.target.value)}
style={{ flex: "0 0 160px", minWidth: 0, marginTop: 0 }}>
<option value="">{t("transferRules.autoTarget")}</option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
<button className="btn" onClick={hinzufuegen} disabled={busy || !input.trim()}>
<Icon name="plus" size={15} />
</button>
</div>
<div className="mail-actions" style={{ marginTop: "var(--sp-2)" }}>
<button className="btn sm" disabled={busy} onClick={nachtraeglichAnwenden}>
{t("categories.applyRules")}
</button>
<span className="muted small">{t("categories.applyRulesHint")}</span>
</div>
{uebersprungen.length > 0 && (
<div className="alert warn" style={{ display: "block", marginTop: "var(--sp-2)" }}>
<strong className="small">{t("categories.applySkipped")}</strong>
<ul className="muted small" style={{ paddingLeft: "1.2em", margin: 0 }}>
{uebersprungen.map((m, i) => (
<li key={i} className="selectable">{m.datum} · {m.text} — {m.grund}</li>
))}
</ul>
</div>
)}
{words.length === 0 ? (
<div className="muted small">{t("transferRules.empty")}</div>
) : (
<div className="kw-chips">
{words.map((w) => (
<span className="kw-chip" key={w.id}>
{w.word}
{w.amount_cents ? <span className="kw-amount">= {centsToInput(w.amount_cents)}</span> : null}
<span className="kw-amount">→ {w.target_account_id ? (accName(w.target_account_id) || "?") : t("transferRules.autoTarget")}</span>
<button type="button" title={t("common.delete")} onClick={() => entfernen(w.id)}>
<Icon name="close" size={13} />
</button>
</span>
))}
</div>
)}
</div>
);
}
export default function Accounts() {
const { t } = useTranslation();
const { baseCurrency } = useSettings();
const confirm = useConfirm();
const toast = useToast();
const [accounts, setAccounts] = useState([]);
const [editing, setEditing] = useState(null); // Account-Objekt oder null
const [form, setForm] = useState(null); // Formularzustand oder null (geschlossen)
const [busy, setBusy] = useState(false);
async function saldoUebernehmen(a) {
if (!(await confirm({
title: t("accounts.adoptBankBalance"),
message: t("accounts.adoptConfirm", {
bank: formatMoney(a.bank_balance_cents, a.currency),
diff: formatMoney(a.balance_cents - a.bank_balance_cents, a.currency),
}),
confirmLabel: t("accounts.adoptBankBalance"),
}))) return;
try {
await api.adoptBankBalance(a.id);
toast(t("accounts.adopted"));
laden();
} catch (e) {
toast(e.message, "error");
}
}
async function laden() {
try {
setAccounts(await api.listAccounts());
} catch (err) {
toast(err.message, "error");
}
}
useEffect(() => { laden(); }, []);
function openNew() {
setEditing(null);
setForm(emptyForm());
}
function openEdit(acc) {
setEditing(acc);
setForm({
name: acc.name,
type: acc.type,
currency: acc.currency,
icon: acc.icon,
color: acc.color,
opening_balance: centsToInput(acc.opening_balance_cents),
opening_date: acc.opening_date || "",
identifiers: acc.identifiers.map((i) => ({ kind: i.kind, value: i.value })),
});
}
function setType(type) {
// Icon mitziehen, solange der Nutzer das Standard-Icon des alten Typs hatte.
setForm((f) => ({
...f,
type,
icon: f.icon === TYPE_ICON[f.type] ? TYPE_ICON[type] : f.icon,
}));
}
async function speichern(e) {
e.preventDefault();
setBusy(true);
const body = {
name: form.name.trim(),
type: form.type,
currency: form.currency,
icon: form.icon,
color: form.color,
opening_balance_cents: parseAmountToCents(form.opening_balance) ?? 0,
opening_date: form.opening_date || null,
identifiers: form.identifiers
.filter((i) => i.value.trim())
.map((i) => ({ kind: i.kind, value: i.value.trim() })),
};
try {
if (editing) await api.updateAccount(editing.id, body);
else await api.createAccount(body);
setForm(null);
toast(t("settings.saved"));
laden();
} catch (err) {
toast(err.message, "error");
} finally {
setBusy(false);
}
}
// Reihenfolge per Pfeil: die Liste stand sonst in der Anlegereihenfolge.
// Optimistisch umsortieren, damit die Liste nicht bei jedem Klick springt.
async function verschiebe(index, richtung) {
const ziel = index + richtung;
if (ziel < 0 || ziel >= accounts.length) return;
const neu = [...accounts];
[neu[index], neu[ziel]] = [neu[ziel], neu[index]];
setAccounts(neu);
try {
await api.setAccountOrder(neu.map((a) => a.id));
} catch (e) {
toast(e.message, "error");
laden();
}
}
async function loeschen(acc) {
if (!(await confirm({ title: t("common.delete"), message: t("accounts.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
try {
await api.deleteAccount(acc.id);
laden();
} catch (err) {
toast(err.message, "error");
}
}
const total = accounts.reduce((s, a) => s + a.balance_base_cents, 0);
return (
<div>
<div className="page-head">
<h1>{t("accounts.title")}</h1>
<button className="btn primary" onClick={openNew}>
<Icon name="plus" size={16} /> {t("accounts.new")}
</button>
</div>
{accounts.length === 0 ? (
<div className="card"><div className="empty-state">{t("accounts.empty")}</div></div>
) : (
<div className="card">
<div className="account-total">
<span className="muted">{t("accounts.total")}</span>
<strong>{formatMoney(total, baseCurrency)}</strong>
</div>
<div className="account-list">
{accounts.map((a, i) => (
<div className="account-row" key={a.id}>
<IconChip name={a.icon} color={a.color} />
<div className="named-text grow">
<div><strong>{a.name}</strong></div>
<div className="named-sub">
{t(`accountTypes.${a.type}`)} · {a.currency}
{/* „1 Nr." sagte niemandem, was gemeint ist: die Kennungen, an denen
Certain eine Auszugszeile diesem Konto zuordnet. */}
{a.identifiers.length > 0
&& ` · ${t("accounts.identShort", { count: a.identifiers.length })}`}
</div>
{/* Bei angebundenen Konten der Saldo, den die Bank fuehrt.
Er ersetzt die eigene Rechnung nicht, er prueft sie: Eine
Abweichung heisst, dass eine Buchung fehlt oder doppelt
ist – sonst faellt das erst beim Vergleich von Hand auf. */}
{a.bank_balance_cents != null && (
<div className="named-sub">
{a.bank_balance_cents === a.balance_cents ? (
<span className="saldo-ok">
{t("accounts.bankBalanceMatches")}
{a.bank_balance_at ? ` · ${formatDate(a.bank_balance_at)}` : ""}
</span>
) : (
<span className="saldo-abweichung">
{t("accounts.bankBalanceDiffers", {
bank: formatMoney(a.bank_balance_cents, a.currency),
diff: formatMoney(a.balance_cents - a.bank_balance_cents,
a.currency),
})}{" "}
{/* Die Bank kennt den wahren Stand. Uebernehmen legt
die Differenz in den Anfangsbestand – Buchungen
werden nicht angefasst. */}
<button type="button" className="btn sm"
onClick={() => saldoUebernehmen(a)}>
{t("accounts.adoptBankBalance")}
</button>
</span>
)}
</div>
)}
</div>
<Money cents={a.balance_cents} currency={a.currency}
baseCents={a.balance_base_cents} baseCurrency={baseCurrency} />
<div className="row-actions">
<button className="btn sm ghost" title={t("accounts.moveUp")}
disabled={i === 0} onClick={() => verschiebe(i, -1)}>
<Icon name="chevron-up" size={15} />
</button>
<button className="btn sm ghost" title={t("accounts.moveDown")}
disabled={i === accounts.length - 1} onClick={() => verschiebe(i, 1)}>
<Icon name="chevron-down" size={15} />
</button>
<button className="btn sm ghost" title={t("common.edit")} onClick={() => openEdit(a)}>
<Icon name="edit" size={15} />
</button>
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschen(a)}>
<Icon name="trash" size={15} />
</button>
</div>
</div>
))}
</div>
</div>
)}
<TransferKeywords accounts={accounts} />
{form && (
<Modal
title={editing ? t("accounts.edit") : t("accounts.new")}
onClose={() => setForm(null)}
footer={
<>
<button className="btn" onClick={() => setForm(null)}>{t("common.cancel")}</button>
<button className="btn primary" form="account-form" type="submit" disabled={busy}>
{t("common.save")}
</button>
</>
}
>
<form id="account-form" onSubmit={speichern}>
<label>
<span>{t("accounts.name")}</span>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input autoFocus value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</label>
<div className="row">
<label className="grow">
<span>{t("accounts.type")}</span>
<select value={form.type} onChange={(e) => setType(e.target.value)}>
{ACCOUNT_TYPES.map((tp) => (
<option key={tp} value={tp}>{t(`accountTypes.${tp}`)}</option>
))}
</select>
</label>
<label style={{ minWidth: 120 }}>
<span>{t("accounts.currency")}</span>
<select value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value })}>
<option value="EUR">EUR</option>
<option value="CHF">CHF</option>
</select>
</label>
</div>
<div className="row">
<label className="grow">
<span>{t("accounts.openingBalance")} ({form.currency})</span>
<input inputMode="decimal" value={form.opening_balance}
onChange={(e) => setForm({ ...form, opening_balance: e.target.value })} placeholder="0,00" />
</label>
<label style={{ minWidth: 150 }}>
<span>{t("accounts.openingDate")}</span>
<input type="date" value={form.opening_date}
onChange={(e) => setForm({ ...form, opening_date: e.target.value })} />
</label>
</div>
<p className="muted small">{t("accounts.openingDateHint")}</p>
<label>
<span>{t("accounts.icon")}</span>
<IconPicker icons={ACCOUNT_ICONS} value={form.icon} color={form.color}
onChange={(name) => setForm({ ...form, icon: name })} />
</label>
<label>
<span>{t("accounts.color")}</span>
<div className="color-row">
<input type="color" value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} />
<IconChip name={form.icon} color={form.color} />
</div>
</label>
<label style={{ marginBottom: 6 }}><span>{t("accounts.identifiers")}</span></label>
{form.identifiers.map((ident, idx) => (
<div className="row" key={idx} style={{ marginBottom: 6 }}>
<select style={{ flex: "0 0 150px", width: "auto" }} value={ident.kind}
onChange={(e) => {
const next = [...form.identifiers];
next[idx] = { ...ident, kind: e.target.value };
setForm({ ...form, identifiers: next });
}}>
<option value="iban">IBAN</option>
<option value="account_no">{t("identifierKinds.account_no")}</option>
<option value="card_no">{t("identifierKinds.card_no")}</option>
<option value="text">{t("identifierKinds.text")}</option>
</select>
<input className="grow" style={{ minWidth: 0 }} value={ident.value}
placeholder={ident.kind === "text" ? t("accounts.textIdentPlaceholder") : "DE.. / Nr."}
onChange={(e) => {
const next = [...form.identifiers];
next[idx] = { ...ident, value: e.target.value };
setForm({ ...form, identifiers: next });
}} />
<button type="button" className="btn sm ghost"
onClick={() => setForm({ ...form, identifiers: form.identifiers.filter((_, i) => i !== idx) })}>
<Icon name="trash" size={15} />
</button>
</div>
))}
{form.identifiers.some((i) => i.kind === "text" && i.value.trim().length > 0
&& i.value.trim().length < 6) && (
<p className="muted small">{t("accounts.shortIdentWarning")}</p>
)}
<button type="button" className="btn sm"
onClick={() => setForm({ ...form, identifiers: [...form.identifiers, { kind: "iban", value: "" }] })}>
<Icon name="plus" size={15} /> {t("accounts.addIdentifier")}
</button>
</form>
</Modal>
)}
</div>
);
}