Files
moneyfy/backend/tests/test_month_start_day.py
T
Jonas MenzelandClaude Opus 5 0a6261fc55
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
feat: einstellbarer Monatsbeginn zum Gehaltstag
Wer nach dem Gehaltseingang plant, stellt unter Einstellungen den Tag
ein, ab dem ein neuer Monat zählt. Der Zeitraum läuft dann vom
Gehaltstag bis zum Vortag des nächsten und trägt den Namen des Monats,
in dem er beginnt: Mit dem 25. umfasst „September 2026“ den 25.09. bis
zum 24.10. Ein Starttag jenseits der Monatslänge rutscht auf den
Monatsletzten, sodass 31 verlässlich den letzten Tag des Monats meint.

Dashboard, Cashflow-Kalender, Budgets, Zwölf-Monats-Vorschau, die
Kategorienauswertung, der Monatsexport und die Benachrichtigung über
überschrittene Budgets rechnen mit diesem Zeitraum. Budgets bleiben je
Monat gepflegt; der Bezeichner ist weiterhin der Monatserste, nur der
Schnitt verschiebt sich. Bestandsinstallationen bleiben beim Ersten.

Die Einstellung liegt in einer einzeiligen Tabelle hinter
GET/PUT /api/settings; die Monatsauswertungen liefern zusätzlich
period_start und period_end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fj7mB1PGA1aDHyfdSGHgzD
2026-09-10 09:52:51 +02:00

196 lines
7.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)