@devlas/dte-sii 2.9.7 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/EnviadorSII.js CHANGED
@@ -12,8 +12,9 @@
12
12
  * - Consulta de estado de envíos
13
13
  */
14
14
 
15
- const https = require('https');
16
- const forge = require('node-forge');
15
+ const https = require('https');
16
+ const crypto = require('crypto');
17
+ const forge = require('node-forge');
17
18
  const FormData = require('form-data');
18
19
  const {
19
20
  saveEnvioArtifacts,
@@ -84,6 +85,49 @@ class EnviadorSII {
84
85
  };
85
86
  }
86
87
 
88
+ // ============================================
89
+ // TLS helper — remplaza fetch() para endpoints SII
90
+ // ============================================
91
+
92
+ /**
93
+ * POST HTTPS a un endpoint SII con opciones TLS compatibles (rejectUnauthorized:false,
94
+ * TLSv1.2 max, legacy renegotiation). Reemplaza fetch() que usa undici y no acepta
95
+ * estas opciones, causando EPROTO/alert 48 "unknown_ca" en maullin/palena.
96
+ * @param {string} urlStr
97
+ * @param {{ headers?: Record<string,string>, body?: Buffer|string, timeoutMs?: number }} opts
98
+ * @returns {Promise<{ ok: boolean, status: number, text: string }>}
99
+ */
100
+ _siiPost(urlStr, { headers = {}, body = '', timeoutMs = 30000 } = {}) {
101
+ return new Promise((resolve, reject) => {
102
+ const url = new URL(urlStr);
103
+ const buf = Buffer.isBuffer(body) ? body : Buffer.from(body, 'latin1');
104
+ const opts = {
105
+ hostname: url.hostname,
106
+ port: url.port || 443,
107
+ path: url.pathname + url.search,
108
+ method: 'POST',
109
+ headers: { 'Content-Length': buf.length, ...headers },
110
+ rejectUnauthorized: false,
111
+ maxVersion: 'TLSv1.2',
112
+ secureOptions:
113
+ crypto.constants.SSL_OP_ALLOW_UNSAFE_LEGACY_RENEGOTIATION |
114
+ crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
115
+ };
116
+ const req = https.request(opts, (res) => {
117
+ const chunks = [];
118
+ res.on('data', (c) => chunks.push(c));
119
+ res.on('end', () => {
120
+ const text = Buffer.concat(chunks).toString('latin1');
121
+ resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, text });
122
+ });
123
+ });
124
+ req.on('error', reject);
125
+ req.setTimeout(timeoutMs, () => { req.destroy(); reject(new Error(`_siiPost timeout: ${urlStr}`)); });
126
+ req.write(buf);
127
+ req.end();
128
+ });
129
+ }
130
+
87
131
  // ============================================
88
132
  // AUTENTICACIÓN REST (Boletas)
89
133
  // ============================================
@@ -198,13 +242,10 @@ class EnviadorSII {
198
242
  await wait(attempt * 1000);
199
243
  }
200
244
 
201
- const response = await fetch(url, {
202
- method: 'POST',
203
- headers: {
204
- 'Content-Type': 'text/xml; charset=utf-8',
205
- 'SOAPAction': '',
206
- },
245
+ const response = await this._siiPost(url, {
246
+ headers: { 'Content-Type': 'text/xml; charset=utf-8', 'SOAPAction': '' },
207
247
  body: soapEnvelope,
248
+ timeoutMs: getConfigSection('timeout')?.soap || 30000,
208
249
  });
209
250
 
210
251
  if (!response.ok) {
@@ -215,7 +256,7 @@ class EnviadorSII {
215
256
  throw siiError(`Error obteniendo semilla SOAP: ${response.status}`, ERROR_CODES.SII_CONNECTION_FAILED);
216
257
  }
217
258
 
218
- const xml = await response.text();
259
+ const xml = response.text;
219
260
  // Usar utilidad centralizada para decodificar entidades
220
261
  const decodedXml = decodeXmlEntities(xml);
221
262
 
@@ -233,7 +274,7 @@ class EnviadorSII {
233
274
  throw siiError('No se pudo extraer semilla de la respuesta SOAP', ERROR_CODES.SII_INVALID_RESPONSE);
234
275
  } catch (error) {
235
276
  if (isRetryableError(error) && attempt < maxRetries) {
236
- log.log(` [!] Error de conexión semilla SOAP (${error.cause?.code || 'socket'}), reintentando...`);
277
+ log.log(` [!] Error de conexión semilla SOAP (${error.code || error.cause?.code || 'socket'}), reintentando...`);
237
278
  continue;
238
279
  }
239
280
  throw error;
@@ -282,25 +323,21 @@ class EnviadorSII {
282
323
  </soapenv:Body>
283
324
  </soapenv:Envelope>`;
284
325
 
285
- const response = await fetch(url, {
286
- method: 'POST',
287
- headers: {
288
- 'Content-Type': 'text/xml; charset=utf-8',
289
- 'SOAPAction': '',
290
- },
326
+ const response = await this._siiPost(url, {
327
+ headers: { 'Content-Type': 'text/xml; charset=utf-8', 'SOAPAction': '' },
291
328
  body: soapEnvelope,
329
+ timeoutMs: getConfigSection('timeout')?.soap || 30000,
292
330
  });
293
331
 
294
332
  if (!response.ok) {
295
- const errorText = await response.text();
296
333
  if (isRetryableStatus(response.status) && attempt < maxRetries) {
297
334
  log.log(` [!] Error token SOAP (${response.status}), reintentando...`);
298
335
  continue;
299
336
  }
300
- throw siiError(`Error obteniendo token SOAP: ${response.status} - ${errorText}`, ERROR_CODES.SII_CONNECTION_FAILED);
337
+ throw siiError(`Error obteniendo token SOAP: ${response.status} - ${response.text}`, ERROR_CODES.SII_CONNECTION_FAILED);
301
338
  }
302
339
 
303
- const xml = await response.text();
340
+ const xml = response.text;
304
341
  const decodedXml = xml
305
342
  .replace(/&lt;/g, '<')
306
343
  .replace(/&gt;/g, '>')
@@ -329,7 +366,7 @@ class EnviadorSII {
329
366
  throw siiError('No se pudo obtener token SOAP del SII', ERROR_CODES.SII_INVALID_RESPONSE);
330
367
  } catch (error) {
331
368
  if (isRetryableError(error) && attempt < maxRetries) {
332
- log.log(` [!] Error de conexión token SOAP (${error.cause?.code || 'socket'}), reintentando...`);
369
+ log.log(` [!] Error de conexión token SOAP (${error.code || error.cause?.code || 'socket'}), reintentando...`);
333
370
  continue;
334
371
  }
335
372
  throw error;
@@ -756,19 +793,14 @@ class EnviadorSII {
756
793
  log.log('Consultando estado SOAP:', urlQueryEstUp);
757
794
  log.log(' TrackID:', trackId, 'RUT:', rutEmisor);
758
795
 
759
- const response = await fetch(urlQueryEstUp, {
760
- method: 'POST',
761
- headers: {
762
- 'Content-Type': 'text/xml; charset=utf-8',
763
- 'SOAPAction': '',
764
- },
796
+ const response = await this._siiPost(urlQueryEstUp, {
797
+ headers: { 'Content-Type': 'text/xml; charset=utf-8', 'SOAPAction': '' },
765
798
  body: soapBody,
799
+ timeoutMs: 30000,
766
800
  });
767
-
768
- const text = await response.text();
769
-
801
+
770
802
  // Usar decodeXmlEntities centralizado
771
- const decoded = decodeXmlEntities(text).replace(/&#xd;/g, '\n');
803
+ const decoded = decodeXmlEntities(response.text).replace(/&#xd;/g, '\n');
772
804
 
773
805
 
774
806
 
@@ -810,6 +842,10 @@ class EnviadorSII {
810
842
  mensaje = '[!] Aceptado con Reparos';
811
843
  esExitoso = true;
812
844
  break;
845
+ case 'LOK':
846
+ mensaje = `[OK] Envio de Libro Aceptado - Cuadrado: ${glosa || ''}`.trim();
847
+ esExitoso = true;
848
+ break;
813
849
  case 'REC':
814
850
  mensaje = '[...] Envío Recibido - Esperando validación';
815
851
  esIntermedio = true;
@@ -867,6 +903,20 @@ class EnviadorSII {
867
903
  mensaje = '[...] Envío en Proceso - Validando...';
868
904
  esIntermedio = true;
869
905
  break;
906
+ case 'LNC':
907
+ mensaje = `[ERR] Tipo de Envio de Libro No Corresponde: ${glosa || 'Período ya tiene LTC'}`;
908
+ esRechazado = true;
909
+ break;
910
+ case 'LRH':
911
+ mensaje = `[ERR] Libro Rechazado con Historial: ${glosa || 'Rechazo con registro'}`;
912
+ esRechazado = true;
913
+ break;
914
+ case 'LSO':
915
+ // Schema de Envio de Libro Correcto: schema validado, SII aún no procesó el contenido.
916
+ // Es un estado INTERMEDIO — el libro no está en LTC todavía.
917
+ mensaje = '[...] Schema de Libro Correcto - Procesando contenido...';
918
+ esIntermedio = true;
919
+ break;
870
920
  // Códigos numéricos negativos = errores del servicio de consulta SII (NO rechazo del documento)
871
921
  // Según doc SII: son errores del sistema de consulta, el documento puede estar OK
872
922
  case '-11':
@@ -1226,15 +1276,7 @@ class EnviadorSII {
1226
1276
  const backoffMultiplier = retryConfig?.backoffMultiplier || 1.8;
1227
1277
  let lastError = null;
1228
1278
 
1229
- // Calcular Content-Length según tipo de body
1230
- const contentLength = Buffer.isBuffer(body)
1231
- ? body.length.toString()
1232
- : Buffer.byteLength(body).toString();
1233
-
1234
1279
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
1235
- const controller = new AbortController();
1236
- const timeout = setTimeout(() => controller.abort(), 60000);
1237
-
1238
1280
  try {
1239
1281
  if (attempt > 1) {
1240
1282
  const delay = Math.min(initialDelay * Math.pow(backoffMultiplier, attempt - 2), 15000);
@@ -1242,28 +1284,23 @@ class EnviadorSII {
1242
1284
  await new Promise(resolve => setTimeout(resolve, delay));
1243
1285
  }
1244
1286
 
1245
- const response = await fetch(url, {
1246
- method: 'POST',
1287
+ const response = await this._siiPost(url, {
1247
1288
  headers: {
1248
1289
  'Cookie': `TOKEN=${this.tokenSoap}`,
1249
1290
  'Content-Type': `multipart/form-data; boundary=${boundary}`,
1250
1291
  'User-Agent': 'Mozilla/4.0 ( compatible; PROG 1.0; Windows NT)',
1251
1292
  'Accept': '*/*',
1252
1293
  'Connection': 'keep-alive',
1253
- 'Content-Length': contentLength,
1254
1294
  },
1255
1295
  body: body,
1256
- signal: controller.signal,
1296
+ timeoutMs: 60000,
1257
1297
  });
1258
- clearTimeout(timeout);
1259
1298
 
1260
- const responseText = await response.text();
1261
-
1262
- const result = this._parsearRespuestaSoap(responseText, response.ok, response.status, tipoEnvio);
1299
+ const result = this._parsearRespuestaSoap(response.text, response.ok, response.status, tipoEnvio);
1263
1300
 
1264
1301
  saveEnvioArtifacts({
1265
1302
  xml,
1266
- responseText,
1303
+ responseText: response.text,
1267
1304
  responseOk: response.ok,
1268
1305
  responseStatus: response.status,
1269
1306
  trackId: result.trackId,
@@ -1273,27 +1310,26 @@ class EnviadorSII {
1273
1310
 
1274
1311
  return result;
1275
1312
  } catch (fetchError) {
1276
- clearTimeout(timeout);
1277
1313
  lastError = fetchError;
1278
-
1314
+
1279
1315
  // Usar isRetryableError centralizado de utils
1280
1316
  if (isRetryableError(fetchError) && attempt < maxRetries) {
1281
- log.log(` [!] Error de conexión ${tipoEnvio} (${fetchError.cause?.code || 'socket'}), reintentando...`);
1317
+ log.log(` [!] Error de conexión ${tipoEnvio} (${fetchError.code || fetchError.cause?.code || 'socket'}), reintentando...`);
1282
1318
  continue;
1283
1319
  }
1284
-
1320
+
1285
1321
  saveEnvioArtifacts({
1286
1322
  xml,
1287
- responseText: `ERROR_FETCH: ${fetchError.message}`,
1323
+ responseText: `ERROR: ${fetchError.message}`,
1288
1324
  responseOk: false,
1289
1325
  responseStatus: null,
1290
1326
  trackId: null,
1291
1327
  ambiente: this.ambiente,
1292
1328
  tipoEnvio,
1293
- error: fetchError.cause || fetchError,
1329
+ error: fetchError,
1294
1330
  });
1295
1331
 
1296
- log.error('Fetch error details:', fetchError.cause || fetchError);
1332
+ log.error('Error en envío SII:', fetchError.message);
1297
1333
  throw fetchError;
1298
1334
  }
1299
1335
  }
@@ -1373,6 +1409,16 @@ class EnviadorSII {
1373
1409
  };
1374
1410
  }
1375
1411
 
1412
+ // cvc-*: error de validación XSD (p.ej. elemento fuera de orden, tipo incorrecto)
1413
+ // El SII puede retornar status 7 para errores XSD sin prefijo SCH-00001
1414
+ if (detailMsg.includes('cvc-')) {
1415
+ return {
1416
+ ok: false, status,
1417
+ error: `XML rechazado por SII: error de validación XSD. Detail: ${detailMsg}`,
1418
+ respuesta: responseText, duplicado: false,
1419
+ };
1420
+ }
1421
+
1376
1422
  // Duplicado real: SII ya recibió este SetDTE ID
1377
1423
  if (trackId) {
1378
1424
  return { ok: true, status, trackId, archivo: fileName, mensaje: `[DUPLICADO] TrackID recuperado: ${trackId}`, respuesta: responseText };
package/LICENSE CHANGED
@@ -1,27 +1,27 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Devlas SpA — https://devlas.cl
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
22
-
23
- ---
24
-
25
- Esta librería implementa el protocolo XML del Servicio de Impuestos Internos (SII)
26
- de Chile tal como está documentado públicamente por el propio SII.
27
- Inspirada conceptualmente en LibreDTE de SASCO SpA (https://libredte.cl).
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Devlas SpA — https://devlas.cl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ Esta librería implementa el protocolo XML del Servicio de Impuestos Internos (SII)
26
+ de Chile tal como está documentado públicamente por el propio SII.
27
+ Inspirada conceptualmente en LibreDTE de SASCO SpA (https://libredte.cl).
@@ -13,6 +13,16 @@ const LibroBase = require('./LibroBase');
13
13
  class LibroCompraVenta extends LibroBase {
14
14
  constructor(certificado) {
15
15
  super(certificado);
16
+ this.ltcTotales = null;
17
+ }
18
+
19
+ /**
20
+ * Establece los totales del envío LTC (MENSUAL/TOTAL) previo para este período.
21
+ * Cuando se genera un AJUSTE, TotalesPeriodo = LTC + delta actual.
22
+ * @param {Array} totalesLtc - Array de resumen del libro TOTAL (misma estructura que setResumen)
23
+ */
24
+ setLtcTotales(totalesLtc) {
25
+ this.ltcTotales = totalesLtc || null;
16
26
  }
17
27
 
18
28
  setCaratula(caratula) {
@@ -24,10 +34,11 @@ class LibroCompraVenta extends LibroBase {
24
34
  if (!this.caratula) {
25
35
  throw new Error('Debe establecer la carátula antes de generar');
26
36
  }
27
-
37
+
38
+ const tipoEnvio = String(this.caratula?.TipoEnvio || '').toUpperCase();
28
39
  const caratulaXml = this._renderCaratula();
29
40
  const resumenXml = this._renderResumen();
30
- const detalleXml = this._renderDetalle();
41
+ const detalleXml = this._renderDetalle(tipoEnvio === 'AJUSTE');
31
42
  const envioLibroId = this.id;
32
43
  const tmstFirma = this._getTmstFirma();
33
44
 
@@ -64,13 +75,53 @@ class LibroCompraVenta extends LibroBase {
64
75
 
65
76
  _renderResumen() {
66
77
  if (!this.resumen.length) return '';
67
-
78
+
68
79
  const tipoEnvio = String(this.caratula?.TipoEnvio || '').toUpperCase();
69
- const useSegmento = tipoEnvio && tipoEnvio !== 'TOTAL';
70
- const resumenTag = useSegmento ? 'ResumenSegmento' : 'ResumenPeriodo';
71
- const totalesTag = useSegmento ? 'TotalesSegmento' : 'TotalesPeriodo';
72
-
73
- const order = [
80
+
81
+ if (tipoEnvio === 'AJUSTE') {
82
+ // AJUSTE: ResumenSegmento (delta actual) + ResumenPeriodo (LTC acumulado + delta)
83
+ const segmentoXml = this._renderResumenSection('ResumenSegmento', 'TotalesSegmento');
84
+ if (this.ltcTotales && this.ltcTotales.length > 0) {
85
+ // TotalesPeriodo = totales LTC previos + delta de este AJUSTE
86
+ const acumulado = this._acumularResumen(this.ltcTotales, this.resumen);
87
+ const savedResumen = this.resumen;
88
+ this.resumen = acumulado;
89
+ const periodoXml = this._renderResumenSection('ResumenPeriodo', 'TotalesPeriodo');
90
+ this.resumen = savedResumen;
91
+ return segmentoXml + periodoXml;
92
+ }
93
+ // Sin LTC guardado: ambas secciones con los mismos datos (ajuste inicial)
94
+ return segmentoXml +
95
+ this._renderResumenSection('ResumenPeriodo', 'TotalesPeriodo');
96
+ }
97
+
98
+ const useSegmento = tipoEnvio === 'PARCIAL';
99
+ return this._renderResumenSection(
100
+ useSegmento ? 'ResumenSegmento' : 'ResumenPeriodo',
101
+ useSegmento ? 'TotalesSegmento' : 'TotalesPeriodo'
102
+ );
103
+ }
104
+
105
+ _renderResumenSection(resumenTag, totalesTag) {
106
+ // TotalesPeriodo y TotalesSegmento tienen ordenamientos XSD distintos para FctProp:
107
+ // Periodo: FctProp viene al final, después de TotImpVehiculo
108
+ // Segmento: FctProp viene antes de TotMntTotal, después de TotIVAUsoComun
109
+ // y no incluye TotImpVehiculo en la secuencia post-TotMntTotal
110
+ const isSegmento = totalesTag === 'TotalesSegmento';
111
+ // TotalesSegmento (AJUSTE/PARCIAL): FctProp/TotCredIVAUsoComun no existen en ese tipo XSD.
112
+ // TotalesPeriodo: orden original validado por el SII — FctProp inmediatamente después de TotIVAUsoComun.
113
+ const order = isSegmento ? [
114
+ 'TpoDoc', 'TpoImp', 'TotDoc', 'TotAnulado', 'TotOpExe', 'TotMntExe',
115
+ 'TotMntNeto', 'TotOpIVARec', 'TotMntIVA', 'TotOpActivoFijo',
116
+ 'TotMntActivoFijo', 'TotMntIVAActivoFijo', 'TotIVANoRec',
117
+ 'TotOpIVAUsoComun', 'TotIVAUsoComun',
118
+ 'TotIVAFueraPlazo', 'TotIVAPropio', 'TotIVATerceros', 'TotLey18211',
119
+ 'TotOtrosImp', 'TotImpSinCredito', 'TotOpIVARetTotal', 'TotIVARetTotal',
120
+ 'TotOpIVARetParcial', 'TotIVARetParcial', 'TotCredEC', 'TotDepEnvase',
121
+ 'TotLiquidaciones', 'TotMntTotal', 'TotOpIVANoRetenido', 'TotIVANoRetenido',
122
+ 'TotMntNoFact', 'TotMntPeriodo', 'TotPsjNac', 'TotPsjInt', 'TotTabPuros',
123
+ 'TotTabCigarrillos', 'TotTabElaborado',
124
+ ] : [
74
125
  'TpoDoc', 'TpoImp', 'TotDoc', 'TotAnulado', 'TotOpExe', 'TotMntExe',
75
126
  'TotMntNeto', 'TotOpIVARec', 'TotMntIVA', 'TotOpActivoFijo',
76
127
  'TotMntActivoFijo', 'TotMntIVAActivoFijo', 'TotIVANoRec',
@@ -82,7 +133,7 @@ class LibroCompraVenta extends LibroBase {
82
133
  'TotMntNoFact', 'TotMntPeriodo', 'TotPsjNac', 'TotPsjInt', 'TotTabPuros',
83
134
  'TotTabCigarrillos', 'TotTabElaborado', 'TotImpVehiculo',
84
135
  ];
85
-
136
+
86
137
  let xml = `<${resumenTag}>`;
87
138
  for (const r of this.resumen) {
88
139
  const normalized = { ...r };
@@ -94,12 +145,12 @@ class LibroCompraVenta extends LibroBase {
94
145
  ) {
95
146
  normalized.TotMntExe = 0;
96
147
  }
97
-
148
+
98
149
  xml += `<${totalesTag}>`;
99
150
  order.forEach((key) => {
100
151
  const value = normalized[key];
101
152
  if (value === undefined || value === null || value === '') return;
102
-
153
+
103
154
  if (Array.isArray(value)) {
104
155
  value.forEach((item) => {
105
156
  if (item && typeof item === 'object') {
@@ -132,9 +183,77 @@ class LibroCompraVenta extends LibroBase {
132
183
  return xml;
133
184
  }
134
185
 
135
- _renderDetalle() {
186
+ /**
187
+ * Acumula dos arrays de resumen (base LTC + delta AJUSTE) sumando campos numéricos por TpoDoc.
188
+ * @private
189
+ */
190
+ _acumularResumen(base, delta) {
191
+ const map = new Map();
192
+ for (const item of base) {
193
+ map.set(item.TpoDoc, { ...item });
194
+ }
195
+
196
+ // Key field per array element type — used to match base vs delta items
197
+ const ARRAY_KEY_FIELDS = {
198
+ TotIVANoRec: 'CodIVANoRec',
199
+ TotOtrosImp: 'CodImp',
200
+ };
201
+ // Rate/factor fields — keep existing value (don't sum), set from delta if absent
202
+ const RATE_KEYS = new Set(['TpoDoc', 'TpoImp', 'FctProp']);
203
+
204
+ // Merge two arrays of sub-objects: sum all numeric fields except the key field
205
+ const _mergeArrayField = (baseArr, deltaArr, keyField) => {
206
+ const m = new Map();
207
+ for (const item of (baseArr || [])) m.set(item[keyField], { ...item });
208
+ for (const item of (deltaArr || [])) {
209
+ if (m.has(item[keyField])) {
210
+ const acc = m.get(item[keyField]);
211
+ for (const [k, v] of Object.entries(item)) {
212
+ if (k === keyField) continue;
213
+ const n = Number(v);
214
+ if (!Number.isNaN(n) && v !== '' && v !== null && v !== undefined) {
215
+ acc[k] = (Number(acc[k] || 0)) + n;
216
+ }
217
+ }
218
+ } else {
219
+ m.set(item[keyField], { ...item });
220
+ }
221
+ }
222
+ return Array.from(m.values());
223
+ };
224
+
225
+ for (const item of delta) {
226
+ if (map.has(item.TpoDoc)) {
227
+ const acc = map.get(item.TpoDoc);
228
+ for (const [key, val] of Object.entries(item)) {
229
+ if (RATE_KEYS.has(key)) {
230
+ // Keep existing value; propagate from delta only if absent in base
231
+ if (acc[key] === undefined || acc[key] === null) acc[key] = val;
232
+ continue;
233
+ }
234
+ if (Array.isArray(val)) {
235
+ const keyField = ARRAY_KEY_FIELDS[key];
236
+ if (keyField) {
237
+ acc[key] = _mergeArrayField(acc[key], val, keyField);
238
+ }
239
+ continue;
240
+ }
241
+ const numVal = Number(val);
242
+ if (!Number.isNaN(numVal) && val !== '' && val !== null && val !== undefined) {
243
+ acc[key] = (Number(acc[key] || 0)) + numVal;
244
+ }
245
+ }
246
+ } else {
247
+ map.set(item.TpoDoc, { ...item });
248
+ }
249
+ }
250
+ return Array.from(map.values());
251
+ }
252
+
253
+ // isAjuste: cuando true, agrega Operacion=1 (agrega) a cada ítem que no la tenga
254
+ _renderDetalle(isAjuste = false) {
136
255
  if (!this.detalle.length) return '';
137
-
256
+
138
257
  const order = [
139
258
  'TpoDoc', 'Emisor', 'IndFactCompra', 'NroDoc', 'Anulado', 'Operacion',
140
259
  'TpoImp', 'TasaImp', 'NumInt', 'IndServicio', 'IndSinCosto', 'FchDoc',
@@ -146,11 +265,16 @@ class LibroCompraVenta extends LibroBase {
146
265
  'MntNoFact', 'MntPeriodo', 'PsjNac', 'PsjInt', 'TabPuros', 'TabCigarrillos',
147
266
  'TabElaborado', 'ImpVehiculo',
148
267
  ];
149
-
268
+
150
269
  let xml = '';
151
270
  for (const d of this.detalle) {
152
271
  const normalized = { ...d };
153
-
272
+
273
+ // AJUSTE: Operacion=1 (agrega) por defecto si no está explícito
274
+ if (isAjuste && (normalized.Operacion === undefined || normalized.Operacion === null || normalized.Operacion === '')) {
275
+ normalized.Operacion = 1;
276
+ }
277
+
154
278
  // Calcular TasaImp si no existe
155
279
  if (
156
280
  (normalized.TasaImp === undefined || normalized.TasaImp === null || normalized.TasaImp === '') &&
@@ -164,12 +288,12 @@ class LibroCompraVenta extends LibroBase {
164
288
  normalized.TasaImp = tasa.toFixed(2).replace(/\.00$/, '.0');
165
289
  }
166
290
  }
167
-
291
+
168
292
  xml += '<Detalle>';
169
293
  order.forEach((key) => {
170
294
  const value = normalized[key];
171
295
  if (value === undefined || value === null || value === '') return;
172
-
296
+
173
297
  if (Array.isArray(value)) {
174
298
  value.forEach((item) => {
175
299
  if (item && typeof item === 'object') {
package/LibroGuia.js CHANGED
@@ -40,8 +40,10 @@ class LibroGuia extends LibroBase {
40
40
  throw new Error('Debe establecer la carátula antes de generar');
41
41
  }
42
42
 
43
+ const tipoEnvio = String(this.caratula?.TipoEnvio || '').toUpperCase();
43
44
  const caratulaXml = this._renderCaratula();
44
45
  const resumenXml = this._renderResumen();
46
+ // AJUSTE con documentos: Detalle + ResumenSegmento + ResumenPeriodo
45
47
  const detalleXml = this._renderDetalle();
46
48
 
47
49
  const schemaLoc = 'http://www.sii.cl/SiiDte LibroGuia_v10.xsd';
@@ -87,7 +89,9 @@ class LibroGuia extends LibroBase {
87
89
  _renderResumen() {
88
90
  const resumen = this._buildResumenPeriodo();
89
91
  if (!resumen) return '';
90
-
92
+
93
+ const tipoEnvio = String(this.caratula?.TipoEnvio || '').toUpperCase();
94
+
91
95
  const order = [
92
96
  'TotFolAnulado',
93
97
  'TotGuiaAnulada',
@@ -95,30 +99,37 @@ class LibroGuia extends LibroBase {
95
99
  'TotMntGuiaVta',
96
100
  'TotTraslado',
97
101
  ];
98
-
99
- let xml = '<ResumenPeriodo>';
100
- order.forEach((key) => {
101
- const value = resumen[key];
102
- if (value === undefined || value === null || value === '' || value === false) return;
103
-
104
- if (Array.isArray(value)) {
105
- value.forEach((item) => {
106
- if (item && typeof item === 'object') {
107
- xml += `<${key}>`;
108
- Object.keys(item).forEach((itemKey) => {
109
- if (item[itemKey] !== undefined && item[itemKey] !== null && item[itemKey] !== '') {
110
- xml += `<${itemKey}>${this._escapeXmlText(String(item[itemKey]))}</${itemKey}>`;
111
- }
112
- });
113
- xml += `</${key}>`;
114
- }
115
- });
116
- } else {
117
- xml += `<${key}>${this._escapeXmlText(String(value))}</${key}>`;
118
- }
119
- });
120
- xml += '</ResumenPeriodo>';
121
- return xml;
102
+
103
+ const _renderSection = (tag) => {
104
+ let xml = `<${tag}>`;
105
+ order.forEach((key) => {
106
+ const value = resumen[key];
107
+ if (value === undefined || value === null || value === '' || value === false) return;
108
+ if (Array.isArray(value)) {
109
+ value.forEach((item) => {
110
+ if (item && typeof item === 'object') {
111
+ xml += `<${key}>`;
112
+ Object.keys(item).forEach((itemKey) => {
113
+ if (item[itemKey] !== undefined && item[itemKey] !== null && item[itemKey] !== '') {
114
+ xml += `<${itemKey}>${this._escapeXmlText(String(item[itemKey]))}</${itemKey}>`;
115
+ }
116
+ });
117
+ xml += `</${key}>`;
118
+ }
119
+ });
120
+ } else {
121
+ xml += `<${key}>${this._escapeXmlText(String(value))}</${key}>`;
122
+ }
123
+ });
124
+ xml += `</${tag}>`;
125
+ return xml;
126
+ };
127
+
128
+ // AJUSTE con documentos: ResumenSegmento (delta) + ResumenPeriodo (acumulado)
129
+ if (tipoEnvio === 'AJUSTE' && this.detalle.length > 0) {
130
+ return _renderSection('ResumenSegmento') + _renderSection('ResumenPeriodo');
131
+ }
132
+ return _renderSection('ResumenPeriodo');
122
133
  }
123
134
 
124
135
  _renderDetalle() {