@devlas/dte-sii 2.5.16 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ConsumoFolio.js CHANGED
@@ -223,8 +223,9 @@ class ConsumoFolio {
223
223
  }
224
224
 
225
225
  // Construir XML SIN indentación para C14N consistente
226
+ // NOTA: SII DTEUpload requiere ISO-8859-1 (endpoint SOAP/CGI antiguo)
226
227
  const schemaLoc = 'http://www.sii.cl/SiiDte ConsumoFolio_v10.xsd';
227
- const xmlSinFirma = `<?xml version="1.0" encoding="UTF-8"?>
228
+ const xmlSinFirma = `<?xml version="1.0" encoding="ISO-8859-1"?>
228
229
  <ConsumoFolios xmlns="http://www.sii.cl/SiiDte" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="${schemaLoc}" version="1.0"><DocumentoConsumoFolios ID="${this.id}"><Caratula version="1.0"><RutEmisor>${this.caratula.RutEmisor}</RutEmisor><RutEnvia>${this.caratula.RutEnvia}</RutEnvia><FchResol>${this.caratula.FchResol}</FchResol><NroResol>${this.caratula.NroResol}</NroResol><FchInicio>${this.caratula.FchInicio}</FchInicio><FchFinal>${this.caratula.FchFinal}</FchFinal><SecEnvio>${this.caratula.SecEnvio}</SecEnvio><TmstFirmaEnv>${this.caratula.TmstFirmaEnv}</TmstFirmaEnv></Caratula>${resumenXml}</DocumentoConsumoFolios></ConsumoFolios>`;
229
230
 
230
231
  // Firmar el documento
package/EnviadorSII.js CHANGED
@@ -1132,7 +1132,9 @@ class EnviadorSII {
1132
1132
  body += xml;
1133
1133
  body += `\r\n--${boundary}--\r\n`;
1134
1134
 
1135
- return await this._enviarMultipart(url, body, boundary, xml, 'RCOF');
1135
+ // SII DTEUpload requiere ISO-8859-1 (igual que EnvioBOLETA y EnvioDTE SOAP)
1136
+ const bodyBuffer = Buffer.from(body, 'latin1');
1137
+ return await this._enviarMultipart(url, bodyBuffer, boundary, xml, 'RCOF');
1136
1138
  }
1137
1139
 
1138
1140
  /**
@@ -1337,9 +1339,30 @@ class EnviadorSII {
1337
1339
  3: 'Error en XML',
1338
1340
  4: 'Error de firma',
1339
1341
  5: 'Error de sistema',
1342
+ 7: 'Envío duplicado — el SII ya recibió este set anteriormente',
1340
1343
  99: 'Error desconocido',
1341
1344
  };
1342
1345
 
1346
+ // STATUS 7 = Envío duplicado (para EnvioBOLETA/EnvioDTE).
1347
+ // EXCEPCIÓN: CHR-00001 en <DETAIL> = error de charset, NO es duplicado.
1348
+ if (status === 7) {
1349
+ const detailMatch = responseText.match(/<ERROR>([^<]*)<\/ERROR>/i);
1350
+ const detailMsg = detailMatch ? detailMatch[1] : '';
1351
+ if (detailMsg.includes('CHR-00001')) {
1352
+ return {
1353
+ ok: false,
1354
+ status,
1355
+ error: `Error de charset en XML enviado (${detailMsg}). Verifique encoding="ISO-8859-1" en la declaración XML.`,
1356
+ respuesta: responseText,
1357
+ duplicado: false,
1358
+ };
1359
+ }
1360
+ if (trackId) {
1361
+ return { ok: true, status, trackId, archivo: fileName, mensaje: `[DUPLICADO] TrackID recuperado: ${trackId}`, respuesta: responseText };
1362
+ }
1363
+ return { ok: false, status, error: errorMessages[7], respuesta: responseText, duplicado: true };
1364
+ }
1365
+
1343
1366
  return {
1344
1367
  ok: false,
1345
1368
  status: status,
@@ -247,31 +247,41 @@ class BoletaCert {
247
247
  */
248
248
  generarConsumoFolio(envioBoleta, options = {}) {
249
249
  const { ConsumoFolio } = this._lib();
250
+ const { DOMParser } = require('@xmldom/xmldom');
250
251
 
251
252
  const consumoFolio = new ConsumoFolio(this.certificado);
252
253
 
253
- // Agregar documentos desde el EnvioBOLETA
254
- // Necesitamos extraer la info de cada boleta
255
- const boletas = envioBoleta.dtes || [];
256
- for (const dte of boletas) {
257
- const tipo = dte.datos.Encabezado.IdDoc.TipoDTE;
258
- const folio = dte.datos.Encabezado.IdDoc.Folio;
259
- const fechaEmision = dte.datos.Encabezado.IdDoc.FchEmis;
260
- const totales = dte.datos.Encabezado.Totales;
254
+ // Parsear el XML generado para obtener los totales exactos que quedaron firmados.
255
+ // NO leer de dte.datos.Encabezado.Totales: son valores pre-cálculo (input del constructor)
256
+ // y no coinciden con los totales calculados que el SII valida contra el RCOF.
257
+ const parser = new DOMParser();
258
+ const doc = parser.parseFromString(envioBoleta.xml, 'text/xml');
259
+ const dteEls = doc.getElementsByTagName('Documento');
260
+
261
+ for (let i = 0; i < dteEls.length; i++) {
262
+ const dteEl = dteEls[i];
263
+ const tipo = parseInt(dteEl.getElementsByTagName('TipoDTE')[0]?.textContent || '39', 10);
264
+ const folio = parseInt(dteEl.getElementsByTagName('Folio')[0]?.textContent || '0', 10);
265
+ const fchEmis = dteEl.getElementsByTagName('FchEmis')[0]?.textContent || '';
266
+
267
+ const totalesEl = dteEl.getElementsByTagName('Totales')[0];
268
+ const mntNeto = parseInt(totalesEl?.getElementsByTagName('MntNeto')[0]?.textContent || '0', 10);
269
+ const mntExe = parseInt(totalesEl?.getElementsByTagName('MntExe')[0]?.textContent || '0', 10);
270
+ const iva = parseInt(totalesEl?.getElementsByTagName('IVA')[0]?.textContent || '0', 10);
271
+ const mntTotal = parseInt(totalesEl?.getElementsByTagName('MntTotal')[0]?.textContent || '0', 10);
261
272
 
262
- // Estructura que espera ConsumoFolio: Totales como hijo de Encabezado
263
273
  consumoFolio.agregar(tipo, {
264
274
  Encabezado: {
265
275
  IdDoc: {
266
276
  TipoDTE: tipo,
267
277
  Folio: folio,
268
- FchEmis: fechaEmision,
278
+ FchEmis: fchEmis,
269
279
  },
270
280
  Totales: {
271
- MntNeto: totales.MntNeto || 0,
272
- MntExe: totales.MntExe || 0,
273
- IVA: totales.IVA || 0,
274
- MntTotal: totales.MntTotal || 0,
281
+ MntNeto: mntNeto,
282
+ MntExe: mntExe,
283
+ IVA: iva,
284
+ MntTotal: mntTotal,
275
285
  }
276
286
  },
277
287
  });
@@ -384,6 +394,7 @@ class BoletaCert {
384
394
  * @param {string} options.setPath - Ruta al archivo del set de pruebas
385
395
  * @param {Object} options.cafBoleta - CAF para boletas tipo 39
386
396
  * @param {number} options.folioInicial - Folio inicial a usar
397
+ * @param {number} [options.secEnvio=1] - Número de secuencia del RCOF (auto-incrementar por run)
387
398
  * @returns {Promise<Object>} Resultado con trackIds
388
399
  */
389
400
  async ejecutarCertificacion(options) {
@@ -431,52 +442,125 @@ class BoletaCert {
431
442
  const resultadoBoleta = await enviador.enviarBoletaSoap(envioBoleta);
432
443
 
433
444
  if (!resultadoBoleta.ok) {
434
- console.log(` [ERR] Error: ${resultadoBoleta.error}`);
435
- return { success: false, error: resultadoBoleta.error, fase: 'EnvioBOLETA' };
445
+ if (resultadoBoleta.duplicado) {
446
+ // STATUS 7 = Envío duplicado el mismo set ya fue enviado al SII con anterioridad.
447
+ // Continuamos con el RCOF y la declaración; el SII ya tiene el set.
448
+ console.log(` [⚠️ DUPLICADO] EnvioBOLETA ya enviado anteriormente — continuando con RCOF y declaración...`);
449
+ resultadoBoleta = { ok: false, duplicado: true, trackId: null };
450
+ } else {
451
+ console.log(` [ERR] Error: ${resultadoBoleta.error}`);
452
+ return { success: false, error: resultadoBoleta.error, fase: 'EnvioBOLETA' };
453
+ }
454
+ } else {
455
+ console.log(` [OK] Enviado - TrackId: ${resultadoBoleta.trackId}`);
436
456
  }
437
- console.log(` [OK] Enviado - TrackId: ${resultadoBoleta.trackId}`);
438
457
 
439
- // 5. Generar RCOF
440
- console.log('\nGenerando RCOF (ConsumoFolio)...');
441
- const consumoFolio = this.generarConsumoFolio(envioBoleta);
442
- consumoFolio.generar(); // generar() ya incluye firmar() internamente
443
- console.log(` XML generado: ${consumoFolio.xml.length} bytes`);
444
-
445
- // Guardar debug
446
- if (this.debugDir) {
447
- const debugPath = path.join(this.debugDir, 'boleta-cert');
448
- fs.writeFileSync(path.join(debugPath, 'ConsumoFolio.xml'), consumoFolio.xml, 'utf-8');
449
- console.log(` Guardado en: ${path.join(debugPath, 'ConsumoFolio.xml')}`);
458
+ // 5 & 6. Generar y enviar RCOF — loop hasta que SII lo acepte o se agoten intentos.
459
+ // Estrategia: enviar → si ok, esperar 30s → consultar estado (EPR o RPR = éxito).
460
+ // Si DUPLICADO: el SII ya tiene un RCOF para este RUT/período. El entorno de certificación
461
+ // acepta solo 1 RCOF por RUT por día — tras 3 duplicados consecutivos se asume ya enviado.
462
+ console.log('\nGenerando y enviando RCOF (ConsumoFolio)...');
463
+ const secInicio = options.secEnvio || 100;
464
+ const MAX_INTENTOS_RCOF = 10;
465
+ const MAX_DUPLICADOS_CONSECUTIVOS = 3;
466
+ let secUsado = secInicio;
467
+ let resultadoRCOF = { ok: false };
468
+ let trackIdRCOFloop = null;
469
+ let duplicadosConsecutivos = 0;
470
+
471
+ for (let intento = 0; intento < MAX_INTENTOS_RCOF; intento++) {
472
+ const secActual = secInicio + intento;
473
+ const cf = this.generarConsumoFolio(envioBoleta, { secEnvio: secActual });
474
+ cf.generar();
475
+
476
+ if (this.debugDir) {
477
+ const debugPath = path.join(this.debugDir, 'boleta-cert');
478
+ fs.mkdirSync(debugPath, { recursive: true });
479
+ const fname = intento === 0 ? 'ConsumoFolio.xml' : `ConsumoFolio-sec${secActual}.xml`;
480
+ fs.writeFileSync(path.join(debugPath, fname), cf.xml, 'utf-8');
481
+ }
482
+
483
+ console.log(` → Enviando RCOF SecEnvio=${secActual} (intento ${intento + 1}/${MAX_INTENTOS_RCOF})...`);
484
+ const res = await enviador.enviarConsumoFolios(cf);
485
+ secUsado = secActual;
486
+
487
+ if (!res.ok && res.duplicado) {
488
+ duplicadosConsecutivos++;
489
+ console.log(` [⚠️ DUPLICADO ${duplicadosConsecutivos}/${MAX_DUPLICADOS_CONSECUTIVOS}] SecEnvio=${secActual} ya existe en SII.`);
490
+ if (duplicadosConsecutivos >= MAX_DUPLICADOS_CONSECUTIVOS) {
491
+ // El entorno SII solo permite 1 RCOF por RUT/día → ya hay uno registrado. Continuar.
492
+ console.log(` [ℹ️] SII ya tiene un RCOF para hoy (${MAX_DUPLICADOS_CONSECUTIVOS} duplicados consecutivos). Continuando sin nuevo trackId RCOF.`);
493
+ resultadoRCOF = { ok: true, trackId: null, rcofYaEnviado: true };
494
+ break;
495
+ }
496
+ console.log(` → Probando sec=${secActual + 1}...`);
497
+ continue;
498
+ }
499
+
500
+ duplicadosConsecutivos = 0;
501
+
502
+ if (!res.ok) {
503
+ console.log(` [ERR] Error al enviar RCOF (sec=${secActual}): ${res.error}`);
504
+ resultadoRCOF = res;
505
+ break;
506
+ }
507
+
508
+ // Enviado correctamente — esperar 30s y verificar que SII lo aceptó
509
+ // Nota: EPR (Envío Procesado) Y RPR (Aceptado con Reparos) son ambos válidos para RCOF.
510
+ trackIdRCOFloop = res.trackId;
511
+ console.log(` ✓ RCOF recibido por SII — TrackId: ${trackIdRCOFloop} (SecEnvio=${secActual})`);
512
+ console.log(` ⏳ Esperando 30s para verificar estado RCOF en SII...`);
513
+ await new Promise(r => setTimeout(r, 30000));
514
+
515
+ let estadoRcof;
516
+ try {
517
+ estadoRcof = await enviador.consultarEstadoSoap(trackIdRCOFloop, this.emisor.rut);
518
+ console.log(` [Estado RCOF] ${estadoRcof.estado} — ${estadoRcof.mensaje}`);
519
+ } catch (e) {
520
+ console.log(` [!] No se pudo consultar estado RCOF: ${e.message} — asumiendo aceptado`);
521
+ resultadoRCOF = { ok: true, trackId: trackIdRCOFloop };
522
+ break;
523
+ }
524
+
525
+ // EPR = procesado, RPR = aceptado con reparos (ambos válidos para RCOF), estados intermedios = OK para continuar
526
+ if (estadoRcof.esExitoso || estadoRcof.esIntermedio) {
527
+ resultadoRCOF = { ok: true, trackId: trackIdRCOFloop, estado: estadoRcof.estado };
528
+ break;
529
+ }
530
+
531
+ // SII lo rechazó explícitamente — probar con el siguiente sec
532
+ console.log(` [⚠️ RECHAZADO] RCOF sec=${secActual} rechazado (${estadoRcof.estado}: ${estadoRcof.glosa || estadoRcof.mensaje}). Probando sec=${secActual + 1}...`);
533
+ trackIdRCOFloop = null;
450
534
  }
451
-
452
- // 6. Enviar RCOF
453
- console.log('\nEnviando RCOF al SII...');
454
- const resultadoRCOF = await enviador.enviarConsumoFolios(consumoFolio);
455
-
535
+
456
536
  if (!resultadoRCOF.ok) {
457
- console.log(` [ERR] Error: ${resultadoRCOF.error}`);
458
- return {
459
- success: false,
460
- error: resultadoRCOF.error,
461
- fase: 'RCOF',
462
- trackIdBoleta: resultadoBoleta.trackId
463
- };
537
+ if (!resultadoRCOF.error) {
538
+ console.log(` [⚠️] RCOF no aceptado tras ${MAX_INTENTOS_RCOF} intentos. Continuando sin trackId RCOF.`);
539
+ } else {
540
+ console.log(` [ERR] Error RCOF: ${resultadoRCOF.error}`);
541
+ return {
542
+ success: false,
543
+ error: resultadoRCOF.error,
544
+ fase: 'RCOF',
545
+ trackIdBoleta: resultadoBoleta.trackId
546
+ };
547
+ }
464
548
  }
465
- console.log(` [OK] Enviado - TrackId: ${resultadoRCOF.trackId}`);
466
549
 
467
550
  // Resumen
468
551
  console.log('\n' + '═'.repeat(60));
469
552
  console.log('[OK] CERTIFICACIÓN BOLETAS COMPLETADA');
470
553
  console.log('═'.repeat(60));
471
- console.log(` EnvioBOLETA: ${resultadoBoleta.trackId}`);
472
- console.log(` RCOF: ${resultadoRCOF.trackId}`);
554
+ console.log(` EnvioBOLETA: ${resultadoBoleta.trackId ?? '(enviado previamente)'}`);
555
+ console.log(` RCOF: ${resultadoRCOF.trackId ?? '(enviado previamente)'}`);
473
556
  console.log(` Boletas: ${boletas.length}`);
474
557
  console.log(` Folios: ${folioInicial} - ${folioFinal}`);
475
558
 
476
559
  return {
477
560
  success: true,
478
- trackIdBoleta: resultadoBoleta.trackId,
479
- trackIdRCOF: resultadoRCOF.trackId,
561
+ trackIdBoleta: resultadoBoleta.trackId ?? null,
562
+ trackIdRCOF: resultadoRCOF.trackId ?? null,
563
+ secEnvioRCOF: secUsado,
480
564
  boletas: boletas.length,
481
565
  folioInicial,
482
566
  folioFinal,
@@ -1926,11 +1926,7 @@ class CertRunner {
1926
1926
 
1927
1927
  let browser;
1928
1928
  try {
1929
- browser = await puppeteer.launch({
1930
- headless: true,
1931
- ignoreHTTPSErrors: true,
1932
- args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors'],
1933
- });
1929
+ browser = await puppeteer.launch(this._getPuppeteerLaunchOptions());
1934
1930
 
1935
1931
  const page = await browser.newPage();
1936
1932
  await page.setCookie(...puppeteerCookies);
@@ -2114,11 +2110,7 @@ class CertRunner {
2114
2110
  const [rutNum, dvChar] = this.config.emisor.rut.split('-');
2115
2111
  let browser;
2116
2112
  try {
2117
- browser = await puppeteer.launch({
2118
- headless: true,
2119
- ignoreHTTPSErrors: true,
2120
- args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors'],
2121
- });
2113
+ browser = await puppeteer.launch(this._getPuppeteerLaunchOptions());
2122
2114
  const page = await browser.newPage();
2123
2115
  await page.setCookie(...puppeteerCookies);
2124
2116
  await page.goto('https://www4.sii.cl/pdfdteInternet/', { waitUntil: 'networkidle2', timeout: 60000 });
@@ -2137,23 +2129,32 @@ class CertRunner {
2137
2129
  if (btn) btn.click();
2138
2130
  }, 'Rut');
2139
2131
 
2140
- // Descartar diálogo "ya existe revisión" si aparece
2141
- await new Promise(r => setTimeout(r, 1500));
2142
- await page.evaluate(() => {
2143
- const si = Array.from(document.querySelectorAll('button.x-btn-text'))
2144
- .find(b => /^s[ií]$/i.test(b.textContent.trim()));
2145
- if (si) si.click();
2146
- }).catch(() => {});
2132
+ // IMPORTANTE: NO hacer click en "Sí" del diálogo "ya existe revisión".
2133
+ // Hacerlo crea una revisión nueva vacía y borra el estado "POR REVISAR" del DOM.
2134
+ // El estado de la revisión existente es visible en el body aunque el diálogo esté abierto.
2135
+ await new Promise(r => setTimeout(r, 3000));
2147
2136
 
2148
- // Esperar hasta 8s a que aparezca el estado en el DOM
2137
+ // Esperar hasta 10s a que aparezca el estado en el DOM (detrás del diálogo si aplica)
2149
2138
  await page.waitForFunction(() => {
2150
2139
  const t = (document.body.textContent || '').toUpperCase();
2151
- return t.includes('ESTADO DE LA REVISI') ||
2152
- t.includes('POR REVISAR') || t.includes('APROBADO') ||
2153
- t.includes('EN REVISI') || t.includes('RECHAZADO');
2154
- }, { timeout: 8000, polling: 500 }).catch(() => {});
2140
+ return t.includes('POR REVISAR') || t.includes('APROBADO') ||
2141
+ t.includes('EN REVISI') || t.includes('RECHAZADO') ||
2142
+ t.includes('ENVIADO AL SII');
2143
+ }, { timeout: 10000, polling: 500 }).catch(() => {});
2155
2144
 
2145
+ // Leer estado directamente del label del formulario en el DOM
2156
2146
  const estado = await page.evaluate(() => {
2147
+ // Primero intentar leer el label específico de estado (más preciso)
2148
+ const labels = Array.from(document.querySelectorAll('.x-form-label'));
2149
+ for (const lbl of labels) {
2150
+ const txt = (lbl.textContent || '').trim().toUpperCase();
2151
+ if (txt === 'POR REVISAR') return 'POR REVISAR';
2152
+ if (txt === 'APROBADO') return 'APROBADO';
2153
+ if (txt.startsWith('EN REVISI')) return 'EN REVISIÓN';
2154
+ if (txt === 'RECHAZADO') return 'RECHAZADO';
2155
+ if (txt.includes('ENVIADO AL SII')) return 'ENVIADO AL SII';
2156
+ }
2157
+ // Fallback: buscar en todo el body
2157
2158
  const t = (document.body.textContent || '').toUpperCase();
2158
2159
  if (t.includes('APROBADO')) return 'APROBADO';
2159
2160
  if (t.includes('POR REVISAR')) return 'POR REVISAR';
@@ -2167,7 +2168,13 @@ class CertRunner {
2167
2168
  } catch (err) {
2168
2169
  return { estado: null, error: err.message };
2169
2170
  } finally {
2170
- if (browser) await browser.close().catch(() => {});
2171
+ if (browser) {
2172
+ await Promise.race([
2173
+ browser.close().catch(() => {}),
2174
+ new Promise(r => setTimeout(r, 8000)),
2175
+ ]);
2176
+ try { browser.process()?.kill('SIGKILL'); } catch { /* ignorar */ }
2177
+ }
2171
2178
  }
2172
2179
  }
2173
2180
 
@@ -2222,12 +2229,7 @@ class CertRunner {
2222
2229
 
2223
2230
  let browser;
2224
2231
  try {
2225
- browser = await puppeteer.launch({
2226
- headless: true,
2227
- ignoreHTTPSErrors: true,
2228
- protocolTimeout: 300000, // 5 min — DOM.setFileInputFiles con 256 archivos supera el default de 30s
2229
- args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors'],
2230
- });
2232
+ browser = await puppeteer.launch(this._getPuppeteerLaunchOptions([], { protocolTimeout: 300000 }));
2231
2233
  const page = await browser.newPage();
2232
2234
  await page.setCookie(...puppeteerCookies);
2233
2235
 
@@ -2618,14 +2620,26 @@ class CertRunner {
2618
2620
  }
2619
2621
 
2620
2622
  const pageText = await page.$eval('body', el => el.textContent).catch(() => '');
2621
- const exitoso = /revision.*creada|solicitud.*enviada|documentos.*enviados|fue.*enviado|[eé]xito/i.test(pageText);
2623
+ const exitoso = /revision.*creada|solicitud.*enviada|documentos.*enviados|fue.*enviado|[eé]xito|por revisar/i.test(pageText);
2622
2624
  console.log(` → Resultado: ${exitoso ? '[OK] enviado correctamente' : '[!] sin confirmación explícita'}`);
2625
+ // Imprimir el marcador de éxito ANTES de cerrar el browser para que quede en stdout
2626
+ // aunque browser.close() cuelgue y el proceso sea forzado a terminar.
2627
+ if (exitoso) process.stdout.write('\nMUESTRAS SUBIDAS EXITOSAMENTE\n');
2623
2628
  return { success: exitoso, pageText: pageText.substring(0, 800) };
2624
2629
 
2625
2630
  } catch (err) {
2626
2631
  return { success: false, error: err.message };
2627
2632
  } finally {
2628
- if (browser) await browser.close().catch(() => {});
2633
+ if (browser) {
2634
+ // browser.close() puede colgarse indefinidamente si Chromium no responde.
2635
+ // Limitar a 8 segundos; si no cierra, matar el proceso del browser directamente.
2636
+ await Promise.race([
2637
+ browser.close().catch(() => {}),
2638
+ new Promise(r => setTimeout(r, 8000)),
2639
+ ]);
2640
+ // Forzar cierre del proceso de Chromium si quedó colgado
2641
+ try { browser.process()?.kill('SIGKILL'); } catch { /* ignorar */ }
2642
+ }
2629
2643
  }
2630
2644
  }
2631
2645
 
@@ -2655,25 +2669,37 @@ class CertRunner {
2655
2669
 
2656
2670
  let browser;
2657
2671
  try {
2658
- browser = await puppeteer.launch({
2659
- headless: true,
2660
- ignoreHTTPSErrors: true,
2661
- args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors'],
2662
- });
2672
+ browser = await puppeteer.launch(this._getPuppeteerLaunchOptions());
2663
2673
  const page = await browser.newPage();
2664
2674
  await page.setCookie(...puppeteerCookies);
2665
2675
  page.on('dialog', async dlg => { console.log(` → [dialog SET=1] ${dlg.message()}`); await dlg.accept(); });
2666
2676
 
2677
+ // Helper debug: guarda screenshot + HTML con timestamp
2678
+ const dbgDirSet1 = this.config.debugDir ? path.join(this.config.debugDir, 'boleta-set1') : null;
2679
+ if (dbgDirSet1) fs.mkdirSync(dbgDirSet1, { recursive: true });
2680
+ const saveDebugSet1 = async (label) => {
2681
+ if (!dbgDirSet1) return;
2682
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
2683
+ const base = path.join(dbgDirSet1, `${label}-${ts}`);
2684
+ try { await page.screenshot({ path: `${base}.png`, fullPage: true }); } catch {}
2685
+ try { fs.writeFileSync(`${base}.html`, await page.content(), 'utf-8'); } catch {}
2686
+ console.log(` [debug] Captura guardada: ${base}.png`);
2687
+ };
2688
+
2667
2689
  console.log(' → Cargando portal certBolElectDteInternet (SET=1)...');
2668
2690
  await page.goto('https://www4.sii.cl/certBolElectDteInternet/?SET=1', {
2669
2691
  waitUntil: 'networkidle2', timeout: 60000,
2670
2692
  });
2671
2693
  await new Promise(r => setTimeout(r, 2000));
2694
+ await saveDebugSet1('01-carga');
2672
2695
 
2673
2696
  // Paso 1: RUT empresa → "Confirmar Empresa"
2674
- const rutInput = await page.$('input[maxlength="8"]');
2675
- const dvInput = await page.$('input[maxlength="1"]');
2676
- if (!rutInput) throw new Error('certBolElectDteInternet/?SET=1: no se encontró campo RUT');
2697
+ const rutInput = await page.waitForSelector('input[maxlength="8"]', { timeout: 15000 }).catch(() => null);
2698
+ const dvInput = await page.waitForSelector('input[maxlength="1"]', { timeout: 5000 }).catch(() => null);
2699
+ if (!rutInput) {
2700
+ await saveDebugSet1('ERROR-norut');
2701
+ throw new Error('certBolElectDteInternet/?SET=1: no se encontró campo RUT');
2702
+ }
2677
2703
  console.log(` → Ingresando RUT empresa: ${rutNum}-${dvChar}`);
2678
2704
  await rutInput.click({ clickCount: 3 }); await rutInput.type(rutNum);
2679
2705
  await dvInput.click({ clickCount: 3 }); await dvInput.type(dvChar);
@@ -2690,6 +2716,7 @@ class CertRunner {
2690
2716
  return document.querySelector('input[type="checkbox"]') !== null;
2691
2717
  }, { timeout: 40000, polling: 500 }).catch(() => {});
2692
2718
  await new Promise(r => setTimeout(r, 500));
2719
+ await saveDebugSet1('02-post-confirm');
2693
2720
 
2694
2721
  // Paso 2: Marcar todos los checkboxes
2695
2722
  const nCbs = await page.evaluate(() => {
@@ -2776,9 +2803,9 @@ class CertRunner {
2776
2803
  });
2777
2804
 
2778
2805
  if (!setText || setText.trim().length < 10) {
2779
- if (this.config.debugDir) {
2780
- fs.mkdirSync(this.config.debugDir, { recursive: true });
2781
- fs.writeFileSync(path.join(this.config.debugDir, 'boleta-set-debug.txt'), setText || '', 'utf-8');
2806
+ if (dbgDirSet1) {
2807
+ fs.writeFileSync(path.join(dbgDirSet1, 'boleta-set-contenido-vacio.txt'), setText || '', 'utf-8');
2808
+ await saveDebugSet1('ERROR-descarga-vacia');
2782
2809
  }
2783
2810
  throw new Error(`DownloadFileServlet devolvió contenido vacío (${setText?.length ?? 0} chars). Verificar cookies.`);
2784
2811
  }
@@ -2793,6 +2820,15 @@ class CertRunner {
2793
2820
  console.log(` ✓ Set de pruebas obtenido (${setText.length} chars)`);
2794
2821
  return { success: true, setText };
2795
2822
  } catch (err) {
2823
+ try {
2824
+ if (dbgDirSet1) {
2825
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
2826
+ const base = path.join(dbgDirSet1, `ERROR-catch-${ts}`);
2827
+ await page.screenshot({ path: `${base}.png`, fullPage: true }).catch(() => {});
2828
+ fs.writeFileSync(`${base}.html`, await page.content().catch(() => ''), 'utf-8');
2829
+ console.log(` [debug] Captura de error guardada: ${base}.png`);
2830
+ }
2831
+ } catch {}
2796
2832
  return { success: false, error: err.message };
2797
2833
  } finally {
2798
2834
  if (browser) await browser.close().catch(() => {});
@@ -2821,20 +2857,27 @@ class CertRunner {
2821
2857
 
2822
2858
  let browser;
2823
2859
  try {
2824
- browser = await puppeteer.launch({
2825
- headless: true,
2826
- ignoreHTTPSErrors: true,
2827
- args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors'],
2828
- });
2860
+ browser = await puppeteer.launch(this._getPuppeteerLaunchOptions());
2829
2861
  const page = await browser.newPage();
2830
2862
  await page.setCookie(...puppeteerCookies);
2831
2863
  page.on('dialog', async dlg => { console.log(` → [dialog SET=2] ${dlg.message()}`); await dlg.accept(); });
2832
2864
 
2865
+ const debugSet2Dir = this.config.debugDir ? require('path').join(this.config.debugDir, 'boleta-set2') : null;
2866
+ if (debugSet2Dir) require('fs').mkdirSync(debugSet2Dir, { recursive: true });
2867
+ const snapSet2 = async (nombre) => {
2868
+ if (!debugSet2Dir) return;
2869
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
2870
+ const p = require('path').join(debugSet2Dir, `${nombre}-${ts}.png`);
2871
+ await page.screenshot({ path: p, fullPage: true }).catch(() => {});
2872
+ console.log(` [debug] Captura guardada: ${p}`);
2873
+ };
2874
+
2833
2875
  console.log(' → Cargando portal certBolElectDteInternet (SET=2)...');
2834
2876
  await page.goto('https://www4.sii.cl/certBolElectDteInternet/?SET=2', {
2835
2877
  waitUntil: 'networkidle2', timeout: 60000,
2836
2878
  });
2837
2879
  await new Promise(r => setTimeout(r, 2000));
2880
+ await snapSet2('01-carga');
2838
2881
 
2839
2882
  // Paso 1: RUT empresa → "Confirmar Empresa"
2840
2883
  const rutInput = await page.$('input[maxlength="8"]');
@@ -2856,10 +2899,15 @@ class CertRunner {
2856
2899
  return document.querySelector('input[maxlength="15"]') !== null;
2857
2900
  }, { timeout: 40000, polling: 500 }).catch(() => {});
2858
2901
  await new Promise(r => setTimeout(r, 500));
2902
+ await snapSet2('02-post-confirm');
2859
2903
 
2860
2904
  // Paso 2: Ingresar TrackId
2861
2905
  const trackInput = await page.$('input[maxlength="15"]');
2862
- if (!trackInput) throw new Error('No se encontró campo "Identificador de Envio" en certBolElectDteInternet/?SET=2');
2906
+ if (!trackInput) {
2907
+ const pageText = await page.evaluate(() => (document.body.innerText || '').trim().substring(0, 600));
2908
+ console.log(` [!] Texto del portal al fallar:\n${pageText}`);
2909
+ throw new Error('No se encontró campo "Identificador de Envio" en certBolElectDteInternet/?SET=2');
2910
+ }
2863
2911
  console.log(` → Ingresando TrackId: ${trackId}`);
2864
2912
  await trackInput.click({ clickCount: 3 });
2865
2913
  await trackInput.type(String(trackId));
@@ -2878,6 +2926,7 @@ class CertRunner {
2878
2926
  return t.includes('ENVI') || t.includes('CORREO') || t.includes('ERROR') ||
2879
2927
  t.includes('VALIDACI') || t.includes('SOLICITUD');
2880
2928
  }, { timeout: 15000, polling: 500 }).catch(() => {});
2929
+ await snapSet2('03-respuesta');
2881
2930
 
2882
2931
  const respuesta = await page.evaluate(() => (document.body.innerText || '').trim().substring(0, 500));
2883
2932
  console.log(` ✓ Validación solicitada. Respuesta: ${respuesta.substring(0, 120)}`);
@@ -2920,11 +2969,7 @@ class CertRunner {
2920
2969
 
2921
2970
  let browser;
2922
2971
  try {
2923
- browser = await puppeteer.launch({
2924
- headless: true,
2925
- ignoreHTTPSErrors: true,
2926
- args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors'],
2927
- });
2972
+ browser = await puppeteer.launch(this._getPuppeteerLaunchOptions());
2928
2973
  const page = await browser.newPage();
2929
2974
  await page.setCookie(...puppeteerCookies);
2930
2975
 
@@ -2936,6 +2981,18 @@ class CertRunner {
2936
2981
  await dlg.accept();
2937
2982
  });
2938
2983
 
2984
+ // Helper debug: guarda screenshot + HTML con timestamp
2985
+ const dbgDirDecl = this.config.debugDir ? path.join(this.config.debugDir, 'boleta-declaracion') : null;
2986
+ if (dbgDirDecl) fs.mkdirSync(dbgDirDecl, { recursive: true });
2987
+ const saveDebugDecl = async (label) => {
2988
+ if (!dbgDirDecl) return;
2989
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
2990
+ const base = path.join(dbgDirDecl, `${label}-${ts}`);
2991
+ try { await page.screenshot({ path: `${base}.png`, fullPage: true }); } catch {}
2992
+ try { fs.writeFileSync(`${base}.html`, await page.content(), 'utf-8'); } catch {}
2993
+ console.log(` [debug] Captura guardada: ${base}.png`);
2994
+ };
2995
+
2939
2996
  // ── PASOS 1+2: Confirmar Empresa → esperar formulario (con retry) ──
2940
2997
  // El portal GWT a veces responde con error transitorio ("empresa no autorizada")
2941
2998
  // que se resuelve recargando la página y reintentando.
@@ -2950,11 +3007,15 @@ class CertRunner {
2950
3007
  waitUntil: 'networkidle2', timeout: 60000,
2951
3008
  });
2952
3009
  await new Promise(r => setTimeout(r, 2500));
3010
+ await saveDebugDecl(`intento${intento}-01-carga`);
2953
3011
 
2954
3012
  // GWT requiere eventos de teclado reales — NO funciona con .value = ...
2955
- const rutInput = await page.$('input[maxlength="8"]');
2956
- const dvInput = await page.$('input[maxlength="1"]');
2957
- if (!rutInput) throw new Error('certBolElectDteInternet/: no se encontró campo RUT empresa');
3013
+ const rutInput = await page.waitForSelector('input[maxlength="8"]', { timeout: 15000 }).catch(() => null);
3014
+ const dvInput = await page.waitForSelector('input[maxlength="1"]', { timeout: 5000 }).catch(() => null);
3015
+ if (!rutInput) {
3016
+ await saveDebugDecl(`intento${intento}-ERROR-norut`);
3017
+ throw new Error('certBolElectDteInternet/: no se encontró campo RUT empresa');
3018
+ }
2958
3019
  console.log(` → Ingresando RUT empresa: ${rutEmpresaRaw}`);
2959
3020
  await rutInput.click({ clickCount: 3 }); await rutInput.type(rutEmpNum);
2960
3021
  await dvInput.click({ clickCount: 3 }); await dvInput.type(rutEmpDv);
@@ -2972,10 +3033,12 @@ class CertRunner {
2972
3033
  await page.waitForFunction(() => {
2973
3034
  return document.querySelector('input[type="checkbox"]') !== null;
2974
3035
  }, { timeout: 40000, polling: 500 }).catch(() => {});
3036
+ await saveDebugDecl(`intento${intento}-02-post-confirm`);
2975
3037
 
2976
3038
  if (dialogMsg) {
2977
3039
  // Portal lanzó alert — puede ser transitorio. Guardar y reintentar.
2978
3040
  lastDialogMsg = dialogMsg;
3041
+ await saveDebugDecl(`intento${intento}-DIALOG`);
2979
3042
  console.log(` [!] Portal respondió con alerta en intento ${intento}: ${dialogMsg.substring(0, 100)}`);
2980
3043
  if (intento < MAX_INTENTOS) {
2981
3044
  console.log(' → Recargando y reintentando en 3s...');
@@ -2983,6 +3046,7 @@ class CertRunner {
2983
3046
  continue;
2984
3047
  }
2985
3048
  // Agotados los intentos con alerta — el SII puede requerir esperar SOK
3049
+ await saveDebugDecl('PENDINGSOK-final');
2986
3050
  return { success: false, pendingSok: true, error: lastDialogMsg };
2987
3051
  }
2988
3052
 
@@ -3063,9 +3127,7 @@ class CertRunner {
3063
3127
  await fillByLabel('Correo electrónico Proveedor', correoProveedor);
3064
3128
 
3065
3129
  // Captura pre-submit
3066
- if (this.config.debugDir) {
3067
- await page.screenshot({ path: path.join(this.config.debugDir, 'boleta-declaracion-pre-submit.png'), fullPage: true }).catch(() => {});
3068
- }
3130
+ await saveDebugDecl('03-pre-submit');
3069
3131
 
3070
3132
  // ── PASO 4: Click "Grabar Declaración" ───────────────────────
3071
3133
  const submitOk = await page.evaluate(() => {
@@ -3074,7 +3136,10 @@ class CertRunner {
3074
3136
  if (btn) { btn.click(); return true; }
3075
3137
  return false;
3076
3138
  });
3077
- if (!submitOk) throw new Error('No se encontró botón "Grabar Declaración" en certBolElectDteInternet/');
3139
+ if (!submitOk) {
3140
+ await saveDebugDecl('ERROR-no-boton-grabar');
3141
+ throw new Error('No se encontró botón "Grabar Declaración" en certBolElectDteInternet/');
3142
+ }
3078
3143
  console.log(' → Click "Grabar Declaración"...');
3079
3144
 
3080
3145
  // Esperar confirmación del SII
@@ -3086,23 +3151,154 @@ class CertRunner {
3086
3151
 
3087
3152
  const msgFinal = await page.evaluate(() => (document.body.textContent || '').trim().substring(0, 300));
3088
3153
  console.log(` ✓ Declaración completada. Respuesta: ${msgFinal.substring(0, 150)}`);
3089
-
3090
- if (this.config.debugDir) {
3091
- await page.screenshot({ path: path.join(this.config.debugDir, 'boleta-declaracion-post-submit.png'), fullPage: true }).catch(() => {});
3092
- }
3154
+ await saveDebugDecl('04-post-submit-OK');
3093
3155
 
3094
3156
  return { success: true, mensaje: msgFinal };
3095
3157
  } catch (err) {
3158
+ // Guardar captura del estado de error si page sigue abierta
3159
+ try {
3160
+ if (dbgDirDecl) {
3161
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
3162
+ const base = path.join(dbgDirDecl, `ERROR-catch-${ts}`);
3163
+ await page.screenshot({ path: `${base}.png`, fullPage: true }).catch(() => {});
3164
+ fs.writeFileSync(`${base}.html`, await page.content().catch(() => ''), 'utf-8');
3165
+ console.log(` [debug] Captura de error guardada: ${base}.png`);
3166
+ }
3167
+ } catch {}
3096
3168
  return { success: false, error: err.message };
3097
3169
  } finally {
3098
3170
  if (browser) await browser.close().catch(() => {});
3099
3171
  }
3100
3172
  }
3101
3173
 
3174
+ /**
3175
+ * Verifica si la empresa ya está autorizada para emitir Boletas Electrónicas tipo 39
3176
+ * en PRODUCCIÓN (palena.sii.cl). Si el tipo 39 aparece en el select de
3177
+ * of_solicita_folios_dcto significa que el SII ya certificó la empresa.
3178
+ *
3179
+ * También consulta el avance en certificación (maullin) como dato adicional.
3180
+ *
3181
+ * @returns {Promise<{
3182
+ * autorizadaProduccion: boolean,
3183
+ * autorizadaCertificacion: boolean,
3184
+ * mensaje: string,
3185
+ * tiposDisponiblesProduccion: number[],
3186
+ * estadoCertificacion: string|null
3187
+ * }>}
3188
+ */
3189
+ async verificarAutorizacionBoleta() {
3190
+ const https = require('https');
3191
+ const cookieJar = await this._obtenerCookiesSII();
3192
+ const cookieStr = Object.entries(cookieJar).map(([k, v]) => `${k}=${v}`).join('; ');
3193
+
3194
+ const fetchHtml = (url) => new Promise((resolve, reject) => {
3195
+ const req = https.get(url, {
3196
+ headers: {
3197
+ 'Cookie': cookieStr,
3198
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
3199
+ 'Accept': 'text/html,*/*',
3200
+ },
3201
+ rejectUnauthorized: false,
3202
+ }, (res) => {
3203
+ const chunks = [];
3204
+ res.on('data', c => chunks.push(c));
3205
+ res.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
3206
+ });
3207
+ req.on('error', reject);
3208
+ req.setTimeout(20000, () => { req.destroy(); reject(new Error('Timeout')); });
3209
+ });
3210
+
3211
+ const result = {
3212
+ autorizadaProduccion: false,
3213
+ autorizadaCertificacion: false,
3214
+ mensaje: '',
3215
+ tiposDisponiblesProduccion: [],
3216
+ estadoCertificacion: null,
3217
+ };
3218
+
3219
+ // ── 1. Verificar producción: of_solicita_folios_dcto en palena ─────────
3220
+ try {
3221
+ const htmlProd = await fetchHtml('https://palena.sii.cl/cvc_cgi/dte/of_solicita_folios_dcto');
3222
+ if (this.config.debugDir) {
3223
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
3224
+ fs.writeFileSync(
3225
+ path.join(this.config.debugDir, `verificar-boleta-produccion-${ts}.html`),
3226
+ htmlProd, 'utf-8'
3227
+ );
3228
+ }
3229
+ const matches = [...htmlProd.matchAll(/<option[^>]+value="(\d+)"[^>]*>/gi)];
3230
+ result.tiposDisponiblesProduccion = matches.map(m => parseInt(m[1], 10)).filter(n => n > 0);
3231
+ result.autorizadaProduccion = result.tiposDisponiblesProduccion.includes(39);
3232
+ console.log(` ✓ Producción — tipos disponibles: [${result.tiposDisponiblesProduccion.join(', ')}] | Boleta 39: ${result.autorizadaProduccion ? '✅ SÍ' : '❌ NO'}`);
3233
+ } catch (e) {
3234
+ console.log(` [!] No se pudo verificar producción: ${e.message}`);
3235
+ }
3236
+
3237
+ // ── 2. Verificar certificación: pe_avance6 en maullin ─────────────────
3238
+ try {
3239
+ const htmlCert = await fetchHtml('https://maullin.sii.cl/cvc_cgi/dte/pe_avance6');
3240
+ if (this.config.debugDir) {
3241
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
3242
+ fs.writeFileSync(
3243
+ path.join(this.config.debugDir, `verificar-boleta-certificacion-${ts}.html`),
3244
+ htmlCert, 'utf-8'
3245
+ );
3246
+ }
3247
+ // Buscar fila de boleta en tabla de avance
3248
+ const boletaRow = htmlCert.match(/BOLETA[^<]*<\/[^>]+>\s*<[^>]+><b>([^<]+)<\/b>/i);
3249
+ if (boletaRow) {
3250
+ result.estadoCertificacion = boletaRow[1].trim();
3251
+ result.autorizadaCertificacion = /conform|aprobad|autoriz/i.test(result.estadoCertificacion);
3252
+ }
3253
+ if (!result.estadoCertificacion && /autorizada.*boleta|boleta.*autorizada/i.test(htmlCert)) {
3254
+ result.autorizadaCertificacion = true;
3255
+ result.estadoCertificacion = 'AUTORIZADA';
3256
+ }
3257
+ console.log(` ✓ Certificación — estado boleta: ${result.estadoCertificacion || '(no encontrado)'} | Autorizada: ${result.autorizadaCertificacion ? '✅ SÍ' : '❌ NO'}`);
3258
+ } catch (e) {
3259
+ console.log(` [!] No se pudo verificar certificación: ${e.message}`);
3260
+ }
3261
+
3262
+ result.mensaje = result.autorizadaProduccion
3263
+ ? '✅ Empresa autorizada para emitir Boleta Electrónica (tipo 39) en producción'
3264
+ : result.autorizadaCertificacion
3265
+ ? '⏳ Boleta certificada en SII pero aún no disponible en producción'
3266
+ : '❌ Boleta NO autorizada — certificación incompleta';
3267
+
3268
+ return result;
3269
+ }
3270
+
3102
3271
  // ═══════════════════════════════════════════════════════════════
3103
3272
  // Helpers privados
3104
3273
  // ═══════════════════════════════════════════════════════════════
3105
3274
 
3275
+ /**
3276
+ * Opciones base para puppeteer.launch.
3277
+ * Usa Chrome del sistema (executablePath) para evitar crashes del Chromium
3278
+ * bundled en Windows (0xC0000409). Respeta this.config.puppeteerHeadless.
3279
+ */
3280
+ _getPuppeteerLaunchOptions(extraArgs = [], extraOpts = {}) {
3281
+ const fs = require('fs');
3282
+ const chromePaths = [
3283
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
3284
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
3285
+ process.env.LOCALAPPDATA ? process.env.LOCALAPPDATA + '\\Google\\Chrome\\Application\\chrome.exe' : null,
3286
+ ].filter(Boolean);
3287
+ const systemChrome = chromePaths.find(p => fs.existsSync(p));
3288
+
3289
+ const opts = {
3290
+ headless: this.config.puppeteerHeadless !== false,
3291
+ ignoreHTTPSErrors: true,
3292
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors', ...extraArgs],
3293
+ ...extraOpts,
3294
+ };
3295
+ if (systemChrome) {
3296
+ opts.executablePath = systemChrome;
3297
+ console.log(` [browser] Usando Chrome del sistema: ${systemChrome}`);
3298
+ }
3299
+ return opts;
3300
+ }
3301
+
3106
3302
  _getFechaHoy() {
3107
3303
  const now = new Date();
3108
3304
  return `${String(now.getDate()).padStart(2, '0')}-${String(now.getMonth() + 1).padStart(2, '0')}-${now.getFullYear()}`;
@@ -20,7 +20,7 @@ const fs = require('fs');
20
20
  const path = require('path');
21
21
  const { XMLParser, XMLBuilder } = require('fast-xml-parser');
22
22
  const bwipjs = require('bwip-js');
23
- const puppeteer = require('puppeteer');
23
+ const { launchBrowser } = require('../utils/browser');
24
24
 
25
25
  // Usar constantes del core
26
26
  const {
@@ -485,7 +485,7 @@ class MuestrasImpresas {
485
485
  }
486
486
 
487
487
  /**
488
- * Genera PDF desde HTML
488
+ * Genera PDF desde HTML y lo escribe a disco.
489
489
  * @private
490
490
  */
491
491
  async _generarPdf({ html, outputPath, browser }) {
@@ -501,6 +501,34 @@ class MuestrasImpresas {
501
501
  await page.close();
502
502
  }
503
503
 
504
+ /**
505
+ * Genera un PDF desde HTML y devuelve el contenido como Buffer (sin escribir a disco).
506
+ * Útil para servir el PDF directamente desde una respuesta HTTP.
507
+ *
508
+ * @param {string} html - HTML a convertir a PDF
509
+ * @param {object} [opts]
510
+ * @param {string} [opts.width='215mm']
511
+ * @param {string} [opts.height='280mm']
512
+ * @returns {Promise<Buffer>}
513
+ */
514
+ async generarPdfBuffer(html, opts = {}) {
515
+ const browser = await launchBrowser();
516
+ try {
517
+ const page = await browser.newPage();
518
+ await page.setContent(html, { waitUntil: 'networkidle0' });
519
+ const pdfBuffer = await page.pdf({
520
+ printBackground: true,
521
+ width: opts.width ?? '215mm',
522
+ height: opts.height ?? '280mm',
523
+ margin: { top: '10mm', right: '10mm', bottom: '10mm', left: '10mm' },
524
+ });
525
+ await page.close();
526
+ return Buffer.from(pdfBuffer);
527
+ } finally {
528
+ await browser.close();
529
+ }
530
+ }
531
+
504
532
  /**
505
533
  * Genera muestras impresas desde archivos XML
506
534
  * @param {Object} options
@@ -524,7 +552,7 @@ class MuestrasImpresas {
524
552
  console.log(` SET-PRUEBAS: ${pruebasDir}`);
525
553
  console.log(` SET-SIMULACION: ${simulacionDir}`);
526
554
 
527
- const browser = await puppeteer.launch({ headless: true });
555
+ const browser = await launchBrowser();
528
556
  const resultado = {
529
557
  success: true,
530
558
  totalDocs: 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devlas/dte-sii",
3
- "version": "2.5.16",
3
+ "version": "2.7.0",
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",
@@ -28,6 +28,9 @@
28
28
  "engines": {
29
29
  "node": ">=18.0.0"
30
30
  },
31
+ "optionalDependencies": {
32
+ "@sparticuz/chromium": "^133.0.0"
33
+ },
31
34
  "dependencies": {
32
35
  "@xmldom/xmldom": "^0.8.11",
33
36
  "bwip-js": "^4.8.0",
@@ -40,6 +43,9 @@
40
43
  "xml-c14n": "^0.0.6",
41
44
  "xml-crypto": "^6.1.2"
42
45
  },
46
+ "optionalDependencies": {
47
+ "@sparticuz/chromium": "^133.0.0"
48
+ },
43
49
  "files": [
44
50
  "index.js",
45
51
  "dte-sii.d.ts",
@@ -0,0 +1,73 @@
1
+ // Copyright (c) 2026 Devlas SpA — https://devlas.cl
2
+ // Licencia MIT. Ver archivo LICENSE para mas detalles.
3
+ /**
4
+ * utils/browser.js
5
+ *
6
+ * Helper para lanzar Puppeteer con el Chromium correcto según el entorno:
7
+ * - Producción / serverless (Railway, Lambda, etc.): usa @sparticuz/chromium
8
+ * - Desarrollo local: usa el Chrome del sistema si @sparticuz/chromium no está disponible
9
+ *
10
+ * Uso:
11
+ * const { launchBrowser } = require('./utils/browser')
12
+ * const browser = await launchBrowser()
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const puppeteer = require('puppeteer');
18
+
19
+ /**
20
+ * Devuelve las opciones de lanzamiento para puppeteer.launch() según el entorno.
21
+ * @returns {Promise<import('puppeteer').LaunchOptions>}
22
+ */
23
+ async function getLaunchOptions() {
24
+ // 1. Intentar @sparticuz/chromium (viene en el paquete, funciona en serverless)
25
+ try {
26
+ const chromium = require('@sparticuz/chromium');
27
+ return {
28
+ args: chromium.args,
29
+ defaultViewport: chromium.defaultViewport,
30
+ executablePath: await chromium.executablePath(),
31
+ headless: chromium.headless ?? true,
32
+ };
33
+ } catch {
34
+ // No está disponible, continuar con fallback
35
+ }
36
+
37
+ // 2. Ruta explícita por variable de entorno
38
+ if (process.env.PUPPETEER_EXECUTABLE_PATH) {
39
+ return {
40
+ headless: true,
41
+ args: ['--no-sandbox', '--disable-setuid-sandbox'],
42
+ executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
43
+ };
44
+ }
45
+
46
+ // 3. Chrome del sistema (rutas comunes Linux / Windows)
47
+ const fs = require('fs');
48
+ const SYSTEM_PATHS = [
49
+ '/usr/bin/google-chrome-stable',
50
+ '/usr/bin/google-chrome',
51
+ '/usr/bin/chromium-browser',
52
+ '/usr/bin/chromium',
53
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
54
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
55
+ ];
56
+ const executablePath = SYSTEM_PATHS.find(p => { try { return fs.existsSync(p) } catch { return false } });
57
+ return {
58
+ headless: true,
59
+ args: ['--no-sandbox', '--disable-setuid-sandbox'],
60
+ ...(executablePath ? { executablePath } : {}),
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Lanza un browser Puppeteer con el Chromium correcto para el entorno actual.
66
+ * @returns {Promise<import('puppeteer').Browser>}
67
+ */
68
+ async function launchBrowser() {
69
+ const opts = await getLaunchOptions();
70
+ return puppeteer.launch(opts);
71
+ }
72
+
73
+ module.exports = { launchBrowser, getLaunchOptions };