Der Stichtag eines Kontos wurde gespeichert und angezeigt, von der Saldo-Berechnung aber ignoriert: sie addierte jede Buchung des Kontos, auch die aus der Zeit vor dem Stichtag. Damit lief jeder Saldo um die gesamte Vorgeschichte daneben. Der Anfangssaldo beschreibt jetzt den Stand am ENDE des Stichtags – so steht er auf dem Auszug. Buchungen bis einschliesslich dieses Tages zaehlen nicht mehr zum Kontostand, bleiben aber in Liste und Auswertungen. Beim Uebertrag wird jede Seite an ihrem eigenen Stichtag gemessen; der Vermoegensverlauf folgt derselben Regel. Ohne Stichtag zaehlt weiterhin alles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
165 lines
7.0 KiB
Python
165 lines
7.0 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
|