feat: mis cobranzas, recordatorios de pago y agente IA con acciones

- 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
This commit is contained in:
2026-08-28 08:07:12 +00:00
parent 59944668a8
commit aa27e89c75
18 changed files with 1437 additions and 17 deletions

View File

@@ -1,16 +1,22 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NanClient } from './ai.service.js'
import type { HerramientaDef, MensajeApi, NanClient, RespuestaCruda } from './ai.service.js'
import {
HERRAMIENTAS,
chat,
construirSystemPrompt,
crearEjecutoresHerramientas,
decidirIndexacion,
dedupeFuentes,
ejecutarToolCall,
fragmentar,
hashContenido,
indexarDocumento,
rankearChunks,
similitudCoseno,
type ArgsBuscarUnidad,
type ArgsCotizarUnidad,
} from './ai.service.js'
import { calcularPropuesta, obtenerPrecioLista, totalPagado, type UnidadCotizable } from './cotizacion.service.js'
// ---------- Prisma mockeado (los tests no tocan la BD ni la red) ----------
@@ -277,3 +283,243 @@ describe('dedupeFuentes', () => {
])
})
})
// ---------- Herramientas: cotizar_unidad / buscar_unidad ----------
const unidadSolana: UnidadCotizable = {
codigo: 'SOLANA-6',
modelo: 'Modelo A',
fechaEntrega: null,
supEstacionamiento: 12,
supConstruccion: 60,
supExcedente: 0,
precioSinAcabados: 2_000_000,
precioConAcabados: 2_100_000,
status: 'DISPONIBLE',
desarrollo: 'SOLANA',
mesesParaFirma: 12,
etapa: null,
}
const unidadApartada: UnidadCotizable = { ...unidadSolana, codigo: 'SOLANA-7', status: 'APARTADO' }
const unidadDiagonal: UnidadCotizable = {
...unidadSolana,
codigo: 'DIA-101',
modelo: 'Loft 1R',
desarrollo: 'DIAGONAL',
precioSinAcabados: 2_850_000,
fechaEntrega: '2027-06-30',
mesesParaFirma: null,
}
function ejecutoresCon(unidades: UnidadCotizable[]) {
return crearEjecutoresHerramientas(async () => unidades)
}
describe('crearEjecutoresHerramientas', () => {
it('cotizar_unidad devuelve los números del motor para una unidad DISPONIBLE', async () => {
const r = (await ejecutoresCon([unidadSolana]).cotizar_unidad({ codigo: 'solana-6' })) as Record<string, unknown>
const esperada = calcularPropuesta(unidadSolana, 'BANCARIO', false, 20000)
expect(r['error']).toBeUndefined()
expect(r['unidad']).toBe('SOLANA-6')
expect(r['enganche']).toBeCloseTo(esperada.enganche, 2)
expect(r['mensualidad']).toBeCloseTo(esperada.mensualidad!, 2)
expect(r['pagoFinal']).toBeCloseTo(esperada.pagoFinal, 2)
expect(r['precioLista']).toBe(obtenerPrecioLista(unidadSolana, false))
expect(r['totalPagado']).toBeCloseTo(totalPagado(esperada, 20000)!, 2)
expect(String(r['nota'])).toContain('orientativa')
})
it('cotizar_unidad: unidad inexistente o no DISPONIBLE devuelve error claro', async () => {
const e = ejecutoresCon([unidadApartada])
expect(await e.cotizar_unidad({ codigo: 'NOPE-1' })).toEqual({
error: 'La unidad NOPE-1 no existe en el inventario',
})
const r = (await e.cotizar_unidad({ codigo: 'SOLANA-7' })) as { error: string }
expect(r.error).toContain('no está DISPONIBLE')
expect(r.error).toContain('APARTADO')
})
it('cotizar_unidad: tipoPropuesta inválido devuelve error con los válidos', async () => {
const r = (await ejecutoresCon([unidadSolana]).cotizar_unidad({
codigo: 'SOLANA-6',
tipoPropuesta: 'leasing',
})) as { error: string }
expect(r.error).toContain('tipoPropuesta inválido')
expect(r.error).toContain('BANCARIO')
})
it('buscar_unidad filtra por desarrollo, modelo y precioMax; solo DISPONIBLE, máx 5', async () => {
const muchas = Array.from({ length: 8 }, (_, i) => ({
...unidadSolana,
codigo: `SOLANA-${i + 1}`,
}))
const e = ejecutoresCon([unidadSolana, unidadApartada, unidadDiagonal, ...muchas])
// solo DISPONIBLE y tope de 5
const todas = (await e.buscar_unidad({})) as { codigo: string }[]
expect(todas).toHaveLength(5)
expect(todas.every((u) => u.codigo !== 'SOLANA-7')).toBe(true)
// por desarrollo
const diagonal = (await e.buscar_unidad({ desarrollo: 'diagonal' })) as { codigo: string }[]
expect(diagonal.map((u) => u.codigo)).toEqual(['DIA-101'])
// por modelo (texto contenido, case-insensitive)
const loft = (await e.buscar_unidad({ modelo: 'loft' })) as { codigo: string }[]
expect(loft.map((u) => u.codigo)).toEqual(['DIA-101'])
// por precio máximo (sin acabados)
const baratas = (await e.buscar_unidad({ precioMax: 2_500_000 })) as { precio: number }[]
expect(baratas.length).toBeGreaterThan(0)
expect(baratas.every((u) => u.precio <= 2_500_000)).toBe(true)
})
})
describe('ejecutarToolCall', () => {
const ejecutores = {
async cotizar_unidad(args: ArgsCotizarUnidad) {
return { ok: true, args }
},
async buscar_unidad(_args: ArgsBuscarUnidad) {
throw new Error('Monday caído')
},
}
it('parsea los argumentos JSON y devuelve el resultado serializado', async () => {
const r = await ejecutarToolCall(
{ id: 't1', nombre: 'cotizar_unidad', argumentosJson: '{"codigo":"SOLANA-6"}' },
ejecutores,
)
expect(JSON.parse(r)).toEqual({ ok: true, args: { codigo: 'SOLANA-6' } })
})
it('argumentos inválidos, herramienta desconocida o error del ejecutor van como JSON de error', async () => {
expect(JSON.parse(await ejecutarToolCall({ id: 't', nombre: 'cotizar_unidad', argumentosJson: '{mal' }, ejecutores)))
.toHaveProperty('error')
expect(JSON.parse(await ejecutarToolCall({ id: 't', nombre: 'borrar_todo', argumentosJson: '{}' }, ejecutores)))
.toEqual({ error: 'Herramienta desconocida: borrar_todo' })
expect(JSON.parse(await ejecutarToolCall({ id: 't', nombre: 'buscar_unidad', argumentosJson: '{}' }, ejecutores)))
.toEqual({ error: 'Monday caído' })
})
})
// ---------- Chat con tool calling ----------
/** Cliente NAN falso con function calling: devuelve las respuestas guionizadas en orden */
function clienteConTools(respuestas: RespuestaCruda[]) {
const llamadas: { mensajes: MensajeApi[]; herramientas: HerramientaDef[] }[] = []
const client: NanClient & { llamadas: typeof llamadas } = {
llamadas,
async embed(textos: string[]) {
return textos.map((t) => [t.length, 1, 1])
},
async chat() {
return 'chat plano'
},
async chatConTools(mensajes, herramientas) {
// snapshot: el array de mensajes se muta entre rondas
llamadas.push({ mensajes: mensajes.map((m) => ({ ...m })), herramientas })
return respuestas[llamadas.length - 1] ?? { contenido: 'fin', toolCalls: [] }
},
}
return client
}
function mocksPrismaChat() {
prismaMock.aIConversacion.create.mockResolvedValue({ id: 'conv-tools', createdAt: new Date() })
prismaMock.aIChunk.findMany.mockResolvedValue([])
prismaMock.aIMensaje.findMany.mockResolvedValue([])
prismaMock.aIMensaje.create.mockResolvedValue({})
}
describe('chat con herramientas (tool calling)', () => {
it('ejecuta el tool pedido y hace la segunda llamada con el resultado', async () => {
mocksPrismaChat()
const client = clienteConTools([
{
contenido: null,
toolCalls: [{ id: 'call-1', nombre: 'cotizar_unidad', argumentosJson: '{"codigo":"SOLANA-6","tipoPropuesta":"BANCARIO"}' }],
},
{ contenido: 'La SOLANA-6 queda con enganche de $80,000…', toolCalls: [] },
])
const argsRecibidos: ArgsCotizarUnidad[] = []
const ejecutores = {
async cotizar_unidad(args: ArgsCotizarUnidad) {
argsRecibidos.push(args)
return { enganche: 80000, mensualidad: 8333.33 }
},
async buscar_unidad() {
return []
},
}
const r = await chat(null, 'cotízame la SOLANA-6 a bancario', client, { ejecutores })
expect(r.respuesta).toContain('80,000')
expect(client.llamadas).toHaveLength(2)
// primera llamada: con las herramientas registradas
expect(client.llamadas[0]!.herramientas).toEqual(HERRAMIENTAS)
// el ejecutor recibió los argumentos parseados
expect(argsRecibidos).toEqual([{ codigo: 'SOLANA-6', tipoPropuesta: 'BANCARIO' }])
// segunda llamada: historial con el tool_call del asistente y la respuesta del tool
const msgs2 = client.llamadas[1]!.mensajes
const asistente = msgs2.find((m) => m.role === 'assistant' && m.tool_calls)
expect(asistente?.tool_calls?.[0]).toMatchObject({ id: 'call-1', function: { name: 'cotizar_unidad' } })
const toolMsg = msgs2.find((m) => m.role === 'tool')
expect(toolMsg?.tool_call_id).toBe('call-1')
expect(JSON.parse(toolMsg!.content!)).toMatchObject({ enganche: 80000 })
// se persisten solo user/assistant (los tools no van a la BD)
const roles = prismaMock.aIMensaje.create.mock.calls.map((c) => (c[0] as { data: { rol: string } }).data.rol)
expect(roles).toEqual(['user', 'assistant'])
})
it('máx 2 rondas de tools: la llamada de cierre va sin herramientas', async () => {
mocksPrismaChat()
const toolCall = (id: string): RespuestaCruda => ({
contenido: null,
toolCalls: [{ id, nombre: 'buscar_unidad', argumentosJson: '{}' }],
})
const client = clienteConTools([toolCall('c1'), toolCall('c2'), { contenido: 'cierre forzado', toolCalls: [] }])
const ejecutores = {
async cotizar_unidad() {
return {}
},
async buscar_unidad() {
return [{ codigo: 'SOLANA-6' }]
},
}
const r = await chat(null, 'búscame algo y cotízalo todo', client, { ejecutores })
expect(client.llamadas).toHaveLength(3) // 2 rondas de tools + cierre
expect(client.llamadas[2]!.herramientas).toEqual([])
expect(r.respuesta).toBe('cierre forzado')
})
it('respuesta directa sin tools: una sola llamada', async () => {
mocksPrismaChat()
const client = clienteConTools([{ contenido: 'Respuesta directa.', toolCalls: [] }])
const r = await chat(null, 'hola', client, {
ejecutores: { cotizar_unidad: async () => ({}), buscar_unidad: async () => [] },
})
expect(client.llamadas).toHaveLength(1)
expect(r.respuesta).toBe('Respuesta directa.')
})
})
// ---------- Memoria por usuario ----------
describe('chat: memoria por usuario', () => {
it('la conversación nueva queda ligada al usuarioId del token', async () => {
mocksPrismaChat()
const client = clienteFalso()
await chat(null, 'hola', client, { usuarioId: 'user-123' })
expect(prismaMock.aIConversacion.create).toHaveBeenCalledWith({ data: { usuarioId: 'user-123' } })
prismaMock.aIConversacion.create.mockClear()
await chat(null, 'hola', client)
expect(prismaMock.aIConversacion.create).toHaveBeenCalledWith({ data: { usuarioId: null } })
})
})

View File

@@ -2,6 +2,16 @@ import crypto from 'node:crypto'
import { prisma } from './prisma.js'
import { config } from '../config/index.js'
import { AppError, NotFoundError } from '../utils/errors.js'
import { mondayService } from './monday.service.js'
import {
APARTADO_DEFAULT,
calcularPropuesta,
obtenerPrecioLista,
TIPO_PROPUESTA_API,
totalPagado,
type TipoPropuestaApi,
type UnidadCotizable,
} from './cotizacion.service.js'
// ---------- Cliente NAN (API OpenAI-compatible: chat + embeddings) ----------
@@ -10,9 +20,36 @@ export interface MensajeChat {
contenido: string
}
// Mensaje en el formato crudo de la API (incluye tool calls y respuestas de tools)
export interface MensajeApi {
role: 'system' | 'user' | 'assistant' | 'tool'
content: string | null
tool_calls?: { id: string; type: 'function'; function: { name: string; arguments: string } }[]
tool_call_id?: string
}
export interface ToolCallSolicitado {
id: string
nombre: string
argumentosJson: string
}
export interface RespuestaCruda {
contenido: string | null
toolCalls: ToolCallSolicitado[]
}
/** Definición de herramienta en el formato function-calling de OpenAI */
export interface HerramientaDef {
type: 'function'
function: { name: string; description: string; parameters: Record<string, unknown> }
}
export interface NanClient {
chat(mensajes: MensajeChat[]): Promise<string>
embed(textos: string[]): Promise<number[][]>
/** Chat con herramientas (function calling); opcional para compat con clientes simples */
chatConTools?(mensajes: MensajeApi[], herramientas: HerramientaDef[]): Promise<RespuestaCruda>
}
interface NanOpciones {
@@ -47,6 +84,32 @@ export function crearNanClient(opciones: NanOpciones): NanClient {
if (!contenido) throw new AppError(502, 'El proveedor IA no devolvió respuesta')
return contenido
},
async chatConTools(mensajes, herramientas) {
const body: Record<string, unknown> = {
model: opciones.chatModel,
messages: mensajes,
temperature: 0.2,
}
// Sin herramientas registradas el proveedor responde texto plano (cierre del loop)
if (herramientas.length > 0) {
body['tools'] = herramientas
body['tool_choice'] = 'auto'
}
const data = (await postJson(`${opciones.baseUrl}/chat/completions`, opciones.apiKey, body)) as {
choices?: {
message?: {
content?: string | null
tool_calls?: { id: string; type: string; function: { name: string; arguments: string } }[]
}
}[]
}
const message = data.choices?.[0]?.message
if (!message) throw new AppError(502, 'El proveedor IA no devolvió respuesta')
const toolCalls = (message.tool_calls ?? [])
.filter((t) => t.type === 'function')
.map((t) => ({ id: t.id, nombre: t.function.name, argumentosJson: t.function.arguments }))
return { contenido: message.content ?? null, toolCalls }
},
async embed(textos) {
if (textos.length === 0) return []
const data = (await postJson(`${opciones.baseUrl}/embeddings`, opciones.apiKey, {
@@ -186,6 +249,13 @@ export function construirSystemPrompt(contexto: ChunkRecuperado[]): string {
'- Responde en español, de forma clara y concisa.',
'- Al final de tu respuesta cita las fuentes usadas con el formato: "Fuentes: <título 1>, <título 2>".',
'',
'HERRAMIENTAS:',
'- Tienes herramientas para cotizar una unidad con el motor del cotizador y para buscar unidades',
' disponibles. Úsalas cuando te pidan números o disponibilidad; NUNCA inventes precios ni cifras.',
'- Cuando uses cotizar_unidad, presenta enganche, mensualidades y pago final, y aclara que la',
' cotización es ORIENTATIVA: el PDF oficial se genera en el cotizador (/quote/).',
'- Si la unidad no existe o no está DISPONIBLE, dilo claramente y ofrece buscar alternativas.',
'',
contexto.length > 0
? `CONTEXTO:\n\n${bloques}`
: 'CONTEXTO: (vacío — no se encontró información relevante en la base de conocimiento)',
@@ -270,6 +340,160 @@ export async function indexarDocumento(
return { titulo, estado: 'creado', chunks: textos.length }
}
// ---------- Herramientas (function calling): cotizar y buscar unidades ----------
const TIPOS_PROPUESTA_VALIDOS = Object.keys(TIPO_PROPUESTA_API) as TipoPropuestaApi[]
export const HERRAMIENTAS: HerramientaDef[] = [
{
type: 'function',
function: {
name: 'cotizar_unidad',
description:
'Cotiza una unidad con el motor del cotizador (enganche, mensualidad, pago final, total). ' +
'Úsala cuando pidan números de una unidad concreta. Requiere el código de la unidad (p. ej. SOLANA-6).',
parameters: {
type: 'object',
properties: {
codigo: { type: 'string', description: 'Código de la unidad, p. ej. SOLANA-6' },
tipoPropuesta: {
type: 'string',
enum: TIPOS_PROPUESTA_VALIDOS,
description: 'Esquema de pago; default BANCARIO',
},
apartado: { type: 'number', description: `Monto del apartado en MXN; default ${APARTADO_DEFAULT}` },
acabados: { type: 'boolean', description: 'Incluir cocina y closet (default false)' },
},
required: ['codigo'],
},
},
},
{
type: 'function',
function: {
name: 'buscar_unidad',
description:
'Busca hasta 5 unidades DISPONIBLE que cumplan los filtros (desarrollo, modelo, precio máximo).',
parameters: {
type: 'object',
properties: {
desarrollo: { type: 'string', description: 'Desarrollo, p. ej. SOLANA, DIAGONAL, ATELIER' },
modelo: { type: 'string', description: 'Texto contenido en el nombre del modelo' },
precioMax: { type: 'number', description: 'Precio de lista máximo en MXN (sin acabados)' },
},
},
},
},
]
export interface ArgsCotizarUnidad {
codigo?: string
tipoPropuesta?: string
apartado?: number
acabados?: boolean
}
export interface ArgsBuscarUnidad {
desarrollo?: string
modelo?: string
precioMax?: number
}
export interface EjecutoresHerramientas {
cotizar_unidad(args: ArgsCotizarUnidad): Promise<unknown>
buscar_unidad(args: ArgsBuscarUnidad): Promise<unknown>
}
/** Redondeo monetario a 2 decimales para los números que lee el modelo */
const r2 = (x: number | null): number | null => (x === null ? null : Math.round(x * 100) / 100)
/**
* Ejecutores reales: inventario desde Monday (o el buscador inyectado en tests)
* y cálculo con el motor de cotización portado de los Excel.
*/
export function crearEjecutoresHerramientas(
buscarUnidades: () => Promise<UnidadCotizable[]> = () => mondayService.getUnidades(),
): EjecutoresHerramientas {
return {
async cotizar_unidad(args) {
const codigo = (args.codigo ?? '').trim().toUpperCase()
if (codigo === '') return { error: 'Falta el código de la unidad' }
const unidades = await buscarUnidades()
const unidad = unidades.find((u) => u.codigo.toUpperCase() === codigo)
if (!unidad) return { error: `La unidad ${codigo} no existe en el inventario` }
if (unidad.status !== 'DISPONIBLE') {
return { error: `La unidad ${codigo} no está DISPONIBLE (status actual: ${unidad.status})` }
}
const tipo = (args.tipoPropuesta ?? 'BANCARIO').trim().toUpperCase()
if (!TIPOS_PROPUESTA_VALIDOS.includes(tipo as TipoPropuestaApi)) {
return { error: `tipoPropuesta inválido: ${tipo}. Válidos: ${TIPOS_PROPUESTA_VALIDOS.join(', ')}` }
}
const apartado =
typeof args.apartado === 'number' && Number.isFinite(args.apartado) && args.apartado > 0
? args.apartado
: APARTADO_DEFAULT
const acabados = args.acabados === true
const propuesta = calcularPropuesta(unidad, tipo as TipoPropuestaApi, acabados, apartado)
return {
unidad: unidad.codigo,
desarrollo: unidad.desarrollo,
modelo: unidad.modelo,
esquema: propuesta.titulo,
precioLista: r2(obtenerPrecioLista(unidad, acabados)),
descuento: propuesta.descuento,
precioFinal: r2(propuesta.precioFinal),
apartado: r2(apartado),
enganche: r2(propuesta.enganche),
numMensualidades: propuesta.numMensualidades,
mensualidad: r2(propuesta.mensualidad),
mensualidadesTotal: r2(propuesta.mensualidadesTotal),
pagoFinal: r2(propuesta.pagoFinal),
totalPagado: r2(totalPagado(propuesta, apartado)),
nota: 'Cotización orientativa; el PDF oficial se genera en el cotizador (/quote/).',
}
},
async buscar_unidad(args) {
const unidades = await buscarUnidades()
let lista = unidades.filter((u) => u.status === 'DISPONIBLE')
const desarrollo = args.desarrollo?.trim().toUpperCase()
if (desarrollo) lista = lista.filter((u) => (u.desarrollo || '').toUpperCase() === desarrollo)
const modelo = args.modelo?.trim().toLowerCase()
if (modelo) lista = lista.filter((u) => u.modelo.toLowerCase().includes(modelo))
if (typeof args.precioMax === 'number' && Number.isFinite(args.precioMax)) {
lista = lista.filter((u) => u.precioSinAcabados <= args.precioMax!)
}
return lista.slice(0, 5).map((u) => ({
codigo: u.codigo,
desarrollo: u.desarrollo,
modelo: u.modelo,
precio: r2(u.precioSinAcabados),
precioConAcabados: r2(u.precioConAcabados),
supConstruccion: u.supConstruccion,
}))
},
}
}
/** Ejecuta un tool call contra los ejecutores; nunca lanza (el error va al modelo) */
export async function ejecutarToolCall(
call: ToolCallSolicitado,
ejecutores: EjecutoresHerramientas,
): Promise<string> {
let args: Record<string, unknown>
try {
args = JSON.parse(call.argumentosJson || '{}') as Record<string, unknown>
} catch {
return JSON.stringify({ error: `Argumentos inválidos para ${call.nombre}` })
}
const ejecutor = ejecutores[call.nombre as keyof EjecutoresHerramientas]
if (!ejecutor) return JSON.stringify({ error: `Herramienta desconocida: ${call.nombre}` })
try {
return JSON.stringify(await ejecutor(args))
} catch (err) {
return JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
}
}
// ---------- Chat RAG ----------
const HISTORIAL_MAX = 10
@@ -295,16 +519,77 @@ export function dedupeFuentes(chunks: ChunkRecuperado[]): FuenteCitada[] {
return [...porTitulo.entries()].map(([titulo, score]) => ({ titulo, score: Math.round(score * 1000) / 1000 }))
}
export interface OpcionesChat {
/** sub del JWT: la conversación nueva queda ligada al usuario (memoria por usuario) */
usuarioId?: string | null
/** Ejecutores de herramientas (default: los reales contra Monday + motor de cotización) */
ejecutores?: EjecutoresHerramientas
}
/** Máximo de rondas de ejecución de herramientas por mensaje */
export const MAX_RONDAS_TOOLS = 2
/**
* Loop de tool calling: si el modelo pide herramientas, se ejecutan y se hace
* una segunda llamada con los resultados (máx MAX_RONDAS_TOOLS rondas; después
* se fuerza el cierre sin herramientas). Sin chatConTools en el cliente, chat plano.
*/
async function responderConHerramientas(
mensajes: MensajeApi[],
client: NanClient,
ejecutores: EjecutoresHerramientas,
): Promise<string> {
if (!client.chatConTools) {
// Cliente simple (tests legados): chat plano sin tools
return client.chat(
mensajes
.filter((m) => m.role !== 'tool')
.map((m) => ({
rol: m.role === 'assistant' ? ('assistant' as const) : m.role === 'system' ? ('system' as const) : ('user' as const),
contenido: m.content ?? '',
})),
)
}
let herramientas = HERRAMIENTAS
for (let ronda = 0; ronda <= MAX_RONDAS_TOOLS; ronda++) {
const resp = await client.chatConTools(mensajes, herramientas)
if (resp.toolCalls.length === 0) {
if (resp.contenido) return resp.contenido
throw new AppError(502, 'El proveedor IA no devolvió respuesta')
}
if (ronda === MAX_RONDAS_TOOLS) break // no debería pasar: la última llamada va sin tools
mensajes.push({
role: 'assistant',
content: resp.contenido,
tool_calls: resp.toolCalls.map((t) => ({
id: t.id,
type: 'function' as const,
function: { name: t.nombre, arguments: t.argumentosJson },
})),
})
for (const call of resp.toolCalls) {
const resultado = await ejecutarToolCall(call, ejecutores)
mensajes.push({ role: 'tool', tool_call_id: call.id, content: resultado })
}
if (ronda === MAX_RONDAS_TOOLS - 1) herramientas = [] // cierre: forzar texto plano
}
throw new AppError(502, 'El proveedor IA no cerró la respuesta tras usar herramientas')
}
export async function chat(
sessionId: string | null,
mensaje: string,
client: NanClient = clienteNan(),
opciones: OpcionesChat = {},
): Promise<RespuestaChat> {
let conversacion = sessionId
? await prisma.aIConversacion.findUnique({ where: { id: sessionId } })
: null
if (sessionId && !conversacion) throw new NotFoundError('Conversación no encontrada')
conversacion ??= await prisma.aIConversacion.create({ data: {} })
conversacion ??= await prisma.aIConversacion.create({
data: { usuarioId: opciones.usuarioId ?? null },
})
await prisma.aIMensaje.create({
data: { conversacionId: conversacion.id, rol: 'user', contenido: mensaje },
@@ -334,12 +619,16 @@ export async function chat(
orderBy: { createdAt: 'asc' },
take: -HISTORIAL_MAX,
})
const mensajes: MensajeChat[] = [
{ rol: 'system', contenido: construirSystemPrompt(top) },
...historial.map((m) => ({ rol: m.rol === 'assistant' ? ('assistant' as const) : ('user' as const), contenido: m.contenido })),
const mensajes: MensajeApi[] = [
{ role: 'system', content: construirSystemPrompt(top) },
...historial.map((m) => ({ role: m.rol === 'assistant' ? ('assistant' as const) : ('user' as const), content: m.contenido })),
]
const respuesta = await client.chat(mensajes)
const respuesta = await responderConHerramientas(
mensajes,
client,
opciones.ejecutores ?? crearEjecutoresHerramientas(),
)
const fuentes = dedupeFuentes(top)
await prisma.aIMensaje.create({

View File

@@ -46,6 +46,8 @@ export interface PlanPago {
status: StatusPlan
/** Item del tablero Apartado (Monday) ligado al plan (puente a postventa) */
mondayItemId?: string | null
/** Vendedor dueño del plan (sub del JWT); null en planes históricos */
vendedorId?: string | null
pagos: Pago[]
notas: NotaPlan[]
createdAt: string // ISO

View File

@@ -0,0 +1,281 @@
import { describe, expect, it, vi } from 'vitest'
import type { Pago, PlanPago } from './cobranza.service.js'
import {
MondayActividadCanal,
RecordatoriosCobranzaService,
WhatsAppCanal,
candidatosRecordatorio,
debeEnviar,
diasHastaVencimiento,
elegirDestinatario,
type CanalRecordatorio,
type DestinatarioRecordatorio,
type RecordatorioEnviado,
type RecordatoriosStore,
} from './recordatorios-cobranza.service.js'
// ---------- Fixtures ----------
function pago(parcial: Partial<Pago> & { id: string; fechaVencimiento: string }): Pago {
return {
numPago: 1,
monto: 10000,
montoPagado: 0,
aplicaciones: [],
...parcial,
}
}
function plan(parcial: Partial<PlanPago> & { id: string; pagos: Pago[] }): PlanPago {
return {
clienteNombre: 'Cliente Demo',
unidadCodigo: 'SOLANA-6',
proyecto: 'SOLANA',
fechaInicio: '2026-01-15',
engancheTotal: 100000,
numPagos: 10,
mensualidad: 10000,
status: 'ACTIVO',
vendedorId: null,
notas: [],
createdAt: '2026-01-15T00:00:00.000Z',
...parcial,
}
}
/** Suma/resta días a una fecha y devuelve 'YYYY-MM-DD' */
function isoMasDias(base: Date, dias: number): string {
const d = new Date(base.getFullYear(), base.getMonth(), base.getDate() + dias)
const m = String(d.getMonth() + 1).padStart(2, '0')
const dia = String(d.getDate()).padStart(2, '0')
return `${d.getFullYear()}-${m}-${dia}`
}
const HOY = new Date(2026, 7, 28, 12, 0, 0) // 28-ago-2026 al mediodía
// ---------- Fechas ----------
describe('diasHastaVencimiento', () => {
it('0 si vence hoy (sin importar la hora), negativo si ya pasó', () => {
expect(diasHastaVencimiento('2026-08-28', HOY)).toBe(0)
expect(diasHastaVencimiento('2026-08-31', HOY)).toBe(3)
expect(diasHastaVencimiento('2026-08-25', HOY)).toBe(-3)
})
})
// ---------- Detección de candidatos ----------
describe('candidatosRecordatorio', () => {
it('pago que vence dentro de la ventana → PROXIMO_VENCER con los días correctos', () => {
const planes = [plan({ id: 'p1', pagos: [pago({ id: 'pg1', fechaVencimiento: isoMasDias(HOY, 2) })] })]
const r = candidatosRecordatorio(planes, HOY, 3)
expect(r).toHaveLength(1)
expect(r[0]).toMatchObject({ pagoId: 'pg1', tipo: 'PROXIMO_VENCER', dias: 2, montoPendiente: 10000 })
expect(r[0]!.mensaje).toContain('vence en 2 día(s)')
})
it('pago que vence hoy → PROXIMO_VENCER con "vence HOY"', () => {
const planes = [plan({ id: 'p1', pagos: [pago({ id: 'pg1', fechaVencimiento: isoMasDias(HOY, 0) })] })]
const r = candidatosRecordatorio(planes, HOY, 3)
expect(r[0]).toMatchObject({ tipo: 'PROXIMO_VENCER', dias: 0 })
expect(r[0]!.mensaje).toContain('vence HOY')
})
it('pago fuera de la ventana no genera recordatorio', () => {
const planes = [plan({ id: 'p1', pagos: [pago({ id: 'pg1', fechaVencimiento: isoMasDias(HOY, 5) })] })]
expect(candidatosRecordatorio(planes, HOY, 3)).toHaveLength(0)
})
it('pago vencido → VENCIDO con días de atraso', () => {
const planes = [plan({ id: 'p1', pagos: [pago({ id: 'pg1', fechaVencimiento: isoMasDias(HOY, -5) })] })]
const r = candidatosRecordatorio(planes, HOY, 3)
expect(r[0]).toMatchObject({ tipo: 'VENCIDO', dias: -5 })
expect(r[0]!.mensaje).toContain('VENCIDO hace 5 día(s)')
})
it('pago PARCIAL vencido cuenta como VENCIDO con el monto pendiente', () => {
const planes = [
plan({ id: 'p1', pagos: [pago({ id: 'pg1', fechaVencimiento: isoMasDias(HOY, -2), montoPagado: 4000 })] }),
]
const r = candidatosRecordatorio(planes, HOY, 3)
expect(r[0]).toMatchObject({ tipo: 'VENCIDO', montoPendiente: 6000 })
})
it('ignora pagos PAGADO y planes CANCELADO', () => {
const planes = [
plan({ id: 'p1', pagos: [pago({ id: 'pg1', fechaVencimiento: isoMasDias(HOY, 1), montoPagado: 10000 })] }),
plan({ id: 'p2', status: 'CANCELADO', pagos: [pago({ id: 'pg2', fechaVencimiento: isoMasDias(HOY, -10) })] }),
]
expect(candidatosRecordatorio(planes, HOY, 3)).toHaveLength(0)
})
})
// ---------- Dedup ----------
describe('debeEnviar', () => {
it('nunca enviado → sí; mismo día → no (ambos tipos)', () => {
expect(debeEnviar(null, 'VENCIDO', HOY, 7)).toBe(true)
const hoyTemprano = new Date(2026, 7, 28, 1, 0, 0)
expect(debeEnviar(hoyTemprano, 'VENCIDO', HOY, 7)).toBe(false)
expect(debeEnviar(hoyTemprano, 'PROXIMO_VENCER', HOY, 7)).toBe(false)
})
it('PROXIMO_VENCER en otro día → sí (la fecha se acerca)', () => {
const ayer = new Date(2026, 7, 27, 9, 0, 0)
expect(debeEnviar(ayer, 'PROXIMO_VENCER', HOY, 7)).toBe(true)
})
it('VENCIDO: solo re-recuerda cuando pasaron ≥ revolverDias', () => {
const hace3 = new Date(2026, 7, 25, 9, 0, 0)
const hace7 = new Date(2026, 7, 21, 9, 0, 0)
expect(debeEnviar(hace3, 'VENCIDO', HOY, 7)).toBe(false)
expect(debeEnviar(hace7, 'VENCIDO', HOY, 7)).toBe(true)
})
})
// ---------- Selección de destinatario ----------
describe('elegirDestinatario', () => {
it('vendedor mapeado → vendedor; si no, coordinador; si no, ninguno', () => {
expect(elegirDestinatario(123, 999)).toEqual({ mondayUserId: 123, origen: 'vendedor' })
expect(elegirDestinatario(null, 999)).toEqual({ mondayUserId: 999, origen: 'coordinador' })
expect(elegirDestinatario(null, 0)).toEqual({ mondayUserId: null, origen: 'ninguno' })
})
})
// ---------- Canales ----------
describe('canales', () => {
it('MondayActividadCanal solo está disponible con mondayUserId y crea la actividad', async () => {
const creadas: { nombre: string; responsableId: number }[] = []
const canal = new MondayActividadCanal({
async crearActividad(datos) {
creadas.push({ nombre: datos.nombre, responsableId: datos.responsableId })
return 'item-1'
},
})
expect(canal.disponible({ mondayUserId: null, origen: 'ninguno' })).toBe(false)
expect(canal.disponible({ mondayUserId: 42, origen: 'vendedor' })).toBe(true)
const id = await canal.enviar({ mondayUserId: 42, origen: 'vendedor' }, 'mensaje de prueba', '2026-08-28')
expect(id).toBe('item-1')
expect(creadas).toEqual([{ nombre: 'mensaje de prueba', responsableId: 42 }])
})
it('WhatsAppCanal es un stub inactivo: nunca disponible y solo loguea', async () => {
const canal = new WhatsAppCanal()
expect(canal.disponible({ mondayUserId: 42, origen: 'vendedor' })).toBe(false)
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
await expect(canal.enviar()).resolves.toBe('pendiente-wa-crm')
expect(spy).toHaveBeenCalledWith(expect.stringContaining('pendiente WA CRM'))
spy.mockRestore()
})
})
// ---------- Pasada del motor (store y canal inyectados) ----------
/** Canal fake que captura envíos; solo disponible con destinatario (como Monday) */
function canalFake() {
const envios: { destinatario: DestinatarioRecordatorio; mensaje: string }[] = []
const canal: CanalRecordatorio = {
nombre: 'fake',
disponible: (d) => d.mondayUserId !== null,
async enviar(destinatario, mensaje) {
envios.push({ destinatario, mensaje })
return 'ok'
},
}
return { canal, envios }
}
function storeFake(planes: PlanPago[], mondayId: number | null) {
const log: RecordatorioEnviado[] = []
const ultimos = new Map<string, Date>()
const store: RecordatoriosStore = {
cargarPlanes: async () => planes,
mondayUserIdDeVendedor: async () => mondayId,
ultimoRecordatorio: async (pagoId, tipo) => ultimos.get(`${pagoId}:${tipo}`) ?? null,
registrarLog: async (r) => {
log.push(r)
ultimos.set(`${r.pagoId}:${r.tipo}`, HOY)
},
}
return { store, log }
}
describe('RecordatoriosCobranzaService.ejecutarPasada', () => {
const planes = [
plan({
id: 'p1',
vendedorId: 'vend-1',
pagos: [pago({ id: 'pg1', fechaVencimiento: isoMasDias(HOY, 2) })],
}),
]
it('dry-run: lista lo que enviaría sin enviar ni escribir en el log', async () => {
const { store, log } = storeFake(planes, 555)
const { canal, envios } = canalFake()
const svc = new RecordatoriosCobranzaService(store, [canal])
const r = await svc.ejecutarPasada({ dryRun: true, ahora: HOY })
expect(r.dryRun).toBe(true)
expect(r.evaluados).toHaveLength(1)
expect(r.evaluados[0]).toMatchObject({
pagoId: 'pg1',
decision: 'enviado',
destinatario: { mondayUserId: 555, origen: 'vendedor' },
})
expect(envios).toHaveLength(0)
expect(log).toHaveLength(0)
expect(r.enviados).toHaveLength(0)
})
it('pasada real: envía por el canal, registra el log y la segunda pasada deduplica', async () => {
const { store, log } = storeFake(planes, 555)
const { canal, envios } = canalFake()
const svc = new RecordatoriosCobranzaService(store, [canal])
const r1 = await svc.ejecutarPasada({ ahora: HOY })
expect(r1.enviados).toHaveLength(1)
expect(r1.enviados[0]).toMatchObject({ pagoId: 'pg1', tipo: 'PROXIMO_VENCER', canal: 'fake', destinatario: '555' })
expect(envios).toHaveLength(1)
expect(log).toHaveLength(1)
// Segunda pasada el mismo día: dedup, nada nuevo
const r2 = await svc.ejecutarPasada({ ahora: HOY })
expect(r2.enviados).toHaveLength(0)
expect(r2.evaluados[0]).toMatchObject({ decision: 'dedup' })
expect(envios).toHaveLength(1)
expect(log).toHaveLength(1)
})
it('sin destinatario (sin mapeo ni coordinador): solo registra en el log, sin canal', async () => {
const { store, log } = storeFake(planes, null)
const { canal, envios } = canalFake()
const svc = new RecordatoriosCobranzaService(store, [canal])
// config de test no define MONDAY_COORDINADOR_USER_ID → 0 → 'ninguno'
const r = await svc.ejecutarPasada({ ahora: HOY })
expect(r.enviados).toHaveLength(1)
expect(r.enviados[0]).toMatchObject({ canal: 'log', destinatario: 'sin-destinatario' })
expect(envios).toHaveLength(0)
expect(log).toHaveLength(1)
})
it('un fallo del canal no escribe en el log (reintentable) y se reporta en fallidos', async () => {
const { store, log } = storeFake(planes, 555)
const canalRoto: CanalRecordatorio = {
nombre: 'roto',
disponible: () => true,
enviar: async () => {
throw new Error('Monday caído')
},
}
const svc = new RecordatoriosCobranzaService(store, [canalRoto])
const r = await svc.ejecutarPasada({ ahora: HOY })
expect(r.enviados).toHaveLength(0)
expect(r.fallidos).toEqual([{ pagoId: 'pg1', error: 'Monday caído' }])
expect(log).toHaveLength(0)
})
})

View File

@@ -0,0 +1,345 @@
import { config } from '../config/index.js'
import { parseFechaLocal, round2, statusPago, type Pago, type PlanPago } from './cobranza.service.js'
import { mondayService, type MondayService } from './monday.service.js'
import { prisma } from './prisma.js'
/**
* Motor de recordatorios de pago de la cobranza.
*
* Escaneo diario (cron en index.ts) sobre los planes ACTIVO:
* - pagos que vencen en ≤ RECORDATORIO_DIAS_ANTES días → "próximo a vencer"
* - pagos vencidos → "vencido", re-recordatorio cada RECORDATORIO_REVOLVER_DIAS
*
* Canal actual: Actividad en Monday al vendedor del plan (si tiene
* mondayUserId; si no, al coordinador de MONDAY_COORDINADOR_USER_ID; si no,
* solo se registra en el log). La interfaz CanalRecordatorio permite enchufar
* WhatsApp después (stub WhatsAppCanal incluido, inactivo hasta el WA CRM).
*
* Dedup e historial: tabla RecordatorioLog (no se repite el mismo recordatorio
* el mismo día; los vencidos respetan la ventana de re-recordatorio).
*/
export type TipoRecordatorio = 'PROXIMO_VENCER' | 'VENCIDO'
const DIA_MS = 24 * 60 * 60 * 1000
// ---------- Detección de candidatos (lógica pura, testeable sin DB ni red) ----------
export interface CandidatoRecordatorio {
pagoId: string
numPago: number
tipo: TipoRecordatorio
planId: string
clienteNombre: string
unidadCodigo: string
proyecto: string
vendedorId: string | null
fechaVencimiento: string // 'YYYY-MM-DD'
montoPendiente: number
/** Días hasta el vencimiento (negativo = días de atraso) */
dias: number
mensaje: string
}
const medianoche = (d: Date): Date => new Date(d.getFullYear(), d.getMonth(), d.getDate())
/** Días enteros de `hoy` al vencimiento (0 = vence hoy, negativo = vencido) */
export function diasHastaVencimiento(fechaVencimiento: string, hoy: Date): number {
return Math.round((parseFechaLocal(fechaVencimiento).getTime() - medianoche(hoy).getTime()) / DIA_MS)
}
const formatoDinero = (n: number): string =>
`$${n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
function mensajeRecordatorio(c: Omit<CandidatoRecordatorio, 'mensaje'>): string {
const base =
`Cobranza ${c.unidadCodigo} (${c.clienteNombre}) — pago ${c.numPago} ` +
`por ${formatoDinero(c.montoPendiente)} con vencimiento ${c.fechaVencimiento}`
if (c.tipo === 'PROXIMO_VENCER') {
return c.dias === 0 ? `${base}: vence HOY.` : `${base}: vence en ${c.dias} día(s).`
}
return `${base}: VENCIDO hace ${Math.abs(c.dias)} día(s). Favor de cobrar o reagendar.`
}
/**
* Pagos de planes ACTIVO que ameritan recordatorio hoy:
* - PROXIMO_VENCER: no cubierto y vence entre hoy y hoy+diasAntes
* - VENCIDO: no cubierto y la fecha ya pasó (incluye PARCIAL vencido)
*/
export function candidatosRecordatorio(
planes: PlanPago[],
hoy: Date,
diasAntes: number,
): CandidatoRecordatorio[] {
const candidatos: CandidatoRecordatorio[] = []
for (const plan of planes) {
if (plan.status === 'CANCELADO') continue
for (const pago of plan.pagos) {
const status = statusPago(pago, hoy)
if (status === 'PAGADO') continue
const dias = diasHastaVencimiento(pago.fechaVencimiento, hoy)
let tipo: TipoRecordatorio | null = null
if (dias < 0) tipo = 'VENCIDO'
else if (dias <= diasAntes) tipo = 'PROXIMO_VENCER'
if (!tipo) continue
const base: Omit<CandidatoRecordatorio, 'mensaje'> = {
pagoId: pago.id,
numPago: pago.numPago,
tipo,
planId: plan.id,
clienteNombre: plan.clienteNombre,
unidadCodigo: plan.unidadCodigo,
proyecto: plan.proyecto,
vendedorId: plan.vendedorId ?? null,
fechaVencimiento: pago.fechaVencimiento,
montoPendiente: round2(pago.monto - pago.montoPagado),
dias,
}
candidatos.push({ ...base, mensaje: mensajeRecordatorio(base) })
}
}
return candidatos
}
// ---------- Dedup (lógica pura sobre el último recordatorio registrado) ----------
const esMismoDia = (a: Date, b: Date): boolean => medianoche(a).getTime() === medianoche(b).getTime()
/**
* ¿Toca enviar el recordatorio dado el último registrado?
* - nunca enviado → sí
* - mismo día → no (dedup diario, ambos tipos)
* - PROXIMO_VENCER en otro día → sí (la fecha de vencimiento se acerca)
* - VENCIDO → solo si pasaron ≥ revolverDias desde el último
*/
export function debeEnviar(
ultimo: Date | null,
tipo: TipoRecordatorio,
ahora: Date,
revolverDias: number,
): boolean {
if (!ultimo) return true
if (esMismoDia(ultimo, ahora)) return false
if (tipo === 'PROXIMO_VENCER') return true
return ahora.getTime() - ultimo.getTime() >= revolverDias * DIA_MS
}
// ---------- Destinatario ----------
export interface DestinatarioRecordatorio {
/** mondayUserId del responsable de la actividad; null = no hay a quién asignar */
mondayUserId: number | null
origen: 'vendedor' | 'coordinador' | 'ninguno'
}
/** Regla pura de selección: vendedor mapeado → coordinador (env) → nadie */
export function elegirDestinatario(
mondayUserIdVendedor: number | null,
coordinadorUserId: number,
): DestinatarioRecordatorio {
if (mondayUserIdVendedor !== null) return { mondayUserId: mondayUserIdVendedor, origen: 'vendedor' }
if (Number.isInteger(coordinadorUserId) && coordinadorUserId > 0) {
return { mondayUserId: coordinadorUserId, origen: 'coordinador' }
}
return { mondayUserId: null, origen: 'ninguno' }
}
// ---------- Canales ----------
export interface CanalRecordatorio {
readonly nombre: string
/** true si el canal puede entregar a este destinatario */
disponible(destinatario: DestinatarioRecordatorio): boolean
/** Entrega el recordatorio; devuelve un detalle (p. ej. el item id de Monday) */
enviar(destinatario: DestinatarioRecordatorio, mensaje: string, fecha: string): Promise<string>
}
type MondayActividades = Pick<MondayService, 'crearActividad'>
/** Canal actual: Actividad en Monday asignada al responsable (tablero Actividades) */
export class MondayActividadCanal implements CanalRecordatorio {
readonly nombre = 'monday-actividad'
constructor(private readonly monday: MondayActividades) {}
disponible(destinatario: DestinatarioRecordatorio): boolean {
return destinatario.mondayUserId !== null
}
async enviar(destinatario: DestinatarioRecordatorio, mensaje: string, fecha: string): Promise<string> {
return this.monday.crearActividad({
nombre: mensaje.length > 250 ? `${mensaje.slice(0, 247)}...` : mensaje,
tipoLabel: 'Tarea',
responsableId: destinatario.mondayUserId!,
fecha,
estadoLabel: 'Abierto',
})
}
}
/**
* Stub del canal WhatsApp: inactivo hasta que exista la integración del WA CRM.
* `disponible` siempre false (nunca se elige); `enviar` solo loguea.
*/
export class WhatsAppCanal implements CanalRecordatorio {
readonly nombre = 'whatsapp'
disponible(): boolean {
return false
}
async enviar(): Promise<string> {
// eslint-disable-next-line no-console
console.log('[recordatorios] canal WhatsApp pendiente WA CRM: no se envió nada')
return 'pendiente-wa-crm'
}
}
// ---------- Servicio ----------
export interface RecordatorioEnviado {
pagoId: string
tipo: TipoRecordatorio
canal: string
destinatario: string
mensaje: string
}
export interface CandidatoEvaluado extends CandidatoRecordatorio {
destinatario: DestinatarioRecordatorio
/** 'enviado' | 'dedup' (ya se recordó dentro de la ventana) */
decision: 'enviado' | 'dedup'
}
export interface ResultadoPasada {
dryRun: boolean
evaluados: CandidatoEvaluado[]
enviados: RecordatorioEnviado[]
fallidos: { pagoId: string; error: string }[]
}
/** Acceso a datos inyectable (Prisma en prod, stubs en tests) */
export interface RecordatoriosStore {
/** Planes ACTIVO con pagos y aplicaciones, en forma de dominio */
cargarPlanes(): Promise<PlanPago[]>
/** mondayUserId del vendedor; null si no existe o no está mapeado */
mondayUserIdDeVendedor(vendedorId: string): Promise<number | null>
/** Fecha del último recordatorio registrado para ese pago y tipo */
ultimoRecordatorio(pagoId: string, tipo: TipoRecordatorio): Promise<Date | null>
registrarLog(r: RecordatorioEnviado): Promise<void>
}
const hoyISO = (d: Date): string =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
export class RecordatoriosCobranzaService {
constructor(
private readonly store: RecordatoriosStore,
private readonly canales: CanalRecordatorio[],
) {}
/**
* Una pasada del motor. Con `dryRun: true` no envía nada ni escribe en el
* log: solo devuelve lo que enviaría (para POST .../run?dry=1).
*/
async ejecutarPasada(opciones: { dryRun?: boolean; ahora?: Date } = {}): Promise<ResultadoPasada> {
const dryRun = opciones.dryRun ?? false
const ahora = opciones.ahora ?? new Date()
const { diasAntes, revolverDias } = config.recordatoriosCobranza
const planes = await this.store.cargarPlanes()
const candidatos = candidatosRecordatorio(planes, ahora, diasAntes)
const resultado: ResultadoPasada = { dryRun, evaluados: [], enviados: [], fallidos: [] }
for (const c of candidatos) {
const mondayVendedor = c.vendedorId ? await this.store.mondayUserIdDeVendedor(c.vendedorId) : null
const destinatario = elegirDestinatario(mondayVendedor, config.automatizaciones.coordinadorUserId)
const ultimo = await this.store.ultimoRecordatorio(c.pagoId, c.tipo)
if (!debeEnviar(ultimo, c.tipo, ahora, revolverDias)) {
resultado.evaluados.push({ ...c, destinatario, decision: 'dedup' })
continue
}
resultado.evaluados.push({ ...c, destinatario, decision: 'enviado' })
if (dryRun) continue
const canal = this.canales.find((k) => k.disponible(destinatario))
const enviado: RecordatorioEnviado = {
pagoId: c.pagoId,
tipo: c.tipo,
canal: canal?.nombre ?? 'log',
destinatario:
destinatario.mondayUserId !== null ? String(destinatario.mondayUserId) : 'sin-destinatario',
mensaje: c.mensaje,
}
try {
if (canal) await canal.enviar(destinatario, c.mensaje, hoyISO(ahora))
await this.store.registrarLog(enviado)
resultado.enviados.push(enviado)
} catch (err) {
// Un fallo del canal NO queda en el log: la siguiente pasada reintenta
resultado.fallidos.push({ pagoId: c.pagoId, error: err instanceof Error ? err.message : String(err) })
}
}
return resultado
}
}
// ---------- Cableado con Prisma + Monday (singleton para crons y endpoints) ----------
export const prismaRecordatoriosStore: RecordatoriosStore = {
async cargarPlanes() {
const planes = await prisma.planPago.findMany({
where: { status: 'ACTIVO' },
include: { pagos: { include: { aplicaciones: true } } },
})
return planes.map((plan) => ({
id: plan.id,
clienteNombre: plan.clienteNombre,
unidadCodigo: plan.unidadCodigo,
proyecto: plan.proyecto,
fechaInicio: hoyISO(plan.fechaInicio),
engancheTotal: plan.engancheTotal,
numPagos: plan.numPagos,
mensualidad: plan.mensualidad,
status: 'ACTIVO',
vendedorId: plan.vendedorId,
notas: [],
createdAt: plan.createdAt.toISOString(),
pagos: plan.pagos.map(
(p): Pago => ({
id: p.id,
numPago: p.numPago,
fechaVencimiento: hoyISO(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,
})),
}),
),
}))
},
async mondayUserIdDeVendedor(vendedorId) {
const usuario = await prisma.usuario.findUnique({
where: { id: vendedorId },
select: { mondayUserId: true },
})
return usuario?.mondayUserId ?? null
},
async ultimoRecordatorio(pagoId, tipo) {
const ultimo = await prisma.recordatorioLog.findFirst({
where: { pagoId, tipo },
orderBy: { createdAt: 'desc' },
select: { createdAt: true },
})
return ultimo?.createdAt ?? null
},
async registrarLog(r) {
await prisma.recordatorioLog.create({ data: r })
},
}
/** Singleton usado por el cron y los endpoints (los tests instancian el suyo) */
export const recordatoriosCobranzaService = new RecordatoriosCobranzaService(prismaRecordatoriosStore, [
new MondayActividadCanal(mondayService),
new WhatsAppCanal(),
])