Introducción: El cuello de botella operativo de las detracciones SPOT
En el régimen tributario peruano (sistema SPOT), conciliar los depósitos de detracciones en las cuentas del Banco de la Nación suele ser una de las tareas administrativas más monótonas y propensas a errores. Diariamente, los equipos contables deben acceder a la plataforma web de SUNAT, descargar las constancias, identificar a qué cliente o proveedor corresponde cada depósito y registrarlo manualmente en su ERP.
Dado que SUNAT no dispone de un API REST público para la consulta de constancias de detracción, la solución estándar en ingeniería de software consiste en implementar un flujo RPA (Robotic Process Automation) robusto y desacoplado.
Arquitectura de la Solución
Diseñamos un pipeline distribuido en tres capas independientes que evita el almacenamiento intermedio en disco y previene la pérdida de datos:
- Scraper Headless (Puppeteer / Node.js): Emula la sesión de un usuario autorizado, descarta popups de verificación, consulta la tabla de detracciones e inspecciona los modales de detalle para extraer comprobantes y RUCs.
- Orquestador (n8n): Se ejecuta mediante un cron diario matutino, consume la salida estándar del scraper como un flujo binario en memoria y lo envía vía HTTP POST con token de autorización.
- Backend Contable (Laravel / PSA): Endpoint protegido que recibe el flujo CSV, limpia y formatea los tipos de datos y ejecuta una transacción con inserción idempotente.
1. Extracción de Datos con Puppeteer (sunat_scraper.js)
El portal web de SUNAT presenta varios desafíos: navegación en árboles multinivel dinámicos, iframes de validación de datos de contacto y modales asíncronos para ver la información completa de cada comprobante.
Para no interferir con la captura de datos en el orquestador, el script envía todos sus mensajes de diagnóstico a stderr y reserva stdout exclusivamente para las líneas puras del archivo CSV:
const puppeteer = require('puppeteer');
// Credenciales protegidas mediante variables de entorno
const RUC = process.env.SUNAT_RUC || 'XXXXXXXXXXX';
const USER = process.env.SUNAT_USER || 'USUARIO_SECUNDARIO';
const PASS = process.env.SUNAT_PASS || 'PASSWORD_SECRETO';
// Parsea rangos de fechas opcionales (--start dd/mm/aaaa --end dd/mm/aaaa)
function getDatesFromArgs() {
const args = process.argv.slice(2);
let startDate = '', endDate = '';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--start' && args[i + 1]) startDate = args[i + 1];
if (args[i] === '--end' && args[i + 1]) endDate = args[i + 1];
}
if (!startDate || !endDate) {
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
const fmt = d => `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
if (!startDate) startDate = fmt(yesterday);
if (!endDate) endDate = fmt(today);
}
return { startDate, endDate };
}
const { startDate, endDate } = getDatesFromArgs();
async function run() {
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu']
});
const page = await browser.newPage();
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
await page.setViewport({ width: 1280, height: 950 });
try {
console.error("[INFO] Iniciando conexión con SUNAT...");
await page.goto('https://e-menu.sunat.gob.pe/cl-ti-itmenu2/MenuInternetPlataforma.htm?exe=55.1.1.1.1', { waitUntil: 'networkidle2' });
// Autenticación Clave SOL
await page.waitForSelector('#txtRuc');
await page.type('#txtRuc', RUC);
await page.type('#txtUsuario', USER);
const passInput = (await page.$('#txtContrasena')) !== null ? '#txtContrasena' : '#txtClave';
await page.type(passInput, PASS);
await page.click((await page.$('#btnAceptar')) !== null ? '#btnAceptar' : '#btnSubmit');
await page.waitForFunction(
() => document.querySelector('#nivel4_55_2_1_1_4') !== null || document.querySelector('iframe#ifrVCE') !== null,
{ timeout: 30000 }
);
// Omitir popup de validación de datos de contacto si aparece
const hasValidation = await page.evaluate(() => {
const iframe = document.querySelector('iframe#ifrVCE');
return iframe && iframe.getBoundingClientRect().width > 0;
});
if (hasValidation) {
console.error("[INFO] Omitiendo validación de datos de contacto...");
const frame = await (await page.$('iframe#ifrVCE')).contentFrame();
if (frame) {
await frame.waitForSelector('#btnFinalizarValidacionDatos', { timeout: 8000 });
await frame.evaluate(() => document.querySelector('#btnFinalizarValidacionDatos').click());
await new Promise(r => setTimeout(r, 2000));
await frame.waitForSelector('#btnCerrar', { timeout: 8000 });
await frame.evaluate(() => document.querySelector('#btnCerrar').click());
await new Promise(r => setTimeout(r, 5000));
}
}
// Navegación en el menú lateral
const clickNode = async (sel) => {
await page.evaluate(s => {
const el = document.querySelector(s) || document.querySelector(`${s} span`);
if (el) el.click();
}, sel);
await new Promise(r => setTimeout(r, 1500));
};
await clickNode('#nivel1_55');
await clickNode('#nivel2_55_2');
await clickNode('#nivel3_55_2_1');
await clickNode('#nivel4_55_2_1_1_4');
// Entrar al contexto del iframe de aplicación
await page.waitForSelector('iframe#iframeApplication', { timeout: 20000 });
await new Promise(r => setTimeout(r, 8000));
const frame = await (await page.$('iframe#iframeApplication')).contentFrame();
// Seleccionar Cuenta Convencional y consultar
await frame.select('#tipoCuenta', '1');
await frame.evaluate((s, e) => {
const start = document.querySelector('#fechaInicio');
const end = document.querySelector('#fechaFin');
if (start && end) {
start.value = s;
start.dispatchEvent(new Event('change', { bubbles: true }));
end.value = e;
end.dispatchEvent(new Event('change', { bubbles: true }));
}
}, startDate, endDate);
await frame.click('#btnConsultar');
await new Promise(r => setTimeout(r, 10000));
const csvHeader = "Tipo de Cuenta,Numero de Cuenta,Numero Constancia,Periodo Tributario,RUC Proveedor,Nombre Proveedor,Tipo de Documento Adquiriente,Numero de Documento Adquiriente,Nombre/Razon Social del Adquiriente,Fecha Pago,Monto Deposito,Tipo Bien,Tipo Operacion,Tipo de Comprobante,Serie de Comprobante,Numero de Comprobante,Numero de pago de Detracciones,";
const rowsCount = await frame.evaluate(() => document.querySelectorAll('table#tablaIndividual tbody tr').length);
if (rowsCount === 0) {
console.log(csvHeader);
return;
}
const csvRows = [csvHeader];
// Iteración de cada constancia para abrir el modal y extraer el detalle completo
for (let i = 0; i < rowsCount; i++) {
await frame.evaluate((idx) => {
const links = document.querySelectorAll('table#tablaIndividual tbody tr td a');
if (links[idx]) links[idx].click();
}, i);
await new Promise(r => setTimeout(r, 5000));
const detail = await frame.evaluate(() => {
const get = s => { const el = document.querySelector(s); return el ? el.innerText.trim() : ''; };
return {
cuenta: get('.spnNroCuenta'),
constancia: get('.spnNroConstancia'),
periodo: get('.spnPeriodo'),
rucProv: get('.spnRucProveedor'),
nomProv: get('.spnDesProv'),
tipoDocAdq: get('.spnTipoDocAdq'),
numDocAdq: get('.spnNumDocAdq'),
nomAdq: get('.spnDesAdq'),
fecPago: get('.spnFecPago'),
monto: get('.spnMonto'),
bien: get('.spnTipoBien'),
operacion: get('.spnTipoOperacion'),
tipoComp: get('.spnTipoComprobante'),
numComp: get('.spnNumComprobante')
};
});
if (detail && detail.constancia) {
const tipoDoc = detail.tipoDocAdq ? detail.tipoDocAdq.split(' ')[0] : '';
const fecha = detail.fecPago ? detail.fecPago.split(' ')[0] : '';
const monto = detail.monto ? detail.monto.replace(/[^\d.]/g, '') : '0.00';
const parts = (detail.numComp || '').split('-');
const serie = parts[0] ? parts[0].trim() : '';
const numero = parts[1] ? parts[1].trim() : '';
csvRows.push(`"Cuenta Convencional","${detail.cuenta}","${detail.constancia}","${detail.periodo}","${detail.rucProv}","${detail.nomProv}","${tipoDoc}","${detail.numDocAdq}","${detail.nomAdq}","${fecha}","${monto}","${detail.bien}","${detail.operacion}","${detail.tipoComp}","${serie}","${numero}"," ",`);
}
// Cerrar modal
await frame.evaluate(() => {
if (typeof jQuery !== 'undefined') jQuery('#myModal2').modal('hide');
else document.querySelector('#myModal2 button.close')?.click();
});
await new Promise(r => setTimeout(r, 1500));
}
// Salida limpia a STDOUT
console.log(csvRows.join('\n'));
} catch (err) {
console.error("[ERROR CRÍTICO]", err.message);
process.exit(1);
} finally {
await browser.close();
}
}
run();
2. Orquestación y Flujo Binario en n8n
Uno de los errores más comunes al manejar archivos en n8n es serializar cadenas de miles de líneas en variables JSON. Para optimizar memoria, utilizamos el nodo Execute Command con el parámetro responseFormat: "file". Esto captura directamente la salida del script como un buffer binario nativo que viaja de inmediato al nodo HTTP Request:
- Schedule Trigger: Configurado a las
0 7 * * *(todos los días a las 07:00 AM). - Execute Command: Invoca
node sunat_scraper.jssin persistencia en disco. - HTTP Request: Realiza una petición
POSTcon la cabeceraAuthorization: Beareradjuntando el binario en crudo con la propiedadsendBinaryData: true.
3. Ingesta e Idempotencia en el Backend (Laravel)
El endpoint de destino procesa el stream en memoria y utiliza updateOrInsert para asegurar que las reejecuciones no dupliquen los abonos contables:
getContent();
if (empty(trim($csvContent))) {
return response()->json(['status' => 'error', 'message' => 'Payload vacío.'], 400);
}
$lines = preg_split('/\r\n|\r|\n/', $csvContent);
$importedCount = 0;
DB::beginTransaction();
try {
foreach ($lines as $index => $line) {
if ($index === 0 || empty(trim($line))) continue;
$data = str_getcsv($line, ',', '"');
$numeroConstancia = trim($data[2] ?? '');
if (empty($numeroConstancia)) continue;
$fechaPago = !empty($data[9])
? Carbon::createFromFormat('d/m/Y', trim($data[9]))->format('Y-m-d')
: null;
$monto = !empty($data[10])
? floatval(preg_replace('/[^\d.]/', '', str_replace(',', '', $data[10])))
: 0.00;
DB::table('detracciones')->updateOrInsert(
['numero_constancia' => $numeroConstancia],
[
'tipo_cuenta' => trim($data[0] ?? ''),
'numero_cuenta' => trim($data[1] ?? ''),
'periodo_tributario' => trim($data[3] ?? ''),
'ruc_proveedor' => trim($data[4] ?? ''),
'nombre_proveedor' => trim($data[5] ?? ''),
'tipo_documento_adquiriente' => trim($data[6] ?? ''),
'numero_documento_adquiriente' => trim($data[7] ?? ''),
'nombre_adquiriente' => trim($data[8] ?? ''),
'fecha_pago' => $fechaPago,
'monto_deposito' => $monto,
'tipo_bien' => trim($data[11] ?? ''),
'tipo_operacion' => trim($data[12] ?? ''),
'tipo_comprobante' => trim($data[13] ?? ''),
'serie_comprobante' => trim($data[14] ?? ''),
'numero_comprobante' => trim($data[15] ?? ''),
'updated_at' => now(),
]
);
$importedCount++;
}
DB::commit();
return response()->json([
'status' => 'success',
'imported' => $importedCount,
'message' => "Procesadas {$importedCount} detracciones."
]);
} catch (\Exception $e) {
DB::rollBack();
return response()->json(['status' => 'error', 'message' => $e->getMessage()], 500);
}
}
}
Consideraciones Legales y Cumplimiento Normativo (¿Qué dice SUNAT?)
Una interrogante común entre desarrolladores y directores de TI es si este tipo de automatizaciones son legales o podrían acarrear sanciones administrativas:
1. Legalidad del acceso y titularidad de los datos
Bajo la Ley de Delitos Informáticos en el Perú (Ley N° 30096), el acceso no autorizado y el sabotaje informático están penados. Sin embargo, en este caso el titular de la empresa está accediendo a su propia información tributaria utilizando credenciales legítimas. No existe vulneración de barreras criptográficas, intrusión a cuentas ajenas ni alteración de registros en los servidores de la administración tributaria; se trata de una operación estrictamente de sólo lectura.
2. Términos de uso de la Clave SOL (R.S. N° 109-2000/SUNAT)
La normativa de SUNAT asigna total responsabilidad al contribuyente sobre cualquier acción que se realice mediante su Clave SOL. Al no existir un API oficial para el SPOT, las soluciones de tipo RPA son el estándar en la industria para ERPs y firmas contables. No obstante, para mantener la operación en estricto cumplimiento se deben aplicar dos principios fundamentales:
- Principio de Mínimo Privilegio (Usuario Secundario): Jamás debe utilizarse la Clave SOL principal. Debe crearse un usuario secundario en el menú SOL habilitando exclusivamente los permisos de "Consulta de Detracciones", inhabilitando opciones de declaración, transferencias o cambios en el RUC.
- Protección contra Denegación de Servicio (DoS): Los sistemas de seguridad perimetral de SUNAT (WAF) detectan y bloquean IPs que emiten ráfagas agresivas. Por ello, el bot debe configurarse con frecuencia moderada (una vez al día) e incorporar pausas de espera entre clics (1.5 a 5 segundos) que reproduzcan el comportamiento de navegación humana.
Adoptando estas medidas, la automatización se convierte en un puente seguro y legal que elimina horas de trabajo repetitivo diario y reduce a cero la tasa de error en la conciliación tributaria.