@devlas/dte-sii 2.8.3 → 2.9.3

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 CHANGED
@@ -17,6 +17,15 @@ const path = require('path');
17
17
  const SiiSession = require('./SiiSession');
18
18
  const { splitRut } = require('./utils/rut');
19
19
 
20
+ /**
21
+ * Registro global de sesiones SII (singleton por ambiente+rut).
22
+ * Permite reutilizar la misma sesión HTTP entre múltiples llamadas a CafSolicitor
23
+ * sin abrir una nueva sesión en el portal del SII cada vez.
24
+ * Clave: `${ambiente}::${rutEmisor}`
25
+ * @type {Map<string, SiiSession>}
26
+ */
27
+ const _sessionRegistry = new Map();
28
+
20
29
  /**
21
30
  * Clase para solicitar CAFs al SII
22
31
  */
@@ -28,7 +37,6 @@ class CafSolicitor {
28
37
  * @param {string} options.pfxPath - Ruta absoluta al certificado PFX
29
38
  * @param {string} options.pfxPassword - Contraseña del certificado
30
39
  * @param {string} [options.baseDir] - Directorio base para guardar archivos
31
- * @param {string} [options.sessionPath] - Ruta al archivo de sesión compartida
32
40
  * @param {string} [options.runStamp] - Timestamp de la ejecución
33
41
  */
34
42
  constructor(options = {}) {
@@ -48,22 +56,22 @@ class CafSolicitor {
48
56
  this.ambiente = options.ambiente.toLowerCase();
49
57
  this.rutEmisor = options.rutEmisor;
50
58
  this.baseDir = options.baseDir || path.resolve(__dirname, '..', '..');
51
- this.sessionPath = options.sessionPath || null;
52
59
  this.runStamp = options.runStamp || new Date().toISOString().replace(/[:.]/g, '-');
53
60
 
54
- // Crear SiiSession para manejar HTTP y cookies
55
- this.session = new SiiSession({
56
- ambiente: this.ambiente,
57
- pfxPath: options.pfxPath,
58
- pfxPassword: options.pfxPassword,
59
- });
60
-
61
- // Cargar sesión compartida si existe
62
- if (this.sessionPath) {
63
- const loaded = this.session.loadSession(this.sessionPath);
64
- if (loaded) {
65
- console.log('[CafSolicitor] ✓ Usando sesión compartida');
66
- }
61
+ // Reutilizar sesión SII global si ya existe para este ambiente+rut.
62
+ // Esto evita abrir múltiples sesiones en el portal del SII.
63
+ const sessionKey = `${this.ambiente}::${this.rutEmisor}`;
64
+ if (_sessionRegistry.has(sessionKey)) {
65
+ this.session = _sessionRegistry.get(sessionKey);
66
+ console.log('[CafSolicitor] ♻️ Reutilizando sesión SII en memoria');
67
+ } else {
68
+ this.session = new SiiSession({
69
+ ambiente: this.ambiente,
70
+ pfxPath: options.pfxPath,
71
+ pfxPassword: options.pfxPassword,
72
+ });
73
+ _sessionRegistry.set(sessionKey, this.session);
74
+ console.log('[CafSolicitor] 🔑 Nueva sesión SII registrada para', sessionKey);
67
75
  }
68
76
  }
69
77
 
@@ -193,13 +201,75 @@ class CafSolicitor {
193
201
  // Verificar si obtuvimos el CAF
194
202
  if (response.body && response.body.includes('<AUTORIZACION')) {
195
203
  const cafPath = this._saveCafOrganized(response.body, tipoDte);
196
- return { success: true, cafPath, xml: response.body };
204
+ return { success: true, cafPath, xml: response.body, maxAutor: this._lastMaxAutor ?? cantidad };
197
205
  }
198
206
 
199
207
  if (response.body && response.body.includes('Autenticaci')) {
200
208
  return { success: false, error: 'El SII devolvió página de autenticación' };
201
209
  }
202
210
 
211
+ // Detectar error de MAX_AUTOR: "La cantidad de documentos a timbrar debe ser menor o igual al máximo autorizado"
212
+ // Puede ocurrir cuando el MAX_AUTOR efectivo del SII es menor al mostrado en el formulario
213
+ // (ej: ya hay un timbraje del mismo día que consume parte del cupo diario).
214
+ // Solución: reintentar con cantidad=1 para garantizar obtener al menos 1 folio.
215
+ if (response.body && response.body.includes('menor o igual al m')) {
216
+ if (cantidad > 1) {
217
+ console.warn(`[CafSolicitor] MAX_AUTOR excedido para ${cantidad} folios — reintentando con 1 folio...`);
218
+ // No limpiar cookieJar: el error es del formulario, no de la sesión.
219
+ // Reutilizar la sesión activa evita abrir una nueva y acumular sesiones en el SII.
220
+ this.runStamp = new Date().toISOString().replace(/[:.]/g, '-');
221
+ return this.solicitar({ tipoDte, cantidad: 1 });
222
+ }
223
+ return { success: false, error: 'Cantidad de folios excede el máximo que SII autoriza por solicitud para este tipo de documento (MAX_AUTOR). Verifica el estado de timbraje de tu empresa en el portal SII.' };
224
+ }
225
+
226
+ // Detectar rango ya autorizado — el CAF existe en SII pero no fue capturado
227
+ // (ocurre cuando una solicitud previa tuvo éxito en SII pero falló en la red al devolver el XML,
228
+ // o cuando of_genera_archivo devolvió DTE-OFGA y el SII registró el rango igualmente)
229
+ const finalBody = response.body || '';
230
+ if (finalBody.includes('ya fue autorizado el rango desde') || finalBody.includes('ya fue autorizado')) {
231
+ const m = finalBody.match(/ya fue autorizado el rango desde\s+(\d+)\s+hasta\s+(\d+)/i);
232
+ const desde = m ? parseInt(m[1]) : null;
233
+ const hasta = m ? parseInt(m[2]) : null;
234
+
235
+ // Intentar recuperar el XML directamente enviando los datos del rango a of_genera_archivo.
236
+ // El SII tiene el CAF autorizado en su sistema; si enviamos los campos correctos
237
+ // puede devolver el XML aunque la sesión de timbraje original ya terminó.
238
+ if (desde != null && hasta != null) {
239
+ console.warn(`[CafSolicitor] Rango ${desde}-${hasta} ya autorizado — intentando recuperar XML desde of_genera_archivo...`);
240
+ const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
241
+ const recoveryFields = {
242
+ RUT_EMP: rut,
243
+ DV_EMP: dv,
244
+ COD_DOCTO: String(tipoDte),
245
+ FOLIO_INI: String(desde),
246
+ FOLIO_FIN: String(hasta),
247
+ FECHA: today,
248
+ ACEPTAR: 'AQUI',
249
+ };
250
+ try {
251
+ const recoveryResponse = await this.session.submitForm('/cvc_cgi/dte/of_genera_archivo', recoveryFields);
252
+ const recoveryBody = recoveryResponse.body || '';
253
+ this._saveDebug(debugDir, 'recovery.xml', recoveryBody);
254
+ if (recoveryBody.includes('<AUTORIZACION')) {
255
+ console.log(`[CafSolicitor] ✅ CAF recuperado exitosamente para rango ${desde}-${hasta}`);
256
+ const cafPath = this._saveCafOrganized(recoveryBody, tipoDte);
257
+ return { success: true, cafPath, xml: recoveryBody, maxAutor: hasta - desde + 1 };
258
+ }
259
+ console.warn(`[CafSolicitor] Recuperación de rango ${desde}-${hasta} no devolvió XML (${recoveryBody.substring(0, 80).replace(/\s+/g, ' ')})`);
260
+ } catch (recoveryErr) {
261
+ console.warn(`[CafSolicitor] Error al intentar recuperar rango: ${recoveryErr.message}`);
262
+ }
263
+ }
264
+
265
+ const rangoStr = desde != null && hasta != null ? ` ${desde}-${hasta}` : '';
266
+ return {
267
+ success: false,
268
+ rangoYaAutorizado: desde != null && hasta != null ? { folioDesde: desde, folioHasta: hasta } : null,
269
+ error: `SII: Ya existe CAF autorizado para el rango${rangoStr}. El XML fue aprobado por SII en una solicitud previa pero no se pudo recuperar automáticamente. Descárgalo manualmente desde el portal SII (Factura Electrónica → Solicitud de Timbraje).`,
270
+ };
271
+ }
272
+
203
273
  return { success: false, error: 'No se obtuvo CAF en la respuesta' };
204
274
 
205
275
  } catch (err) {
@@ -233,11 +303,16 @@ class CafSolicitor {
233
303
  // Selección de tipo de documento
234
304
  if (currentHtml.includes('COD_DOCTO')) {
235
305
  const selectInputs = SiiSession.extractInputValues(currentHtml);
306
+ // NO enviar CANT_DOCTOS aquí — el browser tampoco lo envía en este paso.
307
+ // Si se envía un número, el SII no incluye MAX_AUTOR ni CONTROL="S" en la
308
+ // respuesta, lo que causa rechazo silencioso en of_genera_folio más adelante.
309
+ // El SII fija CANT_DOCTOS automáticamente según MAX_AUTOR al responder.
236
310
  const selectFields = {
237
311
  ...selectInputs,
238
312
  RUT_EMP: rut,
239
313
  DV_EMP: dv,
240
314
  COD_DOCTO: tipoDte,
315
+ CANT_DOCTOS: '',
241
316
  };
242
317
 
243
318
  response = await this.session.submitForm('/cvc_cgi/dte/of_solicita_folios_dcto', selectFields);
@@ -261,13 +336,19 @@ class CafSolicitor {
261
336
 
262
337
  const formAction3 = SiiSession.extractFormAction(currentHtml) || '/cvc_cgi/dte/of_confirma_folio';
263
338
  const inputs3 = SiiSession.extractInputValues(currentHtml);
264
-
339
+
340
+ // CANT_DOCTOS debe enviarse con un valor <= MAX_AUTOR.
341
+ // Si se omite o excede MAX_AUTOR, el SII rechaza devolviendo la página de inicio (rechazo silencioso).
342
+ const maxAutor = parseInt(inputs3.MAX_AUTOR || String(cantidad), 10);
343
+ const cantReal = Math.min(cantidad, maxAutor);
344
+ this._lastMaxAutor = maxAutor; // guardado para retornarlo desde solicitar()
345
+
265
346
  const step3Fields = {
266
347
  ...inputs3,
267
348
  RUT_EMP: rut,
268
349
  DV_EMP: dv,
269
350
  COD_DOCTO: tipoDte,
270
- CANT_DOCTOS: cantidad,
351
+ CANT_DOCTOS: String(cantReal),
271
352
  ACEPTAR: 'Solicitar Numeración',
272
353
  };
273
354
 
@@ -321,9 +402,17 @@ class CafSolicitor {
321
402
 
322
403
  const formAction = SiiSession.extractFormAction(currentHtml) || '/cvc_cgi/dte/of_genera_folio';
323
404
  const inputs = SiiSession.extractInputValues(currentHtml);
324
-
405
+
406
+ // Respetar el máximo autorizado por el SII (puede ser menor a lo solicitado)
407
+ const maxAutor = parseInt(inputs.MAX_AUTOR || '999', 10);
408
+ const folioIni = parseInt(inputs.FOLIO_INI || '1', 10);
409
+ const cantOriginal = parseInt(inputs.CANT_DOCTOS || '1', 10);
410
+ const cantReal = Math.min(cantOriginal, maxAutor);
411
+
325
412
  const fields = {
326
413
  ...inputs,
414
+ CANT_DOCTOS: String(cantReal),
415
+ FOLIO_FIN: String(folioIni + cantReal - 1),
327
416
  ACEPTAR: 'Obtener Folios',
328
417
  };
329
418
 
@@ -331,9 +420,20 @@ class CafSolicitor {
331
420
  currentHtml = response.body || '';
332
421
  this._saveDebug(debugDir, 'genera.html', currentHtml);
333
422
 
423
+ // El SII rechaza el request si ese rango ya fue autorizado en una solicitud previa
424
+ // (ocurre en reintentos cuando la primera solicitud llegó a SII pero falló la red al devolver el XML)
425
+ if (currentHtml.includes('ya fue autorizado')) {
426
+ const m = currentHtml.match(/ya fue autorizado el rango desde\s+(\d+)\s+hasta\s+(\d+)/i);
427
+ console.warn(`[CafSolicitor] of_genera_folio: rango ya autorizado${m ? ' ' + m[1] + '-' + m[2] : ''} — el XML existe en SII pero no se obtuvo`);
428
+ return response;
429
+ }
430
+
334
431
  // Paso final: of_genera_archivo
335
432
  if (!currentHtml.includes('<AUTORIZACION') && currentHtml.includes('of_genera_archivo')) {
336
- response = await this._processGeneraArchivo(response, debugDir);
433
+ // Guardar los inputs del genera.html para poder reintentar of_genera_archivo si falla con DTE-OFGA
434
+ const generaInputs = SiiSession.extractInputValues(currentHtml);
435
+ const generaFormAction = SiiSession.extractFormAction(currentHtml) || '/cvc_cgi/dte/of_genera_archivo';
436
+ response = await this._processGeneraArchivo(response, debugDir, generaInputs, generaFormAction);
337
437
  }
338
438
 
339
439
  return response;
@@ -342,8 +442,12 @@ class CafSolicitor {
342
442
  /**
343
443
  * Procesa generación de archivo CAF
344
444
  * @private
445
+ * @param {Object} response - Respuesta previa
446
+ * @param {string} debugDir - Directorio de debug
447
+ * @param {Object} [retryInputs] - Inputs de genera.html para retry si falla con DTE-OFGA
448
+ * @param {string} [retryFormAction] - URL de of_genera_archivo para retry
345
449
  */
346
- async _processGeneraArchivo(response, debugDir) {
450
+ async _processGeneraArchivo(response, debugDir, retryInputs, retryFormAction) {
347
451
  let currentHtml = response.body || '';
348
452
 
349
453
  const formAction = SiiSession.extractFormAction(currentHtml) || '/cvc_cgi/dte/of_genera_archivo';
@@ -358,6 +462,32 @@ class CafSolicitor {
358
462
  currentHtml = response.body || '';
359
463
  this._saveDebug(debugDir, 'archivo.xml', currentHtml);
360
464
 
465
+ // El SII a veces devuelve error interno DTE-OFGA (servidor sobrecargado o fallo transitorio).
466
+ // En ese caso el rango ya fue registrado en SII pero el XML no se generó.
467
+ // Reintentar el mismo POST hasta 3 veces con delay de 3s antes de rendirse.
468
+ if (!currentHtml.includes('<AUTORIZACION') && currentHtml.includes('DTE-OFGA')) {
469
+ for (let attempt = 1; attempt <= 3; attempt++) {
470
+ const delaySecs = attempt * 3;
471
+ console.warn(`[CafSolicitor] Error DTE-OFGA en of_genera_archivo — reintentando en ${delaySecs}s (intento ${attempt}/3)...`);
472
+ await new Promise(r => setTimeout(r, delaySecs * 1000));
473
+ // Reintentar con los inputs originales del genera.html si están disponibles,
474
+ // ya que los del archivo.xml (página de error) no tienen los campos necesarios.
475
+ const retryFields = retryInputs ? { ...retryInputs, ACEPTAR: 'AQUI' } : fields;
476
+ const retryAction = retryFormAction || formAction;
477
+ response = await this.session.submitForm(retryAction, retryFields);
478
+ currentHtml = response.body || '';
479
+ this._saveDebug(debugDir, `archivo-retry${attempt}.xml`, currentHtml);
480
+ if (currentHtml.includes('<AUTORIZACION')) {
481
+ console.log(`[CafSolicitor] ✅ CAF obtenido en retry ${attempt} de of_genera_archivo`);
482
+ return response;
483
+ }
484
+ if (!currentHtml.includes('DTE-OFGA')) {
485
+ // Error diferente (ya fue autorizado, etc.) — salir del loop
486
+ break;
487
+ }
488
+ }
489
+ }
490
+
361
491
  // A veces hay un paso extra
362
492
  if (!currentHtml.includes('<AUTORIZACION') && currentHtml.includes('of_genera_archivo')) {
363
493
  const formAction2 = SiiSession.extractFormAction(currentHtml) || '/cvc_cgi/dte/of_genera_archivo';
package/DTE.js CHANGED
@@ -71,8 +71,14 @@ class DTE {
71
71
 
72
72
  _convertirDatosSimplificados(d) {
73
73
  const esExenta = d.tipo === 41;
74
- const { detalle, mntBruto, mntExento } = this._procesarItems(d.items, esExenta);
75
- const totales = this._calcularTotales(mntBruto, mntExento, esExenta);
74
+ const esBoleta = TIPOS_BOLETA.includes(d.tipo);
75
+ const precioConIva = d.precioConIva === true;
76
+ // Boletas afectas: reportar precios al consumidor (bruto con IVA) en <Detalle>.
77
+ // El SII valida MntTotal = suma(MontoItem), por lo que MontoItem debe ser precio con IVA.
78
+ // Para facturas: convertir a neto (precio sin IVA) como de costumbre.
79
+ const convertirANeto = precioConIva && !esBoleta;
80
+ const { detalle, mntNeto, mntExento } = this._procesarItems(d.items, esExenta, convertirANeto);
81
+ const totales = this._calcularTotales(mntNeto, mntExento, esExenta, esBoleta, esBoleta && precioConIva);
76
82
 
77
83
  const resultado = {
78
84
  Encabezado: {
@@ -86,6 +92,7 @@ class DTE {
86
92
  Receptor: {
87
93
  RUTRecep: d.receptor?.RUTRecep ?? '66666666-6',
88
94
  RznSocRecep: sanitizeSiiText(d.receptor?.RznSocRecep ?? 'Consumidor Final'),
95
+ ...(d.receptor?.GiroRecep ? { GiroRecep: d.receptor.GiroRecep } : {}),
89
96
  DirRecep: sanitizeSiiText(d.receptor?.DirRecep ?? 'Sin Direccion'),
90
97
  CmnaRecep: d.receptor?.CmnaRecep ?? 'Santiago',
91
98
  },
@@ -105,24 +112,29 @@ class DTE {
105
112
  return resultado;
106
113
  }
107
114
 
108
- _procesarItems(items, esExenta) {
109
- let mntBruto = 0;
115
+ _procesarItems(items, esExenta, precioConIva = false) {
116
+ let mntNeto = 0;
110
117
  let mntExento = 0;
111
118
 
112
119
  const detalle = items.map((item, idx) => {
113
120
  const qty = item.QtyItem || 1;
114
- const prc = item.PrcItem;
121
+ const esItemExento = esExenta || item.IndExe === 1;
122
+ // Si precioConIva=true y el ítem no es exento, el PrcItem viene con IVA incluido
123
+ // (precio al consumidor del POS) → convertir a neto dividiendo por 1+TASA_IVA.
124
+ const prc = (precioConIva && !esItemExento)
125
+ ? Math.round(item.PrcItem / (1 + TASA_IVA / 100))
126
+ : item.PrcItem;
115
127
  const montoItem = Math.round(qty * prc);
116
128
 
117
- if (esExenta || item.IndExe === 1) {
129
+ if (esItemExento) {
118
130
  mntExento += montoItem;
119
131
  } else {
120
- mntBruto += montoItem;
132
+ mntNeto += montoItem;
121
133
  }
122
134
 
123
135
  const det = {
124
136
  NroLinDet: idx + 1,
125
- ...(esExenta || item.IndExe ? { IndExe: 1 } : {}),
137
+ ...(esItemExento ? { IndExe: 1 } : {}),
126
138
  NmbItem: sanitizeSiiText(item.NmbItem),
127
139
  QtyItem: qty,
128
140
  ...(item.UnmdItem ? { UnmdItem: item.UnmdItem } : {}),
@@ -133,18 +145,35 @@ class DTE {
133
145
  return det;
134
146
  });
135
147
 
136
- return { detalle, mntBruto, mntExento };
148
+ return { detalle, mntNeto, mntExento };
137
149
  }
138
150
 
139
- _calcularTotales(mntBruto, mntExento, esExenta) {
140
- const mntNeto = esExenta ? 0 : Math.round(mntBruto / (1 + (TASA_IVA / 100)));
141
- const iva = esExenta ? 0 : (mntBruto - mntNeto);
142
- const mntTotal = mntNeto + iva + mntExento;
151
+ // PrcItem = precio neto unitario (o precio con IVA si se usa precioConIva:true).
152
+ // IVA = mntNeto * TasaIVA.
153
+ _calcularTotales(mntNeto, mntExento, esExenta, esBoleta = false, esBruto = false) {
154
+ // Boleta afecta con precios al consumidor (brutos): mntNeto contiene el total bruto (con IVA).
155
+ // MntNeto y IVA se derivan del bruto; MntTotal = bruto + exento = suma(MontoItem).
156
+ if (esBoleta && esBruto && !esExenta && mntNeto > 0) {
157
+ const bruto = mntNeto;
158
+ const neto = Math.round(bruto / (1 + TASA_IVA / 100));
159
+ const iva = bruto - neto;
160
+ const mntTotal = bruto + mntExento;
161
+ const totales = { MntNeto: neto };
162
+ if (mntExento > 0) totales.MntExe = mntExento;
163
+ totales.IVA = iva;
164
+ totales.MntTotal = mntTotal;
165
+ return totales;
166
+ }
167
+
168
+ const neto = esExenta ? 0 : mntNeto;
169
+ const iva = esExenta ? 0 : Math.round(neto * TASA_IVA / 100);
170
+ const mntTotal = neto + iva + mntExento;
143
171
 
144
172
  const totales = {};
145
- if (mntNeto > 0) totales.MntNeto = mntNeto;
173
+ if (neto > 0) totales.MntNeto = neto;
174
+ if (neto > 0 && !esBoleta) totales.TasaIVA = TASA_IVA; // requerido en facturas, NO en boletas (EnvioBOLETA_v11.xsd no lo admite)
146
175
  if (mntExento > 0) totales.MntExe = mntExento;
147
- if (mntNeto > 0) totales.IVA = iva;
176
+ if (neto > 0) totales.IVA = iva;
148
177
  totales.MntTotal = mntTotal;
149
178
 
150
179
  return totales;
package/EnviadorSII.js CHANGED
@@ -437,7 +437,7 @@ class EnviadorSII {
437
437
  formData.append('rutCompany', rutCompany.toString());
438
438
  formData.append('dvCompany', dvCompany.toUpperCase());
439
439
 
440
- const xmlBuffer = Buffer.from(xml, 'utf-8');
440
+ const xmlBuffer = Buffer.from(xml, 'latin1');
441
441
  formData.append('archivo', xmlBuffer, {
442
442
  filename: 'EnvioBOLETA.xml',
443
443
  contentType: 'application/xml',
@@ -703,13 +703,20 @@ class EnviadorSII {
703
703
  else if (estado === 'RCH') mensaje = `[ERR] Rechazado: ${json.glosa || json.descripcion || 'Sin detalle'}`;
704
704
  else mensaje = `Estado: ${estado}`;
705
705
 
706
+ const esExitoso = ['EPR', 'RPR', 'RLV'].includes(estado);
707
+ const esRechazado = ['RPT', 'RFR', 'VOF', 'RCT', 'RCH', 'RSC'].includes(estado);
708
+ const esIntermedio = !esExitoso && !esRechazado;
709
+
706
710
  return {
707
711
  ok: true,
708
- trackId: trackId,
709
- estado: estado,
712
+ ...json,
713
+ trackId,
714
+ estado,
710
715
  descripcion: json.descripcion || json.glosa,
711
716
  mensaje,
712
- ...json,
717
+ esExitoso,
718
+ esRechazado,
719
+ esIntermedio,
713
720
  };
714
721
  } catch (e) {
715
722
  return {
@@ -1343,24 +1350,34 @@ class EnviadorSII {
1343
1350
  99: 'Error desconocido',
1344
1351
  };
1345
1352
 
1346
- // STATUS 7 = Envío duplicado (para EnvioBOLETA/EnvioDTE).
1347
- // EXCEPCIÓN: CHR-00001 en <DETAIL> = error de charset, NO es duplicado.
1353
+ // STATUS 7 = puede ser duplicado real, o error de schema/charset (NO duplicado).
1348
1354
  if (status === 7) {
1349
1355
  const detailMatch = responseText.match(/<ERROR>([^<]*)<\/ERROR>/i);
1350
1356
  const detailMsg = detailMatch ? detailMatch[1] : '';
1357
+
1358
+ // CHR-00001: error de charset en el XML (encoding incorrecto)
1351
1359
  if (detailMsg.includes('CHR-00001')) {
1352
1360
  return {
1353
- ok: false,
1354
- status,
1361
+ ok: false, status,
1355
1362
  error: `Error de charset en XML enviado (${detailMsg}). Verifique encoding="ISO-8859-1" en la declaración XML.`,
1356
- respuesta: responseText,
1357
- duplicado: false,
1363
+ respuesta: responseText, duplicado: false,
1364
+ };
1365
+ }
1366
+
1367
+ // SCH-00001: XML no cumple el esquema (ej: campo undefined, fecha inválida, esquema incorrecto)
1368
+ if (detailMsg.includes('SCH-00001')) {
1369
+ return {
1370
+ ok: false, status,
1371
+ error: `XML rechazado por SII: esquema inválido (${detailMsg}). El XML contiene campos malformados o la referencia de esquema no es reconocida.`,
1372
+ respuesta: responseText, duplicado: false,
1358
1373
  };
1359
1374
  }
1375
+
1376
+ // Duplicado real: SII ya recibió este SetDTE ID
1360
1377
  if (trackId) {
1361
1378
  return { ok: true, status, trackId, archivo: fileName, mensaje: `[DUPLICADO] TrackID recuperado: ${trackId}`, respuesta: responseText };
1362
1379
  }
1363
- return { ok: false, status, error: errorMessages[7], respuesta: responseText, duplicado: true };
1380
+ return { ok: false, status, error: `${errorMessages[7]}. Detail: ${detailMsg || 'sin detalle'}`, respuesta: responseText, duplicado: true };
1364
1381
  }
1365
1382
 
1366
1383
  return {
package/Envio.js CHANGED
@@ -62,17 +62,21 @@ class EnvioBase {
62
62
  }
63
63
 
64
64
  /**
65
- * Generar SetId con formato específico
65
+ * Generar SetId con formato específico.
66
+ * @param {string} prefix
67
+ * @param {string} [suffix] - Sufijo opcional (ej: ID de sobre) para garantizar unicidad entre sobres del mismo segundo.
66
68
  */
67
- _generateSetId(prefix) {
69
+ _generateSetId(prefix, suffix) {
68
70
  const now = new Date();
69
- const dd = String(now.getDate()).padStart(2, '0');
70
- const mm = String(now.getMonth() + 1).padStart(2, '0');
71
+ const dd = String(now.getDate()).padStart(2, '0');
72
+ const mm = String(now.getMonth() + 1).padStart(2, '0');
71
73
  const yyyy = now.getFullYear();
72
- const hh = String(now.getHours()).padStart(2, '0');
73
- const min = String(now.getMinutes()).padStart(2, '0');
74
- const ss = String(now.getSeconds()).padStart(2, '0');
75
- return `${prefix}_${dd}_${mm}_${yyyy}_${hh}_${min}_${ss}`;
74
+ const hh = String(now.getHours()).padStart(2, '0');
75
+ const min = String(now.getMinutes()).padStart(2, '0');
76
+ const ss = String(now.getSeconds()).padStart(2, '0');
77
+ const ms = String(now.getMilliseconds()).padStart(3, '0');
78
+ const base = `${prefix}_${dd}_${mm}_${yyyy}_${hh}_${min}_${ss}_${ms}`;
79
+ return suffix ? `${base}_${suffix}` : base;
76
80
  }
77
81
 
78
82
  /**