From 5dbfd6abc4d3893e9a8f1791a3a3bf4cd89b8756 Mon Sep 17 00:00:00 2001 From: Guido Date: Fri, 21 Aug 2026 15:26:21 +0200 Subject: [PATCH] fix: Anfangssaldo gilt ab dem Stichtag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/routers/dashboard.py | 25 +++++++------ backend/app/services/balances.py | 55 +++++++++++++++++++---------- backend/tests/test_accounts.py | 60 ++++++++++++++++++++++++++++++++ web/src/locales/de.json | 3 +- web/src/locales/en.json | 3 +- web/src/pages/Accounts.jsx | 1 + 6 files changed, 116 insertions(+), 31 deletions(-) diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 19617e0..5c46878 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -250,17 +250,22 @@ def net_worth( opening_eur = sum(to_eur(a.opening_balance_cents, a.currency, a.opening_date or start) for a in accounts) + def zaehlt(acc: Account | None, on: date) -> bool: + """Buchungen bis zum Stichtag stecken schon im Anfangssaldo.""" + return acc is not None and (acc.opening_date is None or on > acc.opening_date) + def eur_effect(t: Transaction) -> int: - if t.type == TxnType.income: - return t.amount_eur_cents - if t.type == TxnType.expense: - return -t.amount_eur_cents - # Transfer: Netto-Effekt aufs Vermögen = EUR-Wert Zugang - EUR-Wert Abgang. - to_acc = acc_by_id.get(t.to_account_id) if t.to_account_id else None - if to_acc is None: - return 0 # Zielkonto archiviert/gelöscht: ignorieren - credited = t.to_amount_cents if t.to_amount_cents is not None else t.amount_cents - return to_eur(credited, to_acc.currency, t.date) - t.amount_eur_cents + # Jede Seite wird für sich geprüft: Der Stichtag des Quellkontos kann + # anders liegen als der des Zielkontos. + effekt = 0 + if zaehlt(acc_by_id.get(t.account_id), t.date): + effekt += t.amount_eur_cents if t.type == TxnType.income else -t.amount_eur_cents + if t.type == TxnType.transfer: + to_acc = acc_by_id.get(t.to_account_id) if t.to_account_id else None + if zaehlt(to_acc, t.date): + credited = t.to_amount_cents if t.to_amount_cents is not None else t.amount_cents + effekt += to_eur(credited, to_acc.currency, t.date) + return effekt txns = db.query(Transaction).filter( Transaction.owner_id == user.id, Transaction.account_id.in_(list(acc_by_id.keys()) or [0]) diff --git a/backend/app/services/balances.py b/backend/app/services/balances.py index 40784bf..8094f66 100644 --- a/backend/app/services/balances.py +++ b/backend/app/services/balances.py @@ -1,6 +1,7 @@ """Kontostände (in Kontowährung) und Umrechnung in die Hauptwährung. -Saldo = Anfangssaldo + Einnahmen - Ausgaben - Transfers-raus + Transfers-rein. +Saldo = Anfangssaldo + Einnahmen - Ausgaben - Transfers-raus + Transfers-rein, +gezählt ab dem Stichtag des Kontos (siehe ``counts_after_opening``). Transfers werden in der jeweiligen Kontowährung verrechnet (bei währungsüber- greifenden Transfers am Zielkonto der gutgeschriebene Betrag ``to_amount_cents``). """ @@ -18,28 +19,44 @@ def _sum(query) -> int: return int(query.scalar() or 0) +def counts_after_opening(account: Account): + """Bedingung, die Buchungen bis zum Stichtag ausschliesst. + + Der Anfangssaldo beschreibt den Stand *am Ende* des Stichtags – so steht er + auf jedem Auszug und im E-Banking. Buchungen bis einschliesslich dieses Tages + stecken also schon darin und dürfen nicht noch einmal zählen. Sie bleiben in + der Liste und in den Auswertungen, nur der Kontostand lässt sie aus. + Ohne Stichtag zählt alles – dann ist der Anfangssaldo der Stand von Beginn an. + """ + if account.opening_date is None: + return None + return Transaction.date > account.opening_date + + def account_balance_cents(db: Session, account: Account) -> int: - income = _sum( - db.query(func.sum(Transaction.amount_cents)).filter( - Transaction.account_id == account.id, Transaction.type == TxnType.income - ) + ab = counts_after_opening(account) + + def summe(spalte, *bedingungen) -> int: + query = db.query(func.sum(spalte)).filter(*bedingungen) + if ab is not None: + query = query.filter(ab) + return _sum(query) + + income = summe( + Transaction.amount_cents, + Transaction.account_id == account.id, Transaction.type == TxnType.income, ) - expense = _sum( - db.query(func.sum(Transaction.amount_cents)).filter( - Transaction.account_id == account.id, Transaction.type == TxnType.expense - ) + expense = summe( + Transaction.amount_cents, + Transaction.account_id == account.id, Transaction.type == TxnType.expense, ) - transfer_out = _sum( - db.query(func.sum(Transaction.amount_cents)).filter( - Transaction.account_id == account.id, Transaction.type == TxnType.transfer - ) + transfer_out = summe( + Transaction.amount_cents, + Transaction.account_id == account.id, Transaction.type == TxnType.transfer, ) - transfer_in = _sum( - db.query( - func.sum(func.coalesce(Transaction.to_amount_cents, Transaction.amount_cents)) - ).filter( - Transaction.to_account_id == account.id, Transaction.type == TxnType.transfer - ) + transfer_in = summe( + func.coalesce(Transaction.to_amount_cents, Transaction.amount_cents), + Transaction.to_account_id == account.id, Transaction.type == TxnType.transfer, ) return account.opening_balance_cents + income - expense - transfer_out + transfer_in diff --git a/backend/tests/test_accounts.py b/backend/tests/test_accounts.py index 4df286f..82044ba 100644 --- a/backend/tests/test_accounts.py +++ b/backend/tests/test_accounts.py @@ -102,3 +102,63 @@ def test_transfer_requires_other_account(admin): "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 diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 45782e8..5147c0c 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -165,7 +165,8 @@ "identShort": "Nr.", "total": "Gesamt", "empty": "Noch keine Konten. Lege dein erstes Konto an.", - "deleteConfirm": "Dieses Konto und alle zugehörigen Buchungen löschen?" + "deleteConfirm": "Dieses Konto und alle zugehörigen Buchungen löschen?", + "openingDateHint": "Stand am Ende dieses Tages. Buchungen bis dahin zählen nicht zum Kontostand, bleiben aber in Liste und Auswertungen. Ohne Stichtag zählt alles." }, "accountTypes": { "girokonto": "Girokonto", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index bbb9ac9..6478113 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -165,7 +165,8 @@ "identShort": "id(s)", "total": "Total", "empty": "No accounts yet. Create your first account.", - "deleteConfirm": "Delete this account and all its transactions?" + "deleteConfirm": "Delete this account and all its transactions?", + "openingDateHint": "Balance at the end of that day. Bookings up to then don't count toward the balance but stay in the list and reports. Without a date, everything counts." }, "accountTypes": { "girokonto": "Checking", diff --git a/web/src/pages/Accounts.jsx b/web/src/pages/Accounts.jsx index e5dbd9b..cf820ec 100644 --- a/web/src/pages/Accounts.jsx +++ b/web/src/pages/Accounts.jsx @@ -299,6 +299,7 @@ export default function Accounts() { onChange={(e) => setForm({ ...form, opening_date: e.target.value })} /> +

{t("accounts.openingDateHint")}