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")} + +
+ )} {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