diff --git a/CHANGELOG.md b/CHANGELOG.md index 51aad9f..5f819da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ die Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/). ### Hinzugefügt +- Einstellbarer Monatsbeginn: Wer am Gehaltstag rechnet, setzt unter + *Einstellungen › Monatsbeginn* den Tag, 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 – der 25. September eröffnet also + „September“ und schließt am 24. Oktober. Ein Starttag jenseits der + Monatslänge rutscht auf den Monatsletzten, sodass 31 verlässlich „letzter Tag + des Monats“ bedeutet. Dashboard, Cashflow-Kalender, Budgets, Vorschau, + Kategorien, Monatsexport und die Budget-Benachrichtigung rechnen mit diesem + Zeitraum. Neue Endpunkte `GET`/`PUT /api/settings`; die Monatsauswertungen + liefern zusätzlich `period_start` und `period_end`. Bestandsinstallationen + bleiben beim Monatsersten. - `python -m app.scripts.reset_password` setzt das Passwort eines Benutzers oder legt ihn an. Nötig, weil die Erstanlage beim Start nur bei leerer Benutzertabelle greift und ein nachträglich geändertes diff --git a/README.md b/README.md index 3bff324..5b88b15 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ echte Kontostände auskommen. Ablegen unter `docs/screenshots/`. - Ratenzahlungen mit Restschuld- und Restratenberechnung. - Rücklagenbildung für nicht-monatliche Posten. - Firmenlogos und Markenfarben, lokal zwischengespeichert (siehe unten). +- Einstellbarer Monatsbeginn: Der Monat startet wahlweise am Ersten oder am + Gehaltstag; alle Monatsansichten folgen diesem Zeitraum (siehe unten). - Auswertungen: Monatsübersicht, Cashflow-Kalender, 12-Monats-Forecast, Kategorien, Abo-Übersicht, Jahresvergleich, Budgets, Sparziele. - Benachrichtigungen per SMTP und Apprise. @@ -113,6 +115,24 @@ Nützliche Ziele: `make check` (alle Prüfungen), `make test`, `make lint`, Kürzel greifen nur, solange kein Eingabefeld den Fokus hat. +### Monatsbeginn + +Standardmäßig ist ein Monat der Kalendermonat. Wer nach dem Gehaltseingang +plant, stellt unter *Einstellungen › Monatsbeginn* den Tag ein, ab dem ein neuer +Monat zählt. Der Zeitraum läuft dann vom Gehaltstag bis zum Vortag des nächsten +und heißt nach dem Monat, in dem er beginnt: Mit dem 25. als Beginn umfasst +„September 2026“ die Tage vom 25.09. bis zum 24.10. + +Ein Starttag jenseits der Monatslänge rutscht auf den Monatsletzten – die 31 +bedeutet also verlässlich „letzter Tag des Monats“, auch im Februar. + +Dashboard, Cashflow-Kalender, Budgets, Zwölf-Monats-Vorschau, die +Kategorienauswertung, der Monatsexport und die Benachrichtigung über +überschrittene Budgets rechnen mit diesem Zeitraum. Budgets bleiben dabei je +Monat gepflegt; der Bezeichner ist weiterhin der Monatserste, nur der Schnitt +verschiebt sich. Die Einstellung gilt für die gesamte Installation und wirkt +sofort, ohne die Daten anzufassen. + ## Barrierefreiheit Die Farbtoken sind gegen die WCAG-Kontraste geprüft – in beiden Modi erreicht diff --git a/backend/alembic/versions/20260910_0900_app_setting.py b/backend/alembic/versions/20260910_0900_app_setting.py new file mode 100644 index 0000000..c952e88 --- /dev/null +++ b/backend/alembic/versions/20260910_0900_app_setting.py @@ -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') diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index fe2ef30..102d1e7 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -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)] diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 1ef6e15..0a6419d 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -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) diff --git a/backend/app/api/routes/export.py b/backend/app/api/routes/export.py index 77277c8..17f1961 100644 --- a/backend/app/api/routes/export.py +++ b/backend/app/api/routes/export.py @@ -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) diff --git a/backend/app/api/routes/reports.py b/backend/app/api/routes/reports.py index 5a3dfdf..526f848 100644 --- a/backend/app/api/routes/reports.py +++ b/backend/app/api/routes/reports.py @@ -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], diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py new file mode 100644 index 0000000..473d650 --- /dev/null +++ b/backend/app/api/routes/settings.py @@ -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) diff --git a/backend/app/core/clock.py b/backend/app/core/clock.py index 32460d4..ecac675 100644 --- a/backend/app/core/clock.py +++ b/backend/app/core/clock.py @@ -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 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 38df691..9580bfe 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/core.py b/backend/app/models/core.py index bf4a3c8..c132ba0 100644 --- a/backend/app/models/core.py +++ b/backend/app/models/core.py @@ -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.""" diff --git a/backend/app/schemas/report.py b/backend/app/schemas/report.py index 7e88862..eee252c 100644 --- a/backend/app/schemas/report.py +++ b/backend/app/schemas/report.py @@ -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] diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py new file mode 100644 index 0000000..1df59a8 --- /dev/null +++ b/backend/app/schemas/settings.py @@ -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 diff --git a/backend/app/services/notifications.py b/backend/app/services/notifications.py index 4a9f4ee..c090d4d 100644 --- a/backend/app/services/notifications.py +++ b/backend/app/services/notifications.py @@ -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" ] diff --git a/backend/app/services/reports.py b/backend/app/services/reports.py index ec86def..fbad3bc 100644 --- a/backend/app/services/reports.py +++ b/backend/app/services/reports.py @@ -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 diff --git a/backend/app/services/settings.py b/backend/app/services/settings.py new file mode 100644 index 0000000..aaac846 --- /dev/null +++ b/backend/app/services/settings.py @@ -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 diff --git a/backend/tests/test_month_start_day.py b/backend/tests/test_month_start_day.py new file mode 100644 index 0000000..bf5739c --- /dev/null +++ b/backend/tests/test_month_start_day.py @@ -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) diff --git a/frontend/src/hooks/useAppSettings.ts b/frontend/src/hooks/useAppSettings.ts new file mode 100644 index 0000000..de68593 --- /dev/null +++ b/frontend/src/hooks/useAppSettings.ts @@ -0,0 +1,35 @@ +/** Anwendungseinstellungen – derzeit der Monatsbeginn (Gehaltstag). */ + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api } from "@/lib/api"; +import { DEFAULT_MONTH_START_DAY } from "@/lib/period"; +import { toast } from "@/store/toast"; +import type { AppSettings, AppSettingsInput } from "@/types/api"; + +export function useAppSettings() { + return useQuery({ + queryKey: ["settings"], + queryFn: () => api.get("/settings"), + // Der Monatsbeginn ändert sich selten und bestimmt jede Monatsansicht. + staleTime: 5 * 60 * 1000, + }); +} + +/** Der eingestellte Monatsbeginn; bis die Abfrage lädt, der Monatserste. */ +export function useMonthStartDay(): number { + return useAppSettings().data?.month_start_day ?? DEFAULT_MONTH_START_DAY; +} + +export function useSaveAppSettings() { + const client = useQueryClient(); + return useMutation({ + mutationFn: (daten: AppSettingsInput) => api.put("/settings", daten), + onSuccess: (einstellungen) => { + client.setQueryData(["settings"], einstellungen); + // Jede Monatsansicht rechnet ab jetzt anders. + void client.invalidateQueries({ queryKey: ["reports"] }); + toast.success("Monatsbeginn gespeichert."); + }, + }); +} diff --git a/frontend/src/hooks/useMonthNavigation.test.tsx b/frontend/src/hooks/useMonthNavigation.test.tsx new file mode 100644 index 0000000..c425e3f --- /dev/null +++ b/frontend/src/hooks/useMonthNavigation.test.tsx @@ -0,0 +1,85 @@ +/** Die Monatsansichten müssen den Abrechnungsmonat abfragen, nicht den Kalendermonat. */ + +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useMonthNavigation } from "@/hooks/useMonthNavigation"; +import { mockFetch, renderWithProviders } from "@/test/utils"; + +function Seite() { + const { monat, beschriftung, zurueck, vor, heute } = useMonthNavigation(); + return ( +
+

{monat}

+

{beschriftung}

+ + + +
+ ); +} + +function einstellung(tag: number) { + mockFetch({ + "/api/settings": { + month_start_day: tag, + current_month: "2026-09-01", + current_period_start: "2026-09-01", + current_period_end: "2026-09-30", + }, + }); +} + +describe("Monatsnavigation", () => { + beforeEach(() => { + // Fester Stichtag: der 10. liegt vor einem Gehaltstag am 25. + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(new Date(2026, 8, 10, 12)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("zeigt ohne abweichende Einstellung den Kalendermonat", async () => { + einstellung(1); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("beschriftung")).toHaveTextContent("September 2026"); + }); + expect(screen.getByTestId("monat")).toHaveTextContent("2026-09-01"); + }); + + it("springt bei Gehaltstag 25 auf den laufenden Zeitraum", async () => { + einstellung(25); + renderWithProviders(); + + // Der 10.09. gehört noch zum Zeitraum, der am 25.08. begonnen hat. + await waitFor(() => { + expect(screen.getByTestId("monat")).toHaveTextContent("2026-08-01"); + }); + expect(screen.getByTestId("beschriftung")).toHaveTextContent( + "August 2026 · 25.08.2026 – 24.09.2026", + ); + }); + + it("blättert monatsweise und kehrt zum laufenden Zeitraum zurück", async () => { + const nutzer = userEvent.setup(); + einstellung(25); + renderWithProviders(); + + await waitFor(() => expect(screen.getByTestId("monat")).toHaveTextContent("2026-08-01")); + + await nutzer.click(screen.getByText("vor")); + expect(screen.getByTestId("monat")).toHaveTextContent("2026-09-01"); + + await nutzer.click(screen.getByText("zurück")); + await nutzer.click(screen.getByText("zurück")); + expect(screen.getByTestId("monat")).toHaveTextContent("2026-07-01"); + + await nutzer.click(screen.getByText("heute")); + expect(screen.getByTestId("monat")).toHaveTextContent("2026-08-01"); + }); +}); diff --git a/frontend/src/hooks/useMonthNavigation.ts b/frontend/src/hooks/useMonthNavigation.ts new file mode 100644 index 0000000..902e555 --- /dev/null +++ b/frontend/src/hooks/useMonthNavigation.ts @@ -0,0 +1,37 @@ +/** Monatsauswahl der Monatsansichten, ausgerichtet am eingestellten Monatsbeginn. */ + +import { useState } from "react"; + +import { useMonthStartDay } from "@/hooks/useAppSettings"; +import { addMonthsIso } from "@/lib/format"; +import { periodKey, periodLabel } from "@/lib/period"; + +export interface MonthNavigation { + /** Bezeichner des angezeigten Abrechnungsmonats – immer ein Monatserster. */ + monat: string; + /** Der eingestellte Monatsbeginn. */ + startDay: number; + /** „September 2026“, bei abweichendem Monatsbeginn samt Zeitraumgrenzen. */ + beschriftung: string; + zurueck: () => void; + vor: () => void; + heute: () => void; +} + +export function useMonthNavigation(): MonthNavigation { + const startDay = useMonthStartDay(); + const [gewaehlt, setGewaehlt] = useState(null); + + // Ohne eigene Auswahl folgt die Ansicht dem laufenden Zeitraum. Trifft die + // Einstellung später ein, rückt sie ohne Zutun auf den richtigen Monat. + const monat = gewaehlt ?? periodKey(startDay); + + return { + monat, + startDay, + beschriftung: periodLabel(startDay, monat), + zurueck: () => setGewaehlt(addMonthsIso(monat, -1)), + vor: () => setGewaehlt(addMonthsIso(monat, 1)), + heute: () => setGewaehlt(null), + }; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 62105f6..cfae199 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -146,6 +146,7 @@ export const api = { get: (path: string, params?: Record) => request(path, { params }), post: (path: string, body?: unknown, params?: Record) => request(path, { method: "POST", body, params }), + put: (path: string, body: unknown) => request(path, { method: "PUT", body }), patch: (path: string, body: unknown) => request(path, { method: "PATCH", body }), del: (path: string) => request(path, { method: "DELETE" }), upload: (path: string, formData: FormData) => diff --git a/frontend/src/lib/period.test.ts b/frontend/src/lib/period.test.ts new file mode 100644 index 0000000..f785e9b --- /dev/null +++ b/frontend/src/lib/period.test.ts @@ -0,0 +1,28 @@ +/** Abrechnungsmonate – dieselben Fälle prüft das Backend in Python. */ + +import { describe, expect, it } from "vitest"; + +import { periodBounds, periodKey, periodLabel } from "@/lib/period"; + +describe("Abrechnungsmonate", () => { + it("lässt den Monatsersten den Kalendermonat sein", () => { + expect(periodBounds(1, "2026-09-01")).toEqual({ start: "2026-09-01", end: "2026-09-30" }); + expect(periodKey(1, new Date(2026, 8, 30))).toBe("2026-09-01"); + expect(periodLabel(1, "2026-09-01")).toBe("September 2026"); + }); + + it("schneidet den Zeitraum am Gehaltstag", () => { + expect(periodBounds(25, "2026-09-01")).toEqual({ start: "2026-09-25", end: "2026-10-24" }); + expect(periodKey(25, new Date(2026, 8, 24))).toBe("2026-08-01"); + expect(periodKey(25, new Date(2026, 8, 25))).toBe("2026-09-01"); + }); + + it("kürzt einen Starttag jenseits der Monatslänge auf den Letzten", () => { + expect(periodBounds(31, "2026-01-01")).toEqual({ start: "2026-01-31", end: "2026-02-27" }); + expect(periodBounds(31, "2026-02-01")).toEqual({ start: "2026-02-28", end: "2026-03-30" }); + }); + + it("nennt bei abweichendem Beginn die Grenzen des Zeitraums", () => { + expect(periodLabel(25, "2026-09-01")).toBe("September 2026 · 25.09.2026 – 24.10.2026"); + }); +}); diff --git a/frontend/src/lib/period.ts b/frontend/src/lib/period.ts new file mode 100644 index 0000000..67d4f23 --- /dev/null +++ b/frontend/src/lib/period.ts @@ -0,0 +1,57 @@ +/** + * Abrechnungsmonate im Frontend. + * + * Ein Abrechnungsmonat beginnt am eingestellten Gehaltstag und endet am Vortag + * des nächsten. Benannt wird er nach dem Monat, in dem er beginnt: Der 25.09. + * eröffnet den Zeitraum „September“, der bis zum 24.10. läuft. Liegt der + * Starttag jenseits der Monatslänge, rutscht er auf den Monatsletzten – so + * bedeutet 31 verlässlich „letzter Tag des Monats“. + * + * Dieselbe Rechnung steht im Backend; hier steht sie, damit Seitenköpfe den + * Zeitraum schon vor der ersten Antwort benennen können. + */ + +import { formatDate, formatMonth, toIsoDate } from "@/lib/format"; + +export const DEFAULT_MONTH_START_DAY = 1; +export const MIN_MONTH_START_DAY = 1; +export const MAX_MONTH_START_DAY = 31; + +/** Der Gehaltstag im Monat von `jahr`/`monat`, gekürzt auf den Monatsletzten. */ +function ankerTag(jahr: number, monat: number, startTag: number): Date { + const letzter = new Date(jahr, monat + 1, 0).getDate(); + return new Date(jahr, monat, Math.min(startTag, letzter)); +} + +function ausIso(monatsErster: string): Date { + const teile = monatsErster.split("-").map(Number); + return new Date(teile[0] ?? 1970, (teile[1] ?? 1) - 1, 1); +} + +/** Bezeichner des Abrechnungsmonats, in dem `datum` liegt – immer ein Monatserster. */ +export function periodKey(startDay: number, datum: Date = new Date()): string { + const tag = new Date(datum.getFullYear(), datum.getMonth(), datum.getDate()); + const anker = ankerTag(tag.getFullYear(), tag.getMonth(), startDay); + const versatz = tag >= anker ? 0 : -1; + return toIsoDate(new Date(tag.getFullYear(), tag.getMonth() + versatz, 1)); +} + +/** Erster und letzter Tag des Abrechnungsmonats mit dem Bezeichner `monthKey`. */ +export function periodBounds(startDay: number, monthKey: string): { start: string; end: string } { + const schluessel = ausIso(monthKey); + const beginn = ankerTag(schluessel.getFullYear(), schluessel.getMonth(), startDay); + const naechster = ankerTag(schluessel.getFullYear(), schluessel.getMonth() + 1, startDay); + naechster.setDate(naechster.getDate() - 1); + return { start: toIsoDate(beginn), end: toIsoDate(naechster) }; +} + +/** + * Beschriftung für Seitenköpfe: „September 2026“, bei abweichendem Monatsbeginn + * ergänzt um die Grenzen des Zeitraums. + */ +export function periodLabel(startDay: number, monthKey: string): string { + const name = formatMonth(monthKey); + if (startDay === DEFAULT_MONTH_START_DAY) return name; + const { start, end } = periodBounds(startDay, monthKey); + return `${name} · ${formatDate(start)} – ${formatDate(end)}`; +} diff --git a/frontend/src/pages/BudgetsPage.tsx b/frontend/src/pages/BudgetsPage.tsx index 633a4ae..eba1640 100644 --- a/frontend/src/pages/BudgetsPage.tsx +++ b/frontend/src/pages/BudgetsPage.tsx @@ -21,12 +21,13 @@ import { useSaveBudget, useSaveBudgetTemplate, } from "@/hooks/useBudgets"; +import { useMonthNavigation } from "@/hooks/useMonthNavigation"; import { useBudgetStatus } from "@/hooks/useReports"; -import { addMonthsIso, firstOfMonth, formatDate, formatMoney, formatMonth } from "@/lib/format"; +import { formatDate, formatMoney, formatMonth } from "@/lib/format"; import type { Budget, BudgetTemplate } from "@/types/api"; export function BudgetsPage() { - const [monat, setMonat] = useState(() => firstOfMonth()); + const { monat, beschriftung, zurueck, vor, heute } = useMonthNavigation(); const [formular, setFormular] = useState<"budget" | "template" | null>(null); const [loeschen, setLoeschen] = useState(null); const [vorlageLoeschen, setVorlageLoeschen] = useState(null); @@ -42,23 +43,23 @@ export function BudgetsPage() { <>
- - + + + +
+

Vorschau

+

+ So werden die Zeiträume mit dem gewählten Tag geschnitten. +

+ +
+ {[laufend, addMonthsIso(laufend, 1)].map((schluessel, index) => { + const grenzen = periodBounds(gewaehlt, schluessel); + return ( +
+
+ {formatMonth(schluessel)} + {index === 0 && laufend} +
+
+ {formatDate(grenzen.start)} – {formatDate(grenzen.end)} +
+
+ ); + })} +
+
+
+ ); +} + /* --- Konten --------------------------------------------------------------- */ function AccountsSection() { diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 83a9c7b..e6dca5e 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -368,7 +368,10 @@ export interface SavingsGoalInput { } export interface MonthReport { + /** Bezeichner des Abrechnungsmonats – immer ein Monatserster. */ month: IsoDate; + period_start: IsoDate; + period_end: IsoDate; planned: Totals; actual: Totals; previous_planned: Totals; @@ -385,6 +388,8 @@ export interface MonthReport { export interface ForecastMonth { month: IsoDate; + period_start: IsoDate; + period_end: IsoDate; income: Money; expenses: Money; balance: Money; @@ -478,6 +483,8 @@ export interface CalendarDay { export interface CalendarMonth { month: IsoDate; + period_start: IsoDate; + period_end: IsoDate; days: CalendarDay[]; opening_balance: Money; closing_balance: Money; @@ -520,6 +527,7 @@ export interface SavingsGoalProgress { } export interface Dashboard { + month_start_day: number; month: MonthReport; total_balance: Money; forecast: ForecastMonth[]; @@ -601,3 +609,15 @@ export interface NotificationRunResult { skipped: number; failed: number; } + +export interface AppSettings { + /** Tag, an dem der Abrechnungsmonat beginnt – der Gehaltstag. */ + month_start_day: number; + current_month: IsoDate; + current_period_start: IsoDate; + current_period_end: IsoDate; +} + +export interface AppSettingsInput { + month_start_day: number; +}