@devlas/dte-sii 2.8.3 → 2.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CafSolicitor.js CHANGED
@@ -200,6 +200,11 @@ class CafSolicitor {
200
200
  return { success: false, error: 'El SII devolvió página de autenticación' };
201
201
  }
202
202
 
203
+ // Detectar error de límite diario del SII
204
+ if (response.body && response.body.includes('menor o igual al m')) {
205
+ return { success: false, error: 'Límite diario de folios SII agotado (MAX_AUTOR). Reintenta mañana.' };
206
+ }
207
+
203
208
  return { success: false, error: 'No se obtuvo CAF en la respuesta' };
204
209
 
205
210
  } catch (err) {
@@ -233,11 +238,15 @@ class CafSolicitor {
233
238
  // Selección de tipo de documento
234
239
  if (currentHtml.includes('COD_DOCTO')) {
235
240
  const selectInputs = SiiSession.extractInputValues(currentHtml);
241
+ // Respetar MAX_AUTOR que el SII informa en el formulario.
242
+ const maxAutorSelect = parseInt(selectInputs.MAX_AUTOR || '999', 10);
243
+ const cantSelect = Math.min(cantidad, maxAutorSelect);
236
244
  const selectFields = {
237
245
  ...selectInputs,
238
246
  RUT_EMP: rut,
239
247
  DV_EMP: dv,
240
248
  COD_DOCTO: tipoDte,
249
+ CANT_DOCTOS: cantSelect,
241
250
  };
242
251
 
243
252
  response = await this.session.submitForm('/cvc_cgi/dte/of_solicita_folios_dcto', selectFields);
@@ -262,12 +271,15 @@ class CafSolicitor {
262
271
  const formAction3 = SiiSession.extractFormAction(currentHtml) || '/cvc_cgi/dte/of_confirma_folio';
263
272
  const inputs3 = SiiSession.extractInputValues(currentHtml);
264
273
 
274
+ // Respetar MAX_AUTOR si viene en este paso también
275
+ const maxAutorStep3 = parseInt(inputs3.MAX_AUTOR || '999', 10);
276
+ const cantStep3 = Math.min(cantidad, maxAutorStep3);
265
277
  const step3Fields = {
266
278
  ...inputs3,
267
279
  RUT_EMP: rut,
268
280
  DV_EMP: dv,
269
281
  COD_DOCTO: tipoDte,
270
- CANT_DOCTOS: cantidad,
282
+ CANT_DOCTOS: cantStep3,
271
283
  ACEPTAR: 'Solicitar Numeración',
272
284
  };
273
285
 
@@ -321,9 +333,17 @@ class CafSolicitor {
321
333
 
322
334
  const formAction = SiiSession.extractFormAction(currentHtml) || '/cvc_cgi/dte/of_genera_folio';
323
335
  const inputs = SiiSession.extractInputValues(currentHtml);
324
-
336
+
337
+ // Respetar el máximo autorizado por el SII (puede ser menor a lo solicitado)
338
+ const maxAutor = parseInt(inputs.MAX_AUTOR || '999', 10);
339
+ const folioIni = parseInt(inputs.FOLIO_INI || '1', 10);
340
+ const cantOriginal = parseInt(inputs.CANT_DOCTOS || '1', 10);
341
+ const cantReal = Math.min(cantOriginal, maxAutor);
342
+
325
343
  const fields = {
326
344
  ...inputs,
345
+ CANT_DOCTOS: String(cantReal),
346
+ FOLIO_FIN: String(folioIni + cantReal - 1),
327
347
  ACEPTAR: 'Obtener Folios',
328
348
  };
329
349
 
package/DTE.js CHANGED
@@ -71,8 +71,9 @@ class DTE {
71
71
 
72
72
  _convertirDatosSimplificados(d) {
73
73
  const esExenta = d.tipo === 41;
74
- const { detalle, mntBruto, mntExento } = this._procesarItems(d.items, esExenta);
75
- const totales = this._calcularTotales(mntBruto, mntExento, esExenta);
74
+ const precioConIva = d.precioConIva === true;
75
+ const { detalle, mntNeto, mntExento } = this._procesarItems(d.items, esExenta, precioConIva);
76
+ const totales = this._calcularTotales(mntNeto, mntExento, esExenta);
76
77
 
77
78
  const resultado = {
78
79
  Encabezado: {
@@ -86,6 +87,7 @@ class DTE {
86
87
  Receptor: {
87
88
  RUTRecep: d.receptor?.RUTRecep ?? '66666666-6',
88
89
  RznSocRecep: sanitizeSiiText(d.receptor?.RznSocRecep ?? 'Consumidor Final'),
90
+ ...(d.receptor?.GiroRecep ? { GiroRecep: d.receptor.GiroRecep } : {}),
89
91
  DirRecep: sanitizeSiiText(d.receptor?.DirRecep ?? 'Sin Direccion'),
90
92
  CmnaRecep: d.receptor?.CmnaRecep ?? 'Santiago',
91
93
  },
@@ -105,24 +107,29 @@ class DTE {
105
107
  return resultado;
106
108
  }
107
109
 
108
- _procesarItems(items, esExenta) {
109
- let mntBruto = 0;
110
+ _procesarItems(items, esExenta, precioConIva = false) {
111
+ let mntNeto = 0;
110
112
  let mntExento = 0;
111
113
 
112
114
  const detalle = items.map((item, idx) => {
113
115
  const qty = item.QtyItem || 1;
114
- const prc = item.PrcItem;
116
+ const esItemExento = esExenta || item.IndExe === 1;
117
+ // Si precioConIva=true y el ítem no es exento, el PrcItem viene con IVA incluido
118
+ // (precio al consumidor del POS) → convertir a neto dividiendo por 1+TASA_IVA.
119
+ const prc = (precioConIva && !esItemExento)
120
+ ? Math.round(item.PrcItem / (1 + TASA_IVA / 100))
121
+ : item.PrcItem;
115
122
  const montoItem = Math.round(qty * prc);
116
123
 
117
- if (esExenta || item.IndExe === 1) {
124
+ if (esItemExento) {
118
125
  mntExento += montoItem;
119
126
  } else {
120
- mntBruto += montoItem;
127
+ mntNeto += montoItem;
121
128
  }
122
129
 
123
130
  const det = {
124
131
  NroLinDet: idx + 1,
125
- ...(esExenta || item.IndExe ? { IndExe: 1 } : {}),
132
+ ...(esItemExento ? { IndExe: 1 } : {}),
126
133
  NmbItem: sanitizeSiiText(item.NmbItem),
127
134
  QtyItem: qty,
128
135
  ...(item.UnmdItem ? { UnmdItem: item.UnmdItem } : {}),
@@ -133,18 +140,21 @@ class DTE {
133
140
  return det;
134
141
  });
135
142
 
136
- return { detalle, mntBruto, mntExento };
143
+ return { detalle, mntNeto, mntExento };
137
144
  }
138
145
 
139
- _calcularTotales(mntBruto, mntExento, esExenta) {
140
- const mntNeto = esExenta ? 0 : Math.round(mntBruto / (1 + (TASA_IVA / 100)));
141
- const iva = esExenta ? 0 : (mntBruto - mntNeto);
142
- const mntTotal = mntNeto + iva + mntExento;
146
+ // PrcItem = precio neto unitario (o precio con IVA si se usa precioConIva:true).
147
+ // IVA = mntNeto * TasaIVA.
148
+ _calcularTotales(mntNeto, mntExento, esExenta) {
149
+ const neto = esExenta ? 0 : mntNeto;
150
+ const iva = esExenta ? 0 : Math.round(neto * TASA_IVA / 100);
151
+ const mntTotal = neto + iva + mntExento;
143
152
 
144
153
  const totales = {};
145
- if (mntNeto > 0) totales.MntNeto = mntNeto;
154
+ if (neto > 0) totales.MntNeto = neto;
155
+ if (neto > 0) totales.TasaIVA = TASA_IVA; // requerido en facturas
146
156
  if (mntExento > 0) totales.MntExe = mntExento;
147
- if (mntNeto > 0) totales.IVA = iva;
157
+ if (neto > 0) totales.IVA = iva;
148
158
  totales.MntTotal = mntTotal;
149
159
 
150
160
  return totales;
package/SiiPortalAuth.js CHANGED
@@ -121,7 +121,9 @@ class SiiPortalAuth {
121
121
  if (cookieStr) options.headers['Cookie'] = cookieStr;
122
122
 
123
123
  if (body) {
124
- options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
124
+ if (!options.headers['Content-Type']) {
125
+ options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
126
+ }
125
127
  options.headers['Content-Length'] = Buffer.byteLength(body);
126
128
  }
127
129
 
@@ -493,6 +495,117 @@ if (!fs.existsSync(SESSION_CACHE_PATH)) {
493
495
  fecha_autorizacion: datos['Fecha Autorización'] || null,
494
496
  };
495
497
  }
498
+
499
+ // ─── Consemitidos (www4.sii.cl/consemitidosinternetui) ───────────────────────
500
+
501
+ /**
502
+ * Navega a consemitidosinternetui para obtener el TOKEN de sesión.
503
+ * El TOKEN es el mismo valor que CSESSIONID y va como conversationId en el body.
504
+ * @private
505
+ */
506
+ async _obtenerTokenConsemitidos(cookieJar) {
507
+ await this._request('https://www4.sii.cl/consemitidosinternetui/', { cookieJar });
508
+ const token = cookieJar['TOKEN'] || cookieJar['CSESSIONID'];
509
+ if (!token) {
510
+ throw new Error('SiiPortalAuth: no se pudo obtener TOKEN de sesión de consemitidosinternetui');
511
+ }
512
+ return token;
513
+ }
514
+
515
+ /**
516
+ * Llama a un endpoint JSON de la API consemitidosinternetui.
517
+ * @private
518
+ */
519
+ async _callConsemitidos(method, data, token, cookieJar) {
520
+ const body = JSON.stringify({
521
+ metaData: {
522
+ namespace: `cl.sii.sdi.lob.diii.consemitidos.data.api.interfaces.FacadeService/${method}`,
523
+ conversationId: token,
524
+ transactionId: crypto.randomUUID(),
525
+ page: null,
526
+ },
527
+ data,
528
+ });
529
+ const res = await this._request(
530
+ `https://www4.sii.cl/consemitidosinternetui/services/data/facadeService/${method}`,
531
+ {
532
+ method: 'POST',
533
+ body,
534
+ cookieJar,
535
+ headers: {
536
+ 'Content-Type': 'application/json',
537
+ 'Accept': 'application/json, text/plain, */*',
538
+ 'Origin': 'https://www4.sii.cl',
539
+ 'Referer': 'https://www4.sii.cl/consemitidosinternetui/',
540
+ },
541
+ }
542
+ );
543
+ try {
544
+ return JSON.parse(res.body);
545
+ } catch {
546
+ throw new Error(`SiiPortalAuth: respuesta no-JSON de ${method}: ${res.body.slice(0, 300)}`);
547
+ }
548
+ }
549
+
550
+ /**
551
+ * Obtiene el detalle de DTEs emitidos o recibidos desde www4.sii.cl.
552
+ *
553
+ * @param {string} rut - RUT sin DV (ej: "78206276")
554
+ * @param {string} dv - DV (ej: "K")
555
+ * @param {string} periodo - Período YYYY-MM (ej: "2026-05")
556
+ * @param {number} operacion - 1 = compras / recibidos, 2 = ventas / emitidos
557
+ * @param {Object} [cookieJar] - Sesión ya autenticada (opcional)
558
+ * @returns {Promise<{ resumen: Array, detalles: Array }>}
559
+ */
560
+ async obtenerDetalleDtes(rut, dv, periodo, operacion = 2, cookieJar = null) {
561
+ const jar = cookieJar || await this.autenticar();
562
+ const token = await this._obtenerTokenConsemitidos(jar);
563
+
564
+ // Mapeo de convención interna → convención SII:
565
+ // interno: 1 = compras/recibidos, 2 = ventas/emitidos
566
+ // SII: 1 = emitidos, 2 = recibidos
567
+ const siiOperacion = operacion === 2 ? 1 : 2;
568
+ const esEmitidos = siiOperacion === 1;
569
+
570
+ // 1. Resumen mensual — saber qué tipos de DTE hay en el período
571
+ const resumenResp = await this._callConsemitidos('getResumen', {
572
+ periodo,
573
+ rutContribuyente: rut,
574
+ dvContribuyente: dv,
575
+ operacion: siiOperacion,
576
+ }, token, jar);
577
+
578
+ const resumen = resumenResp.data?.resumenDte ?? [];
579
+ if (resumen.length === 0) return { resumen: [], detalles: [] };
580
+
581
+ // 2. Detalle por cada tipo DTE encontrado en el resumen
582
+ const detallesArr = await Promise.all(
583
+ resumen.map(async (t) => {
584
+ // Para emitidos tipo 33/34, el SII expone un método específico
585
+ const metodo = esEmitidos && (t.tipoDoc === 33 || t.tipoDoc === 34)
586
+ ? 'getDetalleEmitidos3334'
587
+ : 'getDetalleRecibidos';
588
+ const resp = await this._callConsemitidos(metodo, {
589
+ tipoDoc: String(t.tipoDoc),
590
+ rut,
591
+ dv,
592
+ periodo,
593
+ operacion: siiOperacion,
594
+ derrCodigo: String(t.tipoDoc),
595
+ refNCD: '0',
596
+ }, token, jar);
597
+ const items = resp.dataResp?.detalles ?? [];
598
+ // Completar tipoDoc y tipoDocDesc desde el resumen si no vienen en el detalle
599
+ return items.map((d) => ({
600
+ ...d,
601
+ tipoDoc: t.tipoDoc,
602
+ tipoDocDesc: d.descTipoDoc || t.tipoDocDesc,
603
+ }));
604
+ })
605
+ );
606
+
607
+ return { resumen, detalles: detallesArr.flat() };
608
+ }
496
609
  }
497
610
 
498
611
  module.exports = SiiPortalAuth;
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.2",
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",
@@ -51,6 +51,89 @@ const SOAP_ENDPOINTS = {
51
51
  },
52
52
  };
53
53
 
54
+ /**
55
+ * Web Service SOAP oficial — Aceptación/Reclamo de DTE recibido
56
+ * Fuente: "WS Consulta y Registro de Aceptación/Reclamo a DTE recibido" v1.2, 07/08/2017, SII
57
+ *
58
+ * Métodos disponibles:
59
+ * ingresarAceptacionReclamoDoc(rutEmisor, dvEmisor, tipoDoc, folio, accionDoc)
60
+ * accionDoc: ACD | ERM | RCD | RFP | RFT
61
+ * listarEventosHistDoc(rutEmisor, dvEmisor, tipoDoc, folio)
62
+ * consultarDocDteCedible(rutEmisor, dvEmisor, tipoDoc, folio)
63
+ * consultarFechaRecepcionSii(rutEmisor, dvEmisor, tipoDoc, folio)
64
+ *
65
+ * Autenticación: token SII vía cookie `TOKEN=...` (mismo flujo seed/token de SOAP_ENDPOINTS,
66
+ * NO usa cookies NETSCAPE_LIVEWIRE del portal). Ver SiiSession.js getToken().
67
+ *
68
+ * Respuestas:
69
+ * ingresarAceptacionReclamoDoc → { codResp: number, descResp: string }
70
+ * listarEventosHistDoc → { codResp, descResp, listaEventosDoc: [{ codEvento, descEvento,
71
+ * rutResponsable, dvResponsable, fechaEvento }] }
72
+ * codEvento extra (solo en lista): NCA | ENC
73
+ * consultarDocDteCedible → { codResp: number, descResp: string }
74
+ * consultarFechaRecepcionSii → string VARCHAR(100), ej: '21-02-2017 19:02:03'
75
+ */
76
+ const WSRECLAMO_ENDPOINTS = {
77
+ certificacion: 'https://ws2.sii.cl/WSREGISTRORECLAMODTECERT/registroreclamodteservice?wsdl',
78
+ produccion: 'https://ws1.sii.cl/WSREGISTRORECLAMODTE/registroreclamodteservice?wsdl',
79
+ };
80
+
81
+ /**
82
+ * Acciones válidas para ingresarAceptacionReclamoDoc
83
+ */
84
+ const WSRECLAMO_ACCIONES = {
85
+ ACD: 'Acepta Contenido del Documento',
86
+ ERM: 'Otorga Recibo de Mercaderías o Servicios',
87
+ RCD: 'Reclamo al Contenido del Documento',
88
+ RFP: 'Reclamo por Falta Parcial de Mercaderías',
89
+ RFT: 'Reclamo por Falta Total de Mercaderías',
90
+ };
91
+
92
+ /**
93
+ * Códigos de respuesta compartidos (ingresarAceptacionReclamoDoc + listarEventosHistDoc)
94
+ * Fuente: WS v1.2, 07/08/2017, tablas de parámetros de salida
95
+ */
96
+ const WSRECLAMO_CODIGOS = {
97
+ 0: 'Acción Completada OK',
98
+ 1: 'Rut Emisor Erróneo',
99
+ 2: 'Número de Folio Erróneo',
100
+ 3: 'Tipo de documento no corresponde (distinto de 33, 34, 43)',
101
+ 4: 'Acción inválida',
102
+ 5: 'DTE ya está reclamado por XXX (RFP, RFT o RCD)',
103
+ 6: 'No se puede acusar recibo de mercadería de DTE previamente reclamado por XXX (RFP, RFT, RCD)',
104
+ 7: 'Evento registrado previamente',
105
+ 8: 'Pasados 8 días después de la recepción no es posible registrar reclamos o eventos',
106
+ 9: 'No existen registros de acuerdo a los parámetros ingresados',
107
+ 10: 'Documento no emitido y/o recibido en el SII desde el 14 de enero de 2017 en adelante',
108
+ 11: 'No se puede reclamar DTE previamente aceptado',
109
+ 12: 'No se puede dar por aceptado DTE previamente rechazado por XXX (RFP, RFT, RCD)',
110
+ 13: 'No se puede reclamar DTE previamente registrado como acuso recibo mercadería',
111
+ 14: 'Acción autorizada sólo para empresa receptora o emisora',
112
+ 15: 'Listado de eventos del documento',
113
+ 16: 'Documento no presenta eventos de reclamos o acuse de recibo',
114
+ 17: 'Acción autorizada solo para empresa receptora',
115
+ 18: 'Documento no ha sido recibido',
116
+ 19: 'Reclamo de mercaderías ya realizado',
117
+ '-1': 'Error Interno — Rut Receptor debe reintentar más tarde',
118
+ };
119
+
120
+ /**
121
+ * Códigos de respuesta exclusivos de consultarDocDteCedible
122
+ * Fuente: WS v1.2, tabla parámetros de salida consultarDocDteCedible
123
+ */
124
+ const WSRECLAMO_CODIGOS_CEDIBLE = {
125
+ 1: 'Rut Emisor Erróneo',
126
+ 2: 'Número de Folio Erróneo',
127
+ 10: 'Documento no emitido y/o recibido en el SII desde el 14 de enero de 2017 en adelante',
128
+ 18: 'Documento no ha sido recibido',
129
+ 20: 'Tipo de documento no es cedible',
130
+ 21: 'DTE No cedible — referenciado por nota de crédito de anulación del emisor dentro de los primeros 8 días',
131
+ 22: 'No existe registro de reclamo o de recepción de mercadería o servicios',
132
+ 23: 'DTE Cedible, sin reclamos',
133
+ 24: 'DTE No Cedible — reclamado por el receptor',
134
+ 25: 'DTE Cedible — habiendo pasado 8 días se entiende dado acuse de recibo',
135
+ };
136
+
54
137
  /**
55
138
  * Endpoints REST para Boletas Electrónicas
56
139
  */
@@ -213,6 +296,10 @@ module.exports = {
213
296
  SOAP_ENDPOINTS,
214
297
  REST_ENDPOINTS,
215
298
  CERT_ENDPOINTS,
299
+ WSRECLAMO_ENDPOINTS,
300
+ WSRECLAMO_ACCIONES,
301
+ WSRECLAMO_CODIGOS,
302
+ WSRECLAMO_CODIGOS_CEDIBLE,
216
303
 
217
304
  // Funciones
218
305
  getHost,