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

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)