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:
@@ -0,0 +1,55 @@
|
||||
"""Wiederverwendbare FastAPI-Dependencies."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.cookies import ACCESS_COOKIE
|
||||
from app.core.errors import AppError, AuthError
|
||||
from app.core.security import decode_token
|
||||
from app.db.session import get_session
|
||||
from app.models import AppUser
|
||||
from app.services.auth import get_user
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
class PasswordChangeRequiredError(AppError):
|
||||
"""Der Zugriff ist erst nach dem erzwungenen Passwortwechsel möglich."""
|
||||
|
||||
status_code = 403
|
||||
code = "password_change_required"
|
||||
|
||||
|
||||
async def get_current_user(request: Request, session: DbSession) -> AppUser:
|
||||
"""Liest das Access-Token aus dem Cookie und lädt den Benutzer."""
|
||||
token = request.cookies.get(ACCESS_COOKIE)
|
||||
if not token:
|
||||
raise AuthError("Nicht angemeldet.", code="not_authenticated")
|
||||
|
||||
payload = decode_token(token, "access")
|
||||
try:
|
||||
user_id = int(payload["sub"])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise AuthError("Ungültiges Token.", code="invalid_token") from exc
|
||||
|
||||
user = await get_user(session, user_id)
|
||||
if user is None:
|
||||
raise AuthError("Der Benutzer existiert nicht mehr.", code="invalid_token")
|
||||
return user
|
||||
|
||||
|
||||
CurrentUser = Annotated[AppUser, Depends(get_current_user)]
|
||||
|
||||
|
||||
async def get_active_user(user: CurrentUser) -> AppUser:
|
||||
"""Wie `get_current_user`, verlangt aber einen abgeschlossenen Passwortwechsel."""
|
||||
if user.must_change_password:
|
||||
raise PasswordChangeRequiredError(
|
||||
"Das Passwort muss zuerst geändert werden.",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
ActiveUser = Annotated[AppUser, Depends(get_active_user)]
|
||||
@@ -1,8 +1,45 @@
|
||||
"""Sammelrouter für alle /api-Endpunkte."""
|
||||
"""Sammelrouter für alle /api-Endpunkte.
|
||||
|
||||
from fastapi import APIRouter
|
||||
Alle Routen außer `/api/auth/*`, `/api/me` und den Systemendpunkten erfordern
|
||||
eine gültige Anmeldung **und** einen abgeschlossenen Passwortwechsel.
|
||||
"""
|
||||
|
||||
from app.api.routes import system
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api.deps import get_active_user
|
||||
from app.api.routes import (
|
||||
accounts,
|
||||
auth,
|
||||
budgets,
|
||||
categories,
|
||||
me,
|
||||
merchants,
|
||||
occurrences,
|
||||
recurrences,
|
||||
reports,
|
||||
savings_goals,
|
||||
system,
|
||||
transactions,
|
||||
)
|
||||
|
||||
api_router = APIRouter(prefix="/api")
|
||||
|
||||
# Ohne Authentifizierung erreichbar.
|
||||
api_router.include_router(system.router)
|
||||
api_router.include_router(auth.router)
|
||||
api_router.include_router(me.router)
|
||||
|
||||
# Alles Weitere nur für angemeldete Benutzer.
|
||||
protected = APIRouter(dependencies=[Depends(get_active_user)])
|
||||
protected.include_router(accounts.router)
|
||||
protected.include_router(categories.router)
|
||||
protected.include_router(merchants.router)
|
||||
protected.include_router(recurrences.router)
|
||||
protected.include_router(occurrences.router)
|
||||
protected.include_router(transactions.router)
|
||||
protected.include_router(budgets.router)
|
||||
protected.include_router(budgets.templates)
|
||||
protected.include_router(savings_goals.router)
|
||||
protected.include_router(reports.router)
|
||||
|
||||
api_router.include_router(protected)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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.")
|
||||
@@ -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.")
|
||||
@@ -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.")
|
||||
@@ -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
|
||||
@@ -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.")
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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.")
|
||||
@@ -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.")
|
||||
Reference in New Issue
Block a user