feat(sat): reporte diario de errores SAT por proxy

- Agrega tabla sat_proxy_errors con proxy_usado, error_code, stage, message.
- sat-client.service.ts expone proxyInfo usado en cada conexión SAT.
- sat.service.ts registra errores de bloqueo/devolución del SAT en sat_proxy_errors
  y guarda proxyUsed en sat_sync_jobs.
- Nuevo job sat-proxy-report.job.ts: cron 8 AM CDMX, envía email a ADMIN_EMAIL
  con errores de las últimas 24h agrupados por proxy/error_code.
- Template de email sat-proxy-report.ts y método sendSatProxyReport.
- Registra el cron en src/index.ts.
- Actualiza docs/SAT-SYNC-IMPLEMENTATION.md.
This commit is contained in:
Horux Dev
2026-08-03 14:29:54 +00:00
parent b43c9f334a
commit a008659eda
10 changed files with 274 additions and 7 deletions

View File

@@ -0,0 +1,2 @@
-- Agrega proxy_used a sat_sync_jobs para diagnosticar bloqueos por IP
ALTER TABLE "sat_sync_jobs" ADD COLUMN IF NOT EXISTS "proxy_used" VARCHAR(255);

View File

@@ -0,0 +1,13 @@
-- Tabla de errores SAT por proxy para reportes diarios
CREATE TABLE IF NOT EXISTS "sat_proxy_errors" (
"id" TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
"job_id" TEXT NOT NULL REFERENCES "sat_sync_jobs"("id") ON DELETE CASCADE,
"proxy_used" VARCHAR(255),
"error_code" VARCHAR(50),
"stage" VARCHAR(255),
"message" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS "sat_proxy_errors_created_at_idx" ON "sat_proxy_errors"("created_at");
CREATE INDEX IF NOT EXISTS "sat_proxy_errors_proxy_used_created_at_idx" ON "sat_proxy_errors"("proxy_used", "created_at");

View File

@@ -677,8 +677,12 @@ model SatSyncJob {
// usuario (botón UI). Cambia la política de retry: 2 intentos vs 3 del // usuario (botón UI). Cambia la política de retry: 2 intentos vs 3 del
// bootstrap puro. Daily/incremental ignoran este campo. // bootstrap puro. Daily/incremental ignoran este campo.
isCustomRange Boolean @default(false) @map("is_custom_range") isCustomRange Boolean @default(false) @map("is_custom_range")
// Proxy usado en el último request SAT que falló (host:port). Ayuda a diagnosticar
// bloqueos por IP y a generar reportes diarios de errores por proxy.
proxyUsed String? @map("proxy_used") @db.VarChar(255)
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
proxyErrors SatProxyError[]
@@index([tenantId]) @@index([tenantId])
@@index([status]) @@index([status])
@@ -686,6 +690,24 @@ model SatSyncJob {
@@map("sat_sync_jobs") @@map("sat_sync_jobs")
} }
// Errores de bloqueo/devolución del SAT por proxy.
// Permite reportes diarios de cuántos 404/500X/etc. ocurrieron en cada IP.
model SatProxyError {
id String @id @default(uuid())
jobId String @map("job_id")
proxyUsed String? @map("proxy_used") @db.VarChar(255)
errorCode String? @map("error_code") @db.VarChar(50)
stage String? @map("stage") @db.VarChar(255)
message String? @map("message")
createdAt DateTime @default(now()) @map("created_at")
job SatSyncJob @relation(fields: [jobId], references: [id], onDelete: Cascade)
@@index([createdAt])
@@index([proxyUsed, createdAt])
@@map("sat_proxy_errors")
}
enum SatSyncType { enum SatSyncType {
initial initial
daily daily

View File

@@ -7,6 +7,7 @@ import { startWeeklyUpdateJob } from './jobs/weekly-update.job.js';
import { startMetricasInvalidationsJob } from './jobs/metricas-invalidations.job.js'; import { startMetricasInvalidationsJob } from './jobs/metricas-invalidations.job.js';
import { startNotificationsJob } from './jobs/notifications.job.js'; import { startNotificationsJob } from './jobs/notifications.job.js';
import { startSatSyncMonitorJob } from './jobs/sat-sync-monitor.job.js'; import { startSatSyncMonitorJob } from './jobs/sat-sync-monitor.job.js';
import { startSatProxyReportJob } from './jobs/sat-proxy-report.job.js';
import { startRecordatoriosPeriodicosJob } from './jobs/recordatorios-periodicos.job.js'; import { startRecordatoriosPeriodicosJob } from './jobs/recordatorios-periodicos.job.js';
const PORT = parseInt(env.PORT, 10); const PORT = parseInt(env.PORT, 10);
@@ -26,6 +27,7 @@ const server = app.listen(PORT, '0.0.0.0', () => {
startSatSyncJob(); startSatSyncJob();
startMetricasInvalidationsJob(); startMetricasInvalidationsJob();
startSatSyncMonitorJob(); startSatSyncMonitorJob();
startSatProxyReportJob();
startRecordatoriosPeriodicosJob(); startRecordatoriosPeriodicosJob();
if (sendRealEmails) { if (sendRealEmails) {
startWeeklyUpdateJob(); startWeeklyUpdateJob();

View File

@@ -0,0 +1,93 @@
import cron from 'node-cron';
import { prisma } from '../config/database.js';
import { env } from '../config/env.js';
import { emailService } from '../services/email/email.service.js';
import type { SatProxyReportData } from '../services/email/templates/sat-proxy-report.js';
const PROXY_REPORT_CRON_SCHEDULE = '0 8 * * *'; // 8:00 AM CDMX diario
let reportTask: ReturnType<typeof cron.schedule> | null = null;
function cdmxDateString(d: Date): string {
return d.toLocaleDateString('es-MX', { timeZone: 'America/Mexico_City' });
}
function hoursAgo(hours: number): Date {
return new Date(Date.now() - hours * 60 * 60 * 1000);
}
export async function runSatProxyReport(): Promise<void> {
console.log('[SAT Proxy Report] Generando reporte diario de errores por proxy');
try {
const cutoff = hoursAgo(24);
const rows = await prisma.$queryRaw<Array<{ proxy: string | null; errorCode: string | null; count: bigint }>>`
SELECT proxy_used AS proxy, error_code AS "errorCode", COUNT(*) AS count
FROM sat_proxy_errors
WHERE created_at >= ${cutoff}
GROUP BY proxy_used, error_code
ORDER BY count DESC, proxy_used ASC, error_code ASC
`;
const byProxy = rows.map(r => ({
proxy: r.proxy || 'IP directa del servidor',
errorCode: r.errorCode || '—',
count: Number(r.count),
}));
const totalErrors = byProxy.reduce((sum, r) => sum + r.count, 0);
const now = new Date();
const data: SatProxyReportData = {
generatedAt: now.toLocaleString('es-MX', { timeZone: 'America/Mexico_City' }),
recipient: env.ADMIN_EMAIL,
dateFrom: cdmxDateString(hoursAgo(24)),
dateTo: cdmxDateString(now),
totalErrors,
byProxy,
};
const recipient = env.ADMIN_EMAIL;
if (!recipient) {
console.warn('[SAT Proxy Report] ADMIN_EMAIL no configurado, no se envía reporte');
return;
}
console.log(`[SAT Proxy Report] Enviando reporte a ${recipient}: ${totalErrors} errores en ${byProxy.length} grupos`);
await emailService.sendSatProxyReport(recipient, data);
console.log('[SAT Proxy Report] Reporte enviado');
} catch (error: any) {
console.error('[SAT Proxy Report] Error generando reporte:', error.message || error);
}
}
export function startSatProxyReportJob(): void {
if (reportTask) {
console.log('[SAT Proxy Report] Job ya está programado');
return;
}
if (!cron.validate(PROXY_REPORT_CRON_SCHEDULE)) {
console.error('[SAT Proxy Report] Expresión cron inválida:', PROXY_REPORT_CRON_SCHEDULE);
return;
}
reportTask = cron.schedule(PROXY_REPORT_CRON_SCHEDULE, async () => {
try {
await runSatProxyReport();
} catch (error: any) {
console.error('[SAT Proxy Report Cron] Error:', error.message || error);
}
}, {
timezone: 'America/Mexico_City',
});
console.log(`[SAT Proxy Report] Programado: ${PROXY_REPORT_CRON_SCHEDULE} (America/Mexico_City)`);
}
export function stopSatProxyReportJob(): void {
if (reportTask) {
reportTask.stop();
reportTask = null;
}
}

View File

@@ -50,6 +50,11 @@ export const emailService = {
await sendEmail(to, `🚨 Alerta SAT: ${total} anomalía${total === 1 ? '' : 's'} detectada${total === 1 ? '' : 's'}`, satSyncAlertEmail(data)); await sendEmail(to, `🚨 Alerta SAT: ${total} anomalía${total === 1 ? '' : 's'} detectada${total === 1 ? '' : 's'}`, satSyncAlertEmail(data));
}, },
sendSatProxyReport: async (to: string, data: import('./templates/sat-proxy-report.js').SatProxyReportData) => {
const { satProxyReportEmail } = await import('./templates/sat-proxy-report.js');
await sendEmail(to, `📊 Reporte diario SAT: ${data.totalErrors} errores por proxy`, satProxyReportEmail(data));
},
sendSubscriptionExpiring: async (to: string, data: { nombre: string; plan: string; expiresAt: string }) => { sendSubscriptionExpiring: async (to: string, data: { nombre: string; plan: string; expiresAt: string }) => {
const { subscriptionExpiringEmail } = await import('./templates/subscription-expiring.js'); const { subscriptionExpiringEmail } = await import('./templates/subscription-expiring.js');
await sendEmail(to, 'Tu suscripción vence en 5 días', subscriptionExpiringEmail(data)); await sendEmail(to, 'Tu suscripción vence en 5 días', subscriptionExpiringEmail(data));

View File

@@ -0,0 +1,63 @@
import { baseTemplate, heading, infoBox, BRAND_COLORS as C } from './base.js';
export interface ProxyErrorRow {
proxy: string;
errorCode: string;
count: number;
}
export interface SatProxyReportData {
generatedAt: string;
recipient: string;
dateFrom: string;
dateTo: string;
totalErrors: number;
byProxy: ProxyErrorRow[];
}
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>`;
}
export function satProxyReportEmail(data: SatProxyReportData): string {
const rowsHtml = data.byProxy.length > 0
? data.byProxy.map(r => tableRow([
r.proxy ? `<code style="font-size:12px;">${r.proxy}</code>` : '<span style="color:#dc2626;">IP directa del servidor</span>',
r.errorCode || '—',
`<strong>${r.count}</strong>`,
])).join('')
: tableRow(['—', '—', '<span style="color:#16a34a;">Sin errores de bloqueo</span>']);
return baseTemplate(`
${heading('📊 Reporte diario de errores SAT por proxy')}
<p style="color:${C.textPrimary};margin:0 0 16px;">
Resumen de errores de bloqueo/devolución del SAT en las últimas 24 horas,
agrupados por proxy/IP usada.
</p>
${infoBox(`
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td style="padding:6px 0;color:${C.textMuted};">Período</td><td style="padding:6px 0;color:${C.textPrimary};font-weight:600;text-align:right;">${data.dateFrom}${data.dateTo}</td></tr>
<tr><td style="padding:6px 0;color:${C.textMuted};">Errores totales</td><td style="padding:6px 0;color:${data.totalErrors > 0 ? '#dc2626' : '#16a34a'};font-weight:600;text-align:right;">${data.totalErrors}</td></tr>
</table>
`)}
<h3 style="font-family:'Inter', sans-serif;font-weight:600;color:${C.textPrimary};margin:28px 0 12px;font-size:16px;">Errores por proxy (${data.byProxy.length})</h3>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
<thead>${tableHeader(['Proxy / IP', 'Código', 'Cantidad'])}</thead>
<tbody>${rowsHtml}</tbody>
</table>
<p style="color:${C.textMuted};margin:24px 0 0;font-size:12px;">
Reporte generado el ${data.generatedAt} para ${data.recipient}.<br/>
Configura la lista de proxies con SAT_PROXY_LIST y la concurrencia con SAT_CONCURRENT_CONTRIBUYENTES.
</p>
`);
}

View File

@@ -18,6 +18,12 @@ export interface FielData {
password: string; password: string;
} }
export interface ProxyInfo {
host: string;
port: number;
url: string;
}
/** /**
* Timeout explícito para el cliente HTTP del SAT (ms). * Timeout explícito para el cliente HTTP del SAT (ms).
* *
@@ -38,7 +44,7 @@ const SAT_WEB_CLIENT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutos
/** /**
* Crea el servicio de descarga masiva del SAT usando los datos de la FIEL * Crea el servicio de descarga masiva del SAT usando los datos de la FIEL
*/ */
export function createSatService(fielData: FielData): Service { export function createSatService(fielData: FielData): { service: Service; proxyInfo: ProxyInfo | null } {
// Crear FIEL usando el método estático create // Crear FIEL usando el método estático create
const fiel = Fiel.create(fielData.cerContent, fielData.keyContent, fielData.password); const fiel = Fiel.create(fielData.cerContent, fielData.keyContent, fielData.password);
@@ -50,7 +56,8 @@ export function createSatService(fielData: FielData): Service {
// Crear cliente HTTP con timeout explícito para evitar el bug de la librería // Crear cliente HTTP con timeout explícito para evitar el bug de la librería
// cuando ocurre un timeout de red. Si hay proxies configurados, se usa uno // cuando ocurre un timeout de red. Si hay proxies configurados, se usa uno
// del pool para reducir el riesgo de bloqueo por IP del SAT. // del pool para reducir el riesgo de bloqueo por IP del SAT.
const proxyAgent = proxyManager.createNextAgent(); const proxy = proxyManager.getNextProxy();
const proxyAgent = proxy ? proxyManager.createAgent(proxy) : null;
if (proxyAgent) { if (proxyAgent) {
console.log('[SAT] Usando proxy para la conexión con el SAT'); console.log('[SAT] Usando proxy para la conexión con el SAT');
} else { } else {
@@ -68,7 +75,11 @@ export function createSatService(fielData: FielData): Service {
const requestBuilder = new FielRequestBuilder(fiel); const requestBuilder = new FielRequestBuilder(fiel);
// Crear y retornar el servicio // Crear y retornar el servicio
return new Service(requestBuilder, webClient, undefined, ServiceEndpoints.cfdi()); const service = new Service(requestBuilder, webClient, undefined, ServiceEndpoints.cfdi());
return {
service,
proxyInfo: proxy ? { host: proxy.host, port: proxy.port, url: proxy.url } : null,
};
} }
export interface QueryResult { export interface QueryResult {

View File

@@ -8,6 +8,7 @@ import {
verifySatRequest, verifySatRequest,
downloadSatPackage, downloadSatPackage,
type FielData, type FielData,
type ProxyInfo,
} from './sat-client.service.js'; } from './sat-client.service.js';
import { processPackage, processMetadataPackage, extractXmlsFromZip, type CfdiParsed, type CfdiMetadata } from './sat-parser.service.js'; import { processPackage, processMetadataPackage, extractXmlsFromZip, type CfdiParsed, type CfdiMetadata } from './sat-parser.service.js';
import { recomputarSaldoPendiente, uuidsAfectadosPorCfdi } from '../../utils/saldo.js'; import { recomputarSaldoPendiente, uuidsAfectadosPorCfdi } from '../../utils/saldo.js';
@@ -91,6 +92,7 @@ function computeNextRetryAt(
interface SyncContext { interface SyncContext {
fielData: FielData; fielData: FielData;
service: Service; service: Service;
proxyInfo: ProxyInfo | null;
rfc: string; rfc: string;
tenantId: string; tenantId: string;
databaseName: string; databaseName: string;
@@ -98,6 +100,33 @@ interface SyncContext {
getPool: () => Promise<Pool>; getPool: () => Promise<Pool>;
} }
/**
* Registra un error de bloqueo/devolución del SAT junto con el proxy usado.
* Permite reportes diarios de errores por IP.
*/
async function recordSatProxyError(
jobId: string,
ctx: SyncContext,
errorCode: string | null,
stage: string,
message: string,
): Promise<void> {
try {
const proxyUsed = ctx.proxyInfo ? `${ctx.proxyInfo.host}:${ctx.proxyInfo.port}` : null;
await prisma.satProxyError.create({
data: {
jobId,
proxyUsed,
errorCode,
stage,
message,
},
});
} catch (err: any) {
console.error('[SAT] Error guardando sat_proxy_error:', err.message);
}
}
/** /**
* Actualiza el progreso de un job * Actualiza el progreso de un job
*/ */
@@ -117,6 +146,7 @@ async function updateJobProgress(
completedAt: Date; completedAt: Date;
retryCount: number; retryCount: number;
nextRetryAt: Date; nextRetryAt: Date;
proxyUsed: string | null;
}> }>
): Promise<void> { ): Promise<void> {
await prisma.satSyncJob.update({ await prisma.satSyncJob.update({
@@ -700,6 +730,7 @@ async function requestAndDownload(
return { packageContents: [], totalCfdis: 0 }; return { packageContents: [], totalCfdis: 0 };
} }
if (/error no controlado/i.test(queryResult.message || '')) { if (/error no controlado/i.test(queryResult.message || '')) {
await recordSatProxyError(jobId, ctx, queryResult.statusCode || '404', stageIdForTimeout(label), queryResult.message || 'Error no controlado');
if (isDaily) { if (isDaily) {
// En daily no detenemos el job por un 404 transitorio del SAT; se // En daily no detenemos el job por un 404 transitorio del SAT; se
// registra como no fatal para diagnóstico y se continúa. // registra como no fatal para diagnóstico y se continúa.
@@ -709,6 +740,7 @@ async function requestAndDownload(
console.warn(`[SAT] Rechazo transitorio del SAT (${label}): ${queryResult.message} — se reintentará`); console.warn(`[SAT] Rechazo transitorio del SAT (${label}): ${queryResult.message} — se reintentará`);
throw new SatTransientError(stageIdForTimeout(label), queryResult.message); throw new SatTransientError(stageIdForTimeout(label), queryResult.message);
} }
await recordSatProxyError(jobId, ctx, queryResult.statusCode || null, stageIdForTimeout(label), queryResult.message || 'Error SAT');
throw new Error(`Error SAT (${label}): ${queryResult.message}`); throw new Error(`Error SAT (${label}): ${queryResult.message}`);
} }
@@ -735,6 +767,7 @@ async function requestAndDownload(
console.log(`[SAT] Solicitudes agotadas de por vida (${label}); se cancela y se omite este rango.`); console.log(`[SAT] Solicitudes agotadas de por vida (${label}); se cancela y se omite este rango.`);
return { packageContents: [], totalCfdis: 0 }; return { packageContents: [], totalCfdis: 0 };
} }
await recordSatProxyError(jobId, ctx, verifyResult.status, stageIdForTimeout(label), verifyResult.message || `Solicitud ${verifyResult.status}`);
throw new Error(`Solicitud fallida (${label}): ${verifyResult.message}`); throw new Error(`Solicitud fallida (${label}): ${verifyResult.message}`);
} }
} }
@@ -1523,7 +1556,7 @@ export async function startSync(
password: decryptedFiel.password, password: decryptedFiel.password,
}; };
const service = createSatService(fielData); const { service, proxyInfo } = createSatService(fielData);
const tenant = await prisma.tenant.findUnique({ const tenant = await prisma.tenant.findUnique({
where: { id: tenantId }, where: { id: tenantId },
@@ -1564,12 +1597,14 @@ export async function startSync(
dateTo: dateTo || now, dateTo: dateTo || now,
startedAt: now, startedAt: now,
isCustomRange, isCustomRange,
proxyUsed: proxyInfo ? `${proxyInfo.host}:${proxyInfo.port}` : null,
}, },
}); });
const ctx: SyncContext = { const ctx: SyncContext = {
fielData, fielData,
service, service,
proxyInfo,
rfc: decryptedFiel.rfc, rfc: decryptedFiel.rfc,
tenantId, tenantId,
databaseName: tenant.databaseName, databaseName: tenant.databaseName,
@@ -1721,7 +1756,7 @@ export async function retryTimedOutJobs(): Promise<void> {
continue; continue;
} }
const service = createSatService({ const { service, proxyInfo } = createSatService({
cerContent: decryptedFiel.cerContent, cerContent: decryptedFiel.cerContent,
keyContent: decryptedFiel.keyContent, keyContent: decryptedFiel.keyContent,
password: decryptedFiel.password, password: decryptedFiel.password,
@@ -1734,6 +1769,7 @@ export async function retryTimedOutJobs(): Promise<void> {
password: decryptedFiel.password, password: decryptedFiel.password,
}, },
service, service,
proxyInfo,
rfc: decryptedFiel.rfc, rfc: decryptedFiel.rfc,
tenantId: job.tenantId, tenantId: job.tenantId,
databaseName: job.tenant.databaseName, databaseName: job.tenant.databaseName,
@@ -1744,7 +1780,7 @@ export async function retryTimedOutJobs(): Promise<void> {
// B: resetear startedAt al inicio de ESTE intento para que el watchdog // B: resetear startedAt al inicio de ESTE intento para que el watchdog
// mida el intento actual (y no mate un retry legítimo por el startedAt // mida el intento actual (y no mate un retry legítimo por el startedAt
// original del job). La política de retries se ancla a createdAt. // original del job). La política de retries se ancla a createdAt.
await updateJobProgress(job.id, { status: 'running', errorMessage: null as any, startedAt: new Date() }); await updateJobProgress(job.id, { status: 'running', errorMessage: null as any, startedAt: new Date(), proxyUsed: proxyInfo ? `${proxyInfo.host}:${proxyInfo.port}` : null });
// Para jobs daily, intentamos retomar desde la última etapa completada. // Para jobs daily, intentamos retomar desde la última etapa completada.
let resumeFromStage: string | undefined; let resumeFromStage: string | undefined;
@@ -1890,7 +1926,7 @@ export async function continuePendingDailyRequests(): Promise<void> {
continue; continue;
} }
const service = createSatService({ const { service, proxyInfo } = createSatService({
cerContent: decryptedFiel.cerContent, cerContent: decryptedFiel.cerContent,
keyContent: decryptedFiel.keyContent, keyContent: decryptedFiel.keyContent,
password: decryptedFiel.password, password: decryptedFiel.password,
@@ -1903,6 +1939,7 @@ export async function continuePendingDailyRequests(): Promise<void> {
password: decryptedFiel.password, password: decryptedFiel.password,
}, },
service, service,
proxyInfo,
rfc: decryptedFiel.rfc, rfc: decryptedFiel.rfc,
tenantId: job.tenantId, tenantId: job.tenantId,
databaseName: job.tenant.databaseName, databaseName: job.tenant.databaseName,

View File

@@ -94,6 +94,7 @@ Definidos en `apps/api/src/jobs/sat-sync.job.ts`:
| Incremental Enterprise | `0 11,15,19 * * *` | 11 AM, 3 PM, 7 PM | Sync incremental | | Incremental Enterprise | `0 11,15,19 * * *` | 11 AM, 3 PM, 7 PM | Sync incremental |
| SAT Watchdog | `0 */2 * * *` | Cada 2 horas | Marcar jobs `running` sin heartbeat como failed | | SAT Watchdog | `0 */2 * * *` | Cada 2 horas | Marcar jobs `running` sin heartbeat como failed |
| SAT Monitor | `0 */2 * * *` | Cada 2 horas | Alertar por email de jobs fallidos | | SAT Monitor | `0 */2 * * *` | Cada 2 horas | Alertar por email de jobs fallidos |
| SAT Proxy Report | `0 8 * * *` | 8:00 AM | Reporte diario de errores SAT por proxy |
## 6. Flujo de sincronización ## 6. Flujo de sincronización
@@ -144,6 +145,21 @@ SAT_CONCURRENT_CONTRIBUYENTES=10
- **`ProxyManager`** (`apps/api/src/services/sat/proxy.service.ts`): parsea `SAT_PROXY_LIST`, rota proxies y crea `HttpsProxyAgent`. - **`ProxyManager`** (`apps/api/src/services/sat/proxy.service.ts`): parsea `SAT_PROXY_LIST`, rota proxies y crea `HttpsProxyAgent`.
- **`sat-client.service.ts`**: usa el agente del proxy en cada petición SOAP al SAT. - **`sat-client.service.ts`**: usa el agente del proxy en cada petición SOAP al SAT.
### Reporte diario de errores por proxy
Cada error de bloqueo/devolución del SAT se guarda en `public.sat_proxy_errors` con el proxy usado.
Un cron a las **8:00 AM CDMX** envía un email a `ADMIN_EMAIL` con:
- Total de errores en las últimas 24 horas.
- Tabla agrupada por `proxy` + `error_code`.
Archivos:
- `apps/api/src/jobs/sat-proxy-report.job.ts` — cron y consulta.
- `apps/api/src/services/email/templates/sat-proxy-report.ts` — template del email.
- `apps/api/src/services/email/email.service.ts``sendSatProxyReport`.
### Comportamiento ### Comportamiento
- Cada llamada a `getNextProxy()` devuelve el siguiente proxy del pool (round-robin). - Cada llamada a `getNextProxy()` devuelve el siguiente proxy del pool (round-robin).
@@ -269,6 +285,8 @@ psql "$DATABASE_URL" -c "
- **Proxies SAT rotativos**: integración de `ProxyManager` y pool de proxies HTTP/HTTPS para evitar bloqueo por IP del SAT. - **Proxies SAT rotativos**: integración de `ProxyManager` y pool de proxies HTTP/HTTPS para evitar bloqueo por IP del SAT.
- **Concurrencia por contribuyente**: el scheduler daily e incremental procesa hasta `SAT_CONCURRENT_CONTRIBUYENTES=10` RFCs en paralelo, sin importar a cuántos tenants pertenezcan. - **Concurrencia por contribuyente**: el scheduler daily e incremental procesa hasta `SAT_CONCURRENT_CONTRIBUYENTES=10` RFCs en paralelo, sin importar a cuántos tenants pertenezcan.
- **Reporte diario de errores por proxy**: cron a las 8 AM CDMX que envía a `ADMIN_EMAIL` un resumen de errores SAT agrupados por proxy/IP.
- Tabla `public.sat_proxy_errors` para trazabilidad de bloqueos por IP.
- Variables de entorno: `SAT_PROXY_LIST`, `SAT_PROXY_STRATEGY`, `SAT_PROXY_FALLBACK_DIRECT`, `SAT_CONCURRENT_CONTRIBUYENTES`. - Variables de entorno: `SAT_PROXY_LIST`, `SAT_PROXY_STRATEGY`, `SAT_PROXY_FALLBACK_DIRECT`, `SAT_CONCURRENT_CONTRIBUYENTES`.
### 2026-07-31 ### 2026-07-31
@@ -288,6 +306,7 @@ psql "$DATABASE_URL" -c "
## 15. Próximos pasos ## 15. Próximos pasos
- [x] Implementar proxies rotativos para evitar bloqueo por IP del SAT. - [x] Implementar proxies rotativos para evitar bloqueo por IP del SAT.
- [x] Reporte diario de errores por proxy.
- [ ] Monitorear tasa de éxito tras proxies + concurrencia por contribuyente. - [ ] Monitorear tasa de éxito tras proxies + concurrencia por contribuyente.
- [ ] Revisar/renovar FIELs inválidas reportadas por el monitor. - [ ] Revisar/renovar FIELs inválidas reportadas por el monitor.
- [ ] Evaluar ampliar ventana horaria del daily (610 AM) si el volumen de RFCs supera el throughput con 10 paralelos. - [ ] Evaluar ampliar ventana horaria del daily (610 AM) si el volumen de RFCs supera el throughput con 10 paralelos.