@devlas/dte-sii 2.9.2 → 2.9.3

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
@@ -17,6 +17,15 @@ const path = require('path');
17
17
  const SiiSession = require('./SiiSession');
18
18
  const { splitRut } = require('./utils/rut');
19
19
 
20
+ /**
21
+ * Registro global de sesiones SII (singleton por ambiente+rut).
22
+ * Permite reutilizar la misma sesión HTTP entre múltiples llamadas a CafSolicitor
23
+ * sin abrir una nueva sesión en el portal del SII cada vez.
24
+ * Clave: `${ambiente}::${rutEmisor}`
25
+ * @type {Map<string, SiiSession>}
26
+ */
27
+ const _sessionRegistry = new Map();
28
+
20
29
  /**
21
30
  * Clase para solicitar CAFs al SII
22
31
  */
@@ -28,7 +37,6 @@ class CafSolicitor {
28
37
  * @param {string} options.pfxPath - Ruta absoluta al certificado PFX
29
38
  * @param {string} options.pfxPassword - Contraseña del certificado
30
39
  * @param {string} [options.baseDir] - Directorio base para guardar archivos
31
- * @param {string} [options.sessionPath] - Ruta al archivo de sesión compartida
32
40
  * @param {string} [options.runStamp] - Timestamp de la ejecución
33
41
  */
34
42
  constructor(options = {}) {
@@ -48,22 +56,22 @@ class CafSolicitor {
48
56
  this.ambiente = options.ambiente.toLowerCase();
49
57
  this.rutEmisor = options.rutEmisor;
50
58
  this.baseDir = options.baseDir || path.resolve(__dirname, '..', '..');
51
- this.sessionPath = options.sessionPath || null;
52
59
  this.runStamp = options.runStamp || new Date().toISOString().replace(/[:.]/g, '-');
53
60
 
54
- // Crear SiiSession para manejar HTTP y cookies
55
- this.session = new SiiSession({
56
- ambiente: this.ambiente,
57
- pfxPath: options.pfxPath,
58
- pfxPassword: options.pfxPassword,
59
- });
60
-
61
- // Cargar sesión compartida si existe
62
- if (this.sessionPath) {
63
- const loaded = this.session.loadSession(this.sessionPath);
64
- if (loaded) {
65
- console.log('[CafSolicitor] ✓ Usando sesión compartida');
66
- }
61
+ // Reutilizar sesión SII global si ya existe para este ambiente+rut.
62
+ // Esto evita abrir múltiples sesiones en el portal del SII.
63
+ const sessionKey = `${this.ambiente}::${this.rutEmisor}`;
64
+ if (_sessionRegistry.has(sessionKey)) {
65
+ this.session = _sessionRegistry.get(sessionKey);
66
+ console.log('[CafSolicitor] ♻️ Reutilizando sesión SII en memoria');
67
+ } else {
68
+ this.session = new SiiSession({
69
+ ambiente: this.ambiente,
70
+ pfxPath: options.pfxPath,
71
+ pfxPassword: options.pfxPassword,
72
+ });
73
+ _sessionRegistry.set(sessionKey, this.session);
74
+ console.log('[CafSolicitor] 🔑 Nueva sesión SII registrada para', sessionKey);
67
75
  }
68
76
  }
69
77
 
@@ -193,16 +201,73 @@ class CafSolicitor {
193
201
  // Verificar si obtuvimos el CAF
194
202
  if (response.body && response.body.includes('<AUTORIZACION')) {
195
203
  const cafPath = this._saveCafOrganized(response.body, tipoDte);
196
- return { success: true, cafPath, xml: response.body };
204
+ return { success: true, cafPath, xml: response.body, maxAutor: this._lastMaxAutor ?? cantidad };
197
205
  }
198
206
 
199
207
  if (response.body && response.body.includes('Autenticaci')) {
200
208
  return { success: false, error: 'El SII devolvió página de autenticación' };
201
209
  }
202
210
 
203
- // Detectar error de límite diario del SII
211
+ // Detectar error de MAX_AUTOR: "La cantidad de documentos a timbrar debe ser menor o igual al máximo autorizado"
212
+ // Puede ocurrir cuando el MAX_AUTOR efectivo del SII es menor al mostrado en el formulario
213
+ // (ej: ya hay un timbraje del mismo día que consume parte del cupo diario).
214
+ // Solución: reintentar con cantidad=1 para garantizar obtener al menos 1 folio.
204
215
  if (response.body && response.body.includes('menor o igual al m')) {
205
- return { success: false, error: 'Límite diario de folios SII agotado (MAX_AUTOR). Reintenta mañana.' };
216
+ if (cantidad > 1) {
217
+ console.warn(`[CafSolicitor] MAX_AUTOR excedido para ${cantidad} folios — reintentando con 1 folio...`);
218
+ // No limpiar cookieJar: el error es del formulario, no de la sesión.
219
+ // Reutilizar la sesión activa evita abrir una nueva y acumular sesiones en el SII.
220
+ this.runStamp = new Date().toISOString().replace(/[:.]/g, '-');
221
+ return this.solicitar({ tipoDte, cantidad: 1 });
222
+ }
223
+ return { success: false, error: 'Cantidad de folios excede el máximo que SII autoriza por solicitud para este tipo de documento (MAX_AUTOR). Verifica el estado de timbraje de tu empresa en el portal SII.' };
224
+ }
225
+
226
+ // Detectar rango ya autorizado — el CAF existe en SII pero no fue capturado
227
+ // (ocurre cuando una solicitud previa tuvo éxito en SII pero falló en la red al devolver el XML,
228
+ // o cuando of_genera_archivo devolvió DTE-OFGA y el SII registró el rango igualmente)
229
+ const finalBody = response.body || '';
230
+ if (finalBody.includes('ya fue autorizado el rango desde') || finalBody.includes('ya fue autorizado')) {
231
+ const m = finalBody.match(/ya fue autorizado el rango desde\s+(\d+)\s+hasta\s+(\d+)/i);
232
+ const desde = m ? parseInt(m[1]) : null;
233
+ const hasta = m ? parseInt(m[2]) : null;
234
+
235
+ // Intentar recuperar el XML directamente enviando los datos del rango a of_genera_archivo.
236
+ // El SII tiene el CAF autorizado en su sistema; si enviamos los campos correctos
237
+ // puede devolver el XML aunque la sesión de timbraje original ya terminó.
238
+ if (desde != null && hasta != null) {
239
+ console.warn(`[CafSolicitor] Rango ${desde}-${hasta} ya autorizado — intentando recuperar XML desde of_genera_archivo...`);
240
+ const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
241
+ const recoveryFields = {
242
+ RUT_EMP: rut,
243
+ DV_EMP: dv,
244
+ COD_DOCTO: String(tipoDte),
245
+ FOLIO_INI: String(desde),
246
+ FOLIO_FIN: String(hasta),
247
+ FECHA: today,
248
+ ACEPTAR: 'AQUI',
249
+ };
250
+ try {
251
+ const recoveryResponse = await this.session.submitForm('/cvc_cgi/dte/of_genera_archivo', recoveryFields);
252
+ const recoveryBody = recoveryResponse.body || '';
253
+ this._saveDebug(debugDir, 'recovery.xml', recoveryBody);
254
+ if (recoveryBody.includes('<AUTORIZACION')) {
255
+ console.log(`[CafSolicitor] ✅ CAF recuperado exitosamente para rango ${desde}-${hasta}`);
256
+ const cafPath = this._saveCafOrganized(recoveryBody, tipoDte);
257
+ return { success: true, cafPath, xml: recoveryBody, maxAutor: hasta - desde + 1 };
258
+ }
259
+ console.warn(`[CafSolicitor] Recuperación de rango ${desde}-${hasta} no devolvió XML (${recoveryBody.substring(0, 80).replace(/\s+/g, ' ')})`);
260
+ } catch (recoveryErr) {
261
+ console.warn(`[CafSolicitor] Error al intentar recuperar rango: ${recoveryErr.message}`);
262
+ }
263
+ }
264
+
265
+ const rangoStr = desde != null && hasta != null ? ` ${desde}-${hasta}` : '';
266
+ return {
267
+ success: false,
268
+ rangoYaAutorizado: desde != null && hasta != null ? { folioDesde: desde, folioHasta: hasta } : null,
269
+ error: `SII: Ya existe CAF autorizado para el rango${rangoStr}. El XML fue aprobado por SII en una solicitud previa pero no se pudo recuperar automáticamente. Descárgalo manualmente desde el portal SII (Factura Electrónica → Solicitud de Timbraje).`,
270
+ };
206
271
  }
207
272
 
208
273
  return { success: false, error: 'No se obtuvo CAF en la respuesta' };
@@ -238,15 +303,16 @@ class CafSolicitor {
238
303
  // Selección de tipo de documento
239
304
  if (currentHtml.includes('COD_DOCTO')) {
240
305
  const selectInputs = SiiSession.extractInputValues(currentHtml);
241
- // Respetar MAX_AUTOR que el SII informa en el formulario.
242
- const maxAutorSelect = parseInt(selectInputs.MAX_AUTOR || '999', 10);
243
- const cantSelect = Math.min(cantidad, maxAutorSelect);
306
+ // NO enviar CANT_DOCTOS aquí — el browser tampoco lo envía en este paso.
307
+ // Si se envía un número, el SII no incluye MAX_AUTOR ni CONTROL="S" en la
308
+ // respuesta, lo que causa rechazo silencioso en of_genera_folio más adelante.
309
+ // El SII fija CANT_DOCTOS automáticamente según MAX_AUTOR al responder.
244
310
  const selectFields = {
245
311
  ...selectInputs,
246
312
  RUT_EMP: rut,
247
313
  DV_EMP: dv,
248
314
  COD_DOCTO: tipoDte,
249
- CANT_DOCTOS: cantSelect,
315
+ CANT_DOCTOS: '',
250
316
  };
251
317
 
252
318
  response = await this.session.submitForm('/cvc_cgi/dte/of_solicita_folios_dcto', selectFields);
@@ -270,16 +336,19 @@ class CafSolicitor {
270
336
 
271
337
  const formAction3 = SiiSession.extractFormAction(currentHtml) || '/cvc_cgi/dte/of_confirma_folio';
272
338
  const inputs3 = SiiSession.extractInputValues(currentHtml);
273
-
274
- // Respetar MAX_AUTOR si viene en este paso también
275
- const maxAutorStep3 = parseInt(inputs3.MAX_AUTOR || '999', 10);
276
- const cantStep3 = Math.min(cantidad, maxAutorStep3);
339
+
340
+ // CANT_DOCTOS debe enviarse con un valor <= MAX_AUTOR.
341
+ // Si se omite o excede MAX_AUTOR, el SII rechaza devolviendo la página de inicio (rechazo silencioso).
342
+ const maxAutor = parseInt(inputs3.MAX_AUTOR || String(cantidad), 10);
343
+ const cantReal = Math.min(cantidad, maxAutor);
344
+ this._lastMaxAutor = maxAutor; // guardado para retornarlo desde solicitar()
345
+
277
346
  const step3Fields = {
278
347
  ...inputs3,
279
348
  RUT_EMP: rut,
280
349
  DV_EMP: dv,
281
350
  COD_DOCTO: tipoDte,
282
- CANT_DOCTOS: cantStep3,
351
+ CANT_DOCTOS: String(cantReal),
283
352
  ACEPTAR: 'Solicitar Numeración',
284
353
  };
285
354
 
@@ -351,9 +420,20 @@ class CafSolicitor {
351
420
  currentHtml = response.body || '';
352
421
  this._saveDebug(debugDir, 'genera.html', currentHtml);
353
422
 
423
+ // El SII rechaza el request si ese rango ya fue autorizado en una solicitud previa
424
+ // (ocurre en reintentos cuando la primera solicitud llegó a SII pero falló la red al devolver el XML)
425
+ if (currentHtml.includes('ya fue autorizado')) {
426
+ const m = currentHtml.match(/ya fue autorizado el rango desde\s+(\d+)\s+hasta\s+(\d+)/i);
427
+ console.warn(`[CafSolicitor] of_genera_folio: rango ya autorizado${m ? ' ' + m[1] + '-' + m[2] : ''} — el XML existe en SII pero no se obtuvo`);
428
+ return response;
429
+ }
430
+
354
431
  // Paso final: of_genera_archivo
355
432
  if (!currentHtml.includes('<AUTORIZACION') && currentHtml.includes('of_genera_archivo')) {
356
- response = await this._processGeneraArchivo(response, debugDir);
433
+ // Guardar los inputs del genera.html para poder reintentar of_genera_archivo si falla con DTE-OFGA
434
+ const generaInputs = SiiSession.extractInputValues(currentHtml);
435
+ const generaFormAction = SiiSession.extractFormAction(currentHtml) || '/cvc_cgi/dte/of_genera_archivo';
436
+ response = await this._processGeneraArchivo(response, debugDir, generaInputs, generaFormAction);
357
437
  }
358
438
 
359
439
  return response;
@@ -362,8 +442,12 @@ class CafSolicitor {
362
442
  /**
363
443
  * Procesa generación de archivo CAF
364
444
  * @private
445
+ * @param {Object} response - Respuesta previa
446
+ * @param {string} debugDir - Directorio de debug
447
+ * @param {Object} [retryInputs] - Inputs de genera.html para retry si falla con DTE-OFGA
448
+ * @param {string} [retryFormAction] - URL de of_genera_archivo para retry
365
449
  */
366
- async _processGeneraArchivo(response, debugDir) {
450
+ async _processGeneraArchivo(response, debugDir, retryInputs, retryFormAction) {
367
451
  let currentHtml = response.body || '';
368
452
 
369
453
  const formAction = SiiSession.extractFormAction(currentHtml) || '/cvc_cgi/dte/of_genera_archivo';
@@ -378,6 +462,32 @@ class CafSolicitor {
378
462
  currentHtml = response.body || '';
379
463
  this._saveDebug(debugDir, 'archivo.xml', currentHtml);
380
464
 
465
+ // El SII a veces devuelve error interno DTE-OFGA (servidor sobrecargado o fallo transitorio).
466
+ // En ese caso el rango ya fue registrado en SII pero el XML no se generó.
467
+ // Reintentar el mismo POST hasta 3 veces con delay de 3s antes de rendirse.
468
+ if (!currentHtml.includes('<AUTORIZACION') && currentHtml.includes('DTE-OFGA')) {
469
+ for (let attempt = 1; attempt <= 3; attempt++) {
470
+ const delaySecs = attempt * 3;
471
+ console.warn(`[CafSolicitor] Error DTE-OFGA en of_genera_archivo — reintentando en ${delaySecs}s (intento ${attempt}/3)...`);
472
+ await new Promise(r => setTimeout(r, delaySecs * 1000));
473
+ // Reintentar con los inputs originales del genera.html si están disponibles,
474
+ // ya que los del archivo.xml (página de error) no tienen los campos necesarios.
475
+ const retryFields = retryInputs ? { ...retryInputs, ACEPTAR: 'AQUI' } : fields;
476
+ const retryAction = retryFormAction || formAction;
477
+ response = await this.session.submitForm(retryAction, retryFields);
478
+ currentHtml = response.body || '';
479
+ this._saveDebug(debugDir, `archivo-retry${attempt}.xml`, currentHtml);
480
+ if (currentHtml.includes('<AUTORIZACION')) {
481
+ console.log(`[CafSolicitor] ✅ CAF obtenido en retry ${attempt} de of_genera_archivo`);
482
+ return response;
483
+ }
484
+ if (!currentHtml.includes('DTE-OFGA')) {
485
+ // Error diferente (ya fue autorizado, etc.) — salir del loop
486
+ break;
487
+ }
488
+ }
489
+ }
490
+
381
491
  // A veces hay un paso extra
382
492
  if (!currentHtml.includes('<AUTORIZACION') && currentHtml.includes('of_genera_archivo')) {
383
493
  const formAction2 = SiiSession.extractFormAction(currentHtml) || '/cvc_cgi/dte/of_genera_archivo';
package/DTE.js CHANGED
@@ -71,9 +71,14 @@ class DTE {
71
71
 
72
72
  _convertirDatosSimplificados(d) {
73
73
  const esExenta = d.tipo === 41;
74
+ const esBoleta = TIPOS_BOLETA.includes(d.tipo);
74
75
  const precioConIva = d.precioConIva === true;
75
- const { detalle, mntNeto, mntExento } = this._procesarItems(d.items, esExenta, precioConIva);
76
- const totales = this._calcularTotales(mntNeto, mntExento, esExenta);
76
+ // Boletas afectas: reportar precios al consumidor (bruto con IVA) en <Detalle>.
77
+ // El SII valida MntTotal = suma(MontoItem), por lo que MontoItem debe ser precio con IVA.
78
+ // Para facturas: convertir a neto (precio sin IVA) como de costumbre.
79
+ const convertirANeto = precioConIva && !esBoleta;
80
+ const { detalle, mntNeto, mntExento } = this._procesarItems(d.items, esExenta, convertirANeto);
81
+ const totales = this._calcularTotales(mntNeto, mntExento, esExenta, esBoleta, esBoleta && precioConIva);
77
82
 
78
83
  const resultado = {
79
84
  Encabezado: {
@@ -145,14 +150,28 @@ class DTE {
145
150
 
146
151
  // PrcItem = precio neto unitario (o precio con IVA si se usa precioConIva:true).
147
152
  // IVA = mntNeto * TasaIVA.
148
- _calcularTotales(mntNeto, mntExento, esExenta) {
153
+ _calcularTotales(mntNeto, mntExento, esExenta, esBoleta = false, esBruto = false) {
154
+ // Boleta afecta con precios al consumidor (brutos): mntNeto contiene el total bruto (con IVA).
155
+ // MntNeto y IVA se derivan del bruto; MntTotal = bruto + exento = suma(MontoItem).
156
+ if (esBoleta && esBruto && !esExenta && mntNeto > 0) {
157
+ const bruto = mntNeto;
158
+ const neto = Math.round(bruto / (1 + TASA_IVA / 100));
159
+ const iva = bruto - neto;
160
+ const mntTotal = bruto + mntExento;
161
+ const totales = { MntNeto: neto };
162
+ if (mntExento > 0) totales.MntExe = mntExento;
163
+ totales.IVA = iva;
164
+ totales.MntTotal = mntTotal;
165
+ return totales;
166
+ }
167
+
149
168
  const neto = esExenta ? 0 : mntNeto;
150
169
  const iva = esExenta ? 0 : Math.round(neto * TASA_IVA / 100);
151
170
  const mntTotal = neto + iva + mntExento;
152
171
 
153
172
  const totales = {};
154
173
  if (neto > 0) totales.MntNeto = neto;
155
- if (neto > 0) totales.TasaIVA = TASA_IVA; // requerido en facturas
174
+ if (neto > 0 && !esBoleta) totales.TasaIVA = TASA_IVA; // requerido en facturas, NO en boletas (EnvioBOLETA_v11.xsd no lo admite)
156
175
  if (mntExento > 0) totales.MntExe = mntExento;
157
176
  if (neto > 0) totales.IVA = iva;
158
177
  totales.MntTotal = mntTotal;
package/EnviadorSII.js CHANGED
@@ -437,7 +437,7 @@ class EnviadorSII {
437
437
  formData.append('rutCompany', rutCompany.toString());
438
438
  formData.append('dvCompany', dvCompany.toUpperCase());
439
439
 
440
- const xmlBuffer = Buffer.from(xml, 'utf-8');
440
+ const xmlBuffer = Buffer.from(xml, 'latin1');
441
441
  formData.append('archivo', xmlBuffer, {
442
442
  filename: 'EnvioBOLETA.xml',
443
443
  contentType: 'application/xml',
@@ -703,13 +703,20 @@ class EnviadorSII {
703
703
  else if (estado === 'RCH') mensaje = `[ERR] Rechazado: ${json.glosa || json.descripcion || 'Sin detalle'}`;
704
704
  else mensaje = `Estado: ${estado}`;
705
705
 
706
+ const esExitoso = ['EPR', 'RPR', 'RLV'].includes(estado);
707
+ const esRechazado = ['RPT', 'RFR', 'VOF', 'RCT', 'RCH', 'RSC'].includes(estado);
708
+ const esIntermedio = !esExitoso && !esRechazado;
709
+
706
710
  return {
707
711
  ok: true,
708
- trackId: trackId,
709
- estado: estado,
712
+ ...json,
713
+ trackId,
714
+ estado,
710
715
  descripcion: json.descripcion || json.glosa,
711
716
  mensaje,
712
- ...json,
717
+ esExitoso,
718
+ esRechazado,
719
+ esIntermedio,
713
720
  };
714
721
  } catch (e) {
715
722
  return {
@@ -1343,24 +1350,34 @@ class EnviadorSII {
1343
1350
  99: 'Error desconocido',
1344
1351
  };
1345
1352
 
1346
- // STATUS 7 = Envío duplicado (para EnvioBOLETA/EnvioDTE).
1347
- // EXCEPCIÓN: CHR-00001 en <DETAIL> = error de charset, NO es duplicado.
1353
+ // STATUS 7 = puede ser duplicado real, o error de schema/charset (NO duplicado).
1348
1354
  if (status === 7) {
1349
1355
  const detailMatch = responseText.match(/<ERROR>([^<]*)<\/ERROR>/i);
1350
1356
  const detailMsg = detailMatch ? detailMatch[1] : '';
1357
+
1358
+ // CHR-00001: error de charset en el XML (encoding incorrecto)
1351
1359
  if (detailMsg.includes('CHR-00001')) {
1352
1360
  return {
1353
- ok: false,
1354
- status,
1361
+ ok: false, status,
1355
1362
  error: `Error de charset en XML enviado (${detailMsg}). Verifique encoding="ISO-8859-1" en la declaración XML.`,
1356
- respuesta: responseText,
1357
- duplicado: false,
1363
+ respuesta: responseText, duplicado: false,
1364
+ };
1365
+ }
1366
+
1367
+ // SCH-00001: XML no cumple el esquema (ej: campo undefined, fecha inválida, esquema incorrecto)
1368
+ if (detailMsg.includes('SCH-00001')) {
1369
+ return {
1370
+ ok: false, status,
1371
+ error: `XML rechazado por SII: esquema inválido (${detailMsg}). El XML contiene campos malformados o la referencia de esquema no es reconocida.`,
1372
+ respuesta: responseText, duplicado: false,
1358
1373
  };
1359
1374
  }
1375
+
1376
+ // Duplicado real: SII ya recibió este SetDTE ID
1360
1377
  if (trackId) {
1361
1378
  return { ok: true, status, trackId, archivo: fileName, mensaje: `[DUPLICADO] TrackID recuperado: ${trackId}`, respuesta: responseText };
1362
1379
  }
1363
- return { ok: false, status, error: errorMessages[7], respuesta: responseText, duplicado: true };
1380
+ return { ok: false, status, error: `${errorMessages[7]}. Detail: ${detailMsg || 'sin detalle'}`, respuesta: responseText, duplicado: true };
1364
1381
  }
1365
1382
 
1366
1383
  return {
package/Envio.js CHANGED
@@ -62,17 +62,21 @@ class EnvioBase {
62
62
  }
63
63
 
64
64
  /**
65
- * Generar SetId con formato específico
65
+ * Generar SetId con formato específico.
66
+ * @param {string} prefix
67
+ * @param {string} [suffix] - Sufijo opcional (ej: ID de sobre) para garantizar unicidad entre sobres del mismo segundo.
66
68
  */
67
- _generateSetId(prefix) {
69
+ _generateSetId(prefix, suffix) {
68
70
  const now = new Date();
69
- const dd = String(now.getDate()).padStart(2, '0');
70
- const mm = String(now.getMonth() + 1).padStart(2, '0');
71
+ const dd = String(now.getDate()).padStart(2, '0');
72
+ const mm = String(now.getMonth() + 1).padStart(2, '0');
71
73
  const yyyy = now.getFullYear();
72
- const hh = String(now.getHours()).padStart(2, '0');
73
- const min = String(now.getMinutes()).padStart(2, '0');
74
- const ss = String(now.getSeconds()).padStart(2, '0');
75
- return `${prefix}_${dd}_${mm}_${yyyy}_${hh}_${min}_${ss}`;
74
+ const hh = String(now.getHours()).padStart(2, '0');
75
+ const min = String(now.getMinutes()).padStart(2, '0');
76
+ const ss = String(now.getSeconds()).padStart(2, '0');
77
+ const ms = String(now.getMilliseconds()).padStart(3, '0');
78
+ const base = `${prefix}_${dd}_${mm}_${yyyy}_${hh}_${min}_${ss}_${ms}`;
79
+ return suffix ? `${base}_${suffix}` : base;
76
80
  }
77
81
 
78
82
  /**
package/FolioService.js CHANGED
@@ -249,10 +249,14 @@ class FolioService {
249
249
  const debugDir = path.join(this.debugDir, 'auto-caf', 'anulacion', debugStamp);
250
250
  fs.mkdirSync(debugDir, { recursive: true });
251
251
 
252
- // Asegurar sesión
253
- this.session.reset();
252
+ // Asegurar sesión — sin reset() para reutilizar cookieJar cargado del caché
254
253
  const page = await this.session.ensureSession('/cvc_cgi/dte/af_anular1');
255
254
 
255
+ // Persistir sesión para reutilizar en llamadas posteriores (evita abrir sesiones SII extra)
256
+ if (this.sessionPath) {
257
+ try { this.session.saveSession(this.sessionPath); } catch (_) {}
258
+ }
259
+
256
260
  if (!page.body || !page.body.includes('ANULACION DE FOLIOS')) {
257
261
  fs.writeFileSync(path.join(debugDir, 'error.html'), page.body || '', 'utf8');
258
262
  throw new Error('No se pudo acceder a la página de anulación.');
@@ -264,17 +268,21 @@ class FolioService {
264
268
  ...hiddenInputs,
265
269
  RUT_EMP: rut,
266
270
  DV_EMP: dv,
271
+ PAGINA: '1',
267
272
  COD_DOCTO: String(tipoDte),
268
273
  ACEPTAR: 'Consultar',
269
274
  };
270
275
 
271
- const consulta = await this.session.submitForm(
272
- '/cvc_cgi/dte/af_anular2',
273
- fields,
274
- `https://${this.session.getBaseHost()}/cvc_cgi/dte/af_anular1`
276
+ const consulta = await this._withRetry('af_anular2', () =>
277
+ this.session.submitForm(
278
+ '/cvc_cgi/dte/af_anular2',
279
+ fields,
280
+ `https://${this.session.getBaseHost()}/cvc_cgi/dte/af_anular1`
281
+ )
275
282
  );
276
283
 
277
284
  let currentHtml = consulta.body || '';
285
+ try { fs.writeFileSync(path.join(debugDir, 'page-1.html'), currentHtml, 'utf8'); } catch (_) {}
278
286
  let info = this._parseAnulacionTable(currentHtml);
279
287
  let action = SiiSession.extractFormActionByName(currentHtml, 'frm') || '/cvc_cgi/dte/af_anular2';
280
288
  let hiddenInputsConsulta = SiiSession.extractInputValues(currentHtml);
@@ -291,10 +299,12 @@ class FolioService {
291
299
  nextFields.PAGINA = String(nextButton.page);
292
300
  }
293
301
 
294
- const nextRes = await this.session.submitForm(
295
- action,
296
- nextFields,
297
- `https://${this.session.getBaseHost()}/cvc_cgi/dte/af_anular2`
302
+ const nextRes = await this._withRetry(`af_anular2 pág ${nextButton.page || safety + 2}`, () =>
303
+ this.session.submitForm(
304
+ action,
305
+ nextFields,
306
+ `https://${this.session.getBaseHost()}/cvc_cgi/dte/af_anular2`
307
+ )
298
308
  );
299
309
 
300
310
  currentHtml = nextRes.body || '';
@@ -318,6 +328,7 @@ class FolioService {
318
328
  safety += 1;
319
329
  }
320
330
 
331
+ console.log(`[FolioService] consultarFolios tipoDte=${tipoDte}: ${info.ranges.length} rango(s) encontrado(s)`);
321
332
  return {
322
333
  ok: true,
323
334
  tipoDte,
@@ -330,110 +341,179 @@ class FolioService {
330
341
  }
331
342
 
332
343
  /**
333
- * Anula folios en el SII
334
- * @param {Object} params - Parámetros
335
- * @returns {Promise<Object>}
344
+ * Anula folios en el SII usando el flujo bulk: af_anular3 → af_anular.
345
+ * Un request por rango CAF en lugar de un request por folio individual.
346
+ *
347
+ * @param {Object} params
348
+ * @param {number} params.tipoDte
349
+ * @param {number|null} [params.folioDesde] - Si se omite, anula todos los rangos pendientes
350
+ * @param {number|null} [params.folioHasta]
351
+ * @param {string} [params.motivo]
352
+ * @returns {Promise<{ok:boolean, anulados:Array, rechazados:Array, totalAnulados:number, totalRechazados:number}>}
336
353
  */
337
354
  async anularFolios({ tipoDte, folioDesde = null, folioHasta = null, motivo = 'Folios no utilizados' }) {
338
- const consulta = await this.consultarFolios({ tipoDte });
339
- const anulados = [];
355
+ const debugStampA = new Date().toISOString().replace(/[:.]/g, '-');
356
+ const debugDirA = path.join(this.debugDir, 'auto-caf', 'anulacion', debugStampA);
357
+ fs.mkdirSync(debugDirA, { recursive: true });
358
+
359
+ const { rut, dv } = SiiSession.parseRut(this.rutEmisor);
360
+ const anulados = [];
340
361
  const rechazados = [];
362
+ const vistos = new Set(); // claves "folioDesde-folioHasta" ya procesadas en esta ejecución
341
363
 
342
- // Calcular total de folios a anular para mostrar progreso
343
- let totalFolios = 0;
344
- if (Number.isFinite(folioDesde) && Number.isFinite(folioHasta)) {
345
- totalFolios = folioHasta - folioDesde + 1;
346
- } else {
347
- for (const range of consulta.ranges) {
348
- totalFolios += (range.folioHasta - range.folioDesde + 1);
349
- }
350
- }
351
- let foliosAnulados = 0;
364
+ const maxPasadas = 4;
352
365
 
353
- const anularFolioIndividual = async (folio) => {
354
- let currentHtml = consulta.html;
366
+ for (let pasada = 0; pasada < maxPasadas; pasada++) {
367
+ const consulta = await this.consultarFolios({ tipoDte });
355
368
 
356
- const range = consulta.ranges.find((r) => folio >= r.folioDesde && folio <= r.folioHasta);
357
-
358
- if (range && range.formFields && Object.keys(range.formFields).length) {
359
- const selectRes = await this.session.submitForm(
360
- range.formAction || consulta.action,
361
- range.formFields,
362
- `https://${this.session.getBaseHost()}/cvc_cgi/dte/af_anular1`
363
- );
364
- currentHtml = selectRes.body || currentHtml;
365
- } else if (range && range.selection) {
366
- const selectFields = {
367
- ...consulta.hiddenInputs,
368
- [range.selection.name]: range.selection.value || '',
369
- };
370
- const selectRes = await this.session.submitForm(
371
- consulta.action,
372
- selectFields,
373
- `https://${this.session.getBaseHost()}/cvc_cgi/dte/af_anular1`
374
- );
375
- currentHtml = selectRes.body || currentHtml;
369
+ // Filtrar rangos dentro del rango solicitado y que no hayamos intentado aún
370
+ let rangos = consulta.ranges.filter(r => {
371
+ if (Number.isFinite(folioDesde) && Number.isFinite(folioHasta)) {
372
+ if (r.folioDesde > folioHasta || r.folioHasta < folioDesde) return false;
373
+ }
374
+ return !vistos.has(`${r.folioDesde}-${r.folioHasta}`);
375
+ });
376
+
377
+ if (rangos.length === 0) {
378
+ console.log(`[FolioService] Pasada ${pasada + 1}: sin rangos nuevos, finalizando`);
379
+ break;
376
380
  }
377
381
 
378
- const action = SiiSession.extractFormAction(currentHtml) || consulta.action;
379
- const fields = SiiSession.extractInputValues(currentHtml);
380
- this._setFolioFields(fields, folio, folio);
381
- this._setMotivoField(fields, motivo);
382
+ console.log(`[FolioService] Pasada ${pasada + 1}: ${rangos.length} rango(s) a procesar`);
383
+ const host = this.session.getBaseHost();
382
384
 
383
- const resultRes = await this.session.submitForm(
384
- action,
385
- fields,
386
- `https://${this.session.getBaseHost()}/cvc_cgi/dte/af_anular2`
387
- );
385
+ for (const range of rangos) {
386
+ vistos.add(`${range.folioDesde}-${range.folioHasta}`);
387
+ const iniA = folioDesde != null ? Math.max(range.folioDesde, folioDesde) : range.folioDesde;
388
+ const finA = folioHasta != null ? Math.min(range.folioHasta, folioHasta) : range.folioHasta;
389
+ const count = finA - iniA + 1;
390
+ const { dia, mes, ano } = this._parseFecha(range.fecha);
388
391
 
389
- // Guardar resultado para debug
390
- try {
391
- const debugDir = path.join(this.debugDir, 'auto-caf', 'anulacion', 'resultados');
392
- fs.mkdirSync(debugDir, { recursive: true });
393
- const stamp = new Date().toISOString().replace(/[:.]/g, '-');
394
- fs.writeFileSync(path.join(debugDir, `sii-anulacion-result-${folio}-${stamp}.html`), resultRes.body || '', 'utf8');
395
- } catch (_) {
396
- // ignore
397
- }
392
+ // Paso 1: af_anular3 — obtener el formulario con campos ocultos del SII
393
+ let body3;
394
+ try {
395
+ const r3 = await this._withRetry(`af_anular3 rango ${iniA}-${finA}`, () =>
396
+ this.session.submitForm(
397
+ '/cvc_cgi/dte/af_anular3',
398
+ {
399
+ RUT_EMP: rut, DV_EMP: dv,
400
+ DIA: dia, MES: mes, ANO: ano,
401
+ COD_DOCTO: String(tipoDte),
402
+ FOLIO_INI: String(range.folioDesde),
403
+ FOLIO_FIN: String(range.folioHasta),
404
+ CANT_DOCTOS: String(range.cantidad ?? (range.folioHasta - range.folioDesde + 1)),
405
+ },
406
+ `https://${host}/cvc_cgi/dte/af_anular2`
407
+ )
408
+ );
409
+ body3 = r3.body || '';
410
+ } catch (err) {
411
+ console.error(`[FolioService] af_anular3 falló rango ${iniA}-${finA}: ${err.message}`);
412
+ rechazados.push({ folioDesde: iniA, folioHasta: finA, count, reason: 'error-red' });
413
+ continue;
414
+ }
398
415
 
399
- // Log de progreso
400
- foliosAnulados += 1;
401
- const pct = Math.round((foliosAnulados / totalFolios) * 100);
402
- process.stdout.write(`\r Anulando folio ${folio} (${foliosAnulados}/${totalFolios} - ${pct}%)`);
416
+ if (body3.includes('ya ha sido efectuado') || body3.includes('ya fue anulado') ||
417
+ body3.includes('efectuado anteriormente')) {
418
+ rechazados.push({ folioDesde: iniA, folioHasta: finA, count, reason: 'ya-anulado' });
419
+ console.warn(`[FolioService] Rango ${iniA}-${finA}: ya anulado (af_anular3)`);
420
+ try { fs.writeFileSync(path.join(debugDirA, `rango-${iniA}-${finA}-af_anular3-error.html`), body3, 'utf8'); } catch (_) {}
421
+ continue;
422
+ }
423
+ try { fs.writeFileSync(path.join(debugDirA, `rango-${iniA}-${finA}-af_anular3.html`), body3, 'utf8'); } catch (_) {}
403
424
 
404
- return { folio, ...this._parseAnulacionResult(resultRes.body || '') };
405
- };
425
+ // Extraer campos ocultos del formulario devuelto por SII
426
+ const formFields = SiiSession.extractInputValues(body3);
427
+ formFields.FOLIO_INI_A = String(iniA);
428
+ formFields.FOLIO_FIN_A = String(finA);
429
+ formFields.MOTIVO = motivo;
406
430
 
407
- // Anular rango específico o todos
408
- if (Number.isFinite(folioDesde) && Number.isFinite(folioHasta)) {
409
- for (let folio = folioDesde; folio <= folioHasta; folio += 1) {
410
- const result = await anularFolioIndividual(folio);
411
- if (result.ok) {
412
- anulados.push(result);
413
- } else if (result.ok === false) {
414
- rechazados.push(result);
431
+ // Paso 2: af_anular intentar anulación bulk del rango completo
432
+ let bodyBulk;
433
+ try {
434
+ const rBulk = await this._withRetry(`af_anular bulk ${iniA}-${finA}`, () =>
435
+ this.session.submitForm('/cvc_cgi/dte/af_anular', formFields, `https://${host}/cvc_cgi/dte/af_anular3`)
436
+ );
437
+ bodyBulk = rBulk.body || '';
438
+ } catch (err) {
439
+ console.error(`[FolioService] af_anular bulk falló rango ${iniA}-${finA}: ${err.message}`);
440
+ rechazados.push({ folioDesde: iniA, folioHasta: finA, count, reason: 'error-red' });
441
+ continue;
415
442
  }
416
- }
417
- } else {
418
- // Anular todos los rangos
419
- for (const range of consulta.ranges) {
420
- for (let folio = range.folioDesde; folio <= range.folioHasta; folio += 1) {
421
- const result = await anularFolioIndividual(folio);
422
- if (result.ok) {
423
- anulados.push(result);
424
- } else if (result.ok === false) {
425
- rechazados.push(result);
443
+
444
+ const exitoBulk = bodyBulk.includes('ha autorizado la anulaci') ||
445
+ bodyBulk.includes('SOLICITUD ANULACION DE FOLIOS');
446
+ if (exitoBulk) {
447
+ anulados.push({ folioDesde: iniA, folioHasta: finA, count });
448
+ try { fs.writeFileSync(path.join(debugDirA, `rango-${iniA}-${finA}-af_anular-ok.html`), bodyBulk, 'utf8'); } catch (_) {}
449
+ console.log(`[FolioService] ✓ Rango ${iniA}-${finA} (${count} folios) anulado en bulk`);
450
+ continue;
451
+ }
452
+
453
+ try { fs.writeFileSync(path.join(debugDirA, `rango-${iniA}-${finA}-af_anular-fail.html`), bodyBulk, 'utf8'); } catch (_) {}
454
+ console.warn(`[FolioService] bulk falló rango ${iniA}-${finA}: ${bodyBulk.slice(0, 300).replace(/\s+/g, ' ')}`);
455
+
456
+ const yaConflicto = bodyBulk.includes('ya ha sido efectuado') ||
457
+ bodyBulk.includes('ya fue anulado') ||
458
+ bodyBulk.includes('efectuado anteriormente') ||
459
+ bodyBulk.includes('recepcionad');
460
+ if (!yaConflicto) {
461
+ const razon = this._parseAnulacionResult(bodyBulk).reason || 'error';
462
+ rechazados.push({ folioDesde: iniA, folioHasta: finA, count, reason: razon });
463
+ console.warn(`[FolioService] ✗ Rango ${iniA}-${finA} rechazado: ${razon}`);
464
+ continue;
465
+ }
466
+
467
+ // Fallback: algunos folios del rango ya fueron anulados/receptados.
468
+ // Reutilizar la sesión de af_anular3 y anular folio a folio.
469
+ console.log(`[FolioService] Rango ${iniA}-${finA}: conflicto bulk, fallback folio-a-folio...`);
470
+ let i = 0;
471
+ for (let folio = iniA; folio <= finA; folio++) {
472
+ i++;
473
+ process.stdout.write(`\r[FolioService] ${iniA}-${finA}: folio ${folio} (${i}/${count}) `);
474
+ const singleFields = { ...formFields, FOLIO_INI_A: String(folio), FOLIO_FIN_A: String(folio) };
475
+ let bs;
476
+ try {
477
+ const rSingle = await this._withRetry(`af_anular folio ${folio}`, () =>
478
+ this.session.submitForm('/cvc_cgi/dte/af_anular', singleFields, `https://${host}/cvc_cgi/dte/af_anular3`)
479
+ );
480
+ bs = rSingle.body || '';
481
+ } catch (err) {
482
+ rechazados.push({ folioDesde: folio, folioHasta: folio, count: 1, reason: 'error-red' });
483
+ continue;
484
+ }
485
+ const ok = bs.includes('ha autorizado la anulaci') || bs.includes('SOLICITUD ANULACION DE FOLIOS');
486
+ if (ok) {
487
+ anulados.push({ folioDesde: folio, folioHasta: folio, count: 1 });
488
+ } else {
489
+ const razon = this._parseAnulacionResult(bs).reason || 'error';
490
+ rechazados.push({ folioDesde: folio, folioHasta: folio, count: 1, reason: razon });
426
491
  }
427
492
  }
493
+ console.log('');
428
494
  }
429
- }
430
495
 
431
- // Nueva línea después del progreso
432
- if (totalFolios > 0) {
433
- console.log('');
496
+ // Pausa breve entre pasadas para no saturar el SII
497
+ if (pasada < maxPasadas - 1) await this._sleep(1500);
434
498
  }
435
499
 
436
- return { ok: true, anulados, rechazados };
500
+ const totalAnulados = anulados.reduce((s, r) => s + r.count, 0);
501
+ const totalRechazados = rechazados.reduce((s, r) => s + r.count, 0);
502
+ console.log(`[FolioService] Completado: ${totalAnulados} anulados, ${totalRechazados} rechazados`);
503
+
504
+ return { ok: true, anulados, rechazados, totalAnulados, totalRechazados };
505
+ }
506
+
507
+ /**
508
+ * Parsea una fecha "DD-MM-YYYY" o "DD/MM/YYYY" en sus componentes.
509
+ * @private
510
+ */
511
+ _parseFecha(fecha) {
512
+ const clean = String(fecha || '').replace(/[\s ]/g, '').trim();
513
+ const m = clean.match(/^(\d{1,2})[-\/](\d{1,2})[-\/](\d{4})$/);
514
+ if (m) return { dia: m[1].padStart(2, '0'), mes: m[2].padStart(2, '0'), ano: m[3] };
515
+ // Si no tiene fecha válida, dejar vacío (el SII puede no requerirla siempre)
516
+ return { dia: '', mes: '', ano: '' };
437
517
  }
438
518
 
439
519
  /**
@@ -547,6 +627,15 @@ class FolioService {
547
627
  * @private
548
628
  */
549
629
  _parseAnulacionTable(html) {
630
+ // Primario: extraer desde formularios (no depende de posición de columnas, más robusto
631
+ // ante tablas anidadas que cortan el regex de filas)
632
+ const formRanges = this._extractRangesFromForms(html);
633
+ if (formRanges.length > 0) {
634
+ const ultimoFolioFinal = formRanges.reduce((acc, r) => r.folioHasta > acc ? r.folioHasta : acc, 0);
635
+ return { ranges: formRanges, ultimoFolioFinal: ultimoFolioFinal || null };
636
+ }
637
+
638
+ // Fallback: parsing por columnas de tabla (backup si no hay forms con FOLIO_INI/FOLIO_FIN)
550
639
  const rows = html.match(/<tr[^>]*>[\s\S]*?<\/tr>/gi) || [];
551
640
  const ranges = [];
552
641
 
@@ -588,6 +677,53 @@ class FolioService {
588
677
  return { ranges, ultimoFolioFinal: ultimoFolioFinal || null };
589
678
  }
590
679
 
680
+ /**
681
+ * Extrae rangos de folios directamente de los <form> de la página af_anular2.
682
+ * Cada fila de la tabla tiene un form con inputs ocultos FOLIO_INI, FOLIO_FIN,
683
+ * CANT_DOCTOS, DIA, MES, ANO. Este método es más confiable que parsear columnas
684
+ * porque no se ve afectado por tablas anidadas ni por variaciones en el orden de columnas.
685
+ * @private
686
+ */
687
+ _extractRangesFromForms(html) {
688
+ const ranges = [];
689
+ const formRegex = /<form[^>]*>[\s\S]*?<\/form>/gi;
690
+ const forms = String(html || '').match(formRegex) || [];
691
+
692
+ forms.forEach(formHtml => {
693
+ const fields = SiiSession.extractInputValues(formHtml);
694
+ const folioDesde = SiiSession.parseIntFromText(fields.FOLIO_INI);
695
+ const folioHasta = SiiSession.parseIntFromText(fields.FOLIO_FIN);
696
+ if (!Number.isFinite(folioDesde) || !Number.isFinite(folioHasta)) return;
697
+
698
+ const cantidadRaw = SiiSession.parseIntFromText(fields.CANT_DOCTOS);
699
+ const cantidad = Number.isFinite(cantidadRaw) ? cantidadRaw : (folioHasta - folioDesde + 1);
700
+ const dia = String(fields.DIA || '').padStart(2, '0');
701
+ const mes = String(fields.MES || '').padStart(2, '0');
702
+ const ano = fields.ANO || '';
703
+ const fecha = (dia !== '00' && mes !== '00' && ano) ? `${dia}-${mes}-${ano}` : null;
704
+
705
+ ranges.push({
706
+ fecha,
707
+ cantidad,
708
+ folioDesde,
709
+ folioHasta,
710
+ efectuadoPor: null,
711
+ selection: null,
712
+ formFields: fields,
713
+ formAction: SiiSession.extractFormAction(formHtml) || '',
714
+ });
715
+ });
716
+
717
+ // Deduplicar por folioDesde+folioHasta (el SII puede repetir el mismo form)
718
+ const seen = new Set();
719
+ return ranges.filter(r => {
720
+ const key = `${r.folioDesde}-${r.folioHasta}`;
721
+ if (seen.has(key)) return false;
722
+ seen.add(key);
723
+ return true;
724
+ });
725
+ }
726
+
591
727
  /**
592
728
  * @private
593
729
  */
@@ -675,6 +811,7 @@ class FolioService {
675
811
  if (
676
812
  text.includes('ya fue anulado') ||
677
813
  text.includes('anulado anteriormente') ||
814
+ text.includes('ya ha sido efectuado anteriormente') ||
678
815
  (text.includes('anulad') && text.includes('ya'))
679
816
  ) {
680
817
  return { ok: false, reason: 'ya-anulado' };
@@ -695,9 +832,41 @@ class FolioService {
695
832
  if (text.includes('anulaci') && text.includes('no')) {
696
833
  return { ok: false, reason: 'rechazado' };
697
834
  }
835
+
836
+ if (text.trim() === 'error 500' || text.includes('error 500') || text.includes('internal server error')) {
837
+ return { ok: false, reason: 'error-sii-500' };
838
+ }
698
839
 
699
840
  return { ok: null, reason: 'desconocido' };
700
841
  }
842
+
843
+ /**
844
+ * Reintenta una función async ante errores de red.
845
+ * No reintenta en errores de lógica (respuestas HTML del SII con error).
846
+ * @private
847
+ */
848
+ async _withRetry(label, fn) {
849
+ const retries = this.config.retries ?? 2;
850
+ const delayMs = this.config.retryDelayMs ?? 1500;
851
+ let lastErr;
852
+ for (let i = 0; i <= retries; i++) {
853
+ try {
854
+ return await fn();
855
+ } catch (err) {
856
+ lastErr = err;
857
+ if (i < retries) {
858
+ console.warn(`[FolioService] ${label}: error "${err.message}", reintentando (${i + 1}/${retries})...`);
859
+ await this._sleep(delayMs);
860
+ }
861
+ }
862
+ }
863
+ throw lastErr;
864
+ }
865
+
866
+ /** @private */
867
+ _sleep(ms) {
868
+ return new Promise(r => setTimeout(r, ms));
869
+ }
701
870
  }
702
871
 
703
872
  module.exports = FolioService;
package/SiiSession.js CHANGED
@@ -264,6 +264,76 @@ class SiiSession {
264
264
  return { success: true, response: redirected.response };
265
265
  }
266
266
 
267
+ /**
268
+ * Cierra la sesión activa en el SII para liberar el cupo de sesiones.
269
+ * Es seguro llamar aunque no haya sesión activa.
270
+ */
271
+ async logout() {
272
+ if (!this.cookieJar) return;
273
+ // El SII usa estos endpoints según el tipo de autenticación.
274
+ // Intentamos ambos; ignoramos errores.
275
+ const logoutUrls = [
276
+ `https://${this.baseHost}/cgi_AUT2000/autLogout.cgi`,
277
+ 'https://www.sii.cl/AUT2000/autLogout.cgi',
278
+ 'https://herculesr.sii.cl/cgi_AUT2000/autLogout.cgi',
279
+ ];
280
+ for (const url of logoutUrls) {
281
+ try {
282
+ await this.request(url, { method: 'GET' });
283
+ } catch (_) {}
284
+ }
285
+ this.cookieJar = '';
286
+ }
287
+
288
+ /**
289
+ * Detecta la página de "demasiadas sesiones" y, si el SII ofrece un form
290
+ * para cerrar las sesiones anteriores, lo envía automáticamente.
291
+ * Devuelve true si se envió el form de cierre (hay que reintentar la sesión).
292
+ * @private
293
+ */
294
+ async _tryForceCloseSessions(body) {
295
+ if (!body || !body.includes('superado el m')) return false;
296
+
297
+ // Guardar HTML para diagnóstico
298
+ try {
299
+ const fs = require('fs');
300
+ const path = require('path');
301
+ const dbgDir = path.resolve(__dirname, '..', 'devlas-cloud-api-node', 'debug', 'sii-sessions');
302
+ fs.mkdirSync(dbgDir, { recursive: true });
303
+ fs.writeFileSync(path.join(dbgDir, `demasiadas-sesiones-${Date.now()}.html`), body, 'utf-8');
304
+ } catch (_) {}
305
+
306
+ // El SII a veces incluye un form o link para cerrar sesiones anteriores
307
+ // Buscamos: action con "CierraAnt", "cerrar", "logout" o "Anular"
308
+ const formActionMatch = body.match(/<form[^>]+action=["']([^"']*(?:CierraAnt|cerrar|Logout|anular)[^"']*)/i);
309
+ if (formActionMatch) {
310
+ const closeUrl = formActionMatch[1];
311
+ const inputs = SiiSession.extractInputValues(body);
312
+ console.warn(`[SiiSession] Detectadas demasiadas sesiones — cerrando sesiones anteriores en ${closeUrl}...`);
313
+ try {
314
+ await this.submitForm(closeUrl, { ...inputs, ACEPTAR: 'Aceptar' });
315
+ return true;
316
+ } catch (e) {
317
+ console.warn(`[SiiSession] No se pudo cerrar sesiones anteriores: ${e.message}`);
318
+ }
319
+ }
320
+
321
+ // Alternativa: link directo (href) a CierraAnt o similar
322
+ const linkMatch = body.match(/href=["']([^"']*CierraAnt[^"']*)/i);
323
+ if (linkMatch) {
324
+ const closeUrl = new URL(linkMatch[1], `https://${this.baseHost}`).toString();
325
+ console.warn(`[SiiSession] Cerrando sesiones anteriores via GET ${closeUrl}...`);
326
+ try {
327
+ await this.request(closeUrl, { method: 'GET' });
328
+ return true;
329
+ } catch (e) {
330
+ console.warn(`[SiiSession] No se pudo cerrar sesiones anteriores: ${e.message}`);
331
+ }
332
+ }
333
+
334
+ return false;
335
+ }
336
+
267
337
  /**
268
338
  * Asegura una sesión autenticada para acceder a una página
269
339
  * @param {string} targetPath - Ruta del recurso
@@ -278,9 +348,25 @@ class SiiSession {
278
348
 
279
349
  // Detectar bloqueo por demasiadas sesiones
280
350
  if (response.body && response.body.includes('superado el m')) {
281
- const errorMsg = 'SII: Demasiadas sesiones abiertas. Espera ~30 min o cierra sesión manualmente en el portal SII.';
282
- console.error(`\n[ERR] ${errorMsg}\n`);
283
- throw new Error(errorMsg);
351
+ // Intentar cerrar sesiones anteriores automáticamente si el SII lo ofrece
352
+ const closed = await this._tryForceCloseSessions(response.body);
353
+ if (closed) {
354
+ // Reintentar la sesión después de cerrar las anteriores
355
+ console.warn('[SiiSession] Sesiones anteriores cerradas — reintentando autenticación...');
356
+ response = await this.request(targetUrl, { method: 'GET' });
357
+ const redirected2 = await this.followRedirects(response);
358
+ response = redirected2.response;
359
+ // Si sigue con demasiadas sesiones, lanzar error
360
+ if (response.body && response.body.includes('superado el m')) {
361
+ const errorMsg = 'SII: Demasiadas sesiones abiertas. Espera ~30 min o cierra sesión manualmente en el portal SII.';
362
+ console.error(`\n[ERR] ${errorMsg}\n`);
363
+ throw new Error(errorMsg);
364
+ }
365
+ } else {
366
+ const errorMsg = 'SII: Demasiadas sesiones abiertas. Espera ~30 min o cierra sesión manualmente en el portal SII.';
367
+ console.error(`\n[ERR] ${errorMsg}\n`);
368
+ throw new Error(errorMsg);
369
+ }
284
370
  }
285
371
 
286
372
  // Si requiere autenticación
@@ -291,9 +377,23 @@ class SiiSession {
291
377
 
292
378
  // Verificar si el login resultó en bloqueo por sesiones
293
379
  if (response.body && response.body.includes('superado el m')) {
294
- const errorMsg = 'SII: Demasiadas sesiones abiertas. Espera ~30 min o cierra sesión manualmente en el portal SII.';
295
- console.error(`\n[ERR] ${errorMsg}\n`);
296
- throw new Error(errorMsg);
380
+ // Intentar cerrar sesiones anteriores automáticamente
381
+ const closed = await this._tryForceCloseSessions(response.body);
382
+ if (closed) {
383
+ console.warn('[SiiSession] Sesiones anteriores cerradas post-login — reintentando...');
384
+ const retry = await this.request(targetUrl, { method: 'GET' });
385
+ const redirected3 = await this.followRedirects(retry);
386
+ response = redirected3.response;
387
+ if (response.body && response.body.includes('superado el m')) {
388
+ const errorMsg = 'SII: Demasiadas sesiones abiertas. Espera ~30 min o cierra sesión manualmente en el portal SII.';
389
+ console.error(`\n[ERR] ${errorMsg}\n`);
390
+ throw new Error(errorMsg);
391
+ }
392
+ } else {
393
+ const errorMsg = 'SII: Demasiadas sesiones abiertas. Espera ~30 min o cierra sesión manualmente en el portal SII.';
394
+ console.error(`\n[ERR] ${errorMsg}\n`);
395
+ throw new Error(errorMsg);
396
+ }
297
397
  }
298
398
  }
299
399
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devlas/dte-sii",
3
- "version": "2.9.2",
3
+ "version": "2.9.3",
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",