- Anmeldung über Argon2id und JWT in httpOnly-Cookies, Refresh mit echter Rotation über die neue Tabelle refresh_token - AuthProvider-Protokoll als Vorbereitung für OIDC, Administrator-Anlage beim Erststart mit erzwungenem Passwortwechsel - CRUD für Konten, Kategorien (zweistufiger Baum), Firmen, Recurrences, Preisversionen, Buchungen, Budgets, Vorlagen und Sparziele - Fälligkeiten mit Overlay-Logik: abrufen, bestätigen, auslassen, zurücksetzen - Kontosalden zum Stichtag, Monatsübersicht mit Plan-Ist-Vergleich - SECRET_KEY jetzt mindestens 32 Zeichen; Platzhalter in Produktion abgelehnt - 61 neue Integrationstests, insgesamt 148 grün Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
114 lines
3.9 KiB
Python
114 lines
3.9 KiB
Python
"""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)
|