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
@@ -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")