@devlas/dte-sii 2.8.3 → 2.9.3

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/SiiSession.js CHANGED
@@ -264,6 +264,76 @@ class SiiSession {
264
264
  return { success: true, response: redirected.response };
265
265
  }
266
266
 
267
+ /**
268
+ * Cierra la sesión activa en el SII para liberar el cupo de sesiones.
269
+ * Es seguro llamar aunque no haya sesión activa.
270
+ */
271
+ async logout() {
272
+ if (!this.cookieJar) return;
273
+ // El SII usa estos endpoints según el tipo de autenticación.
274
+ // Intentamos ambos; ignoramos errores.
275
+ const logoutUrls = [
276
+ `https://${this.baseHost}/cgi_AUT2000/autLogout.cgi`,
277
+ 'https://www.sii.cl/AUT2000/autLogout.cgi',
278
+ 'https://herculesr.sii.cl/cgi_AUT2000/autLogout.cgi',
279
+ ];
280
+ for (const url of logoutUrls) {
281
+ try {
282
+ await this.request(url, { method: 'GET' });
283
+ } catch (_) {}
284
+ }
285
+ this.cookieJar = '';
286
+ }
287
+
288
+ /**
289
+ * Detecta la página de "demasiadas sesiones" y, si el SII ofrece un form
290
+ * para cerrar las sesiones anteriores, lo envía automáticamente.
291
+ * Devuelve true si se envió el form de cierre (hay que reintentar la sesión).
292
+ * @private
293
+ */
294
+ async _tryForceCloseSessions(body) {
295
+ if (!body || !body.includes('superado el m')) return false;
296
+
297
+ // Guardar HTML para diagnóstico
298
+ try {
299
+ const fs = require('fs');
300
+ const path = require('path');
301
+ const dbgDir = path.resolve(__dirname, '..', 'devlas-cloud-api-node', 'debug', 'sii-sessions');
302
+ fs.mkdirSync(dbgDir, { recursive: true });
303
+ fs.writeFileSync(path.join(dbgDir, `demasiadas-sesiones-${Date.now()}.html`), body, 'utf-8');
304
+ } catch (_) {}
305
+
306
+ // El SII a veces incluye un form o link para cerrar sesiones anteriores
307
+ // Buscamos: action con "CierraAnt", "cerrar", "logout" o "Anular"
308
+ const formActionMatch = body.match(/<form[^>]+action=["']([^"']*(?:CierraAnt|cerrar|Logout|anular)[^"']*)/i);
309
+ if (formActionMatch) {
310
+ const closeUrl = formActionMatch[1];
311
+ const inputs = SiiSession.extractInputValues(body);
312
+ console.warn(`[SiiSession] Detectadas demasiadas sesiones — cerrando sesiones anteriores en ${closeUrl}...`);
313
+ try {
314
+ await this.submitForm(closeUrl, { ...inputs, ACEPTAR: 'Aceptar' });
315
+ return true;
316
+ } catch (e) {
317
+ console.warn(`[SiiSession] No se pudo cerrar sesiones anteriores: ${e.message}`);
318
+ }
319
+ }
320
+
321
+ // Alternativa: link directo (href) a CierraAnt o similar
322
+ const linkMatch = body.match(/href=["']([^"']*CierraAnt[^"']*)/i);
323
+ if (linkMatch) {
324
+ const closeUrl = new URL(linkMatch[1], `https://${this.baseHost}`).toString();
325
+ console.warn(`[SiiSession] Cerrando sesiones anteriores via GET ${closeUrl}...`);
326
+ try {
327
+ await this.request(closeUrl, { method: 'GET' });
328
+ return true;
329
+ } catch (e) {
330
+ console.warn(`[SiiSession] No se pudo cerrar sesiones anteriores: ${e.message}`);
331
+ }
332
+ }
333
+
334
+ return false;
335
+ }
336
+
267
337
  /**
268
338
  * Asegura una sesión autenticada para acceder a una página
269
339
  * @param {string} targetPath - Ruta del recurso
@@ -278,9 +348,25 @@ class SiiSession {
278
348
 
279
349
  // Detectar bloqueo por demasiadas sesiones
280
350
  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[ERR] ${errorMsg}\n`);
283
- throw new Error(errorMsg);
351
+ // Intentar cerrar sesiones anteriores automáticamente si el SII lo ofrece
352
+ const closed = await this._tryForceCloseSessions(response.body);
353
+ if (closed) {
354
+ // Reintentar la sesión después de cerrar las anteriores
355
+ console.warn('[SiiSession] Sesiones anteriores cerradas — reintentando autenticación...');
356
+ response = await this.request(targetUrl, { method: 'GET' });
357
+ const redirected2 = await this.followRedirects(response);
358
+ response = redirected2.response;
359
+ // Si sigue con demasiadas sesiones, lanzar error
360
+ if (response.body && response.body.includes('superado el m')) {
361
+ const errorMsg = 'SII: Demasiadas sesiones abiertas. Espera ~30 min o cierra sesión manualmente en el portal SII.';
362
+ console.error(`\n[ERR] ${errorMsg}\n`);
363
+ throw new Error(errorMsg);
364
+ }
365
+ } else {
366
+ const errorMsg = 'SII: Demasiadas sesiones abiertas. Espera ~30 min o cierra sesión manualmente en el portal SII.';
367
+ console.error(`\n[ERR] ${errorMsg}\n`);
368
+ throw new Error(errorMsg);
369
+ }
284
370
  }
285
371
 
286
372
  // Si requiere autenticación
@@ -291,9 +377,23 @@ class SiiSession {
291
377
 
292
378
  // Verificar si el login resultó en bloqueo por sesiones
293
379
  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[ERR] ${errorMsg}\n`);
296
- throw new Error(errorMsg);
380
+ // Intentar cerrar sesiones anteriores automáticamente
381
+ const closed = await this._tryForceCloseSessions(response.body);
382
+ if (closed) {
383
+ console.warn('[SiiSession] Sesiones anteriores cerradas post-login — reintentando...');
384
+ const retry = await this.request(targetUrl, { method: 'GET' });
385
+ const redirected3 = await this.followRedirects(retry);
386
+ response = redirected3.response;
387
+ if (response.body && response.body.includes('superado el m')) {
388
+ const errorMsg = 'SII: Demasiadas sesiones abiertas. Espera ~30 min o cierra sesión manualmente en el portal SII.';
389
+ console.error(`\n[ERR] ${errorMsg}\n`);
390
+ throw new Error(errorMsg);
391
+ }
392
+ } else {
393
+ const errorMsg = 'SII: Demasiadas sesiones abiertas. Espera ~30 min o cierra sesión manualmente en el portal SII.';
394
+ console.error(`\n[ERR] ${errorMsg}\n`);
395
+ throw new Error(errorMsg);
396
+ }
297
397
  }
298
398
  }
299
399
  }
package/WsReclamo.js ADDED
@@ -0,0 +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;
package/index.js CHANGED
@@ -35,6 +35,9 @@ const LibroGuia = require('./LibroGuia');
35
35
  // Helpers para certificación
36
36
  const CertFolioHelper = require('./cert/CertFolioHelper');
37
37
 
38
+ // WS Aceptación/Reclamo DTE (v2.9.0)
39
+ const WsReclamo = require('./WsReclamo');
40
+
38
41
  const utils = require('./utils');
39
42
 
40
43
  // Re-exports de utilidades para compatibilidad
@@ -240,6 +243,11 @@ module.exports = {
240
243
  // ─────────────────────────────────────────
241
244
  CertFolioHelper,
242
245
 
246
+ // ─────────────────────────────────────────
247
+ // WS Aceptación/Reclamo DTE (v2.9.0)
248
+ // ─────────────────────────────────────────
249
+ WsReclamo,
250
+
243
251
  // ─────────────────────────────────────────
244
252
  // UTILIDADES (todo el namespace)
245
253
  // ─────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devlas/dte-sii",
3
- "version": "2.8.3",
3
+ "version": "2.9.3",
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",