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>
This commit is contained in:
Guido
2026-08-27 16:02:04 +02:00
co-authored by Claude Opus 5
parent 1b47e5abe1
commit f2c1ab7dec
8 changed files with 314 additions and 12 deletions
+4
View File
@@ -224,6 +224,10 @@ export const api = {
request(`/transactions/${id}/transfer`, {
method: "POST", body: { to_account_id: toAccountId, trotzdem },
}),
// Regeln nachträglich auf Buchungen ohne Kategorie anwenden. `vorschau`
// ändert nichts – erst zeigen, dann tun.
applyRules: (vorschau = true) =>
request(`/categories/keywords/anwenden?vorschau=${vorschau}`, { method: "POST" }),
transactionLinks: () => request("/transactions/links"),
searchTransfers: () => request("/uebertraege/suchen", { method: "POST" }),
connectTransfer: (a_id, b_id) =>
+10 -3
View File
@@ -164,7 +164,6 @@
"identifiers": "Erkennungs-Nummern (IBAN etc.)",
"addIdentifier": "Nummer hinzufügen",
"textIdentPlaceholder": "z. B. Sparkonto, Tagesgeld",
"identShort": "Nr.",
"total": "Gesamt",
"empty": "Noch keine Konten. Lege dein erstes Konto an.",
"deleteConfirm": "Dieses Konto und alle zugehörigen Buchungen löschen?",
@@ -176,7 +175,9 @@
"bankBalanceDiffers": "Bank: {{bank}} · Certain weicht um {{diff}} ab",
"adoptBankBalance": "Saldo der Bank übernehmen",
"adoptConfirm": "Der Saldo von {{bank}} wird als richtig übernommen. Die Differenz von {{diff}} wandert in den Anfangsbestand – sie steht für Bewegungen, die Certain nie gesehen hat. Deine Buchungen bleiben unverändert.",
"adopted": "Saldo übernommen."
"adopted": "Saldo übernommen.",
"identShort_one": "{{count}} Kennung",
"identShort_other": "{{count}} Kennungen"
},
"accountTypes": {
"girokonto": "Girokonto",
@@ -226,7 +227,13 @@
"searchPlaceholder": "Kategorie suchen …",
"excludedFromStats": "Nicht in Auswertungen einrechnen",
"excludedHint": "Für Umbuchungen zwischen eigenen Konten: Der Kontostand ändert sich, aber Budget, Dashboard und Statistik lassen diese Buchungen außen vor – das Geld ist ja nicht ausgegeben.",
"excludedShort": "neutral"
"excludedShort": "neutral",
"applyRules": "Regeln nachträglich anwenden",
"applyRulesHint": "Regeln greifen beim Import. Alles, was vorher hereinkam – oder bevor es die Regel gab –, liegt noch ohne Kategorie da. Hier holst du das nach.",
"applyPreview": "{{kategorien}} bekämen eine Kategorie, {{uebertraege}} würden zu Überträgen. Jetzt anwenden?",
"applyNothing": "Keine Buchung ohne Kategorie passt auf eine Regel.",
"applyDone": "{{kategorien}} zugeordnet, {{uebertraege}} als Übertrag gebucht.",
"applySkipped": "Übersprungen, weil unklar"
},
"transactions": {
"title": "Buchungen",
+10 -3
View File
@@ -164,7 +164,6 @@
"identifiers": "Identifiers (IBAN etc.)",
"addIdentifier": "Add identifier",
"textIdentPlaceholder": "e.g. Savings, Deposit",
"identShort": "id(s)",
"total": "Total",
"empty": "No accounts yet. Create your first account.",
"deleteConfirm": "Delete this account and all its transactions?",
@@ -176,7 +175,9 @@
"bankBalanceDiffers": "Bank: {{bank}} · Certain differs by {{diff}}",
"adoptBankBalance": "Adopt the bank's balance",
"adoptConfirm": "The balance of {{bank}} is taken as correct. The difference of {{diff}} moves into the opening balance – it stands for movements Certain never saw. Your transactions stay unchanged.",
"adopted": "Balance adopted."
"adopted": "Balance adopted.",
"identShort_one": "{{count}} identifier",
"identShort_other": "{{count}} identifiers"
},
"accountTypes": {
"girokonto": "Checking",
@@ -226,7 +227,13 @@
"searchPlaceholder": "Search category …",
"excludedFromStats": "Exclude from reports",
"excludedHint": "For transfers between your own accounts: the balance still changes, but budget, dashboard and statistics leave these out – the money was not spent.",
"excludedShort": "neutral"
"excludedShort": "neutral",
"applyRules": "Apply rules to existing transactions",
"applyRulesHint": "Rules apply on import. Anything that came in earlier – or before the rule existed – still sits there without a category. This catches up.",
"applyPreview": "{{kategorien}} would get a category, {{uebertraege}} would become transfers. Apply now?",
"applyNothing": "No uncategorised transaction matches a rule.",
"applyDone": "{{kategorien}} categorised, {{uebertraege}} booked as transfers.",
"applySkipped": "Skipped because unclear"
},
"transactions": {
"title": "Transactions",
+54 -1
View File
@@ -44,8 +44,42 @@ function TransferKeywords({ accounts }) {
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()); }
@@ -99,6 +133,22 @@ function TransferKeywords({ accounts }) {
<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>
) : (
@@ -265,7 +315,10 @@ export default function Accounts() {
<div><strong>{a.name}</strong></div>
<div className="named-sub">
{t(`accountTypes.${a.type}`)} · {a.currency}
{a.identifiers.length > 0 && ` · ${a.identifiers.length} ${t("accounts.identShort")}`}
{/* „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