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
+17
View File
@@ -78,6 +78,23 @@ die Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
Logo-Auswahldialog mit Vorauswahl, erneuter Suche über eine Domain und Upload.
- Beträge durchgängig über `Intl.NumberFormat('de-DE')`; Eingaben akzeptieren
deutsche wie englische Schreibweise.
- Auswertungen: Monatsübersicht, 12-Monats-Forecast, Kategorienaufteilung mit
Drilldown, Abo-Übersicht mit Jahreskosten, Jahresvergleich, Cashflow-Kalender,
Budget-Ampel und Fortschritt der Sparziele.
- Alle Berichte bauen auf einer gemeinsamen Bewegungsschicht auf, sodass
Monatsübersicht, Forecast und Kalender nachweislich dieselben Zahlen zeigen.
- Budgetübertrag über Monatsgrenzen und Budgets auf Oberkategorien, die deren
Unterkategorien einschließen.
- `GET /api/reports/dashboard` bündelt alles, was die Startseite braucht.
- Export als CSV (Semikolon, Komma als Dezimaltrenner, UTF-8 mit BOM) und XLSX
mit typisierten Beträgen und deutschem Zahlenformat für Buchungen, Posten
und die Monatsauswertung.
- Dashboard, Cashflow-Kalender, Budget-, Sparziel- und Auswertungsseite im
Frontend; Diagramme mit Recharts.
- Die Farbpalette der Diagramme wurde gegen die hellen und dunklen Flächen der
Anwendung auf Kontrast und Farbfehlsichtigkeit geprüft. Grün/Rot schied als
Serienpaar aus (bei Deuteranopie nicht unterscheidbar) und bleibt den
Vorzeichen im Text vorbehalten.
### Geändert
+13
View File
@@ -83,6 +83,19 @@ vollständige, kommentierte Liste. Die wichtigsten:
| `COOKIE_SECURE` | `true` | Hinter HTTPS `true`, für lokales HTTP `false` |
| `SCHEDULER_ENABLED` | `true` | Täglicher Benachrichtigungslauf um 07:00 |
## Diagramme
Die Serienfarben stammen aus einer Palette, die gegen die hellen und dunklen
Flächen der Anwendung auf Kontrast und Farbfehlsichtigkeit geprüft wurde; ihre
Reihenfolge ist Teil dieser Absicherung. Grün und Rot bleiben den Vorzeichen im
Text vorbehalten als Serienpaar wären sie bei Deuteranopie nicht zu
unterscheiden. Jedes Diagramm mit mehr als einer Serie führt eine Legende, und
zu den Balkendiagrammen lässt sich die Wertetabelle aufklappen.
Einnahmen, Ausgaben und der kumulierte Kontostand stehen bewusst in zwei
getrennten Diagrammen untereinander: eine gemeinsame Größenachse würde bei so
verschiedenen Größenordnungen einen Zusammenhang vortäuschen.
## Logos und Markenfarben
Jede Firma bekommt ein Bild. Die Provider-Kette arbeitet der Reihe nach:
+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
+11 -2
View File
@@ -5,8 +5,13 @@ import { Navigate, Route, Routes } from "react-router-dom";
import { AppLayout } from "@/components/layout/AppLayout";
import { Skeleton } from "@/components/ui/Feedback";
import { useMe } from "@/hooks/useAuth";
import { BudgetsPage } from "@/pages/BudgetsPage";
import { CalendarPage } from "@/pages/CalendarPage";
import { ChangePasswordPage } from "@/pages/ChangePasswordPage";
import { DashboardPage } from "@/pages/DashboardPage";
import { GoalsPage } from "@/pages/GoalsPage";
import { LoginPage } from "@/pages/LoginPage";
import { ReportsPage } from "@/pages/ReportsPage";
import { MerchantsPage } from "@/pages/MerchantsPage";
import { RecurrencesPage } from "@/pages/RecurrencesPage";
import { SettingsPage } from "@/pages/SettingsPage";
@@ -31,12 +36,16 @@ export function App() {
return (
<Routes>
<Route element={<AppLayout />}>
<Route path="/" element={<Navigate to="/recurrences" replace />} />
<Route path="/" element={<DashboardPage />} />
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/recurrences" element={<RecurrencesPage />} />
<Route path="/transactions" element={<TransactionsPage />} />
<Route path="/merchants" element={<MerchantsPage />} />
<Route path="/budgets" element={<BudgetsPage />} />
<Route path="/goals" element={<GoalsPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/recurrences" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
);
@@ -0,0 +1,77 @@
/** Tests der Budget-Ampel. */
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { BudgetMeter } from "@/components/charts/BudgetMeter";
import type { BudgetStatus } from "@/types/api";
function budget(overrides: Partial<BudgetStatus> = {}): BudgetStatus {
return {
category_id: 11,
category_name: "Lebensmittel",
color: "#84cc16",
period_month: "2026-03-01",
limit_amount: "400.00",
carried_over: "0.00",
available: "400.00",
spent: "100.00",
remaining: "300.00",
ratio: 0.25,
state: "ok",
rollover: false,
is_template: false,
...overrides,
};
}
function rendern(status: BudgetStatus) {
return render(
<ul>
<BudgetMeter budget={status} />
</ul>,
);
}
describe("BudgetMeter", () => {
it("zeigt Verbrauch, Rest und Prozentwert", () => {
rendern(budget());
expect(screen.getByText("Lebensmittel")).toBeInTheDocument();
expect(screen.getByText(/100,00/)).toBeInTheDocument();
expect(screen.getByText(/400,00/)).toBeInTheDocument();
expect(screen.getByText(/300,00.*übrig/)).toBeInTheDocument();
});
it("trägt den Zustand nicht allein über die Farbe", () => {
rendern(budget({ ratio: 0.9, state: "warning", spent: "360.00", remaining: "40.00" }));
// Prozentwert und Wortlaut stehen neben dem Symbol.
expect(screen.getByText(/90 %/)).toBeInTheDocument();
expect(screen.getByText(/fast ausgeschöpft/)).toBeInTheDocument();
});
it("meldet eine Überschreitung mit dem Fehlbetrag", () => {
rendern(
budget({ ratio: 1.15, state: "exceeded", spent: "460.00", remaining: "-60.00" }),
);
expect(screen.getByText(/überschritten/)).toBeInTheDocument();
expect(screen.getByText(/60,00.*zu viel/)).toBeInTheDocument();
});
it("stellt den Fortschritt auch für Screenreader bereit", () => {
rendern(budget({ ratio: 0.25 }));
const messgeraet = screen.getByRole("meter");
expect(messgeraet).toHaveAttribute("aria-valuenow", "25");
expect(messgeraet).toHaveAccessibleName(/Lebensmittel: 25 Prozent verbraucht/);
});
it("nennt Übertrag und Herkunft aus einer Vorlage", () => {
rendern(budget({ carried_over: "50.00", available: "450.00", is_template: true }));
expect(screen.getByText(/Übertrag aus Vormonaten: 50,00/)).toBeInTheDocument();
expect(screen.getByText(/Aus einer Vorlage/)).toBeInTheDocument();
});
});
@@ -0,0 +1,92 @@
/**
* Budget-Ampel.
*
* Der Zustand wird nie allein über die Farbe getragen: jede Zeile führt ein
* Symbol, einen Wortlaut und den Prozentwert mit.
*/
import { AlertTriangle, CheckCircle2, CircleAlert } from "lucide-react";
import { STATUS } from "@/components/charts/chartTheme";
import { formatMoney, toNumber } from "@/lib/format";
import { cn } from "@/lib/cn";
import type { BudgetState, BudgetStatus } from "@/types/api";
const ZUSTAND: Record<
BudgetState,
{ label: string; color: string; icon: typeof CheckCircle2; klasse: string }
> = {
ok: { label: "im Rahmen", color: STATUS.good, icon: CheckCircle2, klasse: "text-positive" },
warning: {
label: "fast ausgeschöpft",
color: STATUS.warning,
icon: AlertTriangle,
klasse: "text-warning",
},
exceeded: {
label: "überschritten",
color: STATUS.critical,
icon: CircleAlert,
klasse: "text-negative",
},
};
export function BudgetMeter({ budget }: { budget: BudgetStatus }) {
const zustand = ZUSTAND[budget.state];
const Symbol = zustand.icon;
const anteil = Math.min(budget.ratio, 1);
const ueberzogen = budget.ratio > 1;
return (
<li className="card p-3">
<div className="flex items-baseline justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span
aria-hidden
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: budget.color }}
/>
<span className="truncate text-sm font-medium text-ink">{budget.category_name}</span>
</div>
<span className="shrink-0 tabular text-sm text-ink">
{formatMoney(budget.spent)}
<span className="text-faint"> / {formatMoney(budget.available)}</span>
</span>
</div>
<div
className="mt-2 h-2 overflow-hidden rounded-full bg-raised"
role="meter"
aria-valuenow={Math.round(budget.ratio * 100)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${budget.category_name}: ${Math.round(budget.ratio * 100)} Prozent verbraucht`}
>
<div
className="h-full rounded-full transition-all"
style={{ width: `${anteil * 100}%`, backgroundColor: zustand.color }}
/>
</div>
<div className="mt-1.5 flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs">
<span className={cn("flex items-center gap-1", zustand.klasse)}>
<Symbol aria-hidden className="h-3.5 w-3.5" />
{Math.round(budget.ratio * 100)} % · {zustand.label}
</span>
<span className={ueberzogen ? "text-negative" : "text-muted"}>
{ueberzogen
? `${formatMoney(Math.abs(toNumber(budget.remaining)))} zu viel`
: `${formatMoney(budget.remaining)} übrig`}
</span>
</div>
{(budget.carried_over !== "0.00" || budget.is_template) && (
<p className="mt-1 text-[11px] text-faint">
{budget.carried_over !== "0.00" &&
`Übertrag aus Vormonaten: ${formatMoney(budget.carried_over)}. `}
{budget.is_template && "Aus einer Vorlage."}
</p>
)}
</li>
);
}
@@ -0,0 +1,82 @@
/** Tests des Kategorien-Rings samt Drilldown. */
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { CategoryDonut } from "@/components/charts/CategoryDonut";
import { renderWithProviders } from "@/test/utils";
import type { CategorySlice } from "@/types/api";
function slice(
id: number,
name: string,
amount: string,
children: CategorySlice[] = [],
): CategorySlice {
return {
category_id: id,
name,
color: "#84cc16",
icon: "circle",
amount,
count: 1,
children,
};
}
const KATEGORIEN: CategorySlice[] = [
slice(1, "Wohnen", "950.00", [slice(11, "Miete", "900.00"), slice(12, "Strom", "50.00")]),
slice(2, "Lebenshaltung", "320.00", [slice(21, "Lebensmittel", "320.00")]),
slice(3, "Mobilität", "130.00"),
];
describe("CategoryDonut", () => {
it("zeigt Summe, Anteile und Beträge im Klartext", () => {
renderWithProviders(<CategoryDonut categories={KATEGORIEN} />);
// Die Zuordnung hängt nie allein an der Farbe.
expect(screen.getByText("Wohnen")).toBeInTheDocument();
expect(screen.getByText("Lebenshaltung")).toBeInTheDocument();
expect(screen.getByText(/1.400,00/)).toBeInTheDocument();
expect(screen.getByText("68 %")).toBeInTheDocument();
});
it("blättert per Klick in die Unterkategorien und wieder zurück", async () => {
const nutzer = userEvent.setup();
renderWithProviders(<CategoryDonut categories={KATEGORIEN} title="Ausgaben" />);
await nutzer.click(screen.getByRole("button", { name: /Wohnen/ }));
expect(screen.getByText("Ausgaben · Wohnen")).toBeInTheDocument();
expect(screen.getByText("Miete")).toBeInTheDocument();
expect(screen.getByText("Strom")).toBeInTheDocument();
await nutzer.click(screen.getByRole("button", { name: "Zurück" }));
expect(screen.getByText("Lebenshaltung")).toBeInTheDocument();
});
it("bietet keinen Drilldown bei nur einer Unterkategorie", () => {
renderWithProviders(<CategoryDonut categories={KATEGORIEN} />);
// Lebenshaltung hat nur ein Kind ein Aufklappen brächte nichts.
expect(screen.queryByRole("button", { name: /Lebenshaltung/ })).not.toBeInTheDocument();
});
it("fasst mehr als sechs Kategorien zusammen", () => {
const viele = Array.from({ length: 9 }, (_, index) =>
slice(index + 1, `Kategorie ${index + 1}`, String((9 - index) * 10)),
);
renderWithProviders(<CategoryDonut categories={viele} />);
expect(screen.getByText("Weitere (3)")).toBeInTheDocument();
expect(screen.queryByText("Kategorie 8")).not.toBeInTheDocument();
});
it("meldet einen leeren Zeitraum verständlich", () => {
renderWithProviders(<CategoryDonut categories={[]} />);
expect(screen.getByText(/keine Bewegungen/)).toBeInTheDocument();
});
});
@@ -0,0 +1,188 @@
/**
* Aufteilung nach Kategorien mit Drilldown auf die Unterkategorien.
*
* Die Farben stammen aus den Kategorien selbst sie sind dort die Identität
* und tauchen in der ganzen Oberfläche auf. Damit die Zuordnung nicht allein an
* der Farbe hängt, trägt jede Zeile daneben Name und Betrag.
*/
import { useState } from "react";
import { ChevronLeft } from "lucide-react";
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
import { ChartCard, ChartTooltip } from "@/components/charts/ChartFrame";
import { Button } from "@/components/ui/Button";
import { formatMoney, toNumber } from "@/lib/format";
import type { CategorySlice } from "@/types/api";
/** Mehr Segmente lassen sich im Ring nicht mehr auseinanderhalten. */
const MAX_SEGMENTE = 6;
const REST_FARBE = "#94a3b8";
interface Segment {
id: number;
name: string;
color: string;
amount: number;
hasChildren: boolean;
}
function zuSegmenten(slices: CategorySlice[]): Segment[] {
const sortiert = [...slices].sort((links, rechts) => toNumber(rechts.amount) - toNumber(links.amount));
const sichtbar = sortiert.slice(0, MAX_SEGMENTE);
const rest = sortiert.slice(MAX_SEGMENTE);
const segmente: Segment[] = sichtbar.map((slice) => ({
id: slice.category_id,
name: slice.name,
color: slice.color,
amount: toNumber(slice.amount),
hasChildren: slice.children.length > 1,
}));
if (rest.length > 0) {
segmente.push({
id: -1,
name: `Weitere (${rest.length})`,
color: REST_FARBE,
amount: rest.reduce((summe, slice) => summe + toNumber(slice.amount), 0),
hasChildren: false,
});
}
return segmente;
}
export function CategoryDonut({
categories,
title = "Nach Kategorien",
description,
}: {
categories: CategorySlice[];
title?: string;
description?: string;
}) {
const [drilldown, setDrilldown] = useState<CategorySlice | null>(null);
const quelle = drilldown ? drilldown.children : categories;
const segmente = zuSegmenten(quelle);
const gesamt = segmente.reduce((summe, segment) => summe + segment.amount, 0);
if (segmente.length === 0) {
return (
<ChartCard title={title} description={description}>
<p className="py-10 text-center text-xs text-muted">
Für diesen Zeitraum gibt es keine Bewegungen.
</p>
</ChartCard>
);
}
return (
<ChartCard
title={drilldown ? `${title} · ${drilldown.name}` : title}
description={description}
actions={
drilldown && (
<Button size="sm" variant="ghost" onClick={() => setDrilldown(null)}>
<ChevronLeft aria-hidden className="h-3.5 w-3.5" />
Zurück
</Button>
)
}
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="relative h-48 w-full sm:w-48 sm:shrink-0">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={segmente}
dataKey="amount"
nameKey="name"
innerRadius="62%"
outerRadius="98%"
// Zwei Pixel Fläche zwischen den Segmenten statt eines Rahmens.
paddingAngle={2}
stroke="none"
onClick={(eintrag: unknown) => {
const segment = eintrag as Segment;
const passend = quelle.find((slice) => slice.category_id === segment.id);
if (passend && passend.children.length > 1) setDrilldown(passend);
}}
>
{segmente.map((segment) => (
<Cell
key={segment.id}
fill={segment.color}
className={segment.hasChildren ? "cursor-pointer" : undefined}
/>
))}
</Pie>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const segment = payload[0]?.payload as Segment;
const anteil = gesamt > 0 ? (segment.amount / gesamt) * 100 : 0;
return (
<ChartTooltip
title={segment.name}
rows={[
{ label: "Betrag", value: formatMoney(segment.amount), color: segment.color },
{ label: "Anteil", value: `${anteil.toFixed(1)} %` },
]}
/>
);
}}
/>
</PieChart>
</ResponsiveContainer>
{/* Die Summe steht in der Mitte dort sucht man sie. */}
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className="text-[11px] text-muted">Gesamt</span>
<span className="tabular text-sm font-semibold text-ink">{formatMoney(gesamt)}</span>
</div>
</div>
<ul className="min-w-0 flex-1 space-y-1">
{segmente.map((segment) => {
const anteil = gesamt > 0 ? (segment.amount / gesamt) * 100 : 0;
const passend = quelle.find((slice) => slice.category_id === segment.id);
const aufklappbar = Boolean(passend && passend.children.length > 1);
const inhalt = (
<>
<span
aria-hidden
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: segment.color }}
/>
<span className="min-w-0 flex-1 truncate text-ink">{segment.name}</span>
<span className="shrink-0 tabular text-muted">{anteil.toFixed(0)} %</span>
<span className="w-24 shrink-0 text-right tabular font-medium text-ink">
{formatMoney(segment.amount)}
</span>
</>
);
return (
<li key={segment.id}>
{aufklappbar ? (
<button
type="button"
onClick={() => passend && setDrilldown(passend)}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1 text-xs transition hover:bg-raised"
>
{inhalt}
</button>
) : (
<div className="flex items-center gap-2 px-2 py-1 text-xs">{inhalt}</div>
)}
</li>
);
})}
</ul>
</div>
</ChartCard>
);
}
@@ -0,0 +1,196 @@
/** Rahmen, Legende und Tooltip für alle Diagramme gleich. */
import { type ReactNode } from "react";
import { cn } from "@/lib/cn";
import { formatMoney } from "@/lib/format";
export interface LegendItem {
label: string;
color: string;
}
export function ChartCard({
title,
description,
legend,
actions,
children,
className,
}: {
title: string;
description?: string;
legend?: LegendItem[];
actions?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<section className={cn("card p-4", className)}>
<header className="mb-3 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-ink">{title}</h2>
{description && <p className="mt-0.5 text-xs text-muted">{description}</p>}
</div>
{actions}
</header>
{/* Ab zwei Serien ist die Legende immer vorhanden Farbe allein trägt nie. */}
{legend && legend.length > 1 && (
<ul className="mb-3 flex flex-wrap gap-x-4 gap-y-1">
{legend.map((eintrag) => (
<li key={eintrag.label} className="flex items-center gap-1.5 text-xs text-muted">
<span
aria-hidden
className="h-2 w-2 rounded-full"
style={{ backgroundColor: eintrag.color }}
/>
{eintrag.label}
</li>
))}
</ul>
)}
{children}
</section>
);
}
export interface TooltipRow {
label: string;
value: string;
color?: string;
}
/** Einheitlicher Tooltip; die Werte stehen in Textfarbe, nicht in der Serienfarbe. */
export function ChartTooltip({ title, rows }: { title: string; rows: TooltipRow[] }) {
return (
<div className="rounded-lg border border-line bg-surface px-3 py-2 shadow-xl">
<p className="mb-1 text-xs font-medium text-ink">{title}</p>
<ul className="space-y-0.5">
{rows.map((zeile) => (
<li key={zeile.label} className="flex items-center gap-2 text-xs">
{zeile.color && (
<span
aria-hidden
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: zeile.color }}
/>
)}
<span className="text-muted">{zeile.label}</span>
<span className="ml-auto tabular text-ink">{zeile.value}</span>
</li>
))}
</ul>
</div>
);
}
/** Aufklappbare Wertetabelle die Alternative zum Ablesen aus der Grafik. */
export function DataTable({
columns,
rows,
caption,
}: {
columns: string[];
rows: (string | number)[][];
caption: string;
}) {
return (
<details className="mt-3 text-xs">
<summary className="cursor-pointer text-muted transition hover:text-ink">
Werte als Tabelle
</summary>
<div className="mt-2 overflow-x-auto">
<table className="w-full">
<caption className="sr-only">{caption}</caption>
<thead className="text-left text-muted">
<tr>
{columns.map((spalte, index) => (
<th
key={spalte}
scope="col"
className={cn("py-1 font-medium", index > 0 && "text-right")}
>
{spalte}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-line">
{rows.map((zeile, zeilenIndex) => (
<tr key={zeilenIndex}>
{zeile.map((zelle, spaltenIndex) => (
<td
key={spaltenIndex}
className={cn(
"py-1",
spaltenIndex === 0 ? "text-ink" : "text-right tabular text-muted",
)}
>
{zelle}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</details>
);
}
/** Kennzahl für Fälle, in denen eine Zahl mehr sagt als ein Diagramm. */
export function StatTile({
label,
value,
hint,
tone = "default",
large = false,
}: {
label: string;
value: string;
hint?: ReactNode;
tone?: "default" | "positive" | "negative";
large?: boolean;
}) {
return (
<div className="card p-4">
<p className="text-xs text-muted">{label}</p>
<p
className={cn(
"mt-1 font-semibold",
large ? "text-3xl" : "text-xl",
tone === "positive" && "text-positive",
tone === "negative" && "text-negative",
tone === "default" && "text-ink",
)}
>
{value}
</p>
{hint && <div className="mt-1 text-xs text-muted">{hint}</div>}
</div>
);
}
/** Veränderung gegenüber einem Vergleichswert, mit Vorzeichen und Wortlaut. */
export function DeltaHint({
value,
goodWhenPositive = true,
suffix = "ggü. Vormonat",
}: {
value: string;
goodWhenPositive?: boolean;
suffix?: string;
}) {
const zahl = Number.parseFloat(value);
if (!Number.isFinite(zahl) || zahl === 0) {
return <span className="text-faint">unverändert {suffix}</span>;
}
const gut = zahl > 0 === goodWhenPositive;
return (
<span className={gut ? "text-positive" : "text-negative"}>
{zahl > 0 ? "▲" : "▼"} {formatMoney(Math.abs(zahl))} {suffix}
</span>
);
}
@@ -0,0 +1,181 @@
/**
* Zwölf-Monats-Vorschau.
*
* Bewusst zwei Diagramme untereinander statt zweier Größenachsen in einem Bild:
* Monatsbeträge und kumulierter Kontostand haben verschiedene Größenordnungen,
* eine gemeinsame Achse würde einen Zusammenhang vortäuschen.
*/
import {
Bar,
BarChart,
CartesianGrid,
Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { ChartCard, ChartTooltip, DataTable } from "@/components/charts/ChartFrame";
import {
AXIS_PROPS,
BAR_GAP,
BAR_RADIUS,
GRID_PROPS,
SERIES,
} from "@/components/charts/chartTheme";
import { formatMoney, toNumber } from "@/lib/format";
import type { ForecastMonth } from "@/types/api";
const MONATSKUERZEL = [
"Jan",
"Feb",
"Mär",
"Apr",
"Mai",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Dez",
];
interface Punkt {
label: string;
monat: string;
einkuenfte: number;
ausgaben: number;
saldo: number;
kumuliert: number;
}
function kompakt(wert: number): string {
return new Intl.NumberFormat("de-DE", {
notation: "compact",
maximumFractionDigits: 1,
}).format(wert);
}
export function ForecastChart({ months }: { months: ForecastMonth[] }) {
const punkte: Punkt[] = months.map((monat) => {
const datum = new Date(monat.month);
return {
label: `${MONATSKUERZEL[datum.getMonth()]} ${String(datum.getFullYear()).slice(2)}`,
monat: monat.month,
einkuenfte: toNumber(monat.income),
ausgaben: toNumber(monat.expenses),
saldo: toNumber(monat.balance),
kumuliert: toNumber(monat.cumulative_balance),
};
});
return (
<div className="space-y-3">
<ChartCard
title="Einnahmen und Ausgaben"
description="Jährliche Posten stehen in ihrem echten Fälligkeitsmonat."
legend={[
{ label: "Einkünfte", color: SERIES.one },
{ label: "Ausgaben", color: SERIES.two },
]}
>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={punkte} barGap={BAR_GAP} margin={{ top: 4, right: 4, bottom: 0, left: 4 }}>
<CartesianGrid {...GRID_PROPS} />
<XAxis dataKey="label" {...AXIS_PROPS} />
<YAxis {...AXIS_PROPS} width={52} tickFormatter={kompakt} />
<Tooltip
cursor={{ fill: "var(--viz-grid)", opacity: 0.35 }}
content={({ active, payload, label }) =>
active && payload?.length ? (
<ChartTooltip
title={String(label)}
rows={[
{
label: "Einkünfte",
value: formatMoney(payload[0]?.payload.einkuenfte ?? 0),
color: SERIES.one,
},
{
label: "Ausgaben",
value: formatMoney(payload[0]?.payload.ausgaben ?? 0),
color: SERIES.two,
},
{
label: "Saldo",
value: formatMoney(payload[0]?.payload.saldo ?? 0),
},
]}
/>
) : null
}
/>
<Bar dataKey="einkuenfte" name="Einkünfte" fill={SERIES.one} radius={BAR_RADIUS} />
<Bar dataKey="ausgaben" name="Ausgaben" fill={SERIES.two} radius={BAR_RADIUS} />
</BarChart>
</ResponsiveContainer>
</div>
<DataTable
caption="Einnahmen und Ausgaben je Monat"
columns={["Monat", "Einkünfte", "Ausgaben", "Saldo"]}
rows={punkte.map((punkt) => [
punkt.label,
formatMoney(punkt.einkuenfte),
formatMoney(punkt.ausgaben),
formatMoney(punkt.saldo),
])}
/>
</ChartCard>
<ChartCard
title="Kumulierter Kontostand"
description="Fortgeschrieben aus dem heutigen Saldo aller Konten."
>
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={punkte} margin={{ top: 8, right: 8, bottom: 0, left: 4 }}>
<CartesianGrid {...GRID_PROPS} />
<XAxis dataKey="label" {...AXIS_PROPS} />
<YAxis {...AXIS_PROPS} width={52} tickFormatter={kompakt} />
{/* Die Nulllinie ist die Grenze, auf die es ankommt. */}
<ReferenceLine y={0} stroke="var(--viz-axis)" strokeWidth={1} />
<Tooltip
cursor={{ stroke: "var(--viz-grid)", strokeWidth: 1 }}
content={({ active, payload, label }) =>
active && payload?.length ? (
<ChartTooltip
title={String(label)}
rows={[
{
label: "Kontostand",
value: formatMoney(payload[0]?.payload.kumuliert ?? 0),
color: SERIES.one,
},
]}
/>
) : null
}
/>
<Line
type="monotone"
dataKey="kumuliert"
name="Kontostand"
stroke={SERIES.one}
strokeWidth={2}
dot={{ r: 3, strokeWidth: 0, fill: SERIES.one }}
activeDot={{ r: 5, strokeWidth: 2, stroke: "var(--viz-surface)" }}
/>
</LineChart>
</ResponsiveContainer>
</div>
</ChartCard>
</div>
);
}
@@ -0,0 +1,119 @@
/** Jahresvergleich je Oberkategorie als gruppierte Balken. */
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { ChartCard, ChartTooltip, DataTable } from "@/components/charts/ChartFrame";
import {
AXIS_PROPS,
BAR_GAP,
GRID_PROPS,
SERIES,
} from "@/components/charts/chartTheme";
import { formatMoney, formatSignedMoney, toNumber } from "@/lib/format";
import type { YearComparison } from "@/types/api";
function kompakt(wert: number): string {
return new Intl.NumberFormat("de-DE", {
notation: "compact",
maximumFractionDigits: 1,
}).format(wert);
}
export function YearComparisonChart({ data }: { data: YearComparison }) {
const punkte = data.rows.map((zeile) => ({
name: zeile.name,
aktuell: toNumber(zeile.current),
vorjahr: toNumber(zeile.previous),
delta: toNumber(zeile.delta),
}));
if (punkte.length === 0) {
return (
<ChartCard title={`Jahresvergleich ${data.year}`}>
<p className="py-10 text-center text-xs text-muted">
Für {data.year} und {data.year - 1} liegen keine Ausgaben vor.
</p>
</ChartCard>
);
}
return (
<ChartCard
title={`Jahresvergleich ${data.year}`}
description={`${formatMoney(data.current_total)} gegenüber ${formatMoney(
data.previous_total,
)} im Vorjahr.`}
legend={[
{ label: String(data.year), color: SERIES.one },
{ label: String(data.year - 1), color: SERIES.two },
]}
>
<div style={{ height: Math.max(200, punkte.length * 44) }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={punkte}
layout="vertical"
barGap={BAR_GAP}
margin={{ top: 4, right: 12, bottom: 0, left: 4 }}
>
<CartesianGrid {...GRID_PROPS} horizontal={false} vertical />
<XAxis type="number" {...AXIS_PROPS} tickFormatter={kompakt} />
<YAxis type="category" dataKey="name" {...AXIS_PROPS} width={110} />
<Tooltip
cursor={{ fill: "var(--viz-grid)", opacity: 0.35 }}
content={({ active, payload, label }) =>
active && payload?.length ? (
<ChartTooltip
title={String(label)}
rows={[
{
label: String(data.year),
value: formatMoney(payload[0]?.payload.aktuell ?? 0),
color: SERIES.one,
},
{
label: String(data.year - 1),
value: formatMoney(payload[0]?.payload.vorjahr ?? 0),
color: SERIES.two,
},
{
label: "Veränderung",
value: formatSignedMoney(payload[0]?.payload.delta ?? 0),
},
]}
/>
) : null
}
/>
<Bar dataKey="aktuell" name={String(data.year)} fill={SERIES.one} radius={[0, 4, 4, 0]} />
<Bar
dataKey="vorjahr"
name={String(data.year - 1)}
fill={SERIES.two}
radius={[0, 4, 4, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
<DataTable
caption={`Ausgaben ${data.year} gegenüber ${data.year - 1}`}
columns={["Kategorie", String(data.year), String(data.year - 1), "Veränderung"]}
rows={punkte.map((punkt) => [
punkt.name,
formatMoney(punkt.aktuell),
formatMoney(punkt.vorjahr),
formatSignedMoney(punkt.delta),
])}
/>
</ChartCard>
);
}
@@ -0,0 +1,48 @@
/**
* Gemeinsame Bausteine der Diagramme.
*
* Die Serienfarben stammen aus einer Palette, die gegen die hellen und dunklen
* Flächen dieser Anwendung auf Kontrast und Farbfehlsichtigkeit geprüft wurde.
* Die Reihenfolge ist Teil dieser Absicherung und darf nicht umsortiert werden.
*/
export const SERIES = {
/** Einkünfte, laufendes Jahr, kumulierter Saldo. */
one: "var(--viz-series-1)",
/** Ausgaben, Vorjahr. */
two: "var(--viz-series-2)",
three: "var(--viz-series-3)",
} as const;
export const CHROME = {
grid: "var(--viz-grid)",
axis: "var(--viz-axis)",
label: "var(--viz-label)",
surface: "var(--viz-surface)",
} as const;
export const STATUS = {
good: "var(--viz-good)",
warning: "var(--viz-warning)",
critical: "var(--viz-critical)",
} as const;
/** Achsen und Raster bleiben zurückhaltend: dünne, durchgezogene Linien. */
export const AXIS_PROPS = {
stroke: CHROME.axis,
tickLine: false,
axisLine: false,
tick: { fill: CHROME.label, fontSize: 11 },
} as const;
export const GRID_PROPS = {
stroke: CHROME.grid,
strokeWidth: 1,
vertical: false,
} as const;
/** Abgerundete Balkenenden an der Grundlinie. */
export const BAR_RADIUS: [number, number, number, number] = [4, 4, 0, 0];
/** Zwei Pixel Fläche zwischen benachbarten Balken statt eines Rahmens. */
export const BAR_GAP = 2;
+18 -1
View File
@@ -1,6 +1,17 @@
import { NavLink } from "react-router-dom";
import { Building2, type LucideIcon, Receipt, Repeat, Settings } from "lucide-react";
import {
BarChart3,
Building2,
CalendarDays,
LayoutDashboard,
type LucideIcon,
PiggyBank,
Receipt,
Repeat,
Settings,
Wallet,
} from "lucide-react";
import { cn } from "@/lib/cn";
@@ -11,9 +22,14 @@ interface NavEintrag {
}
const NAVIGATION: NavEintrag[] = [
{ to: "/", label: "Dashboard", icon: LayoutDashboard },
{ to: "/calendar", label: "Kalender", icon: CalendarDays },
{ to: "/recurrences", label: "Wiederkehrend", icon: Repeat },
{ to: "/transactions", label: "Buchungen", icon: Receipt },
{ to: "/merchants", label: "Firmen", icon: Building2 },
{ to: "/budgets", label: "Budgets", icon: Wallet },
{ to: "/goals", label: "Sparziele", icon: PiggyBank },
{ to: "/reports", label: "Auswertungen", icon: BarChart3 },
{ to: "/settings", label: "Einstellungen", icon: Settings },
];
@@ -35,6 +51,7 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
key={to}
to={to}
onClick={onNavigate}
end={to === "/"}
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition",
+128
View File
@@ -0,0 +1,128 @@
/** Budgets, Vorlagen und Sparziele als Stammdaten. */
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { toast } from "@/store/toast";
import type {
Budget,
BudgetInput,
BudgetTemplate,
MessageResponse,
SavingsGoal,
SavingsGoalInput,
} from "@/types/api";
function invalidate(client: ReturnType<typeof useQueryClient>): void {
void client.invalidateQueries({ queryKey: ["budgets"] });
void client.invalidateQueries({ queryKey: ["budget-templates"] });
void client.invalidateQueries({ queryKey: ["reports"] });
}
export function useBudgets(month?: string) {
return useQuery({
queryKey: ["budgets", month ?? ""],
queryFn: () => api.get<Budget[]>("/budgets", month ? { month } : undefined),
});
}
export function useSaveBudget() {
const client = useQueryClient();
return useMutation({
mutationFn: ({ id, daten }: { id?: number; daten: BudgetInput | Partial<BudgetInput> }) =>
id ? api.patch<Budget>(`/budgets/${id}`, daten) : api.post<Budget>("/budgets", daten),
onSuccess: (_budget, variablen) => {
invalidate(client);
toast.success(variablen.id ? "Budget gespeichert." : "Budget angelegt.");
},
});
}
export function useDeleteBudget() {
const client = useQueryClient();
return useMutation({
mutationFn: (id: number) => api.del<MessageResponse>(`/budgets/${id}`),
onSuccess: () => {
invalidate(client);
toast.success("Budget gelöscht.");
},
});
}
export function useBudgetTemplates() {
return useQuery({
queryKey: ["budget-templates"],
queryFn: () => api.get<BudgetTemplate[]>("/budget-templates"),
});
}
export function useSaveBudgetTemplate() {
const client = useQueryClient();
return useMutation({
mutationFn: (daten: {
category_id: number;
valid_from: string;
limit_amount: string;
rollover?: boolean;
}) => api.post<BudgetTemplate>("/budget-templates", daten),
onSuccess: () => {
invalidate(client);
toast.success("Budgetvorlage angelegt.");
},
});
}
export function useDeleteBudgetTemplate() {
const client = useQueryClient();
return useMutation({
mutationFn: (id: number) => api.del<MessageResponse>(`/budget-templates/${id}`),
onSuccess: () => {
invalidate(client);
toast.success("Budgetvorlage gelöscht.");
},
});
}
export function useSavingsGoals(includeArchived = false) {
return useQuery({
queryKey: ["savings-goals", includeArchived],
queryFn: () =>
api.get<SavingsGoal[]>(
"/savings-goals",
includeArchived ? { include_archived: true } : undefined,
),
});
}
export function useSaveGoal() {
const client = useQueryClient();
return useMutation({
mutationFn: ({
id,
daten,
}: {
id?: number;
daten: SavingsGoalInput | Partial<SavingsGoalInput>;
}) =>
id
? api.patch<SavingsGoal>(`/savings-goals/${id}`, daten)
: api.post<SavingsGoal>("/savings-goals", daten),
onSuccess: (_ziel, variablen) => {
void client.invalidateQueries({ queryKey: ["savings-goals"] });
void client.invalidateQueries({ queryKey: ["reports"] });
toast.success(variablen.id ? "Sparziel gespeichert." : "Sparziel angelegt.");
},
});
}
export function useDeleteGoal() {
const client = useQueryClient();
return useMutation({
mutationFn: (id: number) => api.del<MessageResponse>(`/savings-goals/${id}`),
onSuccess: () => {
void client.invalidateQueries({ queryKey: ["savings-goals"] });
void client.invalidateQueries({ queryKey: ["reports"] });
toast.success("Sparziel gelöscht.");
},
});
}
+93
View File
@@ -0,0 +1,93 @@
/** Abfragen der Auswertungen. */
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type {
BudgetStatus,
CalendarMonth,
CategoryReport,
Dashboard,
EntryKind,
Forecast,
MonthReport,
SavingsGoalProgress,
SubscriptionReport,
YearComparison,
} from "@/types/api";
export function useDashboard(month: string) {
return useQuery({
queryKey: ["reports", "dashboard", month],
queryFn: () => api.get<Dashboard>("/reports/dashboard", { month }),
});
}
export function useMonthReport(month: string) {
return useQuery({
queryKey: ["reports", "month", month],
queryFn: () => api.get<MonthReport>("/reports/month", { month }),
});
}
export function useForecast(months = 12, start?: string) {
return useQuery({
queryKey: ["reports", "forecast", months, start ?? ""],
queryFn: () => api.get<Forecast>("/reports/forecast", { months, start }),
});
}
export function useCategoryReport(from: string, to: string, kind: EntryKind = "expense") {
return useQuery({
queryKey: ["reports", "categories", from, to, kind],
queryFn: () => api.get<CategoryReport>("/reports/categories", { from, to, kind }),
});
}
export function useSubscriptions() {
return useQuery({
queryKey: ["reports", "subscriptions"],
queryFn: () => api.get<SubscriptionReport>("/reports/subscriptions"),
});
}
export function useYearComparison(year: number, kind: EntryKind = "expense") {
return useQuery({
queryKey: ["reports", "year-comparison", year, kind],
queryFn: () => api.get<YearComparison>("/reports/year-comparison", { year, kind }),
});
}
export function useCalendar(month: string) {
return useQuery({
queryKey: ["reports", "calendar", month],
queryFn: () => api.get<CalendarMonth>("/reports/calendar", { month }),
});
}
export function useBudgetStatus(month: string) {
return useQuery({
queryKey: ["reports", "budgets", month],
queryFn: () => api.get<BudgetStatus[]>("/reports/budgets", { month }),
});
}
export function useGoalProgress() {
return useQuery({
queryKey: ["reports", "savings-goals"],
queryFn: () => api.get<SavingsGoalProgress[]>("/reports/savings-goals"),
});
}
/** Baut die URL eines Exports; der Browser lädt sie direkt herunter. */
export function exportUrl(
what: "transactions" | "recurrences" | "month",
format: "csv" | "xlsx",
params: Record<string, string | number | undefined> = {},
): string {
const suchparameter = new URLSearchParams({ format });
for (const [schluessel, wert] of Object.entries(params)) {
if (wert !== undefined && wert !== "") suchparameter.set(schluessel, String(wert));
}
return `/api/export/${what}?${suchparameter.toString()}`;
}
+30
View File
@@ -19,6 +19,24 @@
--color-warning: 180 83 9;
}
/* Diagrammfarben, heller Modus.
Die Reihenfolge der Serienfarben ist die Absicherung gegen Farbfehlsichtigkeit
und wurde gegen die Flächen dieser Anwendung geprüft nicht umsortieren.
Grün/Rot wäre als Serienpaar bei Deuteranopie nicht unterscheidbar (ΔE 7,9)
und bleibt deshalb den Vorzeichen im Text vorbehalten. */
:root {
--viz-series-1: #2a78d6;
--viz-series-2: #eb6834;
--viz-series-3: #1baf7a;
--viz-grid: #e2e8f0;
--viz-axis: #94a3b8;
--viz-label: #64748b;
--viz-surface: #ffffff;
--viz-good: #0ca30c;
--viz-warning: #fab219;
--viz-critical: #d03b3b;
}
/* Dunkler Modus die Vorgabe der Anwendung */
.dark {
--color-ground: 15 17 21;
@@ -33,6 +51,18 @@
--color-positive: 74 222 128;
--color-negative: 248 113 113;
--color-warning: 251 191 36;
/* Eigene Stufen für die dunkle Fläche, kein automatisches Umdrehen. */
--viz-series-1: #3987e5;
--viz-series-2: #d95926;
--viz-series-3: #199e70;
--viz-grid: #2b3038;
--viz-axis: #4b5563;
--viz-label: #94a3b8;
--viz-surface: #181b21;
--viz-good: #0ca30c;
--viz-warning: #fab219;
--viz-critical: #d03b3b;
}
html {
+307
View File
@@ -0,0 +1,307 @@
/** Budgets je Monat samt Ampel und dauerhaften Vorlagen. */
import { type FormEvent, useState } from "react";
import { ChevronLeft, ChevronRight, Plus, Trash2, Wallet } from "lucide-react";
import { CategorySelect } from "@/components/EntitySelects";
import { BudgetMeter } from "@/components/charts/BudgetMeter";
import { PageHeader } from "@/components/layout/AppLayout";
import { Button } from "@/components/ui/Button";
import { Badge, ConfirmDialog, EmptyState, Skeleton } from "@/components/ui/Feedback";
import { Checkbox, Field, Input } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { MoneyInput } from "@/components/ui/MoneyInput";
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
import {
useBudgetTemplates,
useBudgets,
useDeleteBudget,
useDeleteBudgetTemplate,
useSaveBudget,
useSaveBudgetTemplate,
} from "@/hooks/useBudgets";
import { useBudgetStatus } from "@/hooks/useReports";
import { addMonthsIso, firstOfMonth, formatDate, formatMoney, formatMonth } from "@/lib/format";
import type { Budget, BudgetTemplate } from "@/types/api";
export function BudgetsPage() {
const [monat, setMonat] = useState(() => firstOfMonth());
const [formular, setFormular] = useState<"budget" | "template" | null>(null);
const [loeschen, setLoeschen] = useState<Budget | null>(null);
const [vorlageLoeschen, setVorlageLoeschen] = useState<BudgetTemplate | null>(null);
const { data: status = [], isLoading } = useBudgetStatus(monat);
const { data: budgets = [] } = useBudgets(monat);
const { data: vorlagen = [] } = useBudgetTemplates();
const entfernen = useDeleteBudget();
const vorlageEntfernen = useDeleteBudgetTemplate();
const kategorieName = useCategoryLookup();
return (
<>
<PageHeader
title="Budgets"
description={formatMonth(monat)}
actions={
<>
<div className="flex items-center gap-1">
<Button
size="sm"
onClick={() => setMonat((alt) => addMonthsIso(alt, -1))}
aria-label="Vorheriger Monat"
>
<ChevronLeft aria-hidden className="h-4 w-4" />
</Button>
<Button size="sm" onClick={() => setMonat(firstOfMonth())}>
Heute
</Button>
<Button
size="sm"
onClick={() => setMonat((alt) => addMonthsIso(alt, 1))}
aria-label="Nächster Monat"
>
<ChevronRight aria-hidden className="h-4 w-4" />
</Button>
</div>
<Button onClick={() => setFormular("template")}>Vorlage</Button>
<Button variant="primary" onClick={() => setFormular("budget")}>
<Plus aria-hidden className="h-4 w-4" />
Budget
</Button>
</>
}
/>
{isLoading ? (
<Skeleton className="h-64" />
) : status.length === 0 ? (
<EmptyState
icon={Wallet}
title="Keine Budgets in diesem Monat"
description="Ein Budget zeigt, wie viel einer Kategorie noch bleibt. Mit einer Vorlage gilt es dauerhaft ab einem Monat."
action={
<Button variant="primary" onClick={() => setFormular("budget")}>
<Plus aria-hidden className="h-4 w-4" />
Erstes Budget anlegen
</Button>
}
/>
) : (
<ul className="grid gap-2 lg:grid-cols-2">
{status.map((eintrag) => (
<BudgetMeter key={eintrag.category_id} budget={eintrag} />
))}
</ul>
)}
{budgets.length > 0 && (
<section className="mt-6">
<h2 className="mb-2 text-sm font-semibold text-ink">
Einzelbudgets in diesem Monat
</h2>
<ul className="divide-y divide-line rounded-card border border-line">
{budgets.map((budget) => (
<li key={budget.id} className="group flex items-center gap-3 px-3 py-2 text-sm">
<span className="min-w-0 flex-1 truncate text-ink">
{kategorieName(budget.category_id)}
</span>
{budget.rollover && <Badge>Übertrag</Badge>}
<span className="tabular text-muted">{formatMoney(budget.limit_amount)}</span>
<Button
size="sm"
variant="ghost"
className="opacity-0 transition group-hover:opacity-100 focus:opacity-100"
onClick={() => setLoeschen(budget)}
aria-label="Budget löschen"
>
<Trash2 aria-hidden className="h-3.5 w-3.5" />
</Button>
</li>
))}
</ul>
</section>
)}
{vorlagen.length > 0 && (
<section className="mt-6">
<h2 className="mb-2 text-sm font-semibold text-ink">Dauerhafte Vorlagen</h2>
<ul className="divide-y divide-line rounded-card border border-line">
{vorlagen.map((vorlage) => (
<li key={vorlage.id} className="group flex items-center gap-3 px-3 py-2 text-sm">
<span className="min-w-0 flex-1 truncate text-ink">
{kategorieName(vorlage.category_id)}
</span>
<span className="text-xs text-muted">
ab {formatDate(vorlage.valid_from)}
{vorlage.valid_until && ` bis ${formatDate(vorlage.valid_until)}`}
</span>
{vorlage.rollover && <Badge>Übertrag</Badge>}
<span className="tabular text-muted">{formatMoney(vorlage.limit_amount)}</span>
<Button
size="sm"
variant="ghost"
className="opacity-0 transition group-hover:opacity-100 focus:opacity-100"
onClick={() => setVorlageLoeschen(vorlage)}
aria-label="Vorlage löschen"
>
<Trash2 aria-hidden className="h-3.5 w-3.5" />
</Button>
</li>
))}
</ul>
</section>
)}
<BudgetDialog
kind={formular}
month={monat}
open={formular !== null}
onClose={() => setFormular(null)}
/>
<ConfirmDialog
open={loeschen !== null}
title="Budget löschen"
description="Das Budget dieses Monats wird entfernt."
loading={entfernen.isPending}
onCancel={() => setLoeschen(null)}
onConfirm={() => {
if (loeschen) entfernen.mutate(loeschen.id, { onSuccess: () => setLoeschen(null) });
}}
/>
<ConfirmDialog
open={vorlageLoeschen !== null}
title="Vorlage löschen"
description="Die Vorlage gilt danach in keinem Monat mehr."
loading={vorlageEntfernen.isPending}
onCancel={() => setVorlageLoeschen(null)}
onConfirm={() => {
if (vorlageLoeschen)
vorlageEntfernen.mutate(vorlageLoeschen.id, {
onSuccess: () => setVorlageLoeschen(null),
});
}}
/>
</>
);
}
function BudgetDialog({
kind,
month,
open,
onClose,
}: {
kind: "budget" | "template" | null;
month: string;
open: boolean;
onClose: () => void;
}) {
const [kategorie, setKategorie] = useState<number | null>(null);
const [betrag, setBetrag] = useState("");
const [uebertrag, setUebertrag] = useState(false);
const [gueltigAb, setGueltigAb] = useState(month);
const budgetSpeichern = useSaveBudget();
const vorlageSpeichern = useSaveBudgetTemplate();
const laeuft = budgetSpeichern.isPending || vorlageSpeichern.isPending;
function absenden(ereignis: FormEvent) {
ereignis.preventDefault();
if (kategorie === null || !betrag) return;
const fertig = {
onSuccess: () => {
setKategorie(null);
setBetrag("");
setUebertrag(false);
onClose();
},
};
if (kind === "template") {
vorlageSpeichern.mutate(
{
category_id: kategorie,
valid_from: gueltigAb,
limit_amount: betrag,
rollover: uebertrag,
},
fertig,
);
} else {
budgetSpeichern.mutate(
{
daten: {
category_id: kategorie,
period_month: month,
limit_amount: betrag,
rollover: uebertrag,
},
},
fertig,
);
}
}
return (
<Modal
open={open}
onClose={onClose}
size="sm"
title={kind === "template" ? "Neue Budgetvorlage" : "Neues Budget"}
description={
kind === "template"
? "Gilt ab dem gewählten Monat dauerhaft, bis eine neue Vorlage sie ablöst."
: `Gilt nur für ${formatMonth(month)}.`
}
footer={
<>
<Button onClick={onClose}>Abbrechen</Button>
<Button
variant="primary"
onClick={absenden}
loading={laeuft}
disabled={kategorie === null || !betrag}
>
Speichern
</Button>
</>
}
>
<form onSubmit={absenden} className="space-y-3">
<Field label="Kategorie" required hint="Budgets auf einer Oberkategorie umfassen deren Unterkategorien.">
{(id) => (
<CategorySelect id={id} required kind="expense" value={kategorie} onChange={setKategorie} />
)}
</Field>
{kind === "template" && (
<Field label="Gültig ab" required>
{(id) => (
<Input
id={id}
type="month"
value={gueltigAb.slice(0, 7)}
onChange={(ereignis) => setGueltigAb(`${ereignis.target.value}-01`)}
/>
)}
</Field>
)}
<Field label="Betrag" required>
{(id) => <MoneyInput id={id} value={betrag} onValueChange={setBetrag} autoFocus />}
</Field>
<Checkbox
label="Rest in den Folgemonat übertragen"
hint="Nicht verbrauchtes Budget erhöht das Budget des nächsten Monats."
checked={uebertrag}
onChange={(ereignis) => setUebertrag(ereignis.target.checked)}
/>
</form>
</Modal>
);
}
+202
View File
@@ -0,0 +1,202 @@
/** Tests des Cashflow-Kalenders. */
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CalendarPage } from "@/pages/CalendarPage";
import { renderWithProviders } from "@/test/utils";
import type { CalendarDay, CalendarEntry } from "@/types/api";
function eintrag(overrides: Partial<CalendarEntry> = {}): CalendarEntry {
return {
title: "Miete",
kind: "expense",
amount: "950.00",
category_id: 11,
merchant_id: null,
account_id: 1,
source: "recurrence",
recurrence_id: 7,
occurrence_date: "2026-03-01",
status: "planned",
is_variable: false,
...overrides,
};
}
/** Baut einen vollständigen März 2026 mit zwei belegten Tagen. */
function maerz(): CalendarDay[] {
return Array.from({ length: 31 }, (_, index) => {
const tag = index + 1;
const datum = `2026-03-${String(tag).padStart(2, "0")}`;
const wochentag = new Date(datum).getDay();
if (tag === 2) {
return {
date: datum,
entries: [eintrag({ occurrence_date: "2026-03-01" })],
net: "-950.00",
running_balance: "50.00",
is_business_day: true,
};
}
if (tag === 28) {
return {
date: datum,
entries: [
eintrag({
title: "Gehalt",
kind: "income",
amount: "3200.00",
recurrence_id: 8,
occurrence_date: "2026-03-28",
}),
],
net: "3200.00",
running_balance: "3250.00",
is_business_day: true,
};
}
return {
date: datum,
entries: [],
net: "0.00",
running_balance: tag < 2 ? "1000.00" : tag < 28 ? "50.00" : "3250.00",
is_business_day: wochentag !== 0 && wochentag !== 6,
};
});
}
function mockApi() {
const anfragen: { url: string; method: string; body: unknown }[] = [];
const json = (daten: unknown) =>
new Response(JSON.stringify(daten), {
status: 200,
headers: { "content-type": "application/json" },
});
vi.stubGlobal(
"fetch",
vi.fn(async (eingabe: RequestInfo | URL, init?: RequestInit) => {
const url = typeof eingabe === "string" ? eingabe : eingabe.toString();
anfragen.push({
url,
method: init?.method ?? "GET",
body: typeof init?.body === "string" ? JSON.parse(init.body) : null,
});
if (url.includes("/api/reports/calendar")) {
return json({
month: "2026-03-01",
days: maerz(),
opening_balance: "1000.00",
closing_balance: "3250.00",
lowest_balance: "50.00",
lowest_balance_on: "2026-03-02",
});
}
if (url.includes("/api/merchants")) {
return json({ items: [], total: 0, limit: 200, offset: 0 });
}
if (url.includes("/api/occurrences/")) {
return json({});
}
return json({});
}),
);
return anfragen;
}
describe("Cashflow-Kalender", () => {
beforeEach(() => {
mockApi();
});
it("zeigt die Kennzahlen des Monats", async () => {
renderWithProviders(<CalendarPage />);
await waitFor(() => {
expect(screen.getByText("Stand zu Monatsbeginn")).toBeInTheDocument();
});
expect(screen.getByText(/1.000,00/)).toBeInTheDocument();
expect(screen.getByText("Tiefster Stand")).toBeInTheDocument();
expect(screen.getByText(/am 02\.03\.2026/)).toBeInTheDocument();
});
it("rendert ein vollständiges Monatsraster", async () => {
renderWithProviders(<CalendarPage />);
await waitFor(() => {
expect(screen.getByLabelText(/02\.03\.2026/)).toBeInTheDocument();
});
// 31 Tage im März.
expect(screen.getAllByRole("button", { name: /\d{2}\.03\.2026/ })).toHaveLength(31);
});
it("öffnet die Tagesansicht mit den Posten", async () => {
const nutzer = userEvent.setup();
renderWithProviders(<CalendarPage />);
await waitFor(() => expect(screen.getByLabelText(/02\.03\.2026/)).toBeInTheDocument());
await nutzer.click(screen.getByLabelText(/02\.03\.2026/));
const detail = await screen.findByRole("heading", { name: "02.03.2026" });
const bereich = detail.closest("section")!;
// Der Betrag steht sowohl in der Zeile als auch im Tagessaldo hier zählt die Zeile.
const zeile = within(bereich).getByRole("listitem");
expect(within(zeile).getByText("Miete")).toBeInTheDocument();
expect(within(zeile).getByText(/950,00/)).toBeInTheDocument();
expect(within(zeile).getByText("Geplant")).toBeInTheDocument();
});
it("bestätigt eine Fälligkeit über das nominale Datum", async () => {
const anfragen = mockApi();
const nutzer = userEvent.setup();
renderWithProviders(<CalendarPage />);
await waitFor(() => expect(screen.getByLabelText(/02\.03\.2026/)).toBeInTheDocument());
await nutzer.click(screen.getByLabelText(/02\.03\.2026/));
await nutzer.click(await screen.findByRole("button", { name: "Miete bestätigen" }));
await waitFor(() => {
const bestaetigung = anfragen.find((e) => e.url.includes("/occurrences/confirm"));
expect(bestaetigung).toBeDefined();
// Der Zahltag ist der 02.03., der Schlüssel bleibt der 01.03.
expect(bestaetigung?.body).toEqual({
recurrence_id: 7,
occurrence_date: "2026-03-01",
});
});
});
it("meldet leere Tage verständlich", async () => {
const nutzer = userEvent.setup();
renderWithProviders(<CalendarPage />);
await waitFor(() => expect(screen.getByLabelText(/05\.03\.2026/)).toBeInTheDocument());
await nutzer.click(screen.getByLabelText(/05\.03\.2026/));
expect(await screen.findByText("Nichts fällig")).toBeInTheDocument();
});
it("blättert in den Vormonat", async () => {
const anfragen = mockApi();
const nutzer = userEvent.setup();
renderWithProviders(<CalendarPage />);
await waitFor(() => expect(screen.getByLabelText(/02\.03\.2026/)).toBeInTheDocument());
const vorher = anfragen.filter((e) => e.url.includes("/reports/calendar")).length;
await nutzer.click(screen.getByRole("button", { name: "Vorheriger Monat" }));
await waitFor(() => {
expect(anfragen.filter((e) => e.url.includes("/reports/calendar")).length).toBeGreaterThan(
vorher,
);
});
});
});
+441
View File
@@ -0,0 +1,441 @@
/**
* Cashflow-Kalender.
*
* Monatsraster mit den Fälligkeiten je Tag; darunter der Verlauf des
* Kontostands über den Monat.
*/
import { useState } from "react";
import { CalendarDays, Check, ChevronLeft, ChevronRight, SkipForward } from "lucide-react";
import {
Area,
AreaChart,
CartesianGrid,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { ChartCard, ChartTooltip, StatTile } from "@/components/charts/ChartFrame";
import { AXIS_PROPS, GRID_PROPS, SERIES } from "@/components/charts/chartTheme";
import { MerchantLogo } from "@/components/MerchantLogo";
import { PageHeader } from "@/components/layout/AppLayout";
import { Button } from "@/components/ui/Button";
import { EmptyState, Skeleton } from "@/components/ui/Feedback";
import { useConfirmOccurrence, useMerchants, useSkipOccurrence } from "@/hooks/useEntities";
import { useCalendar } from "@/hooks/useReports";
import { cn } from "@/lib/cn";
import { addMonthsIso, firstOfMonth, formatDate, formatMoney, formatMonth, todayIso, toNumber } from "@/lib/format";
import type { CalendarDay, CalendarEntry, Merchant } from "@/types/api";
const WOCHENTAGE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
/** Führende Leerfelder, damit der Monat am richtigen Wochentag beginnt. */
function fuehrendeLeerfelder(erster: string): number {
const datum = new Date(erster);
return (datum.getDay() + 6) % 7;
}
export function CalendarPage() {
const [monat, setMonat] = useState(() => firstOfMonth());
const [gewaehlterTag, setGewaehlterTag] = useState<string | null>(null);
const { data, isLoading } = useCalendar(monat);
const { data: firmenSeite } = useMerchants();
const firmen = new Map((firmenSeite?.items ?? []).map((firma) => [firma.id, firma]));
const tage = data?.days ?? [];
const heute = todayIso();
const detailTag = tage.find((tag) => tag.date === gewaehlterTag) ?? null;
return (
<>
<PageHeader
title="Cashflow-Kalender"
description={formatMonth(monat)}
actions={
<div className="flex items-center gap-1">
<Button
size="sm"
onClick={() => {
setMonat((alt) => addMonthsIso(alt, -1));
setGewaehlterTag(null);
}}
aria-label="Vorheriger Monat"
>
<ChevronLeft aria-hidden className="h-4 w-4" />
</Button>
<Button
size="sm"
onClick={() => {
setMonat(firstOfMonth());
setGewaehlterTag(null);
}}
>
Heute
</Button>
<Button
size="sm"
onClick={() => {
setMonat((alt) => addMonthsIso(alt, 1));
setGewaehlterTag(null);
}}
aria-label="Nächster Monat"
>
<ChevronRight aria-hidden className="h-4 w-4" />
</Button>
</div>
}
/>
{isLoading || !data ? (
<div className="space-y-3">
<Skeleton className="h-96" />
<Skeleton className="h-48" />
</div>
) : (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<StatTile label="Stand zu Monatsbeginn" value={formatMoney(data.opening_balance)} />
<StatTile
label="Stand zum Monatsende"
value={formatMoney(data.closing_balance)}
tone={toNumber(data.closing_balance) < 0 ? "negative" : "default"}
/>
<StatTile
label="Tiefster Stand"
value={formatMoney(data.lowest_balance)}
tone={toNumber(data.lowest_balance) < 0 ? "negative" : "default"}
hint={data.lowest_balance_on ? `am ${formatDate(data.lowest_balance_on)}` : undefined}
/>
</div>
<section className="card p-3">
<div className="mb-1 grid grid-cols-7 gap-1.5">
{WOCHENTAGE.map((tag) => (
<div key={tag} className="py-1 text-center text-[11px] font-medium text-muted">
{tag}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1.5">
{Array.from({ length: fuehrendeLeerfelder(data.month) }, (_, index) => (
<div key={`leer-${index}`} aria-hidden />
))}
{tage.map((tag) => (
<TagZelle
key={tag.date}
tag={tag}
istHeute={tag.date === heute}
istGewaehlt={tag.date === gewaehlterTag}
firmen={firmen}
onSelect={() => setGewaehlterTag(tag.date === gewaehlterTag ? null : tag.date)}
/>
))}
</div>
</section>
{detailTag && (
<TagDetail
tag={detailTag}
firmen={firmen}
onClose={() => setGewaehlterTag(null)}
/>
)}
<ChartCard
title="Verlauf des Kontostands"
description="Fortgeschrieben aus dem Stand zu Monatsbeginn."
>
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={tage.map((tag) => ({
tag: new Date(tag.date).getDate(),
stand: toNumber(tag.running_balance),
}))}
margin={{ top: 8, right: 8, bottom: 0, left: 4 }}
>
<defs>
<linearGradient id="verlauf" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={SERIES.one} stopOpacity={0.28} />
<stop offset="100%" stopColor={SERIES.one} stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid {...GRID_PROPS} />
<XAxis dataKey="tag" {...AXIS_PROPS} />
<YAxis
{...AXIS_PROPS}
width={52}
tickFormatter={(wert: number) =>
new Intl.NumberFormat("de-DE", {
notation: "compact",
maximumFractionDigits: 1,
}).format(wert)
}
/>
<ReferenceLine y={0} stroke="var(--viz-axis)" strokeWidth={1} />
<Tooltip
cursor={{ stroke: "var(--viz-grid)", strokeWidth: 1 }}
content={({ active, payload, label }) =>
active && payload?.length ? (
<ChartTooltip
title={`${label}. ${formatMonth(monat)}`}
rows={[
{
label: "Kontostand",
value: formatMoney(payload[0]?.payload.stand ?? 0),
color: SERIES.one,
},
]}
/>
) : null
}
/>
<Area
type="monotone"
dataKey="stand"
stroke={SERIES.one}
strokeWidth={2}
fill="url(#verlauf)"
dot={false}
activeDot={{ r: 5, strokeWidth: 2, stroke: "var(--viz-surface)" }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</ChartCard>
</div>
)}
</>
);
}
function TagZelle({
tag,
istHeute,
istGewaehlt,
firmen,
onSelect,
}: {
tag: CalendarDay;
istHeute: boolean;
istGewaehlt: boolean;
firmen: Map<number, Merchant>;
onSelect: () => void;
}) {
const netto = toNumber(tag.net);
const tagesZahl = new Date(tag.date).getDate();
return (
<button
type="button"
onClick={onSelect}
aria-pressed={istGewaehlt}
aria-label={`${formatDate(tag.date)}, ${tag.entries.length} Posten, Saldo ${formatMoney(tag.net)}`}
className={cn(
"flex min-h-[5.5rem] flex-col rounded-lg border p-1.5 text-left transition",
istGewaehlt ? "border-accent bg-accent/10" : "border-line hover:border-faint",
!tag.is_business_day && "bg-raised/60",
)}
>
<div className="flex items-center justify-between">
<span
className={cn(
"text-xs tabular",
istHeute
? "flex h-5 w-5 items-center justify-center rounded-full bg-accent font-semibold text-accent-ink"
: tag.is_business_day
? "text-muted"
: "text-faint",
)}
>
{tagesZahl}
</span>
{netto !== 0 && (
<span
className={cn("tabular text-[10px]", netto < 0 ? "text-negative" : "text-positive")}
>
{netto > 0 ? "+" : ""}
{new Intl.NumberFormat("de-DE", {
notation: "compact",
maximumFractionDigits: 0,
}).format(Math.abs(netto))}
</span>
)}
</div>
<div className="mt-1 flex flex-wrap gap-0.5">
{tag.entries.slice(0, 4).map((eintrag, index) => {
const firma = eintrag.merchant_id ? firmen.get(eintrag.merchant_id) : undefined;
return (
<MerchantLogo
key={`${eintrag.title}-${index}`}
merchant={
firma ?? {
name: eintrag.title,
logo_asset_id: null,
brand_color: null,
brand_color_dark: null,
}
}
size={18}
/>
);
})}
{tag.entries.length > 4 && (
<span className="self-center text-[10px] text-faint">+{tag.entries.length - 4}</span>
)}
</div>
</button>
);
}
function TagDetail({
tag,
firmen,
onClose,
}: {
tag: CalendarDay;
firmen: Map<number, Merchant>;
onClose: () => void;
}) {
const bestaetigen = useConfirmOccurrence();
const auslassen = useSkipOccurrence();
return (
<section className="card p-4">
<header className="mb-2 flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-ink">{formatDate(tag.date)}</h2>
<div className="flex items-center gap-3 text-xs">
<span className="text-muted">
Saldo{" "}
<span className={cn("tabular", toNumber(tag.net) < 0 ? "text-negative" : "text-positive")}>
{formatMoney(tag.net)}
</span>
</span>
<Button size="sm" variant="ghost" onClick={onClose}>
Schließen
</Button>
</div>
</header>
{tag.entries.length === 0 ? (
<EmptyState
icon={CalendarDays}
title="Nichts fällig"
description="An diesem Tag steht keine Zahlung an."
/>
) : (
<ul className="divide-y divide-line">
{tag.entries.map((eintrag, index) => (
<EintragZeile
key={`${eintrag.recurrence_id ?? "t"}-${eintrag.occurrence_date ?? index}`}
eintrag={eintrag}
firma={eintrag.merchant_id ? firmen.get(eintrag.merchant_id) : undefined}
onConfirm={() =>
eintrag.recurrence_id &&
eintrag.occurrence_date &&
bestaetigen.mutate({
recurrence_id: eintrag.recurrence_id,
occurrence_date: eintrag.occurrence_date,
})
}
onSkip={() =>
eintrag.recurrence_id &&
eintrag.occurrence_date &&
auslassen.mutate({
recurrence_id: eintrag.recurrence_id,
occurrence_date: eintrag.occurrence_date,
})
}
busy={bestaetigen.isPending || auslassen.isPending}
/>
))}
</ul>
)}
</section>
);
}
function EintragZeile({
eintrag,
firma,
onConfirm,
onSkip,
busy,
}: {
eintrag: CalendarEntry;
firma: Merchant | undefined;
onConfirm: () => void;
onSkip: () => void;
busy: boolean;
}) {
const bestaetigbar = eintrag.source === "recurrence" && eintrag.status === "planned";
return (
<li className="flex items-center gap-3 py-2">
<MerchantLogo
merchant={
firma ?? {
name: eintrag.title,
logo_asset_id: null,
brand_color: null,
brand_color_dark: null,
}
}
size={32}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-ink">{eintrag.title}</p>
<p className="text-xs text-faint">
{eintrag.source === "transaction"
? "Einmalige Buchung"
: eintrag.status === "confirmed"
? "Bestätigt"
: "Geplant"}
{eintrag.is_variable && " · geschätzt"}
</p>
</div>
<span
className={cn(
"shrink-0 tabular text-sm font-medium",
eintrag.kind === "income" ? "text-positive" : "text-ink",
)}
>
{eintrag.kind === "income" ? "+" : ""}
{formatMoney(eintrag.amount)}
</span>
{bestaetigbar && (
<div className="flex shrink-0 gap-1">
<Button
size="sm"
variant="ghost"
onClick={onConfirm}
disabled={busy}
aria-label={`${eintrag.title} bestätigen`}
>
<Check aria-hidden className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={onSkip}
disabled={busy}
aria-label={`${eintrag.title} auslassen`}
>
<SkipForward aria-hidden className="h-3.5 w-3.5" />
</Button>
</div>
)}
</li>
);
}
+262
View File
@@ -0,0 +1,262 @@
/** Dashboard: die Kennzahlen des Monats auf einen Blick. */
import { useState } from "react";
import { Link } from "react-router-dom";
import { AlertTriangle, ChevronLeft, ChevronRight, PiggyBank, Wallet } from "lucide-react";
import { CategoryDonut } from "@/components/charts/CategoryDonut";
import { BudgetMeter } from "@/components/charts/BudgetMeter";
import { ChartCard, DeltaHint, StatTile } from "@/components/charts/ChartFrame";
import { ForecastChart } from "@/components/charts/ForecastChart";
import { MerchantLogo } from "@/components/MerchantLogo";
import { PageHeader } from "@/components/layout/AppLayout";
import { Button } from "@/components/ui/Button";
import { Badge, EmptyState, Skeleton } from "@/components/ui/Feedback";
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
import { useMerchants } from "@/hooks/useEntities";
import { useDashboard } from "@/hooks/useReports";
import { addMonthsIso, firstOfMonth, formatDate, formatMoney, formatMonth, relativeDays } from "@/lib/format";
export function DashboardPage() {
const [monat, setMonat] = useState(() => firstOfMonth());
const { data, isLoading } = useDashboard(monat);
const { data: firmenSeite } = useMerchants();
const kategorieName = useCategoryLookup();
const firmen = new Map((firmenSeite?.items ?? []).map((firma) => [firma.id, firma]));
return (
<>
<PageHeader
title="Dashboard"
description={formatMonth(monat)}
actions={
<div className="flex items-center gap-1">
<Button
size="sm"
onClick={() => setMonat((alt) => addMonthsIso(alt, -1))}
aria-label="Vorheriger Monat"
>
<ChevronLeft aria-hidden className="h-4 w-4" />
</Button>
<Button size="sm" onClick={() => setMonat(firstOfMonth())}>
Heute
</Button>
<Button
size="sm"
onClick={() => setMonat((alt) => addMonthsIso(alt, 1))}
aria-label="Nächster Monat"
>
<ChevronRight aria-hidden className="h-4 w-4" />
</Button>
</div>
}
/>
{isLoading || !data ? (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }, (_, index) => (
<Skeleton key={index} className="h-24" />
))}
</div>
<Skeleton className="h-72" />
</div>
) : (
<div className="space-y-4">
{/* Die große Kennzahl steht allein sie ist die Antwort auf die
häufigste Frage und braucht kein Diagramm. */}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<StatTile
label="Verfügbar nach Fixkosten"
value={formatMoney(data.month.available_after_fixed)}
large
tone={
Number.parseFloat(data.month.available_after_fixed) < 0 ? "negative" : "positive"
}
hint={`Einkünfte ${formatMoney(data.month.planned.income)} abzüglich Fixkosten ${formatMoney(
data.month.fixed_costs,
)}${
data.month.reserves !== "0.00"
? ` und Rücklagen ${formatMoney(data.month.reserves)}`
: ""
}`}
/>
<StatTile
label="Einkünfte im Monat"
value={formatMoney(data.month.planned.income)}
hint={<DeltaHint value={data.month.delta_to_previous.income} />}
/>
<StatTile
label="Ausgaben im Monat"
value={formatMoney(data.month.planned.expenses)}
hint={
<DeltaHint value={data.month.delta_to_previous.expenses} goodWhenPositive={false} />
}
/>
<StatTile
label="Kontostand heute"
value={formatMoney(data.total_balance)}
hint={`${data.month.open_count} offen, ${data.month.confirmed_count} bestätigt`}
/>
</div>
{data.upcoming_deadlines.length > 0 && (
<div className="card border-warning/40 p-4">
<h2 className="flex items-center gap-2 text-sm font-semibold text-warning">
<AlertTriangle aria-hidden className="h-4 w-4" />
Kündigungsfristen laufen ab
</h2>
<ul className="mt-2 space-y-1">
{data.upcoming_deadlines.map((eintrag) => (
<li key={eintrag.recurrence_id} className="flex flex-wrap gap-x-2 text-xs">
<span className="font-medium text-ink">{eintrag.title}</span>
<span className="text-muted">
kündbar bis {formatDate(eintrag.contract_term?.notice_deadline)}
</span>
<Badge tone="warning">noch {eintrag.days_until_notice} Tage</Badge>
</li>
))}
</ul>
</div>
)}
<div className="grid gap-3 lg:grid-cols-2">
<CategoryDonut
categories={data.categories}
description={`Ausgaben im ${formatMonth(monat)}. Klick auf eine Kategorie zeigt die Unterkategorien.`}
/>
<ChartCard
title="Nächste Fälligkeiten"
description="Die kommenden 14 Tage."
>
{data.upcoming.length === 0 ? (
<p className="py-8 text-center text-xs text-muted">
In den nächsten zwei Wochen steht nichts an.
</p>
) : (
<ul className="divide-y divide-line">
{data.upcoming.slice(0, 8).map((eintrag, index) => {
const firma = eintrag.merchant_id ? firmen.get(eintrag.merchant_id) : undefined;
return (
<li
key={`${eintrag.recurrence_id ?? "t"}-${eintrag.occurrence_date ?? index}`}
className="flex items-center gap-2.5 py-2"
>
<MerchantLogo
merchant={
firma ?? {
name: eintrag.title,
logo_asset_id: null,
brand_color: null,
brand_color_dark: null,
}
}
size={28}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-ink">{eintrag.title}</p>
<p className="truncate text-xs text-faint">
{kategorieName(eintrag.category_id)}
</p>
</div>
<span
className={`shrink-0 tabular text-sm ${
eintrag.kind === "income" ? "text-positive" : "text-ink"
}`}
>
{eintrag.kind === "income" ? "+" : ""}
{formatMoney(eintrag.amount)}
</span>
</li>
);
})}
</ul>
)}
<Link
to="/calendar"
className="mt-2 inline-block text-xs text-accent transition hover:underline"
>
Zum Cashflow-Kalender
</Link>
</ChartCard>
</div>
<ForecastChart months={data.forecast} />
<div className="grid gap-3 lg:grid-cols-2">
<section>
<h2 className="mb-2 text-sm font-semibold text-ink">Budgets</h2>
{data.budgets.length === 0 ? (
<EmptyState
icon={Wallet}
title="Keine Budgets für diesen Monat"
description="Lege ein Budget an, um den Verbrauch je Kategorie im Blick zu behalten."
action={
<Link to="/budgets">
<Button variant="primary">Budgets pflegen</Button>
</Link>
}
/>
) : (
<ul className="space-y-2">
{data.budgets.map((budget) => (
<BudgetMeter key={budget.category_id} budget={budget} />
))}
</ul>
)}
</section>
<section>
<h2 className="mb-2 text-sm font-semibold text-ink">Sparziele</h2>
{data.goals.length === 0 ? (
<EmptyState
icon={PiggyBank}
title="Keine Sparziele"
description="Lege ein Ziel an moneyfy rechnet die nötige Monatsrate aus."
action={
<Link to="/goals">
<Button variant="primary">Sparziel anlegen</Button>
</Link>
}
/>
) : (
<ul className="space-y-2">
{data.goals.map((ziel) => (
<li key={ziel.goal_id} className="card p-3">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-sm font-medium text-ink">{ziel.name}</span>
<span className="shrink-0 tabular text-sm text-ink">
{formatMoney(ziel.current_amount)}
<span className="text-faint"> / {formatMoney(ziel.target_amount)}</span>
</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-raised">
<div
className="h-full rounded-full transition-all"
style={{
width: `${Math.min(ziel.ratio, 1) * 100}%`,
backgroundColor: ziel.color,
}}
/>
</div>
<p className="mt-1.5 text-xs text-muted">
{ziel.target_date
? `Bis ${formatDate(ziel.target_date)} (${relativeDays(ziel.target_date)}) · ${formatMoney(
ziel.required_monthly,
)} pro Monat nötig`
: "Ohne Zieldatum"}
</p>
</li>
))}
</ul>
)}
</section>
</div>
</div>
)}
</>
);
}
+306
View File
@@ -0,0 +1,306 @@
/** Sparziele mit Fortschritt und nötiger Monatsrate. */
import { type FormEvent, useState } from "react";
import { AlertTriangle, CheckCircle2, Pencil, PiggyBank, Plus, Trash2 } from "lucide-react";
import { AccountSelect } from "@/components/EntitySelects";
import { PageHeader } from "@/components/layout/AppLayout";
import { Button } from "@/components/ui/Button";
import { Badge, ConfirmDialog, EmptyState, Skeleton } from "@/components/ui/Feedback";
import { Field, Input } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { MoneyInput } from "@/components/ui/MoneyInput";
import { useDeleteGoal, useSaveGoal, useSavingsGoals } from "@/hooks/useBudgets";
import { useGoalProgress } from "@/hooks/useReports";
import { formatDate, formatMoney, relativeDays } from "@/lib/format";
import type { SavingsGoal, SavingsGoalProgress } from "@/types/api";
export function GoalsPage() {
const [bearbeiten, setBearbeiten] = useState<SavingsGoal | null | undefined>(undefined);
const [loeschen, setLoeschen] = useState<SavingsGoal | null>(null);
const { data: ziele = [], isLoading } = useSavingsGoals();
const { data: fortschritt = [] } = useGoalProgress();
const entfernen = useDeleteGoal();
const nachId = new Map(fortschritt.map((eintrag) => [eintrag.goal_id, eintrag]));
return (
<>
<PageHeader
title="Sparziele"
description="Fortschritt und die Rate, die bis zum Zieldatum nötig ist."
actions={
<Button variant="primary" onClick={() => setBearbeiten(null)}>
<Plus aria-hidden className="h-4 w-4" />
Sparziel
</Button>
}
/>
{isLoading ? (
<div className="grid gap-3 sm:grid-cols-2">
{Array.from({ length: 2 }, (_, index) => (
<Skeleton key={index} className="h-40" />
))}
</div>
) : ziele.length === 0 ? (
<EmptyState
icon={PiggyBank}
title="Noch keine Sparziele"
description="Ob neues Fahrrad oder Rücklage fürs Auto moneyfy rechnet aus, was du monatlich zurücklegen musst."
action={
<Button variant="primary" onClick={() => setBearbeiten(null)}>
<Plus aria-hidden className="h-4 w-4" />
Erstes Sparziel anlegen
</Button>
}
/>
) : (
<ul className="grid gap-3 sm:grid-cols-2">
{ziele.map((ziel) => (
<GoalCard
key={ziel.id}
goal={ziel}
progress={nachId.get(ziel.id)}
onEdit={() => setBearbeiten(ziel)}
onDelete={() => setLoeschen(ziel)}
/>
))}
</ul>
)}
<GoalDialog
goal={bearbeiten}
open={bearbeiten !== undefined}
onClose={() => setBearbeiten(undefined)}
/>
<ConfirmDialog
open={loeschen !== null}
title="Sparziel löschen"
description={`${loeschen?.name}“ wird endgültig gelöscht.`}
loading={entfernen.isPending}
onCancel={() => setLoeschen(null)}
onConfirm={() => {
if (loeschen) entfernen.mutate(loeschen.id, { onSuccess: () => setLoeschen(null) });
}}
/>
</>
);
}
function GoalCard({
goal,
progress,
onEdit,
onDelete,
}: {
goal: SavingsGoal;
progress: SavingsGoalProgress | undefined;
onEdit: () => void;
onDelete: () => void;
}) {
const anteil = Math.min(progress?.ratio ?? 0, 1);
const erreicht = anteil >= 1;
return (
<li className="card group p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="truncate text-sm font-semibold text-ink">{goal.name}</h3>
<p className="text-xs text-muted">
{goal.target_date
? `Ziel bis ${formatDate(goal.target_date)} · ${relativeDays(goal.target_date)}`
: "Ohne Zieldatum"}
</p>
</div>
<div className="flex shrink-0 gap-1 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100">
<Button size="sm" variant="ghost" onClick={onEdit} aria-label={`${goal.name} bearbeiten`}>
<Pencil aria-hidden className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" onClick={onDelete} aria-label={`${goal.name} löschen`}>
<Trash2 aria-hidden className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<p className="mt-3 tabular text-2xl font-semibold text-ink">
{formatMoney(goal.current_amount)}
<span className="text-base font-normal text-faint"> / {formatMoney(goal.target_amount)}</span>
</p>
<div
className="mt-2 h-2 overflow-hidden rounded-full bg-raised"
role="meter"
aria-valuenow={Math.round(anteil * 100)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${goal.name}: ${Math.round(anteil * 100)} Prozent erreicht`}
>
<div
className="h-full rounded-full transition-all"
style={{ width: `${anteil * 100}%`, backgroundColor: goal.color }}
/>
</div>
<div className="mt-2 flex flex-wrap items-center justify-between gap-2 text-xs">
<span className="text-muted">{Math.round(anteil * 100)} % erreicht</span>
{erreicht ? (
<Badge tone="positive">
<CheckCircle2 aria-hidden className="h-3 w-3" />
Ziel erreicht
</Badge>
) : (
progress?.required_monthly && (
<span className="text-muted">
{formatMoney(progress.required_monthly)} pro Monat nötig
{progress.months_left !== null && ` (${progress.months_left} Monate)`}
</span>
)
)}
</div>
{progress?.is_on_track === false && (
<p className="mt-2 flex items-center gap-1.5 rounded-lg bg-warning/10 px-2 py-1.5 text-xs text-warning">
<AlertTriangle aria-hidden className="h-3.5 w-3.5 shrink-0" />
Die geplante Rate von {formatMoney(goal.monthly_contribution)} reicht nicht bis zum
Zieldatum.
</p>
)}
</li>
);
}
function GoalDialog({
goal,
open,
onClose,
}: {
goal: SavingsGoal | null | undefined;
open: boolean;
onClose: () => void;
}) {
const speichern = useSaveGoal();
const [name, setName] = useState("");
const [ziel, setZiel] = useState("");
const [stand, setStand] = useState("0.00");
const [zieldatum, setZieldatum] = useState("");
const [rate, setRate] = useState("");
const [konto, setKonto] = useState<number | null>(null);
const [farbe, setFarbe] = useState("#10b981");
const [initialisiert, setInitialisiert] = useState<number | null | undefined>(undefined);
if (open && initialisiert !== (goal?.id ?? null)) {
setName(goal?.name ?? "");
setZiel(goal?.target_amount ?? "");
setStand(goal?.current_amount ?? "0.00");
setZieldatum(goal?.target_date ?? "");
setRate(goal?.monthly_contribution ?? "");
setKonto(goal?.account_id ?? null);
setFarbe(goal?.color ?? "#10b981");
setInitialisiert(goal?.id ?? null);
}
function absenden(ereignis: FormEvent) {
ereignis.preventDefault();
if (!name.trim() || !ziel) return;
speichern.mutate(
{
id: goal?.id,
daten: {
name: name.trim(),
target_amount: ziel,
current_amount: stand || "0.00",
target_date: zieldatum || null,
monthly_contribution: rate || null,
account_id: konto,
color: farbe,
},
},
{
onSuccess: () => {
setInitialisiert(undefined);
onClose();
},
},
);
}
return (
<Modal
open={open}
onClose={onClose}
title={goal ? "Sparziel bearbeiten" : "Neues Sparziel"}
footer={
<>
<Button onClick={onClose}>Abbrechen</Button>
<Button
variant="primary"
onClick={absenden}
loading={speichern.isPending}
disabled={!name.trim() || !ziel}
>
Speichern
</Button>
</>
}
>
<form onSubmit={absenden} className="grid gap-3 sm:grid-cols-2">
<Field label="Name" required className="sm:col-span-2">
{(id) => (
<Input
id={id}
value={name}
required
autoFocus
placeholder="Neues Fahrrad"
onChange={(ereignis) => setName(ereignis.target.value)}
/>
)}
</Field>
<Field label="Zielbetrag" required>
{(id) => <MoneyInput id={id} value={ziel} onValueChange={setZiel} />}
</Field>
<Field label="Bereits gespart">
{(id) => <MoneyInput id={id} value={stand} onValueChange={setStand} />}
</Field>
<Field label="Zieldatum" hint="Ohne Datum wird keine Rate berechnet.">
{(id) => (
<Input
id={id}
type="date"
value={zieldatum}
onChange={(ereignis) => setZieldatum(ereignis.target.value)}
/>
)}
</Field>
<Field label="Geplante Monatsrate" hint="moneyfy prüft, ob sie ausreicht.">
{(id) => <MoneyInput id={id} value={rate} onValueChange={setRate} />}
</Field>
<Field label="Konto">
{(id) => <AccountSelect id={id} value={konto} onChange={setKonto} />}
</Field>
<Field label="Farbe">
{(id) => (
<Input
id={id}
type="color"
value={farbe}
className="h-10 p-1"
onChange={(ereignis) => setFarbe(ereignis.target.value)}
/>
)}
</Field>
</form>
</Modal>
);
}
+371
View File
@@ -0,0 +1,371 @@
/** Auswertungen: Abos, Kategorien, Jahresvergleich und Export. */
import { useState } from "react";
import { AlertTriangle, Download, Repeat } from "lucide-react";
import { CategoryDonut } from "@/components/charts/CategoryDonut";
import { ChartCard, StatTile } from "@/components/charts/ChartFrame";
import { YearComparisonChart } from "@/components/charts/YearComparisonChart";
import { PageHeader } from "@/components/layout/AppLayout";
import { Button } from "@/components/ui/Button";
import { Badge, EmptyState, Skeleton } from "@/components/ui/Feedback";
import { Select } from "@/components/ui/Field";
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
import {
exportUrl,
useCategoryReport,
useSubscriptions,
useYearComparison,
} from "@/hooks/useReports";
import { cn } from "@/lib/cn";
import { firstOfMonth, formatDate, formatMoney, formatMonth, toNumber } from "@/lib/format";
import { describeRRule } from "@/lib/rrule";
import type { Subscription } from "@/types/api";
type Reiter = "subscriptions" | "categories" | "year" | "export";
const REITER: { id: Reiter; label: string }[] = [
{ id: "subscriptions", label: "Abos" },
{ id: "categories", label: "Kategorien" },
{ id: "year", label: "Jahresvergleich" },
{ id: "export", label: "Export" },
];
export function ReportsPage() {
const [reiter, setReiter] = useState<Reiter>("subscriptions");
return (
<>
<PageHeader title="Auswertungen" />
<div className="mb-5 flex gap-1 overflow-x-auto border-b border-line" role="tablist">
{REITER.map((eintrag) => (
<button
key={eintrag.id}
type="button"
role="tab"
aria-selected={reiter === eintrag.id}
onClick={() => setReiter(eintrag.id)}
className={cn(
"-mb-px whitespace-nowrap border-b-2 px-3 py-2 text-sm font-medium transition",
reiter === eintrag.id
? "border-accent text-accent"
: "border-transparent text-muted hover:text-ink",
)}
>
{eintrag.label}
</button>
))}
</div>
{reiter === "subscriptions" && <SubscriptionsTab />}
{reiter === "categories" && <CategoriesTab />}
{reiter === "year" && <YearTab />}
{reiter === "export" && <ExportTab />}
</>
);
}
/* --- Abos ----------------------------------------------------------------- */
type Sortierung = "annual" | "monthly" | "title" | "notice";
function SubscriptionsTab() {
const [sortierung, setSortierung] = useState<Sortierung>("annual");
const { data, isLoading } = useSubscriptions();
const kategorieName = useCategoryLookup();
if (isLoading || !data) return <Skeleton className="h-64" />;
if (data.entries.length === 0) {
return (
<EmptyState
icon={Repeat}
title="Keine laufenden Posten"
description="Sobald wiederkehrende Ausgaben angelegt sind, erscheint hier die Jahresübersicht."
/>
);
}
const sortiert = [...data.entries].sort((links, rechts) => {
switch (sortierung) {
case "monthly":
return toNumber(rechts.monthly_cost) - toNumber(links.monthly_cost);
case "title":
return links.title.localeCompare(rechts.title, "de");
case "notice":
return (links.days_until_notice ?? 99999) - (rechts.days_until_notice ?? 99999);
default:
return toNumber(rechts.annual_cost) - toNumber(links.annual_cost);
}
});
return (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<StatTile label="Abos gesamt p. a." value={formatMoney(data.total_annual)} large />
<StatTile label="Entspricht pro Monat" value={formatMoney(data.total_monthly)} />
<StatTile
label="Laufende Posten"
value={String(data.entries.filter((eintrag) => !eintrag.is_installment).length)}
hint={`zuzüglich ${data.entries.filter((eintrag) => eintrag.is_installment).length} Ratenzahlungen`}
/>
</div>
{data.upcoming_deadlines.length > 0 && (
<div className="card border-warning/40 p-4">
<h2 className="flex items-center gap-2 text-sm font-semibold text-warning">
<AlertTriangle aria-hidden className="h-4 w-4" />
Kündigungsfristen der nächsten 60 Tage
</h2>
<ul className="mt-2 space-y-1 text-xs">
{data.upcoming_deadlines.map((eintrag) => (
<li key={eintrag.recurrence_id} className="flex flex-wrap items-center gap-x-2">
<span className="font-medium text-ink">{eintrag.title}</span>
<span className="text-muted">
kündbar bis {formatDate(eintrag.contract_term?.notice_deadline)}
</span>
<Badge tone="warning">noch {eintrag.days_until_notice} Tage</Badge>
</li>
))}
</ul>
</div>
)}
<ChartCard
title="Alle laufenden Posten"
description="Ratenzahlungen sind gekennzeichnet und zählen nicht in die Gesamtsumme."
actions={
<Select
aria-label="Sortierung"
className="h-9 w-44"
value={sortierung}
onChange={(ereignis) => setSortierung(ereignis.target.value as Sortierung)}
>
<option value="annual">Nach Jahreskosten</option>
<option value="monthly">Nach Monatskosten</option>
<option value="title">Nach Name</option>
<option value="notice">Nach Kündigungsfrist</option>
</Select>
}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<caption className="sr-only">Laufende Posten mit Jahreskosten</caption>
<thead className="border-b border-line text-left text-xs text-muted">
<tr>
<th scope="col" className="py-2 font-medium">
Posten
</th>
<th scope="col" className="hidden py-2 font-medium md:table-cell">
Rhythmus
</th>
<th scope="col" className="py-2 text-right font-medium">
pro Monat
</th>
<th scope="col" className="py-2 text-right font-medium">
pro Jahr
</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{sortiert.map((eintrag) => (
<SubscriptionRow
key={eintrag.recurrence_id}
entry={eintrag}
categoryName={kategorieName(eintrag.category_id)}
/>
))}
</tbody>
</table>
</div>
</ChartCard>
</div>
);
}
function SubscriptionRow({
entry,
categoryName,
}: {
entry: Subscription;
categoryName: string;
}) {
return (
<tr>
<td className="py-2">
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-medium text-ink">{entry.title}</span>
{entry.is_installment && <Badge tone="accent">Raten</Badge>}
{entry.is_cancelled && <Badge tone="negative">Gekündigt</Badge>}
{entry.days_until_notice !== null && entry.days_until_notice <= 60 && (
<Badge tone="warning">Frist in {entry.days_until_notice} Tagen</Badge>
)}
</div>
<p className="text-xs text-faint">
{entry.merchant_name ? `${entry.merchant_name} · ` : ""}
{categoryName}
</p>
</td>
<td className="hidden py-2 text-xs text-muted md:table-cell">
{describeRRule(entry.rrule)}
</td>
<td className="py-2 text-right tabular text-muted">{formatMoney(entry.monthly_cost)}</td>
<td className="py-2 text-right tabular font-medium text-ink">
{formatMoney(entry.annual_cost)}
</td>
</tr>
);
}
/* --- Kategorien ----------------------------------------------------------- */
function CategoriesTab() {
const [zeitraum, setZeitraum] = useState<"month" | "year">("month");
const heute = new Date();
const von =
zeitraum === "month" ? firstOfMonth() : `${heute.getFullYear()}-01-01`;
const bis =
zeitraum === "month"
? new Date(heute.getFullYear(), heute.getMonth() + 1, 0).toISOString().slice(0, 10)
: `${heute.getFullYear()}-12-31`;
const { data, isLoading } = useCategoryReport(von, bis);
if (isLoading || !data) return <Skeleton className="h-72" />;
return (
<div className="space-y-3">
<div className="flex gap-1">
{(
[
["month", formatMonth(von)],
["year", String(heute.getFullYear())],
] as const
).map(([wert, beschriftung]) => (
<Button
key={wert}
size="sm"
variant={zeitraum === wert ? "primary" : "secondary"}
onClick={() => setZeitraum(wert)}
>
{beschriftung}
</Button>
))}
</div>
<CategoryDonut
categories={data.categories}
title="Ausgaben nach Kategorien"
description="Klick auf eine Kategorie zeigt die Unterkategorien."
/>
</div>
);
}
/* --- Jahresvergleich ------------------------------------------------------ */
function YearTab() {
const [jahr, setJahr] = useState(() => new Date().getFullYear());
const { data, isLoading } = useYearComparison(jahr);
return (
<div className="space-y-3">
<Select
aria-label="Jahr"
className="h-9 w-32"
value={jahr}
onChange={(ereignis) => setJahr(Number(ereignis.target.value))}
>
{Array.from({ length: 6 }, (_, index) => new Date().getFullYear() - index).map((wert) => (
<option key={wert} value={wert}>
{wert}
</option>
))}
</Select>
{isLoading || !data ? <Skeleton className="h-72" /> : <YearComparisonChart data={data} />}
</div>
);
}
/* --- Export --------------------------------------------------------------- */
function ExportTab() {
const [monat, setMonat] = useState(() => firstOfMonth());
const jahr = new Date().getFullYear();
return (
<div className="grid gap-3 lg:grid-cols-3">
<ExportCard
title="Buchungen"
description={`Alle einmaligen Buchungen des Jahres ${jahr}.`}
links={[
{ label: "CSV", href: exportUrl("transactions", "csv") },
{ label: "Excel", href: exportUrl("transactions", "xlsx") },
]}
/>
<ExportCard
title="Wiederkehrende Posten"
description="Alle Serien mit Rhythmus, Jahreskosten und Vertragsdaten."
links={[
{ label: "CSV", href: exportUrl("recurrences", "csv") },
{ label: "Excel", href: exportUrl("recurrences", "xlsx") },
]}
/>
<ExportCard
title="Monatsauswertung"
description="Alle Bewegungen des Monats samt Kennzahlen."
extra={
<input
type="month"
aria-label="Monat der Auswertung"
value={monat.slice(0, 7)}
onChange={(ereignis) => setMonat(`${ereignis.target.value}-01`)}
className="mb-3 w-full rounded-lg border border-line bg-raised px-3 py-2 text-sm text-ink"
/>
}
links={[
{ label: "CSV", href: exportUrl("month", "csv", { month: monat }) },
{ label: "Excel", href: exportUrl("month", "xlsx", { month: monat }) },
]}
/>
</div>
);
}
function ExportCard({
title,
description,
links,
extra,
}: {
title: string;
description: string;
links: { label: string; href: string }[];
extra?: React.ReactNode;
}) {
return (
<section className="card flex flex-col p-4">
<h2 className="text-sm font-semibold text-ink">{title}</h2>
<p className="mb-3 mt-0.5 text-xs text-muted">{description}</p>
{extra}
<div className="mt-auto flex gap-2">
{links.map((verweis) => (
<a
key={verweis.label}
href={verweis.href}
download
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-line bg-raised px-3 text-xs font-medium text-ink transition hover:bg-line"
>
<Download aria-hidden className="h-3.5 w-3.5" />
{verweis.label}
</a>
))}
</div>
</section>
);
}
+42
View File
@@ -21,3 +21,45 @@ Object.defineProperty(window, "matchMedia", {
dispatchEvent: () => false,
}),
});
// jsdom rechnet kein Layout: ohne diese Ergänzungen bliebe jedes Diagramm
// 0 Pixel groß und Recharts würde bei jedem Test eine Warnung ausgeben.
const TEST_BREITE = 640;
const TEST_HOEHE = 320;
class ResizeObserverStub implements ResizeObserver {
constructor(private readonly rueckruf: ResizeObserverCallback) {}
observe(ziel: Element): void {
const abmessung = { inlineSize: TEST_BREITE, blockSize: TEST_HOEHE };
this.rueckruf(
[
{
target: ziel,
contentRect: { width: TEST_BREITE, height: TEST_HOEHE } as DOMRectReadOnly,
borderBoxSize: [abmessung],
contentBoxSize: [abmessung],
devicePixelContentBoxSize: [abmessung],
} as ResizeObserverEntry,
],
this,
);
}
unobserve(): void {}
disconnect(): void {}
}
globalThis.ResizeObserver ??= ResizeObserverStub;
for (const [eigenschaft, wert] of [
["offsetWidth", TEST_BREITE],
["clientWidth", TEST_BREITE],
["offsetHeight", TEST_HOEHE],
["clientHeight", TEST_HOEHE],
] as const) {
Object.defineProperty(HTMLElement.prototype, eigenschaft, {
configurable: true,
value: wert,
});
}
+199
View File
@@ -315,6 +315,58 @@ export interface Totals {
balance: Money;
}
export interface Budget {
id: number;
category_id: number;
period_month: IsoDate;
limit_amount: Money;
rollover: boolean;
created_at: IsoDateTime;
}
export interface BudgetInput {
category_id: number;
period_month: IsoDate;
limit_amount: Money;
rollover?: boolean;
}
export interface BudgetTemplate {
id: number;
category_id: number;
valid_from: IsoDate;
valid_until: IsoDate | null;
limit_amount: Money;
rollover: boolean;
}
export interface SavingsGoal {
id: number;
name: string;
target_amount: Money;
target_date: IsoDate | null;
current_amount: Money;
account_id: number | null;
monthly_contribution: Money | null;
color: string;
icon: string;
is_archived: boolean;
created_at: IsoDateTime;
updated_at: IsoDateTime;
}
export interface SavingsGoalInput {
name: string;
target_amount: Money;
target_date?: IsoDate | null;
current_amount?: Money;
account_id?: number | null;
monthly_contribution?: Money | null;
color?: string;
icon?: string;
is_archived?: boolean;
}
export interface MonthReport {
month: IsoDate;
planned: Totals;
@@ -330,3 +382,150 @@ export interface MonthReport {
open_count: number;
skipped_count: number;
}
export interface ForecastMonth {
month: IsoDate;
income: Money;
expenses: Money;
balance: Money;
cumulative_balance: Money;
}
export interface Forecast {
months: ForecastMonth[];
total_income: Money;
total_expenses: Money;
}
export interface CategorySlice {
category_id: number;
name: string;
color: string;
icon: string;
amount: Money;
count: number;
children: CategorySlice[];
}
export interface CategoryReport {
date_from: IsoDate;
date_to: IsoDate;
kind: EntryKind;
total: Money;
categories: CategorySlice[];
}
export interface Subscription {
recurrence_id: number;
title: string;
merchant_id: number | null;
merchant_name: string | null;
category_id: number;
amount: Money;
annual_cost: Money;
monthly_cost: Money;
rrule: string;
is_installment: boolean;
is_cancelled: boolean;
contract_term: ContractTerm | null;
days_until_notice: number | null;
}
export interface SubscriptionReport {
entries: Subscription[];
total_annual: Money;
total_monthly: Money;
upcoming_deadlines: Subscription[];
}
export interface YearComparisonRow {
category_id: number;
name: string;
color: string;
current: Money;
previous: Money;
delta: Money;
}
export interface YearComparison {
year: number;
rows: YearComparisonRow[];
current_total: Money;
previous_total: Money;
}
export interface CalendarEntry {
title: string;
kind: EntryKind;
amount: Money;
category_id: number;
merchant_id: number | null;
account_id: number | null;
source: "recurrence" | "transaction";
recurrence_id: number | null;
occurrence_date: IsoDate | null;
status: OccurrenceStatus | null;
is_variable: boolean;
}
export interface CalendarDay {
date: IsoDate;
entries: CalendarEntry[];
net: Money;
running_balance: Money;
is_business_day: boolean;
}
export interface CalendarMonth {
month: IsoDate;
days: CalendarDay[];
opening_balance: Money;
closing_balance: Money;
lowest_balance: Money;
lowest_balance_on: IsoDate | null;
}
export type BudgetState = "ok" | "warning" | "exceeded";
export interface BudgetStatus {
category_id: number;
category_name: string;
color: string;
period_month: IsoDate;
limit_amount: Money;
carried_over: Money;
available: Money;
spent: Money;
remaining: Money;
ratio: number;
state: BudgetState;
rollover: boolean;
is_template: boolean;
}
export interface SavingsGoalProgress {
goal_id: number;
name: string;
color: string;
icon: string;
target_amount: Money;
current_amount: Money;
remaining_amount: Money;
ratio: number;
target_date: IsoDate | null;
months_left: number | null;
required_monthly: Money | null;
monthly_contribution: Money | null;
is_on_track: boolean | null;
}
export interface Dashboard {
month: MonthReport;
total_balance: Money;
forecast: ForecastMonth[];
categories: CategorySlice[];
budgets: BudgetStatus[];
goals: SavingsGoalProgress[];
upcoming: CalendarEntry[];
upcoming_deadlines: Subscription[];
}
+10
View File
@@ -22,6 +22,16 @@ export default defineConfig({
build: {
outDir: "dist",
sourcemap: false,
rollupOptions: {
output: {
// Recharts wiegt mehr als der Rest der Anwendung und ändert sich selten
// als eigener Chunk bleibt er über Releases hinweg im Browser-Cache.
manualChunks: {
charts: ["recharts"],
vendor: ["react", "react-dom", "react-router-dom", "@tanstack/react-query"],
},
},
},
},
test: {
globals: true,