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

@@ -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); }
}