feat: Projektfundament mit Datenmodell, Migrationen und Seed

- Repo-Struktur für Backend, Frontend, Dokumentation und Gitea-Workflows
- FastAPI-Skeleton mit pydantic-settings und RFC-7807-artigem Fehlerformat
- Vollständiges Datenmodell (15 Tabellen) als SQLAlchemy-2.0-Modelle
- Alembic gegen die Async-Engine inklusive Erstmigration
- Idempotenter Seed für den deutschen Kategoriebaum und Standardregeln
- Endpunkte /api/health und /api/version
- pytest-Infrastruktur mit transaktionsisolierten Fixtures

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
This commit is contained in:
moneyfy
2026-09-09 13:12:08 +02:00
co-authored by Claude Opus 5
commit 0b06775be3
40 changed files with 2236 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
"""SQLAlchemy-Modelle. Import hier hält Alembics Autogenerate vollständig."""
from app.db.base import Base
from app.models.core import Account, AppUser, Category, LogoAsset, Merchant
from app.models.enums import (
AccountType,
BusinessDayShift,
EntryKind,
LogoSource,
LogoStatus,
NotificationChannel,
NotificationStatus,
NotificationType,
OccurrenceStatus,
)
from app.models.finance import (
AmountVersion,
Budget,
BudgetTemplate,
Occurrence,
Recurrence,
ReserveLedger,
SavingsGoal,
Transaction,
)
from app.models.notification import NotificationLog, NotificationRule
__all__ = [
"Account",
"AccountType",
"AmountVersion",
"AppUser",
"Base",
"Budget",
"BudgetTemplate",
"BusinessDayShift",
"Category",
"EntryKind",
"LogoAsset",
"LogoSource",
"LogoStatus",
"Merchant",
"NotificationChannel",
"NotificationLog",
"NotificationRule",
"NotificationStatus",
"NotificationType",
"Occurrence",
"OccurrenceStatus",
"Recurrence",
"ReserveLedger",
"SavingsGoal",
"Transaction",
]
+150
View File
@@ -0,0 +1,150 @@
"""Stammdaten: Konten, Kategorien, Firmen, Logos, Benutzer."""
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import (
Boolean,
CheckConstraint,
Date,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base, CreatedAtMixin, Money, TimestampMixin
from app.models.enums import (
AccountType,
EntryKind,
LogoSource,
LogoStatus,
pg_enum,
)
class Account(Base, TimestampMixin):
"""Ein Konto.
Der Saldo wird aus Eröffnungssaldo plus allen bestätigten Buchungen fortgeschrieben.
"""
__tablename__ = "account"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(120), nullable=False)
type: Mapped[AccountType] = mapped_column(
pg_enum(AccountType, "account_type"), nullable=False, default=AccountType.CHECKING
)
iban_last4: Mapped[str | None] = mapped_column(String(4), nullable=True)
opening_balance: Mapped[Decimal] = mapped_column(Money, nullable=False, default=Decimal("0.00"))
opening_balance_date: Mapped[date] = mapped_column(Date, nullable=False)
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3b82f6")
icon: Mapped[str] = mapped_column(String(64), nullable=False, default="wallet")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
__table_args__ = (
UniqueConstraint("name", name="uq_account_name"),
Index("ix_account_is_active", "is_active"),
)
class Category(Base):
"""Zweistufiger Kategoriebaum Unterkategorien haben genau einen Elternknoten."""
__tablename__ = "category"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
parent_id: Mapped[int | None] = mapped_column(
ForeignKey("category.id", ondelete="RESTRICT"), nullable=True
)
name: Mapped[str] = mapped_column(String(120), nullable=False)
kind: Mapped[EntryKind] = mapped_column(pg_enum(EntryKind, "entry_kind"), nullable=False)
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#64748b")
icon: Mapped[str] = mapped_column(String(64), nullable=False, default="circle")
is_fixed_cost: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
parent: Mapped["Category | None"] = relationship(
remote_side="Category.id", back_populates="children"
)
children: Mapped[list["Category"]] = relationship(
back_populates="parent", cascade="save-update, merge", order_by="Category.sort_order"
)
__table_args__ = (
UniqueConstraint("parent_id", "name", name="uq_category_parent_id"),
Index("ix_category_kind", "kind"),
)
class LogoAsset(Base):
"""Im Volume abgelegtes Logo. Auslieferung erfolgt ausschließlich lokal."""
__tablename__ = "logo_asset"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
sha256: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
mime: Mapped[str] = mapped_column(String(64), nullable=False)
width: Mapped[int | None] = mapped_column(Integer, nullable=True)
height: Mapped[int | None] = mapped_column(Integer, nullable=True)
file_path: Mapped[str] = mapped_column(String(255), nullable=False)
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
class Merchant(Base, CreatedAtMixin):
"""Firma bzw. Zahlungsempfänger inklusive Markenlogo und -farbe."""
__tablename__ = "merchant"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(160), nullable=False)
normalized_name: Mapped[str] = mapped_column(String(160), nullable=False, unique=True)
domain: Mapped[str | None] = mapped_column(String(255), nullable=True)
aliases: Mapped[list[str]] = mapped_column(
ARRAY(Text), nullable=False, default=list, server_default="{}"
)
logo_asset_id: Mapped[int | None] = mapped_column(
ForeignKey("logo_asset.id", ondelete="SET NULL"), nullable=True
)
brand_color: Mapped[str | None] = mapped_column(String(9), nullable=True)
brand_color_dark: Mapped[str | None] = mapped_column(String(9), nullable=True)
logo_source: Mapped[LogoSource | None] = mapped_column(
pg_enum(LogoSource, "logo_source"), nullable=True
)
logo_status: Mapped[LogoStatus] = mapped_column(
pg_enum(LogoStatus, "logo_status"), nullable=False, default=LogoStatus.PENDING
)
logo_asset: Mapped[LogoAsset | None] = relationship(lazy="joined")
__table_args__ = (Index("ix_merchant_normalized_name", "normalized_name"),)
class AppUser(Base, CreatedAtMixin):
"""Single-User-Betrieb; `external_subject` ist für eine spätere OIDC-Anbindung vorgesehen."""
__tablename__ = "app_user"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(120), nullable=False, unique=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
must_change_password: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
external_subject: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
CheckConstraint(
"password_hash IS NOT NULL OR external_subject IS NOT NULL",
name="local_or_external_login",
),
)
+77
View File
@@ -0,0 +1,77 @@
"""Aufzählungstypen des Datenmodells (werden als native PostgreSQL-Enums angelegt)."""
from enum import StrEnum
from sqlalchemy import Enum as SAEnum
class AccountType(StrEnum):
CHECKING = "checking"
CREDIT_CARD = "credit_card"
SAVINGS = "savings"
CASH = "cash"
class EntryKind(StrEnum):
"""Richtung eines Postens: Ausgabe oder Einnahme."""
EXPENSE = "expense"
INCOME = "income"
class BusinessDayShift(StrEnum):
"""Verschiebung, wenn ein Fälligkeitstag auf Wochenende/Feiertag fällt."""
NONE = "none"
NEXT = "next"
PREVIOUS = "previous"
class OccurrenceStatus(StrEnum):
PLANNED = "planned"
CONFIRMED = "confirmed"
SKIPPED = "skipped"
class LogoSource(StrEnum):
SIMPLE_ICONS = "simple-icons"
LOGODEV = "logodev"
BRANDFETCH = "brandfetch"
FAVICON = "favicon"
UPLOAD = "upload"
GENERATED = "generated"
class LogoStatus(StrEnum):
PENDING = "pending"
RESOLVED = "resolved"
FAILED = "failed"
MANUAL = "manual"
class NotificationType(StrEnum):
DUE_SOON = "due_soon"
NOTICE_DEADLINE = "notice_deadline"
BUDGET_EXCEEDED = "budget_exceeded"
CONTRACT_RENEWAL = "contract_renewal"
class NotificationChannel(StrEnum):
SMTP = "smtp"
APPRISE = "apprise"
class NotificationStatus(StrEnum):
SENT = "sent"
FAILED = "failed"
def pg_enum(enum_cls: type[StrEnum], name: str) -> SAEnum:
"""Erzeugt einen nativen PostgreSQL-Enum-Typ mit den String-Werten der Enum-Klasse."""
return SAEnum(
enum_cls,
name=name,
native_enum=True,
values_callable=lambda cls: [member.value for member in cls],
validate_strings=True,
)
+271
View File
@@ -0,0 +1,271 @@
"""Bewegungsdaten: Recurrences, Preishistorie, Occurrences, Buchungen, Budgets, Ziele."""
from datetime import date
from decimal import Decimal
from sqlalchemy import (
Boolean,
CheckConstraint,
Date,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base, CreatedAtMixin, Money, TimestampMixin
from app.models.core import Account, Category, Merchant
from app.models.enums import BusinessDayShift, EntryKind, OccurrenceStatus, pg_enum
class Recurrence(Base, TimestampMixin):
"""Wiederkehrende Zahlung oder Einkunft, beschrieben durch eine RFC-5545-RRULE."""
__tablename__ = "recurrence"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
kind: Mapped[EntryKind] = mapped_column(pg_enum(EntryKind, "entry_kind"), nullable=False)
title: Mapped[str] = mapped_column(String(160), nullable=False)
merchant_id: Mapped[int | None] = mapped_column(
ForeignKey("merchant.id", ondelete="SET NULL"), nullable=True
)
category_id: Mapped[int] = mapped_column(
ForeignKey("category.id", ondelete="RESTRICT"), nullable=False
)
account_id: Mapped[int] = mapped_column(
ForeignKey("account.id", ondelete="RESTRICT"), nullable=False
)
amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
is_variable: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR")
# RRULE ohne DTSTART der Startzeitpunkt steht separat in `dtstart`.
rrule: Mapped[str] = mapped_column(Text, nullable=False)
dtstart: Mapped[date] = mapped_column(Date, nullable=False)
until: Mapped[date | None] = mapped_column(Date, nullable=True)
business_day_shift: Mapped[BusinessDayShift] = mapped_column(
pg_enum(BusinessDayShift, "business_day_shift"),
nullable=False,
default=BusinessDayShift.NEXT,
server_default="next",
)
holiday_region: Mapped[str] = mapped_column(
String(8), nullable=False, default="DE-NW", server_default="DE-NW"
)
# Ratenzahlung / Kredit
installments_total: Mapped[int | None] = mapped_column(Integer, nullable=True)
principal_amount: Mapped[Decimal | None] = mapped_column(Money, nullable=True)
# Vertragsdaten
contract_start: Mapped[date | None] = mapped_column(Date, nullable=True)
contract_min_term_months: Mapped[int | None] = mapped_column(Integer, nullable=True)
contract_notice_period_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
contract_auto_renew_months: Mapped[int | None] = mapped_column(Integer, nullable=True)
contract_cancelled_at: Mapped[date | None] = mapped_column(Date, nullable=True)
reserve_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
tags: Mapped[list[str]] = mapped_column(
ARRAY(Text), nullable=False, default=list, server_default="{}"
)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
merchant: Mapped[Merchant | None] = relationship(lazy="selectin")
category: Mapped[Category] = relationship(lazy="selectin")
account: Mapped[Account] = relationship(lazy="selectin")
amount_versions: Mapped[list["AmountVersion"]] = relationship(
back_populates="recurrence",
cascade="all, delete-orphan",
order_by="AmountVersion.valid_from",
lazy="selectin",
)
occurrences: Mapped[list["Occurrence"]] = relationship(
back_populates="recurrence",
cascade="all, delete-orphan",
order_by="Occurrence.occurrence_date",
)
__table_args__ = (
CheckConstraint(
"installments_total IS NULL OR installments_total > 0", name="installments"
),
Index("ix_recurrence_kind_active", "kind", "is_active"),
Index("ix_recurrence_category_id", "category_id"),
Index("ix_recurrence_merchant_id", "merchant_id"),
)
class AmountVersion(Base, CreatedAtMixin):
"""Preishistorie: gültig ist die Version mit dem größten `valid_from <= Fälligkeitsdatum`."""
__tablename__ = "amount_version"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
recurrence_id: Mapped[int] = mapped_column(
ForeignKey("recurrence.id", ondelete="CASCADE"), nullable=False
)
amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
valid_from: Mapped[date] = mapped_column(Date, nullable=False)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
recurrence: Mapped[Recurrence] = relationship(back_populates="amount_versions")
__table_args__ = (
UniqueConstraint("recurrence_id", "valid_from", name="uq_amount_version_recurrence_id"),
)
class Occurrence(Base, TimestampMixin):
"""Materialisierte Einzelfälligkeit nur bei Abweichung, Bestätigung oder Auslassung."""
__tablename__ = "occurrence"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
recurrence_id: Mapped[int] = mapped_column(
ForeignKey("recurrence.id", ondelete="CASCADE"), nullable=False
)
# Immer das nominale (unverschobene) Datum dadurch bleibt die Zuordnung stabil.
occurrence_date: Mapped[date] = mapped_column(Date, nullable=False)
status: Mapped[OccurrenceStatus] = mapped_column(
pg_enum(OccurrenceStatus, "occurrence_status"),
nullable=False,
default=OccurrenceStatus.PLANNED,
)
planned_amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
actual_amount: Mapped[Decimal | None] = mapped_column(Money, nullable=True)
actual_date: Mapped[date | None] = mapped_column(Date, nullable=True)
account_id: Mapped[int | None] = mapped_column(
ForeignKey("account.id", ondelete="RESTRICT"), nullable=True
)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
recurrence: Mapped[Recurrence] = relationship(back_populates="occurrences")
__table_args__ = (
UniqueConstraint("recurrence_id", "occurrence_date", name="uq_occurrence_recurrence_id"),
Index("ix_occurrence_date", "occurrence_date"),
)
class Transaction(Base, CreatedAtMixin):
"""Einmalige Buchung ohne Wiederholung."""
__tablename__ = "transaction"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
kind: Mapped[EntryKind] = mapped_column(pg_enum(EntryKind, "entry_kind"), nullable=False)
title: Mapped[str] = mapped_column(String(160), nullable=False)
merchant_id: Mapped[int | None] = mapped_column(
ForeignKey("merchant.id", ondelete="SET NULL"), nullable=True
)
category_id: Mapped[int] = mapped_column(
ForeignKey("category.id", ondelete="RESTRICT"), nullable=False
)
account_id: Mapped[int] = mapped_column(
ForeignKey("account.id", ondelete="RESTRICT"), nullable=False
)
amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
booking_date: Mapped[date] = mapped_column(Date, nullable=False)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
tags: Mapped[list[str]] = mapped_column(
ARRAY(Text), nullable=False, default=list, server_default="{}"
)
merchant: Mapped[Merchant | None] = relationship(lazy="selectin")
category: Mapped[Category] = relationship(lazy="selectin")
account: Mapped[Account] = relationship(lazy="selectin")
__table_args__ = (
Index("ix_transaction_booking_date", "booking_date"),
Index("ix_transaction_category_id", "category_id"),
)
class Budget(Base, CreatedAtMixin):
"""Monatsbudget je Kategorie. `period_month` ist immer der Monatserste."""
__tablename__ = "budget"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
category_id: Mapped[int] = mapped_column(
ForeignKey("category.id", ondelete="CASCADE"), nullable=False
)
period_month: Mapped[date] = mapped_column(Date, nullable=False)
limit_amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
rollover: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
category: Mapped[Category] = relationship(lazy="selectin")
__table_args__ = (
UniqueConstraint("category_id", "period_month", name="uq_budget_category_id"),
CheckConstraint("date_trunc('month', period_month) = period_month", name="month_start"),
)
class BudgetTemplate(Base, TimestampMixin):
"""Dauerhaftes Budget ab einem Monat erspart die Pflege jedes einzelnen Monats."""
__tablename__ = "budget_template"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
category_id: Mapped[int] = mapped_column(
ForeignKey("category.id", ondelete="CASCADE"), nullable=False
)
valid_from: Mapped[date] = mapped_column(Date, nullable=False)
valid_until: Mapped[date | None] = mapped_column(Date, nullable=True)
limit_amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
rollover: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
category: Mapped[Category] = relationship(lazy="selectin")
__table_args__ = (
UniqueConstraint("category_id", "valid_from", name="uq_budget_template_category_id"),
CheckConstraint("date_trunc('month', valid_from) = valid_from", name="month_start"),
)
class SavingsGoal(Base, TimestampMixin):
"""Sparziel mit Fortschritt und optionaler Zielrate."""
__tablename__ = "savings_goal"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(160), nullable=False)
target_amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
target_date: Mapped[date | None] = mapped_column(Date, nullable=True)
current_amount: Mapped[Decimal] = mapped_column(Money, nullable=False, default=Decimal("0.00"))
account_id: Mapped[int | None] = mapped_column(
ForeignKey("account.id", ondelete="SET NULL"), nullable=True
)
monthly_contribution: Mapped[Decimal | None] = mapped_column(Money, nullable=True)
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#10b981")
icon: Mapped[str] = mapped_column(String(64), nullable=False, default="piggy-bank")
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
__table_args__ = (UniqueConstraint("name", name="uq_savings_goal_name"),)
class ReserveLedger(Base, CreatedAtMixin):
"""Monatliche Rücklagenbildung für nicht-monatliche Posten."""
__tablename__ = "reserve_ledger"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
recurrence_id: Mapped[int] = mapped_column(
ForeignKey("recurrence.id", ondelete="CASCADE"), nullable=False
)
period_month: Mapped[date] = mapped_column(Date, nullable=False)
amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
is_released: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
__table_args__ = (
UniqueConstraint("recurrence_id", "period_month", name="uq_reserve_ledger_recurrence_id"),
CheckConstraint("date_trunc('month', period_month) = period_month", name="month_start"),
)
+73
View File
@@ -0,0 +1,73 @@
"""Benachrichtigungsregeln und Versandprotokoll."""
from datetime import date, datetime
from sqlalchemy import (
Boolean,
Date,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base, CreatedAtMixin
from app.models.enums import (
NotificationChannel,
NotificationStatus,
NotificationType,
pg_enum,
)
class NotificationRule(Base, CreatedAtMixin):
"""Eine Regel pro Ereignistyp und Kanal."""
__tablename__ = "notification_rule"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
type: Mapped[NotificationType] = mapped_column(
pg_enum(NotificationType, "notification_type"), nullable=False
)
lead_days: Mapped[int] = mapped_column(Integer, nullable=False, default=7)
channel: Mapped[NotificationChannel] = mapped_column(
pg_enum(NotificationChannel, "notification_channel"), nullable=False
)
target: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
logs: Mapped[list["NotificationLog"]] = relationship(
back_populates="rule", cascade="all, delete-orphan"
)
class NotificationLog(Base):
"""Versandprotokoll. `dedupe_day` verhindert Doppelversand am selben Tag."""
__tablename__ = "notification_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
rule_id: Mapped[int] = mapped_column(
ForeignKey("notification_rule.id", ondelete="CASCADE"), nullable=False
)
ref_type: Mapped[str] = mapped_column(String(40), nullable=False)
ref_id: Mapped[str] = mapped_column(String(80), nullable=False)
dedupe_day: Mapped[date] = mapped_column(Date, nullable=False)
sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
status: Mapped[NotificationStatus] = mapped_column(
pg_enum(NotificationStatus, "notification_status"), nullable=False
)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
rule: Mapped[NotificationRule] = relationship(back_populates="logs")
__table_args__ = (
UniqueConstraint(
"rule_id", "ref_type", "ref_id", "dedupe_day", name="uq_notification_log_rule_id"
),
Index("ix_notification_log_sent_at", "sent_at"),
)