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

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