- 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
179 lines
6.0 KiB
Python
179 lines
6.0 KiB
Python
"""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,
|
||
)
|