@devlas/dte-sii 2.12.25 → 2.13.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 +16 -2
- package/DTE.js +15 -2
- package/EnviadorSII.js +31 -11
- package/Envio.js +5 -0
- package/FolioRegistry.js +7 -2
- package/FolioService.js +62 -3
- package/README.md +77 -0
- package/SiiCertificacion.js +59 -10
- package/SiiPortalAuth.js +131 -6
- package/SiiSession.js +27 -9
- package/WsReclamo.js +7 -6
- package/cert/BoletaCert.js +37 -10
- package/cert/CertRunner.js +261 -33
- package/cert/ConfigLoader.js +4 -2
- package/cert/IntercambioCert.js +4 -1
- package/dte-sii.d.ts +48 -1
- package/index.js +2 -0
- package/package.json +1 -1
- package/utils/httpDebug.js +223 -0
- package/utils/index.js +2 -0
- package/utils/paths.js +152 -0
- package/utils/sanitize.js +48 -0
- package/utils/xml.js +4 -2
package/cert/CertRunner.js
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
|
|
17
17
|
const fs = require('fs');
|
|
18
18
|
const path = require('path');
|
|
19
|
+
const { resolveArtifactDir } = require('../utils/paths');
|
|
20
|
+
const { registrarHttpDebug } = require('../utils/httpDebug');
|
|
19
21
|
|
|
20
22
|
// Core
|
|
21
23
|
const { Certificado, EnvioDTE } = require('../index');
|
|
@@ -52,6 +54,7 @@ const { STEPS, emitProgress } = require('../utils/progress');
|
|
|
52
54
|
* @property {Object} receptor - { rut, razon_social, giro, direccion, comuna, ciudad }
|
|
53
55
|
* @property {string} [ambiente='certificacion']
|
|
54
56
|
* @property {string} [debugDir]
|
|
57
|
+
* @property {string} [stateDir] - Estado que persiste entre corridas (periodo-libros, ltc-totales). Default: debugDir.
|
|
55
58
|
* @property {string} [sessionPath]
|
|
56
59
|
*/
|
|
57
60
|
|
|
@@ -64,7 +67,28 @@ class CertRunner {
|
|
|
64
67
|
|
|
65
68
|
this.config = config;
|
|
66
69
|
this.ambiente = config.ambiente || 'certificacion';
|
|
67
|
-
|
|
70
|
+
// El consumidor inyecta el directorio (ver utils/paths.resolveRunDir para el layout
|
|
71
|
+
// recomendado). El fallback ya no depende de process.cwd(), que cambiaba según desde
|
|
72
|
+
// dónde se lanzara el proceso y hacía impredecible dónde quedaban los artefactos.
|
|
73
|
+
this.debugDir = resolveArtifactDir(config.debugDir, config.debugDir ? '' : 'cert-v2');
|
|
74
|
+
/**
|
|
75
|
+
* Directorio del estado que SOBREVIVE a la corrida, separado de `debugDir`.
|
|
76
|
+
*
|
|
77
|
+
* `debugDir` es por corrida (y por etapa, cuando lo inyecta un orquestador): sirve
|
|
78
|
+
* para el rastro, que justamente se quiere aislado. Pero hay estado que NO es rastro
|
|
79
|
+
* y cuyo valor está en acumularse entre corridas — sobre todo `periodo-libros.json`,
|
|
80
|
+
* que guarda hasta qué período retrocedió la búsqueda de un mes contable abierto.
|
|
81
|
+
*
|
|
82
|
+
* Guardarlo en `debugDir` lo hacía frágil: si el proceso moría (timeout, SIGKILL)
|
|
83
|
+
* antes del volcado final al directorio estable, se perdía TODA la búsqueda y el
|
|
84
|
+
* intento siguiente arrancaba desde el mismo período de siempre. En una empresa con
|
|
85
|
+
* muchas pruebas encima, donde el SII rechaza decenas de períodos seguidos con "LNC
|
|
86
|
+
* - Libro Cerrado", eso significa no avanzar nunca por más que se reintente.
|
|
87
|
+
*
|
|
88
|
+
* Por defecto es `debugDir`, así que el comportamiento no cambia para quien no lo
|
|
89
|
+
* inyecte.
|
|
90
|
+
*/
|
|
91
|
+
this.stateDir = config.stateDir || this.debugDir;
|
|
68
92
|
this.sessionPath = config.sessionPath || path.join(this.debugDir, 'session.json');
|
|
69
93
|
|
|
70
94
|
// Componentes (lazy init)
|
|
@@ -115,6 +139,9 @@ class CertRunner {
|
|
|
115
139
|
pfxPath: this.config.certificado.path,
|
|
116
140
|
pfxPassword: this.config.certificado.password,
|
|
117
141
|
debugDir: this.debugDir,
|
|
142
|
+
// El registro de folios ya anulados debe sobrevivir a la corrida, o cada
|
|
143
|
+
// ejecución vuelve a intentar anular los mismos folios y los pierde igual.
|
|
144
|
+
stateDir: this.stateDir,
|
|
118
145
|
sessionPath: this.sessionPath, // Usar sesión compartida
|
|
119
146
|
retries: 3,
|
|
120
147
|
});
|
|
@@ -260,8 +287,44 @@ class CertRunner {
|
|
|
260
287
|
// Limpiar ANTES de pedir rompe ese espiral. Es seguro: solo se anulan folios
|
|
261
288
|
// que el SII reporta como no recepcionados, y los que ya fueron anulados o
|
|
262
289
|
// usados se rechazan sin efecto.
|
|
290
|
+
//
|
|
291
|
+
// Pero se limpia SOLO SI HACE FALTA (ver el sondeo dentro del loop). Hacerlo
|
|
292
|
+
// siempre era caro y casi nunca útil, y "se rechazan sin efecto" es engañoso: sin
|
|
293
|
+
// efecto en el SII, sí, pero cada rechazo cuesta requests y minutos de corrida.
|
|
263
294
|
for (const tipoDte of Object.keys(cafRequired)) {
|
|
264
295
|
try {
|
|
296
|
+
// ── ¿Hace falta limpiar? ────────────────────────────────────────────────
|
|
297
|
+
// Antes esto se ejecutaba SIEMPRE, y casi nunca servía. Medido el 11/08/2026
|
|
298
|
+
// en una corrida real: 87 intentos de anulación, 0 anulados, todos rechazados
|
|
299
|
+
// por "ya anulado" — mientras el SII autorizaba 73 folios de factura y solo
|
|
300
|
+
// hacían falta 4. Minutos de corrida y ~180 requests al SII para nada.
|
|
301
|
+
//
|
|
302
|
+
// El sondeo cuesta ~3 requests, no emite nada y responde la única pregunta
|
|
303
|
+
// que importa: ¿el SII está racionando este tipo?
|
|
304
|
+
const tope = await this.folioService.consultarTope({ tipoDte: Number(tipoDte) });
|
|
305
|
+
const necesarios = cafRequired[tipoDte];
|
|
306
|
+
|
|
307
|
+
// `sinTope` = el SII ni siquiera publica MAX_AUTOR, o sea no está limitando
|
|
308
|
+
// este tipo. Limpiar ahí es puro costo.
|
|
309
|
+
// Con tope publicado se exige un margen de 3x sobre lo necesario, no apenas
|
|
310
|
+
// lo justo: el racionamiento se endurece rápido y conviene actuar antes de
|
|
311
|
+
// quedar contra la pared, que es el bloqueo duro del que cuesta salir.
|
|
312
|
+
const holgado = tope.sinTope || (tope.maxAutor !== null && tope.maxAutor >= necesarios * 3);
|
|
313
|
+
if (holgado) {
|
|
314
|
+
console.log(
|
|
315
|
+
` Tipo ${tipoDte}: sin limpieza previa — ` +
|
|
316
|
+
(tope.sinTope
|
|
317
|
+
? 'el SII no está racionando este tipo'
|
|
318
|
+
: `MAX_AUTOR=${tope.maxAutor} para ${necesarios} folio(s) necesarios`)
|
|
319
|
+
);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
console.warn(
|
|
324
|
+
`[CertRunner] Tipo ${tipoDte}: MAX_AUTOR=${tope.maxAutor} ajustado para ${necesarios} ` +
|
|
325
|
+
`folio(s) (FOLIOS_DISP=${tope.foliosDisp}) — limpiando folios sin usar...`
|
|
326
|
+
);
|
|
327
|
+
|
|
265
328
|
// ACOTADA a propósito. Solo interesan los folios que ESTA certificación
|
|
266
329
|
// timbró y dejó sin usar, que son de hoy. Sin el corte por antigüedad la
|
|
267
330
|
// limpieza barre el historial completo de la empresa: en un RUT con
|
|
@@ -1364,7 +1427,7 @@ class CertRunner {
|
|
|
1364
1427
|
* @returns {string} Período en formato YYYY-MM
|
|
1365
1428
|
*/
|
|
1366
1429
|
_getPeriodoLibros() {
|
|
1367
|
-
const stateFile = path.join(this.
|
|
1430
|
+
const stateFile = path.join(this.stateDir, 'periodo-libros.json');
|
|
1368
1431
|
|
|
1369
1432
|
// Cargar estado existente o crear nuevo
|
|
1370
1433
|
let state = { periodo: null, lastRun: null };
|
|
@@ -1398,7 +1461,7 @@ class CertRunner {
|
|
|
1398
1461
|
* @returns {string} Nuevo período
|
|
1399
1462
|
*/
|
|
1400
1463
|
_decrementarPeriodoLibros() {
|
|
1401
|
-
const stateFile = path.join(this.
|
|
1464
|
+
const stateFile = path.join(this.stateDir, 'periodo-libros.json');
|
|
1402
1465
|
const currentPeriodo = this._getPeriodoLibros();
|
|
1403
1466
|
|
|
1404
1467
|
const [year, month] = currentPeriodo.split('-').map(Number);
|
|
@@ -1433,7 +1496,7 @@ class CertRunner {
|
|
|
1433
1496
|
* @param {string} periodo - Período en formato YYYY-MM
|
|
1434
1497
|
*/
|
|
1435
1498
|
resetPeriodoLibros(periodo) {
|
|
1436
|
-
const stateFile = path.join(this.
|
|
1499
|
+
const stateFile = path.join(this.stateDir, 'periodo-libros.json');
|
|
1437
1500
|
const state = { periodo, lastRun: new Date().toISOString() };
|
|
1438
1501
|
|
|
1439
1502
|
try {
|
|
@@ -1452,7 +1515,7 @@ class CertRunner {
|
|
|
1452
1515
|
* @private
|
|
1453
1516
|
*/
|
|
1454
1517
|
_guardarLtcTotales(periodo, tipo, resumen) {
|
|
1455
|
-
const ltcFile = path.join(this.
|
|
1518
|
+
const ltcFile = path.join(this.stateDir, 'ltc-totales.json');
|
|
1456
1519
|
let data = {};
|
|
1457
1520
|
try {
|
|
1458
1521
|
if (fs.existsSync(ltcFile)) data = JSON.parse(fs.readFileSync(ltcFile, 'utf8'));
|
|
@@ -1475,7 +1538,7 @@ class CertRunner {
|
|
|
1475
1538
|
* @private
|
|
1476
1539
|
*/
|
|
1477
1540
|
_leerLtcTotales(periodo, tipo) {
|
|
1478
|
-
const ltcFile = path.join(this.
|
|
1541
|
+
const ltcFile = path.join(this.stateDir, 'ltc-totales.json');
|
|
1479
1542
|
try {
|
|
1480
1543
|
if (fs.existsSync(ltcFile)) {
|
|
1481
1544
|
const data = JSON.parse(fs.readFileSync(ltcFile, 'utf8'));
|
|
@@ -2374,10 +2437,17 @@ class CertRunner {
|
|
|
2374
2437
|
},
|
|
2375
2438
|
};
|
|
2376
2439
|
if (body) opts.headers['Content-Length'] = Buffer.byteLength(body);
|
|
2440
|
+
const _t0 = Date.now();
|
|
2377
2441
|
const req = https.request(opts, (res) => {
|
|
2378
2442
|
let data = '';
|
|
2379
2443
|
res.on('data', c => data += c);
|
|
2380
|
-
res.on('end', () =>
|
|
2444
|
+
res.on('end', () => {
|
|
2445
|
+
registrarHttpDebug({
|
|
2446
|
+
url: urlStr, method, status: res.statusCode, headers: res.headers,
|
|
2447
|
+
body: data, reqBody: body, ms: Date.now() - _t0, cliente: 'pfeInternet',
|
|
2448
|
+
});
|
|
2449
|
+
resolve({ status: res.statusCode, body: data, headers: res.headers });
|
|
2450
|
+
});
|
|
2381
2451
|
});
|
|
2382
2452
|
req.on('error', reject);
|
|
2383
2453
|
if (body) req.write(body);
|
|
@@ -2683,10 +2753,23 @@ class CertRunner {
|
|
|
2683
2753
|
},
|
|
2684
2754
|
};
|
|
2685
2755
|
if (bodyBuf) opts.headers['Content-Length'] = bodyBuf.length;
|
|
2756
|
+
const _t0 = Date.now();
|
|
2686
2757
|
const req = https.request(opts, (res) => {
|
|
2687
2758
|
const chunks = [];
|
|
2688
2759
|
res.on('data', c => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)));
|
|
2689
|
-
res.on('end', () =>
|
|
2760
|
+
res.on('end', () => {
|
|
2761
|
+
const _body = Buffer.concat(chunks).toString('utf8');
|
|
2762
|
+
// pdfdteInternet es un TERCER cliente HTTP, aparte de SiiSession y
|
|
2763
|
+
// SiiPortalAuth: no pasa por ninguno de los dos hooks, así que todo el flujo
|
|
2764
|
+
// de muestras impresas era invisible en el debug. Se notó el 11/08/2026, al
|
|
2765
|
+
// quedar bloqueada la subida con un mensaje del SII que no aparecía en
|
|
2766
|
+
// ninguna captura porque estas llamadas simplemente no se registraban.
|
|
2767
|
+
registrarHttpDebug({
|
|
2768
|
+
url: urlStr, method, status: res.statusCode, headers: res.headers,
|
|
2769
|
+
body: _body, reqBody: body, ms: Date.now() - _t0, cliente: 'pdfdteInternet',
|
|
2770
|
+
});
|
|
2771
|
+
resolve({ status: res.statusCode, body: _body, headers: res.headers });
|
|
2772
|
+
});
|
|
2690
2773
|
});
|
|
2691
2774
|
req.on('error', reject);
|
|
2692
2775
|
if (bodyBuf) req.write(bodyBuf);
|
|
@@ -2817,15 +2900,17 @@ class CertRunner {
|
|
|
2817
2900
|
} catch (_) {}
|
|
2818
2901
|
}
|
|
2819
2902
|
|
|
2820
|
-
|
|
2903
|
+
// ⚠️ No se deduce la cantidad de documentos de la tabla: el string numérico corto
|
|
2904
|
+
// que se tomaba como contador es en realidad el dígito verificador del RUT del
|
|
2905
|
+
// usuario. Reportaba "8 documentos" con 41 subidos. Ver el mismo caso en
|
|
2906
|
+
// _subirMuestrasImpresasPortal.
|
|
2907
|
+
let estado = null, fechaEnvio = null, revId = null;
|
|
2821
2908
|
if (table) {
|
|
2822
2909
|
const estadoStr = table.find(s => typeof s === 'string' && /APROBADO|POR REVISAR|RECHAZADO|INGRESO|EN REVISI/i.test(s));
|
|
2823
2910
|
const errorStr = table.find(s => typeof s === 'string' && /El estado de la postulacion/i.test(s));
|
|
2824
2911
|
const fechaStr = table.find(s => typeof s === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(s));
|
|
2825
|
-
const nStr = table.find(s => typeof s === 'string' && /^\d+$/.test(s) && +s > 0 && +s < 500);
|
|
2826
2912
|
estado = estadoStr ? estadoStr.toUpperCase() : (errorStr ? 'BLOQUEADO' : null);
|
|
2827
2913
|
fechaEnvio = fechaStr || null;
|
|
2828
|
-
numImpresos = nStr ? parseInt(nStr, 10) : null;
|
|
2829
2914
|
}
|
|
2830
2915
|
// Fallback: regex directo en el body
|
|
2831
2916
|
if (!estado) {
|
|
@@ -2839,7 +2924,7 @@ class CertRunner {
|
|
|
2839
2924
|
let n = 0; for (const c of m[1]) n = n * 64 + GWT_B64_V.indexOf(c);
|
|
2840
2925
|
if (n >= 100000 && n <= 9999999) { revId = n; break; }
|
|
2841
2926
|
}
|
|
2842
|
-
return { estado, revId,
|
|
2927
|
+
return { estado, revId, fechaEnvio };
|
|
2843
2928
|
} catch (err) {
|
|
2844
2929
|
return { estado: null, error: err.message };
|
|
2845
2930
|
}
|
|
@@ -2918,6 +3003,33 @@ class CertRunner {
|
|
|
2918
3003
|
if (leeResp.status !== 200) throw new Error(`pdfdteInternet leeImpreso HTTP ${leeResp.status}`);
|
|
2919
3004
|
|
|
2920
3005
|
// ── Parsear string table GWT (siempre antes de flags/version al final) ─────
|
|
3006
|
+
/**
|
|
3007
|
+
* Extrae el mensaje de error que el SII mete en la tabla de strings de GWT.
|
|
3008
|
+
*
|
|
3009
|
+
* ⚠️ El portal contesta HTTP 200 y `//OK` incluso cuando rechaza: el motivo viaja
|
|
3010
|
+
* como una frase en castellano dentro de la tabla. Antes se buscaba UNA frase fija
|
|
3011
|
+
* ("El estado de la postulacion"), así que cualquier otro rechazo pasaba de largo:
|
|
3012
|
+
* `leeImpreso` lo leía como "no hay revisión previa" y seguía, y `creaLista` moría
|
|
3013
|
+
* después con "no se pudo extraer ID de revisión de: //OK[0,0,2,...".
|
|
3014
|
+
* Caso real (11/08/2026): "El contribuyente ... no ha solicitado Set de pruebas."
|
|
3015
|
+
* venía en AMBAS respuestas y no se reportó en ninguna.
|
|
3016
|
+
*
|
|
3017
|
+
* El criterio es amplio a propósito: es preferible cortar mostrando el texto literal
|
|
3018
|
+
* del SII que seguir a ciegas y fallar más adelante sin él.
|
|
3019
|
+
*/
|
|
3020
|
+
const _mensajeErrorGwt = (body) => {
|
|
3021
|
+
const tabla = _parseGwtStringTable(body);
|
|
3022
|
+
if (!tabla) return null;
|
|
3023
|
+
return tabla.find(x =>
|
|
3024
|
+
typeof x === 'string' &&
|
|
3025
|
+
x.length > 15 &&
|
|
3026
|
+
// El resto de la tabla son nombres de clase Java y tipos GWT.
|
|
3027
|
+
!/^[\w.$]+\/\d+$/.test(x) && !x.includes('cl.sii.sdi') &&
|
|
3028
|
+
/\s/.test(x) &&
|
|
3029
|
+
/(no ha|no est|no se|no corresp|debe |error|inv[aá]lid|rechaz|El estado|El contribuyente)/i.test(x)
|
|
3030
|
+
) || null;
|
|
3031
|
+
};
|
|
3032
|
+
|
|
2921
3033
|
const _parseGwtStringTable = (body) => {
|
|
2922
3034
|
const stIdx = body.lastIndexOf(',["');
|
|
2923
3035
|
if (stIdx === -1) return null;
|
|
@@ -2936,10 +3048,11 @@ class CertRunner {
|
|
|
2936
3048
|
const estadoActual = estadoMatch ? estadoMatch[1].toUpperCase() : null;
|
|
2937
3049
|
|
|
2938
3050
|
// Detectar mensaje de error del SII: tabla parseada o regex directo en body (por si \x27 falla)
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
3051
|
+
// Sin estado, cualquier frase de error de la tabla es el motivo real. Antes solo se
|
|
3052
|
+
// reconocía "El estado de la postulacion", así que "El contribuyente ... no ha
|
|
3053
|
+
// solicitado Set de pruebas." se leía como "no hay revisión previa" y el flujo
|
|
3054
|
+
// seguía hasta reventar en creaLista sin ese texto.
|
|
3055
|
+
const errorEstadoMsg = !estadoActual && _mensajeErrorGwt(leeResp.body);
|
|
2943
3056
|
if (errorEstadoMsg) {
|
|
2944
3057
|
// OJO: el SII devuelve "El estado de la postulacion NO es: 'DOCUMENTOS IMPRESOS'" en DOS
|
|
2945
3058
|
// situaciones distintas, y el mensaje no las distingue:
|
|
@@ -2959,20 +3072,23 @@ class CertRunner {
|
|
|
2959
3072
|
// Si ya hay una revisión activa (no rechazada ni aprobada), no se puede re-subir.
|
|
2960
3073
|
// El SII sólo permite crear nueva revisión cuando el estado es RECHAZADO o APROBADO.
|
|
2961
3074
|
if (estadoActual && estadoActual !== 'RECHAZADO' && estadoActual !== 'APROBADO') {
|
|
2962
|
-
// Extraer detalles adicionales de la respuesta leeImpreso para diagnóstico
|
|
2963
|
-
|
|
3075
|
+
// Extraer detalles adicionales de la respuesta leeImpreso para diagnóstico.
|
|
3076
|
+
//
|
|
3077
|
+
// ⚠️ NO se intenta sacar de acá la cantidad de documentos. La tabla de strings trae
|
|
3078
|
+
// el RUT del usuario partido en número y dígito verificador, y "buscar el primer
|
|
3079
|
+
// string numérico menor a 500" agarraba ese DV: reportaba 8 documentos cuando se
|
|
3080
|
+
// habían subido 41. Un dato inventado es peor que ninguno, así que se omite.
|
|
3081
|
+
let fechaEnvio = null, revId = null;
|
|
2964
3082
|
if (leeTable) {
|
|
2965
3083
|
const fechaStr = leeTable.find(s => typeof s === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(s));
|
|
2966
|
-
|
|
2967
|
-
fechaEnvio = fechaStr || null;
|
|
2968
|
-
numImpresos = nStr ? parseInt(nStr, 10) : null;
|
|
3084
|
+
fechaEnvio = fechaStr || null;
|
|
2969
3085
|
}
|
|
2970
3086
|
for (const m of leeResp.body.matchAll(/'([A-Za-z0-9$_]{3,7})'/g)) {
|
|
2971
3087
|
let n = 0; for (const c of m[1]) n = n * 64 + GWT_B64.indexOf(c);
|
|
2972
3088
|
if (n >= 100000 && n <= 9999999) { revId = n; break; }
|
|
2973
3089
|
}
|
|
2974
|
-
console.log(` → Estado: ${estadoActual}${revId ? ` | Revisión #${revId}` : ''}${fechaEnvio ? ` | Enviado: ${fechaEnvio}` : ''}
|
|
2975
|
-
return { success: true, alreadyCompleted: true, estado: estadoActual, revId,
|
|
3090
|
+
console.log(` → Estado: ${estadoActual}${revId ? ` | Revisión #${revId}` : ''}${fechaEnvio ? ` | Enviado: ${fechaEnvio}` : ''} — ya enviadas, no se requiere re-subida`);
|
|
3091
|
+
return { success: true, alreadyCompleted: true, estado: estadoActual, revId, fechaEnvio };
|
|
2976
3092
|
}
|
|
2977
3093
|
if (estadoActual) {
|
|
2978
3094
|
console.log(` → Estado previo: ${estadoActual} — creando nueva revisión`);
|
|
@@ -2991,11 +3107,22 @@ class CertRunner {
|
|
|
2991
3107
|
if (creaResp.status !== 200 || !creaResp.body.startsWith('//OK')) {
|
|
2992
3108
|
throw new Error(`pdfdteInternet creaLista falló HTTP ${creaResp.status}: ${creaResp.body.substring(0, 200)}`);
|
|
2993
3109
|
}
|
|
2994
|
-
// Detectar error de SII dentro del //OK
|
|
2995
|
-
|
|
2996
|
-
|
|
3110
|
+
// Detectar error de SII dentro del //OK.
|
|
3111
|
+
//
|
|
3112
|
+
// ⚠️ El portal contesta HTTP 200 y `//OK` incluso cuando rechaza: el motivo viaja
|
|
3113
|
+
// como una frase en castellano dentro de la tabla de strings de GWT. Antes acá se
|
|
3114
|
+
// buscaba UNA sola frase ("El estado de la postulacion"), así que cualquier otro
|
|
3115
|
+
// rechazo pasaba de largo, no se encontraba el ID de revisión y el usuario recibía
|
|
3116
|
+
// "no se pudo extraer ID de revisión de: //OK[0,0,2,...".
|
|
3117
|
+
// Caso real (11/08/2026): "El contribuyente 78206276-K no ha solicitado Set de
|
|
3118
|
+
// pruebas." quedó completamente oculto detrás de ese mensaje.
|
|
3119
|
+
//
|
|
3120
|
+
// Ahora se reconoce cualquier frase con pinta de mensaje al usuario. Es deliberado
|
|
3121
|
+
// que el criterio sea amplio: es preferible cortar con el texto literal del SII que
|
|
3122
|
+
// seguir a ciegas y fallar más adelante sin él.
|
|
3123
|
+
const creaErrorMsg = _mensajeErrorGwt(creaResp.body);
|
|
2997
3124
|
if (creaErrorMsg) {
|
|
2998
|
-
throw new Error(`
|
|
3125
|
+
throw new Error(`El SII rechazó la subida de muestras: ${creaErrorMsg}`);
|
|
2999
3126
|
}
|
|
3000
3127
|
|
|
3001
3128
|
// Extraer ID de revisión (Long codificado en GWT base64, ej: 'BAqo' = 264872)
|
|
@@ -3065,14 +3192,66 @@ class CertRunner {
|
|
|
3065
3192
|
fs.writeFileSync(path.join(debugDir, `pdfte-enviar-sii-resp.txt`), enviarResp.body, 'utf8');
|
|
3066
3193
|
}
|
|
3067
3194
|
|
|
3068
|
-
|
|
3195
|
+
// ── Paso 5: verificar en qué quedó la revisión ─────────────────────────
|
|
3196
|
+
//
|
|
3197
|
+
// ⚠️ `solicitaRevisionSII` devuelve `//OK[0,[],0,7]` (lista vacía) tanto si el SII
|
|
3198
|
+
// acepta como si rechaza. Dar por buena la subida con solo ese OK es exactamente el
|
|
3199
|
+
// error que se venía arrastrando: `pdfdteInternet` es el servicio **validaPdfDte**,
|
|
3200
|
+
// un validador AUTOMÁTICO de formato, no una bandeja de revisores. Puede pasar la
|
|
3201
|
+
// revisión a RECHAZADO en minutos.
|
|
3202
|
+
//
|
|
3203
|
+
// Caso real (11/08/2026): la revisión 271384 se creó 21:53, se subieron 41 PDFs, el
|
|
3204
|
+
// envío devolvió OK, la etapa se marcó completada... y el portal ya la tenía
|
|
3205
|
+
// RECHAZADO. El usuario quedó esperando "3 a 7 días hábiles" por algo que ya había
|
|
3206
|
+
// fallado. Por eso se relee el estado y se devuelve tal cual.
|
|
3207
|
+
let estadoPost = null;
|
|
3208
|
+
try {
|
|
3209
|
+
const verifResp = await gwtPost(leeBody);
|
|
3210
|
+
const m = verifResp.body.match(/"(APROBADO|POR REVISAR|EN REVISI[OÓ]N|RECHAZADO|INGRESO|ENVIADO AL SII)"/i);
|
|
3211
|
+
estadoPost = m ? m[1].toUpperCase() : null;
|
|
3212
|
+
if (debugDir) {
|
|
3213
|
+
fs.writeFileSync(path.join(debugDir, 'pdfte-estado-post-envio.txt'), verifResp.body, 'utf8');
|
|
3214
|
+
}
|
|
3215
|
+
} catch (e) {
|
|
3216
|
+
// No poder confirmar no invalida el envío: se reporta como desconocido.
|
|
3217
|
+
console.warn(` [!] No se pudo confirmar el estado tras el envío: ${e.message}`);
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
if (estadoPost === 'RECHAZADO') {
|
|
3221
|
+
console.error(` [ERR] El SII RECHAZÓ la revisión ${revId} de muestras impresas.`);
|
|
3222
|
+
return {
|
|
3223
|
+
success: false,
|
|
3224
|
+
rechazado: true,
|
|
3225
|
+
revId,
|
|
3226
|
+
estado: estadoPost,
|
|
3227
|
+
error: `El SII rechazó las muestras impresas (revisión ${revId}). Revisa el detalle en el portal ` +
|
|
3228
|
+
`de Muestras Impresas y corrige el formato de los documentos antes de volver a subirlos.`,
|
|
3229
|
+
};
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
console.log(` ✓ Solicitud enviada al SII correctamente (${pdfPaths.length} PDFs, revisión ${revId})` +
|
|
3233
|
+
(estadoPost ? ` — estado: ${estadoPost}` : ''));
|
|
3069
3234
|
process.stdout.write('\nMUESTRAS SUBIDAS EXITOSAMENTE\n');
|
|
3070
|
-
return { success: true, revId };
|
|
3235
|
+
return { success: true, revId, estado: estadoPost };
|
|
3071
3236
|
}
|
|
3072
3237
|
|
|
3073
3238
|
// ═══════════════════════════════════════════════════════════════
|
|
3074
3239
|
// BOLETA ELECTRÓNICA — automatización portal certBolElectDteInternet
|
|
3075
3240
|
// ═══════════════════════════════════════════════════════════════
|
|
3241
|
+
//
|
|
3242
|
+
// Métodos que se usan para la CERTIFICACIÓN DE BOLETAS (flujo, en orden):
|
|
3243
|
+
// 1. obtenerSetBoletaPortal → descarga el Set de Pruebas (?SET=1)
|
|
3244
|
+
// 2. (BoletaCert.ejecutarCertificacion emite EnvioBOLETA + RCOF → devuelve trackId)
|
|
3245
|
+
// 3. solicitarValidacionBoletaPortal → declara el trackId del envío (?SET=2)
|
|
3246
|
+
// 4. completarDeclaracionBoletaPortal→ declara cumplimiento y autoriza producción
|
|
3247
|
+
//
|
|
3248
|
+
// Apoyo (solo lectura / recuperación de estado):
|
|
3249
|
+
// - consultarEstadoBoletaPortal → estado en el portal GWT de boleta
|
|
3250
|
+
// - verificarAutorizacionBoleta → confirma si la empresa ya emite tipo 39
|
|
3251
|
+
//
|
|
3252
|
+
// El portal de boleta usa GWT-RPC (certBolElectDteInternet), distinto del portal
|
|
3253
|
+
// pe_avance usado para facturas/DTE. La certificación de boletas NO usa las fases
|
|
3254
|
+
// de sets/libros/simulación/intercambio/muestras; esas son solo del track DTE.
|
|
3076
3255
|
|
|
3077
3256
|
/**
|
|
3078
3257
|
* Descarga el Set de Pruebas de Boleta Electrónica desde el portal SII.
|
|
@@ -3116,7 +3295,19 @@ class CertRunner {
|
|
|
3116
3295
|
'Content-Length': buf.length,
|
|
3117
3296
|
},
|
|
3118
3297
|
...tlsOpts,
|
|
3119
|
-
}, (res) => {
|
|
3298
|
+
}, (res) => {
|
|
3299
|
+
const ch = []; const _t0 = Date.now();
|
|
3300
|
+
res.on('data', c => ch.push(c));
|
|
3301
|
+
res.on('end', () => {
|
|
3302
|
+
const _body = Buffer.concat(ch).toString('utf-8');
|
|
3303
|
+
registrarHttpDebug({
|
|
3304
|
+
url: `${CBE_BASE}facade`, method: 'POST', status: res.statusCode,
|
|
3305
|
+
headers: res.headers, body: _body, reqBody: bodyStr,
|
|
3306
|
+
ms: Date.now() - _t0, cliente: 'certBolElectDte',
|
|
3307
|
+
});
|
|
3308
|
+
resolve(_body);
|
|
3309
|
+
});
|
|
3310
|
+
});
|
|
3120
3311
|
req.on('error', reject);
|
|
3121
3312
|
req.setTimeout(30000, () => { req.destroy(); reject(new Error('timeout gwtPost CBE')); });
|
|
3122
3313
|
req.write(buf); req.end();
|
|
@@ -3150,7 +3341,20 @@ class CertRunner {
|
|
|
3150
3341
|
'Accept': 'text/plain,text/html,*/*',
|
|
3151
3342
|
},
|
|
3152
3343
|
...tlsOpts,
|
|
3153
|
-
}, (res) => {
|
|
3344
|
+
}, (res) => {
|
|
3345
|
+
const ch = []; const _t0 = Date.now();
|
|
3346
|
+
res.on('data', c => ch.push(c));
|
|
3347
|
+
res.on('end', () => {
|
|
3348
|
+
const _body = Buffer.concat(ch).toString('utf-8');
|
|
3349
|
+
// GET sin cuerpo: acá NO hay `bodyStr` (eso es de los gwtPost de más arriba).
|
|
3350
|
+
registrarHttpDebug({
|
|
3351
|
+
url: dlUrl, method: 'GET', status: res.statusCode,
|
|
3352
|
+
headers: res.headers, body: _body,
|
|
3353
|
+
ms: Date.now() - _t0, cliente: 'certBolElectDte',
|
|
3354
|
+
});
|
|
3355
|
+
resolve(_body);
|
|
3356
|
+
});
|
|
3357
|
+
});
|
|
3154
3358
|
req.on('error', reject);
|
|
3155
3359
|
req.setTimeout(30000, () => { req.destroy(); reject(new Error('Timeout DownloadFileServlet boleta')); });
|
|
3156
3360
|
});
|
|
@@ -3211,7 +3415,19 @@ class CertRunner {
|
|
|
3211
3415
|
'Content-Length': buf.length,
|
|
3212
3416
|
},
|
|
3213
3417
|
...tlsOpts,
|
|
3214
|
-
}, (res) => {
|
|
3418
|
+
}, (res) => {
|
|
3419
|
+
const ch = []; const _t0 = Date.now();
|
|
3420
|
+
res.on('data', c => ch.push(c));
|
|
3421
|
+
res.on('end', () => {
|
|
3422
|
+
const _body = Buffer.concat(ch).toString('utf-8');
|
|
3423
|
+
registrarHttpDebug({
|
|
3424
|
+
url: `${CBE_BASE}facade`, method: 'POST', status: res.statusCode,
|
|
3425
|
+
headers: res.headers, body: _body, reqBody: bodyStr,
|
|
3426
|
+
ms: Date.now() - _t0, cliente: 'certBolElectDte',
|
|
3427
|
+
});
|
|
3428
|
+
resolve(_body);
|
|
3429
|
+
});
|
|
3430
|
+
});
|
|
3215
3431
|
req.on('error', reject);
|
|
3216
3432
|
req.setTimeout(30000, () => { req.destroy(); reject(new Error('timeout gwtPost CBE')); });
|
|
3217
3433
|
req.write(buf); req.end();
|
|
@@ -3272,7 +3488,19 @@ class CertRunner {
|
|
|
3272
3488
|
'Content-Length': buf.length,
|
|
3273
3489
|
},
|
|
3274
3490
|
...tlsOpts,
|
|
3275
|
-
}, (res) => {
|
|
3491
|
+
}, (res) => {
|
|
3492
|
+
const ch = []; const _t0 = Date.now();
|
|
3493
|
+
res.on('data', c => ch.push(c));
|
|
3494
|
+
res.on('end', () => {
|
|
3495
|
+
const _body = Buffer.concat(ch).toString('utf-8');
|
|
3496
|
+
registrarHttpDebug({
|
|
3497
|
+
url: `${CBE_BASE}facade`, method: 'POST', status: res.statusCode,
|
|
3498
|
+
headers: res.headers, body: _body, reqBody: bodyStr,
|
|
3499
|
+
ms: Date.now() - _t0, cliente: 'certBolElectDte',
|
|
3500
|
+
});
|
|
3501
|
+
resolve(_body);
|
|
3502
|
+
});
|
|
3503
|
+
});
|
|
3276
3504
|
req.on('error', reject);
|
|
3277
3505
|
req.setTimeout(30000, () => { req.destroy(); reject(new Error('timeout gwtPost CBE')); });
|
|
3278
3506
|
req.write(buf); req.end();
|
package/cert/ConfigLoader.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
const path = require('path');
|
|
13
13
|
const fs = require('fs');
|
|
14
|
+
const { resolveDataDir } = require('../utils/paths');
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Carga configuración desde .env y retorna objetos estructurados
|
|
@@ -21,8 +22,9 @@ const fs = require('fs');
|
|
|
21
22
|
* @returns {Object} Configuración estructurada { EMISOR, RECEPTOR, CERT_PATH, CERT_PASS, AMBIENTE, BASE_DIR, ... }
|
|
22
23
|
*/
|
|
23
24
|
function loadConfig(options = {}) {
|
|
24
|
-
// Determinar baseDir
|
|
25
|
-
|
|
25
|
+
// Determinar baseDir. El fallback era `__dirname/../../..`, que asumía el layout de
|
|
26
|
+
// directorios del consumidor original (la lib dentro de un monorepo concreto).
|
|
27
|
+
const baseDir = options.baseDir || resolveDataDir();
|
|
26
28
|
|
|
27
29
|
// Cargar .env (opcional — las vars pueden venir de process.env inyectadas por el proceso padre)
|
|
28
30
|
const envPath = options.envPath || path.join(baseDir, '.env');
|
package/cert/IntercambioCert.js
CHANGED
|
@@ -15,6 +15,7 @@ const fs = require('fs');
|
|
|
15
15
|
const path = require('path');
|
|
16
16
|
const { XMLParser, XMLBuilder } = require('fast-xml-parser');
|
|
17
17
|
const { SignedXml } = require('xml-crypto');
|
|
18
|
+
const { resolveArtifactDir } = require('../utils/paths');
|
|
18
19
|
|
|
19
20
|
const DECLARACION_RECIBO = 'El acuse de recibo que se declara en este acto, de acuerdo a lo dispuesto en la letra b) del Art. 4, y la letra c) del Art. 5 de la Ley 19.983, acredita que la entrega de mercaderias o servicio(s) prestado(s) ha(n) sido recibido(s).';
|
|
20
21
|
|
|
@@ -357,7 +358,9 @@ class IntercambioCert {
|
|
|
357
358
|
* @returns {Object} Resultado con rutas de archivos generados
|
|
358
359
|
*/
|
|
359
360
|
async generarIntercambio(envioDteXml, options = {}) {
|
|
360
|
-
|
|
361
|
+
// `'./debug/intercambio'` era relativo al cwd del proceso: los archivos aparecían
|
|
362
|
+
// en un lugar distinto según desde dónde se hubiera lanzado el runner.
|
|
363
|
+
const outDir = resolveArtifactDir(options.outDir || this.debugDir, 'intercambio');
|
|
361
364
|
fs.mkdirSync(outDir, { recursive: true });
|
|
362
365
|
|
|
363
366
|
console.log('\n' + '═'.repeat(60));
|
package/dte-sii.d.ts
CHANGED
|
@@ -615,10 +615,24 @@ export interface FolioServiceConfig {
|
|
|
615
615
|
cafDir?: string;
|
|
616
616
|
}
|
|
617
617
|
|
|
618
|
+
export interface FolioTope {
|
|
619
|
+
/** true si el SII no publicó los campos de tope en el formulario. */
|
|
620
|
+
sinTope: boolean;
|
|
621
|
+
/** Máximo autorizado por el SII, o null si no vino. */
|
|
622
|
+
maxAutor: number | null;
|
|
623
|
+
/** Folios disponibles según el SII, o null si no vino. */
|
|
624
|
+
foliosDisp: number | null;
|
|
625
|
+
}
|
|
626
|
+
|
|
618
627
|
export class FolioService {
|
|
619
628
|
constructor(config?: FolioServiceConfig);
|
|
620
629
|
getNextFolio(tipoDte: number, rutEmisor: string, caf: CAF): Promise<number>;
|
|
621
630
|
markFolioUsed(tipoDte: number, folio: number, rutEmisor: string): Promise<void>;
|
|
631
|
+
/**
|
|
632
|
+
* Lee el tope autorizado SIN solicitar folios. Permite decidir si hace falta
|
|
633
|
+
* anular antes de pedir, en vez de anular a ciegas.
|
|
634
|
+
*/
|
|
635
|
+
consultarTope(options: { tipoDte: number }): Promise<FolioTope>;
|
|
622
636
|
}
|
|
623
637
|
|
|
624
638
|
export interface FolioRegistryOptions {
|
|
@@ -656,9 +670,34 @@ export class SiiSession {
|
|
|
656
670
|
getToken(tipo?: 'soap' | 'rest'): Promise<string>;
|
|
657
671
|
}
|
|
658
672
|
|
|
673
|
+
export interface CafSolicitarOptions {
|
|
674
|
+
tipoDte: number;
|
|
675
|
+
cantidad?: number;
|
|
676
|
+
/** Mínimo aceptable si el SII autoriza menos de `cantidad`. */
|
|
677
|
+
minCantidad?: number | null;
|
|
678
|
+
/**
|
|
679
|
+
* Sondeo: llega hasta leer el tope autorizado y NO solicita folios.
|
|
680
|
+
* Sirve para decidir si hace falta anular antes de pedir, sin efectos secundarios.
|
|
681
|
+
*/
|
|
682
|
+
soloConsultarTope?: boolean;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
export interface CafSolicitarResult {
|
|
686
|
+
success: boolean;
|
|
687
|
+
xml?: string;
|
|
688
|
+
error?: string;
|
|
689
|
+
errorCode?: string;
|
|
690
|
+
folioDesde?: number;
|
|
691
|
+
folioHasta?: number;
|
|
692
|
+
}
|
|
693
|
+
|
|
659
694
|
export class CafSolicitor {
|
|
660
695
|
constructor(config: object);
|
|
661
|
-
|
|
696
|
+
/**
|
|
697
|
+
* ⚠️ Recibe un OBJETO, no argumentos posicionales. La declaración anterior
|
|
698
|
+
* (`solicitar(tipoDte, cantidad)`) no coincidía con la implementación.
|
|
699
|
+
*/
|
|
700
|
+
solicitar(options: CafSolicitarOptions): Promise<CafSolicitarResult>;
|
|
662
701
|
}
|
|
663
702
|
|
|
664
703
|
// ============================================
|
|
@@ -698,6 +737,13 @@ export class CertFolioHelper {
|
|
|
698
737
|
|
|
699
738
|
// Sanitizacion
|
|
700
739
|
export function sanitizeSiiText(text: string): string;
|
|
740
|
+
/**
|
|
741
|
+
* Normaliza a ASCII para el TED (timbre PDF417).
|
|
742
|
+
*
|
|
743
|
+
* El lector del SII pierde los bytes >= 128: `Cajón` llega como `Cajnn` y el timbre no
|
|
744
|
+
* valida. Se aplica SOLO a lo que entra al timbre — el cuerpo del DTE conserva las tildes.
|
|
745
|
+
*/
|
|
746
|
+
export function sanitizeTedText(text: string): string;
|
|
701
747
|
export function truncateText(text: string, maxLen: number, preserveWords?: boolean): string;
|
|
702
748
|
export function sanitizeGiroRecep(giro: string): string;
|
|
703
749
|
export function sanitizeRazonSocial(razonSocial: string): string;
|
|
@@ -841,6 +887,7 @@ export function createTlsOptions(certificado: Certificado): object;
|
|
|
841
887
|
|
|
842
888
|
export const utils: {
|
|
843
889
|
sanitizeSiiText: typeof sanitizeSiiText;
|
|
890
|
+
sanitizeTedText: typeof sanitizeTedText;
|
|
844
891
|
truncateText: typeof truncateText;
|
|
845
892
|
sanitizeGiroRecep: typeof sanitizeGiroRecep;
|
|
846
893
|
sanitizeRazonSocial: typeof sanitizeRazonSocial;
|
package/index.js
CHANGED
|
@@ -44,6 +44,7 @@ const utils = require('./utils');
|
|
|
44
44
|
const {
|
|
45
45
|
// Sanitización
|
|
46
46
|
sanitizeSiiText,
|
|
47
|
+
sanitizeTedText,
|
|
47
48
|
truncateText,
|
|
48
49
|
sanitizeGiroRecep,
|
|
49
50
|
sanitizeRazonSocial,
|
|
@@ -257,6 +258,7 @@ module.exports = {
|
|
|
257
258
|
// Sanitización
|
|
258
259
|
// ─────────────────────────────────────────
|
|
259
260
|
sanitizeSiiText,
|
|
261
|
+
sanitizeTedText,
|
|
260
262
|
truncateText,
|
|
261
263
|
sanitizeGiroRecep,
|
|
262
264
|
sanitizeRazonSocial,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devlas/dte-sii",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.13.0",
|
|
4
4
|
"description": "Facturación y boletas electrónicas para el SII de Chile. Genera, timbra, firma y envía DTEs, libros electrónicos y automatiza la certificación.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "dte-sii.d.ts",
|