@devlas/dte-sii 2.12.26 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SiiPortalAuth.js CHANGED
@@ -26,11 +26,12 @@ const https = require('https');
26
26
  const http = require('http');
27
27
  const fs = require('fs');
28
28
  const path = require('path');
29
- const os = require('os');
30
29
  const { URL } = require('url');
31
30
  const forge = require('node-forge');
32
31
  const crypto = require('crypto');
33
32
  const SiiSessionStore = require('./SiiSessionStore');
33
+ const { resolveDataDir } = require('./utils/paths');
34
+ const { registrarHttpDebug } = require('./utils/httpDebug');
34
35
 
35
36
  function _cookieObjToStr(obj) {
36
37
  return Object.entries(obj).map(([k, v]) => `${k}=${v}`).join('; ');
@@ -57,10 +58,12 @@ const SII_TLS_OPTS = {
57
58
  // ─── Ruta del caché de sesión ─────────────────────────────────────────────────
58
59
  // Guarda las cookies NETSCAPE_LIVEWIRE.* para reusar entre ejecuciones y evitar
59
60
  // el error "máximo de sesiones autenticadas" del SII.
60
- const SESSION_CACHE_PATH = path.join(
61
- process.env.DATADIR || path.join(os.homedir(), 'AppData', 'Roaming', 'POS'),
62
- 'sii_session_cache.json'
63
- );
61
+ //
62
+ // El directorio lo define el consumidor vía DATADIR; el fallback es os.tmpdir(),
63
+ // neutral respecto del sistema operativo. Antes caía a `~/AppData/Roaming/POS`,
64
+ // una convención de un producto Windows específico hardcodeada en una librería
65
+ // genérica de DTE — en Linux creaba esa ruta igual, porque Node no la valida.
66
+ const SESSION_CACHE_PATH = path.join(resolveDataDir(), 'sii_session_cache.json');
64
67
 
65
68
  /**
66
69
  * Registro global de instancias SiiPortalAuth por certHash (singleton por certificado).
@@ -196,6 +199,7 @@ class SiiPortalAuth {
196
199
 
197
200
  _request(urlStr, { method = 'GET', cookieJar = {}, body = null, headers = {}, usarCert = false } = {}) {
198
201
  return new Promise((resolve, reject) => {
202
+ const _t0 = Date.now();
199
203
  const url = new URL(urlStr);
200
204
  const isHttps = url.protocol === 'https:';
201
205
 
@@ -248,7 +252,23 @@ class SiiPortalAuth {
248
252
  const encoding = /charset=utf-?8/i.test(ct)
249
253
  ? 'utf8'
250
254
  : (isSiiHost || /iso-8859|latin-1|windows-1252/i.test(ct)) ? 'latin1' : 'utf8';
251
- resolve({ status: res.statusCode, headers: res.headers, body: buf.toString(encoding), cookieJar });
255
+ const bodyStr = buf.toString(encoding);
256
+
257
+ // Cliente HTTP independiente de SiiSession (https nativo, sin código en común):
258
+ // necesita su propio hook o quedarían fuera las llamadas del flujo de
259
+ // verificación de autorización, que hoy no dejan ningún rastro.
260
+ registrarHttpDebug({
261
+ url: urlStr,
262
+ method,
263
+ status: res.statusCode,
264
+ headers: res.headers,
265
+ body: bodyStr,
266
+ reqBody: body,
267
+ ms: Date.now() - _t0,
268
+ cliente: 'SiiPortalAuth',
269
+ });
270
+
271
+ resolve({ status: res.statusCode, headers: res.headers, body: bodyStr, cookieJar });
252
272
  });
253
273
  });
254
274
 
@@ -1036,6 +1056,111 @@ if (!fs.existsSync(SESSION_CACHE_PATH)) {
1036
1056
  SiiSessionStore.delete(hash);
1037
1057
  }
1038
1058
  }
1059
+
1060
+ /**
1061
+ * Autentica en el portal SII y devuelve los datos frescos del emisor
1062
+ * (resolución + datos del contribuyente) ya normalizados, junto con el
1063
+ * cookieJar de la sesión iniciada para reutilizarla.
1064
+ *
1065
+ * Es una operación completa del portal —autenticar, consultar dos endpoints,
1066
+ * reintentar con sesión fresca si la cacheada quedó inservible y normalizar el
1067
+ * resultado—, así que vive acá y no en el consumidor: no tiene ningún
1068
+ * conocimiento del proyecto que la use.
1069
+ *
1070
+ * @param {Object} opts
1071
+ * @param {Buffer} opts.pfxBuffer - Contenido del .pfx
1072
+ * @param {string} [opts.pfxPassword] - Contraseña del .pfx
1073
+ * @param {string} [opts.rutEmpresa] - RUT de la empresa (acepta puntos). Si no se
1074
+ * pasa, se intenta deducir de las cookies del portal tras autenticar.
1075
+ * @param {Function} [opts.onAviso] - Callback para avisos no fatales (default: console.warn).
1076
+ * @returns {Promise<{ emisor: Object, cookieJar: Object }>}
1077
+ * @throws Si no puede autenticar, si el RUT es inválido o si faltan datos de resolución.
1078
+ */
1079
+ static async obtenerEmisor({ pfxBuffer, pfxPassword = '', rutEmpresa = '', onAviso } = {}) {
1080
+ const aviso = onAviso || ((msg) => console.warn(msg));
1081
+ const auth = new SiiPortalAuth({ pfxBuffer, pfxPassword });
1082
+
1083
+ // Se resuelve antes de autenticar para poder reintentar con sesión fresca.
1084
+ let rutNum, dv;
1085
+ if (rutEmpresa) {
1086
+ const rutLimpio = String(rutEmpresa).replace(/\./g, '');
1087
+ const match = rutLimpio.match(/^(\d+)-([0-9Kk])$/);
1088
+ if (!match) {
1089
+ throw new Error(`RUT empresa inválido: "${rutEmpresa}". Formato esperado: "78206276-K"`);
1090
+ }
1091
+ rutNum = match[1];
1092
+ dv = match[2].toUpperCase();
1093
+ }
1094
+
1095
+ let cookieJar = await auth.autenticar();
1096
+
1097
+ const rutDesdeCookies = (jar) => [
1098
+ jar['NETSCAPE_LIVEWIRE.rutm'] || jar['NETSCAPE_LIVEWIRE.rut'] || null,
1099
+ jar['NETSCAPE_LIVEWIRE.dvm'] || jar['NETSCAPE_LIVEWIRE.dv'] || null,
1100
+ ];
1101
+
1102
+ if (!rutNum) {
1103
+ [rutNum, dv] = rutDesdeCookies(cookieJar);
1104
+ if (!rutNum || !dv) {
1105
+ throw new Error(
1106
+ 'No se pudo determinar el RUT empresa desde las cookies del portal SII. ' +
1107
+ 'Indica el RUT explícitamente en opts.rutEmpresa.',
1108
+ );
1109
+ }
1110
+ }
1111
+
1112
+ // Retry con sesión fresca: a veces la sesión cacheada pasa la validación básica
1113
+ // pero ad_empresa2 devuelve otra página (ej. "Actualización de Datos del
1114
+ // Contribuyente"). Secuencial a propósito: compartir el cookieJar en paralelo
1115
+ // genera una race condition — ce_consulta_muestra_e puede sobreescribir la cookie
1116
+ // Tivoli (TS0xxxxxxx) que ad_empresa1 dejó, y ad_empresa2 responde vacío.
1117
+ let datosResol, datosContrib;
1118
+ for (let intento = 1; intento <= 2; intento++) {
1119
+ try {
1120
+ datosResol = await auth.obtenerDatosEmpresa(rutNum, dv, cookieJar);
1121
+ datosContrib = await auth.obtenerDatosContribuyente(rutNum, dv, cookieJar).catch((e) => {
1122
+ aviso(`[SiiPortalAuth] obtenerDatosContribuyente falló (no crítico): ${e.message}`);
1123
+ return null;
1124
+ });
1125
+ break;
1126
+ } catch (e) {
1127
+ if (intento === 1) {
1128
+ aviso(`[SiiPortalAuth] Sesión cacheada inválida (${e.message}). Re-autenticando...`);
1129
+ SiiPortalAuth.limpiarSesionCache();
1130
+ cookieJar = await auth.autenticar();
1131
+ if (!rutEmpresa) {
1132
+ const [r, d] = rutDesdeCookies(cookieJar);
1133
+ rutNum = r || rutNum;
1134
+ dv = d || dv;
1135
+ }
1136
+ continue;
1137
+ }
1138
+ throw e;
1139
+ }
1140
+ }
1141
+
1142
+ if (!datosResol?.fch_resol) {
1143
+ throw new Error(
1144
+ `No se encontraron datos de resolución para ${rutNum}-${dv} en el portal SII.\n` +
1145
+ 'Verifica que la empresa esté habilitada para emitir DTE.',
1146
+ );
1147
+ }
1148
+
1149
+ // fch_resol ya viene normalizada a YYYY-MM-DD desde obtenerDatosEmpresa
1150
+ const emisor = {
1151
+ rut: `${rutNum}-${dv}`,
1152
+ razon_social: datosContrib?.razonSocial || datosResol.razonSocial || '',
1153
+ giro: datosContrib?.actividades?.[0]?.descripcion || datosContrib?.glosa || '',
1154
+ acteco: datosContrib?.acteco || datosContrib?.actividades?.[0]?.codigo || '',
1155
+ direccion: datosContrib?.direccion || '',
1156
+ comuna: datosContrib?.comuna || '',
1157
+ ciudad: datosContrib?.ciudad || datosContrib?.comuna || '',
1158
+ fch_resol: datosResol.fch_resol,
1159
+ nro_resol: datosResol.nro_resol ?? 0,
1160
+ };
1161
+
1162
+ return { emisor, cookieJar };
1163
+ }
1039
1164
  }
1040
1165
 
1041
1166
  module.exports = SiiPortalAuth;
package/SiiSession.js CHANGED
@@ -20,6 +20,8 @@ const {
20
20
  getHost,
21
21
  createScopedLogger,
22
22
  } = require('./utils');
23
+ const { resolveDebugDir, saveDebugFile } = require('./utils/paths');
24
+ const { registrarHttpDebug } = require('./utils/httpDebug');
23
25
 
24
26
  const log = createScopedLogger('SiiSession');
25
27
 
@@ -34,6 +36,8 @@ class SiiSession {
34
36
  * @param {string} [options.pfxPath] - Ruta al archivo PFX
35
37
  * @param {string} [options.pfxPassword] - Contraseña del PFX
36
38
  * @param {Object} [options.certificado] - Instancia de Certificado
39
+ * @param {string} [options.debugDir] - Dónde escribir HTML de diagnóstico. Si no se
40
+ * provee cae a `SII_DEBUG_DIR`; si tampoco existe, no se escribe nada.
37
41
  */
38
42
  constructor(options = {}) {
39
43
  // Validar parámetros obligatorios usando validador centralizado
@@ -41,9 +45,10 @@ class SiiSession {
41
45
  throw new Error('SiiSession: options.ambiente es obligatorio');
42
46
  }
43
47
  this.ambiente = validateAmbiente(options.ambiente);
44
-
48
+
45
49
  // Usar host centralizado desde endpoints
46
50
  this.baseHost = getHost(this.ambiente);
51
+ this.debugDir = options.debugDir || null;
47
52
  this.cookieJar = '';
48
53
  this.tlsOptions = null;
49
54
  // Agent nativo con el certificado + flags TLS legacy del SII. got no reenvía
@@ -204,6 +209,7 @@ class SiiSession {
204
209
  * @private
205
210
  */
206
211
  async _doRequest(url, options, isPost) {
212
+ const _t0 = Date.now();
207
213
  const res = await got(url, {
208
214
  method: options.method || 'GET',
209
215
  headers: {
@@ -257,6 +263,21 @@ class SiiSession {
257
263
  bodyStr = buffer.toString('utf8');
258
264
  }
259
265
 
266
+ // Punto único por el que pasa TODO el tráfico de SiiSession: cada salto de
267
+ // redirect, cada submit de formulario y cada reintento. Enganchar acá cubre a
268
+ // SiiCertificacion, CertRunner, CafSolicitor, FolioService, BoletaCert y
269
+ // SetsProvider sin tocar ninguno.
270
+ registrarHttpDebug({
271
+ url,
272
+ method: options.method || 'GET',
273
+ status: res.statusCode,
274
+ headers: res.headers,
275
+ body: bodyStr,
276
+ reqBody: options.body,
277
+ ms: Date.now() - _t0,
278
+ cliente: 'SiiSession',
279
+ });
280
+
260
281
  return {
261
282
  status: res.statusCode,
262
283
  headers: res.headers,
@@ -352,14 +373,11 @@ class SiiSession {
352
373
  async _tryForceCloseSessions(body) {
353
374
  if (!body || !body.includes('superado el m')) return false;
354
375
 
355
- // Guardar HTML para diagnóstico
356
- try {
357
- const fs = require('fs');
358
- const path = require('path');
359
- const dbgDir = path.resolve(__dirname, '..', 'devlas-cloud-api-node', 'debug', 'sii-sessions');
360
- fs.mkdirSync(dbgDir, { recursive: true });
361
- fs.writeFileSync(path.join(dbgDir, `demasiadas-sesiones-${Date.now()}.html`), body, 'utf-8');
362
- } catch (_) {}
376
+ // Guardar HTML para diagnóstico — solo si el consumidor definió dónde.
377
+ // Antes esto apuntaba a `../devlas-cloud-api-node/debug/sii-sessions`: la librería
378
+ // nombraba a un repo consumidor y asumía que estaba como carpeta hermana, así que
379
+ // en cualquier otra instalación escribía en un lugar inesperado o fallaba en silencio.
380
+ saveDebugFile(resolveDebugDir(this.debugDir), `demasiadas-sesiones-${Date.now()}.html`, body);
363
381
 
364
382
  // El SII a veces incluye un form o link para cerrar sesiones anteriores
365
383
  // Buscamos: action con "CierraAnt", "cerrar", "logout" o "Anular"
package/WsReclamo.js CHANGED
@@ -15,6 +15,10 @@
15
15
  */
16
16
 
17
17
  const forge = require('node-forge');
18
+ const { fetchRegistrado } = require('./utils/httpDebug');
19
+
20
+ /** `fetchRegistrado` con el cliente ya fijado, para no repetirlo en cada llamada. */
21
+ const fetchRegistradoWs = (url, opciones) => fetchRegistrado(url, opciones, 'WsReclamo');
18
22
  const {
19
23
  SOAP_ENDPOINTS,
20
24
  WSRECLAMO_ENDPOINTS,
@@ -82,14 +86,13 @@ class WsReclamo {
82
86
  <soapenv:Body><getSeed/></soapenv:Body>
83
87
  </soapenv:Envelope>`;
84
88
 
85
- const res = await fetch(this._seedUrl, {
89
+ const { response: res, text: xml } = await fetchRegistradoWs(this._seedUrl, {
86
90
  method: 'POST',
87
91
  headers: { 'Content-Type': 'text/xml; charset=utf-8', SOAPAction: '' },
88
92
  body: envelope,
89
93
  });
90
94
  if (!res.ok) throw siiError(`Error semilla: ${res.status}`, ERROR_CODES.SII_CONNECTION_FAILED);
91
95
 
92
- const xml = await res.text();
93
96
  const semilla = extractTagContent(decodeXmlEntities(xml), 'SEMILLA');
94
97
  if (!semilla) throw siiError('No se obtuvo semilla del SII', ERROR_CODES.SII_INVALID_RESPONSE);
95
98
  return semilla;
@@ -114,14 +117,13 @@ class WsReclamo {
114
117
  </soapenv:Body>
115
118
  </soapenv:Envelope>`;
116
119
 
117
- const res = await fetch(this._tokenUrl, {
120
+ const { response: res, text: xml } = await fetchRegistradoWs(this._tokenUrl, {
118
121
  method: 'POST',
119
122
  headers: { 'Content-Type': 'text/xml; charset=utf-8', SOAPAction: '' },
120
123
  body: envelope,
121
124
  });
122
125
  if (!res.ok) throw siiError(`Error token: ${res.status}`, ERROR_CODES.SII_CONNECTION_FAILED);
123
126
 
124
- const xml = await res.text();
125
127
  const decoded = xml.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
126
128
  const token = extractTagContent(decoded, 'TOKEN');
127
129
  if (!token) throw siiError('No se obtuvo TOKEN del SII', ERROR_CODES.SII_AUTH_FAILED);
@@ -247,7 +249,7 @@ class WsReclamo {
247
249
  </soapenv:Body>
248
250
  </soapenv:Envelope>`;
249
251
 
250
- const res = await fetch(this._wsUrl, {
252
+ const { response: res, text: xml } = await fetchRegistradoWs(this._wsUrl, {
251
253
  method: 'POST',
252
254
  headers: {
253
255
  'Content-Type': 'text/xml; charset=utf-8',
@@ -268,7 +270,6 @@ class WsReclamo {
268
270
  throw siiError(`WSRECLAMO ${metodo}: HTTP ${res.status}`, ERROR_CODES.SII_CONNECTION_FAILED);
269
271
  }
270
272
 
271
- const xml = await res.text();
272
273
  return xml;
273
274
  }
274
275
 
@@ -22,6 +22,7 @@
22
22
 
23
23
  const path = require('path');
24
24
  const fs = require('fs');
25
+ const { registrarHttpDebug } = require('../utils/httpDebug');
25
26
 
26
27
  class BoletaCert {
27
28
  /**
@@ -663,14 +664,23 @@ class BoletaCert {
663
664
 
664
665
  // Hacer request
665
666
  const requestWithCert = (options, payload) => new Promise((resolve, reject) => {
667
+ const t0 = Date.now();
666
668
  const req = https.request({ ...options, ...tlsOptions }, (res) => {
667
669
  let data = '';
668
670
  res.on('data', (chunk) => (data += chunk));
669
- res.on('end', () => resolve({
670
- status: res.statusCode,
671
- text: data,
672
- headers: res.headers,
673
- }));
671
+ res.on('end', () => {
672
+ registrarHttpDebug({
673
+ url: `https://${options.hostname}${options.path}`,
674
+ method: options.method || 'GET',
675
+ status: res.statusCode,
676
+ headers: res.headers,
677
+ body: data,
678
+ reqBody: payload,
679
+ ms: Date.now() - t0,
680
+ cliente: 'BoletaCert',
681
+ });
682
+ resolve({ status: res.statusCode, text: data, headers: res.headers });
683
+ });
674
684
  });
675
685
  req.on('error', reject);
676
686
  if (payload) req.write(payload);
@@ -784,14 +794,23 @@ class BoletaCert {
784
794
 
785
795
  // Hacer request
786
796
  const requestWithCert = (options, payload) => new Promise((resolve, reject) => {
797
+ const t0 = Date.now();
787
798
  const req = https.request({ ...options, ...tlsOptions }, (res) => {
788
799
  let data = '';
789
800
  res.on('data', (chunk) => (data += chunk));
790
- res.on('end', () => resolve({
791
- status: res.statusCode,
792
- text: data,
793
- headers: res.headers,
794
- }));
801
+ res.on('end', () => {
802
+ registrarHttpDebug({
803
+ url: `https://${options.hostname}${options.path}`,
804
+ method: options.method || 'GET',
805
+ status: res.statusCode,
806
+ headers: res.headers,
807
+ body: data,
808
+ reqBody: payload,
809
+ ms: Date.now() - t0,
810
+ cliente: 'BoletaCert',
811
+ });
812
+ resolve({ status: res.statusCode, text: data, headers: res.headers });
813
+ });
795
814
  });
796
815
  req.on('error', reject);
797
816
  if (payload) req.write(payload);