diff --git a/.env.example b/.env.example index dd92b2e..60ed0d3 100644 --- a/.env.example +++ b/.env.example @@ -22,8 +22,9 @@ POSTGRES_PASSWORD=bitte-aendern DATABASE_URL=postgresql+asyncpg://moneyfy:bitte-aendern@moneyfy-db:5432/moneyfy # --- Sicherheit -------------------------------------------------------------- -# Signaturschlüssel für JWTs. Erzeugen mit: openssl rand -hex 32 -SECRET_KEY=bitte-aendern +# Signaturschlüssel für JWTs, mindestens 32 Zeichen. +# Erzeugen mit: openssl rand -hex 32 +SECRET_KEY=bitte-aendern-mindestens-32-zeichen-lang ACCESS_TOKEN_TTL_MINUTES=30 REFRESH_TOKEN_TTL_DAYS=14 COOKIE_SECURE=true # Hinter HTTPS-Proxy true, für lokales HTTP false diff --git a/CHANGELOG.md b/CHANGELOG.md index f350ba9..3a33267 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,3 +35,23 @@ die Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/). `notice_deadline` (Mindestlaufzeit, automatische Verlängerung, Kündigungsfrist) sowie `annual_burden` und `monthly_reserve` für die Rücklagenbildung. - `validate_rrule` und `next_dates` als Grundlage für Eingabeprüfung und Vorschau. +- Authentifizierung mit Argon2id, JWT in httpOnly-Cookies (Access 30 min, Refresh + 14 Tage) und echter Token-Rotation über die Tabelle `refresh_token`; ein erneut + eingelöstes Refresh-Token beendet alle Sitzungen. +- `AuthProvider`-Protokoll mit lokalem Provider als Vorbereitung für OIDC. +- Anlage des Administrators beim Erststart mit erzwungenem Passwortwechsel; bis + dahin sind alle Fachrouten gesperrt. +- CRUD für Konten, Kategorien, Firmen, wiederkehrende Posten, Preisversionen, + Buchungen, Budgets, Budgetvorlagen und Sparziele. +- Fälligkeiten-API mit Overlay-Logik: abrufen, bestätigen (auch mit abweichendem + Betrag oder Datum), auslassen und zurücksetzen. +- Kontosalden zum Stichtag aus Eröffnungssaldo, Buchungen und bestätigten + Fälligkeiten, inklusive abweichender Konten je Fälligkeit. +- Monatsübersicht mit Plan-Ist-Vergleich, Aufteilung in fixe und variable Kosten, + Rücklagen und Vergleich zum Vormonat. +- Vollständig annotiertes OpenAPI-Dokument unter `/api/docs`. + +### Geändert + +- `SECRET_KEY` muss mindestens 32 Zeichen lang sein (Vorgabe von HS256); in + Produktion wird der Platzhalterwert beim Start abgelehnt. diff --git a/README.md b/README.md index 49cb884..bb9697e 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ vollständige, kommentierte Liste. Die wichtigsten: | Variable | Standard | Bedeutung | |---|---|---| | `DATABASE_URL` | – | PostgreSQL-DSN, `postgresql://` wird auf asyncpg umgestellt | -| `SECRET_KEY` | – | Signaturschlüssel für JWTs (`openssl rand -hex 32`) | +| `SECRET_KEY` | – | Signaturschlüssel für JWTs, mind. 32 Zeichen (`openssl rand -hex 32`) | | `MONEYFY_ADMIN_USER` / `MONEYFY_ADMIN_PASSWORD` | `admin` / – | Beim Erststart angelegter Benutzer | | `TIMEZONE` | `Europe/Berlin` | Zeitzone der gesamten Anwendung | | `HOLIDAY_REGION` | `DE-NW` | Feiertagsregion für Werktagsverschiebungen | diff --git a/backend/alembic/versions/20260909_1323_refresh_token_rotation.py b/backend/alembic/versions/20260909_1323_refresh_token_rotation.py new file mode 100644 index 0000000..fbdf434 --- /dev/null +++ b/backend/alembic/versions/20260909_1323_refresh_token_rotation.py @@ -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 ### diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..fe2ef30 --- /dev/null +++ b/backend/app/api/deps.py @@ -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)] diff --git a/backend/app/api/router.py b/backend/app/api/router.py index cfd9993..f6b17d1 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,8 +1,45 @@ -"""Sammelrouter für alle /api-Endpunkte.""" +"""Sammelrouter für alle /api-Endpunkte. -from fastapi import APIRouter +Alle Routen außer `/api/auth/*`, `/api/me` und den Systemendpunkten erfordern +eine gültige Anmeldung **und** einen abgeschlossenen Passwortwechsel. +""" -from app.api.routes import system +from fastapi import APIRouter, Depends + +from app.api.deps import get_active_user +from app.api.routes import ( + accounts, + auth, + budgets, + categories, + me, + merchants, + occurrences, + recurrences, + reports, + savings_goals, + system, + transactions, +) api_router = APIRouter(prefix="/api") + +# Ohne Authentifizierung erreichbar. api_router.include_router(system.router) +api_router.include_router(auth.router) +api_router.include_router(me.router) + +# Alles Weitere nur für angemeldete Benutzer. +protected = APIRouter(dependencies=[Depends(get_active_user)]) +protected.include_router(accounts.router) +protected.include_router(categories.router) +protected.include_router(merchants.router) +protected.include_router(recurrences.router) +protected.include_router(occurrences.router) +protected.include_router(transactions.router) +protected.include_router(budgets.router) +protected.include_router(budgets.templates) +protected.include_router(savings_goals.router) +protected.include_router(reports.router) + +api_router.include_router(protected) diff --git a/backend/app/api/routes/accounts.py b/backend/app/api/routes/accounts.py new file mode 100644 index 0000000..25725bb --- /dev/null +++ b/backend/app/api/routes/accounts.py @@ -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, + ) diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py new file mode 100644 index 0000000..5b82808 --- /dev/null +++ b/backend/app/api/routes/auth.py @@ -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.") diff --git a/backend/app/api/routes/budgets.py b/backend/app/api/routes/budgets.py new file mode 100644 index 0000000..39872a8 --- /dev/null +++ b/backend/app/api/routes/budgets.py @@ -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.") diff --git a/backend/app/api/routes/categories.py b/backend/app/api/routes/categories.py new file mode 100644 index 0000000..79aeb13 --- /dev/null +++ b/backend/app/api/routes/categories.py @@ -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.") diff --git a/backend/app/api/routes/me.py b/backend/app/api/routes/me.py new file mode 100644 index 0000000..9780020 --- /dev/null +++ b/backend/app/api/routes/me.py @@ -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 diff --git a/backend/app/api/routes/merchants.py b/backend/app/api/routes/merchants.py new file mode 100644 index 0000000..03f7bef --- /dev/null +++ b/backend/app/api/routes/merchants.py @@ -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.") diff --git a/backend/app/api/routes/occurrences.py b/backend/app/api/routes/occurrences.py new file mode 100644 index 0000000..0158a1d --- /dev/null +++ b/backend/app/api/routes/occurrences.py @@ -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) diff --git a/backend/app/api/routes/recurrences.py b/backend/app/api/routes/recurrences.py new file mode 100644 index 0000000..390f65d --- /dev/null +++ b/backend/app/api/routes/recurrences.py @@ -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)) diff --git a/backend/app/api/routes/reports.py b/backend/app/api/routes/reports.py new file mode 100644 index 0000000..a928148 --- /dev/null +++ b/backend/app/api/routes/reports.py @@ -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, + ) diff --git a/backend/app/api/routes/savings_goals.py b/backend/app/api/routes/savings_goals.py new file mode 100644 index 0000000..0b30b5d --- /dev/null +++ b/backend/app/api/routes/savings_goals.py @@ -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.") diff --git a/backend/app/api/routes/transactions.py b/backend/app/api/routes/transactions.py new file mode 100644 index 0000000..f2f44c2 --- /dev/null +++ b/backend/app/api/routes/transactions.py @@ -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.") diff --git a/backend/app/core/clock.py b/backend/app/core/clock.py new file mode 100644 index 0000000..32460d4 --- /dev/null +++ b/backend/app/core/clock.py @@ -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)) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 163a763..e6f6d32 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -4,9 +4,12 @@ from functools import lru_cache from pathlib import Path from typing import Literal -from pydantic import Field, PostgresDsn, field_validator +from pydantic import Field, PostgresDsn, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict +# Erkennbarer Platzhalter: erlaubt lokale Starts, ist in Produktion aber verboten. +PLACEHOLDER_SECRET = "bitte-aendern-" + "0" * 32 + class Settings(BaseSettings): """Alle Einstellungen stammen aus der Umgebung bzw. einer .env-Datei.""" @@ -34,7 +37,8 @@ class Settings(BaseSettings): ) # --- Sicherheit ---------------------------------------------------------- - secret_key: str = Field(default="change-me-in-production", min_length=8) + # HS256 verlangt mindestens 32 Byte Schlüsselmaterial (RFC 7518, Abschnitt 3.2). + secret_key: str = Field(default=PLACEHOLDER_SECRET, min_length=32) access_token_ttl_minutes: int = 30 refresh_token_ttl_days: int = 14 cookie_secure: bool = True @@ -88,6 +92,15 @@ class Settings(BaseSettings): return value.replace("postgresql://", "postgresql+asyncpg://", 1) return value + @model_validator(mode="after") + def _reject_placeholder_secret(self) -> "Settings": + """In Produktion muss ein eigener Signaturschlüssel gesetzt sein.""" + if self.environment == "production" and self.secret_key == PLACEHOLDER_SECRET: + raise ValueError( + "SECRET_KEY ist nicht gesetzt. Einen Schlüssel erzeugen mit: openssl rand -hex 32" + ) + return self + @property def sync_database_url(self) -> str: """Synchrone Variante der DSN – wird von Alembic benötigt.""" diff --git a/backend/app/core/cookies.py b/backend/app/core/cookies.py new file mode 100644 index 0000000..66d0d8d --- /dev/null +++ b/backend/app/core/cookies.py @@ -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", + ) diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..096ca49 --- /dev/null +++ b/backend/app/core/security.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index c89e4fb..427416e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,6 +10,8 @@ from fastapi.middleware.cors import CORSMiddleware from app.api.router import api_router from app.core.config import settings from app.core.errors import register_exception_handlers +from app.db.session import SessionLocal, engine +from app.services.auth import ensure_admin_user, purge_expired_refresh_tokens logging.basicConfig( level=logging.DEBUG if settings.debug else logging.INFO, @@ -22,11 +24,30 @@ logger = logging.getLogger("moneyfy") async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: """Start- und Stopplogik der Anwendung.""" settings.logo_storage_dir.mkdir(parents=True, exist_ok=True) + await _bootstrap() logger.info("moneyfy %s gestartet (%s)", settings.app_version, settings.environment) yield + await engine.dispose() logger.info("moneyfy wird beendet") +async def _bootstrap() -> None: + """Erststart: Administrator anlegen und abgelaufene Sitzungen aufräumen. + + Fehler werden protokolliert, beenden die Anwendung aber nicht – der + Health-Check meldet eine nicht erreichbare Datenbank ohnehin. + """ + try: + async with SessionLocal() as session: + await ensure_admin_user(session) + removed = await purge_expired_refresh_tokens(session) + await session.commit() + if removed: + logger.info("%d abgelaufene Sitzungen entfernt.", removed) + except Exception: + logger.exception("Start-Initialisierung fehlgeschlagen.") + + def create_app() -> FastAPI: app = FastAPI( title="moneyfy", diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 274d772..38df691 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,7 +1,7 @@ """SQLAlchemy-Modelle. Import hier hält Alembics Autogenerate vollständig.""" from app.db.base import Base -from app.models.core import Account, AppUser, Category, LogoAsset, Merchant +from app.models.core import Account, AppUser, Category, LogoAsset, Merchant, RefreshToken from app.models.enums import ( AccountType, BusinessDayShift, @@ -48,6 +48,7 @@ __all__ = [ "Occurrence", "OccurrenceStatus", "Recurrence", + "RefreshToken", "ReserveLedger", "SavingsGoal", "Transaction", diff --git a/backend/app/models/core.py b/backend/app/models/core.py index 233b746..bf4a3c8 100644 --- a/backend/app/models/core.py +++ b/backend/app/models/core.py @@ -148,3 +148,26 @@ class AppUser(Base, CreatedAtMixin): name="local_or_external_login", ), ) + + +class RefreshToken(Base, CreatedAtMixin): + """Ausgegebenes Refresh-Token. + + Für die Rotation beim Refresh wird serverseitiger Zustand benötigt: Ein Token + ist nur gültig, solange seine `jti` hier ungesperrt hinterlegt ist. Beim Refresh + wird der alte Eintrag gesperrt und ein neuer angelegt. + """ + + __tablename__ = "refresh_token" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + jti: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False + ) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + user: Mapped[AppUser] = relationship() + + __table_args__ = (Index("ix_refresh_token_user_id", "user_id"),) diff --git a/backend/app/schemas/account.py b/backend/app/schemas/account.py new file mode 100644 index 0000000..86b7e0c --- /dev/null +++ b/backend/app/schemas/account.py @@ -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.") diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..9c2f1cf --- /dev/null +++ b/backend/app/schemas/auth.py @@ -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 diff --git a/backend/app/schemas/budget.py b/backend/app/schemas/budget.py new file mode 100644 index 0000000..d2eafc8 --- /dev/null +++ b/backend/app/schemas/budget.py @@ -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 diff --git a/backend/app/schemas/category.py b/backend/app/schemas/category.py new file mode 100644 index 0000000..07ce553 --- /dev/null +++ b/backend/app/schemas/category.py @@ -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) diff --git a/backend/app/schemas/common.py b/backend/app/schemas/common.py new file mode 100644 index 0000000..7f172c1 --- /dev/null +++ b/backend/app/schemas/common.py @@ -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 diff --git a/backend/app/schemas/merchant.py b/backend/app/schemas/merchant.py new file mode 100644 index 0000000..29219b8 --- /dev/null +++ b/backend/app/schemas/merchant.py @@ -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 diff --git a/backend/app/schemas/occurrence.py b/backend/app/schemas/occurrence.py new file mode 100644 index 0000000..31e2319 --- /dev/null +++ b/backend/app/schemas/occurrence.py @@ -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 diff --git a/backend/app/schemas/recurrence.py b/backend/app/schemas/recurrence.py new file mode 100644 index 0000000..dc560e3 --- /dev/null +++ b/backend/app/schemas/recurrence.py @@ -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 diff --git a/backend/app/schemas/report.py b/backend/app/schemas/report.py new file mode 100644 index 0000000..a49dd85 --- /dev/null +++ b/backend/app/schemas/report.py @@ -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 diff --git a/backend/app/schemas/transaction.py b/backend/app/schemas/transaction.py new file mode 100644 index 0000000..9556931 --- /dev/null +++ b/backend/app/schemas/transaction.py @@ -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 diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py new file mode 100644 index 0000000..a8c500d --- /dev/null +++ b/backend/app/services/auth.py @@ -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) diff --git a/backend/app/services/balances.py b/backend/app/services/balances.py new file mode 100644 index 0000000..6b1dbf1 --- /dev/null +++ b/backend/app/services/balances.py @@ -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) diff --git a/backend/app/services/crud.py b/backend/app/services/crud.py new file mode 100644 index 0000000..fb8dc8b --- /dev/null +++ b/backend/app/services/crud.py @@ -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() diff --git a/backend/app/services/merchants.py b/backend/app/services/merchants.py new file mode 100644 index 0000000..6e8da32 --- /dev/null +++ b/backend/app/services/merchants.py @@ -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 diff --git a/backend/app/services/occurrences.py b/backend/app/services/occurrences.py new file mode 100644 index 0000000..c65bf04 --- /dev/null +++ b/backend/app/services/occurrences.py @@ -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() diff --git a/backend/app/services/recurrence.py b/backend/app/services/recurrence.py index 81462c4..141cd68 100644 --- a/backend/app/services/recurrence.py +++ b/backend/app/services/recurrence.py @@ -17,12 +17,12 @@ from datetime import date, datetime, timedelta from decimal import ROUND_HALF_UP, Decimal from functools import lru_cache from typing import Protocol, runtime_checkable -from zoneinfo import ZoneInfo import holidays from dateutil.relativedelta import relativedelta from dateutil.rrule import rrulestr +from app.core.clock import today from app.models.enums import BusinessDayShift, EntryKind, OccurrenceStatus ZERO = Decimal("0.00") @@ -247,7 +247,7 @@ def next_dates( after: date | None = None, ) -> list[date]: """Die nächsten `count` nominalen Termine nach `after` (einschließlich).""" - start = after or _today() + start = after or today() end = start results: list[date] = [] # Fenster schrittweise vergrößern, bis genug Termine gefunden sind. @@ -391,7 +391,7 @@ def installments_remaining( if not total: return None - reference = as_of or _today() + reference = as_of or today() schedule = expand( recurrence, recurrence.dtstart, @@ -437,7 +437,7 @@ def contract_term(recurrence: RecurrenceLike, as_of: date | None = None) -> Cont return None start = recurrence.contract_start or recurrence.dtstart - reference = as_of or _today() + reference = as_of or today() term_start = start term_end = start + relativedelta(months=recurrence.contract_min_term_months) - timedelta(days=1) @@ -492,7 +492,7 @@ def annual_burden( Bewusst kein fester Intervallfaktor: eine halbjährliche Zahlung, die im Fenster nur einmal fällt, ergibt auch nur eine Belastung. """ - start = as_of or _today() + start = as_of or today() end = start + relativedelta(years=1) - timedelta(days=1) schedule = expand(recurrence, start, end, amount_versions=amount_versions) return _sum(item.amount for item in schedule) @@ -518,11 +518,6 @@ def monthly_reserve( # --- Interne Helfer ----------------------------------------------------------- -def _today() -> date: - """Heutiges Datum in der fachlichen Zeitzone.""" - return datetime.now(ZoneInfo("Europe/Berlin")).date() - - def _as_datetime(day: date) -> datetime: """dateutil rechnet intern mit datetime; die Uhrzeit ist fachlich bedeutungslos.""" return datetime(day.year, day.month, day.day) diff --git a/backend/app/services/reports.py b/backend/app/services/reports.py new file mode 100644 index 0000000..1d94152 --- /dev/null +++ b/backend/app/services/reports.py @@ -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, + ) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 5b1a53f..0084807 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -10,13 +10,16 @@ os.environ.setdefault( "postgresql+asyncpg://moneyfy:moneyfy@127.0.0.1:5432/moneyfy_test", ), ) -os.environ.setdefault("SECRET_KEY", "test-secret-key") +os.environ.setdefault("SECRET_KEY", "test-secret-key-mindestens-32-zeichen-lang") os.environ.setdefault("ENVIRONMENT", "test") from collections.abc import AsyncGenerator +from datetime import date +from decimal import Decimal import pytest from httpx import ASGITransport, AsyncClient +from sqlalchemy import select from sqlalchemy.ext.asyncio import ( AsyncSession, async_sessionmaker, @@ -24,9 +27,14 @@ from sqlalchemy.ext.asyncio import ( ) from app.core.config import settings +from app.core.security import hash_password from app.db.session import get_session from app.main import app as fastapi_app -from app.models import Base +from app.models import Account, AppUser, Base, Category +from app.models.enums import AccountType +from app.services.seed import seed_all + +TEST_PASSWORD = "sicher-genug-123" @pytest.fixture(scope="session") @@ -76,3 +84,55 @@ async def client(session: AsyncSession) -> AsyncGenerator[AsyncClient, None]: async with AsyncClient(transport=transport, base_url="http://test") as http_client: yield http_client fastapi_app.dependency_overrides.clear() + + +@pytest.fixture +async def user(session: AsyncSession) -> AppUser: + """Ein angemeldefähiger Benutzer mit bekanntem Passwort.""" + account = AppUser( + username="tester", + email="tester@example.com", + password_hash=hash_password(TEST_PASSWORD), + must_change_password=False, + ) + session.add(account) + await session.flush() + return account + + +@pytest.fixture +async def auth_client(client: AsyncClient, user: AppUser) -> AsyncClient: + """Bereits angemeldeter Client – die Cookies bleiben am Client hängen.""" + response = await client.post( + "/api/auth/login", json={"username": user.username, "password": TEST_PASSWORD} + ) + assert response.status_code == 200, response.text + return client + + +@pytest.fixture +async def seeded(session: AsyncSession) -> dict[str, int]: + """Kategoriebaum plus ein Konto – die Grundlage der meisten Integrationstests.""" + await seed_all(session) + + account = Account( + name="Girokonto", + type=AccountType.CHECKING, + opening_balance=Decimal("1000.00"), + opening_balance_date=date(2026, 1, 1), + ) + session.add(account) + await session.flush() + + async def category_id(name: str) -> int: + stmt = select(Category).where(Category.name == name) + return (await session.execute(stmt)).scalars().first().id + + return { + "account_id": account.id, + "streaming": await category_id("Streaming"), + "miete": await category_id("Miete"), + "kredite": await category_id("Kredite"), + "gehalt": await category_id("Gehalt"), + "lebensmittel": await category_id("Lebensmittel"), + } diff --git a/backend/tests/test_api_crud.py b/backend/tests/test_api_crud.py new file mode 100644 index 0000000..7c07ab0 --- /dev/null +++ b/backend/tests/test_api_crud.py @@ -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 + ) diff --git a/backend/tests/test_api_occurrences.py b/backend/tests/test_api_occurrences.py new file mode 100644 index 0000000..3cfe09a --- /dev/null +++ b/backend/tests/test_api_occurrences.py @@ -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" diff --git a/backend/tests/test_api_recurrences.py b/backend/tests/test_api_recurrences.py new file mode 100644 index 0000000..c77b891 --- /dev/null +++ b/backend/tests/test_api_recurrences.py @@ -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" diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..af384d6 --- /dev/null +++ b/backend/tests/test_auth.py @@ -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