"""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