Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et finances personnelles avec import de relevés bancaires. Architecture : FastAPI + SQLAlchemy 2.0 + PostgreSQL 16, React 18 + TS + Vite + Tailwind + ECharts, déploiement Docker Compose. Modules auto-découverts des deux côtés (pkgutil / import.meta.glob) et framework de connecteurs à deux voies (importeurs de fichiers + ingestion JSON) pour brancher de nouvelles sources sans toucher au noyau. Validé : 292 tests pytest, tsc + vite build, contrat API/web vérifié contre le schéma OpenAPI, et déploiement Docker réel sur PostgreSQL 16 (28 tables, SPA servie par nginx, wizard de premier démarrage). Documentation : README.md, docs/GUIDE.md, CONVENTIONS.md, et les documents de conception et de recherche dans docs/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
714 lines
24 KiB
Python
714 lines
24 KiB
Python
"""Chart-ready aggregates (datamodel-finance.md §8 and §9.8).
|
|
|
|
Every expense figure is returned as a POSITIVE number (the semantics carry the
|
|
sign, §10.7). The common scope of §8.1 excludes internal transfers, non-EUR
|
|
transactions and archived accounts (unless accounts are given explicitly).
|
|
"""
|
|
|
|
import uuid
|
|
from collections import defaultdict
|
|
from dataclasses import dataclass, field
|
|
from datetime import date, timedelta
|
|
from decimal import ROUND_HALF_UP, Decimal
|
|
from typing import Any
|
|
|
|
import sqlalchemy as sa
|
|
from sqlalchemy import Select, func, or_, select
|
|
from sqlalchemy.orm import Session, aliased
|
|
|
|
from app.modules.finance.categorize import (
|
|
detect_recurring,
|
|
monthly_total_estimate,
|
|
)
|
|
from app.modules.finance.enums import CategoryKind
|
|
from app.modules.finance.models import (
|
|
FinAccount,
|
|
FinBudget,
|
|
FinCategory,
|
|
FinTransaction,
|
|
)
|
|
from app.modules.finance.normalize import add_months, merchant_key, month_str
|
|
from app.modules.finance.presets import (
|
|
DEFICIT_NODE_NAME,
|
|
INCOME_NODE_NAME,
|
|
MUTED_COLOR,
|
|
PALETTE,
|
|
SAVINGS_NODE_NAME,
|
|
UNCATEGORIZED_COLOR,
|
|
UNCATEGORIZED_LABEL,
|
|
)
|
|
|
|
ZERO = Decimal("0.00")
|
|
ONE_DAY = timedelta(days=1)
|
|
BUDGET_WARNING_RATIO = Decimal(80)
|
|
BUDGET_OVER_RATIO = Decimal(100)
|
|
|
|
|
|
def money(value: Any) -> Decimal:
|
|
"""Coerce a SQL aggregate (float on SQLite, Decimal on PG) to 2 decimals."""
|
|
if value is None:
|
|
return ZERO
|
|
if not isinstance(value, Decimal):
|
|
value = Decimal(str(value))
|
|
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
|
|
|
|
|
def month_range(months: int, end: date) -> list[str]:
|
|
"""The `months` last month keys ending with `end`'s month (inclusive)."""
|
|
first = add_months(end.replace(day=1), -(months - 1))
|
|
return [month_str(add_months(first, i)) for i in range(months)]
|
|
|
|
|
|
@dataclass
|
|
class StatsScope:
|
|
"""Aliases + predicates shared by every aggregate (§8.1)."""
|
|
|
|
category: Any
|
|
parent: Any
|
|
stmt: Select
|
|
|
|
|
|
def scope(user_id: int, account_ids: list[uuid.UUID] | None) -> StatsScope:
|
|
category = aliased(FinCategory)
|
|
parent = aliased(FinCategory)
|
|
stmt = (
|
|
select()
|
|
.select_from(FinTransaction)
|
|
.join(FinAccount, FinAccount.id == FinTransaction.account_id)
|
|
.outerjoin(category, category.id == FinTransaction.category_id)
|
|
.outerjoin(parent, parent.id == category.parent_id)
|
|
.where(
|
|
FinTransaction.user_id == user_id,
|
|
FinTransaction.currency == "EUR",
|
|
FinTransaction.transfer_group_id.is_(None),
|
|
or_(category.kind.is_(None), category.kind != CategoryKind.TRANSFER),
|
|
)
|
|
)
|
|
if account_ids:
|
|
stmt = stmt.where(FinTransaction.account_id.in_(account_ids))
|
|
else:
|
|
stmt = stmt.where(FinAccount.is_archived.is_(False))
|
|
return StatsScope(category=category, parent=parent, stmt=stmt)
|
|
|
|
|
|
def _year_month() -> tuple[Any, Any]:
|
|
"""Portable month extraction (SQLite -> strftime, PostgreSQL -> EXTRACT)."""
|
|
return (
|
|
sa.extract("year", FinTransaction.booked_date),
|
|
sa.extract("month", FinTransaction.booked_date),
|
|
)
|
|
|
|
|
|
def _key(year: Any, month: Any) -> str:
|
|
return f"{int(year):04d}-{int(month):02d}"
|
|
|
|
|
|
def _direction_clause(direction: str) -> Any:
|
|
if direction == "credit":
|
|
return FinTransaction.amount > 0
|
|
return FinTransaction.amount < 0
|
|
|
|
|
|
def _color_for(index: int, color: str | None) -> str:
|
|
return color or PALETTE[index % len(PALETTE)]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /stats/monthly-by-category
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def monthly_by_category(
|
|
db: Session,
|
|
user_id: int,
|
|
*,
|
|
months: int = 12,
|
|
level: str = "root",
|
|
direction: str = "debit",
|
|
account_ids: list[uuid.UUID] | None = None,
|
|
today: date | None = None,
|
|
) -> dict[str, Any]:
|
|
today = today or date.today() # noqa: DTZ011 — civil day, no tz conversion
|
|
keys = month_range(months, today)
|
|
start = date.fromisoformat(f"{keys[0]}-01")
|
|
end = add_months(date.fromisoformat(f"{keys[-1]}-01"), 1)
|
|
|
|
sc = scope(user_id, account_ids)
|
|
year_col, month_col = _year_month()
|
|
if level == "child":
|
|
cat_id = sc.category.id
|
|
cat_name = sc.category.name
|
|
cat_color = func.coalesce(sc.category.color, sc.parent.color)
|
|
else:
|
|
cat_id = func.coalesce(sc.parent.id, sc.category.id)
|
|
cat_name = func.coalesce(sc.parent.name, sc.category.name)
|
|
cat_color = func.coalesce(sc.parent.color, sc.category.color)
|
|
|
|
stmt = (
|
|
sc.stmt.add_columns(
|
|
year_col.label("y"),
|
|
month_col.label("m"),
|
|
cat_id.label("cid"),
|
|
cat_name.label("cname"),
|
|
cat_color.label("ccolor"),
|
|
func.sum(FinTransaction.amount).label("total"),
|
|
)
|
|
.where(
|
|
FinTransaction.booked_date >= start,
|
|
FinTransaction.booked_date < end,
|
|
_direction_clause(direction),
|
|
)
|
|
.group_by(year_col, month_col, cat_id, cat_name, cat_color)
|
|
)
|
|
|
|
buckets: dict[tuple[uuid.UUID | None, str, str | None], dict[str, Decimal]] = (
|
|
defaultdict(lambda: defaultdict(lambda: ZERO))
|
|
)
|
|
for row in db.execute(stmt):
|
|
key = _key(row.y, row.m)
|
|
ident = (row.cid, row.cname or UNCATEGORIZED_LABEL, row.ccolor)
|
|
buckets[ident][key] = money(abs(money(row.total)))
|
|
|
|
series: list[dict[str, Any]] = []
|
|
for index, (ident, per_month) in enumerate(
|
|
sorted(buckets.items(), key=lambda kv: -sum(kv[1].values()))
|
|
):
|
|
cid, name, color = ident
|
|
series.append(
|
|
{
|
|
"category_id": cid,
|
|
"name": name if cid else UNCATEGORIZED_LABEL,
|
|
"color": UNCATEGORIZED_COLOR if not cid else _color_for(index, color),
|
|
"data": [per_month.get(key, ZERO) for key in keys],
|
|
}
|
|
)
|
|
totals = [
|
|
money(sum((s["data"][i] for s in series), ZERO)) for i in range(len(keys))
|
|
]
|
|
return {"months": keys, "series": series, "totals": totals}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /stats/cashflow
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def cashflow(
|
|
db: Session,
|
|
user_id: int,
|
|
*,
|
|
months: int = 12,
|
|
account_ids: list[uuid.UUID] | None = None,
|
|
today: date | None = None,
|
|
) -> dict[str, Any]:
|
|
today = today or date.today() # noqa: DTZ011 — civil day, no tz conversion
|
|
keys = month_range(months, today)
|
|
start = date.fromisoformat(f"{keys[0]}-01")
|
|
end = add_months(date.fromisoformat(f"{keys[-1]}-01"), 1)
|
|
|
|
sc = scope(user_id, account_ids)
|
|
year_col, month_col = _year_month()
|
|
positive = func.sum(
|
|
sa.case((FinTransaction.amount > 0, FinTransaction.amount), else_=0)
|
|
)
|
|
negative = func.sum(
|
|
sa.case((FinTransaction.amount < 0, FinTransaction.amount), else_=0)
|
|
)
|
|
stmt = (
|
|
sc.stmt.add_columns(
|
|
year_col.label("y"),
|
|
month_col.label("m"),
|
|
positive.label("income"),
|
|
negative.label("expenses"),
|
|
)
|
|
.where(FinTransaction.booked_date >= start, FinTransaction.booked_date < end)
|
|
.group_by(year_col, month_col)
|
|
)
|
|
income_by_month: dict[str, Decimal] = {}
|
|
expenses_by_month: dict[str, Decimal] = {}
|
|
for row in db.execute(stmt):
|
|
key = _key(row.y, row.m)
|
|
income_by_month[key] = money(row.income)
|
|
expenses_by_month[key] = money(abs(money(row.expenses)))
|
|
|
|
income = [income_by_month.get(key, ZERO) for key in keys]
|
|
expenses = [expenses_by_month.get(key, ZERO) for key in keys]
|
|
net = [money(i - e) for i, e in zip(income, expenses, strict=True)]
|
|
cumulative: list[Decimal] = []
|
|
running = ZERO
|
|
for value in net:
|
|
running = money(running + value)
|
|
cumulative.append(running)
|
|
return {
|
|
"months": keys,
|
|
"income": income,
|
|
"expenses": expenses,
|
|
"net": net,
|
|
"cumulative_net": cumulative,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /stats/top-merchants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def top_merchants(
|
|
db: Session,
|
|
user_id: int,
|
|
*,
|
|
months: int = 3,
|
|
limit: int = 15,
|
|
direction: str = "debit",
|
|
account_ids: list[uuid.UUID] | None = None,
|
|
today: date | None = None,
|
|
) -> dict[str, Any]:
|
|
today = today or date.today() # noqa: DTZ011 — civil day, no tz conversion
|
|
keys = month_range(months, today)
|
|
start = date.fromisoformat(f"{keys[0]}-01")
|
|
end = add_months(date.fromisoformat(f"{keys[-1]}-01"), 1)
|
|
|
|
sc = scope(user_id, account_ids)
|
|
stmt = sc.stmt.add_columns(
|
|
FinTransaction.counterparty,
|
|
FinTransaction.label_clean,
|
|
FinTransaction.label_raw,
|
|
FinTransaction.amount,
|
|
sc.category.name.label("cname"),
|
|
func.coalesce(sc.category.color, sc.parent.color).label("ccolor"),
|
|
).where(
|
|
FinTransaction.booked_date >= start,
|
|
FinTransaction.booked_date < end,
|
|
_direction_clause(direction),
|
|
)
|
|
|
|
groups: dict[str, dict[str, Any]] = {}
|
|
for row in db.execute(stmt):
|
|
name = row.counterparty or merchant_key(row.label_clean or row.label_raw)
|
|
if not name:
|
|
name = UNCATEGORIZED_LABEL
|
|
entry = groups.setdefault(
|
|
name,
|
|
{
|
|
"merchant": name,
|
|
"total": ZERO,
|
|
"count": 0,
|
|
"category_name": row.cname,
|
|
"category_color": row.ccolor,
|
|
},
|
|
)
|
|
entry["total"] = money(entry["total"] + abs(money(row.amount)))
|
|
entry["count"] += 1
|
|
if entry["category_name"] is None and row.cname:
|
|
entry["category_name"] = row.cname
|
|
entry["category_color"] = row.ccolor
|
|
|
|
items = sorted(groups.values(), key=lambda e: -e["total"])[:limit]
|
|
for entry in items:
|
|
entry["average"] = money(entry["total"] / entry["count"])
|
|
return {
|
|
"period": {"from": start, "to": end - ONE_DAY},
|
|
"items": items,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /stats/budget-progress (§8.3)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _children_map(db: Session, user_id: int) -> dict[uuid.UUID, list[uuid.UUID]]:
|
|
rows = db.execute(
|
|
select(FinCategory.id, FinCategory.parent_id).where(
|
|
FinCategory.user_id == user_id
|
|
)
|
|
).all()
|
|
children: dict[uuid.UUID, list[uuid.UUID]] = defaultdict(list)
|
|
for cid, parent_id in rows:
|
|
if parent_id is not None:
|
|
children[parent_id].append(cid)
|
|
return children
|
|
|
|
|
|
def expenses_by_category(
|
|
db: Session,
|
|
user_id: int,
|
|
start: date,
|
|
end: date,
|
|
account_ids: list[uuid.UUID] | None = None,
|
|
) -> dict[uuid.UUID | None, Decimal]:
|
|
"""Absolute expense total per (leaf) category over [start, end)."""
|
|
sc = scope(user_id, account_ids)
|
|
stmt = (
|
|
sc.stmt.add_columns(
|
|
FinTransaction.category_id.label("cid"),
|
|
func.sum(FinTransaction.amount).label("total"),
|
|
)
|
|
.where(
|
|
FinTransaction.booked_date >= start,
|
|
FinTransaction.booked_date < end,
|
|
FinTransaction.amount < 0,
|
|
)
|
|
.group_by(FinTransaction.category_id)
|
|
)
|
|
return {row.cid: money(abs(money(row.total))) for row in db.execute(stmt)}
|
|
|
|
|
|
def budget_progress(
|
|
db: Session,
|
|
user_id: int,
|
|
month: date,
|
|
*,
|
|
account_ids: list[uuid.UUID] | None = None,
|
|
today: date | None = None,
|
|
) -> dict[str, Any]:
|
|
today = today or date.today() # noqa: DTZ011 — civil day, no tz conversion
|
|
start = month.replace(day=1)
|
|
end = add_months(start, 1)
|
|
budgets = list(
|
|
db.scalars(
|
|
select(FinBudget)
|
|
.where(
|
|
FinBudget.user_id == user_id,
|
|
FinBudget.start_month <= start,
|
|
or_(FinBudget.end_month.is_(None), FinBudget.end_month >= start),
|
|
)
|
|
.order_by(FinBudget.start_month.asc())
|
|
).all()
|
|
)
|
|
if not budgets:
|
|
return {
|
|
"month": month_str(start),
|
|
"items": [],
|
|
"totals": {"budget": ZERO, "actual": ZERO, "progress_pct": 0.0},
|
|
}
|
|
|
|
actuals = expenses_by_category(db, user_id, start, end, account_ids)
|
|
children = _children_map(db, user_id)
|
|
categories = {
|
|
c.id: c
|
|
for c in db.scalars(
|
|
select(FinCategory).where(FinCategory.user_id == user_id)
|
|
).all()
|
|
}
|
|
|
|
is_current = start <= today < end
|
|
days_elapsed = (today - start).days + 1 if is_current else None
|
|
days_in_month = (end - start).days
|
|
|
|
items: list[dict[str, Any]] = []
|
|
total_budget = ZERO
|
|
total_actual = ZERO
|
|
for budget in budgets:
|
|
category = categories.get(budget.category_id)
|
|
subtree = [budget.category_id, *children.get(budget.category_id, [])]
|
|
actual = money(sum((actuals.get(cid, ZERO) for cid in subtree), ZERO))
|
|
amount = money(budget.monthly_amount)
|
|
progress = (
|
|
float((actual / amount * 100).quantize(Decimal("0.1"))) if amount else 0.0
|
|
)
|
|
projected = None
|
|
if is_current and days_elapsed:
|
|
projected = money(actual / Decimal(days_elapsed) * Decimal(days_in_month))
|
|
status = "ok"
|
|
if Decimal(str(progress)) > BUDGET_OVER_RATIO:
|
|
status = "over"
|
|
elif Decimal(str(progress)) >= BUDGET_WARNING_RATIO or (
|
|
projected is not None and projected > amount
|
|
):
|
|
status = "warning"
|
|
items.append(
|
|
{
|
|
"budget_id": budget.id,
|
|
"category_id": budget.category_id,
|
|
"category_name": category.name if category else UNCATEGORIZED_LABEL,
|
|
"category_color": (category.color if category else None)
|
|
or UNCATEGORIZED_COLOR,
|
|
"budget": amount,
|
|
"actual": actual,
|
|
"remaining": money(amount - actual),
|
|
"progress_pct": progress,
|
|
"projected_eom": projected,
|
|
"status": status,
|
|
}
|
|
)
|
|
total_budget = money(total_budget + amount)
|
|
total_actual = money(total_actual + actual)
|
|
|
|
totals_pct = (
|
|
float((total_actual / total_budget * 100).quantize(Decimal("0.1")))
|
|
if total_budget
|
|
else 0.0
|
|
)
|
|
return {
|
|
"month": month_str(start),
|
|
"items": items,
|
|
"totals": {
|
|
"budget": total_budget,
|
|
"actual": total_actual,
|
|
"progress_pct": totals_pct,
|
|
},
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /stats/sankey (§9.8)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class _SankeyBuilder:
|
|
nodes: list[dict[str, Any]] = field(default_factory=list)
|
|
links: list[dict[str, Any]] = field(default_factory=list)
|
|
seen: set[str] = field(default_factory=set)
|
|
|
|
def node(self, name: str, color: str) -> str:
|
|
if name not in self.seen:
|
|
self.seen.add(name)
|
|
self.nodes.append({"name": name, "color": color})
|
|
return name
|
|
|
|
def link(self, source: str, target: str, value: Decimal) -> None:
|
|
if value > 0:
|
|
self.links.append(
|
|
{"source": source, "target": target, "value": money(value)}
|
|
)
|
|
|
|
|
|
def sankey(
|
|
db: Session,
|
|
user_id: int,
|
|
*,
|
|
month: date | None = None,
|
|
months: int = 1,
|
|
account_ids: list[uuid.UUID] | None = None,
|
|
today: date | None = None,
|
|
) -> dict[str, Any]:
|
|
today = today or date.today() # noqa: DTZ011 — civil day, no tz conversion
|
|
if month is not None:
|
|
start = month.replace(day=1)
|
|
end = add_months(start, 1)
|
|
else:
|
|
keys = month_range(months, today)
|
|
start = date.fromisoformat(f"{keys[0]}-01")
|
|
end = add_months(date.fromisoformat(f"{keys[-1]}-01"), 1)
|
|
|
|
sc = scope(user_id, account_ids)
|
|
stmt = (
|
|
sc.stmt.add_columns(
|
|
sc.category.id.label("cid"),
|
|
sc.category.name.label("cname"),
|
|
sc.category.kind.label("ckind"),
|
|
sc.category.color.label("ccolor"),
|
|
sc.parent.id.label("pid"),
|
|
sc.parent.name.label("pname"),
|
|
sc.parent.color.label("pcolor"),
|
|
func.sum(FinTransaction.amount).label("total"),
|
|
func.sum(
|
|
sa.case((FinTransaction.amount > 0, FinTransaction.amount), else_=0)
|
|
).label("credit"),
|
|
func.sum(
|
|
sa.case((FinTransaction.amount < 0, -FinTransaction.amount), else_=0)
|
|
).label("debit"),
|
|
)
|
|
.where(FinTransaction.booked_date >= start, FinTransaction.booked_date < end)
|
|
.group_by(
|
|
sc.category.id,
|
|
sc.category.name,
|
|
sc.category.kind,
|
|
sc.category.color,
|
|
sc.parent.id,
|
|
sc.parent.name,
|
|
sc.parent.color,
|
|
)
|
|
)
|
|
|
|
income_roots: dict[str, dict[str, Any]] = {}
|
|
expense_roots: dict[str, dict[str, Any]] = {}
|
|
children: dict[str, dict[str, Decimal]] = defaultdict(
|
|
lambda: defaultdict(lambda: ZERO)
|
|
)
|
|
child_colors: dict[str, str] = {}
|
|
total_income = ZERO
|
|
total_expense = ZERO
|
|
|
|
for row in db.execute(stmt):
|
|
root_name = row.pname or row.cname
|
|
root_color = row.pcolor or row.ccolor
|
|
# Uncategorized rows are TWO independent buckets (§9.8): credits feed
|
|
# "Autres revenus", debits feed "Non catégorisé". Netting them against
|
|
# each other would silently drop the uncategorized expenses AND shrink
|
|
# the income side by the same amount. A real category keeps netting so
|
|
# that a refund simply lowers that category's expense (and a category
|
|
# never ends up on both sides of the hub, which would build a cycle).
|
|
if row.cid is None:
|
|
credit, debit = money(row.credit), money(row.debit)
|
|
else:
|
|
total = money(row.total)
|
|
credit = total if total > 0 else ZERO
|
|
debit = -total if total < 0 else ZERO
|
|
|
|
if credit > 0:
|
|
name = root_name or "Autres revenus"
|
|
entry = income_roots.setdefault(
|
|
name, {"total": ZERO, "color": root_color or PALETTE[0]}
|
|
)
|
|
entry["total"] = money(entry["total"] + credit)
|
|
total_income = money(total_income + credit)
|
|
if debit > 0:
|
|
name = root_name or UNCATEGORIZED_LABEL
|
|
entry = expense_roots.setdefault(
|
|
name,
|
|
{
|
|
"total": ZERO,
|
|
"color": (root_color or UNCATEGORIZED_COLOR)
|
|
if row.cid
|
|
else UNCATEGORIZED_COLOR,
|
|
},
|
|
)
|
|
entry["total"] = money(entry["total"] + debit)
|
|
total_expense = money(total_expense + debit)
|
|
if row.pid is not None and row.cname:
|
|
child_name = row.cname
|
|
if child_name in expense_roots or child_name in income_roots:
|
|
child_name = f"{row.pname} · {row.cname}"
|
|
children[name][child_name] = money(children[name][child_name] + debit)
|
|
child_colors[child_name] = row.ccolor or row.pcolor or PALETTE[0]
|
|
|
|
builder = _SankeyBuilder()
|
|
hub = builder.node(INCOME_NODE_NAME, MUTED_COLOR)
|
|
for index, (name, entry) in enumerate(
|
|
sorted(income_roots.items(), key=lambda kv: -kv[1]["total"])
|
|
):
|
|
builder.node(name, _color_for(index, entry["color"]))
|
|
builder.link(name, hub, entry["total"])
|
|
if total_expense > total_income:
|
|
builder.node(DEFICIT_NODE_NAME, "#D03B3B")
|
|
builder.link(DEFICIT_NODE_NAME, hub, money(total_expense - total_income))
|
|
for index, (name, entry) in enumerate(
|
|
sorted(expense_roots.items(), key=lambda kv: -kv[1]["total"])
|
|
):
|
|
builder.node(name, _color_for(index, entry["color"]))
|
|
builder.link(hub, name, entry["total"])
|
|
for child_name, value in sorted(
|
|
children.get(name, {}).items(), key=lambda kv: -kv[1]
|
|
):
|
|
builder.node(child_name, child_colors.get(child_name, PALETTE[index % 8]))
|
|
builder.link(name, child_name, value)
|
|
if total_income > total_expense:
|
|
builder.node(SAVINGS_NODE_NAME, "#0CA30C")
|
|
builder.link(hub, SAVINGS_NODE_NAME, money(total_income - total_expense))
|
|
|
|
return {
|
|
"period": {"from": start, "to": end - ONE_DAY},
|
|
"nodes": builder.nodes,
|
|
"links": builder.links,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /stats/recurring + GET /dashboard
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def recurring(
|
|
db: Session,
|
|
user_id: int,
|
|
*,
|
|
direction: str = "debit",
|
|
include_inactive: bool = False,
|
|
today: date | None = None,
|
|
) -> dict[str, Any]:
|
|
series = detect_recurring(
|
|
db,
|
|
user_id,
|
|
direction=direction,
|
|
include_inactive=include_inactive,
|
|
today=today,
|
|
)
|
|
return {
|
|
"items": series,
|
|
"monthly_total_estimate": monthly_total_estimate(series),
|
|
}
|
|
|
|
|
|
def account_balances(db: Session, user_id: int) -> dict[uuid.UUID, Decimal]:
|
|
rows = db.execute(
|
|
select(
|
|
FinTransaction.account_id,
|
|
func.sum(FinTransaction.amount),
|
|
)
|
|
.where(FinTransaction.user_id == user_id)
|
|
.group_by(FinTransaction.account_id)
|
|
).all()
|
|
return {account_id: money(total) for account_id, total in rows}
|
|
|
|
|
|
def account_transaction_counts(db: Session, user_id: int) -> dict[uuid.UUID, int]:
|
|
rows = db.execute(
|
|
select(FinTransaction.account_id, func.count())
|
|
.where(FinTransaction.user_id == user_id)
|
|
.group_by(FinTransaction.account_id)
|
|
).all()
|
|
return {account_id: int(count) for account_id, count in rows}
|
|
|
|
|
|
def dashboard(
|
|
db: Session,
|
|
user_id: int,
|
|
*,
|
|
today: date | None = None,
|
|
) -> dict[str, Any]:
|
|
"""KPIs of the finance home tab (ux-pages §13.1)."""
|
|
today = today or date.today() # noqa: DTZ011 — civil day, no tz conversion
|
|
accounts = list(
|
|
db.scalars(
|
|
select(FinAccount).where(
|
|
FinAccount.user_id == user_id, FinAccount.is_archived.is_(False)
|
|
)
|
|
).all()
|
|
)
|
|
balances = account_balances(db, user_id)
|
|
total_balance = money(
|
|
sum(
|
|
(money(a.initial_balance) + balances.get(a.id, ZERO) for a in accounts),
|
|
ZERO,
|
|
)
|
|
)
|
|
|
|
flow = cashflow(db, user_id, months=6, today=today)
|
|
current_key = month_str(today)
|
|
index = flow["months"].index(current_key)
|
|
month_expenses = flow["expenses"][index]
|
|
month_income = flow["income"][index]
|
|
month_net = flow["net"][index]
|
|
past = [value for i, value in enumerate(flow["expenses"]) if i != index]
|
|
average_expenses = money(sum(past, ZERO) / Decimal(len(past))) if past else ZERO
|
|
|
|
budgets = budget_progress(db, user_id, today.replace(day=1), today=today)
|
|
uncategorized = (
|
|
db.scalar(
|
|
select(func.count())
|
|
.select_from(FinTransaction)
|
|
.where(
|
|
FinTransaction.user_id == user_id,
|
|
FinTransaction.category_id.is_(None),
|
|
FinTransaction.transfer_group_id.is_(None),
|
|
)
|
|
)
|
|
or 0
|
|
)
|
|
return {
|
|
"month": current_key,
|
|
"total_balance": total_balance,
|
|
"accounts_count": len(accounts),
|
|
"month_expenses": month_expenses,
|
|
"month_income": month_income,
|
|
"month_net": month_net,
|
|
"average_expenses_6m": average_expenses,
|
|
"budget_total": budgets["totals"]["budget"],
|
|
"budget_actual": budgets["totals"]["actual"],
|
|
"budget_progress_pct": budgets["totals"]["progress_pct"],
|
|
"uncategorized_count": int(uncategorized),
|
|
}
|