fix(sat, payments, admin): multiple production fixes
- 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
This commit is contained in:
@@ -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 {
|
private cleanupIdlePools(): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const maxIdle = 5 * 60 * 1000;
|
const maxIdle = 12 * 60 * 60 * 1000;
|
||||||
|
|
||||||
for (const [tenantId, entry] of this.pools.entries()) {
|
for (const [tenantId, entry] of this.pools.entries()) {
|
||||||
if (now - entry.lastAccess.getTime() > maxIdle) {
|
if (now - entry.lastAccess.getTime() > maxIdle) {
|
||||||
|
|||||||
@@ -10,6 +10,21 @@ import { despachoPlanTieneDualidadDb } from '../services/plan-catalogo.service.j
|
|||||||
import { emailService } from '../services/email/email.service.js';
|
import { emailService } from '../services/email/email.service.js';
|
||||||
import { getTenantOwnerEmail } from '../utils/memberships.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) {
|
export async function handleMercadoPagoWebhook(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const { type, data } = req.body;
|
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
|
// precio de renewal. Se detecta comparando el monto cobrado contra lo que
|
||||||
// `getPlanPrice(phase='firstYear')` devolvería para este plan.
|
// `getPlanPrice(phase='firstYear')` devolvería para este plan.
|
||||||
const esPrimerPago = subscription.status === 'pending';
|
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({
|
await prisma.subscription.update({
|
||||||
where: { id: subscription.id },
|
where: { id: subscription.id },
|
||||||
data: { status: 'authorized' },
|
data: updateData,
|
||||||
});
|
});
|
||||||
subscriptionService.invalidateSubscriptionCache(tenantId);
|
subscriptionService.invalidateSubscriptionCache(tenantId);
|
||||||
|
|
||||||
|
|||||||
@@ -21,19 +21,50 @@ const EXPIRY_REMINDERS_CRON = '0 9 * * *'; // 9:00 AM diario — avisos p
|
|||||||
let isRunning = false;
|
let isRunning = false;
|
||||||
let isIncrementalRunning = 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<boolean> {
|
||||||
|
// 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
|
* Obtiene los tenants que tienen FIEL configurada y activa
|
||||||
*/
|
*/
|
||||||
async function getTenantsWithFiel(): Promise<string[]> {
|
async function getTenantsWithFiel(): Promise<string[]> {
|
||||||
const tenants = await prisma.tenant.findMany({
|
const tenants = await prisma.tenant.findMany({
|
||||||
where: { active: true },
|
where: { active: true },
|
||||||
select: { id: true },
|
select: { id: true, databaseName: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const tenantsWithFiel: string[] = [];
|
const tenantsWithFiel: string[] = [];
|
||||||
|
|
||||||
for (const tenant of tenants) {
|
for (const tenant of tenants) {
|
||||||
const hasFiel = await hasFielConfigured(tenant.id);
|
const hasFiel = await hasAnyFielConfigured(tenant.id, tenant.databaseName);
|
||||||
if (hasFiel) {
|
if (hasFiel) {
|
||||||
tenantsWithFiel.push(tenant.id);
|
tenantsWithFiel.push(tenant.id);
|
||||||
}
|
}
|
||||||
@@ -172,12 +203,12 @@ async function getTenantsConSatIncremental(): Promise<string[]> {
|
|||||||
|
|
||||||
const tenants = await prisma.tenant.findMany({
|
const tenants = await prisma.tenant.findMany({
|
||||||
where: { active: true, plan: { in: planNames as any } },
|
where: { active: true, plan: { in: planNames as any } },
|
||||||
select: { id: true },
|
select: { id: true, databaseName: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
for (const tenant of tenants) {
|
for (const tenant of tenants) {
|
||||||
if (await hasFielConfigured(tenant.id)) {
|
if (await hasAnyFielConfigured(tenant.id, tenant.databaseName)) {
|
||||||
result.push(tenant.id);
|
result.push(tenant.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,11 +66,13 @@ export async function getClientesStats(range: ClientesStatsRange): Promise<Clien
|
|||||||
paymentsCount: payments._count,
|
paymentsCount: payments._count,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3) Clientes que NO renovaron: subs cuyo currentPeriodEnd cae en el rango
|
// 3) Clientes que NO renovaron:
|
||||||
// y que están en status terminal (cancelled, trial_expired, paused) o sin
|
// a) Subs cuyo currentPeriodEnd cae en el rango y están en status terminal
|
||||||
// payment posterior aprobado. Nota: un sub `authorized` con periodEnd
|
// (cancelled, trial_expired, paused).
|
||||||
// pasado es un "se renovó automáticamente" — para detectar no-renovaciones
|
// b) Tenants cuyo trialEndsAt ya pasó y NO tienen suscripción authorized
|
||||||
// miramos status efectivo + ausencia de payment en los siguientes 7 días.
|
// (incluye trials que nunca convirtieron o cuya sub fue borrada).
|
||||||
|
// c) Tenants con sub trial vencida (currentPeriodEnd < ahora) que nunca
|
||||||
|
// fue marcada trial_expired por el cron.
|
||||||
const subsExpiradas = await prisma.subscription.findMany({
|
const subsExpiradas = await prisma.subscription.findMany({
|
||||||
where: {
|
where: {
|
||||||
currentPeriodEnd: { gte: range.from, lte: range.to },
|
currentPeriodEnd: { gte: range.from, lte: range.to },
|
||||||
@@ -84,14 +86,99 @@ export async function getClientesStats(range: ClientesStatsRange): Promise<Clien
|
|||||||
tenant: { select: { id: true, nombre: true, rfc: true } },
|
tenant: { select: { id: true, nombre: true, rfc: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const noRenovaciones = subsExpiradas.map(s => ({
|
|
||||||
tenantId: s.tenantId,
|
const noRenovacionesMap = new Map<string, ClientesStats['noRenovaciones'][number]>();
|
||||||
tenantNombre: s.tenant?.nombre ?? '',
|
for (const s of subsExpiradas) {
|
||||||
rfc: s.tenant?.rfc ?? '',
|
noRenovacionesMap.set(s.tenantId, {
|
||||||
plan: String(s.plan),
|
tenantId: s.tenantId,
|
||||||
currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? '',
|
tenantNombre: s.tenant?.nombre ?? '',
|
||||||
statusActual: s.status,
|
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)
|
// 4) Usuarios por cliente (memberships activos por tenant)
|
||||||
const memberships = await prisma.tenantMembership.findMany({
|
const memberships = await prisma.tenantMembership.findMany({
|
||||||
|
|||||||
@@ -348,6 +348,17 @@ export async function emitInvoiceIfApplicable(paymentId: string): Promise<void>
|
|||||||
data: { facturapiInvoiceId: invoice.id },
|
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({
|
auditLog({
|
||||||
tenantId: payment.tenantId,
|
tenantId: payment.tenantId,
|
||||||
action: 'invoice.emitted_auto',
|
action: 'invoice.emitted_auto',
|
||||||
|
|||||||
@@ -72,9 +72,17 @@ export async function querySat(
|
|||||||
requestType: 'metadata' | 'cfdi' = 'cfdi'
|
requestType: 'metadata' | 'cfdi' = 'cfdi'
|
||||||
): Promise<QueryResult> {
|
): Promise<QueryResult> {
|
||||||
try {
|
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(
|
const period = DateTimePeriod.createFromValues(
|
||||||
formatDateForSat(fechaInicio),
|
formatDateForSat(fechaInicio),
|
||||||
formatDateForSat(fechaFin)
|
formatDateForSat(adjustedFechaFin)
|
||||||
);
|
);
|
||||||
|
|
||||||
const downloadType = new DownloadType(tipo === 'emitidos' ? 'issued' : 'received');
|
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 {
|
function formatDateForSat(date: Date): string {
|
||||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} 00:00:00`;
|
||||||
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ export interface SweepResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = {
|
const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = {
|
||||||
initial: 8,
|
initial: 24,
|
||||||
daily: 4,
|
daily: 4,
|
||||||
incremental: 2,
|
incremental: 2,
|
||||||
custom: 4,
|
custom: 24,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user