mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 10:28:47 +02:00
Compare commits
6 Commits
248417dbab
...
d3b5188b67
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3b5188b67 | ||
|
|
c563618957 | ||
|
|
3cd2927b5f | ||
|
|
3b147bf8be | ||
|
|
8b4961d257 | ||
|
|
088062f757 |
@@ -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 ###
|
||||||
@@ -9,7 +9,7 @@ from sqlmodel import Session, func, select
|
|||||||
from app.auth import get_current_user
|
from app.auth import get_current_user
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.models.models import Category, Transaction, TransactionType
|
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
|
from app.services.exchange_rate import get_converted_amount_expr
|
||||||
|
|
||||||
router = APIRouter(prefix="/analytics", tags=["analytics"])
|
router = APIRouter(prefix="/analytics", tags=["analytics"])
|
||||||
@@ -53,7 +53,10 @@ def spending_by_category(
|
|||||||
func.sum(amount_crc).label("total"),
|
func.sum(amount_crc).label("total"),
|
||||||
func.count().label("count"),
|
func.count().label("count"),
|
||||||
)
|
)
|
||||||
.where(Transaction.transaction_type == TransactionType.COMPRA)
|
.where(
|
||||||
|
Transaction.transaction_type == TransactionType.COMPRA,
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
|
)
|
||||||
.group_by(Transaction.category_id)
|
.group_by(Transaction.category_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -126,6 +129,7 @@ def monthly_trend(
|
|||||||
Transaction.transaction_type == TransactionType.COMPRA,
|
Transaction.transaction_type == TransactionType.COMPRA,
|
||||||
Transaction.date >= start,
|
Transaction.date >= start,
|
||||||
Transaction.date < end,
|
Transaction.date < end,
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -171,7 +175,10 @@ def daily_spending(
|
|||||||
func.sum(amount_crc).label("total"),
|
func.sum(amount_crc).label("total"),
|
||||||
func.count().label("count"),
|
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))
|
.group_by(func.date(Transaction.date))
|
||||||
.order_by(func.date(Transaction.date))
|
.order_by(func.date(Transaction.date))
|
||||||
)
|
)
|
||||||
|
|||||||
234
backend/app/api/v1/endpoints/installments.py
Normal file
234
backend/app/api/v1/endpoints/installments.py
Normal 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()
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ FRESH_START_MONTH = 3
|
|||||||
# Income-like transaction types that should never be counted as expenses
|
# Income-like transaction types that should never be counted as expenses
|
||||||
INCOME_TYPES = (TransactionType.DEPOSITO, TransactionType.SALARY)
|
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:
|
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."""
|
"""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.coalesce(func.sum(amount_crc), 0),
|
||||||
func.count(),
|
func.count(),
|
||||||
)
|
)
|
||||||
.where(*where)
|
.where(NOT_INSTALLMENT_ANCHOR, *where)
|
||||||
.group_by(Transaction.source, Transaction.transaction_type)
|
.group_by(Transaction.source, Transaction.transaction_type)
|
||||||
).all()
|
).all()
|
||||||
return {(s, t): (float(total), cnt) for s, t, total, cnt in rows}
|
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.source == TransactionSource.CREDIT_CARD,
|
||||||
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
||||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||||
)
|
)
|
||||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||||
@@ -245,6 +251,7 @@ def compute_actuals_by_category(
|
|||||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||||
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
||||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||||
)
|
)
|
||||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||||
@@ -265,6 +272,7 @@ def compute_actuals_by_category(
|
|||||||
Transaction.source != TransactionSource.CREDIT_CARD,
|
Transaction.source != TransactionSource.CREDIT_CARD,
|
||||||
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
||||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
)
|
)
|
||||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||||
).all()
|
).all()
|
||||||
@@ -306,6 +314,7 @@ def compute_cc_by_category(
|
|||||||
Transaction.date < cc_end,
|
Transaction.date < cc_end,
|
||||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||||
)
|
)
|
||||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||||
@@ -324,6 +333,7 @@ def compute_cc_by_category(
|
|||||||
Transaction.date < prev_end,
|
Transaction.date < prev_end,
|
||||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||||
)
|
)
|
||||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||||
@@ -440,6 +450,7 @@ def compute_monthly_projection(
|
|||||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||||
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
||||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||||
)
|
)
|
||||||
.group_by(Transaction.transaction_type)
|
.group_by(Transaction.transaction_type)
|
||||||
@@ -455,6 +466,7 @@ def compute_monthly_projection(
|
|||||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||||
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
||||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||||
)
|
)
|
||||||
.group_by(Transaction.transaction_type)
|
.group_by(Transaction.transaction_type)
|
||||||
@@ -470,6 +482,7 @@ def compute_monthly_projection(
|
|||||||
Transaction.source != TransactionSource.CREDIT_CARD,
|
Transaction.source != TransactionSource.CREDIT_CARD,
|
||||||
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
||||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
)
|
)
|
||||||
.group_by(Transaction.transaction_type)
|
.group_by(Transaction.transaction_type)
|
||||||
).all()
|
).all()
|
||||||
|
|||||||
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)
|
||||||
385
backend/tests/test_installments.py
Normal file
385
backend/tests/test_installments.py
Normal 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
|
||||||
@@ -20,6 +20,7 @@ import Asistente from "./pages/Asistente";
|
|||||||
import Analytics from "./pages/Analytics";
|
import Analytics from "./pages/Analytics";
|
||||||
import Budget from "./pages/Budget";
|
import Budget from "./pages/Budget";
|
||||||
import Salarios from "./pages/Salarios";
|
import Salarios from "./pages/Salarios";
|
||||||
|
import Financiamientos from "./pages/Financiamientos";
|
||||||
import Pensions from "./pages/Pensions";
|
import Pensions from "./pages/Pensions";
|
||||||
import Proyecciones from "./pages/Proyecciones";
|
import Proyecciones from "./pages/Proyecciones";
|
||||||
import ServiciosMunicipales from "./pages/ServiciosMunicipales";
|
import ServiciosMunicipales from "./pages/ServiciosMunicipales";
|
||||||
@@ -56,6 +57,7 @@ function AppRoutes() {
|
|||||||
<Route path="/analytics" element={<Analytics />} />
|
<Route path="/analytics" element={<Analytics />} />
|
||||||
<Route path="/proyecciones" element={<Proyecciones />} />
|
<Route path="/proyecciones" element={<Proyecciones />} />
|
||||||
<Route path="/salarios" element={<Salarios />} />
|
<Route path="/salarios" element={<Salarios />} />
|
||||||
|
<Route path="/financiamientos" element={<Financiamientos />} />
|
||||||
<Route path="/pensions" element={<Pensions />} />
|
<Route path="/pensions" element={<Pensions />} />
|
||||||
<Route path="/servicios-municipales" element={<ServiciosMunicipales />} />
|
<Route path="/servicios-municipales" element={<ServiciosMunicipales />} />
|
||||||
<Route path="/sync" element={<SyncStatus />} />
|
<Route path="/sync" element={<SyncStatus />} />
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Landmark,
|
Landmark,
|
||||||
PiggyBank,
|
PiggyBank,
|
||||||
Droplets,
|
Droplets,
|
||||||
|
Layers,
|
||||||
LogOut,
|
LogOut,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Wallet,
|
Wallet,
|
||||||
@@ -54,6 +55,7 @@ const navSections: NavSection[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ to: "/budget", icon: Calculator, label: "Presupuesto" },
|
{ to: "/budget", icon: Calculator, label: "Presupuesto" },
|
||||||
{ to: "/salarios", icon: Landmark, label: "Salarios" },
|
{ to: "/salarios", icon: Landmark, label: "Salarios" },
|
||||||
|
{ to: "/financiamientos", icon: Layers, label: "Financiamientos" },
|
||||||
{ to: "/pensions", icon: PiggyBank, label: "Pensiones" },
|
{ to: "/pensions", icon: PiggyBank, label: "Pensiones" },
|
||||||
{ to: "/proyecciones", icon: TrendingUp, label: "Proyecciones" },
|
{ to: "/proyecciones", icon: TrendingUp, label: "Proyecciones" },
|
||||||
{ to: "/planificador", icon: Telescope, label: "Planificador" },
|
{ to: "/planificador", icon: Telescope, label: "Planificador" },
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
ArrowRightFromLine,
|
ArrowRightFromLine,
|
||||||
Banknote,
|
Banknote,
|
||||||
|
Layers,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -46,6 +47,7 @@ export interface TransactionListProps {
|
|||||||
showSourceIcon?: boolean;
|
showSourceIcon?: boolean;
|
||||||
addLabel?: string;
|
addLabel?: string;
|
||||||
onToggleDeferred?: (tx: Transaction) => void;
|
onToggleDeferred?: (tx: Transaction) => void;
|
||||||
|
onConvertTasaCero?: (tx: Transaction) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TransactionList({
|
export default function TransactionList({
|
||||||
@@ -61,6 +63,7 @@ export default function TransactionList({
|
|||||||
showSourceIcon = false,
|
showSourceIcon = false,
|
||||||
addLabel = 'Add Transaction',
|
addLabel = 'Add Transaction',
|
||||||
onToggleDeferred,
|
onToggleDeferred,
|
||||||
|
onConvertTasaCero,
|
||||||
}: TransactionListProps) {
|
}: TransactionListProps) {
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Transaction | null>(null);
|
const [editing, setEditing] = useState<Transaction | null>(null);
|
||||||
@@ -165,6 +168,7 @@ export default function TransactionList({
|
|||||||
onEdit: handleEdit,
|
onEdit: handleEdit,
|
||||||
onDelete: (id) => setDeleteId(id),
|
onDelete: (id) => setDeleteId(id),
|
||||||
onToggleDeferred,
|
onToggleDeferred,
|
||||||
|
onConvertTasaCero,
|
||||||
selection: {
|
selection: {
|
||||||
selected,
|
selected,
|
||||||
allSelected,
|
allSelected,
|
||||||
@@ -182,7 +186,7 @@ export default function TransactionList({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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
|
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" />
|
? <ArrowLeftRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||||
: null
|
: 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>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{new Date(tx.date).toLocaleDateString('es-CR', { month: 'short', day: 'numeric' })}
|
{new Date(tx.date).toLocaleDateString('es-CR', { month: 'short', day: 'numeric' })}
|
||||||
@@ -343,14 +352,30 @@ export default function TransactionList({
|
|||||||
data-sensitive
|
data-sensitive
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-mono text-sm font-medium shrink-0',
|
'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' ? '+' : '-'}
|
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}
|
||||||
{formatAmount(tx.amount, tx.currency)}
|
{formatAmount(tx.amount, tx.currency)}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-0.5 shrink-0">
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { type ColumnDef } from '@tanstack/react-table';
|
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 { type Transaction } from '@/lib/api';
|
||||||
import { formatAmount } from '@/lib/format';
|
import { formatAmount } from '@/lib/format';
|
||||||
@@ -21,6 +21,7 @@ interface TransactionColumnOptions {
|
|||||||
onEdit: (tx: Transaction) => void;
|
onEdit: (tx: Transaction) => void;
|
||||||
onDelete: (txId: number) => void;
|
onDelete: (txId: number) => void;
|
||||||
onToggleDeferred?: (tx: Transaction) => void;
|
onToggleDeferred?: (tx: Transaction) => void;
|
||||||
|
onConvertTasaCero?: (tx: Transaction) => void;
|
||||||
selection?: RowSelection;
|
selection?: RowSelection;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export function getTransactionColumns({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onToggleDeferred,
|
onToggleDeferred,
|
||||||
|
onConvertTasaCero,
|
||||||
selection,
|
selection,
|
||||||
}: TransactionColumnOptions): ColumnDef<Transaction, unknown>[] {
|
}: TransactionColumnOptions): ColumnDef<Transaction, unknown>[] {
|
||||||
const columns: ColumnDef<Transaction, unknown>[] = [];
|
const columns: ColumnDef<Transaction, unknown>[] = [];
|
||||||
@@ -107,6 +109,16 @@ export function getTransactionColumns({
|
|||||||
Diferida
|
Diferida
|
||||||
</Badge>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -144,7 +156,8 @@ export function getTransactionColumns({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'font-mono font-medium',
|
'font-mono font-medium',
|
||||||
tx.transaction_type !== 'COMPRA' && 'text-primary',
|
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' ? '-' : '+'}
|
{tx.transaction_type === 'COMPRA' ? '-' : '+'}
|
||||||
@@ -162,7 +175,21 @@ export function getTransactionColumns({
|
|||||||
const tx = row.original;
|
const tx = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-end gap-1">
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@@ -396,6 +396,53 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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/": {
|
"/api/v1/municipal-receipts/": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1189,6 +1236,121 @@ export interface components {
|
|||||||
/** Detail */
|
/** Detail */
|
||||||
detail?: components["schemas"]["ValidationError"][];
|
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 */
|
||||||
LoginRequest: {
|
LoginRequest: {
|
||||||
/** Password */
|
/** Password */
|
||||||
@@ -1857,6 +2019,13 @@ export interface components {
|
|||||||
deferred_to_next_cycle: boolean;
|
deferred_to_next_cycle: boolean;
|
||||||
/** Id */
|
/** Id */
|
||||||
id: number;
|
id: number;
|
||||||
|
/** Installment Plan Id */
|
||||||
|
installment_plan_id?: number | null;
|
||||||
|
/**
|
||||||
|
* Is Installment Anchor
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
is_installment_anchor: boolean;
|
||||||
/** Merchant */
|
/** Merchant */
|
||||||
merchant: string;
|
merchant: string;
|
||||||
/** Notes */
|
/** 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: {
|
list_receipts_api_v1_municipal_receipts__get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -240,6 +240,35 @@ export const upsertBalanceOverride = (
|
|||||||
export const deleteBalanceOverride = (year: number, month: number) =>
|
export const deleteBalanceOverride = (year: number, month: number) =>
|
||||||
api.delete(`/budget/balance-override/${year}/${month}`);
|
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 ---
|
// --- Salarios ---
|
||||||
|
|
||||||
export type SalariosSummary = Schema<'SalariosSummary'>;
|
export type SalariosSummary = Schema<'SalariosSummary'>;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import PeriodNavigator from '@/components/PeriodNavigator';
|
|||||||
import MonthlyDetail from '@/components/budget/MonthlyDetail';
|
import MonthlyDetail from '@/components/budget/MonthlyDetail';
|
||||||
import RecurringItemsManager from '@/components/budget/RecurringItemsManager';
|
import RecurringItemsManager from '@/components/budget/RecurringItemsManager';
|
||||||
import TransactionList from '@/components/TransactionList';
|
import TransactionList from '@/components/TransactionList';
|
||||||
|
import ConvertToInstallmentsDialog from '@/components/transactions/ConvertToInstallmentsDialog';
|
||||||
|
|
||||||
|
|
||||||
const MIN_YEAR = 2026;
|
const MIN_YEAR = 2026;
|
||||||
@@ -44,6 +45,7 @@ export default function Budget() {
|
|||||||
const [subTab, setSubTab] = useState<'detail' | 'transactions'>('detail');
|
const [subTab, setSubTab] = useState<'detail' | 'transactions'>('detail');
|
||||||
const [txSearch, setTxSearch] = useState('');
|
const [txSearch, setTxSearch] = useState('');
|
||||||
const [txSource, setTxSource] = useState<'CREDIT_CARD' | 'CASH_AND_TRANSFER'>('CREDIT_CARD');
|
const [txSource, setTxSource] = useState<'CREDIT_CARD' | 'CASH_AND_TRANSFER'>('CREDIT_CARD');
|
||||||
|
const [convertTx, setConvertTx] = useState<Transaction | null>(null);
|
||||||
|
|
||||||
const txQuery = useQuery({
|
const txQuery = useQuery({
|
||||||
queryKey: ['transactions', 'budget', year, selectedMonth, txSource, txSearch],
|
queryKey: ['transactions', 'budget', year, selectedMonth, txSource, txSearch],
|
||||||
@@ -79,6 +81,7 @@ export default function Budget() {
|
|||||||
|
|
||||||
const invalidateTransactionsAndBudget = useCallback(() => {
|
const invalidateTransactionsAndBudget = useCallback(() => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['installment-plans'] });
|
||||||
refresh();
|
refresh();
|
||||||
}, [queryClient, refresh]);
|
}, [queryClient, refresh]);
|
||||||
|
|
||||||
@@ -209,6 +212,7 @@ export default function Budget() {
|
|||||||
showSourceIcon={txSource === 'CASH_AND_TRANSFER'}
|
showSourceIcon={txSource === 'CASH_AND_TRANSFER'}
|
||||||
emptyMessage={`Sin transacciones de ${txSource === 'CREDIT_CARD' ? 'tarjeta' : 'efectivo o transferencia'} en ${MONTH_NAMES[selectedMonth]}`}
|
emptyMessage={`Sin transacciones de ${txSource === 'CREDIT_CARD' ? 'tarjeta' : 'efectivo o transferencia'} en ${MONTH_NAMES[selectedMonth]}`}
|
||||||
onToggleDeferred={txSource === 'CREDIT_CARD' ? handleToggleDeferred : undefined}
|
onToggleDeferred={txSource === 'CREDIT_CARD' ? handleToggleDeferred : undefined}
|
||||||
|
onConvertTasaCero={txSource === 'CREDIT_CARD' ? setConvertTx : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -224,6 +228,13 @@ export default function Budget() {
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
<ConvertToInstallmentsDialog
|
||||||
|
open={convertTx !== null}
|
||||||
|
onOpenChange={(open) => !open && setConvertTx(null)}
|
||||||
|
onSaved={invalidateTransactionsAndBudget}
|
||||||
|
tx={convertTx}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
207
frontend/src/pages/Financiamientos.tsx
Normal file
207
frontend/src/pages/Financiamientos.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user