diff --git a/api-gateway/prisma/migrations/20260928120000_cotizacion_esquema_frasa/migration.sql b/api-gateway/prisma/migrations/20260928120000_cotizacion_esquema_frasa/migration.sql new file mode 100644 index 0000000..2aa9c5e --- /dev/null +++ b/api-gateway/prisma/migrations/20260928120000_cotizacion_esquema_frasa/migration.sql @@ -0,0 +1,14 @@ +-- Esquema de cotización único FRASA 2026 (aprobado por el cliente el 28 sep 2026): +-- apartado libre + enganche de firma 2.5% + enganche 7.5% diferible + +-- escrituración 5.5% (a la firma o diferida con el enganche). Las columnas +-- legacy (tipoPropuesta, descuento, precioFinal, enganche, mensualidad, +-- pagoFinal) se siguen llenando para compatibilidad de reportes. +-- Patrón manual: sqlite3 + _prisma_migrations. NUNCA migrate dev. + +ALTER TABLE "Cotizacion" ADD COLUMN "esquema" TEXT NOT NULL DEFAULT 'FRASA_2026'; +ALTER TABLE "Cotizacion" ADD COLUMN "saldoFirma" REAL; +ALTER TABLE "Cotizacion" ADD COLUMN "pctEngancheFirma" REAL; +ALTER TABLE "Cotizacion" ADD COLUMN "engancheDiferible" REAL; +ALTER TABLE "Cotizacion" ADD COLUMN "pctEscrituracion" REAL; +ALTER TABLE "Cotizacion" ADD COLUMN "escrituracionDiferida" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "Cotizacion" ADD COLUMN "escrituracion" REAL; diff --git a/api-gateway/prisma/schema.prisma b/api-gateway/prisma/schema.prisma index 8392dcf..4f01f40 100644 --- a/api-gateway/prisma/schema.prisma +++ b/api-gateway/prisma/schema.prisma @@ -21,7 +21,7 @@ model Cotizacion { clienteNombre String clienteEmail String? clienteTelefono String? - tipoPropuesta String // BANCARIO | INFONAVIT | CONTADO | SEMI_CONTADO + tipoPropuesta String // BANCARIO | INFONAVIT | CONTADO | SEMI_CONTADO | FRASA_2026 (esquema único) acabados Boolean apartado Float // TODO prod: Decimal en Postgres descuento Float // fracción (0.02 = 2%) @@ -31,6 +31,16 @@ model Cotizacion { numMensualidades Int mensualidad Float? // null => N/A (Contado) pagoFinal Float + // Esquema único FRASA 2026 (apartado + firma 2.5% + enganche 7.5% + + // escrituración 5.5%). Las filas legacy tienen esquema='FRASA_2026' por el + // DEFAULT pero saldoFirma/engancheDiferible/escrituracion null. + esquema String @default("FRASA_2026") + saldoFirma Float? // lo que se paga a la firma por el enganche de firma (>= 0) + pctEngancheFirma Float? // 0.025 (constante del esquema; se persiste por si cambia) + engancheDiferible Float? // enganche del 7.5%, neto del excedente del apartado + pctEscrituracion Float? // 0.055 + escrituracionDiferida Boolean @default(false) + escrituracion Float? // gastos de escrituración (5.5% del precio) 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 e6f4277..ae41bc4 100644 --- a/api-gateway/src/controllers/cotizaciones.controller.test.ts +++ b/api-gateway/src/controllers/cotizaciones.controller.test.ts @@ -69,7 +69,28 @@ const bodyBase = { acabados: false, clienteNombre: 'Cliente Demo', clienteTelefono: '4771234567', - tipoPropuesta: 'BANCARIO' as const, +} + +/** + * Esquema único FRASA 2026 esperado para filaUnidad (precio $1,000,000) con + * los defaults del body: apartado $15,000, N=14, escrituración a la firma. + * Caso dorado del cliente: hoy $15,000 · firma $65,000 · 14 × $5,357.14. + */ +const esquemaDorado = { + esquema: 'FRASA_2026', + apartado: 15000, + saldoFirma: 10000, + engancheDiferible: 75000, + escrituracion: 55000, + escrituracionDiferida: false, + tipoPropuesta: 'FRASA_2026', + descuento: 0, + precioLista: 1_000_000, + precioFinal: 1_000_000, + enganche: 75000, + numMensualidades: 14, + mensualidad: 75000 / 14, + pagoFinal: 65000, } function reqFake(body: unknown): Request { @@ -110,10 +131,54 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => { expect(status).toHaveBeenCalledWith(201) const respuesta = json.mock.calls[0]?.[0] as Record expect(respuesta).toMatchObject({ unidadCodigo: 'U-101', desarrollo: 'DIAGONAL', mondayItemId: null }) - expect(respuesta['propuesta']).toBeDefined() + expect(respuesta['esquema']).toBeDefined() expect(respuesta['folio']).toBeDefined() }) + it('caso dorado: recalcula el esquema FRASA 2026 server-side y persiste columnas nuevas y legacy', async () => { + const { res, json } = resFake() + + await crearCotizacion(reqFake(bodyBase), res) + + expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({ data: expect.objectContaining(esquemaDorado) }) + const respuesta = json.mock.calls[0]?.[0] as { + esquema: { saldoFirma: number; pagoUnicoFirma: number; mensualidad: number; baseDiferible: number } + } + expect(respuesta.esquema.saldoFirma).toBe(10000) + expect(respuesta.esquema.pagoUnicoFirma).toBe(65000) + expect(respuesta.esquema.baseDiferible).toBe(75000) + expect(respuesta.esquema.mensualidad).toBeCloseTo(75000 / 14, 2) + }) + + it('escrituración diferida: entra al diferible y baja el pago a la firma', async () => { + const { res, json } = resFake() + + await crearCotizacion(reqFake({ ...bodyBase, escrituracionDiferida: true }), res) + + expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + escrituracionDiferida: true, + pagoFinal: 10000, + mensualidad: 130000 / 14, + }), + }) + const respuesta = json.mock.calls[0]?.[0] as { esquema: { baseDiferible: number } } + expect(respuesta.esquema.baseDiferible).toBe(130000) + }) + + it('apartado mayor al 2.5%: el excedente se acredita al enganche (no se cobra dos veces)', async () => { + const { res, json } = resFake() + + await crearCotizacion(reqFake({ ...bodyBase, apartado: 30000 }), res) + + expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ apartado: 30000, saldoFirma: 0, enganche: 70000, pagoFinal: 55000 }), + }) + const respuesta = json.mock.calls[0]?.[0] as { esquema: { excedenteApartado: number; totalPagado: number } } + expect(respuesta.esquema.excedenteApartado).toBe(5000) + expect(respuesta.esquema.totalPagado).toBeCloseTo(155000, 2) + }) + it('la unidad no existe en el espejo → 404 sin crear cotización', async () => { prismaMock.unidad.findUnique.mockResolvedValue(null) const { res } = resFake() @@ -195,7 +260,7 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => { expect(prismaMock.leadCaptura.findFirst).not.toHaveBeenCalled() }) - it('toma la fecha de entrega de la captura: mensualidad y total ya no salen nulos', async () => { + it('sin numMensualidades usa el default del esquema (14)', async () => { prismaMock.capturaDepartamento.findMany.mockResolvedValue([ { unidadCodigo: 'U-101', campo: 'habitabilidad', valor: '2027-10-08' }, ]) @@ -206,12 +271,11 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => { const respuesta = json.mock.calls[0]?.[0] as { numMensualidades: number mensualidad: number - propuesta: { numMensualidades: number; mensualidad: number; mensualidadesTotal: number } + esquema: { numMensualidades: number; mensualidad: number; baseDiferible: number } } - expect(respuesta.numMensualidades).toBeGreaterThan(0) - expect(respuesta.numMensualidades).toBe(respuesta.propuesta.numMensualidades) - expect(respuesta.propuesta.mensualidad).toBeGreaterThan(0) - expect(respuesta.propuesta.mensualidadesTotal).toBeGreaterThan(0) + expect(respuesta.numMensualidades).toBe(14) + expect(respuesta.numMensualidades).toBe(respuesta.esquema.numMensualidades) + expect(respuesta.esquema.mensualidad).toBeCloseTo(respuesta.esquema.baseDiferible / 14, 6) }) it('las mensualidades elegidas por el vendedor viajan al cálculo y se persisten', async () => { @@ -219,13 +283,20 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => { await crearCotizacion(reqFake({ ...bodyBase, numMensualidades: 12 }), res) - // DIAGONAL bancario sin acabados: remanente 75,000 en 12 pagos + // Enganche diferible $75,000 en 12 pagos expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({ data: expect.objectContaining({ numMensualidades: 12, mensualidad: 6250 }), }) - const respuesta = json.mock.calls[0]?.[0] as { numMensualidades: number; propuesta: { numMensualidades: number } } + const respuesta = json.mock.calls[0]?.[0] as { numMensualidades: number; esquema: { numMensualidades: number } } expect(respuesta.numMensualidades).toBe(12) - expect(respuesta.propuesta.numMensualidades).toBe(12) + expect(respuesta.esquema.numMensualidades).toBe(12) + }) + + it('rechaza mensualidades fuera de rango (1..36) sin persistir', async () => { + const { res } = resFake() + + await expect(crearCotizacion(reqFake({ ...bodyBase, numMensualidades: 48 }), res)).rejects.toBeDefined() + expect(prismaMock.cotizacion.create).not.toHaveBeenCalled() }) it('unidad no DISPONIBLE (apartada/vendida) → 409 y no se crea cotización', async () => { @@ -266,47 +337,41 @@ describe('crearCotizacion — reglas comerciales del admin', () => { ] } - it('sin reglas guardadas cotiza igual que siempre (respaldo en constantes)', async () => { + it('sin reglas guardadas cotiza con el esquema único (respaldo en constantes)', async () => { const { res, status } = resFake() await crearCotizacion(reqFake(bodyBase), res) expect(status).toHaveBeenCalledWith(201) expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ desarrollo: 'DIAGONAL', descuento: 0 }), + data: expect.objectContaining({ desarrollo: 'DIAGONAL', descuento: 0, precioFinal: 1_000_000 }), }) }) - it('usa el descuento configurado por el admin', async () => { + it('los descuentos que configure el admin YA NO aplican: el esquema único no los usa', async () => { prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue( reglasGuardadas('DIAGONAL', { descuentoContado: 0.1 }), ) const { res, status } = resFake() - await crearCotizacion(reqFake({ ...bodyBase, tipoPropuesta: 'CONTADO' }), res) + await crearCotizacion(reqFake(bodyBase), res) expect(status).toHaveBeenCalledWith(201) expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ descuento: 0.1, precioFinal: 900_000 }), + data: expect.objectContaining({ descuento: 0, precioFinal: 1_000_000 }), }) }) - it('propuesta desactivada → 409 con el mensaje del servidor y sin persistir', async () => { + it('propuesta desactivada por el admin ya NO bloquea: el esquema único no usa propuestas', async () => { prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue( reglasGuardadas('DIAGONAL', { propuestaInactiva: 'CREDITO_BANCARIO' }), ) - const { res } = resFake() + const { res, status } = resFake() - await expect(crearCotizacion(reqFake(bodyBase), res)).rejects.toMatchObject({ - status: 409, - message: expect.stringContaining('La propuesta BANCARIO no está disponible'), - }) - expect(prismaMock.cotizacion.create).not.toHaveBeenCalled() + await crearCotizacion(reqFake(bodyBase), res) - // Las otras propuestas siguen cotizándose - const { res: res2, status } = resFake() - await crearCotizacion(reqFake({ ...bodyBase, tipoPropuesta: 'CONTADO' }), res2) expect(status).toHaveBeenCalledWith(201) + expect(prismaMock.cotizacion.create).toHaveBeenCalled() }) it('desarrollo desactivado (activoEnCotizador: false) → 409 y sin persistir', async () => { diff --git a/api-gateway/src/controllers/cotizaciones.controller.ts b/api-gateway/src/controllers/cotizaciones.controller.ts index 5824dea..fdc6bf5 100644 --- a/api-gateway/src/controllers/cotizaciones.controller.ts +++ b/api-gateway/src/controllers/cotizaciones.controller.ts @@ -1,12 +1,16 @@ import type { Request, Response } from 'express' import { z } from 'zod' import { getUnidadLocal } from '../services/unidades-local.service.js' +import { obtenerPrecioLista } from '../services/cotizacion.service.js' import { - calcularPropuesta, - obtenerPrecioLista, - propuestasVigentes, - TIPO_PROPUESTA_API, -} from '../services/cotizacion.service.js' + calcularEsquemaFrasa, + MENSUALIDADES_DEFAULT, + MENSUALIDADES_MAX, + MENSUALIDADES_MIN, + PCT_ENGANCHE_FIRMA, + PCT_ESCRITURACION, + type EsquemaFrasaResultado, +} from '../services/cotizacion-esquema.service.js' import { getReglasDesarrollo } from '../services/reglas-desarrollo.service.js' import { procesarAlertasInventario, @@ -27,19 +31,24 @@ const crearCotizacionSchema = z.object({ clienteNombre: z.string().min(1), clienteEmail: z.string().email().optional(), clienteTelefono: z.string().min(7).optional(), - tipoPropuesta: z.enum(['BANCARIO', 'INFONAVIT', 'COFINAVIT', 'CONTADO', 'SEMI_CONTADO']), - descuento: z.number().min(0).max(1).optional(), // fracción (0.02 = 2%) - // Enganche elegido por el asesor (fracción, mínimo 2.5%); solo aplica a - // esquemas con crédito (bancario/Infonavit). El Excel permite 2.5%–5%. - enganchePct: z.number().min(0.025, 'El enganche mínimo es 2.5%').max(1).optional(), - // Mensualidades elegidas por el asesor; sin él se resuelve con la fecha de - // entrega de la unidad (o el plazo por defecto del desarrollo) - numMensualidades: z.number().int().min(1).max(120).optional(), + // Esquema único FRASA 2026: mensualidades del diferimiento (elección del + // vendedor, 1..36; sin ellas va el default de 14) y si la escrituración + // (5.5%) se difiere con el enganche o se paga a la firma + numMensualidades: z + .number() + .int('Las mensualidades deben ser un número entero') + .min(MENSUALIDADES_MIN, `Mínimo ${MENSUALIDADES_MIN} mensualidad`) + .max(MENSUALIDADES_MAX, `Máximo ${MENSUALIDADES_MAX} mensualidades`) + .optional(), + escrituracionDiferida: z.boolean().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(), }) +/** Apartado por defecto del esquema único (el cotizador arranca con el mismo valor) */ +const APARTADO_DEFAULT = 15000 + /** Unidad cotizable: solo inmuebles DISPONIBLES (apartar/vender no se puede cotizar dos veces) */ function validarUnidadDisponible(unidad: { codigo: string; status: string }): void { const status = unidad.status.trim().toUpperCase() @@ -83,8 +92,9 @@ export async function crearCotizacion(req: Request, res: Response): Promise p.tipo === tipoInterno)) { - throw new AppError( - 409, - `La propuesta ${body.tipoPropuesta} no está disponible para el desarrollo ${reglas.id}. Elige otra propuesta.`, - ) - } - const apartado = body.apartado ?? 20000 // APARTADO_DEFAULT - const propuesta = calcularPropuesta( - unidad, - body.tipoPropuesta, - body.acabados, - apartado, - body.descuento, - body.enganchePct, - body.numMensualidades, - reglas, - ) + // El API RECALCULA el esquema server-side con el precio de lista de la + // unidad: no se confía en los montos que manda el cliente. + const apartado = body.apartado ?? APARTADO_DEFAULT + const numMensualidades = body.numMensualidades ?? MENSUALIDADES_DEFAULT + const escrituracionDiferida = body.escrituracionDiferida ?? false const precioLista = obtenerPrecioLista(unidad, body.acabados) + const esquema: EsquemaFrasaResultado = calcularEsquemaFrasa({ + precio: precioLista, + apartado, + numMensualidades, + escrituracionDiferida, + }) const cotizacion = await prisma.cotizacion.create({ data: { @@ -121,18 +124,27 @@ export async function crearCotizacion(req: Request, res: Response): Promise no se pudo resolver el plazo (no debería ocurrir: los desarrollos - // tienen plazo por defecto). Si no, el N elegido por el vendedor. - numMensualidades: propuesta.numMensualidades ?? 0, - mensualidad: propuesta.mensualidad, - pagoFinal: propuesta.pagoFinal, + // Columnas nuevas del esquema único + esquema: 'FRASA_2026', + saldoFirma: esquema.saldoFirma, + pctEngancheFirma: PCT_ENGANCHE_FIRMA, + engancheDiferible: esquema.enganche, + pctEscrituracion: PCT_ESCRITURACION, + escrituracionDiferida, + escrituracion: esquema.escrituracion, + // Columnas legacy, rellenadas para compatibilidad de reportes: + // 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. + tipoPropuesta: 'FRASA_2026', + descuento: 0, + precioFinal: precioLista, + enganche: esquema.enganche, + numMensualidades, + mensualidad: esquema.mensualidad, + pagoFinal: esquema.pagoUnicoFirma, }, }) @@ -163,7 +175,7 @@ export async function crearCotizacion(req: Request, res: Response): Promise { + it('escrituración de contado: firma $65,000 y mensualidades de $75,000/14', () => { + const r = calcularEsquemaFrasa({ ...CASO, escrituracionDiferida: false }) + expect(r.engancheFirmaTotal).toBe(25000) // 2.5% + expect(r.saldoFirma).toBe(10000) // 25,000 − 15,000 apartado acreditado + expect(r.excedenteApartado).toBe(0) + expect(r.enganche).toBe(75000) // 7.5% + expect(r.escrituracion).toBe(55000) // 5.5% + expect(r.pagoUnicoFirma).toBe(65000) // 10,000 + 55,000 + expect(r.baseDiferible).toBe(75000) + expect(r.mensualidad).toBeCloseTo(75000 / 14, 2) // 5,357.14 + expect(r.totalPagado).toBeCloseTo(1000000 * (PCT_ENGANCHE_FIRMA + PCT_ENGANCHE + PCT_ESCRITURACION), 2) + }) + + it('escrituración diferida: firma $10,000 y mensualidades de $130,000/14', () => { + const r = calcularEsquemaFrasa({ ...CASO, escrituracionDiferida: true }) + expect(r.saldoFirma).toBe(10000) + expect(r.pagoUnicoFirma).toBe(10000) + expect(r.baseDiferible).toBe(130000) // 75,000 + 55,000 + expect(r.mensualidad).toBeCloseTo(130000 / 14, 2) // 9,285.71 + }) + + it('el total siempre cuadra: apartado + firma + diferible', () => { + for (const dif of [false, true]) { + const r = calcularEsquemaFrasa({ ...CASO, escrituracionDiferida: dif }) + expect(r.apartado + r.pagoUnicoFirma + r.baseDiferible).toBeCloseTo(r.totalPagado, 6) + expect(r.totalPagado).toBeCloseTo(155000, 2) // 15.5% del precio + } + }) +}) + +describe('calcularEsquemaFrasa — apartado vs enganche de firma', () => { + it('apartado exactamente el 2.5%: saldo a firma 0', () => { + const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 25000, numMensualidades: 14, escrituracionDiferida: false }) + expect(r.saldoFirma).toBe(0) + expect(r.excedenteApartado).toBe(0) + expect(r.enganche).toBe(75000) + }) + + it('apartado mayor al 2.5%: el excedente se acredita al enganche (no se cobra dos veces)', () => { + const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 30000, numMensualidades: 14, escrituracionDiferida: false }) + expect(r.saldoFirma).toBe(0) + expect(r.excedenteApartado).toBe(5000) + expect(r.enganche).toBe(70000) // 75,000 − 5,000 + expect(r.totalPagado).toBeCloseTo(155000, 2) // 30k + 70k + 55k + }) + + it('apartado $0: el cliente paga todo el 2.5% a la firma', () => { + const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 0, numMensualidades: 14, escrituracionDiferida: false }) + expect(r.saldoFirma).toBe(25000) + expect(r.enganche).toBe(75000) + }) +}) + +describe('validarEsquemaInput — guardas del esquema', () => { + const valido = { precio: 1000000, apartado: 15000, numMensualidades: 14, escrituracionDiferida: false } + + it('acepta el caso dorado', () => { + expect(validarEsquemaInput(valido)).toBeNull() + }) + + it('rechaza precio no positivo, apartado negativo y apartado mayor al precio', () => { + expect(validarEsquemaInput({ ...valido, precio: 0 })).toContain('precio') + expect(validarEsquemaInput({ ...valido, apartado: -1 })).toContain('apartado') + expect(validarEsquemaInput({ ...valido, apartado: 2000000 })).toContain('superar') + }) + + it('rechaza mensualidades fuera de rango o no enteras', () => { + expect(validarEsquemaInput({ ...valido, numMensualidades: 0 })).toContain('Mínimo') + expect(validarEsquemaInput({ ...valido, numMensualidades: 37 })).toContain('Máximo') + expect(validarEsquemaInput({ ...valido, numMensualidades: 2.5 })).toContain('entero') + }) + + it('calcularEsquemaFrasa lanza el mensaje de validación', () => { + expect(() => calcularEsquemaFrasa({ ...valido, numMensualidades: 0 })).toThrow('Mínimo') + }) + + it('constantes del esquema (globales para todos los desarrollos)', () => { + expect(PCT_ENGANCHE_FIRMA).toBe(0.025) + expect(PCT_ENGANCHE).toBe(0.075) + expect(PCT_ESCRITURACION).toBe(0.055) + expect(MENSUALIDADES_DEFAULT).toBe(14) + expect(MENSUALIDADES_MIN).toBe(1) + expect(MENSUALIDADES_MAX).toBe(36) + }) +}) diff --git a/api-gateway/src/services/cotizacion-esquema.service.ts b/api-gateway/src/services/cotizacion-esquema.service.ts new file mode 100644 index 0000000..b6e403e --- /dev/null +++ b/api-gateway/src/services/cotizacion-esquema.service.ts @@ -0,0 +1,103 @@ +/** + * Esquema de cotización FRASA 2026 (flujo único, aprobado por el cliente): + * + * 1. APARTADO: cantidad libre (sin mínimo), aparta la unidad. Hoy. + * 2. ENGANCHE DE FIRMA: 2.5% del precio. El apartado se acredita DENTRO de + * este concepto → lo que se paga a la firma es el saldo restante + * (si el apartado supera el 2.5%, el excedente pasa al enganche). + * 3. ENGANCHE: 7.5% del precio, diferible en N mensualidades (N a elección + * del vendedor). + * 4. GASTOS DE ESCRITURACIÓN: 5.5% del precio. El cliente los salda en un + * pago a la firma O los difiere junto con el enganche. + * + * Espejo en TypeScript de apps/cotizador/src/lib/esquema-frasa.ts: las fórmulas + * deben mantenerse idénticas en ambos lados (el API recalcula server-side y no + * confía en los montos que manda el cliente). Servicio puro: no toca prisma. + * Porcentajes globales para todos los desarrollos (confirmado por el cliente + * el 28 sep 2026). + */ + +export const PCT_ENGANCHE_FIRMA = 0.025 +export const PCT_ENGANCHE = 0.075 +export const PCT_ESCRITURACION = 0.055 + +export const MENSUALIDADES_DEFAULT = 14 +export const MENSUALIDADES_MIN = 1 +export const MENSUALIDADES_MAX = 36 + +export interface EsquemaFrasaInput { + /** Precio de la unidad (ya resuelto acabados/precio único) */ + precio: number + /** Apartado: cantidad libre (>= 0) */ + apartado: number + /** Número de mensualidades del diferimiento (elección del vendedor) */ + numMensualidades: number + /** true = la escrituración se difiere con el enganche; false = se paga a la firma */ + escrituracionDiferida: boolean +} + +export interface EsquemaFrasaResultado { + precio: number + apartado: number + /** 2.5% del precio (concepto completo) */ + engancheFirmaTotal: number + /** Lo que se paga a la firma por el enganche de firma (>= 0) */ + saldoFirma: number + /** Apartado que excedió el 2.5% y se acredita al enganche (>= 0) */ + excedenteApartado: number + /** Enganche del 7.5%, neto del excedente del apartado */ + enganche: number + escrituracion: number + escrituracionDiferida: boolean + /** Todo lo que se liquida a la firma: saldoFirma + escrituración si no se difiere */ + pagoUnicoFirma: number + /** Lo que entra al diferimiento: enganche (+ escrituración si se difiere) */ + baseDiferible: number + numMensualidades: number + mensualidad: number + /** apartado + firma + diferible — debe cuadrar con el precio saldado */ + totalPagado: number +} + +/** Error de validación de entrada; null si es válida */ +export function validarEsquemaInput(input: EsquemaFrasaInput): string | null { + if (!Number.isFinite(input.precio) || input.precio <= 0) return 'El precio debe ser mayor a cero' + if (!Number.isFinite(input.apartado) || input.apartado < 0) return 'El apartado no puede ser negativo' + if (input.apartado > input.precio) return 'El apartado no puede superar el precio de la propiedad' + if (!Number.isInteger(input.numMensualidades)) return 'Las mensualidades deben ser un número entero' + if (input.numMensualidades < MENSUALIDADES_MIN) return `Mínimo ${MENSUALIDADES_MIN} mensualidad` + if (input.numMensualidades > MENSUALIDADES_MAX) return `Máximo ${MENSUALIDADES_MAX} mensualidades` + return null +} + +export function calcularEsquemaFrasa(input: EsquemaFrasaInput): EsquemaFrasaResultado { + const error = validarEsquemaInput(input) + if (error) throw new Error(error) + + const { precio, apartado, numMensualidades, escrituracionDiferida } = input + const engancheFirmaTotal = precio * PCT_ENGANCHE_FIRMA + const saldoFirma = Math.max(0, engancheFirmaTotal - apartado) + const excedenteApartado = Math.max(0, apartado - engancheFirmaTotal) + const enganche = precio * PCT_ENGANCHE - excedenteApartado + const escrituracion = precio * PCT_ESCRITURACION + const pagoUnicoFirma = saldoFirma + (escrituracionDiferida ? 0 : escrituracion) + const baseDiferible = enganche + (escrituracionDiferida ? escrituracion : 0) + const mensualidad = baseDiferible / numMensualidades + const totalPagado = apartado + pagoUnicoFirma + baseDiferible + + return { + precio, + apartado, + engancheFirmaTotal, + saldoFirma, + excedenteApartado, + enganche, + escrituracion, + escrituracionDiferida, + pagoUnicoFirma, + baseDiferible, + numMensualidades, + mensualidad, + totalPagado, + } +} diff --git a/apps/cotizador/src/components/ComparadorPropuestas.tsx b/apps/cotizador/src/components/ComparadorPropuestas.tsx deleted file mode 100644 index 4117bd9..0000000 --- a/apps/cotizador/src/components/ComparadorPropuestas.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { useState } from 'react' -import type { PropuestaResultado } from '../lib/calculations' -import { indiceMenorTotal, totalPagado } from '../lib/calculations' -import { formatoMoneda, formatoPorcentaje } from '../lib/formatters' - -interface Props { - precioLista: number - apartado: number - propuestas: PropuestaResultado[] - titulos: string[] -} - -interface Fila { - etiqueta: string - obtener: (p: PropuestaResultado, i: number) => string - destacada?: boolean -} - -/** Sección colapsable con la tabla comparativa de las 4 propuestas */ -export function ComparadorPropuestas({ precioLista, apartado, propuestas, titulos }: Props) { - const [abierto, setAbierto] = useState(false) - - const totales = propuestas.map((p) => totalPagado(p, apartado)) - // null (plazo desconocido) nunca gana el menor total - const ganador = indiceMenorTotal(totales.map((t) => t ?? Number.POSITIVE_INFINITY)) - - const filas: Fila[] = [ - { etiqueta: 'Precio de lista', obtener: () => formatoMoneda(precioLista) }, - { - etiqueta: 'Descuento', - obtener: (p) => `${formatoMoneda(p.montoDescuento)} (${formatoPorcentaje(p.descuento)})`, - }, - { etiqueta: 'Precio final c/desc.', obtener: (p) => formatoMoneda(p.precioFinal) }, - { etiqueta: 'Enganche', obtener: (p) => formatoMoneda(p.enganche) }, - { - etiqueta: 'Mensualidad', - obtener: (p) => - p.notaMensualidades - ? `— (${p.notaMensualidades})` - : p.mensualidad === null - ? 'N/A' - : `${p.numMensualidades} × ${formatoMoneda(p.mensualidad)}`, - }, - { - etiqueta: 'Total mensualidades', - obtener: (p) => - p.notaMensualidades - ? '—' - : p.mensualidadesTotal === null - ? 'N/A' - : formatoMoneda(p.mensualidadesTotal), - }, - { etiqueta: 'Pago final', obtener: (p) => formatoMoneda(p.pagoFinal) }, - { - etiqueta: 'Total pagado', - obtener: (_p, i) => (totales[i] === null ? '—' : formatoMoneda(totales[i])), - destacada: true, - }, - { - etiqueta: 'Diferencia vs precio de lista', - obtener: (_p, i) => (totales[i] === null ? '—' : formatoMoneda(totales[i] - precioLista)), - }, - ] - - /** Borde acento que enmarca la columna con el menor total pagado */ - const bordeColumna = (i: number, posicion: 'inicio' | 'media' | 'fin'): string => { - if (i !== ganador) return '' - const laterales = 'border-x-2 border-x-accent' - if (posicion === 'inicio') return `${laterales} border-t-2 border-t-accent` - if (posicion === 'fin') return `${laterales} border-b-2 border-b-accent` - return laterales - } - - return ( -
-
-

- Comparar propuestas -

- -
- - {abierto && ( -
-
- - - - - {propuestas.map((p, i) => ( - - ))} - - - - {filas.map((fila, indiceFila) => ( - - - {propuestas.map((p, i) => ( - - ))} - - ))} - -
- Concepto - - - {titulos[i] ?? p.tipo} - - {i === ganador && ( - - Menor total - - )} -
- {fila.etiqueta} - - {fila.obtener(p, i)} -
-
-

- El enganche ya incluye la deducción del apartado. El total pagado suma apartado + - enganche + mensualidades + pago final. -

-
- )} -
- ) -} diff --git a/apps/cotizador/src/components/ComparadorUnidades.tsx b/apps/cotizador/src/components/ComparadorUnidades.tsx deleted file mode 100644 index ef170ae..0000000 --- a/apps/cotizador/src/components/ComparadorUnidades.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import { useMemo, useState } from 'react' -import type { Unidad, TipoPropuesta } from '../types' -import { compararUnidades, type ColumnaComparador } from '../lib/comparadorUnidades' -import { formatoMoneda } from '../lib/formatters' -import { SIN_REGLAS, desarrolloEfectivo, idsCotizables, type ReglasDesarrollo } from '../lib/reglas' - -interface Props { - unidades: Unidad[] - /** Tipo de propuesta activa en el cotizador (se aplica a ambas unidades) */ - tipo: TipoPropuesta - apartado: number - acabados: boolean - /** Reglas comerciales por desarrollo (vacío => constantes) */ - reglas?: ReglasDesarrollo -} - -const formatoM2 = (v: number | null): string => (v === null ? '—' : `${v.toLocaleString('es-MX')} m²`) - -interface Fila { - etiqueta: string - obtener: (c: ColumnaComparador) => string - destacada?: boolean -} - -/** - * Comparador de 2 unidades (del mismo o distinto desarrollo), lado a lado, - * con la propuesta activa calculada por el motor con los parámetros de cada - * desarrollo (las reglas del admin con respaldo en las constantes). Solo ofrece - * unidades DISPONIBLES de desarrollos activos en el cotizador: no se compara - * (ni se cotiza) inventario apartado, vendido, bloqueado o desactivado. - */ -export function ComparadorUnidades({ unidades, tipo, apartado, acabados, reglas = SIN_REGLAS }: Props) { - const [abierto, setAbierto] = useState(false) - const [codigoA, setCodigoA] = useState('') - const [codigoB, setCodigoB] = useState('') - - const desarrollos = useMemo(() => idsCotizables(reglas), [reglas]) - - const disponibles = useMemo( - () => - unidades.filter( - (u) => - (u.status ?? '').trim().toUpperCase() === 'DISPONIBLE' && - desarrollos.includes(u.desarrollo ?? 'DIAGONAL'), - ), - [unidades, desarrollos], - ) - - const porCodigo = useMemo(() => { - const m = new Map() - for (const u of disponibles) m.set(`${u.desarrollo ?? 'DIAGONAL'}|${u.codigo}`, u) - return m - }, [disponibles]) - - const unidadA = porCodigo.get(codigoA) ?? null - const unidadB = porCodigo.get(codigoB) ?? null - - const comparacion = useMemo( - () => - unidadA && unidadB - ? compararUnidades(unidadA, unidadB, tipo, apartado, acabados, reglas) - : null, - [unidadA, unidadB, tipo, apartado, acabados, reglas], - ) - - const filas: Fila[] = [ - { etiqueta: 'Desarrollo', obtener: (c) => c.desarrolloNombre }, - { etiqueta: 'Modelo', obtener: (c) => c.unidad.modelo }, - { etiqueta: 'Estatus', obtener: (c) => c.unidad.status }, - { etiqueta: 'Sup. construcción', obtener: (c) => formatoM2(c.supConstruccion) }, - { etiqueta: 'Precio de lista', obtener: (c) => formatoMoneda(c.precioLista) }, - { etiqueta: 'Precio final c/desc.', obtener: (c) => formatoMoneda(c.propuesta.precioFinal) }, - { etiqueta: 'Enganche', obtener: (c) => formatoMoneda(c.propuesta.enganche) }, - { - etiqueta: 'Mensualidad', - obtener: (c) => - c.propuesta.notaMensualidades - ? `— (${c.propuesta.notaMensualidades})` - : c.propuesta.mensualidad === null - ? 'N/A' - : `${c.propuesta.numMensualidades} × ${formatoMoneda(c.propuesta.mensualidad)}`, - }, - { etiqueta: 'Pago final', obtener: (c) => formatoMoneda(c.propuesta.pagoFinal) }, - { - etiqueta: 'Total pagado', - obtener: (c) => (c.total === null ? '—' : formatoMoneda(c.total)), - destacada: true, - }, - ] - - const selector = (id: string, valor: string, onChange: (v: string) => void, etiqueta: string) => ( -
- - -
- ) - - return ( -
-
-

- Comparar unidades -

- -
- - {abierto && ( -
-
- {selector('comparador-a', codigoA, setCodigoA, 'Unidad A')} - {selector('comparador-b', codigoB, setCodigoB, 'Unidad B')} -
- - {!comparacion ? ( -

- Selecciona dos unidades para ver la comparación lado a lado. -

- ) : ( -
- - - - - {[comparacion.a, comparacion.b].map((c) => ( - - ))} - - - - {filas.map((fila) => ( - - - {[comparacion.a, comparacion.b].map((c) => ( - - ))} - - ))} - -
- Concepto - - {c.unidad.codigo} -
- {fila.etiqueta} - - {fila.obtener(c)} -
-

- Solo se ofrecen unidades DISPONIBLES. Cada unidad se calcula con los parámetros de - su desarrollo y el mismo apartado. El enganche ya incluye la deducción del apartado. -

-
- )} -
- )} -
- ) -} diff --git a/apps/cotizador/src/components/EsquemaPanel.tsx b/apps/cotizador/src/components/EsquemaPanel.tsx new file mode 100644 index 0000000..eb45dc4 --- /dev/null +++ b/apps/cotizador/src/components/EsquemaPanel.tsx @@ -0,0 +1,164 @@ +import { + MENSUALIDADES_MAX, + MENSUALIDADES_MIN, + PCT_ENGANCHE, + PCT_ENGANCHE_FIRMA, + PCT_ESCRITURACION, + type EsquemaFrasaResultado, +} from '../lib/esquema-frasa' +import { formatoMoneda, formatoPorcentaje } from '../lib/formatters' + +interface Props { + /** Esquema FRASA 2026 calculado (siempre presente: solo se renderiza con unidad) */ + esquema: EsquemaFrasaResultado + numMensualidades: number + onNumMensualidades: (n: number) => void + escrituracionDiferida: boolean + onEscrituracionDiferida: (v: boolean) => void +} + +const pasoBase = + 'flex-1 rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#0F172A]' +const pasoTitulo = 'text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]' +const pasoMonto = 'mt-1 text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]' + +/** + * Panel único del esquema FRASA 2026 (reemplaza a las 4 propuestas): + * línea de tiempo Hoy → A la firma → Mensualidades, el detalle de conceptos + * (enganche de firma 2.5%, enganche 7.5%, escrituración 5.5%) y los controles + * del diferimiento: número de mensualidades (1..36) y si la escrituración se + * difiere con el enganche o se paga a la firma. + */ +export function EsquemaPanel({ + esquema, + numMensualidades, + onNumMensualidades, + escrituracionDiferida, + onEscrituracionDiferida, +}: Props) { + return ( +
+ {/* Línea de tiempo del esquema */} +
+
+

Hoy

+

{formatoMoneda(esquema.apartado)}

+

Apartado (aparta la unidad)

+
+
+

A la firma

+

{formatoMoneda(esquema.pagoUnicoFirma)}

+

+ Saldo de enganche de firma + {!escrituracionDiferida && ' + escrituración'} +

+
+
+

Después

+

+ {esquema.numMensualidades} × {formatoMoneda(esquema.mensualidad)} +

+

+ Total diferible {formatoMoneda(esquema.baseDiferible)} +

+
+
+ + {/* Detalle de conceptos */} +
+

+ Esquema FRASA 2026 — conceptos +

+
+
+
+ Enganche de firma ({formatoPorcentaje(PCT_ENGANCHE_FIRMA)}) +
+
+ {formatoMoneda(esquema.engancheFirmaTotal)} + + − apartado acreditado → saldo a la firma {formatoMoneda(esquema.saldoFirma)} + +
+
+ {esquema.excedenteApartado > 0 && ( +
+
+ Apartado que excede el 2.5% +
+
+ {formatoMoneda(esquema.excedenteApartado)} + + acreditado al enganche + +
+
+ )} +
+
+ Enganche ({formatoPorcentaje(PCT_ENGANCHE)}) diferible +
+
+ {formatoMoneda(esquema.enganche)} +
+
+
+
+ Gastos de escrituración ({formatoPorcentaje(PCT_ESCRITURACION)}) +
+
+ {formatoMoneda(esquema.escrituracion)} + + {escrituracionDiferida ? 'diferidos con el enganche' : 'pagados a la firma'} + +
+
+
+
+ Total del esquema (15.5% del precio) +
+
+ {formatoMoneda(esquema.totalPagado)} +
+
+
+
+ + {/* Controles del diferimiento */} +
+
+ + +

+ De {MENSUALIDADES_MIN} a {MENSUALIDADES_MAX}; cada una de {formatoMoneda(esquema.mensualidad)} +

+
+ +
+
+ ) +} diff --git a/apps/cotizador/src/components/MisCotizaciones.tsx b/apps/cotizador/src/components/MisCotizaciones.tsx index c59d202..1cc6b4b 100644 --- a/apps/cotizador/src/components/MisCotizaciones.tsx +++ b/apps/cotizador/src/components/MisCotizaciones.tsx @@ -11,6 +11,9 @@ interface Props { } const TITULO_PROPUESTA: Record = { + // Esquema único FRASA 2026 (cotizaciones nuevas); las viejas muestran su + // tipoPropuesta legacy (BANCARIO, INFONAVIT, CONTADO, SEMI_CONTADO) + FRASA_2026: 'Esquema FRASA', BANCARIO: 'Crédito Bancario', INFONAVIT: 'Crédito Infonavit-Cofinavit', COFINAVIT: 'Crédito Infonavit-Cofinavit', diff --git a/apps/cotizador/src/components/ProposalCard.tsx b/apps/cotizador/src/components/ProposalCard.tsx deleted file mode 100644 index cf99127..0000000 --- a/apps/cotizador/src/components/ProposalCard.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import type { PropuestaResultado } from '../lib/calculations' -import { formatoMoneda } from '../lib/formatters' - -interface Props { - numero: number - titulo: string - propuesta: PropuestaResultado -} - -function Renglon({ etiqueta, valor, nota }: { etiqueta: string; valor: string; nota?: string }) { - return ( -
- - {etiqueta} - {nota && ( - - {nota} - - )} - - {valor} -
- ) -} - -/** Tarjeta de una propuesta comercial (el descuento ya no es editable: usa el del Excel) */ -export function ProposalCard({ numero, titulo, propuesta }: Props) { - return ( -
-
-

- Propuesta comercial #{numero} -

-

{titulo}

-
- -
- {propuesta.descuento > 0 && ( - - )} - - - {propuesta.notaMensualidades ? ( - - ) : propuesta.mensualidad !== null && propuesta.mensualidadesTotal !== null ? ( - - ) : ( - - )} - -
-
- ) -} diff --git a/apps/cotizador/src/hooks/useCotizacion.test.tsx b/apps/cotizador/src/hooks/useCotizacion.test.tsx index 08bfae3..1f2bc27 100644 --- a/apps/cotizador/src/hooks/useCotizacion.test.tsx +++ b/apps/cotizador/src/hooks/useCotizacion.test.tsx @@ -1,31 +1,22 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it } from 'vitest' import { act, cleanup, renderHook } from '@testing-library/react' -import { useCotizacion } from './useCotizacion' -import { reglasDesdeApi, SIN_REGLAS, type ReglasDesarrollo } from '../lib/reglas' -import { DESARROLLOS } from '../data/desarrollos' +import { APARTADO_ESQUEMA_DEFAULT, useCotizacion } from './useCotizacion' +import { SIN_REGLAS } from '../lib/reglas' +import { MENSUALIDADES_DEFAULT, calcularEsquemaFrasa } from '../lib/esquema-frasa' import { INVENTARIO_SEED } from '../data/unidades' /** - * Reglas comerciales configurables en el cotizador: las propuestas desactivadas - * por el admin dejan de ofrecerse y, si la elegida se desactiva, se avisa y se - * cae a la primera vigente. + * Estado central con el esquema único FRASA 2026: apartado libre (default + * $15,000), mensualidades del diferimiento (default 14) y escrituración a la + * firma o diferida. Ya no hay propuestas: el resultado sale de + * calcularEsquemaFrasa (mismo motor que el API). */ const unidadDiagonal = INVENTARIO_SEED.find((u) => u.desarrollo === 'DIAGONAL')! -function reglasDiagonales(activa: (tipo: string) => boolean): ReglasDesarrollo { - return reglasDesdeApi({ - DIAGONAL: { - propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) => ({ ...p, activa: activa(p.tipo) })), - }, - }) -} - -function montar(reglas: ReglasDesarrollo = SIN_REGLAS) { - const hook = renderHook(({ reglas }) => useCotizacion(reglas), { - initialProps: { reglas: reglas as ReglasDesarrollo }, - }) +function montar() { + const hook = renderHook(() => useCotizacion(SIN_REGLAS)) act(() => { hook.result.current.setUnidad(unidadDiagonal) }) @@ -34,75 +25,128 @@ function montar(reglas: ReglasDesarrollo = SIN_REGLAS) { afterEach(cleanup) -describe('useCotizacion y las reglas del admin', () => { - it('sin reglas ofrece las 4 propuestas de siempre', () => { +describe('useCotizacion — esquema único FRASA 2026', () => { + it('arranca con apartado $15,000, 14 mensualidades y escrituración a la firma', () => { const { result } = montar() - expect(result.current.propuestas.map((p) => p.tipo)).toEqual([ - 'CREDITO_BANCARIO', - 'CREDITO_INFONAVIT_COFINAVIT', - 'CONTADO', - 'SEMI_CONTADO', - ]) - expect(result.current.avisoPropuestaDesactivada).toBeNull() + expect(result.current.apartado).toBe(APARTADO_ESQUEMA_DEFAULT) + expect(result.current.apartadoEfectivo).toBe(15000) + expect(result.current.numMensualidades).toBe(MENSUALIDADES_DEFAULT) + expect(result.current.escrituracionDiferida).toBe(false) }) - it('una propuesta desactivada no se ofrece ni se puede elegir', () => { - const { result } = montar(reglasDiagonales((tipo) => tipo !== 'CONTADO')) + it('el resultado cuadra con calcularEsquemaFrasa sobre el precio de lista', () => { + const { result } = montar() + const { unidad, acabados } = result.current - expect(result.current.propuestas.map((p) => p.tipo)).toEqual([ - 'CREDITO_BANCARIO', - 'CREDITO_INFONAVIT_COFINAVIT', - 'SEMI_CONTADO', - ]) - }) - - it('si el admin desactiva la propuesta elegida, avisa y cae a la primera vigente', () => { - const { result, rerender } = montar() - - // El vendedor elige Contado (índice 2) - act(() => { - result.current.setPropuestaIndex(2) + const esperado = calcularEsquemaFrasa({ + precio: result.current.precioLista, + apartado: 15000, + numMensualidades: 14, + escrituracionDiferida: false, }) - expect(result.current.propuestas[2]?.tipo).toBe('CONTADO') - - // El admin la desactiva (llegan reglas nuevas) - rerender({ reglas: reglasDiagonales((tipo) => tipo !== 'CONTADO') }) - - expect(result.current.propuestas.map((p) => p.tipo)).not.toContain('CONTADO') - expect(result.current.propuestaIndex).toBe(0) - expect(result.current.avisoPropuestaDesactivada).toContain('Contado') - expect(result.current.avisoPropuestaDesactivada).toContain('Crédito Bancario') + expect(result.current.resultado).toEqual(esperado) + // El esquema siempre liquida el 15.5% del precio (2.5 + 7.5 + 5.5) + expect(result.current.resultado?.totalPagado).toBeCloseTo(result.current.precioLista * 0.155, 2) + expect(unidad).toBe(unidadDiagonal) + expect(acabados).toBe(false) }) - it('los descuentos por defecto salen de las reglas configuradas', () => { - const { result } = montar( - reglasDesdeApi({ - DIAGONAL: { - propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) => - p.tipo === 'CONTADO' ? { ...p, descuentoDefault: 0.06 } : p, - ), - }, - }), - ) - - expect(result.current.descuentos.contado).toBe(0.06) - expect(result.current.descuentos.semiContado).toBe(0.01) + it('sin unidad no hay resultado (panel vacío)', () => { + const { result } = montar() + act(() => { + result.current.setUnidad(null) + }) + expect(result.current.resultado).toBeNull() + expect(result.current.precioLista).toBe(0) }) - it('las reglas cambian los números de las propuestas', () => { - const { result } = montar( - reglasDesdeApi({ - DIAGONAL: { - propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) => - p.tipo === 'CREDITO_BANCARIO' ? { ...p, pctEnganche: 0.05 } : p, - ), - }, - }), - ) + it('el apartado libre alimenta el saldo a firma; el excedente acredita al enganche', () => { + const { result } = montar() + const excedente = Math.round(result.current.precioLista * 0.025) + 5000 - const bancario = result.current.propuestas.find((p) => p.tipo === 'CREDITO_BANCARIO') - const precioLista = result.current.precioLista - expect(bancario?.enganche).toBeCloseTo(0.05 * precioLista - result.current.apartadoEfectivo, 2) + act(() => { + result.current.setApartado(excedente) + }) + const r = result.current.resultado! + expect(r.excedenteApartado).toBeGreaterThan(0) + expect(r.saldoFirma).toBe(0) + // El total no cambia: el excedente se mueve de concepto, no se cobra dos veces + expect(r.totalPagado).toBeCloseTo(result.current.precioLista * 0.155, 2) + }) + + it('las mensualidades elegidas recalculan la mensualidad sobre el diferible', () => { + const { result } = montar() + + act(() => { + result.current.setNumMensualidades(12) + }) + const r = result.current.resultado! + expect(r.numMensualidades).toBe(12) + expect(r.mensualidad).toBeCloseTo(r.baseDiferible / 12, 6) + }) + + it('escrituración diferida: sube el diferible y baja el pago a la firma', () => { + const { result } = montar() + + act(() => { + result.current.setEscrituracionDiferida(true) + }) + const r = result.current.resultado! + expect(r.escrituracionDiferida).toBe(true) + expect(r.baseDiferible).toBeCloseTo(r.enganche + r.escrituracion, 6) + expect(r.pagoUnicoFirma).toBeCloseTo(r.saldoFirma, 6) + }) + + it('restaurar carga una cotización guardada del esquema (N y escrituración)', () => { + const { result } = montar() + + act(() => { + result.current.restaurar({ + desarrollo: 'DIAGONAL', + unidad: unidadDiagonal, + acabados: true, + apartado: 20000, + numMensualidades: 24, + escrituracionDiferida: true, + }) + }) + expect(result.current.acabados).toBe(true) + expect(result.current.apartadoEfectivo).toBe(20000) + expect(result.current.numMensualidades).toBe(24) + expect(result.current.escrituracionDiferida).toBe(true) + expect(result.current.resultado?.numMensualidades).toBe(24) + }) + + it('restaurar una cotización legacy (sin N ni escrituración) cae a los defaults', () => { + const { result } = montar() + + act(() => { + result.current.restaurar({ + desarrollo: 'DIAGONAL', + unidad: unidadDiagonal, + acabados: false, + apartado: 20000, + }) + }) + expect(result.current.numMensualidades).toBe(MENSUALIDADES_DEFAULT) + expect(result.current.escrituracionDiferida).toBe(false) + }) + + it('nueva reinicia apartado, mensualidades y escrituración, y limpia la unidad', () => { + const { result } = montar() + + act(() => { + result.current.setApartado(40000) + result.current.setNumMensualidades(6) + result.current.setEscrituracionDiferida(true) + }) + act(() => { + result.current.nueva() + }) + expect(result.current.unidad).toBeNull() + expect(result.current.apartado).toBe(APARTADO_ESQUEMA_DEFAULT) + expect(result.current.numMensualidades).toBe(MENSUALIDADES_DEFAULT) + expect(result.current.escrituracionDiferida).toBe(false) }) }) diff --git a/apps/cotizador/src/hooks/useCotizacion.ts b/apps/cotizador/src/hooks/useCotizacion.ts index 15f1f64..1896629 100644 --- a/apps/cotizador/src/hooks/useCotizacion.ts +++ b/apps/cotizador/src/hooks/useCotizacion.ts @@ -1,25 +1,20 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import type { Unidad, DescuentosPropuestas, ParametrosDesarrollo } from '../types' -import { - APARTADO_DEFAULT, - calcularPropuestas, - obtenerPrecioLista, - resolverNumMensualidades, - validarEnganchePct, - type PropuestaResultado, -} from '../lib/calculations' +import { useMemo, useState } from 'react' +import type { Unidad, ParametrosDesarrollo } from '../types' +import { obtenerPrecioLista, resolverNumMensualidades } from '../lib/calculations' import { DESARROLLO_DEFAULT } from '../data/desarrollos' import { - SIN_REGLAS, - descuentosDefaultEfectivo, - desarrolloEfectivo, - propuestasVigentes, - type ReglasDesarrollo, -} from '../lib/reglas' + MENSUALIDADES_DEFAULT, + calcularEsquemaFrasa, + type EsquemaFrasaResultado, +} from '../lib/esquema-frasa' +import { SIN_REGLAS, desarrolloEfectivo, type ReglasDesarrollo } from '../lib/reglas' + +/** Apartado con el que arranca (y se reinicia) el cotizador: cantidad libre */ +export const APARTADO_ESQUEMA_DEFAULT = 15000 interface UseCotizacionResult { desarrollo: string - /** Cambia de desarrollo: limpia la unidad elegida y reinicia los descuentos */ + /** Cambia de desarrollo: limpia la unidad elegida */ setDesarrollo: (id: string) => void params: ParametrosDesarrollo unidad: Unidad | null @@ -30,25 +25,17 @@ interface UseCotizacionResult { setApartado: (v: number) => void /** Apartado aplicado a los cálculos (acotado a >= 0; el vendedor puede apartar con cualquier monto, incluso $0) */ apartadoEfectivo: number - /** Enganche capturado en PORCENTAJE (10 = 10%); null = % del Excel de cada propuesta */ - enganchePct: number | null - setEnganchePct: (v: number | null) => void - /** Mensaje de error del enganche capturado; null si es válido */ - errorEnganche: string | null - descuentos: DescuentosPropuestas - setDescuento: (clave: keyof DescuentosPropuestas, valor: number) => void - /** Índice de la propuesta elegida (para guardar/compartir) */ - propuestaIndex: number - setPropuestaIndex: (i: number) => void + /** Número de mensualidades del diferimiento (elección del vendedor, 1..36) */ + numMensualidades: number + setNumMensualidades: (n: number) => void + /** true = la escrituración (5.5%) se difiere con el enganche; false = se paga a la firma */ + escrituracionDiferida: boolean + setEscrituracionDiferida: (v: boolean) => void precioLista: number - /** Número de mensualidades elegido por el vendedor; null = usa el calculado */ - numMensualidades: number | null - setNumMensualidades: (n: number | null) => void - /** Número efectivo de mensualidades (la elegida o la calculada) */ + /** Esquema FRASA 2026 calculado; null mientras no hay unidad elegida */ + resultado: EsquemaFrasaResultado | null + /** Meses restantes para la entrega de la unidad (ficha técnica); null si no se pueden resolver */ numMeses: number | null - propuestas: PropuestaResultado[] - /** Aviso cuando el admin desactivó la propuesta que estaba elegida; null si no aplica */ - avisoPropuestaDesactivada: string | null /** Restaura el estado completo desde una cotización guardada (historial) */ restaurar: (estado: EstadoRestaurar) => void /** Reinicia la cotización (botón "Nueva") */ @@ -61,60 +48,34 @@ export interface EstadoRestaurar { unidad: Unidad acabados: boolean apartado: number - /** Descuento (fracción) de la propuesta elegida; se aplica sobre su clave */ - descuento?: number - propuestaIndex?: number - /** Mensualidades elegidas al guardar la cotización (null = usar el plazo calculado) */ - numMensualidades?: number | null + /** Mensualidades del diferimiento guardadas (default 14) */ + numMensualidades?: number + /** Escrituración diferida guardada (default false) */ + escrituracionDiferida?: boolean } /** - * Estado central de la cotización: desarrollo, unidad, acabados, apartado y - * descuentos. Las reglas comerciales llegan del API (con respaldo en las - * constantes): si el admin desactiva una propuesta, deja de ofrecerse y, si era - * la elegida, se avisa y se cae a la primera vigente. + * Estado central de la cotización con el esquema único FRASA 2026: + * desarrollo, unidad, acabados, apartado libre, mensualidades del + * diferimiento (1..36, default 14) y si la escrituración se difiere o se paga + * a la firma. El resultado sale de calcularEsquemaFrasa (módulo puro, mismo + * motor que el API). Ya no hay propuestas: descuentos, enganche en % e índice + * de propuesta desaparecieron con las 4 propuestas. */ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizacionResult { const [desarrollo, setDesarrolloState] = useState(DESARROLLO_DEFAULT) const [unidad, setUnidad] = useState(null) const [acabados, setAcabados] = useState(false) - const [apartado, setApartado] = useState(APARTADO_DEFAULT) - const [enganchePct, setEnganchePct] = useState(null) - const [descuentos, setDescuentos] = useState( - descuentosDefaultEfectivo(reglas, DESARROLLO_DEFAULT), - ) - const [propuestaIndex, setPropuestaIndex] = useState(0) - const [numMensualidades, setNumMensualidades] = useState(null) - const [avisoPropuestaDesactivada, setAvisoPropuestaDesactivada] = useState(null) + const [apartado, setApartado] = useState(APARTADO_ESQUEMA_DEFAULT) + const [numMensualidades, setNumMensualidades] = useState(MENSUALIDADES_DEFAULT) + const [escrituracionDiferida, setEscrituracionDiferida] = useState(false) const params = desarrolloEfectivo(reglas, desarrollo) - // Los descuentos por defecto se re-aplican cuando cambian las reglas del - // desarrollo (p. ej. al llegar las del API), sin pisar lo que capturó el - // vendedor mientras las reglas no cambian. - const descuentosAplicados = useRef('') - const defaults = useMemo( - () => descuentosDefaultEfectivo(reglas, desarrollo), - [reglas, desarrollo], - ) - useEffect(() => { - const clave = JSON.stringify(defaults) - if (descuentosAplicados.current === clave) return - descuentosAplicados.current = clave - setDescuentos(defaults) - }, [defaults]) - const setDesarrollo = (id: string) => { setDesarrolloState(id) setUnidad(null) setAcabados(false) - setDescuentos(descuentosDefaultEfectivo(reglas, id)) - setPropuestaIndex(0) - setNumMensualidades(null) - } - - const setDescuento = (clave: keyof DescuentosPropuestas, valor: number) => { - setDescuentos((prev) => ({ ...prev, [clave]: valor })) } const restaurar = (estado: EstadoRestaurar) => { @@ -122,38 +83,23 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac setUnidad(estado.unidad) setAcabados(estado.acabados) setApartado(estado.apartado) - const base = descuentosDefaultEfectivo(reglas, estado.desarrollo) - const paramsRestaurados = desarrolloEfectivo(reglas, estado.desarrollo) - const vigentes = propuestasVigentes(paramsRestaurados) - if (estado.descuento !== undefined) { - const idx = estado.propuestaIndex ?? 0 - const clave = vigentes[idx]?.claveDescuento - if (clave) base[clave] = estado.descuento - } - setDescuentos(base) - setPropuestaIndex(estado.propuestaIndex ?? 0) - setNumMensualidades(estado.numMensualidades ?? null) + setNumMensualidades(estado.numMensualidades ?? MENSUALIDADES_DEFAULT) + setEscrituracionDiferida(estado.escrituracionDiferida ?? false) } const nueva = () => { setUnidad(null) setAcabados(false) - setApartado(APARTADO_DEFAULT) - setEnganchePct(null) - setDescuentos(descuentosDefaultEfectivo(reglas, desarrollo)) - setPropuestaIndex(0) - setNumMensualidades(null) - setAvisoPropuestaDesactivada(null) + setApartado(APARTADO_ESQUEMA_DEFAULT) + setNumMensualidades(MENSUALIDADES_DEFAULT) + setEscrituracionDiferida(false) } - const numMesesCalculado = useMemo( + const numMeses = useMemo( () => (unidad ? resolverNumMensualidades(unidad, params) : null), [unidad, params], ) - /** Número efectivo de mensualidades: la elegida por el vendedor, o la calculada */ - const numMeses = numMensualidades ?? numMesesCalculado - const precioLista = useMemo( () => (unidad ? obtenerPrecioLista(unidad, acabados) : 0), [unidad, acabados], @@ -163,46 +109,15 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac // incluso $0; solo se acota a >= 0 como guarda contra capturas raras const apartadoEfectivo = Math.max(0, apartado) - // Enganche personalizado: null = default del Excel; inválido no entra al cálculo - const errorEnganche = validarEnganchePct(enganchePct) - const engancheEfectivo = errorEnganche === null && enganchePct !== null ? enganchePct / 100 : null - - const propuestas = useMemo(() => { - if (!unidad) return [] - return calcularPropuestas({ - unidad, - acabados, + const resultado = useMemo(() => { + if (!unidad || precioLista <= 0) return null + return calcularEsquemaFrasa({ + precio: precioLista, apartado: apartadoEfectivo, - descuentos, - desarrollo, - reglas: params, - enganchePct: engancheEfectivo, - numMeses: numMeses ?? undefined, + numMensualidades, + escrituracionDiferida, }) - }, [unidad, acabados, apartadoEfectivo, descuentos, desarrollo, params, engancheEfectivo, numMeses]) - - // Propuesta elegida: si el índice se sale de la lista vigente (p. ej. el admin - // desactivó una propuesta) se cae a la primera y, si la que estaba elegida ya - // no existe, se avisa con claridad. - const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))] - const tipoPrevio = useRef<{ tipo: string; titulo: string } | null>(null) - - useEffect(() => { - const previo = tipoPrevio.current - const vigentes = propuestasVigentes(params) - if (previo !== null && !vigentes.some((p) => p.tipo === previo.tipo)) { - const sustituto = propuestas[0]?.titulo ?? 'otra propuesta' - setAvisoPropuestaDesactivada( - `El administrador desactivó la propuesta ${previo.titulo} para ${params.nombre}. Se seleccionó ${sustituto}; elige otra si lo prefieres.`, - ) - setPropuestaIndex(0) - } else if (propuestaIndex >= propuestas.length) { - setPropuestaIndex(0) - } - tipoPrevio.current = propuestaActiva - ? { tipo: propuestaActiva.tipo, titulo: propuestaActiva.titulo } - : null - }, [params, propuestas, propuestaActiva, propuestaIndex]) + }, [unidad, precioLista, apartadoEfectivo, numMensualidades, escrituracionDiferida]) return { desarrollo, @@ -215,19 +130,13 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac apartado, setApartado, apartadoEfectivo, - enganchePct, - setEnganchePct, - errorEnganche, - descuentos, - setDescuento, - propuestaIndex, - setPropuestaIndex, - precioLista, numMensualidades, setNumMensualidades, + escrituracionDiferida, + setEscrituracionDiferida, + precioLista, + resultado, numMeses, - propuestas, - avisoPropuestaDesactivada, restaurar, nueva, } diff --git a/apps/cotizador/src/lib/compartir.ts b/apps/cotizador/src/lib/compartir.ts index b0914d4..2fd17c9 100644 --- a/apps/cotizador/src/lib/compartir.ts +++ b/apps/cotizador/src/lib/compartir.ts @@ -1,10 +1,11 @@ -import type { PropuestaResultado } from './calculations' +import type { EsquemaFrasaResultado } from './esquema-frasa' import { formatoMoneda, formatoFechaLarga } from './formatters' export interface DatosCompartir { unidadCodigo: string desarrolloNombre: string - propuesta: PropuestaResultado + /** Esquema único FRASA 2026 calculado */ + esquema: EsquemaFrasaResultado apartado: number vigencia: Date /** Folio y URL pública solo existen si la cotización ya se guardó en el API */ @@ -13,8 +14,9 @@ export interface DatosCompartir { } /** - * Texto resumen de la propuesta, listo para copiar/pegar en WhatsApp. - * Incluye unidad, desarrollo, propuesta elegida, montos y vigencia. + * Texto resumen del esquema FRASA 2026, listo para copiar/pegar en WhatsApp. + * Incluye unidad, desarrollo, línea de tiempo (hoy / firma / mensualidades) + * y vigencia. */ export function textoCompartir(d: DatosCompartir): string { const lineas: string[] = [] @@ -23,20 +25,14 @@ export function textoCompartir(d: DatosCompartir): string { ) lineas.push(`Desarrollo: ${d.desarrolloNombre}`) lineas.push(`Unidad: ${d.unidadCodigo}`) - lineas.push(`Propuesta: ${d.propuesta.titulo}`) - lineas.push(`Precio final c/desc: ${formatoMoneda(d.propuesta.precioFinal)}`) - lineas.push(`Apartado: ${formatoMoneda(d.apartado)}`) - lineas.push(`Enganche: ${formatoMoneda(d.propuesta.enganche)}`) - if (d.propuesta.notaMensualidades) { - lineas.push(`Mensualidades: — (${d.propuesta.notaMensualidades})`) - } else if (d.propuesta.mensualidad !== null) { - lineas.push( - `Mensualidades: ${d.propuesta.numMensualidades} × ${formatoMoneda(d.propuesta.mensualidad)}`, - ) - } else { - lineas.push('Mensualidades: N/A') - } - lineas.push(`Pago final: ${formatoMoneda(d.propuesta.pagoFinal)}`) + lineas.push(`Precio de lista: ${formatoMoneda(d.esquema.precio)}`) + lineas.push(`Apartado (hoy): ${formatoMoneda(d.apartado)}`) + lineas.push(`Pago a la firma: ${formatoMoneda(d.esquema.pagoUnicoFirma)}`) + lineas.push( + `Mensualidades: ${d.esquema.numMensualidades} × ${formatoMoneda(d.esquema.mensualidad)} (total diferible ${formatoMoneda(d.esquema.baseDiferible)})`, + ) + lineas.push(`Escrituración 5.5%: ${d.esquema.escrituracionDiferida ? 'diferida con el enganche' : `pagada a la firma (${formatoMoneda(d.esquema.escrituracion)})`}`) + lineas.push(`Total del esquema: ${formatoMoneda(d.esquema.totalPagado)}`) lineas.push(`Vigencia: hasta el ${formatoFechaLarga(d.vigencia)}`) if (d.urlPublica) lineas.push(`Consulta: ${d.urlPublica}`) return lineas.join('\n') diff --git a/apps/cotizador/src/lib/esquema-frasa.test.ts b/apps/cotizador/src/lib/esquema-frasa.test.ts new file mode 100644 index 0000000..a4d007c --- /dev/null +++ b/apps/cotizador/src/lib/esquema-frasa.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { + calcularEsquemaFrasa, + validarEsquemaInput, + PCT_ENGANCHE_FIRMA, + PCT_ENGANCHE, + PCT_ESCRITURACION, +} from './esquema-frasa' + +/** Caso dorado del cliente (28 sep 2026): casa de $1,000,000 */ +const CASO = { precio: 1000000, apartado: 15000, numMensualidades: 14 } + +describe('calcularEsquemaFrasa — caso dorado $1,000,000 / apartado $15,000', () => { + it('escrituración de contado: firma $65,000 y mensualidades de $75,000/14', () => { + const r = calcularEsquemaFrasa({ ...CASO, escrituracionDiferida: false }) + expect(r.engancheFirmaTotal).toBe(25000) // 2.5% + expect(r.saldoFirma).toBe(10000) // 25,000 − 15,000 apartado acreditado + expect(r.excedenteApartado).toBe(0) + expect(r.enganche).toBe(75000) // 7.5% + expect(r.escrituracion).toBe(55000) // 5.5% + expect(r.pagoUnicoFirma).toBe(65000) // 10,000 + 55,000 + expect(r.baseDiferible).toBe(75000) + expect(r.mensualidad).toBeCloseTo(75000 / 14, 2) // 5,357.14 + expect(r.totalPagado).toBeCloseTo(1000000 * (PCT_ENGANCHE_FIRMA + PCT_ENGANCHE + PCT_ESCRITURACION) + 0, 2) + }) + + it('escrituración diferida: firma $10,000 y mensualidades de $130,000/14', () => { + const r = calcularEsquemaFrasa({ ...CASO, escrituracionDiferida: true }) + expect(r.saldoFirma).toBe(10000) + expect(r.pagoUnicoFirma).toBe(10000) + expect(r.baseDiferible).toBe(130000) // 75,000 + 55,000 + expect(r.mensualidad).toBeCloseTo(130000 / 14, 2) // 9,285.71 + }) + + it('el total siempre cuadra: apartado + firma + diferible', () => { + for (const dif of [false, true]) { + const r = calcularEsquemaFrasa({ ...CASO, escrituracionDiferida: dif }) + expect(r.apartado + r.pagoUnicoFirma + r.baseDiferible).toBeCloseTo(r.totalPagado, 6) + // y equivale al 15.5% del precio (2.5 + 7.5 + 5.5) salvo excedentes + expect(r.totalPagado).toBeCloseTo(155000, 2) + } + }) +}) + +describe('calcularEsquemaFrasa — apartado vs enganche de firma', () => { + it('apartado exactamente el 2.5%: saldo a firma 0', () => { + const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 25000, numMensualidades: 14, escrituracionDiferida: false }) + expect(r.saldoFirma).toBe(0) + expect(r.excedenteApartado).toBe(0) + expect(r.enganche).toBe(75000) + }) + + it('apartado mayor al 2.5%: el excedente se acredita al enganche', () => { + const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 30000, numMensualidades: 14, escrituracionDiferida: false }) + expect(r.saldoFirma).toBe(0) + expect(r.excedenteApartado).toBe(5000) + expect(r.enganche).toBe(70000) // 75,000 − 5,000 + expect(r.totalPagado).toBeCloseTo(155000, 2) // no se cobra dos veces: 30k + 70k + 55k + }) + + it('apartado mínimo (sin mínimo fijo): $1 aparta igual', () => { + const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 1, numMensualidades: 14, escrituracionDiferida: false }) + expect(r.saldoFirma).toBe(24999) + }) +}) + +describe('calcularEsquemaFrasa — mensualidades', () => { + it('N=1: todo el diferible en una sola mensualidad', () => { + const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 15000, numMensualidades: 1, escrituracionDiferida: true }) + expect(r.mensualidad).toBe(130000) + }) + + it('validación: rechaza entradas inválidas', () => { + expect(validarEsquemaInput({ precio: 0, apartado: 0, numMensualidades: 14, escrituracionDiferida: false })).toMatch(/precio/i) + expect(validarEsquemaInput({ precio: 1000000, apartado: -5, numMensualidades: 14, escrituracionDiferida: false })).toMatch(/apartado/i) + expect(validarEsquemaInput({ precio: 1000000, apartado: 2000000, numMensualidades: 14, escrituracionDiferida: false })).toMatch(/superar/i) + expect(validarEsquemaInput({ precio: 1000000, apartado: 15000, numMensualidades: 0, escrituracionDiferida: false })).toMatch(/mensualidad/i) + expect(validarEsquemaInput({ precio: 1000000, apartado: 15000, numMensualidades: 14.5, escrituracionDiferida: false })).toMatch(/entero/i) + expect(validarEsquemaInput({ precio: 1000000, apartado: 15000, numMensualidades: 14, escrituracionDiferida: false })).toBeNull() + }) +}) diff --git a/apps/cotizador/src/lib/esquema-frasa.ts b/apps/cotizador/src/lib/esquema-frasa.ts new file mode 100644 index 0000000..9b2d7f3 --- /dev/null +++ b/apps/cotizador/src/lib/esquema-frasa.ts @@ -0,0 +1,100 @@ +/** + * Esquema de cotización FRASA 2026 (flujo único, aprobado por el cliente): + * + * 1. APARTADO: cantidad libre (sin mínimo), aparta la unidad. Hoy. + * 2. ENGANCHE DE FIRMA: 2.5% del precio. El apartado se acredita DENTRO de + * este concepto → lo que se paga a la firma es el saldo restante + * (si el apartado supera el 2.5%, el excedente pasa al enganche). + * 3. ENGANCHE: 7.5% del precio, diferible en N mensualidades (N a elección + * del vendedor). + * 4. GASTOS DE ESCRITURACIÓN: 5.5% del precio. El cliente los salda en un + * pago a la firma O los difiere junto con el enganche. + * + * Módulo puro (sin UI, sin API) para probarlo a fondo. Porcentajes globales + * para todos los desarrollos (confirmado por el cliente el 28 sep 2026). + */ + +export const PCT_ENGANCHE_FIRMA = 0.025 +export const PCT_ENGANCHE = 0.075 +export const PCT_ESCRITURACION = 0.055 + +export const MENSUALIDADES_DEFAULT = 14 +export const MENSUALIDADES_MIN = 1 +export const MENSUALIDADES_MAX = 36 + +export interface EsquemaFrasaInput { + /** Precio de la unidad (ya resuelto acabados/precio único) */ + precio: number + /** Apartado: cantidad libre (>= 0) */ + apartado: number + /** Número de mensualidades del diferimiento (elección del vendedor) */ + numMensualidades: number + /** true = la escrituración se difiere con el enganche; false = se paga a la firma */ + escrituracionDiferida: boolean +} + +export interface EsquemaFrasaResultado { + precio: number + apartado: number + /** 2.5% del precio (concepto completo) */ + engancheFirmaTotal: number + /** Lo que se paga a la firma por el enganche de firma (>= 0) */ + saldoFirma: number + /** Apartado que excedió el 2.5% y se acredita al enganche (>= 0) */ + excedenteApartado: number + /** Enganche del 7.5%, neto del excedente del apartado */ + enganche: number + escrituracion: number + escrituracionDiferida: boolean + /** Todo lo que se liquida a la firma: saldoFirma + escrituración si no se difiere */ + pagoUnicoFirma: number + /** Lo que entra al diferimiento: enganche (+ escrituración si se difiere) */ + baseDiferible: number + numMensualidades: number + mensualidad: number + /** apartado + firma + diferible — debe cuadrar con el precio saldado */ + totalPagado: number +} + +/** Error de validación de entrada; null si es válida */ +export function validarEsquemaInput(input: EsquemaFrasaInput): string | null { + if (!Number.isFinite(input.precio) || input.precio <= 0) return 'El precio debe ser mayor a cero' + if (!Number.isFinite(input.apartado) || input.apartado < 0) return 'El apartado no puede ser negativo' + if (input.apartado > input.precio) return 'El apartado no puede superar el precio de la propiedad' + if (!Number.isInteger(input.numMensualidades)) return 'Las mensualidades deben ser un número entero' + if (input.numMensualidades < MENSUALIDADES_MIN) return `Mínimo ${MENSUALIDADES_MIN} mensualidad` + if (input.numMensualidades > MENSUALIDADES_MAX) return `Máximo ${MENSUALIDADES_MAX} mensualidades` + return null +} + +export function calcularEsquemaFrasa(input: EsquemaFrasaInput): EsquemaFrasaResultado { + const error = validarEsquemaInput(input) + if (error) throw new Error(error) + + const { precio, apartado, numMensualidades, escrituracionDiferida } = input + const engancheFirmaTotal = precio * PCT_ENGANCHE_FIRMA + const saldoFirma = Math.max(0, engancheFirmaTotal - apartado) + const excedenteApartado = Math.max(0, apartado - engancheFirmaTotal) + const enganche = precio * PCT_ENGANCHE - excedenteApartado + const escrituracion = precio * PCT_ESCRITURACION + const pagoUnicoFirma = saldoFirma + (escrituracionDiferida ? 0 : escrituracion) + const baseDiferible = enganche + (escrituracionDiferida ? escrituracion : 0) + const mensualidad = baseDiferible / numMensualidades + const totalPagado = apartado + pagoUnicoFirma + baseDiferible + + return { + precio, + apartado, + engancheFirmaTotal, + saldoFirma, + excedenteApartado, + enganche, + escrituracion, + escrituracionDiferida, + pagoUnicoFirma, + baseDiferible, + numMensualidades, + mensualidad, + totalPagado, + } +} diff --git a/apps/cotizador/src/lib/herramientas.test.ts b/apps/cotizador/src/lib/herramientas.test.ts index c338d8c..e1f6ee4 100644 --- a/apps/cotizador/src/lib/herramientas.test.ts +++ b/apps/cotizador/src/lib/herramientas.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest' import { folioCotizacion } from './folio' import { textoCompartir, telefonoWhatsApp, urlWhatsApp } from './compartir' import { compararUnidades } from './comparadorUnidades' -import { APARTADO_DEFAULT, calcularPropuestas, DESCUENTOS_DEFAULT, totalPagado } from './calculations' +import { calcularEsquemaFrasa } from './esquema-frasa' +import { APARTADO_DEFAULT, totalPagado } from './calculations' import { UNIDADES_SEED } from '../data/unidades' import { UNIDADES_ATELIER } from '../data/unidades-atelier' import { DESARROLLOS } from '../data/desarrollos' @@ -16,30 +17,31 @@ describe('folioCotizacion', () => { }) describe('textoCompartir', () => { - const propuesta = calcularPropuestas({ - unidad: UNIDADES_SEED[0], - acabados: true, - apartado: APARTADO_DEFAULT, - descuentos: { ...DESCUENTOS_DEFAULT }, - numMeses: 14, - })[0] + // Caso dorado del cliente: $1,000,000, apartado $15,000, N=14 + const esquema = calcularEsquemaFrasa({ + precio: 1000000, + apartado: 15000, + numMensualidades: 14, + escrituracionDiferida: false, + }) const base = { unidadCodigo: 'U-101', desarrolloNombre: 'Diagonal', - propuesta, - apartado: APARTADO_DEFAULT, + esquema, + apartado: 15000, vigencia: new Date('2026-08-31T00:00:00'), } - it('incluye unidad, desarrollo, propuesta, montos y vigencia', () => { + it('incluye unidad, desarrollo, línea de tiempo del esquema y vigencia', () => { const texto = textoCompartir({ ...base, folio: 'COT-3F8A1B', urlPublica: 'https://x/api/cotizaciones/1/publico' }) expect(texto).toContain('Cotización COT-3F8A1B') expect(texto).toContain('Desarrollo: Diagonal') expect(texto).toContain('Unidad: U-101') - expect(texto).toContain(`Propuesta: ${propuesta.titulo}`) - expect(texto).toContain(`Enganche: ${formatoMoneda(propuesta.enganche)}`) - expect(texto).toContain(`Pago final: ${formatoMoneda(propuesta.pagoFinal)}`) + expect(texto).toContain(`Apartado (hoy): ${formatoMoneda(15000)}`) + expect(texto).toContain(`Pago a la firma: ${formatoMoneda(65000)}`) + expect(texto).toContain(`Mensualidades: 14 × ${formatoMoneda(75000 / 14)}`) + expect(texto).toContain(`Total del esquema: ${formatoMoneda(155000)}`) expect(texto).toContain('Vigencia: hasta el 31 de agosto de 2026') expect(texto).toContain('Consulta: https://x/api/cotizaciones/1/publico') }) @@ -50,15 +52,16 @@ describe('textoCompartir', () => { expect(texto).not.toContain('Consulta:') }) - it('contado (mensualidad null) muestra Mensualidades: N/A', () => { - const contado = calcularPropuestas({ - unidad: UNIDADES_SEED[0], - acabados: true, - apartado: APARTADO_DEFAULT, - descuentos: { ...DESCUENTOS_DEFAULT }, - numMeses: 14, - }).find((p) => p.tipo === 'CONTADO')! - expect(textoCompartir({ ...base, propuesta: contado })).toContain('Mensualidades: N/A') + it('escrituración diferida: lo dice en el texto', () => { + const diferido = calcularEsquemaFrasa({ + precio: 1000000, + apartado: 15000, + numMensualidades: 14, + escrituracionDiferida: true, + }) + const texto = textoCompartir({ ...base, esquema: diferido }) + expect(texto).toContain('Escrituración 5.5%: diferida con el enganche') + expect(texto).toContain(`Pago a la firma: ${formatoMoneda(10000)}`) }) }) diff --git a/apps/cotizador/src/lib/pdf.test.ts b/apps/cotizador/src/lib/pdf.test.ts index 8de4cac..5e13a7e 100644 --- a/apps/cotizador/src/lib/pdf.test.ts +++ b/apps/cotizador/src/lib/pdf.test.ts @@ -1,26 +1,27 @@ import { describe, it, expect } from 'vitest' import { crearDocumentoPdf, textoCuotaMantenimiento } from './pdf' -import { calcularPropuestas, APARTADO_DEFAULT, DESCUENTOS_DEFAULT } from './calculations' +import { calcularEsquemaFrasa } from './esquema-frasa' import { UNIDADES_SEED } from '../data/unidades' import { DESARROLLOS, LISTA_DESARROLLOS } from '../data/desarrollos' import { formatoMoneda } from './formatters' +/** Esquema único FRASA 2026: caso dorado $1,000,000 / apartado $15,000 / N=14 */ +const esquemaDorado = calcularEsquemaFrasa({ + precio: 1000000, + apartado: 15000, + numMensualidades: 14, + escrituracionDiferida: false, +}) + describe('crearDocumentoPdf', () => { it('construye el documento ANEXO A sin errores y con el nombre correcto', () => { const unidad = UNIDADES_SEED[0] - const propuestas = calcularPropuestas({ - unidad, - acabados: true, - apartado: APARTADO_DEFAULT, - descuentos: { ...DESCUENTOS_DEFAULT }, - numMeses: 14, - }) const { doc, nombreArchivo } = crearDocumentoPdf({ unidad, acabados: true, - apartado: APARTADO_DEFAULT, + apartado: 15000, precioLista: unidad.precioConAcabados, - propuestas, + esquema: { ...esquemaDorado, precio: unidad.precioConAcabados }, cliente: { nombre: 'Juan Pérez' }, }) expect(nombreArchivo).toMatch(/^cotizacion_U-101_\d{4}-\d{2}-\d{2}\.pdf$/) @@ -30,24 +31,38 @@ describe('crearDocumentoPdf', () => { expect(texto.length).toBeGreaterThan(0) }) - it('imprime folio, vigencia y QR cuando la cotización ya está guardada', () => { + it('imprime la línea de tiempo del esquema (hoy / firma / mensualidades)', () => { const unidad = UNIDADES_SEED[0] - const propuestas = calcularPropuestas({ + const { doc } = crearDocumentoPdf({ unidad, acabados: true, - apartado: APARTADO_DEFAULT, - descuentos: { ...DESCUENTOS_DEFAULT }, - numMeses: 14, + apartado: 15000, + precioLista: unidad.precioConAcabados, + esquema: esquemaDorado, + cliente: { nombre: 'Juan Pérez' }, }) + const pdf = new TextDecoder('latin1').decode(doc.output('arraybuffer')) + expect(pdf).toContain('ESQUEMA FRASA 2026') + // jspdf escapa los paréntesis en el stream: se valida el texto sin ellos + expect(pdf).toContain('APARTADO') + expect(pdf).toContain('ENGANCHE DE FIRMA') + expect(pdf).toContain('GASTOS DE ESCRITURACIÓN') + expect(pdf).toContain(formatoMoneda(esquemaDorado.pagoUnicoFirma)) + expect(pdf).toContain('14 × ') + expect(pdf).toContain(formatoMoneda(esquemaDorado.totalPagado)) + }) + + it('imprime folio, vigencia y QR cuando la cotización ya está guardada', () => { + const unidad = UNIDADES_SEED[0] // PNG 1x1 válido; en la app lo genera la librería qrcode const png1x1 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' const { doc } = crearDocumentoPdf({ unidad, acabados: true, - apartado: APARTADO_DEFAULT, + apartado: 15000, precioLista: unidad.precioConAcabados, - propuestas, + esquema: esquemaDorado, cliente: { nombre: 'Juan Pérez' }, folio: 'COT-3F8A1B', qrDataUrl: png1x1, @@ -58,19 +73,12 @@ describe('crearDocumentoPdf', () => { it('imprime el folio local CTE-LOCAL-* cuando el servidor no respondió', () => { const unidad = UNIDADES_SEED[0] - const propuestas = calcularPropuestas({ - unidad, - acabados: true, - apartado: APARTADO_DEFAULT, - descuentos: { ...DESCUENTOS_DEFAULT }, - numMeses: 14, - }) const { doc } = crearDocumentoPdf({ unidad, acabados: true, - apartado: APARTADO_DEFAULT, + apartado: 15000, precioLista: unidad.precioConAcabados, - propuestas, + esquema: esquemaDorado, cliente: { nombre: 'Juan Pérez' }, folio: 'CTE-LOCAL-1757568000000', }) diff --git a/apps/cotizador/src/lib/pdf.ts b/apps/cotizador/src/lib/pdf.ts index a1c56e2..b8177da 100644 --- a/apps/cotizador/src/lib/pdf.ts +++ b/apps/cotizador/src/lib/pdf.ts @@ -1,7 +1,8 @@ import { jsPDF } from 'jspdf' import autoTable from 'jspdf-autotable' import type { Unidad, Cliente, ParametrosDesarrollo } from '../types' -import type { PropuestaResultado } from './calculations' +import type { EsquemaFrasaResultado } from './esquema-frasa' +import { PCT_ENGANCHE, PCT_ENGANCHE_FIRMA, PCT_ESCRITURACION } from './esquema-frasa' import { formatoMoneda, formatoPorcentaje, formatoFechaLarga, fechaISOHoy } from './formatters' import { DESARROLLOS, DESARROLLO_DEFAULT } from '../data/desarrollos' @@ -10,8 +11,8 @@ export interface PdfInput { acabados: boolean apartado: number precioLista: number - /** Propuestas a imprimir; desde la app llega SOLO la propuesta elegida */ - propuestas: PropuestaResultado[] + /** Esquema único FRASA 2026 a imprimir */ + esquema: EsquemaFrasaResultado cliente: Cliente /** Parámetros del desarrollo; por defecto DIAGONAL */ desarrollo?: ParametrosDesarrollo @@ -30,7 +31,7 @@ export const textoCuotaMantenimiento = (cuota: number | null): string => cuota === null ? 'PENDIENTE' : formatoMoneda(cuota) const DISCLAIMER = - 'ESTA COTIZACION NO INCLUYE GASTOS DE ESCRITURACION. LOS VALORES ANTERIORES SON EN PESOS, MERAMENTE INFORMATIVOS, POR LO QUE ESTE DOCUMENTO NO CONSTITUYE PREAPROBACIÓN Y POR LO TANTO NO COMPROMETE AL DESARROLLADOR.\n\nMONTOS, PORCENTAJES Y PRECIOS EN LA PRESENTE COTIZACIÓN PUEDEN CAMBIAR SIN PREVIO AVISO.\n\nEL MONTO TOTAL DE ENGANCHE INCLUYE LA DEDUCCIÓN DEL MONTO DE APARTADO.\n\nLA PRESENTE COTIZACIÓN TIENE UNA VIGENCIA DE 3 DÍAS NATURALES A PARTIR DE LA FECHA DE EMISIÓN, PARA CONGELAR EL PRECIO DE VENTA ES NECESARIO REALIZAR EL PAGO DE SU APARTADO COMPLETO Y TENDRÁ 7 DÍAS PARA FORMALIZAR Y NO PERDER LA UBICACIÓN.' + 'ESTA COTIZACION INCLUYE LOS GASTOS DE ESCRITURACION (5.5% DEL PRECIO), PAGADEROS A LA FIRMA O DIFERIDOS CON EL ENGANCHE SEGUN LO INDICADO. LOS VALORES ANTERIORES SON EN PESOS, MERAMENTE INFORMATIVOS, POR LO QUE ESTE DOCUMENTO NO CONSTITUYE PREAPROBACIÓN Y POR LO TANTO NO COMPROMETE AL DESARROLLADOR.\n\nMONTOS, PORCENTAJES Y PRECIOS EN LA PRESENTE COTIZACIÓN PUEDEN CAMBIAR SIN PREVIO AVISO.\n\nEL ENGANCHE DE FIRMA (2.5%) INCLUYE LA DEDUCCIÓN DEL MONTO DE APARTADO; SI EL APARTADO LO EXCEDE, EL EXCEDENTE SE ACREDITA AL ENGANCHE.\n\nLA PRESENTE COTIZACIÓN TIENE UNA VIGENCIA DE 3 DÍAS NATURALES A PARTIR DE LA FECHA DE EMISIÓN, PARA CONGELAR EL PRECIO DE VENTA ES NECESARIO REALIZAR EL PAGO DE SU APARTADO COMPLETO Y TENDRÁ 7 DÍAS PARA FORMALIZAR Y NO PERDER LA UBICACIÓN.' const CUENTA_DEPOSITO = 'PARA CUENTAS EN MEXICO:\nTITULAR: FRASA DESARROLLOS, S.A.P.I. DE C.V.\n\nBBVA BANCOMER PAGO SOLO EN PESOS\nCUENTA: 0111525921 CLABE: 012028001115259210\n\nBBVA BANCOMER PAGO SOLO EN DOLARES\nCUENTA: 0111571737 CLABE: 012028001115717370\n\nPARA RECIBIR TRANSFERENCIAS DESDE EL EXTRANJERO:\nBANCO DEL BENEFICIARIO: BBVA Bancomer.\nSWIFT/BIC CODE: BCMRMXMMXXX\nBENEFICIARIO: FRASA DESARROLLOS, S.A.P.I. DE C.V.\nCUENTA: 01111571737\n(CLABE 18 DÍGITOS): 012 028 001 115 717 370\nSOLO PAGO EN DOLARES' @@ -50,7 +51,7 @@ export interface DocumentoPdf { } /** Construye el documento PDF "ANEXO A" (sin descargarlo) */ -export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, propuestas, cliente, desarrollo, folio, qrDataUrl, vigencia }: PdfInput): DocumentoPdf { +export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, esquema, cliente, desarrollo, folio, qrDataUrl, vigencia }: PdfInput): DocumentoPdf { const params = desarrollo ?? DESARROLLOS[DESARROLLO_DEFAULT] const doc = new jsPDF() const hoy = new Date() @@ -128,51 +129,55 @@ export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, pro }) y = finalY(doc) + 8 - // 3. Propuesta general — una tabla por propuesta + // 3. Esquema único FRASA 2026 — una sola tabla con la línea de tiempo doc.setFontSize(12) doc.setFont('helvetica', 'bold') - doc.text('PROPUESTA GENERAL', MARGEN, y) + doc.text('PROPUESTA GENERAL — ESQUEMA FRASA 2026', MARGEN, y) y += 4 - propuestas.forEach((p, i) => { - const mensualidades = p.notaMensualidades - ? { pagos: `— (${p.notaMensualidades})`, total: '—' } - : p.mensualidad !== null && p.mensualidadesTotal !== null - ? { - pagos: `${p.numMensualidades} × ${formatoMoneda(p.mensualidad)}`, - total: formatoMoneda(p.mensualidadesTotal), - } - : { pagos: 'N/A', total: 'N/A' } - - // Con una sola propuesta (la elegida por el vendedor) no se numera - const encabezado = - propuestas.length === 1 - ? `PROPUESTA COMERCIAL — ${p.titulo.toUpperCase()}` - : `PROPUESTA COMERCIAL #${i + 1} — ${p.titulo.toUpperCase()}` - - autoTable(doc, { - startY: y, - margin: { left: MARGEN, right: MARGEN }, - styles: { fontSize: 8 }, - headStyles: { fillColor: [37, 99, 235] }, - head: [[encabezado, '%', 'PAGOS MENSUALES', 'TOTAL']], - body: [ - ['DESCUENTO', formatoPorcentaje(p.descuento), '', ''], - ['MONTO DE DESCUENTO', '', '', formatoMoneda(p.montoDescuento)], - ['PRECIO FINAL C/DESC', '', '', formatoMoneda(p.precioFinal)], - ['ENGANCHE', '', '', formatoMoneda(p.enganche)], - ['MENSUALIDADES', '', mensualidades.pagos, mensualidades.total], - ['PAGO FINAL', '', '', formatoMoneda(p.pagoFinal)], + autoTable(doc, { + startY: y, + margin: { left: MARGEN, right: MARGEN }, + styles: { fontSize: 8 }, + headStyles: { fillColor: [37, 99, 235] }, + head: [['CONCEPTO', '%', 'PAGOS MENSUALES', 'TOTAL']], + body: [ + ['APARTADO (HOY)', '', '', formatoMoneda(apartado)], + [ + `ENGANCHE DE FIRMA (${formatoPorcentaje(PCT_ENGANCHE_FIRMA)})`, + formatoPorcentaje(PCT_ENGANCHE_FIRMA), + '', + formatoMoneda(esquema.engancheFirmaTotal), ], - }) - y = finalY(doc) + 6 - - // Salto de página antes de la siguiente tabla si no cabe - if (y > 240 && i < propuestas.length - 1) { - doc.addPage() - y = MARGEN - } + [ + `SALDO A LA FIRMA (firma − apartado${esquema.excedenteApartado > 0 ? ' + excedente al enganche' : ''})`, + '', + '', + formatoMoneda(esquema.saldoFirma), + ], + [ + `GASTOS DE ESCRITURACIÓN (${formatoPorcentaje(PCT_ESCRITURACION)}) — ${esquema.escrituracionDiferida ? 'DIFERIDOS CON EL ENGANCHE' : 'PAGADOS A LA FIRMA'}`, + formatoPorcentaje(PCT_ESCRITURACION), + '', + formatoMoneda(esquema.escrituracion), + ], + ['PAGO A LA FIRMA', '', '', formatoMoneda(esquema.pagoUnicoFirma)], + [ + `ENGANCHE (${formatoPorcentaje(PCT_ENGANCHE)}) DIFERIBLE`, + formatoPorcentaje(PCT_ENGANCHE), + '', + formatoMoneda(esquema.enganche), + ], + [ + 'MENSUALIDADES', + '', + `${esquema.numMensualidades} × ${formatoMoneda(esquema.mensualidad)}`, + formatoMoneda(esquema.baseDiferible), + ], + ['TOTAL DEL ESQUEMA', '', '', formatoMoneda(esquema.totalPagado)], + ], }) + y = finalY(doc) + 6 y += 2 // 4. ¿Qué sigue? diff --git a/apps/cotizador/src/pages/CotizadorPage.reglas.test.tsx b/apps/cotizador/src/pages/CotizadorPage.reglas.test.tsx index 0230ddd..ccdf5be 100644 --- a/apps/cotizador/src/pages/CotizadorPage.reglas.test.tsx +++ b/apps/cotizador/src/pages/CotizadorPage.reglas.test.tsx @@ -9,7 +9,8 @@ import { INVENTARIO_SEED } from '../data/unidades' /** * Reglas configurables por el admin en la página del cotizador: * - un desarrollo con activoEnCotizador: false no se ofrece - * - una propuesta desactivada no se muestra ni se puede elegir + * - una propuesta desactivada ya NO afecta: el esquema único FRASA 2026 no + * usa las 4 propuestas (sigue cotizando igual) */ const unidadDiagonal = INVENTARIO_SEED.find((u) => u.desarrollo === 'DIAGONAL')! @@ -60,7 +61,7 @@ afterEach(() => { }) describe('CotizadorPage con reglas del admin', () => { - it('no ofrece el desarrollo desactivado ni la propuesta desactivada', async () => { + it('no ofrece el desarrollo desactivado; una propuesta desactivada ya no bloquea el esquema', async () => { render( @@ -74,16 +75,18 @@ describe('CotizadorPage con reglas del admin', () => { expect(screen.getByRole('button', { name: /Diagonal/ })).toBeTruthy() expect(screen.getByRole('button', { name: /Senda/ })).toBeTruthy() - // Cargar las 4 propuestas de DIAGONAL: solo las 3 vigentes + // Cargar la unidad de DIAGONAL (con la propuesta Contado desactivada por + // el admin): el esquema único se calcula igual, sin propuestas fireEvent.focus(inputUnidad) fireEvent.change(inputUnidad, { target: { value: unidadDiagonal.codigo } }) fireEvent.mouseDown(screen.getByRole('option', { name: new RegExp(unidadDiagonal.codigo) })) await waitFor(() => { - expect(screen.getByText('Crédito Bancario')).toBeTruthy() + expect(screen.getByText('Esquema FRASA 2026 — conceptos')).toBeTruthy() }) - expect(screen.getByText('Crédito Infonavit-Cofinavit')).toBeTruthy() - expect(screen.getByText('Semi Contado')).toBeTruthy() - expect(screen.queryByText('Contado')).toBeNull() + // La línea de tiempo del esquema (Hoy / A la firma / Después) + expect(screen.getByText('Hoy')).toBeTruthy() + expect(screen.getByText('A la firma')).toBeTruthy() + expect(screen.getByText('Después')).toBeTruthy() }) }) diff --git a/apps/cotizador/src/pages/CotizadorPage.smoke.test.tsx b/apps/cotizador/src/pages/CotizadorPage.smoke.test.tsx index 0602eff..d926928 100644 --- a/apps/cotizador/src/pages/CotizadorPage.smoke.test.tsx +++ b/apps/cotizador/src/pages/CotizadorPage.smoke.test.tsx @@ -6,9 +6,9 @@ import CotizadorPage from './CotizadorPage' import { INVENTARIO_SEED } from '../data/unidades' /** - * Smoke del cierre de la cotización: - * - barra sticky con propuesta elegida + Compartir/Guardar/Generar PDF - * - preselección por defecto de la propuesta de menor total + * Smoke del cierre de la cotización con el esquema único FRASA 2026: + * - barra sticky con resumen del esquema + Compartir/Guardar/Generar PDF + * - panel único (línea de tiempo Hoy → Firma → Mensualidades) * - sin servidor: la cotización se guarda localmente con folio CTE-LOCAL-* */ const unidad = INVENTARIO_SEED.find((u) => u.status === 'DISPONIBLE' && u.desarrollo === 'DIAGONAL')! @@ -35,7 +35,7 @@ afterEach(() => { }) describe('CotizadorPage — barra sticky y guardado local', () => { - it('preselecciona la propuesta más barata y guarda local con folio CTE-LOCAL cuando el API no responde', async () => { + it('muestra el esquema único y guarda local con folio CTE-LOCAL cuando el API no responde', async () => { const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input) const metodo = init?.method ?? 'GET' @@ -58,7 +58,7 @@ describe('CotizadorPage — barra sticky y guardado local', () => { expect(await screen.findByRole('button', { name: 'Generar PDF' })).toBeTruthy() expect(screen.getByRole('button', { name: 'Guardar' })).toBeTruthy() expect(screen.getByRole('button', { name: 'Compartir' })).toBeTruthy() - expect(screen.getByText(/Selecciona una unidad para elegir propuesta/)).toBeTruthy() + expect(screen.getByText(/Selecciona una unidad para ver el esquema de cotización\./)).toBeTruthy() // Elegir una unidad del inventario const inputUnidad = screen.getByLabelText('Unidad') as HTMLInputElement @@ -66,12 +66,11 @@ describe('CotizadorPage — barra sticky y guardado local', () => { fireEvent.change(inputUnidad, { target: { value: unidad.codigo } }) fireEvent.mouseDown(screen.getByRole('option', { name: new RegExp(unidad.codigo) })) - // Preselección por defecto: la propuesta de menor total pagado - const selectPropuesta = (await screen.findByLabelText( - 'Cambiar propuesta elegida', - )) as HTMLSelectElement - await waitFor(() => expect(selectPropuesta.value).toBe('2')) // Contado es la más barata - expect(screen.getByText(/Propuesta elegida: #3/)).toBeTruthy() + // Panel único del esquema: línea de tiempo y conceptos + expect(await screen.findByText('Esquema FRASA 2026 — conceptos')).toBeTruthy() + expect(screen.getByText('Hoy')).toBeTruthy() + expect(screen.getByText('A la firma')).toBeTruthy() + expect(screen.getByRole('option', { name: '14 mensualidades' })).toBeTruthy() // Guardar con el servidor caído → folio local persistido en el dispositivo fireEvent.change(screen.getByLabelText(/Nombre del cliente/), { @@ -82,10 +81,20 @@ describe('CotizadorPage — barra sticky y guardado local', () => { await waitFor(() => { const locales = JSON.parse( almacenamiento.get('frasa-cotizador:cotizaciones-locales') ?? '[]', - ) as { folio?: string; clienteNombre?: string }[] + ) as { + folio?: string + clienteNombre?: string + esquema?: string + tipoPropuesta?: string + saldoFirma?: number | null + }[] expect(locales).toHaveLength(1) expect(locales[0]?.folio ?? '').toMatch(/^CTE-LOCAL-\d+$/) expect(locales[0]?.clienteNombre).toBe('Cliente Prueba Local') + // La instantánea local ya vive en el esquema único + expect(locales[0]?.esquema).toBe('FRASA_2026') + expect(locales[0]?.tipoPropuesta).toBe('FRASA_2026') + expect(locales[0]?.saldoFirma).not.toBeNull() }) expect(screen.getByText(/cotización guardada localmente con folio CTE-LOCAL-/)).toBeTruthy() }) @@ -100,12 +109,17 @@ describe('CotizadorPage — barra sticky y guardado local', () => { clienteNombre: 'Cliente Antiguo', clienteTelefono: null, clienteEmail: null, + esquema: 'FRASA_2026', tipoPropuesta: 'BANCARIO', acabados: false, apartado: 20000, descuento: 0, precioLista: vendida.precioConAcabados, precioFinal: 0, + saldoFirma: null, + engancheDiferible: null, + escrituracion: null, + escrituracionDiferida: false, enganche: 0, numMensualidades: 12, mensualidad: 1000, @@ -139,6 +153,8 @@ describe('CotizadorPage — barra sticky y guardado local', () => { const alerta = await screen.findByRole('alert') expect(alerta.textContent).toContain(`La unidad ${vendida.codigo} no está disponible`) expect(screen.getByText(/ya no está disponible \(estatus: VENDIDO\): no se puede cotizar de nuevo/)).toBeTruthy() + // Es una cotización del esquema anterior (legacy): lo dice al retomarla + expect(screen.getByText(/Usa el esquema anterior \(BANCARIO\)/)).toBeTruthy() for (const nombre of ['Generar PDF', 'Guardar', 'Compartir']) { expect((screen.getByRole('button', { name: nombre }) as HTMLButtonElement).disabled).toBe(true) } diff --git a/apps/cotizador/src/pages/CotizadorPage.tsx b/apps/cotizador/src/pages/CotizadorPage.tsx index 73aa8c4..69971c3 100644 --- a/apps/cotizador/src/pages/CotizadorPage.tsx +++ b/apps/cotizador/src/pages/CotizadorPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' @@ -10,9 +10,7 @@ import { SkeletonList } from "../components/Skeleton" import toast from "react-hot-toast" import { UnitSelector } from '../components/UnitSelector' import { UnitInfoCard } from '../components/UnitInfoCard' -import { ProposalCard } from '../components/ProposalCard' -import { ComparadorPropuestas } from '../components/ComparadorPropuestas' -import { ComparadorUnidades } from '../components/ComparadorUnidades' +import { EsquemaPanel } from '../components/EsquemaPanel' import { MisCotizaciones } from '../components/MisCotizaciones' import { ModalCliente } from '../components/ModalCliente' import { BuscadorCliente } from '../components/BuscadorCliente' @@ -21,16 +19,13 @@ import { formatoMoneda, formatoFechaLarga } from '../lib/formatters' import { calcularVigencia } from '../lib/vigencia' import { folioCotizacion } from '../lib/folio' import { textoCompartir, urlWhatsApp } from '../lib/compartir' -import { indiceMenorTotal, totalPagado, ENGANCHE_MAXIMO_PCT } from '../lib/calculations' -import { desarrolloEfectivo, idsCotizables, propuestaVigente } from '../lib/reglas' +import { desarrolloEfectivo, idsCotizables } from '../lib/reglas' import { getMisCotizaciones, getCotizacion, guardarCotizacion, urlPublicaCotizacion, ErrorApiCotizacion, - TIPO_A_API, - TIPO_DE_API, type CotizacionGuardada, } from '../services/api' import { cargarCotizacionesLocales, guardarCotizacionLocal } from '../services/offline-db' @@ -39,6 +34,10 @@ import type { Cliente, Unidad } from '../types' /** Solo el inventario DISPONIBLE se puede cotizar (apartar/vender bloquea la unidad) */ const esDisponible = (u: Unidad): boolean => (u.status ?? '').trim().toUpperCase() === 'DISPONIBLE' +/** Una cotización guardada ya vive en el esquema único (filas nuevas) */ +const esEsquemaNuevo = (c: CotizacionGuardada): boolean => + c.esquema === 'FRASA_2026' && c.saldoFirma != null + const esquemaCliente = z.object({ nombre: z.string().min(3, 'El nombre del cliente es requerido (mínimo 3 caracteres)'), email: z.union([z.literal(''), z.string().email('Correo electrónico inválido')]).optional(), @@ -70,17 +69,13 @@ export default function CotizadorPage() { apartado, setApartado, apartadoEfectivo, - enganchePct, - setEnganchePct, - errorEnganche, - propuestaIndex, - setPropuestaIndex, - precioLista, numMensualidades, setNumMensualidades, + escrituracionDiferida, + setEscrituracionDiferida, + precioLista, + resultado, numMeses, - propuestas, - avisoPropuestaDesactivada, restaurar, nueva, } = cotizacion @@ -121,11 +116,6 @@ export default function CotizadorPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [desarrollos, desarrollo]) - // La propuesta elegida dejó de estar disponible (la desactivó el admin) - useEffect(() => { - if (avisoPropuestaDesactivada !== null) setAviso(avisoPropuestaDesactivada) - }, [avisoPropuestaDesactivada]) - useEffect(() => { let vivo = true /** Combina servidor + locales, sin duplicados, más recientes primero */ @@ -241,33 +231,9 @@ export default function CotizadorPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [cotizacionPendiente, cargando, unidades]) - // La propuesta elegida (para guardar/compartir); acotada por si cambia el desarrollo - const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))] - // Total pagado por el cliente con la propuesta elegida; null si no se puede conocer - const totalActivo = propuestaActiva ? totalPagado(propuestaActiva, apartadoEfectivo) : null // Una unidad apartada/vendida no se cotiza: se avisa y se bloquean las acciones const unidadNoDisponible = unidad !== null && !esDisponible(unidad) - const puedeGenerarPdf = Boolean(unidad) && !unidadNoDisponible && !errorEnganche && !guardando - - // ---------- Preselección por defecto: la propuesta de menor total pagado ---------- - // Al cambiar de unidad (o al elegir la primera) se preselecciona la propuesta - // más barata; la elección manual del vendedor se respeta hasta el siguiente - // cambio de unidad. handleRetomar marca saltarAuto: manda SU propuesta guardada. - const unidadPrevia = useRef(undefined) - const saltarAuto = useRef(false) - - useEffect(() => { - const codigo = unidad?.codigo ?? null - const cambioUnidad = unidadPrevia.current !== undefined && unidadPrevia.current !== codigo - unidadPrevia.current = codigo - if (!cambioUnidad || saltarAuto.current) { - saltarAuto.current = false - return - } - const totales = propuestas.map((p) => totalPagado(p, apartadoEfectivo)) - const indiceMenor = indiceMenorTotal(totales.map((t) => t ?? Number.POSITIVE_INFINITY)) - if (indiceMenor >= 0) setPropuestaIndex(indiceMenor) - }, [unidad?.codigo, propuestas, apartadoEfectivo]) + const puedeGenerarPdf = Boolean(unidad) && !unidadNoDisponible && !guardando /** true cuando la cotización vive solo en este dispositivo (el API no respondió) */ const esCotizacionLocal = (c: CotizacionGuardada): boolean => c.id.startsWith('local-') @@ -275,24 +241,30 @@ export default function CotizadorPage() { /** Instantánea local de la cotización con folio CTE-LOCAL- */ const cotizacionLocal = (cliente: Cliente): CotizacionGuardada => { const timestamp = Date.now() + if (!unidad || !resultado) throw new Error('No hay esquema para guardar localmente') return { id: `local-${timestamp}`, folio: `CTE-LOCAL-${timestamp}`, - unidadCodigo: unidad!.codigo, + unidadCodigo: unidad.codigo, desarrollo, clienteNombre: cliente.nombre, clienteTelefono: cliente.telefono ?? null, clienteEmail: cliente.email ?? null, - tipoPropuesta: TIPO_A_API[propuestaActiva.tipo], + esquema: 'FRASA_2026', + tipoPropuesta: 'FRASA_2026', acabados, apartado: apartadoEfectivo, - descuento: propuestaActiva.descuento, + descuento: 0, precioLista, - precioFinal: propuestaActiva.precioFinal, - enganche: propuestaActiva.enganche, - numMensualidades: propuestaActiva.numMensualidades ?? 0, - mensualidad: propuestaActiva.mensualidad, - pagoFinal: propuestaActiva.pagoFinal, + precioFinal: precioLista, + saldoFirma: resultado.saldoFirma, + engancheDiferible: resultado.enganche, + escrituracion: resultado.escrituracion, + escrituracionDiferida, + enganche: resultado.enganche, + numMensualidades, + mensualidad: resultado.mensualidad, + pagoFinal: resultado.pagoUnicoFirma, createdAt: new Date(timestamp).toISOString(), } } @@ -305,19 +277,17 @@ export default function CotizadorPage() { * devuelve null: se avisa y no se guarda nada. */ const guardarEnApi = async (cliente: Cliente): Promise => { - if (!unidad || !propuestaActiva) return null + if (!unidad || !resultado) return null try { const guardada = await guardarCotizacion({ unidadCodigo: unidad.codigo, clienteNombre: cliente.nombre, ...(cliente.email ? { clienteEmail: cliente.email } : {}), ...(cliente.telefono ? { clienteTelefono: cliente.telefono } : {}), - tipoPropuesta: TIPO_A_API[propuestaActiva.tipo], acabados, apartado: apartadoEfectivo, - descuento: propuestaActiva.descuento, - ...(numMeses !== null ? { numMensualidades: numMeses } : {}), - ...(enganchePct !== null && errorEnganche === null ? { enganchePct: enganchePct / 100 } : {}), + numMensualidades, + escrituracionDiferida, ...(oportunidadId !== null ? { oportunidadId } : {}), }) const conFolio = { ...guardada, folio: guardada.folio ?? folioCotizacion(guardada.id) } @@ -344,7 +314,7 @@ export default function CotizadorPage() { } const generarPdfConCliente = async (cliente: Cliente) => { - if (!unidad) return + if (!unidad || !resultado) return setGuardando(true) try { const guardada = await guardarEnApi(cliente) @@ -365,8 +335,8 @@ export default function CotizadorPage() { acabados, apartado: apartadoEfectivo, precioLista, - // El PDF imprime SOLO la propuesta elegida por el vendedor - propuestas: propuestaActiva ? [propuestaActiva] : [], + // El PDF imprime SOLO el esquema único FRASA 2026 + esquema: resultado, cliente, desarrollo: params, folio, @@ -432,11 +402,11 @@ export default function CotizadorPage() { } const handleCompartir = async () => { - if (!unidad || !propuestaActiva) return + if (!unidad || !resultado) return const texto = textoCompartir({ unidadCodigo: unidad.codigo, desarrolloNombre: params.nombre, - propuesta: propuestaActiva, + esquema: resultado, apartado: apartadoEfectivo, vigencia: calcularVigencia(), ...(ultimaGuardada @@ -468,32 +438,32 @@ export default function CotizadorPage() { setAviso(`La unidad ${c.unidadCodigo} ya no está en el inventario actual.`) return } - const tipo = TIPO_DE_API[c.tipoPropuesta] - const paramsGuardados = desarrolloEfectivo(reglas, c.desarrollo) - const idx = paramsGuardados.propuestas.filter(propuestaVigente).findIndex((p) => p.tipo === tipo) - if (idx < 0) { - setAviso( - `La cotización ${c.folio ?? ''} usa la propuesta ${c.tipoPropuesta}, que ya no está disponible para ${c.desarrollo}. Elige otra propuesta.`, - ) - } - saltarAuto.current = true // no re-preseleccionar la más barata: manda la guardada + const nueva = esEsquemaNuevo(c) restaurar({ desarrollo: c.desarrollo, unidad: u, acabados: c.acabados, apartado: c.apartado, - descuento: c.descuento, - propuestaIndex: idx >= 0 ? idx : 0, - numMensualidades: c.numMensualidades > 0 ? c.numMensualidades : null, + // Las cotizaciones del esquema anterior retoman con los defaults del + // nuevo esquema (14 mensualidades, escrituración a la firma) + ...(nueva + ? { + numMensualidades: c.numMensualidades > 0 ? c.numMensualidades : undefined, + escrituracionDiferida: c.escrituracionDiferida, + } + : {}), }) setValue('nombre', c.clienteNombre, { shouldValidate: true }) setValue('telefono', c.clienteTelefono ?? '', { shouldValidate: true }) setValue('email', c.clienteEmail ?? '', { shouldValidate: true }) setUltimaGuardada(c) + const avisoEsquema = nueva + ? '' + : ` Usa el esquema anterior (${c.tipoPropuesta}); se cargó con el esquema FRASA 2026 y sus valores por defecto.` setAviso( esDisponible(u) - ? `Cotización ${c.folio ?? ''} retomada.` - : `Cotización ${c.folio ?? ''} retomada, pero la unidad ${u.codigo} ya no está disponible (estatus: ${u.status}): no se puede cotizar de nuevo.`, + ? `Cotización ${c.folio ?? ''} retomada.${avisoEsquema}` + : `Cotización ${c.folio ?? ''} retomada, pero la unidad ${u.codigo} ya no está disponible (estatus: ${u.status}): no se puede cotizar de nuevo.${avisoEsquema}`, ) } @@ -639,67 +609,6 @@ export default function CotizadorPage() {

{formatoMoneda(apartado)}

-
- - { - const crudo = e.target.value - if (crudo === '') { - setEnganchePct(null) - return - } - const v = Number(crudo) - setEnganchePct(Number.isFinite(v) ? v : null) - }} - className={errorEnganche ? campoInputError : campoInput} - /> - {errorEnganche ? ( -

{errorEnganche}

- ) : ( -

- Aplica a crédito bancario e Infonavit/Cofinavit; rango válido 2.5% a{' '} - {ENGANCHE_MAXIMO_PCT}% (lo que permite el Excel) - {unidad ? ` (${formatoMoneda((precioLista * Math.max(enganchePct ?? 2.5, 2.5)) / 100)})` : ''} -

- )} -
- -
- - - {numMeses !== null && ( -

- Se usarán {numMeses} mensualidades para el cálculo -

- )} -
- { setValue('nombre', lead.nombre, { shouldValidate: true }) @@ -783,33 +692,22 @@ export default function CotizadorPage() { {unidad && } - {/* Panel de propuestas */} + {/* Panel del esquema único FRASA 2026 */}
- {!unidad ? ( + {!unidad || !resultado ? (

- Selecciona una unidad para ver las propuestas comerciales. + Selecciona una unidad para ver el esquema de cotización.

) : ( -
-
- {propuestas.map((p, i) => ( - - ))} -
- p.titulo)} - /> -
+ )}
@@ -822,44 +720,24 @@ export default function CotizadorPage() { error={errorMias} onRetomar={handleRetomar} /> - - {/* Barra fija de cierre: propuesta elegida + acciones, siempre visibles */} + {/* Barra fija de cierre: resumen del esquema + acciones, siempre visibles */}
- {propuestaActiva ? ( - <> - - - Propuesta elegida: #{propuestas.indexOf(propuestaActiva) + 1} {propuestaActiva.titulo} - {totalActivo !== null ? ` · Total ${formatoMoneda(totalActivo)}` : ''} - + {resultado ? ( + + + Esquema FRASA 2026 · Hoy {formatoMoneda(resultado.apartado)} · A la firma{' '} + {formatoMoneda(resultado.pagoUnicoFirma)} · {resultado.numMensualidades} ×{' '} + {formatoMoneda(resultado.mensualidad)} - - + ) : (

- Selecciona una unidad para elegir propuesta, guardar y generar el PDF. + Selecciona una unidad para ver el esquema, guardar y generar el PDF.

)} {aviso && ( diff --git a/apps/cotizador/src/services/api.ts b/apps/cotizador/src/services/api.ts index 111b6c2..a25cec6 100644 --- a/apps/cotizador/src/services/api.ts +++ b/apps/cotizador/src/services/api.ts @@ -1,4 +1,4 @@ -import type { Unidad, TipoPropuesta } from '../types' +import type { Unidad } from '../types' import { INVENTARIO_SEED } from '../data/unidades' import { reglasDesdeApi, type ReglasDesarrollo } from '../lib/reglas' import { conAuth, manejar401 } from './auth' @@ -72,22 +72,12 @@ export async function getReglasCotizador(): Promise { // ---------- Cotizaciones guardadas (historial del vendedor) ---------- -/** Tipo de propuesta tal como lo espera el API (body de POST /cotizaciones) */ -export type TipoPropuestaApi = 'BANCARIO' | 'INFONAVIT' | 'CONTADO' | 'SEMI_CONTADO' - -export const TIPO_A_API: Record = { - CREDITO_BANCARIO: 'BANCARIO', - CREDITO_INFONAVIT_COFINAVIT: 'INFONAVIT', - CONTADO: 'CONTADO', - SEMI_CONTADO: 'SEMI_CONTADO', -} - -export const TIPO_DE_API: Record = { - BANCARIO: 'CREDITO_BANCARIO', - INFONAVIT: 'CREDITO_INFONAVIT_COFINAVIT', - CONTADO: 'CONTADO', - SEMI_CONTADO: 'SEMI_CONTADO', -} +/** + * Tipo de propuesta tal como viene del API. Las cotizaciones nuevas usan el + * esquema único FRASA_2026; los valores legacy (BANCARIO, …) quedan en las + * filas viejas para no romper reportes ni el historial. + */ +export type TipoPropuestaApi = 'BANCARIO' | 'INFONAVIT' | 'CONTADO' | 'SEMI_CONTADO' | 'FRASA_2026' export interface CotizacionGuardada { id: string @@ -97,12 +87,22 @@ export interface CotizacionGuardada { clienteNombre: string clienteTelefono: string | null clienteEmail: string | null + /** Esquema único FRASA 2026 (las viejas traen su tipoPropuesta legacy) */ + esquema?: string tipoPropuesta: TipoPropuestaApi acabados: boolean apartado: number descuento: number precioLista: number precioFinal: number + /** Lo que se paga a la firma por el enganche de firma (esquema nuevo) */ + saldoFirma?: number | null + /** Enganche del 7.5%, neto del excedente del apartado (esquema nuevo) */ + engancheDiferible?: number | null + /** Gastos de escrituración 5.5% (esquema nuevo) */ + escrituracion?: number | null + /** true = escrituración diferida con el enganche (esquema nuevo) */ + escrituracionDiferida?: boolean enganche: number numMensualidades: number mensualidad: number | null @@ -115,12 +115,13 @@ export interface GuardarCotizacionPayload { clienteNombre: string clienteEmail?: string clienteTelefono?: string - tipoPropuesta: TipoPropuestaApi acabados: boolean + /** Apartado: cantidad libre (sin él, el API usa su default) */ apartado?: number - descuento?: number - /** Mensualidades elegidas por el vendedor (sin él, el API usa el plazo del desarrollo) */ + /** Mensualidades del diferimiento (1..36; sin ellas, el API usa 14) */ numMensualidades?: number + /** true = la escrituración (5.5%) se difiere con el enganche */ + escrituracionDiferida?: boolean /** Item del tablero Oportunidades a vincular (botón "Cotizar" de Mi día) */ oportunidadId?: string }