@devlas/dte-sii 2.12.26 → 2.13.1
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 +135 -15
- package/DTE.js +8 -2
- package/EnviadorSII.js +31 -11
- package/FolioRegistry.js +7 -2
- package/FolioService.js +62 -3
- package/README.md +62 -0
- package/SiiCertificacion.js +59 -10
- package/SiiPortalAuth.js +131 -6
- package/SiiSession.js +27 -9
- package/WsReclamo.js +7 -6
- package/cert/BoletaCert.js +29 -10
- package/cert/CertRunner.js +247 -33
- package/cert/ConfigLoader.js +4 -2
- package/cert/IntercambioCert.js +4 -1
- package/dte-sii.d.ts +48 -1
- package/index.js +2 -0
- package/package.json +1 -1
- package/utils/httpDebug.js +223 -0
- package/utils/index.js +2 -0
- package/utils/paths.js +152 -0
- package/utils/sanitize.js +48 -0
- package/utils/xml.js +4 -2
package/CafSolicitor.js
CHANGED
|
@@ -19,6 +19,7 @@ const SiiSession = require('./SiiSession');
|
|
|
19
19
|
const SiiPortalAuth = require('./SiiPortalAuth');
|
|
20
20
|
const SiiSessionStore = require('./SiiSessionStore');
|
|
21
21
|
const { splitRut } = require('./utils/rut');
|
|
22
|
+
const { resolveDataDir } = require('./utils/paths');
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Registro global de sesiones SII (singleton por ambiente+rut).
|
|
@@ -58,7 +59,9 @@ class CafSolicitor {
|
|
|
58
59
|
|
|
59
60
|
this.ambiente = options.ambiente.toLowerCase();
|
|
60
61
|
this.rutEmisor = options.rutEmisor;
|
|
61
|
-
|
|
62
|
+
// Fallback neutral: antes era `__dirname/../..`, que en una instalación normal
|
|
63
|
+
// apunta dentro de node_modules — se escribían CAFs dentro de node_modules.
|
|
64
|
+
this.baseDir = options.baseDir || resolveDataDir();
|
|
62
65
|
this.runStamp = options.runStamp || new Date().toISOString().replace(/[:.]/g, '-');
|
|
63
66
|
|
|
64
67
|
// pfxBuffer tiene prioridad sobre pfxPath para evitar I/O a disco
|
|
@@ -235,7 +238,7 @@ class CafSolicitor {
|
|
|
235
238
|
}
|
|
236
239
|
|
|
237
240
|
static esBloqueoTimbraje(html) {
|
|
238
|
-
return /NO\s+(SE\s+)?AUTORIZA\s+TIMBRAJE/i.test(
|
|
241
|
+
return /NO\s+(SE\s+)?AUTORIZA\s+TIMBRAJE/i.test(CafSolicitor.textoVisible(html, 4000));
|
|
239
242
|
}
|
|
240
243
|
|
|
241
244
|
/**
|
|
@@ -251,7 +254,8 @@ class CafSolicitor {
|
|
|
251
254
|
* no confirmado por el mensaje) de un fallo real desconocido.
|
|
252
255
|
*/
|
|
253
256
|
static esNoAutorizadoIngresarOpcion(html) {
|
|
254
|
-
return /no\s+est
|
|
257
|
+
return /no\s+est.{0,8}\s*autorizado\s+para\s+ingresar\s+a\s+esta\s+opci.{0,8}n/i
|
|
258
|
+
.test(CafSolicitor.textoVisible(html, 4000));
|
|
255
259
|
}
|
|
256
260
|
|
|
257
261
|
/**
|
|
@@ -264,9 +268,91 @@ class CafSolicitor {
|
|
|
264
268
|
* es reenviar los permisos del usuario (Paso 5 de la inscripción) EN EL MISMO
|
|
265
269
|
* AMBIENTE donde se está pidiendo el folio.
|
|
266
270
|
*/
|
|
271
|
+
/**
|
|
272
|
+
* El SII exige hacer el trámite PRESENCIALMENTE en la oficina.
|
|
273
|
+
*
|
|
274
|
+
* Sale al intentar inscribir a la empresa como facturador electrónico, incluso en el
|
|
275
|
+
* sistema gratuito: "deberá presentarse en la oficina del SII correspondiente a su
|
|
276
|
+
* domicilio y solicitar la asistencia de un funcionario". Suele deberse a observaciones
|
|
277
|
+
* en el RUT, domicilio sin verificar o anotaciones del contribuyente.
|
|
278
|
+
*
|
|
279
|
+
* Se detecta porque es un callejón sin salida para el software: mientras esté, la
|
|
280
|
+
* empresa no puede inscribirse, no queda autorizada en palena, no puede enrolar
|
|
281
|
+
* usuarios ni timbrar. Sin este mensaje el usuario ve un error genérico y vuelve a
|
|
282
|
+
* intentar sin saber que lo único que corresponde es ir al SII.
|
|
283
|
+
*
|
|
284
|
+
* Caso real (12/08/2026, RUT 78441936-3): Verificación de Actividades aprobada,
|
|
285
|
+
* usuario enrolado en maullin y certificación de boleta enviada — y aun así palena
|
|
286
|
+
* rechazaba todo, porque la inscripción por internet estaba bloqueada de origen.
|
|
287
|
+
*/
|
|
288
|
+
static esRequiereTramitePresencial(html) {
|
|
289
|
+
const t = CafSolicitor.textoVisible(html, 4000);
|
|
290
|
+
return /deber.{0,8}\s*presentarse\s+en\s+la\s+oficina\s+del\s+SII/i.test(t)
|
|
291
|
+
|| (/no\s+cumple\s+con\s+los\s+requisitos/i.test(t)
|
|
292
|
+
&& /asistencia\s+de\s+un\s+funcionario/i.test(t));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* "la empresa no está autorizada para operar en esta modalidad"
|
|
297
|
+
*
|
|
298
|
+
* Distinto de `esUsuarioSinPermiso`: ahí el sujeto es el USUARIO del certificado, acá
|
|
299
|
+
* es la EMPRESA. El SII lo devuelve en palena cuando el contribuyente todavía no fue
|
|
300
|
+
* autorizado como emisor electrónico en producción — no hay nada que arreglar del lado
|
|
301
|
+
* del enrolamiento, porque el portal ni siquiera deja abrir la mantención de usuarios.
|
|
302
|
+
*
|
|
303
|
+
* Caso real (12/08/2026, RUT 78441936-3): el flujo de enrolamiento siguió de largo con
|
|
304
|
+
* páginas vacías —sin formulario ni hidden `key`— hasta reventar con un 500 en
|
|
305
|
+
* `eu_graba_usuario`. El 500 era el síntoma; esta frase, en el PRIMER paso, la causa.
|
|
306
|
+
*/
|
|
307
|
+
static esEmpresaNoAutorizada(html) {
|
|
308
|
+
return /(la\s+)?empresa\s+no\s+est.{0,8}\s*autorizada\s+para\s+operar/i
|
|
309
|
+
.test(CafSolicitor.textoVisible(html, 4000));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* ¿Este HTML es un rechazo que hace inútil seguir el flujo?
|
|
314
|
+
*
|
|
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
|
|
317
|
+
* (`of_solicita_folios_dcto`), donde solo se miraba `esBloqueoTimbraje`. El flujo
|
|
318
|
+
* siguió de largo y terminó devolviendo el genérico `UNKNOWN: No se obtuvo CAF`,
|
|
319
|
+
* escondiendo un mensaje que la librería ya sabía interpretar desde julio.
|
|
320
|
+
*
|
|
321
|
+
* Por eso el chequeo es UNO SOLO y se aplica en TODOS los pasos: agregar un patrón
|
|
322
|
+
* nuevo no debe obligar a acordarse de replicarlo en cada punto del flujo.
|
|
323
|
+
*/
|
|
324
|
+
static esRechazoDuro(html) {
|
|
325
|
+
const h = String(html || '');
|
|
326
|
+
return CafSolicitor.esBloqueoTimbraje(h)
|
|
327
|
+
|| CafSolicitor.esRequiereTramitePresencial(h)
|
|
328
|
+
|| CafSolicitor.esEmpresaNoAutorizada(h)
|
|
329
|
+
|| CafSolicitor.esNoAutorizadoIngresarOpcion(h)
|
|
330
|
+
|| CafSolicitor.esUsuarioSinPermiso(h)
|
|
331
|
+
|| /no\s+registra\s+Verificaci.{0,8}n\s+de\s+Actividades/i.test(CafSolicitor.textoVisible(h, 4000));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Texto legible de una página del SII, sin markup.
|
|
336
|
+
*
|
|
337
|
+
* Para que un rechazo desconocido no se pierda: sin esto, el motivo real queda solo
|
|
338
|
+
* en el HTML —que en producción no se guarda— y el operador ve `UNKNOWN` sin más.
|
|
339
|
+
*/
|
|
340
|
+
static textoVisible(html, max = 400) {
|
|
341
|
+
return String(html || '')
|
|
342
|
+
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
343
|
+
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
344
|
+
.replace(/<[^>]+>/g, ' ')
|
|
345
|
+
.replace(/ /gi, ' ').replace(/á/gi, 'a').replace(/é/gi, 'e')
|
|
346
|
+
.replace(/í/gi, 'i').replace(/ó/gi, 'o').replace(/ú/gi, 'u')
|
|
347
|
+
.replace(/ñ/gi, 'n').replace(/&[a-z]+;/gi, ' ')
|
|
348
|
+
.replace(/\s+/g, ' ')
|
|
349
|
+
.trim()
|
|
350
|
+
.slice(0, max);
|
|
351
|
+
}
|
|
352
|
+
|
|
267
353
|
static esUsuarioSinPermiso(html) {
|
|
268
|
-
return /no\s+tiene\s+permiso\s+en\s+(la\s+)?empresa|usuario\s+no\s+autorizado\s+para\s+(la\s+)?empresa|no\s+est
|
|
269
|
-
.test(
|
|
354
|
+
return /no\s+tiene\s+permiso\s+en\s+(la\s+)?empresa|usuario\s+no\s+autorizado\s+para\s+(la\s+)?empresa|no\s+est.{0,8}\s*autorizado\s+para\s+operar\s+en\s+la\s+empresa/i
|
|
355
|
+
.test(CafSolicitor.textoVisible(html, 4000));
|
|
270
356
|
}
|
|
271
357
|
|
|
272
358
|
/**
|
|
@@ -280,7 +366,7 @@ class CafSolicitor {
|
|
|
280
366
|
* la simulación de certificación.
|
|
281
367
|
* @returns {Promise<Object>} - { success, cafPath, xml, maxAutor, foliosDisp, error, errorCode }
|
|
282
368
|
*/
|
|
283
|
-
async solicitar({ tipoDte, cantidad = 1, minCantidad = null }) {
|
|
369
|
+
async solicitar({ tipoDte, cantidad = 1, minCantidad = null, soloConsultarTope = false }) {
|
|
284
370
|
const { numero: rut, dv } = splitRut(this.rutEmisor);
|
|
285
371
|
const debugDir = this._getDebugDir(tipoDte);
|
|
286
372
|
|
|
@@ -289,6 +375,10 @@ class CafSolicitor {
|
|
|
289
375
|
this._lastFoliosDisp = null;
|
|
290
376
|
this._maxAutorInsuficiente = false;
|
|
291
377
|
this._minCantidad = minCantidad;
|
|
378
|
+
// Modo sondeo: recorre el flujo solo hasta el formulario que expone MAX_AUTOR y
|
|
379
|
+
// corta ahí, sin emitir nada. Permite saber si el SII está racionando este tipo
|
|
380
|
+
// ANTES de decidir si vale la pena anular folios.
|
|
381
|
+
this._soloConsultarTope = soloConsultarTope;
|
|
292
382
|
|
|
293
383
|
// Rate limiting: mínimo 1001ms entre solicitudes para no saturar el portal SII.
|
|
294
384
|
const _now = Date.now();
|
|
@@ -395,6 +485,22 @@ class CafSolicitor {
|
|
|
395
485
|
return { success: false, errorCode: 'TIMBRAJE_BLOQUEADO', error: 'SII: No se autoriza timbraje. Folios acumulados excesivos o situaciones tributarias pendientes. Revisa el portal SII → Factura Electrónica → Solicitud de Timbraje.' };
|
|
396
486
|
}
|
|
397
487
|
|
|
488
|
+
if (response.body && CafSolicitor.esRequiereTramitePresencial(response.body)) {
|
|
489
|
+
return {
|
|
490
|
+
success: false,
|
|
491
|
+
errorCode: 'REQUIERE_TRAMITE_PRESENCIAL',
|
|
492
|
+
error: 'El SII exige completar la inscripción en Factura Electrónica de forma presencial: el representante legal debe ir con su cédula a la oficina del SII del domicilio de la empresa. Hasta entonces no se pueden obtener folios. Suele deberse a observaciones en el RUT o al domicilio sin verificar.',
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (response.body && CafSolicitor.esEmpresaNoAutorizada(response.body)) {
|
|
497
|
+
return {
|
|
498
|
+
success: false,
|
|
499
|
+
errorCode: 'EMPRESA_NO_AUTORIZADA',
|
|
500
|
+
error: `SII (${this.ambiente}): la EMPRESA no está autorizada para operar como emisor electrónico en este ambiente. No es un problema del usuario ni de sus permisos: hasta que el SII autorice al contribuyente, el portal no permite ni enrolar usuarios ni solicitar folios.`,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
398
504
|
if (response.body && CafSolicitor.esNoAutorizadoIngresarOpcion(response.body)) {
|
|
399
505
|
return {
|
|
400
506
|
success: false,
|
|
@@ -517,7 +623,14 @@ class CafSolicitor {
|
|
|
517
623
|
return { success: false, errorCode: 'SESSION_EXPIRED', error: 'Sesión SII inválida — registro limpiado, el próximo intento reautenticará.' };
|
|
518
624
|
}
|
|
519
625
|
|
|
520
|
-
|
|
626
|
+
// Se adjunta lo que dijo el SII: un `UNKNOWN` sin el motivo obliga a reproducir
|
|
627
|
+
// el fallo con el debug encendido, que es justo lo que no se puede hacer en
|
|
628
|
+
// producción cuando el problema es de un cliente concreto.
|
|
629
|
+
return {
|
|
630
|
+
success: false,
|
|
631
|
+
errorCode: 'UNKNOWN',
|
|
632
|
+
error: `No se obtuvo CAF en la respuesta. El SII respondió: "${CafSolicitor.textoVisible(response.body)}"`,
|
|
633
|
+
};
|
|
521
634
|
|
|
522
635
|
} catch (err) {
|
|
523
636
|
const msg = err.message || '';
|
|
@@ -550,8 +663,8 @@ class CafSolicitor {
|
|
|
550
663
|
|
|
551
664
|
// Defensivo: mismo rechazo por Verificación de Actividades puede aparecer si el SII
|
|
552
665
|
// lo entrega recién en un paso posterior en vez del response inicial de solicitar().
|
|
553
|
-
if (
|
|
554
|
-
return response; // solicitar()
|
|
666
|
+
if (CafSolicitor.esRechazoDuro(currentHtml)) {
|
|
667
|
+
return response; // solicitar() traduce el motivo desde response.body
|
|
555
668
|
}
|
|
556
669
|
|
|
557
670
|
const realFormAction = SiiSession.extractFormAction(currentHtml);
|
|
@@ -585,8 +698,8 @@ class CafSolicitor {
|
|
|
585
698
|
|
|
586
699
|
// Rechazo duro antes del check de COD_DOCTO: la página de rechazo también contiene
|
|
587
700
|
// "COD_DOCTO" en su JavaScript, lo que causaría un POST innecesario con datos vacíos.
|
|
588
|
-
if (CafSolicitor.
|
|
589
|
-
return response; // solicitar()
|
|
701
|
+
if (CafSolicitor.esRechazoDuro(currentHtml)) {
|
|
702
|
+
return response; // solicitar() traduce el motivo desde response.body
|
|
590
703
|
}
|
|
591
704
|
|
|
592
705
|
// Selección de tipo de documento
|
|
@@ -608,8 +721,8 @@ class CafSolicitor {
|
|
|
608
721
|
currentHtml = response.body || '';
|
|
609
722
|
this._saveDebug(debugDir, 'select.html', currentHtml);
|
|
610
723
|
|
|
611
|
-
if (CafSolicitor.
|
|
612
|
-
return response; // solicitar()
|
|
724
|
+
if (CafSolicitor.esRechazoDuro(currentHtml)) {
|
|
725
|
+
return response; // solicitar() traduce el motivo desde response.body
|
|
613
726
|
}
|
|
614
727
|
}
|
|
615
728
|
|
|
@@ -627,8 +740,8 @@ class CafSolicitor {
|
|
|
627
740
|
async _processStep3(response, rut, dv, tipoDte, cantidad, debugDir) {
|
|
628
741
|
let currentHtml = response.body || '';
|
|
629
742
|
|
|
630
|
-
if (
|
|
631
|
-
return response; // solicitar()
|
|
743
|
+
if (CafSolicitor.esRechazoDuro(currentHtml)) {
|
|
744
|
+
return response; // solicitar() traduce el motivo desde response.body
|
|
632
745
|
}
|
|
633
746
|
|
|
634
747
|
const formAction3 = SiiSession.extractFormAction(currentHtml) || '/cvc_cgi/dte/of_confirma_folio';
|
|
@@ -654,6 +767,13 @@ class CafSolicitor {
|
|
|
654
767
|
// quien necesita N folios exactos (ej. simulación de certificación): el CAF chico
|
|
655
768
|
// no se usa, suma +1 a FOLIOS_DISP y empeora el tope en el próximo intento.
|
|
656
769
|
// Por eso, si el llamador declaró un mínimo, se aborta ANTES de emitir.
|
|
770
|
+
if (this._soloConsultarTope) {
|
|
771
|
+
// Se leyó lo único que interesaba (MAX_AUTOR/FOLIOS_DISP). Cortar acá es lo que
|
|
772
|
+
// hace barato el sondeo y, sobre todo, lo deja sin efectos: no se confirma folio,
|
|
773
|
+
// no se emite CAF, no sube FOLIOS_DISP.
|
|
774
|
+
return response;
|
|
775
|
+
}
|
|
776
|
+
|
|
657
777
|
if (tieneMaxAutor && this._minCantidad && maxAutor < this._minCantidad) {
|
|
658
778
|
this._maxAutorInsuficiente = true;
|
|
659
779
|
console.warn(
|
package/DTE.js
CHANGED
|
@@ -11,6 +11,7 @@ const { XMLBuilder } = require('fast-xml-parser');
|
|
|
11
11
|
const { DOMParser } = require('@xmldom/xmldom');
|
|
12
12
|
const {
|
|
13
13
|
sanitizeSiiText,
|
|
14
|
+
sanitizeTedText,
|
|
14
15
|
formatBase64InXml,
|
|
15
16
|
normalizeEmisor,
|
|
16
17
|
normalizeReceptor,
|
|
@@ -264,8 +265,13 @@ class DTE {
|
|
|
264
265
|
|
|
265
266
|
this.tmstFirma = timestampOverride || new Date().toISOString().replace(/\.\d{3}Z$/, '');
|
|
266
267
|
|
|
267
|
-
|
|
268
|
-
|
|
268
|
+
// sanitizeTedText (no sanitizeSiiText): pliega los acentos a ASCII porque el lector
|
|
269
|
+
// de PDF417 del SII no devuelve los bytes ≥128 tal como se codificaron, y como el TED
|
|
270
|
+
// va firmado, el SII valida la firma sobre lo que él leyó → "TED - Firma invalida".
|
|
271
|
+
// Se aplica ANTES de firmar, así la firma cubre exactamente los bytes del barcode.
|
|
272
|
+
// El DTE conserva el texto real con acentos; esto solo afecta al timbre.
|
|
273
|
+
const rznRecepRaw = sanitizeTedText((enc.Receptor.RznSocRecep || '').substring(0, 40));
|
|
274
|
+
const it1Raw = sanitizeTedText((primerItem.NmbItem || 'Producto').substring(0, 40));
|
|
269
275
|
const rznRecepXml = this._escapeXmlText(rznRecepRaw);
|
|
270
276
|
const it1Xml = this._escapeXmlText(it1Raw);
|
|
271
277
|
|
package/EnviadorSII.js
CHANGED
|
@@ -16,6 +16,7 @@ const https = require('https');
|
|
|
16
16
|
const crypto = require('crypto');
|
|
17
17
|
const forge = require('node-forge');
|
|
18
18
|
const FormData = require('form-data');
|
|
19
|
+
const { registrarHttpDebug, fetchRegistrado } = require('./utils/httpDebug');
|
|
19
20
|
const {
|
|
20
21
|
saveEnvioArtifacts,
|
|
21
22
|
siiError,
|
|
@@ -40,6 +41,9 @@ const {
|
|
|
40
41
|
|
|
41
42
|
const log = createScopedLogger('EnviadorSII');
|
|
42
43
|
|
|
44
|
+
/** `fetchRegistrado` con el cliente ya fijado, para no repetirlo en cada llamada. */
|
|
45
|
+
const fetchRegistradoSII = (url, opciones) => fetchRegistrado(url, opciones, 'EnviadorSII');
|
|
46
|
+
|
|
43
47
|
class EnviadorSII {
|
|
44
48
|
/**
|
|
45
49
|
* @param {Object} certificado - Instancia de Certificado (OBLIGATORIO)
|
|
@@ -113,11 +117,16 @@ class EnviadorSII {
|
|
|
113
117
|
crypto.constants.SSL_OP_ALLOW_UNSAFE_LEGACY_RENEGOTIATION |
|
|
114
118
|
crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
|
|
115
119
|
};
|
|
120
|
+
const _t0 = Date.now();
|
|
116
121
|
const req = https.request(opts, (res) => {
|
|
117
122
|
const chunks = [];
|
|
118
123
|
res.on('data', (c) => chunks.push(c));
|
|
119
124
|
res.on('end', () => {
|
|
120
125
|
const text = Buffer.concat(chunks).toString('latin1');
|
|
126
|
+
registrarHttpDebug({
|
|
127
|
+
url: urlStr, method: 'POST', status: res.statusCode, headers: res.headers,
|
|
128
|
+
body: text, reqBody: buf.toString('latin1'), ms: Date.now() - _t0, cliente: 'EnviadorSII',
|
|
129
|
+
});
|
|
121
130
|
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, text });
|
|
122
131
|
});
|
|
123
132
|
});
|
|
@@ -138,7 +147,7 @@ class EnviadorSII {
|
|
|
138
147
|
async getSemilla() {
|
|
139
148
|
const url = this.urls[this.ambiente].semilla;
|
|
140
149
|
|
|
141
|
-
const response = await
|
|
150
|
+
const { response, text: xml } = await fetchRegistradoSII(url, {
|
|
142
151
|
method: 'GET',
|
|
143
152
|
headers: {
|
|
144
153
|
'Accept': 'application/xml',
|
|
@@ -148,8 +157,7 @@ class EnviadorSII {
|
|
|
148
157
|
if (!response.ok) {
|
|
149
158
|
throw new Error(`Error obteniendo semilla: ${response.status}`);
|
|
150
159
|
}
|
|
151
|
-
|
|
152
|
-
const xml = await response.text();
|
|
160
|
+
|
|
153
161
|
// Usar parser centralizado
|
|
154
162
|
const data = parseXml(xml);
|
|
155
163
|
|
|
@@ -184,7 +192,7 @@ class EnviadorSII {
|
|
|
184
192
|
|
|
185
193
|
const url = this.urls[this.ambiente].token;
|
|
186
194
|
|
|
187
|
-
const response = await
|
|
195
|
+
const { response, text: xml } = await fetchRegistradoSII(url, {
|
|
188
196
|
method: 'POST',
|
|
189
197
|
headers: {
|
|
190
198
|
'Content-Type': 'application/xml',
|
|
@@ -194,11 +202,9 @@ class EnviadorSII {
|
|
|
194
202
|
});
|
|
195
203
|
|
|
196
204
|
if (!response.ok) {
|
|
197
|
-
|
|
198
|
-
throw new Error(`Error obteniendo token: ${response.status} - ${errorText}`);
|
|
205
|
+
throw new Error(`Error obteniendo token: ${response.status} - ${xml}`);
|
|
199
206
|
}
|
|
200
|
-
|
|
201
|
-
const xml = await response.text();
|
|
207
|
+
|
|
202
208
|
// Usar parser centralizado con namespaces removidos
|
|
203
209
|
const data = parseXmlNoNs(xml);
|
|
204
210
|
|
|
@@ -503,9 +509,17 @@ class EnviadorSII {
|
|
|
503
509
|
},
|
|
504
510
|
}, (res) => {
|
|
505
511
|
let data = '';
|
|
512
|
+
const _t0 = Date.now();
|
|
506
513
|
res.on('data', chunk => data += chunk);
|
|
507
514
|
res.on('end', () => {
|
|
508
515
|
log.log('HTTP Status:', res.statusCode);
|
|
516
|
+
// Es LA llamada que sube el DTE/BOLETA al SII. Se registra el multipart completo
|
|
517
|
+
// (incluye el XML enviado) porque cuando el SII rechaza, el motivo está ahí.
|
|
518
|
+
registrarHttpDebug({
|
|
519
|
+
url, method: 'POST', status: res.statusCode, headers: res.headers,
|
|
520
|
+
body: data, reqBody: formBuffer.toString('latin1'),
|
|
521
|
+
ms: Date.now() - _t0, cliente: 'EnviadorSII',
|
|
522
|
+
});
|
|
509
523
|
resolve({ text: data, status: res.statusCode });
|
|
510
524
|
});
|
|
511
525
|
});
|
|
@@ -586,9 +600,17 @@ class EnviadorSII {
|
|
|
586
600
|
},
|
|
587
601
|
}, (res) => {
|
|
588
602
|
let data = '';
|
|
603
|
+
const _t0 = Date.now();
|
|
589
604
|
res.on('data', chunk => data += chunk);
|
|
590
605
|
res.on('end', () => {
|
|
591
606
|
log.log('HTTP Status:', res.statusCode);
|
|
607
|
+
// Es LA llamada que sube el DTE/BOLETA al SII. Se registra el multipart completo
|
|
608
|
+
// (incluye el XML enviado) porque cuando el SII rechaza, el motivo está ahí.
|
|
609
|
+
registrarHttpDebug({
|
|
610
|
+
url, method: 'POST', status: res.statusCode, headers: res.headers,
|
|
611
|
+
body: data, reqBody: formBuffer.toString('latin1'),
|
|
612
|
+
ms: Date.now() - _t0, cliente: 'EnviadorSII',
|
|
613
|
+
});
|
|
592
614
|
resolve({ text: data, status: res.statusCode });
|
|
593
615
|
});
|
|
594
616
|
});
|
|
@@ -700,7 +722,7 @@ class EnviadorSII {
|
|
|
700
722
|
|
|
701
723
|
log.log('Consultando estado:', url);
|
|
702
724
|
|
|
703
|
-
const response = await
|
|
725
|
+
const { response, text: responseText } = await fetchRegistradoSII(url, {
|
|
704
726
|
method: 'GET',
|
|
705
727
|
headers: {
|
|
706
728
|
'Cookie': `TOKEN=${this.token}`,
|
|
@@ -708,8 +730,6 @@ class EnviadorSII {
|
|
|
708
730
|
'User-Agent': 'Mozilla/4.0 ( compatible; PROG 1.0; Windows NT)',
|
|
709
731
|
},
|
|
710
732
|
});
|
|
711
|
-
|
|
712
|
-
const responseText = await response.text();
|
|
713
733
|
log.log('Respuesta estado:', responseText);
|
|
714
734
|
|
|
715
735
|
if (!response.ok) {
|
package/FolioRegistry.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
const fs = require('fs');
|
|
13
13
|
const path = require('path');
|
|
14
14
|
const crypto = require('crypto');
|
|
15
|
+
const { resolveDataDir } = require('./utils/paths');
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Clase para manejar el registro de folios
|
|
@@ -28,7 +29,11 @@ class FolioRegistry {
|
|
|
28
29
|
} else if (options.baseDir) {
|
|
29
30
|
this.registryPath = path.join(options.baseDir, 'debug', 'folios.json');
|
|
30
31
|
} else {
|
|
31
|
-
|
|
32
|
+
// folios.json es estado FUNCIONAL (control de folios usados), no diagnóstico:
|
|
33
|
+
// si se pierde o se escribe en un lugar impredecible se pueden repetir folios
|
|
34
|
+
// ante el SII. Por eso el fallback es resolveDataDir() y no una ruta relativa
|
|
35
|
+
// a node_modules, que era donde caía `__dirname/../..`.
|
|
36
|
+
this.registryPath = path.join(resolveDataDir(), 'debug', 'folios.json');
|
|
32
37
|
}
|
|
33
38
|
}
|
|
34
39
|
|
|
@@ -414,7 +419,7 @@ class FolioRegistry {
|
|
|
414
419
|
throw new Error(`findLatestCaf: ambiente inválido "${ambiente}"`);
|
|
415
420
|
}
|
|
416
421
|
|
|
417
|
-
const base = baseDir ||
|
|
422
|
+
const base = baseDir || resolveDataDir();
|
|
418
423
|
|
|
419
424
|
// Buscar en estructura organizada: debug/caf/<ambiente>/<rut>/<tipoDte>/<fecha>/
|
|
420
425
|
const rutClean = rutEmisor.replace(/\./g, '').toUpperCase();
|
package/FolioService.js
CHANGED
|
@@ -15,6 +15,7 @@ const SiiSession = require('./SiiSession');
|
|
|
15
15
|
const FolioRegistry = require('./FolioRegistry');
|
|
16
16
|
const CAF = require('./CAF');
|
|
17
17
|
const CafSolicitor = require('./CafSolicitor');
|
|
18
|
+
const { resolveDataDir } = require('./utils/paths');
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Clase para gestión integral de folios
|
|
@@ -51,11 +52,28 @@ class FolioService {
|
|
|
51
52
|
|
|
52
53
|
this.ambiente = options.ambiente;
|
|
53
54
|
this.rutEmisor = options.rutEmisor;
|
|
54
|
-
|
|
55
|
+
// Fallback neutral: antes era `__dirname/../..`, que en una instalación normal
|
|
56
|
+
// apunta dentro de node_modules — se escribían artefactos dentro de la propia
|
|
57
|
+
// carpeta de dependencias.
|
|
58
|
+
this.baseDir = options.baseDir || resolveDataDir();
|
|
55
59
|
|
|
56
60
|
// Directorios
|
|
57
61
|
this.cafDir = options.cafDir || path.join(this.baseDir, 'debug', 'auto-caf');
|
|
58
62
|
this.debugDir = options.debugDir || path.join(this.baseDir, 'debug');
|
|
63
|
+
/**
|
|
64
|
+
* Estado que debe SOBREVIVIR a la corrida, separado de `debugDir`.
|
|
65
|
+
*
|
|
66
|
+
* Acá vive el registro de folios que el SII ya reportó como anulados
|
|
67
|
+
* (`folios-anulados-{rut}-{tipo}.json`). Guardarlo en `debugDir` — que es por
|
|
68
|
+
* corrida — lo volvía inútil: nacía vacío en cada ejecución y se volvían a
|
|
69
|
+
* intentar anular los mismos folios una y otra vez.
|
|
70
|
+
*
|
|
71
|
+
* Medido el 11/08/2026 en una empresa con muchas pruebas encima: 87 intentos de
|
|
72
|
+
* anulación en una sola etapa, **0 anulados y 87 rechazados por "ya anulado"**.
|
|
73
|
+
* Como el camino bulk falla y cae a folio-a-folio, cada intento cuesta 2 requests
|
|
74
|
+
* al SII: varios minutos de la corrida gastados en no hacer nada.
|
|
75
|
+
*/
|
|
76
|
+
this.stateDir = options.stateDir || this.debugDir;
|
|
59
77
|
|
|
60
78
|
// Sesión SII — priorizar reutilización para evitar bans del SII
|
|
61
79
|
// Orden: (1) sesión explícita, (2) registro de CafSolicitor, (3) nueva sesión
|
|
@@ -289,6 +307,31 @@ class FolioService {
|
|
|
289
307
|
* usar para destrabar el tope. Ponerlo en false lo deja en modo consulta.
|
|
290
308
|
* @returns {Promise<Object>} { ok, cafPath, otorgados, maxAutor, foliosDisp, errorCode, error }
|
|
291
309
|
*/
|
|
310
|
+
/**
|
|
311
|
+
* Consulta cuánto autoriza el SII para un tipo, SIN emitir nada.
|
|
312
|
+
*
|
|
313
|
+
* Recorre el flujo de timbraje solo hasta el formulario que expone MAX_AUTOR y
|
|
314
|
+
* FOLIOS_DISP, y corta ahí. Sirve para decidir si hace falta anular folios antes de
|
|
315
|
+
* pedir, en vez de anular siempre por las dudas.
|
|
316
|
+
*
|
|
317
|
+
* ⚠️ Ausencia de MAX_AUTOR no es cero: el SII **solo publica esos campos cuando está
|
|
318
|
+
* racionando** ese tipo de documento (verificado 2026-07-22: tras anular folios, los
|
|
319
|
+
* tipos 56 y 61 dejaron de exponerlos). Por eso se devuelve `sinTope: true`, que
|
|
320
|
+
* significa "el SII no está limitando", no "no se pudo leer".
|
|
321
|
+
*
|
|
322
|
+
* @returns {Promise<{ sinTope: boolean, maxAutor: number|null, foliosDisp: number|null }>}
|
|
323
|
+
*/
|
|
324
|
+
async consultarTope({ tipoDte }) {
|
|
325
|
+
if (!this.cafSolicitor) {
|
|
326
|
+
throw new Error('FolioService: CafSolicitor no inicializado (se requiere pfxPath y pfxPassword)');
|
|
327
|
+
}
|
|
328
|
+
await this.cafSolicitor.solicitar({ tipoDte, cantidad: 1, soloConsultarTope: true });
|
|
329
|
+
const maxAutor = this.cafSolicitor._lastMaxAutor ?? null;
|
|
330
|
+
const foliosDisp = this.cafSolicitor._lastFoliosDisp ?? null;
|
|
331
|
+
// `_lastFoliosDisp` queda en null justamente cuando el SII no publicó los campos.
|
|
332
|
+
return { sinTope: foliosDisp === null, maxAutor, foliosDisp };
|
|
333
|
+
}
|
|
334
|
+
|
|
292
335
|
async solicitarCafExacto({ tipoDte, cantidad, permitirAnular = true }) {
|
|
293
336
|
if (!this.cafSolicitor) {
|
|
294
337
|
throw new Error('FolioService: CafSolicitor no inicializado (se requiere pfxPath y pfxPassword)');
|
|
@@ -525,7 +568,7 @@ class FolioService {
|
|
|
525
568
|
*/
|
|
526
569
|
_anuladosPath(tipoDte) {
|
|
527
570
|
const rutLimpio = String(this.rutEmisor || '').replace(/[^0-9kK]/g, '');
|
|
528
|
-
return path.join(this.
|
|
571
|
+
return path.join(this.stateDir, `folios-anulados-${rutLimpio}-${tipoDte}.json`);
|
|
529
572
|
}
|
|
530
573
|
|
|
531
574
|
/** Set de claves "desde-hasta" que el SII ya reportó como anuladas. */
|
|
@@ -543,7 +586,7 @@ class FolioService {
|
|
|
543
586
|
|
|
544
587
|
_guardarAnulados(tipoDte, set) {
|
|
545
588
|
try {
|
|
546
|
-
fs.mkdirSync(this.
|
|
589
|
+
fs.mkdirSync(this.stateDir, { recursive: true });
|
|
547
590
|
fs.writeFileSync(this._anuladosPath(tipoDte), JSON.stringify([...set]), 'utf8');
|
|
548
591
|
} catch (err) {
|
|
549
592
|
console.warn(`[FolioService] No se pudo persistir registro de anulados: ${err.message}`);
|
|
@@ -695,6 +738,11 @@ class FolioService {
|
|
|
695
738
|
if (!yaConflicto) {
|
|
696
739
|
const razon = this._parseAnulacionResult(bodyBulk).reason || 'error';
|
|
697
740
|
rechazados.push({ folioDesde: iniA, folioHasta: finA, count, reason: razon });
|
|
741
|
+
// Mismo criterio que en el camino folio-a-folio: solo se recuerdan los
|
|
742
|
+
// rechazos definitivos, no los transitorios.
|
|
743
|
+
if (razon === 'ya-anulado' || razon === 'recepcionado') {
|
|
744
|
+
yaAnulados.add(`${iniA}-${finA}`);
|
|
745
|
+
}
|
|
698
746
|
console.warn(`[FolioService] ✗ Rango ${iniA}-${finA} rechazado: ${razon}`);
|
|
699
747
|
continue;
|
|
700
748
|
}
|
|
@@ -724,6 +772,17 @@ class FolioService {
|
|
|
724
772
|
} else {
|
|
725
773
|
const razon = this._parseAnulacionResult(bs).reason || 'error';
|
|
726
774
|
rechazados.push({ folioDesde: folio, folioHasta: folio, count: 1, reason: razon });
|
|
775
|
+
// Un folio ya anulado, o ya recepcionado en un DTE enviado, NO va a volver
|
|
776
|
+
// a ser anulable nunca. Recordarlo es lo único que evita reintentarlo en
|
|
777
|
+
// cada corrida. Faltaba justo acá, en el camino folio-a-folio, que es donde
|
|
778
|
+
// caen casi todos los rechazos porque el bulk conflictúa siempre: en una
|
|
779
|
+
// etapa medida el 11/08/2026 se registró 1 rango de 33 rechazos, y los 32
|
|
780
|
+
// restantes se reintentaron desde cero en la corrida siguiente.
|
|
781
|
+
// `error-red`, `error-sii-500` y `desconocido` quedan fuera a propósito:
|
|
782
|
+
// son transitorios y sí conviene reintentarlos.
|
|
783
|
+
if (razon === 'ya-anulado' || razon === 'recepcionado') {
|
|
784
|
+
yaAnulados.add(`${folio}-${folio}`);
|
|
785
|
+
}
|
|
727
786
|
}
|
|
728
787
|
}
|
|
729
788
|
console.log('');
|
package/README.md
CHANGED
|
@@ -35,6 +35,8 @@ npm install @devlas/dte-sii
|
|
|
35
35
|
- [Referencia de clases](#referencia-de-clases)
|
|
36
36
|
- [Estructura de archivos](#estructura-de-archivos)
|
|
37
37
|
- [Certificación SII](#certificación-sii)
|
|
38
|
+
- [Depuración: captura de llamadas al SII](#depuración-captura-de-llamadas-al-sii)
|
|
39
|
+
- [Ambientes](#ambientes)
|
|
38
40
|
- [Licencia](#licencia)
|
|
39
41
|
|
|
40
42
|
---
|
|
@@ -884,6 +886,45 @@ const { CertFolioHelper } = require('@devlas/dte-sii')
|
|
|
884
886
|
|
|
885
887
|
---
|
|
886
888
|
|
|
889
|
+
## Depuración: captura de llamadas al SII
|
|
890
|
+
|
|
891
|
+
Cuando el SII rechaza algo, el motivo viene en el HTML o el XML que devuelve, y sin ese
|
|
892
|
+
cuerpo guardado no hay forma de saber qué pasó. `utils/httpDebug.js` graba **todas** las
|
|
893
|
+
llamadas HTTP de la librería.
|
|
894
|
+
|
|
895
|
+
Está **apagado por defecto**: solo actúa si defines `SII_HTTP_DEBUG_DIR`. Sin esa variable
|
|
896
|
+
el costo es una comparación por request, así que se puede dejar el código como está en
|
|
897
|
+
producción.
|
|
898
|
+
|
|
899
|
+
```bash
|
|
900
|
+
SII_HTTP_DEBUG_DIR=/tmp/sii-debug node tu-script.js
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
Deja en ese directorio:
|
|
904
|
+
|
|
905
|
+
```
|
|
906
|
+
001-POST-DTEUpload-200.html <- respuesta (cabecera con URL, status, ms, cliente)
|
|
907
|
+
001-POST-DTEUpload-200-request.txt <- cuerpo enviado, si supera 2000 caracteres
|
|
908
|
+
index.jsonl <- una línea por llamada, para grep/jq
|
|
909
|
+
```
|
|
910
|
+
|
|
911
|
+
```bash
|
|
912
|
+
# ¿Qué llamadas hizo y cuánto tardó cada una?
|
|
913
|
+
jq -r '"\(.n) \(.method) \(.url) -> \(.status) \(.ms)ms [\(.cliente)]"' /tmp/sii-debug/index.jsonl
|
|
914
|
+
```
|
|
915
|
+
|
|
916
|
+
Cubre los cinco clientes HTTP de la librería, que son independientes entre sí: `SiiSession`,
|
|
917
|
+
`SiiPortalAuth`, `EnviadorSII`, `CertRunner`/`BoletaCert` y `WsReclamo`.
|
|
918
|
+
|
|
919
|
+
**Se redactan** `set-cookie`, `cookie`, `authorization` y `<RSASK>` (la llave privada RSA del
|
|
920
|
+
CAF). Aun así, el resto del contenido son documentos tributarios: trata ese directorio como
|
|
921
|
+
material sensible y púrgalo.
|
|
922
|
+
|
|
923
|
+
Para dirigir la captura por etapa, redefine la variable antes de cada bloque: se lee en cada
|
|
924
|
+
llamada, no una sola vez al cargar el módulo.
|
|
925
|
+
|
|
926
|
+
---
|
|
927
|
+
|
|
887
928
|
## Ambientes
|
|
888
929
|
|
|
889
930
|
| Ambiente | Constante | Descripción |
|
|
@@ -893,6 +934,27 @@ const { CertFolioHelper } = require('@devlas/dte-sii')
|
|
|
893
934
|
|
|
894
935
|
> Siempre verifica la variable de entorno `SII_AMBIENTE` (o el parámetro `ambiente`) antes de ejecutar código DTE para evitar envíos accidentales a producción.
|
|
895
936
|
|
|
937
|
+
### `TZ` es obligatoria
|
|
938
|
+
|
|
939
|
+
Define `TZ=America/Santiago` en el proceso que use esta librería.
|
|
940
|
+
|
|
941
|
+
`CertRunner` construye fechas con la hora local al declarar avance y libros. Con el proceso
|
|
942
|
+
en UTC, entre las ~20:00 y medianoche de Chile genera la fecha del **día siguiente**: un
|
|
943
|
+
envío registrado el día 11 se declara como del 12 y el SII responde *"FECHA NO CORRESPONDE
|
|
944
|
+
AL ENVIO"*, dejando el flujo esperando algo que nunca va a llegar.
|
|
945
|
+
|
|
946
|
+
> Para comprobarlo no sirve `date`: dentro de un contenedor sin `tzdata` **miente**. Usa
|
|
947
|
+
> `node -e "console.log(new Date().toString())"`.
|
|
948
|
+
|
|
949
|
+
### Acentos en el timbre (TED)
|
|
950
|
+
|
|
951
|
+
El lector de PDF417 del SII pierde los bytes ≥ 128: `Cajón` llega como `Cajnn` y el timbre no
|
|
952
|
+
valida. Por eso `DTE` normaliza a ASCII `RznSocRecep` y `NmbItem` **solo dentro del TED**,
|
|
953
|
+
antes de firmarlo.
|
|
954
|
+
|
|
955
|
+
El cuerpo del DTE conserva las tildes: el documento impreso y el XML que recibe el receptor
|
|
956
|
+
se ven correctos. Si generas el PDF417 por tu cuenta, respeta esa misma regla.
|
|
957
|
+
|
|
896
958
|
---
|
|
897
959
|
|
|
898
960
|
## Licencia
|