- PlanPago.vendedorId + filtro ?mios=1; toggle Mis planes/Todos en /pay - Motor de recordatorios: próximos a vencer (3d) y vencidos (re-cada 7d), canal Monday Actividad + stub WhatsApp listo para WA CRM, log persistente, cron diario 9:30, run manual con dry-run, historial en /api/cobranza/recordatorios - Agente IA: memoria por usuario (conversaciones aisladas) y tool calling: cotizar_unidad (motor real por desarrollo) y buscar_unidad (inventario Monday) - Tests: api 261, cobranza 75, agente-ia 13
313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
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 { config } from '../config/index.js'
|
|
import { mondayService, type MondayService } from '../services/monday.service.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[]
|
|
}
|
|
|
|
/** Todos los planes en forma de dominio (lo que necesita el agregador de reportes) */
|
|
export async function cargarPlanesDominio(): Promise<PlanPagoDominio[]> {
|
|
const planes = await prisma.planPago.findMany({ include: includeCompleto })
|
|
return planes.map(aDominio)
|
|
}
|
|
|
|
// Exportado también para reportes.controller (agregador de cobranza)
|
|
export 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',
|
|
mondayItemId: plan.mondayItemId,
|
|
vendedorId: plan.vendedorId,
|
|
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(),
|
|
// Item del tablero Apartado (Monday) ligado al plan; también ligable después con PATCH
|
|
mondayItemId: z.string().min(1).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,
|
|
mondayItemId: body.mondayItemId ?? null,
|
|
// Dueño del plan: sub del JWT (null si AUTH_ENABLED=false o token sin sub)
|
|
vendedorId: req.usuarioToken?.sub ?? null,
|
|
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)))
|
|
}
|
|
|
|
// Roles con vista global de cobranza: el filtro ?mios=1 no les aplica
|
|
const ROLES_VEN_TODO: readonly string[] = ['admin', 'coordinador']
|
|
|
|
/**
|
|
* Filtro "mis cobranzas": con ?mios=1 un vendedor solo ve sus planes
|
|
* (vendedorId = sub del token). Admin/coordinador ven todo aunque pidan mios;
|
|
* sin token (AUTH_ENABLED=false) el filtro no aplica.
|
|
*/
|
|
export function resolverFiltroMios(
|
|
mios: unknown,
|
|
token: { sub: string; rol: string } | undefined,
|
|
): { vendedorId: string } | undefined {
|
|
if (mios !== '1') return undefined
|
|
if (!token) return undefined
|
|
if (ROLES_VEN_TODO.includes(token.rol)) return undefined
|
|
return { vendedorId: token.sub }
|
|
}
|
|
|
|
/** GET /api/planes — con saldo/status calculados; ?mios=1 filtra los del vendedor */
|
|
export async function listarPlanes(req: Request, res: Response): Promise<void> {
|
|
const where = resolverFiltroMios(req.query['mios'], req.usuarioToken)
|
|
const planes = await prisma.planPago.findMany({
|
|
...(where ? { where } : {}),
|
|
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),
|
|
})
|
|
|
|
type MondayApartado = Pick<MondayService, 'cambiarValorColumna'>
|
|
|
|
/**
|
|
* Puente cobranza → postventa: cuando el plan queda LIQUIDADO y está ligado a
|
|
* un item del tablero Apartado, ese item pasa a 'X Escriturar'.
|
|
* Un fallo de Monday NUNCA rompe el abono: se loguea y devuelve false.
|
|
*/
|
|
export async function notificarEscrituracionSiLiquidado(
|
|
plan: { mondayItemId?: string | null },
|
|
liquidado: boolean,
|
|
monday: MondayApartado = mondayService,
|
|
): Promise<boolean> {
|
|
if (!liquidado || plan.mondayItemId == null || plan.mondayItemId === '') return false
|
|
try {
|
|
await monday.cambiarValorColumna(config.monday.boards.apartado, plan.mondayItemId, 'Estado', 'X Escriturar')
|
|
return true
|
|
} catch (err) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(`No se pudo mover el apartado ${plan.mondayItemId} a 'X Escriturar':`, err)
|
|
return false
|
|
}
|
|
}
|
|
|
|
/** 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)
|
|
const dominioActualizado = aDominio(actualizado)
|
|
const calculado = conCalculados(dominioActualizado)
|
|
|
|
// Si el abono liquidó el plan, avisar a postventa (no bloquea la respuesta)
|
|
await notificarEscrituracionSiLiquidado(dominioActualizado, calculado.status === 'LIQUIDADO')
|
|
|
|
res.status(201).json(calculado)
|
|
}
|
|
|
|
const ligarMondaySchema = z.object({ mondayItemId: z.string().min(1).nullable() })
|
|
|
|
/** PATCH /api/planes/:id/monday — liga (o desliga con null) el item de Apartado en Monday */
|
|
export async function ligarMondayItem(req: Request, res: Response): Promise<void> {
|
|
const body = ligarMondaySchema.parse(req.body)
|
|
const plan = await cargarPlan(String(req.params['id']))
|
|
await prisma.planPago.update({ where: { id: plan.id }, data: { mondayItemId: body.mondayItemId } })
|
|
const actualizado = await cargarPlan(plan.id)
|
|
res.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)))
|
|
}
|