@devlas/dte-sii 2.9.8 → 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
  // ============================================
@@ -192,24 +236,17 @@ class EnviadorSII {
192
236
  </soapenv:Envelope>`;
193
237
 
194
238
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
195
- const controller = new AbortController();
196
- const soapTimeout = setTimeout(() => controller.abort(), getConfigSection('timeout')?.soap || 30000);
197
239
  try {
198
240
  if (attempt > 1) {
199
241
  log.log(` [...] Reintento semilla SOAP ${attempt}/${maxRetries}...`);
200
242
  await wait(attempt * 1000);
201
243
  }
202
244
 
203
- const response = await fetch(url, {
204
- method: 'POST',
205
- headers: {
206
- 'Content-Type': 'text/xml; charset=utf-8',
207
- 'SOAPAction': '',
208
- },
245
+ const response = await this._siiPost(url, {
246
+ headers: { 'Content-Type': 'text/xml; charset=utf-8', 'SOAPAction': '' },
209
247
  body: soapEnvelope,
210
- signal: controller.signal,
248
+ timeoutMs: getConfigSection('timeout')?.soap || 30000,
211
249
  });
212
- clearTimeout(soapTimeout);
213
250
 
214
251
  if (!response.ok) {
215
252
  if (isRetryableStatus(response.status) && attempt < maxRetries) {
@@ -219,7 +256,7 @@ class EnviadorSII {
219
256
  throw siiError(`Error obteniendo semilla SOAP: ${response.status}`, ERROR_CODES.SII_CONNECTION_FAILED);
220
257
  }
221
258
 
222
- const xml = await response.text();
259
+ const xml = response.text;
223
260
  // Usar utilidad centralizada para decodificar entidades
224
261
  const decodedXml = decodeXmlEntities(xml);
225
262
 
@@ -236,9 +273,8 @@ class EnviadorSII {
236
273
 
237
274
  throw siiError('No se pudo extraer semilla de la respuesta SOAP', ERROR_CODES.SII_INVALID_RESPONSE);
238
275
  } catch (error) {
239
- clearTimeout(soapTimeout);
240
276
  if (isRetryableError(error) && attempt < maxRetries) {
241
- 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...`);
242
278
  continue;
243
279
  }
244
280
  throw error;
@@ -269,8 +305,6 @@ class EnviadorSII {
269
305
  const url = this.urls[this.ambiente].tokenSoap;
270
306
 
271
307
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
272
- const controller = new AbortController();
273
- const soapTimeout = setTimeout(() => controller.abort(), getConfigSection('timeout')?.soap || 30000);
274
308
  try {
275
309
  if (attempt > 1) {
276
310
  log.log(` [...] Reintento token SOAP ${attempt}/${maxRetries}...`);
@@ -289,27 +323,21 @@ class EnviadorSII {
289
323
  </soapenv:Body>
290
324
  </soapenv:Envelope>`;
291
325
 
292
- const response = await fetch(url, {
293
- method: 'POST',
294
- headers: {
295
- 'Content-Type': 'text/xml; charset=utf-8',
296
- 'SOAPAction': '',
297
- },
326
+ const response = await this._siiPost(url, {
327
+ headers: { 'Content-Type': 'text/xml; charset=utf-8', 'SOAPAction': '' },
298
328
  body: soapEnvelope,
299
- signal: controller.signal,
329
+ timeoutMs: getConfigSection('timeout')?.soap || 30000,
300
330
  });
301
- clearTimeout(soapTimeout);
302
331
 
303
332
  if (!response.ok) {
304
- const errorText = await response.text();
305
333
  if (isRetryableStatus(response.status) && attempt < maxRetries) {
306
334
  log.log(` [!] Error token SOAP (${response.status}), reintentando...`);
307
335
  continue;
308
336
  }
309
- 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);
310
338
  }
311
339
 
312
- const xml = await response.text();
340
+ const xml = response.text;
313
341
  const decodedXml = xml
314
342
  .replace(/&lt;/g, '<')
315
343
  .replace(/&gt;/g, '>')
@@ -337,9 +365,8 @@ class EnviadorSII {
337
365
 
338
366
  throw siiError('No se pudo obtener token SOAP del SII', ERROR_CODES.SII_INVALID_RESPONSE);
339
367
  } catch (error) {
340
- clearTimeout(soapTimeout);
341
368
  if (isRetryableError(error) && attempt < maxRetries) {
342
- 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...`);
343
370
  continue;
344
371
  }
345
372
  throw error;
@@ -766,19 +793,14 @@ class EnviadorSII {
766
793
  log.log('Consultando estado SOAP:', urlQueryEstUp);
767
794
  log.log(' TrackID:', trackId, 'RUT:', rutEmisor);
768
795
 
769
- const response = await fetch(urlQueryEstUp, {
770
- method: 'POST',
771
- headers: {
772
- 'Content-Type': 'text/xml; charset=utf-8',
773
- 'SOAPAction': '',
774
- },
796
+ const response = await this._siiPost(urlQueryEstUp, {
797
+ headers: { 'Content-Type': 'text/xml; charset=utf-8', 'SOAPAction': '' },
775
798
  body: soapBody,
799
+ timeoutMs: 30000,
776
800
  });
777
-
778
- const text = await response.text();
779
-
801
+
780
802
  // Usar decodeXmlEntities centralizado
781
- const decoded = decodeXmlEntities(text).replace(/&#xd;/g, '\n');
803
+ const decoded = decodeXmlEntities(response.text).replace(/&#xd;/g, '\n');
782
804
 
783
805
 
784
806
 
@@ -820,6 +842,10 @@ class EnviadorSII {
820
842
  mensaje = '[!] Aceptado con Reparos';
821
843
  esExitoso = true;
822
844
  break;
845
+ case 'LOK':
846
+ mensaje = `[OK] Envio de Libro Aceptado - Cuadrado: ${glosa || ''}`.trim();
847
+ esExitoso = true;
848
+ break;
823
849
  case 'REC':
824
850
  mensaje = '[...] Envío Recibido - Esperando validación';
825
851
  esIntermedio = true;
@@ -877,6 +903,20 @@ class EnviadorSII {
877
903
  mensaje = '[...] Envío en Proceso - Validando...';
878
904
  esIntermedio = true;
879
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;
880
920
  // Códigos numéricos negativos = errores del servicio de consulta SII (NO rechazo del documento)
881
921
  // Según doc SII: son errores del sistema de consulta, el documento puede estar OK
882
922
  case '-11':
@@ -1236,15 +1276,7 @@ class EnviadorSII {
1236
1276
  const backoffMultiplier = retryConfig?.backoffMultiplier || 1.8;
1237
1277
  let lastError = null;
1238
1278
 
1239
- // Calcular Content-Length según tipo de body
1240
- const contentLength = Buffer.isBuffer(body)
1241
- ? body.length.toString()
1242
- : Buffer.byteLength(body).toString();
1243
-
1244
1279
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
1245
- const controller = new AbortController();
1246
- const timeout = setTimeout(() => controller.abort(), 60000);
1247
-
1248
1280
  try {
1249
1281
  if (attempt > 1) {
1250
1282
  const delay = Math.min(initialDelay * Math.pow(backoffMultiplier, attempt - 2), 15000);
@@ -1252,28 +1284,23 @@ class EnviadorSII {
1252
1284
  await new Promise(resolve => setTimeout(resolve, delay));
1253
1285
  }
1254
1286
 
1255
- const response = await fetch(url, {
1256
- method: 'POST',
1287
+ const response = await this._siiPost(url, {
1257
1288
  headers: {
1258
1289
  'Cookie': `TOKEN=${this.tokenSoap}`,
1259
1290
  'Content-Type': `multipart/form-data; boundary=${boundary}`,
1260
1291
  'User-Agent': 'Mozilla/4.0 ( compatible; PROG 1.0; Windows NT)',
1261
1292
  'Accept': '*/*',
1262
1293
  'Connection': 'keep-alive',
1263
- 'Content-Length': contentLength,
1264
1294
  },
1265
1295
  body: body,
1266
- signal: controller.signal,
1296
+ timeoutMs: 60000,
1267
1297
  });
1268
- clearTimeout(timeout);
1269
1298
 
1270
- const responseText = await response.text();
1271
-
1272
- const result = this._parsearRespuestaSoap(responseText, response.ok, response.status, tipoEnvio);
1299
+ const result = this._parsearRespuestaSoap(response.text, response.ok, response.status, tipoEnvio);
1273
1300
 
1274
1301
  saveEnvioArtifacts({
1275
1302
  xml,
1276
- responseText,
1303
+ responseText: response.text,
1277
1304
  responseOk: response.ok,
1278
1305
  responseStatus: response.status,
1279
1306
  trackId: result.trackId,
@@ -1283,27 +1310,26 @@ class EnviadorSII {
1283
1310
 
1284
1311
  return result;
1285
1312
  } catch (fetchError) {
1286
- clearTimeout(timeout);
1287
1313
  lastError = fetchError;
1288
-
1314
+
1289
1315
  // Usar isRetryableError centralizado de utils
1290
1316
  if (isRetryableError(fetchError) && attempt < maxRetries) {
1291
- 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...`);
1292
1318
  continue;
1293
1319
  }
1294
-
1320
+
1295
1321
  saveEnvioArtifacts({
1296
1322
  xml,
1297
- responseText: `ERROR_FETCH: ${fetchError.message}`,
1323
+ responseText: `ERROR: ${fetchError.message}`,
1298
1324
  responseOk: false,
1299
1325
  responseStatus: null,
1300
1326
  trackId: null,
1301
1327
  ambiente: this.ambiente,
1302
1328
  tipoEnvio,
1303
- error: fetchError.cause || fetchError,
1329
+ error: fetchError,
1304
1330
  });
1305
1331
 
1306
- log.error('Fetch error details:', fetchError.cause || fetchError);
1332
+ log.error('Error en envío SII:', fetchError.message);
1307
1333
  throw fetchError;
1308
1334
  }
1309
1335
  }
@@ -1383,6 +1409,16 @@ class EnviadorSII {
1383
1409
  };
1384
1410
  }
1385
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
+
1386
1422
  // Duplicado real: SII ya recibió este SetDTE ID
1387
1423
  if (trackId) {
1388
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') {