Files
lifetrack/apps/api/app/modules/finance/stats.py
T
MeeJayandClaude Opus 5 93f0689c1e Initial import: LifeTrack v1 (santé, vape, finances)
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>
2026-08-14 10:48:57 +02:00

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),
}