@devlas/dte-sii 2.11.0 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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;