@devlas/dte-sii 2.12.22 → 2.12.24

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
@@ -187,17 +187,78 @@ class CafSolicitor {
187
187
  );
188
188
  }
189
189
 
190
+ /**
191
+ * Detecta la página de bloqueo duro de timbraje del SII.
192
+ *
193
+ * El SII usa al menos DOS redacciones para la misma página de rechazo:
194
+ * - "NO SE AUTORIZA TIMBRAJE ELECTRÓNICO" (la documentada en PLAN-MEJORAS-CAF.md)
195
+ * - "NO AUTORIZA TIMBRAJE ELECTRÓNICA" (observada 2026-07-22 en tipo 56)
196
+ *
197
+ * Detectar solo la primera hacía que la segunda cayera al genérico
198
+ * `UNKNOWN: No se obtuvo CAF en la respuesta`, sin disparar el remedio de
199
+ * anular folios — que es justamente lo que la página pide hacer. Ambas
200
+ * variantes carecen de <form> e <input>, así que sin esta detección el flujo
201
+ * sigue de largo hasta fallar sin diagnóstico.
202
+ *
203
+ * @param {string} html - Cuerpo de la respuesta del SII
204
+ * @returns {boolean}
205
+ */
206
+ /**
207
+ * Campos de tipo de documento del formulario de postulación (`pe_confirma`),
208
+ * verificados contra el form real del SII el 2026-07-22.
209
+ *
210
+ * El listado original omitía `CONIVA`, `SET11`, `BOLELE` y `BOLEXE`, que el
211
+ * SII sí incluye. Se exportan acá para que el consumidor no tenga que
212
+ * mantener la lista duplicada.
213
+ */
214
+ static get CAMPOS_POSTULACION() {
215
+ return {
216
+ // Factura electrónica
217
+ ESFAC: 'S', // habilita la sección factura
218
+ FACT: 'S', // Facturas
219
+ CONIVA: 'S', // con IVA
220
+ NC: 'S', // Notas de crédito
221
+ ND: 'S', // Notas de débito
222
+ SET03: 'S', // Guías de despacho
223
+ SET06: 'S', // Facturas exentas
224
+ SET72: 'S', // Factura de compra
225
+ // Boleta electrónica
226
+ ESBOL: 'S', // habilita la sección boleta
227
+ BOLELE: 'S',
228
+ BOLELEC: 'S', // Boleta electrónica
229
+ BOLEXE: 'S',
230
+ BOLEXEN: 'S', // Boleta exenta electrónica
231
+ // NO se marcan por defecto:
232
+ // SET11 → Documentos de exportación (requiere trámite aduanero aparte)
233
+ // SET84 → Liquidación factura electrónica (caso de uso específico)
234
+ };
235
+ }
236
+
237
+ static esBloqueoTimbraje(html) {
238
+ return /NO\s+(SE\s+)?AUTORIZA\s+TIMBRAJE/i.test(String(html || ''));
239
+ }
240
+
190
241
  /**
191
242
  * Solicita un CAF al SII
192
243
  * @param {Object} params - Parámetros
193
244
  * @param {number} params.tipoDte - Tipo de DTE (33, 34, 39, 56, 61, etc.)
194
245
  * @param {number} [params.cantidad=1] - Cantidad de folios a solicitar
195
- * @returns {Promise<Object>} - { success, cafPath, xml, error }
246
+ * @param {number} [params.minCantidad] - Mínimo aceptable. Si el SII declara un
247
+ * MAX_AUTOR menor, se aborta ANTES de emitir en vez de aceptar un rango corto
248
+ * (ver `_processStep3`). Útil para flujos que necesitan N folios exactos, como
249
+ * la simulación de certificación.
250
+ * @returns {Promise<Object>} - { success, cafPath, xml, maxAutor, foliosDisp, error, errorCode }
196
251
  */
197
- async solicitar({ tipoDte, cantidad = 1 }) {
252
+ async solicitar({ tipoDte, cantidad = 1, minCantidad = null }) {
198
253
  const { numero: rut, dv } = splitRut(this.rutEmisor);
199
254
  const debugDir = this._getDebugDir(tipoDte);
200
255
 
256
+ // Estado por-solicitud leído desde el form del SII en _processStep3
257
+ this._lastMaxAutor = null;
258
+ this._lastFoliosDisp = null;
259
+ this._maxAutorInsuficiente = false;
260
+ this._minCantidad = minCantidad;
261
+
201
262
  // Rate limiting: mínimo 1001ms entre solicitudes para no saturar el portal SII.
202
263
  const _now = Date.now();
203
264
  const _elapsed = _now - CafSolicitor._lastSolicitudAt;
@@ -272,13 +333,34 @@ class CafSolicitor {
272
333
  return { success: false, errorCode: 'WAAP_BLOCKED', error: 'IP bloqueada por el firewall del SII. Espera antes de reintentar.' };
273
334
  }
274
335
 
336
+ // Abort temprano por MAX_AUTOR insuficiente (ver _processStep3). Se chequea antes
337
+ // que el resto porque en este caso no se llegó a emitir nada: el body es la página
338
+ // del paso previo, sin <AUTORIZACION> ni marcador de error propio.
339
+ if (this._maxAutorInsuficiente) {
340
+ return {
341
+ success: false,
342
+ errorCode: 'MAX_AUTOR_INSUFICIENTE',
343
+ maxAutor: this._lastMaxAutor,
344
+ foliosDisp: this._lastFoliosDisp,
345
+ error: `SII: autoriza como máximo ${this._lastMaxAutor} folio(s) para el tipo ${tipoDte} ` +
346
+ `y se requieren ${minCantidad} (FOLIOS_DISP=${this._lastFoliosDisp}). ` +
347
+ `Ocurre cuando hay folios timbrados sin utilizar — anúlalos o emite documentos de este tipo.`,
348
+ };
349
+ }
350
+
275
351
  // Verificar si obtuvimos el CAF
276
352
  if (response.body && response.body.includes('<AUTORIZACION')) {
277
353
  const cafPath = this._saveCafOrganized(response.body, tipoDte);
278
- return { success: true, cafPath, xml: response.body, maxAutor: this._lastMaxAutor ?? cantidad };
354
+ return {
355
+ success: true,
356
+ cafPath,
357
+ xml: response.body,
358
+ maxAutor: this._lastMaxAutor ?? cantidad,
359
+ foliosDisp: this._lastFoliosDisp,
360
+ };
279
361
  }
280
362
 
281
- if (response.body && response.body.includes('NO SE AUTORIZA')) {
363
+ if (response.body && CafSolicitor.esBloqueoTimbraje(response.body)) {
282
364
  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.' };
283
365
  }
284
366
 
@@ -301,6 +383,26 @@ class CafSolicitor {
301
383
  // (ej: ya hay un timbraje del mismo día que consume parte del cupo diario).
302
384
  // Solución: reintentar con cantidad=1 para garantizar obtener al menos 1 folio.
303
385
  if (response.body && response.body.includes('menor o igual al m')) {
386
+ // Con `minCantidad` el llamador necesita N folios exactos: caer a 1 le
387
+ // entrega un CAF inservible Y suma +1 a FOLIOS_DISP (el folio queda sin
388
+ // usar), agravando el racionamiento en el próximo intento. Este retry
389
+ // preexistente bypaseaba el chequeo temprano de _processStep3, que solo
390
+ // cubre el MAX_AUTOR declarado en el formulario — no el efectivo, que el
391
+ // SII recién aplica al recibir el submit.
392
+ if (minCantidad && minCantidad > 1) {
393
+ console.warn(
394
+ `[CafSolicitor] Tipo ${tipoDte}: MAX_AUTOR efectivo insuficiente para ${cantidad} folios ` +
395
+ `y se requieren al menos ${minCantidad} — no se cae a 1 folio.`
396
+ );
397
+ return {
398
+ success: false,
399
+ errorCode: 'MAX_AUTOR_INSUFICIENTE',
400
+ maxAutor: this._lastMaxAutor,
401
+ foliosDisp: this._lastFoliosDisp,
402
+ error: `SII rechazó ${cantidad} folios para el tipo ${tipoDte} por exceder el máximo autorizado, ` +
403
+ `y se requieren al menos ${minCantidad}. Ocurre cuando hay folios timbrados sin utilizar.`,
404
+ };
405
+ }
304
406
  if (cantidad > 1) {
305
407
  console.warn(`[CafSolicitor] MAX_AUTOR excedido para ${cantidad} folios — reintentando con 1 folio...`);
306
408
  // No limpiar cookieJar: el error es del formulario, no de la sesión.
@@ -436,7 +538,7 @@ class CafSolicitor {
436
538
 
437
539
  // Rechazo duro antes del check de COD_DOCTO: la página de rechazo también contiene
438
540
  // "COD_DOCTO" en su JavaScript, lo que causaría un POST innecesario con datos vacíos.
439
- if (currentHtml.includes('NO SE AUTORIZA')) {
541
+ if (CafSolicitor.esBloqueoTimbraje(currentHtml)) {
440
542
  return response; // solicitar() detectará el bloqueo en response.body
441
543
  }
442
544
 
@@ -459,7 +561,7 @@ class CafSolicitor {
459
561
  currentHtml = response.body || '';
460
562
  this._saveDebug(debugDir, 'select.html', currentHtml);
461
563
 
462
- if (currentHtml.includes('NO SE AUTORIZA')) {
564
+ if (CafSolicitor.esBloqueoTimbraje(currentHtml)) {
463
565
  return response; // solicitar() detectará el bloqueo en response.body
464
566
  }
465
567
  }
@@ -487,9 +589,32 @@ class CafSolicitor {
487
589
 
488
590
  // CANT_DOCTOS debe enviarse con un valor <= MAX_AUTOR.
489
591
  // Si se omite o excede MAX_AUTOR, el SII rechaza devolviendo la página de inicio (rechazo silencioso).
592
+ //
593
+ // MAX_AUTOR/FOLIOS_DISP solo aparecen cuando el SII está LIMITANDO este tipo de
594
+ // documento (confirmado experimentalmente 2026-07-22: tras anular folios, los tipos
595
+ // 56 y 61 dejaron de exponerlos). Su ausencia significa "sin tope" → no se recorta.
596
+ const tieneMaxAutor =
597
+ inputs3.MAX_AUTOR !== undefined && inputs3.MAX_AUTOR !== '';
490
598
  const maxAutor = parseInt(inputs3.MAX_AUTOR || String(cantidad), 10);
599
+ const foliosDisp = parseInt(inputs3.FOLIOS_DISP || '0', 10);
491
600
  const cantReal = Math.min(cantidad, maxAutor);
492
601
  this._lastMaxAutor = maxAutor; // guardado para retornarlo desde solicitar()
602
+ this._lastFoliosDisp = tieneMaxAutor ? foliosDisp : null;
603
+
604
+ // El SII raciona MAX_AUTOR según los folios timbrados sin utilizar (FOLIOS_DISP):
605
+ // con FOLIOS_DISP=0 autoriza 100, con 2 ya baja a 1, y si sigue subiendo pasa a
606
+ // bloqueo duro ("NO SE AUTORIZA"). Aceptar un rango corto es contraproducente para
607
+ // quien necesita N folios exactos (ej. simulación de certificación): el CAF chico
608
+ // no se usa, suma +1 a FOLIOS_DISP y empeora el tope en el próximo intento.
609
+ // Por eso, si el llamador declaró un mínimo, se aborta ANTES de emitir.
610
+ if (tieneMaxAutor && this._minCantidad && maxAutor < this._minCantidad) {
611
+ this._maxAutorInsuficiente = true;
612
+ console.warn(
613
+ `[CafSolicitor] Tipo ${tipoDte}: MAX_AUTOR=${maxAutor} < mínimo requerido ${this._minCantidad} ` +
614
+ `(FOLIOS_DISP=${foliosDisp}) — abortando sin emitir para no agravar el racionamiento.`
615
+ );
616
+ return response;
617
+ }
493
618
 
494
619
  const step3Fields = {
495
620
  ...inputs3,
package/FolioService.js CHANGED
@@ -213,7 +213,10 @@ class FolioService {
213
213
  try {
214
214
  await this.solicitarCaf({ tipoDte, cantidad });
215
215
  } catch (error) {
216
- // Continuar con fallback
216
+ // Continuar con fallback, pero dejar rastro: sin este log era imposible
217
+ // diagnosticar por qué aparecían CAFs de 1 folio (el fallback de más abajo)
218
+ // en vez del rango pedido.
219
+ console.warn(`[FolioService] solicitarCaf tipo ${tipoDte} x${cantidad} falló: ${error.message} — probando fallback`);
217
220
  }
218
221
 
219
222
  let resolvedPath = this.findLatestCaf(tipoDte);
@@ -247,6 +250,147 @@ class FolioService {
247
250
  return resolvedPath;
248
251
  }
249
252
 
253
+ /**
254
+ * Cuenta los folios de un CAF leyendo su rango <D>/<H>.
255
+ * @private
256
+ * @returns {number} Cantidad de folios, o 0 si no se pudo parsear
257
+ */
258
+ _contarFoliosCaf(cafPath) {
259
+ try {
260
+ const xml = fs.readFileSync(cafPath, 'utf8');
261
+ const d = xml.match(/<D>(\d+)<\/D>/);
262
+ const h = xml.match(/<H>(\d+)<\/H>/);
263
+ if (!d || !h) return 0;
264
+ return Number(h[1]) - Number(d[1]) + 1;
265
+ } catch (_) {
266
+ return 0;
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Solicita un CAF garantizando una cantidad EXACTA de folios.
272
+ *
273
+ * A diferencia de `solicitarCafConFallback` — que acepta lo que el SII dé y
274
+ * cae a 1 folio como último recurso, algo razonable para reponer folios de
275
+ * emisión en runtime — este método sirve a flujos que necesitan N folios
276
+ * exactos en una sola pasada (ej. la simulación de certificación, que genera
277
+ * un plan fijo de documentos y muere si falta un folio).
278
+ *
279
+ * El SII raciona vía MAX_AUTOR según los folios timbrados sin utilizar
280
+ * (FOLIOS_DISP), y si se acumulan pasa a bloqueo duro. Anularlos revierte
281
+ * ambos estados — verificado contra maullin el 2026-07-22: anular 6 folios
282
+ * llevó FOLIOS_DISP 6→0 y MAX_AUTOR 1→5, y levantó los bloqueos duros de los
283
+ * tipos 56 y 61. Por eso acá se anula y se reintenta una vez.
284
+ *
285
+ * @param {Object} params
286
+ * @param {number} params.tipoDte - Tipo de DTE
287
+ * @param {number} params.cantidad - Folios requeridos (mínimo aceptable)
288
+ * @param {boolean} [params.permitirAnular=true] - Si puede anular folios sin
289
+ * usar para destrabar el tope. Ponerlo en false lo deja en modo consulta.
290
+ * @returns {Promise<Object>} { ok, cafPath, otorgados, maxAutor, foliosDisp, errorCode, error }
291
+ */
292
+ async solicitarCafExacto({ tipoDte, cantidad, permitirAnular = true }) {
293
+ if (!this.cafSolicitor) {
294
+ throw new Error('FolioService: CafSolicitor no inicializado (se requiere pfxPath y pfxPassword)');
295
+ }
296
+
297
+ const pedir = () =>
298
+ this.cafSolicitor.solicitar({ tipoDte, cantidad, minCantidad: cantidad });
299
+
300
+ let result = await pedir();
301
+
302
+ // Ambos códigos tienen la misma causa raíz (folios sin utilizar acumulados)
303
+ // y el mismo remedio. MAX_AUTOR_INSUFICIENTE aborta antes de emitir;
304
+ // TIMBRAJE_BLOQUEADO es el estado terminal al que se llega si se ignora.
305
+ const recuperable =
306
+ result.errorCode === 'MAX_AUTOR_INSUFICIENTE' ||
307
+ result.errorCode === 'TIMBRAJE_BLOQUEADO';
308
+
309
+ if (!result.success && recuperable && permitirAnular) {
310
+ console.warn(
311
+ `[FolioService] Tipo ${tipoDte}: ${result.errorCode} — anulando folios sin utilizar y reintentando...`
312
+ );
313
+ try {
314
+ // Acotado: acá sí interesan los folios viejos (son los que inflan
315
+ // FOLIOS_DISP), pero sin barrer el historial entero — anular de a
316
+ // cientos satura al SII y suma al contador de "anulados últimos 6
317
+ // meses", que también juega en contra del cupo.
318
+ const anul = await this.anularFolios({ tipoDte, maxRangos: 20 });
319
+
320
+ // Desglosar los motivos reales del rechazo: no todos son "ya recepcionado"
321
+ // (folio consumido en un DTE enviado). También caen acá los folios ya
322
+ // anulados en una pasada anterior, que el SII sigue listando como
323
+ // candidatos pero rechaza al reintentar.
324
+ const motivos = (anul.rechazados || []).reduce((acc, r) => {
325
+ const k = r.reason || 'sin motivo';
326
+ acc[k] = (acc[k] || 0) + (r.count || 1);
327
+ return acc;
328
+ }, {});
329
+ const detalleMotivos = Object.entries(motivos)
330
+ .map(([k, v]) => `${v} ${k}`)
331
+ .join(', ');
332
+ console.log(
333
+ `[FolioService] Tipo ${tipoDte}: ${anul.totalAnulados} folio(s) anulado(s), ` +
334
+ `${anul.totalRechazados} rechazado(s)${detalleMotivos ? ` (${detalleMotivos})` : ''}`
335
+ );
336
+
337
+ if (anul.totalAnulados === 0) {
338
+ // Sin folios liberados, el tope del SII no se movió: reintentar es un
339
+ // request garantizado a fallar. Se corta acá con un diagnóstico preciso.
340
+ return {
341
+ ok: false,
342
+ cafPath: null,
343
+ otorgados: 0,
344
+ maxAutor: result.maxAutor ?? null,
345
+ foliosDisp: result.foliosDisp ?? null,
346
+ errorCode: 'SIN_FOLIOS_ANULABLES',
347
+ error:
348
+ `El SII tiene bloqueado el timbraje del tipo ${tipoDte} y no quedan folios anulables ` +
349
+ `(${anul.totalRechazados} rechazado(s)${detalleMotivos ? `: ${detalleMotivos}` : ''}). ` +
350
+ `Para destrabarlo hay que emitir y enviar al SII documentos de este tipo con los folios ya timbrados, ` +
351
+ `o esperar: el SII recalcula el tope según la emisión y las anulaciones de los últimos 6 meses.`,
352
+ };
353
+ }
354
+
355
+ result = await pedir();
356
+ } catch (err) {
357
+ console.warn(`[FolioService] Tipo ${tipoDte}: anulación falló: ${err.message}`);
358
+ }
359
+ }
360
+
361
+ const base = {
362
+ maxAutor: result.maxAutor ?? null,
363
+ foliosDisp: result.foliosDisp ?? null,
364
+ };
365
+
366
+ if (!result.success) {
367
+ return {
368
+ ok: false,
369
+ cafPath: null,
370
+ otorgados: 0,
371
+ ...base,
372
+ errorCode: result.errorCode || 'UNKNOWN',
373
+ error: result.error || `No se pudo obtener CAF para tipo ${tipoDte}`,
374
+ };
375
+ }
376
+
377
+ // Defensa final: aunque MAX_AUTOR haya dado el visto bueno, verificar que el
378
+ // rango realmente alcance antes de dárselo por bueno al llamador.
379
+ const otorgados = this._contarFoliosCaf(result.cafPath);
380
+ if (otorgados < Number(cantidad)) {
381
+ return {
382
+ ok: false,
383
+ cafPath: result.cafPath,
384
+ otorgados,
385
+ ...base,
386
+ errorCode: 'FOLIOS_INSUFICIENTES',
387
+ error: `SII otorgó ${otorgados} folio(s) de ${cantidad} requeridos para el tipo ${tipoDte}.`,
388
+ };
389
+ }
390
+
391
+ return { ok: true, cafPath: result.cafPath, otorgados, ...base };
392
+ }
393
+
250
394
  /**
251
395
  * Consulta el estado de folios en el SII
252
396
  * @param {Object} params - Parámetros
@@ -360,7 +504,53 @@ class FolioService {
360
504
  * @param {string} [params.motivo]
361
505
  * @returns {Promise<{ok:boolean, anulados:Array, rechazados:Array, totalAnulados:number, totalRechazados:number}>}
362
506
  */
363
- async anularFolios({ tipoDte, folioDesde = null, folioHasta = null, motivo = 'Folios no utilizados', maxRangos = 50 }) {
507
+ /**
508
+ * @param {number} [params.soloUltimosDias] - Si se indica, solo se anulan los
509
+ * rangos timbrados dentro de esos días. Sirve para limpiar los sobrantes de
510
+ * una corrida reciente sin tocar el historial de la empresa: sin este filtro
511
+ * la función barre TODOS los rangos sin utilizar, lo que en un RUT con
512
+ * volumen real significa cientos de anulaciones contra el SII — y, peor, el
513
+ * propio SII cuenta los folios anulados de los últimos 6 meses en contra del
514
+ * cupo de timbraje. Verificado 2026-07-22: una limpieza sin acotar anuló 296
515
+ * folios de un RUT antes de detenerla.
516
+ */
517
+ /**
518
+ * Ruta del registro de rangos ya anulados, por RUT y tipo de DTE.
519
+ *
520
+ * El SII sigue listando un folio anulado como "sin utilizar" —anulado es, en
521
+ * efecto, sin usar— así que `consultarFolios` lo devuelve para siempre y cada
522
+ * limpieza vuelve a intentarlo. Sin memoria entre corridas eso son
523
+ * round-trips al SII que solo pueden fallar, y peor: van comiendo el cupo de
524
+ * `maxRangos`, hasta dejar fuera a los sobrantes que sí hay que anular.
525
+ */
526
+ _anuladosPath(tipoDte) {
527
+ const rutLimpio = String(this.rutEmisor || '').replace(/[^0-9kK]/g, '');
528
+ return path.join(this.debugDir, `folios-anulados-${rutLimpio}-${tipoDte}.json`);
529
+ }
530
+
531
+ /** Set de claves "desde-hasta" que el SII ya reportó como anuladas. */
532
+ _cargarAnulados(tipoDte) {
533
+ try {
534
+ const raw = fs.readFileSync(this._anuladosPath(tipoDte), 'utf8');
535
+ const arr = JSON.parse(raw);
536
+ return new Set(Array.isArray(arr) ? arr : []);
537
+ } catch (_) {
538
+ // Sin registro previo (o ilegible): se parte de cero. Nunca es fatal —
539
+ // como mucho se repite el intento una vez más.
540
+ return new Set();
541
+ }
542
+ }
543
+
544
+ _guardarAnulados(tipoDte, set) {
545
+ try {
546
+ fs.mkdirSync(this.debugDir, { recursive: true });
547
+ fs.writeFileSync(this._anuladosPath(tipoDte), JSON.stringify([...set]), 'utf8');
548
+ } catch (err) {
549
+ console.warn(`[FolioService] No se pudo persistir registro de anulados: ${err.message}`);
550
+ }
551
+ }
552
+
553
+ async anularFolios({ tipoDte, folioDesde = null, folioHasta = null, motivo = 'Folios no utilizados', maxRangos = 50, soloUltimosDias = null }) {
364
554
  const debugStampA = new Date().toISOString().replace(/[:.]/g, '-');
365
555
  const debugDirA = path.join(this.debugDir, 'auto-caf', 'anulacion', debugStampA);
366
556
  fs.mkdirSync(debugDirA, { recursive: true });
@@ -369,18 +559,38 @@ class FolioService {
369
559
  const anulados = [];
370
560
  const rechazados = [];
371
561
  const vistos = new Set(); // claves "folioDesde-folioHasta" ya procesadas en esta ejecución
562
+ // Rangos que el SII ya reportó como anulados en corridas anteriores: se
563
+ // saltan de entrada para no gastar el cupo de `maxRangos` en reintentos
564
+ // que solo pueden volver a fallar. Ver `_anuladosPath`.
565
+ const yaAnulados = this._cargarAnulados(tipoDte);
566
+ const yaAnuladosInicial = yaAnulados.size;
372
567
 
373
568
  const maxPasadas = 4;
374
569
 
375
570
  for (let pasada = 0; pasada < maxPasadas; pasada++) {
376
571
  const consulta = await this.consultarFolios({ tipoDte });
377
572
 
573
+ // Corte por antigüedad: `fecha` viene como DD-MM-AAAA en cada rango.
574
+ // Un rango sin fecha parseable se descarta cuando el filtro está activo —
575
+ // ante la duda, no anular.
576
+ const limiteMs = Number.isFinite(soloUltimosDias)
577
+ ? Date.now() - soloUltimosDias * 24 * 60 * 60 * 1000
578
+ : null;
579
+
378
580
  // Filtrar rangos dentro del rango solicitado y que no hayamos intentado aún
379
581
  let rangos = consulta.ranges.filter(r => {
380
582
  if (Number.isFinite(folioDesde) && Number.isFinite(folioHasta)) {
381
583
  if (r.folioDesde > folioHasta || r.folioHasta < folioDesde) return false;
382
584
  }
383
- return !vistos.has(`${r.folioDesde}-${r.folioHasta}`);
585
+ if (limiteMs !== null) {
586
+ const { dia, mes, ano } = this._parseFecha(r.fecha);
587
+ if (!dia || !mes || !ano) return false;
588
+ const t = new Date(`${ano}-${mes}-${dia}T00:00:00`).getTime();
589
+ if (!Number.isFinite(t) || t < limiteMs) return false;
590
+ }
591
+ const clave = `${r.folioDesde}-${r.folioHasta}`;
592
+ if (yaAnulados.has(clave)) return false;
593
+ return !vistos.has(clave);
384
594
  });
385
595
 
386
596
  // Limitar a los más recientes para evitar operaciones masivas
@@ -425,9 +635,17 @@ class FolioService {
425
635
  continue;
426
636
  }
427
637
 
638
+ // El SII responde "ha sido anulado anteriormente" (no "efectuado"), variante
639
+ // que las tres cadenas originales no cubrían: el rango caía al POST de
640
+ // af_anular con inputs vacíos y volvía una página de error con "Error 500",
641
+ // registrándose como fallo genérico en vez de "ya anulado". Verificado
642
+ // 2026-07-22 contra maullin, tipo 56 folios 2/4/5/6.
428
643
  if (body3.includes('ya ha sido efectuado') || body3.includes('ya fue anulado') ||
429
- body3.includes('efectuado anteriormente')) {
644
+ body3.includes('efectuado anteriormente') ||
645
+ /anulad[oa]\s+anteriormente/i.test(body3)) {
430
646
  rechazados.push({ folioDesde: iniA, folioHasta: finA, count, reason: 'ya-anulado' });
647
+ // Se recuerda para que la próxima corrida no lo reintente.
648
+ yaAnulados.add(`${iniA}-${finA}`);
431
649
  console.warn(`[FolioService] ✗ Rango ${iniA}-${finA}: ya anulado (af_anular3)`);
432
650
  try { fs.writeFileSync(path.join(debugDirA, `rango-${iniA}-${finA}-af_anular3-error.html`), body3, 'utf8'); } catch (_) {}
433
651
  continue;
@@ -457,6 +675,11 @@ class FolioService {
457
675
  bodyBulk.includes('SOLICITUD ANULACION DE FOLIOS');
458
676
  if (exitoBulk) {
459
677
  anulados.push({ folioDesde: iniA, folioHasta: finA, count });
678
+ // Un rango recién anulado sigue apareciendo en `consultarFolios` como
679
+ // "sin utilizar": si no se recuerda acá, la próxima corrida lo
680
+ // reintenta y el SII contesta "ya anulado". Ésta es la fuente
681
+ // principal de los rangos zombis.
682
+ yaAnulados.add(`${iniA}-${finA}`);
460
683
  try { fs.writeFileSync(path.join(debugDirA, `rango-${iniA}-${finA}-af_anular-ok.html`), bodyBulk, 'utf8'); } catch (_) {}
461
684
  console.log(`[FolioService] ✓ Rango ${iniA}-${finA} (${count} folios) anulado en bulk`);
462
685
  continue;
@@ -497,6 +720,7 @@ class FolioService {
497
720
  const ok = bs.includes('ha autorizado la anulaci') || bs.includes('SOLICITUD ANULACION DE FOLIOS');
498
721
  if (ok) {
499
722
  anulados.push({ folioDesde: folio, folioHasta: folio, count: 1 });
723
+ yaAnulados.add(`${folio}-${folio}`);
500
724
  } else {
501
725
  const razon = this._parseAnulacionResult(bs).reason || 'error';
502
726
  rechazados.push({ folioDesde: folio, folioHasta: folio, count: 1, reason: razon });
@@ -509,9 +733,16 @@ class FolioService {
509
733
  if (pasada < maxPasadas - 1) await this._sleep(1500);
510
734
  }
511
735
 
736
+ if (yaAnulados.size !== yaAnuladosInicial) {
737
+ this._guardarAnulados(tipoDte, yaAnulados);
738
+ }
739
+
512
740
  const totalAnulados = anulados.reduce((s, r) => s + r.count, 0);
513
741
  const totalRechazados = rechazados.reduce((s, r) => s + r.count, 0);
514
- console.log(`[FolioService] Completado: ${totalAnulados} anulados, ${totalRechazados} rechazados`);
742
+ console.log(
743
+ `[FolioService] Completado: ${totalAnulados} anulados, ${totalRechazados} rechazados` +
744
+ (yaAnulados.size ? ` (${yaAnulados.size} rango(s) en memoria, se omiten en la próxima corrida)` : '')
745
+ );
515
746
 
516
747
  return { ok: true, anulados, rechazados, totalAnulados, totalRechazados };
517
748
  }
@@ -1553,7 +1553,20 @@ class SiiCertificacion {
1553
1553
 
1554
1554
  if (algunoRechazado) {
1555
1555
  emitProgress(STEPS.SETS_REJECTED);
1556
- return { success: false, error: 'Sets rechazados', estados: estadosRelevantes };
1556
+ // El detalle va en el error, no solo en `estados`: el llamador suele
1557
+ // interpolar `error` en el mensaje que ve el usuario, y un literal
1558
+ // "Sets rechazados" rendía el inútil "Sets rechazados: Sets rechazados"
1559
+ // sin decir cuál set ni con qué estado. Se nombran solo los rechazados
1560
+ // para no ahogar el dato entre los que sí pasaron.
1561
+ const detalle = Object.values(estadosRelevantes)
1562
+ .filter(e => e.esRechazado)
1563
+ .map(e => `${e.nombre}: ${e.estado}`)
1564
+ .join('; ');
1565
+ return {
1566
+ success: false,
1567
+ error: detalle || 'el SII rechazó uno o más sets',
1568
+ estados: estadosRelevantes,
1569
+ };
1557
1570
  }
1558
1571
  }
1559
1572
 
package/SiiSession.js CHANGED
@@ -620,8 +620,23 @@ SiiSession.extractInputValues = function(html) {
620
620
  const regex = /<input[^>]+>/gi;
621
621
  const matches = html.match(regex) || [];
622
622
  matches.forEach((tag) => {
623
- const nameMatch = tag.match(/name\s*=\s*"([^"]+)"/i);
624
- const valueMatch = tag.match(/value\s*=\s*"([^"]*)"/i);
623
+ // El SII mezcla estilos en el mismo formulario: unos atributos van con
624
+ // comillas y otros sin ellas (`value = 1`). Con solo el patrón entrecomillado,
625
+ // esos campos se enviaban vacíos y el SII rebotaba a la misma página sin
626
+ // explicar por qué — observado 2026-07-22 en pe_datos_empresa, donde
627
+ // EXISTE_PROD/EXISTE_CERT vienen sin comillas y trababan la postulación.
628
+ // El fallback sin comillas exige un separador antes del atributo: sin eso
629
+ // captura el `value=` que aparece DENTRO de handlers JS
630
+ // (`onblur="this.value=this.value.toUpperCase()"`), devolviendo basura
631
+ // como valor del campo.
632
+ const nameMatch =
633
+ tag.match(/name\s*=\s*"([^"]+)"/i) ||
634
+ tag.match(/name\s*=\s*'([^']+)'/i) ||
635
+ tag.match(/(?:^|[\s<])name\s*=\s*([^\s>"']+)/i);
636
+ const valueMatch =
637
+ tag.match(/value\s*=\s*"([^"]*)"/i) ||
638
+ tag.match(/value\s*=\s*'([^']*)'/i) ||
639
+ tag.match(/(?:^|[\s<])value\s*=\s*([^\s>"']+)/i);
625
640
  if (nameMatch) {
626
641
  inputs[nameMatch[1]] = valueMatch ? valueMatch[1] : '';
627
642
  }
@@ -247,23 +247,76 @@ class CertRunner {
247
247
  this.folioHelper.counters.clear();
248
248
  this.folioHelper.usedFolios.clear();
249
249
 
250
+ // Limpieza previa de folios timbrados y nunca utilizados.
251
+ //
252
+ // Cada corrida de simulación regenera su plan de documentos completo, así que
253
+ // los folios que quedaron de un intento fallido NO se reutilizan jamás: son
254
+ // basura garantizada. Pero el SII los cuenta como "disponibles sin utilizar"
255
+ // (FOLIOS_DISP) y baja el tope de timbraje (MAX_AUTOR) hasta bloquearlo del
256
+ // todo. Observado 2026-07-22 en un RUT de certificación: 7 intentos fallidos
257
+ // dejaron 21 folios muertos en los tipos 34 y 52, y llevaron a los tipos 56 y
258
+ // 61 a bloqueo duro irreversible (sin folios anulables restantes).
259
+ //
260
+ // Limpiar ANTES de pedir rompe ese espiral. Es seguro: solo se anulan folios
261
+ // que el SII reporta como no recepcionados, y los que ya fueron anulados o
262
+ // usados se rechazan sin efecto.
263
+ for (const tipoDte of Object.keys(cafRequired)) {
264
+ try {
265
+ // ACOTADA a propósito. Solo interesan los folios que ESTA certificación
266
+ // timbró y dejó sin usar, que son de hoy. Sin el corte por antigüedad la
267
+ // limpieza barre el historial completo de la empresa: en un RUT con
268
+ // volumen real eso son cientos de anulaciones contra el SII y, peor, el
269
+ // SII cuenta los folios anulados de los últimos 6 meses EN CONTRA del
270
+ // cupo de timbraje — la limpieza terminaría provocando el bloqueo que
271
+ // intenta evitar. Verificado 2026-07-22: sin acotar anuló 296 folios de
272
+ // un RUT (rango 1851–2909) antes de detenerla a mano.
273
+ const limpieza = await this.folioService.anularFolios({
274
+ tipoDte: Number(tipoDte),
275
+ soloUltimosDias: 1,
276
+ maxRangos: 10,
277
+ });
278
+ if (limpieza.totalAnulados > 0) {
279
+ console.log(
280
+ ` Tipo ${tipoDte}: ${limpieza.totalAnulados} folio(s) sin usar de intentos previos anulados`
281
+ );
282
+ }
283
+ } catch (err) {
284
+ // No es fatal: si la limpieza falla, la solicitud de abajo igual puede
285
+ // funcionar, y si no, solicitarCafExacto reintenta anulando.
286
+ console.warn(`[CertRunner] Limpieza previa tipo ${tipoDte} falló: ${err.message}`);
287
+ }
288
+ }
289
+
250
290
  for (const [tipoDte, cantidad] of Object.entries(cafRequired)) {
251
291
  emitProgress(STEPS.CAF_REQUESTING, { tipo: Number(tipoDte) });
252
292
  console.log(` Tipo ${tipoDte}: ${cantidad} folios...`);
253
-
254
- // Usar solicitarCafConFallback que solicita y retorna el path
255
- const cafPath = await this.folioService.solicitarCafConFallback({
293
+
294
+ // solicitarCafExacto (no ConFallback): la simulación genera un plan fijo de
295
+ // documentos y muere si falta un folio, así que un CAF corto no sirve. El
296
+ // método aborta antes de emitir si el SII no autoriza lo suficiente, anula
297
+ // folios sin utilizar para destrabar el tope, y reintenta.
298
+ const res = await this.folioService.solicitarCafExacto({
256
299
  tipoDte: Number(tipoDte),
257
300
  cantidad: Number(cantidad),
258
301
  });
259
-
260
- if (!cafPath) {
261
- throw new Error(`No se pudo obtener CAF para tipo ${tipoDte}`);
302
+
303
+ if (!res.ok) {
304
+ const detalle = [
305
+ res.maxAutor != null ? `MAX_AUTOR=${res.maxAutor}` : null,
306
+ res.foliosDisp != null ? `FOLIOS_DISP=${res.foliosDisp}` : null,
307
+ ].filter(Boolean).join(', ');
308
+ throw new Error(
309
+ `Folios insuficientes para tipo ${tipoDte}: se requieren ${cantidad} y el SII ` +
310
+ `otorgó ${res.otorgados}${detalle ? ` (${detalle})` : ''}. ` +
311
+ `El SII limita el timbraje cuando hay folios ya autorizados sin utilizar; ` +
312
+ `emite o anula documentos electrónicos de este tipo y reintenta. ` +
313
+ `[${res.errorCode}] ${res.error || ''}`.trim()
314
+ );
262
315
  }
263
-
264
- cafs[tipoDte] = cafPath;
316
+
317
+ cafs[tipoDte] = res.cafPath;
265
318
  emitProgress(STEPS.CAF_OK, { tipo: Number(tipoDte) });
266
- console.log(` ✓ CAF tipo ${tipoDte}`);
319
+ console.log(` ✓ CAF tipo ${tipoDte} (${res.otorgados} folios)`);
267
320
  }
268
321
 
269
322
  return cafs;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devlas/dte-sii",
3
- "version": "2.12.22",
3
+ "version": "2.12.24",
4
4
  "description": "Facturación y boletas electrónicas para el SII de Chile. Genera, timbra, firma y envía DTEs, libros electrónicos y automatiza la certificación.",
5
5
  "main": "index.js",
6
6
  "types": "dte-sii.d.ts",