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
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
"""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))
|
||||
Reference in New Issue
Block a user