GET /api/budget/template gab je Zeile nur planned_cents heraus, obwohl das Modell modus und zuschlag_cents fuehrt und PUT sie zurueckgibt. Wer die Vorlage so liest, haelt eine berechnete Zeile fuer eine feste - und schreibt sie beim naechsten Speichern auch als feste zurueck. Die Weboberflaeche traf es nicht, weil sie die Betriebsart aus der Monatsansicht nimmt. Die iOS-App bietet beide Richtungen an und haette den Fehler sichtbar gemacht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
402 lines
17 KiB
Python
402 lines
17 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_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"]
|
||
|
||
|
||
def test_vorlage_nennt_betriebsart_und_zuschlag(admin):
|
||
"""Die Vorlage gab bisher nur planned_cents heraus.
|
||
|
||
Wer sie so liest, haelt eine berechnete Zeile fuer eine feste - und
|
||
schreibt sie beim naechsten Speichern auch als feste zurueck. PUT und die
|
||
Monatsansicht nennen beide Felder laengst; hier fehlten sie schlicht.
|
||
|
||
Eine eigene Kategorie statt Lebensmittel: Die Testdatenbank lebt ueber den
|
||
ganzen Lauf, und eine Vorlagenzeile auf "berechnet" umzustellen wuerde die
|
||
Nachbartests treffen.
|
||
"""
|
||
import secrets
|
||
|
||
gruppe = next(g for g in admin.get("/api/categories").json()
|
||
if g["kind"] == "expense")
|
||
kat = admin.post("/api/categories", json={
|
||
"group_id": gruppe["id"], "name": f"Vorlagenart {secrets.token_hex(3)}"}).json()
|
||
|
||
r = admin.put(f"/api/budget/template/category/{kat['id']}",
|
||
params={"planned_cents": 12000, "modus": "berechnet",
|
||
"zuschlag_cents": 2500})
|
||
assert r.status_code == 200, r.text
|
||
|
||
zeile = next(l for l in admin.get("/api/budget/template").json()["lines"]
|
||
if l["category_id"] == kat["id"])
|
||
assert zeile["planned_cents"] == 12000
|
||
assert zeile["modus"] == "berechnet"
|
||
assert zeile["zuschlag_cents"] == 2500
|