"""Fortschreibung der Kontosalden. Saldo = Eröffnungssaldo + alle einmaligen Buchungen + alle bestätigten Fälligkeiten bis zum Stichtag. Geplante oder ausgelassene Fälligkeiten bleiben außen vor – sie sind Prognose, keine Bewegung. """ from dataclasses import dataclass from datetime import date from decimal import Decimal from sqlalchemy import case, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.clock import today from app.models import Account, Occurrence, Recurrence, Transaction from app.models.enums import EntryKind, OccurrenceStatus ZERO = Decimal("0.00") @dataclass(frozen=True, slots=True) class AccountBalance: """Aufgeschlüsselter Saldo eines Kontos.""" account_id: int as_of: date opening_balance: Decimal booked_transactions: Decimal booked_occurrences: Decimal @property def balance(self) -> Decimal: return self.opening_balance + self.booked_transactions + self.booked_occurrences async def account_balance( session: AsyncSession, account: Account, as_of: date | None = None ) -> AccountBalance: """Berechnet den Saldo eines Kontos zum Stichtag (einschließlich).""" reference = as_of or today() transactions = await _transaction_sum(session, account.id, reference) occurrences = await _occurrence_sum(session, account.id, reference) return AccountBalance( account_id=account.id, as_of=reference, opening_balance=account.opening_balance, booked_transactions=transactions, booked_occurrences=occurrences, ) async def _transaction_sum(session: AsyncSession, account_id: int, as_of: date) -> Decimal: """Vorzeichenbehaftete Summe der einmaligen Buchungen bis zum Stichtag.""" signed = func.sum( case( (Transaction.kind == EntryKind.EXPENSE, -Transaction.amount), else_=Transaction.amount, ) ) stmt = select(func.coalesce(signed, ZERO)).where( Transaction.account_id == account_id, Transaction.booking_date <= as_of, ) return (await session.execute(stmt)).scalar_one() async def _occurrence_sum(session: AsyncSession, account_id: int, as_of: date) -> Decimal: """Vorzeichenbehaftete Summe der bestätigten Fälligkeiten bis zum Stichtag. Maßgeblich sind der Ist-Betrag und – falls erfasst – das Ist-Datum. Das Konto kann je Fälligkeit vom Konto der Recurrence abweichen. """ effective_account = func.coalesce(Occurrence.account_id, Recurrence.account_id) effective_amount = func.coalesce(Occurrence.actual_amount, Occurrence.planned_amount) effective_date = func.coalesce(Occurrence.actual_date, Occurrence.occurrence_date) signed = func.sum( case( (Recurrence.kind == EntryKind.EXPENSE, -effective_amount), else_=effective_amount, ) ) stmt = ( select(func.coalesce(signed, ZERO)) .select_from(Occurrence) .join(Recurrence, Recurrence.id == Occurrence.recurrence_id) .where( Occurrence.status == OccurrenceStatus.CONFIRMED, effective_account == account_id, effective_date <= as_of, ) ) return (await session.execute(stmt)).scalar_one() async def all_balances( session: AsyncSession, as_of: date | None = None, *, only_active: bool = True ) -> list[AccountBalance]: """Salden aller Konten zum Stichtag.""" stmt = select(Account).order_by(Account.sort_order, Account.name) if only_active: stmt = stmt.where(Account.is_active.is_(True)) accounts = (await session.execute(stmt)).scalars().all() return [await account_balance(session, account, as_of) for account in accounts] async def total_balance(session: AsyncSession, as_of: date | None = None) -> Decimal: """Summe aller aktiven Kontosalden.""" balances = await all_balances(session, as_of) return sum((item.balance for item in balances), ZERO)