From 4a3ddd2f35e9cb4fdd370b0ac290f5b40ebdaee8 Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Mon, 28 Sep 2026 19:56:37 +0000 Subject: [PATCH] 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). --- .../migration.sql | 7 + api-gateway/prisma/schema.prisma | 3 + .../cotizaciones.controller.test.ts | 41 +++ .../controllers/cotizaciones.controller.ts | 5 + .../src/components/OpcionesCredito.tsx | 236 ++++++++++++++++++ .../src/lib/opciones-credito.test.ts | 184 ++++++++++++++ apps/cotizador/src/lib/opciones-credito.ts | 178 +++++++++++++ apps/cotizador/src/lib/pdf.test.ts | 57 +++++ apps/cotizador/src/lib/pdf.ts | 52 +++- apps/cotizador/src/pages/CotizadorPage.tsx | 105 +++++++- apps/cotizador/src/services/api.ts | 4 + 11 files changed, 863 insertions(+), 9 deletions(-) create mode 100644 api-gateway/prisma/migrations/20260928153000_opciones_credito_cotizacion/migration.sql create mode 100644 apps/cotizador/src/components/OpcionesCredito.tsx create mode 100644 apps/cotizador/src/lib/opciones-credito.test.ts create mode 100644 apps/cotizador/src/lib/opciones-credito.ts diff --git a/api-gateway/prisma/migrations/20260928153000_opciones_credito_cotizacion/migration.sql b/api-gateway/prisma/migrations/20260928153000_opciones_credito_cotizacion/migration.sql new file mode 100644 index 0000000..8b44ecd --- /dev/null +++ b/api-gateway/prisma/migrations/20260928153000_opciones_credito_cotizacion/migration.sql @@ -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; diff --git a/api-gateway/prisma/schema.prisma b/api-gateway/prisma/schema.prisma index 4f01f40..0c64ad0 100644 --- a/api-gateway/prisma/schema.prisma +++ b/api-gateway/prisma/schema.prisma @@ -41,6 +41,9 @@ model Cotizacion { pctEscrituracion Float? // 0.055 escrituracionDiferida Boolean @default(false) 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? createdAt DateTime @default(now()) diff --git a/api-gateway/src/controllers/cotizaciones.controller.test.ts b/api-gateway/src/controllers/cotizaciones.controller.test.ts index ae41bc4..bcf2efe 100644 --- a/api-gateway/src/controllers/cotizaciones.controller.test.ts +++ b/api-gateway/src/controllers/cotizaciones.controller.test.ts @@ -299,6 +299,47 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => { 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 () => { prismaMock.unidad.findUnique.mockResolvedValue({ ...filaUnidad, codigo: 'ATELIER-10', status: 'VENDIDO' }) const { res } = resFake() diff --git a/api-gateway/src/controllers/cotizaciones.controller.ts b/api-gateway/src/controllers/cotizaciones.controller.ts index fdc6bf5..f946cd0 100644 --- a/api-gateway/src/controllers/cotizaciones.controller.ts +++ b/api-gateway/src/controllers/cotizaciones.controller.ts @@ -41,6 +41,9 @@ const crearCotizacionSchema = z.object({ .max(MENSUALIDADES_MAX, `Máximo ${MENSUALIDADES_MAX} mensualidades`) .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 // día manda el id; sin él se busca por teléfono) oportunidadId: z.string().trim().min(1).optional(), @@ -135,6 +138,8 @@ export async function crearCotizacion(req: Request, res: Response): Promise + 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 { + 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 ( +
+
+ {titulo} +
+
+ {monto} +
+
+ ) +} + +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 ( +
+
+

{r.titulo}

+ {r.descuento > 0 && ( + + Descuento {formatoPorcentaje(r.descuento)} + + )} +
+ + {!r.esquemaVigente ? ( +

+ El apartado supera el 10% del precio final con descuento; revisa el monto de apartado. +

+ ) : ( + <> +
+ + + {r.tipo === 'CONTADO' ? ( + <> +
+
+ Se liquida de contado +
+
{formatoMoneda(r.saldoAFinanciar)}
+
+ + + + ) : ( + + )} + {esSemi && ( + + )} +
+ + {esSemi && ( +
+ + { + 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} + /> +
+ )} + + {conCredito && ( + <> +
+
+ + { + const v = Number(e.target.value) + onCambiarAjustes( + r.tipo, + ajustar(r.tipo, ajuste, { tasaAnual: Number.isFinite(v) ? v / 100 : 0 }), + ) + }} + className={inputClase} + /> +
+
+ + +
+
+ +
+

Mensualidad estimada

+

{formatoMoneda(r.mensualidadEstimada ?? 0)}

+
+ +
+ + + +
+ +

+ Mensualidad estimada con tasa y plazo ajustables; sujeta a aprobación del banco. No + constituye preaprobación. +

+ + )} + + )} + + +
+ ) +} + +/** + * 4 opciones de financiamiento del saldo (84.5% del precio final) sobre el + * esquema FRASA 2026: Crédito Bancario, Infonavit-Cofinavit, Semi Contado y + * Contado. Cada tarjeta ajusta su tasa/plazo (y % de contado para la semi), + * estima la mensualidad del crédito y permite elegir la propuesta que sale + * en el PDF. + */ +export function OpcionesCredito({ opciones, ajustes, onCambiarAjustes, elegida, onElegir }: Props) { + return ( +
+

+ Opciones de financiamiento del saldo (84.5% del precio final) +

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

) : ( - + <> + + + setAjustesCredito((prev) => ({ ...prev, [tipo]: ajustes })) + } + elegida={opcionElegida} + onElegir={setOpcionElegida} + /> + )} diff --git a/apps/cotizador/src/services/api.ts b/apps/cotizador/src/services/api.ts index a25cec6..0d23de9 100644 --- a/apps/cotizador/src/services/api.ts +++ b/apps/cotizador/src/services/api.ts @@ -107,6 +107,8 @@ export interface CotizacionGuardada { numMensualidades: number mensualidad: number | null pagoFinal: number + /** JSON {elegida, ajustes} de las opciones de crédito (filas nuevas) */ + opcionesCredito?: string | null createdAt: string } @@ -124,6 +126,8 @@ export interface GuardarCotizacionPayload { escrituracionDiferida?: boolean /** Item del tablero Oportunidades a vincular (botón "Cotizar" de Mi día) */ oportunidadId?: string + /** JSON serializado {elegida, ajustes} de las 4 opciones de crédito; el API lo guarda crudo */ + opcionesCredito?: string } /**