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
+17
View File
@@ -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 = (
+133
View File
@@ -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)}
+69
View File
@@ -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
+17 -5
View File
@@ -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):