Sparen zaehlt im Budget wie eine Ausgabe – zu Recht, das Geld ist diesen Monat gebunden. Es stand danach aber in Rot da, und wer tausend zuruecklegte statt achthundert, bekam eine Warnung fuer etwas Gutes. Ein Haken an der Kategorie stellt das ab. Zwei Folgen: Der Betrag steht neutral. NICHT gruen: Gruen heisst in Certain ueberall "Geld kommt rein", und CHF 1'010 in Gruen liest sich als Einnahme. Grau sagt genau das Richtige – kein Verlust, kein Gewinn. Und mehr als geplant ist keine Ueberschreitung mehr. Das gilt auch eine Ebene hoeher: Eine Gruppe misst sich an dem, was WIRKLICH zu viel war. Sonst faerbt sich "Finanzen" rot, weil jemand gespart hat, und die echte Ueberschreitung daneben faellt nicht mehr auf. Angezeigt werden weiter die vollen Summen; nur das Urteil laesst das Sparen aus. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
332 lines
14 KiB
Python
332 lines
14 KiB
Python
"""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)
|
||
|
||
|
||
# --- Eine Ausgabe, die kein Verlust ist --------------------------------------
|
||
|
||
|
||
def test_sparen_zaehlt_weiter_gilt_aber_nicht_als_ueberschreitung(admin):
|
||
"""Der Betrag bleibt im Budget – das Geld ist diesen Monat gebunden. Nur
|
||
die Warnung faellt weg: Wer mehr zuruecklegt als geplant, hat nichts
|
||
ueberzogen, und Rot waere schlicht falsch herum."""
|
||
admin.put("/api/settings/budget_start_day", params={"value": "1"})
|
||
sparen = _kategorie(admin, "Sparen & Investments")
|
||
admin.put(f"/api/budget/template/category/{sparen}", params={"planned_cents": 10000})
|
||
s = _sparen_szenario(admin, betrag=80000) # deutlich mehr als geplant
|
||
|
||
def zeile():
|
||
v = admin.get("/api/budget/current").json()
|
||
return next(c for g in v["groups"] for c in g["categories"]
|
||
if c["category_id"] == sparen)
|
||
|
||
ohne = zeile()
|
||
assert ohne["actual_cents"] >= 80000, "der Betrag muss weiter zaehlen"
|
||
assert ohne["over"] is True, "die Tatsache bleibt die Tatsache"
|
||
assert ohne["good"] is False
|
||
|
||
r = admin.patch(f"/api/categories/{sparen}", json={"spending_is_good": True})
|
||
assert r.status_code == 200, r.text
|
||
try:
|
||
mit = zeile()
|
||
assert mit["actual_cents"] == ohne["actual_cents"], "Betrag darf sich nicht aendern"
|
||
assert mit["good"] is True, "die Kategorie meldet sich nicht als 'kein Verlust'"
|
||
finally:
|
||
admin.patch(f"/api/categories/{sparen}", json={"spending_is_good": False})
|
||
admin.put(f"/api/budget/template/category/{sparen}", params={"planned_cents": 0})
|
||
|
||
|
||
def test_gruppe_gilt_nur_dann_als_kein_verlust_wenn_alle_es_sind(admin):
|
||
"""Sonst verschwindet eine echte Ueberschreitung hinter dem Sparen, das
|
||
zufaellig in derselben Gruppe liegt."""
|
||
sparen = _kategorie(admin, "Sparen & Investments")
|
||
r = admin.patch(f"/api/categories/{sparen}", json={"spending_is_good": True})
|
||
assert r.status_code == 200, r.text
|
||
try:
|
||
v = admin.get("/api/budget/current").json()
|
||
gruppe = next(g for g in v["groups"]
|
||
if any(c["category_id"] == sparen for c in g["categories"]))
|
||
andere = [c for c in gruppe["categories"] if c["category_id"] != sparen]
|
||
if andere:
|
||
assert gruppe["good"] is False, "eine gemischte Gruppe ist nicht 'kein Verlust'"
|
||
assert next(c for c in gruppe["categories"]
|
||
if c["category_id"] == sparen)["good"] is True
|
||
finally:
|
||
admin.patch(f"/api/categories/{sparen}", json={"spending_is_good": False})
|
||
|
||
|
||
def test_gruppe_wird_nicht_rot_weil_jemand_zu_viel_gespart_hat(admin):
|
||
"""Die Ueberschreitung einer Gruppe misst sich an dem, was wirklich zu viel
|
||
war. Sonst faerbt sich "Finanzen" rot, weil mehr zurueckgelegt wurde als
|
||
geplant – und die echte Ueberschreitung daneben faellt nicht mehr auf."""
|
||
admin.put("/api/settings/budget_start_day", params={"value": "1"})
|
||
sparen = _kategorie(admin, "Sparen & Investments")
|
||
admin.put(f"/api/budget/template/category/{sparen}", params={"planned_cents": 10000})
|
||
admin.patch(f"/api/categories/{sparen}", json={"spending_is_good": True})
|
||
try:
|
||
_sparen_szenario(admin, betrag=90000) # weit ueber dem Plan
|
||
v = admin.get("/api/budget/current").json()
|
||
gruppe = next(g for g in v["groups"]
|
||
if any(c["category_id"] == sparen for c in g["categories"]))
|
||
zeile = next(c for c in gruppe["categories"] if c["category_id"] == sparen)
|
||
|
||
assert zeile["over"] is True, "die Tatsache bleibt stehen"
|
||
assert zeile["good"] is True
|
||
assert gruppe["actual_cents"] >= 90000, "die Summe zeigt weiter alles"
|
||
assert gruppe["over"] is False, "Gruppe rot wegen des Sparens"
|
||
finally:
|
||
admin.patch(f"/api/categories/{sparen}", json={"spending_is_good": False})
|
||
admin.put(f"/api/budget/template/category/{sparen}", params={"planned_cents": 0})
|