Compare commits
9 Commits
3f31e25ae7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5636427be | ||
|
|
ad6eec51ef | ||
|
|
a008659eda | ||
|
|
b43c9f334a | ||
|
|
099f34c903 | ||
|
|
3de0014e80 | ||
|
|
b39bbcdd0a | ||
|
|
5489c84e6b | ||
|
|
24d35333df |
@@ -101,3 +101,6 @@ SAT_PROXY_LIST=
|
|||||||
SAT_PROXY_STRATEGY=round-robin
|
SAT_PROXY_STRATEGY=round-robin
|
||||||
# Si es true, cuando todos los proxies fallan se intenta con la IP directa del servidor.
|
# Si es true, cuando todos los proxies fallan se intenta con la IP directa del servidor.
|
||||||
SAT_PROXY_FALLBACK_DIRECT=true
|
SAT_PROXY_FALLBACK_DIRECT=true
|
||||||
|
# Máximo de contribuyentes sincronizados en paralelo por el scheduler (default: 10).
|
||||||
|
# Aprovecha el pool de proxies; cada RFC suele usar una IP distinta en round-robin.
|
||||||
|
SAT_CONCURRENT_CONTRIBUYENTES=10
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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");
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Deshabilita SAT incremental para business_control; solo daily + retry programado.
|
||||||
|
UPDATE "despacho_plan_prices"
|
||||||
|
SET "permite_sat_incremental" = false
|
||||||
|
WHERE "plan" = 'business_control';
|
||||||
@@ -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
|
||||||
|
|||||||
20
apps/api/scripts/test-proxy-rotation.ts
Normal file
20
apps/api/scripts/test-proxy-rotation.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { ProxyManager } from '../src/services/sat/proxy.service.js';
|
||||||
|
|
||||||
|
const manager = new ProxyManager(
|
||||||
|
process.env.SAT_PROXY_LIST || '',
|
||||||
|
(process.env.SAT_PROXY_STRATEGY as any) || 'round-robin',
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Total de proxies: ${manager.getProxyCount()}`);
|
||||||
|
console.log(`Estrategia: ${process.env.SAT_PROXY_STRATEGY || 'round-robin'}`);
|
||||||
|
console.log('Próximos 10 proxies seleccionados:');
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const proxy = manager.getNextProxy();
|
||||||
|
if (!proxy) {
|
||||||
|
console.log(` ${i + 1}. (sin proxy configurado)`);
|
||||||
|
} else {
|
||||||
|
console.log(` ${i + 1}. ${proxy.host}:${proxy.port}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
93
apps/api/src/jobs/sat-proxy-report.job.ts
Normal file
93
apps/api/src/jobs/sat-proxy-report.job.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,8 @@ const SYNC_CRON_SCHEDULE = '0 6-10 * * *'; // 6:00–10:00 AM CDMX — ~20% de t
|
|||||||
const RECOVERY_CRON_SCHEDULE = '0 10 * * *'; // 10:00 AM todos los días
|
const RECOVERY_CRON_SCHEDULE = '0 10 * * *'; // 10:00 AM todos los días
|
||||||
const RETRY_9AM_CRON_SCHEDULE = '0 9 * * *'; // 9:00 AM todos los días
|
const RETRY_9AM_CRON_SCHEDULE = '0 9 * * *'; // 9:00 AM todos los días
|
||||||
const RETRY_4PM_CRON_SCHEDULE = '0 16 * * *'; // 4:00 PM todos los días
|
const RETRY_4PM_CRON_SCHEDULE = '0 16 * * *'; // 4:00 PM todos los días
|
||||||
const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas
|
const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas (legacy, se mantiene por compatibilidad)
|
||||||
|
const CONCURRENT_CONTRIBUYENTES = Number(process.env.SAT_CONCURRENT_CONTRIBUYENTES || '10'); // Máximo de contribuyentes en paralelo
|
||||||
const OPINION_CRON_SCHEDULE = '0 4 * * 0'; // Sundays 4:00 AM
|
const OPINION_CRON_SCHEDULE = '0 4 * * 0'; // Sundays 4:00 AM
|
||||||
const CSF_CRON_SCHEDULE = '0 4 1 * *'; // Día 1 de cada mes 04:00 AM (CSF mensual)
|
const CSF_CRON_SCHEDULE = '0 4 1 * *'; // Día 1 de cada mes 04:00 AM (CSF mensual)
|
||||||
const INCREMENTAL_CRON_SCHEDULE = '0 11,15,19 * * *'; // 11:00, 15:00 y 19:00; fuera de ese rango el daily (6-10 AM) cubre
|
const INCREMENTAL_CRON_SCHEDULE = '0 11,15,19 * * *'; // 11:00, 15:00 y 19:00; fuera de ese rango el daily (6-10 AM) cubre
|
||||||
@@ -133,7 +134,100 @@ async function getContribuyentesParaSync(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ejecuta sincronización para un tenant y sus contribuyentes
|
* Unidad mínima de sincronización: un tenant (legacy) o un contribuyente.
|
||||||
|
*/
|
||||||
|
interface SyncUnit {
|
||||||
|
tenantId: string;
|
||||||
|
contribuyenteId?: string;
|
||||||
|
syncType: 'initial' | 'daily' | 'incremental';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recolecta todas las unidades de sync para un conjunto de tenants.
|
||||||
|
* - Modo incremental: solo incluye contribuyentes/tenants con initial completado.
|
||||||
|
* - Modo daily/initial: determina initial vs daily por contribuyente.
|
||||||
|
*/
|
||||||
|
async function getSyncUnits(
|
||||||
|
tenantIds: string[],
|
||||||
|
options: { incremental?: boolean; logPrefix?: string } = {}
|
||||||
|
): Promise<SyncUnit[]> {
|
||||||
|
const { incremental = false, logPrefix = '[SAT Cron]' } = options;
|
||||||
|
const units: SyncUnit[] = [];
|
||||||
|
|
||||||
|
for (const tenantId of tenantIds) {
|
||||||
|
try {
|
||||||
|
const tenant = await prisma.tenant.findUnique({
|
||||||
|
where: { id: tenantId },
|
||||||
|
select: { databaseName: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let contribuyenteIds: string[] = [];
|
||||||
|
if (tenant?.databaseName) {
|
||||||
|
const { ids, total } = await getContribuyentesParaSync(tenantId, tenant.databaseName, logPrefix);
|
||||||
|
if (total > 0 && ids.length === 0) {
|
||||||
|
console.log(`${logPrefix} Tenant ${tenantId}: ningún contribuyente con FIEL vigente, se omite`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
contribuyenteIds = ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant legacy sin contribuyentes (Horux 360)
|
||||||
|
if (contribuyenteIds.length === 0) {
|
||||||
|
if (incremental) {
|
||||||
|
const hasInitial = await prisma.satSyncJob.findFirst({
|
||||||
|
where: { tenantId, contribuyenteId: null, type: 'initial', status: 'completed' },
|
||||||
|
});
|
||||||
|
if (!hasInitial) continue;
|
||||||
|
units.push({ tenantId, syncType: 'incremental' });
|
||||||
|
} else {
|
||||||
|
const needsInitial = await needsInitialSync(tenantId);
|
||||||
|
units.push({ tenantId, syncType: needsInitial ? 'initial' : 'daily' });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contribuyentes del tenant
|
||||||
|
for (const contribuyenteId of contribuyenteIds) {
|
||||||
|
if (incremental) {
|
||||||
|
const hasInitial = await prisma.satSyncJob.findFirst({
|
||||||
|
where: { tenantId, contribuyenteId, type: 'initial', status: 'completed' },
|
||||||
|
});
|
||||||
|
if (!hasInitial) continue;
|
||||||
|
units.push({ tenantId, contribuyenteId, syncType: 'incremental' });
|
||||||
|
} else {
|
||||||
|
const needsInitial = await needsInitialSync(tenantId, contribuyenteId);
|
||||||
|
units.push({ tenantId, contribuyenteId, syncType: needsInitial ? 'initial' : 'daily' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`${logPrefix} Error recolectando unidades para tenant ${tenantId}:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return units;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta sync para una unidad (tenant o contribuyente), respetando locks.
|
||||||
|
*/
|
||||||
|
async function syncUnit(unit: SyncUnit, logPrefix: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const status = await getSyncStatus(unit.tenantId, unit.contribuyenteId);
|
||||||
|
if (status.hasActiveSync) {
|
||||||
|
console.log(`${logPrefix} ${unit.tenantId}${unit.contribuyenteId ? ` contribuyente ${unit.contribuyenteId}` : ''} ya tiene sync activo, omitiendo`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${logPrefix} Iniciando sync ${unit.syncType} para ${unit.tenantId}${unit.contribuyenteId ? ` contribuyente ${unit.contribuyenteId}` : ''}`);
|
||||||
|
const jobId = await startSync(unit.tenantId, unit.syncType, undefined, undefined, unit.contribuyenteId);
|
||||||
|
console.log(`${logPrefix} Job ${jobId} iniciado`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`${logPrefix} Error sincronizando ${unit.tenantId}${unit.contribuyenteId ? ` contribuyente ${unit.contribuyenteId}` : ''}:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta sincronización para un tenant y sus contribuyentes (modo secuencial legacy)
|
||||||
*/
|
*/
|
||||||
async function syncTenant(tenantId: string): Promise<void> {
|
async function syncTenant(tenantId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -247,13 +341,22 @@ async function runSyncJob(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Procesar en lotes para no saturar
|
// Recolectar unidades de sync (contribuyentes o tenants legacy)
|
||||||
for (let i = 0; i < groupTenants.length; i += CONCURRENT_SYNCS) {
|
const units = await getSyncUnits(groupTenants, { logPrefix: '[SAT Cron]' });
|
||||||
const batch = groupTenants.slice(i, i + CONCURRENT_SYNCS);
|
console.log(`[SAT Cron] Ventana ${hour}:00 CDMX — ${units.length} unidades de sync listas (max ${CONCURRENT_CONTRIBUYENTES} paralelas)`);
|
||||||
await Promise.all(batch.map(syncTenant));
|
|
||||||
|
if (units.length === 0) {
|
||||||
|
console.log('[SAT Cron] No hay unidades de sync en este grupo');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Procesar en lotes de contribuyentes para aprovechar los proxies
|
||||||
|
for (let i = 0; i < units.length; i += CONCURRENT_CONTRIBUYENTES) {
|
||||||
|
const batch = units.slice(i, i + CONCURRENT_CONTRIBUYENTES);
|
||||||
|
await Promise.all(batch.map(unit => syncUnit(unit, '[SAT Cron]')));
|
||||||
|
|
||||||
// Pequeña pausa entre lotes
|
// Pequeña pausa entre lotes
|
||||||
if (i + CONCURRENT_SYNCS < groupTenants.length) {
|
if (i + CONCURRENT_CONTRIBUYENTES < units.length) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,7 +373,8 @@ async function runSyncJob(): Promise<void> {
|
|||||||
* Obtiene los tenants activos cuyo plan habilita SAT incremental (3 syncs/día
|
* Obtiene los tenants activos cuyo plan habilita SAT incremental (3 syncs/día
|
||||||
* adicionales al daily). El flag vive en `despacho_plan_prices.permite_sat_incremental`,
|
* adicionales al daily). El flag vive en `despacho_plan_prices.permite_sat_incremental`,
|
||||||
* editable por admin global desde `/configuracion/precios-suscripcion`.
|
* editable por admin global desde `/configuracion/precios-suscripcion`.
|
||||||
* Default backfill: mi_empresa_plus, business_control, business_cloud.
|
* Planes con incremental: mi_empresa_plus, business_cloud.
|
||||||
|
* business_control usa solo daily + retry programado.
|
||||||
*/
|
*/
|
||||||
async function getTenantsConSatIncremental(): Promise<string[]> {
|
async function getTenantsConSatIncremental(): Promise<string[]> {
|
||||||
const planesIncrementales = await prisma.despachoPlanPrice.findMany({
|
const planesIncrementales = await prisma.despachoPlanPrice.findMany({
|
||||||
@@ -385,11 +489,19 @@ async function runIncrementalSyncJob(): Promise<void> {
|
|||||||
|
|
||||||
if (tenantIds.length === 0) return;
|
if (tenantIds.length === 0) return;
|
||||||
|
|
||||||
for (let i = 0; i < tenantIds.length; i += CONCURRENT_SYNCS) {
|
const units = await getSyncUnits(tenantIds, { incremental: true, logPrefix: '[SAT Cron Inc]' });
|
||||||
const batch = tenantIds.slice(i, i + CONCURRENT_SYNCS);
|
console.log(`[SAT Cron Inc] ${units.length} unidades de sync listas (max ${CONCURRENT_CONTRIBUYENTES} paralelas)`);
|
||||||
await Promise.all(batch.map(incrementalSyncTenant));
|
|
||||||
|
|
||||||
if (i + CONCURRENT_SYNCS < tenantIds.length) {
|
if (units.length === 0) {
|
||||||
|
console.log('[SAT Cron Inc] No hay unidades de sync');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < units.length; i += CONCURRENT_CONTRIBUYENTES) {
|
||||||
|
const batch = units.slice(i, i + CONCURRENT_CONTRIBUYENTES);
|
||||||
|
await Promise.all(batch.map(unit => syncUnit(unit, '[SAT Cron Inc]')));
|
||||||
|
|
||||||
|
if (i + CONCURRENT_CONTRIBUYENTES < units.length) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
63
apps/api/src/services/email/templates/sat-proxy-report.ts
Normal file
63
apps/api/src/services/email/templates/sat-proxy-report.ts
Normal 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>
|
||||||
|
`);
|
||||||
|
}
|
||||||
100
apps/api/src/services/sat/proxy.service.ts
Normal file
100
apps/api/src/services/sat/proxy.service.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||||
|
|
||||||
|
export interface ProxyConfig {
|
||||||
|
url: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProxyStrategy = 'round-robin' | 'random';
|
||||||
|
|
||||||
|
function parseProxyList(raw: string): ProxyConfig[] {
|
||||||
|
if (!raw.trim()) return [];
|
||||||
|
|
||||||
|
const configs: ProxyConfig[] = [];
|
||||||
|
const items = raw.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
try {
|
||||||
|
const url = new URL(item);
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||||
|
console.warn(`[ProxyManager] Protocolo no soportado, se omite: ${item}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
configs.push({
|
||||||
|
url: item,
|
||||||
|
host: url.hostname,
|
||||||
|
port: Number(url.port) || (url.protocol === 'https:' ? 443 : 80),
|
||||||
|
username: url.username || undefined,
|
||||||
|
password: url.password || undefined,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[ProxyManager] URL de proxy inválida, se omite: ${item}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return configs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProxyManager {
|
||||||
|
private proxies: ProxyConfig[];
|
||||||
|
private strategy: ProxyStrategy;
|
||||||
|
private currentIndex = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
proxyList = process.env.SAT_PROXY_LIST || '',
|
||||||
|
strategy: ProxyStrategy = (process.env.SAT_PROXY_STRATEGY as ProxyStrategy) || 'round-robin',
|
||||||
|
) {
|
||||||
|
this.proxies = parseProxyList(proxyList);
|
||||||
|
this.strategy = ['round-robin', 'random'].includes(strategy) ? strategy : 'round-robin';
|
||||||
|
|
||||||
|
if (this.proxies.length > 0) {
|
||||||
|
console.log(`[ProxyManager] ${this.proxies.length} proxy(s) configurados (estrategia: ${this.strategy})`);
|
||||||
|
} else {
|
||||||
|
console.log('[ProxyManager] No hay proxies configurados; se usará la IP directa del servidor');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasProxies(): boolean {
|
||||||
|
return this.proxies.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getProxyCount(): number {
|
||||||
|
return this.proxies.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
getNextProxy(): ProxyConfig | null {
|
||||||
|
if (this.proxies.length === 0) return null;
|
||||||
|
|
||||||
|
if (this.strategy === 'random') {
|
||||||
|
return this.proxies[Math.floor(Math.random() * this.proxies.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxy = this.proxies[this.currentIndex];
|
||||||
|
this.currentIndex = (this.currentIndex + 1) % this.proxies.length;
|
||||||
|
return proxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
createAgent(proxy: ProxyConfig): HttpsProxyAgent<string> {
|
||||||
|
return new HttpsProxyAgent(proxy.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea un agente con el siguiente proxy del pool.
|
||||||
|
* Útil cuando se quiere una nueva conexión por solicitud.
|
||||||
|
*/
|
||||||
|
createNextAgent(): HttpsProxyAgent<string> | null {
|
||||||
|
const proxy = this.getNextProxy();
|
||||||
|
if (!proxy) return null;
|
||||||
|
console.log(`[ProxyManager] Usando proxy: ${proxy.host}:${proxy.port}`);
|
||||||
|
return this.createAgent(proxy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instancia global del proxy manager.
|
||||||
|
* Lee SAT_PROXY_LIST y SAT_PROXY_STRATEGY del entorno.
|
||||||
|
*/
|
||||||
|
export const proxyManager = new ProxyManager();
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
DocumentStatus,
|
DocumentStatus,
|
||||||
ServiceEndpoints,
|
ServiceEndpoints,
|
||||||
} from '@nodecfdi/sat-ws-descarga-masiva';
|
} from '@nodecfdi/sat-ws-descarga-masiva';
|
||||||
|
import { proxyManager } from './proxy.service.js';
|
||||||
|
|
||||||
export interface FielData {
|
export interface FielData {
|
||||||
cerContent: string;
|
cerContent: string;
|
||||||
@@ -17,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).
|
||||||
*
|
*
|
||||||
@@ -37,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);
|
||||||
|
|
||||||
@@ -47,18 +54,32 @@ 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.
|
// 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.
|
||||||
|
const proxy = proxyManager.getNextProxy();
|
||||||
|
const proxyAgent = proxy ? proxyManager.createAgent(proxy) : null;
|
||||||
|
if (proxyAgent) {
|
||||||
|
console.log('[SAT] Usando proxy para la conexión con el SAT');
|
||||||
|
} else {
|
||||||
|
console.log('[SAT] Sin proxy configurado; usando IP directa del servidor');
|
||||||
|
}
|
||||||
|
|
||||||
const webClient = new (HttpsWebClient as any)(
|
const webClient = new (HttpsWebClient as any)(
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
SAT_WEB_CLIENT_TIMEOUT_MS,
|
SAT_WEB_CLIENT_TIMEOUT_MS,
|
||||||
|
proxyAgent,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Crear request builder con la FIEL
|
// Crear request builder con la FIEL
|
||||||
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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -305,6 +305,7 @@ export default function PreciosSuscripcionPage() {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
<strong>SAT Inc</strong> habilita 3 syncs SAT extra al día (11:00, 15:00, 19:00) además del daily de las 03:00.
|
<strong>SAT Inc</strong> habilita 3 syncs SAT extra al día (11:00, 15:00, 19:00) además del daily de las 03:00.
|
||||||
|
Disponible en <strong>Business Cloud</strong> y <strong>Mi Empresa Plus</strong>; no incluido en <strong>Business Control</strong>.
|
||||||
Ventana de 8h por sync, deduplicado por UUID. Latencia típica de un CFDI ~1-2h en horario laboral
|
Ventana de 8h por sync, deduplicado por UUID. Latencia típica de un CFDI ~1-2h en horario laboral
|
||||||
vs ~24h con solo el daily.
|
vs ~24h con solo el daily.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Los datos se almacenan en la base de datos del tenant correspondiente.
|
|||||||
| `apps/api/src/services/sat/sat.service.ts` | Lógica principal de sincronización, políticas de reintento, polling |
|
| `apps/api/src/services/sat/sat.service.ts` | Lógica principal de sincronización, políticas de reintento, polling |
|
||||||
| `apps/api/src/services/sat/sat-client.service.ts` | Cliente del SAT, `query`, `verify`, `download` |
|
| `apps/api/src/services/sat/sat-client.service.ts` | Cliente del SAT, `query`, `verify`, `download` |
|
||||||
| `apps/api/src/services/sat/sat-parser.service.ts` | Parseo de XMLs y metadata |
|
| `apps/api/src/services/sat/sat-parser.service.ts` | Parseo de XMLs y metadata |
|
||||||
|
| `apps/api/src/services/sat/proxy.service.ts` | Pool rotativo de proxies SAT |
|
||||||
| `apps/api/src/services/sat/sat-crypto.service.ts` | Encriptación AES-256-GCM de credenciales FIEL |
|
| `apps/api/src/services/sat/sat-crypto.service.ts` | Encriptación AES-256-GCM de credenciales FIEL |
|
||||||
| `apps/api/src/services/fiel.service.ts` | FIEL a nivel tenant (legacy) |
|
| `apps/api/src/services/fiel.service.ts` | FIEL a nivel tenant (legacy) |
|
||||||
| `apps/api/src/services/contribuyente-fiel.service.ts` | FIEL por contribuyente (modelo despacho) |
|
| `apps/api/src/services/contribuyente-fiel.service.ts` | FIEL por contribuyente (modelo despacho) |
|
||||||
@@ -87,12 +88,13 @@ Definidos en `apps/api/src/jobs/sat-sync.job.ts`:
|
|||||||
|
|
||||||
| Job | Expresión | Horario CDMX | Propósito |
|
| Job | Expresión | Horario CDMX | Propósito |
|
||||||
|-----|-----------|--------------|-----------|
|
|-----|-----------|--------------|-----------|
|
||||||
| SAT Cron | `0 6-10 * * *` | 6:00–10:00 AM | Daily sync, ~20% de tenants por hora |
|
| SAT Cron | `0 6-10 * * *` | 6:00–10:00 AM | Daily sync, ~20% de tenants por hora, hasta `SAT_CONCURRENT_CONTRIBUYENTES` paralelos |
|
||||||
| Recovery Cron | `0 10 * * *` | 10:00 AM | Recuperar jobs `running` atorados |
|
| Recovery Cron | `0 10 * * *` | 10:00 AM | Recuperar jobs `running` atorados |
|
||||||
| Daily Retry | `0 9 * * *` y `0 16 * * *` | 9:00 AM y 4:00 PM | Reintentar daily fallidos |
|
| Daily Retry | `0 9 * * *` y `0 16 * * *` | 9:00 AM y 4:00 PM | Reintentar daily fallidos |
|
||||||
| 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
|
||||||
|
|
||||||
@@ -116,7 +118,61 @@ Definidos en `apps/api/src/jobs/sat-sync.job.ts`:
|
|||||||
1. Ventana de 8 horas: `ahora - 10h` a `ahora - 2h`.
|
1. Ventana de 8 horas: `ahora - 10h` a `ahora - 2h`.
|
||||||
2. Descarga XMLs + metadata de emitidos y recibidos.
|
2. Descarga XMLs + metadata de emitidos y recibidos.
|
||||||
|
|
||||||
## 7. Polling y límites
|
## 7. Proxies SAT (rotación por IP)
|
||||||
|
|
||||||
|
Para mitigar el bloqueo `404 Error no controlado` causado por cuota de solicitudes desde una sola IP pública, el sistema soporta un pool de proxies HTTP/HTTPS rotativos.
|
||||||
|
|
||||||
|
### Configuración
|
||||||
|
|
||||||
|
Variables en `apps/api/.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lista de proxies separados por coma. Soporta autenticación básica.
|
||||||
|
SAT_PROXY_LIST=http://user:pass@host1:port,http://user:pass@host2:port
|
||||||
|
|
||||||
|
# Estrategia de rotación: round-robin | random (default: round-robin)
|
||||||
|
SAT_PROXY_STRATEGY=round-robin
|
||||||
|
|
||||||
|
# Si true, cuando todos los proxies fallan se intenta con la IP directa del servidor.
|
||||||
|
SAT_PROXY_FALLBACK_DIRECT=true
|
||||||
|
|
||||||
|
# Máximo de contribuyentes sincronizados en paralelo por el scheduler (default: 10).
|
||||||
|
SAT_CONCURRENT_CONTRIBUYENTES=10
|
||||||
|
```
|
||||||
|
|
||||||
|
### Componentes
|
||||||
|
|
||||||
|
- **`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.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
- Cada llamada a `getNextProxy()` devuelve el siguiente proxy del pool (round-robin).
|
||||||
|
- Cuando el scheduler lanza 10 contribuyentes en paralelo, cada uno tiende a usar una IP distinta.
|
||||||
|
- Si un proxy devuelve error de conexión, el cliente SAT puede caer a la IP directa según `SAT_PROXY_FALLBACK_DIRECT`.
|
||||||
|
|
||||||
|
### Recomendaciones operativas
|
||||||
|
|
||||||
|
- Tamaño mínimo del pool: **1 proxy por cada 5 RFCs** que se sincronicen en paralelo.
|
||||||
|
- Con `SAT_CONCURRENT_CONTRIBUYENTES=10`, un pool de 10 proxies da una IP por RFC en el peor caso.
|
||||||
|
- Monitorear logs por `[ProxyManager] Usando proxy: ...` y por 404 persistentes en una misma IP.
|
||||||
|
|
||||||
|
## 8. Polling y límites
|
||||||
|
|
||||||
Después de crear una solicitud (`query`) al SAT, el sistema verifica el estado periódicamente (`verify`).
|
Después de crear una solicitud (`query`) al SAT, el sistema verifica el estado periódicamente (`verify`).
|
||||||
|
|
||||||
@@ -133,7 +189,7 @@ Esto da un máximo de **~45 minutos por solicitud** (9 × 5 min).
|
|||||||
|
|
||||||
Cada solicitud al SAT tiene su propio polling; los intentos no se comparten entre solicitudes.
|
Cada solicitud al SAT tiene su propio polling; los intentos no se comparten entre solicitudes.
|
||||||
|
|
||||||
## 8. Políticas de reintentos
|
## 9. Políticas de reintentos
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const RETRY_POLICIES = {
|
const RETRY_POLICIES = {
|
||||||
@@ -148,7 +204,7 @@ const MAX_DAILY_RETRY_ATTEMPTS = 5; // original + 2 automáticos + 2 crons fijos
|
|||||||
|
|
||||||
Los reintentos automáticos se programan desde `createdAt` del job. Los reintentos por cron fijo (9 AM / 4 PM) se manejan en `continuePendingDailyRequests`.
|
Los reintentos automáticos se programan desde `createdAt` del job. Los reintentos por cron fijo (9 AM / 4 PM) se manejan en `continuePendingDailyRequests`.
|
||||||
|
|
||||||
## 9. Manejo de errores
|
## 10. Manejo de errores
|
||||||
|
|
||||||
### Errores no fatales (se registran, no abortan en daily)
|
### Errores no fatales (se registran, no abortan en daily)
|
||||||
|
|
||||||
@@ -166,7 +222,7 @@ Los reintentos automáticos se programan desde `createdAt` del job. Los reintent
|
|||||||
- FIEL inválida o vencida.
|
- FIEL inválida o vencida.
|
||||||
- Errores que no son transitorios y no están en la lista de no fatales.
|
- Errores que no son transitorios y no están en la lista de no fatales.
|
||||||
|
|
||||||
## 10. Errores comunes del SAT
|
## 11. Errores comunes del SAT
|
||||||
|
|
||||||
| Código/Mensaje | Significado | Acción |
|
| Código/Mensaje | Significado | Acción |
|
||||||
|----------------|-------------|--------|
|
|----------------|-------------|--------|
|
||||||
@@ -178,7 +234,7 @@ Los reintentos automáticos se programan desde `createdAt` del job. Los reintent
|
|||||||
| "Fecha final invalida" | Fecha futura o mal formada | Usar `getYesterdayEnd()` |
|
| "Fecha final invalida" | Fecha futura o mal formada | Usar `getYesterdayEnd()` |
|
||||||
| "El certificado no es válido" | FIEL rechazada por el SAT | Revisar vigencia/contraseña de FIEL |
|
| "El certificado no es válido" | FIEL rechazada por el SAT | Revisar vigencia/contraseña de FIEL |
|
||||||
|
|
||||||
## 11. Monitoreo y comandos útiles
|
## 12. Monitoreo y comandos útiles
|
||||||
|
|
||||||
### Estado del API
|
### Estado del API
|
||||||
|
|
||||||
@@ -223,7 +279,15 @@ psql "$DATABASE_URL" -c "
|
|||||||
"
|
"
|
||||||
```
|
```
|
||||||
|
|
||||||
## 12. Changelog reciente
|
## 13. Changelog reciente
|
||||||
|
|
||||||
|
### 2026-08-03
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **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`.
|
||||||
|
|
||||||
### 2026-07-31
|
### 2026-07-31
|
||||||
|
|
||||||
@@ -233,14 +297,16 @@ psql "$DATABASE_URL" -c "
|
|||||||
- Daily retry fijo a las 9:00 AM y 4:00 PM CDMX.
|
- Daily retry fijo a las 9:00 AM y 4:00 PM CDMX.
|
||||||
- Incremental Enterprise a las 11:00 AM, 3:00 PM y 7:00 PM CDMX.
|
- Incremental Enterprise a las 11:00 AM, 3:00 PM y 7:00 PM CDMX.
|
||||||
|
|
||||||
## 13. Problemas conocidos
|
## 14. Problemas conocidos
|
||||||
|
|
||||||
1. **Bloqueo `404 Error no controlado` del SAT**: Aparece cuando se hacen muchas consultas desde la misma IP. Mitigación temporal: reducir frecuencia de polling y metadata solo domingos.
|
1. **Bloqueo `404 Error no controlado` del SAT**: Aparece cuando se hacen muchas consultas desde la misma IP. Mitigación: proxies rotativos (`SAT_PROXY_LIST`) + concurrencia controlada por contribuyente + metadata histórica solo domingos.
|
||||||
2. **FIEL inválida**: Algunos tenants/contribuyentes tienen FIEL rechazada por el SAT. Requiere revisar/renovar FIEL.
|
2. **FIEL inválida**: Algunos tenants/contribuyentes tienen FIEL rechazada por el SAT. Requiere revisar/renovar FIEL.
|
||||||
3. **Jobs `initial` atorados en `running`**: Pueden quedar si el proceso se reinicia; el recovery cron y el watchdog los limpian.
|
3. **Jobs `initial` atorados en `running`**: Pueden quedar si el proceso se reinicia; el recovery cron y el watchdog los limpian.
|
||||||
|
|
||||||
## 14. Próximos pasos
|
## 15. Próximos pasos
|
||||||
|
|
||||||
- [ ] Implementar proxies rotativos para evitar bloqueo por IP del SAT.
|
- [x] Implementar proxies rotativos para evitar bloqueo por IP del SAT.
|
||||||
- [ ] Monitorear tasa de éxito tras reducir polling y metadata solo domingos.
|
- [x] Reporte diario de errores por proxy.
|
||||||
|
- [ ] 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 (6–10 AM) si el volumen de RFCs supera el throughput con 10 paralelos.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
diff --git a/build/index.js b/build/index.js
|
diff --git a/build/index.js b/build/index.js
|
||||||
index bf7a6aafce966c4ab44ba3abb240578cc68779d6..4678df8734b098f20c7e14d8593bd4328fe759d8 100644
|
index bf7a6aafce966c4ab44ba3abb240578cc68779d6..df01102262bbe6d544a5a1b4453977be8a826d0d 100644
|
||||||
--- a/build/index.js
|
--- a/build/index.js
|
||||||
+++ b/build/index.js
|
+++ b/build/index.js
|
||||||
@@ -266,7 +266,13 @@ var ServiceConsumer = class _ServiceConsumer {
|
@@ -266,7 +266,13 @@ var ServiceConsumer = class _ServiceConsumer {
|
||||||
@@ -17,3 +17,27 @@ index bf7a6aafce966c4ab44ba3abb240578cc68779d6..4678df8734b098f20c7e14d8593bd432
|
|||||||
}
|
}
|
||||||
this.checkErrors(request, response, exception);
|
this.checkErrors(request, response, exception);
|
||||||
return response.getBody();
|
return response.getBody();
|
||||||
|
@@ -2660,10 +2666,12 @@ var HttpsWebClient = class {
|
||||||
|
_fireRequestClosure;
|
||||||
|
_fireResponseClosure;
|
||||||
|
_timeout;
|
||||||
|
- constructor(onFireRequest, onFireResponse, timeout) {
|
||||||
|
+ _agent;
|
||||||
|
+ constructor(onFireRequest, onFireResponse, timeout, agent = void 0) {
|
||||||
|
this._fireRequestClosure = onFireRequest;
|
||||||
|
this._fireResponseClosure = onFireResponse;
|
||||||
|
this._timeout = timeout;
|
||||||
|
+ this._agent = agent;
|
||||||
|
}
|
||||||
|
fireRequest(request) {
|
||||||
|
if (this._fireRequestClosure) {
|
||||||
|
@@ -2679,7 +2687,8 @@ var HttpsWebClient = class {
|
||||||
|
const options = {
|
||||||
|
method: request.getMethod(),
|
||||||
|
headers: request.getHeaders(),
|
||||||
|
- timeout: this._timeout ?? request.getTimeout() ?? void 0
|
||||||
|
+ timeout: this._timeout ?? request.getTimeout() ?? void 0,
|
||||||
|
+ agent: this._agent
|
||||||
|
};
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let clientRequest;
|
||||||
|
|||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -6,7 +6,7 @@ settings:
|
|||||||
|
|
||||||
patchedDependencies:
|
patchedDependencies:
|
||||||
'@nodecfdi/sat-ws-descarga-masiva@2.0.0':
|
'@nodecfdi/sat-ws-descarga-masiva@2.0.0':
|
||||||
hash: n2q5glw3wdhkcidljfdzrkmxnq
|
hash: i4ncoh7xgprkdron5l2ech4ifm
|
||||||
path: patches/@nodecfdi__sat-ws-descarga-masiva@2.0.0.patch
|
path: patches/@nodecfdi__sat-ws-descarga-masiva@2.0.0.patch
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
@@ -39,7 +39,7 @@ importers:
|
|||||||
version: 3.2.0(luxon@3.7.2)
|
version: 3.2.0(luxon@3.7.2)
|
||||||
'@nodecfdi/sat-ws-descarga-masiva':
|
'@nodecfdi/sat-ws-descarga-masiva':
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.0.0(patch_hash=n2q5glw3wdhkcidljfdzrkmxnq)(@nodecfdi/cfdi-core@1.0.1)(luxon@3.7.2)
|
version: 2.0.0(patch_hash=i4ncoh7xgprkdron5l2ech4ifm)(@nodecfdi/cfdi-core@1.0.1)(luxon@3.7.2)
|
||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: ^5.22.0
|
specifier: ^5.22.0
|
||||||
version: 5.22.0(prisma@5.22.0)
|
version: 5.22.0(prisma@5.22.0)
|
||||||
@@ -3133,7 +3133,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
luxon: 3.7.2
|
luxon: 3.7.2
|
||||||
|
|
||||||
'@nodecfdi/sat-ws-descarga-masiva@2.0.0(patch_hash=n2q5glw3wdhkcidljfdzrkmxnq)(@nodecfdi/cfdi-core@1.0.1)(luxon@3.7.2)':
|
'@nodecfdi/sat-ws-descarga-masiva@2.0.0(patch_hash=i4ncoh7xgprkdron5l2ech4ifm)(@nodecfdi/cfdi-core@1.0.1)(luxon@3.7.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nodecfdi/cfdi-core': 1.0.1
|
'@nodecfdi/cfdi-core': 1.0.1
|
||||||
'@nodecfdi/credentials': 3.2.0(luxon@3.7.2)
|
'@nodecfdi/credentials': 3.2.0(luxon@3.7.2)
|
||||||
|
|||||||
Reference in New Issue
Block a user