@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/SiiSession.js ADDED
@@ -0,0 +1,499 @@
1
+ // Copyright (c) 2026 Devlas SpA — https://devlas.cl
2
+ // Licencia MIT. Ver archivo LICENSE para mas detalles.
3
+ /**
4
+ * SiiSession.js - Manejo de sesiones HTTP con el SII
5
+ *
6
+ * Proporciona autenticación con certificado digital y manejo de cookies
7
+ * para interactuar con los servicios web del SII.
8
+ *
9
+ * @module SiiSession
10
+ */
11
+
12
+ const got = require('got');
13
+ const {
14
+ loadPfxFromBuffer,
15
+ loadPfxFromFile,
16
+ createTlsOptions,
17
+ validateAmbiente,
18
+ getHost,
19
+ createScopedLogger,
20
+ } = require('./utils');
21
+
22
+ const log = createScopedLogger('SiiSession');
23
+
24
+ /**
25
+ * Clase para manejar sesiones HTTP con el SII
26
+ */
27
+ class SiiSession {
28
+ /**
29
+ * @param {Object} options - Opciones de configuración
30
+ * @param {string} options.ambiente - 'certificacion' o 'produccion' (OBLIGATORIO)
31
+ * @param {Buffer|string} [options.pfxBuffer] - Buffer del archivo PFX
32
+ * @param {string} [options.pfxPath] - Ruta al archivo PFX
33
+ * @param {string} [options.pfxPassword] - Contraseña del PFX
34
+ * @param {Object} [options.certificado] - Instancia de Certificado
35
+ */
36
+ constructor(options = {}) {
37
+ // Validar parámetros obligatorios usando validador centralizado
38
+ if (!options.ambiente) {
39
+ throw new Error('SiiSession: options.ambiente es obligatorio');
40
+ }
41
+ this.ambiente = validateAmbiente(options.ambiente);
42
+
43
+ // Usar host centralizado desde endpoints
44
+ this.baseHost = getHost(this.ambiente);
45
+ this.cookieJar = '';
46
+ this.tlsOptions = null;
47
+
48
+ // Configurar TLS desde certificado
49
+ if (options.certificado) {
50
+ this._configureTlsFromCertificado(options.certificado);
51
+ } else if (options.pfxBuffer && options.pfxPassword) {
52
+ this._configureTlsFromBuffer(options.pfxBuffer, options.pfxPassword);
53
+ } else if (options.pfxPath && options.pfxPassword) {
54
+ this._configureTlsFromFile(options.pfxPath, options.pfxPassword);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Configura TLS desde una instancia de Certificado
60
+ * @private
61
+ */
62
+ _configureTlsFromCertificado(certificado) {
63
+ try {
64
+ this.tlsOptions = {
65
+ key: certificado.getPrivateKeyPEM(),
66
+ cert: certificado.getCertificatePEM(),
67
+ certificate: certificado.getCertificatePEM(),
68
+ rejectUnauthorized: false,
69
+ };
70
+ } catch (error) {
71
+ log.error('Error configurando TLS desde certificado:', error.message);
72
+ this.tlsOptions = null;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Configura TLS desde un buffer PFX usando utilidad centralizada
78
+ * @private
79
+ */
80
+ _configureTlsFromBuffer(pfxBuffer, password) {
81
+ try {
82
+ const pfxData = loadPfxFromBuffer(pfxBuffer, password);
83
+ this.tlsOptions = createTlsOptions(pfxData);
84
+ } catch (error) {
85
+ log.error('Error configurando TLS desde PFX:', error.message);
86
+ this.tlsOptions = null;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Configura TLS desde un archivo PFX usando utilidad centralizada
92
+ * @private
93
+ */
94
+ _configureTlsFromFile(pfxPath, password) {
95
+ try {
96
+ const pfxData = loadPfxFromFile(pfxPath, password);
97
+ this.tlsOptions = createTlsOptions(pfxData);
98
+ } catch (error) {
99
+ log.error('Error configurando TLS desde archivo PFX:', error.message);
100
+ this.tlsOptions = null;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Parsea un RUT en sus componentes
106
+ * @param {string} rutCompleto - RUT con formato XX.XXX.XXX-X o XXXXXXXX-X
107
+ * @returns {{rut: string, dv: string}}
108
+ */
109
+ static parseRut(rutCompleto) {
110
+ const clean = String(rutCompleto || '').replace(/\./g, '').toUpperCase();
111
+ const [rut, dv] = clean.split('-');
112
+ return { rut, dv };
113
+ }
114
+
115
+ /**
116
+ * Codifica campos para form-urlencoded
117
+ * @param {Object} fields - Campos a codificar
118
+ * @returns {string}
119
+ */
120
+ static formEncode(fields) {
121
+ return Object.entries(fields)
122
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v ?? '')}`)
123
+ .join('&');
124
+ }
125
+
126
+ /**
127
+ * Combina cookies existentes con nuevas del header Set-Cookie
128
+ * @private
129
+ */
130
+ _mergeCookies(current, setCookieHeader) {
131
+ const jar = new Map();
132
+ const addCookie = (cookieStr) => {
133
+ const [pair] = cookieStr.split(';');
134
+ const [name, value] = pair.split('=');
135
+ if (name) jar.set(name.trim(), (value || '').trim());
136
+ };
137
+
138
+ if (current) {
139
+ current.split(';').forEach((c) => addCookie(c));
140
+ }
141
+
142
+ if (Array.isArray(setCookieHeader)) {
143
+ setCookieHeader.forEach((c) => addCookie(c));
144
+ } else if (setCookieHeader) {
145
+ addCookie(setCookieHeader);
146
+ }
147
+
148
+ return Array.from(jar.entries())
149
+ .map(([k, v]) => `${k}=${v}`)
150
+ .join('; ');
151
+ }
152
+
153
+ /**
154
+ * Realiza una petición HTTP
155
+ * @param {string} url - URL de destino
156
+ * @param {Object} options - Opciones de la petición
157
+ * @returns {Promise<Object>}
158
+ */
159
+ async request(url, options = {}) {
160
+ const res = await got(url, {
161
+ method: options.method || 'GET',
162
+ headers: {
163
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
164
+ ...(this.cookieJar ? { Cookie: this.cookieJar } : {}),
165
+ ...(options.headers || {}),
166
+ },
167
+ body: options.body,
168
+ followRedirect: false,
169
+ throwHttpErrors: false,
170
+ https: this.tlsOptions || { rejectUnauthorized: false },
171
+ responseType: 'buffer', // Obtener como buffer para manejar encoding
172
+ });
173
+
174
+ this.cookieJar = this._mergeCookies(this.cookieJar, res.headers['set-cookie']);
175
+
176
+ // Detectar encoding del Content-Type y convertir correctamente
177
+ let bodyStr;
178
+ const contentType = res.headers['content-type'] || '';
179
+ const buffer = res.body;
180
+
181
+ // El SII de Chile usa ISO-8859-1 para TODO su contenido (HTML, XML, text/plain, octet-stream, etc.)
182
+ // Forzar ISO-8859-1 para cualquier respuesta de sii.cl que no especifique UTF-8
183
+ const isSiiUrl = url.includes('sii.cl');
184
+ const hasUtf8 = contentType.toLowerCase().includes('utf-8');
185
+ const forceIso = contentType.toLowerCase().includes('iso-8859-1') ||
186
+ contentType.toLowerCase().includes('latin1');
187
+
188
+ // Aplicar ISO-8859-1 si:
189
+ // 1. El Content-Type especifica ISO-8859-1/latin1, O
190
+ // 2. Es una URL del SII y NO especifica UTF-8 (incluyendo octet-stream, text/*, xml, etc.)
191
+ if (forceIso || (isSiiUrl && !hasUtf8)) {
192
+ // SII usa ISO-8859-1, convertir cada byte a su codepoint Unicode correspondiente
193
+ // ISO-8859-1 es un subconjunto directo de Unicode (codepoints 0-255)
194
+ bodyStr = '';
195
+ for (let i = 0; i < buffer.length; i++) {
196
+ bodyStr += String.fromCharCode(buffer[i]);
197
+ }
198
+ } else {
199
+ bodyStr = buffer.toString('utf8');
200
+ }
201
+
202
+ return {
203
+ status: res.statusCode,
204
+ headers: res.headers,
205
+ body: bodyStr,
206
+ rawBody: res.body, // Buffer original por si se necesita
207
+ url: res.url,
208
+ cookieJar: this.cookieJar,
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Sigue redirecciones HTTP
214
+ * @param {Object} initial - Respuesta inicial
215
+ * @param {number} maxRedirects - Máximo de redirecciones
216
+ * @returns {Promise<Object>}
217
+ */
218
+ async followRedirects(initial, maxRedirects = 8) {
219
+ let response = initial;
220
+ let redirects = 0;
221
+ let lastLocationUrl = null;
222
+
223
+ while ([301, 302, 303, 307, 308].includes(response.status) && response.headers.location && redirects < maxRedirects) {
224
+ const nextUrl = new URL(response.headers.location, response.url).toString();
225
+ lastLocationUrl = nextUrl;
226
+ response = await this.request(nextUrl, { method: 'GET' });
227
+ redirects += 1;
228
+ }
229
+
230
+ return { response, lastLocationUrl };
231
+ }
232
+
233
+ /**
234
+ * Realiza login con certificado digital
235
+ * @param {string} lastLocationUrl - URL de redirección del login
236
+ * @returns {Promise<Object>}
237
+ */
238
+ async loginWithCertificate(lastLocationUrl) {
239
+ if (!lastLocationUrl || !this.tlsOptions) {
240
+ return { success: false, response: null };
241
+ }
242
+
243
+ const locationUrl = new URL(lastLocationUrl);
244
+ const referencia = locationUrl.search ? decodeURIComponent(locationUrl.search.slice(1)) : '';
245
+
246
+ if (!referencia) {
247
+ return { success: false, response: null };
248
+ }
249
+
250
+ const loginUrl = `https://herculesr.sii.cl/cgi_AUT2000/CAutInicio.cgi?${referencia}`;
251
+ const loginBody = SiiSession.formEncode({ referencia });
252
+
253
+ const loginResponse = await this.request(loginUrl, {
254
+ method: 'POST',
255
+ headers: {
256
+ 'Content-Type': 'application/x-www-form-urlencoded',
257
+ 'Content-Length': Buffer.byteLength(loginBody),
258
+ Referer: loginUrl,
259
+ },
260
+ body: loginBody,
261
+ });
262
+
263
+ const redirected = await this.followRedirects(loginResponse);
264
+ return { success: true, response: redirected.response };
265
+ }
266
+
267
+ /**
268
+ * Asegura una sesión autenticada para acceder a una página
269
+ * @param {string} targetPath - Ruta del recurso
270
+ * @returns {Promise<Object>}
271
+ */
272
+ async ensureSession(targetPath) {
273
+ const targetUrl = `https://${this.baseHost}${targetPath}`;
274
+ let response = await this.request(targetUrl, { method: 'GET' });
275
+
276
+ const redirected = await this.followRedirects(response);
277
+ response = redirected.response;
278
+
279
+ // Detectar bloqueo por demasiadas sesiones
280
+ if (response.body && response.body.includes('superado el m')) {
281
+ const errorMsg = 'SII: Demasiadas sesiones abiertas. Espera ~30 min o cierra sesión manualmente en el portal SII.';
282
+ console.error(`\n❌ ${errorMsg}\n`);
283
+ throw new Error(errorMsg);
284
+ }
285
+
286
+ // Si requiere autenticación
287
+ if (response.body && response.body.includes('Autenticaci')) {
288
+ const certLogin = await this.loginWithCertificate(redirected.lastLocationUrl);
289
+ if (certLogin.success && certLogin.response) {
290
+ response = certLogin.response;
291
+
292
+ // Verificar si el login resultó en bloqueo por sesiones
293
+ if (response.body && response.body.includes('superado el m')) {
294
+ const errorMsg = 'SII: Demasiadas sesiones abiertas. Espera ~30 min o cierra sesión manualmente en el portal SII.';
295
+ console.error(`\n❌ ${errorMsg}\n`);
296
+ throw new Error(errorMsg);
297
+ }
298
+ }
299
+ }
300
+
301
+ // Si hay redirección a af_anular1
302
+ if (response.body && response.body.includes('/cvc_cgi/dte/af_anular1')) {
303
+ const continued = await this.request(targetUrl, { method: 'GET' });
304
+ const continuedResult = await this.followRedirects(continued);
305
+ return continuedResult.response;
306
+ }
307
+
308
+ return response;
309
+ }
310
+
311
+ /**
312
+ * Envía un formulario HTTP
313
+ * @param {string} action - URL o path de acción
314
+ * @param {Object} fields - Campos del formulario
315
+ * @param {string} [referer] - URL de referencia
316
+ * @returns {Promise<Object>}
317
+ */
318
+ async submitForm(action, fields, referer = null) {
319
+ const url = new URL(action, `https://${this.baseHost}`).toString();
320
+ const body = SiiSession.formEncode(fields);
321
+
322
+ return this.request(url, {
323
+ method: 'POST',
324
+ headers: {
325
+ 'Content-Type': 'application/x-www-form-urlencoded',
326
+ 'Content-Length': Buffer.byteLength(body),
327
+ ...(referer ? { Referer: referer } : {}),
328
+ },
329
+ body,
330
+ });
331
+ }
332
+
333
+ /**
334
+ * Resetea la sesión
335
+ */
336
+ reset() {
337
+ this.cookieJar = '';
338
+ }
339
+
340
+ /**
341
+ * Retorna el host base según ambiente
342
+ * @returns {string}
343
+ */
344
+ getBaseHost() {
345
+ return this.baseHost;
346
+ }
347
+
348
+ /**
349
+ * Guarda la sesión actual en un archivo JSON
350
+ * @param {string} filePath - Ruta del archivo donde guardar
351
+ */
352
+ saveSession(filePath) {
353
+ const fs = require('fs');
354
+ const sessionData = {
355
+ cookieJar: this.cookieJar,
356
+ baseHost: this.baseHost,
357
+ savedAt: Date.now(),
358
+ expiresAt: Date.now() + (25 * 60 * 1000), // 25 minutos de validez
359
+ };
360
+ fs.writeFileSync(filePath, JSON.stringify(sessionData, null, 2), 'utf8');
361
+ }
362
+
363
+ /**
364
+ * Carga una sesión desde un archivo JSON
365
+ * @param {string} filePath - Ruta del archivo de sesión
366
+ * @returns {boolean} - true si la sesión fue cargada exitosamente y es válida
367
+ */
368
+ loadSession(filePath) {
369
+ const fs = require('fs');
370
+ try {
371
+ if (!fs.existsSync(filePath)) {
372
+ return false;
373
+ }
374
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
375
+
376
+ // Verificar que la sesión no haya expirado
377
+ if (data.expiresAt && Date.now() > data.expiresAt) {
378
+ console.log('Sesión SII expirada, se requiere nuevo login');
379
+ return false;
380
+ }
381
+
382
+ // Verificar que el host coincida
383
+ if (data.baseHost && data.baseHost !== this.baseHost) {
384
+ console.log('Host SII no coincide, se requiere nuevo login');
385
+ return false;
386
+ }
387
+
388
+ this.cookieJar = data.cookieJar || '';
389
+ console.log('Sesión SII cargada desde archivo');
390
+ return true;
391
+ } catch (err) {
392
+ console.log('Error cargando sesión SII:', err.message);
393
+ return false;
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Verifica si existe una sesión guardada y es válida
399
+ * @param {string} filePath - Ruta del archivo de sesión
400
+ * @returns {boolean}
401
+ */
402
+ static isSessionValid(filePath) {
403
+ const fs = require('fs');
404
+ try {
405
+ if (!fs.existsSync(filePath)) {
406
+ return false;
407
+ }
408
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
409
+ return data.expiresAt && Date.now() < data.expiresAt;
410
+ } catch {
411
+ return false;
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Elimina el archivo de sesión
417
+ * @param {string} filePath - Ruta del archivo de sesión
418
+ */
419
+ static clearSession(filePath) {
420
+ const fs = require('fs');
421
+ try {
422
+ if (fs.existsSync(filePath)) {
423
+ fs.unlinkSync(filePath);
424
+ }
425
+ } catch {
426
+ // Ignorar errores
427
+ }
428
+ }
429
+ }
430
+
431
+ // Utilidades de parsing HTML
432
+ SiiSession.extractFormAction = function(html) {
433
+ const match = html.match(/<form[^>]*action="([^"]+)"/i);
434
+ return match ? match[1] : null;
435
+ };
436
+
437
+ SiiSession.extractFormActionByName = function(html, formName) {
438
+ if (!formName) return SiiSession.extractFormAction(html);
439
+ const regex = new RegExp(`<form[^>]*name="${formName}"[^>]*action="([^"]+)"`, 'i');
440
+ const match = String(html || '').match(regex);
441
+ return match ? match[1] : null;
442
+ };
443
+
444
+ SiiSession.extractInputValues = function(html) {
445
+ const inputs = {};
446
+ const regex = /<input[^>]+>/gi;
447
+ const matches = html.match(regex) || [];
448
+ matches.forEach((tag) => {
449
+ const nameMatch = tag.match(/name\s*=\s*"([^"]+)"/i);
450
+ const valueMatch = tag.match(/value\s*=\s*"([^"]*)"/i);
451
+ if (nameMatch) {
452
+ inputs[nameMatch[1]] = valueMatch ? valueMatch[1] : '';
453
+ }
454
+ });
455
+ return inputs;
456
+ };
457
+
458
+ SiiSession.extractFormInputsByName = function(html, formName = null) {
459
+ const formRegex = formName
460
+ ? new RegExp(`<form[^>]*name="${formName}"[^>]*>[\\s\\S]*?<\\/form>`, 'i')
461
+ : /<form[^>]*>[\s\S]*?<\/form>/i;
462
+ const match = String(html || '').match(formRegex);
463
+ if (!match) return {};
464
+ return SiiSession.extractInputValues(match[0]);
465
+ };
466
+
467
+ SiiSession.extractInputTags = function(html) {
468
+ const tags = [];
469
+ const regex = /<input[^>]+>/gi;
470
+ const matches = html.match(regex) || [];
471
+ matches.forEach((tag) => {
472
+ const nameMatch = tag.match(/name\s*=\s*"([^"]+)"/i);
473
+ const valueMatch = tag.match(/value\s*=\s*"([^"]*)"/i);
474
+ const typeMatch = tag.match(/type\s*=\s*"([^"]+)"/i);
475
+ const onClickMatch = tag.match(/onClick\s*=\s*"([^"]+)"/i);
476
+ tags.push({
477
+ name: nameMatch ? nameMatch[1] : null,
478
+ value: valueMatch ? valueMatch[1] : null,
479
+ type: typeMatch ? typeMatch[1] : null,
480
+ onClick: onClickMatch ? onClickMatch[1] : null,
481
+ });
482
+ });
483
+ return tags;
484
+ };
485
+
486
+ SiiSession.stripHtml = function(value) {
487
+ return String(value || '')
488
+ .replace(/<[^>]+>/g, ' ')
489
+ .replace(/&nbsp;/gi, ' ')
490
+ .replace(/\s+/g, ' ')
491
+ .trim();
492
+ };
493
+
494
+ SiiSession.parseIntFromText = function(value) {
495
+ const match = String(value || '').match(/(\d{1,})/);
496
+ return match ? Number(match[1]) : null;
497
+ };
498
+
499
+ module.exports = SiiSession;