@devlas/dte-sii 2.12.26 → 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.
@@ -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
- // Usar baseDir si se proporciona, sino raíz de static/nodejs como fallback
280
- const effectiveBase = baseDir || path.resolve(__dirname, '..', '..', '..');
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'}`);