from datetime import date import pytest from finbot.analytics import ( by_category, budget_status, detect_recurring, dominant_currency, month_bounds, period_bounds, previous_month_bounds, project_month_end, split_by_currency, summarise, top_merchants, total_minor, ) from finbot.models import Budget, Expense TODAY = date(2026, 8, 15) def make(amount, category="other", currency="INR", day=TODAY, merchant="", desc="x"): return Expense( user_id=1, amount_minor=amount, currency=currency, description=desc, category=category, occurred_on=day, merchant=merchant, ) class TestPeriods: @pytest.mark.parametrize( "name,start,end", [ ("today", TODAY, TODAY), ("yesterday", date(2026, 8, 14), date(2026, 8, 14)), ("week", date(2026, 8, 10), TODAY), # Monday of that week ("month", date(2026, 8, 1), TODAY), ("last_month", date(2026, 7, 1), date(2026, 7, 31)), ("year", date(2026, 1, 1), TODAY), ("30d", date(2026, 7, 17), TODAY), ], ) def test_bounds(self, name, start, end): got_start, got_end, _label = period_bounds(name, TODAY) assert (got_start, got_end) == (start, end) def test_unknown_period_defaults_to_month(self): start, end, _ = period_bounds("nonsense", TODAY) assert (start, end) == (date(2026, 8, 1), TODAY) def test_month_bounds_handles_month_length(self): assert month_bounds(date(2026, 2, 10))[1] == date(2026, 2, 28) def test_previous_month_across_a_year_boundary(self): assert previous_month_bounds(date(2026, 1, 5)) == ( date(2025, 12, 1), date(2025, 12, 31), ) class TestAggregation: def test_total_and_by_category(self): expenses = [ make(10000, "food_dining"), make(5000, "food_dining"), make(20000, "rent"), ] assert total_minor(expenses) == 35000 assert by_category(expenses) == [("rent", 20000), ("food_dining", 15000)] def test_split_by_currency(self): expenses = [make(100, currency="INR"), make(200, currency="USD")] buckets = split_by_currency(expenses) assert set(buckets) == {"INR", "USD"} def test_dominant_currency_by_count(self): expenses = [make(100, currency="INR"), make(100, currency="INR"), make(999999, currency="USD")] assert dominant_currency(expenses, "EUR") == "INR" def test_dominant_currency_falls_back_when_empty(self): assert dominant_currency([], "SGD") == "SGD" def test_summarise_only_counts_the_named_currency(self): expenses = [make(10000, currency="INR"), make(50000, currency="USD")] summary = summarise(expenses, "INR", "Aug", date(2026, 8, 1), TODAY) assert summary.total_minor == 10000 assert summary.count == 1 def test_delta_pct(self): current = [make(15000)] previous = [make(10000)] summary = summarise(current, "INR", "Aug", date(2026, 8, 1), TODAY, previous) assert summary.delta_pct == pytest.approx(50.0) def test_delta_pct_is_none_without_a_baseline(self): summary = summarise([make(100)], "INR", "Aug", date(2026, 8, 1), TODAY, []) assert summary.delta_pct is None def test_top_merchants(self): expenses = [ make(100, merchant="Swiggy"), make(300, merchant="Swiggy"), make(200, merchant="Uber"), ] assert top_merchants(expenses)[0] == ("Swiggy", 400) class TestProjection: def test_projects_to_month_end(self): # 15 days elapsed of a 31-day August, ₹150 spent -> ~₹310. expenses = [make(15000, day=date(2026, 8, 5))] projected = project_month_end(expenses, TODAY) assert projected == pytest.approx(31000, rel=0.01) class TestRecurring: def _monthly(self, merchant, amount, months): return [ make(amount, day=date(2026, m, 5), merchant=merchant, desc=merchant) for m in months ] def test_detects_a_monthly_subscription(self): expenses = self._monthly("Netflix", 64900, [5, 6, 7]) found = detect_recurring(expenses) assert len(found) == 1 assert found[0].months == 3 assert found[0].typical_minor == 64900 def test_ignores_charges_in_too_few_months(self): expenses = self._monthly("Netflix", 64900, [6, 7]) assert detect_recurring(expenses) == [] def test_ignores_repeat_spending_with_erratic_amounts(self): # Same shop every month, wildly different amounts -- not a subscription. expenses = [ make(10000, day=date(2026, 5, 5), merchant="Bigbasket", desc="Bigbasket"), make(90000, day=date(2026, 6, 5), merchant="Bigbasket", desc="Bigbasket"), make(250000, day=date(2026, 7, 5), merchant="Bigbasket", desc="Bigbasket"), ] assert detect_recurring(expenses) == [] def test_three_charges_in_one_month_is_not_recurring(self): expenses = [ make(64900, day=date(2026, 7, d), merchant="Netflix", desc="Netflix") for d in (1, 10, 20) ] assert detect_recurring(expenses) == [] def test_normalises_noisy_merchant_strings(self): expenses = [ make(64900, day=date(2026, 5, 5), desc="NETFLIX.COM 447192"), make(64900, day=date(2026, 6, 5), desc="NETFLIX.COM 883021"), make(64900, day=date(2026, 7, 5), desc="Netflix"), ] found = detect_recurring(expenses) assert len(found) == 1 assert found[0].months == 3 def test_annualised(self): found = detect_recurring(self._monthly("Netflix", 50000, [5, 6, 7])) assert found[0].annualised_minor == 600000 def test_separates_currencies(self): expenses = self._monthly("Netflix", 64900, [5, 6, 7]) + [ make(1000, day=date(2026, m, 5), merchant="Netflix", desc="Netflix", currency="USD") for m in (5, 6, 7) ] found = detect_recurring(expenses) assert {f.currency for f in found} == {"INR", "USD"} class TestBudgets: def test_status_calculation(self): budgets = [Budget(user_id=1, category="food_dining", amount_minor=100000, currency="INR")] expenses = [make(60000, "food_dining")] status = budget_status(budgets, expenses)[0] assert status.spent_minor == 60000 assert status.pct == pytest.approx(60.0) assert status.remaining_minor == 40000 assert not status.over def test_over_budget(self): budgets = [Budget(user_id=1, category="rent", amount_minor=10000, currency="INR")] status = budget_status(budgets, [make(15000, "rent")])[0] assert status.over assert status.remaining_minor == -5000 def test_ignores_other_currencies(self): budgets = [Budget(user_id=1, category="rent", amount_minor=10000, currency="INR")] status = budget_status(budgets, [make(99999, "rent", currency="USD")])[0] assert status.spent_minor == 0 def test_no_budgets_yields_no_statuses(self): assert budget_status([], [make(100)]) == []