"""Schemata für wiederkehrende Posten, Preisversionen und Vorschau.""" from datetime import date, datetime from pydantic import Field, model_validator from app.models.enums import BusinessDayShift, EntryKind from app.schemas.common import ApiModel, InputModel, Money, PositiveMoney from app.schemas.merchant import MerchantOut from app.services.recurrence import InvalidRRuleError, validate_rrule class RecurrenceBase(InputModel): kind: EntryKind title: str = Field(min_length=1, max_length=160, examples=["Netflix Standard"]) merchant_id: int | None = None category_id: int account_id: int amount: PositiveMoney = Field( description="Aktueller Betrag. Immer positiv – die Richtung steckt in `kind`." ) is_variable: bool = Field( default=False, description="Geschätzter Betrag, das Ist weicht regelmäßig ab." ) currency: str = Field(default="EUR", pattern=r"^[A-Z]{3}$") rrule: str = Field( max_length=500, examples=["FREQ=MONTHLY;BYMONTHDAY=1"], description="Vollständige RFC-5545-RRULE ohne DTSTART.", ) dtstart: date = Field(description="Erste mögliche Fälligkeit der Serie.") until: date | None = Field(default=None, description="Hartes Serienende, einschließlich.") business_day_shift: BusinessDayShift = Field( default=BusinessDayShift.NEXT, description="Verschiebung, wenn der Termin auf Wochenende oder Feiertag fällt.", ) holiday_region: str = Field(default="DE-NW", max_length=8, examples=["DE-NW"]) installments_total: int | None = Field( default=None, ge=1, description="Anzahl Raten; beendet die Serie unabhängig von der RRULE." ) principal_amount: PositiveMoney | None = Field( default=None, description="Ursprüngliche Darlehenssumme für die Restschuldberechnung." ) contract_start: date | None = None contract_min_term_months: int | None = Field(default=None, ge=1) contract_notice_period_days: int | None = Field(default=None, ge=0) contract_auto_renew_months: int | None = Field(default=None, ge=1) reserve_enabled: bool = Field( default=False, description="Bildet monatliche Rücklagen für nicht-monatliche Posten." ) notes: str | None = None tags: list[str] = Field(default_factory=list) is_active: bool = True @model_validator(mode="after") def _check_rule_and_dates(self) -> "RecurrenceBase": try: validate_rrule(self.rrule, self.dtstart) except InvalidRRuleError as exc: raise ValueError(str(exc)) from exc if self.until is not None and self.until < self.dtstart: raise ValueError("Das Serienende darf nicht vor dem Start liegen.") return self class RecurrenceCreate(RecurrenceBase): pass class RecurrenceUpdate(InputModel): """Alle Felder optional. RRULE und `dtstart` werden zusammen geprüft.""" kind: EntryKind | None = None title: str | None = Field(default=None, min_length=1, max_length=160) merchant_id: int | None = None category_id: int | None = None account_id: int | None = None amount: PositiveMoney | None = None is_variable: bool | None = None currency: str | None = Field(default=None, pattern=r"^[A-Z]{3}$") rrule: str | None = Field(default=None, max_length=500) dtstart: date | None = None until: date | None = None business_day_shift: BusinessDayShift | None = None holiday_region: str | None = Field(default=None, max_length=8) installments_total: int | None = Field(default=None, ge=1) principal_amount: PositiveMoney | None = None contract_start: date | None = None contract_min_term_months: int | None = Field(default=None, ge=1) contract_notice_period_days: int | None = Field(default=None, ge=0) contract_auto_renew_months: int | None = Field(default=None, ge=1) contract_cancelled_at: date | None = None reserve_enabled: bool | None = None notes: str | None = None tags: list[str] | None = None is_active: bool | None = None class AmountVersionCreate(InputModel): amount: PositiveMoney valid_from: date = Field(description="Gilt für alle Fälligkeiten ab diesem Tag.") note: str | None = Field(default=None, examples=["Preiserhöhung laut Schreiben vom 01.06."]) class AmountVersionOut(ApiModel): id: int recurrence_id: int amount: Money valid_from: date note: str | None created_at: datetime class ContractTermOut(ApiModel): """Laufende Vertragsperiode und Kündigungstermin.""" term_start: date term_end: date notice_deadline: date | None renews_on: date | None is_cancelled: bool class InstallmentStatusOut(ApiModel): """Stand einer Ratenzahlung.""" total: int paid: int remaining: int paid_amount: Money remaining_amount: Money final_due_date: date | None class RecurrenceOut(ApiModel): id: int kind: EntryKind title: str merchant_id: int | None category_id: int account_id: int amount: Money is_variable: bool currency: str rrule: str dtstart: date until: date | None business_day_shift: BusinessDayShift holiday_region: str installments_total: int | None principal_amount: Money | None contract_start: date | None contract_min_term_months: int | None contract_notice_period_days: int | None contract_auto_renew_months: int | None contract_cancelled_at: date | None reserve_enabled: bool notes: str | None tags: list[str] is_active: bool created_at: datetime updated_at: datetime class RecurrenceDetailOut(RecurrenceOut): """Posten samt berechneter Zusatzangaben für die Detailansicht.""" merchant: MerchantOut | None = None amount_versions: list[AmountVersionOut] = Field(default_factory=list) next_dates: list[date] = Field( default_factory=list, description="Die nächsten fünf nominalen Termine." ) monthly_reserve: Money | None = Field( default=None, description="Rücklage pro Monat, wenn `reserve_enabled` gesetzt ist." ) annual_burden: Money = Field(description="Belastung der kommenden zwölf Monate.") contract_term: ContractTermOut | None = None installments: InstallmentStatusOut | None = None