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:
+3
-2
@@ -22,8 +22,9 @@ POSTGRES_PASSWORD=bitte-aendern
|
||||
DATABASE_URL=postgresql+asyncpg://moneyfy:bitte-aendern@moneyfy-db:5432/moneyfy
|
||||
|
||||
# --- Sicherheit --------------------------------------------------------------
|
||||
# Signaturschlüssel für JWTs. Erzeugen mit: openssl rand -hex 32
|
||||
SECRET_KEY=bitte-aendern
|
||||
# Signaturschlüssel für JWTs, mindestens 32 Zeichen.
|
||||
# Erzeugen mit: openssl rand -hex 32
|
||||
SECRET_KEY=bitte-aendern-mindestens-32-zeichen-lang
|
||||
ACCESS_TOKEN_TTL_MINUTES=30
|
||||
REFRESH_TOKEN_TTL_DAYS=14
|
||||
COOKIE_SECURE=true # Hinter HTTPS-Proxy true, für lokales HTTP false
|
||||
|
||||
@@ -35,3 +35,23 @@ die Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
`notice_deadline` (Mindestlaufzeit, automatische Verlängerung, Kündigungsfrist)
|
||||
sowie `annual_burden` und `monthly_reserve` für die Rücklagenbildung.
|
||||
- `validate_rrule` und `next_dates` als Grundlage für Eingabeprüfung und Vorschau.
|
||||
- Authentifizierung mit Argon2id, JWT in httpOnly-Cookies (Access 30 min, Refresh
|
||||
14 Tage) und echter Token-Rotation über die Tabelle `refresh_token`; ein erneut
|
||||
eingelöstes Refresh-Token beendet alle Sitzungen.
|
||||
- `AuthProvider`-Protokoll mit lokalem Provider als Vorbereitung für OIDC.
|
||||
- Anlage des Administrators beim Erststart mit erzwungenem Passwortwechsel; bis
|
||||
dahin sind alle Fachrouten gesperrt.
|
||||
- CRUD für Konten, Kategorien, Firmen, wiederkehrende Posten, Preisversionen,
|
||||
Buchungen, Budgets, Budgetvorlagen und Sparziele.
|
||||
- Fälligkeiten-API mit Overlay-Logik: abrufen, bestätigen (auch mit abweichendem
|
||||
Betrag oder Datum), auslassen und zurücksetzen.
|
||||
- Kontosalden zum Stichtag aus Eröffnungssaldo, Buchungen und bestätigten
|
||||
Fälligkeiten, inklusive abweichender Konten je Fälligkeit.
|
||||
- Monatsübersicht mit Plan-Ist-Vergleich, Aufteilung in fixe und variable Kosten,
|
||||
Rücklagen und Vergleich zum Vormonat.
|
||||
- Vollständig annotiertes OpenAPI-Dokument unter `/api/docs`.
|
||||
|
||||
### Geändert
|
||||
|
||||
- `SECRET_KEY` muss mindestens 32 Zeichen lang sein (Vorgabe von HS256); in
|
||||
Produktion wird der Platzhalterwert beim Start abgelehnt.
|
||||
|
||||
@@ -64,7 +64,7 @@ vollständige, kommentierte Liste. Die wichtigsten:
|
||||
| Variable | Standard | Bedeutung |
|
||||
|---|---|---|
|
||||
| `DATABASE_URL` | – | PostgreSQL-DSN, `postgresql://` wird auf asyncpg umgestellt |
|
||||
| `SECRET_KEY` | – | Signaturschlüssel für JWTs (`openssl rand -hex 32`) |
|
||||
| `SECRET_KEY` | – | Signaturschlüssel für JWTs, mind. 32 Zeichen (`openssl rand -hex 32`) |
|
||||
| `MONEYFY_ADMIN_USER` / `MONEYFY_ADMIN_PASSWORD` | `admin` / – | Beim Erststart angelegter Benutzer |
|
||||
| `TIMEZONE` | `Europe/Berlin` | Zeitzone der gesamten Anwendung |
|
||||
| `HOLIDAY_REGION` | `DE-NW` | Feiertagsregion für Werktagsverschiebungen |
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""refresh token rotation
|
||||
|
||||
Revision ID: 07d8d62011b3
|
||||
Revises: 2bc4ab55dbe9
|
||||
Create Date: 2026-09-09 13:23:58.343172+02:00
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '07d8d62011b3'
|
||||
down_revision: str | None = '2bc4ab55dbe9'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('refresh_token',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('jti', sa.String(length=64), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['app_user.id'], name=op.f('fk_refresh_token_user_id_app_user'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_refresh_token')),
|
||||
sa.UniqueConstraint('jti', name=op.f('uq_refresh_token_jti'))
|
||||
)
|
||||
op.create_index('ix_refresh_token_user_id', 'refresh_token', ['user_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index('ix_refresh_token_user_id', table_name='refresh_token')
|
||||
op.drop_table('refresh_token')
|
||||
# ### end Alembic commands ###
|
||||
@@ -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.")
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Zeitfunktionen. Fachlich gilt durchgängig `Europe/Berlin`."""
|
||||
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def tz() -> ZoneInfo:
|
||||
return ZoneInfo(settings.timezone)
|
||||
|
||||
|
||||
def now() -> datetime:
|
||||
"""Aktueller Zeitpunkt in der fachlichen Zeitzone."""
|
||||
return datetime.now(tz())
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
"""Aktueller Zeitpunkt in UTC – für Zeitstempel in der Datenbank."""
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def today() -> date:
|
||||
"""Heutiges Datum in der fachlichen Zeitzone."""
|
||||
return now().date()
|
||||
|
||||
|
||||
def month_start(day: date) -> date:
|
||||
"""Erster Tag des Monats, in dem `day` liegt."""
|
||||
return day.replace(day=1)
|
||||
|
||||
|
||||
def month_end(day: date) -> date:
|
||||
"""Letzter Tag des Monats, in dem `day` liegt."""
|
||||
if day.month == 12:
|
||||
return day.replace(day=31)
|
||||
return day.replace(month=day.month + 1, day=1) - timedelta(days=1)
|
||||
|
||||
|
||||
def add_months(day: date, months: int) -> date:
|
||||
"""Verschiebt ein Datum um ganze Monate; der Monatserste bleibt Monatserster."""
|
||||
total = day.month - 1 + months
|
||||
year = day.year + total // 12
|
||||
month = total % 12 + 1
|
||||
last_day = month_end(date(year, month, 1)).day
|
||||
return date(year, month, min(day.day, last_day))
|
||||
@@ -4,9 +4,12 @@ from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, PostgresDsn, field_validator
|
||||
from pydantic import Field, PostgresDsn, field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# Erkennbarer Platzhalter: erlaubt lokale Starts, ist in Produktion aber verboten.
|
||||
PLACEHOLDER_SECRET = "bitte-aendern-" + "0" * 32
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Alle Einstellungen stammen aus der Umgebung bzw. einer .env-Datei."""
|
||||
@@ -34,7 +37,8 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
# --- Sicherheit ----------------------------------------------------------
|
||||
secret_key: str = Field(default="change-me-in-production", min_length=8)
|
||||
# HS256 verlangt mindestens 32 Byte Schlüsselmaterial (RFC 7518, Abschnitt 3.2).
|
||||
secret_key: str = Field(default=PLACEHOLDER_SECRET, min_length=32)
|
||||
access_token_ttl_minutes: int = 30
|
||||
refresh_token_ttl_days: int = 14
|
||||
cookie_secure: bool = True
|
||||
@@ -88,6 +92,15 @@ class Settings(BaseSettings):
|
||||
return value.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _reject_placeholder_secret(self) -> "Settings":
|
||||
"""In Produktion muss ein eigener Signaturschlüssel gesetzt sein."""
|
||||
if self.environment == "production" and self.secret_key == PLACEHOLDER_SECRET:
|
||||
raise ValueError(
|
||||
"SECRET_KEY ist nicht gesetzt. Einen Schlüssel erzeugen mit: openssl rand -hex 32"
|
||||
)
|
||||
return self
|
||||
|
||||
@property
|
||||
def sync_database_url(self) -> str:
|
||||
"""Synchrone Variante der DSN – wird von Alembic benötigt."""
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Setzen und Löschen der Authentifizierungs-Cookies."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import Response
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
ACCESS_COOKIE = "moneyfy_access"
|
||||
REFRESH_COOKIE = "moneyfy_refresh"
|
||||
|
||||
# Der Refresh-Cookie wird nur an die Endpunkte geschickt, die ihn wirklich brauchen.
|
||||
REFRESH_COOKIE_PATH = "/api/auth"
|
||||
|
||||
|
||||
def set_auth_cookies(
|
||||
response: Response,
|
||||
access_token: str,
|
||||
access_expires_at: datetime,
|
||||
refresh_token: str,
|
||||
refresh_expires_at: datetime,
|
||||
) -> None:
|
||||
"""Legt beide Cookies als httpOnly/SameSite=Lax ab."""
|
||||
now = datetime.now(UTC)
|
||||
response.set_cookie(
|
||||
ACCESS_COOKIE,
|
||||
access_token,
|
||||
max_age=max(int((access_expires_at - now).total_seconds()), 0),
|
||||
httponly=True,
|
||||
secure=settings.cookie_secure,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
domain=settings.cookie_domain,
|
||||
)
|
||||
response.set_cookie(
|
||||
REFRESH_COOKIE,
|
||||
refresh_token,
|
||||
max_age=max(int((refresh_expires_at - now).total_seconds()), 0),
|
||||
httponly=True,
|
||||
secure=settings.cookie_secure,
|
||||
samesite="lax",
|
||||
path=REFRESH_COOKIE_PATH,
|
||||
domain=settings.cookie_domain,
|
||||
)
|
||||
|
||||
|
||||
def clear_auth_cookies(response: Response) -> None:
|
||||
"""Entfernt beide Cookies – muss dieselben Attribute wie beim Setzen verwenden."""
|
||||
response.delete_cookie(
|
||||
ACCESS_COOKIE,
|
||||
path="/",
|
||||
domain=settings.cookie_domain,
|
||||
httponly=True,
|
||||
secure=settings.cookie_secure,
|
||||
samesite="lax",
|
||||
)
|
||||
response.delete_cookie(
|
||||
REFRESH_COOKIE,
|
||||
path=REFRESH_COOKIE_PATH,
|
||||
domain=settings.cookie_domain,
|
||||
httponly=True,
|
||||
secure=settings.cookie_secure,
|
||||
samesite="lax",
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Passwort-Hashing und JWT-Ausstellung."""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any, Literal
|
||||
|
||||
import jwt
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import InvalidHashError, VerifyMismatchError
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.errors import AuthError
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
TokenType = Literal["access", "refresh"]
|
||||
|
||||
_hasher = PasswordHasher()
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Erzeugt einen Argon2id-Hash."""
|
||||
return _hasher.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str | None) -> bool:
|
||||
"""Prüft ein Passwort gegen den Hash. Fehlender Hash gilt immer als falsch."""
|
||||
if not password_hash:
|
||||
return False
|
||||
try:
|
||||
return _hasher.verify(password_hash, password)
|
||||
except (VerifyMismatchError, InvalidHashError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def needs_rehash(password_hash: str) -> bool:
|
||||
"""True, wenn der Hash mit veralteten Parametern erzeugt wurde."""
|
||||
try:
|
||||
return _hasher.check_needs_rehash(password_hash)
|
||||
except (InvalidHashError, ValueError):
|
||||
return True
|
||||
|
||||
|
||||
def create_token(
|
||||
subject: int,
|
||||
token_type: TokenType,
|
||||
*,
|
||||
expires_in: timedelta,
|
||||
jti: str | None = None,
|
||||
) -> tuple[str, str, datetime]:
|
||||
"""Erzeugt ein signiertes JWT und liefert (Token, jti, Ablaufzeitpunkt)."""
|
||||
now = datetime.now(UTC)
|
||||
expires_at = now + expires_in
|
||||
token_id = jti or uuid.uuid4().hex
|
||||
payload: dict[str, Any] = {
|
||||
"sub": str(subject),
|
||||
"typ": token_type,
|
||||
"jti": token_id,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int(expires_at.timestamp()),
|
||||
"iss": settings.app_name,
|
||||
}
|
||||
token = jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
|
||||
return token, token_id, expires_at
|
||||
|
||||
|
||||
def create_access_token(subject: int) -> tuple[str, str, datetime]:
|
||||
return create_token(
|
||||
subject, "access", expires_in=timedelta(minutes=settings.access_token_ttl_minutes)
|
||||
)
|
||||
|
||||
|
||||
def create_refresh_token(subject: int) -> tuple[str, str, datetime]:
|
||||
return create_token(
|
||||
subject, "refresh", expires_in=timedelta(days=settings.refresh_token_ttl_days)
|
||||
)
|
||||
|
||||
|
||||
def decode_token(token: str, expected_type: TokenType) -> dict[str, Any]:
|
||||
"""Prüft Signatur, Ablauf und Tokenart. Wirft `AuthError` bei jedem Problem."""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.secret_key,
|
||||
algorithms=[ALGORITHM],
|
||||
issuer=settings.app_name,
|
||||
options={"require": ["exp", "sub", "jti"]},
|
||||
)
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
raise AuthError("Die Sitzung ist abgelaufen.", code="token_expired") from exc
|
||||
except jwt.PyJWTError as exc:
|
||||
raise AuthError("Ungültiges Token.", code="invalid_token") from exc
|
||||
|
||||
if payload.get("typ") != expected_type:
|
||||
raise AuthError("Ungültige Tokenart.", code="invalid_token")
|
||||
return payload
|
||||
@@ -10,6 +10,8 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.api.router import api_router
|
||||
from app.core.config import settings
|
||||
from app.core.errors import register_exception_handlers
|
||||
from app.db.session import SessionLocal, engine
|
||||
from app.services.auth import ensure_admin_user, purge_expired_refresh_tokens
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if settings.debug else logging.INFO,
|
||||
@@ -22,11 +24,30 @@ logger = logging.getLogger("moneyfy")
|
||||
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Start- und Stopplogik der Anwendung."""
|
||||
settings.logo_storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
await _bootstrap()
|
||||
logger.info("moneyfy %s gestartet (%s)", settings.app_version, settings.environment)
|
||||
yield
|
||||
await engine.dispose()
|
||||
logger.info("moneyfy wird beendet")
|
||||
|
||||
|
||||
async def _bootstrap() -> None:
|
||||
"""Erststart: Administrator anlegen und abgelaufene Sitzungen aufräumen.
|
||||
|
||||
Fehler werden protokolliert, beenden die Anwendung aber nicht – der
|
||||
Health-Check meldet eine nicht erreichbare Datenbank ohnehin.
|
||||
"""
|
||||
try:
|
||||
async with SessionLocal() as session:
|
||||
await ensure_admin_user(session)
|
||||
removed = await purge_expired_refresh_tokens(session)
|
||||
await session.commit()
|
||||
if removed:
|
||||
logger.info("%d abgelaufene Sitzungen entfernt.", removed)
|
||||
except Exception:
|
||||
logger.exception("Start-Initialisierung fehlgeschlagen.")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="moneyfy",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""SQLAlchemy-Modelle. Import hier hält Alembics Autogenerate vollständig."""
|
||||
|
||||
from app.db.base import Base
|
||||
from app.models.core import Account, AppUser, Category, LogoAsset, Merchant
|
||||
from app.models.core import Account, AppUser, Category, LogoAsset, Merchant, RefreshToken
|
||||
from app.models.enums import (
|
||||
AccountType,
|
||||
BusinessDayShift,
|
||||
@@ -48,6 +48,7 @@ __all__ = [
|
||||
"Occurrence",
|
||||
"OccurrenceStatus",
|
||||
"Recurrence",
|
||||
"RefreshToken",
|
||||
"ReserveLedger",
|
||||
"SavingsGoal",
|
||||
"Transaction",
|
||||
|
||||
@@ -148,3 +148,26 @@ class AppUser(Base, CreatedAtMixin):
|
||||
name="local_or_external_login",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class RefreshToken(Base, CreatedAtMixin):
|
||||
"""Ausgegebenes Refresh-Token.
|
||||
|
||||
Für die Rotation beim Refresh wird serverseitiger Zustand benötigt: Ein Token
|
||||
ist nur gültig, solange seine `jti` hier ungesperrt hinterlegt ist. Beim Refresh
|
||||
wird der alte Eintrag gesperrt und ein neuer angelegt.
|
||||
"""
|
||||
|
||||
__tablename__ = "refresh_token"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
jti: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
user: Mapped[AppUser] = relationship()
|
||||
|
||||
__table_args__ = (Index("ix_refresh_token_user_id", "user_id"),)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Schemata für Konten."""
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.models.enums import AccountType
|
||||
from app.schemas.common import ApiModel, HexColor, InputModel, Money
|
||||
|
||||
|
||||
class AccountBase(InputModel):
|
||||
name: str = Field(min_length=1, max_length=120, examples=["Girokonto"])
|
||||
type: AccountType = AccountType.CHECKING
|
||||
iban_last4: str | None = Field(
|
||||
default=None, pattern=r"^\d{4}$", description="Letzte vier Stellen der IBAN."
|
||||
)
|
||||
opening_balance: Money = Field(
|
||||
default=Decimal("0.00"), description="Saldo zum Stichtag `opening_balance_date`."
|
||||
)
|
||||
opening_balance_date: date
|
||||
color: HexColor = "#3b82f6"
|
||||
icon: str = Field(default="wallet", max_length=64, description="Name eines lucide-Icons.")
|
||||
is_active: bool = True
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class AccountCreate(AccountBase):
|
||||
pass
|
||||
|
||||
|
||||
class AccountUpdate(InputModel):
|
||||
"""Alle Felder optional – gesetzt wird nur, was mitgeschickt wurde."""
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||
type: AccountType | None = None
|
||||
iban_last4: str | None = Field(default=None, pattern=r"^\d{4}$")
|
||||
opening_balance: Money | None = None
|
||||
opening_balance_date: date | None = None
|
||||
color: HexColor | None = None
|
||||
icon: str | None = Field(default=None, max_length=64)
|
||||
is_active: bool | None = None
|
||||
sort_order: int | None = None
|
||||
|
||||
|
||||
class AccountOut(ApiModel):
|
||||
id: int
|
||||
name: str
|
||||
type: AccountType
|
||||
iban_last4: str | None
|
||||
opening_balance: Money
|
||||
opening_balance_date: date
|
||||
color: str
|
||||
icon: str
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class AccountBalanceOut(ApiModel):
|
||||
"""Fortgeschriebener Saldo zu einem Stichtag."""
|
||||
|
||||
account_id: int
|
||||
as_of: date
|
||||
opening_balance: Money
|
||||
booked_transactions: Money = Field(description="Summe der einmaligen Buchungen.")
|
||||
booked_occurrences: Money = Field(description="Summe der bestätigten Fälligkeiten.")
|
||||
balance: Money = Field(description="Eröffnungssaldo zuzüglich aller Bewegungen.")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Schemata für Anmeldung und Benutzerkonto."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.common import ApiModel, InputModel
|
||||
|
||||
|
||||
class LoginRequest(InputModel):
|
||||
username: str = Field(min_length=1, max_length=120, examples=["admin"])
|
||||
password: str = Field(min_length=1, max_length=256)
|
||||
|
||||
|
||||
class ChangePasswordRequest(InputModel):
|
||||
current_password: str = Field(min_length=1, max_length=256)
|
||||
new_password: str = Field(min_length=10, max_length=256)
|
||||
|
||||
|
||||
class UserOut(ApiModel):
|
||||
"""Der angemeldete Benutzer."""
|
||||
|
||||
id: int
|
||||
username: str
|
||||
email: str | None = None
|
||||
must_change_password: bool = Field(
|
||||
description="Solange true, sind außer /api/me und /api/auth/* alle Routen gesperrt."
|
||||
)
|
||||
last_login_at: datetime | None = None
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Schemata für Budgets, Budgetvorlagen und Sparziele."""
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from app.schemas.common import (
|
||||
ApiModel,
|
||||
HexColor,
|
||||
InputModel,
|
||||
Money,
|
||||
NonNegativeMoney,
|
||||
PositiveMoney,
|
||||
)
|
||||
|
||||
|
||||
def _to_month_start(value: date) -> date:
|
||||
"""Budgets gelten immer für einen ganzen Monat."""
|
||||
return value.replace(day=1)
|
||||
|
||||
|
||||
class BudgetCreate(InputModel):
|
||||
category_id: int
|
||||
period_month: date = Field(description="Beliebiger Tag im Monat; wird auf den Ersten gesetzt.")
|
||||
limit_amount: PositiveMoney
|
||||
rollover: bool = Field(
|
||||
default=False, description="Nicht verbrauchtes Budget in den Folgemonat übernehmen."
|
||||
)
|
||||
|
||||
@field_validator("period_month")
|
||||
@classmethod
|
||||
def _normalise(cls, value: date) -> date:
|
||||
return _to_month_start(value)
|
||||
|
||||
|
||||
class BudgetUpdate(InputModel):
|
||||
limit_amount: PositiveMoney | None = None
|
||||
rollover: bool | None = None
|
||||
|
||||
|
||||
class BudgetOut(ApiModel):
|
||||
id: int
|
||||
category_id: int
|
||||
period_month: date
|
||||
limit_amount: Money
|
||||
rollover: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class BudgetTemplateCreate(InputModel):
|
||||
category_id: int
|
||||
valid_from: date = Field(description="Ab diesem Monat gilt die Vorlage dauerhaft.")
|
||||
valid_until: date | None = Field(default=None, description="Letzter Monat, sonst unbegrenzt.")
|
||||
limit_amount: PositiveMoney
|
||||
rollover: bool = False
|
||||
|
||||
@field_validator("valid_from", "valid_until")
|
||||
@classmethod
|
||||
def _normalise(cls, value: date | None) -> date | None:
|
||||
return _to_month_start(value) if value else None
|
||||
|
||||
|
||||
class BudgetTemplateUpdate(InputModel):
|
||||
valid_until: date | None = None
|
||||
limit_amount: PositiveMoney | None = None
|
||||
rollover: bool | None = None
|
||||
|
||||
@field_validator("valid_until")
|
||||
@classmethod
|
||||
def _normalise(cls, value: date | None) -> date | None:
|
||||
return _to_month_start(value) if value else None
|
||||
|
||||
|
||||
class BudgetTemplateOut(ApiModel):
|
||||
id: int
|
||||
category_id: int
|
||||
valid_from: date
|
||||
valid_until: date | None
|
||||
limit_amount: Money
|
||||
rollover: bool
|
||||
|
||||
|
||||
class SavingsGoalCreate(InputModel):
|
||||
name: str = Field(min_length=1, max_length=160, examples=["Neues Fahrrad"])
|
||||
target_amount: PositiveMoney
|
||||
target_date: date | None = None
|
||||
current_amount: NonNegativeMoney = Decimal("0.00")
|
||||
account_id: int | None = None
|
||||
monthly_contribution: PositiveMoney | None = None
|
||||
color: HexColor = "#10b981"
|
||||
icon: str = Field(default="piggy-bank", max_length=64)
|
||||
is_archived: bool = False
|
||||
|
||||
|
||||
class SavingsGoalUpdate(InputModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=160)
|
||||
target_amount: PositiveMoney | None = None
|
||||
target_date: date | None = None
|
||||
current_amount: NonNegativeMoney | None = None
|
||||
account_id: int | None = None
|
||||
monthly_contribution: PositiveMoney | None = None
|
||||
color: HexColor | None = None
|
||||
icon: str | None = Field(default=None, max_length=64)
|
||||
is_archived: bool | None = None
|
||||
|
||||
|
||||
class SavingsGoalOut(ApiModel):
|
||||
id: int
|
||||
name: str
|
||||
target_amount: Money
|
||||
target_date: date | None
|
||||
current_amount: Money
|
||||
account_id: int | None
|
||||
monthly_contribution: Money | None
|
||||
color: str
|
||||
icon: str
|
||||
is_archived: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Schemata für den zweistufigen Kategoriebaum."""
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.models.enums import EntryKind
|
||||
from app.schemas.common import ApiModel, HexColor, InputModel
|
||||
|
||||
|
||||
class CategoryCreate(InputModel):
|
||||
name: str = Field(min_length=1, max_length=120, examples=["Streaming"])
|
||||
kind: EntryKind = Field(description="Wird bei Unterkategorien vom Elternknoten übernommen.")
|
||||
parent_id: int | None = Field(
|
||||
default=None, description="Nur Oberkategorien zulässig – der Baum ist zweistufig."
|
||||
)
|
||||
color: HexColor = "#64748b"
|
||||
icon: str = Field(default="circle", max_length=64)
|
||||
is_fixed_cost: bool = Field(
|
||||
default=False, description="Zählt in die Kennzahl 'Verfügbar nach Fixkosten'."
|
||||
)
|
||||
sort_order: int = 0
|
||||
is_archived: bool = False
|
||||
|
||||
|
||||
class CategoryUpdate(InputModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||
parent_id: int | None = None
|
||||
color: HexColor | None = None
|
||||
icon: str | None = Field(default=None, max_length=64)
|
||||
is_fixed_cost: bool | None = None
|
||||
sort_order: int | None = None
|
||||
is_archived: bool | None = None
|
||||
|
||||
|
||||
class CategoryOut(ApiModel):
|
||||
id: int
|
||||
parent_id: int | None
|
||||
name: str
|
||||
kind: EntryKind
|
||||
color: str
|
||||
icon: str
|
||||
is_fixed_cost: bool
|
||||
sort_order: int
|
||||
is_archived: bool
|
||||
|
||||
|
||||
class CategoryTreeOut(CategoryOut):
|
||||
"""Oberkategorie mit ihren Unterkategorien."""
|
||||
|
||||
children: list[CategoryOut] = Field(default_factory=list)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Gemeinsame Bausteine aller API-Schemata."""
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
# Geldbeträge werden als Decimal geführt und im JSON als String ausgeliefert,
|
||||
# damit auf dem Weg zum Frontend keine Genauigkeit verloren geht.
|
||||
Money = Annotated[Decimal, Field(max_digits=12, decimal_places=2)]
|
||||
PositiveMoney = Annotated[Decimal, Field(gt=0, max_digits=12, decimal_places=2)]
|
||||
NonNegativeMoney = Annotated[Decimal, Field(ge=0, max_digits=12, decimal_places=2)]
|
||||
HexColor = Annotated[str, Field(pattern=r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")]
|
||||
|
||||
|
||||
class ApiModel(BaseModel):
|
||||
"""Basisklasse für Ausgabeschemata; liest Werte direkt von ORM-Objekten."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class InputModel(BaseModel):
|
||||
"""Basisklasse für Eingabeschemata; unbekannte Felder werden abgewiesen."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||
|
||||
|
||||
class Page[T](ApiModel):
|
||||
"""Einfache Seitenausgabe mit Gesamtzahl."""
|
||||
|
||||
items: list[T]
|
||||
total: int = Field(description="Gesamtzahl der Treffer ohne Berücksichtigung der Seite.")
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class DateRange(InputModel):
|
||||
"""Ein von/bis-Zeitraum, beide Grenzen einschließlich."""
|
||||
|
||||
date_from: date
|
||||
date_to: date
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Einheitliches Fehlerformat aller Endpunkte."""
|
||||
|
||||
detail: str = Field(description="Für Menschen lesbare Beschreibung des Fehlers.")
|
||||
code: str = Field(description="Stabiler Fehlercode zur Auswertung im Frontend.")
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
"""Antwort für Aktionen ohne eigene Nutzlast."""
|
||||
|
||||
detail: str
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Schemata für Firmen und Zahlungsempfänger."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.models.enums import LogoSource, LogoStatus
|
||||
from app.schemas.common import ApiModel, HexColor, InputModel
|
||||
|
||||
|
||||
class MerchantCreate(InputModel):
|
||||
name: str = Field(min_length=1, max_length=160, examples=["Netflix"])
|
||||
domain: str | None = Field(
|
||||
default=None,
|
||||
max_length=255,
|
||||
examples=["netflix.com"],
|
||||
description="Verbessert die Trefferquote der Logosuche erheblich.",
|
||||
)
|
||||
aliases: list[str] = Field(
|
||||
default_factory=list, description="Weitere Schreibweisen, etwa aus Kontoauszügen."
|
||||
)
|
||||
brand_color: HexColor | None = None
|
||||
brand_color_dark: HexColor | None = None
|
||||
|
||||
|
||||
class MerchantUpdate(InputModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=160)
|
||||
domain: str | None = Field(default=None, max_length=255)
|
||||
aliases: list[str] | None = None
|
||||
brand_color: HexColor | None = None
|
||||
brand_color_dark: HexColor | None = None
|
||||
|
||||
|
||||
class MerchantOut(ApiModel):
|
||||
id: int
|
||||
name: str
|
||||
normalized_name: str
|
||||
domain: str | None
|
||||
aliases: list[str]
|
||||
logo_asset_id: int | None = Field(
|
||||
description="Wenn gesetzt, ist das Logo unter /api/logos/{id} abrufbar."
|
||||
)
|
||||
brand_color: str | None
|
||||
brand_color_dark: str | None
|
||||
logo_source: LogoSource | None
|
||||
logo_status: LogoStatus
|
||||
created_at: datetime
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Schemata für einzelne Fälligkeiten."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.models.enums import EntryKind, OccurrenceStatus
|
||||
from app.schemas.common import ApiModel, InputModel, Money, PositiveMoney
|
||||
|
||||
|
||||
class OccurrenceOut(ApiModel):
|
||||
"""Eine berechnete Fälligkeit, ggf. überlagert von einer materialisierten Zeile."""
|
||||
|
||||
recurrence_id: int
|
||||
recurrence_title: str
|
||||
kind: EntryKind
|
||||
category_id: int
|
||||
merchant_id: int | None
|
||||
account_id: int | None
|
||||
nominal_date: date = Field(
|
||||
description="Von der Wiederholungsregel geliefertes Datum. Schlüssel für "
|
||||
"`confirm` und `skip`, auch wenn der Zahltag verschoben ist."
|
||||
)
|
||||
due_date: date = Field(description="Zahltag nach Wochenend- und Feiertagsverschiebung.")
|
||||
effective_date: date = Field(description="Ist-Datum, sonst der Zahltag.")
|
||||
amount: Money = Field(description="Sollbetrag laut Preishistorie.")
|
||||
actual_amount: Money | None = None
|
||||
effective_amount: Money = Field(description="Ist-Betrag, sonst Soll. Ausgelassene zählen 0.")
|
||||
status: OccurrenceStatus
|
||||
is_variable: bool
|
||||
occurrence_id: int | None = None
|
||||
note: str | None = None
|
||||
installment_number: int | None = None
|
||||
installments_total: int | None = None
|
||||
|
||||
|
||||
class OccurrenceConfirm(InputModel):
|
||||
"""Bestätigt eine Fälligkeit, wahlweise mit abweichendem Betrag oder Datum."""
|
||||
|
||||
recurrence_id: int
|
||||
occurrence_date: date = Field(description="Das nominale Datum aus `OccurrenceOut`.")
|
||||
actual_amount: PositiveMoney | None = Field(
|
||||
default=None, description="Ohne Angabe gilt der Sollbetrag."
|
||||
)
|
||||
actual_date: date | None = Field(
|
||||
default=None, description="Ohne Angabe gilt der berechnete Zahltag."
|
||||
)
|
||||
account_id: int | None = Field(default=None, description="Abweichendes Konto.")
|
||||
note: str | None = None
|
||||
|
||||
|
||||
class OccurrenceSkip(InputModel):
|
||||
"""Markiert eine Fälligkeit als ausgefallen; sie zählt danach nirgends mehr mit."""
|
||||
|
||||
recurrence_id: int
|
||||
occurrence_date: date
|
||||
note: str | None = None
|
||||
|
||||
|
||||
class OccurrenceReset(InputModel):
|
||||
"""Nimmt eine Bestätigung oder Auslassung zurück."""
|
||||
|
||||
recurrence_id: int
|
||||
occurrence_date: date
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Schemata für wiederkehrende Posten, Preisversionen und Vorschau."""
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from app.models.enums import BusinessDayShift, EntryKind
|
||||
from app.schemas.common import ApiModel, InputModel, Money, PositiveMoney
|
||||
from app.schemas.merchant import MerchantOut
|
||||
from app.services.recurrence import InvalidRRuleError, validate_rrule
|
||||
|
||||
|
||||
class RecurrenceBase(InputModel):
|
||||
kind: EntryKind
|
||||
title: str = Field(min_length=1, max_length=160, examples=["Netflix Standard"])
|
||||
merchant_id: int | None = None
|
||||
category_id: int
|
||||
account_id: int
|
||||
amount: PositiveMoney = Field(
|
||||
description="Aktueller Betrag. Immer positiv – die Richtung steckt in `kind`."
|
||||
)
|
||||
is_variable: bool = Field(
|
||||
default=False, description="Geschätzter Betrag, das Ist weicht regelmäßig ab."
|
||||
)
|
||||
currency: str = Field(default="EUR", pattern=r"^[A-Z]{3}$")
|
||||
|
||||
rrule: str = Field(
|
||||
max_length=500,
|
||||
examples=["FREQ=MONTHLY;BYMONTHDAY=1"],
|
||||
description="Vollständige RFC-5545-RRULE ohne DTSTART.",
|
||||
)
|
||||
dtstart: date = Field(description="Erste mögliche Fälligkeit der Serie.")
|
||||
until: date | None = Field(default=None, description="Hartes Serienende, einschließlich.")
|
||||
|
||||
business_day_shift: BusinessDayShift = Field(
|
||||
default=BusinessDayShift.NEXT,
|
||||
description="Verschiebung, wenn der Termin auf Wochenende oder Feiertag fällt.",
|
||||
)
|
||||
holiday_region: str = Field(default="DE-NW", max_length=8, examples=["DE-NW"])
|
||||
|
||||
installments_total: int | None = Field(
|
||||
default=None, ge=1, description="Anzahl Raten; beendet die Serie unabhängig von der RRULE."
|
||||
)
|
||||
principal_amount: PositiveMoney | None = Field(
|
||||
default=None, description="Ursprüngliche Darlehenssumme für die Restschuldberechnung."
|
||||
)
|
||||
|
||||
contract_start: date | None = None
|
||||
contract_min_term_months: int | None = Field(default=None, ge=1)
|
||||
contract_notice_period_days: int | None = Field(default=None, ge=0)
|
||||
contract_auto_renew_months: int | None = Field(default=None, ge=1)
|
||||
|
||||
reserve_enabled: bool = Field(
|
||||
default=False, description="Bildet monatliche Rücklagen für nicht-monatliche Posten."
|
||||
)
|
||||
notes: str | None = None
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
is_active: bool = True
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_rule_and_dates(self) -> "RecurrenceBase":
|
||||
try:
|
||||
validate_rrule(self.rrule, self.dtstart)
|
||||
except InvalidRRuleError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
if self.until is not None and self.until < self.dtstart:
|
||||
raise ValueError("Das Serienende darf nicht vor dem Start liegen.")
|
||||
return self
|
||||
|
||||
|
||||
class RecurrenceCreate(RecurrenceBase):
|
||||
pass
|
||||
|
||||
|
||||
class RecurrenceUpdate(InputModel):
|
||||
"""Alle Felder optional. RRULE und `dtstart` werden zusammen geprüft."""
|
||||
|
||||
kind: EntryKind | None = None
|
||||
title: str | None = Field(default=None, min_length=1, max_length=160)
|
||||
merchant_id: int | None = None
|
||||
category_id: int | None = None
|
||||
account_id: int | None = None
|
||||
amount: PositiveMoney | None = None
|
||||
is_variable: bool | None = None
|
||||
currency: str | None = Field(default=None, pattern=r"^[A-Z]{3}$")
|
||||
rrule: str | None = Field(default=None, max_length=500)
|
||||
dtstart: date | None = None
|
||||
until: date | None = None
|
||||
business_day_shift: BusinessDayShift | None = None
|
||||
holiday_region: str | None = Field(default=None, max_length=8)
|
||||
installments_total: int | None = Field(default=None, ge=1)
|
||||
principal_amount: PositiveMoney | None = None
|
||||
contract_start: date | None = None
|
||||
contract_min_term_months: int | None = Field(default=None, ge=1)
|
||||
contract_notice_period_days: int | None = Field(default=None, ge=0)
|
||||
contract_auto_renew_months: int | None = Field(default=None, ge=1)
|
||||
contract_cancelled_at: date | None = None
|
||||
reserve_enabled: bool | None = None
|
||||
notes: str | None = None
|
||||
tags: list[str] | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class AmountVersionCreate(InputModel):
|
||||
amount: PositiveMoney
|
||||
valid_from: date = Field(description="Gilt für alle Fälligkeiten ab diesem Tag.")
|
||||
note: str | None = Field(default=None, examples=["Preiserhöhung laut Schreiben vom 01.06."])
|
||||
|
||||
|
||||
class AmountVersionOut(ApiModel):
|
||||
id: int
|
||||
recurrence_id: int
|
||||
amount: Money
|
||||
valid_from: date
|
||||
note: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ContractTermOut(ApiModel):
|
||||
"""Laufende Vertragsperiode und Kündigungstermin."""
|
||||
|
||||
term_start: date
|
||||
term_end: date
|
||||
notice_deadline: date | None
|
||||
renews_on: date | None
|
||||
is_cancelled: bool
|
||||
|
||||
|
||||
class InstallmentStatusOut(ApiModel):
|
||||
"""Stand einer Ratenzahlung."""
|
||||
|
||||
total: int
|
||||
paid: int
|
||||
remaining: int
|
||||
paid_amount: Money
|
||||
remaining_amount: Money
|
||||
final_due_date: date | None
|
||||
|
||||
|
||||
class RecurrenceOut(ApiModel):
|
||||
id: int
|
||||
kind: EntryKind
|
||||
title: str
|
||||
merchant_id: int | None
|
||||
category_id: int
|
||||
account_id: int
|
||||
amount: Money
|
||||
is_variable: bool
|
||||
currency: str
|
||||
rrule: str
|
||||
dtstart: date
|
||||
until: date | None
|
||||
business_day_shift: BusinessDayShift
|
||||
holiday_region: str
|
||||
installments_total: int | None
|
||||
principal_amount: Money | None
|
||||
contract_start: date | None
|
||||
contract_min_term_months: int | None
|
||||
contract_notice_period_days: int | None
|
||||
contract_auto_renew_months: int | None
|
||||
contract_cancelled_at: date | None
|
||||
reserve_enabled: bool
|
||||
notes: str | None
|
||||
tags: list[str]
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class RecurrenceDetailOut(RecurrenceOut):
|
||||
"""Posten samt berechneter Zusatzangaben für die Detailansicht."""
|
||||
|
||||
merchant: MerchantOut | None = None
|
||||
amount_versions: list[AmountVersionOut] = Field(default_factory=list)
|
||||
next_dates: list[date] = Field(
|
||||
default_factory=list, description="Die nächsten fünf nominalen Termine."
|
||||
)
|
||||
monthly_reserve: Money | None = Field(
|
||||
default=None, description="Rücklage pro Monat, wenn `reserve_enabled` gesetzt ist."
|
||||
)
|
||||
annual_burden: Money = Field(description="Belastung der kommenden zwölf Monate.")
|
||||
contract_term: ContractTermOut | None = None
|
||||
installments: InstallmentStatusOut | None = None
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Schemata der Auswertungen."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.common import ApiModel, Money
|
||||
|
||||
|
||||
class TotalsOut(ApiModel):
|
||||
"""Einnahmen, Ausgaben und Saldo einer Sicht."""
|
||||
|
||||
income: Money
|
||||
expenses: Money
|
||||
balance: Money
|
||||
|
||||
|
||||
class MonthComparisonOut(ApiModel):
|
||||
"""Veränderung gegenüber dem Vormonat."""
|
||||
|
||||
income: Money
|
||||
expenses: Money
|
||||
balance: Money
|
||||
|
||||
|
||||
class MonthReportOut(ApiModel):
|
||||
"""Monatsübersicht mit Plan-Ist-Vergleich."""
|
||||
|
||||
month: date = Field(description="Immer der Monatserste.")
|
||||
planned: TotalsOut = Field(description="Soll aus Fälligkeiten und Buchungen.")
|
||||
actual: TotalsOut = Field(
|
||||
description="Ist aus bestätigten Fälligkeiten und allen einmaligen Buchungen."
|
||||
)
|
||||
previous_planned: TotalsOut
|
||||
previous_actual: TotalsOut
|
||||
delta_to_previous: MonthComparisonOut
|
||||
|
||||
fixed_costs: Money = Field(description="Ausgaben in Kategorien mit `is_fixed_cost`.")
|
||||
variable_costs: Money
|
||||
reserves: Money = Field(description="Summe der monatlichen Rücklagen.")
|
||||
available_after_fixed: Money = Field(description="Einkünfte abzüglich Fixkosten und Rücklagen.")
|
||||
|
||||
confirmed_count: int
|
||||
open_count: int
|
||||
skipped_count: int
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Schemata für einmalige Buchungen."""
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.models.enums import EntryKind
|
||||
from app.schemas.common import ApiModel, InputModel, Money, PositiveMoney
|
||||
from app.schemas.merchant import MerchantOut
|
||||
|
||||
|
||||
class TransactionCreate(InputModel):
|
||||
kind: EntryKind
|
||||
title: str = Field(min_length=1, max_length=160, examples=["Wocheneinkauf"])
|
||||
merchant_id: int | None = None
|
||||
category_id: int
|
||||
account_id: int
|
||||
amount: PositiveMoney = Field(description="Immer positiv – die Richtung steckt in `kind`.")
|
||||
booking_date: date
|
||||
note: str | None = None
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TransactionUpdate(InputModel):
|
||||
kind: EntryKind | None = None
|
||||
title: str | None = Field(default=None, min_length=1, max_length=160)
|
||||
merchant_id: int | None = None
|
||||
category_id: int | None = None
|
||||
account_id: int | None = None
|
||||
amount: PositiveMoney | None = None
|
||||
booking_date: date | None = None
|
||||
note: str | None = None
|
||||
tags: list[str] | None = None
|
||||
|
||||
|
||||
class TransactionOut(ApiModel):
|
||||
id: int
|
||||
kind: EntryKind
|
||||
title: str
|
||||
merchant_id: int | None
|
||||
category_id: int
|
||||
account_id: int
|
||||
amount: Money
|
||||
booking_date: date
|
||||
note: str | None
|
||||
tags: list[str]
|
||||
created_at: datetime
|
||||
merchant: MerchantOut | None = None
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Authentifizierung: Provider-Abstraktion, Anmeldung und Token-Rotation.
|
||||
|
||||
Die Anmeldung läuft grundsätzlich über einen `AuthProvider`. Aktuell existiert
|
||||
nur der lokale Provider; die Abstraktion samt `app_user.external_subject` ist
|
||||
bewusst vorhanden, damit ein OIDC-Provider später ohne Umbau ergänzt werden kann.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.errors import AuthError, ValidationError
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
hash_password,
|
||||
needs_rehash,
|
||||
verify_password,
|
||||
)
|
||||
from app.models import AppUser, RefreshToken
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MIN_PASSWORD_LENGTH = 10
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Credentials:
|
||||
"""Anmeldedaten des lokalen Providers."""
|
||||
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TokenPair:
|
||||
"""Frisch ausgestelltes Token-Paar samt Ablaufzeitpunkten."""
|
||||
|
||||
access_token: str
|
||||
access_expires_at: datetime
|
||||
refresh_token: str
|
||||
refresh_expires_at: datetime
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AuthProvider(Protocol):
|
||||
"""Ein Verfahren, das einen Benutzer identifiziert."""
|
||||
|
||||
name: str
|
||||
|
||||
async def authenticate(self, session: AsyncSession, credentials: object) -> AppUser: ...
|
||||
|
||||
|
||||
class LocalAuthProvider:
|
||||
"""Benutzername und Passwort gegen den Argon2-Hash in der Datenbank."""
|
||||
|
||||
name = "local"
|
||||
|
||||
async def authenticate(self, session: AsyncSession, credentials: object) -> AppUser:
|
||||
if not isinstance(credentials, Credentials):
|
||||
raise AuthError("Ungültige Anmeldedaten.", code="invalid_credentials")
|
||||
|
||||
user = await get_user_by_username(session, credentials.username)
|
||||
# Auch ohne Treffer wird geprüft, damit die Antwortzeit nichts verrät.
|
||||
password_hash = user.password_hash if user else None
|
||||
if not verify_password(credentials.password, password_hash) or user is None:
|
||||
raise AuthError("Benutzername oder Passwort ist falsch.", code="invalid_credentials")
|
||||
|
||||
if user.password_hash and needs_rehash(user.password_hash):
|
||||
user.password_hash = hash_password(credentials.password)
|
||||
|
||||
user.last_login_at = datetime.now(UTC)
|
||||
await session.flush()
|
||||
return user
|
||||
|
||||
|
||||
# TODO(OIDC): Sobald die Anbindung umgesetzt wird, hier einen `OidcAuthProvider`
|
||||
# registrieren, der `OIDC_ISSUER`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET` aus den
|
||||
# Settings nutzt und den Benutzer über `app_user.external_subject` auflöst.
|
||||
_PROVIDERS: dict[str, AuthProvider] = {"local": LocalAuthProvider()}
|
||||
|
||||
|
||||
def get_auth_provider(name: str = "local") -> AuthProvider:
|
||||
"""Liefert den registrierten Provider."""
|
||||
provider = _PROVIDERS.get(name)
|
||||
if provider is None:
|
||||
raise AuthError(f"Unbekanntes Anmeldeverfahren: {name}", code="unknown_provider")
|
||||
return provider
|
||||
|
||||
|
||||
# --- Benutzer ------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_user_by_username(session: AsyncSession, username: str) -> AppUser | None:
|
||||
stmt = select(AppUser).where(func.lower(AppUser.username) == username.strip().lower())
|
||||
return (await session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user(session: AsyncSession, user_id: int) -> AppUser | None:
|
||||
return await session.get(AppUser, user_id)
|
||||
|
||||
|
||||
def validate_password(password: str) -> None:
|
||||
"""Mindestanforderungen an ein neues Passwort."""
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
raise ValidationError(
|
||||
f"Das Passwort muss mindestens {MIN_PASSWORD_LENGTH} Zeichen lang sein.",
|
||||
code="password_too_short",
|
||||
)
|
||||
|
||||
|
||||
async def change_password(
|
||||
session: AsyncSession, user: AppUser, current_password: str, new_password: str
|
||||
) -> None:
|
||||
"""Ändert das Passwort und macht alle bestehenden Sitzungen ungültig."""
|
||||
if not verify_password(current_password, user.password_hash):
|
||||
raise AuthError("Das aktuelle Passwort ist falsch.", code="invalid_credentials")
|
||||
if current_password == new_password:
|
||||
raise ValidationError(
|
||||
"Das neue Passwort muss sich vom bisherigen unterscheiden.",
|
||||
code="password_unchanged",
|
||||
)
|
||||
validate_password(new_password)
|
||||
|
||||
user.password_hash = hash_password(new_password)
|
||||
user.must_change_password = False
|
||||
await revoke_all_refresh_tokens(session, user.id)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def ensure_admin_user(session: AsyncSession) -> AppUser | None:
|
||||
"""Legt beim Erststart den konfigurierten Administrator an.
|
||||
|
||||
Passiert nur, wenn noch kein Benutzer existiert und ein Passwort gesetzt ist.
|
||||
Der Benutzer muss das Passwort bei der ersten Anmeldung ändern.
|
||||
"""
|
||||
existing = (await session.execute(select(func.count()).select_from(AppUser))).scalar_one()
|
||||
if existing:
|
||||
return None
|
||||
if not settings.moneyfy_admin_password:
|
||||
logger.warning(
|
||||
"Kein Benutzer vorhanden und MONEYFY_ADMIN_PASSWORD ist nicht gesetzt – "
|
||||
"es kann sich niemand anmelden."
|
||||
)
|
||||
return None
|
||||
|
||||
user = AppUser(
|
||||
username=settings.moneyfy_admin_user,
|
||||
password_hash=hash_password(settings.moneyfy_admin_password),
|
||||
must_change_password=True,
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
logger.info("Administrator '%s' angelegt (Passwortwechsel erforderlich).", user.username)
|
||||
return user
|
||||
|
||||
|
||||
# --- Sitzungen -----------------------------------------------------------------
|
||||
|
||||
|
||||
async def issue_tokens(session: AsyncSession, user: AppUser) -> TokenPair:
|
||||
"""Stellt ein neues Token-Paar aus und hinterlegt das Refresh-Token."""
|
||||
access_token, _, access_expires = create_access_token(user.id)
|
||||
refresh_token, jti, refresh_expires = create_refresh_token(user.id)
|
||||
|
||||
session.add(RefreshToken(jti=jti, user_id=user.id, expires_at=refresh_expires))
|
||||
await session.flush()
|
||||
|
||||
return TokenPair(
|
||||
access_token=access_token,
|
||||
access_expires_at=access_expires,
|
||||
refresh_token=refresh_token,
|
||||
refresh_expires_at=refresh_expires,
|
||||
)
|
||||
|
||||
|
||||
async def rotate_tokens(session: AsyncSession, refresh_token: str) -> tuple[AppUser, TokenPair]:
|
||||
"""Prüft ein Refresh-Token, sperrt es und stellt ein neues Paar aus."""
|
||||
payload = decode_token(refresh_token, "refresh")
|
||||
jti = payload["jti"]
|
||||
|
||||
stored = (
|
||||
await session.execute(select(RefreshToken).where(RefreshToken.jti == jti))
|
||||
).scalar_one_or_none()
|
||||
if stored is None:
|
||||
raise AuthError("Die Sitzung ist nicht mehr gültig.", code="invalid_token")
|
||||
if stored.revoked_at is not None:
|
||||
# Ein bereits verwendetes Token deutet auf Diebstahl hin – alles sperren.
|
||||
await revoke_all_refresh_tokens(session, stored.user_id)
|
||||
raise AuthError("Die Sitzung wurde beendet.", code="token_reused")
|
||||
if stored.expires_at <= datetime.now(UTC):
|
||||
raise AuthError("Die Sitzung ist abgelaufen.", code="token_expired")
|
||||
|
||||
user = await get_user(session, stored.user_id)
|
||||
if user is None:
|
||||
raise AuthError("Der Benutzer existiert nicht mehr.", code="invalid_token")
|
||||
|
||||
stored.revoked_at = datetime.now(UTC)
|
||||
await session.flush()
|
||||
return user, await issue_tokens(session, user)
|
||||
|
||||
|
||||
async def revoke_refresh_token(session: AsyncSession, refresh_token: str | None) -> None:
|
||||
"""Sperrt genau ein Refresh-Token. Ungültige Token werden still ignoriert."""
|
||||
if not refresh_token:
|
||||
return
|
||||
try:
|
||||
payload = decode_token(refresh_token, "refresh")
|
||||
except AuthError:
|
||||
return
|
||||
|
||||
stored = (
|
||||
await session.execute(select(RefreshToken).where(RefreshToken.jti == payload["jti"]))
|
||||
).scalar_one_or_none()
|
||||
if stored is not None and stored.revoked_at is None:
|
||||
stored.revoked_at = datetime.now(UTC)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def revoke_all_refresh_tokens(session: AsyncSession, user_id: int) -> None:
|
||||
"""Sperrt alle offenen Sitzungen eines Benutzers."""
|
||||
stmt = select(RefreshToken).where(
|
||||
RefreshToken.user_id == user_id, RefreshToken.revoked_at.is_(None)
|
||||
)
|
||||
for token in (await session.execute(stmt)).scalars():
|
||||
token.revoked_at = datetime.now(UTC)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def purge_expired_refresh_tokens(session: AsyncSession) -> int:
|
||||
"""Räumt abgelaufene Token auf. Wird beim Start und vom Scheduler aufgerufen."""
|
||||
stmt = select(RefreshToken).where(RefreshToken.expires_at <= datetime.now(UTC))
|
||||
tokens = list((await session.execute(stmt)).scalars())
|
||||
for token in tokens:
|
||||
await session.delete(token)
|
||||
await session.flush()
|
||||
return len(tokens)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Fortschreibung der Kontosalden.
|
||||
|
||||
Saldo = Eröffnungssaldo + alle einmaligen Buchungen + alle bestätigten
|
||||
Fälligkeiten bis zum Stichtag. Geplante oder ausgelassene Fälligkeiten bleiben
|
||||
außen vor – sie sind Prognose, keine Bewegung.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.clock import today
|
||||
from app.models import Account, Occurrence, Recurrence, Transaction
|
||||
from app.models.enums import EntryKind, OccurrenceStatus
|
||||
|
||||
ZERO = Decimal("0.00")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccountBalance:
|
||||
"""Aufgeschlüsselter Saldo eines Kontos."""
|
||||
|
||||
account_id: int
|
||||
as_of: date
|
||||
opening_balance: Decimal
|
||||
booked_transactions: Decimal
|
||||
booked_occurrences: Decimal
|
||||
|
||||
@property
|
||||
def balance(self) -> Decimal:
|
||||
return self.opening_balance + self.booked_transactions + self.booked_occurrences
|
||||
|
||||
|
||||
async def account_balance(
|
||||
session: AsyncSession, account: Account, as_of: date | None = None
|
||||
) -> AccountBalance:
|
||||
"""Berechnet den Saldo eines Kontos zum Stichtag (einschließlich)."""
|
||||
reference = as_of or today()
|
||||
|
||||
transactions = await _transaction_sum(session, account.id, reference)
|
||||
occurrences = await _occurrence_sum(session, account.id, reference)
|
||||
|
||||
return AccountBalance(
|
||||
account_id=account.id,
|
||||
as_of=reference,
|
||||
opening_balance=account.opening_balance,
|
||||
booked_transactions=transactions,
|
||||
booked_occurrences=occurrences,
|
||||
)
|
||||
|
||||
|
||||
async def _transaction_sum(session: AsyncSession, account_id: int, as_of: date) -> Decimal:
|
||||
"""Vorzeichenbehaftete Summe der einmaligen Buchungen bis zum Stichtag."""
|
||||
signed = func.sum(
|
||||
case(
|
||||
(Transaction.kind == EntryKind.EXPENSE, -Transaction.amount),
|
||||
else_=Transaction.amount,
|
||||
)
|
||||
)
|
||||
stmt = select(func.coalesce(signed, ZERO)).where(
|
||||
Transaction.account_id == account_id,
|
||||
Transaction.booking_date <= as_of,
|
||||
)
|
||||
return (await session.execute(stmt)).scalar_one()
|
||||
|
||||
|
||||
async def _occurrence_sum(session: AsyncSession, account_id: int, as_of: date) -> Decimal:
|
||||
"""Vorzeichenbehaftete Summe der bestätigten Fälligkeiten bis zum Stichtag.
|
||||
|
||||
Maßgeblich sind der Ist-Betrag und – falls erfasst – das Ist-Datum. Das Konto
|
||||
kann je Fälligkeit vom Konto der Recurrence abweichen.
|
||||
"""
|
||||
effective_account = func.coalesce(Occurrence.account_id, Recurrence.account_id)
|
||||
effective_amount = func.coalesce(Occurrence.actual_amount, Occurrence.planned_amount)
|
||||
effective_date = func.coalesce(Occurrence.actual_date, Occurrence.occurrence_date)
|
||||
|
||||
signed = func.sum(
|
||||
case(
|
||||
(Recurrence.kind == EntryKind.EXPENSE, -effective_amount),
|
||||
else_=effective_amount,
|
||||
)
|
||||
)
|
||||
stmt = (
|
||||
select(func.coalesce(signed, ZERO))
|
||||
.select_from(Occurrence)
|
||||
.join(Recurrence, Recurrence.id == Occurrence.recurrence_id)
|
||||
.where(
|
||||
Occurrence.status == OccurrenceStatus.CONFIRMED,
|
||||
effective_account == account_id,
|
||||
effective_date <= as_of,
|
||||
)
|
||||
)
|
||||
return (await session.execute(stmt)).scalar_one()
|
||||
|
||||
|
||||
async def all_balances(
|
||||
session: AsyncSession, as_of: date | None = None, *, only_active: bool = True
|
||||
) -> list[AccountBalance]:
|
||||
"""Salden aller Konten zum Stichtag."""
|
||||
stmt = select(Account).order_by(Account.sort_order, Account.name)
|
||||
if only_active:
|
||||
stmt = stmt.where(Account.is_active.is_(True))
|
||||
accounts = (await session.execute(stmt)).scalars().all()
|
||||
return [await account_balance(session, account, as_of) for account in accounts]
|
||||
|
||||
|
||||
async def total_balance(session: AsyncSession, as_of: date | None = None) -> Decimal:
|
||||
"""Summe aller aktiven Kontosalden."""
|
||||
balances = await all_balances(session, as_of)
|
||||
return sum((item.balance for item in balances), ZERO)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Kleine Helfer, die sich über alle CRUD-Endpunkte wiederholen."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import Select, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.errors import NotFoundError
|
||||
from app.db.base import Base
|
||||
|
||||
# Sprechende Bezeichnungen für Fehlermeldungen in der Oberfläche.
|
||||
LABELS: dict[str, str] = {
|
||||
"account": "Das Konto",
|
||||
"amount_version": "Die Preisversion",
|
||||
"budget": "Das Budget",
|
||||
"budget_template": "Die Budgetvorlage",
|
||||
"category": "Die Kategorie",
|
||||
"logo_asset": "Das Logo",
|
||||
"merchant": "Die Firma",
|
||||
"notification_rule": "Die Benachrichtigungsregel",
|
||||
"occurrence": "Die Fälligkeit",
|
||||
"recurrence": "Der wiederkehrende Posten",
|
||||
"savings_goal": "Das Sparziel",
|
||||
"transaction": "Die Buchung",
|
||||
}
|
||||
|
||||
|
||||
def label_for(model: type[Base]) -> str:
|
||||
return LABELS.get(model.__tablename__, "Der Datensatz")
|
||||
|
||||
|
||||
async def get_or_404[ModelT: Base](
|
||||
session: AsyncSession, model: type[ModelT], object_id: int
|
||||
) -> ModelT:
|
||||
"""Lädt einen Datensatz oder wirft einen 404 mit deutscher Meldung."""
|
||||
instance = await session.get(model, object_id)
|
||||
if instance is None:
|
||||
raise NotFoundError(f"{label_for(model)} mit der ID {object_id} existiert nicht.")
|
||||
return instance
|
||||
|
||||
|
||||
def apply_updates(instance: Base, payload: BaseModel) -> Base:
|
||||
"""Überträgt nur die tatsächlich gesetzten Felder eines PATCH-Schemas."""
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(instance, field, value)
|
||||
return instance
|
||||
|
||||
|
||||
async def count_of(session: AsyncSession, statement: Select[Any]) -> int:
|
||||
"""Zählt die Treffer einer Abfrage ohne Sortierung und Seitenbegrenzung."""
|
||||
subquery = statement.order_by(None).options().subquery()
|
||||
return (await session.execute(select(func.count()).select_from(subquery))).scalar_one()
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Firmenstammdaten: Normalisierung und Suche."""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Merchant
|
||||
|
||||
# Umlaute und ß werden ausgeschrieben, damit "Müller" und "Mueller" zusammenfallen.
|
||||
_TRANSLITERATIONS = {
|
||||
"ä": "ae",
|
||||
"ö": "oe",
|
||||
"ü": "ue",
|
||||
"ß": "ss",
|
||||
"æ": "ae",
|
||||
"ø": "oe",
|
||||
"å": "aa",
|
||||
}
|
||||
|
||||
# Rechtsformen und Zusätze, die für die Wiedererkennung einer Marke irrelevant sind.
|
||||
_LEGAL_SUFFIXES = (
|
||||
"gmbh co kg",
|
||||
"gmbh",
|
||||
"ag",
|
||||
"kg",
|
||||
"ohg",
|
||||
"ug",
|
||||
"se",
|
||||
"ev",
|
||||
"mbh",
|
||||
"inc",
|
||||
"llc",
|
||||
"ltd",
|
||||
"plc",
|
||||
"sa",
|
||||
"bv",
|
||||
"nv",
|
||||
)
|
||||
|
||||
|
||||
def normalize_name(name: str) -> str:
|
||||
"""Kleinschreibung ohne Sonderzeichen – Grundlage für Eindeutigkeit und Logosuche."""
|
||||
text = name.strip().lower()
|
||||
for source, target in _TRANSLITERATIONS.items():
|
||||
text = text.replace(source, target)
|
||||
# Verbleibende Akzente auflösen (é -> e).
|
||||
text = unicodedata.normalize("NFKD", text)
|
||||
text = "".join(char for char in text if not unicodedata.combining(char))
|
||||
# Punkte fallen ersatzlos weg, damit "B.V." und "e.V." als ein Wort erkannt werden.
|
||||
text = text.replace(".", "")
|
||||
text = re.sub(r"[^a-z0-9]+", " ", text).strip()
|
||||
|
||||
for suffix in _LEGAL_SUFFIXES:
|
||||
if text.endswith(f" {suffix}"):
|
||||
text = text[: -len(suffix) - 1].strip()
|
||||
break
|
||||
|
||||
return re.sub(r"\s+", "", text)
|
||||
|
||||
|
||||
def domain_from_name(name: str) -> str | None:
|
||||
"""Rät eine Domain aus dem Firmennamen – nur als letzter Anhaltspunkt gedacht."""
|
||||
slug = normalize_name(name)
|
||||
if not slug or len(slug) < 3:
|
||||
return None
|
||||
return f"{slug}.com"
|
||||
|
||||
|
||||
async def find_by_normalized_name(session: AsyncSession, name: str) -> Merchant | None:
|
||||
stmt = select(Merchant).where(Merchant.normalized_name == normalize_name(name))
|
||||
return (await session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
|
||||
def search_statement(query: str | None):
|
||||
"""Abfrage über Name, normalisierten Namen und Domain."""
|
||||
stmt = select(Merchant).order_by(Merchant.name)
|
||||
if query:
|
||||
pattern = f"%{query.strip().lower()}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Merchant.normalized_name.like(f"%{normalize_name(query)}%"),
|
||||
Merchant.name.ilike(pattern),
|
||||
Merchant.domain.ilike(pattern),
|
||||
)
|
||||
)
|
||||
return stmt
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Fälligkeiten: virtuelle Expansion über alle Posten sowie Bestätigen und Auslassen."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.errors import NotFoundError, ValidationError
|
||||
from app.models import Occurrence, Recurrence
|
||||
from app.models.enums import EntryKind, OccurrenceStatus
|
||||
from app.services.recurrence import (
|
||||
PlannedOccurrence,
|
||||
expand,
|
||||
expand_by_due_date,
|
||||
resolve_amount,
|
||||
)
|
||||
|
||||
# Overlays werden mit Puffer geladen, weil Ist-Datum und Soll-Datum auseinanderliegen können.
|
||||
_OVERLAY_PADDING_DAYS = timedelta(days=45)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DueItem:
|
||||
"""Eine Fälligkeit samt der Recurrence, aus der sie stammt."""
|
||||
|
||||
planned: PlannedOccurrence
|
||||
recurrence: Recurrence
|
||||
|
||||
|
||||
async def load_recurrences(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
kind: EntryKind | None = None,
|
||||
only_active: bool = True,
|
||||
category_id: int | None = None,
|
||||
account_id: int | None = None,
|
||||
recurrence_id: int | None = None,
|
||||
) -> list[Recurrence]:
|
||||
"""Lädt Posten samt Preishistorie in einem Rutsch."""
|
||||
stmt = (
|
||||
select(Recurrence)
|
||||
.options(selectinload(Recurrence.amount_versions))
|
||||
.order_by(Recurrence.title)
|
||||
)
|
||||
if only_active:
|
||||
stmt = stmt.where(Recurrence.is_active.is_(True))
|
||||
if kind is not None:
|
||||
stmt = stmt.where(Recurrence.kind == kind)
|
||||
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 recurrence_id is not None:
|
||||
stmt = stmt.where(Recurrence.id == recurrence_id)
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
async def load_overlays(
|
||||
session: AsyncSession,
|
||||
recurrence_ids: Sequence[int],
|
||||
date_from: date,
|
||||
date_to: date,
|
||||
) -> dict[int, list[Occurrence]]:
|
||||
"""Materialisierte Fälligkeiten im Zeitfenster, nach Recurrence gruppiert.
|
||||
|
||||
Das Fenster wird großzügig gewählt, weil Ist-Datum und nominales Datum
|
||||
auseinanderliegen können.
|
||||
"""
|
||||
if not recurrence_ids:
|
||||
return {}
|
||||
stmt = select(Occurrence).where(
|
||||
Occurrence.recurrence_id.in_(recurrence_ids),
|
||||
Occurrence.occurrence_date >= date_from,
|
||||
Occurrence.occurrence_date <= date_to,
|
||||
)
|
||||
grouped: dict[int, list[Occurrence]] = {}
|
||||
for row in (await session.execute(stmt)).scalars():
|
||||
grouped.setdefault(row.recurrence_id, []).append(row)
|
||||
return grouped
|
||||
|
||||
|
||||
async def due_items(
|
||||
session: AsyncSession,
|
||||
date_from: date,
|
||||
date_to: date,
|
||||
*,
|
||||
by_due_date: bool = True,
|
||||
kind: EntryKind | None = None,
|
||||
only_active: bool = True,
|
||||
category_id: int | None = None,
|
||||
account_id: int | None = None,
|
||||
recurrence_id: int | None = None,
|
||||
) -> list[DueItem]:
|
||||
"""Expandiert alle passenden Posten über das Zeitfenster.
|
||||
|
||||
`by_due_date=True` gruppiert nach dem tatsächlichen Zahltag – die richtige
|
||||
Sicht für Kalender und Monatsauswertungen. `False` filtert nach dem nominalen
|
||||
Datum.
|
||||
"""
|
||||
recurrences = await load_recurrences(
|
||||
session,
|
||||
kind=kind,
|
||||
only_active=only_active,
|
||||
category_id=category_id,
|
||||
account_id=account_id,
|
||||
recurrence_id=recurrence_id,
|
||||
)
|
||||
if not recurrences:
|
||||
return []
|
||||
|
||||
# Overlays großzügig laden: eine Zahlung kann Wochen nach dem Soll erfasst werden.
|
||||
padding = _OVERLAY_PADDING_DAYS
|
||||
overlays = await load_overlays(
|
||||
session,
|
||||
[item.id for item in recurrences],
|
||||
date_from - padding,
|
||||
date_to + padding,
|
||||
)
|
||||
|
||||
expander = expand_by_due_date if by_due_date else expand
|
||||
items: list[DueItem] = []
|
||||
for recurrence in recurrences:
|
||||
planned = expander(
|
||||
recurrence,
|
||||
date_from,
|
||||
date_to,
|
||||
amount_versions=recurrence.amount_versions,
|
||||
occurrences=overlays.get(recurrence.id, []),
|
||||
)
|
||||
items.extend(DueItem(planned=entry, recurrence=recurrence) for entry in planned)
|
||||
|
||||
items.sort(key=lambda item: (item.planned.effective_date, item.recurrence.title))
|
||||
return items
|
||||
|
||||
|
||||
async def get_recurrence(session: AsyncSession, recurrence_id: int) -> Recurrence:
|
||||
stmt = (
|
||||
select(Recurrence)
|
||||
.options(selectinload(Recurrence.amount_versions))
|
||||
.where(Recurrence.id == recurrence_id)
|
||||
)
|
||||
recurrence = (await session.execute(stmt)).scalar_one_or_none()
|
||||
if recurrence is None:
|
||||
raise NotFoundError(
|
||||
f"Der wiederkehrende Posten mit der ID {recurrence_id} existiert nicht."
|
||||
)
|
||||
return recurrence
|
||||
|
||||
|
||||
async def _materialise(
|
||||
session: AsyncSession, recurrence: Recurrence, occurrence_date: date
|
||||
) -> Occurrence:
|
||||
"""Legt die Zeile für eine Fälligkeit an oder lädt die vorhandene.
|
||||
|
||||
Es werden nur Termine akzeptiert, die die Wiederholungsregel tatsächlich liefert –
|
||||
das nominale Datum ist der Schlüssel.
|
||||
"""
|
||||
matches = expand(
|
||||
recurrence,
|
||||
occurrence_date,
|
||||
occurrence_date,
|
||||
amount_versions=recurrence.amount_versions,
|
||||
)
|
||||
if not matches:
|
||||
raise ValidationError(
|
||||
f"Zum {occurrence_date.isoformat()} gibt es für '{recurrence.title}' keine Fälligkeit.",
|
||||
code="occurrence_not_due",
|
||||
)
|
||||
|
||||
stmt = select(Occurrence).where(
|
||||
Occurrence.recurrence_id == recurrence.id,
|
||||
Occurrence.occurrence_date == occurrence_date,
|
||||
)
|
||||
existing = (await session.execute(stmt)).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
occurrence = Occurrence(
|
||||
recurrence_id=recurrence.id,
|
||||
occurrence_date=occurrence_date,
|
||||
status=OccurrenceStatus.PLANNED,
|
||||
planned_amount=matches[0].amount,
|
||||
)
|
||||
session.add(occurrence)
|
||||
await session.flush()
|
||||
return occurrence
|
||||
|
||||
|
||||
async def confirm(
|
||||
session: AsyncSession,
|
||||
recurrence_id: int,
|
||||
occurrence_date: date,
|
||||
*,
|
||||
actual_amount: Decimal | None = None,
|
||||
actual_date: date | None = None,
|
||||
account_id: int | None = None,
|
||||
note: str | None = None,
|
||||
) -> Occurrence:
|
||||
"""Bestätigt eine Fälligkeit, wahlweise mit abweichendem Betrag oder Datum."""
|
||||
recurrence = await get_recurrence(session, recurrence_id)
|
||||
occurrence = await _materialise(session, recurrence, occurrence_date)
|
||||
|
||||
occurrence.status = OccurrenceStatus.CONFIRMED
|
||||
occurrence.planned_amount = resolve_amount(
|
||||
recurrence, occurrence_date, recurrence.amount_versions
|
||||
)
|
||||
occurrence.actual_amount = (
|
||||
actual_amount if actual_amount is not None else occurrence.planned_amount
|
||||
)
|
||||
occurrence.actual_date = actual_date
|
||||
if account_id is not None:
|
||||
occurrence.account_id = account_id
|
||||
if note is not None:
|
||||
occurrence.note = note
|
||||
|
||||
await session.flush()
|
||||
return occurrence
|
||||
|
||||
|
||||
async def skip(
|
||||
session: AsyncSession,
|
||||
recurrence_id: int,
|
||||
occurrence_date: date,
|
||||
*,
|
||||
note: str | None = None,
|
||||
) -> Occurrence:
|
||||
"""Markiert eine Fälligkeit als ausgefallen."""
|
||||
recurrence = await get_recurrence(session, recurrence_id)
|
||||
occurrence = await _materialise(session, recurrence, occurrence_date)
|
||||
|
||||
occurrence.status = OccurrenceStatus.SKIPPED
|
||||
occurrence.actual_amount = None
|
||||
occurrence.actual_date = None
|
||||
if note is not None:
|
||||
occurrence.note = note
|
||||
|
||||
await session.flush()
|
||||
return occurrence
|
||||
|
||||
|
||||
async def reset(session: AsyncSession, recurrence_id: int, occurrence_date: date) -> None:
|
||||
"""Nimmt Bestätigung oder Auslassung zurück und entfernt die materialisierte Zeile."""
|
||||
stmt = select(Occurrence).where(
|
||||
Occurrence.recurrence_id == recurrence_id,
|
||||
Occurrence.occurrence_date == occurrence_date,
|
||||
)
|
||||
occurrence = (await session.execute(stmt)).scalar_one_or_none()
|
||||
if occurrence is None:
|
||||
raise NotFoundError(
|
||||
f"Zum {occurrence_date.isoformat()} ist keine abweichende Fälligkeit erfasst."
|
||||
)
|
||||
await session.delete(occurrence)
|
||||
await session.flush()
|
||||
@@ -17,12 +17,12 @@ from datetime import date, datetime, timedelta
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from functools import lru_cache
|
||||
from typing import Protocol, runtime_checkable
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import holidays
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from dateutil.rrule import rrulestr
|
||||
|
||||
from app.core.clock import today
|
||||
from app.models.enums import BusinessDayShift, EntryKind, OccurrenceStatus
|
||||
|
||||
ZERO = Decimal("0.00")
|
||||
@@ -247,7 +247,7 @@ def next_dates(
|
||||
after: date | None = None,
|
||||
) -> list[date]:
|
||||
"""Die nächsten `count` nominalen Termine nach `after` (einschließlich)."""
|
||||
start = after or _today()
|
||||
start = after or today()
|
||||
end = start
|
||||
results: list[date] = []
|
||||
# Fenster schrittweise vergrößern, bis genug Termine gefunden sind.
|
||||
@@ -391,7 +391,7 @@ def installments_remaining(
|
||||
if not total:
|
||||
return None
|
||||
|
||||
reference = as_of or _today()
|
||||
reference = as_of or today()
|
||||
schedule = expand(
|
||||
recurrence,
|
||||
recurrence.dtstart,
|
||||
@@ -437,7 +437,7 @@ def contract_term(recurrence: RecurrenceLike, as_of: date | None = None) -> Cont
|
||||
return None
|
||||
|
||||
start = recurrence.contract_start or recurrence.dtstart
|
||||
reference = as_of or _today()
|
||||
reference = as_of or today()
|
||||
|
||||
term_start = start
|
||||
term_end = start + relativedelta(months=recurrence.contract_min_term_months) - timedelta(days=1)
|
||||
@@ -492,7 +492,7 @@ def annual_burden(
|
||||
Bewusst kein fester Intervallfaktor: eine halbjährliche Zahlung, die im Fenster
|
||||
nur einmal fällt, ergibt auch nur eine Belastung.
|
||||
"""
|
||||
start = as_of or _today()
|
||||
start = as_of or today()
|
||||
end = start + relativedelta(years=1) - timedelta(days=1)
|
||||
schedule = expand(recurrence, start, end, amount_versions=amount_versions)
|
||||
return _sum(item.amount for item in schedule)
|
||||
@@ -518,11 +518,6 @@ def monthly_reserve(
|
||||
# --- Interne Helfer -----------------------------------------------------------
|
||||
|
||||
|
||||
def _today() -> date:
|
||||
"""Heutiges Datum in der fachlichen Zeitzone."""
|
||||
return datetime.now(ZoneInfo("Europe/Berlin")).date()
|
||||
|
||||
|
||||
def _as_datetime(day: date) -> datetime:
|
||||
"""dateutil rechnet intern mit datetime; die Uhrzeit ist fachlich bedeutungslos."""
|
||||
return datetime(day.year, day.month, day.day)
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Auswertungen. In dieser Phase die Monatsübersicht."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.clock import add_months, month_end, month_start
|
||||
from app.models import Category, Transaction
|
||||
from app.models.enums import EntryKind, OccurrenceStatus
|
||||
from app.services.occurrences import due_items, load_recurrences
|
||||
from app.services.recurrence import monthly_reserve
|
||||
|
||||
ZERO = Decimal("0.00")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Totals:
|
||||
"""Einnahmen, Ausgaben und Saldo einer Sicht."""
|
||||
|
||||
income: Decimal = ZERO
|
||||
expenses: Decimal = ZERO
|
||||
|
||||
@property
|
||||
def balance(self) -> Decimal:
|
||||
return self.income - self.expenses
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MonthReport:
|
||||
"""Kennzahlen eines Monats."""
|
||||
|
||||
month: date
|
||||
planned: Totals
|
||||
actual: Totals
|
||||
previous_planned: Totals
|
||||
previous_actual: Totals
|
||||
fixed_costs: Decimal = ZERO
|
||||
variable_costs: Decimal = ZERO
|
||||
reserves: Decimal = ZERO
|
||||
confirmed_count: int = 0
|
||||
open_count: int = 0
|
||||
skipped_count: int = 0
|
||||
categories: dict[int, Decimal] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def available_after_fixed(self) -> Decimal:
|
||||
"""Einkünfte abzüglich Fixkosten und Rücklagen – die große Kennzahl im Dashboard."""
|
||||
return self.planned.income - self.fixed_costs - self.reserves
|
||||
|
||||
@property
|
||||
def income_delta(self) -> Decimal:
|
||||
return self.planned.income - self.previous_planned.income
|
||||
|
||||
@property
|
||||
def expenses_delta(self) -> Decimal:
|
||||
return self.planned.expenses - self.previous_planned.expenses
|
||||
|
||||
@property
|
||||
def balance_delta(self) -> Decimal:
|
||||
return self.planned.balance - self.previous_planned.balance
|
||||
|
||||
|
||||
async def _fixed_cost_map(session: AsyncSession) -> dict[int, bool]:
|
||||
"""Kategorie-ID -> ist Fixkostenkategorie."""
|
||||
rows = await session.execute(select(Category.id, Category.is_fixed_cost))
|
||||
return dict(rows.all())
|
||||
|
||||
|
||||
async def _month_totals(
|
||||
session: AsyncSession, month: date, fixed_costs: dict[int, bool]
|
||||
) -> tuple[Totals, Totals, Decimal, Decimal, dict[int, int], dict[int, Decimal]]:
|
||||
"""Rechnet einen Monat aus Fälligkeiten und Buchungen zusammen."""
|
||||
start = month_start(month)
|
||||
end = month_end(month)
|
||||
|
||||
planned_income = planned_expenses = ZERO
|
||||
actual_income = actual_expenses = ZERO
|
||||
fixed = variable = ZERO
|
||||
counts = {"confirmed": 0, "open": 0, "skipped": 0}
|
||||
per_category: dict[int, Decimal] = {}
|
||||
|
||||
for item in await due_items(session, start, end):
|
||||
planned = item.planned
|
||||
if planned.status is OccurrenceStatus.SKIPPED:
|
||||
counts["skipped"] += 1
|
||||
continue
|
||||
if planned.status is OccurrenceStatus.CONFIRMED:
|
||||
counts["confirmed"] += 1
|
||||
else:
|
||||
counts["open"] += 1
|
||||
|
||||
if planned.kind is EntryKind.INCOME:
|
||||
planned_income += planned.amount
|
||||
if planned.status is OccurrenceStatus.CONFIRMED:
|
||||
actual_income += planned.effective_amount
|
||||
continue
|
||||
|
||||
planned_expenses += planned.amount
|
||||
if planned.status is OccurrenceStatus.CONFIRMED:
|
||||
actual_expenses += planned.effective_amount
|
||||
# Für die Fix/Variabel-Aufteilung zählt der beste bekannte Wert.
|
||||
betrag = planned.effective_amount
|
||||
if fixed_costs.get(item.recurrence.category_id, False):
|
||||
fixed += betrag
|
||||
else:
|
||||
variable += betrag
|
||||
per_category[item.recurrence.category_id] = (
|
||||
per_category.get(item.recurrence.category_id, ZERO) + betrag
|
||||
)
|
||||
|
||||
# Einmalige Buchungen sind immer Ist und zugleich Teil des Plans.
|
||||
stmt = select(Transaction).where(
|
||||
Transaction.booking_date >= start, Transaction.booking_date <= end
|
||||
)
|
||||
for transaction in (await session.execute(stmt)).scalars():
|
||||
if transaction.kind is EntryKind.INCOME:
|
||||
planned_income += transaction.amount
|
||||
actual_income += transaction.amount
|
||||
continue
|
||||
planned_expenses += transaction.amount
|
||||
actual_expenses += transaction.amount
|
||||
if fixed_costs.get(transaction.category_id, False):
|
||||
fixed += transaction.amount
|
||||
else:
|
||||
variable += transaction.amount
|
||||
per_category[transaction.category_id] = (
|
||||
per_category.get(transaction.category_id, ZERO) + transaction.amount
|
||||
)
|
||||
|
||||
return (
|
||||
Totals(income=planned_income, expenses=planned_expenses),
|
||||
Totals(income=actual_income, expenses=actual_expenses),
|
||||
fixed,
|
||||
variable,
|
||||
counts,
|
||||
per_category,
|
||||
)
|
||||
|
||||
|
||||
async def _reserve_total(session: AsyncSession, month: date) -> Decimal:
|
||||
"""Summe der monatlichen Rücklagen aller Posten mit aktivierter Rücklagenbildung."""
|
||||
total = ZERO
|
||||
for recurrence in await load_recurrences(session):
|
||||
if recurrence.reserve_enabled:
|
||||
total += monthly_reserve(
|
||||
recurrence, month_start(month), amount_versions=recurrence.amount_versions
|
||||
)
|
||||
return total
|
||||
|
||||
|
||||
async def month_report(session: AsyncSession, month: date) -> MonthReport:
|
||||
"""Monatsübersicht inklusive Vergleich zum Vormonat."""
|
||||
fixed_costs = await _fixed_cost_map(session)
|
||||
current = month_start(month)
|
||||
previous = add_months(current, -1)
|
||||
|
||||
planned, actual, fixed, variable, counts, per_category = await _month_totals(
|
||||
session, current, fixed_costs
|
||||
)
|
||||
previous_planned, previous_actual, *_ = await _month_totals(session, previous, fixed_costs)
|
||||
|
||||
return MonthReport(
|
||||
month=current,
|
||||
planned=planned,
|
||||
actual=actual,
|
||||
previous_planned=previous_planned,
|
||||
previous_actual=previous_actual,
|
||||
fixed_costs=fixed,
|
||||
variable_costs=variable,
|
||||
reserves=await _reserve_total(session, current),
|
||||
confirmed_count=counts["confirmed"],
|
||||
open_count=counts["open"],
|
||||
skipped_count=counts["skipped"],
|
||||
categories=per_category,
|
||||
)
|
||||
@@ -10,13 +10,16 @@ os.environ.setdefault(
|
||||
"postgresql+asyncpg://moneyfy:moneyfy@127.0.0.1:5432/moneyfy_test",
|
||||
),
|
||||
)
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key")
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-mindestens-32-zeichen-lang")
|
||||
os.environ.setdefault("ENVIRONMENT", "test")
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
@@ -24,9 +27,14 @@ from sqlalchemy.ext.asyncio import (
|
||||
)
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import hash_password
|
||||
from app.db.session import get_session
|
||||
from app.main import app as fastapi_app
|
||||
from app.models import Base
|
||||
from app.models import Account, AppUser, Base, Category
|
||||
from app.models.enums import AccountType
|
||||
from app.services.seed import seed_all
|
||||
|
||||
TEST_PASSWORD = "sicher-genug-123"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@@ -76,3 +84,55 @@ async def client(session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
|
||||
yield http_client
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def user(session: AsyncSession) -> AppUser:
|
||||
"""Ein angemeldefähiger Benutzer mit bekanntem Passwort."""
|
||||
account = AppUser(
|
||||
username="tester",
|
||||
email="tester@example.com",
|
||||
password_hash=hash_password(TEST_PASSWORD),
|
||||
must_change_password=False,
|
||||
)
|
||||
session.add(account)
|
||||
await session.flush()
|
||||
return account
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_client(client: AsyncClient, user: AppUser) -> AsyncClient:
|
||||
"""Bereits angemeldeter Client – die Cookies bleiben am Client hängen."""
|
||||
response = await client.post(
|
||||
"/api/auth/login", json={"username": user.username, "password": TEST_PASSWORD}
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def seeded(session: AsyncSession) -> dict[str, int]:
|
||||
"""Kategoriebaum plus ein Konto – die Grundlage der meisten Integrationstests."""
|
||||
await seed_all(session)
|
||||
|
||||
account = Account(
|
||||
name="Girokonto",
|
||||
type=AccountType.CHECKING,
|
||||
opening_balance=Decimal("1000.00"),
|
||||
opening_balance_date=date(2026, 1, 1),
|
||||
)
|
||||
session.add(account)
|
||||
await session.flush()
|
||||
|
||||
async def category_id(name: str) -> int:
|
||||
stmt = select(Category).where(Category.name == name)
|
||||
return (await session.execute(stmt)).scalars().first().id
|
||||
|
||||
return {
|
||||
"account_id": account.id,
|
||||
"streaming": await category_id("Streaming"),
|
||||
"miete": await category_id("Miete"),
|
||||
"kredite": await category_id("Kredite"),
|
||||
"gehalt": await category_id("Gehalt"),
|
||||
"lebensmittel": await category_id("Lebensmittel"),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Integrationstests der CRUD-Endpunkte."""
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
# --- Konten --------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_konto_lebenszyklus(auth_client: AsyncClient) -> None:
|
||||
angelegt = await auth_client.post(
|
||||
"/api/accounts",
|
||||
json={
|
||||
"name": "Girokonto",
|
||||
"type": "checking",
|
||||
"iban_last4": "4711",
|
||||
"opening_balance": "1500.00",
|
||||
"opening_balance_date": "2026-01-01",
|
||||
},
|
||||
)
|
||||
assert angelegt.status_code == 201
|
||||
konto = angelegt.json()
|
||||
assert konto["opening_balance"] == "1500.00"
|
||||
|
||||
geaendert = await auth_client.patch(
|
||||
f"/api/accounts/{konto['id']}", json={"name": "Gehaltskonto", "sort_order": 3}
|
||||
)
|
||||
assert geaendert.status_code == 200
|
||||
assert geaendert.json()["name"] == "Gehaltskonto"
|
||||
assert geaendert.json()["iban_last4"] == "4711" # unverändert
|
||||
|
||||
liste = (await auth_client.get("/api/accounts")).json()
|
||||
assert [item["name"] for item in liste] == ["Gehaltskonto"]
|
||||
|
||||
geloescht = await auth_client.delete(f"/api/accounts/{konto['id']}")
|
||||
assert geloescht.status_code == 200
|
||||
assert (await auth_client.get(f"/api/accounts/{konto['id']}")).status_code == 404
|
||||
|
||||
|
||||
async def test_doppelter_kontoname_wird_abgewiesen(auth_client: AsyncClient) -> None:
|
||||
payload = {"name": "Girokonto", "opening_balance_date": "2026-01-01"}
|
||||
assert (await auth_client.post("/api/accounts", json=payload)).status_code == 201
|
||||
|
||||
zweites = await auth_client.post("/api/accounts", json=payload)
|
||||
assert zweites.status_code == 409
|
||||
assert zweites.json()["code"] == "conflict"
|
||||
|
||||
|
||||
async def test_unbekanntes_feld_wird_abgewiesen(auth_client: AsyncClient) -> None:
|
||||
antwort = await auth_client.post(
|
||||
"/api/accounts",
|
||||
json={"name": "X", "opening_balance_date": "2026-01-01", "tippfehler": 1},
|
||||
)
|
||||
assert antwort.status_code == 422
|
||||
assert antwort.json()["code"] == "validation_error"
|
||||
|
||||
|
||||
async def test_ungueltige_farbe_wird_abgewiesen(auth_client: AsyncClient) -> None:
|
||||
antwort = await auth_client.post(
|
||||
"/api/accounts",
|
||||
json={"name": "X", "opening_balance_date": "2026-01-01", "color": "blau"},
|
||||
)
|
||||
assert antwort.status_code == 422
|
||||
|
||||
|
||||
# --- Kategorien ----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_kategoriebaum_ist_zweistufig(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
baum = (await auth_client.get("/api/categories")).json()
|
||||
|
||||
wohnen = next(item for item in baum if item["name"] == "Wohnen")
|
||||
assert wohnen["parent_id"] is None
|
||||
assert {kind["name"] for kind in wohnen["children"]} == {
|
||||
"Miete",
|
||||
"Nebenkosten",
|
||||
"Strom",
|
||||
"Internet",
|
||||
"Rundfunkbeitrag",
|
||||
}
|
||||
assert next(k for k in wohnen["children"] if k["name"] == "Miete")["is_fixed_cost"] is True
|
||||
|
||||
|
||||
async def test_dritte_ebene_wird_verweigert(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
antwort = await auth_client.post(
|
||||
"/api/categories",
|
||||
json={"name": "Netflix", "kind": "expense", "parent_id": seeded["streaming"]},
|
||||
)
|
||||
|
||||
assert antwort.status_code == 422
|
||||
assert antwort.json()["code"] == "category_too_deep"
|
||||
|
||||
|
||||
async def test_unterkategorie_erbt_die_richtung(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
baum = (await auth_client.get("/api/categories")).json()
|
||||
wohnen = next(item for item in baum if item["name"] == "Wohnen")
|
||||
|
||||
# Absichtlich die falsche Richtung mitschicken – der Elternknoten gewinnt.
|
||||
antwort = await auth_client.post(
|
||||
"/api/categories",
|
||||
json={"name": "Gartenpflege", "kind": "income", "parent_id": wohnen["id"]},
|
||||
)
|
||||
|
||||
assert antwort.status_code == 201
|
||||
assert antwort.json()["kind"] == "expense"
|
||||
|
||||
|
||||
async def test_verwendete_kategorie_kann_nicht_geloescht_werden(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
await auth_client.post(
|
||||
"/api/recurrences",
|
||||
json={
|
||||
"kind": "expense",
|
||||
"title": "Miete",
|
||||
"category_id": seeded["miete"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "950.00",
|
||||
"rrule": "FREQ=MONTHLY;BYMONTHDAY=1",
|
||||
"dtstart": "2026-01-01",
|
||||
},
|
||||
)
|
||||
|
||||
antwort = await auth_client.delete(f"/api/categories/{seeded['miete']}")
|
||||
assert antwort.status_code == 409
|
||||
assert antwort.json()["code"] == "category_in_use"
|
||||
|
||||
|
||||
# --- Firmen --------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_firma_wird_normalisiert(auth_client: AsyncClient) -> None:
|
||||
antwort = await auth_client.post(
|
||||
"/api/merchants", json={"name": "Netflix International B.V.", "domain": "netflix.com"}
|
||||
)
|
||||
|
||||
assert antwort.status_code == 201
|
||||
firma = antwort.json()
|
||||
assert firma["normalized_name"] == "netflixinternational"
|
||||
assert firma["logo_status"] == "pending"
|
||||
assert firma["logo_asset_id"] is None
|
||||
|
||||
|
||||
async def test_firmensuche(auth_client: AsyncClient) -> None:
|
||||
for name in ["Netflix", "Spotify", "Deutsche Telekom AG"]:
|
||||
await auth_client.post("/api/merchants", json={"name": name})
|
||||
|
||||
treffer = (await auth_client.get("/api/merchants", params={"q": "telekom"})).json()
|
||||
assert treffer["total"] == 1
|
||||
assert treffer["items"][0]["name"] == "Deutsche Telekom AG"
|
||||
|
||||
alle = (await auth_client.get("/api/merchants")).json()
|
||||
assert alle["total"] == 3
|
||||
|
||||
|
||||
async def test_doppelte_firma_wird_abgewiesen(auth_client: AsyncClient) -> None:
|
||||
await auth_client.post("/api/merchants", json={"name": "Netflix"})
|
||||
|
||||
# Andere Schreibweise, gleicher normalisierter Name.
|
||||
antwort = await auth_client.post("/api/merchants", json={"name": "NETFLIX"})
|
||||
assert antwort.status_code == 409
|
||||
|
||||
|
||||
async def test_firma_loeschen_loest_verweise(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
firma = (await auth_client.post("/api/merchants", json={"name": "Rewe"})).json()
|
||||
buchung = (
|
||||
await auth_client.post(
|
||||
"/api/transactions",
|
||||
json={
|
||||
"kind": "expense",
|
||||
"title": "Wocheneinkauf",
|
||||
"category_id": seeded["lebensmittel"],
|
||||
"account_id": seeded["account_id"],
|
||||
"merchant_id": firma["id"],
|
||||
"amount": "84.30",
|
||||
"booking_date": "2026-03-05",
|
||||
},
|
||||
)
|
||||
).json()
|
||||
|
||||
assert (await auth_client.delete(f"/api/merchants/{firma['id']}")).status_code == 200
|
||||
danach = (await auth_client.get(f"/api/transactions/{buchung['id']}")).json()
|
||||
assert danach["merchant_id"] is None
|
||||
|
||||
|
||||
# --- Buchungen -----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_buchung_mit_falscher_kategorierichtung(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
antwort = await auth_client.post(
|
||||
"/api/transactions",
|
||||
json={
|
||||
"kind": "income",
|
||||
"title": "Falsch einsortiert",
|
||||
"category_id": seeded["lebensmittel"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "10.00",
|
||||
"booking_date": "2026-03-01",
|
||||
},
|
||||
)
|
||||
|
||||
assert antwort.status_code == 422
|
||||
assert antwort.json()["code"] == "category_kind_mismatch"
|
||||
|
||||
|
||||
async def test_buchungen_filtern_und_blaettern(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
for tag in range(1, 6):
|
||||
await auth_client.post(
|
||||
"/api/transactions",
|
||||
json={
|
||||
"kind": "expense",
|
||||
"title": f"Einkauf {tag}",
|
||||
"category_id": seeded["lebensmittel"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "20.00",
|
||||
"booking_date": f"2026-03-0{tag}",
|
||||
},
|
||||
)
|
||||
|
||||
seite = (await auth_client.get("/api/transactions", params={"limit": 2, "offset": 0})).json()
|
||||
assert seite["total"] == 5
|
||||
assert len(seite["items"]) == 2
|
||||
# Neueste zuerst.
|
||||
assert seite["items"][0]["booking_date"] == "2026-03-05"
|
||||
|
||||
zeitraum = (
|
||||
await auth_client.get(
|
||||
"/api/transactions", params={"from": "2026-03-02", "to": "2026-03-03"}
|
||||
)
|
||||
).json()
|
||||
assert zeitraum["total"] == 2
|
||||
|
||||
suche = (await auth_client.get("/api/transactions", params={"q": "Einkauf 4"})).json()
|
||||
assert suche["total"] == 1
|
||||
|
||||
|
||||
# --- Budgets und Sparziele -----------------------------------------------------
|
||||
|
||||
|
||||
async def test_budget_wird_auf_den_monatsersten_normalisiert(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
antwort = await auth_client.post(
|
||||
"/api/budgets",
|
||||
json={
|
||||
"category_id": seeded["lebensmittel"],
|
||||
"period_month": "2026-03-17",
|
||||
"limit_amount": "450.00",
|
||||
},
|
||||
)
|
||||
|
||||
assert antwort.status_code == 201
|
||||
assert antwort.json()["period_month"] == "2026-03-01"
|
||||
|
||||
|
||||
async def test_budget_pro_kategorie_und_monat_nur_einmal(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
payload = {
|
||||
"category_id": seeded["lebensmittel"],
|
||||
"period_month": "2026-03-01",
|
||||
"limit_amount": "450.00",
|
||||
}
|
||||
await auth_client.post("/api/budgets", json=payload)
|
||||
|
||||
zweites = await auth_client.post("/api/budgets", json=payload)
|
||||
assert zweites.status_code == 409
|
||||
|
||||
|
||||
async def test_budgetvorlage(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
antwort = await auth_client.post(
|
||||
"/api/budget-templates",
|
||||
json={
|
||||
"category_id": seeded["lebensmittel"],
|
||||
"valid_from": "2026-01-15",
|
||||
"limit_amount": "500.00",
|
||||
},
|
||||
)
|
||||
|
||||
assert antwort.status_code == 201
|
||||
assert antwort.json()["valid_from"] == "2026-01-01"
|
||||
assert antwort.json()["valid_until"] is None
|
||||
|
||||
|
||||
async def test_sparziel_lebenszyklus(auth_client: AsyncClient) -> None:
|
||||
angelegt = await auth_client.post(
|
||||
"/api/savings-goals",
|
||||
json={"name": "Neues Fahrrad", "target_amount": "1800.00", "target_date": "2027-04-01"},
|
||||
)
|
||||
assert angelegt.status_code == 201
|
||||
ziel = angelegt.json()
|
||||
assert ziel["current_amount"] == "0.00"
|
||||
|
||||
geaendert = await auth_client.patch(
|
||||
f"/api/savings-goals/{ziel['id']}", json={"current_amount": "450.00"}
|
||||
)
|
||||
assert geaendert.json()["current_amount"] == "450.00"
|
||||
|
||||
await auth_client.patch(f"/api/savings-goals/{ziel['id']}", json={"is_archived": True})
|
||||
assert (await auth_client.get("/api/savings-goals")).json() == []
|
||||
assert (
|
||||
len((await auth_client.get("/api/savings-goals", params={"include_archived": True})).json())
|
||||
== 1
|
||||
)
|
||||
@@ -0,0 +1,340 @@
|
||||
"""Integrationstests der Fälligkeiten-Overlays und der Saldoberechnung."""
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
async def abo(client: AsyncClient, seeded: dict, **overrides) -> dict:
|
||||
payload = {
|
||||
"kind": "expense",
|
||||
"title": "Netflix",
|
||||
"category_id": seeded["streaming"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "13.99",
|
||||
"rrule": "FREQ=MONTHLY;BYMONTHDAY=15",
|
||||
"dtstart": "2026-01-15",
|
||||
"business_day_shift": "none",
|
||||
}
|
||||
payload.update(overrides)
|
||||
antwort = await client.post("/api/recurrences", json=payload)
|
||||
assert antwort.status_code == 201, antwort.text
|
||||
return antwort.json()
|
||||
|
||||
|
||||
async def test_bestaetigen_ohne_betrag_uebernimmt_das_soll(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
posten = await abo(auth_client, seeded)
|
||||
|
||||
body = (
|
||||
await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={"recurrence_id": posten["id"], "occurrence_date": "2026-02-15"},
|
||||
)
|
||||
).json()
|
||||
|
||||
assert body["actual_amount"] == "13.99"
|
||||
assert body["effective_amount"] == "13.99"
|
||||
assert body["status"] == "confirmed"
|
||||
|
||||
|
||||
async def test_auslassen_nimmt_die_faelligkeit_aus_der_rechnung(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
posten = await abo(auth_client, seeded)
|
||||
|
||||
body = (
|
||||
await auth_client.post(
|
||||
"/api/occurrences/skip",
|
||||
json={
|
||||
"recurrence_id": posten["id"],
|
||||
"occurrence_date": "2026-02-15",
|
||||
"note": "Monat geschenkt",
|
||||
},
|
||||
)
|
||||
).json()
|
||||
assert body["status"] == "skipped"
|
||||
assert body["effective_amount"] == "0.00"
|
||||
|
||||
report = (await auth_client.get("/api/reports/month", params={"month": "2026-02-01"})).json()
|
||||
assert report["planned"]["expenses"] == "0.00"
|
||||
assert report["skipped_count"] == 1
|
||||
|
||||
|
||||
async def test_zuruecksetzen_entfernt_die_abweichung(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
posten = await abo(auth_client, seeded)
|
||||
await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={
|
||||
"recurrence_id": posten["id"],
|
||||
"occurrence_date": "2026-02-15",
|
||||
"actual_amount": "20.00",
|
||||
},
|
||||
)
|
||||
|
||||
zurueck = (
|
||||
await auth_client.post(
|
||||
"/api/occurrences/reset",
|
||||
json={"recurrence_id": posten["id"], "occurrence_date": "2026-02-15"},
|
||||
)
|
||||
).json()
|
||||
|
||||
assert zurueck["status"] == "planned"
|
||||
assert zurueck["actual_amount"] is None
|
||||
assert zurueck["occurrence_id"] is None
|
||||
|
||||
|
||||
async def test_bestaetigung_an_einem_termin_ohne_faelligkeit(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
posten = await abo(auth_client, seeded)
|
||||
|
||||
antwort = await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={"recurrence_id": posten["id"], "occurrence_date": "2026-02-16"},
|
||||
)
|
||||
|
||||
assert antwort.status_code == 422
|
||||
assert antwort.json()["code"] == "occurrence_not_due"
|
||||
|
||||
|
||||
async def test_nominales_datum_bleibt_schluessel_trotz_verschiebung(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
"""Der 01.02.2026 ist ein Sonntag; der Zahltag rutscht auf den 02.02."""
|
||||
posten = await abo(
|
||||
auth_client,
|
||||
seeded,
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
|
||||
dtstart="2026-02-01",
|
||||
business_day_shift="next",
|
||||
)
|
||||
|
||||
faelligkeiten = (
|
||||
await auth_client.get("/api/occurrences", params={"from": "2026-02-01", "to": "2026-02-28"})
|
||||
).json()
|
||||
assert faelligkeiten[0]["nominal_date"] == "2026-02-01"
|
||||
assert faelligkeiten[0]["due_date"] == "2026-02-02"
|
||||
|
||||
# Bestätigt wird über das nominale Datum.
|
||||
bestaetigt = await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={"recurrence_id": posten["id"], "occurrence_date": "2026-02-01"},
|
||||
)
|
||||
assert bestaetigt.status_code == 200
|
||||
assert bestaetigt.json()["due_date"] == "2026-02-02"
|
||||
|
||||
|
||||
async def test_verschobene_faelligkeit_zaehlt_im_zahlmonat(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
"""Der 31.05.2026 ist ein Sonntag – die Zahlung gehört in den Juni."""
|
||||
await abo(
|
||||
auth_client,
|
||||
seeded,
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=-1",
|
||||
dtstart="2026-01-31",
|
||||
business_day_shift="next",
|
||||
)
|
||||
|
||||
mai = (
|
||||
await auth_client.get("/api/occurrences", params={"from": "2026-05-01", "to": "2026-05-31"})
|
||||
).json()
|
||||
juni = (
|
||||
await auth_client.get("/api/occurrences", params={"from": "2026-06-01", "to": "2026-06-30"})
|
||||
).json()
|
||||
|
||||
assert [item["nominal_date"] for item in mai] == []
|
||||
assert [item["nominal_date"] for item in juni] == ["2026-05-31", "2026-06-30"]
|
||||
|
||||
# Nach nominalem Datum gefiltert sieht es anders aus.
|
||||
nominal = (
|
||||
await auth_client.get(
|
||||
"/api/occurrences",
|
||||
params={"from": "2026-05-01", "to": "2026-05-31", "by_due_date": False},
|
||||
)
|
||||
).json()
|
||||
assert [item["nominal_date"] for item in nominal] == ["2026-05-31"]
|
||||
|
||||
|
||||
async def test_faelligkeiten_lassen_sich_filtern(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
await abo(auth_client, seeded)
|
||||
await abo(
|
||||
auth_client,
|
||||
seeded,
|
||||
title="Gehalt",
|
||||
kind="income",
|
||||
category_id=seeded["gehalt"],
|
||||
amount="3000.00",
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=28",
|
||||
dtstart="2026-01-28",
|
||||
)
|
||||
|
||||
einkuenfte = (
|
||||
await auth_client.get(
|
||||
"/api/occurrences",
|
||||
params={"from": "2026-02-01", "to": "2026-02-28", "kind": "income"},
|
||||
)
|
||||
).json()
|
||||
assert [item["recurrence_title"] for item in einkuenfte] == ["Gehalt"]
|
||||
|
||||
alle = (
|
||||
await auth_client.get("/api/occurrences", params={"from": "2026-02-01", "to": "2026-02-28"})
|
||||
).json()
|
||||
assert len(alle) == 2
|
||||
# Sortiert nach tatsächlichem Zahltag.
|
||||
assert [item["effective_date"] for item in alle] == ["2026-02-15", "2026-02-28"]
|
||||
|
||||
|
||||
async def test_deaktivierte_posten_erscheinen_nicht(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
posten = await abo(auth_client, seeded)
|
||||
await auth_client.patch(f"/api/recurrences/{posten['id']}", json={"is_active": False})
|
||||
|
||||
ohne = (
|
||||
await auth_client.get("/api/occurrences", params={"from": "2026-02-01", "to": "2026-02-28"})
|
||||
).json()
|
||||
mit = (
|
||||
await auth_client.get(
|
||||
"/api/occurrences",
|
||||
params={"from": "2026-02-01", "to": "2026-02-28", "include_inactive": True},
|
||||
)
|
||||
).json()
|
||||
|
||||
assert ohne == []
|
||||
assert len(mit) == 1
|
||||
|
||||
|
||||
# --- Kontosalden ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_saldo_beruecksichtigt_buchungen_und_bestaetigungen(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
konto = seeded["account_id"]
|
||||
posten = await abo(auth_client, seeded)
|
||||
|
||||
await auth_client.post(
|
||||
"/api/transactions",
|
||||
json={
|
||||
"kind": "expense",
|
||||
"title": "Wocheneinkauf",
|
||||
"category_id": seeded["lebensmittel"],
|
||||
"account_id": konto,
|
||||
"amount": "84.30",
|
||||
"booking_date": "2026-02-05",
|
||||
},
|
||||
)
|
||||
await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={
|
||||
"recurrence_id": posten["id"],
|
||||
"occurrence_date": "2026-02-15",
|
||||
"actual_amount": "13.99",
|
||||
},
|
||||
)
|
||||
|
||||
saldo = (
|
||||
await auth_client.get(f"/api/accounts/{konto}/balance", params={"as_of": "2026-02-28"})
|
||||
).json()
|
||||
|
||||
assert saldo["opening_balance"] == "1000.00"
|
||||
assert saldo["booked_transactions"] == "-84.30"
|
||||
assert saldo["booked_occurrences"] == "-13.99"
|
||||
assert saldo["balance"] == "901.71"
|
||||
|
||||
|
||||
async def test_saldo_zaehlt_nur_bis_zum_stichtag(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
konto = seeded["account_id"]
|
||||
posten = await abo(auth_client, seeded)
|
||||
await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={"recurrence_id": posten["id"], "occurrence_date": "2026-02-15"},
|
||||
)
|
||||
|
||||
davor = (
|
||||
await auth_client.get(f"/api/accounts/{konto}/balance", params={"as_of": "2026-02-14"})
|
||||
).json()
|
||||
danach = (
|
||||
await auth_client.get(f"/api/accounts/{konto}/balance", params={"as_of": "2026-02-15"})
|
||||
).json()
|
||||
|
||||
assert davor["balance"] == "1000.00"
|
||||
assert danach["balance"] == "986.01"
|
||||
|
||||
|
||||
async def test_saldo_folgt_dem_istdatum(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
"""Wird eine Zahlung später erfasst, zählt sie erst ab dem Ist-Datum."""
|
||||
konto = seeded["account_id"]
|
||||
posten = await abo(auth_client, seeded)
|
||||
await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={
|
||||
"recurrence_id": posten["id"],
|
||||
"occurrence_date": "2026-02-15",
|
||||
"actual_date": "2026-03-02",
|
||||
},
|
||||
)
|
||||
|
||||
februar = (
|
||||
await auth_client.get(f"/api/accounts/{konto}/balance", params={"as_of": "2026-02-28"})
|
||||
).json()
|
||||
maerz = (
|
||||
await auth_client.get(f"/api/accounts/{konto}/balance", params={"as_of": "2026-03-31"})
|
||||
).json()
|
||||
|
||||
assert februar["balance"] == "1000.00"
|
||||
assert maerz["balance"] == "986.01"
|
||||
|
||||
|
||||
async def test_geplante_faelligkeiten_veraendern_den_saldo_nicht(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
konto = seeded["account_id"]
|
||||
await abo(auth_client, seeded)
|
||||
|
||||
saldo = (
|
||||
await auth_client.get(f"/api/accounts/{konto}/balance", params={"as_of": "2026-12-31"})
|
||||
).json()
|
||||
|
||||
assert saldo["balance"] == "1000.00"
|
||||
|
||||
|
||||
async def test_abweichendes_konto_bei_der_bestaetigung(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
zweitkonto = (
|
||||
await auth_client.post(
|
||||
"/api/accounts",
|
||||
json={
|
||||
"name": "Kreditkarte",
|
||||
"type": "credit_card",
|
||||
"opening_balance": "0.00",
|
||||
"opening_balance_date": "2026-01-01",
|
||||
},
|
||||
)
|
||||
).json()
|
||||
posten = await abo(auth_client, seeded)
|
||||
|
||||
await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={
|
||||
"recurrence_id": posten["id"],
|
||||
"occurrence_date": "2026-02-15",
|
||||
"account_id": zweitkonto["id"],
|
||||
},
|
||||
)
|
||||
|
||||
giro = (
|
||||
await auth_client.get(
|
||||
f"/api/accounts/{seeded['account_id']}/balance", params={"as_of": "2026-12-31"}
|
||||
)
|
||||
).json()
|
||||
karte = (
|
||||
await auth_client.get(
|
||||
f"/api/accounts/{zweitkonto['id']}/balance", params={"as_of": "2026-12-31"}
|
||||
)
|
||||
).json()
|
||||
|
||||
assert giro["balance"] == "1000.00"
|
||||
assert karte["balance"] == "-13.99"
|
||||
@@ -0,0 +1,349 @@
|
||||
"""Integrationstests: Posten anlegen, Fälligkeiten abrufen, bestätigen, Monatsreport."""
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
async def anlegen(client: AsyncClient, seeded: dict, **overrides) -> dict:
|
||||
"""Legt ein Abo an; einzelne Felder lassen sich überschreiben."""
|
||||
payload = {
|
||||
"kind": "expense",
|
||||
"title": "Netflix Standard",
|
||||
"category_id": seeded["streaming"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "13.99",
|
||||
"rrule": "FREQ=MONTHLY;BYMONTHDAY=15",
|
||||
"dtstart": "2026-01-15",
|
||||
"business_day_shift": "none",
|
||||
}
|
||||
payload.update(overrides)
|
||||
antwort = await client.post("/api/recurrences", json=payload)
|
||||
assert antwort.status_code == 201, antwort.text
|
||||
return antwort.json()
|
||||
|
||||
|
||||
# --- Der geforderte Durchstich -------------------------------------------------
|
||||
|
||||
|
||||
async def test_durchstich_anlegen_abrufen_bestaetigen_report(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
"""Recurrence anlegen → Fälligkeiten abrufen → mit abweichendem Betrag
|
||||
bestätigen → der Monatsreport weist das Ist aus."""
|
||||
posten = await anlegen(auth_client, seeded)
|
||||
assert posten["amount"] == "13.99"
|
||||
assert len(posten["amount_versions"]) == 1
|
||||
assert posten["annual_burden"] == "167.88" # zwölf Monate à 13,99
|
||||
|
||||
# Fälligkeiten des ersten Quartals abrufen.
|
||||
faelligkeiten = (
|
||||
await auth_client.get("/api/occurrences", params={"from": "2026-01-01", "to": "2026-03-31"})
|
||||
).json()
|
||||
assert [item["nominal_date"] for item in faelligkeiten] == [
|
||||
"2026-01-15",
|
||||
"2026-02-15",
|
||||
"2026-03-15",
|
||||
]
|
||||
assert all(item["status"] == "planned" for item in faelligkeiten)
|
||||
assert all(item["occurrence_id"] is None for item in faelligkeiten)
|
||||
|
||||
# Februar mit abweichendem Betrag und Datum bestätigen.
|
||||
bestaetigt = await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={
|
||||
"recurrence_id": posten["id"],
|
||||
"occurrence_date": "2026-02-15",
|
||||
"actual_amount": "15.49",
|
||||
"actual_date": "2026-02-17",
|
||||
"note": "Preis erhöht",
|
||||
},
|
||||
)
|
||||
assert bestaetigt.status_code == 200
|
||||
body = bestaetigt.json()
|
||||
assert body["status"] == "confirmed"
|
||||
assert body["amount"] == "13.99" # Soll bleibt stehen
|
||||
assert body["actual_amount"] == "15.49"
|
||||
assert body["effective_amount"] == "15.49"
|
||||
assert body["effective_date"] == "2026-02-17"
|
||||
assert body["occurrence_id"] is not None
|
||||
|
||||
# Der Monatsreport weist Soll und Ist getrennt aus.
|
||||
report = (await auth_client.get("/api/reports/month", params={"month": "2026-02-01"})).json()
|
||||
assert report["month"] == "2026-02-01"
|
||||
assert report["planned"]["expenses"] == "13.99"
|
||||
assert report["actual"]["expenses"] == "15.49"
|
||||
assert report["confirmed_count"] == 1
|
||||
assert report["open_count"] == 0
|
||||
|
||||
# Im Januar ist nichts bestätigt – dort bleibt das Ist leer.
|
||||
januar = (await auth_client.get("/api/reports/month", params={"month": "2026-01-01"})).json()
|
||||
assert januar["planned"]["expenses"] == "13.99"
|
||||
assert januar["actual"]["expenses"] == "0.00"
|
||||
assert januar["open_count"] == 1
|
||||
|
||||
|
||||
async def test_report_verrechnet_einkuenfte_und_fixkosten(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
await anlegen(
|
||||
auth_client,
|
||||
seeded,
|
||||
title="Gehalt",
|
||||
kind="income",
|
||||
category_id=seeded["gehalt"],
|
||||
amount="3200.00",
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=28",
|
||||
dtstart="2026-01-28",
|
||||
)
|
||||
await anlegen(
|
||||
auth_client,
|
||||
seeded,
|
||||
title="Miete",
|
||||
category_id=seeded["miete"],
|
||||
amount="950.00",
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
|
||||
dtstart="2026-01-01",
|
||||
)
|
||||
await anlegen(auth_client, seeded) # Netflix – Abos zählen laut Seed als Fixkosten
|
||||
await anlegen(
|
||||
auth_client,
|
||||
seeded,
|
||||
title="Wocheneinkauf",
|
||||
category_id=seeded["lebensmittel"],
|
||||
amount="120.00",
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=5",
|
||||
dtstart="2026-01-05",
|
||||
is_variable=True,
|
||||
)
|
||||
|
||||
report = (await auth_client.get("/api/reports/month", params={"month": "2026-03-10"})).json()
|
||||
|
||||
assert report["planned"]["income"] == "3200.00"
|
||||
assert report["planned"]["expenses"] == "1083.99"
|
||||
assert report["planned"]["balance"] == "2116.01"
|
||||
# Miete und Netflix sind Fixkostenkategorien, Lebensmittel nicht.
|
||||
assert report["fixed_costs"] == "963.99"
|
||||
assert report["variable_costs"] == "120.00"
|
||||
assert report["available_after_fixed"] == "2236.01"
|
||||
|
||||
|
||||
async def test_report_vergleicht_mit_dem_vormonat(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
posten = await anlegen(auth_client, seeded, amount="10.00")
|
||||
await auth_client.post(
|
||||
f"/api/recurrences/{posten['id']}/amount-versions",
|
||||
json={"amount": "20.00", "valid_from": "2026-03-01"},
|
||||
)
|
||||
|
||||
report = (await auth_client.get("/api/reports/month", params={"month": "2026-03-01"})).json()
|
||||
|
||||
assert report["planned"]["expenses"] == "20.00"
|
||||
assert report["previous_planned"]["expenses"] == "10.00"
|
||||
assert report["delta_to_previous"]["expenses"] == "10.00"
|
||||
assert report["delta_to_previous"]["balance"] == "-10.00"
|
||||
|
||||
|
||||
# --- Anlegen und Prüfen --------------------------------------------------------
|
||||
|
||||
|
||||
async def test_ungueltige_rrule_wird_abgewiesen(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
antwort = await auth_client.post(
|
||||
"/api/recurrences",
|
||||
json={
|
||||
"kind": "expense",
|
||||
"title": "Kaputt",
|
||||
"category_id": seeded["streaming"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "5.00",
|
||||
"rrule": "FREQ=QUARTERLY",
|
||||
"dtstart": "2026-01-01",
|
||||
},
|
||||
)
|
||||
|
||||
assert antwort.status_code == 422
|
||||
assert antwort.json()["code"] == "validation_error"
|
||||
|
||||
|
||||
async def test_dtstart_in_der_rrule_wird_abgewiesen(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
antwort = await auth_client.post(
|
||||
"/api/recurrences",
|
||||
json={
|
||||
"kind": "expense",
|
||||
"title": "Mit DTSTART",
|
||||
"category_id": seeded["streaming"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "5.00",
|
||||
"rrule": "DTSTART=20260101;FREQ=MONTHLY",
|
||||
"dtstart": "2026-01-01",
|
||||
},
|
||||
)
|
||||
assert antwort.status_code == 422
|
||||
|
||||
|
||||
async def test_falsche_kategorierichtung_wird_abgewiesen(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
antwort = await auth_client.post(
|
||||
"/api/recurrences",
|
||||
json={
|
||||
"kind": "income",
|
||||
"title": "Gehalt in Ausgabenkategorie",
|
||||
"category_id": seeded["streaming"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "3000.00",
|
||||
"rrule": "FREQ=MONTHLY",
|
||||
"dtstart": "2026-01-01",
|
||||
},
|
||||
)
|
||||
|
||||
assert antwort.status_code == 422
|
||||
assert antwort.json()["code"] == "category_kind_mismatch"
|
||||
|
||||
|
||||
async def test_negativer_betrag_wird_abgewiesen(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
antwort = await auth_client.post(
|
||||
"/api/recurrences",
|
||||
json={
|
||||
"kind": "expense",
|
||||
"title": "Negativ",
|
||||
"category_id": seeded["streaming"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "-5.00",
|
||||
"rrule": "FREQ=MONTHLY",
|
||||
"dtstart": "2026-01-01",
|
||||
},
|
||||
)
|
||||
assert antwort.status_code == 422
|
||||
|
||||
|
||||
# --- Preisversionen ------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_preisversion_wirkt_ab_dem_stichtag(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
posten = await anlegen(auth_client, seeded, amount="9.99")
|
||||
|
||||
await auth_client.post(
|
||||
f"/api/recurrences/{posten['id']}/amount-versions",
|
||||
json={"amount": "13.99", "valid_from": "2026-04-01", "note": "Preiserhöhung"},
|
||||
)
|
||||
|
||||
vorschau = (
|
||||
await auth_client.get(
|
||||
f"/api/recurrences/{posten['id']}/preview",
|
||||
params={"from": "2026-01-01", "to": "2026-06-30"},
|
||||
)
|
||||
).json()
|
||||
|
||||
betraege = {item["nominal_date"]: item["amount"] for item in vorschau}
|
||||
assert betraege["2026-03-15"] == "9.99"
|
||||
assert betraege["2026-04-15"] == "13.99"
|
||||
|
||||
# Der Basisbetrag folgt der jüngsten Version.
|
||||
detail = (await auth_client.get(f"/api/recurrences/{posten['id']}")).json()
|
||||
assert detail["amount"] == "13.99"
|
||||
assert len(detail["amount_versions"]) == 2
|
||||
|
||||
|
||||
async def test_doppelte_preisversion_wird_abgewiesen(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
posten = await anlegen(auth_client, seeded)
|
||||
|
||||
antwort = await auth_client.post(
|
||||
f"/api/recurrences/{posten['id']}/amount-versions",
|
||||
json={"amount": "20.00", "valid_from": "2026-01-15"},
|
||||
)
|
||||
assert antwort.status_code == 409
|
||||
|
||||
|
||||
# --- Vorschau, Raten, Verträge -------------------------------------------------
|
||||
|
||||
|
||||
async def test_vorschau_liefert_die_naechsten_termine(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
posten = await anlegen(auth_client, seeded)
|
||||
|
||||
vorschau = (
|
||||
await auth_client.get(
|
||||
f"/api/recurrences/{posten['id']}/preview",
|
||||
params={"from": "2026-01-01", "to": "2026-03-31"},
|
||||
)
|
||||
).json()
|
||||
|
||||
assert [item["nominal_date"] for item in vorschau] == [
|
||||
"2026-01-15",
|
||||
"2026-02-15",
|
||||
"2026-03-15",
|
||||
]
|
||||
assert vorschau[0]["recurrence_title"] == "Netflix Standard"
|
||||
|
||||
|
||||
async def test_kredit_zeigt_restschuld_und_raten(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
posten = await anlegen(
|
||||
auth_client,
|
||||
seeded,
|
||||
title="Autokredit",
|
||||
category_id=seeded["kredite"],
|
||||
amount="250.00",
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
|
||||
dtstart="2026-01-01",
|
||||
installments_total=36,
|
||||
principal_amount="9000.00",
|
||||
)
|
||||
|
||||
assert posten["installments"]["total"] == 36
|
||||
assert posten["installments"]["final_due_date"] == "2028-12-01"
|
||||
|
||||
vorschau = (
|
||||
await auth_client.get(
|
||||
f"/api/recurrences/{posten['id']}/preview",
|
||||
params={"from": "2026-01-01", "to": "2032-12-31"},
|
||||
)
|
||||
).json()
|
||||
assert len(vorschau) == 36
|
||||
assert vorschau[0]["installment_number"] == 1
|
||||
assert vorschau[-1]["installment_number"] == 36
|
||||
|
||||
|
||||
async def test_vertrag_kuendigen_beendet_die_serie(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
posten = await anlegen(
|
||||
auth_client,
|
||||
seeded,
|
||||
title="Handyvertrag",
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
|
||||
dtstart="2026-03-01",
|
||||
contract_start="2026-03-01",
|
||||
contract_min_term_months=24,
|
||||
contract_notice_period_days=90,
|
||||
contract_auto_renew_months=12,
|
||||
)
|
||||
|
||||
assert posten["contract_term"]["term_end"] == "2028-02-29"
|
||||
assert posten["contract_term"]["notice_deadline"] == "2027-12-01"
|
||||
assert posten["contract_term"]["renews_on"] == "2028-03-01"
|
||||
|
||||
gekuendigt = (await auth_client.post(f"/api/recurrences/{posten['id']}/cancel")).json()
|
||||
assert gekuendigt["contract_cancelled_at"] == "2028-02-29"
|
||||
assert gekuendigt["contract_term"]["is_cancelled"] is True
|
||||
|
||||
vorschau = (
|
||||
await auth_client.get(
|
||||
f"/api/recurrences/{posten['id']}/preview",
|
||||
params={"from": "2026-01-01", "to": "2030-12-31"},
|
||||
)
|
||||
).json()
|
||||
assert vorschau[-1]["nominal_date"] == "2028-02-01"
|
||||
|
||||
|
||||
async def test_ruecklage_wird_ausgewiesen(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
posten = await anlegen(
|
||||
auth_client,
|
||||
seeded,
|
||||
title="Kfz-Versicherung",
|
||||
amount="612.00",
|
||||
rrule="FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=15",
|
||||
dtstart="2026-01-15",
|
||||
reserve_enabled=True,
|
||||
)
|
||||
|
||||
assert posten["monthly_reserve"] == "51.00"
|
||||
assert posten["annual_burden"] == "612.00"
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Tests der Anmeldung, Token-Rotation und Zugriffsbeschränkung."""
|
||||
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.cookies import ACCESS_COOKIE, REFRESH_COOKIE
|
||||
from app.core.security import hash_password
|
||||
from app.models import AppUser, RefreshToken
|
||||
from tests.conftest import TEST_PASSWORD
|
||||
|
||||
|
||||
async def test_anmeldung_setzt_beide_cookies(client: AsyncClient, user: AppUser) -> None:
|
||||
response = await client.post(
|
||||
"/api/auth/login", json={"username": "tester", "password": TEST_PASSWORD}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["username"] == "tester"
|
||||
assert ACCESS_COOKIE in response.cookies
|
||||
assert REFRESH_COOKIE in response.cookies
|
||||
|
||||
# Beide Cookies sind httpOnly und auf SameSite=Lax gesetzt.
|
||||
header = "; ".join(response.headers.get_list("set-cookie"))
|
||||
assert header.count("HttpOnly") == 2
|
||||
assert header.count("SameSite=lax") == 2
|
||||
|
||||
|
||||
async def test_anmeldung_ist_unabhaengig_von_der_gross_schreibung(
|
||||
client: AsyncClient, user: AppUser
|
||||
) -> None:
|
||||
response = await client.post(
|
||||
"/api/auth/login", json={"username": "TESTER", "password": TEST_PASSWORD}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def test_falsches_passwort_wird_abgewiesen(client: AsyncClient, user: AppUser) -> None:
|
||||
response = await client.post(
|
||||
"/api/auth/login", json={"username": "tester", "password": "falsch"}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["code"] == "invalid_credentials"
|
||||
assert ACCESS_COOKIE not in response.cookies
|
||||
|
||||
|
||||
async def test_unbekannter_benutzer_erhaelt_dieselbe_meldung(client: AsyncClient) -> None:
|
||||
response = await client.post(
|
||||
"/api/auth/login", json={"username": "gibtesnicht", "password": "egal"}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["code"] == "invalid_credentials"
|
||||
|
||||
|
||||
async def test_geschuetzte_route_ohne_anmeldung(client: AsyncClient) -> None:
|
||||
response = await client.get("/api/accounts")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["code"] == "not_authenticated"
|
||||
|
||||
|
||||
async def test_geschuetzte_route_mit_anmeldung(auth_client: AsyncClient) -> None:
|
||||
assert (await auth_client.get("/api/accounts")).status_code == 200
|
||||
|
||||
|
||||
async def test_me_liefert_den_angemeldeten_benutzer(auth_client: AsyncClient) -> None:
|
||||
body = (await auth_client.get("/api/me")).json()
|
||||
|
||||
assert body["username"] == "tester"
|
||||
assert body["email"] == "tester@example.com"
|
||||
assert body["must_change_password"] is False
|
||||
assert "password_hash" not in body
|
||||
|
||||
|
||||
async def test_systemendpunkte_bleiben_offen(client: AsyncClient) -> None:
|
||||
assert (await client.get("/api/health")).status_code == 200
|
||||
assert (await client.get("/api/version")).status_code == 200
|
||||
|
||||
|
||||
async def test_refresh_rotiert_das_token(auth_client: AsyncClient, session: AsyncSession) -> None:
|
||||
altes_token = auth_client.cookies[REFRESH_COOKIE]
|
||||
|
||||
response = await auth_client.post("/api/auth/refresh")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert auth_client.cookies[REFRESH_COOKIE] != altes_token
|
||||
|
||||
tokens = (await session.execute(select(RefreshToken))).scalars().all()
|
||||
assert len(tokens) == 2
|
||||
assert sum(1 for token in tokens if token.revoked_at is None) == 1
|
||||
|
||||
|
||||
async def test_wiederverwendetes_refresh_token_beendet_alle_sitzungen(
|
||||
auth_client: AsyncClient, session: AsyncSession
|
||||
) -> None:
|
||||
altes_token = auth_client.cookies[REFRESH_COOKIE]
|
||||
await auth_client.post("/api/auth/refresh")
|
||||
|
||||
# Das bereits verbrauchte Token noch einmal einlösen.
|
||||
auth_client.cookies.set(REFRESH_COOKIE, altes_token, path="/api/auth")
|
||||
response = await auth_client.post("/api/auth/refresh")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["code"] == "token_reused"
|
||||
|
||||
tokens = (await session.execute(select(RefreshToken))).scalars().all()
|
||||
assert all(token.revoked_at is not None for token in tokens)
|
||||
|
||||
|
||||
async def test_refresh_ohne_cookie(client: AsyncClient) -> None:
|
||||
response = await client.post("/api/auth/refresh")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["code"] == "not_authenticated"
|
||||
|
||||
|
||||
async def test_abmelden_loescht_die_cookies(auth_client: AsyncClient) -> None:
|
||||
response = await auth_client.post("/api/auth/logout")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert not auth_client.cookies.get(ACCESS_COOKIE)
|
||||
assert (await auth_client.get("/api/accounts")).status_code == 401
|
||||
|
||||
|
||||
async def test_passwortwechsel_beendet_alle_sitzungen(
|
||||
auth_client: AsyncClient, session: AsyncSession, user: AppUser
|
||||
) -> None:
|
||||
response = await auth_client.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": TEST_PASSWORD, "new_password": "noch-sicherer-456"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
tokens = (await session.execute(select(RefreshToken))).scalars().all()
|
||||
assert all(token.revoked_at is not None for token in tokens)
|
||||
|
||||
# Anmeldung nur noch mit dem neuen Passwort.
|
||||
assert (
|
||||
await auth_client.post(
|
||||
"/api/auth/login", json={"username": "tester", "password": TEST_PASSWORD}
|
||||
)
|
||||
).status_code == 401
|
||||
assert (
|
||||
await auth_client.post(
|
||||
"/api/auth/login", json={"username": "tester", "password": "noch-sicherer-456"}
|
||||
)
|
||||
).status_code == 200
|
||||
|
||||
|
||||
async def test_passwortwechsel_prueft_das_alte_passwort(auth_client: AsyncClient) -> None:
|
||||
response = await auth_client.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": "falsch", "new_password": "noch-sicherer-456"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["code"] == "invalid_credentials"
|
||||
|
||||
|
||||
async def test_zu_kurzes_passwort_wird_abgewiesen(auth_client: AsyncClient) -> None:
|
||||
response = await auth_client.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": TEST_PASSWORD, "new_password": "kurz"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["code"] == "validation_error"
|
||||
|
||||
|
||||
async def test_gleiches_passwort_wird_abgewiesen(auth_client: AsyncClient) -> None:
|
||||
response = await auth_client.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": TEST_PASSWORD, "new_password": TEST_PASSWORD},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["code"] == "password_unchanged"
|
||||
|
||||
|
||||
async def test_erzwungener_passwortwechsel_sperrt_die_fachrouten(
|
||||
client: AsyncClient, session: AsyncSession
|
||||
) -> None:
|
||||
session.add(
|
||||
AppUser(
|
||||
username="neuling",
|
||||
password_hash=hash_password("start-passwort-1"),
|
||||
must_change_password=True,
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
await client.post(
|
||||
"/api/auth/login", json={"username": "neuling", "password": "start-passwort-1"}
|
||||
)
|
||||
|
||||
# /api/me bleibt erreichbar, damit die Oberfläche den Zustand erkennt.
|
||||
me = await client.get("/api/me")
|
||||
assert me.status_code == 200
|
||||
assert me.json()["must_change_password"] is True
|
||||
|
||||
gesperrt = await client.get("/api/accounts")
|
||||
assert gesperrt.status_code == 403
|
||||
assert gesperrt.json()["code"] == "password_change_required"
|
||||
|
||||
# Nach dem Wechsel ist der Zugriff frei.
|
||||
await client.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": "start-passwort-1", "new_password": "richtig-sicher-9"},
|
||||
)
|
||||
await client.post(
|
||||
"/api/auth/login", json={"username": "neuling", "password": "richtig-sicher-9"}
|
||||
)
|
||||
assert (await client.get("/api/accounts")).status_code == 200
|
||||
Reference in New Issue
Block a user