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>
256 lines
10 KiB
Python
256 lines
10 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)
|