feat(cotizador): enganche adicional hasta 90% de anticipo total (UI, opciones de credito, PDF, API y persistencia)
This commit is contained in:
@@ -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;
|
||||
@@ -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?
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user