@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
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// Copyright (c) 2026 Devlas SpA — https://devlas.cl
|
|
2
|
+
// Licencia MIT. Ver archivo LICENSE para mas detalles.
|
|
3
|
+
/**
|
|
4
|
+
* httpDebug.js — Registro de TODAS las llamadas HTTP al portal del SII.
|
|
5
|
+
*
|
|
6
|
+
* Motivación real: depurando una certificación hubo cinco minutos de pantalla
|
|
7
|
+
* congelada sin ninguna forma de saber qué estaba pasando. La causa era una
|
|
8
|
+
* consulta que dispara cuatro requests secuenciales al portal, ninguno de los
|
|
9
|
+
* cuales dejaba rastro. De 26 llamadas en `SiiCertificacion` solo 5 se guardaban,
|
|
10
|
+
* y de las 14 de `SiiPortalAuth`, ninguna.
|
|
11
|
+
*
|
|
12
|
+
* Este módulo es el único punto donde se decide qué se guarda y cómo. Se engancha
|
|
13
|
+
* en los dos clientes HTTP de la librería (son independientes: `SiiSession` usa
|
|
14
|
+
* `got` y `SiiPortalAuth` usa `https` nativo, sin código en común).
|
|
15
|
+
*
|
|
16
|
+
* ── Está apagado por defecto ──────────────────────────────────────────────────
|
|
17
|
+
* Solo actúa si el consumidor define `SII_HTTP_DEBUG_DIR`. Sin esa variable, el
|
|
18
|
+
* costo es una comparación por request y nada más.
|
|
19
|
+
*
|
|
20
|
+
* ── Nunca rompe la operación real ─────────────────────────────────────────────
|
|
21
|
+
* Todo va dentro de try/catch. Un fallo al escribir el diagnóstico jamás puede
|
|
22
|
+
* tumbar un envío al SII.
|
|
23
|
+
*
|
|
24
|
+
* @module dte-sii/utils/httpDebug
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
|
|
30
|
+
/** Tope por archivo. Una respuesta del portal ronda los 50 KB; 512 KB cubre casos raros sin llenar el disco. */
|
|
31
|
+
const MAX_BYTES = 512 * 1024;
|
|
32
|
+
|
|
33
|
+
/** Contador por directorio: numera las llamadas para que el orden cronológico se lea en el nombre. */
|
|
34
|
+
const _contadores = new Map();
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Cabeceras que nunca se escriben en claro.
|
|
38
|
+
* `set-cookie` y `cookie` llevan la sesión viva del SII (~90 min de validez): quedaría
|
|
39
|
+
* una credencial usable en disco. `authorization` por el mismo motivo.
|
|
40
|
+
*/
|
|
41
|
+
const HEADERS_SENSIBLES = new Set(['set-cookie', 'cookie', 'authorization', 'proxy-authorization']);
|
|
42
|
+
|
|
43
|
+
/** Marcador único, para poder grepear qué se redactó. */
|
|
44
|
+
const REDACTADO = '[REDACTADO-POR-httpDebug]';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Redacta secretos del cuerpo de una respuesta.
|
|
48
|
+
*
|
|
49
|
+
* `<RSASK>` es la llave privada RSA que el SII entrega dentro del CAF: si se guarda
|
|
50
|
+
* en claro, cualquiera con acceso al disco puede timbrar documentos con esos folios.
|
|
51
|
+
* Hoy se escribe sin protección en varios puntos de la librería; acá al menos no se
|
|
52
|
+
* amplifica el problema.
|
|
53
|
+
*/
|
|
54
|
+
function redactarCuerpo(texto) {
|
|
55
|
+
if (!texto) return texto;
|
|
56
|
+
return String(texto)
|
|
57
|
+
.replace(/<RSASK>[\s\S]*?<\/RSASK>/gi, `<RSASK>${REDACTADO}</RSASK>`)
|
|
58
|
+
.replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/gi, REDACTADO);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Devuelve una copia de los headers con los sensibles redactados. */
|
|
62
|
+
function redactarHeaders(headers) {
|
|
63
|
+
const salida = {};
|
|
64
|
+
for (const [k, v] of Object.entries(headers || {})) {
|
|
65
|
+
salida[k] = HEADERS_SENSIBLES.has(k.toLowerCase()) ? REDACTADO : v;
|
|
66
|
+
}
|
|
67
|
+
return salida;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Convierte una URL en un fragmento corto y legible para el nombre de archivo. */
|
|
71
|
+
function _slugDesdeUrl(urlStr) {
|
|
72
|
+
try {
|
|
73
|
+
const { pathname } = new URL(urlStr);
|
|
74
|
+
const ultimo = pathname.split('/').filter(Boolean).pop() || 'raiz';
|
|
75
|
+
return ultimo.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 48);
|
|
76
|
+
} catch {
|
|
77
|
+
return 'url-invalida';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function _siguienteNumero(dir) {
|
|
82
|
+
const n = (_contadores.get(dir) || 0) + 1;
|
|
83
|
+
_contadores.set(dir, n);
|
|
84
|
+
return String(n).padStart(3, '0');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* ¿Está activa la captura? Se lee en cada llamada a propósito: el consumidor puede
|
|
89
|
+
* definir la variable por corrida (un directorio distinto por etapa).
|
|
90
|
+
* @returns {string|null} Directorio destino, o null si está apagada.
|
|
91
|
+
*/
|
|
92
|
+
function dirActivo() {
|
|
93
|
+
return process.env.SII_HTTP_DEBUG_DIR || null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Registra una llamada HTTP. Best-effort: nunca lanza.
|
|
98
|
+
*
|
|
99
|
+
* Escribe dos cosas:
|
|
100
|
+
* - `NNN-METODO-recurso-STATUS.html` con el cuerpo de la respuesta.
|
|
101
|
+
* - Una línea en `index.jsonl` con los metadatos, para poder revisar una corrida
|
|
102
|
+
* entera de un vistazo (`jq`, `grep`) sin abrir cada archivo.
|
|
103
|
+
*
|
|
104
|
+
* @param {Object} info
|
|
105
|
+
* @param {string} info.url
|
|
106
|
+
* @param {string} [info.method='GET']
|
|
107
|
+
* @param {number} [info.status]
|
|
108
|
+
* @param {Object} [info.headers] - Cabeceras de respuesta (se redactan).
|
|
109
|
+
* @param {string} [info.body] - Cuerpo de respuesta (se redacta).
|
|
110
|
+
* @param {string} [info.reqBody] - Cuerpo enviado (form POST). Se redacta igual.
|
|
111
|
+
* @param {number} [info.ms] - Duración.
|
|
112
|
+
* @param {string} [info.cliente] - Qué cliente HTTP lo emitió, para saber de dónde vino.
|
|
113
|
+
*/
|
|
114
|
+
function registrarHttpDebug(info = {}) {
|
|
115
|
+
const dir = dirActivo();
|
|
116
|
+
if (!dir) return;
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
120
|
+
|
|
121
|
+
const { url = '', method = 'GET', status = 0, headers, body, reqBody, ms, cliente } = info;
|
|
122
|
+
const n = _siguienteNumero(dir);
|
|
123
|
+
const nombre = `${n}-${method.toUpperCase()}-${_slugDesdeUrl(url)}-${status}.html`;
|
|
124
|
+
|
|
125
|
+
let cuerpo = redactarCuerpo(body) ?? '';
|
|
126
|
+
let truncado = false;
|
|
127
|
+
if (Buffer.byteLength(cuerpo, 'utf-8') > MAX_BYTES) {
|
|
128
|
+
cuerpo = cuerpo.slice(0, MAX_BYTES) + `\n\n${REDACTADO} — truncado en ${MAX_BYTES} bytes`;
|
|
129
|
+
truncado = true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// El cuerpo enviado importa tanto como la respuesta: cuando el SII rechaza un
|
|
133
|
+
// envío, la causa está en el XML que se le mandó. Si es corto va inline en el
|
|
134
|
+
// comentario; si no, a un archivo aparte para no volver ilegible el principal.
|
|
135
|
+
const reqTexto = reqBody ? redactarCuerpo(String(reqBody)) : '';
|
|
136
|
+
let archivoReq = null;
|
|
137
|
+
if (reqTexto.length > 2000) {
|
|
138
|
+
archivoReq = nombre.replace(/\.html$/, '-request.txt');
|
|
139
|
+
const recortado = Buffer.byteLength(reqTexto, 'utf-8') > MAX_BYTES
|
|
140
|
+
? reqTexto.slice(0, MAX_BYTES) + `\n\n${REDACTADO} — truncado en ${MAX_BYTES} bytes`
|
|
141
|
+
: reqTexto;
|
|
142
|
+
fs.writeFileSync(path.join(dir, archivoReq), recortado, 'utf-8');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Encabezado legible dentro del propio archivo: al abrir uno suelto se entiende
|
|
146
|
+
// a qué llamada corresponde sin tener que cruzarlo con el índice.
|
|
147
|
+
const cabecera = [
|
|
148
|
+
'<!--',
|
|
149
|
+
` ${method.toUpperCase()} ${url}`,
|
|
150
|
+
` status: ${status}${ms != null ? ` | ${ms} ms` : ''}${cliente ? ` | cliente: ${cliente}` : ''}`,
|
|
151
|
+
` fecha: ${new Date().toISOString()}`,
|
|
152
|
+
archivoReq ? ` request body: ${archivoReq}` : null,
|
|
153
|
+
reqTexto && !archivoReq ? ` request body: ${reqTexto}` : null,
|
|
154
|
+
'-->',
|
|
155
|
+
'',
|
|
156
|
+
].filter(Boolean).join('\n');
|
|
157
|
+
|
|
158
|
+
fs.writeFileSync(path.join(dir, nombre), cabecera + cuerpo, 'utf-8');
|
|
159
|
+
|
|
160
|
+
fs.appendFileSync(
|
|
161
|
+
path.join(dir, 'index.jsonl'),
|
|
162
|
+
JSON.stringify({
|
|
163
|
+
n,
|
|
164
|
+
ts: new Date().toISOString(),
|
|
165
|
+
method: method.toUpperCase(),
|
|
166
|
+
url,
|
|
167
|
+
status,
|
|
168
|
+
ms: ms ?? null,
|
|
169
|
+
cliente: cliente ?? null,
|
|
170
|
+
bytes: Buffer.byteLength(String(body ?? ''), 'utf-8'),
|
|
171
|
+
truncado,
|
|
172
|
+
archivo: nombre,
|
|
173
|
+
archivoRequest: archivoReq,
|
|
174
|
+
headers: redactarHeaders(headers),
|
|
175
|
+
}) + '\n',
|
|
176
|
+
'utf-8',
|
|
177
|
+
);
|
|
178
|
+
} catch (_e) {
|
|
179
|
+
// El diagnóstico jamás puede romper la operación real contra el SII.
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* `fetch` que ademas deja el intercambio en el debug HTTP.
|
|
185
|
+
*
|
|
186
|
+
* Devuelve el texto ya leido porque el cuerpo de un Response se consume una sola vez:
|
|
187
|
+
* si el debug lo leyera por su cuenta, el llamador se quedaria sin cuerpo.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} url
|
|
190
|
+
* @param {Object} [opciones] - Igual que las de `fetch`.
|
|
191
|
+
* @param {string} [cliente] - Quien hizo la llamada, para ubicarla en el indice.
|
|
192
|
+
* @returns {Promise<{ response: Response, text: string }>}
|
|
193
|
+
*/
|
|
194
|
+
async function fetchRegistrado(url, opciones = {}, cliente = undefined) {
|
|
195
|
+
const t0 = Date.now();
|
|
196
|
+
const response = await fetch(url, opciones);
|
|
197
|
+
const text = await response.text();
|
|
198
|
+
// Con el debug apagado se sale acá, ANTES de tocar nada de la respuesta. Estas llamadas
|
|
199
|
+
// están en el camino de emisión real (EnviadorSII, WsReclamo): recorrer los headers para
|
|
200
|
+
// después descartarlos sería trabajo y superficie de fallo en producción a cambio de nada.
|
|
201
|
+
if (!dirActivo()) return { response, text };
|
|
202
|
+
registrarHttpDebug({
|
|
203
|
+
url,
|
|
204
|
+
method: opciones.method || 'GET',
|
|
205
|
+
status: response.status,
|
|
206
|
+
headers: Object.fromEntries(response.headers),
|
|
207
|
+
body: text,
|
|
208
|
+
reqBody: opciones.body,
|
|
209
|
+
ms: Date.now() - t0,
|
|
210
|
+
cliente,
|
|
211
|
+
});
|
|
212
|
+
return { response, text };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
module.exports = {
|
|
216
|
+
registrarHttpDebug,
|
|
217
|
+
fetchRegistrado,
|
|
218
|
+
redactarCuerpo,
|
|
219
|
+
redactarHeaders,
|
|
220
|
+
dirActivo,
|
|
221
|
+
REDACTADO,
|
|
222
|
+
MAX_BYTES,
|
|
223
|
+
};
|
package/utils/index.js
CHANGED
|
@@ -52,6 +52,7 @@ const {
|
|
|
52
52
|
// Sanitización
|
|
53
53
|
const {
|
|
54
54
|
sanitizeSiiText,
|
|
55
|
+
sanitizeTedText,
|
|
55
56
|
truncateText,
|
|
56
57
|
sanitizeGiroRecep,
|
|
57
58
|
sanitizeRazonSocial,
|
|
@@ -176,6 +177,7 @@ module.exports = {
|
|
|
176
177
|
// Sanitización
|
|
177
178
|
// ─────────────────────────────────────────
|
|
178
179
|
sanitizeSiiText,
|
|
180
|
+
sanitizeTedText,
|
|
179
181
|
truncateText,
|
|
180
182
|
sanitizeGiroRecep,
|
|
181
183
|
sanitizeRazonSocial,
|
package/utils/paths.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Copyright (c) 2026 Devlas SpA — https://devlas.cl
|
|
2
|
+
// Licencia MIT. Ver archivo LICENSE para mas detalles.
|
|
3
|
+
/**
|
|
4
|
+
* paths.js — Resolución centralizada de los directorios donde escribe la librería.
|
|
5
|
+
*
|
|
6
|
+
* REGLA DE FRONTERA: esta librería es un paquete público y **no conoce a sus
|
|
7
|
+
* consumidores**. Nunca debe adivinar dónde escribir ni asumir un layout de
|
|
8
|
+
* directorios, un sistema operativo o el nombre de un proyecto que la usa.
|
|
9
|
+
* El directorio siempre lo inyecta quien la consume: por parámetro (preferido)
|
|
10
|
+
* o por variable de entorno.
|
|
11
|
+
*
|
|
12
|
+
* Antes de este módulo convivían cinco convenciones distintas —`process.cwd()`,
|
|
13
|
+
* `__dirname/../..`, rutas relativas, una ruta con el nombre de un repo consumidor
|
|
14
|
+
* y una ruta de `AppData` de Windows— lo que hacía imposible saber dónde iba a
|
|
15
|
+
* quedar un archivo de diagnóstico.
|
|
16
|
+
*
|
|
17
|
+
* @module dte-sii/utils/paths
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const os = require('os');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Directorio para archivos de diagnóstico (HTML del portal, XML, dumps).
|
|
26
|
+
*
|
|
27
|
+
* Orden de resolución: el que inyecta el consumidor → `SII_DEBUG_DIR` → `null`.
|
|
28
|
+
* Devolver `null` es una respuesta válida y significa **no escribir nada**: el
|
|
29
|
+
* diagnóstico es opcional y jamás debe crear directorios en ubicaciones que el
|
|
30
|
+
* consumidor no eligió.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} [dirInyectado] - Directorio provisto por el consumidor.
|
|
33
|
+
* @returns {string|null} Ruta absoluta, o null si nadie definió dónde escribir.
|
|
34
|
+
*/
|
|
35
|
+
function resolveDebugDir(dirInyectado) {
|
|
36
|
+
const dir = dirInyectado || process.env.SII_DEBUG_DIR || null;
|
|
37
|
+
return dir ? path.resolve(dir) : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Directorio para datos que deben sobrevivir entre ejecuciones (caché de sesión
|
|
42
|
+
* SII, por ejemplo).
|
|
43
|
+
*
|
|
44
|
+
* A diferencia del debug, acá sí hay un fallback: perder la caché de sesión
|
|
45
|
+
* dispara el error "máximo de sesiones autenticadas" del SII, así que conviene
|
|
46
|
+
* escribir en algún lado antes que en ninguno. El fallback es `os.tmpdir()`,
|
|
47
|
+
* que es neutral respecto del sistema operativo.
|
|
48
|
+
*
|
|
49
|
+
* ⚠️ En un contenedor con filesystem efímero, `DATADIR` debe apuntar a un volumen
|
|
50
|
+
* persistente o la caché se pierde en cada redeploy.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} [dirInyectado] - Directorio provisto por el consumidor.
|
|
53
|
+
* @returns {string} Ruta absoluta (siempre devuelve algo).
|
|
54
|
+
*/
|
|
55
|
+
function resolveDataDir(dirInyectado) {
|
|
56
|
+
return path.resolve(dirInyectado || process.env.DATADIR || path.join(os.tmpdir(), 'dte-sii'));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Directorio para **artefactos funcionales**: archivos que la propia librería vuelve
|
|
61
|
+
* a leer después (`estructuras.json`, `session.json`, `periodo-libros.json`, CAFs).
|
|
62
|
+
*
|
|
63
|
+
* Se distingue de `resolveDebugDir` porque acá no se puede devolver `null`: si no
|
|
64
|
+
* hay dónde escribir, el flujo se rompe más adelante al intentar leerlos. Por eso
|
|
65
|
+
* hay fallback, pero neutral (`resolveDataDir`) en vez de las adivinanzas que había
|
|
66
|
+
* antes — `process.cwd()` (depende de desde dónde se lanzó el proceso) y
|
|
67
|
+
* `__dirname/../..` (que en una instalación normal apunta dentro de `node_modules`).
|
|
68
|
+
*
|
|
69
|
+
* @param {string} [dirInyectado] - Directorio provisto por el consumidor.
|
|
70
|
+
* @param {string} [subdir] - Subdirectorio opcional bajo el base resuelto.
|
|
71
|
+
* @returns {string} Ruta absoluta (siempre devuelve algo).
|
|
72
|
+
*/
|
|
73
|
+
function resolveArtifactDir(dirInyectado, subdir = '') {
|
|
74
|
+
const base = path.resolve(
|
|
75
|
+
dirInyectado || process.env.SII_DEBUG_DIR || path.join(resolveDataDir(), 'debug'),
|
|
76
|
+
);
|
|
77
|
+
return subdir ? path.join(base, subdir) : base;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Escribe un archivo de diagnóstico. Best-effort: si no hay directorio definido
|
|
82
|
+
* o falla la escritura, no hace nada y **nunca lanza** — el diagnóstico no puede
|
|
83
|
+
* tumbar una operación real contra el SII.
|
|
84
|
+
*
|
|
85
|
+
* @param {string|null} dir - Directorio ya resuelto (ver resolveDebugDir).
|
|
86
|
+
* @param {string} filename - Nombre del archivo.
|
|
87
|
+
* @param {string|Buffer} content - Contenido.
|
|
88
|
+
* @returns {string|null} Ruta escrita, o null si no se escribió.
|
|
89
|
+
*/
|
|
90
|
+
function saveDebugFile(dir, filename, content) {
|
|
91
|
+
if (!dir) return null;
|
|
92
|
+
try {
|
|
93
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
94
|
+
const filePath = path.join(dir, filename);
|
|
95
|
+
fs.writeFileSync(filePath, content ?? '', 'utf-8');
|
|
96
|
+
return filePath;
|
|
97
|
+
} catch (_e) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Stamp de tiempo apto para nombres de archivo y directorio. Convención ya usada en todo el repo. */
|
|
103
|
+
function runStamp(date = new Date()) {
|
|
104
|
+
return date.toISOString().replace(/[:.]/g, '-');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Normaliza un RUT para usarlo como nombre de directorio: sin puntos, en mayúsculas. */
|
|
108
|
+
function rutSlug(rut) {
|
|
109
|
+
return String(rut || 'sin-rut').replace(/\./g, '').trim().toUpperCase();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Directorio de UNA ejecución, clasificado para poder encontrarlo después.
|
|
114
|
+
*
|
|
115
|
+
* {base}/{RUT}/{YYYY-MM-DD}/{HH-MM-SS}_{operacion}/
|
|
116
|
+
*
|
|
117
|
+
* Tres niveles con un porqué cada uno:
|
|
118
|
+
* - **RUT** primero: al depurar siempre se parte de "qué contribuyente".
|
|
119
|
+
* - **Fecha** después: agrupa el día completo; es el filtro natural cuando el
|
|
120
|
+
* usuario dice "esto pasó ayer" y hay muchas corridas acumuladas.
|
|
121
|
+
* - **Hora + operación**: dentro del día, las corridas quedan ordenadas
|
|
122
|
+
* cronológicamente por nombre y se lee de un vistazo qué hizo cada una
|
|
123
|
+
* (`14-32-07_generar-sets`) sin tener que abrirlas.
|
|
124
|
+
*
|
|
125
|
+
* Reemplaza cinco layouts distintos que convivían (`debug/auto-caf/{RUT}/{stamp}/{tipo}`,
|
|
126
|
+
* `debug/cert-v2/{rut}/{stamp}`, `debug/caf/{ambiente}/{RUT}/...`, archivos sueltos en
|
|
127
|
+
* la raíz de `debug/cert-v2`, y rutas relativas al cwd).
|
|
128
|
+
*
|
|
129
|
+
* @param {Object} opts
|
|
130
|
+
* @param {string} [opts.baseDir] - Base inyectada por el consumidor.
|
|
131
|
+
* @param {string} opts.rut - RUT del contribuyente.
|
|
132
|
+
* @param {string} opts.operacion - Qué se está ejecutando (ej. 'generar-sets', 'caf-39').
|
|
133
|
+
* @param {Date} [opts.date] - Momento de la corrida (default: ahora).
|
|
134
|
+
* @returns {string} Ruta absoluta del directorio de la corrida.
|
|
135
|
+
*/
|
|
136
|
+
function resolveRunDir({ baseDir, rut, operacion, date = new Date() }) {
|
|
137
|
+
const iso = date.toISOString(); // 2026-08-11T22:46:35.123Z
|
|
138
|
+
const fecha = iso.slice(0, 10); // 2026-08-11
|
|
139
|
+
const hora = iso.slice(11, 19).replace(/:/g, '-'); // 22-46-35
|
|
140
|
+
const nombre = operacion ? `${hora}_${operacion}` : hora;
|
|
141
|
+
return path.join(resolveArtifactDir(baseDir), rutSlug(rut), fecha, nombre);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = {
|
|
145
|
+
resolveDebugDir,
|
|
146
|
+
resolveDataDir,
|
|
147
|
+
resolveArtifactDir,
|
|
148
|
+
resolveRunDir,
|
|
149
|
+
saveDebugFile,
|
|
150
|
+
runStamp,
|
|
151
|
+
rutSlug,
|
|
152
|
+
};
|
package/utils/sanitize.js
CHANGED
|
@@ -40,6 +40,53 @@ function sanitizeSiiText(text) {
|
|
|
40
40
|
.trim();
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Sanitizar texto que va DENTRO del TED (timbre electrónico).
|
|
45
|
+
*
|
|
46
|
+
* Además de lo que hace `sanitizeSiiText`, pliega los acentuados de Latin-1 a ASCII
|
|
47
|
+
* (á→a, ñ→n, Ü→U…).
|
|
48
|
+
*
|
|
49
|
+
* ⚠️ Esto NO es cosmético y no se puede omitir: **el lector de PDF417 del SII no
|
|
50
|
+
* devuelve los bytes ≥ 128 tal como se codificaron**. Medido contra el portal de
|
|
51
|
+
* Muestras Impresas el 11/08/2026 — el SII leyó del código de barras:
|
|
52
|
+
*
|
|
53
|
+
* "Cajón AFECTO" -> "Cajnn AFECTO"
|
|
54
|
+
* "Pañuelo AFECTO" -> "Pauuelo AFECTO"
|
|
55
|
+
* "CIGUEÑALES" -> "CIGUEAALES"
|
|
56
|
+
*
|
|
57
|
+
* (el carácter acentuado se pierde y el siguiente se duplica). Como el TED viaja
|
|
58
|
+
* firmado, el SII verifica la firma sobre lo que ÉL leyó, no sobre lo que se codificó:
|
|
59
|
+
* cualquier documento con tilde o ñ en RSR o IT1 termina rechazado con
|
|
60
|
+
* "Error Tecnico: TED - Firma invalida", y basta uno para tumbar la revisión completa.
|
|
61
|
+
*
|
|
62
|
+
* Se descartó que fuera un problema nuestro de codificación: se verificó que la firma
|
|
63
|
+
* es válida sobre los bytes latin1, que el TED del envío y el del DTE son idénticos, y
|
|
64
|
+
* que bwip-js codifica el byte correctamente — el barcode generado con `binarytext`
|
|
65
|
+
* resultó idéntico al generado escapando el byte a mano (`^243` con `parse`).
|
|
66
|
+
*
|
|
67
|
+
* Solo aplica al TED. El DTE conserva el texto real con sus acentos: acá se pliega
|
|
68
|
+
* únicamente lo que va al código de barras, y se hace ANTES de firmar para que la
|
|
69
|
+
* firma cubra exactamente los bytes que el SII va a leer.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} text - Texto a sanitizar (RSR o IT1 del TED)
|
|
72
|
+
* @returns {string} - Texto sin caracteres fuera de ASCII
|
|
73
|
+
*/
|
|
74
|
+
function sanitizeTedText(text) {
|
|
75
|
+
return sanitizeSiiText(text)
|
|
76
|
+
// Descompone en letra base + diacrítico y descarta el diacrítico.
|
|
77
|
+
.normalize('NFD')
|
|
78
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
79
|
+
// Lo que NFD no separa (ligaduras y símbolos con forma propia).
|
|
80
|
+
.replace(/[fffifl]/g, m => ({ 'ff': 'ff', 'fi': 'fi', 'fl': 'fl' })[m])
|
|
81
|
+
.replace(/Æ/g, 'AE').replace(/æ/g, 'ae')
|
|
82
|
+
.replace(/Ø/g, 'O').replace(/ø/g, 'o')
|
|
83
|
+
.replace(/Ð/g, 'D').replace(/ð/g, 'd')
|
|
84
|
+
.replace(/Þ/g, 'TH').replace(/þ/g, 'th')
|
|
85
|
+
.replace(/ß/g, 'ss')
|
|
86
|
+
// Red de seguridad: cualquier resto no-ASCII se elimina antes del barcode.
|
|
87
|
+
.replace(/[^\x20-\x7E]/g, '');
|
|
88
|
+
}
|
|
89
|
+
|
|
43
90
|
/**
|
|
44
91
|
* Truncar texto a longitud máxima (preservando palabras completas si es posible)
|
|
45
92
|
*
|
|
@@ -127,6 +174,7 @@ function safeSegment(value, fallback = 'sin-valor') {
|
|
|
127
174
|
|
|
128
175
|
module.exports = {
|
|
129
176
|
sanitizeSiiText,
|
|
177
|
+
sanitizeTedText,
|
|
130
178
|
truncateText,
|
|
131
179
|
sanitizeGiroRecep,
|
|
132
180
|
sanitizeRazonSocial,
|
package/utils/xml.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
const { XMLParser, XMLBuilder } = require('fast-xml-parser');
|
|
12
12
|
const { safeSegment } = require('./sanitize');
|
|
13
|
+
const { resolveDataDir } = require('./paths');
|
|
13
14
|
|
|
14
15
|
// ============================================
|
|
15
16
|
// PARSERS SINGLETON (evita múltiples instancias)
|
|
@@ -276,8 +277,9 @@ function saveEnvioArtifacts({
|
|
|
276
277
|
const tipoDte = meta.items.length === 1 ? meta.items[0]?.tipoDTE : 'multiple';
|
|
277
278
|
const folio = meta.items.length === 1 ? meta.items[0]?.folio : null;
|
|
278
279
|
|
|
279
|
-
//
|
|
280
|
-
|
|
280
|
+
// El fallback era `__dirname/../../..`, una ruta que solo tenía sentido con un
|
|
281
|
+
// layout de directorios concreto del consumidor original.
|
|
282
|
+
const effectiveBase = baseDir || resolveDataDir();
|
|
281
283
|
const historicoBaseDir = path.join(effectiveBase, 'historicos');
|
|
282
284
|
const rutDir = safeSegment(meta.rutEmisor || 'sin-rut');
|
|
283
285
|
const tipoDir = safeSegment(`dte-${tipoDte || 'sin-tipo'}`);
|