@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/Certificado.js ADDED
@@ -0,0 +1,123 @@
1
+ // Copyright (c) 2026 Devlas SpA — https://devlas.cl
2
+ // Licencia MIT. Ver archivo LICENSE para mas detalles.
3
+ /**
4
+ * Certificado Digital
5
+ *
6
+ * Maneja certificados PFX/P12 para firma electrónica
7
+ */
8
+
9
+ const forge = require('node-forge');
10
+ const {
11
+ certError,
12
+ ERROR_CODES,
13
+ createScopedLogger,
14
+ loadPfxFromBuffer,
15
+ isCertificateExpired,
16
+ getDaysUntilExpiry,
17
+ } = require('./utils');
18
+
19
+ const log = createScopedLogger('Certificado');
20
+
21
+ class Certificado {
22
+ /**
23
+ * @param {Buffer} pfxBuffer - Contenido del archivo .pfx/.p12
24
+ * @param {string} password - Contraseña del certificado
25
+ * @throws {DteSiiError} Si el certificado es inválido o la contraseña es incorrecta
26
+ */
27
+ constructor(pfxBuffer, password) {
28
+ if (!pfxBuffer) {
29
+ throw certError('PFX buffer es requerido', ERROR_CODES.CERT_INVALID);
30
+ }
31
+
32
+ if (!password) {
33
+ throw certError('Password del certificado es requerido', ERROR_CODES.CERT_PASSWORD_WRONG);
34
+ }
35
+
36
+ // Usar utilidad centralizada para cargar PFX
37
+ const pfxData = loadPfxFromBuffer(pfxBuffer, password);
38
+
39
+ this.cert = pfxData.certificate;
40
+ this.privateKey = pfxData.privateKey;
41
+
42
+ // Verificar expiración usando utilidad centralizada
43
+ if (isCertificateExpired(pfxData.notAfter)) {
44
+ throw certError(
45
+ `Certificado expirado el ${pfxData.notAfter.toISOString()}`,
46
+ ERROR_CODES.CERT_EXPIRED,
47
+ { expiresAt: pfxData.notAfter }
48
+ );
49
+ }
50
+
51
+ // Advertir si está próximo a expirar
52
+ const daysUntilExpiry = getDaysUntilExpiry(pfxData.notAfter);
53
+ if (daysUntilExpiry <= 30) {
54
+ log.warn(`⚠️ Certificado expira en ${daysUntilExpiry} días`);
55
+ }
56
+
57
+ // Usar datos extraídos por la utilidad centralizada
58
+ this.rut = pfxData.rut;
59
+ this.nombre = pfxData.cn;
60
+
61
+ log.log('📜 Subject fields:', Object.entries(pfxData.subject).map(([k, v]) => `${k}: ${v}`).join(', '));
62
+ }
63
+
64
+ getPrivateKeyPem() {
65
+ return forge.pki.privateKeyToPem(this.privateKey);
66
+ }
67
+
68
+ getCertificatePem() {
69
+ return forge.pki.certificateToPem(this.cert);
70
+ }
71
+
72
+ // Alias para compatibilidad
73
+ getPrivateKeyPEM() {
74
+ return this.getPrivateKeyPem();
75
+ }
76
+
77
+ getCertificatePEM() {
78
+ return this.getCertificatePem();
79
+ }
80
+
81
+ /**
82
+ * Obtener certificado X509 en base64 (sin headers PEM)
83
+ */
84
+ getCertificateBase64() {
85
+ const pem = this.getCertificatePem();
86
+ return pem
87
+ .replace(/-----BEGIN CERTIFICATE-----/g, '')
88
+ .replace(/-----END CERTIFICATE-----/g, '')
89
+ .replace(/[\r\n\s]/g, '');
90
+ }
91
+
92
+ /**
93
+ * Obtener Modulus de la clave pública en base64
94
+ */
95
+ getModulus() {
96
+ let bytes = this.privateKey.n.toByteArray();
97
+ if (bytes[0] === 0) bytes = bytes.slice(1);
98
+ const str = bytes.map(b => String.fromCharCode(b < 0 ? b + 256 : b)).join('');
99
+ return forge.util.encode64(str);
100
+ }
101
+
102
+ /**
103
+ * Obtener Exponent de la clave pública en base64
104
+ */
105
+ getExponent() {
106
+ let bytes = this.privateKey.e.toByteArray();
107
+ if (bytes[0] === 0) bytes = bytes.slice(1);
108
+ const str = bytes.map(b => String.fromCharCode(b < 0 ? b + 256 : b)).join('');
109
+ return forge.util.encode64(str);
110
+ }
111
+
112
+ /**
113
+ * Firmar datos con SHA1withRSA
114
+ */
115
+ sign(data, encoding = 'utf8') {
116
+ const md = forge.md.sha1.create();
117
+ md.update(data, encoding);
118
+ const signature = this.privateKey.sign(md);
119
+ return forge.util.encode64(signature);
120
+ }
121
+ }
122
+
123
+ module.exports = Certificado;
@@ -0,0 +1,376 @@
1
+ // Copyright (c) 2026 Devlas SpA — https://devlas.cl
2
+ // Licencia MIT. Ver archivo LICENSE para mas detalles.
3
+ /**
4
+ * ConsumoFolio.js
5
+ *
6
+ * Genera RCOF (Resumen Consumo de Folios) para boletas electrónicas.
7
+ * Documento requerido por SII para informar los folios de boletas utilizados.
8
+ */
9
+
10
+ const crypto = require('crypto');
11
+ const { DOMParser } = require('@xmldom/xmldom');
12
+
13
+ class ConsumoFolio {
14
+ constructor(certificado) {
15
+ this.certificado = certificado;
16
+ this.caratula = null;
17
+ this.documentos = {
18
+ 39: [], // Boleta electrónica
19
+ 41: [], // Boleta exenta electrónica
20
+ };
21
+ this.xml = null;
22
+ this.id = null;
23
+ }
24
+
25
+ /**
26
+ * Establecer documentos desde objetos DTE
27
+ */
28
+ setDocumentos(tipo, documentos) {
29
+ if (tipo !== 39 && tipo !== 41) {
30
+ throw new Error('Tipo de documento debe ser 39 o 41');
31
+ }
32
+ this.documentos[tipo] = Array.isArray(documentos) ? documentos : [];
33
+ }
34
+
35
+ /**
36
+ * Agregar documento individual
37
+ */
38
+ agregar(tipo, dte) {
39
+ if (tipo !== 39 && tipo !== 41) {
40
+ throw new Error('Tipo de documento debe ser 39 o 41');
41
+ }
42
+ this.documentos[tipo].push(dte);
43
+ }
44
+
45
+ /**
46
+ * Agregar documentos desde un EnvioBoleta parseado
47
+ * @param {Object} envioBoleta - Objeto con { Tipo39: [], Tipo41: [] }
48
+ */
49
+ agregarDesdeEnvioBoleta(envioBoleta) {
50
+ if (envioBoleta.Tipo39) {
51
+ for (const doc of envioBoleta.Tipo39) {
52
+ this.agregar(39, doc);
53
+ }
54
+ }
55
+ if (envioBoleta.Tipo41) {
56
+ for (const doc of envioBoleta.Tipo41) {
57
+ this.agregar(41, doc);
58
+ }
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Establecer carátula del RCOF
64
+ */
65
+ setCaratula(caratula) {
66
+ this.caratula = { ...caratula };
67
+ // Normalizar FchResol: DD-MM-YYYY → YYYY-MM-DD (xs:date)
68
+ if (this.caratula.FchResol) {
69
+ const m = String(this.caratula.FchResol).match(/^(\d{2})-(\d{2})-(\d{4})$/);
70
+ if (m) this.caratula.FchResol = `${m[3]}-${m[2]}-${m[1]}`;
71
+ }
72
+ this.caratula.FchInicio = this.caratula.FchInicio || this.getFechaEmisionInicial();
73
+ this.caratula.FchFinal = this.caratula.FchFinal || this.getFechaEmisionFinal();
74
+ // Generar TmstFirmaEnv si no viene (formato ISO 8601: YYYY-MM-DDTHH:MM:SS)
75
+ if (!this.caratula.TmstFirmaEnv) {
76
+ const now = new Date();
77
+ this.caratula.TmstFirmaEnv = now.toISOString().slice(0, 19);
78
+ }
79
+ this.id = `RCOF_${(caratula.RutEmisor || '').replace('-', '')}_${Date.now()}`;
80
+ }
81
+
82
+ getFechaEmisionInicial() {
83
+ const todas = [...this.documentos[39], ...this.documentos[41]];
84
+ if (todas.length === 0) return null;
85
+
86
+ const fechas = todas.map(d => {
87
+ if (d.Encabezado?.IdDoc?.FchEmis) return d.Encabezado.IdDoc.FchEmis;
88
+ if (d.FchEmis) return d.FchEmis;
89
+ return null;
90
+ }).filter(f => f);
91
+
92
+ if (fechas.length === 0) return null;
93
+ fechas.sort();
94
+ return fechas[0];
95
+ }
96
+
97
+ getFechaEmisionFinal() {
98
+ const todas = [...this.documentos[39], ...this.documentos[41]];
99
+ if (todas.length === 0) return null;
100
+
101
+ const fechas = todas.map(d => {
102
+ if (d.Encabezado?.IdDoc?.FchEmis) return d.Encabezado.IdDoc.FchEmis;
103
+ if (d.FchEmis) return d.FchEmis;
104
+ return null;
105
+ }).filter(f => f);
106
+
107
+ if (fechas.length === 0) return null;
108
+ fechas.sort();
109
+ return fechas[fechas.length - 1];
110
+ }
111
+
112
+ /**
113
+ * Obtener resumen por tipo de documento
114
+ */
115
+ getResumen() {
116
+ const resumenes = [];
117
+
118
+ for (const tipo of [39, 41]) {
119
+ const docs = this.documentos[tipo];
120
+ if (docs.length === 0) continue;
121
+
122
+ const resumen = {
123
+ TipoDocumento: tipo,
124
+ MntNeto: 0,
125
+ MntIva: 0,
126
+ TasaIVA: 19,
127
+ MntExento: 0,
128
+ MntTotal: 0,
129
+ FoliosEmitidos: docs.length,
130
+ FoliosAnulados: 0,
131
+ FoliosUtilizados: docs.length,
132
+ RangoUtilizados: this.getRangos(docs),
133
+ };
134
+
135
+ for (const doc of docs) {
136
+ const totales = doc.Encabezado?.Totales || doc;
137
+ resumen.MntNeto += totales.MntNeto || 0;
138
+ resumen.MntIva += totales.IVA || 0;
139
+ resumen.MntExento += totales.MntExe || 0;
140
+ resumen.MntTotal += totales.MntTotal || 0;
141
+ }
142
+
143
+ // Boleta exenta no tiene IVA
144
+ if (tipo === 41) {
145
+ resumen.MntNeto = 0;
146
+ resumen.MntIva = 0;
147
+ resumen.TasaIVA = 0;
148
+ }
149
+
150
+ resumenes.push(resumen);
151
+ }
152
+
153
+ return resumenes;
154
+ }
155
+
156
+ /**
157
+ * Obtener rangos de folios utilizados
158
+ */
159
+ getRangos(docs) {
160
+ const folios = docs.map(d => {
161
+ if (d.Encabezado?.IdDoc?.Folio) return d.Encabezado.IdDoc.Folio;
162
+ if (d.Folio) return d.Folio;
163
+ return null;
164
+ }).filter(f => f !== null).sort((a, b) => a - b);
165
+
166
+ if (folios.length === 0) return [];
167
+
168
+ const rangos = [];
169
+ let inicio = folios[0];
170
+ let fin = folios[0];
171
+
172
+ for (let i = 1; i < folios.length; i++) {
173
+ if (folios[i] === fin + 1) {
174
+ fin = folios[i];
175
+ } else {
176
+ rangos.push({ Inicial: inicio, Final: fin });
177
+ inicio = folios[i];
178
+ fin = folios[i];
179
+ }
180
+ }
181
+ rangos.push({ Inicial: inicio, Final: fin });
182
+
183
+ return rangos;
184
+ }
185
+
186
+ /**
187
+ * Generar XML del Consumo de Folios
188
+ * Genera XML SIN indentación para C14N consistente
189
+ */
190
+ generar() {
191
+ if (!this.caratula) {
192
+ throw new Error('Debe establecer la carátula antes de generar');
193
+ }
194
+
195
+ const resumenes = this.getResumen();
196
+
197
+ // Construir XML de resúmenes SIN indentación (para C14N limpio)
198
+ let resumenXml = '';
199
+ for (const r of resumenes) {
200
+ resumenXml += '<Resumen>';
201
+ resumenXml += `<TipoDocumento>${r.TipoDocumento}</TipoDocumento>`;
202
+
203
+ if (r.MntNeto) resumenXml += `<MntNeto>${r.MntNeto}</MntNeto>`;
204
+ if (r.MntIva) resumenXml += `<MntIva>${r.MntIva}</MntIva>`;
205
+ if (r.TasaIVA) resumenXml += `<TasaIVA>${r.TasaIVA}</TasaIVA>`;
206
+ if (r.MntExento) resumenXml += `<MntExento>${r.MntExento}</MntExento>`;
207
+
208
+ resumenXml += `<MntTotal>${r.MntTotal}</MntTotal>`;
209
+ resumenXml += `<FoliosEmitidos>${r.FoliosEmitidos}</FoliosEmitidos>`;
210
+ resumenXml += `<FoliosAnulados>${r.FoliosAnulados}</FoliosAnulados>`;
211
+ resumenXml += `<FoliosUtilizados>${r.FoliosUtilizados}</FoliosUtilizados>`;
212
+
213
+ if (r.RangoUtilizados && r.RangoUtilizados.length > 0) {
214
+ for (const rango of r.RangoUtilizados) {
215
+ resumenXml += '<RangoUtilizados>';
216
+ resumenXml += `<Inicial>${rango.Inicial}</Inicial>`;
217
+ resumenXml += `<Final>${rango.Final}</Final>`;
218
+ resumenXml += '</RangoUtilizados>';
219
+ }
220
+ }
221
+
222
+ resumenXml += '</Resumen>';
223
+ }
224
+
225
+ // Construir XML SIN indentación para C14N consistente
226
+ const schemaLoc = 'http://www.sii.cl/SiiDte ConsumoFolio_v10.xsd';
227
+ const xmlSinFirma = `<?xml version="1.0" encoding="ISO-8859-1"?>
228
+ <ConsumoFolios xmlns="http://www.sii.cl/SiiDte" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="${schemaLoc}" version="1.0"><DocumentoConsumoFolios ID="${this.id}"><Caratula version="1.0"><RutEmisor>${this.caratula.RutEmisor}</RutEmisor><RutEnvia>${this.caratula.RutEnvia}</RutEnvia><FchResol>${this.caratula.FchResol}</FchResol><NroResol>${this.caratula.NroResol}</NroResol><FchInicio>${this.caratula.FchInicio}</FchInicio><FchFinal>${this.caratula.FchFinal}</FchFinal><SecEnvio>${this.caratula.SecEnvio}</SecEnvio><TmstFirmaEnv>${this.caratula.TmstFirmaEnv}</TmstFirmaEnv></Caratula>${resumenXml}</DocumentoConsumoFolios></ConsumoFolios>`;
229
+
230
+ // Firmar el documento
231
+ this.xml = this.firmar(xmlSinFirma);
232
+ return this.xml;
233
+ }
234
+
235
+ /**
236
+ * Firmar el XML del Consumo de Folios
237
+ *
238
+ * Estrategia: usar C14N con namespaces heredados como PHP DOMElement::C14N()
239
+ */
240
+ firmar(xml) {
241
+ const parser = new DOMParser();
242
+ const doc = parser.parseFromString(xml, 'text/xml');
243
+
244
+ // Obtener el elemento a firmar
245
+ const documentoConsumoFolios = doc.getElementsByTagName('DocumentoConsumoFolios')[0];
246
+
247
+ // Obtener namespace del padre (ConsumoFolios)
248
+ const root = doc.getElementsByTagName('ConsumoFolios')[0];
249
+ const nsDefault = root.getAttribute('xmlns') || 'http://www.sii.cl/SiiDte';
250
+
251
+ // Calcular C14N manual (incluye namespace heredado como PHP)
252
+ const c14nDocumento = this._c14nDocumentoConsumoFolios(documentoConsumoFolios, nsDefault);
253
+
254
+ console.log('🔍 C14N Length:', c14nDocumento.length);
255
+ console.log('🔍 C14N primeros 300 chars:', c14nDocumento.substring(0, 300));
256
+
257
+ // Calcular DigestValue = base64(sha1(C14N))
258
+ const digest = crypto.createHash('sha1').update(c14nDocumento).digest('base64');
259
+ console.log('🔍 DigestValue:', digest);
260
+
261
+ // Construir SignedInfo (con tags expandidos para firmar)
262
+ // PHP incluye xmlns:xsi en SignedInfo para ConsumoFolio
263
+ const signedInfoParaFirmar = `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"></CanonicalizationMethod><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"></SignatureMethod><Reference URI="#${this.id}"><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"></Transform></Transforms><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"></DigestMethod><DigestValue>${digest}</DigestValue></Reference></SignedInfo>`;
264
+
265
+ // Firmar SignedInfo con RSA-SHA1
266
+ const sign = crypto.createSign('RSA-SHA1');
267
+ sign.update(signedInfoParaFirmar);
268
+ const signatureValue = sign.sign(this.certificado.getPrivateKeyPem(), 'base64');
269
+ const formattedSignatureValue = signatureValue.match(/.{1,76}/g).join('\n');
270
+
271
+ // Obtener datos del certificado
272
+ const certBase64 = this.certificado.getCertificatePem()
273
+ .replace('-----BEGIN CERTIFICATE-----', '')
274
+ .replace('-----END CERTIFICATE-----', '')
275
+ .replace(/\s/g, '');
276
+ const modulus = this.certificado.getModulus();
277
+ const exponent = this.certificado.getExponent();
278
+
279
+ // Construir Signature (con self-closing tags para guardar)
280
+ const signedInfoParaGuardar = `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/><Reference URI="#${this.id}"><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/></Transforms><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><DigestValue>${digest}</DigestValue></Reference></SignedInfo>`;
281
+
282
+ const signatureXml = `<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">${signedInfoParaGuardar}<SignatureValue>\n${formattedSignatureValue}</SignatureValue><KeyInfo><KeyValue><RSAKeyValue><Modulus>${modulus}</Modulus><Exponent>${exponent}</Exponent></RSAKeyValue></KeyValue><X509Data><X509Certificate>${certBase64}</X509Certificate></X509Data></KeyInfo></Signature>`;
283
+
284
+ // Insertar firma DESPUÉS de DocumentoConsumoFolios, ANTES del cierre de ConsumoFolios
285
+ const xmlFirmado = xml.replace('</DocumentoConsumoFolios></ConsumoFolios>', `</DocumentoConsumoFolios>${signatureXml}</ConsumoFolios>`);
286
+
287
+ return xmlFirmado;
288
+ }
289
+
290
+ /**
291
+ * Calcular C14N del DocumentoConsumoFolios
292
+ *
293
+ * PHP DOMElement::C14N() incluye TODOS los namespaces en ámbito (in-scope),
294
+ * no solo los que están en uso directo.
295
+ */
296
+ _c14nDocumentoConsumoFolios(elemento, nsDefault) {
297
+ const id = elemento.getAttribute('ID');
298
+
299
+ // Construir apertura con AMBOS namespaces heredados (como PHP C14N)
300
+ let c14n = `<DocumentoConsumoFolios`;
301
+ c14n += ` xmlns="${nsDefault}"`;
302
+ c14n += ` xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"`;
303
+ c14n += ` ID="${id}">`;
304
+
305
+ // Serializar contenido recursivamente
306
+ for (let i = 0; i < elemento.childNodes.length; i++) {
307
+ const child = elemento.childNodes[i];
308
+ if (child.nodeType === 1) { // Element
309
+ c14n += this._serializeElementForC14N(child);
310
+ } else if (child.nodeType === 3) { // Text
311
+ c14n += this._escapeXmlText(child.nodeValue);
312
+ }
313
+ }
314
+
315
+ c14n += '</DocumentoConsumoFolios>';
316
+ return c14n;
317
+ }
318
+
319
+ /**
320
+ * Serializar elemento para C14N (sin repetir namespaces del padre)
321
+ */
322
+ _serializeElementForC14N(elemento) {
323
+ const tagName = elemento.tagName || elemento.nodeName;
324
+ let result = `<${tagName}`;
325
+
326
+ // Agregar atributos (excepto xmlns que ya está en el padre)
327
+ const attrs = [];
328
+ for (let i = 0; i < elemento.attributes.length; i++) {
329
+ const attr = elemento.attributes[i];
330
+ if (attr.name !== 'xmlns' && attr.name !== 'xmlns:xsi') {
331
+ attrs.push({ name: attr.name, value: attr.value });
332
+ }
333
+ }
334
+ // Ordenar atributos alfabéticamente (requisito C14N)
335
+ attrs.sort((a, b) => a.name.localeCompare(b.name));
336
+ for (const attr of attrs) {
337
+ result += ` ${attr.name}="${this._escapeXmlAttr(attr.value)}"`;
338
+ }
339
+
340
+ result += '>';
341
+
342
+ // Serializar hijos
343
+ for (let i = 0; i < elemento.childNodes.length; i++) {
344
+ const child = elemento.childNodes[i];
345
+ if (child.nodeType === 1) {
346
+ result += this._serializeElementForC14N(child);
347
+ } else if (child.nodeType === 3) {
348
+ result += this._escapeXmlText(child.nodeValue);
349
+ }
350
+ }
351
+
352
+ result += `</${tagName}>`;
353
+ return result;
354
+ }
355
+
356
+ _escapeXmlText(text) {
357
+ return text
358
+ .replace(/&/g, '&amp;')
359
+ .replace(/</g, '&lt;')
360
+ .replace(/>/g, '&gt;');
361
+ }
362
+
363
+ _escapeXmlAttr(text) {
364
+ return text
365
+ .replace(/&/g, '&amp;')
366
+ .replace(/</g, '&lt;')
367
+ .replace(/>/g, '&gt;')
368
+ .replace(/"/g, '&quot;');
369
+ }
370
+
371
+ getXML() {
372
+ return this.xml;
373
+ }
374
+ }
375
+
376
+ module.exports = ConsumoFolio;