feat: Kontostand aufschluesseln – und der Angleich-Knopf bleibt

Zwei Sachen aus derselben Wurzel: Wenn ein Saldo nicht stimmt, sieht man nicht,
warum, und kann auch nichts dagegen tun.

Ein Klick auf den Kontostand zeigt jetzt die Rechnung: Anfangsbestand,
Einnahmen, Ausgaben, Uebertraege raus und rein – jeweils mit der Anzahl der
Buchungen – und was daraus wird. Darunter der Saldo der Bank und, einzeln
aufgefuehrt, alle Uebertraege des Kontos. Genau die sind die haeufigste
Ursache und in einer langen Buchungsliste am schwersten zu ueberblicken.

Rechnung und Kontenliste kommen aus EINER Quelle: ``account_balance_cents``
liest jetzt aus der Aufschluesselung. Sonst koennte die Anzeige etwas anderes
behaupten als der Saldo, und man suchte den Fehler im Falschen.

Ausserdem war der Knopf "Saldo der Bank uebernehmen" verschwunden, sobald die
Bank nur einen verfuegbaren Saldo meldet. Das war zu weit gedacht: Nicht
vergleichbar heisst nicht, dass man nicht angleichen duerfte. Der Knopf ist
zurueck, und die Nachfrage sagt in dem Fall, worauf man sich einlaesst – der
verfuegbare Saldo enthaelt Vorgemerktes, das spaeter noch als Buchung kommt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-30 13:33:27 +02:00
co-authored by Claude Opus 5
parent 7fe2678192
commit 7c0d13cc8a
8 changed files with 296 additions and 29 deletions
+48
View File
@@ -241,6 +241,54 @@ def _entkoppele_uebertraege(db: Session, acc: Account) -> None:
db.flush() db.flush()
@router.get("/{account_id}/aufschluesselung")
def balance_breakdown(account_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)) -> dict:
"""Woraus sich der Saldo zusammensetzt – samt aller Übertrags-Beine.
Stimmt ein Kontostand nicht, liegt es fast immer an einer dieser Zahlen
oder an einem Übertrag, der zu viel oder zu wenig verschiebt. Ohne diese
Ansicht sucht man in einer langen Buchungsliste; mit ihr sieht man sofort,
welcher Teil nicht passt.
"""
konto = get_owned(db, Account, account_id, user)
teile = balances.aufschluesselung(db, konto)
namen = {a.id: a.name for a in db.query(Account).filter(Account.owner_id == user.id)}
# Die Übertrags-Beine einzeln: Sie sind der Teil, den man in der Liste am
# schwersten überblickt, und die haeufigste Ursache.
beine = [
{"id": t.id, "datum": t.date.isoformat(),
"von": namen.get(t.account_id, "?"), "nach": namen.get(t.to_account_id, "?"),
"raus": t.account_id == konto.id,
"cents": (t.amount_cents if t.account_id == konto.id
else (t.to_amount_cents or t.amount_cents)),
"text": t.payee or t.description}
for t in db.query(Transaction).filter(
Transaction.owner_id == user.id,
Transaction.type == TxnType.transfer,
(Transaction.account_id == konto.id) | (Transaction.to_account_id == konto.id),
).order_by(Transaction.date.desc(), Transaction.id.desc()).limit(50)
]
verknuepfung = (
db.query(BankAccountLink)
.filter(BankAccountLink.owner_id == user.id,
BankAccountLink.account_id == konto.id)
.first()
)
return {
"waehrung": konto.currency,
"teile": teile,
"uebertraege": beine,
"bank": {
"cents": verknuepfung.balance_cents if verknuepfung else None,
"art": verknuepfung.balance_type if verknuepfung else None,
"vergleichbar": (bank_sync.saldo_vergleichbar(verknuepfung.balance_type)
if verknuepfung else False),
},
}
@router.post("/{account_id}/saldo-uebernehmen", response_model=AccountOut) @router.post("/{account_id}/saldo-uebernehmen", response_model=AccountOut)
def adopt_bank_balance(account_id: int, db: Session = Depends(get_db), def adopt_bank_balance(account_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)): user: User = Depends(get_current_user)):
+35 -18
View File
@@ -33,32 +33,49 @@ def counts_after_opening(account: Account):
return Transaction.date > account.opening_date return Transaction.date > account.opening_date
def account_balance_cents(db: Session, account: Account) -> int: def aufschluesselung(db: Session, account: Account) -> dict:
"""Woraus sich der Saldo zusammensetzt – Teil fuer Teil.
Dieselbe Rechnung wie ``account_balance_cents``, nur offen gelegt. Beide
aus EINER Quelle, damit die Anzeige nicht etwas anderes behauptet als der
Saldo: Stimmt eine Zahl nicht, sieht man hier, welche.
"""
ab = counts_after_opening(account) ab = counts_after_opening(account)
def summe(spalte, *bedingungen) -> int: def teil(spalte, *bedingungen) -> dict:
query = db.query(func.sum(spalte)).filter(*bedingungen) frage = db.query(func.sum(spalte), func.count()).filter(*bedingungen)
if ab is not None: if ab is not None:
query = query.filter(ab) frage = frage.filter(ab)
return _sum(query) summe, anzahl = frage.one()
return {"cents": int(summe or 0), "anzahl": int(anzahl or 0)}
income = summe( teile = {
"einnahmen": teil(
Transaction.amount_cents, Transaction.amount_cents,
Transaction.account_id == account.id, Transaction.type == TxnType.income, Transaction.account_id == account.id, Transaction.type == TxnType.income),
) "ausgaben": teil(
expense = summe(
Transaction.amount_cents, Transaction.amount_cents,
Transaction.account_id == account.id, Transaction.type == TxnType.expense, Transaction.account_id == account.id, Transaction.type == TxnType.expense),
) "uebertraege_raus": teil(
transfer_out = summe(
Transaction.amount_cents, Transaction.amount_cents,
Transaction.account_id == account.id, Transaction.type == TxnType.transfer, Transaction.account_id == account.id, Transaction.type == TxnType.transfer),
) "uebertraege_rein": teil(
transfer_in = summe(
func.coalesce(Transaction.to_amount_cents, Transaction.amount_cents), func.coalesce(Transaction.to_amount_cents, Transaction.amount_cents),
Transaction.to_account_id == account.id, Transaction.type == TxnType.transfer, Transaction.to_account_id == account.id,
) Transaction.type == TxnType.transfer),
return account.opening_balance_cents + income - expense - transfer_out + transfer_in }
teile["anfangsbestand"] = {"cents": account.opening_balance_cents, "anzahl": 0}
teile["saldo"] = {
"cents": (account.opening_balance_cents
+ teile["einnahmen"]["cents"] - teile["ausgaben"]["cents"]
- teile["uebertraege_raus"]["cents"] + teile["uebertraege_rein"]["cents"]),
"anzahl": 0,
}
return teile
def account_balance_cents(db: Session, account: Account) -> int:
return aufschluesselung(db, account)["saldo"]["cents"]
def account_balance_base_cents( def account_balance_base_cents(
+48
View File
@@ -221,3 +221,51 @@ def test_kontenreihenfolge_nimmt_keine_fremden_konten(admin, client):
eigen = _mk_account(admin, name="Eigenes Konto") eigen = _mk_account(admin, name="Eigenes Konto")
r = admin.put("/api/accounts/order", json={"ids": [eigen["id"], 999999]}) r = admin.put("/api/accounts/order", json={"ids": [eigen["id"], 999999]})
assert r.status_code == 404 assert r.status_code == 404
def test_saldo_laesst_sich_aufschluesseln(admin):
"""Stimmt ein Kontostand nicht, liegt es fast immer an einer dieser Zahlen
oder an einem Uebertrag. Ohne diese Ansicht sucht man in einer langen
Buchungsliste."""
import secrets
a = admin.post("/api/accounts", json={
"name": f"Aufschluss-A {secrets.token_hex(3)}", "type": "girokonto",
"currency": "CHF", "opening_balance_cents": 100000}).json()
b = admin.post("/api/accounts", json={
"name": f"Aufschluss-B {secrets.token_hex(3)}", "type": "girokonto",
"currency": "CHF", "opening_balance_cents": 0}).json()
admin.post("/api/transactions", json={
"type": "income", "date": "2026-08-01", "account_id": a["id"],
"amount_cents": 50000, "payee": "Lohn"})
admin.post("/api/transactions", json={
"type": "expense", "date": "2026-08-02", "account_id": a["id"],
"amount_cents": 20000, "payee": "Miete"})
admin.post("/api/transactions", json={
"type": "transfer", "date": "2026-08-03", "account_id": a["id"],
"to_account_id": b["id"], "amount_cents": 30000})
r = admin.get(f"/api/accounts/{a['id']}/aufschluesselung")
assert r.status_code == 200, r.text
teile = r.json()["teile"]
assert teile["anfangsbestand"]["cents"] == 100000
assert teile["einnahmen"] == {"cents": 50000, "anzahl": 1}
assert teile["ausgaben"] == {"cents": 20000, "anzahl": 1}
assert teile["uebertraege_raus"] == {"cents": 30000, "anzahl": 1}
assert teile["uebertraege_rein"] == {"cents": 0, "anzahl": 0}
assert teile["saldo"]["cents"] == 100000 + 50000 - 20000 - 30000
# Und die Rechnung muss dasselbe sagen wie die Kontenliste.
assert admin.get(f"/api/accounts/{a['id']}").json()["balance_cents"] == \
teile["saldo"]["cents"]
# Das Uebertrags-Bein steht einzeln da – dort sucht man zuerst.
beine = r.json()["uebertraege"]
assert len(beine) == 1 and beine[0]["raus"] is True
assert beine[0]["cents"] == 30000 and beine[0]["nach"] == b["name"]
# Auf der Gegenseite dasselbe Bein, nur andersherum.
gegen = admin.get(f"/api/accounts/{b['id']}/aufschluesselung").json()
assert gegen["teile"]["uebertraege_rein"] == {"cents": 30000, "anzahl": 1}
assert gegen["uebertraege"][0]["raus"] is False
+2
View File
@@ -251,6 +251,8 @@ export const api = {
{ method: "POST" }), { method: "POST" }),
// Den Saldo der Bank als richtig uebernehmen – die Differenz wandert in // Den Saldo der Bank als richtig uebernehmen – die Differenz wandert in
// den Anfangsbestand, die Buchungen bleiben unangetastet. // den Anfangsbestand, die Buchungen bleiben unangetastet.
// Woraus sich ein Saldo zusammensetzt – zum Nachrechnen, wenn er nicht stimmt.
balanceBreakdown: (accountId) => request(`/accounts/${accountId}/aufschluesselung`),
adoptBankBalance: (accountId) => adoptBankBalance: (accountId) =>
request(`/accounts/${accountId}/saldo-uebernehmen`, { method: "POST" }), request(`/accounts/${accountId}/saldo-uebernehmen`, { method: "POST" }),
setBankPoll: (id, minutes) => setBankPoll: (id, minutes) =>
+36
View File
@@ -1407,3 +1407,39 @@ tr.row-dup {
.uebertrag-frage .btn { .uebertrag-frage .btn {
flex: 0 0 auto; flex: 0 0 auto;
} }
/* Aufgeschlüsselter Kontostand: eine Rechnung, also untereinander und rechts
ausgerichtet – so lässt sie sich nachvollziehen wie auf Papier. */
/* Die Zeile darf nur umbrechen, wenn die Rechnung offen ist – sonst
verschiebt sich die Kontenliste ohne Grund. */
.account-row.mit-aufschluss {
flex-wrap: wrap;
}
.aufschluss {
flex: 1 1 100%;
margin: var(--sp-2) 0 0 38px;
padding: var(--sp-3);
border-left: 2px solid var(--border-strong);
font-size: 0.85rem;
}
.aufschluss-zeile {
display: flex;
justify-content: space-between;
gap: var(--sp-3);
padding: 2px 0;
min-width: 0;
}
.aufschluss-zeile > span:last-child {
font-variant-numeric: tabular-nums;
}
.aufschluss-summe {
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid var(--border-strong);
font-weight: 600;
}
.aufschluss-uebertraege {
margin-top: var(--sp-3);
padding-top: var(--sp-2);
border-top: 1px dashed var(--border);
}
+14 -1
View File
@@ -178,7 +178,20 @@
"adopted": "Saldo übernommen.", "adopted": "Saldo übernommen.",
"identShort_one": "{{count}} Kennung", "identShort_one": "{{count}} Kennung",
"identShort_other": "{{count}} Kennungen", "identShort_other": "{{count}} Kennungen",
"bankBalanceAvailable": "Bank meldet {{bank}} als verfügbaren Saldo – der enthält Vorgemerktes und lässt sich nicht vergleichen." "bankBalanceAvailable": "Bank meldet {{bank}} als verfügbaren Saldo – der enthält Vorgemerktes und lässt sich nicht vergleichen.",
"adoptConfirmAvailable": "Achtung: {{bank}} ist der VERFÜGBARE Saldo – er enthält Vorgemerktes, das später noch als Buchung kommt. Gleichst du jetzt an, ist dein Konto genau um diese Vormerkungen zu hoch, sobald sie gebucht werden. Die Differenz von {{diff}} wandert in den Anfangsbestand, deine Buchungen bleiben unverändert.",
"breakdown": "Woraus setzt sich das zusammen?",
"part": {
"anfangsbestand": "Anfangsbestand",
"einnahmen": "Einnahmen",
"ausgaben": "Ausgaben",
"uebertraege_raus": "Überträge raus",
"uebertraege_rein": "Überträge rein",
"saldo": "ergibt",
"bankGebucht": "Bank (gebucht)",
"bankVerfuegbar": "Bank (verfügbar)",
"legs": "Die Überträge dieses Kontos"
}
}, },
"accountTypes": { "accountTypes": {
"girokonto": "Girokonto", "girokonto": "Girokonto",
+14 -1
View File
@@ -178,7 +178,20 @@
"adopted": "Balance adopted.", "adopted": "Balance adopted.",
"identShort_one": "{{count}} identifier", "identShort_one": "{{count}} identifier",
"identShort_other": "{{count}} identifiers", "identShort_other": "{{count}} identifiers",
"bankBalanceAvailable": "The bank reports {{bank}} as the available balance – it includes pending items and cannot be compared." "bankBalanceAvailable": "The bank reports {{bank}} as the available balance – it includes pending items and cannot be compared.",
"adoptConfirmAvailable": "Careful: {{bank}} is the AVAILABLE balance – it includes pending items that will still arrive as bookings. If you align now, your account will be off by exactly those once they are booked. The difference of {{diff}} moves into the opening balance; your transactions stay unchanged.",
"breakdown": "What is this made of?",
"part": {
"anfangsbestand": "Opening balance",
"einnahmen": "Income",
"ausgaben": "Expenses",
"uebertraege_raus": "Transfers out",
"uebertraege_rein": "Transfers in",
"saldo": "makes",
"bankGebucht": "Bank (booked)",
"bankVerfuegbar": "Bank (available)",
"legs": "This account's transfers"
}
}, },
"accountTypes": { "accountTypes": {
"girokonto": "Checking", "girokonto": "Checking",
+93 -3
View File
@@ -44,6 +44,8 @@ function TransferKeywords({ accounts }) {
const [amount, setAmount] = useState(""); const [amount, setAmount] = useState("");
const [target, setTarget] = useState(""); const [target, setTarget] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// Aufgeschlüsselter Saldo je Konto, sobald man danach fragt.
const [aufschluss, setAufschluss] = useState({});
// Was beim nachträglichen Anwenden übersprungen wurde, samt Grund. // Was beim nachträglichen Anwenden übersprungen wurde, samt Grund.
const [uebersprungen, setUebersprungen] = useState([]); const [uebersprungen, setUebersprungen] = useState([]);
const confirm = useConfirm(); const confirm = useConfirm();
@@ -179,11 +181,24 @@ export default function Accounts() {
const [editing, setEditing] = useState(null); // Account-Objekt oder null const [editing, setEditing] = useState(null); // Account-Objekt oder null
const [form, setForm] = useState(null); // Formularzustand oder null (geschlossen) const [form, setForm] = useState(null); // Formularzustand oder null (geschlossen)
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// Aufgeschlüsselter Saldo je Konto, sobald man danach fragt.
const [aufschluss, setAufschluss] = useState({});
async function aufschluesseln(a) {
if (aufschluss[a.id]) { setAufschluss((s) => ({ ...s, [a.id]: null })); return; }
try {
const daten = await api.balanceBreakdown(a.id);
setAufschluss((s) => ({ ...s, [a.id]: daten }));
} catch (e) {
toast(e.message, "error");
}
}
async function saldoUebernehmen(a) { async function saldoUebernehmen(a) {
if (!(await confirm({ if (!(await confirm({
title: t("accounts.adoptBankBalance"), title: t("accounts.adoptBankBalance"),
message: t("accounts.adoptConfirm", { message: t(a.bank_balance_vergleichbar
? "accounts.adoptConfirm" : "accounts.adoptConfirmAvailable", {
bank: formatMoney(a.bank_balance_cents, a.currency), bank: formatMoney(a.bank_balance_cents, a.currency),
diff: formatMoney(a.balance_cents - a.bank_balance_cents, a.currency), diff: formatMoney(a.balance_cents - a.bank_balance_cents, a.currency),
}), }),
@@ -309,7 +324,8 @@ export default function Accounts() {
</div> </div>
<div className="account-list"> <div className="account-list">
{accounts.map((a, i) => ( {accounts.map((a, i) => (
<div className="account-row" key={a.id}> <div className={`account-row${aufschluss[a.id] ? " mit-aufschluss" : ""}`}
key={a.id}>
<IconChip name={a.icon} color={a.color} /> <IconChip name={a.icon} color={a.color} />
<div className="named-text grow"> <div className="named-text grow">
<div><strong>{a.name}</strong></div> <div><strong>{a.name}</strong></div>
@@ -333,7 +349,17 @@ export default function Accounts() {
<span className="muted"> <span className="muted">
{t("accounts.bankBalanceAvailable", { {t("accounts.bankBalanceAvailable", {
bank: formatMoney(a.bank_balance_cents, a.currency), bank: formatMoney(a.bank_balance_cents, a.currency),
})} })}{" "}
{/* Nicht vergleichbar heisst nicht, dass man nicht
angleichen dürfte – nur, dass Certain es nicht
von sich aus als Abweichung meldet. Die
Entscheidung bleibt beim Nutzer. */}
{a.bank_balance_cents !== a.balance_cents && (
<button type="button" className="btn sm"
onClick={() => saldoUebernehmen(a)}>
{t("accounts.adoptBankBalance")}
</button>
)}
</span> </span>
) : a.bank_balance_cents === a.balance_cents ? ( ) : a.bank_balance_cents === a.balance_cents ? (
<span className="saldo-ok"> <span className="saldo-ok">
@@ -359,8 +385,14 @@ export default function Accounts() {
</div> </div>
)} )}
</div> </div>
<button type="button" className="btn sm ghost" title={t("accounts.breakdown")}
onClick={() => aufschluesseln(a)}>
<Money cents={a.balance_cents} currency={a.currency} <Money cents={a.balance_cents} currency={a.currency}
baseCents={a.balance_base_cents} baseCurrency={baseCurrency} /> baseCents={a.balance_base_cents} baseCurrency={baseCurrency} />
</button>
{aufschluss[a.id] && (
<Aufschluesselung daten={aufschluss[a.id]} t={t} />
)}
<div className="row-actions"> <div className="row-actions">
<button className="btn sm ghost" title={t("accounts.moveUp")} <button className="btn sm ghost" title={t("accounts.moveUp")}
disabled={i === 0} onClick={() => verschiebe(i, -1)}> disabled={i === 0} onClick={() => verschiebe(i, -1)}>
@@ -488,3 +520,61 @@ export default function Accounts() {
</div> </div>
); );
} }
// Woraus sich ein Kontostand zusammensetzt. Stimmt er nicht, liegt es fast
// immer an einer dieser Zahlen oder an einem Übertrag, der zu viel oder zu
// wenig verschiebt – dann sieht man hier sofort, welcher Teil nicht passt.
function Aufschluesselung({ daten, t }) {
const { teile, uebertraege, bank, waehrung } = daten;
const zeile = (schluessel, vorzeichen) => {
const teil = teile[schluessel];
return (
<div className="aufschluss-zeile" key={schluessel}>
<span>
{t(`accounts.part.${schluessel}`)}
{teil.anzahl > 0 && <span className="muted"> ({teil.anzahl})</span>}
</span>
<span className="nowrap">
{vorzeichen}{formatMoney(teil.cents, waehrung)}
</span>
</div>
);
};
return (
<div className="aufschluss">
{zeile("anfangsbestand", "")}
{zeile("einnahmen", "+")}
{zeile("ausgaben", "−")}
{zeile("uebertraege_raus", "−")}
{zeile("uebertraege_rein", "+")}
<div className="aufschluss-zeile aufschluss-summe">
<span>{t("accounts.part.saldo")}</span>
<span className="nowrap">{formatMoney(teile.saldo.cents, waehrung)}</span>
</div>
{bank.cents != null && (
<div className="aufschluss-zeile muted">
<span>{t(bank.vergleichbar ? "accounts.part.bankGebucht"
: "accounts.part.bankVerfuegbar")}</span>
<span className="nowrap">{formatMoney(bank.cents, waehrung)}</span>
</div>
)}
{uebertraege.length > 0 && (
<div className="aufschluss-uebertraege">
<div className="muted small">{t("accounts.part.legs")}</div>
{uebertraege.map((u) => (
<div className="aufschluss-zeile" key={u.id}>
<span className="ellipsis">
{formatDate(u.datum)} · {u.von} → {u.nach}
{u.text ? ` · ${u.text}` : ""}
</span>
<span className="nowrap">
{u.raus ? "−" : "+"}{formatMoney(u.cents, waehrung)}
</span>
</div>
))}
</div>
)}
</div>
);
}