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
+9
View File
@@ -11,6 +11,7 @@ from app.core.security import decode_token
from app.db.session import get_session
from app.models import AppUser
from app.services.auth import get_user
from app.services.settings import month_start_day
DbSession = Annotated[AsyncSession, Depends(get_session)]
@@ -53,3 +54,11 @@ async def get_active_user(user: CurrentUser) -> AppUser:
ActiveUser = Annotated[AppUser, Depends(get_active_user)]
async def get_month_start_day(session: DbSession) -> int:
"""Der eingestellte Monatsbeginn jede monatsbezogene Auswertung richtet sich danach."""
return await month_start_day(session)
MonthStartDay = Annotated[int, Depends(get_month_start_day)]
+2
View File
@@ -21,6 +21,7 @@ from app.api.routes import (
recurrences,
reports,
savings_goals,
settings,
system,
transactions,
)
@@ -47,5 +48,6 @@ protected.include_router(savings_goals.router)
protected.include_router(reports.router)
protected.include_router(export.router)
protected.include_router(notifications.router)
protected.include_router(settings.router)
api_router.include_router(protected)
+10 -7
View File
@@ -6,8 +6,8 @@ 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.api.deps import DbSession, MonthStartDay
from app.core.clock import today
from app.core.errors import ValidationError
from app.models import Account, Merchant, Transaction
from app.models.enums import EntryKind
@@ -15,9 +15,11 @@ 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,
current_period,
flow_rows,
flows,
month_report,
period_of,
recurrence_rows,
reserve_total,
)
@@ -156,15 +158,16 @@ async def export_recurrences(
)
async def export_month(
session: DbSession,
start_day: MonthStartDay,
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())
zeitraum = period_of(month, start_day) if month else current_period(start_day)
namen = await category_names(session)
bewegungen = await flows(session, monat, month_end(monat))
bewegungen = await flows(session, zeitraum.start, zeitraum.end)
zeilen = flow_rows(bewegungen, namen)
bericht = await month_report(session, monat)
bericht = await month_report(session, zeitraum.key, start_day)
kennzahlen: list[dict[str, object]] = [
{"Kennzahl": "Einnahmen (Plan)", "Betrag": bericht.planned.income},
@@ -175,11 +178,11 @@ async def export_month(
{"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": "Rücklagen", "Betrag": await reserve_total(session, zeitraum)},
{"Kennzahl": "Verfügbar nach Fixkosten", "Betrag": bericht.available_after_fixed},
]
kennung = monat.strftime("%Y-%m")
kennung = zeitraum.key.strftime("%Y-%m")
if fmt == "csv":
# CSV kennt keine Blätter die Kennzahlen folgen nach einer Leerzeile.
inhalt = to_csv(zeilen, FLOW_COLUMNS)
+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],
+45
View File
@@ -0,0 +1,45 @@
"""Anwendungseinstellungen."""
from fastapi import APIRouter
from app.api.deps import DbSession
from app.core.clock import period_bounds, period_key, today
from app.schemas.settings import AppSettingsIn, AppSettingsOut
from app.services.settings import load_settings, save_settings
router = APIRouter(prefix="/settings", tags=["settings"])
def _out(month_start_day: int) -> AppSettingsOut:
schluessel = period_key(today(), month_start_day)
beginn, ende = period_bounds(schluessel, month_start_day)
return AppSettingsOut(
month_start_day=month_start_day,
current_month=schluessel,
current_period_start=beginn,
current_period_end=ende,
)
@router.get(
"",
response_model=AppSettingsOut,
summary="Einstellungen lesen",
description="Liefert den eingestellten Monatsbeginn und den daraus folgenden "
"laufenden Abrechnungsmonat.",
)
async def read_settings(session: DbSession) -> AppSettingsOut:
return _out((await load_settings(session)).month_start_day)
@router.put(
"",
response_model=AppSettingsOut,
summary="Einstellungen ändern",
description="Ein geänderter Monatsbeginn wirkt sofort auf Dashboard, Kalender, "
"Budgets, Vorschau und Export.",
)
async def update_settings(session: DbSession, payload: AppSettingsIn) -> AppSettingsOut:
eintrag = await save_settings(session, month_start_day=payload.month_start_day)
await session.commit()
return _out(eintrag.month_start_day)