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:
@@ -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({
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { baseTemplate, heading, BRAND_COLORS as C } from './base.js';
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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>
|
||||
`);
|
||||
}
|
||||
63
apps/api/src/services/email/templates/trial-invitation.ts
Normal file
63
apps/api/src/services/email/templates/trial-invitation.ts
Normal 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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
191
apps/api/src/services/trial-invitations.service.ts
Normal file
191
apps/api/src/services/trial-invitations.service.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user