mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 11:28:47 +02:00
Tasa Cero: InstallmentPlan model, cuota service, migration
BAC zero-interest financing: an anchor purchase splits into N monthly cuota transactions (truncate-to-0.10 rule, last cuota absorbs rounding; one cuota per 18th-cut billing cycle on the 19th). Pinned against real Financiamientos data. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 ###
|
||||||
@@ -169,6 +169,17 @@ class Transaction(TransactionBase, table=True):
|
|||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
created_at: datetime = Field(default_factory=utcnow)
|
||||||
category: Optional[Category] = Relationship(back_populates="transactions")
|
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):
|
class TransactionCreate(TransactionBase):
|
||||||
@@ -179,6 +190,8 @@ class TransactionRead(TransactionBase):
|
|||||||
id: int
|
id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
category: Optional[CategoryRead] = None
|
category: Optional[CategoryRead] = None
|
||||||
|
installment_plan_id: Optional[int] = None
|
||||||
|
is_installment_anchor: bool = False
|
||||||
|
|
||||||
|
|
||||||
class TransactionUpdate(SQLModel):
|
class TransactionUpdate(SQLModel):
|
||||||
@@ -194,6 +207,39 @@ class TransactionUpdate(SQLModel):
|
|||||||
deferred_to_next_cycle: Optional[bool] = None
|
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 ---
|
# --- Exchange Rate ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
187
backend/app/services/installments.py
Normal file
187
backend/app/services/installments.py
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user