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:
@@ -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;
|
||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
236
apps/cotizador/src/components/OpcionesCredito.tsx
Normal file
236
apps/cotizador/src/components/OpcionesCredito.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
184
apps/cotizador/src/lib/opciones-credito.test.ts
Normal file
184
apps/cotizador/src/lib/opciones-credito.test.ts
Normal 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)))
|
||||||
|
})
|
||||||
|
})
|
||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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)}`, {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user