- Monitor de sincronización SAT (sat-sync-monitor.job + alerta por correo). - Scraper de CSF más robusto (iframes, blobs, popups, validación PDF). - Reactivación de contribuyentes desactivados y limpieza al desactivar. - Timeout de constancia aumentado a 5 min. - Variables de entorno SAT en .env.example y env.ts.
189 lines
8.1 KiB
TypeScript
189 lines
8.1 KiB
TypeScript
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<number> {
|
|
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<string, string> = {};
|
|
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<ReturnType<typeof adjustDespachoOverage>> | 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<ReturnType<typeof adjustDespachoOverage>> | 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); }
|
|
}
|