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

@@ -21,9 +21,19 @@ from app.models.models import (
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.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"])
@@ -130,17 +140,22 @@ def bulk_action(
txs = session.exec(
select(Transaction).where(col(Transaction.id).in_(req.ids))
).all()
affected = 0
for tx in txs:
if req.action == "delete":
teardown_plan_for_anchor(session, tx)
session.delete(tx)
elif req.action == "set_category":
tx.category_id = req.category_id
session.add(tx)
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)
session.add(tx)
affected += 1
session.commit()
return {"affected": len(txs)}
return {"affected": affected}
@router.get("/export")
@@ -205,7 +220,9 @@ def list_billing_cycles(
# Count transactions in this cycle
count_result = session.exec(
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()
count = count_result[0] if count_result else 0
@@ -268,6 +285,20 @@ def create_transaction(
session.commit()
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
symbols = {Currency.CRC: "", Currency.USD: "$", Currency.EUR: ""}
symbol = symbols.get(tx.currency, tx.currency.value)
@@ -278,7 +309,7 @@ def create_transaction(
send_push_to_all(
session,
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",
)
@@ -300,6 +331,11 @@ def update_transaction(
if not tx:
raise HTTPException(status_code=404, detail="Transaction not found")
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():
setattr(tx, key, value)
session.add(tx)
@@ -317,5 +353,6 @@ def delete_transaction(
tx = session.get(Transaction, transaction_id)
if not tx:
raise HTTPException(status_code=404, detail="Transaction not found")
teardown_plan_for_anchor(session, tx)
session.delete(tx)
session.commit()