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 && (