Saltar al contenido principal

Mejores prácticas

Arquitectura y diseño​

Separación de ambientes​

La URL base es la misma para todos los ambientes (https://ecf.facturord.app); el ambiente de la DGII se elige con dgii_env en cada operación. Centraliza esa decisión en la configuración:

// config/facturo.js
const DGII_ENV_BY_NODE_ENV = {
development: 'testecf',
staging: 'certecf',
production: 'ecf',
};

module.exports = {
baseUrl: process.env.FACTURO_BASE_URL || 'https://ecf.facturord.app/',
apiKey: process.env.FACTURO_API_KEY,
companyId: process.env.FACTURO_COMPANY_ID,
dgiiEnv: process.env.FACTURO_DGII_ENV || DGII_ENV_BY_NODE_ENV[process.env.NODE_ENV || 'development'],
};

No uses datos reales de clientes en testecf, no hagas pruebas en ecf (los comprobantes de producción tienen validez fiscal) y no compartas la misma API Key entre desarrollo y producción.

Llama a la API solo desde tu backend​

La API Key da acceso a la emisión fiscal de tu empresa. Úsala únicamente en servidores, workers o funciones serverless; nunca en el navegador ni en apps móviles.

Usa los clientes oficiales​

Los clientes oficiales ya resuelven autenticación, rutas, timeouts y parseo de errores. Reutiliza una sola instancia del cliente por proceso.

Circuit breaker​

Si la DGII o la red dejan de responder, un circuit breaker evita que tu aplicación siga acumulando peticiones fallidas:

class CircuitBreaker {
constructor(threshold = 5, timeout = 60000) {
this.failureCount = 0;
this.threshold = threshold;
this.timeout = timeout;
this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
this.nextAttempt = Date.now();
}

async execute(fn) {
if (this.state === 'OPEN') {
if (Date.now() < this.nextAttempt) {
throw new Error('Circuit breaker is OPEN');
}
this.state = 'HALF_OPEN';
}

try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}

onSuccess() {
this.failureCount = 0;
this.state = 'CLOSED';
}

onFailure() {
this.failureCount++;
if (this.failureCount >= this.threshold) {
this.state = 'OPEN';
this.nextAttempt = Date.now() + this.timeout;
}
}
}

// Uso
const breaker = new CircuitBreaker();
const result = await breaker.execute(() => client.emitRichECF({ dgii_env: 'ecf', ecf }));

Gestión de secuencias (e-NCF)​

Control centralizado​

Un e-NCF tiene 13 caracteres: serie (1), tipo (2) y secuencia (10). Genera las secuencias de forma atómica en un único lugar:

-- PostgreSQL
CREATE TABLE ecf_sequences (
company_rnc VARCHAR(11) NOT NULL,
ecf_type VARCHAR(2) NOT NULL,
last_sequence BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (company_rnc, ecf_type)
);
// Generador atómico
async function getNextSequence(rnc, ecfType) {
const result = await db.query(`
INSERT INTO ecf_sequences (company_rnc, ecf_type, last_sequence)
VALUES ($1, $2, 1)
ON CONFLICT (company_rnc, ecf_type)
DO UPDATE SET last_sequence = ecf_sequences.last_sequence + 1,
updated_at = NOW()
RETURNING last_sequence
`, [rnc, ecfType]);

return result.rows[0].last_sequence;
}

// Formatear e-NCF
function formatENCF(ecfType, number, serie = 'E') {
return `${serie}${ecfType}${String(number).padStart(10, '0')}`;
}

// Uso
const next = await getNextSequence('123456789', '31');
const encf = formatENCF('31', next);
// Resultado: E310000000042

Manejo de concurrencia​

Si no puedes usar un UPSERT atómico, bloquea la fila durante la transacción:

from sqlalchemy import select
from sqlalchemy.orm import Session


def get_next_sequence(session: Session, rnc: str, ecf_type: str) -> int:
# FOR UPDATE previene condiciones de carrera
stmt = (
select(EcfSequence)
.where(EcfSequence.company_rnc == rnc, EcfSequence.ecf_type == ecf_type)
.with_for_update()
)
sequence = session.execute(stmt).scalar_one_or_none()

if sequence is None:
sequence = EcfSequence(company_rnc=rnc, ecf_type=ecf_type, last_sequence=1)
session.add(sequence)
else:
sequence.last_sequence += 1

session.commit()
return sequence.last_sequence

Secuencias no utilizadas​

  • No reutilices e-NCF, ni siquiera de documentos rechazados.
  • Anula con ANECF las secuencias saltadas, dañadas o que no se van a usar.
  • Un comprobante ya aceptado por la DGII no se anula con ANECF: emite una Nota de Crédito (E34).

Emisión confiable​

Guarda los identificadores de la emisión​

Persiste document_id, encf y track_id en cuanto recibas la respuesta de emisión:

CREATE TABLE ecf_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
facturo_doc_id UUID UNIQUE,
encf VARCHAR(13) NOT NULL,
ecf_type VARCHAR(2) NOT NULL,
dgii_env VARCHAR(10) NOT NULL,
track_id VARCHAR(100),
internal_status VARCHAR(50),
dgii_status VARCHAR(50),
security_code VARCHAR(20),
qr_url TEXT,
total_amount DECIMAL(15, 2),
payload JSONB NOT NULL,
error_message TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE (dgii_env, encf)
);

Reintentos con backoff​

Reintenta solo errores de red y 5xx. Los 4xx indican datos o permisos incorrectos: corrígelos antes de reenviar.

async function requestWithBackoff(fn, maxRetries = 3) {
let lastError;

for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
lastError = error;

// No reintentar errores 4xx
if (error.statusCode >= 400 && error.statusCode < 500) {
throw error;
}

// Backoff exponencial
const delay = Math.min(1000 * Math.pow(2, i), 10000);
await new Promise(resolve => setTimeout(resolve, delay));
}
}

throw lastError;
}
Evita duplicar comprobantes

Si un documento ya tiene document_id, usa reintentar emisión en lugar de volver a emitirlo. Tras un timeout, consulta el estado antes de reenviar.

Consulta de estado con intervalos crecientes​

async function waitForDgiiStatus(trackId, isFinal, maxAttempts = 8) {
for (let i = 0; i < maxAttempts; i++) {
const status = await client.getTrackingStatus(trackId);
if (isFinal(status)) {
return status;
}
// 2 s, 4 s, 8 s ... hasta 60 s
await new Promise(resolve => setTimeout(resolve, Math.min(2000 * 2 ** i, 60000)));
}
throw new Error(`Estado no definitivo para el Track ID ${trackId}`);
}

Para volúmenes altos, mueve la consulta de estados a un job en segundo plano en lugar de bloquear la petición del usuario.

Procesamiento por lotes​

Limita la concurrencia al emitir muchos documentos:

async function emitInBatches(payloads, batchSize = 10) {
const results = [];

for (let i = 0; i < payloads.length; i += batchSize) {
const batch = payloads.slice(i, i + batchSize);
const batchResults = await Promise.allSettled(
batch.map(ecf => client.emitRichECF({ dgii_env: 'ecf', ecf }))
);
results.push(...batchResults);
}

return {
successful: results.filter(r => r.status === 'fulfilled'),
failed: results.filter(r => r.status === 'rejected'),
};
}

Validación de datos​

Validar antes de enviar​

Validar en tu sistema antes de enviar evita rechazos y secuencias desperdiciadas:

function validateTotals(ecf) {
const t = ecf.Encabezado.Totales;
const calculated =
(t.MontoGravadoTotal || 0) +
(t.MontoExento || 0) +
(t.TotalITBIS || 0) +
(t.MontoImpuestoAdicional || 0);

if (Math.abs(calculated - t.MontoTotal) > 0.01) {
throw new Error('MontoTotal no coincide con gravado + exento + ITBIS');
}
}

function validateENCF(encf) {
// Serie E–Z (excepto P), tipo de 2 dígitos y secuencia de 10 dígitos
return /^[E-OQ-Z]\d{12}$/.test(encf);
}

Validar RNC y cédula​

function validateRNC(rnc) {
const clean = rnc.replace(/[-\s]/g, '');
if (!/^\d{9}$/.test(clean)) return false;

const weights = [7, 9, 8, 6, 5, 4, 3, 2];
const sum = weights.reduce((acc, w, i) => acc + Number(clean[i]) * w, 0);
const remainder = sum % 11;
const checkDigit = remainder === 0 ? 2 : remainder === 1 ? 1 : 11 - remainder;

return Number(clean[8]) === checkDigit;
}

function validateCedula(cedula) {
const clean = cedula.replace(/[-\s]/g, '');
if (!/^\d{11}$/.test(clean)) return false;

// Algoritmo de Luhn
let sum = 0;
for (let i = 0; i < 10; i++) {
let n = Number(clean[i]) * (i % 2 === 0 ? 1 : 2);
if (n > 9) n -= 9;
sum += n;
}
return (10 - (sum % 10)) % 10 === Number(clean[10]);
}

Seguridad​

Protección de credenciales​

  • Guarda la API Key en variables de entorno o en un gestor de secretos.
  • Usa claves distintas por ambiente y por aplicación, con los permisos mínimos necesarios.
  • Rota las claves periódicamente y revoca las que no uses.
  • No incluyas la clave en el código, en repositorios ni en logs.
  • No la envíes al navegador ni a apps móviles.

Cifrado de la API Key en base de datos​

Si tienes que guardar la API Key en base de datos (por ejemplo, en un sistema multiempresa), cífrala:

const crypto = require('crypto');

class SecureStorage {
constructor(masterKeyHex) {
this.masterKey = Buffer.from(masterKeyHex, 'hex'); // 32 bytes
}

encrypt(text) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', this.masterKey, iv);
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
return {
iv: iv.toString('hex'),
encrypted: encrypted.toString('hex'),
authTag: cipher.getAuthTag().toString('hex'),
};
}

decrypt({ iv, encrypted, authTag }) {
const decipher = crypto.createDecipheriv('aes-256-gcm', this.masterKey, Buffer.from(iv, 'hex'));
decipher.setAuthTag(Buffer.from(authTag, 'hex'));
return Buffer.concat([
decipher.update(Buffer.from(encrypted, 'hex')),
decipher.final(),
]).toString('utf8');
}
}

Monitoreo y observabilidad​

Logs estructurados​

Registra identificadores y códigos, nunca la API Key:

logger.info('e-CF emitido', {
documentId: res.document_id,
encf: res.encf,
trackId: res.track_id,
dgiiEnv: 'ecf',
});

logger.error('Falló la emisión', {
statusCode: error.statusCode,
message: error.message,
encf,
});

Métricas recomendadas​

  • Documentos emitidos por tipo y ambiente
  • Documentos rechazados por la DGII
  • Latencia de las llamadas a la API
  • Errores por código HTTP
  • Documentos pendientes de estado definitivo

Pruebas​

Pruebas de integración contra testecf​

const { FacturoClient } = require('../lib/facturo-client');

describe('Integración Facturo', () => {
const client = new FacturoClient({
apiKey: process.env.FACTURO_TEST_API_KEY,
defaultCompanyId: process.env.FACTURO_TEST_COMPANY_ID,
});

test('emite un e-CF en pruebas', async () => {
const res = await client.emitRichECF({ dgii_env: 'testecf', ecf: buildTestEcf() });

expect(res.encf).toMatch(/^E31\d{10}$/);
expect(res.track_id).toBeTruthy();
});
});

En pruebas unitarias, inyecta un fetch simulado con la opción customFetch del cliente Node.js para no depender de la red.


Checklist de producción​

Seguridad​

  • API Key en variables de entorno o gestor de secretos
  • Claves separadas por ambiente, con permisos mínimos
  • La API Key nunca llega al navegador ni a los logs
  • 2FA activado para usuarios administradores del portal

Emisión​

  • Integración validada en testecf y certificada en certecf
  • dgii_env configurado como ecf solo en producción
  • Generación atómica de e-NCF
  • document_id, encf y track_id persistidos
  • Reintentos con backoff y uso del endpoint de reintento
  • Proceso de anulación (ANECF) para secuencias no usadas

Operación​

  • Logs estructurados y métricas
  • Alertas ante rechazos de la DGII y errores 5xx
  • Respaldo de los XML firmados

Si tienes dudas sobre la implementación, escribe a soporte@facturo.do o pregunta en Discord.