docs: actualiza CAMBIOS-2026-05-04.md y commitea fixes pendientes
- Monitor de sincronización SAT (sat-sync-monitor.job + alerta por correo). - Scraper de CSF más robusto (iframes, blobs, popups, validación PDF). - Reactivación de contribuyentes desactivados y limpieza al desactivar. - Timeout de constancia aumentado a 5 min. - Variables de entorno SAT en .env.example y env.ts.
This commit is contained in:
@@ -49,6 +49,17 @@ SMTP_FROM=Horux360 <noreply@horuxfin.com>
|
||||
# ----- Notificaciones admin --------------------------------------------------
|
||||
ADMIN_EMAIL=carlos@horuxfin.com # destino de "nuevo cliente" + alertas internas
|
||||
|
||||
# ----- Monitoreo sincronización SAT ------------------------------------------
|
||||
# Email separado para alertas de SAT (fallos, jobs atorados, FIEL sin sync inicial).
|
||||
# Si no se configura, usa ADMIN_EMAIL.
|
||||
SAT_ALERT_EMAIL=
|
||||
# Cron del monitor (default: cada 2 horas). Ej: 0 8 * * * para digest diario 8 AM.
|
||||
SAT_MONITOR_SCHEDULE=0 */2 * * *
|
||||
# Horas para considerar un job running/pending como atorado (default 2h).
|
||||
SAT_STUCK_RUNNING_HOURS=2
|
||||
# Ventana hacia atrás para reportar jobs fallados (default 24h).
|
||||
SAT_FAILED_LOOKBACK_HOURS=24
|
||||
|
||||
# ----- Facturapi (emisión CFDI) — opcional -----------------------------------
|
||||
# Sin esto, los tenants no pueden emitir facturas, pero la app arranca.
|
||||
FACTURAPI_USER_KEY= # sk_user_... (cuenta maestra Horux 360)
|
||||
|
||||
@@ -53,6 +53,12 @@ const envSchema = z.object({
|
||||
// Admin notification email
|
||||
ADMIN_EMAIL: z.string().default('carlos@horuxfin.com'),
|
||||
|
||||
// SAT sync monitoring alerts (optional; falls back to ADMIN_EMAIL)
|
||||
SAT_ALERT_EMAIL: z.string().email().optional(),
|
||||
SAT_MONITOR_SCHEDULE: z.string().default('0 */2 * * *'),
|
||||
SAT_STUCK_RUNNING_HOURS: z.string().transform(v => parseInt(v, 10)).default('2'),
|
||||
SAT_FAILED_LOOKBACK_HOURS: z.string().transform(v => parseInt(v, 10)).default('24'),
|
||||
|
||||
// Facturapi
|
||||
FACTURAPI_USER_KEY: z.string().optional(),
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ export async function create(req: Request, res: Response, next: NextFunction) {
|
||||
}
|
||||
}
|
||||
|
||||
const row = await contribuyenteService.createContribuyente(req.tenantPool!, data);
|
||||
const { row, reactivated } = await contribuyenteService.createContribuyente(req.tenantPool!, data);
|
||||
|
||||
// Si se asignó un supervisor, agregar el contribuyente a todas las carteras
|
||||
// top-level de ese supervisor para que aparezca directamente en su vista.
|
||||
@@ -119,7 +119,7 @@ export async function create(req: Request, res: Response, next: NextFunction) {
|
||||
console.error('[Contribuyente] Overage adjust failed (non-blocking):', err.message || err);
|
||||
}
|
||||
|
||||
return res.status(201).json({ ...row, overage });
|
||||
return res.status(reactivated ? 200 : 201).json({ ...row, reactivated, overage });
|
||||
} catch (err: any) {
|
||||
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
|
||||
if (err.code === '23505') return next(new AppError(409, 'Ya existe un contribuyente con este RFC'));
|
||||
|
||||
@@ -6,6 +6,7 @@ import { startSatSyncJob } from './jobs/sat-sync.job.js';
|
||||
import { startWeeklyUpdateJob } from './jobs/weekly-update.job.js';
|
||||
import { startMetricasInvalidationsJob } from './jobs/metricas-invalidations.job.js';
|
||||
import { startNotificationsJob } from './jobs/notifications.job.js';
|
||||
import { startSatSyncMonitorJob } from './jobs/sat-sync-monitor.job.js';
|
||||
|
||||
const PORT = parseInt(env.PORT, 10);
|
||||
|
||||
@@ -23,13 +24,14 @@ const server = app.listen(PORT, '0.0.0.0', () => {
|
||||
if (cronsEnabled) {
|
||||
startSatSyncJob();
|
||||
startMetricasInvalidationsJob();
|
||||
startSatSyncMonitorJob();
|
||||
if (sendRealEmails) {
|
||||
startWeeklyUpdateJob();
|
||||
startNotificationsJob();
|
||||
} else {
|
||||
console.log('[Cron] weekly-update + notifications omitidos en dev (evita emails reales)');
|
||||
}
|
||||
console.log(`[Cron] SAT + metricas activos (NODE_ENV=${env.NODE_ENV}, ENABLE_CRONS_IN_DEV=${process.env.ENABLE_CRONS_IN_DEV ?? 'unset'})`);
|
||||
console.log(`[Cron] SAT + metricas + SAT monitor activos (NODE_ENV=${env.NODE_ENV}, ENABLE_CRONS_IN_DEV=${process.env.ENABLE_CRONS_IN_DEV ?? 'unset'})`);
|
||||
} else {
|
||||
console.log('[Cron] Jobs omitidos en dev (usar ENABLE_CRONS_IN_DEV=1 para activar)');
|
||||
}
|
||||
|
||||
303
apps/api/src/jobs/sat-sync-monitor.job.ts
Normal file
303
apps/api/src/jobs/sat-sync-monitor.job.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import cron from 'node-cron';
|
||||
import { prisma, tenantDb } from '../config/database.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { emailService } from '../services/email/email.service.js';
|
||||
import { sweepStaleSatJobs } from '../services/sat/sweep-stale-jobs.service.js';
|
||||
import type { SatSyncAlertData } from '../services/email/templates/sat-sync-alert.js';
|
||||
|
||||
let monitorTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
|
||||
interface TenantInfo {
|
||||
id: string;
|
||||
rfc: string;
|
||||
nombre: string;
|
||||
databaseName: string | null;
|
||||
}
|
||||
|
||||
interface ContribuyenteInfo {
|
||||
id: string;
|
||||
rfc: string;
|
||||
nombre: string;
|
||||
}
|
||||
|
||||
function hoursAgo(hours: number): Date {
|
||||
return new Date(Date.now() - hours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
async function loadActiveTenants(): Promise<Map<string, TenantInfo>> {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, nombre: true, databaseName: true },
|
||||
});
|
||||
const map = new Map<string, TenantInfo>();
|
||||
for (const t of tenants) {
|
||||
map.set(t.id, t);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function loadContribuyentesForTenant(tenant: TenantInfo): Promise<Map<string, ContribuyenteInfo>> {
|
||||
const map = new Map<string, ContribuyenteInfo>();
|
||||
if (!tenant.databaseName) return map;
|
||||
try {
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
const { rows } = await pool.query(`
|
||||
SELECT c.entidad_id AS id, c.rfc, eg.nombre
|
||||
FROM contribuyentes c
|
||||
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
||||
`);
|
||||
for (const r of rows) {
|
||||
map.set(r.id, { id: r.id, rfc: r.rfc, nombre: r.nombre });
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[SAT Monitor] Error cargando contribuyentes para tenant ${tenant.rfc}:`, err.message);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function findFailedJobs(lookbackHours: number) {
|
||||
const cutoff = hoursAgo(lookbackHours);
|
||||
return prisma.satSyncJob.findMany({
|
||||
where: {
|
||||
status: 'failed',
|
||||
completedAt: { gte: cutoff },
|
||||
},
|
||||
orderBy: { completedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async function findPendingOldJobs(pendingHours: number) {
|
||||
const cutoff = hoursAgo(pendingHours);
|
||||
return prisma.satSyncJob.findMany({
|
||||
where: {
|
||||
status: 'pending',
|
||||
createdAt: { lte: cutoff },
|
||||
OR: [
|
||||
{ nextRetryAt: null },
|
||||
{ nextRetryAt: { lte: new Date() } },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async function findMissingInitialSync(tenants: Map<string, TenantInfo>): Promise<SatSyncAlertData['missingInitial']> {
|
||||
const missing: SatSyncAlertData['missingInitial'] = [];
|
||||
|
||||
for (const tenant of tenants.values()) {
|
||||
if (!tenant.databaseName) continue;
|
||||
try {
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
const { rows: contribuyentes } = await pool.query(`
|
||||
SELECT c.entidad_id AS id, c.rfc, eg.nombre
|
||||
FROM contribuyentes c
|
||||
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
||||
JOIN fiel_contribuyente f ON f.contribuyente_id = c.entidad_id
|
||||
WHERE f.is_active = true
|
||||
AND f.valid_until >= NOW()
|
||||
ORDER BY eg.nombre
|
||||
`);
|
||||
|
||||
if (contribuyentes.length === 0) continue;
|
||||
|
||||
const contribuyenteIds = contribuyentes.map((c: any) => c.id);
|
||||
const initialJobs = await prisma.satSyncJob.findMany({
|
||||
where: {
|
||||
tenantId: tenant.id,
|
||||
contribuyenteId: { in: contribuyenteIds },
|
||||
type: 'initial',
|
||||
status: { in: ['completed', 'running', 'pending'] },
|
||||
},
|
||||
select: { contribuyenteId: true, status: true },
|
||||
});
|
||||
const completedOrInProgressIds = new Set(initialJobs.map(j => j.contribuyenteId));
|
||||
|
||||
for (const c of contribuyentes) {
|
||||
if (!completedOrInProgressIds.has(c.id)) {
|
||||
missing.push({
|
||||
tenantName: tenant.nombre,
|
||||
tenantRfc: tenant.rfc,
|
||||
contribuyenteName: c.nombre,
|
||||
contribuyenteRfc: c.rfc,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[SAT Monitor] Error revisando FIEL sin sync inicial para tenant ${tenant.rfc}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return missing;
|
||||
}
|
||||
|
||||
function buildAlertData(
|
||||
tenants: Map<string, TenantInfo>,
|
||||
contribuyenteMaps: Map<string, Map<string, ContribuyenteInfo>>,
|
||||
failed: Awaited<ReturnType<typeof findFailedJobs>>,
|
||||
stale: Array<{ id: string; tenantId: string; kind: 'pending-stale' | 'running-stale'; ageHours: number }>,
|
||||
staleJobsById: Map<string, Awaited<ReturnType<typeof prisma.satSyncJob.findFirst>>>,
|
||||
pendingOld: Awaited<ReturnType<typeof findPendingOldJobs>>,
|
||||
missingInitial: SatSyncAlertData['missingInitial']
|
||||
): SatSyncAlertData {
|
||||
const now = new Date();
|
||||
const generatedAt = now.toLocaleString('es-MX', { timeZone: 'America/Mexico_City' });
|
||||
|
||||
const resolveJob = (job: { tenantId: string; contribuyenteId: string | null }) => {
|
||||
const tenant = tenants.get(job.tenantId);
|
||||
const contribMap = contribuyenteMaps.get(job.tenantId);
|
||||
const contrib = job.contribuyenteId ? contribMap?.get(job.contribuyenteId) : undefined;
|
||||
return {
|
||||
tenantName: tenant?.nombre || job.tenantId,
|
||||
tenantRfc: tenant?.rfc || '—',
|
||||
contribuyenteName: contrib?.nombre || null,
|
||||
contribuyenteRfc: contrib?.rfc || null,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
generatedAt,
|
||||
recipient: env.SAT_ALERT_EMAIL ?? env.ADMIN_EMAIL,
|
||||
summary: {
|
||||
failed: failed.length,
|
||||
stale: stale.length,
|
||||
stuckRunning: 0,
|
||||
pendingOld: pendingOld.length,
|
||||
missingInitial: missingInitial.length,
|
||||
},
|
||||
failed: failed.map(j => ({
|
||||
...resolveJob(j),
|
||||
type: j.type,
|
||||
errorMessage: j.errorMessage,
|
||||
completedAt: j.completedAt,
|
||||
})),
|
||||
stale: stale.map(e => {
|
||||
const job = staleJobsById.get(e.id);
|
||||
return {
|
||||
id: e.id,
|
||||
...resolveJob({ tenantId: e.tenantId, contribuyenteId: job?.contribuyenteId ?? null }),
|
||||
type: job?.type || '—',
|
||||
kind: e.kind,
|
||||
ageHours: e.ageHours,
|
||||
};
|
||||
}),
|
||||
stuckRunning: [],
|
||||
pendingOld: pendingOld.map(j => ({
|
||||
id: j.id,
|
||||
...resolveJob(j),
|
||||
type: j.type,
|
||||
createdAt: j.createdAt,
|
||||
nextRetryAt: j.nextRetryAt,
|
||||
hoursPending: Math.round((now.getTime() - j.createdAt.getTime()) / 3_600_000),
|
||||
})),
|
||||
missingInitial,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runSatSyncMonitor(): Promise<void> {
|
||||
console.log('[SAT Monitor] Iniciando revisión de sincronizaciones SAT');
|
||||
|
||||
const pendingHours = env.SAT_STUCK_RUNNING_HOURS;
|
||||
const failedLookbackHours = env.SAT_FAILED_LOOKBACK_HOURS;
|
||||
|
||||
try {
|
||||
const tenants = await loadActiveTenants();
|
||||
|
||||
const [failed, staleResult, pendingOld, missingInitial] = await Promise.all([
|
||||
findFailedJobs(failedLookbackHours),
|
||||
sweepStaleSatJobs({ apply: false }),
|
||||
findPendingOldJobs(pendingHours),
|
||||
findMissingInitialSync(tenants),
|
||||
]);
|
||||
|
||||
const jobTenantIds = new Set<string>();
|
||||
for (const j of [...failed, ...pendingOld]) {
|
||||
jobTenantIds.add(j.tenantId);
|
||||
}
|
||||
for (const e of staleResult.entries) {
|
||||
jobTenantIds.add(e.tenantId);
|
||||
}
|
||||
|
||||
const staleJobsById = new Map(
|
||||
(
|
||||
await prisma.satSyncJob.findMany({
|
||||
where: { id: { in: staleResult.entries.map(e => e.id) } },
|
||||
})
|
||||
).map(j => [j.id, j])
|
||||
);
|
||||
|
||||
for (const e of staleResult.entries) {
|
||||
const job = staleJobsById.get(e.id);
|
||||
if (job?.contribuyenteId && job.tenantId) {
|
||||
jobTenantIds.add(job.tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
const contribuyenteMaps = new Map<string, Map<string, ContribuyenteInfo>>();
|
||||
for (const tenantId of jobTenantIds) {
|
||||
const tenant = tenants.get(tenantId);
|
||||
if (!tenant) continue;
|
||||
const map = await loadContribuyentesForTenant(tenant);
|
||||
contribuyenteMaps.set(tenantId, map);
|
||||
}
|
||||
|
||||
const alertData = buildAlertData(
|
||||
tenants,
|
||||
contribuyenteMaps,
|
||||
failed,
|
||||
staleResult.entries,
|
||||
staleJobsById,
|
||||
pendingOld,
|
||||
missingInitial
|
||||
);
|
||||
|
||||
const hasIssues =
|
||||
alertData.summary.failed > 0 ||
|
||||
alertData.summary.stale > 0 ||
|
||||
alertData.summary.pendingOld > 0 ||
|
||||
alertData.summary.missingInitial > 0;
|
||||
|
||||
if (!hasIssues) {
|
||||
console.log('[SAT Monitor] Sin anomalías detectadas');
|
||||
return;
|
||||
}
|
||||
|
||||
const recipient = env.SAT_ALERT_EMAIL ?? env.ADMIN_EMAIL;
|
||||
console.log(`[SAT Monitor] Enviando alerta a ${recipient}:`, alertData.summary);
|
||||
await emailService.sendSatSyncAlert(recipient, alertData);
|
||||
console.log('[SAT Monitor] Alerta enviada');
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Monitor] Error en revisión:', error.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
export function startSatSyncMonitorJob(): void {
|
||||
if (monitorTask) {
|
||||
console.log('[SAT Monitor] Job ya está programado');
|
||||
return;
|
||||
}
|
||||
|
||||
const schedule = env.SAT_MONITOR_SCHEDULE;
|
||||
if (!cron.validate(schedule)) {
|
||||
console.error('[SAT Monitor] Expresión cron inválida:', schedule);
|
||||
return;
|
||||
}
|
||||
|
||||
monitorTask = cron.schedule(schedule, async () => {
|
||||
try {
|
||||
await runSatSyncMonitor();
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Monitor Cron] Error:', error.message || error);
|
||||
}
|
||||
}, {
|
||||
timezone: 'America/Mexico_City',
|
||||
});
|
||||
|
||||
console.log(`[SAT Monitor] Programado: ${schedule}`);
|
||||
}
|
||||
|
||||
export function stopSatSyncMonitorJob(): void {
|
||||
if (monitorTask) {
|
||||
monitorTask.stop();
|
||||
monitorTask = null;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { loginSatCsf } from './sat/sat-csf-login.js';
|
||||
import { extractCsfPdf } from './sat/sat-csf-scraper.js';
|
||||
import { parseCsfPdf, type ConstanciaSituacionFiscal, type Domicilio, type RegimenCsf } from './sat/sat-csf-parser.js';
|
||||
|
||||
const PROCESS_TIMEOUT = 180_000;
|
||||
const PROCESS_TIMEOUT = 300_000;
|
||||
|
||||
export interface ConstanciaRow {
|
||||
id: number;
|
||||
|
||||
@@ -135,10 +135,61 @@ export async function getContribuyenteById(pool: Pool, id: string, tenantId?: st
|
||||
return mergeContribuyenteWithTenant(row, tenantData);
|
||||
}
|
||||
|
||||
export async function createContribuyente(pool: Pool, data: CreateContribuyenteData): Promise<ContribuyenteRow> {
|
||||
export async function createContribuyente(
|
||||
pool: Pool,
|
||||
data: CreateContribuyenteData,
|
||||
): Promise<{ row: ContribuyenteRow; reactivated: boolean }> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// 1. Si el RFC ya existe, reactivar la entidad desactivada en lugar de
|
||||
// violar el UNIQUE de contribuyentes.rfc. Si está activa, lanzar
|
||||
// error 23505 para que el controller devuelva 409.
|
||||
const { rows: existing } = await client.query<{ entidad_id: string; active: boolean }>(`
|
||||
SELECT c.entidad_id, e.active
|
||||
FROM contribuyentes c
|
||||
JOIN entidades_gestionadas e ON e.id = c.entidad_id
|
||||
WHERE UPPER(c.rfc) = UPPER($1)
|
||||
`, [data.rfc]);
|
||||
|
||||
if (existing.length > 0) {
|
||||
const { entidad_id, active } = existing[0];
|
||||
if (active) {
|
||||
await client.query('ROLLBACK');
|
||||
const err: any = new Error('Ya existe un contribuyente activo con este RFC');
|
||||
err.code = '23505';
|
||||
throw err;
|
||||
}
|
||||
|
||||
await client.query(`
|
||||
UPDATE entidades_gestionadas
|
||||
SET active = true,
|
||||
nombre = $1,
|
||||
identificador = $2,
|
||||
supervisor_user_id = $3,
|
||||
updated_at = now()
|
||||
WHERE id = $4
|
||||
`, [data.razonSocial, data.rfc.toUpperCase(), data.supervisorUserId ?? null, entidad_id]);
|
||||
|
||||
await client.query(`
|
||||
UPDATE contribuyentes
|
||||
SET regimen_fiscal = $1,
|
||||
codigo_postal = $2,
|
||||
domicilio = $3
|
||||
WHERE entidad_id = $4
|
||||
`, [data.regimenFiscal ?? null, data.codigoPostal ?? null, data.domicilio ? JSON.stringify(data.domicilio) : null, entidad_id]);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
await backfillCfdiContribuyente(pool, entidad_id, data.rfc.toUpperCase()).catch(
|
||||
(err) => console.error('[Contribuyente] Backfill CFDIs failed (non-blocking):', err)
|
||||
);
|
||||
|
||||
return { row: (await getContribuyenteById(pool, entidad_id))!, reactivated: true };
|
||||
}
|
||||
|
||||
// 2. Caso normal: crear nuevo contribuyente
|
||||
const { rows: [entidad] } = await client.query(`
|
||||
INSERT INTO entidades_gestionadas (tipo, nombre, identificador, supervisor_user_id)
|
||||
VALUES ('CONTRIBUYENTE', $1, $2, $3)
|
||||
@@ -157,7 +208,7 @@ export async function createContribuyente(pool: Pool, data: CreateContribuyenteD
|
||||
(err) => console.error('[Contribuyente] Backfill CFDIs failed (non-blocking):', err)
|
||||
);
|
||||
|
||||
return (await getContribuyenteById(pool, entidad.id))!;
|
||||
return { row: (await getContribuyenteById(pool, entidad.id))!, reactivated: false };
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
@@ -220,11 +271,39 @@ export async function updateContribuyente(pool: Pool, id: string, data: Partial<
|
||||
}
|
||||
|
||||
export async function deactivateContribuyente(pool: Pool, id: string): Promise<boolean> {
|
||||
const { rowCount } = await pool.query(
|
||||
'UPDATE entidades_gestionadas SET active = false, updated_at = now() WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
return (rowCount ?? 0) > 0;
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const { rowCount } = await client.query(
|
||||
'UPDATE entidades_gestionadas SET active = false, updated_at = now() WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
const ok = (rowCount ?? 0) > 0;
|
||||
|
||||
if (ok) {
|
||||
// Limpiar asignaciones para que no aparezca en carteras ni accesos de cliente
|
||||
await client.query('DELETE FROM cartera_entidades WHERE entidad_id = $1', [id]).catch((err) => {
|
||||
console.error('[Contribuyente] Error limpiando cartera_entidades:', err);
|
||||
});
|
||||
await client.query('DELETE FROM cliente_accesos WHERE entidad_id = $1', [id]).catch((err) => {
|
||||
console.error('[Contribuyente] Error limpiando cliente_accesos:', err);
|
||||
});
|
||||
|
||||
// Desactivar FIEL para que no siga sincronizándose mientras está inactivo
|
||||
await client.query('UPDATE fiel_contribuyente SET is_active = false WHERE contribuyente_id = $1', [id]).catch((err) => {
|
||||
console.error('[Contribuyente] Error desactivando FIEL:', err);
|
||||
});
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
return ok;
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,6 +44,12 @@ export const emailService = {
|
||||
await sendEmail(env.ADMIN_EMAIL, `Pago fallido: ${data.nombre}`, paymentFailedEmail(data));
|
||||
},
|
||||
|
||||
sendSatSyncAlert: async (to: string, data: import('./templates/sat-sync-alert.js').SatSyncAlertData) => {
|
||||
const { satSyncAlertEmail } = await import('./templates/sat-sync-alert.js');
|
||||
const total = data.summary.failed + data.summary.stale + data.summary.stuckRunning + data.summary.pendingOld + data.summary.missingInitial;
|
||||
await sendEmail(to, `🚨 Alerta SAT: ${total} anomalía${total === 1 ? '' : 's'} detectada${total === 1 ? '' : 's'}`, satSyncAlertEmail(data));
|
||||
},
|
||||
|
||||
sendSubscriptionExpiring: async (to: string, data: { nombre: string; plan: string; expiresAt: string }) => {
|
||||
const { subscriptionExpiringEmail } = await import('./templates/subscription-expiring.js');
|
||||
await sendEmail(to, 'Tu suscripción vence en 5 días', subscriptionExpiringEmail(data));
|
||||
|
||||
194
apps/api/src/services/email/templates/sat-sync-alert.ts
Normal file
194
apps/api/src/services/email/templates/sat-sync-alert.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { baseTemplate, heading, infoBox, BRAND_COLORS as C } from './base.js';
|
||||
|
||||
export interface SatSyncAlertData {
|
||||
generatedAt: string;
|
||||
recipient: string;
|
||||
summary: {
|
||||
failed: number;
|
||||
stale: number;
|
||||
stuckRunning: number;
|
||||
pendingOld: number;
|
||||
missingInitial: number;
|
||||
};
|
||||
failed: Array<{
|
||||
tenantName: string;
|
||||
tenantRfc: string;
|
||||
contribuyenteName?: string | null;
|
||||
contribuyenteRfc?: string | null;
|
||||
type: string;
|
||||
errorMessage?: string | null;
|
||||
completedAt?: Date | string | null;
|
||||
}>;
|
||||
stale: Array<{
|
||||
id: string;
|
||||
tenantName: string;
|
||||
tenantRfc: string;
|
||||
contribuyenteName?: string | null;
|
||||
contribuyenteRfc?: string | null;
|
||||
type: string;
|
||||
kind: 'pending-stale' | 'running-stale';
|
||||
ageHours: number;
|
||||
}>;
|
||||
stuckRunning: Array<{
|
||||
id: string;
|
||||
tenantName: string;
|
||||
tenantRfc: string;
|
||||
contribuyenteName?: string | null;
|
||||
contribuyenteRfc?: string | null;
|
||||
type: string;
|
||||
progressPercent: number;
|
||||
startedAt?: Date | string | null;
|
||||
hoursRunning: number;
|
||||
}>;
|
||||
pendingOld: Array<{
|
||||
id: string;
|
||||
tenantName: string;
|
||||
tenantRfc: string;
|
||||
contribuyenteName?: string | null;
|
||||
contribuyenteRfc?: string | null;
|
||||
type: string;
|
||||
createdAt?: Date | string | null;
|
||||
nextRetryAt?: Date | string | null;
|
||||
hoursPending: number;
|
||||
}>;
|
||||
missingInitial: Array<{
|
||||
tenantName: string;
|
||||
tenantRfc: string;
|
||||
contribuyenteName: string;
|
||||
contribuyenteRfc: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function fmtDate(value?: Date | string | null): string {
|
||||
if (!value) return 'N/A';
|
||||
const d = typeof value === 'string' ? new Date(value) : value;
|
||||
return d.toLocaleString('es-MX', { timeZone: 'America/Mexico_City' });
|
||||
}
|
||||
|
||||
function tableHeader(cells: string[]): string {
|
||||
return `<tr>
|
||||
${cells.map(c => `<th align="left" style="padding:8px 12px;background-color:${C.bgLight};color:${C.textMuted};font-size:12px;font-weight:500;text-transform:uppercase;letter-spacing:0.04em;border-bottom:1px solid ${C.border};">${c}</th>`).join('')}
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function tableRow(cells: string[]): string {
|
||||
return `<tr>
|
||||
${cells.map(c => `<td style="padding:10px 12px;border-bottom:1px solid ${C.border};color:${C.textPrimary};font-size:13px;vertical-align:top;">${c}</td>`).join('')}
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function section(title: string, color: string, rowsHtml: string, headers: string[]): string {
|
||||
return `
|
||||
<h3 style="font-family:'Inter', sans-serif;font-weight:600;color:${color};margin:28px 0 12px;font-size:16px;">${title}</h3>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<thead>${tableHeader(headers)}</thead>
|
||||
<tbody>${rowsHtml}</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
export function satSyncAlertEmail(data: SatSyncAlertData): string {
|
||||
const { summary } = data;
|
||||
|
||||
const summaryRows = [
|
||||
{ label: 'Jobs fallidos recientes', value: summary.failed, color: summary.failed > 0 ? '#dc2626' : C.textPrimary },
|
||||
{ label: 'Jobs stale detectados', value: summary.stale, color: summary.stale > 0 ? '#dc2626' : C.textPrimary },
|
||||
{ label: 'Running atorados sin progreso', value: summary.stuckRunning, color: summary.stuckRunning > 0 ? '#f59e0b' : C.textPrimary },
|
||||
{ label: 'Pending sin atender', value: summary.pendingOld, color: summary.pendingOld > 0 ? '#f59e0b' : C.textPrimary },
|
||||
{ label: 'Contribuyentes con FIEL sin sync inicial', value: summary.missingInitial, color: summary.missingInitial > 0 ? '#dc2626' : C.textPrimary },
|
||||
]
|
||||
.map(r => `<tr><td style="padding:6px 0;color:${C.textMuted};">${r.label}</td><td style="padding:6px 0;color:${r.color};font-weight:600;text-align:right;">${r.value}</td></tr>`)
|
||||
.join('');
|
||||
|
||||
const failedHtml = data.failed.length > 0
|
||||
? section(
|
||||
`Jobs fallidos (${data.failed.length})`,
|
||||
'#dc2626',
|
||||
data.failed.map(j => tableRow([
|
||||
`<strong>${j.tenantName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.tenantRfc}</span>`,
|
||||
j.contribuyenteName ? `<strong>${j.contribuyenteName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.contribuyenteRfc || ''}</span>` : '—',
|
||||
j.type,
|
||||
`<span style="color:#dc2626;">${j.errorMessage || 'Sin mensaje'}</span>`,
|
||||
fmtDate(j.completedAt),
|
||||
])).join(''),
|
||||
['Tenant', 'Contribuyente', 'Tipo', 'Error', 'Fecha fallo']
|
||||
)
|
||||
: '';
|
||||
|
||||
const staleHtml = data.stale.length > 0
|
||||
? section(
|
||||
`Jobs stale detectados por el watchdog (${data.stale.length})`,
|
||||
'#dc2626',
|
||||
data.stale.map(j => tableRow([
|
||||
`<strong>${j.tenantName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.tenantRfc}</span>`,
|
||||
j.contribuyenteName ? `<strong>${j.contribuyenteName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.contribuyenteRfc || ''}</span>` : '—',
|
||||
j.type,
|
||||
j.kind === 'running-stale' ? 'Running abandonado' : 'Pending abandonado',
|
||||
`${j.ageHours}h`,
|
||||
])).join(''),
|
||||
['Tenant', 'Contribuyente', 'Tipo', 'Problema', 'Antigüedad']
|
||||
)
|
||||
: '';
|
||||
|
||||
const stuckHtml = data.stuckRunning.length > 0
|
||||
? section(
|
||||
`Running atorados sin avance (${data.stuckRunning.length})`,
|
||||
'#f59e0b',
|
||||
data.stuckRunning.map(j => tableRow([
|
||||
`<strong>${j.tenantName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.tenantRfc}</span>`,
|
||||
j.contribuyenteName ? `<strong>${j.contribuyenteName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.contribuyenteRfc || ''}</span>` : '—',
|
||||
j.type,
|
||||
`${j.progressPercent}%`,
|
||||
`${j.hoursRunning}h`,
|
||||
fmtDate(j.startedAt),
|
||||
])).join(''),
|
||||
['Tenant', 'Contribuyente', 'Tipo', 'Progreso', 'Tiempo', 'Inicio']
|
||||
)
|
||||
: '';
|
||||
|
||||
const pendingHtml = data.pendingOld.length > 0
|
||||
? section(
|
||||
`Pending sin atender (${data.pendingOld.length})`,
|
||||
'#f59e0b',
|
||||
data.pendingOld.map(j => tableRow([
|
||||
`<strong>${j.tenantName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.tenantRfc}</span>`,
|
||||
j.contribuyenteName ? `<strong>${j.contribuyenteName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.contribuyenteRfc || ''}</span>` : '—',
|
||||
j.type,
|
||||
`${j.hoursPending}h`,
|
||||
j.nextRetryAt ? fmtDate(j.nextRetryAt) : 'Sin reintento',
|
||||
])).join(''),
|
||||
['Tenant', 'Contribuyente', 'Tipo', 'Tiempo pendiente', 'Próximo reintento']
|
||||
)
|
||||
: '';
|
||||
|
||||
const missingHtml = data.missingInitial.length > 0
|
||||
? section(
|
||||
`Contribuyentes con FIEL sin sync inicial (${data.missingInitial.length})`,
|
||||
'#dc2626',
|
||||
data.missingInitial.map(j => tableRow([
|
||||
`<strong>${j.tenantName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.tenantRfc}</span>`,
|
||||
`<strong>${j.contribuyenteName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.contribuyenteRfc}</span>`,
|
||||
])).join(''),
|
||||
['Tenant', 'Contribuyente']
|
||||
)
|
||||
: '';
|
||||
|
||||
return baseTemplate(`
|
||||
${heading('🚨 Alerta de sincronización SAT')}
|
||||
<p style="color:${C.textPrimary};margin:0 0 16px;">
|
||||
El monitoreo de sincronizaciones SAT detectó anomalías que requieren revisión interna.
|
||||
</p>
|
||||
${infoBox(`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${summaryRows}</table>`)}
|
||||
|
||||
${failedHtml}
|
||||
${staleHtml}
|
||||
${stuckHtml}
|
||||
${pendingHtml}
|
||||
${missingHtml}
|
||||
|
||||
<p style="color:${C.textMuted};margin:24px 0 0;font-size:12px;">
|
||||
Reporte generado el ${data.generatedAt} para ${data.recipient}.<br/>
|
||||
Configura umbrales con SAT_STUCK_RUNNING_HOURS y SAT_FAILED_LOOKBACK_HOURS.
|
||||
</p>
|
||||
`);
|
||||
}
|
||||
@@ -1,40 +1,113 @@
|
||||
import type { Page, Locator, Frame, Response } from 'playwright';
|
||||
import type { Page, Locator, Frame, Response, BrowserContext } from 'playwright';
|
||||
import type { CsfLoginSession } from './sat-csf-login.js';
|
||||
|
||||
async function tryFetchPdfFromUrl(page: Page, url: string): Promise<Buffer | null> {
|
||||
async function tryFetchPdfFromUrl(frame: Frame, url: string): Promise<Buffer | null> {
|
||||
if (!url || url === 'about:blank') return null;
|
||||
|
||||
// Blob / data URI → fetchear dentro del navegador para respetar cookies/sesión
|
||||
if (url.startsWith('blob:') || url.startsWith('data:')) {
|
||||
const arr = await page.evaluate(async (u) => {
|
||||
const r = await fetch(u);
|
||||
const buf = await r.arrayBuffer();
|
||||
return Array.from(new Uint8Array(buf));
|
||||
}, url);
|
||||
return Buffer.from(arr);
|
||||
try {
|
||||
const page = frame.page();
|
||||
const arr = await page.evaluate(async (u) => {
|
||||
const r = await fetch(u);
|
||||
const buf = await r.arrayBuffer();
|
||||
return Array.from(new Uint8Array(buf));
|
||||
}, url);
|
||||
const buf = Buffer.from(arr);
|
||||
return buf.subarray(0, 5).toString().startsWith('%PDF-') ? buf : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// URL http(s) relativa o absoluta → fetchear desde el frame para mantener sesión
|
||||
if (url.startsWith('http')) {
|
||||
const response = await page.context().request.get(url);
|
||||
if (!response.ok()) return null;
|
||||
return Buffer.from(await response.body());
|
||||
try {
|
||||
const response = await frame.page().context().request.get(url);
|
||||
if (!response.ok()) return null;
|
||||
const ct = response.headers()['content-type'] ?? '';
|
||||
if (!ct.includes('application/pdf') && !url.toLowerCase().includes('.pdf')) return null;
|
||||
const buf = Buffer.from(await response.body());
|
||||
return buf.subarray(0, 5).toString().startsWith('%PDF-') ? buf : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca "Generar Constancia" en cualquiera de los frames del appPage (vive
|
||||
* típicamente en un iframe JSF legacy: rfcampc.siat.sat.gob.mx/PTSC/...).
|
||||
* Intenta 3 rutas: download event, popup con viewer, response interception.
|
||||
*/
|
||||
export async function extractCsfPdf(session: CsfLoginSession): Promise<Buffer> {
|
||||
const { context, appPage } = session;
|
||||
async function findPdfInFrames(appPage: Page, deadlineMs: number): Promise<Buffer | null> {
|
||||
const deadline = Date.now() + deadlineMs;
|
||||
|
||||
let interceptedPdf: Buffer | null = null;
|
||||
const responseListener = async (response: Response) => {
|
||||
const ct = response.headers()['content-type'] ?? '';
|
||||
if (ct.includes('application/pdf')) {
|
||||
try { interceptedPdf = Buffer.from(await response.body()); } catch { /* ok */ }
|
||||
while (Date.now() < deadline) {
|
||||
const frames = appPage.frames();
|
||||
|
||||
for (const frame of frames) {
|
||||
try {
|
||||
const frameUrl = frame.url();
|
||||
|
||||
// 1. Frame cuya URL sea directamente un PDF
|
||||
if (
|
||||
frameUrl.toLowerCase().includes('.pdf') ||
|
||||
frameUrl.includes('application/pdf')
|
||||
) {
|
||||
const body = await frame.content().catch(() => null);
|
||||
if (!body) continue;
|
||||
// content() de un PDF no es util; intentar fetch por URL
|
||||
const pdf = await tryFetchPdfFromUrl(frame, frameUrl);
|
||||
if (pdf) return pdf;
|
||||
}
|
||||
|
||||
// 2. <embed type="application/pdf">
|
||||
const embed = frame.locator('embed[type="application/pdf"]').first();
|
||||
if ((await embed.count()) > 0) {
|
||||
const src = await embed.getAttribute('src');
|
||||
if (src) {
|
||||
const pdf = await tryFetchPdfFromUrl(frame, src);
|
||||
if (pdf) return pdf;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. <iframe> cuyo src apunte al visor/constancia (IdcSiat, .pdf, etc.)
|
||||
const iframes = await frame.locator('iframe').all();
|
||||
for (const iframe of iframes) {
|
||||
const src = await iframe.getAttribute('src');
|
||||
if (src) {
|
||||
const lower = src.toLowerCase();
|
||||
if (
|
||||
lower.includes('.pdf') ||
|
||||
lower.includes('idcsiat') ||
|
||||
lower.includes('reimpresion') ||
|
||||
lower.includes('consultatramite')
|
||||
) {
|
||||
const pdf = await tryFetchPdfFromUrl(frame, src);
|
||||
if (pdf) return pdf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. <a download> o link con data/blob generado por jsPDF
|
||||
const downloadLinks = await frame.locator('a[download], a[href*="data:"], a[href*="blob:"]').all();
|
||||
for (const link of downloadLinks) {
|
||||
const href = await link.getAttribute('href');
|
||||
if (href) {
|
||||
const pdf = await tryFetchPdfFromUrl(frame, href);
|
||||
if (pdf) return pdf;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Frame puede estar navegando; ignorar y continuar
|
||||
}
|
||||
}
|
||||
};
|
||||
context.on('response', responseListener);
|
||||
|
||||
await appPage.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findGenerarButton(appPage: Page, timeoutMs: number): Promise<Locator | null> {
|
||||
const GENERAR_SELECTORS = [
|
||||
'button:has-text("Generar Constancia")',
|
||||
'button:has-text("Generar constancia")',
|
||||
@@ -44,77 +117,109 @@ export async function extractCsfPdf(session: CsfLoginSession): Promise<Buffer> {
|
||||
'a:has-text("Generar constancia")',
|
||||
].join(', ');
|
||||
|
||||
let generarLocator: Locator | null = null;
|
||||
let foundFrame: Frame | null = null;
|
||||
const deadline = Date.now() + 90_000;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
for (const frame of appPage.frames()) {
|
||||
const loc = frame.locator(GENERAR_SELECTORS).first();
|
||||
const count = await loc.count().catch(() => 0);
|
||||
if (count > 0 && await loc.isVisible().catch(() => false)) {
|
||||
generarLocator = loc;
|
||||
foundFrame = frame;
|
||||
break;
|
||||
if (count > 0 && (await loc.isVisible().catch(() => false))) {
|
||||
return loc;
|
||||
}
|
||||
}
|
||||
if (generarLocator) break;
|
||||
await appPage.waitForTimeout(1000);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!generarLocator || !foundFrame) {
|
||||
context.off('response', responseListener);
|
||||
throw new Error('Botón "Generar Constancia" no encontrado en ningún frame del portal SAT (tras 90s)');
|
||||
}
|
||||
/**
|
||||
* Busca "Generar Constancia" en cualquiera de los frames del appPage,
|
||||
* hace click, y extrae el PDF desde el visor/iframe que genera el SAT.
|
||||
*
|
||||
* El portal SAT actual (2025-2026) genera la CSF dentro de un iframe JSF
|
||||
* legacy (rfcampc.siat.sat.gob.mx/PTSC/.../ConsultaTramite.jsf). El PDF
|
||||
* no siempre se descarga como evento de download ni abre popup; a veces
|
||||
* se renderiza en un <embed> o en un iframe cuyo src devuelve el PDF.
|
||||
*/
|
||||
export async function extractCsfPdf(session: CsfLoginSession): Promise<Buffer> {
|
||||
const { context, appPage } = session;
|
||||
|
||||
await generarLocator.scrollIntoViewIfNeeded();
|
||||
await appPage.waitForTimeout(500);
|
||||
|
||||
const popupPromise = context.waitForEvent('page', { timeout: 15_000 }).catch(() => null);
|
||||
const downloadPromise = appPage.waitForEvent('download', { timeout: 15_000 }).catch(() => null);
|
||||
await generarLocator.click();
|
||||
|
||||
const [popup, download] = await Promise.all([popupPromise, downloadPromise]);
|
||||
let interceptedPdf: Buffer | null = null;
|
||||
const responseListener = async (response: Response) => {
|
||||
const ct = response.headers()['content-type'] ?? '';
|
||||
if (ct.includes('application/pdf')) {
|
||||
try {
|
||||
interceptedPdf = Buffer.from(await response.body());
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
}
|
||||
};
|
||||
context.on('response', responseListener);
|
||||
|
||||
try {
|
||||
// Path 1: download event
|
||||
const generarLocator = await findGenerarButton(appPage, 90_000);
|
||||
if (!generarLocator) {
|
||||
throw new Error('Botón "Generar Constancia" no encontrado en ningún frame del portal SAT (tras 90s)');
|
||||
}
|
||||
|
||||
await generarLocator.scrollIntoViewIfNeeded();
|
||||
await appPage.waitForTimeout(500);
|
||||
|
||||
// Algunos botones del SAT usan JSF/ajax; un click simple a veces no basta.
|
||||
// Hacemos click normal y, como fallback, dispatchEvent si no hay reacción.
|
||||
await generarLocator.click();
|
||||
await appPage.waitForTimeout(2000);
|
||||
|
||||
// Intentar extraer el PDF del iframe/visor
|
||||
let pdf = await findPdfInFrames(appPage, 60_000);
|
||||
if (pdf) return pdf;
|
||||
|
||||
// Si aún no hay PDF, algunos flujos abren popup clásico
|
||||
const popupPromise = context.waitForEvent('page', { timeout: 10_000 }).catch(() => null);
|
||||
const downloadPromise = appPage.waitForEvent('download', { timeout: 10_000 }).catch(() => null);
|
||||
|
||||
// Reintentar click por si el primero no disparó el handler
|
||||
const stillVisible = await generarLocator.isVisible().catch(() => false);
|
||||
if (stillVisible) {
|
||||
await generarLocator.dispatchEvent('click');
|
||||
}
|
||||
|
||||
const [popup, download] = await Promise.all([popupPromise, downloadPromise]);
|
||||
|
||||
if (download) {
|
||||
const stream = await download.createReadStream();
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) chunks.push(chunk as Buffer);
|
||||
const pdf = Buffer.concat(chunks);
|
||||
if (!pdf.subarray(0, 5).toString().startsWith('%PDF-')) {
|
||||
throw new Error('El archivo descargado no es un PDF válido');
|
||||
}
|
||||
return pdf;
|
||||
const downloaded = Buffer.concat(chunks);
|
||||
if (downloaded.subarray(0, 5).toString().startsWith('%PDF-')) return downloaded;
|
||||
}
|
||||
|
||||
// Path 2: viewer popup
|
||||
if (popup) {
|
||||
await popup.waitForLoadState('domcontentloaded').catch(() => undefined);
|
||||
await popup.waitForTimeout(2000);
|
||||
|
||||
let pdf = await tryFetchPdfFromUrl(popup, popup.url()).catch(() => null);
|
||||
if (!pdf) {
|
||||
const embedSrc = await popup.locator('embed[type="application/pdf"], iframe').first().getAttribute('src').catch(() => null);
|
||||
if (embedSrc) {
|
||||
const absolute = new URL(embedSrc, popup.url()).toString();
|
||||
pdf = await tryFetchPdfFromUrl(popup, absolute).catch(() => null);
|
||||
}
|
||||
pdf = await findPdfInFrames(popup, 20_000);
|
||||
if (pdf) return pdf;
|
||||
|
||||
const embedSrc = await popup.locator('embed[type="application/pdf"], iframe').first().getAttribute('src').catch(() => null);
|
||||
if (embedSrc) {
|
||||
const absolute = new URL(embedSrc, popup.url()).toString();
|
||||
pdf = await tryFetchPdfFromUrl(popup.mainFrame(), absolute).catch(() => null);
|
||||
if (pdf) return pdf;
|
||||
}
|
||||
if (!pdf && interceptedPdf) pdf = interceptedPdf;
|
||||
if (!pdf || pdf.length === 0) throw new Error('El visor abrió pero no se pudo extraer el PDF');
|
||||
if (!pdf.subarray(0, 5).toString().startsWith('%PDF-')) throw new Error('Buffer extraído no es un PDF válido');
|
||||
return pdf;
|
||||
|
||||
if (interceptedPdf) return interceptedPdf;
|
||||
throw new Error('El visor abrió pero no se pudo extraer el PDF');
|
||||
}
|
||||
|
||||
// Path 3: inline response (no popup, no download)
|
||||
await appPage.waitForTimeout(3000);
|
||||
if (interceptedPdf) {
|
||||
const pdf = interceptedPdf as Buffer;
|
||||
if (!pdf.subarray(0, 5).toString().startsWith('%PDF-')) throw new Error('Buffer interceptado no es un PDF válido');
|
||||
return pdf;
|
||||
}
|
||||
throw new Error('Click en "Generar Constancia" no produjo descarga, popup ni respuesta PDF');
|
||||
// Último intento: esperar un poco más a que el iframe termine de cargar
|
||||
await appPage.waitForTimeout(5000);
|
||||
pdf = await findPdfInFrames(appPage, 20_000);
|
||||
if (pdf) return pdf;
|
||||
|
||||
if (interceptedPdf) return interceptedPdf;
|
||||
|
||||
throw new Error('Click en "Generar Constancia" no produjo un PDF descargable ni visible');
|
||||
} finally {
|
||||
context.off('response', responseListener);
|
||||
}
|
||||
|
||||
@@ -820,9 +820,12 @@ async function processMetadataRange(
|
||||
}
|
||||
|
||||
/**
|
||||
* Determina el tamaño de bloque óptimo consultando metadata del rango completo.
|
||||
* Determina el tamaño de bloque óptimo consultando metadata en chunks de 1 año.
|
||||
* <= 15,000 CFDIs → bloques de 6 meses
|
||||
* > 15,000 CFDIs → bloques de 2 meses
|
||||
* > 15,000 CFDIs → bloques de 3 meses
|
||||
*
|
||||
* El SAT no genera paquetes de metadata para rangos muy grandes (p. ej. 6 años),
|
||||
* así que el sondeo se divide en bloques anuales.
|
||||
*/
|
||||
async function determineChunkMonths(
|
||||
ctx: SyncContext,
|
||||
@@ -851,17 +854,20 @@ async function determineChunkMonths(
|
||||
}
|
||||
|
||||
const THRESHOLD = 15_000;
|
||||
const probeChunks = generateChunks(fechaInicio, fechaFin, 12);
|
||||
let totalCfdis = 0;
|
||||
|
||||
for (const tipo of ['emitidos', 'recibidos'] as const) {
|
||||
try {
|
||||
const { totalCfdis: count } = await requestAndDownload(
|
||||
ctx, jobId, fechaInicio, fechaFin, tipo, 'metadata'
|
||||
);
|
||||
totalCfdis += count;
|
||||
console.log(`[SAT] Sondeo metadata ${tipo}: ${count} CFDIs en rango completo`);
|
||||
} catch (error: any) {
|
||||
console.log(`[SAT] No se pudo sondear metadata ${tipo}: ${error.message}`);
|
||||
for (const { start, end } of probeChunks) {
|
||||
try {
|
||||
const { totalCfdis: count } = await requestAndDownload(
|
||||
ctx, jobId, start, end, tipo, 'metadata'
|
||||
);
|
||||
totalCfdis += count;
|
||||
console.log(`[SAT] Sondeo metadata ${tipo} ${start.toISOString().slice(0, 10)} → ${end.toISOString().slice(0, 10)}: ${count} CFDIs`);
|
||||
} catch (error: any) {
|
||||
console.log(`[SAT] No se pudo sondear metadata ${tipo} ${start.toISOString().slice(0, 10)} → ${end.toISOString().slice(0, 10)}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -907,7 +913,7 @@ async function processInitialSync(
|
||||
// Paso 1: Sondeo — determinar tamaño de bloque para XMLs
|
||||
const chunkMonths = await determineChunkMonths(ctx, jobId, inicioHistorico, fechaFin);
|
||||
const xmlChunks = generateChunks(inicioHistorico, fechaFin, chunkMonths);
|
||||
const metaChunks = generateChunks(inicioHistorico, fechaFin, 36); // bloques de 3 años
|
||||
const metaChunks = generateChunks(inicioHistorico, fechaFin, 12); // bloques de 1 año
|
||||
|
||||
console.log(`[SAT] Sincronización: ${xmlChunks.length} bloques XML (${chunkMonths}m) + ${metaChunks.length} bloques metadata (36m)`);
|
||||
|
||||
|
||||
@@ -57,6 +57,9 @@ export default function ContribuyentesPage() {
|
||||
...form,
|
||||
supervisorUserId: assignSelf ? user?.id : undefined,
|
||||
});
|
||||
if (created.reactivated) {
|
||||
alert(`El contribuyente ${form.rfc.toUpperCase()} fue reactivado. Se recuperó su historial (CFDIs, FIEL, tareas, etc.).`);
|
||||
}
|
||||
// Overage Business Cloud: si el 4º+ RFC disparó un nuevo addon, abre
|
||||
// MercadoPago para autorizar el cobro recurrente mensual de $45/RFC.
|
||||
if (created.overage?.action === 'created' && created.overage.paymentUrl) {
|
||||
@@ -71,7 +74,7 @@ export default function ContribuyentesPage() {
|
||||
};
|
||||
|
||||
const handleDeactivate = async (id: string, rfc: string) => {
|
||||
if (!confirm(`¿Desactivar contribuyente ${rfc}?`)) return;
|
||||
if (!confirm(`¿Desactivar contribuyente ${rfc}?\n\nPuedes volver a activarlo más adelante agregando el mismo RFC. Se conservarán sus CFDIs y historial.`)) return;
|
||||
try {
|
||||
const result = await deactivateMut.mutateAsync(id);
|
||||
if (result.overage?.action === 'cancelled') {
|
||||
|
||||
@@ -35,7 +35,7 @@ export interface OverageAdjustResult {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export type ContribuyenteWithOverage = Contribuyente & { overage?: OverageAdjustResult };
|
||||
export type ContribuyenteWithOverage = Contribuyente & { overage?: OverageAdjustResult; reactivated?: boolean };
|
||||
|
||||
export async function getContribuyentes(): Promise<{ data: Contribuyente[] }> {
|
||||
const { data } = await apiClient.get('/contribuyentes');
|
||||
|
||||
@@ -331,8 +331,306 @@ La tabla `cat_clave_prod_serv` de la BD central estaba vacía; el catálogo nunc
|
||||
| `apps/web/lib/api/catalogos.ts` | `searchClaveProdServ` acepta `AbortSignal` |
|
||||
| `apps/web/app/(dashboard)/facturacion/page.tsx` | `handleSearchProduct` con `AbortController`, try/catch y `autoComplete="off"` |
|
||||
|
||||
## 16. Fix: CSF retry, backoff, delays entre tenants y timeouts aumentados
|
||||
|
||||
**Fecha:** 2026-06-01
|
||||
|
||||
### Cambios
|
||||
- Se agregó **retry con backoff** en la descarga de Constancias de Situación Fiscal.
|
||||
- Se agregaron **delays entre tenants** para no saturar el portal del SAT.
|
||||
- Se aumentaron los timeouts del proceso de constancia.
|
||||
|
||||
### Archivos modificados
|
||||
| Archivo | Cambio |
|
||||
|---|---|
|
||||
| `apps/api/src/services/constancia.service.ts` | Retry con backoff y manejo de errores |
|
||||
| `apps/api/src/services/sat/sat-csf-login.ts` | Timeouts y delays ajustados |
|
||||
| `apps/api/src/jobs/sat-sync.job.ts` | Delay entre ejecuciones de CSF por tenant |
|
||||
|
||||
---
|
||||
|
||||
## 17. Fix: múltiples fixes de producción en SAT, pagos y admin
|
||||
|
||||
**Fecha:** 2026-06-10
|
||||
|
||||
### Cambios
|
||||
- Ajustes en el pool de conexiones a tenants (`database.ts`).
|
||||
- Webhooks de MercadoPago más robustos ante eventos inesperados.
|
||||
- Mejoras en `admin-clientes.service.ts` e `invoicing.service.ts`.
|
||||
- Sweep de jobs SAT stale ajustado.
|
||||
|
||||
### Archivos modificados
|
||||
| Archivo | Cambio |
|
||||
|---|---|
|
||||
| `apps/api/src/config/database.ts` | Configuración de pool |
|
||||
| `apps/api/src/controllers/webhook.controller.ts` | Robustez en webhooks MP |
|
||||
| `apps/api/src/jobs/sat-sync.job.ts` | Fixes en recovery y scheduling |
|
||||
| `apps/api/src/services/admin-clientes.service.ts` | Fixes de admin |
|
||||
| `apps/api/src/services/payment/invoicing.service.ts` | Fixes de facturación |
|
||||
| `apps/api/src/services/sat/sat-client.service.ts` | Fixes de cliente SAT |
|
||||
| `apps/api/src/services/sat/sweep-stale-jobs.service.ts` | Ajuste de stale jobs |
|
||||
|
||||
---
|
||||
|
||||
## 18. Feat: scorecards de notas de crédito en dashboard
|
||||
|
||||
**Fecha:** 2026-06-13
|
||||
|
||||
### Cambios
|
||||
- Se agregaron scorecards de **notas de crédito emitidas** y **recibidas**.
|
||||
- Se reordenaron las tarjetas del dashboard.
|
||||
- Se ajustó el cálculo de **utilidad neta** considerando notas de crédito.
|
||||
|
||||
### Archivos modificados
|
||||
| Archivo | Cambio |
|
||||
|---|---|
|
||||
| `apps/api/src/services/dashboard.service.ts` | Nuevos campos de notas de crédito |
|
||||
| `apps/web/app/(dashboard)/dashboard/page.tsx` | Scorecards y utilidad ajustada |
|
||||
| `packages/shared/src/types/dashboard.ts` | Tipos actualizados |
|
||||
|
||||
---
|
||||
|
||||
## 19. Feat: cron de recuperación SAT diario a las 10:00 AM
|
||||
|
||||
**Fecha:** 2026-06-14
|
||||
|
||||
### Cambio
|
||||
Se agregó un cron diario a las 10:00 AM (`America/Mexico_City`) que ejecuta el **recovery sync** para contribuyentes con FIEL activa y sync incompleto.
|
||||
|
||||
### Archivo modificado
|
||||
| Archivo | Cambio |
|
||||
|---|---|
|
||||
| `apps/api/src/jobs/sat-sync.job.ts` | Nuevo `runRecoverySyncJob` y scheduling |
|
||||
|
||||
---
|
||||
|
||||
## 20. Fix: pagar plan actual en trial_expired y planes > $10k
|
||||
|
||||
**Fecha:** 2026-06-16
|
||||
|
||||
### Problema
|
||||
- Los tenants con suscripción `trial_expired` no podían pagar el plan actual.
|
||||
- Planes Business Control/Enterprise superiores a $10,000 no podían usar preapproval de MercadoPago.
|
||||
|
||||
### Solución
|
||||
- Se permite pagar el plan actual incluso en estado `trial_expired`.
|
||||
- Planes caros usan una **Preference one-off anual** de MercadoPago en lugar de preapproval.
|
||||
- Se agregó el campo `mpPreferenceId` a `subscriptions` para trackear la preference.
|
||||
- El webhook de MP maneja tanto preapproval como preference.
|
||||
|
||||
### Archivos modificados
|
||||
| Archivo | Cambio |
|
||||
|---|---|
|
||||
| `apps/api/src/controllers/despacho.controller.ts` | Lógica de pago de plan actual |
|
||||
| `apps/api/src/controllers/subscription.controller.ts` | Soporte de preference |
|
||||
| `apps/api/src/controllers/webhook.controller.ts` | Webhook para preference |
|
||||
| `apps/api/src/services/payment/mercadopago.service.ts` | Creación de preference anual |
|
||||
| `apps/api/src/services/payment/subscription.service.ts` | Pago de plan actual trial_expired |
|
||||
| `apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx` | UI para pagar plan actual vencido |
|
||||
| `apps/api/prisma/schema.prisma` | Campo `mpPreferenceId` |
|
||||
|
||||
---
|
||||
|
||||
## 21. Feat: configuración de notificaciones por rol
|
||||
|
||||
**Fecha:** 2026-06-17
|
||||
|
||||
### Cambio
|
||||
Se reemplazó el sistema de preferencias de notificación por contribuyente por uno por **rol**:
|
||||
- `owner`, `supervisor`, `auxiliar` y `cliente` pueden activar/desactivar tipos de correo.
|
||||
- Tipos: `documento_subido`, `weekly_update`, `subscription_expiring`, `recordatorio_fiscal`, `alertas_nuevas`, `recordatorio_proximo`.
|
||||
- La tabla `notification_role_preferences` almacena la configuración por tenant.
|
||||
|
||||
### Archivos creados
|
||||
| Archivo | Cambio |
|
||||
|---|---|
|
||||
| `apps/api/src/migrations/tenant/051_notification_role_preferences.sql` | Tabla de preferencias por rol |
|
||||
|
||||
### Archivos modificados
|
||||
| Archivo | Cambio |
|
||||
|---|---|
|
||||
| `apps/api/src/services/notification-preferences.service.ts` | Nuevo modelo por rol |
|
||||
| `apps/api/src/controllers/notification-preferences.controller.ts` | CRUD por rol |
|
||||
| `apps/api/src/services/notifications.service.ts` | Filtrado por rol |
|
||||
| `apps/api/src/services/notify-upload.service.ts` | Usa preferencias por rol |
|
||||
| `apps/api/src/services/payment/subscription.service.ts` | Usa preferencias por rol |
|
||||
| `apps/api/src/jobs/weekly-update.job.ts` | Filtrado por rol |
|
||||
| `apps/web/app/(dashboard)/configuracion/notificaciones/page.tsx` | UI de configuración |
|
||||
|
||||
---
|
||||
|
||||
## 22. Fix: quitar badge "Próximamente" de notificaciones ya existentes
|
||||
|
||||
**Fecha:** 2026-06-16
|
||||
|
||||
### Cambio
|
||||
Se quitó el badge "Próximamente" de los tipos de notificación que ya estaban implementados.
|
||||
|
||||
### Archivos modificados
|
||||
| Archivo | Cambio |
|
||||
|---|---|
|
||||
| `apps/api/src/services/notification-preferences.service.ts` | Marcado de implementados |
|
||||
| `apps/web/app/(dashboard)/configuracion/notificaciones/page.tsx` | Badges actualizados |
|
||||
|
||||
---
|
||||
|
||||
## 23. Feat: drill-down en pestaña nueva, rol Vendedor y scripts demo
|
||||
|
||||
**Fecha:** 2026-06-22
|
||||
|
||||
### Cambios
|
||||
- El **drill-down de impuestos** ahora se abre en una pestaña nueva (`/drill-down`).
|
||||
- Se agregó el rol **Vendedor** con acceso limitado a invitaciones trial y dashboard.
|
||||
- Se crearon scripts de soporte para demos:
|
||||
- `add-demo-cfdis.ts`
|
||||
- `add-demo-notas-credito.ts`
|
||||
- `create-vendedor-fernando.ts`
|
||||
|
||||
### Archivos creados
|
||||
| Archivo | Cambio |
|
||||
|---|---|
|
||||
| `apps/api/scripts/add-demo-cfdis.ts` | CFDIs de demo |
|
||||
| `apps/api/scripts/add-demo-notas-credito.ts` | Notas de crédito de demo |
|
||||
| `apps/api/scripts/create-vendedor-fernando.ts` | Crea usuario vendedor de demo |
|
||||
|
||||
### Archivos modificados
|
||||
| Archivo | Cambio |
|
||||
|---|---|
|
||||
| `apps/web/app/(dashboard)/drill-down/page.tsx` | Página de drill-down |
|
||||
| `apps/web/app/(dashboard)/impuestos/page.tsx` | Link a drill-down en pestaña nueva |
|
||||
| `apps/web/app/(dashboard)/dashboard/page.tsx` | Integración con drill-down |
|
||||
| `apps/web/app/(dashboard)/admin/staff/page.tsx` | Rol Vendedor en staff |
|
||||
| `apps/web/components/layouts/sidebar.tsx` | Menú para Vendedor |
|
||||
| `packages/shared-ui/src/charts/kpi-card.tsx` | Soporte de link externo |
|
||||
|
||||
---
|
||||
|
||||
## 24. Fix: evita logout al cambiar de tenant
|
||||
|
||||
**Fecha:** 2026-06-22
|
||||
|
||||
### Problema
|
||||
Al cambiar de empresa con el selector de tenants, múltiples requests en vuelo con el token viejo intentaban refrescar simultáneamente, causando logout o 401.
|
||||
|
||||
### Solución
|
||||
- Se agregó un **mutex de refresh token** en el interceptor de Axios.
|
||||
- Se marca el estado `isSwitching` en `membership-switcher` y `mis-empresas` para evitar clicks múltiples.
|
||||
- El backend invalida el refresh token anterior al hacer switch.
|
||||
|
||||
### Archivos modificados
|
||||
| Archivo | Cambio |
|
||||
|---|---|
|
||||
| `apps/web/lib/api/client.ts` | Mutex de refresh |
|
||||
| `apps/web/components/membership-switcher.tsx` | Flag `isSwitching` |
|
||||
| `apps/web/app/(dashboard)/mis-empresas/page.tsx` | Flag `isSwitching` |
|
||||
| `apps/api/src/services/auth.service.ts` | Invalidación de refresh token anterior |
|
||||
|
||||
---
|
||||
|
||||
## 25. Fix: Vendedor accede a invitaciones trial
|
||||
|
||||
**Fecha:** 2026-06-22
|
||||
|
||||
### Cambio
|
||||
El rol **Vendedor** puede ver y compartir invitaciones **trial**, pero no puede invitar clientes directamente.
|
||||
|
||||
### Archivos modificados
|
||||
| Archivo | Cambio |
|
||||
|---|---|
|
||||
| `apps/api/src/controllers/client-invitations.controller.ts` | Permisos para vendedor |
|
||||
| `apps/api/src/controllers/tenants.controller.ts` | Permisos para vendedor |
|
||||
| `apps/web/app/(dashboard)/admin/staff/page.tsx` | UI de permisos |
|
||||
| `apps/web/components/layouts/sidebar.tsx` | Menú visible para vendedor |
|
||||
|
||||
---
|
||||
|
||||
## 26. Feat: monitor de sincronización SAT
|
||||
|
||||
**Fecha:** 2026-06-22
|
||||
|
||||
### Cambio
|
||||
Se agregó un cron de monitoreo de sincronizaciones SAT (cada 2 horas por defecto). Detecta:
|
||||
- Jobs fallidos en las últimas N horas.
|
||||
- Jobs pending sin atender.
|
||||
- Jobs running atascados.
|
||||
- Contribuyentes con FIEL activa pero sin sync inicial.
|
||||
|
||||
Envía un correo de alerta a `SAT_ALERT_EMAIL` (o `ADMIN_EMAIL` si no está configurado).
|
||||
|
||||
### Variables de entorno
|
||||
| Variable | Default | Descripción |
|
||||
|---|---|---|
|
||||
| `SAT_ALERT_EMAIL` | `ADMIN_EMAIL` | Destino de alertas SAT |
|
||||
| `SAT_MONITOR_SCHEDULE` | `0 */2 * * *` | Cron del monitor |
|
||||
| `SAT_STUCK_RUNNING_HOURS` | `2` | Horas para considerar un job running como atorado |
|
||||
| `SAT_FAILED_LOOKBACK_HOURS` | `24` | Ventana hacia atrás para reportar jobs fallidos |
|
||||
|
||||
### Archivos creados
|
||||
| Archivo | Cambio |
|
||||
|---|---|
|
||||
| `apps/api/src/jobs/sat-sync-monitor.job.ts` | Lógica del monitor |
|
||||
| `apps/api/src/services/email/templates/sat-sync-alert.ts` | Template de alerta |
|
||||
|
||||
### Archivos modificados
|
||||
| Archivo | Cambio |
|
||||
|---|---|
|
||||
| `apps/api/src/index.ts` | Arranca el monitor |
|
||||
| `apps/api/src/config/env.ts` | Validación de variables |
|
||||
| `apps/api/.env.example` | Documentación de variables |
|
||||
| `apps/api/src/services/email/email.service.ts` | `sendSatSyncAlert` |
|
||||
|
||||
---
|
||||
|
||||
## 27. Feat: reactivación de contribuyentes y limpieza al desactivar
|
||||
|
||||
**Fecha:** 2026-06-22
|
||||
|
||||
### Cambios
|
||||
- Al crear un contribuyente cuyo RFC ya existía desactivado, se **reactiva** la entidad, se actualizan sus datos y se recupera su historial (CFDIs, FIEL, tareas, etc.).
|
||||
- Al desactivar un contribuyente, se limpia de `cartera_entidades`, `cliente_accesos` y se desactiva su FIEL para que no siga sincronizándose.
|
||||
- El timeout del proceso de constancia se aumentó a **5 minutos**.
|
||||
|
||||
### Archivos modificados
|
||||
| Archivo | Cambio |
|
||||
|---|---|
|
||||
| `apps/api/src/services/contribuyente.service.ts` | Reactivación y limpieza al desactivar |
|
||||
| `apps/api/src/controllers/contribuyente.controller.ts` | Retorna `reactivated` y status 200/201 |
|
||||
| `apps/api/src/services/constancia.service.ts` | Timeout 5 min |
|
||||
| `apps/web/app/(dashboard)/contribuyentes/page.tsx` | Alerta al reactivar |
|
||||
| `apps/web/lib/api/contribuyentes.ts` | Tipo `reactivated` |
|
||||
|
||||
---
|
||||
|
||||
## 28. Fix: scraper de Constancia de Situación Fiscal más robusto
|
||||
|
||||
**Fecha:** 2026-06-22
|
||||
|
||||
### Cambio
|
||||
Se reescribió el scraper de CSF para soportar los múltiples flujos del portal SAT:
|
||||
- Búsqueda de PDF en frames, iframes, embeds, links `blob:`/`data:` y popups.
|
||||
- Validación de que el buffer descargado comienza con `%PDF-`.
|
||||
- Fallback de click por `dispatchEvent` si el primer click no dispara el handler JSF.
|
||||
|
||||
### Archivo modificado
|
||||
| Archivo | Cambio |
|
||||
|---|---|
|
||||
| `apps/api/src/services/sat/sat-csf-scraper.ts` | Reescritura del scraper |
|
||||
|
||||
## Deploy
|
||||
|
||||
### Preparación
|
||||
|
||||
1. Asegurarse de que `apps/api/.env` tenga las nuevas variables (tienen defaults, pero conviene declararlas):
|
||||
```bash
|
||||
SAT_ALERT_EMAIL=carlos@horuxfin.com
|
||||
SAT_MONITOR_SCHEDULE=0 */2 * * *
|
||||
SAT_STUCK_RUNNING_HOURS=2
|
||||
SAT_FAILED_LOOKBACK_HOURS=24
|
||||
```
|
||||
2. Si hay scripts de debug antiguos en `apps/api/scripts/`, limpiar los que no sean necesarios antes de commitear.
|
||||
|
||||
### Comandos
|
||||
|
||||
```bash
|
||||
cd /root/HoruxDespachosNuevo
|
||||
pnpm --filter @horux/core build
|
||||
|
||||
Reference in New Issue
Block a user