feat(cotizador): 4 opciones de credito sobre el esquema FRASA (saldo 84.5% con mensualidad estimada)

- Nuevo modulo puro lib/opciones-credito.ts: Bancario, Infonavit-Cofinavit,
  Semi Contado (1%) y Contado (2%), con descuentos del Excel del cliente.
  Cada opcion recalcula el esquema FRASA 2026 sobre el precio final y
  estima la mensualidad del credito (anualidad con tasa y plazo ajustables;
  % de contado para la Semi). Ajustes validados (tasa 0-50%, plazo 1-35).
- Nuevo componente OpcionesCredito: grid de 4 tarjetas con tasa/plazo/% de
  contado editables, mensualidad estimada y boton 'Usar en el PDF'.
- CotizadorPage: estado de ajustes y opcion elegida, memo con try/catch
  (defaults de respaldo), restauracion defensiva al retomar cotizacion.
- PDF: seccion 'PROPUESTA DE FINANCIAMIENTO ELEGIDA' con renglones del
  credito (o pago de contado y ahorro para Contado) + disclaimer.
- API: columna Cotizacion.opcionesCredito (TEXT, JSON {elegida, ajustes})
  con migracion manual aplicada en SQLite; zod max 4000; se persiste crudo
  y los GET devuelven la fila completa.

Tests: cotizador 149/149 (19 nuevos en opciones-credito, 2 en pdf),
API 746/746 (3 nuevos en cotizaciones). Smoke real POST+GET en produccion
local con unidad ATELIER-34 (201 con opcionesCredito; fila de prueba borrada).
This commit is contained in:
2026-09-28 19:56:37 +00:00
parent 61d1b84fc3
commit 4a3ddd2f35
11 changed files with 863 additions and 9 deletions

View File

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