@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.
@@ -98,6 +98,10 @@ class CertRunner {
98
98
  this._siiCert = null;
99
99
  this._setsProvider = null;
100
100
  this._estructuras = null;
101
+ // CAF timbrados por adelantado para toda la corrida (ver precargarCafsDeSets).
102
+ // Mientras sea null, cada set pide los suyos como antes.
103
+ this._cafsPrecargados = null;
104
+ this._planPrecargado = null;
101
105
 
102
106
  // Caché de sesión SII en memoria (evita logins múltiples durante la misma ejecución)
103
107
  // Se puede inyectar un cookieJar ya obtenido vía config.cookieJar para reutilizar sesión.
@@ -262,6 +266,219 @@ class CertRunner {
262
266
  Object.assign(this.resultados, resultadosJson);
263
267
  }
264
268
 
269
+ /**
270
+ * Timbra de una sola vez TODOS los folios que la corrida va a necesitar, sumados por
271
+ * tipo entre los cuatro sets. Llamar antes de ejecutar el primer set.
272
+ *
273
+ * El problema que resuelve (medido el 13/08/2026, RUT 78480527-1): cada set pedía sus
274
+ * propios folios cuando le tocaba, así que del tipo 56 se pedían tres tandas separadas
275
+ * —básico 1, exenta 2, compra 1— en la misma corrida. El SII raciona el timbraje según
276
+ * cuántos folios tengas autorizados sin usar, así que la primera tanda le bajaba el tope
277
+ * de 100 a 1 y la segunda se estrellaba con `MAX_AUTOR=1 < 2`. La corrida moría a mitad,
278
+ * y lo único que la destrababa era esperar a que el SII procesara lo ya emitido.
279
+ *
280
+ * Pedir el total junto no rompe el orden de emisión, que sí es obligatorio: las notas de
281
+ * crédito y débito llevan `Referencia` al documento que corrigen (ver SetBasico), o sea
282
+ * que se emiten después de sus facturas, set por set. Pero el timbraje es independiente
283
+ * de la emisión — se pueden tener folios en mano sin haber emitido nada — así que es lo
284
+ * único de la secuencia que sí se puede consolidar.
285
+ *
286
+ * @param {Object[]} planes - cafRequired de cada set, ej. [{33:4,56:1}, {34:3,56:2}]
287
+ * @returns {Promise<Object>} { 33: cafPath, 56: cafPath, ... }
288
+ */
289
+ async precargarCafsDeSets(planes) {
290
+ const total = {};
291
+ for (const plan of planes) {
292
+ for (const [tipoDte, cantidad] of Object.entries(plan || {})) {
293
+ total[tipoDte] = (total[tipoDte] || 0) + Number(cantidad || 0);
294
+ }
295
+ }
296
+
297
+ const resumen = Object.entries(total).map(([t, c]) => `${t}x${c}`).join(', ');
298
+ console.log(`\n Timbrando el total de la corrida de una vez: ${resumen}`);
299
+
300
+ this._cafsPrecargados = await this.solicitarCafs(total);
301
+ this._planPrecargado = total;
302
+ return this._cafsPrecargados;
303
+ }
304
+
305
+ /**
306
+ * Devuelve los CAF precargados que le tocan a un set.
307
+ *
308
+ * No vuelve a limpiar `folioHelper.counters`: con un CAF por tipo compartido entre sets,
309
+ * el contador tiene que seguir avanzando donde lo dejó el set anterior. Reiniciarlo haría
310
+ * que dos sets emitieran el MISMO folio, que el SII rechaza como duplicado.
311
+ * @private
312
+ */
313
+ _cafsDelPlan(cafRequired, nombreSet) {
314
+ const cafs = {};
315
+ for (const tipoDte of Object.keys(cafRequired)) {
316
+ const cafPath = this._cafsPrecargados[tipoDte];
317
+ if (!cafPath) {
318
+ throw new Error(
319
+ `${nombreSet} necesita folios del tipo ${tipoDte} pero no se timbraron en la ` +
320
+ `precarga (se timbró: ${Object.keys(this._cafsPrecargados).join(', ') || 'nada'}). ` +
321
+ `Es un error de programación: el plan de precarga quedó desalineado con los sets.`
322
+ );
323
+ }
324
+ cafs[tipoDte] = cafPath;
325
+ }
326
+ return cafs;
327
+ }
328
+
329
+ /**
330
+ * Busca en disco un CAF de este tipo, timbrado por una corrida anterior, que todavía
331
+ * tenga folios suficientes sin usar. Devuelve null si no hay ninguno servible.
332
+ *
333
+ * Solo mira CAF del ambiente y RUT actuales (`findLatestCaf` ya acota por eso) y exige
334
+ * que el rango alcance para lo que se necesita: un CAF corto no sirve, porque la
335
+ * simulación arma un plan fijo de documentos y muere si falta un folio.
336
+ * @private
337
+ */
338
+ /**
339
+ * Registro de rangos de folios ya emitidos, por RUT.
340
+ *
341
+ * ⚠️ Va por RANGO y no por ruta de archivo, y eso no es un detalle de implementación.
342
+ * El mismo CAF se guarda en DOS árboles distintos con el mismo contenido:
343
+ *
344
+ * debug/auto-caf/{rut}/{ts}/{tipo}/archivo.xml
345
+ * debug/caf/{ambiente}/{rut}/{tipo}/{ts}/caf-{tipo}-{desde}-{hasta}.xml
346
+ *
347
+ * `findLatestCaf` puede devolver cualquiera de las dos. Marcar la copia que se usó deja
348
+ * la otra intacta, y la etapa siguiente la encuentra "sin usar" y repite los folios.
349
+ * Pasó el 14/08/2026: ENVIAR_SETS marcó `caf-33-4523-4526.xml` a las 03:53:00 y
350
+ * SIMULACION reusó `auto-caf/.../33/archivo.xml` a las 03:53:5x — los mismos folios.
351
+ *
352
+ * Lo que identifica a un folio es (RUT, tipo, número), no dónde quedó el archivo.
353
+ * @private
354
+ */
355
+ _foliosUsadosPath() {
356
+ const rutLimpio = String(this.config.emisor.rut || '').replace(/[^0-9kK]/g, '');
357
+ return path.join(this.stateDir, `folios-usados-${rutLimpio}.json`);
358
+ }
359
+
360
+ /** @returns {Record<string, Array<[number, number]>>} rangos consumidos por tipo de DTE */
361
+ _cargarFoliosUsados() {
362
+ try {
363
+ const p = this._foliosUsadosPath();
364
+ if (!fs.existsSync(p)) return {};
365
+ const data = JSON.parse(fs.readFileSync(p, 'utf8'));
366
+ return (data && typeof data === 'object') ? data : {};
367
+ } catch { return {}; }
368
+ }
369
+
370
+ /** Lee (tipo, desde, hasta) de un CAF en disco. `null` si no se pudo. */
371
+ _rangoDelCaf(cafPath) {
372
+ try {
373
+ const { CAF } = require('../index');
374
+ const caf = new CAF(fs.readFileSync(cafPath, 'utf8'));
375
+ const tipo = Number(caf.da?.TD);
376
+ const desde = caf.getFolioDesde();
377
+ const hasta = caf.getFolioHasta();
378
+ if (!Number.isFinite(tipo) || !Number.isFinite(desde) || !Number.isFinite(hasta)) return null;
379
+ return { tipo, desde, hasta };
380
+ } catch { return null; }
381
+ }
382
+
383
+ /**
384
+ * Marca los folios de estos CAF como emitidos, para que no se reusen nunca.
385
+ *
386
+ * Se llama en un `finally`, apenas se intentó el envío y sin mirar si salió bien: si
387
+ * el envío viajó, el SII ya vio esos folios aunque después lo rechace. Quemar un folio
388
+ * de más es barato; repetirlo cuesta la etapa entera.
389
+ * @private
390
+ */
391
+ _marcarCafsConsumidos(cafs) {
392
+ const registro = this._cargarFoliosUsados();
393
+ let cambio = false;
394
+
395
+ for (const ruta of Object.values(cafs || {})) {
396
+ for (const p of (Array.isArray(ruta) ? ruta : [ruta])) {
397
+ // El `.usado` se mantiene: no es la fuente de verdad, pero deja el estado a la
398
+ // vista de quien mire el directorio.
399
+ try { fs.writeFileSync(`${p}.usado`, new Date().toISOString(), 'utf8'); }
400
+ catch (e) { console.warn(`[CertRunner] No se pudo marcar ${p} como usado: ${e.message}`); }
401
+
402
+ const r = this._rangoDelCaf(p);
403
+ if (!r) continue;
404
+ const clave = String(r.tipo);
405
+ registro[clave] = registro[clave] || [];
406
+ if (!registro[clave].some(([d, h]) => d === r.desde && h === r.hasta)) {
407
+ registro[clave].push([r.desde, r.hasta]);
408
+ cambio = true;
409
+ }
410
+ }
411
+ }
412
+
413
+ if (!cambio) return;
414
+ try {
415
+ fs.mkdirSync(path.dirname(this._foliosUsadosPath()), { recursive: true });
416
+ fs.writeFileSync(this._foliosUsadosPath(), JSON.stringify(registro, null, 2), 'utf8');
417
+ } catch (e) {
418
+ console.warn(`[CertRunner] No se pudo guardar el registro de folios usados: ${e.message}`);
419
+ }
420
+ }
421
+
422
+ /** ¿Algún folio de [desde, hasta] ya se emitió? */
423
+ _rangoYaConsumido(tipoDte, desde, hasta) {
424
+ const rangos = this._cargarFoliosUsados()[String(tipoDte)] || [];
425
+ return rangos.some(([d, h]) => desde <= h && hasta >= d);
426
+ }
427
+
428
+ _cafReusable(tipoDte, cantidad) {
429
+ try {
430
+ const cafPath = this.folioService.findLatestCaf(tipoDte);
431
+ if (!cafPath || !fs.existsSync(cafPath)) return null;
432
+ // Ya se emitió con él: reusarlo repetiría folios y el SII rechaza el envío entero.
433
+ if (fs.existsSync(`${cafPath}.usado`)) {
434
+ console.log(` Tipo ${tipoDte}: el CAF previo ya se usó para emitir — se pedirá uno nuevo`);
435
+ return null;
436
+ }
437
+ const rango = this._rangoDelCaf(cafPath);
438
+ if (rango && this._rangoYaConsumido(tipoDte, rango.desde, rango.hasta)) {
439
+ console.log(` Tipo ${tipoDte}: los folios ${rango.desde}-${rango.hasta} ya se emitieron `
440
+ + '(otra copia del mismo CAF) — se pedirá uno nuevo');
441
+ return null;
442
+ }
443
+
444
+ // Require local, igual que en _construirCafObjects (línea ~2110): `../index` cierra
445
+ // el ciclo con este archivo, así que importarlo arriba deja CAF sin definir.
446
+ const { CAF } = require('../index');
447
+ const caf = new CAF(fs.readFileSync(cafPath, 'utf8'));
448
+
449
+ // Doble chequeo del RUT, aunque findLatestCaf ya filtre. Firmar un timbre con el CAF
450
+ // de otro contribuyente hace que el SII rechace el envío ENTERO con `RFR - Rechazado
451
+ // por Error en Firma`, y el síntoma aparece etapas más adelante (la declaración de
452
+ // simulación se traba porque el envío tiene rechazos). Un fallo así de caro no puede
453
+ // depender de que una función de otro archivo siga filtrando bien.
454
+ const rutCaf = String(caf.da?.RE ?? '').replace(/\./g, '').trim().toUpperCase();
455
+ const rutMio = String(this.config.emisor.rut).replace(/\./g, '').trim().toUpperCase();
456
+ if (rutCaf !== rutMio) {
457
+ console.warn(
458
+ `[CertRunner] CAF previo del tipo ${tipoDte} DESCARTADO: es del RUT ${rutCaf}, ` +
459
+ `no del emisor ${rutMio} (${cafPath})`
460
+ );
461
+ return null;
462
+ }
463
+
464
+ const desde = caf.getFolioDesde();
465
+ const hasta = caf.getFolioHasta();
466
+ if (!Number.isFinite(desde) || !Number.isFinite(hasta)) return null;
467
+
468
+ // Cuántos folios de ese rango quedan libres según el contador de ESTA corrida.
469
+ // Si el helper ya consumió parte del rango, lo que queda es lo que sirve.
470
+ const yaUsados = this.folioHelper.usedFolios.get(tipoDte)?.size ?? 0;
471
+ const disponibles = (hasta - desde + 1) - yaUsados;
472
+ if (disponibles < cantidad) return null;
473
+
474
+ return { path: cafPath, desde, hasta };
475
+ } catch (err) {
476
+ // Un CAF ilegible no puede tumbar la corrida: se pide uno nuevo y listo.
477
+ console.warn(`[CertRunner] No se pudo evaluar el CAF previo del tipo ${tipoDte}: ${err.message}`);
478
+ return null;
479
+ }
480
+ }
481
+
265
482
  /**
266
483
  * Solicita CAFs frescos para los tipos especificados
267
484
  * @param {Object} cafRequired - { 33: 4, 56: 1, 61: 3 }
@@ -269,7 +486,11 @@ class CertRunner {
269
486
  */
270
487
  async solicitarCafs(cafRequired) {
271
488
  const cafs = {};
272
-
489
+ // Lo que el sondeo vio en ESTA llamada. Se reinicia para no arrastrar el estado del
490
+ // SII de una corrida anterior: el bloqueo de timbraje se destraba y se vuelve a
491
+ // trabar, así que un dato viejo mandaría a reobtener cuando ya no hace falta.
492
+ this._topeConsultado = {};
493
+
273
494
  // Limpiar contadores para nuevos CAFs
274
495
  this.folioHelper.counters.clear();
275
496
  this.folioHelper.usedFolios.clear();
@@ -302,14 +523,30 @@ class CertRunner {
302
523
  // El sondeo cuesta ~3 requests, no emite nada y responde la única pregunta
303
524
  // que importa: ¿el SII está racionando este tipo?
304
525
  const tope = await this.folioService.consultarTope({ tipoDte: Number(tipoDte) });
526
+ // Se guarda para el bucle de abajo: ahí se decide si conviene intentar la
527
+ // reobtención (solo tiene sentido con el timbraje bloqueado) y sondear de nuevo
528
+ // sería otro viaje al portal para saber algo que ya sabemos.
529
+ this._topeConsultado = { ...(this._topeConsultado ?? {}), [tipoDte]: tope };
305
530
  const necesarios = cafRequired[tipoDte];
306
531
 
307
532
  // `sinTope` = el SII ni siquiera publica MAX_AUTOR, o sea no está limitando
308
533
  // este tipo. Limpiar ahí es puro costo.
309
- // Con tope publicado se exige un margen de 3x sobre lo necesario, no apenas
310
- // lo justo: el racionamiento se endurece rápido y conviene actuar antes de
311
- // quedar contra la pared, que es el bloqueo duro del que cuesta salir.
312
- const holgado = tope.sinTope || (tope.maxAutor !== null && tope.maxAutor >= necesarios * 3);
534
+ //
535
+ // Con tope publicado alcanza con que el cupo CUBRA lo necesario. Antes se exigía
536
+ // un margen de 3x para "actuar antes de quedar contra la pared", pero medido el
537
+ // 13/08/2026 (RUT 78480527-1) ese margen disparaba la limpieza teniendo cupo de
538
+ // sobra —tipo 61 con MAX_AUTOR=6 para 3 folios, tipo 33 con 4 para 4— y en los
539
+ // tres tipos el resultado fue `0 anulados`: el SII rechazó cada anulación.
540
+ //
541
+ // O sea que la limpieza preventiva no compra nada. Y si algún día funcionara sería
542
+ // peor: el SII cuenta los folios anulados de los últimos 6 meses EN CONTRA del cupo
543
+ // (ver el comentario largo más abajo), así que anular de más acerca el bloqueo en
544
+ // vez de alejarlo. Se limpia solo cuando el cupo realmente no da.
545
+ // `bloqueado` gana sobre todo lo demás: el SII no autoriza nada para este tipo y
546
+ // no publica MAX_AUTOR, así que sin este chequeo `sinTope` daba true y se saltaba
547
+ // la limpieza — que es lo único que puede destrabarlo.
548
+ const holgado = !tope.bloqueado
549
+ && (tope.sinTope || (tope.maxAutor !== null && tope.maxAutor >= necesarios));
313
550
  if (holgado) {
314
551
  console.log(
315
552
  ` Tipo ${tipoDte}: sin limpieza previa — ` +
@@ -319,23 +556,44 @@ class CertRunner {
319
556
  );
320
557
  continue;
321
558
  }
322
-
559
+ // Un solo mensaje según el motivo: con el timbraje bloqueado el SII no publica
560
+ // MAX_AUTOR ni FOLIOS_DISP, así que el texto de racionamiento saldría con `null`
561
+ // en los dos números y confundiría a quien lea el log.
323
562
  console.warn(
324
- `[CertRunner] Tipo ${tipoDte}: MAX_AUTOR=${tope.maxAutor} ajustado para ${necesarios} ` +
325
- `folio(s) (FOLIOS_DISP=${tope.foliosDisp}) limpiando folios sin usar...`
563
+ tope.bloqueado
564
+ ? `[CertRunner] Tipo ${tipoDte}: el SII tiene el timbraje BLOQUEADO para este tipo ` +
565
+ `(no autoriza folios nuevos) — limpiando folios sin usar antes de pedir...`
566
+ : `[CertRunner] Tipo ${tipoDte}: MAX_AUTOR=${tope.maxAutor} ajustado para ${necesarios} ` +
567
+ `folio(s) (FOLIOS_DISP=${tope.foliosDisp}) — limpiando folios sin usar...`
326
568
  );
327
569
 
328
- // ACOTADA a propósito. Solo interesan los folios que ESTA certificación
329
- // timbró y dejó sin usar, que son de hoy. Sin el corte por antigüedad la
330
- // limpieza barre el historial completo de la empresa: en un RUT con
331
- // volumen real eso son cientos de anulaciones contra el SII y, peor, el
332
- // SII cuenta los folios anulados de los últimos 6 meses EN CONTRA del
333
- // cupo de timbraje la limpieza terminaría provocando el bloqueo que
334
- // intenta evitar. Verificado 2026-07-22: sin acotar anuló 296 folios de
335
- // un RUT (rango 1851–2909) antes de detenerla a mano.
570
+ // ACOTADA a propósito. Sin corte por antigüedad la limpieza barre el historial
571
+ // completo de la empresa: en un RUT con volumen real eso son cientos de
572
+ // anulaciones contra el SII y, peor, el SII cuenta los folios anulados de los
573
+ // últimos 6 meses EN CONTRA del cupo de timbraje la limpieza terminaría
574
+ // provocando el bloqueo que intenta evitar. Verificado 2026-07-22: sin acotar
575
+ // anuló 296 folios de un RUT (rango 1851–2909) antes de detenerla a mano.
576
+ //
577
+ // Pero la ventana no puede ser la misma en los dos ambientes:
578
+ //
579
+ // producción — el historial son documentos reales del comercio. Anular ahí es
580
+ // destructivo y el riesgo de arriba es el que manda: 1 día.
581
+ // maullin — TODO el historial es de pruebas, propias o de corridas viejas.
582
+ // Con 1 día quedaba un callejón sin salida: los folios que
583
+ // bloquean el timbraje suelen ser de semanas atrás, el filtro los
584
+ // descartaba antes de intentarlos, y la corrida se quedaba
585
+ // reintentando para siempre contra algo que nunca iba a cambiar.
586
+ // Caso real (14/08/2026, RUT 77967443-6): tipo 56 bloqueado por
587
+ // folios del 22-07, seis intentos idénticos, cero anulados.
588
+ //
589
+ // Ojo: esto es el ÚLTIMO recurso. Antes de llegar acá se intenta reusar el CAF
590
+ // previo (ver _cafReusable), que resuelve el mismo bloqueo sin anular nada y sin
591
+ // gastar el cupo de timbraje. Anular es para cuando el folio quedó autorizado
592
+ // pero su CAF ya no está en disco.
593
+ const DIAS_LIMPIEZA = this.ambiente === 'produccion' ? 1 : 180;
336
594
  const limpieza = await this.folioService.anularFolios({
337
595
  tipoDte: Number(tipoDte),
338
- soloUltimosDias: 1,
596
+ soloUltimosDias: DIAS_LIMPIEZA,
339
597
  maxRangos: 10,
340
598
  });
341
599
  if (limpieza.totalAnulados > 0) {
@@ -352,8 +610,64 @@ class CertRunner {
352
610
 
353
611
  for (const [tipoDte, cantidad] of Object.entries(cafRequired)) {
354
612
  emitProgress(STEPS.CAF_REQUESTING, { tipo: Number(tipoDte) });
613
+
614
+ // ── ¿Ya tenemos folios de un intento anterior? ──────────────────────────
615
+ //
616
+ // Cuando una etapa falla DESPUÉS de haber timbrado algunos tipos (típico: se cae en
617
+ // el último y se pierde todo), el reintento volvía a pedir todo de cero. Los folios
618
+ // del intento anterior quedaban timbrados y sin usar, y el SII cuenta exactamente eso
619
+ // para negar el timbraje: "usted tiene disponible una cantidad de folios suficiente".
620
+ //
621
+ // O sea que cada reintento empeoraba el bloqueo que intentaba superar. Medido el
622
+ // 14/08/2026 (RUT 77967443-6): 6 reintentos de ENVIAR_SETS quemaron 66 folios
623
+ // —tipos 33, 34, 46 y 52, seis rangos cada uno— sin emitir un solo documento,
624
+ // mientras el tipo 56 seguía bloqueado.
625
+ //
626
+ // Reusarlos es seguro: los sets se envían recién cuando TODOS los CAF están en mano,
627
+ // así que si el intento anterior falló, esos folios nunca se emitieron.
628
+ const previo = this._cafReusable(Number(tipoDte), Number(cantidad));
629
+ if (previo) {
630
+ cafs[tipoDte] = previo.path;
631
+ emitProgress(STEPS.CAF_OK, { tipo: Number(tipoDte) });
632
+ console.log(` ✓ CAF tipo ${tipoDte} reusado del intento anterior (folios ${previo.desde}-${previo.hasta})`);
633
+ continue;
634
+ }
635
+
355
636
  console.log(` Tipo ${tipoDte}: ${cantidad} folios...`);
356
637
 
638
+ // ── Con el timbraje bloqueado, recuperar antes que pedir ────────────────
639
+ //
640
+ // El SII bloquea el timbraje cuando el contribuyente ya tiene folios sin usar, y su
641
+ // propio mensaje da la salida: "debe emitir y enviar documentos electrónicos al SII
642
+ // o anular folios". Emitir es lo que levanta el bloqueo; anular lo AGRAVA, porque
643
+ // los folios anulados pesan 6 meses en contra del cupo.
644
+ //
645
+ // Para emitir hace falta el CAF de esos folios, y la reobtención del portal lo
646
+ // devuelve sin gastar cupo. Por eso va acá: después de reusar lo que hay en disco y
647
+ // antes de `solicitarCafExacto`, que pide folios nuevos y, si falla, anula.
648
+ //
649
+ // Solo se intenta con el timbraje bloqueado. Con cupo disponible pedir es más simple
650
+ // y no cuesta nada; la reobtención implica un request por rango para descartar los
651
+ // anulados, que el listado del SII no distingue.
652
+ if (this._topeConsultado?.[tipoDte]?.bloqueado) {
653
+ const reob = await this.folioService.reobtenerCaf({
654
+ tipoDte: Number(tipoDte), cantidad: Number(cantidad),
655
+ });
656
+ if (reob.ok) {
657
+ // Puede ser más de uno: el SII entrega los folios reobtenidos de a uno y cada
658
+ // CAF firma con su propia llave, así que se pasan todos y el set toma de cada
659
+ // uno según el folio (ver SetBase._tomarFolio).
660
+ cafs[tipoDte] = reob.cafPaths.length > 1 ? reob.cafPaths : reob.cafPaths[0];
661
+ emitProgress(STEPS.CAF_OK, { tipo: Number(tipoDte) });
662
+ console.log(
663
+ ` ✓ CAF tipo ${tipoDte} recuperado del SII: ${reob.cafPaths.length} CAF ` +
664
+ `de folios ya autorizados, sin gastar cupo`
665
+ );
666
+ continue;
667
+ }
668
+ console.warn(`[CertRunner] Tipo ${tipoDte}: reobtención no sirvió — ${reob.motivo}`);
669
+ }
670
+
357
671
  // solicitarCafExacto (no ConFallback): la simulación genera un plan fijo de
358
672
  // documentos y muere si falta un folio, así que un CAF corto no sirve. El
359
673
  // método aborta antes de emitir si el SII no autoriza lo suficiente, anula
@@ -442,7 +756,13 @@ class CertRunner {
442
756
 
443
757
  const cafRequired = setData.cafRequired ||
444
758
  (typeof cafFallback === 'function' ? cafFallback(setData) : cafFallback);
445
- const cafs = await this.solicitarCafs(cafRequired);
759
+
760
+ // Si ya se timbró el total de la corrida por adelantado (ver precargarCafsDeSets),
761
+ // este set reusa esos CAF en vez de pedir los suyos. Pedir acá volvería a partir el
762
+ // timbraje en tandas, que es justamente lo que gatilla el racionamiento del SII.
763
+ const cafs = this._cafsPrecargados
764
+ ? this._cafsDelPlan(cafRequired, ClaseSet.name)
765
+ : await this.solicitarCafs(cafRequired);
446
766
 
447
767
  const set = new ClaseSet({
448
768
  config: {
@@ -460,7 +780,23 @@ class CertRunner {
460
780
  enviador: this._createEnviador(enviadorNombre),
461
781
  });
462
782
 
463
- const resultado = await set.ejecutar(setData, cafs);
783
+ // Un folio NO se reutiliza nunca. Se marca el CAF apenas se intentó enviar, sin mirar
784
+ // si salió bien: si el envío viajó, el SII ya vio esos folios aunque después lo
785
+ // rechace, y reemitirlos invalida el envío entero.
786
+ //
787
+ // Hace falta marcarlo en DISCO porque el contador de folios vive en memoria y cada
788
+ // etapa corre en un proceso aparte. Sin esto, la etapa siguiente reusa el mismo CAF y
789
+ // empieza el rango de nuevo. Pasó el 14/08/2026: ENVIAR_SETS emitió los folios
790
+ // 4519-4522 del tipo 33 y SIMULACION reusó ese CAF reemitiéndolos — el SII devolvió
791
+ // `RFR - Rechazado por Error en Firma` para todo el envío de simulación.
792
+ //
793
+ // Quemar un folio de más es barato; repetirlo cuesta la etapa completa.
794
+ let resultado;
795
+ try {
796
+ resultado = await set.ejecutar(setData, cafs);
797
+ } finally {
798
+ this._marcarCafsConsumidos(cafs);
799
+ }
464
800
  this.resultados[resultadoKey] = resultado;
465
801
  return resultado;
466
802
  }
@@ -1548,6 +1884,29 @@ class CertRunner {
1548
1884
  return null;
1549
1885
  }
1550
1886
 
1887
+ /**
1888
+ * Pregunta al SII en qué estado quedó un envío, por su trackId.
1889
+ *
1890
+ * Es la única forma de distinguir "el SII todavía lo está procesando" de "el SII lo
1891
+ * rechazó": mirando el avance de la postulación los dos casos se ven igual, la etapa
1892
+ * simplemente no se mueve. Devuelve `null` si no se pudo consultar, para que quien
1893
+ * llama decida sin quedarse colgado de una consulta que falló.
1894
+ *
1895
+ * @param {string} trackId
1896
+ * @returns {Promise<{estado: string, glosa?: string, esRechazado: boolean, esExitoso: boolean} | null>}
1897
+ */
1898
+ async consultarEstadoEnvio(trackId) {
1899
+ if (!trackId) return null;
1900
+ try {
1901
+ const enviador = new EnviadorSII(this.certificado, this.ambiente);
1902
+ const r = await enviador.consultarEstadoSoap(trackId, this.config.emisor.rut);
1903
+ return r && r.estado ? r : null;
1904
+ } catch (e) {
1905
+ console.log(` [!] No se pudo consultar el estado del envío ${trackId}: ${e.message}`);
1906
+ return null;
1907
+ }
1908
+ }
1909
+
1551
1910
  /**
1552
1911
  * Crea un enviador de libros
1553
1912
  * @private
@@ -2018,7 +2377,13 @@ class CertRunner {
2018
2377
  // Enviar al SII
2019
2378
  console.log('\n Enviando al SII...');
2020
2379
  const enviador = this._createEnviador();
2021
- const resultado = await enviador.enviar(envioDte);
2380
+ let resultado;
2381
+ try {
2382
+ resultado = await enviador.enviar(envioDte);
2383
+ } finally {
2384
+ // Mismo criterio que en los sets: los folios de la simulación quedan quemados.
2385
+ this._marcarCafsConsumidos(cafs);
2386
+ }
2022
2387
 
2023
2388
  const result = {
2024
2389
  success: !!resultado?.trackId,
@@ -2237,8 +2602,11 @@ class CertRunner {
2237
2602
  console.log(`\n ¡SIMULACIÓN APROBADA! Etapa actual: ${etapaActual}`);
2238
2603
  return { success: true, etapa: etapaActual };
2239
2604
  } else if (esRechazado) {
2605
+ // `rechazado` distingue "el SII dijo que no" de "el SII todavía no dijo nada".
2606
+ // Sin esa marca, quien clasifica la etapa lo lee como espera y la reintenta en
2607
+ // loop, cuando lo que hay que hacer es corregir y reenviar.
2240
2608
  console.log(` [ERR] SIMULACIÓN: ${simEstado.estado}`);
2241
- return { success: false, error: 'Simulación rechazada' };
2609
+ return { success: false, rechazado: true, error: `Simulación rechazada por el SII: ${simEstado.estado}` };
2242
2610
  } else {
2243
2611
  console.log(` [...] SIMULACIÓN: ${simEstado.estado || 'EN REVISION'}`);
2244
2612
  }
@@ -2254,6 +2622,32 @@ class CertRunner {
2254
2622
  await sleep(intervalo);
2255
2623
  }
2256
2624
 
2625
+ // ⚠️ Que se agoten los intentos NO significa "el SII se está tomando su tiempo".
2626
+ // El portal tampoco avanza cuando el envío fue RECHAZADO, y ahí esperar más no
2627
+ // sirve de nada: hay que corregir y reenviar. Distinguir los dos casos mirando el
2628
+ // avance es imposible — en ambos la etapa se queda quieta. La única fuente de
2629
+ // verdad es el estado del envío, así que se le pregunta al SII por el trackId.
2630
+ //
2631
+ // Caso real (14/08/2026, RUT 78206276-K): la simulación quedó `RFR - Rechazado por
2632
+ // Error en Firma` y este método devolvió "Timeout esperando aprobación". Quien lo
2633
+ // llamaba lo leyó como "sigue en revisión", siguió adelante y marcó la etapa como
2634
+ // completada sobre un envío que el SII había rechazado.
2635
+ const trackIdSim = this.resultados.simulacion?.trackId;
2636
+ const envio = await this.consultarEstadoEnvio(trackIdSim);
2637
+ if (envio?.esRechazado) {
2638
+ const glosa = envio.glosa || envio.descripcion || envio.mensaje || envio.estado;
2639
+ console.log(`\n [ERR] El SII RECHAZÓ el envío de simulación (${envio.estado}): ${glosa}`);
2640
+ return {
2641
+ success: false,
2642
+ rechazado: true,
2643
+ estadoEnvio: envio.estado,
2644
+ error: `El SII rechazó el envío de simulación (${envio.estado}): ${glosa}`,
2645
+ };
2646
+ }
2647
+ if (envio?.estado) {
2648
+ console.log(`\n [...] Envío de simulación ${trackIdSim} en estado ${envio.estado} — el SII sigue procesando`);
2649
+ }
2650
+
2257
2651
  console.log('\n [!] Timeout esperando aprobación de simulación');
2258
2652
  return { success: false, error: 'Timeout esperando aprobación' };
2259
2653
  }
package/cert/SetBase.js CHANGED
@@ -214,10 +214,61 @@ class SetBase {
214
214
  cafs[tipoDte] = cafPath;
215
215
  this.logger.log(` ✓ CAF tipo ${tipoDte}: ${cafPath}`);
216
216
  }
217
-
217
+
218
218
  return cafs;
219
219
  }
220
220
 
221
+ /**
222
+ * Reserva el próximo folio y devuelve EL CAF QUE LO CONTIENE.
223
+ *
224
+ * `cafRef` acepta una ruta (comportamiento de siempre) o una lista de rutas. La lista
225
+ * hace falta porque el SII no siempre entrega un rango contiguo: al reobtener folios ya
226
+ * autorizados los devuelve de a uno (folios 1-1 y 3-3 como CAF separados, visto el
227
+ * 14/08/2026 en el RUT 77967443-6).
228
+ *
229
+ * Y no alcanza con concatenar numeraciones: **cada CAF trae su propia llave privada RSA**
230
+ * (`CAF.js` → `RSASK`) y el timbre de cada documento se firma con la llave del CAF que
231
+ * contiene ESE folio (`DTE.js` → `caf.sign(...)`). Por eso hay que devolver el par
232
+ * folio+CAF junto, y no solo el número: firmar el folio 3 con la llave del CAF del folio
233
+ * 1 produce un timbre inválido y el SII rechaza el envío completo con "Rechazado por
234
+ * Error en Firma".
235
+ *
236
+ * Antes este método estaba duplicado —idéntico, mismo hash— en SetBasico, SetGuia,
237
+ * SetExenta y SetCompra. Se unifica acá para que la lógica de qué llave firma qué
238
+ * documento viva en un solo lugar.
239
+ *
240
+ * @param {string|string[]} cafRef - ruta al CAF, o varias en orden de preferencia
241
+ * @returns {{ caf: Object, cafXml: string, folio: number }}
242
+ */
243
+ _tomarFolio(cafRef) {
244
+ const { CAF } = require('../index');
245
+ const fs = require('fs');
246
+ const rutas = Array.isArray(cafRef) ? cafRef : [cafRef];
247
+
248
+ let ultimoError = null;
249
+ for (const ruta of rutas) {
250
+ const cafXml = fs.readFileSync(ruta, 'utf8');
251
+ const caf = new CAF(cafXml);
252
+ try {
253
+ const folio = this.folioHelper.reserveNextFolio({
254
+ rutEmisor: this.config.emisor.rut,
255
+ tipoDte: caf.getTipoDTE(),
256
+ folioDesde: caf.getFolioDesde(),
257
+ folioHasta: caf.getFolioHasta(),
258
+ ambiente: this.config.ambiente || 'certificacion',
259
+ cafFingerprint: this.folioHelper.createCafFingerprint(cafXml),
260
+ });
261
+ return { caf, cafXml, folio };
262
+ } catch (err) {
263
+ // `reserveNextFolio` lanza cuando el rango se agotó: se pasa al CAF siguiente.
264
+ // Cualquier otro error sí es fatal y se propaga.
265
+ if (!/No hay más folios disponibles/.test(err.message)) throw err;
266
+ ultimoError = err;
267
+ }
268
+ }
269
+ throw ultimoError ?? new Error('No se recibió ningún CAF para reservar folio');
270
+ }
271
+
221
272
  /**
222
273
  * Calcula cuántos folios se necesitan para un tipo de DTE
223
274
  * Subclases pueden sobrescribir para lógica específica
package/cert/SetBasico.js CHANGED
@@ -104,11 +104,9 @@ class SetBasico extends SetBase {
104
104
  const fs = require('fs');
105
105
 
106
106
  // Cargar CAF
107
- const cafXml = fs.readFileSync(cafPath, 'utf8');
108
- const caf = new CAF(cafXml);
107
+ const { caf, cafXml, folio } = this._tomarFolio(cafPath);
109
108
 
110
109
  // Reservar folio
111
- const folio = this._reservarFolio(caf, cafXml);
112
110
 
113
111
  // Construir detalle e items
114
112
  const detalle = buildDetalle(caso.items);
@@ -180,11 +178,9 @@ class SetBasico extends SetBase {
180
178
  }
181
179
 
182
180
  // Cargar CAF
183
- const cafXml = fs.readFileSync(cafPath, 'utf8');
184
- const caf = new CAF(cafXml);
181
+ const { caf, cafXml, folio } = this._tomarFolio(cafPath);
185
182
 
186
183
  // Reservar folio
187
- const folio = this._reservarFolio(caf, cafXml);
188
184
 
189
185
  // Determinar items a usar
190
186
  let items = caso.itemsFromCaso
@@ -267,11 +263,9 @@ class SetBasico extends SetBase {
267
263
  }
268
264
 
269
265
  // Cargar CAF
270
- const cafXml = fs.readFileSync(cafPath, 'utf8');
271
- const caf = new CAF(cafXml);
266
+ const { caf, cafXml, folio } = this._tomarFolio(cafPath);
272
267
 
273
268
  // Reservar folio
274
- const folio = this._reservarFolio(caf, cafXml);
275
269
 
276
270
  // Items
277
271
  const itemsFinal = caso.items || [{ nombre: 'SIN MONTO', cantidad: 1, precio: 0 }];
@@ -322,18 +316,6 @@ class SetBasico extends SetBase {
322
316
  * Compatible con folioHelper de cert-base
323
317
  * @private
324
318
  */
325
- _reservarFolio(caf, cafXml) {
326
- const cafFingerprint = this.folioHelper.createCafFingerprint(cafXml);
327
- const folio = this.folioHelper.reserveNextFolio({
328
- rutEmisor: this.config.emisor.rut,
329
- tipoDte: caf.getTipoDTE(),
330
- folioDesde: caf.getFolioDesde(),
331
- folioHasta: caf.getFolioHasta(),
332
- ambiente: this.config.ambiente || 'certificacion',
333
- cafFingerprint,
334
- });
335
- return folio;
336
- }
337
319
 
338
320
  /**
339
321
  * Obtiene la fecha de emisión en formato YYYY-MM-DD