feat: facturación primer pago, fixes SAT/MP, autocompletado RFCs/conceptos

Backend:
- Notificación email al admin cuando llega primer pago aprobado (sin factura auto)
- Endpoints GET /pagos-sin-factura y POST /emitir-factura-pago para admin global
- Fix vinculación org Facturapi Horux 360 (69f23a5a242e0af47a41fa0d)
- Fix webhook MP: validación defensiva de x-signature header
- Fix autocompleto RFCs: eliminado filtro por contribuyenteId
- Fix autocompleto conceptos: eliminado filtro por contribuyenteId
- SAT fixes: anti-bot CSF scraper, request reuse, date range fix, stale job thresholds
- SAT sync request reuse across jobs para evitar agotar cuota diaria
- Typo fix MP_ACCESS_TOKEN en .env
- Trial invitations system backend

Frontend:
- Nueva página /admin/facturas-pendientes con tabla y emisión manual
- Métrica 'Facturas pendientes' en /clientes (clickable)
- Navegación onboarding FIEL/CSD corregida
- Sidebar themes sincronizados
- Fix SAT portal migration scraper (NetIQ)
- Trial invitation acceptance pages
This commit is contained in:
Horux Dev
2026-05-09 21:56:42 +00:00
parent b00b677c54
commit 9f11a0ba39
70 changed files with 2801 additions and 609 deletions

View File

@@ -3,7 +3,7 @@ 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);
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
if (!isAdmin) {
res.status(403).json({ message: 'Solo el administrador global puede consultar el audit log' });
}

View File

@@ -9,10 +9,15 @@ function effectiveTenantId(req: Request): string {
const ROLES_OWNER = new Set(['owner', 'cfo']);
const ROLES_SUPERVISORY = new Set(['owner', 'cfo', 'supervisor']);
const ROLES_ASIGNADOS = new Set(['owner', 'cfo', 'supervisor', 'auxiliar']);
const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']);
function isPlatformStaff(user: Request['user']): boolean {
return (user?.platformRoles || []).some(r => PLATFORM_SUPERSET.has(r));
}
export async function getContribuyentesStats(req: Request, res: Response, next: NextFunction) {
try {
if (!ROLES_OWNER.has(req.user!.role)) {
if (!isPlatformStaff(req.user) && !ROLES_OWNER.has(req.user!.role)) {
throw new AppError(403, 'Solo owner puede ver estas métricas');
}
const tenantId = effectiveTenantId(req);
@@ -27,7 +32,7 @@ export async function getContribuyentesStats(req: Request, res: Response, next:
export async function getMisAsignados(req: Request, res: Response, next: NextFunction) {
try {
if (!ROLES_ASIGNADOS.has(req.user!.role)) {
if (!isPlatformStaff(req.user) && !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;
@@ -47,7 +52,7 @@ export async function getMisAsignados(req: Request, res: Response, next: NextFun
export async function getEquipoStats(req: Request, res: Response, next: NextFunction) {
try {
if (!ROLES_SUPERVISORY.has(req.user!.role)) {
if (!isPlatformStaff(req.user) && !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;

View File

@@ -10,10 +10,10 @@ const signupSchema = z.object({
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'),
// plan y frequency ya no se escogen en el registro — todos empiezan con trial genérico.
// Se mantienen opcionales para compatibilidad backward con clientes antiguos.
plan: z.enum(['trial', 'mi_empresa', 'mi_empresa_plus', 'business_control', 'business_cloud']).optional(),
frequency: z.enum(['monthly', 'annual']).optional(),
}),
owner: z.object({
nombre: z.string().min(2, 'Nombre del owner requerido'),
@@ -42,16 +42,10 @@ export async function getMyPlan(req: Request, res: Response, next: NextFunction)
// 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.
//
// FIX: Si hay una subscription en trial con un plan específico (ej.
// business_control desde una TrialInvitation), respetamos ese plan
// para que el feature-gate y los límites funcionen correctamente.
const subscription = await prisma.subscription.findFirst({
where: { tenantId, status: { in: ['authorized', 'pending', 'paused', 'trial'] } },
orderBy: { createdAt: 'desc' },
@@ -61,6 +55,18 @@ export async function getMyPlan(req: Request, res: Response, next: NextFunction)
},
});
let currentPlan: string;
if (subscription?.status === 'trial' && subscription.plan && subscription.plan !== 'trial') {
currentPlan = subscription.plan;
} else 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.
return res.json({
plan: currentPlan,
dbMode: tenant.dbMode,

View File

@@ -518,8 +518,6 @@ export async function searchConceptos(req: Request, res: Response, next: NextFun
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) {
@@ -548,7 +546,6 @@ export async function searchConceptos(req: Request, res: Response, next: NextFun
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
@@ -664,40 +661,20 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
});
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}%`]));
}
// Búsqueda en el catálogo completo de RFCs. El contribuyente activo solo
// filtra CFDIs relacionados / PPD, no el autocompleto de RFCs — de lo
// contrario no se podría facturar a un cliente nuevo que nunca haya
// aparecido en un CFDI previo.
const { 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); }
@@ -787,3 +764,123 @@ export async function comprarPaquete(req: Request, res: Response, next: NextFunc
next(error);
}
}
// ── Admin global: pagos de suscripción sin factura ──
export async function getPagosSinFactura(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 consultar pagos sin factura' });
}
const payments = await prisma.payment.findMany({
where: {
status: 'approved',
facturapiInvoiceId: null,
kind: 'subscription',
amount: { gt: 0 },
},
include: {
subscription: { select: { plan: true, frequency: true } },
tenant: { select: { nombre: true, rfc: true } },
},
orderBy: { paidAt: 'desc' },
});
res.json(payments);
} catch (error) { next(error); }
}
export async function emitirFacturaPago(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 emitir facturas de pago' });
}
const paymentId = String(req.params.paymentId);
const payment = await prisma.payment.findUnique({
where: { id: paymentId },
include: { subscription: true },
});
if (!payment) {
return next(new AppError(404, 'Pago no encontrado'));
}
if (payment.status !== 'approved') {
return next(new AppError(400, 'Solo pagos aprobados pueden facturarse'));
}
if (payment.facturapiInvoiceId) {
return next(new AppError(400, 'Este pago ya tiene una factura emitida'));
}
// Reutilizar helpers del servicio de facturación
const { getEmitterTenant, getCustomerFromTenant } = await import('../services/payment/invoicing.service.js');
const emitter = await getEmitterTenant();
const amount = Number(payment.amount);
const plan = (payment as any).subscription?.plan || 'custom';
const frequency = (payment as any).subscription?.frequency || 'monthly';
const descFrecuencia = frequency === 'annual' ? 'anual' : 'mensual';
const description = `Suscripción ${plan} ${descFrecuencia} a Horux Despachos`;
const customer = await getCustomerFromTenant(payment.tenantId);
if (!customer) {
return next(new AppError(400, 'El tenant no tiene datos fiscales completos. No se puede facturar.'));
}
const tenantPref = await prisma.tenant.findUnique({
where: { id: payment.tenantId },
select: { factUsoCfdi: true },
});
const usoCfdi = customer ? (tenantPref?.factUsoCfdi || 'G03') : 'S01';
const formaPagoMap: Record<string, string> = {
master: '04', visa: '04', amex: '04',
debmaster: '28', debvisa: '28',
account_money: '03', bank_transfer: '03',
};
const normalizedMethod = (payment.paymentMethod || '').toLowerCase().replace(/^proration-/, '');
const formaPago = formaPagoMap[normalizedMethod] || '03';
const payload = {
customer: {
legalName: customer.legalName,
taxId: customer.taxId,
taxSystem: customer.taxSystem,
email: customer.email,
zip: customer.zip,
},
items: [
{
description,
productKey: '81112502',
unitKey: 'E48',
unitName: 'Servicio',
quantity: 1,
price: amount,
taxIncluded: true,
taxes: [{ type: 'IVA', rate: 0.16, factor: 'Tasa' }],
},
],
use: usoCfdi,
paymentForm: formaPago,
paymentMethod: 'PUE',
currency: 'MXN',
};
const invoice = await facturapiService.createInvoice(emitter.id, payload as any);
await prisma.payment.update({
where: { id: payment.id },
data: { facturapiInvoiceId: invoice.id },
});
auditFromReq(req, 'invoice.emitted_manual', {
entityType: 'Payment',
entityId: payment.id,
metadata: { facturapiInvoiceId: invoice.id, amount, plan, frequency },
});
res.json({ success: true, invoiceId: invoice.id, paymentId: payment.id });
} catch (error) { next(error); }
}

View File

@@ -134,7 +134,7 @@ export async function retry(req: Request, res: Response): Promise<void> {
*/
export async function cronInfo(req: Request, res: Response): Promise<void> {
try {
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) {
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId))) {
res.status(403).json({ error: 'Solo el administrador global puede ver info del cron' });
return;
}
@@ -151,7 +151,7 @@ export async function cronInfo(req: Request, res: Response): Promise<void> {
*/
export async function runCron(req: Request, res: Response): Promise<void> {
try {
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) {
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId))) {
res.status(403).json({ error: 'Solo el administrador global puede ejecutar el cron' });
return;
}

View File

@@ -5,7 +5,7 @@ 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);
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
if (!isAdmin) {
res.status(403).json({ message: 'Solo el administrador global puede gestionar suscripciones' });
}
@@ -19,7 +19,7 @@ async function requireGlobalAdmin(req: Request, res: Response): Promise<boolean>
*/
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);
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
if (!isAdmin) {
res.status(403).json({ message: 'Solo puedes gestionar la suscripción de tu propio tenant' });
}

View File

@@ -6,16 +6,24 @@ 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))) {
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId))) {
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 isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
if (!isAdmin) {
// Evita 403 en consola del frontend cuando componentes sin-gate hacen polling
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
return res.json([]);
}
const tenants = await tenantsService.getAllTenants();
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.json(tenants);
} catch (error) {
next(error);
@@ -24,7 +32,10 @@ export async function getAllTenants(req: Request, res: Response, next: NextFunct
export async function getTenant(req: Request, res: Response, next: NextFunction) {
try {
await requireGlobalAdmin(req);
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
if (!isAdmin) {
return res.status(404).json({ message: 'Cliente no encontrado' });
}
const tenant = await tenantsService.getTenantById(String(req.params.id));
if (!tenant) {
@@ -68,13 +79,15 @@ export async function updateTenant(req: Request, res: Response, next: NextFuncti
await requireGlobalAdmin(req);
const id = String(req.params.id);
const { nombre, rfc, plan, active } = req.body;
const { nombre, rfc, plan, active, amount, firstPaymentDueAt } = req.body;
const tenant = await tenantsService.updateTenant(id, {
nombre,
rfc,
plan,
active,
amount,
firstPaymentDueAt: firstPaymentDueAt || null,
});
res.json(tenant);

View File

@@ -0,0 +1,130 @@
import type { Request, Response, NextFunction } from 'express';
import * as trialInvitationService from '../services/trial-invitations.service.js';
import { isGlobalAdmin } from '../utils/global-admin.js';
import { prisma } from '../config/database.js';
async function requireGlobalAdmin(req: Request, res: Response): Promise<boolean> {
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
if (!isAdmin) {
res.status(403).json({ message: 'Solo el administrador global puede gestionar invitaciones de trial' });
}
return isAdmin;
}
export async function createInvitation(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requireGlobalAdmin(req, res))) return;
const { tenantId, plan, durationDays } = req.body;
if (!tenantId || !durationDays || durationDays < 1 || durationDays > 365) {
return res.status(400).json({ message: 'tenantId y durationDays (1-365) son requeridos' });
}
const invitation = await trialInvitationService.createInvitation({
tenantId,
invitedByUserId: req.user!.userId,
plan: plan || 'business_control',
durationDays: parseInt(durationDays, 10),
});
res.status(201).json(invitation);
} catch (error: any) {
if (error.message?.includes('ya tiene') || error.message?.includes('no encontrado')) {
return res.status(400).json({ message: error.message });
}
next(error);
}
}
export async function getAllInvitations(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requireGlobalAdmin(req, res))) return;
const { tenantId, status } = req.query;
const invitations = await trialInvitationService.getInvitations({
tenantId: typeof tenantId === 'string' ? tenantId : undefined,
status: typeof status === 'string' ? status : undefined,
});
res.json(invitations);
} catch (error) {
next(error);
}
}
export async function getMyPendingInvitation(req: Request, res: Response, next: NextFunction) {
try {
const invitation = await trialInvitationService.getPendingInvitationForTenant(req.user!.tenantId);
res.json(invitation);
} catch (error) {
next(error);
}
}
export async function acceptInvitation(req: Request, res: Response, next: NextFunction) {
try {
const token = typeof req.params.token === 'string' ? req.params.token : '';
if (!token) {
return res.status(400).json({ message: 'Token requerido' });
}
const result = await trialInvitationService.acceptInvitation(token, req.user!.userId);
res.json(result);
} catch (error: any) {
if (
error.message?.includes('no encontrada') ||
error.message?.includes('ya ') ||
error.message?.includes('expirado') ||
error.message?.includes('Solo el dueño')
) {
return res.status(400).json({ message: error.message });
}
next(error);
}
}
export async function cancelInvitation(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requireGlobalAdmin(req, res))) return;
const id = typeof req.params.id === 'string' ? req.params.id : '';
const result = await trialInvitationService.cancelInvitation(id);
res.json(result);
} catch (error: any) {
if (error.message?.includes('no encontrada') || error.message?.includes('Solo se pueden')) {
return res.status(400).json({ message: error.message });
}
next(error);
}
}
export async function getInvitationByToken(req: Request, res: Response, next: NextFunction) {
try {
const token = typeof req.params.token === 'string' ? req.params.token : '';
const invitation = await prisma.trialInvitation.findUnique({
where: { token },
});
if (!invitation) {
return res.status(404).json({ message: 'Invitación no encontrada' });
}
const tenant = await prisma.tenant.findUnique({
where: { id: invitation.tenantId },
select: { nombre: true, rfc: true },
});
// No exponer datos sensibles
res.json({
id: invitation.id,
tenantId: invitation.tenantId,
plan: invitation.plan,
durationDays: invitation.durationDays,
status: invitation.status,
expiresAt: invitation.expiresAt,
tenant,
});
} catch (error) {
next(error);
}
}

View File

@@ -27,7 +27,7 @@ const updateGlobalSchema = z.object({
});
async function isGlobalAdmin(req: Request): Promise<boolean> {
return checkGlobalAdmin(req.user!.tenantId, req.user!.role);
return checkGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
}
export async function getUsuarios(req: Request, res: Response, next: NextFunction) {