feat(cotizador): 4 opciones de credito sobre el esquema FRASA (saldo 84.5% con mensualidad estimada)

- Nuevo modulo puro lib/opciones-credito.ts: Bancario, Infonavit-Cofinavit,
  Semi Contado (1%) y Contado (2%), con descuentos del Excel del cliente.
  Cada opcion recalcula el esquema FRASA 2026 sobre el precio final y
  estima la mensualidad del credito (anualidad con tasa y plazo ajustables;
  % de contado para la Semi). Ajustes validados (tasa 0-50%, plazo 1-35).
- Nuevo componente OpcionesCredito: grid de 4 tarjetas con tasa/plazo/% de
  contado editables, mensualidad estimada y boton 'Usar en el PDF'.
- CotizadorPage: estado de ajustes y opcion elegida, memo con try/catch
  (defaults de respaldo), restauracion defensiva al retomar cotizacion.
- PDF: seccion 'PROPUESTA DE FINANCIAMIENTO ELEGIDA' con renglones del
  credito (o pago de contado y ahorro para Contado) + disclaimer.
- API: columna Cotizacion.opcionesCredito (TEXT, JSON {elegida, ajustes})
  con migracion manual aplicada en SQLite; zod max 4000; se persiste crudo
  y los GET devuelven la fila completa.

Tests: cotizador 149/149 (19 nuevos en opciones-credito, 2 en pdf),
API 746/746 (3 nuevos en cotizaciones). Smoke real POST+GET en produccion
local con unidad ATELIER-34 (201 con opcionesCredito; fila de prueba borrada).
This commit is contained in:
2026-09-28 19:56:37 +00:00
parent 61d1b84fc3
commit 4a3ddd2f35
11 changed files with 863 additions and 9 deletions

View File

@@ -0,0 +1,178 @@
/**
* Opciones de credito sobre el esquema FRASA 2026 (Excel del cliente, 4
* propuestas): Crédito Bancario, Crédito Infonavit-Cofinavit, Semi Contado
* y Contado. Cada opcion aplica su descuento (0/0/1%/2%) al precio de lista,
* recalcula el esquema FRASA 2026 sobre el precio final y estima la
* mensualidad del credito hipotecario restante (saldo a financiar = 84.5%
* del precio final, una vez saldado el 15.5% del esquema).
*
* Módulo puro (sin UI, sin API) para probarlo a fondo.
*/
import { calcularEsquemaFrasa, type EsquemaFrasaResultado } from './esquema-frasa'
export type TipoCredito =
| 'CREDITO_BANCARIO'
| 'CREDITO_INFONAVIT_COFINAVIT'
| 'SEMI_CONTADO'
| 'CONTADO'
export interface AjustesCredito {
/** Tasa anual nominal (0.105 = 10.5%) */
tasaAnual: number
/** Plazo en años (entero, 1..35) */
plazoAnos: number
/**
* Solo SEMI_CONTADO: fraccion (0..1) del saldo a financiar que el cliente
* liquida de contado a la firma; el resto va al credito hipotecario.
*/
pctContado?: number
}
/** Descuentos sobre el precio de lista definidos en el Excel del cliente */
export const DESCUENTO_POR_TIPO: Record<TipoCredito, number> = {
CREDITO_BANCARIO: 0,
CREDITO_INFONAVIT_COFINAVIT: 0,
SEMI_CONTADO: 0.01,
CONTADO: 0.02,
}
export const AJUSTES_DEFAULT: Record<TipoCredito, AjustesCredito> = {
CREDITO_BANCARIO: { tasaAnual: 0.105, plazoAnos: 20 },
CREDITO_INFONAVIT_COFINAVIT: { tasaAnual: 0.105, plazoAnos: 25 },
SEMI_CONTADO: { tasaAnual: 0.105, plazoAnos: 20, pctContado: 0.5 },
// Valores dummy: el contado no usa credito
CONTADO: { tasaAnual: 0, plazoAnos: 1 },
}
/** Orden de presentacion de las 4 opciones (el del Excel del cliente) */
export const TIPOS_ORDEN: readonly TipoCredito[] = [
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'SEMI_CONTADO',
'CONTADO',
]
export const TITULO_POR_TIPO: Record<TipoCredito, string> = {
CREDITO_BANCARIO: 'Crédito Bancario',
CREDITO_INFONAVIT_COFINAVIT: 'Crédito Infonavit-Cofinavit',
SEMI_CONTADO: 'Semi Contado',
CONTADO: 'Contado',
}
const round2 = (x: number): number => Number(x.toFixed(2))
export interface EntradaOpcion {
/** Precio de lista de la unidad (sin descuento) */
precioLista: number
apartado: number
numMensualidades: number
escrituracionDiferida: boolean
tipo: TipoCredito
ajustes: AjustesCredito
}
export interface ResultadoOpcionCredito {
tipo: TipoCredito
titulo: string
/** Fraccion de descuento sobre el precio de lista (0/0.01/0.02) */
descuento: number
montoDescuento: number
/** precioLista − montoDescuento (base del esquema FRASA 2026) */
precioFinal: number
/** Esquema FRASA 2026 calculado sobre el precio final (sin redondear) */
esquema: EsquemaFrasaResultado
/** precioFinal − esquema.totalPagado (lo que queda para financiar) */
saldoAFinanciar: number
/** SEMI: saldoAFinanciar * pctContado; resto 0 */
pagoContado: number
/** saldoAFinanciar − pagoContado (0 en CONTADO) */
montoCredito: number
tasaAnual: number
plazoAnos: number
pctContado?: number
/** Anualidad del credito; null en CONTADO */
mensualidadEstimada: number | null
/** mensualidad * 12 * plazoAnos − montoCredito (0 en CONTADO) */
interesesEstimados: number
/** precioFinal + interesesEstimados */
totalEstimado: number
/** === montoDescuento: lo que se ahorra vs el precio de lista */
ahorroVsLista: number
/** false si el apartado excede el 10% del precio final (enganche negativo) */
esquemaVigente: boolean
}
/** Error de validación de ajustes; null si son válidos */
export function validarAjustes(tipo: TipoCredito, a: AjustesCredito): string | null {
if (!Number.isFinite(a.tasaAnual) || a.tasaAnual < 0 || a.tasaAnual > 0.5) {
return 'La tasa anual debe estar entre 0% y 50%'
}
if (!Number.isInteger(a.plazoAnos) || a.plazoAnos < 1 || a.plazoAnos > 35) {
return 'El plazo debe ser un número entero de 1 a 35 años'
}
if (tipo === 'SEMI_CONTADO' && a.pctContado !== undefined) {
if (!Number.isFinite(a.pctContado) || a.pctContado < 0 || a.pctContado > 1) {
return 'El porcentaje de contado debe estar entre 0% y 100%'
}
}
return null
}
/** Anualidad (mensualidad) de un crédito: monto * i / (1 − (1+i)^−n) */
function mensualidadCredito(monto: number, tasaAnual: number, plazoAnos: number): number {
const n = plazoAnos * 12
if (tasaAnual === 0) return monto / n
const i = tasaAnual / 12
return (monto * i) / (1 - Math.pow(1 + i, -n))
}
export function calcularOpcionCredito(e: EntradaOpcion): ResultadoOpcionCredito {
const errorAjustes = validarAjustes(e.tipo, e.ajustes)
if (errorAjustes) throw new Error(errorAjustes)
const descuento = DESCUENTO_POR_TIPO[e.tipo]
const montoDescuento = round2(e.precioLista * descuento)
const precioFinal = e.precioLista - montoDescuento
const esquema = calcularEsquemaFrasa({
precio: precioFinal,
apartado: e.apartado,
numMensualidades: e.numMensualidades,
escrituracionDiferida: e.escrituracionDiferida,
})
const saldoAFinanciar = round2(precioFinal - esquema.totalPagado)
const esContado = e.tipo === 'CONTADO'
const pctContado = e.tipo === 'SEMI_CONTADO' ? (e.ajustes.pctContado ?? 0) : 0
const pagoContado = esContado ? 0 : round2(saldoAFinanciar * pctContado)
const montoCredito = esContado ? 0 : round2(saldoAFinanciar - pagoContado)
const mensualidadEstimada = esContado
? null
: round2(mensualidadCredito(montoCredito, e.ajustes.tasaAnual, e.ajustes.plazoAnos))
const interesesEstimados = esContado
? 0
: round2(mensualidadEstimada! * 12 * e.ajustes.plazoAnos - montoCredito)
const totalEstimado = round2(precioFinal + interesesEstimados)
return {
tipo: e.tipo,
titulo: TITULO_POR_TIPO[e.tipo],
descuento,
montoDescuento,
precioFinal,
esquema,
saldoAFinanciar,
pagoContado,
montoCredito,
tasaAnual: e.ajustes.tasaAnual,
plazoAnos: e.ajustes.plazoAnos,
...(e.tipo === 'SEMI_CONTADO' ? { pctContado: e.ajustes.pctContado } : {}),
mensualidadEstimada,
interesesEstimados,
totalEstimado,
ahorroVsLista: montoDescuento,
esquemaVigente: esquema.enganche >= 0,
}
}