@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 +7 -7
- package/EnviadorSII.js +1 -1
- package/FolioService.js +2 -2
- package/README.md +146 -25
- package/SiiPortalAuth.js +394 -32
- package/SiiSession.js +2 -2
- package/cert/CertRunner.js +10 -10
- package/cert/SetBase.js +1 -1
- package/cert/SetParser.js +15 -1
- package/index.js +1 -1
- package/package.json +2 -2
- package/utils/c14n.js +1 -1
- package/utils/pfx.js +1 -1
- package/test-qdetestlibro.js +0 -174
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:
|
|
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" (
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
327
|
-
RutEnvia: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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 = '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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**
|
|
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;
|
|
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
|
|
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(/ /gi, ' ')
|
|
73
|
+
.replace(/á/gi, 'á').replace(/é/gi, 'é').replace(/í/gi, 'í')
|
|
74
|
+
.replace(/ó/gi, 'ó').replace(/ú/gi, 'ú').replace(/ñ/gi, 'ñ')
|
|
75
|
+
.replace(/&/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
|
-
|
|
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
|
-
/**
|
|
392
|
-
|
|
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.
|
|
400
|
-
|
|
401
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
/**
|
|
434
|
-
|
|
435
|
-
|
|
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: "
|
|
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
|
|
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
|
|
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
|
package/cert/CertRunner.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devlas/dte-sii",
|
|
3
|
-
"version": "2.
|
|
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
|
|
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
|
|
186
|
+
* contra Acepta, IDOK y Signapis).
|
|
187
187
|
* @param {forge.pki.Certificate} cert - Certificado
|
|
188
188
|
* @returns {string|null} RUT o null
|
|
189
189
|
*/
|
package/test-qdetestlibro.js
DELETED
|
@@ -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
|
-
});
|