diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index f805e4a..236b75f 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -171,7 +171,9 @@ def expense_share( geplant = sum(planned.get(i, 0) for i in ids) rows.append({"name": name, "color": color, "amount": val, "planned": geplant, - "over": geplant > 0 and val > geplant}) + "over": geplant > 0 and val > geplant, + # Womit sich die Zeile aufschluesseln laesst. + "ids": list(ids)}) rows.sort(key=lambda r: r["amount"], reverse=True) # Das Geplante ueber ALLE Ausgaben-Einheiten, nicht nur die mit Ausgaben: @@ -194,6 +196,49 @@ def expense_share( } +@router.get("/expense-share/buchungen") +def expense_share_rows( + ids: list[int] = Query(default=[]), + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> dict: + """Die einzelnen Buchungen hinter einem Anteil. + + Eine Zahl, die man nicht aufschluesseln kann, muss man glauben. Diese hier + kommt aus derselben Stelle wie die Summe auf der Karte – was hier steht, + ist genau das, was dort gezaehlt wurde, nicht eine zweite Suche mit + aehnlichen Bedingungen. + """ + base = base_currency(db, user) + label = _label(db, user) + start, end = budget.period_bounds(label, _start_day(db, user)) + gesucht = set(ids) + + konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)} + zeilen = [] + for cat_id, txn, cents in budget.expense_rows(db, user, label, start, end, base): + if gesucht and cat_id not in gesucht: + continue + konto = konten.get(txn.account_id) + zeilen.append({ + "id": txn.id, + "date": txn.date.isoformat(), + "title": txn.payee or txn.description or "", + "account": konto.name if konto else "", + # Beides: Der Betrag, wie er auf dem Konto steht, UND der, mit dem + # gerechnet wurde. Weichen sie ab, liegt es an der Umrechnung – und + # das sieht man dann sofort. + "amount_cents": txn.amount_cents, + "currency": konto.currency if konto else base, + "base_cents": cents, + "type": txn.type.value, + "budget_period_manuell": txn.budget_period is not None, + }) + zeilen.sort(key=lambda z: (z["date"], z["id"])) + return {"rows": zeilen, "total": sum(z["base_cents"] for z in zeilen), + "currency": base, "von": start.isoformat(), "bis": end.isoformat()} + + @router.get("/budget-progress") def budget_progress( group_by: str = Query(default="group"), diff --git a/backend/app/services/budget.py b/backend/app/services/budget.py index afe1755..be889fc 100644 --- a/backend/app/services/budget.py +++ b/backend/app/services/budget.py @@ -218,18 +218,24 @@ def _neutrale_kategorien(db: Session, user: User) -> set[int]: } -def actual_expense_by_category(db: Session, user: User, label: str, start: date, end: date, - base: str) -> dict[int, int]: - """Ist-Ausgaben je Kategorie in Hauptwährung (inkl. budgetierter Sparen-Transfers).""" +def expense_rows(db: Session, user: User, label: str, start: date, end: date, + base: str) -> list[tuple[int, Transaction, int]]: + """Die einzelnen Buchungen hinter den Ist-Ausgaben: (Kategorie, Buchung, Betrag). + + Die Summe darueber IST der Ist-Wert – beides kommt aus dieser einen Stelle, + damit eine Aufstellung nie etwas anderes ergeben kann als die Zahl, die sie + erklaeren soll. + """ from .uebertraege import verknuepfte_ids - result: dict[int, int] = defaultdict(int) neutral = _neutrale_kategorien(db, user) # Verknüpfte Buchungen sind zwei Seiten derselben Bewegung zwischen eigenen # Konten – Geld wurde verschoben, nicht ausgegeben. Übersprungen, nicht # umkategorisiert: Löst man die Verknüpfung, zählt die Buchung mit ihrer # alten Kategorie sofort wieder mit. verknuepft = verknuepfte_ids(db, user) + zeilen: list[tuple[int, Transaction, int]] = [] + expenses = ( db.query(Transaction) .filter( @@ -243,7 +249,7 @@ def actual_expense_by_category(db: Session, user: User, label: str, start: date, for t in expenses: if t.category_id in neutral or t.id in verknuepft: continue - result[t.category_id] += _to_base(db, t.amount_eur_cents, base, t.date) + zeilen.append((t.category_id, t, _to_base(db, t.amount_eur_cents, base, t.date))) # Transfers auf Sparen/Depot zählen (sofern nicht auf "neutral" gesetzt) als # budgetierte Ausgabe gegen budget_category_id. @@ -262,7 +268,16 @@ def actual_expense_by_category(db: Session, user: User, label: str, start: date, continue to_acc = db.get(Account, t.to_account_id) if t.to_account_id else None if to_acc is not None and to_acc.type in SAVINGS_ACCOUNT_TYPES: - result[t.budget_category_id] += _to_base(db, t.amount_eur_cents, base, t.date) + zeilen.append((t.budget_category_id, t, _to_base(db, t.amount_eur_cents, base, t.date))) + return zeilen + + +def actual_expense_by_category(db: Session, user: User, label: str, start: date, end: date, + base: str) -> dict[int, int]: + """Ist-Ausgaben je Kategorie in Hauptwährung (inkl. budgetierter Sparen-Transfers).""" + result: dict[int, int] = defaultdict(int) + for cat_id, _txn, cents in expense_rows(db, user, label, start, end, base): + result[cat_id] += cents return result diff --git a/backend/tests/test_budget.py b/backend/tests/test_budget.py index 9abe731..f29a999 100644 --- a/backend/tests/test_budget.py +++ b/backend/tests/test_budget.py @@ -309,3 +309,63 @@ def test_der_eigene_monat_wird_nicht_festgeschrieben(admin): assert tx["budget_period"] == "2026-10" assert b.period_of(date(2026, 9, 26), 25) == "2026-10" admin.put("/api/settings/budget_start_day", params={"value": "1"}) + + +def test_aufstellung_ergibt_genau_die_summe_der_karte(admin): + """Die Aufstellung kommt aus derselben Stelle wie die Summe – sie kann also + gar nicht etwas anderes zeigen als das, was gezaehlt wurde.""" + import secrets + + admin.put("/api/settings/budget_start_day", params={"value": "1"}) + today = date.today() + acc = admin.post("/api/accounts", json={ + "name": f"Aufstellung {secrets.token_hex(3)}", "currency": "EUR"}).json() + leb = _lebensmittel(admin) + betraege = [1234, 5678, 999] + for c in betraege: + admin.post("/api/transactions", json={ + "type": "expense", "date": str(today), "account_id": acc["id"], + "amount_cents": c, "category_id": leb, "payee": f"Laden {c}"}) + + karte = admin.get("/api/dashboard/expense-share?group_by=category").json() + zeile = next(r for r in karte["rows"] if r["name"] == "Lebensmittel") + assert zeile["ids"], "ohne die Kategorien laesst sich nichts aufschluesseln" + + ids = "&".join(f"ids={i}" for i in zeile["ids"]) + auf = admin.get(f"/api/dashboard/expense-share/buchungen?{ids}").json() + assert auf["total"] == zeile["amount"], "Aufstellung und Karte muessen dasselbe ergeben" + assert sum(r["base_cents"] for r in auf["rows"]) == zeile["amount"] + assert auf["von"] == karte["von"] and auf["bis"] == karte["bis"] + + # Unsere drei Buchungen stehen darin, jede genau einmal. + unsere = [r for r in auf["rows"] if r["title"].startswith("Laden ")] + assert sorted(r["amount_cents"] for r in unsere) == sorted(betraege) + + +def test_aufstellung_zeigt_eine_verschobene_buchung_im_neuen_monat(admin): + """Wandert eine Buchung, wandert sie auch in der Aufstellung mit.""" + import secrets + + from app.services import budget as b + + admin.put("/api/settings/budget_start_day", params={"value": "1"}) + today = date.today() + hier = b.period_of(today, 1) + acc = admin.post("/api/accounts", json={ + "name": f"Wander {secrets.token_hex(3)}", "currency": "EUR"}).json() + leb = _lebensmittel(admin) + tx = admin.post("/api/transactions", json={ + "type": "expense", "date": str(today), "account_id": acc["id"], + "amount_cents": 4242, "category_id": leb, "payee": "Wanderladen"}).json() + + karte = admin.get("/api/dashboard/expense-share?group_by=category").json() + ids = "&".join(f"ids={i}" for i in + next(r for r in karte["rows"] if r["name"] == "Lebensmittel")["ids"]) + assert any(r["id"] == tx["id"] for r in + admin.get(f"/api/dashboard/expense-share/buchungen?{ids}").json()["rows"]) + + admin.patch(f"/api/transactions/{tx['id']}", json={"budget_period": b.shift_label(hier, 1)}) + auf = admin.get(f"/api/dashboard/expense-share/buchungen?{ids}").json() + assert not any(r["id"] == tx["id"] for r in auf["rows"]) + karte2 = admin.get("/api/dashboard/expense-share?group_by=category").json() + assert auf["total"] == next(r for r in karte2["rows"] if r["name"] == "Lebensmittel")["amount"] diff --git a/web/src/api.js b/web/src/api.js index 6484465..9eab73b 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -329,6 +329,9 @@ export const api = { resetDashboardLayout: () => request("/dashboard/layout", { method: "DELETE" }), cardBudgetDonut: (groupBy) => request(`/dashboard/budget-donut${groupBy ? `?group_by=${groupBy}` : ""}`), cardExpenseShare: (groupBy) => request(`/dashboard/expense-share${groupBy ? `?group_by=${groupBy}` : ""}`), + // Woraus ein Anteil besteht – dieselbe Stelle, die auch die Summe bildet. + cardExpenseShareRows: (ids) => + request(`/dashboard/expense-share/buchungen?${ids.map((i) => `ids=${i}`).join("&")}`), cardBudgetProgress: (groupBy) => request(`/dashboard/budget-progress${groupBy ? `?group_by=${groupBy}` : ""}`), cardIncomeExpense: () => request("/dashboard/income-expense"), cardBalances: () => request("/dashboard/balances"), diff --git a/web/src/components/Modal.jsx b/web/src/components/Modal.jsx index fe21e47..cc9e291 100644 --- a/web/src/components/Modal.jsx +++ b/web/src/components/Modal.jsx @@ -1,4 +1,5 @@ import { useRef } from "react"; +import { createPortal } from "react-dom"; import Icon from "./Icon"; @@ -9,7 +10,11 @@ export default function Modal({ title, onClose, children, footer, wide = false } // liegt das Mouseup auf dem Hintergrund; der Klick feuert dann auf dem // gemeinsamen Elternelement und schloss den Dialog mitten im Markieren. const startAussen = useRef(false); - return ( + // An den Seitenkoerper, nicht an die Stelle im Baum: Steht der Aufrufer in + // einem Element mit `transform` – jede Kachel der Uebersicht tut das –, waere + // das sonst der Bezugsrahmen fuer `position: fixed`, und der Dialog sitzt + // eingeklemmt in der Kachel statt ueber der Seite. + return createPortal(
{formatDate(data.von)} – {formatDate(data.bis)}
+ {!data.rows.length &&