feat: einstellbarer Monatsbeginn zum Gehaltstag
CI / backend (push) Successful in 2m33s
Images bauen / build (backend) (push) Successful in 3m26s
Images bauen / build (frontend) (push) Successful in 4m4s
CI / frontend (push) Successful in 6m32s

Wer nach dem Gehaltseingang plant, stellt unter Einstellungen den Tag
ein, ab dem ein neuer Monat zählt. Der Zeitraum läuft dann vom
Gehaltstag bis zum Vortag des nächsten und trägt den Namen des Monats,
in dem er beginnt: Mit dem 25. umfasst „September 2026“ den 25.09. bis
zum 24.10. Ein Starttag jenseits der Monatslänge rutscht auf den
Monatsletzten, sodass 31 verlässlich den letzten Tag des Monats meint.

Dashboard, Cashflow-Kalender, Budgets, Zwölf-Monats-Vorschau, die
Kategorienauswertung, der Monatsexport und die Benachrichtigung über
überschrittene Budgets rechnen mit diesem Zeitraum. Budgets bleiben je
Monat gepflegt; der Bezeichner ist weiterhin der Monatserste, nur der
Schnitt verschiebt sich. Bestandsinstallationen bleiben beim Ersten.

Die Einstellung liegt in einer einzeiligen Tabelle hinter
GET/PUT /api/settings; die Monatsauswertungen liefern zusätzlich
period_start und period_end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fj7mB1PGA1aDHyfdSGHgzD
This commit is contained in:
Jonas Menzel
2026-09-10 09:52:51 +02:00
co-authored by Claude Opus 5
parent 998d5867df
commit 0a6261fc55
31 changed files with 1153 additions and 119 deletions
+41 -17
View File
@@ -6,8 +6,8 @@ from decimal import Decimal
from fastapi import APIRouter, Query
from sqlalchemy import select
from app.api.deps import DbSession
from app.core.clock import month_end, month_start, today
from app.api.deps import DbSession, MonthStartDay
from app.core.clock import today
from app.core.errors import ValidationError
from app.models import SavingsGoal
from app.models.enums import EntryKind
@@ -43,10 +43,12 @@ from app.services.reports import (
budget_status,
calendar_month,
category_breakdown,
current_period,
flows,
forecast,
month_report,
months_between,
period_of,
required_monthly_rate,
subscriptions,
year_comparison,
@@ -67,6 +69,8 @@ def _totals(value: Totals) -> TotalsOut:
def _month(report: MonthReport) -> MonthReportOut:
return MonthReportOut(
month=report.month,
period_start=report.period.start,
period_end=report.period.end,
planned=_totals(report.planned),
actual=_totals(report.actual),
previous_planned=_totals(report.previous_planned),
@@ -89,6 +93,8 @@ def _month(report: MonthReport) -> MonthReportOut:
def _forecast_month(entry: ForecastMonth) -> ForecastMonthOut:
return ForecastMonthOut(
month=entry.month,
period_start=entry.period_start,
period_end=entry.period_end,
income=entry.income,
expenses=entry.expenses,
balance=entry.balance,
@@ -216,11 +222,15 @@ async def _goals(session: DbSession, as_of: date) -> list[SavingsGoalProgressOut
)
async def read_month_report(
session: DbSession,
start_day: MonthStartDay,
month: date | None = Query(
default=None, description="Beliebiger Tag im gewünschten Monat; Vorgabe ist heute."
default=None,
description="Beliebiger Tag im gewünschten Monat; ausschlaggebend ist dessen "
"Monatserster. Vorgabe ist der laufende Abrechnungsmonat.",
),
) -> MonthReportOut:
return _month(await month_report(session, month or today()))
monat = month or current_period(start_day).key
return _month(await month_report(session, monat, start_day))
@router.get(
@@ -232,10 +242,13 @@ async def read_month_report(
)
async def read_forecast(
session: DbSession,
start_day: MonthStartDay,
months: int = Query(default=12, ge=1, le=MAX_FORECAST_MONTHS),
start: date | None = Query(default=None, description="Erster Monat; Vorgabe ist heute."),
start: date | None = Query(
default=None, description="Erster Monat; Vorgabe ist der laufende Abrechnungsmonat."
),
) -> ForecastOut:
monate = await forecast(session, months, start)
monate = await forecast(session, months, start, start_day)
return ForecastOut(
months=[_forecast_month(monat) for monat in monate],
total_income=sum((monat.income for monat in monate), Decimal("0.00")),
@@ -251,13 +264,14 @@ async def read_forecast(
)
async def read_categories(
session: DbSession,
start_day: MonthStartDay,
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)
laufend = current_period(start_day)
start = date_from or laufend.start
ende = date_to or laufend.end
if ende < start:
raise ValidationError("'to' darf nicht vor 'from' liegen.", code="invalid_date_range")
@@ -327,11 +341,15 @@ async def read_year_comparison(
)
async def read_calendar(
session: DbSession,
start_day: MonthStartDay,
month: date | None = Query(default=None, description="Beliebiger Tag im Monat."),
) -> CalendarMonthOut:
raster = await calendar_month(session, month or today())
monat = month or current_period(start_day).key
raster = await calendar_month(session, monat, start_day=start_day)
return CalendarMonthOut(
month=raster.month,
period_start=raster.period_start,
period_end=raster.period_end,
days=[
CalendarDayOut(
date=tag.on,
@@ -358,9 +376,11 @@ async def read_calendar(
)
async def read_budget_status(
session: DbSession,
start_day: MonthStartDay,
month: date | None = Query(default=None),
) -> list[BudgetStatusOut]:
return [_budget(eintrag) for eintrag in await budget_status(session, month or today())]
monat = month or current_period(start_day).key
return [_budget(eintrag) for eintrag in await budget_status(session, monat, start_day)]
@router.get(
@@ -382,25 +402,29 @@ async def read_goal_progress(session: DbSession) -> list[SavingsGoalProgressOut]
)
async def read_dashboard(
session: DbSession,
start_day: MonthStartDay,
month: date | None = Query(default=None),
) -> DashboardOut:
heute = today()
monat = month_start(month or heute)
zeitraum = period_of(month, start_day) if month else current_period(start_day, heute)
bericht = await month_report(session, monat)
vorschau = await forecast(session, 12, monat)
gruppen = await category_breakdown(session, monat, month_end(monat))
bericht = await month_report(session, zeitraum.key, start_day)
vorschau = await forecast(session, 12, zeitraum.key, start_day)
gruppen = await category_breakdown(session, zeitraum.start, zeitraum.end)
abos = await subscriptions(session)
# Die nächsten zwei Wochen ab heute, unabhängig vom betrachteten Monat.
# Die nächsten zwei Wochen ab heute, unabhängig vom betrachteten Zeitraum.
naechste = await flows(session, heute, heute + timedelta(days=14))
return DashboardOut(
month_start_day=start_day,
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)],
budgets=[
_budget(eintrag) for eintrag in await budget_status(session, zeitraum.key, start_day)
],
goals=await _goals(session, heute),
upcoming=[_entry(eintrag) for eintrag in naechste[:20]],
upcoming_deadlines=[_subscription(eintrag) for eintrag in abos.upcoming_deadlines],