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,7 @@
-- Opciones de credito del saldo (84.5%) sobre el esquema FRASA 2026: las 4
-- propuestas del Excel del cliente (Bancario, Infonavit-Cofinavit, Semi
-- Contado, Contado) con la opcion elegida y los ajustes del vendedor.
-- Se guarda como JSON crudo {elegida, ajustes}; null en filas anteriores.
-- Patron manual: sqlite3 + _prisma_migrations. NUNCA migrate dev.
ALTER TABLE "Cotizacion" ADD COLUMN "opcionesCredito" TEXT;

View File

@@ -41,6 +41,9 @@ model Cotizacion {
pctEscrituracion Float? // 0.055 pctEscrituracion Float? // 0.055
escrituracionDiferida Boolean @default(false) escrituracionDiferida Boolean @default(false)
escrituracion Float? // gastos de escrituración (5.5% del precio) escrituracion Float? // gastos de escrituración (5.5% del precio)
// Opciones de credito del saldo (84.5%): JSON {elegida, ajustes} serializado;
// el cotizador manda el ajuste del vendedor y se persiste crudo.
opcionesCredito String?
mondayItemId String? mondayItemId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())

View File

@@ -299,6 +299,47 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled() expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
}) })
it('persiste y devuelve las opciones de crédito que manda el cotizador', async () => {
const opcionesCredito = JSON.stringify({
elegida: 'CONTADO',
ajustes: {
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 },
CONTADO: { tasaAnual: 0, plazoAnos: 1 },
},
})
const { res, status, json } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, opcionesCredito }), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ opcionesCredito }),
})
// La respuesta del POST incluye el campo (los GET devuelven la fila completa)
expect(json.mock.calls[0]?.[0]).toMatchObject({ opcionesCredito })
})
it('sin opciones de crédito persiste null', async () => {
const { res } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ opcionesCredito: null }),
})
})
it('rechaza opciones de crédito que exceden 4000 caracteres', async () => {
const { res } = resFake()
await expect(
crearCotizacion(reqFake({ ...bodyBase, opcionesCredito: 'x'.repeat(4001) }), res),
).rejects.toBeDefined()
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
})
it('unidad no DISPONIBLE (apartada/vendida) → 409 y no se crea cotización', async () => { it('unidad no DISPONIBLE (apartada/vendida) → 409 y no se crea cotización', async () => {
prismaMock.unidad.findUnique.mockResolvedValue({ ...filaUnidad, codigo: 'ATELIER-10', status: 'VENDIDO' }) prismaMock.unidad.findUnique.mockResolvedValue({ ...filaUnidad, codigo: 'ATELIER-10', status: 'VENDIDO' })
const { res } = resFake() const { res } = resFake()

View File

@@ -41,6 +41,9 @@ const crearCotizacionSchema = z.object({
.max(MENSUALIDADES_MAX, `Máximo ${MENSUALIDADES_MAX} mensualidades`) .max(MENSUALIDADES_MAX, `Máximo ${MENSUALIDADES_MAX} mensualidades`)
.optional(), .optional(),
escrituracionDiferida: z.boolean().optional(), escrituracionDiferida: z.boolean().optional(),
// Opciones de credito del saldo (84.5%): JSON serializado {elegida, ajustes}
// que manda el cotizador; se persiste crudo y se devuelve en los GET
opcionesCredito: z.string().max(4000).optional(),
// Oportunidad a la que queda vinculada la cotización (botón "Cotizar" de Mi // Oportunidad a la que queda vinculada la cotización (botón "Cotizar" de Mi
// día manda el id; sin él se busca por teléfono) // día manda el id; sin él se busca por teléfono)
oportunidadId: z.string().trim().min(1).optional(), oportunidadId: z.string().trim().min(1).optional(),
@@ -135,6 +138,8 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
pctEscrituracion: PCT_ESCRITURACION, pctEscrituracion: PCT_ESCRITURACION,
escrituracionDiferida, escrituracionDiferida,
escrituracion: esquema.escrituracion, escrituracion: esquema.escrituracion,
// Opciones de credito elegidas por el vendedor (JSON crudo)
opcionesCredito: body.opcionesCredito ?? null,
// Columnas legacy, rellenadas para compatibilidad de reportes: // Columnas legacy, rellenadas para compatibilidad de reportes:
// sin descuentos en el esquema único, el precio final es el de lista y // sin descuentos en el esquema único, el precio final es el de lista y
// el "pago final" es todo lo que se liquida a la firma. // el "pago final" es todo lo que se liquida a la firma.

View File

@@ -0,0 +1,236 @@
import type { AjustesCredito, ResultadoOpcionCredito, TipoCredito } from '../lib/opciones-credito'
import { formatoMoneda, formatoPorcentaje } from '../lib/formatters'
interface Props {
opciones: ResultadoOpcionCredito[]
ajustes: Record<TipoCredito, AjustesCredito>
onCambiarAjustes: (tipo: TipoCredito, ajustes: AjustesCredito) => void
elegida: TipoCredito
onElegir: (tipo: TipoCredito) => void
}
const cardBase =
'flex flex-col rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm dark:border-[#1E293B] dark:bg-[#0F172A]'
const etiqueta = 'mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
const inputClase =
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]'
const fila = 'flex flex-col gap-1 py-1.5 sm:flex-row sm:items-center sm:justify-between'
const filaEtiqueta = 'text-sm text-[#64748B] dark:text-[#94A3B8]'
const filaMonto = 'text-sm font-medium text-[#111827] dark:text-[#E2E8F0]'
/** Clamp básico de los ajustes al cambiar un input (evita estados inválidos) */
function ajustar(tipo: TipoCredito, previo: AjustesCredito, parcial: Partial<AjustesCredito>): AjustesCredito {
const siguiente = { ...previo, ...parcial }
if (tipo === 'SEMI_CONTADO') {
const pct = Number(siguiente.pctContado ?? 0)
siguiente.pctContado = Math.min(1, Math.max(0, Number.isFinite(pct) ? pct : 0))
}
const tasa = Number(siguiente.tasaAnual)
siguiente.tasaAnual = Math.min(0.5, Math.max(0, Number.isFinite(tasa) ? tasa : 0))
const plazo = Number(siguiente.plazoAnos)
siguiente.plazoAnos = Math.min(35, Math.max(1, Number.isFinite(plazo) ? Math.round(plazo) : 20))
return siguiente
}
function Fila({ titulo, monto, negritas = false }: { titulo: string; monto: string; negritas?: boolean }) {
return (
<div className={fila}>
<dt className={negritas ? `text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]` : filaEtiqueta}>
{titulo}
</dt>
<dd className={negritas ? 'text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]' : filaMonto}>
{monto}
</dd>
</div>
)
}
function TarjetaOpcion({
r,
ajuste,
elegida,
onCambiarAjustes,
onElegir,
}: {
r: ResultadoOpcionCredito
ajuste: AjustesCredito
elegida: boolean
onCambiarAjustes: (tipo: TipoCredito, ajustes: AjustesCredito) => void
onElegir: (tipo: TipoCredito) => void
}) {
const conCredito = r.tipo !== 'CONTADO'
const esSemi = r.tipo === 'SEMI_CONTADO'
return (
<article
className={`${cardBase} ${elegida ? 'ring-2 ring-accent' : ''} ${r.esquemaVigente ? '' : 'opacity-90'}`}
>
<header className="mb-3 flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">{r.titulo}</h3>
{r.descuento > 0 && (
<span className="inline-flex shrink-0 items-center rounded-full bg-accent/10 px-2.5 py-1 text-xs font-semibold text-accent">
Descuento {formatoPorcentaje(r.descuento)}
</span>
)}
</header>
{!r.esquemaVigente ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-medium text-amber-800 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
El apartado supera el 10% del precio final con descuento; revisa el monto de apartado.
</p>
) : (
<>
<dl className="divide-y divide-[#E5E7EB] dark:divide-[#1E293B]">
<Fila titulo="Precio final c/desc." monto={formatoMoneda(r.precioFinal)} />
<Fila
titulo="Esquema FRASA 2026"
monto={`A la firma ${formatoMoneda(r.esquema.pagoUnicoFirma)} · ${r.esquema.numMensualidades} × ${formatoMoneda(r.esquema.mensualidad)} diferible`}
/>
{r.tipo === 'CONTADO' ? (
<>
<div className={fila}>
<dt className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Se liquida de contado
</dt>
<dd className="text-sm font-semibold text-accent">{formatoMoneda(r.saldoAFinanciar)}</dd>
</div>
<Fila titulo="Ahorro vs precio de lista" monto={formatoMoneda(r.ahorroVsLista)} />
<Fila titulo="Total a pagar" monto={formatoMoneda(r.totalEstimado)} negritas />
</>
) : (
<Fila titulo="Saldo a financiar (84.5%)" monto={formatoMoneda(r.saldoAFinanciar)} />
)}
{esSemi && (
<Fila titulo="Pago de contado a la firma" monto={formatoMoneda(r.pagoContado)} />
)}
</dl>
{esSemi && (
<div className="mt-3">
<label htmlFor={`pct-contado-${r.tipo}`} className={etiqueta}>
% de contado
</label>
<input
id={`pct-contado-${r.tipo}`}
type="number"
min={0}
max={100}
step={5}
value={Math.round((ajuste.pctContado ?? 0) * 100)}
onChange={(e) => {
const v = Number(e.target.value)
const pct = Number.isFinite(v) ? Math.min(100, Math.max(0, v)) / 100 : 0
onCambiarAjustes(r.tipo, ajustar(r.tipo, ajuste, { pctContado: pct }))
}}
className={inputClase}
/>
</div>
)}
{conCredito && (
<>
<div className="mt-3 grid grid-cols-2 gap-3">
<div>
<label htmlFor={`tasa-${r.tipo}`} className={etiqueta}>
Tasa anual %
</label>
<input
id={`tasa-${r.tipo}`}
type="number"
min={0}
max={50}
step={0.1}
value={Number((ajuste.tasaAnual * 100).toFixed(2))}
onChange={(e) => {
const v = Number(e.target.value)
onCambiarAjustes(
r.tipo,
ajustar(r.tipo, ajuste, { tasaAnual: Number.isFinite(v) ? v / 100 : 0 }),
)
}}
className={inputClase}
/>
</div>
<div>
<label htmlFor={`plazo-${r.tipo}`} className={etiqueta}>
Plazo
</label>
<select
id={`plazo-${r.tipo}`}
value={ajuste.plazoAnos}
onChange={(e) => onCambiarAjustes(r.tipo, ajustar(r.tipo, ajuste, { plazoAnos: Number(e.target.value) }))}
className={inputClase}
>
{Array.from({ length: 31 }, (_, i) => i + 5).map((n) => (
<option key={n} value={n}>
{n} años
</option>
))}
</select>
</div>
</div>
<div className="mt-3 rounded-lg bg-accent/10 px-3 py-2.5">
<p className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">Mensualidad estimada</p>
<p className="text-lg font-semibold text-accent">{formatoMoneda(r.mensualidadEstimada ?? 0)}</p>
</div>
<dl className="mt-3 divide-y divide-[#E5E7EB] dark:divide-[#1E293B]">
<Fila titulo="Monto del crédito" monto={formatoMoneda(r.montoCredito)} />
<Fila titulo="Intereses estimados" monto={formatoMoneda(r.interesesEstimados)} />
<Fila titulo="Total estimado" monto={formatoMoneda(r.totalEstimado)} negritas />
</dl>
<p className="mt-3 text-xs text-[#64748B] dark:text-[#94A3B8]">
Mensualidad estimada con tasa y plazo ajustables; sujeta a aprobación del banco. No
constituye preaprobación.
</p>
</>
)}
</>
)}
<button
type="button"
onClick={() => onElegir(r.tipo)}
aria-pressed={elegida}
className={`mt-4 w-full rounded-lg px-4 py-2 text-sm font-medium shadow-sm transition-all duration-100 ease-out ${
elegida
? 'bg-accent text-white hover:bg-accent-hover'
: 'border border-[#D1D5DB] bg-white text-[#374151] hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] dark:hover:bg-[#1E293B]'
}`}
>
{elegida ? 'Elegida — se incluye en el PDF' : 'Usar en el PDF'}
</button>
</article>
)
}
/**
* 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 (
<section className="mt-6">
<h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#111827] dark:text-[#E2E8F0]">
Opciones de financiamiento del saldo (84.5% del precio final)
</h2>
<div className="grid gap-4 sm:grid-cols-2">
{opciones.map((r) => (
<TarjetaOpcion
key={r.tipo}
r={r}
ajuste={ajustes[r.tipo]}
elegida={r.tipo === elegida}
onCambiarAjustes={onCambiarAjustes}
onElegir={onElegir}
/>
))}
</div>
</section>
)
}

View File

@@ -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<EntradaOpcion> = {}) {
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)))
})
})

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,
}
}

View File

@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { crearDocumentoPdf, textoCuotaMantenimiento } from './pdf' import { crearDocumentoPdf, textoCuotaMantenimiento } from './pdf'
import { calcularEsquemaFrasa } from './esquema-frasa' import { calcularEsquemaFrasa } from './esquema-frasa'
import { AJUSTES_DEFAULT, calcularOpcionCredito } from './opciones-credito'
import { UNIDADES_SEED } from '../data/unidades' import { UNIDADES_SEED } from '../data/unidades'
import { DESARROLLOS, LISTA_DESARROLLOS } from '../data/desarrollos' import { DESARROLLOS, LISTA_DESARROLLOS } from '../data/desarrollos'
import { formatoMoneda } from './formatters' import { formatoMoneda } from './formatters'
@@ -71,6 +72,62 @@ describe('crearDocumentoPdf', () => {
expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1) 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ó', () => { it('imprime el folio local CTE-LOCAL-* cuando el servidor no respondió', () => {
const unidad = UNIDADES_SEED[0] const unidad = UNIDADES_SEED[0]
const { doc } = crearDocumentoPdf({ const { doc } = crearDocumentoPdf({

View File

@@ -3,6 +3,7 @@ import autoTable from 'jspdf-autotable'
import type { Unidad, Cliente, ParametrosDesarrollo } from '../types' import type { Unidad, Cliente, ParametrosDesarrollo } from '../types'
import type { EsquemaFrasaResultado } from './esquema-frasa' import type { EsquemaFrasaResultado } from './esquema-frasa'
import { PCT_ENGANCHE, PCT_ENGANCHE_FIRMA, PCT_ESCRITURACION } 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 { formatoMoneda, formatoPorcentaje, formatoFechaLarga, fechaISOHoy } from './formatters'
import { DESARROLLOS, DESARROLLO_DEFAULT } from '../data/desarrollos' import { DESARROLLOS, DESARROLLO_DEFAULT } from '../data/desarrollos'
@@ -13,6 +14,8 @@ export interface PdfInput {
precioLista: number precioLista: number
/** Esquema único FRASA 2026 a imprimir */ /** Esquema único FRASA 2026 a imprimir */
esquema: EsquemaFrasaResultado esquema: EsquemaFrasaResultado
/** Opción de financiamiento elegida por el vendedor (sección de financiamiento) */
opcion?: ResultadoOpcionCredito
cliente: Cliente cliente: Cliente
/** Parámetros del desarrollo; por defecto DIAGONAL */ /** Parámetros del desarrollo; por defecto DIAGONAL */
desarrollo?: ParametrosDesarrollo desarrollo?: ParametrosDesarrollo
@@ -51,7 +54,7 @@ export interface DocumentoPdf {
} }
/** Construye el documento PDF "ANEXO A" (sin descargarlo) */ /** 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 params = desarrollo ?? DESARROLLOS[DESARROLLO_DEFAULT]
const doc = new jsPDF() const doc = new jsPDF()
const hoy = new Date() const hoy = new Date()
@@ -180,6 +183,53 @@ export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, esq
y = finalY(doc) + 6 y = finalY(doc) + 6
y += 2 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? // 4. ¿Qué sigue?
parrafo('¿QUÉ SIGUE?', { negritas: true, tamano: 11 }) parrafo('¿QUÉ SIGUE?', { negritas: true, tamano: 11 })
parrafo(`ENTREGAR LOS SIGUIENTES DOCUMENTOS MÁXIMO EL DÍA: ${formatoFechaLarga(limiteDocumentos)}`, { parrafo(`ENTREGAR LOS SIGUIENTES DOCUMENTOS MÁXIMO EL DÍA: ${formatoFechaLarga(limiteDocumentos)}`, {

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
@@ -11,6 +11,7 @@ import toast from "react-hot-toast"
import { UnitSelector } from '../components/UnitSelector' import { UnitSelector } from '../components/UnitSelector'
import { UnitInfoCard } from '../components/UnitInfoCard' import { UnitInfoCard } from '../components/UnitInfoCard'
import { EsquemaPanel } from '../components/EsquemaPanel' import { EsquemaPanel } from '../components/EsquemaPanel'
import { OpcionesCredito } from '../components/OpcionesCredito'
import { MisCotizaciones } from '../components/MisCotizaciones' import { MisCotizaciones } from '../components/MisCotizaciones'
import { ModalCliente } from '../components/ModalCliente' import { ModalCliente } from '../components/ModalCliente'
import { BuscadorCliente } from '../components/BuscadorCliente' import { BuscadorCliente } from '../components/BuscadorCliente'
@@ -19,6 +20,13 @@ import { formatoMoneda, formatoFechaLarga } from '../lib/formatters'
import { calcularVigencia } from '../lib/vigencia' import { calcularVigencia } from '../lib/vigencia'
import { folioCotizacion } from '../lib/folio' import { folioCotizacion } from '../lib/folio'
import { textoCompartir, urlWhatsApp } from '../lib/compartir' 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 { desarrolloEfectivo, idsCotizables } from '../lib/reglas'
import { import {
getMisCotizaciones, getMisCotizaciones,
@@ -161,6 +169,39 @@ export default function CotizadorPage() {
const nombreCliente = watch('nombre') ?? '' const nombreCliente = watch('nombre') ?? ''
// 4 opciones de crédito sobre el esquema FRASA 2026 (saldo 84.5% a financiar)
const [ajustesCredito, setAjustesCredito] = useState<Record<TipoCredito, AjustesCredito>>(() =>
structuredClone(AJUSTES_DEFAULT),
)
const [opcionElegida, setOpcionElegida] = useState<TipoCredito>('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<TipoCredito, AjustesCredito>) =>
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, // 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 // desarrollo, unidad y el id de la oportunidad a vincular al guardar
useEffect(() => { useEffect(() => {
@@ -265,6 +306,7 @@ export default function CotizadorPage() {
numMensualidades, numMensualidades,
mensualidad: resultado.mensualidad, mensualidad: resultado.mensualidad,
pagoFinal: resultado.pagoUnicoFirma, pagoFinal: resultado.pagoUnicoFirma,
opcionesCredito: JSON.stringify({ elegida: opcionElegida, ajustes: ajustesCredito }),
createdAt: new Date(timestamp).toISOString(), createdAt: new Date(timestamp).toISOString(),
} }
} }
@@ -288,6 +330,7 @@ export default function CotizadorPage() {
apartado: apartadoEfectivo, apartado: apartadoEfectivo,
numMensualidades, numMensualidades,
escrituracionDiferida, escrituracionDiferida,
opcionesCredito: JSON.stringify({ elegida: opcionElegida, ajustes: ajustesCredito }),
...(oportunidadId !== null ? { oportunidadId } : {}), ...(oportunidadId !== null ? { oportunidadId } : {}),
}) })
const conFolio = { ...guardada, folio: guardada.folio ?? folioCotizacion(guardada.id) } const conFolio = { ...guardada, folio: guardada.folio ?? folioCotizacion(guardada.id) }
@@ -337,6 +380,8 @@ export default function CotizadorPage() {
precioLista, precioLista,
// El PDF imprime SOLO el esquema único FRASA 2026 // El PDF imprime SOLO el esquema único FRASA 2026
esquema: resultado, esquema: resultado,
// + la opción de financiamiento elegida (Crédito Bancario por default)
opcion: opcionesCredito.find((o) => o.tipo === opcionElegida),
cliente, cliente,
desarrollo: params, desarrollo: params,
folio, folio,
@@ -457,6 +502,39 @@ export default function CotizadorPage() {
setValue('telefono', c.clienteTelefono ?? '', { shouldValidate: true }) setValue('telefono', c.clienteTelefono ?? '', { shouldValidate: true })
setValue('email', c.clienteEmail ?? '', { shouldValidate: true }) setValue('email', c.clienteEmail ?? '', { shouldValidate: true })
setUltimaGuardada(c) 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<TipoCredito, AjustesCredito>
}
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 const avisoEsquema = nueva
? '' ? ''
: ` Usa el esquema anterior (${c.tipoPropuesta}); se cargó con el esquema FRASA 2026 y sus valores por defecto.` : ` 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() {
</p> </p>
</div> </div>
) : ( ) : (
<EsquemaPanel <>
esquema={resultado} <EsquemaPanel
numMensualidades={numMensualidades} esquema={resultado}
onNumMensualidades={setNumMensualidades} numMensualidades={numMensualidades}
escrituracionDiferida={escrituracionDiferida} onNumMensualidades={setNumMensualidades}
onEscrituracionDiferida={setEscrituracionDiferida} escrituracionDiferida={escrituracionDiferida}
/> onEscrituracionDiferida={setEscrituracionDiferida}
/>
<OpcionesCredito
opciones={opcionesCredito}
ajustes={ajustesCredito}
onCambiarAjustes={(tipo, ajustes) =>
setAjustesCredito((prev) => ({ ...prev, [tipo]: ajustes }))
}
elegida={opcionElegida}
onElegir={setOpcionElegida}
/>
</>
)} )}
</section> </section>
</div> </div>

View File

@@ -107,6 +107,8 @@ export interface CotizacionGuardada {
numMensualidades: number numMensualidades: number
mensualidad: number | null mensualidad: number | null
pagoFinal: number pagoFinal: number
/** JSON {elegida, ajustes} de las opciones de crédito (filas nuevas) */
opcionesCredito?: string | null
createdAt: string createdAt: string
} }
@@ -124,6 +126,8 @@ export interface GuardarCotizacionPayload {
escrituracionDiferida?: boolean escrituracionDiferida?: boolean
/** Item del tablero Oportunidades a vincular (botón "Cotizar" de Mi día) */ /** Item del tablero Oportunidades a vincular (botón "Cotizar" de Mi día) */
oportunidadId?: string oportunidadId?: string
/** JSON serializado {elegida, ajustes} de las 4 opciones de crédito; el API lo guarda crudo */
opcionesCredito?: string
} }
/** /**