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
@@ -0,0 +1,38 @@
"""app setting
Revision ID: 5f2a91c0d7e4
Revises: 07d8d62011b3
Create Date: 2026-09-10 09:00:00.000000+02:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = '5f2a91c0d7e4'
down_revision: str | None = '07d8d62011b3'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table('app_setting',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('month_start_day', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.CheckConstraint('id = 1', name=op.f('ck_app_setting_single_row')),
sa.CheckConstraint('month_start_day BETWEEN 1 AND 31', name=op.f('ck_app_setting_month_start_day_range')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_app_setting'))
)
# Bestandsinstallationen rechnen bisher ab dem Monatsersten dabei bleibt es,
# bis der Gehaltstag in den Einstellungen geändert wird.
op.execute(
"INSERT INTO app_setting (id, month_start_day, created_at, updated_at) "
"VALUES (1, 1, now(), now())"
)
def downgrade() -> None:
op.drop_table('app_setting')
+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)
+42
View File
@@ -44,3 +44,45 @@ def add_months(day: date, months: int) -> date:
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
+10 -1
View File
@@ -1,7 +1,15 @@
"""SQLAlchemy-Modelle. Import hier hält Alembics Autogenerate vollständig."""
from app.db.base import Base
from app.models.core import Account, AppUser, Category, LogoAsset, Merchant, RefreshToken
from app.models.core import (
Account,
AppSetting,
AppUser,
Category,
LogoAsset,
Merchant,
RefreshToken,
)
from app.models.enums import (
AccountType,
BusinessDayShift,
@@ -29,6 +37,7 @@ __all__ = [
"Account",
"AccountType",
"AmountVersion",
"AppSetting",
"AppUser",
"Base",
"Budget",
+18
View File
@@ -129,6 +129,24 @@ class Merchant(Base, CreatedAtMixin):
__table_args__ = (Index("ix_merchant_normalized_name", "normalized_name"),)
class AppSetting(Base, TimestampMixin):
"""Anwendungsweite Einstellungen. Es gibt genau eine Zeile mit `id = 1`.
`month_start_day` legt den Gehaltstag fest: Ab diesem Tag rechnet moneyfy
einen neuen Monat. Ein Wert jenseits der Monatslänge trifft den Monatsletzten.
"""
__tablename__ = "app_setting"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
month_start_day: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
__table_args__ = (
CheckConstraint("id = 1", name="single_row"),
CheckConstraint("month_start_day BETWEEN 1 AND 31", name="month_start_day_range"),
)
class AppUser(Base, CreatedAtMixin):
"""Single-User-Betrieb; `external_subject` ist für eine spätere OIDC-Anbindung vorgesehen."""
+17 -5
View File
@@ -28,7 +28,11 @@ class MonthComparisonOut(ApiModel):
class MonthReportOut(ApiModel):
"""Monatsübersicht mit Plan-Ist-Vergleich."""
month: date = Field(description="Immer der Monatserste.")
month: date = Field(
description="Bezeichner des Abrechnungsmonats immer der Monatserste des Startmonats."
)
period_start: date = Field(description="Erster Tag des Zeitraums, also der Monatsbeginn.")
period_end: date = Field(description="Letzter Tag des Zeitraums.")
planned: TotalsOut = Field(description="Soll aus Fälligkeiten und Buchungen.")
actual: TotalsOut = Field(
description="Ist aus bestätigten Fälligkeiten und allen einmaligen Buchungen."
@@ -48,14 +52,16 @@ class MonthReportOut(ApiModel):
class ForecastMonthOut(ApiModel):
"""Ein Monat der Vorschau."""
"""Ein Abrechnungsmonat der Vorschau."""
month: date
period_start: date
period_end: date
income: Money
expenses: Money
balance: Money
cumulative_balance: Money = Field(
description="Prognostizierter Kontostand am Monatsende über alle Konten."
description="Prognostizierter Kontostand am Ende des Zeitraums über alle Konten."
)
@@ -159,8 +165,13 @@ class CalendarDayOut(ApiModel):
class CalendarMonthOut(ApiModel):
month: date
days: list[CalendarDayOut]
month: date = Field(description="Bezeichner des Abrechnungsmonats immer der Monatserste.")
period_start: date
period_end: date
days: list[CalendarDayOut] = Field(
description="Alle Tage des Zeitraums; bei abweichendem Monatsbeginn über zwei "
"Kalendermonate hinweg."
)
opening_balance: Money
closing_balance: Money
lowest_balance: Money
@@ -210,6 +221,7 @@ class SavingsGoalProgressOut(ApiModel):
class DashboardOut(ApiModel):
"""Alles, was das Dashboard in einem Aufruf braucht."""
month_start_day: int = Field(description="Der eingestellte Monatsbeginn.")
month: MonthReportOut
total_balance: Money
forecast: list[ForecastMonthOut]
+36
View File
@@ -0,0 +1,36 @@
"""Schemata der Anwendungseinstellungen."""
from datetime import date
from typing import Annotated
from pydantic import Field
from app.core.clock import MAX_MONTH_START_DAY, MIN_MONTH_START_DAY
from app.schemas.common import ApiModel, InputModel
MonthStartDay = Annotated[
int,
Field(
ge=MIN_MONTH_START_DAY,
le=MAX_MONTH_START_DAY,
description="Tag, an dem der Abrechnungsmonat beginnt üblicherweise der Gehaltstag. "
"Ein Wert jenseits der Monatslänge trifft den Monatsletzten.",
),
]
class AppSettingsOut(ApiModel):
"""Die gültigen Einstellungen samt aktuellem Abrechnungsmonat."""
month_start_day: MonthStartDay
current_month: date = Field(
description="Bezeichner des laufenden Abrechnungsmonats immer ein Monatserster."
)
current_period_start: date
current_period_end: date
class AppSettingsIn(InputModel):
"""Änderbare Einstellungen."""
month_start_day: MonthStartDay
+9 -7
View File
@@ -17,7 +17,7 @@ from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.clock import add_months, month_end, month_start, today, utcnow
from app.core.clock import add_months, month_end, today, utcnow
from app.core.config import settings
from app.models import LogoAsset, Merchant, NotificationLog, NotificationRule
from app.models.enums import (
@@ -29,7 +29,8 @@ from app.models.enums import (
from app.services.channels import Attachment, ChannelError, Notification, get_channel
from app.services.occurrences import load_recurrences
from app.services.recurrence import contract_term
from app.services.reports import budget_status, flows
from app.services.reports import budget_status, current_period, flows
from app.services.settings import month_start_day
logger = logging.getLogger(__name__)
@@ -169,14 +170,15 @@ async def collect_notice_deadlines(
async def collect_budget_exceeded(
session: AsyncSession, rule: NotificationRule, as_of: date
) -> list[Event]:
"""Überschrittene Budgets höchstens einmal je Monat und Kategorie."""
monat = month_start(as_of)
"""Überschrittene Budgets höchstens einmal je Abrechnungsmonat und Kategorie."""
monatsbeginn = await month_start_day(session)
zeitraum = current_period(monatsbeginn, as_of)
return [
Event(
ref_type=REF_BUDGET,
ref_id=str(eintrag.category_id),
dedupe_day=monat,
dedupe_day=zeitraum.key,
headline=eintrag.category_name,
detail=(
f"{_money(eintrag.spent)} von {_money(eintrag.available)} verbraucht "
@@ -184,9 +186,9 @@ async def collect_budget_exceeded(
f"{_money(abs(eintrag.remaining))} zu viel"
),
amount=eintrag.spent,
on=month_end(monat),
on=zeitraum.end,
)
for eintrag in await budget_status(session, monat)
for eintrag in await budget_status(session, zeitraum.key, monatsbeginn)
if eintrag.state == "exceeded"
]
+100 -35
View File
@@ -14,7 +14,14 @@ 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, today
from app.core.clock import (
DEFAULT_MONTH_START_DAY,
add_months,
month_start,
period_bounds,
period_key,
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
@@ -83,6 +90,33 @@ class Totals:
return self.income - self.expenses
@dataclass(frozen=True, slots=True)
class Period:
"""Ein Abrechnungsmonat.
Er läuft vom eingestellten Monatsbeginn dem Gehaltstag bis zum Vortag des
nächsten und trägt den Namen des Monats, in dem er beginnt. Bei Monatsbeginn 1
ist er deckungsgleich mit dem Kalendermonat.
"""
key: date
"""Bezeichner: der Erste des Monats, in dem der Zeitraum beginnt."""
start: date
end: date
def period_of(month: date, start_day: int = DEFAULT_MONTH_START_DAY) -> Period:
"""Der Abrechnungsmonat mit dem Bezeichner `month`."""
schluessel = month_start(month)
beginn, ende = period_bounds(schluessel, start_day)
return Period(key=schluessel, start=beginn, end=ende)
def current_period(start_day: int = DEFAULT_MONTH_START_DAY, as_of: date | None = None) -> Period:
"""Der Abrechnungsmonat, in dem `as_of` liegt Vorgabe ist heute."""
return period_of(period_key(as_of or today(), start_day), start_day)
def totals_of(
entries: Iterable[FlowEntry],
*,
@@ -180,9 +214,9 @@ async def flows(
@dataclass(slots=True)
class MonthReport:
"""Kennzahlen eines Monats."""
"""Kennzahlen eines Abrechnungsmonats."""
month: date
period: Period
planned: Totals
actual: Totals
previous_planned: Totals
@@ -194,6 +228,11 @@ class MonthReport:
open_count: int = 0
skipped_count: int = 0
@property
def month(self) -> date:
"""Bezeichner des Zeitraums immer ein Monatserster."""
return self.period.key
@property
def available_after_fixed(self) -> Decimal:
"""Einkünfte abzüglich Fixkosten und Rücklagen die große Kennzahl im Dashboard."""
@@ -213,27 +252,29 @@ class MonthReport:
async def _month_flows(
session: AsyncSession, month: date, fixed_costs: dict[int, bool]
session: AsyncSession, period: Period, fixed_costs: dict[int, bool]
) -> list[FlowEntry]:
return await flows(session, month_start(month), month_end(month), fixed_costs=fixed_costs)
return await flows(session, period.start, period.end, fixed_costs=fixed_costs)
async def reserve_total(session: AsyncSession, month: date) -> Decimal:
async def reserve_total(session: AsyncSession, period: Period) -> Decimal:
"""Summe der monatlichen Rücklagen aller Posten mit aktivierter Rücklagenbildung."""
total = ZERO
for recurrence in await load_recurrences(session):
if recurrence.reserve_enabled:
total += monthly_reserve(
recurrence, month_start(month), amount_versions=recurrence.amount_versions
recurrence, period.start, amount_versions=recurrence.amount_versions
)
return total
async def month_report(session: AsyncSession, month: date) -> MonthReport:
async def month_report(
session: AsyncSession, month: date, start_day: int = DEFAULT_MONTH_START_DAY
) -> MonthReport:
"""Monatsübersicht inklusive Vergleich zum Vormonat."""
fix = await fixed_cost_map(session)
aktuell = month_start(month)
vormonat = add_months(aktuell, -1)
aktuell = period_of(month, start_day)
vormonat = period_of(add_months(aktuell.key, -1), start_day)
bewegungen = await _month_flows(session, aktuell, fix)
vorherige = await _month_flows(session, vormonat, fix)
@@ -245,13 +286,13 @@ async def month_report(session: AsyncSession, month: date) -> MonthReport:
# 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))
for item in await due_items(session, aktuell.start, aktuell.end)
if item.planned.status is OccurrenceStatus.SKIPPED
)
serien = [entry for entry in bewegungen if entry.source == "recurrence"]
return MonthReport(
month=aktuell,
period=aktuell,
planned=totals_of(bewegungen),
actual=totals_of(bewegungen, basis="effective", only_confirmed=True),
previous_planned=totals_of(vorherige),
@@ -270,13 +311,15 @@ async def month_report(session: AsyncSession, month: date) -> MonthReport:
@dataclass(frozen=True, slots=True)
class ForecastMonth:
"""Ein Monat der Vorschau."""
"""Ein Abrechnungsmonat der Vorschau."""
month: date
period_start: date
period_end: date
income: Decimal
expenses: Decimal
cumulative_balance: Decimal
"""Prognostizierter Kontostand am Monatsende über alle Konten."""
"""Prognostizierter Kontostand am Ende des Zeitraums über alle Konten."""
@property
def balance(self) -> Decimal:
@@ -284,28 +327,33 @@ class ForecastMonth:
async def forecast(
session: AsyncSession, months: int = 12, start: date | None = None
session: AsyncSession,
months: int = 12,
start: date | None = None,
start_day: int = DEFAULT_MONTH_START_DAY,
) -> 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())
erster = period_of(start, start_day) if start is not None else current_period(start_day)
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))
# Ausgangspunkt ist der bestätigte Kontostand am Tag vor dem ersten Zeitraum.
laufend = await total_balance(session, erster.start - 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)
zeitraum = period_of(add_months(erster.key, versatz), start_day)
bewegungen = await flows(session, zeitraum.start, zeitraum.end, fixed_costs=fix)
summen = totals_of(bewegungen, basis="effective")
laufend += summen.balance
ergebnis.append(
ForecastMonth(
month=monat,
month=zeitraum.key,
period_start=zeitraum.start,
period_end=zeitraum.end,
income=summen.income,
expenses=summen.expenses,
cumulative_balance=laufend,
@@ -567,6 +615,9 @@ class CalendarDay:
@dataclass(slots=True)
class CalendarMonth:
month: date
"""Bezeichner des Abrechnungsmonats immer ein Monatserster."""
period_start: date
period_end: date
days: list[CalendarDay]
opening_balance: Decimal
closing_balance: Decimal
@@ -575,11 +626,16 @@ class CalendarMonth:
async def calendar_month(
session: AsyncSession, month: date, *, holiday_region: str = "DE-NW"
session: AsyncSession,
month: date,
*,
start_day: int = DEFAULT_MONTH_START_DAY,
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)
"""Tagesraster des Abrechnungsmonats mit den Fälligkeiten und dem laufenden Kontostand."""
zeitraum = period_of(month, start_day)
beginn = zeitraum.start
ende = zeitraum.end
eroeffnung = await total_balance(session, beginn - timedelta(days=1))
bewegungen = await flows(session, beginn, ende)
@@ -614,7 +670,9 @@ async def calendar_month(
tag += timedelta(days=1)
return CalendarMonth(
month=beginn,
month=zeitraum.key,
period_start=beginn,
period_end=ende,
days=tage,
opening_balance=eroeffnung,
closing_balance=laufend,
@@ -697,14 +755,17 @@ async def _effective_limits(
return ergebnis
async def _spent_by_category(session: AsyncSession, month: date) -> dict[int, Decimal]:
"""Ausgaben eines Monats je Kategorie, Unterkategorien auf die Oberkategorie gerollt."""
async def _spent_by_category(
session: AsyncSession, month: date, start_day: int
) -> dict[int, Decimal]:
"""Ausgaben eines Zeitraums je Kategorie, Unterkategorien auf die Oberkategorie gerollt."""
kategorien = {
kategorie.id: kategorie.parent_id
for kategorie in (await session.execute(select(Category))).scalars()
}
zeitraum = period_of(month, start_day)
ergebnis: dict[int, Decimal] = {}
for entry in await flows(session, month_start(month), month_end(month)):
for entry in await flows(session, zeitraum.start, zeitraum.end):
if entry.kind is not EntryKind.EXPENSE:
continue
# Ein Budget auf der Oberkategorie umfasst auch deren Unterkategorien.
@@ -715,8 +776,10 @@ async def _spent_by_category(session: AsyncSession, month: date) -> dict[int, De
return ergebnis
async def budget_status(session: AsyncSession, month: date) -> list[BudgetStatus]:
"""Budgets eines Monats samt Verbrauch und Übertrag."""
async def budget_status(
session: AsyncSession, month: date, start_day: int = DEFAULT_MONTH_START_DAY
) -> list[BudgetStatus]:
"""Budgets eines Abrechnungsmonats samt Verbrauch und Übertrag."""
monat = month_start(month)
limits = await _effective_limits(session, monat)
if not limits:
@@ -725,7 +788,7 @@ async def budget_status(session: AsyncSession, month: date) -> list[BudgetStatus
namen = {
kategorie.id: kategorie for kategorie in (await session.execute(select(Category))).scalars()
}
ausgaben = await _spent_by_category(session, monat)
ausgaben = await _spent_by_category(session, monat, start_day)
ergebnis: list[BudgetStatus] = []
for kategorie_id, (limit, rollover, aus_vorlage) in limits.items():
@@ -733,7 +796,7 @@ async def budget_status(session: AsyncSession, month: date) -> list[BudgetStatus
if kategorie is None:
continue
uebertrag = await _carry_over(session, kategorie_id, monat) if rollover else ZERO
uebertrag = await _carry_over(session, kategorie_id, monat, start_day) if rollover else ZERO
ergebnis.append(
BudgetStatus(
category_id=kategorie_id,
@@ -752,7 +815,9 @@ async def budget_status(session: AsyncSession, month: date) -> list[BudgetStatus
return ergebnis
async def _carry_over(session: AsyncSession, category_id: int, month: date) -> Decimal:
async def _carry_over(
session: AsyncSession, category_id: int, month: date, start_day: int
) -> Decimal:
"""Nicht verbrauchtes Budget aus den Vormonaten.
Es wird höchstens ein Jahr zurückgeschaut; ein Überschreiten setzt den
@@ -772,7 +837,7 @@ async def _carry_over(session: AsyncSession, category_id: int, month: date) -> D
uebertrag = ZERO
continue
ausgaben = (await _spent_by_category(session, vormonat)).get(category_id, ZERO)
ausgaben = (await _spent_by_category(session, vormonat, start_day)).get(category_id, ZERO)
uebertrag = max(limit + uebertrag - ausgaben, ZERO)
return uebertrag
+37
View File
@@ -0,0 +1,37 @@
"""Anwendungsweite Einstellungen.
Die Tabelle enthält genau eine Zeile. Fehlt sie etwa direkt nach der
Migration , liefert `load_settings` die Vorgaben, ohne sie zu schreiben.
Erst ein Speichern legt die Zeile an.
"""
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.clock import DEFAULT_MONTH_START_DAY
from app.models import AppSetting
SETTING_ID = 1
async def load_settings(session: AsyncSession) -> AppSetting:
"""Die Einstellungen; ohne gespeicherte Zeile ein Objekt mit den Vorgaben."""
vorhanden = await session.get(AppSetting, SETTING_ID)
if vorhanden is not None:
return vorhanden
return AppSetting(id=SETTING_ID, month_start_day=DEFAULT_MONTH_START_DAY)
async def month_start_day(session: AsyncSession) -> int:
"""Der eingestellte Gehaltstag ab ihm beginnt der Abrechnungsmonat."""
return (await load_settings(session)).month_start_day
async def save_settings(session: AsyncSession, *, month_start_day: int) -> AppSetting:
"""Schreibt die Einstellungen und legt die Zeile bei Bedarf an."""
eintrag = await session.get(AppSetting, SETTING_ID)
if eintrag is None:
eintrag = AppSetting(id=SETTING_ID)
session.add(eintrag)
eintrag.month_start_day = month_start_day
await session.flush()
return eintrag
+195
View File
@@ -0,0 +1,195 @@
"""Der einstellbare Monatsbeginn der Gehaltstag statt des Monatsersten."""
from datetime import date
import pytest
from httpx import AsyncClient
from app.core.clock import month_anchor, period_bounds, period_key, period_start
from app.services.reports import current_period, period_of
# --- Zeitrechnung ---------------------------------------------------------------
def test_monatserster_bleibt_der_kalendermonat() -> None:
assert period_bounds(date(2026, 9, 1), 1) == (date(2026, 9, 1), date(2026, 9, 30))
assert period_key(date(2026, 9, 30), 1) == date(2026, 9, 1)
def test_gehaltstag_verschiebt_den_zeitraum() -> None:
"""Der Zeitraum trägt den Namen des Monats, in dem er beginnt."""
assert period_bounds(date(2026, 9, 1), 25) == (date(2026, 9, 25), date(2026, 10, 24))
assert period_key(date(2026, 9, 24), 25) == date(2026, 8, 1)
assert period_key(date(2026, 9, 25), 25) == date(2026, 9, 1)
def test_starttag_jenseits_der_monatslaenge_trifft_den_letzten() -> None:
"""Der 31. bedeutet „letzter Tag des Monats“ auch im Februar."""
assert month_anchor(date(2026, 2, 10), 31) == date(2026, 2, 28)
assert period_bounds(date(2026, 1, 1), 31) == (date(2026, 1, 31), date(2026, 2, 27))
assert period_bounds(date(2026, 2, 1), 31) == (date(2026, 2, 28), date(2026, 3, 30))
@pytest.mark.parametrize("start_day", [1, 5, 15, 25, 28, 31])
def test_zeitraeume_schliessen_lueckenlos_aneinander_an(start_day: int) -> None:
"""Kein Tag fällt zwischen zwei Zeiträume, und keiner zählt doppelt."""
monat = date(2025, 12, 1)
_, vorher_ende = period_bounds(monat, start_day)
for versatz in range(1, 18):
schluessel = date(
monat.year + (monat.month - 1 + versatz) // 12,
(monat.month - 1 + versatz) % 12 + 1,
1,
)
beginn, ende = period_bounds(schluessel, start_day)
assert (beginn - vorher_ende).days == 1
assert period_start(beginn, start_day) == beginn
assert period_start(ende, start_day) == beginn
vorher_ende = ende
def test_current_period_richtet_sich_nach_dem_stichtag() -> None:
zeitraum = current_period(25, date(2026, 9, 10))
assert (zeitraum.key, zeitraum.start, zeitraum.end) == (
date(2026, 8, 1),
date(2026, 8, 25),
date(2026, 9, 24),
)
# --- Einstellung über die API ---------------------------------------------------
async def test_einstellung_lesen_und_speichern(auth_client: AsyncClient) -> None:
antwort = await auth_client.get("/api/settings")
assert antwort.status_code == 200
assert antwort.json()["month_start_day"] == 1
antwort = await auth_client.put("/api/settings", json={"month_start_day": 25})
assert antwort.status_code == 200
daten = antwort.json()
assert daten["month_start_day"] == 25
assert daten["current_period_start"].endswith("-25")
assert (await auth_client.get("/api/settings")).json()["month_start_day"] == 25
async def test_einstellung_weist_ungueltige_tage_ab(auth_client: AsyncClient) -> None:
for tag in (0, 32):
antwort = await auth_client.put("/api/settings", json={"month_start_day": tag})
assert antwort.status_code == 422, antwort.text
# --- Wirkung auf die Auswertungen -----------------------------------------------
async def _buchung(client: AsyncClient, seeded: dict, tag: str, betrag: str) -> None:
antwort = await client.post(
"/api/transactions",
json={
"kind": "expense",
"title": f"Einkauf {tag}",
"category_id": seeded["lebensmittel"],
"account_id": seeded["account_id"],
"amount": betrag,
"booking_date": tag,
},
)
assert antwort.status_code == 201, antwort.text
async def test_monatsuebersicht_folgt_dem_gehaltstag(
auth_client: AsyncClient, seeded: dict
) -> None:
"""Der 20. September gehört zum August, der 26. zum September."""
await _buchung(auth_client, seeded, "2026-09-20", "40.00")
await _buchung(auth_client, seeded, "2026-09-26", "60.00")
await auth_client.put("/api/settings", json={"month_start_day": 25})
antwort = await auth_client.get("/api/reports/month", params={"month": "2026-09-01"})
assert antwort.status_code == 200
daten = antwort.json()
assert daten["period_start"] == "2026-09-25"
assert daten["period_end"] == "2026-10-24"
assert daten["planned"]["expenses"] == "60.00"
# Der 20.09. liegt im Vormonat, also im Zeitraum August.
assert daten["previous_planned"]["expenses"] == "40.00"
async def test_kalender_laeuft_vom_gehaltstag_bis_zum_vortag(
auth_client: AsyncClient, seeded: dict
) -> None:
await auth_client.put("/api/settings", json={"month_start_day": 25})
antwort = await auth_client.get("/api/reports/calendar", params={"month": "2026-09-01"})
assert antwort.status_code == 200
daten = antwort.json()
assert daten["month"] == "2026-09-01"
assert daten["period_start"] == "2026-09-25"
assert daten["period_end"] == "2026-10-24"
assert daten["days"][0]["date"] == "2026-09-25"
assert daten["days"][-1]["date"] == "2026-10-24"
assert len(daten["days"]) == 30
async def test_vorschau_beginnt_mit_dem_gehaltsmonat(
auth_client: AsyncClient, seeded: dict
) -> None:
await auth_client.put("/api/settings", json={"month_start_day": 25})
antwort = await auth_client.get(
"/api/reports/forecast", params={"months": 3, "start": "2026-09-01"}
)
assert antwort.status_code == 200
monate = antwort.json()["months"]
assert [monat["month"] for monat in monate] == ["2026-09-01", "2026-10-01", "2026-11-01"]
assert monate[0]["period_start"] == "2026-09-25"
assert monate[2]["period_end"] == "2026-12-24"
async def test_budget_zaehlt_nur_ausgaben_des_gehaltsmonats(
auth_client: AsyncClient, seeded: dict
) -> None:
antwort = await auth_client.post(
"/api/budgets",
json={
"category_id": seeded["lebensmittel"],
"period_month": "2026-09-01",
"limit_amount": "500.00",
},
)
assert antwort.status_code == 201, antwort.text
await _buchung(auth_client, seeded, "2026-09-20", "40.00")
await _buchung(auth_client, seeded, "2026-10-05", "60.00")
await auth_client.put("/api/settings", json={"month_start_day": 25})
antwort = await auth_client.get("/api/reports/budgets", params={"month": "2026-09-01"})
assert antwort.status_code == 200
stand = next(
eintrag for eintrag in antwort.json() if eintrag["category_id"] == seeded["lebensmittel"]
)
# 20.09. liegt vor dem Gehaltstag, 05.10. danach.
assert stand["spent"] == "60.00"
async def test_dashboard_liefert_den_eingestellten_monatsbeginn(
auth_client: AsyncClient, seeded: dict
) -> None:
await auth_client.put("/api/settings", json={"month_start_day": 25})
antwort = await auth_client.get("/api/reports/dashboard", params={"month": "2026-09-01"})
assert antwort.status_code == 200
daten = antwort.json()
assert daten["month_start_day"] == 25
assert daten["month"]["period_start"] == "2026-09-25"
def test_period_of_kuerzt_beliebige_tage_auf_den_monatsersten() -> None:
zeitraum = period_of(date(2026, 9, 17), 25)
assert zeitraum.key == date(2026, 9, 1)
assert zeitraum.start == date(2026, 9, 25)