@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/CafSolicitor.js +151 -21
- package/DTE.js +44 -15
- package/EnviadorSII.js +28 -11
- package/Envio.js +12 -8
- package/FolioService.js +263 -94
- package/SiiPortalAuth.js +114 -1
- package/SiiSession.js +106 -6
- package/WsReclamo.js +434 -0
- package/index.js +8 -0
- package/package.json +1 -1
- package/utils/endpoints.js +87 -0
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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, '&')
|
|
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;
|
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.
|
|
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",
|