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:
Horux Dev
2026-06-10 18:11:47 +00:00
parent bd7e499ab7
commit b1eaf41681
7 changed files with 192 additions and 26 deletions

View File

@@ -66,11 +66,13 @@ export async function getClientesStats(range: ClientesStatsRange): Promise<Clien
paymentsCount: payments._count,
};
// 3) Clientes que NO renovaron: subs cuyo currentPeriodEnd cae en el rango
// y que están en status terminal (cancelled, trial_expired, paused) o sin
// payment posterior aprobado. Nota: un sub `authorized` con periodEnd
// pasado es un "se renovó automáticamente" — para detectar no-renovaciones
// miramos status efectivo + ausencia de payment en los siguientes 7 días.
// 3) Clientes que NO renovaron:
// a) Subs cuyo currentPeriodEnd cae en el rango y están en status terminal
// (cancelled, trial_expired, paused).
// b) Tenants cuyo trialEndsAt ya pasó y NO tienen suscripción authorized
// (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({
where: {
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 } },
},
});
const noRenovaciones = subsExpiradas.map(s => ({
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<string, ClientesStats['noRenovaciones'][number]>();
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({

View File

@@ -348,6 +348,17 @@ export async function emitInvoiceIfApplicable(paymentId: string): Promise<void>
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',

View File

@@ -72,9 +72,17 @@ export async function querySat(
requestType: 'metadata' | 'cfdi' = 'cfdi'
): Promise<QueryResult> {
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`;
}

View File

@@ -14,10 +14,10 @@ export interface SweepResult {
}
const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = {
initial: 8,
initial: 24,
daily: 4,
incremental: 2,
custom: 4,
custom: 24,
};
/**