@devlas/dte-sii 2.13.2 → 2.14.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/cert/SetCompra.js CHANGED
@@ -99,9 +99,7 @@ class SetCompra extends SetBase {
99
99
  const { DTE, CAF, buildDetalleCompra, buildSetReferencia } = require('../index');
100
100
  const fs = require('fs');
101
101
 
102
- const cafXml = fs.readFileSync(cafPath, 'utf8');
103
- const caf = new CAF(cafXml);
104
- const folio = this._reservarFolio(caf, cafXml);
102
+ const { caf, cafXml, folio } = this._tomarFolio(cafPath);
105
103
 
106
104
  // Construir detalle con buildDetalleCompra (incluye CodImpAdic)
107
105
  const items = this._normalizarItems(caso.items);
@@ -151,9 +149,7 @@ class SetCompra extends SetBase {
151
149
  const { DTE, CAF, buildDetalleCompra, buildSetReferencia } = require('../index');
152
150
  const fs = require('fs');
153
151
 
154
- const cafXml = fs.readFileSync(cafPath, 'utf8');
155
- const caf = new CAF(cafXml);
156
- const folio = this._reservarFolio(caf, cafXml);
152
+ const { caf, cafXml, folio } = this._tomarFolio(cafPath);
157
153
 
158
154
  // Obtener documento referenciado
159
155
  const docRef = this._docRefs[caso.referenciaCaso];
@@ -219,9 +215,7 @@ class SetCompra extends SetBase {
219
215
  const { DTE, CAF, buildDetalleCompra, buildSetReferencia } = require('../index');
220
216
  const fs = require('fs');
221
217
 
222
- const cafXml = fs.readFileSync(cafPath, 'utf8');
223
- const caf = new CAF(cafXml);
224
- const folio = this._reservarFolio(caf, cafXml);
218
+ const { caf, cafXml, folio } = this._tomarFolio(cafPath);
225
219
 
226
220
  // Obtener documento referenciado
227
221
  const docRef = this._docRefs[caso.referenciaCaso];
@@ -394,18 +388,6 @@ class SetCompra extends SetBase {
394
388
  * Reserva el siguiente folio disponible
395
389
  * @private
396
390
  */
397
- _reservarFolio(caf, cafXml) {
398
- const cafFingerprint = this.folioHelper.createCafFingerprint(cafXml);
399
- const folio = this.folioHelper.reserveNextFolio({
400
- rutEmisor: this.config.emisor.rut,
401
- tipoDte: caf.getTipoDTE(),
402
- folioDesde: caf.getFolioDesde(),
403
- folioHasta: caf.getFolioHasta(),
404
- ambiente: this.config.ambiente || 'certificacion',
405
- cafFingerprint,
406
- });
407
- return folio;
408
- }
409
391
 
410
392
  /**
411
393
  * Obtiene la fecha de emisión
package/cert/SetExenta.js CHANGED
@@ -104,9 +104,7 @@ class SetExenta extends SetBase {
104
104
  const { DTE, CAF, buildDetalle, calcularTotalesDesdeDetalle, buildSetReferencia } = require('../index');
105
105
  const fs = require('fs');
106
106
 
107
- const cafXml = fs.readFileSync(cafPath, 'utf8');
108
- const caf = new CAF(cafXml);
109
- const folio = this._reservarFolio(caf, cafXml);
107
+ const { caf, cafXml, folio } = this._tomarFolio(cafPath);
110
108
 
111
109
  // Marcar todos los items como exentos
112
110
  const items = this._normalizarItemsExentos(caso.items);
@@ -156,9 +154,7 @@ class SetExenta extends SetBase {
156
154
  const { DTE, CAF, buildDetalle, calcularTotalesDesdeDetalle, buildSetReferencia } = require('../index');
157
155
  const fs = require('fs');
158
156
 
159
- const cafXml = fs.readFileSync(cafPath, 'utf8');
160
- const caf = new CAF(cafXml);
161
- const folio = this._reservarFolio(caf, cafXml);
157
+ const { caf, cafXml, folio } = this._tomarFolio(cafPath);
162
158
 
163
159
  // Obtener documento referenciado
164
160
  const docRef = this._docRefs[caso.referenciaCaso];
@@ -224,9 +220,7 @@ class SetExenta extends SetBase {
224
220
  const { DTE, CAF, buildDetalle, calcularTotalesDesdeDetalle, buildSetReferencia } = require('../index');
225
221
  const fs = require('fs');
226
222
 
227
- const cafXml = fs.readFileSync(cafPath, 'utf8');
228
- const caf = new CAF(cafXml);
229
- const folio = this._reservarFolio(caf, cafXml);
223
+ const { caf, cafXml, folio } = this._tomarFolio(cafPath);
230
224
 
231
225
  // Obtener documento referenciado
232
226
  const docRef = this._docRefs[caso.referenciaCaso];
@@ -412,18 +406,6 @@ class SetExenta extends SetBase {
412
406
  * Reserva el siguiente folio disponible
413
407
  * @private
414
408
  */
415
- _reservarFolio(caf, cafXml) {
416
- const cafFingerprint = this.folioHelper.createCafFingerprint(cafXml);
417
- const folio = this.folioHelper.reserveNextFolio({
418
- rutEmisor: this.config.emisor.rut,
419
- tipoDte: caf.getTipoDTE(),
420
- folioDesde: caf.getFolioDesde(),
421
- folioHasta: caf.getFolioHasta(),
422
- ambiente: this.config.ambiente || 'certificacion',
423
- cafFingerprint,
424
- });
425
- return folio;
426
- }
427
409
 
428
410
  /**
429
411
  * Obtiene la fecha de emisión
package/cert/SetGuia.js CHANGED
@@ -82,11 +82,9 @@ class SetGuia extends SetBase {
82
82
  const fs = require('fs');
83
83
 
84
84
  // Cargar CAF
85
- const cafXml = fs.readFileSync(cafPath, 'utf8');
86
- const caf = new CAF(cafXml);
85
+ const { caf, cafXml, folio } = this._tomarFolio(cafPath);
87
86
 
88
87
  // Reservar folio
89
- const folio = this._reservarFolio(caf, cafXml);
90
88
 
91
89
  // Construir detalle (buildDetalleGuia siempre incluye QtyItem)
92
90
  const items = this._normalizarItems(caso.items);
@@ -178,18 +176,6 @@ class SetGuia extends SetBase {
178
176
  * Reserva el siguiente folio disponible
179
177
  * @private
180
178
  */
181
- _reservarFolio(caf, cafXml) {
182
- const cafFingerprint = this.folioHelper.createCafFingerprint(cafXml);
183
- const folio = this.folioHelper.reserveNextFolio({
184
- rutEmisor: this.config.emisor.rut,
185
- tipoDte: caf.getTipoDTE(),
186
- folioDesde: caf.getFolioDesde(),
187
- folioHasta: caf.getFolioHasta(),
188
- ambiente: this.config.ambiente || 'certificacion',
189
- cafFingerprint,
190
- });
191
- return folio;
192
- }
193
179
 
194
180
  /**
195
181
  * Obtiene la fecha de emisión en formato YYYY-MM-DD
package/dte-sii.d.ts CHANGED
@@ -622,6 +622,17 @@ export interface FolioTope {
622
622
  maxAutor: number | null;
623
623
  /** Folios disponibles según el SII, o null si no vino. */
624
624
  foliosDisp: number | null;
625
+ /** true si el SII bloqueó el timbraje para este tipo. Tiene prioridad sobre `sinTope`. */
626
+ bloqueado?: boolean;
627
+ }
628
+
629
+ export interface ReobtenerCafResult {
630
+ /** Rutas de los CAF recuperados. El SII entrega los folios de a uno, así que pueden ser varios. */
631
+ paths: string[];
632
+ /** Cantidad total de folios recuperados entre todos los rangos. */
633
+ folios: number;
634
+ /** Rangos que se descartaron por estar anulados en el SII. */
635
+ descartados: Array<{ desde: number; hasta: number; motivo: string }>;
625
636
  }
626
637
 
627
638
  export class FolioService {
@@ -633,6 +644,15 @@ export class FolioService {
633
644
  * anular antes de pedir, en vez de anular a ciegas.
634
645
  */
635
646
  consultarTope(options: { tipoDte: number }): Promise<FolioTope>;
647
+ /**
648
+ * Recupera CAF ya autorizados desde el portal, sin gastar cupo de timbraje.
649
+ *
650
+ * Es la salida cuando el SII bloqueó el timbraje: los folios ya están autorizados,
651
+ * solo se perdió el archivo. Junta tantos rangos como haga falta y descarta los que
652
+ * el portal reporta como anulados (los documentos emitidos con esos folios se
653
+ * rechazan).
654
+ */
655
+ reobtenerCaf(options: { tipoDte: number; cantidad: number }): Promise<ReobtenerCafResult | null>;
636
656
  }
637
657
 
638
658
  export interface FolioRegistryOptions {
@@ -698,6 +718,15 @@ export class CafSolicitor {
698
718
  * (`solicitar(tipoDte, cantidad)`) no coincidía con la implementación.
699
719
  */
700
720
  solicitar(options: CafSolicitarOptions): Promise<CafSolicitarResult>;
721
+ /**
722
+ * Lista los rangos de folios ya autorizados que el portal permite volver a descargar.
723
+ *
724
+ * ⚠️ El listado incluye rangos ANULADOS sin distinguirlos: solo al abrir cada uno el
725
+ * portal lo avisa. Por eso `reobtenerCaf` hace un request por rango.
726
+ */
727
+ listarReobtenibles(tipoDte: number): Promise<Array<{ desde: number; hasta: number; raw?: string }>>;
728
+ /** Descarga el CAF de un rango ya autorizado. Devuelve null si el rango está anulado. */
729
+ reobtenerCaf(tipoDte: number, rango: { desde: number; hasta: number }): Promise<string | null>;
701
730
  }
702
731
 
703
732
  // ============================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devlas/dte-sii",
3
- "version": "2.13.2",
3
+ "version": "2.14.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",
@@ -8,7 +8,7 @@
8
8
  "author": "Devlas SpA <ti@devlas.cl> (https://devlas.cl)",
9
9
  "homepage": "https://github.com/devlas-cl/dte-sii",
10
10
  "scripts": {
11
- "test": "node test/pfx.test.js"
11
+ "test": "node test/pfx.test.js && node test/folios-consolidados.test.js && node test/c14n-apostrofe.test.js"
12
12
  },
13
13
  "repository": {
14
14
  "type": "git",
package/utils/c14n.js CHANGED
@@ -38,23 +38,29 @@ function escapeText(text) {
38
38
  }
39
39
 
40
40
  /**
41
- * Corrige entidades en contenido XML (apóstrofes y comillas)
42
- * @param {string} xml - XML a procesar
43
- * @returns {string} - XML con entidades corregidas
41
+ * ⚠️ NO agregar acá un paso que escape apóstrofes o comillas en el contenido.
42
+ *
43
+ * Existía un `fixEntities()` que recorría el XML canónico y convertía `'` en `&apos;` y
44
+ * `"` en `&quot;` dentro del texto. Canonical XML (REC-xml-c14n-20010315, §1.3 y §2.3)
45
+ * escapa en nodos de texto SOLO `&`, `<`, `>` y el retorno de carro; el apóstrofe y la
46
+ * comilla doble quedan literales. `escapeText()` acá arriba ya hace exactamente eso.
47
+ *
48
+ * Con ese paso de más, la forma canónica que firmábamos dejaba de ser la que el SII
49
+ * calcula al recibir el documento, así que el DigestValue no coincidía y el SII rechazaba
50
+ * el ENVÍO COMPLETO con `RFR - Rechazado por Error en Firma` — sin decir cuál documento ni
51
+ * por qué. Y solo pasaba cuando algún texto traía un apóstrofe, que es lo que lo hacía
52
+ * tan difícil de ver: los mismos sets pasaban o fallaban según el contenido del set.
53
+ *
54
+ * Medido el 14/08/2026 (RUT 78206276-K, maullin). Un solo ítem, "CAPACITACION USO PLC'S
55
+ * CNC", en un único documento del Set Factura Exenta:
56
+ *
57
+ * Set Básico → EPR 0 documentos con digest divergente
58
+ * Set Guía → EPR 0
59
+ * Set Exenta → RFR 1 (nota de débito 56/2616, la del apóstrofe)
60
+ * Set Compra → EPR 0
61
+ *
62
+ * El envío entero se cae por un carácter en un ítem. Ver `test/c14n-apostrofe.test.js`.
44
63
  */
45
- function fixEntities(xml) {
46
- let out = '';
47
- let inContent = false;
48
- for (let i = 0; i < xml.length; i++) {
49
- const ch = xml[i];
50
- if (ch === '>') inContent = true;
51
- if (ch === '<') inContent = false;
52
- if (inContent && ch === "'") { out += '&apos;'; continue; }
53
- if (inContent && ch === '"') { out += '&quot;'; continue; }
54
- out += ch;
55
- }
56
- return out;
57
- }
58
64
 
59
65
  /**
60
66
  * Serializa un nodo DOM a XML canónico
@@ -257,7 +263,6 @@ module.exports = {
257
263
  // Escape/Text functions
258
264
  escapeAttr,
259
265
  escapeText,
260
- fixEntities,
261
266
  // Serialization
262
267
  serializeNode,
263
268
  serializeElement,
@@ -171,6 +171,20 @@ function registrarHttpDebug(info = {}) {
171
171
  truncado,
172
172
  archivo: nombre,
173
173
  archivoRequest: archivoReq,
174
+ // El cuerpo enviado va TAMBIÉN acá, no solo en el HTML.
175
+ //
176
+ // `index.jsonl` es lo único que la purga conserva siempre (ver cert-purga.ts), y
177
+ // el HTML con su cabecera es lo primero que se descarta. Depurando el 14/08/2026
178
+ // hacia atrás quedó a la vista: del día anterior se podía leer QUÉ endpoints se
179
+ // llamaron, pero no CON QUÉ datos, que era justo lo necesario para comparar una
180
+ // corrida buena contra una fallada.
181
+ //
182
+ // Se recorta a 2000 caracteres: el índice se guarda un año y tiene que seguir
183
+ // siendo liviano. El cuerpo completo, mientras exista, está en el HTML o en el
184
+ // `-request.txt`.
185
+ reqBody: reqTexto
186
+ ? (reqTexto.length > 2000 ? reqTexto.slice(0, 2000) + ` …${REDACTADO} recortado` : reqTexto)
187
+ : null,
174
188
  headers: redactarHeaders(headers),
175
189
  }) + '\n',
176
190
  'utf-8',