Eine Saeule 3a ist gesperrt – bis zur Rente, zur Selbstaendigkeit oder zur Auswanderung. Bisher zaehlte ihr Stand in dieselbe Summe wie das Girokonto, und die Antwort auf "wie viel habe ich" war um genau diesen Betrag zu hoch. Das Geld steht da und ist doch nicht da. Konten lassen sich jetzt als gebunden kennzeichnen. "Gesamt" zeigt dann nur noch, was wirklich ausgegeben werden kann; darunter steht "zusaetzlich CHF 2'004.61 gebunden" – aufklappbar, mit jedem Konto einzeln. Eine Summe, die man nicht nachrechnen kann, muss man glauben. Verschwiegen wird nichts: Der Saldo bleibt am Konto stehen, und Vermoegensverlauf wie Nettovermoegen rechnen weiter damit. Es nicht ausgeben zu koennen heisst nicht, es nicht zu besitzen – das ist der ganze Unterschied, und ein Test haelt ihn fest. Ein eigenes Kennzeichen, keine Ableitung aus dem Kontotyp: Ein frei verkaeufliches Depot und eine gesperrte Saeule 3a kommen beide als "depot" herein. Am Typ laesst sich das nicht ablesen – am ausdruecklichen Eintrag schon. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
428 lines
17 KiB
Python
428 lines
17 KiB
Python
"""Dashboard: pro Nutzer gespeichertes Karten-Layout und die Datenendpunkte je Karte."""
|
||
|
||
import json
|
||
from collections import defaultdict
|
||
from datetime import date, timedelta
|
||
|
||
from fastapi import APIRouter, Body, Depends, Query
|
||
from sqlalchemy.orm import Session
|
||
|
||
from ..database import get_db
|
||
from ..deps import get_current_user
|
||
from ..models import (
|
||
Account,
|
||
Category,
|
||
CategoryGroup,
|
||
CategoryKind,
|
||
DashboardLayout,
|
||
Transaction,
|
||
TxnType,
|
||
User,
|
||
)
|
||
from ..services import balances, budget, currency
|
||
from .accounts import base_currency
|
||
from .settings import BUDGET_START_DAY_KEY, get_user_setting
|
||
|
||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||
|
||
# Sinnvolles Standard-Layout (12-Spalten-Raster). Eine Karte je Typ.
|
||
DEFAULT_LAYOUT = [
|
||
{"i": "account-balances", "type": "account-balances", "x": 0, "y": 0, "w": 4, "h": 5},
|
||
{"i": "budget-donut", "type": "budget-donut", "x": 4, "y": 0, "w": 4, "h": 5},
|
||
{"i": "income-expense", "type": "income-expense", "x": 8, "y": 0, "w": 4, "h": 2},
|
||
{"i": "budget-progress", "type": "budget-progress", "x": 8, "y": 2, "w": 4, "h": 3},
|
||
{"i": "net-worth", "type": "net-worth", "x": 0, "y": 5, "w": 8, "h": 4},
|
||
{"i": "recent-transactions", "type": "recent-transactions", "x": 8, "y": 5, "w": 4, "h": 4},
|
||
]
|
||
|
||
|
||
def _start_day(db: Session, user: User) -> int:
|
||
return budget.start_tag(db, user)
|
||
|
||
|
||
def _label(db: Session, user: User) -> str:
|
||
return budget.current_label(_start_day(db, user))
|
||
|
||
|
||
# --- Layout ----------------------------------------------------------------
|
||
|
||
|
||
@router.get("/layout")
|
||
def get_layout(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||
row = db.query(DashboardLayout).filter(DashboardLayout.owner_id == user.id).first()
|
||
if row is None:
|
||
return {"layout": DEFAULT_LAYOUT, "default": True}
|
||
try:
|
||
layout = json.loads(row.layout)
|
||
except ValueError:
|
||
layout = DEFAULT_LAYOUT
|
||
return {"layout": layout, "default": False}
|
||
|
||
|
||
@router.put("/layout")
|
||
def save_layout(
|
||
layout: list = Body(...),
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
) -> dict:
|
||
row = db.query(DashboardLayout).filter(DashboardLayout.owner_id == user.id).first()
|
||
if row is None:
|
||
row = DashboardLayout(owner_id=user.id, layout=json.dumps(layout))
|
||
db.add(row)
|
||
else:
|
||
row.layout = json.dumps(layout)
|
||
db.commit()
|
||
return {"layout": layout, "default": False}
|
||
|
||
|
||
@router.delete("/layout")
|
||
def reset_layout(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||
row = db.query(DashboardLayout).filter(DashboardLayout.owner_id == user.id).first()
|
||
if row is not None:
|
||
db.delete(row)
|
||
db.commit()
|
||
return {"layout": DEFAULT_LAYOUT, "default": True}
|
||
|
||
|
||
# --- Kartendaten -----------------------------------------------------------
|
||
|
||
|
||
def _expense_groups(db: Session, user: User) -> list[CategoryGroup]:
|
||
return (
|
||
db.query(CategoryGroup)
|
||
.filter(CategoryGroup.owner_id == user.id, CategoryGroup.kind == CategoryKind.expense)
|
||
.order_by(CategoryGroup.position, CategoryGroup.name)
|
||
.all()
|
||
)
|
||
|
||
|
||
def _units(groups: list[CategoryGroup], by_category: bool):
|
||
"""Liefert je Anzeige-Einheit (name, farbe, [kategorie-ids]). Bei ``by_category``
|
||
ist jede Kategorie eine eigene Einheit, sonst jede Gruppe (Summe ihrer Kategorien)."""
|
||
for g in groups:
|
||
if by_category:
|
||
for c in g.categories:
|
||
if not c.is_archived:
|
||
yield (c.name, c.color, [c.id])
|
||
else:
|
||
yield (g.name, g.color, [c.id for c in g.categories if not c.is_archived])
|
||
|
||
|
||
def _kein_verlust(groups: list[CategoryGroup]) -> set[int]:
|
||
"""Kategorien, deren Ausgabe kein Verlust ist (Sparen, Ruecklagen).
|
||
|
||
Ihre Betraege stehen neutral statt rot, und mehr als geplant ist bei ihnen
|
||
keine Warnung.
|
||
"""
|
||
return {c.id for g in groups for c in g.categories if c.spending_is_good}
|
||
|
||
|
||
@router.get("/budget-donut")
|
||
def budget_donut(
|
||
group_by: str = Query(default="group"),
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
) -> dict:
|
||
base = base_currency(db, user)
|
||
label = _label(db, user)
|
||
start, end = budget.period_bounds(label, _start_day(db, user))
|
||
planned = budget.effective_planned(db, user, label)
|
||
groups = _expense_groups(db, user)
|
||
by_category = group_by == "category"
|
||
|
||
def collect(values: dict[int, int]):
|
||
segs, tot = [], 0
|
||
for name, color, ids in _units(groups, by_category):
|
||
val = sum(values.get(i, 0) for i in ids)
|
||
if val > 0:
|
||
segs.append({"name": name, "value": val, "color": color})
|
||
tot += val
|
||
return segs, tot
|
||
|
||
segments, total = collect(planned)
|
||
mode = "planned"
|
||
if total == 0:
|
||
segments, total = collect(budget.actual_expense_by_category(db, user, label, start, end, base))
|
||
mode = "actual"
|
||
return {"segments": segments, "total": total, "currency": base, "mode": mode,
|
||
"label": label, "group_by": "category" if by_category else "group"}
|
||
|
||
|
||
@router.get("/expense-share")
|
||
def expense_share(
|
||
group_by: str = Query(default="category"),
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
) -> dict:
|
||
"""Tatsächliche Ausgaben des laufenden Budget-Monats je Kategorie/Gruppe.
|
||
|
||
Anders als der Budget-Donut zeigt das rein die Ist-Ausgaben – unabhängig
|
||
davon, ob für den Monat überhaupt geplant wurde. Die Anteile rechnet die
|
||
Oberfläche aus `amount` und `total`, damit sich gerundete Prozente nicht zu
|
||
99,9 % addieren."""
|
||
base = base_currency(db, user)
|
||
label = _label(db, user)
|
||
start, end = budget.period_bounds(label, _start_day(db, user))
|
||
actual = budget.actual_expense_by_category(db, user, label, start, end, base)
|
||
# Das Geplante kommt mit, damit die Karte zeigen kann, ob ein Anteil noch
|
||
# im Rahmen liegt. Ohne Plan wird nichts behauptet: „ueber Budget" waere
|
||
# sonst die Aussage ueber jede Kategorie, fuer die nie eine gemacht wurde.
|
||
planned = budget.effective_planned(db, user, label)
|
||
by_category = group_by != "group"
|
||
|
||
# Als Liste: ``_units`` ist ein Generator, und er wird hier zweimal
|
||
# gebraucht – beim zweiten Mal waere er leer und das Geplante 0.
|
||
gruppen = _expense_groups(db, user)
|
||
gut = _kein_verlust(gruppen)
|
||
einheiten = list(_units(gruppen, by_category))
|
||
rows = []
|
||
for name, color, ids in einheiten:
|
||
val = sum(actual.get(i, 0) for i in ids)
|
||
if val > 0:
|
||
geplant = sum(planned.get(i, 0) for i in ids)
|
||
# Ueberschritten ist die Zeile an dem, was WIRKLICH zu viel war –
|
||
# das Sparen bleibt beim Urteil draussen, nicht beim Betrag.
|
||
echt_geplant = sum(planned.get(i, 0) for i in ids if i not in gut)
|
||
echt_ist = sum(actual.get(i, 0) for i in ids if i not in gut)
|
||
rows.append({"name": name, "color": color, "amount": val,
|
||
"planned": geplant,
|
||
"over": echt_geplant > 0 and echt_ist > echt_geplant,
|
||
"good": bool(ids) and all(i in gut for i in ids),
|
||
# 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:
|
||
# Der Monatsplan umfasst auch, wofuer noch nichts ausgegeben wurde.
|
||
gesamt = sum(r["amount"] for r in rows)
|
||
gesamt_geplant = sum(planned.get(i, 0) for _, _, ids in einheiten for i in ids)
|
||
return {
|
||
"rows": rows,
|
||
"total": gesamt,
|
||
"total_planned": gesamt_geplant,
|
||
"total_over": gesamt_geplant > 0 and gesamt > gesamt_geplant,
|
||
# Der Zeitraum gehoert dazu: Bei einem Budget-Monat, der nicht am
|
||
# Ersten beginnt, ist sonst nicht nachvollziehbar, welche Buchungen
|
||
# ueberhaupt gezaehlt wurden.
|
||
"von": start.isoformat(),
|
||
"bis": end.isoformat(),
|
||
"currency": base,
|
||
"label": label,
|
||
"group_by": "category" if by_category else "group",
|
||
}
|
||
|
||
|
||
@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"),
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
) -> dict:
|
||
base = base_currency(db, user)
|
||
label = _label(db, user)
|
||
start, end = budget.period_bounds(label, _start_day(db, user))
|
||
planned = budget.effective_planned(db, user, label)
|
||
actual = budget.actual_expense_by_category(db, user, label, start, end, base)
|
||
by_category = group_by == "category"
|
||
gruppen = _expense_groups(db, user)
|
||
gut = _kein_verlust(gruppen)
|
||
rows = []
|
||
for name, color, ids in _units(gruppen, by_category):
|
||
gp = sum(planned.get(i, 0) for i in ids)
|
||
ga = sum(actual.get(i, 0) for i in ids)
|
||
if gp > 0 or ga > 0:
|
||
echt_geplant = sum(planned.get(i, 0) for i in ids if i not in gut)
|
||
echt_ist = sum(actual.get(i, 0) for i in ids if i not in gut)
|
||
rows.append({"name": name, "color": color, "planned": gp, "actual": ga,
|
||
"over": echt_ist > echt_geplant,
|
||
"good": bool(ids) and all(i in gut for i in ids)})
|
||
return {"rows": rows, "currency": base, "label": label,
|
||
"group_by": "category" if by_category else "group"}
|
||
|
||
|
||
@router.get("/income-expense")
|
||
def income_expense(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||
base = base_currency(db, user)
|
||
label = _label(db, user)
|
||
start, end = budget.period_bounds(label, _start_day(db, user))
|
||
expense = sum(budget.actual_expense_by_category(db, user, label, start, end, base).values())
|
||
income = sum(budget.income_by_category(db, user, label, start, end, base).values())
|
||
return {"income": income, "expense": expense, "net": income - expense, "currency": base, "label": label}
|
||
|
||
|
||
@router.get("/balances")
|
||
def dashboard_balances(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||
base = base_currency(db, user)
|
||
today = date.today()
|
||
accounts = (
|
||
db.query(Account)
|
||
.filter(Account.owner_id == user.id)
|
||
.order_by(Account.position, Account.name)
|
||
.all()
|
||
)
|
||
rows = []
|
||
total = 0
|
||
gebunden = 0
|
||
for a in accounts:
|
||
bb = balances.account_balance_base_cents(db, a, base, today)
|
||
rows.append({
|
||
"id": a.id, "name": a.name, "icon": a.icon, "color": a.color,
|
||
"currency": a.currency, "balance_cents": balances.account_balance_cents(db, a),
|
||
"balance_base_cents": bb, "archived": a.is_archived, "locked": a.is_locked,
|
||
})
|
||
if a.is_archived:
|
||
continue
|
||
# Gesperrtes Geld steht neben der Summe, nicht darin: Die Frage, die
|
||
# diese Zahl beantwortet, ist „wie viel habe ich zur Verfuegung".
|
||
# Verschwinden darf es trotzdem nicht – es gehoert dem Nutzer.
|
||
if a.is_locked:
|
||
gebunden += bb
|
||
else:
|
||
total += bb
|
||
return {"accounts": rows, "total": total, "gebunden": gebunden, "currency": base}
|
||
|
||
|
||
@router.get("/net-worth")
|
||
def net_worth(
|
||
days: int = Query(default=90, ge=7, le=365),
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
) -> dict:
|
||
base = base_currency(db, user)
|
||
end = date.today()
|
||
start = end - timedelta(days=days - 1)
|
||
|
||
accounts = db.query(Account).filter(Account.owner_id == user.id, Account.is_archived == False).all() # noqa: E712
|
||
acc_by_id = {a.id: a for a in accounts}
|
||
|
||
def to_eur(cents: int, ccy: str, on: date) -> int:
|
||
v = currency.to_eur_cents(db, cents, ccy, on)
|
||
return v if v is not None else cents
|
||
|
||
opening_eur = sum(to_eur(a.opening_balance_cents, a.currency, a.opening_date or start) for a in accounts)
|
||
|
||
def zaehlt(acc: Account | None, on: date) -> bool:
|
||
"""Buchungen bis zum Stichtag stecken schon im Anfangssaldo."""
|
||
return acc is not None and (acc.opening_date is None or on > acc.opening_date)
|
||
|
||
def eur_effect(t: Transaction) -> int:
|
||
# Jede Seite wird für sich geprüft: Der Stichtag des Quellkontos kann
|
||
# anders liegen als der des Zielkontos.
|
||
effekt = 0
|
||
if zaehlt(acc_by_id.get(t.account_id), t.date):
|
||
effekt += t.amount_eur_cents if t.type == TxnType.income else -t.amount_eur_cents
|
||
if t.type == TxnType.transfer:
|
||
to_acc = acc_by_id.get(t.to_account_id) if t.to_account_id else None
|
||
if zaehlt(to_acc, t.date):
|
||
credited = t.to_amount_cents if t.to_amount_cents is not None else t.amount_cents
|
||
effekt += to_eur(credited, to_acc.currency, t.date)
|
||
return effekt
|
||
|
||
txns = db.query(Transaction).filter(
|
||
Transaction.owner_id == user.id, Transaction.account_id.in_(list(acc_by_id.keys()) or [0])
|
||
).all() if acc_by_id else []
|
||
|
||
before = sum(eur_effect(t) for t in txns if t.date < start)
|
||
deltas: dict[date, int] = defaultdict(int)
|
||
for t in txns:
|
||
if start <= t.date <= end:
|
||
deltas[t.date] += eur_effect(t)
|
||
|
||
running = opening_eur + before
|
||
labels: list[str] = []
|
||
values_eur: list[int] = []
|
||
d = start
|
||
while d <= end:
|
||
running += deltas.get(d, 0)
|
||
labels.append(d.isoformat())
|
||
values_eur.append(running)
|
||
d += timedelta(days=1)
|
||
|
||
if base != "EUR":
|
||
rate = currency.rate_on(db, base, end)
|
||
if rate is not None:
|
||
values_eur = [int(round(v * float(rate))) for v in values_eur]
|
||
return {"labels": labels, "values": values_eur, "currency": base}
|
||
|
||
|
||
@router.get("/recent")
|
||
def recent(
|
||
limit: int = Query(default=8, le=50),
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
) -> dict:
|
||
base = base_currency(db, user)
|
||
accs = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
|
||
cats = {c.id: c for c in db.query(Category).filter(Category.owner_id == user.id)}
|
||
rows = (
|
||
db.query(Transaction)
|
||
.filter(Transaction.owner_id == user.id)
|
||
.order_by(Transaction.date.desc(), Transaction.id.desc())
|
||
.limit(limit)
|
||
.all()
|
||
)
|
||
out = []
|
||
for t in rows:
|
||
acc = accs.get(t.account_id)
|
||
if t.type == TxnType.transfer:
|
||
to = accs.get(t.to_account_id)
|
||
title = f"{acc.name if acc else '?'} → {to.name if to else '?'}"
|
||
icon, color, sign = "arrow-left-right", "#6b7480", None
|
||
else:
|
||
cat = cats.get(t.category_id) if t.category_id else None
|
||
title = cat.name if cat else (t.payee or "—")
|
||
icon = cat.icon if cat else "more-horizontal"
|
||
color = cat.color if cat else "#6b7480"
|
||
sign = "pos" if t.type == TxnType.income else "neg"
|
||
out.append({
|
||
"id": t.id, "date": t.date.isoformat(), "type": t.type.value,
|
||
"title": title, "icon": icon, "color": color, "sign": sign,
|
||
"amount_cents": t.amount_cents, "currency": acc.currency if acc else "EUR",
|
||
"amount_base_cents": t.amount_eur_cents if base == "EUR"
|
||
else (currency.from_eur_cents(db, t.amount_eur_cents, base, t.date) or t.amount_eur_cents),
|
||
})
|
||
return {"transactions": out, "currency": base}
|