@devlas/dte-sii 2.5.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.
Files changed (60) hide show
  1. package/BoletaService.js +109 -0
  2. package/CAF.js +173 -0
  3. package/CafSolicitor.js +380 -0
  4. package/Certificado.js +123 -0
  5. package/ConsumoFolio.js +376 -0
  6. package/DTE.js +399 -0
  7. package/EnviadorSII.js +1304 -0
  8. package/Envio.js +196 -0
  9. package/FolioRegistry.js +553 -0
  10. package/FolioService.js +703 -0
  11. package/LICENSE +27 -0
  12. package/LibroBase.js +134 -0
  13. package/LibroCompraVenta.js +205 -0
  14. package/LibroGuia.js +225 -0
  15. package/README.md +239 -0
  16. package/Signer.js +94 -0
  17. package/SiiCertificacion.js +1189 -0
  18. package/SiiPortalAuth.js +460 -0
  19. package/SiiSession.js +499 -0
  20. package/cert/BoletaCert.js +731 -0
  21. package/cert/CertFolioHelper.js +185 -0
  22. package/cert/CertRunner.js +2658 -0
  23. package/cert/ConfigLoader.js +133 -0
  24. package/cert/IntercambioCert.js +429 -0
  25. package/cert/LibroCompras.js +359 -0
  26. package/cert/LibroGuias.js +171 -0
  27. package/cert/LibroVentas.js +153 -0
  28. package/cert/MuestrasImpresas.js +676 -0
  29. package/cert/SetBase.js +321 -0
  30. package/cert/SetBasico.js +413 -0
  31. package/cert/SetCompra.js +472 -0
  32. package/cert/SetExenta.js +490 -0
  33. package/cert/SetGuia.js +283 -0
  34. package/cert/SetParser.js +1184 -0
  35. package/cert/SetsProvider.js +499 -0
  36. package/cert/Simulacion.js +521 -0
  37. package/cert/comunaOficina.js +460 -0
  38. package/cert/index.js +124 -0
  39. package/cert/types.js +330 -0
  40. package/dte-sii.d.ts +458 -0
  41. package/index.js +428 -0
  42. package/package.json +48 -0
  43. package/utils/c14n.js +275 -0
  44. package/utils/calculo.js +396 -0
  45. package/utils/config.js +276 -0
  46. package/utils/constants.js +302 -0
  47. package/utils/emisor.js +174 -0
  48. package/utils/endpoints.js +225 -0
  49. package/utils/error.js +235 -0
  50. package/utils/index.js +339 -0
  51. package/utils/logger.js +239 -0
  52. package/utils/pfx.js +203 -0
  53. package/utils/receptor.js +218 -0
  54. package/utils/referencia.js +169 -0
  55. package/utils/resolucion.js +119 -0
  56. package/utils/rut.js +169 -0
  57. package/utils/sanitize.js +124 -0
  58. package/utils/tokenCache.js +214 -0
  59. package/utils/xml.js +358 -0
  60. package/utils.js +4 -0
package/utils/c14n.js ADDED
@@ -0,0 +1,275 @@
1
+ // Copyright (c) 2026 Devlas SpA — https://devlas.cl
2
+ // Licencia MIT. Ver archivo LICENSE para mas detalles.
3
+ /**
4
+ * Módulo de Canonicalización XML (C14N)
5
+ *
6
+ * Funciones compartidas para serialización canónica de XML
7
+ * según el estándar W3C Canonical XML Version 1.0.
8
+ *
9
+ * Usado por: DTE.js, Signer.js, LibroBase.js, ConsumoFolio.js
10
+ */
11
+
12
+ /**
13
+ * Escapa atributos XML según C14N
14
+ * @param {string} value - Valor del atributo
15
+ * @returns {string} - Valor escapado
16
+ */
17
+ function escapeAttr(value) {
18
+ return (value || '')
19
+ .replace(/&/g, '&')
20
+ .replace(/</g, '&lt;')
21
+ .replace(/"/g, '&quot;')
22
+ .replace(/\t/g, '&#x9;')
23
+ .replace(/\n/g, '&#xA;')
24
+ .replace(/\r/g, '&#xD;');
25
+ }
26
+
27
+ /**
28
+ * Escapa contenido de texto XML según C14N
29
+ * @param {string} text - Contenido de texto
30
+ * @returns {string} - Texto escapado
31
+ */
32
+ function escapeText(text) {
33
+ return (text || '')
34
+ .replace(/&/g, '&amp;')
35
+ .replace(/</g, '&lt;')
36
+ .replace(/>/g, '&gt;')
37
+ .replace(/\r/g, '&#xD;');
38
+ }
39
+
40
+ /**
41
+ * Corrige entidades en contenido XML (apóstrofes y comillas)
42
+ * @param {string} xml - XML a procesar
43
+ * @returns {string} - XML con entidades corregidas
44
+ */
45
+ function fixEntities(xml) {
46
+ let out = '';
47
+ let inContent = false;
48
+ for (let i = 0; i < xml.length; i++) {
49
+ const ch = xml[i];
50
+ if (ch === '>') inContent = true;
51
+ if (ch === '<') inContent = false;
52
+ if (inContent && ch === "'") { out += '&apos;'; continue; }
53
+ if (inContent && ch === '"') { out += '&quot;'; continue; }
54
+ out += ch;
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * Serializa un nodo DOM a XML canónico
61
+ * @param {Node} node - Nodo DOM a serializar
62
+ * @param {Map} inheritedNs - Namespaces heredados
63
+ * @param {Object} options - Opciones
64
+ * @param {boolean} [options.omitNsFromChildren=false] - No incluir xmlns en hijos
65
+ * @returns {string} - XML serializado
66
+ */
67
+ function serializeNode(node, inheritedNs, options = {}) {
68
+ const { omitNsFromChildren = false } = options;
69
+
70
+ // Nodo de texto
71
+ if (node.nodeType === 3) {
72
+ return escapeText(node.nodeValue || '');
73
+ }
74
+
75
+ // Solo elementos
76
+ if (node.nodeType !== 1) return '';
77
+
78
+ const tagName = node.tagName || node.nodeName;
79
+ const currentNs = new Map(inheritedNs);
80
+ let result = `<${tagName}`;
81
+
82
+ // Recoger y ordenar atributos
83
+ const attrs = [];
84
+ for (let i = 0; i < node.attributes.length; i++) {
85
+ const attr = node.attributes[i];
86
+
87
+ // Manejar namespaces
88
+ if (attr.name.startsWith('xmlns')) {
89
+ // Si ya está en el contexto heredado con el mismo valor, omitir
90
+ if (currentNs.get(attr.name) === attr.value) continue;
91
+ // Opción: omitir xmlns de hijos
92
+ if (omitNsFromChildren && (attr.name === 'xmlns' || attr.name === 'xmlns:xsi')) continue;
93
+ currentNs.set(attr.name, attr.value);
94
+ }
95
+
96
+ attrs.push({ name: attr.name, value: attr.value });
97
+ }
98
+
99
+ // Ordenar: xmlns primero, luego xmlns:*, luego otros alfabéticamente
100
+ attrs.sort((a, b) => {
101
+ if (a.name === 'xmlns') return -1;
102
+ if (b.name === 'xmlns') return 1;
103
+ const aXmlns = a.name.startsWith('xmlns:');
104
+ const bXmlns = b.name.startsWith('xmlns:');
105
+ if (aXmlns && !bXmlns) return -1;
106
+ if (!aXmlns && bXmlns) return 1;
107
+ return a.name.localeCompare(b.name);
108
+ });
109
+
110
+ for (const attr of attrs) {
111
+ result += ` ${attr.name}="${escapeAttr(attr.value)}"`;
112
+ }
113
+ result += '>';
114
+
115
+ // Serializar hijos
116
+ for (let i = 0; i < node.childNodes.length; i++) {
117
+ result += serializeNode(node.childNodes[i], currentNs, options);
118
+ }
119
+
120
+ result += `</${tagName}>`;
121
+ return result;
122
+ }
123
+
124
+ /**
125
+ * Serializa un elemento con sus hijos para C14N (versión simplificada para Libros)
126
+ * @param {Element} elemento - Elemento DOM
127
+ * @param {Object} options - Opciones
128
+ * @param {boolean} [options.omitRootNs=false] - Omitir xmlns/xmlns:xsi del elemento
129
+ * @returns {string} - XML serializado
130
+ */
131
+ function serializeElement(elemento, options = {}) {
132
+ const { omitRootNs = false } = options;
133
+ const tagName = elemento.tagName || elemento.nodeName;
134
+ let result = `<${tagName}`;
135
+
136
+ // Recoger y ordenar atributos
137
+ const attrs = [];
138
+ for (let i = 0; i < elemento.attributes.length; i++) {
139
+ const attr = elemento.attributes[i];
140
+ // Opcionalmente omitir xmlns del root
141
+ if (omitRootNs && (attr.name === 'xmlns' || attr.name === 'xmlns:xsi')) continue;
142
+ attrs.push({ name: attr.name, value: attr.value });
143
+ }
144
+
145
+ attrs.sort((a, b) => a.name.localeCompare(b.name));
146
+
147
+ for (const attr of attrs) {
148
+ result += ` ${attr.name}="${escapeAttr(attr.value)}"`;
149
+ }
150
+ result += '>';
151
+
152
+ // Serializar hijos
153
+ for (let i = 0; i < elemento.childNodes.length; i++) {
154
+ const child = elemento.childNodes[i];
155
+ if (child.nodeType === 1) {
156
+ result += serializeElement(child, { omitRootNs: false });
157
+ } else if (child.nodeType === 3) {
158
+ result += escapeText(child.nodeValue);
159
+ }
160
+ }
161
+
162
+ result += `</${tagName}>`;
163
+ return result;
164
+ }
165
+
166
+ /**
167
+ * Canonicaliza un elemento raíz con namespaces específicos
168
+ * @param {Element} elemento - Elemento DOM raíz
169
+ * @param {Object} config - Configuración
170
+ * @param {string} config.tagName - Nombre del tag raíz
171
+ * @param {string} config.id - ID del elemento
172
+ * @param {string} config.xmlns - Namespace por defecto
173
+ * @param {string} [config.xmlnsXsi] - Namespace XSI (opcional)
174
+ * @returns {string} - XML canonicalizado
175
+ */
176
+ function canonicalizeRoot(elemento, config) {
177
+ const { tagName, id, xmlns, xmlnsXsi } = config;
178
+
179
+ let c14n = `<${tagName}`;
180
+ if (xmlns) c14n += ` xmlns="${xmlns}"`;
181
+ if (xmlnsXsi) c14n += ` xmlns:xsi="${xmlnsXsi}"`;
182
+ if (id) c14n += ` ID="${id}"`;
183
+ c14n += '>';
184
+
185
+ // Serializar hijos (sin heredar xmlns del padre)
186
+ for (let i = 0; i < elemento.childNodes.length; i++) {
187
+ const child = elemento.childNodes[i];
188
+ if (child.nodeType === 1) {
189
+ c14n += serializeElement(child, { omitRootNs: true });
190
+ } else if (child.nodeType === 3) {
191
+ c14n += escapeText(child.nodeValue);
192
+ }
193
+ }
194
+
195
+ c14n += `</${tagName}>`;
196
+ return c14n;
197
+ }
198
+
199
+ // ============================================
200
+ // XML-DSig SignedInfo / Signature Helpers
201
+ // ============================================
202
+
203
+ /**
204
+ * Constantes XML-DSig
205
+ */
206
+ const XMLDSIG_NS = 'http://www.w3.org/2000/09/xmldsig#';
207
+ const C14N_ALGORITHM = 'http://www.w3.org/TR/2001/REC-xml-c14n-20010315';
208
+ const RSA_SHA1_ALGORITHM = 'http://www.w3.org/2000/09/xmldsig#rsa-sha1';
209
+ const SHA1_ALGORITHM = 'http://www.w3.org/2000/09/xmldsig#sha1';
210
+ const ENVELOPED_SIGNATURE = 'http://www.w3.org/2000/09/xmldsig#enveloped-signature';
211
+ const XSI_NS = 'http://www.w3.org/2001/XMLSchema-instance';
212
+
213
+ /**
214
+ * Construye el elemento SignedInfo para firma XML-DSig
215
+ * @param {string} refId - ID del elemento referenciado (con o sin #)
216
+ * @param {string} digestValue - DigestValue calculado en base64
217
+ * @param {Object} options - Opciones de construcción
218
+ * @param {boolean} [options.expandTags=false] - Expandir tags vacíos (para cálculo de firma)
219
+ * @param {boolean} [options.includeXsi=true] - Incluir xmlns:xsi
220
+ * @returns {string} - SignedInfo XML
221
+ */
222
+ function buildSignedInfo(refId, digestValue, options = {}) {
223
+ const { expandTags = false, includeXsi = true } = options;
224
+
225
+ // Normalizar refId (agregar # si no lo tiene)
226
+ const uri = refId.startsWith('#') ? refId : `#${refId}`;
227
+
228
+ const xmlns = `xmlns="${XMLDSIG_NS}"`;
229
+ const xsi = includeXsi ? ` xmlns:xsi="${XSI_NS}"` : '';
230
+
231
+ if (expandTags) {
232
+ // Tags expandidos: <Tag></Tag> - usado para calcular firma
233
+ return `<SignedInfo ${xmlns}${xsi}><CanonicalizationMethod Algorithm="${C14N_ALGORITHM}"></CanonicalizationMethod><SignatureMethod Algorithm="${RSA_SHA1_ALGORITHM}"></SignatureMethod><Reference URI="${uri}"><Transforms><Transform Algorithm="${ENVELOPED_SIGNATURE}"></Transform></Transforms><DigestMethod Algorithm="${SHA1_ALGORITHM}"></DigestMethod><DigestValue>${digestValue}</DigestValue></Reference></SignedInfo>`;
234
+ }
235
+
236
+ // Tags compactos: <Tag/> - usado para guardar en XML
237
+ return `<SignedInfo ${xmlns}${xsi}><CanonicalizationMethod Algorithm="${C14N_ALGORITHM}"/><SignatureMethod Algorithm="${RSA_SHA1_ALGORITHM}"/><Reference URI="${uri}"><Transforms><Transform Algorithm="${ENVELOPED_SIGNATURE}"/></Transforms><DigestMethod Algorithm="${SHA1_ALGORITHM}"/><DigestValue>${digestValue}</DigestValue></Reference></SignedInfo>`;
238
+ }
239
+
240
+ /**
241
+ * Construye la Signature completa XML-DSig
242
+ * @param {string} signedInfo - SignedInfo XML (formato compacto)
243
+ * @param {string} signatureValue - Valor de firma en base64
244
+ * @param {Object} keyInfo - Información de la clave
245
+ * @param {string} keyInfo.modulus - Modulus RSA en base64
246
+ * @param {string} keyInfo.exponent - Exponent RSA en base64
247
+ * @param {string} keyInfo.certificate - Certificado X509 en base64
248
+ * @returns {string} - Signature XML completa
249
+ */
250
+ function buildSignature(signedInfo, signatureValue, keyInfo) {
251
+ const { modulus, exponent, certificate } = keyInfo;
252
+
253
+ return `<Signature xmlns="${XMLDSIG_NS}">${signedInfo}<SignatureValue>\n${signatureValue}</SignatureValue><KeyInfo><KeyValue><RSAKeyValue><Modulus>${modulus}</Modulus><Exponent>${exponent}</Exponent></RSAKeyValue></KeyValue><X509Data><X509Certificate>${certificate}</X509Certificate></X509Data></KeyInfo></Signature>`;
254
+ }
255
+
256
+ module.exports = {
257
+ // Escape/Text functions
258
+ escapeAttr,
259
+ escapeText,
260
+ fixEntities,
261
+ // Serialization
262
+ serializeNode,
263
+ serializeElement,
264
+ canonicalizeRoot,
265
+ // XML-DSig helpers
266
+ buildSignedInfo,
267
+ buildSignature,
268
+ // Constantes
269
+ XMLDSIG_NS,
270
+ C14N_ALGORITHM,
271
+ RSA_SHA1_ALGORITHM,
272
+ SHA1_ALGORITHM,
273
+ ENVELOPED_SIGNATURE,
274
+ XSI_NS,
275
+ };
@@ -0,0 +1,396 @@
1
+ // Copyright (c) 2026 Devlas SpA — https://devlas.cl
2
+ // Licencia MIT. Ver archivo LICENSE para mas detalles.
3
+ /**
4
+ * Utilidades de Cálculo
5
+ *
6
+ * Funciones centralizadas para cálculo de totales y construcción de detalle DTE.
7
+ * Estas funciones son reutilizables tanto en certificación como en producción.
8
+ *
9
+ * @module dte-sii/utils/calculo
10
+ */
11
+
12
+ // Usar la constante centralizada de constants.js
13
+ const { TASA_IVA } = require('./constants');
14
+
15
+ // Alias para compatibilidad con código existente
16
+ const TASA_IVA_DEFAULT = TASA_IVA;
17
+
18
+ // ============================================
19
+ // FORMATEO
20
+ // ============================================
21
+
22
+ /**
23
+ * Formatea un número con decimales fijos
24
+ * @param {number} value - Valor a formatear
25
+ * @param {number} [decimals=6] - Cantidad de decimales
26
+ * @returns {string}
27
+ */
28
+ function formatDecimal(value, decimals = 6) {
29
+ return Number(value).toFixed(decimals);
30
+ }
31
+
32
+ /**
33
+ * Calcula el monto de un ítem con descuento
34
+ * @param {number} cantidad - Cantidad de unidades
35
+ * @param {number} precio - Precio unitario
36
+ * @param {number} [descuentoPct=0] - Porcentaje de descuento
37
+ * @returns {{ base: number, descuentoMonto: number, montoItem: number }}
38
+ */
39
+ function calcularMontoItem(cantidad, precio, descuentoPct = 0) {
40
+ const qty = Number(cantidad || 1);
41
+ const prc = Number(precio || 0);
42
+ const base = Math.round(qty * prc);
43
+ const descuentoMonto = descuentoPct > 0 ? Math.round(base * (descuentoPct / 100)) : 0;
44
+ const montoItem = base - descuentoMonto;
45
+ return { base, descuentoMonto, montoItem };
46
+ }
47
+
48
+ // ============================================
49
+ // CÁLCULO DE TOTALES
50
+ // ============================================
51
+
52
+ /**
53
+ * Calcula totales desde un array de items
54
+ * Función unificada que maneja todos los tipos de DTE
55
+ *
56
+ * @param {Array} items - Array de items con { cantidad, precio, exento?, descuentoPct? }
57
+ * @param {Object} [options] - Opciones de cálculo
58
+ * @param {number} [options.tasaIva=19] - Tasa de IVA
59
+ * @param {number} [options.descuentoGlobalPct=0] - Descuento global en porcentaje
60
+ * @param {boolean} [options.preciosNetos=true] - Si los precios son netos (sin IVA)
61
+ * @param {boolean} [options.soloExento=false] - Si el documento es solo exento (ej: tipo 34)
62
+ * @param {boolean} [options.conRetencion=false] - Si aplica retención total IVA (ej: tipo 46)
63
+ * @param {number} [options.tipoImpRetencion=15] - Tipo de impuesto para retención
64
+ * @returns {{ totales: Object, descuentoGlobalMonto: number }}
65
+ */
66
+ function calcularTotalesDesdeItems(items, optionsOrDescuento = {}) {
67
+ // Compatibilidad: si el segundo parámetro es número, es descuentoGlobalPct (API antigua)
68
+ const options = typeof optionsOrDescuento === 'number' || optionsOrDescuento === null
69
+ ? { descuentoGlobalPct: optionsOrDescuento || 0 }
70
+ : (optionsOrDescuento || {});
71
+
72
+ const {
73
+ tasaIva = TASA_IVA_DEFAULT,
74
+ descuentoGlobalPct = 0,
75
+ preciosNetos = true,
76
+ soloExento = false,
77
+ conRetencion = false,
78
+ tipoImpRetencion = 15,
79
+ } = options;
80
+
81
+ let mntAfecto = 0;
82
+ let mntExento = 0;
83
+
84
+ (items || []).forEach((item) => {
85
+ const { montoItem } = calcularMontoItem(
86
+ item.cantidad,
87
+ item.precio,
88
+ item.descuentoPct
89
+ );
90
+
91
+ if (item.exento || soloExento) {
92
+ mntExento += montoItem;
93
+ } else {
94
+ mntAfecto += montoItem;
95
+ }
96
+ });
97
+
98
+ // Aplicar descuento global solo a afectos
99
+ const descuentoGlobalMonto = descuentoGlobalPct > 0
100
+ ? Math.round(mntAfecto * (descuentoGlobalPct / 100))
101
+ : 0;
102
+
103
+ // Calcular neto
104
+ let mntNeto;
105
+ if (preciosNetos) {
106
+ mntNeto = Math.max(0, mntAfecto - descuentoGlobalMonto);
107
+ } else {
108
+ // Precios incluyen IVA - extraer neto
109
+ const afectoNeto = mntAfecto - descuentoGlobalMonto;
110
+ mntNeto = afectoNeto > 0 ? Math.round(afectoNeto / (1 + (tasaIva / 100))) : 0;
111
+ }
112
+
113
+ // Calcular IVA
114
+ const iva = mntNeto > 0 ? Math.round(mntNeto * (tasaIva / 100)) : 0;
115
+
116
+ // Calcular total
117
+ // Con retención: MntTotal = MntNeto + MntExe (IVA se cancela con retención)
118
+ // Sin retención: MntTotal = MntNeto + IVA + MntExe
119
+ const mntTotal = conRetencion
120
+ ? mntNeto + mntExento
121
+ : mntNeto + iva + mntExento;
122
+
123
+ // Construir objeto de totales en orden SII
124
+ const totales = {};
125
+ if (mntNeto > 0) totales.MntNeto = mntNeto;
126
+ if (mntExento > 0) totales.MntExe = mntExento;
127
+ if (mntNeto > 0) totales.TasaIVA = tasaIva;
128
+ if (mntNeto > 0) totales.IVA = iva;
129
+
130
+ // Retención de IVA (para Factura de Compra tipo 46)
131
+ if (conRetencion && iva > 0) {
132
+ totales.ImptoReten = [{
133
+ TipoImp: tipoImpRetencion,
134
+ TasaImp: tasaIva,
135
+ MontoImp: iva,
136
+ }];
137
+ }
138
+
139
+ totales.MntTotal = mntTotal;
140
+
141
+ return { totales, descuentoGlobalMonto };
142
+ }
143
+
144
+ /**
145
+ * Calcula totales desde un array de detalle ya construido
146
+ * Útil para Guías de Despacho y otros casos donde el detalle ya está armado
147
+ *
148
+ * @param {Array} detalle - Array de líneas de detalle con { MontoItem, IndExe? }
149
+ * @param {Object} [options] - Opciones de cálculo
150
+ * @param {number} [options.tasaIva=19] - Tasa de IVA
151
+ * @param {boolean} [options.preciosNetos=true] - Si los precios son netos
152
+ * @param {boolean} [options.conRetencion=false] - Si aplica retención de IVA
153
+ * @param {boolean} [options.sinValores=false] - Si es traslado sin valores (IndTraslado=5)
154
+ * @returns {Object} Totales para el DTE
155
+ */
156
+ function calcularTotalesDesdeDetalle(detalle, options = {}) {
157
+ const {
158
+ tasaIva = TASA_IVA_DEFAULT,
159
+ preciosNetos = true,
160
+ conRetencion = false,
161
+ sinValores = false,
162
+ } = options;
163
+
164
+ // Traslado sin valores
165
+ if (sinValores) {
166
+ return { TasaIVA: tasaIva, MntTotal: 0 };
167
+ }
168
+
169
+ let mntBruto = 0;
170
+ let mntExento = 0;
171
+
172
+ (detalle || []).forEach((det) => {
173
+ const monto = Number(det.MontoItem || 0);
174
+ if (det.IndExe === 1) {
175
+ mntExento += monto;
176
+ } else {
177
+ mntBruto += monto;
178
+ }
179
+ });
180
+
181
+ if (mntBruto === 0 && mntExento === 0) {
182
+ return { TasaIVA: tasaIva, MntTotal: 0 };
183
+ }
184
+
185
+ const mntNeto = preciosNetos
186
+ ? mntBruto
187
+ : (mntBruto > 0 ? Math.round(mntBruto / (1 + (tasaIva / 100))) : 0);
188
+
189
+ const iva = mntNeto > 0 ? Math.round(mntNeto * (tasaIva / 100)) : 0;
190
+
191
+ const mntTotal = conRetencion
192
+ ? mntNeto + mntExento
193
+ : mntNeto + iva + mntExento;
194
+
195
+ const totales = {};
196
+ if (mntNeto > 0) totales.MntNeto = mntNeto;
197
+ if (mntExento > 0) totales.MntExe = mntExento;
198
+ if (mntNeto > 0) totales.TasaIVA = tasaIva;
199
+ if (mntNeto > 0) totales.IVA = iva;
200
+
201
+ if (conRetencion && iva > 0) {
202
+ totales.ImptoReten = [{
203
+ TipoImp: 15,
204
+ TasaImp: tasaIva,
205
+ MontoImp: iva,
206
+ }];
207
+ }
208
+
209
+ totales.MntTotal = mntTotal;
210
+
211
+ return totales;
212
+ }
213
+
214
+ // ============================================
215
+ // CONSTRUCCIÓN DE DETALLE
216
+ // ============================================
217
+
218
+ /**
219
+ * Construye array de detalle para DTE desde items
220
+ * Función unificada que maneja todos los tipos de documentos
221
+ *
222
+ * @param {Array} items - Items con { nombre, cantidad?, precio?, unidad?, exento?, descuentoPct? }
223
+ * @param {Object} [options] - Opciones de construcción
224
+ * @param {boolean} [options.allowIndExe=true] - Agregar IndExe=1 para items exentos (default: true para compatibilidad)
225
+ * @param {number} [options.codImpAdic=null] - Código de impuesto adicional (ej: 15 para F. Compra)
226
+ * @param {boolean} [options.forcePriced=false] - Forzar QtyItem/PrcItem aunque precio sea 0
227
+ * @param {boolean} [options.includeUnidad=false] - Incluir UnmdItem siempre
228
+ * @param {Function} [options.sanitize=null] - Función para sanitizar texto
229
+ * @returns {Array} Detalle formateado para DTE
230
+ */
231
+ function buildDetalle(items, options = {}) {
232
+ const {
233
+ allowIndExe = true, // Por defecto true para mantener compatibilidad con código original
234
+ codImpAdic = null,
235
+ forcePriced = false,
236
+ includeUnidad = false,
237
+ sanitize = (v) => String(v || '').trim(),
238
+ } = options;
239
+
240
+ return (items || []).map((item, idx) => {
241
+ const qty = Number(item.cantidad ?? 1);
242
+ const prc = Number(item.precio ?? 0);
243
+ const descuentoPct = Number(item.descuentoPct || 0);
244
+ const nombreItem = sanitize(item.nombre);
245
+
246
+ const { base, descuentoMonto, montoItem } = calcularMontoItem(qty, prc, descuentoPct);
247
+
248
+ // Construir línea de detalle en orden del schema SII
249
+ const det = {
250
+ NroLinDet: idx + 1,
251
+ };
252
+
253
+ // IndExe antes de NmbItem
254
+ if (allowIndExe && item.exento) {
255
+ det.IndExe = 1;
256
+ }
257
+
258
+ det.NmbItem = nombreItem;
259
+
260
+ // QtyItem, UnmdItem, PrcItem
261
+ if (prc > 0 || forcePriced) {
262
+ det.QtyItem = qty;
263
+ if (item.unidad || includeUnidad) {
264
+ det.UnmdItem = item.unidad || 'UN';
265
+ }
266
+ det.PrcItem = prc > 0 ? formatDecimal(Math.max(0.000001, prc)) : prc;
267
+
268
+ // Descuento por línea
269
+ if (descuentoPct > 0) {
270
+ det.DescuentoPct = descuentoPct;
271
+ det.DescuentoMonto = descuentoMonto;
272
+ }
273
+ }
274
+
275
+ // CodImpAdic (antes de MontoItem según schema)
276
+ if (codImpAdic && !item.exento) {
277
+ det.CodImpAdic = codImpAdic;
278
+ }
279
+
280
+ det.MontoItem = montoItem;
281
+
282
+ return det;
283
+ });
284
+ }
285
+
286
+ /**
287
+ * Construye detalle para Guía de Despacho
288
+ * Similar a buildDetalle pero con lógica específica para guías
289
+ *
290
+ * @param {Array} items - Items de la guía
291
+ * @param {Object} [options] - Opciones
292
+ * @returns {Array} Detalle formateado
293
+ */
294
+ function buildDetalleGuia(items, options = {}) {
295
+ const { sanitize = (v) => String(v || '').trim() } = options;
296
+
297
+ return (items || []).map((item, idx) => {
298
+ const qty = Number(item.cantidad ?? 1);
299
+ const prc = item.precio !== undefined && item.precio !== null ? Number(item.precio) : null;
300
+ const monto = prc !== null ? Math.round(qty * prc) : (item.monto ?? undefined);
301
+
302
+ // Orden XSD: NroLinDet, CdgItem?, IndExe?, NmbItem, DscItem?, QtyRef?, UnmdRef?, PrcRef?, QtyItem?, UnmdItem?, PrcItem?, MontoItem?
303
+ const det = {
304
+ NroLinDet: idx + 1,
305
+ };
306
+
307
+ // IndExe va ANTES de NmbItem según XSD
308
+ if (item.exento) {
309
+ det.IndExe = 1;
310
+ }
311
+
312
+ det.NmbItem = sanitize(item.nombre);
313
+ det.QtyItem = qty;
314
+ det.UnmdItem = item.unidad || 'UN';
315
+
316
+ if (prc !== null && prc > 0) {
317
+ det.PrcItem = prc;
318
+ }
319
+
320
+ if (monto !== undefined) {
321
+ det.MontoItem = monto;
322
+ }
323
+
324
+ return det;
325
+ });
326
+ }
327
+
328
+ /**
329
+ * Construye detalle para Factura de Compra (tipo 46)
330
+ * Similar a buildDetalle pero siempre incluye unidad y soporta CodImpAdic
331
+ *
332
+ * @param {Array} items - Items de la factura de compra
333
+ * @param {Object} [options] - Opciones
334
+ * @param {number} [options.codImpAdic=15] - Código impuesto adicional (15 = IVA Retenido Total)
335
+ * @param {Function} [options.sanitize] - Función para sanitizar texto
336
+ * @returns {Array} Detalle formateado
337
+ */
338
+ function buildDetalleCompra(items, options = {}) {
339
+ const { codImpAdic = 15, sanitize = (v) => String(v || '').trim() } = options;
340
+
341
+ return buildDetalle(items, {
342
+ allowIndExe: true,
343
+ codImpAdic,
344
+ forcePriced: true,
345
+ includeUnidad: true,
346
+ sanitize,
347
+ });
348
+ }
349
+
350
+ // ============================================
351
+ // DESCUENTO/RECARGO GLOBAL
352
+ // ============================================
353
+
354
+ /**
355
+ * Construye estructura DscRcgGlobal para descuento global
356
+ *
357
+ * @param {number} descuentoPct - Porcentaje de descuento
358
+ * @param {string} [glosa='DESCUENTO GLOBAL ITEMES AFECTOS'] - Descripción
359
+ * @returns {Array|null} Estructura DscRcgGlobal o null si no hay descuento
360
+ */
361
+ function buildDescuentoGlobal(descuentoPct, glosa = 'DESCUENTO GLOBAL ITEMES AFECTOS') {
362
+ if (!descuentoPct || descuentoPct <= 0) return null;
363
+
364
+ return [{
365
+ NroLinDR: 1,
366
+ TpoMov: 'D',
367
+ GlosaDR: glosa,
368
+ TpoValor: '%',
369
+ ValorDR: descuentoPct,
370
+ }];
371
+ }
372
+
373
+ // ============================================
374
+ // EXPORTS
375
+ // ============================================
376
+
377
+ module.exports = {
378
+ // Constantes
379
+ TASA_IVA_DEFAULT,
380
+
381
+ // Formateo
382
+ formatDecimal,
383
+ calcularMontoItem,
384
+
385
+ // Cálculo de totales
386
+ calcularTotalesDesdeItems,
387
+ calcularTotalesDesdeDetalle,
388
+
389
+ // Construcción de detalle
390
+ buildDetalle,
391
+ buildDetalleGuia,
392
+ buildDetalleCompra,
393
+
394
+ // Descuento global
395
+ buildDescuentoGlobal,
396
+ };