@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 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
@@ -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
- 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
@@ -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 = process.env.SII_DEBUG_DIR || path.join(__dirname, '../../debug/cert-v2');
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 = process.env.SII_DEBUG_DIR || path.join(__dirname, '../../debug');
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 = process.env.SII_DEBUG_DIR || path.join(__dirname, '../../debug/cert-v2');
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 = process.env.SII_DEBUG_DIR || path.join(__dirname, '../../debug/cert-v2');
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 = process.env.SII_DEBUG_DIR || path.join(__dirname, '../../debug');
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 = process.env.SII_DEBUG_DIR || path.join(__dirname, '../../debug');
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 = process.env.SII_DEBUG_DIR || path.join(__dirname, '../../debug/cert-v2');
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
- const algunoRechazado = Object.values(estadosRelevantes).some(e => e.esRechazado);
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 {