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:
moneyfy
2026-09-09 13:40:37 +02:00
co-authored by Claude Opus 5
parent 70d73cf8d3
commit b586d27b77
46 changed files with 4866 additions and 21 deletions
+257
View File
@@ -0,0 +1,257 @@
"""Fälligkeiten: virtuelle Expansion über alle Posten sowie Bestätigen und Auslassen."""
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import date, timedelta
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.errors import NotFoundError, ValidationError
from app.models import Occurrence, Recurrence
from app.models.enums import EntryKind, OccurrenceStatus
from app.services.recurrence import (
PlannedOccurrence,
expand,
expand_by_due_date,
resolve_amount,
)
# Overlays werden mit Puffer geladen, weil Ist-Datum und Soll-Datum auseinanderliegen können.
_OVERLAY_PADDING_DAYS = timedelta(days=45)
@dataclass(frozen=True, slots=True)
class DueItem:
"""Eine Fälligkeit samt der Recurrence, aus der sie stammt."""
planned: PlannedOccurrence
recurrence: Recurrence
async def load_recurrences(
session: AsyncSession,
*,
kind: EntryKind | None = None,
only_active: bool = True,
category_id: int | None = None,
account_id: int | None = None,
recurrence_id: int | None = None,
) -> list[Recurrence]:
"""Lädt Posten samt Preishistorie in einem Rutsch."""
stmt = (
select(Recurrence)
.options(selectinload(Recurrence.amount_versions))
.order_by(Recurrence.title)
)
if only_active:
stmt = stmt.where(Recurrence.is_active.is_(True))
if kind is not None:
stmt = stmt.where(Recurrence.kind == kind)
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 recurrence_id is not None:
stmt = stmt.where(Recurrence.id == recurrence_id)
return list((await session.execute(stmt)).scalars().all())
async def load_overlays(
session: AsyncSession,
recurrence_ids: Sequence[int],
date_from: date,
date_to: date,
) -> dict[int, list[Occurrence]]:
"""Materialisierte Fälligkeiten im Zeitfenster, nach Recurrence gruppiert.
Das Fenster wird großzügig gewählt, weil Ist-Datum und nominales Datum
auseinanderliegen können.
"""
if not recurrence_ids:
return {}
stmt = select(Occurrence).where(
Occurrence.recurrence_id.in_(recurrence_ids),
Occurrence.occurrence_date >= date_from,
Occurrence.occurrence_date <= date_to,
)
grouped: dict[int, list[Occurrence]] = {}
for row in (await session.execute(stmt)).scalars():
grouped.setdefault(row.recurrence_id, []).append(row)
return grouped
async def due_items(
session: AsyncSession,
date_from: date,
date_to: date,
*,
by_due_date: bool = True,
kind: EntryKind | None = None,
only_active: bool = True,
category_id: int | None = None,
account_id: int | None = None,
recurrence_id: int | None = None,
) -> list[DueItem]:
"""Expandiert alle passenden Posten über das Zeitfenster.
`by_due_date=True` gruppiert nach dem tatsächlichen Zahltag die richtige
Sicht für Kalender und Monatsauswertungen. `False` filtert nach dem nominalen
Datum.
"""
recurrences = await load_recurrences(
session,
kind=kind,
only_active=only_active,
category_id=category_id,
account_id=account_id,
recurrence_id=recurrence_id,
)
if not recurrences:
return []
# Overlays großzügig laden: eine Zahlung kann Wochen nach dem Soll erfasst werden.
padding = _OVERLAY_PADDING_DAYS
overlays = await load_overlays(
session,
[item.id for item in recurrences],
date_from - padding,
date_to + padding,
)
expander = expand_by_due_date if by_due_date else expand
items: list[DueItem] = []
for recurrence in recurrences:
planned = expander(
recurrence,
date_from,
date_to,
amount_versions=recurrence.amount_versions,
occurrences=overlays.get(recurrence.id, []),
)
items.extend(DueItem(planned=entry, recurrence=recurrence) for entry in planned)
items.sort(key=lambda item: (item.planned.effective_date, item.recurrence.title))
return items
async def get_recurrence(session: AsyncSession, recurrence_id: int) -> Recurrence:
stmt = (
select(Recurrence)
.options(selectinload(Recurrence.amount_versions))
.where(Recurrence.id == recurrence_id)
)
recurrence = (await session.execute(stmt)).scalar_one_or_none()
if recurrence is None:
raise NotFoundError(
f"Der wiederkehrende Posten mit der ID {recurrence_id} existiert nicht."
)
return recurrence
async def _materialise(
session: AsyncSession, recurrence: Recurrence, occurrence_date: date
) -> Occurrence:
"""Legt die Zeile für eine Fälligkeit an oder lädt die vorhandene.
Es werden nur Termine akzeptiert, die die Wiederholungsregel tatsächlich liefert
das nominale Datum ist der Schlüssel.
"""
matches = expand(
recurrence,
occurrence_date,
occurrence_date,
amount_versions=recurrence.amount_versions,
)
if not matches:
raise ValidationError(
f"Zum {occurrence_date.isoformat()} gibt es für '{recurrence.title}' keine Fälligkeit.",
code="occurrence_not_due",
)
stmt = select(Occurrence).where(
Occurrence.recurrence_id == recurrence.id,
Occurrence.occurrence_date == occurrence_date,
)
existing = (await session.execute(stmt)).scalar_one_or_none()
if existing is not None:
return existing
occurrence = Occurrence(
recurrence_id=recurrence.id,
occurrence_date=occurrence_date,
status=OccurrenceStatus.PLANNED,
planned_amount=matches[0].amount,
)
session.add(occurrence)
await session.flush()
return occurrence
async def confirm(
session: AsyncSession,
recurrence_id: int,
occurrence_date: date,
*,
actual_amount: Decimal | None = None,
actual_date: date | None = None,
account_id: int | None = None,
note: str | None = None,
) -> Occurrence:
"""Bestätigt eine Fälligkeit, wahlweise mit abweichendem Betrag oder Datum."""
recurrence = await get_recurrence(session, recurrence_id)
occurrence = await _materialise(session, recurrence, occurrence_date)
occurrence.status = OccurrenceStatus.CONFIRMED
occurrence.planned_amount = resolve_amount(
recurrence, occurrence_date, recurrence.amount_versions
)
occurrence.actual_amount = (
actual_amount if actual_amount is not None else occurrence.planned_amount
)
occurrence.actual_date = actual_date
if account_id is not None:
occurrence.account_id = account_id
if note is not None:
occurrence.note = note
await session.flush()
return occurrence
async def skip(
session: AsyncSession,
recurrence_id: int,
occurrence_date: date,
*,
note: str | None = None,
) -> Occurrence:
"""Markiert eine Fälligkeit als ausgefallen."""
recurrence = await get_recurrence(session, recurrence_id)
occurrence = await _materialise(session, recurrence, occurrence_date)
occurrence.status = OccurrenceStatus.SKIPPED
occurrence.actual_amount = None
occurrence.actual_date = None
if note is not None:
occurrence.note = note
await session.flush()
return occurrence
async def reset(session: AsyncSession, recurrence_id: int, occurrence_date: date) -> None:
"""Nimmt Bestätigung oder Auslassung zurück und entfernt die materialisierte Zeile."""
stmt = select(Occurrence).where(
Occurrence.recurrence_id == recurrence_id,
Occurrence.occurrence_date == occurrence_date,
)
occurrence = (await session.execute(stmt)).scalar_one_or_none()
if occurrence is None:
raise NotFoundError(
f"Zum {occurrence_date.isoformat()} ist keine abweichende Fälligkeit erfasst."
)
await session.delete(occurrence)
await session.flush()