+ Mensualidad estimada con tasa y plazo ajustables; sujeta a aprobación del banco. No
+ constituye preaprobación.
+
+ >
+ )}
+ >
+ )}
+
+
+
+ )
+}
+
+/**
+ * 4 opciones de financiamiento del saldo (84.5% del precio final) sobre el
+ * esquema FRASA 2026: Crédito Bancario, Infonavit-Cofinavit, Semi Contado y
+ * Contado. Cada tarjeta ajusta su tasa/plazo (y % de contado para la semi),
+ * estima la mensualidad del crédito y permite elegir la propuesta que sale
+ * en el PDF.
+ */
+export function OpcionesCredito({ opciones, ajustes, onCambiarAjustes, elegida, onElegir }: Props) {
+ return (
+
+
+ Opciones de financiamiento del saldo (84.5% del precio final)
+
+
+ {opciones.map((r) => (
+
+ ))}
+
+
+ )
+}
diff --git a/apps/cotizador/src/lib/opciones-credito.test.ts b/apps/cotizador/src/lib/opciones-credito.test.ts
new file mode 100644
index 0000000..f092b5f
--- /dev/null
+++ b/apps/cotizador/src/lib/opciones-credito.test.ts
@@ -0,0 +1,184 @@
+import { describe, expect, it } from 'vitest'
+import {
+ AJUSTES_DEFAULT,
+ DESCUENTO_POR_TIPO,
+ TIPOS_ORDEN,
+ calcularOpcionCredito,
+ validarAjustes,
+ type EntradaOpcion,
+ type TipoCredito,
+} from './opciones-credito'
+
+/** Caso dorado del cliente: casa de $1,000,000, apartado $15,000, N=14 */
+const CASO = { precioLista: 1000000, apartado: 15000, numMensualidades: 14, escrituracionDiferida: false }
+
+function opcion(tipo: TipoCredito, extra: Partial = {}) {
+ return calcularOpcionCredito({ ...CASO, tipo, ajustes: AJUSTES_DEFAULT[tipo], ...extra })
+}
+
+/** Anualidad con la misma fórmula del módulo (el test calcula su esperado) */
+function anualidadEsperada(monto: number, tasaAnual: number, plazoAnos: number): number {
+ const n = plazoAnos * 12
+ const i = tasaAnual / 12
+ return Number(((monto * i) / (1 - Math.pow(1 + i, -n))).toFixed(2))
+}
+
+describe('calcularOpcionCredito — Crédito Bancario (caso dorado)', () => {
+ const r = opcion('CREDITO_BANCARIO')
+
+ it('sin descuento: precio final = lista y saldo a financiar $845,000', () => {
+ expect(DESCUENTO_POR_TIPO.CREDITO_BANCARIO).toBe(0)
+ expect(r.precioFinal).toBe(1000000)
+ expect(r.montoDescuento).toBe(0)
+ expect(r.saldoAFinanciar).toBe(845000) // 1,000,000 − 155,000 del esquema
+ expect(r.pagoContado).toBe(0)
+ expect(r.montoCredito).toBe(845000)
+ })
+
+ it('esquema FRASA 2026 anidado: firma única $65,000 y diferible $75,000', () => {
+ expect(r.esquema.pagoUnicoFirma).toBe(65000) // saldo firma 10,000 + escrituración 55,000
+ expect(r.esquema.baseDiferible).toBe(75000)
+ expect(r.esquema.totalPagado).toBeCloseTo(155000, 2)
+ })
+
+ it('mensualidad estimada con la fórmula de anualidad (10.5% a 20 años)', () => {
+ const esperada = anualidadEsperada(845000, 0.105, 20)
+ expect(esperada).toBeGreaterThan(8000)
+ expect(esperada).toBeLessThan(8500) // ~8,436.xx
+ expect(r.mensualidadEstimada).toBe(esperada)
+ expect(r.interesesEstimados).toBeGreaterThan(0)
+ expect(r.interesesEstimados).toBe(
+ Number((r.mensualidadEstimada! * 12 * 20 - r.montoCredito).toFixed(2)),
+ )
+ expect(r.totalEstimado).toBe(Number((r.precioFinal + r.interesesEstimados).toFixed(2)))
+ })
+
+ it('ahorro vs lista 0 y esquema vigente', () => {
+ expect(r.ahorroVsLista).toBe(0)
+ expect(r.esquemaVigente).toBe(true)
+ })
+})
+
+describe('calcularOpcionCredito — Contado (2% de descuento)', () => {
+ const r = opcion('CONTADO')
+
+ it('precio final $980,000 y ahorro de $20,000', () => {
+ expect(r.descuento).toBe(0.02)
+ expect(r.montoDescuento).toBe(20000)
+ expect(r.precioFinal).toBe(980000)
+ expect(r.ahorroVsLista).toBe(20000)
+ })
+
+ it('saldo a financiar = round2(980,000 × 0.845) = $828,100', () => {
+ expect(r.saldoAFinanciar).toBe(828100)
+ })
+
+ it('esquema sobre el precio final: firma única $63,400', () => {
+ expect(r.esquema.saldoFirma).toBe(9500) // 24,500 − 15,000 apartado
+ expect(r.esquema.escrituracion).toBe(53900) // 5.5% de 980,000
+ expect(r.esquema.pagoUnicoFirma).toBe(63400) // 9,500 + 53,900
+ })
+
+ it('sin credito: mensualidad null, intereses 0 y total estimado = precio final', () => {
+ expect(r.montoCredito).toBe(0)
+ expect(r.pagoContado).toBe(0)
+ expect(r.mensualidadEstimada).toBeNull()
+ expect(r.interesesEstimados).toBe(0)
+ expect(r.totalEstimado).toBe(980000)
+ })
+})
+
+describe('calcularOpcionCredito — Semi Contado (1% + 50% de contado)', () => {
+ const r = opcion('SEMI_CONTADO')
+
+ it('precio final $990,000 y saldo a financiar $836,550', () => {
+ expect(r.montoDescuento).toBe(10000)
+ expect(r.precioFinal).toBe(990000)
+ expect(r.saldoAFinanciar).toBe(836550) // round2(990,000 × 0.845)
+ expect(r.esquemaVigente).toBe(true)
+ })
+
+ it('pago de contado y crédito mitad y mitad', () => {
+ expect(r.pctContado).toBe(0.5)
+ expect(r.pagoContado).toBe(418275)
+ expect(r.montoCredito).toBe(418275)
+ const esperada = anualidadEsperada(418275, 0.105, 20)
+ expect(r.mensualidadEstimada).toBe(esperada)
+ })
+})
+
+describe('calcularOpcionCredito — Crédito Infonavit-Cofinavit', () => {
+ it('sin descuento, plazo default 25 años y esquema vigente', () => {
+ const r = opcion('CREDITO_INFONAVIT_COFINAVIT')
+ expect(r.precioFinal).toBe(1000000)
+ expect(r.saldoAFinanciar).toBe(845000)
+ expect(r.plazoAnos).toBe(25)
+ expect(r.esquemaVigente).toBe(true)
+ const esperada = anualidadEsperada(845000, 0.105, 25)
+ expect(r.mensualidadEstimada).toBe(esperada)
+ })
+})
+
+describe('calcularOpcionCredito — saldo con los 4 tipos', () => {
+ it('con apartado <= 2.5% del precio final, saldo = round2(precioFinal × 0.845)', () => {
+ for (const tipo of TIPOS_ORDEN) {
+ const r = opcion(tipo)
+ expect(r.saldoAFinanciar).toBe(Number((r.precioFinal * 0.845).toFixed(2)))
+ }
+ })
+})
+
+describe('validarAjustes', () => {
+ it('rechaza tasas fuera de rango (0%..50%)', () => {
+ expect(validarAjustes('CREDITO_BANCARIO', { tasaAnual: -0.1, plazoAnos: 20 })).toMatch(/tasa/i)
+ expect(validarAjustes('CREDITO_BANCARIO', { tasaAnual: 0.6, plazoAnos: 20 })).toMatch(/tasa/i)
+ })
+
+ it('rechaza plazos inválidos (entero 1..35)', () => {
+ expect(validarAjustes('CREDITO_BANCARIO', { tasaAnual: 0.105, plazoAnos: 0 })).toMatch(/plazo/i)
+ expect(validarAjustes('CREDITO_BANCARIO', { tasaAnual: 0.105, plazoAnos: 3.5 })).toMatch(/plazo/i)
+ expect(validarAjustes('CREDITO_BANCARIO', { tasaAnual: 0.105, plazoAnos: 36 })).toMatch(/plazo/i)
+ })
+
+ it('rechaza pctContado fuera de 0..1 solo para Semi Contado', () => {
+ expect(validarAjustes('SEMI_CONTADO', { tasaAnual: 0.105, plazoAnos: 20, pctContado: 1.2 })).toMatch(/contado/i)
+ expect(validarAjustes('SEMI_CONTADO', { tasaAnual: 0.105, plazoAnos: 20, pctContado: 0.5 })).toBeNull()
+ })
+
+ it('acepta ajustes válidos', () => {
+ expect(validarAjustes('CREDITO_BANCARIO', { tasaAnual: 0.105, plazoAnos: 20 })).toBeNull()
+ expect(validarAjustes('CONTADO', { tasaAnual: 0, plazoAnos: 1 })).toBeNull()
+ })
+
+ it('calcularOpcionCredito lanza Error cuando la validación falla', () => {
+ expect(() =>
+ calcularOpcionCredito({
+ ...CASO,
+ tipo: 'SEMI_CONTADO',
+ ajustes: { tasaAnual: 0.105, plazoAnos: 20, pctContado: 1.5 },
+ }),
+ ).toThrow(/contado/i)
+ })
+})
+
+describe('calcularOpcionCredito — esquema no vigente', () => {
+ it('apartado que excede el 10% del precio final con descuento: enganche negativo', () => {
+ // Contado: precio final 980,000 → firma 2.5% = 24,500 y enganche 7.5% = 73,500
+ // Apartado 200,000: excedente 175,500 > 73,500 → enganche = 73,500 − 175,500 < 0
+ const r = opcion('CONTADO', { apartado: 200000 })
+ expect(r.precioFinal).toBe(980000)
+ expect(r.esquema.excedenteApartado).toBe(175500)
+ expect(r.esquema.enganche).toBeLessThan(0)
+ expect(r.esquemaVigente).toBe(false)
+ })
+})
+
+describe('calcularOpcionCredito — tasa cero', () => {
+ it('mensualidad = monto / n cuando la tasa es 0', () => {
+ const r = opcion('CREDITO_BANCARIO', { ajustes: { tasaAnual: 0, plazoAnos: 20 } })
+ const mensualidad = Number((845000 / 240).toFixed(2))
+ expect(r.mensualidadEstimada).toBe(mensualidad)
+ // Con tasa 0 los intereses son el redondeo de la mensualidad (±centavos)
+ expect(r.interesesEstimados).toBe(Number((mensualidad * 240 - 845000).toFixed(2)))
+ })
+})
diff --git a/apps/cotizador/src/lib/opciones-credito.ts b/apps/cotizador/src/lib/opciones-credito.ts
new file mode 100644
index 0000000..ef7c0bc
--- /dev/null
+++ b/apps/cotizador/src/lib/opciones-credito.ts
@@ -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 = {
+ CREDITO_BANCARIO: 0,
+ CREDITO_INFONAVIT_COFINAVIT: 0,
+ SEMI_CONTADO: 0.01,
+ CONTADO: 0.02,
+}
+
+export const AJUSTES_DEFAULT: Record = {
+ 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 = {
+ 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,
+ }
+}
diff --git a/apps/cotizador/src/lib/pdf.test.ts b/apps/cotizador/src/lib/pdf.test.ts
index 5e13a7e..0fdbf9c 100644
--- a/apps/cotizador/src/lib/pdf.test.ts
+++ b/apps/cotizador/src/lib/pdf.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'
import { crearDocumentoPdf, textoCuotaMantenimiento } from './pdf'
import { calcularEsquemaFrasa } from './esquema-frasa'
+import { AJUSTES_DEFAULT, calcularOpcionCredito } from './opciones-credito'
import { UNIDADES_SEED } from '../data/unidades'
import { DESARROLLOS, LISTA_DESARROLLOS } from '../data/desarrollos'
import { formatoMoneda } from './formatters'
@@ -71,6 +72,62 @@ describe('crearDocumentoPdf', () => {
expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1)
})
+ it('imprime la sección de financiamiento elegido cuando viene la opción', () => {
+ const unidad = UNIDADES_SEED[0]
+ const opcion = calcularOpcionCredito({
+ precioLista: 1000000,
+ apartado: 15000,
+ numMensualidades: 14,
+ escrituracionDiferida: false,
+ tipo: 'CONTADO',
+ ajustes: AJUSTES_DEFAULT.CONTADO,
+ })
+ const { doc } = crearDocumentoPdf({
+ unidad,
+ acabados: true,
+ apartado: 15000,
+ precioLista: 1000000,
+ esquema: esquemaDorado,
+ opcion,
+ cliente: { nombre: 'Juan Pérez' },
+ })
+ const pdf = new TextDecoder('latin1').decode(doc.output('arraybuffer'))
+ expect(pdf).toContain('PROPUESTA DE FINANCIAMIENTO ELEGIDA')
+ expect(pdf).toContain('CONTADO')
+ // Precio final con descuento, pago de contado y ahorro vs lista
+ expect(pdf).toContain(formatoMoneda(opcion.precioFinal))
+ expect(pdf).toContain(formatoMoneda(opcion.saldoAFinanciar))
+ expect(pdf).toContain(formatoMoneda(opcion.ahorroVsLista))
+ expect(pdf).toContain('NO CONSTITUYE PREAPROBACIÓN')
+ })
+
+ it('imprime tasa y plazo cuando la opción elegida usa crédito', () => {
+ const unidad = UNIDADES_SEED[0]
+ const opcion = calcularOpcionCredito({
+ precioLista: 1000000,
+ apartado: 15000,
+ numMensualidades: 14,
+ escrituracionDiferida: false,
+ tipo: 'CREDITO_BANCARIO',
+ ajustes: AJUSTES_DEFAULT.CREDITO_BANCARIO,
+ })
+ const { doc } = crearDocumentoPdf({
+ unidad,
+ acabados: true,
+ apartado: 15000,
+ precioLista: 1000000,
+ esquema: esquemaDorado,
+ opcion,
+ cliente: { nombre: 'Juan Pérez' },
+ })
+ const pdf = new TextDecoder('latin1').decode(doc.output('arraybuffer'))
+ expect(pdf).toContain('CRÉDITO BANCARIO')
+ expect(pdf).toContain('MENSUALIDAD ESTIMADA')
+ expect(pdf).toContain(formatoMoneda(opcion.mensualidadEstimada!))
+ expect(pdf).toContain('INTERESES ESTIMADOS')
+ expect(pdf).toContain('10.5% A 20 AÑOS')
+ })
+
it('imprime el folio local CTE-LOCAL-* cuando el servidor no respondió', () => {
const unidad = UNIDADES_SEED[0]
const { doc } = crearDocumentoPdf({
diff --git a/apps/cotizador/src/lib/pdf.ts b/apps/cotizador/src/lib/pdf.ts
index b8177da..ac5f7fd 100644
--- a/apps/cotizador/src/lib/pdf.ts
+++ b/apps/cotizador/src/lib/pdf.ts
@@ -3,6 +3,7 @@ import autoTable from 'jspdf-autotable'
import type { Unidad, Cliente, ParametrosDesarrollo } from '../types'
import type { EsquemaFrasaResultado } from './esquema-frasa'
import { PCT_ENGANCHE, PCT_ENGANCHE_FIRMA, PCT_ESCRITURACION } from './esquema-frasa'
+import type { ResultadoOpcionCredito } from './opciones-credito'
import { formatoMoneda, formatoPorcentaje, formatoFechaLarga, fechaISOHoy } from './formatters'
import { DESARROLLOS, DESARROLLO_DEFAULT } from '../data/desarrollos'
@@ -13,6 +14,8 @@ export interface PdfInput {
precioLista: number
/** Esquema único FRASA 2026 a imprimir */
esquema: EsquemaFrasaResultado
+ /** Opción de financiamiento elegida por el vendedor (sección de financiamiento) */
+ opcion?: ResultadoOpcionCredito
cliente: Cliente
/** Parámetros del desarrollo; por defecto DIAGONAL */
desarrollo?: ParametrosDesarrollo
@@ -51,7 +54,7 @@ export interface DocumentoPdf {
}
/** Construye el documento PDF "ANEXO A" (sin descargarlo) */
-export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, esquema, cliente, desarrollo, folio, qrDataUrl, vigencia }: PdfInput): DocumentoPdf {
+export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, esquema, opcion, cliente, desarrollo, folio, qrDataUrl, vigencia }: PdfInput): DocumentoPdf {
const params = desarrollo ?? DESARROLLOS[DESARROLLO_DEFAULT]
const doc = new jsPDF()
const hoy = new Date()
@@ -180,6 +183,53 @@ export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, esq
y = finalY(doc) + 6
y += 2
+ // 3.1 Propuesta de financiamiento elegida (una de las 4 opciones de crédito)
+ if (opcion) {
+ doc.setFontSize(12)
+ doc.setFont('helvetica', 'bold')
+ doc.text(`PROPUESTA DE FINANCIAMIENTO ELEGIDA — ${opcion.titulo.toUpperCase()}`, MARGEN, y)
+ y += 4
+
+ const renglones: string[][] = [
+ ['PRECIO FINAL C/DESC.', formatoMoneda(opcion.precioFinal)],
+ ...(opcion.montoDescuento > 0
+ ? [[`DESCUENTO (${formatoPorcentaje(opcion.descuento)})`, formatoMoneda(opcion.montoDescuento)]]
+ : []),
+ ...(opcion.tipo === 'CONTADO'
+ ? [
+ ['PAGO DE CONTADO', formatoMoneda(opcion.saldoAFinanciar)],
+ ['AHORRO VS PRECIO DE LISTA', formatoMoneda(opcion.ahorroVsLista)],
+ ]
+ : [
+ ...(opcion.tipo === 'SEMI_CONTADO'
+ ? [
+ ['PAGO DE CONTADO A LA FIRMA', formatoMoneda(opcion.pagoContado)],
+ ['SALDO RESTANTE A FINANCIAR', formatoMoneda(opcion.montoCredito)],
+ ]
+ : [['SALDO A FINANCIAR', formatoMoneda(opcion.saldoAFinanciar)]]),
+ ['MONTO DEL CRÉDITO', formatoMoneda(opcion.montoCredito)],
+ ['TASA Y PLAZO', `${(opcion.tasaAnual * 100).toLocaleString('es-MX')}% A ${opcion.plazoAnos} AÑOS`],
+ ['MENSUALIDAD ESTIMADA', formatoMoneda(opcion.mensualidadEstimada ?? 0)],
+ ['INTERESES ESTIMADOS', formatoMoneda(opcion.interesesEstimados)],
+ ]),
+ ['TOTAL ESTIMADO', formatoMoneda(opcion.totalEstimado)],
+ ]
+
+ autoTable(doc, {
+ startY: y,
+ margin: { left: MARGEN, right: MARGEN },
+ styles: { fontSize: 8 },
+ headStyles: { fillColor: [37, 99, 235] },
+ head: [['CONCEPTO', 'TOTAL']],
+ body: renglones,
+ })
+ y = finalY(doc) + 4
+ parrafo(
+ 'Mensualidad estimada sujeta a aprobación del banco; no constituye preaprobación.',
+ )
+ y += 2
+ }
+
// 4. ¿Qué sigue?
parrafo('¿QUÉ SIGUE?', { negritas: true, tamano: 11 })
parrafo(`ENTREGAR LOS SIGUIENTES DOCUMENTOS MÁXIMO EL DÍA: ${formatoFechaLarga(limiteDocumentos)}`, {
diff --git a/apps/cotizador/src/pages/CotizadorPage.tsx b/apps/cotizador/src/pages/CotizadorPage.tsx
index 69971c3..a7e35d8 100644
--- a/apps/cotizador/src/pages/CotizadorPage.tsx
+++ b/apps/cotizador/src/pages/CotizadorPage.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react'
+import { useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
@@ -11,6 +11,7 @@ import toast from "react-hot-toast"
import { UnitSelector } from '../components/UnitSelector'
import { UnitInfoCard } from '../components/UnitInfoCard'
import { EsquemaPanel } from '../components/EsquemaPanel'
+import { OpcionesCredito } from '../components/OpcionesCredito'
import { MisCotizaciones } from '../components/MisCotizaciones'
import { ModalCliente } from '../components/ModalCliente'
import { BuscadorCliente } from '../components/BuscadorCliente'
@@ -19,6 +20,13 @@ import { formatoMoneda, formatoFechaLarga } from '../lib/formatters'
import { calcularVigencia } from '../lib/vigencia'
import { folioCotizacion } from '../lib/folio'
import { textoCompartir, urlWhatsApp } from '../lib/compartir'
+import {
+ AJUSTES_DEFAULT,
+ TIPOS_ORDEN,
+ calcularOpcionCredito,
+ type AjustesCredito,
+ type TipoCredito,
+} from '../lib/opciones-credito'
import { desarrolloEfectivo, idsCotizables } from '../lib/reglas'
import {
getMisCotizaciones,
@@ -161,6 +169,39 @@ export default function CotizadorPage() {
const nombreCliente = watch('nombre') ?? ''
+ // 4 opciones de crédito sobre el esquema FRASA 2026 (saldo 84.5% a financiar)
+ const [ajustesCredito, setAjustesCredito] = useState>(() =>
+ structuredClone(AJUSTES_DEFAULT),
+ )
+ const [opcionElegida, setOpcionElegida] = useState('CREDITO_BANCARIO')
+
+ const opcionesCredito = useMemo(() => {
+ // Sin unidad no hay opciones que calcular (la sección no se renderiza)
+ if (!Number.isFinite(precioLista) || precioLista <= 0) return []
+ const calcular = (ajustes: Record) =>
+ TIPOS_ORDEN.map((tipo) =>
+ calcularOpcionCredito({
+ precioLista,
+ apartado: apartadoEfectivo,
+ numMensualidades,
+ escrituracionDiferida,
+ tipo,
+ ajustes: ajustes[tipo],
+ }),
+ )
+ try {
+ return calcular(ajustesCredito)
+ } catch {
+ // Ajustes inválidos (estado restaurado corrupto, carrera de inputs):
+ // se recalcula con los defaults para no romper la página
+ try {
+ return calcular(AJUSTES_DEFAULT)
+ } catch {
+ return []
+ }
+ }
+ }, [precioLista, apartadoEfectivo, numMensualidades, escrituracionDiferida, ajustesCredito])
+
// Prefill desde la URL (botón "Cotizar" de Mi día): cliente, contacto,
// desarrollo, unidad y el id de la oportunidad a vincular al guardar
useEffect(() => {
@@ -265,6 +306,7 @@ export default function CotizadorPage() {
numMensualidades,
mensualidad: resultado.mensualidad,
pagoFinal: resultado.pagoUnicoFirma,
+ opcionesCredito: JSON.stringify({ elegida: opcionElegida, ajustes: ajustesCredito }),
createdAt: new Date(timestamp).toISOString(),
}
}
@@ -288,6 +330,7 @@ export default function CotizadorPage() {
apartado: apartadoEfectivo,
numMensualidades,
escrituracionDiferida,
+ opcionesCredito: JSON.stringify({ elegida: opcionElegida, ajustes: ajustesCredito }),
...(oportunidadId !== null ? { oportunidadId } : {}),
})
const conFolio = { ...guardada, folio: guardada.folio ?? folioCotizacion(guardada.id) }
@@ -337,6 +380,8 @@ export default function CotizadorPage() {
precioLista,
// El PDF imprime SOLO el esquema único FRASA 2026
esquema: resultado,
+ // + la opción de financiamiento elegida (Crédito Bancario por default)
+ opcion: opcionesCredito.find((o) => o.tipo === opcionElegida),
cliente,
desarrollo: params,
folio,
@@ -457,6 +502,39 @@ export default function CotizadorPage() {
setValue('telefono', c.clienteTelefono ?? '', { shouldValidate: true })
setValue('email', c.clienteEmail ?? '', { shouldValidate: true })
setUltimaGuardada(c)
+ // Restaura la opción de crédito elegida y sus ajustes (guardados como JSON)
+ if (typeof c.opcionesCredito === 'string' && c.opcionesCredito.length > 0) {
+ try {
+ const guardado = JSON.parse(c.opcionesCredito) as {
+ elegida?: TipoCredito
+ ajustes?: Record
+ }
+ if (guardado.elegida && TIPOS_ORDEN.includes(guardado.elegida)) {
+ setOpcionElegida(guardado.elegida)
+ }
+ if (guardado.ajustes && typeof guardado.ajustes === 'object') {
+ setAjustesCredito((prev) => {
+ const siguiente = { ...prev }
+ for (const tipo of TIPOS_ORDEN) {
+ const a = guardado.ajustes?.[tipo]
+ // Solo se restauran ajustes con la forma esperada; si no, el default
+ if (a && typeof a.tasaAnual === 'number' && typeof a.plazoAnos === 'number') {
+ siguiente[tipo] = {
+ tasaAnual: a.tasaAnual,
+ plazoAnos: a.plazoAnos,
+ ...(tipo === 'SEMI_CONTADO' && typeof a.pctContado === 'number'
+ ? { pctContado: a.pctContado }
+ : {}),
+ }
+ }
+ }
+ return siguiente
+ })
+ }
+ } catch {
+ // JSON corrupto: se conservan los ajustes actuales (defaults)
+ }
+ }
const avisoEsquema = nueva
? ''
: ` Usa el esquema anterior (${c.tipoPropuesta}); se cargó con el esquema FRASA 2026 y sus valores por defecto.`
@@ -701,13 +779,24 @@ export default function CotizadorPage() {
) : (
-
+ <>
+
+
+ setAjustesCredito((prev) => ({ ...prev, [tipo]: ajustes }))
+ }
+ elegida={opcionElegida}
+ onElegir={setOpcionElegida}
+ />
+ >
)}
diff --git a/apps/cotizador/src/services/api.ts b/apps/cotizador/src/services/api.ts
index a25cec6..0d23de9 100644
--- a/apps/cotizador/src/services/api.ts
+++ b/apps/cotizador/src/services/api.ts
@@ -107,6 +107,8 @@ export interface CotizacionGuardada {
numMensualidades: number
mensualidad: number | null
pagoFinal: number
+ /** JSON {elegida, ajustes} de las opciones de crédito (filas nuevas) */
+ opcionesCredito?: string | null
createdAt: string
}
@@ -124,6 +126,8 @@ export interface GuardarCotizacionPayload {
escrituracionDiferida?: boolean
/** Item del tablero Oportunidades a vincular (botón "Cotizar" de Mi día) */
oportunidadId?: string
+ /** JSON serializado {elegida, ajustes} de las 4 opciones de crédito; el API lo guarda crudo */
+ opcionesCredito?: string
}
/**