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