feat: Salden-Seite mit Stichtag und Monatsuebersicht
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,15 +3,16 @@ from decimal import Decimal
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth import COOKIE, session_valid
|
from app.auth import COOKIE, session_valid
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
|
from app.engine.loans import add_months
|
||||||
from app.engine.recurrence import occurrences
|
from app.engine.recurrence import occurrences
|
||||||
from app.models.tables import (Account, Category, PlannedItem,
|
from app.models.tables import (Account, Category, PlannedItem,
|
||||||
ProjectionResult, RecurringItem,
|
ProjectionResult, RecurringItem,
|
||||||
ScenarioLoan, ScenarioModifier)
|
ScenarioLoan, ScenarioModifier, Transaction)
|
||||||
from app.routers.imports import list_imports as _list_imports
|
from app.routers.imports import list_imports as _list_imports
|
||||||
from app.routers.imports import preview as _preview_import
|
from app.routers.imports import preview as _preview_import
|
||||||
from app.routers.planning import list_loans as _list_loans
|
from app.routers.planning import list_loans as _list_loans
|
||||||
@@ -84,6 +85,25 @@ def _opt_date(value: str | None) -> date | None:
|
|||||||
return 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)])
|
@router.get("/", dependencies=[Depends(gui_session)])
|
||||||
def index(request: Request, session: Session = Depends(get_session)):
|
def index(request: Request, session: Session = Depends(get_session)):
|
||||||
accounts = session.execute(select(Account)).scalars().all()
|
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)])
|
@router.get("/import", dependencies=[Depends(gui_session)])
|
||||||
def import_page(request: Request, session: Session = Depends(get_session)):
|
def import_page(request: Request, session: Session = Depends(get_session)):
|
||||||
return templates.TemplateResponse(request, "import.html", {
|
return templates.TemplateResponse(request, "import.html", {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<a href="/">Übersicht</a>
|
<a href="/">Übersicht</a>
|
||||||
<a href="/import">Import</a>
|
<a href="/import">Import</a>
|
||||||
<a href="/buchungen">Buchungen</a>
|
<a href="/buchungen">Buchungen</a>
|
||||||
|
<a href="/salden">Salden</a>
|
||||||
<a href="/planung">Planung</a>
|
<a href="/planung">Planung</a>
|
||||||
<a href="/hilfe">Hilfe</a>
|
<a href="/hilfe">Hilfe</a>
|
||||||
<a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a>
|
<a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a>
|
||||||
|
|||||||
@@ -35,6 +35,15 @@
|
|||||||
seine Buchungen), z. B. um einen Auszug mit einem verbesserten Parser neu
|
seine Buchungen), z. B. um einen Auszug mit einem verbesserten Parser neu
|
||||||
zu importieren; einzelne Buchungen bleiben unlöschbar.
|
zu importieren; einzelne Buchungen bleiben unlöschbar.
|
||||||
</li>
|
</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>
|
<li>
|
||||||
<strong>Kategorien & Regeln:</strong> Buchungen lassen sich kategorisieren;
|
<strong>Kategorien & Regeln:</strong> Buchungen lassen sich kategorisieren;
|
||||||
eine Regel ("Muster im Text → Kategorie") kategorisiert künftige Importe
|
eine Regel ("Muster im Text → Kategorie") kategorisiert künftige Importe
|
||||||
|
|||||||
67
finance/app/templates/salden.html
Normal file
67
finance/app/templates/salden.html
Normal 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 %}
|
||||||
@@ -7,7 +7,7 @@ H = {"Authorization": "Bearer test-key"}
|
|||||||
|
|
||||||
|
|
||||||
def test_pages_require_login(client):
|
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)
|
r = client.get(path, follow_redirects=False)
|
||||||
assert r.status_code in (302, 303), path
|
assert r.status_code in (302, 303), path
|
||||||
assert r.headers["location"] == "/login"
|
assert r.headers["location"] == "/login"
|
||||||
@@ -15,7 +15,7 @@ def test_pages_require_login(client):
|
|||||||
|
|
||||||
def test_pages_render_after_login(client):
|
def test_pages_render_after_login(client):
|
||||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
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)
|
r = client.get(path)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert "Finanzberatung" in r.text
|
assert "Finanzberatung" in r.text
|
||||||
@@ -193,3 +193,78 @@ def test_planning_page_shows_empty_suggestions_hint(client):
|
|||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert "Vorschläge aus Buchungen" in r.text
|
assert "Vorschläge aus Buchungen" in r.text
|
||||||
assert "Keine Vorschläge" 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
|
||||||
|
|||||||
Reference in New Issue
Block a user