Files
moneyfy/backend/app/services/reports.py
T
moneyfyandClaude Opus 5 b586d27b77 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
2026-09-09 13:40:37 +02:00

179 lines
6.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
)