Die Vertraege standen bisher nur auf ihrer eigenen Seite. Im Budget sah man
nichts davon – man musste selbst nachrechnen, was fest gebunden ist, und beim
naechsten Preisaufschlag noch einmal.
Jetzt hat jede Kategorie zwei Betriebsarten, umschaltbar ueber ein Sigma neben
dem Eingabefeld:
fest eine Zahl, die man hinschreibt. Wie bisher.
berechnet Summe der Vertraege dieser Kategorie plus einem Zuschlag.
Der Zuschlag ist der Punkt. Wer fuer Streaming 62 plant, weil 42 gebunden sind
und 20 Luft sein sollen, muss beim naechsten Preisaufschlag nicht nachrechnen
– und verliert seine 20 dabei auch nicht. Unter der Zeile steht die Rechnung
offen, sonst stuende im Feld eine Zahl, die nicht der Soll-Betrag ist.
Das gilt fuer JEDE Kategorie, nicht nur fuer eine Abo-Kategorie: Ein
Handyvertrag liegt in "Telefon & Internet", und die rechnet sich genauso.
Auch auf "fest" steht der Hinweis da ("davon 16.84 fest gebunden") – sonst
sieht man nie, was einen schon bindet.
Zwei Dinge, die sonst still danebengingen, melden sich jetzt oben auf der
Seite: Vertraege OHNE Kategorie (ihr Geld fehlt im Budget, ohne dass irgendwo
steht, dass es fehlt) und Waehrungen ohne Kurs.
Gerechnet wird in effective_planned – der einen Stelle, aus der Budget-Seite
UND Dashboard ihr Soll beziehen. Damit koennen die beiden gar nicht erst
auseinanderlaufen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
231 lines
8.6 KiB
Python
231 lines
8.6 KiB
Python
"""Der Soll-Betrag rechnet sich aus den Vertraegen – plus einem Zuschlag.
|
||
|
||
Der Zuschlag ist der Kern: Wer fuer Streaming 80 plant, weil 58 fest gebunden
|
||
sind und 22 Luft sein sollen, will beim naechsten Preisaufschlag nicht
|
||
nachrechnen muessen und seine 22 auch nicht verlieren.
|
||
"""
|
||
|
||
import secrets
|
||
from contextlib import contextmanager
|
||
from datetime import date
|
||
|
||
|
||
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 _abo(admin, **kw):
|
||
body = {"name": "Vertrag " + secrets.token_hex(3), "amount_cents": 1000,
|
||
"currency": "EUR", "interval_months": 1}
|
||
body.update(kw)
|
||
r = admin.post("/api/subscriptions", json=body)
|
||
assert r.status_code == 201, r.text
|
||
return r.json()
|
||
|
||
|
||
@contextmanager
|
||
def vertraege(admin, *bausteine):
|
||
"""Legt Vertraege an und raeumt sie hinterher wieder weg.
|
||
|
||
Die Testdatenbank lebt ueber den ganzen Lauf, und die Summe je Kategorie
|
||
geht ueber ALLE Vertraege des Nutzers – ohne Aufraeumen zaehlt der naechste
|
||
Test die des vorigen mit.
|
||
"""
|
||
angelegt = [_abo(admin, **b) for b in bausteine]
|
||
try:
|
||
yield angelegt
|
||
finally:
|
||
for a in angelegt:
|
||
admin.delete("/api/subscriptions/" + str(a["id"]))
|
||
|
||
|
||
def _zeile(admin, kategorie_id):
|
||
v = admin.get("/api/budget/current").json()
|
||
return next(c for g in v["groups"] for c in g["categories"]
|
||
if c["category_id"] == kategorie_id)
|
||
|
||
|
||
def _setze(admin, kategorie_id, **params):
|
||
r = admin.put("/api/budget/template/category/" + str(kategorie_id), params=params)
|
||
assert r.status_code == 200, r.text
|
||
return r.json()
|
||
|
||
|
||
def _leere(admin, kategorie_id):
|
||
_setze(admin, kategorie_id, modus="fest", planned_cents=0)
|
||
|
||
|
||
def _nur_vorlage(admin):
|
||
"""Die Monats-Anpassung entfernen, damit die Vorlage wirkt.
|
||
|
||
Ohne das haengen diese Tests davon ab, ob ein frueherer Test den laufenden
|
||
Monat angepasst hat – der Override schlaegt die Vorlage, und alle Werte
|
||
stuenden auf null. Die Testdatenbank lebt ueber den ganzen Lauf.
|
||
"""
|
||
admin.put("/api/settings/budget_start_day", params={"value": "1"})
|
||
heute = date.today()
|
||
admin.delete(f"/api/budget/{heute.year:04d}-{heute.month:02d}")
|
||
|
||
|
||
def _preis(admin, abo_id, cents):
|
||
r = admin.patch("/api/subscriptions/" + str(abo_id), json={"amount_cents": cents})
|
||
assert r.status_code == 200, r.text
|
||
|
||
|
||
def test_fest_bleibt_wie_es_war(admin):
|
||
"""Der Normalfall aendert sich nicht: eine Zahl bleibt eine Zahl, auch
|
||
wenn in der Kategorie Vertraege liegen."""
|
||
_nur_vorlage(admin)
|
||
kat = _kategorie(admin, "Abos & Streaming")
|
||
_leere(admin, kat)
|
||
with vertraege(admin, {"amount_cents": 1990, "category_id": kat}):
|
||
_setze(admin, kat, planned_cents=8000)
|
||
try:
|
||
z = _zeile(admin, kat)
|
||
assert z["modus"] == "fest"
|
||
assert z["planned_cents"] == 8000
|
||
# Der Hinweis steht trotzdem da – sonst sieht man nie, was gebunden ist.
|
||
assert z["aus_vertraegen_cents"] == 1990
|
||
assert z["vertraege_anzahl"] == 1
|
||
finally:
|
||
_leere(admin, kat)
|
||
|
||
|
||
def test_berechnet_zaehlt_vertraege_und_zuschlag_zusammen(admin):
|
||
_nur_vorlage(admin)
|
||
kat = _kategorie(admin, "Abos & Streaming")
|
||
_leere(admin, kat)
|
||
with vertraege(admin, {"amount_cents": 1990, "category_id": kat},
|
||
{"amount_cents": 1200, "category_id": kat}):
|
||
_setze(admin, kat, modus="berechnet", zuschlag_cents=2000)
|
||
try:
|
||
z = _zeile(admin, kat)
|
||
assert z["aus_vertraegen_cents"] == 3190
|
||
assert z["zuschlag_cents"] == 2000
|
||
assert z["planned_cents"] == 5190, "Soll ist nicht Vertraege + Zuschlag"
|
||
finally:
|
||
_leere(admin, kat)
|
||
|
||
|
||
def test_preisaufschlag_zieht_das_budget_mit(admin):
|
||
"""Der Grund fuer das Ganze: nicht nachrechnen muessen – und den eigenen
|
||
Zuschlag dabei behalten."""
|
||
_nur_vorlage(admin)
|
||
kat = _kategorie(admin, "Abos & Streaming")
|
||
_leere(admin, kat)
|
||
with vertraege(admin, {"amount_cents": 1790, "category_id": kat}) as (abo,):
|
||
_setze(admin, kat, modus="berechnet", zuschlag_cents=2200)
|
||
try:
|
||
assert _zeile(admin, kat)["planned_cents"] == 1790 + 2200
|
||
|
||
_preis(admin, abo["id"], 1990)
|
||
z = _zeile(admin, kat)
|
||
assert z["planned_cents"] == 1990 + 2200, "Soll folgte dem Preis nicht"
|
||
assert z["zuschlag_cents"] == 2200, "der eigene Zuschlag ging verloren"
|
||
finally:
|
||
_leere(admin, kat)
|
||
|
||
|
||
def test_jahresvertrag_zaehlt_mit_seinem_monatswert(admin):
|
||
_nur_vorlage(admin)
|
||
kat = _kategorie(admin, "Mobilität")
|
||
_leere(admin, kat)
|
||
with vertraege(admin, {"amount_cents": 24000, "interval_months": 12,
|
||
"category_id": kat}):
|
||
_setze(admin, kat, modus="berechnet", zuschlag_cents=0)
|
||
try:
|
||
assert _zeile(admin, kat)["planned_cents"] == 2000, \
|
||
"240 im Jahr sind 20 im Monat"
|
||
finally:
|
||
_leere(admin, kat)
|
||
|
||
|
||
def test_gekuendigte_vertraege_zaehlen_nicht(admin):
|
||
_nur_vorlage(admin)
|
||
kat = _kategorie(admin, "Abos & Streaming")
|
||
_leere(admin, kat)
|
||
with vertraege(admin, {"amount_cents": 5000, "category_id": kat}) as (abo,):
|
||
_setze(admin, kat, modus="berechnet", zuschlag_cents=0)
|
||
try:
|
||
assert _zeile(admin, kat)["planned_cents"] == 5000
|
||
admin.patch("/api/subscriptions/" + str(abo["id"]),
|
||
json={"is_active": False})
|
||
assert _zeile(admin, kat)["planned_cents"] == 0
|
||
finally:
|
||
_leere(admin, kat)
|
||
|
||
|
||
def test_berechnet_mit_zuschlag_null_bleibt_bestehen(admin):
|
||
"""Eine Zeile auf "fest" mit 0 ist dasselbe wie "kein Budget" und wird
|
||
geloescht. Bei "berechnet" ist die Zeile SELBST die Aussage – sie darf
|
||
nicht verschwinden, nur weil der Zuschlag null ist."""
|
||
_nur_vorlage(admin)
|
||
kat = _kategorie(admin, "Gesundheit")
|
||
_leere(admin, kat)
|
||
_setze(admin, kat, modus="berechnet", zuschlag_cents=0)
|
||
try:
|
||
assert _zeile(admin, kat)["modus"] == "berechnet"
|
||
finally:
|
||
_leere(admin, kat)
|
||
|
||
|
||
def test_zurueckschalten_findet_den_alten_wert_wieder(admin):
|
||
_nur_vorlage(admin)
|
||
kat = _kategorie(admin, "Abos & Streaming")
|
||
_leere(admin, kat)
|
||
_setze(admin, kat, planned_cents=7700)
|
||
_setze(admin, kat, modus="berechnet", zuschlag_cents=500)
|
||
try:
|
||
_setze(admin, kat, modus="fest")
|
||
assert _zeile(admin, kat)["planned_cents"] == 7700, "alter Wert verloren"
|
||
finally:
|
||
_leere(admin, kat)
|
||
|
||
|
||
def test_angepasster_monat_erbt_die_betriebsart(admin):
|
||
"""Sonst friert er auf der Zahl ein, die zufaellig in planned_cents stand –
|
||
und der Preisaufschlag kaeme im angepassten Monat nicht mehr an."""
|
||
_nur_vorlage(admin)
|
||
kat = _kategorie(admin, "Abos & Streaming")
|
||
_leere(admin, kat)
|
||
heute = date.today()
|
||
label = f"{heute.year:04d}-{heute.month:02d}"
|
||
with vertraege(admin, {"amount_cents": 3000, "category_id": kat}) as (abo,):
|
||
_setze(admin, kat, modus="berechnet", zuschlag_cents=1000)
|
||
try:
|
||
# Irgendeine andere Kategorie anfassen – das macht den Monat
|
||
# "angepasst" und uebernimmt die Vorlage in den Monat.
|
||
andere = _kategorie(admin, "Kleidung")
|
||
r = admin.put("/api/budget/" + label + "/category/" + str(andere),
|
||
params={"planned_cents": 100})
|
||
assert r.status_code == 200, r.text
|
||
assert admin.get("/api/budget/current").json()["customized"] is True
|
||
|
||
z = _zeile(admin, kat)
|
||
assert z["modus"] == "berechnet", "Betriebsart beim Anpassen verloren"
|
||
_preis(admin, abo["id"], 4000)
|
||
assert _zeile(admin, kat)["planned_cents"] == 4000 + 1000
|
||
finally:
|
||
admin.delete("/api/budget/" + label)
|
||
_leere(admin, kat)
|
||
|
||
|
||
def test_vertraege_ohne_kategorie_werden_gemeldet(admin):
|
||
"""Ihr Geld fehlt sonst im Budget, ohne dass irgendwo steht, dass es fehlt."""
|
||
vorher = admin.get("/api/budget/current").json()["vertraege"]["ohne_kategorie_cents"]
|
||
with vertraege(admin, {"amount_cents": 2590, "category_id": None}):
|
||
v = admin.get("/api/budget/current").json()["vertraege"]
|
||
assert v["ohne_kategorie_cents"] == vorher + 2590
|
||
assert v["ohne_kategorie_anzahl"] >= 1
|
||
|
||
|
||
def test_unbekannte_betriebsart_wird_abgelehnt(admin):
|
||
kat = _kategorie(admin, "Gesundheit")
|
||
r = admin.put("/api/budget/template/category/" + str(kat),
|
||
params={"modus": "irgendwas"})
|
||
assert r.status_code == 400, r.text
|