- 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
272 lines
11 KiB
Python
272 lines
11 KiB
Python
"""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"),
|
||
)
|