Files
certain/backend/tests/test_budget.py
GuidoandClaude Opus 4.8 8ed4bf5254 feat: Phase 2 – Budget-Engine + Direktzugriff (http://IP:Port)
Budget:
- Modelle + Migration 0003: budget_template_lines, planned_income_lines,
  budget_periods, budget_period_lines.
- services/budget: Perioden-Grenzen (einstellbarer Starttag, Label nach Endmonat),
  Standard-Vorlage vs. Monats-Override (Snapshot-on-Customize), Soll/Ist inkl.
  budgetierter Sparen-Transfers, geplante Einnahmen (Soll/Ist).
- routers/budgets: Vorlage, geplante Einnahmen (CRUD), Monats-Soll/Ist,
  Monats-Override, Zurücksetzen. pytest: Perioden, Soll/Ist, Override/Reset, Einnahmen.
- Frontend: Budget-Seite (Monat + Standard-Budget), Fortschrittsbalken grün/rot,
  Monats-Navigation, editierbare Soll-Beträge; Sparen-Option im Transfer-Formular.

Deployment-Fix (Nutzer-Anforderung: http://IP:Port muss funktionieren):
- Dienst bindet an APP_BIND_HOST=0.0.0.0 (direkt im LAN erreichbar).
- APP_COOKIE_SECURE=false als Standard (sonst kein Login über reines HTTP);
  für reinen HTTPS-Betrieb umstellbar. Doku + install.sh entsprechend angepasst.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 19:16:27 +02:00

105 lines
4.4 KiB
Python
Raw Permalink 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.
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_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