Eine Zahl, die man nicht aufschluesseln kann, muss man glauben. Genau das war der Streitpunkt: Die Karte nannte fuer Lebensmittel einen Betrag, und es gab keine Moeglichkeit nachzusehen, welche Zahlungen darin einbezogen sind und welche nicht. Jetzt oeffnet ein Klick auf einen Anteil - in der Liste wie in der Legende des Kreises - die Buchungen dahinter: Datum, Empfaenger, Konto, Betrag. Die Liste kommt aus derselben Stelle, die auch die Summe bildet (`budget.expense_rows`), sie kann also gar nicht etwas anderes zeigen als das, was gezaehlt wurde. Ein Test haelt das fest. Steht eine Buchung darin, obwohl ihr Datum ausserhalb des Zeitraums liegt, traegt sie die Kalendermarke: Dann wurde ihr Budget-Monat von Hand gesetzt, und das ist der Grund. Nebenbei: Dialoge haengen jetzt am Seitenkoerper. Kacheln der Uebersicht werden per `transform` positioniert - damit war die Kachel der Bezugsrahmen fuer `position: fixed`, und der Dialog sass eingeklemmt darin statt ueber der Seite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
372 lines
16 KiB
Python
372 lines
16 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"]
|