Add CFDI XML parser and invoice import services with tests

Implement Task 3 (CFDI Parser) and Task 4 (Invoice Import) following TDD:
- CFDI parser extracts UUID, emisor, fecha, totals, and conceptos from CFDI 4.0 XML
- Import service inserts parsed invoice data into SQLite with duplicate detection
  and cross-references catalogo_intercambios for part number lookups
- All 12 tests pass (5 parser + 3 import + 4 existing database tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
consultoria-as
2026-02-17 06:28:33 +00:00
parent 27efded33a
commit 364d6d591e
5 changed files with 258 additions and 0 deletions

View File

@@ -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 };

View File

@@ -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 };

69
tests/cfdi-parser.test.js Normal file
View File

@@ -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 = `<?xml version="1.0" encoding="utf-8"?>
<cfdi:Comprobante xmlns:cfdi="http://www.sat.gob.mx/cfd/4"
xmlns:tfd="http://www.sat.gob.mx/TimbreFiscalDigital"
Version="4.0" Fecha="2026-02-01T08:00:00" SubTotal="100.00" Total="116.00">
<cfdi:Emisor Rfc="BBB020202BBB" Nombre="Proveedor Unico" />
<cfdi:Conceptos>
<cfdi:Concepto ClaveProdServ="12345678" NoIdentificacion="X-1"
Descripcion="Articulo unico" Cantidad="1"
ValorUnitario="100.00" Importe="100.00" />
</cfdi:Conceptos>
<cfdi:Complemento>
<tfd:TimbreFiscalDigital UUID="SINGLE-UUID-TEST" />
</cfdi:Complemento>
</cfdi:Comprobante>`;
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);
});
});

20
tests/fixtures/sample-cfdi.xml vendored Normal file
View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<cfdi:Comprobante xmlns:cfdi="http://www.sat.gob.mx/cfd/4"
xmlns:tfd="http://www.sat.gob.mx/TimbreFiscalDigital"
Version="4.0"
Fecha="2026-01-15T10:30:00"
SubTotal="1500.00"
Total="1740.00">
<cfdi:Emisor Rfc="AAA010101AAA" Nombre="Refacciones del Norte SA" />
<cfdi:Conceptos>
<cfdi:Concepto ClaveProdServ="25101503" NoIdentificacion="BK-001"
Descripcion="Balata ceramica delantera" Cantidad="4"
ValorUnitario="250.00" Importe="1000.00" />
<cfdi:Concepto ClaveProdServ="25101504" NoIdentificacion="FT-200"
Descripcion="Filtro de aceite" Cantidad="10"
ValorUnitario="50.00" Importe="500.00" />
</cfdi:Conceptos>
<cfdi:Complemento>
<tfd:TimbreFiscalDigital UUID="6F8A29C3-1234-4567-8901-ABCDEF123456" />
</cfdi:Complemento>
</cfdi:Comprobante>

View File

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