feat(api): Core-API mit Authentifizierung, CRUD und Monatsreport
- Anmeldung über Argon2id und JWT in httpOnly-Cookies, Refresh mit echter Rotation über die neue Tabelle refresh_token - AuthProvider-Protokoll als Vorbereitung für OIDC, Administrator-Anlage beim Erststart mit erzwungenem Passwortwechsel - CRUD für Konten, Kategorien (zweistufiger Baum), Firmen, Recurrences, Preisversionen, Buchungen, Budgets, Vorlagen und Sparziele - Fälligkeiten mit Overlay-Logik: abrufen, bestätigen, auslassen, zurücksetzen - Kontosalden zum Stichtag, Monatsübersicht mit Plan-Ist-Vergleich - SECRET_KEY jetzt mindestens 32 Zeichen; Platzhalter in Produktion abgelehnt - 61 neue Integrationstests, insgesamt 148 grün Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
"""Authentifizierung: Provider-Abstraktion, Anmeldung und Token-Rotation.
|
||||
|
||||
Die Anmeldung läuft grundsätzlich über einen `AuthProvider`. Aktuell existiert
|
||||
nur der lokale Provider; die Abstraktion samt `app_user.external_subject` ist
|
||||
bewusst vorhanden, damit ein OIDC-Provider später ohne Umbau ergänzt werden kann.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.errors import AuthError, ValidationError
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
hash_password,
|
||||
needs_rehash,
|
||||
verify_password,
|
||||
)
|
||||
from app.models import AppUser, RefreshToken
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MIN_PASSWORD_LENGTH = 10
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Credentials:
|
||||
"""Anmeldedaten des lokalen Providers."""
|
||||
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TokenPair:
|
||||
"""Frisch ausgestelltes Token-Paar samt Ablaufzeitpunkten."""
|
||||
|
||||
access_token: str
|
||||
access_expires_at: datetime
|
||||
refresh_token: str
|
||||
refresh_expires_at: datetime
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AuthProvider(Protocol):
|
||||
"""Ein Verfahren, das einen Benutzer identifiziert."""
|
||||
|
||||
name: str
|
||||
|
||||
async def authenticate(self, session: AsyncSession, credentials: object) -> AppUser: ...
|
||||
|
||||
|
||||
class LocalAuthProvider:
|
||||
"""Benutzername und Passwort gegen den Argon2-Hash in der Datenbank."""
|
||||
|
||||
name = "local"
|
||||
|
||||
async def authenticate(self, session: AsyncSession, credentials: object) -> AppUser:
|
||||
if not isinstance(credentials, Credentials):
|
||||
raise AuthError("Ungültige Anmeldedaten.", code="invalid_credentials")
|
||||
|
||||
user = await get_user_by_username(session, credentials.username)
|
||||
# Auch ohne Treffer wird geprüft, damit die Antwortzeit nichts verrät.
|
||||
password_hash = user.password_hash if user else None
|
||||
if not verify_password(credentials.password, password_hash) or user is None:
|
||||
raise AuthError("Benutzername oder Passwort ist falsch.", code="invalid_credentials")
|
||||
|
||||
if user.password_hash and needs_rehash(user.password_hash):
|
||||
user.password_hash = hash_password(credentials.password)
|
||||
|
||||
user.last_login_at = datetime.now(UTC)
|
||||
await session.flush()
|
||||
return user
|
||||
|
||||
|
||||
# TODO(OIDC): Sobald die Anbindung umgesetzt wird, hier einen `OidcAuthProvider`
|
||||
# registrieren, der `OIDC_ISSUER`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET` aus den
|
||||
# Settings nutzt und den Benutzer über `app_user.external_subject` auflöst.
|
||||
_PROVIDERS: dict[str, AuthProvider] = {"local": LocalAuthProvider()}
|
||||
|
||||
|
||||
def get_auth_provider(name: str = "local") -> AuthProvider:
|
||||
"""Liefert den registrierten Provider."""
|
||||
provider = _PROVIDERS.get(name)
|
||||
if provider is None:
|
||||
raise AuthError(f"Unbekanntes Anmeldeverfahren: {name}", code="unknown_provider")
|
||||
return provider
|
||||
|
||||
|
||||
# --- Benutzer ------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_user_by_username(session: AsyncSession, username: str) -> AppUser | None:
|
||||
stmt = select(AppUser).where(func.lower(AppUser.username) == username.strip().lower())
|
||||
return (await session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user(session: AsyncSession, user_id: int) -> AppUser | None:
|
||||
return await session.get(AppUser, user_id)
|
||||
|
||||
|
||||
def validate_password(password: str) -> None:
|
||||
"""Mindestanforderungen an ein neues Passwort."""
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
raise ValidationError(
|
||||
f"Das Passwort muss mindestens {MIN_PASSWORD_LENGTH} Zeichen lang sein.",
|
||||
code="password_too_short",
|
||||
)
|
||||
|
||||
|
||||
async def change_password(
|
||||
session: AsyncSession, user: AppUser, current_password: str, new_password: str
|
||||
) -> None:
|
||||
"""Ändert das Passwort und macht alle bestehenden Sitzungen ungültig."""
|
||||
if not verify_password(current_password, user.password_hash):
|
||||
raise AuthError("Das aktuelle Passwort ist falsch.", code="invalid_credentials")
|
||||
if current_password == new_password:
|
||||
raise ValidationError(
|
||||
"Das neue Passwort muss sich vom bisherigen unterscheiden.",
|
||||
code="password_unchanged",
|
||||
)
|
||||
validate_password(new_password)
|
||||
|
||||
user.password_hash = hash_password(new_password)
|
||||
user.must_change_password = False
|
||||
await revoke_all_refresh_tokens(session, user.id)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def ensure_admin_user(session: AsyncSession) -> AppUser | None:
|
||||
"""Legt beim Erststart den konfigurierten Administrator an.
|
||||
|
||||
Passiert nur, wenn noch kein Benutzer existiert und ein Passwort gesetzt ist.
|
||||
Der Benutzer muss das Passwort bei der ersten Anmeldung ändern.
|
||||
"""
|
||||
existing = (await session.execute(select(func.count()).select_from(AppUser))).scalar_one()
|
||||
if existing:
|
||||
return None
|
||||
if not settings.moneyfy_admin_password:
|
||||
logger.warning(
|
||||
"Kein Benutzer vorhanden und MONEYFY_ADMIN_PASSWORD ist nicht gesetzt – "
|
||||
"es kann sich niemand anmelden."
|
||||
)
|
||||
return None
|
||||
|
||||
user = AppUser(
|
||||
username=settings.moneyfy_admin_user,
|
||||
password_hash=hash_password(settings.moneyfy_admin_password),
|
||||
must_change_password=True,
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
logger.info("Administrator '%s' angelegt (Passwortwechsel erforderlich).", user.username)
|
||||
return user
|
||||
|
||||
|
||||
# --- Sitzungen -----------------------------------------------------------------
|
||||
|
||||
|
||||
async def issue_tokens(session: AsyncSession, user: AppUser) -> TokenPair:
|
||||
"""Stellt ein neues Token-Paar aus und hinterlegt das Refresh-Token."""
|
||||
access_token, _, access_expires = create_access_token(user.id)
|
||||
refresh_token, jti, refresh_expires = create_refresh_token(user.id)
|
||||
|
||||
session.add(RefreshToken(jti=jti, user_id=user.id, expires_at=refresh_expires))
|
||||
await session.flush()
|
||||
|
||||
return TokenPair(
|
||||
access_token=access_token,
|
||||
access_expires_at=access_expires,
|
||||
refresh_token=refresh_token,
|
||||
refresh_expires_at=refresh_expires,
|
||||
)
|
||||
|
||||
|
||||
async def rotate_tokens(session: AsyncSession, refresh_token: str) -> tuple[AppUser, TokenPair]:
|
||||
"""Prüft ein Refresh-Token, sperrt es und stellt ein neues Paar aus."""
|
||||
payload = decode_token(refresh_token, "refresh")
|
||||
jti = payload["jti"]
|
||||
|
||||
stored = (
|
||||
await session.execute(select(RefreshToken).where(RefreshToken.jti == jti))
|
||||
).scalar_one_or_none()
|
||||
if stored is None:
|
||||
raise AuthError("Die Sitzung ist nicht mehr gültig.", code="invalid_token")
|
||||
if stored.revoked_at is not None:
|
||||
# Ein bereits verwendetes Token deutet auf Diebstahl hin – alles sperren.
|
||||
await revoke_all_refresh_tokens(session, stored.user_id)
|
||||
raise AuthError("Die Sitzung wurde beendet.", code="token_reused")
|
||||
if stored.expires_at <= datetime.now(UTC):
|
||||
raise AuthError("Die Sitzung ist abgelaufen.", code="token_expired")
|
||||
|
||||
user = await get_user(session, stored.user_id)
|
||||
if user is None:
|
||||
raise AuthError("Der Benutzer existiert nicht mehr.", code="invalid_token")
|
||||
|
||||
stored.revoked_at = datetime.now(UTC)
|
||||
await session.flush()
|
||||
return user, await issue_tokens(session, user)
|
||||
|
||||
|
||||
async def revoke_refresh_token(session: AsyncSession, refresh_token: str | None) -> None:
|
||||
"""Sperrt genau ein Refresh-Token. Ungültige Token werden still ignoriert."""
|
||||
if not refresh_token:
|
||||
return
|
||||
try:
|
||||
payload = decode_token(refresh_token, "refresh")
|
||||
except AuthError:
|
||||
return
|
||||
|
||||
stored = (
|
||||
await session.execute(select(RefreshToken).where(RefreshToken.jti == payload["jti"]))
|
||||
).scalar_one_or_none()
|
||||
if stored is not None and stored.revoked_at is None:
|
||||
stored.revoked_at = datetime.now(UTC)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def revoke_all_refresh_tokens(session: AsyncSession, user_id: int) -> None:
|
||||
"""Sperrt alle offenen Sitzungen eines Benutzers."""
|
||||
stmt = select(RefreshToken).where(
|
||||
RefreshToken.user_id == user_id, RefreshToken.revoked_at.is_(None)
|
||||
)
|
||||
for token in (await session.execute(stmt)).scalars():
|
||||
token.revoked_at = datetime.now(UTC)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def purge_expired_refresh_tokens(session: AsyncSession) -> int:
|
||||
"""Räumt abgelaufene Token auf. Wird beim Start und vom Scheduler aufgerufen."""
|
||||
stmt = select(RefreshToken).where(RefreshToken.expires_at <= datetime.now(UTC))
|
||||
tokens = list((await session.execute(stmt)).scalars())
|
||||
for token in tokens:
|
||||
await session.delete(token)
|
||||
await session.flush()
|
||||
return len(tokens)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Fortschreibung der Kontosalden.
|
||||
|
||||
Saldo = Eröffnungssaldo + alle einmaligen Buchungen + alle bestätigten
|
||||
Fälligkeiten bis zum Stichtag. Geplante oder ausgelassene Fälligkeiten bleiben
|
||||
außen vor – sie sind Prognose, keine Bewegung.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.clock import today
|
||||
from app.models import Account, Occurrence, Recurrence, Transaction
|
||||
from app.models.enums import EntryKind, OccurrenceStatus
|
||||
|
||||
ZERO = Decimal("0.00")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccountBalance:
|
||||
"""Aufgeschlüsselter Saldo eines Kontos."""
|
||||
|
||||
account_id: int
|
||||
as_of: date
|
||||
opening_balance: Decimal
|
||||
booked_transactions: Decimal
|
||||
booked_occurrences: Decimal
|
||||
|
||||
@property
|
||||
def balance(self) -> Decimal:
|
||||
return self.opening_balance + self.booked_transactions + self.booked_occurrences
|
||||
|
||||
|
||||
async def account_balance(
|
||||
session: AsyncSession, account: Account, as_of: date | None = None
|
||||
) -> AccountBalance:
|
||||
"""Berechnet den Saldo eines Kontos zum Stichtag (einschließlich)."""
|
||||
reference = as_of or today()
|
||||
|
||||
transactions = await _transaction_sum(session, account.id, reference)
|
||||
occurrences = await _occurrence_sum(session, account.id, reference)
|
||||
|
||||
return AccountBalance(
|
||||
account_id=account.id,
|
||||
as_of=reference,
|
||||
opening_balance=account.opening_balance,
|
||||
booked_transactions=transactions,
|
||||
booked_occurrences=occurrences,
|
||||
)
|
||||
|
||||
|
||||
async def _transaction_sum(session: AsyncSession, account_id: int, as_of: date) -> Decimal:
|
||||
"""Vorzeichenbehaftete Summe der einmaligen Buchungen bis zum Stichtag."""
|
||||
signed = func.sum(
|
||||
case(
|
||||
(Transaction.kind == EntryKind.EXPENSE, -Transaction.amount),
|
||||
else_=Transaction.amount,
|
||||
)
|
||||
)
|
||||
stmt = select(func.coalesce(signed, ZERO)).where(
|
||||
Transaction.account_id == account_id,
|
||||
Transaction.booking_date <= as_of,
|
||||
)
|
||||
return (await session.execute(stmt)).scalar_one()
|
||||
|
||||
|
||||
async def _occurrence_sum(session: AsyncSession, account_id: int, as_of: date) -> Decimal:
|
||||
"""Vorzeichenbehaftete Summe der bestätigten Fälligkeiten bis zum Stichtag.
|
||||
|
||||
Maßgeblich sind der Ist-Betrag und – falls erfasst – das Ist-Datum. Das Konto
|
||||
kann je Fälligkeit vom Konto der Recurrence abweichen.
|
||||
"""
|
||||
effective_account = func.coalesce(Occurrence.account_id, Recurrence.account_id)
|
||||
effective_amount = func.coalesce(Occurrence.actual_amount, Occurrence.planned_amount)
|
||||
effective_date = func.coalesce(Occurrence.actual_date, Occurrence.occurrence_date)
|
||||
|
||||
signed = func.sum(
|
||||
case(
|
||||
(Recurrence.kind == EntryKind.EXPENSE, -effective_amount),
|
||||
else_=effective_amount,
|
||||
)
|
||||
)
|
||||
stmt = (
|
||||
select(func.coalesce(signed, ZERO))
|
||||
.select_from(Occurrence)
|
||||
.join(Recurrence, Recurrence.id == Occurrence.recurrence_id)
|
||||
.where(
|
||||
Occurrence.status == OccurrenceStatus.CONFIRMED,
|
||||
effective_account == account_id,
|
||||
effective_date <= as_of,
|
||||
)
|
||||
)
|
||||
return (await session.execute(stmt)).scalar_one()
|
||||
|
||||
|
||||
async def all_balances(
|
||||
session: AsyncSession, as_of: date | None = None, *, only_active: bool = True
|
||||
) -> list[AccountBalance]:
|
||||
"""Salden aller Konten zum Stichtag."""
|
||||
stmt = select(Account).order_by(Account.sort_order, Account.name)
|
||||
if only_active:
|
||||
stmt = stmt.where(Account.is_active.is_(True))
|
||||
accounts = (await session.execute(stmt)).scalars().all()
|
||||
return [await account_balance(session, account, as_of) for account in accounts]
|
||||
|
||||
|
||||
async def total_balance(session: AsyncSession, as_of: date | None = None) -> Decimal:
|
||||
"""Summe aller aktiven Kontosalden."""
|
||||
balances = await all_balances(session, as_of)
|
||||
return sum((item.balance for item in balances), ZERO)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Kleine Helfer, die sich über alle CRUD-Endpunkte wiederholen."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import Select, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.errors import NotFoundError
|
||||
from app.db.base import Base
|
||||
|
||||
# Sprechende Bezeichnungen für Fehlermeldungen in der Oberfläche.
|
||||
LABELS: dict[str, str] = {
|
||||
"account": "Das Konto",
|
||||
"amount_version": "Die Preisversion",
|
||||
"budget": "Das Budget",
|
||||
"budget_template": "Die Budgetvorlage",
|
||||
"category": "Die Kategorie",
|
||||
"logo_asset": "Das Logo",
|
||||
"merchant": "Die Firma",
|
||||
"notification_rule": "Die Benachrichtigungsregel",
|
||||
"occurrence": "Die Fälligkeit",
|
||||
"recurrence": "Der wiederkehrende Posten",
|
||||
"savings_goal": "Das Sparziel",
|
||||
"transaction": "Die Buchung",
|
||||
}
|
||||
|
||||
|
||||
def label_for(model: type[Base]) -> str:
|
||||
return LABELS.get(model.__tablename__, "Der Datensatz")
|
||||
|
||||
|
||||
async def get_or_404[ModelT: Base](
|
||||
session: AsyncSession, model: type[ModelT], object_id: int
|
||||
) -> ModelT:
|
||||
"""Lädt einen Datensatz oder wirft einen 404 mit deutscher Meldung."""
|
||||
instance = await session.get(model, object_id)
|
||||
if instance is None:
|
||||
raise NotFoundError(f"{label_for(model)} mit der ID {object_id} existiert nicht.")
|
||||
return instance
|
||||
|
||||
|
||||
def apply_updates(instance: Base, payload: BaseModel) -> Base:
|
||||
"""Überträgt nur die tatsächlich gesetzten Felder eines PATCH-Schemas."""
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(instance, field, value)
|
||||
return instance
|
||||
|
||||
|
||||
async def count_of(session: AsyncSession, statement: Select[Any]) -> int:
|
||||
"""Zählt die Treffer einer Abfrage ohne Sortierung und Seitenbegrenzung."""
|
||||
subquery = statement.order_by(None).options().subquery()
|
||||
return (await session.execute(select(func.count()).select_from(subquery))).scalar_one()
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Firmenstammdaten: Normalisierung und Suche."""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Merchant
|
||||
|
||||
# Umlaute und ß werden ausgeschrieben, damit "Müller" und "Mueller" zusammenfallen.
|
||||
_TRANSLITERATIONS = {
|
||||
"ä": "ae",
|
||||
"ö": "oe",
|
||||
"ü": "ue",
|
||||
"ß": "ss",
|
||||
"æ": "ae",
|
||||
"ø": "oe",
|
||||
"å": "aa",
|
||||
}
|
||||
|
||||
# Rechtsformen und Zusätze, die für die Wiedererkennung einer Marke irrelevant sind.
|
||||
_LEGAL_SUFFIXES = (
|
||||
"gmbh co kg",
|
||||
"gmbh",
|
||||
"ag",
|
||||
"kg",
|
||||
"ohg",
|
||||
"ug",
|
||||
"se",
|
||||
"ev",
|
||||
"mbh",
|
||||
"inc",
|
||||
"llc",
|
||||
"ltd",
|
||||
"plc",
|
||||
"sa",
|
||||
"bv",
|
||||
"nv",
|
||||
)
|
||||
|
||||
|
||||
def normalize_name(name: str) -> str:
|
||||
"""Kleinschreibung ohne Sonderzeichen – Grundlage für Eindeutigkeit und Logosuche."""
|
||||
text = name.strip().lower()
|
||||
for source, target in _TRANSLITERATIONS.items():
|
||||
text = text.replace(source, target)
|
||||
# Verbleibende Akzente auflösen (é -> e).
|
||||
text = unicodedata.normalize("NFKD", text)
|
||||
text = "".join(char for char in text if not unicodedata.combining(char))
|
||||
# Punkte fallen ersatzlos weg, damit "B.V." und "e.V." als ein Wort erkannt werden.
|
||||
text = text.replace(".", "")
|
||||
text = re.sub(r"[^a-z0-9]+", " ", text).strip()
|
||||
|
||||
for suffix in _LEGAL_SUFFIXES:
|
||||
if text.endswith(f" {suffix}"):
|
||||
text = text[: -len(suffix) - 1].strip()
|
||||
break
|
||||
|
||||
return re.sub(r"\s+", "", text)
|
||||
|
||||
|
||||
def domain_from_name(name: str) -> str | None:
|
||||
"""Rät eine Domain aus dem Firmennamen – nur als letzter Anhaltspunkt gedacht."""
|
||||
slug = normalize_name(name)
|
||||
if not slug or len(slug) < 3:
|
||||
return None
|
||||
return f"{slug}.com"
|
||||
|
||||
|
||||
async def find_by_normalized_name(session: AsyncSession, name: str) -> Merchant | None:
|
||||
stmt = select(Merchant).where(Merchant.normalized_name == normalize_name(name))
|
||||
return (await session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
|
||||
def search_statement(query: str | None):
|
||||
"""Abfrage über Name, normalisierten Namen und Domain."""
|
||||
stmt = select(Merchant).order_by(Merchant.name)
|
||||
if query:
|
||||
pattern = f"%{query.strip().lower()}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Merchant.normalized_name.like(f"%{normalize_name(query)}%"),
|
||||
Merchant.name.ilike(pattern),
|
||||
Merchant.domain.ilike(pattern),
|
||||
)
|
||||
)
|
||||
return stmt
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Fälligkeiten: virtuelle Expansion über alle Posten sowie Bestätigen und Auslassen."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.errors import NotFoundError, ValidationError
|
||||
from app.models import Occurrence, Recurrence
|
||||
from app.models.enums import EntryKind, OccurrenceStatus
|
||||
from app.services.recurrence import (
|
||||
PlannedOccurrence,
|
||||
expand,
|
||||
expand_by_due_date,
|
||||
resolve_amount,
|
||||
)
|
||||
|
||||
# Overlays werden mit Puffer geladen, weil Ist-Datum und Soll-Datum auseinanderliegen können.
|
||||
_OVERLAY_PADDING_DAYS = timedelta(days=45)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DueItem:
|
||||
"""Eine Fälligkeit samt der Recurrence, aus der sie stammt."""
|
||||
|
||||
planned: PlannedOccurrence
|
||||
recurrence: Recurrence
|
||||
|
||||
|
||||
async def load_recurrences(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
kind: EntryKind | None = None,
|
||||
only_active: bool = True,
|
||||
category_id: int | None = None,
|
||||
account_id: int | None = None,
|
||||
recurrence_id: int | None = None,
|
||||
) -> list[Recurrence]:
|
||||
"""Lädt Posten samt Preishistorie in einem Rutsch."""
|
||||
stmt = (
|
||||
select(Recurrence)
|
||||
.options(selectinload(Recurrence.amount_versions))
|
||||
.order_by(Recurrence.title)
|
||||
)
|
||||
if only_active:
|
||||
stmt = stmt.where(Recurrence.is_active.is_(True))
|
||||
if kind is not None:
|
||||
stmt = stmt.where(Recurrence.kind == kind)
|
||||
if category_id is not None:
|
||||
stmt = stmt.where(Recurrence.category_id == category_id)
|
||||
if account_id is not None:
|
||||
stmt = stmt.where(Recurrence.account_id == account_id)
|
||||
if recurrence_id is not None:
|
||||
stmt = stmt.where(Recurrence.id == recurrence_id)
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
async def load_overlays(
|
||||
session: AsyncSession,
|
||||
recurrence_ids: Sequence[int],
|
||||
date_from: date,
|
||||
date_to: date,
|
||||
) -> dict[int, list[Occurrence]]:
|
||||
"""Materialisierte Fälligkeiten im Zeitfenster, nach Recurrence gruppiert.
|
||||
|
||||
Das Fenster wird großzügig gewählt, weil Ist-Datum und nominales Datum
|
||||
auseinanderliegen können.
|
||||
"""
|
||||
if not recurrence_ids:
|
||||
return {}
|
||||
stmt = select(Occurrence).where(
|
||||
Occurrence.recurrence_id.in_(recurrence_ids),
|
||||
Occurrence.occurrence_date >= date_from,
|
||||
Occurrence.occurrence_date <= date_to,
|
||||
)
|
||||
grouped: dict[int, list[Occurrence]] = {}
|
||||
for row in (await session.execute(stmt)).scalars():
|
||||
grouped.setdefault(row.recurrence_id, []).append(row)
|
||||
return grouped
|
||||
|
||||
|
||||
async def due_items(
|
||||
session: AsyncSession,
|
||||
date_from: date,
|
||||
date_to: date,
|
||||
*,
|
||||
by_due_date: bool = True,
|
||||
kind: EntryKind | None = None,
|
||||
only_active: bool = True,
|
||||
category_id: int | None = None,
|
||||
account_id: int | None = None,
|
||||
recurrence_id: int | None = None,
|
||||
) -> list[DueItem]:
|
||||
"""Expandiert alle passenden Posten über das Zeitfenster.
|
||||
|
||||
`by_due_date=True` gruppiert nach dem tatsächlichen Zahltag – die richtige
|
||||
Sicht für Kalender und Monatsauswertungen. `False` filtert nach dem nominalen
|
||||
Datum.
|
||||
"""
|
||||
recurrences = await load_recurrences(
|
||||
session,
|
||||
kind=kind,
|
||||
only_active=only_active,
|
||||
category_id=category_id,
|
||||
account_id=account_id,
|
||||
recurrence_id=recurrence_id,
|
||||
)
|
||||
if not recurrences:
|
||||
return []
|
||||
|
||||
# Overlays großzügig laden: eine Zahlung kann Wochen nach dem Soll erfasst werden.
|
||||
padding = _OVERLAY_PADDING_DAYS
|
||||
overlays = await load_overlays(
|
||||
session,
|
||||
[item.id for item in recurrences],
|
||||
date_from - padding,
|
||||
date_to + padding,
|
||||
)
|
||||
|
||||
expander = expand_by_due_date if by_due_date else expand
|
||||
items: list[DueItem] = []
|
||||
for recurrence in recurrences:
|
||||
planned = expander(
|
||||
recurrence,
|
||||
date_from,
|
||||
date_to,
|
||||
amount_versions=recurrence.amount_versions,
|
||||
occurrences=overlays.get(recurrence.id, []),
|
||||
)
|
||||
items.extend(DueItem(planned=entry, recurrence=recurrence) for entry in planned)
|
||||
|
||||
items.sort(key=lambda item: (item.planned.effective_date, item.recurrence.title))
|
||||
return items
|
||||
|
||||
|
||||
async def get_recurrence(session: AsyncSession, recurrence_id: int) -> Recurrence:
|
||||
stmt = (
|
||||
select(Recurrence)
|
||||
.options(selectinload(Recurrence.amount_versions))
|
||||
.where(Recurrence.id == recurrence_id)
|
||||
)
|
||||
recurrence = (await session.execute(stmt)).scalar_one_or_none()
|
||||
if recurrence is None:
|
||||
raise NotFoundError(
|
||||
f"Der wiederkehrende Posten mit der ID {recurrence_id} existiert nicht."
|
||||
)
|
||||
return recurrence
|
||||
|
||||
|
||||
async def _materialise(
|
||||
session: AsyncSession, recurrence: Recurrence, occurrence_date: date
|
||||
) -> Occurrence:
|
||||
"""Legt die Zeile für eine Fälligkeit an oder lädt die vorhandene.
|
||||
|
||||
Es werden nur Termine akzeptiert, die die Wiederholungsregel tatsächlich liefert –
|
||||
das nominale Datum ist der Schlüssel.
|
||||
"""
|
||||
matches = expand(
|
||||
recurrence,
|
||||
occurrence_date,
|
||||
occurrence_date,
|
||||
amount_versions=recurrence.amount_versions,
|
||||
)
|
||||
if not matches:
|
||||
raise ValidationError(
|
||||
f"Zum {occurrence_date.isoformat()} gibt es für '{recurrence.title}' keine Fälligkeit.",
|
||||
code="occurrence_not_due",
|
||||
)
|
||||
|
||||
stmt = select(Occurrence).where(
|
||||
Occurrence.recurrence_id == recurrence.id,
|
||||
Occurrence.occurrence_date == occurrence_date,
|
||||
)
|
||||
existing = (await session.execute(stmt)).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
occurrence = Occurrence(
|
||||
recurrence_id=recurrence.id,
|
||||
occurrence_date=occurrence_date,
|
||||
status=OccurrenceStatus.PLANNED,
|
||||
planned_amount=matches[0].amount,
|
||||
)
|
||||
session.add(occurrence)
|
||||
await session.flush()
|
||||
return occurrence
|
||||
|
||||
|
||||
async def confirm(
|
||||
session: AsyncSession,
|
||||
recurrence_id: int,
|
||||
occurrence_date: date,
|
||||
*,
|
||||
actual_amount: Decimal | None = None,
|
||||
actual_date: date | None = None,
|
||||
account_id: int | None = None,
|
||||
note: str | None = None,
|
||||
) -> Occurrence:
|
||||
"""Bestätigt eine Fälligkeit, wahlweise mit abweichendem Betrag oder Datum."""
|
||||
recurrence = await get_recurrence(session, recurrence_id)
|
||||
occurrence = await _materialise(session, recurrence, occurrence_date)
|
||||
|
||||
occurrence.status = OccurrenceStatus.CONFIRMED
|
||||
occurrence.planned_amount = resolve_amount(
|
||||
recurrence, occurrence_date, recurrence.amount_versions
|
||||
)
|
||||
occurrence.actual_amount = (
|
||||
actual_amount if actual_amount is not None else occurrence.planned_amount
|
||||
)
|
||||
occurrence.actual_date = actual_date
|
||||
if account_id is not None:
|
||||
occurrence.account_id = account_id
|
||||
if note is not None:
|
||||
occurrence.note = note
|
||||
|
||||
await session.flush()
|
||||
return occurrence
|
||||
|
||||
|
||||
async def skip(
|
||||
session: AsyncSession,
|
||||
recurrence_id: int,
|
||||
occurrence_date: date,
|
||||
*,
|
||||
note: str | None = None,
|
||||
) -> Occurrence:
|
||||
"""Markiert eine Fälligkeit als ausgefallen."""
|
||||
recurrence = await get_recurrence(session, recurrence_id)
|
||||
occurrence = await _materialise(session, recurrence, occurrence_date)
|
||||
|
||||
occurrence.status = OccurrenceStatus.SKIPPED
|
||||
occurrence.actual_amount = None
|
||||
occurrence.actual_date = None
|
||||
if note is not None:
|
||||
occurrence.note = note
|
||||
|
||||
await session.flush()
|
||||
return occurrence
|
||||
|
||||
|
||||
async def reset(session: AsyncSession, recurrence_id: int, occurrence_date: date) -> None:
|
||||
"""Nimmt Bestätigung oder Auslassung zurück und entfernt die materialisierte Zeile."""
|
||||
stmt = select(Occurrence).where(
|
||||
Occurrence.recurrence_id == recurrence_id,
|
||||
Occurrence.occurrence_date == occurrence_date,
|
||||
)
|
||||
occurrence = (await session.execute(stmt)).scalar_one_or_none()
|
||||
if occurrence is None:
|
||||
raise NotFoundError(
|
||||
f"Zum {occurrence_date.isoformat()} ist keine abweichende Fälligkeit erfasst."
|
||||
)
|
||||
await session.delete(occurrence)
|
||||
await session.flush()
|
||||
@@ -17,12 +17,12 @@ from datetime import date, datetime, timedelta
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from functools import lru_cache
|
||||
from typing import Protocol, runtime_checkable
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import holidays
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from dateutil.rrule import rrulestr
|
||||
|
||||
from app.core.clock import today
|
||||
from app.models.enums import BusinessDayShift, EntryKind, OccurrenceStatus
|
||||
|
||||
ZERO = Decimal("0.00")
|
||||
@@ -247,7 +247,7 @@ def next_dates(
|
||||
after: date | None = None,
|
||||
) -> list[date]:
|
||||
"""Die nächsten `count` nominalen Termine nach `after` (einschließlich)."""
|
||||
start = after or _today()
|
||||
start = after or today()
|
||||
end = start
|
||||
results: list[date] = []
|
||||
# Fenster schrittweise vergrößern, bis genug Termine gefunden sind.
|
||||
@@ -391,7 +391,7 @@ def installments_remaining(
|
||||
if not total:
|
||||
return None
|
||||
|
||||
reference = as_of or _today()
|
||||
reference = as_of or today()
|
||||
schedule = expand(
|
||||
recurrence,
|
||||
recurrence.dtstart,
|
||||
@@ -437,7 +437,7 @@ def contract_term(recurrence: RecurrenceLike, as_of: date | None = None) -> Cont
|
||||
return None
|
||||
|
||||
start = recurrence.contract_start or recurrence.dtstart
|
||||
reference = as_of or _today()
|
||||
reference = as_of or today()
|
||||
|
||||
term_start = start
|
||||
term_end = start + relativedelta(months=recurrence.contract_min_term_months) - timedelta(days=1)
|
||||
@@ -492,7 +492,7 @@ def annual_burden(
|
||||
Bewusst kein fester Intervallfaktor: eine halbjährliche Zahlung, die im Fenster
|
||||
nur einmal fällt, ergibt auch nur eine Belastung.
|
||||
"""
|
||||
start = as_of or _today()
|
||||
start = as_of or today()
|
||||
end = start + relativedelta(years=1) - timedelta(days=1)
|
||||
schedule = expand(recurrence, start, end, amount_versions=amount_versions)
|
||||
return _sum(item.amount for item in schedule)
|
||||
@@ -518,11 +518,6 @@ def monthly_reserve(
|
||||
# --- Interne Helfer -----------------------------------------------------------
|
||||
|
||||
|
||||
def _today() -> date:
|
||||
"""Heutiges Datum in der fachlichen Zeitzone."""
|
||||
return datetime.now(ZoneInfo("Europe/Berlin")).date()
|
||||
|
||||
|
||||
def _as_datetime(day: date) -> datetime:
|
||||
"""dateutil rechnet intern mit datetime; die Uhrzeit ist fachlich bedeutungslos."""
|
||||
return datetime(day.year, day.month, day.day)
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Auswertungen. In dieser Phase die Monatsübersicht."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.clock import add_months, month_end, month_start
|
||||
from app.models import Category, Transaction
|
||||
from app.models.enums import EntryKind, OccurrenceStatus
|
||||
from app.services.occurrences import due_items, load_recurrences
|
||||
from app.services.recurrence import monthly_reserve
|
||||
|
||||
ZERO = Decimal("0.00")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Totals:
|
||||
"""Einnahmen, Ausgaben und Saldo einer Sicht."""
|
||||
|
||||
income: Decimal = ZERO
|
||||
expenses: Decimal = ZERO
|
||||
|
||||
@property
|
||||
def balance(self) -> Decimal:
|
||||
return self.income - self.expenses
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MonthReport:
|
||||
"""Kennzahlen eines Monats."""
|
||||
|
||||
month: date
|
||||
planned: Totals
|
||||
actual: Totals
|
||||
previous_planned: Totals
|
||||
previous_actual: Totals
|
||||
fixed_costs: Decimal = ZERO
|
||||
variable_costs: Decimal = ZERO
|
||||
reserves: Decimal = ZERO
|
||||
confirmed_count: int = 0
|
||||
open_count: int = 0
|
||||
skipped_count: int = 0
|
||||
categories: dict[int, Decimal] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def available_after_fixed(self) -> Decimal:
|
||||
"""Einkünfte abzüglich Fixkosten und Rücklagen – die große Kennzahl im Dashboard."""
|
||||
return self.planned.income - self.fixed_costs - self.reserves
|
||||
|
||||
@property
|
||||
def income_delta(self) -> Decimal:
|
||||
return self.planned.income - self.previous_planned.income
|
||||
|
||||
@property
|
||||
def expenses_delta(self) -> Decimal:
|
||||
return self.planned.expenses - self.previous_planned.expenses
|
||||
|
||||
@property
|
||||
def balance_delta(self) -> Decimal:
|
||||
return self.planned.balance - self.previous_planned.balance
|
||||
|
||||
|
||||
async def _fixed_cost_map(session: AsyncSession) -> dict[int, bool]:
|
||||
"""Kategorie-ID -> ist Fixkostenkategorie."""
|
||||
rows = await session.execute(select(Category.id, Category.is_fixed_cost))
|
||||
return dict(rows.all())
|
||||
|
||||
|
||||
async def _month_totals(
|
||||
session: AsyncSession, month: date, fixed_costs: dict[int, bool]
|
||||
) -> tuple[Totals, Totals, Decimal, Decimal, dict[int, int], dict[int, Decimal]]:
|
||||
"""Rechnet einen Monat aus Fälligkeiten und Buchungen zusammen."""
|
||||
start = month_start(month)
|
||||
end = month_end(month)
|
||||
|
||||
planned_income = planned_expenses = ZERO
|
||||
actual_income = actual_expenses = ZERO
|
||||
fixed = variable = ZERO
|
||||
counts = {"confirmed": 0, "open": 0, "skipped": 0}
|
||||
per_category: dict[int, Decimal] = {}
|
||||
|
||||
for item in await due_items(session, start, end):
|
||||
planned = item.planned
|
||||
if planned.status is OccurrenceStatus.SKIPPED:
|
||||
counts["skipped"] += 1
|
||||
continue
|
||||
if planned.status is OccurrenceStatus.CONFIRMED:
|
||||
counts["confirmed"] += 1
|
||||
else:
|
||||
counts["open"] += 1
|
||||
|
||||
if planned.kind is EntryKind.INCOME:
|
||||
planned_income += planned.amount
|
||||
if planned.status is OccurrenceStatus.CONFIRMED:
|
||||
actual_income += planned.effective_amount
|
||||
continue
|
||||
|
||||
planned_expenses += planned.amount
|
||||
if planned.status is OccurrenceStatus.CONFIRMED:
|
||||
actual_expenses += planned.effective_amount
|
||||
# Für die Fix/Variabel-Aufteilung zählt der beste bekannte Wert.
|
||||
betrag = planned.effective_amount
|
||||
if fixed_costs.get(item.recurrence.category_id, False):
|
||||
fixed += betrag
|
||||
else:
|
||||
variable += betrag
|
||||
per_category[item.recurrence.category_id] = (
|
||||
per_category.get(item.recurrence.category_id, ZERO) + betrag
|
||||
)
|
||||
|
||||
# Einmalige Buchungen sind immer Ist und zugleich Teil des Plans.
|
||||
stmt = select(Transaction).where(
|
||||
Transaction.booking_date >= start, Transaction.booking_date <= end
|
||||
)
|
||||
for transaction in (await session.execute(stmt)).scalars():
|
||||
if transaction.kind is EntryKind.INCOME:
|
||||
planned_income += transaction.amount
|
||||
actual_income += transaction.amount
|
||||
continue
|
||||
planned_expenses += transaction.amount
|
||||
actual_expenses += transaction.amount
|
||||
if fixed_costs.get(transaction.category_id, False):
|
||||
fixed += transaction.amount
|
||||
else:
|
||||
variable += transaction.amount
|
||||
per_category[transaction.category_id] = (
|
||||
per_category.get(transaction.category_id, ZERO) + transaction.amount
|
||||
)
|
||||
|
||||
return (
|
||||
Totals(income=planned_income, expenses=planned_expenses),
|
||||
Totals(income=actual_income, expenses=actual_expenses),
|
||||
fixed,
|
||||
variable,
|
||||
counts,
|
||||
per_category,
|
||||
)
|
||||
|
||||
|
||||
async def _reserve_total(session: AsyncSession, month: date) -> Decimal:
|
||||
"""Summe der monatlichen Rücklagen aller Posten mit aktivierter Rücklagenbildung."""
|
||||
total = ZERO
|
||||
for recurrence in await load_recurrences(session):
|
||||
if recurrence.reserve_enabled:
|
||||
total += monthly_reserve(
|
||||
recurrence, month_start(month), amount_versions=recurrence.amount_versions
|
||||
)
|
||||
return total
|
||||
|
||||
|
||||
async def month_report(session: AsyncSession, month: date) -> MonthReport:
|
||||
"""Monatsübersicht inklusive Vergleich zum Vormonat."""
|
||||
fixed_costs = await _fixed_cost_map(session)
|
||||
current = month_start(month)
|
||||
previous = add_months(current, -1)
|
||||
|
||||
planned, actual, fixed, variable, counts, per_category = await _month_totals(
|
||||
session, current, fixed_costs
|
||||
)
|
||||
previous_planned, previous_actual, *_ = await _month_totals(session, previous, fixed_costs)
|
||||
|
||||
return MonthReport(
|
||||
month=current,
|
||||
planned=planned,
|
||||
actual=actual,
|
||||
previous_planned=previous_planned,
|
||||
previous_actual=previous_actual,
|
||||
fixed_costs=fixed,
|
||||
variable_costs=variable,
|
||||
reserves=await _reserve_total(session, current),
|
||||
confirmed_count=counts["confirmed"],
|
||||
open_count=counts["open"],
|
||||
skipped_count=counts["skipped"],
|
||||
categories=per_category,
|
||||
)
|
||||
Reference in New Issue
Block a user