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