mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 14:48: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>
197 lines
7.5 KiB
Python
197 lines
7.5 KiB
Python
"""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)
|