Buchungen bis einschliesslich des Stichtags zaehlen nicht in den Kontostand – sie stecken schon im Anfangsbestand. In der Liste stehen sie aber, und in den Auswertungen zaehlen sie mit. Das ist richtig so, war in der Oberflaeche aber nirgends zu sehen: Der Unterschied zwischen dem, was man sieht, und dem, was gerechnet wird, liess sich nicht erklaeren. Ein Konto, dessen Stichtag nach dem Beginn des eingelesenen Auszugs liegt, zeigt deshalb einen Kontostand, der zu den sichtbaren Buchungen nicht passt – und niemand kaeme darauf, warum. Die Rechnung nennt jetzt Stichtag, Anzahl und Summe der ausgeschlossenen Buchungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
303 lines
14 KiB
Python
303 lines
14 KiB
Python
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):
|
||
r = admin.post("/api/accounts", json={"name": "X", "currency": "USD"})
|
||
assert r.status_code == 422
|
||
|
||
|
||
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
|