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
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user