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()
@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)):
+39 -22
View File
@@ -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(
+48
View File
@@ -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