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
+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,
)