- 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
129 lines
4.4 KiB
Python
129 lines
4.4 KiB
Python
"""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,
|
|
)
|