From 1b7107b9bdf59ee8a2d47a63404fef15908455d0 Mon Sep 17 00:00:00 2001 From: Guido Date: Wed, 19 Aug 2026 18:59:24 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=201=20=E2=80=93=20Konten,=20Kateg?= =?UTF-8?q?orien,=20Transaktionen,=20EZB-W=C3=A4hrungsumrechnung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Modelle + Migration 0002: accounts, account_identifiers, category_groups, categories, transactions, exchange_rates (alle owner-gescoped). - EZB-Kursdienst (90-Tage/Historie) + Backfill-Skript; Umrechnung EUR<->CHF zum Buchungsdatum mit lokalem Cache und manueller Übersteuerung; EUR-Pivot (amount_eur_cents) eingefroren. - Kontostände + Netto-Vermögen (Umrechnung in Hauptwährung). - Router: accounts (inkl. IBAN-Identifikatoren), categories (Gruppen+Kategorien), transactions (Ausgabe/Einnahme/Transfer, cross-currency), rates. - Gruppierte Standard-Kategorien je Nutzer beim Anlegen. - pytest-Suite: Währung, Salden, Cross-Currency-Transfer, Nutzer-Isolation. Frontend: - Seiten Konten, Kategorien, Buchungen; Icon-Chip, Geldbetrag mit Umrechnung, Icon-/Farbwähler, Modal; erweitertes SVG-Icon-Set (Konto-/Kategorie-Icons). - Währungs-/Datumsformatierung (EUR de-DE, CHF de-CH), i18n DE/EN erweitert. - Live-Umrechnungs-Vorschau im Transfer-Formular. Co-Authored-By: Claude Opus 4.8 --- backend/app/deps.py | 11 + backend/app/main.py | 17 +- backend/app/models.py | 195 +++++++++- backend/app/routers/accounts.py | 137 +++++++ backend/app/routers/categories.py | 154 ++++++++ backend/app/routers/rates.py | 71 ++++ backend/app/routers/transactions.py | 219 +++++++++++ backend/app/routers/users.py | 3 + backend/app/schemas.py | 220 +++++++++++- backend/app/seed.py | 63 +++- backend/app/services/balances.py | 64 ++++ backend/app/services/currency.py | 82 +++++ backend/app/services/ecb_rates.py | 82 +++++ .../0002_accounts_categories_transactions.py | 133 +++++++ backend/pytest.ini | 3 + backend/scripts/__init__.py | 0 backend/scripts/backfill_rates.py | 28 ++ backend/tests/conftest.py | 39 ++ backend/tests/test_accounts.py | 84 +++++ backend/tests/test_currency.py | 59 +++ backend/tests/test_isolation.py | 33 ++ web/src/App.jsx | 9 + web/src/api.js | 38 ++ web/src/components/Icon.jsx | 148 ++++++++ web/src/components/IconChip.jsx | 15 + web/src/components/IconPicker.jsx | 32 ++ web/src/components/Modal.jsx | 22 ++ web/src/components/Money.jsx | 28 ++ web/src/finance.css | 198 ++++++++++ web/src/format.js | 23 ++ web/src/locales/de.json | 70 ++++ web/src/locales/en.json | 70 ++++ web/src/pages/Accounts.jsx | 263 ++++++++++++++ web/src/pages/Categories.jsx | 169 +++++++++ web/src/pages/Transactions.jsx | 340 ++++++++++++++++++ 35 files changed, 3116 insertions(+), 6 deletions(-) create mode 100644 backend/app/routers/accounts.py create mode 100644 backend/app/routers/categories.py create mode 100644 backend/app/routers/rates.py create mode 100644 backend/app/routers/transactions.py create mode 100644 backend/app/services/balances.py create mode 100644 backend/app/services/currency.py create mode 100644 backend/app/services/ecb_rates.py create mode 100644 backend/migrations/versions/0002_accounts_categories_transactions.py create mode 100644 backend/pytest.ini create mode 100644 backend/scripts/__init__.py create mode 100644 backend/scripts/backfill_rates.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_accounts.py create mode 100644 backend/tests/test_currency.py create mode 100644 backend/tests/test_isolation.py create mode 100644 web/src/components/IconChip.jsx create mode 100644 web/src/components/IconPicker.jsx create mode 100644 web/src/components/Modal.jsx create mode 100644 web/src/components/Money.jsx create mode 100644 web/src/pages/Accounts.jsx create mode 100644 web/src/pages/Categories.jsx create mode 100644 web/src/pages/Transactions.jsx diff --git a/backend/app/deps.py b/backend/app/deps.py index 3c52ec7..e01b7e4 100644 --- a/backend/app/deps.py +++ b/backend/app/deps.py @@ -34,3 +34,14 @@ def require_admin(current_user: User = Depends(get_current_user)) -> User: detail="Nur Administratoren dürfen diese Aktion ausführen", ) return current_user + + +def get_owned(db: Session, model, obj_id: int, user: User): + """Holt eine Zeile und stellt sicher, dass sie dem Nutzer gehört – sonst 404. + + Bewusst 404 (nicht 403): fremde IDs sollen nicht einmal als existent gelten. + Zentrale Stelle für die Mandanten-Isolation.""" + obj = db.get(model, obj_id) + if obj is None or getattr(obj, "owner_id", None) != user.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Nicht gefunden") + return obj diff --git a/backend/app/main.py b/backend/app/main.py index 1aeead7..4bfa0b0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -17,8 +17,17 @@ from .branding import APP_NAME, VERSION, git_sha from .config import get_settings from .database import SessionLocal from .migrate import upgrade_to_head -from .routers import auth, settings as settings_router, users +from .routers import ( + accounts, + auth, + categories, + rates, + settings as settings_router, + transactions, + users, +) from .seed import ensure_first_admin +from .services import ecb_rates settings = get_settings() @@ -34,6 +43,8 @@ async def lifespan(app: FastAPI): db = SessionLocal() try: ensure_first_admin(db) + # Best-effort: aktuelle EZB-Kurse nachladen (ohne Internet läuft es weiter). + ecb_rates.ensure_rates_on_startup(db, settings.ecb_fetch_on_start) finally: db.close() yield @@ -73,6 +84,10 @@ def version() -> dict: api.include_router(auth.router) api.include_router(users.router) api.include_router(settings_router.router) +api.include_router(accounts.router) +api.include_router(categories.router) +api.include_router(transactions.router) +api.include_router(rates.router) app.include_router(api) diff --git a/backend/app/models.py b/backend/app/models.py index 8aacbad..46fbf7a 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -12,15 +12,22 @@ hinzu und werden dann alle über ``owner_id`` je Benutzer getrennt. from __future__ import annotations import enum -from datetime import datetime, timezone +# date/time unter Alias, weil es Spalten namens "date"/"time" gibt, die die +# gleichnamigen Typen sonst in den (späten) Annotationen beschatten. +from datetime import date as date_t, datetime, time as time_t, timezone from sqlalchemy import ( Boolean, + Date, DateTime, Enum as SAEnum, ForeignKey, Integer, + Numeric, String, + Text, + Time, + UniqueConstraint, ) from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -41,6 +48,42 @@ class Role(str, enum.Enum): user = "user" +class AccountType(str, enum.Enum): + girokonto = "girokonto" + sparkonto = "sparkonto" + bargeld = "bargeld" + kreditkarte = "kreditkarte" + depot = "depot" + sonstiges = "sonstiges" + + +# Kontotypen, deren Ziel-Transfer standardmäßig als budgetierte Ausgabe zählt +# (Sparen/Investment). Andere Transfers sind budgetneutral. +SAVINGS_ACCOUNT_TYPES = {AccountType.sparkonto, AccountType.depot} + + +class IdentifierKind(str, enum.Enum): + iban = "iban" + account_no = "account_no" + card_no = "card_no" + + +class CategoryKind(str, enum.Enum): + income = "income" + expense = "expense" + + +class TxnType(str, enum.Enum): + expense = "expense" + income = "income" + transfer = "transfer" + + +class BudgetTreatment(str, enum.Enum): + budgeted = "budgeted" + neutral = "neutral" + + class User(Base): __tablename__ = "users" @@ -96,3 +139,153 @@ class UserSetting(Base): value: Mapped[str | None] = mapped_column(String(255), nullable=True) user: Mapped[User] = relationship(back_populates="settings") + + +# --------------------------------------------------------------------------- +# Phase 1: Konten, Kategorien, Transaktionen, Wechselkurse +# Alle fachlichen Tabellen tragen owner_id und werden nur owner-gescoped gelesen. +# --------------------------------------------------------------------------- + + +class Account(Base): + __tablename__ = "accounts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + owner_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + name: Mapped[str] = mapped_column(String(120), nullable=False) + type: Mapped[AccountType] = mapped_column(_enum(AccountType), nullable=False, default=AccountType.girokonto) + currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR") + icon: Mapped[str] = mapped_column(String(40), nullable=False, default="wallet") + color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f51b5") + opening_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + opening_date: Mapped[date_t | None] = mapped_column(Date, nullable=True) + is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now) + + identifiers: Mapped[list[AccountIdentifier]] = relationship( + back_populates="account", cascade="all, delete-orphan" + ) + + +class AccountIdentifier(Base): + """Erkennungsmerkmal eines Kontos (IBAN/Kontonummer/Kartennummer) für die + Transfer-Erkennung beim Import. ``value_norm`` ist die normalisierte Form.""" + + __tablename__ = "account_identifiers" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + account_id: Mapped[int] = mapped_column( + ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True + ) + kind: Mapped[IdentifierKind] = mapped_column(_enum(IdentifierKind), nullable=False, default=IdentifierKind.iban) + value: Mapped[str] = mapped_column(String(64), nullable=False) + value_norm: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + + account: Mapped[Account] = relationship(back_populates="identifiers") + + __table_args__ = ( + UniqueConstraint("account_id", "kind", "value_norm", name="uq_ident_account_kind_value"), + ) + + +class CategoryGroup(Base): + __tablename__ = "category_groups" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + owner_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + name: Mapped[str] = mapped_column(String(120), nullable=False) + kind: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense) + icon: Mapped[str] = mapped_column(String(40), nullable=False, default="list") + color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480") + position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + categories: Mapped[list[Category]] = relationship( + back_populates="group", cascade="all, delete-orphan" + ) + + +class Category(Base): + __tablename__ = "categories" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + owner_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + group_id: Mapped[int] = mapped_column( + ForeignKey("category_groups.id", ondelete="CASCADE"), nullable=False, index=True + ) + name: Mapped[str] = mapped_column(String(120), nullable=False) + type: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense) + icon: Mapped[str] = mapped_column(String(40), nullable=False, default="more-horizontal") + color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480") + position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + group: Mapped[CategoryGroup] = relationship(back_populates="categories") + + +class Transaction(Base): + __tablename__ = "transactions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + owner_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + type: Mapped[TxnType] = mapped_column(_enum(TxnType), nullable=False) + date: Mapped[date_t] = mapped_column(Date, nullable=False, index=True) + time: Mapped[time_t | None] = mapped_column(Time, nullable=True) + + account_id: Mapped[int] = mapped_column( + ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True + ) + to_account_id: Mapped[int | None] = mapped_column( + ForeignKey("accounts.id", ondelete="CASCADE"), nullable=True, index=True + ) + + # Betrag in Kontowährung (bei Transfers: abgebuchter Betrag vom Quellkonto). + amount_cents: Mapped[int] = mapped_column(Integer, nullable=False) + # Nur bei währungsübergreifenden Transfers: gutgeschriebener Betrag am Zielkonto. + to_amount_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) + # Manuell gesetzter Kurs (Kontowährung je 1 EUR); überschreibt den EZB-Kurs. + fx_rate: Mapped[float | None] = mapped_column(Numeric(18, 8), nullable=True) + # Eingefrorener EUR-Wert von amount_cents zum Buchungsdatum (Pivot für Budget/Dashboard). + amount_eur_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + category_id: Mapped[int | None] = mapped_column( + ForeignKey("categories.id", ondelete="SET NULL"), nullable=True, index=True + ) + # Übersteuert die Standard-Regel für Sparen/Depot-Transfers (budgeted|neutral). + budget_treatment: Mapped[BudgetTreatment | None] = mapped_column(_enum(BudgetTreatment), nullable=True) + # Kategorie, gegen die ein budgetierter Transfer zählt (Sparen/Investment). + budget_category_id: Mapped[int | None] = mapped_column( + ForeignKey("categories.id", ondelete="SET NULL"), nullable=True + ) + + payee: Mapped[str | None] = mapped_column(String(255), nullable=True) + description: Mapped[str | None] = mapped_column(String(255), nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now) + + +class ExchangeRate(Base): + """EZB-Referenzkurs: wie viele Einheiten der Währung ein Euro wert war. + + EUR ist implizit 1.0 (keine Zeile). In der Praxis nur CHF. Nicht owner-gescoped + (öffentliche Referenzdaten).""" + + __tablename__ = "exchange_rates" + + date: Mapped[date_t] = mapped_column(Date, primary_key=True) + currency: Mapped[str] = mapped_column(String(3), primary_key=True) + rate_per_eur: Mapped[float] = mapped_column(Numeric(18, 8), nullable=False) + source: Mapped[str] = mapped_column(String(16), nullable=False, default="ecb") diff --git a/backend/app/routers/accounts.py b/backend/app/routers/accounts.py new file mode 100644 index 0000000..b8cc7e7 --- /dev/null +++ b/backend/app/routers/accounts.py @@ -0,0 +1,137 @@ +"""Konten inkl. Erkennungs-Identifikatoren. Alles owner-gescoped.""" + +from datetime import date + +from fastapi import APIRouter, Depends, status +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 ..schemas import AccountCreate, AccountIdentifierOut, AccountOut, AccountUpdate +from ..services import balances +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.""" + return "".join(ch for ch in value.upper() if ch.isalnum()) + + +def base_currency(db: Session, user: User) -> str: + return get_user_setting(db, user, BASE_CURRENCY_KEY) or "EUR" + + +def to_out(db: Session, acc: Account, base: str, on: date) -> AccountOut: + return AccountOut( + id=acc.id, + name=acc.name, + type=acc.type, + currency=acc.currency, + icon=acc.icon, + color=acc.color, + opening_balance_cents=acc.opening_balance_cents, + opening_date=acc.opening_date, + is_archived=acc.is_archived, + position=acc.position, + identifiers=[ + AccountIdentifierOut(id=i.id, kind=i.kind, value=i.value) for i in acc.identifiers + ], + balance_cents=balances.account_balance_cents(db, acc), + balance_base_cents=balances.account_balance_base_cents(db, acc, base, on), + ) + + +def _set_identifiers(acc: Account, items) -> None: + """Ersetzt die Identifikator-Liste (dedupliziert nach Art + Normalform).""" + acc.identifiers.clear() + seen: set[tuple[str, str]] = set() + for it in items: + norm = normalize_identifier(it.value) + key = (it.kind.value, norm) + if not norm or key in seen: + continue + seen.add(key) + acc.identifiers.append( + AccountIdentifier(kind=it.kind, value=it.value.strip(), value_norm=norm) + ) + + +@router.get("", response_model=list[AccountOut]) +def list_accounts(db: Session = Depends(get_db), user: User = Depends(get_current_user)): + base = base_currency(db, user) + today = date.today() + accounts = ( + db.query(Account) + .filter(Account.owner_id == user.id) + .order_by(Account.position, Account.name) + .all() + ) + return [to_out(db, a, base, today) for a in accounts] + + +@router.post("", response_model=AccountOut, status_code=status.HTTP_201_CREATED) +def create_account( + payload: AccountCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user) +): + max_pos = ( + db.query(Account.position) + .filter(Account.owner_id == user.id) + .order_by(Account.position.desc()) + .first() + ) + acc = Account( + owner_id=user.id, + name=payload.name.strip(), + type=payload.type, + currency=payload.currency, + icon=payload.icon, + color=payload.color, + opening_balance_cents=payload.opening_balance_cents, + opening_date=payload.opening_date, + position=(max_pos[0] + 1) if max_pos else 0, + ) + _set_identifiers(acc, payload.identifiers) + db.add(acc) + db.commit() + db.refresh(acc) + return to_out(db, acc, base_currency(db, user), date.today()) + + +@router.get("/{account_id}", response_model=AccountOut) +def get_account( + account_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user) +): + acc = get_owned(db, Account, account_id, user) + return to_out(db, acc, base_currency(db, user), date.today()) + + +@router.patch("/{account_id}", response_model=AccountOut) +def update_account( + account_id: int, + payload: AccountUpdate, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + acc = get_owned(db, Account, account_id, user) + data = payload.model_dump(exclude_unset=True) + identifiers = data.pop("identifiers", None) + for key, value in data.items(): + if value is not None or key in ("opening_date",): + setattr(acc, key, value) + if identifiers is not None: + _set_identifiers(acc, payload.identifiers) + db.commit() + db.refresh(acc) + return to_out(db, acc, base_currency(db, user), date.today()) + + +@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_account( + account_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user) +): + acc = get_owned(db, Account, account_id, user) + db.delete(acc) + db.commit() diff --git a/backend/app/routers/categories.py b/backend/app/routers/categories.py new file mode 100644 index 0000000..c33c45d --- /dev/null +++ b/backend/app/routers/categories.py @@ -0,0 +1,154 @@ +"""Kategorie-Gruppen und Kategorien (zweistufig). Alles owner-gescoped. + +Eine Kategorie erbt ihre Art (Einnahme/Ausgabe) von ihrer Gruppe. +""" + +from fastapi import APIRouter, Depends, status +from sqlalchemy.orm import Session + +from ..database import get_db +from ..deps import get_current_user, get_owned +from ..models import Category, CategoryGroup, User +from ..schemas import ( + CategoryCreate, + CategoryGroupCreate, + CategoryGroupOut, + CategoryGroupUpdate, + CategoryOut, + CategoryUpdate, +) + +router = APIRouter(prefix="/categories", tags=["categories"]) + + +def group_out(g: CategoryGroup) -> CategoryGroupOut: + cats = sorted(g.categories, key=lambda c: (c.position, c.name.lower())) + return CategoryGroupOut( + id=g.id, + name=g.name, + kind=g.kind, + icon=g.icon, + color=g.color, + position=g.position, + is_builtin=g.is_builtin, + categories=[CategoryOut.model_validate(c) for c in cats], + ) + + +def _next_position(db: Session, model, **filters) -> int: + q = db.query(model.position) + for k, v in filters.items(): + q = q.filter(getattr(model, k) == v) + row = q.order_by(model.position.desc()).first() + return (row[0] + 1) if row else 0 + + +@router.get("", response_model=list[CategoryGroupOut]) +def list_categories(db: Session = Depends(get_db), user: User = Depends(get_current_user)): + groups = ( + db.query(CategoryGroup) + .filter(CategoryGroup.owner_id == user.id) + .order_by(CategoryGroup.position, CategoryGroup.name) + .all() + ) + return [group_out(g) for g in groups] + + +# --- Gruppen --------------------------------------------------------------- + + +@router.post("/groups", response_model=CategoryGroupOut, status_code=status.HTTP_201_CREATED) +def create_group( + payload: CategoryGroupCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user) +): + g = CategoryGroup( + owner_id=user.id, + name=payload.name.strip(), + kind=payload.kind, + icon=payload.icon, + color=payload.color, + position=_next_position(db, CategoryGroup, owner_id=user.id), + ) + db.add(g) + db.commit() + db.refresh(g) + return group_out(g) + + +@router.patch("/groups/{group_id}", response_model=CategoryGroupOut) +def update_group( + group_id: int, + payload: CategoryGroupUpdate, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + g = get_owned(db, CategoryGroup, group_id, user) + for key, value in payload.model_dump(exclude_unset=True).items(): + if value is not None: + setattr(g, key, value) + db.commit() + db.refresh(g) + return group_out(g) + + +@router.delete("/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_group( + group_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user) +): + g = get_owned(db, CategoryGroup, group_id, user) + db.delete(g) + db.commit() + + +# --- Kategorien ------------------------------------------------------------ + + +@router.post("", response_model=CategoryOut, status_code=status.HTTP_201_CREATED) +def create_category( + payload: CategoryCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user) +): + group = get_owned(db, CategoryGroup, payload.group_id, user) + cat = Category( + owner_id=user.id, + group_id=group.id, + name=payload.name.strip(), + type=group.kind, # erbt die Art der Gruppe + icon=payload.icon, + color=payload.color, + position=_next_position(db, Category, owner_id=user.id, group_id=group.id), + ) + db.add(cat) + db.commit() + db.refresh(cat) + return CategoryOut.model_validate(cat) + + +@router.patch("/{category_id}", response_model=CategoryOut) +def update_category( + category_id: int, + payload: CategoryUpdate, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + cat = get_owned(db, Category, category_id, user) + data = payload.model_dump(exclude_unset=True) + new_group_id = data.pop("group_id", None) + if new_group_id is not None: + group = get_owned(db, CategoryGroup, new_group_id, user) + cat.group_id = group.id + cat.type = group.kind # Art an die neue Gruppe angleichen + for key, value in data.items(): + if value is not None or key == "is_archived": + setattr(cat, key, value) + db.commit() + db.refresh(cat) + return CategoryOut.model_validate(cat) + + +@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_category( + category_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user) +): + cat = get_owned(db, Category, category_id, user) + db.delete(cat) + db.commit() diff --git a/backend/app/routers/rates.py b/backend/app/routers/rates.py new file mode 100644 index 0000000..23d6064 --- /dev/null +++ b/backend/app/routers/rates.py @@ -0,0 +1,71 @@ +"""Wechselkurse: Status und Umrechnungs-Vorschau (für das Buchungsformular).""" + +from datetime import date + +from fastapi import APIRouter, Depends +from sqlalchemy import func +from sqlalchemy.orm import Session + +from ..database import get_db +from ..deps import get_current_user +from ..models import ExchangeRate, User +from ..schemas import ConversionOut, RateStatus +from ..services import currency + +router = APIRouter(prefix="/rates", tags=["rates"]) + + +@router.get("/status", response_model=list[RateStatus]) +def status(db: Session = Depends(get_db), _: User = Depends(get_current_user)): + out: list[RateStatus] = [] + currencies = [ + row[0] for row in db.query(ExchangeRate.currency).distinct().order_by(ExchangeRate.currency) + ] + for ccy in currencies: + agg = db.query( + func.count(ExchangeRate.date), + func.min(ExchangeRate.date), + func.max(ExchangeRate.date), + ).filter(ExchangeRate.currency == ccy).one() + latest = ( + db.query(ExchangeRate) + .filter(ExchangeRate.currency == ccy) + .order_by(ExchangeRate.date.desc()) + .first() + ) + out.append( + RateStatus( + currency=ccy, + count=agg[0] or 0, + min_date=agg[1], + max_date=agg[2], + latest_rate=float(latest.rate_per_eur) if latest else None, + ) + ) + return out + + +@router.get("/convert", response_model=ConversionOut) +def convert( + amount_cents: int, + from_currency: str, + to_currency: str, + on: date | None = None, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +): + on = on or date.today() + from_currency = from_currency.upper() + to_currency = to_currency.upper() + converted = currency.convert(db, amount_cents, from_currency, to_currency, on) + # Anzeige-Kurs: EZB-Kurs der Nicht-EUR-Währung (Einheiten je 1 EUR). + non_eur = to_currency if to_currency != "EUR" else from_currency + rate = currency.rate_on(db, non_eur, on) + return ConversionOut( + amount_cents=amount_cents, + from_currency=from_currency, + to_currency=to_currency, + date=on, + converted_cents=converted, + rate=float(rate) if rate is not None else None, + ) diff --git a/backend/app/routers/transactions.py b/backend/app/routers/transactions.py new file mode 100644 index 0000000..093f594 --- /dev/null +++ b/backend/app/routers/transactions.py @@ -0,0 +1,219 @@ +"""Transaktionen: Ausgabe, Einnahme, interner Transfer. Owner-gescoped. + +Beim Speichern wird der eingefrorene EUR-Wert (``amount_eur_cents``) zum +Buchungsdatum berechnet (Pivot für Budget/Dashboard). Ein manuell gesetzter +Kurs (``fx_rate``) hat Vorrang vor dem EZB-Kurs. +""" + +from datetime import date + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from ..database import get_db +from ..deps import get_current_user, get_owned +from ..models import Account, Category, Transaction, TxnType, User +from ..schemas import TransactionCreate, TransactionOut, TransactionUpdate +from ..services import currency +from .accounts import base_currency + +router = APIRouter(prefix="/transactions", tags=["transactions"]) + + +def _fx(txn: Transaction) -> float | None: + return float(txn.fx_rate) if txn.fx_rate is not None else None + + +def _recompute(db: Session, txn: Transaction, from_acc: Account, to_acc: Account | None) -> None: + """Setzt amount_eur_cents und (bei Transfers) to_amount_cents neu.""" + txn.amount_eur_cents = currency.compute_amount_eur_cents( + db, txn.amount_cents, from_acc.currency, txn.date, _fx(txn) + ) + if txn.type == TxnType.transfer and to_acc is not None: + if txn.to_amount_cents is None: + if to_acc.currency == from_acc.currency: + txn.to_amount_cents = txn.amount_cents + else: + conv = currency.convert( + db, txn.amount_cents, from_acc.currency, to_acc.currency, txn.date, _fx(txn) + ) + txn.to_amount_cents = conv if conv is not None else txn.amount_cents + else: + txn.to_amount_cents = None + + +def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, str]) -> TransactionOut: + acc_ccy = ccy_by_account.get(txn.account_id, "EUR") + if base == "EUR": + amount_base = txn.amount_eur_cents + else: + conv = currency.from_eur_cents(db, txn.amount_eur_cents, base, txn.date) + amount_base = conv if conv is not None else txn.amount_eur_cents + return TransactionOut( + id=txn.id, + type=txn.type, + date=txn.date, + time=txn.time, + account_id=txn.account_id, + to_account_id=txn.to_account_id, + account_currency=acc_ccy, + amount_cents=txn.amount_cents, + to_amount_cents=txn.to_amount_cents, + fx_rate=_fx(txn), + amount_eur_cents=txn.amount_eur_cents, + amount_base_cents=amount_base, + category_id=txn.category_id, + budget_treatment=txn.budget_treatment, + budget_category_id=txn.budget_category_id, + payee=txn.payee, + description=txn.description, + notes=txn.notes, + ) + + +def _currency_map(db: Session, user: User) -> dict[int, str]: + rows = db.query(Account.id, Account.currency).filter(Account.owner_id == user.id).all() + return {row[0]: row[1] for row in rows} + + +def _validate_category(db: Session, user: User, category_id: int | None) -> None: + if category_id is not None: + get_owned(db, Category, category_id, user) + + +@router.get("", response_model=list[TransactionOut]) +def list_transactions( + db: Session = Depends(get_db), + user: User = Depends(get_current_user), + account_id: int | None = None, + category_id: int | None = None, + type: TxnType | None = None, + date_from: date | None = None, + date_to: date | None = None, + limit: int = Query(default=200, le=1000), + offset: int = 0, +): + q = db.query(Transaction).filter(Transaction.owner_id == user.id) + if account_id is not None: + q = q.filter( + (Transaction.account_id == account_id) | (Transaction.to_account_id == account_id) + ) + if category_id is not None: + q = q.filter(Transaction.category_id == category_id) + if type is not None: + q = q.filter(Transaction.type == type) + if date_from is not None: + q = q.filter(Transaction.date >= date_from) + if date_to is not None: + q = q.filter(Transaction.date <= date_to) + rows = q.order_by(Transaction.date.desc(), Transaction.id.desc()).limit(limit).offset(offset).all() + base = base_currency(db, user) + ccy = _currency_map(db, user) + return [to_out(db, t, base, ccy) for t in rows] + + +@router.post("", response_model=TransactionOut, status_code=status.HTTP_201_CREATED) +def create_transaction( + payload: TransactionCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user) +): + from_acc = get_owned(db, Account, payload.account_id, user) + to_acc = None + category_id = payload.category_id + + if payload.type == TxnType.transfer: + if payload.to_account_id is None or payload.to_account_id == payload.account_id: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Transfer braucht ein anderes Zielkonto") + to_acc = get_owned(db, Account, payload.to_account_id, user) + category_id = None # Transfers werden nicht regulär kategorisiert + else: + if payload.to_account_id is not None: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Nur Transfers haben ein Zielkonto") + _validate_category(db, user, category_id) + + _validate_category(db, user, payload.budget_category_id) + + txn = Transaction( + owner_id=user.id, + type=payload.type, + date=payload.date, + time=payload.time, + account_id=from_acc.id, + to_account_id=to_acc.id if to_acc else None, + amount_cents=payload.amount_cents, + to_amount_cents=payload.to_amount_cents, + fx_rate=payload.fx_rate, + category_id=category_id, + budget_treatment=payload.budget_treatment, + budget_category_id=payload.budget_category_id, + payee=(payload.payee or None), + description=(payload.description or None), + notes=(payload.notes or None), + ) + _recompute(db, txn, from_acc, to_acc) + db.add(txn) + db.commit() + db.refresh(txn) + return to_out(db, txn, base_currency(db, user), _currency_map(db, user)) + + +@router.get("/{txn_id}", response_model=TransactionOut) +def get_transaction( + txn_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user) +): + txn = get_owned(db, Transaction, txn_id, user) + return to_out(db, txn, base_currency(db, user), _currency_map(db, user)) + + +@router.patch("/{txn_id}", response_model=TransactionOut) +def update_transaction( + txn_id: int, + payload: TransactionUpdate, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + txn = get_owned(db, Transaction, txn_id, user) + data = payload.model_dump(exclude_unset=True) + + for key in ("type", "date", "time", "amount_cents", "to_amount_cents", "fx_rate", + "budget_treatment", "payee", "description", "notes"): + if key in data: + setattr(txn, key, data[key]) + + if "account_id" in data and data["account_id"] is not None: + get_owned(db, Account, data["account_id"], user) + txn.account_id = data["account_id"] + if "to_account_id" in data: + if data["to_account_id"] is not None: + get_owned(db, Account, data["to_account_id"], user) + txn.to_account_id = data["to_account_id"] + if "category_id" in data: + _validate_category(db, user, data["category_id"]) + txn.category_id = data["category_id"] + if "budget_category_id" in data: + _validate_category(db, user, data["budget_category_id"]) + txn.budget_category_id = data["budget_category_id"] + + # Konsistenz je nach Typ + Neuberechnung der abgeleiteten Werte. + from_acc = get_owned(db, Account, txn.account_id, user) + to_acc = None + if txn.type == TxnType.transfer: + if txn.to_account_id is None or txn.to_account_id == txn.account_id: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Transfer braucht ein anderes Zielkonto") + to_acc = get_owned(db, Account, txn.to_account_id, user) + txn.category_id = None + else: + txn.to_account_id = None + _recompute(db, txn, from_acc, to_acc) + + db.commit() + db.refresh(txn) + return to_out(db, txn, base_currency(db, user), _currency_map(db, user)) + + +@router.delete("/{txn_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_transaction( + txn_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user) +): + txn = get_owned(db, Transaction, txn_id, user) + db.delete(txn) + db.commit() diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 97c8716..95db8b9 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -8,6 +8,7 @@ from ..deps import require_admin from ..models import Role, User from ..schemas import UserCreate, UserOut, UserUpdate from ..security import hash_password +from ..seed import ensure_starter_categories router = APIRouter(prefix="/users", tags=["users"], dependencies=[Depends(require_admin)]) @@ -33,6 +34,8 @@ def create_user(payload: UserCreate, db: Session = Depends(get_db)) -> User: db.add(user) db.commit() db.refresh(user) + # Neuen Nutzern die Startkategorien mitgeben. + ensure_starter_categories(db, user) return user diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 542ce98..0cacb6b 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,10 +1,26 @@ """Pydantic-Schemas für Anfragen/Antworten (Phase 0: Auth, Benutzer, Einstellungen).""" -from datetime import datetime +from datetime import date as date_t, datetime, time as time_t -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator -from .models import Role +from .models import ( + AccountType, + BudgetTreatment, + CategoryKind, + IdentifierKind, + Role, + TxnType, +) + +ALLOWED_CURRENCIES = {"EUR", "CHF"} + + +def _check_currency(v: str) -> str: + v = v.upper() + if v not in ALLOWED_CURRENCIES: + raise ValueError("Währung muss EUR oder CHF sein") + return v class UserOut(BaseModel): @@ -41,3 +57,201 @@ class PasswordChange(BaseModel): class SettingOut(BaseModel): key: str value: str | None = None + + +# --- Konten ---------------------------------------------------------------- + + +class AccountIdentifierIn(BaseModel): + kind: IdentifierKind = IdentifierKind.iban + value: str = Field(min_length=1, max_length=64) + + +class AccountIdentifierOut(BaseModel): + id: int + kind: IdentifierKind + value: str + + +class AccountCreate(BaseModel): + name: str = Field(min_length=1, max_length=120) + type: AccountType = AccountType.girokonto + currency: str = "EUR" + icon: str = "wallet" + color: str = "#3f51b5" + opening_balance_cents: int = 0 + opening_date: date_t | None = None + identifiers: list[AccountIdentifierIn] = Field(default_factory=list) + + @field_validator("currency") + @classmethod + def _cur(cls, v: str) -> str: + return _check_currency(v) + + +class AccountUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=120) + type: AccountType | None = None + currency: str | None = None + icon: str | None = None + color: str | None = None + opening_balance_cents: int | None = None + opening_date: date_t | None = None + is_archived: bool | None = None + position: int | None = None + # Wenn gesetzt, ersetzt die komplette Identifikator-Liste. + identifiers: list[AccountIdentifierIn] | None = None + + @field_validator("currency") + @classmethod + def _cur(cls, v: str | None) -> str | None: + return _check_currency(v) if v is not None else None + + +class AccountOut(BaseModel): + id: int + name: str + type: AccountType + currency: str + icon: str + color: str + opening_balance_cents: int + opening_date: date_t | None + is_archived: bool + position: int + identifiers: list[AccountIdentifierOut] + balance_cents: int + balance_base_cents: int + + +# --- Kategorien ------------------------------------------------------------ + + +class CategoryOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + group_id: int + name: str + type: CategoryKind + icon: str + color: str + position: int + is_builtin: bool + is_archived: bool + + +class CategoryGroupOut(BaseModel): + id: int + name: str + kind: CategoryKind + icon: str + color: str + position: int + is_builtin: bool + categories: list[CategoryOut] + + +class CategoryGroupCreate(BaseModel): + name: str = Field(min_length=1, max_length=120) + kind: CategoryKind = CategoryKind.expense + icon: str = "list" + color: str = "#6b7480" + + +class CategoryGroupUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=120) + icon: str | None = None + color: str | None = None + position: int | None = None + + +class CategoryCreate(BaseModel): + group_id: int + name: str = Field(min_length=1, max_length=120) + icon: str = "more-horizontal" + color: str = "#6b7480" + + +class CategoryUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=120) + group_id: int | None = None + icon: str | None = None + color: str | None = None + position: int | None = None + is_archived: bool | None = None + + +# --- Transaktionen --------------------------------------------------------- + + +class TransactionCreate(BaseModel): + type: TxnType + date: date_t + time: time_t | None = None + account_id: int + to_account_id: int | None = None + amount_cents: int = Field(gt=0) + to_amount_cents: int | None = Field(default=None, gt=0) + fx_rate: float | None = Field(default=None, gt=0) + category_id: int | None = None + budget_treatment: BudgetTreatment | None = None + budget_category_id: int | None = None + payee: str | None = Field(default=None, max_length=255) + description: str | None = Field(default=None, max_length=255) + notes: str | None = None + + +class TransactionUpdate(BaseModel): + type: TxnType | None = None + date: date_t | None = None + time: time_t | None = None + account_id: int | None = None + to_account_id: int | None = None + amount_cents: int | None = Field(default=None, gt=0) + to_amount_cents: int | None = Field(default=None, gt=0) + fx_rate: float | None = Field(default=None, gt=0) + category_id: int | None = None + budget_treatment: BudgetTreatment | None = None + budget_category_id: int | None = None + payee: str | None = Field(default=None, max_length=255) + description: str | None = Field(default=None, max_length=255) + notes: str | None = None + + +class TransactionOut(BaseModel): + id: int + type: TxnType + date: date_t + time: time_t | None + account_id: int + to_account_id: int | None + account_currency: str + amount_cents: int + to_amount_cents: int | None + fx_rate: float | None + amount_eur_cents: int + amount_base_cents: int + category_id: int | None + budget_treatment: BudgetTreatment | None + budget_category_id: int | None + payee: str | None + description: str | None + notes: str | None + + +class RateStatus(BaseModel): + currency: str + count: int + min_date: date_t | None + max_date: date_t | None + latest_rate: float | None + + +class ConversionOut(BaseModel): + amount_cents: int + from_currency: str + to_currency: str + date: date_t + converted_cents: int | None + rate: float | None diff --git a/backend/app/seed.py b/backend/app/seed.py index f049f43..8ed584c 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -6,9 +6,68 @@ Fachliche Startdaten (Standard-Kategorien usw.) folgen in späteren Phasen. from sqlalchemy.orm import Session from .config import get_settings -from .models import Role, User +from .models import Category, CategoryGroup, CategoryKind, Role, User from .security import hash_password +# Vorgefertigte, gruppierte Startkategorien je Benutzer. Alles frei änderbar; +# wer eine Gruppe/Kategorie löscht, bekommt sie nicht automatisch zurück. +# (Gruppe, Art, Gruppen-Icon, Gruppen-Farbe, [(Kategoriename, Icon), ...]) +STARTER_CATEGORIES: list[tuple[str, CategoryKind, str, str, list[tuple[str, str]]]] = [ + ("Fixkosten", CategoryKind.expense, "landmark", "#3f51b5", [ + ("Wohnen", "home"), + ("Versicherungen", "shield"), + ("Telefon & Internet", "wifi"), + ("Abos & Streaming", "refresh-ccw"), + ]), + ("Lebenshaltung", CategoryKind.expense, "shopping-cart", "#2e7d55", [ + ("Lebensmittel", "shopping-cart"), + ("Restaurant", "utensils"), + ("Mobilität", "car"), + ("Gesundheit", "heart-pulse"), + ("Kleidung", "shirt"), + ]), + ("Freizeit & Persönliches", CategoryKind.expense, "film", "#b06f14", [ + ("Freizeit", "film"), + ("Reisen", "plane"), + ("Sport & Fitness", "dumbbell"), + ("Bildung", "graduation-cap"), + ("Haustiere", "paw-print"), + ("Geschenke & Spenden", "gift"), + ]), + ("Finanzen", CategoryKind.expense, "piggy-bank", "#2a78d6", [ + ("Sparen & Investments", "piggy-bank"), + ("Steuern & Gebühren", "percent"), + ]), + ("Sonstiges", CategoryKind.expense, "more-horizontal", "#6b7480", [ + ("Sonstiges", "more-horizontal"), + ]), + ("Einnahmen", CategoryKind.income, "coins", "#2e7d55", [ + ("Gehalt", "banknote"), + ("Nebeneinkünfte", "coins"), + ("Erstattungen", "receipt"), + ]), +] + + +def ensure_starter_categories(db: Session, user: User) -> None: + """Legt die Startkategorien für einen Benutzer an – nur, wenn er noch keine hat.""" + exists = db.query(CategoryGroup).filter(CategoryGroup.owner_id == user.id).first() + if exists is not None: + return + for gpos, (gname, kind, gicon, gcolor, cats) in enumerate(STARTER_CATEGORIES): + group = CategoryGroup( + owner_id=user.id, name=gname, kind=kind, icon=gicon, color=gcolor, + position=gpos, is_builtin=True, + ) + db.add(group) + db.flush() + for cpos, (cname, cicon) in enumerate(cats): + db.add(Category( + owner_id=user.id, group_id=group.id, name=cname, type=kind, + icon=cicon, color=gcolor, position=cpos, is_builtin=True, + )) + db.commit() + def ensure_first_admin(db: Session) -> None: """Legt einen Admin an, solange noch gar kein Benutzer existiert.""" @@ -24,3 +83,5 @@ def ensure_first_admin(db: Session) -> None: ) db.add(admin) db.commit() + db.refresh(admin) + ensure_starter_categories(db, admin) diff --git a/backend/app/services/balances.py b/backend/app/services/balances.py new file mode 100644 index 0000000..40784bf --- /dev/null +++ b/backend/app/services/balances.py @@ -0,0 +1,64 @@ +"""Kontostände (in Kontowährung) und Umrechnung in die Hauptwährung. + +Saldo = Anfangssaldo + Einnahmen - Ausgaben - Transfers-raus + Transfers-rein. +Transfers werden in der jeweiligen Kontowährung verrechnet (bei währungsüber- +greifenden Transfers am Zielkonto der gutgeschriebene Betrag ``to_amount_cents``). +""" + +from datetime import date + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from ..models import Account, Transaction, TxnType +from . import currency + + +def _sum(query) -> int: + return int(query.scalar() or 0) + + +def account_balance_cents(db: Session, account: Account) -> int: + income = _sum( + db.query(func.sum(Transaction.amount_cents)).filter( + Transaction.account_id == account.id, Transaction.type == TxnType.income + ) + ) + expense = _sum( + db.query(func.sum(Transaction.amount_cents)).filter( + Transaction.account_id == account.id, Transaction.type == TxnType.expense + ) + ) + transfer_out = _sum( + db.query(func.sum(Transaction.amount_cents)).filter( + Transaction.account_id == account.id, Transaction.type == TxnType.transfer + ) + ) + transfer_in = _sum( + db.query( + func.sum(func.coalesce(Transaction.to_amount_cents, Transaction.amount_cents)) + ).filter( + Transaction.to_account_id == account.id, Transaction.type == TxnType.transfer + ) + ) + return account.opening_balance_cents + income - expense - transfer_out + transfer_in + + +def account_balance_base_cents( + db: Session, account: Account, base_currency: str, on: date +) -> int: + """Saldo in die Hauptwährung umgerechnet (aktueller Kurs). Fällt auf den + nativen Wert zurück, wenn kein Kurs verfügbar ist.""" + native = account_balance_cents(db, account) + if account.currency == base_currency: + return native + conv = currency.convert(db, native, account.currency, base_currency, on) + return conv if conv is not None else native + + +def net_worth_cents(db: Session, accounts: list[Account], base_currency: str, on: date) -> int: + return sum( + account_balance_base_cents(db, a, base_currency, on) + for a in accounts + if not a.is_archived + ) diff --git a/backend/app/services/currency.py b/backend/app/services/currency.py new file mode 100644 index 0000000..c9a2167 --- /dev/null +++ b/backend/app/services/currency.py @@ -0,0 +1,82 @@ +"""Währungsumrechnung EUR<->CHF über die lokal gecachten EZB-Referenzkurse. + +Kurse werden als ``rate_per_eur`` gespeichert: wie viele Einheiten der Währung +ein Euro wert war (EUR ist implizit 1.0). Umgerechnet wird immer mit dem Kurs zum +Buchungsdatum; fehlt ein Kurs für genau diesen Tag (Wochenende/Feiertag), wird der +letzte verfügbare Börsentag davor genommen. +""" + +from datetime import date +from decimal import Decimal, ROUND_HALF_UP + +from sqlalchemy.orm import Session + +from ..models import ExchangeRate + + +def rate_on(db: Session, currency: str, on: date) -> Decimal | None: + """Kurs (Währungseinheiten je 1 EUR) am Tag ``on`` oder dem letzten davor.""" + if currency == "EUR": + return Decimal(1) + row = ( + db.query(ExchangeRate) + .filter(ExchangeRate.currency == currency, ExchangeRate.date <= on) + .order_by(ExchangeRate.date.desc()) + .first() + ) + return Decimal(str(row.rate_per_eur)) if row is not None else None + + +def _round_cents(value: Decimal) -> int: + return int(value.quantize(Decimal(1), rounding=ROUND_HALF_UP)) + + +def to_eur_cents( + db: Session, amount_cents: int, currency: str, on: date, fx_rate: float | None = None +) -> int | None: + """Rechnet einen Betrag in Kontowährung in EUR-Cent um. + + ``fx_rate`` (Kontowährung je 1 EUR) hat Vorrang vor dem EZB-Kurs. Gibt None + zurück, wenn kein Kurs verfügbar ist (Aufrufer entscheidet über Fallback).""" + if currency == "EUR": + return amount_cents + rate = Decimal(str(fx_rate)) if fx_rate else rate_on(db, currency, on) + if not rate or rate == 0: + return None + return _round_cents(Decimal(amount_cents) / rate) + + +def from_eur_cents(db: Session, eur_cents: int, currency: str, on: date) -> int | None: + """Rechnet EUR-Cent in die Zielwährung um (Kurs zum Tag ``on``).""" + if currency == "EUR": + return eur_cents + rate = rate_on(db, currency, on) + if rate is None: + return None + return _round_cents(Decimal(eur_cents) * rate) + + +def convert( + db: Session, + amount_cents: int, + from_ccy: str, + to_ccy: str, + on: date, + fx_rate: float | None = None, +) -> int | None: + """Rechnet zwischen zwei Währungen um (über EUR als Pivot).""" + if from_ccy == to_ccy: + return amount_cents + eur = to_eur_cents(db, amount_cents, from_ccy, on, fx_rate) + if eur is None: + return None + return from_eur_cents(db, eur, to_ccy, on) + + +def compute_amount_eur_cents( + db: Session, amount_cents: int, currency: str, on: date, fx_rate: float | None = None +) -> int: + """Eingefrorener EUR-Wert für eine Transaktion. Fällt notfalls (kein Kurs + verfügbar) auf 1:1 zurück, damit das Speichern nie scheitert.""" + eur = to_eur_cents(db, amount_cents, currency, on, fx_rate) + return eur if eur is not None else amount_cents diff --git a/backend/app/services/ecb_rates.py b/backend/app/services/ecb_rates.py new file mode 100644 index 0000000..40f8ffc --- /dev/null +++ b/backend/app/services/ecb_rates.py @@ -0,0 +1,82 @@ +"""EZB-Referenzkurse laden und lokal cachen. + +Die EZB veröffentlicht die Euro-Referenzkurse werktäglich als XML: ein Tages-Feed, +ein 90-Tage-Feed und die volle Historie seit 1999. Wir brauchen nur CHF. Historische +Kurse ändern sich nicht – vorhandene Zeilen werden daher nicht überschrieben. +""" + +import xml.etree.ElementTree as ET +from datetime import date +from decimal import Decimal + +import httpx +from sqlalchemy.orm import Session + +from ..models import ExchangeRate + +FEED_90D = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist-90d.xml" +FEED_FULL = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist.xml" + +# Welche Währungen wir speichern (Hauptwährungen des Nutzers: EUR ist Pivot). +CURRENCIES = {"CHF"} + + +def _parse(xml_bytes: bytes) -> list[tuple[date, str, str]]: + """Extrahiert (Datum, Währung, Kurs) aus dem EZB-XML (namespace-tolerant).""" + root = ET.fromstring(xml_bytes) + rows: list[tuple[date, str, str]] = [] + for elem in root.iter(): + t = elem.attrib.get("time") + if not t: + continue + try: + d = date.fromisoformat(t) + except ValueError: + continue + for child in elem: + ccy = child.attrib.get("currency") + rate = child.attrib.get("rate") + if ccy in CURRENCIES and rate: + rows.append((d, ccy, rate)) + return rows + + +def _fetch(url: str, timeout: float = 15.0) -> bytes: + with httpx.Client(timeout=timeout, follow_redirects=True) as client: + resp = client.get(url) + resp.raise_for_status() + return resp.content + + +def _upsert(db: Session, rows: list[tuple[date, str, str]]) -> int: + added = 0 + for d, ccy, rate in rows: + if db.get(ExchangeRate, (d, ccy)) is None: + db.add(ExchangeRate(date=d, currency=ccy, rate_per_eur=Decimal(rate), source="ecb")) + added += 1 + if added: + db.commit() + return added + + +def refresh_recent(db: Session) -> int: + """Lädt die letzten ~90 Tage (schnell) und ergänzt fehlende Tage.""" + return _upsert(db, _parse(_fetch(FEED_90D))) + + +def backfill_full(db: Session) -> int: + """Lädt die komplette Historie (einmalig, größer).""" + return _upsert(db, _parse(_fetch(FEED_FULL))) + + +def ensure_rates_on_startup(db: Session, enabled: bool) -> None: + """Beim App-Start best-effort die aktuellen Kurse nachladen. + + Blockiert nie hart: Ohne Internet läuft die App weiter, nur ohne frische Kurse. + Die volle Historie holt das Install-Skript / der systemd-Timer separat.""" + if not enabled: + return + try: + refresh_recent(db) + except Exception: # noqa: BLE001 – bewusst tolerant (Netz weg etc.) + pass diff --git a/backend/migrations/versions/0002_accounts_categories_transactions.py b/backend/migrations/versions/0002_accounts_categories_transactions.py new file mode 100644 index 0000000..b306077 --- /dev/null +++ b/backend/migrations/versions/0002_accounts_categories_transactions.py @@ -0,0 +1,133 @@ +"""Konten, Identifikatoren, Kategorien(-gruppen), Transaktionen, Wechselkurse. + +Revision ID: 0002 +Revises: 0001 +Create Date: 2026-08-19 +""" +from alembic import op +import sqlalchemy as sa + +revision = "0002" +down_revision = "0001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "accounts", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("type", sa.String(length=32), nullable=False), + sa.Column("currency", sa.String(length=3), nullable=False), + sa.Column("icon", sa.String(length=40), nullable=False, server_default="wallet"), + sa.Column("color", sa.String(length=9), nullable=False, server_default="#3f51b5"), + sa.Column("opening_balance_cents", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("opening_date", sa.Date(), nullable=True), + sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("position", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index("ix_accounts_owner_id", "accounts", ["owner_id"]) + + op.create_table( + "account_identifiers", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("account_id", sa.Integer(), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False), + sa.Column("kind", sa.String(length=32), nullable=False), + sa.Column("value", sa.String(length=64), nullable=False), + sa.Column("value_norm", sa.String(length=64), nullable=False), + sa.UniqueConstraint("account_id", "kind", "value_norm", name="uq_ident_account_kind_value"), + ) + op.create_index("ix_account_identifiers_account_id", "account_identifiers", ["account_id"]) + op.create_index("ix_account_identifiers_value_norm", "account_identifiers", ["value_norm"]) + + op.create_table( + "category_groups", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("kind", sa.String(length=32), nullable=False), + sa.Column("icon", sa.String(length=40), nullable=False, server_default="list"), + sa.Column("color", sa.String(length=9), nullable=False, server_default="#6b7480"), + sa.Column("position", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default=sa.text("0")), + ) + op.create_index("ix_category_groups_owner_id", "category_groups", ["owner_id"]) + + op.create_table( + "categories", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("group_id", sa.Integer(), sa.ForeignKey("category_groups.id", ondelete="CASCADE"), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("type", sa.String(length=32), nullable=False), + sa.Column("icon", sa.String(length=40), nullable=False, server_default="more-horizontal"), + sa.Column("color", sa.String(length=9), nullable=False, server_default="#6b7480"), + sa.Column("position", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.text("0")), + ) + op.create_index("ix_categories_owner_id", "categories", ["owner_id"]) + op.create_index("ix_categories_group_id", "categories", ["group_id"]) + + op.create_table( + "transactions", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("type", sa.String(length=32), nullable=False), + sa.Column("date", sa.Date(), nullable=False), + sa.Column("time", sa.Time(), nullable=True), + sa.Column("account_id", sa.Integer(), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False), + sa.Column("to_account_id", sa.Integer(), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=True), + sa.Column("amount_cents", sa.Integer(), nullable=False), + sa.Column("to_amount_cents", sa.Integer(), nullable=True), + sa.Column("fx_rate", sa.Numeric(18, 8), nullable=True), + sa.Column("amount_eur_cents", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("category_id", sa.Integer(), sa.ForeignKey("categories.id", ondelete="SET NULL"), nullable=True), + sa.Column("budget_treatment", sa.String(length=32), nullable=True), + sa.Column("budget_category_id", sa.Integer(), sa.ForeignKey("categories.id", ondelete="SET NULL"), nullable=True), + sa.Column("payee", sa.String(length=255), nullable=True), + sa.Column("description", sa.String(length=255), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index("ix_transactions_owner_id", "transactions", ["owner_id"]) + op.create_index("ix_transactions_date", "transactions", ["date"]) + op.create_index("ix_transactions_account_id", "transactions", ["account_id"]) + op.create_index("ix_transactions_to_account_id", "transactions", ["to_account_id"]) + op.create_index("ix_transactions_category_id", "transactions", ["category_id"]) + + op.create_table( + "exchange_rates", + sa.Column("date", sa.Date(), primary_key=True), + sa.Column("currency", sa.String(length=3), primary_key=True), + sa.Column("rate_per_eur", sa.Numeric(18, 8), nullable=False), + sa.Column("source", sa.String(length=16), nullable=False, server_default="ecb"), + ) + + +def downgrade() -> None: + op.drop_table("exchange_rates") + for ix in ( + "ix_transactions_category_id", + "ix_transactions_to_account_id", + "ix_transactions_account_id", + "ix_transactions_date", + "ix_transactions_owner_id", + ): + op.drop_index(ix, table_name="transactions") + op.drop_table("transactions") + op.drop_index("ix_categories_group_id", table_name="categories") + op.drop_index("ix_categories_owner_id", table_name="categories") + op.drop_table("categories") + op.drop_index("ix_category_groups_owner_id", table_name="category_groups") + op.drop_table("category_groups") + op.drop_index("ix_account_identifiers_value_norm", table_name="account_identifiers") + op.drop_index("ix_account_identifiers_account_id", table_name="account_identifiers") + op.drop_table("account_identifiers") + op.drop_index("ix_accounts_owner_id", table_name="accounts") + op.drop_table("accounts") diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..c7b23ec --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = . +testpaths = tests diff --git a/backend/scripts/__init__.py b/backend/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/scripts/backfill_rates.py b/backend/scripts/backfill_rates.py new file mode 100644 index 0000000..b4eae00 --- /dev/null +++ b/backend/scripts/backfill_rates.py @@ -0,0 +1,28 @@ +"""EZB-Wechselkurse laden (aus dem backend/-Verzeichnis: ``python -m scripts.backfill_rates``). + +Ohne Argument: volle Historie. Mit ``--recent``: nur die letzten ~90 Tage +(für den täglichen systemd-Timer). +""" + +import sys + +from app.database import SessionLocal +from app.services import ecb_rates + + +def main() -> None: + recent = "--recent" in sys.argv + db = SessionLocal() + try: + if recent: + n = ecb_rates.refresh_recent(db) + print(f"{n} neue Kurs-Tage geladen (90-Tage-Feed).") + else: + n = ecb_rates.backfill_full(db) + print(f"{n} neue Kurs-Tage geladen (volle Historie).") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..77bde7f --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,39 @@ +"""Test-Setup: frische SQLite-DB je Testlauf, kein EZB-Netzabruf.""" + +import os +import secrets +import tempfile + +# Umgebung setzen, BEVOR app importiert wird (config wird beim Import gelesen). +os.environ.setdefault("APP_DATA_DIR", tempfile.mkdtemp(prefix="cf-test-")) +os.environ.setdefault("APP_ECB_FETCH_ON_START", "false") +os.environ.setdefault("APP_ADMIN_USERNAME", "admin") +os.environ.setdefault("APP_ADMIN_PASSWORD", "adminpass") +os.environ.setdefault("APP_SESSION_SECRET", "test-secret") + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def client(): + from app.main import app + + with TestClient(app) as c: + yield c + + +@pytest.fixture() +def admin(client): + r = client.post("/api/auth/login", data={"username": "admin", "password": "adminpass"}) + assert r.status_code == 200 + return client + + +def new_user(admin_client) -> tuple[str, str]: + """Legt (als Admin) einen frischen Nutzer an und gibt (username, passwort) zurück.""" + username = "u_" + secrets.token_hex(4) + password = "pw_" + secrets.token_hex(4) + r = admin_client.post("/api/users", json={"username": username, "password": password, "role": "user"}) + assert r.status_code == 201, r.text + return username, password diff --git a/backend/tests/test_accounts.py b/backend/tests/test_accounts.py new file mode 100644 index 0000000..27175ac --- /dev/null +++ b/backend/tests/test_accounts.py @@ -0,0 +1,84 @@ +from datetime import date, timedelta +from decimal import Decimal + + +def _seed_recent_rates(rate: str = "0.95") -> None: + from app.database import SessionLocal + from app.models import ExchangeRate + + db = SessionLocal() + try: + today = date.today() + for delta in range(0, 7): + day = today - timedelta(days=delta) + if db.get(ExchangeRate, (day, "CHF")) is None: + db.add(ExchangeRate(date=day, currency="CHF", rate_per_eur=Decimal(rate))) + db.commit() + finally: + db.close() + + +def _mk_account(admin, **kw): + body = {"name": "A", "type": "girokonto", "currency": "EUR", "opening_balance_cents": 0} + body.update(kw) + r = admin.post("/api/accounts", json=body) + assert r.status_code == 201, r.text + return r.json() + + +def test_starter_categories_present(admin): + groups = admin.get("/api/categories").json() + assert len(groups) >= 6 + names = {c["name"] for g in groups for c in g["categories"]} + assert {"Gehalt", "Lebensmittel", "Sparen & Investments"} <= names + + +def test_currency_validation(admin): + r = admin.post("/api/accounts", json={"name": "X", "currency": "USD"}) + assert r.status_code == 422 + + +def test_identifiers_stored_and_deduped(admin): + acc = _mk_account( + admin, + name="Mit IBAN", + identifiers=[ + {"kind": "iban", "value": "DE12 3456"}, + {"kind": "iban", "value": "de123456"}, # normiert identisch -> dedupliziert + {"kind": "card_no", "value": "1234"}, + ], + ) + kinds = sorted((i["kind"], i["value"]) for i in acc["identifiers"]) + assert len(acc["identifiers"]) == 2 + assert ("card_no", "1234") in kinds + + +def test_balance_and_cross_currency_transfer(admin): + _seed_recent_rates("0.95") + d = date.today() + eur = _mk_account(admin, name="Giro", currency="EUR", opening_balance_cents=100000) + chf = _mk_account(admin, name="Spar", type="sparkonto", currency="CHF", opening_balance_cents=0) + + admin.post("/api/transactions", json={"type": "income", "date": str(d), "account_id": eur["id"], "amount_cents": 50000}) + admin.post("/api/transactions", json={"type": "expense", "date": str(d), "account_id": eur["id"], "amount_cents": 20000}) + assert admin.get(f"/api/accounts/{eur['id']}").json()["balance_cents"] == 130000 + + # Transfer 100 EUR -> CHF (0.95) = 95 CHF gutgeschrieben. + r = admin.post("/api/transactions", json={ + "type": "transfer", "date": str(d), "account_id": eur["id"], + "to_account_id": chf["id"], "amount_cents": 10000, + }) + assert r.status_code == 201, r.text + assert r.json()["to_amount_cents"] == 9500 + assert admin.get(f"/api/accounts/{eur['id']}").json()["balance_cents"] == 120000 + assert admin.get(f"/api/accounts/{chf['id']}").json()["balance_cents"] == 9500 + + +def test_transfer_requires_other_account(admin): + eur = _mk_account(admin, name="Solo", currency="EUR") + d = date.today() + r = admin.post("/api/transactions", json={ + "type": "transfer", "date": str(d), "account_id": eur["id"], + "to_account_id": eur["id"], "amount_cents": 100, + }) + assert r.status_code == 400 diff --git a/backend/tests/test_currency.py b/backend/tests/test_currency.py new file mode 100644 index 0000000..ce616db --- /dev/null +++ b/backend/tests/test_currency.py @@ -0,0 +1,59 @@ +from datetime import date +from decimal import Decimal + + +def _ensure_rate(day: date, rate: str) -> None: + from app.database import SessionLocal + from app.models import ExchangeRate + + db = SessionLocal() + try: + if db.get(ExchangeRate, (day, "CHF")) is None: + db.add(ExchangeRate(date=day, currency="CHF", rate_per_eur=Decimal(rate))) + db.commit() + finally: + db.close() + + +def test_conversion_and_override(admin): + from app.database import SessionLocal + from app.services import currency + + d = date(2026, 1, 15) + _ensure_rate(d, "0.95") + db = SessionLocal() + try: + assert currency.to_eur_cents(db, 9500, "CHF", d) == 10000 # 9500 / 0.95 + assert currency.from_eur_cents(db, 10000, "CHF", d) == 9500 + assert currency.convert(db, 10000, "EUR", "CHF", d) == 9500 + assert currency.to_eur_cents(db, 5000, "EUR", d) == 5000 # EUR bleibt EUR + # Manuell gesetzter Kurs hat Vorrang vor dem EZB-Kurs. + assert currency.to_eur_cents(db, 10000, "CHF", d, fx_rate=2.0) == 5000 + finally: + db.close() + + +def test_weekend_fallback(admin): + from app.database import SessionLocal + from app.services import currency + + friday = date(2026, 3, 6) + sunday = date(2026, 3, 8) # kein Kurs -> Freitag gilt + _ensure_rate(friday, "0.90") + db = SessionLocal() + try: + assert currency.rate_on(db, "CHF", sunday) == Decimal("0.90") + finally: + db.close() + + +def test_missing_rate_returns_none(admin): + from app.database import SessionLocal + from app.services import currency + + db = SessionLocal() + try: + # Weit in der Vergangenheit gibt es keinen Kurs -> None. + assert currency.to_eur_cents(db, 1000, "CHF", date(1990, 1, 1)) is None + finally: + db.close() diff --git a/backend/tests/test_isolation.py b/backend/tests/test_isolation.py new file mode 100644 index 0000000..e33aff8 --- /dev/null +++ b/backend/tests/test_isolation.py @@ -0,0 +1,33 @@ +import secrets +from datetime import date + + +def test_users_are_isolated(admin): + from fastapi.testclient import TestClient + from app.main import app + + # Admin legt ein Konto an. + acc = admin.post("/api/accounts", json={"name": "AdminKonto", "currency": "EUR"}).json() + + # Frischen Nutzer anlegen und getrennt einloggen. + username = "iso_" + secrets.token_hex(4) + password = "pw_" + secrets.token_hex(4) + assert admin.post("/api/users", json={"username": username, "password": password, "role": "user"}).status_code == 201 + + other = TestClient(app) + assert other.post("/api/auth/login", data={"username": username, "password": password}).status_code == 200 + + # Sieht keine fremden Konten/Buchungen. + assert other.get("/api/accounts").json() == [] + assert other.get("/api/transactions").json() == [] + # Zugriff auf fremdes Konto -> 404 (nicht 403: existiert für ihn schlicht nicht). + assert other.get(f"/api/accounts/{acc['id']}").status_code == 404 + # Buchung auf fremdes Konto -> 404. + r = other.post("/api/transactions", json={ + "type": "expense", "date": str(date.today()), "account_id": acc["id"], "amount_cents": 100, + }) + assert r.status_code == 404 + # Eigene Startkategorien sind vorhanden. + assert len(other.get("/api/categories").json()) >= 6 + # Kein Admin: darf keine Nutzer listen. + assert other.get("/api/users").status_code == 403 diff --git a/web/src/App.jsx b/web/src/App.jsx index 694b446..9f9fada 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -7,6 +7,9 @@ import BrandMark from "./components/BrandMark"; import Login from "./pages/Login"; import ChangePassword from "./pages/ChangePassword"; import Dashboard from "./pages/Dashboard"; +import Accounts from "./pages/Accounts"; +import Categories from "./pages/Categories"; +import Transactions from "./pages/Transactions"; import Users from "./pages/Users"; import Settings from "./pages/Settings"; @@ -38,6 +41,9 @@ function Sidebar() {