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