feat: Phase 1 – Konten, Kategorien, Transaktionen, EZB-Währungsumrechnung

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 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-19 18:59:24 +02:00
co-authored by Claude Opus 4.8
parent 2c795a57c8
commit 1b7107b9bd
35 changed files with 3116 additions and 6 deletions
+194 -1
View File
@@ -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")