"""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 (Mar–Dec) 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)