"""Wiederkehrende Posten samt Preisversionen, Vorschau und Kündigung.""" from datetime import date from fastapi import APIRouter, Query, status from sqlalchemy import select from sqlalchemy.exc import IntegrityError from app.api.deps import DbSession from app.core.clock import add_months, today from app.core.errors import ConflictError, ValidationError from app.models import AmountVersion, Category, Occurrence, Recurrence from app.models.enums import EntryKind from app.schemas.common import ErrorResponse, MessageResponse from app.schemas.merchant import MerchantOut from app.schemas.occurrence import OccurrenceOut from app.schemas.recurrence import ( AmountVersionCreate, AmountVersionOut, ContractTermOut, InstallmentStatusOut, RecurrenceCreate, RecurrenceDetailOut, RecurrenceOut, RecurrenceUpdate, ) from app.services.crud import apply_updates, get_or_404 from app.services.occurrences import get_recurrence from app.services.recurrence import ( InvalidRRuleError, annual_burden, contract_term, expand, installments_remaining, monthly_reserve, next_dates, validate_rrule, ) router = APIRouter(prefix="/recurrences", tags=["recurrences"]) NOT_FOUND = {status.HTTP_404_NOT_FOUND: {"model": ErrorResponse}} def to_occurrence_out(planned, recurrence: Recurrence) -> OccurrenceOut: """Übersetzt ein Engine-Ergebnis in das API-Schema.""" return OccurrenceOut( recurrence_id=recurrence.id, recurrence_title=recurrence.title, kind=planned.kind, category_id=recurrence.category_id, merchant_id=recurrence.merchant_id, account_id=planned.account_id, nominal_date=planned.nominal_date, due_date=planned.due_date, effective_date=planned.effective_date, amount=planned.amount, actual_amount=planned.actual_amount, effective_amount=planned.effective_amount, status=planned.status, is_variable=planned.is_variable, occurrence_id=planned.occurrence_id, note=planned.note, installment_number=planned.installment_number, installments_total=planned.installments_total, ) async def _check_category(session: DbSession, category_id: int, kind: EntryKind) -> None: """Kategorie und Posten müssen dieselbe Richtung haben.""" category = await get_or_404(session, Category, category_id) if category.kind is not kind: richtung = "Einkünfte" if kind is EntryKind.INCOME else "Ausgaben" raise ValidationError( f"Die Kategorie '{category.name}' ist nicht für {richtung} vorgesehen.", code="category_kind_mismatch", ) async def _detail(session: DbSession, recurrence: Recurrence) -> RecurrenceDetailOut: """Reichert einen Posten um die berechneten Kennzahlen an.""" reference = today() term = contract_term(recurrence, reference) installments = installments_remaining( recurrence, reference, amount_versions=recurrence.amount_versions ) return RecurrenceDetailOut( **RecurrenceOut.model_validate(recurrence).model_dump(), merchant=MerchantOut.model_validate(recurrence.merchant) if recurrence.merchant else None, amount_versions=[ AmountVersionOut.model_validate(version) for version in recurrence.amount_versions ], next_dates=next_dates(recurrence, count=5, after=reference), monthly_reserve=( monthly_reserve(recurrence, reference, amount_versions=recurrence.amount_versions) if recurrence.reserve_enabled else None ), annual_burden=annual_burden( recurrence, reference, amount_versions=recurrence.amount_versions ), contract_term=ContractTermOut.model_validate(term) if term else None, installments=(InstallmentStatusOut.model_validate(installments) if installments else None), ) @router.get("", response_model=list[RecurrenceOut], summary="Posten auflisten") async def list_recurrences( session: DbSession, kind: EntryKind | None = Query(default=None), active: bool | None = Query(default=None, description="Nach Aktivstatus filtern."), category_id: int | None = Query(default=None), account_id: int | None = Query(default=None), merchant_id: int | None = Query(default=None), ) -> list[Recurrence]: stmt = select(Recurrence).order_by(Recurrence.title) if kind is not None: stmt = stmt.where(Recurrence.kind == kind) if active is not None: stmt = stmt.where(Recurrence.is_active.is_(active)) if category_id is not None: stmt = stmt.where(Recurrence.category_id == category_id) if account_id is not None: stmt = stmt.where(Recurrence.account_id == account_id) if merchant_id is not None: stmt = stmt.where(Recurrence.merchant_id == merchant_id) return list((await session.execute(stmt)).scalars().all()) @router.post( "", response_model=RecurrenceDetailOut, status_code=status.HTTP_201_CREATED, summary="Posten anlegen", description="Legt zugleich die erste Preisversion ab `dtstart` an.", ) async def create_recurrence(payload: RecurrenceCreate, session: DbSession) -> RecurrenceDetailOut: await _check_category(session, payload.category_id, payload.kind) recurrence = Recurrence(**payload.model_dump()) session.add(recurrence) await session.flush() # Erste Preisversion, damit die Preishistorie von Anfang an lückenlos ist. session.add( AmountVersion( recurrence_id=recurrence.id, amount=payload.amount, valid_from=payload.dtstart, note="Anfangsbetrag", ) ) try: await session.commit() except IntegrityError as exc: await session.rollback() raise ConflictError("Der Posten konnte nicht angelegt werden.") from exc return await _detail(session, await get_recurrence(session, recurrence.id)) @router.get( "/{recurrence_id}", response_model=RecurrenceDetailOut, responses=NOT_FOUND, summary="Posten lesen", ) async def read_recurrence(recurrence_id: int, session: DbSession) -> RecurrenceDetailOut: return await _detail(session, await get_recurrence(session, recurrence_id)) @router.patch( "/{recurrence_id}", response_model=RecurrenceDetailOut, responses=NOT_FOUND, summary="Posten ändern", description="Eine Betragsänderung hier gilt rückwirkend für die ganze Serie. " "Für einen Preiswechsel ab einem Stichtag stattdessen eine Preisversion anlegen.", ) async def update_recurrence( recurrence_id: int, payload: RecurrenceUpdate, session: DbSession ) -> RecurrenceDetailOut: recurrence = await get_recurrence(session, recurrence_id) kind = payload.kind or recurrence.kind if payload.category_id is not None or payload.kind is not None: await _check_category(session, payload.category_id or recurrence.category_id, kind) rrule = payload.rrule if payload.rrule is not None else recurrence.rrule dtstart = payload.dtstart if payload.dtstart is not None else recurrence.dtstart if payload.rrule is not None or payload.dtstart is not None: try: validate_rrule(rrule, dtstart) except InvalidRRuleError as exc: raise ValidationError(str(exc), code="invalid_rrule") from exc until = payload.until if "until" in payload.model_fields_set else recurrence.until if until is not None and until < dtstart: raise ValidationError( "Das Serienende darf nicht vor dem Start liegen.", code="invalid_date_range" ) apply_updates(recurrence, payload) await session.commit() return await _detail(session, await get_recurrence(session, recurrence_id)) @router.delete( "/{recurrence_id}", response_model=MessageResponse, responses=NOT_FOUND, summary="Posten löschen", description="Entfernt den Posten samt Preishistorie und erfassten Fälligkeiten. " "Für die Erhaltung der Historie besser `is_active=false` setzen.", ) async def delete_recurrence(recurrence_id: int, session: DbSession) -> MessageResponse: recurrence = await get_or_404(session, Recurrence, recurrence_id) await session.delete(recurrence) await session.commit() return MessageResponse(detail="Wiederkehrender Posten gelöscht.") # --- Preisversionen ------------------------------------------------------------ @router.get( "/{recurrence_id}/amount-versions", response_model=list[AmountVersionOut], responses=NOT_FOUND, summary="Preishistorie lesen", ) async def list_amount_versions(recurrence_id: int, session: DbSession) -> list[AmountVersion]: recurrence = await get_recurrence(session, recurrence_id) return list(recurrence.amount_versions) @router.post( "/{recurrence_id}/amount-versions", response_model=AmountVersionOut, status_code=status.HTTP_201_CREATED, responses=NOT_FOUND, summary="Preisversion anlegen", description="Ab `valid_from` gilt der neue Betrag. Vergangene Fälligkeiten " "bleiben dadurch betragstreu.", ) async def create_amount_version( recurrence_id: int, payload: AmountVersionCreate, session: DbSession ) -> AmountVersion: recurrence = await get_recurrence(session, recurrence_id) version = AmountVersion( recurrence_id=recurrence.id, amount=payload.amount, valid_from=payload.valid_from, note=payload.note, ) session.add(version) # Der Basisbetrag folgt der jüngsten Version, damit Liste und Detail übereinstimmen. if all(existing.valid_from <= payload.valid_from for existing in recurrence.amount_versions): recurrence.amount = payload.amount try: await session.commit() except IntegrityError as exc: await session.rollback() raise ConflictError( f"Zum {payload.valid_from.isoformat()} existiert bereits eine Preisversion." ) from exc await session.refresh(version) return version @router.delete( "/{recurrence_id}/amount-versions/{version_id}", response_model=MessageResponse, responses=NOT_FOUND, summary="Preisversion löschen", ) async def delete_amount_version( recurrence_id: int, version_id: int, session: DbSession ) -> MessageResponse: version = await get_or_404(session, AmountVersion, version_id) if version.recurrence_id != recurrence_id: raise ValidationError( "Die Preisversion gehört nicht zu diesem Posten.", code="version_mismatch" ) await session.delete(version) await session.commit() return MessageResponse(detail="Preisversion gelöscht.") # --- Vorschau und Kündigung ---------------------------------------------------- @router.get( "/{recurrence_id}/preview", response_model=list[OccurrenceOut], responses=NOT_FOUND, summary="Fälligkeiten vorschauen", description="Berechnete Termine im Zeitfenster, gefiltert nach dem nominalen Datum.", ) async def preview( recurrence_id: int, session: DbSession, date_from: date | None = Query(default=None, alias="from"), date_to: date | None = Query(default=None, alias="to"), ) -> list[OccurrenceOut]: recurrence = await get_recurrence(session, recurrence_id) start = date_from or today() end = date_to or add_months(start, 12) if end < start: raise ValidationError("'to' darf nicht vor 'from' liegen.", code="invalid_date_range") stmt = select(Occurrence).where( Occurrence.recurrence_id == recurrence_id, Occurrence.occurrence_date >= start, Occurrence.occurrence_date <= end, ) overlays = list((await session.execute(stmt)).scalars().all()) planned = expand( recurrence, start, end, amount_versions=recurrence.amount_versions, occurrences=overlays, ) return [to_occurrence_out(item, recurrence) for item in planned] @router.post( "/{recurrence_id}/cancel", response_model=RecurrenceDetailOut, responses=NOT_FOUND, summary="Vertrag kündigen", description="Setzt `contract_cancelled_at`. Ohne Datum wird der nächste " "Kündigungstermin verwendet, ersatzweise das Vertragsende.", ) async def cancel_recurrence( recurrence_id: int, session: DbSession, effective_date: date | None = Query( default=None, description="Letzter Tag, an dem der Vertrag läuft." ), ) -> RecurrenceDetailOut: recurrence = await get_recurrence(session, recurrence_id) cancel_on = effective_date if cancel_on is None: term = contract_term(recurrence, today()) if term is None: raise ValidationError( "Für diesen Posten ist keine Vertragslaufzeit hinterlegt – bitte " "ein Kündigungsdatum angeben.", code="no_contract_term", ) cancel_on = term.term_end if cancel_on < recurrence.dtstart: raise ValidationError( "Das Kündigungsdatum darf nicht vor dem Serienstart liegen.", code="invalid_date_range", ) recurrence.contract_cancelled_at = cancel_on await session.commit() return await _detail(session, await get_recurrence(session, recurrence_id))