195 lines
6.8 KiB
TypeScript
195 lines
6.8 KiB
TypeScript
/**
|
||
* 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,
|
||
}
|
||
}
|