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:
moneyfy
2026-09-09 13:40:37 +02:00
co-authored by Claude Opus 5
parent 70d73cf8d3
commit b586d27b77
46 changed files with 4866 additions and 21 deletions
+128
View File
@@ -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,
)
+106
View File
@@ -0,0 +1,106 @@
"""Anmeldung, Abmeldung, Token-Erneuerung und Passwortwechsel."""
from fastapi import APIRouter, Request, Response, status
from app.api.deps import CurrentUser, DbSession
from app.core.cookies import REFRESH_COOKIE, clear_auth_cookies, set_auth_cookies
from app.core.errors import AuthError
from app.schemas.auth import ChangePasswordRequest, LoginRequest, UserOut
from app.schemas.common import ErrorResponse, MessageResponse
from app.services.auth import (
Credentials,
change_password,
get_auth_provider,
issue_tokens,
revoke_refresh_token,
rotate_tokens,
)
router = APIRouter(prefix="/auth", tags=["auth"])
UNAUTHORIZED = {status.HTTP_401_UNAUTHORIZED: {"model": ErrorResponse}}
@router.post(
"/login",
response_model=UserOut,
responses=UNAUTHORIZED,
summary="Anmelden",
description="Prüft die Zugangsdaten und legt Access- und Refresh-Token als "
"httpOnly-Cookies ab.",
)
async def login(payload: LoginRequest, response: Response, session: DbSession) -> UserOut:
provider = get_auth_provider("local")
user = await provider.authenticate(
session, Credentials(username=payload.username, password=payload.password)
)
tokens = await issue_tokens(session, user)
await session.commit()
set_auth_cookies(
response,
tokens.access_token,
tokens.access_expires_at,
tokens.refresh_token,
tokens.refresh_expires_at,
)
return UserOut.model_validate(user)
@router.post(
"/refresh",
response_model=UserOut,
responses=UNAUTHORIZED,
summary="Sitzung erneuern",
description="Tauscht das Refresh-Token gegen ein neues Paar. Das alte Token "
"wird dabei gesperrt (Rotation).",
)
async def refresh(request: Request, response: Response, session: DbSession) -> UserOut:
token = request.cookies.get(REFRESH_COOKIE)
if not token:
clear_auth_cookies(response)
raise AuthError("Nicht angemeldet.", code="not_authenticated")
user, tokens = await rotate_tokens(session, token)
await session.commit()
set_auth_cookies(
response,
tokens.access_token,
tokens.access_expires_at,
tokens.refresh_token,
tokens.refresh_expires_at,
)
return UserOut.model_validate(user)
@router.post(
"/logout",
response_model=MessageResponse,
summary="Abmelden",
description="Sperrt das aktuelle Refresh-Token und löscht beide Cookies.",
)
async def logout(request: Request, response: Response, session: DbSession) -> MessageResponse:
await revoke_refresh_token(session, request.cookies.get(REFRESH_COOKIE))
await session.commit()
clear_auth_cookies(response)
return MessageResponse(detail="Abgemeldet.")
@router.post(
"/change-password",
response_model=MessageResponse,
responses=UNAUTHORIZED,
summary="Passwort ändern",
description="Ändert das Passwort und beendet dabei alle bestehenden Sitzungen.",
)
async def change_own_password(
payload: ChangePasswordRequest,
response: Response,
user: CurrentUser,
session: DbSession,
) -> MessageResponse:
await change_password(session, user, payload.current_password, payload.new_password)
await session.commit()
clear_auth_cookies(response)
return MessageResponse(detail="Passwort geändert. Bitte neu anmelden.")
+168
View File
@@ -0,0 +1,168 @@
"""Budgets und Budgetvorlagen."""
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.errors import ConflictError, ValidationError
from app.models import Budget, BudgetTemplate, Category
from app.schemas.budget import (
BudgetCreate,
BudgetOut,
BudgetTemplateCreate,
BudgetTemplateOut,
BudgetTemplateUpdate,
BudgetUpdate,
)
from app.schemas.common import ErrorResponse, MessageResponse
from app.services.crud import apply_updates, get_or_404
router = APIRouter(prefix="/budgets", tags=["budgets"])
NOT_FOUND = {status.HTTP_404_NOT_FOUND: {"model": ErrorResponse}}
@router.get(
"",
response_model=list[BudgetOut],
summary="Budgets auflisten",
description="Ohne `month` werden alle Monate geliefert.",
)
async def list_budgets(
session: DbSession,
month: date | None = Query(default=None, description="Beliebiger Tag im gesuchten Monat."),
category_id: int | None = Query(default=None),
) -> list[Budget]:
stmt = select(Budget).order_by(Budget.period_month.desc(), Budget.category_id)
if month is not None:
stmt = stmt.where(Budget.period_month == month.replace(day=1))
if category_id is not None:
stmt = stmt.where(Budget.category_id == category_id)
return list((await session.execute(stmt)).scalars().all())
@router.post(
"", response_model=BudgetOut, status_code=status.HTTP_201_CREATED, summary="Budget anlegen"
)
async def create_budget(payload: BudgetCreate, session: DbSession) -> Budget:
await get_or_404(session, Category, payload.category_id)
budget = Budget(**payload.model_dump())
session.add(budget)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise ConflictError(
"Für diese Kategorie und diesen Monat gibt es bereits ein Budget."
) from exc
await session.refresh(budget)
return budget
@router.patch(
"/{budget_id}", response_model=BudgetOut, responses=NOT_FOUND, summary="Budget ändern"
)
async def update_budget(budget_id: int, payload: BudgetUpdate, session: DbSession) -> Budget:
budget = await get_or_404(session, Budget, budget_id)
apply_updates(budget, payload)
await session.commit()
await session.refresh(budget)
return budget
@router.delete(
"/{budget_id}",
response_model=MessageResponse,
responses=NOT_FOUND,
summary="Budget löschen",
)
async def delete_budget(budget_id: int, session: DbSession) -> MessageResponse:
budget = await get_or_404(session, Budget, budget_id)
await session.delete(budget)
await session.commit()
return MessageResponse(detail="Budget gelöscht.")
# --- Vorlagen ------------------------------------------------------------------
templates = APIRouter(prefix="/budget-templates", tags=["budgets"])
@templates.get(
"",
response_model=list[BudgetTemplateOut],
summary="Budgetvorlagen auflisten",
description="Vorlagen gelten ab `valid_from` dauerhaft, sodass nicht jeder "
"Monat einzeln gepflegt werden muss.",
)
async def list_templates(
session: DbSession, category_id: int | None = Query(default=None)
) -> list[BudgetTemplate]:
stmt = select(BudgetTemplate).order_by(
BudgetTemplate.category_id, BudgetTemplate.valid_from.desc()
)
if category_id is not None:
stmt = stmt.where(BudgetTemplate.category_id == category_id)
return list((await session.execute(stmt)).scalars().all())
@templates.post(
"",
response_model=BudgetTemplateOut,
status_code=status.HTTP_201_CREATED,
summary="Budgetvorlage anlegen",
)
async def create_template(payload: BudgetTemplateCreate, session: DbSession) -> BudgetTemplate:
await get_or_404(session, Category, payload.category_id)
if payload.valid_until is not None and payload.valid_until < payload.valid_from:
raise ValidationError(
"'valid_until' darf nicht vor 'valid_from' liegen.", code="invalid_date_range"
)
template = BudgetTemplate(**payload.model_dump())
session.add(template)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise ConflictError(
"Für diese Kategorie gibt es ab diesem Monat bereits eine Vorlage."
) from exc
await session.refresh(template)
return template
@templates.patch(
"/{template_id}",
response_model=BudgetTemplateOut,
responses=NOT_FOUND,
summary="Budgetvorlage ändern",
)
async def update_template(
template_id: int, payload: BudgetTemplateUpdate, session: DbSession
) -> BudgetTemplate:
template = await get_or_404(session, BudgetTemplate, template_id)
if payload.valid_until is not None and payload.valid_until < template.valid_from:
raise ValidationError(
"'valid_until' darf nicht vor 'valid_from' liegen.", code="invalid_date_range"
)
apply_updates(template, payload)
await session.commit()
await session.refresh(template)
return template
@templates.delete(
"/{template_id}",
response_model=MessageResponse,
responses=NOT_FOUND,
summary="Budgetvorlage löschen",
)
async def delete_template(template_id: int, session: DbSession) -> MessageResponse:
template = await get_or_404(session, BudgetTemplate, template_id)
await session.delete(template)
await session.commit()
return MessageResponse(detail="Budgetvorlage gelöscht.")
+196
View File
@@ -0,0 +1,196 @@
"""Kategorien als zweistufiger Baum."""
from fastapi import APIRouter, Query, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import DbSession
from app.core.errors import ConflictError, ValidationError
from app.models import Budget, BudgetTemplate, Category, Recurrence, Transaction
from app.models.enums import EntryKind
from app.schemas.category import (
CategoryCreate,
CategoryOut,
CategoryTreeOut,
CategoryUpdate,
)
from app.schemas.common import ErrorResponse, MessageResponse
from app.services.crud import apply_updates, get_or_404
router = APIRouter(prefix="/categories", tags=["categories"])
NOT_FOUND = {status.HTTP_404_NOT_FOUND: {"model": ErrorResponse}}
async def _load_parent(session: AsyncSession, parent_id: int) -> Category:
"""Lädt den Elternknoten und stellt sicher, dass der Baum zweistufig bleibt."""
parent = await get_or_404(session, Category, parent_id)
if parent.parent_id is not None:
raise ValidationError(
"Der Kategoriebaum ist zweistufig eine Unterkategorie kann keine "
"weiteren Unterkategorien haben.",
code="category_too_deep",
)
return parent
@router.get(
"",
response_model=list[CategoryTreeOut],
summary="Kategoriebaum lesen",
description="Liefert die Oberkategorien mit ihren Unterkategorien, sortiert nach "
"`sort_order` und Name.",
)
async def list_categories(
session: DbSession,
kind: EntryKind | None = Query(default=None, description="Nach Richtung filtern."),
include_archived: bool = Query(default=False, description="Archivierte einbeziehen."),
) -> list[CategoryTreeOut]:
stmt = select(Category).order_by(Category.sort_order, Category.name)
if kind is not None:
stmt = stmt.where(Category.kind == kind)
if not include_archived:
stmt = stmt.where(Category.is_archived.is_(False))
categories = list((await session.execute(stmt)).scalars().all())
children: dict[int, list[Category]] = {}
for category in categories:
if category.parent_id is not None:
children.setdefault(category.parent_id, []).append(category)
return [
CategoryTreeOut(
**CategoryOut.model_validate(category).model_dump(),
children=[CategoryOut.model_validate(child) for child in children.get(category.id, [])],
)
for category in categories
if category.parent_id is None
]
@router.get(
"/flat",
response_model=list[CategoryOut],
summary="Kategorien flach auflisten",
description="Alle Kategorien ohne Verschachtelung praktisch für Auswahlfelder.",
)
async def list_categories_flat(
session: DbSession,
kind: EntryKind | None = Query(default=None),
include_archived: bool = Query(default=False),
) -> list[Category]:
stmt = select(Category).order_by(Category.sort_order, Category.name)
if kind is not None:
stmt = stmt.where(Category.kind == kind)
if not include_archived:
stmt = stmt.where(Category.is_archived.is_(False))
return list((await session.execute(stmt)).scalars().all())
@router.post(
"",
response_model=CategoryOut,
status_code=status.HTTP_201_CREATED,
summary="Kategorie anlegen",
)
async def create_category(payload: CategoryCreate, session: DbSession) -> Category:
data = payload.model_dump()
if payload.parent_id is not None:
parent = await _load_parent(session, payload.parent_id)
# Die Richtung ergibt sich zwingend aus dem Elternknoten.
data["kind"] = parent.kind
category = Category(**data)
session.add(category)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise ConflictError(
f"Es gibt an dieser Stelle bereits eine Kategorie namens '{payload.name}'."
) from exc
await session.refresh(category)
return category
@router.get(
"/{category_id}", response_model=CategoryOut, responses=NOT_FOUND, summary="Kategorie lesen"
)
async def read_category(category_id: int, session: DbSession) -> Category:
return await get_or_404(session, Category, category_id)
@router.patch(
"/{category_id}", response_model=CategoryOut, responses=NOT_FOUND, summary="Kategorie ändern"
)
async def update_category(
category_id: int, payload: CategoryUpdate, session: DbSession
) -> Category:
category = await get_or_404(session, Category, category_id)
if "parent_id" in payload.model_fields_set and payload.parent_id is not None:
if payload.parent_id == category_id:
raise ValidationError(
"Eine Kategorie kann sich nicht selbst übergeordnet sein.",
code="category_cycle",
)
parent = await _load_parent(session, payload.parent_id)
if parent.kind is not category.kind:
raise ValidationError(
"Ober- und Unterkategorie müssen dieselbe Richtung haben.",
code="category_kind_mismatch",
)
# Eine Kategorie mit eigenen Kindern darf nicht selbst zum Kind werden.
stmt = select(Category.id).where(Category.parent_id == category_id).limit(1)
if (await session.execute(stmt)).first() is not None:
raise ValidationError(
"Diese Kategorie hat Unterkategorien und kann daher nicht untergeordnet werden.",
code="category_too_deep",
)
apply_updates(category, payload)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise ConflictError(
"Es gibt an dieser Stelle bereits eine Kategorie mit diesem Namen."
) from exc
await session.refresh(category)
return category
@router.delete(
"/{category_id}",
response_model=MessageResponse,
responses=NOT_FOUND,
summary="Kategorie löschen",
description="Nur möglich, solange nichts darauf verweist. Andernfalls archivieren.",
)
async def delete_category(category_id: int, session: DbSession) -> MessageResponse:
category = await get_or_404(session, Category, category_id)
stmt = select(Category.id).where(Category.parent_id == category_id).limit(1)
if (await session.execute(stmt)).first() is not None:
raise ConflictError(
"Die Kategorie hat Unterkategorien und kann nicht gelöscht werden.",
code="category_has_children",
)
for model, bezeichnung in (
(Recurrence, "wiederkehrende Posten"),
(Transaction, "Buchungen"),
(Budget, "Budgets"),
(BudgetTemplate, "Budgetvorlagen"),
):
stmt = select(model.id).where(model.category_id == category_id).limit(1)
if (await session.execute(stmt)).first() is not None:
raise ConflictError(
f"Die Kategorie wird noch von {bezeichnung} verwendet. Archiviere sie stattdessen.",
code="category_in_use",
)
await session.delete(category)
await session.commit()
return MessageResponse(detail="Kategorie gelöscht.")
+20
View File
@@ -0,0 +1,20 @@
"""Angaben zum angemeldeten Benutzer."""
from fastapi import APIRouter
from app.api.deps import CurrentUser
from app.models import AppUser
from app.schemas.auth import UserOut
router = APIRouter(tags=["auth"])
@router.get(
"/me",
response_model=UserOut,
summary="Angemeldeten Benutzer lesen",
description="Verlangt nur eine gültige Anmeldung auch bei erzwungenem "
"Passwortwechsel abrufbar.",
)
async def read_me(user: CurrentUser) -> AppUser:
return user
+118
View File
@@ -0,0 +1,118 @@
"""Firmen und Zahlungsempfänger."""
from fastapi import APIRouter, Query, status
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from app.api.deps import DbSession
from app.core.errors import ConflictError
from app.models import Merchant, Recurrence, Transaction
from app.models.enums import LogoStatus
from app.schemas.common import ErrorResponse, MessageResponse, Page
from app.schemas.merchant import MerchantCreate, MerchantOut, MerchantUpdate
from app.services.crud import apply_updates, get_or_404
from app.services.merchants import normalize_name, search_statement
router = APIRouter(prefix="/merchants", tags=["merchants"])
NOT_FOUND = {status.HTTP_404_NOT_FOUND: {"model": ErrorResponse}}
@router.get(
"",
response_model=Page[MerchantOut],
summary="Firmen suchen",
description="Volltextsuche über Name, normalisierten Namen und Domain.",
)
async def list_merchants(
session: DbSession,
q: str | None = Query(default=None, description="Suchbegriff."),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> Page[MerchantOut]:
stmt = search_statement(q)
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[MerchantOut](
items=[MerchantOut.model_validate(item) for item in items],
total=total,
limit=limit,
offset=offset,
)
@router.post(
"",
response_model=MerchantOut,
status_code=status.HTTP_201_CREATED,
summary="Firma anlegen",
description="Antwortet sofort. Der Logo-Status steht zunächst auf `pending`.",
)
async def create_merchant(payload: MerchantCreate, session: DbSession) -> Merchant:
merchant = Merchant(
name=payload.name,
normalized_name=normalize_name(payload.name),
domain=payload.domain,
aliases=payload.aliases,
brand_color=payload.brand_color,
brand_color_dark=payload.brand_color_dark,
logo_status=LogoStatus.PENDING,
)
session.add(merchant)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise ConflictError(f"Die Firma '{payload.name}' ist bereits angelegt.") from exc
await session.refresh(merchant)
return merchant
@router.get(
"/{merchant_id}", response_model=MerchantOut, responses=NOT_FOUND, summary="Firma lesen"
)
async def read_merchant(merchant_id: int, session: DbSession) -> Merchant:
return await get_or_404(session, Merchant, merchant_id)
@router.patch(
"/{merchant_id}", response_model=MerchantOut, responses=NOT_FOUND, summary="Firma ändern"
)
async def update_merchant(
merchant_id: int, payload: MerchantUpdate, session: DbSession
) -> Merchant:
merchant = await get_or_404(session, Merchant, merchant_id)
apply_updates(merchant, payload)
if payload.name is not None:
merchant.normalized_name = normalize_name(payload.name)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise ConflictError("Eine Firma mit diesem Namen ist bereits angelegt.") from exc
await session.refresh(merchant)
return merchant
@router.delete(
"/{merchant_id}",
response_model=MessageResponse,
responses=NOT_FOUND,
summary="Firma löschen",
description="Verweise aus Buchungen und Posten werden dabei auf 'keine Firma' gesetzt.",
)
async def delete_merchant(merchant_id: int, session: DbSession) -> MessageResponse:
merchant = await get_or_404(session, Merchant, merchant_id)
# ON DELETE SET NULL greift erst in der Datenbank; die geladenen Objekte
# müssen daher explizit nachgezogen werden.
for model in (Recurrence, Transaction):
stmt = select(model).where(model.merchant_id == merchant_id)
for row in (await session.execute(stmt)).scalars():
row.merchant_id = None
await session.delete(merchant)
await session.commit()
return MessageResponse(detail="Firma gelöscht.")
+152
View File
@@ -0,0 +1,152 @@
"""Fälligkeiten über alle Posten hinweg: abrufen, bestätigen, auslassen."""
from datetime import date
from fastapi import APIRouter, Query, status
from app.api.deps import DbSession
from app.api.routes.recurrences import to_occurrence_out
from app.core.clock import add_months, today
from app.core.errors import ValidationError
from app.models.enums import EntryKind, OccurrenceStatus
from app.schemas.common import ErrorResponse, MessageResponse
from app.schemas.occurrence import (
OccurrenceConfirm,
OccurrenceOut,
OccurrenceReset,
OccurrenceSkip,
)
from app.services import occurrences as service
router = APIRouter(prefix="/occurrences", tags=["occurrences"])
NOT_FOUND = {status.HTTP_404_NOT_FOUND: {"model": ErrorResponse}}
@router.get(
"",
response_model=list[OccurrenceOut],
summary="Fälligkeiten im Zeitraum",
description="Expandiert alle passenden Posten und überlagert sie mit den "
"erfassten Abweichungen. Ohne Angabe umfasst das Fenster die nächsten drei Monate.",
)
async def list_occurrences(
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),
recurrence_id: int | None = Query(default=None),
status_filter: OccurrenceStatus | None = Query(
default=None, alias="status", description="Nach Status filtern."
),
include_inactive: bool = Query(
default=False, description="Auch deaktivierte Posten einbeziehen."
),
by_due_date: bool = Query(
default=True,
description="True gruppiert nach dem tatsächlichen Zahltag, False nach dem "
"nominalen Datum.",
),
) -> list[OccurrenceOut]:
start = date_from or today()
end = date_to or add_months(start, 3)
if end < start:
raise ValidationError("'to' darf nicht vor 'from' liegen.", code="invalid_date_range")
items = await service.due_items(
session,
start,
end,
by_due_date=by_due_date,
kind=kind,
only_active=not include_inactive,
category_id=category_id,
account_id=account_id,
recurrence_id=recurrence_id,
)
if status_filter is not None:
items = [item for item in items if item.planned.status is status_filter]
return [to_occurrence_out(item.planned, item.recurrence) for item in items]
@router.post(
"/confirm",
response_model=OccurrenceOut,
responses=NOT_FOUND,
summary="Fälligkeit bestätigen",
description="Schlüssel ist das nominale Datum. Ohne `actual_amount` gilt der Sollbetrag.",
)
async def confirm_occurrence(payload: OccurrenceConfirm, session: DbSession) -> OccurrenceOut:
await service.confirm(
session,
payload.recurrence_id,
payload.occurrence_date,
actual_amount=payload.actual_amount,
actual_date=payload.actual_date,
account_id=payload.account_id,
note=payload.note,
)
await session.commit()
return await _single(session, payload.recurrence_id, payload.occurrence_date)
@router.post(
"/skip",
response_model=OccurrenceOut,
responses=NOT_FOUND,
summary="Fälligkeit auslassen",
description="Die Fälligkeit zählt danach in keiner Auswertung mehr mit.",
)
async def skip_occurrence(payload: OccurrenceSkip, session: DbSession) -> OccurrenceOut:
await service.skip(session, payload.recurrence_id, payload.occurrence_date, note=payload.note)
await session.commit()
return await _single(session, payload.recurrence_id, payload.occurrence_date)
@router.post(
"/reset",
response_model=OccurrenceOut,
responses=NOT_FOUND,
summary="Bestätigung zurücknehmen",
description="Löscht die erfasste Abweichung; die Fälligkeit gilt wieder als geplant.",
)
async def reset_occurrence(payload: OccurrenceReset, session: DbSession) -> OccurrenceOut:
await service.reset(session, payload.recurrence_id, payload.occurrence_date)
await session.commit()
return await _single(session, payload.recurrence_id, payload.occurrence_date)
@router.delete(
"/{recurrence_id}/{occurrence_date}",
response_model=MessageResponse,
responses=NOT_FOUND,
summary="Erfasste Abweichung löschen",
)
async def delete_occurrence(
recurrence_id: int, occurrence_date: date, session: DbSession
) -> MessageResponse:
await service.reset(session, recurrence_id, occurrence_date)
await session.commit()
return MessageResponse(detail="Erfasste Abweichung gelöscht.")
async def _single(session: DbSession, recurrence_id: int, occurrence_date: date) -> OccurrenceOut:
"""Liest genau eine Fälligkeit nach einer Änderung frisch aus."""
recurrence = await service.get_recurrence(session, recurrence_id)
items = await service.due_items(
session,
occurrence_date,
occurrence_date,
by_due_date=False,
only_active=False,
recurrence_id=recurrence_id,
)
if not items:
raise ValidationError(
f"Zum {occurrence_date.isoformat()} gibt es für '{recurrence.title}' keine Fälligkeit.",
code="occurrence_not_due",
)
return to_occurrence_out(items[0].planned, items[0].recurrence)
+368
View File
@@ -0,0 +1,368 @@
"""Wiederkehrende Posten samt Preisversionen, Vorschau und Kündigung."""
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 add_months, today
from app.core.errors import ConflictError, ValidationError
from app.models import AmountVersion, Category, Occurrence, Recurrence
from app.models.enums import EntryKind
from app.schemas.common import ErrorResponse, MessageResponse
from app.schemas.merchant import MerchantOut
from app.schemas.occurrence import OccurrenceOut
from app.schemas.recurrence import (
AmountVersionCreate,
AmountVersionOut,
ContractTermOut,
InstallmentStatusOut,
RecurrenceCreate,
RecurrenceDetailOut,
RecurrenceOut,
RecurrenceUpdate,
)
from app.services.crud import apply_updates, get_or_404
from app.services.occurrences import get_recurrence
from app.services.recurrence import (
InvalidRRuleError,
annual_burden,
contract_term,
expand,
installments_remaining,
monthly_reserve,
next_dates,
validate_rrule,
)
router = APIRouter(prefix="/recurrences", tags=["recurrences"])
NOT_FOUND = {status.HTTP_404_NOT_FOUND: {"model": ErrorResponse}}
def to_occurrence_out(planned, recurrence: Recurrence) -> OccurrenceOut:
"""Übersetzt ein Engine-Ergebnis in das API-Schema."""
return OccurrenceOut(
recurrence_id=recurrence.id,
recurrence_title=recurrence.title,
kind=planned.kind,
category_id=recurrence.category_id,
merchant_id=recurrence.merchant_id,
account_id=planned.account_id,
nominal_date=planned.nominal_date,
due_date=planned.due_date,
effective_date=planned.effective_date,
amount=planned.amount,
actual_amount=planned.actual_amount,
effective_amount=planned.effective_amount,
status=planned.status,
is_variable=planned.is_variable,
occurrence_id=planned.occurrence_id,
note=planned.note,
installment_number=planned.installment_number,
installments_total=planned.installments_total,
)
async def _check_category(session: DbSession, category_id: int, kind: EntryKind) -> None:
"""Kategorie und Posten müssen dieselbe Richtung haben."""
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",
)
async def _detail(session: DbSession, recurrence: Recurrence) -> RecurrenceDetailOut:
"""Reichert einen Posten um die berechneten Kennzahlen an."""
reference = today()
term = contract_term(recurrence, reference)
installments = installments_remaining(
recurrence, reference, amount_versions=recurrence.amount_versions
)
return RecurrenceDetailOut(
**RecurrenceOut.model_validate(recurrence).model_dump(),
merchant=MerchantOut.model_validate(recurrence.merchant) if recurrence.merchant else None,
amount_versions=[
AmountVersionOut.model_validate(version) for version in recurrence.amount_versions
],
next_dates=next_dates(recurrence, count=5, after=reference),
monthly_reserve=(
monthly_reserve(recurrence, reference, amount_versions=recurrence.amount_versions)
if recurrence.reserve_enabled
else None
),
annual_burden=annual_burden(
recurrence, reference, amount_versions=recurrence.amount_versions
),
contract_term=ContractTermOut.model_validate(term) if term else None,
installments=(InstallmentStatusOut.model_validate(installments) if installments else None),
)
@router.get("", response_model=list[RecurrenceOut], summary="Posten auflisten")
async def list_recurrences(
session: DbSession,
kind: EntryKind | None = Query(default=None),
active: bool | None = Query(default=None, description="Nach Aktivstatus filtern."),
category_id: int | None = Query(default=None),
account_id: int | None = Query(default=None),
merchant_id: int | None = Query(default=None),
) -> list[Recurrence]:
stmt = select(Recurrence).order_by(Recurrence.title)
if kind is not None:
stmt = stmt.where(Recurrence.kind == kind)
if active is not None:
stmt = stmt.where(Recurrence.is_active.is_(active))
if category_id is not None:
stmt = stmt.where(Recurrence.category_id == category_id)
if account_id is not None:
stmt = stmt.where(Recurrence.account_id == account_id)
if merchant_id is not None:
stmt = stmt.where(Recurrence.merchant_id == merchant_id)
return list((await session.execute(stmt)).scalars().all())
@router.post(
"",
response_model=RecurrenceDetailOut,
status_code=status.HTTP_201_CREATED,
summary="Posten anlegen",
description="Legt zugleich die erste Preisversion ab `dtstart` an.",
)
async def create_recurrence(payload: RecurrenceCreate, session: DbSession) -> RecurrenceDetailOut:
await _check_category(session, payload.category_id, payload.kind)
recurrence = Recurrence(**payload.model_dump())
session.add(recurrence)
await session.flush()
# Erste Preisversion, damit die Preishistorie von Anfang an lückenlos ist.
session.add(
AmountVersion(
recurrence_id=recurrence.id,
amount=payload.amount,
valid_from=payload.dtstart,
note="Anfangsbetrag",
)
)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise ConflictError("Der Posten konnte nicht angelegt werden.") from exc
return await _detail(session, await get_recurrence(session, recurrence.id))
@router.get(
"/{recurrence_id}",
response_model=RecurrenceDetailOut,
responses=NOT_FOUND,
summary="Posten lesen",
)
async def read_recurrence(recurrence_id: int, session: DbSession) -> RecurrenceDetailOut:
return await _detail(session, await get_recurrence(session, recurrence_id))
@router.patch(
"/{recurrence_id}",
response_model=RecurrenceDetailOut,
responses=NOT_FOUND,
summary="Posten ändern",
description="Eine Betragsänderung hier gilt rückwirkend für die ganze Serie. "
"Für einen Preiswechsel ab einem Stichtag stattdessen eine Preisversion anlegen.",
)
async def update_recurrence(
recurrence_id: int, payload: RecurrenceUpdate, session: DbSession
) -> RecurrenceDetailOut:
recurrence = await get_recurrence(session, recurrence_id)
kind = payload.kind or recurrence.kind
if payload.category_id is not None or payload.kind is not None:
await _check_category(session, payload.category_id or recurrence.category_id, kind)
rrule = payload.rrule if payload.rrule is not None else recurrence.rrule
dtstart = payload.dtstart if payload.dtstart is not None else recurrence.dtstart
if payload.rrule is not None or payload.dtstart is not None:
try:
validate_rrule(rrule, dtstart)
except InvalidRRuleError as exc:
raise ValidationError(str(exc), code="invalid_rrule") from exc
until = payload.until if "until" in payload.model_fields_set else recurrence.until
if until is not None and until < dtstart:
raise ValidationError(
"Das Serienende darf nicht vor dem Start liegen.", code="invalid_date_range"
)
apply_updates(recurrence, payload)
await session.commit()
return await _detail(session, await get_recurrence(session, recurrence_id))
@router.delete(
"/{recurrence_id}",
response_model=MessageResponse,
responses=NOT_FOUND,
summary="Posten löschen",
description="Entfernt den Posten samt Preishistorie und erfassten Fälligkeiten. "
"Für die Erhaltung der Historie besser `is_active=false` setzen.",
)
async def delete_recurrence(recurrence_id: int, session: DbSession) -> MessageResponse:
recurrence = await get_or_404(session, Recurrence, recurrence_id)
await session.delete(recurrence)
await session.commit()
return MessageResponse(detail="Wiederkehrender Posten gelöscht.")
# --- Preisversionen ------------------------------------------------------------
@router.get(
"/{recurrence_id}/amount-versions",
response_model=list[AmountVersionOut],
responses=NOT_FOUND,
summary="Preishistorie lesen",
)
async def list_amount_versions(recurrence_id: int, session: DbSession) -> list[AmountVersion]:
recurrence = await get_recurrence(session, recurrence_id)
return list(recurrence.amount_versions)
@router.post(
"/{recurrence_id}/amount-versions",
response_model=AmountVersionOut,
status_code=status.HTTP_201_CREATED,
responses=NOT_FOUND,
summary="Preisversion anlegen",
description="Ab `valid_from` gilt der neue Betrag. Vergangene Fälligkeiten "
"bleiben dadurch betragstreu.",
)
async def create_amount_version(
recurrence_id: int, payload: AmountVersionCreate, session: DbSession
) -> AmountVersion:
recurrence = await get_recurrence(session, recurrence_id)
version = AmountVersion(
recurrence_id=recurrence.id,
amount=payload.amount,
valid_from=payload.valid_from,
note=payload.note,
)
session.add(version)
# Der Basisbetrag folgt der jüngsten Version, damit Liste und Detail übereinstimmen.
if all(existing.valid_from <= payload.valid_from for existing in recurrence.amount_versions):
recurrence.amount = payload.amount
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise ConflictError(
f"Zum {payload.valid_from.isoformat()} existiert bereits eine Preisversion."
) from exc
await session.refresh(version)
return version
@router.delete(
"/{recurrence_id}/amount-versions/{version_id}",
response_model=MessageResponse,
responses=NOT_FOUND,
summary="Preisversion löschen",
)
async def delete_amount_version(
recurrence_id: int, version_id: int, session: DbSession
) -> MessageResponse:
version = await get_or_404(session, AmountVersion, version_id)
if version.recurrence_id != recurrence_id:
raise ValidationError(
"Die Preisversion gehört nicht zu diesem Posten.", code="version_mismatch"
)
await session.delete(version)
await session.commit()
return MessageResponse(detail="Preisversion gelöscht.")
# --- Vorschau und Kündigung ----------------------------------------------------
@router.get(
"/{recurrence_id}/preview",
response_model=list[OccurrenceOut],
responses=NOT_FOUND,
summary="Fälligkeiten vorschauen",
description="Berechnete Termine im Zeitfenster, gefiltert nach dem nominalen Datum.",
)
async def preview(
recurrence_id: int,
session: DbSession,
date_from: date | None = Query(default=None, alias="from"),
date_to: date | None = Query(default=None, alias="to"),
) -> list[OccurrenceOut]:
recurrence = await get_recurrence(session, recurrence_id)
start = date_from or today()
end = date_to or add_months(start, 12)
if end < start:
raise ValidationError("'to' darf nicht vor 'from' liegen.", code="invalid_date_range")
stmt = select(Occurrence).where(
Occurrence.recurrence_id == recurrence_id,
Occurrence.occurrence_date >= start,
Occurrence.occurrence_date <= end,
)
overlays = list((await session.execute(stmt)).scalars().all())
planned = expand(
recurrence,
start,
end,
amount_versions=recurrence.amount_versions,
occurrences=overlays,
)
return [to_occurrence_out(item, recurrence) for item in planned]
@router.post(
"/{recurrence_id}/cancel",
response_model=RecurrenceDetailOut,
responses=NOT_FOUND,
summary="Vertrag kündigen",
description="Setzt `contract_cancelled_at`. Ohne Datum wird der nächste "
"Kündigungstermin verwendet, ersatzweise das Vertragsende.",
)
async def cancel_recurrence(
recurrence_id: int,
session: DbSession,
effective_date: date | None = Query(
default=None, description="Letzter Tag, an dem der Vertrag läuft."
),
) -> RecurrenceDetailOut:
recurrence = await get_recurrence(session, recurrence_id)
cancel_on = effective_date
if cancel_on is None:
term = contract_term(recurrence, today())
if term is None:
raise ValidationError(
"Für diesen Posten ist keine Vertragslaufzeit hinterlegt bitte "
"ein Kündigungsdatum angeben.",
code="no_contract_term",
)
cancel_on = term.term_end
if cancel_on < recurrence.dtstart:
raise ValidationError(
"Das Kündigungsdatum darf nicht vor dem Serienstart liegen.",
code="invalid_date_range",
)
recurrence.contract_cancelled_at = cancel_on
await session.commit()
return await _detail(session, await get_recurrence(session, recurrence_id))
+51
View File
@@ -0,0 +1,51 @@
"""Auswertungen."""
from datetime import date
from fastapi import APIRouter, Query
from app.api.deps import DbSession
from app.core.clock import today
from app.schemas.report import MonthComparisonOut, MonthReportOut, TotalsOut
from app.services.reports import Totals, month_report
router = APIRouter(prefix="/reports", tags=["reports"])
def _totals(value: Totals) -> TotalsOut:
return TotalsOut(income=value.income, expenses=value.expenses, balance=value.balance)
@router.get(
"/month",
response_model=MonthReportOut,
summary="Monatsübersicht",
description="Einnahmen, Ausgaben, Saldo, Plan-Ist-Vergleich, Aufteilung in fixe "
"und variable Kosten sowie die Veränderung gegenüber dem Vormonat.",
)
async def read_month_report(
session: DbSession,
month: date | None = Query(
default=None, description="Beliebiger Tag im gewünschten Monat; Vorgabe ist heute."
),
) -> MonthReportOut:
report = await month_report(session, month or today())
return MonthReportOut(
month=report.month,
planned=_totals(report.planned),
actual=_totals(report.actual),
previous_planned=_totals(report.previous_planned),
previous_actual=_totals(report.previous_actual),
delta_to_previous=MonthComparisonOut(
income=report.income_delta,
expenses=report.expenses_delta,
balance=report.balance_delta,
),
fixed_costs=report.fixed_costs,
variable_costs=report.variable_costs,
reserves=report.reserves,
available_after_fixed=report.available_after_fixed,
confirmed_count=report.confirmed_count,
open_count=report.open_count,
skipped_count=report.skipped_count,
)
+80
View File
@@ -0,0 +1,80 @@
"""Sparziele."""
from fastapi import APIRouter, Query, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app.api.deps import DbSession
from app.core.errors import ConflictError
from app.models import SavingsGoal
from app.schemas.budget import SavingsGoalCreate, SavingsGoalOut, SavingsGoalUpdate
from app.schemas.common import ErrorResponse, MessageResponse
from app.services.crud import apply_updates, get_or_404
router = APIRouter(prefix="/savings-goals", tags=["savings-goals"])
NOT_FOUND = {status.HTTP_404_NOT_FOUND: {"model": ErrorResponse}}
@router.get("", response_model=list[SavingsGoalOut], summary="Sparziele auflisten")
async def list_goals(
session: DbSession,
include_archived: bool = Query(default=False),
) -> list[SavingsGoal]:
stmt = select(SavingsGoal).order_by(SavingsGoal.target_date.nulls_last(), SavingsGoal.name)
if not include_archived:
stmt = stmt.where(SavingsGoal.is_archived.is_(False))
return list((await session.execute(stmt)).scalars().all())
@router.post(
"",
response_model=SavingsGoalOut,
status_code=status.HTTP_201_CREATED,
summary="Sparziel anlegen",
)
async def create_goal(payload: SavingsGoalCreate, session: DbSession) -> SavingsGoal:
goal = SavingsGoal(**payload.model_dump())
session.add(goal)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise ConflictError(f"Das Sparziel '{payload.name}' existiert bereits.") from exc
await session.refresh(goal)
return goal
@router.get(
"/{goal_id}", response_model=SavingsGoalOut, responses=NOT_FOUND, summary="Sparziel lesen"
)
async def read_goal(goal_id: int, session: DbSession) -> SavingsGoal:
return await get_or_404(session, SavingsGoal, goal_id)
@router.patch(
"/{goal_id}", response_model=SavingsGoalOut, responses=NOT_FOUND, summary="Sparziel ändern"
)
async def update_goal(goal_id: int, payload: SavingsGoalUpdate, session: DbSession) -> SavingsGoal:
goal = await get_or_404(session, SavingsGoal, goal_id)
apply_updates(goal, payload)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise ConflictError("Ein Sparziel mit diesem Namen existiert bereits.") from exc
await session.refresh(goal)
return goal
@router.delete(
"/{goal_id}",
response_model=MessageResponse,
responses=NOT_FOUND,
summary="Sparziel löschen",
)
async def delete_goal(goal_id: int, session: DbSession) -> MessageResponse:
goal = await get_or_404(session, SavingsGoal, goal_id)
await session.delete(goal)
await session.commit()
return MessageResponse(detail="Sparziel gelöscht.")
+138
View File
@@ -0,0 +1,138 @@
"""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.")