feat: API Gateway (Express 5 + Prisma + Monday) y conexion de apps al API

- api-gateway: servicio Monday (retry 429/complejidad, paginacion cursor,
  column IDs por titulo), endpoints unidades/cotizaciones/clientes/planes,
  webhook con handshake challenge, Prisma SQLite dev, 55 tests
- cotizador: inventario desde /api/unidades con fallback a seed
- alta-clientes: POST /api/clientes + upload multipart de documentos
- cobranza: persistencia via API con respaldo offline en localStorage
- .env.example con board IDs del workspace Frasa-CAS
This commit is contained in:
2026-08-21 23:39:17 +00:00
parent ecd4edac02
commit 0546f35d2a
48 changed files with 7129 additions and 66 deletions

View File

@@ -0,0 +1,310 @@
import { describe, expect, it } from 'vitest'
import {
alCorriente,
avancePlan,
cobranzaDelDia,
construirNota,
diasAtraso,
generarCalendario,
moraDePago,
pagadoTotal,
proximoVencimiento,
round2,
saldoPlan,
statusPago,
statusPlan,
sumarMeses,
totalMora,
validarAbono,
type AbonoInput,
type Pago,
type PlanPago,
} from './cobranza.service.js'
const pagoBase = (override: Partial<Pago> = {}): Pago => ({
id: 'p1',
numPago: 1,
fechaVencimiento: '2026-02-15',
monto: 10000,
montoPagado: 0,
aplicaciones: [],
...override,
})
/**
* Helper de test: valida con `validarAbono` (la función real del servicio) y
* devuelve una copia del plan con el abono aplicado — el equivalente en
* memoria de lo que el controller persiste en Prisma.
*/
function aplicarAbono(plan: PlanPago, abono: AbonoInput): PlanPago {
const { pago, aplicacion } = validarAbono(plan, abono)
return {
...plan,
pagos: plan.pagos.map((p) =>
p.id === pago.id
? { ...p, montoPagado: round2(p.montoPagado + aplicacion.monto), aplicaciones: [...p.aplicaciones, aplicacion] }
: p,
),
}
}
function planDePrueba(): PlanPago {
const { mensualidad, pagos } = generarCalendario('2026-01-10', 30000, 3)
return {
id: 'plan-1',
ventaRef: 'VTA-001',
clienteNombre: 'Cliente Prueba',
unidadCodigo: 'U-1',
proyecto: 'SENDA',
fechaInicio: '2026-01-10',
engancheTotal: 30000,
numPagos: 3,
mensualidad,
status: 'ACTIVO',
pagos,
notas: [],
createdAt: '2026-01-01T00:00:00.000Z',
}
}
describe('generarCalendario', () => {
it('genera 12 pagos cuya suma es exactamente el enganche', () => {
const { mensualidad, pagos } = generarCalendario('2026-01-15', 180000, 12)
expect(pagos).toHaveLength(12)
expect(mensualidad).toBe(15000)
expect(round2(pagos.reduce((acc, p) => acc + p.monto, 0))).toBe(180000)
expect(pagos[0]?.numPago).toBe(1)
expect(pagos[11]?.numPago).toBe(12)
expect(pagos[0]?.fechaVencimiento).toBe('2026-01-15')
expect(pagos[11]?.fechaVencimiento).toBe('2026-12-15')
})
it('el último pago absorbe el residuo del redondeo (100000 / 3)', () => {
const { mensualidad, pagos } = generarCalendario('2026-03-01', 100000, 3)
expect(mensualidad).toBe(33333.33)
expect(pagos.map((p) => p.monto)).toEqual([33333.33, 33333.33, 33333.34])
expect(round2(pagos.reduce((acc, p) => acc + p.monto, 0))).toBe(100000)
})
it('ajusta el día cuando el mes no lo tiene (31 de enero → 28 de febrero)', () => {
const { pagos } = generarCalendario('2025-01-31', 30000, 3)
expect(pagos[0]?.fechaVencimiento).toBe('2025-01-31')
expect(pagos[1]?.fechaVencimiento).toBe('2025-02-28')
expect(pagos[2]?.fechaVencimiento).toBe('2025-03-31')
})
it('respeta año bisiesto (31 de enero 2024 → 29 de febrero)', () => {
expect(sumarMeses(new Date(2024, 0, 31), 1)).toEqual(new Date(2024, 1, 29))
})
it('rechaza parámetros inválidos', () => {
expect(() => generarCalendario('2026-01-01', 0, 6)).toThrow()
expect(() => generarCalendario('2026-01-01', 1000, 0)).toThrow()
})
})
describe('statusPago', () => {
const hoy = new Date('2026-02-16T00:00:00')
it('PAGADO cuando montoPagado cubre el monto', () => {
expect(statusPago(pagoBase({ montoPagado: 10000 }), hoy)).toBe('PAGADO')
})
it('PARCIAL con abono menor al monto, aunque ya venció', () => {
expect(statusPago(pagoBase({ montoPagado: 4000 }), hoy)).toBe('PARCIAL')
})
it('VENCIDO sin abono y hoy posterior al vencimiento', () => {
expect(statusPago(pagoBase(), hoy)).toBe('VENCIDO')
})
it('PENDIENTE sin abono y hoy en o antes del vencimiento', () => {
expect(statusPago(pagoBase(), new Date('2026-02-15T00:00:00'))).toBe('PENDIENTE')
expect(statusPago(pagoBase(), new Date('2026-02-01T00:00:00'))).toBe('PENDIENTE')
})
})
describe('validarAbono', () => {
it('aplica un abono parcial y lo agrega al historial', () => {
const plan = planDePrueba()
const actualizado = aplicarAbono(plan, {
pagoId: plan.pagos[0]!.id,
fechaPago: '2026-01-10',
monto: 4000,
referencia: 'SPEI-123',
})
expect(actualizado.pagos[0]?.montoPagado).toBe(4000)
expect(actualizado.pagos[0]?.aplicaciones).toHaveLength(1)
expect(actualizado.pagos[0]?.aplicaciones[0]?.referencia).toBe('SPEI-123')
expect(plan.pagos[0]?.montoPagado).toBe(0) // no muta el original
})
it('permite completar un pago con varios abonos', () => {
let plan = planDePrueba()
plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 4000, referencia: 'A1' })
plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-11', monto: 6000, referencia: 'A2' })
expect(plan.pagos[0]?.montoPagado).toBe(10000)
expect(plan.pagos[0]?.aplicaciones).toHaveLength(2)
expect(statusPago(plan.pagos[0]!, new Date('2026-01-12T00:00:00'))).toBe('PAGADO')
})
it('rechaza abonos que exceden el restante (estricto al centavo)', () => {
const plan = planDePrueba()
expect(() =>
validarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 10000.01, referencia: 'X1' }),
).toThrow(/excede el restante/)
})
it('rechaza abonos menores o iguales a 0', () => {
const plan = planDePrueba()
expect(() =>
validarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 0, referencia: 'X1' }),
).toThrow(/mayor a 0/)
expect(() =>
validarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: -500, referencia: 'X1' }),
).toThrow(/mayor a 0/)
})
it('rechaza un pagoId inexistente', () => {
const plan = planDePrueba()
expect(() =>
validarAbono(plan, { pagoId: 'no-existe', fechaPago: '2026-01-10', monto: 100, referencia: 'X1' }),
).toThrow(/no encontrado/)
})
})
describe('saldo, avance y status del plan', () => {
it('saldoPlan y pagadoTotal reflejan los abonos', () => {
let plan = planDePrueba()
expect(saldoPlan(plan)).toBe(30000)
expect(pagadoTotal(plan)).toBe(0)
plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 10000, referencia: 'A1' })
expect(saldoPlan(plan)).toBe(20000)
expect(pagadoTotal(plan)).toBe(10000)
expect(avancePlan(plan)).toBeCloseTo(1 / 3, 5)
})
it('el plan queda LIQUIDADO al cubrir todo el enganche', () => {
let plan = planDePrueba()
for (const pago of plan.pagos) {
plan = aplicarAbono(plan, {
pagoId: pago.id,
fechaPago: '2026-03-10',
monto: pago.monto - pago.montoPagado,
referencia: `LIQ-${pago.numPago}`,
})
}
expect(saldoPlan(plan)).toBe(0)
expect(statusPlan(plan)).toBe('LIQUIDADO')
})
it('CANCELADO prevalece aunque tenga saldo', () => {
const plan = { ...planDePrueba(), status: 'CANCELADO' as const }
expect(statusPlan(plan)).toBe('CANCELADO')
})
it('alCorriente es falso cuando hay pagos vencidos', () => {
expect(alCorriente(planDePrueba(), new Date('2026-02-15T00:00:00'))).toBe(false)
expect(alCorriente(planDePrueba(), new Date('2026-01-05T00:00:00'))).toBe(true)
})
})
describe('proximoVencimiento y diasAtraso', () => {
it('devuelve el primer pago no cubierto con fecha >= hoy', () => {
const prox = proximoVencimiento(planDePrueba(), new Date('2026-01-20T00:00:00'))
expect(prox?.numPago).toBe(2)
})
it('si no hay futuros, devuelve el vencido más antiguo', () => {
const prox = proximoVencimiento(planDePrueba(), new Date('2026-04-01T00:00:00'))
expect(prox?.numPago).toBe(1)
})
it('devuelve null cuando todo está cubierto', () => {
let plan = planDePrueba()
for (const pago of plan.pagos) {
plan = aplicarAbono(plan, { pagoId: pago.id, fechaPago: '2026-01-10', monto: pago.monto, referencia: 'X' })
}
expect(proximoVencimiento(plan, new Date('2026-01-15T00:00:00'))).toBeNull()
})
it('diasAtraso cuenta desde el vencido más antiguo', () => {
expect(diasAtraso(planDePrueba(), new Date('2026-01-25T00:00:00'))).toBe(15)
expect(diasAtraso(planDePrueba(), new Date('2026-01-10T00:00:00'))).toBe(0)
})
it('un pago PARCIAL vencido no cuenta como atraso', () => {
let plan = planDePrueba()
plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 100, referencia: 'A' })
expect(diasAtraso(plan, new Date('2026-02-01T00:00:00'))).toBe(0)
})
})
describe('moraDePago y totalMora', () => {
const hoy = new Date('2026-03-17T00:00:00') // pago de 10000 vencido el 15-feb → 30 días
it('es 0 cuando la tasa es 0, cuando no está vencido o cuando ya está cubierto', () => {
expect(moraDePago(pagoBase(), 0, hoy)).toBe(0)
expect(moraDePago(pagoBase(), 2, new Date('2026-02-15T00:00:00'))).toBe(0)
expect(moraDePago(pagoBase({ montoPagado: 10000 }), 2, hoy)).toBe(0)
})
it('cálculo exacto: restante × (tasa/100) / 30 × días vencidos', () => {
expect(moraDePago(pagoBase(), 2, hoy)).toBe(200) // 10000 × 0.02 / 30 × 30
expect(moraDePago(pagoBase(), 3, new Date('2026-02-25T00:00:00'))).toBe(100) // × 0.03 / 30 × 10
})
it('un pago PARCIAL vencido genera mora sobre su restante', () => {
expect(moraDePago(pagoBase({ montoPagado: 4000 }), 2, hoy)).toBe(120)
})
it('totalMora suma la mora de todos los pagos y usa tasa 0 por defecto', () => {
const plan = planDePrueba() // vence 10-ene, 10-feb, 10-mar; hoy 20-mar
const hoyPlan = new Date('2026-03-20T00:00:00')
expect(totalMora(plan, hoyPlan)).toBe(0)
const conTasa: PlanPago = { ...plan, tasaMoraMensual: 3 }
expect(totalMora(conTasa, hoyPlan)).toBe(1170) // 690 + 380 + 100
})
})
describe('cobranzaDelDia', () => {
it('incluye vencidos y los que vencen en los próximos 7 días, ordenados por urgencia', () => {
const hoy = new Date('2026-03-05T00:00:00')
const items = cobranzaDelDia([planDePrueba()], hoy)
expect(items.map((i) => i.pago.numPago)).toEqual([1, 2, 3])
expect(items.map((i) => i.atraso)).toEqual([54, 23, 0])
})
it('excluye pagos cubiertos, planes no activos y vencimientos lejanos', () => {
const hoy = new Date('2026-01-05T00:00:00')
let plan = planDePrueba()
plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-05', monto: 10000, referencia: 'A' })
const cancelado: PlanPago = { ...planDePrueba(), status: 'CANCELADO' }
expect(cobranzaDelDia([plan, cancelado], hoy)).toHaveLength(0)
const items40 = cobranzaDelDia([plan, cancelado], hoy, 40)
expect(items40.map((i) => i.pago.numPago)).toEqual([2])
})
it('un PARCIAL vencido aparece con sus días de atraso', () => {
const hoy = new Date('2026-02-01T00:00:00')
let plan = planDePrueba()
plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 4000, referencia: 'A' })
const items = cobranzaDelDia([plan], hoy)
expect(items[0]?.pago.numPago).toBe(1)
expect(items[0]?.atraso).toBe(22)
})
})
describe('construirNota', () => {
it('construye la nota con texto limpio, id y fecha', () => {
const nota = construirNota(' Cliente promete pago el viernes ')
expect(nota.texto).toBe('Cliente promete pago el viernes')
expect(nota.id).toBeTruthy()
expect(nota.fecha).toBeTruthy()
})
it('rechaza notas de menos de 3 caracteres', () => {
expect(() => construirNota(' ok ')).toThrow(/al menos 3/)
expect(() => construirNota('')).toThrow(/al menos 3/)
})
})

View File

@@ -0,0 +1,316 @@
import { randomUUID } from 'node:crypto'
/**
* Lógica de cobranza espejo de apps/cobranza/src/lib/cobranza.ts.
* Son funciones puras: los controllers mapean los registros de Prisma a
* estas formas, ejecutan la lógica y persisten los cambios.
*/
export type StatusPago = 'PENDIENTE' | 'PAGADO' | 'VENCIDO' | 'PARCIAL'
export type StatusPlan = 'ACTIVO' | 'LIQUIDADO' | 'CANCELADO'
export interface AplicacionPago {
id: string
fechaPago: string // ISO
monto: number
referencia: string
}
export interface Pago {
id: string
numPago: number
fechaVencimiento: string // 'YYYY-MM-DD'
monto: number
montoPagado: number
aplicaciones: AplicacionPago[]
}
export interface NotaPlan {
id: string
fecha: string // ISO
texto: string
}
export interface PlanPago {
id: string
ventaRef?: string | null
clienteNombre: string
clienteTelefono?: string | null
unidadCodigo: string
proyecto: string
fechaInicio: string // 'YYYY-MM-DD'
engancheTotal: number
numPagos: number
mensualidad: number
tasaMoraMensual?: number
status: StatusPlan
pagos: Pago[]
notas: NotaPlan[]
createdAt: string // ISO
}
/** Redondeo monetario a 2 decimales */
export const round2 = (x: number): number => Math.round(x * 100) / 100
/** Tolerancia para comparaciones de dinero (centavos) */
export const EPS = 0.01
const gte = (a: number, b: number): boolean => a >= b - EPS
/** Parsea 'YYYY-MM-DD' como fecha LOCAL (nunca `new Date(iso)` a secas, que interpreta UTC) */
export function parseFechaLocal(iso: string): Date {
return new Date(`${iso}T00:00:00`)
}
function aISO(fecha: Date): string {
const y = fecha.getFullYear()
const m = String(fecha.getMonth() + 1).padStart(2, '0')
const d = String(fecha.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
/** Último día del mes (mes 0-based) */
function ultimoDiaDelMes(anio: number, mes: number): number {
return new Date(anio, mes + 1, 0).getDate()
}
/**
* Suma `meses` conservando el día del mes; si el mes destino no tiene ese día,
* usa su último día (p. ej. 31 de enero + 1 mes → 28/29 de febrero).
*/
export function sumarMeses(fecha: Date, meses: number): Date {
const totalMeses = fecha.getMonth() + meses
const anio = fecha.getFullYear() + Math.floor(totalMeses / 12)
const mes = ((totalMeses % 12) + 12) % 12
const dia = Math.min(fecha.getDate(), ultimoDiaDelMes(anio, mes))
return new Date(anio, mes, dia)
}
/** Compara solo la parte de fecha (ignora horas) */
function compararFechas(a: Date, b: Date): number {
const da = new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime()
const db = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime()
return da - db
}
export interface CalendarioGenerado {
mensualidad: number
pagos: Pago[]
}
/**
* Genera N pagos con vencimiento mensual a partir de `fechaInicio`.
* mensualidad = round2(engancheTotal / numPagos); el ÚLTIMO pago absorbe
* el residuo del redondeo para que la suma sea exactamente `engancheTotal`.
*/
export function generarCalendario(
fechaInicio: string,
engancheTotal: number,
numPagos: number,
): CalendarioGenerado {
if (numPagos < 1) throw new Error('numPagos debe ser al menos 1')
if (engancheTotal <= 0) throw new Error('engancheTotal debe ser mayor a 0')
const inicio = parseFechaLocal(fechaInicio)
const mensualidad = round2(engancheTotal / numPagos)
const pagos: Pago[] = []
for (let i = 0; i < numPagos; i++) {
const monto =
i < numPagos - 1 ? mensualidad : round2(engancheTotal - mensualidad * (numPagos - 1))
pagos.push({
id: randomUUID(),
numPago: i + 1,
fechaVencimiento: aISO(sumarMeses(inicio, i)),
monto,
montoPagado: 0,
aplicaciones: [],
})
}
return { mensualidad, pagos }
}
/**
* Estado de un pago a la fecha `hoy`.
* PAGADO si cubrió el monto; PARCIAL si tiene abonos sin cubrir; sin abonos:
* VENCIDO si hoy > fechaVencimiento, si no PENDIENTE.
* Decisión de negocio: un pago PARCIAL cuya fecha ya venció sigue siendo
* PARCIAL (tiene abono aplicado); el atraso se señala vía `diasAtraso`.
*/
export function statusPago(pago: Pago, hoy: Date = new Date()): StatusPago {
if (gte(pago.montoPagado, pago.monto)) return 'PAGADO'
if (pago.montoPagado > EPS) return 'PARCIAL'
return compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) > 0 ? 'VENCIDO' : 'PENDIENTE'
}
/** Saldo pendiente del plan: suma(monto - montoPagado) */
export function saldoPlan(plan: PlanPago): number {
return round2(plan.pagos.reduce((acc, p) => acc + (p.monto - p.montoPagado), 0))
}
/** Total cobrado (suma de lo aplicado a todos los pagos) */
export function pagadoTotal(plan: PlanPago): number {
return round2(plan.pagos.reduce((acc, p) => acc + p.montoPagado, 0))
}
/** Fracción de avance 0..1 (pagado / engancheTotal) */
export function avancePlan(plan: PlanPago): number {
return plan.engancheTotal > 0 ? pagadoTotal(plan) / plan.engancheTotal : 0
}
const restante = (p: Pago): number => round2(p.monto - p.montoPagado)
const estaCubierto = (p: Pago): boolean => gte(p.montoPagado, p.monto)
/**
* Primer pago no cubierto con fecha >= hoy; si no hay, el vencido más antiguo.
* Devuelve null si el plan está cubierto por completo.
*/
export function proximoVencimiento(plan: PlanPago, hoy: Date = new Date()): Pago | null {
const noCubiertos = plan.pagos.filter((p) => !estaCubierto(p))
const futuro = noCubiertos.find((p) => compararFechas(parseFechaLocal(p.fechaVencimiento), hoy) >= 0)
if (futuro) return futuro
const vencidos = noCubiertos.sort(
(a, b) => parseFechaLocal(a.fechaVencimiento).getTime() - parseFechaLocal(b.fechaVencimiento).getTime(),
)
return vencidos[0] ?? null
}
/** Días naturales hasta el próximo vencimiento; null si el plan está cubierto */
export function diasParaProximoVencimiento(plan: PlanPago, hoy: Date = new Date()): number | null {
const prox = proximoVencimiento(plan, hoy)
if (!prox) return null
return Math.round(compararFechas(parseFechaLocal(prox.fechaVencimiento), hoy) / 86400000)
}
/** Días de atraso del pago vencido más antiguo (0 si no hay vencidos sin abono) */
export function diasAtraso(plan: PlanPago, hoy: Date = new Date()): number {
const vencidos = plan.pagos
.filter((p) => statusPago(p, hoy) === 'VENCIDO')
.sort(
(a, b) =>
parseFechaLocal(a.fechaVencimiento).getTime() - parseFechaLocal(b.fechaVencimiento).getTime(),
)
const primero = vencidos[0]
if (!primero) return 0
return Math.round(compararFechas(hoy, parseFechaLocal(primero.fechaVencimiento)) / 86400000)
}
/** Status derivado: CANCELADO solo manual; LIQUIDADO si saldo == 0; si no, ACTIVO */
export function statusPlan(plan: PlanPago): StatusPlan {
if (plan.status === 'CANCELADO') return 'CANCELADO'
return saldoPlan(plan) <= EPS ? 'LIQUIDADO' : 'ACTIVO'
}
/** Al corriente = sin pagos VENCIDOS */
export function alCorriente(plan: PlanPago, hoy: Date = new Date()): boolean {
return !plan.pagos.some((p) => statusPago(p, hoy) === 'VENCIDO')
}
/**
* Mora simple de un pago: restante × (tasa% / 100) / 30 × días vencidos.
* Solo aplica a pagos vencidos con saldo; un PARCIAL vencido también genera
* mora sobre su restante. Tasa 0 (o negativa) = sin mora.
*/
export function moraDePago(pago: Pago, tasaMoraMensual: number, hoy: Date = new Date()): number {
if (tasaMoraMensual <= 0) return 0
const falta = restante(pago)
if (falta <= EPS) return 0
const diasVencido = Math.round(compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) / 86400000)
if (diasVencido <= 0) return 0
return round2((falta * (tasaMoraMensual / 100) / 30) * diasVencido)
}
/** Mora acumulada del plan a la fecha `hoy` (usa plan.tasaMoraMensual ?? 0) */
export function totalMora(plan: PlanPago, hoy: Date = new Date()): number {
const tasa = plan.tasaMoraMensual ?? 0
return round2(plan.pagos.reduce((acc, p) => acc + moraDePago(p, tasa, hoy), 0))
}
export interface CobranzaItem {
plan: PlanPago
pago: Pago
/** Días de atraso (0 si aún no vence) */
atraso: number
}
/**
* Lista plana de "cobranza del día" a través de todos los planes ACTIVOS:
* pagos vencidos (incluye PARCIAL vencido) más los que vencen en los próximos
* `diasAnticipacion` días. Orden de urgencia: mayor atraso primero; a igual
* atraso, el vencimiento más próximo primero.
*/
export function cobranzaDelDia(
planes: PlanPago[],
hoy: Date = new Date(),
diasAnticipacion = 7,
): CobranzaItem[] {
const limite = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate() + diasAnticipacion)
const items: CobranzaItem[] = []
for (const plan of planes) {
if (statusPlan(plan) !== 'ACTIVO') continue
for (const pago of plan.pagos) {
if (estaCubierto(pago)) continue
if (compararFechas(parseFechaLocal(pago.fechaVencimiento), limite) > 0) continue
const atraso = Math.max(
0,
Math.round(compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) / 86400000),
)
items.push({ plan, pago, atraso })
}
}
return items.sort((a, b) => {
if (a.atraso !== b.atraso) return b.atraso - a.atraso
return compararFechas(
parseFechaLocal(a.pago.fechaVencimiento),
parseFechaLocal(b.pago.fechaVencimiento),
)
})
}
/**
* Valida el texto de una nota y devuelve la nota construida.
* El texto es requerido (mín. 3 caracteres).
*/
export function construirNota(texto: string): NotaPlan {
const limpio = texto.trim()
if (limpio.length < 3) {
throw new Error('La nota debe tener al menos 3 caracteres')
}
return { id: randomUUID(), fecha: new Date().toISOString(), texto: limpio }
}
export interface AbonoInput {
pagoId: string
fechaPago: string // ISO
monto: number
referencia: string
}
/**
* Valida un abono contra el plan y devuelve la aplicación construida más el
* pago objetivo. Reglas (idénticas al frontend):
* - rechaza montos <= 0
* - el pago debe existir en el plan
* - estricto al centavo: el abono nunca puede exceder el restante del pago
*/
export function validarAbono(plan: PlanPago, abono: AbonoInput): { pago: Pago; aplicacion: AplicacionPago } {
if (round2(abono.monto) <= 0) {
throw new Error('El abono debe ser mayor a 0')
}
const pago = plan.pagos.find((p) => p.id === abono.pagoId)
if (!pago) throw new Error('Pago no encontrado en el plan')
const falta = restante(pago)
if (round2(abono.monto - falta) > 0) {
throw new Error(`El abono excede el restante del pago (${falta.toFixed(2)})`)
}
const aplicacion: AplicacionPago = {
id: randomUUID(),
fechaPago: abono.fechaPago,
monto: round2(abono.monto),
referencia: abono.referencia,
}
return { pago, aplicacion }
}
/** Restante de un pago (expuesto para los controllers) */
export function restantePago(p: Pago): number {
return restante(p)
}

View File

@@ -0,0 +1,141 @@
import { describe, it, expect } from 'vitest'
import {
APARTADO_DEFAULT,
calcularNumMensualidades,
calcularPropuesta,
calcularPropuestas,
indiceMenorTotal,
obtenerPrecioLista,
totalPagado,
type UnidadCotizable,
} from './cotizacion.service.js'
// Fixture validado contra el Excel real: U-101, con acabados, apartado 20000, N = 14
const U101: UnidadCotizable = {
codigo: 'U-101',
modelo: '1R',
fechaEntrega: '2027-10-08',
supEstacionamiento: 12.5,
supConstruccion: 69.485,
supExcedente: 0,
precioSinAcabados: 4415000,
precioConAcabados: 4579000,
status: 'DISPONIBLE',
desarrollo: 'SENDA',
}
const N = 14
function calcular(descuentos = { creditoBancario: 0, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01 }) {
return calcularPropuestas({
unidad: U101,
acabados: true,
apartado: APARTADO_DEFAULT,
descuentos,
numMeses: N,
})
}
describe('fixture U-101 (validado contra Excel de producción)', () => {
it('precio de lista con acabados', () => {
expect(obtenerPrecioLista(U101, true)).toBe(4579000)
expect(obtenerPrecioLista(U101, false)).toBe(4415000)
})
it('Propuesta 1 — CRÉDITO BANCARIO (desc 0)', () => {
const [p] = calcular()
expect(p?.enganche).toBeCloseTo(94475, 2)
expect(p?.mensualidadesTotal).toBeCloseTo(343425, 2)
expect(p?.mensualidad).toBeCloseTo(24530.357142, 4)
expect(p?.pagoFinal).toBeCloseTo(4121100, 2)
})
it('Propuesta 2 — CRÉDITO INFONAVIT-COFINAVIT (desc 0)', () => {
const [, p] = calcular()
expect(p?.enganche).toBeCloseTo(94475, 2)
expect(p?.mensualidadesTotal).toBeCloseTo(114475, 2)
expect(p?.mensualidad).toBeCloseTo(8176.785714, 4)
expect(p?.pagoFinal).toBeCloseTo(4350050, 2)
})
it('Propuesta 3 — CONTADO (desc 2%)', () => {
const [, , p] = calcular()
expect(p?.montoDescuento).toBeCloseTo(91580, 2)
expect(p?.precioFinal).toBeCloseTo(4487420, 2)
expect(p?.enganche).toBeCloseTo(4018678, 2)
expect(p?.pagoFinal).toBeCloseTo(448742, 2)
expect(p?.mensualidad).toBeNull()
expect(p?.mensualidadesTotal).toBeNull()
})
it('Propuesta 4 — SEMI CONTADO (desc 1%)', () => {
const [, , , p] = calcular()
expect(p?.montoDescuento).toBeCloseTo(45790, 2)
expect(p?.precioFinal).toBeCloseTo(4533210, 2)
expect(p?.enganche).toBeCloseTo(2246605, 2)
expect(p?.mensualidadesTotal).toBeCloseTo(1813284, 2)
expect(p?.mensualidad).toBeCloseTo(129520.285714, 4)
expect(p?.pagoFinal).toBeCloseTo(453321, 2)
})
it('calcularPropuesta (una sola) reproduce CONTADO con descuento default', () => {
const p = calcularPropuesta(U101, 'CONTADO', true, APARTADO_DEFAULT, 0.02)
expect(p.montoDescuento).toBeCloseTo(91580, 2)
expect(p.precioFinal).toBeCloseTo(4487420, 2)
})
})
describe('comportamiento documentado del Excel', () => {
it('Bancario con descuento > 0: el enganche sigue calculándose sobre precio LISTA', () => {
const [p] = calcular({ creditoBancario: 0.05, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01 })
expect(p?.enganche).toBeCloseTo(0.025 * 4579000 - APARTADO_DEFAULT, 2)
expect(p?.montoDescuento).toBeCloseTo(228950, 2)
expect(p?.precioFinal).toBeCloseTo(4350050, 2)
expect(p?.pagoFinal).toBeCloseTo(0.9 * 4350050, 2)
const total = 4350050 - APARTADO_DEFAULT - 94475 - 0.9 * 4350050
expect(p?.mensualidadesTotal).toBeCloseTo(total, 2)
})
it('Infonavit usa precioLista (no precioFinal) en el total de mensualidades', () => {
const [, p] = calcular({ creditoBancario: 0, creditoInfonavit: 0.05, contado: 0.02, semiContado: 0.01 })
const total = 4579000 - (p?.pagoFinal ?? 0) - APARTADO_DEFAULT - (p?.enganche ?? 0)
expect(p?.mensualidadesTotal).toBeCloseTo(total, 2)
})
})
describe('calcularNumMensualidades', () => {
it('calcula meses redondeados con 30.4 días por mes', () => {
const hoy = new Date('2026-08-20T00:00:00')
expect(calcularNumMensualidades('2027-10-08', hoy)).toBe(14)
})
it('nunca devuelve menos de 1', () => {
const hoy = new Date('2030-01-01T00:00:00')
expect(calcularNumMensualidades('2027-10-08', hoy)).toBe(1)
})
})
describe('totalPagado', () => {
it('bancario: coincide con el precio de lista (desc 0)', () => {
const [bancario] = calcular()
expect(totalPagado(bancario!, APARTADO_DEFAULT)).toBeCloseTo(4579000, 2)
})
it('contado y semi contado: coinciden con el precio final c/desc', () => {
const [, , contado, semi] = calcular()
expect(totalPagado(contado!, APARTADO_DEFAULT)).toBeCloseTo(4487420, 2)
expect(totalPagado(semi!, APARTADO_DEFAULT)).toBeCloseTo(4533210, 2)
})
})
describe('indiceMenorTotal', () => {
it('contado (2% desc) tiene el menor total pagado', () => {
const propuestas = calcular()
const totales = propuestas.map((p) => totalPagado(p, APARTADO_DEFAULT))
expect(indiceMenorTotal(totales)).toBe(2)
})
it('en empate gana el primero; lista vacía devuelve -1', () => {
expect(indiceMenorTotal([100, 100, 200])).toBe(0)
expect(indiceMenorTotal([])).toBe(-1)
})
})

View File

@@ -0,0 +1,245 @@
/**
* Cálculos replicados fielmente del Excel de producción "COTIZADOR" de
* Frasa Desarrollos (port de apps/cotizador/src/lib/calculations.ts).
* NO "corregir" las fórmulas: algunas usan precio de lista donde
* intuitivamente iría precio final; así está en el Excel y así debe quedar.
*/
export interface UnidadCotizable {
codigo: string
modelo: string
fechaEntrega: string // ISO 'YYYY-MM-DD' (terminación de obra)
supEstacionamiento: number
supConstruccion: number
supExcedente: number
precioSinAcabados: number
precioConAcabados: number
status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA'
desarrollo: string
}
export type TipoPropuesta =
| 'CREDITO_BANCARIO'
| 'CREDITO_INFONAVIT_COFINAVIT'
| 'CONTADO'
| 'SEMI_CONTADO'
export interface DescuentosPropuestas {
creditoBancario: number
creditoInfonavit: number
contado: number
semiContado: number
}
export const APARTADO_DEFAULT = 20000
// Descuentos por defecto de cada propuesta (fracción, no porcentaje)
export const DESCUENTOS_DEFAULT: DescuentosPropuestas = {
creditoBancario: 0,
creditoInfonavit: 0,
contado: 0.02,
semiContado: 0.01,
}
export interface PropuestaResultado {
tipo: TipoPropuesta
descuento: number // fracción (0.02 = 2%)
montoDescuento: number
precioFinal: number // precio lista - descuento
enganche: number // NETO del apartado (el apartado ya se pagó)
numMensualidades: number
mensualidad: number | null // null => N/A (Contado)
mensualidadesTotal: number | null // null => N/A (Contado)
pagoFinal: number
}
export interface CotizacionInput {
unidad: UnidadCotizable
acabados: boolean // true => incluye cocina y closet
apartado: number
descuentos: DescuentosPropuestas
/** Permite forzar N en tests; por defecto se calcula desde fechaEntrega */
numMeses?: number
}
const MS_POR_MES = 30.4 * 86400000
/** Meses (redondeados, mínimo 1) desde hoy hasta la terminación de obra */
export function calcularNumMensualidades(fechaEntrega: string, hoy: Date = new Date()): number {
const entrega = new Date(`${fechaEntrega}T00:00:00`)
return Math.max(1, Math.round((entrega.getTime() - hoy.getTime()) / MS_POR_MES))
}
function baseComun(precioLista: number, descuento: number) {
// Excel COTIZADOR: MONTO DE DESCUENTO y PRECIO FINAL C/DESC
const montoDescuento = precioLista * descuento
const precioFinal = precioLista - montoDescuento
return { montoDescuento, precioFinal }
}
/** PROPUESTA 1 — CRÉDITO BANCARIO */
function calcularBancario(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
// Excel COTIZADOR!M17 — enganche 2.5% sobre precio LISTA, neto del apartado
const enganche = 0.025 * precioLista - apartado
// Excel COTIZADOR!M19 — pago final 90% del precio final c/desc
const pagoFinal = 0.9 * precioFinal
// Excel COTIZADOR!M18 — remanente a mensualidades
const mensualidadesTotal = precioFinal - apartado - enganche - pagoFinal
return {
tipo: 'CREDITO_BANCARIO',
descuento,
montoDescuento,
precioFinal,
enganche,
numMensualidades: n,
mensualidad: mensualidadesTotal / n,
mensualidadesTotal,
pagoFinal,
}
}
/** PROPUESTA 2 — CRÉDITO INFONAVIT-COFINAVIT */
function calcularInfonavit(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
// Excel COTIZADOR!M26 — enganche 2.5% sobre precio LISTA, neto del apartado
const enganche = 0.025 * precioLista - apartado
// Excel COTIZADOR!M28 — pago final 95% del precio final c/desc
const pagoFinal = 0.95 * precioFinal
// Excel COTIZADOR!M27 — OJO: el Excel usa precioLista aquí, NO precioFinal. Replicar tal cual.
const mensualidadesTotal = precioLista - pagoFinal - apartado - enganche
return {
tipo: 'CREDITO_INFONAVIT_COFINAVIT',
descuento,
montoDescuento,
precioFinal,
enganche,
numMensualidades: n,
mensualidad: mensualidadesTotal / n,
mensualidadesTotal,
pagoFinal,
}
}
/** PROPUESTA 3 — CONTADO (sin mensualidades) */
function calcularContado(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
// Excel COTIZADOR!M36 — enganche 90% del precio final c/desc, neto del apartado
const enganche = 0.9 * precioFinal - apartado
// Excel COTIZADOR!M37 — pago final 10% del precio final c/desc
const pagoFinal = 0.1 * precioFinal
return {
tipo: 'CONTADO',
descuento,
montoDescuento,
precioFinal,
enganche,
numMensualidades: n,
mensualidad: null, // N/A
mensualidadesTotal: null, // N/A
pagoFinal,
}
}
/** PROPUESTA 4 — SEMI CONTADO */
function calcularSemiContado(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
// Excel COTIZADOR!M45 — enganche 50% del precio final c/desc, neto del apartado
const enganche = 0.5 * precioFinal - apartado
// Excel COTIZADOR!M46 — 40% del precio final a mensualidades (= 1 - 0.5 - 0.1)
const mensualidadesTotal = precioFinal * 0.4
// Excel COTIZADOR!M47 — pago final 10% del precio final c/desc
const pagoFinal = 0.1 * precioFinal
return {
tipo: 'SEMI_CONTADO',
descuento,
montoDescuento,
precioFinal,
enganche,
numMensualidades: n,
mensualidad: mensualidadesTotal / n,
mensualidadesTotal,
pagoFinal,
}
}
/** Precio de lista según si incluye cocina y closet (acabados) */
export function obtenerPrecioLista(unidad: UnidadCotizable, acabados: boolean): number {
return acabados ? unidad.precioConAcabados : unidad.precioSinAcabados
}
/**
* Total pagado por el cliente en una propuesta:
* apartado + enganche (neto del apartado) + mensualidades + pago final.
*/
export function totalPagado(p: PropuestaResultado, apartado: number): number {
return apartado + p.enganche + (p.mensualidadesTotal ?? 0) + p.pagoFinal
}
/** Índice del menor total pagado; en empate gana el primero. -1 si la lista está vacía */
export function indiceMenorTotal(totales: number[]): number {
if (totales.length === 0) return -1
let indice = 0
for (let i = 1; i < totales.length; i++) {
const actual = totales[i]
const mejor = totales[indice]
if (actual !== undefined && mejor !== undefined && actual < mejor) indice = i
}
return indice
}
/** Calcula las 4 propuestas comerciales */
export function calcularPropuestas(input: CotizacionInput): PropuestaResultado[] {
const { unidad, acabados, apartado, descuentos } = input
const precioLista = obtenerPrecioLista(unidad, acabados)
const n = input.numMeses ?? calcularNumMensualidades(unidad.fechaEntrega)
return [
calcularBancario(precioLista, apartado, descuentos.creditoBancario, n),
calcularInfonavit(precioLista, apartado, descuentos.creditoInfonavit, n),
calcularContado(precioLista, apartado, descuentos.contado, n),
calcularSemiContado(precioLista, apartado, descuentos.semiContado, n),
]
}
/** Mapa entre el nombre del API (body) y el nombre interno de la propuesta */
export const TIPO_PROPUESTA_API = {
BANCARIO: 'CREDITO_BANCARIO',
INFONAVIT: 'CREDITO_INFONAVIT_COFINAVIT',
CONTADO: 'CONTADO',
SEMI_CONTADO: 'SEMI_CONTADO',
} as const
export type TipoPropuestaApi = keyof typeof TIPO_PROPUESTA_API
const DESCUENTO_DEFAULT_POR_TIPO: Record<TipoPropuestaApi, number> = {
BANCARIO: DESCUENTOS_DEFAULT.creditoBancario,
INFONAVIT: DESCUENTOS_DEFAULT.creditoInfonavit,
CONTADO: DESCUENTOS_DEFAULT.contado,
SEMI_CONTADO: DESCUENTOS_DEFAULT.semiContado,
}
/**
* Calcula UNA propuesta (la elegida por el cliente) para persistir/cotizar.
* `descuento` opcional (fracción); si no viene, usa el default del tipo.
*/
export function calcularPropuesta(
unidad: UnidadCotizable,
tipo: TipoPropuestaApi,
acabados: boolean,
apartado: number = APARTADO_DEFAULT,
descuento?: number,
): PropuestaResultado {
const precioLista = obtenerPrecioLista(unidad, acabados)
const n = calcularNumMensualidades(unidad.fechaEntrega)
const d = descuento ?? DESCUENTO_DEFAULT_POR_TIPO[tipo]
switch (tipo) {
case 'BANCARIO':
return calcularBancario(precioLista, apartado, d, n)
case 'INFONAVIT':
return calcularInfonavit(precioLista, apartado, d, n)
case 'CONTADO':
return calcularContado(precioLista, apartado, d, n)
case 'SEMI_CONTADO':
return calcularSemiContado(precioLista, apartado, d, n)
}
}

View File

@@ -0,0 +1,235 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MondayService } from './monday.service.js'
// Columnas con slugs autogenerados (como las reales de Monday); el servicio
// debe resolverlas por TÍTULO, nunca hardcodear el id.
const COLUMNAS_UNIDADES = [
{ id: 'text_mm6eaaa', title: 'Desarrollo', type: 'text' },
{ id: 'text_mm6ebbb', title: 'Modelo', type: 'text' },
{ id: 'date_mm6eccc', title: 'Fecha entrega', type: 'date' },
{ id: 'numeric_mm6eddd', title: 'Sup. construcción', type: 'numbers' },
{ id: 'numeric_mm6eeee', title: 'Sup. excedente', type: 'numbers' },
{ id: 'numeric_mm6efff', title: 'Sup. estacionamiento', type: 'numbers' },
{ id: 'numeric_mm6eggg', title: 'Precio sin acabados', type: 'numbers' },
{ id: 'numeric_mm6ehhh', title: 'Precio con acabados', type: 'numbers' },
{ id: 'color_mm6eiii', title: 'Disponibilidad', type: 'status' },
]
function itemUnidad(id: string, codigo: string, disponibilidad: string) {
return {
id,
name: codigo,
column_values: [
{ id: 'text_mm6eaaa', text: 'SENDA', value: null },
{ id: 'text_mm6ebbb', text: '1R', value: null },
{ id: 'date_mm6eccc', text: '2027-10-08', value: null },
{ id: 'numeric_mm6eddd', text: '69.485', value: null },
{ id: 'numeric_mm6eeee', text: '0', value: null },
{ id: 'numeric_mm6efff', text: '12.5', value: null },
{ id: 'numeric_mm6eggg', text: '4415000', value: null },
{ id: 'numeric_mm6ehhh', text: '4579000', value: null },
{ id: 'color_mm6eiii', text: disponibilidad, value: null },
],
}
}
const respuestaOk = (data: unknown): Response =>
new Response(JSON.stringify({ data }), { status: 200, headers: { 'Content-Type': 'application/json' } })
interface LlamadaFetch {
body: { query: string; variables: Record<string, unknown> }
}
/** Instala un fetch mockeado que responde según el query; devuelve las llamadas registradas */
function mockearFetch(handler: (body: LlamadaFetch['body']) => Response): LlamadaFetch[] {
const llamadas: LlamadaFetch[] = []
vi.stubGlobal('fetch', async (_url: unknown, init: { body: string }) => {
const body = JSON.parse(init.body) as LlamadaFetch['body']
llamadas.push({ body })
return handler(body)
})
return llamadas
}
const crearServicio = (): MondayService =>
new MondayService({ apiKey: 'test', apiUrl: 'https://api.test/v2', baseDelayMs: 1 })
describe('MondayService', () => {
beforeEach(() => vi.useRealTimers())
afterEach(() => vi.unstubAllGlobals())
it('resuelve column IDs por TÍTULO y mapea unidades al tipo del cotizador', async () => {
mockearFetch((body) => {
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] })
return respuestaOk({
boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE')] } }],
})
})
const unidades = await crearServicio().getUnidades()
expect(unidades).toHaveLength(1)
expect(unidades[0]).toEqual({
codigo: 'U-101',
modelo: '1R',
fechaEntrega: '2027-10-08',
supConstruccion: 69.485,
supExcedente: 0,
supEstacionamiento: 12.5,
precioSinAcabados: 4415000,
precioConAcabados: 4579000,
status: 'DISPONIBLE',
desarrollo: 'SENDA',
})
})
it('excluye items placeholder sin datos (p. ej. "Task 1" default de Monday)', async () => {
const placeholder = {
id: '99',
name: 'Task 1',
column_values: COLUMNAS_UNIDADES.map((c) => ({ id: c.id, text: '', value: null })),
}
mockearFetch((body) => {
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] })
return respuestaOk({
boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE'), placeholder] } }],
})
})
const unidades = await crearServicio().getUnidades()
expect(unidades.map((u) => u.codigo)).toEqual(['U-101'])
})
it('pagina con cursor hasta agotar las páginas', async () => {
const llamadas = mockearFetch((body) => {
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] })
if (body.variables['cursor'] === 'pagina-2') {
return respuestaOk({
boards: [{ items_page: { cursor: null, items: [itemUnidad('2', 'U-102', 'APARTADA')] } }],
})
}
return respuestaOk({
boards: [{ items_page: { cursor: 'pagina-2', items: [itemUnidad('1', 'U-101', 'VENDIDA')] } }],
})
})
const unidades = await crearServicio().getUnidades()
expect(unidades.map((u) => u.codigo)).toEqual(['U-101', 'U-102'])
expect(unidades[0]?.status).toBe('VENDIDA')
expect(unidades[1]?.status).toBe('APARTADA')
// 1 llamada de columnas + 2 de items_page
expect(llamadas).toHaveLength(3)
expect(llamadas[2]?.body.variables['cursor']).toBe('pagina-2')
})
it('usa el cache de 5 minutos para no repetir llamadas', async () => {
const llamadas = mockearFetch((body) => {
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] })
return respuestaOk({
boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE')] } }],
})
})
const svc = crearServicio()
await svc.getUnidades()
await svc.getUnidades()
expect(llamadas).toHaveLength(2) // columnas + una sola página de items
})
it('reintenta ante HTTP 429 con backoff y logra responder', async () => {
let intentos = 0
mockearFetch((body) => {
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] })
intentos++
if (intentos === 1) return new Response('rate limited', { status: 429 })
return respuestaOk({
boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE')] } }],
})
})
const unidades = await crearServicio().getUnidades()
expect(unidades).toHaveLength(1)
expect(intentos).toBe(2)
})
it('reintenta ante error de complejidad (error_code COMPLEXITY_BUDGET_EXCEEDED)', async () => {
let intentos = 0
mockearFetch((body) => {
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] })
intentos++
if (intentos < 3) {
return new Response(
JSON.stringify({ error_code: 'COMPLEXITY_BUDGET_EXCEEDED', error_message: 'Complexity budget exceeded' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}
return respuestaOk({
boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE')] } }],
})
})
const unidades = await crearServicio().getUnidades()
expect(unidades).toHaveLength(1)
expect(intentos).toBe(3)
})
it('agota los reintentos y lanza error', async () => {
mockearFetch(() => new Response('rate limited', { status: 429 }))
await expect(crearServicio().getUnidades()).rejects.toThrow(/no respondió/)
})
it('lanza en errores GraphQL no retryables sin reintentar', async () => {
let llamadasTotal = 0
mockearFetch(() => {
llamadasTotal++
return new Response(JSON.stringify({ errors: [{ message: 'Invalid board ID' }] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
})
await expect(crearServicio().getUnidades()).rejects.toThrow(/Invalid board ID/)
expect(llamadasTotal).toBe(1)
})
it('crearOportunidad envía column_values como JSON.stringify en variables', async () => {
const columnasOportunidades = [
{ id: 'text_u1', title: 'Unidad', type: 'text' },
{ id: 'text_e1', title: 'Cliente email', type: 'text' },
{ id: 'text_t1', title: 'Cliente teléfono', type: 'text' },
{ id: 'color_tp', title: 'Tipo propuesta', type: 'status' },
{ id: 'numeric_pl', title: 'Precio de lista', type: 'numbers' },
{ id: 'numeric_dp', title: 'Descuento %', type: 'numbers' },
{ id: 'numeric_pf', title: 'Precio final', type: 'numbers' },
{ id: 'date_fc', title: 'Fecha cotización', type: 'date' },
{ id: 'color_es', title: 'Estado', type: 'status' },
]
const llamadas = mockearFetch((body) => {
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: columnasOportunidades }] })
return respuestaOk({ create_item: { id: '987654321' } })
})
const id = await crearServicio().crearOportunidad({
clienteNombre: 'Juan Pérez',
unidadCodigo: 'U-101',
tipoPropuestaLabel: 'BANCARIO',
precioLista: 4579000,
descuentoPct: 0,
precioFinal: 4579000,
fechaCotizacion: '2026-08-21',
estadoLabel: 'COTIZADA',
clienteEmail: 'juan@example.com',
})
expect(id).toBe('987654321')
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
expect(mutacion).toBeDefined()
const columnValues = mutacion?.body.variables['columnValues']
expect(typeof columnValues).toBe('string') // JSON.stringify, nunca interpolado en el query
const parsed = JSON.parse(columnValues as string) as Record<string, unknown>
expect(parsed['text_u1']).toBe('U-101')
expect(parsed['text_e1']).toBe('juan@example.com')
expect(parsed['numeric_pl']).toBe(4579000)
expect(parsed['color_tp']).toEqual({ label: 'BANCARIO' })
expect(parsed['color_es']).toEqual({ label: 'COTIZADA' })
expect(parsed['date_fc']).toEqual({ date: '2026-08-21' })
expect(parsed['text_t1']).toBeUndefined() // no se envían columnas vacías
})
})

View File

@@ -0,0 +1,391 @@
import { config } from '../config/index.js'
import type { UnidadCotizable } from './cotizacion.service.js'
import { AppError } from '../utils/errors.js'
/** Unidad tal como la consume el API (y el frontend del cotizador) */
export type Unidad = UnidadCotizable
interface ColumnaMeta {
id: string
title: string
type: string
}
interface ColumnValue {
id: string
text: string | null
value: string | null
}
interface ItemMonday {
id: string
name: string
column_values: ColumnValue[]
}
interface GraphQLErrorItem {
message: string
extensions?: { code?: string }
}
interface GraphQLResponse<T> {
data?: T
errors?: GraphQLErrorItem[]
error_code?: string
error_message?: string
}
/** Error retryable de Monday (rate limit / presupuesto de complejidad / 5xx) */
class MondayRetryableError extends Error {}
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
const CACHE_UNIDADES_MS = 5 * 60 * 1000 // 5 minutos
export interface MondayServiceOptions {
apiKey?: string
apiUrl?: string
maxReintentos?: number
baseDelayMs?: number
}
export class MondayService {
private readonly apiKey: string
private readonly apiUrl: string
private readonly maxReintentos: number
private readonly baseDelayMs: number
// Cache de metadatos de columnas: boardId -> (título -> meta)
private readonly columnasCache = new Map<string, Map<string, ColumnaMeta>>()
// Cache de unidades + mapa codigo -> itemId (para escrituras)
private unidadesCache: { unidades: Unidad[]; itemIds: Map<string, string>; expira: number } | null = null
constructor(opts: MondayServiceOptions = {}) {
this.apiKey = opts.apiKey ?? config.monday.apiKey
this.apiUrl = opts.apiUrl ?? config.monday.apiUrl
this.maxReintentos = opts.maxReintentos ?? 3
this.baseDelayMs = opts.baseDelayMs ?? 500
}
/**
* Helper GraphQL contra la API de Monday.
* - Headers Authorization / API-Version.
* - Lanza en errores GraphQL.
* - Retry con backoff exponencial ante 429, errores de complejidad y 5xx
* (máx `maxReintentos` reintentos).
*/
async graphql<T>(query: string, variables: Record<string, unknown> = {}): Promise<T> {
let ultimoError: Error | null = null
for (let intento = 0; intento <= this.maxReintentos; intento++) {
if (intento > 0) await sleep(this.baseDelayMs * 2 ** (intento - 1))
try {
return await this.graphqlOnce<T>(query, variables)
} catch (err) {
if (!(err instanceof MondayRetryableError)) throw err
ultimoError = err
}
}
throw new AppError(502, `Monday no respondió tras ${this.maxReintentos + 1} intentos: ${ultimoError?.message}`)
}
private async graphqlOnce<T>(query: string, variables: Record<string, unknown>): Promise<T> {
const res = await fetch(this.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: this.apiKey,
'API-Version': config.monday.apiVersion,
},
body: JSON.stringify({ query, variables }),
})
if (res.status === 429) throw new MondayRetryableError('HTTP 429 (rate limit)')
if (res.status >= 500) throw new MondayRetryableError(`HTTP ${res.status}`)
if (!res.ok) throw new AppError(502, `Monday respondió HTTP ${res.status}`)
const body = (await res.json()) as GraphQLResponse<T>
// Monday devuelve errores de complejidad como 200 con error_code
if (body.error_code) {
if (/complexity/i.test(body.error_code) || /complexity/i.test(body.error_message ?? '')) {
throw new MondayRetryableError(`Complejidad excedida: ${body.error_code}`)
}
throw new AppError(502, `Error de Monday: ${body.error_code} ${body.error_message ?? ''}`)
}
if (body.errors?.length) {
const msg = body.errors.map((e) => e.message).join('; ')
const esComplejidad = body.errors.some(
(e) => /complexity/i.test(e.message) || /complexity/i.test(e.extensions?.code ?? ''),
)
if (esComplejidad) throw new MondayRetryableError(`Complejidad excedida: ${msg}`)
throw new AppError(502, `Errores GraphQL de Monday: ${msg}`)
}
if (!body.data) throw new AppError(502, 'Monday respondió sin data')
return body.data
}
/** Metadatos de columnas de un tablero, mapeadas por TÍTULO (cache en memoria) */
private async columnasDe(boardId: string): Promise<Map<string, ColumnaMeta>> {
const cached = this.columnasCache.get(boardId)
if (cached) return cached
const data = await this.graphql<{ boards: { columns: ColumnaMeta[] }[] }>(
`query ($ids: [ID!]) { boards(ids: $ids) { columns { id title type } } }`,
{ ids: [boardId] },
)
const columnas = data.boards[0]?.columns ?? []
const mapa = new Map<string, ColumnaMeta>(columnas.map((c) => [c.title, c]))
this.columnasCache.set(boardId, mapa)
return mapa
}
/** Resuelve el column ID por título exacto; lanza si no existe en el tablero */
private async columnaId(boardId: string, titulo: string): Promise<ColumnaMeta> {
const cols = await this.columnasDe(boardId)
const col = cols.get(titulo)
if (!col) throw new AppError(500, `Columna "${titulo}" no existe en el tablero ${boardId}`)
return col
}
/** Ping barato para /api/health */
async ping(): Promise<boolean> {
try {
const data = await this.graphql<{ boards: { id: string }[] }>(
`query ($ids: [ID!]) { boards(ids: $ids) { id } }`,
{ ids: [config.monday.boards.unidades] },
)
return (data.boards?.length ?? 0) > 0
} catch {
return false
}
}
/** Invalida el cache de unidades (llamar tras cualquier escritura que las afecte) */
invalidarCacheUnidades(): void {
this.unidadesCache = null
}
/** Todas las unidades del tablero, con paginación por cursor y cache de 5 min */
async getUnidades(): Promise<Unidad[]> {
const ahora = Date.now()
if (this.unidadesCache && this.unidadesCache.expira > ahora) {
return this.unidadesCache.unidades
}
const boardId = config.monday.boards.unidades
const cols = await this.columnasDe(boardId)
const items: ItemMonday[] = []
let cursor: string | null = null
do {
// La primera página no lleva cursor; las siguientes sí (nunca asumir una sola página)
const data: { boards: { items_page: { cursor: string | null; items: ItemMonday[] } }[] } =
cursor === null
? await this.graphql(
`query ($ids: [ID!]) {
boards(ids: $ids) {
items_page(limit: 500) { cursor items { id name column_values { id text value } } }
}
}`,
{ ids: [boardId] },
)
: await this.graphql(
`query ($ids: [ID!], $cursor: String!) {
boards(ids: $ids) {
items_page(limit: 500, cursor: $cursor) { cursor items { id name column_values { id text value } } }
}
}`,
{ ids: [boardId], cursor },
)
const page = data.boards[0]?.items_page
if (!page) break
items.push(...page.items)
cursor = page.cursor
} while (cursor !== null)
const itemIds = new Map<string, string>()
const unidades: Unidad[] = []
for (const item of items) {
const unidad = this.mapearUnidad(item, cols)
// Items placeholder (sin modelo ni precios, p. ej. el "Task 1" default de
// Monday) no son inventario: se excluyen del listado
if (unidad.modelo === '' && unidad.precioSinAcabados === 0 && unidad.precioConAcabados === 0) continue
itemIds.set(item.name, item.id)
unidades.push(unidad)
}
this.unidadesCache = { unidades, itemIds, expira: ahora + CACHE_UNIDADES_MS }
return unidades
}
/** Una unidad por código (U-101, U-310...); null si no existe */
async getUnidad(codigo: string): Promise<Unidad | null> {
const unidades = await this.getUnidades()
return unidades.find((u) => u.codigo === codigo) ?? null
}
private mapearUnidad(item: ItemMonday, cols: Map<string, ColumnaMeta>): Unidad {
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
const textoDe = (titulo: string): string => {
const col = cols.get(titulo)
if (!col) throw new AppError(500, `Columna "${titulo}" no existe en el tablero Unidades`)
return porId.get(col.id)?.text ?? ''
}
const numeroDe = (titulo: string): number => {
const n = Number(textoDe(titulo))
return Number.isFinite(n) ? n : 0
}
const status = textoDe('Disponibilidad').toUpperCase()
return {
codigo: item.name,
modelo: textoDe('Modelo'),
fechaEntrega: textoDe('Fecha entrega'),
supEstacionamiento: numeroDe('Sup. estacionamiento'),
supConstruccion: numeroDe('Sup. construcción'),
supExcedente: numeroDe('Sup. excedente'),
precioSinAcabados: numeroDe('Precio sin acabados'),
precioConAcabados: numeroDe('Precio con acabados'),
status: status === 'APARTADA' || status === 'VENDIDA' ? status : 'DISPONIBLE',
desarrollo: textoDe('Desarrollo'),
}
}
/**
* Serializa un valor crudo al formato que espera el tipo de columna de Monday.
* column_values SIEMPRE se envía con JSON.stringify en variables (nunca
* template literals dentro del query).
*/
private valorPorTipo(col: ColumnaMeta, crudo: string | number): unknown {
switch (col.type) {
case 'status':
return { label: String(crudo) }
case 'date':
return { date: String(crudo) }
case 'numbers':
return typeof crudo === 'number' ? crudo : Number(crudo)
case 'email':
return { email: String(crudo), text: String(crudo) }
case 'phone':
return { phone: String(crudo), countryShortName: 'MX' }
default:
return String(crudo)
}
}
private async construirColumnValues(
boardId: string,
valores: Record<string, string | number | undefined>,
): Promise<string> {
const out: Record<string, unknown> = {}
for (const [titulo, crudo] of Object.entries(valores)) {
if (crudo === undefined || crudo === '') continue
const col = await this.columnaId(boardId, titulo)
out[col.id] = this.valorPorTipo(col, crudo)
}
return JSON.stringify(out)
}
private async crearItem(boardId: string, nombre: string, columnValues: string): Promise<string> {
const data = await this.graphql<{ create_item: { id: string } }>(
`mutation ($boardId: ID!, $itemName: String!, $columnValues: JSON!) {
create_item(board_id: $boardId, item_name: $itemName, column_values: $columnValues) { id }
}`,
{ boardId, itemName: nombre, columnValues },
)
return data.create_item.id
}
// ---------- Escrituras de negocio ----------
async crearOportunidad(datos: DatosOportunidad): Promise<string> {
const boardId = config.monday.boards.oportunidades
const columnValues = await this.construirColumnValues(boardId, {
Unidad: datos.unidadCodigo,
'Cliente email': datos.clienteEmail,
'Cliente teléfono': datos.clienteTelefono,
'Tipo propuesta': datos.tipoPropuestaLabel,
'Precio de lista': datos.precioLista,
'Descuento %': datos.descuentoPct,
'Precio final': datos.precioFinal,
'Fecha cotización': datos.fechaCotizacion,
Estado: datos.estadoLabel,
})
return this.crearItem(boardId, datos.clienteNombre, columnValues)
}
async crearContacto(datos: DatosContacto): Promise<string> {
const boardId = config.monday.boards.contactos
const columnValues = await this.construirColumnValues(boardId, {
Teléfono: datos.telefono,
Email: datos.email,
CURP: datos.curp,
RFC: datos.rfc,
Dirección: datos.direccion,
Estado: datos.estadoLabel,
})
return this.crearItem(boardId, datos.nombre, columnValues)
}
async crearApartado(datos: DatosApartado): Promise<string> {
const boardId = config.monday.boards.apartado
const columnValues = await this.construirColumnValues(boardId, {
Unidad: datos.unidadCodigo,
'Monto apartado': datos.montoApartado,
'Fecha apartado': datos.fechaApartado,
'Fecha límite': datos.fechaLimite,
Estado: datos.estadoLabel,
})
return this.crearItem(boardId, datos.clienteNombre, columnValues)
}
async actualizarDisponibilidadUnidad(codigo: string, status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA'): Promise<void> {
const boardId = config.monday.boards.unidades
// Asegura cache fresco para resolver el itemId
await this.getUnidades()
const itemId = this.unidadesCache?.itemIds.get(codigo)
if (!itemId) throw new AppError(404, `Unidad ${codigo} no encontrada en Monday`)
const col = await this.columnaId(boardId, 'Disponibilidad')
await this.graphql(
`mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) {
change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id }
}`,
{ boardId, itemId, columnId: col.id, value: JSON.stringify({ label: status }) },
)
this.invalidarCacheUnidades()
}
}
// ---------- DTOs de escritura ----------
export interface DatosOportunidad {
clienteNombre: string
unidadCodigo: string
tipoPropuestaLabel: string
precioLista: number
descuentoPct: number // porcentaje (2 = 2%)
precioFinal: number
fechaCotizacion: string // 'YYYY-MM-DD'
estadoLabel: string // p. ej. 'COTIZADA'
clienteEmail?: string
clienteTelefono?: string
}
export interface DatosContacto {
nombre: string
telefono?: string
email?: string
curp?: string
rfc?: string
direccion?: string
estadoLabel: string // p. ej. 'CLIENTE'
}
export interface DatosApartado {
clienteNombre: string
unidadCodigo: string
montoApartado?: number
fechaApartado: string // 'YYYY-MM-DD'
fechaLimite: string // 'YYYY-MM-DD'
estadoLabel: string // p. ej. 'VIGENTE'
}
/** Singleton usado por los controllers (los tests instancian el suyo) */
export const mondayService = new MondayService()

View File

@@ -0,0 +1,4 @@
import { PrismaClient } from '@prisma/client'
// Cliente único compartido (Prisma gestiona el pool internamente)
export const prisma = new PrismaClient()