Files
moneyfy/backend/app/services/balances.py
T
moneyfyandClaude Opus 5 b586d27b77 feat(api): Core-API mit Authentifizierung, CRUD und Monatsreport
- 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
2026-09-09 13:40:37 +02:00

114 lines
3.9 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.
"""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)