diff --git a/src/services/cfdi-parser.js b/src/services/cfdi-parser.js new file mode 100644 index 0000000..afcda4b --- /dev/null +++ b/src/services/cfdi-parser.js @@ -0,0 +1,49 @@ +const fs = require('fs'); +const { XMLParser } = require('fast-xml-parser'); + +const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + isArray: (name) => { + return name === 'cfdi:Concepto'; + }, +}); + +function parseCfdiFile(filePath) { + const xml = fs.readFileSync(filePath, 'utf-8'); + return parseCfdiXml(xml); +} + +function parseCfdiXml(xml) { + const parsed = parser.parse(xml); + const comprobante = parsed['cfdi:Comprobante']; + + const emisor = comprobante['cfdi:Emisor']; + const conceptosNode = comprobante['cfdi:Conceptos']['cfdi:Concepto']; + const complemento = comprobante['cfdi:Complemento']; + + const timbre = complemento['tfd:TimbreFiscalDigital']; + const uuid = timbre['@_UUID']; + + const conceptosList = Array.isArray(conceptosNode) ? conceptosNode : [conceptosNode]; + const conceptos = conceptosList.map((c) => ({ + claveProdServ: c['@_ClaveProdServ'], + noIdentificacion: c['@_NoIdentificacion'] || '', + descripcion: c['@_Descripcion'], + cantidad: parseFloat(c['@_Cantidad']), + valorUnitario: parseFloat(c['@_ValorUnitario']), + importe: parseFloat(c['@_Importe']), + })); + + return { + uuid, + rfcEmisor: emisor['@_Rfc'], + nombreEmisor: emisor['@_Nombre'], + fecha: comprobante['@_Fecha'], + subtotal: parseFloat(comprobante['@_SubTotal']), + total: parseFloat(comprobante['@_Total']), + conceptos, + }; +} + +module.exports = { parseCfdiFile, parseCfdiXml }; diff --git a/src/services/import-service.js b/src/services/import-service.js new file mode 100644 index 0000000..a1593f5 --- /dev/null +++ b/src/services/import-service.js @@ -0,0 +1,65 @@ +const { parseCfdiFile } = require('./cfdi-parser'); + +function importXmlFile(db, filePath) { + let parsed; + try { + parsed = parseCfdiFile(filePath); + } catch (err) { + return { success: false, reason: 'parse_error', error: err.message }; + } + + const existing = db.prepare('SELECT id FROM facturas WHERE uuid = ?').get(parsed.uuid); + if (existing) { + return { success: false, reason: 'duplicate', uuid: parsed.uuid }; + } + + const insertFactura = db.prepare(` + INSERT INTO facturas (uuid, rfc_emisor, nombre_emisor, fecha, subtotal, total, xml_path) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + + const insertConcepto = db.prepare(` + INSERT INTO conceptos (factura_id, clave_prod_serv, no_identificacion, descripcion, cantidad, valor_unitario, importe, intercambio) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + + const getIntercambio = db.prepare( + 'SELECT no_parte_intercambio FROM catalogo_intercambios WHERE no_parte_proveedor = ?' + ); + + const transaction = db.transaction(() => { + const { lastInsertRowid } = insertFactura.run( + parsed.uuid, + parsed.rfcEmisor, + parsed.nombreEmisor, + parsed.fecha, + parsed.subtotal, + parsed.total, + filePath + ); + + for (const concepto of parsed.conceptos) { + const intercambioRow = getIntercambio.get(concepto.noIdentificacion); + const intercambio = intercambioRow ? intercambioRow.no_parte_intercambio : null; + + insertConcepto.run( + lastInsertRowid, + concepto.claveProdServ, + concepto.noIdentificacion, + concepto.descripcion, + concepto.cantidad, + concepto.valorUnitario, + concepto.importe, + intercambio + ); + } + + return lastInsertRowid; + }); + + transaction(); + + return { success: true, uuid: parsed.uuid }; +} + +module.exports = { importXmlFile }; diff --git a/tests/cfdi-parser.test.js b/tests/cfdi-parser.test.js new file mode 100644 index 0000000..78a2ab3 --- /dev/null +++ b/tests/cfdi-parser.test.js @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; +import { parseCfdiFile } from '../src/services/cfdi-parser.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +describe('CFDI Parser', () => { + const fixturePath = path.join(__dirname, 'fixtures', 'sample-cfdi.xml'); + + it('should extract UUID from TimbreFiscalDigital', () => { + const result = parseCfdiFile(fixturePath); + expect(result.uuid).toBe('6F8A29C3-1234-4567-8901-ABCDEF123456'); + }); + + it('should extract emisor data', () => { + const result = parseCfdiFile(fixturePath); + expect(result.rfcEmisor).toBe('AAA010101AAA'); + expect(result.nombreEmisor).toBe('Refacciones del Norte SA'); + }); + + it('should extract fecha, subtotal, total', () => { + const result = parseCfdiFile(fixturePath); + expect(result.fecha).toBe('2026-01-15T10:30:00'); + expect(result.subtotal).toBe(1500.0); + expect(result.total).toBe(1740.0); + }); + + it('should extract all conceptos', () => { + const result = parseCfdiFile(fixturePath); + expect(result.conceptos).toHaveLength(2); + + expect(result.conceptos[0]).toEqual({ + claveProdServ: '25101503', + noIdentificacion: 'BK-001', + descripcion: 'Balata ceramica delantera', + cantidad: 4, + valorUnitario: 250.0, + importe: 1000.0, + }); + }); + + it('should handle single concepto (not array)', () => { + const singleConceptXml = ` + + + + + + + + + `; + + const tmpPath = path.join(__dirname, 'fixtures', 'single-concept.xml'); + fs.writeFileSync(tmpPath, singleConceptXml); + + const result = parseCfdiFile(tmpPath); + expect(result.conceptos).toHaveLength(1); + expect(result.conceptos[0].descripcion).toBe('Articulo unico'); + + fs.unlinkSync(tmpPath); + }); +}); diff --git a/tests/fixtures/sample-cfdi.xml b/tests/fixtures/sample-cfdi.xml new file mode 100644 index 0000000..ce849e5 --- /dev/null +++ b/tests/fixtures/sample-cfdi.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + diff --git a/tests/import-service.test.js b/tests/import-service.test.js new file mode 100644 index 0000000..7344009 --- /dev/null +++ b/tests/import-service.test.js @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +describe('Import Service', () => { + let db; + + beforeEach(async () => { + const { createDatabase } = await import('../src/services/database.js'); + db = createDatabase(':memory:'); + }); + + it('should import a CFDI XML into the database', async () => { + const { importXmlFile } = await import('../src/services/import-service.js'); + const fixturePath = path.join(__dirname, 'fixtures', 'sample-cfdi.xml'); + const result = importXmlFile(db, fixturePath); + + expect(result.success).toBe(true); + expect(result.uuid).toBe('6F8A29C3-1234-4567-8901-ABCDEF123456'); + + const factura = db.prepare('SELECT * FROM facturas WHERE uuid = ?').get(result.uuid); + expect(factura).toBeTruthy(); + expect(factura.nombre_emisor).toBe('Refacciones del Norte SA'); + + const conceptos = db.prepare('SELECT * FROM conceptos WHERE factura_id = ?').all(factura.id); + expect(conceptos).toHaveLength(2); + }); + + it('should skip duplicate UUIDs', async () => { + const { importXmlFile } = await import('../src/services/import-service.js'); + const fixturePath = path.join(__dirname, 'fixtures', 'sample-cfdi.xml'); + importXmlFile(db, fixturePath); + const result = importXmlFile(db, fixturePath); + + expect(result.success).toBe(false); + expect(result.reason).toBe('duplicate'); + }); + + it('should cross-reference intercambios from catalogo', async () => { + const { importXmlFile } = await import('../src/services/import-service.js'); + db.prepare( + 'INSERT INTO catalogo_intercambios (no_parte_proveedor, no_parte_intercambio) VALUES (?, ?)' + ).run('BK-001', 'INT-9999'); + + const fixturePath = path.join(__dirname, 'fixtures', 'sample-cfdi.xml'); + importXmlFile(db, fixturePath); + + const concepto = db.prepare( + "SELECT * FROM conceptos WHERE no_identificacion = 'BK-001'" + ).get(); + expect(concepto.intercambio).toBe('INT-9999'); + }); +});