feat: Salden-Seite mit Stichtag und Monatsuebersicht

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 21:53:23 +02:00
parent ea4858c4de
commit 7a0f71aa8c
5 changed files with 220 additions and 4 deletions

View File

@@ -3,15 +3,16 @@ from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth import COOKIE, session_valid
from app.db import get_session
from app.engine.loans import add_months
from app.engine.recurrence import occurrences
from app.models.tables import (Account, Category, PlannedItem,
ProjectionResult, RecurringItem,
ScenarioLoan, ScenarioModifier)
ScenarioLoan, ScenarioModifier, Transaction)
from app.routers.imports import list_imports as _list_imports
from app.routers.imports import preview as _preview_import
from app.routers.planning import list_loans as _list_loans
@@ -84,6 +85,25 @@ def _opt_date(value: str | None) -> date | None:
return None
def _month_starts_desc(session: Session, today: date) -> list[date]:
"""Liefert die Monatsersten von "heute" rueckwaerts bis zum Monat der
fruehesten bestaetigten Buchung, absteigend sortiert (heute zuerst).
Ohne bestaetigte Buchungen: leere Liste."""
earliest = session.execute(
select(func.min(Transaction.booking_date)).where(Transaction.status == "confirmed")
).scalar_one_or_none()
if earliest is None:
return []
start = date(earliest.year, earliest.month, 1)
current = date(today.year, today.month, 1)
months = []
m = current
while m >= start:
months.append(m)
m = add_months(m, -1)
return months
@router.get("/", dependencies=[Depends(gui_session)])
def index(request: Request, session: Session = Depends(get_session)):
accounts = session.execute(select(Account)).scalars().all()
@@ -158,6 +178,50 @@ def buchungen(
})
@router.get("/salden", dependencies=[Depends(gui_session)])
def salden_page(
request: Request,
stichtag: str | None = None,
session: Session = Depends(get_session),
):
# Lenient wie die Filter auf /buchungen: ein leerer oder kaputter
# stichtag-Parameter fuehrt nie zu 422, sondern faellt auf "heute" zurueck.
today = date.today()
parsed_stichtag = _opt_date(stichtag) or today
accounts = session.execute(select(Account)).scalars().all()
balances = [(a, account_balance(session, a, at=parsed_stichtag)) for a in accounts]
total = sum((b for _, b in balances), Decimal("0"))
month_rows = []
for month in _month_starts_desc(session, today):
# Saldo am Monatsanfang = Stand am ENDE DES VORTAGS (letzter Tag des
# Vormonats), NICHT account_balance(at=month) - sonst wuerden
# Buchungen AM 1. bereits in den "Anfangs"-Saldo einfliessen und den
# Ausweis verzerren. Siehe Fussnote im Template ("Stand: Ende des
# Vortags").
vortag = month - timedelta(days=1)
values = [(a, account_balance(session, a, at=vortag)) for a in accounts]
month_rows.append({
"month": month,
"values": values,
"total": sum((v for _, v in values), Decimal("0")),
})
accounts_without_anchor = [
a for a in accounts if a.anchor_date is None or a.anchor_balance is None
]
return templates.TemplateResponse(request, "salden.html", {
"stichtag": parsed_stichtag,
"balances": balances,
"total": total,
"accounts": accounts,
"months": month_rows,
"accounts_without_anchor": accounts_without_anchor,
})
@router.get("/import", dependencies=[Depends(gui_session)])
def import_page(request: Request, session: Session = Depends(get_session)):
return templates.TemplateResponse(request, "import.html", {

View File

@@ -12,6 +12,7 @@
<a href="/">Übersicht</a>
<a href="/import">Import</a>
<a href="/buchungen">Buchungen</a>
<a href="/salden">Salden</a>
<a href="/planung">Planung</a>
<a href="/hilfe">Hilfe</a>
<a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a>

View File

@@ -35,6 +35,15 @@
seine Buchungen), z. B. um einen Auszug mit einem verbesserten Parser neu
zu importieren; einzelne Buchungen bleiben unlöschbar.
</li>
<li>
<strong>Salden-Seite:</strong> zeigt den Saldo je Konto zu einem
wählbaren Stichtag (Default: heute) sowie eine Monatsanfangs-Übersicht
vom frühesten Buchungsmonat bis heute, absteigend. Die
Monatsanfangs-Werte gelten "Stand: Ende des Vortags" (letzter Tag des
Vormonats), damit Buchungen am 1. eines Monats den Ausweis des
Monatsanfangs nicht verfälschen. Konten ohne Anker sind mit "ohne Anker —
relative Werte" gekennzeichnet.
</li>
<li>
<strong>Kategorien &amp; Regeln:</strong> Buchungen lassen sich kategorisieren;
eine Regel ("Muster im Text → Kategorie") kategorisiert künftige Importe

View File

@@ -0,0 +1,67 @@
{% extends "base.html" %}
{% block title %}Salden Finanzberatung{% endblock %}
{% block content %}
<h1>Salden</h1>
<h2>Salden am {{ stichtag.strftime('%d.%m.%Y') }}</h2>
<form method="get" action="/salden" class="inline-form">
<input type="date" name="stichtag" value="{{ stichtag.isoformat() }}">
<button type="submit">Anzeigen</button>
</form>
<table>
<thead>
<tr><th>Konto</th><th>Saldo</th></tr>
</thead>
<tbody>
{% for account, balance in balances %}
<tr>
<td class="account">{{ account.name }}</td>
<td class="amount {{ 'neg' if balance < 0 else '' }}">{{ '%.2f'|format(balance) }} €</td>
</tr>
{% else %}
<tr><td colspan="2">Keine Konten angelegt.</td></tr>
{% endfor %}
<tr class="total-row">
<td><strong>Gesamt</strong></td>
<td class="amount {{ 'neg' if total < 0 else '' }}"><strong>{{ '%.2f'|format(total) }} €</strong></td>
</tr>
</tbody>
</table>
<h2>Monatsanfangs-Übersicht</h2>
<p class="hint">
Stand: Ende des Vortags (letzter Tag des Vormonats) — Buchungen am 1. eines
Monats zählen bewusst noch nicht zum "Anfangs"-Saldo, sonst würde der
Monatsanfang durch Buchungen des laufenden Monats verzerrt.
</p>
<table>
<thead>
<tr>
<th>Monat</th>
{% for account in accounts %}<th>{{ account.name }}</th>{% endfor %}
<th>Gesamt</th>
</tr>
</thead>
<tbody>
{% for row in months %}
<tr data-month="{{ row['month'].isoformat() }}">
<td>{{ row['month'].strftime('%m.%Y') }}</td>
{% for account, value in row['values'] %}
<td class="amount {{ 'neg' if value < 0 else '' }}">{{ '%.2f'|format(value) }} €</td>
{% endfor %}
<td class="amount {{ 'neg' if row['total'] < 0 else '' }}">{{ '%.2f'|format(row['total']) }} €</td>
</tr>
{% else %}
<tr><td colspan="{{ accounts|length + 2 }}">Keine bestätigten Buchungen vorhanden.</td></tr>
{% endfor %}
</tbody>
</table>
{% if accounts_without_anchor %}
<p class="hint">
Fußnote „ohne Anker — relative Werte" (Basis 0 statt Datei-Kontostand) gilt für:
{{ accounts_without_anchor|map(attribute='name')|join(', ') }}.
</p>
{% endif %}
{% endblock %}

View File

@@ -7,7 +7,7 @@ H = {"Authorization": "Bearer test-key"}
def test_pages_require_login(client):
for path in ("/", "/buchungen", "/import", "/planung", "/hilfe"):
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/hilfe"):
r = client.get(path, follow_redirects=False)
assert r.status_code in (302, 303), path
assert r.headers["location"] == "/login"
@@ -15,7 +15,7 @@ def test_pages_require_login(client):
def test_pages_render_after_login(client):
client.post("/login", data={"username": "admin", "password": "geheim"})
for path in ("/", "/buchungen", "/import", "/planung", "/hilfe"):
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/hilfe"):
r = client.get(path)
assert r.status_code == 200
assert "Finanzberatung" in r.text
@@ -193,3 +193,78 @@ def test_planning_page_shows_empty_suggestions_hint(client):
assert r.status_code == 200
assert "Vorschläge aus Buchungen" in r.text
assert "Keine Vorschläge" in r.text
def test_salden_page_stichtag_and_month_overview(client, db):
from app.engine.loans import add_months
from app.models.tables import Transaction
# Drei Monate relativ zu "heute" verwenden statt fester Kalenderdaten,
# damit der Test unabhaengig vom Ausfuehrungsdatum immer genau 3
# Monatsanfangs-Zeilen (frühester Buchungsmonat bis heute) erzeugt.
today = date.today()
m0 = date(today.year, today.month, 1)
m1 = add_months(m0, -1)
m2 = add_months(m0, -2)
acc = Account(bank="dkb", iban="DE-SALDEN-1", name="Salden-Konto", type="giro")
db.add(acc)
db.flush()
acc.anchor_date = m2
acc.anchor_balance = Decimal("1000.00")
db.flush()
db.add_all([
Transaction(account_id=acc.id, booking_date=m2 + timedelta(days=5),
amount=Decimal("111.11"), purpose="SALDEN-TEST-1",
counterparty="X", status="confirmed", dedup_hash="salden-h1"),
Transaction(account_id=acc.id, booking_date=m1 + timedelta(days=3),
amount=Decimal("22.22"), purpose="SALDEN-TEST-2",
counterparty="X", status="confirmed", dedup_hash="salden-h2"),
Transaction(account_id=acc.id, booking_date=m0 + timedelta(days=2),
amount=Decimal("-33.33"), purpose="SALDEN-TEST-3",
counterparty="X", status="confirmed", dedup_hash="salden-h3"),
])
db.commit()
client.post("/login", data={"username": "admin", "password": "geheim"})
# Stichtag zwischen Buchung 2 und Buchung 3: Saldo = Anker + Buchung1 + Buchung2.
stichtag = m1 + timedelta(days=10)
expected_stichtag = Decimal("1000.00") + Decimal("111.11") + Decimal("22.22")
r = client.get(f"/salden?stichtag={stichtag.isoformat()}")
assert r.status_code == 200
assert f"{expected_stichtag:.2f}" in r.text
# Monatsuebersicht: genau 3 Monatszeilen (m2, m1, m0).
assert r.text.count('data-month="') == 3
for m in (m0, m1, m2):
assert f'data-month="{m.isoformat()}"' in r.text
# Monatsanfangs-Saldo fuer m1 = Stand Ende Vortag von m1 (letzter Tag von
# m2), d.h. NACH Buchung 1 (in m2), aber VOR Buchung 2 (die faellt erst
# in m1 selbst und darf den Monatsanfang nicht verfaelschen).
expected_m1_start = Decimal("1000.00") + Decimal("111.11")
assert f"{expected_m1_start:.2f}" in r.text
def test_salden_stichtag_garbage_falls_back_to_today(client):
# Lenient wie die Filter auf /buchungen: ein kaputter stichtag-Parameter
# darf nie zu 422/500 fuehren, sondern degradiert auf "heute".
client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get("/salden?stichtag=garbage")
assert r.status_code == 200
assert date.today().strftime('%d.%m.%Y') in r.text
def test_salden_shows_ohne_anker_footnote(client, db):
# UX-Regel: Konten ohne Anker sind immer sichtbar als relative Werte
# gekennzeichnet, nicht stillschweigend wie geankerte Konten dargestellt.
acc = Account(bank="vr", iban="DE-SALDEN-2", name="Ohne-Anker-Konto", type="giro")
db.add(acc)
db.commit()
client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get("/salden")
assert r.status_code == 200
assert "ohne Anker" in r.text and "relative Werte" in r.text