diff --git a/finance/app/routers/gui.py b/finance/app/routers/gui.py index 80b8213..d9ef6c6 100644 --- a/finance/app/routers/gui.py +++ b/finance/app/routers/gui.py @@ -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", { diff --git a/finance/app/templates/base.html b/finance/app/templates/base.html index 8d2ffbd..0fa0915 100644 --- a/finance/app/templates/base.html +++ b/finance/app/templates/base.html @@ -12,6 +12,7 @@ Übersicht Import Buchungen + Salden Planung Hilfe Grafana diff --git a/finance/app/templates/hilfe.html b/finance/app/templates/hilfe.html index f306f4e..7b874ed 100644 --- a/finance/app/templates/hilfe.html +++ b/finance/app/templates/hilfe.html @@ -35,6 +35,15 @@ seine Buchungen), z. B. um einen Auszug mit einem verbesserten Parser neu zu importieren; einzelne Buchungen bleiben unlöschbar. +
  • + Salden-Seite: 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. +
  • Kategorien & Regeln: Buchungen lassen sich kategorisieren; eine Regel ("Muster im Text → Kategorie") kategorisiert künftige Importe diff --git a/finance/app/templates/salden.html b/finance/app/templates/salden.html new file mode 100644 index 0000000..c3b78d4 --- /dev/null +++ b/finance/app/templates/salden.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} +{% block title %}Salden – Finanzberatung{% endblock %} +{% block content %} +

    Salden

    + +

    Salden am {{ stichtag.strftime('%d.%m.%Y') }}

    +
    + + +
    + + + + + + + {% for account, balance in balances %} + + + + + {% else %} + + {% endfor %} + + + + + +
    KontoSaldo
    {{ '%.2f'|format(balance) }} €
    Keine Konten angelegt.
    Gesamt{{ '%.2f'|format(total) }} €
    + +

    Monatsanfangs-Übersicht

    +

    + 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. +

    + + + + + {% for account in accounts %}{% endfor %} + + + + + {% for row in months %} + + + {% for account, value in row['values'] %} + + {% endfor %} + + + {% else %} + + {% endfor %} + +
    Monat{{ account.name }}Gesamt
    {{ row['month'].strftime('%m.%Y') }}{{ '%.2f'|format(value) }} €{{ '%.2f'|format(row['total']) }} €
    Keine bestätigten Buchungen vorhanden.
    + +{% if accounts_without_anchor %} +

    + Fußnote „ohne Anker — relative Werte" (Basis 0 statt Datei-Kontostand) gilt für: + {{ accounts_without_anchor|map(attribute='name')|join(', ') }}. +

    +{% endif %} +{% endblock %} diff --git a/finance/tests/test_gui.py b/finance/tests/test_gui.py index 1b73ba8..b01869d 100644 --- a/finance/tests/test_gui.py +++ b/finance/tests/test_gui.py @@ -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