Compare commits

...

6 Commits

Author SHA1 Message Date
Carlos Escalante
d3b5188b67 Tasa Cero: badges, convert dialog, Financiamientos page
Some checks failed
Deploy to VPS / test (push) Failing after 56m9s
Deploy to VPS / deploy (push) Has been skipped
Violet 'Tasa Cero'/'Cuota' badges (anchor amount struck through like
deferred), convert/edit action on CC purchases with a live cuota
preview, and a Financiamientos page mirroring the bank's tab (progress,
monto cuota, saldo inicial/faltante, totals).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 16:13:18 -06:00
Carlos Escalante
c563618957 Tasa Cero: regenerate API types + installment-plan client helpers
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 16:13:18 -06:00
Carlos Escalante
3cd2927b5f Tasa Cero: backend tests (rounding, scheduling, exclusion, lifecycle)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 16:13:00 -06:00
Carlos Escalante
3b147bf8be 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>
2026-07-03 16:13:00 -06:00
Carlos Escalante
8b4961d257 Tasa Cero: exclude anchors from budget/analytics aggregates
The anchor stays visible in listings but only its cuotas count toward
actuals — future cuotas intentionally show up in future months as
committed spend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 16:13:00 -06:00
Carlos Escalante
088062f757 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>
2026-07-03 16:13:00 -06:00
18 changed files with 1907 additions and 14 deletions

View File

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

View File

@@ -9,7 +9,7 @@ from sqlmodel import Session, func, select
from app.auth import get_current_user
from app.db import get_session
from app.models.models import Category, Transaction, TransactionType
from app.services.budget_projection import get_cycle_range
from app.services.budget_projection import NOT_INSTALLMENT_ANCHOR, get_cycle_range
from app.services.exchange_rate import get_converted_amount_expr
router = APIRouter(prefix="/analytics", tags=["analytics"])
@@ -53,7 +53,10 @@ def spending_by_category(
func.sum(amount_crc).label("total"),
func.count().label("count"),
)
.where(Transaction.transaction_type == TransactionType.COMPRA)
.where(
Transaction.transaction_type == TransactionType.COMPRA,
NOT_INSTALLMENT_ANCHOR,
)
.group_by(Transaction.category_id)
)
@@ -126,6 +129,7 @@ def monthly_trend(
Transaction.transaction_type == TransactionType.COMPRA,
Transaction.date >= start,
Transaction.date < end,
NOT_INSTALLMENT_ANCHOR,
)
).first()
@@ -171,7 +175,10 @@ def daily_spending(
func.sum(amount_crc).label("total"),
func.count().label("count"),
)
.where(Transaction.transaction_type == TransactionType.COMPRA)
.where(
Transaction.transaction_type == TransactionType.COMPRA,
NOT_INSTALLMENT_ANCHOR,
)
.group_by(func.date(Transaction.date))
.order_by(func.date(Transaction.date))
)

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,
)
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()

View File

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

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

View File

@@ -23,6 +23,11 @@ FRESH_START_MONTH = 3
# Income-like transaction types that should never be counted as expenses
INCOME_TYPES = (TransactionType.DEPOSITO, TransactionType.SALARY)
# Tasa Cero: the original purchase ("anchor") is excluded from all budget
# aggregates — its generated cuota transactions are counted instead.
# See app/services/installments.py.
NOT_INSTALLMENT_ANCHOR = Transaction.is_installment_anchor == False # noqa: E712
def get_effective_amount(item: RecurringItem, month: int, year: int) -> float | None:
"""Return the effective amount for a recurring item in a given month, or None if inactive."""
@@ -133,7 +138,7 @@ def compute_actuals_by_source(
func.coalesce(func.sum(amount_crc), 0),
func.count(),
)
.where(*where)
.where(NOT_INSTALLMENT_ANCHOR, *where)
.group_by(Transaction.source, Transaction.transaction_type)
).all()
return {(s, t): (float(total), cnt) for s, t, total, cnt in rows}
@@ -225,6 +230,7 @@ def compute_actuals_by_category(
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
NOT_INSTALLMENT_ANCHOR,
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
@@ -245,6 +251,7 @@ def compute_actuals_by_category(
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
NOT_INSTALLMENT_ANCHOR,
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
@@ -265,6 +272,7 @@ def compute_actuals_by_category(
Transaction.source != TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
NOT_INSTALLMENT_ANCHOR,
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
@@ -306,6 +314,7 @@ def compute_cc_by_category(
Transaction.date < cc_end,
Transaction.source == TransactionSource.CREDIT_CARD,
col(Transaction.transaction_type).notin_(INCOME_TYPES),
NOT_INSTALLMENT_ANCHOR,
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
@@ -324,6 +333,7 @@ def compute_cc_by_category(
Transaction.date < prev_end,
Transaction.source == TransactionSource.CREDIT_CARD,
col(Transaction.transaction_type).notin_(INCOME_TYPES),
NOT_INSTALLMENT_ANCHOR,
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
@@ -440,6 +450,7 @@ def compute_monthly_projection(
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
NOT_INSTALLMENT_ANCHOR,
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
.group_by(Transaction.transaction_type)
@@ -455,6 +466,7 @@ def compute_monthly_projection(
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
NOT_INSTALLMENT_ANCHOR,
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
.group_by(Transaction.transaction_type)
@@ -470,6 +482,7 @@ def compute_monthly_projection(
Transaction.source != TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
NOT_INSTALLMENT_ANCHOR,
)
.group_by(Transaction.transaction_type)
).all()

View 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)

View File

@@ -0,0 +1,385 @@
"""Tasa Cero installment plans: rounding, scheduling, budget exclusion,
auto-detection at ingestion, regeneration, unconvert, and cascade behavior.
Rounding and scheduling cases are pinned against real BAC Financiamientos
data (2026-05/07). Endpoint functions are exercised directly with a session,
same style as test_import_review_and_bulk.py.
"""
from datetime import datetime
from decimal import Decimal
import pytest
from fastapi import HTTPException
from app.api.v1.endpoints.installments import (
create_installment_plan,
delete_installment_plan,
get_installment_plan,
list_installment_plans,
update_installment_plan,
)
from app.api.v1.endpoints.transactions import (
BulkActionRequest,
bulk_action,
create_transaction,
delete_transaction,
update_transaction,
)
from app.models.models import (
Category,
InstallmentPlan,
InstallmentPlanCreate,
InstallmentPlanUpdate,
Transaction,
TransactionCreate,
TransactionSource,
TransactionType,
TransactionUpdate,
)
from app.services.budget_projection import (
compute_actuals_by_source,
get_cycle_range,
)
from app.services.installments import (
compute_installment_amounts,
compute_installment_dates,
is_tasa_cero_merchant,
)
from sqlmodel import select
def make_cc_tx(
session,
merchant="CC CONSTRUPLAZA",
amount="111053.60",
date=datetime(2026, 6, 25, 15, 43),
reference="75669363",
source=TransactionSource.CREDIT_CARD,
**kw,
):
tx = Transaction(
amount=Decimal(amount),
merchant=merchant,
date=date,
reference=reference,
source=source,
**kw,
)
session.add(tx)
session.commit()
session.refresh(tx)
return tx
def convert(session, tx, n=3, first=None):
return create_installment_plan(
InstallmentPlanCreate(
transaction_id=tx.id, num_installments=n, first_installment_date=first
),
session=session,
_user="t",
)
def get_cuotas(session, plan_id):
return session.exec(
select(Transaction)
.where(Transaction.installment_plan_id == plan_id)
.order_by(Transaction.date)
).all()
class TestRounding:
"""Pinned against real BAC plans."""
@pytest.mark.parametrize(
"total,n,per,last",
[
("111053.60", 3, "37017.80", "37018.00"),
("425800.00", 6, "70966.60", "70967.00"),
("98632.00", 3, "32877.30", "32877.40"),
("190864.80", 3, "63621.60", "63621.60"), # divides exactly
],
)
def test_real_bac_plans(self, total, n, per, last):
amounts = compute_installment_amounts(Decimal(total), n)
assert amounts[:-1] == [Decimal(per)] * (n - 1)
assert amounts[-1] == Decimal(last)
def test_sum_invariant(self):
for total, n in [("100.00", 3), ("214800.00", 3), ("99999.99", 7)]:
amounts = compute_installment_amounts(Decimal(total), n)
assert sum(amounts) == Decimal(total)
assert len(amounts) == n
class TestScheduling:
def test_first_before_cut_stays_in_purchase_cycle(self):
# 14/06 purchase (cycle 18/05-18/06) -> cuotas 19/06, 19/07
dates = compute_installment_dates(datetime(2026, 6, 14, 19, 22), 3)
assert dates == [
datetime(2026, 6, 14, 19, 22),
datetime(2026, 6, 19),
datetime(2026, 7, 19),
]
def test_first_after_cut(self):
# 27/06 purchase (cycle 18/06-18/07) -> cuotas 19/07, 19/08
dates = compute_installment_dates(datetime(2026, 6, 27, 19, 43), 3)
assert dates == [
datetime(2026, 6, 27, 19, 43),
datetime(2026, 7, 19),
datetime(2026, 8, 19),
]
def test_december_wrap(self):
dates = compute_installment_dates(datetime(2026, 12, 20), 3)
assert dates == [
datetime(2026, 12, 20),
datetime(2027, 1, 19),
datetime(2027, 2, 19),
]
def test_consecutive_cycles(self):
"""Each cuota lands in its own consecutive billing cycle."""
dates = compute_installment_dates(datetime(2026, 5, 5, 11, 19), 6)
cycles = []
for d in dates:
if d.day >= 18:
cy, cm = d.year, d.month
else:
cy, cm = (d.year, d.month - 1) if d.month > 1 else (d.year - 1, 12)
start, end = get_cycle_range(cy, cm)
assert start <= d < end
cycles.append((cy, cm))
assert cycles == [(2026, m) for m in range(4, 10)]
class TestDetection:
@pytest.mark.parametrize(
"merchant,expected",
[
("CC CONSTRUPLAZA", True),
("CC TILO.CO", True),
("CONSTRUPLAZA S", False),
("ALMACENES SIMAN", False),
("MERCCADO", False),
],
)
def test_merchant_regex(self, merchant, expected):
assert is_tasa_cero_merchant(merchant) is expected
def test_ingestion_auto_creates_plan(self, session):
tx = create_transaction(
TransactionCreate(
amount=Decimal("301614.60"),
merchant="CC CONSTRUPLAZA",
date=datetime(2026, 6, 27, 19, 43),
reference="24724909",
),
session=session,
_user="t",
)
assert tx.is_installment_anchor is True
plan = session.exec(
select(InstallmentPlan).where(
InstallmentPlan.anchor_transaction_id == tx.id
)
).one()
assert plan.num_installments == 3
assert plan.first_installment_date == tx.date
cuotas = get_cuotas(session, plan.id)
assert [c.amount for c in cuotas] == [
Decimal("100538.20"),
Decimal("100538.20"),
Decimal("100538.20"),
]
assert cuotas[0].merchant == "CC CONSTRUPLAZA CUOTA:01/03"
assert cuotas[0].reference == "24724909-C01"
assert all(c.installment_plan_id == plan.id for c in cuotas)
@pytest.mark.parametrize(
"kw",
[
{"merchant": "WALMART"},
{"transaction_type": TransactionType.DEVOLUCION},
{"source": TransactionSource.CASH},
],
)
def test_ingestion_ignores_non_tasa_cero(self, session, kw):
data = dict(
amount=Decimal("1000"),
merchant="CC CONSTRUPLAZA",
date=datetime(2026, 6, 1),
)
data.update(kw)
tx = create_transaction(
TransactionCreate(**data), session=session, _user="t"
)
assert tx.is_installment_anchor is False
assert session.exec(select(InstallmentPlan)).all() == []
def test_duplicate_reference_still_409_and_creates_nothing(self, session):
make_cc_tx(session)
with pytest.raises(HTTPException) as exc:
create_transaction(
TransactionCreate(
amount=Decimal("1"),
merchant="CC CONSTRUPLAZA",
date=datetime(2026, 7, 1),
reference="75669363",
),
session=session,
_user="t",
)
assert exc.value.status_code == 409
assert session.exec(select(InstallmentPlan)).all() == []
class TestBudgetExclusion:
def test_anchor_excluded_cuotas_counted_per_month(self, session):
tx = make_cc_tx(session, date=datetime(2026, 6, 25))
convert(session, tx) # cuotas: 25/06, 19/07, 19/08
# Budget month = cycle ENDING on its 18th: 25/06 -> July, 19/07 -> Aug,
# 19/08 -> Sep. The full 111,053.60 must appear nowhere.
by_month = {
m: compute_actuals_by_source(session, 2026, m)["CREDIT_CARD"]["net"]
for m in (6, 7, 8, 9, 10)
}
assert by_month[6] == 0
assert by_month[7] == pytest.approx(37017.80)
assert by_month[8] == pytest.approx(37017.80)
assert by_month[9] == pytest.approx(37018.00)
assert by_month[10] == 0
def test_cuotas_inherit_anchor_category(self, session):
cat = Category(name="Ferretería")
session.add(cat)
session.commit()
tx = make_cc_tx(session, category_id=cat.id)
plan = convert(session, tx)
assert all(
c.category_id == cat.id for c in get_cuotas(session, plan.id)
)
class TestPlanLifecycle:
def test_manual_convert_validations(self, session):
tx = make_cc_tx(session)
convert(session, tx)
# already converted
with pytest.raises(HTTPException) as exc:
convert(session, tx)
assert exc.value.status_code == 409
# a cuota cannot itself be converted
cuota = session.exec(
select(Transaction).where(Transaction.installment_plan_id.is_not(None))
).first()
with pytest.raises(HTTPException) as exc:
convert(session, cuota)
assert exc.value.status_code == 400
# non-CC rejected
cash = make_cc_tx(
session, reference="r-cash", source=TransactionSource.CASH
)
with pytest.raises(HTTPException) as exc:
convert(session, cash)
assert exc.value.status_code == 400
def test_regenerate_3_to_6(self, session):
tx = make_cc_tx(session, amount="425800.00", reference="58710871")
plan = convert(session, tx)
updated = update_installment_plan(
plan.id,
InstallmentPlanUpdate(
num_installments=6,
first_installment_date=datetime(2026, 5, 19),
),
session=session,
_user="t",
)
assert updated.num_installments == 6
assert updated.installment_amount == pytest.approx(70966.60)
assert updated.last_installment_amount == pytest.approx(70967.00)
cuotas = get_cuotas(session, plan.id)
assert len(cuotas) == 6
assert cuotas[0].date == datetime(2026, 5, 19)
assert cuotas[-1].date == datetime(2026, 10, 19)
assert cuotas[0].reference == "58710871-C01"
assert cuotas[-1].merchant == "CC CONSTRUPLAZA CUOTA:06/06"
def test_unconvert_restores_anchor(self, session):
tx = make_cc_tx(session)
plan = convert(session, tx)
delete_installment_plan(plan.id, session=session, _user="t")
session.refresh(tx)
assert tx.is_installment_anchor is False
assert get_cuotas(session, plan.id) == []
assert session.exec(select(InstallmentPlan)).all() == []
def test_delete_anchor_tears_down_plan(self, session):
tx = make_cc_tx(session)
plan = convert(session, tx)
delete_transaction(tx.id, session=session, _user="t")
assert session.exec(select(InstallmentPlan)).all() == []
assert get_cuotas(session, plan.id) == []
def test_bulk_delete_anchor_tears_down_plan(self, session):
tx = make_cc_tx(session)
convert(session, tx)
bulk_action(
BulkActionRequest(ids=[tx.id], action="delete"),
session=session,
_user="t",
)
assert session.exec(select(InstallmentPlan)).all() == []
assert session.exec(select(Transaction)).all() == []
def test_list_and_detail(self, session):
tx = make_cc_tx(session, date=datetime(2026, 5, 12, 15, 41))
convert(session, tx, first=datetime(2026, 5, 19))
resp = list_installment_plans(session=session, _user="t")
assert len(resp.plans) == 1
plan = resp.plans[0]
assert plan.merchant == "CC CONSTRUPLAZA"
assert plan.total_amount == pytest.approx(111053.60)
assert plan.remaining_amount + plan.paid_amount == pytest.approx(
111053.60
)
assert resp.total_remaining == pytest.approx(plan.remaining_amount)
detail = get_installment_plan(plan.id, session=session, _user="t")
assert len(detail.cuotas) == 3
class TestDeferredGuards:
def test_patch_cuota_deferred_rejected(self, session):
tx = make_cc_tx(session)
plan = convert(session, tx)
cuota = get_cuotas(session, plan.id)[0]
with pytest.raises(HTTPException) as exc:
update_transaction(
cuota.id,
TransactionUpdate(deferred_to_next_cycle=True),
session=session,
_user="t",
)
assert exc.value.status_code == 400
def test_bulk_set_deferred_skips_cuotas(self, session):
tx = make_cc_tx(session)
plan = convert(session, tx)
cuota = get_cuotas(session, plan.id)[0]
normal = make_cc_tx(session, merchant="WALMART", reference="w1")
resp = bulk_action(
BulkActionRequest(
ids=[cuota.id, normal.id], action="set_deferred", deferred=True
),
session=session,
_user="t",
)
assert resp["affected"] == 1
session.refresh(cuota)
session.refresh(normal)
assert cuota.deferred_to_next_cycle is False
assert normal.deferred_to_next_cycle is True

View File

@@ -20,6 +20,7 @@ import Asistente from "./pages/Asistente";
import Analytics from "./pages/Analytics";
import Budget from "./pages/Budget";
import Salarios from "./pages/Salarios";
import Financiamientos from "./pages/Financiamientos";
import Pensions from "./pages/Pensions";
import Proyecciones from "./pages/Proyecciones";
import ServiciosMunicipales from "./pages/ServiciosMunicipales";
@@ -56,6 +57,7 @@ function AppRoutes() {
<Route path="/analytics" element={<Analytics />} />
<Route path="/proyecciones" element={<Proyecciones />} />
<Route path="/salarios" element={<Salarios />} />
<Route path="/financiamientos" element={<Financiamientos />} />
<Route path="/pensions" element={<Pensions />} />
<Route path="/servicios-municipales" element={<ServiciosMunicipales />} />
<Route path="/sync" element={<SyncStatus />} />

View File

@@ -8,6 +8,7 @@ import {
Landmark,
PiggyBank,
Droplets,
Layers,
LogOut,
TrendingUp,
Wallet,
@@ -54,6 +55,7 @@ const navSections: NavSection[] = [
items: [
{ to: "/budget", icon: Calculator, label: "Presupuesto" },
{ to: "/salarios", icon: Landmark, label: "Salarios" },
{ to: "/financiamientos", icon: Layers, label: "Financiamientos" },
{ to: "/pensions", icon: PiggyBank, label: "Pensiones" },
{ to: "/proyecciones", icon: TrendingUp, label: "Proyecciones" },
{ to: "/planificador", icon: Telescope, label: "Planificador" },

View File

@@ -9,6 +9,7 @@ import {
ArrowLeftRight,
ArrowRightFromLine,
Banknote,
Layers,
} from 'lucide-react';
import { toast } from 'sonner';
@@ -46,6 +47,7 @@ export interface TransactionListProps {
showSourceIcon?: boolean;
addLabel?: string;
onToggleDeferred?: (tx: Transaction) => void;
onConvertTasaCero?: (tx: Transaction) => void;
}
export default function TransactionList({
@@ -61,6 +63,7 @@ export default function TransactionList({
showSourceIcon = false,
addLabel = 'Add Transaction',
onToggleDeferred,
onConvertTasaCero,
}: TransactionListProps) {
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Transaction | null>(null);
@@ -165,6 +168,7 @@ export default function TransactionList({
onEdit: handleEdit,
onDelete: (id) => setDeleteId(id),
onToggleDeferred,
onConvertTasaCero,
selection: {
selected,
allSelected,
@@ -182,7 +186,7 @@ export default function TransactionList({
},
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[showCategory, showSourceIcon, onToggleDeferred, selected, allSelected, transactions],
[showCategory, showSourceIcon, onToggleDeferred, onConvertTasaCero, selected, allSelected, transactions],
);
const visibleTransactions = pendingDeletes.size
@@ -331,6 +335,11 @@ export default function TransactionList({
? <ArrowLeftRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
: null
)}
{tx.is_installment_anchor && (
<span className="text-[10px] text-violet-600 border border-violet-300 rounded px-1 shrink-0">
Tasa Cero
</span>
)}
</div>
<p className="text-xs text-muted-foreground">
{new Date(tx.date).toLocaleDateString('es-CR', { month: 'short', day: 'numeric' })}
@@ -343,14 +352,30 @@ export default function TransactionList({
data-sensitive
className={cn(
'font-mono text-sm font-medium shrink-0',
tx.transaction_type === 'DEVOLUCION' && 'text-primary'
tx.transaction_type === 'DEVOLUCION' && 'text-primary',
(tx.deferred_to_next_cycle || tx.is_installment_anchor) &&
'opacity-50 line-through decoration-muted-foreground/60',
)}
>
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}
{formatAmount(tx.amount, tx.currency)}
</span>
<div className="flex items-center gap-0.5 shrink-0">
{onToggleDeferred && (
{onConvertTasaCero &&
tx.transaction_type === 'COMPRA' &&
tx.installment_plan_id == null && (
<Button
variant="ghost"
size="icon"
title={tx.is_installment_anchor ? 'Editar plan Tasa Cero' : 'Convertir a Tasa Cero'}
aria-label={tx.is_installment_anchor ? 'Edit Tasa Cero plan' : 'Convert to Tasa Cero'}
onClick={() => onConvertTasaCero(tx)}
className={cn(tx.is_installment_anchor && 'text-violet-600')}
>
<Layers className="w-4 h-4" />
</Button>
)}
{onToggleDeferred && tx.installment_plan_id == null && (
<Button
variant="ghost"
size="icon"

View File

@@ -0,0 +1,278 @@
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import {
createInstallmentPlan,
deleteInstallmentPlan,
getInstallmentPlans,
updateInstallmentPlan,
type InstallmentPlan,
type Transaction,
} from '@/lib/api';
import { formatAmount } from '@/lib/format';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
/** Mirror of the backend cuota split (services/installments.py): truncate to
* 0.10, last cuota absorbs the remainder. Preview only — the backend is the
* source of truth. */
function computeAmounts(total: number, n: number): number[] {
const per = Math.floor((total / n) * 10) / 10;
const last = Math.round((total - per * (n - 1)) * 100) / 100;
return [...Array<number>(n - 1).fill(per), last];
}
/** Mirror of the backend schedule: cuota 1 on the chosen date, then the 19th
* of consecutive billing-cycle months (cycles cut on the 18th). */
function computeDates(first: Date, n: number): Date[] {
let cy = first.getFullYear();
let cm = first.getMonth();
if (first.getDate() < 18) {
cm -= 1;
if (cm < 0) {
cm = 11;
cy -= 1;
}
}
const dates = [first];
for (let i = 1; i < n; i++) dates.push(new Date(cy, cm + i, 19));
return dates;
}
function toDateInputValue(iso: string): string {
return iso.slice(0, 10);
}
interface ConvertToInstallmentsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSaved: () => void;
/** Create mode: the transaction to convert. If it is already an anchor,
* its plan is looked up and the dialog switches to edit mode. */
tx?: Transaction | null;
/** Edit mode: the plan to edit (e.g. from the Financiamientos page). */
plan?: InstallmentPlan | null;
}
export default function ConvertToInstallmentsDialog({
open,
onOpenChange,
onSaved,
tx,
plan,
}: ConvertToInstallmentsDialogProps) {
// An anchor transaction without an explicit plan -> find it in the list.
const needsLookup = open && !plan && !!tx?.is_installment_anchor;
const plansQ = useQuery({
queryKey: ['installment-plans'],
queryFn: ({ signal: _s }) => getInstallmentPlans().then((r) => r.data),
enabled: needsLookup,
});
const effectivePlan =
plan ??
(needsLookup
? plansQ.data?.plans.find((p) => p.anchor_transaction_id === tx?.id) ?? null
: null);
const isEdit = !!effectivePlan;
const total = effectivePlan ? effectivePlan.total_amount : tx?.amount ?? 0;
const currency = effectivePlan ? effectivePlan.currency : tx?.currency ?? 'CRC';
const merchant = effectivePlan ? effectivePlan.merchant : tx?.merchant ?? '';
const purchaseDate = effectivePlan
? effectivePlan.purchase_date
: tx?.date ?? new Date().toISOString();
const [numStr, setNumStr] = useState('3');
const [firstDate, setFirstDate] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
setNumStr(String(effectivePlan?.num_installments ?? 3));
setFirstDate(
toDateInputValue(effectivePlan?.first_installment_date ?? purchaseDate),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, effectivePlan?.id, tx?.id]);
const n = parseInt(numStr, 10);
const validN = Number.isFinite(n) && n >= 2 && n <= 48;
const preview = useMemo(() => {
if (!validN || !firstDate) return [];
const amounts = computeAmounts(total, n);
const dates = computeDates(new Date(`${firstDate}T00:00:00`), n);
return amounts.map((amount, i) => ({ amount, date: dates[i] }));
}, [validN, n, firstDate, total]);
const handleSave = async () => {
if (!validN) return;
setSaving(true);
try {
if (isEdit && effectivePlan) {
await updateInstallmentPlan(effectivePlan.id, {
num_installments: n,
first_installment_date: firstDate,
});
toast.success('Plan Tasa Cero actualizado');
} else if (tx) {
await createInstallmentPlan({
transaction_id: tx.id,
num_installments: n,
first_installment_date: firstDate,
});
toast.success(`Convertida a Tasa Cero (${n} cuotas)`);
}
onSaved();
onOpenChange(false);
} catch {
toast.error('No se pudo guardar el plan Tasa Cero');
} finally {
setSaving(false);
}
};
const handleUnconvert = async () => {
if (!effectivePlan) return;
const confirmed = window.confirm(
'Se eliminarán las cuotas y la compra volverá a contar como un solo cargo. ¿Continuar?',
);
if (!confirmed) return;
setSaving(true);
try {
await deleteInstallmentPlan(effectivePlan.id);
toast.success('Plan Tasa Cero eliminado');
onSaved();
onOpenChange(false);
} catch {
toast.error('No se pudo eliminar el plan');
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={(next) => !saving && onOpenChange(next)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{isEdit ? 'Editar plan Tasa Cero' : 'Convertir a Tasa Cero'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="rounded-lg border bg-muted/40 px-3 py-2 text-sm">
<p className="font-medium truncate">{merchant}</p>
<p className="text-muted-foreground">
<span data-sensitive className="font-mono">
{formatAmount(total, currency)}
</span>
{' — '}
{new Date(purchaseDate).toLocaleDateString('es-CR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</p>
</div>
{needsLookup && plansQ.isPending ? (
<p className="text-sm text-muted-foreground">Cargando plan</p>
) : (
<>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="tc-cuotas">Número de cuotas</Label>
<Input
id="tc-cuotas"
type="number"
min={2}
max={48}
value={numStr}
onChange={(e) => setNumStr(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="tc-first">Primera cuota</Label>
<Input
id="tc-first"
type="date"
value={firstDate}
onChange={(e) => setFirstDate(e.target.value)}
/>
</div>
</div>
{preview.length > 0 && (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
Cuotas proyectadas
</Label>
<div className="rounded-lg border divide-y divide-border max-h-44 overflow-y-auto">
{preview.map((c, i) => (
<div
key={i}
className="flex items-center justify-between px-3 py-1.5 text-sm"
>
<span className="text-muted-foreground font-mono text-xs">
{String(i + 1).padStart(2, '0')}/
{String(n).padStart(2, '0')} {' '}
{c.date.toLocaleDateString('es-CR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</span>
<span data-sensitive className="font-mono">
{formatAmount(c.amount, currency)}
</span>
</div>
))}
</div>
</div>
)}
</>
)}
</div>
<DialogFooter className="gap-2 sm:justify-between">
{isEdit ? (
<Button
variant="outline"
className="text-destructive hover:text-destructive"
onClick={handleUnconvert}
disabled={saving}
>
Deshacer Tasa Cero
</Button>
) : (
<span />
)}
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
Cancelar
</Button>
<Button
onClick={handleSave}
disabled={saving || !validN || !firstDate || (needsLookup && !effectivePlan)}
>
{saving ? 'Guardando…' : isEdit ? 'Guardar' : 'Convertir'}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,5 +1,5 @@
import { type ColumnDef } from '@tanstack/react-table';
import { ArrowLeftRight, ArrowRightFromLine, Banknote, Pencil, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
import { ArrowLeftRight, ArrowRightFromLine, Banknote, Layers, Pencil, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
import { type Transaction } from '@/lib/api';
import { formatAmount } from '@/lib/format';
@@ -21,6 +21,7 @@ interface TransactionColumnOptions {
onEdit: (tx: Transaction) => void;
onDelete: (txId: number) => void;
onToggleDeferred?: (tx: Transaction) => void;
onConvertTasaCero?: (tx: Transaction) => void;
selection?: RowSelection;
}
@@ -30,6 +31,7 @@ export function getTransactionColumns({
onEdit,
onDelete,
onToggleDeferred,
onConvertTasaCero,
selection,
}: TransactionColumnOptions): ColumnDef<Transaction, unknown>[] {
const columns: ColumnDef<Transaction, unknown>[] = [];
@@ -107,6 +109,16 @@ export function getTransactionColumns({
Diferida
</Badge>
)}
{tx.is_installment_anchor && (
<Badge variant="outline" className="ml-1.5 text-[10px] px-1 py-0 shrink-0 text-violet-600 border-violet-300">
Tasa Cero
</Badge>
)}
{tx.installment_plan_id != null && (
<Badge variant="outline" className="ml-1.5 text-[10px] px-1 py-0 shrink-0 text-violet-500/80 border-violet-200">
Cuota
</Badge>
)}
</div>
);
},
@@ -144,7 +156,8 @@ export function getTransactionColumns({
className={cn(
'font-mono font-medium',
tx.transaction_type !== 'COMPRA' && 'text-primary',
tx.deferred_to_next_cycle && 'opacity-50 line-through decoration-muted-foreground/60',
(tx.deferred_to_next_cycle || tx.is_installment_anchor) &&
'opacity-50 line-through decoration-muted-foreground/60',
)}
>
{tx.transaction_type === 'COMPRA' ? '-' : '+'}
@@ -162,7 +175,21 @@ export function getTransactionColumns({
const tx = row.original;
return (
<div className="flex items-center justify-end gap-1">
{onToggleDeferred && (
{onConvertTasaCero &&
tx.transaction_type === 'COMPRA' &&
tx.installment_plan_id == null && (
<Button
variant="ghost"
size="icon"
title={tx.is_installment_anchor ? 'Editar plan Tasa Cero' : 'Convertir a Tasa Cero'}
aria-label={tx.is_installment_anchor ? 'Edit Tasa Cero plan' : 'Convert to Tasa Cero'}
onClick={() => onConvertTasaCero(tx)}
className={cn(tx.is_installment_anchor && 'text-violet-600')}
>
<Layers className="w-4 h-4" />
</Button>
)}
{onToggleDeferred && tx.installment_plan_id == null && (
<Button
variant="ghost"
size="icon"

View File

@@ -396,6 +396,53 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/installment-plans/": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Installment Plans */
get: operations["list_installment_plans_api_v1_installment_plans__get"];
put?: never;
/**
* Create Installment Plan
* @description Convert an existing credit-card purchase into a Tasa Cero plan.
*/
post: operations["create_installment_plan_api_v1_installment_plans__post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/installment-plans/{plan_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Installment Plan */
get: operations["get_installment_plan_api_v1_installment_plans__plan_id__get"];
put?: never;
post?: never;
/**
* Delete Installment Plan
* @description Unconvert: remove the plan and its cuotas; the anchor becomes a normal
* transaction again.
*/
delete: operations["delete_installment_plan_api_v1_installment_plans__plan_id__delete"];
options?: never;
head?: never;
/**
* Update Installment Plan
* @description Edit a plan; cuota rows are regenerated (per-cuota edits are lost).
*/
patch: operations["update_installment_plan_api_v1_installment_plans__plan_id__patch"];
trace?: never;
};
"/api/v1/municipal-receipts/": {
parameters: {
query?: never;
@@ -1189,6 +1236,121 @@ export interface components {
/** Detail */
detail?: components["schemas"]["ValidationError"][];
};
/** InstallmentPlanCreate */
InstallmentPlanCreate: {
/** First Installment Date */
first_installment_date?: string | null;
/**
* Num Installments
* @default 3
*/
num_installments: number;
/** Transaction Id */
transaction_id: number;
};
/** InstallmentPlanDetail */
InstallmentPlanDetail: {
/** Anchor Transaction Id */
anchor_transaction_id: number;
/**
* Cuotas
* @default []
*/
cuotas: components["schemas"]["TransactionRead"][];
/** Cuotas Billed */
cuotas_billed: number;
/** Currency */
currency: string;
/**
* First Installment Date
* Format: date-time
*/
first_installment_date: string;
/** Id */
id: number;
/** Installment Amount */
installment_amount: number;
/** Is Completed */
is_completed: boolean;
/** Last Installment Amount */
last_installment_amount: number;
/** Merchant */
merchant: string;
/** Next Cuota Date */
next_cuota_date?: string | null;
/** Notes */
notes?: string | null;
/** Num Installments */
num_installments: number;
/** Paid Amount */
paid_amount: number;
/**
* Purchase Date
* Format: date-time
*/
purchase_date: string;
/** Remaining Amount */
remaining_amount: number;
/** Total Amount */
total_amount: number;
};
/** InstallmentPlanListResponse */
InstallmentPlanListResponse: {
/** Plans */
plans: components["schemas"]["InstallmentPlanRead"][];
/** Total Remaining */
total_remaining: number;
};
/** InstallmentPlanRead */
InstallmentPlanRead: {
/** Anchor Transaction Id */
anchor_transaction_id: number;
/** Cuotas Billed */
cuotas_billed: number;
/** Currency */
currency: string;
/**
* First Installment Date
* Format: date-time
*/
first_installment_date: string;
/** Id */
id: number;
/** Installment Amount */
installment_amount: number;
/** Is Completed */
is_completed: boolean;
/** Last Installment Amount */
last_installment_amount: number;
/** Merchant */
merchant: string;
/** Next Cuota Date */
next_cuota_date?: string | null;
/** Notes */
notes?: string | null;
/** Num Installments */
num_installments: number;
/** Paid Amount */
paid_amount: number;
/**
* Purchase Date
* Format: date-time
*/
purchase_date: string;
/** Remaining Amount */
remaining_amount: number;
/** Total Amount */
total_amount: number;
};
/** InstallmentPlanUpdate */
InstallmentPlanUpdate: {
/** First Installment Date */
first_installment_date?: string | null;
/** Notes */
notes?: string | null;
/** Num Installments */
num_installments?: number | null;
};
/** LoginRequest */
LoginRequest: {
/** Password */
@@ -1857,6 +2019,13 @@ export interface components {
deferred_to_next_cycle: boolean;
/** Id */
id: number;
/** Installment Plan Id */
installment_plan_id?: number | null;
/**
* Is Installment Anchor
* @default false
*/
is_installment_anchor: boolean;
/** Merchant */
merchant: string;
/** Notes */
@@ -2955,6 +3124,183 @@ export interface operations {
};
};
};
list_installment_plans_api_v1_installment_plans__get: {
parameters: {
query?: never;
header?: {
authorization?: string | null;
};
path?: never;
cookie?: {
ws_token?: string | null;
};
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["InstallmentPlanListResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
create_installment_plan_api_v1_installment_plans__post: {
parameters: {
query?: never;
header?: {
authorization?: string | null;
};
path?: never;
cookie?: {
ws_token?: string | null;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["InstallmentPlanCreate"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["InstallmentPlanRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_installment_plan_api_v1_installment_plans__plan_id__get: {
parameters: {
query?: never;
header?: {
authorization?: string | null;
};
path: {
plan_id: number;
};
cookie?: {
ws_token?: string | null;
};
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["InstallmentPlanDetail"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
delete_installment_plan_api_v1_installment_plans__plan_id__delete: {
parameters: {
query?: never;
header?: {
authorization?: string | null;
};
path: {
plan_id: number;
};
cookie?: {
ws_token?: string | null;
};
};
requestBody?: never;
responses: {
/** @description Successful Response */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
update_installment_plan_api_v1_installment_plans__plan_id__patch: {
parameters: {
query?: never;
header?: {
authorization?: string | null;
};
path: {
plan_id: number;
};
cookie?: {
ws_token?: string | null;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["InstallmentPlanUpdate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["InstallmentPlanRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_receipts_api_v1_municipal_receipts__get: {
parameters: {
query?: never;

View File

@@ -240,6 +240,35 @@ export const upsertBalanceOverride = (
export const deleteBalanceOverride = (year: number, month: number) =>
api.delete(`/budget/balance-override/${year}/${month}`);
// --- Installment Plans (Tasa Cero) ---
export type InstallmentPlan = Schema<'InstallmentPlanRead'>;
export type InstallmentPlanDetail = Schema<'InstallmentPlanDetail'>;
export type InstallmentPlanListResponse = Schema<'InstallmentPlanListResponse'>;
export interface InstallmentPlanCreate {
transaction_id: number;
num_installments?: number;
first_installment_date?: string | null;
}
export interface InstallmentPlanUpdate {
num_installments?: number;
first_installment_date?: string | null;
notes?: string | null;
}
export const getInstallmentPlans = () =>
api.get<InstallmentPlanListResponse>("/installment-plans/");
export const getInstallmentPlanDetail = (id: number) =>
api.get<InstallmentPlanDetail>(`/installment-plans/${id}`);
export const createInstallmentPlan = (data: InstallmentPlanCreate) =>
api.post<InstallmentPlan>("/installment-plans/", data);
export const updateInstallmentPlan = (id: number, data: InstallmentPlanUpdate) =>
api.patch<InstallmentPlan>(`/installment-plans/${id}`, data);
export const deleteInstallmentPlan = (id: number) =>
api.delete(`/installment-plans/${id}`);
// --- Salarios ---
export type SalariosSummary = Schema<'SalariosSummary'>;

View File

@@ -14,6 +14,7 @@ import PeriodNavigator from '@/components/PeriodNavigator';
import MonthlyDetail from '@/components/budget/MonthlyDetail';
import RecurringItemsManager from '@/components/budget/RecurringItemsManager';
import TransactionList from '@/components/TransactionList';
import ConvertToInstallmentsDialog from '@/components/transactions/ConvertToInstallmentsDialog';
const MIN_YEAR = 2026;
@@ -44,6 +45,7 @@ export default function Budget() {
const [subTab, setSubTab] = useState<'detail' | 'transactions'>('detail');
const [txSearch, setTxSearch] = useState('');
const [txSource, setTxSource] = useState<'CREDIT_CARD' | 'CASH_AND_TRANSFER'>('CREDIT_CARD');
const [convertTx, setConvertTx] = useState<Transaction | null>(null);
const txQuery = useQuery({
queryKey: ['transactions', 'budget', year, selectedMonth, txSource, txSearch],
@@ -79,6 +81,7 @@ export default function Budget() {
const invalidateTransactionsAndBudget = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['transactions'] });
queryClient.invalidateQueries({ queryKey: ['installment-plans'] });
refresh();
}, [queryClient, refresh]);
@@ -209,6 +212,7 @@ export default function Budget() {
showSourceIcon={txSource === 'CASH_AND_TRANSFER'}
emptyMessage={`Sin transacciones de ${txSource === 'CREDIT_CARD' ? 'tarjeta' : 'efectivo o transferencia'} en ${MONTH_NAMES[selectedMonth]}`}
onToggleDeferred={txSource === 'CREDIT_CARD' ? handleToggleDeferred : undefined}
onConvertTasaCero={txSource === 'CREDIT_CARD' ? setConvertTx : undefined}
/>
)}
</TabsContent>
@@ -224,6 +228,13 @@ export default function Budget() {
/>
</TabsContent>
</Tabs>
<ConvertToInstallmentsDialog
open={convertTx !== null}
onOpenChange={(open) => !open && setConvertTx(null)}
onSaved={invalidateTransactionsAndBudget}
tx={convertTx}
/>
</div>
);
}

View File

@@ -0,0 +1,207 @@
import { useState } from 'react';
import { Layers } from 'lucide-react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
getInstallmentPlans,
type InstallmentPlan,
} from '@/lib/api';
import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import ErrorState from '@/components/ErrorState';
import ConvertToInstallmentsDialog from '@/components/transactions/ConvertToInstallmentsDialog';
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('es-CR', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
}
function progressLabel(plan: InstallmentPlan) {
return `${String(plan.cuotas_billed).padStart(3, '0')}/${String(plan.num_installments).padStart(3, '0')}`;
}
export default function Financiamientos() {
const queryClient = useQueryClient();
const [editing, setEditing] = useState<InstallmentPlan | null>(null);
const plansQ = useQuery({
queryKey: ['installment-plans'],
queryFn: () => getInstallmentPlans().then((r) => r.data),
});
const plans = plansQ.data?.plans ?? [];
const active = plans.filter((p) => !p.is_completed);
const completed = plans.filter((p) => p.is_completed);
const totalRemaining = plansQ.data?.total_remaining ?? 0;
const totalCuotaMes = active.reduce((sum, p) => sum + p.installment_amount, 0);
const handleSaved = () => {
queryClient.invalidateQueries({ queryKey: ['installment-plans'] });
queryClient.invalidateQueries({ queryKey: ['transactions'] });
queryClient.invalidateQueries({ queryKey: ['budget'] });
};
const renderRows = (rows: InstallmentPlan[]) =>
rows.map((plan) => (
<TableRow
key={plan.id}
tabIndex={0}
role="button"
aria-label={`Editar plan de ${plan.merchant}`}
className={cn(
'cursor-pointer focus-visible:outline-2 focus-visible:outline-ring',
plan.is_completed && 'opacity-60',
)}
onClick={() => setEditing(plan)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setEditing(plan);
}
}}
>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<span className="truncate">{plan.merchant}</span>
{plan.is_completed && (
<Badge variant="outline" className="text-[10px] px-1 py-0 shrink-0">
Completado
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground whitespace-nowrap">
{formatDate(plan.purchase_date)}
</TableCell>
<TableCell className="font-mono text-xs">{progressLabel(plan)}</TableCell>
<TableCell className="text-right font-mono" data-sensitive>
{formatAmount(plan.installment_amount, plan.currency)}
</TableCell>
<TableCell className="text-right font-mono" data-sensitive>
{formatAmount(plan.total_amount, plan.currency)}
</TableCell>
<TableCell className="text-right font-mono font-medium" data-sensitive>
{formatAmount(plan.remaining_amount, plan.currency)}
</TableCell>
<TableCell className="text-muted-foreground whitespace-nowrap">
{plan.next_cuota_date ? formatDate(plan.next_cuota_date) : '—'}
</TableCell>
</TableRow>
));
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Layers className="w-6 h-6 text-primary" />
<h1 className="text-2xl font-bold tracking-tight">Financiamientos</h1>
</div>
<p className="text-sm text-muted-foreground -mt-4">
Compras Tasa Cero pagadas en cuotas mensuales sin intereses. Las cuotas
futuras ya cuentan en el presupuesto de sus meses.
</p>
{/* Summary */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Saldo faltante total
</CardTitle>
</CardHeader>
<CardContent>
<span data-sensitive className="text-2xl font-bold font-mono">
{formatAmount(totalRemaining, 'CRC')}
</span>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Cuotas por mes (activos)
</CardTitle>
</CardHeader>
<CardContent>
<span data-sensitive className="text-2xl font-bold font-mono">
{formatAmount(totalCuotaMes, 'CRC')}
</span>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Planes activos
</CardTitle>
</CardHeader>
<CardContent>
<span className="text-2xl font-bold font-mono">{active.length}</span>
{completed.length > 0 && (
<span className="ml-2 text-sm text-muted-foreground">
(+{completed.length} completado{completed.length === 1 ? '' : 's'})
</span>
)}
</CardContent>
</Card>
</div>
{/* Plans table */}
{plansQ.isError ? (
<ErrorState
message="No se pudieron cargar los financiamientos"
onRetry={() => plansQ.refetch()}
/>
) : (
<Card>
<CardContent className="p-0 overflow-x-auto">
{plans.length === 0 && !plansQ.isPending ? (
<div className="px-5 py-16 text-center text-muted-foreground text-sm">
<Layers className="w-8 h-8 mx-auto mb-3 text-muted-foreground/50" />
<p>
Sin financiamientos. Convertí una compra de tarjeta a Tasa
Cero desde la lista de transacciones.
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Comercio</TableHead>
<TableHead>Fecha compra</TableHead>
<TableHead>Cuotas</TableHead>
<TableHead className="text-right">Monto cuota</TableHead>
<TableHead className="text-right">Saldo inicial</TableHead>
<TableHead className="text-right">Saldo faltante</TableHead>
<TableHead>Próxima cuota</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{renderRows(active)}
{renderRows(completed)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)}
<ConvertToInstallmentsDialog
open={editing !== null}
onOpenChange={(open) => !open && setEditing(null)}
onSaved={handleSaved}
plan={editing}
/>
</div>
);
}