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

@@ -19,7 +19,7 @@ export async function getDashboardMetrics() {
prisma.subscription.count({ where: { status: 'cancelled' } }),
prisma.tenant.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
prisma.tenant.findMany({
where: { dbMode: 'BYO', connectorTunnelHostname: { not: null } },
where: { connectorTunnelHostname: { not: null } },
select: { id: true, nombre: true, rfc: true, connectorLastSeen: true, connectorVersion: true },
}),
prisma.payment.aggregate({

View File

@@ -40,7 +40,7 @@ export async function provisionConnector(tenantId: string): Promise<{
await prisma.tenant.update({
where: { id: tenantId },
data: {
dbMode: 'BYO',
// El conector es una feature de respaldo; el tenant siempre permanece MANAGED
connectorTokenEnc: tokenEncoded,
connectorTunnelHostname: hostname,
},
@@ -92,7 +92,7 @@ export async function verifyConnectorToken(token: string): Promise<string | null
// Find tenant by trying to decrypt stored tokens.
// This is O(N) — for production, use a hashed token lookup table.
const tenants = await prisma.tenant.findMany({
where: { dbMode: 'BYO', connectorTokenEnc: { not: null } },
where: { connectorTokenEnc: { not: null } },
select: { id: true, connectorTokenEnc: true },
});
@@ -132,7 +132,7 @@ export async function getConnectorStatus(tenantId: string): Promise<{
select: { dbMode: true, connectorTunnelHostname: true, connectorLastSeen: true, connectorVersion: true },
});
if (!tenant || tenant.dbMode !== 'BYO' || !tenant.connectorTunnelHostname) {
if (!tenant || !tenant.connectorTunnelHostname) {
return { configured: false, status: 'not_configured' };
}

View File

@@ -69,7 +69,11 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
// caso donde el click sintético no dispara el handler del SAT. Si algún
// ambiente necesita ver el browser (debug), setear SAT_HEADLESS=false.
const headless = process.env.SAT_HEADLESS !== 'false';
const browser = await chromium.launch({ headless });
const browser = await chromium.launch({
headless,
args: ['--disable-blink-features=AutomationControlled'],
ignoreDefaultArgs: ['--enable-automation'],
});
try {
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 3 minutos')), PROCESS_TIMEOUT),
@@ -171,6 +175,28 @@ async function matchRegimenesToCatalogo(regimenesCsf: RegimenCsf[]): Promise<num
return [...new Set(ids)];
}
/**
* Límites de longitud en el schema Prisma de Tenant (defensivo para
* evitar P2000 cuando el SAT devuelve valores más largos de lo esperado).
*/
const TENANT_FIELD_LIMITS: Record<string, number> = {
codigoPostal: 5,
calle: 255,
numExterior: 20,
numInterior: 20,
colonia: 255,
ciudad: 100,
municipio: 100,
estado: 100,
telefono: 20,
};
function truncateToLimit(key: string, value: string): string {
const limit = TENANT_FIELD_LIMITS[key];
if (!limit || value.length <= limit) return value;
return value.slice(0, limit);
}
/**
* Aplica el domicilio + regímenes activos de la CSF al tenant. Idempotente:
* se puede llamar N veces, el resultado final refleja el último CSF.
@@ -183,7 +209,9 @@ export async function sincronizarDatosFiscales(
const fields = domicilioToTenantFields(csf.domicilio);
const updates: Record<string, string> = {};
for (const [k, v] of Object.entries(fields)) {
if (v && v.trim().length > 0) updates[k] = v.trim();
if (v && v.trim().length > 0) {
updates[k] = truncateToLimit(k, v.trim());
}
}
if (Object.keys(updates).length > 0) {

View File

@@ -26,7 +26,7 @@ export async function signupDespacho(data: DespachoSignupRequest) {
plan: 'trial',
databaseName: databaseName,
verticalProfile: despacho.verticalProfile as any,
dbMode: (despacho.plan === 'business_control' ? 'BYO' : 'MANAGED') as any,
dbMode: 'MANAGED',
dbSchemaVersion: 0,
trialEndsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
codigoPostal: despacho.codigoPostal,
@@ -91,40 +91,9 @@ export async function signupDespacho(data: DespachoSignupRequest) {
email: result.user.email,
}).catch(err => console.error('[Signup] Welcome email failed:', err));
// If paid plan, create MP checkout via subscriptionService.subscribe()
// que también crea la fila Subscription en BD (clave para que el webhook
// pueda aplicar la dualidad firstYear→renewal tras el primer cobro aprobado).
let paymentUrl: string | undefined;
if (data.despacho.plan && data.despacho.plan !== 'trial') {
try {
const subscriptionService = await import('./payment/subscription.service.js');
const result2 = await subscriptionService.subscribe({
tenantId: result.tenant.id,
plan: data.despacho.plan as any,
// mi_empresa(+) acepta monthly/annual; los demás solo annual
// — el subscribe valida y rechaza monthly cuando no aplica.
frequency: data.despacho.frequency || 'annual',
payerEmail: owner.email,
});
paymentUrl = result2.paymentUrl;
} catch (err: any) {
// Rollback: delete tenant + user since payment couldn't be set up
await prisma.tenantMembership.deleteMany({ where: { tenantId: result.tenant.id } }).catch(() => {});
await prisma.refreshToken.deleteMany({ where: { userId: result.user.id } }).catch(() => {});
await prisma.tenant.delete({ where: { id: result.tenant.id } }).catch(() => {});
await prisma.user.delete({ where: { id: result.user.id } }).catch(() => {});
const msg = err?.message || '';
if (msg.includes('MercadoPago no está configurado') || msg.includes('Unauthorized access')) {
throw new Error('No se pudo procesar el cobro. Verifica que el sistema de pagos esté configurado o selecciona el plan Trial.');
}
throw new Error(msg || 'No se pudo procesar el cobro.');
}
}
return {
accessToken,
refreshToken,
paymentUrl,
user: {
id: result.user.id,
email: result.user.email,

View File

@@ -68,6 +68,18 @@ export const emailService = {
await sendEmail(env.ADMIN_EMAIL, `Nuevo cliente: ${data.clienteNombre} (${data.clienteRfc})`, newClientAdminEmail(data));
},
sendPrimerPagoFacturar: async (data: {
clienteNombre: string;
clienteRfc: string;
amount: number;
plan: string;
paymentDate: string;
paymentId: string;
}) => {
const { primerPagoFacturarEmail } = await import('./templates/primer-pago-facturar.js');
await sendEmail(env.ADMIN_EMAIL, `Factura pendiente: primer pago de ${data.clienteNombre}`, primerPagoFacturarEmail(data));
},
sendWeeklyUpdate: async (to: string, data: import('./templates/weekly-update.js').WeeklyUpdateData) => {
const { weeklyUpdateEmail } = await import('./templates/weekly-update.js');
await sendEmail(to, `Actualización semanal — ${data.empresa}`, weeklyUpdateEmail(data));
@@ -91,6 +103,17 @@ export const emailService = {
await sendEmail(to, `Prueba finalizada — ${data.despachoNombre}`, trialExpiredEmail(data));
},
sendTrialInvitation: async (to: string, data: {
despachoNombre: string;
plan: string;
durationDays: number;
acceptUrl: string;
expiresAt: string;
}) => {
const { trialInvitationEmail } = await import('./templates/trial-invitation.js');
await sendEmail(to, `Invitación especial — Prueba ${data.plan === 'business_control' ? 'Business Control' : data.plan}`, trialInvitationEmail(data));
},
/**
* Notifica la subida de una declaración o documento extra al despacho.
* `recipients` debe venir deduplicado por el caller. El subject se

View File

@@ -0,0 +1,55 @@
import { baseTemplate, heading, BRAND_COLORS as C } from './base.js';
function escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function row(label: string, value: string, isLast = false) {
const border = isLast ? '' : `border-bottom:1px solid ${C.border};`;
return `<tr>
<td style="padding:10px 16px;${border}font-weight:500;color:${C.textMuted};width:40%;font-size:13px;">${label}</td>
<td style="padding:10px 16px;${border}color:${C.textPrimary};font-size:14px;">${value}</td>
</tr>`;
}
export function primerPagoFacturarEmail(data: {
clienteNombre: string;
clienteRfc: string;
amount: number;
plan: string;
paymentDate: string;
paymentId: string;
}): string {
const formattedAmount = new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(data.amount);
return baseTemplate(`
${heading('Primer pago aprobado — factura pendiente')}
<p style="color:${C.textPrimary};margin:0 0 24px;">
El cliente <strong>${escapeHtml(data.clienteNombre)}</strong> realizó su primer pago exitosamente.
Como es el primer pago, la factura <strong>debe emitirse manualmente</strong>.
</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom:24px;border:1px solid ${C.border};border-radius:8px;overflow:hidden;">
${row('Cliente', `<strong>${escapeHtml(data.clienteNombre)}</strong>`)}
${row('RFC', `<span style="font-family:monospace;">${escapeHtml(data.clienteRfc)}</span>`)}
${row('Plan', escapeHtml(data.plan))}
${row('Monto', `<strong>${formattedAmount}</strong>`)}
${row('Fecha de pago', data.paymentDate, true)}
</table>
<div style="text-align:center;margin:24px 0;">
<a href="https://horuxfin.com/admin/facturas-pendientes" style="display:inline-block;background-color:${C.primary};color:#ffffff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;font-size:14px;">
Ver facturas pendientes
</a>
</div>
<div style="background-color:#fef9c3;border-left:4px solid #eab308;border-radius:8px;padding:12px 16px;margin:0 0 16px;">
<p style="margin:0;color:#854d0e;font-size:13px;">
<strong> Nota:</strong> Los pagos subsecuentes se facturarán automáticamente. Solo el primer pago requiere emisión manual.
</p>
</div>
`);
}

View File

@@ -0,0 +1,63 @@
export interface TrialInvitationData {
despachoNombre: string;
plan: string;
durationDays: number;
acceptUrl: string;
expiresAt: string;
}
export function trialInvitationEmail(data: TrialInvitationData): string {
const planDisplay = data.plan === 'business_control' ? 'Business Control' : data.plan;
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #1e40af; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 8px 8px; }
.button { display: inline-block; background: #1e40af; color: white; padding: 14px 28px; text-decoration: none; border-radius: 6px; font-weight: bold; margin: 20px 0; }
.highlight { background: #dbeafe; padding: 15px; border-radius: 6px; margin: 15px 0; }
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎉 Invitación especial para ${data.despachoNombre}</h1>
</div>
<div class="content">
<p>Hola,</p>
<p>Has recibido una invitación exclusiva para probar <strong>${planDisplay}</strong> durante <strong>${data.durationDays} días</strong> completamente gratis.</p>
<div class="highlight">
<strong>¿Qué incluye?</strong>
<ul>
<li>Hasta 100 RFCs</li>
<li>Usuarios ilimitados</li>
<li>API de integración</li>
<li>SAT incremental</li>
<li>Todas las funciones de Business Control</li>
</ul>
</div>
<p style="text-align: center;">
<a href="${data.acceptUrl}" class="button">Aceptar invitación</a>
</p>
<p><strong>Importante:</strong> Esta invitación expira el <strong>${data.expiresAt}</strong>. Una vez que aceptes, tendrás ${data.durationDays} días para probar todas las funciones sin compromiso.</p>
<p>Al finalizar el periodo de prueba, podrás contratar el plan para continuar con el servicio.</p>
<p>Si tienes alguna duda, contacta a nuestro equipo de soporte.</p>
</div>
<div class="footer">
<p>Horux Despachos — Simplificando la contabilidad</p>
</div>
</div>
</body>
</html>
`.trim();
}

View File

@@ -80,7 +80,7 @@ async function isFirstApprovedPayment(
* Busca el tenant emisor (Horux 360) con su organización Facturapi configurada.
* Si falta, lanza error — el admin global tiene que crear la organización primero.
*/
async function getEmitterTenant() {
export async function getEmitterTenant() {
const tenant = await prisma.tenant.findUnique({
where: { rfc: GLOBAL_ADMIN_RFC },
select: {
@@ -125,7 +125,7 @@ interface CustomerData {
* Retorna `null` si falta cualquier dato requerido — el caller debe caer
* a público en general en ese caso.
*/
async function getCustomerFromTenant(payerTenantId: string): Promise<CustomerData | null> {
export async function getCustomerFromTenant(payerTenantId: string): Promise<CustomerData | null> {
const tenant = await prisma.tenant.findUnique({
where: { id: payerTenantId },
select: {
@@ -179,7 +179,7 @@ async function getCustomerFromTenant(payerTenantId: string): Promise<CustomerDat
* Construye el payload para Facturapi. Acepta customer real (datos del cliente)
* o fallback a público en general si `customer` es null.
*/
function buildInvoicePayload(params: {
export function buildInvoicePayload(params: {
amount: number;
description: string; // Texto del concepto — varía por kind (subscription vs timbres)
emitterCp: string;
@@ -272,6 +272,22 @@ export async function emitInvoiceIfApplicable(paymentId: string): Promise<void>
// Gate 4: primer pago del tenant → manual
if (await isFirstApprovedPayment(payment.tenantId, payment.id)) {
console.log(`[Invoicing] Payment ${paymentId} es el PRIMER pago aprobado del tenant ${payment.tenantId}, skip (factura manual)`);
// Notificar al admin global para que emita la factura manualmente
const tenant = await prisma.tenant.findUnique({
where: { id: payment.tenantId },
select: { nombre: true, rfc: true },
});
if (tenant) {
const { emailService } = await import('../email/email.service.js');
emailService.sendPrimerPagoFacturar({
clienteNombre: tenant.nombre,
clienteRfc: tenant.rfc || '',
amount,
plan: payment.subscription?.plan || 'custom',
paymentDate: payment.paidAt?.toISOString() || new Date().toISOString(),
paymentId: payment.id,
}).catch(err => console.error('[Invoicing] Error enviando notificación de primer pago:', err));
}
return;
}

View File

@@ -323,6 +323,7 @@ export function verifyWebhookSignature(
const parts: Record<string, string> = {};
for (const part of xSignature.split(',')) {
const [key, value] = part.split('=');
if (!key || value === undefined) continue;
parts[key.trim()] = value.trim();
}

View File

@@ -19,7 +19,13 @@ export async function loginSatCsf(
keyPath: string,
password: string,
): Promise<CsfLoginSession> {
const context = await browser.newContext({ acceptDownloads: true });
const context = await browser.newContext({
acceptDownloads: true,
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
});
await context.addInitScript(() => {
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
});
const publicPage = await context.newPage();
publicPage.setDefaultTimeout(60_000);
@@ -66,12 +72,34 @@ export async function loginSatCsf(
await fileInputs.nth(0).setInputFiles(cerPath);
await fileInputs.nth(1).setInputFiles(keyPath);
// Esperar a que el cert async parsing termine (RFC auto-populado por SAT).
try {
await loginPage.waitForFunction(
() => {
const rfc = document.getElementById('rfc') as HTMLInputElement | null;
return rfc !== null && rfc.value.length >= 12;
},
null,
{ timeout: 30_000 },
);
} catch (err) {
const html = await loginPage.content();
const { writeFileSync, mkdirSync } = await import('node:fs');
const debugDir = '/tmp/horux-csf-debug';
try { mkdirSync(debugDir, { recursive: true }); } catch { /* ok */ }
writeFileSync(`${debugDir}/04c-rfc-timeout-html.html`, html);
throw err;
}
// Password + Enviar
await loginPage.locator('input[type="password"]').first().fill(password);
await loginPage.locator('button:has-text("Enviar"), input[value="Enviar"]').first().click();
await loginPage.locator('button:has-text("Enviar"), input[value="Enviar"]').first().click({ noWaitAfter: true });
// Esperar a que salga del dominio de login
await loginPage.waitForURL(url => !url.toString().includes('loginda.siat.sat.gob.mx'), { timeout: 60_000 });
// Esperar a que salga del dominio de login y aterrice en el portal SAT
await loginPage.waitForURL(
url => url.toString().includes('wwwmat.sat.gob.mx/operacion/'),
{ timeout: 60_000 },
);
await loginPage.waitForLoadState('networkidle').catch(() => undefined);
await loginPage.waitForTimeout(2000);

View File

@@ -85,12 +85,29 @@ function extractLabels(text: string): Map<string, string> {
const result = new Map<string, string>();
const labelAlternation = LABELS.map(escapeRegex).join('|');
const re = new RegExp(
`(${labelAlternation})\\s*:\\s*([\\s\\S]*?)(?=\\s+(?:${labelAlternation})\\s*:|\\n?\\s*(?:Datos del domicilio registrado|Actividades Económicas|Regímenes|Obligaciones|Cadena Original|Sus datos personales)\\b|\\n\\s*--\\s*\\d+\\s+of\\s+\\d+|$)`,
`(${labelAlternation})\\s*:\\s*([\\s\\S]*?)(?=\\s*(?:${labelAlternation})\\s*:|\\n?\\s*(?:Datos del domicilio registrado|Actividades Económicas|Regímenes|Obligaciones|Cadena Original|Sus datos personales)\\b|\\n\\s*--\\s*\\d+\\s+of\\s+\\d+|$)`,
'g',
);
for (const match of text.matchAll(re)) {
const label = match[1];
const value = match[2].replace(/\s+/g, ' ').trim();
let value = match[2].replace(/\s+/g, ' ').trim();
// Defensa: el SAT a veces pone etiquetas consecutivas sin valor intermedio
// (ej. "Número Interior:\nNombre de la Colonia: X"). El regex lazy captura
// de más y el valor termina incluyendo el nombre de la siguiente etiqueta.
// Limpiamos cualquier prefijo de otra etiqueta del SAT que haya quedado al
// inicio del valor.
for (const otherLabel of LABELS) {
if (otherLabel === label) continue;
const prefix = otherLabel + ':';
const lowerValue = value.toLowerCase();
const lowerPrefix = prefix.toLowerCase();
if (lowerValue.startsWith(lowerPrefix)) {
value = value.slice(prefix.length).trim();
break;
}
}
if (!result.has(label)) result.set(label, value);
}
return result;

View File

@@ -111,7 +111,12 @@ export function extractXmlsFromZip(zipBase64: string): ExtractedXml[] {
for (const entry of entries) {
if (entry.entryName.toLowerCase().endsWith('.xml')) {
const content = entry.getData().toString('utf-8');
let content = entry.getData().toString('utf-8');
// Remover UTF-8 BOM si existe — fast-xml-parser no lo maneja y devuelve
// result.Comprobante = undefined, dejando el CFDI sin parsear.
if (content.charCodeAt(0) === 0xFEFF) {
content = content.slice(1);
}
xmlFiles.push({
filename: entry.entryName,
content,
@@ -140,8 +145,13 @@ export function extractXmlsFromZip(zipBase64: string): ExtractedXml[] {
*/
function parseCfdiDate(str: string | null | undefined): Date {
if (!str) return new Date(0);
const s = String(str).trim();
let s = String(str).trim();
if (!s) return new Date(0);
// Defensa: el SAT a veces concatena múltiples fechas con '|' (ej. en
// FechaTimbrado duplicado). Tomamos solo la primera fecha válida.
if (s.includes('|')) {
s = s.split('|')[0].trim();
}
const hasTz = /[Zz]|[+-]\d{2}:?\d{2}$/.test(s);
return new Date(hasTz ? s : s + 'Z');
}
@@ -155,18 +165,28 @@ function pf(val: any): number {
return parseFloat(val || '0') || 0;
}
/**
* Extrae el UUID del TimbreFiscalDigital
*/
function getFirstTimbre(comprobante: any): any {
const timbre = comprobante.Complemento?.TimbreFiscalDigital;
if (!timbre) return null;
return Array.isArray(timbre) ? timbre[0] : timbre;
}
/**
* Extrae el UUID del TimbreFiscalDigital
*/
function extractUuid(comprobante: any): string {
return comprobante.Complemento?.TimbreFiscalDigital?.['@_UUID'] || '';
const timbre = getFirstTimbre(comprobante);
return timbre?.['@_UUID'] || '';
}
/**
* Extrae datos del timbre: fecha cert SAT y PAC
*/
function extractTimbreData(comprobante: any): { fechaCertSat: Date | null; pac: string | null } {
const timbre = comprobante.Complemento?.TimbreFiscalDigital;
const timbre = getFirstTimbre(comprobante);
if (!timbre) return { fechaCertSat: null, pac: null };
return {
@@ -322,7 +342,7 @@ function extractPagos(comprobante: any): {
}
}
result.fechaPagoP = fechas.length > 0 ? fechas.join('|') : null;
result.fechaPagoP = fechas.length > 0 ? parseCfdiDate(fechas[0]).toISOString() : null;
result.numParcialidad = parcialidades.length > 0 ? parcialidades.join('|') : null;
result.uuidRelacionado = uuids.length > 0 ? uuids.join('|') : null;
result.saldoInsoluto = saldos.length > 0 ? saldos.join('|') : null;
@@ -370,9 +390,9 @@ function extractNomina(comprobante: any): {
const nomina = complemento.Nomina;
if (!nomina) return result;
result.fechaPago = nomina['@_FechaPago'] || null;
result.fechaInicialPago = nomina['@_FechaInicialPago'] || null;
result.fechaFinalPago = nomina['@_FechaFinalPago'] || null;
result.fechaPago = nomina['@_FechaPago'] ? parseCfdiDate(nomina['@_FechaPago']).toISOString() : null;
result.fechaInicialPago = nomina['@_FechaInicialPago'] ? parseCfdiDate(nomina['@_FechaInicialPago']).toISOString() : null;
result.fechaFinalPago = nomina['@_FechaFinalPago'] ? parseCfdiDate(nomina['@_FechaFinalPago']).toISOString() : null;
result.numDiasPagados = pf(nomina['@_NumDiasPagados']);
result.totalPercepciones = pf(nomina['@_TotalPercepciones']);
result.totalDeducciones = pf(nomina['@_TotalDeducciones']);

View File

@@ -547,9 +547,35 @@ async function requestAndDownload(
// Intentar reusar requestId previo del mismo job/kindKey (caso retry)
const jobRow = await prisma.satSyncJob.findUnique({
where: { id: jobId },
select: { satRequestIds: true },
select: { satRequestIds: true, tenantId: true, contribuyenteId: true, dateFrom: true, dateTo: true },
});
const existingMap = (jobRow?.satRequestIds as Record<string, string> | null) || {};
let existingMap = (jobRow?.satRequestIds as Record<string, string> | null) || {};
// Si no existe en el job actual, buscar en el job más reciente del mismo tenant/contribuyente
// SOLO si el rango de fechas es idéntico (mismo dateFrom/dateTo).
if (!existingMap[kindKey]) {
const previousJob = await prisma.satSyncJob.findFirst({
where: {
tenantId: jobRow?.tenantId,
contribuyenteId: jobRow?.contribuyenteId ?? null,
id: { not: jobId },
dateFrom: jobRow?.dateFrom,
dateTo: jobRow?.dateTo,
},
orderBy: { createdAt: 'desc' },
select: { satRequestIds: true },
});
if (previousJob?.satRequestIds) {
const prevMap = previousJob.satRequestIds as Record<string, string>;
if (prevMap[kindKey]) {
console.log(`[SAT] Reutilizando requestId de job previo (${label}): ${prevMap[kindKey]}`);
// Copiar al job actual para futuros usos
await persistSatRequestId(jobId, kindKey, prevMap[kindKey]);
existingMap = { ...existingMap, [kindKey]: prevMap[kindKey] };
}
}
}
let requestId: string | null = existingMap[kindKey] || null;
let verifyResult: Awaited<ReturnType<typeof verifySatRequest>> | undefined;
@@ -651,7 +677,8 @@ async function processDateRange(
jobId: string,
fechaInicio: Date,
fechaFin: Date,
tipoCfdi: CfdiSyncType
tipoCfdi: CfdiSyncType,
skipJobUpdate = false
): Promise<{ found: number; downloaded: number; inserted: number; updated: number }> {
let totalFound = 0;
let totalDownloaded = 0;
@@ -678,12 +705,14 @@ async function processDateRange(
console.error(`[SAT] Error en XMLs ${tipoCfdi}: ${error.message}`);
}
await updateJobProgress(jobId, {
cfdisFound: totalFound,
cfdisDownloaded: totalDownloaded,
cfdisInserted: totalInserted,
cfdisUpdated: totalUpdated,
});
if (!skipJobUpdate) {
await updateJobProgress(jobId, {
cfdisFound: totalFound,
cfdisDownloaded: totalDownloaded,
cfdisInserted: totalInserted,
cfdisUpdated: totalUpdated,
});
}
return {
found: totalFound,
@@ -787,7 +816,9 @@ async function processInitialSync(
customDateTo?: Date
): Promise<void> {
const ahora = new Date();
const inicioHistorico = customDateFrom || new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), 1);
// Exactamente 6 años atrás desde hoy (mismo día del mes), no inicio de mes.
// El SAT rechaza "mayor a 6 años" si usamos el día 1 del mes hace 6 años.
const inicioHistorico = customDateFrom || new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), ahora.getDate());
const fechaFin = customDateTo || ahora;
// Paso 1: Sondeo — determinar tamaño de bloque para XMLs
@@ -802,13 +833,29 @@ async function processInitialSync(
let totalInserted = 0;
let totalUpdated = 0;
const totalSteps = xmlChunks.length * 2 + metaChunks.length * 2; // emitidos + recibidos por cada chunk
let completedSteps = 0;
// Helper para actualizar progreso acumulado
async function reportProgress() {
completedSteps++;
const progressPercent = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0;
await updateJobProgress(jobId, {
cfdisFound: totalFound,
cfdisDownloaded: totalDownloaded,
cfdisInserted: totalInserted,
cfdisUpdated: totalUpdated,
progressPercent,
});
}
// Paso 2: Descargar XMLs de vigentes (bloques de 3/6 meses)
for (let i = 0; i < xmlChunks.length; i++) {
const { start, end } = xmlChunks[i];
console.log(`[SAT] XML bloque ${i + 1}/${xmlChunks.length}: ${start.toISOString().slice(0, 10)}${end.toISOString().slice(0, 10)}`);
try {
const emitidos = await processDateRange(ctx, jobId, start, end, 'emitidos');
const emitidos = await processDateRange(ctx, jobId, start, end, 'emitidos', true);
totalFound += emitidos.found;
totalDownloaded += emitidos.downloaded;
totalInserted += emitidos.inserted;
@@ -816,9 +863,10 @@ async function processInitialSync(
} catch (error: any) {
console.error(`[SAT] Error emitidos XML bloque ${i + 1}:`, error.message);
}
await reportProgress();
try {
const recibidos = await processDateRange(ctx, jobId, start, end, 'recibidos');
const recibidos = await processDateRange(ctx, jobId, start, end, 'recibidos', true);
totalFound += recibidos.found;
totalDownloaded += recibidos.downloaded;
totalInserted += recibidos.inserted;
@@ -826,6 +874,7 @@ async function processInitialSync(
} catch (error: any) {
console.error(`[SAT] Error recibidos XML bloque ${i + 1}:`, error.message);
}
await reportProgress();
await new Promise(resolve => setTimeout(resolve, 5000));
}
@@ -842,6 +891,7 @@ async function processInitialSync(
} catch (error: any) {
console.error(`[SAT] Error metadata emitidos bloque ${i + 1}:`, error.message);
}
await reportProgress();
try {
const { inserted, updated } = await processMetadataRange(ctx, jobId, start, end, 'recibidos');
@@ -850,6 +900,7 @@ async function processInitialSync(
} catch (error: any) {
console.error(`[SAT] Error metadata recibidos bloque ${i + 1}:`, error.message);
}
await reportProgress();
await new Promise(resolve => setTimeout(resolve, 5000));
}
@@ -859,6 +910,7 @@ async function processInitialSync(
cfdisDownloaded: totalDownloaded,
cfdisInserted: totalInserted,
cfdisUpdated: totalUpdated,
progressPercent: 100,
});
}

View File

@@ -13,6 +13,13 @@ export interface SweepResult {
}>;
}
const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = {
initial: 8,
daily: 4,
incremental: 2,
custom: 4,
};
/**
* Watchdog para jobs `sat_sync_jobs` stale.
*
@@ -22,35 +29,45 @@ export interface SweepResult {
* (dev, caída, reinicio largo) el job queda colgado y bloquea el
* lock para nuevos syncs del mismo (tenant, contribuyente).
*
* 2. `running` con `startedAt` > runningHours atrás. Un sync inicial
* típico termina en <2h; si lleva >runningHours es casi seguro
* huérfano de un proceso que murió. La solicitud SAT ya expiró.
* 2. `running` con `startedAt` > runningHours atrás. Thresholds difieren
* por tipo: initial (8h) porque un bootstrap de 6 años puede tardar
* varias horas; daily (4h); incremental (2h) porque es ventana corta.
* Si lleva >threshold es casi seguro huérfano de un proceso que murió.
*
* Marca ambos como `failed` con `errorMessage` descriptivo. Idempotente
* (volver a correrlo no reabre los ya-marcados-failed).
*
* - `apply=false` (default): dry-run, no toca BD.
* - `pendingHours`/`runningHours`: thresholds (default 12h / 4h).
* - `pendingHours`: threshold pending (default 12h).
* - `runningHours`: fallback threshold running si no se usa por-tipo (default 4h).
* - `runningHoursByType`: override por tipo de sync.
*/
export async function sweepStaleSatJobs(params: {
apply: boolean;
pendingHours?: number;
runningHours?: number;
runningHoursByType?: Record<string, number>;
} = { apply: false }): Promise<SweepResult> {
const pendingHours = params.pendingHours ?? 12;
const runningHours = params.runningHours ?? 4;
const runningHoursByType = { ...DEFAULT_RUNNING_HOURS_BY_TYPE, ...(params.runningHoursByType || {}) };
const now = new Date();
const pendingCutoff = new Date(now.getTime() - pendingHours * 3600 * 1000);
const runningCutoff = new Date(now.getTime() - runningHours * 3600 * 1000);
const stalePending = await prisma.satSyncJob.findMany({
where: { status: 'pending', nextRetryAt: { lt: pendingCutoff } },
orderBy: { createdAt: 'asc' },
});
const staleRunning = await prisma.satSyncJob.findMany({
where: { status: 'running', startedAt: { lt: runningCutoff } },
// running: evaluar por tipo usando thresholds distintos
const allRunning = await prisma.satSyncJob.findMany({
where: { status: 'running' },
orderBy: { createdAt: 'asc' },
});
const staleRunning = allRunning.filter(j => {
const thresholdHours = runningHoursByType[j.type] ?? params.runningHours ?? 4;
const cutoff = new Date(now.getTime() - thresholdHours * 3600 * 1000);
return (j.startedAt ?? j.createdAt) < cutoff;
});
const result: SweepResult = {
pendingFound: stalePending.length,
@@ -83,12 +100,13 @@ export async function sweepStaleSatJobs(params: {
result.pendingMarked++;
}
for (const j of staleRunning) {
const thresholdHours = runningHoursByType[j.type] ?? params.runningHours ?? 4;
await prisma.satSyncJob.update({
where: { id: j.id },
data: {
status: 'failed',
completedAt: now,
errorMessage: `Abandoned by watchdog: running with startedAt ${j.startedAt?.toISOString()} > ${runningHours}h (process crash / orphan). SAT request is lost; re-launch manually.`,
errorMessage: `Abandoned by watchdog: running ${j.type} with startedAt ${j.startedAt?.toISOString()} > ${thresholdHours}h (process crash / orphan). SAT request is lost; re-launch manually.`,
},
});
result.runningMarked++;

View File

@@ -17,7 +17,17 @@ export async function getAllTenants() {
createdAt: true,
_count: {
select: { memberships: { where: { active: true } } as any }
}
},
subscriptions: {
orderBy: { createdAt: 'desc' },
take: 1,
select: {
id: true,
amount: true,
currentPeriodEnd: true,
status: true,
},
},
},
orderBy: { nombre: 'asc' }
});
@@ -266,8 +276,10 @@ export async function updateTenant(id: string, data: {
rfc?: string;
plan?: DespachoPlan;
active?: boolean;
amount?: number;
firstPaymentDueAt?: string | null;
}) {
return prisma.tenant.update({
const tenant = await prisma.tenant.update({
where: { id },
data: {
...(data.nombre && { nombre: data.nombre }),
@@ -285,6 +297,29 @@ export async function updateTenant(id: string, data: {
createdAt: true,
}
});
// Actualizar subscription del tenant (plan custom o cualquier plan con amount)
if (data.amount !== undefined || data.firstPaymentDueAt !== undefined) {
const subscription = await prisma.subscription.findFirst({
where: { tenantId: id },
orderBy: { createdAt: 'desc' },
});
if (subscription) {
const updateData: any = {};
if (data.amount !== undefined) {
updateData.amount = data.amount;
}
if (data.firstPaymentDueAt !== undefined) {
updateData.currentPeriodEnd = data.firstPaymentDueAt ? new Date(data.firstPaymentDueAt) : null;
}
await prisma.subscription.update({
where: { id: subscription.id },
data: updateData,
});
}
}
return tenant;
}
export async function getDatosFiscales(id: string) {

View File

@@ -0,0 +1,191 @@
import { prisma } from '../config/database.js';
import { emailService } from './email/email.service.js';
import { getTenantOwnerEmail } from '../utils/memberships.js';
import crypto from 'crypto';
function generateToken(): string {
return crypto.randomBytes(32).toString('hex');
}
export async function createInvitation(params: {
tenantId: string;
invitedByUserId: string;
plan?: string;
durationDays: number;
}) {
const tenant = await prisma.tenant.findUnique({
where: { id: params.tenantId },
select: { nombre: true, rfc: true, plan: true },
});
if (!tenant) throw new Error('Tenant no encontrado');
// Verificar que no haya ya una invitación pendiente para este tenant
const existingPending = await prisma.trialInvitation.findFirst({
where: { tenantId: params.tenantId, status: 'pending' },
});
if (existingPending) {
throw new Error('Este tenant ya tiene una invitación de trial pendiente');
}
// Verificar que el tenant no tenga ya una suscripción activa del mismo plan
const existingSub = await prisma.subscription.findFirst({
where: {
tenantId: params.tenantId,
status: { in: ['authorized', 'pending', 'trial'] },
plan: (params.plan || 'business_control') as any,
},
});
if (existingSub) {
throw new Error(`Este tenant ya tiene una suscripción activa o en trial de ${params.plan || 'business_control'}`);
}
const token = generateToken();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 días para aceptar
const invitation = await prisma.trialInvitation.create({
data: {
tenantId: params.tenantId,
invitedBy: params.invitedByUserId,
plan: params.plan || 'business_control',
durationDays: params.durationDays,
token,
expiresAt,
emailSentTo: null,
},
});
// Enviar email al owner (fire-and-forget)
const ownerEmail = await getTenantOwnerEmail(params.tenantId);
if (ownerEmail) {
await prisma.trialInvitation.update({
where: { id: invitation.id },
data: { emailSentTo: ownerEmail },
});
const acceptUrl = `${process.env.FRONTEND_URL || 'https://app.horux360.com'}/invitacion/trial/${token}`;
emailService.sendTrialInvitation(ownerEmail, {
despachoNombre: tenant.nombre,
plan: invitation.plan,
durationDays: invitation.durationDays,
acceptUrl,
expiresAt: expiresAt.toLocaleDateString('es-MX'),
}).catch((err: any) => console.error('[TrialInvitation] Email failed:', err.message));
}
return invitation;
}
export async function acceptInvitation(token: string, userId: string) {
const invitation = await prisma.trialInvitation.findUnique({
where: { token },
});
if (!invitation) throw new Error('Invitación no encontrada');
if (invitation.status !== 'pending') throw new Error(`Invitación ya ${invitation.status}`);
if (invitation.expiresAt < new Date()) {
await prisma.trialInvitation.update({
where: { id: invitation.id },
data: { status: 'expired' },
});
throw new Error('La invitación ha expirado');
}
// Verificar que el usuario sea owner del tenant
const membership = await prisma.tenantMembership.findFirst({
where: { userId, tenantId: invitation.tenantId, isOwner: true, active: true },
});
if (!membership) {
throw new Error('Solo el dueño del despacho puede aceptar esta invitación');
}
const trialEndsAt = new Date();
trialEndsAt.setDate(trialEndsAt.getDate() + invitation.durationDays);
const now = new Date();
await prisma.$transaction(async (tx) => {
// Actualizar tenant
await tx.tenant.update({
where: { id: invitation.tenantId },
data: {
plan: invitation.plan as any,
trialEndsAt,
},
});
// Cancelar cualquier subscription trial anterior genérica
await tx.subscription.updateMany({
where: { tenantId: invitation.tenantId, status: 'trial' },
data: { status: 'trial_converted' },
});
// Crear nueva subscription de trial con el plan específico
await tx.subscription.create({
data: {
tenantId: invitation.tenantId,
plan: invitation.plan as any,
status: 'trial',
amount: 0,
frequency: 'annual',
currentPeriodStart: now,
currentPeriodEnd: trialEndsAt,
},
});
// Marcar invitación como aceptada
await tx.trialInvitation.update({
where: { id: invitation.id },
data: { status: 'accepted', acceptedAt: now },
});
});
return { success: true, trialEndsAt, plan: invitation.plan, durationDays: invitation.durationDays };
}
export async function getInvitations(filters?: { tenantId?: string; status?: string }) {
const where: any = {};
if (filters?.tenantId) where.tenantId = filters.tenantId;
if (filters?.status) where.status = filters.status;
const invitations = await prisma.trialInvitation.findMany({
where,
orderBy: { createdAt: 'desc' },
});
// Enrich with tenant data
const tenantIds = [...new Set(invitations.map(i => i.tenantId))];
const tenants = await prisma.tenant.findMany({
where: { id: { in: tenantIds } },
select: { id: true, nombre: true, rfc: true },
});
const tenantMap = new Map(tenants.map(t => [t.id, t]));
return invitations.map(inv => ({
...inv,
tenant: tenantMap.get(inv.tenantId) || null,
}));
}
export async function getPendingInvitationForTenant(tenantId: string) {
const invitation = await prisma.trialInvitation.findFirst({
where: { tenantId, status: 'pending', expiresAt: { gt: new Date() } },
});
if (!invitation) return null;
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { nombre: true, rfc: true },
});
return { ...invitation, tenant };
}
export async function cancelInvitation(invitationId: string) {
const invitation = await prisma.trialInvitation.findUnique({
where: { id: invitationId },
});
if (!invitation) throw new Error('Invitación no encontrada');
if (invitation.status !== 'pending') throw new Error('Solo se pueden cancelar invitaciones pendientes');
return prisma.trialInvitation.update({
where: { id: invitationId },
data: { status: 'cancelled' },
});
}