feat(reports): Auswertungen, Kalender, Budgets und Export

Backend:
- Gemeinsame Bewegungsschicht flows(), auf der alle Berichte aufbauen; Plan und
  Ist bleiben dabei getrennt
- Forecast, Kategorien mit Drilldown, Abo-Übersicht, Jahresvergleich,
  Cashflow-Kalender, Budget-Ampel, Sparziel-Fortschritt, gebündeltes Dashboard
- Budgetübertrag über Monatsgrenzen, Budgets auf Oberkategorien schließen
  Unterkategorien ein
- Export als CSV (BOM, Semikolon, deutsches Dezimaltrennzeichen) und XLSX mit
  typisierten Beträgen

Frontend:
- Dashboard, Cashflow-Kalender mit Bestätigen direkt am Tag, Budget-, Sparziel-
  und Auswertungsseite
- Diagrammpalette gegen beide Flächen auf Kontrast und Farbfehlsichtigkeit
  geprüft; Grün/Rot als Serienpaar verworfen
- Einnahmen/Ausgaben und kumulierter Saldo in getrennten Diagrammen statt auf
  zwei Größenachsen
- Recharts in einen eigenen Chunk ausgelagert

27 neue Backend-Tests (235 gesamt), 16 neue Frontend-Tests (65 gesamt)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
This commit is contained in:
moneyfy
2026-09-09 16:50:22 +02:00
co-authored by Claude Opus 5
parent 8d6fcfeb58
commit 0adf154049
31 changed files with 5721 additions and 121 deletions
+623
View File
@@ -0,0 +1,623 @@
"""Integrationstests der Auswertungen."""
import io
from httpx import AsyncClient
from openpyxl import load_workbook
async def posten(client: AsyncClient, seeded: dict, **overrides) -> dict:
payload = {
"kind": "expense",
"title": "Netflix",
"category_id": seeded["streaming"],
"account_id": seeded["account_id"],
"amount": "13.99",
"rrule": "FREQ=MONTHLY;BYMONTHDAY=15",
"dtstart": "2026-01-15",
"business_day_shift": "none",
}
payload.update(overrides)
antwort = await client.post("/api/recurrences", json=payload)
assert antwort.status_code == 201, antwort.text
return antwort.json()
async def haushalt(client: AsyncClient, seeded: dict) -> None:
"""Ein realistisches Grundgerüst: Gehalt, Miete, Abo, jährliche Versicherung."""
await posten(
client,
seeded,
title="Gehalt",
kind="income",
category_id=seeded["gehalt"],
amount="3200.00",
rrule="FREQ=MONTHLY;BYMONTHDAY=28",
dtstart="2026-01-28",
)
await posten(
client,
seeded,
title="Miete",
category_id=seeded["miete"],
amount="950.00",
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
dtstart="2026-01-01",
)
await posten(client, seeded)
await posten(
client,
seeded,
title="Kfz-Versicherung",
category_id=seeded["miete"],
amount="612.00",
rrule="FREQ=YEARLY;BYMONTH=7;BYMONTHDAY=1",
dtstart="2026-07-01",
reserve_enabled=True,
)
# --- Forecast ------------------------------------------------------------------
async def test_forecast_setzt_jaehrliche_posten_in_den_richtigen_monat(
auth_client: AsyncClient, seeded: dict
) -> None:
"""Akzeptanzkriterium: der jährliche Posten taucht nur im Juli auf."""
await haushalt(auth_client, seeded)
antwort = await auth_client.get(
"/api/reports/forecast", params={"months": 12, "start": "2026-01-01"}
)
assert antwort.status_code == 200
monate = antwort.json()["months"]
assert len(monate) == 12
assert monate[0]["month"] == "2026-01-01"
assert monate[11]["month"] == "2026-12-01"
nach_monat = {monat["month"]: monat for monat in monate}
# Januar bis Juni: Miete 950 + Netflix 13,99
assert nach_monat["2026-01-01"]["expenses"] == "963.99"
assert nach_monat["2026-06-01"]["expenses"] == "963.99"
# Juli zusätzlich die Jahresprämie
assert nach_monat["2026-07-01"]["expenses"] == "1575.99"
assert nach_monat["2026-08-01"]["expenses"] == "963.99"
async def test_forecast_summiert_den_kontostand_auf(auth_client: AsyncClient, seeded: dict) -> None:
await haushalt(auth_client, seeded)
monate = (
await auth_client.get("/api/reports/forecast", params={"months": 3, "start": "2026-01-01"})
).json()["months"]
# Eröffnungssaldo 1000 plus dem Saldo jedes Monats.
assert monate[0]["balance"] == "2236.01"
assert monate[0]["cumulative_balance"] == "3236.01"
assert monate[1]["cumulative_balance"] == "5472.02"
assert monate[2]["cumulative_balance"] == "7708.03"
async def test_forecast_ohne_daten_ist_flach(auth_client: AsyncClient, seeded: dict) -> None:
monate = (await auth_client.get("/api/reports/forecast", params={"months": 2})).json()["months"]
assert all(monat["income"] == "0.00" for monat in monate)
assert all(monat["cumulative_balance"] == "1000.00" for monat in monate)
# --- Kategorien ----------------------------------------------------------------
async def test_kategorien_mit_drilldown(auth_client: AsyncClient, seeded: dict) -> None:
await haushalt(auth_client, seeded)
bericht = (
await auth_client.get(
"/api/reports/categories", params={"from": "2026-03-01", "to": "2026-03-31"}
)
).json()
assert bericht["total"] == "963.99"
namen = {gruppe["name"]: gruppe for gruppe in bericht["categories"]}
assert namen["Wohnen"]["amount"] == "950.00"
# Der Drilldown zeigt die Unterkategorie.
assert [kind["name"] for kind in namen["Wohnen"]["children"]] == ["Miete"]
assert namen["Abos & Medien"]["amount"] == "13.99"
async def test_kategorien_koennen_einkuenfte_zeigen(auth_client: AsyncClient, seeded: dict) -> None:
await haushalt(auth_client, seeded)
bericht = (
await auth_client.get(
"/api/reports/categories",
params={"from": "2026-03-01", "to": "2026-03-31", "kind": "income"},
)
).json()
assert bericht["total"] == "3200.00"
assert bericht["categories"][0]["name"] == "Einkünfte"
async def test_kategorien_weisen_verdrehten_zeitraum_ab(
auth_client: AsyncClient, seeded: dict
) -> None:
antwort = await auth_client.get(
"/api/reports/categories", params={"from": "2026-03-31", "to": "2026-03-01"}
)
assert antwort.status_code == 422
assert antwort.json()["code"] == "invalid_date_range"
# --- Abo-Übersicht -------------------------------------------------------------
async def test_aboübersicht_mit_jahreskosten(auth_client: AsyncClient, seeded: dict) -> None:
await haushalt(auth_client, seeded)
bericht = (await auth_client.get("/api/reports/subscriptions")).json()
nach_titel = {eintrag["title"]: eintrag for eintrag in bericht["entries"]}
assert nach_titel["Miete"]["annual_cost"] == "11400.00"
assert nach_titel["Netflix"]["annual_cost"] == "167.88"
assert nach_titel["Kfz-Versicherung"]["annual_cost"] == "612.00"
assert nach_titel["Kfz-Versicherung"]["monthly_cost"] == "51.00"
# Absteigend nach Jahreskosten sortiert.
assert next(eintrag["title"] for eintrag in bericht["entries"]) == "Miete"
assert bericht["total_annual"] == "12179.88"
assert bericht["total_monthly"] == "1014.99"
async def test_raten_zaehlen_nicht_in_die_abosumme(auth_client: AsyncClient, seeded: dict) -> None:
await posten(auth_client, seeded)
await posten(
auth_client,
seeded,
title="Autokredit",
category_id=seeded["kredite"],
amount="250.00",
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
dtstart="2026-01-01",
installments_total=36,
)
bericht = (await auth_client.get("/api/reports/subscriptions")).json()
kredit = next(e for e in bericht["entries"] if e["title"] == "Autokredit")
assert kredit["is_installment"] is True
# Nur Netflix zählt in die Summe.
assert bericht["total_annual"] == "167.88"
async def test_ablaufende_kuendigungsfrist_wird_gemeldet(
auth_client: AsyncClient, seeded: dict
) -> None:
from datetime import date, timedelta
# Vertrag so legen, dass der Kündigungstermin in 30 Tagen liegt.
heute = date.today()
laufzeitende = heute + timedelta(days=30 + 90)
vertragsbeginn = laufzeitende + timedelta(days=1) - timedelta(days=365)
await posten(
auth_client,
seeded,
title="Handyvertrag",
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
dtstart=vertragsbeginn.isoformat(),
contract_start=vertragsbeginn.isoformat(),
contract_min_term_months=12,
contract_notice_period_days=90,
)
bericht = (await auth_client.get("/api/reports/subscriptions")).json()
fristen = {eintrag["title"] for eintrag in bericht["upcoming_deadlines"]}
assert "Handyvertrag" in fristen
eintrag = next(e for e in bericht["entries"] if e["title"] == "Handyvertrag")
assert 25 <= eintrag["days_until_notice"] <= 35
# --- Jahresvergleich -----------------------------------------------------------
async def test_jahresvergleich(auth_client: AsyncClient, seeded: dict) -> None:
# Ein Abo, das erst 2026 beginnt 2025 gibt es also nichts.
await posten(auth_client, seeded, amount="10.00")
bericht = (await auth_client.get("/api/reports/year-comparison", params={"year": 2026})).json()
assert bericht["year"] == 2026
zeile = next(z for z in bericht["rows"] if z["name"] == "Abos & Medien")
assert zeile["current"] == "120.00" # zwölf Monate à 10
assert zeile["previous"] == "0.00"
assert zeile["delta"] == "120.00"
# --- Kalender ------------------------------------------------------------------
async def test_kalender_zeigt_faelligkeiten_und_verlauf(
auth_client: AsyncClient, seeded: dict
) -> None:
await haushalt(auth_client, seeded)
kalender = (
await auth_client.get("/api/reports/calendar", params={"month": "2026-03-01"})
).json()
assert kalender["month"] == "2026-03-01"
assert len(kalender["days"]) == 31
nach_tag = {tag["date"]: tag for tag in kalender["days"]}
# Am 1. März ist die Miete fällig.
assert [eintrag["title"] for eintrag in nach_tag["2026-03-01"]["entries"]] == ["Miete"]
assert nach_tag["2026-03-01"]["net"] == "-950.00"
# Der 15. trägt Netflix, der 28. das Gehalt.
assert [eintrag["title"] for eintrag in nach_tag["2026-03-15"]["entries"]] == ["Netflix"]
assert [eintrag["title"] for eintrag in nach_tag["2026-03-28"]["entries"]] == ["Gehalt"]
# Der laufende Kontostand schreibt sich über den Monat fort.
# Eröffnung 1000, nach der Miete am 1. noch 50, nach dem Gehalt am 28. wieder hoch.
assert nach_tag["2026-03-01"]["running_balance"] == "50.00"
assert nach_tag["2026-03-15"]["running_balance"] == "36.01"
assert nach_tag["2026-03-28"]["running_balance"] == "3236.01"
assert kalender["closing_balance"] == nach_tag["2026-03-31"]["running_balance"]
assert kalender["lowest_balance_on"] is not None
async def test_kalender_kennzeichnet_werktage(auth_client: AsyncClient, seeded: dict) -> None:
kalender = (
await auth_client.get("/api/reports/calendar", params={"month": "2026-04-01"})
).json()
nach_tag = {tag["date"]: tag for tag in kalender["days"]}
assert nach_tag["2026-04-03"]["is_business_day"] is False # Karfreitag
assert nach_tag["2026-04-06"]["is_business_day"] is False # Ostermontag
assert nach_tag["2026-04-07"]["is_business_day"] is True
async def test_kalender_liefert_das_nominale_datum_mit(
auth_client: AsyncClient, seeded: dict
) -> None:
"""Der Kalender muss Bestätigen ermöglichen dafür braucht er den Schlüssel."""
eintrag = await posten(
auth_client,
seeded,
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
dtstart="2026-02-01",
business_day_shift="next",
)
kalender = (
await auth_client.get("/api/reports/calendar", params={"month": "2026-02-01"})
).json()
nach_tag = {tag["date"]: tag for tag in kalender["days"]}
# 01.02.2026 ist ein Sonntag; gezahlt wird am 02.02.
assert nach_tag["2026-02-01"]["entries"] == []
posten_eintrag = nach_tag["2026-02-02"]["entries"][0]
assert posten_eintrag["occurrence_date"] == "2026-02-01"
assert posten_eintrag["recurrence_id"] == eintrag["id"]
assert posten_eintrag["status"] == "planned"
# --- Budget-Ampel --------------------------------------------------------------
async def test_budget_ampel_schaltet_richtig(auth_client: AsyncClient, seeded: dict) -> None:
await auth_client.post(
"/api/budgets",
json={
"category_id": seeded["lebensmittel"],
"period_month": "2026-03-01",
"limit_amount": "400.00",
},
)
async def verbrauche(betrag: str) -> None:
await auth_client.post(
"/api/transactions",
json={
"kind": "expense",
"title": "Einkauf",
"category_id": seeded["lebensmittel"],
"account_id": seeded["account_id"],
"amount": betrag,
"booking_date": "2026-03-05",
},
)
async def status() -> dict:
antwort = await auth_client.get("/api/reports/budgets", params={"month": "2026-03-01"})
return antwort.json()[0]
await verbrauche("100.00")
assert (await status())["state"] == "ok" # 25 %
await verbrauche("219.00")
assert (await status())["state"] == "ok" # 79,75 %
await verbrauche("2.00")
aktuell = await status()
assert aktuell["state"] == "warning" # 80,25 %
assert aktuell["spent"] == "321.00"
await verbrauche("100.00")
ueberschritten = await status()
assert ueberschritten["state"] == "exceeded"
assert ueberschritten["remaining"] == "-21.00"
async def test_budget_auf_oberkategorie_umfasst_unterkategorien(
auth_client: AsyncClient, seeded: dict
) -> None:
baum = (await auth_client.get("/api/categories")).json()
lebenshaltung = next(k for k in baum if k["name"] == "Lebenshaltung")
await auth_client.post(
"/api/budgets",
json={
"category_id": lebenshaltung["id"],
"period_month": "2026-03-01",
"limit_amount": "500.00",
},
)
await auth_client.post(
"/api/transactions",
json={
"kind": "expense",
"title": "Einkauf",
"category_id": seeded["lebensmittel"],
"account_id": seeded["account_id"],
"amount": "120.00",
"booking_date": "2026-03-05",
},
)
status = (await auth_client.get("/api/reports/budgets", params={"month": "2026-03-01"})).json()
assert status[0]["spent"] == "120.00"
async def test_budgetvorlage_gilt_ab_dem_monat(auth_client: AsyncClient, seeded: dict) -> None:
await auth_client.post(
"/api/budget-templates",
json={
"category_id": seeded["lebensmittel"],
"valid_from": "2026-02-01",
"limit_amount": "450.00",
},
)
januar = (await auth_client.get("/api/reports/budgets", params={"month": "2026-01-01"})).json()
maerz = (await auth_client.get("/api/reports/budgets", params={"month": "2026-03-01"})).json()
assert januar == []
assert maerz[0]["limit_amount"] == "450.00"
assert maerz[0]["is_template"] is True
async def test_rollover_uebertraegt_den_rest(auth_client: AsyncClient, seeded: dict) -> None:
for monat in ("2026-01-01", "2026-02-01"):
await auth_client.post(
"/api/budgets",
json={
"category_id": seeded["lebensmittel"],
"period_month": monat,
"limit_amount": "400.00",
"rollover": True,
},
)
await auth_client.post(
"/api/transactions",
json={
"kind": "expense",
"title": "Einkauf Januar",
"category_id": seeded["lebensmittel"],
"account_id": seeded["account_id"],
"amount": "300.00",
"booking_date": "2026-01-10",
},
)
februar = (await auth_client.get("/api/reports/budgets", params={"month": "2026-02-01"})).json()
assert februar[0]["carried_over"] == "100.00"
assert februar[0]["available"] == "500.00"
# --- Sparziele -----------------------------------------------------------------
async def test_sparziel_fortschritt_und_rate(auth_client: AsyncClient) -> None:
from datetime import date
from dateutil.relativedelta import relativedelta
ziel_datum = date.today() + relativedelta(months=10)
await auth_client.post(
"/api/savings-goals",
json={
"name": "Neues Fahrrad",
"target_amount": "2000.00",
"current_amount": "500.00",
"target_date": ziel_datum.isoformat(),
"monthly_contribution": "100.00",
},
)
fortschritt = (await auth_client.get("/api/reports/savings-goals")).json()[0]
assert fortschritt["remaining_amount"] == "1500.00"
assert fortschritt["ratio"] == 0.25
assert fortschritt["months_left"] == 10
assert fortschritt["required_monthly"] == "150.00"
# 100 Euro reichen bei 150 nötigen nicht.
assert fortschritt["is_on_track"] is False
async def test_erreichtes_sparziel_braucht_keine_rate(auth_client: AsyncClient) -> None:
from datetime import date
await auth_client.post(
"/api/savings-goals",
json={
"name": "Urlaubskasse",
"target_amount": "1000.00",
"current_amount": "1000.00",
"target_date": (date.today().replace(year=date.today().year + 1)).isoformat(),
},
)
fortschritt = (await auth_client.get("/api/reports/savings-goals")).json()[0]
assert fortschritt["ratio"] == 1.0
assert fortschritt["required_monthly"] == "0.00"
# --- Dashboard -----------------------------------------------------------------
async def test_dashboard_buendelt_alles(auth_client: AsyncClient, seeded: dict) -> None:
await haushalt(auth_client, seeded)
dashboard = (
await auth_client.get("/api/reports/dashboard", params={"month": "2026-03-01"})
).json()
assert dashboard["month"]["month"] == "2026-03-01"
assert dashboard["month"]["available_after_fixed"] == "2185.01"
assert len(dashboard["forecast"]) == 12
assert len(dashboard["categories"]) == 2
assert dashboard["total_balance"] == "1000.00"
assert dashboard["budgets"] == []
assert dashboard["goals"] == []
# --- Export --------------------------------------------------------------------
async def test_export_buchungen_als_csv(auth_client: AsyncClient, seeded: dict) -> None:
await auth_client.post(
"/api/transactions",
json={
"kind": "expense",
"title": "Wocheneinkauf",
"category_id": seeded["lebensmittel"],
"account_id": seeded["account_id"],
"amount": "84.30",
"booking_date": "2026-03-05",
},
)
antwort = await auth_client.get(
"/api/export/transactions",
params={"format": "csv", "from": "2026-01-01", "to": "2026-12-31"},
)
assert antwort.status_code == 200
assert antwort.headers["content-type"].startswith("text/csv")
assert "moneyfy-buchungen" in antwort.headers["content-disposition"]
inhalt = antwort.content
# Excel erkennt UTF-8 nur mit BOM zuverlässig.
assert inhalt.startswith(b"\xef\xbb\xbf")
text = inhalt.decode("utf-8-sig")
zeilen = text.strip().splitlines()
assert zeilen[0].startswith("Datum;Titel;Richtung;Betrag")
# Deutsches Dezimaltrennzeichen.
assert "84,30" in zeilen[1]
assert "Lebenshaltung · Lebensmittel" in zeilen[1]
async def test_export_buchungen_als_xlsx(auth_client: AsyncClient, seeded: dict) -> None:
await auth_client.post(
"/api/transactions",
json={
"kind": "expense",
"title": "Wocheneinkauf",
"category_id": seeded["lebensmittel"],
"account_id": seeded["account_id"],
"amount": "84.30",
"booking_date": "2026-03-05",
},
)
antwort = await auth_client.get("/api/export/transactions", params={"format": "xlsx"})
assert antwort.status_code == 200
assert antwort.content[:2] == b"PK"
mappe = load_workbook(io.BytesIO(antwort.content))
blatt = mappe["Buchungen"]
kopf = [zelle.value for zelle in blatt[1]]
assert kopf[:4] == ["Datum", "Titel", "Richtung", "Betrag"]
# Der Betrag ist eine Zahl, keine Zeichenkette Excel kann damit rechnen.
assert blatt.cell(row=2, column=4).value == 84.3
assert "" in blatt.cell(row=2, column=4).number_format
async def test_export_posten(auth_client: AsyncClient, seeded: dict) -> None:
await haushalt(auth_client, seeded)
antwort = await auth_client.get("/api/export/recurrences", params={"format": "xlsx"})
mappe = load_workbook(io.BytesIO(antwort.content))
blatt = mappe["Posten"]
titel = [blatt.cell(row=zeile, column=1).value for zeile in range(2, blatt.max_row + 1)]
assert set(titel) == {"Gehalt", "Kfz-Versicherung", "Miete", "Netflix"}
async def test_export_monatsauswertung(auth_client: AsyncClient, seeded: dict) -> None:
await haushalt(auth_client, seeded)
antwort = await auth_client.get(
"/api/export/month", params={"format": "xlsx", "month": "2026-03-01"}
)
mappe = load_workbook(io.BytesIO(antwort.content))
assert mappe.sheetnames == ["Bewegungen", "Kennzahlen"]
bewegungen = mappe["Bewegungen"]
titel = [bewegungen.cell(row=zeile, column=2).value for zeile in range(2, 5)]
assert set(titel) == {"Miete", "Netflix", "Gehalt"}
kennzahlen = {
mappe["Kennzahlen"].cell(row=zeile, column=1).value: mappe["Kennzahlen"]
.cell(row=zeile, column=2)
.value
for zeile in range(2, mappe["Kennzahlen"].max_row + 1)
}
assert kennzahlen["Ausgaben (Plan)"] == 963.99
assert kennzahlen["Einnahmen (Plan)"] == 3200.0
async def test_export_monatsauswertung_als_csv(auth_client: AsyncClient, seeded: dict) -> None:
await haushalt(auth_client, seeded)
antwort = await auth_client.get(
"/api/export/month", params={"format": "csv", "month": "2026-03-01"}
)
text = antwort.content.decode("utf-8-sig")
assert "Datum;Titel;Kategorie" in text
# Die Kennzahlen folgen nach einer Leerzeile im selben Dokument.
assert "Kennzahl;Betrag" in text
assert "Verfügbar nach Fixkosten" in text
async def test_export_weist_verdrehten_zeitraum_ab(auth_client: AsyncClient) -> None:
antwort = await auth_client.get(
"/api/export/transactions", params={"from": "2026-12-31", "to": "2026-01-01"}
)
assert antwort.status_code == 422
assert antwort.json()["code"] == "invalid_date_range"
async def test_export_verlangt_anmeldung(client: AsyncClient) -> None:
assert (await client.get("/api/export/transactions")).status_code == 401