diff --git a/backend/app/models.py b/backend/app/models.py index e6d4e1a..1e45cd9 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -66,6 +66,7 @@ class IdentifierKind(str, enum.Enum): iban = "iban" account_no = "account_no" card_no = "card_no" + text = "text" # freier Erkennungs-Text im Buchungstext (Zielkonto bei Transfers) class CategoryKind(str, enum.Enum): @@ -491,6 +492,10 @@ class CategorizationRule(Base): category_id: Mapped[int | None] = mapped_column( ForeignKey("categories.id", ondelete="CASCADE"), nullable=True ) + # Optionale Zusatzbedingung: Regel greift nur bei genau diesem (absoluten) Betrag. + amount_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) + # Wenn True: passende Buchung wird zum Transfer (category_id bleibt leer). + is_transfer: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) source: Mapped[RuleSource] = mapped_column(_enum(RuleSource), nullable=False, default=RuleSource.learned) priority: Mapped[int] = mapped_column(Integer, nullable=False, default=0) hits: Mapped[int] = mapped_column(Integer, nullable=False, default=0) diff --git a/backend/app/routers/accounts.py b/backend/app/routers/accounts.py index b8cc7e7..6a483c6 100644 --- a/backend/app/routers/accounts.py +++ b/backend/app/routers/accounts.py @@ -7,16 +7,21 @@ from sqlalchemy.orm import Session from ..database import get_db from ..deps import get_current_user, get_owned -from ..models import Account, AccountIdentifier, User +from ..models import Account, AccountIdentifier, IdentifierKind, User from ..schemas import AccountCreate, AccountIdentifierOut, AccountOut, AccountUpdate from ..services import balances +from ..services.csv_import import normalize_text from .settings import BASE_CURRENCY_KEY, get_user_setting router = APIRouter(prefix="/accounts", tags=["accounts"]) -def normalize_identifier(value: str) -> str: - """Vereinheitlicht IBAN/Nummer für den Abgleich: Großbuchstaben, nur alphanumerisch.""" +def normalize_identifier(value: str, kind: IdentifierKind) -> str: + """Vereinheitlicht einen Identifikator für den Abgleich. IBAN/Nummer: + Großbuchstaben, nur alphanumerisch. Freier Text: klein, getrimmt (für die + Zielkonto-Erkennung im Buchungstext).""" + if kind == IdentifierKind.text: + return normalize_text(value) return "".join(ch for ch in value.upper() if ch.isalnum()) @@ -49,7 +54,7 @@ def _set_identifiers(acc: Account, items) -> None: acc.identifiers.clear() seen: set[tuple[str, str]] = set() for it in items: - norm = normalize_identifier(it.value) + norm = normalize_identifier(it.value, it.kind) key = (it.kind.value, norm) if not norm or key in seen: continue diff --git a/backend/app/routers/categories.py b/backend/app/routers/categories.py index b985224..f34c301 100644 --- a/backend/app/routers/categories.py +++ b/backend/app/routers/categories.py @@ -20,6 +20,8 @@ from ..schemas import ( KeywordCreate, KeywordOut, KeywordUpdate, + TransferKeywordCreate, + TransferKeywordOut, ) from ..services.csv_import import normalize_text @@ -168,7 +170,7 @@ def delete_category( def _keyword_out(rule: CategorizationRule) -> KeywordOut: - return KeywordOut(id=rule.id, category_id=rule.category_id, word=rule.pattern) + return KeywordOut(id=rule.id, category_id=rule.category_id, word=rule.pattern, amount_cents=rule.amount_cents) def _owned_manual_rule(db: Session, rule_id: int, user: User) -> CategorizationRule: @@ -178,6 +180,30 @@ def _owned_manual_rule(db: Session, rule_id: int, user: User) -> CategorizationR return rule +def _conflict(db: Session, clash: CategorizationRule) -> HTTPException: + """Klare Meldung, wenn ein Schlagwort schon woanders (Kategorie/Transfer) hängt.""" + if clash.is_transfer: + wo = "als Transfer-Regel" + else: + cat = db.get(Category, clash.category_id) + wo = f"der Kategorie „{cat.name if cat else '?'}“" + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Schlagwort ist bereits {wo} zugeordnet.", + ) + + +def _find_any_rule(db: Session, user: User, pattern: str, exclude_id: int | None = None): + q = db.query(CategorizationRule).filter( + CategorizationRule.owner_id == user.id, + CategorizationRule.match_field == MatchField.any, + CategorizationRule.pattern == pattern, + ) + if exclude_id is not None: + q = q.filter(CategorizationRule.id != exclude_id) + return q.first() + + @router.get("/keywords/flagged", response_model=list[FlaggedKeywordOut]) def list_flagged_keywords(db: Session = Depends(get_db), user: User = Depends(get_current_user)): """Manuelle Schlagwörter, die auffällig oft wegkorrigiert werden.""" @@ -196,7 +222,8 @@ def list_flagged_keywords(db: Session = Depends(get_db), user: User = Depends(ge names = {c.id: c.name for c in db.query(Category).filter(Category.owner_id == user.id)} return [ FlaggedKeywordOut( - id=r.id, category_id=r.category_id, category_name=names.get(r.category_id, "?"), + id=r.id, category_id=r.category_id, + category_name="→ Transfer" if r.is_transfer else names.get(r.category_id, "?"), word=r.pattern, applied_count=r.applied_count, overridden_count=r.overridden_count, ) for r in rules @@ -225,27 +252,16 @@ def update_keyword( if not pattern: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort") if pattern != rule.pattern: - clash = ( - db.query(CategorizationRule) - .filter( - CategorizationRule.owner_id == user.id, - CategorizationRule.match_field == MatchField.any, - CategorizationRule.pattern == pattern, - CategorizationRule.id != rule.id, - ) - .first() - ) + clash = _find_any_rule(db, user, pattern, exclude_id=rule.id) if clash is not None: - other = db.get(Category, clash.category_id) - other_name = other.name if other else "?" - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"Schlagwort ist bereits der Kategorie „{other_name}“ zugeordnet.", - ) + raise _conflict(db, clash) rule.pattern = pattern rule.applied_count = 0 rule.overridden_count = 0 rule.confirmed = False + # Betrag nur ändern, wenn mitgeschickt (leere Inline-Wortänderung darf ihn nicht löschen). + if "amount_cents" in payload.model_fields_set: + rule.amount_cents = payload.amount_cents db.commit() db.refresh(rule) return _keyword_out(rule) @@ -285,29 +301,62 @@ def add_keyword( pattern = normalize_text(payload.word)[:255] if not pattern: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort") - existing = ( - db.query(CategorizationRule) - .filter( - CategorizationRule.owner_id == user.id, - CategorizationRule.match_field == MatchField.any, - CategorizationRule.pattern == pattern, - ) - .first() - ) + existing = _find_any_rule(db, user, pattern) if existing is not None: - if existing.category_id == cat.id: - return _keyword_out(existing) # schon vorhanden – unverändert zurück - other = db.get(Category, existing.category_id) - other_name = other.name if other else "?" - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"Schlagwort ist bereits der Kategorie „{other_name}“ zugeordnet.", - ) + if not existing.is_transfer and existing.category_id == cat.id and existing.amount_cents == payload.amount_cents: + return _keyword_out(existing) # schon identisch vorhanden – unverändert zurück + raise _conflict(db, existing) rule = CategorizationRule( owner_id=user.id, match_field=MatchField.any, pattern=pattern, - category_id=cat.id, source=RuleSource.manual, priority=10, + category_id=cat.id, amount_cents=payload.amount_cents, + source=RuleSource.manual, priority=10, ) db.add(rule) db.commit() db.refresh(rule) return _keyword_out(rule) + + +# --- Transfer-Schlagwörter (Text -> Transfer, Zielkonto aus Konto-Texten) ------ + + +def _transfer_keyword_out(rule: CategorizationRule) -> TransferKeywordOut: + return TransferKeywordOut(id=rule.id, word=rule.pattern, amount_cents=rule.amount_cents) + + +@router.get("/transfer-keywords", response_model=list[TransferKeywordOut]) +def list_transfer_keywords(db: Session = Depends(get_db), user: User = Depends(get_current_user)): + rules = ( + db.query(CategorizationRule) + .filter( + CategorizationRule.owner_id == user.id, + CategorizationRule.source == RuleSource.manual, + CategorizationRule.is_transfer.is_(True), + ) + .order_by(CategorizationRule.pattern) + .all() + ) + return [_transfer_keyword_out(r) for r in rules] + + +@router.post("/transfer-keywords", response_model=TransferKeywordOut, status_code=status.HTTP_201_CREATED) +def add_transfer_keyword( + payload: TransferKeywordCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user) +): + pattern = normalize_text(payload.word)[:255] + if not pattern: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort") + existing = _find_any_rule(db, user, pattern) + if existing is not None: + if existing.is_transfer and existing.amount_cents == payload.amount_cents: + return _transfer_keyword_out(existing) + raise _conflict(db, existing) + rule = CategorizationRule( + owner_id=user.id, match_field=MatchField.any, pattern=pattern, + category_id=None, amount_cents=payload.amount_cents, is_transfer=True, + source=RuleSource.manual, priority=10, + ) + db.add(rule) + db.commit() + db.refresh(rule) + return _transfer_keyword_out(rule) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index df93c13..3c48723 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -352,21 +352,35 @@ class ImportCommitIn(BaseModel): class KeywordCreate(BaseModel): word: str = Field(min_length=1, max_length=255) + amount_cents: int | None = Field(default=None, gt=0) class KeywordUpdate(BaseModel): word: str = Field(min_length=1, max_length=255) + amount_cents: int | None = Field(default=None, gt=0) class KeywordOut(BaseModel): id: int category_id: int word: str + amount_cents: int | None = None + + +class TransferKeywordCreate(BaseModel): + word: str = Field(min_length=1, max_length=255) + amount_cents: int | None = Field(default=None, gt=0) + + +class TransferKeywordOut(BaseModel): + id: int + word: str + amount_cents: int | None = None class FlaggedKeywordOut(BaseModel): id: int - category_id: int + category_id: int | None = None category_name: str word: str applied_count: int diff --git a/backend/app/services/csv_import.py b/backend/app/services/csv_import.py index 58ccfbc..9d82e42 100644 --- a/backend/app/services/csv_import.py +++ b/backend/app/services/csv_import.py @@ -19,6 +19,7 @@ from ..models import ( CategorizationRule, Category, CategoryKind, + IdentifierKind, ImportBatch, ImportStatus, ImportTemplate, @@ -146,9 +147,10 @@ def import_hash(account_id: int, d: date | None, amount_cents: int | None, payee def _rule_sort_key(rule: CategorizationRule) -> tuple: - """Reihenfolge der Regelanwendung: längstes Schlagwort zuerst, dann manuelle - vor gelernten, dann höhere Priorität/mehr Treffer.""" + """Reihenfolge der Regelanwendung: Betrags-Regeln (spezifischer) zuerst, dann + längstes Schlagwort, dann manuelle vor gelernten, dann Priorität/Treffer.""" return ( + 1 if rule.amount_cents is not None else 0, len(rule.pattern or ""), 1 if rule.source == RuleSource.manual else 0, rule.priority, @@ -156,32 +158,55 @@ def _rule_sort_key(rule: CategorizationRule) -> tuple: ) -def _apply_rules( +def _pattern_hit(rule: CategorizationRule, p: str, z: str) -> bool: + if rule.match_field == MatchField.payee: + return rule.pattern in p + if rule.match_field == MatchField.purpose: + return rule.pattern in z + return rule.pattern in p or rule.pattern in z # any: Empfänger ODER Zweck + + +def _match_rule( rules: list[CategorizationRule], payee: str, purpose: str, + amount: int | None, allowed_kind: CategoryKind | None, cat_kind: dict[int, CategoryKind], -) -> tuple[int | None, int | None]: - """Erste passende Regel -> (category_id, rule_id). Nur Kategorien, deren Art - (Einnahme/Ausgabe) zur Buchung passt, damit ein Ausgabe-Schlagwort keine - Einnahme falsch einordnet.""" +) -> CategorizationRule | None: + """Erste passende Regel (Muster + optionale Betragsbedingung). Kategorie-Regeln + greifen nur, wenn die Kategorie-Art (Einnahme/Ausgabe) zur Buchung passt; + Transfer-Regeln kennen keine Art.""" p = normalize_text(payee) z = normalize_text(purpose) - for rule in rules: # bereits sortiert (längstes/manuell zuerst) - if not rule.pattern or not rule.category_id: + absamt = abs(amount) if amount is not None else None + for rule in rules: # bereits sortiert (Betrag/längstes/manuell zuerst) + if not rule.pattern: + continue + if rule.amount_cents is not None and (absamt is None or absamt != rule.amount_cents): + continue + if rule.is_transfer: + if _pattern_hit(rule, p, z): + return rule + continue + if not rule.category_id: continue if allowed_kind is not None and cat_kind.get(rule.category_id) != allowed_kind: continue - if rule.match_field == MatchField.payee: - hit = rule.pattern in p - elif rule.match_field == MatchField.purpose: - hit = rule.pattern in z - else: # any: Empfänger ODER Zweck - hit = rule.pattern in p or rule.pattern in z - if hit: - return rule.category_id, rule.id - return None, None + if _pattern_hit(rule, p, z): + return rule + return None + + +def _guess_target_account(text_idents: list[tuple[str, int]], payee: str, purpose: str, source_id: int) -> int | None: + """Zielkonto eines Transfers aus dem Buchungstext raten: längster passender + Erkennungs-Text je Konto gewinnt.""" + hay = f"{normalize_text(payee)} {normalize_text(purpose)}" + best: tuple[int, int] | None = None # (Länge, Konto-ID) + for val, acc_id in text_idents: + if val and acc_id != source_id and val in hay and (best is None or len(val) > best[0]): + best = (len(val), acc_id) + return best[1] if best else None def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict]: @@ -223,13 +248,17 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict """Reichert Kandidaten an: Typ, Auto-Kategorie, interner Transfer, Duplikat. Wird von CSV- UND Beleg-Import genutzt (gleiche Prüf-/Buchungslogik).""" - idents: dict[str, int] = {} - for value_norm, acc_id in ( - db.query(AccountIdentifier.value_norm, AccountIdentifier.account_id) + idents: dict[str, int] = {} # IBAN/Nummer -> Konto (für Transfer via IBAN) + text_idents: list[tuple[str, int]] = [] # freie Erkennungs-Texte -> Konto + for value_norm, acc_id, kind in ( + db.query(AccountIdentifier.value_norm, AccountIdentifier.account_id, AccountIdentifier.kind) .join(Account, Account.id == AccountIdentifier.account_id) .filter(Account.owner_id == user.id) ): - idents[value_norm] = acc_id + if kind == IdentifierKind.text: + text_idents.append((value_norm, acc_id)) + else: + idents[value_norm] = acc_id rules = db.query(CategorizationRule).filter(CategorizationRule.owner_id == user.id).all() rules.sort(key=_rule_sort_key, reverse=True) # längstes/manuell zuerst @@ -266,9 +295,14 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict to_account_id = other else: kind = CategoryKind.expense if ttype == "expense" else CategoryKind.income - category_id, matched_rule_id = _apply_rules( - rules, payee or "", purpose or "", kind, cat_kind - ) + rule = _match_rule(rules, payee or "", purpose or "", amount, kind, cat_kind) + if rule is not None: + matched_rule_id = rule.id + if rule.is_transfer: + ttype = "transfer" + to_account_id = _guess_target_account(text_idents, payee or "", purpose or "", account.id) + else: + category_id = rule.category_id h = import_hash(account.id, d, amount, payee) is_dup = h in existing or h in seen diff --git a/backend/migrations/versions/0007_rule_amount_transfer.py b/backend/migrations/versions/0007_rule_amount_transfer.py new file mode 100644 index 0000000..b9f6d8c --- /dev/null +++ b/backend/migrations/versions/0007_rule_amount_transfer.py @@ -0,0 +1,30 @@ +"""Regeln: optionale Betragsbedingung + Transfer-Regeln. + +Erlaubt Schlagwörter mit fester Betragsbedingung (z. B. „SV = 5.00 → Restaurant") +und Transfer-Regeln (Text → Transfer). Konto-Identifikatoren erlauben zusätzlich +die Art ``text`` (freier Erkennungs-Text) – das ist ein reiner Enum-Wert (VARCHAR), +daher ohne Schemaänderung. + +Revision ID: 0007 +Revises: 0006 +Create Date: 2026-08-19 +""" +from alembic import op +import sqlalchemy as sa + +revision = "0007" +down_revision = "0006" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("categorization_rules") as batch: + batch.add_column(sa.Column("amount_cents", sa.Integer(), nullable=True)) + batch.add_column(sa.Column("is_transfer", sa.Boolean(), nullable=False, server_default=sa.text("0"))) + + +def downgrade() -> None: + with op.batch_alter_table("categorization_rules") as batch: + batch.drop_column("is_transfer") + batch.drop_column("amount_cents") diff --git a/backend/tests/test_keywords.py b/backend/tests/test_keywords.py index e399df3..734fa3a 100644 --- a/backend/tests/test_keywords.py +++ b/backend/tests/test_keywords.py @@ -148,6 +148,52 @@ def test_below_threshold_not_flagged(admin): assert kw["id"] not in [f["id"] for f in admin.get("/api/categories/keywords/flagged").json()] +def test_amount_condition_rule(admin): + giro = _account(admin, name="Giro Amt") + a, _ = _expense_cats(admin, 2) + # „sv" nur bei genau 5.00 -> Kategorie a + r = admin.post(f"/api/categories/{a['id']}/keywords", json={"word": "sv", "amount_cents": 500}) + assert r.status_code == 201, r.text + assert r.json()["amount_cents"] == 500 + + csv = ( + "Datum;Betrag;Empfaenger;Zweck\n" + "01.03.2026;-5,00;SV Restaurant;Mittag\n" # 5.00 -> a + "02.03.2026;-8,00;SV Restaurant;Produkt\n" # anderer Betrag -> keine Regel + ) + rows = _preview_csv(admin, giro["id"], csv, COLMAP) + assert rows[0]["category_id"] == a["id"] + assert rows[1]["category_id"] is None + + +def test_transfer_rule_and_target_guess(admin): + giro = _account(admin, name="Giro TR") + spar = _account(admin, name="Sparkonto TR", type="sparkonto", + identifiers=[{"kind": "text", "value": "Sparen"}]) + assert any(i["kind"] == "text" for i in spar["identifiers"]) + + r = admin.post("/api/categories/transfer-keywords", json={"word": "uebertrag"}) + assert r.status_code == 201, r.text + + csv = ( + "Datum;Betrag;Empfaenger;Zweck\n" + "01.03.2026;-100,00;Max;Uebertrag Sparen\n" # -> Transfer, Ziel = Sparkonto + "02.03.2026;-50,00;Max;Uebertrag woandershin\n" # -> Transfer, kein Ziel erkannt + ) + rows = _preview_csv(admin, giro["id"], csv, COLMAP) + assert rows[0]["type"] == "transfer" + assert rows[0]["to_account_id"] == spar["id"] + assert rows[1]["type"] == "transfer" + assert rows[1]["to_account_id"] is None + + +def test_transfer_keyword_conflicts_with_category_keyword(admin): + a, _ = _expense_cats(admin, 2) + admin.post(f"/api/categories/{a['id']}/keywords", json={"word": "doppelwort"}) + # gleiches Wort als Transfer-Regel -> Konflikt + assert admin.post("/api/categories/transfer-keywords", json={"word": "doppelwort"}).status_code == 409 + + def test_no_retroactive_apply(admin): giro = _account(admin, name="Giro Retro") a, _ = _expense_cats(admin, 2) diff --git a/web/public/favicon.svg b/web/public/favicon.svg index f3d43ab..c0b086e 100644 --- a/web/public/favicon.svg +++ b/web/public/favicon.svg @@ -1,9 +1,6 @@ diff --git a/web/src/api.js b/web/src/api.js index e8f37a9..e30cac7 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -141,13 +141,16 @@ export const api = { updateCategory: (id, body) => request(`/categories/${id}`, { method: "PATCH", body }), deleteCategory: (id) => request(`/categories/${id}`, { method: "DELETE" }), - // Schlagwörter je Kategorie (manuelle Auto-Zuordnung) + Prüfmeldungen + // Schlagwörter je Kategorie (manuelle Auto-Zuordnung, optional mit Betrag) + Prüfmeldungen listKeywords: (categoryId) => request(`/categories/${categoryId}/keywords`), - addKeyword: (categoryId, word) => request(`/categories/${categoryId}/keywords`, { method: "POST", body: { word } }), - updateKeyword: (ruleId, word) => request(`/categories/keywords/${ruleId}`, { method: "PATCH", body: { word } }), + addKeyword: (categoryId, body) => request(`/categories/${categoryId}/keywords`, { method: "POST", body }), + updateKeyword: (ruleId, body) => request(`/categories/keywords/${ruleId}`, { method: "PATCH", body }), deleteKeyword: (ruleId) => request(`/categories/keywords/${ruleId}`, { method: "DELETE" }), flaggedKeywords: () => request("/categories/keywords/flagged"), confirmKeyword: (ruleId) => request(`/categories/keywords/${ruleId}/confirm`, { method: "POST" }), + // Transfer-Schlagwörter (Text -> Transfer, Zielkonto über Konto-Texte) + listTransferKeywords: () => request("/categories/transfer-keywords"), + addTransferKeyword: (body) => request("/categories/transfer-keywords", { method: "POST", body }), // Transaktionen listTransactions: (params = {}) => { diff --git a/web/src/components/BrandMark.jsx b/web/src/components/BrandMark.jsx index 87c65ac..e4039da 100644 --- a/web/src/components/BrandMark.jsx +++ b/web/src/components/BrandMark.jsx @@ -1,33 +1,17 @@ import { APP_NAME } from "../brand"; -// Wort-Bild-Marke für die Seitenleiste. Das Zeichen ist ein Inline-SVG -// (gestapelte Münzen) im Akzentton – bewusst kein Emoji. +// Wort-Bild-Marke für die Seitenleiste. Das Zeichen ist der Münzhaufen aus +// public/favicon.svg (identisch zum Browser-Tab-Icon) – bewusst kein Emoji. export default function BrandMark({ showName = true, size = 26 }) { return (
{t("transferRules.hint")}
+