Files
lifetrack/apps/api/app/tests/modules/test_finance_calculations.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

401 lines
15 KiB
Python

"""Calculation tests: recurring detection, budget rollup and chart aggregates.
Reference: datamodel-finance.md §7 (recurring), §8 (aggregates), §9.8 (stats).
Every function takes an explicit `today`, so the expectations are deterministic.
"""
import uuid
from datetime import date, timedelta
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.modules.auth.models import User
from app.modules.finance import service, stats
from app.modules.finance.categorize import (
detect_recurring,
monthly_total_estimate,
)
from app.modules.finance.enums import AccountKind, CategorySource
from app.modules.finance.models import (
FinAccount,
FinBudget,
FinCategory,
FinTransaction,
)
from app.modules.finance.normalize import add_months, compute_dedup_hash
TODAY = date(2026, 8, 13)
THIS_MONTH = date(2026, 8, 1)
def account(db: Session, user: User, name: str = "Compte courant") -> FinAccount:
row = FinAccount(
id=uuid.uuid4(),
user_id=user.id,
name=name,
kind=AccountKind.CHECKING,
currency="EUR",
initial_balance=Decimal("1000.00"),
)
db.add(row)
db.commit()
return row
def category(db: Session, user: User, name: str) -> FinCategory:
service.ensure_seed(db, user.id)
found = db.scalars(
select(FinCategory).where(
FinCategory.user_id == user.id, FinCategory.name == name
)
).first()
assert found is not None, name
return found
def add_tx(
db: Session,
user: User,
acc: FinAccount,
day: date,
amount: str,
label: str,
cat: FinCategory | None = None,
counterparty: str | None = None,
) -> FinTransaction:
value = Decimal(amount)
tx = FinTransaction(
id=uuid.uuid4(),
user_id=user.id,
account_id=acc.id,
booked_date=day,
amount=value,
currency="EUR",
label_raw=label,
label_clean=label,
counterparty=counterparty,
category_id=cat.id if cat else None,
category_source=CategorySource.RULE if cat else None,
dedup_hash=compute_dedup_hash(acc.id, day, value, label, 0)
+ uuid.uuid4().hex[:4],
)
db.add(tx)
db.commit()
return tx
# ---------------------------------------------------------------------------
# §7 — recurring detection
# ---------------------------------------------------------------------------
def test_recurring_detects_a_monthly_subscription(db: Session, user: User) -> None:
acc = account(db, user)
abo = category(db, user, "Abonnements & streaming")
for months_ago in range(6, 0, -1):
day = add_months(THIS_MONTH, -months_ago) + timedelta(days=27)
amount = "-13.49" if months_ago != 3 else "-13.99"
add_tx(db, user, acc, day, amount, "PRLV SEPA NETFLIX.COM", abo)
# noise: a one-off purchase must not create a series
add_tx(db, user, acc, date(2026, 7, 4), "-89.00", "ACHAT CB DARTY 4444")
series = detect_recurring(db, user.id, today=TODAY)
assert len(series) == 1
netflix = series[0]
assert netflix.merchant_key == "NETFLIX COM"
assert netflix.periodicity == "monthly"
assert netflix.occurrences == 6
assert netflix.expected_amount == Decimal("13.49")
assert netflix.category_id == abo.id
assert netflix.is_active is True
assert netflix.next_date_predicted > netflix.last_date
assert monthly_total_estimate(series) == Decimal("13.49")
def test_recurring_ignores_irregular_and_unstable_series(
db: Session, user: User
) -> None:
acc = account(db, user)
# irregular intervals
for day in (date(2026, 3, 2), date(2026, 3, 20), date(2026, 7, 15)):
add_tx(db, user, acc, day, "-30.00", "CARTE BOUTIQUE ALPHA")
# regular but wildly unstable amounts
for index, amount in enumerate(("-10.00", "-90.00", "-200.00")):
add_tx(
db,
user,
acc,
add_months(THIS_MONTH, -(3 - index)) + timedelta(days=4),
amount,
"CARTE BOUTIQUE BETA",
)
assert detect_recurring(db, user.id, today=TODAY) == []
def test_recurring_on_income_direction(db: Session, user: User) -> None:
acc = account(db, user)
salaire = category(db, user, "Salaire")
for months_ago in range(5, 0, -1):
add_tx(
db,
user,
acc,
add_months(THIS_MONTH, -months_ago) + timedelta(days=1),
"2450.00",
"VIR SEPA SALAIRE EMPLOYEUR SA",
salaire,
)
series = detect_recurring(db, user.id, direction="credit", today=TODAY)
assert len(series) == 1
assert series[0].periodicity == "monthly"
assert series[0].expected_amount == Decimal("2450.00")
def test_recurring_marks_inactive_series(db: Session, user: User) -> None:
acc = account(db, user)
for months_ago in range(12, 8, -1):
add_tx(
db,
user,
acc,
add_months(THIS_MONTH, -months_ago) + timedelta(days=9),
"-9.99",
"PRLV SEPA VIEUX SERVICE",
)
assert detect_recurring(db, user.id, today=TODAY) == []
inactive = detect_recurring(db, user.id, include_inactive=True, today=TODAY)
assert len(inactive) == 1
assert inactive[0].is_active is False
# ---------------------------------------------------------------------------
# §8.3 — budgets
# ---------------------------------------------------------------------------
def test_budget_rollup_root_covers_children(db: Session, user: User) -> None:
acc = account(db, user)
alimentation = category(db, user, "Alimentation")
courses = category(db, user, "Courses")
restaurants = category(db, user, "Restaurants & bars")
db.add(
FinBudget(
id=uuid.uuid4(),
user_id=user.id,
category_id=alimentation.id,
monthly_amount=Decimal("450.00"),
start_month=date(2026, 1, 1),
)
)
db.add(
FinBudget(
id=uuid.uuid4(),
user_id=user.id,
category_id=courses.id,
monthly_amount=Decimal("200.00"),
start_month=date(2026, 1, 1),
)
)
db.commit()
add_tx(db, user, acc, date(2026, 8, 3), "-300.00", "COURSES", courses)
add_tx(db, user, acc, date(2026, 8, 6), "-100.00", "RESTO", restaurants)
add_tx(db, user, acc, date(2026, 7, 6), "-500.00", "MOIS PRECEDENT", courses)
progress = stats.budget_progress(db, user.id, THIS_MONTH, today=TODAY)
by_category = {item["category_name"]: item for item in progress["items"]}
root = by_category["Alimentation"]
assert root["actual"] == Decimal("400.00") # child + sibling child
assert root["remaining"] == Decimal("50.00")
assert root["progress_pct"] == 88.9
assert root["status"] == "warning"
assert root["projected_eom"] is not None # current month -> linear projection
child = by_category["Courses"]
assert child["actual"] == Decimal("300.00")
assert child["status"] == "over"
assert progress["totals"]["budget"] == Decimal("650.00")
def test_budget_progress_of_a_past_month_has_no_projection(
db: Session, user: User
) -> None:
acc = account(db, user)
courses = category(db, user, "Courses")
db.add(
FinBudget(
id=uuid.uuid4(),
user_id=user.id,
category_id=courses.id,
monthly_amount=Decimal("200.00"),
start_month=date(2026, 1, 1),
end_month=date(2026, 7, 1),
)
)
db.commit()
add_tx(db, user, acc, date(2026, 7, 6), "-120.00", "COURSES", courses)
progress = stats.budget_progress(db, user.id, date(2026, 7, 1), today=TODAY)
assert progress["items"][0]["projected_eom"] is None
assert progress["items"][0]["status"] == "ok"
# the budget ended in July: it no longer applies in August
assert stats.budget_progress(db, user.id, THIS_MONTH, today=TODAY)["items"] == []
# ---------------------------------------------------------------------------
# §9.8 — chart-ready aggregates
# ---------------------------------------------------------------------------
def _sample_month(db: Session, user: User) -> FinAccount:
acc = account(db, user)
courses = category(db, user, "Courses")
restaurants = category(db, user, "Restaurants & bars")
salaire = category(db, user, "Salaire")
add_tx(
db, user, acc, date(2026, 8, 2), "-120.00", "CARREFOUR", courses, "Carrefour"
)
add_tx(db, user, acc, date(2026, 8, 9), "-80.00", "CARREFOUR", courses, "Carrefour")
add_tx(db, user, acc, date(2026, 8, 10), "-60.00", "BRASSERIE", restaurants)
add_tx(db, user, acc, date(2026, 8, 11), "-25.00", "INCONNU SANS CATEGORIE")
add_tx(db, user, acc, date(2026, 8, 1), "2450.00", "SALAIRE", salaire)
return acc
def test_monthly_by_category_rolls_children_into_roots(db: Session, user: User) -> None:
_sample_month(db, user)
data = stats.monthly_by_category(db, user.id, months=3, today=TODAY)
assert data["months"][-1] == "2026-08"
series = {item["name"]: item for item in data["series"]}
assert series["Alimentation"]["data"][-1] == Decimal("260.00")
assert series["Non catégorisé"]["data"][-1] == Decimal("25.00")
assert series["Non catégorisé"]["color"] == "#9ca3af"
assert data["totals"][-1] == Decimal("285.00")
child_level = stats.monthly_by_category(
db, user.id, months=1, level="child", today=TODAY
)
names = {item["name"] for item in child_level["series"]}
assert {"Courses", "Restaurants & bars"} <= names
def test_cashflow_series(db: Session, user: User) -> None:
_sample_month(db, user)
data = stats.cashflow(db, user.id, months=2, today=TODAY)
assert data["months"] == ["2026-07", "2026-08"]
assert data["income"] == [Decimal("0.00"), Decimal("2450.00")]
assert data["expenses"] == [Decimal("0.00"), Decimal("285.00")]
assert data["net"][-1] == Decimal("2165.00")
assert data["cumulative_net"][-1] == Decimal("2165.00")
def test_transfers_are_excluded_from_stats(db: Session, user: User) -> None:
acc = _sample_month(db, user)
other = account(db, user, "Livret A")
group = uuid.uuid4()
leg_a = add_tx(db, user, acc, date(2026, 8, 12), "-500.00", "VIR VERS LIVRET")
leg_b = add_tx(db, user, other, date(2026, 8, 12), "500.00", "VIR DEPUIS COURANT")
for leg in (leg_a, leg_b):
leg.transfer_group_id = group
db.commit()
data = stats.cashflow(db, user.id, months=1, today=TODAY)
assert data["expenses"] == [Decimal("285.00")]
assert data["income"] == [Decimal("2450.00")]
def test_top_merchants_groups_by_counterparty(db: Session, user: User) -> None:
_sample_month(db, user)
data = stats.top_merchants(db, user.id, months=1, today=TODAY)
top = data["items"][0]
assert top["merchant"] == "Carrefour"
assert top["total"] == Decimal("200.00")
assert top["count"] == 2
assert top["average"] == Decimal("100.00")
assert top["category_name"] == "Courses"
def test_sankey_structure(db: Session, user: User) -> None:
_sample_month(db, user)
data = stats.sankey(db, user.id, month=THIS_MONTH, today=TODAY)
names = [node["name"] for node in data["nodes"]]
assert "Revenus" in names
assert "Salaire" in names
assert "Alimentation" in names
assert "Épargne du mois" in names
links = {(link["source"], link["target"]): link["value"] for link in data["links"]}
assert links[("Salaire", "Revenus")] == Decimal("2450.00")
assert links[("Revenus", "Alimentation")] == Decimal("260.00")
assert links[("Alimentation", "Courses")] == Decimal("200.00")
assert links[("Revenus", "Épargne du mois")] == Decimal("2165.00")
assert len(names) == len(set(names)) # ECharts requires unique node names
def test_sankey_never_nets_uncategorized_credits_against_debits(
db: Session, user: User
) -> None:
"""§9.8: uncategorized credits feed « Autres revenus », uncategorized debits
feed « Non catégorisé » — netting the two buckets would drop the expense
AND shrink the income side by the same amount."""
acc = _sample_month(db, user) # already holds a -25.00 uncategorized debit
add_tx(db, user, acc, date(2026, 8, 6), "300.00", "VIR RECU SANS CATEGORIE")
data = stats.sankey(db, user.id, month=THIS_MONTH, today=TODAY)
names = [node["name"] for node in data["nodes"]]
assert "Non catégorisé" in names
assert "Autres revenus" in names
links = {(link["source"], link["target"]): link["value"] for link in data["links"]}
assert links[("Autres revenus", "Revenus")] == Decimal("300.00")
assert links[("Revenus", "Non catégorisé")] == Decimal("25.00")
income = sum(v for (_s, t), v in links.items() if t == "Revenus")
expenses = sum(
v for (s, t), v in links.items() if s == "Revenus" and t != "Épargne du mois"
)
assert income == Decimal("2750.00") # 2450 salary + 300 uncategorized credit
assert expenses == Decimal("285.00") # 120 + 80 + 60 + 25
assert links[("Revenus", "Épargne du mois")] == Decimal("2465.00")
assert len(names) == len(set(names))
def test_sankey_nets_a_refund_inside_its_own_category(db: Session, user: User) -> None:
"""A credit on an EXPENSE category is a refund: it lowers that category's
expense instead of opening an income node with the same name (which would
make the ECharts sankey cyclic)."""
acc = _sample_month(db, user)
courses = category(db, user, "Courses")
add_tx(
db, user, acc, date(2026, 8, 12), "50.00", "REMBOURSEMENT CARREFOUR", courses
)
data = stats.sankey(db, user.id, month=THIS_MONTH, today=TODAY)
links = {(link["source"], link["target"]): link["value"] for link in data["links"]}
assert ("Alimentation", "Revenus") not in links # no cycle through the hub
assert links[("Revenus", "Alimentation")] == Decimal("210.00") # 260 - 50
assert links[("Alimentation", "Courses")] == Decimal("150.00") # 200 - 50
names = [node["name"] for node in data["nodes"]]
assert len(names) == len(set(names))
def test_dashboard_kpis(db: Session, user: User) -> None:
_sample_month(db, user)
courses = category(db, user, "Courses")
db.add(
FinBudget(
id=uuid.uuid4(),
user_id=user.id,
category_id=courses.id,
monthly_amount=Decimal("400.00"),
start_month=date(2026, 1, 1),
)
)
db.commit()
kpi = stats.dashboard(db, user.id, today=TODAY)
assert kpi["month"] == "2026-08"
assert kpi["accounts_count"] == 1
assert kpi["total_balance"] == Decimal("3165.00") # 1000 initial + net
assert kpi["month_expenses"] == Decimal("285.00")
assert kpi["month_income"] == Decimal("2450.00")
assert kpi["month_net"] == Decimal("2165.00")
assert kpi["uncategorized_count"] == 1
assert kpi["budget_total"] == Decimal("400.00")
assert kpi["budget_actual"] == Decimal("200.00")