From b1eaf41681b50f8e0009ca977df12f632748897c Mon Sep 17 00:00:00 2001 From: Horux Dev Date: Wed, 10 Jun 2026 18:11:47 +0000 Subject: [PATCH] fix(sat, payments, admin): multiple production fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sat sweep-stale-jobs: increase initial/custom sync threshold 8h→24h to prevent watchdog killing long historical syncs - sat-client: fix formatDateForSat same-day rejection by auto-adjusting fechaFin - sat-sync job: check fiel_contribuyente in addition to fiel_credentials for cron eligibility - database: extend pool idle cleanup from 5min to 12h to prevent pool closure during long syncs - webhook controller: auto-extend currentPeriodEnd on recurring MercadoPago payments - invoicing service: auto-send FacturAPI invoice by email after creation - admin-clientes: fix no-renovaciones detection to include expired trials and deleted subscriptions --- apps/api/src/config/database.ts | 6 +- .../api/src/controllers/webhook.controller.ts | 28 ++++- apps/api/src/jobs/sat-sync.job.ts | 39 +++++- .../src/services/admin-clientes.service.ts | 113 ++++++++++++++++-- .../src/services/payment/invoicing.service.ts | 11 ++ .../src/services/sat/sat-client.service.ts | 17 ++- .../services/sat/sweep-stale-jobs.service.ts | 4 +- 7 files changed, 192 insertions(+), 26 deletions(-) diff --git a/apps/api/src/config/database.ts b/apps/api/src/config/database.ts index a958b39..1b24d44 100644 --- a/apps/api/src/config/database.ts +++ b/apps/api/src/config/database.ts @@ -187,11 +187,13 @@ class TenantConnectionManager { } /** - * Remove idle pools (not accessed in last 5 minutes). + * Remove idle pools (not accessed in last 12 hours). + * SAT syncs (initial/daily) can run for hours in background; + * a 5-minute timeout caused 'pool already ended' errors mid-sync. */ private cleanupIdlePools(): void { const now = Date.now(); - const maxIdle = 5 * 60 * 1000; + const maxIdle = 12 * 60 * 60 * 1000; for (const [tenantId, entry] of this.pools.entries()) { if (now - entry.lastAccess.getTime() > maxIdle) { diff --git a/apps/api/src/controllers/webhook.controller.ts b/apps/api/src/controllers/webhook.controller.ts index 99187cc..bbbad28 100644 --- a/apps/api/src/controllers/webhook.controller.ts +++ b/apps/api/src/controllers/webhook.controller.ts @@ -10,6 +10,21 @@ import { despachoPlanTieneDualidadDb } from '../services/plan-catalogo.service.j import { emailService } from '../services/email/email.service.js'; import { getTenantOwnerEmail } from '../utils/memberships.js'; +/** + * Calcula la siguiente fecha de fin de período según la frecuencia. + * Usa el mismo algoritmo que Mercado Pago: mismo día del mes siguiente, + * ajustando al último día si el mes destino tiene menos días. + */ +function computeNextPeriodEnd(date: Date, frequency: string): Date { + const d = new Date(date); + if (frequency === 'monthly') { + d.setMonth(d.getMonth() + 1); + } else if (frequency === 'annual' || frequency === 'yearly') { + d.setFullYear(d.getFullYear() + 1); + } + return d; +} + export async function handleMercadoPagoWebhook(req: Request, res: Response, next: NextFunction) { try { const { type, data } = req.body; @@ -187,9 +202,20 @@ async function handlePaymentNotification(paymentId: string) { // precio de renewal. Se detecta comparando el monto cobrado contra lo que // `getPlanPrice(phase='firstYear')` devolvería para este plan. const esPrimerPago = subscription.status === 'pending'; + const updateData: { status: string; currentPeriodEnd?: Date } = { status: 'authorized' }; + + // Extender currentPeriodEnd para renovaciones recurrentes. + // El primer pago ya tiene currentPeriodEnd establecido al crear la suscripción; + // solo extendemos en pagos subsecuentes para reflejar el nuevo período cobrado. + if (!esPrimerPago && subscription.currentPeriodEnd) { + const nextPeriodEnd = computeNextPeriodEnd(subscription.currentPeriodEnd, subscription.frequency); + updateData.currentPeriodEnd = nextPeriodEnd; + console.log(`[WEBHOOK] Subscription ${subscription.id} extended to ${nextPeriodEnd.toISOString()} (${subscription.frequency})`); + } + await prisma.subscription.update({ where: { id: subscription.id }, - data: { status: 'authorized' }, + data: updateData, }); subscriptionService.invalidateSubscriptionCache(tenantId); diff --git a/apps/api/src/jobs/sat-sync.job.ts b/apps/api/src/jobs/sat-sync.job.ts index a6f8b94..6cea14b 100644 --- a/apps/api/src/jobs/sat-sync.job.ts +++ b/apps/api/src/jobs/sat-sync.job.ts @@ -21,19 +21,50 @@ const EXPIRY_REMINDERS_CRON = '0 9 * * *'; // 9:00 AM diario — avisos p let isRunning = false; let isIncrementalRunning = false; +/** + * Verifica si un tenant tiene FIEL a nivel tenant (legacy Horux 360) + * o a nivel contribuyente (modelo despacho). + */ +async function hasAnyFielConfigured(tenantId: string, databaseName?: string | null): Promise { + // 1) FIEL legacy a nivel tenant + const hasLegacy = await hasFielConfigured(tenantId); + if (hasLegacy) return true; + + // 2) FIEL por contribuyente (modelo despacho) + if (!databaseName) { + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { databaseName: true }, + }); + databaseName = tenant?.databaseName; + } + if (!databaseName) return false; + + try { + const pool = await tenantDb.getPool(tenantId, databaseName); + const { rows } = await pool.query( + `SELECT 1 FROM fiel_contribuyente WHERE is_active = true LIMIT 1` + ); + return rows.length > 0; + } catch (err: any) { + console.error(`[SAT Cron] Error verificando FIEL contribuyente para tenant ${tenantId}:`, err.message); + return false; + } +} + /** * Obtiene los tenants que tienen FIEL configurada y activa */ async function getTenantsWithFiel(): Promise { const tenants = await prisma.tenant.findMany({ where: { active: true }, - select: { id: true }, + select: { id: true, databaseName: true }, }); const tenantsWithFiel: string[] = []; for (const tenant of tenants) { - const hasFiel = await hasFielConfigured(tenant.id); + const hasFiel = await hasAnyFielConfigured(tenant.id, tenant.databaseName); if (hasFiel) { tenantsWithFiel.push(tenant.id); } @@ -172,12 +203,12 @@ async function getTenantsConSatIncremental(): Promise { const tenants = await prisma.tenant.findMany({ where: { active: true, plan: { in: planNames as any } }, - select: { id: true }, + select: { id: true, databaseName: true }, }); const result: string[] = []; for (const tenant of tenants) { - if (await hasFielConfigured(tenant.id)) { + if (await hasAnyFielConfigured(tenant.id, tenant.databaseName)) { result.push(tenant.id); } } diff --git a/apps/api/src/services/admin-clientes.service.ts b/apps/api/src/services/admin-clientes.service.ts index 2d5b20b..ffa0da7 100644 --- a/apps/api/src/services/admin-clientes.service.ts +++ b/apps/api/src/services/admin-clientes.service.ts @@ -66,11 +66,13 @@ export async function getClientesStats(range: ClientesStatsRange): Promise ({ - tenantId: s.tenantId, - tenantNombre: s.tenant?.nombre ?? '', - rfc: s.tenant?.rfc ?? '', - plan: String(s.plan), - currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? '', - statusActual: s.status, - })); + + const noRenovacionesMap = new Map(); + for (const s of subsExpiradas) { + noRenovacionesMap.set(s.tenantId, { + tenantId: s.tenantId, + tenantNombre: s.tenant?.nombre ?? '', + rfc: s.tenant?.rfc ?? '', + plan: String(s.plan), + currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? '', + statusActual: s.status, + }); + } + + // b + c) Trials vencidos / sin suscripción activa / subs borradas + const now = new Date(); + const tenantsConSubAutorizada = new Set( + (await prisma.subscription.findMany({ + where: { status: 'authorized' }, + select: { tenantId: true }, + })).map(s => s.tenantId) + ); + const excluded = Array.from(tenantsConSubAutorizada); + + // Tenants con trialEndsAt pasado y sin sub authorized + const tenantsTrialsVencidos = await prisma.tenant.findMany({ + where: { + trialEndsAt: { lt: now }, + id: { notIn: excluded }, + }, + select: { id: true, nombre: true, rfc: true, plan: true, trialEndsAt: true }, + }); + for (const t of tenantsTrialsVencidos) { + if (noRenovacionesMap.has(t.id)) continue; + noRenovacionesMap.set(t.id, { + tenantId: t.id, + tenantNombre: t.nombre, + rfc: t.rfc ?? '', + plan: String(t.plan ?? 'trial'), + currentPeriodEnd: t.trialEndsAt?.toISOString() ?? '', + statusActual: 'trial_expired', + }); + } + + // Tenants con sub trial vencida (currentPeriodEnd < ahora) que nunca fue + // marcada trial_expired por el cron, y no tienen otra sub authorized. + const subsTrialVencidas = await prisma.subscription.findMany({ + where: { + status: 'trial', + currentPeriodEnd: { lt: now }, + tenantId: { notIn: excluded }, + }, + select: { + tenantId: true, + plan: true, + currentPeriodEnd: true, + tenant: { select: { id: true, nombre: true, rfc: true } }, + }, + }); + for (const s of subsTrialVencidas) { + if (noRenovacionesMap.has(s.tenantId)) continue; + noRenovacionesMap.set(s.tenantId, { + tenantId: s.tenantId, + tenantNombre: s.tenant?.nombre ?? '', + rfc: s.tenant?.rfc ?? '', + plan: String(s.plan), + currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? '', + statusActual: 'trial_expired', + }); + } + + // Tenants con plan de pago asignado manualmente (plan != 'trial') pero + // sin NINGUNA suscripción. Indica que nunca iniciaron el flujo de pago. + const tenantsConPlanPeroSinSub = await prisma.tenant.findMany({ + where: { + plan: { not: 'trial' }, + id: { notIn: excluded }, + subscriptions: { none: {} }, + }, + select: { id: true, nombre: true, rfc: true, plan: true, createdAt: true }, + }); + for (const t of tenantsConPlanPeroSinSub) { + if (noRenovacionesMap.has(t.id)) continue; + noRenovacionesMap.set(t.id, { + tenantId: t.id, + tenantNombre: t.nombre, + rfc: t.rfc ?? '', + plan: String(t.plan), + currentPeriodEnd: t.createdAt.toISOString(), + statusActual: 'sin_suscripcion', + }); + } + + const noRenovaciones = Array.from(noRenovacionesMap.values()); // 4) Usuarios por cliente (memberships activos por tenant) const memberships = await prisma.tenantMembership.findMany({ diff --git a/apps/api/src/services/payment/invoicing.service.ts b/apps/api/src/services/payment/invoicing.service.ts index 3dc931f..64064cf 100644 --- a/apps/api/src/services/payment/invoicing.service.ts +++ b/apps/api/src/services/payment/invoicing.service.ts @@ -348,6 +348,17 @@ export async function emitInvoiceIfApplicable(paymentId: string): Promise data: { facturapiInvoiceId: invoice.id }, }); + // Enviar factura por email al cliente cuando se factura con datos reales + // (no público en general). Fail-soft: si el envío falla, no bloquea. + if (customer?.email) { + try { + await facturapiService.sendInvoiceByEmail(emitter.id, invoice.id, customer.email); + console.log(`[Invoicing] Factura ${invoice.id} enviada a ${customer.email}`); + } catch (emailErr: any) { + console.error(`[Invoicing] Error enviando factura ${invoice.id} a ${customer.email}:`, emailErr.message || emailErr); + } + } + auditLog({ tenantId: payment.tenantId, action: 'invoice.emitted_auto', diff --git a/apps/api/src/services/sat/sat-client.service.ts b/apps/api/src/services/sat/sat-client.service.ts index 01307b4..c4ad2cd 100644 --- a/apps/api/src/services/sat/sat-client.service.ts +++ b/apps/api/src/services/sat/sat-client.service.ts @@ -72,9 +72,17 @@ export async function querySat( requestType: 'metadata' | 'cfdi' = 'cfdi' ): Promise { try { + // El SAT rechaza fechaInicial >= fechaFinal. Como formatDateForSat trunca + // a medianoche, dos fechas dentro del mismo día calendario resultan iguales. + // Ajustamos fechaFin al día siguiente para evitar el error. + let adjustedFechaFin = fechaFin; + if (formatDateForSat(fechaInicio) === formatDateForSat(fechaFin)) { + adjustedFechaFin = new Date(fechaFin.getTime() + 24 * 60 * 60 * 1000); + } + const period = DateTimePeriod.createFromValues( formatDateForSat(fechaInicio), - formatDateForSat(fechaFin) + formatDateForSat(adjustedFechaFin) ); const downloadType = new DownloadType(tipo === 'emitidos' ? 'issued' : 'received'); @@ -239,10 +247,11 @@ export async function downloadSatPackage( } /** - * Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss) + * Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss). + * El SAT requiere hora 00:00:00; cualquier otra hora causa + * "Fecha final invalida" / "Fecha inicial invalida". */ function formatDateForSat(date: Date): string { const pad = (n: number) => n.toString().padStart(2, '0'); - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` + - `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} 00:00:00`; } diff --git a/apps/api/src/services/sat/sweep-stale-jobs.service.ts b/apps/api/src/services/sat/sweep-stale-jobs.service.ts index f0e37b2..33e692a 100644 --- a/apps/api/src/services/sat/sweep-stale-jobs.service.ts +++ b/apps/api/src/services/sat/sweep-stale-jobs.service.ts @@ -14,10 +14,10 @@ export interface SweepResult { } const DEFAULT_RUNNING_HOURS_BY_TYPE: Record = { - initial: 8, + initial: 24, daily: 4, incremental: 2, - custom: 4, + custom: 24, }; /**