@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.
@@ -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
- this.debugDir = config.debugDir || path.join(process.cwd(), 'debug', 'cert-v2');
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.debugDir, 'periodo-libros.json');
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.debugDir, 'periodo-libros.json');
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.debugDir, 'periodo-libros.json');
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.debugDir, 'ltc-totales.json');
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.debugDir, 'ltc-totales.json');
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', () => resolve({ status: res.statusCode, body: data, headers: res.headers }));
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', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8'), headers: res.headers }));
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
- let estado = null, numImpresos = null, fechaEnvio = null, revId = null;
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, numImpresos, fechaEnvio };
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
- const errorEstadoMsg = !estadoActual && (
2940
- (leeTable && leeTable.find(s => typeof s === 'string' && /El estado de la postulacion/i.test(s))) ||
2941
- (() => { const m = leeResp.body.match(/"(El estado de la postulacion[^"\\]*(?:\\.[^"\\]*)*)"/i); return m ? m[1].replace(/\\x27/g, "'").replace(/\\x22/g, '"') : null; })()
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
- let numImpresos = null, fechaEnvio = null, revId = null;
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
- const nStr = leeTable.find(s => typeof s === 'string' && /^\d+$/.test(s) && +s > 0 && +s < 500);
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}` : ''}${numImpresos ? ` | ${numImpresos} documentos` : ''} — ya enviadas, no se requiere re-subida`);
2975
- return { success: true, alreadyCompleted: true, estado: estadoActual, revId, numImpresos, fechaEnvio };
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 (e.g. revisión en curso bloqueando)
2995
- const creaTable = _parseGwtStringTable(creaResp.body);
2996
- const creaErrorMsg = creaTable && creaTable.find(s => typeof s === 'string' && /El estado de la postulacion/i.test(s));
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(`pdfdteInternet creaLista: ${creaErrorMsg} — Espere a que el SII procese la revisión en curso.`);
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,9 +3192,47 @@ class CertRunner {
3065
3192
  fs.writeFileSync(path.join(debugDir, `pdfte-enviar-sii-resp.txt`), enviarResp.body, 'utf8');
3066
3193
  }
3067
3194
 
3068
- console.log(` Solicitud enviada al SII correctamente (${pdfPaths.length} PDFs, revisión ${revId})`);
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
  // ═══════════════════════════════════════════════════════════════
@@ -3130,7 +3295,19 @@ class CertRunner {
3130
3295
  'Content-Length': buf.length,
3131
3296
  },
3132
3297
  ...tlsOpts,
3133
- }, (res) => { const ch = []; res.on('data', c => ch.push(c)); res.on('end', () => resolve(Buffer.concat(ch).toString('utf-8'))); });
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
+ });
3134
3311
  req.on('error', reject);
3135
3312
  req.setTimeout(30000, () => { req.destroy(); reject(new Error('timeout gwtPost CBE')); });
3136
3313
  req.write(buf); req.end();
@@ -3164,7 +3341,20 @@ class CertRunner {
3164
3341
  'Accept': 'text/plain,text/html,*/*',
3165
3342
  },
3166
3343
  ...tlsOpts,
3167
- }, (res) => { const ch = []; res.on('data', c => ch.push(c)); res.on('end', () => resolve(Buffer.concat(ch).toString('utf-8'))); });
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
+ });
3168
3358
  req.on('error', reject);
3169
3359
  req.setTimeout(30000, () => { req.destroy(); reject(new Error('Timeout DownloadFileServlet boleta')); });
3170
3360
  });
@@ -3225,7 +3415,19 @@ class CertRunner {
3225
3415
  'Content-Length': buf.length,
3226
3416
  },
3227
3417
  ...tlsOpts,
3228
- }, (res) => { const ch = []; res.on('data', c => ch.push(c)); res.on('end', () => resolve(Buffer.concat(ch).toString('utf-8'))); });
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
+ });
3229
3431
  req.on('error', reject);
3230
3432
  req.setTimeout(30000, () => { req.destroy(); reject(new Error('timeout gwtPost CBE')); });
3231
3433
  req.write(buf); req.end();
@@ -3286,7 +3488,19 @@ class CertRunner {
3286
3488
  'Content-Length': buf.length,
3287
3489
  },
3288
3490
  ...tlsOpts,
3289
- }, (res) => { const ch = []; res.on('data', c => ch.push(c)); res.on('end', () => resolve(Buffer.concat(ch).toString('utf-8'))); });
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
+ });
3290
3504
  req.on('error', reject);
3291
3505
  req.setTimeout(30000, () => { req.destroy(); reject(new Error('timeout gwtPost CBE')); });
3292
3506
  req.write(buf); req.end();
@@ -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
- const baseDir = options.baseDir || path.resolve(__dirname, '..', '..', '..');
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');
@@ -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
- const outDir = options.outDir || this.debugDir || './debug/intercambio';
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
- solicitar(tipoDte: number, cantidad?: number): Promise<string>;
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.12.26",
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",