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:
@@ -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
|
||||
Reference in New Issue
Block a user