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:
@@ -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)
|
||||
|
||||
@@ -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()}
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user