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>
401 lines
15 KiB
Python
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")
|