Oracle / tests /test_analytics.py
spacedout-bits's picture
Deploy coco-finbot
bfd6783 verified
Raw History Blame Contribute Delete
7.31 kB
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)]) == []