from datetime import date def test_period_bounds_and_labels(): from app.services import budget # Gehalt am 25.: September-Budget läuft 25.08.–24.09. assert budget.period_bounds("2026-09", 25) == (date(2026, 8, 25), date(2026, 9, 24)) # Starttag 1: Kalendermonat. assert budget.period_bounds("2026-09", 1) == (date(2026, 9, 1), date(2026, 9, 30)) # Label-Zuordnung assert budget.period_of(date(2026, 8, 27), 25) == "2026-09" assert budget.period_of(date(2026, 9, 10), 25) == "2026-09" assert budget.period_of(date(2026, 9, 26), 25) == "2026-10" assert budget.period_of(date(2026, 9, 15), 1) == "2026-09" assert budget.shift_label("2026-09", 1) == "2026-10" assert budget.shift_label("2026-01", -1) == "2025-12" def _lebensmittel(admin): groups = admin.get("/api/categories").json() for g in groups: for c in g["categories"]: if c["name"] == "Lebensmittel": return c["id"] raise AssertionError("Lebensmittel-Kategorie fehlt") def _gehalt(admin): groups = admin.get("/api/categories").json() for g in groups: for c in g["categories"]: if c["name"] == "Gehalt": return c["id"] raise AssertionError("Gehalt-Kategorie fehlt") def test_budget_soll_ist_and_override(admin): # Starttag 1 -> aktueller Zeitraum = Kalendermonat, heutige Buchungen zählen. admin.put("/api/settings/budget_start_day", params={"value": "1"}) today = date.today() label = f"{today.year:04d}-{today.month:02d}" acc = admin.post("/api/accounts", json={"name": "Budgetkonto", "currency": "EUR"}).json() leb = _lebensmittel(admin) # Standard-Budget für Lebensmittel: 500,00 EUR. admin.put(f"/api/budget/template/category/{leb}", params={"planned_cents": 50000}) # Ausgabe 300,00 EUR heute. admin.post("/api/transactions", json={"type": "expense", "date": str(today), "account_id": acc["id"], "amount_cents": 30000, "category_id": leb}) v = admin.get("/api/budget/current").json() row = next(c for g in v["groups"] for c in g["categories"] if c["category_id"] == leb) assert row["planned_cents"] == 50000 assert row["actual_cents"] == 30000 assert row["over"] is False assert v["customized"] is False # Noch 300 EUR -> insgesamt 600 > 500 -> über Budget. admin.post("/api/transactions", json={"type": "expense", "date": str(today), "account_id": acc["id"], "amount_cents": 30000, "category_id": leb}) v = admin.get(f"/api/budget/{label}").json() row = next(c for g in v["groups"] for c in g["categories"] if c["category_id"] == leb) assert row["actual_cents"] == 60000 assert row["over"] is True # Monats-Override auf 700 EUR -> nicht mehr über Budget, Monat individualisiert. v = admin.put(f"/api/budget/{label}/category/{leb}", params={"planned_cents": 70000}).json() row = next(c for g in v["groups"] for c in g["categories"] if c["category_id"] == leb) assert row["planned_cents"] == 70000 assert row["over"] is False assert v["customized"] is True # Vorlage bleibt unverändert. tpl = admin.get("/api/budget/template").json() tpl_leb = next(l for l in tpl["lines"] if l["category_id"] == leb) assert tpl_leb["planned_cents"] == 50000 # Zurücksetzen -> wieder Vorlagenwert, nicht mehr individualisiert. v = admin.delete(f"/api/budget/{label}").json() row = next(c for g in v["groups"] for c in g["categories"] if c["category_id"] == leb) assert row["planned_cents"] == 50000 assert v["customized"] is False def test_budget_copy_month_and_template(admin): admin.put("/api/settings/budget_start_day", params={"value": "1"}) today = date.today() label = f"{today.year:04d}-{today.month:02d}" leb = _lebensmittel(admin) admin.put(f"/api/budget/template/category/{leb}", params={"planned_cents": 50000}) # Monat individuell auf 700. admin.put(f"/api/budget/{label}/category/{leb}", params={"planned_cents": 70000}) # Monat -> Vorlage: Vorlage übernimmt den 700er Wert. r = admin.post(f"/api/budget/template/from-month/{label}") assert r.status_code == 200, r.text tpl = r.json() assert next(l for l in tpl["lines"] if l["category_id"] == leb)["planned_cents"] == 70000 # Vorlage auf 900 ändern, dann Vorlage -> Monat (überschreibt den angepassten Monat). admin.put(f"/api/budget/template/category/{leb}", params={"planned_cents": 90000}) v = admin.post(f"/api/budget/{label}/from-template").json() row = next(c for g in v["groups"] for c in g["categories"] if c["category_id"] == leb) assert row["planned_cents"] == 90000 assert v["customized"] is True def test_planned_income(admin): admin.put("/api/settings/budget_start_day", params={"value": "1"}) today = date.today() acc = admin.post("/api/accounts", json={"name": "Lohnkonto", "currency": "EUR"}).json() gehalt = _gehalt(admin) r = admin.post("/api/budget/income", json={ "label": "Gehalt", "category_id": gehalt, "target_account_id": acc["id"], "planned_cents": 300000, }) assert r.status_code == 201 # Tatsächliche Einnahme 3000 EUR. admin.post("/api/transactions", json={"type": "income", "date": str(today), "account_id": acc["id"], "amount_cents": 300000, "category_id": gehalt}) v = admin.get("/api/budget/current").json() line = next(i for i in v["income"] if i["label"] == "Gehalt") assert line["planned_cents"] == 300000 assert line["received_cents"] == 300000 assert v["totals"]["planned_income"] >= 300000 def test_jeder_tag_liegt_in_genau_seinem_budget_monat(): """period_of und period_bounds muessen dasselbe sagen. Der Starttag wird auf den letzten Tag des Monats gekappt – einen 31. Februar gibt es nicht. Ohne dieselbe Kappung auf beiden Seiten fiel der 28. Februar bei Starttag 31 in einen Monat, der ihn nicht enthaelt: Die Ausgabe tauchte in keiner Auswertung auf. """ from datetime import date, timedelta from app.services.budget import period_bounds, period_of fehler = [] for start_day in range(1, 32): tag = date(2024, 1, 1) # Schaltjahr eingeschlossen while tag < date(2028, 1, 1): von, bis = period_bounds(period_of(tag, start_day), start_day) if not von <= tag <= bis: fehler.append((start_day, tag.isoformat())) tag += timedelta(days=1) assert not fehler, f"Tage ausserhalb ihres Budget-Monats: {fehler[:5]}" def test_budget_monate_stossen_lueckenlos_aneinander(): """Kein Tag darf zwischen zwei Monate fallen und keiner in beide.""" from datetime import timedelta from app.services.budget import period_bounds, shift_label for start_day in (1, 15, 25, 28, 29, 31): label = "2026-01" for _ in range(24): _, bis = period_bounds(label, start_day) naechstes = shift_label(label, 1) von_naechstes, _ = period_bounds(naechstes, start_day) assert von_naechstes == bis + timedelta(days=1), ( f"Starttag {start_day}: {label} endet {bis}, " f"{naechstes} beginnt {von_naechstes}") label = naechstes def test_ausgabenanteile_nennen_das_geplante(admin): """Die Karte soll zeigen, ob ein Anteil noch im Rahmen liegt. Ohne Plan wird nichts behauptet – 'ueber Budget' waere sonst die Aussage ueber jede Kategorie, fuer die nie eine Vorgabe gemacht wurde.""" import datetime leb = _lebensmittel(admin) name = next(c["name"] for g in admin.get("/api/categories").json() for c in g["categories"] if c["id"] == leb) konto = admin.post("/api/accounts", json={ "name": "Anteile", "type": "girokonto", "currency": "EUR", "opening_balance_cents": 0}).json() admin.post("/api/transactions", json={ "type": "expense", "date": datetime.date.today().isoformat(), "account_id": konto["id"], "amount_cents": 60000, "category_id": leb}) # Ueber den MONATS-Wert, nicht die Vorlage: Ein vorhandener Monats-Wert # aus einem anderen Test uebersteuert die Vorlage sonst. label = admin.get("/api/budget/current").json()["label"] def plane(cents): r = admin.put(f"/api/budget/{label}/category/{leb}", params={"planned_cents": cents}) assert r.status_code == 200, r.text def zeile(): rows = admin.get("/api/dashboard/expense-share").json()["rows"] return next(r for r in rows if r["name"] == name) ist = zeile()["amount"] plane(0) assert zeile()["planned"] == 0 assert zeile()["over"] is False, "ohne Plan darf nichts als ueberschritten gelten" plane(ist + 10000) assert zeile()["over"] is False, "im Rahmen" plane(max(1, ist - 10000)) assert zeile()["over"] is True, "ueberschritten" # Und die Mitte des Ringdiagramms bekommt dieselbe Grundlage fuer das # Ganze – sonst faerbte sich die Legende, die Summe aber nicht. ganz = admin.get("/api/dashboard/expense-share").json() assert ganz["total_planned"] > 0 assert ganz["total_over"] == (ganz["total"] > ganz["total_planned"])