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