Files
certain/backend/tests/test_sparziel.py
T
ScarriffleandClaude Opus 5 b69c383a72 fix: "Nicht in Auswertungen einrechnen" galt fuer Sparen nicht
Wer den Haken an einer Kategorie setzt, sagt damit ausdruecklich: nicht ins
Budget, nicht ins Dashboard, nicht in die Statistik. Die Sparen-Auswertung
las ihn nicht – das Geld stand danach im Ringdiagramm, obwohl der Nutzer
genau das abbestellt hatte.

Beide Uebertrags-Wege halten sich jetzt daran, der am Stueck wie der aus zwei
verknuepften Buchungen. Der Haken an der Kategorie gewinnt; ein Sparziel am
Konto darf ihn nicht stillschweigend aushebeln.

Und weil beides zusammen keinen Sinn ergibt, sagt es das Konto-Formular jetzt
auch: Waehlt man dort eine Kategorie mit Haken, steht darunter, dass so nichts
im Budget erscheinen wird.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 14:13:22 +02:00

256 lines
10 KiB
Python
Raw 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.
"""Ein Uebertrag auf ein Spar-Konto zaehlt im Budget als Sparen.
Der Anlass ist ein Revolut-Pocket: Bei der Bank ist es ein ganz normales
Konto, sein Uebertrag kommt als ZWEI verknuepfte Buchungen herein (Abgang
hier, Zugang dort), und beide uebersprang die Auswertung. Wer 200 auf sein
Sparkonto legt, hat sie in dem Monat aber nicht mehr zum Ausgeben – genau das
soll ein Budget zeigen.
Gesagt wird es EINMAL am Konto, nicht an jeder Buchung.
"""
from datetime import date
def _konto(admin, name, **kw):
body = {"name": name, "type": "girokonto", "currency": "EUR",
"opening_balance_cents": 0}
body.update(kw)
r = admin.post("/api/accounts", json=body)
assert r.status_code == 201, r.text
return r.json()
def _kategorie(admin, name):
for g in admin.get("/api/categories").json():
for c in g["categories"]:
if c["name"] == name:
return c["id"]
raise AssertionError(f"Kategorie '{name}' fehlt")
def _buchung(admin, konto, typ, betrag, datum, payee=""):
r = admin.post("/api/transactions", json={
"type": typ, "date": datum, "account_id": konto["id"],
"amount_cents": betrag, "payee": payee})
assert r.status_code == 201, r.text
return r.json()
def _ist(admin, kategorie_id):
"""Ist-Wert dieser Kategorie im laufenden Budget-Monat."""
v = admin.get("/api/budget/current").json()
for g in v["groups"]:
for c in g["categories"]:
if c["category_id"] == kategorie_id:
return c["actual_cents"]
raise AssertionError("Kategorie steht nicht im Budget")
def _verknuepfen(admin, abgang, zugang):
r = admin.post("/api/uebertraege/verbinden",
json={"a_id": abgang["id"], "b_id": zugang["id"]})
assert r.status_code == 200, r.text
return r.json()
def _sparen_szenario(admin, betrag=20000):
"""Giro -> Pocket als verknuepftes Paar, Pocket mit Spar-Kategorie."""
admin.put("/api/settings/budget_start_day", params={"value": "1"})
heute = str(date.today())
sparen = _kategorie(admin, "Sparen & Investments")
giro = _konto(admin, "Giro fuer Sparen")
pocket = _konto(admin, "Revolut Pocket", savings_category_id=sparen)
abgang = _buchung(admin, giro, "expense", betrag, heute, payee="Sparen")
zugang = _buchung(admin, pocket, "income", betrag, heute, payee="Sparen")
_verknuepfen(admin, abgang, zugang)
return {"sparen": sparen, "giro": giro, "pocket": pocket,
"abgang": abgang, "zugang": zugang}
def test_verknuepfter_uebertrag_auf_spar_konto_zaehlt_als_sparen(admin):
vorher = _ist(admin, _kategorie(admin, "Sparen & Investments"))
s = _sparen_szenario(admin)
assert _ist(admin, s["sparen"]) == vorher + 20000
def test_ohne_spar_kategorie_am_konto_bleibt_der_uebertrag_neutral(admin):
"""Der Normalfall aendert sich NICHT. Ein Uebertrag aufs Tagesgeld, das
niemand als Sparziel benannt hat, ist weiterhin bloss verschobenes Geld."""
admin.put("/api/settings/budget_start_day", params={"value": "1"})
heute = str(date.today())
sparen = _kategorie(admin, "Sparen & Investments")
vorher = _ist(admin, sparen)
giro = _konto(admin, "Giro ohne Sparziel")
anderes = _konto(admin, "Zweitkonto ohne Sparziel")
abgang = _buchung(admin, giro, "expense", 15000, heute)
zugang = _buchung(admin, anderes, "income", 15000, heute)
_verknuepfen(admin, abgang, zugang)
assert _ist(admin, sparen) == vorher
def test_der_abgang_zaehlt_genau_einmal(admin):
"""Traegt der Abgang schon eine eigene Kategorie, darf er nicht zweimal
gezaehlt werden – einmal dort und einmal als Sparen. Er gehoert zum
Sparen; seine alte Kategorie tritt zurueck."""
admin.put("/api/settings/budget_start_day", params={"value": "1"})
heute = str(date.today())
sparen = _kategorie(admin, "Sparen & Investments")
lebensmittel = _kategorie(admin, "Lebensmittel")
vor_sparen = _ist(admin, sparen)
vor_leb = _ist(admin, lebensmittel)
giro = _konto(admin, "Giro einmal zaehlen")
pocket = _konto(admin, "Pocket einmal zaehlen", savings_category_id=sparen)
r = admin.post("/api/transactions", json={
"type": "expense", "date": heute, "account_id": giro["id"],
"amount_cents": 7500, "category_id": lebensmittel})
assert r.status_code == 201, r.text
abgang = r.json()
# Solange sie unverknuepft ist, zaehlt sie ganz normal gegen ihre Kategorie.
assert _ist(admin, lebensmittel) == vor_leb + 7500
zugang = _buchung(admin, pocket, "income", 7500, heute)
_verknuepfen(admin, abgang, zugang)
assert _ist(admin, sparen) == vor_sparen + 7500, "nicht als Sparen gezaehlt"
assert _ist(admin, lebensmittel) == vor_leb, "zusaetzlich in der alten Kategorie geblieben"
def test_einzelne_buchung_laesst_sich_wieder_herausnehmen(admin):
"""Am Konto gesagt heisst nicht: fuer immer und fuer jede Buchung. Wer eine
einzelne Bewegung anders sieht, setzt sie auf neutral."""
s = _sparen_szenario(admin)
mit = _ist(admin, s["sparen"])
r = admin.patch(f"/api/transactions/{s['abgang']['id']}",
json={"budget_treatment": "neutral"})
assert r.status_code == 200, r.text
assert _ist(admin, s["sparen"]) == mit - 20000
def test_eigene_budget_kategorie_schlaegt_die_des_kontos(admin):
s = _sparen_szenario(admin)
andere = _kategorie(admin, "Lebensmittel")
vorher_andere = _ist(admin, andere)
mit = _ist(admin, s["sparen"])
r = admin.patch(f"/api/transactions/{s['abgang']['id']}",
json={"budget_category_id": andere})
assert r.status_code == 200, r.text
assert _ist(admin, s["sparen"]) == mit - 20000
assert _ist(admin, andere) == vorher_andere + 20000
def test_geloeste_verknuepfung_nimmt_das_sparen_zurueck(admin):
"""Die Verknuepfung ist ein Etikett. Zieht man es ab, ist der Abgang wieder
eine gewoehnliche Buchung – und zaehlt nicht mehr als Sparen."""
vorher = _ist(admin, _kategorie(admin, "Sparen & Investments"))
s = _sparen_szenario(admin)
assert _ist(admin, s["sparen"]) == vorher + 20000
link = next(l for l in admin.get("/api/uebertraege").json()
if l["from_txn_id"] == s["abgang"]["id"])
assert admin.delete(f"/api/uebertraege/{link['id']}").status_code == 204
assert _ist(admin, s["sparen"]) == vorher
def test_uebertrag_am_stueck_nimmt_die_kategorie_des_kontos(admin):
"""Die andere Form des Uebertrags – EINE Buchung mit Zielkonto, wie sie das
Formular anlegt. Auch sie soll das Sparziel des Kontos von allein finden,
ohne dass man die Kategorie an der Buchung wiederholt."""
admin.put("/api/settings/budget_start_day", params={"value": "1"})
heute = str(date.today())
sparen = _kategorie(admin, "Sparen & Investments")
vorher = _ist(admin, sparen)
giro = _konto(admin, "Giro am Stueck")
pocket = _konto(admin, "Pocket am Stueck", savings_category_id=sparen)
r = admin.post("/api/transactions", json={
"type": "transfer", "date": heute, "account_id": giro["id"],
"to_account_id": pocket["id"], "amount_cents": 5000})
assert r.status_code == 201, r.text
assert _ist(admin, sparen) == vorher + 5000
def test_sparziel_muss_eine_ausgabe_kategorie_sein(admin):
"""Gegen eine Einnahme-Kategorie liesse sich Sparen nicht verplanen."""
gehalt = _kategorie(admin, "Gehalt")
r = admin.post("/api/accounts", json={
"name": "Falsches Sparziel", "type": "sparkonto", "currency": "EUR",
"opening_balance_cents": 0, "savings_category_id": gehalt})
assert r.status_code == 400, r.text
assert "Ausgabe" in r.json()["detail"]
def test_sparziel_laesst_sich_wieder_loesen(admin):
sparen = _kategorie(admin, "Sparen & Investments")
konto = _konto(admin, "Sparziel loesbar", savings_category_id=sparen)
assert konto["savings_category_id"] == sparen
r = admin.patch(f"/api/accounts/{konto['id']}", json={"savings_category_id": None})
assert r.status_code == 200, r.text
assert r.json()["savings_category_id"] is None
def _neutral_setzen(admin, kategorie_id, wert=True):
r = admin.patch(f"/api/categories/{kategorie_id}",
json={"excluded_from_stats": wert})
assert r.status_code == 200, r.text
return r.json()
def test_nicht_in_auswertungen_schlaegt_das_sparziel(admin):
"""Der Haken an der Kategorie ist eine ausdrueckliche Ansage. Ein Sparziel
am Konto darf sie nicht stillschweigend aushebeln – sonst stuende das Geld
in Budget und Ringdiagramm, obwohl der Nutzer genau das abbestellt hat."""
sparen = _kategorie(admin, "Sparen & Investments")
try:
# Der Haken MUSS vor dem Ablesen sitzen: Er nimmt auch das Sparen der
# Nachbartests aus der Auswertung, und die Testdatenbank lebt ueber den
# ganzen Lauf.
_neutral_setzen(admin, sparen, True)
vorher = _ist(admin, sparen)
s = _sparen_szenario(admin)
assert _ist(admin, s["sparen"]) == vorher, "trotz Haken in der Auswertung"
finally:
_neutral_setzen(admin, sparen, False)
def test_ohne_haken_zaehlt_dasselbe_konto_wieder(admin):
"""Die Gegenprobe: Es liegt WIRKLICH am Haken und nicht daran, dass der
Uebertrag ohnehin nicht gefunden wurde."""
sparen = _kategorie(admin, "Sparen & Investments")
_neutral_setzen(admin, sparen, False)
ohne_haken = _ist(admin, sparen) # Stand vor dem Szenario, ohne Haken
_neutral_setzen(admin, sparen, True)
mit_haken = _ist(admin, sparen)
s = _sparen_szenario(admin, betrag=33300)
assert _ist(admin, s["sparen"]) == mit_haken, "mit Haken doch gezaehlt"
_neutral_setzen(admin, sparen, False)
assert _ist(admin, s["sparen"]) == ohne_haken + 33300, "ohne Haken nicht gezaehlt"
def test_uebertrag_am_stueck_haelt_sich_auch_daran(admin):
"""Dieselbe Regel fuer die andere Form des Uebertrags."""
admin.put("/api/settings/budget_start_day", params={"value": "1"})
sparen = _kategorie(admin, "Sparen & Investments")
try:
_neutral_setzen(admin, sparen, True)
vorher = _ist(admin, sparen)
giro = _konto(admin, "Giro Stueck neutral")
pocket = _konto(admin, "Pocket Stueck neutral", savings_category_id=sparen)
r = admin.post("/api/transactions", json={
"type": "transfer", "date": str(date.today()), "account_id": giro["id"],
"to_account_id": pocket["id"], "amount_cents": 9900})
assert r.status_code == 201, r.text
assert _ist(admin, sparen) == vorher
finally:
_neutral_setzen(admin, sparen, False)