@devlas/dte-sii 2.15.0 → 2.16.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/CafSolicitor.js CHANGED
@@ -37,7 +37,7 @@ class CafSolicitor {
37
37
  /**
38
38
  * @param {Object} options - Opciones de configuración
39
39
  * @param {string} options.ambiente - 'certificacion' o 'produccion'
40
- * @param {string} options.rutEmisor - RUT del emisor (ej: 76192083-9)
40
+ * @param {string} options.rutEmisor - RUT del emisor (ej: 76543210-K)
41
41
  * @param {string} options.pfxPath - Ruta absoluta al certificado PFX
42
42
  * @param {string} options.pfxPassword - Contraseña del certificado
43
43
  * @param {string} [options.baseDir] - Directorio base para guardar archivos
@@ -194,7 +194,7 @@ class CafSolicitor {
194
194
  * Detecta la página de bloqueo duro de timbraje del SII.
195
195
  *
196
196
  * El SII usa al menos DOS redacciones para la misma página de rechazo:
197
- * - "NO SE AUTORIZA TIMBRAJE ELECTRÓNICO" (la documentada en PLAN-MEJORAS-CAF.md)
197
+ * - "NO SE AUTORIZA TIMBRAJE ELECTRÓNICO" (ver el CHANGELOG)
198
198
  * - "NO AUTORIZA TIMBRAJE ELECTRÓNICA" (observada 2026-07-22 en tipo 56)
199
199
  *
200
200
  * Detectar solo la primera hacía que la segunda cayera al genérico
@@ -247,7 +247,7 @@ class CafSolicitor {
247
247
  * Verificación de Actividades sale explícito ("no registra Verificación de
248
248
  * Actividades..."), en producción el SII solo dice esto — mismo texto para tipo 39
249
249
  * (boleta) y 33 (factura), sin indicar la causa real. Verificado 2026-07-24 contra
250
- * 78441936-3, en ambos tipos, con el mismo request real repetido dos veces.
250
+ * 79555666-7, en ambos tipos, con el mismo request real repetido dos veces.
251
251
  *
252
252
  * Sin esta detección caía en el genérico `UNKNOWN: No se obtuvo CAF en la
253
253
  * respuesta`, que no distingue este caso (probable Verificación de Actividades,
@@ -281,7 +281,7 @@ class CafSolicitor {
281
281
  * usuarios ni timbrar. Sin este mensaje el usuario ve un error genérico y vuelve a
282
282
  * intentar sin saber que lo único que corresponde es ir al SII.
283
283
  *
284
- * Caso real (12/08/2026, RUT 78441936-3): Verificación de Actividades aprobada,
284
+ * Caso real (12/08/2026, RUT 79555666-7): Verificación de Actividades aprobada,
285
285
  * usuario enrolado en maullin y certificación de boleta enviada — y aun así palena
286
286
  * rechazaba todo, porque la inscripción por internet estaba bloqueada de origen.
287
287
  */
@@ -300,7 +300,7 @@ class CafSolicitor {
300
300
  * autorizado como emisor electrónico en producción — no hay nada que arreglar del lado
301
301
  * del enrolamiento, porque el portal ni siquiera deja abrir la mantención de usuarios.
302
302
  *
303
- * Caso real (12/08/2026, RUT 78441936-3): el flujo de enrolamiento siguió de largo con
303
+ * Caso real (12/08/2026, RUT 79555666-7): el flujo de enrolamiento siguió de largo con
304
304
  * páginas vacías —sin formulario ni hidden `key`— hasta reventar con un 500 en
305
305
  * `eu_graba_usuario`. El 500 era el síntoma; esta frase, en el PRIMER paso, la causa.
306
306
  */
@@ -313,7 +313,7 @@ class CafSolicitor {
313
313
  * ¿Este HTML es un rechazo que hace inútil seguir el flujo?
314
314
  *
315
315
  * ⚠️ Los rechazos del SII NO llegan siempre en la última respuesta. Este caso real
316
- * (12/08/2026, RUT 78441936-3, tipo 39 en palena) llegó en el PASO 2
316
+ * (12/08/2026, RUT 79555666-7, tipo 39 en palena) llegó en el PASO 2
317
317
  * (`of_solicita_folios_dcto`), donde solo se miraba `esBloqueoTimbraje`. El flujo
318
318
  * siguió de largo y terminó devolviendo el genérico `UNKNOWN: No se obtuvo CAF`,
319
319
  * escondiendo un mensaje que la librería ya sabía interpretar desde julio.
@@ -381,7 +381,7 @@ class CafSolicitor {
381
381
  * anulado completamente... los documentos que el Servicio reciba con dichos folios
382
382
  * serán rechazados". Por eso hay que abrir cada rango para saber si sirve, y por eso
383
383
  * este método hace un request por rango. Verificado en maullin el 14/08/2026 con el
384
- * RUT 77967443-6: de 6 rangos listados en el tipo 56, solo 2 eran usables.
384
+ * RUT 76543210-K: de 6 rangos listados en el tipo 56, solo 2 eran usables.
385
385
  *
386
386
  * @param {number} tipoDte
387
387
  * @returns {Promise<Array<{ campos: Object, folioDesde: number, folioHasta: number,
package/EnviadorSII.js CHANGED
@@ -1195,7 +1195,7 @@ class EnviadorSII {
1195
1195
 
1196
1196
  const [rutNum, dv] = rutEmisor.split('-');
1197
1197
  // Fallback a rutEmisor si el certificado no expone RUT (ver
1198
- // docs/EXTRACCION_RUT_CERTIFICADO.md) — evita un TypeError por .split
1198
+ // ver el JSDoc de Certificado) — evita un TypeError por .split
1199
1199
  // sobre null cuando el PFX no tiene el RUT en ningún campo conocido.
1200
1200
  const rutEnvia = this.certificado.rut || rutEmisor;
1201
1201
  const [rutEnviaNum, dvEnvia] = rutEnvia.split('-');
package/FolioService.js CHANGED
@@ -167,7 +167,7 @@ class FolioService {
167
167
  // tipo entraba como candidato válido.
168
168
  //
169
169
  // Pasó de verdad (14/08/2026): al reusar CAF previos, los tipos 56 y 61 del
170
- // RUT 77967443-6 resolvieron a CAF de 78206276-K. El timbre quedó firmado con
170
+ // RUT 76543210-K resolvieron a CAF de 77111222-3. El timbre quedó firmado con
171
171
  // la llave de otro contribuyente y el SII devolvió `RFR - Rechazado por Error
172
172
  // en Firma` para todo el envío, lo que a su vez trababa la declaración de
173
173
  // simulación ("no debe contener documentos con reparos o rechazos").
@@ -761,7 +761,7 @@ class FolioService {
761
761
  // negativas: son resultados esperados (ese rango ya estaba anulado, o sus folios ya
762
762
  // se usaron) y aparecen mezclados con anulaciones exitosas.
763
763
  //
764
- // Contarlos rompía la limpieza (medido el 13/08/2026, RUT 77967443-6, tipo 33):
764
+ // Contarlos rompía la limpieza (medido el 13/08/2026, RUT 76543210-K, tipo 33):
765
765
  // 20 rangos a procesar
766
766
  // ✓ 47-49 anulado ✓ 43-46 anulado
767
767
  // ✗ 31-34 ya anulado ✗ 27-30 ya anulado → CORTE, 8 rangos sin intentar
package/README.md CHANGED
@@ -25,6 +25,7 @@ npm install @devlas/dte-sii
25
25
  - [Libros electrónicos y RCOF](#libros-electrónicos-y-rcof)
26
26
  - [Gestión de folios](#gestión-de-folios)
27
27
  - [Sesión y autenticación con el SII](#sesión-y-autenticación-con-el-sii)
28
+ - [Descargar el XML completo desde el portal (Respaldo MIPYME)](#descargar-el-xml-completo-desde-el-portal-respaldo-mipyme)
28
29
  - [Aceptación y reclamo de DTE (WsReclamo)](#aceptación-y-reclamo-de-dte-wsreclamo)
29
30
  - [Estados SII: Interpretación de respuestas](#estados-sii-interpretación-de-respuestas)
30
31
  - [Manejo de errores](#manejo-de-errores)
@@ -69,7 +70,7 @@ const caf = new CAF(fs.readFileSync('caf_33.xml', 'utf8'))
69
70
  const dte = new DTE({
70
71
  Encabezado: {
71
72
  IdDoc: { TipoDTE: 33, Folio: 1 },
72
- Emisor: { RUTEmisor: '76354771-K', RznSoc: 'Mi Empresa SpA', GiroEmis: 'Software', DirOrigen: 'Av. Ejemplo 123', CmnaOrigen: 'Santiago', Acteco: 620200 },
73
+ Emisor: { RUTEmisor: '76543210-K', RznSoc: 'Mi Empresa SpA', GiroEmis: 'Software', DirOrigen: 'Av. Ejemplo 123', CmnaOrigen: 'Santiago', Acteco: 620200 },
73
74
  Receptor: { RUTRecep: '12345678-9', RznSocRecep: 'Cliente SA', GiroRecep: 'Comercio', DirRecep: 'Calle 456', CmnaRecep: 'Providencia' },
74
75
  },
75
76
  Detalle: [
@@ -81,7 +82,7 @@ dte.generarXML().timbrar(caf).firmar(cert)
81
82
 
82
83
  const envio = new EnvioDTE({ certificado: cert })
83
84
  envio.agregar(dte)
84
- envio.setCaratula({ RutEmisor: '76354771-K', RutReceptor: '60803000-K', FchResol: '2024-01-15', NroResol: 123 })
85
+ envio.setCaratula({ RutEmisor: '76543210-K', RutReceptor: '60803000-K', FchResol: '2024-01-15', NroResol: 123 })
85
86
  envio.generar()
86
87
 
87
88
  const enviador = new EnviadorSII(cert, 'produccion') // o 'certificacion'
@@ -121,7 +122,7 @@ const dte = new DTE({
121
122
  tipo: 33,
122
123
  folio: 1,
123
124
  emisor: {
124
- rut: '76354771-K', razonSocial: 'Mi Empresa SpA',
125
+ rut: '76543210-K', razonSocial: 'Mi Empresa SpA',
125
126
  giro: 'Desarrollo de software', direccion: 'Av. Ejemplo 123',
126
127
  comuna: 'Santiago', actividadEconomica: 620200,
127
128
  },
@@ -158,7 +159,7 @@ const { EnvioDTE, EnviadorSII } = require('@devlas/dte-sii')
158
159
  const envio = new EnvioDTE({ certificado: cert })
159
160
  envio.agregar(dte)
160
161
  envio.setCaratula({
161
- RutEmisor: '76354771-K',
162
+ RutEmisor: '76543210-K',
162
163
  RutReceptor: '60803000-K', // RUT del SII para envíos propios
163
164
  FchResol: '2024-01-15',
164
165
  NroResol: 123,
@@ -178,14 +179,14 @@ const resultado = await enviador.enviarDteSoap(envio)
178
179
  // Estado del sobre (EnvioDTE)
179
180
  const estadoSobre = await enviador.consultarEstado({
180
181
  trackId: resultado.trackId,
181
- rutEmisor: '76354771-K',
182
+ rutEmisor: '76543210-K',
182
183
  })
183
184
  // estadoSobre.esExitoso / esIntermedio / esRechazado
184
185
  // estadoSobre.codigo → 'EPR', 'RPR', 'RSC', etc.
185
186
 
186
187
  // Estado de un DTE individual
187
188
  const estadoDte = await enviador.consultarEstadoDte({
188
- rutEmisor: '76354771-K',
189
+ rutEmisor: '76543210-K',
189
190
  rutReceptor: '12345678-9',
190
191
  tipoDte: 33,
191
192
  folio: 1,
@@ -244,13 +245,13 @@ const fs = require('fs')
244
245
  const cert = new Certificado(fs.readFileSync('empresa_cert.pfx'), 'clave')
245
246
  const caf = new CAF(fs.readFileSync('caf_39_cert.xml', 'utf8'))
246
247
 
247
- const dte = new DTE({ tipo: 39, folio: 1, emisor: { rut: '76354771-K', ... }, items: [ ... ] })
248
+ const dte = new DTE({ tipo: 39, folio: 1, emisor: { rut: '76543210-K', ... }, items: [ ... ] })
248
249
  dte.generarXML().timbrar(caf).firmar(cert)
249
250
 
250
251
  const envio = new EnvioBOLETA({ certificado: cert })
251
252
  envio.agregar(dte)
252
253
  envio.setCaratula({
253
- RutEmisor: '76354771-K',
254
+ RutEmisor: '76543210-K',
254
255
  FchResol: '2019-10-18', // fecha de resolución de certificación (entregada por el SII)
255
256
  NroResol: 0, // siempre 0 en certificación
256
257
  })
@@ -271,13 +272,13 @@ const fs = require('fs')
271
272
  const cert = new Certificado(fs.readFileSync('empresa_prod.pfx'), 'clave')
272
273
  const caf = new CAF(fs.readFileSync('caf_39_prod.xml', 'utf8'))
273
274
 
274
- const dte = new DTE({ tipo: 39, folio: 1, emisor: { rut: '76354771-K', ... }, items: [ ... ] })
275
+ const dte = new DTE({ tipo: 39, folio: 1, emisor: { rut: '76543210-K', ... }, items: [ ... ] })
275
276
  dte.generarXML().timbrar(caf).firmar(cert)
276
277
 
277
278
  const envio = new EnvioBOLETA({ certificado: cert })
278
279
  envio.agregar(dte)
279
280
  envio.setCaratula({
280
- RutEmisor: '76354771-K',
281
+ RutEmisor: '76543210-K',
281
282
  FchResol: '2024-01-15', // fecha real de resolución SII de la empresa
282
283
  NroResol: 123, // número real de resolución SII de la empresa
283
284
  })
@@ -303,7 +304,7 @@ service.cargarCAF(fs.readFileSync('caf_39.xml', 'utf8'))
303
304
 
304
305
  const boleta = await service.crearBoleta({
305
306
  folio: 1,
306
- emisor: { rut: '76354771-K', razonSocial: 'Mi Empresa', giro: 'Software', ... },
307
+ emisor: { rut: '76543210-K', razonSocial: 'Mi Empresa', giro: 'Software', ... },
307
308
  items: [{ nombre: 'Producto', cantidad: 1, precioConIva: 10000 }],
308
309
  resolucion: {
309
310
  fecha: process.env.SII_AMBIENTE === 'certificacion' ? '2019-10-18' : '2024-01-15',
@@ -323,8 +324,8 @@ const { LibroCompraVenta, Certificado } = require('@devlas/dte-sii')
323
324
 
324
325
  const libro = new LibroCompraVenta()
325
326
  libro.setCaratula({
326
- RutEmisorLibro: '76354771-K',
327
- RutEnvia: '76354771-K',
327
+ RutEmisorLibro: '76543210-K',
328
+ RutEnvia: '76543210-K',
328
329
  PeriodoTributario: '2024-06',
329
330
  FchResol: '2024-01-15', NroResol: 123,
330
331
  TipoOperacion: 'VENTA', // o 'COMPRA'
@@ -349,7 +350,7 @@ const { ConsumoFolio, CAF, Certificado } = require('@devlas/dte-sii')
349
350
 
350
351
  const rcof = new ConsumoFolio()
351
352
  rcof.setCaratula({
352
- RutEmisor: '76354771-K',
353
+ RutEmisor: '76543210-K',
353
354
  FchResol: '2024-01-15',
354
355
  NroResol: 0,
355
356
  FchInicio: '2024-06-15',
@@ -395,7 +396,7 @@ const fingerprint = createCafFingerprint(cafXml) // hash único del CA
395
396
 
396
397
  // Reservar el siguiente folio disponible del rango del CAF
397
398
  const folio = registry.reserveNextFolio({
398
- rutEmisor: '76354771-K',
399
+ rutEmisor: '76543210-K',
399
400
  tipoDte: caf.getTipoDTE(),
400
401
  folioDesde: caf.getFolioDesde(),
401
402
  folioHasta: caf.getFolioHasta(),
@@ -407,7 +408,7 @@ const folio = registry.reserveNextFolio({
407
408
 
408
409
  // Marcar folio como enviado al recibir trackId del SII
409
410
  registry.markFolioSent({
410
- rutEmisor: '76354771-K', tipoDte: 33, folio,
411
+ rutEmisor: '76543210-K', tipoDte: 33, folio,
411
412
  folioDesde: caf.getFolioDesde(), folioHasta: caf.getFolioHasta(),
412
413
  ambiente: 'produccion', cafFingerprint: fingerprint,
413
414
  trackId: '0245283324',
@@ -421,7 +422,7 @@ const { resolveCafPath } = require('@devlas/dte-sii')
421
422
 
422
423
  const cafPath = resolveCafPath({
423
424
  tipoDte: 33,
424
- rutEmisor: '76354771-K',
425
+ rutEmisor: '76543210-K',
425
426
  requiredCount: 1, // necesito al menos 1 folio disponible
426
427
  ambiente: 'produccion',
427
428
  })
@@ -439,7 +440,7 @@ const { FolioService, Certificado } = require('@devlas/dte-sii')
439
440
 
440
441
  const service = new FolioService({
441
442
  ambiente: 'produccion',
442
- rutEmisor: '76354771-K',
443
+ rutEmisor: '76543210-K',
443
444
  certificado: new Certificado(fs.readFileSync('empresa.pfx'), 'clave'),
444
445
  })
445
446
 
@@ -493,7 +494,7 @@ const fs = require('fs')
493
494
  const path = require('path')
494
495
 
495
496
  const CAF_DIR = path.join(__dirname, 'cafs')
496
- const RUT = '76354771-K'
497
+ const RUT = '76543210-K'
497
498
  const AMBIENTE = 'produccion'
498
499
  const TIPO_DTE = 33
499
500
  const UMBRAL = 10 // solicitar nuevo CAF cuando queden menos de N folios
@@ -568,6 +569,41 @@ const datos = await auth.obtenerDatosEmpresa()
568
569
  const cookies = await SiiPortalAuth.getCookieStringForPfx(cert)
569
570
  ```
570
571
 
572
+ #### Caché de sesión: un mapa por certificado
573
+
574
+ Un login contra `zeusr.sii.cl` es un handshake con certificado, **caro y contado por el SII**,
575
+ que bloquea el RUT por *"máximo de sesiones autenticadas"*. Por eso las cookies se cachean en
576
+ disco, en `$DATADIR/sii_session_cache.json`, con un TTL de 90 minutos.
577
+
578
+ Desde **2.16.0 el caché es un mapa por huella de certificado**. Antes guardaba una sola sesión,
579
+ así que en un servidor multi-tenant cada certificado invalidaba al anterior y **todos**
580
+ re-autenticaban en cada pasada: el costo crecía lineal con la base de clientes.
581
+
582
+ - El formato viejo se **migra**, no se descarta.
583
+ - Poda automática: expiradas primero, y tope de 200 entradas.
584
+ - Escritura atómica y relectura previa, para que dos réplicas sobre el mismo volumen no se
585
+ borren las sesiones entre sí.
586
+
587
+ ```javascript
588
+ SiiPortalAuth.limpiarSesionCache(certHash) // borra una
589
+ SiiPortalAuth.limpiarSesionCache() // borra todas
590
+ ```
591
+
592
+ > ⚠️ **En un servidor, apunta `DATADIR` a un volumen persistente.** Sin eso el caché vive en el
593
+ > filesystem del contenedor y se pierde en cada redeploy, forzando un re-login de toda la base.
594
+
595
+ #### Reintento ante fallas de red/TLS
596
+
597
+ `autenticar()` reintenta 3 veces con espera progresiva (1s, 2s, 4s) ante errores de transporte.
598
+
599
+ > ⚠️ El SII devuelve **`EPROTO` de forma intermitente** al abrir la conexión TLS con certificado
600
+ > (`rsa_pss ... last octet invalid`), y reintentando con el **mismo** certificado funciona. **No
601
+ > es señal de certificado vencido ni no habilitado**, aunque lo parezca. Interpretarlo así marca
602
+ > como rotos certificados que están sanos.
603
+
604
+ El **límite de sesiones nunca se reintenta** (cada intento empeora el bloqueo) y se distingue por
605
+ `err.code === 'SII_LIMITE_SESIONES'`.
606
+
571
607
  ### SiiSession: sesiones HTTP autenticadas
572
608
 
573
609
  ```javascript
@@ -580,6 +616,91 @@ const resp = await session.request('GET', 'https://herculesr.sii.cl/...')
580
616
 
581
617
  ---
582
618
 
619
+ ## Descargar el XML completo desde el portal (Respaldo MIPYME)
620
+
621
+ `descargarRespaldoMipyme()` baja el **XML firmado completo** de los DTE emitidos o recibidos
622
+ desde el "Respaldo de archivos MIPYME" del portal (`www1.sii.cl/cgi-bin/Portal001`).
623
+
624
+ Es la única vía que entrega el **documento entero**: detalle línea por línea, `CdgItem` del
625
+ proveedor, referencias y TED. `obtenerDetalleDtes()` solo trae metadatos y
626
+ `obtenerResumenRegistro()` solo totales mensuales.
627
+
628
+ **Requiere únicamente el certificado digital**, no estar certificado como emisor.
629
+
630
+ > 🔴 **Existe SOLO en producción: no hay ambiente de certificación.** Medido sobre
631
+ > `/cgi-bin/Portal001/lista_documentos.cgi`: `www1.sii.cl` responde 200, `maullin.sii.cl`
632
+ > redirige a `Error404` y `www4c.sii.cl` da 404 (para contrastar, una ruta real de maullin
633
+ > redirige al login de certificación, no a un 404).
634
+ >
635
+ > Consecuencia para cualquier consumidor: con el resto del sistema apuntando a maullin, este
636
+ > método **igual lee documentos reales del contribuyente**. Es de solo lectura contra el SII,
637
+ > pero escribe facturas reales en la base del entorno que lo llame. Un entorno de desarrollo
638
+ > necesita una puerta explícita; no alcanza con mirar la variable de ambiente del DTE, porque
639
+ > esta función no tiene ambientes.
640
+
641
+ ```javascript
642
+ const auth = new SiiPortalAuth({ pfxBuffer, pfxPassword })
643
+
644
+ const { total, tramos } = await auth.descargarRespaldoMipyme('76543210', '6', {
645
+ origen: 'RCP', // 'ENV' emitidos | 'RCP' recibidos
646
+ desde: '2026-01-01',
647
+ hasta: '2026-08-18',
648
+ tipoDoc: '', // vacío = todos
649
+ reintentos: 3,
650
+ })
651
+ // tramos: [{ desde, hasta, total, xml }, ...] — un XML por tramo
652
+ ```
653
+
654
+ ### Modo streaming (`onTramo`) — obligatorio para históricos grandes
655
+
656
+ Sin `onTramo` **todos los XML quedan en memoria hasta el final**: ~6,4 KB por documento, o sea
657
+ unos 7 MB para 1.100 documentos, y crece lineal.
658
+
659
+ ```javascript
660
+ await auth.descargarRespaldoMipyme(rut, dv, {
661
+ origen: 'RCP', desde, hasta,
662
+ onTramo: async (t) => { await guardar(t.xml) }, // se persiste y se suelta
663
+ })
664
+ // con onTramo, los tramos del resultado vienen SIN `xml`
665
+ ```
666
+
667
+ ### Qué hay que saber del portal
668
+
669
+ | | |
670
+ |---|---|
671
+ | **Tope de 20 por descarga** | Es del servidor, no cosmético. Con 21 devuelve **HTML de error**, no un XML recortado. El método trocea el rango solo, del **más reciente al más viejo**. |
672
+ | **Un día con más de 20** | No se puede partir más por fecha: se lanza error explícito. Tiene salida cortando por `TPO_DOC` (un tipo de DTE por consulta). ⚠️ **No** usar `FOLIO`/`FOLIOHASTA`: borran `FEC_HASTA` en silencio y devuelven otro conjunto. |
673
+ | **Encoding** | El XML viene en **ISO-8859-1**. Leerlo como utf8 rompe los acentos. |
674
+ | **Captcha** | Hoy va vacío, pero el SII puede encenderlo sin avisar → `RESPALDO_CAPTCHA`. |
675
+ | **Alcance** | Solo lo registrado en el sistema de facturación **gratuito** del SII. Un comercio que ya migró a otro sistema no encuentra ahí sus documentos nuevos. |
676
+
677
+ > 🔴 **El portal viejo comunica sus rechazos por `alert()` de JavaScript, con HTTP 200** — no en
678
+ > el HTML visible ni en el `<title>`, que dice otra cosa. Y la página **válida** trae además un
679
+ > `//alert(...)` **comentado**. Clasificar por título, limpiar los `<script>` antes de parsear, o
680
+ > creerle al alert comentado: las tres cosas producen diagnósticos falsos.
681
+
682
+ Los errores traen **`err.mensajePortal`** con el texto exacto del SII. **Mostrar ese texto, no
683
+ una traducción propia.**
684
+
685
+ ⚠️ **`SIN_DATOS` no es lo mismo que `INDETERMINADO`.** El primero es un veredicto definitivo
686
+ ("acá no hay nada") y el consumidor puede cerrar ese período para siempre. Los otros dos
687
+ significan "no se pudo concluir" y "no pudimos preguntar": tratarlos igual cerró en falso un
688
+ período que ya tenía 24 documentos bajados.
689
+
690
+ | código | qué pasó | ¿reintentar? |
691
+ |---|---|---|
692
+ | `RESPALDO_SIN_DATOS` | el RUT no tiene información en MIPYME | no |
693
+ | `RESPALDO_CAPTCHA` | el SII encendió el captcha | no |
694
+ | `RESPALDO_RECHAZADO` | rechazo con un texto que no conocemos; llega literal | no |
695
+ | `RESPALDO_INDETERMINADO` | **varios** mensajes del template, sin veredicto único; llegan en `err.mensajesPortal` | sí, más tarde |
696
+ | `RESPALDO_SIN_EMPRESA` | página de ingreso sin ningún alert; causa no determinada | no |
697
+
698
+ Contrato completo, respuestas reales y los errores que cuestan tiempo (es **POST** no GET;
699
+ `ORIGEN=ENV` no `EMI`; el listado es obligatorio antes de la descarga) en
700
+ la sección de arriba.
701
+
702
+ ---
703
+
583
704
  ## Aceptación y reclamo de DTE (WsReclamo)
584
705
 
585
706
  `WsReclamo` implementa el web service `WSRECLAMO` del SII (v1.2) para registrar eventos de aceptación/rechazo de DTE por parte del receptor.
@@ -594,7 +715,7 @@ const ws = new WsReclamo(new Certificado(fs.readFileSync('empresa.pfx'), 'clave'
594
715
 
595
716
  // Consultar historial de eventos de un DTE
596
717
  const eventos = await ws.listarEventosHistDoc({
597
- rutEmisor: '76354771-K',
718
+ rutEmisor: '76543210-K',
598
719
  tipoDTE: 33,
599
720
  folio: 1,
600
721
  rutReceptor: '12345678-9',
@@ -605,7 +726,7 @@ const estado = await ws.consultarEstadoReceptor({ ... })
605
726
 
606
727
  // Registrar aceptación (ACD) o reclamo (RCD)
607
728
  await ws.ingresarAceptacion({
608
- rutEmisor: '76354771-K', tipoDTE: 33, folio: 1,
729
+ rutEmisor: '76543210-K', tipoDTE: 33, folio: 1,
609
730
  accion: 'ACD', // ACD=Aceptado, RCD=Reclamado, ERM=Otorga Mercaderías
610
731
  })
611
732
  ```
@@ -676,7 +797,7 @@ const { configure, configureRetry } = require('@devlas/dte-sii')
676
797
  // Configuración global (aplicar al inicio de la app)
677
798
  configure({
678
799
  ambiente: 'produccion', // 'produccion' | 'certificacion'
679
- defaultRutEmisor: '76354771-K',
800
+ defaultRutEmisor: '76543210-K',
680
801
  tokenCacheTtlMs: 300_000, // 5 minutos (default)
681
802
  })
682
803
 
@@ -758,7 +879,7 @@ const require = createRequire(import.meta.url)
758
879
  const { Certificado, CAF, DTE, EnviadorSII } = require('@devlas/dte-sii')
759
880
  ```
760
881
 
761
- **TypeScript con ESM** (patrón usado en `devlas-cloud-api-node`):
882
+ **TypeScript con ESM** :
762
883
 
763
884
  ```typescript
764
885
  import { createRequire } from 'module'
@@ -814,7 +935,7 @@ import type {
814
935
  | `FolioService` | `FolioService.js` | Consulta, solicita y anula folios ante el SII |
815
936
  | `CafSolicitor` | `CafSolicitor.js` | Solicitud automatizada de CAF al SII |
816
937
  | `SiiSession` | `SiiSession.js` | Sesiones HTTP autenticadas con certificado (cookie jar) |
817
- | `SiiPortalAuth` | `SiiPortalAuth.js` | Autenticación al portal SII; obtiene datos de empresa; Singleton por cert |
938
+ | `SiiPortalAuth` | `SiiPortalAuth.js` | Autenticación al portal SII; datos de empresa; RCV; **respaldo MIPYME (XML completo)**; caché de sesión por certificado |
818
939
 
819
940
  ### Libros y reportes
820
941
 
@@ -880,7 +1001,7 @@ El directorio `cert/` contiene los helpers necesarios para ejecutar el proceso d
880
1001
  - Generación de muestras impresas
881
1002
 
882
1003
  ```javascript
883
- // Uso desde devlas-cloud-api-node
1004
+ // Uso desde un proyecto ESM
884
1005
  const { CertFolioHelper } = require('@devlas/dte-sii')
885
1006
  ```
886
1007
 
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
@@ -296,7 +296,7 @@ class CertRunner {
296
296
  * Timbra de una sola vez TODOS los folios que la corrida va a necesitar, sumados por
297
297
  * tipo entre los cuatro sets. Llamar antes de ejecutar el primer set.
298
298
  *
299
- * El problema que resuelve (medido el 13/08/2026, RUT 78480527-1): cada set pedía sus
299
+ * El problema que resuelve (medido el 13/08/2026, RUT 79888999-0): cada set pedía sus
300
300
  * propios folios cuando le tocaba, así que del tipo 56 se pedían tres tandas separadas
301
301
  * —básico 1, exenta 2, compra 1— en la misma corrida. El SII raciona el timbraje según
302
302
  * cuántos folios tengas autorizados sin usar, así que la primera tanda le bajaba el tope
@@ -599,7 +599,7 @@ class CertRunner {
599
599
  //
600
600
  // Con tope publicado alcanza con que el cupo CUBRA lo necesario. Antes se exigía
601
601
  // un margen de 3x para "actuar antes de quedar contra la pared", pero medido el
602
- // 13/08/2026 (RUT 78480527-1) ese margen disparaba la limpieza teniendo cupo de
602
+ // 13/08/2026 (RUT 79888999-0) ese margen disparaba la limpieza teniendo cupo de
603
603
  // sobra —tipo 61 con MAX_AUTOR=6 para 3 folios, tipo 33 con 4 para 4— y en los
604
604
  // tres tipos el resultado fue `0 anulados`: el SII rechazó cada anulación.
605
605
  //
@@ -648,7 +648,7 @@ class CertRunner {
648
648
  // bloquean el timbraje suelen ser de semanas atrás, el filtro los
649
649
  // descartaba antes de intentarlos, y la corrida se quedaba
650
650
  // reintentando para siempre contra algo que nunca iba a cambiar.
651
- // Caso real (14/08/2026, RUT 77967443-6): tipo 56 bloqueado por
651
+ // Caso real (14/08/2026, RUT 76543210-K): tipo 56 bloqueado por
652
652
  // folios del 22-07, seis intentos idénticos, cero anulados.
653
653
  //
654
654
  // Ojo: esto es el ÚLTIMO recurso. Antes de llegar acá se intenta reusar el CAF
@@ -684,7 +684,7 @@ class CertRunner {
684
684
  // para negar el timbraje: "usted tiene disponible una cantidad de folios suficiente".
685
685
  //
686
686
  // O sea que cada reintento empeoraba el bloqueo que intentaba superar. Medido el
687
- // 14/08/2026 (RUT 77967443-6): 6 reintentos de ENVIAR_SETS quemaron 66 folios
687
+ // 14/08/2026 (RUT 76543210-K): 6 reintentos de ENVIAR_SETS quemaron 66 folios
688
688
  // —tipos 33, 34, 46 y 52, seis rangos cada uno— sin emitir un solo documento,
689
689
  // mientras el tipo 56 seguía bloqueado.
690
690
  //
@@ -2693,7 +2693,7 @@ class CertRunner {
2693
2693
  // avance es imposible — en ambos la etapa se queda quieta. La única fuente de
2694
2694
  // verdad es el estado del envío, así que se le pregunta al SII por el trackId.
2695
2695
  //
2696
- // Caso real (14/08/2026, RUT 78206276-K): la simulación quedó `RFR - Rechazado por
2696
+ // Caso real (14/08/2026, RUT 77111222-3): la simulación quedó `RFR - Rechazado por
2697
2697
  // Error en Firma` y este método devolvió "Timeout esperando aprobación". Quien lo
2698
2698
  // llamaba lo leyó como "sigue en revisión", siguió adelante y marcó la etapa como
2699
2699
  // completada sobre un envío que el SII había rechazado.
@@ -3573,7 +3573,7 @@ class CertRunner {
3573
3573
  // buscaba UNA sola frase ("El estado de la postulacion"), así que cualquier otro
3574
3574
  // rechazo pasaba de largo, no se encontraba el ID de revisión y el usuario recibía
3575
3575
  // "no se pudo extraer ID de revisión de: //OK[0,0,2,...".
3576
- // Caso real (11/08/2026): "El contribuyente 78206276-K no ha solicitado Set de
3576
+ // Caso real (11/08/2026): "El contribuyente 77111222-3 no ha solicitado Set de
3577
3577
  // pruebas." quedó completamente oculto detrás de ese mensaje.
3578
3578
  //
3579
3579
  // Ahora se reconoce cualquier frase con pinta de mensaje al usuario. Es deliberado
@@ -3974,7 +3974,7 @@ class CertRunner {
3974
3974
  * - recuperar el estado de boleta cuando las banderas locales se resetearon (ese
3975
3975
  * portal NO aparece en pe_avance, así que consultarEstadoAvance no lo cubre)
3976
3976
  *
3977
- * Verificado 2026-07-24 contra 78441936-3 con curl crudo (independiente de este
3977
+ * Verificado 2026-07-24 contra 79555666-7 con curl crudo (independiente de este
3978
3978
  * código) — misma respuesta `//OK[0,[],0,7]` cuando no hay P90 todavía.
3979
3979
  *
3980
3980
  * @returns {Promise<{success: boolean, inscrita: boolean, listaParaDeclarar: boolean, estado: string|null, error?: string}>}
@@ -4017,7 +4017,7 @@ class CertRunner {
4017
4017
  //
4018
4018
  // Darlas por (a) siempre hacía que, después de una declaración exitosa, se
4019
4019
  // informara "esperando aprobación del SII (SOK)". La pantalla le pedía al usuario
4020
- // esperar una etapa que ya había superado (visto el 17/08/2026, RUT 78441936-3:
4020
+ // esperar una etapa que ya había superado (visto el 17/08/2026, RUT 79555666-7:
4021
4021
  // declaró a las 14:01 con DECLARACION EFECTUADA y a las 14:5x seguía diciendo que
4022
4022
  // faltaba el SOK).
4023
4023
  //
@@ -4239,7 +4239,7 @@ class CertRunner {
4239
4239
  // autorizada". Es la secuencia que ya usa CafSolicitor._processMultiStepFlow() para
4240
4240
  // pedir folios de verdad, y por eso ESE camino sí funciona.
4241
4241
  //
4242
- // Caso real (17/08/2026, RUT 78441936-3): el flujo reportaba "Boleta NO autorizada"
4242
+ // Caso real (17/08/2026, RUT 79555666-7): el flujo reportaba "Boleta NO autorizada"
4243
4243
  // mientras el SII tenía a la empresa como FACTURADOR ELECTRONICO con BOLETA
4244
4244
  // ELECTRONICA autorizada hasta el folio 103009 desde el 12/07/2026, y palena le
4245
4245
  // entregaba folios reales de factura por el camino bien armado. Un comercio ya
@@ -4271,7 +4271,7 @@ class CertRunner {
4271
4271
  // veía cero tipos y concluía "no autorizada" — un dato inventado, indistinguible
4272
4272
  // de la respuesta real.
4273
4273
  //
4274
- // Visto el 17/08/2026 (RUT 78441936-3): palena devolvió 3377 bytes con
4274
+ // Visto el 17/08/2026 (RUT 79555666-7): palena devolvió 3377 bytes con
4275
4275
  // "No ha sido posible completar su solicitud... código LIBRUD-OFSF-DTE-3-1-02" y
4276
4276
  // el flujo venía reportando "certificación incompleta" desde hacía días.
4277
4277
  //
package/cert/SetBase.js CHANGED
@@ -224,7 +224,7 @@ class SetBase {
224
224
  * `cafRef` acepta una ruta (comportamiento de siempre) o una lista de rutas. La lista
225
225
  * hace falta porque el SII no siempre entrega un rango contiguo: al reobtener folios ya
226
226
  * autorizados los devuelve de a uno (folios 1-1 y 3-3 como CAF separados, visto el
227
- * 14/08/2026 en el RUT 77967443-6).
227
+ * 14/08/2026 en el RUT 76543210-K).
228
228
  *
229
229
  * Y no alcanza con concatenar numeraciones: **cada CAF trae su propia llave privada RSA**
230
230
  * (`CAF.js` → `RSASK`) y el timbre de cada documento se firma con la llave del CAF que
package/cert/SetParser.js CHANGED
@@ -1016,7 +1016,21 @@ function generarEstructuraLibroCompras(set, opts = {}) {
1016
1016
  NroDoc: doc.folio,
1017
1017
  TasaImp: tasaIva,
1018
1018
  FchDoc: new Date().toISOString().split('T')[0], // Se debe ajustar
1019
- RUTDoc: '17096073-4', // RUT por defecto para certificación
1019
+ // Contraparte de relleno del libro de COMPRAS de certificación: los documentos del set
1020
+ // son ficticios y va emparejada con `RznSoc: 'Razon Social'`, también un marcador.
1021
+ //
1022
+ // ⚠️ Este valor SÍ llega al SII: `LibroCompras.generarDesdeEstructuras()` hace
1023
+ // `{ ...doc }` y el RUTDoc entra tal cual al XML que se declara. No es código muerto.
1024
+ //
1025
+ // Se usa el RUT del propio SII porque cumple las tres cosas que hacen falta: existe como
1026
+ // contribuyente (si el SII validara existencia, pasa), es público y no es dato personal
1027
+ // de nadie, y ya se usa en este repo como contraparte (`RutReceptor` de los envíos).
1028
+ //
1029
+ // ⚠️ Antes acá había un RUT tomado de la documentación de otro sistema de facturación,
1030
+ // que resultaba ser el de una persona real. NO poner un RUT de un ejemplo ajeno, y
1031
+ // evitar `11111111-1`: tiene DV válido pero es el dummy más conocido de Chile y varios
1032
+ // sistemas lo rechazan por eso.
1033
+ RUTDoc: '60803000-K',
1020
1034
  RznSoc: 'Razon Social',
1021
1035
  };
1022
1036
 
package/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * Facturación y boletas electrónicas para el SII de Chile.
7
7
  *
8
8
  * @version 2.5.2
9
- * @author Devlas SpA <hola@devlas.cl>
9
+ * @author Devlas SpA <ti@devlas.cl>
10
10
  * @license MIT
11
11
  */
12
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devlas/dte-sii",
3
- "version": "2.15.0",
3
+ "version": "2.16.0",
4
4
  "description": "Facturación y boletas electrónicas para el SII de Chile. Genera, timbra, firma y envía DTEs, libros electrónicos y automatiza la certificación.",
5
5
  "main": "index.js",
6
6
  "types": "dte-sii.d.ts",
@@ -8,7 +8,7 @@
8
8
  "author": "Devlas SpA <ti@devlas.cl> (https://devlas.cl)",
9
9
  "homepage": "https://github.com/devlas-cl/dte-sii",
10
10
  "scripts": {
11
- "test": "node test/pfx.test.js && node test/folios-consolidados.test.js && node test/c14n-apostrofe.test.js && node test/precargar-plan-corrida.test.js"
11
+ "test": "node test/pfx.test.js && node test/folios-consolidados.test.js && node test/c14n-apostrofe.test.js && node test/precargar-plan-corrida.test.js && node test/respaldo-mipyme.test.js"
12
12
  },
13
13
  "repository": {
14
14
  "type": "git",
package/utils/c14n.js CHANGED
@@ -51,7 +51,7 @@ function escapeText(text) {
51
51
  * por qué. Y solo pasaba cuando algún texto traía un apóstrofe, que es lo que lo hacía
52
52
  * tan difícil de ver: los mismos sets pasaban o fallaban según el contenido del set.
53
53
  *
54
- * Medido el 14/08/2026 (RUT 78206276-K, maullin). Un solo ítem, "CAPACITACION USO PLC'S
54
+ * Medido el 14/08/2026 (RUT 77111222-3, maullin). Un solo ítem, "CAPACITACION USO PLC'S
55
55
  * CNC", en un único documento del Set Factura Exenta:
56
56
  *
57
57
  * Set Básico → EPR 0 documentos con digest divergente
package/utils/pfx.js CHANGED
@@ -183,7 +183,7 @@ function extractSubjectFields(cert) {
183
183
  * 1.3.6.1.4.1.8321.1 — reservado bajo la normativa chilena de firma
184
184
  * electrónica (Ley 19.799). A diferencia de serialNumber/OU/CN, que son
185
185
  * convención de cada CA, este OID es consistente entre CAs (verificado
186
- * contra Acepta, IDOK y Signapis — ver docs/EXTRACCION_RUT_CERTIFICADO.md).
186
+ * contra Acepta, IDOK y Signapis).
187
187
  * @param {forge.pki.Certificate} cert - Certificado
188
188
  * @returns {string|null} RUT o null
189
189
  */
@@ -1,174 +0,0 @@
1
- 'use strict';
2
- /**
3
- * test-qdetestlibro.js
4
- *
5
- * Prueba los endpoints del portal SII para consultar libros electrónicos:
6
- * 1. QEstLibro — lista todos los libros del año y extrae los Códigos
7
- * 2. QDetEstLibro — detalle de cada envío (TrackId, estado, etc.)
8
- *
9
- * Uso:
10
- * node test-qdetestlibro.js [year=2026] [periodo=2026-04]
11
- *
12
- * Requiere que la sesión exista o la crea automáticamente.
13
- */
14
-
15
- const path = require('path');
16
- const fs = require('fs');
17
- const SiiCertificacion = require('./SiiCertificacion.js');
18
-
19
- // ─── Configuración ────────────────────────────────────────────────────────────
20
- const PFX_PATH = path.resolve(__dirname, '../devlas-cloud-api-node/secret/19925444-8.pfx');
21
- const PFX_PASS = 'Lsr12345';
22
- const RUT_EMPRESA = '78206276';
23
- const DV_EMPRESA = 'K';
24
- const SESSION_PATH = path.resolve(__dirname, '../devlas-cloud-api-node/debug/cert-v2/session.json');
25
-
26
- const YEAR = process.argv.find(a => a.startsWith('year='))?.split('=')[1] || '2026';
27
- const PERIODO = process.argv.find(a => a.startsWith('periodo='))?.split('=')[1] || null; // null = todos
28
-
29
- // ─── Main ─────────────────────────────────────────────────────────────────────
30
- async function main() {
31
- console.log('=== test-qdetestlibro.js ===');
32
- console.log('PFX:', PFX_PATH);
33
- console.log('RUT:', `${RUT_EMPRESA}-${DV_EMPRESA}`);
34
- console.log('Year:', YEAR, '| Filtro período:', PERIODO || '(todos)');
35
-
36
- const cert = new SiiCertificacion({
37
- pfxPath: PFX_PATH,
38
- pfxPassword: PFX_PASS,
39
- rutEmpresa: RUT_EMPRESA,
40
- dvEmpresa: DV_EMPRESA,
41
- sessionPath: SESSION_PATH,
42
- });
43
-
44
- // ── 1. Asegurar sesión portal en subsistema /cgi_dte/UPL/ ─────────────────
45
- // DTEauth?7 es la página del formulario de búsqueda de libros.
46
- // CSESSIONID es el token de sesión del portal SII. Lo necesitamos válido.
47
- console.log('\n[1] Autenticando en /cgi_dte/UPL/DTEauth?7...');
48
- // Parchar request para capturar Set-Cookie crudos de DTEauth?7
49
- const origRequest = cert.session.request.bind(cert.session);
50
- let lastRawHeaders = null;
51
- cert.session.request = async function(url, opts) {
52
- const resp = await origRequest(url, opts);
53
- if (url.includes('DTEauth')) {
54
- lastRawHeaders = resp.headers;
55
- }
56
- return resp;
57
- };
58
- const authResp = await cert.session.ensureSession('/cgi_dte/UPL/DTEauth?7');
59
- cert.session.request = origRequest; // restaurar
60
- console.log(' Status DTEauth:', authResp?.status);
61
- if (lastRawHeaders) {
62
- const sc = lastRawHeaders['set-cookie'];
63
- console.log(' Set-Cookie DTEauth?7:', JSON.stringify(sc));
64
- }
65
- console.log(' CSESSIONID en jar:', cert.session.cookieJar?.match(/CSESSIONID=[^;]+/)?.[0] || '(none)');
66
- fs.writeFileSync(path.join(__dirname, 'test-output', 'dteauth7.html'), authResp?.body || '', 'latin1');
67
-
68
- // ── TEST DIRECTO: llamar QDetEstLibro sin QEstLibro de por medio ──────────
69
- console.log('\n[TEST DIRECTO] QDetEstLibro sin pasar por QEstLibro...');
70
- const urlDetDirect = `https://maullin.sii.cl/cgi_dte/UPL/QDetEstLibro` +
71
- `?Codigo=COMPRA-772220&rutC=78206276&dvC=K&periodo=2026-04`;
72
- const directResp = await cert.session.request(urlDetDirect);
73
- console.log(' Status:', directResp.status);
74
- const directBody = directResp.body;
75
- if (directBody.includes('SESION HA EXPIRADO')) {
76
- console.warn(' [WARN] TAMBIÉN falla sin QEstLibro — es el CSESSIONID o la sesión');
77
- } else if (directBody.includes('AUTORIZADO')) {
78
- console.warn(' [WARN] Error de autorización');
79
- console.log(directBody.slice(0, 500));
80
- } else {
81
- console.log(' OK! Funciona directamente');
82
- console.log(directBody.slice(0, 1000));
83
- }
84
- fs.writeFileSync(path.join(__dirname, 'test-output', 'qdetestlibro-direct.html'), directBody, 'latin1');
85
-
86
- // ── 2. Llamar QEstLibro ────────────────────────────────────────────────────
87
- const urlLista = `https://maullin.sii.cl/cgi_dte/UPL/QEstLibro` +
88
- `?rutCompany=${RUT_EMPRESA}&dvCompany=${DV_EMPRESA}&TrackId=&year=${YEAR}&month=00&tipo=TODOS`;
89
-
90
- console.log('\n[2] GET', urlLista);
91
- const listaResp = await cert.session.request(urlLista);
92
- console.log(' Status:', listaResp.status);
93
- console.log(' Set-Cookie headers:', listaResp.headers?.['set-cookie'] || '(none)');
94
- console.log(' Cookies post-QEstLibro:', cert.session.cookieJar?.slice(0, 250) + '...');
95
-
96
- if (listaResp.body.includes('SESION HA EXPIRADO')) {
97
- console.error(' [ERR] Sesión expirada en QEstLibro — revisar ensureSession');
98
- process.exit(1);
99
- }
100
-
101
- // Guardar HTML para inspección
102
- const htmlListaPath = path.join(__dirname, 'test-output', 'qestlibro.html');
103
- fs.mkdirSync(path.dirname(htmlListaPath), { recursive: true });
104
- fs.writeFileSync(htmlListaPath, listaResp.body, 'latin1');
105
- console.log(' HTML guardado en:', htmlListaPath);
106
-
107
- // ── 3. Parsear la tabla: extraer Código → periodo → operación ──────────────
108
- // El HTML tiene links tipo: QDetEstLibro?Codigo=VENTA-772219&rutC=...&periodo=2026-04
109
- // href sin comillas: href=QDetEstLibro?Codigo=X&rutC=Y&dvC=Z&periodo=PPPP>Ver
110
- // el > cierra el atributo, por eso lo excluimos del grupo de captura
111
- const linkRegex = /QDetEstLibro\?Codigo=([^&"'\s>]+)&rutC=[^&"'\s>]+&dvC=[^&"'\s>]+&periodo=([^&"'\s>]+)/gi;
112
- const codigos = {}; // { '2026-04': { VENTA: 'VENTA-772219', COMPRA: 'COMPRA-XXXXX' } }
113
- let m;
114
- while ((m = linkRegex.exec(listaResp.body)) !== null) {
115
- const codigo = m[1];
116
- const periodo = m[2];
117
- if (PERIODO && periodo !== PERIODO) continue;
118
- codigos[periodo] = codigos[periodo] || {};
119
- const tipoMatch = /^(VENTA|COMPRA|GUIAS?)/i.exec(codigo);
120
- const tipo = tipoMatch ? tipoMatch[1].toUpperCase() : codigo;
121
- codigos[periodo][tipo] = codigo;
122
- }
123
-
124
- console.log('\n Códigos encontrados:');
125
- if (Object.keys(codigos).length === 0) {
126
- console.log(' (ninguno — revisar HTML en test-output/qestlibro.html)');
127
- }
128
- for (const [p, ops] of Object.entries(codigos)) {
129
- for (const [op, cod] of Object.entries(ops)) {
130
- console.log(` ${p} / ${op} → ${cod}`);
131
- }
132
- }
133
-
134
- // ── 4. Llamar QDetEstLibro para cada código ────────────────────────────────
135
- // Usamos las MISMAS cookies que obtuvimos de DTEauth?7 (paso 1).
136
- // NO re-autenticamos: si DTEauth?7 usa tokens one-shot, un segundo call
137
- // lo consumiría sin beneficio. Las cookies NETSCAPE_LIVEWIRE persisten.
138
- for (const [periodo, ops] of Object.entries(codigos)) {
139
- for (const [operacion, codigo] of Object.entries(ops)) {
140
- const urlDet = `https://maullin.sii.cl/cgi_dte/UPL/QDetEstLibro` +
141
- `?Codigo=${encodeURIComponent(codigo)}&rutC=${RUT_EMPRESA}&dvC=${DV_EMPRESA}&periodo=${periodo}`;
142
-
143
- const refererQEstLibro = `https://maullin.sii.cl/cgi_dte/UPL/QEstLibro` +
144
- `?rutCompany=${RUT_EMPRESA}&dvCompany=${DV_EMPRESA}&TrackId=&year=${YEAR}&month=00&tipo=TODOS`;
145
-
146
- console.log(`\n[3] QDetEstLibro ${periodo} ${operacion} (${codigo})`);
147
- console.log(' GET', urlDet);
148
-
149
- const detResp = await cert.session.request(urlDet, {
150
- headers: { Referer: refererQEstLibro },
151
- });
152
- console.log(' Status:', detResp.status);
153
-
154
- const outPath = path.join(__dirname, 'test-output', `qdetestlibro-${periodo}-${operacion}.html`);
155
- fs.writeFileSync(outPath, detResp.body, 'latin1');
156
-
157
- if (detResp.body.includes('SESION HA EXPIRADO')) {
158
- console.warn(' [WARN] Sesión expirada — revisar cookies / DTEauth');
159
- console.log('\n--- BODY (500 chars) ---\n', detResp.body.slice(0, 500));
160
- } else {
161
- console.log(' HTML guardado en:', outPath);
162
- console.log('\n--- BODY ---\n', detResp.body);
163
- }
164
- }
165
- }
166
-
167
- console.log('\n=== Fin ===');
168
- }
169
-
170
- main().catch(e => {
171
- console.error('[FATAL]', e.message);
172
- console.error(e.stack);
173
- process.exit(1);
174
- });