Files
moneyfyandClaude Opus 5 b586d27b77 feat(api): Core-API mit Authentifizierung, CRUD und Monatsreport
- Anmeldung über Argon2id und JWT in httpOnly-Cookies, Refresh mit echter
  Rotation über die neue Tabelle refresh_token
- AuthProvider-Protokoll als Vorbereitung für OIDC, Administrator-Anlage beim
  Erststart mit erzwungenem Passwortwechsel
- CRUD für Konten, Kategorien (zweistufiger Baum), Firmen, Recurrences,
  Preisversionen, Buchungen, Budgets, Vorlagen und Sparziele
- Fälligkeiten mit Overlay-Logik: abrufen, bestätigen, auslassen, zurücksetzen
- Kontosalden zum Stichtag, Monatsübersicht mit Plan-Ist-Vergleich
- SECRET_KEY jetzt mindestens 32 Zeichen; Platzhalter in Produktion abgelehnt
- 61 neue Integrationstests, insgesamt 148 grün

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
2026-09-09 13:40:37 +02:00

184 lines
6.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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