Spaces:
Running on Zero
Running on Zero
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)]) == []
|