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>
105 lines
4.4 KiB
Python
105 lines
4.4 KiB
Python
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
|