- 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
139 lines
4.9 KiB
Python
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.")
|