From 0adf154049eef8cd0ce5433a558cd18a027bb2a9 Mon Sep 17 00:00:00 2001 From: moneyfy Date: Wed, 9 Sep 2026 16:50:22 +0200 Subject: [PATCH] feat(reports): Auswertungen, Kalender, Budgets und Export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH --- CHANGELOG.md | 17 + README.md | 13 + backend/app/api/router.py | 2 + backend/app/api/routes/export.py | 200 ++++ backend/app/api/routes/reports.py | 391 +++++++- backend/app/schemas/report.py | 177 ++++ backend/app/services/export.py | 121 +++ backend/app/services/reports.py | 892 ++++++++++++++++-- backend/tests/test_api_reports.py | 623 ++++++++++++ frontend/src/App.tsx | 13 +- .../components/charts/BudgetMeter.test.tsx | 77 ++ .../src/components/charts/BudgetMeter.tsx | 92 ++ .../components/charts/CategoryDonut.test.tsx | 82 ++ .../src/components/charts/CategoryDonut.tsx | 188 ++++ frontend/src/components/charts/ChartFrame.tsx | 196 ++++ .../src/components/charts/ForecastChart.tsx | 181 ++++ .../components/charts/YearComparisonChart.tsx | 119 +++ frontend/src/components/charts/chartTheme.ts | 48 + frontend/src/components/layout/Sidebar.tsx | 19 +- frontend/src/hooks/useBudgets.ts | 128 +++ frontend/src/hooks/useReports.ts | 93 ++ frontend/src/index.css | 30 + frontend/src/pages/BudgetsPage.tsx | 307 ++++++ frontend/src/pages/CalendarPage.test.tsx | 202 ++++ frontend/src/pages/CalendarPage.tsx | 441 +++++++++ frontend/src/pages/DashboardPage.tsx | 262 +++++ frontend/src/pages/GoalsPage.tsx | 306 ++++++ frontend/src/pages/ReportsPage.tsx | 371 ++++++++ frontend/src/test/setup.ts | 42 + frontend/src/types/api.ts | 199 ++++ frontend/vite.config.ts | 10 + 31 files changed, 5721 insertions(+), 121 deletions(-) create mode 100644 backend/app/api/routes/export.py create mode 100644 backend/app/services/export.py create mode 100644 backend/tests/test_api_reports.py create mode 100644 frontend/src/components/charts/BudgetMeter.test.tsx create mode 100644 frontend/src/components/charts/BudgetMeter.tsx create mode 100644 frontend/src/components/charts/CategoryDonut.test.tsx create mode 100644 frontend/src/components/charts/CategoryDonut.tsx create mode 100644 frontend/src/components/charts/ChartFrame.tsx create mode 100644 frontend/src/components/charts/ForecastChart.tsx create mode 100644 frontend/src/components/charts/YearComparisonChart.tsx create mode 100644 frontend/src/components/charts/chartTheme.ts create mode 100644 frontend/src/hooks/useBudgets.ts create mode 100644 frontend/src/hooks/useReports.ts create mode 100644 frontend/src/pages/BudgetsPage.tsx create mode 100644 frontend/src/pages/CalendarPage.test.tsx create mode 100644 frontend/src/pages/CalendarPage.tsx create mode 100644 frontend/src/pages/DashboardPage.tsx create mode 100644 frontend/src/pages/GoalsPage.tsx create mode 100644 frontend/src/pages/ReportsPage.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 27d7c1f..5aabcd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index cd95b02..153c8cc 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/backend/app/api/router.py b/backend/app/api/router.py index ab95fd0..36c1dac 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -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) diff --git a/backend/app/api/routes/export.py b/backend/app/api/routes/export.py new file mode 100644 index 0000000..77277c8 --- /dev/null +++ b/backend/app/api/routes/export.py @@ -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()} diff --git a/backend/app/api/routes/reports.py b/backend/app/api/routes/reports.py index a928148..0d5beb4 100644 --- a/backend/app/api/routes/reports.py +++ b/backend/app/api/routes/reports.py @@ -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], + ) diff --git a/backend/app/schemas/report.py b/backend/app/schemas/report.py index a49dd85..c657252 100644 --- a/backend/app/schemas/report.py +++ b/backend/app/schemas/report.py @@ -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] diff --git a/backend/app/services/export.py b/backend/app/services/export.py new file mode 100644 index 0000000..2fef422 --- /dev/null +++ b/backend/app/services/export.py @@ -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}" diff --git a/backend/app/services/reports.py b/backend/app/services/reports.py index 1d94152..ec86def 100644 --- a/backend/app/services/reports.py +++ b/backend/app/services/reports.py @@ -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 diff --git a/backend/tests/test_api_reports.py b/backend/tests/test_api_reports.py new file mode 100644 index 0000000..854c822 --- /dev/null +++ b/backend/tests/test_api_reports.py @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 22a2871..8a3faa3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( }> - } /> + } /> + } /> } /> } /> } /> + } /> + } /> + } /> } /> - } /> + } /> ); diff --git a/frontend/src/components/charts/BudgetMeter.test.tsx b/frontend/src/components/charts/BudgetMeter.test.tsx new file mode 100644 index 0000000..b81fd44 --- /dev/null +++ b/frontend/src/components/charts/BudgetMeter.test.tsx @@ -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 { + 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( +
    + +
, + ); +} + +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(); + }); +}); diff --git a/frontend/src/components/charts/BudgetMeter.tsx b/frontend/src/components/charts/BudgetMeter.tsx new file mode 100644 index 0000000..ae1d818 --- /dev/null +++ b/frontend/src/components/charts/BudgetMeter.tsx @@ -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 ( +
  • +
    +
    + + {budget.category_name} +
    + + {formatMoney(budget.spent)} + / {formatMoney(budget.available)} + +
    + +
    +
    +
    + +
    + + + {Math.round(budget.ratio * 100)} % · {zustand.label} + + + {ueberzogen + ? `${formatMoney(Math.abs(toNumber(budget.remaining)))} zu viel` + : `${formatMoney(budget.remaining)} übrig`} + +
    + + {(budget.carried_over !== "0.00" || budget.is_template) && ( +

    + {budget.carried_over !== "0.00" && + `Übertrag aus Vormonaten: ${formatMoney(budget.carried_over)}. `} + {budget.is_template && "Aus einer Vorlage."} +

    + )} +
  • + ); +} diff --git a/frontend/src/components/charts/CategoryDonut.test.tsx b/frontend/src/components/charts/CategoryDonut.test.tsx new file mode 100644 index 0000000..9a74d79 --- /dev/null +++ b/frontend/src/components/charts/CategoryDonut.test.tsx @@ -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(); + + // 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(); + + 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(); + + // 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(); + + expect(screen.getByText("Weitere (3)")).toBeInTheDocument(); + expect(screen.queryByText("Kategorie 8")).not.toBeInTheDocument(); + }); + + it("meldet einen leeren Zeitraum verständlich", () => { + renderWithProviders(); + + expect(screen.getByText(/keine Bewegungen/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/charts/CategoryDonut.tsx b/frontend/src/components/charts/CategoryDonut.tsx new file mode 100644 index 0000000..40e4745 --- /dev/null +++ b/frontend/src/components/charts/CategoryDonut.tsx @@ -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(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 ( + +

    + Für diesen Zeitraum gibt es keine Bewegungen. +

    +
    + ); + } + + return ( + setDrilldown(null)}> + + Zurück + + ) + } + > +
    +
    + + + { + 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) => ( + + ))} + + { + if (!active || !payload?.length) return null; + const segment = payload[0]?.payload as Segment; + const anteil = gesamt > 0 ? (segment.amount / gesamt) * 100 : 0; + return ( + + ); + }} + /> + + + + {/* Die Summe steht in der Mitte – dort sucht man sie. */} +
    + Gesamt + {formatMoney(gesamt)} +
    +
    + +
      + {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 = ( + <> + + {segment.name} + {anteil.toFixed(0)} % + + {formatMoney(segment.amount)} + + + ); + + return ( +
    • + {aufklappbar ? ( + + ) : ( +
      {inhalt}
      + )} +
    • + ); + })} +
    +
    +
    + ); +} diff --git a/frontend/src/components/charts/ChartFrame.tsx b/frontend/src/components/charts/ChartFrame.tsx new file mode 100644 index 0000000..f3d47b0 --- /dev/null +++ b/frontend/src/components/charts/ChartFrame.tsx @@ -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 ( +
    +
    +
    +

    {title}

    + {description &&

    {description}

    } +
    + {actions} +
    + + {/* Ab zwei Serien ist die Legende immer vorhanden – Farbe allein trägt nie. */} + {legend && legend.length > 1 && ( +
      + {legend.map((eintrag) => ( +
    • + + {eintrag.label} +
    • + ))} +
    + )} + + {children} +
    + ); +} + +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 ( +
    +

    {title}

    +
      + {rows.map((zeile) => ( +
    • + {zeile.color && ( + + )} + {zeile.label} + {zeile.value} +
    • + ))} +
    +
    + ); +} + +/** Aufklappbare Wertetabelle – die Alternative zum Ablesen aus der Grafik. */ +export function DataTable({ + columns, + rows, + caption, +}: { + columns: string[]; + rows: (string | number)[][]; + caption: string; +}) { + return ( +
    + + Werte als Tabelle + +
    + + + + + {columns.map((spalte, index) => ( + + ))} + + + + {rows.map((zeile, zeilenIndex) => ( + + {zeile.map((zelle, spaltenIndex) => ( + + ))} + + ))} + +
    {caption}
    0 && "text-right")} + > + {spalte} +
    + {zelle} +
    +
    +
    + ); +} + +/** 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 ( +
    +

    {label}

    +

    + {value} +

    + {hint &&
    {hint}
    } +
    + ); +} + +/** 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 unverändert {suffix}; + } + const gut = zahl > 0 === goodWhenPositive; + return ( + + {zahl > 0 ? "▲" : "▼"} {formatMoney(Math.abs(zahl))} {suffix} + + ); +} diff --git a/frontend/src/components/charts/ForecastChart.tsx b/frontend/src/components/charts/ForecastChart.tsx new file mode 100644 index 0000000..f5a4112 --- /dev/null +++ b/frontend/src/components/charts/ForecastChart.tsx @@ -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 ( +
    + +
    + + + + + + + active && payload?.length ? ( + + ) : null + } + /> + + + + +
    + + [ + punkt.label, + formatMoney(punkt.einkuenfte), + formatMoney(punkt.ausgaben), + formatMoney(punkt.saldo), + ])} + /> +
    + + +
    + + + + + + {/* Die Nulllinie ist die Grenze, auf die es ankommt. */} + + + active && payload?.length ? ( + + ) : null + } + /> + + + +
    +
    +
    + ); +} diff --git a/frontend/src/components/charts/YearComparisonChart.tsx b/frontend/src/components/charts/YearComparisonChart.tsx new file mode 100644 index 0000000..866f5af --- /dev/null +++ b/frontend/src/components/charts/YearComparisonChart.tsx @@ -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 ( + +

    + Für {data.year} und {data.year - 1} liegen keine Ausgaben vor. +

    +
    + ); + } + + return ( + +
    + + + + + + + active && payload?.length ? ( + + ) : null + } + /> + + + + +
    + + [ + punkt.name, + formatMoney(punkt.aktuell), + formatMoney(punkt.vorjahr), + formatSignedMoney(punkt.delta), + ])} + /> +
    + ); +} diff --git a/frontend/src/components/charts/chartTheme.ts b/frontend/src/components/charts/chartTheme.ts new file mode 100644 index 0000000..970da6d --- /dev/null +++ b/frontend/src/components/charts/chartTheme.ts @@ -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; diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 197c26d..0181452 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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", diff --git a/frontend/src/hooks/useBudgets.ts b/frontend/src/hooks/useBudgets.ts new file mode 100644 index 0000000..557be59 --- /dev/null +++ b/frontend/src/hooks/useBudgets.ts @@ -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): 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("/budgets", month ? { month } : undefined), + }); +} + +export function useSaveBudget() { + const client = useQueryClient(); + return useMutation({ + mutationFn: ({ id, daten }: { id?: number; daten: BudgetInput | Partial }) => + id ? api.patch(`/budgets/${id}`, daten) : api.post("/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(`/budgets/${id}`), + onSuccess: () => { + invalidate(client); + toast.success("Budget gelöscht."); + }, + }); +} + +export function useBudgetTemplates() { + return useQuery({ + queryKey: ["budget-templates"], + queryFn: () => api.get("/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("/budget-templates", daten), + onSuccess: () => { + invalidate(client); + toast.success("Budgetvorlage angelegt."); + }, + }); +} + +export function useDeleteBudgetTemplate() { + const client = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => api.del(`/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( + "/savings-goals", + includeArchived ? { include_archived: true } : undefined, + ), + }); +} + +export function useSaveGoal() { + const client = useQueryClient(); + return useMutation({ + mutationFn: ({ + id, + daten, + }: { + id?: number; + daten: SavingsGoalInput | Partial; + }) => + id + ? api.patch(`/savings-goals/${id}`, daten) + : api.post("/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(`/savings-goals/${id}`), + onSuccess: () => { + void client.invalidateQueries({ queryKey: ["savings-goals"] }); + void client.invalidateQueries({ queryKey: ["reports"] }); + toast.success("Sparziel gelöscht."); + }, + }); +} diff --git a/frontend/src/hooks/useReports.ts b/frontend/src/hooks/useReports.ts new file mode 100644 index 0000000..07a661f --- /dev/null +++ b/frontend/src/hooks/useReports.ts @@ -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("/reports/dashboard", { month }), + }); +} + +export function useMonthReport(month: string) { + return useQuery({ + queryKey: ["reports", "month", month], + queryFn: () => api.get("/reports/month", { month }), + }); +} + +export function useForecast(months = 12, start?: string) { + return useQuery({ + queryKey: ["reports", "forecast", months, start ?? ""], + queryFn: () => api.get("/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("/reports/categories", { from, to, kind }), + }); +} + +export function useSubscriptions() { + return useQuery({ + queryKey: ["reports", "subscriptions"], + queryFn: () => api.get("/reports/subscriptions"), + }); +} + +export function useYearComparison(year: number, kind: EntryKind = "expense") { + return useQuery({ + queryKey: ["reports", "year-comparison", year, kind], + queryFn: () => api.get("/reports/year-comparison", { year, kind }), + }); +} + +export function useCalendar(month: string) { + return useQuery({ + queryKey: ["reports", "calendar", month], + queryFn: () => api.get("/reports/calendar", { month }), + }); +} + +export function useBudgetStatus(month: string) { + return useQuery({ + queryKey: ["reports", "budgets", month], + queryFn: () => api.get("/reports/budgets", { month }), + }); +} + +export function useGoalProgress() { + return useQuery({ + queryKey: ["reports", "savings-goals"], + queryFn: () => api.get("/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 { + 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()}`; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 7021886..c037535 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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 { diff --git a/frontend/src/pages/BudgetsPage.tsx b/frontend/src/pages/BudgetsPage.tsx new file mode 100644 index 0000000..633a4ae --- /dev/null +++ b/frontend/src/pages/BudgetsPage.tsx @@ -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(null); + const [vorlageLoeschen, setVorlageLoeschen] = useState(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 ( + <> + +
    + + + +
    + + + + } + /> + + {isLoading ? ( + + ) : status.length === 0 ? ( + setFormular("budget")}> + + Erstes Budget anlegen + + } + /> + ) : ( +
      + {status.map((eintrag) => ( + + ))} +
    + )} + + {budgets.length > 0 && ( +
    +

    + Einzelbudgets in diesem Monat +

    +
      + {budgets.map((budget) => ( +
    • + + {kategorieName(budget.category_id)} + + {budget.rollover && Übertrag} + {formatMoney(budget.limit_amount)} + +
    • + ))} +
    +
    + )} + + {vorlagen.length > 0 && ( +
    +

    Dauerhafte Vorlagen

    +
      + {vorlagen.map((vorlage) => ( +
    • + + {kategorieName(vorlage.category_id)} + + + ab {formatDate(vorlage.valid_from)} + {vorlage.valid_until && ` bis ${formatDate(vorlage.valid_until)}`} + + {vorlage.rollover && Übertrag} + {formatMoney(vorlage.limit_amount)} + +
    • + ))} +
    +
    + )} + + setFormular(null)} + /> + + setLoeschen(null)} + onConfirm={() => { + if (loeschen) entfernen.mutate(loeschen.id, { onSuccess: () => setLoeschen(null) }); + }} + /> + + 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(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 ( + + + + + } + > +
    + + {(id) => ( + + )} + + + {kind === "template" && ( + + {(id) => ( + setGueltigAb(`${ereignis.target.value}-01`)} + /> + )} + + )} + + + {(id) => } + + + setUebertrag(ereignis.target.checked)} + /> + +
    + ); +} diff --git a/frontend/src/pages/CalendarPage.test.tsx b/frontend/src/pages/CalendarPage.test.tsx new file mode 100644 index 0000000..8c48282 --- /dev/null +++ b/frontend/src/pages/CalendarPage.test.tsx @@ -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 { + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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, + ); + }); + }); +}); diff --git a/frontend/src/pages/CalendarPage.tsx b/frontend/src/pages/CalendarPage.tsx new file mode 100644 index 0000000..c1c2229 --- /dev/null +++ b/frontend/src/pages/CalendarPage.tsx @@ -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(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 ( + <> + + + + + + } + /> + + {isLoading || !data ? ( +
    + + +
    + ) : ( +
    +
    + + + +
    + +
    +
    + {WOCHENTAGE.map((tag) => ( +
    + {tag} +
    + ))} +
    + +
    + {Array.from({ length: fuehrendeLeerfelder(data.month) }, (_, index) => ( +
    + ))} + + {tage.map((tag) => ( + setGewaehlterTag(tag.date === gewaehlterTag ? null : tag.date)} + /> + ))} +
    +
    + + {detailTag && ( + setGewaehlterTag(null)} + /> + )} + + +
    + + ({ + tag: new Date(tag.date).getDate(), + stand: toNumber(tag.running_balance), + }))} + margin={{ top: 8, right: 8, bottom: 0, left: 4 }} + > + + + + + + + + + + new Intl.NumberFormat("de-DE", { + notation: "compact", + maximumFractionDigits: 1, + }).format(wert) + } + /> + + + active && payload?.length ? ( + + ) : null + } + /> + + + +
    +
    +
    + )} + + ); +} + +function TagZelle({ + tag, + istHeute, + istGewaehlt, + firmen, + onSelect, +}: { + tag: CalendarDay; + istHeute: boolean; + istGewaehlt: boolean; + firmen: Map; + onSelect: () => void; +}) { + const netto = toNumber(tag.net); + const tagesZahl = new Date(tag.date).getDate(); + + return ( + + ); +} + +function TagDetail({ + tag, + firmen, + onClose, +}: { + tag: CalendarDay; + firmen: Map; + onClose: () => void; +}) { + const bestaetigen = useConfirmOccurrence(); + const auslassen = useSkipOccurrence(); + + return ( +
    +
    +

    {formatDate(tag.date)}

    +
    + + Saldo{" "} + + {formatMoney(tag.net)} + + + +
    +
    + + {tag.entries.length === 0 ? ( + + ) : ( +
      + {tag.entries.map((eintrag, index) => ( + + 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} + /> + ))} +
    + )} +
    + ); +} + +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 ( +
  • + + +
    +

    {eintrag.title}

    +

    + {eintrag.source === "transaction" + ? "Einmalige Buchung" + : eintrag.status === "confirmed" + ? "Bestätigt" + : "Geplant"} + {eintrag.is_variable && " · geschätzt"} +

    +
    + + + {eintrag.kind === "income" ? "+" : "−"} + {formatMoney(eintrag.amount)} + + + {bestaetigbar && ( +
    + + +
    + )} +
  • + ); +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..6346fd5 --- /dev/null +++ b/frontend/src/pages/DashboardPage.tsx @@ -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 ( + <> + + + + + + } + /> + + {isLoading || !data ? ( +
    +
    + {Array.from({ length: 4 }, (_, index) => ( + + ))} +
    + +
    + ) : ( +
    + {/* Die große Kennzahl steht allein – sie ist die Antwort auf die + häufigste Frage und braucht kein Diagramm. */} +
    + + } + /> + + } + /> + +
    + + {data.upcoming_deadlines.length > 0 && ( +
    +

    + + Kündigungsfristen laufen ab +

    +
      + {data.upcoming_deadlines.map((eintrag) => ( +
    • + {eintrag.title} + + kündbar bis {formatDate(eintrag.contract_term?.notice_deadline)} + + noch {eintrag.days_until_notice} Tage +
    • + ))} +
    +
    + )} + +
    + + + + {data.upcoming.length === 0 ? ( +

    + In den nächsten zwei Wochen steht nichts an. +

    + ) : ( +
      + {data.upcoming.slice(0, 8).map((eintrag, index) => { + const firma = eintrag.merchant_id ? firmen.get(eintrag.merchant_id) : undefined; + return ( +
    • + +
      +

      {eintrag.title}

      +

      + {kategorieName(eintrag.category_id)} +

      +
      + + {eintrag.kind === "income" ? "+" : "−"} + {formatMoney(eintrag.amount)} + +
    • + ); + })} +
    + )} + + Zum Cashflow-Kalender + +
    +
    + + + +
    +
    +

    Budgets

    + {data.budgets.length === 0 ? ( + + + + } + /> + ) : ( +
      + {data.budgets.map((budget) => ( + + ))} +
    + )} +
    + +
    +

    Sparziele

    + {data.goals.length === 0 ? ( + + + + } + /> + ) : ( +
      + {data.goals.map((ziel) => ( +
    • +
      + {ziel.name} + + {formatMoney(ziel.current_amount)} + / {formatMoney(ziel.target_amount)} + +
      +
      +
      +
      +

      + {ziel.target_date + ? `Bis ${formatDate(ziel.target_date)} (${relativeDays(ziel.target_date)}) · ${formatMoney( + ziel.required_monthly, + )} pro Monat nötig` + : "Ohne Zieldatum"} +

      +
    • + ))} +
    + )} +
    +
    +
    + )} + + ); +} diff --git a/frontend/src/pages/GoalsPage.tsx b/frontend/src/pages/GoalsPage.tsx new file mode 100644 index 0000000..903a552 --- /dev/null +++ b/frontend/src/pages/GoalsPage.tsx @@ -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(undefined); + const [loeschen, setLoeschen] = useState(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 ( + <> + setBearbeiten(null)}> + + Sparziel + + } + /> + + {isLoading ? ( +
    + {Array.from({ length: 2 }, (_, index) => ( + + ))} +
    + ) : ziele.length === 0 ? ( + setBearbeiten(null)}> + + Erstes Sparziel anlegen + + } + /> + ) : ( +
      + {ziele.map((ziel) => ( + setBearbeiten(ziel)} + onDelete={() => setLoeschen(ziel)} + /> + ))} +
    + )} + + setBearbeiten(undefined)} + /> + + 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 ( +
  • +
    +
    +

    {goal.name}

    +

    + {goal.target_date + ? `Ziel bis ${formatDate(goal.target_date)} · ${relativeDays(goal.target_date)}` + : "Ohne Zieldatum"} +

    +
    +
    + + +
    +
    + +

    + {formatMoney(goal.current_amount)} + / {formatMoney(goal.target_amount)} +

    + +
    +
    +
    + +
    + {Math.round(anteil * 100)} % erreicht + {erreicht ? ( + + + Ziel erreicht + + ) : ( + progress?.required_monthly && ( + + {formatMoney(progress.required_monthly)} pro Monat nötig + {progress.months_left !== null && ` (${progress.months_left} Monate)`} + + ) + )} +
    + + {progress?.is_on_track === false && ( +

    + + Die geplante Rate von {formatMoney(goal.monthly_contribution)} reicht nicht bis zum + Zieldatum. +

    + )} +
  • + ); +} + +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(null); + const [farbe, setFarbe] = useState("#10b981"); + const [initialisiert, setInitialisiert] = useState(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 ( + + + + + } + > +
    + + {(id) => ( + setName(ereignis.target.value)} + /> + )} + + + + {(id) => } + + + + {(id) => } + + + + {(id) => ( + setZieldatum(ereignis.target.value)} + /> + )} + + + + {(id) => } + + + + {(id) => } + + + + {(id) => ( + setFarbe(ereignis.target.value)} + /> + )} + +
    +
    + ); +} diff --git a/frontend/src/pages/ReportsPage.tsx b/frontend/src/pages/ReportsPage.tsx new file mode 100644 index 0000000..1090821 --- /dev/null +++ b/frontend/src/pages/ReportsPage.tsx @@ -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("subscriptions"); + + return ( + <> + + +
    + {REITER.map((eintrag) => ( + + ))} +
    + + {reiter === "subscriptions" && } + {reiter === "categories" && } + {reiter === "year" && } + {reiter === "export" && } + + ); +} + +/* --- Abos ----------------------------------------------------------------- */ + +type Sortierung = "annual" | "monthly" | "title" | "notice"; + +function SubscriptionsTab() { + const [sortierung, setSortierung] = useState("annual"); + const { data, isLoading } = useSubscriptions(); + const kategorieName = useCategoryLookup(); + + if (isLoading || !data) return ; + + if (data.entries.length === 0) { + return ( + + ); + } + + 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 ( +
    +
    + + + !eintrag.is_installment).length)} + hint={`zuzüglich ${data.entries.filter((eintrag) => eintrag.is_installment).length} Ratenzahlungen`} + /> +
    + + {data.upcoming_deadlines.length > 0 && ( +
    +

    + + Kündigungsfristen der nächsten 60 Tage +

    +
      + {data.upcoming_deadlines.map((eintrag) => ( +
    • + {eintrag.title} + + kündbar bis {formatDate(eintrag.contract_term?.notice_deadline)} + + noch {eintrag.days_until_notice} Tage +
    • + ))} +
    +
    + )} + + setSortierung(ereignis.target.value as Sortierung)} + > + + + + + + } + > +
    + + + + + + + + + + + + {sortiert.map((eintrag) => ( + + ))} + +
    Laufende Posten mit Jahreskosten
    + Posten + + Rhythmus + + pro Monat + + pro Jahr +
    +
    +
    +
    + ); +} + +function SubscriptionRow({ + entry, + categoryName, +}: { + entry: Subscription; + categoryName: string; +}) { + return ( + + +
    + {entry.title} + {entry.is_installment && Raten} + {entry.is_cancelled && Gekündigt} + {entry.days_until_notice !== null && entry.days_until_notice <= 60 && ( + Frist in {entry.days_until_notice} Tagen + )} +
    +

    + {entry.merchant_name ? `${entry.merchant_name} · ` : ""} + {categoryName} +

    + + + {describeRRule(entry.rrule)} + + {formatMoney(entry.monthly_cost)} + + {formatMoney(entry.annual_cost)} + + + ); +} + +/* --- 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 ; + + return ( +
    +
    + {( + [ + ["month", formatMonth(von)], + ["year", String(heute.getFullYear())], + ] as const + ).map(([wert, beschriftung]) => ( + + ))} +
    + + +
    + ); +} + +/* --- Jahresvergleich ------------------------------------------------------ */ + +function YearTab() { + const [jahr, setJahr] = useState(() => new Date().getFullYear()); + const { data, isLoading } = useYearComparison(jahr); + + return ( +
    + + + {isLoading || !data ? : } +
    + ); +} + +/* --- Export --------------------------------------------------------------- */ + +function ExportTab() { + const [monat, setMonat] = useState(() => firstOfMonth()); + const jahr = new Date().getFullYear(); + + return ( +
    + + + + + 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 }) }, + ]} + /> +
    + ); +} + +function ExportCard({ + title, + description, + links, + extra, +}: { + title: string; + description: string; + links: { label: string; href: string }[]; + extra?: React.ReactNode; +}) { + return ( +
    +

    {title}

    +

    {description}

    + {extra} +
    + {links.map((verweis) => ( + + + {verweis.label} + + ))} +
    +
    + ); +} diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 966dc29..1f82e21 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -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, + }); +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index b695d4e..cecea46 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -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[]; +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index c4b8aca..b894a2b 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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,