Search by

SDK oficial en PHP del API de facturación electrónica de FactuSmart (SUNAT, Perú): facturas, boletas, notas, guías y anulaciones.

v0.6.0 2026-11-10 20:53 UTC

This package is auto-updated.

Last update: 2026-11-10 20:54:49 UTC


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-curl y ext-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'];

baseUrl no 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->raiz() GET / nuevo; ¿responde el API? (estado: ok); no usa RUC
$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->rucs->subirCertificado($ruc, $certificado, $clave) PUT /rucs/{ruc}/certificado nuevo; .pfx/.p12 por ruta o bytes (el SDK hace el base64); nunca se reintenta solo
$fs->rucs->configurarSunat($ruc, $datos) PUT /rucs/{ruc}/sunat usuario_sol + clave_sol, entorno (prueba/produccion) y/o modalidad (propio/pse, con pse_usuario + pse_clave) y confirmar (producción → prueba); nunca se reintenta solo
$fs->rucs->verificar($ruc) POST /rucs/{ruc}/verificar nuevo; prueba certificado y SOL contra SUNAT sin emitir; se reintenta como un GET
$fs->rucs->configuracion($ruc) GET /rucs/{ruc}/configuracion nuevo; entorno, certificado, SOL enmascarado y faltantes
$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
$fs->consultas->consultarRuc($numero) / consultarDni($numero) / consultar($tipo, $numero) GET /consultas/{tipo}/{numero} nuevo; nombre y dirección de un RUC o DNI; gratis (cupo propio); no manda ruc

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

Consultar un RUC o DNI

use FactuSmart\Codigos;
use FactuSmart\Excepciones\{LimiteDePeticiones, NoEncontrado};

try {
    $c = $fs->consultas->consultarRuc('20131312955');   // o consultarDni('12345678')
    echo $c['nombre'], ' — ', $c['direccion'] ?? 'sin dirección', "\n";
    echo "Te quedan {$c['consultas_restantes']} consultas (se renueva el {$c['se_renueva']})\n";
} catch (NoEncontrado $e) {
    echo 'Ese número no existe';
} catch (LimiteDePeticiones $e) {
    if ($e->codigo === Codigos::LIMITE_CONSULTAS) {
        echo 'Se acabó el cupo; vuelve el ', $e->respuesta['se_renueva'];
    }
}

No gasta créditos ni manda ruc. Repetir un número en 30 días responde desde_cache: true y no descuenta. estado, condicion, direccion y ubigeo pueden venir null. LIMITE_CONSULTAS (429) no se reintenta; CONSULTA_NO_DISPONIBLE (503) sí, como cualquier GET.

Configurar un RUC por API (certificado, SOL y producción)

use FactuSmart\Excepciones\ErrorDeValidacion;

$ruc = '20777666555';
try {
    // 1. Certificado: ruta del archivo o sus bytes (file_get_contents, lo que subió tu usuario...).
    $r = $fs->rucs->subirCertificado($ruc, '/ruta/certificado.pfx', $clavePfx);
    echo "Vence el {$r['certificado']['vence']}
";       // ruc_coincide: true | null (+ aviso)

    // 2. Usuario secundario SOL y paso a producción (pueden ir en el mismo pedido).
    $fs->rucs->configurarSunat($ruc, ['usuario_sol' => 'FACTURA1', 'clave_sol' => $claveSol, 'entorno' => 'produccion']);

    // 3. Prueba real contra SUNAT, sin emitir nada.
    $v = $fs->rucs->verificar($ruc);
    if (!$v['listo_para_emitir']) {
        echo $v['checks']['sol']['mensaje'];              // ok: true | false | null (no aplica / SUNAT no respondió)
    }
} catch (ErrorDeValidacion $e) {
    echo $e->mensaje;                                     // clave incorrecta, vencido, de otro RUC, falta algo...
    print_r($e->respuesta['faltantes'] ?? []);            // al pasar a producción: certificado, sol...
}

// En cualquier momento, sin llamar a SUNAT:
$cfg = $fs->rucs->configuracion($ruc);   // entorno, certificado.vence, sol.usuario ('FA*****1'), faltantes

Dos caminos para producción (Smart PSE es el único PSE):

Modalidad Quién firma Qué necesitas para produccion
propio tu certificado, directo a SUNAT certificado (subirCertificado) + usuario SOL
pse Smart PSE, sin certificado propio pse_usuario + pse_clave (usuario_secundaria y token_acceso de la empresa en Smart PSE) + usuario SOL
// Camino Smart PSE: sin certificado. El SOL también hace falta en producción (Consultar CDR, arreglo del 1033).
$cfg = $fs->rucs->configurarSunat($ruc, [
    'modalidad' => 'pse', 'pse_usuario' => $pseUsuario, 'pse_clave' => $pseClave,
    'usuario_sol' => 'FACTURA1', 'clave_sol' => $claveSol, 'entorno' => 'produccion',
]);
echo $cfg['pse']['usuario'];   // 'FU*****F' (la clave nunca se devuelve ni sale en un error del SDK)
echo $cfg['aviso'];            // recuerda designar a Smart PSE como tu PSE en SUNAT
$v = $fs->rucs->verificar($ruc);
var_dump($v['checks']['pse']['ok']);   // pide un token a Smart PSE, no emite
$fs->rucs->configurarSunat($ruc, ['modalidad' => 'propio']);   // volver a tu certificado

Antes de emitir en producción con Smart PSE, el contribuyente debe designar a Smart PSE como su PSE en SUNAT (Clave SOL → Empresas → PSE). verificar() no puede comprobar esa designación.

Volver de producción a prueba exige 'confirmar' => true (los comprobantes de prueba no tienen validez y usan la misma numeración). Sin él: ErrorDeValidacion con codigo CONFIRMAR_CAMBIO_ENTORNO y no se guarda nada.

try {
    $fs->rucs->configurarSunat($ruc, ['entorno' => 'prueba']);
} catch (ErrorDeValidacion $e) {
    if ($e->codigo === Codigos::CONFIRMAR_CAMBIO_ENTORNO) {
        $cfg = $fs->rucs->configurarSunat($ruc, ['entorno' => 'prueba', 'confirmar' => true]);   // trae `aviso`
    }
}

Errores propios de la configuración del RUC:

codigo HTTP Qué hacer
RUC_DESACTIVADO 403 RUC bloqueado o plan vencido: reactívalo. Aplica a subirCertificado, configurarSunat y verificar (configuracion sigue respondiendo)
LIMITE_VERIFICACIONES 429 más de 10 verificaciones por hora para la misma llave y RUC (verificar, o configurarSunat con claves). Espera $e->segundosDeEspera (cabecera Retry-After); el SDK no lo repite solo porque la espera puede ser de varios minutos
CONFIRMAR_CAMBIO_ENTORNO 422 repite con 'confirmar' => true si de verdad quieres pasar a prueba. No reintentable
PSE_NO_DISPONIBLE 503 Smart PSE no respondió con el RUC en producción; no se guardó nada. Reintenta más tarde

configuracion()['faltantes'] puede traer certificado, certificado_vencido, certificado_sin_clave_privada, certificado_otro_ruc (el certificado instalado no es de este RUC; solo con propio), sol (también con pse en producción) y pse_credenciales. Puede traer aviso (designar a Smart PSE, o el certificado vence en < 30 días). La respuesta siempre trae modalidad y pse (null con propio).

  • El RUC va solo en la ruta (11 dígitos; se valida antes de enviar). No se usa el RUC por defecto del cliente.
  • $certificado: si el string trae un byte nulo, no es UTF-8 válido o empieza con 0x30 (como todo .pfx), se toma como bytes; si no, como ruta, y si el archivo no existe falla sin enviar nada. Máx. 5 MB.
  • Las claves (clave, clave_sol) y el contenido del certificado nunca salen en los mensajes de error del SDK ni en $e->respuesta/$e->campos (se tapan con ***), ni en la traza de la excepción (PHP ≥ 8.2, #[\SensitiveParameter]).
  • La llave de prueba (fs_test_…) no puede subir certificados ni cambiar SOL/entorno (403 SIN_PERMISO); verificar() y configuracion() sí funcionan con ella. Ninguno consume crédito.

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_unitario es sin IGV.
  • afectacion: 10 gravado (IGV 18 %), 20 exonerado, 30 inafecto, 40 exportación (IGV 0, suma en total_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 manda null).
  • 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 (0 si fue de red).
  • $e->mensaje: el message del API, en español, para mostrar.
  • $e->codigo: código estable en MAYÚSCULAS para decidir en tu código (constantes en FactuSmart\Codigos). null si no hubo respuesta o el servidor aún no lo manda.
  • $e->reintentable: true si el API dice que repetir la misma petición puede funcionar; false si 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)
LIMITE_CONSULTAS se acabó el cupo de consultas RUC/DNI (429, no reintentable; ver se_renueva)
CONSULTA_NO_DISPONIBLE SUNAT y el respaldo no respondieron la consulta RUC/DNI (503, reintentable)
LIMITE_VERIFICACIONES más de 10 verificaciones por hora del RUC (429, reintentable tras $e->segundosDeEspera; el SDK no lo repite solo)
CONFIRMAR_CAMBIO_ENTORNO pasar a prueba un RUC en producción exige 'confirmar' => true (422)
PSE_NO_DISPONIBLE Smart PSE no respondió; no se guardó nada (503, reintentable más tarde)
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_decode y luego json_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 ErrorDeValidacion con codigo ANULACION_MIXTA. Haz una petición por tipo.
  • La respuesta trae también external_id, creditos_restantes, credito_cobrado y, en RUCs demo, modo_demo y pruebas_restantes_hoy.
  • Nunca se reintenta sola (no admite Idempotency-Key).

Reintentos e idempotencia

  • comprobantes->emitir() siempre manda el header Idempotency-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"). En emitir, también el 409 ("emisión en curso con esa Idempotency-Key"). Y cualquier error cuyo cuerpo diga reintentable: true (p.ej. ERROR_INTERNO, SUNAT_NO_DISPONIBLE), solo en las operaciones de abajo.
  • reintentable: false manda: si el cuerpo del error lo dice, no se reintenta nunca, aunque sea 429, 409 o 5xx. Caso real: 429 con codigo LIMITE_PRUEBAS_DIARIO (tope diario de los RUCs demo) lanza LimiteDePeticiones tras una sola petición, con $e->codigo === Codigos::LIMITE_PRUEBAS_DIARIO y $e->reintentable === false. Decide por $e->codigo, no por la clase. Si el servidor no manda reintentable, se usa la regla por HTTP.
  • 429 LIMITE_VERIFICACIONES tampoco se repite solo (aunque sea reintentable: true): su Retry-After puede ser de media hora. Lanza LimiteDePeticiones con $e->segundosDeEspera.
  • Se reintentan: todas las consultas (GET), comprobantes->calcular() y rucs->verificar() (no tienen efectos), comprobantes->emitir() y rucs->crear() (idempotente por RUC).
  • Nunca se reintentan solos: anulaciones->crear(), guias->emitir(), comprobantes->reenviar(), comprobantes->consultarSunat(), rucs->activar(), rucs->subirCertificado(), rucs->configurarSunat().
  • maxReintentos => 0 desactiva 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.