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