feat(cotizador): enganche adicional hasta 90% de anticipo total (UI, opciones de credito, PDF, API y persistencia)

This commit is contained in:
2026-09-29 21:08:02 +00:00
parent ed367582c3
commit 013e13b9df
15 changed files with 309 additions and 12 deletions

View File

@@ -0,0 +1,6 @@
-- Enganche adicional (anticipo extra) del esquema FRASA 2026: el cliente puede
-- dar hasta 90% de anticipo total (esquema + adicional), quedando un mínimo
-- del 10% del precio por financiar. 0 por default (no aplica a filas viejas).
-- Patrón manual: sqlite3 + _prisma_migrations. NUNCA migrate dev.
ALTER TABLE "Cotizacion" ADD COLUMN "engancheAdicional" REAL NOT NULL DEFAULT 0;

View File

@@ -41,6 +41,7 @@ model Cotizacion {
pctEscrituracion Float? // 0.055
escrituracionDiferida Boolean @default(false)
escrituracion Float? // gastos de escrituración (5.5% del precio)
engancheAdicional Float? @default(0) // anticipo extra; el total no puede pasar del 90% del precio
// Opciones de credito del saldo (84.5%): JSON {elegida, ajustes} serializado;
// el cotizador manda el ajuste del vendedor y se persiste crudo.
opcionesCredito String?

View File

@@ -516,3 +516,27 @@ describe('alertasInventarioEndpoint (POST /api/cotizaciones/alertas-inventario)
expect(prismaMock.actividadLocal.create).not.toHaveBeenCalled()
})
})
describe('crearCotizacion — enganche adicional', () => {
it('persiste el enganche adicional y recalcula el saldo a financiar', async () => {
const { res, json } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, engancheAdicional: 400000 }), res)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ engancheAdicional: 400000 }),
})
const respuesta = json.mock.calls[0]?.[0] as { esquema: { engancheAdicional: number; saldoAFinanciar: number } }
expect(respuesta.esquema.engancheAdicional).toBe(400000)
expect(respuesta.esquema.saldoAFinanciar).toBeCloseTo(445000, 2)
})
it('rechaza con 400 un adicional que pase del 90% de enganche total', async () => {
const { res } = resFake()
await expect(
crearCotizacion(reqFake({ ...bodyBase, engancheAdicional: 900000 }), res),
).rejects.toBeDefined()
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
})
})

View File

@@ -41,6 +41,9 @@ const crearCotizacionSchema = z.object({
.max(MENSUALIDADES_MAX, `Máximo ${MENSUALIDADES_MAX} mensualidades`)
.optional(),
escrituracionDiferida: z.boolean().optional(),
// Enganche adicional (anticipo extra): el total de anticipos (esquema +
// adicional) no puede superar el 90% del precio; el espejo lo valida
engancheAdicional: z.number().min(0, 'El enganche adicional no puede ser negativo').optional(),
// Opciones de credito del saldo (84.5%): JSON serializado {elegida, ajustes}
// que manda el cotizador; se persiste crudo y se devuelve en los GET
opcionesCredito: z.string().max(4000).optional(),
@@ -117,12 +120,21 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
const numMensualidades = Math.min(body.numMensualidades ?? MENSUALIDADES_DEFAULT, topeMensualidades)
const escrituracionDiferida = body.escrituracionDiferida ?? false
const precioLista = obtenerPrecioLista(unidad, body.acabados)
const esquema: EsquemaFrasaResultado = calcularEsquemaFrasa({
precio: precioLista,
apartado,
numMensualidades,
escrituracionDiferida,
})
// Enganche adicional: el total de anticipos no puede pasar del 90% del
// precio (espejo valida; se traduce a 400 con el mensaje del negocio)
const esquema: EsquemaFrasaResultado = (() => {
try {
return calcularEsquemaFrasa({
precio: precioLista,
apartado,
numMensualidades,
escrituracionDiferida,
...(body.engancheAdicional !== undefined ? { engancheAdicional: body.engancheAdicional } : {}),
})
} catch (err) {
throw new AppError(400, err instanceof Error ? err.message : 'Datos del esquema inválidos')
}
})()
const cotizacion = await prisma.cotizacion.create({
data: {
@@ -143,6 +155,7 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
pctEscrituracion: PCT_ESCRITURACION,
escrituracionDiferida,
escrituracion: esquema.escrituracion,
engancheAdicional: esquema.engancheAdicional,
// Opciones de credito elegidas por el vendedor (JSON crudo)
opcionesCredito: body.opcionesCredito ?? null,
// Columnas legacy, rellenadas para compatibilidad de reportes:

View File

@@ -4,6 +4,7 @@ import {
validarEsquemaInput,
PCT_ENGANCHE_FIRMA,
PCT_ENGANCHE,
PCT_ENGANCHE_MAXIMO,
PCT_ESCRITURACION,
MENSUALIDADES_DEFAULT,
MENSUALIDADES_MIN,
@@ -107,3 +108,28 @@ describe('validarEsquemaInput — guardas del esquema', () => {
expect(MENSUALIDADES_MAX).toBe(36)
})
})
describe('calcularEsquemaFrasa (API) — enganche adicional (hasta 90% de anticipo)', () => {
const base = { precio: 1000000, apartado: 15000, numMensualidades: 14, escrituracionDiferida: false }
it('sin adicional: 0 y saldo a financiar del 84.5%', () => {
const r = calcularEsquemaFrasa(base)
expect(r.engancheAdicional).toBe(0)
expect(r.saldoAFinanciar).toBeCloseTo(845000, 2)
})
it('el adicional reduce el saldo a financiar peso a peso', () => {
const r = calcularEsquemaFrasa({ ...base, engancheAdicional: 400000 })
expect(r.saldoAFinanciar).toBeCloseTo(445000, 2)
expect(r.totalPagado).toBeCloseTo(155000, 2)
})
it('rechaza pasar del 90% de enganche total', () => {
expect(PCT_ENGANCHE_MAXIMO).toBe(0.9)
expect(() => calcularEsquemaFrasa({ ...base, engancheAdicional: 745001 })).toThrow(/90%/)
})
it('rechaza adicional negativo', () => {
expect(validarEsquemaInput({ ...base, engancheAdicional: -1 })).toMatch(/adicional/i)
})
})

View File

@@ -20,6 +20,9 @@
export const PCT_ENGANCHE_FIRMA = 0.025
export const PCT_ENGANCHE = 0.075
export const PCT_ESCRITURACION = 0.055
/** Tope total de enganche (esquema + adicional): el cliente puede dar hasta
* 90% de anticipo, quedando un mínimo del 10% del precio a financiar */
export const PCT_ENGANCHE_MAXIMO = 0.9
export const MENSUALIDADES_DEFAULT = 14
export const MENSUALIDADES_MIN = 1
@@ -34,6 +37,9 @@ export interface EsquemaFrasaInput {
numMensualidades: number
/** true = la escrituración se difiere con el enganche; false = se paga a la firma */
escrituracionDiferida: boolean
/** Enganche adicional (anticipo extra, >= 0): opcional; el total de
* anticipos (esquema + adicional) no puede superar el 90% del precio */
engancheAdicional?: number
}
export interface EsquemaFrasaResultado {
@@ -57,6 +63,10 @@ export interface EsquemaFrasaResultado {
mensualidad: number
/** apartado + firma + diferible — debe cuadrar con el precio saldado */
totalPagado: number
/** Anticipo extra sobre el esquema (0 si no se captura) */
engancheAdicional: number
/** Lo que queda por financiar: precio − esquema − adicional (mínimo 10%) */
saldoAFinanciar: number
}
/** Error de validación de entrada; null si es válida */
@@ -67,6 +77,8 @@ export function validarEsquemaInput(input: EsquemaFrasaInput): string | null {
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`
const adicional = input.engancheAdicional ?? 0
if (!Number.isFinite(adicional) || adicional < 0) return 'El enganche adicional no puede ser negativo'
return null
}
@@ -75,6 +87,7 @@ export function calcularEsquemaFrasa(input: EsquemaFrasaInput): EsquemaFrasaResu
if (error) throw new Error(error)
const { precio, apartado, numMensualidades, escrituracionDiferida } = input
const engancheAdicional = input.engancheAdicional ?? 0
const engancheFirmaTotal = precio * PCT_ENGANCHE_FIRMA
const saldoFirma = Math.max(0, engancheFirmaTotal - apartado)
const excedenteApartado = Math.max(0, apartado - engancheFirmaTotal)
@@ -84,6 +97,14 @@ export function calcularEsquemaFrasa(input: EsquemaFrasaInput): EsquemaFrasaResu
const baseDiferible = enganche + (escrituracionDiferida ? escrituracion : 0)
const mensualidad = baseDiferible / numMensualidades
const totalPagado = apartado + pagoUnicoFirma + baseDiferible
// El anticipo total (esquema + adicional) no puede superar el 90% del precio
if (totalPagado + engancheAdicional > precio * PCT_ENGANCHE_MAXIMO + 1e-9) {
throw new Error(
`El enganche total no puede superar el ${PCT_ENGANCHE_MAXIMO * 100}% del precio (` +
`máx. ${(precio * PCT_ENGANCHE_MAXIMO - totalPagado).toFixed(2)} de enganche adicional)`,
)
}
const saldoAFinanciar = precio - totalPagado - engancheAdicional
return {
precio,
@@ -99,5 +120,7 @@ export function calcularEsquemaFrasa(input: EsquemaFrasaInput): EsquemaFrasaResu
numMensualidades,
mensualidad,
totalPagado,
engancheAdicional,
saldoAFinanciar,
}
}