"""Tasa Cero (BAC zero-interest installment financing) plans. A qualifying credit-card purchase ("anchor") is billed by the bank as N equal monthly cuotas instead of one charge. Here the anchor transaction is flagged (`is_installment_anchor`) and excluded from budget aggregates, and N real cuota Transaction rows are generated so they flow through the existing billing-cycle logic — including future-dated cuotas, which intentionally show up in future months' budgets as committed spend. Verified BAC mechanics (real Financiamientos data, 2026-05/07): - cuota = total / N truncated to 0.10; the LAST cuota absorbs the remainder (111,053.60/3 -> 37,017.80 x2 + 37,018.00). - Cuotas post one per 18th-cut billing cycle, on the 19th, except the first one whose date varies per plan (purchase date or a later 19th) — hence `first_installment_date` is stored per plan and user-editable. Known v1 gaps: paste-imported real "CUOTA:XX/YY" statement lines won't dedup against the synthetic cuotas; editing the anchor's amount does not recompute the plan (edit the plan instead). Cascades are handled explicitly here in Python: tests run on SQLite without FK enforcement, and transaction.installment_plan_id has no DB-level FK (it would be circular with installmentplan.anchor_transaction_id). """ import re from datetime import datetime from decimal import ROUND_DOWN, Decimal from typing import Optional from sqlmodel import Session, select from app.models.models import ( InstallmentPlan, Transaction, TransactionType, ) from app.services.budget_projection import get_previous_cycle # BAC prefixes Tasa Cero voucher merchants with "CC " (observed with a double # space: "CC CONSTRUPLAZA"). Non-prefixed plans (e.g. ALMACENES SIMAN) are # converted manually via POST /installment-plans/. TASA_CERO_RE = re.compile(r"^CC\s", re.IGNORECASE) DEFAULT_NUM_INSTALLMENTS = 3 def is_tasa_cero_merchant(merchant: str) -> bool: return bool(TASA_CERO_RE.match(merchant)) def compute_installment_amounts(total: Decimal, n: int) -> list[Decimal]: """Split `total` into n cuotas using BAC's rule: truncate to 0.10, last cuota absorbs the remainder. Invariant: sum(result) == total.""" per = (total / n).quantize(Decimal("0.1"), rounding=ROUND_DOWN) return [per] * (n - 1) + [total - per * (n - 1)] def _add_months(year: int, month: int, delta: int) -> tuple[int, int]: idx = year * 12 + (month - 1) + delta return idx // 12, idx % 12 + 1 def compute_installment_dates(first: datetime, n: int) -> list[datetime]: """Cuota 1 = `first` verbatim; cuotas 2..n land on the 19th of consecutive billing cycles (cycle = [18th, next 18th), see get_cycle_range).""" if first.day >= 18: cycle_y, cycle_m = first.year, first.month else: cycle_y, cycle_m = get_previous_cycle(first.year, first.month) dates = [first] for i in range(1, n): y, m = _add_months(cycle_y, cycle_m, i) dates.append(datetime(y, m, 19)) return dates def _generate_cuotas( session: Session, plan: InstallmentPlan, anchor: Transaction ) -> None: amounts = compute_installment_amounts(anchor.amount, plan.num_installments) dates = compute_installment_dates( plan.first_installment_date, plan.num_installments ) merchant_label = " ".join(anchor.merchant.split()) # collapse "CC X" ref_base = anchor.reference or f"TC{anchor.id}" n = plan.num_installments for i, (amount, when) in enumerate(zip(amounts, dates), start=1): session.add( Transaction( amount=amount, currency=anchor.currency, merchant=f"{merchant_label} CUOTA:{i:02d}/{n:02d}", city=anchor.city, date=when, card_type=anchor.card_type, card_last4=anchor.card_last4, reference=f"{ref_base}-C{i:02d}", transaction_type=TransactionType.COMPRA, source=anchor.source, bank=anchor.bank, notes=f"Tasa Cero plan #{plan.id}", category_id=anchor.category_id, deferred_to_next_cycle=False, installment_plan_id=plan.id, ) ) def _delete_cuotas(session: Session, plan: InstallmentPlan) -> None: cuotas = session.exec( select(Transaction).where(Transaction.installment_plan_id == plan.id) ).all() for cuota in cuotas: session.delete(cuota) def create_plan( session: Session, anchor: Transaction, num_installments: int = DEFAULT_NUM_INSTALLMENTS, first_installment_date: Optional[datetime] = None, ) -> InstallmentPlan: """Convert `anchor` (must be committed, id set) into a Tasa Cero plan and generate its cuota rows. Caller commits.""" amounts = compute_installment_amounts(anchor.amount, num_installments) plan = InstallmentPlan( anchor_transaction_id=anchor.id, num_installments=num_installments, first_installment_date=first_installment_date or anchor.date, total_amount=anchor.amount, installment_amount=amounts[0], currency=anchor.currency, ) session.add(plan) session.flush() # assign plan.id for the cuota FK/notes anchor.is_installment_anchor = True session.add(anchor) _generate_cuotas(session, plan, anchor) return plan def regenerate_cuotas( session: Session, plan: InstallmentPlan, anchor: Transaction, num_installments: Optional[int] = None, first_installment_date: Optional[datetime] = None, ) -> InstallmentPlan: """Apply plan edits by delete-and-recreate of the cuota rows. Per-cuota edits (e.g. recategorizations) are lost; category is re-inherited from the anchor. Caller commits.""" if num_installments is not None: plan.num_installments = num_installments if first_installment_date is not None: plan.first_installment_date = first_installment_date plan.total_amount = anchor.amount plan.installment_amount = compute_installment_amounts( anchor.amount, plan.num_installments )[0] _delete_cuotas(session, plan) _generate_cuotas(session, plan, anchor) session.add(plan) return plan def delete_plan(session: Session, plan: InstallmentPlan, anchor: Transaction) -> None: """Unconvert: remove cuotas + plan, restore the anchor to a normal transaction. Caller commits.""" _delete_cuotas(session, plan) anchor.is_installment_anchor = False session.add(anchor) session.delete(plan) def teardown_plan_for_anchor(session: Session, anchor: Transaction) -> None: """The anchor itself is being deleted: remove its plan and cuotas.""" if not anchor.is_installment_anchor: return plan = session.exec( select(InstallmentPlan).where( InstallmentPlan.anchor_transaction_id == anchor.id ) ).first() if plan: _delete_cuotas(session, plan) session.delete(plan)