Files
FRASA-CAS/apps/cotizador/src/lib/opciones-credito.ts

195 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { PCT_ENGANCHE_MAXIMO, 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
/** Enganche adicional (monto absoluto, >= 0): se acota al 90% del anticipo */
engancheAdicional?: number
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 esquemaBase = calcularEsquemaFrasa({
precio: precioFinal,
apartado: e.apartado,
numMensualidades: e.numMensualidades,
escrituracionDiferida: e.escrituracionDiferida,
})
// El adicional se acota igual que en el hook: el anticipo total no puede
// superar el 90% del precio final de la opción
const adicionalSolicitado = Math.max(0, e.engancheAdicional ?? 0)
const adicionalEfectivo = Math.min(adicionalSolicitado, round2(precioFinal * PCT_ENGANCHE_MAXIMO - esquemaBase.totalPagado))
const esquema =
adicionalEfectivo > 0
? calcularEsquemaFrasa({
precio: precioFinal,
apartado: e.apartado,
numMensualidades: e.numMensualidades,
escrituracionDiferida: e.escrituracionDiferida,
engancheAdicional: adicionalEfectivo,
})
: esquemaBase
const saldoAFinanciar = round2(esquema.saldoAFinanciar)
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,
}
}