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 { 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 { 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 { 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 { 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 { 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 { 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 /** * 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 { 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 { 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 { 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 { 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 { 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))) }