Files
GuidoandClaude Opus 5 4c61a02c53 fix: Eine berechnete Budget-Zeile ueberlebt jetzt das Kopieren
Beide Kopierwege uebernahmen nur planned_cents. Ein Eintrag der Betriebsart
"berechnet" fiel dabei still auf "fest" zurueck - mit der Zahl, die zufaellig
zuletzt in planned_cents stand. Der Bezug zu den Vertraegen war danach weg,
ohne dass irgendwo stand, dass er weg ist.

Vorlage -> Monat (copy_template_to_month) zieht jetzt nach, was
ensure_customized nebenan laengst richtig macht. Monat -> Vorlage
(copy_month_to_template) nimmt die Betriebsart mit, statt die gerade
herauskommende Zahl einzufrieren: Eine berechnete Zeile soll auch in der
Vorlage berechnet bleiben.

Dabei aufgefallen: Eine berechnete Zeile gehoert auch dann in die Vorlage,
wenn planned_cents null ist - ihre Zahl steht in den Vertraegen. Eine feste
Null bleibt dagegen draussen, sonst fuellte sich die Vorlage mit Nullzeilen
fuer jede je beruehrte Kategorie.

Das war Backend-Ergaenzung D und damit die letzte offene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 21:04:46 +02:00

465 lines
20 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_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
def _frische_kategorie(admin, name):
import secrets
gruppe = next(g for g in admin.get("/api/categories").json()
if g["kind"] == "expense")
return admin.post("/api/categories", json={
"group_id": gruppe["id"], "name": f"{name} {secrets.token_hex(3)}"}).json()["id"]
def test_berechnete_zeile_ueberlebt_den_weg_in_den_monat(admin):
"""Vorlage -> Monat darf die Betriebsart nicht verlieren.
Fiel die Zeile auf "fest" zurueck, stand als Soll die Zahl, die zufaellig
zuletzt in planned_cents lag - und der Bezug zu den Vertraegen war weg,
ohne dass irgendwo stand, dass er weg ist.
"""
today = date.today()
label = f"{today.year:04d}-{today.month:02d}"
kat = _frische_kategorie(admin, "Berechnet-Monat")
admin.put(f"/api/budget/template/category/{kat}",
params={"planned_cents": 0, "modus": "berechnet", "zuschlag_cents": 1500})
r = admin.post(f"/api/budget/{label}/from-template")
assert r.status_code == 200, r.text
zeile = next(c for g in r.json()["groups"] for c in g["categories"]
if c["category_id"] == kat)
assert zeile["modus"] == "berechnet"
assert zeile["zuschlag_cents"] == 1500
def test_berechnete_zeile_ueberlebt_den_weg_in_die_vorlage(admin):
"""Monat -> Vorlage ebenso: "diesen Monat als Standard festlegen" soll die
Betriebsart uebernehmen, nicht die gerade herauskommende Zahl einfrieren."""
today = date.today()
label = f"{today.year:04d}-{today.month:02d}"
kat = _frische_kategorie(admin, "Berechnet-Vorlage")
admin.put(f"/api/budget/{label}/category/{kat}",
params={"planned_cents": 0, "modus": "berechnet", "zuschlag_cents": 2500})
r = admin.post(f"/api/budget/template/from-month/{label}")
assert r.status_code == 200, r.text
zeile = next(l for l in r.json()["lines"] if l["category_id"] == kat)
assert zeile["modus"] == "berechnet"
assert zeile["zuschlag_cents"] == 2500
def test_eine_feste_null_landet_nicht_in_der_vorlage(admin):
"""Nichts Geplantes bleibt nichts Geplantes - sonst fuellte sich die
Vorlage mit Nullzeilen fuer jede je beruehrte Kategorie."""
today = date.today()
label = f"{today.year:04d}-{today.month:02d}"
kat = _frische_kategorie(admin, "Nullzeile")
admin.put(f"/api/budget/{label}/category/{kat}", params={"planned_cents": 0})
r = admin.post(f"/api/budget/template/from-month/{label}")
assert all(l["category_id"] != kat for l in r.json()["lines"])