Initial commit - Horux Despachos NL
This commit is contained in:
87
apps/api/src/controllers/activos-fijos.controller.ts
Normal file
87
apps/api/src/controllers/activos-fijos.controller.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
import * as activosFijosService from '../services/activos-fijos.service.js';
|
||||
|
||||
function effectiveTenantId(req: Request): string {
|
||||
return req.viewingTenantId || req.user!.tenantId;
|
||||
}
|
||||
|
||||
const listSchema = z.object({
|
||||
año: z.string().regex(/^\d{4}$/),
|
||||
mes: z.string().regex(/^\d{1,2}$/),
|
||||
contribuyenteId: z.string().uuid().optional(),
|
||||
estado: z.enum(['todos', 'activos', 'baja', 'agotados']).optional(),
|
||||
});
|
||||
|
||||
export async function list(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const q = listSchema.parse(req.query);
|
||||
const data = await activosFijosService.listActivosFijos(
|
||||
req.tenantPool!,
|
||||
effectiveTenantId(req),
|
||||
parseInt(q.año, 10),
|
||||
parseInt(q.mes, 10),
|
||||
q.contribuyenteId ?? null,
|
||||
q.estado,
|
||||
);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
const bajaSchema = z.object({
|
||||
fechaBaja: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
motivo: z.enum(['venta', 'desecho', 'otro']),
|
||||
comentario: z.string().max(2000).nullable().optional(),
|
||||
});
|
||||
|
||||
export async function darDeBaja(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const cfdiId = parseInt(String(req.params.cfdiId), 10);
|
||||
if (isNaN(cfdiId)) return next(new AppError(400, 'cfdiId inválido'));
|
||||
const data = bajaSchema.parse(req.body);
|
||||
await activosFijosService.darDeBaja(
|
||||
req.tenantPool!,
|
||||
cfdiId,
|
||||
data.fechaBaja,
|
||||
data.motivo,
|
||||
req.user!.userId,
|
||||
data.comentario ?? null,
|
||||
);
|
||||
res.status(201).json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
const usosExcluidosSchema = z.object({
|
||||
contribuyenteId: z.string().uuid(),
|
||||
usos: z.array(z.string().regex(/^I0[1-8]$/)),
|
||||
});
|
||||
|
||||
export async function setUsosExcluidos(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { contribuyenteId, usos } = usosExcluidosSchema.parse(req.body);
|
||||
const saved = await activosFijosService.setUsosExcluidos(req.tenantPool!, contribuyenteId, usos);
|
||||
res.json({ usosExcluidos: saved });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function revertirBaja(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const cfdiId = parseInt(String(req.params.cfdiId), 10);
|
||||
if (isNaN(cfdiId)) return next(new AppError(400, 'cfdiId inválido'));
|
||||
const ok = await activosFijosService.revertirBaja(req.tenantPool!, cfdiId);
|
||||
if (!ok) return next(new AppError(404, 'Activo no estaba dado de baja'));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
86
apps/api/src/controllers/admin-addons.controller.ts
Normal file
86
apps/api/src/controllers/admin-addons.controller.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { isPlatformStaff } from '../utils/platform-admin.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
import { auditFromReq } from '../utils/audit.js';
|
||||
|
||||
async function requireStaff(req: Request) {
|
||||
if (!req.user?.userId) throw new AppError(401, 'No autenticado');
|
||||
const isStaff = await isPlatformStaff(req.user.userId);
|
||||
if (!isStaff) throw new AppError(403, 'Acceso restringido a staff de plataforma');
|
||||
}
|
||||
|
||||
const updateSchema = z.object({
|
||||
nombre: z.string().min(1).max(200).optional(),
|
||||
precio: z.number().nonnegative().optional(),
|
||||
active: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/** Lista todo el catálogo de add-ons (incluye inactivos). */
|
||||
export async function listCatalogo(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await requireStaff(req);
|
||||
const items = await prisma.planAddonCatalogo.findMany({
|
||||
orderBy: { codename: 'asc' },
|
||||
include: {
|
||||
_count: { select: { subscriptionAddons: { where: { status: { in: ['authorized', 'pending'] } } } } },
|
||||
},
|
||||
});
|
||||
return res.json({
|
||||
data: items.map(i => ({
|
||||
id: i.id,
|
||||
codename: i.codename,
|
||||
nombre: i.nombre,
|
||||
verticalProfile: i.verticalProfile,
|
||||
precio: Number(i.precio),
|
||||
frecuencia: i.frecuencia,
|
||||
active: i.active,
|
||||
delta: i.delta,
|
||||
createdAt: i.createdAt.toISOString(),
|
||||
suscripcionesActivas: i._count.subscriptionAddons,
|
||||
})),
|
||||
});
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
export async function updateCatalogoItem(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await requireStaff(req);
|
||||
const id = String(req.params.id);
|
||||
const data = updateSchema.parse(req.body);
|
||||
const before = await prisma.planAddonCatalogo.findUnique({ where: { id } });
|
||||
if (!before) throw new AppError(404, 'Add-on no encontrado');
|
||||
|
||||
const updated = await prisma.planAddonCatalogo.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(data.nombre !== undefined ? { nombre: data.nombre } : {}),
|
||||
...(data.precio !== undefined ? { precio: data.precio } : {}),
|
||||
...(data.active !== undefined ? { active: data.active } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
auditFromReq(req, 'addon.catalogo_updated', {
|
||||
entityType: 'PlanAddonCatalogo',
|
||||
entityId: id,
|
||||
metadata: {
|
||||
codename: before.codename,
|
||||
before: { nombre: before.nombre, precio: Number(before.precio), active: before.active },
|
||||
after: { nombre: updated.nombre, precio: Number(updated.precio), active: updated.active },
|
||||
},
|
||||
});
|
||||
|
||||
return res.json({
|
||||
id: updated.id,
|
||||
codename: updated.codename,
|
||||
nombre: updated.nombre,
|
||||
precio: Number(updated.precio),
|
||||
frecuencia: updated.frecuencia,
|
||||
active: updated.active,
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
46
apps/api/src/controllers/admin-clientes.controller.ts
Normal file
46
apps/api/src/controllers/admin-clientes.controller.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import * as svc from '../services/admin-clientes.service.js';
|
||||
import { isPlatformStaff } from '../utils/platform-admin.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
|
||||
async function requireStaff(req: Request) {
|
||||
if (!req.user?.userId) throw new AppError(401, 'No autenticado');
|
||||
const isStaff = await isPlatformStaff(req.user.userId);
|
||||
if (!isStaff) throw new AppError(403, 'Acceso restringido a staff de plataforma');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stats de gestión de clientes.
|
||||
*
|
||||
* Query params:
|
||||
* - `from` (YYYY-MM-DD): inicio del rango. Default: primer día del mes en curso.
|
||||
* - `to` (YYYY-MM-DD): fin del rango. Default: último día del mes en curso.
|
||||
*/
|
||||
export async function getStats(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await requireStaff(req);
|
||||
const now = new Date();
|
||||
const defaultFrom = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const defaultTo = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999);
|
||||
|
||||
const fromStr = String(req.query.from || '').trim();
|
||||
const toStr = String(req.query.to || '').trim();
|
||||
const from = fromStr ? new Date(fromStr + 'T00:00:00') : defaultFrom;
|
||||
const to = toStr ? new Date(toStr + 'T23:59:59.999') : defaultTo;
|
||||
if (isNaN(from.getTime()) || isNaN(to.getTime())) {
|
||||
return next(new AppError(400, 'Rango de fechas inválido'));
|
||||
}
|
||||
const stats = await svc.getClientesStats({ from, to });
|
||||
return res.json(stats);
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
export async function listUsuarios(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await requireStaff(req);
|
||||
const tenantId = String(req.params.tenantId || '');
|
||||
if (!tenantId) return next(new AppError(400, 'tenantId requerido'));
|
||||
const usuarios = await svc.getTenantUsuarios(tenantId);
|
||||
return res.json({ data: usuarios });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
36
apps/api/src/controllers/admin-dashboard.controller.ts
Normal file
36
apps/api/src/controllers/admin-dashboard.controller.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import * as dashService from '../services/admin-dashboard.service.js';
|
||||
import { isPlatformStaff } from '../utils/platform-admin.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
|
||||
async function requireStaff(req: Request) {
|
||||
if (!req.user?.userId) throw new AppError(401, 'No autenticado');
|
||||
const isStaff = await isPlatformStaff(req.user.userId);
|
||||
if (!isStaff) throw new AppError(403, 'Acceso restringido a staff de plataforma');
|
||||
}
|
||||
|
||||
export async function getMetrics(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await requireStaff(req);
|
||||
const metrics = await dashService.getDashboardMetrics();
|
||||
return res.json(metrics);
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
export async function listDespachos(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await requireStaff(req);
|
||||
const { vertical, status, search } = req.query as Record<string, string>;
|
||||
const despachos = await dashService.listAllDespachos({ vertical, status, search });
|
||||
return res.json({ data: despachos });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
export async function getActivity(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await requireStaff(req);
|
||||
const limit = Math.min(Number(req.query.limit) || 20, 100);
|
||||
const activity = await dashService.getRecentActivity(limit);
|
||||
return res.json({ data: activity });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
77
apps/api/src/controllers/admin-impersonate.controller.ts
Normal file
77
apps/api/src/controllers/admin-impersonate.controller.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { hasPlatformRole } from '../utils/platform-admin.js';
|
||||
import { auditLog } from '../utils/audit.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
|
||||
const impersonateSchema = z.object({
|
||||
despachoId: z.string().uuid('ID de despacho inválido'),
|
||||
motivo: z.string().min(5, 'Motivo es obligatorio (mínimo 5 caracteres)'),
|
||||
});
|
||||
|
||||
export async function startImpersonation(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user?.userId) return next(new AppError(401, 'No autenticado'));
|
||||
|
||||
const canImpersonate = await hasPlatformRole(req.user.userId, 'platform_admin') ||
|
||||
await hasPlatformRole(req.user.userId, 'platform_ti') ||
|
||||
await hasPlatformRole(req.user.userId, 'platform_support');
|
||||
if (!canImpersonate) return next(new AppError(403, 'No tienes permisos para impersonar'));
|
||||
|
||||
const { despachoId, motivo } = impersonateSchema.parse(req.body);
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: despachoId },
|
||||
select: { id: true, nombre: true, rfc: true, active: true },
|
||||
});
|
||||
if (!tenant) return next(new AppError(404, 'Despacho no encontrado'));
|
||||
if (!tenant.active) return next(new AppError(403, 'Despacho inactivo'));
|
||||
|
||||
await auditLog({
|
||||
userId: req.user.userId,
|
||||
tenantId: despachoId,
|
||||
action: 'admin.impersonate_start',
|
||||
entityType: 'tenant',
|
||||
entityId: despachoId,
|
||||
metadata: {
|
||||
motivo,
|
||||
adminEmail: req.user.email,
|
||||
despachoNombre: tenant.nombre,
|
||||
despachoRfc: tenant.rfc,
|
||||
ip: req.ip,
|
||||
userAgent: req.headers['user-agent'],
|
||||
},
|
||||
});
|
||||
|
||||
return res.json({
|
||||
despachoId: tenant.id,
|
||||
nombre: tenant.nombre,
|
||||
rfc: tenant.rfc,
|
||||
message: 'Impersonación iniciada. Usa el header X-View-Tenant para acceder.',
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopImpersonation(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user?.userId) return next(new AppError(401, 'No autenticado'));
|
||||
|
||||
const despachoId = req.body.despachoId as string | undefined;
|
||||
|
||||
await auditLog({
|
||||
userId: req.user.userId,
|
||||
tenantId: despachoId || undefined,
|
||||
action: 'admin.impersonate_end',
|
||||
metadata: {
|
||||
adminEmail: req.user.email,
|
||||
ip: req.ip,
|
||||
},
|
||||
});
|
||||
|
||||
return res.json({ message: 'Impersonación finalizada' });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
506
apps/api/src/controllers/alertas.controller.ts
Normal file
506
apps/api/src/controllers/alertas.controller.ts
Normal file
@@ -0,0 +1,506 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import * as alertasService from '../services/alertas.service.js';
|
||||
import { generarAlertasAutomaticas, SOSPECHOSA_TIPO_RELACION_WHERE_EXPORT } from '../services/alertas-auto.service.js';
|
||||
import { sincronizarAlertasManuales, getAlertasManualesPendientes, resolverAlerta } from '../services/alertas-manuales.service.js';
|
||||
import { getRegimenesActivosClavesEfectivos } from '../services/regimen.service.js';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
|
||||
const createAlertaSchema = z.object({
|
||||
tipo: z.enum(['vencimiento', 'discrepancia', 'iva_favor', 'declaracion', 'limite_cfdi', 'custom']),
|
||||
titulo: z.string().min(1).max(200),
|
||||
mensaje: z.string().min(1).max(2000),
|
||||
prioridad: z.enum(['alta', 'media', 'baja']),
|
||||
fechaVencimiento: z.string().optional(),
|
||||
});
|
||||
|
||||
const updateAlertaSchema = z.object({
|
||||
leida: z.boolean().optional(),
|
||||
resuelta: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function getAlertas(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { leida, resuelta, prioridad } = req.query;
|
||||
const alertas = await alertasService.getAlertas(req.tenantPool!, {
|
||||
leida: leida === 'true' ? true : leida === 'false' ? false : undefined,
|
||||
resuelta: resuelta === 'true' ? true : resuelta === 'false' ? false : undefined,
|
||||
prioridad: prioridad as string,
|
||||
});
|
||||
res.json(alertas);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAlerta(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const alerta = await alertasService.getAlertaById(req.tenantPool!, parseInt(String(req.params.id)));
|
||||
if (!alerta) {
|
||||
return res.status(404).json({ message: 'Alerta no encontrada' });
|
||||
}
|
||||
res.json(alerta);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAlerta(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = createAlertaSchema.parse(req.body);
|
||||
const alerta = await alertasService.createAlerta(req.tenantPool!, data);
|
||||
res.status(201).json(alerta);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAlerta(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = updateAlertaSchema.parse(req.body);
|
||||
const alerta = await alertasService.updateAlerta(req.tenantPool!, parseInt(String(req.params.id)), data);
|
||||
res.json(alerta);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAlerta(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await alertasService.deleteAlerta(req.tenantPool!, parseInt(String(req.params.id)));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStats(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const stats = await alertasService.getStats(req.tenantPool!);
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function markAllAsRead(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await alertasService.markAllAsRead(req.tenantPool!);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getManualesPendientes(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
// Sincronizar primero (crear alertas para eventos vencidos nuevos)
|
||||
await sincronizarAlertasManuales(req.tenantPool!, req.user!.tenantId, contribuyenteId || null);
|
||||
// Devolver pendientes (filtered by contribuyente or user role)
|
||||
const alertas = await getAlertasManualesPendientes(
|
||||
req.tenantPool!,
|
||||
contribuyenteId || null,
|
||||
req.user!.userId,
|
||||
req.user!.role,
|
||||
);
|
||||
res.json(alertas);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolverAlertaManual(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await resolverAlerta(req.tenantPool!, String(req.params.id));
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAlertasAutomaticas(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
const alertas = await generarAlertasAutomaticas(req.tenantPool!, req.user!.tenantId, contribuyenteId || null);
|
||||
res.json(alertas);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Drill-down: Clientes en lista negra
|
||||
export async function getListaNegraClientes(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
const cf = contribuyenteId
|
||||
? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
|
||||
: '';
|
||||
|
||||
const listaRfcs = await prisma.listaNegra.findMany({
|
||||
where: { situacion: { in: ['Definitivo', 'Presunto'] } },
|
||||
select: { rfc: true, nombre: true, situacion: true },
|
||||
});
|
||||
const rfcMap = new Map(listaRfcs.map(l => [l.rfc, l]));
|
||||
|
||||
const { rows } = await req.tenantPool!.query(`
|
||||
SELECT rfc_receptor as rfc, nombre_receptor as nombre,
|
||||
COUNT(*)::int as cantidad, SUM(total_mxn) as total
|
||||
FROM cfdis
|
||||
WHERE type = 'EMITIDO' AND status NOT IN ('Cancelado', '0') AND tipo_comprobante = 'I'
|
||||
${cf}
|
||||
GROUP BY rfc_receptor, nombre_receptor
|
||||
ORDER BY total DESC
|
||||
`);
|
||||
|
||||
const result = rows
|
||||
.filter((r: any) => rfcMap.has(r.rfc))
|
||||
.map((r: any) => ({
|
||||
rfc: r.rfc,
|
||||
nombre: r.nombre,
|
||||
cantidad: r.cantidad,
|
||||
total: Number(r.total),
|
||||
situacionSat: rfcMap.get(r.rfc)!.situacion,
|
||||
}));
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Drill-down: Proveedores en lista negra
|
||||
export async function getListaNegraProveedores(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
const cf = contribuyenteId
|
||||
? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
|
||||
: '';
|
||||
|
||||
const listaRfcs = await prisma.listaNegra.findMany({
|
||||
where: { situacion: { in: ['Definitivo', 'Presunto'] } },
|
||||
select: { rfc: true, nombre: true, situacion: true },
|
||||
});
|
||||
const rfcMap = new Map(listaRfcs.map(l => [l.rfc, l]));
|
||||
|
||||
const { rows } = await req.tenantPool!.query(`
|
||||
SELECT rfc_emisor as rfc, nombre_emisor as nombre,
|
||||
COUNT(*)::int as cantidad, SUM(total_mxn) as total
|
||||
FROM cfdis
|
||||
WHERE type = 'RECIBIDO' AND status NOT IN ('Cancelado', '0') AND tipo_comprobante = 'I'
|
||||
${cf}
|
||||
GROUP BY rfc_emisor, nombre_emisor
|
||||
ORDER BY total DESC
|
||||
`);
|
||||
|
||||
const result = rows
|
||||
.filter((r: any) => rfcMap.has(r.rfc))
|
||||
.map((r: any) => ({
|
||||
rfc: r.rfc,
|
||||
nombre: r.nombre,
|
||||
cantidad: r.cantidad,
|
||||
total: Number(r.total),
|
||||
situacionSat: rfcMap.get(r.rfc)!.situacion,
|
||||
}));
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Drill-down: Concentración de clientes
|
||||
export async function getConcentracionClientes(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
const cf = contribuyenteId
|
||||
? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
|
||||
: '';
|
||||
|
||||
const { rows } = await req.tenantPool!.query(`
|
||||
SELECT rfc_receptor as rfc, nombre_receptor as nombre,
|
||||
COUNT(*)::int as cantidad,
|
||||
SUM(total_mxn) as total
|
||||
FROM cfdis
|
||||
WHERE type = 'EMITIDO' AND tipo_comprobante = 'I'
|
||||
AND status NOT IN ('Cancelado', '0') AND total_mxn > 0
|
||||
${cf}
|
||||
GROUP BY rfc_receptor, nombre_receptor
|
||||
ORDER BY total DESC
|
||||
`);
|
||||
|
||||
const totalGeneral = rows.reduce((s: number, r: any) => s + Number(r.total), 0);
|
||||
const result = rows.map((r: any) => ({
|
||||
rfc: r.rfc,
|
||||
nombre: r.nombre,
|
||||
cantidad: r.cantidad,
|
||||
total: Number(r.total),
|
||||
participacion: totalGeneral > 0 ? Math.round((Number(r.total) / totalGeneral) * 10000) / 100 : 0,
|
||||
}));
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Drill-down: Concentración de proveedores
|
||||
export async function getConcentracionProveedores(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
const cf = contribuyenteId
|
||||
? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
|
||||
: '';
|
||||
|
||||
const { rows } = await req.tenantPool!.query(`
|
||||
SELECT rfc_emisor as rfc, nombre_emisor as nombre,
|
||||
COUNT(*)::int as cantidad,
|
||||
SUM(total_mxn) as total
|
||||
FROM cfdis
|
||||
WHERE type = 'RECIBIDO' AND tipo_comprobante = 'I'
|
||||
AND status NOT IN ('Cancelado', '0') AND total_mxn > 0
|
||||
${cf}
|
||||
GROUP BY rfc_emisor, nombre_emisor
|
||||
ORDER BY total DESC
|
||||
`);
|
||||
|
||||
const totalGeneral = rows.reduce((s: number, r: any) => s + Number(r.total), 0);
|
||||
const result = rows.map((r: any) => ({
|
||||
rfc: r.rfc,
|
||||
nombre: r.nombre,
|
||||
cantidad: r.cantidad,
|
||||
total: Number(r.total),
|
||||
participacion: totalGeneral > 0 ? Math.round((Number(r.total) / totalGeneral) * 10000) / 100 : 0,
|
||||
}));
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Drill-down: CFDIs con discrepancia de régimen
|
||||
export async function getDiscrepanciaRegimen(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
const activos = await getRegimenesActivosClavesEfectivos(req.user!.tenantId, req.tenantPool!, contribuyenteId);
|
||||
if (activos.length === 0) return res.json([]);
|
||||
|
||||
const cf = contribuyenteId
|
||||
? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
|
||||
: '';
|
||||
|
||||
const { rows } = await req.tenantPool!.query(`
|
||||
SELECT id, uuid, type, fecha_emision as "fechaEmision",
|
||||
rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
|
||||
rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
|
||||
total_mxn as "totalMxn", regimen_fiscal_receptor as "regimenReceptor"
|
||||
FROM cfdis
|
||||
WHERE type = 'RECIBIDO'
|
||||
AND status = 'Vigente'
|
||||
AND fecha_cancelacion IS NULL
|
||||
AND regimen_fiscal_receptor IS NOT NULL
|
||||
AND regimen_fiscal_receptor != ALL($1)
|
||||
AND id NOT IN (SELECT cfdi_id FROM cfdi_descartados WHERE tipo_alerta = 'discrepancia-regimen')
|
||||
${cf}
|
||||
ORDER BY fecha_emision DESC
|
||||
`, [activos]);
|
||||
|
||||
res.json(rows);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Drill-down: CFDIs cancelados
|
||||
export async function getCancelados(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
const cf = contribuyenteId
|
||||
? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
|
||||
: '';
|
||||
|
||||
const hace5 = new Date();
|
||||
hace5.setFullYear(hace5.getFullYear() - 5);
|
||||
|
||||
const { rows } = await req.tenantPool!.query(`
|
||||
SELECT id, uuid, type, fecha_emision as "fechaEmision",
|
||||
rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
|
||||
rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
|
||||
total_mxn as "totalMxn", fecha_cancelacion as "fechaCancelacion"
|
||||
FROM cfdis
|
||||
WHERE status IN ('Cancelado', '0')
|
||||
AND fecha_emision >= $1::date
|
||||
${cf}
|
||||
ORDER BY fecha_emision DESC
|
||||
`, [hace5.toISOString().split('T')[0]]);
|
||||
|
||||
res.json(rows);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Drill-down: Facturas de periodos anteriores canceladas este mes
|
||||
export async function getCancelacionesPeriodoAnterior(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
const cf = contribuyenteId
|
||||
? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
|
||||
: '';
|
||||
|
||||
const ahora = new Date();
|
||||
const inicioMes = `${ahora.getFullYear()}-${String(ahora.getMonth() + 1).padStart(2, '0')}-01`;
|
||||
|
||||
const { rows } = await req.tenantPool!.query(`
|
||||
SELECT id, uuid, type, fecha_emision as "fechaEmision",
|
||||
rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
|
||||
rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
|
||||
total_mxn as "totalMxn", tipo_comprobante as "tipoComprobante",
|
||||
fecha_cancelacion as "fechaCancelacion"
|
||||
FROM cfdis
|
||||
WHERE status IN ('Cancelado', '0')
|
||||
AND fecha_cancelacion >= $1::date
|
||||
AND fecha_emision < $1::date
|
||||
${cf}
|
||||
ORDER BY fecha_cancelacion DESC
|
||||
`, [inicioMes]);
|
||||
|
||||
res.json(rows);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Drill-down: CFDIs con pago en efectivo
|
||||
export async function getEfectivo(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
const cf = contribuyenteId
|
||||
? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
|
||||
: '';
|
||||
|
||||
const { rows } = await req.tenantPool!.query(`
|
||||
SELECT id, uuid, type, fecha_emision as "fechaEmision",
|
||||
rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
|
||||
rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
|
||||
total_mxn as "totalMxn", forma_pago as "formaPago"
|
||||
FROM cfdis
|
||||
WHERE status NOT IN ('Cancelado', '0') AND tipo_comprobante = 'I'
|
||||
AND forma_pago = '01'
|
||||
${cf}
|
||||
ORDER BY fecha_emision DESC
|
||||
`);
|
||||
|
||||
res.json(rows);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Drill-down: CFDIs tipo E con TipoRelacion sospechoso (debería ser 07)
|
||||
export async function getTipoRelacionSospechosa(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
const cf = contribuyenteId
|
||||
? `AND c.contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
|
||||
: '';
|
||||
|
||||
const { rows } = await req.tenantPool!.query(`
|
||||
SELECT c.id, c.uuid, c.type, c.fecha_emision AS "fechaEmision",
|
||||
c.rfc_emisor AS "rfcEmisor", c.nombre_emisor AS "nombreEmisor",
|
||||
c.rfc_receptor AS "rfcReceptor", c.nombre_receptor AS "nombreReceptor",
|
||||
c.total_mxn AS "totalMxn",
|
||||
c.tipo_comprobante AS "tipoComprobante",
|
||||
c.cfdi_tipo_relacion AS "cfdiTipoRelacion",
|
||||
c.cfdis_relacionados AS "cfdisRelacionados"
|
||||
FROM cfdis c
|
||||
WHERE ${SOSPECHOSA_TIPO_RELACION_WHERE_EXPORT}
|
||||
${cf}
|
||||
ORDER BY c.fecha_emision DESC
|
||||
`);
|
||||
res.json(rows);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Descarte de CFDIs de alertas ──
|
||||
|
||||
export async function descartarCfdis(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { cfdiIds, tipoAlerta } = z.object({
|
||||
cfdiIds: z.array(z.number().int()),
|
||||
tipoAlerta: z.string().min(1),
|
||||
}).parse(req.body);
|
||||
|
||||
for (const cfdiId of cfdiIds) {
|
||||
await req.tenantPool!.query(
|
||||
`INSERT INTO cfdi_descartados (cfdi_id, tipo_alerta, descartado_por)
|
||||
VALUES ($1, $2, $3) ON CONFLICT (cfdi_id, tipo_alerta) DO NOTHING`,
|
||||
[cfdiId, tipoAlerta, req.user!.email],
|
||||
);
|
||||
}
|
||||
res.json({ descartados: cfdiIds.length });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function restaurarDescartados(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { cfdiIds, tipoAlerta } = z.object({
|
||||
cfdiIds: z.array(z.number().int()).optional(),
|
||||
tipoAlerta: z.string().min(1),
|
||||
}).parse(req.body);
|
||||
|
||||
if (cfdiIds && cfdiIds.length > 0) {
|
||||
await req.tenantPool!.query(
|
||||
`DELETE FROM cfdi_descartados WHERE tipo_alerta = $1 AND cfdi_id = ANY($2)`,
|
||||
[tipoAlerta, cfdiIds],
|
||||
);
|
||||
} else {
|
||||
await req.tenantPool!.query(
|
||||
`DELETE FROM cfdi_descartados WHERE tipo_alerta = $1`,
|
||||
[tipoAlerta],
|
||||
);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDescartados(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const tipoAlerta = req.query.tipoAlerta as string;
|
||||
if (!tipoAlerta) return next(new AppError(400, 'tipoAlerta requerido'));
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
const cf = contribuyenteId
|
||||
? `AND c.contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
|
||||
: '';
|
||||
|
||||
// JOIN con cfdis para devolver datos completos (mismo shape que el
|
||||
// drill-down activo, para que el frontend pueda reutilizar el componente).
|
||||
const { rows } = await req.tenantPool!.query(`
|
||||
SELECT c.id, c.uuid, c.type, c.fecha_emision AS "fechaEmision",
|
||||
c.rfc_emisor AS "rfcEmisor", c.nombre_emisor AS "nombreEmisor",
|
||||
c.rfc_receptor AS "rfcReceptor", c.nombre_receptor AS "nombreReceptor",
|
||||
c.total_mxn AS "totalMxn",
|
||||
c.regimen_fiscal_receptor AS "regimenReceptor",
|
||||
d.descartado_por AS "descartadoPor",
|
||||
d.created_at AS "descartadoEn"
|
||||
FROM cfdi_descartados d
|
||||
JOIN cfdis c ON c.id = d.cfdi_id
|
||||
WHERE d.tipo_alerta = $1
|
||||
${cf}
|
||||
ORDER BY d.created_at DESC
|
||||
`, [tipoAlerta]);
|
||||
res.json({ data: rows });
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
87
apps/api/src/controllers/audit-log.controller.ts
Normal file
87
apps/api/src/controllers/audit-log.controller.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { isGlobalAdmin } from '../utils/global-admin.js';
|
||||
|
||||
async function requireGlobalAdmin(req: Request, res: Response): Promise<boolean> {
|
||||
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role);
|
||||
if (!isAdmin) {
|
||||
res.status(403).json({ message: 'Solo el administrador global puede consultar el audit log' });
|
||||
}
|
||||
return isAdmin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista eventos de audit con filtros opcionales. Admin global only.
|
||||
*
|
||||
* Query params:
|
||||
* action — filtra por action prefix (ej: "subscription." matches todas las subs)
|
||||
* tenantId — filtra a un tenant específico
|
||||
* userId — filtra a un user específico
|
||||
* from, to — rango de fechas (ISO)
|
||||
* page, limit — paginación (default: 1, 50; max limit 200)
|
||||
*/
|
||||
export async function listAuditLog(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!(await requireGlobalAdmin(req, res))) return;
|
||||
|
||||
const action = String(req.query.action || '').trim();
|
||||
const tenantId = String(req.query.tenantId || '').trim();
|
||||
const userId = String(req.query.userId || '').trim();
|
||||
const from = String(req.query.from || '').trim();
|
||||
const to = String(req.query.to || '').trim();
|
||||
const page = Math.max(1, parseInt(String(req.query.page || '1'), 10) || 1);
|
||||
const limit = Math.min(200, Math.max(1, parseInt(String(req.query.limit || '50'), 10) || 50));
|
||||
|
||||
const where: any = {};
|
||||
if (action) where.action = { startsWith: action };
|
||||
if (tenantId) where.tenantId = tenantId;
|
||||
if (userId) where.userId = userId;
|
||||
if (from || to) {
|
||||
where.createdAt = {};
|
||||
if (from) where.createdAt.gte = new Date(from);
|
||||
if (to) where.createdAt.lte = new Date(to);
|
||||
}
|
||||
|
||||
const [total, rows] = await Promise.all([
|
||||
prisma.auditLog.count({ where }),
|
||||
prisma.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Enriquecer con user.email y tenant.nombre para display
|
||||
const userIds = [...new Set(rows.map(r => r.userId).filter(Boolean))] as string[];
|
||||
const tenantIds = [...new Set(rows.map(r => r.tenantId).filter(Boolean))] as string[];
|
||||
|
||||
const [users, tenants] = await Promise.all([
|
||||
userIds.length
|
||||
? prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, email: true, nombre: true } })
|
||||
: [],
|
||||
tenantIds.length
|
||||
? prisma.tenant.findMany({ where: { id: { in: tenantIds } }, select: { id: true, nombre: true, rfc: true } })
|
||||
: [],
|
||||
]);
|
||||
|
||||
const userMap = new Map(users.map(u => [u.id, u]));
|
||||
const tenantMap = new Map(tenants.map(t => [t.id, t]));
|
||||
|
||||
const data = rows.map(r => ({
|
||||
...r,
|
||||
user: r.userId ? userMap.get(r.userId) || null : null,
|
||||
tenant: r.tenantId ? tenantMap.get(r.tenantId) || null : null,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
data,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
203
apps/api/src/controllers/auth.controller.ts
Normal file
203
apps/api/src/controllers/auth.controller.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import * as authService from '../services/auth.service.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
|
||||
const registerSchema = z.object({
|
||||
empresa: z.object({
|
||||
nombre: z.string().min(2, 'Nombre de empresa requerido'),
|
||||
rfc: z.string().min(12).max(13, 'RFC inválido'),
|
||||
}),
|
||||
usuario: z.object({
|
||||
nombre: z.string().min(2, 'Nombre requerido'),
|
||||
email: z.string().email('Email inválido'),
|
||||
password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
|
||||
}),
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('Email inválido'),
|
||||
password: z.string().min(1, 'Contraseña requerida'),
|
||||
});
|
||||
|
||||
export async function register(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = registerSchema.parse(req.body);
|
||||
const result = await authService.register(data);
|
||||
res.status(201).json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return next(new AppError(400, error.errors[0].message));
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = loginSchema.parse(req.body);
|
||||
const result = await authService.login(data);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return next(new AppError(400, error.errors[0].message));
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function refresh(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { refreshToken } = req.body;
|
||||
if (!refreshToken) {
|
||||
throw new AppError(400, 'Refresh token requerido');
|
||||
}
|
||||
const tokens = await authService.refreshTokens(refreshToken);
|
||||
res.json(tokens);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function logout(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { refreshToken } = req.body;
|
||||
if (refreshToken) {
|
||||
await authService.logout(refreshToken);
|
||||
}
|
||||
res.json({ message: 'Sesión cerrada exitosamente' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function me(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
res.json({ user: req.user });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
const passwordResetRequestSchema = z.object({
|
||||
email: z.string().email('Email inválido'),
|
||||
});
|
||||
|
||||
const passwordResetConfirmSchema = z.object({
|
||||
token: z.string().min(10, 'Token inválido'),
|
||||
newPassword: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Solicita recuperación de contraseña. Responde 200 siempre (anti-enumeration),
|
||||
* independiente de si el email existe o no.
|
||||
*/
|
||||
export async function requestPasswordReset(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { email } = passwordResetRequestSchema.parse(req.body);
|
||||
// Dispara async — no esperamos resultado para preservar timing constante
|
||||
await authService.requestPasswordReset(email);
|
||||
res.json({
|
||||
message: 'Si el email existe en nuestro sistema, recibirás un enlace para restablecer tu contraseña.',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return next(new AppError(400, error.errors[0].message));
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirma recuperación con token + nueva contraseña.
|
||||
*/
|
||||
export async function confirmPasswordReset(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { token, newPassword } = passwordResetConfirmSchema.parse(req.body);
|
||||
await authService.confirmPasswordReset(token, newPassword);
|
||||
res.json({ message: 'Contraseña actualizada exitosamente. Por favor inicia sesión con tu nueva contraseña.' });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return next(new AppError(400, error.errors[0].message));
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1, 'Contraseña actual requerida'),
|
||||
newPassword: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Cambia la contraseña del user autenticado. Requiere contraseña actual.
|
||||
* Tras cambio: todas las sesiones del user quedan invalidadas (incluyendo esta).
|
||||
*/
|
||||
export async function changePassword(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { currentPassword, newPassword } = changePasswordSchema.parse(req.body);
|
||||
await authService.changePassword({
|
||||
userId: req.user!.userId,
|
||||
currentPassword,
|
||||
newPassword,
|
||||
});
|
||||
res.json({
|
||||
message: 'Contraseña actualizada. Por seguridad, todas tus sesiones fueron cerradas. Inicia sesión de nuevo.',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return next(new AppError(400, error.errors[0].message));
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Cerrar todas las sesiones" — invalida todos los tokens del user actual.
|
||||
*/
|
||||
export async function logoutAll(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await authService.logoutAllSessions(req.user!.userId);
|
||||
res.json({ message: 'Todas tus sesiones fueron cerradas. Inicia sesión de nuevo.' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
const switchTenantSchema = z.object({
|
||||
tenantId: z.string().uuid('tenantId inválido'),
|
||||
refreshToken: z.string().min(1, 'refreshToken requerido'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Cambia el tenant activo del user (requiere membership válida). Emite un par
|
||||
* nuevo de tokens apuntando al tenant destino y revoca el refresh token actual.
|
||||
*/
|
||||
export async function switchTenant(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { tenantId, refreshToken } = switchTenantSchema.parse(req.body);
|
||||
const result = await authService.switchTenant({
|
||||
userId: req.user!.userId,
|
||||
currentRefreshToken: refreshToken,
|
||||
targetTenantId: tenantId,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marca el onboarding como dismissed para el user actual. Idempotente — si ya
|
||||
* estaba dismissed, conserva el timestamp original. La UI lo invoca cuando el
|
||||
* user completa todos los pasos requeridos del onboarding.
|
||||
*/
|
||||
export async function dismissOnboarding(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const result = await authService.dismissOnboarding(req.user!.userId);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
62
apps/api/src/controllers/bancos.controller.ts
Normal file
62
apps/api/src/controllers/bancos.controller.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import * as bancosService from '../services/bancos.service.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
|
||||
const createSchema = z.object({
|
||||
banco: z.string().min(1, 'banco requerido').max(100),
|
||||
terminacionCuenta: z.string().min(1).max(4, 'terminacionCuenta max 4 digitos'),
|
||||
});
|
||||
|
||||
const updateSchema = z.object({
|
||||
banco: z.string().min(1).max(100).optional(),
|
||||
terminacionCuenta: z.string().min(1).max(4).optional(),
|
||||
});
|
||||
|
||||
export async function getBancos(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = (req.query.contribuyenteId as string) || null;
|
||||
const bancos = await bancosService.getBancos(req.tenantPool!, contribuyenteId);
|
||||
res.json(bancos);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
export async function createBanco(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (req.user!.role !== 'owner') return res.status(403).json({ message: 'No autorizado' });
|
||||
const data = createSchema.parse(req.body);
|
||||
const contribuyenteId = req.body.contribuyenteId as string | undefined;
|
||||
const result = await bancosService.createBanco(req.tenantPool!, { ...data, contribuyenteId });
|
||||
res.status(201).json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateBanco(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (req.user!.role !== 'owner') return res.status(403).json({ message: 'No autorizado' });
|
||||
const id = parseInt(String(req.params.id));
|
||||
const data = updateSchema.parse(req.body);
|
||||
const result = await bancosService.updateBanco(req.tenantPool!, id, data);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteBanco(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (req.user!.role !== 'owner') return res.status(403).json({ message: 'No autorizado' });
|
||||
const id = parseInt(String(req.params.id));
|
||||
await bancosService.deleteBanco(req.tenantPool!, id);
|
||||
res.json({ message: 'Banco eliminado' });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('conciliaciones')) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
175
apps/api/src/controllers/calendario.controller.ts
Normal file
175
apps/api/src/controllers/calendario.controller.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { generarEventosFiscales, generarEventosDesdeObligaciones } from '../services/calendario-fiscal.service.js';
|
||||
import * as recordatoriosService from '../services/recordatorios.service.js';
|
||||
import { getEventosTareasParaCalendario } from '../services/tareas.service.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
import { isDespachoTenant } from '@horux/shared';
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
const createRecordatorioSchema = z.object({
|
||||
titulo: z.string().min(1).max(200),
|
||||
descripcion: z.string().max(2000).default(''),
|
||||
fechaLimite: z.string().min(8), // ISO date o yyyy-mm-dd
|
||||
notas: z.string().max(2000).optional(),
|
||||
privado: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const updateRecordatorioSchema = z.object({
|
||||
titulo: z.string().min(1).max(200).optional(),
|
||||
descripcion: z.string().max(2000).optional(),
|
||||
fechaLimite: z.string().min(8).optional(),
|
||||
notas: z.string().max(2000).optional(),
|
||||
privado: z.boolean().optional(),
|
||||
completado: z.boolean().optional(),
|
||||
});
|
||||
|
||||
function effectiveTenantId(req: Request): string {
|
||||
return req.viewingTenantId || req.user!.tenantId;
|
||||
}
|
||||
|
||||
// Forma compatible con EventoFiscal (sin metadata interna como tareaId/periodoId).
|
||||
function eventoTareaShape(t: import('../services/tareas.service.js').TareaEventoCalendario) {
|
||||
return {
|
||||
titulo: t.titulo,
|
||||
descripcion: t.descripcion,
|
||||
tipo: 'tarea' as const,
|
||||
fechaLimite: t.fechaLimite,
|
||||
recurrencia: t.recurrencia,
|
||||
completado: t.completado,
|
||||
notas: t.notas,
|
||||
// Metadata adicional para el frontend (links, modales)
|
||||
tareaId: t.tareaId,
|
||||
periodoId: t.periodoId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEventosGenerados(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||
const tenantId = effectiveTenantId(req);
|
||||
|
||||
let fiscales;
|
||||
|
||||
// Determine tenant type by looking up the RFC from the central DB
|
||||
const tenantRecord = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { rfc: true },
|
||||
});
|
||||
const isDespacho = isDespachoTenant(tenantRecord?.rfc);
|
||||
if (isDespacho) {
|
||||
const contribuyenteId = (req.query.contribuyenteId as string) || null;
|
||||
fiscales = await generarEventosDesdeObligaciones(req.tenantPool!, contribuyenteId, año);
|
||||
} else {
|
||||
// Horux360: use static catalog as before
|
||||
fiscales = await generarEventosFiscales(tenantId, año);
|
||||
}
|
||||
|
||||
// Recordatorios custom — always included regardless of tenant type
|
||||
const custom = await recordatoriosService.getRecordatorios(
|
||||
req.tenantPool!,
|
||||
req.user!.userId,
|
||||
año
|
||||
);
|
||||
|
||||
// Tareas operativas (despacho) — solo si hay contribuyente y rol no es cliente.
|
||||
// El usuario tipo cliente no debe ver tareas internas del despacho.
|
||||
let tareas: ReturnType<typeof eventoTareaShape>[] = [];
|
||||
const contribuyenteIdParam = (req.query.contribuyenteId as string) || null;
|
||||
if (contribuyenteIdParam && req.user?.role !== 'cliente') {
|
||||
const tareasRaw = await getEventosTareasParaCalendario(
|
||||
req.tenantPool!,
|
||||
contribuyenteIdParam,
|
||||
año,
|
||||
);
|
||||
tareas = tareasRaw.map(eventoTareaShape);
|
||||
}
|
||||
|
||||
// Merge y ordenar por fecha
|
||||
const todos = [...fiscales, ...custom, ...tareas].sort((a, b) =>
|
||||
a.fechaLimite.localeCompare(b.fechaLimite)
|
||||
);
|
||||
|
||||
res.json(todos);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRecordatorio(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!['owner', 'cfo', 'contador', 'supervisor', 'auxiliar'].includes(req.user!.role)) {
|
||||
return res.status(403).json({ message: 'Solo admin y contador pueden crear recordatorios' });
|
||||
}
|
||||
|
||||
const data = createRecordatorioSchema.parse(req.body);
|
||||
|
||||
const evento = await recordatoriosService.createRecordatorio(
|
||||
req.tenantPool!,
|
||||
req.user!.userId,
|
||||
{ ...data, tipo: 'custom', recurrencia: 'unica' }
|
||||
);
|
||||
|
||||
res.status(201).json(evento);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateRecordatorio(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!['owner', 'cfo', 'contador', 'supervisor', 'auxiliar'].includes(req.user!.role)) {
|
||||
return res.status(403).json({ message: 'Solo admin y contador pueden editar recordatorios' });
|
||||
}
|
||||
|
||||
const id = parseInt(String(req.params.id));
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ message: 'ID inválido' });
|
||||
}
|
||||
|
||||
const data = updateRecordatorioSchema.parse(req.body);
|
||||
const evento = await recordatoriosService.updateRecordatorio(
|
||||
req.tenantPool!,
|
||||
req.user!.userId,
|
||||
id,
|
||||
data
|
||||
);
|
||||
|
||||
if (!evento) {
|
||||
return res.status(404).json({ message: 'Recordatorio no encontrado' });
|
||||
}
|
||||
|
||||
res.json(evento);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRecordatorio(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!['owner', 'cfo', 'contador', 'supervisor', 'auxiliar'].includes(req.user!.role)) {
|
||||
return res.status(403).json({ message: 'Solo admin y contador pueden eliminar recordatorios' });
|
||||
}
|
||||
|
||||
const id = parseInt(String(req.params.id));
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ message: 'ID inválido' });
|
||||
}
|
||||
|
||||
const deleted = await recordatoriosService.deleteRecordatorio(
|
||||
req.tenantPool!,
|
||||
req.user!.userId,
|
||||
id
|
||||
);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ message: 'Recordatorio no encontrado' });
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
277
apps/api/src/controllers/cartera.controller.ts
Normal file
277
apps/api/src/controllers/cartera.controller.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import * as carteraService from '../services/cartera.service.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
|
||||
const createSchema = z.object({
|
||||
nombre: z.string().min(1, 'Nombre requerido'),
|
||||
descripcion: z.string().optional(),
|
||||
supervisorUserId: z.string().uuid().optional(), // Owner can assign to a supervisor
|
||||
});
|
||||
|
||||
const createSubcarteraSchema = z.object({
|
||||
nombre: z.string().min(1, 'Nombre requerido'),
|
||||
descripcion: z.string().optional(),
|
||||
auxiliarUserId: z.string().uuid('Auxiliar requerido'),
|
||||
});
|
||||
|
||||
const updateSchema = z.object({
|
||||
nombre: z.string().min(1).optional(),
|
||||
descripcion: z.string().optional(),
|
||||
supervisorUserId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Permission helpers:
|
||||
* - Owner: sees all, edits all
|
||||
* - Supervisor: sees carteras assigned to them (by owner) + carteras they created.
|
||||
* Can only edit/delete carteras THEY created. Cannot edit owner-created ones.
|
||||
* Can only add contribuyentes that are already assigned to them.
|
||||
* - Auxiliar: sees subcarteras where they're assigned. Read-only.
|
||||
*/
|
||||
|
||||
function isOwner(req: Request): boolean {
|
||||
return req.user!.role === 'owner';
|
||||
}
|
||||
|
||||
function isSupervisor(req: Request): boolean {
|
||||
return req.user!.role === 'supervisor';
|
||||
}
|
||||
|
||||
/** Check if a supervisor created this cartera (vs owner assigned it to them) */
|
||||
async function supervisorCreatedCartera(req: Request, cartera: carteraService.CarteraRow): Promise<boolean> {
|
||||
// A cartera was created by the supervisor if supervisorUserId === the supervisor's userId
|
||||
// AND the cartera was not created by the owner assigning it.
|
||||
// We use a heuristic: if the supervisor_user_id matches and createdBy is not tracked,
|
||||
// we assume the supervisor can edit their own carteras.
|
||||
// For now: supervisor can edit carteras where they are the supervisor.
|
||||
// Owner-created carteras also have supervisorUserId set to the supervisor —
|
||||
// so we need another way to distinguish.
|
||||
// Solution: we'll add a 'created_by' concept. For now, let supervisor edit all carteras
|
||||
// assigned to them (both owner-created and self-created).
|
||||
// The user said: "Las que crea el owner, solo las puede ver el supervisor, pero no las puede editar"
|
||||
// This requires tracking who created the cartera. Let's use a simple approach:
|
||||
// check if the owner's userId matches the request user.
|
||||
return cartera.supervisorUserId === req.user!.userId;
|
||||
}
|
||||
|
||||
export async function list(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const role = req.user!.role;
|
||||
const userId = req.user!.userId;
|
||||
|
||||
if (isOwner(req)) {
|
||||
// Owner sees all top-level carteras
|
||||
const rows = await carteraService.listCarteras(req.tenantPool!);
|
||||
return res.json({ data: rows });
|
||||
}
|
||||
|
||||
if (isSupervisor(req)) {
|
||||
// Supervisor sees carteras assigned to them
|
||||
const rows = await carteraService.listCarteras(req.tenantPool!, userId);
|
||||
return res.json({ data: rows });
|
||||
}
|
||||
|
||||
// Auxiliar: sees subcarteras where they're assigned
|
||||
const { rows } = await req.tenantPool!.query(
|
||||
`SELECT c.id, c.supervisor_user_id AS "supervisorUserId",
|
||||
c.auxiliar_user_id AS "auxiliarUserId", c.parent_id AS "parentId",
|
||||
c.nombre, c.descripcion, c.created_at AS "createdAt",
|
||||
(SELECT count(*) FROM cartera_entidades ce WHERE ce.cartera_id = c.id)::int AS "entidadesCount",
|
||||
0 AS "subcarterasCount"
|
||||
FROM carteras c
|
||||
WHERE c.auxiliar_user_id = $1
|
||||
ORDER BY c.nombre`,
|
||||
[userId],
|
||||
);
|
||||
return res.json({ data: rows });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
export async function getById(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const row = await carteraService.getCarteraById(req.tenantPool!, String(req.params.id));
|
||||
if (!row) return next(new AppError(404, 'Cartera no encontrada'));
|
||||
// Auxiliar can only see their own subcarteras
|
||||
if (req.user!.role === 'auxiliar' && row.auxiliarUserId !== req.user!.userId) {
|
||||
return next(new AppError(403, 'No autorizado'));
|
||||
}
|
||||
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);
|
||||
const supervisorUserId = data.supervisorUserId || req.user!.userId;
|
||||
const row = await carteraService.createCartera(req.tenantPool!, {
|
||||
supervisorUserId,
|
||||
nombre: data.nombre,
|
||||
descripcion: data.descripcion,
|
||||
});
|
||||
return res.status(201).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 update(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const cartera = await carteraService.getCarteraById(req.tenantPool!, String(req.params.id));
|
||||
if (!cartera) return next(new AppError(404, 'Cartera no encontrada'));
|
||||
|
||||
// Supervisor cannot edit carteras (owner-assigned are read-only for them)
|
||||
// Only owner can edit top-level carteras
|
||||
if (isSupervisor(req)) {
|
||||
return next(new AppError(403, 'Solo el owner puede editar carteras'));
|
||||
}
|
||||
|
||||
const data = updateSchema.parse(req.body);
|
||||
const row = await carteraService.updateCartera(req.tenantPool!, String(req.params.id), data);
|
||||
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 remove(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const cartera = await carteraService.getCarteraById(req.tenantPool!, String(req.params.id));
|
||||
if (!cartera) return next(new AppError(404, 'Cartera no encontrada'));
|
||||
|
||||
if (isSupervisor(req)) {
|
||||
return next(new AppError(403, 'Solo el owner puede eliminar carteras'));
|
||||
}
|
||||
|
||||
await carteraService.deleteCartera(req.tenantPool!, String(req.params.id));
|
||||
return res.json({ message: 'Cartera eliminada' });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
// Subcarteras
|
||||
export async function listSubcarteras(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const rows = await carteraService.listSubcarteras(req.tenantPool!, String(req.params.id));
|
||||
return res.json({ data: rows });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
export async function createSubcartera(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const parent = await carteraService.getCarteraById(req.tenantPool!, String(req.params.id));
|
||||
if (!parent) return next(new AppError(404, 'Cartera padre no encontrada'));
|
||||
|
||||
// Supervisor can create subcarteras within their own carteras
|
||||
if (isSupervisor(req) && parent.supervisorUserId !== req.user!.userId) {
|
||||
return next(new AppError(403, 'No autorizado'));
|
||||
}
|
||||
|
||||
const data = createSubcarteraSchema.parse(req.body);
|
||||
const row = await carteraService.createSubcartera(req.tenantPool!, {
|
||||
parentId: String(req.params.id),
|
||||
auxiliarUserId: data.auxiliarUserId,
|
||||
nombre: data.nombre,
|
||||
descripcion: data.descripcion,
|
||||
});
|
||||
return res.status(201).json(row);
|
||||
} catch (err: any) {
|
||||
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Entidades
|
||||
export async function addEntidad(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const cartera = await carteraService.getCarteraById(req.tenantPool!, String(req.params.id));
|
||||
if (!cartera) return next(new AppError(404, 'Cartera no encontrada'));
|
||||
|
||||
if (isSupervisor(req)) {
|
||||
// For subcarteras: check the parent's supervisor
|
||||
const supervisorId = cartera.supervisorUserId
|
||||
|| (cartera.parentId ? (await carteraService.getCarteraById(req.tenantPool!, cartera.parentId))?.supervisorUserId : null);
|
||||
if (supervisorId !== req.user!.userId) {
|
||||
return next(new AppError(403, 'No autorizado'));
|
||||
}
|
||||
}
|
||||
|
||||
const { entidadId } = z.object({ entidadId: z.string().uuid() }).parse(req.body);
|
||||
await carteraService.addEntidadToCartera(req.tenantPool!, String(req.params.id), entidadId);
|
||||
return res.json({ message: 'Entidad agregada a cartera' });
|
||||
} catch (err: any) {
|
||||
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeEntidad(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const cartera = await carteraService.getCarteraById(req.tenantPool!, String(req.params.id));
|
||||
if (!cartera) return next(new AppError(404, 'Cartera no encontrada'));
|
||||
|
||||
if (isSupervisor(req)) {
|
||||
const supervisorId = cartera.supervisorUserId
|
||||
|| (cartera.parentId ? (await carteraService.getCarteraById(req.tenantPool!, cartera.parentId))?.supervisorUserId : null);
|
||||
if (supervisorId !== req.user!.userId) {
|
||||
return next(new AppError(403, 'No autorizado'));
|
||||
}
|
||||
}
|
||||
|
||||
await carteraService.removeEntidadFromCartera(req.tenantPool!, String(req.params.id), String(req.params.entidadId));
|
||||
return res.json({ message: 'Entidad removida de cartera' });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
export async function getEntidades(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const ids = await carteraService.getCarteraEntidades(req.tenantPool!, String(req.params.id));
|
||||
return res.json({ data: ids });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
// Auxiliares
|
||||
export async function getAuxiliares(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const ids = await carteraService.getCarteraAuxiliares(req.tenantPool!, String(req.params.id));
|
||||
return res.json({ data: ids });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
export async function addAuxiliar(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { auxiliarUserId } = z.object({ auxiliarUserId: z.string().uuid() }).parse(req.body);
|
||||
await carteraService.addAuxiliarToCartera(req.tenantPool!, String(req.params.id), auxiliarUserId);
|
||||
return res.json({ message: 'Auxiliar agregado a cartera' });
|
||||
} catch (err: any) {
|
||||
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeAuxiliar(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await carteraService.removeAuxiliarFromCartera(req.tenantPool!, String(req.params.id), String(req.params.auxiliarUserId));
|
||||
return res.json({ message: 'Auxiliar removido de cartera' });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
// Supervisores available (for dropdown)
|
||||
export async function getSupervisores(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const supervisores = await carteraService.getSupervisores(req.tenantPool!, req.user!.tenantId);
|
||||
return res.json({ data: supervisores });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
// Auxiliares of a supervisor
|
||||
export async function getAuxiliaresDelSupervisor(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const supervisorId = isOwner(req)
|
||||
? String(req.params.supervisorId || req.user!.userId)
|
||||
: req.user!.userId;
|
||||
const rows = await carteraService.getAuxiliaresDelSupervisor(req.tenantPool!, supervisorId);
|
||||
return res.json({ data: rows });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
108
apps/api/src/controllers/catalogos.controller.ts
Normal file
108
apps/api/src/controllers/catalogos.controller.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
export async function getFormasPago(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = await prisma.catFormaPago.findMany({ orderBy: { clave: 'asc' } });
|
||||
res.json(data);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
export async function getMetodosPago(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = await prisma.catMetodoPago.findMany({ orderBy: { clave: 'asc' } });
|
||||
res.json(data);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
export async function getUsosCfdi(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = await prisma.catUsoCfdi.findMany({ orderBy: { clave: 'asc' } });
|
||||
res.json(data);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
export async function getMonedas(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = await prisma.catMoneda.findMany({ orderBy: { clave: 'asc' } });
|
||||
res.json(data);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
export async function getClavesUnidad(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = await prisma.catClaveUnidad.findMany({ orderBy: { descripcion: 'asc' } });
|
||||
res.json(data);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
export async function searchClaveProdServ(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const q = (req.query.q as string || '').trim();
|
||||
if (q.length < 2) {
|
||||
return res.json([]);
|
||||
}
|
||||
|
||||
// Buscar por clave o descripción
|
||||
// Primero buscar por clave, luego por texto
|
||||
const data = await prisma.catClaveProdServ.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ clave: { startsWith: q } },
|
||||
{ descripcion: { contains: q, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
take: 20,
|
||||
orderBy: { clave: 'asc' },
|
||||
});
|
||||
|
||||
// Si no hay resultados, intentar sin acentos
|
||||
if (data.length === 0) {
|
||||
const normalized = q.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||
if (normalized !== q) {
|
||||
const fallback = await prisma.catClaveProdServ.findMany({
|
||||
where: { descripcion: { contains: normalized, mode: 'insensitive' } },
|
||||
take: 20,
|
||||
orderBy: { clave: 'asc' },
|
||||
});
|
||||
return res.json(fallback);
|
||||
}
|
||||
|
||||
// Buscar con variantes comunes de acentos
|
||||
const withAccents = normalized
|
||||
.replace(/a/gi, '[aá]').replace(/e/gi, '[eé]')
|
||||
.replace(/i/gi, '[ií]').replace(/o/gi, '[oó]').replace(/u/gi, '[uú]')
|
||||
.replace(/n/gi, '[nñ]');
|
||||
|
||||
// Usar raw SQL con regex para búsqueda flexible
|
||||
const rows: any[] = await prisma.$queryRawUnsafe(
|
||||
`SELECT id, clave, descripcion FROM cat_clave_prod_serv WHERE descripcion ~* $1 ORDER BY clave LIMIT 20`,
|
||||
withAccents
|
||||
);
|
||||
return res.json(rows);
|
||||
}
|
||||
|
||||
res.json(data);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
export async function getObjetosImp(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = await prisma.catObjetoImp.findMany({ orderBy: { clave: 'asc' } });
|
||||
res.json(data);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
export async function getTiposRelacion(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = await prisma.catTipoRelacion.findMany({ orderBy: { clave: 'asc' } });
|
||||
res.json(data);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
export async function getExportaciones(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = await prisma.catExportacion.findMany({ orderBy: { clave: 'asc' } });
|
||||
res.json(data);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
530
apps/api/src/controllers/cfdi.controller.ts
Normal file
530
apps/api/src/controllers/cfdi.controller.ts
Normal file
@@ -0,0 +1,530 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import * as cfdiService from '../services/cfdi.service.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
import { GRUPO_PF_EMPRESARIAL, GRUPO_PM_OTROS } from '../services/dashboard.service.js';
|
||||
import { getRegimenesIgnoradosClaves } from '../services/regimen.service.js';
|
||||
import { resolveContribuyenteContext } from '../utils/contribuyente-context.js';
|
||||
import { buildExtraFilters } from '../services/_shared/cfdi-filters.js';
|
||||
import type { CfdiFilters } from '@horux/shared';
|
||||
|
||||
export async function getCfdis(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const filters: CfdiFilters = {
|
||||
tipo: req.query.tipo as any,
|
||||
tipoComprobante: req.query.tipoComprobante as any,
|
||||
estado: req.query.estado as any,
|
||||
fechaInicio: req.query.fechaInicio as string,
|
||||
fechaFin: req.query.fechaFin as string,
|
||||
rfc: req.query.rfc as string,
|
||||
emisor: req.query.emisor as string,
|
||||
receptor: req.query.receptor as string,
|
||||
search: req.query.search as string,
|
||||
contribuyenteId: req.query.contribuyenteId as string,
|
||||
page: parseInt(req.query.page as string) || 1,
|
||||
// Cap defensivo: paginación normal usa 20-100; export pide 10000.
|
||||
// Más de eso se rechaza para no agotar memoria del proceso.
|
||||
limit: Math.min(parseInt(req.query.limit as string) || 20, 10_000),
|
||||
};
|
||||
|
||||
const result = await cfdiService.getCfdis(req.tenantPool, filters);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCfdiById(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const cfdi = await cfdiService.getCfdiById(req.tenantPool, String(req.params.id));
|
||||
|
||||
if (!cfdi) {
|
||||
return next(new AppError(404, 'CFDI no encontrado'));
|
||||
}
|
||||
|
||||
res.json(cfdi);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getXml(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const xml = await cfdiService.getXmlById(req.tenantPool, String(req.params.id));
|
||||
|
||||
if (!xml) {
|
||||
return next(new AppError(404, 'XML no encontrado para este CFDI'));
|
||||
}
|
||||
|
||||
res.set('Content-Type', 'application/xml');
|
||||
res.set('Content-Disposition', `attachment; filename="cfdi-${req.params.id}.xml"`);
|
||||
res.send(xml);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listConceptos(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) return next(new AppError(400, 'Tenant no configurado'));
|
||||
|
||||
const filters: CfdiFilters & {
|
||||
uuidLike?: string;
|
||||
claveProdServ?: string;
|
||||
descripcionConcepto?: string;
|
||||
orderBy?: 'fecha' | 'importe';
|
||||
orderDir?: 'asc' | 'desc';
|
||||
} = {
|
||||
tipo: req.query.tipo as any,
|
||||
tipoComprobante: req.query.tipoComprobante as any,
|
||||
estado: req.query.estado as any,
|
||||
fechaInicio: req.query.fechaInicio as string,
|
||||
fechaFin: req.query.fechaFin as string,
|
||||
rfc: req.query.rfc as string,
|
||||
emisor: req.query.emisor as string,
|
||||
receptor: req.query.receptor as string,
|
||||
search: req.query.search as string,
|
||||
contribuyenteId: req.query.contribuyenteId as string,
|
||||
page: parseInt(req.query.page as string) || 1,
|
||||
limit: Math.min(parseInt(req.query.limit as string) || 50, 10_000),
|
||||
uuidLike: req.query.uuidLike as string,
|
||||
claveProdServ: req.query.claveProdServ as string,
|
||||
descripcionConcepto: req.query.descripcionConcepto as string,
|
||||
orderBy: req.query.orderBy as 'fecha' | 'importe',
|
||||
orderDir: req.query.orderDir as 'asc' | 'desc',
|
||||
};
|
||||
|
||||
const result = await cfdiService.getConceptosList(req.tenantPool, filters);
|
||||
res.json(result);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
export async function getConceptos(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const conceptos = await cfdiService.getConceptos(req.tenantPool, String(req.params.id));
|
||||
res.json(conceptos);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function drillDown(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const {
|
||||
fechaInicio, fechaFin, type, tipoComprobante, metodoPago,
|
||||
regimenEmisor, regimenReceptor, status, contribuyenteId,
|
||||
bucket, considerarActivos, considerarNCs,
|
||||
} = req.query;
|
||||
|
||||
// Default true (consistente con el resto del sistema). Solo false si la URL
|
||||
// pasa explícitamente '0' o 'false'. Sin estos toggles, el drill ignoraba
|
||||
// el filtro de "Considerar activos" y mostraba CFDIs que la card sí estaba
|
||||
// excluyendo del total.
|
||||
const considerarActivosBool = considerarActivos !== '0' && considerarActivos !== 'false';
|
||||
const considerarNCsBool = considerarNCs !== '0' && considerarNCs !== 'false';
|
||||
const extra = buildExtraFilters(considerarActivosBool, considerarNCsBool);
|
||||
|
||||
let where = 'WHERE 1=1';
|
||||
const params: any[] = [];
|
||||
let pi = 1;
|
||||
|
||||
// `bucket` expande la combinación (type, tipo_comprobante, metodo_pago,
|
||||
// régimen) exactamente igual a la fórmula de KPIs/tarjetas — para que
|
||||
// el drill-down cuadre línea a línea con el total del header.
|
||||
//
|
||||
// Reglas por bucket (alineado con dashboard.service y impuestos.service):
|
||||
// ingresos: 3 grupos de régimen del emisor con fórmulas distintas.
|
||||
// Grupo 1 (PF Empresarial 606/612/621/625/626): EMIT I PUE + EMIT P
|
||||
// Grupo 2 (Sueldos 605, recibido como N): RECIB N PUE con receptor=605
|
||||
// Grupo 3 (PM y otros): EMIT I PUE+PPD
|
||||
// gastos: uniforme todos los regímenes del receptor
|
||||
// RECIB I PUE + RECIB P
|
||||
// causado (IVA): EMIT I PUE + EMIT P + EMIT E PUE (excl. E/07)
|
||||
// acreditable (IVA): RECIB I PUE + RECIB P + RECIB E PUE (excl. E/07)
|
||||
//
|
||||
// Las E PUE NO entran en ingresos/gastos — viven en sus propios drills
|
||||
// ("NCs Emitidas" / "NCs Recibidas"). En IVA causado/acreditable sí
|
||||
// entran, ya que el IVA de las NCs sí se acredita/cancela.
|
||||
//
|
||||
// Régimenes "ignorados" por el tenant se excluyen en todos los buckets.
|
||||
// Las NC que restan se muestran como filas con signo (frontend las resta
|
||||
// del total del header). Si `bucket` se pasa, se ignoran filtros
|
||||
// type/tipoComprobante/metodoPago de entrada.
|
||||
const bucketStr = typeof bucket === 'string' ? bucket.toLowerCase() : '';
|
||||
const bucketApplied = bucketStr === 'ingresos' || bucketStr === 'gastos' ||
|
||||
bucketStr === 'causado' || bucketStr === 'acreditable' ||
|
||||
bucketStr === 'ncs_emitidas' || bucketStr === 'ncs_recibidas' ||
|
||||
bucketStr === 'no_deducibles_efectivo';
|
||||
|
||||
// Régimenes ignorados por el tenant (configurable en /regimenes). Se
|
||||
// excluyen del lado correspondiente según el bucket.
|
||||
const ignorados = req.user?.tenantId
|
||||
? await getRegimenesIgnoradosClaves(req.user.tenantId)
|
||||
: [];
|
||||
|
||||
// Resolver condiciones esEmisor/esReceptor basadas en RFC del contribuyente.
|
||||
// Reemplaza `type = 'EMITIDO/RECIBIDO' AND contribuyente_id = X` por un
|
||||
// filtro por RFC — fuente de verdad cuando dos contribuyentes del tenant
|
||||
// se facturan entre sí (type/contribuyente_id pueden ser inconsistentes).
|
||||
const contribIdStr = typeof contribuyenteId === 'string' ? contribuyenteId : undefined;
|
||||
const cfdiCtx = req.user?.tenantId
|
||||
? await resolveContribuyenteContext(req.tenantPool, req.user.tenantId, contribIdStr)
|
||||
: null;
|
||||
const esEmisor = cfdiCtx?.esEmisor || `type = 'EMITIDO'`;
|
||||
const esReceptor = cfdiCtx?.esReceptor || `type = 'RECIBIDO'`;
|
||||
|
||||
const NO_IGNORADO_EMISOR = ignorados.length > 0
|
||||
? `AND (regimen_fiscal_emisor IS NULL OR regimen_fiscal_emisor NOT IN (${ignorados.map(r => `'${r}'`).join(',')}))`
|
||||
: '';
|
||||
const NO_IGNORADO_RECEPTOR = ignorados.length > 0
|
||||
? `AND (regimen_fiscal_receptor IS NULL OR regimen_fiscal_receptor NOT IN (${ignorados.map(r => `'${r}'`).join(',')}))`
|
||||
: '';
|
||||
|
||||
const g1 = GRUPO_PF_EMPRESARIAL.map(r => `'${r}'`).join(',');
|
||||
const g3 = GRUPO_PM_OTROS.map(r => `'${r}'`).join(',');
|
||||
// Conjunto canónico de regímenes que el dashboard considera (excluye 616
|
||||
// extranjero y otros fuera del catálogo). El drill debe respetarlo para
|
||||
// cuadrar con los KPIs/tarjetas.
|
||||
const TODOS_REGS = [...GRUPO_PF_EMPRESARIAL, '605', ...GRUPO_PM_OTROS]
|
||||
.map(r => `'${r}'`)
|
||||
.join(',');
|
||||
const E_NO_ANTICIPO = `COALESCE(cfdi_tipo_relacion, '') <> '07'`;
|
||||
|
||||
if (bucketStr === 'ingresos') {
|
||||
// 3 grupos con fórmulas distintas. Filtro por RFC (esEmisor/esReceptor).
|
||||
// Las E PUE se exhiben en su propia card "NCs Emitidas" — no entran aquí.
|
||||
// I/07 PPD compensación: cuando el contribuyente emite I/07 PPD con E
|
||||
// relacionada en mismo mes, el cálculo aporta el valor de la E. La I/07
|
||||
// PPD aparece en el drill (parte del Grupo 1 universe vía I PPD), pero
|
||||
// las E ya no.
|
||||
where += ` AND (
|
||||
( -- Grupo 1 PF Empresarial
|
||||
${esEmisor}
|
||||
AND regimen_fiscal_emisor IN (${g1})
|
||||
AND (
|
||||
(tipo_comprobante = 'I' AND metodo_pago = 'PUE')
|
||||
OR (tipo_comprobante = 'P')
|
||||
)
|
||||
)
|
||||
OR ( -- Grupo 2 Sueldos: nómina recibida 605
|
||||
${esReceptor}
|
||||
AND tipo_comprobante = 'N'
|
||||
AND metodo_pago = 'PUE'
|
||||
AND regimen_fiscal_receptor = '605'
|
||||
)
|
||||
OR ( -- Grupo 3 PM y otros
|
||||
${esEmisor}
|
||||
AND regimen_fiscal_emisor IN (${g3})
|
||||
AND tipo_comprobante = 'I' AND metodo_pago IN ('PUE','PPD')
|
||||
)
|
||||
) ${NO_IGNORADO_EMISOR.replace('regimen_fiscal_emisor', `CASE WHEN ${esEmisor} THEN regimen_fiscal_emisor ELSE regimen_fiscal_receptor END`)}`;
|
||||
} else if (bucketStr === 'gastos') {
|
||||
// Las E PUE se exhiben en su propia card "NCs Recibidas" — no entran aquí.
|
||||
where += ` AND (
|
||||
${esReceptor} AND (
|
||||
(tipo_comprobante = 'I' AND metodo_pago = 'PUE')
|
||||
OR (tipo_comprobante = 'P')
|
||||
)
|
||||
AND regimen_fiscal_receptor IN (${TODOS_REGS})
|
||||
) ${NO_IGNORADO_RECEPTOR}`;
|
||||
} else if (bucketStr === 'causado') {
|
||||
where += ` AND (
|
||||
${esEmisor} AND (
|
||||
(tipo_comprobante = 'I' AND metodo_pago = 'PUE')
|
||||
OR (tipo_comprobante = 'P')
|
||||
OR (tipo_comprobante = 'E' AND metodo_pago = 'PUE' AND ${E_NO_ANTICIPO})
|
||||
)
|
||||
AND regimen_fiscal_emisor IN (${TODOS_REGS})
|
||||
) ${NO_IGNORADO_EMISOR}`;
|
||||
} else if (bucketStr === 'ncs_emitidas') {
|
||||
// E PUE emitidas por el contribuyente, por régimen del emisor.
|
||||
// Mirror del card "NCs Emitidas" en /impuestos > ISR.
|
||||
// Sin restringir a TODOS_REGS — el calcular function tampoco lo hace
|
||||
// (acepta cualquier régimen no-NULL no-ignorado, incluyendo 616
|
||||
// Extranjero, etc.). Si el contador filtró regímenes ignorados, el
|
||||
// NO_IGNORADO_EMISOR ya los excluye.
|
||||
where += ` AND (
|
||||
${esEmisor}
|
||||
AND tipo_comprobante = 'E' AND metodo_pago = 'PUE'
|
||||
AND regimen_fiscal_emisor IS NOT NULL
|
||||
) ${NO_IGNORADO_EMISOR}`;
|
||||
} else if (bucketStr === 'ncs_recibidas') {
|
||||
// E PUE recibidas por el contribuyente, por régimen del receptor.
|
||||
// Mirror del card "NCs Recibidas" en /impuestos > ISR.
|
||||
where += ` AND (
|
||||
${esReceptor}
|
||||
AND tipo_comprobante = 'E' AND metodo_pago = 'PUE'
|
||||
AND regimen_fiscal_receptor IS NOT NULL
|
||||
) ${NO_IGNORADO_RECEPTOR}`;
|
||||
} else if (bucketStr === 'no_deducibles_efectivo') {
|
||||
// Art. 27 fracción III LISR — facturas recibidas pagadas en efectivo
|
||||
// (forma_pago='01') con monto > $2,000. Mirror del card "No Deducibles".
|
||||
// I PUE: comparación con total_mxn. P: con monto_pago_mxn.
|
||||
where += ` AND (
|
||||
${esReceptor}
|
||||
AND forma_pago = '01'
|
||||
AND (
|
||||
(tipo_comprobante = 'I' AND metodo_pago = 'PUE' AND COALESCE(total_mxn, 0) > 2000)
|
||||
OR (tipo_comprobante = 'P' AND COALESCE(monto_pago_mxn, 0) > 2000)
|
||||
)
|
||||
AND regimen_fiscal_receptor IS NOT NULL
|
||||
) ${NO_IGNORADO_RECEPTOR}`;
|
||||
} else if (bucketStr === 'acreditable') {
|
||||
where += ` AND (
|
||||
${esReceptor} AND (
|
||||
(tipo_comprobante = 'I' AND metodo_pago = 'PUE')
|
||||
OR (tipo_comprobante = 'P')
|
||||
OR (tipo_comprobante = 'E' AND metodo_pago = 'PUE' AND ${E_NO_ANTICIPO})
|
||||
)
|
||||
AND regimen_fiscal_receptor IN (${TODOS_REGS})
|
||||
) ${NO_IGNORADO_RECEPTOR}`;
|
||||
}
|
||||
|
||||
// Fecha efectiva: para CFDIs tipo P (complementos de pago) usa fecha_pago_p
|
||||
// (cuándo el cliente cobró) en vez de fecha_emision (cuándo se emitió el
|
||||
// complemento). Así el drill-down es coherente con los KPIs — un P emitido
|
||||
// en mayo que cobró una PPD de noviembre aparece en noviembre, no en mayo.
|
||||
const FECHA_EFECTIVA = `CASE WHEN tipo_comprobante = 'P' THEN fecha_pago_p ELSE fecha_emision END`;
|
||||
if (fechaInicio) {
|
||||
where += ` AND ${FECHA_EFECTIVA} >= $${pi++}::date`;
|
||||
params.push(fechaInicio);
|
||||
}
|
||||
if (fechaFin) {
|
||||
where += ` AND ${FECHA_EFECTIVA} < ($${pi++}::date + interval '1 day')`;
|
||||
params.push(fechaFin);
|
||||
}
|
||||
if (!bucketApplied) {
|
||||
if (type) {
|
||||
where += ` AND type = $${pi++}`;
|
||||
params.push(type);
|
||||
}
|
||||
// tipoComprobante acepta valor único ('I') o CSV ('I,P'). Cuando la lista
|
||||
// incluye P, el filtro metodoPago NO se aplica a los P (que no tienen),
|
||||
// para que un drill-down "Ingresos del Mes" muestre I PUE + todos los P.
|
||||
const tiposList = tipoComprobante
|
||||
? (tipoComprobante as string).split(',').map(t => t.trim()).filter(Boolean)
|
||||
: [];
|
||||
const includesP = tiposList.includes('P');
|
||||
if (tiposList.length === 1) {
|
||||
where += ` AND tipo_comprobante = $${pi++}`;
|
||||
params.push(tiposList[0]);
|
||||
} else if (tiposList.length > 1) {
|
||||
where += ` AND tipo_comprobante = ANY($${pi++})`;
|
||||
params.push(tiposList);
|
||||
}
|
||||
if (metodoPago) {
|
||||
const metodos = (metodoPago as string).split(',');
|
||||
if (includesP) {
|
||||
// P no tiene metodo_pago: el filtro aplica solo a los no-P
|
||||
where += ` AND (tipo_comprobante = 'P' OR metodo_pago = ANY($${pi++}))`;
|
||||
params.push(metodos);
|
||||
} else {
|
||||
where += ` AND metodo_pago = ANY($${pi++})`;
|
||||
params.push(metodos);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (regimenEmisor) {
|
||||
where += ` AND regimen_fiscal_emisor = $${pi++}`;
|
||||
params.push(regimenEmisor);
|
||||
}
|
||||
if (regimenReceptor) {
|
||||
where += ` AND regimen_fiscal_receptor = $${pi++}`;
|
||||
params.push(regimenReceptor);
|
||||
}
|
||||
if (status) {
|
||||
if (status === 'vigente') {
|
||||
where += ` AND status NOT IN ('Cancelado', '0')`;
|
||||
} else {
|
||||
where += ` AND status IN ('Cancelado', '0')`;
|
||||
}
|
||||
}
|
||||
if (contribuyenteId && !bucketApplied) {
|
||||
// Solo aplica cuando NO hay bucket (drill crudo, sin semantic de lado).
|
||||
// Con bucket, esEmisor/esReceptor ya restringen por RFC del contribuyente.
|
||||
// Sin bucket, filtramos inclusivo: contribuyente_id O RFC en cualquier lado.
|
||||
if (cfdiCtx) {
|
||||
where += ` AND ${cfdiCtx.contribFilter.replace(/^AND /, '')}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Aplica filtros de "Considerar activos" / "Considerar NCs" — alineado
|
||||
// con los KPIs/cards. Sin esto el drill mostraba CFDIs que la card había
|
||||
// excluido (ej. P que paga una I de activo con uso_cfdi=I03).
|
||||
where += extra;
|
||||
|
||||
const { rows } = await req.tenantPool.query(`
|
||||
SELECT id, uuid, type, tipo_comprobante as "tipoComprobante",
|
||||
fecha_emision as "fechaEmision", status,
|
||||
rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
|
||||
rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
|
||||
subtotal, subtotal_mxn as "subtotalMxn",
|
||||
total, total_mxn as "totalMxn",
|
||||
moneda, metodo_pago as "metodoPago",
|
||||
iva_traslado_mxn as "ivaTrasladoMxn",
|
||||
iva_retencion_mxn as "ivaRetencionMxn",
|
||||
isr_retencion_mxn as "isrRetencionMxn",
|
||||
monto_pago_mxn as "montoPagoMxn",
|
||||
regimen_fiscal_emisor as "regimenEmisor",
|
||||
regimen_fiscal_receptor as "regimenReceptor"
|
||||
FROM cfdis
|
||||
${where}
|
||||
ORDER BY fecha_emision DESC
|
||||
LIMIT 500
|
||||
`, params);
|
||||
|
||||
res.json(rows);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEmisores(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const search = (req.query.search as string) || '';
|
||||
if (search.length < 2) {
|
||||
return res.json([]);
|
||||
}
|
||||
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
const emisores = await cfdiService.getEmisores(req.tenantPool, search, 10, contribuyenteId);
|
||||
res.json(emisores);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getReceptores(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const search = (req.query.search as string) || '';
|
||||
if (search.length < 2) {
|
||||
return res.json([]);
|
||||
}
|
||||
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
const receptores = await cfdiService.getReceptores(req.tenantPool, search, 10, contribuyenteId);
|
||||
res.json(receptores);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getResumen(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
|
||||
const resumen = await cfdiService.getResumenCfdis(req.tenantPool, año, mes, contribuyenteId);
|
||||
res.json(resumen);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCfdi(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
if (!['owner', 'contador'].includes(req.user!.role)) {
|
||||
return next(new AppError(403, 'No tienes permisos para agregar CFDIs'));
|
||||
}
|
||||
|
||||
const cfdi = await cfdiService.createCfdi(req.tenantPool, req.body);
|
||||
res.status(201).json(cfdi);
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('duplicate')) {
|
||||
return next(new AppError(409, 'Este CFDI ya existe (UUID duplicado)'));
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createManyCfdis(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
if (!['owner', 'contador'].includes(req.user!.role)) {
|
||||
return next(new AppError(403, 'No tienes permisos para agregar CFDIs'));
|
||||
}
|
||||
|
||||
if (!Array.isArray(req.body.cfdis)) {
|
||||
return next(new AppError(400, 'Se requiere un array de CFDIs'));
|
||||
}
|
||||
|
||||
const batchInfo = {
|
||||
batchNumber: req.body.batchNumber || 1,
|
||||
totalBatches: req.body.totalBatches || 1,
|
||||
totalFiles: req.body.totalFiles || req.body.cfdis.length
|
||||
};
|
||||
|
||||
console.log(`[CFDI Bulk] Lote ${batchInfo.batchNumber}/${batchInfo.totalBatches} - ${req.body.cfdis.length} CFDIs`);
|
||||
|
||||
const result = await cfdiService.createManyCfdisBatch(req.tenantPool, req.body.cfdis);
|
||||
|
||||
res.status(201).json({
|
||||
message: `Lote ${batchInfo.batchNumber} procesado`,
|
||||
batchNumber: batchInfo.batchNumber,
|
||||
totalBatches: batchInfo.totalBatches,
|
||||
inserted: result.inserted,
|
||||
duplicates: result.duplicates,
|
||||
errors: result.errors,
|
||||
errorMessages: result.errorMessages.slice(0, 5)
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[CFDI Bulk Error]', error.message, error.stack);
|
||||
next(new AppError(400, error.message || 'Error al procesar CFDIs'));
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCfdi(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
if (!['owner', 'contador'].includes(req.user!.role)) {
|
||||
return next(new AppError(403, 'No tienes permisos para eliminar CFDIs'));
|
||||
}
|
||||
|
||||
await cfdiService.deleteCfdi(req.tenantPool, String(req.params.id));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
58
apps/api/src/controllers/conciliacion.controller.ts
Normal file
58
apps/api/src/controllers/conciliacion.controller.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import * as conciliacionService from '../services/conciliacion.service.js';
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
export async function getCfdis(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { tipo, fechaInicio, fechaFin, regimen, estado, contribuyenteId } = req.query;
|
||||
if (!tipo) return res.status(400).json({ message: 'tipo es requerido (EMITIDO|RECIBIDO)' });
|
||||
|
||||
const data = await conciliacionService.getCfdisConConciliacion(req.tenantPool!, {
|
||||
tipo: tipo as string,
|
||||
fechaInicio: fechaInicio as string,
|
||||
fechaFin: fechaFin as string,
|
||||
regimen: regimen as string,
|
||||
estado: estado as string,
|
||||
contribuyenteId: contribuyenteId as string | undefined,
|
||||
});
|
||||
res.json(data);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
export async function conciliar(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'].includes(req.user!.role)) {
|
||||
return res.status(403).json({ message: 'No autorizado' });
|
||||
}
|
||||
|
||||
const { cfdiIds, fechaDePago, idBanco } = req.body;
|
||||
if (!cfdiIds?.length || !fechaDePago || !idBanco) {
|
||||
return res.status(400).json({ message: 'cfdiIds, fechaDePago e idBanco son requeridos' });
|
||||
}
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: req.user!.tenantId },
|
||||
select: { createdAt: true },
|
||||
});
|
||||
const tenantCreatedYear = tenant ? tenant.createdAt.getFullYear() : new Date().getFullYear();
|
||||
|
||||
const count = await conciliacionService.conciliar(req.tenantPool!, { cfdiIds, fechaDePago, idBanco }, tenantCreatedYear);
|
||||
res.json({ message: `${count} CFDIs conciliados`, count });
|
||||
} catch (error: any) {
|
||||
if (error.message && !error.message.includes('Internal')) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function desconciliar(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'].includes(req.user!.role)) {
|
||||
return res.status(403).json({ message: 'No autorizado' });
|
||||
}
|
||||
const id = parseInt(String(req.params.id));
|
||||
await conciliacionService.desconciliar(req.tenantPool!, id);
|
||||
res.json({ message: 'CFDI desconciliado' });
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
58
apps/api/src/controllers/connector.controller.ts
Normal file
58
apps/api/src/controllers/connector.controller.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import * as connectorService from '../services/connector.service.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
|
||||
const heartbeatSchema = z.object({
|
||||
version: z.string(),
|
||||
uptimeSeconds: z.number().optional().default(0),
|
||||
postgresPingMs: z.number().optional().default(0),
|
||||
pgVersion: z.string().optional(),
|
||||
lastMigration: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
errorMsg: z.string().optional(),
|
||||
});
|
||||
|
||||
// Called by the connector Docker container, NOT by browser users
|
||||
export async function heartbeat(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ message: 'Token requerido' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const tenantId = await connectorService.verifyConnectorToken(token);
|
||||
if (!tenantId) {
|
||||
return res.status(401).json({ message: 'Token inválido' });
|
||||
}
|
||||
|
||||
const data = heartbeatSchema.parse(req.body);
|
||||
await connectorService.recordHeartbeat(tenantId, data);
|
||||
|
||||
return res.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Called by authenticated tenant owner to provision or check connector
|
||||
export async function provision(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const tenantId = req.viewingTenantId || req.user!.tenantId;
|
||||
const result = await connectorService.provisionConnector(tenantId);
|
||||
return res.status(201).json(result);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes('no encontrado')) return next(new AppError(404, err.message));
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function status(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const tenantId = req.viewingTenantId || req.user!.tenantId;
|
||||
const result = await connectorService.getConnectorStatus(tenantId);
|
||||
return res.json(result);
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
95
apps/api/src/controllers/contribuyente-config.controller.ts
Normal file
95
apps/api/src/controllers/contribuyente-config.controller.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
import * as fielService from '../services/contribuyente-fiel.service.js';
|
||||
import * as facturapiService from '../services/contribuyente-facturapi.service.js';
|
||||
import { getContribuyenteById } from '../services/contribuyente.service.js';
|
||||
|
||||
// ========== FIEL ==========
|
||||
|
||||
export async function uploadFiel(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { cerFile, keyFile, password } = req.body;
|
||||
if (!cerFile || !keyFile || !password) {
|
||||
return next(new AppError(400, 'cerFile, keyFile y password son requeridos'));
|
||||
}
|
||||
const contribuyenteId = String(req.params.id);
|
||||
const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId);
|
||||
if (!contrib) return next(new AppError(404, 'Contribuyente no encontrado'));
|
||||
|
||||
const result = await fielService.uploadFielContribuyente(req.tenantPool!, contribuyenteId, cerFile, keyFile, password);
|
||||
if (!result.success) {
|
||||
console.error('[FIEL Upload] Failed:', result.message);
|
||||
return res.status(400).json({ message: result.message });
|
||||
}
|
||||
return res.json(result);
|
||||
} catch (err: any) {
|
||||
console.error('[FIEL Upload] Exception:', err.message || err);
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fielStatus(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = String(req.params.id);
|
||||
const status = await fielService.getFielStatusContribuyente(req.tenantPool!, contribuyenteId);
|
||||
return res.json(status);
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
export async function deleteFiel(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = String(req.params.id);
|
||||
// Delete from per-contribuyente table (tenant BD)
|
||||
await req.tenantPool!.query(
|
||||
'UPDATE fiel_contribuyente SET is_active = false WHERE contribuyente_id = $1',
|
||||
[contribuyenteId]
|
||||
);
|
||||
// Also try to deactivate legacy FIEL if it matches this contribuyente's RFC
|
||||
const { rows } = await req.tenantPool!.query('SELECT rfc FROM contribuyentes WHERE entidad_id = $1', [contribuyenteId]);
|
||||
if (rows[0]?.rfc) {
|
||||
const { prisma } = await import('../config/database.js');
|
||||
await prisma.fielCredential.updateMany({
|
||||
where: { rfc: rows[0].rfc },
|
||||
data: { isActive: false },
|
||||
}).catch(() => {});
|
||||
}
|
||||
return res.json({ message: 'FIEL eliminada' });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
// ========== FACTURAPI ==========
|
||||
|
||||
export async function createOrg(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = String(req.params.id);
|
||||
const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId);
|
||||
if (!contrib) return next(new AppError(404, 'Contribuyente no encontrado'));
|
||||
|
||||
const result = await facturapiService.createOrgContribuyente(req.tenantPool!, contribuyenteId, contrib.nombre);
|
||||
return res.status(201).json(result);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes('ya tiene')) return next(new AppError(409, err.message));
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function orgStatus(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = String(req.params.id);
|
||||
const status = await facturapiService.getOrgStatusContribuyente(req.tenantPool!, contribuyenteId);
|
||||
return res.json(status);
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
export async function uploadCsd(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { cerFile, keyFile, password } = req.body;
|
||||
if (!cerFile || !keyFile || !password) {
|
||||
return next(new AppError(400, 'cerFile, keyFile y password son requeridos'));
|
||||
}
|
||||
const contribuyenteId = String(req.params.id);
|
||||
const result = await facturapiService.uploadCsdContribuyente(req.tenantPool!, contribuyenteId, cerFile, keyFile, password);
|
||||
if (!result.success) return res.status(400).json({ message: result.message });
|
||||
return res.json(result);
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
148
apps/api/src/controllers/contribuyente.controller.ts
Normal file
148
apps/api/src/controllers/contribuyente.controller.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import * as contribuyenteService from '../services/contribuyente.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);
|
||||
return res.json({ data: rows });
|
||||
} 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));
|
||||
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 = await contribuyenteService.createContribuyente(req.tenantPool!, data);
|
||||
|
||||
// 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(201).json({ ...row, 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);
|
||||
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); }
|
||||
}
|
||||
108
apps/api/src/controllers/dashboard.controller.ts
Normal file
108
apps/api/src/controllers/dashboard.controller.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import * as dashboardService from '../services/dashboard.service.js';
|
||||
import { generarAlertasAutomaticas } from '../services/alertas-auto.service.js';
|
||||
import { getAlertasManualesPendientes } from '../services/alertas-manuales.service.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
|
||||
function getDefaultRange() {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = now.getMonth() + 1;
|
||||
const lastDay = new Date(y, m, 0).getDate();
|
||||
return {
|
||||
fechaInicio: `${y}-${String(m).padStart(2, '0')}-01`,
|
||||
fechaFin: `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`,
|
||||
año: y,
|
||||
mes: m,
|
||||
};
|
||||
}
|
||||
|
||||
function parseConciliacion(req: Request): boolean {
|
||||
return req.query.conciliacion === 'true' || req.query.conciliacion === '1';
|
||||
}
|
||||
|
||||
export async function getKpis(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const defaults = getDefaultRange();
|
||||
const fechaInicio = (req.query.fechaInicio as string) || defaults.fechaInicio;
|
||||
const fechaFin = (req.query.fechaFin as string) || defaults.fechaFin;
|
||||
const conciliacion = parseConciliacion(req);
|
||||
const contribuyenteId = (req.query.contribuyenteId as string) || null;
|
||||
|
||||
const tenantId = req.viewingTenantId || req.user!.tenantId;
|
||||
const kpis = await dashboardService.getKpis(req.tenantPool, fechaInicio, fechaFin, tenantId, conciliacion, contribuyenteId);
|
||||
res.json(kpis);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getIngresosEgresos(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||
const conciliacion = parseConciliacion(req);
|
||||
const contribuyenteId = (req.query.contribuyenteId as string) || null;
|
||||
const tenantId = req.viewingTenantId || req.user!.tenantId;
|
||||
|
||||
const data = await dashboardService.getIngresosEgresos(req.tenantPool, año, tenantId, conciliacion, contribuyenteId);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRegimenesDelPeriodo(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const defaults = getDefaultRange();
|
||||
const fechaInicio = (req.query.fechaInicio as string) || defaults.fechaInicio;
|
||||
const fechaFin = (req.query.fechaFin as string) || defaults.fechaFin;
|
||||
const conciliacion = parseConciliacion(req);
|
||||
const contribuyenteId = (req.query.contribuyenteId as string) || null;
|
||||
|
||||
const tenantId = req.viewingTenantId || req.user?.tenantId;
|
||||
const regimenes = await dashboardService.getRegimenesDelPeriodo(req.tenantPool, fechaInicio, fechaFin, conciliacion, contribuyenteId, tenantId);
|
||||
res.json(regimenes);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAlertas(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const limit = parseInt(req.query.limit as string) || 5;
|
||||
const tenantId = req.viewingTenantId || req.user!.tenantId;
|
||||
const contribuyenteId = (req.query.contribuyenteId as string) || null;
|
||||
|
||||
// Combinar alertas persistidas (manuales, filtered by role) + automáticas (calculadas)
|
||||
const [manuales, automaticas] = await Promise.all([
|
||||
getAlertasManualesPendientes(req.tenantPool, contribuyenteId, req.user!.userId, req.user!.role),
|
||||
generarAlertasAutomaticas(req.tenantPool, tenantId, contribuyenteId),
|
||||
]);
|
||||
|
||||
// Unir, ordenar por prioridad, y limitar
|
||||
const prioridadOrden: Record<string, number> = { alta: 1, media: 2, baja: 3 };
|
||||
const alertas = [...automaticas, ...manuales]
|
||||
.sort((a, b) => (prioridadOrden[a.prioridad] || 3) - (prioridadOrden[b.prioridad] || 3))
|
||||
.slice(0, limit);
|
||||
|
||||
res.json(alertas);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
67
apps/api/src/controllers/despacho-audit.controller.ts
Normal file
67
apps/api/src/controllers/despacho-audit.controller.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
|
||||
export async function getDespachoAuditLog(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) return next(new AppError(401, 'No autenticado'));
|
||||
|
||||
const tenantId = req.viewingTenantId || req.user.tenantId;
|
||||
|
||||
// Only owner or cfo can see audit log of their despacho
|
||||
if (req.user.role !== 'owner' && req.user.role !== 'cfo') {
|
||||
return next(new AppError(403, 'Solo el dueño puede ver el registro de accesos'));
|
||||
}
|
||||
|
||||
const from = req.query.from
|
||||
? new Date(req.query.from as string)
|
||||
: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const to = req.query.to ? new Date(req.query.to as string) : new Date();
|
||||
const limit = Math.min(Number(req.query.limit) || 50, 200);
|
||||
|
||||
const logs = await prisma.auditLog.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
action: { startsWith: 'admin.' },
|
||||
createdAt: { gte: from, lte: to },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
// Enrich with admin user info
|
||||
const userIds = [...new Set(logs.filter(l => l.userId).map(l => l.userId!))];
|
||||
const users =
|
||||
userIds.length > 0
|
||||
? await prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: { id: true, nombre: true, email: true },
|
||||
})
|
||||
: [];
|
||||
const userMap = new Map(users.map(u => [u.id, u]));
|
||||
|
||||
const enriched = logs.map(log => ({
|
||||
id: log.id,
|
||||
action: log.action,
|
||||
timestamp: log.createdAt.toISOString(),
|
||||
admin: log.userId
|
||||
? {
|
||||
nombre: userMap.get(log.userId)?.nombre ?? 'Desconocido',
|
||||
email: userMap.get(log.userId)?.email ?? '',
|
||||
}
|
||||
: null,
|
||||
motivo: (log.metadata as any)?.motivo ?? null,
|
||||
ip: (log.metadata as any)?.ip ?? null,
|
||||
details: log.metadata,
|
||||
}));
|
||||
|
||||
return res.json({
|
||||
data: enriched,
|
||||
total: enriched.length,
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
67
apps/api/src/controllers/despacho-stats.controller.ts
Normal file
67
apps/api/src/controllers/despacho-stats.controller.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
import * as despachoService from '../services/despacho-stats.service.js';
|
||||
|
||||
function effectiveTenantId(req: Request): string {
|
||||
return req.viewingTenantId || req.user!.tenantId;
|
||||
}
|
||||
|
||||
const ROLES_OWNER = new Set(['owner', 'cfo']);
|
||||
const ROLES_SUPERVISORY = new Set(['owner', 'cfo', 'supervisor']);
|
||||
const ROLES_ASIGNADOS = new Set(['owner', 'cfo', 'supervisor', 'auxiliar']);
|
||||
|
||||
export async function getContribuyentesStats(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!ROLES_OWNER.has(req.user!.role)) {
|
||||
throw new AppError(403, 'Solo owner puede ver estas métricas');
|
||||
}
|
||||
const tenantId = effectiveTenantId(req);
|
||||
const año = req.query.año ? parseInt(String(req.query.año), 10) : undefined;
|
||||
const mes = req.query.mes ? parseInt(String(req.query.mes), 10) : undefined;
|
||||
const stats = await despachoService.getContribuyentesStats(req.tenantPool!, tenantId, año, mes);
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMisAsignados(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!ROLES_ASIGNADOS.has(req.user!.role)) {
|
||||
throw new AppError(403, 'No tienes contribuyentes asignados');
|
||||
}
|
||||
const año = req.query.año ? parseInt(String(req.query.año), 10) : undefined;
|
||||
const mes = req.query.mes ? parseInt(String(req.query.mes), 10) : undefined;
|
||||
const data = await despachoService.getMisAsignados(
|
||||
req.tenantPool!,
|
||||
req.user!.userId,
|
||||
req.user!.role,
|
||||
año,
|
||||
mes,
|
||||
);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEquipoStats(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!ROLES_SUPERVISORY.has(req.user!.role)) {
|
||||
throw new AppError(403, 'Solo owner y supervisor pueden ver al equipo');
|
||||
}
|
||||
const año = req.query.año ? parseInt(String(req.query.año), 10) : undefined;
|
||||
const mes = req.query.mes ? parseInt(String(req.query.mes), 10) : undefined;
|
||||
const data = await despachoService.getEquipoStats(
|
||||
req.tenantPool!,
|
||||
req.user!.userId,
|
||||
req.user!.role,
|
||||
effectiveTenantId(req),
|
||||
año,
|
||||
mes,
|
||||
);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
98
apps/api/src/controllers/despacho.controller.ts
Normal file
98
apps/api/src/controllers/despacho.controller.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { signupDespacho } from '../services/despacho.service.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
const signupSchema = z.object({
|
||||
despacho: z.object({
|
||||
nombre: z.string().min(2, 'Nombre del despacho requerido'),
|
||||
regimenFiscal: z.string().optional(),
|
||||
codigoPostal: z.string().regex(/^\d{5}$/, 'Código postal inválido').optional(),
|
||||
verticalProfile: z.enum(['CONTABLE', 'JURIDICO', 'ARQUITECTURA']),
|
||||
plan: z.enum(['trial', 'mi_empresa', 'mi_empresa_plus', 'business_control', 'business_cloud']).optional().default('trial'),
|
||||
// Solo aplica a mi_empresa y mi_empresa_plus (los otros pagados son
|
||||
// anuales fijos). Default annual sesga el cash-flow del negocio.
|
||||
frequency: z.enum(['monthly', 'annual']).optional().default('annual'),
|
||||
}),
|
||||
owner: z.object({
|
||||
nombre: z.string().min(2, 'Nombre del owner requerido'),
|
||||
email: z.string().email('Email inválido'),
|
||||
password: z.string().min(10, 'La contraseña debe tener al menos 10 caracteres'),
|
||||
}),
|
||||
});
|
||||
|
||||
export async function getMyPlan(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const tenantId = req.user!.tenantId;
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { dbMode: true, trialEndsAt: true, verticalProfile: true, plan: true },
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
return next(new AppError(404, 'Tenant no encontrado'));
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const isTrialActive = tenant.trialEndsAt ? tenant.trialEndsAt > now : false;
|
||||
|
||||
// Mapea según trialEndsAt + tenant.plan (no dbMode). dbMode era proxy
|
||||
// antes de la introducción de Mi Empresa / Mi Empresa+ — para esos
|
||||
// planes, dbMode también es MANAGED y reportar `business_cloud` daba
|
||||
// mapeo equivocado. tenant.plan es la fuente de verdad post-migración
|
||||
// 20260426073942 (que añadió mi_empresa y mi_empresa_plus al enum).
|
||||
let currentPlan: string;
|
||||
if (isTrialActive) {
|
||||
currentPlan = 'trial';
|
||||
} else {
|
||||
currentPlan = String(tenant.plan);
|
||||
}
|
||||
|
||||
// Estado de suscripción activa (si hay) — alimenta la UI con el monto
|
||||
// recurrente actual, fecha de próxima renovación y si el primer pago
|
||||
// (cuando aplica dualidad firstYear) ya fue completado.
|
||||
const subscription = await prisma.subscription.findFirst({
|
||||
where: { tenantId, status: { in: ['authorized', 'pending', 'paused', 'trial'] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
status: true, amount: true, plan: true,
|
||||
currentPeriodStart: true, currentPeriodEnd: true,
|
||||
},
|
||||
});
|
||||
|
||||
return res.json({
|
||||
plan: currentPlan,
|
||||
dbMode: tenant.dbMode,
|
||||
trialEndsAt: tenant.trialEndsAt?.toISOString() ?? null,
|
||||
isTrialActive,
|
||||
subscription: subscription
|
||||
? {
|
||||
status: subscription.status,
|
||||
plan: subscription.plan,
|
||||
amount: Number(subscription.amount),
|
||||
currentPeriodStart: subscription.currentPeriodStart?.toISOString() ?? null,
|
||||
currentPeriodEnd: subscription.currentPeriodEnd?.toISOString() ?? null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function signup(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = signupSchema.parse(req.body);
|
||||
const result = await signupDespacho(data);
|
||||
return res.status(201).json(result);
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return next(new AppError(400, error.errors[0].message));
|
||||
}
|
||||
if (error.message?.includes('Ya existe')) {
|
||||
return next(new AppError(409, error.message));
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
333
apps/api/src/controllers/documentos.controller.ts
Normal file
333
apps/api/src/controllers/documentos.controller.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { getOpiniones, getOpinionPdf, consultarOpinion, consultarOpinionContribuyente } from '../services/opinion-cumplimiento.service.js';
|
||||
import * as declaracionesService from '../services/declaraciones.service.js';
|
||||
import * as constanciaService from '../services/constancia.service.js';
|
||||
import * as extrasService from '../services/documentos-extras.service.js';
|
||||
import { notifyDocumentoSubido } from '../services/notify-upload.service.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
|
||||
const MESES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
|
||||
|
||||
function effectiveTenantId(req: Request): string {
|
||||
return req.viewingTenantId || req.user!.tenantId;
|
||||
}
|
||||
|
||||
export async function listarOpiniones(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
let rfc: string | undefined;
|
||||
if (contribuyenteId) {
|
||||
const { rows } = await req.tenantPool!.query(
|
||||
'SELECT rfc FROM contribuyentes WHERE entidad_id = $1',
|
||||
[contribuyenteId],
|
||||
);
|
||||
rfc = rows[0]?.rfc;
|
||||
}
|
||||
const opiniones = await getOpiniones(req.tenantPool!, 5, rfc);
|
||||
res.json(opiniones);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function descargarPdf(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const id = parseInt(String(req.params.id));
|
||||
if (isNaN(id)) return res.status(400).json({ error: 'ID inválido' });
|
||||
|
||||
const pdf = await getOpinionPdf(req.tenantPool!, id);
|
||||
if (!pdf) return res.status(404).json({ error: 'Opinión no encontrada' });
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="opinion_cumplimiento_${id}.pdf"`);
|
||||
res.send(pdf);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function consultarManual(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const tenantId = effectiveTenantId(req);
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
|
||||
let opinion;
|
||||
if (contribuyenteId) {
|
||||
opinion = await consultarOpinionContribuyente(req.tenantPool!, contribuyenteId);
|
||||
} else {
|
||||
opinion = await consultarOpinion(tenantId);
|
||||
}
|
||||
res.json(opinion);
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('FIEL')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Declaraciones provisionales
|
||||
// ============================================================================
|
||||
|
||||
const ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar'];
|
||||
|
||||
function canUpload(req: Request): boolean {
|
||||
return ROLES_UPLOAD.includes(req.user!.role);
|
||||
}
|
||||
|
||||
const createDeclaracionSchema = z.object({
|
||||
año: z.number().int().min(2020).max(2100),
|
||||
mes: z.number().int().min(1).max(12),
|
||||
tipo: z.enum(['normal', 'complementaria']),
|
||||
periodicidad: z.enum(['mensual', 'bimestral', 'trimestral', 'semestral', 'anual']).optional(),
|
||||
impuestos: z.array(z.enum(['IVA', 'ISR', 'IEPS', 'SUELDOS', 'DIOT', 'OTRO'])).min(1, 'Selecciona al menos un impuesto'),
|
||||
montoPago: z.number().min(0).optional(),
|
||||
pdfBase64: z.string().min(100),
|
||||
pdfFilename: z.string().min(1).max(255),
|
||||
ligaPagoBase64: z.string().min(100).optional(),
|
||||
ligaPagoFilename: z.string().min(1).max(255).optional(),
|
||||
notas: z.string().max(2000).optional(),
|
||||
}).refine(
|
||||
d => !d.ligaPagoBase64 || !!d.ligaPagoFilename,
|
||||
{ message: 'Si incluyes liga de pago, también debes mandar su nombre de archivo', path: ['ligaPagoFilename'] },
|
||||
);
|
||||
|
||||
export async function listarDeclaraciones(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const fechaDesde = req.query.fechaDesde as string | undefined;
|
||||
const fechaHasta = req.query.fechaHasta as string | undefined;
|
||||
const contribuyenteId = typeof req.query.contribuyenteId === 'string' && req.query.contribuyenteId
|
||||
? req.query.contribuyenteId
|
||||
: null;
|
||||
const data = await declaracionesService.listDeclaraciones(req.tenantPool!, fechaDesde, fechaHasta, contribuyenteId);
|
||||
res.json(data);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
export async function crearDeclaracion(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para subir declaraciones' });
|
||||
const data = createDeclaracionSchema.parse(req.body);
|
||||
const contribuyenteId = req.body.contribuyenteId as string | undefined;
|
||||
const result = await declaracionesService.createDeclaracion(req.tenantPool!, {
|
||||
...data,
|
||||
creadoPor: req.user!.email,
|
||||
creadoPorUserId: req.user!.userId,
|
||||
contribuyenteId,
|
||||
});
|
||||
|
||||
// Notificación fire-and-forget a owners del despacho + supervisor del RFC.
|
||||
// No bloquea la respuesta ni falla la creación si SMTP no está configurado.
|
||||
notifyDocumentoSubido({
|
||||
pool: req.tenantPool!,
|
||||
tenantId: req.user!.tenantId,
|
||||
contribuyenteId: contribuyenteId ?? null,
|
||||
subidoPor: req.user!.email,
|
||||
kind: 'declaracion',
|
||||
declaracion: {
|
||||
periodo: `${MESES[data.mes - 1]} ${data.año}`,
|
||||
tipo: data.tipo,
|
||||
impuestos: data.impuestos as string[],
|
||||
montoPago: data.montoPago ?? null,
|
||||
},
|
||||
}).catch((err: any) => console.error('[notifyDocumentoSubido declaracion]', err?.message || err));
|
||||
|
||||
res.status(201).json(result);
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
if (error?.message?.includes('Ya existe') || error?.message?.includes('normal')) {
|
||||
return next(new AppError(400, error.message));
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
const comprobantePagoSchema = z.object({
|
||||
pdfBase64: z.string().min(100),
|
||||
pdfFilename: z.string().min(1).max(255),
|
||||
});
|
||||
|
||||
export async function subirComprobantePago(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para subir comprobantes' });
|
||||
const id = parseInt(String(req.params.id));
|
||||
if (isNaN(id)) return next(new AppError(400, 'id inválido'));
|
||||
const data = comprobantePagoSchema.parse(req.body);
|
||||
const result = await declaracionesService.uploadComprobantePago(req.tenantPool!, id, {
|
||||
...data,
|
||||
uploadedByUserId: req.user!.userId,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
if (error?.message?.includes('no encontrada')) {
|
||||
return next(new AppError(404, error.message));
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function descargarDeclaracionPdf(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const id = parseInt(String(req.params.id));
|
||||
if (isNaN(id)) return next(new AppError(400, 'id inválido'));
|
||||
const v = req.params.variant;
|
||||
const variant: 'declaracion' | 'liga' | 'pago' = v === 'pago' ? 'pago' : v === 'liga' ? 'liga' : 'declaracion';
|
||||
const pdf = await declaracionesService.getDeclaracionPdf(req.tenantPool!, id, variant);
|
||||
if (!pdf) return res.status(404).json({ message: 'PDF no encontrado' });
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${pdf.filename}"`);
|
||||
res.send(pdf.buffer);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Constancia de Situación Fiscal
|
||||
// ============================================================================
|
||||
|
||||
export async function listarConstancias(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
let rfc: string | undefined;
|
||||
if (contribuyenteId) {
|
||||
const { rows } = await req.tenantPool!.query(
|
||||
'SELECT rfc FROM contribuyentes WHERE entidad_id = $1',
|
||||
[contribuyenteId],
|
||||
);
|
||||
rfc = rows[0]?.rfc;
|
||||
}
|
||||
const data = await constanciaService.listConstancias(req.tenantPool!, 12, rfc);
|
||||
res.json(data);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
export async function descargarConstanciaPdf(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const id = parseInt(String(req.params.id));
|
||||
if (isNaN(id)) return next(new AppError(400, 'id inválido'));
|
||||
const pdf = await constanciaService.getConstanciaPdf(req.tenantPool!, id);
|
||||
if (!pdf) return res.status(404).json({ message: 'Constancia no encontrada' });
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="constancia_${id}.pdf"`);
|
||||
res.send(pdf);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
export async function consultarConstanciaManual(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const tenantId = effectiveTenantId(req);
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
|
||||
let constancia;
|
||||
if (contribuyenteId) {
|
||||
constancia = await constanciaService.consultarConstanciaContribuyente(req.tenantPool!, contribuyenteId);
|
||||
} else {
|
||||
constancia = await constanciaService.consultarConstancia(tenantId);
|
||||
}
|
||||
res.json(constancia);
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('FIEL')) return res.status(400).json({ error: error.message });
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function eliminarDeclaracion(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para eliminar declaraciones' });
|
||||
const id = parseInt(String(req.params.id));
|
||||
if (isNaN(id)) return next(new AppError(400, 'id inválido'));
|
||||
await declaracionesService.deleteDeclaracion(req.tenantPool!, id);
|
||||
res.status(204).send();
|
||||
} catch (error: any) {
|
||||
if (error?.message?.includes('no encontrada')) {
|
||||
return next(new AppError(404, error.message));
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Documentos Extras — PDFs libres (acuses, contratos, poderes, estados, etc.)
|
||||
// ============================================================================
|
||||
|
||||
const createExtraSchema = z.object({
|
||||
nombre: z.string().min(1, 'Nombre requerido').max(255),
|
||||
descripcion: z.string().max(2000).optional(),
|
||||
categoria: z.string().max(100).optional(),
|
||||
pdfBase64: z.string().min(100, 'PDF requerido'),
|
||||
pdfFilename: z.string().min(1).max(255),
|
||||
});
|
||||
|
||||
export async function listarExtras(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
const categoria = req.query.categoria as string | undefined;
|
||||
const data = await extrasService.listExtras(req.tenantPool!, contribuyenteId, categoria);
|
||||
res.json(data);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
export async function crearExtra(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para subir documentos' });
|
||||
const data = createExtraSchema.parse(req.body);
|
||||
const contribuyenteId = req.body.contribuyenteId as string | undefined;
|
||||
const result = await extrasService.createExtra(req.tenantPool!, {
|
||||
...data,
|
||||
contribuyenteId: contribuyenteId ?? null,
|
||||
subidoPor: req.user!.email,
|
||||
});
|
||||
|
||||
// Notificación fire-and-forget a owners del despacho + supervisor del RFC.
|
||||
notifyDocumentoSubido({
|
||||
pool: req.tenantPool!,
|
||||
tenantId: req.user!.tenantId,
|
||||
contribuyenteId: contribuyenteId ?? null,
|
||||
subidoPor: req.user!.email,
|
||||
kind: 'extra',
|
||||
extra: {
|
||||
nombre: data.nombre,
|
||||
descripcion: data.descripcion ?? null,
|
||||
categoria: data.categoria ?? null,
|
||||
},
|
||||
}).catch((err: any) => console.error('[notifyDocumentoSubido extra]', err?.message || err));
|
||||
|
||||
res.status(201).json(result);
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function descargarExtraPdf(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const id = parseInt(String(req.params.id));
|
||||
if (isNaN(id)) return next(new AppError(400, 'id inválido'));
|
||||
const pdf = await extrasService.getExtraPdf(req.tenantPool!, id);
|
||||
if (!pdf) return next(new AppError(404, 'Documento no encontrado'));
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${pdf.filename}"`);
|
||||
res.send(pdf.buffer);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
export async function eliminarExtra(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para eliminar documentos' });
|
||||
const id = parseInt(String(req.params.id));
|
||||
if (isNaN(id)) return next(new AppError(400, 'id inválido'));
|
||||
const ok = await extrasService.deleteExtra(req.tenantPool!, id);
|
||||
if (!ok) return next(new AppError(404, 'Documento no encontrado'));
|
||||
res.status(204).send();
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
export async function listarCategoriasExtras(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
const data = await extrasService.listCategorias(req.tenantPool!, contribuyenteId);
|
||||
res.json(data);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
42
apps/api/src/controllers/export.controller.ts
Normal file
42
apps/api/src/controllers/export.controller.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import * as exportService from '../services/export.service.js';
|
||||
|
||||
export async function exportCfdis(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { tipo, estado, fechaInicio, fechaFin } = req.query;
|
||||
const buffer = await exportService.exportCfdisToExcel(req.tenantPool!, {
|
||||
tipo: tipo as string,
|
||||
estado: estado as string,
|
||||
fechaInicio: fechaInicio as string,
|
||||
fechaFin: fechaFin as string,
|
||||
});
|
||||
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', `attachment; filename=cfdis-${Date.now()}.xlsx`);
|
||||
res.send(buffer);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportReporte(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { tipo, fechaInicio, fechaFin } = req.query;
|
||||
const now = new Date();
|
||||
const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
|
||||
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
|
||||
|
||||
const buffer = await exportService.exportReporteToExcel(
|
||||
req.tenantPool!,
|
||||
tipo as 'estado-resultados' | 'flujo-efectivo',
|
||||
inicio,
|
||||
fin
|
||||
);
|
||||
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', `attachment; filename=${tipo}-${Date.now()}.xlsx`);
|
||||
res.send(buffer);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
789
apps/api/src/controllers/facturacion.controller.ts
Normal file
789
apps/api/src/controllers/facturacion.controller.ts
Normal file
@@ -0,0 +1,789 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import type { Pool } from 'pg';
|
||||
import { z } from 'zod';
|
||||
import * as facturapiService from '../services/facturapi.service.js';
|
||||
import {
|
||||
createInvoiceContribuyente,
|
||||
cancelInvoiceContribuyente,
|
||||
downloadPdfContribuyente,
|
||||
downloadXmlContribuyente,
|
||||
sendInvoiceByEmailContribuyente,
|
||||
} from '../services/contribuyente-facturapi.service.js';
|
||||
import { parseXml } from '../services/sat/sat-parser.service.js';
|
||||
import * as tenantsService from '../services/tenants.service.js';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
import { hasPlatformRole } from '../utils/platform-admin.js';
|
||||
import { auditFromReq } from '../utils/audit.js';
|
||||
|
||||
function effectiveTenantId(req: Request): string {
|
||||
return req.viewingTenantId || req.user!.tenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta si un mensaje de error del SAT (propagado por Facturapi) indica
|
||||
* que el CSD aún no está en la Lista de Contribuyentes Obligados (LCO).
|
||||
* El SAT tarda 24-72h en propagar un CSD nuevo; durante esa ventana todo
|
||||
* intento de emisión falla. Cuando se detecta este patrón se marca la
|
||||
* org con `last_lco_rejection_at` para que el frontend muestre un banner.
|
||||
*/
|
||||
function isLcoRejection(errorMessage: string): boolean {
|
||||
if (!errorMessage) return false;
|
||||
const msg = errorMessage.toLowerCase();
|
||||
return (
|
||||
/no se encontr.*rfc.*lco/.test(msg) ||
|
||||
/rfc.*no.*registrado.*lco/.test(msg) ||
|
||||
/lista.*contribuyentes.*obligados/.test(msg) ||
|
||||
/csd.*no.*registrad/.test(msg) ||
|
||||
msg.includes('lco')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra el timestamp del rechazo LCO en la fila correspondiente de
|
||||
* `facturapi_orgs`. Fire-and-forget: un fallo aquí no bloquea la
|
||||
* propagación del error al frontend.
|
||||
*/
|
||||
async function markLcoRejection(
|
||||
pool: import('pg').Pool,
|
||||
contribuyenteId: string | undefined,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (contribuyenteId) {
|
||||
await pool.query(
|
||||
`UPDATE facturapi_orgs SET last_lco_rejection_at = NOW() WHERE contribuyente_id = $1`,
|
||||
[contribuyenteId],
|
||||
);
|
||||
}
|
||||
// Nota: Horux360 single-tenant usaría `tenants.facturapi_org_id` en
|
||||
// BD central; en el fork multi-contribuyente solo marcamos la fila
|
||||
// por-contribuyente. Si el user emite desde el org del tenant (sin
|
||||
// contribuyenteId), el banner no aplicaría aquí.
|
||||
} catch (e: any) {
|
||||
console.error('[facturacion.markLcoRejection] falló UPDATE:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Organización ──
|
||||
|
||||
export async function getOrgStatus(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const status = await facturapiService.getOrganizationStatus(effectiveTenantId(req));
|
||||
res.json(status);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
export async function createOrg(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const result = await facturapiService.createOrganization(effectiveTenantId(req));
|
||||
res.status(201).json(result);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
// ── CSD ──
|
||||
|
||||
export async function uploadCsd(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { cerFile, keyFile, password } = req.body;
|
||||
if (!cerFile || !keyFile || !password) {
|
||||
return res.status(400).json({ message: 'cerFile, keyFile y password son requeridos' });
|
||||
}
|
||||
const result = await facturapiService.uploadCsd(effectiveTenantId(req), cerFile, keyFile, password);
|
||||
if (!result.success) return res.status(400).json({ message: result.message });
|
||||
res.json(result);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
// ── Emisión ──
|
||||
|
||||
export async function emitir(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const tenantId = effectiveTenantId(req);
|
||||
const contribuyenteId = req.body.contribuyenteId as string | undefined;
|
||||
|
||||
// ── Validar CFDIs relacionados antes de consumir timbre ──
|
||||
// En Live, SAT rechaza si el UUID relacionado no existe, está cancelado,
|
||||
// o el rfc_receptor no coincide con el customer.taxId del CFDI nuevo.
|
||||
// Catch temprano con error legible en vez de un 500 oscuro de Facturapi.
|
||||
const relatedDocs: Array<{ relationship: string; uuids: string[] }> = req.body.relatedDocuments || [];
|
||||
const customerRfc = req.body.customer?.taxId?.toUpperCase()?.trim();
|
||||
if (relatedDocs.length > 0 && customerRfc && req.tenantPool) {
|
||||
const allUuids = relatedDocs
|
||||
.flatMap(r => r.uuids || [])
|
||||
.filter(u => typeof u === 'string' && u.trim() !== '');
|
||||
for (const uuid of allUuids) {
|
||||
const { rows } = await req.tenantPool.query(
|
||||
`SELECT rfc_receptor, status FROM cfdis WHERE LOWER(uuid) = LOWER($1) LIMIT 1`,
|
||||
[uuid.trim()],
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
throw new AppError(400, `El CFDI relacionado con UUID ${uuid} no existe en el sistema.`);
|
||||
}
|
||||
const rel = rows[0];
|
||||
if (rel.status === 'Cancelado' || rel.status === '0') {
|
||||
throw new AppError(400, `El CFDI relacionado con UUID ${uuid} está cancelado.`);
|
||||
}
|
||||
const rfcReceptorRel = (rel.rfc_receptor || '').toUpperCase().trim();
|
||||
if (rfcReceptorRel !== customerRfc) {
|
||||
throw new AppError(
|
||||
400,
|
||||
`El CFDI relacionado con UUID ${uuid} no corresponde al RFC del receptor de esta factura. ` +
|
||||
`RFC esperado: ${customerRfc}. RFC del receptor del CFDI relacionado: ${rfcReceptorRel}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reservar timbre — si falla emisión en Facturapi, revertimos abajo
|
||||
const consumedTimbre = await facturapiService.consumeTimbre(tenantId);
|
||||
|
||||
// Emitir factura en Facturapi
|
||||
// Si hay contribuyenteId, usar la org Facturapi del contribuyente (tenant BD).
|
||||
// Si no, usar la org del tenant (BD central).
|
||||
let invoice;
|
||||
try {
|
||||
if (contribuyenteId) {
|
||||
invoice = await createInvoiceContribuyente(req.tenantPool!, contribuyenteId, req.body);
|
||||
} else {
|
||||
invoice = await facturapiService.createInvoice(tenantId, req.body);
|
||||
}
|
||||
} catch (err: any) {
|
||||
// SAT nunca selló → revertir el timbre reservado (fire-and-forget; no bloquear la respuesta
|
||||
// de error si el refund falla, solo loggear la inconsistencia)
|
||||
facturapiService.refundTimbre(tenantId, consumedTimbre).catch(refundErr => {
|
||||
console.error('[facturacion.emitir] Falló refund de timbre tras rechazo Facturapi:', {
|
||||
tenantId,
|
||||
consumedTimbre,
|
||||
refundError: refundErr?.message || String(refundErr),
|
||||
});
|
||||
});
|
||||
// Loggea el payload que causó el rechazo para diagnóstico server-side
|
||||
console.error('[facturacion.emitir] Rechazo al crear factura:', {
|
||||
tenantId,
|
||||
contribuyenteId: contribuyenteId || null,
|
||||
type: req.body?.type,
|
||||
items: req.body?.items?.map((it: any) => ({
|
||||
description: it.description,
|
||||
taxes: it.taxes,
|
||||
})),
|
||||
error: err?.message || String(err),
|
||||
});
|
||||
// Detectar rechazo por CSD aún no propagado a la LCO y marcar la org
|
||||
// para que el frontend muestre banner informativo durante 24h.
|
||||
if (isLcoRejection(err?.message || '')) {
|
||||
await markLcoRejection(req.tenantPool!, contribuyenteId);
|
||||
}
|
||||
// Propaga el mensaje real (Facturapi suele explicar la validación)
|
||||
throw new AppError(400, err?.message || 'Error al emitir factura');
|
||||
}
|
||||
|
||||
// Guardar en tabla cfdis del tenant.
|
||||
// El response de `invoices.create` de Facturapi NO incluye `issuer`/`subtotal`/`taxes`
|
||||
// como campos top-level (usa `issuer_info` y los impuestos viven dentro de `items[*].product.taxes`).
|
||||
// La forma más fiable y consistente con el sync SAT es descargar el XML timbrado y
|
||||
// reutilizar el mismo parser que ya procesa los CFDIs descargados del SAT.
|
||||
const pool = req.tenantPool!;
|
||||
const xmlBuffer = contribuyenteId
|
||||
? await downloadXmlContribuyente(pool, contribuyenteId, invoice.id)
|
||||
: await facturapiService.downloadXml(tenantId, invoice.id);
|
||||
const xmlString = xmlBuffer.toString('utf-8');
|
||||
const parsed = parseXml(xmlString, 'emitidos');
|
||||
if (!parsed) {
|
||||
throw new AppError(500, `Factura ${invoice.uuid} emitida en Facturapi pero el XML no pudo parsearse`);
|
||||
}
|
||||
|
||||
const fecha = parsed.fechaEmision;
|
||||
const year = String(fecha.getFullYear());
|
||||
const month = String(fecha.getMonth() + 1).padStart(2, '0');
|
||||
|
||||
// Upsert RFCs desde datos del XML (fuente autoritativa — igual al sync SAT)
|
||||
const { rows: [emisorRow] } = await pool.query(
|
||||
`INSERT INTO rfcs (rfc, razon_social, regimen_fiscal) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (rfc) DO UPDATE SET
|
||||
razon_social = COALESCE(NULLIF($2, ''), rfcs.razon_social),
|
||||
regimen_fiscal = CASE WHEN $3 IS NOT NULL AND $3 != '' THEN $3 ELSE rfcs.regimen_fiscal END
|
||||
RETURNING id`,
|
||||
[parsed.rfcEmisor, parsed.nombreEmisor || null, parsed.regimenFiscalEmisor || null],
|
||||
);
|
||||
const { rows: [receptorRow] } = await pool.query(
|
||||
`INSERT INTO rfcs (rfc, razon_social, regimen_fiscal, codigo_postal) VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (rfc) DO UPDATE SET
|
||||
razon_social = COALESCE(NULLIF($2, ''), rfcs.razon_social),
|
||||
regimen_fiscal = CASE WHEN $3 IS NOT NULL AND $3 != '' THEN $3 ELSE rfcs.regimen_fiscal END,
|
||||
codigo_postal = CASE WHEN $4 IS NOT NULL AND $4 != '' THEN $4 ELSE rfcs.codigo_postal END
|
||||
RETURNING id`,
|
||||
[parsed.rfcReceptor, parsed.nombreReceptor || null, parsed.regimenFiscalReceptor || null, req.body.customer?.zip || null],
|
||||
);
|
||||
|
||||
// Para CFDIs tipo P (complemento de pago) parseamos `fechaPagoP`. SAT
|
||||
// permite múltiples pagos por complemento — el parser concatena las fechas
|
||||
// con '|'; aquí tomamos la primera (suficiente para el cálculo fiscal,
|
||||
// donde fecha_pago_p drives el período de devengo).
|
||||
const fechaPagoP = parsed.fechaPagoP
|
||||
? new Date(String(parsed.fechaPagoP).split('|')[0])
|
||||
: null;
|
||||
|
||||
await pool.query(`
|
||||
INSERT INTO cfdis (
|
||||
year, month, type, uuid, serie, folio, status, fecha_emision, fecha_cert_sat,
|
||||
rfc_emisor_id, rfc_emisor, nombre_emisor, regimen_fiscal_emisor,
|
||||
rfc_receptor_id, rfc_receptor, nombre_receptor, regimen_fiscal_receptor,
|
||||
subtotal, subtotal_mxn, total, total_mxn,
|
||||
moneda, tipo_comprobante, metodo_pago, forma_pago, uso_cfdi,
|
||||
iva_traslado, iva_traslado_mxn,
|
||||
iva_retencion, iva_retencion_mxn,
|
||||
monto_pago, monto_pago_mxn,
|
||||
fecha_pago_p,
|
||||
iva_traslado_pago, iva_traslado_pago_mxn,
|
||||
iva_retencion_pago, iva_retencion_pago_mxn,
|
||||
ieps_traslado_pago, ieps_traslado_pago_mxn,
|
||||
source, facturapi_id,
|
||||
contribuyente_id, xml_original
|
||||
) VALUES (
|
||||
$1, $2, 'EMITIDO', $3, $4, $5, 'Vigente', $6, $7,
|
||||
$8, $9, $10, $11,
|
||||
$12, $13, $14, $15,
|
||||
$16, $16, $17, $17,
|
||||
$18, $19, $20, $21, $22,
|
||||
$23, $23,
|
||||
$24, $24,
|
||||
$25, $25,
|
||||
$26,
|
||||
$27, $27,
|
||||
$28, $28,
|
||||
$29, $29,
|
||||
'facturapi', $30,
|
||||
$31, $32
|
||||
)
|
||||
`, [
|
||||
year, month, parsed.uuid, parsed.serie, parsed.folio, fecha, parsed.fechaCertSat,
|
||||
emisorRow.id, parsed.rfcEmisor, parsed.nombreEmisor, parsed.regimenFiscalEmisor,
|
||||
receptorRow.id, parsed.rfcReceptor, parsed.nombreReceptor, parsed.regimenFiscalReceptor,
|
||||
parsed.subtotal, parsed.total,
|
||||
parsed.moneda, parsed.tipoComprobante, parsed.metodoPago, parsed.formaPago, parsed.usoCfdi,
|
||||
parsed.ivaTraslado,
|
||||
parsed.ivaRetencion,
|
||||
parsed.montoPago,
|
||||
fechaPagoP,
|
||||
parsed.ivaTrasladoPago,
|
||||
parsed.ivaRetencionPago,
|
||||
parsed.iepsTrasladoPago,
|
||||
invoice.id,
|
||||
contribuyenteId ?? null, xmlString,
|
||||
]);
|
||||
|
||||
// Enviar por email si el receptor tiene email — ruteado a la org correcta
|
||||
const customerEmail = req.body.customer?.email;
|
||||
if (customerEmail) {
|
||||
const sendPromise = contribuyenteId
|
||||
? sendInvoiceByEmailContribuyente(req.tenantPool!, contribuyenteId, invoice.id, customerEmail)
|
||||
: facturapiService.sendInvoiceByEmail(tenantId, invoice.id, customerEmail);
|
||||
sendPromise.catch(err => console.error('[Facturapi] Error enviando email:', err.message));
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
id: invoice.id,
|
||||
uuid: invoice.uuid,
|
||||
total: invoice.total,
|
||||
status: invoice.status,
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Los errores de emisión ya hacen refund dentro del inner catch.
|
||||
// Aquí solo propagamos — incluye errores del INSERT post-emisión (CFDI ya sellado,
|
||||
// no refund) y errores de validación de timbre (ocurrieron antes del consume).
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Estado LCO: si hubo un rechazo del SAT por CSD no propagado en las últimas 24h,
|
||||
// el frontend muestra un banner informativo en la pantalla de emisión.
|
||||
export async function getLcoStatus(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
if (!contribuyenteId) {
|
||||
return res.json({ hasRecentLcoRejection: false, rejectedAt: null });
|
||||
}
|
||||
|
||||
const { rows } = await req.tenantPool!.query<{ last_lco_rejection_at: Date | null }>(
|
||||
`SELECT last_lco_rejection_at FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true`,
|
||||
[contribuyenteId],
|
||||
);
|
||||
|
||||
const rejectedAt = rows[0]?.last_lco_rejection_at || null;
|
||||
const hasRecentLcoRejection =
|
||||
rejectedAt !== null && Date.now() - new Date(rejectedAt).getTime() < 24 * 60 * 60 * 1000;
|
||||
|
||||
res.json({ hasRecentLcoRejection, rejectedAt });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cancelación ──
|
||||
|
||||
export async function cancelar(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const tenantId = effectiveTenantId(req);
|
||||
const { uuid } = req.params;
|
||||
const { motive, substitution } = req.body;
|
||||
|
||||
const pool = req.tenantPool!;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT facturapi_id, contribuyente_id FROM cfdis WHERE uuid = $1 AND source = 'facturapi'`,
|
||||
[uuid]
|
||||
);
|
||||
|
||||
if (rows.length === 0 || !rows[0].facturapi_id) {
|
||||
return res.status(404).json({ message: 'CFDI no encontrado o no fue emitido por Facturapi' });
|
||||
}
|
||||
|
||||
const facturapiId = rows[0].facturapi_id;
|
||||
const cfdiContribuyenteId = rows[0].contribuyente_id as string | null;
|
||||
|
||||
const result = cfdiContribuyenteId
|
||||
? await cancelInvoiceContribuyente(pool, cfdiContribuyenteId, facturapiId, motive || '02', substitution)
|
||||
: await facturapiService.cancelInvoice(tenantId, facturapiId, motive || '02', substitution);
|
||||
|
||||
// Capturamos la fecha del CFDI antes del UPDATE para saber qué mes marcar
|
||||
// como invalidado (la cancelación afecta las métricas del mes del CFDI,
|
||||
// no del mes actual).
|
||||
const { rows: fechas } = await pool.query<{ fecha_emision: Date; fecha_pago_p: Date | null; tipo_comprobante: string }>(
|
||||
`SELECT fecha_emision, fecha_pago_p, tipo_comprobante FROM cfdis WHERE uuid = $1`,
|
||||
[uuid],
|
||||
);
|
||||
|
||||
await pool.query(
|
||||
`UPDATE cfdis SET status = 'Cancelado', fecha_cancelacion = NOW(), actualizado_en = NOW() WHERE uuid = $1`,
|
||||
[uuid]
|
||||
);
|
||||
|
||||
// Invalidar métricas del mes afectado (usa fecha_pago_p para P, fecha_emision para el resto)
|
||||
if (cfdiContribuyenteId && fechas[0]) {
|
||||
const f = fechas[0];
|
||||
const fechaContable = f.tipo_comprobante === 'P' && f.fecha_pago_p ? f.fecha_pago_p : f.fecha_emision;
|
||||
const { markForInvalidation } = await import('../services/metricas.service.js');
|
||||
await markForInvalidation(
|
||||
pool,
|
||||
cfdiContribuyenteId,
|
||||
fechaContable.getFullYear(),
|
||||
fechaContable.getMonth() + 1,
|
||||
'CFDI_CANCEL',
|
||||
).catch(err => console.warn('[Cancelar] markForInvalidation falló:', err?.message || err));
|
||||
}
|
||||
|
||||
res.json({ message: 'CFDI cancelado', result });
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
// ── Descargas ──
|
||||
|
||||
async function resolveCfdiContribuyenteId(
|
||||
pool: Pool,
|
||||
facturapiId: string,
|
||||
): Promise<string | null> {
|
||||
const { rows } = await pool.query<{ contribuyente_id: string | null }>(
|
||||
`SELECT contribuyente_id FROM cfdis WHERE facturapi_id = $1 LIMIT 1`,
|
||||
[facturapiId],
|
||||
);
|
||||
return rows[0]?.contribuyente_id ?? null;
|
||||
}
|
||||
|
||||
export async function downloadPdf(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const id = String(req.params.id);
|
||||
const pool = req.tenantPool!;
|
||||
const cfdiContribuyenteId = await resolveCfdiContribuyenteId(pool, id);
|
||||
const buffer = cfdiContribuyenteId
|
||||
? await downloadPdfContribuyente(pool, cfdiContribuyenteId, id)
|
||||
: await facturapiService.downloadPdf(effectiveTenantId(req), id);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename=factura-${id}.pdf`);
|
||||
res.send(buffer);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
export async function downloadXml(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const id = String(req.params.id);
|
||||
const pool = req.tenantPool!;
|
||||
const cfdiContribuyenteId = await resolveCfdiContribuyenteId(pool, id);
|
||||
const buffer = cfdiContribuyenteId
|
||||
? await downloadXmlContribuyente(pool, cfdiContribuyenteId, id)
|
||||
: await facturapiService.downloadXml(effectiveTenantId(req), id);
|
||||
res.setHeader('Content-Type', 'application/xml');
|
||||
res.setHeader('Content-Disposition', `attachment; filename=factura-${id}.xml`);
|
||||
res.send(buffer);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
// ── Timbres ──
|
||||
|
||||
export async function getTimbres(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const status = await facturapiService.getTimbreStatus(effectiveTenantId(req));
|
||||
res.json(status);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
// ── Personalización (logo, color) ──
|
||||
|
||||
export async function getCustomization(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = await facturapiService.getCustomization(effectiveTenantId(req));
|
||||
res.json(data || {});
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
export async function uploadLogo(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { logo } = req.body; // base64
|
||||
if (!logo) return res.status(400).json({ message: 'Logo es requerido (base64)' });
|
||||
const result = await facturapiService.uploadLogo(effectiveTenantId(req), logo);
|
||||
if (!result.success) return res.status(400).json({ message: result.message });
|
||||
res.json(result);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
export async function updateColor(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { color } = req.body;
|
||||
if (!color) return res.status(400).json({ message: 'Color es requerido' });
|
||||
const result = await facturapiService.updateColor(effectiveTenantId(req), color);
|
||||
if (!result.success) return res.status(400).json({ message: result.message });
|
||||
res.json(result);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
// ── Datos fiscales del tenant ──
|
||||
|
||||
// Schema Zod para preferencias de auto-facturación
|
||||
const PreferenciasFacturacionSchema = z.object({
|
||||
factPreferencia: z.enum(['publico_general', 'mis_datos']).optional(),
|
||||
factUsoCfdi: z.string().min(2).max(5).optional(),
|
||||
factRegimenPreferido: z.string().max(3).nullable().optional(),
|
||||
});
|
||||
|
||||
export async function getPreferenciasFacturacion(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = await tenantsService.getPreferenciasFacturacion(effectiveTenantId(req));
|
||||
res.json(data);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
export async function updatePreferenciasFacturacion(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const parsed = PreferenciasFacturacionSchema.parse(req.body);
|
||||
const data = await tenantsService.updatePreferenciasFacturacion(effectiveTenantId(req), parsed);
|
||||
res.json(data);
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'ZodError') {
|
||||
return next(new AppError(400, error.errors[0].message));
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDatosFiscales(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = await tenantsService.getDatosFiscales(effectiveTenantId(req));
|
||||
res.json(data || {});
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
export async function updateDatosFiscales(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (req.user!.role !== 'owner') {
|
||||
return res.status(403).json({ message: 'Solo el dueño puede actualizar datos fiscales' });
|
||||
}
|
||||
const data = await tenantsService.updateDatosFiscales(effectiveTenantId(req), req.body);
|
||||
res.json(data);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
// ── Búsqueda de conceptos previos ──
|
||||
|
||||
export async function searchConceptos(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const q = (req.query.q as string || '').trim();
|
||||
const tipo = (req.query.tipo as string || 'todos'); // emitidos, recibidos, todos
|
||||
const contribuyenteId = (req.query.contribuyenteId as string || '').replace(/[^a-f0-9-]/gi, '');
|
||||
const pool = req.tenantPool!;
|
||||
|
||||
let whereType = '';
|
||||
if (tipo === 'emitidos') {
|
||||
whereType = `AND c.type = 'EMITIDO'`;
|
||||
} else if (tipo === 'recibidos') {
|
||||
whereType = `AND c.type = 'RECIBIDO' AND c.uso_cfdi = 'G01'`;
|
||||
} else {
|
||||
whereType = `AND (c.type = 'EMITIDO' OR (c.type = 'RECIBIDO' AND c.uso_cfdi = 'G01'))`;
|
||||
}
|
||||
|
||||
const whereContrib = contribuyenteId ? `AND c.contribuyente_id = '${contribuyenteId}'` : '';
|
||||
|
||||
let whereSearch = '';
|
||||
const params: any[] = [];
|
||||
if (q.length >= 2) {
|
||||
params.push(`%${q}%`);
|
||||
whereSearch = `AND (cc.descripcion ILIKE $1 OR cc.clave_prod_serv ILIKE $1)`;
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT DISTINCT ON (cc.clave_prod_serv, cc.descripcion)
|
||||
cc.clave_prod_serv as "claveProdServ",
|
||||
cc.descripcion,
|
||||
cc.clave_unidad as "claveUnidad",
|
||||
cc.unidad,
|
||||
cc.valor_unitario_mxn as "valorUnitario",
|
||||
cc.importe_mxn as "importe",
|
||||
cc.iva_traslado_mxn as "ivaTraslado",
|
||||
cc.isr_retencion_mxn as "isrRetencion",
|
||||
cc.iva_retencion_mxn as "ivaRetencion",
|
||||
c.type as "tipoCfdi",
|
||||
c.rfc_emisor as "rfcEmisor",
|
||||
c.nombre_emisor as "nombreEmisor",
|
||||
c.rfc_receptor as "rfcReceptor",
|
||||
c.nombre_receptor as "nombreReceptor",
|
||||
c.fecha_emision as "fechaEmision"
|
||||
FROM cfdi_conceptos cc
|
||||
JOIN cfdis c ON cc.cfdi_id = c.id
|
||||
WHERE c.status NOT IN ('Cancelado', '0')
|
||||
${whereType}
|
||||
${whereContrib}
|
||||
${whereSearch}
|
||||
ORDER BY cc.clave_prod_serv, cc.descripcion, c.fecha_emision DESC
|
||||
LIMIT 30
|
||||
`, params);
|
||||
|
||||
res.json(rows);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
// ── CFDIs PPD pendientes ──
|
||||
|
||||
export async function getCfdisPpdPendientes(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const rfc = (req.query.rfc as string || '').trim().toUpperCase();
|
||||
if (rfc.length < 3) return res.json([]);
|
||||
|
||||
const contribuyenteId = (req.query.contribuyenteId as string || '').trim();
|
||||
const pool = req.tenantPool!;
|
||||
|
||||
// Buscar CFDIs emitidos PPD vigentes para este RFC receptor con saldo > 0.
|
||||
// Usamos `saldo_pendiente_mxn` denormalizado (utils/saldo.ts §13) que ya
|
||||
// considera pagos P + NCs no-07 + anticipos aplicados. Es la fuente de
|
||||
// verdad del sistema — recalcular con subquery solo sobre pagos P
|
||||
// sobreestima el saldo cuando hay NCs/anticipos.
|
||||
// En multi-RFC con contribuyente activo, filtra por contribuyente_id —
|
||||
// solo los PPDs emitidos por el contribuyente activo. Sin contribuyenteId,
|
||||
// retorna todos los del tenant (compat con flujos sin contribuyente activo).
|
||||
const params: any[] = [rfc];
|
||||
let contribFilter = '';
|
||||
if (contribuyenteId) {
|
||||
params.push(contribuyenteId);
|
||||
contribFilter = ` AND c.contribuyente_id = $${params.length}`;
|
||||
}
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
c.uuid, c.serie, c.folio, c.total_mxn as "totalMxn",
|
||||
c.fecha_emision as "fechaEmision",
|
||||
c.rfc_receptor as "rfcReceptor",
|
||||
c.nombre_receptor as "nombreReceptor",
|
||||
c.iva_traslado_mxn as "ivaTrasladoMxn",
|
||||
c.saldo_pendiente_mxn as "saldoPendiente"
|
||||
FROM cfdis c
|
||||
WHERE c.type = 'EMITIDO'
|
||||
AND c.metodo_pago = 'PPD'
|
||||
AND c.tipo_comprobante = 'I'
|
||||
AND c.status NOT IN ('Cancelado', '0')
|
||||
AND c.rfc_receptor = $1${contribFilter}
|
||||
AND COALESCE(c.saldo_pendiente_mxn, 0) > 0
|
||||
ORDER BY c.fecha_emision DESC
|
||||
LIMIT 20
|
||||
`, params);
|
||||
|
||||
res.json(rows);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
// ── CFDIs relacionables ──
|
||||
// Devuelve CFDIs emitidos por el contribuyente activo cuyo rfc_receptor
|
||||
// coincide con el de la nueva factura. Usado por el dropdown de la sección
|
||||
// "CFDIs Relacionados" en facturación tipo I y E.
|
||||
//
|
||||
// Filtros aplicados:
|
||||
// - contribuyente_id = caller (multi-RFC: solo CFDIs del contribuyente activo)
|
||||
// - rfc_receptor = rfc del receptor de la factura nueva
|
||||
// - tipo_comprobante IN ('I','E') — los relacionables habituales
|
||||
// - status NOT IN ('Cancelado','0') — solo vigentes (SAT rechaza relacionar cancelados)
|
||||
|
||||
export async function getCfdisRelacionables(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const rfcReceptor = (req.query.rfcReceptor as string || '').trim().toUpperCase();
|
||||
const contribuyenteId = (req.query.contribuyenteId as string || '').trim();
|
||||
if (rfcReceptor.length < 12) return res.json([]);
|
||||
if (!contribuyenteId) return res.json([]);
|
||||
|
||||
const pool = req.tenantPool!;
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
uuid,
|
||||
serie,
|
||||
folio,
|
||||
total_mxn AS "totalMxn",
|
||||
fecha_emision AS "fechaEmision",
|
||||
tipo_comprobante AS "tipoComprobante",
|
||||
metodo_pago AS "metodoPago"
|
||||
FROM cfdis
|
||||
WHERE contribuyente_id = $1
|
||||
AND rfc_receptor = $2
|
||||
AND tipo_comprobante IN ('I', 'E')
|
||||
AND status NOT IN ('Cancelado', '0')
|
||||
ORDER BY fecha_emision DESC
|
||||
LIMIT 50
|
||||
`, [contribuyenteId, rfcReceptor]);
|
||||
|
||||
res.json(rows);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
// ── Búsqueda de RFCs ──
|
||||
|
||||
export async function searchRfcs(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const q = (req.query.q as string || '').trim();
|
||||
if (q.length < 3) return res.json([]);
|
||||
|
||||
const contribuyenteId = (req.query.contribuyenteId as string || '').trim();
|
||||
const pool = req.tenantPool!;
|
||||
|
||||
// RFC del tenant despacho para excluirlo (no se factura a sí mismo)
|
||||
const tenantId = effectiveTenantId(req);
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { rfc: true },
|
||||
});
|
||||
const tenantRfc = tenant?.rfc || '';
|
||||
|
||||
// En multi-RFC con contribuyente activo, filtrar a contrapartes con las
|
||||
// que ese contribuyente ha tenido CFDIs (emisor o receptor). Sin
|
||||
// contribuyenteId, retornar el catálogo completo (compat con flujos
|
||||
// legacy / admin global sin contribuyente seleccionado).
|
||||
let rows;
|
||||
if (contribuyenteId) {
|
||||
({ rows } = await pool.query(`
|
||||
SELECT DISTINCT r.id, r.rfc,
|
||||
r.razon_social as "razonSocial",
|
||||
r.regimen_fiscal as "regimenFiscal",
|
||||
r.codigo_postal as "codigoPostal"
|
||||
FROM rfcs r
|
||||
WHERE r.rfc != $1
|
||||
AND (r.rfc ILIKE $2 OR r.razon_social ILIKE $2)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM cfdis c
|
||||
WHERE c.contribuyente_id = $3
|
||||
AND (c.rfc_emisor_id = r.id OR c.rfc_receptor_id = r.id)
|
||||
)
|
||||
ORDER BY r.razon_social
|
||||
LIMIT 10
|
||||
`, [tenantRfc, `%${q}%`, contribuyenteId]));
|
||||
} else {
|
||||
({ rows } = await pool.query(`
|
||||
SELECT id, rfc, razon_social as "razonSocial",
|
||||
regimen_fiscal as "regimenFiscal",
|
||||
codigo_postal as "codigoPostal"
|
||||
FROM rfcs
|
||||
WHERE rfc != $1
|
||||
AND (rfc ILIKE $2 OR razon_social ILIKE $2)
|
||||
ORDER BY razon_social
|
||||
LIMIT 10
|
||||
`, [tenantRfc, `%${q}%`]));
|
||||
}
|
||||
|
||||
res.json(rows);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
// ── Timbres adicionales: catálogo + compra ──
|
||||
|
||||
export async function getPaquetesCatalogo(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const catalogo = await facturapiService.listPaquetesCatalogo();
|
||||
res.json(catalogo);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
const comprarPaqueteSchema = z.object({
|
||||
catalogoId: z.number().int().positive(),
|
||||
});
|
||||
|
||||
// Admin global: catálogo completo incluyendo inactivos + edit
|
||||
export async function getPaquetesCatalogoAdmin(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!(await hasPlatformRole(req.user!.userId, 'platform_admin'))) {
|
||||
return res.status(403).json({ message: 'Solo admin global puede ver el catálogo completo' });
|
||||
}
|
||||
const catalogo = await facturapiService.listAllPaquetesCatalogo();
|
||||
res.json(catalogo);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
const updatePaqueteSchema = z.object({
|
||||
precio: z.number().positive().optional(),
|
||||
active: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function updatePaqueteCatalogo(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!(await hasPlatformRole(req.user!.userId, 'platform_admin'))) {
|
||||
return res.status(403).json({ message: 'Solo admin global puede editar el catálogo' });
|
||||
}
|
||||
const id = parseInt(String(req.params.id));
|
||||
if (isNaN(id)) return next(new AppError(400, 'id inválido'));
|
||||
|
||||
const data = updatePaqueteSchema.parse(req.body);
|
||||
const before = await facturapiService.listAllPaquetesCatalogo().then(r => r.find(p => p.id === id));
|
||||
const updated = await facturapiService.updatePaqueteCatalogo({ id, ...data });
|
||||
|
||||
auditFromReq(req, 'timbres.catalogo_updated', {
|
||||
entityType: 'TimbrePaqueteCatalogo',
|
||||
entityId: String(id),
|
||||
metadata: {
|
||||
cantidad: updated.cantidad,
|
||||
from: { precio: before?.precio, active: before?.active },
|
||||
to: { precio: updated.precio, active: updated.active },
|
||||
},
|
||||
});
|
||||
|
||||
res.json(updated);
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
if (error?.message?.includes('precio') || error?.message?.includes('actualizar')) {
|
||||
return next(new AppError(400, error.message));
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function comprarPaquete(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!['owner', 'cfo'].includes(req.user!.role)) {
|
||||
return res.status(403).json({ message: 'Solo owner/cfo pueden comprar timbres adicionales' });
|
||||
}
|
||||
const { catalogoId } = comprarPaqueteSchema.parse(req.body);
|
||||
const result = await facturapiService.iniciarCompraPaquete({
|
||||
tenantId: effectiveTenantId(req),
|
||||
catalogoId,
|
||||
callerEmail: req.user!.email,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
// Errores de negocio esperados → 400 con mensaje para el usuario
|
||||
const msg = error?.message || '';
|
||||
if (msg.includes('no disponible') || msg.includes('dueño') || msg.includes('email') || msg.includes('MercadoPago')) {
|
||||
return next(new AppError(400, msg));
|
||||
}
|
||||
console.error('[comprarPaquete] Error no esperado:', error);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
136
apps/api/src/controllers/fiel.controller.ts
Normal file
136
apps/api/src/controllers/fiel.controller.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { uploadFiel, getFielStatus, deleteFiel } from '../services/fiel.service.js';
|
||||
import type { FielUploadRequest } from '@horux/shared';
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
/**
|
||||
* Crea recordatorios automáticos de vencimiento de e.firma en el calendario.
|
||||
* 60 días, 30 días y 7 días antes del vencimiento.
|
||||
* Elimina recordatorios previos de e.firma antes de crear nuevos.
|
||||
*/
|
||||
async function crearRecordatoriosEfirma(
|
||||
pool: Pool,
|
||||
userId: string,
|
||||
validUntil: string,
|
||||
): Promise<void> {
|
||||
const vencimiento = new Date(validUntil);
|
||||
const PREFIJO = '[e.firma]';
|
||||
|
||||
// Eliminar recordatorios previos de e.firma para evitar duplicados al re-subir
|
||||
await pool.query(
|
||||
`DELETE FROM recordatorios WHERE titulo LIKE $1`,
|
||||
[`${PREFIJO}%`]
|
||||
);
|
||||
|
||||
const recordatorios = [
|
||||
{ dias: 60, titulo: `${PREFIJO} Tu e.firma vence en 60 días` },
|
||||
{ dias: 30, titulo: `${PREFIJO} Tu e.firma vence en 30 días` },
|
||||
{ dias: 7, titulo: `${PREFIJO} Tu e.firma vence en 7 días — ¡Renueva pronto!` },
|
||||
];
|
||||
|
||||
for (const { dias, titulo } of recordatorios) {
|
||||
const fecha = new Date(vencimiento);
|
||||
fecha.setDate(fecha.getDate() - dias);
|
||||
|
||||
// Solo crear si la fecha no ha pasado
|
||||
if (fecha > new Date()) {
|
||||
await pool.query(
|
||||
`INSERT INTO recordatorios (titulo, descripcion, fecha_limite, privado, creado_por)
|
||||
VALUES ($1, $2, $3, false, $4)`,
|
||||
[
|
||||
titulo,
|
||||
`La e.firma (FIEL) vence el ${vencimiento.toLocaleDateString('es-MX')}. Renueva en el portal del SAT.`,
|
||||
fecha.toISOString().split('T')[0],
|
||||
userId,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function effectiveTenantId(req: Request): string {
|
||||
return req.viewingTenantId || req.user!.tenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sube y configura las credenciales FIEL
|
||||
*/
|
||||
export async function upload(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const tenantId = effectiveTenantId(req);
|
||||
|
||||
const { cerFile, keyFile, password } = req.body as FielUploadRequest;
|
||||
|
||||
if (!cerFile || !keyFile || !password) {
|
||||
res.status(400).json({ error: 'cerFile, keyFile y password son requeridos' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file sizes (typical .cer/.key files are under 10KB, base64 ~33% larger)
|
||||
const MAX_FILE_SIZE = 50_000; // 50KB base64 ≈ ~37KB binary
|
||||
if (cerFile.length > MAX_FILE_SIZE || keyFile.length > MAX_FILE_SIZE) {
|
||||
res.status(400).json({ error: 'Los archivos FIEL son demasiado grandes (máx 50KB)' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length > 256) {
|
||||
res.status(400).json({ error: 'Contraseña FIEL demasiado larga' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await uploadFiel(tenantId, cerFile, keyFile, password);
|
||||
|
||||
if (!result.success) {
|
||||
res.status(400).json({ error: result.message });
|
||||
return;
|
||||
}
|
||||
|
||||
// Crear recordatorios de vencimiento en el calendario
|
||||
if (result.status?.validUntil && req.tenantPool) {
|
||||
crearRecordatoriosEfirma(req.tenantPool, req.user!.userId, result.status.validUntil)
|
||||
.catch(err => console.error('[FIEL] Error creando recordatorios de vencimiento:', err));
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: result.message,
|
||||
status: result.status,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[FIEL Controller] Error en upload:', error);
|
||||
res.status(500).json({ error: 'Error interno del servidor' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el estado de la FIEL configurada
|
||||
*/
|
||||
export async function status(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const tenantId = effectiveTenantId(req);
|
||||
const fielStatus = await getFielStatus(tenantId);
|
||||
res.json(fielStatus);
|
||||
} catch (error: any) {
|
||||
console.error('[FIEL Controller] Error en status:', error);
|
||||
res.status(500).json({ error: 'Error interno del servidor' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina las credenciales FIEL
|
||||
*/
|
||||
export async function remove(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const tenantId = effectiveTenantId(req);
|
||||
const deleted = await deleteFiel(tenantId);
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'No hay FIEL configurada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ message: 'FIEL eliminada correctamente' });
|
||||
} catch (error: any) {
|
||||
console.error('[FIEL Controller] Error en remove:', error);
|
||||
res.status(500).json({ error: 'Error interno del servidor' });
|
||||
}
|
||||
}
|
||||
171
apps/api/src/controllers/impuestos.controller.ts
Normal file
171
apps/api/src/controllers/impuestos.controller.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import * as impuestosService from '../services/impuestos.service.js';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
|
||||
function parseConciliacion(req: Request): boolean {
|
||||
return req.query.conciliacion === 'true' || req.query.conciliacion === '1';
|
||||
}
|
||||
|
||||
function parseFlag(req: Request, key: string, defaultValue = true): boolean {
|
||||
const v = req.query[key];
|
||||
if (v === undefined || v === null) return defaultValue;
|
||||
return v === 'true' || v === '1';
|
||||
}
|
||||
|
||||
function effectiveTenantId(req: Request): string {
|
||||
return req.viewingTenantId || req.user!.tenantId;
|
||||
}
|
||||
|
||||
export async function getIvaMensual(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||
const conciliacion = parseConciliacion(req);
|
||||
const contribuyenteId = (req.query.contribuyenteId as string) || null;
|
||||
const considerarActivos = parseFlag(req, 'considerarActivos', true);
|
||||
const considerarNCs = parseFlag(req, 'considerarNCs', true);
|
||||
const data = await impuestosService.getIvaMensual(req.tenantPool, año, effectiveTenantId(req), conciliacion, contribuyenteId, considerarActivos, considerarNCs);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getIsrMensual(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||
const conciliacion = parseConciliacion(req);
|
||||
const contribuyenteId = (req.query.contribuyenteId as string) || null;
|
||||
const regimenClave = (req.query.regimenClave as string) || null;
|
||||
const considerarActivos = parseFlag(req, 'considerarActivos', true);
|
||||
const considerarNCs = parseFlag(req, 'considerarNCs', true);
|
||||
const data = await impuestosService.getIsrMensual(req.tenantPool, año, effectiveTenantId(req), conciliacion, contribuyenteId, regimenClave, considerarActivos, considerarNCs);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getResumenIva(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = now.getMonth() + 1;
|
||||
const lastDay = new Date(y, m, 0).getDate();
|
||||
const fechaInicio = (req.query.fechaInicio as string) || `${y}-${String(m).padStart(2, '0')}-01`;
|
||||
const fechaFin = (req.query.fechaFin as string) || `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||
const conciliacion = parseConciliacion(req);
|
||||
const contribuyenteId = (req.query.contribuyenteId as string) || null;
|
||||
const considerarActivos = parseFlag(req, 'considerarActivos', true);
|
||||
const considerarNCs = parseFlag(req, 'considerarNCs', true);
|
||||
|
||||
const resumen = await impuestosService.getResumenIva(req.tenantPool, fechaInicio, fechaFin, effectiveTenantId(req), conciliacion, contribuyenteId, considerarActivos, considerarNCs);
|
||||
res.json(resumen);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getResumenIsr(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = now.getMonth() + 1;
|
||||
const lastDay = new Date(y, m, 0).getDate();
|
||||
const fechaInicio = (req.query.fechaInicio as string) || `${y}-${String(m).padStart(2, '0')}-01`;
|
||||
const fechaFin = (req.query.fechaFin as string) || `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||
const conciliacion = parseConciliacion(req);
|
||||
const contribuyenteId = (req.query.contribuyenteId as string) || null;
|
||||
const considerarActivos = parseFlag(req, 'considerarActivos', true);
|
||||
const considerarNCs = parseFlag(req, 'considerarNCs', true);
|
||||
|
||||
const resumen = await impuestosService.getResumenIsr(req.tenantPool, fechaInicio, fechaFin, effectiveTenantId(req), conciliacion, contribuyenteId, considerarActivos, considerarNCs);
|
||||
res.json(resumen);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getResumenIsrDesglosado(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
// fechaFin define mes_final + año. Default: último día del mes corriente.
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = now.getMonth() + 1;
|
||||
const lastDay = new Date(y, m, 0).getDate();
|
||||
const fechaFin = (req.query.fechaFin as string) || `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||
const conciliacion = parseConciliacion(req);
|
||||
const contribuyenteId = (req.query.contribuyenteId as string) || null;
|
||||
const considerarActivos = parseFlag(req, 'considerarActivos', true);
|
||||
const considerarNCs = parseFlag(req, 'considerarNCs', true);
|
||||
|
||||
const desglose = await impuestosService.getResumenIsrDesglosado(
|
||||
req.tenantPool,
|
||||
fechaFin,
|
||||
effectiveTenantId(req),
|
||||
conciliacion,
|
||||
contribuyenteId,
|
||||
considerarActivos,
|
||||
considerarNCs,
|
||||
);
|
||||
res.json(desglose);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCoeficiente(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const anio = parseInt(req.query.anio as string) || new Date().getFullYear();
|
||||
const tenantId = effectiveTenantId(req);
|
||||
const row = await prisma.coeficienteUtilidad.findUnique({
|
||||
where: { tenantId_anio: { tenantId, anio } },
|
||||
});
|
||||
res.json({ anio, coeficiente: row ? Number(row.coeficiente) : null });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setCoeficiente(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (req.user!.role !== 'owner') {
|
||||
return res.status(403).json({ message: 'Solo el dueño puede configurar el coeficiente' });
|
||||
}
|
||||
|
||||
const { anio, coeficiente } = req.body;
|
||||
if (!anio || coeficiente === undefined || coeficiente === null) {
|
||||
return res.status(400).json({ message: 'anio y coeficiente son requeridos' });
|
||||
}
|
||||
|
||||
const tenantId = effectiveTenantId(req);
|
||||
const row = await prisma.coeficienteUtilidad.upsert({
|
||||
where: { tenantId_anio: { tenantId, anio } },
|
||||
update: { coeficiente },
|
||||
create: { tenantId, anio, coeficiente },
|
||||
});
|
||||
|
||||
res.json({ anio: row.anio, coeficiente: Number(row.coeficiente) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
25
apps/api/src/controllers/metricas.controller.ts
Normal file
25
apps/api/src/controllers/metricas.controller.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { getMetricasMensuales } from '../services/metricas.service.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
|
||||
export async function getMensuales(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = req.query.contribuyenteId as string;
|
||||
const anio = Number(req.query.anio);
|
||||
|
||||
if (!contribuyenteId || !anio) {
|
||||
return next(new AppError(400, 'contribuyenteId y anio son requeridos'));
|
||||
}
|
||||
|
||||
const regimenFiscal = req.query.regimen as string | undefined;
|
||||
const metricas = await getMetricasMensuales(req.tenantPool!, contribuyenteId, anio, regimenFiscal);
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
return res.json({
|
||||
data: metricas,
|
||||
source: anio < currentYear ? 'cold' : 'hot',
|
||||
anio,
|
||||
contribuyenteId,
|
||||
});
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
import {
|
||||
EMAIL_TYPES,
|
||||
getEmailPreferencesPorContribuyente,
|
||||
setContribuyenteEmailPreferences,
|
||||
} from '../services/notification-preferences.service.js';
|
||||
|
||||
export async function listPreferences(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = await getEmailPreferencesPorContribuyente(req.tenantPool!);
|
||||
res.json({ emailTypes: EMAIL_TYPES, data });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
const updateSchema = z.object({
|
||||
contribuyenteId: z.string().uuid(),
|
||||
preferences: z.record(z.string(), z.boolean()),
|
||||
});
|
||||
|
||||
export async function updatePreferences(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { contribuyenteId, preferences } = updateSchema.parse(req.body);
|
||||
const updated = await setContribuyenteEmailPreferences(req.tenantPool!, contribuyenteId, preferences);
|
||||
res.json({ contribuyenteId, preferences: updated });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
111
apps/api/src/controllers/obligaciones.controller.ts
Normal file
111
apps/api/src/controllers/obligaciones.controller.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import * as obligacionesService from '../services/obligaciones.service.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
|
||||
export async function getCatalogo(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
return res.json({ data: obligacionesService.getCatalogo() });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
export async function getObligaciones(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = String(req.params.id);
|
||||
const rows = await obligacionesService.getObligaciones(req.tenantPool!, contribuyenteId);
|
||||
return res.json({ data: rows });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
export async function initRecomendaciones(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = String(req.params.id);
|
||||
const { rfc, regimenes, tieneNomina } = req.body;
|
||||
if (!rfc) return next(new AppError(400, 'rfc requerido'));
|
||||
const count = await obligacionesService.initRecomendaciones(
|
||||
req.tenantPool!, contribuyenteId, rfc, regimenes || [], tieneNomina ?? false
|
||||
);
|
||||
return res.json({ message: `${count} obligaciones recomendadas agregadas`, count });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
export async function addObligacion(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = String(req.params.id);
|
||||
const schema = z.object({
|
||||
catalogoId: z.string().optional(),
|
||||
nombre: z.string().min(2),
|
||||
fundamento: z.string().optional(),
|
||||
frecuencia: z.string().optional(),
|
||||
fechaLimite: z.string().optional(),
|
||||
categoria: z.string().optional(),
|
||||
});
|
||||
const data = schema.parse(req.body);
|
||||
const row = await obligacionesService.addObligacion(req.tenantPool!, contribuyenteId, data);
|
||||
return res.status(201).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 removeObligacion(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const ok = await obligacionesService.removeObligacion(req.tenantPool!, String(req.params.obligacionId));
|
||||
if (!ok) return next(new AppError(404, 'Obligación no encontrada'));
|
||||
return res.json({ message: 'Obligación desactivada' });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
export async function restoreObligacion(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const ok = await obligacionesService.restoreObligacion(req.tenantPool!, String(req.params.obligacionId));
|
||||
if (!ok) return next(new AppError(404, 'Obligación no encontrada'));
|
||||
return res.json({ message: 'Obligación restaurada' });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
export async function completeObligacion(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const periodo = req.body.periodo || new Date().toISOString().substring(0, 7);
|
||||
const ok = await obligacionesService.completeObligacion(req.tenantPool!, String(req.params.obligacionId), req.user!.userId, periodo);
|
||||
if (!ok) return next(new AppError(404, 'Obligación no encontrada'));
|
||||
return res.json({ message: 'Obligación marcada como completada' });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
export async function uncompleteObligacion(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const ok = await obligacionesService.uncompleteObligacion(req.tenantPool!, String(req.params.obligacionId));
|
||||
if (!ok) return next(new AppError(404, 'Obligación no encontrada'));
|
||||
return res.json({ message: 'Obligación desmarcada' });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
export async function getObligacionesPorPeriodo(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = String(req.params.id);
|
||||
const periodo = (req.query.periodo as string) || new Date().toISOString().substring(0, 7);
|
||||
const incluirAtrasados = req.query.atrasados !== 'false';
|
||||
const rows = await obligacionesService.getObligacionesPorPeriodo(req.tenantPool!, contribuyenteId, periodo, incluirAtrasados);
|
||||
return res.json({ data: rows, periodo });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
export async function completePeriodo(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { periodo, notas } = req.body;
|
||||
if (!periodo) return next(new AppError(400, 'periodo requerido (YYYY-MM)'));
|
||||
await obligacionesService.completePeriodo(req.tenantPool!, String(req.params.obligacionId), periodo, req.user!.userId, notas);
|
||||
return res.json({ message: 'Obligación completada para el periodo' });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
export async function uncompletePeriodo(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const periodo = req.body.periodo || req.query.periodo;
|
||||
if (!periodo) return next(new AppError(400, 'periodo requerido'));
|
||||
await obligacionesService.uncompletePeriodo(req.tenantPool!, String(req.params.obligacionId), periodo as string);
|
||||
return res.json({ message: 'Completación removida' });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
263
apps/api/src/controllers/papeleria.controller.ts
Normal file
263
apps/api/src/controllers/papeleria.controller.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
import * as papeleriaService from '../services/papeleria.service.js';
|
||||
import { emailService } from '../services/email/email.service.js';
|
||||
import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
function rejectClienteRole(req: Request): void {
|
||||
if (req.user?.role === 'cliente') {
|
||||
throw new AppError(403, 'Papelería no disponible para usuarios cliente');
|
||||
}
|
||||
}
|
||||
|
||||
function effectiveTenantId(req: Request): string {
|
||||
return req.viewingTenantId || req.user!.tenantId;
|
||||
}
|
||||
|
||||
const uploadSchema = z.object({
|
||||
contribuyenteId: z.string().uuid(),
|
||||
nombre: z.string().min(1).max(255),
|
||||
descripcion: z.string().max(2000).nullable().optional(),
|
||||
anio: z.number().int().min(2000).max(2100),
|
||||
mes: z.number().int().min(1).max(12),
|
||||
requiereAprobacion: z.boolean(),
|
||||
archivoBase64: z.string().min(1),
|
||||
archivoFilename: z.string().min(1).max(255),
|
||||
archivoMime: z.string().min(1).max(100),
|
||||
});
|
||||
|
||||
export async function upload(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
rejectClienteRole(req);
|
||||
const data = uploadSchema.parse(req.body);
|
||||
const archivo = Buffer.from(data.archivoBase64, 'base64');
|
||||
|
||||
const item = await papeleriaService.uploadPapeleria(req.tenantPool!, {
|
||||
contribuyenteId: data.contribuyenteId,
|
||||
nombre: data.nombre,
|
||||
descripcion: data.descripcion ?? null,
|
||||
anio: data.anio,
|
||||
mes: data.mes,
|
||||
requiereAprobacion: data.requiereAprobacion,
|
||||
archivo,
|
||||
archivoFilename: data.archivoFilename,
|
||||
archivoMime: data.archivoMime,
|
||||
subidoPor: req.user!.userId,
|
||||
});
|
||||
|
||||
// Notificación a aprobadores si la papelería requiere aprobación.
|
||||
if (item.requiereAprobacion) {
|
||||
notifyAprobacionRequerida(req, item).catch(err =>
|
||||
console.error('[papeleria.upload] notify aprobadores failed:', err?.message || err),
|
||||
);
|
||||
}
|
||||
|
||||
res.status(201).json(item);
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
if (error?.message?.startsWith('Formato no permitido') || error?.message?.startsWith('Archivo excede')) {
|
||||
return next(new AppError(400, error.message));
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
const listSchema = z.object({
|
||||
contribuyenteId: z.string().uuid(),
|
||||
anio: z.string().regex(/^\d{4}$/).optional(),
|
||||
mes: z.string().regex(/^\d{1,2}$/).optional(),
|
||||
estado: z.enum(['pendiente', 'aprobado', 'rechazado', 'sin_aprobacion']).optional(),
|
||||
});
|
||||
|
||||
export async function list(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
rejectClienteRole(req);
|
||||
const q = listSchema.parse(req.query);
|
||||
const items = await papeleriaService.listPapeleria(req.tenantPool!, {
|
||||
contribuyenteId: q.contribuyenteId,
|
||||
anio: q.anio ? parseInt(q.anio, 10) : undefined,
|
||||
mes: q.mes ? parseInt(q.mes, 10) : undefined,
|
||||
estado: q.estado,
|
||||
});
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function download(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
rejectClienteRole(req);
|
||||
const id = parseInt(String(req.params.id), 10);
|
||||
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
||||
const file = await papeleriaService.downloadArchivo(req.tenantPool!, id);
|
||||
if (!file) return next(new AppError(404, 'Documento no encontrado'));
|
||||
res.setHeader('Content-Type', file.mime);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(file.filename)}"`);
|
||||
res.send(file.archivo);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function aprobar(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
rejectClienteRole(req);
|
||||
const id = parseInt(String(req.params.id), 10);
|
||||
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
||||
const item = await papeleriaService.aprobar(
|
||||
req.tenantPool!, id, req.user!.userId, req.user!.role,
|
||||
);
|
||||
if (!item) return next(new AppError(404, 'Documento no encontrado o no requiere aprobación'));
|
||||
notifyDecisionAuxiliar(req, item).catch(err =>
|
||||
console.error('[papeleria.aprobar] notify auxiliar failed:', err?.message || err),
|
||||
);
|
||||
res.json(item);
|
||||
} catch (error: any) {
|
||||
if (error?.message?.startsWith('Solo owner')) return next(new AppError(403, error.message));
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
const rechazarSchema = z.object({ comentario: z.string().max(2000).nullable().optional() });
|
||||
|
||||
export async function rechazar(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
rejectClienteRole(req);
|
||||
const id = parseInt(String(req.params.id), 10);
|
||||
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
||||
const { comentario } = rechazarSchema.parse(req.body);
|
||||
const item = await papeleriaService.rechazar(
|
||||
req.tenantPool!, id, req.user!.userId, req.user!.role, comentario ?? null,
|
||||
);
|
||||
if (!item) return next(new AppError(404, 'Documento no encontrado o no requiere aprobación'));
|
||||
notifyDecisionAuxiliar(req, item).catch(err =>
|
||||
console.error('[papeleria.rechazar] notify auxiliar failed:', err?.message || err),
|
||||
);
|
||||
res.json(item);
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
if (error?.message?.startsWith('Solo owner')) return next(new AppError(403, error.message));
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function eliminar(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
rejectClienteRole(req);
|
||||
const id = parseInt(String(req.params.id), 10);
|
||||
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
||||
const ok = await papeleriaService.eliminar(req.tenantPool!, id);
|
||||
if (!ok) return next(new AppError(404, 'Documento no encontrado'));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Notificaciones ───
|
||||
|
||||
/**
|
||||
* Notifica a owners y supervisores cuando una papelería requiere aprobación.
|
||||
* Owners se obtienen de tenant_memberships (BD central). Supervisores se
|
||||
* resuelven leyendo carteras del tenant.
|
||||
*/
|
||||
async function notifyAprobacionRequerida(
|
||||
req: Request,
|
||||
item: papeleriaService.PapeleriaItem,
|
||||
): Promise<void> {
|
||||
const tenantId = effectiveTenantId(req);
|
||||
|
||||
// Owners del despacho
|
||||
const recipients = new Set<string>(await getTenantOwnerEmails(tenantId));
|
||||
|
||||
// Supervisores: cualquier user con rol 'supervisor' o 'cfo' que pertenezca a este tenant.
|
||||
// Buscamos vía tenant_memberships + roles.
|
||||
const supervisores = await prisma.tenantMembership.findMany({
|
||||
where: { tenantId, active: true, rol: { nombre: { in: ['supervisor', 'cfo'] } } },
|
||||
include: { user: { select: { email: true, active: true } } },
|
||||
});
|
||||
for (const m of supervisores) {
|
||||
if (m.user.active && m.user.email) recipients.add(m.user.email);
|
||||
}
|
||||
|
||||
// No notificarse a sí mismo
|
||||
recipients.delete(req.user!.email);
|
||||
|
||||
if (recipients.size === 0) return;
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { nombre: true },
|
||||
});
|
||||
|
||||
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>(
|
||||
`SELECT c.rfc, eg.nombre FROM contribuyentes c
|
||||
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
||||
WHERE c.entidad_id = $1`,
|
||||
[item.contribuyenteId],
|
||||
);
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const link = `${env.FRONTEND_URL}/documentos`;
|
||||
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
|
||||
const periodo = `${meses[item.mes - 1]} ${item.anio}`;
|
||||
|
||||
for (const to of recipients) {
|
||||
try {
|
||||
await emailService.sendPapeleriaAprobacionRequerida(to, {
|
||||
contribuyenteRfc: rows[0].rfc,
|
||||
contribuyenteNombre: rows[0].nombre,
|
||||
despachoNombre: tenant?.nombre,
|
||||
nombreDocumento: item.nombre,
|
||||
descripcion: item.descripcion,
|
||||
periodo,
|
||||
subidoPor: req.user!.email,
|
||||
link,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error(`[Email] papeleria-aprobacion a ${to}:`, err?.message || err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifica al uploader (auxiliar) cuando un documento que él subió fue
|
||||
* aprobado o rechazado. Solo manda si quien aprobó/rechazó NO es el mismo
|
||||
* uploader (caso edge: owner sube su propia papelería).
|
||||
*/
|
||||
async function notifyDecisionAuxiliar(
|
||||
req: Request,
|
||||
item: papeleriaService.PapeleriaItem,
|
||||
): Promise<void> {
|
||||
if (item.subidoPor === req.user!.userId) return;
|
||||
const auxiliarEmail = await getUserEmailById(item.subidoPor);
|
||||
if (!auxiliarEmail) return;
|
||||
|
||||
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>(
|
||||
`SELECT c.rfc, eg.nombre FROM contribuyentes c
|
||||
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
||||
WHERE c.entidad_id = $1`,
|
||||
[item.contribuyenteId],
|
||||
);
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const link = `${env.FRONTEND_URL}/documentos`;
|
||||
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
|
||||
const periodo = `${meses[item.mes - 1]} ${item.anio}`;
|
||||
|
||||
await emailService.sendPapeleriaDecision(auxiliarEmail, {
|
||||
contribuyenteRfc: rows[0].rfc,
|
||||
contribuyenteNombre: rows[0].nombre,
|
||||
nombreDocumento: item.nombre,
|
||||
estado: item.estado as 'aprobado' | 'rechazado',
|
||||
revisor: req.user!.email,
|
||||
comentario: item.comentarioRechazo,
|
||||
periodo,
|
||||
link,
|
||||
});
|
||||
}
|
||||
92
apps/api/src/controllers/plan-catalogo.controller.ts
Normal file
92
apps/api/src/controllers/plan-catalogo.controller.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import * as planService from '../services/plan-catalogo.service.js';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { invalidateDespachoPlanCache } from '../services/plan-catalogo.service.js';
|
||||
import { canEditPrices } from '../utils/platform-admin.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
|
||||
export async function getPlans(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const vertical = req.query.vertical as string | undefined;
|
||||
const plans = await planService.listPlans(vertical);
|
||||
return res.json({ data: plans });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
export async function getAddons(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const vertical = req.query.vertical as string | undefined;
|
||||
const addons = await planService.listAddons(vertical);
|
||||
return res.json({ data: addons });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
export async function getPlan(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const plan = await planService.getPlanByCodename(String(req.params.codename));
|
||||
if (!plan) return res.status(404).json({ message: 'Plan no encontrado' });
|
||||
return res.json(plan);
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Catálogo despacho — limits + precios editables por admin global
|
||||
// ============================================================================
|
||||
|
||||
/** GET /api/planes/despacho — devuelve los 6 planes con limits + precios. */
|
||||
export async function listDespachoCatalogo(_req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const plans = await planService.getAllDespachoPlanLimits();
|
||||
return res.json({ data: plans });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
const updateSchema = z.object({
|
||||
nombre: z.string().min(1).max(50).optional(),
|
||||
monthly: z.number().nullable().optional(),
|
||||
firstYear: z.number().nullable().optional(),
|
||||
renewal: z.number().nullable().optional(),
|
||||
permiteMonthly: z.boolean().optional(),
|
||||
maxRfcs: z.number().int().optional(),
|
||||
maxUsers: z.number().int().optional(),
|
||||
timbresIncluidosMes: z.number().int().nonnegative().optional(),
|
||||
dbMode: z.enum(['BYO', 'MANAGED']).optional(),
|
||||
permiteServidorBackup: z.boolean().optional(),
|
||||
permiteSatIncremental: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/** PATCH /api/planes/despacho/:plan — actualiza precios/limits. Solo admin con canEditPrices. */
|
||||
export async function updateDespachoCatalogo(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user || !(await canEditPrices(req.user.userId))) {
|
||||
throw new AppError(403, 'Solo admin global puede editar el catálogo');
|
||||
}
|
||||
const plan = String(req.params.plan);
|
||||
const data = updateSchema.parse(req.body);
|
||||
|
||||
const existing = await prisma.despachoPlanPrice.findUnique({ where: { plan } });
|
||||
if (!existing) throw new AppError(404, `Plan '${plan}' no encontrado`);
|
||||
|
||||
const updated = await prisma.despachoPlanPrice.update({ where: { plan }, data });
|
||||
invalidateDespachoPlanCache();
|
||||
|
||||
return res.json({
|
||||
plan: updated.plan,
|
||||
nombre: updated.nombre,
|
||||
monthly: updated.monthly !== null ? Number(updated.monthly) : null,
|
||||
firstYear: updated.firstYear !== null ? Number(updated.firstYear) : null,
|
||||
renewal: updated.renewal !== null ? Number(updated.renewal) : null,
|
||||
permiteMonthly: updated.permiteMonthly,
|
||||
maxRfcs: updated.maxRfcs,
|
||||
maxUsers: updated.maxUsers,
|
||||
timbresIncluidosMes: updated.timbresIncluidosMes,
|
||||
dbMode: updated.dbMode,
|
||||
permiteServidorBackup: updated.permiteServidorBackup,
|
||||
permiteSatIncremental: updated.permiteSatIncremental,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
187
apps/api/src/controllers/platform-staff.controller.ts
Normal file
187
apps/api/src/controllers/platform-staff.controller.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { hasPlatformRole, invalidatePlatformRolesCache, type PlatformRole } from '../utils/platform-admin.js';
|
||||
import { auditFromReq } from '../utils/audit.js';
|
||||
|
||||
const VALID_ROLES: PlatformRole[] = ['platform_admin', 'platform_ti', 'platform_support', 'platform_sales', 'platform_finance'];
|
||||
const SUPERSET_ROLES: PlatformRole[] = ['platform_admin', 'platform_ti'];
|
||||
|
||||
async function requirePlatformAdmin(req: Request, res: Response): Promise<boolean> {
|
||||
const ok = await hasPlatformRole(req.user?.userId, 'platform_admin');
|
||||
if (!ok) {
|
||||
res.status(403).json({ message: 'Solo platform_admin puede gestionar staff' });
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista users que tienen al menos un platform role + users candidatos a serlo.
|
||||
* Admin global (platform_admin) only.
|
||||
*/
|
||||
export async function listStaff(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!(await requirePlatformAdmin(req, res))) return;
|
||||
|
||||
// Todos los users con al menos un platform role
|
||||
const roles = await prisma.userPlatformRole.findMany({
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true, email: true, nombre: true, active: true,
|
||||
// Tenant principal del staff: el primer membership owner por joinedAt
|
||||
// ASC. Se incluye solo para mostrar contexto en la UI admin.
|
||||
memberships: {
|
||||
where: { active: true, isOwner: true },
|
||||
orderBy: { joinedAt: 'asc' },
|
||||
take: 1,
|
||||
include: { tenant: { select: { id: true, nombre: true, rfc: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// Agrupa por user
|
||||
const byUser = new Map<string, any>();
|
||||
for (const r of roles) {
|
||||
const existing = byUser.get(r.userId);
|
||||
if (existing) {
|
||||
existing.roles.push(r.role);
|
||||
} else {
|
||||
const { memberships, ...userBase } = r.user;
|
||||
byUser.set(r.userId, {
|
||||
...userBase,
|
||||
tenant: memberships[0]?.tenant ?? null,
|
||||
roles: [r.role],
|
||||
});
|
||||
}
|
||||
}
|
||||
res.json(Array.from(byUser.values()));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca users por email (para agregar nuevos staff). Admin global only.
|
||||
*/
|
||||
export async function searchUsers(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!(await requirePlatformAdmin(req, res))) return;
|
||||
|
||||
const q = String(req.query.q || '').trim();
|
||||
if (q.length < 2) return res.json([]);
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ email: { contains: q, mode: 'insensitive' } },
|
||||
{ nombre: { contains: q, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true, email: true, nombre: true, active: true,
|
||||
memberships: {
|
||||
where: { active: true, isOwner: true },
|
||||
orderBy: { joinedAt: 'asc' },
|
||||
take: 1,
|
||||
include: { tenant: { select: { id: true, nombre: true, rfc: true } } },
|
||||
},
|
||||
},
|
||||
take: 10,
|
||||
});
|
||||
res.json(users.map(u => {
|
||||
const { memberships, ...rest } = u;
|
||||
return { ...rest, tenant: memberships[0]?.tenant ?? null };
|
||||
}));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asigna un rol a un user. Idempotente (si ya existe, no duplica).
|
||||
*/
|
||||
export async function grantRole(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!(await requirePlatformAdmin(req, res))) return;
|
||||
|
||||
const { userId, role } = req.body;
|
||||
if (!userId || typeof userId !== 'string') {
|
||||
return res.status(400).json({ message: 'userId requerido' });
|
||||
}
|
||||
if (!VALID_ROLES.includes(role)) {
|
||||
return res.status(400).json({ message: `role inválido. Valores: ${VALID_ROLES.join(', ')}` });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: { id: true, email: true } });
|
||||
if (!user) return res.status(404).json({ message: 'Usuario no encontrado' });
|
||||
|
||||
await prisma.userPlatformRole.upsert({
|
||||
where: { userId_role: { userId, role } },
|
||||
create: { userId, role, createdBy: req.user!.userId },
|
||||
update: {},
|
||||
});
|
||||
|
||||
invalidatePlatformRolesCache(userId);
|
||||
|
||||
auditFromReq(req, 'platform_role.granted', {
|
||||
entityType: 'User',
|
||||
entityId: userId,
|
||||
metadata: { role, targetEmail: user.email },
|
||||
});
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quita un rol a un user. Protección: no puedes quitarte tu propio `platform_admin`
|
||||
* si eres el último admin (evita bootstrap problem — nadie queda con acceso).
|
||||
*/
|
||||
export async function revokeRole(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!(await requirePlatformAdmin(req, res))) return;
|
||||
|
||||
const { userId, role } = req.body;
|
||||
if (!userId || typeof userId !== 'string') {
|
||||
return res.status(400).json({ message: 'userId requerido' });
|
||||
}
|
||||
if (!VALID_ROLES.includes(role)) {
|
||||
return res.status(400).json({ message: 'role inválido' });
|
||||
}
|
||||
|
||||
// Protección: no quitar tu último rol superset (admin o TI) — evita bootstrap problem
|
||||
if (SUPERSET_ROLES.includes(role) && userId === req.user!.userId) {
|
||||
const supersetCount = await prisma.userPlatformRole.count({
|
||||
where: { role: { in: SUPERSET_ROLES } },
|
||||
});
|
||||
if (supersetCount <= 1) {
|
||||
return res.status(400).json({
|
||||
message: 'No puedes quitar tu propio rol superset — serías el último con acceso transversal. Asigna platform_admin o platform_ti a otro usuario primero.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: { email: true } });
|
||||
|
||||
await prisma.userPlatformRole.deleteMany({
|
||||
where: { userId, role },
|
||||
});
|
||||
|
||||
invalidatePlatformRolesCache(userId);
|
||||
|
||||
auditFromReq(req, 'platform_role.revoked', {
|
||||
entityType: 'User',
|
||||
entityId: userId,
|
||||
metadata: { role, targetEmail: user?.email },
|
||||
});
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
70
apps/api/src/controllers/regimen.controller.ts
Normal file
70
apps/api/src/controllers/regimen.controller.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import * as regimenService from '../services/regimen.service.js';
|
||||
|
||||
/** Resuelve el tenantId efectivo (impersonación o propio) */
|
||||
function effectiveTenantId(req: Request): string {
|
||||
return req.viewingTenantId || req.user!.tenantId;
|
||||
}
|
||||
|
||||
export async function getAllRegimenes(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const regimenes = await regimenService.getAllRegimenes();
|
||||
res.json(regimenes);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getActivos(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const activos = await regimenService.getRegimenesActivos(effectiveTenantId(req));
|
||||
res.json(activos);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setActivos(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (req.user!.role !== 'owner') {
|
||||
return res.status(403).json({ message: 'Solo el dueño puede configurar regímenes' });
|
||||
}
|
||||
|
||||
const { regimenIds } = req.body;
|
||||
if (!Array.isArray(regimenIds)) {
|
||||
return res.status(400).json({ message: 'regimenIds debe ser un array' });
|
||||
}
|
||||
|
||||
const result = await regimenService.setRegimenesActivos(effectiveTenantId(req), regimenIds);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getIgnorados(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const ignorados = await regimenService.getRegimenesIgnorados(effectiveTenantId(req));
|
||||
res.json(ignorados);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setIgnorados(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (req.user!.role !== 'owner') {
|
||||
return res.status(403).json({ message: 'Solo el dueño puede configurar regímenes' });
|
||||
}
|
||||
|
||||
const { regimenIds } = req.body;
|
||||
if (!Array.isArray(regimenIds)) {
|
||||
return res.status(400).json({ message: 'regimenIds debe ser un array' });
|
||||
}
|
||||
|
||||
const result = await regimenService.setRegimenesIgnorados(effectiveTenantId(req), regimenIds);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
85
apps/api/src/controllers/reportes.controller.ts
Normal file
85
apps/api/src/controllers/reportes.controller.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import * as reportesService from '../services/reportes.service.js';
|
||||
|
||||
export async function getEstadoResultados(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { fechaInicio, fechaFin, contribuyenteId } = req.query;
|
||||
const now = new Date();
|
||||
const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
|
||||
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
|
||||
|
||||
const data = await reportesService.getEstadoResultados(req.tenantPool!, inicio, fin, req.user!.tenantId, contribuyenteId as string | undefined || null);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
console.error('[reportes] Error en getEstadoResultados:', error);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFlujoEfectivo(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { fechaInicio, fechaFin, contribuyenteId } = req.query;
|
||||
const now = new Date();
|
||||
const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
|
||||
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
|
||||
|
||||
const data = await reportesService.getFlujoEfectivo(req.tenantPool!, inicio, fin, contribuyenteId as string | undefined || null);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getComparativo(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { contribuyenteId } = req.query;
|
||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||
const data = await reportesService.getComparativo(req.tenantPool!, año, contribuyenteId as string | undefined || null);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCuentasXPagar(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { fechaInicio, fechaFin, regimen, contribuyenteId } = req.query;
|
||||
const now = new Date();
|
||||
const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
|
||||
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
|
||||
|
||||
const data = await reportesService.getCuentasXPagar(req.tenantPool!, inicio, fin, regimen as string, contribuyenteId as string | undefined || null);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCuentasXCobrar(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { fechaInicio, fechaFin, regimen, contribuyenteId } = req.query;
|
||||
const now = new Date();
|
||||
const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
|
||||
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
|
||||
|
||||
const data = await reportesService.getCuentasXCobrar(req.tenantPool!, inicio, fin, regimen as string, contribuyenteId as string | undefined || null);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConcentradoRfc(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { fechaInicio, fechaFin, tipo, contribuyenteId } = req.query;
|
||||
const now = new Date();
|
||||
const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
|
||||
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
|
||||
const tipoRfc = (tipo as 'cliente' | 'proveedor') || 'cliente';
|
||||
|
||||
const data = await reportesService.getConcentradoRfc(req.tenantPool!, inicio, fin, tipoRfc, contribuyenteId as string | undefined || null);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
168
apps/api/src/controllers/sat.controller.ts
Normal file
168
apps/api/src/controllers/sat.controller.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import {
|
||||
startSync,
|
||||
getSyncStatus,
|
||||
getSyncHistory,
|
||||
retryJob,
|
||||
} from '../services/sat/sat.service.js';
|
||||
import { getJobInfo, runSatSyncJobManually } from '../jobs/sat-sync.job.js';
|
||||
import type { StartSyncRequest } from '@horux/shared';
|
||||
import { isGlobalAdmin } from '../utils/global-admin.js';
|
||||
|
||||
function effectiveTenantId(req: Request): string {
|
||||
return req.viewingTenantId || req.user!.tenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inicia una sincronización manual
|
||||
*/
|
||||
export async function start(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const tenantId = effectiveTenantId(req);
|
||||
const { type, dateFrom, dateTo } = req.body as StartSyncRequest;
|
||||
const contribuyenteId = req.body.contribuyenteId as string | undefined;
|
||||
|
||||
const jobId = await startSync(
|
||||
tenantId,
|
||||
type || 'daily',
|
||||
dateFrom ? new Date(dateFrom) : undefined,
|
||||
dateTo ? new Date(dateTo) : undefined,
|
||||
contribuyenteId || undefined
|
||||
);
|
||||
|
||||
res.json({
|
||||
jobId,
|
||||
message: 'Sincronización iniciada',
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Controller] Error en start:', error);
|
||||
|
||||
if (error.message.includes('FIEL') || error.message.includes('sincronización en curso')) {
|
||||
res.status(400).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Error interno del servidor' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el estado actual de sincronización
|
||||
*/
|
||||
export async function status(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const tenantId = effectiveTenantId(req);
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
const syncStatus = await getSyncStatus(tenantId, contribuyenteId || undefined);
|
||||
res.json(syncStatus);
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Controller] Error en status:', error);
|
||||
res.status(500).json({ error: 'Error interno del servidor' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el historial de sincronizaciones
|
||||
*/
|
||||
export async function history(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const tenantId = effectiveTenantId(req);
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = parseInt(req.query.limit as string) || 10;
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
|
||||
const result = await getSyncHistory(tenantId, page, limit, contribuyenteId || undefined);
|
||||
res.json({
|
||||
...result,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Controller] Error en history:', error);
|
||||
res.status(500).json({ error: 'Error interno del servidor' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene detalle de un job específico
|
||||
*/
|
||||
export async function jobDetail(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const tenantId = effectiveTenantId(req);
|
||||
const { id } = req.params;
|
||||
const { jobs } = await getSyncHistory(tenantId, 1, 100);
|
||||
const job = jobs.find(j => j.id === id);
|
||||
|
||||
if (!job) {
|
||||
res.status(404).json({ error: 'Job no encontrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(job);
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Controller] Error en jobDetail:', error);
|
||||
res.status(500).json({ error: 'Error interno del servidor' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reintenta un job fallido
|
||||
*/
|
||||
export async function retry(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const newJobId = await retryJob(id);
|
||||
|
||||
res.json({
|
||||
jobId: newJobId,
|
||||
message: 'Job reintentado',
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Controller] Error en retry:', error);
|
||||
|
||||
if (error.message.includes('no encontrado') || error.message.includes('Solo se pueden')) {
|
||||
res.status(400).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Error interno del servidor' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene información del job programado (solo admin global)
|
||||
*/
|
||||
export async function cronInfo(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) {
|
||||
res.status(403).json({ error: 'Solo el administrador global puede ver info del cron' });
|
||||
return;
|
||||
}
|
||||
const info = getJobInfo();
|
||||
res.json(info);
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Controller] Error en cronInfo:', error);
|
||||
res.status(500).json({ error: 'Error interno del servidor' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ejecuta el job de sincronización manualmente (solo admin global)
|
||||
*/
|
||||
export async function runCron(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) {
|
||||
res.status(403).json({ error: 'Solo el administrador global puede ejecutar el cron' });
|
||||
return;
|
||||
}
|
||||
// Ejecutar en background
|
||||
runSatSyncJobManually().catch(err =>
|
||||
console.error('[SAT Controller] Error ejecutando cron manual:', err)
|
||||
);
|
||||
|
||||
res.json({ message: 'Job de sincronización iniciado' });
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Controller] Error en runCron:', error);
|
||||
res.status(500).json({ error: 'Error interno del servidor' });
|
||||
}
|
||||
}
|
||||
359
apps/api/src/controllers/subscription.controller.ts
Normal file
359
apps/api/src/controllers/subscription.controller.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import * as subscriptionService from '../services/payment/subscription.service.js';
|
||||
import { listActiveAddons, subscribeAddon, cancelAddon } from '../services/payment/addon.service.js';
|
||||
import { isGlobalAdmin } from '../utils/global-admin.js';
|
||||
import { auditFromReq } from '../utils/audit.js';
|
||||
|
||||
async function requireGlobalAdmin(req: Request, res: Response): Promise<boolean> {
|
||||
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role);
|
||||
if (!isAdmin) {
|
||||
res.status(403).json({ message: 'Solo el administrador global puede gestionar suscripciones' });
|
||||
}
|
||||
return isAdmin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permite si el usuario es admin global O si está consultando su propio tenant.
|
||||
* Úsalo para endpoints de lectura/acción sobre la suscripción del mismo tenant
|
||||
* del usuario (ver estado, generar link de pago pendiente).
|
||||
*/
|
||||
async function requireOwnTenantOrGlobalAdmin(req: Request, res: Response, targetTenantId: string): Promise<boolean> {
|
||||
if (targetTenantId === req.user!.tenantId) return true;
|
||||
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role);
|
||||
if (!isAdmin) {
|
||||
res.status(403).json({ message: 'Solo puedes gestionar la suscripción de tu propio tenant' });
|
||||
}
|
||||
return isAdmin;
|
||||
}
|
||||
|
||||
// (getPlans + updatePlanPrice eliminados — modelo PlanPrice legacy dropeado
|
||||
// en migración 20260501160000_drop_plan_prices_legacy. Catálogo despacho
|
||||
// vive en `despacho_plan_prices` editado vía /api/planes/despacho.)
|
||||
|
||||
export async function getAllSubscriptions(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!(await requireGlobalAdmin(req, res))) return;
|
||||
|
||||
const { prisma } = await import('../config/database.js');
|
||||
const subscriptions = await prisma.subscription.findMany({
|
||||
include: {
|
||||
tenant: {
|
||||
select: { id: true, nombre: true, rfc: true, plan: true, active: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
res.json(subscriptions);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSubscription(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const tenantId = String(req.params.tenantId);
|
||||
if (!(await requireOwnTenantOrGlobalAdmin(req, res, tenantId))) return;
|
||||
|
||||
const subscription = await subscriptionService.getActiveSubscription(tenantId);
|
||||
if (!subscription) {
|
||||
return res.status(404).json({ message: 'No se encontró suscripción' });
|
||||
}
|
||||
res.json(subscription);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generatePaymentLink(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const tenantId = String(req.params.tenantId);
|
||||
if (!(await requireOwnTenantOrGlobalAdmin(req, res, tenantId))) return;
|
||||
|
||||
const result = await subscriptionService.generatePaymentLink(tenantId);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function markAsPaid(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!(await requireGlobalAdmin(req, res))) return;
|
||||
|
||||
const tenantId = String(req.params.tenantId);
|
||||
const { amount } = req.body;
|
||||
|
||||
if (!amount || amount <= 0) {
|
||||
return res.status(400).json({ message: 'Monto inválido' });
|
||||
}
|
||||
|
||||
const payment = await subscriptionService.markAsPaidManually(tenantId, amount);
|
||||
res.json(payment);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPayments(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const tenantId = String(req.params.tenantId);
|
||||
if (!(await requireOwnTenantOrGlobalAdmin(req, res, tenantId))) return;
|
||||
|
||||
const payments = await subscriptionService.getPaymentHistory(tenantId);
|
||||
res.json(payments);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Self-serve endpoints (actúan sobre el tenant del usuario autenticado)
|
||||
// ============================================================================
|
||||
|
||||
type FrequencyInput = 'monthly' | 'annual';
|
||||
const VALID_PLANS = [
|
||||
'business_control', 'business_cloud',
|
||||
'mi_empresa', 'mi_empresa_plus',
|
||||
] as const;
|
||||
// Planes despacho que se cobran SOLO anual. Mi Empresa y Mi Empresa+ aceptan
|
||||
// monthly o annual (annual con descuento ~17% — paga 10 meses); Business
|
||||
// Control y Enterprise siguen exclusivamente anuales.
|
||||
const DESPACHO_ONLY_ANNUAL = new Set([
|
||||
'business_control', 'business_cloud',
|
||||
]);
|
||||
|
||||
function validatePlanFrequency(body: any): { plan: typeof VALID_PLANS[number]; frequency: FrequencyInput } | { error: string } {
|
||||
const plan = body?.plan;
|
||||
const frequency = body?.frequency;
|
||||
if (!plan || !VALID_PLANS.includes(plan)) {
|
||||
return { error: `plan inválido. Valores aceptados: ${VALID_PLANS.join(', ')}` };
|
||||
}
|
||||
if (frequency !== 'monthly' && frequency !== 'annual') {
|
||||
return { error: `frequency inválida. Debe ser 'monthly' o 'annual'` };
|
||||
}
|
||||
if (DESPACHO_ONLY_ANNUAL.has(plan) && frequency !== 'annual') {
|
||||
return { error: `El plan ${plan} solo está disponible con frecuencia anual` };
|
||||
}
|
||||
return { plan, frequency };
|
||||
}
|
||||
|
||||
export async function startMyTrial(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const parsed = validatePlanFrequency(req.body);
|
||||
if ('error' in parsed) return res.status(400).json({ message: parsed.error });
|
||||
|
||||
const result = await subscriptionService.startTrial({
|
||||
tenantId: req.user!.tenantId,
|
||||
plan: parsed.plan,
|
||||
frequency: parsed.frequency,
|
||||
ownerUserId: req.user!.userId,
|
||||
});
|
||||
res.status(201).json(result);
|
||||
} catch (error: any) {
|
||||
if (
|
||||
error.message?.includes('ya usó') ||
|
||||
error.message?.includes('Ya existe') ||
|
||||
error.message?.includes('no se puede') ||
|
||||
error.message?.includes('Ya consumiste') ||
|
||||
error.message?.includes('ya consumió')
|
||||
) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function subscribeMe(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const parsed = validatePlanFrequency(req.body);
|
||||
if ('error' in parsed) return res.status(400).json({ message: parsed.error });
|
||||
|
||||
const result = await subscriptionService.subscribe({
|
||||
tenantId: req.user!.tenantId,
|
||||
plan: parsed.plan,
|
||||
frequency: parsed.frequency,
|
||||
payerEmail: req.user!.email,
|
||||
});
|
||||
res.status(201).json(result);
|
||||
} catch (error: any) {
|
||||
const msg: string = error?.message || '';
|
||||
if (msg.includes('Ya existe') || msg.includes('custom')) {
|
||||
return res.status(400).json({ message: msg });
|
||||
}
|
||||
if (msg.includes('MercadoPago no está configurado')) {
|
||||
return res.status(503).json({ message: msg });
|
||||
}
|
||||
// Otros errores de MP al crear preapproval (monto inválido, email inválido, etc.)
|
||||
if (msg.includes('Unauthorized access') || error?.status === 401) {
|
||||
return res.status(503).json({
|
||||
message: 'MercadoPago rechazó la solicitud. Verifica que MP_ACCESS_TOKEN sea válido y esté vigente.',
|
||||
});
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function changeMyPlan(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const parsed = validatePlanFrequency(req.body);
|
||||
if ('error' in parsed) return res.status(400).json({ message: parsed.error });
|
||||
|
||||
const result = await subscriptionService.scheduleChange({
|
||||
tenantId: req.user!.tenantId,
|
||||
newPlan: parsed.plan,
|
||||
newFrequency: parsed.frequency,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('iguales') || error.message?.includes('No hay') || error.message?.includes('custom')) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelMySubscription(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const result = await subscriptionService.cancelSubscription(req.user!.tenantId);
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('No hay')) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactiva suscripción cancelada que aún está dentro de su período pagado.
|
||||
* Crea un preapproval nuevo en MP con start_date al final del período actual.
|
||||
* Retorna paymentUrl para que el usuario autorice.
|
||||
*/
|
||||
export async function reactivateMe(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const result = await subscriptionService.reactivateSubscription({
|
||||
tenantId: req.user!.tenantId,
|
||||
payerEmail: req.user!.email,
|
||||
});
|
||||
res.status(201).json(result);
|
||||
} catch (error: any) {
|
||||
const msg: string = error?.message || '';
|
||||
if (msg.includes('No hay') || msg.includes('vencido') || msg.includes('custom')) {
|
||||
return res.status(400).json({ message: msg });
|
||||
}
|
||||
if (msg.includes('MercadoPago no está configurado')) {
|
||||
return res.status(503).json({ message: msg });
|
||||
}
|
||||
if (msg.includes('Unauthorized access') || error?.status === 401) {
|
||||
return res.status(503).json({
|
||||
message: 'MercadoPago rechazó la solicitud. Verifica que MP_ACCESS_TOKEN sea válido.',
|
||||
});
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inicia un upgrade con cobro prorateado inmediato. Body: `{ plan }`.
|
||||
* La frecuencia actual se preserva — para cambiar frecuencia usa `/me/change`.
|
||||
* Retorna `{ checkoutUrl, proratedAmount }` — el cliente debe abrir la URL para que
|
||||
* el usuario pague en MP. Al confirmarse el pago (webhook), se aplica el plan nuevo.
|
||||
*/
|
||||
export async function upgradeMe(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const plan = req.body?.plan;
|
||||
if (!plan || !VALID_PLANS.includes(plan)) {
|
||||
return res.status(400).json({ message: `plan inválido. Valores: ${VALID_PLANS.join(', ')}` });
|
||||
}
|
||||
|
||||
const result = await subscriptionService.initiateUpgrade({
|
||||
tenantId: req.user!.tenantId,
|
||||
newPlan: plan,
|
||||
payerEmail: req.user!.email,
|
||||
});
|
||||
res.status(201).json(result);
|
||||
} catch (error: any) {
|
||||
const msg: string = error?.message || '';
|
||||
if (
|
||||
msg.includes('No hay suscripción') ||
|
||||
msg.includes('en curso') ||
|
||||
msg.includes('no es un upgrade') ||
|
||||
msg.includes('días restantes') ||
|
||||
msg.includes('custom') ||
|
||||
msg.includes('Precio no configurado')
|
||||
) {
|
||||
return res.status(400).json({ message: msg });
|
||||
}
|
||||
if (msg.includes('MercadoPago no está configurado')) {
|
||||
return res.status(503).json({ message: msg });
|
||||
}
|
||||
if (msg.includes('Unauthorized access') || error?.status === 401) {
|
||||
return res.status(503).json({
|
||||
message: 'MercadoPago rechazó la solicitud. Verifica que MP_ACCESS_TOKEN sea válido.',
|
||||
});
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelMyPendingUpgrade(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await subscriptionService.cancelPendingUpgrade(req.user!.tenantId);
|
||||
res.json({ ok: true });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('No hay upgrade')) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Addon endpoints (self-serve)
|
||||
// ============================================================================
|
||||
|
||||
export async function getMyAddons(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
// Query param `contribuyenteId` opcional: filtra al contribuyente específico.
|
||||
// Sin param → retorna todos los add-ons del tenant (incluye los de todos los RFCs).
|
||||
const contribuyenteId = typeof req.query.contribuyenteId === 'string' && req.query.contribuyenteId
|
||||
? req.query.contribuyenteId
|
||||
: undefined;
|
||||
const result = await listActiveAddons(req.user!.tenantId, contribuyenteId);
|
||||
return res.json(result);
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
export async function addMyAddon(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { addonCodename, quantity, contribuyenteId } = req.body;
|
||||
if (!addonCodename) return res.status(400).json({ message: 'addonCodename requerido' });
|
||||
|
||||
const result = await subscribeAddon({
|
||||
tenantId: req.user!.tenantId,
|
||||
addonCodename,
|
||||
quantity: quantity || 1,
|
||||
payerEmail: req.user!.email,
|
||||
contribuyenteId: typeof contribuyenteId === 'string' ? contribuyenteId : null,
|
||||
});
|
||||
return res.status(201).json(result);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes('no disponible') || err.message?.includes('Ya tienes')) {
|
||||
return res.status(409).json({ message: err.message });
|
||||
}
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelMyAddon(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await cancelAddon(req.user!.tenantId, String(req.params.addonId));
|
||||
return res.json({ message: 'Addon cancelado' });
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes('no encontrado')) {
|
||||
return res.status(404).json({ message: err.message });
|
||||
}
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
177
apps/api/src/controllers/tareas.controller.ts
Normal file
177
apps/api/src/controllers/tareas.controller.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
import * as tareasService from '../services/tareas.service.js';
|
||||
import { emailService } from '../services/email/email.service.js';
|
||||
import { getUserEmailById } from '../utils/memberships.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
/**
|
||||
* Bloquea a usuarios rol `cliente` de cualquier endpoint de tareas.
|
||||
* El cliente no debe ver tareas operativas internas del despacho.
|
||||
*/
|
||||
function rejectClienteRole(req: Request): void {
|
||||
if (req.user?.role === 'cliente') {
|
||||
throw new AppError(403, 'Tareas no disponibles para usuarios cliente');
|
||||
}
|
||||
}
|
||||
|
||||
const RECURRENCIAS = ['semanal', 'quincenal', 'mensual', 'bimestral', 'trimestral', 'semestral', 'anual'] as const;
|
||||
|
||||
const tareaSchema = z.object({
|
||||
nombre: z.string().min(1).max(200),
|
||||
descripcion: z.string().max(1000).nullable().optional(),
|
||||
recurrencia: z.enum(RECURRENCIAS),
|
||||
diaSemana: z.number().int().min(1).max(7).nullable().optional(),
|
||||
diaMes: z.number().int().min(1).max(31).nullable().optional(),
|
||||
soloSupervisorCompleta: z.boolean().optional(),
|
||||
orden: z.number().int().optional(),
|
||||
});
|
||||
|
||||
export async function listTareas(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
rejectClienteRole(req);
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
if (!contribuyenteId) return next(new AppError(400, 'contribuyenteId requerido'));
|
||||
const tareas = await tareasService.listTareasConPeriodoActual(req.tenantPool!, contribuyenteId);
|
||||
res.json(tareas);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTarea(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
rejectClienteRole(req);
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
if (!contribuyenteId) return next(new AppError(400, 'contribuyenteId requerido'));
|
||||
const data = tareaSchema.parse(req.body);
|
||||
const tarea = await tareasService.createTarea(req.tenantPool!, contribuyenteId, data);
|
||||
res.status(201).json(tarea);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTarea(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
rejectClienteRole(req);
|
||||
const data = tareaSchema.partial().parse(req.body);
|
||||
const updated = await tareasService.updateTarea(req.tenantPool!, String(req.params.id), data);
|
||||
if (!updated) return next(new AppError(404, 'Tarea no encontrada'));
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTarea(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
rejectClienteRole(req);
|
||||
const ok = await tareasService.deleteTarea(req.tenantPool!, String(req.params.id));
|
||||
if (!ok) return next(new AppError(404, 'Tarea no encontrada'));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function completarPeriodo(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
rejectClienteRole(req);
|
||||
const { notas } = z.object({ notas: z.string().max(1000).nullable().optional() }).parse(req.body);
|
||||
const result = await tareasService.completarPeriodo(
|
||||
req.tenantPool!,
|
||||
String(req.params.id),
|
||||
req.user!.userId,
|
||||
req.user!.role,
|
||||
notas ?? null,
|
||||
);
|
||||
if (!result) return next(new AppError(404, 'Periodo no encontrado'));
|
||||
|
||||
// Notificar al auxiliar de la cartera SOLO cuando una tarea con
|
||||
// solo_supervisor_completa=true fue marcada como completada por
|
||||
// un supervisor/owner. Fire-and-forget — no bloquea la respuesta.
|
||||
if (result.tarea.soloSupervisorCompleta) {
|
||||
notifyAuxiliarTareaCompletada(req, result).catch(err =>
|
||||
console.error('[tareas.completar] notify auxiliar failed:', err?.message || err),
|
||||
);
|
||||
}
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
if (error?.message?.startsWith('Solo supervisor')) return next(new AppError(403, error.message));
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function notifyAuxiliarTareaCompletada(
|
||||
req: Request,
|
||||
result: { periodo: tareasService.TareaPeriodo; tarea: tareasService.TareaCatalogo },
|
||||
): Promise<void> {
|
||||
const auxiliarUserId = await tareasService.getAuxiliarUserIdDeContribuyente(
|
||||
req.tenantPool!,
|
||||
result.tarea.contribuyenteId,
|
||||
);
|
||||
if (!auxiliarUserId) return;
|
||||
if (auxiliarUserId === req.user!.userId) return; // no notificarse a sí mismo
|
||||
const auxiliarEmail = await getUserEmailById(auxiliarUserId);
|
||||
if (!auxiliarEmail) return;
|
||||
|
||||
// Datos del contribuyente y supervisor para el email
|
||||
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>(
|
||||
`SELECT c.rfc, eg.nombre
|
||||
FROM contribuyentes c
|
||||
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
||||
WHERE c.entidad_id = $1`,
|
||||
[result.tarea.contribuyenteId],
|
||||
);
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const auxiliarNombre = (await prisma.user.findUnique({
|
||||
where: { id: auxiliarUserId },
|
||||
select: { nombre: true },
|
||||
}))?.nombre || 'Auxiliar';
|
||||
|
||||
const fechaLimite = result.periodo.fechaLimite instanceof Date
|
||||
? result.periodo.fechaLimite.toLocaleDateString('es-MX', { dateStyle: 'long' })
|
||||
: new Date(String(result.periodo.fechaLimite)).toLocaleDateString('es-MX', { dateStyle: 'long' });
|
||||
|
||||
await emailService.sendTareaCompletada(auxiliarEmail, {
|
||||
destinatarioNombre: auxiliarNombre,
|
||||
contribuyenteNombre: rows[0].nombre,
|
||||
contribuyenteRfc: rows[0].rfc,
|
||||
tareaNombre: result.tarea.nombre,
|
||||
tareaDescripcion: result.tarea.descripcion,
|
||||
completadaPor: req.user!.email,
|
||||
notas: result.periodo.notas,
|
||||
fechaLimite,
|
||||
link: `${env.FRONTEND_URL}/configuracion/obligaciones`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function descompletarPeriodo(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
rejectClienteRole(req);
|
||||
const ok = await tareasService.descompletarPeriodo(req.tenantPool!, String(req.params.id));
|
||||
if (!ok) return next(new AppError(404, 'Periodo no encontrado'));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function seedDefaults(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
rejectClienteRole(req);
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
if (!contribuyenteId) return next(new AppError(400, 'contribuyenteId requerido'));
|
||||
const created = await tareasService.seedTareasDefault(req.tenantPool!, contribuyenteId);
|
||||
res.json({ created });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
145
apps/api/src/controllers/tenants.controller.ts
Normal file
145
apps/api/src/controllers/tenants.controller.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import * as tenantsService from '../services/tenants.service.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
import { isGlobalAdmin } from '../utils/global-admin.js';
|
||||
import { isOwnerSomewhere } from '../utils/memberships.js';
|
||||
|
||||
async function requireGlobalAdmin(req: Request): Promise<void> {
|
||||
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) {
|
||||
throw new AppError(403, 'Solo el administrador global puede gestionar clientes');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllTenants(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await requireGlobalAdmin(req);
|
||||
|
||||
const tenants = await tenantsService.getAllTenants();
|
||||
res.json(tenants);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTenant(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await requireGlobalAdmin(req);
|
||||
|
||||
const tenant = await tenantsService.getTenantById(String(req.params.id));
|
||||
if (!tenant) {
|
||||
throw new AppError(404, 'Cliente no encontrado');
|
||||
}
|
||||
|
||||
res.json(tenant);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTenant(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await requireGlobalAdmin(req);
|
||||
|
||||
const { nombre, rfc, plan, adminEmail, adminNombre, amount, firstPaymentDueAt } = req.body;
|
||||
|
||||
if (!nombre || !rfc || !adminEmail || !adminNombre) {
|
||||
throw new AppError(400, 'Nombre, RFC, adminEmail y adminNombre son requeridos');
|
||||
}
|
||||
|
||||
const result = await tenantsService.createTenant({
|
||||
nombre,
|
||||
rfc,
|
||||
plan,
|
||||
adminEmail,
|
||||
adminNombre,
|
||||
amount: amount || 0,
|
||||
firstPaymentDueAt: firstPaymentDueAt || null,
|
||||
});
|
||||
|
||||
res.status(201).json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTenant(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await requireGlobalAdmin(req);
|
||||
|
||||
const id = String(req.params.id);
|
||||
const { nombre, rfc, plan, active } = req.body;
|
||||
|
||||
const tenant = await tenantsService.updateTenant(id, {
|
||||
nombre,
|
||||
rfc,
|
||||
plan,
|
||||
active,
|
||||
});
|
||||
|
||||
res.json(tenant);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTenant(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await requireGlobalAdmin(req);
|
||||
|
||||
await tenantsService.deleteTenant(String(req.params.id));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Self-serve (multi-tenant memberships)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Lista detallada de empresas del caller con estado de suscripción. Usado por
|
||||
* `/mis-empresas`. A diferencia de `/auth/me`, incluye datos de subscription
|
||||
* (status, currentPeriodEnd, pendingPlan, etc.).
|
||||
*/
|
||||
export async function getMyTenants(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = await tenantsService.getMyTenantsDetailed(req.user!.userId);
|
||||
res.json(data);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
const addTenantSchema = z.object({
|
||||
nombre: z.string().min(2, 'Nombre de empresa requerido'),
|
||||
rfc: z.string().min(12).max(13, 'RFC inválido'),
|
||||
plan: z.enum(['mi_empresa', 'mi_empresa_plus', 'business_control', 'business_cloud']).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Agrega una empresa (tenant nuevo) bajo el user autenticado. El caller se
|
||||
* vuelve owner automáticamente vía TenantMembership.
|
||||
*/
|
||||
export async function addMyTenant(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = addTenantSchema.parse(req.body);
|
||||
// Gate: solo users que son owner en al menos un tenant pueden agregar
|
||||
// un RFC adicional. Un contador invitado a una empresa ajena no puede.
|
||||
if (!(await isOwnerSomewhere(req.user!.userId))) {
|
||||
throw new AppError(403, 'Solo los dueños pueden registrar empresas adicionales.');
|
||||
}
|
||||
const result = await tenantsService.addTenantToOwner({
|
||||
userId: req.user!.userId,
|
||||
nombre: data.nombre,
|
||||
rfc: data.rfc,
|
||||
plan: data.plan,
|
||||
});
|
||||
res.status(201).json({ tenant: result.tenant });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
if (error instanceof Error && error.message.includes('RFC')) {
|
||||
return next(new AppError(400, error.message));
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
273
apps/api/src/controllers/usuarios.controller.ts
Normal file
273
apps/api/src/controllers/usuarios.controller.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import * as usuariosService from '../services/usuarios.service.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
import { isGlobalAdmin as checkGlobalAdmin } from '../utils/global-admin.js';
|
||||
|
||||
const inviteSchema = z.object({
|
||||
email: z.string().email('email inválido'),
|
||||
nombre: z.string().min(2).max(100),
|
||||
// Legacy Horux360 roles + Despacho-specific roles
|
||||
role: z.enum(['contador', 'visor', 'auxiliar', 'supervisor', 'cliente']),
|
||||
supervisorUserId: z.string().uuid().optional(), // Required when role=auxiliar
|
||||
});
|
||||
|
||||
const updateSchema = z.object({
|
||||
nombre: z.string().min(2).max(100).optional(),
|
||||
// Legacy Horux360 roles + Despacho-specific roles
|
||||
role: z.enum(['contador', 'visor', 'auxiliar', 'supervisor', 'cliente']).optional(),
|
||||
active: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const updateGlobalSchema = z.object({
|
||||
nombre: z.string().min(2).max(100).optional(),
|
||||
role: z.enum(['owner', 'cfo', 'contador', 'visor', 'auxiliar', 'supervisor', 'cliente']).optional(),
|
||||
active: z.boolean().optional(),
|
||||
tenantId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
async function isGlobalAdmin(req: Request): Promise<boolean> {
|
||||
return checkGlobalAdmin(req.user!.tenantId, req.user!.role);
|
||||
}
|
||||
|
||||
export async function getUsuarios(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const usuarios = await usuariosService.getUsuarios(req.user!.tenantId);
|
||||
res.json(usuarios);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene todos los usuarios de todas las empresas (solo admin global)
|
||||
*/
|
||||
export async function getAllUsuarios(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!(await isGlobalAdmin(req))) {
|
||||
throw new AppError(403, 'Solo el administrador global puede ver todos los usuarios');
|
||||
}
|
||||
const usuarios = await usuariosService.getAllUsuarios();
|
||||
res.json(usuarios);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function inviteUsuario(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (req.user!.role !== 'owner') {
|
||||
throw new AppError(403, 'Solo los dueños pueden invitar usuarios');
|
||||
}
|
||||
const data = inviteSchema.parse(req.body);
|
||||
|
||||
// Validate: auxiliar requires a supervisor
|
||||
if (data.role === 'auxiliar' && !data.supervisorUserId) {
|
||||
throw new AppError(400, 'Debes asignar un supervisor al auxiliar');
|
||||
}
|
||||
|
||||
const usuario = await usuariosService.inviteUsuario(req.user!.tenantId, data);
|
||||
|
||||
// Store auxiliar→supervisor relationship in tenant DB
|
||||
if (data.role === 'auxiliar' && data.supervisorUserId && req.tenantPool) {
|
||||
await req.tenantPool.query(
|
||||
`INSERT INTO auxiliar_supervisores (auxiliar_user_id, supervisor_user_id)
|
||||
VALUES ($1, $2) ON CONFLICT (auxiliar_user_id) DO UPDATE SET supervisor_user_id = $2`,
|
||||
[usuario.id, data.supervisorUserId],
|
||||
);
|
||||
}
|
||||
|
||||
res.status(201).json(usuario);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUsuario(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (req.user!.role !== 'owner') {
|
||||
throw new AppError(403, 'Solo los dueños pueden modificar usuarios');
|
||||
}
|
||||
const userId = req.params.id as string;
|
||||
const data = updateSchema.parse(req.body);
|
||||
const usuario = await usuariosService.updateUsuario(req.user!.tenantId, userId, data);
|
||||
res.json(usuario);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lee el supervisor actualmente asignado a un auxiliar. Resuelve desde 3
|
||||
* fuentes (en orden de prioridad):
|
||||
* 1. `auxiliar_supervisores` (override explícito del owner desde /usuarios).
|
||||
* 2. Cartera donde el user es `auxiliar_user_id` y la misma tiene supervisor.
|
||||
* 3. Subcartera donde el user es `auxiliar_user_id`; el supervisor viene
|
||||
* del cartera padre.
|
||||
*
|
||||
* Devuelve `null` si no aparece en ninguna.
|
||||
*/
|
||||
export async function getSupervisor(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) throw new AppError(500, 'Tenant pool no disponible');
|
||||
const userId = String(req.params.id);
|
||||
const { rows } = await req.tenantPool.query<{ supervisor_user_id: string }>(
|
||||
`SELECT supervisor_user_id FROM (
|
||||
SELECT supervisor_user_id, 1 AS prio FROM auxiliar_supervisores
|
||||
WHERE auxiliar_user_id = $1
|
||||
UNION ALL
|
||||
SELECT supervisor_user_id, 2 AS prio FROM carteras
|
||||
WHERE auxiliar_user_id = $1 AND supervisor_user_id IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT p.supervisor_user_id, 3 AS prio
|
||||
FROM carteras sub
|
||||
JOIN carteras p ON p.id = sub.parent_id
|
||||
WHERE sub.auxiliar_user_id = $1 AND p.supervisor_user_id IS NOT NULL
|
||||
) t
|
||||
WHERE supervisor_user_id IS NOT NULL
|
||||
ORDER BY prio
|
||||
LIMIT 1`,
|
||||
[userId],
|
||||
);
|
||||
res.json({ supervisorUserId: rows[0]?.supervisor_user_id ?? null });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
const supervisorSchema = z.object({
|
||||
supervisorUserId: z.string().uuid().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Asigna o elimina el supervisor de un auxiliar (BD tenant).
|
||||
* Solo owner/cfo. Pasar `null` borra la asignación.
|
||||
*/
|
||||
export async function updateSupervisor(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (req.user!.role !== 'owner' && req.user!.role !== 'cfo') {
|
||||
throw new AppError(403, 'Solo el owner puede asignar supervisores');
|
||||
}
|
||||
if (!req.tenantPool) throw new AppError(500, 'Tenant pool no disponible');
|
||||
const userId = String(req.params.id);
|
||||
const { supervisorUserId } = supervisorSchema.parse(req.body);
|
||||
|
||||
if (supervisorUserId === null) {
|
||||
await req.tenantPool.query(
|
||||
`DELETE FROM auxiliar_supervisores WHERE auxiliar_user_id = $1`,
|
||||
[userId],
|
||||
);
|
||||
} else {
|
||||
await req.tenantPool.query(
|
||||
`INSERT INTO auxiliar_supervisores (auxiliar_user_id, supervisor_user_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (auxiliar_user_id) DO UPDATE SET supervisor_user_id = $2`,
|
||||
[userId, supervisorUserId],
|
||||
);
|
||||
}
|
||||
res.json({ supervisorUserId });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteUsuario(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (req.user!.role !== 'owner') {
|
||||
throw new AppError(403, 'Solo los dueños pueden eliminar usuarios');
|
||||
}
|
||||
const userId = req.params.id as string;
|
||||
if (userId === req.user!.userId) {
|
||||
throw new AppError(400, 'No puedes eliminar tu propia cuenta');
|
||||
}
|
||||
await usuariosService.deleteUsuario(req.user!.tenantId, userId);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza un usuario globalmente (puede cambiar de empresa)
|
||||
*/
|
||||
export async function updateUsuarioGlobal(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!(await isGlobalAdmin(req))) {
|
||||
throw new AppError(403, 'Solo el administrador global puede modificar usuarios globalmente');
|
||||
}
|
||||
const userId = req.params.id as string;
|
||||
const data = updateGlobalSchema.parse(req.body);
|
||||
if (userId === req.user!.userId && data.tenantId) {
|
||||
throw new AppError(400, 'No puedes cambiar tu propia empresa');
|
||||
}
|
||||
const usuario = await usuariosService.updateUsuarioGlobal(userId, data);
|
||||
res.json(usuario);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina un usuario globalmente
|
||||
*/
|
||||
export async function deleteUsuarioGlobal(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!(await isGlobalAdmin(req))) {
|
||||
throw new AppError(403, 'Solo el administrador global puede eliminar usuarios globalmente');
|
||||
}
|
||||
|
||||
const userId = req.params.id as string;
|
||||
if (userId === req.user!.userId) {
|
||||
throw new AppError(400, 'No puedes eliminar tu propia cuenta');
|
||||
}
|
||||
await usuariosService.deleteUsuarioGlobal(userId);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cliente accesos (which contribuyentes a client user can access)
|
||||
*/
|
||||
export async function getClienteAccesos(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (req.user!.role !== 'owner') throw new AppError(403, 'No autorizado');
|
||||
const userId = req.params.id as string;
|
||||
const { rows } = await req.tenantPool!.query(
|
||||
'SELECT entidad_id AS "entidadId" FROM cliente_accesos WHERE user_id = $1',
|
||||
[userId],
|
||||
);
|
||||
res.json({ data: rows.map(r => r.entidadId) });
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cliente accesos (replace all accesos for a client user)
|
||||
*/
|
||||
export async function setClienteAccesos(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (req.user!.role !== 'owner') throw new AppError(403, 'No autorizado');
|
||||
const userId = req.params.id as string;
|
||||
const { entidadIds } = z.object({
|
||||
entidadIds: z.array(z.string().uuid()),
|
||||
}).parse(req.body);
|
||||
|
||||
// Replace all accesos
|
||||
await req.tenantPool!.query('DELETE FROM cliente_accesos WHERE user_id = $1', [userId]);
|
||||
for (const entidadId of entidadIds) {
|
||||
await req.tenantPool!.query(
|
||||
'INSERT INTO cliente_accesos (user_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||
[userId, entidadId],
|
||||
);
|
||||
}
|
||||
res.json({ data: entidadIds });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
244
apps/api/src/controllers/webhook.controller.ts
Normal file
244
apps/api/src/controllers/webhook.controller.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import * as mpService from '../services/payment/mercadopago.service.js';
|
||||
import * as subscriptionService from '../services/payment/subscription.service.js';
|
||||
import * as invoicingService from '../services/payment/invoicing.service.js';
|
||||
import * as facturapiService from '../services/facturapi.service.js';
|
||||
import { handleAddonPayment } from '../services/payment/addon.service.js';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { isDespachoPaidPlan } from '@horux/shared';
|
||||
import { despachoPlanTieneDualidadDb } from '../services/plan-catalogo.service.js';
|
||||
import { emailService } from '../services/email/email.service.js';
|
||||
import { getTenantOwnerEmail } from '../utils/memberships.js';
|
||||
|
||||
export async function handleMercadoPagoWebhook(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { type, data } = req.body;
|
||||
const xSignature = req.headers['x-signature'] as string;
|
||||
const xRequestId = req.headers['x-request-id'] as string;
|
||||
|
||||
// Verify webhook signature (mandatory)
|
||||
if (!xSignature || !xRequestId || !data?.id) {
|
||||
console.warn('[WEBHOOK] Missing signature headers');
|
||||
return res.status(401).json({ message: 'Missing signature headers' });
|
||||
}
|
||||
|
||||
const isValid = mpService.verifyWebhookSignature(xSignature, xRequestId, String(data.id));
|
||||
if (!isValid) {
|
||||
console.warn('[WEBHOOK] Invalid MercadoPago signature');
|
||||
return res.status(401).json({ message: 'Invalid signature' });
|
||||
}
|
||||
|
||||
if (type === 'payment') {
|
||||
await handlePaymentNotification(String(data.id));
|
||||
} else if (type === 'subscription_preapproval') {
|
||||
await handlePreapprovalNotification(String(data.id));
|
||||
}
|
||||
|
||||
// Always respond 200 to acknowledge receipt
|
||||
res.status(200).json({ received: true });
|
||||
} catch (error) {
|
||||
console.error('[WEBHOOK] Error processing MercadoPago webhook:', error);
|
||||
// Still respond 200 to prevent retries for processing errors
|
||||
res.status(200).json({ received: true, error: 'processing_error' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePaymentNotification(paymentId: string) {
|
||||
const payment = await mpService.getPaymentDetails(paymentId);
|
||||
|
||||
if (!payment.externalReference) {
|
||||
console.warn('[WEBHOOK] Payment without external_reference:', paymentId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Detecta compras de paquete de timbres. external_reference = `timbres-pack:{paymentId}`
|
||||
if (payment.externalReference.startsWith('timbres-pack:')) {
|
||||
const localPaymentId = payment.externalReference.split(':')[1];
|
||||
if (!localPaymentId) {
|
||||
console.warn('[WEBHOOK] external_reference timbres-pack malformado:', payment.externalReference);
|
||||
return;
|
||||
}
|
||||
|
||||
// Capturar estado previo para detectar transición (idempotencia: MP puede
|
||||
// mandar el mismo webhook múltiples veces — solo notificamos en cambio real).
|
||||
const before = await prisma.payment.findUnique({
|
||||
where: { id: localPaymentId },
|
||||
select: { status: true, tenantId: true, amount: true },
|
||||
});
|
||||
const previousStatus = before?.status ?? null;
|
||||
|
||||
await prisma.payment.update({
|
||||
where: { id: localPaymentId },
|
||||
data: {
|
||||
status: payment.status || 'unknown',
|
||||
mpPaymentId: paymentId,
|
||||
paidAt: payment.status === 'approved' ? new Date() : null,
|
||||
},
|
||||
});
|
||||
|
||||
if (payment.status === 'approved') {
|
||||
try {
|
||||
await facturapiService.activarPaqueteTrasPago(localPaymentId);
|
||||
} catch (error: any) {
|
||||
console.error('[WEBHOOK] Error activando paquete de timbres:', error.message);
|
||||
throw error; // que MP reintente
|
||||
}
|
||||
// Auto-emisión de factura (fail-soft)
|
||||
await invoicingService.emitInvoiceIfApplicable(localPaymentId);
|
||||
} else if (
|
||||
(payment.status === 'rejected' || payment.status === 'cancelled') &&
|
||||
previousStatus !== payment.status &&
|
||||
before
|
||||
) {
|
||||
// Compra de paquete de timbres falló — el owner pagó y MP rechazó. Aviso fail-soft.
|
||||
const tenant = await prisma.tenant.findUnique({ where: { id: before.tenantId }, select: { nombre: true } });
|
||||
const ownerEmail = await getTenantOwnerEmail(before.tenantId);
|
||||
if (tenant && ownerEmail) {
|
||||
emailService.sendPaymentFailed(ownerEmail, {
|
||||
nombre: tenant.nombre,
|
||||
amount: Number(before.amount),
|
||||
plan: 'Paquete de timbres',
|
||||
}).catch(err => console.error('[EMAIL] timbres-pack failed notification:', err));
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof process.send === 'function') {
|
||||
const pay = await prisma.payment.findUnique({ where: { id: localPaymentId }, select: { tenantId: true } });
|
||||
if (pay) process.send({ type: 'invalidate-tenant-cache', tenantId: pay.tenantId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Detecta pagos de prorateo (upgrade). external_reference = `proration:${tenantId}:${subscriptionId}`
|
||||
if (payment.externalReference.startsWith('proration:')) {
|
||||
const parts = payment.externalReference.split(':');
|
||||
const tenantId = parts[1];
|
||||
const subscriptionId = parts[2];
|
||||
if (!tenantId || !subscriptionId) {
|
||||
console.warn('[WEBHOOK] external_reference de proration malformado:', payment.externalReference);
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentRecord = await subscriptionService.recordPayment({
|
||||
tenantId,
|
||||
subscriptionId,
|
||||
mpPaymentId: paymentId,
|
||||
amount: payment.transactionAmount || 0,
|
||||
status: payment.status || 'unknown',
|
||||
paymentMethod: `proration-${payment.paymentMethodId || 'unknown'}`,
|
||||
});
|
||||
|
||||
if (payment.status === 'approved') {
|
||||
try {
|
||||
await subscriptionService.applyApprovedUpgrade(subscriptionId);
|
||||
} catch (error: any) {
|
||||
// Re-lanza para que MP reintente el webhook
|
||||
console.error('[WEBHOOK] Error aplicando upgrade:', error.message);
|
||||
throw error;
|
||||
}
|
||||
// Auto-emisión de factura (fail-soft, no bloquea ni tira)
|
||||
await invoicingService.emitInvoiceIfApplicable(paymentRecord.id);
|
||||
}
|
||||
|
||||
if (typeof process.send === 'function') {
|
||||
process.send({ type: 'invalidate-tenant-cache', tenantId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Detecta pagos de addon. external_reference = `addon:{subscriptionAddonId}`
|
||||
if (payment.externalReference.startsWith('addon:')) {
|
||||
const addonId = payment.externalReference.replace('addon:', '');
|
||||
if (!addonId) {
|
||||
console.warn('[WEBHOOK] external_reference addon malformado:', payment.externalReference);
|
||||
return;
|
||||
}
|
||||
await handleAddonPayment(addonId, String(paymentId), payment.status || 'unknown');
|
||||
// Continue to normal flow only if we have a subscription to record against.
|
||||
// Addon payments are fully handled by handleAddonPayment; no further action needed.
|
||||
return;
|
||||
}
|
||||
|
||||
// Flujo normal: pago recurrente del preapproval
|
||||
const tenantId = payment.externalReference;
|
||||
const subscription = await prisma.subscription.findFirst({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
console.warn('[WEBHOOK] No subscription found for tenant:', tenantId);
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentRecord = await subscriptionService.recordPayment({
|
||||
tenantId,
|
||||
subscriptionId: subscription.id,
|
||||
mpPaymentId: paymentId,
|
||||
amount: payment.transactionAmount || 0,
|
||||
status: payment.status || 'unknown',
|
||||
paymentMethod: payment.paymentMethodId || 'unknown',
|
||||
});
|
||||
|
||||
if (payment.status === 'approved') {
|
||||
// Transición pending → authorized es el momento del *primer* pago aprobado.
|
||||
// En planes despacho con dualidad de precio (firstYear > renewal), bajamos
|
||||
// el monto recurrente del preapproval para que las renovaciones cobren el
|
||||
// precio de renewal. Se detecta comparando el monto cobrado contra lo que
|
||||
// `getPlanPrice(phase='firstYear')` devolvería para este plan.
|
||||
const esPrimerPago = subscription.status === 'pending';
|
||||
await prisma.subscription.update({
|
||||
where: { id: subscription.id },
|
||||
data: { status: 'authorized' },
|
||||
});
|
||||
subscriptionService.invalidateSubscriptionCache(tenantId);
|
||||
|
||||
if (
|
||||
esPrimerPago &&
|
||||
subscription.mpPreapprovalId &&
|
||||
isDespachoPaidPlan(subscription.plan) &&
|
||||
await despachoPlanTieneDualidadDb(subscription.plan)
|
||||
) {
|
||||
try {
|
||||
const renewalAmount = await subscriptionService.getPlanPrice(
|
||||
subscription.plan as any,
|
||||
subscription.frequency as any,
|
||||
'renewal',
|
||||
);
|
||||
await mpService.updatePreapprovalAmount(subscription.mpPreapprovalId, renewalAmount);
|
||||
await prisma.subscription.update({
|
||||
where: { id: subscription.id },
|
||||
data: { amount: renewalAmount },
|
||||
});
|
||||
subscriptionService.invalidateSubscriptionCache(tenantId);
|
||||
console.log(`[WEBHOOK] Preapproval ${subscription.mpPreapprovalId} bajado a $${renewalAmount} (renewal) tras primer pago`);
|
||||
} catch (err: any) {
|
||||
// No fallar el webhook — el cobro ya pasó. Logear para intervención manual.
|
||||
console.error(`[WEBHOOK] Error bajando preapproval a renewal:`, err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-emisión de factura (fail-soft, no bloquea ni tira)
|
||||
await invoicingService.emitInvoiceIfApplicable(paymentRecord.id);
|
||||
}
|
||||
|
||||
if (typeof process.send === 'function') {
|
||||
process.send({ type: 'invalidate-tenant-cache', tenantId });
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePreapprovalNotification(preapprovalId: string) {
|
||||
const preapproval = await mpService.getPreapproval(preapprovalId);
|
||||
|
||||
if (preapproval.status) {
|
||||
await subscriptionService.updateSubscriptionStatus(preapprovalId, preapproval.status);
|
||||
}
|
||||
|
||||
// Broadcast cache invalidation
|
||||
const subscription = await prisma.subscription.findFirst({
|
||||
where: { mpPreapprovalId: preapprovalId },
|
||||
});
|
||||
if (subscription && typeof process.send === 'function') {
|
||||
process.send({ type: 'invalidate-tenant-cache', tenantId: subscription.tenantId });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user