@devlas/dte-sii 2.5.12 → 2.5.13

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.
@@ -378,14 +378,22 @@ class SiiCertificacion {
378
378
  // Parsear estado de cada set/libro
379
379
  const estadoSets = {};
380
380
 
381
- // Buscar filas de la tabla con formato:
382
- // <td>SET/LIBRO nombre</td><td><b>ESTADO</b></td>
383
- const rowRegex = /<tr[^>]*>[\s\S]*?<td[^>]*>[\s\S]*?(SET[^<]*|LIBRO[^<]*)<\/font><\/td>[\s\S]*?<td[^>]*>[\s\S]*?<b>([^<]+)<\/b>/gi;
384
- let rowMatch;
385
- while ((rowMatch = rowRegex.exec(html)) !== null) {
386
- const nombre = rowMatch[1].trim();
387
- const estado = rowMatch[2].trim().toUpperCase();
388
- estadoSets[nombre] = estado;
381
+ // Parsear estado de cada set/libro fila por fila para evitar falsos positivos
382
+ // (la regex global cruzaba filas y asignaba REVISADO CONFORME de SET CASO GENERAL a LIBRO DE VENTAS)
383
+ const rows = html.split(/<\/tr>/i);
384
+ for (const row of rows) {
385
+ const nameMatch = /(SET[^<\n\r]*|LIBRO[^<\n\r]*)<\/font><\/td>/i.exec(row);
386
+ if (!nameMatch) continue;
387
+ const nombre = nameMatch[1].trim();
388
+ // Estado explícito en <b>...</b>
389
+ const stateMatch = /<b>([^<]+)<\/b>/i.exec(row);
390
+ if (stateMatch) {
391
+ estadoSets[nombre] = stateMatch[1].trim().toUpperCase();
392
+ } else {
393
+ // Fila con campos input: leer valor EST oculto (S01 = sin declarar, S21 = declarado)
394
+ const estMatch = /name="EST\d+"[^>]*value="([^"]+)"/i.exec(row);
395
+ estadoSets[nombre] = estMatch ? estMatch[1] : 'S01';
396
+ }
389
397
  }
390
398
 
391
399
  // Verificar si todos los sets requeridos están REVISADO CONFORME
@@ -541,16 +549,17 @@ class SiiCertificacion {
541
549
  // El SII cambia el orden según qué sets están aprobados
542
550
  const fieldMapping = {};
543
551
 
544
- if (process.env.DEBUG_SII) {
552
+ // Guardar pe_avance2 para debug (siempre)
553
+ {
545
554
  const fs = require('fs');
546
555
  const path = require('path');
547
- const debugDir = process.env.SII_DEBUG_DIR || path.join(__dirname, '../../debug');
556
+ const debugDir = process.env.SII_DEBUG_DIR || path.join(__dirname, '../../debug/cert-v2');
548
557
  if (!fs.existsSync(debugDir)) {
549
558
  fs.mkdirSync(debugDir, { recursive: true });
550
559
  }
551
- const debugPath = path.join(debugDir, 'pe_avance2.html');
560
+ const debugPath = path.join(debugDir, 'pe_avance2_form.html');
552
561
  fs.writeFileSync(debugPath, formHtml, 'utf8');
553
- console.log(' [DEBUG] HTML guardado en:', debugPath);
562
+ console.log(' 📄 HTML pe_avance2 formulario guardado en debug/cert-v2/pe_avance2_form.html');
554
563
  }
555
564
 
556
565
  // Extraer todas las filas <tr>...</tr> del formulario
@@ -737,13 +746,112 @@ class SiiCertificacion {
737
746
  // Éxito si el form se envió sin errores de sesión/contenido.
738
747
  // El estado del envío (ERRORES O REPAROS / EN REVISION / REVISADO CONFORME)
739
748
  // NO determina el éxito de la declaración — eso se resuelve vía polling.
740
- const success = (!hasError || hasSuccess) && !sesionExpirada && !contenidoNoCorresponde;
749
+ const postOk = (!hasError || hasSuccess) && !sesionExpirada && !contenidoNoCorresponde;
750
+
751
+ if (!postOk) {
752
+ return {
753
+ success: false,
754
+ error: errorMsg || undefined,
755
+ status: declareResponse.status,
756
+ rawHtml: body,
757
+ formHtml,
758
+ setsDeclarados: Object.keys(sets),
759
+ formDataSent: formData,
760
+ };
761
+ }
762
+
763
+ // Guardar HTML de pe_avance3 (respuesta de declaración) siempre para debug
764
+ {
765
+ const fs = require('fs');
766
+ const path = require('path');
767
+ const debugDir = process.env.SII_DEBUG_DIR || path.join(__dirname, '../../debug/cert-v2');
768
+ if (!fs.existsSync(debugDir)) fs.mkdirSync(debugDir, { recursive: true });
769
+ fs.writeFileSync(path.join(debugDir, 'pe_avance3_response.html'), body, 'utf8');
770
+ console.log(' 📄 HTML pe_avance3 guardado en debug/cert-v2/pe_avance3_response.html');
771
+ }
772
+
773
+ // 7. VERIFICACIÓN POST-DECLARACIÓN: Re-leer pe_avance2 y confirmar que los TrackIDs se guardaron
774
+ // Si aparece "EN REVISION" → la declaración fue aceptada y el SII está procesando
775
+ // Si reaparecen inputs vacíos → la declaración no se guardó
776
+ let verificado = true;
777
+ let verificacionError = '';
778
+ let enRevision = false;
779
+ try {
780
+ const verifyResponse = await this.session.submitForm(
781
+ '/cvc_cgi/dte/pe_avance2',
782
+ { RUT_EMP: this.rutEmpresa, DV_EMP: this.dvEmpresa, ACEPTAR: 'Continuar' },
783
+ 'https://maullin.sii.cl/cvc_cgi/dte/pe_avance1'
784
+ );
785
+ const verifyHtml = verifyResponse.body || '';
786
+
787
+ // Guardar HTML de verificación pe_avance2 siempre
788
+ {
789
+ const fs = require('fs');
790
+ const path = require('path');
791
+ const debugDir = process.env.SII_DEBUG_DIR || path.join(__dirname, '../../debug/cert-v2');
792
+ if (!fs.existsSync(debugDir)) fs.mkdirSync(debugDir, { recursive: true });
793
+ fs.writeFileSync(path.join(debugDir, 'pe_avance2_verify.html'), verifyHtml, 'utf8');
794
+ console.log(' 📄 HTML pe_avance2 verificación guardado en debug/cert-v2/pe_avance2_verify.html');
795
+ }
796
+
797
+ // Para cada set que declaramos, verificar estado
798
+ const verifyRows = verifyHtml.split(/<\/tr>/i);
799
+ const camposVacios = [];
800
+ const camposEnRevision = [];
801
+ for (const [setName, index] of Object.entries(fieldMapping)) {
802
+ if (!sets[setName]) continue;
803
+ for (const row of verifyRows) {
804
+ const numEnvMatch = row.match(new RegExp(`NAME="NUM_ENV${index}"`, 'i'));
805
+ if (!numEnvMatch) {
806
+ // Si no hay input NUM_ENV, verificar si aparece REVISADO CONFORME o EN REVISION
807
+ const labelPattern = patterns.find(p => p.name === setName);
808
+ if (labelPattern && labelPattern.label.test(row)) {
809
+ if (/<b>[^<]*REVISADO CONFORME[^<]*<\/b>/i.test(row)) break;
810
+ if (/EN REVISION/i.test(row)) {
811
+ camposEnRevision.push(setName);
812
+ break;
813
+ }
814
+ }
815
+ continue;
816
+ }
817
+ // Tiene input — verificar si tiene valor o está REVISADO CONFORME
818
+ const tieneConforme = /<b>[^<]*REVISADO CONFORME[^<]*<\/b>/i.test(row);
819
+ if (tieneConforme) break;
820
+ if (/EN REVISION/i.test(row)) {
821
+ camposEnRevision.push(setName);
822
+ break;
823
+ }
824
+ const valueMatch = row.match(new RegExp(`NAME="NUM_ENV${index}"[^>]*value="([^"]*)"`, 'i'));
825
+ const tieneValor = valueMatch && valueMatch[1] && valueMatch[1].trim() !== '';
826
+ if (!tieneValor) {
827
+ camposVacios.push(setName);
828
+ }
829
+ break;
830
+ }
831
+ }
832
+
833
+ if (camposEnRevision.length > 0) {
834
+ enRevision = true;
835
+ console.log(` 🔄 EN REVISION: ${camposEnRevision.join(', ')} — declaración aceptada, SII procesando`);
836
+ }
837
+
838
+ if (camposVacios.length > 0 && !enRevision) {
839
+ verificado = false;
840
+ verificacionError = `Declaración NO se guardó en el portal SII. Campos vacíos para: ${camposVacios.join(', ')}. Posible error de sesión o TrackID no reconocido.`;
841
+ console.log(` ⚠️ ${verificacionError}`);
842
+ }
843
+ } catch (verifyErr) {
844
+ console.log(` ⚠️ No se pudo verificar la declaración: ${verifyErr.message}`);
845
+ }
741
846
 
742
847
  return {
743
- success,
744
- error: errorMsg || undefined,
848
+ success: verificado || enRevision,
849
+ error: verificacionError || errorMsg || undefined,
850
+ verificado,
851
+ enRevision,
745
852
  status: declareResponse.status,
746
853
  rawHtml: body,
854
+ formHtml,
747
855
  setsDeclarados: Object.keys(sets),
748
856
  formDataSent: formData,
749
857
  };
@@ -348,6 +348,16 @@ class CertRunner {
348
348
  const result = await this.siiCert.declararAvance({ sets });
349
349
  lastResult = result;
350
350
 
351
+ // Guardar el form pe_avance2 (antes del POST) para debug
352
+ if (result.formHtml) {
353
+ fs.writeFileSync(
354
+ path.join(this.debugDir, `${debugPrefix}-pe_avance2-${intento}.html`),
355
+ result.formHtml,
356
+ 'utf8'
357
+ );
358
+ }
359
+
360
+ // Guardar la respuesta pe_avance3 (despues del POST) para debug
351
361
  if (result.rawHtml) {
352
362
  fs.writeFileSync(
353
363
  path.join(this.debugDir, `${debugPrefix}-${intento}.html`),
@@ -370,6 +380,11 @@ class CertRunner {
370
380
  if (noProcessedError && intento < maxIntentos) {
371
381
  console.log(` ⏳ SII aún procesando, reintentando en ${intervalo / 1000}s...`);
372
382
  await sleep(intervalo);
383
+ } else if (result.verificado === false && intento < maxIntentos) {
384
+ // Verificación post-declaración falló: los campos quedaron vacíos en el portal
385
+ console.log(` ⚠️ Verificación fallida: ${result.error}`);
386
+ console.log(` 🔄 Reintentando declaración en ${intervalo / 1000}s...`);
387
+ await sleep(intervalo);
373
388
  } else if (!result.success) {
374
389
  console.log(` ⚠️ Error declarando ${label}: ${result.error || 'desconocido'}`);
375
390
  break;
@@ -460,6 +475,7 @@ class CertRunner {
460
475
  * @param {Object} [options] - Opciones
461
476
  * @param {Object} [options.setBasicoResult] - Resultado del SetBasico
462
477
  * @param {Object} [options.setGuiaResult] - Resultado del SetGuia
478
+ * @param {Object} [options.setsResultados] - Track IDs de los sets (basico/guia/exenta/compra) para incluirlos en la declaración conjunta
463
479
  * @returns {Promise<Object>} Resultado con todos los libros
464
480
  */
465
481
  async ejecutarFase4Libros(options = {}) {
@@ -471,12 +487,49 @@ class CertRunner {
471
487
  const resultados = {};
472
488
  const errores = [];
473
489
 
490
+ // Verificar cuáles libros ya están REVISADO CONFORME en el portal (no re-enviar)
491
+ let _estadoActual = {};
492
+ try {
493
+ const _consultaPrevia = await this.siiCert.consultarEstadoSets();
494
+ if (_consultaPrevia.success) _estadoActual = _consultaPrevia.estadoSets || {};
495
+ const _yaConformes = Object.entries(_estadoActual)
496
+ .filter(([k, v]) => k.toUpperCase().includes('LIBRO') && v === 'REVISADO CONFORME')
497
+ .map(([k]) => k);
498
+ if (_yaConformes.length) {
499
+ console.log(` ℹ️ Ya en REVISADO CONFORME (se omitirán): ${_yaConformes.join(', ')}`);
500
+ }
501
+ } catch (_e) { /* ignorar error de consulta previa */ }
502
+
503
+ const _estaConforme = (nombre) => {
504
+ const nombreUpper = nombre.toUpperCase();
505
+ const e = Object.entries(_estadoActual).find(([k]) => {
506
+ const ku = k.toUpperCase();
507
+ if (nombreUpper === 'LIBRO DE COMPRAS') return ku.includes('LIBRO DE COMPRAS') && !ku.includes('EXENTOS');
508
+ return ku.includes(nombreUpper);
509
+ });
510
+ return e && e[1] === 'REVISADO CONFORME';
511
+ };
512
+
513
+ // Helper: guardar resultados parciales a disco tras cada envío exitoso
514
+ const _resultadosLibrosPath = path.join(this.debugDir, 'resultados-libros.json');
515
+ const _guardarResultadosParciales = () => {
516
+ try {
517
+ fs.writeFileSync(_resultadosLibrosPath, JSON.stringify(resultados, null, 2));
518
+ } catch (_e) { /* ignorar */ }
519
+ };
520
+
474
521
  try {
475
522
  // 1. Libro de Compras (usa datos del SII)
476
- console.log('\n📖 Enviando Libro de Compras...');
477
- resultados.libroCompras = await this.ejecutarLibroCompras(options);
478
- if (!resultados.libroCompras.success) {
479
- errores.push(`Libro Compras: ${resultados.libroCompras.error}`);
523
+ if (_estaConforme('LIBRO DE COMPRAS')) {
524
+ console.log('\n✅ Libro de Compras ya está REVISADO CONFORME — omitiendo');
525
+ } else {
526
+ console.log('\n📖 Enviando Libro de Compras...');
527
+ resultados.libroCompras = await this.ejecutarLibroCompras(options);
528
+ if (!resultados.libroCompras.success) {
529
+ errores.push(`Libro Compras: ${resultados.libroCompras.error}`);
530
+ } else {
531
+ _guardarResultadosParciales();
532
+ }
480
533
  }
481
534
  } catch (e) {
482
535
  errores.push(`Libro Compras: ${e.message}`);
@@ -484,10 +537,16 @@ class CertRunner {
484
537
 
485
538
  try {
486
539
  // 2. Libro de Ventas (usa SetBasico)
487
- console.log('\n📖 Enviando Libro de Ventas...');
488
- resultados.libroVentas = await this.ejecutarLibroVentas(options);
489
- if (!resultados.libroVentas.success) {
490
- errores.push(`Libro Ventas: ${resultados.libroVentas.error}`);
540
+ if (_estaConforme('LIBRO DE VENTAS')) {
541
+ console.log('\n✅ Libro de Ventas ya está REVISADO CONFORME — omitiendo');
542
+ } else {
543
+ console.log('\n📖 Enviando Libro de Ventas...');
544
+ resultados.libroVentas = await this.ejecutarLibroVentas(options);
545
+ if (!resultados.libroVentas.success) {
546
+ errores.push(`Libro Ventas: ${resultados.libroVentas.error}`);
547
+ } else {
548
+ _guardarResultadosParciales();
549
+ }
491
550
  }
492
551
  } catch (e) {
493
552
  errores.push(`Libro Ventas: ${e.message}`);
@@ -495,22 +554,34 @@ class CertRunner {
495
554
 
496
555
  try {
497
556
  // 3. Libro de Guías (usa SetGuia)
498
- console.log('\n📖 Enviando Libro de Guías...');
499
- resultados.libroGuias = await this.ejecutarLibroGuias(options);
500
- if (!resultados.libroGuias.success) {
501
- errores.push(`Libro Guías: ${resultados.libroGuias.error}`);
557
+ if (_estaConforme('LIBRO DE GUIAS')) {
558
+ console.log('\n✅ Libro de Guías ya está REVISADO CONFORME — omitiendo');
559
+ } else {
560
+ console.log('\n📖 Enviando Libro de Guías...');
561
+ resultados.libroGuias = await this.ejecutarLibroGuias(options);
562
+ if (!resultados.libroGuias.success) {
563
+ errores.push(`Libro Guías: ${resultados.libroGuias.error}`);
564
+ } else {
565
+ _guardarResultadosParciales();
566
+ }
502
567
  }
503
568
  } catch (e) {
504
569
  errores.push(`Libro Guías: ${e.message}`);
505
570
  }
506
571
 
507
- // 4. Libro de Compras para Exentos (solo si el SII lo entregó)
572
+ // 4. Libro de Compras para Exentos (solo si el SII lo entregó y no está ya aprobado)
508
573
  if (this._estructuras?.libroComprasExentos) {
509
574
  try {
510
- console.log('\n📖 Enviando Libro de Compras para Exentos...');
511
- resultados.libroComprasExentos = await this.ejecutarLibroComprasExentos(options);
512
- if (!resultados.libroComprasExentos.success) {
513
- errores.push(`Libro Compras Exentos: ${resultados.libroComprasExentos.error}`);
575
+ if (_estaConforme('LIBRO DE COMPRAS PARA EXENTOS')) {
576
+ console.log('\n✅ Libro Compras Exentos ya está REVISADO CONFORME — omitiendo');
577
+ } else {
578
+ console.log('\n📖 Enviando Libro de Compras para Exentos...');
579
+ resultados.libroComprasExentos = await this.ejecutarLibroComprasExentos(options);
580
+ if (!resultados.libroComprasExentos.success) {
581
+ errores.push(`Libro Compras Exentos: ${resultados.libroComprasExentos.error}`);
582
+ } else {
583
+ _guardarResultadosParciales();
584
+ }
514
585
  }
515
586
  } catch (e) {
516
587
  errores.push(`Libro Compras Exentos: ${e.message}`);
@@ -518,18 +589,65 @@ class CertRunner {
518
589
  }
519
590
 
520
591
  // Contar libros obligatorios (ventas + compras + guías)
592
+ // Un libro cuenta como OK si fue enviado exitosamente O si ya era REVISADO CONFORME (omitido)
521
593
  const librosObligatorios = ['libroVentas', 'libroCompras', 'libroGuias'];
522
- const librosEnviados = librosObligatorios.filter(k => resultados[k]?.success).length;
594
+ const librosEnviados = librosObligatorios.filter(k => {
595
+ if (resultados[k]?.success) return true;
596
+ // Mapeo clave→nombre para consultar _estaConforme
597
+ const nombreMap = { libroVentas: 'LIBRO DE VENTAS', libroCompras: 'LIBRO DE COMPRAS', libroGuias: 'LIBRO DE GUIAS' };
598
+ return _estaConforme(nombreMap[k]);
599
+ }).length;
523
600
 
524
601
  if (librosEnviados === 3) {
525
- // 4. Declarar los libros
602
+ // 4. Declarar los libros (incluyendo sets para evitar que pe_avance3 los resetee)
526
603
  console.log('\n📝 Declarando libros...');
527
604
  try {
528
- const declaracion = await this.declararLibros();
605
+ const declaracion = await this.declararLibros({ ...resultados, ...(options.setsResultados || {}) });
529
606
  resultados.declaracion = declaracion;
530
607
 
531
608
  if (declaracion.success) {
532
- console.log('\n✅ FASE 4 COMPLETADA: Todos los libros enviados y declarados');
609
+ console.log('\n✅ Libros declarados esperando revisión del SII...');
610
+
611
+ // Polling real: esperar hasta REVISADO CONFORME o S25 (máx 20 intentos x 15s = 5 min)
612
+ const _librosAEsperar = ['LIBRO DE VENTAS', 'LIBRO DE COMPRAS', 'LIBRO DE GUIAS'];
613
+ if (this._estructuras?.libroComprasExentos) _librosAEsperar.push('LIBRO DE COMPRAS PARA EXENTOS');
614
+ // Solo esperar los libros que realmente enviamos (no los que ya eran REVISADO CONFORME)
615
+ const _librosEnviados = _librosAEsperar.filter(n => !_estaConforme(n));
616
+
617
+ console.log(`\n⏳ Esperando aprobación del SII para: ${_librosEnviados.join(', ')}`);
618
+ let _aprobados = false;
619
+ for (let _i = 0; _i < 20; _i++) {
620
+ await sleep(15000);
621
+ const _poll = await this.siiCert.consultarEstadoSets();
622
+ if (!_poll.success) continue;
623
+ const _ss = _poll.estadoSets || {};
624
+ const _info = Object.entries(_ss)
625
+ .filter(([k]) => k.toUpperCase().includes('LIBRO'))
626
+ .map(([k, v]) => `${k.trim()}: ${v}`);
627
+ if (_info.length) console.log(` 🔄 Intento ${_i + 1}/20: ${_info.join(' | ')}`);
628
+
629
+ const _todosOk = _librosEnviados.every(nombre => {
630
+ const e = Object.entries(_ss).find(([k]) => k.toUpperCase().includes(nombre));
631
+ return e && (e[1] === 'REVISADO CONFORME' || e[1] === 'S25');
632
+ });
633
+ const _algunError = _librosEnviados.some(nombre => {
634
+ const e = Object.entries(_ss).find(([k]) => k.toUpperCase().includes(nombre));
635
+ return e && (e[1] === 'LNC' || e[1] === 'LRH' || e[1].includes('RECHAZADO') || e[1].includes('ERROR'));
636
+ });
637
+
638
+ if (_todosOk) {
639
+ console.log('\n🎉 ¡LIBROS APROBADOS POR EL SII!');
640
+ _aprobados = true;
641
+ break;
642
+ }
643
+ if (_algunError) {
644
+ console.log('\n❌ Hay libros rechazados. Revisar emails del SII.');
645
+ break;
646
+ }
647
+ }
648
+ if (!_aprobados) {
649
+ console.log('\n⚠️ Timeout (5 min). El SII aún no responde. Verifica con --avance más tarde.');
650
+ }
533
651
  } else {
534
652
  console.log(`\n⚠️ Libros enviados pero declaración con error: ${declaracion.error}`);
535
653
  }
@@ -563,6 +681,12 @@ class CertRunner {
563
681
  const sets = {};
564
682
 
565
683
  const mapping = {
684
+ // Sets — incluirlos para que pe_avance3 no los resetee a S01
685
+ basico: 'setBasico',
686
+ guia: 'setGuiaDespacho',
687
+ exenta: 'setFacturaExenta',
688
+ compra: 'setFacturaCompra',
689
+ // Libros
566
690
  libroVentas: 'libroVentas',
567
691
  libroCompras: 'libroCompras',
568
692
  libroGuias: 'libroGuias',
@@ -120,10 +120,32 @@ class LibroCompras {
120
120
  else if (doc.IVANoRec) {
121
121
  docAjustado.TasaImp = 0;
122
122
  docAjustado.MntIVA = 0;
123
+ // SII requiere MntNeto presente (aunque sea 0) cuando hay IVANoRec — LBR-3 si falta
124
+ if (docAjustado.MntNeto === undefined || docAjustado.MntNeto === null) {
125
+ docAjustado.MntNeto = 0;
126
+ }
123
127
  }
124
128
  // Caso normal: Asegurar TasaImp sea número entero (19) no decimal (0.19)
129
+ // Si hay TasaImp pero no hay MntNeto, el SII igual exige TasaImp + MntNeto=0 + MntIVA=0 explícitos
125
130
  else if (doc.TasaImp !== undefined) {
126
- docAjustado.TasaImp = doc.TasaImp < 1 ? Math.round(doc.TasaImp * 100) : doc.TasaImp;
131
+ const tasaNum = doc.TasaImp < 1 ? Math.round(doc.TasaImp * 100) : doc.TasaImp;
132
+ const tieneMntNeto = doc.MntNeto !== undefined && Number(doc.MntNeto) > 0;
133
+ const tieneMntExe = doc.MntExe !== undefined && Number(doc.MntExe) > 0;
134
+ if (tasaNum > 0 && !tieneMntNeto && tieneMntExe) {
135
+ // Documento con TasaImp + MntExe pero sin MntNeto (p.ej. TipoDoc=30 folio exento)
136
+ // SII exige TasaImp + MntNeto + MntIVA presentes; con MntNeto=0 da LOK+LBR-2 (aceptado)
137
+ // Borrar los campos da LRH+LBR-3 (rechazado) — NO borrar
138
+ docAjustado.TasaImp = tasaNum;
139
+ docAjustado.MntNeto = 0;
140
+ docAjustado.MntIVA = 0;
141
+ } else if (tasaNum > 0 && !tieneMntNeto) {
142
+ // TasaImp presente pero sin MntNeto ni MntExe: SII exige ceros explícitos
143
+ docAjustado.TasaImp = tasaNum;
144
+ docAjustado.MntNeto = 0;
145
+ docAjustado.MntIVA = 0;
146
+ } else {
147
+ docAjustado.TasaImp = tasaNum;
148
+ }
127
149
  }
128
150
 
129
151
  return docAjustado;
@@ -262,6 +284,7 @@ class LibroCompras {
262
284
  TotIVANoRec: {},
263
285
  TotOtrosImp: {},
264
286
  TotIVARetTotal: 0,
287
+ TotMntNoFact: 0,
265
288
  });
266
289
  }
267
290
 
@@ -271,6 +294,7 @@ class LibroCompras {
271
294
  r.TotMntNeto += Number(doc.MntNeto || 0);
272
295
  r.TotMntIVA += Number(doc.MntIVA || 0);
273
296
  r.TotMntTotal += Number(doc.MntTotal || 0);
297
+ r.TotMntNoFact += Number(doc.MntNoFact || 0);
274
298
 
275
299
  // IVA Uso Común
276
300
  if (doc.IVAUsoComun) {
@@ -319,10 +343,26 @@ class LibroCompras {
319
343
  TotDoc: r.TotDoc,
320
344
  };
321
345
 
322
- if (r.TotMntExe > 0) limpio.TotMntExe = r.TotMntExe;
323
- if (r.TotMntNeto > 0) limpio.TotMntNeto = r.TotMntNeto;
324
- if (r.TotMntIVA > 0) limpio.TotMntIVA = r.TotMntIVA;
325
- if (r.TotMntTotal > 0) limpio.TotMntTotal = r.TotMntTotal;
346
+ // TotMntExe siempre presente (incluso como 0)
347
+ // TotMntNeto y TotMntIVA solo se emiten cuando son > 0:
348
+ // - Para docs afectos: siempre > 0 se emiten
349
+ // - Para docs puramente exentos (TpoDoc=30/34/etc sin IVA): son 0
350
+ // y emitir <TotMntNeto>0</TotMntNeto> causa 'El Monto Neto No Cuadra'
351
+ // porque el SII espera que esté AUSENTE, no cero
352
+ limpio.TotMntExe = r.TotMntExe;
353
+ // El XSD LibroCV requiere el orden estricto: TotMntNeto → TotMntIVA → TotIVANoRec/TotOtrosImp
354
+ // TotMntNeto y TotMntIVA son SIEMPRE requeridos por el XSD (incluso como 0)
355
+ const tieneIVANoRec = Object.keys(r.TotIVANoRec).length > 0;
356
+ const tieneOtrosImp = Object.keys(r.TotOtrosImp).length > 0;
357
+ const necesitaIVAFields = tieneIVANoRec || tieneOtrosImp;
358
+ limpio.TotMntNeto = r.TotMntNeto;
359
+ limpio.TotMntIVA = r.TotMntIVA;
360
+ limpio.TotMntTotal = r.TotMntTotal;
361
+
362
+ // MntNoFact (TpoDoc=32 Liquidación-Factura)
363
+ if (r.TotMntNoFact > 0) {
364
+ limpio.TotMntNoFact = r.TotMntNoFact;
365
+ }
326
366
 
327
367
  // IVA Uso Común
328
368
  if (r.TotOpIVAUsoComun > 0) {
package/cert/SetParser.js CHANGED
@@ -124,12 +124,12 @@ function mapearTipoDocLibro(tipo) {
124
124
  if (t.includes('FACTURA EXENTA ELECTRONICA') || (t.includes('NO AFECTA') && t.includes('ELECTRONICA'))) return 34;
125
125
  if (t.includes('FACTURA ELECTRONICA')) return 33;
126
126
  if (t.includes('FACTURA EXENTA') || t.includes('FACTURA NO AFECTA')) return 30; // exenta papel
127
- if (t === 'FACTURA') return 30;
127
+ if (t === 'FACTURA') return 30; // factura afecta papel → TpoDoc=30 (regular libro); exentos libro usa lógica especial
128
128
  if (t.includes('NOTA DE CREDITO') && (t.includes('ELECTRONICA') || t.includes('ELECTRONICO'))) return 61;
129
129
  if (t.includes('NOTA DE CREDITO')) return 60;
130
130
  if (t.includes('NOTA DE DEBITO') && (t.includes('ELECTRONICA') || t.includes('ELECTRONICO'))) return 56;
131
131
  if (t.includes('NOTA DE DEBITO')) return 55;
132
- return 30;
132
+ return null;
133
133
  }
134
134
 
135
135
  // ═══════════════════════════════════════════════════════════════
@@ -991,14 +991,26 @@ function generarEstructuraSetExportacion(set) {
991
991
  /**
992
992
  * Genera estructura para LIBRO DE COMPRAS (IECV)
993
993
  */
994
- function generarEstructuraLibroCompras(set) {
994
+ function generarEstructuraLibroCompras(set, opts = {}) {
995
+ const esExentos = opts.esExentos === true;
995
996
  const detalle = [];
996
997
  const resumen = {};
997
998
 
998
999
  for (const doc of set.documentosLibro) {
999
- const tipoDoc = mapearTipoDocLibro(doc.tipoDocumento);
1000
+ let tipoDoc = mapearTipoDocLibro(doc.tipoDocumento);
1000
1001
  const tasaIva = 0.19;
1001
1002
 
1003
+ // En libro de compras EXENTOS, los TpoDoc correctos son:
1004
+ // FACTURA (papel afecta) → 30 (con IVANoRec)
1005
+ // FACTURA EXENTA (papel) → 32 (Fac. Venta B&S No Afectos/Exentos, papel)
1006
+ // FACTURA ELECTRONICA → 33 (con IVANoRec si tiene montoAfecto)
1007
+ // FACTURA EXENTA ELECTRONICA → 34 (puramente exenta)
1008
+ // NC/NCE/ND/NDE → 60/61/55/56
1009
+ // Total: 7 tipos distintos = 7 líneas de resumen
1010
+ const esFacturaExentaPapel = esExentos && tipoDoc === 30
1011
+ && !doc.montoAfecto && !!doc.montoExento;
1012
+ if (esFacturaExentaPapel) tipoDoc = 32;
1013
+
1002
1014
  const detalleDoc = {
1003
1015
  TpoDoc: tipoDoc,
1004
1016
  NroDoc: doc.folio,
@@ -1035,16 +1047,56 @@ function generarEstructuraLibroCompras(set) {
1035
1047
  detalleDoc.IVARetTotal = Math.round((doc.montoAfecto || 0) * tasaIva);
1036
1048
  }
1037
1049
 
1038
- // Referencia para notas de crédito
1039
- if (tipoDoc === 60 && doc.observacion) {
1040
- const matchRef = doc.observacion.match(/FACTURA(?:\s+ELECTRONICA)?\s+(\d+)/i);
1041
- if (matchRef) {
1042
- detalleDoc.TpoDocRef = doc.observacion.includes('ELECTRONICA') ? 33 : 30;
1043
- detalleDoc.FolioDocRef = parseInt(matchRef[1]);
1050
+ // Referencia para notas de crédito/débito (TpoDoc 60/61/55/56)
1051
+ const esNota = (tipoDoc === 60 || tipoDoc === 61 || tipoDoc === 55 || tipoDoc === 56);
1052
+ if (esNota && doc.observacion) {
1053
+ // El folio referenciado es siempre el último número de la observación
1054
+ const matchFolio = doc.observacion.match(/(\d+)\s*$/i);
1055
+ if (matchFolio) {
1056
+ detalleDoc.FolioDocRef = parseInt(matchFolio[1]);
1057
+ if (doc.observacion.match(/FACTURA EXENTA ELECTRONICA/i)) {
1058
+ detalleDoc.TpoDocRef = 34;
1059
+ } else if (doc.observacion.match(/FACTURA EXENTA/i)) {
1060
+ detalleDoc.TpoDocRef = esExentos ? 32 : 30;
1061
+ } else if (doc.observacion.match(/FACTURA.*ELECTRONICA/i)) {
1062
+ detalleDoc.TpoDocRef = 33;
1063
+ } else if (doc.observacion.match(/FACTURA/i)) {
1064
+ detalleDoc.TpoDocRef = 30;
1065
+ }
1066
+ // Sin TpoDocRef si no se menciona FACTURA (ej. referencia a otra NC/ND)
1044
1067
  }
1045
1068
  }
1046
1069
 
1047
- detalleDoc.MntTotal = (detalleDoc.MntNeto || 0) + (detalleDoc.MntIVA || 0) + (detalleDoc.MntExe || 0);
1070
+ // En libro de compras EXENTOS: lógica por tipo de doc
1071
+ // Docs con montoAfecto → IVANoRec (IVA no recuperable)
1072
+ // Docs puramente exentos (TpoDoc=32/34) → sin IVANoRec
1073
+ // TpoDoc=46 → usa IVARetTotal (no IVANoRec)
1074
+ const esPuramenteExento = !doc.montoAfecto && !!doc.montoExento;
1075
+ if (esExentos && tipoDoc !== 46 && !esPuramenteExento) {
1076
+ const mntIVA = detalleDoc.MntIVA || 0;
1077
+ detalleDoc.IVANoRec = { CodIVANoRec: 1, MntIVANoRec: mntIVA };
1078
+ detalleDoc.MntIVA = 0;
1079
+ if (detalleDoc.MntNeto === undefined || detalleDoc.MntNeto === null) {
1080
+ detalleDoc.MntNeto = 0;
1081
+ }
1082
+ }
1083
+ // TpoDoc=32 (Fac. Exenta papel): solo MntExe + MntTotal, sin TasaImp/MntNeto/MntIVA/IVANoRec
1084
+ if (esFacturaExentaPapel) {
1085
+ delete detalleDoc.TasaImp;
1086
+ delete detalleDoc.MntNeto;
1087
+ delete detalleDoc.MntIVA;
1088
+ delete detalleDoc.IVANoRec;
1089
+ }
1090
+ // Para docs puramente exentos que NO son TpoDoc=32: TasaImp=19 + MntNeto=0 + MntIVA=0
1091
+ if (esExentos && esPuramenteExento && !esFacturaExentaPapel) {
1092
+ detalleDoc.TasaImp = Math.round(tasaIva * 100); // TasaImp=19 requerido
1093
+ detalleDoc.MntNeto = 0;
1094
+ detalleDoc.MntIVA = 0;
1095
+ }
1096
+
1097
+ // MntTotal incluye IVANoRec (IVA pagado pero no deducible)
1098
+ const mntIvaNoRecTotal = (esExentos && detalleDoc.IVANoRec) ? (detalleDoc.IVANoRec.MntIVANoRec || 0) : 0;
1099
+ detalleDoc.MntTotal = (detalleDoc.MntNeto || 0) + (detalleDoc.MntIVA || 0) + (detalleDoc.MntExe || 0) + mntIvaNoRecTotal;
1048
1100
  if (doc.retencionTotal && tipoDoc === 46) {
1049
1101
  detalleDoc.MntTotal = detalleDoc.MntNeto || 0; // Sin IVA pagado
1050
1102
  }
@@ -1069,6 +1121,36 @@ function generarEstructuraLibroCompras(set) {
1069
1121
  resumen[tipoDoc].TotMntTotal += detalleDoc.MntTotal || 0;
1070
1122
  }
1071
1123
 
1124
+ // Post-proceso para libro de compras EXENTOS:
1125
+ // NC/NCE con sin montos declarados que anulan un doc con montos deben heredar esos montos
1126
+ if (esExentos) {
1127
+ for (const doc of detalle) {
1128
+ const esNotaAnulacion = (doc.TpoDoc === 60 || doc.TpoDoc === 61 || doc.TpoDoc === 55 || doc.TpoDoc === 56)
1129
+ && !doc.MntNeto && !doc.MntExe && doc.FolioDocRef;
1130
+ if (esNotaAnulacion) {
1131
+ const refDoc = detalle.find(d => d.NroDoc === doc.FolioDocRef);
1132
+ if (refDoc) {
1133
+ if (refDoc.MntExe) doc.MntExe = refDoc.MntExe;
1134
+ if (refDoc.MntNeto) {
1135
+ doc.MntNeto = refDoc.MntNeto;
1136
+ // Heredar IVANoRec del doc referenciado
1137
+ if (refDoc.IVANoRec) {
1138
+ doc.IVANoRec = { CodIVANoRec: 1, MntIVANoRec: refDoc.IVANoRec.MntIVANoRec || 0 };
1139
+ }
1140
+ }
1141
+ const mntIvaNoRecDoc = (doc.IVANoRec ? (doc.IVANoRec.MntIVANoRec || 0) : 0);
1142
+ doc.MntTotal = (doc.MntNeto || 0) + (doc.MntExe || 0) + mntIvaNoRecDoc;
1143
+ // Actualizar resumen para este TpoDoc
1144
+ if (resumen[doc.TpoDoc]) {
1145
+ resumen[doc.TpoDoc].TotMntExe += doc.MntExe || 0;
1146
+ resumen[doc.TpoDoc].TotMntNeto += doc.MntNeto || 0;
1147
+ resumen[doc.TpoDoc].TotMntTotal += doc.MntTotal;
1148
+ }
1149
+ }
1150
+ }
1151
+ }
1152
+ }
1153
+
1072
1154
  return {
1073
1155
  numeroAtencion: set.numeroAtencion,
1074
1156
  factorProporcionalidad: set.factorProporcionalidad || 0.6,
@@ -1136,8 +1218,9 @@ function generarEstructurasParaScripts(datosExtraidos, receptorConfig = {}) {
1136
1218
  estructuras.libroCompras = generarEstructuraLibroCompras(set);
1137
1219
  break;
1138
1220
  case 'LIBRO_COMPRAS_EXENTOS':
1139
- // Misma estructura que LIBRO_COMPRAS pero marcado como exentos
1140
- estructuras.libroComprasExentos = generarEstructuraLibroCompras(set);
1221
+ // En el libro de exentos, FACTURA afecta papel usa TpoDoc=29 (Factura de Inicio)
1222
+ // para distinguirse de FACTURA EXENTA (TpoDoc=30) en el ResumenPeriodo
1223
+ estructuras.libroComprasExentos = generarEstructuraLibroCompras(set, { esExentos: true });
1141
1224
  break;
1142
1225
  case 'LIBRO_VENTAS':
1143
1226
  // El libro de ventas se genera con los datos del set básico o exento
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devlas/dte-sii",
3
- "version": "2.5.12",
3
+ "version": "2.5.13",
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",