@munyaal/cfdi
v1.0.0
Published
Librería para crear y firmar xml para emitir cfdi
Readme
@munyaal/cfdi
Esta es una librería para crear y sellar archivos xml para un CFDi 4.0
Características
- Construcción fácil del comprobante a través de objetos tomando de base la documentación del Anexo 20
- Generación de cadena original
- Sellado del comprobante haciendo uso del certificado CSD (
.cer) y la llave privada (.key) del emisor - Guardar los documentos en el directorio de preferencia
- Contrato de errores unificado y tipado (
CfdiError+CfdiErrorCode)
Requisitos
Esta librería está pensada para ser usada en un entorno back end, dicho entorno debe contar con lo siguiente.
- Instalación de OpenSSL 3.0.7
- Instrucciones para Windows 10/11
- Instrucciones para Ubuntu 20/22
- Instrucciones para macOS 20/22 (no probado)
- Es importante comprobar su instalación
- El XSLT para la cadena original (
assets/xslt/cadenaoriginal_4_0.xslt) y su SEF precompilado (assets/xslt/cadenaoriginal_4_0.sef.json) ya vienen incluidos en este paquete y se resuelven por defecto. La ruta in-process usa el SEF (≈1000× más rápido quexslt3), pero conserva byte-a-byte la misma cadena original. Solo pasapaths.xsltCfdi40ainitializeCfdisi quieres usar un XSLT propio o una versión distinta — en ese caso, el helper caerá al caminoxslt3legacy. - Debes contar con los siguientes archivos del emisor
- Certificado CSD (
.cer) - Llave privada (
.key) - Contraseña del CSD
- Documentación
- Certificado CSD (
Instalación
npm i @munyaal/cfdiUso
El uso de esta librería se podría resumir en los siguientes pasos.
- Configuración del servicio
- Creación del comprobante
- Sellar y generar xml
Configuración del servicio
import { initializeCfdi } from '@munyaal/cfdi';
// Servicio que sella y certifica (camino por defecto).
const service = initializeCfdi({
certificate: {
cerPath: '/ruta/al/certificado.cer',
keyPath: '/ruta/al/archivo.key',
password: 'contraseña del CSD',
},
// `behavior` y `paths` son opcionales: ambas opciones sellan y
// certifican por defecto y la XSLT resuelve a
// `assets/xslt/cadenaoriginal_4_0.xslt` cuando no se sobreescribe.
behavior: { seal: true, certify: true },
paths: {
xsltCfdi40: 'assets/xslt/cadenaoriginal_4_0.xslt',
outputDir: '/tmp/cfdi',
},
});
// Servicio "solo quiero el XML, no firmar". No requiere certificado.
const unsignService = initializeCfdi({
behavior: { seal: false, certify: false },
});Creación del comprobante
El Comprobante y sus nodos hijos (Emisor, Receptor, Conceptos,
Impuestos) se asignan después de new, no se pasan dentro del
constructor: cada constructor solo recibe sus propios atributos, y
los hijos se montan sobre la instancia ya creada (igual que en los
casos de prueba de test/cases/).
import {
Comprobante,
ComprobanteEmisor,
ComprobanteReceptor,
ComprobanteConcepto,
ComprobanteConceptoImpuestos,
ComprobanteConceptoImpuestosTraslado,
ComprobanteImpuestos,
ComprobanteImpuestosTraslado,
MonedaEnum,
TipoComprobanteEnum,
ExportacionEnum,
RegimenFiscalEnum,
UsoCfdiEnum,
ObjetoImpEnum,
ImpuestoEnum,
TipoFactorEnum,
FormaPagoEnum,
MetodoPagoEnum,
} from '@munyaal/cfdi';
const comprobante = new Comprobante({
Version: '4.0',
Serie: 'A',
Folio: 'FOL-1',
Fecha: '2024-01-01T00:00:00',
FormaPago: FormaPagoEnum.FP03, // Transferencia electrónica de fondos
SubTotal: '100.00',
Moneda: MonedaEnum.MXN,
Total: '116.00',
TipoDeComprobante: TipoComprobanteEnum.I,
Exportacion: ExportacionEnum.E01,
MetodoPago: MetodoPagoEnum.PUE,
LugarExpedicion: '77725',
});
comprobante.Emisor = new ComprobanteEmisor({
Rfc: 'XAXX010101000',
Nombre: 'Emisor de Prueba',
RegimenFiscal: RegimenFiscalEnum.RF616, // Sin obligaciones fiscales
});
comprobante.Receptor = new ComprobanteReceptor({
Rfc: 'XAXX010101000',
Nombre: 'Receptor de Prueba',
DomicilioFiscalReceptor: '77725',
RegimenFiscalReceptor: RegimenFiscalEnum.RF616, // Sin obligaciones fiscales
UsoCFDI: UsoCfdiEnum.S01, // Sin efectos fiscales
});
const concepto = new ComprobanteConcepto({
ClaveProdServ: '01010101',
Cantidad: '1',
ClaveUnidad: 'H87',
Descripcion: 'Servicio de prueba',
ValorUnitario: '100.00',
Importe: '100.00',
ObjetoImp: ObjetoImpEnum.OI02, // Sí objeto de impuesto
});
const conceptoImpuestos = new ComprobanteConceptoImpuestos();
conceptoImpuestos.Traslados.push(
new ComprobanteConceptoImpuestosTraslado({
Base: '100.00',
Impuesto: ImpuestoEnum.I002, // IVA
TipoFactor: TipoFactorEnum.Tasa,
TasaOCuota: '0.160000',
Importe: '16.00',
}),
);
concepto.Impuestos = conceptoImpuestos;
comprobante.Conceptos.push(concepto);
// Total de impuestos trasladados a nivel comprobante.
const impuestos = new ComprobanteImpuestos({
TotalImpuestosTrasladados: '16.00',
});
impuestos.Traslados.push(
new ComprobanteImpuestosTraslado({
Base: '100.00',
Impuesto: ImpuestoEnum.I002, // IVA
TipoFactor: TipoFactorEnum.Tasa,
TasaOCuota: '0.160000',
Importe: '16.00',
}),
);
comprobante.Impuestos = impuestos;Sellar y generar xml
try {
const xml = await service.getXMLSellado(comprobante);
// `xml` es la cadena del CFDI 4.0 firmada y certificada.
// `saveXml` la persiste en disco y devuelve la ruta del archivo.
const path = await service.saveXml(xml, 'mi-cfdi');
console.log(`CFDi firmado en: ${path}`);
} catch (e) {
// Ver la sección de manejo de errores más abajo.
handleError(e);
}Manejo de errores
Cada fallo que produce la librería es una instancia de CfdiError
(exportada desde @munyaal/cfdi), con la forma pública:
| Campo | Tipo | Descripción |
| -------------- | ------------------- | ------------------------------------------------------------------------------------------ |
| code | CfdiErrorCode | Identificador estable (uno de los valores de CfdiErrorCode). Es el contrato público. |
| process | string | Etiqueta humana de la operación que falló (p. ej. getCertificate, sellarComprobante). |
| message | string | Descripción humana de lo que salió mal (texto libre, no contiene el código). |
| suggestions | readonly string[] | Pistas accionables, en orden, para que el usuario sepa cómo resolver el problema. |
| cause | unknown | Error original de execa / openssl / fs (ES2022 Error.cause). |
| toJSON() | object | Devuelve exactamente { code, process, message, suggestions } para logs y telemetría. |
Ejemplo: try / catch con CfdiError y CfdiErrorCode
import { CfdiError, CfdiErrorCode, initializeCfdi } from '@munyaal/cfdi';
const service = initializeCfdi({
certificate: {
cerPath: '/ruta/al/certificado.cer',
keyPath: '/ruta/al/archivo.key',
password: 'contraseña del CSD',
},
});
try {
const xml = await service.getXMLSellado(comprobante);
// ...uso de `xml`...
} catch (e) {
if (e instanceof CfdiError) {
// `error.code` es el contrato estable. Se puede comparar
// contra el enum (recomendado) o contra el string numérico.
switch (e.code) {
case CfdiErrorCode.CertificateRead: // 'ERROR: 001'
case CfdiErrorCode.CadenaOriginal: // 'ERROR: 002'
case CfdiErrorCode.PrivateKeyRead: // 'ERROR: 003'
case CfdiErrorCode.AssetNotFound: // 'ERROR: 004'
case CfdiErrorCode.XmlRequiredAttribute: // 'ERROR: 005'
case CfdiErrorCode.XmlRequiredElement: // 'ERROR: 006'
case CfdiErrorCode.XmlWrite: // 'ERROR: 007'
case CfdiErrorCode.Config: // 'ERROR: 008'
case CfdiErrorCode.Seal: // 'ERROR: 009'
case CfdiErrorCode.Certify: // 'ERROR: 010'
case CfdiErrorCode.XmlInvalidAttribute: // 'ERROR: 011' (Phase 1 round 2)
// Rama según el código estable
break;
}
console.error(`[${e.code}] ${e.process}: ${e.message}`);
for (const s of e.suggestions) {
console.error(` - ${s}`);
}
// `e.cause` es el error original de execa / openssl / fs
// cuando aplica. Útil para diagnóstico avanzado.
if (e.cause instanceof Error) {
console.error('causa original:', e.cause);
}
// `toJSON()` devuelve exactamente los cuatro campos públicos,
// sin `cause`, sin `name`, sin `stack`. Es la forma
// recomendada para serializar a logs / telemetría.
const payload = e.toJSON();
telemetry.track('cfdi_error', payload);
} else {
// Cualquier otro error es ajeno a la librería.
throw e;
}
}Tabla de códigos
Todos los valores de CfdiErrorCode siguen el formato ERROR: NNN.
La columna Mensaje / resumen resume el contenido típico de
error.message; la columna Sugerencias típicas resume las pistas
más comunes en error.suggestions (cada llamada puede añadir otras
específicas del contexto, p. ej. el path concreto que falló).
| Enum member | Código | process | Mensaje / resumen | Sugerencias típicas |
| ---------------------------- | -------------- | ------------------------------------------ | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| CfdiErrorCode.CertificateRead | ERROR: 001 | getCertificate | No se pudo obtener el certificado (.cer). | Verifica la instalación de openssl. Valida la existencia del certificado en <path>. |
| CfdiErrorCode.CadenaOriginal | ERROR: 002 | getCadenaOriginal | No se pudo generar la cadena original (xslt3). | Valida la existencia del XML temporal. Valida la existencia del XSLT en <path>. Si xslt3 no está en PATH, instala sus dependencias o npm i -g xslt3. |
| CfdiErrorCode.PrivateKeyRead | ERROR: 003 | getKey | No se pudo obtener la clave privada (.key). | Verifica la instalación de openssl. Valida la existencia de la clave privada en <path>. |
| CfdiErrorCode.AssetNotFound | ERROR: 004 | resolveAssetPath | No se encontró el asset <input> (lista los candidatos probados). | Verifica que el archivo exista en alguna de las rutas. Pasa la ruta explícita al inicializar. |
| CfdiErrorCode.XmlRequiredAttribute | ERROR: 005 | XMLAttributeModel.setSchema | El atributo "<key>" es requerido. | Proporciona el valor en la instancia. Verifica que la propiedad esté presente antes de serializar. |
| CfdiErrorCode.XmlRequiredElement | ERROR: 006 | XMLChildModel.setSchema | El elemento "<key>" es requerido. | Proporciona el valor en la instancia. Verifica que la propiedad esté presente antes de serializar. |
| CfdiErrorCode.XmlWrite | ERROR: 007 | saveXml | No se pudo escribir el XML en el directorio de salida (<outputDir>). | Verifica que el directorio exista y sea escribible. Revisa los permisos. Asegúrate de que el nombre no apunte a un directorio. |
| CfdiErrorCode.Config | ERROR: 008 | assertCertificateConfigured | certificate is required when behavior.seal or behavior.certify is true. o certificate is incomplete: missing <campos>. | Pasa certificate: { cerPath, keyPath, password } a initializeCfdi. O bien, desactiva el sellado con behavior: { seal: false, certify: false }. |
| CfdiErrorCode.Seal | ERROR: 009 | sellarComprobante | No se pudo sellar el comprobante. | Verifica que el certificado, la clave privada y la contraseña sean correctos. Verifica que el XSLT sea accesible. Consulta err.cause. |
| CfdiErrorCode.Certify | ERROR: 010 | certificarComprobante | No se pudo certificar el comprobante. | Verifica que el archivo .cer exista y sea legible por openssl. Verifica la instalación de openssl. Consulta err.cause. |
| CfdiErrorCode.XmlInvalidAttribute | ERROR: 011 | XMLAttributeModel.setSchema | El atributo "<key>" no cumple con la restricción estructural (<motivo>). | Verifica que <key> cumpla con el patrón del XSD. Usa un valor del catálogo SAT si aplica. Distinto de ERROR: 005: el valor existe pero es incorrecto. |
Contrato estable: error.code y toJSON()
error.codees el contrato estable. Todos los demás campos son descriptivos y pueden ajustarse sin que eso sea un breaking change. Compara siempre contraCfdiErrorCode.X(recomendado) o contra el string numérico ('ERROR: 001', etc.). El string numérico es el valor del enum, así que ambas formas son la misma expresión.error.toJSON()devuelve exactamente{ code, process, message, suggestions }, sincause, sinname, sinstack. Es la forma recomendada para serializar a logs, telemetría o respuestas de API.causese mantiene solo en proceso: inspección in-memory, no JSON.error.messagees texto humano libre. No se garantiza que contenga el código, ni que sea estable entre versiones. Úsalo solo para mostrar al usuario final, no para branchear.- La librería no imprime errores automáticamente. No se hace
ninguna llamada a
console.errordesde el código de la librería en ninguna ruta de fallo. El logging es decisión y responsabilidad del consumidor; si quieres persistir los errores, hazlo en tucatcha partir dee.toJSON()o de los campos públicos.
Ejemplo: serializar un error para una API de logs
import { CfdiError } from '@munyaal/cfdi';
try {
await service.getXMLSellado(comprobante);
} catch (e) {
if (e instanceof CfdiError) {
// `e.toJSON()` es seguro de pasar a `JSON.stringify`: no
// contiene `cause`, `name` ni `stack`, solo los cuatro
// campos públicos.
const payload = e.toJSON();
// payload === {
// code: 'ERROR: 008',
// process: 'assertCertificateConfigured',
// message: 'certificate is required ...',
// suggestions: [
// 'Pasa certificate: { cerPath, keyPath, password } a initializeCfdi',
// 'O bien, desactiva el sellado con behavior: { seal: false, certify: false }',
// ],
// }
res.status(400).json({ ok: false, error: payload });
} else {
throw e;
}
}Migración desde versiones anteriores
Si vienes de una versión que usaba los códigos textuales
('ERROR: CONFIG', 'ERROR: AL SELLAR', 'ERROR: AL CERTIFICAR'),
actualiza a la forma numérica ('ERROR: 008', 'ERROR: 009',
'ERROR: 010') o, mejor aún, al enum (CfdiErrorCode.Config,
CfdiErrorCode.Seal, CfdiErrorCode.Certify). El resto del
contrato (code / process / message / suggestions /
cause / toJSON()) es estable desde la iteración 7.
