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
89 lines
2.8 KiB
Python
89 lines
2.8 KiB
Python
"""Zeitfunktionen. Fachlich gilt durchgängig `Europe/Berlin`."""
|
||
|
||
from datetime import UTC, date, datetime, timedelta
|
||
from zoneinfo import ZoneInfo
|
||
|
||
from app.core.config import settings
|
||
|
||
|
||
def tz() -> ZoneInfo:
|
||
return ZoneInfo(settings.timezone)
|
||
|
||
|
||
def now() -> datetime:
|
||
"""Aktueller Zeitpunkt in der fachlichen Zeitzone."""
|
||
return datetime.now(tz())
|
||
|
||
|
||
def utcnow() -> datetime:
|
||
"""Aktueller Zeitpunkt in UTC – für Zeitstempel in der Datenbank."""
|
||
return datetime.now(UTC)
|
||
|
||
|
||
def today() -> date:
|
||
"""Heutiges Datum in der fachlichen Zeitzone."""
|
||
return now().date()
|
||
|
||
|
||
def month_start(day: date) -> date:
|
||
"""Erster Tag des Monats, in dem `day` liegt."""
|
||
return day.replace(day=1)
|
||
|
||
|
||
def month_end(day: date) -> date:
|
||
"""Letzter Tag des Monats, in dem `day` liegt."""
|
||
if day.month == 12:
|
||
return day.replace(day=31)
|
||
return day.replace(month=day.month + 1, day=1) - timedelta(days=1)
|
||
|
||
|
||
def add_months(day: date, months: int) -> date:
|
||
"""Verschiebt ein Datum um ganze Monate; der Monatserste bleibt Monatserster."""
|
||
total = day.month - 1 + months
|
||
year = day.year + total // 12
|
||
month = total % 12 + 1
|
||
last_day = month_end(date(year, month, 1)).day
|
||
return date(year, month, min(day.day, last_day))
|
||
|
||
|
||
# --- Abrechnungsmonat ----------------------------------------------------------
|
||
#
|
||
# Wer am Gehaltstag rechnet, für den beginnt der Monat nicht am Ersten. Ein
|
||
# Abrechnungsmonat läuft vom Gehaltstag bis zum Vortag des nächsten und trägt
|
||
# den Namen des Monats, in dem er beginnt: Startet er am 25. September, heißt
|
||
# er „September“ und endet am 24. Oktober.
|
||
|
||
MIN_MONTH_START_DAY = 1
|
||
MAX_MONTH_START_DAY = 31
|
||
DEFAULT_MONTH_START_DAY = 1
|
||
|
||
|
||
def month_anchor(day: date, start_day: int) -> date:
|
||
"""Der Gehaltstag im Monat von `day`.
|
||
|
||
Ein Starttag jenseits der Monatslänge rutscht auf den Monatsletzten – so
|
||
trifft der 31. in jedem Monat den letzten Tag.
|
||
"""
|
||
return day.replace(day=min(start_day, month_end(day).day))
|
||
|
||
|
||
def period_start(day: date, start_day: int) -> date:
|
||
"""Beginn des Abrechnungsmonats, in dem `day` liegt."""
|
||
anchor = month_anchor(day, start_day)
|
||
if day >= anchor:
|
||
return anchor
|
||
return month_anchor(add_months(month_start(day), -1), start_day)
|
||
|
||
|
||
def period_key(day: date, start_day: int) -> date:
|
||
"""Bezeichner des Abrechnungsmonats, in dem `day` liegt – immer ein Monatserster."""
|
||
return month_start(period_start(day, start_day))
|
||
|
||
|
||
def period_bounds(month: date, start_day: int) -> tuple[date, date]:
|
||
"""Erster und letzter Tag des Abrechnungsmonats mit dem Bezeichner `month`."""
|
||
schluessel = month_start(month)
|
||
beginn = month_anchor(schluessel, start_day)
|
||
ende = month_anchor(add_months(schluessel, 1), start_day) - timedelta(days=1)
|
||
return beginn, ende
|