@devlas/dte-sii 2.13.2 → 2.14.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
@@ -366,6 +366,115 @@ class CafSolicitor {
366
366
  * la simulación de certificación.
367
367
  * @returns {Promise<Object>} - { success, cafPath, xml, maxAutor, foliosDisp, error, errorCode }
368
368
  */
369
+ /**
370
+ * Lista los rangos de folios YA AUTORIZADOS que el SII permite volver a descargar.
371
+ *
372
+ * Es la salida a un timbraje bloqueado que no cuesta cupo. Cuando el SII niega folios
373
+ * nuevos dice, textual: "usted tiene disponible una cantidad de folios suficiente...
374
+ * debe emitir y enviar documentos electrónicos al SII o anular folios". Anular es
375
+ * contraproducente —los folios anulados pesan 6 meses EN CONTRA del cupo, así que
376
+ * profundiza el bloqueo—; emitir es lo que lo levanta. Pero para emitir hace falta el
377
+ * CAF, y si se perdió, esto lo recupera.
378
+ *
379
+ * ⚠️ El listado del paso 2 incluye rangos ANULADOS sin distinguirlos: recién al abrir
380
+ * cada uno (paso 3) el portal avisa "el rango de folios que desea reobtener ha sido
381
+ * anulado completamente... los documentos que el Servicio reciba con dichos folios
382
+ * serán rechazados". Por eso hay que abrir cada rango para saber si sirve, y por eso
383
+ * este método hace un request por rango. Verificado en maullin el 14/08/2026 con el
384
+ * RUT 77967443-6: de 6 rangos listados en el tipo 56, solo 2 eran usables.
385
+ *
386
+ * @param {number} tipoDte
387
+ * @returns {Promise<Array<{ campos: Object, folioDesde: number, folioHasta: number,
388
+ * cantidad: number, anulado: boolean }>>}
389
+ */
390
+ async listarReobtenibles(tipoDte) {
391
+ const { numero, dv } = splitRut(this.rutEmisor);
392
+
393
+ await this.session.request(`https://${this.session.getBaseHost()}/cvc_cgi/dte/rf_reobtencion1_folios`);
394
+ const lista = await this.session.submitForm('/cvc_cgi/dte/rf_reobtencion2_folios', {
395
+ RUT_EMP: numero, DV_EMP: String(dv).toUpperCase(), PAGINA: '1',
396
+ COD_DOCTO: String(tipoDte), ACEPTAR: 'Consultar',
397
+ }, '/cvc_cgi/dte/rf_reobtencion1_folios');
398
+
399
+ // Cada rango viene en su propio <form name="frmN"> con todos sus ocultos.
400
+ const rangos = [...String(lista.body || '').matchAll(/<form\s+name="frm\d+"[\s\S]*?<\/form>/gi)]
401
+ .map(([bloque]) => {
402
+ const campos = {};
403
+ for (const [, k, v] of bloque.matchAll(/<input[^>]*name="([^"]+)"[^>]*value="([^"]*)"/gi)) campos[k] = v;
404
+ return campos;
405
+ })
406
+ .filter(c => c.FOLIO_INI && c.FOLIO_FIN);
407
+
408
+ const salida = [];
409
+ for (const campos of rangos) {
410
+ const detalle = await this.session.submitForm(
411
+ '/cvc_cgi/dte/rf_reobtencion3_folios', campos, '/cvc_cgi/dte/rf_reobtencion2_folios',
412
+ );
413
+ const html = String(detalle.body || '');
414
+ salida.push({
415
+ campos,
416
+ folioDesde: Number(campos.FOLIO_INI),
417
+ folioHasta: Number(campos.FOLIO_FIN),
418
+ cantidad: Number(campos.FOLIO_FIN) - Number(campos.FOLIO_INI) + 1,
419
+ anulado: /ha sido anulado/i.test(html),
420
+ // El paso 3 ya trae el formulario final; se guarda para no repetir el request.
421
+ confirmacion: html,
422
+ });
423
+ }
424
+ return salida;
425
+ }
426
+
427
+ /**
428
+ * Descarga el CAF de un rango ya autorizado. `rango` es un elemento de
429
+ * `listarReobtenibles()`. Devuelve el mismo shape que `solicitar()` para que el
430
+ * llamador no tenga que distinguir de dónde salió el CAF.
431
+ */
432
+ async reobtenerCaf(tipoDte, rango) {
433
+ if (rango.anulado) {
434
+ return { success: false, errorCode: 'RANGO_ANULADO',
435
+ error: `El rango ${rango.folioDesde}-${rango.folioHasta} del tipo ${tipoDte} está anulado: el SII rechazaría cualquier documento emitido con esos folios.` };
436
+ }
437
+
438
+ // El paso 3 devolvió el formulario final (`form1` → rf_genera_folio) con sus ocultos.
439
+ const campos = {};
440
+ const bloque = String(rango.confirmacion || '').match(/<form\s+name="form1"[\s\S]*?<\/form>/i)?.[0] ?? '';
441
+ for (const [, k, v] of bloque.matchAll(/<input[^>]*name="([^"]+)"[^>]*value="([^"]*)"/gi)) campos[k] = v;
442
+ if (!campos.FOLIO_INI) {
443
+ return { success: false, errorCode: 'REOBTENCION_SIN_FORMULARIO',
444
+ error: 'El SII no devolvió el formulario de generación para este rango.' };
445
+ }
446
+
447
+ // `rf_genera_folio` NO devuelve el XML: emite la resolución ("el SII ha autorizado...
448
+ // la numeración desde N hasta M") y recién ahí ofrece el enlace de descarga, que es
449
+ // otro formulario contra `rf_genera_archivo`. Son dos pasos, como en el timbraje
450
+ // normal (of_genera_folio → of_genera_archivo).
451
+ const autorizacion = await this.session.submitForm('/cvc_cgi/dte/rf_genera_folio',
452
+ { ...campos, ACEPTAR: 'Solicitar Folios' }, '/cvc_cgi/dte/rf_reobtencion3_folios');
453
+
454
+ const camposArchivo = {};
455
+ const formArchivo = String(autorizacion.body || '')
456
+ .match(/<form[^>]*action="[^"]*rf_genera_archivo"[\s\S]*?<\/form>/i)?.[0] ?? '';
457
+ for (const [, k, v] of formArchivo.matchAll(/<input[^>]*name="([^"]+)"[^>]*value="([^"]*)"/gi)) camposArchivo[k] = v;
458
+ if (!camposArchivo.FOLIO_INI) {
459
+ return { success: false, errorCode: 'REOBTENCION_SIN_DESCARGA',
460
+ error: 'El SII autorizó la reobtención pero no ofreció el enlace de descarga del CAF.' };
461
+ }
462
+
463
+ const resp = await this.session.submitForm('/cvc_cgi/dte/rf_genera_archivo',
464
+ { ...camposArchivo, ACEPTAR: 'AQUI' }, '/cvc_cgi/dte/rf_genera_folio');
465
+ const body = String(resp.body || '');
466
+ if (!body.includes('<AUTORIZACION')) {
467
+ return { success: false, errorCode: 'REOBTENCION_SIN_CAF',
468
+ error: 'El SII no devolvió el XML del CAF al reobtener.' };
469
+ }
470
+
471
+ const cafPath = this._saveCafOrganized(body, tipoDte);
472
+ return {
473
+ success: true, cafPath, xml: body, reobtenido: true,
474
+ folioDesde: rango.folioDesde, folioHasta: rango.folioHasta, otorgados: rango.cantidad,
475
+ };
476
+ }
477
+
369
478
  async solicitar({ tipoDte, cantidad = 1, minCantidad = null, soloConsultarTope = false }) {
370
479
  const { numero: rut, dv } = splitRut(this.rutEmisor);
371
480
  const debugDir = this._getDebugDir(tipoDte);
@@ -421,9 +530,14 @@ class CafSolicitor {
421
530
 
422
531
  this._saveDebug(debugDir, 'step1-submit.html', response.body || '');
423
532
 
424
- // Guardar sesión para reutilización
533
+ // Guardar sesión para reutilización. Best-effort: si falla, se pierde el
534
+ // reuso pero NO se aborta la solicitud de folios ya en curso.
425
535
  if (this.sessionPath) {
426
- this.session.saveSession(this.sessionPath);
536
+ try {
537
+ this.session.saveSession(this.sessionPath);
538
+ } catch (e) {
539
+ console.warn(`[CafSolicitor] No se pudo guardar la sesión: ${e.message}`);
540
+ }
427
541
  }
428
542
 
429
543
  // Rechazo duro por falta de Verificación de Actividades: debe detectarse ANTES de
@@ -658,12 +772,31 @@ class CafSolicitor {
658
772
  * Procesa el flujo multi-paso del SII para obtener CAF
659
773
  * @private
660
774
  */
775
+ /**
776
+ * Igual que `esRechazoDuro`, pero además deja registrado si el motivo fue el bloqueo de
777
+ * timbraje, para que `consultarTope()` pueda distinguir "el SII no limita este tipo" de
778
+ * "el SII no autoriza nada" — dos casos que se ven idénticos porque en ambos la página
779
+ * viene sin MAX_AUTOR.
780
+ *
781
+ * Existe porque el corte por rechazo duro está en CUATRO puntos distintos del flujo
782
+ * (`_processMultiStepFlow` dos veces, su paso intermedio y `_processStep3`), y cada uno
783
+ * retorna apenas lo detecta. Marcar la bandera en uno solo dejaba la detección
784
+ * inalcanzable según por dónde saliera: el sondeo del tipo 56 informaba "no está
785
+ * racionando" con el timbraje cerrado (14/08/2026). Se marca donde se detecta, no en un
786
+ * punto elegido a mano.
787
+ * @private
788
+ */
789
+ _esRechazoDuroYMarca(html) {
790
+ if (CafSolicitor.esBloqueoTimbraje(html)) this._lastBloqueoTimbraje = true;
791
+ return CafSolicitor.esRechazoDuro(html);
792
+ }
793
+
661
794
  async _processMultiStepFlow(response, rut, dv, tipoDte, cantidad, debugDir) {
662
795
  let currentHtml = response.body || '';
663
796
 
664
797
  // Defensivo: mismo rechazo por Verificación de Actividades puede aparecer si el SII
665
798
  // lo entrega recién en un paso posterior en vez del response inicial de solicitar().
666
- if (CafSolicitor.esRechazoDuro(currentHtml)) {
799
+ if (this._esRechazoDuroYMarca(currentHtml)) {
667
800
  return response; // solicitar() traduce el motivo desde response.body
668
801
  }
669
802
 
@@ -698,7 +831,7 @@ class CafSolicitor {
698
831
 
699
832
  // Rechazo duro antes del check de COD_DOCTO: la página de rechazo también contiene
700
833
  // "COD_DOCTO" en su JavaScript, lo que causaría un POST innecesario con datos vacíos.
701
- if (CafSolicitor.esRechazoDuro(currentHtml)) {
834
+ if (this._esRechazoDuroYMarca(currentHtml)) {
702
835
  return response; // solicitar() traduce el motivo desde response.body
703
836
  }
704
837
 
@@ -721,7 +854,7 @@ class CafSolicitor {
721
854
  currentHtml = response.body || '';
722
855
  this._saveDebug(debugDir, 'select.html', currentHtml);
723
856
 
724
- if (CafSolicitor.esRechazoDuro(currentHtml)) {
857
+ if (this._esRechazoDuroYMarca(currentHtml)) {
725
858
  return response; // solicitar() traduce el motivo desde response.body
726
859
  }
727
860
  }
@@ -740,7 +873,14 @@ class CafSolicitor {
740
873
  async _processStep3(response, rut, dv, tipoDte, cantidad, debugDir) {
741
874
  let currentHtml = response.body || '';
742
875
 
743
- if (CafSolicitor.esRechazoDuro(currentHtml)) {
876
+ // Se marca ANTES del corte por rechazo duro, no después.
877
+ //
878
+ // `esRechazoDuro` incluye el bloqueo de timbraje, así que en ese caso la función
879
+ // retorna acá mismo y todo lo que viene abajo —incluida la lectura de MAX_AUTOR—
880
+ // no se ejecuta. Marcarlo más adelante dejaba la bandera en false justo en el único
881
+ // caso que tiene que detectar, y el sondeo seguía informando "el SII no está
882
+ // racionando este tipo" con el timbraje cerrado (visto el 14/08/2026, tipo 56).
883
+ if (this._esRechazoDuroYMarca(currentHtml)) {
744
884
  return response; // solicitar() traduce el motivo desde response.body
745
885
  }
746
886
 
package/DTE.js CHANGED
@@ -20,7 +20,7 @@ const {
20
20
  TIPOS_BOLETA,
21
21
  TASA_IVA,
22
22
  } = require('./utils');
23
- const { serializeNode, fixEntities, escapeAttr, escapeText, buildSignedInfo, buildSignature } = require('./utils/c14n');
23
+ const { serializeNode, escapeAttr, escapeText, buildSignedInfo, buildSignature } = require('./utils/c14n');
24
24
 
25
25
  // ============================================
26
26
  // CONSTANTES
@@ -410,7 +410,7 @@ class DTE {
410
410
  }
411
411
 
412
412
  c14n += '</Documento>';
413
- return fixEntities(c14n);
413
+ return c14n;
414
414
  }
415
415
 
416
416
  // ============================================
package/FolioService.js CHANGED
@@ -109,11 +109,17 @@ class FolioService {
109
109
 
110
110
  // Solicitador de CAF interno (migrado de test-caf-solicitar.js)
111
111
  this.cafSolicitor = null;
112
- if (options.pfxPath && options.pfxPassword) {
112
+ // `pfxBuffer` también sirve: el constructor pedía `pfxPath` o sí, aunque la propia
113
+ // clase ya acepta buffer para la sesión y CafSolicitor lo soporta. Un consumidor que
114
+ // tiene el certificado en memoria (leído de la BD, sin escribirlo a disco) quedaba con
115
+ // `cafSolicitor: null` y todo lo que depende de él —solicitar, consultar tope,
116
+ // reobtener— fallaba con "CafSolicitor no inicializado".
117
+ if ((options.pfxPath || options.pfxBuffer) && options.pfxPassword) {
113
118
  this.cafSolicitor = new CafSolicitor({
114
119
  ambiente: this.ambiente,
115
120
  rutEmisor: this.rutEmisor,
116
121
  pfxPath: options.pfxPath,
122
+ pfxBuffer: options.pfxBuffer,
117
123
  pfxPassword: options.pfxPassword,
118
124
  baseDir: this.baseDir,
119
125
  sessionPath: this.sessionPath,
@@ -152,7 +158,23 @@ class FolioService {
152
158
  try {
153
159
  const xml = fs.readFileSync(fullPath, 'utf8');
154
160
  const tdMatch = xml.match(/<TD>(\d+)<\/TD>/i);
155
- if (tdMatch && Number(tdMatch[1]) === Number(tipoDte)) {
161
+ // ⚠️ El RUT se valida acá, contra el <RE> del propio CAF, y NO se da por
162
+ // supuesto por la ruta.
163
+ //
164
+ // `cafDir` (debug/auto-caf) es COMPARTIDO entre todos los comercios del
165
+ // servidor: sus subcarpetas llevan el RUT, pero la búsqueda es recursiva y
166
+ // antes solo comparaba el tipo de DTE. Un CAF de otra empresa con el mismo
167
+ // tipo entraba como candidato válido.
168
+ //
169
+ // Pasó de verdad (14/08/2026): al reusar CAF previos, los tipos 56 y 61 del
170
+ // RUT 77967443-6 resolvieron a CAF de 78206276-K. El timbre quedó firmado con
171
+ // la llave de otro contribuyente y el SII devolvió `RFR - Rechazado por Error
172
+ // en Firma` para todo el envío, lo que a su vez trababa la declaración de
173
+ // simulación ("no debe contener documentos con reparos o rechazos").
174
+ const reMatch = xml.match(/<RE>([^<]+)<\/RE>/i);
175
+ const rutCaf = (reMatch?.[1] ?? '').replace(/\./g, '').trim().toUpperCase();
176
+ const rutMio = String(this.rutEmisor).replace(/\./g, '').trim().toUpperCase();
177
+ if (tdMatch && Number(tdMatch[1]) === Number(tipoDte) && rutCaf === rutMio) {
156
178
  const stat = fs.statSync(fullPath);
157
179
  matches.push({ filePath: fullPath, mtime: stat.mtimeMs });
158
180
  }
@@ -307,6 +329,86 @@ class FolioService {
307
329
  * usar para destrabar el tope. Ponerlo en false lo deja en modo consulta.
308
330
  * @returns {Promise<Object>} { ok, cafPath, otorgados, maxAutor, foliosDisp, errorCode, error }
309
331
  */
332
+ /**
333
+ * Intenta cubrir `cantidad` folios recuperando un CAF ya autorizado, sin pedirle folios
334
+ * nuevos al SII.
335
+ *
336
+ * Es el paso que va ANTES de pedir y muy antes de anular. Cuando el SII bloquea el
337
+ * timbraje lo hace porque el contribuyente ya tiene folios sin usar; recuperarlos y
338
+ * emitir con ellos es justo lo que el SII pide para levantar el bloqueo, mientras que
339
+ * anular lo empeora (los folios anulados pesan 6 meses en contra del cupo).
340
+ *
341
+ * ⚠️ Solo sirve un rango que por sí solo alcance: el generador de sets recibe UN CAF por
342
+ * tipo (`cafManager.ensureCaf({ tipoDte })` en CertRunner), así que no se pueden juntar
343
+ * dos rangos sueltos para el mismo tipo. El SII suele entregarlos de a un folio, así que
344
+ * esto no siempre alcanza — y cuando no alcanza, se devuelve el motivo en vez de fingir.
345
+ *
346
+ * @returns {Promise<{ ok: boolean, cafPath?: string, motivo?: string, disponibles?: number }>}
347
+ */
348
+ async reobtenerCaf({ tipoDte, cantidad }) {
349
+ if (!this.cafSolicitor) {
350
+ return { ok: false, motivo: 'CafSolicitor no inicializado' };
351
+ }
352
+ let rangos;
353
+ try {
354
+ rangos = await this.cafSolicitor.listarReobtenibles(Number(tipoDte));
355
+ } catch (err) {
356
+ // Nunca fatal: si la reobtención falla, el flujo sigue por el camino normal.
357
+ console.warn(`[FolioService] Tipo ${tipoDte}: no se pudo consultar reobtención — ${err.message}`);
358
+ return { ok: false, motivo: err.message };
359
+ }
360
+
361
+ const usables = rangos.filter(r => !r.anulado);
362
+ const anulados = rangos.length - usables.length;
363
+ console.log(
364
+ `[FolioService] Tipo ${tipoDte}: ${rangos.length} rango(s) reobtenible(s), ` +
365
+ `${usables.length} usable(s)${anulados ? `, ${anulados} anulado(s) descartado(s)` : ''}`
366
+ );
367
+
368
+ const total = usables.reduce((n, r) => n + r.cantidad, 0);
369
+ if (total < cantidad) {
370
+ return {
371
+ ok: false,
372
+ disponibles: total,
373
+ motivo: `los rangos usables no alcanzan (se necesitan ${cantidad}, hay ${total})`,
374
+ };
375
+ }
376
+
377
+ // En orden de folio ascendente: los documentos del set quedan numerados de menor a
378
+ // mayor, como si vinieran de un rango contiguo.
379
+ const elegidos = [];
380
+ let acumulado = 0;
381
+ for (const r of usables.sort((a, b) => a.folioDesde - b.folioDesde)) {
382
+ if (acumulado >= cantidad) break;
383
+ elegidos.push(r);
384
+ acumulado += r.cantidad;
385
+ }
386
+
387
+ // Se descargan TODOS los que hagan falta: el SII entrega los folios reobtenidos de a
388
+ // uno, así que cubrir 4 folios puede requerir 4 CAF distintos. Los sets los aceptan
389
+ // como lista (ver SetBase._tomarFolio) porque cada uno firma con su propia llave.
390
+ const cafPaths = [];
391
+ for (const rango of elegidos) {
392
+ const res = await this.cafSolicitor.reobtenerCaf(Number(tipoDte), rango);
393
+ if (!res.success) {
394
+ // Si uno falla a mitad, lo ya descargado igual sirve mientras alcance.
395
+ console.warn(`[FolioService] Tipo ${tipoDte}: falló reobtener ${rango.folioDesde}-${rango.folioHasta} — ${res.error}`);
396
+ continue;
397
+ }
398
+ cafPaths.push(res.cafPath);
399
+ console.log(`[FolioService] Tipo ${tipoDte}: CAF REOBTENIDO (folios ${res.folioDesde}-${res.folioHasta}) — sin gastar cupo`);
400
+ }
401
+
402
+ const cubiertos = elegidos
403
+ .filter((_, i) => i < cafPaths.length)
404
+ .reduce((n, r) => n + r.cantidad, 0);
405
+ if (cubiertos < cantidad) {
406
+ return { ok: false, disponibles: cubiertos,
407
+ motivo: `se reobtuvieron ${cubiertos} folio(s) de los ${cantidad} necesarios` };
408
+ }
409
+ return { ok: true, cafPaths, cafPath: cafPaths[0] };
410
+ }
411
+
310
412
  /**
311
413
  * Consulta cuánto autoriza el SII para un tipo, SIN emitir nada.
312
414
  *
@@ -325,11 +427,16 @@ class FolioService {
325
427
  if (!this.cafSolicitor) {
326
428
  throw new Error('FolioService: CafSolicitor no inicializado (se requiere pfxPath y pfxPassword)');
327
429
  }
430
+ this.cafSolicitor._lastBloqueoTimbraje = false;
328
431
  await this.cafSolicitor.solicitar({ tipoDte, cantidad: 1, soloConsultarTope: true });
329
432
  const maxAutor = this.cafSolicitor._lastMaxAutor ?? null;
330
433
  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 };
434
+ const bloqueado = this.cafSolicitor._lastBloqueoTimbraje === true;
435
+ // `_lastFoliosDisp` queda en null en DOS casos opuestos: cuando el SII no está
436
+ // limitando este tipo, y cuando lo tiene bloqueado del todo (ahí tampoco publica los
437
+ // campos, muestra "NO AUTORIZA TIMBRAJE"). Sin `bloqueado`, el segundo se leía como el
438
+ // primero y el llamador se saltaba la limpieza que era justo lo que hacía falta.
439
+ return { sinTope: foliosDisp === null && !bloqueado, maxAutor, foliosDisp, bloqueado };
333
440
  }
334
441
 
335
442
  async solicitarCafExacto({ tipoDte, cantidad, permitirAnular = true }) {
@@ -647,7 +754,40 @@ class FolioService {
647
754
  console.log(`[FolioService] Pasada ${pasada + 1}: ${rangos.length} rango(s) a procesar`);
648
755
  const host = this.session.getBaseHost();
649
756
 
757
+ // Corte cuando el SII deja de permitir anular, para no gastar requests confirmando
758
+ // lo mismo rango tras rango.
759
+ //
760
+ // ⚠️ Solo cuentan las negativas REALES. `ya-anulado` y `recepcionado` no son
761
+ // negativas: son resultados esperados (ese rango ya estaba anulado, o sus folios ya
762
+ // se usaron) y aparecen mezclados con anulaciones exitosas.
763
+ //
764
+ // Contarlos rompía la limpieza (medido el 13/08/2026, RUT 77967443-6, tipo 33):
765
+ // 20 rangos a procesar
766
+ // ✓ 47-49 anulado ✓ 43-46 anulado
767
+ // ✗ 31-34 ya anulado ✗ 27-30 ya anulado → CORTE, 8 rangos sin intentar
768
+ // Los éxitos y los "ya anulado" se intercalan, así que cortar ahí abandona rangos
769
+ // todavía anulables y deja el racionamiento puesto: la corrida quedó esperando por
770
+ // folios que se podrían haber liberado en esa misma pasada.
771
+ const NO_SON_NEGATIVA = new Set(['ya-anulado', 'recepcionado']);
772
+ const esNegativaDelSii = (razon) => !NO_SON_NEGATIVA.has(razon);
773
+ const MAX_RECHAZOS_SEGUIDOS = 2;
774
+ let rechazosSeguidos = 0;
775
+
776
+ let procesados = 0;
650
777
  for (const range of rangos) {
778
+ if (rechazosSeguidos >= MAX_RECHAZOS_SEGUIDOS) {
779
+ console.warn(
780
+ `[FolioService] Tipo ${tipoDte}: ${rechazosSeguidos} rangos rechazados seguidos — ` +
781
+ `el SII no está permitiendo anular, se abandona la limpieza ` +
782
+ // Se cuenta contra los rangos de ESTA pasada, no contra `vistos`, que acumula
783
+ // entre pasadas y hacía que el número saliera negativo (visto el 13/08/2026:
784
+ // "se abandona la limpieza (-16 rango(s) sin intentar)").
785
+ `(${rangos.length - procesados} rango(s) sin intentar).`
786
+ );
787
+ break;
788
+ }
789
+ procesados++;
790
+ const anuladosAntes = anulados.length;
651
791
  vistos.add(`${range.folioDesde}-${range.folioHasta}`);
652
792
  const iniA = folioDesde != null ? Math.max(range.folioDesde, folioDesde) : range.folioDesde;
653
793
  const finA = folioHasta != null ? Math.min(range.folioHasta, folioHasta) : range.folioHasta;
@@ -675,6 +815,7 @@ class FolioService {
675
815
  } catch (err) {
676
816
  console.error(`[FolioService] af_anular3 falló rango ${iniA}-${finA}: ${err.message}`);
677
817
  rechazados.push({ folioDesde: iniA, folioHasta: finA, count, reason: 'error-red' });
818
+ rechazosSeguidos = rechazosSeguidos + 1;
678
819
  continue;
679
820
  }
680
821
 
@@ -687,6 +828,7 @@ class FolioService {
687
828
  body3.includes('efectuado anteriormente') ||
688
829
  /anulad[oa]\s+anteriormente/i.test(body3)) {
689
830
  rechazados.push({ folioDesde: iniA, folioHasta: finA, count, reason: 'ya-anulado' });
831
+ // 'ya anulado' NO cuenta: es un no-op, no una negativa del SII.
690
832
  // Se recuerda para que la próxima corrida no lo reintente.
691
833
  yaAnulados.add(`${iniA}-${finA}`);
692
834
  console.warn(`[FolioService] ✗ Rango ${iniA}-${finA}: ya anulado (af_anular3)`);
@@ -711,6 +853,7 @@ class FolioService {
711
853
  } catch (err) {
712
854
  console.error(`[FolioService] af_anular bulk falló rango ${iniA}-${finA}: ${err.message}`);
713
855
  rechazados.push({ folioDesde: iniA, folioHasta: finA, count, reason: 'error-red' });
856
+ rechazosSeguidos = rechazosSeguidos + 1;
714
857
  continue;
715
858
  }
716
859
 
@@ -718,6 +861,7 @@ class FolioService {
718
861
  bodyBulk.includes('SOLICITUD ANULACION DE FOLIOS');
719
862
  if (exitoBulk) {
720
863
  anulados.push({ folioDesde: iniA, folioHasta: finA, count });
864
+ rechazosSeguidos = 0;
721
865
  // Un rango recién anulado sigue apareciendo en `consultarFolios` como
722
866
  // "sin utilizar": si no se recuerda acá, la próxima corrida lo
723
867
  // reintenta y el SII contesta "ya anulado". Ésta es la fuente
@@ -738,6 +882,7 @@ class FolioService {
738
882
  if (!yaConflicto) {
739
883
  const razon = this._parseAnulacionResult(bodyBulk).reason || 'error';
740
884
  rechazados.push({ folioDesde: iniA, folioHasta: finA, count, reason: razon });
885
+ if (esNegativaDelSii(razon)) rechazosSeguidos = rechazosSeguidos + 1;
741
886
  // Mismo criterio que en el camino folio-a-folio: solo se recuerdan los
742
887
  // rechazos definitivos, no los transitorios.
743
888
  if (razon === 'ya-anulado' || razon === 'recepcionado') {
package/Signer.js CHANGED
@@ -9,7 +9,7 @@
9
9
  const crypto = require('crypto');
10
10
  const { DOMParser } = require('@xmldom/xmldom');
11
11
  const { formatBase64InXml } = require('./utils');
12
- const { serializeNode, fixEntities, buildSignedInfo, buildSignature } = require('./utils/c14n');
12
+ const { serializeNode, buildSignedInfo, buildSignature } = require('./utils/c14n');
13
13
 
14
14
  // ============================================
15
15
  // CLASE SIGNER
@@ -87,7 +87,7 @@ class Signer {
87
87
  }
88
88
 
89
89
  c14n += '</SetDTE>';
90
- return fixEntities(c14n);
90
+ return c14n;
91
91
  }
92
92
  }
93
93
 
@@ -107,7 +107,15 @@ class SiiCertificacion {
107
107
  const _sp = options.sessionPath;
108
108
  this.session.ensureSession = async function(targetPath) {
109
109
  const result = await _origEnsure(targetPath);
110
- _sess.saveSession(_sp);
110
+ // Best-effort: este hook corre en CADA establecimiento de sesión, así que
111
+ // una falla al escribir el cache (disco lleno, permisos, volumen no montado)
112
+ // tumbaría toda operación contra el SII. Persistir la sesión es una
113
+ // optimización; la operación en curso ya tiene la sesión viva en memoria.
114
+ try {
115
+ _sess.saveSession(_sp);
116
+ } catch (e) {
117
+ console.warn(`[SiiCertificacion] No se pudo guardar la sesión en ${_sp}: ${e.message}`);
118
+ }
111
119
  return result;
112
120
  };
113
121
  }
package/SiiSession.js CHANGED
@@ -538,17 +538,41 @@ class SiiSession {
538
538
  }
539
539
 
540
540
  /**
541
- * Guarda la sesión actual en un archivo JSON
541
+ * Guarda la sesión actual en un archivo JSON.
542
+ *
543
+ * Crea el directorio contenedor si no existe: `filePath` suele apuntar a un
544
+ * volumen persistente o a una carpeta de debug que en el primer arranque
545
+ * todavía no está creada, y sin esto `writeFileSync` lanza ENOENT. Perder la
546
+ * sesión obliga a re-loguearse contra el SII en cada operación, que es el
547
+ * camino directo al bloqueo por "máximo de sesiones autenticadas" del RUT.
548
+ *
542
549
  * @param {string} filePath - Ruta del archivo donde guardar
543
550
  */
551
+ /**
552
+ * Huella del certificado con el que se configuró el TLS de esta sesión.
553
+ *
554
+ * Identifica de QUIÉN es la sesión. Sin esto, `SII_SESSION_PATH` —que en producción es
555
+ * un archivo ÚNICO para todos los comercios (`/data/sii/session.json`)— hacía que la
556
+ * sesión de un contribuyente se cargara para las operaciones de otro.
557
+ * @private
558
+ */
559
+ _huellaCert() {
560
+ const pem = this.tlsOptions?.cert;
561
+ if (!pem) return null;
562
+ return require('crypto').createHash('sha256').update(String(pem)).digest('hex').slice(0, 16);
563
+ }
564
+
544
565
  saveSession(filePath) {
545
566
  const fs = require('fs');
567
+ const path = require('path');
546
568
  const sessionData = {
547
569
  cookieJar: this.cookieJar,
548
570
  baseHost: this.baseHost,
571
+ certHash: this._huellaCert(),
549
572
  savedAt: Date.now(),
550
573
  expiresAt: Date.now() + (90 * 60 * 1000), // 90 minutos de validez
551
574
  };
575
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
552
576
  fs.writeFileSync(filePath, JSON.stringify(sessionData, null, 2), 'utf8');
553
577
  }
554
578
 
@@ -576,6 +600,26 @@ class SiiSession {
576
600
  console.log('Host SII no coincide, se requiere nuevo login');
577
601
  return false;
578
602
  }
603
+
604
+ // ⚠️ Y que la sesión sea de ESTE certificado.
605
+ //
606
+ // `SII_SESSION_PATH` es un archivo único para todo el proceso (en producción
607
+ // `/data/sii/session.json`), así que en un servidor multi-tenant la sesión del
608
+ // último comercio que operó queda ahí para el siguiente. Cargarla significa actuar
609
+ // ante el SII como el usuario de OTRO contribuyente.
610
+ //
611
+ // Caso real (14/08/2026): una corrida del RUT 78206276-K reusó la sesión del
612
+ // certificado de otra empresa y el portal respondió "usted no está autorizado por la
613
+ // empresa para ingresar a esta opción" al pedir el set de pruebas. El mismo pedido
614
+ // con autenticación fresca funcionó. El síntoma no dice "sesión equivocada", dice
615
+ // "sin permisos", que manda a buscar el problema donde no está.
616
+ //
617
+ // Las sesiones viejas no traen `certHash`: se descartan, que es lo seguro.
618
+ const huella = this._huellaCert();
619
+ if (huella && data.certHash !== huella) {
620
+ console.log('[SessionSii] Sesión de otro certificado — se ignora y se hace login propio');
621
+ return false;
622
+ }
579
623
 
580
624
  this.cookieJar = data.cookieJar || '';
581
625
  console.log('[SessionSii] Sesión SII cargada desde archivo');