From 3cd2927b5f3389d9aaf12c3d0dcc99f11716c574 Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Fri, 3 Jul 2026 16:13:00 -0600 Subject: [PATCH] Tasa Cero: backend tests (rounding, scheduling, exclusion, lifecycle) Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/tests/test_installments.py | 385 +++++++++++++++++++++++++++++ 1 file changed, 385 insertions(+) create mode 100644 backend/tests/test_installments.py diff --git a/backend/tests/test_installments.py b/backend/tests/test_installments.py new file mode 100644 index 0000000..767733b --- /dev/null +++ b/backend/tests/test_installments.py @@ -0,0 +1,385 @@ +"""Tasa Cero installment plans: rounding, scheduling, budget exclusion, +auto-detection at ingestion, regeneration, unconvert, and cascade behavior. + +Rounding and scheduling cases are pinned against real BAC Financiamientos +data (2026-05/07). Endpoint functions are exercised directly with a session, +same style as test_import_review_and_bulk.py. +""" + +from datetime import datetime +from decimal import Decimal + +import pytest +from fastapi import HTTPException + +from app.api.v1.endpoints.installments import ( + create_installment_plan, + delete_installment_plan, + get_installment_plan, + list_installment_plans, + update_installment_plan, +) +from app.api.v1.endpoints.transactions import ( + BulkActionRequest, + bulk_action, + create_transaction, + delete_transaction, + update_transaction, +) +from app.models.models import ( + Category, + InstallmentPlan, + InstallmentPlanCreate, + InstallmentPlanUpdate, + Transaction, + TransactionCreate, + TransactionSource, + TransactionType, + TransactionUpdate, +) +from app.services.budget_projection import ( + compute_actuals_by_source, + get_cycle_range, +) +from app.services.installments import ( + compute_installment_amounts, + compute_installment_dates, + is_tasa_cero_merchant, +) +from sqlmodel import select + + +def make_cc_tx( + session, + merchant="CC CONSTRUPLAZA", + amount="111053.60", + date=datetime(2026, 6, 25, 15, 43), + reference="75669363", + source=TransactionSource.CREDIT_CARD, + **kw, +): + tx = Transaction( + amount=Decimal(amount), + merchant=merchant, + date=date, + reference=reference, + source=source, + **kw, + ) + session.add(tx) + session.commit() + session.refresh(tx) + return tx + + +def convert(session, tx, n=3, first=None): + return create_installment_plan( + InstallmentPlanCreate( + transaction_id=tx.id, num_installments=n, first_installment_date=first + ), + session=session, + _user="t", + ) + + +def get_cuotas(session, plan_id): + return session.exec( + select(Transaction) + .where(Transaction.installment_plan_id == plan_id) + .order_by(Transaction.date) + ).all() + + +class TestRounding: + """Pinned against real BAC plans.""" + + @pytest.mark.parametrize( + "total,n,per,last", + [ + ("111053.60", 3, "37017.80", "37018.00"), + ("425800.00", 6, "70966.60", "70967.00"), + ("98632.00", 3, "32877.30", "32877.40"), + ("190864.80", 3, "63621.60", "63621.60"), # divides exactly + ], + ) + def test_real_bac_plans(self, total, n, per, last): + amounts = compute_installment_amounts(Decimal(total), n) + assert amounts[:-1] == [Decimal(per)] * (n - 1) + assert amounts[-1] == Decimal(last) + + def test_sum_invariant(self): + for total, n in [("100.00", 3), ("214800.00", 3), ("99999.99", 7)]: + amounts = compute_installment_amounts(Decimal(total), n) + assert sum(amounts) == Decimal(total) + assert len(amounts) == n + + +class TestScheduling: + def test_first_before_cut_stays_in_purchase_cycle(self): + # 14/06 purchase (cycle 18/05-18/06) -> cuotas 19/06, 19/07 + dates = compute_installment_dates(datetime(2026, 6, 14, 19, 22), 3) + assert dates == [ + datetime(2026, 6, 14, 19, 22), + datetime(2026, 6, 19), + datetime(2026, 7, 19), + ] + + def test_first_after_cut(self): + # 27/06 purchase (cycle 18/06-18/07) -> cuotas 19/07, 19/08 + dates = compute_installment_dates(datetime(2026, 6, 27, 19, 43), 3) + assert dates == [ + datetime(2026, 6, 27, 19, 43), + datetime(2026, 7, 19), + datetime(2026, 8, 19), + ] + + def test_december_wrap(self): + dates = compute_installment_dates(datetime(2026, 12, 20), 3) + assert dates == [ + datetime(2026, 12, 20), + datetime(2027, 1, 19), + datetime(2027, 2, 19), + ] + + def test_consecutive_cycles(self): + """Each cuota lands in its own consecutive billing cycle.""" + dates = compute_installment_dates(datetime(2026, 5, 5, 11, 19), 6) + cycles = [] + for d in dates: + if d.day >= 18: + cy, cm = d.year, d.month + else: + cy, cm = (d.year, d.month - 1) if d.month > 1 else (d.year - 1, 12) + start, end = get_cycle_range(cy, cm) + assert start <= d < end + cycles.append((cy, cm)) + assert cycles == [(2026, m) for m in range(4, 10)] + + +class TestDetection: + @pytest.mark.parametrize( + "merchant,expected", + [ + ("CC CONSTRUPLAZA", True), + ("CC TILO.CO", True), + ("CONSTRUPLAZA S", False), + ("ALMACENES SIMAN", False), + ("MERCCADO", False), + ], + ) + def test_merchant_regex(self, merchant, expected): + assert is_tasa_cero_merchant(merchant) is expected + + def test_ingestion_auto_creates_plan(self, session): + tx = create_transaction( + TransactionCreate( + amount=Decimal("301614.60"), + merchant="CC CONSTRUPLAZA", + date=datetime(2026, 6, 27, 19, 43), + reference="24724909", + ), + session=session, + _user="t", + ) + assert tx.is_installment_anchor is True + plan = session.exec( + select(InstallmentPlan).where( + InstallmentPlan.anchor_transaction_id == tx.id + ) + ).one() + assert plan.num_installments == 3 + assert plan.first_installment_date == tx.date + cuotas = get_cuotas(session, plan.id) + assert [c.amount for c in cuotas] == [ + Decimal("100538.20"), + Decimal("100538.20"), + Decimal("100538.20"), + ] + assert cuotas[0].merchant == "CC CONSTRUPLAZA CUOTA:01/03" + assert cuotas[0].reference == "24724909-C01" + assert all(c.installment_plan_id == plan.id for c in cuotas) + + @pytest.mark.parametrize( + "kw", + [ + {"merchant": "WALMART"}, + {"transaction_type": TransactionType.DEVOLUCION}, + {"source": TransactionSource.CASH}, + ], + ) + def test_ingestion_ignores_non_tasa_cero(self, session, kw): + data = dict( + amount=Decimal("1000"), + merchant="CC CONSTRUPLAZA", + date=datetime(2026, 6, 1), + ) + data.update(kw) + tx = create_transaction( + TransactionCreate(**data), session=session, _user="t" + ) + assert tx.is_installment_anchor is False + assert session.exec(select(InstallmentPlan)).all() == [] + + def test_duplicate_reference_still_409_and_creates_nothing(self, session): + make_cc_tx(session) + with pytest.raises(HTTPException) as exc: + create_transaction( + TransactionCreate( + amount=Decimal("1"), + merchant="CC CONSTRUPLAZA", + date=datetime(2026, 7, 1), + reference="75669363", + ), + session=session, + _user="t", + ) + assert exc.value.status_code == 409 + assert session.exec(select(InstallmentPlan)).all() == [] + + +class TestBudgetExclusion: + def test_anchor_excluded_cuotas_counted_per_month(self, session): + tx = make_cc_tx(session, date=datetime(2026, 6, 25)) + convert(session, tx) # cuotas: 25/06, 19/07, 19/08 + # Budget month = cycle ENDING on its 18th: 25/06 -> July, 19/07 -> Aug, + # 19/08 -> Sep. The full 111,053.60 must appear nowhere. + by_month = { + m: compute_actuals_by_source(session, 2026, m)["CREDIT_CARD"]["net"] + for m in (6, 7, 8, 9, 10) + } + assert by_month[6] == 0 + assert by_month[7] == pytest.approx(37017.80) + assert by_month[8] == pytest.approx(37017.80) + assert by_month[9] == pytest.approx(37018.00) + assert by_month[10] == 0 + + def test_cuotas_inherit_anchor_category(self, session): + cat = Category(name="Ferretería") + session.add(cat) + session.commit() + tx = make_cc_tx(session, category_id=cat.id) + plan = convert(session, tx) + assert all( + c.category_id == cat.id for c in get_cuotas(session, plan.id) + ) + + +class TestPlanLifecycle: + def test_manual_convert_validations(self, session): + tx = make_cc_tx(session) + convert(session, tx) + # already converted + with pytest.raises(HTTPException) as exc: + convert(session, tx) + assert exc.value.status_code == 409 + # a cuota cannot itself be converted + cuota = session.exec( + select(Transaction).where(Transaction.installment_plan_id.is_not(None)) + ).first() + with pytest.raises(HTTPException) as exc: + convert(session, cuota) + assert exc.value.status_code == 400 + # non-CC rejected + cash = make_cc_tx( + session, reference="r-cash", source=TransactionSource.CASH + ) + with pytest.raises(HTTPException) as exc: + convert(session, cash) + assert exc.value.status_code == 400 + + def test_regenerate_3_to_6(self, session): + tx = make_cc_tx(session, amount="425800.00", reference="58710871") + plan = convert(session, tx) + updated = update_installment_plan( + plan.id, + InstallmentPlanUpdate( + num_installments=6, + first_installment_date=datetime(2026, 5, 19), + ), + session=session, + _user="t", + ) + assert updated.num_installments == 6 + assert updated.installment_amount == pytest.approx(70966.60) + assert updated.last_installment_amount == pytest.approx(70967.00) + cuotas = get_cuotas(session, plan.id) + assert len(cuotas) == 6 + assert cuotas[0].date == datetime(2026, 5, 19) + assert cuotas[-1].date == datetime(2026, 10, 19) + assert cuotas[0].reference == "58710871-C01" + assert cuotas[-1].merchant == "CC CONSTRUPLAZA CUOTA:06/06" + + def test_unconvert_restores_anchor(self, session): + tx = make_cc_tx(session) + plan = convert(session, tx) + delete_installment_plan(plan.id, session=session, _user="t") + session.refresh(tx) + assert tx.is_installment_anchor is False + assert get_cuotas(session, plan.id) == [] + assert session.exec(select(InstallmentPlan)).all() == [] + + def test_delete_anchor_tears_down_plan(self, session): + tx = make_cc_tx(session) + plan = convert(session, tx) + delete_transaction(tx.id, session=session, _user="t") + assert session.exec(select(InstallmentPlan)).all() == [] + assert get_cuotas(session, plan.id) == [] + + def test_bulk_delete_anchor_tears_down_plan(self, session): + tx = make_cc_tx(session) + convert(session, tx) + bulk_action( + BulkActionRequest(ids=[tx.id], action="delete"), + session=session, + _user="t", + ) + assert session.exec(select(InstallmentPlan)).all() == [] + assert session.exec(select(Transaction)).all() == [] + + def test_list_and_detail(self, session): + tx = make_cc_tx(session, date=datetime(2026, 5, 12, 15, 41)) + convert(session, tx, first=datetime(2026, 5, 19)) + resp = list_installment_plans(session=session, _user="t") + assert len(resp.plans) == 1 + plan = resp.plans[0] + assert plan.merchant == "CC CONSTRUPLAZA" + assert plan.total_amount == pytest.approx(111053.60) + assert plan.remaining_amount + plan.paid_amount == pytest.approx( + 111053.60 + ) + assert resp.total_remaining == pytest.approx(plan.remaining_amount) + detail = get_installment_plan(plan.id, session=session, _user="t") + assert len(detail.cuotas) == 3 + + +class TestDeferredGuards: + def test_patch_cuota_deferred_rejected(self, session): + tx = make_cc_tx(session) + plan = convert(session, tx) + cuota = get_cuotas(session, plan.id)[0] + with pytest.raises(HTTPException) as exc: + update_transaction( + cuota.id, + TransactionUpdate(deferred_to_next_cycle=True), + session=session, + _user="t", + ) + assert exc.value.status_code == 400 + + def test_bulk_set_deferred_skips_cuotas(self, session): + tx = make_cc_tx(session) + plan = convert(session, tx) + cuota = get_cuotas(session, plan.id)[0] + normal = make_cc_tx(session, merchant="WALMART", reference="w1") + resp = bulk_action( + BulkActionRequest( + ids=[cuota.id, normal.id], action="set_deferred", deferred=True + ), + session=session, + _user="t", + ) + assert resp["affected"] == 1 + session.refresh(cuota) + session.refresh(normal) + assert cuota.deferred_to_next_cycle is False + assert normal.deferred_to_next_cycle is True