"""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