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"]) def test_budgetzeitraum_von_hand_verschiebt_die_buchung(admin): """Eine Zahlung faellt sichtbar in den einen Monat und gehoert zum anderen. Der Ausnahmefall, fuer den es das Feld gibt: Die Buchung wandert vollstaendig – sie zaehlt im gewaehlten Monat und in ihrem eigenen dann NICHT mehr, sonst stuende sie zweimal da. """ admin.put("/api/settings/budget_start_day", params={"value": "1"}) today = date.today() hier = f"{today.year:04d}-{today.month:02d}" from app.services import budget as b dort = b.shift_label(hier, 1) acc = admin.post("/api/accounts", json={"name": "Zeitraumkonto", "currency": "EUR"}).json() leb = _lebensmittel(admin) vorher = _ist(admin, hier, leb) vorher_dort = _ist(admin, dort, leb) tx = admin.post("/api/transactions", json={ "type": "expense", "date": str(today), "account_id": acc["id"], "amount_cents": 12345, "category_id": leb}).json() assert tx["budget_period"] == hier assert tx["budget_period_manuell"] is False assert _ist(admin, hier, leb) == vorher + 12345 # In den naechsten Monat schieben. tx = admin.patch(f"/api/transactions/{tx['id']}", json={"budget_period": dort}).json() assert tx["budget_period"] == dort assert tx["budget_period_manuell"] is True assert _ist(admin, hier, leb) == vorher, "im eigenen Monat darf sie nicht mehr zaehlen" assert _ist(admin, dort, leb) == vorher_dort + 12345 # Zuruecknehmen -> wieder das Datum. tx = admin.patch(f"/api/transactions/{tx['id']}", json={"budget_period": None}).json() assert tx["budget_period"] == hier assert tx["budget_period_manuell"] is False assert _ist(admin, hier, leb) == vorher + 12345 def _ist(admin, label, cat_id): v = admin.get(f"/api/budget/{label}").json() for g in v["groups"]: for c in g["categories"]: if c["category_id"] == cat_id: return c["actual_cents"] return 0 def test_ein_abwegiger_budgetmonat_wird_abgelehnt(admin): """Ein Vertipper wie 2019-03 liesse die Buchung aus jeder Auswertung verschwinden, ohne dass irgendwo etwas fehlt. Das muss auffallen.""" admin.put("/api/settings/budget_start_day", params={"value": "1"}) today = date.today() acc = admin.post("/api/accounts", json={"name": "Abwegkonto", "currency": "EUR"}).json() tx = admin.post("/api/transactions", json={ "type": "expense", "date": str(today), "account_id": acc["id"], "amount_cents": 500, "category_id": _lebensmittel(admin)}).json() r = admin.patch(f"/api/transactions/{tx['id']}", json={"budget_period": "2019-03"}) assert r.status_code == 400 assert "2019-03" in r.json()["detail"] # Drei Monate in jede Richtung decken den echten Fall ab. from app.services import budget as b eigen = b.period_of(today, 1) assert admin.patch(f"/api/transactions/{tx['id']}", json={"budget_period": b.shift_label(eigen, 3)}).status_code == 200 assert admin.patch(f"/api/transactions/{tx['id']}", json={"budget_period": b.shift_label(eigen, 4)}).status_code == 400 def test_der_eigene_monat_wird_nicht_festgeschrieben(admin): """Waehlt man genau den Monat, der ohnehin gilt, bleibt die Buchung automatisch – verschiebt sich das Datum spaeter, wandert sie mit.""" admin.put("/api/settings/budget_start_day", params={"value": "25"}) from app.services import budget as b acc = admin.post("/api/accounts", json={"name": "Mitwanderkonto", "currency": "EUR"}).json() tx = admin.post("/api/transactions", json={ "type": "expense", "date": "2026-09-10", "account_id": acc["id"], "amount_cents": 700, "category_id": _lebensmittel(admin)}).json() assert tx["budget_period"] == "2026-09" tx = admin.patch(f"/api/transactions/{tx['id']}", json={"budget_period": "2026-09"}).json() assert tx["budget_period_manuell"] is False # Datum ueber den Stichtag hinaus -> der Monat wandert von selbst mit. tx = admin.patch(f"/api/transactions/{tx['id']}", json={"date": "2026-09-26"}).json() assert tx["budget_period"] == "2026-10" assert b.period_of(date(2026, 9, 26), 25) == "2026-10" admin.put("/api/settings/budget_start_day", params={"value": "1"}) def test_aufstellung_ergibt_genau_die_summe_der_karte(admin): """Die Aufstellung kommt aus derselben Stelle wie die Summe – sie kann also gar nicht etwas anderes zeigen als das, was gezaehlt wurde.""" import secrets admin.put("/api/settings/budget_start_day", params={"value": "1"}) today = date.today() acc = admin.post("/api/accounts", json={ "name": f"Aufstellung {secrets.token_hex(3)}", "currency": "EUR"}).json() leb = _lebensmittel(admin) betraege = [1234, 5678, 999] for c in betraege: admin.post("/api/transactions", json={ "type": "expense", "date": str(today), "account_id": acc["id"], "amount_cents": c, "category_id": leb, "payee": f"Laden {c}"}) karte = admin.get("/api/dashboard/expense-share?group_by=category").json() zeile = next(r for r in karte["rows"] if r["name"] == "Lebensmittel") assert zeile["ids"], "ohne die Kategorien laesst sich nichts aufschluesseln" ids = "&".join(f"ids={i}" for i in zeile["ids"]) auf = admin.get(f"/api/dashboard/expense-share/buchungen?{ids}").json() assert auf["total"] == zeile["amount"], "Aufstellung und Karte muessen dasselbe ergeben" assert sum(r["base_cents"] for r in auf["rows"]) == zeile["amount"] assert auf["von"] == karte["von"] and auf["bis"] == karte["bis"] # Unsere drei Buchungen stehen darin, jede genau einmal. unsere = [r for r in auf["rows"] if r["title"].startswith("Laden ")] assert sorted(r["amount_cents"] for r in unsere) == sorted(betraege) def test_aufstellung_zeigt_eine_verschobene_buchung_im_neuen_monat(admin): """Wandert eine Buchung, wandert sie auch in der Aufstellung mit.""" import secrets from app.services import budget as b admin.put("/api/settings/budget_start_day", params={"value": "1"}) today = date.today() hier = b.period_of(today, 1) acc = admin.post("/api/accounts", json={ "name": f"Wander {secrets.token_hex(3)}", "currency": "EUR"}).json() leb = _lebensmittel(admin) tx = admin.post("/api/transactions", json={ "type": "expense", "date": str(today), "account_id": acc["id"], "amount_cents": 4242, "category_id": leb, "payee": "Wanderladen"}).json() karte = admin.get("/api/dashboard/expense-share?group_by=category").json() ids = "&".join(f"ids={i}" for i in next(r for r in karte["rows"] if r["name"] == "Lebensmittel")["ids"]) assert any(r["id"] == tx["id"] for r in admin.get(f"/api/dashboard/expense-share/buchungen?{ids}").json()["rows"]) admin.patch(f"/api/transactions/{tx['id']}", json={"budget_period": b.shift_label(hier, 1)}) auf = admin.get(f"/api/dashboard/expense-share/buchungen?{ids}").json() assert not any(r["id"] == tx["id"] for r in auf["rows"]) karte2 = admin.get("/api/dashboard/expense-share?group_by=category").json() assert auf["total"] == next(r for r in karte2["rows"] if r["name"] == "Lebensmittel")["amount"]