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