From c44892ff722a623329aa9e8cce36bbec33e768aa Mon Sep 17 00:00:00 2001 From: Guido Date: Thu, 20 Aug 2026 19:06:24 +0200 Subject: [PATCH] feat: Transfer-Schlagwort mit festem Zielkonto (z. B. REVOLUT -> Konto Revolut) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Transfer-Regel kann direkt ein Zielkonto tragen (target_account_id); ohne -> weiterhin Auto-Erkennung aus dem Buchungstext (Konto-Texte) - Migration 0010; Endpunkt aktualisiert bestehendes Wort statt Konflikt - Import bevorzugt das feste Zielkonto, sonst _guess_target_account - Konten-Seite: Transfer-Erkennung mit Zielkonto-Auswahl + Anzeige am Chip - Test: festes Zielkonto greift ohne Konto-Texte (75 grün) Co-Authored-By: Claude Opus 4.8 --- backend/app/models.py | 5 +++ backend/app/routers/categories.py | 21 +++++++++---- backend/app/schemas.py | 2 ++ backend/app/services/csv_import.py | 6 +++- .../versions/0010_rule_target_account.py | 31 +++++++++++++++++++ backend/tests/test_keywords.py | 21 +++++++++++++ web/src/locales/de.json | 3 +- web/src/locales/en.json | 3 +- web/src/pages/Accounts.jsx | 16 ++++++++-- 9 files changed, 96 insertions(+), 12 deletions(-) create mode 100644 backend/migrations/versions/0010_rule_target_account.py diff --git a/backend/app/models.py b/backend/app/models.py index accf3c0..fc7b6cd 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -551,6 +551,11 @@ class CategorizationRule(Base): 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) + # Optionales festes Zielkonto für Transfer-Regeln. Ohne -> Zielkonto wird aus + # dem Buchungstext (Konto-Erkennungs-Texte) erraten. + target_account_id: Mapped[int | None] = mapped_column( + ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True + ) # Art der Regel: "expense" | "income" | "transfer". Erlaubt, dasselbe Wort je # einmal für eine Ausgabe- UND eine Einnahme-Kategorie zu verwenden (das # Matching filtert ohnehin nach Buchungsart). diff --git a/backend/app/routers/categories.py b/backend/app/routers/categories.py index 9dee713..ab7f67f 100644 --- a/backend/app/routers/categories.py +++ b/backend/app/routers/categories.py @@ -9,7 +9,7 @@ from sqlalchemy.orm import Session from ..database import get_db from ..deps import get_current_user, get_owned -from ..models import CategorizationRule, Category, CategoryGroup, MatchField, RuleSource, User +from ..models import Account, CategorizationRule, Category, CategoryGroup, MatchField, RuleSource, User from ..schemas import ( CategoryCreate, CategoryGroupCreate, @@ -353,7 +353,10 @@ def add_keyword( def _transfer_keyword_out(rule: CategorizationRule) -> TransferKeywordOut: - return TransferKeywordOut(id=rule.id, word=rule.pattern, amount_cents=rule.amount_cents) + return TransferKeywordOut( + id=rule.id, word=rule.pattern, amount_cents=rule.amount_cents, + target_account_id=rule.target_account_id, + ) @router.get("/transfer-keywords", response_model=list[TransferKeywordOut]) @@ -378,15 +381,21 @@ def add_transfer_keyword( pattern = normalize_text(payload.word)[:255] if not pattern: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort") + target_id = None + if payload.target_account_id is not None: + target_id = get_owned(db, Account, payload.target_account_id, user).id + # Gleiches Wort existiert schon als Transfer-Regel -> aktualisieren (Betrag/Zielkonto). existing = _find_any_rule(db, user, pattern, "transfer") 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) + existing.amount_cents = payload.amount_cents + existing.target_account_id = target_id + db.commit() + db.refresh(existing) + return _transfer_keyword_out(existing) rule = CategorizationRule( owner_id=user.id, match_field=MatchField.any, pattern=pattern, category_id=None, amount_cents=payload.amount_cents, is_transfer=True, - kind="transfer", source=RuleSource.manual, priority=10, + target_account_id=target_id, kind="transfer", source=RuleSource.manual, priority=10, ) db.add(rule) db.commit() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 4a1da49..527db17 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -441,12 +441,14 @@ class KeywordOut(BaseModel): class TransferKeywordCreate(BaseModel): word: str = Field(min_length=1, max_length=255) amount_cents: int | None = Field(default=None, gt=0) + target_account_id: int | None = None class TransferKeywordOut(BaseModel): id: int word: str amount_cents: int | None = None + target_account_id: int | None = None class FlaggedKeywordOut(BaseModel): diff --git a/backend/app/services/csv_import.py b/backend/app/services/csv_import.py index f817dd2..28dcb0e 100644 --- a/backend/app/services/csv_import.py +++ b/backend/app/services/csv_import.py @@ -300,7 +300,11 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict 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) + # Festes Zielkonto der Regel bevorzugen, sonst aus dem Text raten. + if rule.target_account_id and rule.target_account_id != account.id: + to_account_id = rule.target_account_id + else: + to_account_id = _guess_target_account(text_idents, payee or "", purpose or "", account.id) else: category_id = rule.category_id diff --git a/backend/migrations/versions/0010_rule_target_account.py b/backend/migrations/versions/0010_rule_target_account.py new file mode 100644 index 0000000..d388174 --- /dev/null +++ b/backend/migrations/versions/0010_rule_target_account.py @@ -0,0 +1,31 @@ +"""Transfer-Regeln: optionales festes Zielkonto. + +Erlaubt, einem Transfer-Schlagwort direkt ein Zielkonto zuzuweisen (z. B. +„REVOLUT" -> Konto Revolut). Ohne Zielkonto wird es weiterhin aus dem +Buchungstext erraten. + +Revision ID: 0010 +Revises: 0009 +Create Date: 2026-08-20 +""" +from alembic import op +import sqlalchemy as sa + +revision = "0010" +down_revision = "0009" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("categorization_rules") as batch: + batch.add_column(sa.Column("target_account_id", sa.Integer(), nullable=True)) + batch.create_foreign_key( + "fk_rule_target_account", "accounts", ["target_account_id"], ["id"], ondelete="SET NULL" + ) + + +def downgrade() -> None: + with op.batch_alter_table("categorization_rules") as batch: + batch.drop_constraint("fk_rule_target_account", type_="foreignkey") + batch.drop_column("target_account_id") diff --git a/backend/tests/test_keywords.py b/backend/tests/test_keywords.py index a7fadf1..1148e16 100644 --- a/backend/tests/test_keywords.py +++ b/backend/tests/test_keywords.py @@ -249,6 +249,27 @@ def test_transfer_rule_and_target_guess(admin): assert rows[1]["to_account_id"] is None +def test_transfer_keyword_fixed_target(admin): + giro = _account(admin, name="Giro FT") + ziel = _account(admin, name="Revolut Konto") + # Transfer-Schlagwort „revolut" MIT festem Zielkonto (ohne Konto-Texte). + r = admin.post("/api/categories/transfer-keywords", + json={"word": "revolut", "target_account_id": ziel["id"]}) + assert r.status_code == 201, r.text + assert r.json()["target_account_id"] == ziel["id"] + + csv = "Datum;Betrag;Empfaenger;Zweck\n01.03.2026;-25,00;Einkauf REVOLUT;Karte\n" + rows = _preview_csv(admin, giro["id"], csv, COLMAP) + assert rows[0]["type"] == "transfer" + assert rows[0]["to_account_id"] == ziel["id"] + + # Erneutes Anlegen desselben Worts aktualisiert das Zielkonto (kein Konflikt). + r2 = admin.post("/api/categories/transfer-keywords", + json={"word": "revolut", "target_account_id": giro["id"]}) + assert r2.status_code == 201, r2.text + assert r2.json()["target_account_id"] == giro["id"] + + def test_same_word_across_kinds_allowed(admin): a, b = _expense_cats(admin, 2) inc = _income_cat(admin) diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 1466f04..2f534a3 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -173,8 +173,9 @@ }, "transferRules": { "title": "Transfer-Erkennung", - "hint": "Buchungen, deren Text eines dieser Wörter enthält, werden als Transfer erkannt. Das Zielkonto wird aus dem Buchungstext über die Erkennungs-Texte je Konto (Konto bearbeiten, Art Text) erraten.", + "hint": "Buchungen, deren Text eines dieser Wörter enthält (z. B. „Revolut“), werden als Transfer gesetzt. Das Zielkonto kannst du fest wählen – oder automatisch aus dem Buchungstext erraten lassen (über die Erkennungs-Texte je Konto: Konto bearbeiten, Art „Text“).", "wordPlaceholder": "Wort (z. B. Übertrag)", + "autoTarget": "automatisch", "empty": "Noch keine Transfer-Schlagwörter." }, "categories": { diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 8b8b7ca..7684c84 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -173,8 +173,9 @@ }, "transferRules": { "title": "Transfer detection", - "hint": "Transactions whose text contains one of these words are recognized as a transfer. The target account is guessed from the booking text via each account's detection texts (edit account, kind Text).", + "hint": "Transactions whose text contains one of these words (e.g. “Revolut”) are set as a transfer. Pick a fixed target account – or let it be guessed automatically from the booking text (via each account's detection texts: edit account, kind “Text”).", "wordPlaceholder": "Word (e.g. Transfer)", + "autoTarget": "automatic", "empty": "No transfer keywords yet." }, "categories": { diff --git a/web/src/pages/Accounts.jsx b/web/src/pages/Accounts.jsx index 8003839..e5dbd9b 100644 --- a/web/src/pages/Accounts.jsx +++ b/web/src/pages/Accounts.jsx @@ -36,13 +36,15 @@ function emptyForm() { // Globale Transfer-Schlagwörter: Text im Buchungstext -> Transfer. Das Zielkonto // wird über die Erkennungs-Texte je Konto (Identifikator-Art „Text") erraten. -function TransferKeywords() { +function TransferKeywords({ accounts }) { const { t } = useTranslation(); const toast = useToast(); const [words, setWords] = useState([]); const [input, setInput] = useState(""); const [amount, setAmount] = useState(""); + const [target, setTarget] = useState(""); const [busy, setBusy] = useState(false); + const accName = (id) => accounts.find((a) => a.id === id)?.name; async function laden() { try { setWords(await api.listTransferKeywords()); } @@ -58,9 +60,11 @@ function TransferKeywords() { const body = { word: w }; const c = parseAmountToCents(amount); if (c) body.amount_cents = c; + if (target) body.target_account_id = Number(target); await api.addTransferKeyword(body); setInput(""); setAmount(""); + setTarget(""); await laden(); } catch (err) { toast(err.message, "error"); @@ -82,9 +86,14 @@ function TransferKeywords() { onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }} /> setAmount(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }} /> + @@ -97,6 +106,7 @@ function TransferKeywords() { {w.word} {w.amount_cents ? = {centsToInput(w.amount_cents)} : null} + → {w.target_account_id ? (accName(w.target_account_id) || "?") : t("transferRules.autoTarget")} @@ -239,7 +249,7 @@ export default function Accounts() { )} - + {form && (