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,
}
}

View File

@@ -3,6 +3,7 @@ import {
MENSUALIDADES_MIN,
PCT_ENGANCHE,
PCT_ENGANCHE_FIRMA,
PCT_ENGANCHE_MAXIMO,
PCT_ESCRITURACION,
type EsquemaFrasaResultado,
} from '../lib/esquema-frasa'
@@ -15,6 +16,11 @@ interface Props {
fechaEntrega?: string | null
/** Tope de mensualidades: los meses que faltan para la entrega (default 36 sin fecha) */
maxMensualidades?: number
/** Enganche adicional capturado (>= 0) */
engancheAdicional: number
onEngancheAdicional: (v: number) => void
/** Máximo de enganche adicional permitido por el tope del 90% de anticipo */
engancheAdicionalMaximo: number
numMensualidades: number
onNumMensualidades: (n: number) => void
escrituracionDiferida: boolean
@@ -38,6 +44,9 @@ export function EsquemaPanel({
esquema,
fechaEntrega,
maxMensualidades = MENSUALIDADES_MAX,
engancheAdicional,
onEngancheAdicional,
engancheAdicionalMaximo,
numMensualidades,
onNumMensualidades,
escrituracionDiferida,
@@ -132,9 +141,53 @@ export function EsquemaPanel({
{formatoMoneda(esquema.totalPagado)}
</dd>
</div>
{esquema.engancheAdicional > 0 && (
<div className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between">
<dt className="text-sm text-[#64748B] dark:text-[#94A3B8]">Enganche adicional</dt>
<dd className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
{formatoMoneda(esquema.engancheAdicional)}
</dd>
</div>
)}
<div className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between">
<dt className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Saldo a financiar
</dt>
<dd className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
{formatoMoneda(esquema.saldoAFinanciar)}
<span className="ml-2 text-xs font-normal text-[#64748B] dark:text-[#94A3B8]">
{formatoPorcentaje(esquema.saldoAFinanciar / esquema.precio)} del precio
</span>
</dd>
</div>
</dl>
</section>
{/* Enganche adicional: anticipo extra hasta completar 90% del precio */}
<section className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<label htmlFor="enganche-adicional" className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
Enganche adicional (opcional)
</label>
<div className="flex items-center gap-2">
<span className="text-sm text-[#64748B] dark:text-[#94A3B8]">$</span>
<input
id="enganche-adicional"
type="number"
min={0}
max={engancheAdicionalMaximo}
step={1000}
value={engancheAdicional === 0 ? '' : engancheAdicional}
placeholder="0"
onChange={(e) => onEngancheAdicional(e.target.value === '' ? 0 : Number(e.target.value))}
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]"
/>
</div>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
Anticipo extra del cliente; máximo {formatoMoneda(engancheAdicionalMaximo)} para no pasar del{' '}
{formatoPorcentaje(PCT_ENGANCHE_MAXIMO)} de enganche total (queda un mínimo del 10% por financiar)
</p>
</section>
{/* Controles del diferimiento */}
<section className="flex flex-col gap-4 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm sm:flex-row sm:items-end">
<div className="flex-1">

View File

@@ -6,6 +6,7 @@ import {
MENSUALIDADES_DEFAULT,
MENSUALIDADES_MAX,
MENSUALIDADES_MIN,
PCT_ENGANCHE_MAXIMO,
calcularEsquemaFrasa,
type EsquemaFrasaResultado,
} from '../lib/esquema-frasa'
@@ -53,6 +54,12 @@ interface UseCotizacionResult {
/** true = la escrituración (5.5%) se difiere con el enganche; false = se paga a la firma */
escrituracionDiferida: boolean
setEscrituracionDiferida: (v: boolean) => void
/** Enganche adicional (anticipo extra, opcional): el total de anticipos no
* puede superar el 90% del precio (mínimo 10% queda por financiar) */
engancheAdicional: number
setEngancheAdicional: (v: number) => void
/** Máximo de enganche adicional permitido por el tope del 90% (0 sin unidad) */
engancheAdicionalMaximo: number
precioLista: number
/** Esquema FRASA 2026 calculado; null mientras no hay unidad elegida */
resultado: EsquemaFrasaResultado | null
@@ -74,6 +81,8 @@ export interface EstadoRestaurar {
numMensualidades?: number
/** Escrituración diferida guardada (default false) */
escrituracionDiferida?: boolean
/** Enganche adicional guardado (default 0) */
engancheAdicional?: number
}
/**
@@ -91,6 +100,7 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac
const [apartado, setApartado] = useState<number>(APARTADO_ESQUEMA_DEFAULT)
const [numMensualidades, setNumMensualidades] = useState<number>(MENSUALIDADES_DEFAULT)
const [escrituracionDiferida, setEscrituracionDiferida] = useState(false)
const [engancheAdicional, setEngancheAdicionalState] = useState(0)
const params = desarrolloEfectivo(reglas, desarrollo)
@@ -115,6 +125,12 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac
setNumMensualidades(Math.max(MENSUALIDADES_MIN, Math.min(n, topeMensualidades())))
}
/** Enganche adicional: acotado a >= 0; el esquema valida que el anticipo
* total no supere el 90% del precio (mínimo 10% por financiar) */
const setEngancheAdicional = (v: number) => {
setEngancheAdicionalState(Number.isFinite(v) ? Math.max(0, v) : 0)
}
const restaurar = (estado: EstadoRestaurar) => {
setDesarrolloState(estado.desarrollo)
setUnidad(estado.unidad)
@@ -126,6 +142,7 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac
Math.min(estado.numMensualidades ?? MENSUALIDADES_DEFAULT, mensualidadesParaUnidad(estado.unidad)),
)
setEscrituracionDiferida(estado.escrituracionDiferida ?? false)
setEngancheAdicionalState(Math.max(0, estado.engancheAdicional ?? 0))
}
const nueva = () => {
@@ -134,6 +151,7 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac
setApartado(APARTADO_ESQUEMA_DEFAULT)
setNumMensualidades(MENSUALIDADES_DEFAULT)
setEscrituracionDiferida(false)
setEngancheAdicionalState(0)
}
const numMeses = useMemo(
@@ -150,15 +168,36 @@ 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)
/**
* Esquema FRASA 2026 calculado; null mientras no hay unidad elegida. El
* enganche adicional se acota al máximo que permite el 90% de anticipo
* (el esquema lanza error si el total lo rebasa; aquí se previene).
*/
const resultado = useMemo<EsquemaFrasaResultado | null>(() => {
if (!unidad || precioLista <= 0) return null
return calcularEsquemaFrasa({
const base = calcularEsquemaFrasa({
precio: precioLista,
apartado: apartadoEfectivo,
numMensualidades,
escrituracionDiferida,
})
}, [unidad, precioLista, apartadoEfectivo, numMensualidades, escrituracionDiferida])
const maxAdicional = Math.max(0, precioLista * PCT_ENGANCHE_MAXIMO - base.totalPagado)
const adicionalEfectivo = Math.min(Math.max(0, engancheAdicional), maxAdicional)
if (adicionalEfectivo === 0) return base
return calcularEsquemaFrasa({
precio: precioLista,
apartado: apartadoEfectivo,
numMensualidades,
escrituracionDiferida,
engancheAdicional: adicionalEfectivo,
})
}, [unidad, precioLista, apartadoEfectivo, numMensualidades, escrituracionDiferida, engancheAdicional])
/** Máximo de enganche adicional que permite el 90% de anticipo (0 sin unidad) */
const engancheAdicionalMaximo = useMemo(() => {
if (!resultado) return 0
return Math.max(0, precioLista * PCT_ENGANCHE_MAXIMO - (resultado.totalPagado - resultado.engancheAdicional))
}, [resultado, precioLista])
return {
desarrollo,
@@ -175,6 +214,9 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac
setNumMensualidades: ajustarMensualidades,
escrituracionDiferida,
setEscrituracionDiferida,
engancheAdicional,
setEngancheAdicional,
engancheAdicionalMaximo,
precioLista,
resultado,
numMeses,

View File

@@ -79,3 +79,37 @@ describe('calcularEsquemaFrasa — mensualidades', () => {
expect(validarEsquemaInput({ precio: 1000000, apartado: 15000, numMensualidades: 14, escrituracionDiferida: false })).toBeNull()
})
})
describe('calcularEsquemaFrasa — enganche adicional (hasta 90% de anticipo)', () => {
const base = { precio: 1000000, apartado: 15000, numMensualidades: 14, escrituracionDiferida: false }
it('sin adicional: queda en 0 y el saldo a financiar es el 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.engancheAdicional).toBe(400000)
expect(r.totalPagado).toBeCloseTo(155000, 2)
expect(r.saldoAFinanciar).toBeCloseTo(445000, 2)
// El esquema base no cambia: el adicional es aparte
expect(r.enganche).toBeCloseTo(75000, 2)
expect(r.mensualidad).toBeCloseTo(75000 / 14, 2)
})
it('tope exacto: 90% del precio menos el esquema (745,000) es válido', () => {
const r = calcularEsquemaFrasa({ ...base, engancheAdicional: 745000 })
expect(r.saldoAFinanciar).toBeCloseTo(100000, 2)
})
it('rechaza pasar del 90% de enganche total', () => {
expect(() => calcularEsquemaFrasa({ ...base, engancheAdicional: 745001 })).toThrow(/90%/)
expect(() => calcularEsquemaFrasa({ ...base, engancheAdicional: 900000 })).toThrow(/90%/)
})
it('rechaza adicional negativo', () => {
expect(validarEsquemaInput({ ...base, engancheAdicional: -1 })).toMatch(/adicional/i)
})
})

View File

@@ -17,6 +17,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
@@ -31,6 +34,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 {
@@ -54,6 +60,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 */
@@ -64,6 +74,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
}
@@ -72,6 +84,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)
@@ -81,6 +94,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,
@@ -96,5 +117,7 @@ export function calcularEsquemaFrasa(input: EsquemaFrasaInput): EsquemaFrasaResu
numMensualidades,
mensualidad,
totalPagado,
engancheAdicional,
saldoAFinanciar,
}
}

View File

@@ -182,3 +182,21 @@ describe('calcularOpcionCredito — tasa cero', () => {
expect(r.interesesEstimados).toBe(Number((mensualidad * 240 - 845000).toFixed(2)))
})
})
describe('calcularOpcionCredito — enganche adicional', () => {
it('el saldo a financiar de cada opción toma el adicional (y se acota al 90%)', () => {
const base = {
precioLista: 1000000,
apartado: 15000,
numMensualidades: 14,
escrituracionDiferida: false,
ajustes: AJUSTES_DEFAULT.CREDITO_BANCARIO,
}
const r = calcularOpcionCredito({ ...base, tipo: 'CREDITO_BANCARIO', engancheAdicional: 400000 })
expect(r.saldoAFinanciar).toBe(445000)
expect(r.montoCredito).toBe(445000)
// Contado: precio final 980,000 → saldo 828,100 − 400,000 = 428,100 de contado
const contado = calcularOpcionCredito({ ...base, tipo: 'CONTADO', ajustes: AJUSTES_DEFAULT.CONTADO, engancheAdicional: 400000 })
expect(contado.saldoAFinanciar).toBe(428100)
})
})

View File

@@ -9,7 +9,7 @@
* Módulo puro (sin UI, sin API) para probarlo a fondo.
*/
import { calcularEsquemaFrasa, type EsquemaFrasaResultado } from './esquema-frasa'
import { PCT_ENGANCHE_MAXIMO, calcularEsquemaFrasa, type EsquemaFrasaResultado } from './esquema-frasa'
export type TipoCredito =
| 'CREDITO_BANCARIO'
@@ -68,6 +68,8 @@ export interface EntradaOpcion {
apartado: number
numMensualidades: number
escrituracionDiferida: boolean
/** Enganche adicional (monto absoluto, >= 0): se acota al 90% del anticipo */
engancheAdicional?: number
tipo: TipoCredito
ajustes: AjustesCredito
}
@@ -135,14 +137,28 @@ export function calcularOpcionCredito(e: EntradaOpcion): ResultadoOpcionCredito
const montoDescuento = round2(e.precioLista * descuento)
const precioFinal = e.precioLista - montoDescuento
const esquema = calcularEsquemaFrasa({
const esquemaBase = calcularEsquemaFrasa({
precio: precioFinal,
apartado: e.apartado,
numMensualidades: e.numMensualidades,
escrituracionDiferida: e.escrituracionDiferida,
})
// El adicional se acota igual que en el hook: el anticipo total no puede
// superar el 90% del precio final de la opción
const adicionalSolicitado = Math.max(0, e.engancheAdicional ?? 0)
const adicionalEfectivo = Math.min(adicionalSolicitado, round2(precioFinal * PCT_ENGANCHE_MAXIMO - esquemaBase.totalPagado))
const esquema =
adicionalEfectivo > 0
? calcularEsquemaFrasa({
precio: precioFinal,
apartado: e.apartado,
numMensualidades: e.numMensualidades,
escrituracionDiferida: e.escrituracionDiferida,
engancheAdicional: adicionalEfectivo,
})
: esquemaBase
const saldoAFinanciar = round2(precioFinal - esquema.totalPagado)
const saldoAFinanciar = round2(esquema.saldoAFinanciar)
const esContado = e.tipo === 'CONTADO'
const pctContado = e.tipo === 'SEMI_CONTADO' ? (e.ajustes.pctContado ?? 0) : 0
const pagoContado = esContado ? 0 : round2(saldoAFinanciar * pctContado)

View File

@@ -178,6 +178,10 @@ export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, esq
formatoMoneda(esquema.baseDiferible),
],
['TOTAL DEL ESQUEMA', '', '', formatoMoneda(esquema.totalPagado)],
...(esquema.engancheAdicional > 0
? [['ENGANCHE ADICIONAL (ANTICIPO EXTRA)', '', '', formatoMoneda(esquema.engancheAdicional)]]
: []),
['SALDO A FINANCIAR', '', '', formatoMoneda(esquema.saldoAFinanciar)],
],
})
y = finalY(doc) + 6

View File

@@ -82,6 +82,9 @@ export default function CotizadorPage() {
setNumMensualidades,
escrituracionDiferida,
setEscrituracionDiferida,
engancheAdicional,
setEngancheAdicional,
engancheAdicionalMaximo,
precioLista,
resultado,
numMeses,
@@ -186,6 +189,7 @@ export default function CotizadorPage() {
apartado: apartadoEfectivo,
numMensualidades,
escrituracionDiferida,
engancheAdicional: resultado?.engancheAdicional ?? 0,
tipo,
ajustes: ajustes[tipo],
}),
@@ -201,7 +205,7 @@ export default function CotizadorPage() {
return []
}
}
}, [precioLista, apartadoEfectivo, numMensualidades, escrituracionDiferida, ajustesCredito])
}, [precioLista, apartadoEfectivo, numMensualidades, escrituracionDiferida, ajustesCredito, resultado])
// Prefill desde la URL (botón "Cotizar" de Mi día): cliente, contacto,
// desarrollo, unidad y el id de la oportunidad a vincular al guardar
@@ -303,6 +307,7 @@ export default function CotizadorPage() {
engancheDiferible: resultado.enganche,
escrituracion: resultado.escrituracion,
escrituracionDiferida,
engancheAdicional: resultado.engancheAdicional,
enganche: resultado.enganche,
numMensualidades,
mensualidad: resultado.mensualidad,
@@ -331,6 +336,7 @@ export default function CotizadorPage() {
apartado: apartadoEfectivo,
numMensualidades,
escrituracionDiferida,
engancheAdicional: resultado.engancheAdicional,
opcionesCredito: JSON.stringify({ elegida: opcionElegida, ajustes: ajustesCredito }),
...(oportunidadId !== null ? { oportunidadId } : {}),
})
@@ -496,6 +502,7 @@ export default function CotizadorPage() {
? {
numMensualidades: c.numMensualidades > 0 ? c.numMensualidades : undefined,
escrituracionDiferida: c.escrituracionDiferida,
engancheAdicional: c.engancheAdicional ?? 0,
}
: {}),
})
@@ -785,6 +792,9 @@ export default function CotizadorPage() {
esquema={resultado}
fechaEntrega={unidad?.fechaEntrega ?? null}
maxMensualidades={unidad ? mensualidadesParaUnidad(unidad) : MENSUALIDADES_MAX}
engancheAdicional={engancheAdicional}
onEngancheAdicional={setEngancheAdicional}
engancheAdicionalMaximo={engancheAdicionalMaximo}
numMensualidades={numMensualidades}
onNumMensualidades={setNumMensualidades}
escrituracionDiferida={escrituracionDiferida}

View File

@@ -103,6 +103,8 @@ export interface CotizacionGuardada {
escrituracion?: number | null
/** true = escrituración diferida con el enganche (esquema nuevo) */
escrituracionDiferida?: boolean
/** Enganche adicional (anticipo extra; esquema nuevo, 0 por default) */
engancheAdicional?: number | null
enganche: number
numMensualidades: number
mensualidad: number | null
@@ -124,6 +126,8 @@ export interface GuardarCotizacionPayload {
numMensualidades?: number
/** true = la escrituración (5.5%) se difiere con el enganche */
escrituracionDiferida?: boolean
/** Enganche adicional (anticipo extra >= 0); el total no puede pasar del 90% del precio */
engancheAdicional?: number
/** Item del tablero Oportunidades a vincular (botón "Cotizar" de Mi día) */
oportunidadId?: string
/** JSON serializado {elegida, ajustes} de las 4 opciones de crédito; el API lo guarda crudo */