factusmart / sdk
SDK oficial en PHP del API de facturación electrónica de FactuSmart (SUNAT, Perú): facturas, boletas, notas, guías y anulaciones.
Requires
- php: >=8.1
- ext-curl: *
- ext-json: *
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
SDK oficial del API de facturación electrónica de FactuSmart (SUNAT, Perú): facturas, boletas, notas de crédito/débito, guías de remisión GRE 2.0 y anulaciones (facturas y boletas).
- PHP 8.1 o superior, solo
ext-curlyext-json. Cero dependencias. - Mismos nombres y comportamiento que los SDK de Node y Python.
- Los datos van y vuelven tal cual los define el API (claves en español), sin traducir.
Documentación del API: https://factusmart.pe/api-facturacion-electronica/docs
Instalar
composer require factusmart/sdk
Uso en 10 líneas
$fs = new FactuSmart\Cliente(['apiKey' => 'TU_API_KEY', 'baseUrl' => 'https://TU_URL_BASE/api/v1/issuer', 'ruc' => '20100070970']); ['items' => $items, 'totales' => $totales] = FactuSmart\Cliente::calcularComprobante([ ['descripcion' => 'Servicio de consultoría', 'cantidad' => 1, 'valor_unitario' => 100, 'afectacion' => '10'], ]); $r = $fs->comprobantes->emitir([ 'codigo_tipo_documento' => '01', 'datos_del_cliente_o_receptor' => ['codigo_tipo_documento_identidad' => '6', 'numero_documento' => '20131312955'], 'items' => $items, 'totales' => $totales, ]); echo $r['aceptado_por_sunat'] ? 'Aceptado ' : 'NO aceptado ', $r['serie_numero'];
baseUrlno es igual para todos. Cada cuenta vive en un servidor distinto: copia la "URL base de la API" de tu panel de integrador (pestaña Configuración).
Hay un ejemplo completo en ejemplos/emitir_boleta.php.
La regla de oro: HTTP 200 no significa "aceptado por SUNAT"
Un 200 significa que FactuSmart generó tu comprobante. SUNAT puede estar caída o rechazarlo,
y entonces recibes estado: "01" con aceptado_por_sunat: false y el motivo en sunat.descripcion.
El SDK no lanza excepción en ese caso: es un resultado normal que tu sistema debe revisar.
$r = $fs->comprobantes->emitir($datos); if (!$r['aceptado_por_sunat']) { // $r['estado'], $r['sunat']['descripcion'], $r['accion_requerida'] // Reintento gratis, sin gastar otro correlativo: $fs->comprobantes->reenviar($r['external_id']); }
Revisa también entorno (prueba / produccion) y tiene_validez_fiscal: cada RUC nace en modo prueba.
Constructor
| Opción | Obligatoria | Default | Nota |
|---|---|---|---|
apiKey |
sí | — | va en el header X-API-Key |
baseUrl |
sí | — | la URL de tu panel, ej. https://s2.factusmart.pe/api/v1/issuer |
ruc |
no | — | RUC emisor por defecto; se puede cambiar por llamada con ['ruc' => ...] |
timeout |
no | 60 |
segundos (SUNAT a veces tarda) |
maxReintentos |
no | 3 |
ver "Reintentos" |
verificarSsl |
no | true |
ponlo en false solo para pruebas locales con certificado propio |
Si falta apiKey o baseUrl se lanza FactuSmartError de inmediato.
Métodos
Todos devuelven un array con la respuesta JSON del API, salvo descargar() que devuelve los bytes.
$opciones acepta ruc (sobreescribe el del constructor) y, en emitir, idempotencyKey.
| Método | Endpoint | Notas |
|---|---|---|
$fs->ping($opciones) |
GET /ping |
prueba la API key; trae creditos |
$fs->cuenta->saldo() |
GET /account |
creditos_disponibles, plan, RUCs |
$fs->series->listar($opciones) |
GET /series |
FA01, BA01, FN01... |
$fs->comprobantes->emitir($datos, $opciones) |
POST /documents |
1 crédito; siempre con Idempotency-Key |
$fs->comprobantes->listar($filtros, $opciones) |
GET /documents |
filtros: estado, desde, hasta, limite, antes_de |
$fs->comprobantes->consultar($externalId, $opciones) |
GET /documents/{id} |
estado guardado + links |
$fs->comprobantes->descargar($externalId, $tipo, $opciones) |
GET /documents/{id}/{tipo} |
pdf, xml o cdr → string binario; nuevo $opciones['formato'] (solo pdf): a4, a5, ticket, ticket80, ticket58, ticket50 |
$fs->comprobantes->reenviar($externalId, $opciones) |
POST /documents/{id}/reenviar |
gratis; no se reintenta solo |
$fs->comprobantes->consultarSunat($externalId, $opciones) |
POST /documents/{id}/consultar-sunat |
consulta en vivo; no se reintenta solo |
$fs->comprobantes->historial($externalId, $opciones) |
GET /documents/{id}/historial |
línea de tiempo |
$fs->comprobantes->calcular($items, $opciones) |
POST /calcular |
nuevo; el servidor arma items y totales; gratis, no emite; $opciones: porcentaje_igv (18 o 10.5) |
$fs->anulaciones->crear($datos, $opciones) |
POST /anulaciones |
facturas y boletas; 1 crédito; nunca se reintenta solo |
$fs->anulaciones->estado($ticket, $opciones) |
GET /anulaciones/{ticket}/status |
trae tipo y documentos[] |
$fs->guias->emitir($datos, $opciones) |
POST /guias |
revisa enviado; nunca se reintenta solo |
$fs->guias->estado($externalId, $opciones) |
GET /guias/{id}/status |
|
$fs->rucs->listar() |
GET /rucs |
|
$fs->rucs->crear($datos) |
POST /rucs |
idempotente por RUC (ya_existia) |
$fs->rucs->activar($ruc, $activo) |
PATCH /rucs/{ruc}/activation |
true activa, false desactiva |
$fs->catalogos->listar($opciones) |
GET /catalogos |
nuevo; catálogos SUNAT disponibles (catalogos) |
$fs->catalogos->consultar($nombre, $opciones) |
GET /catalogos/{nombre} |
nuevo; $nombre = unidades o número SUNAT (07); $opciones: q, ruc. Usa solo filas con activo: true |
$fs->catalogos->buscarProductoSunat($q, $opciones) |
GET /catalogos/productos-sunat |
nuevo; código de producto SUNAT (catálogo 25) por código o texto; $opciones: limite (máx. 50), nivel, ruc |
$fs->establecimientos->listar($opciones) |
GET /establecimientos |
nuevo; locales (anexos) del RUC con sus series |
$fs->establecimientos->crear($datos, $opciones) |
POST /establecimientos |
nuevo; crea un local; nunca se reintenta solo |
PDF en ticket u otro tamaño
$pdf = $fs->comprobantes->descargar($externalId, 'pdf', ['formato' => 'ticket']); // 80 mm file_put_contents('ticket.pdf', $pdf);
formato: a4, a5, ticket (= ticket80), ticket80, ticket58, ticket50. Sin formato sale el A4
de siempre. Con xml/cdr, o un valor fuera de la lista, lanza ErrorDeValidacion (codigo
DATOS_INVALIDOS, $e->campos['formato']) sin hacer la petición. Pedir un ticket no cambia el PDF
que recibe tu cliente final.
Anexos (establecimientos)
$r = $fs->establecimientos->listar(); foreach ($r['establecimientos'] as $local) { echo $local['codigo_anexo'], ' ', $local['nombre'], $local['predeterminado'] ? ' (predeterminado)' : '', "\n"; } // Regístralo antes en SUNAT (Clave SOL): el API no lo da de alta allá. $nuevo = $fs->establecimientos->crear([ 'codigo_anexo' => '0001', 'nombre' => 'Tienda Miraflores', 'direccion' => 'Av. Larco 345', 'ubigeo' => '150122', 'series' => [['codigo_tipo_documento' => '03', 'serie' => 'B002']], // opcional ]);
crear() nunca se reintenta solo (no es idempotente). Si el plan del RUC no permite más locales lanza
RucNoAutorizado con codigo LIMITE_ESTABLECIMIENTOS.
Buscar el código de producto SUNAT
$r = $fs->catalogos->buscarProductoSunat('computador', ['limite' => 10]); // $r['datos'][0]['codigo'] → '43211503'; $r['hay_mas'] indica si hay más resultados
Lo que comprobantes->emitir() completa por ti
Solo si no lo mandas tú:
ruc→ el del constructor (o el de$opciones). En los GET va como?ruc=; en POST/PATCH, en el cuerpo.numero_documento→"#"(FactuSmart asigna el correlativo). Si mandas tu propio número, se respeta.fecha_de_emision/hora_de_emision→ fecha y hora de Perú (America/Lima), no la de tu servidor.codigo_tipo_operacion→"0101"(solo en facturas y boletas; en notas 07/08 no aplica).codigo_tipo_moneda→"PEN".
guias->emitir() completa igual ruc, numero_documento y la fecha/hora de Perú.
Calcular IGV y totales
¿Calcular en el servidor o local?
$fs->comprobantes->calcular() (servidor) |
Cliente::calcularComprobante() (local) |
|
|---|---|---|
| Petición | sí (POST /calcular, gratis, no emite) |
ninguna |
| Precio | valor_unitario (sin IGV) o precio_unitario (con IGV: 3 × S/ 10.00 = S/ 30.00) |
solo valor_unitario sin IGV |
| Tasa IGV | 18 % o 10.5 % (Ley 31556, porcentaje_igv) |
solo 18 % |
| Lógica en tu lado | cero: el cálculo lo hace siempre FactuSmart | la del SDK (mismos números que el servidor en sus casos) |
Usa el del servidor si tienes precios con IGV, la tasa 10.5 % o quieres que el cálculo lo haga siempre FactuSmart. Usa el local si quieres cero peticiones extra.
$c = $fs->comprobantes->calcular([ ['descripcion' => 'Menú', 'cantidad' => 3, 'precio_unitario' => 10, 'afectacion' => '10'], ], ['porcentaje_igv' => 10.5]); // $c['items'] y $c['totales'] listos para emitir(). Errores en $e->campos con clave items.N.campo.
Local, sin petición
$r = FactuSmart\Cliente::calcularComprobante([ ['codigo_interno' => 'P-001', 'descripcion' => 'Polo', 'cantidad' => 3, 'valor_unitario' => 10.555, 'afectacion' => '10'], ['descripcion' => 'Libro', 'cantidad' => 2, 'valor_unitario' => 5, 'afectacion' => '20', 'unidad_de_medida' => 'NIU'], ]); // $r['items'] y $r['totales'] listos para emitir()
valor_unitarioes sin IGV.afectacion:10gravado (IGV 18 %),20exonerado,30inafecto,40exportación (IGV 0, suma entotal_exportacion).- Redondeo decimal exacto a 2 decimales por ítem (mitad hacia arriba, sin errores de coma flotante:
1.005→1.01), igual que la función de la documentación pública. - Si no mandas
codigo_interno, la clave no aparece en el ítem (no se mandanull). - Da exactamente los mismos números que los SDK de Node y Python (ver
resultado_caso_comun.json). - Para otras afectaciones (gratuitas, IVAP, ISC, bolsas) arma los ítems tú mismo.
Errores
Todas las excepciones heredan de FactuSmart\Excepciones\FactuSmartError y traen:
$e->status: HTTP (0si fue de red).$e->mensaje: elmessagedel API, en español, para mostrar.$e->codigo: código estable en MAYÚSCULAS para decidir en tu código (constantes enFactuSmart\Codigos).nullsi no hubo respuesta o el servidor aún no lo manda.$e->reintentable:truesi el API dice que repetir la misma petición puede funcionar;falsesi no (o si el servidor no lo manda).$e->campos: en los 422, mapa campo → lista de mensajes. Vacío si no aplica.$e->respuesta: el cuerpo completo.
Decide por $e->codigo, nunca por el texto de $e->mensaje (puede cambiar).
| HTTP | Excepción | Cuándo |
|---|---|---|
| 401 | ErrorDeAutenticacion |
API key ausente, inválida o cuenta desactivada |
| 402 | SinCreditos |
sin saldo; no se emitió nada |
| 403 | RucNoAutorizado |
el RUC no es de tu cuenta o está desactivado |
| 404 | NoEncontrado |
comprobante, guía, ticket o RUC inexistente |
| 422 | ErrorDeValidacion |
datos inválidos (el crédito no se cobra). $e->errores trae el mapa campo → mensajes cuando el API lo da |
| 429 | LimiteDePeticiones |
límite superado; $e->segundosDeEspera si se pudo leer |
| otros / red | FactuSmartError |
use FactuSmart\Excepciones\{FactuSmartError, ErrorDeValidacion, SinCreditos}; try { $r = $fs->comprobantes->emitir($datos, ['idempotencyKey' => $venta->id]); } catch (ErrorDeValidacion $e) { echo "Corrige los datos: {$e->mensaje}"; } catch (SinCreditos $e) { echo 'Sube de plan o pide créditos extra para tu plan'; } catch (FactuSmartError $e) { echo "Error {$e->status} {$e->codigo}: {$e->mensaje}"; }
Códigos más comunes:
codigo |
Qué pasó |
|---|---|
NO_AUTENTICADO |
API key inválida o ausente |
PLAN_VENCIDO / SIN_CREDITOS |
renueva el plan / sube de plan o pide créditos extra para tu plan |
RUC_NO_AUTORIZADO / RUC_DESACTIVADO / RUC_REQUERIDO |
problema con el RUC emisor |
DATOS_INVALIDOS |
revisa $e->campos |
NO_ENCONTRADO |
el comprobante, guía, ticket o RUC no existe |
EMISION_EN_CURSO |
otra petición con la misma Idempotency-Key sigue en curso |
SUNAT_NO_DISPONIBLE |
SUNAT no responde; reintentable |
LIMITE_PETICIONES |
espera $e->segundosDeEspera |
LIMITE_PRUEBAS_DIARIO |
RUC demo: llegaste al tope de 200 operaciones/día de la cuenta (429, no reintentable; se reinicia a medianoche de Lima) |
ANULACION_MIXTA |
mezclaste facturas y boletas en una anulación (422) |
LLAVE_DE_PRUEBA_EN_PRODUCCION |
usaste una llave de prueba contra un RUC en producción (403) |
ERROR_INTERNO |
error del servidor; reintentable |
Lista completa en FactuSmart\Codigos (también SIN_PERMISO, RUTA_NO_ENCONTRADA, METODO_NO_PERMITIDO,
CONFLICTO, RUC_SIN_TENANT, CERTIFICADO_NO_CONFIGURADO, ERROR_EMISION, RECHAZADO_SUNAT,
REENVIO_FALLIDO, y ERROR_<status> cuando no hay uno específico).
Verificar webhooks
FactuSmart firma cada webhook con el header X-FactuSmart-Firma (HMAC-SHA256 del cuerpo con tu secreto
de webhooks). Verifícalo antes de confiar en el contenido:
$cuerpo = file_get_contents('php://input'); // el cuerpo CRUDO, tal cual llegó $firma = $_SERVER['HTTP_X_FACTUSMART_FIRMA'] ?? ''; if (!FactuSmart\Cliente::verificarFirmaWebhook($cuerpo, $firma, getenv('FACTUSMART_WEBHOOK_SECRETO'))) { http_response_code(401); exit; } $evento = json_decode($cuerpo, true); // ... procesa $evento ... http_response_code(200);
Usa el cuerpo crudo. Si haces
json_decodey luegojson_encode, los bytes cambian (espacios, escapes, orden) y la firma ya no coincide. En frameworks:$request->getContent()(Laravel/Symfony).
Nunca lanza excepción: firma o secreto vacíos, o una firma que no sea de 64 caracteres hex, devuelven false.
Anulaciones (facturas y boletas)
$fs->anulaciones->crear() anula facturas (comunicación de baja) y boletas (resumen diario; solo
boletas aceptadas, estado 05). El método es el mismo: el API elige el camino y lo dice en la respuesta.
$r = $fs->anulaciones->crear([ 'fecha_de_emision_de_documentos' => '14-10-2026', // DD-MM-AAAA, la de emisión de los comprobantes 'documentos' => [['external_id' => $externalId, 'motivo_anulacion' => 'Error en el comprobante']], ]); // $r['tipo'] 'baja' (facturas) | 'resumen' (boletas) // $r['identificador'] 'RA-20261014-1' | 'RC-20261014-1' // $r['ticket'] para $fs->anulaciones->estado($r['ticket'])
- No mezcles facturas y boletas en la misma petición: lanza
ErrorDeValidacionconcodigoANULACION_MIXTA. Haz una petición por tipo. - La respuesta trae también
external_id,creditos_restantes,credito_cobradoy, en RUCs demo,modo_demoypruebas_restantes_hoy. - Nunca se reintenta sola (no admite
Idempotency-Key).
Reintentos e idempotencia
comprobantes->emitir()siempre manda el headerIdempotency-Key: el tuyo (['idempotencyKey' => ...], recomendado: el id de la venta en tu sistema) o un UUID v4 generado. La misma clave se usa en todos los reintentos de esa llamada, así un reintento nunca emite dos veces. Si vuelves a llamar con la misma clave, el API devuelve el mismo comprobante sin cobrar otra vez.- Se reintenta solo ante error de red/timeout, HTTP 429, 502, 503 y 504,
esperando 1 s, 2 s, 4 s… (o lo que diga
Retry-After/ "Espera N segundos"). Enemitir, también el409("emisión en curso con esa Idempotency-Key"). Y cualquier error cuyo cuerpo digareintentable: true(p.ej.ERROR_INTERNO,SUNAT_NO_DISPONIBLE), solo en las operaciones de abajo. reintentable: falsemanda: si el cuerpo del error lo dice, no se reintenta nunca, aunque sea 429, 409 o 5xx. Caso real:429concodigoLIMITE_PRUEBAS_DIARIO(tope diario de los RUCs demo) lanzaLimiteDePeticionestras una sola petición, con$e->codigo === Codigos::LIMITE_PRUEBAS_DIARIOy$e->reintentable === false. Decide por$e->codigo, no por la clase. Si el servidor no mandareintentable, se usa la regla por HTTP.- Se reintentan: todas las consultas (GET),
comprobantes->calcular()(no tiene efectos),comprobantes->emitir()yrucs->crear()(idempotente por RUC). - Nunca se reintentan solos:
anulaciones->crear(),guias->emitir(),comprobantes->reenviar(),comprobantes->consultarSunat(),rucs->activar(). maxReintentos => 0desactiva los reintentos.
RUCs demo (modo prueba)
En un RUC demo no se cobra crédito: las respuestas traen credito_cobrado: false, modo_demo: true y
pruebas_restantes_hoy. El saldo no baja. A cambio hay un tope de 200 operaciones/día por cuenta
(429 LIMITE_PRUEBAS_DIARIO, no reintentable). Cada RUC nace en modo prueba (SUNAT beta, sin validez fiscal).
Pruebas
composer install vendor/bin/phpunit --testsuite unidad # sin red vendor/bin/phpunit --testsuite integracion # contra un API LOCAL (emite 3 boletas demo) php scripts/caso_comun.php # regenera resultado_caso_comun.json
Las pruebas de integración leen FACTUSMART_BASE_URL, FACTUSMART_API_KEY, FACTUSMART_RUC y
FACTUSMART_VERIFICAR_SSL (valores por defecto en phpunit.xml) y se niegan a correr contra un host
que no sea local. La prueba de contrato lee el openapi.yaml oficial (FACTUSMART_OPENAPI, o la copia en
tests/fixtures/) y falla si algún operationId no tiene método en el SDK.
Licencia
MIT. Ver LICENSE.