@devlas/dte-sii 2.12.26 → 2.13.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 +16 -2
- 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
|
|
@@ -280,7 +283,7 @@ class CafSolicitor {
|
|
|
280
283
|
* la simulación de certificación.
|
|
281
284
|
* @returns {Promise<Object>} - { success, cafPath, xml, maxAutor, foliosDisp, error, errorCode }
|
|
282
285
|
*/
|
|
283
|
-
async solicitar({ tipoDte, cantidad = 1, minCantidad = null }) {
|
|
286
|
+
async solicitar({ tipoDte, cantidad = 1, minCantidad = null, soloConsultarTope = false }) {
|
|
284
287
|
const { numero: rut, dv } = splitRut(this.rutEmisor);
|
|
285
288
|
const debugDir = this._getDebugDir(tipoDte);
|
|
286
289
|
|
|
@@ -289,6 +292,10 @@ class CafSolicitor {
|
|
|
289
292
|
this._lastFoliosDisp = null;
|
|
290
293
|
this._maxAutorInsuficiente = false;
|
|
291
294
|
this._minCantidad = minCantidad;
|
|
295
|
+
// Modo sondeo: recorre el flujo solo hasta el formulario que expone MAX_AUTOR y
|
|
296
|
+
// corta ahí, sin emitir nada. Permite saber si el SII está racionando este tipo
|
|
297
|
+
// ANTES de decidir si vale la pena anular folios.
|
|
298
|
+
this._soloConsultarTope = soloConsultarTope;
|
|
292
299
|
|
|
293
300
|
// Rate limiting: mínimo 1001ms entre solicitudes para no saturar el portal SII.
|
|
294
301
|
const _now = Date.now();
|
|
@@ -654,6 +661,13 @@ class CafSolicitor {
|
|
|
654
661
|
// quien necesita N folios exactos (ej. simulación de certificación): el CAF chico
|
|
655
662
|
// no se usa, suma +1 a FOLIOS_DISP y empeora el tope en el próximo intento.
|
|
656
663
|
// Por eso, si el llamador declaró un mínimo, se aborta ANTES de emitir.
|
|
664
|
+
if (this._soloConsultarTope) {
|
|
665
|
+
// Se leyó lo único que interesaba (MAX_AUTOR/FOLIOS_DISP). Cortar acá es lo que
|
|
666
|
+
// hace barato el sondeo y, sobre todo, lo deja sin efectos: no se confirma folio,
|
|
667
|
+
// no se emite CAF, no sube FOLIOS_DISP.
|
|
668
|
+
return response;
|
|
669
|
+
}
|
|
670
|
+
|
|
657
671
|
if (tieneMaxAutor && this._minCantidad && maxAutor < this._minCantidad) {
|
|
658
672
|
this._maxAutorInsuficiente = true;
|
|
659
673
|
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
|
package/SiiCertificacion.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
const SiiSession = require('./SiiSession.js');
|
|
19
19
|
const { STEPS, emitProgress } = require('./utils/progress');
|
|
20
|
+
const { resolveArtifactDir } = require('./utils/paths');
|
|
20
21
|
|
|
21
22
|
/** Decodifica entidades HTML latinas (el portal SII las usa en vez de UTF-8 crudo) y limpia
|
|
22
23
|
* tags/whitespace, para poder aplicar regex de texto sobre las respuestas de forma confiable. */
|
|
@@ -83,11 +84,18 @@ class SiiCertificacion {
|
|
|
83
84
|
|
|
84
85
|
this.rutEmpresa = options.rutEmpresa.replace(/\./g, '');
|
|
85
86
|
this.dvEmpresa = options.dvEmpresa.toUpperCase();
|
|
86
|
-
|
|
87
|
+
|
|
88
|
+
// Un solo lugar donde se decide dónde escribir. Antes había 7 call sites que
|
|
89
|
+
// recalculaban la ruta a mano, y no coincidían entre sí: cuatro usaban
|
|
90
|
+
// `../../debug/cert-v2` y tres `../../debug`, así que archivos de la misma
|
|
91
|
+
// operación quedaban repartidos en dos carpetas distintas.
|
|
92
|
+
this.debugDir = resolveArtifactDir(options.debugDir, options.debugDir ? '' : 'cert-v2');
|
|
93
|
+
|
|
87
94
|
this.session = new SiiSession({
|
|
88
95
|
pfxPath: options.pfxPath,
|
|
89
96
|
pfxPassword: options.pfxPassword,
|
|
90
97
|
ambiente: 'certificacion',
|
|
98
|
+
debugDir: this.debugDir,
|
|
91
99
|
});
|
|
92
100
|
|
|
93
101
|
// Reutilización de sesión: cargar desde archivo y guardar automáticamente tras cada login
|
|
@@ -632,7 +640,7 @@ class SiiCertificacion {
|
|
|
632
640
|
{
|
|
633
641
|
const fs = require('fs');
|
|
634
642
|
const path = require('path');
|
|
635
|
-
const debugDir =
|
|
643
|
+
const debugDir = this.debugDir;
|
|
636
644
|
if (!fs.existsSync(debugDir)) {
|
|
637
645
|
fs.mkdirSync(debugDir, { recursive: true });
|
|
638
646
|
}
|
|
@@ -789,7 +797,7 @@ class SiiCertificacion {
|
|
|
789
797
|
// Guardar respuesta de pe_avance3 para debug
|
|
790
798
|
const fs = require('fs');
|
|
791
799
|
const path = require('path');
|
|
792
|
-
const debugDir =
|
|
800
|
+
const debugDir = this.debugDir;
|
|
793
801
|
if (!fs.existsSync(debugDir)) {
|
|
794
802
|
fs.mkdirSync(debugDir, { recursive: true });
|
|
795
803
|
}
|
|
@@ -822,6 +830,38 @@ class SiiCertificacion {
|
|
|
822
830
|
errorMsg = 'Respuesta inválida al declarar avance';
|
|
823
831
|
}
|
|
824
832
|
|
|
833
|
+
// ── Datos inconsistentes: error DEFINITIVO, no "todavía no" ────────────
|
|
834
|
+
//
|
|
835
|
+
// El SII contesta 200 con una página normal que dice, entre los antecedentes del
|
|
836
|
+
// set, "FECHA NO CORRESPONDE AL ENVIO". No es un error de sesión ni de contenido,
|
|
837
|
+
// así que caía en el camino de éxito: la verificación posterior encontraba los
|
|
838
|
+
// campos vacíos, el bucle reintentaba 10 veces y después el polling seguía hasta
|
|
839
|
+
// agotar el timeout de la etapa. Minutos quemados esperando algo imposible, y sin
|
|
840
|
+
// que el mensaje real del SII llegara nunca al usuario.
|
|
841
|
+
//
|
|
842
|
+
// Esta ruta la comparten declarar avance, libros y simulación, así que detectarlo
|
|
843
|
+
// acá los cubre a los tres.
|
|
844
|
+
//
|
|
845
|
+
// La causa habitual es la zona horaria: los runners arman las fechas con
|
|
846
|
+
// `new Date().getDate()`, que usa la TZ del proceso. Corriendo en UTC, entre las
|
|
847
|
+
// 20:00 y las 00:00 de Chile ya es el día siguiente y se declara con la fecha de
|
|
848
|
+
// mañana. Por eso `TZ=America/Santiago` es obligatorio (ver CLAUDE.md).
|
|
849
|
+
const _inconsistencia = body.replace(/<[^>]*>/g, ' ')
|
|
850
|
+
.match(/((?:FECHA|RUT|FOLIO|NUMERO|N[UÚ]MERO)[^.<]{0,40}?NO\s+(?:CORRESPONDE|COINCIDE)[^.<]{0,40})/i);
|
|
851
|
+
if (_inconsistencia) {
|
|
852
|
+
const _detalle = _inconsistencia[1].replace(/\s+/g, ' ').trim();
|
|
853
|
+
return {
|
|
854
|
+
success: false,
|
|
855
|
+
datoInconsistente: true,
|
|
856
|
+
error: `El SII rechazó la declaración: ${_detalle}. Los datos declarados no coinciden con lo que el SII tiene registrado del envío; corrígelos y vuelve a declarar (esperar no lo resuelve).`,
|
|
857
|
+
status: declareResponse.status,
|
|
858
|
+
rawHtml: body,
|
|
859
|
+
formHtml,
|
|
860
|
+
setsDeclarados: Object.keys(sets),
|
|
861
|
+
formDataSent: formData,
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
|
|
825
865
|
// Éxito si el form se envió sin errores de sesión/contenido.
|
|
826
866
|
// El estado del envío (ERRORES O REPAROS / EN REVISION / REVISADO CONFORME)
|
|
827
867
|
// NO determina el éxito de la declaración — eso se resuelve vía polling.
|
|
@@ -843,7 +883,7 @@ class SiiCertificacion {
|
|
|
843
883
|
{
|
|
844
884
|
const fs = require('fs');
|
|
845
885
|
const path = require('path');
|
|
846
|
-
const debugDir =
|
|
886
|
+
const debugDir = this.debugDir;
|
|
847
887
|
if (!fs.existsSync(debugDir)) fs.mkdirSync(debugDir, { recursive: true });
|
|
848
888
|
fs.writeFileSync(path.join(debugDir, 'pe_avance3_response.html'), body, 'utf8');
|
|
849
889
|
|
|
@@ -868,7 +908,7 @@ class SiiCertificacion {
|
|
|
868
908
|
{
|
|
869
909
|
const fs = require('fs');
|
|
870
910
|
const path = require('path');
|
|
871
|
-
const debugDir =
|
|
911
|
+
const debugDir = this.debugDir;
|
|
872
912
|
if (!fs.existsSync(debugDir)) fs.mkdirSync(debugDir, { recursive: true });
|
|
873
913
|
fs.writeFileSync(path.join(debugDir, 'pe_avance2_verify.html'), verifyHtml, 'utf8');
|
|
874
914
|
|
|
@@ -1027,7 +1067,7 @@ class SiiCertificacion {
|
|
|
1027
1067
|
if (process.env.DEBUG_SII) {
|
|
1028
1068
|
const fs = require('fs');
|
|
1029
1069
|
const path = require('path');
|
|
1030
|
-
const debugDir =
|
|
1070
|
+
const debugDir = this.debugDir;
|
|
1031
1071
|
if (!fs.existsSync(debugDir)) {
|
|
1032
1072
|
fs.mkdirSync(debugDir, { recursive: true });
|
|
1033
1073
|
}
|
|
@@ -1053,7 +1093,7 @@ class SiiCertificacion {
|
|
|
1053
1093
|
if (process.env.DEBUG_SII) {
|
|
1054
1094
|
const fs = require('fs');
|
|
1055
1095
|
const path = require('path');
|
|
1056
|
-
const debugDir =
|
|
1096
|
+
const debugDir = this.debugDir;
|
|
1057
1097
|
if (!fs.existsSync(debugDir)) {
|
|
1058
1098
|
fs.mkdirSync(debugDir, { recursive: true });
|
|
1059
1099
|
}
|
|
@@ -1201,7 +1241,7 @@ class SiiCertificacion {
|
|
|
1201
1241
|
try {
|
|
1202
1242
|
const fs = require('fs');
|
|
1203
1243
|
const path = require('path');
|
|
1204
|
-
const debugDir =
|
|
1244
|
+
const debugDir = this.debugDir;
|
|
1205
1245
|
if (!fs.existsSync(debugDir)) fs.mkdirSync(debugDir, { recursive: true });
|
|
1206
1246
|
fs.writeFileSync(path.join(debugDir, filename), body || '', 'utf8');
|
|
1207
1247
|
} catch (_e) { /* debug best-effort, no bloquear el flujo real */ }
|
|
@@ -1475,6 +1515,13 @@ class SiiCertificacion {
|
|
|
1475
1515
|
esReparos: upper.includes('REPAROS'),
|
|
1476
1516
|
porRealizar: upper.includes('POR REALIZAR'),
|
|
1477
1517
|
esAnulado: upper.includes('ANULADO'),
|
|
1518
|
+
// Estados que NO se resuelven esperando: el dato declarado no cuadra con lo
|
|
1519
|
+
// que el SII tiene registrado, así que hay que corregirlo y volver a declarar.
|
|
1520
|
+
// Sin esta categoría caían en el limbo (ni conforme, ni en revisión, ni
|
|
1521
|
+
// rechazado) y el polling seguía hasta agotar el timeout de la etapa.
|
|
1522
|
+
// Caso real: "FECHA NO CORRESPONDE AL ENVIO" cuando el proceso corre en UTC
|
|
1523
|
+
// y declara con la fecha del día siguiente (ver TZ en docker-compose.yml).
|
|
1524
|
+
datoInconsistente: /NO CORRESPONDE|NO COINCIDE|FECHA INVALIDA/i.test(upper),
|
|
1478
1525
|
};
|
|
1479
1526
|
}
|
|
1480
1527
|
}
|
|
@@ -1540,7 +1587,9 @@ class SiiCertificacion {
|
|
|
1540
1587
|
: result.estados;
|
|
1541
1588
|
|
|
1542
1589
|
const todosConformes = Object.values(estadosRelevantes).every(e => e.esConforme);
|
|
1543
|
-
|
|
1590
|
+
// `datoInconsistente` cuenta como rechazo: seguir esperando no lo arregla.
|
|
1591
|
+
const algunoRechazado = Object.values(estadosRelevantes)
|
|
1592
|
+
.some(e => e.esRechazado || e.datoInconsistente);
|
|
1544
1593
|
|
|
1545
1594
|
if (onProgress) {
|
|
1546
1595
|
onProgress({ intento, maxIntentos, estado: 'resultado', estados: estadosRelevantes });
|
|
@@ -1559,7 +1608,7 @@ class SiiCertificacion {
|
|
|
1559
1608
|
// sin decir cuál set ni con qué estado. Se nombran solo los rechazados
|
|
1560
1609
|
// para no ahogar el dato entre los que sí pasaron.
|
|
1561
1610
|
const detalle = Object.values(estadosRelevantes)
|
|
1562
|
-
.filter(e => e.esRechazado)
|
|
1611
|
+
.filter(e => e.esRechazado || e.datoInconsistente)
|
|
1563
1612
|
.map(e => `${e.nombre}: ${e.estado}`)
|
|
1564
1613
|
.join('; ');
|
|
1565
1614
|
return {
|