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:
@@ -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.
|
||||
# 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 to_acc is 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
|
||||
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(
|
||||
Transaction.owner_id == user.id, Transaction.account_id.in_(list(acc_by_id.keys()) or [0])
|
||||
|
||||
@@ -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 = summe(
|
||||
Transaction.amount_cents,
|
||||
Transaction.account_id == account.id, Transaction.type == TxnType.expense,
|
||||
)
|
||||
expense = _sum(
|
||||
db.query(func.sum(Transaction.amount_cents)).filter(
|
||||
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_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_out = summe(
|
||||
Transaction.amount_cents,
|
||||
Transaction.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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -299,6 +299,7 @@ export default function Accounts() {
|
||||
onChange={(e) => setForm({ ...form, opening_date: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
<p className="muted small">{t("accounts.openingDateHint")}</p>
|
||||
<label>
|
||||
<span>{t("accounts.icon")}</span>
|
||||
<IconPicker icons={ACCOUNT_ICONS} value={form.icon} color={form.color}
|
||||
|
||||
Reference in New Issue
Block a user