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
196 lines
7.3 KiB
Python
196 lines
7.3 KiB
Python
"""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)
|