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:
@@ -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); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user