@devlas/dte-sii 2.12.8 → 2.12.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CafSolicitor.js CHANGED
@@ -209,7 +209,6 @@ class CafSolicitor {
209
209
  console.log(` RUT: ${this.rutEmisor} | Ambiente: ${this.ambiente}`);
210
210
 
211
211
  try {
212
- // Paso 1: POST inicial a of_solicita_folios
213
212
  const fields = {
214
213
  RUT_EMP: rut,
215
214
  DV_EMP: dv,
@@ -217,20 +216,28 @@ class CafSolicitor {
217
216
  CANTIDAD: cantidad,
218
217
  };
219
218
 
220
- let response = await this.session.submitForm('/cvc_cgi/dte/of_solicita_folios', fields);
219
+ // Paso 1: asegurar sesión con un GET real ANTES de cualquier POST.
220
+ // El SII liga la cookie de sesión al GET que la originó (Tivoli/F5); un POST
221
+ // que no fue precedido de un GET fresco al mismo path en la misma sesión es
222
+ // tratado como fuera de contexto y redirige a autInicioDTE aunque las cookies
223
+ // sean válidas. Replicamos el flujo real de un browser: GET formulario →
224
+ // parsear <form action> + hidden fields → submit con esos campos + los propios.
225
+ const authResponse = await this.session.ensureSession('/cvc_cgi/dte/of_solicita_folios');
226
+
227
+ let response;
228
+ if (this._requiresAuthentication(authResponse.body)) {
229
+ // Sesión sigue inválida tras ensureSession — el chequeo de más abajo
230
+ // detectará esto sobre el body final y retornará SESSION_EXPIRED.
231
+ response = authResponse;
232
+ } else {
233
+ const formAction = SiiSession.extractFormAction(authResponse.body) || '/cvc_cgi/dte/of_solicita_folios';
234
+ const hiddenFields = SiiSession.extractInputValues(authResponse.body);
235
+ response = await this.session.submitForm(formAction, { ...hiddenFields, ...fields });
236
+ }
221
237
 
222
- // Manejar autenticación si es necesaria (incluye 302 a autInicioDTE)
223
- if (this._requiresAuthentication(response.body)) {
224
- const authResult = await this.session.ensureSession('/cvc_cgi/dte/of_solicita_folios');
225
- if (authResult.body) {
226
- // Reintentar después de autenticación
227
- response = await this.session.submitForm('/cvc_cgi/dte/of_solicita_folios', fields);
228
- }
229
-
230
- // Guardar sesión para reutilización
231
- if (this.sessionPath) {
232
- this.session.saveSession(this.sessionPath);
233
- }
238
+ // Guardar sesión para reutilización
239
+ if (this.sessionPath) {
240
+ this.session.saveSession(this.sessionPath);
234
241
  }
235
242
 
236
243
  // Procesar flujo multi-paso del SII
package/SiiSession.js CHANGED
@@ -14,6 +14,8 @@ const {
14
14
  loadPfxFromBuffer,
15
15
  loadPfxFromFile,
16
16
  createTlsOptions,
17
+ createTlsAgent,
18
+ createTlsAgentFromPem,
17
19
  validateAmbiente,
18
20
  getHost,
19
21
  createScopedLogger,
@@ -44,6 +46,10 @@ class SiiSession {
44
46
  this.baseHost = getHost(this.ambiente);
45
47
  this.cookieJar = '';
46
48
  this.tlsOptions = null;
49
+ // Agent nativo con el certificado + flags TLS legacy del SII. got no reenvía
50
+ // secureOptions/maxVersion a través de su opción `https` bajo ningún nombre de
51
+ // clave, así que estos flags solo se pueden aplicar vía un https.Agent nativo.
52
+ this.tlsAgent = null;
47
53
 
48
54
  // Configurar TLS desde certificado
49
55
  if (options.certificado) {
@@ -61,15 +67,19 @@ class SiiSession {
61
67
  */
62
68
  _configureTlsFromCertificado(certificado) {
63
69
  try {
70
+ const keyPem = certificado.getPrivateKeyPEM();
71
+ const certPem = certificado.getCertificatePEM();
64
72
  this.tlsOptions = {
65
- key: certificado.getPrivateKeyPEM(),
66
- cert: certificado.getCertificatePEM(),
67
- certificate: certificado.getCertificatePEM(),
73
+ key: keyPem,
74
+ cert: certPem,
75
+ certificate: certPem,
68
76
  rejectUnauthorized: false,
69
77
  };
78
+ this.tlsAgent = createTlsAgentFromPem(keyPem, certPem);
70
79
  } catch (error) {
71
80
  log.error('Error configurando TLS desde certificado:', error.message);
72
81
  this.tlsOptions = null;
82
+ this.tlsAgent = null;
73
83
  }
74
84
  }
75
85
 
@@ -81,9 +91,11 @@ class SiiSession {
81
91
  try {
82
92
  const pfxData = loadPfxFromBuffer(pfxBuffer, password);
83
93
  this.tlsOptions = createTlsOptions(pfxData);
94
+ this.tlsAgent = createTlsAgent(pfxData);
84
95
  } catch (error) {
85
96
  log.error('Error configurando TLS desde PFX:', error.message);
86
97
  this.tlsOptions = null;
98
+ this.tlsAgent = null;
87
99
  }
88
100
  }
89
101
 
@@ -95,9 +107,11 @@ class SiiSession {
95
107
  try {
96
108
  const pfxData = loadPfxFromFile(pfxPath, password);
97
109
  this.tlsOptions = createTlsOptions(pfxData);
110
+ this.tlsAgent = createTlsAgent(pfxData);
98
111
  } catch (error) {
99
112
  log.error('Error configurando TLS desde archivo PFX:', error.message);
100
113
  this.tlsOptions = null;
114
+ this.tlsAgent = null;
101
115
  }
102
116
  }
103
117
 
@@ -215,6 +229,11 @@ class SiiSession {
215
229
  followRedirect: false,
216
230
  throwHttpErrors: false,
217
231
  https: this.tlsOptions || { rejectUnauthorized: false },
232
+ // got remapea `https` a un set fijo de claves y NO reenvía secureOptions/
233
+ // maxVersion bajo ningún nombre — sin el Agent nativo aquí, el certificado
234
+ // cliente se autentica con handshake TLS incompleto contra el SII (ver
235
+ // utils/pfx.js createTlsAgent).
236
+ ...(this.tlsAgent ? { agent: { https: this.tlsAgent } } : {}),
218
237
  responseType: 'buffer',
219
238
  timeout: { request: options.timeoutMs ?? 20000 },
220
239
  });
package/WsReclamo.js CHANGED
@@ -1,434 +1,434 @@
1
- // Copyright (c) 2026 Devlas SpA — https://devlas.cl
2
- // Licencia MIT. Ver archivo LICENSE para mas detalles.
3
- /**
4
- * WsReclamo.js — Cliente SOAP para el WS de Aceptación/Reclamo de DTEs del SII
5
- *
6
- * Implementa el "WS Consulta y Registro de Aceptación/Reclamo a DTE recibido" v1.2
7
- * Fuente oficial: https://www.sii.cl/factura_electronica/factura_mercado/WSREGISTRORECLAMODTESERVICIO.pdf
8
- *
9
- * Métodos expuestos:
10
- * listarEventosHistDoc(rutEmisor, dvEmisor, tipoDoc, folio) → { codResp, descResp, eventos[] }
11
- * consultarEstadoReceptor(rutEmisor, dvEmisor, tipoDoc, folio) → 'sin_accion'|'aceptada'|'tacita'|'reclamada'
12
- * ingresarAceptacion(rutEmisor, dvEmisor, tipoDoc, folio, accion) → { codResp, descResp }
13
- *
14
- * Autenticación: TOKEN SII vía flujo seed/firma SOAP (mismo que EnviadorSII.getTokenSoap).
15
- */
16
-
17
- const forge = require('node-forge');
18
- const {
19
- SOAP_ENDPOINTS,
20
- WSRECLAMO_ENDPOINTS,
21
- validateAmbiente,
22
- createScopedLogger,
23
- getCachedToken,
24
- setCachedToken,
25
- extractTagContent,
26
- decodeXmlEntities,
27
- parseXmlNoNs,
28
- siiError,
29
- ERROR_CODES,
30
- } = require('./utils');
31
-
32
- const log = createScopedLogger('WsReclamo');
33
-
34
- // ─── Acciones válidas para ingresarAceptacionReclamoDoc ───────────────────────
35
- /** @typedef {'ACD'|'ERM'|'RCD'|'RFP'|'RFT'} AccionReclamo */
36
-
37
- // ─── Mapeo codEvento → estado normalizado ────────────────────────────────────
38
- // Mapeo de codEvento WSRECLAMO → estado receptor
39
- // 'tacita' NO es un evento del WS — es una presunción legal cuando pasan 8 días
40
- // sin eventos (codResp=16). Se calcula en capa de negocio, no aquí.
41
- const ESTADO_POR_EVENTO = {
42
- ACD: 'aceptada', // Acepta Contenido del Documento (explícito)
43
- ERM: 'acuse_recibo', // Otorga Recibo de Mercaderías/Servicios (explícito)
44
- RCD: 'reclamada', // Reclamo al Contenido del Documento
45
- RFP: 'reclamada', // Reclamo por Falta Parcial de Mercaderías
46
- RFT: 'reclamada', // Reclamo por Falta Total de Mercaderías
47
- NCA: 'sin_accion', // NC de anulación que referencia el doc (no registrable por WS)
48
- ENC: 'sin_accion', // NC distinta de anulación que referencia el doc (no registrable)
49
- };
50
-
51
- class WsReclamo {
52
- /**
53
- * @param {Object} certificado — instancia de Certificado con privateKey y cert
54
- * @param {string} ambiente — 'certificacion' | 'produccion'
55
- * @param {Object} [options]
56
- * @param {boolean} [options.useTokenCache=true]
57
- */
58
- constructor(certificado, ambiente, options = {}) {
59
- if (!certificado) throw new Error('WsReclamo: certificado es obligatorio');
60
- this.certificado = certificado;
61
- this.ambiente = validateAmbiente(ambiente);
62
- this.useTokenCache = options.useTokenCache !== false;
63
-
64
- this.rutCert = certificado.rut || 'unknown';
65
-
66
- this._tokenSoap = null;
67
-
68
- // URLs
69
- this._seedUrl = SOAP_ENDPOINTS[this.ambiente].seed;
70
- this._tokenUrl = SOAP_ENDPOINTS[this.ambiente].token;
71
- this._wsUrl = WSRECLAMO_ENDPOINTS[this.ambiente].replace('?wsdl', '');
72
- }
73
-
74
- // ══════════════════════════════════════════════════════════════════════════════
75
- // AUTENTICACIÓN — mismo flujo que EnviadorSII.getTokenSoap
76
- // ══════════════════════════════════════════════════════════════════════════════
77
-
78
- /** Obtiene semilla del servicio SOAP del SII */
79
- async _getSemilla() {
80
- const envelope = `<?xml version="1.0" encoding="UTF-8"?>
81
- <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
82
- <soapenv:Body><getSeed/></soapenv:Body>
83
- </soapenv:Envelope>`;
84
-
85
- const res = await fetch(this._seedUrl, {
86
- method: 'POST',
87
- headers: { 'Content-Type': 'text/xml; charset=utf-8', SOAPAction: '' },
88
- body: envelope,
89
- });
90
- if (!res.ok) throw siiError(`Error semilla: ${res.status}`, ERROR_CODES.SII_CONNECTION_FAILED);
91
-
92
- const xml = await res.text();
93
- const semilla = extractTagContent(decodeXmlEntities(xml), 'SEMILLA');
94
- if (!semilla) throw siiError('No se obtuvo semilla del SII', ERROR_CODES.SII_INVALID_RESPONSE);
95
- return semilla;
96
- }
97
-
98
- /** Firma la semilla y obtiene el TOKEN SOAP */
99
- async _fetchTokenSoap() {
100
- const semilla = await this._getSemilla();
101
- const xmlFirmado = this._firmarSemilla(semilla);
102
-
103
- const escaped = xmlFirmado
104
- .replace(/&/g, '&amp;')
105
- .replace(/</g, '&lt;')
106
- .replace(/>/g, '&gt;');
107
-
108
- const envelope = `<?xml version="1.0" encoding="UTF-8"?>
109
- <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
110
- <soapenv:Body>
111
- <getToken>
112
- <pszXml>${escaped}</pszXml>
113
- </getToken>
114
- </soapenv:Body>
115
- </soapenv:Envelope>`;
116
-
117
- const res = await fetch(this._tokenUrl, {
118
- method: 'POST',
119
- headers: { 'Content-Type': 'text/xml; charset=utf-8', SOAPAction: '' },
120
- body: envelope,
121
- });
122
- if (!res.ok) throw siiError(`Error token: ${res.status}`, ERROR_CODES.SII_CONNECTION_FAILED);
123
-
124
- const xml = await res.text();
125
- const decoded = xml.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
126
- const token = extractTagContent(decoded, 'TOKEN');
127
- if (!token) throw siiError('No se obtuvo TOKEN del SII', ERROR_CODES.SII_AUTH_FAILED);
128
-
129
- return token;
130
- }
131
-
132
- /** Obtiene o reutiliza el token SOAP (con cache opcional) */
133
- async _ensureToken() {
134
- if (this.useTokenCache) {
135
- const cached = getCachedToken(this.ambiente, 'soap', this.rutCert);
136
- if (cached) {
137
- this._tokenSoap = cached;
138
- return cached;
139
- }
140
- }
141
-
142
- const token = await this._fetchTokenSoap();
143
- this._tokenSoap = token;
144
-
145
- if (this.useTokenCache) {
146
- setCachedToken(this.ambiente, 'soap', this.rutCert, token);
147
- }
148
- return token;
149
- }
150
-
151
- /** Invalida el token cacheado para forzar renovación */
152
- invalidarToken() {
153
- this._tokenSoap = null;
154
- // invalidateToken no está disponible en utils — limpiamos el caché con un token vacío
155
- // o simplemente seteamos nulo; el cache expira por TTL
156
- }
157
-
158
- // ══════════════════════════════════════════════════════════════════════════════
159
- // FIRMA DE SEMILLA — idéntico a EnviadorSII._crearXMLSemilla
160
- // ══════════════════════════════════════════════════════════════════════════════
161
-
162
- _firmarSemilla(semilla) {
163
- const xmlContent = `<getToken><item><Semilla>${semilla}</Semilla></item></getToken>`;
164
-
165
- const md = forge.md.sha1.create();
166
- md.update(xmlContent, 'utf8');
167
- const digestValue = forge.util.encode64(md.digest().bytes());
168
-
169
- const signedInfoParaFirmar = [
170
- '<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">',
171
- '<CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"></CanonicalizationMethod>',
172
- '<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"></SignatureMethod>',
173
- '<Reference URI=""><Transforms>',
174
- '<Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"></Transform>',
175
- '</Transforms>',
176
- '<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"></DigestMethod>',
177
- `<DigestValue>${digestValue}</DigestValue>`,
178
- '</Reference></SignedInfo>',
179
- ].join('');
180
-
181
- const mdSign = forge.md.sha1.create();
182
- mdSign.update(signedInfoParaFirmar, 'utf8');
183
- const signature = this.certificado.privateKey.sign(mdSign);
184
- const signatureValue = this._wordwrap(forge.util.encode64(signature), 64);
185
-
186
- const modulus = this._wordwrap(this.certificado.getModulus(), 64);
187
- const exponent = this.certificado.getExponent();
188
- const cert = this._wordwrap(this.certificado.getCertificateBase64(), 64);
189
-
190
- return [
191
- '<?xml version="1.0" encoding="UTF-8"?>',
192
- `<getToken><item><Semilla>${semilla}</Semilla></item>`,
193
- '<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">',
194
- '<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">',
195
- '<CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>',
196
- '<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>',
197
- '<Reference URI=""><Transforms>',
198
- '<Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>',
199
- '</Transforms>',
200
- '<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>',
201
- `<DigestValue>${digestValue}</DigestValue>`,
202
- '</Reference></SignedInfo>',
203
- `<SignatureValue>${signatureValue}</SignatureValue>`,
204
- '<KeyInfo><KeyValue><RSAKeyValue>',
205
- `<Modulus>${modulus}</Modulus>`,
206
- `<Exponent>${exponent}</Exponent>`,
207
- '</RSAKeyValue></KeyValue>',
208
- `<X509Data><X509Certificate>${cert}</X509Certificate></X509Data>`,
209
- '</KeyInfo></Signature></getToken>',
210
- ].join('');
211
- }
212
-
213
- _wordwrap(str, width) {
214
- const lines = [];
215
- for (let i = 0; i < str.length; i += width) {
216
- lines.push(str.substring(i, i + width));
217
- }
218
- return lines.join('\n');
219
- }
220
-
221
- // ══════════════════════════════════════════════════════════════════════════════
222
- // LLAMADAS SOAP AL WSRECLAMO
223
- // ══════════════════════════════════════════════════════════════════════════════
224
-
225
- // Namespace oficial del servicio (fuente: WSDL producción/certificación)
226
- static NS = 'http://ws.registroreclamodte.diii.sdi.sii.cl';
227
-
228
- /**
229
- * Realiza una llamada SOAP al WSRECLAMO con autenticación via TOKEN cookie.
230
- * Namespace verificado desde: https://ws2.sii.cl/WSREGISTRORECLAMODTECERT/registroreclamodteservice?wsdl
231
- * @private
232
- */
233
- async _llamar(metodo, params, reintentar = true) {
234
- const token = await this._ensureToken();
235
- const ns = WsReclamo.NS;
236
-
237
- const innerXml = Object.entries(params)
238
- .map(([k, v]) => `<${k}>${v}</${k}>`)
239
- .join('');
240
-
241
- // El body usa el prefijo ws: con el namespace del servicio
242
- const envelope = `<?xml version="1.0" encoding="UTF-8"?>
243
- <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ws="${ns}">
244
- <soapenv:Header/>
245
- <soapenv:Body>
246
- <ws:${metodo}>${innerXml}</ws:${metodo}>
247
- </soapenv:Body>
248
- </soapenv:Envelope>`;
249
-
250
- const res = await fetch(this._wsUrl, {
251
- method: 'POST',
252
- headers: {
253
- 'Content-Type': 'text/xml; charset=utf-8',
254
- SOAPAction: '',
255
- Cookie: `TOKEN=${token}`,
256
- },
257
- body: envelope,
258
- });
259
-
260
- // Si el SII devuelve 403/401, el token puede haber expirado — reintentar una vez
261
- if ((res.status === 401 || res.status === 403) && reintentar) {
262
- log.log(`[WsReclamo] Token expirado (${res.status}), renovando...`);
263
- this._tokenSoap = null;
264
- return this._llamar(metodo, params, false);
265
- }
266
-
267
- if (!res.ok) {
268
- throw siiError(`WSRECLAMO ${metodo}: HTTP ${res.status}`, ERROR_CODES.SII_CONNECTION_FAILED);
269
- }
270
-
271
- const xml = await res.text();
272
- return xml;
273
- }
274
-
275
- /**
276
- * Lista todos los eventos históricos de aceptación/reclamo para un DTE.
277
- *
278
- * @param {string} rutEmisor — RUT sin DV ni puntos, ej: '76354771'
279
- * @param {string} dvEmisor — DV del RUT, ej: 'K'
280
- * @param {number} tipoDoc — Tipo de DTE, ej: 33 (factura afecta)
281
- * @param {number} folio — Número de folio
282
- * @returns {Promise<{codResp: number, descResp: string, eventos: Array<{codEvento: string, descEvento: string, rutResponsable: string, dvResponsable: string, fechaEvento: string}>}>}
283
- */
284
- async listarEventosHistDoc(rutEmisor, dvEmisor, tipoDoc, folio) {
285
- log.log(`[WsReclamo] listarEventosHistDoc RUT=${rutEmisor}-${dvEmisor} tipo=${tipoDoc} folio=${folio}`);
286
-
287
- const xml = await this._llamar('listarEventosHistDoc', {
288
- rutEmisor,
289
- dvEmisor,
290
- tipoDoc,
291
- folio,
292
- });
293
-
294
- return this._parsearRespuestaEventos(xml);
295
- }
296
-
297
- /**
298
- * Consulta el estado resumido del receptor para un DTE emitido.
299
- * Devuelve el estado derivado del evento más reciente.
300
- *
301
- * Códigos relevantes de listarEventosHistDoc (doc SII v1.2):
302
- * 15 = Listado de eventos del documento (hay eventos)
303
- * 16 = Documento no presenta eventos de reclamos o acuse de recibo
304
- * 18 = Documento no ha sido recibido por el receptor
305
- *
306
- * @returns {Promise<'sin_accion'|'aceptada'|'tacita'|'reclamada'>}
307
- */
308
- async consultarEstadoReceptor(rutEmisor, dvEmisor, tipoDoc, folio) {
309
- const { codResp, eventos } = await this.listarEventosHistDoc(rutEmisor, dvEmisor, tipoDoc, folio);
310
-
311
- // 16 = sin eventos de reclamo/acuse | 18 = no recibido aún
312
- if (codResp === 16 || codResp === 18) {
313
- return 'sin_accion';
314
- }
315
-
316
- // 15 = hay eventos — tomar el más reciente
317
- if (codResp === 15 && eventos && eventos.length > 0) {
318
- const ultimo = eventos[eventos.length - 1];
319
- return ESTADO_POR_EVENTO[ultimo.codEvento] ?? 'sin_accion';
320
- }
321
-
322
- return 'sin_accion';
323
- }
324
-
325
- /**
326
- * Ingresa una acción de aceptación o reclamo sobre un DTE recibido.
327
- * Uso típico: el receptor registra su decisión.
328
- *
329
- * @param {string} rutEmisor
330
- * @param {string} dvEmisor
331
- * @param {number} tipoDoc
332
- * @param {number} folio
333
- * @param {AccionReclamo} accionDoc — 'ACD'|'ERM'|'RCD'|'RFP'|'RFT'
334
- * @returns {Promise<{codResp: number, descResp: string}>}
335
- */
336
- async ingresarAceptacion(rutEmisor, dvEmisor, tipoDoc, folio, accionDoc) {
337
- const ACCIONES_VALIDAS = ['ACD', 'ERM', 'RCD', 'RFP', 'RFT'];
338
- if (!ACCIONES_VALIDAS.includes(accionDoc)) {
339
- throw new Error(`WsReclamo: accionDoc inválida '${accionDoc}'. Debe ser una de: ${ACCIONES_VALIDAS.join(', ')}`);
340
- }
341
-
342
- log.log(`[WsReclamo] ingresarAceptacion RUT=${rutEmisor}-${dvEmisor} tipo=${tipoDoc} folio=${folio} accion=${accionDoc}`);
343
-
344
- const xml = await this._llamar('ingresarAceptacionReclamoDoc', {
345
- rutEmisor,
346
- dvEmisor,
347
- tipoDoc,
348
- folio,
349
- accionDoc,
350
- });
351
-
352
- return this._parsearRespuestaSimple(xml);
353
- }
354
-
355
- // ══════════════════════════════════════════════════════════════════════════════
356
- // PARSERS XML
357
- // ══════════════════════════════════════════════════════════════════════════════
358
-
359
- /**
360
- * Parsea respuesta de listarEventosHistDoc.
361
- *
362
- * Estructura real (verificada en doc SII v1.2 y SoapUI screenshot):
363
- * <return>
364
- * <codResp>15</codResp>
365
- * <descResp>Listado de eventos del documento</descResp>
366
- * <listaEventosDoc>
367
- * <codEvento>ACD</codEvento>
368
- * <descEvento>Acepta Contenido del Documento</descEvento>
369
- * <rutResponsable>45000055</rutResponsable>
370
- * <dvResponsable>8</dvResponsable>
371
- * <fechaEvento>29-12-2016 12:05:36</fechaEvento>
372
- * </listaEventosDoc>
373
- * </return>
374
- */
375
- _parsearRespuestaEventos(xml) {
376
- // Decode HTML entities del wrapper SOAP
377
- const decoded = xml
378
- .replace(/&lt;/g, '<')
379
- .replace(/&gt;/g, '>')
380
- .replace(/&amp;/g, '&')
381
- .replace(/&quot;/g, '"');
382
-
383
- // Extraer el bloque <return>...</return>
384
- const returnMatch = decoded.match(/<return>([\s\S]*?)<\/return>/i);
385
- const bloque = returnMatch ? returnMatch[1] : decoded;
386
-
387
- const codResp = parseInt(extractTagContent(bloque, 'codResp') ?? '16', 10);
388
- const descResp = extractTagContent(bloque, 'descResp') ?? '';
389
-
390
- // Extraer cada <listaEventosDoc>...</listaEventosDoc>
391
- const eventos = [];
392
- const itemRegex = /<listaEventosDoc>([\s\S]*?)<\/listaEventosDoc>/gi;
393
- let match;
394
- while ((match = itemRegex.exec(bloque)) !== null) {
395
- const item = match[1];
396
- eventos.push({
397
- codEvento: extractTagContent(item, 'codEvento') ?? '',
398
- descEvento: extractTagContent(item, 'descEvento') ?? '',
399
- rutResponsable: extractTagContent(item, 'rutResponsable') ?? '',
400
- dvResponsable: extractTagContent(item, 'dvResponsable') ?? '',
401
- fechaEvento: extractTagContent(item, 'fechaEvento') ?? '',
402
- });
403
- }
404
-
405
- return { codResp, descResp, eventos };
406
- }
407
-
408
- /**
409
- * Parsea respuesta de ingresarAceptacionReclamoDoc y consultarDocDteCedible.
410
- *
411
- * Estructura:
412
- * <return>
413
- * <codResp>0</codResp>
414
- * <descResp>Acción completada OK</descResp>
415
- * </return>
416
- *
417
- * codResp 0 = OK (ingresarAceptacion)
418
- */
419
- _parsearRespuestaSimple(xml) {
420
- const decoded = xml
421
- .replace(/&lt;/g, '<')
422
- .replace(/&gt;/g, '>')
423
- .replace(/&amp;/g, '&');
424
-
425
- const returnMatch = decoded.match(/<return>([\s\S]*?)<\/return>/i);
426
- const bloque = returnMatch ? returnMatch[1] : decoded;
427
-
428
- const codResp = parseInt(extractTagContent(bloque, 'codResp') ?? '-1', 10);
429
- const descResp = extractTagContent(bloque, 'descResp') ?? '';
430
- return { codResp, descResp };
431
- }
432
- }
433
-
434
- module.exports = WsReclamo;
1
+ // Copyright (c) 2026 Devlas SpA — https://devlas.cl
2
+ // Licencia MIT. Ver archivo LICENSE para mas detalles.
3
+ /**
4
+ * WsReclamo.js — Cliente SOAP para el WS de Aceptación/Reclamo de DTEs del SII
5
+ *
6
+ * Implementa el "WS Consulta y Registro de Aceptación/Reclamo a DTE recibido" v1.2
7
+ * Fuente oficial: https://www.sii.cl/factura_electronica/factura_mercado/WSREGISTRORECLAMODTESERVICIO.pdf
8
+ *
9
+ * Métodos expuestos:
10
+ * listarEventosHistDoc(rutEmisor, dvEmisor, tipoDoc, folio) → { codResp, descResp, eventos[] }
11
+ * consultarEstadoReceptor(rutEmisor, dvEmisor, tipoDoc, folio) → 'sin_accion'|'aceptada'|'tacita'|'reclamada'
12
+ * ingresarAceptacion(rutEmisor, dvEmisor, tipoDoc, folio, accion) → { codResp, descResp }
13
+ *
14
+ * Autenticación: TOKEN SII vía flujo seed/firma SOAP (mismo que EnviadorSII.getTokenSoap).
15
+ */
16
+
17
+ const forge = require('node-forge');
18
+ const {
19
+ SOAP_ENDPOINTS,
20
+ WSRECLAMO_ENDPOINTS,
21
+ validateAmbiente,
22
+ createScopedLogger,
23
+ getCachedToken,
24
+ setCachedToken,
25
+ extractTagContent,
26
+ decodeXmlEntities,
27
+ parseXmlNoNs,
28
+ siiError,
29
+ ERROR_CODES,
30
+ } = require('./utils');
31
+
32
+ const log = createScopedLogger('WsReclamo');
33
+
34
+ // ─── Acciones válidas para ingresarAceptacionReclamoDoc ───────────────────────
35
+ /** @typedef {'ACD'|'ERM'|'RCD'|'RFP'|'RFT'} AccionReclamo */
36
+
37
+ // ─── Mapeo codEvento → estado normalizado ────────────────────────────────────
38
+ // Mapeo de codEvento WSRECLAMO → estado receptor
39
+ // 'tacita' NO es un evento del WS — es una presunción legal cuando pasan 8 días
40
+ // sin eventos (codResp=16). Se calcula en capa de negocio, no aquí.
41
+ const ESTADO_POR_EVENTO = {
42
+ ACD: 'aceptada', // Acepta Contenido del Documento (explícito)
43
+ ERM: 'acuse_recibo', // Otorga Recibo de Mercaderías/Servicios (explícito)
44
+ RCD: 'reclamada', // Reclamo al Contenido del Documento
45
+ RFP: 'reclamada', // Reclamo por Falta Parcial de Mercaderías
46
+ RFT: 'reclamada', // Reclamo por Falta Total de Mercaderías
47
+ NCA: 'sin_accion', // NC de anulación que referencia el doc (no registrable por WS)
48
+ ENC: 'sin_accion', // NC distinta de anulación que referencia el doc (no registrable)
49
+ };
50
+
51
+ class WsReclamo {
52
+ /**
53
+ * @param {Object} certificado — instancia de Certificado con privateKey y cert
54
+ * @param {string} ambiente — 'certificacion' | 'produccion'
55
+ * @param {Object} [options]
56
+ * @param {boolean} [options.useTokenCache=true]
57
+ */
58
+ constructor(certificado, ambiente, options = {}) {
59
+ if (!certificado) throw new Error('WsReclamo: certificado es obligatorio');
60
+ this.certificado = certificado;
61
+ this.ambiente = validateAmbiente(ambiente);
62
+ this.useTokenCache = options.useTokenCache !== false;
63
+
64
+ this.rutCert = certificado.rut || 'unknown';
65
+
66
+ this._tokenSoap = null;
67
+
68
+ // URLs
69
+ this._seedUrl = SOAP_ENDPOINTS[this.ambiente].seed;
70
+ this._tokenUrl = SOAP_ENDPOINTS[this.ambiente].token;
71
+ this._wsUrl = WSRECLAMO_ENDPOINTS[this.ambiente].replace('?wsdl', '');
72
+ }
73
+
74
+ // ══════════════════════════════════════════════════════════════════════════════
75
+ // AUTENTICACIÓN — mismo flujo que EnviadorSII.getTokenSoap
76
+ // ══════════════════════════════════════════════════════════════════════════════
77
+
78
+ /** Obtiene semilla del servicio SOAP del SII */
79
+ async _getSemilla() {
80
+ const envelope = `<?xml version="1.0" encoding="UTF-8"?>
81
+ <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
82
+ <soapenv:Body><getSeed/></soapenv:Body>
83
+ </soapenv:Envelope>`;
84
+
85
+ const res = await fetch(this._seedUrl, {
86
+ method: 'POST',
87
+ headers: { 'Content-Type': 'text/xml; charset=utf-8', SOAPAction: '' },
88
+ body: envelope,
89
+ });
90
+ if (!res.ok) throw siiError(`Error semilla: ${res.status}`, ERROR_CODES.SII_CONNECTION_FAILED);
91
+
92
+ const xml = await res.text();
93
+ const semilla = extractTagContent(decodeXmlEntities(xml), 'SEMILLA');
94
+ if (!semilla) throw siiError('No se obtuvo semilla del SII', ERROR_CODES.SII_INVALID_RESPONSE);
95
+ return semilla;
96
+ }
97
+
98
+ /** Firma la semilla y obtiene el TOKEN SOAP */
99
+ async _fetchTokenSoap() {
100
+ const semilla = await this._getSemilla();
101
+ const xmlFirmado = this._firmarSemilla(semilla);
102
+
103
+ const escaped = xmlFirmado
104
+ .replace(/&/g, '&amp;')
105
+ .replace(/</g, '&lt;')
106
+ .replace(/>/g, '&gt;');
107
+
108
+ const envelope = `<?xml version="1.0" encoding="UTF-8"?>
109
+ <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
110
+ <soapenv:Body>
111
+ <getToken>
112
+ <pszXml>${escaped}</pszXml>
113
+ </getToken>
114
+ </soapenv:Body>
115
+ </soapenv:Envelope>`;
116
+
117
+ const res = await fetch(this._tokenUrl, {
118
+ method: 'POST',
119
+ headers: { 'Content-Type': 'text/xml; charset=utf-8', SOAPAction: '' },
120
+ body: envelope,
121
+ });
122
+ if (!res.ok) throw siiError(`Error token: ${res.status}`, ERROR_CODES.SII_CONNECTION_FAILED);
123
+
124
+ const xml = await res.text();
125
+ const decoded = xml.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
126
+ const token = extractTagContent(decoded, 'TOKEN');
127
+ if (!token) throw siiError('No se obtuvo TOKEN del SII', ERROR_CODES.SII_AUTH_FAILED);
128
+
129
+ return token;
130
+ }
131
+
132
+ /** Obtiene o reutiliza el token SOAP (con cache opcional) */
133
+ async _ensureToken() {
134
+ if (this.useTokenCache) {
135
+ const cached = getCachedToken(this.ambiente, 'soap', this.rutCert);
136
+ if (cached) {
137
+ this._tokenSoap = cached;
138
+ return cached;
139
+ }
140
+ }
141
+
142
+ const token = await this._fetchTokenSoap();
143
+ this._tokenSoap = token;
144
+
145
+ if (this.useTokenCache) {
146
+ setCachedToken(this.ambiente, 'soap', this.rutCert, token);
147
+ }
148
+ return token;
149
+ }
150
+
151
+ /** Invalida el token cacheado para forzar renovación */
152
+ invalidarToken() {
153
+ this._tokenSoap = null;
154
+ // invalidateToken no está disponible en utils — limpiamos el caché con un token vacío
155
+ // o simplemente seteamos nulo; el cache expira por TTL
156
+ }
157
+
158
+ // ══════════════════════════════════════════════════════════════════════════════
159
+ // FIRMA DE SEMILLA — idéntico a EnviadorSII._crearXMLSemilla
160
+ // ══════════════════════════════════════════════════════════════════════════════
161
+
162
+ _firmarSemilla(semilla) {
163
+ const xmlContent = `<getToken><item><Semilla>${semilla}</Semilla></item></getToken>`;
164
+
165
+ const md = forge.md.sha1.create();
166
+ md.update(xmlContent, 'utf8');
167
+ const digestValue = forge.util.encode64(md.digest().bytes());
168
+
169
+ const signedInfoParaFirmar = [
170
+ '<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">',
171
+ '<CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"></CanonicalizationMethod>',
172
+ '<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"></SignatureMethod>',
173
+ '<Reference URI=""><Transforms>',
174
+ '<Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"></Transform>',
175
+ '</Transforms>',
176
+ '<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"></DigestMethod>',
177
+ `<DigestValue>${digestValue}</DigestValue>`,
178
+ '</Reference></SignedInfo>',
179
+ ].join('');
180
+
181
+ const mdSign = forge.md.sha1.create();
182
+ mdSign.update(signedInfoParaFirmar, 'utf8');
183
+ const signature = this.certificado.privateKey.sign(mdSign);
184
+ const signatureValue = this._wordwrap(forge.util.encode64(signature), 64);
185
+
186
+ const modulus = this._wordwrap(this.certificado.getModulus(), 64);
187
+ const exponent = this.certificado.getExponent();
188
+ const cert = this._wordwrap(this.certificado.getCertificateBase64(), 64);
189
+
190
+ return [
191
+ '<?xml version="1.0" encoding="UTF-8"?>',
192
+ `<getToken><item><Semilla>${semilla}</Semilla></item>`,
193
+ '<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">',
194
+ '<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">',
195
+ '<CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>',
196
+ '<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>',
197
+ '<Reference URI=""><Transforms>',
198
+ '<Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>',
199
+ '</Transforms>',
200
+ '<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>',
201
+ `<DigestValue>${digestValue}</DigestValue>`,
202
+ '</Reference></SignedInfo>',
203
+ `<SignatureValue>${signatureValue}</SignatureValue>`,
204
+ '<KeyInfo><KeyValue><RSAKeyValue>',
205
+ `<Modulus>${modulus}</Modulus>`,
206
+ `<Exponent>${exponent}</Exponent>`,
207
+ '</RSAKeyValue></KeyValue>',
208
+ `<X509Data><X509Certificate>${cert}</X509Certificate></X509Data>`,
209
+ '</KeyInfo></Signature></getToken>',
210
+ ].join('');
211
+ }
212
+
213
+ _wordwrap(str, width) {
214
+ const lines = [];
215
+ for (let i = 0; i < str.length; i += width) {
216
+ lines.push(str.substring(i, i + width));
217
+ }
218
+ return lines.join('\n');
219
+ }
220
+
221
+ // ══════════════════════════════════════════════════════════════════════════════
222
+ // LLAMADAS SOAP AL WSRECLAMO
223
+ // ══════════════════════════════════════════════════════════════════════════════
224
+
225
+ // Namespace oficial del servicio (fuente: WSDL producción/certificación)
226
+ static NS = 'http://ws.registroreclamodte.diii.sdi.sii.cl';
227
+
228
+ /**
229
+ * Realiza una llamada SOAP al WSRECLAMO con autenticación via TOKEN cookie.
230
+ * Namespace verificado desde: https://ws2.sii.cl/WSREGISTRORECLAMODTECERT/registroreclamodteservice?wsdl
231
+ * @private
232
+ */
233
+ async _llamar(metodo, params, reintentar = true) {
234
+ const token = await this._ensureToken();
235
+ const ns = WsReclamo.NS;
236
+
237
+ const innerXml = Object.entries(params)
238
+ .map(([k, v]) => `<${k}>${v}</${k}>`)
239
+ .join('');
240
+
241
+ // El body usa el prefijo ws: con el namespace del servicio
242
+ const envelope = `<?xml version="1.0" encoding="UTF-8"?>
243
+ <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ws="${ns}">
244
+ <soapenv:Header/>
245
+ <soapenv:Body>
246
+ <ws:${metodo}>${innerXml}</ws:${metodo}>
247
+ </soapenv:Body>
248
+ </soapenv:Envelope>`;
249
+
250
+ const res = await fetch(this._wsUrl, {
251
+ method: 'POST',
252
+ headers: {
253
+ 'Content-Type': 'text/xml; charset=utf-8',
254
+ SOAPAction: '',
255
+ Cookie: `TOKEN=${token}`,
256
+ },
257
+ body: envelope,
258
+ });
259
+
260
+ // Si el SII devuelve 403/401, el token puede haber expirado — reintentar una vez
261
+ if ((res.status === 401 || res.status === 403) && reintentar) {
262
+ log.log(`[WsReclamo] Token expirado (${res.status}), renovando...`);
263
+ this._tokenSoap = null;
264
+ return this._llamar(metodo, params, false);
265
+ }
266
+
267
+ if (!res.ok) {
268
+ throw siiError(`WSRECLAMO ${metodo}: HTTP ${res.status}`, ERROR_CODES.SII_CONNECTION_FAILED);
269
+ }
270
+
271
+ const xml = await res.text();
272
+ return xml;
273
+ }
274
+
275
+ /**
276
+ * Lista todos los eventos históricos de aceptación/reclamo para un DTE.
277
+ *
278
+ * @param {string} rutEmisor — RUT sin DV ni puntos, ej: '76354771'
279
+ * @param {string} dvEmisor — DV del RUT, ej: 'K'
280
+ * @param {number} tipoDoc — Tipo de DTE, ej: 33 (factura afecta)
281
+ * @param {number} folio — Número de folio
282
+ * @returns {Promise<{codResp: number, descResp: string, eventos: Array<{codEvento: string, descEvento: string, rutResponsable: string, dvResponsable: string, fechaEvento: string}>}>}
283
+ */
284
+ async listarEventosHistDoc(rutEmisor, dvEmisor, tipoDoc, folio) {
285
+ log.log(`[WsReclamo] listarEventosHistDoc RUT=${rutEmisor}-${dvEmisor} tipo=${tipoDoc} folio=${folio}`);
286
+
287
+ const xml = await this._llamar('listarEventosHistDoc', {
288
+ rutEmisor,
289
+ dvEmisor,
290
+ tipoDoc,
291
+ folio,
292
+ });
293
+
294
+ return this._parsearRespuestaEventos(xml);
295
+ }
296
+
297
+ /**
298
+ * Consulta el estado resumido del receptor para un DTE emitido.
299
+ * Devuelve el estado derivado del evento más reciente.
300
+ *
301
+ * Códigos relevantes de listarEventosHistDoc (doc SII v1.2):
302
+ * 15 = Listado de eventos del documento (hay eventos)
303
+ * 16 = Documento no presenta eventos de reclamos o acuse de recibo
304
+ * 18 = Documento no ha sido recibido por el receptor
305
+ *
306
+ * @returns {Promise<'sin_accion'|'aceptada'|'tacita'|'reclamada'>}
307
+ */
308
+ async consultarEstadoReceptor(rutEmisor, dvEmisor, tipoDoc, folio) {
309
+ const { codResp, eventos } = await this.listarEventosHistDoc(rutEmisor, dvEmisor, tipoDoc, folio);
310
+
311
+ // 16 = sin eventos de reclamo/acuse | 18 = no recibido aún
312
+ if (codResp === 16 || codResp === 18) {
313
+ return 'sin_accion';
314
+ }
315
+
316
+ // 15 = hay eventos — tomar el más reciente
317
+ if (codResp === 15 && eventos && eventos.length > 0) {
318
+ const ultimo = eventos[eventos.length - 1];
319
+ return ESTADO_POR_EVENTO[ultimo.codEvento] ?? 'sin_accion';
320
+ }
321
+
322
+ return 'sin_accion';
323
+ }
324
+
325
+ /**
326
+ * Ingresa una acción de aceptación o reclamo sobre un DTE recibido.
327
+ * Uso típico: el receptor registra su decisión.
328
+ *
329
+ * @param {string} rutEmisor
330
+ * @param {string} dvEmisor
331
+ * @param {number} tipoDoc
332
+ * @param {number} folio
333
+ * @param {AccionReclamo} accionDoc — 'ACD'|'ERM'|'RCD'|'RFP'|'RFT'
334
+ * @returns {Promise<{codResp: number, descResp: string}>}
335
+ */
336
+ async ingresarAceptacion(rutEmisor, dvEmisor, tipoDoc, folio, accionDoc) {
337
+ const ACCIONES_VALIDAS = ['ACD', 'ERM', 'RCD', 'RFP', 'RFT'];
338
+ if (!ACCIONES_VALIDAS.includes(accionDoc)) {
339
+ throw new Error(`WsReclamo: accionDoc inválida '${accionDoc}'. Debe ser una de: ${ACCIONES_VALIDAS.join(', ')}`);
340
+ }
341
+
342
+ log.log(`[WsReclamo] ingresarAceptacion RUT=${rutEmisor}-${dvEmisor} tipo=${tipoDoc} folio=${folio} accion=${accionDoc}`);
343
+
344
+ const xml = await this._llamar('ingresarAceptacionReclamoDoc', {
345
+ rutEmisor,
346
+ dvEmisor,
347
+ tipoDoc,
348
+ folio,
349
+ accionDoc,
350
+ });
351
+
352
+ return this._parsearRespuestaSimple(xml);
353
+ }
354
+
355
+ // ══════════════════════════════════════════════════════════════════════════════
356
+ // PARSERS XML
357
+ // ══════════════════════════════════════════════════════════════════════════════
358
+
359
+ /**
360
+ * Parsea respuesta de listarEventosHistDoc.
361
+ *
362
+ * Estructura real (verificada en doc SII v1.2 y SoapUI screenshot):
363
+ * <return>
364
+ * <codResp>15</codResp>
365
+ * <descResp>Listado de eventos del documento</descResp>
366
+ * <listaEventosDoc>
367
+ * <codEvento>ACD</codEvento>
368
+ * <descEvento>Acepta Contenido del Documento</descEvento>
369
+ * <rutResponsable>45000055</rutResponsable>
370
+ * <dvResponsable>8</dvResponsable>
371
+ * <fechaEvento>29-12-2016 12:05:36</fechaEvento>
372
+ * </listaEventosDoc>
373
+ * </return>
374
+ */
375
+ _parsearRespuestaEventos(xml) {
376
+ // Decode HTML entities del wrapper SOAP
377
+ const decoded = xml
378
+ .replace(/&lt;/g, '<')
379
+ .replace(/&gt;/g, '>')
380
+ .replace(/&amp;/g, '&')
381
+ .replace(/&quot;/g, '"');
382
+
383
+ // Extraer el bloque <return>...</return>
384
+ const returnMatch = decoded.match(/<return>([\s\S]*?)<\/return>/i);
385
+ const bloque = returnMatch ? returnMatch[1] : decoded;
386
+
387
+ const codResp = parseInt(extractTagContent(bloque, 'codResp') ?? '16', 10);
388
+ const descResp = extractTagContent(bloque, 'descResp') ?? '';
389
+
390
+ // Extraer cada <listaEventosDoc>...</listaEventosDoc>
391
+ const eventos = [];
392
+ const itemRegex = /<listaEventosDoc>([\s\S]*?)<\/listaEventosDoc>/gi;
393
+ let match;
394
+ while ((match = itemRegex.exec(bloque)) !== null) {
395
+ const item = match[1];
396
+ eventos.push({
397
+ codEvento: extractTagContent(item, 'codEvento') ?? '',
398
+ descEvento: extractTagContent(item, 'descEvento') ?? '',
399
+ rutResponsable: extractTagContent(item, 'rutResponsable') ?? '',
400
+ dvResponsable: extractTagContent(item, 'dvResponsable') ?? '',
401
+ fechaEvento: extractTagContent(item, 'fechaEvento') ?? '',
402
+ });
403
+ }
404
+
405
+ return { codResp, descResp, eventos };
406
+ }
407
+
408
+ /**
409
+ * Parsea respuesta de ingresarAceptacionReclamoDoc y consultarDocDteCedible.
410
+ *
411
+ * Estructura:
412
+ * <return>
413
+ * <codResp>0</codResp>
414
+ * <descResp>Acción completada OK</descResp>
415
+ * </return>
416
+ *
417
+ * codResp 0 = OK (ingresarAceptacion)
418
+ */
419
+ _parsearRespuestaSimple(xml) {
420
+ const decoded = xml
421
+ .replace(/&lt;/g, '<')
422
+ .replace(/&gt;/g, '>')
423
+ .replace(/&amp;/g, '&');
424
+
425
+ const returnMatch = decoded.match(/<return>([\s\S]*?)<\/return>/i);
426
+ const bloque = returnMatch ? returnMatch[1] : decoded;
427
+
428
+ const codResp = parseInt(extractTagContent(bloque, 'codResp') ?? '-1', 10);
429
+ const descResp = extractTagContent(bloque, 'descResp') ?? '';
430
+ return { codResp, descResp };
431
+ }
432
+ }
433
+
434
+ module.exports = WsReclamo;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devlas/dte-sii",
3
- "version": "2.12.8",
3
+ "version": "2.12.10",
4
4
  "description": "Facturación y boletas electrónicas para el SII de Chile. Genera, timbra, firma y envía DTEs, libros electrónicos y automatiza la certificación.",
5
5
  "main": "index.js",
6
6
  "types": "dte-sii.d.ts",
package/utils/pfx.js CHANGED
@@ -10,9 +10,24 @@
10
10
  */
11
11
 
12
12
  const fs = require('fs');
13
+ const https = require('https');
14
+ const crypto = require('crypto');
13
15
  const forge = require('node-forge');
14
16
  const { certError, ERROR_CODES } = require('./error');
15
17
 
18
+ /**
19
+ * Flags TLS que la infraestructura legacy del SII requiere para completar la
20
+ * autenticación con certificado cliente (mismo set que SiiPortalAuth.SII_TLS_OPTS).
21
+ * Sin `maxVersion: 'TLSv1.2'` + renegociación insegura habilitada, el handshake
22
+ * de client-cert contra los servidores del SII no se completa correctamente.
23
+ */
24
+ const SII_LEGACY_TLS_FLAGS = {
25
+ maxVersion: 'TLSv1.2',
26
+ secureOptions:
27
+ crypto.constants.SSL_OP_ALLOW_UNSAFE_LEGACY_RENEGOTIATION |
28
+ crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
29
+ };
30
+
16
31
  /**
17
32
  * Resultado de cargar un PFX
18
33
  * @typedef {Object} PfxData
@@ -217,17 +232,56 @@ function getDaysUntilExpiry(notAfter) {
217
232
 
218
233
  /**
219
234
  * Crear opciones TLS desde datos PFX
235
+ *
236
+ * IMPORTANTE: `got` (usado por SiiSession) remapea su opción `https` a un set
237
+ * fijo de claves y solo reconoce `certificate` para el certificado cliente —
238
+ * NO `cert` (ver got/dist/source/core/index.js, "HTTPS options remapping").
239
+ * Se incluyen ambas claves (`cert` para consumidores que usan `https` nativo,
240
+ * `certificate` para got) para que el certificado se envíe en ambos casos.
241
+ *
220
242
  * @param {PfxData} pfxData - Datos del PFX
221
243
  * @returns {Object} Opciones TLS para https/got
222
244
  */
223
245
  function createTlsOptions(pfxData) {
246
+ const certPem = pfxData.certificateChainPem || pfxData.certificatePem;
224
247
  return {
225
248
  key: pfxData.privateKeyPem,
226
- cert: pfxData.certificateChainPem || pfxData.certificatePem,
249
+ cert: certPem,
250
+ certificate: certPem,
227
251
  rejectUnauthorized: false,
228
252
  };
229
253
  }
230
254
 
255
+ /**
256
+ * Crea un https.Agent nativo con el certificado cliente + los flags TLS legacy
257
+ * que el SII requiere. `got` no reenvía `secureOptions`/`maxVersion` a través de
258
+ * su opción `https` bajo ningún nombre de clave — la única forma de aplicarlos
259
+ * es con un Agent nativo pasado como `agent.https` en las opciones de got.
260
+ *
261
+ * @param {PfxData} pfxData - Datos del PFX
262
+ * @returns {https.Agent}
263
+ */
264
+ function createTlsAgent(pfxData) {
265
+ return createTlsAgentFromPem(pfxData.privateKeyPem, pfxData.certificateChainPem || pfxData.certificatePem);
266
+ }
267
+
268
+ /**
269
+ * Crea un https.Agent nativo desde un par key/cert PEM ya extraído (ej. desde
270
+ * una instancia de Certificado), con los mismos flags legacy que createTlsAgent.
271
+ *
272
+ * @param {string} keyPem
273
+ * @param {string} certPem
274
+ * @returns {https.Agent}
275
+ */
276
+ function createTlsAgentFromPem(keyPem, certPem) {
277
+ return new https.Agent({
278
+ ...SII_LEGACY_TLS_FLAGS,
279
+ key: keyPem,
280
+ cert: certPem,
281
+ rejectUnauthorized: false,
282
+ });
283
+ }
284
+
231
285
  module.exports = {
232
286
  loadPfxFromBuffer,
233
287
  loadPfxFromFile,
@@ -236,4 +290,6 @@ module.exports = {
236
290
  isCertificateExpired,
237
291
  getDaysUntilExpiry,
238
292
  createTlsOptions,
293
+ createTlsAgent,
294
+ createTlsAgentFromPem,
239
295
  };