feat: Aufschluesselung zeigt, was der Stichtag ausschliesst

Buchungen bis einschliesslich des Stichtags zaehlen nicht in den Kontostand –
sie stecken schon im Anfangsbestand. In der Liste stehen sie aber, und in den
Auswertungen zaehlen sie mit. Das ist richtig so, war in der Oberflaeche aber
nirgends zu sehen: Der Unterschied zwischen dem, was man sieht, und dem, was
gerechnet wird, liess sich nicht erklaeren.

Ein Konto, dessen Stichtag nach dem Beginn des eingelesenen Auszugs liegt,
zeigt deshalb einen Kontostand, der zu den sichtbaren Buchungen nicht passt –
und niemand kaeme darauf, warum.

Die Rechnung nennt jetzt Stichtag, Anzahl und Summe der ausgeschlossenen
Buchungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-30 13:41:22 +02:00
co-authored by Claude Opus 5
parent 7c0d13cc8a
commit 67a32bdda9
5 changed files with 80 additions and 2 deletions
+31
View File
@@ -71,6 +71,37 @@ def aufschluesselung(db: Session, account: Account) -> dict:
- teile["uebertraege_raus"]["cents"] + teile["uebertraege_rein"]["cents"]), - teile["uebertraege_raus"]["cents"] + teile["uebertraege_rein"]["cents"]),
"anzahl": 0, "anzahl": 0,
} }
# Was der Stichtag ausschliesst. Diese Buchungen stehen in der Liste und in
# den Auswertungen, zaehlen aber nicht in den Kontostand – sie stecken schon
# im Anfangsbestand. Ohne diese Angabe ist der Unterschied zwischen dem, was
# man sieht, und dem, was gerechnet wird, nicht zu erklaeren.
if account.opening_date is not None:
bis = Transaction.date <= account.opening_date
def vorher(spalte, *bedingungen) -> tuple[int, int]:
summe, anzahl = (db.query(func.sum(spalte), func.count())
.filter(*bedingungen).filter(bis).one())
return int(summe or 0), int(anzahl or 0)
ein, n1 = vorher(Transaction.amount_cents,
Transaction.account_id == account.id,
Transaction.type == TxnType.income)
aus, n2 = vorher(Transaction.amount_cents,
Transaction.account_id == account.id,
Transaction.type == TxnType.expense)
raus, n3 = vorher(Transaction.amount_cents,
Transaction.account_id == account.id,
Transaction.type == TxnType.transfer)
rein, n4 = vorher(func.coalesce(Transaction.to_amount_cents,
Transaction.amount_cents),
Transaction.to_account_id == account.id,
Transaction.type == TxnType.transfer)
teile["vor_stichtag"] = {"cents": ein - aus - raus + rein,
"anzahl": n1 + n2 + n3 + n4}
else:
teile["vor_stichtag"] = {"cents": 0, "anzahl": 0}
teile["stichtag"] = account.opening_date.isoformat() if account.opening_date else None
return teile return teile
+31
View File
@@ -269,3 +269,34 @@ def test_saldo_laesst_sich_aufschluesseln(admin):
gegen = admin.get(f"/api/accounts/{b['id']}/aufschluesselung").json() gegen = admin.get(f"/api/accounts/{b['id']}/aufschluesselung").json()
assert gegen["teile"]["uebertraege_rein"] == {"cents": 30000, "anzahl": 1} assert gegen["teile"]["uebertraege_rein"] == {"cents": 30000, "anzahl": 1}
assert gegen["uebertraege"][0]["raus"] is False assert gegen["uebertraege"][0]["raus"] is False
def test_aufschluesselung_zeigt_was_der_stichtag_ausschliesst(admin):
"""Buchungen bis zum Stichtag stehen in der Liste, zaehlen aber nicht in
den Kontostand – sie stecken schon im Anfangsbestand. In der Oberflaeche
war das unsichtbar, und der Unterschied zwischen Gesehenem und Gerechnetem
nicht zu erklaeren."""
import secrets
konto = admin.post("/api/accounts", json={
"name": f"Stichtag {secrets.token_hex(3)}", "type": "girokonto",
"currency": "CHF", "opening_balance_cents": 120000,
"opening_date": "2026-08-15"}).json()
# Vor dem Stichtag – steckt schon im Anfangsbestand.
admin.post("/api/transactions", json={
"type": "expense", "date": "2026-08-10", "account_id": konto["id"],
"amount_cents": 150000, "payee": "Alt"})
# Danach – zaehlt.
admin.post("/api/transactions", json={
"type": "expense", "date": "2026-08-20", "account_id": konto["id"],
"amount_cents": 30000, "payee": "Neu"})
teile = admin.get(f"/api/accounts/{konto['id']}/aufschluesselung").json()["teile"]
assert teile["stichtag"] == "2026-08-15"
assert teile["vor_stichtag"] == {"cents": -150000, "anzahl": 1}
assert teile["ausgaben"] == {"cents": 30000, "anzahl": 1}, "nur die nach dem Stichtag"
assert teile["saldo"]["cents"] == 120000 - 30000
# Und die Kontenliste sagt dasselbe.
assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == 90000
+2 -1
View File
@@ -190,7 +190,8 @@
"saldo": "ergibt", "saldo": "ergibt",
"bankGebucht": "Bank (gebucht)", "bankGebucht": "Bank (gebucht)",
"bankVerfuegbar": "Bank (verfügbar)", "bankVerfuegbar": "Bank (verfügbar)",
"legs": "Die Überträge dieses Kontos" "legs": "Die Überträge dieses Kontos",
"beforeCutoff": "davor ({{n}} bis {{datum}}, schon im Anfangsbestand)"
} }
}, },
"accountTypes": { "accountTypes": {
+2 -1
View File
@@ -190,7 +190,8 @@
"saldo": "makes", "saldo": "makes",
"bankGebucht": "Bank (booked)", "bankGebucht": "Bank (booked)",
"bankVerfuegbar": "Bank (available)", "bankVerfuegbar": "Bank (available)",
"legs": "This account's transfers" "legs": "This account's transfers",
"beforeCutoff": "before ({{n}} up to {{datum}}, already in the opening balance)"
} }
}, },
"accountTypes": { "accountTypes": {
+14
View File
@@ -544,6 +544,20 @@ function Aufschluesselung({ daten, t }) {
return ( return (
<div className="aufschluss"> <div className="aufschluss">
{zeile("anfangsbestand", "")} {zeile("anfangsbestand", "")}
{/* Der Stichtag ist der haeufigste unsichtbare Grund dafuer, dass die
Liste etwas anderes zeigt als der Kontostand. */}
{teile.stichtag && teile.vor_stichtag.anzahl > 0 && (
<div className="aufschluss-zeile muted">
<span>
{t("accounts.part.beforeCutoff", {
datum: formatDate(teile.stichtag), n: teile.vor_stichtag.anzahl,
})}
</span>
<span className="nowrap">
{formatMoney(teile.vor_stichtag.cents, waehrung)}
</span>
</div>
)}
{zeile("einnahmen", "+")} {zeile("einnahmen", "+")}
{zeile("ausgaben", "−")} {zeile("ausgaben", "−")}
{zeile("uebertraege_raus", "−")} {zeile("uebertraege_raus", "−")}