@devlas/dte-sii 2.12.16 → 2.12.19

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/DTE.js CHANGED
@@ -102,8 +102,12 @@ class DTE {
102
102
  };
103
103
 
104
104
  if (d.referencia) {
105
+ // Orden fijo exigido por el XSD del SII: NroLinRef, TpoDocRef, FolioRef, FchRef, CodRef, RazonRef.
105
106
  resultado.Referencia = {
106
107
  NroLinRef: d.referencia.NroLinRef || 1,
108
+ ...(d.referencia.TpoDocRef != null ? { TpoDocRef: d.referencia.TpoDocRef } : {}),
109
+ ...(d.referencia.FolioRef != null ? { FolioRef: d.referencia.FolioRef } : {}),
110
+ ...(d.referencia.FchRef ? { FchRef: d.referencia.FchRef } : {}),
107
111
  CodRef: d.referencia.CodRef || d.referencia.codigo,
108
112
  RazonRef: d.referencia.RazonRef || d.referencia.razon,
109
113
  };
@@ -1194,17 +1194,51 @@ class SiiCertificacion {
1194
1194
  }
1195
1195
 
1196
1196
  /**
1197
- * Declara cumplimiento de requisitos (etapa final)
1197
+ * Guarda un HTML de debug del flujo de declaración de cumplimiento.
1198
+ * @private
1199
+ */
1200
+ _saveDeclaracionDebug(filename, body) {
1201
+ try {
1202
+ const fs = require('fs');
1203
+ const path = require('path');
1204
+ const debugDir = process.env.SII_DEBUG_DIR || path.join(__dirname, '../../debug/cert-v2');
1205
+ if (!fs.existsSync(debugDir)) fs.mkdirSync(debugDir, { recursive: true });
1206
+ fs.writeFileSync(path.join(debugDir, filename), body || '', 'utf8');
1207
+ } catch (_e) { /* debug best-effort, no bloquear el flujo real */ }
1208
+ }
1209
+
1210
+ /**
1211
+ * Declara cumplimiento de requisitos (etapa final).
1212
+ *
1213
+ * Es un wizard de 4 pasos en el portal SII, no uno solo:
1214
+ * 1. GET pe_avance7 → formulario "ingrese RUT de la empresa"
1215
+ * 2. POST pe_avance8 (RUT/DV) → pantalla intermedia con hidden OK_MSG=S
1216
+ * 3. POST pe_avance8 (OK_MSG=S) → formulario real: 8 checkboxes de compromisos,
1217
+ * action="pe_avance9"
1218
+ * 4. POST pe_avance9 (checkboxes) → declaración efectuada (acción legal/irreversible)
1219
+ *
1220
+ * La versión anterior de este método solo hacía el paso 2 y devolvía `success: true`
1221
+ * si el texto no calzaba con el regex de bloqueo — pero el resultado del paso 2 es
1222
+ * SIEMPRE la pantalla intermedia del paso 3, nunca una confirmación real. Eso producía
1223
+ * un falso positivo: la declaración jurada real (paso 4) nunca se enviaba al SII, pero
1224
+ * quedaba registrada como completada en la base de datos del caller.
1225
+ *
1198
1226
  * @returns {Promise<Object>} Resultado de la declaración
1199
1227
  */
1200
1228
  async declararCumplimiento() {
1229
+ const RE_BLOQUEO = /no ha finalizado su certificaci|no podr[aá] efectuar la Declaraci/i;
1230
+ const extraerBloqueo = (texto) => {
1231
+ const frases = texto.split(/(?<=\.)\s+/);
1232
+ return (frases.find(f => RE_BLOQUEO.test(f)) || '').trim() || null;
1233
+ };
1234
+
1201
1235
  try {
1202
- // 1. Acceder a la página de declarar cumplimiento
1236
+ // Paso 1: acceder a la página de declarar cumplimiento
1203
1237
  let response = await this.session.ensureSession('/cvc_cgi/dte/pe_avance7');
1204
1238
  response = await this._handleRepresentacionPage(response);
1205
1239
 
1206
- // 2. Enviar formulario
1207
- const formResponse = await this.session.submitForm(
1240
+ // Paso 2: enviar RUT/DV — primer submit del wizard
1241
+ let formResponse = await this.session.submitForm(
1208
1242
  '/cvc_cgi/dte/pe_avance8',
1209
1243
  {
1210
1244
  RUT_EMP: this.rutEmpresa,
@@ -1214,30 +1248,78 @@ class SiiCertificacion {
1214
1248
  'https://maullin.sii.cl/cvc_cgi/dte/pe_avance7'
1215
1249
  );
1216
1250
 
1217
- const body = formResponse.body || '';
1251
+ let body = formResponse.body || '';
1252
+ this._saveDeclaracionDebug('pe_avance8-paso2.html', body);
1218
1253
 
1219
- // El chequeo anterior (body.includes('error')) era demasiado laxo: cualquier página con
1220
- // la palabra "error" (incluso dentro de un <script>) daba falso negativo, y no detectaba
1221
- // el caso REAL de bloqueo, que el SII devuelve con HTTP 200 y texto explícito:
1222
- // "El contribuyente ... no ha finalizado su certificación, encontrándose en el paso
1223
- // DOCUMENTOS IMPRESOS, por lo que no podrá efectuar la Declaración de Cumplimiento."
1224
- // Eso pasa mientras el SII no haya APROBADO las muestras impresas (quedan "Por revisar").
1225
- const texto = limpiarTextoPortal(body);
1254
+ // El chequeo de bloqueo (SII no aprobó aún las muestras impresas) puede aparecer en
1255
+ // cualquiera de los pasos se revisa después de cada submit.
1256
+ let texto = limpiarTextoPortal(body);
1257
+ if (RE_BLOQUEO.test(texto)) {
1258
+ return { success: false, bloqueado: true, mensaje: extraerBloqueo(texto), rawHtml: body };
1259
+ }
1226
1260
 
1227
- const RE_BLOQUEO = /no ha finalizado su certificaci|no podr[aá] efectuar la Declaraci/i;
1228
- const bloqueado = RE_BLOQUEO.test(texto);
1261
+ // Paso 3: la respuesta del paso 2 es una pantalla intermedia con hidden OK_MSG=S —
1262
+ // hay que reenviarla para llegar al formulario real de checkboxes (action=pe_avance9).
1263
+ const hiddenPaso2 = SiiSession.extractInputValues(body);
1264
+ if (hiddenPaso2.OK_MSG) {
1265
+ formResponse = await this.session.submitForm(
1266
+ '/cvc_cgi/dte/pe_avance8',
1267
+ { ...hiddenPaso2, Aceptar: 'Continuar con la Declaración' },
1268
+ 'https://maullin.sii.cl/cvc_cgi/dte/pe_avance8'
1269
+ );
1270
+ body = formResponse.body || '';
1271
+ this._saveDeclaracionDebug('pe_avance8-paso3.html', body);
1229
1272
 
1230
- // Tomar la FRASE que contiene el motivo — no el primer "El contribuyente..." que aparezca
1231
- // (la página arranca con un párrafo descriptivo que también empieza así, y antes se
1232
- // devolvía ese texto genérico en vez del motivo real del bloqueo).
1233
- const frases = texto.split(/(?<=\.)\s+/);
1234
- const mensaje = bloqueado
1235
- ? (frases.find(f => RE_BLOQUEO.test(f)) || '').trim() || null
1236
- : (frases.find(f => /declaraci[oó]n de cumplimiento|ha finalizado|certificad[ao]/i.test(f)) || '').trim() || null;
1273
+ texto = limpiarTextoPortal(body);
1274
+ if (RE_BLOQUEO.test(texto)) {
1275
+ return { success: false, bloqueado: true, mensaje: extraerBloqueo(texto), rawHtml: body };
1276
+ }
1277
+ }
1278
+
1279
+ // Paso 4: formulario final de checkboxes de compromisos (action=pe_avance9).
1280
+ // Es la declaración jurada real — marcar todos los OPC* y confirmar.
1281
+ const formAction = SiiSession.extractFormAction(body);
1282
+ if (formAction && formAction.includes('pe_avance9')) {
1283
+ const hiddenPaso3 = SiiSession.extractInputValues(body);
1284
+ const opcFields = {};
1285
+ for (const key of Object.keys(hiddenPaso3)) {
1286
+ if (/^OPC\d+$/.test(key)) opcFields[key] = 'S';
1287
+ }
1288
+
1289
+ formResponse = await this.session.submitForm(
1290
+ formAction,
1291
+ { ...hiddenPaso3, ...opcFields, CONFIRMAR: 'Confirmar Declaración' },
1292
+ 'https://maullin.sii.cl/cvc_cgi/dte/pe_avance8'
1293
+ );
1294
+ body = formResponse.body || '';
1295
+ this._saveDeclaracionDebug('pe_avance9-final.html', body);
1296
+
1297
+ texto = limpiarTextoPortal(body);
1298
+ if (RE_BLOQUEO.test(texto)) {
1299
+ return { success: false, bloqueado: true, mensaje: extraerBloqueo(texto), rawHtml: body };
1300
+ }
1301
+ }
1302
+
1303
+ // Verificación de éxito real: si la respuesta final TODAVÍA muestra el formulario de
1304
+ // checkboxes (OPC1) o el de ingreso de RUT inicial, el submit no se procesó como se
1305
+ // esperaba (ej. faltó un campo obligatorio) — no asumir éxito solo por ausencia de bloqueo.
1306
+ const siguePendiente = /NAME="OPC1"|name="RUT_EMP"[^>]*type=\s*text/i.test(body);
1307
+ if (siguePendiente) {
1308
+ return {
1309
+ success: false,
1310
+ bloqueado: false,
1311
+ mensaje: 'El SII no confirmó la declaración de cumplimiento — la respuesta final no coincide con lo esperado. Revisar el HTML de debug (pe_avance9-final.html) manualmente.',
1312
+ rawHtml: body,
1313
+ };
1314
+ }
1315
+
1316
+ const mensaje = (texto.split(/(?<=\.)\s+/).find(f =>
1317
+ /declaraci[oó]n de cumplimiento|ha finalizado|certificad[ao]|efectuada/i.test(f)
1318
+ ) || '').trim() || null;
1237
1319
 
1238
1320
  return {
1239
- success: !bloqueado,
1240
- bloqueado,
1321
+ success: true,
1322
+ bloqueado: false,
1241
1323
  mensaje,
1242
1324
  rawHtml: body,
1243
1325
  };
package/SiiPortalAuth.js CHANGED
@@ -241,9 +241,13 @@ class SiiPortalAuth {
241
241
  res.on('end', () => {
242
242
  const buf = Buffer.concat(chunks);
243
243
  const ct = res.headers['content-type'] || '';
244
- // Todos los hosts *.sii.cl sirven páginas ISO-8859-1; a veces no incluyen charset en Content-Type.
244
+ // La mayoría de hosts *.sii.cl sirven páginas ISO-8859-1 sin declarar charset pero
245
+ // algunos servicios más nuevos (ej. complementoscvui) sí declaran "charset=UTF-8"
246
+ // explícito, y hay que respetarlo o se corrompen tildes/ñ (Raíces, Común, etc.).
245
247
  const isSiiHost = url.hostname.endsWith('.sii.cl');
246
- const encoding = (isSiiHost || /iso-8859|latin-1|windows-1252/i.test(ct)) ? 'latin1' : 'utf8';
248
+ const encoding = /charset=utf-?8/i.test(ct)
249
+ ? 'utf8'
250
+ : (isSiiHost || /iso-8859|latin-1|windows-1252/i.test(ct)) ? 'latin1' : 'utf8';
247
251
  resolve({ status: res.statusCode, headers: res.headers, body: buf.toString(encoding), cookieJar });
248
252
  });
249
253
  });
@@ -611,15 +615,26 @@ if (!fs.existsSync(SESSION_CACHE_PATH)) {
611
615
  };
612
616
  }
613
617
 
614
- // ─── Consemitidos (www4.sii.cl/consemitidosinternetui) ───────────────────────
618
+ // ─── Consemitidos (www4.sii.cl/consemitidosinternetui, o www4c.sii.cl en certificación) ─────
619
+
620
+ /**
621
+ * Host del portal RCV/consemitidos según ambiente. `www4c.sii.cl` es el equivalente de
622
+ * certificación de `www4.sii.cl` — sin este mapeo, todo `_callConsemitidos` apuntaba siempre a
623
+ * producción sin importar el ambiente real del comercio (DTEs de certificación jamás aparecían).
624
+ * @private
625
+ */
626
+ _hostConsemitidos(ambiente) {
627
+ return ambiente === 'certificacion' ? 'www4c.sii.cl' : 'www4.sii.cl';
628
+ }
615
629
 
616
630
  /**
617
631
  * Navega a consemitidosinternetui para obtener el TOKEN de sesión.
618
632
  * El TOKEN es el mismo valor que CSESSIONID y va como conversationId en el body.
619
633
  * @private
620
634
  */
621
- async _obtenerTokenConsemitidos(cookieJar) {
622
- await this._request('https://www4.sii.cl/consemitidosinternetui/', { cookieJar });
635
+ async _obtenerTokenConsemitidos(cookieJar, ambiente = 'produccion') {
636
+ const host = this._hostConsemitidos(ambiente);
637
+ await this._request(`https://${host}/consemitidosinternetui/`, { cookieJar });
623
638
  const token = cookieJar['TOKEN'] || cookieJar['CSESSIONID'];
624
639
  if (!token) {
625
640
  throw new Error('SiiPortalAuth: no se pudo obtener TOKEN de sesión de consemitidosinternetui');
@@ -631,7 +646,8 @@ if (!fs.existsSync(SESSION_CACHE_PATH)) {
631
646
  * Llama a un endpoint JSON de la API consemitidosinternetui.
632
647
  * @private
633
648
  */
634
- async _callConsemitidos(method, data, token, cookieJar) {
649
+ async _callConsemitidos(method, data, token, cookieJar, ambiente = 'produccion') {
650
+ const host = this._hostConsemitidos(ambiente);
635
651
  const body = JSON.stringify({
636
652
  metaData: {
637
653
  namespace: `cl.sii.sdi.lob.diii.consemitidos.data.api.interfaces.FacadeService/${method}`,
@@ -642,7 +658,7 @@ if (!fs.existsSync(SESSION_CACHE_PATH)) {
642
658
  data,
643
659
  });
644
660
  const res = await this._request(
645
- `https://www4.sii.cl/consemitidosinternetui/services/data/facadeService/${method}`,
661
+ `https://${host}/consemitidosinternetui/services/data/facadeService/${method}`,
646
662
  {
647
663
  method: 'POST',
648
664
  body,
@@ -650,8 +666,8 @@ if (!fs.existsSync(SESSION_CACHE_PATH)) {
650
666
  headers: {
651
667
  'Content-Type': 'application/json',
652
668
  'Accept': 'application/json, text/plain, */*',
653
- 'Origin': 'https://www4.sii.cl',
654
- 'Referer': 'https://www4.sii.cl/consemitidosinternetui/',
669
+ 'Origin': `https://${host}`,
670
+ 'Referer': `https://${host}/consemitidosinternetui/`,
655
671
  },
656
672
  }
657
673
  );
@@ -663,18 +679,19 @@ if (!fs.existsSync(SESSION_CACHE_PATH)) {
663
679
  }
664
680
 
665
681
  /**
666
- * Obtiene el detalle de DTEs emitidos o recibidos desde www4.sii.cl.
682
+ * Obtiene el detalle de DTEs emitidos o recibidos desde el portal RCV del SII.
667
683
  *
668
684
  * @param {string} rut - RUT sin DV (ej: "12345678")
669
685
  * @param {string} dv - DV (ej: "K")
670
686
  * @param {string} periodo - Período YYYY-MM (ej: "2026-05")
671
687
  * @param {number} operacion - 1 = compras / recibidos, 2 = ventas / emitidos
672
688
  * @param {Object} [cookieJar] - Sesión ya autenticada (opcional)
689
+ * @param {string} [ambiente] - 'produccion' (www4.sii.cl, default) o 'certificacion' (www4c.sii.cl)
673
690
  * @returns {Promise<{ resumen: Array, detalles: Array }>}
674
691
  */
675
- async obtenerDetalleDtes(rut, dv, periodo, operacion = 2, cookieJar = null) {
692
+ async obtenerDetalleDtes(rut, dv, periodo, operacion = 2, cookieJar = null, ambiente = 'produccion') {
676
693
  const jar = cookieJar || await this.autenticar();
677
- const token = await this._obtenerTokenConsemitidos(jar);
694
+ const token = await this._obtenerTokenConsemitidos(jar, ambiente);
678
695
 
679
696
  // Mapeo de convención interna → convención SII:
680
697
  // interno: 1 = compras/recibidos, 2 = ventas/emitidos
@@ -688,7 +705,7 @@ if (!fs.existsSync(SESSION_CACHE_PATH)) {
688
705
  rutContribuyente: rut,
689
706
  dvContribuyente: dv,
690
707
  operacion: siiOperacion,
691
- }, token, jar);
708
+ }, token, jar, ambiente);
692
709
 
693
710
  const resumen = resumenResp.data?.resumenDte ?? [];
694
711
  if (resumen.length === 0) return { resumen: [], detalles: [] };
@@ -708,7 +725,7 @@ if (!fs.existsSync(SESSION_CACHE_PATH)) {
708
725
  operacion: siiOperacion,
709
726
  derrCodigo: String(t.tipoDoc),
710
727
  refNCD: '0',
711
- }, token, jar);
728
+ }, token, jar, ambiente);
712
729
  const items = resp.dataResp?.detalles ?? [];
713
730
  // Completar tipoDoc y tipoDocDesc desde el resumen si no vienen en el detalle
714
731
  return items.map((d) => ({
@@ -722,6 +739,237 @@ if (!fs.existsSync(SESSION_CACHE_PATH)) {
722
739
  return { resumen, detalles: detallesArr.flat() };
723
740
  }
724
741
 
742
+ // ─── Complementos CV (www4.sii.cl/complementoscvui) — reclasificación de compras ──────────
743
+ // Reverse-engineered desde un HAR capturado navegando el portal SII: al abrir una compra
744
+ // recibida en la pestaña "Registro > Compra" y usar "Cambiar tipo de compra", el browser
745
+ // llama a estos 3 endpoints. El conversationId que exige el servidor es el mismo TOKEN de
746
+ // sesión que usa consemitidos/consdcvinternetui (cookie TOKEN/CSESSIONID) — se obtiene
747
+ // visitando consdcvinternetui/ una vez.
748
+
749
+ /**
750
+ * Token de sesión para complementoscvui — mismo mecanismo que _obtenerTokenConsemitidos
751
+ * pero navegando consdcvinternetui/ (la SPA "Registro de Compras y Ventas"), que es donde
752
+ * se originó el conversationId observado en el HAR.
753
+ * @private
754
+ */
755
+ async _obtenerTokenPortalCV(cookieJar, ambiente = 'produccion') {
756
+ const host = this._hostConsemitidos(ambiente);
757
+ await this._request(`https://${host}/consdcvinternetui/`, { cookieJar });
758
+ const token = cookieJar['TOKEN'] || cookieJar['CSESSIONID'];
759
+ if (!token) {
760
+ throw new Error('SiiPortalAuth: no se pudo obtener TOKEN de sesión de consdcvinternetui');
761
+ }
762
+ return token;
763
+ }
764
+
765
+ /**
766
+ * Llama a un endpoint JSON de la API complementoscvui (FacadeServiceCompCompra).
767
+ * @private
768
+ */
769
+ async _callComplementoscvui(method, data, token, cookieJar, ambiente = 'produccion') {
770
+ const host = this._hostConsemitidos(ambiente);
771
+ const body = JSON.stringify({
772
+ metaData: {
773
+ namespace: `cl.sii.sdi.lob.diii.dcv.data.api.interfaces.compcompra.FacadeServiceCompCompra/${method}`,
774
+ conversationId: token,
775
+ transactionId: '1',
776
+ page: { pageSize: 1, pageIndex: 1 },
777
+ },
778
+ data,
779
+ });
780
+ const res = await this._request(
781
+ `https://${host}/complementoscvui/services/data/facadeServiceCompCompraService/${method}`,
782
+ {
783
+ method: 'POST',
784
+ body,
785
+ cookieJar,
786
+ headers: {
787
+ 'Content-Type': 'application/json',
788
+ 'Accept': 'application/json, text/plain, */*',
789
+ 'Origin': `https://${host}`,
790
+ 'Referer': `https://${host}/complementoscvui/`,
791
+ },
792
+ }
793
+ );
794
+ try {
795
+ return JSON.parse(res.body);
796
+ } catch {
797
+ throw new Error(`SiiPortalAuth: respuesta no-JSON de ${method}: ${res.body.slice(0, 300)}`);
798
+ }
799
+ }
800
+
801
+ /**
802
+ * Lista las categorías de "tipo de compra" que el SII permite declarar sobre un documento
803
+ * recibido (Del Giro, Supermercados, Bienes Raíces, Activo Fijo, IVA Uso Común,
804
+ * IVA no Recuperable, No Corresp. Incluir). `intercambiable: true` = el usuario puede
805
+ * cambiarla; las demás son asignadas automáticamente por el SII según el tipo de documento.
806
+ *
807
+ * @param {Object} [cookieJar] - Sesión ya autenticada (opcional)
808
+ * @param {string} [ambiente] - 'produccion' (default) o 'certificacion'
809
+ * @returns {Promise<Array<{ id: string, descripcion: string, intercambiable: boolean }>>}
810
+ */
811
+ async obtenerTiposTransaccionCompra(cookieJar = null, ambiente = 'produccion') {
812
+ const jar = cookieJar || await this.autenticar();
813
+ const token = await this._obtenerTokenPortalCV(jar, ambiente);
814
+ const resp = await this._callComplementoscvui('obtieneTiposTransaccionCompra', null, token, jar, ambiente);
815
+ return resp.data ?? [];
816
+ }
817
+
818
+ /**
819
+ * Detalle completo de un documento de compra tal como lo tiene registrado el SII
820
+ * (incluye `det_tipo_transaccion`, la clasificación de tipo de compra vigente).
821
+ *
822
+ * @param {Object} params
823
+ * @param {string} params.rut - RUT del contribuyente (receptor), sin DV
824
+ * @param {string} params.dv
825
+ * @param {number|string} params.tipoDocumento - Código SII (33=Factura, 46=Factura de Compra, etc.)
826
+ * @param {number|string} params.numeroDocumento - Folio
827
+ * @param {string} params.periodoTributario - "AAAAMM" (ej: "202607")
828
+ * @param {string} params.rutContraparte - RUT del emisor, sin DV
829
+ * @param {string} params.dvContraparte
830
+ * @param {Object} [cookieJar]
831
+ * @param {string} [ambiente]
832
+ */
833
+ async obtenerDetalleDocumentoCompra(params, cookieJar = null, ambiente = 'produccion') {
834
+ const jar = cookieJar || await this.autenticar();
835
+ const token = await this._obtenerTokenPortalCV(jar, ambiente);
836
+ const resp = await this._callComplementoscvui('obtieneDetalleDocumento', {
837
+ rut: params.rut, dv: params.dv, operacion: 'COMPRA',
838
+ tipoDocumento: String(params.tipoDocumento), numeroDocumento: String(params.numeroDocumento),
839
+ periodoTributario: params.periodoTributario,
840
+ rutContraparte: params.rutContraparte, dvContraparte: params.dvContraparte,
841
+ }, token, jar, ambiente);
842
+ return resp.data ?? null;
843
+ }
844
+
845
+ /**
846
+ * Cambia el "tipo de compra" declarado ante el SII para un documento recibido — la misma
847
+ * acción que hace el portal web al usar "Cambiar tipo de compra" en la pestaña Registro.
848
+ * Lanza si el SII no confirma con `{ data: "OK" }`.
849
+ *
850
+ * @param {Object} params - Mismos campos que obtenerDetalleDocumentoCompra más:
851
+ * @param {number|string} params.tipoCompra - id de obtenerTiposTransaccionCompra (ej: "4" = Activo Fijo)
852
+ * @param {Object} [cookieJar]
853
+ * @param {string} [ambiente]
854
+ * @returns {Promise<true>}
855
+ */
856
+ async cambiarTipoCompra(params, cookieJar = null, ambiente = 'produccion') {
857
+ const jar = cookieJar || await this.autenticar();
858
+ const token = await this._obtenerTokenPortalCV(jar, ambiente);
859
+ const resp = await this._callComplementoscvui('cambiaTipoCompra', {
860
+ rut: params.rut, dv: params.dv, operacion: 'COMPRA',
861
+ tipoDocumento: String(params.tipoDocumento), numeroDocumento: String(params.numeroDocumento),
862
+ periodoTributario: params.periodoTributario, tipoCompra: String(params.tipoCompra),
863
+ rutContraparte: params.rutContraparte, dvContraparte: params.dvContraparte,
864
+ }, token, jar, ambiente);
865
+ if (resp.data !== 'OK') {
866
+ throw new Error(`SiiPortalAuth: cambiaTipoCompra no confirmó OK — respuesta: ${JSON.stringify(resp)}`);
867
+ }
868
+ return true;
869
+ }
870
+
871
+ /**
872
+ * Mueve parte del IVA de un documento de "Recuperable" a "Uso Común" (o el inverso).
873
+ * `ivaComun`/`ivaRec` son los montos finales de cada bucket tras el cambio (no un delta) —
874
+ * así los captura el portal: al mover todo a Uso Común, ivaRec queda en "" y ivaComun con
875
+ * el monto total; al revertir, ivaComun queda en "0" e ivaRec recupera el monto total.
876
+ *
877
+ * @param {Object} params - Mismos campos identificadores que cambiarTipoCompra más:
878
+ * @param {string|number} params.ivaComun - Monto final en el bucket "Uso Común"
879
+ * @param {string|number} params.ivaRec - Monto final en el bucket "Recuperable"
880
+ * @param {Object} [cookieJar]
881
+ * @param {string} [ambiente]
882
+ * @returns {Promise<true>}
883
+ */
884
+ async cambiarIvaRecuperableAUsoComun(params, cookieJar = null, ambiente = 'produccion') {
885
+ return this._cambiarIva('cambiaIvaRec2Comun', params, cookieJar, ambiente);
886
+ }
887
+
888
+ /** Inverso de cambiarIvaRecuperableAUsoComun — mueve el IVA de vuelta a "Recuperable". */
889
+ async cambiarIvaUsoComunARecuperable(params, cookieJar = null, ambiente = 'produccion') {
890
+ return this._cambiarIva('cambiaIvaComun2Recuperable', params, cookieJar, ambiente);
891
+ }
892
+
893
+ /** @private */
894
+ async _cambiarIva(metodo, params, cookieJar, ambiente) {
895
+ const jar = cookieJar || await this.autenticar();
896
+ const token = await this._obtenerTokenPortalCV(jar, ambiente);
897
+ const resp = await this._callComplementoscvui(metodo, {
898
+ rut: params.rut, dv: params.dv, operacion: 'COMPRA',
899
+ tipoDocumento: String(params.tipoDocumento), numeroDocumento: String(params.numeroDocumento),
900
+ periodoTributario: params.periodoTributario,
901
+ ivaComun: String(params.ivaComun ?? ''), ivaRec: String(params.ivaRec ?? ''),
902
+ rutContraparte: params.rutContraparte, dvContraparte: params.dvContraparte,
903
+ }, token, jar, ambiente);
904
+ if (resp.data !== 'OK') {
905
+ throw new Error(`SiiPortalAuth: ${metodo} no confirmó OK — respuesta: ${JSON.stringify(resp)}`);
906
+ }
907
+ return true;
908
+ }
909
+
910
+ // ─── consdcvinternetui — resumen "oficial" del RCV, filtrado por estado contable ───────────
911
+ // Distinto de consemitidos: expone el estado real (Registro/No Incluir/Pendiente/Reclamado)
912
+ // mediante `estadoContab`, en vez de listar todo sin distinguir si el SII lo consideró
913
+ // parte del registro tributario oficial. getDetalleCompra/getDetalleVenta de este mismo
914
+ // servicio exigen reCAPTCHA v3 (tokenRecaptcha) — no se automatizan aquí. getResumen no lo
915
+ // exige y sí se puede llamar.
916
+
917
+ /**
918
+ * Resumen "oficial" del RCV (consdcvinternetui) para un período, filtrado por estado
919
+ * contable — a diferencia de `obtenerDetalleDtes` (consemitidos), permite saber con certeza
920
+ * si un documento quedó en el Registro tributario oficial o fue excluido por el SII.
921
+ *
922
+ * @param {string} rutEmisor - RUT del contribuyente autenticado (sin DV)
923
+ * @param {string} dvEmisor
924
+ * @param {string} periodo - "AAAA-MM" (se convierte a "AAAAMM")
925
+ * @param {'COMPRA'|'VENTA'} operacion
926
+ * @param {'REGISTRO'|'PENDIENTE'|'NO_INCLUIR'|'RECLAMADO'} [estadoContab] - default 'REGISTRO'
927
+ * @param {Object} [cookieJar]
928
+ * @param {string} [ambiente]
929
+ * @returns {Promise<{ resumen: Array, cabecera: Object|null }>}
930
+ */
931
+ async obtenerResumenRegistro(rutEmisor, dvEmisor, periodo, operacion, estadoContab = 'REGISTRO', cookieJar = null, ambiente = 'produccion') {
932
+ const jar = cookieJar || await this.autenticar();
933
+ const token = await this._obtenerTokenPortalCV(jar, ambiente);
934
+ const host = this._hostConsemitidos(ambiente);
935
+ const body = JSON.stringify({
936
+ metaData: {
937
+ namespace: 'cl.sii.sdi.lob.diii.consdcv.data.api.interfaces.FacadeService/getResumen',
938
+ conversationId: token,
939
+ transactionId: crypto.randomUUID(),
940
+ page: null,
941
+ },
942
+ data: {
943
+ rutEmisor, dvEmisor,
944
+ ptributario: periodo.replace('-', ''),
945
+ estadoContab,
946
+ operacion,
947
+ busquedaInicial: true,
948
+ },
949
+ });
950
+ const res = await this._request(
951
+ `https://${host}/consdcvinternetui/services/data/facadeService/getResumen`,
952
+ {
953
+ method: 'POST',
954
+ body,
955
+ cookieJar: jar,
956
+ headers: {
957
+ 'Content-Type': 'application/json',
958
+ 'Accept': 'application/json, text/plain, */*',
959
+ 'Origin': `https://${host}`,
960
+ 'Referer': `https://${host}/consdcvinternetui/`,
961
+ },
962
+ }
963
+ );
964
+ let parsed;
965
+ try {
966
+ parsed = JSON.parse(res.body);
967
+ } catch {
968
+ throw new Error(`SiiPortalAuth: respuesta no-JSON de getResumen (consdcvinternetui): ${res.body.slice(0, 300)}`);
969
+ }
970
+ return { resumen: parsed.data ?? [], cabecera: parsed.dataCabecera ?? null };
971
+ }
972
+
725
973
  /**
726
974
  * Retorna las cookies de sesión activas para un PFX dado, en formato string para SiiSession.
727
975
  * Busca primero en el registry en memoria, luego en el caché a disco.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devlas/dte-sii",
3
- "version": "2.12.16",
3
+ "version": "2.12.19",
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",
package/utils/pfx.js CHANGED
@@ -179,7 +179,7 @@ function extractSubjectFields(cert) {
179
179
  }
180
180
 
181
181
  /**
182
- * Extraer RUT del certificado (desde serialNumber o CN)
182
+ * Extraer RUT del certificado (desde serialNumber, OU o CN)
183
183
  * @param {forge.pki.Certificate} cert - Certificado
184
184
  * @returns {string|null} RUT o null
185
185
  */
@@ -194,6 +194,16 @@ function extractRutFromCertificate(cert) {
194
194
  }
195
195
  }
196
196
 
197
+ // Intentar desde OU — algunas CAs (ej. Signapis) codifican el RUN de la
198
+ // persona natural acá en vez de serialNumber, y el CN es solo el nombre
199
+ // sin dígitos (ninguna de las dos otras estrategias lo detecta).
200
+ if (subject.OU) {
201
+ const clean = subject.OU.replace(/\./g, '').toUpperCase();
202
+ if (/^\d{7,8}-[\dK]$/.test(clean)) {
203
+ return clean;
204
+ }
205
+ }
206
+
197
207
  // Intentar desde CN
198
208
  if (subject.CN) {
199
209
  const match = subject.CN.match(/(\d{7,8}-[\dK])/i);