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

22
api-gateway/src/app.ts Normal file
View File

@@ -0,0 +1,22 @@
import express from 'express'
import cors from 'cors'
import { apiRouter } from './routes/index.js'
import { errorHandler } from './middleware/errorHandler.js'
import { requestLogger } from './middleware/requestLogger.js'
export function crearApp(): express.Express {
const app = express()
app.use(cors()) // CORS abierto solo para dev (red interna)
app.use(express.json({ limit: '10mb' }))
app.use(requestLogger)
app.use('/api', apiRouter)
app.use((_req, res) => {
res.status(404).json({ error: 'Ruta no encontrada' })
})
app.use(errorHandler)
return app
}

View File

@@ -0,0 +1,34 @@
import path from 'node:path'
import dotenv from 'dotenv'
// Carga .env local si existe y luego el .env raíz del ecosistema (no pisa vars ya definidas)
dotenv.config({ path: [path.resolve(process.cwd(), '.env'), path.resolve(process.cwd(), '..', '.env')] })
function env(name: string, fallback?: string): string {
const v = process.env[name] ?? fallback
if (v === undefined) throw new Error(`Falta la variable de entorno ${name}`)
return v
}
export const config = {
port: Number(env('PORT', '3000')),
nodeEnv: env('NODE_ENV', 'development'),
databaseUrl: env('DATABASE_URL', 'file:./dev.db'),
uploadsDir: path.resolve(process.cwd(), env('UPLOADS_DIR', './uploads')),
monday: {
apiKey: env('MONDAY_API_KEY'),
apiUrl: env('MONDAY_API_URL', 'https://api.monday.com/v2'),
apiVersion: '2024-10',
boards: {
contactos: env('MONDAY_BOARD_CONTACTOS'),
desarrollos: env('MONDAY_BOARD_DESARROLLOS'),
unidades: env('MONDAY_BOARD_UNIDADES'),
leads: env('MONDAY_BOARD_LEADS'),
oportunidades: env('MONDAY_BOARD_OPORTUNIDADES'),
apartado: env('MONDAY_BOARD_APARTADO'),
ventas: env('MONDAY_BOARD_VENTAS'),
},
},
} as const
export type AppConfig = typeof config

View File

@@ -0,0 +1,191 @@
import type { Request, Response } from 'express'
import path from 'node:path'
import fs from 'node:fs'
import { randomUUID } from 'node:crypto'
import multer from 'multer'
import { z } from 'zod'
import { config } from '../config/index.js'
import { mondayService } from '../services/monday.service.js'
import { prisma } from '../services/prisma.js'
import { NotFoundError, BadRequestError } from '../utils/errors.js'
// ---------- Alta de clientes ----------
const crearClienteSchema = z
.object({
datosGenerales: z
.object({
nombreCompleto: z.string().min(1),
curp: z.string().min(1),
rfc: z.string().min(1),
email: z.string().optional(),
celular: z.string().optional(),
direccion: z.string().optional(),
})
.passthrough(),
viviendaCredito: z
.object({
proyecto: z.string().min(1),
ubicacion: z.string().min(1),
valorVenta: z.union([z.string(), z.number()]).optional(),
enganche: z.union([z.string(), z.number()]).optional(),
})
.passthrough(),
firma: z.object({ firmaDataUrl: z.string().optional() }).passthrough().optional(),
})
.passthrough() // el resto del payload pasa tal cual a payloadJson
const aNumero = (v: string | number | undefined): number => {
if (v === undefined) return 0
const n = typeof v === 'number' ? v : Number(String(v).replace(/[^0-9.-]/g, ''))
return Number.isFinite(n) ? n : 0
}
const hoyMas = (dias: number): string => {
const d = new Date()
d.setDate(d.getDate() + dias)
const m = String(d.getMonth() + 1).padStart(2, '0')
const dia = String(d.getDate()).padStart(2, '0')
return `${d.getFullYear()}-${m}-${dia}`
}
/** POST /api/clientes — persiste el alta y la replica en Monday (Contactos y Apartado) */
export async function crearCliente(req: Request, res: Response): Promise<void> {
const body = crearClienteSchema.parse(req.body)
const dg = body.datosGenerales
const vc = body.viviendaCredito
const cliente = await prisma.cliente.create({
data: {
nombreCompleto: dg.nombreCompleto,
curp: dg.curp,
rfc: dg.rfc,
email: dg.email ?? null,
celular: dg.celular ?? null,
proyecto: vc.proyecto,
unidadCodigo: vc.ubicacion, // la "ubicación" del formato es el código de unidad (U-101, etc.)
payloadJson: JSON.stringify(body),
firmaPng: body.firma?.firmaDataUrl ?? null,
},
})
// Réplica en Monday; si falla, el alta ya quedó persistida y se reportan ids null
let mondayContactoId: string | null = null
let mondayApartadoId: string | null = null
try {
mondayContactoId = await mondayService.crearContacto({
nombre: dg.nombreCompleto,
estadoLabel: 'CLIENTE',
...(dg.celular !== undefined ? { telefono: dg.celular } : {}),
...(dg.email !== undefined ? { email: dg.email } : {}),
curp: dg.curp,
rfc: dg.rfc,
...(dg.direccion !== undefined ? { direccion: dg.direccion } : {}),
})
const valorVenta = aNumero(vc.valorVenta)
if (valorVenta > 0) {
const enganche = aNumero(vc.enganche)
mondayApartadoId = await mondayService.crearApartado({
clienteNombre: dg.nombreCompleto,
unidadCodigo: vc.ubicacion,
...(enganche > 0 ? { montoApartado: enganche } : {}),
fechaApartado: hoyMas(0),
fechaLimite: hoyMas(15),
estadoLabel: 'VIGENTE',
})
}
await prisma.cliente.update({
where: { id: cliente.id },
data: { mondayContactoId, mondayApartadoId },
})
} catch (err) {
// eslint-disable-next-line no-console
console.error('No se pudo replicar el cliente en Monday:', err)
}
res.status(201).json({ ...cliente, mondayContactoId, mondayApartadoId })
}
/** GET /api/clientes/:id */
export async function obtenerCliente(req: Request, res: Response): Promise<void> {
const cliente = await prisma.cliente.findUnique({
where: { id: String(req.params['id']) },
include: { documentos: true },
})
if (!cliente) throw new NotFoundError('Cliente no encontrado')
res.json(cliente)
}
// ---------- Documentos ----------
const MIME_PERMITIDOS = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'])
const EXT_POR_MIME: Record<string, string> = {
'application/pdf': 'pdf',
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
}
const storage = multer.diskStorage({
destination: (req, _file, cb) => {
const dir = path.join(config.uploadsDir, String(req.params['id']))
fs.mkdirSync(dir, { recursive: true })
cb(null, dir)
},
filename: (req, file, cb) => {
const tipo = String(req.body['tipo'] ?? 'OTRO').toUpperCase()
const ext = EXT_POR_MIME[file.mimetype] ?? 'bin'
cb(null, `${tipo}_${randomUUID()}.${ext}`)
},
})
export const uploadDocumentos = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024, files: 10 }, // 10 MB por archivo, máx 10
fileFilter: (_req, file, cb) => {
if (!MIME_PERMITIDOS.has(file.mimetype)) {
cb(new BadRequestError(`Tipo de archivo no permitido: ${file.mimetype}`))
return
}
cb(null, true)
},
})
/** POST /api/clientes/:id/documentos — multipart, campo `archivos` (hasta 10) */
export async function subirDocumentos(req: Request, res: Response): Promise<void> {
const clienteId = String(req.params['id'])
const cliente = await prisma.cliente.findUnique({ where: { id: clienteId } })
if (!cliente) throw new NotFoundError('Cliente no encontrado')
const archivos = (req.files ?? []) as Express.Multer.File[]
const tipo = String(req.body['tipo'] ?? 'OTRO').toUpperCase()
const documentos = await Promise.all(
archivos.map((f) =>
prisma.documento.create({
data: {
clienteId,
tipo,
nombreArchivo: f.originalname,
ruta: path.relative(config.uploadsDir, f.path),
mime: f.mimetype,
tamano: f.size,
},
}),
),
)
res.status(201).json(documentos)
}
/** GET /api/clientes/:id/documentos */
export async function listarDocumentos(req: Request, res: Response): Promise<void> {
const clienteId = String(req.params['id'])
const cliente = await prisma.cliente.findUnique({ where: { id: clienteId } })
if (!cliente) throw new NotFoundError('Cliente no encontrado')
const documentos = await prisma.documento.findMany({
where: { clienteId },
orderBy: { createdAt: 'desc' },
})
res.json(documentos)
}

View File

@@ -0,0 +1,91 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { mondayService } from '../services/monday.service.js'
import { calcularPropuesta, obtenerPrecioLista } from '../services/cotizacion.service.js'
import { prisma } from '../services/prisma.js'
import { NotFoundError } from '../utils/errors.js'
const crearCotizacionSchema = z.object({
unidadCodigo: z.string().min(1),
acabados: z.boolean(),
apartado: z.number().positive().optional(),
clienteNombre: z.string().min(1),
clienteEmail: z.string().email().optional(),
clienteTelefono: z.string().min(7).optional(),
tipoPropuesta: z.enum(['BANCARIO', 'INFONAVIT', 'CONTADO', 'SEMI_CONTADO']),
descuento: z.number().min(0).max(1).optional(), // fracción (0.02 = 2%)
})
const hoyISO = (): string => {
const d = new Date()
const m = String(d.getMonth() + 1).padStart(2, '0')
const dia = String(d.getDate()).padStart(2, '0')
return `${d.getFullYear()}-${m}-${dia}`
}
/** POST /api/cotizaciones — calcula, persiste y registra la oportunidad en Monday */
export async function crearCotizacion(req: Request, res: Response): Promise<void> {
const body = crearCotizacionSchema.parse(req.body)
const unidad = await mondayService.getUnidad(body.unidadCodigo)
if (!unidad) throw new NotFoundError(`Unidad ${body.unidadCodigo} no encontrada`)
const apartado = body.apartado ?? 20000 // APARTADO_DEFAULT
const propuesta = calcularPropuesta(unidad, body.tipoPropuesta, body.acabados, apartado, body.descuento)
const precioLista = obtenerPrecioLista(unidad, body.acabados)
const cotizacion = await prisma.cotizacion.create({
data: {
unidadCodigo: unidad.codigo,
clienteNombre: body.clienteNombre,
clienteEmail: body.clienteEmail ?? null,
clienteTelefono: body.clienteTelefono ?? null,
tipoPropuesta: body.tipoPropuesta,
acabados: body.acabados,
apartado,
descuento: propuesta.descuento,
precioLista,
precioFinal: propuesta.precioFinal,
enganche: propuesta.enganche,
numMensualidades: propuesta.numMensualidades,
mensualidad: propuesta.mensualidad,
pagoFinal: propuesta.pagoFinal,
},
})
// Registra la oportunidad en Monday; si Monday falla, la cotización ya quedó persistida
let mondayItemId: string | null = null
try {
mondayItemId = await mondayService.crearOportunidad({
clienteNombre: body.clienteNombre,
unidadCodigo: unidad.codigo,
tipoPropuestaLabel: body.tipoPropuesta,
precioLista,
descuentoPct: propuesta.descuento * 100,
precioFinal: propuesta.precioFinal,
fechaCotizacion: hoyISO(),
estadoLabel: 'COTIZADA',
...(body.clienteEmail !== undefined ? { clienteEmail: body.clienteEmail } : {}),
...(body.clienteTelefono !== undefined ? { clienteTelefono: body.clienteTelefono } : {}),
})
await prisma.cotizacion.update({ where: { id: cotizacion.id }, data: { mondayItemId } })
} catch (err) {
// eslint-disable-next-line no-console
console.error('No se pudo crear la oportunidad en Monday:', err)
}
res.status(201).json({ ...cotizacion, mondayItemId, propuesta })
}
/** GET /api/cotizaciones */
export async function listarCotizaciones(_req: Request, res: Response): Promise<void> {
const cotizaciones = await prisma.cotizacion.findMany({ orderBy: { createdAt: 'desc' } })
res.json(cotizaciones)
}
/** GET /api/cotizaciones/:id */
export async function obtenerCotizacion(req: Request, res: Response): Promise<void> {
const cotizacion = await prisma.cotizacion.findUnique({ where: { id: String(req.params['id']) } })
if (!cotizacion) throw new NotFoundError('Cotización no encontrada')
res.json(cotizacion)
}

View File

@@ -0,0 +1,8 @@
import type { Request, Response } from 'express'
import { mondayService } from '../services/monday.service.js'
/** GET /api/health — ping barato a Monday */
export async function health(_req: Request, res: Response): Promise<void> {
const mondayUp = await mondayService.ping()
res.json({ status: 'ok', monday: mondayUp ? 'up' : 'down' })
}

View File

@@ -0,0 +1,236 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { prisma } from '../services/prisma.js'
import {
alCorriente,
avancePlan,
cobranzaDelDia,
construirNota,
diasAtraso,
generarCalendario,
pagadoTotal,
parseFechaLocal,
round2,
saldoPlan,
statusPago,
statusPlan,
totalMora,
validarAbono,
type PlanPago as PlanPagoDominio,
} from '../services/cobranza.service.js'
import { BadRequestError, NotFoundError } from '../utils/errors.js'
import type { AplicacionPago, NotaPlan, Pago, PlanPago } from '@prisma/client'
// ---------- Mapeo Prisma → dominio (funciones puras del servicio) ----------
const aFechaISO = (d: Date): string => {
const m = String(d.getMonth() + 1).padStart(2, '0')
const dia = String(d.getDate()).padStart(2, '0')
return `${d.getFullYear()}-${m}-${dia}`
}
type PlanCompleto = PlanPago & {
pagos: (Pago & { aplicaciones: AplicacionPago[] })[]
notas: NotaPlan[]
}
function aDominio(plan: PlanCompleto): PlanPagoDominio {
return {
id: plan.id,
ventaRef: plan.ventaRef,
clienteNombre: plan.clienteNombre,
clienteTelefono: plan.clienteTelefono,
unidadCodigo: plan.unidadCodigo,
proyecto: plan.proyecto,
fechaInicio: aFechaISO(plan.fechaInicio),
engancheTotal: plan.engancheTotal,
numPagos: plan.numPagos,
mensualidad: plan.mensualidad,
tasaMoraMensual: plan.tasaMoraMensual,
status: plan.status === 'CANCELADO' ? 'CANCELADO' : 'ACTIVO',
createdAt: plan.createdAt.toISOString(),
pagos: plan.pagos
.slice()
.sort((a, b) => a.numPago - b.numPago)
.map((p) => ({
id: p.id,
numPago: p.numPago,
fechaVencimiento: aFechaISO(p.fechaVencimiento),
monto: p.monto,
montoPagado: p.montoPagado,
aplicaciones: p.aplicaciones.map((a) => ({
id: a.id,
fechaPago: a.fechaPago.toISOString(),
monto: a.monto,
referencia: a.referencia,
})),
})),
notas: plan.notas.map((n) => ({ id: n.id, fecha: n.fecha.toISOString(), texto: n.texto })),
}
}
/** Plan con los campos calculados que la app de cobranza muestra */
function conCalculados(plan: PlanPagoDominio) {
const hoy = new Date()
return {
...plan,
status: statusPlan(plan),
saldo: saldoPlan(plan),
pagadoTotal: pagadoTotal(plan),
avance: avancePlan(plan),
alCorriente: alCorriente(plan, hoy),
diasAtraso: diasAtraso(plan, hoy),
totalMora: totalMora(plan, hoy),
pagos: plan.pagos.map((p) => ({ ...p, status: statusPago(p, hoy) })),
}
}
const includeCompleto = { pagos: { include: { aplicaciones: true } }, notas: true } as const
async function cargarPlan(id: string): Promise<PlanCompleto> {
const plan = await prisma.planPago.findUnique({ where: { id }, include: includeCompleto })
if (!plan) throw new NotFoundError('Plan de pago no encontrado')
return plan
}
// ---------- Endpoints ----------
const crearPlanSchema = z.object({
ventaRef: z.string().optional(),
clienteNombre: z.string().min(1),
clienteTelefono: z.string().optional(),
unidadCodigo: z.string().min(1),
proyecto: z.string().min(1),
fechaInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'fechaInicio debe ser YYYY-MM-DD'),
engancheTotal: z.number().positive(),
numPagos: z.number().int().min(1),
tasaMoraMensual: z.number().min(0).optional(),
})
/** POST /api/planes — genera el calendario de pagos y lo persiste */
export async function crearPlan(req: Request, res: Response): Promise<void> {
const body = crearPlanSchema.parse(req.body)
let calendario
try {
calendario = generarCalendario(body.fechaInicio, body.engancheTotal, body.numPagos)
} catch (err) {
throw new BadRequestError(err instanceof Error ? err.message : 'Parámetros inválidos')
}
const plan = await prisma.planPago.create({
data: {
ventaRef: body.ventaRef ?? null,
clienteNombre: body.clienteNombre,
clienteTelefono: body.clienteTelefono ?? null,
unidadCodigo: body.unidadCodigo,
proyecto: body.proyecto,
fechaInicio: parseFechaLocal(body.fechaInicio),
engancheTotal: body.engancheTotal,
numPagos: body.numPagos,
mensualidad: calendario.mensualidad,
tasaMoraMensual: body.tasaMoraMensual ?? 0,
pagos: {
create: calendario.pagos.map((p) => ({
numPago: p.numPago,
fechaVencimiento: parseFechaLocal(p.fechaVencimiento),
monto: p.monto,
})),
},
},
include: includeCompleto,
})
res.status(201).json(conCalculados(aDominio(plan)))
}
/** GET /api/planes — con saldo/status calculados */
export async function listarPlanes(_req: Request, res: Response): Promise<void> {
const planes = await prisma.planPago.findMany({
include: includeCompleto,
orderBy: { createdAt: 'desc' },
})
res.json(planes.map((p) => conCalculados(aDominio(p))))
}
/** GET /api/planes/cobranza-del-dia — lista plana de urgencias (antes de /:id) */
export async function cobranzaHoy(req: Request, res: Response): Promise<void> {
const dias = Number(req.query['diasAnticipacion'] ?? 7)
const planes = await prisma.planPago.findMany({ include: includeCompleto })
const items = cobranzaDelDia(
planes.map(aDominio),
new Date(),
Number.isFinite(dias) ? dias : 7,
)
res.json(items)
}
/** GET /api/planes/:id */
export async function obtenerPlan(req: Request, res: Response): Promise<void> {
const plan = await cargarPlan(String(req.params['id']))
res.json(conCalculados(aDominio(plan)))
}
const abonoSchema = z.object({
pagoId: z.string().min(1),
fechaPago: z.string().min(1),
monto: z.number(),
referencia: z.string().min(1),
})
/** POST /api/planes/:id/abonos — aplica un abono (estricto al centavo) */
export async function registrarAbonoEndpoint(req: Request, res: Response): Promise<void> {
const body = abonoSchema.parse(req.body)
const plan = await cargarPlan(String(req.params['id']))
const dominio = aDominio(plan)
let resultado
try {
resultado = validarAbono(dominio, body)
} catch (err) {
throw new BadRequestError(err instanceof Error ? err.message : 'Abono inválido')
}
const { pago, aplicacion } = resultado
await prisma.$transaction([
prisma.aplicacionPago.create({
data: {
pagoId: pago.id,
fechaPago: new Date(body.fechaPago),
monto: aplicacion.monto,
referencia: aplicacion.referencia,
},
}),
prisma.pago.update({
where: { id: pago.id },
data: { montoPagado: round2(pago.montoPagado + aplicacion.monto) },
}),
])
const actualizado = await cargarPlan(plan.id)
res.status(201).json(conCalculados(aDominio(actualizado)))
}
const notaSchema = z.object({ texto: z.string() })
/** POST /api/planes/:id/notas */
export async function agregarNota(req: Request, res: Response): Promise<void> {
const { texto } = notaSchema.parse(req.body)
const plan = await cargarPlan(String(req.params['id']))
let nota
try {
nota = construirNota(texto)
} catch (err) {
throw new BadRequestError(err instanceof Error ? err.message : 'Nota inválida')
}
const creada = await prisma.notaPlan.create({
data: { planId: plan.id, texto: nota.texto },
})
res.status(201).json(creada)
}
/** POST /api/planes/:id/cancelar */
export async function cancelarPlan(req: Request, res: Response): Promise<void> {
const plan = await cargarPlan(String(req.params['id']))
await prisma.planPago.update({ where: { id: plan.id }, data: { status: 'CANCELADO' } })
const actualizado = await cargarPlan(plan.id)
res.json(conCalculados(aDominio(actualizado)))
}

View File

@@ -0,0 +1,17 @@
import type { Request, Response } from 'express'
import { mondayService } from '../services/monday.service.js'
import { NotFoundError } from '../utils/errors.js'
/** GET /api/unidades — ?disponibles=true filtra solo DISPONIBLE */
export async function listarUnidades(req: Request, res: Response): Promise<void> {
const unidades = await mondayService.getUnidades()
const disponibles = req.query['disponibles'] === 'true'
res.json(disponibles ? unidades.filter((u) => u.status === 'DISPONIBLE') : unidades)
}
/** GET /api/unidades/:codigo — 404 si no existe */
export async function obtenerUnidad(req: Request, res: Response): Promise<void> {
const unidad = await mondayService.getUnidad(String(req.params['codigo']))
if (!unidad) throw new NotFoundError(`Unidad ${String(req.params['codigo'])} no encontrada`)
res.json(unidad)
}

View File

@@ -0,0 +1,36 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
const eventoSchema = z
.object({
type: z.string().optional(),
boardId: z.union([z.string(), z.number()]).optional(),
itemId: z.union([z.string(), z.number()]).optional(),
})
.passthrough()
/**
* POST /api/webhooks/monday
* Handshake: Monday envía { challenge } al registrar el webhook y exige que
* se responda tal cual. Para eventos reales solo se loggea y se responde 200.
*
* OJO: Monday NO firma sus webhooks → el payload no es confiable. Antes de
* actuar sobre un evento hay que re-consultar el estado vía API (por ahora
* solo se registra el evento).
*/
export function webhookMonday(req: Request, res: Response): void {
const body = req.body as Record<string, unknown>
if (typeof body?.['challenge'] === 'string') {
res.json({ challenge: body['challenge'] })
return
}
const parsed = eventoSchema.safeParse(body)
if (parsed.success) {
const { type, boardId, itemId } = parsed.data
// eslint-disable-next-line no-console
console.log(`[webhook monday] tipo=${type ?? 'desconocido'} board=${boardId ?? '?'} item=${itemId ?? '?'}`)
}
res.sendStatus(200)
}

9
api-gateway/src/index.ts Normal file
View File

@@ -0,0 +1,9 @@
import { crearApp } from './app.js'
import { config } from './config/index.js'
const app = crearApp()
app.listen(config.port, () => {
// eslint-disable-next-line no-console
console.log(`API Gateway FRASA escuchando en http://localhost:${config.port} (${config.nodeEnv})`)
})

View File

@@ -0,0 +1,23 @@
import type { NextFunction, Request, Response } from 'express'
import { ZodError } from 'zod'
import { MulterError } from 'multer'
import { AppError } from '../utils/errors.js'
/** Error handler central: zod → 400 con issues, AppError → su status, resto → 500 */
export function errorHandler(err: unknown, req: Request, res: Response, _next: NextFunction): void {
if (err instanceof ZodError) {
res.status(400).json({ error: 'Validación fallida', issues: err.issues })
return
}
if (err instanceof MulterError) {
res.status(400).json({ error: `Error de carga de archivos: ${err.message}` })
return
}
if (err instanceof AppError) {
res.status(err.status).json({ error: err.message })
return
}
// eslint-disable-next-line no-console
console.error(`[500] ${req.method} ${req.originalUrl}:`, err)
res.status(500).json({ error: 'Error interno del servidor' })
}

View File

@@ -0,0 +1,11 @@
import type { NextFunction, Request, Response } from 'express'
/** Logger de requests simple: método, ruta, status y duración */
export function requestLogger(req: Request, res: Response, next: NextFunction): void {
const inicio = Date.now()
res.on('finish', () => {
// eslint-disable-next-line no-console
console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${Date.now() - inicio}ms`)
})
next()
}

View File

@@ -0,0 +1,51 @@
import { Router } from 'express'
import { health } from '../controllers/health.controller.js'
import { listarUnidades, obtenerUnidad } from '../controllers/unidades.controller.js'
import {
crearCotizacion,
listarCotizaciones,
obtenerCotizacion,
} from '../controllers/cotizaciones.controller.js'
import {
crearCliente,
listarDocumentos,
obtenerCliente,
subirDocumentos,
uploadDocumentos,
} from '../controllers/clientes.controller.js'
import {
agregarNota,
cancelarPlan,
cobranzaHoy,
crearPlan,
listarPlanes,
obtenerPlan,
registrarAbonoEndpoint,
} from '../controllers/planes.controller.js'
import { webhookMonday } from '../controllers/webhooks.controller.js'
export const apiRouter = Router()
apiRouter.get('/health', health)
apiRouter.get('/unidades', listarUnidades)
apiRouter.get('/unidades/:codigo', obtenerUnidad)
apiRouter.post('/cotizaciones', crearCotizacion)
apiRouter.get('/cotizaciones', listarCotizaciones)
apiRouter.get('/cotizaciones/:id', obtenerCotizacion)
apiRouter.post('/clientes', crearCliente)
apiRouter.get('/clientes/:id', obtenerCliente)
apiRouter.post('/clientes/:id/documentos', uploadDocumentos.array('archivos', 10), subirDocumentos)
apiRouter.get('/clientes/:id/documentos', listarDocumentos)
apiRouter.post('/planes', crearPlan)
apiRouter.get('/planes', listarPlanes)
apiRouter.get('/planes/cobranza-del-dia', cobranzaHoy)
apiRouter.get('/planes/:id', obtenerPlan)
apiRouter.post('/planes/:id/abonos', registrarAbonoEndpoint)
apiRouter.post('/planes/:id/notas', agregarNota)
apiRouter.post('/planes/:id/cancelar', cancelarPlan)
apiRouter.post('/webhooks/monday', webhookMonday)

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

View File

@@ -0,0 +1,24 @@
/** Error con HTTP status para que el error handler lo traduzca a respuesta */
export class AppError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message)
this.name = 'AppError'
}
}
export class NotFoundError extends AppError {
constructor(message = 'Recurso no encontrado') {
super(404, message)
this.name = 'NotFoundError'
}
}
export class BadRequestError extends AppError {
constructor(message: string) {
super(400, message)
this.name = 'BadRequestError'
}
}