import type { Request, Response, NextFunction } from 'express'; import { z } from 'zod'; import * as contribuyenteService from '../services/contribuyente.service.js'; import * as carteraService from '../services/cartera.service.js'; import { AppError } from '../middlewares/error.middleware.js'; import { getEntidadesVisibles } from '../utils/entidades-visibles.js'; import { adjustDespachoOverage } from '../services/payment/addon.service.js'; import { prisma } from '../config/database.js'; /** * Límite duro de contribuyentes mientras el despacho está en trial gratuito. * Una vez expira el trial (`trialEndsAt < now`) este límite deja de aplicar y * el plan vigente toma el control. */ const TRIAL_MAX_CONTRIBUYENTES = 5; /** * Cuenta contribuyentes activos del tenant actual. Usado para ajustar el * overage de Business Control / Enterprise tras crear o desactivar un RFC, * y para enforce el límite del trial. */ async function countActiveContribuyentes(pool: import('pg').Pool): Promise { const { rows: [{ cnt }] } = await pool.query<{ cnt: string }>( `SELECT COUNT(*)::text AS cnt FROM entidades_gestionadas WHERE active = true AND tipo = 'CONTRIBUYENTE'`, ); return Number(cnt) || 0; } const createSchema = z.object({ rfc: z.string().regex(/^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/i, 'RFC inválido'), razonSocial: z.string().min(2, 'Razón social requerida'), regimenFiscal: z.string().length(3).optional(), codigoPostal: z.string().regex(/^\d{5}$/).optional(), domicilio: z.record(z.unknown()).optional(), supervisorUserId: z.string().uuid().optional(), }); const updateSchema = createSchema.partial(); export async function list(req: Request, res: Response, next: NextFunction) { try { const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role); const rows = await contribuyenteService.listContribuyentes(req.tenantPool!, visibleIds, req.user!.tenantId); // Batch lookup de nombres de supervisores const supervisorIds = [...new Set(rows.map(r => r.supervisorUserId).filter(Boolean))] as string[]; const supervisorNames: Record = {}; if (supervisorIds.length > 0) { const users = await prisma.user.findMany({ where: { id: { in: supervisorIds } }, select: { id: true, nombre: true }, }); for (const u of users) supervisorNames[u.id] = u.nombre; } return res.json({ data: rows.map(r => ({ ...r, supervisorNombre: r.supervisorUserId ? (supervisorNames[r.supervisorUserId] ?? null) : null, })), }); } catch (err) { return next(err); } } export async function getById(req: Request, res: Response, next: NextFunction) { try { const row = await contribuyenteService.getContribuyenteById(req.tenantPool!, String(req.params.id), req.user!.tenantId); if (!row) return next(new AppError(404, 'Contribuyente no encontrado')); return res.json(row); } catch (err) { return next(err); } } export async function create(req: Request, res: Response, next: NextFunction) { try { const data = createSchema.parse(req.body); // Trial gate: durante el periodo de prueba (trialEndsAt > now) el despacho // no puede gestionar más de TRIAL_MAX_CONTRIBUYENTES RFCs activos. Cuando // el trial expira, deja de aplicar y el límite del plan vigente toma el control. const tenant = await prisma.tenant.findUnique({ where: { id: req.user!.tenantId }, select: { trialEndsAt: true }, }); const isTrialActive = tenant?.trialEndsAt ? tenant.trialEndsAt > new Date() : false; if (isTrialActive) { const activeCount = await countActiveContribuyentes(req.tenantPool!); if (activeCount >= TRIAL_MAX_CONTRIBUYENTES) { return next(new AppError( 403, `Durante el periodo de prueba puedes gestionar hasta ${TRIAL_MAX_CONTRIBUYENTES} contribuyentes. Contrata un plan para agregar más.`, )); } } const { row, reactivated } = await contribuyenteService.createContribuyente(req.tenantPool!, data); // Si se asignó un supervisor, agregar el contribuyente a todas las carteras // top-level de ese supervisor para que aparezca directamente en su vista. if (data.supervisorUserId) { try { const carteras = await carteraService.listCarteras(req.tenantPool!, data.supervisorUserId); await Promise.all( carteras.map(c => carteraService.addEntidadToCartera(req.tenantPool!, c.id, row.id)) ); } catch (err: any) { console.error('[Contribuyente] Auto-assign to cartera failed (non-blocking):', err.message || err); } } // Ajuste de overage despacho: si el tenant pasa de 100 a 101+ RFCs, crea // el addon y devuelve paymentUrl para que el frontend redirija al usuario. // Fail-soft: si falla el addon, el contribuyente queda creado y se loguea. let overage: Awaited> | null = null; try { const activeCount = await countActiveContribuyentes(req.tenantPool!); overage = await adjustDespachoOverage(req.user!.tenantId, activeCount); } catch (err: any) { console.error('[Contribuyente] Overage adjust failed (non-blocking):', err.message || err); } return res.status(reactivated ? 200 : 201).json({ ...row, reactivated, overage }); } catch (err: any) { if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message)); if (err.code === '23505') return next(new AppError(409, 'Ya existe un contribuyente con este RFC')); return next(err); } } export async function update(req: Request, res: Response, next: NextFunction) { try { const data = updateSchema.parse(req.body); const row = await contribuyenteService.updateContribuyente(req.tenantPool!, String(req.params.id), data); if (!row) return next(new AppError(404, 'Contribuyente no encontrado')); return res.json(row); } catch (err: any) { if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message)); return next(err); } } export async function deactivate(req: Request, res: Response, next: NextFunction) { try { const ok = await contribuyenteService.deactivateContribuyente(req.tenantPool!, String(req.params.id)); if (!ok) return next(new AppError(404, 'Contribuyente no encontrado')); // Ajuste de overage despacho: si el count baja, reduce quantity del // addon (updatePreapprovalAmount) o cancela el preapproval si pasa al límite. let overage: Awaited> | null = null; try { const activeCount = await countActiveContribuyentes(req.tenantPool!); overage = await adjustDespachoOverage(req.user!.tenantId, activeCount); } catch (err: any) { console.error('[Contribuyente] Overage adjust failed (non-blocking):', err.message || err); } return res.json({ message: 'Contribuyente desactivado', overage }); } catch (err) { return next(err); } } export async function backfill(req: Request, res: Response, next: NextFunction) { try { const total = await contribuyenteService.backfillAllContribuyentes(req.tenantPool!); return res.json({ message: `${total} CFDIs asignados a contribuyentes`, total }); } catch (err) { return next(err); } } export async function addClienteAcceso(req: Request, res: Response, next: NextFunction) { try { const { userId } = req.body; if (!userId || typeof userId !== 'string') return next(new AppError(400, 'userId requerido')); const entidadId = String(req.params.id); // Seguridad: supervisor solo puede asignar contribuyentes que supervise if (req.user!.role === 'supervisor') { const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role); if (!visibleIds.includes(entidadId)) { return next(new AppError(403, 'No tienes acceso a este contribuyente')); } } await req.tenantPool!.query( 'INSERT INTO cliente_accesos (user_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [userId, entidadId], ); return res.json({ message: 'Acceso otorgado' }); } catch (err) { return next(err); } }