fix: Anfangssaldo gilt ab dem Stichtag

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>
This commit is contained in:
Guido
2026-08-21 15:26:21 +02:00
co-authored by Claude Opus 5
parent d88efebfeb
commit 5dbfd6abc4
6 changed files with 116 additions and 31 deletions
+15 -10
View File
@@ -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) 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: def eur_effect(t: Transaction) -> int:
if t.type == TxnType.income: # Jede Seite wird für sich geprüft: Der Stichtag des Quellkontos kann
return t.amount_eur_cents # anders liegen als der des Zielkontos.
if t.type == TxnType.expense: effekt = 0
return -t.amount_eur_cents if zaehlt(acc_by_id.get(t.account_id), t.date):
# Transfer: Netto-Effekt aufs Vermögen = EUR-Wert Zugang - EUR-Wert Abgang. effekt += t.amount_eur_cents if t.type == TxnType.income else -t.amount_eur_cents
to_acc = acc_by_id.get(t.to_account_id) if t.to_account_id else None if t.type == TxnType.transfer:
if to_acc is None: to_acc = acc_by_id.get(t.to_account_id) if t.to_account_id else None
return 0 # Zielkonto archiviert/gelöscht: ignorieren if zaehlt(to_acc, t.date):
credited = t.to_amount_cents if t.to_amount_cents is not None else t.amount_cents 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 effekt += to_eur(credited, to_acc.currency, t.date)
return effekt
txns = db.query(Transaction).filter( txns = db.query(Transaction).filter(
Transaction.owner_id == user.id, Transaction.account_id.in_(list(acc_by_id.keys()) or [0]) Transaction.owner_id == user.id, Transaction.account_id.in_(list(acc_by_id.keys()) or [0])
+36 -19
View File
@@ -1,6 +1,7 @@
"""Kontostände (in Kontowährung) und Umrechnung in die Hauptwährung. """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- Transfers werden in der jeweiligen Kontowährung verrechnet (bei währungsüber-
greifenden Transfers am Zielkonto der gutgeschriebene Betrag ``to_amount_cents``). greifenden Transfers am Zielkonto der gutgeschriebene Betrag ``to_amount_cents``).
""" """
@@ -18,28 +19,44 @@ def _sum(query) -> int:
return int(query.scalar() or 0) 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: def account_balance_cents(db: Session, account: Account) -> int:
income = _sum( ab = counts_after_opening(account)
db.query(func.sum(Transaction.amount_cents)).filter(
Transaction.account_id == account.id, Transaction.type == TxnType.income 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( expense = summe(
db.query(func.sum(Transaction.amount_cents)).filter( Transaction.amount_cents,
Transaction.account_id == account.id, Transaction.type == TxnType.expense Transaction.account_id == account.id, Transaction.type == TxnType.expense,
)
) )
transfer_out = _sum( transfer_out = summe(
db.query(func.sum(Transaction.amount_cents)).filter( Transaction.amount_cents,
Transaction.account_id == account.id, Transaction.type == TxnType.transfer Transaction.account_id == account.id, Transaction.type == TxnType.transfer,
)
) )
transfer_in = _sum( transfer_in = summe(
db.query( func.coalesce(Transaction.to_amount_cents, Transaction.amount_cents),
func.sum(func.coalesce(Transaction.to_amount_cents, Transaction.amount_cents)) Transaction.to_account_id == account.id, Transaction.type == TxnType.transfer,
).filter(
Transaction.to_account_id == account.id, Transaction.type == TxnType.transfer
)
) )
return account.opening_balance_cents + income - expense - transfer_out + transfer_in return account.opening_balance_cents + income - expense - transfer_out + transfer_in
+60
View File
@@ -102,3 +102,63 @@ def test_transfer_requires_other_account(admin):
"to_account_id": eur["id"], "amount_cents": 100, "to_account_id": eur["id"], "amount_cents": 100,
}) })
assert r.status_code == 400 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
+2 -1
View File
@@ -165,7 +165,8 @@
"identShort": "Nr.", "identShort": "Nr.",
"total": "Gesamt", "total": "Gesamt",
"empty": "Noch keine Konten. Lege dein erstes Konto an.", "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": { "accountTypes": {
"girokonto": "Girokonto", "girokonto": "Girokonto",
+2 -1
View File
@@ -165,7 +165,8 @@
"identShort": "id(s)", "identShort": "id(s)",
"total": "Total", "total": "Total",
"empty": "No accounts yet. Create your first account.", "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": { "accountTypes": {
"girokonto": "Checking", "girokonto": "Checking",
+1
View File
@@ -299,6 +299,7 @@ export default function Accounts() {
onChange={(e) => setForm({ ...form, opening_date: e.target.value })} /> onChange={(e) => setForm({ ...form, opening_date: e.target.value })} />
</label> </label>
</div> </div>
<p className="muted small">{t("accounts.openingDateHint")}</p>
<label> <label>
<span>{t("accounts.icon")}</span> <span>{t("accounts.icon")}</span>
<IconPicker icons={ACCOUNT_ICONS} value={form.icon} color={form.color} <IconPicker icons={ACCOUNT_ICONS} value={form.icon} color={form.color}