@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 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
- this.baseDir = options.baseDir || path.resolve(__dirname, '..', '..');
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(String(html || ''));
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.\s+autorizado\s+para\s+ingresar\s+a\s+esta\s+opci.n/i.test(String(html || ''));
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(/&nbsp;/gi, ' ').replace(/&aacute;/gi, 'a').replace(/&eacute;/gi, 'e')
346
+ .replace(/&iacute;/gi, 'i').replace(/&oacute;/gi, 'o').replace(/&uacute;/gi, 'u')
347
+ .replace(/&ntilde;/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.\s+autorizado\s+para\s+operar\s+en\s+la\s+empresa/i
269
- .test(String(html || ''));
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
- return { success: false, errorCode: 'UNKNOWN', error: 'No se obtuvo CAF en la respuesta' };
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 (currentHtml.includes('no registra Verificacion de Actividades')) {
554
- return response; // solicitar() detectará el rechazo en response.body
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.esBloqueoTimbraje(currentHtml)) {
589
- return response; // solicitar() detectará el bloqueo en response.body
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.esBloqueoTimbraje(currentHtml)) {
612
- return response; // solicitar() detectará el bloqueo en response.body
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 (currentHtml.includes('no registra Verificacion de Actividades')) {
631
- return response; // solicitar() detectará el rechazo en response.body
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
- const rznRecepRaw = sanitizeSiiText((enc.Receptor.RznSocRecep || '').substring(0, 40));
268
- const it1Raw = sanitizeSiiText((primerItem.NmbItem || 'Producto').substring(0, 40));
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 fetch(url, {
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 fetch(url, {
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
- const errorText = await response.text();
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 fetch(url, {
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
- this.registryPath = path.resolve(__dirname, '..', '..', 'debug', 'folios.json');
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 || process.cwd();
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
- this.baseDir = options.baseDir || path.resolve(__dirname, '..', '..');
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.debugDir, `folios-anulados-${rutLimpio}-${tipoDte}.json`);
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.debugDir, { recursive: true });
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