feat: Regeln (Betrag/Transfer/Konto-Texte) + Münz-Icon + pastellgrünes Theme
Regeln: - Schlagwörter mit optionaler Betragsbedingung (z. B. „SV = 5.00 → Restaurant"); Betrags-Regeln gewinnen vor betragslosen - Transfer-Schlagwörter (Text → Transfer) auf der Konten-Seite - Konto-Identifikator-Art „Text": erkennt das Zielkonto eines Transfers aus dem Buchungstext (längster passender Text gewinnt) - Migration 0007, Endpunkte, Tests (Betrag/Transfer/Konflikt), gesamt 30 grün Optik: - App-Icon: Münzhaufen (aus Nutzer-SVG) weiß auf grünem Grund, klein gehalten - Farbschema auf pastelliges Grün (Akzent hell/dunkel + Favicon) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+15
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user