From f2c1ab7dec750947879efecd083539d680077433 Mon Sep 17 00:00:00 2001 From: Guido Date: Thu, 27 Aug 2026 16:02:04 +0200 Subject: [PATCH] feat: Regeln nachtraeglich auf Buchungen ohne Kategorie anwenden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/routers/categories.py | 17 ++++ backend/app/services/regeln.py | 133 +++++++++++++++++++++++++ backend/tests/test_keywords.py | 69 +++++++++++++ backend/tests/test_neutral_category.py | 22 +++- web/src/api.js | 4 + web/src/locales/de.json | 13 ++- web/src/locales/en.json | 13 ++- web/src/pages/Accounts.jsx | 55 +++++++++- 8 files changed, 314 insertions(+), 12 deletions(-) create mode 100644 backend/app/services/regeln.py diff --git a/backend/app/routers/categories.py b/backend/app/routers/categories.py index cda7ff0..a1c1ee8 100644 --- a/backend/app/routers/categories.py +++ b/backend/app/routers/categories.py @@ -379,6 +379,23 @@ def _transfer_keyword_out(rule: CategorizationRule) -> TransferKeywordOut: ) +@router.post("/keywords/anwenden") +def apply_rules(vorschau: bool = True, db: Session = Depends(get_db), + user: User = Depends(get_current_user)) -> dict: + """Regeln nachträglich auf Buchungen ohne Kategorie anwenden. + + Regeln greifen sonst nur beim Import. Alles, was vorher hereinkam – oder + hereinkam, bevor es die Regel gab –, bleibt liegen; bei einer Bankanbindung + passiert genau das dauernd. + + Mit ``vorschau`` wird nichts geändert. Erst zeigen, dann tun: Ein Übertrag + verschiebt Geld, und das gehört nicht hinter einen unbeschrifteten Knopf. + """ + from ..services import regeln + + return regeln.wende_an(db, user, nur_vorschau=vorschau) + + @router.get("/transfer-keywords", response_model=list[TransferKeywordOut]) def list_transfer_keywords(db: Session = Depends(get_db), user: User = Depends(get_current_user)): rules = ( diff --git a/backend/app/services/regeln.py b/backend/app/services/regeln.py new file mode 100644 index 0000000..ef002c3 --- /dev/null +++ b/backend/app/services/regeln.py @@ -0,0 +1,133 @@ +"""Regeln nachträglich auf Buchungen ohne Kategorie anwenden. + +Regeln greifen beim Import. Alles, was vorher hereinkam – oder hereinkam, +bevor es die Regel gab –, bleibt liegen. Genau das passiert bei einer +Bankanbindung dauernd: Die Umsätze kommen von selbst, die Regel entsteht +später. + +Übertrags-Regeln laufen durch denselben Dienst wie jeder andere Übertrag. +Er lehnt ab, wo die Gegenseite schon gebucht ist; solche Buchungen bleiben +unangetastet und werden gemeldet. Geraten wird nichts. +""" + +from __future__ import annotations + +from sqlalchemy.orm import Session + +from ..models import ( + Account, + CategorizationRule, + Category, + CategoryGroup, + CategoryKind, + Transaction, + TxnType, + User, +) +from . import csv_import, uebertrag_buchen +from .uebertrag_buchen import UebertragFehler + +# Mehr als das ist keine Nachbesserung mehr, sondern ein Umbau – und der +# gehoert nicht hinter einen Knopf. +MAX_BUCHUNGEN = 2000 + + +def _ziel(db: Session, user: User, regel: CategorizationRule, + txn: Transaction) -> Account | None: + """Zielkonto einer Übertrags-Regel: fest gewählt oder aus dem Text erraten. + + Erraten wird an Wortgrenzen über die Erkennungs-Texte der Konten – dieselbe + Prüfung wie beim Verbinden. „Euro" darf nicht „PayPal Europe" treffen. + """ + if regel.target_account_id: + konto = db.get(Account, regel.target_account_id) + return konto if konto and konto.owner_id == user.id else None + + from .uebertraege import _kontobezeichner, _trifft_als_wort + + from .csv_import import normalize_text + + # Reihenfolge beachten: Gesucht wird das WORT im Text, nicht umgekehrt. + text = normalize_text(f"{txn.payee or ''} {txn.description or ''}") + treffer = [ + konto_id + for konto_id, woerter in _kontobezeichner(db, user).items() + if konto_id != txn.account_id + and any(_trifft_als_wort(w, text) for w in woerter) + ] + # Nur bei GENAU einem Treffer. Zwei Möglichkeiten heisst raten. + if len(treffer) != 1: + return None + return db.get(Account, treffer[0]) + + +def wende_an(db: Session, user: User, nur_vorschau: bool = True) -> dict: + """Regeln auf alle Buchungen ohne Kategorie anwenden. + + Mit ``nur_vorschau`` wird nichts geändert – dann steht nur da, was + passieren würde. + """ + regeln = sorted( + db.query(CategorizationRule).filter(CategorizationRule.owner_id == user.id), + key=csv_import._rule_sort_key, + reverse=True, + ) + if not regeln: + return {"kategorien": 0, "uebertraege": 0, "uebersprungen": [], "offen": 0} + + art_je_kategorie = { + cid: kind + for cid, kind in db.query(Category.id, CategoryGroup.kind) + .join(CategoryGroup, CategoryGroup.id == Category.group_id) + .filter(Category.owner_id == user.id) + } + offen = ( + db.query(Transaction) + .filter(Transaction.owner_id == user.id, + Transaction.category_id.is_(None), + Transaction.type != TxnType.transfer) + .order_by(Transaction.date, Transaction.id) + .limit(MAX_BUCHUNGEN) + .all() + ) + + kategorien = uebertraege = 0 + uebersprungen: list[dict] = [] + for txn in offen: + art = (CategoryKind.income if txn.type == TxnType.income + else CategoryKind.expense) + regel = csv_import._match_rule( + regeln, txn.payee or "", txn.description or "", txn.amount_cents, + art, art_je_kategorie) + if regel is None: + continue + + text = txn.payee or txn.description or "ohne Text" + if regel.is_transfer: + ziel = _ziel(db, user, regel, txn) + if ziel is None: + uebersprungen.append({ + "text": text, "datum": txn.date.isoformat(), + "grund": "kein eindeutiges Zielkonto"}) + continue + if nur_vorschau: + uebertraege += 1 + continue + # Je Uebertrag ein eigener Rettungspunkt: Ein einfaches rollback() + # verwuerfe auch alles, was in diesem Durchlauf schon gelungen ist. + try: + with db.begin_nested(): + uebertrag_buchen.aus_buchung(db, user, txn, ziel) + uebertraege += 1 + except UebertragFehler as e: + uebersprungen.append({"text": text, "datum": txn.date.isoformat(), + "grund": str(e)}) + elif regel.category_id: + if not nur_vorschau: + txn.category_id = regel.category_id + kategorien += 1 + + if not nur_vorschau: + db.commit() + return {"kategorien": kategorien, "uebertraege": uebertraege, + "uebersprungen": uebersprungen, "offen": len(offen)} diff --git a/backend/tests/test_keywords.py b/backend/tests/test_keywords.py index 98d6a8f..b050640 100644 --- a/backend/tests/test_keywords.py +++ b/backend/tests/test_keywords.py @@ -3,6 +3,7 @@ längstes gewinnt, Kind-Filter, Korrektur-Erkennung/Flagging, keine Rückwirkung import io import json +import secrets def _account(admin, **kw): @@ -350,3 +351,71 @@ def test_same_word_multiple_amounts(admin): json={"word": "sv gruppe", "amount_cents": 500}) assert doppelt.status_code == 201 assert len(admin.get(f"/api/categories/{mensa['id']}/keywords").json()) == len(worte) + + +def _kto(admin, **kw): + body = {"name": f"K {secrets.token_hex(3)}", "type": "girokonto", + "currency": "CHF", "opening_balance_cents": 0} + body.update(kw) + return admin.post("/api/accounts", json=body).json() + + +def test_regeln_lassen_sich_nachtraeglich_anwenden(admin): + """Regeln greifen sonst nur beim Import. Bei einer Bankanbindung kommen die + Umsaetze von selbst und die Regel entsteht spaeter – dann bleibt alles + liegen, was vorher schon da war.""" + quelle = _kto(admin, name=f"Revolut {secrets.token_hex(3)}") + pocket = _kto(admin, name=f"Sparen Pocket {secrets.token_hex(3)}") + kennung = f"to chf mb:{secrets.token_hex(6)}" + + # Drei Aufrundungen, wie Revolut sie liefert – ohne Kategorie. + ids = [] + for cent in (100, 50, 34): + ids.append(admin.post("/api/transactions", json={ + "type": "expense", "date": "2026-08-25", "account_id": quelle["id"], + "amount_cents": cent, "payee": kennung.upper()}).json()["id"]) + + r = admin.post("/api/categories/transfer-keywords", json={ + "word": kennung, "target_account_id": pocket["id"]}) + assert r.status_code == 201, r.text + + # Erst zeigen, nichts tun. Die Zahlen gelten fuer ALLE offenen Buchungen + # des Nutzers, deshalb wird hier nur geprueft, dass nichts passiert ist. + vor = admin.post("/api/categories/keywords/anwenden", params={"vorschau": True}).json() + assert vor["uebertraege"] >= 3 + assert admin.get(f"/api/accounts/{pocket['id']}").json()["balance_cents"] == 0 + for txn_id in ids: + assert admin.get(f"/api/transactions/{txn_id}").json()["type"] == "expense" + + # Dann wirklich. + admin.post("/api/categories/keywords/anwenden", params={"vorschau": False}) + assert admin.get(f"/api/accounts/{pocket['id']}").json()["balance_cents"] == 184 + assert admin.get(f"/api/accounts/{quelle['id']}").json()["balance_cents"] == -184 + for txn_id in ids: + assert admin.get(f"/api/transactions/{txn_id}").json()["type"] == "transfer" + + # Ein zweiter Lauf ruehrt sie nicht noch einmal an: Ein Uebertrag ist kein + # Kandidat mehr, sonst verschoebe jeder Klick das Geld erneut. + admin.post("/api/categories/keywords/anwenden", params={"vorschau": False}) + assert admin.get(f"/api/accounts/{pocket['id']}").json()["balance_cents"] == 184 + + +def test_nachtraegliches_anwenden_ruehrt_kategorisiertes_nicht_an(admin): + """Nur Buchungen OHNE Kategorie – sonst überschriebe der Knopf stillschweigend + das, was man von Hand zugeordnet hat.""" + konto = _kto(admin) + gruppen = admin.get("/api/categories").json() + kat = next(c["id"] for g in gruppen if g["kind"] == "expense" + for c in g["categories"]) + wort = f"marke{secrets.token_hex(4)}" + txn = admin.post("/api/transactions", json={ + "type": "expense", "date": "2026-08-25", "account_id": konto["id"], + "amount_cents": 999, "payee": wort, "category_id": kat}).json() + + andere = next(c["id"] for g in gruppen if g["kind"] == "expense" + for c in g["categories"] if c["id"] != kat) + admin.post(f"/api/categories/{andere}/keywords", + json={"pattern": wort, "match_field": "any"}) + + admin.post("/api/categories/keywords/anwenden", params={"vorschau": False}) + assert admin.get(f"/api/transactions/{txn['id']}").json()["category_id"] == kat diff --git a/backend/tests/test_neutral_category.py b/backend/tests/test_neutral_category.py index da6a70f..a11b07a 100644 --- a/backend/tests/test_neutral_category.py +++ b/backend/tests/test_neutral_category.py @@ -10,12 +10,24 @@ import datetime def _expense_cats(admin, n=2): + """EIGENE Kategorien anlegen, keine vorhandenen nehmen. + + Der Test stellt eine davon spaeter auf "nicht einrechnen". Nimmt er eine + Kategorie, die andere Buchungen schon benutzen, fallen die mit heraus – und + die Rechnung geht um deren Summe daneben, ohne dass es mit dem zu tun hat, + was hier geprueft wird. + """ + import secrets + + gruppe = next(g for g in admin.get("/api/categories").json() + if g["kind"] == "expense") out = [] - for g in admin.get("/api/categories").json(): - if g["kind"] == "expense": - out += g["categories"] - assert len(out) >= n - return out[:n] + for i in range(n): + r = admin.post("/api/categories", json={ + "group_id": gruppe["id"], "name": f"Neutral-Test {i} {secrets.token_hex(3)}"}) + assert r.status_code == 201, r.text + out.append(r.json()) + return out def _ausgaben(admin): diff --git a/web/src/api.js b/web/src/api.js index 4b13883..023d4b4 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -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) => diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 0014a8f..1a89651 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -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", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index d50a764..f69eafa 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -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", diff --git a/web/src/pages/Accounts.jsx b/web/src/pages/Accounts.jsx index 73fbee7..da8cbeb 100644 --- a/web/src/pages/Accounts.jsx +++ b/web/src/pages/Accounts.jsx @@ -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 }) { +
+ + {t("categories.applyRulesHint")} +
+ {uebersprungen.length > 0 && ( +
+ {t("categories.applySkipped")} +
    + {uebersprungen.map((m, i) => ( +
  • {m.datum} · {m.text} — {m.grund}
  • + ))} +
+
+ )} {words.length === 0 ? (
{t("transferRules.empty")}
) : ( @@ -265,7 +315,10 @@ export default function Accounts() {
{a.name}
{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 })}`}
{/* Bei angebundenen Konten der Saldo, den die Bank fuehrt. Er ersetzt die eigene Rechnung nicht, er prueft sie: Eine