- 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
153 lines
5.3 KiB
Python
153 lines
5.3 KiB
Python
"""Fälligkeiten über alle Posten hinweg: abrufen, bestätigen, auslassen."""
|
|
|
|
from datetime import date
|
|
|
|
from fastapi import APIRouter, Query, status
|
|
|
|
from app.api.deps import DbSession
|
|
from app.api.routes.recurrences import to_occurrence_out
|
|
from app.core.clock import add_months, today
|
|
from app.core.errors import ValidationError
|
|
from app.models.enums import EntryKind, OccurrenceStatus
|
|
from app.schemas.common import ErrorResponse, MessageResponse
|
|
from app.schemas.occurrence import (
|
|
OccurrenceConfirm,
|
|
OccurrenceOut,
|
|
OccurrenceReset,
|
|
OccurrenceSkip,
|
|
)
|
|
from app.services import occurrences as service
|
|
|
|
router = APIRouter(prefix="/occurrences", tags=["occurrences"])
|
|
|
|
NOT_FOUND = {status.HTTP_404_NOT_FOUND: {"model": ErrorResponse}}
|
|
|
|
|
|
@router.get(
|
|
"",
|
|
response_model=list[OccurrenceOut],
|
|
summary="Fälligkeiten im Zeitraum",
|
|
description="Expandiert alle passenden Posten und überlagert sie mit den "
|
|
"erfassten Abweichungen. Ohne Angabe umfasst das Fenster die nächsten drei Monate.",
|
|
)
|
|
async def list_occurrences(
|
|
session: DbSession,
|
|
date_from: date | None = Query(default=None, alias="from"),
|
|
date_to: date | None = Query(default=None, alias="to"),
|
|
kind: EntryKind | None = Query(default=None),
|
|
category_id: int | None = Query(default=None),
|
|
account_id: int | None = Query(default=None),
|
|
recurrence_id: int | None = Query(default=None),
|
|
status_filter: OccurrenceStatus | None = Query(
|
|
default=None, alias="status", description="Nach Status filtern."
|
|
),
|
|
include_inactive: bool = Query(
|
|
default=False, description="Auch deaktivierte Posten einbeziehen."
|
|
),
|
|
by_due_date: bool = Query(
|
|
default=True,
|
|
description="True gruppiert nach dem tatsächlichen Zahltag, False nach dem "
|
|
"nominalen Datum.",
|
|
),
|
|
) -> list[OccurrenceOut]:
|
|
start = date_from or today()
|
|
end = date_to or add_months(start, 3)
|
|
if end < start:
|
|
raise ValidationError("'to' darf nicht vor 'from' liegen.", code="invalid_date_range")
|
|
|
|
items = await service.due_items(
|
|
session,
|
|
start,
|
|
end,
|
|
by_due_date=by_due_date,
|
|
kind=kind,
|
|
only_active=not include_inactive,
|
|
category_id=category_id,
|
|
account_id=account_id,
|
|
recurrence_id=recurrence_id,
|
|
)
|
|
if status_filter is not None:
|
|
items = [item for item in items if item.planned.status is status_filter]
|
|
|
|
return [to_occurrence_out(item.planned, item.recurrence) for item in items]
|
|
|
|
|
|
@router.post(
|
|
"/confirm",
|
|
response_model=OccurrenceOut,
|
|
responses=NOT_FOUND,
|
|
summary="Fälligkeit bestätigen",
|
|
description="Schlüssel ist das nominale Datum. Ohne `actual_amount` gilt der Sollbetrag.",
|
|
)
|
|
async def confirm_occurrence(payload: OccurrenceConfirm, session: DbSession) -> OccurrenceOut:
|
|
await service.confirm(
|
|
session,
|
|
payload.recurrence_id,
|
|
payload.occurrence_date,
|
|
actual_amount=payload.actual_amount,
|
|
actual_date=payload.actual_date,
|
|
account_id=payload.account_id,
|
|
note=payload.note,
|
|
)
|
|
await session.commit()
|
|
return await _single(session, payload.recurrence_id, payload.occurrence_date)
|
|
|
|
|
|
@router.post(
|
|
"/skip",
|
|
response_model=OccurrenceOut,
|
|
responses=NOT_FOUND,
|
|
summary="Fälligkeit auslassen",
|
|
description="Die Fälligkeit zählt danach in keiner Auswertung mehr mit.",
|
|
)
|
|
async def skip_occurrence(payload: OccurrenceSkip, session: DbSession) -> OccurrenceOut:
|
|
await service.skip(session, payload.recurrence_id, payload.occurrence_date, note=payload.note)
|
|
await session.commit()
|
|
return await _single(session, payload.recurrence_id, payload.occurrence_date)
|
|
|
|
|
|
@router.post(
|
|
"/reset",
|
|
response_model=OccurrenceOut,
|
|
responses=NOT_FOUND,
|
|
summary="Bestätigung zurücknehmen",
|
|
description="Löscht die erfasste Abweichung; die Fälligkeit gilt wieder als geplant.",
|
|
)
|
|
async def reset_occurrence(payload: OccurrenceReset, session: DbSession) -> OccurrenceOut:
|
|
await service.reset(session, payload.recurrence_id, payload.occurrence_date)
|
|
await session.commit()
|
|
return await _single(session, payload.recurrence_id, payload.occurrence_date)
|
|
|
|
|
|
@router.delete(
|
|
"/{recurrence_id}/{occurrence_date}",
|
|
response_model=MessageResponse,
|
|
responses=NOT_FOUND,
|
|
summary="Erfasste Abweichung löschen",
|
|
)
|
|
async def delete_occurrence(
|
|
recurrence_id: int, occurrence_date: date, session: DbSession
|
|
) -> MessageResponse:
|
|
await service.reset(session, recurrence_id, occurrence_date)
|
|
await session.commit()
|
|
return MessageResponse(detail="Erfasste Abweichung gelöscht.")
|
|
|
|
|
|
async def _single(session: DbSession, recurrence_id: int, occurrence_date: date) -> OccurrenceOut:
|
|
"""Liest genau eine Fälligkeit nach einer Änderung frisch aus."""
|
|
recurrence = await service.get_recurrence(session, recurrence_id)
|
|
items = await service.due_items(
|
|
session,
|
|
occurrence_date,
|
|
occurrence_date,
|
|
by_due_date=False,
|
|
only_active=False,
|
|
recurrence_id=recurrence_id,
|
|
)
|
|
if not items:
|
|
raise ValidationError(
|
|
f"Zum {occurrence_date.isoformat()} gibt es für '{recurrence.title}' keine Fälligkeit.",
|
|
code="occurrence_not_due",
|
|
)
|
|
return to_occurrence_out(items[0].planned, items[0].recurrence)
|