@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 +151 -21
- package/DTE.js +44 -15
- package/EnviadorSII.js +28 -11
- package/Envio.js +12 -8
- package/FolioService.js +263 -94
- package/SiiPortalAuth.js +114 -1
- package/SiiSession.js +106 -6
- package/WsReclamo.js +434 -0
- package/index.js +8 -0
- package/package.json +1 -1
- package/utils/endpoints.js +87 -0
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
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
75
|
-
const
|
|
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
|
|
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
|
|
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 (
|
|
129
|
+
if (esItemExento) {
|
|
118
130
|
mntExento += montoItem;
|
|
119
131
|
} else {
|
|
120
|
-
|
|
132
|
+
mntNeto += montoItem;
|
|
121
133
|
}
|
|
122
134
|
|
|
123
135
|
const det = {
|
|
124
136
|
NroLinDet: idx + 1,
|
|
125
|
-
...(
|
|
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,
|
|
148
|
+
return { detalle, mntNeto, mntExento };
|
|
137
149
|
}
|
|
138
150
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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 (
|
|
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 (
|
|
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, '
|
|
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
|
-
|
|
709
|
-
|
|
712
|
+
...json,
|
|
713
|
+
trackId,
|
|
714
|
+
estado,
|
|
710
715
|
descripcion: json.descripcion || json.glosa,
|
|
711
716
|
mensaje,
|
|
712
|
-
|
|
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 =
|
|
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
|
-
|
|
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]
|
|
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
|
|
70
|
-
const mm
|
|
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
|
|
73
|
-
const min
|
|
74
|
-
const ss
|
|
75
|
-
|
|
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
|
/**
|