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:
178
apps/cotizador/src/lib/opciones-credito.ts
Normal file
178
apps/cotizador/src/lib/opciones-credito.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user