diff --git a/api-gateway/prisma/migrations/20260929120000_cotizacion_enganche_adicional/migration.sql b/api-gateway/prisma/migrations/20260929120000_cotizacion_enganche_adicional/migration.sql new file mode 100644 index 0000000..9099181 --- /dev/null +++ b/api-gateway/prisma/migrations/20260929120000_cotizacion_enganche_adicional/migration.sql @@ -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; diff --git a/api-gateway/prisma/schema.prisma b/api-gateway/prisma/schema.prisma index 0c64ad0..5c26249 100644 --- a/api-gateway/prisma/schema.prisma +++ b/api-gateway/prisma/schema.prisma @@ -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? diff --git a/api-gateway/src/controllers/cotizaciones.controller.test.ts b/api-gateway/src/controllers/cotizaciones.controller.test.ts index a0d1d23..cf266ea 100644 --- a/api-gateway/src/controllers/cotizaciones.controller.test.ts +++ b/api-gateway/src/controllers/cotizaciones.controller.test.ts @@ -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() + }) +}) diff --git a/api-gateway/src/controllers/cotizaciones.controller.ts b/api-gateway/src/controllers/cotizaciones.controller.ts index 6927d15..9f610bc 100644 --- a/api-gateway/src/controllers/cotizaciones.controller.ts +++ b/api-gateway/src/controllers/cotizaciones.controller.ts @@ -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 { + 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 { 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) + }) +}) diff --git a/api-gateway/src/services/cotizacion-esquema.service.ts b/api-gateway/src/services/cotizacion-esquema.service.ts index b6e403e..bc02d8c 100644 --- a/api-gateway/src/services/cotizacion-esquema.service.ts +++ b/api-gateway/src/services/cotizacion-esquema.service.ts @@ -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, } } diff --git a/apps/cotizador/src/components/EsquemaPanel.tsx b/apps/cotizador/src/components/EsquemaPanel.tsx index 452325f..747eb37 100644 --- a/apps/cotizador/src/components/EsquemaPanel.tsx +++ b/apps/cotizador/src/components/EsquemaPanel.tsx @@ -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)} + {esquema.engancheAdicional > 0 && ( +
+
Enganche adicional
+
+ {formatoMoneda(esquema.engancheAdicional)} +
+
+ )} +
+
+ Saldo a financiar +
+
+ {formatoMoneda(esquema.saldoAFinanciar)} + + {formatoPorcentaje(esquema.saldoAFinanciar / esquema.precio)} del precio + +
+
+ {/* Enganche adicional: anticipo extra hasta completar 90% del precio */} +
+ +
+ $ + 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]" + /> +
+

+ 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) +

+
+ {/* Controles del diferimiento */}
diff --git a/apps/cotizador/src/hooks/useCotizacion.ts b/apps/cotizador/src/hooks/useCotizacion.ts index b34e201..b14a6c8 100644 --- a/apps/cotizador/src/hooks/useCotizacion.ts +++ b/apps/cotizador/src/hooks/useCotizacion.ts @@ -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(APARTADO_ESQUEMA_DEFAULT) const [numMensualidades, setNumMensualidades] = useState(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(() => { 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, diff --git a/apps/cotizador/src/lib/esquema-frasa.test.ts b/apps/cotizador/src/lib/esquema-frasa.test.ts index a4d007c..4d3c2ab 100644 --- a/apps/cotizador/src/lib/esquema-frasa.test.ts +++ b/apps/cotizador/src/lib/esquema-frasa.test.ts @@ -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) + }) +}) diff --git a/apps/cotizador/src/lib/esquema-frasa.ts b/apps/cotizador/src/lib/esquema-frasa.ts index 9b2d7f3..3623d03 100644 --- a/apps/cotizador/src/lib/esquema-frasa.ts +++ b/apps/cotizador/src/lib/esquema-frasa.ts @@ -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, } } diff --git a/apps/cotizador/src/lib/opciones-credito.test.ts b/apps/cotizador/src/lib/opciones-credito.test.ts index f092b5f..389297b 100644 --- a/apps/cotizador/src/lib/opciones-credito.test.ts +++ b/apps/cotizador/src/lib/opciones-credito.test.ts @@ -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) + }) +}) diff --git a/apps/cotizador/src/lib/opciones-credito.ts b/apps/cotizador/src/lib/opciones-credito.ts index ef7c0bc..4d2c926 100644 --- a/apps/cotizador/src/lib/opciones-credito.ts +++ b/apps/cotizador/src/lib/opciones-credito.ts @@ -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) diff --git a/apps/cotizador/src/lib/pdf.ts b/apps/cotizador/src/lib/pdf.ts index ac5f7fd..ec5cc93 100644 --- a/apps/cotizador/src/lib/pdf.ts +++ b/apps/cotizador/src/lib/pdf.ts @@ -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 diff --git a/apps/cotizador/src/pages/CotizadorPage.tsx b/apps/cotizador/src/pages/CotizadorPage.tsx index f2f5e5e..7c75b09 100644 --- a/apps/cotizador/src/pages/CotizadorPage.tsx +++ b/apps/cotizador/src/pages/CotizadorPage.tsx @@ -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} diff --git a/apps/cotizador/src/services/api.ts b/apps/cotizador/src/services/api.ts index 0d23de9..0dea3c7 100644 --- a/apps/cotizador/src/services/api.ts +++ b/apps/cotizador/src/services/api.ts @@ -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 */