@devlas/dte-sii 2.9.8 → 2.11.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 ────────────────────────────────────────────────────────────