diff --git a/backend/app/routers/accounts.py b/backend/app/routers/accounts.py index 146a656..ad3d43e 100644 --- a/backend/app/routers/accounts.py +++ b/backend/app/routers/accounts.py @@ -241,6 +241,54 @@ def _entkoppele_uebertraege(db: Session, acc: Account) -> None: 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) def adopt_bank_balance(account_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): diff --git a/backend/app/services/balances.py b/backend/app/services/balances.py index 8094f66..c2ef4ab 100644 --- a/backend/app/services/balances.py +++ b/backend/app/services/balances.py @@ -33,32 +33,49 @@ def counts_after_opening(account: Account): 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) - def summe(spalte, *bedingungen) -> int: - query = db.query(func.sum(spalte)).filter(*bedingungen) + def teil(spalte, *bedingungen) -> dict: + frage = db.query(func.sum(spalte), func.count()).filter(*bedingungen) if ab is not None: - query = query.filter(ab) - return _sum(query) + frage = frage.filter(ab) + summe, anzahl = frage.one() + return {"cents": int(summe or 0), "anzahl": int(anzahl or 0)} - 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, - ) - 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 + teile = { + "einnahmen": teil( + Transaction.amount_cents, + Transaction.account_id == account.id, Transaction.type == TxnType.income), + "ausgaben": teil( + Transaction.amount_cents, + Transaction.account_id == account.id, Transaction.type == TxnType.expense), + "uebertraege_raus": teil( + Transaction.amount_cents, + Transaction.account_id == account.id, Transaction.type == TxnType.transfer), + "uebertraege_rein": teil( + func.coalesce(Transaction.to_amount_cents, Transaction.amount_cents), + Transaction.to_account_id == account.id, + Transaction.type == TxnType.transfer), + } + 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( diff --git a/backend/tests/test_accounts.py b/backend/tests/test_accounts.py index 3ba5e4a..7a26494 100644 --- a/backend/tests/test_accounts.py +++ b/backend/tests/test_accounts.py @@ -221,3 +221,51 @@ def test_kontenreihenfolge_nimmt_keine_fremden_konten(admin, client): eigen = _mk_account(admin, name="Eigenes Konto") r = admin.put("/api/accounts/order", json={"ids": [eigen["id"], 999999]}) 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 diff --git a/web/src/api.js b/web/src/api.js index 58ec9d8..6437e83 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -251,6 +251,8 @@ export const api = { { method: "POST" }), // Den Saldo der Bank als richtig uebernehmen – die Differenz wandert in // 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) => request(`/accounts/${accountId}/saldo-uebernehmen`, { method: "POST" }), setBankPoll: (id, minutes) => diff --git a/web/src/finance.css b/web/src/finance.css index fdb41fd..368f354 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -1407,3 +1407,39 @@ tr.row-dup { .uebertrag-frage .btn { 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); +} diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 7cd21f9..9ac8e0d 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -178,7 +178,20 @@ "adopted": "Saldo übernommen.", "identShort_one": "{{count}} Kennung", "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": { "girokonto": "Girokonto", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 6c6bd9c..65e2e1f 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -178,7 +178,20 @@ "adopted": "Balance adopted.", "identShort_one": "{{count}} identifier", "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": { "girokonto": "Checking", diff --git a/web/src/pages/Accounts.jsx b/web/src/pages/Accounts.jsx index d4568e4..cfaabbd 100644 --- a/web/src/pages/Accounts.jsx +++ b/web/src/pages/Accounts.jsx @@ -44,6 +44,8 @@ function TransferKeywords({ accounts }) { const [amount, setAmount] = useState(""); const [target, setTarget] = useState(""); 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. const [uebersprungen, setUebersprungen] = useState([]); const confirm = useConfirm(); @@ -179,11 +181,24 @@ export default function Accounts() { const [editing, setEditing] = useState(null); // Account-Objekt oder null const [form, setForm] = useState(null); // Formularzustand oder null (geschlossen) 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) { if (!(await confirm({ 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), diff: formatMoney(a.balance_cents - a.bank_balance_cents, a.currency), }), @@ -309,7 +324,8 @@ export default function Accounts() {