feat: Abos & Vertraege – was regelmaessig abgeht, an einer Stelle

Netflix, Handyvertrag, Krankenkasse, Halbtax. Bisher sah man sie nur als
einzelne Buchungen im Auszug; die Frage "welche Abos habe ich eigentlich, und
was kosten die zusammen im Monat" liess sich nicht beantworten. Genau die
steht jetzt oben auf der Seite.

Der Bereich heisst "Abos & Vertraege", nicht "Abos": Ein Handyvertrag ist kein
Abo und die Versicherung erst recht nicht – niemand soll ueberlegen muessen,
wo sie hingehoert.

Der Monatswert wird gerechnet, nicht gespeichert (Betrag durch Intervall).
Gespeichert veraltete er beim naechsten Preisaufschlag stillschweigend.

Bei laengerem Intervall klappt "Ich lege monatlich zurueck auf" auf, mit dem
gerechneten Monatsbetrag daneben. Bei monatlicher Abrechnung gibt es nichts
anzusparen, also steht es da auch nicht.

DAZU: Waehrungen sind offen.

Die Rechen-Maschinerie konnte immer schon beliebige Waehrungen – sie rechnet
ueber den Euro als Drehpunkt. Beschraenkt haben es zwei Konstanten: eine
Erlaubnisliste mit EUR und CHF, und ein Filter, der aus dem EZB-Feed alles
ausser CHF wegwarf, obwohl er dreissig Waehrungen liefert.

Massgeblich ist jetzt die Liste der EZB. Frei erfundene Waehrungen bleiben
draussen, und das ist kein Geiz: Ohne Kurs liesse sich der Betrag nie
umrechnen – er stuende in keiner Summe und in keinem Budget, und das faellt
erst spaet auf. Ein Vertrag in einer Waehrung ohne Kurs wird deshalb genannt,
statt die Summe stillschweigend zu klein anzuzeigen.

Erkannt und gebucht wird noch nichts; der Erkennungstext wird nur erfasst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-07 15:45:59 +02:00
co-authored by Claude Opus 5
parent ff621984dc
commit f99be47587
18 changed files with 1297 additions and 10 deletions
+163
View File
@@ -0,0 +1,163 @@
"""Abos & Vertraege: verzeichnen und zusammenrechnen.
Der Nutzen dieser Stufe ist eine einzige Zahl – was die Vertraege zusammen im
Monat kosten. Alles andere haengt daran, dass sie stimmt: auch ueber
Waehrungen und ueber verschiedene Intervalle hinweg.
"""
import secrets
from datetime import date, timedelta
from conftest import login_ready, new_user
def _abo(admin, **kw):
body = {"name": "Netflix " + secrets.token_hex(3), "amount_cents": 1790,
"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()
def _liste(admin):
r = admin.get("/api/subscriptions")
assert r.status_code == 200, 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 test_monatswert_wird_aus_dem_intervall_gerechnet(admin):
"""Nicht gespeichert, sondern gerechnet – sonst veraltet er beim naechsten
Preisaufschlag stillschweigend."""
jahr = _abo(admin, amount_cents=40000, interval_months=12)
assert jahr["monatlich_cents"] == 3333
quartal = _abo(admin, amount_cents=3000, interval_months=3)
assert quartal["monatlich_cents"] == 1000
monat = _abo(admin, amount_cents=1790, interval_months=1)
assert monat["monatlich_cents"] == 1790
def test_preisaenderung_zieht_den_monatswert_mit(admin):
abo = _abo(admin, amount_cents=1790, interval_months=1)
r = admin.patch(f"/api/subscriptions/{abo['id']}", json={"amount_cents": 1990})
assert r.status_code == 200, r.text
assert r.json()["monatlich_cents"] == 1990
def test_summe_zaehlt_gekuendigte_nicht_mit(admin):
vorher = _liste(admin)["summary"]["monatlich_cents"]
abo = _abo(admin, amount_cents=2500, interval_months=1, currency="EUR")
assert _liste(admin)["summary"]["monatlich_cents"] == vorher + 2500
admin.patch(f"/api/subscriptions/{abo['id']}", json={"is_active": False})
assert _liste(admin)["summary"]["monatlich_cents"] == vorher
def test_fremde_waehrung_wird_umgerechnet(admin):
"""Ein deutscher Handyvertrag laeuft in Euro, auch wenn das Konto Franken
fuehrt. Ohne Umrechnung liessen sich die Zeilen nicht addieren."""
from app.database import SessionLocal
from app.models import ExchangeRate
with SessionLocal() as s:
if s.get(ExchangeRate, (date.today(), "CHF")) is None:
s.add(ExchangeRate(date=date.today(), currency="CHF",
rate_per_eur=0.95, source="test"))
s.commit()
abo = _abo(admin, amount_cents=1000, interval_months=1, currency="CHF")
# Hauptwaehrung ist EUR: 10.00 CHF bei 0.95 CHF je EUR sind 10.53 EUR.
assert abo["monatlich_base_cents"] == 1053
assert abo["monatlich_cents"] == 1000, "die eigene Waehrung bleibt unangetastet"
def test_waehrung_ohne_kurs_wird_gemeldet_statt_verschluckt(admin):
"""Lieber sagen, dass eine Zeile fehlt, als die Summe zu klein anzuzeigen."""
abo = _abo(admin, amount_cents=999, interval_months=1, currency="ZAR")
summary = _liste(admin)["summary"]
assert "ZAR" in summary["ohne_kurs"], summary
treffer = next(z for z in _liste(admin)["subscriptions"] if z["id"] == abo["id"])
assert treffer["monatlich_base_cents"] is None
def test_dollar_ist_jetzt_erlaubt(admin):
"""Manche Abos werden in Dollar abgerechnet. Frueher lehnte das Formular
alles ausser EUR und CHF ab."""
abo = _abo(admin, currency="USD", amount_cents=1200)
assert abo["currency"] == "USD"
def test_erfundene_waehrung_wird_abgelehnt(admin):
"""Ohne Kurs liesse sich der Betrag nie umrechnen – er stuende in keiner
Summe. Das faellt spaeter auf und ist dann schwer zu erklaeren."""
r = admin.post("/api/subscriptions", json={
"name": "Fantasie", "amount_cents": 100, "currency": "XYZ",
"interval_months": 1})
assert r.status_code == 422, r.text
def test_krummes_intervall_wird_abgelehnt(admin):
r = admin.post("/api/subscriptions", json={
"name": "Krumm", "amount_cents": 100, "currency": "EUR",
"interval_months": 37})
assert r.status_code == 422, r.text
def test_kategorie_muss_eine_ausgabe_sein(admin):
gehalt = _kategorie(admin, "Gehalt")
r = admin.post("/api/subscriptions", json={
"name": "Falsch", "amount_cents": 100, "currency": "EUR",
"interval_months": 1, "category_id": gehalt})
assert r.status_code == 400, r.text
assert "Ausgabe" in r.json()["detail"]
def test_erkennungstext_wird_aus_dem_namen_vorbelegt(admin):
abo = _abo(admin, name="Spotify Familie")
assert abo["payee_pattern"] == "Spotify Familie"
def test_faelligkeit_zaehlt_die_tage(admin):
abo = _abo(admin, next_due=str(date.today() + timedelta(days=9)))
assert abo["tage_bis_faellig"] == 9
ueberfaellig = _abo(admin, next_due=str(date.today() - timedelta(days=2)))
assert ueberfaellig["tage_bis_faellig"] == -2
def test_sparkonto_laesst_sich_wieder_loesen(admin):
"""Sonst wird man ein einmal gesetztes Pocket nie wieder los."""
konto = admin.post("/api/accounts", json={
"name": "Pocket " + secrets.token_hex(3), "type": "sparkonto",
"currency": "EUR", "opening_balance_cents": 0}).json()
abo = _abo(admin, interval_months=12, amount_cents=40000,
savings_account_id=konto["id"])
assert abo["savings_account_id"] == konto["id"]
r = admin.patch(f"/api/subscriptions/{abo['id']}", json={"savings_account_id": None})
assert r.status_code == 200, r.text
assert r.json()["savings_account_id"] is None
def test_fremdes_konto_wird_nicht_angenommen(admin):
"""Sonst schriebe ein Vertrag seine Zahlungen spaeter in ein Konto, das
einem anderen gehoert."""
fremd = admin.post("/api/accounts", json={
"name": "Fremd " + secrets.token_hex(3), "type": "girokonto",
"currency": "EUR", "opening_balance_cents": 0}).json()
username, password = new_user(admin)
anderer, _ = login_ready(username, password)
r = anderer.post("/api/subscriptions", json={
"name": "Fremdzugriff", "amount_cents": 100, "currency": "EUR",
"interval_months": 1, "account_id": fremd["id"]})
assert r.status_code == 404, r.text
+11 -2
View File
@@ -34,8 +34,17 @@ def test_starter_categories_present(admin):
def test_currency_validation(admin):
r = admin.post("/api/accounts", json={"name": "X", "currency": "USD"})
assert r.status_code == 422
"""Waehrungen mit EZB-Referenzkurs sind erlaubt, erfundene nicht.
Frueher standen hier nur EUR und CHF. Ohne Kurs laesst sich ein Betrag
aber nie umrechnen – er stuende in keiner Summe und in keinem Budget, und
genau daran haengt die Grenze, nicht an einer Vorliebe.
"""
r = admin.post("/api/accounts", json={"name": "Dollar", "currency": "USD"})
assert r.status_code == 201, r.text
r = admin.post("/api/accounts", json={"name": "Fantasie", "currency": "XYZ"})
assert r.status_code == 422, r.text
def test_identifiers_stored_and_deduped(admin):