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")}