Files
ScarriffleandClaude Opus 5 f99be47587 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>
2026-09-07 15:45:59 +02:00

312 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from datetime import date, timedelta
from decimal import Decimal
def _seed_recent_rates(rate: str = "0.95") -> None:
from app.database import SessionLocal
from app.models import ExchangeRate
db = SessionLocal()
try:
today = date.today()
for delta in range(0, 7):
day = today - timedelta(days=delta)
if db.get(ExchangeRate, (day, "CHF")) is None:
db.add(ExchangeRate(date=day, currency="CHF", rate_per_eur=Decimal(rate)))
db.commit()
finally:
db.close()
def _mk_account(admin, **kw):
body = {"name": "A", "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 test_starter_categories_present(admin):
groups = admin.get("/api/categories").json()
assert len(groups) >= 6
names = {c["name"] for g in groups for c in g["categories"]}
assert {"Gehalt", "Lebensmittel", "Sparen & Investments"} <= names
def test_currency_validation(admin):
"""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):
acc = _mk_account(
admin,
name="Mit IBAN",
identifiers=[
{"kind": "iban", "value": "DE12 3456"},
{"kind": "iban", "value": "de123456"}, # normiert identisch -> dedupliziert
{"kind": "card_no", "value": "1234"},
],
)
kinds = sorted((i["kind"], i["value"]) for i in acc["identifiers"])
assert len(acc["identifiers"]) == 2
assert ("card_no", "1234") in kinds
def test_update_account_keeps_identifier(admin):
"""Umbenennen mit unverändertem Identifikator darf nicht an der UNIQUE-
Bedingung scheitern (Regression: früheres clear()+neu-Anlegen)."""
acc = _mk_account(admin, name="Revolut CHF",
identifiers=[{"kind": "text", "value": "Revolut**5064*"}])
r = admin.patch(f"/api/accounts/{acc['id']}", json={
"name": "Revolut CHF neu",
"identifiers": [{"kind": "text", "value": "Revolut**5064*"}],
})
assert r.status_code == 200, r.text
assert r.json()["name"] == "Revolut CHF neu"
idents = r.json()["identifiers"]
assert len(idents) == 1 and idents[0]["value"] == "Revolut**5064*"
# Identifikator entfernen klappt ebenfalls.
r2 = admin.patch(f"/api/accounts/{acc['id']}", json={"name": "Ohne", "identifiers": []})
assert r2.status_code == 200, r2.text
assert r2.json()["identifiers"] == []
def test_balance_and_cross_currency_transfer(admin):
_seed_recent_rates("0.95")
d = date.today()
eur = _mk_account(admin, name="Giro", currency="EUR", opening_balance_cents=100000)
chf = _mk_account(admin, name="Spar", type="sparkonto", currency="CHF", opening_balance_cents=0)
admin.post("/api/transactions", json={"type": "income", "date": str(d), "account_id": eur["id"], "amount_cents": 50000})
admin.post("/api/transactions", json={"type": "expense", "date": str(d), "account_id": eur["id"], "amount_cents": 20000})
assert admin.get(f"/api/accounts/{eur['id']}").json()["balance_cents"] == 130000
# Transfer 100 EUR -> CHF (0.95) = 95 CHF gutgeschrieben.
r = admin.post("/api/transactions", json={
"type": "transfer", "date": str(d), "account_id": eur["id"],
"to_account_id": chf["id"], "amount_cents": 10000,
})
assert r.status_code == 201, r.text
assert r.json()["to_amount_cents"] == 9500
assert admin.get(f"/api/accounts/{eur['id']}").json()["balance_cents"] == 120000
assert admin.get(f"/api/accounts/{chf['id']}").json()["balance_cents"] == 9500
def test_transfer_requires_other_account(admin):
eur = _mk_account(admin, name="Solo", currency="EUR")
d = date.today()
r = admin.post("/api/transactions", json={
"type": "transfer", "date": str(d), "account_id": eur["id"],
"to_account_id": eur["id"], "amount_cents": 100,
})
assert r.status_code == 400
def test_stichtag_excludes_earlier_bookings(admin):
"""Der Anfangssaldo gilt am Ende des Stichtags.
Buchungen bis einschliesslich dieses Tages stecken schon darin und dürfen den
Kontostand nicht ein zweites Mal bewegen; erst spätere zählen. Ohne die
Datumsprüfung addierte die Berechnung jede Buchung des Kontos – der Stichtag
war reine Dekoration und Salden liefen um die Vorgeschichte daneben.
"""
stichtag = date(2026, 3, 31)
acc = _mk_account(admin, name="Lohn", currency="EUR",
opening_balance_cents=20000, opening_date=str(stichtag))
def saldo() -> int:
return admin.get(f"/api/accounts/{acc['id']}").json()["balance_cents"]
# Vor dem Stichtag: bleibt buchbar, ändert den Saldo aber nicht.
admin.post("/api/transactions", json={"type": "expense", "date": "2026-02-10",
"account_id": acc["id"], "amount_cents": 7000})
assert saldo() == 20000
# Am Stichtag selbst: ebenfalls schon im Anfangssaldo enthalten.
admin.post("/api/transactions", json={"type": "income", "date": str(stichtag),
"account_id": acc["id"], "amount_cents": 5000})
assert saldo() == 20000
# Erst der Tag danach bewegt den Saldo.
admin.post("/api/transactions", json={"type": "income", "date": "2026-04-01",
"account_id": acc["id"], "amount_cents": 1500})
assert saldo() == 21500
# Die alten Buchungen sind weiterhin da – nur der Saldo lässt sie aus.
liste = admin.get("/api/transactions", params={"account_id": acc["id"]}).json()
daten = {t["date"] for t in (liste["items"] if isinstance(liste, dict) else liste)}
assert {"2026-02-10", "2026-03-31", "2026-04-01"} <= daten
def test_stichtag_gilt_je_konto_beim_transfer(admin):
"""Beim Übertrag wird jede Seite an ihrem eigenen Stichtag gemessen."""
von = _mk_account(admin, name="Von", currency="EUR",
opening_balance_cents=50000, opening_date="2026-03-31")
nach = _mk_account(admin, name="Nach", currency="EUR",
opening_balance_cents=1000, opening_date="2026-01-31")
# Liegt vor dem Stichtag von "Von", aber nach dem von "Nach".
r = admin.post("/api/transactions", json={
"type": "transfer", "date": "2026-02-15", "account_id": von["id"],
"to_account_id": nach["id"], "amount_cents": 30000,
})
assert r.status_code == 201, r.text
assert admin.get(f"/api/accounts/{von['id']}").json()["balance_cents"] == 50000
assert admin.get(f"/api/accounts/{nach['id']}").json()["balance_cents"] == 31000
def test_ohne_stichtag_zaehlt_alles(admin):
acc = _mk_account(admin, name="Ohne", currency="EUR", opening_balance_cents=10000)
admin.post("/api/transactions", json={"type": "expense", "date": "2020-01-01",
"account_id": acc["id"], "amount_cents": 2500})
assert admin.get(f"/api/accounts/{acc['id']}").json()["balance_cents"] == 7500
def test_konto_loeschen_laesst_fremden_saldo_unberuehrt(admin):
"""Ein gelöschtes Konto darf den Saldo eines anderen nicht verändern.
``to_account_id`` hängt an ON DELETE CASCADE. Wurde das Zielkonto gelöscht,
verschwand die ganze Buchung – auch die Abgangsseite, die dem Quellkonto
gehört. Dessen Saldo stieg dadurch stillschweigend um den Übertrag.
"""
quelle = _mk_account(admin, name="Quelle", currency="CHF", opening_balance_cents=100000)
ziel = _mk_account(admin, name="Depot", currency="CHF")
r = admin.post("/api/transactions", json={
"type": "transfer", "date": "2026-03-01", "account_id": quelle["id"],
"to_account_id": ziel["id"], "amount_cents": 30000,
})
assert r.status_code == 201, r.text
assert admin.get(f"/api/accounts/{quelle['id']}").json()["balance_cents"] == 70000
assert admin.delete(f"/api/accounts/{ziel['id']}").status_code == 204
# Das Geld ist abgeflossen und bleibt abgeflossen.
assert admin.get(f"/api/accounts/{quelle['id']}").json()["balance_cents"] == 70000
verbleibend = [t for t in admin.get("/api/transactions").json()
if t["account_id"] == quelle["id"] and t["date"] == "2026-03-01"]
assert len(verbleibend) == 1
assert verbleibend[0]["type"] == "expense"
def test_konto_loeschen_erhaelt_zugang_beim_zielkonto(admin):
"""Umgekehrt: War das gelöschte Konto die Quelle, bleibt der Zugang beim Ziel."""
quelle = _mk_account(admin, name="Weg", currency="CHF", opening_balance_cents=50000)
ziel = _mk_account(admin, name="Bleibt", currency="CHF")
admin.post("/api/transactions", json={
"type": "transfer", "date": "2026-03-02", "account_id": quelle["id"],
"to_account_id": ziel["id"], "amount_cents": 20000,
})
assert admin.get(f"/api/accounts/{ziel['id']}").json()["balance_cents"] == 20000
assert admin.delete(f"/api/accounts/{quelle['id']}").status_code == 204
assert admin.get(f"/api/accounts/{ziel['id']}").json()["balance_cents"] == 20000
def test_kontenreihenfolge_laesst_sich_setzen(admin):
"""Die Liste stand in der Anlegereihenfolge; die will man selbst bestimmen."""
a = _mk_account(admin, name="Zuerst angelegt")
b = _mk_account(admin, name="Danach angelegt")
c = _mk_account(admin, name="Zuletzt angelegt")
namen = lambda: [x["name"] for x in admin.get("/api/accounts").json()
if x["name"].endswith("angelegt")]
assert namen() == ["Zuerst angelegt", "Danach angelegt", "Zuletzt angelegt"]
r = admin.put("/api/accounts/order", json={"ids": [c["id"], a["id"], b["id"]]})
assert r.status_code == 204, r.text
assert namen() == ["Zuletzt angelegt", "Zuerst angelegt", "Danach angelegt"]
def test_kontenreihenfolge_nimmt_keine_fremden_konten(admin, client):
"""Fremde Konto-IDs in der Liste dürfen nichts verschieben."""
eigen = _mk_account(admin, name="Eigenes Konto")
r = admin.put("/api/accounts/order", json={"ids": [eigen["id"], 999999]})
assert r.status_code == 404
def test_saldo_laesst_sich_aufschluesseln(admin):
"""Stimmt ein Kontostand nicht, liegt es fast immer an einer dieser Zahlen
oder an einem Uebertrag. Ohne diese Ansicht sucht man in einer langen
Buchungsliste."""
import secrets
a = admin.post("/api/accounts", json={
"name": f"Aufschluss-A {secrets.token_hex(3)}", "type": "girokonto",
"currency": "CHF", "opening_balance_cents": 100000}).json()
b = admin.post("/api/accounts", json={
"name": f"Aufschluss-B {secrets.token_hex(3)}", "type": "girokonto",
"currency": "CHF", "opening_balance_cents": 0}).json()
admin.post("/api/transactions", json={
"type": "income", "date": "2026-08-01", "account_id": a["id"],
"amount_cents": 50000, "payee": "Lohn"})
admin.post("/api/transactions", json={
"type": "expense", "date": "2026-08-02", "account_id": a["id"],
"amount_cents": 20000, "payee": "Miete"})
admin.post("/api/transactions", json={
"type": "transfer", "date": "2026-08-03", "account_id": a["id"],
"to_account_id": b["id"], "amount_cents": 30000})
r = admin.get(f"/api/accounts/{a['id']}/aufschluesselung")
assert r.status_code == 200, r.text
teile = r.json()["teile"]
assert teile["anfangsbestand"]["cents"] == 100000
assert teile["einnahmen"] == {"cents": 50000, "anzahl": 1}
assert teile["ausgaben"] == {"cents": 20000, "anzahl": 1}
assert teile["uebertraege_raus"] == {"cents": 30000, "anzahl": 1}
assert teile["uebertraege_rein"] == {"cents": 0, "anzahl": 0}
assert teile["saldo"]["cents"] == 100000 + 50000 - 20000 - 30000
# Und die Rechnung muss dasselbe sagen wie die Kontenliste.
assert admin.get(f"/api/accounts/{a['id']}").json()["balance_cents"] == \
teile["saldo"]["cents"]
# Das Uebertrags-Bein steht einzeln da – dort sucht man zuerst.
beine = r.json()["uebertraege"]
assert len(beine) == 1 and beine[0]["raus"] is True
assert beine[0]["cents"] == 30000 and beine[0]["nach"] == b["name"]
# Auf der Gegenseite dasselbe Bein, nur andersherum.
gegen = admin.get(f"/api/accounts/{b['id']}/aufschluesselung").json()
assert gegen["teile"]["uebertraege_rein"] == {"cents": 30000, "anzahl": 1}
assert gegen["uebertraege"][0]["raus"] is False
def test_aufschluesselung_zeigt_was_der_stichtag_ausschliesst(admin):
"""Buchungen bis zum Stichtag stehen in der Liste, zaehlen aber nicht in
den Kontostand – sie stecken schon im Anfangsbestand. In der Oberflaeche
war das unsichtbar, und der Unterschied zwischen Gesehenem und Gerechnetem
nicht zu erklaeren."""
import secrets
konto = admin.post("/api/accounts", json={
"name": f"Stichtag {secrets.token_hex(3)}", "type": "girokonto",
"currency": "CHF", "opening_balance_cents": 120000,
"opening_date": "2026-08-15"}).json()
# Vor dem Stichtag – steckt schon im Anfangsbestand.
admin.post("/api/transactions", json={
"type": "expense", "date": "2026-08-10", "account_id": konto["id"],
"amount_cents": 150000, "payee": "Alt"})
# Danach – zaehlt.
admin.post("/api/transactions", json={
"type": "expense", "date": "2026-08-20", "account_id": konto["id"],
"amount_cents": 30000, "payee": "Neu"})
teile = admin.get(f"/api/accounts/{konto['id']}/aufschluesselung").json()["teile"]
assert teile["stichtag"] == "2026-08-15"
assert teile["vor_stichtag"] == {"cents": -150000, "anzahl": 1}
assert teile["ausgaben"] == {"cents": 30000, "anzahl": 1}, "nur die nach dem Stichtag"
assert teile["saldo"]["cents"] == 120000 - 30000
# Und die Kontenliste sagt dasselbe.
assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == 90000