Tasa Cero: auto-detect 'CC ' vouchers + /installment-plans API

Ingestion auto-converts CC-prefixed credit-card purchases (default 3
cuotas, push note appended). CRUD endpoints cover manual convert (e.g.
ALMACENES SIMAN), edit-with-regeneration, and unconvert; anchor deletion
tears down the plan; cuotas cannot be deferred.

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 8b4961d257
commit 3b147bf8be
3 changed files with 277 additions and 4 deletions

View File

@@ -0,0 +1,234 @@
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlmodel import Session, col, select
from app.auth import get_current_user
from app.db import get_session
from app.models.models import (
InstallmentPlan,
InstallmentPlanCreate,
InstallmentPlanUpdate,
Transaction,
TransactionRead,
TransactionSource,
TransactionType,
)
from app.services.installments import (
compute_installment_amounts,
create_plan,
delete_plan,
regenerate_cuotas,
)
from app.timeutil import utcnow
router = APIRouter(prefix="/installment-plans", tags=["installments"])
MIN_INSTALLMENTS = 2
MAX_INSTALLMENTS = 48
class InstallmentPlanRead(BaseModel):
id: int
anchor_transaction_id: int
merchant: str
purchase_date: datetime
num_installments: int
first_installment_date: datetime
total_amount: float
installment_amount: float
last_installment_amount: float
currency: str
notes: Optional[str] = None
cuotas_billed: int
paid_amount: float
remaining_amount: float
next_cuota_date: Optional[datetime] = None
is_completed: bool
class InstallmentPlanDetail(InstallmentPlanRead):
cuotas: list[TransactionRead] = []
class InstallmentPlanListResponse(BaseModel):
plans: list[InstallmentPlanRead]
total_remaining: float
def _validate_num_installments(n: int) -> None:
if not MIN_INSTALLMENTS <= n <= MAX_INSTALLMENTS:
raise HTTPException(
status_code=400,
detail=f"num_installments must be {MIN_INSTALLMENTS}-{MAX_INSTALLMENTS}",
)
def _get_plan_and_anchor(
session: Session, plan_id: int
) -> tuple[InstallmentPlan, Transaction]:
plan = session.get(InstallmentPlan, plan_id)
if not plan:
raise HTTPException(status_code=404, detail="Installment plan not found")
anchor = session.get(Transaction, plan.anchor_transaction_id)
if not anchor:
raise HTTPException(status_code=404, detail="Anchor transaction not found")
return plan, anchor
def _to_read(
plan: InstallmentPlan, anchor: Transaction, cuotas: list[Transaction]
) -> InstallmentPlanRead:
now = utcnow()
billed = [c for c in cuotas if c.date <= now]
future = [c for c in cuotas if c.date > now]
paid = float(sum(c.amount for c in billed))
total = float(plan.total_amount)
amounts = compute_installment_amounts(
plan.total_amount, plan.num_installments
)
return InstallmentPlanRead(
id=plan.id,
anchor_transaction_id=plan.anchor_transaction_id,
merchant=anchor.merchant,
purchase_date=anchor.date,
num_installments=plan.num_installments,
first_installment_date=plan.first_installment_date,
total_amount=total,
installment_amount=float(plan.installment_amount),
last_installment_amount=float(amounts[-1]),
currency=plan.currency.value,
notes=plan.notes,
cuotas_billed=len(billed),
paid_amount=paid,
remaining_amount=total - paid,
next_cuota_date=min((c.date for c in future), default=None),
is_completed=len(billed) == plan.num_installments,
)
def _cuotas_by_plan(
session: Session, plan_ids: list[int]
) -> dict[int, list[Transaction]]:
grouped: dict[int, list[Transaction]] = {pid: [] for pid in plan_ids}
if plan_ids:
rows = session.exec(
select(Transaction)
.where(col(Transaction.installment_plan_id).in_(plan_ids))
.order_by(col(Transaction.date))
).all()
for row in rows:
grouped[row.installment_plan_id].append(row)
return grouped
@router.get("/", response_model=InstallmentPlanListResponse)
def list_installment_plans(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
plans = session.exec(select(InstallmentPlan)).all()
anchors = {
t.id: t
for t in session.exec(
select(Transaction).where(
col(Transaction.id).in_([p.anchor_transaction_id for p in plans])
)
).all()
} if plans else {}
cuotas = _cuotas_by_plan(session, [p.id for p in plans])
reads = [
_to_read(p, anchors[p.anchor_transaction_id], cuotas[p.id])
for p in plans
if p.anchor_transaction_id in anchors
]
reads.sort(key=lambda r: r.purchase_date, reverse=True)
return InstallmentPlanListResponse(
plans=reads,
total_remaining=sum(r.remaining_amount for r in reads),
)
@router.get("/{plan_id}", response_model=InstallmentPlanDetail)
def get_installment_plan(
plan_id: int,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
plan, anchor = _get_plan_and_anchor(session, plan_id)
cuotas = _cuotas_by_plan(session, [plan.id])[plan.id]
read = _to_read(plan, anchor, cuotas)
return InstallmentPlanDetail(**read.model_dump(), cuotas=cuotas)
@router.post("/", response_model=InstallmentPlanRead, status_code=201)
def create_installment_plan(
data: InstallmentPlanCreate,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
"""Convert an existing credit-card purchase into a Tasa Cero plan."""
_validate_num_installments(data.num_installments)
anchor = session.get(Transaction, data.transaction_id)
if not anchor:
raise HTTPException(status_code=404, detail="Transaction not found")
if anchor.source != TransactionSource.CREDIT_CARD:
raise HTTPException(
status_code=400, detail="Only credit card transactions can be financed"
)
if anchor.transaction_type != TransactionType.COMPRA:
raise HTTPException(
status_code=400, detail="Only COMPRA transactions can be financed"
)
if anchor.installment_plan_id:
raise HTTPException(
status_code=400, detail="A cuota cannot be converted to a plan"
)
if anchor.is_installment_anchor:
raise HTTPException(
status_code=409, detail="Transaction already has an installment plan"
)
plan = create_plan(
session, anchor, data.num_installments, data.first_installment_date
)
session.commit()
session.refresh(plan)
cuotas = _cuotas_by_plan(session, [plan.id])[plan.id]
return _to_read(plan, anchor, cuotas)
@router.patch("/{plan_id}", response_model=InstallmentPlanRead)
def update_installment_plan(
plan_id: int,
data: InstallmentPlanUpdate,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
"""Edit a plan; cuota rows are regenerated (per-cuota edits are lost)."""
plan, anchor = _get_plan_and_anchor(session, plan_id)
if data.num_installments is not None:
_validate_num_installments(data.num_installments)
if data.notes is not None:
plan.notes = data.notes
regenerate_cuotas(
session, plan, anchor, data.num_installments, data.first_installment_date
)
session.commit()
session.refresh(plan)
cuotas = _cuotas_by_plan(session, [plan.id])[plan.id]
return _to_read(plan, anchor, cuotas)
@router.delete("/{plan_id}", status_code=204)
def delete_installment_plan(
plan_id: int,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
"""Unconvert: remove the plan and its cuotas; the anchor becomes a normal
transaction again."""
plan, anchor = _get_plan_and_anchor(session, plan_id)
delete_plan(session, plan, anchor)
session.commit()

View File

@@ -21,9 +21,19 @@ from app.models.models import (
TransactionUpdate, TransactionUpdate,
) )
from app.services.budget_projection import get_cycle_range, get_previous_cycle from app.services.budget_projection import (
NOT_INSTALLMENT_ANCHOR,
get_cycle_range,
get_previous_cycle,
)
from app.services.csv_export import build_transactions_csv from app.services.csv_export import build_transactions_csv
from app.services.exchange_rate import get_converted_amount_expr from app.services.exchange_rate import get_converted_amount_expr
from app.services.installments import (
DEFAULT_NUM_INSTALLMENTS,
create_plan,
is_tasa_cero_merchant,
teardown_plan_for_anchor,
)
router = APIRouter(prefix="/transactions", tags=["transactions"]) router = APIRouter(prefix="/transactions", tags=["transactions"])
@@ -130,17 +140,22 @@ def bulk_action(
txs = session.exec( txs = session.exec(
select(Transaction).where(col(Transaction.id).in_(req.ids)) select(Transaction).where(col(Transaction.id).in_(req.ids))
).all() ).all()
affected = 0
for tx in txs: for tx in txs:
if req.action == "delete": if req.action == "delete":
teardown_plan_for_anchor(session, tx)
session.delete(tx) session.delete(tx)
elif req.action == "set_category": elif req.action == "set_category":
tx.category_id = req.category_id tx.category_id = req.category_id
session.add(tx) session.add(tx)
elif req.action == "set_deferred": elif req.action == "set_deferred":
if tx.installment_plan_id:
continue # cuotas of a plan cannot be deferred
tx.deferred_to_next_cycle = bool(req.deferred) tx.deferred_to_next_cycle = bool(req.deferred)
session.add(tx) session.add(tx)
affected += 1
session.commit() session.commit()
return {"affected": len(txs)} return {"affected": affected}
@router.get("/export") @router.get("/export")
@@ -205,7 +220,9 @@ def list_billing_cycles(
# Count transactions in this cycle # Count transactions in this cycle
count_result = session.exec( count_result = session.exec(
select(func.count(), func.coalesce(func.sum(amount_crc), 0)).where( select(func.count(), func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= start, Transaction.date < end Transaction.date >= start,
Transaction.date < end,
NOT_INSTALLMENT_ANCHOR,
) )
).first() ).first()
count = count_result[0] if count_result else 0 count = count_result[0] if count_result else 0
@@ -268,6 +285,20 @@ def create_transaction(
session.commit() session.commit()
session.refresh(tx) session.refresh(tx)
# Tasa Cero auto-detection: BAC prefixes financed purchases with "CC ".
# Default 3 cuotas starting on the purchase date; editable afterwards via
# /installment-plans.
tasa_cero_note = ""
if (
tx.source == TransactionSource.CREDIT_CARD
and tx.transaction_type == TransactionType.COMPRA
and is_tasa_cero_merchant(tx.merchant)
):
create_plan(session, tx, DEFAULT_NUM_INSTALLMENTS, tx.date)
session.commit()
session.refresh(tx)
tasa_cero_note = f" — Tasa Cero {DEFAULT_NUM_INSTALLMENTS} cuotas"
# Send push notification # Send push notification
symbols = {Currency.CRC: "", Currency.USD: "$", Currency.EUR: ""} symbols = {Currency.CRC: "", Currency.USD: "$", Currency.EUR: ""}
symbol = symbols.get(tx.currency, tx.currency.value) symbol = symbols.get(tx.currency, tx.currency.value)
@@ -278,7 +309,7 @@ def create_transaction(
send_push_to_all( send_push_to_all(
session, session,
title=f"{'🏦' if is_income else '💳'} {tx.merchant}", title=f"{'🏦' if is_income else '💳'} {tx.merchant}",
body=f"{amount_str}{tx.bank.value} {label}", body=f"{amount_str}{tx.bank.value} {label}{tasa_cero_note}",
url="/salarios" if is_income else "/budget", url="/salarios" if is_income else "/budget",
) )
@@ -300,6 +331,11 @@ def update_transaction(
if not tx: if not tx:
raise HTTPException(status_code=404, detail="Transaction not found") raise HTTPException(status_code=404, detail="Transaction not found")
update_data = data.model_dump(exclude_unset=True) update_data = data.model_dump(exclude_unset=True)
if update_data.get("deferred_to_next_cycle") is not None and tx.installment_plan_id:
raise HTTPException(
status_code=400,
detail="Cuotas of an installment plan cannot be deferred",
)
for key, value in update_data.items(): for key, value in update_data.items():
setattr(tx, key, value) setattr(tx, key, value)
session.add(tx) session.add(tx)
@@ -317,5 +353,6 @@ def delete_transaction(
tx = session.get(Transaction, transaction_id) tx = session.get(Transaction, transaction_id)
if not tx: if not tx:
raise HTTPException(status_code=404, detail="Transaction not found") raise HTTPException(status_code=404, detail="Transaction not found")
teardown_plan_for_anchor(session, tx)
session.delete(tx) session.delete(tx)
session.commit() session.commit()

View File

@@ -8,6 +8,7 @@ from app.api.v1.endpoints import (
categories, categories,
exchange_rate, exchange_rate,
import_transactions, import_transactions,
installments,
municipal_receipts, municipal_receipts,
notifications, notifications,
pensions, pensions,
@@ -25,6 +26,7 @@ api_router.include_router(accounts.router)
api_router.include_router(categories.router) api_router.include_router(categories.router)
api_router.include_router(transactions.router) api_router.include_router(transactions.router)
api_router.include_router(import_transactions.router) api_router.include_router(import_transactions.router)
api_router.include_router(installments.router)
api_router.include_router(exchange_rate.router) api_router.include_router(exchange_rate.router)
api_router.include_router(tokens.router) api_router.include_router(tokens.router)
api_router.include_router(analytics.router) api_router.include_router(analytics.router)