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

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

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