@devlas/dte-sii 2.11.0 → 2.12.1
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 +33 -5
- package/LICENSE +27 -27
- package/SiiSession.js +56 -17
- package/WsReclamo.js +434 -434
- package/cert/comunaOficina.js +458 -458
- package/cert/index.js +122 -122
- package/cert/types.js +328 -328
- package/dte-sii.d.ts +3 -0
- package/package.json +1 -1
- package/test-qdetestlibro.js +174 -174
- package/utils/pfx.js +57 -21
- package/test-muestras.js +0 -180
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, '&')
|
|
105
|
-
.replace(/</g, '<')
|
|
106
|
-
.replace(/>/g, '>');
|
|
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(/</g, '<').replace(/>/g, '>').replace(/&/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(/</g, '<')
|
|
379
|
-
.replace(/>/g, '>')
|
|
380
|
-
.replace(/&/g, '&')
|
|
381
|
-
.replace(/"/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(/</g, '<')
|
|
422
|
-
.replace(/>/g, '>')
|
|
423
|
-
.replace(/&/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, '&')
|
|
105
|
+
.replace(/</g, '<')
|
|
106
|
+
.replace(/>/g, '>');
|
|
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(/</g, '<').replace(/>/g, '>').replace(/&/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(/</g, '<')
|
|
379
|
+
.replace(/>/g, '>')
|
|
380
|
+
.replace(/&/g, '&')
|
|
381
|
+
.replace(/"/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(/</g, '<')
|
|
422
|
+
.replace(/>/g, '>')
|
|
423
|
+
.replace(/&/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;
|