mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 12:28:48 +02:00
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>
203 lines
7.0 KiB
Python
203 lines
7.0 KiB
Python
"""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)
|