feat(api): Core-API mit Authentifizierung, CRUD und Monatsreport

- Anmeldung über Argon2id und JWT in httpOnly-Cookies, Refresh mit echter
  Rotation über die neue Tabelle refresh_token
- AuthProvider-Protokoll als Vorbereitung für OIDC, Administrator-Anlage beim
  Erststart mit erzwungenem Passwortwechsel
- CRUD für Konten, Kategorien (zweistufiger Baum), Firmen, Recurrences,
  Preisversionen, Buchungen, Budgets, Vorlagen und Sparziele
- Fälligkeiten mit Overlay-Logik: abrufen, bestätigen, auslassen, zurücksetzen
- Kontosalden zum Stichtag, Monatsübersicht mit Plan-Ist-Vergleich
- SECRET_KEY jetzt mindestens 32 Zeichen; Platzhalter in Produktion abgelehnt
- 61 neue Integrationstests, insgesamt 148 grün

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