feat(reports): Auswertungen, Kalender, Budgets und Export

Backend:
- Gemeinsame Bewegungsschicht flows(), auf der alle Berichte aufbauen; Plan und
  Ist bleiben dabei getrennt
- Forecast, Kategorien mit Drilldown, Abo-Übersicht, Jahresvergleich,
  Cashflow-Kalender, Budget-Ampel, Sparziel-Fortschritt, gebündeltes Dashboard
- Budgetübertrag über Monatsgrenzen, Budgets auf Oberkategorien schließen
  Unterkategorien ein
- Export als CSV (BOM, Semikolon, deutsches Dezimaltrennzeichen) und XLSX mit
  typisierten Beträgen

Frontend:
- Dashboard, Cashflow-Kalender mit Bestätigen direkt am Tag, Budget-, Sparziel-
  und Auswertungsseite
- Diagrammpalette gegen beide Flächen auf Kontrast und Farbfehlsichtigkeit
  geprüft; Grün/Rot als Serienpaar verworfen
- Einnahmen/Ausgaben und kumulierter Saldo in getrennten Diagrammen statt auf
  zwei Größenachsen
- Recharts in einen eigenen Chunk ausgelagert

27 neue Backend-Tests (235 gesamt), 16 neue Frontend-Tests (65 gesamt)

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 16:50:22 +02:00
co-authored by Claude Opus 5
parent 8d6fcfeb58
commit 0adf154049
31 changed files with 5721 additions and 121 deletions
+2
View File
@@ -12,6 +12,7 @@ from app.api.routes import (
auth,
budgets,
categories,
export,
logos,
me,
merchants,
@@ -43,5 +44,6 @@ protected.include_router(budgets.router)
protected.include_router(budgets.templates)
protected.include_router(savings_goals.router)
protected.include_router(reports.router)
protected.include_router(export.router)
api_router.include_router(protected)
+200
View File
@@ -0,0 +1,200 @@
"""Export von Buchungen, Posten und Monatsauswertung als CSV oder XLSX."""
from datetime import date
from typing import Literal
from fastapi import APIRouter, Query, Response, status
from sqlalchemy import select
from app.api.deps import DbSession
from app.core.clock import month_end, month_start, today
from app.core.errors import ValidationError
from app.models import Account, Merchant, Transaction
from app.models.enums import EntryKind
from app.schemas.common import ErrorResponse
from app.services.export import CONTENT_TYPES, filename, to_csv, to_xlsx
from app.services.reports import (
category_names,
flow_rows,
flows,
month_report,
recurrence_rows,
reserve_total,
)
router = APIRouter(prefix="/export", tags=["export"])
ExportFormat = Literal["csv", "xlsx"]
TRANSACTION_COLUMNS = (
"Datum",
"Titel",
"Richtung",
"Betrag",
"Kategorie",
"Firma",
"Konto",
"Notiz",
)
RECURRENCE_COLUMNS = (
"Titel",
"Richtung",
"Betrag",
"Wiederholung",
"Beginn",
"Ende",
"Kategorie",
"Firma",
"Jahreskosten",
"Raten",
"Gekündigt zum",
"Aktiv",
)
FLOW_COLUMNS = ("Datum", "Titel", "Kategorie", "Richtung", "Betrag", "Herkunft", "Bestätigt")
SUMMARY_COLUMNS = ("Kennzahl", "Betrag")
# Starlette benennt die 422-Konstante gerade um fester Wert vermeidet die Abhängigkeit.
BAD_REQUEST = {422: {"model": ErrorResponse}}
def _download(content: bytes, name: str, fmt: ExportFormat) -> Response:
"""Antwort mit passendem Typ und Dateinamen."""
return Response(
content=content,
media_type=CONTENT_TYPES[fmt],
headers={"Content-Disposition": f'attachment; filename="{name}"'},
)
@router.get(
"/transactions",
responses={
status.HTTP_200_OK: {
"content": {"text/csv": {}, CONTENT_TYPES["xlsx"]: {}},
"description": "Die Buchungen als Datei.",
},
**BAD_REQUEST,
},
summary="Buchungen exportieren",
description="Ohne Zeitraum wird das laufende Jahr ausgegeben.",
)
async def export_transactions(
session: DbSession,
fmt: ExportFormat = Query(default="csv", alias="format"),
date_from: date | None = Query(default=None, alias="from"),
date_to: date | None = Query(default=None, alias="to"),
) -> Response:
heute = today()
start = date_from or date(heute.year, 1, 1)
ende = date_to or date(heute.year, 12, 31)
if ende < start:
raise ValidationError("'to' darf nicht vor 'from' liegen.", code="invalid_date_range")
namen = await category_names(session)
firmen = {firma.id: firma.name for firma in (await session.execute(select(Merchant))).scalars()}
konten = await _account_names(session)
stmt = (
select(Transaction)
.where(Transaction.booking_date >= start, Transaction.booking_date <= ende)
.order_by(Transaction.booking_date, Transaction.id)
)
zeilen = [
{
"Datum": buchung.booking_date.isoformat(),
"Titel": buchung.title,
"Richtung": "Einkunft" if buchung.kind is EntryKind.INCOME else "Ausgabe",
"Betrag": buchung.amount,
"Kategorie": namen.get(buchung.category_id, ""),
"Firma": firmen.get(buchung.merchant_id or -1, ""),
"Konto": konten.get(buchung.account_id, ""),
"Notiz": buchung.note or "",
}
for buchung in (await session.execute(stmt)).scalars()
]
zeitraum = f"{start.isoformat()}_{ende.isoformat()}"
if fmt == "csv":
return _download(
to_csv(zeilen, TRANSACTION_COLUMNS), filename("buchungen", "csv", zeitraum), fmt
)
return _download(
to_xlsx({"Buchungen": zeilen}, {"Buchungen": TRANSACTION_COLUMNS}),
filename("buchungen", "xlsx", zeitraum),
fmt,
)
@router.get(
"/recurrences",
responses={status.HTTP_200_OK: {"description": "Die Posten als Datei."}},
summary="Wiederkehrende Posten exportieren",
)
async def export_recurrences(
session: DbSession,
fmt: ExportFormat = Query(default="csv", alias="format"),
) -> Response:
zeilen = await recurrence_rows(session)
if fmt == "csv":
return _download(to_csv(zeilen, RECURRENCE_COLUMNS), filename("posten", "csv"), fmt)
return _download(
to_xlsx({"Posten": zeilen}, {"Posten": RECURRENCE_COLUMNS}),
filename("posten", "xlsx"),
fmt,
)
@router.get(
"/month",
responses={status.HTTP_200_OK: {"description": "Die Monatsauswertung als Datei."}},
summary="Monatsauswertung exportieren",
description="Enthält alle Bewegungen des Monats und eine Kennzahlenübersicht. "
"Im XLSX-Format stehen beide auf getrennten Blättern.",
)
async def export_month(
session: DbSession,
fmt: ExportFormat = Query(default="xlsx", alias="format"),
month: date | None = Query(default=None, description="Beliebiger Tag im Monat."),
) -> Response:
monat = month_start(month or today())
namen = await category_names(session)
bewegungen = await flows(session, monat, month_end(monat))
zeilen = flow_rows(bewegungen, namen)
bericht = await month_report(session, monat)
kennzahlen: list[dict[str, object]] = [
{"Kennzahl": "Einnahmen (Plan)", "Betrag": bericht.planned.income},
{"Kennzahl": "Ausgaben (Plan)", "Betrag": bericht.planned.expenses},
{"Kennzahl": "Saldo (Plan)", "Betrag": bericht.planned.balance},
{"Kennzahl": "Einnahmen (Ist)", "Betrag": bericht.actual.income},
{"Kennzahl": "Ausgaben (Ist)", "Betrag": bericht.actual.expenses},
{"Kennzahl": "Saldo (Ist)", "Betrag": bericht.actual.balance},
{"Kennzahl": "Fixkosten", "Betrag": bericht.fixed_costs},
{"Kennzahl": "Variable Kosten", "Betrag": bericht.variable_costs},
{"Kennzahl": "Rücklagen", "Betrag": await reserve_total(session, monat)},
{"Kennzahl": "Verfügbar nach Fixkosten", "Betrag": bericht.available_after_fixed},
]
kennung = monat.strftime("%Y-%m")
if fmt == "csv":
# CSV kennt keine Blätter die Kennzahlen folgen nach einer Leerzeile.
inhalt = to_csv(zeilen, FLOW_COLUMNS)
inhalt += b"\r\n" + to_csv(kennzahlen, SUMMARY_COLUMNS).removeprefix("".encode())
return _download(inhalt, filename("monat", "csv", kennung), fmt)
return _download(
to_xlsx(
{"Bewegungen": zeilen, "Kennzahlen": kennzahlen},
{"Bewegungen": FLOW_COLUMNS, "Kennzahlen": SUMMARY_COLUMNS},
),
filename("monat", "xlsx", kennung),
fmt,
)
async def _account_names(session: DbSession) -> dict[int, str]:
return {konto.id: konto.name for konto in (await session.execute(select(Account))).scalars()}
+373 -18
View File
@@ -1,35 +1,70 @@
"""Auswertungen."""
from datetime import date
from datetime import date, timedelta
from decimal import Decimal
from fastapi import APIRouter, Query
from sqlalchemy import select
from app.api.deps import DbSession
from app.core.clock import today
from app.schemas.report import MonthComparisonOut, MonthReportOut, TotalsOut
from app.services.reports import Totals, month_report
from app.core.clock import month_end, month_start, today
from app.core.errors import ValidationError
from app.models import SavingsGoal
from app.models.enums import EntryKind
from app.schemas.recurrence import ContractTermOut
from app.schemas.report import (
BudgetStatusOut,
CalendarDayOut,
CalendarEntryOut,
CalendarMonthOut,
CategoryReportOut,
CategorySliceOut,
DashboardOut,
ForecastMonthOut,
ForecastOut,
MonthComparisonOut,
MonthReportOut,
SavingsGoalProgressOut,
SubscriptionOut,
SubscriptionReportOut,
TotalsOut,
YearComparisonOut,
YearComparisonRowOut,
)
from app.services.balances import total_balance
from app.services.reports import (
BudgetStatus,
CategorySlice,
FlowEntry,
ForecastMonth,
MonthReport,
SubscriptionEntry,
Totals,
budget_status,
calendar_month,
category_breakdown,
flows,
forecast,
month_report,
months_between,
required_monthly_rate,
subscriptions,
year_comparison,
)
router = APIRouter(prefix="/reports", tags=["reports"])
MAX_FORECAST_MONTHS = 60
# --- Umsetzung in Schemata -----------------------------------------------------
def _totals(value: Totals) -> TotalsOut:
return TotalsOut(income=value.income, expenses=value.expenses, balance=value.balance)
@router.get(
"/month",
response_model=MonthReportOut,
summary="Monatsübersicht",
description="Einnahmen, Ausgaben, Saldo, Plan-Ist-Vergleich, Aufteilung in fixe "
"und variable Kosten sowie die Veränderung gegenüber dem Vormonat.",
)
async def read_month_report(
session: DbSession,
month: date | None = Query(
default=None, description="Beliebiger Tag im gewünschten Monat; Vorgabe ist heute."
),
) -> MonthReportOut:
report = await month_report(session, month or today())
def _month(report: MonthReport) -> MonthReportOut:
return MonthReportOut(
month=report.month,
planned=_totals(report.planned),
@@ -49,3 +84,323 @@ async def read_month_report(
open_count=report.open_count,
skipped_count=report.skipped_count,
)
def _forecast_month(entry: ForecastMonth) -> ForecastMonthOut:
return ForecastMonthOut(
month=entry.month,
income=entry.income,
expenses=entry.expenses,
balance=entry.balance,
cumulative_balance=entry.cumulative_balance,
)
def _slice(value: CategorySlice) -> CategorySliceOut:
return CategorySliceOut(
category_id=value.category_id,
name=value.name,
color=value.color,
icon=value.icon,
amount=value.amount,
count=value.count,
children=[_slice(kind) for kind in value.children],
)
def _subscription(entry: SubscriptionEntry) -> SubscriptionOut:
return SubscriptionOut(
recurrence_id=entry.recurrence_id,
title=entry.title,
merchant_id=entry.merchant_id,
merchant_name=entry.merchant_name,
category_id=entry.category_id,
amount=entry.amount,
annual_cost=entry.annual_cost,
monthly_cost=entry.monthly_cost,
rrule=entry.rrule,
is_installment=entry.is_installment,
is_cancelled=entry.is_cancelled,
contract_term=ContractTermOut.model_validate(entry.term) if entry.term else None,
days_until_notice=entry.days_until_notice,
)
def _budget(entry: BudgetStatus) -> BudgetStatusOut:
return BudgetStatusOut(
category_id=entry.category_id,
category_name=entry.category_name,
color=entry.color,
period_month=entry.period_month,
limit_amount=entry.limit_amount,
carried_over=entry.carried_over,
available=entry.available,
spent=entry.spent,
remaining=entry.remaining,
ratio=float(entry.ratio),
state=entry.state,
rollover=entry.rollover,
is_template=entry.is_template,
)
def _entry(flow: FlowEntry) -> CalendarEntryOut:
return CalendarEntryOut(
title=flow.title,
kind=flow.kind,
amount=flow.amount,
category_id=flow.category_id,
merchant_id=flow.merchant_id,
account_id=flow.account_id,
source=flow.source,
recurrence_id=flow.recurrence_id,
occurrence_date=flow.occurrence_date,
status=flow.status,
is_variable=flow.is_variable,
)
async def _goals(session: DbSession, as_of: date) -> list[SavingsGoalProgressOut]:
"""Fortschritt aller nicht archivierten Sparziele."""
stmt = (
select(SavingsGoal)
.where(SavingsGoal.is_archived.is_(False))
.order_by(SavingsGoal.target_date.nulls_last(), SavingsGoal.name)
)
ergebnis: list[SavingsGoalProgressOut] = []
for ziel in (await session.execute(stmt)).scalars():
offen = max(ziel.target_amount - ziel.current_amount, 0)
noetig = required_monthly_rate(
ziel.target_amount, ziel.current_amount, ziel.target_date, as_of
)
monate = months_between(as_of, ziel.target_date) if ziel.target_date else None
ergebnis.append(
SavingsGoalProgressOut(
goal_id=ziel.id,
name=ziel.name,
color=ziel.color,
icon=ziel.icon,
target_amount=ziel.target_amount,
current_amount=ziel.current_amount,
remaining_amount=offen,
ratio=(
float(ziel.current_amount / ziel.target_amount)
if ziel.target_amount > 0
else 0.0
),
target_date=ziel.target_date,
months_left=monate,
required_monthly=noetig,
monthly_contribution=ziel.monthly_contribution,
is_on_track=(
None
if noetig is None or ziel.monthly_contribution is None
else ziel.monthly_contribution >= noetig
),
)
)
return ergebnis
# --- Endpunkte -----------------------------------------------------------------
@router.get(
"/month",
response_model=MonthReportOut,
summary="Monatsübersicht",
description="Einnahmen, Ausgaben, Saldo, Plan-Ist-Vergleich, Aufteilung in fixe "
"und variable Kosten sowie die Veränderung gegenüber dem Vormonat.",
)
async def read_month_report(
session: DbSession,
month: date | None = Query(
default=None, description="Beliebiger Tag im gewünschten Monat; Vorgabe ist heute."
),
) -> MonthReportOut:
return _month(await month_report(session, month or today()))
@router.get(
"/forecast",
response_model=ForecastOut,
summary="Vorschau über mehrere Monate",
description="Jährliche Posten erscheinen in ihrem echten Fälligkeitsmonat, weil "
"die Reihe aus der tatsächlichen Expansion entsteht.",
)
async def read_forecast(
session: DbSession,
months: int = Query(default=12, ge=1, le=MAX_FORECAST_MONTHS),
start: date | None = Query(default=None, description="Erster Monat; Vorgabe ist heute."),
) -> ForecastOut:
monate = await forecast(session, months, start)
return ForecastOut(
months=[_forecast_month(monat) for monat in monate],
total_income=sum((monat.income for monat in monate), Decimal("0.00")),
total_expenses=sum((monat.expenses for monat in monate), Decimal("0.00")),
)
@router.get(
"/categories",
response_model=CategoryReportOut,
summary="Aufteilung nach Kategorien",
description="Summen je Oberkategorie mit Drilldown auf die Unterkategorien.",
)
async def read_categories(
session: DbSession,
date_from: date | None = Query(default=None, alias="from"),
date_to: date | None = Query(default=None, alias="to"),
kind: EntryKind = Query(default=EntryKind.EXPENSE),
) -> CategoryReportOut:
heute = today()
start = date_from or month_start(heute)
ende = date_to or month_end(heute)
if ende < start:
raise ValidationError("'to' darf nicht vor 'from' liegen.", code="invalid_date_range")
gruppen = await category_breakdown(session, start, ende, kind)
return CategoryReportOut(
date_from=start,
date_to=ende,
kind=kind,
total=sum((gruppe.amount for gruppe in gruppen), Decimal("0.00")),
categories=[_slice(gruppe) for gruppe in gruppen],
)
@router.get(
"/subscriptions",
response_model=SubscriptionReportOut,
summary="Abo-Übersicht",
description="Alle laufenden Ausgabenposten mit Jahreskosten. Ratenzahlungen sind "
"gekennzeichnet und zählen nicht in die Gesamtsumme.",
)
async def read_subscriptions(session: DbSession) -> SubscriptionReportOut:
bericht = await subscriptions(session)
return SubscriptionReportOut(
entries=[_subscription(eintrag) for eintrag in bericht.entries],
total_annual=bericht.total_annual,
total_monthly=bericht.total_monthly,
upcoming_deadlines=[_subscription(eintrag) for eintrag in bericht.upcoming_deadlines],
)
@router.get(
"/year-comparison",
response_model=YearComparisonOut,
summary="Jahresvergleich",
description="Aktuelles Jahr gegen Vorjahr, aufgeschlüsselt nach Oberkategorie.",
)
async def read_year_comparison(
session: DbSession,
year: int | None = Query(default=None, ge=1970, le=2200),
kind: EntryKind = Query(default=EntryKind.EXPENSE),
) -> YearComparisonOut:
vergleich = await year_comparison(session, year or today().year, kind)
return YearComparisonOut(
year=vergleich.year,
rows=[
YearComparisonRowOut(
category_id=zeile.category_id,
name=zeile.name,
color=zeile.color,
current=zeile.current,
previous=zeile.previous,
delta=zeile.delta,
)
for zeile in vergleich.rows
],
current_total=vergleich.current_total,
previous_total=vergleich.previous_total,
)
@router.get(
"/calendar",
response_model=CalendarMonthOut,
summary="Cashflow-Kalender",
description="Monatsraster mit den Fälligkeiten je Tag und dem laufenden Kontostand.",
)
async def read_calendar(
session: DbSession,
month: date | None = Query(default=None, description="Beliebiger Tag im Monat."),
) -> CalendarMonthOut:
raster = await calendar_month(session, month or today())
return CalendarMonthOut(
month=raster.month,
days=[
CalendarDayOut(
date=tag.on,
entries=[_entry(eintrag) for eintrag in tag.entries],
net=tag.net,
running_balance=tag.running_balance,
is_business_day=tag.is_business_day,
)
for tag in raster.days
],
opening_balance=raster.opening_balance,
closing_balance=raster.closing_balance,
lowest_balance=raster.lowest_balance,
lowest_balance_on=raster.lowest_balance_on,
)
@router.get(
"/budgets",
response_model=list[BudgetStatusOut],
summary="Budget-Ampel",
description="Verbrauch je Budget im Monat. Grün unter 80 %, gelb unter 100 %, "
"darüber rot. Budgets auf einer Oberkategorie umfassen deren Unterkategorien.",
)
async def read_budget_status(
session: DbSession,
month: date | None = Query(default=None),
) -> list[BudgetStatusOut]:
return [_budget(eintrag) for eintrag in await budget_status(session, month or today())]
@router.get(
"/savings-goals",
response_model=list[SavingsGoalProgressOut],
summary="Fortschritt der Sparziele",
description="Fortschritt und die bis zum Zieldatum nötige Monatsrate.",
)
async def read_goal_progress(session: DbSession) -> list[SavingsGoalProgressOut]:
return await _goals(session, today())
@router.get(
"/dashboard",
response_model=DashboardOut,
summary="Dashboard",
description="Bündelt Monatsübersicht, Vorschau, Kategorien, Budgets, Sparziele "
"und die nächsten Fälligkeiten in einem Aufruf.",
)
async def read_dashboard(
session: DbSession,
month: date | None = Query(default=None),
) -> DashboardOut:
heute = today()
monat = month_start(month or heute)
bericht = await month_report(session, monat)
vorschau = await forecast(session, 12, monat)
gruppen = await category_breakdown(session, monat, month_end(monat))
abos = await subscriptions(session)
# Die nächsten zwei Wochen ab heute, unabhängig vom betrachteten Monat.
naechste = await flows(session, heute, heute + timedelta(days=14))
return DashboardOut(
month=_month(bericht),
total_balance=await total_balance(session, heute),
forecast=[_forecast_month(eintrag) for eintrag in vorschau],
categories=[_slice(gruppe) for gruppe in gruppen],
budgets=[_budget(eintrag) for eintrag in await budget_status(session, monat)],
goals=await _goals(session, heute),
upcoming=[_entry(eintrag) for eintrag in naechste[:20]],
upcoming_deadlines=[_subscription(eintrag) for eintrag in abos.upcoming_deadlines],
)
+177
View File
@@ -4,7 +4,9 @@ from datetime import date
from pydantic import Field
from app.models.enums import EntryKind, OccurrenceStatus
from app.schemas.common import ApiModel, Money
from app.schemas.recurrence import ContractTermOut
class TotalsOut(ApiModel):
@@ -43,3 +45,178 @@ class MonthReportOut(ApiModel):
confirmed_count: int
open_count: int
skipped_count: int
class ForecastMonthOut(ApiModel):
"""Ein Monat der Vorschau."""
month: date
income: Money
expenses: Money
balance: Money
cumulative_balance: Money = Field(
description="Prognostizierter Kontostand am Monatsende über alle Konten."
)
class ForecastOut(ApiModel):
months: list[ForecastMonthOut]
total_income: Money
total_expenses: Money
class CategorySliceOut(ApiModel):
"""Summe einer Kategorie; Oberkategorien tragen ihre Unterkategorien."""
category_id: int
name: str
color: str
icon: str
amount: Money
count: int
children: list["CategorySliceOut"] = Field(default_factory=list)
class CategoryReportOut(ApiModel):
date_from: date
date_to: date
kind: EntryKind
total: Money
categories: list[CategorySliceOut]
class SubscriptionOut(ApiModel):
"""Ein laufender Posten mit Jahreskosten."""
recurrence_id: int
title: str
merchant_id: int | None
merchant_name: str | None
category_id: int
amount: Money
annual_cost: Money
monthly_cost: Money
rrule: str
is_installment: bool = Field(
description="Ratenzahlungen zählen nicht in die Summe „Abos gesamt p. a.“."
)
is_cancelled: bool
contract_term: ContractTermOut | None = None
days_until_notice: int | None = Field(
default=None, description="Tage bis zum letzten Kündigungstermin."
)
class SubscriptionReportOut(ApiModel):
entries: list[SubscriptionOut]
total_annual: Money = Field(description="Summe über alle Posten ohne Ratenzahlungen.")
total_monthly: Money
upcoming_deadlines: list[SubscriptionOut] = Field(
description="Kündigungsfristen, die in den nächsten 60 Tagen ablaufen."
)
class YearComparisonRowOut(ApiModel):
category_id: int
name: str
color: str
current: Money
previous: Money
delta: Money
class YearComparisonOut(ApiModel):
year: int
rows: list[YearComparisonRowOut]
current_total: Money
previous_total: Money
class CalendarEntryOut(ApiModel):
"""Ein fälliger Posten im Kalender."""
title: str
kind: EntryKind
amount: Money
category_id: int
merchant_id: int | None
account_id: int | None
source: str = Field(description="'recurrence' oder 'transaction'.")
recurrence_id: int | None = None
occurrence_date: date | None = Field(
default=None, description="Nominales Datum Schlüssel für Bestätigen und Auslassen."
)
status: OccurrenceStatus | None = None
is_variable: bool = False
class CalendarDayOut(ApiModel):
date: date
entries: list[CalendarEntryOut]
net: Money = Field(description="Saldo des Tages, Ausgaben negativ.")
running_balance: Money
is_business_day: bool
class CalendarMonthOut(ApiModel):
month: date
days: list[CalendarDayOut]
opening_balance: Money
closing_balance: Money
lowest_balance: Money
lowest_balance_on: date | None
class BudgetStatusOut(ApiModel):
"""Stand eines Budgets samt Ampel."""
category_id: int
category_name: str
color: str
period_month: date
limit_amount: Money
carried_over: Money = Field(description="Übertrag aus Vormonaten bei aktivem Rollover.")
available: Money = Field(description="Limit zuzüglich Übertrag.")
spent: Money
remaining: Money
ratio: float = Field(description="Verbrauchsanteil; 1.0 entspricht 100 %.")
state: str = Field(description="'ok' unter 80 %, 'warning' unter 100 %, sonst 'exceeded'.")
rollover: bool
is_template: bool
class SavingsGoalProgressOut(ApiModel):
"""Fortschritt eines Sparziels."""
goal_id: int
name: str
color: str
icon: str
target_amount: Money
current_amount: Money
remaining_amount: Money
ratio: float
target_date: date | None
months_left: int | None
required_monthly: Money | None = Field(
default=None, description="Nötige Rate bis zum Zieldatum."
)
monthly_contribution: Money | None
is_on_track: bool | None = Field(
default=None, description="Reicht die geplante Rate bis zum Zieldatum?"
)
class DashboardOut(ApiModel):
"""Alles, was das Dashboard in einem Aufruf braucht."""
month: MonthReportOut
total_balance: Money
forecast: list[ForecastMonthOut]
categories: list[CategorySliceOut]
budgets: list[BudgetStatusOut]
goals: list[SavingsGoalProgressOut]
upcoming: list[CalendarEntryOut] = Field(
description="Die nächsten Fälligkeiten der kommenden 14 Tage."
)
upcoming_deadlines: list[SubscriptionOut]
+121
View File
@@ -0,0 +1,121 @@
"""Export als CSV und XLSX.
CSV verwendet Semikolon als Trennzeichen, Komma als Dezimaltrennzeichen und
UTF-8 mit BOM so öffnet Excel die Datei in deutscher Umgebung ohne Nachfrage.
"""
import csv
import io
from collections.abc import Sequence
from datetime import date
from decimal import Decimal
from typing import Any
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
# Excel erkennt UTF-8 in CSV nur zuverlässig anhand der Byte Order Mark.
BOM = ""
CSV_DELIMITER = ";"
# Deutsches Zahlenformat mit Tausenderpunkt und zwei Nachkommastellen.
XLSX_MONEY_FORMAT = '#,##0.00\\ ""'
XLSX_HEADER_FILL = PatternFill("solid", fgColor="1F2937")
def _format_value(value: Any) -> str:
"""Werte für CSV aufbereiten; Beträge in deutscher Schreibweise."""
if value is None:
return ""
if isinstance(value, Decimal):
return f"{value:.2f}".replace(".", ",")
if isinstance(value, date):
return value.isoformat()
return str(value)
def to_csv(rows: Sequence[dict[str, Any]], columns: Sequence[str] | None = None) -> bytes:
"""Erzeugt eine CSV-Datei aus Zeilen gleicher Struktur."""
spalten = list(columns or (rows[0].keys() if rows else []))
puffer = io.StringIO()
puffer.write(BOM)
schreiber = csv.writer(puffer, delimiter=CSV_DELIMITER, lineterminator="\r\n")
schreiber.writerow(spalten)
for zeile in rows:
schreiber.writerow([_format_value(zeile.get(spalte)) for spalte in spalten])
return puffer.getvalue().encode("utf-8")
def to_xlsx(
sheets: dict[str, Sequence[dict[str, Any]]],
columns: dict[str, Sequence[str]] | None = None,
) -> bytes:
"""Erzeugt eine Arbeitsmappe mit einem Blatt je Eintrag."""
mappe = Workbook()
mappe.remove(mappe.active)
for name, zeilen in sheets.items():
blatt = mappe.create_sheet(title=name[:31])
spalten = list((columns or {}).get(name) or (zeilen[0].keys() if zeilen else []))
if not spalten:
blatt["A1"] = "Keine Daten"
continue
blatt.append(spalten)
for zelle in blatt[1]:
zelle.font = Font(bold=True, color="FFFFFF")
zelle.fill = XLSX_HEADER_FILL
zelle.alignment = Alignment(vertical="center")
for zeile in zeilen:
blatt.append([_xlsx_value(zeile.get(spalte)) for spalte in spalten])
_style_columns(blatt, spalten, zeilen)
blatt.freeze_panes = "A2"
puffer = io.BytesIO()
mappe.save(puffer)
return puffer.getvalue()
def _xlsx_value(value: Any) -> Any:
"""Beträge und Daten bleiben typisiert, damit Excel damit rechnen kann."""
if isinstance(value, Decimal):
return float(value)
if isinstance(value, date):
return value
return value
def _style_columns(blatt, spalten: Sequence[str], zeilen: Sequence[dict[str, Any]]) -> None:
"""Spaltenbreite nach Inhalt und Zahlenformat für Geldspalten."""
for index, spalte in enumerate(spalten, start=1):
buchstabe = get_column_letter(index)
breite = max(
len(str(spalte)),
*(len(_format_value(zeile.get(spalte))) for zeile in zeilen[:200] or [{}]),
)
blatt.column_dimensions[buchstabe].width = min(max(breite + 3, 10), 42)
ist_geld = any(isinstance(zeile.get(spalte), Decimal) for zeile in zeilen[:50])
if not ist_geld:
continue
for zelle in blatt[buchstabe][1:]:
zelle.number_format = XLSX_MONEY_FORMAT
CONTENT_TYPES = {
"csv": "text/csv; charset=utf-8",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
}
def filename(base: str, fmt: str, suffix: str | None = None) -> str:
"""Dateiname für den Download, etwa `moneyfy-buchungen-2026-03.xlsx`."""
teile = ["moneyfy", base]
if suffix:
teile.append(suffix)
return f"{'-'.join(teile)}.{fmt}"
+792 -100
View File
@@ -1,19 +1,74 @@
"""Auswertungen. In dieser Phase die Monatsübersicht."""
"""Auswertungen.
Alle Berichte bauen auf `flows()` auf: einer einheitlichen Liste aus expandierten
Fälligkeiten und einmaligen Buchungen. Dadurch rechnen Monatsübersicht, Forecast,
Kalender und Jahresvergleich nachweislich mit denselben Zahlen.
"""
from collections.abc import Iterable, Sequence
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
from datetime import date, timedelta
from decimal import ROUND_HALF_UP, Decimal
from typing import Literal
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.core.clock import add_months, month_end, month_start, today
from app.models import Budget, BudgetTemplate, Category, Merchant, Recurrence, Transaction
from app.models.enums import EntryKind, OccurrenceStatus
from app.services.balances import total_balance
from app.services.occurrences import due_items, load_recurrences
from app.services.recurrence import monthly_reserve
from app.services.recurrence import (
ContractTerm,
annual_burden,
contract_term,
is_business_day,
monthly_reserve,
)
ZERO = Decimal("0.00")
CENT = Decimal("0.01")
# Rücklaufweite für die Budgetübertragung begrenzt die Rekursion.
ROLLOVER_LOOKBACK_MONTHS = 12
FlowSource = Literal["recurrence", "transaction"]
@dataclass(frozen=True, slots=True)
class FlowEntry:
"""Eine Geldbewegung, unabhängig davon, ob sie aus einer Serie oder einer
einmaligen Buchung stammt."""
on: date
"""Tag, an dem das Geld fließt (Ist-Datum, sonst Zahltag)."""
kind: EntryKind
amount: Decimal
"""Bester bekannter Wert: Ist bei Bestätigung, sonst Soll. Immer positiv."""
planned_amount: Decimal
"""Sollbetrag laut Preishistorie. Bei einmaligen Buchungen gleich `amount`."""
title: str
category_id: int
account_id: int | None
merchant_id: int | None
source: FlowSource
is_fixed_cost: bool
recurrence_id: int | None = None
occurrence_date: date | None = None
status: OccurrenceStatus | None = None
is_variable: bool = False
installment_number: int | None = None
installments_total: int | None = None
@property
def is_confirmed(self) -> bool:
"""Einmalige Buchungen sind immer Ist, Fälligkeiten nur nach Bestätigung."""
return self.source == "transaction" or self.status is OccurrenceStatus.CONFIRMED
@property
def signed(self) -> Decimal:
return -self.amount if self.kind is EntryKind.EXPENSE else self.amount
@dataclass(frozen=True, slots=True)
@@ -28,6 +83,101 @@ class Totals:
return self.income - self.expenses
def totals_of(
entries: Iterable[FlowEntry],
*,
basis: Literal["planned", "effective"] = "planned",
only_confirmed: bool = False,
) -> Totals:
"""Summiert Einnahmen und Ausgaben einer Auswahl.
`basis="planned"` rechnet mit dem Soll, `basis="effective"` mit dem besten
bekannten Wert. Beides zusammen ergibt den Plan-Ist-Vergleich.
"""
income = expenses = ZERO
for entry in entries:
if only_confirmed and not entry.is_confirmed:
continue
betrag = entry.planned_amount if basis == "planned" else entry.amount
if entry.kind is EntryKind.INCOME:
income += betrag
else:
expenses += betrag
return Totals(income=income, expenses=expenses)
async def fixed_cost_map(session: AsyncSession) -> dict[int, bool]:
"""Kategorie-ID -> gehört zu den Fixkosten."""
rows = await session.execute(select(Category.id, Category.is_fixed_cost))
return dict(rows.all())
async def flows(
session: AsyncSession,
date_from: date,
date_to: date,
*,
fixed_costs: dict[int, bool] | None = None,
) -> list[FlowEntry]:
"""Alle Geldbewegungen eines Zeitraums, nach Datum sortiert.
Ausgelassene Fälligkeiten sind bereits herausgefiltert sie fließen nirgends
mehr ein.
"""
fix = fixed_costs if fixed_costs is not None else await fixed_cost_map(session)
ergebnis: list[FlowEntry] = []
for item in await due_items(session, date_from, date_to):
planned = item.planned
if planned.status is OccurrenceStatus.SKIPPED:
continue
ergebnis.append(
FlowEntry(
on=planned.effective_date,
kind=planned.kind,
amount=planned.effective_amount,
planned_amount=planned.amount,
title=item.recurrence.title,
category_id=item.recurrence.category_id,
account_id=planned.account_id,
merchant_id=item.recurrence.merchant_id,
source="recurrence",
is_fixed_cost=fix.get(item.recurrence.category_id, False),
recurrence_id=item.recurrence.id,
occurrence_date=planned.nominal_date,
status=planned.status,
is_variable=planned.is_variable,
installment_number=planned.installment_number,
installments_total=planned.installments_total,
)
)
stmt = select(Transaction).where(
Transaction.booking_date >= date_from, Transaction.booking_date <= date_to
)
for transaction in (await session.execute(stmt)).scalars():
ergebnis.append(
FlowEntry(
on=transaction.booking_date,
kind=transaction.kind,
amount=transaction.amount,
planned_amount=transaction.amount,
title=transaction.title,
category_id=transaction.category_id,
account_id=transaction.account_id,
merchant_id=transaction.merchant_id,
source="transaction",
is_fixed_cost=fix.get(transaction.category_id, False),
)
)
ergebnis.sort(key=lambda entry: (entry.on, entry.title))
return ergebnis
# --- Monatsübersicht -----------------------------------------------------------
@dataclass(slots=True)
class MonthReport:
"""Kennzahlen eines Monats."""
@@ -43,7 +193,6 @@ class MonthReport:
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:
@@ -63,84 +212,13 @@ class MonthReport:
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(
async def _month_flows(
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,
)
) -> list[FlowEntry]:
return await flows(session, month_start(month), month_end(month), fixed_costs=fixed_costs)
async def _reserve_total(session: AsyncSession, month: date) -> Decimal:
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):
@@ -153,26 +231,640 @@ async def _reserve_total(session: AsyncSession, month: date) -> Decimal:
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)
fix = await fixed_cost_map(session)
aktuell = month_start(month)
vormonat = add_months(aktuell, -1)
planned, actual, fixed, variable, counts, per_category = await _month_totals(
session, current, fixed_costs
bewegungen = await _month_flows(session, aktuell, fix)
vorherige = await _month_flows(session, vormonat, fix)
ausgaben = [entry for entry in bewegungen if entry.kind is EntryKind.EXPENSE]
fixkosten = sum((entry.amount for entry in ausgaben if entry.is_fixed_cost), ZERO)
variabel = sum((entry.amount for entry in ausgaben if not entry.is_fixed_cost), ZERO)
# Ausgelassene Fälligkeiten fehlen in `flows` und werden separat gezählt.
ausgelassen = sum(
1
for item in await due_items(session, month_start(month), month_end(month))
if item.planned.status is OccurrenceStatus.SKIPPED
)
previous_planned, previous_actual, *_ = await _month_totals(session, previous, fixed_costs)
serien = [entry for entry in bewegungen if entry.source == "recurrence"]
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,
month=aktuell,
planned=totals_of(bewegungen),
actual=totals_of(bewegungen, basis="effective", only_confirmed=True),
previous_planned=totals_of(vorherige),
previous_actual=totals_of(vorherige, basis="effective", only_confirmed=True),
fixed_costs=fixkosten,
variable_costs=variabel,
reserves=await reserve_total(session, aktuell),
confirmed_count=sum(1 for entry in serien if entry.is_confirmed),
open_count=sum(1 for entry in serien if not entry.is_confirmed),
skipped_count=ausgelassen,
)
# --- Forecast ------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class ForecastMonth:
"""Ein Monat der Vorschau."""
month: date
income: Decimal
expenses: Decimal
cumulative_balance: Decimal
"""Prognostizierter Kontostand am Monatsende über alle Konten."""
@property
def balance(self) -> Decimal:
return self.income - self.expenses
async def forecast(
session: AsyncSession, months: int = 12, start: date | None = None
) -> list[ForecastMonth]:
"""Vorschau über mehrere Monate.
Jährliche Posten erscheinen in ihrem echten Fälligkeitsmonat, weil die Reihe
aus der tatsächlichen Expansion entsteht und nicht aus einem Durchschnitt.
"""
beginn = month_start(start or today())
fix = await fixed_cost_map(session)
# Ausgangspunkt ist der bestätigte Kontostand am Tag vor dem ersten Monat.
laufend = await total_balance(session, beginn - timedelta(days=1))
ergebnis: list[ForecastMonth] = []
for versatz in range(max(1, months)):
monat = add_months(beginn, versatz)
bewegungen = await flows(session, month_start(monat), month_end(monat), fixed_costs=fix)
summen = totals_of(bewegungen, basis="effective")
laufend += summen.balance
ergebnis.append(
ForecastMonth(
month=monat,
income=summen.income,
expenses=summen.expenses,
cumulative_balance=laufend,
)
)
return ergebnis
# --- Kategorien ----------------------------------------------------------------
@dataclass(slots=True)
class CategorySlice:
"""Summe einer Kategorie, optional mit Unterkategorien."""
category_id: int
name: str
color: str
icon: str
amount: Decimal
count: int
children: list["CategorySlice"] = field(default_factory=list)
async def category_breakdown(
session: AsyncSession,
date_from: date,
date_to: date,
kind: EntryKind = EntryKind.EXPENSE,
) -> list[CategorySlice]:
"""Summen je Oberkategorie mit Drilldown auf die Unterkategorien."""
kategorien = {
kategorie.id: kategorie for kategorie in (await session.execute(select(Category))).scalars()
}
bewegungen = [entry for entry in await flows(session, date_from, date_to) if entry.kind is kind]
summen: dict[int, Decimal] = {}
anzahl: dict[int, int] = {}
for entry in bewegungen:
summen[entry.category_id] = summen.get(entry.category_id, ZERO) + entry.amount
anzahl[entry.category_id] = anzahl.get(entry.category_id, 0) + 1
gruppen: dict[int, CategorySlice] = {}
for kategorie_id, betrag in summen.items():
kategorie = kategorien.get(kategorie_id)
if kategorie is None:
continue
wurzel_id = kategorie.parent_id or kategorie.id
wurzel = kategorien.get(wurzel_id)
if wurzel is None:
continue
gruppe = gruppen.get(wurzel_id)
if gruppe is None:
gruppe = CategorySlice(
category_id=wurzel.id,
name=wurzel.name,
color=wurzel.color,
icon=wurzel.icon,
amount=ZERO,
count=0,
)
gruppen[wurzel_id] = gruppe
gruppe.amount += betrag
gruppe.count += anzahl[kategorie_id]
gruppe.children.append(
CategorySlice(
category_id=kategorie.id,
name=kategorie.name,
color=kategorie.color,
icon=kategorie.icon,
amount=betrag,
count=anzahl[kategorie_id],
)
)
ergebnis = sorted(gruppen.values(), key=lambda gruppe: gruppe.amount, reverse=True)
for gruppe in ergebnis:
gruppe.children.sort(key=lambda kind_: kind_.amount, reverse=True)
return ergebnis
# --- Abo-Übersicht -------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class SubscriptionEntry:
"""Ein laufender Posten mit Jahreskosten und Vertragsdaten."""
recurrence_id: int
title: str
merchant_id: int | None
merchant_name: str | None
category_id: int
amount: Decimal
annual_cost: Decimal
monthly_cost: Decimal
rrule: str
is_installment: bool
term: ContractTerm | None
days_until_notice: int | None
is_cancelled: bool
@dataclass(slots=True)
class SubscriptionReport:
entries: list[SubscriptionEntry]
total_annual: Decimal
total_monthly: Decimal
"""Summe der Jahreskosten geteilt durch zwölf."""
upcoming_deadlines: list[SubscriptionEntry]
"""Verträge, deren Kündigungsfrist in den nächsten 60 Tagen abläuft."""
NOTICE_WARNING_DAYS = 60
async def subscriptions(session: AsyncSession, as_of: date | None = None) -> SubscriptionReport:
"""Alle laufenden Ausgaben-Posten mit Jahreskosten.
Als Abo gilt jeder aktive wiederkehrende Ausgabenposten. Ratenzahlungen sind
enthalten, aber als solche gekennzeichnet und zählen nicht in die Summe
„Abos gesamt p. a.“ ein Kredit ist keine dauerhafte Belastung.
"""
stichtag = as_of or today()
firmen = {firma.id: firma.name for firma in (await session.execute(select(Merchant))).scalars()}
eintraege: list[SubscriptionEntry] = []
for recurrence in await load_recurrences(session, kind=EntryKind.EXPENSE):
jahr = annual_burden(recurrence, stichtag, amount_versions=recurrence.amount_versions)
term = contract_term(recurrence, stichtag)
frist = term.notice_deadline if term and not term.is_cancelled else None
eintraege.append(
SubscriptionEntry(
recurrence_id=recurrence.id,
title=recurrence.title,
merchant_id=recurrence.merchant_id,
merchant_name=firmen.get(recurrence.merchant_id or -1),
category_id=recurrence.category_id,
amount=recurrence.amount,
annual_cost=jahr,
monthly_cost=(jahr / 12).quantize(CENT, rounding=ROUND_HALF_UP),
rrule=recurrence.rrule,
is_installment=recurrence.installments_total is not None,
term=term,
days_until_notice=(frist - stichtag).days if frist else None,
is_cancelled=recurrence.contract_cancelled_at is not None,
)
)
eintraege.sort(key=lambda eintrag: eintrag.annual_cost, reverse=True)
laufend = [eintrag for eintrag in eintraege if not eintrag.is_installment]
gesamt = sum((eintrag.annual_cost for eintrag in laufend), ZERO)
return SubscriptionReport(
entries=eintraege,
total_annual=gesamt,
total_monthly=(gesamt / 12).quantize(CENT, rounding=ROUND_HALF_UP),
upcoming_deadlines=sorted(
(
eintrag
for eintrag in eintraege
if eintrag.days_until_notice is not None
and 0 <= eintrag.days_until_notice <= NOTICE_WARNING_DAYS
),
key=lambda eintrag: eintrag.days_until_notice or 0,
),
)
# --- Jahresvergleich -----------------------------------------------------------
@dataclass(frozen=True, slots=True)
class YearComparisonRow:
category_id: int
name: str
color: str
current: Decimal
previous: Decimal
@property
def delta(self) -> Decimal:
return self.current - self.previous
@dataclass(slots=True)
class YearComparison:
year: int
rows: list[YearComparisonRow]
current_total: Decimal
previous_total: Decimal
async def year_comparison(
session: AsyncSession, year: int, kind: EntryKind = EntryKind.EXPENSE
) -> YearComparison:
"""Aktuelles Jahr gegen Vorjahr, aufgeschlüsselt nach Oberkategorie."""
kategorien = {
kategorie.id: kategorie for kategorie in (await session.execute(select(Category))).scalars()
}
async def summen(jahr: int) -> dict[int, Decimal]:
ergebnis: dict[int, Decimal] = {}
for entry in await flows(session, date(jahr, 1, 1), date(jahr, 12, 31)):
if entry.kind is not kind:
continue
kategorie = kategorien.get(entry.category_id)
if kategorie is None:
continue
wurzel = kategorie.parent_id or kategorie.id
ergebnis[wurzel] = ergebnis.get(wurzel, ZERO) + entry.amount
return ergebnis
aktuell = await summen(year)
vorher = await summen(year - 1)
zeilen: list[YearComparisonRow] = []
for kategorie_id in set(aktuell) | set(vorher):
kategorie = kategorien.get(kategorie_id)
if kategorie is None:
continue
zeilen.append(
YearComparisonRow(
category_id=kategorie_id,
name=kategorie.name,
color=kategorie.color,
current=aktuell.get(kategorie_id, ZERO),
previous=vorher.get(kategorie_id, ZERO),
)
)
zeilen.sort(key=lambda zeile: zeile.current, reverse=True)
return YearComparison(
year=year,
rows=zeilen,
current_total=sum((zeile.current for zeile in zeilen), ZERO),
previous_total=sum((zeile.previous for zeile in zeilen), ZERO),
)
# --- Cashflow-Kalender ---------------------------------------------------------
@dataclass(slots=True)
class CalendarDay:
"""Ein Tag im Monatsraster."""
on: date
entries: list[FlowEntry]
net: Decimal
running_balance: Decimal
is_business_day: bool
@dataclass(slots=True)
class CalendarMonth:
month: date
days: list[CalendarDay]
opening_balance: Decimal
closing_balance: Decimal
lowest_balance: Decimal
lowest_balance_on: date | None
async def calendar_month(
session: AsyncSession, month: date, *, holiday_region: str = "DE-NW"
) -> CalendarMonth:
"""Monatsraster mit den Fälligkeiten je Tag und dem laufenden Kontostand."""
beginn = month_start(month)
ende = month_end(month)
eroeffnung = await total_balance(session, beginn - timedelta(days=1))
bewegungen = await flows(session, beginn, ende)
nach_tag: dict[date, list[FlowEntry]] = {}
for entry in bewegungen:
nach_tag.setdefault(entry.on, []).append(entry)
laufend = eroeffnung
tiefstand = eroeffnung
tiefstand_am: date | None = None
tage: list[CalendarDay] = []
tag = beginn
while tag <= ende:
eintraege = nach_tag.get(tag, [])
netto = sum((entry.signed for entry in eintraege), ZERO)
laufend += netto
if laufend < tiefstand:
tiefstand = laufend
tiefstand_am = tag
tage.append(
CalendarDay(
on=tag,
entries=eintraege,
net=netto,
running_balance=laufend,
is_business_day=is_business_day(tag, holiday_region),
)
)
tag += timedelta(days=1)
return CalendarMonth(
month=beginn,
days=tage,
opening_balance=eroeffnung,
closing_balance=laufend,
lowest_balance=tiefstand,
lowest_balance_on=tiefstand_am,
)
# --- Budget-Ampel --------------------------------------------------------------
BudgetState = Literal["ok", "warning", "exceeded"]
# Schwellen der Ampel gemäß Fachspezifikation.
WARNING_RATIO = Decimal("0.8")
@dataclass(frozen=True, slots=True)
class BudgetStatus:
"""Stand eines Budgets im Monat."""
category_id: int
category_name: str
color: str
period_month: date
limit_amount: Decimal
carried_over: Decimal
"""Übertrag aus Vormonaten, falls `rollover` gesetzt ist."""
spent: Decimal
rollover: bool
is_template: bool
"""True, wenn der Wert aus einer Vorlage stammt und nicht aus einem Einzelbudget."""
@property
def available(self) -> Decimal:
return self.limit_amount + self.carried_over
@property
def remaining(self) -> Decimal:
return self.available - self.spent
@property
def ratio(self) -> Decimal:
if self.available <= ZERO:
return Decimal("1") if self.spent > ZERO else ZERO
return self.spent / self.available
@property
def state(self) -> BudgetState:
"""Grün unter 80 %, gelb unter 100 %, darüber rot."""
if self.ratio < WARNING_RATIO:
return "ok"
if self.ratio < Decimal("1"):
return "warning"
return "exceeded"
async def _effective_limits(
session: AsyncSession, month: date
) -> dict[int, tuple[Decimal, bool, bool]]:
"""Gültige Budgets eines Monats: Kategorie -> (Betrag, rollover, aus Vorlage).
Ein ausdrücklich gepflegtes Budget schlägt immer die Vorlage.
"""
monat = month_start(month)
ergebnis: dict[int, tuple[Decimal, bool, bool]] = {}
stmt = select(BudgetTemplate).where(BudgetTemplate.valid_from <= monat)
vorlagen = list((await session.execute(stmt)).scalars())
# Die jüngste passende Vorlage je Kategorie gewinnt.
vorlagen.sort(key=lambda vorlage: vorlage.valid_from)
for vorlage in vorlagen:
if vorlage.valid_until is not None and vorlage.valid_until < monat:
continue
ergebnis[vorlage.category_id] = (vorlage.limit_amount, vorlage.rollover, True)
stmt = select(Budget).where(Budget.period_month == monat)
for budget in (await session.execute(stmt)).scalars():
ergebnis[budget.category_id] = (budget.limit_amount, budget.rollover, False)
return ergebnis
async def _spent_by_category(session: AsyncSession, month: date) -> dict[int, Decimal]:
"""Ausgaben eines Monats je Kategorie, Unterkategorien auf die Oberkategorie gerollt."""
kategorien = {
kategorie.id: kategorie.parent_id
for kategorie in (await session.execute(select(Category))).scalars()
}
ergebnis: dict[int, Decimal] = {}
for entry in await flows(session, month_start(month), month_end(month)):
if entry.kind is not EntryKind.EXPENSE:
continue
# Ein Budget auf der Oberkategorie umfasst auch deren Unterkategorien.
ergebnis[entry.category_id] = ergebnis.get(entry.category_id, ZERO) + entry.amount
eltern = kategorien.get(entry.category_id)
if eltern is not None:
ergebnis[eltern] = ergebnis.get(eltern, ZERO) + entry.amount
return ergebnis
async def budget_status(session: AsyncSession, month: date) -> list[BudgetStatus]:
"""Budgets eines Monats samt Verbrauch und Übertrag."""
monat = month_start(month)
limits = await _effective_limits(session, monat)
if not limits:
return []
namen = {
kategorie.id: kategorie for kategorie in (await session.execute(select(Category))).scalars()
}
ausgaben = await _spent_by_category(session, monat)
ergebnis: list[BudgetStatus] = []
for kategorie_id, (limit, rollover, aus_vorlage) in limits.items():
kategorie = namen.get(kategorie_id)
if kategorie is None:
continue
uebertrag = await _carry_over(session, kategorie_id, monat) if rollover else ZERO
ergebnis.append(
BudgetStatus(
category_id=kategorie_id,
category_name=kategorie.name,
color=kategorie.color,
period_month=monat,
limit_amount=limit,
carried_over=uebertrag,
spent=ausgaben.get(kategorie_id, ZERO),
rollover=rollover,
is_template=aus_vorlage,
)
)
ergebnis.sort(key=lambda eintrag: eintrag.ratio, reverse=True)
return ergebnis
async def _carry_over(session: AsyncSession, category_id: int, month: date) -> Decimal:
"""Nicht verbrauchtes Budget aus den Vormonaten.
Es wird höchstens ein Jahr zurückgeschaut; ein Überschreiten setzt den
Übertrag wieder auf null, statt eine Schuld weiterzureichen.
"""
uebertrag = ZERO
for versatz in range(ROLLOVER_LOOKBACK_MONTHS, 0, -1):
vormonat = add_months(month, -versatz)
limits = await _effective_limits(session, vormonat)
eintrag = limits.get(category_id)
if eintrag is None:
uebertrag = ZERO
continue
limit, rollover, _ = eintrag
if not rollover:
uebertrag = ZERO
continue
ausgaben = (await _spent_by_category(session, vormonat)).get(category_id, ZERO)
uebertrag = max(limit + uebertrag - ausgaben, ZERO)
return uebertrag
# --- Sparziele -----------------------------------------------------------------
def months_between(start: date, end: date) -> int:
"""Volle Monate zwischen zwei Daten, mindestens null."""
return max((end.year - start.year) * 12 + end.month - start.month, 0)
def required_monthly_rate(
target_amount: Decimal, current_amount: Decimal, target_date: date | None, as_of: date
) -> Decimal | None:
"""Rate, die bis zum Zieldatum monatlich nötig ist. `None` ohne Zieldatum."""
if target_date is None:
return None
fehlend = target_amount - current_amount
if fehlend <= ZERO:
return ZERO
monate = months_between(as_of, target_date)
if monate <= 0:
return fehlend
return (fehlend / monate).quantize(CENT, rounding=ROUND_HALF_UP)
# --- Gemeinsame Helfer für den Export ------------------------------------------
def flow_rows(entries: Sequence[FlowEntry], names: dict[int, str]) -> list[dict[str, object]]:
"""Bereitet Bewegungen als Zeilen für den Export auf."""
return [
{
"Datum": entry.on.isoformat(),
"Titel": entry.title,
"Kategorie": names.get(entry.category_id, ""),
"Richtung": "Einkunft" if entry.kind is EntryKind.INCOME else "Ausgabe",
"Betrag": entry.amount,
"Herkunft": "Serie" if entry.source == "recurrence" else "Einmalig",
"Bestätigt": "ja" if entry.is_confirmed else "nein",
}
for entry in entries
]
async def category_names(session: AsyncSession) -> dict[int, str]:
"""Kategorie-ID -> „Oberkategorie · Unterkategorie“."""
kategorien = list((await session.execute(select(Category))).scalars())
nach_id = {kategorie.id: kategorie for kategorie in kategorien}
namen: dict[int, str] = {}
for kategorie in kategorien:
if kategorie.parent_id is None:
namen[kategorie.id] = kategorie.name
else:
eltern = nach_id.get(kategorie.parent_id)
namen[kategorie.id] = f"{eltern.name} · {kategorie.name}" if eltern else kategorie.name
return namen
async def recurrence_rows(
session: AsyncSession, as_of: date | None = None
) -> list[dict[str, object]]:
"""Wiederkehrende Posten als Exportzeilen."""
stichtag = as_of or today()
namen = await category_names(session)
firmen = {firma.id: firma.name for firma in (await session.execute(select(Merchant))).scalars()}
stmt = select(Recurrence).order_by(Recurrence.title)
zeilen: list[dict[str, object]] = []
for recurrence in (await session.execute(stmt)).scalars():
zeilen.append(
{
"Titel": recurrence.title,
"Richtung": "Einkunft" if recurrence.kind is EntryKind.INCOME else "Ausgabe",
"Betrag": recurrence.amount,
"Wiederholung": recurrence.rrule,
"Beginn": recurrence.dtstart.isoformat(),
"Ende": recurrence.until.isoformat() if recurrence.until else "",
"Kategorie": namen.get(recurrence.category_id, ""),
"Firma": firmen.get(recurrence.merchant_id or -1, ""),
"Jahreskosten": annual_burden(
recurrence, stichtag, amount_versions=recurrence.amount_versions
),
"Raten": recurrence.installments_total or "",
"Gekündigt zum": (
recurrence.contract_cancelled_at.isoformat()
if recurrence.contract_cancelled_at
else ""
),
"Aktiv": "ja" if recurrence.is_active else "nein",
}
)
return zeilen
+623
View File
@@ -0,0 +1,623 @@
"""Integrationstests der Auswertungen."""
import io
from httpx import AsyncClient
from openpyxl import load_workbook
async def posten(client: AsyncClient, seeded: dict, **overrides) -> dict:
payload = {
"kind": "expense",
"title": "Netflix",
"category_id": seeded["streaming"],
"account_id": seeded["account_id"],
"amount": "13.99",
"rrule": "FREQ=MONTHLY;BYMONTHDAY=15",
"dtstart": "2026-01-15",
"business_day_shift": "none",
}
payload.update(overrides)
antwort = await client.post("/api/recurrences", json=payload)
assert antwort.status_code == 201, antwort.text
return antwort.json()
async def haushalt(client: AsyncClient, seeded: dict) -> None:
"""Ein realistisches Grundgerüst: Gehalt, Miete, Abo, jährliche Versicherung."""
await posten(
client,
seeded,
title="Gehalt",
kind="income",
category_id=seeded["gehalt"],
amount="3200.00",
rrule="FREQ=MONTHLY;BYMONTHDAY=28",
dtstart="2026-01-28",
)
await posten(
client,
seeded,
title="Miete",
category_id=seeded["miete"],
amount="950.00",
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
dtstart="2026-01-01",
)
await posten(client, seeded)
await posten(
client,
seeded,
title="Kfz-Versicherung",
category_id=seeded["miete"],
amount="612.00",
rrule="FREQ=YEARLY;BYMONTH=7;BYMONTHDAY=1",
dtstart="2026-07-01",
reserve_enabled=True,
)
# --- Forecast ------------------------------------------------------------------
async def test_forecast_setzt_jaehrliche_posten_in_den_richtigen_monat(
auth_client: AsyncClient, seeded: dict
) -> None:
"""Akzeptanzkriterium: der jährliche Posten taucht nur im Juli auf."""
await haushalt(auth_client, seeded)
antwort = await auth_client.get(
"/api/reports/forecast", params={"months": 12, "start": "2026-01-01"}
)
assert antwort.status_code == 200
monate = antwort.json()["months"]
assert len(monate) == 12
assert monate[0]["month"] == "2026-01-01"
assert monate[11]["month"] == "2026-12-01"
nach_monat = {monat["month"]: monat for monat in monate}
# Januar bis Juni: Miete 950 + Netflix 13,99
assert nach_monat["2026-01-01"]["expenses"] == "963.99"
assert nach_monat["2026-06-01"]["expenses"] == "963.99"
# Juli zusätzlich die Jahresprämie
assert nach_monat["2026-07-01"]["expenses"] == "1575.99"
assert nach_monat["2026-08-01"]["expenses"] == "963.99"
async def test_forecast_summiert_den_kontostand_auf(auth_client: AsyncClient, seeded: dict) -> None:
await haushalt(auth_client, seeded)
monate = (
await auth_client.get("/api/reports/forecast", params={"months": 3, "start": "2026-01-01"})
).json()["months"]
# Eröffnungssaldo 1000 plus dem Saldo jedes Monats.
assert monate[0]["balance"] == "2236.01"
assert monate[0]["cumulative_balance"] == "3236.01"
assert monate[1]["cumulative_balance"] == "5472.02"
assert monate[2]["cumulative_balance"] == "7708.03"
async def test_forecast_ohne_daten_ist_flach(auth_client: AsyncClient, seeded: dict) -> None:
monate = (await auth_client.get("/api/reports/forecast", params={"months": 2})).json()["months"]
assert all(monat["income"] == "0.00" for monat in monate)
assert all(monat["cumulative_balance"] == "1000.00" for monat in monate)
# --- Kategorien ----------------------------------------------------------------
async def test_kategorien_mit_drilldown(auth_client: AsyncClient, seeded: dict) -> None:
await haushalt(auth_client, seeded)
bericht = (
await auth_client.get(
"/api/reports/categories", params={"from": "2026-03-01", "to": "2026-03-31"}
)
).json()
assert bericht["total"] == "963.99"
namen = {gruppe["name"]: gruppe for gruppe in bericht["categories"]}
assert namen["Wohnen"]["amount"] == "950.00"
# Der Drilldown zeigt die Unterkategorie.
assert [kind["name"] for kind in namen["Wohnen"]["children"]] == ["Miete"]
assert namen["Abos & Medien"]["amount"] == "13.99"
async def test_kategorien_koennen_einkuenfte_zeigen(auth_client: AsyncClient, seeded: dict) -> None:
await haushalt(auth_client, seeded)
bericht = (
await auth_client.get(
"/api/reports/categories",
params={"from": "2026-03-01", "to": "2026-03-31", "kind": "income"},
)
).json()
assert bericht["total"] == "3200.00"
assert bericht["categories"][0]["name"] == "Einkünfte"
async def test_kategorien_weisen_verdrehten_zeitraum_ab(
auth_client: AsyncClient, seeded: dict
) -> None:
antwort = await auth_client.get(
"/api/reports/categories", params={"from": "2026-03-31", "to": "2026-03-01"}
)
assert antwort.status_code == 422
assert antwort.json()["code"] == "invalid_date_range"
# --- Abo-Übersicht -------------------------------------------------------------
async def test_aboübersicht_mit_jahreskosten(auth_client: AsyncClient, seeded: dict) -> None:
await haushalt(auth_client, seeded)
bericht = (await auth_client.get("/api/reports/subscriptions")).json()
nach_titel = {eintrag["title"]: eintrag for eintrag in bericht["entries"]}
assert nach_titel["Miete"]["annual_cost"] == "11400.00"
assert nach_titel["Netflix"]["annual_cost"] == "167.88"
assert nach_titel["Kfz-Versicherung"]["annual_cost"] == "612.00"
assert nach_titel["Kfz-Versicherung"]["monthly_cost"] == "51.00"
# Absteigend nach Jahreskosten sortiert.
assert next(eintrag["title"] for eintrag in bericht["entries"]) == "Miete"
assert bericht["total_annual"] == "12179.88"
assert bericht["total_monthly"] == "1014.99"
async def test_raten_zaehlen_nicht_in_die_abosumme(auth_client: AsyncClient, seeded: dict) -> None:
await posten(auth_client, seeded)
await posten(
auth_client,
seeded,
title="Autokredit",
category_id=seeded["kredite"],
amount="250.00",
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
dtstart="2026-01-01",
installments_total=36,
)
bericht = (await auth_client.get("/api/reports/subscriptions")).json()
kredit = next(e for e in bericht["entries"] if e["title"] == "Autokredit")
assert kredit["is_installment"] is True
# Nur Netflix zählt in die Summe.
assert bericht["total_annual"] == "167.88"
async def test_ablaufende_kuendigungsfrist_wird_gemeldet(
auth_client: AsyncClient, seeded: dict
) -> None:
from datetime import date, timedelta
# Vertrag so legen, dass der Kündigungstermin in 30 Tagen liegt.
heute = date.today()
laufzeitende = heute + timedelta(days=30 + 90)
vertragsbeginn = laufzeitende + timedelta(days=1) - timedelta(days=365)
await posten(
auth_client,
seeded,
title="Handyvertrag",
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
dtstart=vertragsbeginn.isoformat(),
contract_start=vertragsbeginn.isoformat(),
contract_min_term_months=12,
contract_notice_period_days=90,
)
bericht = (await auth_client.get("/api/reports/subscriptions")).json()
fristen = {eintrag["title"] for eintrag in bericht["upcoming_deadlines"]}
assert "Handyvertrag" in fristen
eintrag = next(e for e in bericht["entries"] if e["title"] == "Handyvertrag")
assert 25 <= eintrag["days_until_notice"] <= 35
# --- Jahresvergleich -----------------------------------------------------------
async def test_jahresvergleich(auth_client: AsyncClient, seeded: dict) -> None:
# Ein Abo, das erst 2026 beginnt 2025 gibt es also nichts.
await posten(auth_client, seeded, amount="10.00")
bericht = (await auth_client.get("/api/reports/year-comparison", params={"year": 2026})).json()
assert bericht["year"] == 2026
zeile = next(z for z in bericht["rows"] if z["name"] == "Abos & Medien")
assert zeile["current"] == "120.00" # zwölf Monate à 10
assert zeile["previous"] == "0.00"
assert zeile["delta"] == "120.00"
# --- Kalender ------------------------------------------------------------------
async def test_kalender_zeigt_faelligkeiten_und_verlauf(
auth_client: AsyncClient, seeded: dict
) -> None:
await haushalt(auth_client, seeded)
kalender = (
await auth_client.get("/api/reports/calendar", params={"month": "2026-03-01"})
).json()
assert kalender["month"] == "2026-03-01"
assert len(kalender["days"]) == 31
nach_tag = {tag["date"]: tag for tag in kalender["days"]}
# Am 1. März ist die Miete fällig.
assert [eintrag["title"] for eintrag in nach_tag["2026-03-01"]["entries"]] == ["Miete"]
assert nach_tag["2026-03-01"]["net"] == "-950.00"
# Der 15. trägt Netflix, der 28. das Gehalt.
assert [eintrag["title"] for eintrag in nach_tag["2026-03-15"]["entries"]] == ["Netflix"]
assert [eintrag["title"] for eintrag in nach_tag["2026-03-28"]["entries"]] == ["Gehalt"]
# Der laufende Kontostand schreibt sich über den Monat fort.
# Eröffnung 1000, nach der Miete am 1. noch 50, nach dem Gehalt am 28. wieder hoch.
assert nach_tag["2026-03-01"]["running_balance"] == "50.00"
assert nach_tag["2026-03-15"]["running_balance"] == "36.01"
assert nach_tag["2026-03-28"]["running_balance"] == "3236.01"
assert kalender["closing_balance"] == nach_tag["2026-03-31"]["running_balance"]
assert kalender["lowest_balance_on"] is not None
async def test_kalender_kennzeichnet_werktage(auth_client: AsyncClient, seeded: dict) -> None:
kalender = (
await auth_client.get("/api/reports/calendar", params={"month": "2026-04-01"})
).json()
nach_tag = {tag["date"]: tag for tag in kalender["days"]}
assert nach_tag["2026-04-03"]["is_business_day"] is False # Karfreitag
assert nach_tag["2026-04-06"]["is_business_day"] is False # Ostermontag
assert nach_tag["2026-04-07"]["is_business_day"] is True
async def test_kalender_liefert_das_nominale_datum_mit(
auth_client: AsyncClient, seeded: dict
) -> None:
"""Der Kalender muss Bestätigen ermöglichen dafür braucht er den Schlüssel."""
eintrag = await posten(
auth_client,
seeded,
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
dtstart="2026-02-01",
business_day_shift="next",
)
kalender = (
await auth_client.get("/api/reports/calendar", params={"month": "2026-02-01"})
).json()
nach_tag = {tag["date"]: tag for tag in kalender["days"]}
# 01.02.2026 ist ein Sonntag; gezahlt wird am 02.02.
assert nach_tag["2026-02-01"]["entries"] == []
posten_eintrag = nach_tag["2026-02-02"]["entries"][0]
assert posten_eintrag["occurrence_date"] == "2026-02-01"
assert posten_eintrag["recurrence_id"] == eintrag["id"]
assert posten_eintrag["status"] == "planned"
# --- Budget-Ampel --------------------------------------------------------------
async def test_budget_ampel_schaltet_richtig(auth_client: AsyncClient, seeded: dict) -> None:
await auth_client.post(
"/api/budgets",
json={
"category_id": seeded["lebensmittel"],
"period_month": "2026-03-01",
"limit_amount": "400.00",
},
)
async def verbrauche(betrag: str) -> None:
await auth_client.post(
"/api/transactions",
json={
"kind": "expense",
"title": "Einkauf",
"category_id": seeded["lebensmittel"],
"account_id": seeded["account_id"],
"amount": betrag,
"booking_date": "2026-03-05",
},
)
async def status() -> dict:
antwort = await auth_client.get("/api/reports/budgets", params={"month": "2026-03-01"})
return antwort.json()[0]
await verbrauche("100.00")
assert (await status())["state"] == "ok" # 25 %
await verbrauche("219.00")
assert (await status())["state"] == "ok" # 79,75 %
await verbrauche("2.00")
aktuell = await status()
assert aktuell["state"] == "warning" # 80,25 %
assert aktuell["spent"] == "321.00"
await verbrauche("100.00")
ueberschritten = await status()
assert ueberschritten["state"] == "exceeded"
assert ueberschritten["remaining"] == "-21.00"
async def test_budget_auf_oberkategorie_umfasst_unterkategorien(
auth_client: AsyncClient, seeded: dict
) -> None:
baum = (await auth_client.get("/api/categories")).json()
lebenshaltung = next(k for k in baum if k["name"] == "Lebenshaltung")
await auth_client.post(
"/api/budgets",
json={
"category_id": lebenshaltung["id"],
"period_month": "2026-03-01",
"limit_amount": "500.00",
},
)
await auth_client.post(
"/api/transactions",
json={
"kind": "expense",
"title": "Einkauf",
"category_id": seeded["lebensmittel"],
"account_id": seeded["account_id"],
"amount": "120.00",
"booking_date": "2026-03-05",
},
)
status = (await auth_client.get("/api/reports/budgets", params={"month": "2026-03-01"})).json()
assert status[0]["spent"] == "120.00"
async def test_budgetvorlage_gilt_ab_dem_monat(auth_client: AsyncClient, seeded: dict) -> None:
await auth_client.post(
"/api/budget-templates",
json={
"category_id": seeded["lebensmittel"],
"valid_from": "2026-02-01",
"limit_amount": "450.00",
},
)
januar = (await auth_client.get("/api/reports/budgets", params={"month": "2026-01-01"})).json()
maerz = (await auth_client.get("/api/reports/budgets", params={"month": "2026-03-01"})).json()
assert januar == []
assert maerz[0]["limit_amount"] == "450.00"
assert maerz[0]["is_template"] is True
async def test_rollover_uebertraegt_den_rest(auth_client: AsyncClient, seeded: dict) -> None:
for monat in ("2026-01-01", "2026-02-01"):
await auth_client.post(
"/api/budgets",
json={
"category_id": seeded["lebensmittel"],
"period_month": monat,
"limit_amount": "400.00",
"rollover": True,
},
)
await auth_client.post(
"/api/transactions",
json={
"kind": "expense",
"title": "Einkauf Januar",
"category_id": seeded["lebensmittel"],
"account_id": seeded["account_id"],
"amount": "300.00",
"booking_date": "2026-01-10",
},
)
februar = (await auth_client.get("/api/reports/budgets", params={"month": "2026-02-01"})).json()
assert februar[0]["carried_over"] == "100.00"
assert februar[0]["available"] == "500.00"
# --- Sparziele -----------------------------------------------------------------
async def test_sparziel_fortschritt_und_rate(auth_client: AsyncClient) -> None:
from datetime import date
from dateutil.relativedelta import relativedelta
ziel_datum = date.today() + relativedelta(months=10)
await auth_client.post(
"/api/savings-goals",
json={
"name": "Neues Fahrrad",
"target_amount": "2000.00",
"current_amount": "500.00",
"target_date": ziel_datum.isoformat(),
"monthly_contribution": "100.00",
},
)
fortschritt = (await auth_client.get("/api/reports/savings-goals")).json()[0]
assert fortschritt["remaining_amount"] == "1500.00"
assert fortschritt["ratio"] == 0.25
assert fortschritt["months_left"] == 10
assert fortschritt["required_monthly"] == "150.00"
# 100 Euro reichen bei 150 nötigen nicht.
assert fortschritt["is_on_track"] is False
async def test_erreichtes_sparziel_braucht_keine_rate(auth_client: AsyncClient) -> None:
from datetime import date
await auth_client.post(
"/api/savings-goals",
json={
"name": "Urlaubskasse",
"target_amount": "1000.00",
"current_amount": "1000.00",
"target_date": (date.today().replace(year=date.today().year + 1)).isoformat(),
},
)
fortschritt = (await auth_client.get("/api/reports/savings-goals")).json()[0]
assert fortschritt["ratio"] == 1.0
assert fortschritt["required_monthly"] == "0.00"
# --- Dashboard -----------------------------------------------------------------
async def test_dashboard_buendelt_alles(auth_client: AsyncClient, seeded: dict) -> None:
await haushalt(auth_client, seeded)
dashboard = (
await auth_client.get("/api/reports/dashboard", params={"month": "2026-03-01"})
).json()
assert dashboard["month"]["month"] == "2026-03-01"
assert dashboard["month"]["available_after_fixed"] == "2185.01"
assert len(dashboard["forecast"]) == 12
assert len(dashboard["categories"]) == 2
assert dashboard["total_balance"] == "1000.00"
assert dashboard["budgets"] == []
assert dashboard["goals"] == []
# --- Export --------------------------------------------------------------------
async def test_export_buchungen_als_csv(auth_client: AsyncClient, seeded: dict) -> None:
await auth_client.post(
"/api/transactions",
json={
"kind": "expense",
"title": "Wocheneinkauf",
"category_id": seeded["lebensmittel"],
"account_id": seeded["account_id"],
"amount": "84.30",
"booking_date": "2026-03-05",
},
)
antwort = await auth_client.get(
"/api/export/transactions",
params={"format": "csv", "from": "2026-01-01", "to": "2026-12-31"},
)
assert antwort.status_code == 200
assert antwort.headers["content-type"].startswith("text/csv")
assert "moneyfy-buchungen" in antwort.headers["content-disposition"]
inhalt = antwort.content
# Excel erkennt UTF-8 nur mit BOM zuverlässig.
assert inhalt.startswith(b"\xef\xbb\xbf")
text = inhalt.decode("utf-8-sig")
zeilen = text.strip().splitlines()
assert zeilen[0].startswith("Datum;Titel;Richtung;Betrag")
# Deutsches Dezimaltrennzeichen.
assert "84,30" in zeilen[1]
assert "Lebenshaltung · Lebensmittel" in zeilen[1]
async def test_export_buchungen_als_xlsx(auth_client: AsyncClient, seeded: dict) -> None:
await auth_client.post(
"/api/transactions",
json={
"kind": "expense",
"title": "Wocheneinkauf",
"category_id": seeded["lebensmittel"],
"account_id": seeded["account_id"],
"amount": "84.30",
"booking_date": "2026-03-05",
},
)
antwort = await auth_client.get("/api/export/transactions", params={"format": "xlsx"})
assert antwort.status_code == 200
assert antwort.content[:2] == b"PK"
mappe = load_workbook(io.BytesIO(antwort.content))
blatt = mappe["Buchungen"]
kopf = [zelle.value for zelle in blatt[1]]
assert kopf[:4] == ["Datum", "Titel", "Richtung", "Betrag"]
# Der Betrag ist eine Zahl, keine Zeichenkette Excel kann damit rechnen.
assert blatt.cell(row=2, column=4).value == 84.3
assert "" in blatt.cell(row=2, column=4).number_format
async def test_export_posten(auth_client: AsyncClient, seeded: dict) -> None:
await haushalt(auth_client, seeded)
antwort = await auth_client.get("/api/export/recurrences", params={"format": "xlsx"})
mappe = load_workbook(io.BytesIO(antwort.content))
blatt = mappe["Posten"]
titel = [blatt.cell(row=zeile, column=1).value for zeile in range(2, blatt.max_row + 1)]
assert set(titel) == {"Gehalt", "Kfz-Versicherung", "Miete", "Netflix"}
async def test_export_monatsauswertung(auth_client: AsyncClient, seeded: dict) -> None:
await haushalt(auth_client, seeded)
antwort = await auth_client.get(
"/api/export/month", params={"format": "xlsx", "month": "2026-03-01"}
)
mappe = load_workbook(io.BytesIO(antwort.content))
assert mappe.sheetnames == ["Bewegungen", "Kennzahlen"]
bewegungen = mappe["Bewegungen"]
titel = [bewegungen.cell(row=zeile, column=2).value for zeile in range(2, 5)]
assert set(titel) == {"Miete", "Netflix", "Gehalt"}
kennzahlen = {
mappe["Kennzahlen"].cell(row=zeile, column=1).value: mappe["Kennzahlen"]
.cell(row=zeile, column=2)
.value
for zeile in range(2, mappe["Kennzahlen"].max_row + 1)
}
assert kennzahlen["Ausgaben (Plan)"] == 963.99
assert kennzahlen["Einnahmen (Plan)"] == 3200.0
async def test_export_monatsauswertung_als_csv(auth_client: AsyncClient, seeded: dict) -> None:
await haushalt(auth_client, seeded)
antwort = await auth_client.get(
"/api/export/month", params={"format": "csv", "month": "2026-03-01"}
)
text = antwort.content.decode("utf-8-sig")
assert "Datum;Titel;Kategorie" in text
# Die Kennzahlen folgen nach einer Leerzeile im selben Dokument.
assert "Kennzahl;Betrag" in text
assert "Verfügbar nach Fixkosten" in text
async def test_export_weist_verdrehten_zeitraum_ab(auth_client: AsyncClient) -> None:
antwort = await auth_client.get(
"/api/export/transactions", params={"from": "2026-12-31", "to": "2026-01-01"}
)
assert antwort.status_code == 422
assert antwort.json()["code"] == "invalid_date_range"
async def test_export_verlangt_anmeldung(client: AsyncClient) -> None:
assert (await client.get("/api/export/transactions")).status_code == 401