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>
99 lines
3.8 KiB
Python
99 lines
3.8 KiB
Python
"""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,
|
||
}
|
||
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
|
||
)
|