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:
+194
-1
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user