"""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")