g-efac / sdk
SDK de PHP para la API /v1 de G-eFac (facturacion electronica e-CF, DGII Republica Dominicana)
Requires
- php: >=8.2
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
- psr/log: ^2.0 || ^3.0
- psr/simple-cache: ^2.0 || ^3.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.9
- laravel/pint: ^1.18
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.5
Suggests
- php-http/discovery: Descubre automaticamente un cliente PSR-18 y las fabricas PSR-17 ya instaladas en tu proyecto
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.