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,128 @@
|
||||
"""Konten inklusive Saldoberechnung."""
|
||||
|
||||
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 today
|
||||
from app.core.errors import ConflictError
|
||||
from app.models import Account, Occurrence, Recurrence, Transaction
|
||||
from app.schemas.account import (
|
||||
AccountBalanceOut,
|
||||
AccountCreate,
|
||||
AccountOut,
|
||||
AccountUpdate,
|
||||
)
|
||||
from app.schemas.common import ErrorResponse, MessageResponse
|
||||
from app.services.balances import account_balance
|
||||
from app.services.crud import apply_updates, get_or_404
|
||||
|
||||
router = APIRouter(prefix="/accounts", tags=["accounts"])
|
||||
|
||||
NOT_FOUND = {status.HTTP_404_NOT_FOUND: {"model": ErrorResponse}}
|
||||
|
||||
|
||||
@router.get("", response_model=list[AccountOut], summary="Konten auflisten")
|
||||
async def list_accounts(
|
||||
session: DbSession,
|
||||
is_active: bool | None = Query(default=None, description="Nach Aktivstatus filtern."),
|
||||
) -> list[Account]:
|
||||
stmt = select(Account).order_by(Account.sort_order, Account.name)
|
||||
if is_active is not None:
|
||||
stmt = stmt.where(Account.is_active.is_(is_active))
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=AccountOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Konto anlegen",
|
||||
)
|
||||
async def create_account(payload: AccountCreate, session: DbSession) -> Account:
|
||||
account = Account(**payload.model_dump())
|
||||
session.add(account)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError as exc:
|
||||
await session.rollback()
|
||||
raise ConflictError(f"Ein Konto namens '{payload.name}' existiert bereits.") from exc
|
||||
await session.refresh(account)
|
||||
return account
|
||||
|
||||
|
||||
@router.get("/{account_id}", response_model=AccountOut, responses=NOT_FOUND, summary="Konto lesen")
|
||||
async def read_account(account_id: int, session: DbSession) -> Account:
|
||||
return await get_or_404(session, Account, account_id)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{account_id}", response_model=AccountOut, responses=NOT_FOUND, summary="Konto ändern"
|
||||
)
|
||||
async def update_account(account_id: int, payload: AccountUpdate, session: DbSession) -> Account:
|
||||
account = await get_or_404(session, Account, account_id)
|
||||
apply_updates(account, payload)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError as exc:
|
||||
await session.rollback()
|
||||
raise ConflictError("Ein Konto mit diesem Namen existiert bereits.") from exc
|
||||
await session.refresh(account)
|
||||
return account
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{account_id}",
|
||||
response_model=MessageResponse,
|
||||
responses=NOT_FOUND,
|
||||
summary="Konto löschen",
|
||||
description="Nur möglich, solange keine Buchungen oder Posten darauf verweisen. "
|
||||
"Andernfalls das Konto auf `is_active=false` setzen.",
|
||||
)
|
||||
async def delete_account(account_id: int, session: DbSession) -> MessageResponse:
|
||||
account = await get_or_404(session, Account, account_id)
|
||||
|
||||
for model, bezeichnung in (
|
||||
(Transaction, "Buchungen"),
|
||||
(Recurrence, "wiederkehrende Posten"),
|
||||
(Occurrence, "abweichende Fälligkeiten"),
|
||||
):
|
||||
stmt = select(model.id).where(model.account_id == account_id).limit(1)
|
||||
if (await session.execute(stmt)).first() is not None:
|
||||
raise ConflictError(
|
||||
f"Das Konto wird noch von {bezeichnung} verwendet und kann nicht "
|
||||
"gelöscht werden. Setze es stattdessen auf inaktiv.",
|
||||
code="account_in_use",
|
||||
)
|
||||
|
||||
await session.delete(account)
|
||||
await session.commit()
|
||||
return MessageResponse(detail="Konto gelöscht.")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{account_id}/balance",
|
||||
response_model=AccountBalanceOut,
|
||||
responses=NOT_FOUND,
|
||||
summary="Kontosaldo zum Stichtag",
|
||||
description="Eröffnungssaldo zuzüglich aller Buchungen und bestätigten "
|
||||
"Fälligkeiten bis einschließlich `as_of`.",
|
||||
)
|
||||
async def read_balance(
|
||||
account_id: int,
|
||||
session: DbSession,
|
||||
as_of: date | None = Query(default=None, description="Stichtag; Vorgabe ist heute."),
|
||||
) -> AccountBalanceOut:
|
||||
account = await get_or_404(session, Account, account_id)
|
||||
balance = await account_balance(session, account, as_of or today())
|
||||
return AccountBalanceOut(
|
||||
account_id=balance.account_id,
|
||||
as_of=balance.as_of,
|
||||
opening_balance=balance.opening_balance,
|
||||
booked_transactions=balance.booked_transactions,
|
||||
booked_occurrences=balance.booked_occurrences,
|
||||
balance=balance.balance,
|
||||
)
|
||||
Reference in New Issue
Block a user