Files
WealthySmart/backend/tests/test_budget_projection.py
Carlos Escalante c725c1487d Add pytest harness and 55-test suite for core backend logic
Covers: billing-cycle characterization (the BE-06 convention, boundary
instants, year wraps, deferred transactions), projection engine
(recurring schedules, no-double-count, cumulative balances, overrides,
fresh start), auth (JWT roundtrip/expiry, API tokens, constant-time
creds, rate-limit window), fail-fast settings, and the BAC paste
parser. SQLite in-memory fixtures, exchange rates pinned — no network.
Also doubles as the float-behavior baseline for the Phase 2 Decimal
migration (plan 1.5). config.py moves to SettingsConfigDict to clear
the pydantic deprecation warning from test output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 19:46:54 -06:00

197 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Projection engine: recurring-item schedules, no-double-count, cumulative
balances with overrides and the 2026-03 fresh start. Also serves as the
float-behavior baseline ahead of the Phase 2 Decimal migration (plan 1.5)."""
from datetime import datetime
import pytest
from sqlmodel import Session
from app.models.models import (
BalanceOverride,
Category,
RecurringFrequency,
RecurringItem,
RecurringItemType,
Transaction,
TransactionSource,
TransactionType,
)
from app.services.budget_projection import (
compute_monthly_projection,
compute_yearly_projection_with_cumulative,
get_effective_amount,
)
def make_item(**kw) -> RecurringItem:
defaults = dict(
name="item",
amount=1000.0,
item_type=RecurringItemType.EXPENSE,
frequency=RecurringFrequency.MONTHLY,
)
defaults.update(kw)
return RecurringItem(**defaults)
class TestEffectiveAmount:
def test_monthly_base_and_override(self):
item = make_item(amount=100.0, override_amounts={"4": 250.0})
assert get_effective_amount(item, 3, 2026) == 100.0
assert get_effective_amount(item, 4, 2026) == 250.0
def test_weekly_counts_weekday_occurrences(self):
# day_of_month stores weekday, 0=Monday. March 2026 has 5 Mondays
# (2, 9, 16, 23, 30); April 2026 has 4 (6, 13, 20, 27).
item = make_item(
amount=100.0, frequency=RecurringFrequency.WEEKLY, day_of_month=0
)
assert get_effective_amount(item, 3, 2026) == 500.0
assert get_effective_amount(item, 4, 2026) == 400.0
def test_quarterly_active_in_quarter_end_months_only(self):
item = make_item(frequency=RecurringFrequency.QUARTERLY, amount=900.0)
for month in (3, 6, 9, 12):
assert get_effective_amount(item, month, 2026) == 900.0
for month in (1, 2, 4, 5, 7, 8, 10, 11):
assert get_effective_amount(item, month, 2026) is None
def test_biannual_base_and_six_months_later(self):
item = make_item(
frequency=RecurringFrequency.BIANNUAL, month_of_year=2, amount=70.0
)
assert get_effective_amount(item, 2, 2026) == 70.0
assert get_effective_amount(item, 8, 2026) == 70.0
assert get_effective_amount(item, 5, 2026) is None
late = make_item(
frequency=RecurringFrequency.BIANNUAL, month_of_year=9, amount=70.0
)
assert get_effective_amount(late, 9, 2026) == 70.0
assert get_effective_amount(late, 3, 2026) == 70.0
def test_yearly_defaults_to_december(self):
item = make_item(frequency=RecurringFrequency.YEARLY, amount=50.0)
assert get_effective_amount(item, 12, 2026) == 50.0
assert get_effective_amount(item, 6, 2026) is None
july = make_item(
frequency=RecurringFrequency.YEARLY, month_of_year=7, amount=50.0
)
assert get_effective_amount(july, 7, 2026) == 50.0
class TestMonthlyProjection:
def _seed(self, session: Session):
groceries = Category(name="Groceries")
other = Category(name="Other")
session.add(groceries)
session.add(other)
session.commit()
session.refresh(groceries)
session.refresh(other)
session.add(
RecurringItem(
name="Salary",
amount=500_000.0,
item_type=RecurringItemType.INCOME,
frequency=RecurringFrequency.MONTHLY,
)
)
session.add(
RecurringItem(
name="Groceries budget",
amount=100_000.0,
item_type=RecurringItemType.EXPENSE,
frequency=RecurringFrequency.MONTHLY,
category_id=groceries.id,
)
)
session.commit()
return groceries, other
def _cc(self, date, amount, category_id=None):
return Transaction(
amount=amount,
merchant="X",
date=date,
transaction_type=TransactionType.COMPRA,
source=TransactionSource.CREDIT_CARD,
category_id=category_id,
)
def test_actuals_replace_projection_for_covered_category(self, session):
groceries, _ = self._seed(session)
# April budget ⇒ cycle [Mar 18, Apr 18)
session.add(self._cc(datetime(2026, 4, 1), 80_000.0, groceries.id))
session.commit()
data = compute_monthly_projection(session, 2026, 4)
grocery_line = next(
i for i in data["expense_items"] if i["name"] == "Groceries budget"
)
assert grocery_line["used_actual"] is True
assert grocery_line["amount"] == pytest.approx(80_000.0)
assert grocery_line["projected_amount"] == pytest.approx(100_000.0)
assert data["projected_fixed_expenses"] == pytest.approx(80_000.0)
def test_projection_used_when_no_actuals(self, session):
self._seed(session)
data = compute_monthly_projection(session, 2026, 4)
assert data["projected_fixed_expenses"] == pytest.approx(100_000.0)
assert data["projected_income"] == pytest.approx(500_000.0)
assert data["net_balance"] == pytest.approx(400_000.0)
def test_uncovered_and_uncategorized_actuals_add_up(self, session):
groceries, other = self._seed(session)
session.add(self._cc(datetime(2026, 4, 1), 80_000.0, groceries.id))
session.add(self._cc(datetime(2026, 4, 2), 5_000.0, other.id))
session.add(self._cc(datetime(2026, 4, 3), 2_000.0, None))
session.commit()
data = compute_monthly_projection(session, 2026, 4)
assert data["uncovered_actual"] == pytest.approx(7_000.0)
assert data["gran_total_egresos"] == pytest.approx(87_000.0)
assert data["net_balance"] == pytest.approx(500_000.0 - 87_000.0)
class TestYearlyCumulative:
def _income_only(self, session: Session, amount=100.0):
session.add(
RecurringItem(
name="Income",
amount=amount,
item_type=RecurringItemType.INCOME,
frequency=RecurringFrequency.MONTHLY,
)
)
session.commit()
def test_fresh_start_zeroes_months_before_march_2026(self, session):
self._income_only(session)
months = compute_yearly_projection_with_cumulative(session, 2026)
assert months[0]["cumulative_balance"] == 0.0 # January
assert months[1]["cumulative_balance"] == 0.0 # February
assert months[2]["cumulative_balance"] == pytest.approx(100.0) # March
assert months[11]["cumulative_balance"] == pytest.approx(1000.0) # Dec
def test_override_resets_cumulative_chain(self, session):
self._income_only(session)
session.add(BalanceOverride(year=2026, month=4, override_balance=500.0))
session.commit()
months = compute_yearly_projection_with_cumulative(session, 2026)
april, may = months[3], months[4]
assert april["balance_overridden"] is True
assert april["cumulative_balance"] == pytest.approx(500.0)
assert may["carryover_balance"] == pytest.approx(500.0)
assert may["cumulative_balance"] == pytest.approx(600.0)
def test_next_year_carries_december_forward(self, session):
self._income_only(session)
months_2027 = compute_yearly_projection_with_cumulative(session, 2027)
# 2026 accrues 10 months (MarDec) of 100 → Jan 2027 carries 1000.
assert months_2027[0]["carryover_balance"] == pytest.approx(1000.0)
assert months_2027[0]["cumulative_balance"] == pytest.approx(1100.0)