feat: einstellbarer Monatsbeginn zum Gehaltstag
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:
co-authored by
Claude Opus 5
parent
998d5867df
commit
0a6261fc55
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user