- 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
369 lines
13 KiB
Python
369 lines
13 KiB
Python
"""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))
|