feat: Konto-Saldo-Anker mit ankerbasierter Saldenrechnung

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 21:25:04 +02:00
parent 41d556bcde
commit f6a53b7e56
9 changed files with 297 additions and 47 deletions

View File

@@ -16,6 +16,12 @@ class Account(Base):
iban: Mapped[str] = mapped_column(String(34), unique=True)
name: Mapped[str] = mapped_column(String(100))
type: Mapped[str] = mapped_column(String(20), default="giro")
# Konto-Saldo-Anker (Ausbaustufe 3 Task 1): ersetzt die bisherige
# Statement-closing-Logik als Basis der Saldenrechnung. Beide Felder sind
# nur gemeinsam gesetzt oder gemeinsam NULL (durchgesetzt in
# app.routers.accounts.AccountPatch).
anchor_date: Mapped[date | None] = mapped_column(Date, nullable=True)
anchor_balance: Mapped[Decimal | None] = mapped_column(MONEY, nullable=True)
class Category(Base):

View File

@@ -1,32 +1,45 @@
from sqlalchemy import text
from sqlalchemy.engine import Engine
# Per-account starting balance: the opening_balance of each account's earliest
# confirmed statement (if any). Used to seed the cumulative sums below so the
# Grafana balance charts show the real account balance, not just the running
# sum of imported transactions (which would start at zero and ignore the
# balance that existed before the first imported statement).
_ACCOUNT_BASE = """
SELECT DISTINCT ON (account_id) account_id, opening_balance
FROM statements
WHERE status = 'confirmed' AND opening_balance IS NOT NULL
ORDER BY account_id, period_start NULLS LAST, id
# Per-account offset for the Konto-Saldo-Anker (Ausbaustufe 3 Task 1): the
# same math as app.services.balances.account_balance, expressed as a single
# constant "base" per account so it can be added to a plain (anchor-agnostic)
# running sum of transaction amounts:
#
# balance(day) = anchor_balance + Sum(tx: anchor_date < booking_date <= day)
# - Sum(tx: day < booking_date <= anchor_date)
# = anchor_balance - cumsum(<=anchor_date) + cumsum(<=day)
# = base + cumsum(<=day)
#
# ... which holds for `day` both before and after anchor_date, since the
# subtracted/added transaction windows collapse into the same telescoping
# sum. Accounts without an anchor get base=0 (via COALESCE below), matching
# account_balance's "Ohne Anker: Summe ab 0" fallback.
_ACCOUNT_OFFSET = """
SELECT a.id AS account_id,
a.anchor_balance - COALESCE((
SELECT SUM(t2.amount) FROM transactions t2
WHERE t2.account_id = a.id AND t2.status = 'confirmed'
AND t2.booking_date <= a.anchor_date
), 0) AS base
FROM accounts a
WHERE a.anchor_date IS NOT NULL AND a.anchor_balance IS NOT NULL
"""
VIEWS: dict[str, str] = {
"v_balance_history": f"""
SELECT t.booking_date AS day, a.name AS account,
COALESCE(b.opening_balance, 0)
COALESCE(b.base, 0)
+ SUM(SUM(t.amount)) OVER (PARTITION BY a.id
ORDER BY t.booking_date) AS balance
FROM transactions t JOIN accounts a ON a.id = t.account_id
LEFT JOIN ({_ACCOUNT_BASE}) b ON b.account_id = a.id
LEFT JOIN ({_ACCOUNT_OFFSET}) b ON b.account_id = a.id
WHERE t.status = 'confirmed'
GROUP BY a.id, a.name, t.booking_date, b.opening_balance""",
GROUP BY a.id, a.name, t.booking_date, b.base""",
"v_balance_total": f"""
SELECT booking_date AS day,
(SELECT COALESCE(SUM(opening_balance), 0)
FROM ({_ACCOUNT_BASE}) s)
(SELECT COALESCE(SUM(base), 0)
FROM ({_ACCOUNT_OFFSET}) s)
+ SUM(SUM(amount)) OVER (ORDER BY booking_date) AS balance
FROM transactions WHERE status = 'confirmed' GROUP BY booking_date""",
"v_monthly_by_category": """

View File

@@ -1,3 +1,4 @@
from datetime import date
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException
@@ -25,14 +26,23 @@ class AccountOut(AccountIn):
model_config = ConfigDict(from_attributes=True)
id: int
balance: Decimal = Decimal("0")
anchor_date: date | None = None
anchor_balance: Decimal | None = None
class AccountPatch(BaseModel):
name: str
# Alle Felder optional; model_fields_set entscheidet, was tatsaechlich
# geaendert wird (Muster wie TransactionPatch.category_id). anchor_date
# und anchor_balance muessen gemeinsam gesetzt oder gemeinsam null sein.
name: str | None = None
anchor_date: date | None = None
anchor_balance: Decimal | None = None
@field_validator("name")
@classmethod
def name_not_blank(cls, v: str) -> str:
def name_not_blank(cls, v: str | None) -> str | None:
if v is None:
return v
v = v.strip()
if not v or len(v) > 100:
raise ValueError("Name muss 1100 Zeichen lang sein")
@@ -75,7 +85,25 @@ def patch_account(account_id: int, data: AccountPatch, session: Session = Depend
acc = session.get(Account, account_id)
if acc is None:
raise HTTPException(404, "Konto nicht gefunden")
acc.name = data.name
# Validierung zuerst, VOR jeder Mutation - sonst haengt bei einem 422 auf
# dem Anker eine bereits geschriebene name-Aenderung im Session-State.
fields = data.model_fields_set
if "name" in fields and data.name is None:
raise HTTPException(422, "Name darf nicht leer sein")
anchor_fields = {"anchor_date", "anchor_balance"} & fields
if anchor_fields and (
anchor_fields != {"anchor_date", "anchor_balance"}
or (data.anchor_date is None) != (data.anchor_balance is None)):
raise HTTPException(422, "Anker braucht Datum und Betrag (oder beide leeren)")
if "name" in fields:
acc.name = data.name
if anchor_fields:
acc.anchor_date = data.anchor_date
acc.anchor_balance = data.anchor_balance
session.commit()
session.refresh(acc)
item = AccountOut.model_validate(acc)

View File

@@ -1,26 +1,53 @@
from datetime import date
from decimal import Decimal
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.tables import Account, Statement, Transaction
from app.models.tables import Account, Transaction
def account_balance(session: Session, account: Account) -> Decimal:
stmt = session.execute(
select(Statement).where(Statement.account_id == account.id,
Statement.status == "confirmed")
.order_by(Statement.period_end.desc())).scalars().first()
def _confirmed_sum(session: Session, account_id: int,
after: date | None, upto: date | None) -> Decimal:
"""Summe bestaetigter Buchungen fuer `account_id` mit `after < booking_date
<= upto` (Raender werden nur gesetzt, wenn nicht None)."""
q = select(func.coalesce(func.sum(Transaction.amount), 0)).where(
Transaction.account_id == account.id, Transaction.status == "confirmed")
if stmt is not None:
q = q.where(Transaction.booking_date > stmt.period_end)
base = stmt.closing_balance
else:
base = Decimal("0")
return Decimal(base) + Decimal(session.execute(q).scalar_one())
Transaction.account_id == account_id, Transaction.status == "confirmed")
if after is not None:
q = q.where(Transaction.booking_date > after)
if upto is not None:
q = q.where(Transaction.booking_date <= upto)
return Decimal(session.execute(q).scalar_one())
def total_balance(session: Session) -> Decimal:
def account_balance(session: Session, account: Account, at: date | None = None) -> Decimal:
"""Kontostand am Ende des Tages `at` (Default: heute).
Konto-Saldo-Anker (Ausbaustufe 3 Task 1) ersetzt die bisherige
Statement-closing-Logik ersatzlos. Konvention: der Anker gilt PER
TAGESENDE des Ankerdatums - er enthaelt bereits alle Buchungen bis
einschliesslich diesem Tag.
Mit Anker: anchor_balance + Sum(tx: anchor_date < booking_date <= at)
- Sum(tx: at < booking_date <= anchor_date)
(jeweils nur eine der beiden Summen ist nicht-leer, je nachdem ob `at`
vor oder nach dem Anker liegt; bei at == anchor_date sind beide leer.)
Ohne Anker: Sum(tx: booking_date <= at) - bisheriges Verhalten mit Basis 0.
"""
if at is None:
at = date.today()
if account.anchor_date is not None and account.anchor_balance is not None:
anchor_date = account.anchor_date
anchor_balance = Decimal(account.anchor_balance)
if at >= anchor_date:
return anchor_balance + _confirmed_sum(session, account.id, anchor_date, at)
return anchor_balance - _confirmed_sum(session, account.id, at, anchor_date)
return _confirmed_sum(session, account.id, None, at)
def total_balance(session: Session, at: date | None = None) -> Decimal:
accounts = session.execute(select(Account)).scalars().all()
return sum((account_balance(session, a) for a in accounts), Decimal("0"))
return sum((account_balance(session, a, at) for a in accounts), Decimal("0"))

View File

@@ -16,7 +16,10 @@
<table>
<thead>
<tr><th>Konto</th><th>Bank</th><th>Saldo</th><th>Umbenennen</th></tr>
<tr>
<th>Konto</th><th>Bank</th><th>Saldo</th><th>Anker</th>
<th>Umbenennen</th><th>Anker pflegen</th>
</tr>
</thead>
<tbody>
{% for account, balance in balances %}
@@ -24,6 +27,13 @@
<td class="account">{{ account.name }}</td>
<td>{{ account.bank }}</td>
<td class="{{ 'neg' if balance < 0 else '' }}">{{ '%.2f'|format(balance) }} €</td>
<td>
{% if account.anchor_date is not none and account.anchor_balance is not none %}
Anker: {{ '%.2f'|format(account.anchor_balance) }} € am {{ account.anchor_date.strftime('%d.%m.%Y') }}
{% else %}
kein Anker
{% endif %}
</td>
<td>
<form class="inline-form" hx-ext="json-form" hx-patch="/api/accounts/{{ account.id }}"
hx-swap="none" hx-on::after-request="if(event.detail.successful){window.location.reload()}">
@@ -31,9 +41,19 @@
<button type="submit">Umbenennen</button>
</form>
</td>
<td>
<form class="inline-form" hx-ext="json-form" hx-patch="/api/accounts/{{ account.id }}"
hx-swap="none" hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<input type="date" name="anchor_date"
value="{{ account.anchor_date.isoformat() if account.anchor_date is not none else '' }}" required>
<input type="text" name="anchor_balance" placeholder="500.00"
value="{{ account.anchor_balance if account.anchor_balance is not none else '' }}" required>
<button type="submit">Anker setzen</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="4">Keine Konten angelegt.</td></tr>
<tr><td colspan="6">Keine Konten angelegt.</td></tr>
{% endfor %}
</tbody>
</table>