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:
Carlos Escalante
2026-07-03 16:13:00 -06:00
parent 248417dbab
commit 088062f757
3 changed files with 288 additions and 0 deletions

View File

@@ -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 ---