@devlas/dte-sii 2.5.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.
Files changed (60) hide show
  1. package/BoletaService.js +109 -0
  2. package/CAF.js +173 -0
  3. package/CafSolicitor.js +380 -0
  4. package/Certificado.js +123 -0
  5. package/ConsumoFolio.js +376 -0
  6. package/DTE.js +399 -0
  7. package/EnviadorSII.js +1304 -0
  8. package/Envio.js +196 -0
  9. package/FolioRegistry.js +553 -0
  10. package/FolioService.js +703 -0
  11. package/LICENSE +27 -0
  12. package/LibroBase.js +134 -0
  13. package/LibroCompraVenta.js +205 -0
  14. package/LibroGuia.js +225 -0
  15. package/README.md +239 -0
  16. package/Signer.js +94 -0
  17. package/SiiCertificacion.js +1189 -0
  18. package/SiiPortalAuth.js +460 -0
  19. package/SiiSession.js +499 -0
  20. package/cert/BoletaCert.js +731 -0
  21. package/cert/CertFolioHelper.js +185 -0
  22. package/cert/CertRunner.js +2658 -0
  23. package/cert/ConfigLoader.js +133 -0
  24. package/cert/IntercambioCert.js +429 -0
  25. package/cert/LibroCompras.js +359 -0
  26. package/cert/LibroGuias.js +171 -0
  27. package/cert/LibroVentas.js +153 -0
  28. package/cert/MuestrasImpresas.js +676 -0
  29. package/cert/SetBase.js +321 -0
  30. package/cert/SetBasico.js +413 -0
  31. package/cert/SetCompra.js +472 -0
  32. package/cert/SetExenta.js +490 -0
  33. package/cert/SetGuia.js +283 -0
  34. package/cert/SetParser.js +1184 -0
  35. package/cert/SetsProvider.js +499 -0
  36. package/cert/Simulacion.js +521 -0
  37. package/cert/comunaOficina.js +460 -0
  38. package/cert/index.js +124 -0
  39. package/cert/types.js +330 -0
  40. package/dte-sii.d.ts +458 -0
  41. package/index.js +428 -0
  42. package/package.json +48 -0
  43. package/utils/c14n.js +275 -0
  44. package/utils/calculo.js +396 -0
  45. package/utils/config.js +276 -0
  46. package/utils/constants.js +302 -0
  47. package/utils/emisor.js +174 -0
  48. package/utils/endpoints.js +225 -0
  49. package/utils/error.js +235 -0
  50. package/utils/index.js +339 -0
  51. package/utils/logger.js +239 -0
  52. package/utils/pfx.js +203 -0
  53. package/utils/receptor.js +218 -0
  54. package/utils/referencia.js +169 -0
  55. package/utils/resolucion.js +119 -0
  56. package/utils/rut.js +169 -0
  57. package/utils/sanitize.js +124 -0
  58. package/utils/tokenCache.js +214 -0
  59. package/utils/xml.js +358 -0
  60. package/utils.js +4 -0
@@ -0,0 +1,731 @@
1
+ // Copyright (c) 2026 Devlas SpA — https://devlas.cl
2
+ // Licencia MIT. Ver archivo LICENSE para mas detalles.
3
+ /**
4
+ * BoletaCert.js
5
+ *
6
+ * Módulo para certificación de Boletas Electrónicas (tipo 39 y 41).
7
+ *
8
+ * Proceso de certificación:
9
+ * 1. Usuario obtiene Set de Pruebas manualmente desde SII (requiere clave tributaria)
10
+ * URL: https://www4.sii.cl/certBolElectDteInternet/?SET=1
11
+ * 2. Se genera EnvioBOLETA con todas las boletas del set
12
+ * 3. Se genera RCOF (ConsumoFolio) para reportar los folios usados
13
+ * 4. Se envían ambos documentos al SII
14
+ * 5. SII valida y aprueba
15
+ */
16
+
17
+ const path = require('path');
18
+ const fs = require('fs');
19
+
20
+ class BoletaCert {
21
+ /**
22
+ * @param {Object} options
23
+ * @param {Object} options.certificado - Instancia de Certificado
24
+ * @param {Object} options.emisor - Datos del emisor { rut, razon_social, giro, direccion, comuna, ciudad, fch_resol, nro_resol }
25
+ * @param {string} options.ambiente - 'certificacion' | 'produccion'
26
+ * @param {Object} options.resolucion - { fecha, numero }
27
+ * @param {string} options.debugDir - Directorio para debug
28
+ */
29
+ constructor(options) {
30
+ this.certificado = options.certificado;
31
+ this.emisor = options.emisor;
32
+ this.ambiente = options.ambiente || 'certificacion';
33
+ this.resolucion = options.resolucion;
34
+ this.debugDir = options.debugDir;
35
+
36
+ // Librerías (se cargan bajo demanda)
37
+ this._dteLib = null;
38
+ }
39
+
40
+ _lib() {
41
+ if (!this._dteLib) {
42
+ this._dteLib = require('../index');
43
+ }
44
+ return this._dteLib;
45
+ }
46
+
47
+ /**
48
+ * Parsea el set de pruebas desde archivo de texto
49
+ * @param {string} filePath - Ruta al archivo del set
50
+ * @returns {Object[]} Array de casos parseados
51
+ */
52
+ parseSetPruebas(filePath) {
53
+ const content = fs.readFileSync(filePath, 'utf-8');
54
+ const lines = content.split('\n').map(l => l.trim());
55
+
56
+ const casos = [];
57
+ let currentCaso = null;
58
+ let inItems = false;
59
+
60
+ for (const line of lines) {
61
+ // Detectar inicio de caso
62
+ const casoMatch = line.match(/^CASO-?(\d+)/i);
63
+ if (casoMatch) {
64
+ if (currentCaso) {
65
+ casos.push(currentCaso);
66
+ }
67
+ currentCaso = {
68
+ numero: parseInt(casoMatch[1], 10),
69
+ items: [],
70
+ observaciones: []
71
+ };
72
+ inItems = false;
73
+ continue;
74
+ }
75
+
76
+ // Detectar línea de headers de items
77
+ if (line.includes('Item') && line.includes('Cantidad') && line.includes('Precio')) {
78
+ inItems = true;
79
+ continue;
80
+ }
81
+
82
+ // Detectar observación
83
+ if (line.startsWith('OBSERVACION')) {
84
+ const obsMatch = line.match(/OBSERVACION[ES]*:\s*"?([^"]+)"?/i);
85
+ if (obsMatch && currentCaso) {
86
+ currentCaso.observaciones.push(obsMatch[1].trim());
87
+ }
88
+ continue;
89
+ }
90
+
91
+ // Parsear item (línea con tabs separando: nombre, cantidad, precio)
92
+ if (inItems && currentCaso && line.length > 0 && !line.startsWith('=')) {
93
+ // Separar por tabs o múltiples espacios
94
+ const parts = line.split(/\t+|\s{2,}/).filter(p => p.trim().length > 0);
95
+ if (parts.length >= 3) {
96
+ const nombre = parts[0].trim();
97
+ const cantidad = parseFloat(parts[1].replace(',', '.'));
98
+ const precio = parseInt(parts[2].replace(/\./g, '').replace(',', ''), 10);
99
+
100
+ if (!isNaN(cantidad) && !isNaN(precio)) {
101
+ // Detectar si es exento por el nombre del item (ej: "item exento 2")
102
+ const esExento = nombre.toLowerCase().includes('exento');
103
+
104
+ currentCaso.items.push({
105
+ nombre,
106
+ cantidad,
107
+ precio,
108
+ exento: esExento
109
+ });
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ // Agregar último caso
116
+ if (currentCaso) {
117
+ casos.push(currentCaso);
118
+ }
119
+
120
+ return casos;
121
+ }
122
+
123
+ /**
124
+ * Genera boletas a partir de los casos del set
125
+ * @param {Object[]} casos - Casos parseados del set
126
+ * @param {Object} cafBoleta - CAF para boletas tipo 39
127
+ * @param {number} folioInicial - Folio inicial
128
+ * @returns {Object} { boletas: DTE[], folioUsados }
129
+ */
130
+ async generarBoletasSet(casos, cafBoleta, folioInicial) {
131
+ const { DTE, CAF } = this._lib();
132
+
133
+ const boletas = [];
134
+ let folioActual = folioInicial;
135
+
136
+ const fechaHoy = new Date().toISOString().split('T')[0];
137
+
138
+ for (const caso of casos) {
139
+ // Preparar items
140
+ const items = caso.items.map(item => ({
141
+ NmbItem: item.nombre,
142
+ QtyItem: item.cantidad,
143
+ PrcItem: item.precio,
144
+ ...(item.unidad ? { UnmdItem: item.unidad } : {}),
145
+ ...(item.exento ? { IndExe: 1 } : {}),
146
+ }));
147
+
148
+ // Verificar si el caso tiene observación sobre unidad de medida
149
+ const obsUnidad = caso.observaciones.find(obs =>
150
+ obs.toLowerCase().includes('unidad de medida')
151
+ );
152
+ if (obsUnidad) {
153
+ // Ejemplo: "Se debe informar en el XML Unidad de medida en Kg."
154
+ const unidadMatch = obsUnidad.match(/en\s+(\w+)\.?$/i);
155
+ if (unidadMatch && items.length > 0) {
156
+ items[0].UnmdItem = unidadMatch[1];
157
+ }
158
+ }
159
+
160
+ // Crear DTE
161
+ const dteDatos = {
162
+ tipo: 39,
163
+ folio: folioActual,
164
+ fechaEmision: fechaHoy,
165
+ emisor: {
166
+ RUTEmisor: this.emisor.rut,
167
+ RznSocEmisor: this.emisor.razon_social,
168
+ GiroEmisor: this.emisor.giro,
169
+ DirOrigen: this.emisor.direccion,
170
+ CmnaOrigen: this.emisor.comuna,
171
+ CiudadOrigen: this.emisor.ciudad,
172
+ },
173
+ receptor: {
174
+ RUTRecep: '66666666-6', // Consumidor final
175
+ RznSocRecep: 'Consumidor Final',
176
+ DirRecep: 'Sin Direccion',
177
+ CmnaRecep: 'Santiago',
178
+ },
179
+ items,
180
+ referencia: {
181
+ codigo: 'SET',
182
+ razon: `CASO-${caso.numero}`,
183
+ },
184
+ certificado: this.certificado,
185
+ caf: cafBoleta,
186
+ };
187
+
188
+ const dte = new DTE(dteDatos);
189
+ boletas.push({
190
+ dte,
191
+ caso: caso.numero,
192
+ folio: folioActual,
193
+ });
194
+
195
+ folioActual++;
196
+ }
197
+
198
+ return {
199
+ boletas,
200
+ foliosUsados: boletas.length,
201
+ folioInicial,
202
+ folioFinal: folioActual - 1,
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Genera el EnvioBOLETA con todas las boletas del set
208
+ * @param {Object[]} boletas - Array de { dte, caso, folio }
209
+ * @returns {EnvioBOLETA}
210
+ */
211
+ generarEnvioBoleta(boletas) {
212
+ const { EnvioBOLETA } = this._lib();
213
+
214
+ const envioBoleta = new EnvioBOLETA({
215
+ rutEmisor: this.emisor.rut,
216
+ rutEnvia: this.certificado.rut,
217
+ fchResol: this.resolucion.fecha,
218
+ nroResol: this.resolucion.numero,
219
+ certificado: this.certificado,
220
+ });
221
+
222
+ // Agregar cada boleta
223
+ for (const { dte } of boletas) {
224
+ envioBoleta.agregar(dte);
225
+ }
226
+
227
+ // Carátula para certificación
228
+ envioBoleta.setCaratula({
229
+ RutEmisor: this.emisor.rut,
230
+ RutEnvia: this.certificado.rut,
231
+ RutReceptor: '60803000-K', // SII en certificación
232
+ FchResol: this.resolucion.fecha,
233
+ NroResol: this.resolucion.numero,
234
+ });
235
+
236
+ envioBoleta.generar();
237
+
238
+ return envioBoleta;
239
+ }
240
+
241
+ /**
242
+ * Genera el RCOF (ConsumoFolio) para las boletas
243
+ * @param {EnvioBOLETA} envioBoleta - EnvioBOLETA generado
244
+ * @param {Object} [options] - Opciones opcionales
245
+ * @param {number} [options.secEnvio] - Número de secuencia del envío (default: 1)
246
+ * @returns {ConsumoFolio}
247
+ */
248
+ generarConsumoFolio(envioBoleta, options = {}) {
249
+ const { ConsumoFolio } = this._lib();
250
+
251
+ const consumoFolio = new ConsumoFolio(this.certificado);
252
+
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;
261
+
262
+ // Estructura que espera ConsumoFolio: Totales como hijo de Encabezado
263
+ consumoFolio.agregar(tipo, {
264
+ Encabezado: {
265
+ IdDoc: {
266
+ TipoDTE: tipo,
267
+ Folio: folio,
268
+ FchEmis: fechaEmision,
269
+ },
270
+ Totales: {
271
+ MntNeto: totales.MntNeto || 0,
272
+ MntExe: totales.MntExe || 0,
273
+ IVA: totales.IVA || 0,
274
+ MntTotal: totales.MntTotal || 0,
275
+ }
276
+ },
277
+ });
278
+ }
279
+
280
+ // Establecer carátula
281
+ consumoFolio.setCaratula({
282
+ RutEmisor: this.emisor.rut,
283
+ RutEnvia: this.certificado.rut,
284
+ FchResol: this.resolucion.fecha,
285
+ NroResol: this.resolucion.numero,
286
+ SecEnvio: options?.secEnvio || 1,
287
+ });
288
+
289
+ return consumoFolio;
290
+ }
291
+
292
+ /**
293
+ * Reenvía solo el RCOF para un EnvioBOLETA ya enviado
294
+ * Útil cuando el RCOF anterior fue rechazado por secuencia o tenía datos incorrectos
295
+ * @param {Object} options
296
+ * @param {string} options.envioBOLETAPath - Ruta al XML del EnvioBOLETA ya enviado
297
+ * @param {number} options.secEnvio - Número de secuencia para el RCOF
298
+ * @returns {Promise<Object>} Resultado con trackId
299
+ */
300
+ async reenviarRCOF(options) {
301
+ const { EnviadorSII, DOMParser } = this._lib();
302
+ const { DOMParser: XMLParser } = require('@xmldom/xmldom');
303
+
304
+ console.log('\n' + '═'.repeat(60));
305
+ console.log('📊 REENVÍO RCOF (ConsumoFolio)');
306
+ console.log('═'.repeat(60));
307
+
308
+ // 1. Parsear el EnvioBOLETA existente para extraer info
309
+ console.log('\n📄 Leyendo EnvioBOLETA...');
310
+ const envioBOLETAXml = fs.readFileSync(options.envioBOLETAPath, 'utf-8');
311
+ const parser = new XMLParser();
312
+ const doc = parser.parseFromString(envioBOLETAXml, 'text/xml');
313
+
314
+ // Extraer info de cada DTE
315
+ const dtes = doc.getElementsByTagName('Documento');
316
+ const boletas = [];
317
+
318
+ for (let i = 0; i < dtes.length; i++) {
319
+ const dte = dtes[i];
320
+ const tipo = parseInt(dte.getElementsByTagName('TipoDTE')[0]?.textContent || '39', 10);
321
+ const folio = parseInt(dte.getElementsByTagName('Folio')[0]?.textContent || '0', 10);
322
+ const fchEmis = dte.getElementsByTagName('FchEmis')[0]?.textContent || '';
323
+
324
+ const totalesEl = dte.getElementsByTagName('Totales')[0];
325
+ const mntNeto = parseInt(totalesEl?.getElementsByTagName('MntNeto')[0]?.textContent || '0', 10);
326
+ const mntExe = parseInt(totalesEl?.getElementsByTagName('MntExe')[0]?.textContent || '0', 10);
327
+ const iva = parseInt(totalesEl?.getElementsByTagName('IVA')[0]?.textContent || '0', 10);
328
+ const mntTotal = parseInt(totalesEl?.getElementsByTagName('MntTotal')[0]?.textContent || '0', 10);
329
+
330
+ boletas.push({
331
+ Encabezado: {
332
+ IdDoc: { TipoDTE: tipo, Folio: folio, FchEmis: fchEmis },
333
+ Totales: { MntNeto: mntNeto, MntExe: mntExe, IVA: iva, MntTotal: mntTotal }
334
+ }
335
+ });
336
+ console.log(` - Folio ${folio}: Neto=${mntNeto}, Exento=${mntExe}, IVA=${iva}, Total=${mntTotal}`);
337
+ }
338
+
339
+ // 2. Crear RCOF
340
+ console.log(`\n📊 Generando RCOF con SecEnvio=${options.secEnvio}...`);
341
+ const { ConsumoFolio } = this._lib();
342
+ const consumoFolio = new ConsumoFolio(this.certificado);
343
+
344
+ for (const b of boletas) {
345
+ consumoFolio.agregar(b.Encabezado.IdDoc.TipoDTE, b);
346
+ }
347
+
348
+ consumoFolio.setCaratula({
349
+ RutEmisor: this.emisor.rut,
350
+ RutEnvia: this.certificado.rut,
351
+ FchResol: this.resolucion.fecha,
352
+ NroResol: this.resolucion.numero,
353
+ SecEnvio: options.secEnvio,
354
+ });
355
+
356
+ consumoFolio.generar();
357
+ console.log(` ✓ XML generado: ${consumoFolio.xml.length} bytes`);
358
+
359
+ // Guardar debug
360
+ if (this.debugDir) {
361
+ const debugPath = path.join(this.debugDir, 'boleta-cert');
362
+ fs.mkdirSync(debugPath, { recursive: true });
363
+ fs.writeFileSync(path.join(debugPath, `ConsumoFolio-sec${options.secEnvio}.xml`), consumoFolio.xml, 'utf-8');
364
+ console.log(` 📄 Guardado en: ${path.join(debugPath, `ConsumoFolio-sec${options.secEnvio}.xml`)}`);
365
+ }
366
+
367
+ // 3. Enviar RCOF
368
+ console.log('\n📤 Enviando RCOF al SII...');
369
+ const enviador = new EnviadorSII(this.certificado, this.ambiente);
370
+ const resultadoRCOF = await enviador.enviarConsumoFolios(consumoFolio);
371
+
372
+ if (!resultadoRCOF.ok) {
373
+ console.log(` ❌ Error: ${resultadoRCOF.error}`);
374
+ return { success: false, error: resultadoRCOF.error };
375
+ }
376
+ console.log(` ✅ Enviado - TrackId: ${resultadoRCOF.trackId}`);
377
+
378
+ return { success: true, trackIdRCOF: resultadoRCOF.trackId };
379
+ }
380
+
381
+ /**
382
+ * Ejecuta el proceso completo de certificación de boletas
383
+ * @param {Object} options
384
+ * @param {string} options.setPath - Ruta al archivo del set de pruebas
385
+ * @param {Object} options.cafBoleta - CAF para boletas tipo 39
386
+ * @param {number} options.folioInicial - Folio inicial a usar
387
+ * @returns {Promise<Object>} Resultado con trackIds
388
+ */
389
+ async ejecutarCertificacion(options) {
390
+ const { EnviadorSII } = this._lib();
391
+
392
+ console.log('\n' + '═'.repeat(60));
393
+ console.log('🎫 CERTIFICACIÓN BOLETAS ELECTRÓNICAS');
394
+ console.log('═'.repeat(60));
395
+
396
+ // 1. Parsear set de pruebas
397
+ console.log('\n📋 Parseando set de pruebas...');
398
+ const casos = this.parseSetPruebas(options.setPath);
399
+ console.log(` ✓ ${casos.length} casos encontrados`);
400
+
401
+ // 2. Generar boletas
402
+ console.log('\n📝 Generando boletas...');
403
+ const { boletas, foliosUsados, folioInicial, folioFinal } = await this.generarBoletasSet(
404
+ casos,
405
+ options.cafBoleta,
406
+ options.folioInicial
407
+ );
408
+ console.log(` ✓ ${boletas.length} boletas generadas (folios ${folioInicial}-${folioFinal})`);
409
+
410
+ for (const b of boletas) {
411
+ const monto = b.dte.montoTotal || 0;
412
+ console.log(` - CASO-${b.caso}: Folio ${b.folio} - $${monto.toLocaleString('es-CL')}`);
413
+ }
414
+
415
+ // 3. Generar EnvioBOLETA
416
+ console.log('\n📦 Generando EnvioBOLETA...');
417
+ const envioBoleta = this.generarEnvioBoleta(boletas);
418
+ console.log(` ✓ XML generado: ${envioBoleta.xml.length} bytes`);
419
+
420
+ // Guardar debug
421
+ if (this.debugDir) {
422
+ const debugPath = path.join(this.debugDir, 'boleta-cert');
423
+ fs.mkdirSync(debugPath, { recursive: true });
424
+ fs.writeFileSync(path.join(debugPath, 'EnvioBOLETA.xml'), envioBoleta.xml, 'utf-8');
425
+ console.log(` 📄 Guardado en: ${path.join(debugPath, 'EnvioBOLETA.xml')}`);
426
+ }
427
+
428
+ // 4. Enviar EnvioBOLETA
429
+ console.log('\n📤 Enviando EnvioBOLETA al SII...');
430
+ const enviador = new EnviadorSII(this.certificado, this.ambiente);
431
+ const resultadoBoleta = await enviador.enviarBoletaSoap(envioBoleta);
432
+
433
+ if (!resultadoBoleta.ok) {
434
+ console.log(` ❌ Error: ${resultadoBoleta.error}`);
435
+ return { success: false, error: resultadoBoleta.error, fase: 'EnvioBOLETA' };
436
+ }
437
+ console.log(` ✅ Enviado - TrackId: ${resultadoBoleta.trackId}`);
438
+
439
+ // 5. Generar RCOF
440
+ console.log('\n📊 Generando 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')}`);
450
+ }
451
+
452
+ // 6. Enviar RCOF
453
+ console.log('\n📤 Enviando RCOF al SII...');
454
+ const resultadoRCOF = await enviador.enviarConsumoFolios(consumoFolio);
455
+
456
+ if (!resultadoRCOF.ok) {
457
+ console.log(` ❌ Error: ${resultadoRCOF.error}`);
458
+ return {
459
+ success: false,
460
+ error: resultadoRCOF.error,
461
+ fase: 'RCOF',
462
+ trackIdBoleta: resultadoBoleta.trackId
463
+ };
464
+ }
465
+ console.log(` ✅ Enviado - TrackId: ${resultadoRCOF.trackId}`);
466
+
467
+ // Resumen
468
+ console.log('\n' + '═'.repeat(60));
469
+ console.log('✅ CERTIFICACIÓN BOLETAS COMPLETADA');
470
+ console.log('═'.repeat(60));
471
+ console.log(` 📄 EnvioBOLETA: ${resultadoBoleta.trackId}`);
472
+ console.log(` 📊 RCOF: ${resultadoRCOF.trackId}`);
473
+ console.log(` 📦 Boletas: ${boletas.length}`);
474
+ console.log(` 📑 Folios: ${folioInicial} - ${folioFinal}`);
475
+
476
+ return {
477
+ success: true,
478
+ trackIdBoleta: resultadoBoleta.trackId,
479
+ trackIdRCOF: resultadoRCOF.trackId,
480
+ boletas: boletas.length,
481
+ folioInicial,
482
+ folioFinal,
483
+ };
484
+ }
485
+
486
+ /**
487
+ * Declara avance de certificación de boletas validando el TrackId
488
+ * Usa el endpoint DTEauth?3 que requiere autenticación con certificado
489
+ * @param {string} trackId - TrackId del envío a validar
490
+ * @returns {Promise<Object>} Resultado de la validación
491
+ */
492
+ async declararAvance(trackId) {
493
+ const https = require('https');
494
+ const forge = require('node-forge');
495
+
496
+ console.log('\n' + '═'.repeat(60));
497
+ console.log('📋 DECLARAR AVANCE BOLETAS');
498
+ console.log('═'.repeat(60));
499
+ console.log(` TrackId: ${trackId}`);
500
+
501
+ const host = this.ambiente === 'produccion' ? 'palena.sii.cl' : 'maullin.sii.cl';
502
+ const endpoint = '/cgi_dte/UPL/DTEauth?3';
503
+ const url = `https://${host}${endpoint}`;
504
+
505
+ // Preparar datos del formulario
506
+ const { splitRut } = this._lib();
507
+ const { numero: rutNum, dv } = splitRut(this.emisor.rut);
508
+
509
+ const formData = {
510
+ RUT: rutNum,
511
+ DV: dv,
512
+ ESSION_ID: trackId,
513
+ };
514
+
515
+ // Codificar como form-urlencoded
516
+ const formEncode = (obj) => Object.entries(obj)
517
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
518
+ .join('&');
519
+ const body = formEncode(formData);
520
+
521
+ // Cargar certificado para TLS mutuo
522
+ const pfx = this.certificado.pfxBuffer;
523
+ const password = this.certificado.password;
524
+ const p12Asn1 = forge.asn1.fromDer(pfx.toString('binary'));
525
+ const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password);
526
+ const keyObj = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag })[forge.pki.oids.pkcs8ShroudedKeyBag][0];
527
+ const certObj = p12.getBags({ bagType: forge.pki.oids.certBag })[forge.pki.oids.certBag][0];
528
+
529
+ const tlsOptions = {
530
+ key: forge.pki.privateKeyToPem(keyObj.key),
531
+ cert: forge.pki.certificateToPem(certObj.cert),
532
+ rejectUnauthorized: false,
533
+ };
534
+
535
+ // Hacer request
536
+ const requestWithCert = (options, payload) => new Promise((resolve, reject) => {
537
+ const req = https.request({ ...options, ...tlsOptions }, (res) => {
538
+ let data = '';
539
+ res.on('data', (chunk) => (data += chunk));
540
+ res.on('end', () => resolve({
541
+ status: res.statusCode,
542
+ text: data,
543
+ headers: res.headers,
544
+ }));
545
+ });
546
+ req.on('error', reject);
547
+ if (payload) req.write(payload);
548
+ req.end();
549
+ });
550
+
551
+ console.log(` 🔗 URL: ${url}`);
552
+
553
+ try {
554
+ const response = await requestWithCert({
555
+ method: 'POST',
556
+ hostname: host,
557
+ path: endpoint,
558
+ headers: {
559
+ 'Content-Type': 'application/x-www-form-urlencoded',
560
+ 'Content-Length': Buffer.byteLength(body),
561
+ },
562
+ }, body);
563
+
564
+ console.log(` 📥 Status: ${response.status}`);
565
+
566
+ // Guardar respuesta para debug
567
+ if (this.debugDir) {
568
+ const debugPath = path.join(this.debugDir, 'boleta-cert');
569
+ fs.mkdirSync(debugPath, { recursive: true });
570
+ fs.writeFileSync(
571
+ path.join(debugPath, `validacion-${trackId}.html`),
572
+ response.text,
573
+ 'utf-8'
574
+ );
575
+ }
576
+
577
+ // Parsear respuesta
578
+ const html = response.text;
579
+
580
+ // Detectar estados
581
+ const esAprobado = /REVISADO CONFORME|APROBADO|OK/i.test(html);
582
+ const esRechazado = /RECHAZADO|ERROR|REPARO/i.test(html);
583
+ const enRevision = /EN REVISION|PROCESANDO/i.test(html);
584
+
585
+ if (esAprobado) {
586
+ console.log(' ✅ BOLETAS APROBADAS');
587
+ return { success: true, estado: 'APROBADO', html };
588
+ } else if (esRechazado) {
589
+ console.log(' ❌ BOLETAS RECHAZADAS');
590
+ // Extraer mensaje de error si existe
591
+ const errorMatch = html.match(/<font[^>]*color[^>]*red[^>]*>([^<]+)</i);
592
+ const errorMsg = errorMatch ? errorMatch[1].trim() : 'Error desconocido';
593
+ return { success: false, estado: 'RECHAZADO', error: errorMsg, html };
594
+ } else if (enRevision) {
595
+ console.log(' 🔄 EN REVISIÓN');
596
+ return { success: true, estado: 'EN_REVISION', html };
597
+ } else {
598
+ console.log(' 📋 Respuesta recibida (verificar manualmente)');
599
+ return { success: true, estado: 'DESCONOCIDO', html };
600
+ }
601
+
602
+ } catch (error) {
603
+ console.error(` ❌ Error: ${error.message}`);
604
+ return { success: false, error: error.message };
605
+ }
606
+ }
607
+
608
+ /**
609
+ * Valida el estado del set de pruebas de boletas consultando al SII
610
+ * @param {string} trackId - TrackId del EnvioBOLETA
611
+ * @returns {Promise<Object>} Estado del set (SOK, SRH, etc.)
612
+ */
613
+ async validarSetBoletas(trackId) {
614
+ const https = require('https');
615
+ const forge = require('node-forge');
616
+
617
+ console.log(`\n Consultando estado del set...`);
618
+ console.log(` TrackId: ${trackId}`);
619
+
620
+ const host = this.ambiente === 'produccion' ? 'www4.sii.cl' : 'www4.sii.cl';
621
+
622
+ // Endpoint para consultar estado del set de boletas
623
+ // El portal usa certBolElectDteInternet con parámetro SET=2 para revisar
624
+ const endpoint = `/certBolElectDteInternet/ConsSetDte.do`;
625
+
626
+ // Preparar datos del formulario
627
+ const { splitRut } = this._lib();
628
+ const { numero: rutNum, dv } = splitRut(this.emisor.rut);
629
+
630
+ const formData = {
631
+ RUT_EMPRESA: rutNum,
632
+ DV_EMPRESA: dv,
633
+ TRACK_ID: trackId,
634
+ };
635
+
636
+ // Codificar como form-urlencoded
637
+ const formEncode = (obj) => Object.entries(obj)
638
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
639
+ .join('&');
640
+ const body = formEncode(formData);
641
+
642
+ // Cargar certificado para TLS mutuo
643
+ const pfx = this.certificado.pfxBuffer;
644
+ const password = this.certificado.password;
645
+ const p12Asn1 = forge.asn1.fromDer(pfx.toString('binary'));
646
+ const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password);
647
+ const keyObj = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag })[forge.pki.oids.pkcs8ShroudedKeyBag][0];
648
+ const certObj = p12.getBags({ bagType: forge.pki.oids.certBag })[forge.pki.oids.certBag][0];
649
+
650
+ const tlsOptions = {
651
+ key: forge.pki.privateKeyToPem(keyObj.key),
652
+ cert: forge.pki.certificateToPem(certObj.cert),
653
+ rejectUnauthorized: false,
654
+ };
655
+
656
+ // Hacer request
657
+ const requestWithCert = (options, payload) => new Promise((resolve, reject) => {
658
+ const req = https.request({ ...options, ...tlsOptions }, (res) => {
659
+ let data = '';
660
+ res.on('data', (chunk) => (data += chunk));
661
+ res.on('end', () => resolve({
662
+ status: res.statusCode,
663
+ text: data,
664
+ headers: res.headers,
665
+ }));
666
+ });
667
+ req.on('error', reject);
668
+ if (payload) req.write(payload);
669
+ req.end();
670
+ });
671
+
672
+ try {
673
+ const response = await requestWithCert({
674
+ method: 'POST',
675
+ hostname: host,
676
+ path: endpoint,
677
+ headers: {
678
+ 'Content-Type': 'application/x-www-form-urlencoded',
679
+ 'Content-Length': Buffer.byteLength(body),
680
+ },
681
+ }, body);
682
+
683
+ // Guardar respuesta para debug
684
+ if (this.debugDir) {
685
+ const debugPath = path.join(this.debugDir, 'boleta-cert');
686
+ fs.mkdirSync(debugPath, { recursive: true });
687
+ fs.writeFileSync(
688
+ path.join(debugPath, `set-estado-${trackId}.html`),
689
+ response.text,
690
+ 'utf-8'
691
+ );
692
+ }
693
+
694
+ const html = response.text;
695
+
696
+ // Buscar estado del set en la respuesta
697
+ const sokMatch = /SOK|SET DE PRUEBA CORRECTO/i.test(html);
698
+ const srhMatch = /SRH|SET DE PRUEBA RECHAZADO/i.test(html);
699
+ const pendienteMatch = /PENDIENTE|EN PROCESO|PROCESANDO/i.test(html);
700
+
701
+ // Extraer detalle de reparos si existe
702
+ const reparosMatch = html.match(/Detalle de Reparos[\s\S]*?(<table[\s\S]*?<\/table>|CASO-\d+[\s\S]*?(?=\n\n|\<))/i);
703
+ const detalle = reparosMatch ? reparosMatch[0].replace(/<[^>]+>/g, ' ').trim().substring(0, 500) : null;
704
+
705
+ if (sokMatch) {
706
+ return { success: true, estado: 'SOK', detalle: 'Set de prueba correcto' };
707
+ } else if (srhMatch) {
708
+ return { success: false, estado: 'SRH', detalle: detalle || 'Set rechazado - ver correo SII' };
709
+ } else if (pendienteMatch) {
710
+ return { success: true, estado: 'PENDIENTE', detalle: 'Aún en proceso de validación' };
711
+ } else {
712
+ // Si no podemos determinar el estado, intentar consultar por correo
713
+ return {
714
+ success: true,
715
+ estado: 'DESCONOCIDO',
716
+ detalle: 'Estado no determinado - verificar correo SII o portal manualmente'
717
+ };
718
+ }
719
+
720
+ } catch (error) {
721
+ console.error(` ⚠️ Error consultando estado: ${error.message}`);
722
+ return {
723
+ success: true,
724
+ estado: 'DESCONOCIDO',
725
+ detalle: `No se pudo consultar automáticamente: ${error.message}`
726
+ };
727
+ }
728
+ }
729
+ }
730
+
731
+ module.exports = BoletaCert;