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

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,
)