@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
package/EnviadorSII.js ADDED
@@ -0,0 +1,1304 @@
1
+ // Copyright (c) 2026 Devlas SpA — https://devlas.cl
2
+ // Licencia MIT. Ver archivo LICENSE para mas detalles.
3
+ /**
4
+ * EnviadorSII.js
5
+ *
6
+ * Comunicación con los servicios web del SII para:
7
+ * - Autenticación (semilla/token)
8
+ * - Envío de boletas (API REST)
9
+ * - Envío de DTEs (SOAP/DTEUpload)
10
+ * - Envío de RCOF (SOAP/DTEUpload)
11
+ * - Envío de Libros (SOAP/DTEUpload)
12
+ * - Consulta de estado de envíos
13
+ */
14
+
15
+ const https = require('https');
16
+ const forge = require('node-forge');
17
+ const FormData = require('form-data');
18
+ const {
19
+ saveEnvioArtifacts,
20
+ siiError,
21
+ ERROR_CODES,
22
+ createScopedLogger,
23
+ getConfigSection,
24
+ withRetry,
25
+ isRetryableError,
26
+ isRetryableStatus,
27
+ getCachedToken,
28
+ setCachedToken,
29
+ invalidateToken,
30
+ // Nuevas utilidades centralizadas
31
+ parseXml,
32
+ parseXmlNoNs,
33
+ decodeXmlEntities,
34
+ extractTagContent,
35
+ SOAP_ENDPOINTS,
36
+ REST_ENDPOINTS,
37
+ validateAmbiente,
38
+ } = require('./utils');
39
+
40
+ const log = createScopedLogger('EnviadorSII');
41
+
42
+ class EnviadorSII {
43
+ /**
44
+ * @param {Object} certificado - Instancia de Certificado (OBLIGATORIO)
45
+ * @param {string} ambiente - 'certificacion' o 'produccion' (OBLIGATORIO)
46
+ * @param {Object} [options] - Opciones adicionales
47
+ * @param {boolean} [options.useTokenCache=true] - Usar cache de tokens
48
+ */
49
+ constructor(certificado, ambiente, options = {}) {
50
+ // Validar parámetros obligatorios (multi-tenant: nunca usar defaults)
51
+ if (!certificado) {
52
+ throw siiError('EnviadorSII: certificado es obligatorio', ERROR_CODES.CONFIG_MISSING);
53
+ }
54
+ if (!ambiente) {
55
+ throw siiError('EnviadorSII: ambiente es obligatorio', ERROR_CODES.CONFIG_MISSING);
56
+ }
57
+
58
+ // Usar validador centralizado
59
+ this.ambiente = validateAmbiente(ambiente);
60
+ this.certificado = certificado;
61
+ this.token = null; // Token REST (boletas)
62
+ this.tokenSoap = null; // Token SOAP (DTEUpload)
63
+
64
+ // Opciones
65
+ this.useTokenCache = options.useTokenCache !== false;
66
+
67
+ // RUT del certificado para cache
68
+ this.rutCert = certificado.rut || 'unknown';
69
+
70
+ // URLs centralizadas desde utils/endpoints.js
71
+ this.urls = {
72
+ certificacion: {
73
+ ...REST_ENDPOINTS.certificacion,
74
+ rcof: SOAP_ENDPOINTS.certificacion.upload,
75
+ semillaSoap: SOAP_ENDPOINTS.certificacion.seed,
76
+ tokenSoap: SOAP_ENDPOINTS.certificacion.token,
77
+ },
78
+ produccion: {
79
+ ...REST_ENDPOINTS.produccion,
80
+ rcof: SOAP_ENDPOINTS.produccion.upload,
81
+ semillaSoap: SOAP_ENDPOINTS.produccion.seed,
82
+ tokenSoap: SOAP_ENDPOINTS.produccion.token,
83
+ },
84
+ };
85
+ }
86
+
87
+ // ============================================
88
+ // AUTENTICACIÓN REST (Boletas)
89
+ // ============================================
90
+
91
+ /**
92
+ * Obtener semilla de autenticación del SII (API REST)
93
+ */
94
+ async getSemilla() {
95
+ const url = this.urls[this.ambiente].semilla;
96
+
97
+ log.log(' Solicitando semilla a:', url);
98
+
99
+ const response = await fetch(url, {
100
+ method: 'GET',
101
+ headers: {
102
+ 'Accept': 'application/xml',
103
+ },
104
+ });
105
+
106
+ if (!response.ok) {
107
+ throw new Error(`Error obteniendo semilla: ${response.status}`);
108
+ }
109
+
110
+ const xml = await response.text();
111
+ log.log(' XML semilla recibido:', xml.substring(0, 500));
112
+
113
+ // Usar parser centralizado
114
+ const data = parseXml(xml);
115
+
116
+ // Estructura con namespaces SII
117
+ const respuesta = data['SII:RESPUESTA'];
118
+ if (respuesta?.['SII:RESP_BODY']?.SEMILLA) {
119
+ return respuesta['SII:RESP_BODY'].SEMILLA.toString();
120
+ }
121
+
122
+ if (data.SII?.RESP_BODY?.SEMILLA) {
123
+ return data.SII.RESP_BODY.SEMILLA.toString();
124
+ }
125
+
126
+ if (data.RESPUESTA?.RESP_BODY?.SEMILLA) {
127
+ return data.RESPUESTA.RESP_BODY.SEMILLA.toString();
128
+ }
129
+
130
+ if (data.getToken?.item?.Semilla) {
131
+ return data.getToken.item.Semilla.toString();
132
+ }
133
+
134
+ log.debug(' Estructura XML parseada:', JSON.stringify(data, null, 2));
135
+ throw new Error('No se pudo obtener semilla del SII');
136
+ }
137
+
138
+ /**
139
+ * Firmar semilla y obtener token (API REST)
140
+ */
141
+ async getToken() {
142
+ const semilla = await this.getSemilla();
143
+ log.log('Semilla obtenida:', semilla);
144
+
145
+ const xmlSemilla = this._crearXMLSemilla(semilla);
146
+
147
+ const url = this.urls[this.ambiente].token;
148
+
149
+ const response = await fetch(url, {
150
+ method: 'POST',
151
+ headers: {
152
+ 'Content-Type': 'application/xml',
153
+ 'Accept': 'application/xml',
154
+ },
155
+ body: xmlSemilla,
156
+ });
157
+
158
+ if (!response.ok) {
159
+ const errorText = await response.text();
160
+ throw new Error(`Error obteniendo token: ${response.status} - ${errorText}`);
161
+ }
162
+
163
+ const xml = await response.text();
164
+ log.log('Respuesta token:', xml);
165
+
166
+ // Usar parser centralizado con namespaces removidos
167
+ const data = parseXmlNoNs(xml);
168
+
169
+ const respuesta = data.RESPUESTA || data['SII:RESPUESTA'];
170
+ if (respuesta) {
171
+ const respBody = respuesta.RESP_BODY || respuesta['SII:RESP_BODY'];
172
+ if (respBody && respBody.TOKEN) {
173
+ this.token = respBody.TOKEN;
174
+ log.log('✅ Token obtenido:', this.token);
175
+ return this.token;
176
+ }
177
+ }
178
+
179
+ throw new Error('No se pudo obtener token del SII');
180
+ }
181
+
182
+ // ============================================
183
+ // AUTENTICACIÓN SOAP (DTEUpload)
184
+ // ============================================
185
+
186
+ /**
187
+ * Obtener semilla del servicio SOAP para DTEUpload
188
+ * Usa configuración centralizada para reintentos
189
+ */
190
+ async getSemillaSoap() {
191
+ const url = this.urls[this.ambiente].semillaSoap;
192
+ log.log(' Solicitando semilla SOAP a:', url);
193
+ const retryConfig = getConfigSection('retry');
194
+ const maxRetries = retryConfig?.maxRetries || 6;
195
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
196
+
197
+ const soapEnvelope = `<?xml version="1.0" encoding="UTF-8"?>
198
+ <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
199
+ <soapenv:Body>
200
+ <getSeed/>
201
+ </soapenv:Body>
202
+ </soapenv:Envelope>`;
203
+
204
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
205
+ try {
206
+ if (attempt > 1) {
207
+ log.log(` 🔄 Reintento semilla SOAP ${attempt}/${maxRetries}...`);
208
+ await wait(attempt * 1000);
209
+ }
210
+
211
+ const response = await fetch(url, {
212
+ method: 'POST',
213
+ headers: {
214
+ 'Content-Type': 'text/xml; charset=utf-8',
215
+ 'SOAPAction': '',
216
+ },
217
+ body: soapEnvelope,
218
+ });
219
+
220
+ if (!response.ok) {
221
+ if (isRetryableStatus(response.status) && attempt < maxRetries) {
222
+ log.log(` ⚠️ Error semilla SOAP (${response.status}), reintentando...`);
223
+ continue;
224
+ }
225
+ throw siiError(`Error obteniendo semilla SOAP: ${response.status}`, ERROR_CODES.SII_CONNECTION_FAILED);
226
+ }
227
+
228
+ const xml = await response.text();
229
+ log.log(' Respuesta semilla SOAP (primeros 400 chars):', xml.substring(0, 400));
230
+
231
+ // Usar utilidad centralizada para decodificar entidades
232
+ const decodedXml = decodeXmlEntities(xml);
233
+
234
+ // Usar utilidad centralizada para extraer contenido de etiqueta
235
+ const semilla = extractTagContent(decodedXml, 'SEMILLA');
236
+ if (semilla) {
237
+ log.log(' Semilla extraída:', semilla);
238
+ return semilla;
239
+ }
240
+
241
+ const estado = extractTagContent(decodedXml, 'ESTADO');
242
+ if (estado && estado !== '00') {
243
+ throw siiError(`Error del SII al obtener semilla: Estado ${estado}`, ERROR_CODES.SII_INVALID_RESPONSE);
244
+ }
245
+
246
+ throw siiError('No se pudo extraer semilla de la respuesta SOAP', ERROR_CODES.SII_INVALID_RESPONSE);
247
+ } catch (error) {
248
+ if (isRetryableError(error) && attempt < maxRetries) {
249
+ log.log(` ⚠️ Error de conexión semilla SOAP (${error.cause?.code || 'socket'}), reintentando...`);
250
+ continue;
251
+ }
252
+ throw error;
253
+ }
254
+ }
255
+
256
+ throw siiError('Semilla SOAP falló después de múltiples reintentos', ERROR_CODES.SII_TIMEOUT);
257
+ }
258
+
259
+ /**
260
+ * Obtener token del servicio SOAP para DTEUpload
261
+ * Usa cache de tokens para evitar solicitudes innecesarias
262
+ */
263
+ async getTokenSoap() {
264
+ // Verificar cache primero
265
+ if (this.useTokenCache) {
266
+ const cached = getCachedToken(this.ambiente, 'soap', this.rutCert);
267
+ if (cached) {
268
+ log.log(' ✅ Token SOAP desde cache');
269
+ this.tokenSoap = cached;
270
+ return cached;
271
+ }
272
+ }
273
+
274
+ const retryConfig = getConfigSection('retry');
275
+ const maxRetries = retryConfig?.maxRetries || 6;
276
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
277
+ const url = this.urls[this.ambiente].tokenSoap;
278
+
279
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
280
+ try {
281
+ if (attempt > 1) {
282
+ log.log(` 🔄 Reintento token SOAP ${attempt}/${maxRetries}...`);
283
+ await wait(attempt * 1000);
284
+ }
285
+
286
+ const semilla = await this.getSemillaSoap();
287
+ log.log(' Semilla SOAP obtenida:', semilla);
288
+
289
+ const xmlSemilla = this._crearXMLSemilla(semilla);
290
+ log.log(' Obteniendo token SOAP de:', url);
291
+
292
+ const soapEnvelope = `<?xml version="1.0" encoding="UTF-8"?>
293
+ <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
294
+ <soapenv:Body>
295
+ <getToken>
296
+ <pszXml>${xmlSemilla.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</pszXml>
297
+ </getToken>
298
+ </soapenv:Body>
299
+ </soapenv:Envelope>`;
300
+
301
+ const response = await fetch(url, {
302
+ method: 'POST',
303
+ headers: {
304
+ 'Content-Type': 'text/xml; charset=utf-8',
305
+ 'SOAPAction': '',
306
+ },
307
+ body: soapEnvelope,
308
+ });
309
+
310
+ if (!response.ok) {
311
+ const errorText = await response.text();
312
+ if (isRetryableStatus(response.status) && attempt < maxRetries) {
313
+ log.log(` ⚠️ Error token SOAP (${response.status}), reintentando...`);
314
+ continue;
315
+ }
316
+ throw siiError(`Error obteniendo token SOAP: ${response.status} - ${errorText}`, ERROR_CODES.SII_CONNECTION_FAILED);
317
+ }
318
+
319
+ const xml = await response.text();
320
+ log.log(' Respuesta token SOAP (primeros 600 chars):', xml.substring(0, 600));
321
+
322
+ const decodedXml = xml
323
+ .replace(/&lt;/g, '<')
324
+ .replace(/&gt;/g, '>')
325
+ .replace(/&quot;/g, '"')
326
+ .replace(/&amp;/g, '&');
327
+
328
+ const tokenMatch = decodedXml.match(/<TOKEN>([^<]+)<\/TOKEN>/i);
329
+ if (tokenMatch) {
330
+ this.tokenSoap = tokenMatch[1];
331
+
332
+ // Guardar en cache
333
+ if (this.useTokenCache) {
334
+ setCachedToken(this.ambiente, 'soap', this.rutCert, this.tokenSoap);
335
+ }
336
+
337
+ log.log(' ✅ Token SOAP obtenido:', this.tokenSoap);
338
+ return this.tokenSoap;
339
+ }
340
+
341
+ const estadoMatch = decodedXml.match(/<ESTADO>(\d+)<\/ESTADO>/);
342
+ const glosaMatch = decodedXml.match(/<GLOSA>([^<]+)<\/GLOSA>/);
343
+ if (estadoMatch && estadoMatch[1] !== '00') {
344
+ throw siiError(`Error del SII: Estado ${estadoMatch[1]} - ${glosaMatch ? glosaMatch[1] : 'Sin detalle'}`, ERROR_CODES.SII_AUTH_FAILED);
345
+ }
346
+
347
+ throw siiError('No se pudo obtener token SOAP del SII', ERROR_CODES.SII_INVALID_RESPONSE);
348
+ } catch (error) {
349
+ if (isRetryableError(error) && attempt < maxRetries) {
350
+ log.log(` ⚠️ Error de conexión token SOAP (${error.cause?.code || 'socket'}), reintentando...`);
351
+ continue;
352
+ }
353
+ throw error;
354
+ }
355
+ }
356
+
357
+ throw siiError('Token SOAP falló después de múltiples reintentos', ERROR_CODES.SII_TIMEOUT);
358
+ }
359
+
360
+ /**
361
+ * Invalidar token cacheado (para forzar renovación)
362
+ */
363
+ invalidateCachedToken(tipo = 'soap') {
364
+ invalidateToken(this.ambiente, tipo, this.rutCert);
365
+ if (tipo === 'soap') {
366
+ this.tokenSoap = null;
367
+ } else {
368
+ this.token = null;
369
+ }
370
+ }
371
+
372
+ // ============================================
373
+ // HELPERS DE FIRMA
374
+ // ============================================
375
+
376
+ /**
377
+ * Crear XML de semilla firmado
378
+ */
379
+ _crearXMLSemilla(semilla) {
380
+ const xmlContent = `<getToken><item><Semilla>${semilla}</Semilla></item></getToken>`;
381
+
382
+ const md = forge.md.sha1.create();
383
+ md.update(xmlContent, 'utf8');
384
+ const digestValue = forge.util.encode64(md.digest().bytes());
385
+
386
+ const signedInfoParaFirmar = `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"></CanonicalizationMethod><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"></SignatureMethod><Reference URI=""><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"></Transform></Transforms><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"></DigestMethod><DigestValue>${digestValue}</DigestValue></Reference></SignedInfo>`;
387
+
388
+ const mdSign = forge.md.sha1.create();
389
+ mdSign.update(signedInfoParaFirmar, 'utf8');
390
+ const signature = this.certificado.privateKey.sign(mdSign);
391
+ const signatureValue = this._wordwrap(forge.util.encode64(signature), 64);
392
+
393
+ const modulus = this._wordwrap(this.certificado.getModulus(), 64);
394
+ const exponent = this.certificado.getExponent();
395
+ const cert = this._wordwrap(this.certificado.getCertificateBase64(), 64);
396
+
397
+ log.log('SignedInfo para firmar (length):', signedInfoParaFirmar.length);
398
+
399
+ const xmlFirmado = `<?xml version="1.0" encoding="ISO-8859-1"?>
400
+ <getToken><item><Semilla>${semilla}</Semilla></item><Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/><Reference URI=""><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/></Transforms><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><DigestValue>${digestValue}</DigestValue></Reference></SignedInfo><SignatureValue>${signatureValue}</SignatureValue><KeyInfo><KeyValue><RSAKeyValue><Modulus>${modulus}</Modulus><Exponent>${exponent}</Exponent></RSAKeyValue></KeyValue><X509Data><X509Certificate>${cert}</X509Certificate></X509Data></KeyInfo></Signature></getToken>`;
401
+
402
+ log.log('DigestValue:', digestValue);
403
+
404
+ return xmlFirmado;
405
+ }
406
+
407
+ /**
408
+ * Wordwrap para base64 (como PHP)
409
+ */
410
+ _wordwrap(str, width) {
411
+ const lines = [];
412
+ for (let i = 0; i < str.length; i += width) {
413
+ lines.push(str.substring(i, i + width));
414
+ }
415
+ return lines.join('\n');
416
+ }
417
+
418
+ // ============================================
419
+ // ENVÍO DE BOLETAS (API REST)
420
+ // ============================================
421
+
422
+ /**
423
+ * Enviar EnvioBOLETA al SII (API REST para Boletas)
424
+ */
425
+ async enviar(envioBoleta, rutEmisor, rutEnvia) {
426
+ if (!this.token) {
427
+ await this.getToken();
428
+ }
429
+
430
+ if (!envioBoleta.xml) {
431
+ envioBoleta.setCaratula({
432
+ RutEmisor: rutEmisor,
433
+ RutEnvia: rutEnvia,
434
+ FchResol: envioBoleta.config?.fchResol || '2014-08-22',
435
+ NroResol: envioBoleta.config?.nroResol !== undefined ? envioBoleta.config.nroResol : 0,
436
+ });
437
+ envioBoleta.generar();
438
+ }
439
+
440
+ const url = this.urls[this.ambiente].envio;
441
+ let xml = envioBoleta.getXML();
442
+
443
+ if (!xml) {
444
+ throw new Error('No se pudo generar el XML del EnvioBOLETA');
445
+ }
446
+
447
+ if (!xml.startsWith('<?xml')) {
448
+ xml = '<?xml version="1.0" encoding="ISO-8859-1"?>\n' + xml;
449
+ }
450
+
451
+ const [rutSenderStr, dvSender] = rutEnvia.split('-');
452
+ const [rutCompanyStr, dvCompany] = rutEmisor.split('-');
453
+ const rutSender = parseInt(rutSenderStr.replace(/\./g, ''), 10);
454
+ const rutCompany = parseInt(rutCompanyStr.replace(/\./g, ''), 10);
455
+
456
+ const formData = new FormData();
457
+ formData.append('rutSender', rutSender.toString());
458
+ formData.append('dvSender', dvSender.toUpperCase());
459
+ formData.append('rutCompany', rutCompany.toString());
460
+ formData.append('dvCompany', dvCompany.toUpperCase());
461
+
462
+ const xmlBuffer = Buffer.from(xml, 'utf-8');
463
+ formData.append('archivo', xmlBuffer, {
464
+ filename: 'EnvioBOLETA.xml',
465
+ contentType: 'application/xml',
466
+ });
467
+
468
+ log.log('Enviando al SII:', url);
469
+ log.log('RUT Sender:', rutSender, '-', dvSender.toUpperCase());
470
+ log.log('RUT Company:', rutCompany, '-', dvCompany.toUpperCase());
471
+ log.log('XML Length:', xmlBuffer.length, 'bytes');
472
+
473
+ const urlObj = new URL(url);
474
+ const formBuffer = formData.getBuffer();
475
+ const formHeaders = formData.getHeaders();
476
+
477
+ const responseText = await new Promise((resolve, reject) => {
478
+ const req = https.request({
479
+ hostname: urlObj.hostname,
480
+ path: urlObj.pathname,
481
+ method: 'POST',
482
+ headers: {
483
+ ...formHeaders,
484
+ 'Content-Length': formBuffer.length,
485
+ 'User-Agent': 'Mozilla/4.0 ( compatible; PROG 1.0; Windows NT)',
486
+ 'Accept': 'application/json',
487
+ 'Cookie': `TOKEN=${this.token}`,
488
+ },
489
+ }, (res) => {
490
+ let data = '';
491
+ res.on('data', chunk => data += chunk);
492
+ res.on('end', () => {
493
+ log.log('HTTP Status:', res.statusCode);
494
+ resolve({ text: data, status: res.statusCode });
495
+ });
496
+ });
497
+
498
+ req.on('error', reject);
499
+ req.write(formBuffer);
500
+ req.end();
501
+ });
502
+
503
+ log.log('Respuesta SII:', responseText.text);
504
+
505
+ const resultado = this._parsearRespuestaEnvio(responseText.text, responseText.status);
506
+ saveEnvioArtifacts({
507
+ xml,
508
+ responseText: responseText.text,
509
+ responseOk: resultado.ok,
510
+ responseStatus: responseText.status,
511
+ trackId: resultado.trackId || null,
512
+ ambiente: this.ambiente,
513
+ tipoEnvio: 'EnvioBOLETA-REST',
514
+ error: resultado.error || null,
515
+ });
516
+
517
+ return resultado;
518
+ }
519
+
520
+ /**
521
+ * Enviar XML ya generado directamente al SII
522
+ */
523
+ async enviarXmlDirecto(xml, rutEmisor, rutEnvia) {
524
+ if (!this.token) {
525
+ await this.getToken();
526
+ }
527
+
528
+ const url = this.urls[this.ambiente].envio;
529
+
530
+ if (!xml.startsWith('<?xml')) {
531
+ xml = '<?xml version="1.0" encoding="ISO-8859-1"?>\n' + xml;
532
+ }
533
+
534
+ const [rutSenderStr, dvSender] = rutEnvia.split('-');
535
+ const [rutCompanyStr, dvCompany] = rutEmisor.split('-');
536
+ const rutSender = parseInt(rutSenderStr.replace(/\./g, ''), 10);
537
+ const rutCompany = parseInt(rutCompanyStr.replace(/\./g, ''), 10);
538
+
539
+ const formData = new FormData();
540
+ formData.append('rutSender', rutSender.toString());
541
+ formData.append('dvSender', dvSender.toUpperCase());
542
+ formData.append('rutCompany', rutCompany.toString());
543
+ formData.append('dvCompany', dvCompany.toUpperCase());
544
+
545
+ const xmlBuffer = Buffer.from(xml, 'utf-8');
546
+ formData.append('archivo', xmlBuffer, {
547
+ filename: 'EnvioBOLETA.xml',
548
+ contentType: 'application/xml',
549
+ });
550
+
551
+ log.log('Enviando XML directo al SII:', url);
552
+ log.log('RUT Sender:', rutSender, '-', dvSender.toUpperCase());
553
+ log.log('RUT Company:', rutCompany, '-', dvCompany.toUpperCase());
554
+ log.log('XML Length:', xmlBuffer.length, 'bytes');
555
+
556
+ const urlObj = new URL(url);
557
+ const formBuffer = formData.getBuffer();
558
+ const formHeaders = formData.getHeaders();
559
+
560
+ const responseText = await new Promise((resolve, reject) => {
561
+ const req = https.request({
562
+ hostname: urlObj.hostname,
563
+ path: urlObj.pathname,
564
+ method: 'POST',
565
+ headers: {
566
+ ...formHeaders,
567
+ 'Content-Length': formBuffer.length,
568
+ 'User-Agent': 'Mozilla/4.0 ( compatible; PROG 1.0; Windows NT)',
569
+ 'Accept': 'application/json',
570
+ 'Cookie': `TOKEN=${this.token}`,
571
+ },
572
+ }, (res) => {
573
+ let data = '';
574
+ res.on('data', chunk => data += chunk);
575
+ res.on('end', () => {
576
+ log.log('HTTP Status:', res.statusCode);
577
+ resolve({ text: data, status: res.statusCode });
578
+ });
579
+ });
580
+
581
+ req.on('error', reject);
582
+ req.write(formBuffer);
583
+ req.end();
584
+ });
585
+
586
+ log.log('Respuesta SII:', responseText.text);
587
+ const resultado = this._parsearRespuestaEnvio(responseText.text, responseText.status);
588
+ saveEnvioArtifacts({
589
+ xml,
590
+ responseText: responseText.text,
591
+ responseOk: resultado.ok,
592
+ responseStatus: responseText.status,
593
+ trackId: resultado.trackId || null,
594
+ ambiente: this.ambiente,
595
+ tipoEnvio: 'EnvioBOLETA-REST',
596
+ error: resultado.error || null,
597
+ });
598
+ return resultado;
599
+ }
600
+
601
+ /**
602
+ * Parsear respuesta del envío (JSON de la API REST)
603
+ */
604
+ _parsearRespuestaEnvio(responseText, httpStatus) {
605
+ if (httpStatus !== 200) {
606
+ return {
607
+ ok: false,
608
+ status: httpStatus,
609
+ trackId: null,
610
+ mensaje: `Error HTTP ${httpStatus}`,
611
+ respuesta: responseText,
612
+ };
613
+ }
614
+
615
+ try {
616
+ const json = JSON.parse(responseText);
617
+
618
+ if (json.trackid) {
619
+ return {
620
+ ok: true,
621
+ status: 0,
622
+ trackId: json.trackid.toString(),
623
+ mensaje: `✅ Enviado al SII - TrackID: ${json.trackid}`,
624
+ respuesta: json,
625
+ };
626
+ }
627
+
628
+ if (json.error || json.mensaje) {
629
+ return {
630
+ ok: false,
631
+ status: json.codigo || json.status || -1,
632
+ trackId: null,
633
+ mensaje: json.error || json.mensaje || 'Error desconocido',
634
+ respuesta: json,
635
+ };
636
+ }
637
+
638
+ return {
639
+ ok: false,
640
+ status: -1,
641
+ trackId: null,
642
+ mensaje: 'Respuesta JSON sin trackid',
643
+ respuesta: json,
644
+ };
645
+ } catch (e) {
646
+ const statusMatch = responseText.match(/<STATUS>(\d+)<\/STATUS>/);
647
+ const trackIdMatch = responseText.match(/<TRACKID>(\d+)<\/TRACKID>/);
648
+
649
+ const status = statusMatch ? parseInt(statusMatch[1]) : null;
650
+ const trackId = trackIdMatch ? trackIdMatch[1] : null;
651
+
652
+ if (status === 0 && trackId) {
653
+ return {
654
+ ok: true,
655
+ status: status,
656
+ trackId: trackId,
657
+ mensaje: `✅ Enviado al SII - TrackID: ${trackId}`,
658
+ };
659
+ }
660
+
661
+ return {
662
+ ok: false,
663
+ status: status,
664
+ trackId: trackId,
665
+ mensaje: 'Error parseando respuesta',
666
+ respuesta: responseText,
667
+ };
668
+ }
669
+ }
670
+
671
+ // ============================================
672
+ // CONSULTA DE ESTADO
673
+ // ============================================
674
+
675
+ /**
676
+ * Consultar estado de envío (API REST)
677
+ */
678
+ async consultarEstado(trackId, rutEmisor, rutEnvia = null) {
679
+ if (!this.token) {
680
+ await this.getToken();
681
+ }
682
+
683
+ const rutEmisorLimpio = rutEmisor.replace(/\./g, '');
684
+ const url = `${this.urls[this.ambiente].estado}${rutEmisorLimpio}-${trackId}`;
685
+
686
+ log.log('Consultando estado:', url);
687
+
688
+ const response = await fetch(url, {
689
+ method: 'GET',
690
+ headers: {
691
+ 'Cookie': `TOKEN=${this.token}`,
692
+ 'Accept': 'application/json',
693
+ 'User-Agent': 'Mozilla/4.0 ( compatible; PROG 1.0; Windows NT)',
694
+ },
695
+ });
696
+
697
+ const responseText = await response.text();
698
+ log.log('Respuesta estado:', responseText);
699
+
700
+ if (!response.ok) {
701
+ return {
702
+ ok: false,
703
+ error: `Error HTTP ${response.status}`,
704
+ respuesta: responseText,
705
+ };
706
+ }
707
+
708
+ try {
709
+ const json = JSON.parse(responseText);
710
+
711
+ let mensaje = '';
712
+ const estado = json.estado;
713
+ if (estado === 'REC') mensaje = '📥 Envío recibido';
714
+ else if (estado === 'SOK') mensaje = '✅ Esquema validado';
715
+ else if (estado === 'FOK') mensaje = '✅ Firma de envío validada';
716
+ else if (estado === 'PRD') mensaje = '⏳ Envío en proceso';
717
+ else if (estado === 'CRT') mensaje = '✅ Carátula OK';
718
+ else if (estado === 'EPR') mensaje = '✅ Envío procesado';
719
+ else if (estado === 'RPT') mensaje = '❌ Rechazado por schema';
720
+ else if (estado === 'RFR') mensaje = '❌ Rechazado por error en firma';
721
+ else if (estado === 'VOF') mensaje = '❌ Error interno en SII';
722
+ else if (estado === 'RCT') mensaje = '❌ Rechazado por error en carátula';
723
+ else if (estado === 'RPR') mensaje = '⚠️ Aceptado con reparos';
724
+ else if (estado === 'RLV') mensaje = '✅ Aceptado - Documento(s) válido(s)';
725
+ else if (estado === 'RCH') mensaje = `❌ Rechazado: ${json.glosa || json.descripcion || 'Sin detalle'}`;
726
+ else mensaje = `Estado: ${estado}`;
727
+
728
+ return {
729
+ ok: true,
730
+ trackId: trackId,
731
+ estado: estado,
732
+ descripcion: json.descripcion || json.glosa,
733
+ mensaje,
734
+ ...json,
735
+ };
736
+ } catch (e) {
737
+ return {
738
+ ok: false,
739
+ error: 'Error parseando respuesta',
740
+ mensaje: 'Error parseando respuesta del SII',
741
+ respuesta: responseText,
742
+ };
743
+ }
744
+ }
745
+
746
+ /**
747
+ * Consultar estado de envío via SOAP (QueryEstUp.jws)
748
+ */
749
+ async consultarEstadoSoap(trackId, rutEmisor) {
750
+ if (!this.tokenSoap) {
751
+ await this.getTokenSoap();
752
+ }
753
+
754
+ const servidor = this.ambiente === 'produccion' ? 'palena' : 'maullin';
755
+ const [rutNum, dv] = rutEmisor.replace(/\./g, '').split('-');
756
+
757
+ const urlQueryEstUp = `https://${servidor}.sii.cl/DTEWS/QueryEstUp.jws`;
758
+
759
+ const soapBody = `<?xml version="1.0" encoding="UTF-8"?>
760
+ <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
761
+ <soapenv:Body>
762
+ <getEstUp>
763
+ <RutEmpresa>${rutNum}</RutEmpresa>
764
+ <DvEmpresa>${dv}</DvEmpresa>
765
+ <TrackId>${trackId}</TrackId>
766
+ <Token>${this.tokenSoap}</Token>
767
+ </getEstUp>
768
+ </soapenv:Body>
769
+ </soapenv:Envelope>`;
770
+
771
+ log.log('📊 Consultando estado SOAP:', urlQueryEstUp);
772
+ log.log(' TrackID:', trackId, 'RUT:', rutEmisor);
773
+
774
+ const response = await fetch(urlQueryEstUp, {
775
+ method: 'POST',
776
+ headers: {
777
+ 'Content-Type': 'text/xml; charset=utf-8',
778
+ 'SOAPAction': '',
779
+ },
780
+ body: soapBody,
781
+ });
782
+
783
+ const text = await response.text();
784
+
785
+ // Usar decodeXmlEntities centralizado
786
+ const decoded = decodeXmlEntities(text).replace(/&#xd;/g, '\n');
787
+
788
+ log.log(' Respuesta SOAP estado:', decoded.substring(0, 500));
789
+
790
+ const estadoMatch = decoded.match(/<ESTADO>([^<]+)<\/ESTADO>/i);
791
+ const glosaMatch = decoded.match(/<GLOSA>([^<]+)<\/GLOSA>/i);
792
+ const numAtencionMatch = decoded.match(/<NUM_ATENCION>([^<]+)<\/NUM_ATENCION>/i);
793
+ const errCodeMatch = decoded.match(/<ERR_CODE>([^<]+)<\/ERR_CODE>/i);
794
+
795
+ const estado = estadoMatch ? estadoMatch[1] : null;
796
+ const glosa = glosaMatch ? glosaMatch[1] : null;
797
+
798
+ if (!estado) {
799
+ if (errCodeMatch) {
800
+ return {
801
+ ok: false,
802
+ error: `Error ${errCodeMatch[1]}`,
803
+ glosa: glosa || 'Error desconocido',
804
+ respuesta: decoded,
805
+ };
806
+ }
807
+ return {
808
+ ok: false,
809
+ error: 'No se pudo obtener estado',
810
+ respuesta: decoded,
811
+ };
812
+ }
813
+
814
+ let mensaje = '';
815
+ let esExitoso = false;
816
+ let esIntermedio = false;
817
+ let esRechazado = false;
818
+
819
+ switch (estado) {
820
+ case 'EPR':
821
+ mensaje = '✅ Envío Procesado - ¡Listo para declarar cumplimiento!';
822
+ esExitoso = true;
823
+ break;
824
+ case 'RPR':
825
+ mensaje = '⚠️ Aceptado con Reparos';
826
+ esExitoso = true;
827
+ break;
828
+ case 'REC':
829
+ mensaje = '⏳ Envío Recibido - Esperando validación';
830
+ esIntermedio = true;
831
+ break;
832
+ case 'SOK':
833
+ mensaje = '⏳ Schema OK - Validando firma...';
834
+ esIntermedio = true;
835
+ break;
836
+ case 'FOK':
837
+ mensaje = '⏳ Firma Validada - Procesando envío...';
838
+ esIntermedio = true;
839
+ break;
840
+ case 'PRD':
841
+ mensaje = '⏳ Envío en Proceso - Validando carátula...';
842
+ esIntermedio = true;
843
+ break;
844
+ case 'CRT':
845
+ mensaje = '⏳ Carátula OK - Finalizando proceso...';
846
+ esIntermedio = true;
847
+ break;
848
+ case 'DNK':
849
+ mensaje = '⏳ En proceso de revisión';
850
+ esIntermedio = true;
851
+ break;
852
+ case 'RPT':
853
+ mensaje = `❌ Rechazado por Schema: ${glosa || 'Error en estructura XML'}`;
854
+ esRechazado = true;
855
+ break;
856
+ case 'RFR':
857
+ mensaje = `❌ Rechazado por Firma: ${glosa || 'Error en firma digital'}`;
858
+ esRechazado = true;
859
+ break;
860
+ case 'VOF':
861
+ mensaje = `❌ Error Interno del SII: ${glosa || 'Reintentar más tarde'}`;
862
+ esRechazado = true;
863
+ break;
864
+ case 'RCT':
865
+ mensaje = `❌ Rechazado por Error en Carátula: ${glosa || 'Verificar datos del emisor'}`;
866
+ esRechazado = true;
867
+ break;
868
+ case 'RCH':
869
+ mensaje = `❌ Rechazado: ${glosa || 'Sin detalle'}`;
870
+ esRechazado = true;
871
+ break;
872
+ case 'RLV':
873
+ mensaje = `❌ Rechazado: ${glosa || 'Error en documento'}`;
874
+ esRechazado = true;
875
+ break;
876
+ default:
877
+ mensaje = `Estado: ${estado} - ${glosa || 'Sin descripción'}`;
878
+ }
879
+
880
+ return {
881
+ ok: true,
882
+ esExitoso,
883
+ esIntermedio,
884
+ esRechazado,
885
+ trackId,
886
+ estado,
887
+ glosa,
888
+ mensaje,
889
+ numAtencion: numAtencionMatch ? numAtencionMatch[1] : null,
890
+ };
891
+ }
892
+
893
+ // ============================================
894
+ // ENVÍO SOAP/DTEUpload
895
+ // ============================================
896
+
897
+ /**
898
+ * Enviar EnvioBOLETA al SII via SOAP/DTEUpload
899
+ */
900
+ async enviarBoletaSoap(envioBoleta) {
901
+ const { DOMParser } = require('@xmldom/xmldom');
902
+
903
+ if (!this.tokenSoap) {
904
+ log.log('📡 Obteniendo token SOAP para DTEUpload...');
905
+ await this.getTokenSoap();
906
+ }
907
+
908
+ const xml = envioBoleta.getXML();
909
+ if (!xml) {
910
+ throw new Error('El EnvioBoleta no tiene XML generado. Llame a generar() primero.');
911
+ }
912
+
913
+ const parser = new DOMParser();
914
+ const doc = parser.parseFromString(xml, 'text/xml');
915
+ const rutEmisor = doc.getElementsByTagName('RutEmisor')[0]?.textContent;
916
+ const rutEnvia = doc.getElementsByTagName('RutEnvia')[0]?.textContent;
917
+
918
+ if (!rutEmisor || !rutEnvia) {
919
+ throw new Error('No se pudo extraer RutEmisor o RutEnvia del XML');
920
+ }
921
+
922
+ const url = this.urls[this.ambiente].rcof;
923
+
924
+ log.log('📤 Enviando EnvioBOLETA via SOAP/DTEUpload...');
925
+ log.log(' URL:', url);
926
+ log.log(' XML Length:', xml.length, 'bytes');
927
+
928
+ const [rutNum, dv] = rutEmisor.split('-');
929
+ const [rutEnviaNum, dvEnvia] = rutEnvia.split('-');
930
+
931
+ const boundary = '----WebKitFormBoundary' + Math.random().toString(36).substring(2);
932
+
933
+ let body = '';
934
+ body += `--${boundary}\r\n`;
935
+ body += `Content-Disposition: form-data; name="rutSender"\r\n\r\n`;
936
+ body += `${parseInt(rutEnviaNum.replace(/\./g, ''), 10)}\r\n`;
937
+
938
+ body += `--${boundary}\r\n`;
939
+ body += `Content-Disposition: form-data; name="dvSender"\r\n\r\n`;
940
+ body += `${dvEnvia.toUpperCase()}\r\n`;
941
+
942
+ body += `--${boundary}\r\n`;
943
+ body += `Content-Disposition: form-data; name="rutCompany"\r\n\r\n`;
944
+ body += `${parseInt(rutNum.replace(/\./g, ''), 10)}\r\n`;
945
+
946
+ body += `--${boundary}\r\n`;
947
+ body += `Content-Disposition: form-data; name="dvCompany"\r\n\r\n`;
948
+ body += `${dv.toUpperCase()}\r\n`;
949
+
950
+ body += `--${boundary}\r\n`;
951
+ body += `Content-Disposition: form-data; name="archivo"; filename="EnvioBOLETA.xml"\r\n`;
952
+ body += `Content-Type: text/xml\r\n\r\n`;
953
+ body += xml;
954
+ body += `\r\n--${boundary}--\r\n`;
955
+
956
+ return await this._enviarMultipart(url, body, boundary, xml, 'EnvioBOLETA');
957
+ }
958
+
959
+ /**
960
+ * Enviar EnvioDTE al SII via SOAP/DTEUpload
961
+ */
962
+ async enviarDteSoap(envioDte) {
963
+ const { DOMParser } = require('@xmldom/xmldom');
964
+
965
+ if (!this.tokenSoap) {
966
+ log.log('📡 Obteniendo token SOAP para DTEUpload...');
967
+ await this.getTokenSoap();
968
+ }
969
+
970
+ const xml = envioDte.getXML();
971
+ if (!xml) {
972
+ throw new Error('El EnvioDTE no tiene XML generado. Llame a generar() primero.');
973
+ }
974
+
975
+ const parser = new DOMParser();
976
+ const doc = parser.parseFromString(xml, 'text/xml');
977
+ const rutEmisor = doc.getElementsByTagName('RutEmisor')[0]?.textContent;
978
+ const rutEnvia = doc.getElementsByTagName('RutEnvia')[0]?.textContent;
979
+
980
+ if (!rutEmisor || !rutEnvia) {
981
+ throw new Error('No se pudo extraer RutEmisor o RutEnvia del XML');
982
+ }
983
+
984
+ const url = this.urls[this.ambiente].rcof;
985
+
986
+ const xmlBuffer = Buffer.from(xml, 'latin1');
987
+
988
+ log.log('📤 Enviando EnvioDTE via SOAP/DTEUpload...');
989
+ log.log(' URL:', url);
990
+ log.log(' XML Length:', xmlBuffer.length, 'bytes');
991
+
992
+ const [rutNum, dv] = rutEmisor.split('-');
993
+ const [rutEnviaNum, dvEnvia] = rutEnvia.split('-');
994
+
995
+ const boundary = '----WebKitFormBoundary' + Math.random().toString(36).substring(2);
996
+
997
+ const bodyParts = [];
998
+ bodyParts.push(Buffer.from(`--${boundary}\r\n`, 'utf8'));
999
+ bodyParts.push(Buffer.from('Content-Disposition: form-data; name="rutSender"\r\n\r\n', 'utf8'));
1000
+ bodyParts.push(Buffer.from(`${parseInt(rutEnviaNum.replace(/\./g, ''), 10)}\r\n`, 'utf8'));
1001
+
1002
+ bodyParts.push(Buffer.from(`--${boundary}\r\n`, 'utf8'));
1003
+ bodyParts.push(Buffer.from('Content-Disposition: form-data; name="dvSender"\r\n\r\n', 'utf8'));
1004
+ bodyParts.push(Buffer.from(`${dvEnvia.toUpperCase()}\r\n`, 'utf8'));
1005
+
1006
+ bodyParts.push(Buffer.from(`--${boundary}\r\n`, 'utf8'));
1007
+ bodyParts.push(Buffer.from('Content-Disposition: form-data; name="rutCompany"\r\n\r\n', 'utf8'));
1008
+ bodyParts.push(Buffer.from(`${parseInt(rutNum.replace(/\./g, ''), 10)}\r\n`, 'utf8'));
1009
+
1010
+ bodyParts.push(Buffer.from(`--${boundary}\r\n`, 'utf8'));
1011
+ bodyParts.push(Buffer.from('Content-Disposition: form-data; name="dvCompany"\r\n\r\n', 'utf8'));
1012
+ bodyParts.push(Buffer.from(`${dv.toUpperCase()}\r\n`, 'utf8'));
1013
+
1014
+ bodyParts.push(Buffer.from(`--${boundary}\r\n`, 'utf8'));
1015
+ bodyParts.push(Buffer.from('Content-Disposition: form-data; name="archivo"; filename="EnvioDTE.xml"\r\n', 'utf8'));
1016
+ bodyParts.push(Buffer.from('Content-Type: text/xml\r\n\r\n', 'utf8'));
1017
+ bodyParts.push(xmlBuffer);
1018
+ bodyParts.push(Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8'));
1019
+
1020
+ const body = Buffer.concat(bodyParts);
1021
+
1022
+ return await this._enviarMultipartBuffer(url, body, boundary, xml, 'EnvioDTE');
1023
+ }
1024
+
1025
+ /**
1026
+ * Enviar Consumo de Folios (RCOF/RVD) al SII via SOAP/CGI
1027
+ */
1028
+ async enviarConsumoFolios(consumoFolio) {
1029
+ const { DOMParser } = require('@xmldom/xmldom');
1030
+
1031
+ if (!this.tokenSoap) {
1032
+ log.log('📡 Obteniendo token SOAP para DTEUpload...');
1033
+ await this.getTokenSoap();
1034
+ }
1035
+
1036
+ const xml = consumoFolio.getXML();
1037
+ if (!xml) {
1038
+ throw new Error('El ConsumoFolio no tiene XML generado. Llame a generar() primero.');
1039
+ }
1040
+
1041
+ const parser = new DOMParser();
1042
+ const doc = parser.parseFromString(xml, 'text/xml');
1043
+ const rutEmisor = doc.getElementsByTagName('RutEmisor')[0]?.textContent;
1044
+
1045
+ if (!rutEmisor) {
1046
+ throw new Error('No se pudo extraer RutEmisor del XML');
1047
+ }
1048
+
1049
+ const url = this.urls[this.ambiente].rcof;
1050
+
1051
+ log.log('Enviando RCOF a:', url);
1052
+ log.log('XML Length:', xml.length, 'bytes');
1053
+
1054
+ const [rutNum, dv] = rutEmisor.split('-');
1055
+ const [rutEnviaNum, dvEnvia] = this.certificado.rut.split('-');
1056
+
1057
+ const boundary = '----WebKitFormBoundary' + Math.random().toString(36).substring(2);
1058
+
1059
+ let body = '';
1060
+ body += `--${boundary}\r\n`;
1061
+ body += `Content-Disposition: form-data; name="rutSender"\r\n\r\n`;
1062
+ body += `${parseInt(rutEnviaNum.replace(/\./g, ''), 10)}\r\n`;
1063
+
1064
+ body += `--${boundary}\r\n`;
1065
+ body += `Content-Disposition: form-data; name="dvSender"\r\n\r\n`;
1066
+ body += `${dvEnvia.toUpperCase()}\r\n`;
1067
+
1068
+ body += `--${boundary}\r\n`;
1069
+ body += `Content-Disposition: form-data; name="rutCompany"\r\n\r\n`;
1070
+ body += `${parseInt(rutNum.replace(/\./g, ''), 10)}\r\n`;
1071
+
1072
+ body += `--${boundary}\r\n`;
1073
+ body += `Content-Disposition: form-data; name="dvCompany"\r\n\r\n`;
1074
+ body += `${dv.toUpperCase()}\r\n`;
1075
+
1076
+ body += `--${boundary}\r\n`;
1077
+ body += `Content-Disposition: form-data; name="archivo"; filename="ConsumoFolios.xml"\r\n`;
1078
+ body += `Content-Type: text/xml\r\n\r\n`;
1079
+ body += xml;
1080
+ body += `\r\n--${boundary}--\r\n`;
1081
+
1082
+ return await this._enviarMultipart(url, body, boundary, xml, 'RCOF');
1083
+ }
1084
+
1085
+ /**
1086
+ * Enviar Libro (Compra/Venta o Guías) al SII via SOAP/CGI
1087
+ */
1088
+ async enviarLibro(libro, nombreArchivo = 'LibroCV.xml') {
1089
+ const { DOMParser } = require('@xmldom/xmldom');
1090
+
1091
+ if (!this.tokenSoap) {
1092
+ log.log('📡 Obteniendo token SOAP para DTEUpload...');
1093
+ await this.getTokenSoap();
1094
+ }
1095
+
1096
+ const xml = libro.getXML();
1097
+ if (!xml) {
1098
+ throw new Error('El Libro no tiene XML generado. Llame a generar() primero.');
1099
+ }
1100
+
1101
+ const parser = new DOMParser();
1102
+ const doc = parser.parseFromString(xml, 'text/xml');
1103
+ const rutEmisor = doc.getElementsByTagName('RutEmisorLibro')[0]?.textContent
1104
+ || doc.getElementsByTagName('RutEmisor')[0]?.textContent;
1105
+ const rutEnvia = doc.getElementsByTagName('RutEnvia')[0]?.textContent;
1106
+
1107
+ if (!rutEmisor || !rutEnvia) {
1108
+ throw new Error('No se pudo extraer RutEmisorLibro o RutEnvia del XML');
1109
+ }
1110
+
1111
+ const url = this.urls[this.ambiente].rcof;
1112
+
1113
+ log.log('📤 Enviando Libro via SOAP/DTEUpload...');
1114
+ log.log(' URL:', url);
1115
+ log.log(' XML Length:', xml.length, 'bytes');
1116
+
1117
+ const [rutNum, dv] = rutEmisor.split('-');
1118
+ const [rutEnviaNum, dvEnvia] = rutEnvia.split('-');
1119
+
1120
+ const boundary = '----WebKitFormBoundary' + Math.random().toString(36).substring(2);
1121
+
1122
+ let body = '';
1123
+ body += `--${boundary}\r\n`;
1124
+ body += 'Content-Disposition: form-data; name="rutSender"\r\n\r\n';
1125
+ body += `${parseInt(rutEnviaNum.replace(/\./g, ''), 10)}\r\n`;
1126
+
1127
+ body += `--${boundary}\r\n`;
1128
+ body += 'Content-Disposition: form-data; name="dvSender"\r\n\r\n';
1129
+ body += `${dvEnvia.toUpperCase()}\r\n`;
1130
+
1131
+ body += `--${boundary}\r\n`;
1132
+ body += 'Content-Disposition: form-data; name="rutCompany"\r\n\r\n';
1133
+ body += `${parseInt(rutNum.replace(/\./g, ''), 10)}\r\n`;
1134
+
1135
+ body += `--${boundary}\r\n`;
1136
+ body += 'Content-Disposition: form-data; name="dvCompany"\r\n\r\n';
1137
+ body += `${dv.toUpperCase()}\r\n`;
1138
+
1139
+ body += `--${boundary}\r\n`;
1140
+ const fileNameLibro = nombreArchivo || 'LibroCV.xml';
1141
+ body += `Content-Disposition: form-data; name="archivo"; filename="${fileNameLibro}"\r\n`;
1142
+ body += 'Content-Type: text/xml\r\n\r\n';
1143
+ body += xml;
1144
+ body += `\r\n--${boundary}--\r\n`;
1145
+
1146
+ return await this._enviarMultipart(url, body, boundary, xml, 'Libro');
1147
+ }
1148
+
1149
+ // ============================================
1150
+ // HELPERS DE ENVÍO
1151
+ // ============================================
1152
+
1153
+ /**
1154
+ * Enviar multipart con reintentos (unificado para string y buffer)
1155
+ * Usa isRetryableError centralizado de utils
1156
+ *
1157
+ * @param {string} url - URL de envío
1158
+ * @param {string|Buffer} body - Body del request
1159
+ * @param {string} boundary - Boundary del multipart
1160
+ * @param {string} xml - XML original para debug
1161
+ * @param {string} tipoEnvio - Tipo de envío para logs
1162
+ * @returns {Object} Resultado del envío
1163
+ */
1164
+ async _enviarMultipart(url, body, boundary, xml, tipoEnvio) {
1165
+ const retryConfig = getConfigSection('retry');
1166
+ const maxRetries = retryConfig?.maxRetries || 8;
1167
+ const initialDelay = retryConfig?.initialDelay || 2000;
1168
+ const backoffMultiplier = retryConfig?.backoffMultiplier || 1.8;
1169
+ let lastError = null;
1170
+
1171
+ // Calcular Content-Length según tipo de body
1172
+ const contentLength = Buffer.isBuffer(body)
1173
+ ? body.length.toString()
1174
+ : Buffer.byteLength(body).toString();
1175
+
1176
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
1177
+ const controller = new AbortController();
1178
+ const timeout = setTimeout(() => controller.abort(), 60000);
1179
+
1180
+ try {
1181
+ if (attempt > 1) {
1182
+ const delay = Math.min(initialDelay * Math.pow(backoffMultiplier, attempt - 2), 15000);
1183
+ log.log(` 🔄 Reintento ${tipoEnvio} ${attempt}/${maxRetries} (delay: ${Math.round(delay/1000)}s)...`);
1184
+ await new Promise(resolve => setTimeout(resolve, delay));
1185
+ }
1186
+
1187
+ const response = await fetch(url, {
1188
+ method: 'POST',
1189
+ headers: {
1190
+ 'Cookie': `TOKEN=${this.tokenSoap}`,
1191
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
1192
+ 'User-Agent': 'Mozilla/4.0 ( compatible; PROG 1.0; Windows NT)',
1193
+ 'Accept': '*/*',
1194
+ 'Connection': 'keep-alive',
1195
+ 'Content-Length': contentLength,
1196
+ },
1197
+ body: body,
1198
+ signal: controller.signal,
1199
+ });
1200
+ clearTimeout(timeout);
1201
+
1202
+ const responseText = await response.text();
1203
+ log.log(` Respuesta ${tipoEnvio}:`, responseText);
1204
+
1205
+ const result = this._parsearRespuestaSoap(responseText, response.ok, response.status, tipoEnvio);
1206
+
1207
+ saveEnvioArtifacts({
1208
+ xml,
1209
+ responseText,
1210
+ responseOk: response.ok,
1211
+ responseStatus: response.status,
1212
+ trackId: result.trackId,
1213
+ ambiente: this.ambiente,
1214
+ tipoEnvio,
1215
+ });
1216
+
1217
+ return result;
1218
+ } catch (fetchError) {
1219
+ clearTimeout(timeout);
1220
+ lastError = fetchError;
1221
+
1222
+ // Usar isRetryableError centralizado de utils
1223
+ if (isRetryableError(fetchError) && attempt < maxRetries) {
1224
+ log.log(` ⚠️ Error de conexión ${tipoEnvio} (${fetchError.cause?.code || 'socket'}), reintentando...`);
1225
+ continue;
1226
+ }
1227
+
1228
+ saveEnvioArtifacts({
1229
+ xml,
1230
+ responseText: `ERROR_FETCH: ${fetchError.message}`,
1231
+ responseOk: false,
1232
+ responseStatus: null,
1233
+ trackId: null,
1234
+ ambiente: this.ambiente,
1235
+ tipoEnvio,
1236
+ error: fetchError.cause || fetchError,
1237
+ });
1238
+
1239
+ log.error('Fetch error details:', fetchError.cause || fetchError);
1240
+ throw fetchError;
1241
+ }
1242
+ }
1243
+
1244
+ throw lastError || new Error(`${tipoEnvio} falló después de múltiples reintentos`);
1245
+ }
1246
+
1247
+ /**
1248
+ * Alias para compatibilidad con código existente que usa _enviarMultipartBuffer
1249
+ * @deprecated Usar _enviarMultipart directamente
1250
+ */
1251
+ async _enviarMultipartBuffer(url, body, boundary, xml, tipoEnvio) {
1252
+ return this._enviarMultipart(url, body, boundary, xml, tipoEnvio);
1253
+ }
1254
+
1255
+ /**
1256
+ * Parsear respuesta SOAP/CGI
1257
+ */
1258
+ _parsearRespuestaSoap(responseText, responseOk, httpStatus, tipoEnvio) {
1259
+ if (!responseOk) {
1260
+ return {
1261
+ ok: false,
1262
+ error: `Error HTTP ${httpStatus}`,
1263
+ respuesta: responseText,
1264
+ };
1265
+ }
1266
+
1267
+ const statusMatch = responseText.match(/<STATUS>(\d+)<\/STATUS>/i);
1268
+ const trackIdMatch = responseText.match(/<TRACKID>(\d+)<\/TRACKID>/i);
1269
+ const fileMatch = responseText.match(/<FILE>([^<]*)<\/FILE>/i);
1270
+
1271
+ const status = statusMatch ? parseInt(statusMatch[1], 10) : null;
1272
+ const trackId = trackIdMatch ? trackIdMatch[1] : null;
1273
+ const fileName = fileMatch ? fileMatch[1] : null;
1274
+
1275
+ if (status === 0 && trackId) {
1276
+ return {
1277
+ ok: true,
1278
+ status: status,
1279
+ trackId: trackId,
1280
+ archivo: fileName,
1281
+ mensaje: `✅ ${tipoEnvio} Enviado - TrackID: ${trackId}`,
1282
+ respuesta: responseText,
1283
+ };
1284
+ }
1285
+
1286
+ const errorMessages = {
1287
+ 1: 'Error de autenticación',
1288
+ 2: 'Error en RUT',
1289
+ 3: 'Error en XML',
1290
+ 4: 'Error de firma',
1291
+ 5: 'Error de sistema',
1292
+ 99: 'Error desconocido',
1293
+ };
1294
+
1295
+ return {
1296
+ ok: false,
1297
+ status: status,
1298
+ error: errorMessages[status] || `Error del SII - Status: ${status}`,
1299
+ respuesta: responseText,
1300
+ };
1301
+ }
1302
+ }
1303
+
1304
+ module.exports = EnviadorSII;