@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/CafSolicitor.js +33 -5
- package/EnviadorSII.js +100 -64
- package/LibroCompraVenta.js +141 -17
- package/LibroGuia.js +36 -25
- package/SiiCertificacion.js +85 -3
- package/SiiPortalAuth.js +85 -19
- package/SiiSession.js +56 -17
- package/cert/BoletaCert.js +41 -4
- package/cert/CertRunner.js +1123 -1209
- package/cert/LibroCompras.js +3 -2
- package/cert/LibroGuias.js +2 -1
- package/cert/LibroVentas.js +2 -1
- package/cert/MuestrasImpresas.js +831 -131
- package/package.json +2 -3
- package/test-qdetestlibro.js +174 -0
- package/utils/progress.js +4 -0
- package/utils/browser.js +0 -79
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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() {
|
package/SiiCertificacion.js
CHANGED
|
@@ -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
|
-
//
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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',
|
|
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
|
-
|
|
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,
|
|
245
|
+
rawBody: res.body,
|
|
207
246
|
url: res.url,
|
|
208
247
|
cookieJar: this.cookieJar,
|
|
209
248
|
};
|
package/cert/BoletaCert.js
CHANGED
|
@@ -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
|
|
532
|
-
|
|
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
|
-
|
|
581
|
+
noAutorizado: resultadoRCOF.noAutorizado || false,
|
|
582
|
+
trackIdBoleta: resultadoBoleta.trackId,
|
|
546
583
|
};
|
|
547
584
|
}
|
|
548
585
|
}
|