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

139 lines
4.9 KiB
Python

"""Einmalige Buchungen."""
from datetime import date
from fastapi import APIRouter, Query, status
from sqlalchemy import func, or_, select
from app.api.deps import DbSession
from app.core.errors import ValidationError
from app.models import Category, Transaction
from app.models.enums import EntryKind
from app.schemas.common import ErrorResponse, MessageResponse, Page
from app.schemas.transaction import TransactionCreate, TransactionOut, TransactionUpdate
from app.services.crud import apply_updates, get_or_404
router = APIRouter(prefix="/transactions", tags=["transactions"])
NOT_FOUND = {status.HTTP_404_NOT_FOUND: {"model": ErrorResponse}}
async def _check_category(session: DbSession, category_id: int, kind: EntryKind) -> None:
category = await get_or_404(session, Category, category_id)
if category.kind is not kind:
richtung = "Einkünfte" if kind is EntryKind.INCOME else "Ausgaben"
raise ValidationError(
f"Die Kategorie '{category.name}' ist nicht für {richtung} vorgesehen.",
code="category_kind_mismatch",
)
@router.get(
"",
response_model=Page[TransactionOut],
summary="Buchungen auflisten",
description="Neueste zuerst. Alle Filter sind kombinierbar.",
)
async def list_transactions(
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),
merchant_id: int | None = Query(default=None),
q: str | None = Query(default=None, description="Suche in Titel und Notiz."),
limit: int = Query(default=50, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> Page[TransactionOut]:
if date_from and date_to and date_to < date_from:
raise ValidationError("'to' darf nicht vor 'from' liegen.", code="invalid_date_range")
stmt = select(Transaction).order_by(Transaction.booking_date.desc(), Transaction.id.desc())
if date_from is not None:
stmt = stmt.where(Transaction.booking_date >= date_from)
if date_to is not None:
stmt = stmt.where(Transaction.booking_date <= date_to)
if kind is not None:
stmt = stmt.where(Transaction.kind == kind)
if category_id is not None:
stmt = stmt.where(Transaction.category_id == category_id)
if account_id is not None:
stmt = stmt.where(Transaction.account_id == account_id)
if merchant_id is not None:
stmt = stmt.where(Transaction.merchant_id == merchant_id)
if q:
pattern = f"%{q.strip()}%"
stmt = stmt.where(or_(Transaction.title.ilike(pattern), Transaction.note.ilike(pattern)))
total = (
await session.execute(select(func.count()).select_from(stmt.order_by(None).subquery()))
).scalar_one()
items = (await session.execute(stmt.limit(limit).offset(offset))).scalars().all()
return Page[TransactionOut](
items=[TransactionOut.model_validate(item) for item in items],
total=total,
limit=limit,
offset=offset,
)
@router.post(
"",
response_model=TransactionOut,
status_code=status.HTTP_201_CREATED,
summary="Buchung anlegen",
)
async def create_transaction(payload: TransactionCreate, session: DbSession) -> Transaction:
await _check_category(session, payload.category_id, payload.kind)
transaction = Transaction(**payload.model_dump())
session.add(transaction)
await session.commit()
await session.refresh(transaction)
return transaction
@router.get(
"/{transaction_id}",
response_model=TransactionOut,
responses=NOT_FOUND,
summary="Buchung lesen",
)
async def read_transaction(transaction_id: int, session: DbSession) -> Transaction:
return await get_or_404(session, Transaction, transaction_id)
@router.patch(
"/{transaction_id}",
response_model=TransactionOut,
responses=NOT_FOUND,
summary="Buchung ändern",
)
async def update_transaction(
transaction_id: int, payload: TransactionUpdate, session: DbSession
) -> Transaction:
transaction = await get_or_404(session, Transaction, transaction_id)
if payload.category_id is not None or payload.kind is not None:
await _check_category(
session,
payload.category_id or transaction.category_id,
payload.kind or transaction.kind,
)
apply_updates(transaction, payload)
await session.commit()
await session.refresh(transaction)
return transaction
@router.delete(
"/{transaction_id}",
response_model=MessageResponse,
responses=NOT_FOUND,
summary="Buchung löschen",
)
async def delete_transaction(transaction_id: int, session: DbSession) -> MessageResponse:
transaction = await get_or_404(session, Transaction, transaction_id)
await session.delete(transaction)
await session.commit()
return MessageResponse(detail="Buchung gelöscht.")