diff --git a/backend/alembic/versions/961802a2f50d_installment_plans_tasa_cero.py b/backend/alembic/versions/961802a2f50d_installment_plans_tasa_cero.py new file mode 100644 index 0000000..273a7e6 --- /dev/null +++ b/backend/alembic/versions/961802a2f50d_installment_plans_tasa_cero.py @@ -0,0 +1,55 @@ +"""installment plans (tasa cero) + +Revision ID: 961802a2f50d +Revises: 7884505b16b3 +Create Date: 2026-07-03 16:00:55.026771 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = '961802a2f50d' +down_revision: Union[str, Sequence[str], None] = '7884505b16b3' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('installmentplan', + sa.Column('num_installments', sa.Integer(), nullable=False), + sa.Column('first_installment_date', sa.DateTime(), nullable=False), + sa.Column('total_amount', sa.Numeric(precision=15, scale=2), nullable=False), + sa.Column('installment_amount', sa.Numeric(precision=15, scale=2), nullable=False), + # Reuse the existing 'currency' enum type — do NOT re-create it. + sa.Column('currency', postgresql.ENUM('CRC', 'USD', 'EUR', 'BTC', 'XMR', name='currency', create_type=False), nullable=False), + sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('anchor_transaction_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['anchor_transaction_id'], ['transaction.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_installmentplan_anchor_transaction_id'), 'installmentplan', ['anchor_transaction_id'], unique=True) + op.add_column('transaction', sa.Column('installment_plan_id', sa.Integer(), nullable=True)) + op.add_column('transaction', sa.Column('is_installment_anchor', sa.Boolean(), server_default='false', nullable=False)) + op.create_index(op.f('ix_transaction_installment_plan_id'), 'transaction', ['installment_plan_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_transaction_installment_plan_id'), table_name='transaction') + op.drop_column('transaction', 'is_installment_anchor') + op.drop_column('transaction', 'installment_plan_id') + op.drop_index(op.f('ix_installmentplan_anchor_transaction_id'), table_name='installmentplan') + op.drop_table('installmentplan') + # ### end Alembic commands ### diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 1990e8f..f51db01 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -169,6 +169,17 @@ class Transaction(TransactionBase, table=True): id: Optional[int] = Field(default=None, primary_key=True) created_at: datetime = Field(default_factory=utcnow) category: Optional[Category] = Relationship(back_populates="transactions") + # Tasa Cero: set on generated cuota rows. Plain int (no DB FK) to avoid a + # circular transaction<->installmentplan dependency; cascades are handled + # explicitly in app.services.installments (SQLite tests don't enforce FKs + # anyway). Table-class only so TransactionCreate/n8n payloads can't set it. + installment_plan_id: Optional[int] = Field(default=None, index=True) + # Tasa Cero: True on the original purchase once converted to a plan; the + # anchor stays visible in listings but is excluded from budget/analytics + # aggregates (its generated cuotas are counted instead). + is_installment_anchor: bool = Field( + default=False, sa_column_kwargs={"server_default": "false"} + ) class TransactionCreate(TransactionBase): @@ -179,6 +190,8 @@ class TransactionRead(TransactionBase): id: int created_at: datetime category: Optional[CategoryRead] = None + installment_plan_id: Optional[int] = None + is_installment_anchor: bool = False class TransactionUpdate(SQLModel): @@ -194,6 +207,39 @@ class TransactionUpdate(SQLModel): deferred_to_next_cycle: Optional[bool] = None +# --- Installment Plan (Tasa Cero) --- + + +class InstallmentPlanBase(SQLModel): + num_installments: int + first_installment_date: datetime + total_amount: Money = Field(max_digits=15, decimal_places=2) + # All cuotas but the last; the last absorbs the rounding remainder. + installment_amount: Money = Field(max_digits=15, decimal_places=2) + currency: Currency = Currency.CRC + notes: Optional[str] = None + + +class InstallmentPlan(InstallmentPlanBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + anchor_transaction_id: int = Field( + foreign_key="transaction.id", ondelete="CASCADE", unique=True, index=True + ) + created_at: datetime = Field(default_factory=utcnow) + + +class InstallmentPlanCreate(SQLModel): + transaction_id: int + num_installments: int = 3 + first_installment_date: Optional[datetime] = None # default: anchor.date + + +class InstallmentPlanUpdate(SQLModel): + num_installments: Optional[int] = None + first_installment_date: Optional[datetime] = None + notes: Optional[str] = None + + # --- Exchange Rate --- diff --git a/backend/app/services/installments.py b/backend/app/services/installments.py new file mode 100644 index 0000000..9a1b2b0 --- /dev/null +++ b/backend/app/services/installments.py @@ -0,0 +1,187 @@ +"""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)