@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/CafSolicitor.js +146 -6
- package/DTE.js +2 -2
- package/FolioService.js +149 -4
- package/Signer.js +2 -2
- package/SiiCertificacion.js +9 -1
- package/SiiSession.js +45 -1
- package/cert/CertRunner.js +415 -21
- package/cert/SetBase.js +52 -1
- package/cert/SetBasico.js +3 -21
- package/cert/SetCompra.js +3 -21
- package/cert/SetExenta.js +3 -21
- package/cert/SetGuia.js +1 -15
- package/dte-sii.d.ts +29 -0
- package/package.json +2 -2
- package/utils/c14n.js +22 -17
- package/utils/httpDebug.js +14 -0
package/CafSolicitor.js
CHANGED
|
@@ -366,6 +366,115 @@ class CafSolicitor {
|
|
|
366
366
|
* la simulación de certificación.
|
|
367
367
|
* @returns {Promise<Object>} - { success, cafPath, xml, maxAutor, foliosDisp, error, errorCode }
|
|
368
368
|
*/
|
|
369
|
+
/**
|
|
370
|
+
* Lista los rangos de folios YA AUTORIZADOS que el SII permite volver a descargar.
|
|
371
|
+
*
|
|
372
|
+
* Es la salida a un timbraje bloqueado que no cuesta cupo. Cuando el SII niega folios
|
|
373
|
+
* nuevos dice, textual: "usted tiene disponible una cantidad de folios suficiente...
|
|
374
|
+
* debe emitir y enviar documentos electrónicos al SII o anular folios". Anular es
|
|
375
|
+
* contraproducente —los folios anulados pesan 6 meses EN CONTRA del cupo, así que
|
|
376
|
+
* profundiza el bloqueo—; emitir es lo que lo levanta. Pero para emitir hace falta el
|
|
377
|
+
* CAF, y si se perdió, esto lo recupera.
|
|
378
|
+
*
|
|
379
|
+
* ⚠️ El listado del paso 2 incluye rangos ANULADOS sin distinguirlos: recién al abrir
|
|
380
|
+
* cada uno (paso 3) el portal avisa "el rango de folios que desea reobtener ha sido
|
|
381
|
+
* anulado completamente... los documentos que el Servicio reciba con dichos folios
|
|
382
|
+
* serán rechazados". Por eso hay que abrir cada rango para saber si sirve, y por eso
|
|
383
|
+
* este método hace un request por rango. Verificado en maullin el 14/08/2026 con el
|
|
384
|
+
* RUT 77967443-6: de 6 rangos listados en el tipo 56, solo 2 eran usables.
|
|
385
|
+
*
|
|
386
|
+
* @param {number} tipoDte
|
|
387
|
+
* @returns {Promise<Array<{ campos: Object, folioDesde: number, folioHasta: number,
|
|
388
|
+
* cantidad: number, anulado: boolean }>>}
|
|
389
|
+
*/
|
|
390
|
+
async listarReobtenibles(tipoDte) {
|
|
391
|
+
const { numero, dv } = splitRut(this.rutEmisor);
|
|
392
|
+
|
|
393
|
+
await this.session.request(`https://${this.session.getBaseHost()}/cvc_cgi/dte/rf_reobtencion1_folios`);
|
|
394
|
+
const lista = await this.session.submitForm('/cvc_cgi/dte/rf_reobtencion2_folios', {
|
|
395
|
+
RUT_EMP: numero, DV_EMP: String(dv).toUpperCase(), PAGINA: '1',
|
|
396
|
+
COD_DOCTO: String(tipoDte), ACEPTAR: 'Consultar',
|
|
397
|
+
}, '/cvc_cgi/dte/rf_reobtencion1_folios');
|
|
398
|
+
|
|
399
|
+
// Cada rango viene en su propio <form name="frmN"> con todos sus ocultos.
|
|
400
|
+
const rangos = [...String(lista.body || '').matchAll(/<form\s+name="frm\d+"[\s\S]*?<\/form>/gi)]
|
|
401
|
+
.map(([bloque]) => {
|
|
402
|
+
const campos = {};
|
|
403
|
+
for (const [, k, v] of bloque.matchAll(/<input[^>]*name="([^"]+)"[^>]*value="([^"]*)"/gi)) campos[k] = v;
|
|
404
|
+
return campos;
|
|
405
|
+
})
|
|
406
|
+
.filter(c => c.FOLIO_INI && c.FOLIO_FIN);
|
|
407
|
+
|
|
408
|
+
const salida = [];
|
|
409
|
+
for (const campos of rangos) {
|
|
410
|
+
const detalle = await this.session.submitForm(
|
|
411
|
+
'/cvc_cgi/dte/rf_reobtencion3_folios', campos, '/cvc_cgi/dte/rf_reobtencion2_folios',
|
|
412
|
+
);
|
|
413
|
+
const html = String(detalle.body || '');
|
|
414
|
+
salida.push({
|
|
415
|
+
campos,
|
|
416
|
+
folioDesde: Number(campos.FOLIO_INI),
|
|
417
|
+
folioHasta: Number(campos.FOLIO_FIN),
|
|
418
|
+
cantidad: Number(campos.FOLIO_FIN) - Number(campos.FOLIO_INI) + 1,
|
|
419
|
+
anulado: /ha sido anulado/i.test(html),
|
|
420
|
+
// El paso 3 ya trae el formulario final; se guarda para no repetir el request.
|
|
421
|
+
confirmacion: html,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
return salida;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Descarga el CAF de un rango ya autorizado. `rango` es un elemento de
|
|
429
|
+
* `listarReobtenibles()`. Devuelve el mismo shape que `solicitar()` para que el
|
|
430
|
+
* llamador no tenga que distinguir de dónde salió el CAF.
|
|
431
|
+
*/
|
|
432
|
+
async reobtenerCaf(tipoDte, rango) {
|
|
433
|
+
if (rango.anulado) {
|
|
434
|
+
return { success: false, errorCode: 'RANGO_ANULADO',
|
|
435
|
+
error: `El rango ${rango.folioDesde}-${rango.folioHasta} del tipo ${tipoDte} está anulado: el SII rechazaría cualquier documento emitido con esos folios.` };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// El paso 3 devolvió el formulario final (`form1` → rf_genera_folio) con sus ocultos.
|
|
439
|
+
const campos = {};
|
|
440
|
+
const bloque = String(rango.confirmacion || '').match(/<form\s+name="form1"[\s\S]*?<\/form>/i)?.[0] ?? '';
|
|
441
|
+
for (const [, k, v] of bloque.matchAll(/<input[^>]*name="([^"]+)"[^>]*value="([^"]*)"/gi)) campos[k] = v;
|
|
442
|
+
if (!campos.FOLIO_INI) {
|
|
443
|
+
return { success: false, errorCode: 'REOBTENCION_SIN_FORMULARIO',
|
|
444
|
+
error: 'El SII no devolvió el formulario de generación para este rango.' };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// `rf_genera_folio` NO devuelve el XML: emite la resolución ("el SII ha autorizado...
|
|
448
|
+
// la numeración desde N hasta M") y recién ahí ofrece el enlace de descarga, que es
|
|
449
|
+
// otro formulario contra `rf_genera_archivo`. Son dos pasos, como en el timbraje
|
|
450
|
+
// normal (of_genera_folio → of_genera_archivo).
|
|
451
|
+
const autorizacion = await this.session.submitForm('/cvc_cgi/dte/rf_genera_folio',
|
|
452
|
+
{ ...campos, ACEPTAR: 'Solicitar Folios' }, '/cvc_cgi/dte/rf_reobtencion3_folios');
|
|
453
|
+
|
|
454
|
+
const camposArchivo = {};
|
|
455
|
+
const formArchivo = String(autorizacion.body || '')
|
|
456
|
+
.match(/<form[^>]*action="[^"]*rf_genera_archivo"[\s\S]*?<\/form>/i)?.[0] ?? '';
|
|
457
|
+
for (const [, k, v] of formArchivo.matchAll(/<input[^>]*name="([^"]+)"[^>]*value="([^"]*)"/gi)) camposArchivo[k] = v;
|
|
458
|
+
if (!camposArchivo.FOLIO_INI) {
|
|
459
|
+
return { success: false, errorCode: 'REOBTENCION_SIN_DESCARGA',
|
|
460
|
+
error: 'El SII autorizó la reobtención pero no ofreció el enlace de descarga del CAF.' };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const resp = await this.session.submitForm('/cvc_cgi/dte/rf_genera_archivo',
|
|
464
|
+
{ ...camposArchivo, ACEPTAR: 'AQUI' }, '/cvc_cgi/dte/rf_genera_folio');
|
|
465
|
+
const body = String(resp.body || '');
|
|
466
|
+
if (!body.includes('<AUTORIZACION')) {
|
|
467
|
+
return { success: false, errorCode: 'REOBTENCION_SIN_CAF',
|
|
468
|
+
error: 'El SII no devolvió el XML del CAF al reobtener.' };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const cafPath = this._saveCafOrganized(body, tipoDte);
|
|
472
|
+
return {
|
|
473
|
+
success: true, cafPath, xml: body, reobtenido: true,
|
|
474
|
+
folioDesde: rango.folioDesde, folioHasta: rango.folioHasta, otorgados: rango.cantidad,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
369
478
|
async solicitar({ tipoDte, cantidad = 1, minCantidad = null, soloConsultarTope = false }) {
|
|
370
479
|
const { numero: rut, dv } = splitRut(this.rutEmisor);
|
|
371
480
|
const debugDir = this._getDebugDir(tipoDte);
|
|
@@ -421,9 +530,14 @@ class CafSolicitor {
|
|
|
421
530
|
|
|
422
531
|
this._saveDebug(debugDir, 'step1-submit.html', response.body || '');
|
|
423
532
|
|
|
424
|
-
// Guardar sesión para reutilización
|
|
533
|
+
// Guardar sesión para reutilización. Best-effort: si falla, se pierde el
|
|
534
|
+
// reuso pero NO se aborta la solicitud de folios ya en curso.
|
|
425
535
|
if (this.sessionPath) {
|
|
426
|
-
|
|
536
|
+
try {
|
|
537
|
+
this.session.saveSession(this.sessionPath);
|
|
538
|
+
} catch (e) {
|
|
539
|
+
console.warn(`[CafSolicitor] No se pudo guardar la sesión: ${e.message}`);
|
|
540
|
+
}
|
|
427
541
|
}
|
|
428
542
|
|
|
429
543
|
// Rechazo duro por falta de Verificación de Actividades: debe detectarse ANTES de
|
|
@@ -658,12 +772,31 @@ class CafSolicitor {
|
|
|
658
772
|
* Procesa el flujo multi-paso del SII para obtener CAF
|
|
659
773
|
* @private
|
|
660
774
|
*/
|
|
775
|
+
/**
|
|
776
|
+
* Igual que `esRechazoDuro`, pero además deja registrado si el motivo fue el bloqueo de
|
|
777
|
+
* timbraje, para que `consultarTope()` pueda distinguir "el SII no limita este tipo" de
|
|
778
|
+
* "el SII no autoriza nada" — dos casos que se ven idénticos porque en ambos la página
|
|
779
|
+
* viene sin MAX_AUTOR.
|
|
780
|
+
*
|
|
781
|
+
* Existe porque el corte por rechazo duro está en CUATRO puntos distintos del flujo
|
|
782
|
+
* (`_processMultiStepFlow` dos veces, su paso intermedio y `_processStep3`), y cada uno
|
|
783
|
+
* retorna apenas lo detecta. Marcar la bandera en uno solo dejaba la detección
|
|
784
|
+
* inalcanzable según por dónde saliera: el sondeo del tipo 56 informaba "no está
|
|
785
|
+
* racionando" con el timbraje cerrado (14/08/2026). Se marca donde se detecta, no en un
|
|
786
|
+
* punto elegido a mano.
|
|
787
|
+
* @private
|
|
788
|
+
*/
|
|
789
|
+
_esRechazoDuroYMarca(html) {
|
|
790
|
+
if (CafSolicitor.esBloqueoTimbraje(html)) this._lastBloqueoTimbraje = true;
|
|
791
|
+
return CafSolicitor.esRechazoDuro(html);
|
|
792
|
+
}
|
|
793
|
+
|
|
661
794
|
async _processMultiStepFlow(response, rut, dv, tipoDte, cantidad, debugDir) {
|
|
662
795
|
let currentHtml = response.body || '';
|
|
663
796
|
|
|
664
797
|
// Defensivo: mismo rechazo por Verificación de Actividades puede aparecer si el SII
|
|
665
798
|
// lo entrega recién en un paso posterior en vez del response inicial de solicitar().
|
|
666
|
-
if (
|
|
799
|
+
if (this._esRechazoDuroYMarca(currentHtml)) {
|
|
667
800
|
return response; // solicitar() traduce el motivo desde response.body
|
|
668
801
|
}
|
|
669
802
|
|
|
@@ -698,7 +831,7 @@ class CafSolicitor {
|
|
|
698
831
|
|
|
699
832
|
// Rechazo duro antes del check de COD_DOCTO: la página de rechazo también contiene
|
|
700
833
|
// "COD_DOCTO" en su JavaScript, lo que causaría un POST innecesario con datos vacíos.
|
|
701
|
-
if (
|
|
834
|
+
if (this._esRechazoDuroYMarca(currentHtml)) {
|
|
702
835
|
return response; // solicitar() traduce el motivo desde response.body
|
|
703
836
|
}
|
|
704
837
|
|
|
@@ -721,7 +854,7 @@ class CafSolicitor {
|
|
|
721
854
|
currentHtml = response.body || '';
|
|
722
855
|
this._saveDebug(debugDir, 'select.html', currentHtml);
|
|
723
856
|
|
|
724
|
-
if (
|
|
857
|
+
if (this._esRechazoDuroYMarca(currentHtml)) {
|
|
725
858
|
return response; // solicitar() traduce el motivo desde response.body
|
|
726
859
|
}
|
|
727
860
|
}
|
|
@@ -740,7 +873,14 @@ class CafSolicitor {
|
|
|
740
873
|
async _processStep3(response, rut, dv, tipoDte, cantidad, debugDir) {
|
|
741
874
|
let currentHtml = response.body || '';
|
|
742
875
|
|
|
743
|
-
|
|
876
|
+
// Se marca ANTES del corte por rechazo duro, no después.
|
|
877
|
+
//
|
|
878
|
+
// `esRechazoDuro` incluye el bloqueo de timbraje, así que en ese caso la función
|
|
879
|
+
// retorna acá mismo y todo lo que viene abajo —incluida la lectura de MAX_AUTOR—
|
|
880
|
+
// no se ejecuta. Marcarlo más adelante dejaba la bandera en false justo en el único
|
|
881
|
+
// caso que tiene que detectar, y el sondeo seguía informando "el SII no está
|
|
882
|
+
// racionando este tipo" con el timbraje cerrado (visto el 14/08/2026, tipo 56).
|
|
883
|
+
if (this._esRechazoDuroYMarca(currentHtml)) {
|
|
744
884
|
return response; // solicitar() traduce el motivo desde response.body
|
|
745
885
|
}
|
|
746
886
|
|
package/DTE.js
CHANGED
|
@@ -20,7 +20,7 @@ const {
|
|
|
20
20
|
TIPOS_BOLETA,
|
|
21
21
|
TASA_IVA,
|
|
22
22
|
} = require('./utils');
|
|
23
|
-
const { serializeNode,
|
|
23
|
+
const { serializeNode, escapeAttr, escapeText, buildSignedInfo, buildSignature } = require('./utils/c14n');
|
|
24
24
|
|
|
25
25
|
// ============================================
|
|
26
26
|
// CONSTANTES
|
|
@@ -410,7 +410,7 @@ class DTE {
|
|
|
410
410
|
}
|
|
411
411
|
|
|
412
412
|
c14n += '</Documento>';
|
|
413
|
-
return
|
|
413
|
+
return c14n;
|
|
414
414
|
}
|
|
415
415
|
|
|
416
416
|
// ============================================
|
package/FolioService.js
CHANGED
|
@@ -109,11 +109,17 @@ class FolioService {
|
|
|
109
109
|
|
|
110
110
|
// Solicitador de CAF interno (migrado de test-caf-solicitar.js)
|
|
111
111
|
this.cafSolicitor = null;
|
|
112
|
-
|
|
112
|
+
// `pfxBuffer` también sirve: el constructor pedía `pfxPath` sí o sí, aunque la propia
|
|
113
|
+
// clase ya acepta buffer para la sesión y CafSolicitor lo soporta. Un consumidor que
|
|
114
|
+
// tiene el certificado en memoria (leído de la BD, sin escribirlo a disco) quedaba con
|
|
115
|
+
// `cafSolicitor: null` y todo lo que depende de él —solicitar, consultar tope,
|
|
116
|
+
// reobtener— fallaba con "CafSolicitor no inicializado".
|
|
117
|
+
if ((options.pfxPath || options.pfxBuffer) && options.pfxPassword) {
|
|
113
118
|
this.cafSolicitor = new CafSolicitor({
|
|
114
119
|
ambiente: this.ambiente,
|
|
115
120
|
rutEmisor: this.rutEmisor,
|
|
116
121
|
pfxPath: options.pfxPath,
|
|
122
|
+
pfxBuffer: options.pfxBuffer,
|
|
117
123
|
pfxPassword: options.pfxPassword,
|
|
118
124
|
baseDir: this.baseDir,
|
|
119
125
|
sessionPath: this.sessionPath,
|
|
@@ -152,7 +158,23 @@ class FolioService {
|
|
|
152
158
|
try {
|
|
153
159
|
const xml = fs.readFileSync(fullPath, 'utf8');
|
|
154
160
|
const tdMatch = xml.match(/<TD>(\d+)<\/TD>/i);
|
|
155
|
-
|
|
161
|
+
// ⚠️ El RUT se valida acá, contra el <RE> del propio CAF, y NO se da por
|
|
162
|
+
// supuesto por la ruta.
|
|
163
|
+
//
|
|
164
|
+
// `cafDir` (debug/auto-caf) es COMPARTIDO entre todos los comercios del
|
|
165
|
+
// servidor: sus subcarpetas llevan el RUT, pero la búsqueda es recursiva y
|
|
166
|
+
// antes solo comparaba el tipo de DTE. Un CAF de otra empresa con el mismo
|
|
167
|
+
// tipo entraba como candidato válido.
|
|
168
|
+
//
|
|
169
|
+
// Pasó de verdad (14/08/2026): al reusar CAF previos, los tipos 56 y 61 del
|
|
170
|
+
// RUT 77967443-6 resolvieron a CAF de 78206276-K. El timbre quedó firmado con
|
|
171
|
+
// la llave de otro contribuyente y el SII devolvió `RFR - Rechazado por Error
|
|
172
|
+
// en Firma` para todo el envío, lo que a su vez trababa la declaración de
|
|
173
|
+
// simulación ("no debe contener documentos con reparos o rechazos").
|
|
174
|
+
const reMatch = xml.match(/<RE>([^<]+)<\/RE>/i);
|
|
175
|
+
const rutCaf = (reMatch?.[1] ?? '').replace(/\./g, '').trim().toUpperCase();
|
|
176
|
+
const rutMio = String(this.rutEmisor).replace(/\./g, '').trim().toUpperCase();
|
|
177
|
+
if (tdMatch && Number(tdMatch[1]) === Number(tipoDte) && rutCaf === rutMio) {
|
|
156
178
|
const stat = fs.statSync(fullPath);
|
|
157
179
|
matches.push({ filePath: fullPath, mtime: stat.mtimeMs });
|
|
158
180
|
}
|
|
@@ -307,6 +329,86 @@ class FolioService {
|
|
|
307
329
|
* usar para destrabar el tope. Ponerlo en false lo deja en modo consulta.
|
|
308
330
|
* @returns {Promise<Object>} { ok, cafPath, otorgados, maxAutor, foliosDisp, errorCode, error }
|
|
309
331
|
*/
|
|
332
|
+
/**
|
|
333
|
+
* Intenta cubrir `cantidad` folios recuperando un CAF ya autorizado, sin pedirle folios
|
|
334
|
+
* nuevos al SII.
|
|
335
|
+
*
|
|
336
|
+
* Es el paso que va ANTES de pedir y muy antes de anular. Cuando el SII bloquea el
|
|
337
|
+
* timbraje lo hace porque el contribuyente ya tiene folios sin usar; recuperarlos y
|
|
338
|
+
* emitir con ellos es justo lo que el SII pide para levantar el bloqueo, mientras que
|
|
339
|
+
* anular lo empeora (los folios anulados pesan 6 meses en contra del cupo).
|
|
340
|
+
*
|
|
341
|
+
* ⚠️ Solo sirve un rango que por sí solo alcance: el generador de sets recibe UN CAF por
|
|
342
|
+
* tipo (`cafManager.ensureCaf({ tipoDte })` en CertRunner), así que no se pueden juntar
|
|
343
|
+
* dos rangos sueltos para el mismo tipo. El SII suele entregarlos de a un folio, así que
|
|
344
|
+
* esto no siempre alcanza — y cuando no alcanza, se devuelve el motivo en vez de fingir.
|
|
345
|
+
*
|
|
346
|
+
* @returns {Promise<{ ok: boolean, cafPath?: string, motivo?: string, disponibles?: number }>}
|
|
347
|
+
*/
|
|
348
|
+
async reobtenerCaf({ tipoDte, cantidad }) {
|
|
349
|
+
if (!this.cafSolicitor) {
|
|
350
|
+
return { ok: false, motivo: 'CafSolicitor no inicializado' };
|
|
351
|
+
}
|
|
352
|
+
let rangos;
|
|
353
|
+
try {
|
|
354
|
+
rangos = await this.cafSolicitor.listarReobtenibles(Number(tipoDte));
|
|
355
|
+
} catch (err) {
|
|
356
|
+
// Nunca fatal: si la reobtención falla, el flujo sigue por el camino normal.
|
|
357
|
+
console.warn(`[FolioService] Tipo ${tipoDte}: no se pudo consultar reobtención — ${err.message}`);
|
|
358
|
+
return { ok: false, motivo: err.message };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const usables = rangos.filter(r => !r.anulado);
|
|
362
|
+
const anulados = rangos.length - usables.length;
|
|
363
|
+
console.log(
|
|
364
|
+
`[FolioService] Tipo ${tipoDte}: ${rangos.length} rango(s) reobtenible(s), ` +
|
|
365
|
+
`${usables.length} usable(s)${anulados ? `, ${anulados} anulado(s) descartado(s)` : ''}`
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
const total = usables.reduce((n, r) => n + r.cantidad, 0);
|
|
369
|
+
if (total < cantidad) {
|
|
370
|
+
return {
|
|
371
|
+
ok: false,
|
|
372
|
+
disponibles: total,
|
|
373
|
+
motivo: `los rangos usables no alcanzan (se necesitan ${cantidad}, hay ${total})`,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// En orden de folio ascendente: los documentos del set quedan numerados de menor a
|
|
378
|
+
// mayor, como si vinieran de un rango contiguo.
|
|
379
|
+
const elegidos = [];
|
|
380
|
+
let acumulado = 0;
|
|
381
|
+
for (const r of usables.sort((a, b) => a.folioDesde - b.folioDesde)) {
|
|
382
|
+
if (acumulado >= cantidad) break;
|
|
383
|
+
elegidos.push(r);
|
|
384
|
+
acumulado += r.cantidad;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Se descargan TODOS los que hagan falta: el SII entrega los folios reobtenidos de a
|
|
388
|
+
// uno, así que cubrir 4 folios puede requerir 4 CAF distintos. Los sets los aceptan
|
|
389
|
+
// como lista (ver SetBase._tomarFolio) porque cada uno firma con su propia llave.
|
|
390
|
+
const cafPaths = [];
|
|
391
|
+
for (const rango of elegidos) {
|
|
392
|
+
const res = await this.cafSolicitor.reobtenerCaf(Number(tipoDte), rango);
|
|
393
|
+
if (!res.success) {
|
|
394
|
+
// Si uno falla a mitad, lo ya descargado igual sirve mientras alcance.
|
|
395
|
+
console.warn(`[FolioService] Tipo ${tipoDte}: falló reobtener ${rango.folioDesde}-${rango.folioHasta} — ${res.error}`);
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
cafPaths.push(res.cafPath);
|
|
399
|
+
console.log(`[FolioService] Tipo ${tipoDte}: CAF REOBTENIDO (folios ${res.folioDesde}-${res.folioHasta}) — sin gastar cupo`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const cubiertos = elegidos
|
|
403
|
+
.filter((_, i) => i < cafPaths.length)
|
|
404
|
+
.reduce((n, r) => n + r.cantidad, 0);
|
|
405
|
+
if (cubiertos < cantidad) {
|
|
406
|
+
return { ok: false, disponibles: cubiertos,
|
|
407
|
+
motivo: `se reobtuvieron ${cubiertos} folio(s) de los ${cantidad} necesarios` };
|
|
408
|
+
}
|
|
409
|
+
return { ok: true, cafPaths, cafPath: cafPaths[0] };
|
|
410
|
+
}
|
|
411
|
+
|
|
310
412
|
/**
|
|
311
413
|
* Consulta cuánto autoriza el SII para un tipo, SIN emitir nada.
|
|
312
414
|
*
|
|
@@ -325,11 +427,16 @@ class FolioService {
|
|
|
325
427
|
if (!this.cafSolicitor) {
|
|
326
428
|
throw new Error('FolioService: CafSolicitor no inicializado (se requiere pfxPath y pfxPassword)');
|
|
327
429
|
}
|
|
430
|
+
this.cafSolicitor._lastBloqueoTimbraje = false;
|
|
328
431
|
await this.cafSolicitor.solicitar({ tipoDte, cantidad: 1, soloConsultarTope: true });
|
|
329
432
|
const maxAutor = this.cafSolicitor._lastMaxAutor ?? null;
|
|
330
433
|
const foliosDisp = this.cafSolicitor._lastFoliosDisp ?? null;
|
|
331
|
-
|
|
332
|
-
|
|
434
|
+
const bloqueado = this.cafSolicitor._lastBloqueoTimbraje === true;
|
|
435
|
+
// `_lastFoliosDisp` queda en null en DOS casos opuestos: cuando el SII no está
|
|
436
|
+
// limitando este tipo, y cuando lo tiene bloqueado del todo (ahí tampoco publica los
|
|
437
|
+
// campos, muestra "NO AUTORIZA TIMBRAJE"). Sin `bloqueado`, el segundo se leía como el
|
|
438
|
+
// primero y el llamador se saltaba la limpieza que era justo lo que hacía falta.
|
|
439
|
+
return { sinTope: foliosDisp === null && !bloqueado, maxAutor, foliosDisp, bloqueado };
|
|
333
440
|
}
|
|
334
441
|
|
|
335
442
|
async solicitarCafExacto({ tipoDte, cantidad, permitirAnular = true }) {
|
|
@@ -647,7 +754,40 @@ class FolioService {
|
|
|
647
754
|
console.log(`[FolioService] Pasada ${pasada + 1}: ${rangos.length} rango(s) a procesar`);
|
|
648
755
|
const host = this.session.getBaseHost();
|
|
649
756
|
|
|
757
|
+
// Corte cuando el SII deja de permitir anular, para no gastar requests confirmando
|
|
758
|
+
// lo mismo rango tras rango.
|
|
759
|
+
//
|
|
760
|
+
// ⚠️ Solo cuentan las negativas REALES. `ya-anulado` y `recepcionado` no son
|
|
761
|
+
// negativas: son resultados esperados (ese rango ya estaba anulado, o sus folios ya
|
|
762
|
+
// se usaron) y aparecen mezclados con anulaciones exitosas.
|
|
763
|
+
//
|
|
764
|
+
// Contarlos rompía la limpieza (medido el 13/08/2026, RUT 77967443-6, tipo 33):
|
|
765
|
+
// 20 rangos a procesar
|
|
766
|
+
// ✓ 47-49 anulado ✓ 43-46 anulado
|
|
767
|
+
// ✗ 31-34 ya anulado ✗ 27-30 ya anulado → CORTE, 8 rangos sin intentar
|
|
768
|
+
// Los éxitos y los "ya anulado" se intercalan, así que cortar ahí abandona rangos
|
|
769
|
+
// todavía anulables y deja el racionamiento puesto: la corrida quedó esperando por
|
|
770
|
+
// folios que se podrían haber liberado en esa misma pasada.
|
|
771
|
+
const NO_SON_NEGATIVA = new Set(['ya-anulado', 'recepcionado']);
|
|
772
|
+
const esNegativaDelSii = (razon) => !NO_SON_NEGATIVA.has(razon);
|
|
773
|
+
const MAX_RECHAZOS_SEGUIDOS = 2;
|
|
774
|
+
let rechazosSeguidos = 0;
|
|
775
|
+
|
|
776
|
+
let procesados = 0;
|
|
650
777
|
for (const range of rangos) {
|
|
778
|
+
if (rechazosSeguidos >= MAX_RECHAZOS_SEGUIDOS) {
|
|
779
|
+
console.warn(
|
|
780
|
+
`[FolioService] Tipo ${tipoDte}: ${rechazosSeguidos} rangos rechazados seguidos — ` +
|
|
781
|
+
`el SII no está permitiendo anular, se abandona la limpieza ` +
|
|
782
|
+
// Se cuenta contra los rangos de ESTA pasada, no contra `vistos`, que acumula
|
|
783
|
+
// entre pasadas y hacía que el número saliera negativo (visto el 13/08/2026:
|
|
784
|
+
// "se abandona la limpieza (-16 rango(s) sin intentar)").
|
|
785
|
+
`(${rangos.length - procesados} rango(s) sin intentar).`
|
|
786
|
+
);
|
|
787
|
+
break;
|
|
788
|
+
}
|
|
789
|
+
procesados++;
|
|
790
|
+
const anuladosAntes = anulados.length;
|
|
651
791
|
vistos.add(`${range.folioDesde}-${range.folioHasta}`);
|
|
652
792
|
const iniA = folioDesde != null ? Math.max(range.folioDesde, folioDesde) : range.folioDesde;
|
|
653
793
|
const finA = folioHasta != null ? Math.min(range.folioHasta, folioHasta) : range.folioHasta;
|
|
@@ -675,6 +815,7 @@ class FolioService {
|
|
|
675
815
|
} catch (err) {
|
|
676
816
|
console.error(`[FolioService] af_anular3 falló rango ${iniA}-${finA}: ${err.message}`);
|
|
677
817
|
rechazados.push({ folioDesde: iniA, folioHasta: finA, count, reason: 'error-red' });
|
|
818
|
+
rechazosSeguidos = rechazosSeguidos + 1;
|
|
678
819
|
continue;
|
|
679
820
|
}
|
|
680
821
|
|
|
@@ -687,6 +828,7 @@ class FolioService {
|
|
|
687
828
|
body3.includes('efectuado anteriormente') ||
|
|
688
829
|
/anulad[oa]\s+anteriormente/i.test(body3)) {
|
|
689
830
|
rechazados.push({ folioDesde: iniA, folioHasta: finA, count, reason: 'ya-anulado' });
|
|
831
|
+
// 'ya anulado' NO cuenta: es un no-op, no una negativa del SII.
|
|
690
832
|
// Se recuerda para que la próxima corrida no lo reintente.
|
|
691
833
|
yaAnulados.add(`${iniA}-${finA}`);
|
|
692
834
|
console.warn(`[FolioService] ✗ Rango ${iniA}-${finA}: ya anulado (af_anular3)`);
|
|
@@ -711,6 +853,7 @@ class FolioService {
|
|
|
711
853
|
} catch (err) {
|
|
712
854
|
console.error(`[FolioService] af_anular bulk falló rango ${iniA}-${finA}: ${err.message}`);
|
|
713
855
|
rechazados.push({ folioDesde: iniA, folioHasta: finA, count, reason: 'error-red' });
|
|
856
|
+
rechazosSeguidos = rechazosSeguidos + 1;
|
|
714
857
|
continue;
|
|
715
858
|
}
|
|
716
859
|
|
|
@@ -718,6 +861,7 @@ class FolioService {
|
|
|
718
861
|
bodyBulk.includes('SOLICITUD ANULACION DE FOLIOS');
|
|
719
862
|
if (exitoBulk) {
|
|
720
863
|
anulados.push({ folioDesde: iniA, folioHasta: finA, count });
|
|
864
|
+
rechazosSeguidos = 0;
|
|
721
865
|
// Un rango recién anulado sigue apareciendo en `consultarFolios` como
|
|
722
866
|
// "sin utilizar": si no se recuerda acá, la próxima corrida lo
|
|
723
867
|
// reintenta y el SII contesta "ya anulado". Ésta es la fuente
|
|
@@ -738,6 +882,7 @@ class FolioService {
|
|
|
738
882
|
if (!yaConflicto) {
|
|
739
883
|
const razon = this._parseAnulacionResult(bodyBulk).reason || 'error';
|
|
740
884
|
rechazados.push({ folioDesde: iniA, folioHasta: finA, count, reason: razon });
|
|
885
|
+
if (esNegativaDelSii(razon)) rechazosSeguidos = rechazosSeguidos + 1;
|
|
741
886
|
// Mismo criterio que en el camino folio-a-folio: solo se recuerdan los
|
|
742
887
|
// rechazos definitivos, no los transitorios.
|
|
743
888
|
if (razon === 'ya-anulado' || razon === 'recepcionado') {
|
package/Signer.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
const crypto = require('crypto');
|
|
10
10
|
const { DOMParser } = require('@xmldom/xmldom');
|
|
11
11
|
const { formatBase64InXml } = require('./utils');
|
|
12
|
-
const { serializeNode,
|
|
12
|
+
const { serializeNode, buildSignedInfo, buildSignature } = require('./utils/c14n');
|
|
13
13
|
|
|
14
14
|
// ============================================
|
|
15
15
|
// CLASE SIGNER
|
|
@@ -87,7 +87,7 @@ class Signer {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
c14n += '</SetDTE>';
|
|
90
|
-
return
|
|
90
|
+
return c14n;
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
package/SiiCertificacion.js
CHANGED
|
@@ -107,7 +107,15 @@ class SiiCertificacion {
|
|
|
107
107
|
const _sp = options.sessionPath;
|
|
108
108
|
this.session.ensureSession = async function(targetPath) {
|
|
109
109
|
const result = await _origEnsure(targetPath);
|
|
110
|
-
|
|
110
|
+
// Best-effort: este hook corre en CADA establecimiento de sesión, así que
|
|
111
|
+
// una falla al escribir el cache (disco lleno, permisos, volumen no montado)
|
|
112
|
+
// tumbaría toda operación contra el SII. Persistir la sesión es una
|
|
113
|
+
// optimización; la operación en curso ya tiene la sesión viva en memoria.
|
|
114
|
+
try {
|
|
115
|
+
_sess.saveSession(_sp);
|
|
116
|
+
} catch (e) {
|
|
117
|
+
console.warn(`[SiiCertificacion] No se pudo guardar la sesión en ${_sp}: ${e.message}`);
|
|
118
|
+
}
|
|
111
119
|
return result;
|
|
112
120
|
};
|
|
113
121
|
}
|
package/SiiSession.js
CHANGED
|
@@ -538,17 +538,41 @@ class SiiSession {
|
|
|
538
538
|
}
|
|
539
539
|
|
|
540
540
|
/**
|
|
541
|
-
* Guarda la sesión actual en un archivo JSON
|
|
541
|
+
* Guarda la sesión actual en un archivo JSON.
|
|
542
|
+
*
|
|
543
|
+
* Crea el directorio contenedor si no existe: `filePath` suele apuntar a un
|
|
544
|
+
* volumen persistente o a una carpeta de debug que en el primer arranque
|
|
545
|
+
* todavía no está creada, y sin esto `writeFileSync` lanza ENOENT. Perder la
|
|
546
|
+
* sesión obliga a re-loguearse contra el SII en cada operación, que es el
|
|
547
|
+
* camino directo al bloqueo por "máximo de sesiones autenticadas" del RUT.
|
|
548
|
+
*
|
|
542
549
|
* @param {string} filePath - Ruta del archivo donde guardar
|
|
543
550
|
*/
|
|
551
|
+
/**
|
|
552
|
+
* Huella del certificado con el que se configuró el TLS de esta sesión.
|
|
553
|
+
*
|
|
554
|
+
* Identifica de QUIÉN es la sesión. Sin esto, `SII_SESSION_PATH` —que en producción es
|
|
555
|
+
* un archivo ÚNICO para todos los comercios (`/data/sii/session.json`)— hacía que la
|
|
556
|
+
* sesión de un contribuyente se cargara para las operaciones de otro.
|
|
557
|
+
* @private
|
|
558
|
+
*/
|
|
559
|
+
_huellaCert() {
|
|
560
|
+
const pem = this.tlsOptions?.cert;
|
|
561
|
+
if (!pem) return null;
|
|
562
|
+
return require('crypto').createHash('sha256').update(String(pem)).digest('hex').slice(0, 16);
|
|
563
|
+
}
|
|
564
|
+
|
|
544
565
|
saveSession(filePath) {
|
|
545
566
|
const fs = require('fs');
|
|
567
|
+
const path = require('path');
|
|
546
568
|
const sessionData = {
|
|
547
569
|
cookieJar: this.cookieJar,
|
|
548
570
|
baseHost: this.baseHost,
|
|
571
|
+
certHash: this._huellaCert(),
|
|
549
572
|
savedAt: Date.now(),
|
|
550
573
|
expiresAt: Date.now() + (90 * 60 * 1000), // 90 minutos de validez
|
|
551
574
|
};
|
|
575
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
552
576
|
fs.writeFileSync(filePath, JSON.stringify(sessionData, null, 2), 'utf8');
|
|
553
577
|
}
|
|
554
578
|
|
|
@@ -576,6 +600,26 @@ class SiiSession {
|
|
|
576
600
|
console.log('Host SII no coincide, se requiere nuevo login');
|
|
577
601
|
return false;
|
|
578
602
|
}
|
|
603
|
+
|
|
604
|
+
// ⚠️ Y que la sesión sea de ESTE certificado.
|
|
605
|
+
//
|
|
606
|
+
// `SII_SESSION_PATH` es un archivo único para todo el proceso (en producción
|
|
607
|
+
// `/data/sii/session.json`), así que en un servidor multi-tenant la sesión del
|
|
608
|
+
// último comercio que operó queda ahí para el siguiente. Cargarla significa actuar
|
|
609
|
+
// ante el SII como el usuario de OTRO contribuyente.
|
|
610
|
+
//
|
|
611
|
+
// Caso real (14/08/2026): una corrida del RUT 78206276-K reusó la sesión del
|
|
612
|
+
// certificado de otra empresa y el portal respondió "usted no está autorizado por la
|
|
613
|
+
// empresa para ingresar a esta opción" al pedir el set de pruebas. El mismo pedido
|
|
614
|
+
// con autenticación fresca funcionó. El síntoma no dice "sesión equivocada", dice
|
|
615
|
+
// "sin permisos", que manda a buscar el problema donde no está.
|
|
616
|
+
//
|
|
617
|
+
// Las sesiones viejas no traen `certHash`: se descartan, que es lo seguro.
|
|
618
|
+
const huella = this._huellaCert();
|
|
619
|
+
if (huella && data.certHash !== huella) {
|
|
620
|
+
console.log('[SessionSii] Sesión de otro certificado — se ignora y se hace login propio');
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
579
623
|
|
|
580
624
|
this.cookieJar = data.cookieJar || '';
|
|
581
625
|
console.log('[SessionSii] Sesión SII cargada desde archivo');
|