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:
Guido
2026-08-19 22:41:38 +02:00
co-authored by Claude Opus 4.8
parent 30bc037579
commit 204a24d0df
16 changed files with 401 additions and 114 deletions
+5
View File
@@ -66,6 +66,7 @@ class IdentifierKind(str, enum.Enum):
iban = "iban" iban = "iban"
account_no = "account_no" account_no = "account_no"
card_no = "card_no" card_no = "card_no"
text = "text" # freier Erkennungs-Text im Buchungstext (Zielkonto bei Transfers)
class CategoryKind(str, enum.Enum): class CategoryKind(str, enum.Enum):
@@ -491,6 +492,10 @@ class CategorizationRule(Base):
category_id: Mapped[int | None] = mapped_column( category_id: Mapped[int | None] = mapped_column(
ForeignKey("categories.id", ondelete="CASCADE"), nullable=True 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) source: Mapped[RuleSource] = mapped_column(_enum(RuleSource), nullable=False, default=RuleSource.learned)
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=0) priority: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
hits: Mapped[int] = mapped_column(Integer, nullable=False, default=0) hits: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+9 -4
View File
@@ -7,16 +7,21 @@ from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..deps import get_current_user, get_owned 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 ..schemas import AccountCreate, AccountIdentifierOut, AccountOut, AccountUpdate
from ..services import balances from ..services import balances
from ..services.csv_import import normalize_text
from .settings import BASE_CURRENCY_KEY, get_user_setting from .settings import BASE_CURRENCY_KEY, get_user_setting
router = APIRouter(prefix="/accounts", tags=["accounts"]) router = APIRouter(prefix="/accounts", tags=["accounts"])
def normalize_identifier(value: str) -> str: def normalize_identifier(value: str, kind: IdentifierKind) -> str:
"""Vereinheitlicht IBAN/Nummer für den Abgleich: Großbuchstaben, nur alphanumerisch.""" """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()) 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() acc.identifiers.clear()
seen: set[tuple[str, str]] = set() seen: set[tuple[str, str]] = set()
for it in items: for it in items:
norm = normalize_identifier(it.value) norm = normalize_identifier(it.value, it.kind)
key = (it.kind.value, norm) key = (it.kind.value, norm)
if not norm or key in seen: if not norm or key in seen:
continue continue
+85 -36
View File
@@ -20,6 +20,8 @@ from ..schemas import (
KeywordCreate, KeywordCreate,
KeywordOut, KeywordOut,
KeywordUpdate, KeywordUpdate,
TransferKeywordCreate,
TransferKeywordOut,
) )
from ..services.csv_import import normalize_text from ..services.csv_import import normalize_text
@@ -168,7 +170,7 @@ def delete_category(
def _keyword_out(rule: CategorizationRule) -> KeywordOut: 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: 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 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]) @router.get("/keywords/flagged", response_model=list[FlaggedKeywordOut])
def list_flagged_keywords(db: Session = Depends(get_db), user: User = Depends(get_current_user)): def list_flagged_keywords(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
"""Manuelle Schlagwörter, die auffällig oft wegkorrigiert werden.""" """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)} names = {c.id: c.name for c in db.query(Category).filter(Category.owner_id == user.id)}
return [ return [
FlaggedKeywordOut( 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, word=r.pattern, applied_count=r.applied_count, overridden_count=r.overridden_count,
) )
for r in rules for r in rules
@@ -225,27 +252,16 @@ def update_keyword(
if not pattern: if not pattern:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort")
if pattern != rule.pattern: if pattern != rule.pattern:
clash = ( clash = _find_any_rule(db, user, pattern, exclude_id=rule.id)
db.query(CategorizationRule)
.filter(
CategorizationRule.owner_id == user.id,
CategorizationRule.match_field == MatchField.any,
CategorizationRule.pattern == pattern,
CategorizationRule.id != rule.id,
)
.first()
)
if clash is not None: if clash is not None:
other = db.get(Category, clash.category_id) raise _conflict(db, clash)
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.",
)
rule.pattern = pattern rule.pattern = pattern
rule.applied_count = 0 rule.applied_count = 0
rule.overridden_count = 0 rule.overridden_count = 0
rule.confirmed = False 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.commit()
db.refresh(rule) db.refresh(rule)
return _keyword_out(rule) return _keyword_out(rule)
@@ -285,29 +301,62 @@ def add_keyword(
pattern = normalize_text(payload.word)[:255] pattern = normalize_text(payload.word)[:255]
if not pattern: if not pattern:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort")
existing = ( existing = _find_any_rule(db, user, pattern)
db.query(CategorizationRule)
.filter(
CategorizationRule.owner_id == user.id,
CategorizationRule.match_field == MatchField.any,
CategorizationRule.pattern == pattern,
)
.first()
)
if existing is not None: if existing is not None:
if existing.category_id == cat.id: if not existing.is_transfer and existing.category_id == cat.id and existing.amount_cents == payload.amount_cents:
return _keyword_out(existing) # schon vorhanden – unverändert zurück return _keyword_out(existing) # schon identisch vorhanden – unverändert zurück
other = db.get(Category, existing.category_id) raise _conflict(db, existing)
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.",
)
rule = CategorizationRule( rule = CategorizationRule(
owner_id=user.id, match_field=MatchField.any, pattern=pattern, 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.add(rule)
db.commit() db.commit()
db.refresh(rule) db.refresh(rule)
return _keyword_out(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
View File
@@ -352,21 +352,35 @@ class ImportCommitIn(BaseModel):
class KeywordCreate(BaseModel): class KeywordCreate(BaseModel):
word: str = Field(min_length=1, max_length=255) word: str = Field(min_length=1, max_length=255)
amount_cents: int | None = Field(default=None, gt=0)
class KeywordUpdate(BaseModel): class KeywordUpdate(BaseModel):
word: str = Field(min_length=1, max_length=255) word: str = Field(min_length=1, max_length=255)
amount_cents: int | None = Field(default=None, gt=0)
class KeywordOut(BaseModel): class KeywordOut(BaseModel):
id: int id: int
category_id: int category_id: int
word: str 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): class FlaggedKeywordOut(BaseModel):
id: int id: int
category_id: int category_id: int | None = None
category_name: str category_name: str
word: str word: str
applied_count: int applied_count: int
+59 -25
View File
@@ -19,6 +19,7 @@ from ..models import (
CategorizationRule, CategorizationRule,
Category, Category,
CategoryKind, CategoryKind,
IdentifierKind,
ImportBatch, ImportBatch,
ImportStatus, ImportStatus,
ImportTemplate, 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: def _rule_sort_key(rule: CategorizationRule) -> tuple:
"""Reihenfolge der Regelanwendung: längstes Schlagwort zuerst, dann manuelle """Reihenfolge der Regelanwendung: Betrags-Regeln (spezifischer) zuerst, dann
vor gelernten, dann höhere Priorität/mehr Treffer.""" längstes Schlagwort, dann manuelle vor gelernten, dann Priorität/Treffer."""
return ( return (
1 if rule.amount_cents is not None else 0,
len(rule.pattern or ""), len(rule.pattern or ""),
1 if rule.source == RuleSource.manual else 0, 1 if rule.source == RuleSource.manual else 0,
rule.priority, 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], rules: list[CategorizationRule],
payee: str, payee: str,
purpose: str, purpose: str,
amount: int | None,
allowed_kind: CategoryKind | None, allowed_kind: CategoryKind | None,
cat_kind: dict[int, CategoryKind], cat_kind: dict[int, CategoryKind],
) -> tuple[int | None, int | None]: ) -> CategorizationRule | None:
"""Erste passende Regel -> (category_id, rule_id). Nur Kategorien, deren Art """Erste passende Regel (Muster + optionale Betragsbedingung). Kategorie-Regeln
(Einnahme/Ausgabe) zur Buchung passt, damit ein Ausgabe-Schlagwort keine greifen nur, wenn die Kategorie-Art (Einnahme/Ausgabe) zur Buchung passt;
Einnahme falsch einordnet.""" Transfer-Regeln kennen keine Art."""
p = normalize_text(payee) p = normalize_text(payee)
z = normalize_text(purpose) z = normalize_text(purpose)
for rule in rules: # bereits sortiert (längstes/​manuell zuerst) absamt = abs(amount) if amount is not None else None
if not rule.pattern or not rule.category_id: 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 continue
if allowed_kind is not None and cat_kind.get(rule.category_id) != allowed_kind: if allowed_kind is not None and cat_kind.get(rule.category_id) != allowed_kind:
continue continue
if rule.match_field == MatchField.payee: if _pattern_hit(rule, p, z):
hit = rule.pattern in p return rule
elif rule.match_field == MatchField.purpose: return None
hit = rule.pattern in z
else: # any: Empfänger ODER Zweck
hit = rule.pattern in p or rule.pattern in z def _guess_target_account(text_idents: list[tuple[str, int]], payee: str, purpose: str, source_id: int) -> int | None:
if hit: """Zielkonto eines Transfers aus dem Buchungstext raten: längster passender
return rule.category_id, rule.id Erkennungs-Text je Konto gewinnt."""
return None, None 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]: 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. """Reichert Kandidaten an: Typ, Auto-Kategorie, interner Transfer, Duplikat.
Wird von CSV- UND Beleg-Import genutzt (gleiche Prüf-/Buchungslogik).""" Wird von CSV- UND Beleg-Import genutzt (gleiche Prüf-/Buchungslogik)."""
idents: dict[str, int] = {} idents: dict[str, int] = {} # IBAN/Nummer -> Konto (für Transfer via IBAN)
for value_norm, acc_id in ( text_idents: list[tuple[str, int]] = [] # freie Erkennungs-Texte -> Konto
db.query(AccountIdentifier.value_norm, AccountIdentifier.account_id) for value_norm, acc_id, kind in (
db.query(AccountIdentifier.value_norm, AccountIdentifier.account_id, AccountIdentifier.kind)
.join(Account, Account.id == AccountIdentifier.account_id) .join(Account, Account.id == AccountIdentifier.account_id)
.filter(Account.owner_id == user.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 = db.query(CategorizationRule).filter(CategorizationRule.owner_id == user.id).all()
rules.sort(key=_rule_sort_key, reverse=True) # längstes/​manuell zuerst 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 to_account_id = other
else: else:
kind = CategoryKind.expense if ttype == "expense" else CategoryKind.income kind = CategoryKind.expense if ttype == "expense" else CategoryKind.income
category_id, matched_rule_id = _apply_rules( rule = _match_rule(rules, payee or "", purpose or "", amount, kind, cat_kind)
rules, payee or "", purpose or "", 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) h = import_hash(account.id, d, amount, payee)
is_dup = h in existing or h in seen 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")
+46
View File
@@ -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()] 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): def test_no_retroactive_apply(admin):
giro = _account(admin, name="Giro Retro") giro = _account(admin, name="Giro Retro")
a, _ = _expense_cats(admin, 2) a, _ = _expense_cats(admin, 2)
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 463 B

After

Width:  |  Height:  |  Size: 11 KiB

+6 -3
View File
@@ -141,13 +141,16 @@ export const api = {
updateCategory: (id, body) => request(`/categories/${id}`, { method: "PATCH", body }), updateCategory: (id, body) => request(`/categories/${id}`, { method: "PATCH", body }),
deleteCategory: (id) => request(`/categories/${id}`, { method: "DELETE" }), 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`), listKeywords: (categoryId) => request(`/categories/${categoryId}/keywords`),
addKeyword: (categoryId, word) => request(`/categories/${categoryId}/keywords`, { method: "POST", body: { word } }), addKeyword: (categoryId, body) => request(`/categories/${categoryId}/keywords`, { method: "POST", body }),
updateKeyword: (ruleId, word) => request(`/categories/keywords/${ruleId}`, { method: "PATCH", body: { word } }), updateKeyword: (ruleId, body) => request(`/categories/keywords/${ruleId}`, { method: "PATCH", body }),
deleteKeyword: (ruleId) => request(`/categories/keywords/${ruleId}`, { method: "DELETE" }), deleteKeyword: (ruleId) => request(`/categories/keywords/${ruleId}`, { method: "DELETE" }),
flaggedKeywords: () => request("/categories/keywords/flagged"), flaggedKeywords: () => request("/categories/keywords/flagged"),
confirmKeyword: (ruleId) => request(`/categories/keywords/${ruleId}/confirm`, { method: "POST" }), 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 // Transaktionen
listTransactions: (params = {}) => { listTransactions: (params = {}) => {
+7 -23
View File
@@ -1,33 +1,17 @@
import { APP_NAME } from "../brand"; import { APP_NAME } from "../brand";
// Wort-Bild-Marke für die Seitenleiste. Das Zeichen ist ein Inline-SVG // Wort-Bild-Marke für die Seitenleiste. Das Zeichen ist der Münzhaufen aus
// (gestapelte Münzen) im Akzentton – bewusst kein Emoji. // public/favicon.svg (identisch zum Browser-Tab-Icon) – bewusst kein Emoji.
export default function BrandMark({ showName = true, size = 26 }) { export default function BrandMark({ showName = true, size = 26 }) {
return ( return (
<div className="brand-mark"> <div className="brand-mark">
<svg <img
className="brand-logo"
src="/favicon.svg"
width={size} width={size}
height={size} height={size}
viewBox="0 0 32 32" alt={APP_NAME}
role="img" />
aria-label={APP_NAME}
focusable="false"
>
<rect x="1" y="1" width="30" height="30" rx="8" fill="var(--accent)" />
<g
transform="translate(2.2 2.2) scale(1.15)"
fill="none"
stroke="#ffffff"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="8" r="6" />
<path d="M18.09 10.37A6 6 0 1 1 10.34 18" />
<path d="M7 6h1v4" />
<path d="m16.71 13.88.7.71-2.82 2.82" />
</g>
</svg>
{showName && <span className="brand-name">{APP_NAME}</span>} {showName && <span className="brand-name">{APP_NAME}</span>}
</div> </div>
); );
+9
View File
@@ -6,6 +6,10 @@
align-items: center; align-items: center;
gap: var(--sp-2); gap: var(--sp-2);
} }
.brand-logo {
display: block;
flex: 0 0 auto;
}
.brand-name { .brand-name {
font-weight: 650; font-weight: 650;
font-size: 1rem; font-size: 1rem;
@@ -739,6 +743,11 @@ tr.row-dup {
border: 1px solid var(--border); border: 1px solid var(--border);
font-size: 0.85rem; font-size: 0.85rem;
} }
.kw-amount {
color: var(--muted);
font-variant-numeric: tabular-nums;
font-size: 0.8em;
}
.kw-chip button { .kw-chip button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+10 -1
View File
@@ -128,6 +128,7 @@
"openingDate": "Stichtag", "openingDate": "Stichtag",
"identifiers": "Erkennungs-Nummern (IBAN etc.)", "identifiers": "Erkennungs-Nummern (IBAN etc.)",
"addIdentifier": "Nummer hinzufügen", "addIdentifier": "Nummer hinzufügen",
"textIdentPlaceholder": "z. B. Sparkonto, Tagesgeld",
"identShort": "Nr.", "identShort": "Nr.",
"total": "Gesamt", "total": "Gesamt",
"empty": "Noch keine Konten. Lege dein erstes Konto an.", "empty": "Noch keine Konten. Lege dein erstes Konto an.",
@@ -144,7 +145,14 @@
"identifierKinds": { "identifierKinds": {
"iban": "IBAN", "iban": "IBAN",
"account_no": "Kontonummer", "account_no": "Kontonummer",
"card_no": "Kartennummer" "card_no": "Kartennummer",
"text": "Text (Erkennung)"
},
"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.",
"wordPlaceholder": "Wort (z. B. Übertrag)",
"empty": "Noch keine Transfer-Schlagwörter."
}, },
"categories": { "categories": {
"title": "Kategorien", "title": "Kategorien",
@@ -163,6 +171,7 @@
"keywordsTitle": "Schlagwörter – {{name}}", "keywordsTitle": "Schlagwörter – {{name}}",
"keywordsHint": "Buchungen, die eines dieser Wörter im Empfänger oder Verwendungszweck enthalten, werden bei künftigen Importen automatisch dieser Kategorie zugeordnet.", "keywordsHint": "Buchungen, die eines dieser Wörter im Empfänger oder Verwendungszweck enthalten, werden bei künftigen Importen automatisch dieser Kategorie zugeordnet.",
"keywordAdd": "Wort eingeben und Enter", "keywordAdd": "Wort eingeben und Enter",
"keywordAmount": "Betrag (optional)",
"keywordEmpty": "Noch keine Schlagwörter.", "keywordEmpty": "Noch keine Schlagwörter.",
"keywordEditHint": "Doppelklick auf ein Schlagwort, um es zu bearbeiten.", "keywordEditHint": "Doppelklick auf ein Schlagwort, um es zu bearbeiten.",
"searchPlaceholder": "Kategorie suchen …" "searchPlaceholder": "Kategorie suchen …"
+10 -1
View File
@@ -128,6 +128,7 @@
"openingDate": "As of", "openingDate": "As of",
"identifiers": "Identifiers (IBAN etc.)", "identifiers": "Identifiers (IBAN etc.)",
"addIdentifier": "Add identifier", "addIdentifier": "Add identifier",
"textIdentPlaceholder": "e.g. Savings, Deposit",
"identShort": "id(s)", "identShort": "id(s)",
"total": "Total", "total": "Total",
"empty": "No accounts yet. Create your first account.", "empty": "No accounts yet. Create your first account.",
@@ -144,7 +145,14 @@
"identifierKinds": { "identifierKinds": {
"iban": "IBAN", "iban": "IBAN",
"account_no": "Account number", "account_no": "Account number",
"card_no": "Card number" "card_no": "Card number",
"text": "Text (detection)"
},
"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).",
"wordPlaceholder": "Word (e.g. Transfer)",
"empty": "No transfer keywords yet."
}, },
"categories": { "categories": {
"title": "Categories", "title": "Categories",
@@ -163,6 +171,7 @@
"keywordsTitle": "Keywords – {{name}}", "keywordsTitle": "Keywords – {{name}}",
"keywordsHint": "Transactions whose payee or description contains one of these words are automatically assigned to this category on future imports.", "keywordsHint": "Transactions whose payee or description contains one of these words are automatically assigned to this category on future imports.",
"keywordAdd": "Type a word and press Enter", "keywordAdd": "Type a word and press Enter",
"keywordAmount": "Amount (optional)",
"keywordEmpty": "No keywords yet.", "keywordEmpty": "No keywords yet.",
"keywordEditHint": "Double-click a keyword to edit it.", "keywordEditHint": "Double-click a keyword to edit it.",
"searchPlaceholder": "Search category …" "searchPlaceholder": "Search category …"
+78 -1
View File
@@ -34,6 +34,80 @@ 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() {
const { t } = useTranslation();
const toast = useToast();
const [words, setWords] = useState([]);
const [input, setInput] = useState("");
const [amount, setAmount] = useState("");
const [busy, setBusy] = useState(false);
async function laden() {
try { setWords(await api.listTransferKeywords()); }
catch (err) { toast(err.message, "error"); }
}
useEffect(() => { laden(); }, []);
async function hinzufuegen() {
const w = input.trim();
if (!w) return;
setBusy(true);
try {
const body = { word: w };
const c = parseAmountToCents(amount);
if (c) body.amount_cents = c;
await api.addTransferKeyword(body);
setInput("");
setAmount("");
await laden();
} catch (err) {
toast(err.message, "error");
} finally {
setBusy(false);
}
}
async function entfernen(id) {
try { await api.deleteKeyword(id); setWords((ws) => ws.filter((x) => x.id !== id)); }
catch (err) { toast(err.message, "error"); }
}
return (
<div className="card" style={{ marginTop: "var(--sp-4)" }}>
<h2>{t("transferRules.title")}</h2>
<p className="muted small">{t("transferRules.hint")}</p>
<div className="kw-add">
<input value={input} placeholder={t("transferRules.wordPlaceholder")}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }} />
<input value={amount} inputMode="decimal" placeholder={t("categories.keywordAmount")}
style={{ flex: "0 0 120px", minWidth: 0, marginTop: 0 }}
onChange={(e) => setAmount(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }} />
<button className="btn" onClick={hinzufuegen} disabled={busy || !input.trim()}>
<Icon name="plus" size={15} />
</button>
</div>
{words.length === 0 ? (
<div className="muted small">{t("transferRules.empty")}</div>
) : (
<div className="kw-chips">
{words.map((w) => (
<span className="kw-chip" key={w.id}>
{w.word}
{w.amount_cents ? <span className="kw-amount">= {centsToInput(w.amount_cents)}</span> : null}
<button type="button" title={t("common.delete")} onClick={() => entfernen(w.id)}>
<Icon name="close" size={13} />
</button>
</span>
))}
</div>
)}
</div>
);
}
export default function Accounts() { export default function Accounts() {
const { t } = useTranslation(); const { t } = useTranslation();
const { baseCurrency } = useSettings(); const { baseCurrency } = useSettings();
@@ -165,6 +239,8 @@ export default function Accounts() {
</div> </div>
)} )}
<TransferKeywords />
{form && ( {form && (
<Modal <Modal
title={editing ? t("accounts.edit") : t("accounts.new")} title={editing ? t("accounts.edit") : t("accounts.new")}
@@ -238,9 +314,10 @@ export default function Accounts() {
<option value="iban">IBAN</option> <option value="iban">IBAN</option>
<option value="account_no">{t("identifierKinds.account_no")}</option> <option value="account_no">{t("identifierKinds.account_no")}</option>
<option value="card_no">{t("identifierKinds.card_no")}</option> <option value="card_no">{t("identifierKinds.card_no")}</option>
<option value="text">{t("identifierKinds.text")}</option>
</select> </select>
<input className="grow" style={{ minWidth: 0 }} value={ident.value} <input className="grow" style={{ minWidth: 0 }} value={ident.value}
placeholder="DE.. / Nr." placeholder={ident.kind === "text" ? t("accounts.textIdentPlaceholder") : "DE.. / Nr."}
onChange={(e) => { onChange={(e) => {
const next = [...form.identifiers]; const next = [...form.identifiers];
next[idx] = { ...ident, value: e.target.value }; next[idx] = { ...ident, value: e.target.value };
+18 -2
View File
@@ -7,14 +7,17 @@ import Icon from "../components/Icon";
import IconChip from "../components/IconChip"; import IconChip from "../components/IconChip";
import Modal from "../components/Modal"; import Modal from "../components/Modal";
import IconPicker, { CATEGORY_ICONS } from "../components/IconPicker"; import IconPicker, { CATEGORY_ICONS } from "../components/IconPicker";
import { centsToInput, parseAmountToCents } from "../format";
// Popup zum Pflegen der Schlagwörter einer Kategorie. Wörter werden beim Import // Popup zum Pflegen der Schlagwörter einer Kategorie. Wörter werden beim Import
// gegen Empfänger/Verwendungszweck geprüft und ordnen Buchungen automatisch zu. // gegen Empfänger/Verwendungszweck geprüft und ordnen Buchungen automatisch zu.
// Optional lässt sich ein fester Betrag als Zusatzbedingung angeben.
function KeywordModal({ category, onClose }) { function KeywordModal({ category, onClose }) {
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useToast(); const toast = useToast();
const [words, setWords] = useState([]); const [words, setWords] = useState([]);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [amount, setAmount] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [editId, setEditId] = useState(null); const [editId, setEditId] = useState(null);
const [editVal, setEditVal] = useState(""); const [editVal, setEditVal] = useState("");
@@ -30,8 +33,12 @@ function KeywordModal({ category, onClose }) {
if (!w) return; if (!w) return;
setBusy(true); setBusy(true);
try { try {
await api.addKeyword(category.id, w); const body = { word: w };
const c = parseAmountToCents(amount);
if (c) body.amount_cents = c;
await api.addKeyword(category.id, body);
setInput(""); setInput("");
setAmount("");
await laden(); await laden();
} catch (err) { } catch (err) {
toast(err.message, "error"); toast(err.message, "error");
@@ -61,7 +68,7 @@ function KeywordModal({ category, onClose }) {
const orig = words.find((x) => x.id === id); const orig = words.find((x) => x.id === id);
if (!val || (orig && val === orig.word)) { abbrechenBearbeiten(); return; } if (!val || (orig && val === orig.word)) { abbrechenBearbeiten(); return; }
try { try {
await api.updateKeyword(id, val); await api.updateKeyword(id, { word: val });
abbrechenBearbeiten(); abbrechenBearbeiten();
await laden(); await laden();
} catch (err) { } catch (err) {
@@ -87,6 +94,14 @@ function KeywordModal({ category, onClose }) {
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }}
/> />
<input
value={amount}
inputMode="decimal"
placeholder={t("categories.keywordAmount")}
style={{ flex: "0 0 120px", minWidth: 0, marginTop: 0 }}
onChange={(e) => setAmount(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }}
/>
<button className="btn" onClick={hinzufuegen} disabled={busy || !input.trim()}> <button className="btn" onClick={hinzufuegen} disabled={busy || !input.trim()}>
<Icon name="plus" size={15} /> <Icon name="plus" size={15} />
</button> </button>
@@ -120,6 +135,7 @@ function KeywordModal({ category, onClose }) {
onDoubleClick={() => starteBearbeiten(w)} onDoubleClick={() => starteBearbeiten(w)}
> >
{w.word} {w.word}
{w.amount_cents ? <span className="kw-amount">= {centsToInput(w.amount_cents)}</span> : null}
<button type="button" title={t("common.delete")} onClick={() => entfernen(w.id)}> <button type="button" title={t("common.delete")} onClick={() => entfernen(w.id)}>
<Icon name="close" size={13} /> <Icon name="close" size={13} />
</button> </button>
+11 -11
View File
@@ -6,9 +6,9 @@
--border-strong: #d1d6dc; --border-strong: #d1d6dc;
--text: #1a1f27; --text: #1a1f27;
--muted: #6b7480; --muted: #6b7480;
--accent: #3f51b5; --accent: #3f9c72;
--accent-hover: #34429a; --accent-hover: #34825f;
--accent-soft: #eceefb; --accent-soft: #e6f3ec;
--danger: #c0392b; --danger: #c0392b;
--danger-soft: #fbeceb; --danger-soft: #fbeceb;
--warn: #b06f14; --warn: #b06f14;
@@ -536,7 +536,7 @@ td select { width: auto; min-width: 0; max-width: 100%; }
} }
.alert .icon { margin-top: 1px; } .alert .icon { margin-top: 1px; }
.alert.error { background: var(--danger-soft); color: var(--danger); border-color: #f0c8c4; } .alert.error { background: var(--danger-soft); color: var(--danger); border-color: #f0c8c4; }
.alert.info { background: var(--accent-soft); color: var(--accent-hover); border-color: #cfd6f4; } .alert.info { background: var(--accent-soft); color: var(--accent-hover); border-color: #c3e6d3; }
.alert.warn { background: var(--warn-soft); color: var(--warn); border-color: #f0dcb8; } .alert.warn { background: var(--warn-soft); color: var(--warn); border-color: #f0dcb8; }
.alert.ok { background: #e6f4ec; color: var(--ok); border-color: #c3e3d0; } .alert.ok { background: #e6f4ec; color: var(--ok); border-color: #c3e3d0; }
@@ -1008,9 +1008,9 @@ select.zeitraum:hover { color: var(--text); }
--border-strong: #333c4a; --border-strong: #333c4a;
--text: #e6edf3; --text: #e6edf3;
--muted: #8b949e; --muted: #8b949e;
--accent: #7c8ae8; --accent: #6cc39d;
--accent-hover: #93a0ef; --accent-hover: #82d0af;
--accent-soft: #1e2340; --accent-soft: #16291f;
--danger: #f0857a; --danger: #f0857a;
--danger-soft: #2a1a1a; --danger-soft: #2a1a1a;
--warn: #e0a955; --warn: #e0a955;
@@ -1023,14 +1023,14 @@ select.zeitraum:hover { color: var(--text); }
} }
:root[data-theme="dark"] { :root[data-theme="dark"] {
--bg: #0d1117; --surface: #161b22; --surface-2: #1c2230; --border: #262d38; --bg: #0d1117; --surface: #161b22; --surface-2: #1c2230; --border: #262d38;
--border-strong: #333c4a; --text: #e6edf3; --muted: #8b949e; --accent: #7c8ae8; --border-strong: #333c4a; --text: #e6edf3; --muted: #8b949e; --accent: #6cc39d;
--accent-hover: #93a0ef; --accent-soft: #1e2340; --danger: #f0857a; --danger-soft: #2a1a1a; --accent-hover: #82d0af; --accent-soft: #16291f; --danger: #f0857a; --danger-soft: #2a1a1a;
--warn: #e0a955; --warn-soft: #2a2312; --ok: #5fbf8c; --warn: #e0a955; --warn-soft: #2a2312; --ok: #5fbf8c;
} }
:root[data-theme="light"] { :root[data-theme="light"] {
--bg: #f6f7f9; --surface: #ffffff; --surface-2: #f1f3f5; --border: #e3e6ea; --bg: #f6f7f9; --surface: #ffffff; --surface-2: #f1f3f5; --border: #e3e6ea;
--border-strong: #d1d6dc; --text: #1a1f27; --muted: #6b7480; --accent: #3f51b5; --border-strong: #d1d6dc; --text: #1a1f27; --muted: #6b7480; --accent: #3f9c72;
--accent-hover: #34429a; --accent-soft: #eceefb; --danger: #c0392b; --danger-soft: #fbeceb; --accent-hover: #34825f; --accent-soft: #e6f3ec; --danger: #c0392b; --danger-soft: #fbeceb;
--warn: #b06f14; --warn-soft: #fbf3e6; --ok: #2e7d55; --warn: #b06f14; --warn-soft: #fbf3e6; --ok: #2e7d55;
} }