emitirFactura(['serie' => 'F001', 'cliente' => [...], 'items' => [...]], 'venta-' . $venta['id']); * * La llave secreta va solo en el servidor (variables de entorno), nunca en el código público. * Documentación completa: https://api.nooki.pe/openapi.json */ namespace Nooki; /** Error de la API de Nooki (o de conexión). */ class NookiError extends \RuntimeException { /** @var int Código HTTP (0 si no hubo respuesta). */ public $status; /** @var string datos_invalidos, no_autorizado, no_encontrado, conflicto, limite_plan, error_conexion, firma_invalida… */ public $codigo; /** @var string|null Campo con el problema, ej. "cliente.num_doc". */ public $campo; /** @var mixed */ public $respuesta; public function __construct(string $mensaje, int $status = 0, string $codigo = 'error_conexion', ?string $campo = null, $respuesta = null) { parent::__construct($mensaje, $status); $this->status = $status; $this->codigo = $codigo; $this->campo = $campo; $this->respuesta = $respuesta; } } class Cliente { const VERSION = '1.0.0'; const ESTADOS_FINALES = ['aceptado', 'observado', 'rechazado', 'error', 'por_verificar', 'anulado']; /** @var string */ private $llave; /** @var string */ private $secreto; /** @var string */ private $url; /** @var int */ private $reintentos; /** @var int */ private $timeout; /** * @param string $llave X-Api-Key (fe_test_… o fe_live_…) * @param string $secreto X-Api-Secret * @param array $op url (https://api.nooki.pe), reintentos (2), timeout en segundos (30) */ public function __construct(string $llave, string $secreto, array $op = []) { if ($llave === '' || $secreto === '') { throw new \InvalidArgumentException('Nooki: faltan la llave y el secreto (se sacan en el portal de Nooki)'); } if (!function_exists('curl_init')) { throw new \RuntimeException('Nooki: activa la extensión curl de PHP'); } $this->llave = $llave; $this->secreto = $secreto; $this->url = rtrim($op['url'] ?? 'https://api.nooki.pe', '/'); $this->reintentos = (int) ($op['reintentos'] ?? 2); $this->timeout = (int) ($op['timeout'] ?? 30); } // ---------- Emisión ---------- // $idempotencia: un valor único por venta (ej. "venta-123"). Si el pedido se repite, no se duplica. public function emitirFactura(array $datos, ?string $idempotencia = null): array { return $this->emitir('/v1/facturas', $datos, $idempotencia); } public function emitirBoleta(array $datos, ?string $idempotencia = null): array { return $this->emitir('/v1/boletas', $datos, $idempotencia); } public function emitirNotaCredito(array $datos, ?string $idempotencia = null): array { return $this->emitir('/v1/notas-credito', $datos, $idempotencia); } public function emitirNotaDebito(array $datos, ?string $idempotencia = null): array { return $this->emitir('/v1/notas-debito', $datos, $idempotencia); } // ---------- Comprobantes ---------- public function comprobante(int $id): array { return $this->pedir('GET', "/v1/comprobantes/$id"); } /** Filtros: tipo_doc, estado, desde, hasta (AAAA-MM-DD), limite, despues_de. Devuelve ['datos' => [...], 'siguiente' => ...]. */ public function comprobantes(array $filtros = []): array { return $this->pedir('GET', '/v1/comprobantes' . self::consulta($filtros)); } public function xml(int $id): string { return $this->pedir('GET', "/v1/comprobantes/$id/xml", null, ['texto' => true]); } public function cdr(int $id): string { return $this->pedir('GET', "/v1/comprobantes/$id/cdr", null, ['texto' => true]); } /** PDF con código QR (bytes): $formato 'a4' (por defecto) o 'ticket' (80 mm). Ej.: file_put_contents('f.pdf', $nooki->pdf($id)); */ public function pdf(int $id, string $formato = 'a4'): string { return $this->pedir('GET', "/v1/comprobantes/$id/pdf?formato=" . rawurlencode($formato), null, ['texto' => true, 'tipo' => 'application/pdf']); } public function reenviar(int $id): array { return $this->pedir('POST', "/v1/comprobantes/$id/reenviar", null, ['reintentar' => false]); } /** Baja (facturas) o resumen de anulación (boletas). Repetirlo devuelve la misma. */ public function anular(int $id, string $motivo): array { return $this->pedir('POST', "/v1/comprobantes/$id/anular", ['motivo' => $motivo]); } /** Espera a que SUNAT responda (mejor usa avisos; esto sirve para scripts y pruebas). */ public function esperar(int $id, int $timeoutS = 60, float $intervaloS = 1.5): array { $limite = microtime(true) + $timeoutS; while (true) { $c = $this->comprobante($id); if (in_array($c['estado'], self::ESTADOS_FINALES, true) || microtime(true) >= $limite) return $c; usleep((int) ($intervaloS * 1e6)); } } // ---------- Resúmenes y bajas ---------- public function crearResumenDiario(?string $fecha = null): array { return $this->pedir('POST', '/v1/resumenes', $fecha ? ['fecha' => $fecha] : (object) [], ['reintentar' => false]); } public function resumenes(array $filtros = []): array { return $this->pedir('GET', '/v1/resumenes' . self::consulta($filtros)); } public function resumen(int $id): array { return $this->pedir('GET', "/v1/resumenes/$id"); } public function xmlResumen(int $id): string { return $this->pedir('GET', "/v1/resumenes/$id/xml", null, ['texto' => true]); } public function cdrResumen(int $id): string { return $this->pedir('GET', "/v1/resumenes/$id/cdr", null, ['texto' => true]); } public function reenviarResumen(int $id): array { return $this->pedir('POST', "/v1/resumenes/$id/reenviar", null, ['reintentar' => false]); } // ---------- Avisos (webhooks) ---------- public function webhook(): array { return $this->pedir('GET', '/v1/webhook'); } /** La primera vez devuelve 'secreto' (una sola vez): guárdalo. */ public function configurarWebhook(string $url): array { return $this->pedir('PUT', '/v1/webhook', ['url' => $url]); } public function desactivarWebhook(): void { $this->pedir('DELETE', '/v1/webhook'); } public function rotarSecretoWebhook(): array { return $this->pedir('POST', '/v1/webhook/secreto', null, ['reintentar' => false]); } public function avisoDePrueba(): array { return $this->pedir('POST', '/v1/webhook/prueba', null, ['reintentar' => false]); } public function entregas(array $filtros = []): array { return $this->pedir('GET', '/v1/webhook/entregas' . self::consulta($filtros)); } public function entrega(string $id): array { return $this->pedir('GET', '/v1/webhook/entregas/' . rawurlencode($id)); } public function reintentarEntrega(string $id): array { return $this->pedir('POST', '/v1/webhook/entregas/' . rawurlencode($id) . '/reintentar'); } // ---------- Empresa ---------- public function empresa(): array { return $this->pedir('GET', '/v1/empresa'); } public function series(): array { return $this->pedir('GET', '/v1/series'); } /** * Comprueba que un aviso viene de Nooki y devuelve su contenido como arreglo. * $aviso = \Nooki\Cliente::verificarAviso(file_get_contents('php://input'), $_SERVER['HTTP_NOOKI_FIRMA'] ?? '', getenv('NOOKI_WEBHOOK_SECRETO')); * Lanza NookiError (codigo firma_invalida) si no es válido: responde 401. */ public static function verificarAviso(string $cuerpo, ?string $cabeceraFirma, string $secreto, int $toleranciaS = 300): array { if (!preg_match('/^t=(\d+),v1=([0-9a-f]{64})$/', trim((string) $cabeceraFirma), $m)) { throw new NookiError('Aviso rechazado: falta la cabecera Nooki-Firma o tiene otro formato', 0, 'firma_invalida'); } if ($secreto === '') throw new NookiError('Aviso rechazado: falta el secreto de avisos', 0, 'firma_invalida'); if (abs(time() - (int) $m[1]) > $toleranciaS) { throw new NookiError('Aviso rechazado: la firma es muy antigua (revisa la hora del servidor)', 0, 'firma_invalida'); } $esperada = hash_hmac('sha256', $m[1] . '.' . $cuerpo, $secreto); if (!hash_equals($esperada, $m[2])) throw new NookiError('Aviso rechazado: la firma no coincide', 0, 'firma_invalida'); $aviso = json_decode($cuerpo, true); if (!is_array($aviso)) throw new NookiError('Aviso rechazado: el contenido no es JSON', 0, 'firma_invalida'); return $aviso; } // ---------- Interno ---------- private function emitir(string $ruta, array $datos, ?string $idempotencia): array { // Siempre con clave de idempotencia: así reintentar ante un corte nunca duplica el comprobante. $clave = $idempotencia ?? ('nooki-' . bin2hex(random_bytes(16))); return $this->pedir('POST', $ruta, $datos, ['cabeceras' => ['Idempotency-Key: ' . $clave]]); } /** @return mixed */ private function pedir(string $metodo, string $ruta, $cuerpo = null, array $op = []) { $texto = $op['texto'] ?? false; $intentos = ($op['reintentar'] ?? true) ? $this->reintentos + 1 : 1; $cabeceras = array_merge([ 'X-Api-Key: ' . $this->llave, 'X-Api-Secret: ' . $this->secreto, 'Accept: ' . ($op['tipo'] ?? ($texto ? 'application/xml' : 'application/json')), 'User-Agent: nooki-php/' . self::VERSION, ], $op['cabeceras'] ?? []); if ($cuerpo !== null) $cabeceras[] = 'Content-Type: application/json'; for ($intento = 1; ; $intento++) { $ch = curl_init($this->url . $ruta); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => $metodo, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $cabeceras, CURLOPT_TIMEOUT => $this->timeout, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_FOLLOWLOCATION => false, ]); if ($cuerpo !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($cuerpo, JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION)); } $respuesta = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $error = curl_error($ch); curl_close($ch); $reintentable = $respuesta === false || in_array($status, [502, 503, 504], true); if ($reintentable && $intento < $intentos) { usleep(500000 * (2 ** ($intento - 1))); continue; } if ($respuesta === false) { throw new NookiError('No se pudo conectar con Nooki: ' . $error, 0, 'error_conexion'); } if ($status === 204) return null; if ($status >= 200 && $status < 300) { return $texto ? $respuesta : json_decode($respuesta, true); } $datos = json_decode($respuesta, true); $e = is_array($datos) ? ($datos['error'] ?? null) : null; throw new NookiError( $e['mensaje'] ?? "Nooki respondió $status", $status, $e['codigo'] ?? 'error_http', $e['campo'] ?? null, $datos ); } } private static function consulta(array $filtros): string { $limpios = array_filter($filtros, function ($v) { return $v !== null && $v !== ''; }); return $limpios ? '?' . http_build_query($limpios) : ''; } }