g-efac/sdk

SDK de PHP para la API /v1 de G-eFac (facturacion electronica e-CF, DGII Republica Dominicana)

Maintainers

Package info

github.com/Walewsky/efac-php-sdk

pkg:composer/g-efac/sdk

Transparency log

Statistics

Installs: 2

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v0.2.0 2026-08-08 02:56 UTC

This package is auto-updated.

Last update: 2026-08-08 03:02:16 UTC


README

SDK oficial para la API /v1 de G-eFac: emisión de e-CF, consulta de estado, historial y anulaciones, el archivo fiscal, y re-obtención de los ingredientes del QR / Representación Impresa para reimpresiones.

Estabilidad: versión 0.x

/v1 es provisional — todavía no está congelada. Mientras siga así, este paquete se publica en 0.x y las versiones menores pueden romper compatibilidad: no hay promesa de semver hasta que la API se congele. Cuando se active el disparador de congelación, el SDK pasa a 1.0.0 y adopta semver en serio — a partir de ahí, ninguna versión menor rompe compatibilidad. El detalle completo del disparador y del compromiso de compatibilidad vive en docs/API-VERSIONING-POLICY.md, dentro del repositorio de la plataforma (ese archivo no se incluye en este paquete).

Instalación

composer require g-efac/sdk

El paquete no impone ningún cliente HTTP: solo depende de las interfaces PSR-18 (cliente) y PSR-17 (fábricas de request/stream). build() necesita las tres piezas — cliente, fábrica de request y fábrica de stream — para funcionar.

Si tu proyecto ya tiene implementaciones de esas interfaces instaladas (Guzzle + su dependencia guzzlehttp/psr7, Symfony HttpClient, Nyholm) puedes sumar php-http/discovery y build() las detecta solas, sin pasar nada. Si no instalas php-http/discovery, tienes que inyectarlas explícitamente con httpClient(...), requestFactory(...) y streamFactory(...) — las tres, no solo el cliente: si falta cualquiera de ellas, build() lanza InvalidArgumentException. Los ejemplos de este README usan inyección explícita con Guzzle para que funcionen tal cual, sin depender de qué tengas instalado.

Para Laravel usa el paquete g-efac/laravel (composer require g-efac/laravel), que hace todo el cableado por ti. Requiere Laravel 12 — es la única versión con soporte de seguridad activo, y composer bloquea las anteriores por defecto.

Primer comprobante

use Efac\Sdk\EfacClient;
use Efac\Sdk\Enum\EcfTipo;
use Efac\Sdk\Enum\TaxTreatment;
use Efac\Sdk\Invoice\Invoice;
use Efac\Sdk\Invoice\Item;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\HttpFactory;

// HttpFactory implementa tanto RequestFactoryInterface como StreamFactoryInterface,
// y viene incluida al instalar guzzlehttp/guzzle (composer require guzzlehttp/guzzle).
$factory = new HttpFactory();

$efac = EfacClient::builder()
    ->baseUrl('https://api.g-efac.com')
    ->credentials($clientId, $clientSecret)
    ->httpClient(new Client())
    ->requestFactory($factory)
    ->streamFactory($factory)
    ->build();

$factura = Invoice::of(EcfTipo::CreditoFiscal31)
    ->seller('101000001', 'ACME SRL', 'Av. Principal 1')
    ->buyer('130000002', 'Cliente SA', email: 'cxp@cliente.do')
    ->addItem(Item::goods('Widget', qty: 2, unitPrice: 500.00, tax: TaxTreatment::Itbis18))
    ->build();

$resultado = $efac->invoices()->emit($factura, 'orden-1042');

echo $resultado->encf;      // E310000000001
echo $resultado->qrUrl;     // URL del timbre para la Representación Impresa

No hay que escribir nada de OAuth: el cliente pide el token a /connect/token, lo cachea y lo renueva antes de que expire.

La llave de idempotencia es obligatoria

emit() exige una llave, y no la genera por ti. La razón es fiscal, no estética:

Sin Idempotency-Key, reintentar una petición que expiró por timeout puede emitir un e-CF duplicado y consumir un segundo e-NCF, que es un recurso fiscal con seguimiento legal.

Usa un identificador estable y propio de tu sistema — el número de orden, el id de la factura interna. Un UUID nuevo en cada reintento cambiaría en cada intento y anularía el mecanismo justo cuando hace falta.

Reenviar la misma llave con el mismo cuerpo repite la respuesta original en lugar de volver a emitir. Reutilizarla con un cuerpo distinto, o mientras una emisión bajo esa llave sigue en curso, devuelve 409.

if ($resultado->wasReplayed()) {
    // El servidor repitió una respuesta anterior; no se emitió nada nuevo.
}

Si de verdad necesitas emitir sin llave, existe emitWithoutIdempotency(). El nombre es incómodo a propósito.

Cuando la emisión queda en cola (202)

DGII no siempre responde de inmediato. En ese caso el servidor devuelve 202 y el e-CF queda en cola:

if ($resultado->isQueued()) {
    $estado = $efac->invoices()->status($resultado->encf);
}

Para esperar el veredicto hay un ayudante con backoff exponencial. Es opt-in: nunca lo llames dentro de una petición web — úsalo en un worker de cola o en un job de consola.

use Efac\Sdk\Exception\TimeoutException;

try {
    $estado = $efac->invoices()->awaitTerminal($resultado->encf, timeoutSeconds: 60);
} catch (TimeoutException $e) {
    // OJO: esto NO es un fallo de emisión. El e-CF se emitió; DGII simplemente no
    // había dado veredicto dentro de la ventana. $e->lastStatus trae lo último visto.
}

Reimpresiones

$qr = $efac->invoices()->qr('E310000000001');

$qr->qrUrl;            // idéntico byte a byte al de la emisión original
$qr->qrImage;          // PNG como data URI
$qr->codigoSeguridad;

Facturas en cola e historial

queued() lista los e-CF que todavía no tienen veredicto de DGII, paginado por cursor:

$pagina = $efac->invoices()->queued(limit: 100);

foreach ($pagina->items as $q) {
    echo $q->encf, '', $q->horasRestantes, "h restantes\n";
}

if ($pagina->hasMore) {
    $siguiente = $efac->invoices()->queued(cursor: $pagina->nextCursor, limit: 100);
}

history() busca en el historial de e-CF emitidos, con filtros opcionales y paginación por número de página:

use Efac\Sdk\Enum\EstadoGrupo;
use Efac\Sdk\Resource\HistoryFilter;

$filtro = (new HistoryFilter())
    ->query('Cliente SA')
    ->estado(EstadoGrupo::Aceptado);

$pagina = $efac->invoices()->history($filtro, page: 1, pageSize: 50);

echo $pagina->total, ' de ', $pagina->pageCount, " páginas\n";
foreach ($pagina->rows as $fila) {
    echo $fila->encf, ' ', $fila->estado?->value, "\n";
}

Omitir un filtro omite ese parámetro por completo; history() sin argumentos trae la primera página sin filtrar.

Anulaciones

void() anula (cancela) un rango de e-NCF. Es irreversible, y a diferencia de emit() no tiene Idempotency-Key: si la petición expira por timeout, la respuesta por sí sola no dice si la anulación ocurrió o no. Antes de reintentar una llamada que expiró, revisa history() para ver el estado del rango — no reenvíes a ciegas.

use Efac\Sdk\Enum\EcfTipo;

$resultado = $efac->invoices()->void(EcfTipo::CreditoFiscal31, from: 1042, to: 1050);

echo $resultado->voided; // 9

Archivo fiscal

$efac->archive() da acceso de solo lectura a la ventana de 10 años del archivo fiscal:

foreach ($efac->archive()->years() as $anio) {
    echo $anio->year, ': ', $anio->total, " documentos\n";
}

foreach ($efac->archive()->months(2026) as $mes) {
    echo $mes->month, ': ', $mes->count ?? 'futuro', "\n";
}

foreach ($efac->archive()->documents(2026, 7) as $doc) {
    echo $doc->encf, ' ', $doc->estado?->value, "\n";
}

documents() está limitado a 500 filas por mes en el servidor; hoy no existe paginación para ese límite.

Errores

Todas las respuestas de error son RFC 7807 (application/problem+json) y llegan como excepciones tipadas. No hace falta mirar códigos de estado.

Excepción HTTP Significado
ValidationException 400, 422 El servidor rechazó el contenido. Revisa $e->problem['errors']
AuthException 401, 403 Credenciales inválidas, sin permiso sobre el recurso, cuenta suspendida, o ambiente no habilitado
ConflictException 409 Llave de idempotencia reusada, secuencia e-NCF agotada, falta el archivo del e-CF, o el rango de void() ya fue anulado o se solapa con una anulación previa. Lee $e->detail
NotFoundException 404 Este emisor no tiene ningún e-CF con ese e-NCF
DgiiUnavailableException 502 DGII no responde. Reintentable
ServerException 500 Fallo inesperado del servidor
TransportException Falló el cliente HTTP: conexión, DNS, TLS, timeout
InvalidInvoiceException Validación local, antes de salir a la red

Todas heredan de EfacException y traen status, title, detail, instance y el cuerpo completo en problem.

Validación: solo estructura

build() valida lo que se puede saber sin conocimiento fiscal: campos obligatorios presentes, RNC de 9 u 11 dígitos, cantidades y precios no negativos.

No aplica reglas de impuestos, requisitos por tipo ni umbrales. Esa autoridad es del servidor, y una copia aquí terminaría rechazando comprobantes que el servidor sí acepta. Si el contenido es incorrecto, la respuesta es un 422 con el mensaje del servidor.

Timeouts

PSR-18 no define una API de timeouts, así que se configuran en el cliente que inyectes:

// Guzzle
use GuzzleHttp\Psr7\HttpFactory;

$factory = new HttpFactory();

$efac = EfacClient::builder()
    ->baseUrl($baseUrl)
    ->credentials($clientId, $clientSecret)
    ->httpClient(new \GuzzleHttp\Client(['connect_timeout' => 5, 'timeout' => 30]))
    ->requestFactory($factory)
    ->streamFactory($factory)
    ->build();

// Symfony HttpClient — Psr18Client implementa a la vez ClientInterface,
// RequestFactoryInterface y StreamFactoryInterface, así que se inyecta tres veces
$symfony = new \Symfony\Component\HttpClient\Psr18Client(
    \Symfony\Component\HttpClient\HttpClient::create(['timeout' => 30]),
);

$efac = EfacClient::builder()
    ->baseUrl($baseUrl)
    ->credentials($clientId, $clientSecret)
    ->httpClient($symfony)
    ->requestFactory($symfony)
    ->streamFactory($symfony)
    ->build();

Por la misma razón, este SDK no reintenta con backoff ante errores 5xx: esa política pertenece a tu cliente HTTP. La única excepción es un 401, que renueva el token y reintenta una sola vez — seguro incluso para emit(), porque un 401 es rechazo en la puerta de autenticación y ahí no se emitió ningún e-CF.

Caché del token

Por defecto el token se guarda en memoria del proceso. En una aplicación web conviene inyectar un almacén PSR-16 compartido para no re-autenticar en cada petición:

$factory = new \GuzzleHttp\Psr7\HttpFactory();

$efac = EfacClient::builder()
    ->baseUrl($baseUrl)
    ->credentials($clientId, $clientSecret)
    ->httpClient(new \GuzzleHttp\Client())
    ->requestFactory($factory)
    ->streamFactory($factory)
    ->tokenCache($miCachePsr16)
    ->build();

La clave se calcula por host, client_id y scope, de modo que varios emisores dentro de la misma aplicación nunca comparten token. El secreto nunca forma parte de la clave.

Alcance de esta versión

Cubierto: POST /v1/invoices, GET /v1/invoices/{encf}, GET /v1/invoices/{encf}/qr, GET /v1/invoices (en cola), GET /v1/issued-invoices (historial), POST /v1/void-requests (anulaciones), GET /v1/archive/years, GET /v1/archive/{year}, GET /v1/archive/{year}/{month} y la obtención del token.

Fuera de alcance por ahora (siguen disponibles vía la colección de Postman): secuencias, certificado, directorio, estado DGII, documentos recibidos, configuración y aprobaciones comerciales.