@devlas/dte-sii 2.9.8 → 2.12.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/LibroGuia.js CHANGED
@@ -40,8 +40,10 @@ class LibroGuia extends LibroBase {
40
40
  throw new Error('Debe establecer la carátula antes de generar');
41
41
  }
42
42
 
43
+ const tipoEnvio = String(this.caratula?.TipoEnvio || '').toUpperCase();
43
44
  const caratulaXml = this._renderCaratula();
44
45
  const resumenXml = this._renderResumen();
46
+ // AJUSTE con documentos: Detalle + ResumenSegmento + ResumenPeriodo
45
47
  const detalleXml = this._renderDetalle();
46
48
 
47
49
  const schemaLoc = 'http://www.sii.cl/SiiDte LibroGuia_v10.xsd';
@@ -87,7 +89,9 @@ class LibroGuia extends LibroBase {
87
89
  _renderResumen() {
88
90
  const resumen = this._buildResumenPeriodo();
89
91
  if (!resumen) return '';
90
-
92
+
93
+ const tipoEnvio = String(this.caratula?.TipoEnvio || '').toUpperCase();
94
+
91
95
  const order = [
92
96
  'TotFolAnulado',
93
97
  'TotGuiaAnulada',
@@ -95,30 +99,37 @@ class LibroGuia extends LibroBase {
95
99
  'TotMntGuiaVta',
96
100
  'TotTraslado',
97
101
  ];
98
-
99
- let xml = '<ResumenPeriodo>';
100
- order.forEach((key) => {
101
- const value = resumen[key];
102
- if (value === undefined || value === null || value === '' || value === false) return;
103
-
104
- if (Array.isArray(value)) {
105
- value.forEach((item) => {
106
- if (item && typeof item === 'object') {
107
- xml += `<${key}>`;
108
- Object.keys(item).forEach((itemKey) => {
109
- if (item[itemKey] !== undefined && item[itemKey] !== null && item[itemKey] !== '') {
110
- xml += `<${itemKey}>${this._escapeXmlText(String(item[itemKey]))}</${itemKey}>`;
111
- }
112
- });
113
- xml += `</${key}>`;
114
- }
115
- });
116
- } else {
117
- xml += `<${key}>${this._escapeXmlText(String(value))}</${key}>`;
118
- }
119
- });
120
- xml += '</ResumenPeriodo>';
121
- return xml;
102
+
103
+ const _renderSection = (tag) => {
104
+ let xml = `<${tag}>`;
105
+ order.forEach((key) => {
106
+ const value = resumen[key];
107
+ if (value === undefined || value === null || value === '' || value === false) return;
108
+ if (Array.isArray(value)) {
109
+ value.forEach((item) => {
110
+ if (item && typeof item === 'object') {
111
+ xml += `<${key}>`;
112
+ Object.keys(item).forEach((itemKey) => {
113
+ if (item[itemKey] !== undefined && item[itemKey] !== null && item[itemKey] !== '') {
114
+ xml += `<${itemKey}>${this._escapeXmlText(String(item[itemKey]))}</${itemKey}>`;
115
+ }
116
+ });
117
+ xml += `</${key}>`;
118
+ }
119
+ });
120
+ } else {
121
+ xml += `<${key}>${this._escapeXmlText(String(value))}</${key}>`;
122
+ }
123
+ });
124
+ xml += `</${tag}>`;
125
+ return xml;
126
+ };
127
+
128
+ // AJUSTE con documentos: ResumenSegmento (delta) + ResumenPeriodo (acumulado)
129
+ if (tipoEnvio === 'AJUSTE' && this.detalle.length > 0) {
130
+ return _renderSection('ResumenSegmento') + _renderSection('ResumenPeriodo');
131
+ }
132
+ return _renderSection('ResumenPeriodo');
122
133
  }
123
134
 
124
135
  _renderDetalle() {
@@ -458,6 +458,53 @@ class SiiCertificacion {
458
458
  }
459
459
  }
460
460
 
461
+ /**
462
+ * Consulta los libros IECV ya enviados al SII para un año dado.
463
+ * Usa el portal QEstLibro (DTEauth?7) para determinar si cada período tiene
464
+ * un envío MENSUAL (TOTAL/LTC) o RECTIFICA (AJUSTE) por tipo de operación.
465
+ *
466
+ * @param {number|string} year - Año (ej: 2026)
467
+ * @returns {Promise<Object>} Mapa con estructura:
468
+ * { '2026-04': { COMPRA: { periodicidad: 'MENSUAL', estado: 'Recibido', codigo: 'COMPRA-...' }, VENTA: {...} } }
469
+ */
470
+ async consultarLibrosExistentes(year) {
471
+ const year4 = String(year || new Date().getFullYear());
472
+
473
+ await this.session.ensureSession('/cgi_dte/UPL/DTEauth?7');
474
+
475
+ const url = `https://maullin.sii.cl/cgi_dte/UPL/QEstLibro` +
476
+ `?rutCompany=${this.rutEmpresa}&dvCompany=${this.dvEmpresa}` +
477
+ `&TrackId=&year=${year4}&month=00&tipo=TODOS`;
478
+
479
+ const resp = await this.session.request(url);
480
+ const html = resp.body || '';
481
+
482
+ if (html.includes('NO ESTA AUTORIZADO') || html.includes('SESION HA EXPIRADO')) {
483
+ throw new Error('QEstLibro: sesión no autorizada o expirada');
484
+ }
485
+
486
+ // Cada fila tiene 6 <td>: periodo, operacion, estado, tipoLibro, periodicidad, cantidad+link
487
+ // Capturamos los 5 primeros <td> de texto plano + el Codigo de la URL del link
488
+ const result = {};
489
+ const rowRegex =
490
+ /<tr><td[^>]*>([^<]+)<\/td><td[^>]*>([^<]+)<\/td><td[^>]*>([^<]+)<\/td><td[^>]*>([^<]+)<\/td><td[^>]*>([^<]+)<\/td><td[^>]*>[^<]*<a href=QDetEstLibro\?Codigo=([^&"'\s>]+)/gi;
491
+
492
+ let m;
493
+ while ((m = rowRegex.exec(html)) !== null) {
494
+ const periodo = m[1].trim();
495
+ const tipo = m[2].trim().toUpperCase(); // COMPRA | VENTA
496
+ const estado = m[3].trim(); // Recibido | ...
497
+ const periodicidad = m[5].trim().toUpperCase(); // MENSUAL | RECTIFICA
498
+ const codigo = m[6].trim();
499
+
500
+ if (!/^\d{4}-\d{2}$/.test(periodo)) continue;
501
+ if (!result[periodo]) result[periodo] = {};
502
+ result[periodo][tipo] = { periodicidad, estado, codigo };
503
+ }
504
+
505
+ return result;
506
+ }
507
+
461
508
  /**
462
509
  * Declara avance en una etapa de certificación con TrackIds específicos
463
510
  * @param {Object} options - Opciones
@@ -859,15 +906,50 @@ class SiiCertificacion {
859
906
  console.log(` [!] No se pudo verificar la declaración: ${verifyErr.message}`);
860
907
  }
861
908
 
862
- // allRejected: SII rechazó todos los sets declarados período incorrecto, no tiene sentido reintentar
909
+ // conErrores: el SII procesó el envío pero encontró errores en el contenido del XML.
910
+ // Detectar en el body de la respuesta de pe_avance3 (no en la re-verificación de pe_avance2).
911
+ const _erroresBody = body || '';
912
+ const _nombresConError = [];
913
+ const _patronesNombre = [
914
+ 'LIBRO DE VENTAS', 'LIBRO DE COMPRAS PARA EXENTOS', 'LIBRO DE COMPRAS',
915
+ 'LIBRO DE GUIAS', 'SET BASICO', 'SET GUIA DE DESPACHO', 'SET FACTURA EXENTA',
916
+ 'SET CASO GENERAL FACTURA COMPRA', 'SET DE SIMULACION',
917
+ ];
918
+ for (const _nom of _patronesNombre) {
919
+ // Buscar fila que contenga el nombre Y "ENVIO CON ERRORES O REPAROS".
920
+ // Se usa un lookahead negativo para que "LIBRO DE COMPRAS" no haga match dentro de
921
+ // "LIBRO DE COMPRAS PARA EXENTOS", evitando falsos positivos.
922
+ const _nomEscapado = _nom.replace(/ /g, '\\s+');
923
+ // Si el nombre es un prefijo de otro nombre más largo, agregar un límite tras él.
924
+ const _otrosNombres = _patronesNombre.filter(n => n !== _nom && n.startsWith(_nom));
925
+ let _patronNom = _nomEscapado;
926
+ if (_otrosNombres.length > 0) {
927
+ // Lookahead negativo: no debe seguir el texto diferenciador de los nombres más largos
928
+ const _sufijos = _otrosNombres.map(n => n.slice(_nom.length).replace(/ /g, '\\s+').trim());
929
+ _patronNom += `(?!\\s+(?:${_sufijos.join('|')}))`;
930
+ }
931
+ const _re = new RegExp(_patronNom + '[\\s\\S]{0,200}?ENVIO CON ERRORES O REPAROS', 'i');
932
+ if (_re.test(_erroresBody)) _nombresConError.push(_nom);
933
+ }
934
+ const conErrores = _nombresConError.length > 0;
935
+ if (conErrores) {
936
+ console.log(` [ERR] SII rechazó por ERRORES DE CONTENIDO: ${_nombresConError.join(', ')}`);
937
+ }
938
+
939
+ // allRejected: SII rechazó todos los sets declarados — período incorrecto, no tiene sentido reintentar.
940
+ // Solo aplica cuando NO hay errores de contenido explícitos (esos deben manejarse por separado).
863
941
  const _declaredCount = Object.keys(fieldMapping).filter(k => sets[k]).length;
864
- const allRejected = !enRevision && camposVacios.length > 0 && camposVacios.length >= _declaredCount && _declaredCount > 0;
942
+ const allRejected = !enRevision && !conErrores && camposVacios.length > 0 && camposVacios.length >= _declaredCount && _declaredCount > 0;
865
943
 
866
944
  return {
867
945
  success: verificado || enRevision,
868
- error: verificacionError || errorMsg || undefined,
946
+ error: conErrores
947
+ ? `ENVIO CON ERRORES O REPAROS: ${_nombresConError.join(', ')}`
948
+ : (verificacionError || errorMsg || undefined),
869
949
  verificado,
870
950
  enRevision,
951
+ conErrores,
952
+ nombresConError: _nombresConError,
871
953
  allRejected,
872
954
  status: declareResponse.status,
873
955
  rawHtml: body,
package/SiiPortalAuth.js CHANGED
@@ -83,7 +83,7 @@ class SiiPortalAuth {
83
83
  if (!pfxBuffer) throw new Error('SiiPortalAuth: pfxBuffer es obligatorio');
84
84
  if (pfxPassword === undefined) throw new Error('SiiPortalAuth: pfxPassword es obligatorio');
85
85
 
86
- const { certPem, keyPem } = SiiPortalAuth._extractPems(pfxBuffer, pfxPassword);
86
+ const { certPem, keyPem, chainPem } = SiiPortalAuth._extractPems(pfxBuffer, pfxPassword);
87
87
  this._certPem = certPem;
88
88
  this._keyPem = keyPem;
89
89
  // Huella para identificar de qué cert es la sesión cacheada
@@ -96,7 +96,11 @@ class SiiPortalAuth {
96
96
  }
97
97
 
98
98
  this._agentePlano = new https.Agent(SII_TLS_OPTS);
99
- this._agenteCert = new https.Agent({ ...SII_TLS_OPTS, cert: certPem, key: keyPem });
99
+ // chainPem incluye hoja + intermedios + raíz extraídos por forge.
100
+ // Necesario para PFX con cadena completa (ej. IDOK) — SII necesita los
101
+ // intermedios para verificar la firma. tls.createSecureContext({ pfx }) no se
102
+ // usa porque OpenSSL 3 (Node 24) rechaza ciertos formatos PKCS12 modernos.
103
+ this._agenteCert = new https.Agent({ ...SII_TLS_OPTS, cert: chainPem, key: keyPem });
100
104
 
101
105
  _instanceRegistry.set(this._certHash, this);
102
106
  }
@@ -106,24 +110,86 @@ class SiiPortalAuth {
106
110
  * (node-forge soporta PKCS12 moderno AES-256 que Node nativo rechaza)
107
111
  * @private
108
112
  */
113
+ /**
114
+ * Extrae clave privada y cadena de certificados de un PFX/PKCS12.
115
+ * Soporta: RSA y EC, 1 a N certificados, cadenas desordenadas,
116
+ * pkcs8ShroudedKeyBag y keyBag, algoritmos legacy (RC2, 3DES) y modernos.
117
+ * Usa forge (no tls.createSecureContext) para ser compatible con formatos
118
+ * PKCS12 que OpenSSL 3 rechaza (ej. IDOK con SHA-1 MAC o AES-256 encryption).
119
+ *
120
+ * @returns {{ certPem: string, keyPem: string, chainPem: string }}
121
+ * certPem - solo el certificado hoja (para _certHash)
122
+ * keyPem - clave privada PEM
123
+ * chainPem - cadena completa ordenada: hoja → intermedios → raíz
124
+ */
109
125
  static _extractPems(pfxBuffer, password) {
110
- const p12Asn1 = forge.asn1.fromDer(pfxBuffer.toString('binary'));
111
- const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password);
112
-
113
- // Clave privada
114
- const keyBags = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag });
115
- const keyBag = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag]?.[0]
116
- || p12.getBags({ bagType: forge.pki.oids.keyBag })[forge.pki.oids.keyBag]?.[0];
117
- if (!keyBag?.key) throw new Error('SiiPortalAuth: no se encontró clave privada en el PFX');
118
- const keyPem = forge.pki.privateKeyToPem(keyBag.key);
119
-
120
- // Certificado
121
- const certBags = p12.getBags({ bagType: forge.pki.oids.certBag });
122
- const certBag = certBags[forge.pki.oids.certBag]?.[0];
123
- if (!certBag?.cert) throw new Error('SiiPortalAuth: no se encontró certificado en el PFX');
124
- const certPem = forge.pki.certificateToPem(certBag.cert);
125
-
126
- return { certPem, keyPem };
126
+ // ── 1. Parsear PKCS12 ────────────────────────────────────────────────────
127
+ let p12;
128
+ try {
129
+ p12 = forge.pkcs12.pkcs12FromAsn1(forge.asn1.fromDer(pfxBuffer.toString('binary')), password);
130
+ } catch (e) {
131
+ throw new Error(`SiiPortalAuth: no se pudo parsear el PFX — ${e.message}`);
132
+ }
133
+
134
+ // ── 2. Clave privada: probar todos los tipos de key bag ──────────────────
135
+ let keyObj = null;
136
+ for (const oid of [forge.pki.oids.pkcs8ShroudedKeyBag, forge.pki.oids.keyBag]) {
137
+ const bags = p12.getBags({ bagType: oid })[oid] || [];
138
+ const found = bags.find(b => b.key);
139
+ if (found) { keyObj = found.key; break; }
140
+ }
141
+ if (!keyObj) throw new Error('SiiPortalAuth: no se encontró clave privada en el PFX');
142
+ const keyPem = forge.pki.privateKeyToPem(keyObj);
143
+
144
+ // ── 3. Certificados: recopilar todos ─────────────────────────────────────
145
+ const certBags = p12.getBags({ bagType: forge.pki.oids.certBag })[forge.pki.oids.certBag] || [];
146
+ const allCerts = certBags.map(b => b.cert).filter(Boolean);
147
+ if (!allCerts.length) throw new Error('SiiPortalAuth: no se encontró certificado en el PFX');
148
+
149
+ // ── 4. Identificar el certificado hoja ───────────────────────────────────
150
+ // Estrategia 1: el cert cuya clave pública coincide con la clave privada
151
+ let leafCert = null;
152
+ try {
153
+ // Funciona para RSA y EC mientras forge pueda derivar la clave pública
154
+ const pubFromKey = forge.pki.setRsaPublicKey
155
+ ? forge.pki.setRsaPublicKey(keyObj.n, keyObj.e) // RSA
156
+ : null;
157
+ if (pubFromKey) {
158
+ const pubPem = forge.pki.publicKeyToPem(pubFromKey);
159
+ leafCert = allCerts.find(c => forge.pki.publicKeyToPem(c.publicKey) === pubPem) ?? null;
160
+ }
161
+ } catch (_) { /* EC u otro — caer a estrategia 2 */ }
162
+
163
+ // Estrategia 2: el primer cert que NO sea CA (basicConstraints.cA = false/absent)
164
+ if (!leafCert) {
165
+ leafCert = allCerts.find(c => {
166
+ const bc = c.getExtension('basicConstraints');
167
+ return !bc || bc.cA !== true;
168
+ }) ?? allCerts[0];
169
+ }
170
+
171
+ // ── 5. Ordenar cadena: hoja → intermedios → raíz ─────────────────────────
172
+ // Construir la cadena siguiendo relaciones issuer→subject
173
+ const remaining = allCerts.filter(c => c !== leafCert);
174
+ const chain = [leafCert];
175
+ let current = leafCert;
176
+
177
+ while (remaining.length > 0) {
178
+ // El siguiente eslabón es el cert cuyo subject coincide con el issuer del actual
179
+ const issuerHash = current.issuer.hash;
180
+ const idx = remaining.findIndex(c => c.subject.hash === issuerHash);
181
+ if (idx < 0) break; // fin de cadena conocida
182
+ current = remaining.splice(idx, 1)[0];
183
+ if (chain.includes(current)) break; // ciclo (self-signed raíz)
184
+ chain.push(current);
185
+ }
186
+ // Appender cualquier cert restante que no pudimos encadenar
187
+ chain.push(...remaining);
188
+
189
+ const certPem = forge.pki.certificateToPem(leafCert);
190
+ const chainPem = chain.map(c => forge.pki.certificateToPem(c)).join('');
191
+
192
+ return { certPem, keyPem, chainPem };
127
193
  }
128
194
 
129
195
  // ─── HTTP helpers ────────────────────────────────────────────────────────────
package/SiiSession.js CHANGED
@@ -157,10 +157,57 @@ class SiiSession {
157
157
  * @returns {Promise<Object>}
158
158
  */
159
159
  async request(url, options = {}) {
160
+ const isPost = (options.method || 'GET').toUpperCase() === 'POST';
161
+ const RETRY_DELAYS = [2000, 4000, 8000];
162
+ const RETRYABLE_CODES = new Set(['ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'TimeoutError']);
163
+ const RETRYABLE_STATUS = new Set([502, 503, 504]);
164
+
165
+ let lastErr;
166
+ for (let attempt = 0; attempt <= RETRY_DELAYS.length; attempt++) {
167
+ if (attempt > 0) {
168
+ const delay = RETRY_DELAYS[attempt - 1];
169
+ log.warn(`[SiiSession] request timeout/5xx — reintentando en ${delay / 1000}s (intento ${attempt}/${RETRY_DELAYS.length})...`);
170
+ await new Promise(r => setTimeout(r, delay));
171
+ }
172
+ try {
173
+ const result = await this._doRequest(url, options, isPost);
174
+ if (RETRYABLE_STATUS.has(result.status)) {
175
+ lastErr = new Error(`HTTP ${result.status}`);
176
+ continue;
177
+ }
178
+ return result;
179
+ } catch (err) {
180
+ const code = err.code || err.constructor?.name || '';
181
+ if (RETRYABLE_CODES.has(code)) { lastErr = err; continue; }
182
+ throw err; // error no retriable — propagar inmediatamente
183
+ }
184
+ }
185
+ throw lastErr;
186
+ }
187
+
188
+ /**
189
+ * Realiza la petición HTTP sin retry. Llamado desde request().
190
+ * @private
191
+ */
192
+ async _doRequest(url, options, isPost) {
160
193
  const res = await got(url, {
161
194
  method: options.method || 'GET',
162
195
  headers: {
163
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
196
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
197
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
198
+ 'Accept-Language': 'es-419,es-US;q=0.9,es;q=0.8,en;q=0.7',
199
+ 'sec-ch-ua': '"Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"',
200
+ 'sec-ch-ua-mobile': '?0',
201
+ 'sec-ch-ua-platform': '"Windows"',
202
+ 'Sec-Fetch-Dest': 'document',
203
+ 'Sec-Fetch-Mode': 'navigate',
204
+ 'Sec-Fetch-Site': 'same-origin',
205
+ 'Sec-Fetch-User': '?1',
206
+ 'Upgrade-Insecure-Requests': '1',
207
+ ...(isPost ? {
208
+ 'Cache-Control': 'max-age=0',
209
+ 'Origin': `https://${this.baseHost}`,
210
+ } : {}),
164
211
  ...(this.cookieJar ? { Cookie: this.cookieJar } : {}),
165
212
  ...(options.headers || {}),
166
213
  },
@@ -168,29 +215,21 @@ class SiiSession {
168
215
  followRedirect: false,
169
216
  throwHttpErrors: false,
170
217
  https: this.tlsOptions || { rejectUnauthorized: false },
171
- responseType: 'buffer', // Obtener como buffer para manejar encoding
218
+ responseType: 'buffer',
219
+ timeout: { request: options.timeoutMs ?? 20000 },
172
220
  });
173
221
 
174
222
  this.cookieJar = this._mergeCookies(this.cookieJar, res.headers['set-cookie']);
175
-
176
- // Detectar encoding del Content-Type y convertir correctamente
177
- let bodyStr;
223
+
178
224
  const contentType = res.headers['content-type'] || '';
179
225
  const buffer = res.body;
180
-
181
- // El SII de Chile usa ISO-8859-1 para TODO su contenido (HTML, XML, text/plain, octet-stream, etc.)
182
- // Forzar ISO-8859-1 para cualquier respuesta de sii.cl que no especifique UTF-8
183
226
  const isSiiUrl = url.includes('sii.cl');
184
227
  const hasUtf8 = contentType.toLowerCase().includes('utf-8');
185
- const forceIso = contentType.toLowerCase().includes('iso-8859-1') ||
228
+ const forceIso = contentType.toLowerCase().includes('iso-8859-1') ||
186
229
  contentType.toLowerCase().includes('latin1');
187
-
188
- // Aplicar ISO-8859-1 si:
189
- // 1. El Content-Type especifica ISO-8859-1/latin1, O
190
- // 2. Es una URL del SII y NO especifica UTF-8 (incluyendo octet-stream, text/*, xml, etc.)
230
+
231
+ let bodyStr;
191
232
  if (forceIso || (isSiiUrl && !hasUtf8)) {
192
- // SII usa ISO-8859-1, convertir cada byte a su codepoint Unicode correspondiente
193
- // ISO-8859-1 es un subconjunto directo de Unicode (codepoints 0-255)
194
233
  bodyStr = '';
195
234
  for (let i = 0; i < buffer.length; i++) {
196
235
  bodyStr += String.fromCharCode(buffer[i]);
@@ -198,12 +237,12 @@ class SiiSession {
198
237
  } else {
199
238
  bodyStr = buffer.toString('utf8');
200
239
  }
201
-
240
+
202
241
  return {
203
242
  status: res.statusCode,
204
243
  headers: res.headers,
205
244
  body: bodyStr,
206
- rawBody: res.body, // Buffer original por si se necesita
245
+ rawBody: res.body,
207
246
  url: res.url,
208
247
  cookieJar: this.cookieJar,
209
248
  };
@@ -162,6 +162,7 @@ class BoletaCert {
162
162
  tipo: 39,
163
163
  folio: folioActual,
164
164
  fechaEmision: fechaHoy,
165
+ precioConIva: true, // Set de pruebas SII define precios "con IVA" (precio al consumidor)
165
166
  emisor: {
166
167
  RUTEmisor: this.emisor.rut,
167
168
  RznSocEmisor: this.emisor.razon_social,
@@ -453,8 +454,34 @@ class BoletaCert {
453
454
  }
454
455
  } else {
455
456
  console.log(` [OK] Enviado - TrackId: ${resultadoBoleta.trackId}`);
457
+
458
+ // Verificar estado SOAP antes de continuar. Error 6 "Rut No Autorizado a Firmar"
459
+ // es fatal — reintentar con otro sec no sirve de nada.
460
+ process.stderr.write(`[PROGRESS]${JSON.stringify({ step: 'BOLETA_SOAP_CHECK' })}\n`);
461
+ console.log(` ⏳ Esperando 20s para verificar estado SOAP del EnvioBOLETA...`);
462
+ await new Promise(r => setTimeout(r, 20000));
463
+ try {
464
+ const _estadoBoleta = await enviador.consultarEstadoSoap(resultadoBoleta.trackId, this.emisor.rut);
465
+ console.log(` [Estado SOAP EnvioBOLETA] ${_estadoBoleta.estado} — ${_estadoBoleta.mensaje}`);
466
+ if (_estadoBoleta.esRechazado) {
467
+ const _glosa = _estadoBoleta.glosa || _estadoBoleta.estado || '?';
468
+ const _esAuth = /autorizado|autorizar|firmar|permiso/i.test(_glosa);
469
+ console.log(` [ERR] EnvioBOLETA rechazado por SII${_esAuth ? ' (error de autorización)' : ''}: ${_glosa}`);
470
+ return {
471
+ success: false,
472
+ error: `EnvioBOLETA rechazado por SII: ${_glosa} (${_estadoBoleta.estado})`,
473
+ fase: 'EnvioBOLETA-SOAP',
474
+ noAutorizado: _esAuth,
475
+ estadoSoap: _estadoBoleta.estado,
476
+ };
477
+ }
478
+ // Estado intermedio (REC, SOK, FOK…) → SII aún procesa, continuar con RCOF
479
+ } catch (_e) {
480
+ console.log(` [!] No se pudo verificar estado SOAP EnvioBOLETA: ${_e.message} — continuando`);
481
+ }
456
482
  }
457
-
483
+
484
+ process.stderr.write(`[PROGRESS]${JSON.stringify({ step: 'BOLETA_RCOF' })}\n`);
458
485
  // 5 & 6. Generar y enviar RCOF — loop hasta que SII lo acepte o se agoten intentos.
459
486
  // Estrategia: enviar → si ok, esperar 30s → consultar estado (EPR o RPR = éxito).
460
487
  // Si DUPLICADO: el SII ya tiene un RCOF para este RUT/período. El entorno de certificación
@@ -528,8 +555,17 @@ class BoletaCert {
528
555
  break;
529
556
  }
530
557
 
531
- // SII lo rechazó explícitamente — probar con el siguiente sec
532
- console.log(` [⚠️ RECHAZADO] RCOF sec=${secActual} rechazado (${estadoRcof.estado}: ${estadoRcof.glosa || estadoRcof.mensaje}). Probando sec=${secActual + 1}...`);
558
+ // SII lo rechazó explícitamente.
559
+ // Si es un error de autorización (error 6 "Rut No Autorizado a Firmar"),
560
+ // cambiar sec no sirve — abortar inmediatamente.
561
+ const _glosaRcof = estadoRcof.glosa || estadoRcof.mensaje || '';
562
+ const _esAuthRcof = /autorizado|autorizar|firmar|permiso/i.test(_glosaRcof);
563
+ if (_esAuthRcof) {
564
+ console.log(` [ERR-AUTH] RCOF rechazado por error de autorización (${estadoRcof.estado}): ${_glosaRcof} — abortando (reintentar no ayuda)`);
565
+ resultadoRCOF = { ok: false, error: `Empresa no autorizada para firmar RCOF: ${_glosaRcof}`, noAutorizado: true };
566
+ break;
567
+ }
568
+ console.log(` [⚠️ RECHAZADO] RCOF sec=${secActual} rechazado (${estadoRcof.estado}: ${_glosaRcof}). Probando sec=${secActual + 1}...`);
533
569
  trackIdRCOFloop = null;
534
570
  }
535
571
 
@@ -542,7 +578,8 @@ class BoletaCert {
542
578
  success: false,
543
579
  error: resultadoRCOF.error,
544
580
  fase: 'RCOF',
545
- trackIdBoleta: resultadoBoleta.trackId
581
+ noAutorizado: resultadoRCOF.noAutorizado || false,
582
+ trackIdBoleta: resultadoBoleta.trackId,
546
583
  };
547
584
  }
548
585
  }