Files
certain/backend/app/services/balances.py
T
GuidoandClaude Opus 5 67a32bdda9 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>
2026-08-30 13:41:22 +02:00

130 lines
5.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Kontostände (in Kontowährung) und Umrechnung in die Hauptwährung.
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``).
"""
from datetime import date
from sqlalchemy import func
from sqlalchemy.orm import Session
from ..models import Account, Transaction, TxnType
from . import currency
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 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 teil(spalte, *bedingungen) -> dict:
frage = db.query(func.sum(spalte), func.count()).filter(*bedingungen)
if ab is not None:
frage = frage.filter(ab)
summe, anzahl = frage.one()
return {"cents": int(summe or 0), "anzahl": int(anzahl or 0)}
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,
}
# 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
def account_balance_cents(db: Session, account: Account) -> int:
return aufschluesselung(db, account)["saldo"]["cents"]
def account_balance_base_cents(
db: Session, account: Account, base_currency: str, on: date
) -> int:
"""Saldo in die Hauptwährung umgerechnet (aktueller Kurs). Fällt auf den
nativen Wert zurück, wenn kein Kurs verfügbar ist."""
native = account_balance_cents(db, account)
if account.currency == base_currency:
return native
conv = currency.convert(db, native, account.currency, base_currency, on)
return conv if conv is not None else native
def net_worth_cents(db: Session, accounts: list[Account], base_currency: str, on: date) -> int:
return sum(
account_balance_base_cents(db, a, base_currency, on)
for a in accounts
if not a.is_archived
)