File size: 7,313 Bytes
bfd6783
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
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)]) == []