feat: esquema unico de cotizacion FRASA 2026 (reemplaza las 4 propuestas)
Flujo aprobado por el cliente (28 sep 2026), porcentajes globales para todos los desarrollos: - Apartado: cantidad libre (sin minimo), se acredita dentro del enganche de firma; el excedente se descuenta del enganche - Enganche de firma: 2.5% del precio (saldo a firma = max(0, 2.5% - apartado)) - Enganche: 7.5%, diferible en N mensualidades (N a eleccion del vendedor, 1-36) - Gastos de escrituracion: 5.5%, pago unico a la firma o diferido con el enganche (toggle) - lib/esquema-frasa.ts (frontend) + cotizacion-esquema.service.ts (backend): modulos puros espejo, caso dorado M/apartado 5k/N=14 en tests de ambos - Migracion 20260928120000: 7 columnas nuevas en Cotizacion (esquema, saldoFirma, engancheDiferible, escrituracion, etc.); legacy se rellena para compatibilidad de reportes (tipoPropuesta=FRASA_2026) - UI: panel de linea de tiempo (Hoy -> Firma -> Mensualidades) sustituye a propuestas/comparadores; PDF y WhatsApp con el esquema unico - API recalcula server-side; smoke end-to-end verificado contra inventario - 743 tests API + 128 cotizador en verde
This commit is contained in:
@@ -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;
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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 () => {
|
||||
|
||||
@@ -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<void
|
||||
validarUnidadDisponible(unidad)
|
||||
|
||||
// Reglas comerciales del desarrollo: las configuradas por el admin (BD) con
|
||||
// respaldo en las constantes históricas. Un desarrollo o una propuesta
|
||||
// desactivados se rechazan con el mensaje que muestra el cotizador.
|
||||
// respaldo en las constantes históricas. Un desarrollo desactivado se
|
||||
// rechaza con el mensaje que muestra el cotizador. El esquema único
|
||||
// FRASA 2026 ya no depende de propuestas (las 4 propuestas desaparecieron).
|
||||
const reglas = await getReglasDesarrollo(unidad.desarrollo)
|
||||
if (reglas.activoEnCotizador === false) {
|
||||
throw new AppError(
|
||||
@@ -92,26 +102,19 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
||||
`El desarrollo ${reglas.nombre ?? reglas.id} no está disponible en el cotizador. Elige una unidad de otro desarrollo.`,
|
||||
)
|
||||
}
|
||||
const tipoInterno = TIPO_PROPUESTA_API[body.tipoPropuesta]
|
||||
if (!propuestasVigentes(reglas).some((p) => 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<void
|
||||
clienteNombre: body.clienteNombre,
|
||||
clienteEmail: body.clienteEmail ?? null,
|
||||
clienteTelefono: body.clienteTelefono ?? null,
|
||||
tipoPropuesta: body.tipoPropuesta,
|
||||
acabados: body.acabados,
|
||||
apartado,
|
||||
descuento: propuesta.descuento,
|
||||
precioLista,
|
||||
precioFinal: propuesta.precioFinal,
|
||||
enganche: propuesta.enganche,
|
||||
// 0 => 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<void
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json({ ...cotizacion, folio: folioCotizacion(cotizacion.id), mondayItemId, propuesta })
|
||||
res.status(201).json({ ...cotizacion, folio: folioCotizacion(cotizacion.id), mondayItemId, esquema })
|
||||
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CREAR_COTIZACION', 'cotizacion', cotizacion.id)
|
||||
}
|
||||
|
||||
@@ -219,7 +231,15 @@ export async function obtenerCotizacionPublica(req: Request, res: Response): Pro
|
||||
folio: folioCotizacion(cotizacion.id),
|
||||
desarrollo: cotizacion.desarrollo,
|
||||
unidadCodigo: cotizacion.unidadCodigo,
|
||||
// Esquema único FRASA 2026 (filas nuevas) + legacy (filas BANCARIO, …)
|
||||
esquema: cotizacion.esquema,
|
||||
tipoPropuesta: cotizacion.tipoPropuesta,
|
||||
precioLista: cotizacion.precioLista,
|
||||
apartado: cotizacion.apartado,
|
||||
saldoFirma: cotizacion.saldoFirma,
|
||||
engancheDiferible: cotizacion.engancheDiferible,
|
||||
escrituracion: cotizacion.escrituracion,
|
||||
escrituracionDiferida: cotizacion.escrituracionDiferida,
|
||||
precioFinal: cotizacion.precioFinal,
|
||||
enganche: cotizacion.enganche,
|
||||
numMensualidades: cotizacion.numMensualidades,
|
||||
|
||||
109
api-gateway/src/services/cotizacion-esquema.service.test.ts
Normal file
109
api-gateway/src/services/cotizacion-esquema.service.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
calcularEsquemaFrasa,
|
||||
validarEsquemaInput,
|
||||
PCT_ENGANCHE_FIRMA,
|
||||
PCT_ENGANCHE,
|
||||
PCT_ESCRITURACION,
|
||||
MENSUALIDADES_DEFAULT,
|
||||
MENSUALIDADES_MIN,
|
||||
MENSUALIDADES_MAX,
|
||||
} from './cotizacion-esquema.service.js'
|
||||
|
||||
/**
|
||||
* Esquema único FRASA 2026: apartado libre + enganche de firma 2.5% +
|
||||
* enganche 7.5% diferible + escrituración 5.5% (firma o diferida). Servicio
|
||||
* puro (sin prisma): el API recalcula server-side y no confía en los montos
|
||||
* del cliente. Debe dar EXACTAMENTE los mismos números que
|
||||
* apps/cotizador/src/lib/esquema-frasa.ts (caso dorado del cliente).
|
||||
*/
|
||||
|
||||
/** 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), 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)
|
||||
})
|
||||
})
|
||||
103
api-gateway/src/services/cotizacion-esquema.service.ts
Normal file
103
api-gateway/src/services/cotizacion-esquema.service.ts
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user