@devlas/dte-sii 2.5.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.
Files changed (60) hide show
  1. package/BoletaService.js +109 -0
  2. package/CAF.js +173 -0
  3. package/CafSolicitor.js +380 -0
  4. package/Certificado.js +123 -0
  5. package/ConsumoFolio.js +376 -0
  6. package/DTE.js +399 -0
  7. package/EnviadorSII.js +1304 -0
  8. package/Envio.js +196 -0
  9. package/FolioRegistry.js +553 -0
  10. package/FolioService.js +703 -0
  11. package/LICENSE +27 -0
  12. package/LibroBase.js +134 -0
  13. package/LibroCompraVenta.js +205 -0
  14. package/LibroGuia.js +225 -0
  15. package/README.md +239 -0
  16. package/Signer.js +94 -0
  17. package/SiiCertificacion.js +1189 -0
  18. package/SiiPortalAuth.js +460 -0
  19. package/SiiSession.js +499 -0
  20. package/cert/BoletaCert.js +731 -0
  21. package/cert/CertFolioHelper.js +185 -0
  22. package/cert/CertRunner.js +2658 -0
  23. package/cert/ConfigLoader.js +133 -0
  24. package/cert/IntercambioCert.js +429 -0
  25. package/cert/LibroCompras.js +359 -0
  26. package/cert/LibroGuias.js +171 -0
  27. package/cert/LibroVentas.js +153 -0
  28. package/cert/MuestrasImpresas.js +676 -0
  29. package/cert/SetBase.js +321 -0
  30. package/cert/SetBasico.js +413 -0
  31. package/cert/SetCompra.js +472 -0
  32. package/cert/SetExenta.js +490 -0
  33. package/cert/SetGuia.js +283 -0
  34. package/cert/SetParser.js +1184 -0
  35. package/cert/SetsProvider.js +499 -0
  36. package/cert/Simulacion.js +521 -0
  37. package/cert/comunaOficina.js +460 -0
  38. package/cert/index.js +124 -0
  39. package/cert/types.js +330 -0
  40. package/dte-sii.d.ts +458 -0
  41. package/index.js +428 -0
  42. package/package.json +48 -0
  43. package/utils/c14n.js +275 -0
  44. package/utils/calculo.js +396 -0
  45. package/utils/config.js +276 -0
  46. package/utils/constants.js +302 -0
  47. package/utils/emisor.js +174 -0
  48. package/utils/endpoints.js +225 -0
  49. package/utils/error.js +235 -0
  50. package/utils/index.js +339 -0
  51. package/utils/logger.js +239 -0
  52. package/utils/pfx.js +203 -0
  53. package/utils/receptor.js +218 -0
  54. package/utils/referencia.js +169 -0
  55. package/utils/resolucion.js +119 -0
  56. package/utils/rut.js +169 -0
  57. package/utils/sanitize.js +124 -0
  58. package/utils/tokenCache.js +214 -0
  59. package/utils/xml.js +358 -0
  60. package/utils.js +4 -0
@@ -0,0 +1,460 @@
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
+ const encoding = /iso-8859|latin-1|windows-1252/i.test(ct) ? 'latin1' : 'utf8';
147
+ resolve({ status: res.statusCode, headers: res.headers, body: buf.toString(encoding), cookieJar });
148
+ });
149
+ });
150
+
151
+ req.on('error', reject);
152
+ if (body) req.write(body);
153
+ req.end();
154
+ });
155
+ }
156
+
157
+ // ─── API pública ─────────────────────────────────────────────────────────────
158
+
159
+ /**
160
+ * Autenticar con certificado en el portal SII y obtener cookies de sesión.
161
+ * Retorna el cookieJar con NETSCAPE_LIVEWIRE.* si tuvo éxito.
162
+ *
163
+ * @returns {Promise<Object>} cookieJar con sesión SII activa
164
+ * @throws {Error} Si la autenticación falla
165
+ */
166
+ async autenticar() {
167
+ const TARGET = 'https://misiir.sii.cl/cgi_misii/siihome.cgi';
168
+
169
+ // ── 1. Intentar reusar sesión cacheada ───────────────────────────────────
170
+ const cached = SiiPortalAuth._cargarSesionCache(this._certHash);
171
+ if (cached) {
172
+ const valida = await this._validarSesion(cached);
173
+ if (valida) {
174
+ console.log('[SiiPortalAuth] ♻️ Reutilizando sesión SII cacheada');
175
+ return cached;
176
+ }
177
+ console.log('[SiiPortalAuth] ⚠️ Sesión cacheada expirada, re-autenticando...');
178
+ }
179
+
180
+ // ── 2. Nueva autenticación ────────────────────────────────────────────────
181
+ const cookieJar = {};
182
+
183
+ await this._request(
184
+ `https://zeusr.sii.cl/AUT2000/InicioAutenticacion/IngresoCertificado.html?${TARGET}`,
185
+ { cookieJar }
186
+ );
187
+
188
+ const r2 = await this._request(
189
+ `https://herculesr.sii.cl/cgi_AUT2000/CAutInicio.cgi?${TARGET}`,
190
+ {
191
+ method: 'POST',
192
+ body: `referencia=${encodeURIComponent(TARGET)}`,
193
+ cookieJar,
194
+ usarCert: true,
195
+ }
196
+ );
197
+
198
+ // Verificar mensaje de límite de sesiones
199
+ if (r2.body.includes('m\u00e1ximo de sesiones') || r2.body.includes('maximo de sesiones') ||
200
+ r2.body.includes('01.01.215.500.709')) {
201
+ throw new Error(
202
+ 'SiiPortalAuth: límite de sesiones SII alcanzado.\n' +
203
+ 'Cierra sesión en sii.cl y espera ~30 min, o las sesiones anteriores expirarán solas.'
204
+ );
205
+ }
206
+
207
+ const autenticado = Object.keys(cookieJar).some(k => k.startsWith('NETSCAPE_LIVEWIRE'));
208
+ if (!autenticado) {
209
+ throw new Error('SiiPortalAuth: autenticación fallida — no se recibieron cookies de sesión NETSCAPE_LIVEWIRE.*');
210
+ }
211
+
212
+ SiiPortalAuth._guardarSesionCache(this._certHash, cookieJar);
213
+ return cookieJar;
214
+ }
215
+
216
+ /**
217
+ * Verifica si una sesión cacheada sigue activa haciendo un GET liviano.
218
+ * Si el SII redirige al login → sesión expirada.
219
+ * @private
220
+ */
221
+ async _validarSesion(cookieJar) {
222
+ try {
223
+ const res = await this._request(
224
+ 'https://maullin.sii.cl/cvc_cgi/dte/ad_empresa1',
225
+ { cookieJar: { ...cookieJar } } // copia para no contaminar
226
+ );
227
+ // Si redirige al login SII → expirada
228
+ const loc = res.headers['location'] || '';
229
+ if (loc.includes('InicioAutenticacion') || loc.includes('IngresoRutClave')) return false;
230
+ // Si el body contiene formulario de empresa → válida
231
+ return res.status === 200 && (res.body.includes('RUT_EMP') || res.body.includes('ad_empresa'));
232
+ } catch {
233
+ return false;
234
+ }
235
+ }
236
+
237
+ /** Lee sesión cacheada del disco para el cert dado. @private */
238
+ static _cargarSesionCache(certHash) {
239
+ try {
240
+ if (!fs.existsSync(SESSION_CACHE_PATH)) return null;
241
+ const data = JSON.parse(fs.readFileSync(SESSION_CACHE_PATH, 'utf8'));
242
+ if (data.certHash !== certHash) return null;
243
+ // TTL: 25 minutos (el SII expira sesiones ~30 min de inactividad)
244
+ if (Date.now() - data.ts > 25 * 60 * 1000) return null;
245
+ return data.cookies;
246
+ } catch {
247
+ return null;
248
+ }
249
+ }
250
+
251
+ /** Guarda sesión en disco. @private */
252
+ static _guardarSesionCache(certHash, cookieJar) {
253
+ try {
254
+ fs.mkdirSync(path.dirname(SESSION_CACHE_PATH), { recursive: true });
255
+ fs.writeFileSync(SESSION_CACHE_PATH, JSON.stringify({
256
+ certHash,
257
+ ts: Date.now(),
258
+ cookies: cookieJar,
259
+ }), 'utf8');
260
+ } catch { /* no crítico */ }
261
+ }
262
+
263
+ /** Borra la sesión cacheada (útil para forzar re-login). */
264
+ static limpiarSesionCache() {
265
+ try { fs.unlinkSync(SESSION_CACHE_PATH); } catch { /* ignorar */ }
266
+ }
267
+
268
+ /**
269
+ * Obtiene datos del contribuyente desde ad_empresa2.
270
+ * Incluye fch_resol, nro_resol, razón social, etc.
271
+ *
272
+ * @param {string} rutEmpresa - RUT sin DV (ej: "78206276")
273
+ * @param {string} dvEmpresa - DV (ej: "K")
274
+ * @param {Object} [cookieJar] - Sesión ya autenticada (opcional; si omite, autenticará)
275
+ * @returns {Promise<Object>} { rut, razonSocial, fch_resol, nro_resol, fecha_autorizacion }
276
+ */
277
+ async obtenerDatosEmpresa(rutEmpresa, dvEmpresa, cookieJar = null) {
278
+ const jar = cookieJar || await this.autenticar();
279
+
280
+ // ad_empresa1 → mostrar el form de ingreso de RUT (obtiene cookie de sesión maullin)
281
+ await this._request('https://maullin.sii.cl/cvc_cgi/dte/ad_empresa1', { cookieJar: jar });
282
+
283
+ // ad_empresa2 → POST con RUT empresa → devuelve tabla con datos
284
+ const res = await this._request(
285
+ 'https://maullin.sii.cl/cvc_cgi/dte/ad_empresa2',
286
+ {
287
+ method: 'POST',
288
+ body: `RUT_EMP=${encodeURIComponent(rutEmpresa)}&DV_EMP=${encodeURIComponent(dvEmpresa)}&ACEPTAR=Ingresar`,
289
+ cookieJar: jar,
290
+ }
291
+ );
292
+
293
+ return SiiPortalAuth._parsearTablaEmpresa(res.body);
294
+ }
295
+
296
+ /**
297
+ * Método de conveniencia: autentica y obtiene TODOS los datos del emisor en un solo paso:
298
+ * - fch_resol / nro_resol (desde ad_empresa2)
299
+ * - rut, razonSocial, giro, dirección, comuna, acteco (desde pe_construccion_dte)
300
+ *
301
+ * @param {string} rutEmpresa - RUT sin DV (ej: "78206276")
302
+ * @param {string} dvEmpresa - DV (ej: "K")
303
+ * @returns {Promise<Object>} Datos completos del emisor
304
+ */
305
+ async fetchDatosEmpresa(rutEmpresa, dvEmpresa) {
306
+ const cookieJar = await this.autenticar();
307
+ const [resolucion, contribuyente] = await Promise.all([
308
+ this.obtenerDatosEmpresa(rutEmpresa, dvEmpresa, cookieJar),
309
+ this.obtenerDatosContribuyente(rutEmpresa, dvEmpresa, cookieJar).catch(() => null),
310
+ ]);
311
+ return { ...contribuyente, ...resolucion };
312
+ }
313
+
314
+ /**
315
+ * Obtiene datos fidedignos del contribuyente para construcción DTE.
316
+ * Flujo:
317
+ * 1. POST pe_construccion_dte con RUT_EMP + DV_EMP
318
+ * → redirige a ce_consulta_muestra_e con la tabla de datos
319
+ * 2. Parsea la tabla: nombre, dirección, actividades económicas, glosa
320
+ *
321
+ * @param {string} rutEmpresa - RUT sin DV (ej: "78206276")
322
+ * @param {string} dvEmpresa - DV (ej: "K")
323
+ * @param {Object} [cookieJar] - Sesión autenticada (si omite, autenticará)
324
+ * @returns {Promise<Object>} { rut, razonSocial, direccion, comuna, acteco, glosa }
325
+ */
326
+ async obtenerDatosContribuyente(rutEmpresa, dvEmpresa, cookieJar = null) {
327
+ const jar = cookieJar || await this.autenticar();
328
+
329
+ // POST directo al action del form: ce_consulta_muestra_e
330
+ // (pe_construccion_dte solo devuelve el formulario, no los datos)
331
+ const res = await this._request(
332
+ 'https://maullin.sii.cl/cvc_cgi/dte/ce_consulta_muestra_e',
333
+ {
334
+ method: 'POST',
335
+ body: `RUT_EMP=${encodeURIComponent(rutEmpresa)}&DV_EMP=${encodeURIComponent(dvEmpresa)}&ACEPTAR=CONSULTAR`,
336
+ cookieJar: jar,
337
+ }
338
+ );
339
+
340
+ return SiiPortalAuth._parsearCeConsultaMuestra(res.body);
341
+ }
342
+
343
+ /**
344
+ * Parsea el HTML de ce_consulta_muestra_e.
345
+ *
346
+ * Estructura real del SII:
347
+ * <td>DATOS DEL CONTRIBUYENTE RUT</td><td>78206276-K</td>
348
+ * <td>NOMBRE O RAZÓN SOCIAL</td><td>DEVLAS SPA</td>
349
+ * <td>DIRECCIÓN DE LA EMPRESA</td><td>AV.ESC.AGRICOLA 1710..., Comuna MACUL</td>
350
+ * <td>DIRECCIÓN REGIONAL DEL CONTRIBUYENTE</td><td>NUNOA</td>
351
+ * + tabla actividades: <td>620100</td><td>ACTIVIDADES DE PROGRAMACION...</td><td>SI</td>
352
+ * + tabla glosa: <td>GLOSA DESCRIPTIVA</td><td>Desarrollo de software...</td>
353
+ * @private
354
+ */
355
+ static _parsearCeConsultaMuestra(html) {
356
+ const clean = (s) => s
357
+ .replace(/<[^>]+>/g, '')
358
+ .replace(/&nbsp;/gi, ' ')
359
+ .replace(/&oacute;/g, 'ó').replace(/&aacute;/g, 'á').replace(/&eacute;/g, 'é')
360
+ .replace(/&iacute;/g, 'í').replace(/&uacute;/g, 'ú').replace(/&ntilde;/g, 'ñ')
361
+ .replace(/&Oacute;/g, 'Ó').replace(/&Aacute;/g, 'Á').replace(/&Eacute;/g, 'É')
362
+ .replace(/&Iacute;/g, 'Í').replace(/&Ntilde;/g, 'Ñ').replace(/&amp;/g, '&')
363
+ .replace(/\s+/g, ' ').trim();
364
+
365
+ const datos = {};
366
+ const actividades = [];
367
+
368
+ for (const row of html.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/gi)) {
369
+ const celdas = [...row[0].matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)].map(m => clean(m[1]));
370
+ if (celdas.length === 2 && celdas[0]) {
371
+ datos[celdas[0].toUpperCase()] = celdas[1];
372
+ } else if (celdas.length === 3) {
373
+ // Filas de actividades económicas: código, descripción, afecto IVA
374
+ const codigo = celdas[0].replace(/\s/g, '');
375
+ if (/^\d{4,6}$/.test(codigo)) {
376
+ actividades.push({ codigo, descripcion: celdas[1], afectoIva: celdas[2] });
377
+ }
378
+ }
379
+ }
380
+
381
+ // Extraer dirección y comuna (la dirección incluye "Comuna XXXX" al final)
382
+ const dirRaw = datos['DIRECCIÓN DE LA EMPRESA'] || datos['DIRECCION DE LA EMPRESA'] || null;
383
+ let direccion = dirRaw;
384
+ let comuna = null;
385
+ if (dirRaw) {
386
+ const comunaMatch = dirRaw.match(/,?\s*Comuna\s+([A-ZÁÉÍÓÚÑ][A-ZÁÉÍÓÚÑA-Z ]+)$/i);
387
+ if (comunaMatch) {
388
+ comuna = comunaMatch[1].trim();
389
+ direccion = dirRaw.slice(0, comunaMatch.index).replace(/,\s*$/, '').trim();
390
+ }
391
+ }
392
+
393
+ return {
394
+ rut: datos['DATOS DEL CONTRIBUYENTE RUT'] || null,
395
+ razonSocial: datos['NOMBRE O RAZÓN SOCIAL'] || datos['NOMBRE O RAZON SOCIAL'] || null,
396
+ direccion,
397
+ comuna,
398
+ acteco: actividades[0]?.codigo || null,
399
+ actividades: actividades.length ? actividades : null,
400
+ glosa: datos['GLOSA DESCRIPTIVA'] || null,
401
+ };
402
+ }
403
+
404
+ // ─── Parser interno ──────────────────────────────────────────────────────────
405
+
406
+ /**
407
+ * Parsea la tabla HTML de ad_empresa2.
408
+ * El HTML tiene filas <tr><td>Label</td><td>&nbsp;Valor</td></tr>
409
+ * @private
410
+ */
411
+ static _parsearTablaEmpresa(html) {
412
+ const datos = {};
413
+
414
+ for (const row of html.matchAll(/<tr>[\s\S]*?<\/tr>/gi)) {
415
+ const celdas = [...row[0].matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)]
416
+ .map(m => m[1]
417
+ .replace(/<[^>]+>/g, '')
418
+ .replace(/&nbsp;/gi, '')
419
+ .replace(/&oacute;/g, 'ó')
420
+ .replace(/&aacute;/g, 'á')
421
+ .replace(/&eacute;/g, 'é')
422
+ .replace(/&iacute;/g, 'í')
423
+ .replace(/&uacute;/g, 'ú')
424
+ .replace(/&ntilde;/g, 'ñ')
425
+ .replace(/&amp;/g, '&')
426
+ .trim()
427
+ );
428
+ if (celdas.length === 2 && celdas[0]) datos[celdas[0]] = celdas[1];
429
+ }
430
+
431
+ const fechaResol = datos['Fecha Resolución'] || null;
432
+ const nroResol = datos['Resolución'] !== undefined ? datos['Resolución'] : null;
433
+
434
+ if (fechaResol === null && nroResol === null) {
435
+ // Loguear los campos encontrados para diagnóstico
436
+ const camposEncontrados = Object.keys(datos);
437
+ console.warn('[SiiPortalAuth] ad_empresa2 campos encontrados:', camposEncontrados.length ? camposEncontrados.join(', ') : '(ninguno)');
438
+ if (!camposEncontrados.length) {
439
+ // Puede ser una página de login o error — loguear inicio del HTML
440
+ console.warn('[SiiPortalAuth] HTML ad_empresa2 (primeros 500 chars):', html.slice(0, 500).replace(/\s+/g, ' '));
441
+ }
442
+ throw new Error('SiiPortalAuth: no se encontraron datos de resolución en la respuesta del SII');
443
+ }
444
+
445
+ // Convertir fecha DD-MM-YYYY → YYYY-MM-DD
446
+ const fchResolIso = fechaResol
447
+ ? fechaResol.replace(/^(\d{2})-(\d{2})-(\d{4})$/, '$3-$2-$1')
448
+ : null;
449
+
450
+ return {
451
+ rut: datos['Rut'] || null,
452
+ razonSocial: datos['Razón Social'] || null,
453
+ fch_resol: fchResolIso,
454
+ nro_resol: nroResol !== null ? parseInt(nroResol, 10) : null,
455
+ fecha_autorizacion: datos['Fecha Autorización'] || null,
456
+ };
457
+ }
458
+ }
459
+
460
+ module.exports = SiiPortalAuth;