@devlas/dte-sii 2.15.0 → 2.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SiiPortalAuth.js CHANGED
@@ -33,6 +33,114 @@ const SiiSessionStore = require('./SiiSessionStore');
33
33
  const { resolveDataDir } = require('./utils/paths');
34
34
  const { registrarHttpDebug } = require('./utils/httpDebug');
35
35
 
36
+ /** Portal viejo (MIPYME). No es www4: esas son las SPA nuevas. */
37
+ const RESPALDO_BASE = 'https://www1.sii.cl/cgi-bin/Portal001';
38
+ /** Tope duro del SII por descarga. Medido 18/08/2026: 20 → OK, 21 → página de error. */
39
+ const RESPALDO_MAX_DOCS = 20;
40
+
41
+ /**
42
+ * Extrae el texto de los `alert(...)` de una página del portal viejo del SII.
43
+ *
44
+ * ⚠️ El portal viejo comunica sus errores por **alert() de JavaScript**, no en el HTML
45
+ * visible ni en el `<title>`. Quien clasifique por título, o quien limpie el HTML sacando
46
+ * los `<script>` antes de leerlo, pierde el motivo entero y se queda con una página que
47
+ * parece decir otra cosa.
48
+ *
49
+ * Caso real (18/08/2026): para un RUT sin datos, la respuesta traía
50
+ * `<title>Seleccionar empresa</title>` mientras el alert decía
51
+ * *"No existe información en MIPYME para el rut ingresado"*. Clasificar por el título llevó
52
+ * a inventar tres causas equivocadas antes de mirar el script.
53
+ *
54
+ * @param {string} html
55
+ * @returns {string[]} mensajes en orden de aparición, ya desescapados
56
+ */
57
+ function extraerAlertasPortal(html = '') {
58
+ const mensajes = [];
59
+ const re = /alert\s*\(\s*(['"])([\s\S]*?)\1\s*\)/g;
60
+ let m;
61
+ while ((m = re.exec(html)) !== null) {
62
+ // ⚠️ La página normal del respaldo trae `//alert("Maximo numero de docmentos...")`
63
+ // COMENTADO dentro del template. Tomarlo por bueno hace creer que un listado válido
64
+ // falló. Se descarta todo alert que venga comentado en su propia línea.
65
+ const inicioLinea = html.lastIndexOf('\n', m.index) + 1;
66
+ const antes = html.slice(inicioLinea, m.index);
67
+ if (/\/\//.test(antes.replace(/[a-z]+:\/\//gi, ''))) continue;
68
+
69
+ const texto = m[2]
70
+ .replace(/\\(['"\\])/g, '$1')
71
+ .replace(/\\[rnt]/g, ' ')
72
+ .replace(/&nbsp;/gi, ' ')
73
+ .replace(/&aacute;/gi, 'á').replace(/&eacute;/gi, 'é').replace(/&iacute;/gi, 'í')
74
+ .replace(/&oacute;/gi, 'ó').replace(/&uacute;/gi, 'ú').replace(/&ntilde;/gi, 'ñ')
75
+ .replace(/&amp;/gi, '&')
76
+ .replace(/<[^>]+>/g, ' ')
77
+ .replace(/\s+/g, ' ')
78
+ .trim();
79
+ if (texto) mensajes.push(texto);
80
+ }
81
+ return mensajes;
82
+ }
83
+
84
+ /**
85
+ * Construye el error de una respuesta del portal que no fue la esperada, usando **lo que el
86
+ * portal realmente dijo** en vez de una causa inferida.
87
+ *
88
+ * Deja el texto del portal en `err.mensajePortal` para que el consumidor pueda mostrarlo tal
89
+ * cual: es más útil y más honesto que cualquier traducción nuestra.
90
+ *
91
+ * @param {string} html - cuerpo de la respuesta
92
+ * @param {string} fallback - mensaje si el portal no dijo nada reconocible
93
+ * @returns {Error & { code?: string, mensajePortal?: string }}
94
+ */
95
+ function errorDeRespuestaPortal(html = '', fallback = 'respuesta inesperada del portal') {
96
+ const alertas = extraerAlertasPortal(html);
97
+ const texto = alertas.join(' | ');
98
+
99
+ if (/recaptcha/i.test(html) && !texto) {
100
+ const err = new Error('El SII activó el captcha en el respaldo MIPYME: no se puede automatizar mientras esté encendido');
101
+ err.code = 'RESPALDO_CAPTCHA';
102
+ return err;
103
+ }
104
+
105
+ // ⚠️ UN alert es un veredicto; VARIOS son el catálogo del template.
106
+ //
107
+ // Medido el 19/08/2026 con EMPRESA EJEMPLO: una respuesta trajo cinco mensajes encadenados y
108
+ // contradictorios entre sí ("no está autorizado", "debe hacerlo el representante legal",
109
+ // "no existe información en MIPYME"…). Eso no es el SII dictaminando: son todas las ramas
110
+ // de error que el template define, ninguna ejecutada. Tomarlo por veredicto marcó al
111
+ // comercio como "sin datos" y **abortó un backfill que venía funcionando**.
112
+ //
113
+ // La página realmente rechazada (Comercio D) trae **un solo** alert, seguido de
114
+ // `history.back()`.
115
+ if (alertas.length > 1) {
116
+ const err = new Error(
117
+ `El portal devolvió una página con ${alertas.length} mensajes de error del template, ` +
118
+ 'sin un veredicto único. No se puede concluir nada: revisar el HTML capturado por SII_HTTP_DEBUG_DIR.'
119
+ );
120
+ err.code = 'RESPALDO_INDETERMINADO';
121
+ err.mensajesPortal = alertas;
122
+ return err;
123
+ }
124
+
125
+ if (texto) {
126
+ const err = new Error(`El portal MIPYME respondió: "${texto}"`);
127
+ err.mensajePortal = texto;
128
+ if (/no existe informaci[oó]n/i.test(texto)) err.code = 'RESPALDO_SIN_DATOS';
129
+ else if (/captcha/i.test(texto)) err.code = 'RESPALDO_CAPTCHA';
130
+ else err.code = 'RESPALDO_RECHAZADO';
131
+ return err;
132
+ }
133
+
134
+ // Sin alert: el título es lo único que queda, pero NO alcanza para afirmar una causa.
135
+ if (/Seleccionar empresa/i.test(html)) {
136
+ const err = new Error('El portal devolvió la página de ingreso ("Seleccionar empresa") sin ningún mensaje. Causa no determinada: revisar el HTML capturado por SII_HTTP_DEBUG_DIR.');
137
+ err.code = 'RESPALDO_SIN_EMPRESA';
138
+ return err;
139
+ }
140
+
141
+ return new Error(fallback);
142
+ }
143
+
36
144
  function _cookieObjToStr(obj) {
37
145
  return Object.entries(obj).map(([k, v]) => `${k}=${v}`).join('; ');
38
146
  }
@@ -64,6 +172,37 @@ const SII_TLS_OPTS = {
64
172
  // una convención de un producto Windows específico hardcodeada en una librería
65
173
  // genérica de DTE — en Linux creaba esa ruta igual, porque Node no la valida.
66
174
  const SESSION_CACHE_PATH = path.join(resolveDataDir(), 'sii_session_cache.json');
175
+ /** TTL de una sesión cacheada. El SII tolera ~2 h de inactividad; se refresca al validarla. */
176
+ const SESSION_CACHE_TTL_MS = 90 * 60 * 1000;
177
+ /** Destino al que el SII redirige tras autenticar con certificado. */
178
+ const TARGET = 'https://misiir.sii.cl/cgi_misii/siihome.cgi';
179
+ /** Intentos de autenticación ante fallas de red/TLS (el primero incluido). */
180
+ const AUTH_MAX_INTENTOS = 3;
181
+
182
+ /**
183
+ * ¿Es una falla de red/TLS que conviene reintentar?
184
+ *
185
+ * ⚠️ Medido el 18/08/2026: el SII devuelve **`EPROTO` de forma intermitente** al abrir la
186
+ * conexión TLS con certificado (`rsa_pss ... last octet invalid`). Reintentando con el mismo
187
+ * certificado funciona. **No es señal de certificado vencido ni no habilitado**, aunque lo
188
+ * parezca: interpretarlo así marca clientes sanos como rotos.
189
+ *
190
+ * Solo entran errores de transporte, que fallan **antes** de que el SII cree la sesión. Un
191
+ * error de negocio del SII (límite de sesiones, credenciales) nunca se reintenta: repetirlo
192
+ * empeora el problema.
193
+ */
194
+ function esErrorTransitorio(err) {
195
+ if (!err) return false;
196
+ if (err.code === 'SII_LIMITE_SESIONES') return false;
197
+ const CODES = new Set([
198
+ 'EPROTO', 'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPIPE',
199
+ 'EAI_AGAIN', 'ENETUNREACH', 'EHOSTUNREACH', 'ERR_SSL_PACKET_LENGTH_TOO_LONG',
200
+ ]);
201
+ if (err.code && CODES.has(err.code)) return true;
202
+ return /socket hang up|timeout|ECONNRESET|EPROTO/i.test(err.message || '');
203
+ }
204
+ /** Tope de sesiones guardadas a la vez. Evita que el archivo crezca con la base de clientes. */
205
+ const SESSION_CACHE_MAX = 200;
67
206
 
68
207
  /**
69
208
  * Registro global de instancias SiiPortalAuth por certHash (singleton por certificado).
@@ -288,8 +427,6 @@ class SiiPortalAuth {
288
427
  * @throws {Error} Si la autenticación falla
289
428
  */
290
429
  async autenticar() {
291
- const TARGET = 'https://misiir.sii.cl/cgi_misii/siihome.cgi';
292
-
293
430
  // ── 1a. Store compartido (cubre sesiones de CafSolicitor/SiiSession) ────────
294
431
  const storedStr = SiiSessionStore.get(this._certHash);
295
432
  if (storedStr) {
@@ -319,6 +456,30 @@ class SiiPortalAuth {
319
456
  }
320
457
 
321
458
  // ── 2. Nueva autenticación ────────────────────────────────────────────────
459
+ // Se reintenta SOLO ante fallas de red/TLS (ver `esErrorTransitorio`). Es seguro porque
460
+ // esas fallan ANTES de que el SII cree la sesión, así que no dejan sesiones colgadas.
461
+ let ultimo;
462
+ for (let intento = 1; intento <= AUTH_MAX_INTENTOS; intento++) {
463
+ try {
464
+ return await this._autenticarNuevo();
465
+ } catch (err) {
466
+ ultimo = err;
467
+ if (!esErrorTransitorio(err) || intento === AUTH_MAX_INTENTOS) throw err;
468
+ const espera = 1000 * Math.pow(2, intento - 1);
469
+ console.warn(`[SiiPortalAuth] Falla transitoria al autenticar (${err.code || err.message.slice(0, 50)}), reintento ${intento + 1}/${AUTH_MAX_INTENTOS} en ${espera} ms`);
470
+ await new Promise((r) => setTimeout(r, espera));
471
+ }
472
+ }
473
+ throw ultimo;
474
+ }
475
+
476
+ /**
477
+ * Un intento de autenticación nueva contra el SII, sin reintentos.
478
+ *
479
+ * ⚠️ El límite de sesiones NO se reintenta: cada intento empeora el problema.
480
+ * @private
481
+ */
482
+ async _autenticarNuevo() {
322
483
  const cookieJar = {};
323
484
 
324
485
  await this._request(
@@ -341,10 +502,12 @@ class SiiPortalAuth {
341
502
  // Verificar mensaje de límite de sesiones
342
503
  if (r2.body.includes('m\u00e1ximo de sesiones') || r2.body.includes('maximo de sesiones') ||
343
504
  r2.body.includes('01.01.215.500.709')) {
344
- throw new Error(
505
+ const errLimite = new Error(
345
506
  'SiiPortalAuth: límite de sesiones SII alcanzado.\n' +
346
507
  'Cierra sesión en sii.cl y espera ~30 min, o las sesiones anteriores expirarán solas.'
347
508
  );
509
+ errLimite.code = 'SII_LIMITE_SESIONES';
510
+ throw errLimite;
348
511
  }
349
512
 
350
513
  const autenticado = Object.keys(cookieJar).some(k => k.startsWith('NETSCAPE_LIVEWIRE'));
@@ -388,51 +551,110 @@ class SiiPortalAuth {
388
551
  }
389
552
  }
390
553
 
391
- /** Lee sesión cacheada del disco para el cert dado. @private */
392
- static _cargarSesionCache(certHash) {
554
+ /**
555
+ * Lee el archivo de cache completo, normalizado al formato v2.
556
+ *
557
+ * ⚠️ El formato v1 guardaba **una sola sesión** (`{ certHash, ts, cookies }`). En un servidor
558
+ * multi-tenant eso significa que cada certificado pisa al anterior y todos terminan
559
+ * re-autenticando en cada pasada, que es justo lo que dispara el bloqueo del SII por
560
+ * "máximo de sesiones autenticadas". v2 es un mapa por `certHash`.
561
+ *
562
+ * El v1 que encuentre se **migra**, no se descarta: perder la sesión vigente al desplegar
563
+ * esta versión forzaría un re-login innecesario.
564
+ *
565
+ * @private
566
+ */
567
+ static _leerArchivoCache() {
393
568
  try {
394
- if (!fs.existsSync(SESSION_CACHE_PATH)) {
395
- console.log('[SiiPortalAuth] Cache: archivo no existe →', SESSION_CACHE_PATH);
396
- return null;
397
- }
569
+ if (!fs.existsSync(SESSION_CACHE_PATH)) return { v: 2, sesiones: {}, existia: false };
398
570
  const data = JSON.parse(fs.readFileSync(SESSION_CACHE_PATH, 'utf8'));
399
- if (data.certHash !== certHash) {
400
- console.log(`[SiiPortalAuth] Cache: cert no coincide (guardado=${data.certHash} actual=${certHash})`);
401
- return null;
402
- }
403
- const edadMin = Math.round((Date.now() - data.ts) / 60000);
404
- // TTL: 90 minutos (SII permite ~2h de inactividad; se refresca en cada validación)
405
- if (Date.now() - data.ts > 90 * 60 * 1000) {
406
- console.log(`[SiiPortalAuth] Cache: sesión expirada (edad=${edadMin} min, TTL=90 min)`);
407
- return null;
571
+ if (data && data.v === 2 && data.sesiones) return { ...data, existia: true };
572
+ if (data && data.certHash && data.cookies) {
573
+ return { v: 2, sesiones: { [data.certHash]: { ts: data.ts, cookies: data.cookies } }, existia: true };
408
574
  }
409
- console.log(`[SiiPortalAuth] Cache: sesión encontrada`);
410
- return data.cookies;
575
+ return { v: 2, sesiones: {}, existia: true };
411
576
  } catch (err) {
412
577
  console.warn('[SiiPortalAuth] Cache: error leyendo caché →', err.message);
578
+ return { v: 2, sesiones: {}, existia: false };
579
+ }
580
+ }
581
+
582
+ /** Lee sesión cacheada del disco para el cert dado. @private */
583
+ static _cargarSesionCache(certHash) {
584
+ const { sesiones, existia } = SiiPortalAuth._leerArchivoCache();
585
+ if (!existia) {
586
+ console.log('[SiiPortalAuth] Cache: archivo no existe →', SESSION_CACHE_PATH);
587
+ return null;
588
+ }
589
+ const entrada = sesiones[certHash];
590
+ if (!entrada) {
591
+ console.log(`[SiiPortalAuth] Cache: sin sesión para este certificado (guardadas=${Object.keys(sesiones).length})`);
413
592
  return null;
414
593
  }
594
+ const edadMin = Math.round((Date.now() - entrada.ts) / 60000);
595
+ if (Date.now() - entrada.ts > SESSION_CACHE_TTL_MS) {
596
+ console.log(`[SiiPortalAuth] Cache: sesión expirada (edad=${edadMin} min, TTL=${SESSION_CACHE_TTL_MS / 60000} min)`);
597
+ return null;
598
+ }
599
+ console.log('[SiiPortalAuth] Cache: sesión encontrada');
600
+ return entrada.cookies;
415
601
  }
416
602
 
417
- /** Guarda sesión en disco. @private */
603
+ /** Guarda sesión en disco, sin pisar las de los otros certificados. @private */
418
604
  static _guardarSesionCache(certHash, cookieJar) {
419
605
  try {
420
606
  fs.mkdirSync(path.dirname(SESSION_CACHE_PATH), { recursive: true });
421
- fs.writeFileSync(SESSION_CACHE_PATH, JSON.stringify({
422
- certHash,
423
- ts: Date.now(),
424
- cookies: cookieJar,
425
- }), 'utf8');
426
- const cookieKeys = Object.keys(cookieJar);
427
- console.log(`[SiiPortalAuth] Cache: sesión guardada`);
607
+
608
+ // Se relee justo antes de escribir: con varias réplicas escribiendo el mismo archivo,
609
+ // partir de una copia vieja en memoria borraría las sesiones que otro proceso guardó.
610
+ const archivo = SiiPortalAuth._leerArchivoCache();
611
+ delete archivo.existia;
612
+ archivo.sesiones[certHash] = { ts: Date.now(), cookies: cookieJar };
613
+
614
+ // Poda: primero lo expirado, después las más viejas si igual se pasa del tope. Sin esto
615
+ // el archivo crece sin límite con la base de clientes.
616
+ const ahora = Date.now();
617
+ for (const [k, v] of Object.entries(archivo.sesiones)) {
618
+ if (ahora - v.ts > SESSION_CACHE_TTL_MS) delete archivo.sesiones[k];
619
+ }
620
+ const claves = Object.keys(archivo.sesiones);
621
+ if (claves.length > SESSION_CACHE_MAX) {
622
+ claves
623
+ .sort((a, b) => archivo.sesiones[a].ts - archivo.sesiones[b].ts)
624
+ .slice(0, claves.length - SESSION_CACHE_MAX)
625
+ .forEach((k) => delete archivo.sesiones[k]);
626
+ }
627
+
628
+ // Escritura atómica: un lector concurrente nunca ve un JSON a medio escribir.
629
+ const tmp = `${SESSION_CACHE_PATH}.${process.pid}.tmp`;
630
+ fs.writeFileSync(tmp, JSON.stringify(archivo), 'utf8');
631
+ fs.renameSync(tmp, SESSION_CACHE_PATH);
632
+ console.log(`[SiiPortalAuth] Cache: sesión guardada (total=${Object.keys(archivo.sesiones).length})`);
428
633
  } catch (err) {
429
634
  console.warn('[SiiPortalAuth] Cache: error guardando caché →', err.message);
430
635
  }
431
636
  }
432
637
 
433
- /** Borra la sesión cacheada (útil para forzar re-login). */
434
- static limpiarSesionCache() {
435
- try { fs.unlinkSync(SESSION_CACHE_PATH); } catch { /* ignorar */ }
638
+ /**
639
+ * Borra sesiones cacheadas (útil para forzar re-login).
640
+ * @param {string} [certHash] - si se omite, borra **todas**.
641
+ */
642
+ static limpiarSesionCache(certHash) {
643
+ if (!certHash) {
644
+ try { fs.unlinkSync(SESSION_CACHE_PATH); } catch { /* ignorar */ }
645
+ return;
646
+ }
647
+ try {
648
+ const archivo = SiiPortalAuth._leerArchivoCache();
649
+ delete archivo.existia;
650
+ if (!archivo.sesiones[certHash]) return;
651
+ delete archivo.sesiones[certHash];
652
+ const tmp = `${SESSION_CACHE_PATH}.${process.pid}.tmp`;
653
+ fs.writeFileSync(tmp, JSON.stringify(archivo), 'utf8');
654
+ fs.renameSync(tmp, SESSION_CACHE_PATH);
655
+ } catch (err) {
656
+ console.warn('[SiiPortalAuth] Cache: error limpiando caché →', err.message);
657
+ }
436
658
  }
437
659
 
438
660
  /**
@@ -1086,7 +1308,7 @@ if (!fs.existsSync(SESSION_CACHE_PATH)) {
1086
1308
  const rutLimpio = String(rutEmpresa).replace(/\./g, '');
1087
1309
  const match = rutLimpio.match(/^(\d+)-([0-9Kk])$/);
1088
1310
  if (!match) {
1089
- throw new Error(`RUT empresa inválido: "${rutEmpresa}". Formato esperado: "78206276-K"`);
1311
+ throw new Error(`RUT empresa inválido: "${rutEmpresa}". Formato esperado: "77111222-3"`);
1090
1312
  }
1091
1313
  rutNum = match[1];
1092
1314
  dv = match[2].toUpperCase();
@@ -1161,6 +1383,146 @@ if (!fs.existsSync(SESSION_CACHE_PATH)) {
1161
1383
 
1162
1384
  return { emisor, cookieJar };
1163
1385
  }
1386
+
1387
+ // ─── Respaldo MIPYME — descarga del XML firmado completo ────────────────────
1388
+
1389
+ /**
1390
+ * Descarga el XML firmado completo de los DTE emitidos o recibidos de una empresa, desde el
1391
+ * "Respaldo de archivos MIPYME" del portal (www1.sii.cl/cgi-bin/Portal001).
1392
+ *
1393
+ * Es la única vía que entrega el **documento completo**: detalle línea por línea, `CdgItem`
1394
+ * del proveedor, referencias y TED. `obtenerDetalleDtes()` solo trae metadatos y
1395
+ * `obtenerResumenRegistro()` solo totales mensuales.
1396
+ *
1397
+ * ⚠️ El SII corta en **20 documentos por descarga**: con 21 devuelve una página de error, no
1398
+ * un XML recortado (medido 18/08/2026: 20 → OK, 21 → error). Por eso el rango se trocea solo
1399
+ * partiendo por la mitad hasta que cada tramo quepa, y se devuelve un XML por tramo.
1400
+ *
1401
+ * ⚠️ Requiere **solo el certificado digital**, no estar certificado como emisor.
1402
+ *
1403
+ * ⚠️ El captcha existe en el formulario pero hoy va vacío. Si el SII lo enciende, la
1404
+ * respuesta deja de ser XML y se lanza `RESPALDO_CAPTCHA`. No reintentar en loop: no se
1405
+ * resuelve solo.
1406
+ *
1407
+ * Ver el README para el contrato completo y los hallazgos del portal.
1408
+ *
1409
+ * @param {string} rut - RUT de la empresa sin DV ni puntos (ej. '76543210')
1410
+ * @param {string} dv - Dígito verificador (ej. '6')
1411
+ * @param {Object} opciones
1412
+ * @param {'ENV'|'RCP'} [opciones.origen='ENV'] - ENV = emitidos, RCP = recibidos
1413
+ * @param {string} opciones.desde - AAAA-MM-DD
1414
+ * @param {string} opciones.hasta - AAAA-MM-DD
1415
+ * @param {string} [opciones.tipoDoc=''] - Código de tipo DTE; vacío = todos
1416
+ * @param {number} [opciones.reintentos=3] - Intentos por request (el portal es lento)
1417
+ * @param {(t: {desde:string,hasta:string,total:number,xml:string}) => Promise<void>} [opciones.onTramo]
1418
+ * Modo streaming: se invoca por cada tramo descargado y el XML **no** se acumula en el
1419
+ * resultado. Obligatorio para históricos grandes: sin esto todos los XML quedan en memoria
1420
+ * hasta el final (~6,4 KB por documento, o sea ~7 MB para 1.100 documentos).
1421
+ * @param {Object} [opciones.cookieJar] - Sesión ya autenticada
1422
+ * @returns {Promise<{ total: number, tramos: Array<{desde:string,hasta:string,total:number,xml?:string}> }>}
1423
+ * Con `onTramo`, los tramos vienen sin `xml` (ya se entregó por callback).
1424
+ */
1425
+ async descargarRespaldoMipyme(rut, dv, opciones = {}) {
1426
+ const {
1427
+ origen = 'ENV', desde, hasta, tipoDoc = '',
1428
+ reintentos = 3, cookieJar = null, onTramo = null,
1429
+ } = opciones;
1430
+
1431
+ if (!desde || !hasta) throw new Error('descargarRespaldoMipyme: faltan `desde` y `hasta` (AAAA-MM-DD)');
1432
+ if (origen !== 'ENV' && origen !== 'RCP') {
1433
+ throw new Error(`descargarRespaldoMipyme: origen debe ser 'ENV' (emitidos) o 'RCP' (recibidos), no '${origen}'`);
1434
+ }
1435
+
1436
+ const jar = cookieJar || await this.autenticar();
1437
+ // El portal viejo espera este flag; SiiPortalAuth no lo setea porque no lo necesita el resto.
1438
+ jar['usa_firma_central'] = 'true';
1439
+
1440
+ const URL_LISTA = `${RESPALDO_BASE}/lista_documentos.cgi`;
1441
+ const cabeceras = {
1442
+ 'Content-Type': 'application/x-www-form-urlencoded',
1443
+ 'Origin': 'https://www1.sii.cl',
1444
+ 'Referer': URL_LISTA,
1445
+ };
1446
+ const comunes = { RUT_EMP: rut, DV_EMP: dv, RUT_RECP: '', FOLIO: '', FOLIOHASTA: '', RZN_SOC: '', TPO_DOC: tipoDoc, ESTADO: '', ORDEN: '' };
1447
+
1448
+ const reintentar = async (fn, etiqueta) => {
1449
+ let ultimo;
1450
+ for (let i = 1; i <= reintentos; i++) {
1451
+ try { return await fn(); } catch (e) {
1452
+ ultimo = e;
1453
+ // Estos no se arreglan reintentando: el portal ya dio su veredicto.
1454
+ if (['RESPALDO_CAPTCHA', 'RESPALDO_SIN_EMPRESA', 'RESPALDO_SIN_DATOS', 'RESPALDO_RECHAZADO', 'RESPALDO_INDETERMINADO'].includes(e.code)) throw e;
1455
+ if (i < reintentos) await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, i - 1)));
1456
+ }
1457
+ }
1458
+ throw new Error(`${etiqueta}: falló tras ${reintentos} intentos — ${ultimo.message}`);
1459
+ };
1460
+
1461
+ /** Consulta el listado y devuelve cuántos documentos hay en el rango. */
1462
+ const contar = (d, h) => reintentar(async () => {
1463
+ const body = new URLSearchParams({
1464
+ 'recaptcha-response': '', ORIGEN: origen, FEC_DESDE: d, FEC_HASTA: h,
1465
+ NUM_PAG: '1', TPO_ARCHIVO: 'dte', ...comunes,
1466
+ }).toString();
1467
+ const res = await this._request(URL_LISTA, { method: 'POST', cookieJar: jar, body, headers: cabeceras });
1468
+ const html = res.body || '';
1469
+
1470
+ // El total manda: si está, la respuesta es buena aunque la página traiga algún alert
1471
+ // incidental. Recién si no está se investiga por qué.
1472
+ const m = html.match(/total de documentos[^0-9]{0,40}(\d+)/i);
1473
+ if (m) return parseInt(m[1], 10);
1474
+
1475
+ throw errorDeRespuestaPortal(html, 'no se pudo leer el total de documentos del listado');
1476
+ }, `listado ${d}..${h}`);
1477
+
1478
+ /** Baja el XML de un rango que ya se sabe que cabe en el tope. */
1479
+ const bajar = (d, h) => reintentar(async () => {
1480
+ const qs = new URLSearchParams({ ...comunes, ORIGEN: origen, FEC_DESDE: d, FEC_HASTA: h, DOWNLOAD: 'XML' }).toString();
1481
+ const res = await this._request(`${RESPALDO_BASE}/download.cgi?${qs}`, { cookieJar: jar, headers: { Referer: URL_LISTA } });
1482
+ const cuerpo = res.body || '';
1483
+ if (cuerpo.trimStart().startsWith('<?xml')) return cuerpo;
1484
+ throw errorDeRespuestaPortal(cuerpo, 'la descarga no devolvió XML');
1485
+ }, `descarga ${d}..${h}`);
1486
+
1487
+ // Parte el rango por la mitad hasta que cada tramo quepa en RESPALDO_MAX_DOCS.
1488
+ const aDia = (s) => new Date(`${s}T00:00:00Z`);
1489
+ const aIso = (x) => x.toISOString().slice(0, 10);
1490
+ const tramos = [];
1491
+ const dividir = async (d, h) => {
1492
+ const n = await contar(d, h);
1493
+ if (n === 0) return;
1494
+ if (n <= RESPALDO_MAX_DOCS) { tramos.push({ desde: d, hasta: h, total: n }); return; }
1495
+ const medio = aIso(new Date((aDia(d).getTime() + aDia(h).getTime()) / 2));
1496
+ if (medio === h || medio === d) {
1497
+ // Un solo día con más de 20 documentos: el SII no deja bajarlo y no hay cómo partirlo.
1498
+ throw new Error(`El ${d} tiene ${n} documentos y el SII solo permite ${RESPALDO_MAX_DOCS} por descarga. No es divisible por fecha.`);
1499
+ }
1500
+ const siguiente = aIso(new Date(aDia(medio).getTime() + 86400000));
1501
+ await dividir(d, medio);
1502
+ await dividir(siguiente, h);
1503
+ };
1504
+ await dividir(desde, hasta);
1505
+
1506
+ // ⚠️ Del MÁS NUEVO al más viejo. El troceo por bisección deja los tramos en orden
1507
+ // cronológico, así que sin esto el consumidor recibe primero lo más antiguo del rango y
1508
+ // espera todas las descargas para ver lo último. Al revés, el primer tramo ya trae los
1509
+ // documentos recientes, que es lo que el usuario está mirando.
1510
+ tramos.sort((a, b) => b.desde.localeCompare(a.desde));
1511
+
1512
+ let total = 0;
1513
+ for (const t of tramos) {
1514
+ const xml = await bajar(t.desde, t.hasta);
1515
+ total += t.total;
1516
+ if (onTramo) {
1517
+ // Modo streaming: el consumidor persiste y el XML se suelta enseguida. Sin esto, un
1518
+ // histórico grande queda entero en memoria (~6,4 KB por documento).
1519
+ await onTramo({ desde: t.desde, hasta: t.hasta, total: t.total, xml });
1520
+ } else {
1521
+ t.xml = xml;
1522
+ }
1523
+ }
1524
+ return { total, tramos };
1525
+ }
1164
1526
  }
1165
1527
 
1166
1528
  module.exports = SiiPortalAuth;
package/SiiSession.js CHANGED
@@ -374,7 +374,7 @@ class SiiSession {
374
374
  if (!body || !body.includes('superado el m')) return false;
375
375
 
376
376
  // Guardar HTML para diagnóstico — solo si el consumidor definió dónde.
377
- // Antes esto apuntaba a `../devlas-cloud-api-node/debug/sii-sessions`: la librería
377
+ // Antes esto apuntaba a un directorio del proyecto consumidor: la librería
378
378
  // nombraba a un repo consumidor y asumía que estaba como carpeta hermana, así que
379
379
  // en cualquier otra instalación escribía en un lugar inesperado o fallaba en silencio.
380
380
  saveDebugFile(resolveDebugDir(this.debugDir), `demasiadas-sesiones-${Date.now()}.html`, body);
@@ -608,7 +608,7 @@ class SiiSession {
608
608
  // último comercio que operó queda ahí para el siguiente. Cargarla significa actuar
609
609
  // ante el SII como el usuario de OTRO contribuyente.
610
610
  //
611
- // Caso real (14/08/2026): una corrida del RUT 78206276-K reusó la sesión del
611
+ // Caso real (14/08/2026): una corrida del RUT 77111222-3 reusó la sesión del
612
612
  // certificado de otra empresa y el portal respondió "usted no está autorizado por la
613
613
  // empresa para ingresar a esta opción" al pedir el set de pruebas. El mismo pedido
614
614
  // con autenticación fresca funcionó. El síntoma no dice "sesión equivocada", dice