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');
+ });
+});