"""Characterization tests for billing-cycle math (review finding BE-06). VERDICT: BE-06 is a false positive. The system is internally consistent: - `get_cycle_range(Y, M)` returns the cycle that STARTS on the 18th of M: [M/18, (M+1)/18). - Budget month M covers the credit-card cycle that ENDS on the 18th of M, i.e. `get_previous_cycle(M)` then `get_cycle_range(M-1)` = [(M-1)/18, M/18) — exactly what the comment in compute_actuals_by_source says. - The frontend (Budget.tsx) follows the same convention: viewing month M it queries cycle_month = M-1. These tests pin that convention so it can never silently shift. """ from datetime import datetime import pytest from sqlmodel import Session from app.models.models import ( Currency, Transaction, TransactionSource, TransactionType, ) from app.services.budget_projection import ( compute_actuals_by_source, get_cycle_range, get_month_range, get_previous_cycle, ) def cc_tx( date: datetime, amount: float = 1000.0, *, tx_type: TransactionType = TransactionType.COMPRA, source: TransactionSource = TransactionSource.CREDIT_CARD, currency: Currency = Currency.CRC, deferred: bool = False, ) -> Transaction: return Transaction( amount=amount, currency=currency, merchant="TEST", date=date, transaction_type=tx_type, source=source, deferred_to_next_cycle=deferred, ) def cc_net(session: Session, year: int, month: int) -> float: return compute_actuals_by_source(session, year, month)["CREDIT_CARD"]["net"] class TestRangeHelpers: def test_cycle_starts_on_the_18th_of_its_label_month(self): assert get_cycle_range(2026, 3) == ( datetime(2026, 3, 18), datetime(2026, 4, 18), ) def test_december_cycle_wraps_into_next_year(self): assert get_cycle_range(2026, 12) == ( datetime(2026, 12, 18), datetime(2027, 1, 18), ) def test_month_range_normal_and_december(self): assert get_month_range(2026, 4) == ( datetime(2026, 4, 1), datetime(2026, 5, 1), ) assert get_month_range(2026, 12) == ( datetime(2026, 12, 1), datetime(2027, 1, 1), ) def test_previous_cycle_january_wraps_to_prior_december(self): assert get_previous_cycle(2026, 1) == (2025, 12) assert get_previous_cycle(2026, 7) == (2026, 6) @pytest.mark.parametrize("month", [0, 13, -1, 99]) def test_invalid_month_rejected(self, month): with pytest.raises(ValueError): get_cycle_range(2026, month) with pytest.raises(ValueError): get_month_range(2026, month) class TestBudgetMonthCycleConvention: """Budget month M ← credit-card cycle [(M-1)/18, M/18).""" def test_april_budget_covers_march18_to_april17(self, session): included = [ cc_tx(datetime(2026, 3, 18, 0, 0), 100.0), # first instant of cycle cc_tx(datetime(2026, 4, 1, 12, 0), 200.0), # mid-cycle cc_tx(datetime(2026, 4, 17, 23, 59), 400.0), # last day of cycle ] excluded = [ cc_tx(datetime(2026, 3, 17, 23, 59), 7000.0), # previous cycle cc_tx(datetime(2026, 4, 18, 0, 0), 9000.0), # next cycle ] for tx in included + excluded: session.add(tx) session.commit() assert cc_net(session, 2026, 4) == pytest.approx(700.0) # The boundary transactions land in the adjacent budget months instead. assert cc_net(session, 2026, 3) == pytest.approx(7000.0) assert cc_net(session, 2026, 5) == pytest.approx(9000.0) def test_january_budget_reaches_into_prior_year(self, session): session.add(cc_tx(datetime(2025, 12, 20), 500.0)) session.commit() assert cc_net(session, 2026, 1) == pytest.approx(500.0) def test_cash_uses_calendar_month_not_cycle(self, session): session.add( cc_tx( datetime(2026, 4, 17), 300.0, source=TransactionSource.CASH, ) ) session.commit() actuals = compute_actuals_by_source(session, 2026, 4) assert actuals["CASH"]["net"] == pytest.approx(300.0) # Not in March's calendar month, despite being in March's... no — # Apr 17 belongs to April calendar month only. assert compute_actuals_by_source(session, 2026, 3)["CASH"][ "net" ] == pytest.approx(0.0) class TestDeferredTransactions: def test_deferred_moves_to_next_budget_month(self, session): # 2026-03-10 lies in cycle [Feb 18, Mar 18) → budget month 3. # Deferred, it must count in budget month 4 instead. session.add(cc_tx(datetime(2026, 3, 10), 1500.0, deferred=True)) session.commit() assert cc_net(session, 2026, 3) == pytest.approx(0.0) assert cc_net(session, 2026, 4) == pytest.approx(1500.0) # And it does not leak into month 5. assert cc_net(session, 2026, 5) == pytest.approx(0.0) def test_deferred_devolucion_also_moves(self, session): session.add(cc_tx(datetime(2026, 3, 10), 9000.0)) # normal, month 3 session.add( cc_tx( datetime(2026, 3, 10), 2000.0, tx_type=TransactionType.DEVOLUCION, deferred=True, ) ) session.commit() assert cc_net(session, 2026, 3) == pytest.approx(9000.0) assert cc_net(session, 2026, 4) == pytest.approx(-2000.0) class TestAmountSemantics: def test_devolucion_subtracts_from_net(self, session): session.add(cc_tx(datetime(2026, 4, 1), 5000.0)) session.add( cc_tx( datetime(2026, 4, 2), 1200.0, tx_type=TransactionType.DEVOLUCION, ) ) session.commit() actuals = compute_actuals_by_source(session, 2026, 4)["CREDIT_CARD"] assert actuals["total_compra"] == pytest.approx(5000.0) assert actuals["total_devolucion"] == pytest.approx(1200.0) assert actuals["net"] == pytest.approx(3800.0) assert actuals["count"] == 2 def test_usd_converted_at_multiplier(self, session): # conftest pins USD→CRC at 500. session.add(cc_tx(datetime(2026, 4, 1), 10.0, currency=Currency.USD)) session.commit() assert cc_net(session, 2026, 4) == pytest.approx(5000.0) def test_income_types_excluded_from_counts(self, session): session.add( cc_tx( datetime(2026, 4, 1), 999999.0, tx_type=TransactionType.SALARY, source=TransactionSource.TRANSFER, ) ) session.commit() actuals = compute_actuals_by_source(session, 2026, 4)["TRANSFER"] assert actuals["count"] == 0 # SALARY is not COMPRA/DEVOLUCION so it contributes nothing to net. assert actuals["net"] == pytest.approx(0.0)