@devlas/dte-sii 2.5.11 → 2.5.12

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
@@ -1,474 +1,474 @@
1
- // Copyright (c) 2026 Devlas SpA — https://devlas.cl
2
- // Licencia MIT. Ver archivo LICENSE para mas detalles.
3
- /**
4
- * SiiPortalAuth.js
5
- *
6
- * Autenticación con certificado digital en el portal del SII (herculesr.sii.cl).
7
- * Permite obtener datos del contribuyente (fch_resol, nro_resol, razón social)
8
- * directamente desde el portal sin login manual.
9
- *
10
- * Flujo (confirmado por análisis del portal):
11
- * 1. GET zeusr.sii.cl/AUT2000/InicioAutenticacion/IngresoCertificado.html?TARGET
12
- * → obtiene cookie F5 BIG-IP de sesión
13
- * 2. POST herculesr.sii.cl/cgi_AUT2000/CAutInicio.cgi?TARGET body: referencia=TARGET
14
- * → herculesr SÍ solicita cert en el TLS handshake inicial (a diferencia de zeusr)
15
- * → SII extrae el RUT del campo serialNumber del certificado
16
- * → responde con set-cookie: NETSCAPE_LIVEWIRE.* (sesión activa)
17
- * 3. POST maullin.sii.cl/cvc_cgi/dte/ad_empresa2 body: RUT_EMP=XXXXX&DV_EMP=X
18
- * → devuelve tabla HTML con datos del contribuyente
19
- *
20
- * @module SiiPortalAuth
21
- */
22
-
23
- 'use strict';
24
-
25
- const https = require('https');
26
- const http = require('http');
27
- const fs = require('fs');
28
- const path = require('path');
29
- const os = require('os');
30
- const { URL } = require('url');
31
- const forge = require('node-forge');
32
- const crypto = require('crypto');
33
-
34
- // ─── Opciones TLS comunes para SII ────────────────────────────────────────────
35
- const SII_TLS_OPTS = {
36
- rejectUnauthorized: false,
37
- maxVersion: 'TLSv1.2',
38
- secureOptions:
39
- crypto.constants.SSL_OP_ALLOW_UNSAFE_LEGACY_RENEGOTIATION |
40
- crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
41
- };
42
-
43
- // ─── Ruta del caché de sesión ─────────────────────────────────────────────────
44
- // Guarda las cookies NETSCAPE_LIVEWIRE.* para reusar entre ejecuciones y evitar
45
- // el error "máximo de sesiones autenticadas" del SII.
46
- const SESSION_CACHE_PATH = path.join(
47
- process.env.DATADIR || path.join(os.homedir(), 'AppData', 'Roaming', 'POS'),
48
- 'sii_session_cache.json'
49
- );
50
-
51
- /**
52
- * Clase principal de autenticación con el portal SII
53
- */
54
- class SiiPortalAuth {
55
- /**
56
- * @param {Object} options
57
- * @param {Buffer} options.pfxBuffer - Buffer del archivo PFX
58
- * @param {string} options.pfxPassword - Contraseña del PFX
59
- */
60
- constructor({ pfxBuffer, pfxPassword }) {
61
- if (!pfxBuffer) throw new Error('SiiPortalAuth: pfxBuffer es obligatorio');
62
- if (pfxPassword === undefined) throw new Error('SiiPortalAuth: pfxPassword es obligatorio');
63
-
64
- const { certPem, keyPem } = SiiPortalAuth._extractPems(pfxBuffer, pfxPassword);
65
- this._certPem = certPem;
66
- this._keyPem = keyPem;
67
- // Huella para identificar de qué cert es la sesión cacheada
68
- this._certHash = crypto.createHash('sha1').update(certPem).digest('hex').slice(0, 12);
69
-
70
- this._agentePlano = new https.Agent(SII_TLS_OPTS);
71
- this._agenteCert = new https.Agent({ ...SII_TLS_OPTS, cert: certPem, key: keyPem });
72
- }
73
-
74
- /**
75
- * Extrae cert + key PEM desde un PFX usando node-forge
76
- * (node-forge soporta PKCS12 moderno AES-256 que Node nativo rechaza)
77
- * @private
78
- */
79
- static _extractPems(pfxBuffer, password) {
80
- const p12Asn1 = forge.asn1.fromDer(pfxBuffer.toString('binary'));
81
- const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password);
82
-
83
- // Clave privada
84
- const keyBags = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag });
85
- const keyBag = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag]?.[0]
86
- || p12.getBags({ bagType: forge.pki.oids.keyBag })[forge.pki.oids.keyBag]?.[0];
87
- if (!keyBag?.key) throw new Error('SiiPortalAuth: no se encontró clave privada en el PFX');
88
- const keyPem = forge.pki.privateKeyToPem(keyBag.key);
89
-
90
- // Certificado
91
- const certBags = p12.getBags({ bagType: forge.pki.oids.certBag });
92
- const certBag = certBags[forge.pki.oids.certBag]?.[0];
93
- if (!certBag?.cert) throw new Error('SiiPortalAuth: no se encontró certificado en el PFX');
94
- const certPem = forge.pki.certificateToPem(certBag.cert);
95
-
96
- return { certPem, keyPem };
97
- }
98
-
99
- // ─── HTTP helpers ────────────────────────────────────────────────────────────
100
-
101
- _request(urlStr, { method = 'GET', cookieJar = {}, body = null, headers = {}, usarCert = false } = {}) {
102
- return new Promise((resolve, reject) => {
103
- const url = new URL(urlStr);
104
- const isHttps = url.protocol === 'https:';
105
-
106
- const options = {
107
- hostname: url.hostname,
108
- port: url.port || (isHttps ? 443 : 80),
109
- path: url.pathname + url.search,
110
- method,
111
- headers: {
112
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
113
- 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
114
- 'Accept-Language': 'es-CL,es;q=0.9',
115
- 'Connection': 'keep-alive',
116
- ...headers,
117
- },
118
- };
119
-
120
- const cookieStr = Object.entries(cookieJar).map(([k, v]) => `${k}=${v}`).join('; ');
121
- if (cookieStr) options.headers['Cookie'] = cookieStr;
122
-
123
- if (body) {
124
- options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
125
- options.headers['Content-Length'] = Buffer.byteLength(body);
126
- }
127
-
128
- if (isHttps) options.agent = usarCert ? this._agenteCert : this._agentePlano;
129
-
130
- const req = (isHttps ? https : http).request(options, (res) => {
131
- // Capturar cookies
132
- for (const cookieHeader of (res.headers['set-cookie'] || [])) {
133
- const [pair] = cookieHeader.split(';');
134
- const eqIdx = pair.indexOf('=');
135
- if (eqIdx > 0) {
136
- const name = pair.slice(0, eqIdx).trim();
137
- const val = pair.slice(eqIdx + 1).trim();
138
- cookieJar[name] = val;
139
- }
140
- }
141
- const chunks = [];
142
- res.on('data', (chunk) => chunks.push(chunk));
143
- res.on('end', () => {
144
- const buf = Buffer.concat(chunks);
145
- const ct = res.headers['content-type'] || '';
146
- // Todos los hosts *.sii.cl sirven páginas ISO-8859-1; a veces no incluyen charset en Content-Type.
147
- const isSiiHost = url.hostname.endsWith('.sii.cl');
148
- const encoding = (isSiiHost || /iso-8859|latin-1|windows-1252/i.test(ct)) ? 'latin1' : 'utf8';
149
- resolve({ status: res.statusCode, headers: res.headers, body: buf.toString(encoding), cookieJar });
150
- });
151
- });
152
-
153
- req.on('error', reject);
154
- if (body) req.write(body);
155
- req.end();
156
- });
157
- }
158
-
159
- // ─── API pública ─────────────────────────────────────────────────────────────
160
-
161
- /**
162
- * Autenticar con certificado en el portal SII y obtener cookies de sesión.
163
- * Retorna el cookieJar con NETSCAPE_LIVEWIRE.* si tuvo éxito.
164
- *
165
- * @returns {Promise<Object>} cookieJar con sesión SII activa
166
- * @throws {Error} Si la autenticación falla
167
- */
168
- async autenticar() {
169
- const TARGET = 'https://misiir.sii.cl/cgi_misii/siihome.cgi';
170
-
171
- // ── 1. Intentar reusar sesión cacheada ───────────────────────────────────
172
- const cached = SiiPortalAuth._cargarSesionCache(this._certHash);
173
- if (cached) {
174
- const valida = await this._validarSesion(cached);
175
- if (valida) {
176
- console.log('[SiiPortalAuth] ♻️ Reutilizando sesión SII cacheada');
177
- return cached;
178
- }
179
- console.log('[SiiPortalAuth] ⚠️ Sesión cacheada expirada, re-autenticando...');
180
- }
181
-
182
- // ── 2. Nueva autenticación ────────────────────────────────────────────────
183
- const cookieJar = {};
184
-
185
- await this._request(
186
- `https://zeusr.sii.cl/AUT2000/InicioAutenticacion/IngresoCertificado.html?${TARGET}`,
187
- { cookieJar }
188
- );
189
-
190
- const r2 = await this._request(
191
- `https://herculesr.sii.cl/cgi_AUT2000/CAutInicio.cgi?${TARGET}`,
192
- {
193
- method: 'POST',
194
- body: `referencia=${encodeURIComponent(TARGET)}`,
195
- cookieJar,
196
- usarCert: true,
197
- }
198
- );
199
-
200
- // Verificar mensaje de límite de sesiones
201
- if (r2.body.includes('m\u00e1ximo de sesiones') || r2.body.includes('maximo de sesiones') ||
202
- r2.body.includes('01.01.215.500.709')) {
203
- throw new Error(
204
- 'SiiPortalAuth: límite de sesiones SII alcanzado.\n' +
205
- 'Cierra sesión en sii.cl y espera ~30 min, o las sesiones anteriores expirarán solas.'
206
- );
207
- }
208
-
209
- const autenticado = Object.keys(cookieJar).some(k => k.startsWith('NETSCAPE_LIVEWIRE'));
210
- if (!autenticado) {
211
- throw new Error('SiiPortalAuth: autenticación fallida — no se recibieron cookies de sesión NETSCAPE_LIVEWIRE.*');
212
- }
213
-
214
- SiiPortalAuth._guardarSesionCache(this._certHash, cookieJar);
215
- return cookieJar;
216
- }
217
-
218
- /**
219
- * Verifica si una sesión cacheada sigue activa haciendo un GET liviano.
220
- * Si el SII redirige al login → sesión expirada.
221
- * @private
222
- */
223
- async _validarSesion(cookieJar) {
224
- try {
225
- const res = await this._request(
226
- 'https://maullin.sii.cl/cvc_cgi/dte/ad_empresa1',
227
- { cookieJar: { ...cookieJar } } // copia para no contaminar
228
- );
229
- // Si redirige al login SII → expirada
230
- const loc = res.headers['location'] || '';
231
- if (loc.includes('InicioAutenticacion') || loc.includes('IngresoRutClave')) return false;
232
- // Si el body contiene formulario de empresa → válida
233
- const valida = res.status === 200 && (res.body.includes('RUT_EMP') || res.body.includes('ad_empresa'));
234
- // Refrescar timestamp del caché para extender TTL mientras la sesión se usa activamente
235
- if (valida) SiiPortalAuth._guardarSesionCache(this._certHash, cookieJar);
236
- return valida;
237
- } catch {
238
- return false;
239
- }
240
- }
241
-
242
- /** Lee sesión cacheada del disco para el cert dado. @private */
243
- static _cargarSesionCache(certHash) {
244
- try {
245
- if (!fs.existsSync(SESSION_CACHE_PATH)) return null;
246
- const data = JSON.parse(fs.readFileSync(SESSION_CACHE_PATH, 'utf8'));
247
- if (data.certHash !== certHash) return null;
248
- // TTL: 90 minutos (SII permite ~2h de inactividad; se refresca en cada validación)
249
- if (Date.now() - data.ts > 90 * 60 * 1000) return null;
250
- return data.cookies;
251
- } catch {
252
- return null;
253
- }
254
- }
255
-
256
- /** Guarda sesión en disco. @private */
257
- static _guardarSesionCache(certHash, cookieJar) {
258
- try {
259
- fs.mkdirSync(path.dirname(SESSION_CACHE_PATH), { recursive: true });
260
- fs.writeFileSync(SESSION_CACHE_PATH, JSON.stringify({
261
- certHash,
262
- ts: Date.now(),
263
- cookies: cookieJar,
264
- }), 'utf8');
265
- } catch { /* no crítico */ }
266
- }
267
-
268
- /** Borra la sesión cacheada (útil para forzar re-login). */
269
- static limpiarSesionCache() {
270
- try { fs.unlinkSync(SESSION_CACHE_PATH); } catch { /* ignorar */ }
271
- }
272
-
273
- /**
274
- * Obtiene datos del contribuyente desde ad_empresa2.
275
- * Incluye fch_resol, nro_resol, razón social, etc.
276
- *
277
- * @param {string} rutEmpresa - RUT sin DV (ej: "78206276")
278
- * @param {string} dvEmpresa - DV (ej: "K")
279
- * @param {Object} [cookieJar] - Sesión ya autenticada (opcional; si omite, autenticará)
280
- * @returns {Promise<Object>} { rut, razonSocial, fch_resol, nro_resol, fecha_autorizacion }
281
- */
282
- async obtenerDatosEmpresa(rutEmpresa, dvEmpresa, cookieJar = null) {
283
- const jar = cookieJar || await this.autenticar();
284
-
285
- // ad_empresa1 → mostrar el form de ingreso de RUT (obtiene cookie de sesión maullin)
286
- await this._request('https://maullin.sii.cl/cvc_cgi/dte/ad_empresa1', { cookieJar: jar });
287
-
288
- // ad_empresa2 → POST con RUT empresa → devuelve tabla con datos
289
- const res = await this._request(
290
- 'https://maullin.sii.cl/cvc_cgi/dte/ad_empresa2',
291
- {
292
- method: 'POST',
293
- body: `RUT_EMP=${encodeURIComponent(rutEmpresa)}&DV_EMP=${encodeURIComponent(dvEmpresa)}&ACEPTAR=Ingresar`,
294
- cookieJar: jar,
295
- }
296
- );
297
-
298
- return SiiPortalAuth._parsearTablaEmpresa(res.body);
299
- }
300
-
301
- /**
302
- * Método de conveniencia: autentica y obtiene TODOS los datos del emisor en un solo paso:
303
- * - fch_resol / nro_resol (desde ad_empresa2)
304
- * - rut, razonSocial, giro, dirección, comuna, acteco (desde pe_construccion_dte)
305
- *
306
- * @param {string} rutEmpresa - RUT sin DV (ej: "78206276")
307
- * @param {string} dvEmpresa - DV (ej: "K")
308
- * @returns {Promise<Object>} Datos completos del emisor
309
- */
310
- async fetchDatosEmpresa(rutEmpresa, dvEmpresa) {
311
- const cookieJar = await this.autenticar();
312
- const [resolucion, contribuyente] = await Promise.all([
313
- this.obtenerDatosEmpresa(rutEmpresa, dvEmpresa, cookieJar),
314
- this.obtenerDatosContribuyente(rutEmpresa, dvEmpresa, cookieJar).catch(() => null),
315
- ]);
316
- return { ...contribuyente, ...resolucion };
317
- }
318
-
319
- /**
320
- * Obtiene datos fidedignos del contribuyente para construcción DTE.
321
- * Flujo:
322
- * 1. POST pe_construccion_dte con RUT_EMP + DV_EMP
323
- * → redirige a ce_consulta_muestra_e con la tabla de datos
324
- * 2. Parsea la tabla: nombre, dirección, actividades económicas, glosa
325
- *
326
- * @param {string} rutEmpresa - RUT sin DV (ej: "78206276")
327
- * @param {string} dvEmpresa - DV (ej: "K")
328
- * @param {Object} [cookieJar] - Sesión autenticada (si omite, autenticará)
329
- * @returns {Promise<Object>} { rut, razonSocial, direccion, comuna, dirReg, acteco, glosa }
330
- */
331
- async obtenerDatosContribuyente(rutEmpresa, dvEmpresa, cookieJar = null) {
332
- const jar = cookieJar || await this.autenticar();
333
-
334
- // POST directo al action del form: ce_consulta_muestra_e
335
- // (pe_construccion_dte solo devuelve el formulario, no los datos)
336
- const res = await this._request(
337
- 'https://maullin.sii.cl/cvc_cgi/dte/ce_consulta_muestra_e',
338
- {
339
- method: 'POST',
340
- body: `RUT_EMP=${encodeURIComponent(rutEmpresa)}&DV_EMP=${encodeURIComponent(dvEmpresa)}&ACEPTAR=CONSULTAR`,
341
- cookieJar: jar,
342
- }
343
- );
344
-
345
- return SiiPortalAuth._parsearCeConsultaMuestra(res.body);
346
- }
347
-
348
- /**
349
- * Parsea el HTML de ce_consulta_muestra_e.
350
- *
351
- * Estructura real del SII:
352
- * <td>DATOS DEL CONTRIBUYENTE RUT</td><td>78206276-K</td>
353
- * <td>NOMBRE O RAZÓN SOCIAL</td><td>DEVLAS SPA</td>
354
- * <td>DIRECCIÓN DE LA EMPRESA</td><td>AV.ESC.AGRICOLA 1710..., Comuna MACUL</td>
355
- * <td>DIRECCIÓN REGIONAL DEL CONTRIBUYENTE</td><td>NUNOA</td>
356
- * + tabla actividades: <td>620100</td><td>ACTIVIDADES DE PROGRAMACION...</td><td>SI</td>
357
- * + tabla glosa: <td>GLOSA DESCRIPTIVA</td><td>Desarrollo de software...</td>
358
- * @private
359
- */
360
- static _parsearCeConsultaMuestra(html) {
361
- const clean = (s) => s
362
- .replace(/<[^>]+>/g, '')
363
- .replace(/&nbsp;/gi, ' ')
364
- .replace(/&oacute;/g, 'ó').replace(/&aacute;/g, 'á').replace(/&eacute;/g, 'é')
365
- .replace(/&iacute;/g, 'í').replace(/&uacute;/g, 'ú').replace(/&ntilde;/g, 'ñ')
366
- .replace(/&Oacute;/g, 'Ó').replace(/&Aacute;/g, 'Á').replace(/&Eacute;/g, 'É')
367
- .replace(/&Iacute;/g, 'Í').replace(/&Ntilde;/g, 'Ñ').replace(/&amp;/g, '&')
368
- .replace(/\s+/g, ' ').trim();
369
-
370
- const datos = {};
371
- const actividades = [];
372
-
373
- for (const row of html.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/gi)) {
374
- const celdas = [...row[0].matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)].map(m => clean(m[1]));
375
- if (celdas.length === 2 && celdas[0]) {
376
- datos[celdas[0].toUpperCase()] = celdas[1];
377
- } else if (celdas.length === 3) {
378
- // Filas de actividades económicas: código, descripción, afecto IVA
379
- const codigo = celdas[0].replace(/\s/g, '');
380
- if (/^\d{4,6}$/.test(codigo)) {
381
- actividades.push({ codigo, descripcion: celdas[1], afectoIva: celdas[2] });
382
- }
383
- }
384
- }
385
-
386
- // Extraer dirección y comuna (la dirección incluye "Comuna XXXX" al final)
387
- const dirRaw = datos['DIRECCIÓN DE LA EMPRESA'] || datos['DIRECCION DE LA EMPRESA'] || null;
388
- let direccion = dirRaw;
389
- let comuna = null;
390
- if (dirRaw) {
391
- const comunaMatch = dirRaw.match(/,?\s*Comuna\s+([A-ZÁÉÍÓÚÑ][A-ZÁÉÍÓÚÑA-Z ]+)$/i);
392
- if (comunaMatch) {
393
- comuna = comunaMatch[1].trim();
394
- direccion = dirRaw.slice(0, comunaMatch.index).replace(/,\s*$/, '').trim();
395
- }
396
- }
397
-
398
- const dirReg = datos['DIRECCIÓN REGIONAL DEL CONTRIBUYENTE'] || datos['DIRECCION REGIONAL DEL CONTRIBUYENTE'] || null;
399
- const glosa = datos['GLOSA DESCRIPTIVA'] || null;
400
-
401
- return {
402
- rut: datos['DATOS DEL CONTRIBUYENTE RUT'] || null,
403
- razonSocial: datos['NOMBRE O RAZÓN SOCIAL'] || datos['NOMBRE O RAZON SOCIAL'] || null,
404
- giro: actividades[0]?.descripcion || null, // descripción de la primera actividad económica
405
- direccion,
406
- comuna,
407
- dirReg,
408
- sucursal_sii: dirReg ? `S.I.I. - ${dirReg}` : null,
409
- acteco: actividades[0]?.codigo || null,
410
- actividades: actividades.length ? actividades : null,
411
- glosa,
412
- };
413
- }
414
-
415
- // ─── Parser interno ──────────────────────────────────────────────────────────
416
-
417
- /**
418
- * Parsea la tabla HTML de ad_empresa2.
419
- * El HTML tiene filas <tr><td>Label</td><td>&nbsp;Valor</td></tr>
420
- * @private
421
- */
422
- static _parsearTablaEmpresa(html) {
423
- const datos = {};
424
- const decode = s => s
425
- .replace(/<[^>]+>/g, '')
426
- .replace(/&nbsp;/gi, '')
427
- .replace(/&oacute;/g, 'ó')
428
- .replace(/&aacute;/g, 'á')
429
- .replace(/&eacute;/g, 'é')
430
- .replace(/&iacute;/g, 'í')
431
- .replace(/&uacute;/g, 'ú')
432
- .replace(/&ntilde;/g, 'ñ')
433
- .replace(/&amp;/g, '&')
434
- .trim();
435
-
436
- // Dividir por apertura <TR> — HTML 4.01 no exige tags </TR> de cierre
437
- for (const seg of html.split(/<tr[^>]*>/i)) {
438
- const celdas = [...seg.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)].map(m => decode(m[1]));
439
- if (celdas.length >= 2 && celdas[0]) datos[celdas[0]] = celdas[1];
440
- }
441
-
442
- // Buscar por regex para ser resiliente ante variaciones de encoding / tildes
443
- const fechaKey = Object.keys(datos).find(k => /fecha.*resol/i.test(k)) || null;
444
- const nroKey = Object.keys(datos).find(k => /^resoluci/i.test(k) && !/fecha/i.test(k)) || null;
445
- const fechaResol = fechaKey ? datos[fechaKey] : null;
446
- const nroResol = nroKey ? datos[nroKey] : null;
447
-
448
- if (fechaResol === null && nroResol === null) {
449
- // Loguear los campos encontrados para diagnóstico
450
- const camposEncontrados = Object.keys(datos);
451
- console.warn('[SiiPortalAuth] ad_empresa2 campos encontrados:', camposEncontrados.length ? camposEncontrados.join(', ') : '(ninguno)');
452
- if (!camposEncontrados.length) {
453
- // Puede ser una página de login o error — loguear inicio del HTML
454
- console.warn('[SiiPortalAuth] HTML ad_empresa2 (primeros 500 chars):', html.slice(0, 500).replace(/\s+/g, ' '));
455
- }
456
- throw new Error('SiiPortalAuth: no se encontraron datos de resolución en la respuesta del SII');
457
- }
458
-
459
- // Convertir fecha DD-MM-YYYY → YYYY-MM-DD
460
- const fchResolIso = fechaResol
461
- ? fechaResol.replace(/^(\d{2})-(\d{2})-(\d{4})$/, '$3-$2-$1')
462
- : null;
463
-
464
- return {
465
- rut: datos['Rut'] || null,
466
- razonSocial: datos['Razón Social'] || null,
467
- fch_resol: fchResolIso,
468
- nro_resol: nroResol !== null ? parseInt(nroResol, 10) : null,
469
- fecha_autorizacion: datos['Fecha Autorización'] || null,
470
- };
471
- }
472
- }
473
-
474
- module.exports = SiiPortalAuth;
1
+ // Copyright (c) 2026 Devlas SpA — https://devlas.cl
2
+ // Licencia MIT. Ver archivo LICENSE para mas detalles.
3
+ /**
4
+ * SiiPortalAuth.js
5
+ *
6
+ * Autenticación con certificado digital en el portal del SII (herculesr.sii.cl).
7
+ * Permite obtener datos del contribuyente (fch_resol, nro_resol, razón social)
8
+ * directamente desde el portal sin login manual.
9
+ *
10
+ * Flujo (confirmado por análisis del portal):
11
+ * 1. GET zeusr.sii.cl/AUT2000/InicioAutenticacion/IngresoCertificado.html?TARGET
12
+ * → obtiene cookie F5 BIG-IP de sesión
13
+ * 2. POST herculesr.sii.cl/cgi_AUT2000/CAutInicio.cgi?TARGET body: referencia=TARGET
14
+ * → herculesr SÍ solicita cert en el TLS handshake inicial (a diferencia de zeusr)
15
+ * → SII extrae el RUT del campo serialNumber del certificado
16
+ * → responde con set-cookie: NETSCAPE_LIVEWIRE.* (sesión activa)
17
+ * 3. POST maullin.sii.cl/cvc_cgi/dte/ad_empresa2 body: RUT_EMP=XXXXX&DV_EMP=X
18
+ * → devuelve tabla HTML con datos del contribuyente
19
+ *
20
+ * @module SiiPortalAuth
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ const https = require('https');
26
+ const http = require('http');
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+ const os = require('os');
30
+ const { URL } = require('url');
31
+ const forge = require('node-forge');
32
+ const crypto = require('crypto');
33
+
34
+ // ─── Opciones TLS comunes para SII ────────────────────────────────────────────
35
+ const SII_TLS_OPTS = {
36
+ rejectUnauthorized: false,
37
+ maxVersion: 'TLSv1.2',
38
+ secureOptions:
39
+ crypto.constants.SSL_OP_ALLOW_UNSAFE_LEGACY_RENEGOTIATION |
40
+ crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
41
+ };
42
+
43
+ // ─── Ruta del caché de sesión ─────────────────────────────────────────────────
44
+ // Guarda las cookies NETSCAPE_LIVEWIRE.* para reusar entre ejecuciones y evitar
45
+ // el error "máximo de sesiones autenticadas" del SII.
46
+ const SESSION_CACHE_PATH = path.join(
47
+ process.env.DATADIR || path.join(os.homedir(), 'AppData', 'Roaming', 'POS'),
48
+ 'sii_session_cache.json'
49
+ );
50
+
51
+ /**
52
+ * Clase principal de autenticación con el portal SII
53
+ */
54
+ class SiiPortalAuth {
55
+ /**
56
+ * @param {Object} options
57
+ * @param {Buffer} options.pfxBuffer - Buffer del archivo PFX
58
+ * @param {string} options.pfxPassword - Contraseña del PFX
59
+ */
60
+ constructor({ pfxBuffer, pfxPassword }) {
61
+ if (!pfxBuffer) throw new Error('SiiPortalAuth: pfxBuffer es obligatorio');
62
+ if (pfxPassword === undefined) throw new Error('SiiPortalAuth: pfxPassword es obligatorio');
63
+
64
+ const { certPem, keyPem } = SiiPortalAuth._extractPems(pfxBuffer, pfxPassword);
65
+ this._certPem = certPem;
66
+ this._keyPem = keyPem;
67
+ // Huella para identificar de qué cert es la sesión cacheada
68
+ this._certHash = crypto.createHash('sha1').update(certPem).digest('hex').slice(0, 12);
69
+
70
+ this._agentePlano = new https.Agent(SII_TLS_OPTS);
71
+ this._agenteCert = new https.Agent({ ...SII_TLS_OPTS, cert: certPem, key: keyPem });
72
+ }
73
+
74
+ /**
75
+ * Extrae cert + key PEM desde un PFX usando node-forge
76
+ * (node-forge soporta PKCS12 moderno AES-256 que Node nativo rechaza)
77
+ * @private
78
+ */
79
+ static _extractPems(pfxBuffer, password) {
80
+ const p12Asn1 = forge.asn1.fromDer(pfxBuffer.toString('binary'));
81
+ const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password);
82
+
83
+ // Clave privada
84
+ const keyBags = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag });
85
+ const keyBag = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag]?.[0]
86
+ || p12.getBags({ bagType: forge.pki.oids.keyBag })[forge.pki.oids.keyBag]?.[0];
87
+ if (!keyBag?.key) throw new Error('SiiPortalAuth: no se encontró clave privada en el PFX');
88
+ const keyPem = forge.pki.privateKeyToPem(keyBag.key);
89
+
90
+ // Certificado
91
+ const certBags = p12.getBags({ bagType: forge.pki.oids.certBag });
92
+ const certBag = certBags[forge.pki.oids.certBag]?.[0];
93
+ if (!certBag?.cert) throw new Error('SiiPortalAuth: no se encontró certificado en el PFX');
94
+ const certPem = forge.pki.certificateToPem(certBag.cert);
95
+
96
+ return { certPem, keyPem };
97
+ }
98
+
99
+ // ─── HTTP helpers ────────────────────────────────────────────────────────────
100
+
101
+ _request(urlStr, { method = 'GET', cookieJar = {}, body = null, headers = {}, usarCert = false } = {}) {
102
+ return new Promise((resolve, reject) => {
103
+ const url = new URL(urlStr);
104
+ const isHttps = url.protocol === 'https:';
105
+
106
+ const options = {
107
+ hostname: url.hostname,
108
+ port: url.port || (isHttps ? 443 : 80),
109
+ path: url.pathname + url.search,
110
+ method,
111
+ headers: {
112
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
113
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
114
+ 'Accept-Language': 'es-CL,es;q=0.9',
115
+ 'Connection': 'keep-alive',
116
+ ...headers,
117
+ },
118
+ };
119
+
120
+ const cookieStr = Object.entries(cookieJar).map(([k, v]) => `${k}=${v}`).join('; ');
121
+ if (cookieStr) options.headers['Cookie'] = cookieStr;
122
+
123
+ if (body) {
124
+ options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
125
+ options.headers['Content-Length'] = Buffer.byteLength(body);
126
+ }
127
+
128
+ if (isHttps) options.agent = usarCert ? this._agenteCert : this._agentePlano;
129
+
130
+ const req = (isHttps ? https : http).request(options, (res) => {
131
+ // Capturar cookies
132
+ for (const cookieHeader of (res.headers['set-cookie'] || [])) {
133
+ const [pair] = cookieHeader.split(';');
134
+ const eqIdx = pair.indexOf('=');
135
+ if (eqIdx > 0) {
136
+ const name = pair.slice(0, eqIdx).trim();
137
+ const val = pair.slice(eqIdx + 1).trim();
138
+ cookieJar[name] = val;
139
+ }
140
+ }
141
+ const chunks = [];
142
+ res.on('data', (chunk) => chunks.push(chunk));
143
+ res.on('end', () => {
144
+ const buf = Buffer.concat(chunks);
145
+ const ct = res.headers['content-type'] || '';
146
+ // Todos los hosts *.sii.cl sirven páginas ISO-8859-1; a veces no incluyen charset en Content-Type.
147
+ const isSiiHost = url.hostname.endsWith('.sii.cl');
148
+ const encoding = (isSiiHost || /iso-8859|latin-1|windows-1252/i.test(ct)) ? 'latin1' : 'utf8';
149
+ resolve({ status: res.statusCode, headers: res.headers, body: buf.toString(encoding), cookieJar });
150
+ });
151
+ });
152
+
153
+ req.on('error', reject);
154
+ if (body) req.write(body);
155
+ req.end();
156
+ });
157
+ }
158
+
159
+ // ─── API pública ─────────────────────────────────────────────────────────────
160
+
161
+ /**
162
+ * Autenticar con certificado en el portal SII y obtener cookies de sesión.
163
+ * Retorna el cookieJar con NETSCAPE_LIVEWIRE.* si tuvo éxito.
164
+ *
165
+ * @returns {Promise<Object>} cookieJar con sesión SII activa
166
+ * @throws {Error} Si la autenticación falla
167
+ */
168
+ async autenticar() {
169
+ const TARGET = 'https://misiir.sii.cl/cgi_misii/siihome.cgi';
170
+
171
+ // ── 1. Intentar reusar sesión cacheada ───────────────────────────────────
172
+ const cached = SiiPortalAuth._cargarSesionCache(this._certHash);
173
+ if (cached) {
174
+ const valida = await this._validarSesion(cached);
175
+ if (valida) {
176
+ console.log('[SiiPortalAuth] ♻️ Reutilizando sesión SII cacheada');
177
+ return cached;
178
+ }
179
+ console.log('[SiiPortalAuth] ⚠️ Sesión cacheada expirada, re-autenticando...');
180
+ }
181
+
182
+ // ── 2. Nueva autenticación ────────────────────────────────────────────────
183
+ const cookieJar = {};
184
+
185
+ await this._request(
186
+ `https://zeusr.sii.cl/AUT2000/InicioAutenticacion/IngresoCertificado.html?${TARGET}`,
187
+ { cookieJar }
188
+ );
189
+
190
+ const r2 = await this._request(
191
+ `https://herculesr.sii.cl/cgi_AUT2000/CAutInicio.cgi?${TARGET}`,
192
+ {
193
+ method: 'POST',
194
+ body: `referencia=${encodeURIComponent(TARGET)}`,
195
+ cookieJar,
196
+ usarCert: true,
197
+ }
198
+ );
199
+
200
+ // Verificar mensaje de límite de sesiones
201
+ if (r2.body.includes('m\u00e1ximo de sesiones') || r2.body.includes('maximo de sesiones') ||
202
+ r2.body.includes('01.01.215.500.709')) {
203
+ throw new Error(
204
+ 'SiiPortalAuth: límite de sesiones SII alcanzado.\n' +
205
+ 'Cierra sesión en sii.cl y espera ~30 min, o las sesiones anteriores expirarán solas.'
206
+ );
207
+ }
208
+
209
+ const autenticado = Object.keys(cookieJar).some(k => k.startsWith('NETSCAPE_LIVEWIRE'));
210
+ if (!autenticado) {
211
+ throw new Error('SiiPortalAuth: autenticación fallida — no se recibieron cookies de sesión NETSCAPE_LIVEWIRE.*');
212
+ }
213
+
214
+ SiiPortalAuth._guardarSesionCache(this._certHash, cookieJar);
215
+ return cookieJar;
216
+ }
217
+
218
+ /**
219
+ * Verifica si una sesión cacheada sigue activa haciendo un GET liviano.
220
+ * Si el SII redirige al login → sesión expirada.
221
+ * @private
222
+ */
223
+ async _validarSesion(cookieJar) {
224
+ try {
225
+ const res = await this._request(
226
+ 'https://maullin.sii.cl/cvc_cgi/dte/ad_empresa1',
227
+ { cookieJar: { ...cookieJar } } // copia para no contaminar
228
+ );
229
+ // Si redirige al login SII → expirada
230
+ const loc = res.headers['location'] || '';
231
+ if (loc.includes('InicioAutenticacion') || loc.includes('IngresoRutClave')) return false;
232
+ // Si el body contiene formulario de empresa → válida
233
+ const valida = res.status === 200 && (res.body.includes('RUT_EMP') || res.body.includes('ad_empresa'));
234
+ // Refrescar timestamp del caché para extender TTL mientras la sesión se usa activamente
235
+ if (valida) SiiPortalAuth._guardarSesionCache(this._certHash, cookieJar);
236
+ return valida;
237
+ } catch {
238
+ return false;
239
+ }
240
+ }
241
+
242
+ /** Lee sesión cacheada del disco para el cert dado. @private */
243
+ static _cargarSesionCache(certHash) {
244
+ try {
245
+ if (!fs.existsSync(SESSION_CACHE_PATH)) return null;
246
+ const data = JSON.parse(fs.readFileSync(SESSION_CACHE_PATH, 'utf8'));
247
+ if (data.certHash !== certHash) return null;
248
+ // TTL: 90 minutos (SII permite ~2h de inactividad; se refresca en cada validación)
249
+ if (Date.now() - data.ts > 90 * 60 * 1000) return null;
250
+ return data.cookies;
251
+ } catch {
252
+ return null;
253
+ }
254
+ }
255
+
256
+ /** Guarda sesión en disco. @private */
257
+ static _guardarSesionCache(certHash, cookieJar) {
258
+ try {
259
+ fs.mkdirSync(path.dirname(SESSION_CACHE_PATH), { recursive: true });
260
+ fs.writeFileSync(SESSION_CACHE_PATH, JSON.stringify({
261
+ certHash,
262
+ ts: Date.now(),
263
+ cookies: cookieJar,
264
+ }), 'utf8');
265
+ } catch { /* no crítico */ }
266
+ }
267
+
268
+ /** Borra la sesión cacheada (útil para forzar re-login). */
269
+ static limpiarSesionCache() {
270
+ try { fs.unlinkSync(SESSION_CACHE_PATH); } catch { /* ignorar */ }
271
+ }
272
+
273
+ /**
274
+ * Obtiene datos del contribuyente desde ad_empresa2.
275
+ * Incluye fch_resol, nro_resol, razón social, etc.
276
+ *
277
+ * @param {string} rutEmpresa - RUT sin DV (ej: "78206276")
278
+ * @param {string} dvEmpresa - DV (ej: "K")
279
+ * @param {Object} [cookieJar] - Sesión ya autenticada (opcional; si omite, autenticará)
280
+ * @returns {Promise<Object>} { rut, razonSocial, fch_resol, nro_resol, fecha_autorizacion }
281
+ */
282
+ async obtenerDatosEmpresa(rutEmpresa, dvEmpresa, cookieJar = null) {
283
+ const jar = cookieJar || await this.autenticar();
284
+
285
+ // ad_empresa1 → mostrar el form de ingreso de RUT (obtiene cookie de sesión maullin)
286
+ await this._request('https://maullin.sii.cl/cvc_cgi/dte/ad_empresa1', { cookieJar: jar });
287
+
288
+ // ad_empresa2 → POST con RUT empresa → devuelve tabla con datos
289
+ const res = await this._request(
290
+ 'https://maullin.sii.cl/cvc_cgi/dte/ad_empresa2',
291
+ {
292
+ method: 'POST',
293
+ body: `RUT_EMP=${encodeURIComponent(rutEmpresa)}&DV_EMP=${encodeURIComponent(dvEmpresa)}&ACEPTAR=Ingresar`,
294
+ cookieJar: jar,
295
+ }
296
+ );
297
+
298
+ return SiiPortalAuth._parsearTablaEmpresa(res.body);
299
+ }
300
+
301
+ /**
302
+ * Método de conveniencia: autentica y obtiene TODOS los datos del emisor en un solo paso:
303
+ * - fch_resol / nro_resol (desde ad_empresa2)
304
+ * - rut, razonSocial, giro, dirección, comuna, acteco (desde pe_construccion_dte)
305
+ *
306
+ * @param {string} rutEmpresa - RUT sin DV (ej: "78206276")
307
+ * @param {string} dvEmpresa - DV (ej: "K")
308
+ * @returns {Promise<Object>} Datos completos del emisor
309
+ */
310
+ async fetchDatosEmpresa(rutEmpresa, dvEmpresa) {
311
+ const cookieJar = await this.autenticar();
312
+ const [resolucion, contribuyente] = await Promise.all([
313
+ this.obtenerDatosEmpresa(rutEmpresa, dvEmpresa, cookieJar),
314
+ this.obtenerDatosContribuyente(rutEmpresa, dvEmpresa, cookieJar).catch(() => null),
315
+ ]);
316
+ return { ...contribuyente, ...resolucion };
317
+ }
318
+
319
+ /**
320
+ * Obtiene datos fidedignos del contribuyente para construcción DTE.
321
+ * Flujo:
322
+ * 1. POST pe_construccion_dte con RUT_EMP + DV_EMP
323
+ * → redirige a ce_consulta_muestra_e con la tabla de datos
324
+ * 2. Parsea la tabla: nombre, dirección, actividades económicas, glosa
325
+ *
326
+ * @param {string} rutEmpresa - RUT sin DV (ej: "78206276")
327
+ * @param {string} dvEmpresa - DV (ej: "K")
328
+ * @param {Object} [cookieJar] - Sesión autenticada (si omite, autenticará)
329
+ * @returns {Promise<Object>} { rut, razonSocial, direccion, comuna, dirReg, acteco, glosa }
330
+ */
331
+ async obtenerDatosContribuyente(rutEmpresa, dvEmpresa, cookieJar = null) {
332
+ const jar = cookieJar || await this.autenticar();
333
+
334
+ // POST directo al action del form: ce_consulta_muestra_e
335
+ // (pe_construccion_dte solo devuelve el formulario, no los datos)
336
+ const res = await this._request(
337
+ 'https://maullin.sii.cl/cvc_cgi/dte/ce_consulta_muestra_e',
338
+ {
339
+ method: 'POST',
340
+ body: `RUT_EMP=${encodeURIComponent(rutEmpresa)}&DV_EMP=${encodeURIComponent(dvEmpresa)}&ACEPTAR=CONSULTAR`,
341
+ cookieJar: jar,
342
+ }
343
+ );
344
+
345
+ return SiiPortalAuth._parsearCeConsultaMuestra(res.body);
346
+ }
347
+
348
+ /**
349
+ * Parsea el HTML de ce_consulta_muestra_e.
350
+ *
351
+ * Estructura real del SII:
352
+ * <td>DATOS DEL CONTRIBUYENTE RUT</td><td>78206276-K</td>
353
+ * <td>NOMBRE O RAZÓN SOCIAL</td><td>DEVLAS SPA</td>
354
+ * <td>DIRECCIÓN DE LA EMPRESA</td><td>AV.ESC.AGRICOLA 1710..., Comuna MACUL</td>
355
+ * <td>DIRECCIÓN REGIONAL DEL CONTRIBUYENTE</td><td>NUNOA</td>
356
+ * + tabla actividades: <td>620100</td><td>ACTIVIDADES DE PROGRAMACION...</td><td>SI</td>
357
+ * + tabla glosa: <td>GLOSA DESCRIPTIVA</td><td>Desarrollo de software...</td>
358
+ * @private
359
+ */
360
+ static _parsearCeConsultaMuestra(html) {
361
+ const clean = (s) => s
362
+ .replace(/<[^>]+>/g, '')
363
+ .replace(/&nbsp;/gi, ' ')
364
+ .replace(/&oacute;/g, 'ó').replace(/&aacute;/g, 'á').replace(/&eacute;/g, 'é')
365
+ .replace(/&iacute;/g, 'í').replace(/&uacute;/g, 'ú').replace(/&ntilde;/g, 'ñ')
366
+ .replace(/&Oacute;/g, 'Ó').replace(/&Aacute;/g, 'Á').replace(/&Eacute;/g, 'É')
367
+ .replace(/&Iacute;/g, 'Í').replace(/&Ntilde;/g, 'Ñ').replace(/&amp;/g, '&')
368
+ .replace(/\s+/g, ' ').trim();
369
+
370
+ const datos = {};
371
+ const actividades = [];
372
+
373
+ for (const row of html.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/gi)) {
374
+ const celdas = [...row[0].matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)].map(m => clean(m[1]));
375
+ if (celdas.length === 2 && celdas[0]) {
376
+ datos[celdas[0].toUpperCase()] = celdas[1];
377
+ } else if (celdas.length === 3) {
378
+ // Filas de actividades económicas: código, descripción, afecto IVA
379
+ const codigo = celdas[0].replace(/\s/g, '');
380
+ if (/^\d{4,6}$/.test(codigo)) {
381
+ actividades.push({ codigo, descripcion: celdas[1], afectoIva: celdas[2] });
382
+ }
383
+ }
384
+ }
385
+
386
+ // Extraer dirección y comuna (la dirección incluye "Comuna XXXX" al final)
387
+ const dirRaw = datos['DIRECCIÓN DE LA EMPRESA'] || datos['DIRECCION DE LA EMPRESA'] || null;
388
+ let direccion = dirRaw;
389
+ let comuna = null;
390
+ if (dirRaw) {
391
+ const comunaMatch = dirRaw.match(/,?\s*Comuna\s+([A-ZÁÉÍÓÚÑ][A-ZÁÉÍÓÚÑA-Z ]+)$/i);
392
+ if (comunaMatch) {
393
+ comuna = comunaMatch[1].trim();
394
+ direccion = dirRaw.slice(0, comunaMatch.index).replace(/,\s*$/, '').trim();
395
+ }
396
+ }
397
+
398
+ const dirReg = datos['DIRECCIÓN REGIONAL DEL CONTRIBUYENTE'] || datos['DIRECCION REGIONAL DEL CONTRIBUYENTE'] || null;
399
+ const glosa = datos['GLOSA DESCRIPTIVA'] || null;
400
+
401
+ return {
402
+ rut: datos['DATOS DEL CONTRIBUYENTE RUT'] || null,
403
+ razonSocial: datos['NOMBRE O RAZÓN SOCIAL'] || datos['NOMBRE O RAZON SOCIAL'] || null,
404
+ giro: actividades[0]?.descripcion || null, // descripción de la primera actividad económica
405
+ direccion,
406
+ comuna,
407
+ dirReg,
408
+ sucursal_sii: dirReg ? `S.I.I. - ${dirReg}` : null,
409
+ acteco: actividades[0]?.codigo || null,
410
+ actividades: actividades.length ? actividades : null,
411
+ glosa,
412
+ };
413
+ }
414
+
415
+ // ─── Parser interno ──────────────────────────────────────────────────────────
416
+
417
+ /**
418
+ * Parsea la tabla HTML de ad_empresa2.
419
+ * El HTML tiene filas <tr><td>Label</td><td>&nbsp;Valor</td></tr>
420
+ * @private
421
+ */
422
+ static _parsearTablaEmpresa(html) {
423
+ const datos = {};
424
+ const decode = s => s
425
+ .replace(/<[^>]+>/g, '')
426
+ .replace(/&nbsp;/gi, '')
427
+ .replace(/&oacute;/g, 'ó')
428
+ .replace(/&aacute;/g, 'á')
429
+ .replace(/&eacute;/g, 'é')
430
+ .replace(/&iacute;/g, 'í')
431
+ .replace(/&uacute;/g, 'ú')
432
+ .replace(/&ntilde;/g, 'ñ')
433
+ .replace(/&amp;/g, '&')
434
+ .trim();
435
+
436
+ // Dividir por apertura <TR> — HTML 4.01 no exige tags </TR> de cierre
437
+ for (const seg of html.split(/<tr[^>]*>/i)) {
438
+ const celdas = [...seg.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)].map(m => decode(m[1]));
439
+ if (celdas.length >= 2 && celdas[0]) datos[celdas[0]] = celdas[1];
440
+ }
441
+
442
+ // Buscar por regex para ser resiliente ante variaciones de encoding / tildes
443
+ const fechaKey = Object.keys(datos).find(k => /fecha.*resol/i.test(k)) || null;
444
+ const nroKey = Object.keys(datos).find(k => /^resoluci/i.test(k) && !/fecha/i.test(k)) || null;
445
+ const fechaResol = fechaKey ? datos[fechaKey] : null;
446
+ const nroResol = nroKey ? datos[nroKey] : null;
447
+
448
+ if (fechaResol === null && nroResol === null) {
449
+ // Loguear los campos encontrados para diagnóstico
450
+ const camposEncontrados = Object.keys(datos);
451
+ console.warn('[SiiPortalAuth] ad_empresa2 campos encontrados:', camposEncontrados.length ? camposEncontrados.join(', ') : '(ninguno)');
452
+ if (!camposEncontrados.length) {
453
+ // Puede ser una página de login o error — loguear inicio del HTML
454
+ console.warn('[SiiPortalAuth] HTML ad_empresa2 (primeros 500 chars):', html.slice(0, 500).replace(/\s+/g, ' '));
455
+ }
456
+ throw new Error('SiiPortalAuth: no se encontraron datos de resolución en la respuesta del SII');
457
+ }
458
+
459
+ // Convertir fecha DD-MM-YYYY → YYYY-MM-DD
460
+ const fchResolIso = fechaResol
461
+ ? fechaResol.replace(/^(\d{2})-(\d{2})-(\d{4})$/, '$3-$2-$1')
462
+ : null;
463
+
464
+ return {
465
+ rut: datos['Rut'] || null,
466
+ razonSocial: datos['Razón Social'] || null,
467
+ fch_resol: fchResolIso,
468
+ nro_resol: nroResol !== null ? parseInt(nroResol, 10) : null,
469
+ fecha_autorizacion: datos['Fecha Autorización'] || null,
470
+ };
471
+ }
472
+ }
473
+
474
+ module.exports = SiiPortalAuth;