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