eu(); * echo $eu['company']['name']; * * O que cada método devolve, em uma frase: * * • UM registro → o objeto direto ($venda['id'], $cliente['name']); * • uma LISTA → ['data' => [...], 'meta' => [...]] — o `meta` traz o * cursor da próxima página, então ele NÃO é escondido; * • todos*() → percorre tudo, registro por registro, virando as páginas * sozinho (é o que você quer em 9 de 10 casos). * * ------------------------------------------------------------------------- * 2) Subir seus clientes (pode rodar quantas vezes quiser, não duplica) * ------------------------------------------------------------------------- * * $api->salvarClientePorExterno('cli-4471', [ * 'name' => 'Padaria do João', * 'person_type' => 'J', * 'document' => '12345678000190', * 'phone' => '(11) 98888-7777', * ]); * * ------------------------------------------------------------------------- * 3) Ler só o que mudou desde a última vez (sincronização incremental) * ------------------------------------------------------------------------- * * foreach ($api->todosClientes(['updated_since' => $ultimaSincronizacao]) as $cliente) { * // ...grava no seu banco. A paginação é feita sozinha. * } * * ------------------------------------------------------------------------- * 4) Registrar uma venda que aconteceu no SEU sistema * ------------------------------------------------------------------------- * * $formas = $api->formasDePagamento()['data']; // descubra os ids uma vez e guarde * * $venda = $api->registrarVenda([ * 'external_id' => 'PED-1001', // o número do pedido no SEU sistema * 'sold_at' => date('c'), * 'items' => [ * ['catalog_item_id' => 341, 'quantity' => '2', 'unit_price' => '10.00'], * ['description' => 'Taxa de montagem', 'quantity' => '1', 'unit_price' => '20.00'], * ], * 'payments' => [ * ['payment_type_id' => 18, 'amount' => '40.00'], * ], * ]); * * ------------------------------------------------------------------------- * 5) Tratar os erros que importam * ------------------------------------------------------------------------- * * try { * $api->registrarVenda($dados); * } catch (MaisPdvErro $e) { * if ($e->ehEstoqueInsuficiente()) { * // avise o cliente: nada foi gravado e nada saiu do estoque * } elseif ($e->ehDuplicado()) { * $jaEntrou = $e->idConflitante(); // a venda já estava lá * } else { * // guarde o request_id: é com ele que o suporte acha a sua chamada * error_log($e->getMessage() . ' [' . $e->requestId() . ']'); * } * } * * ------------------------------------------------------------------------- * 6) Receber webhook com a assinatura conferida * ------------------------------------------------------------------------- * * // no arquivo que recebe o POST do Mais PDV: * $evento = MaisPdv::lerWebhook('segredo_do_endereco'); * http_response_code(200); // responda 2xx ANTES de processar * * if ($evento['event'] === 'receivable.settled') { ... } * * ============================================================================= * Detalhes que evitam dor de cabeça * ============================================================================= * * • Dinheiro é sempre STRING com 2 casas ("149.90"). Float com centavo dá * diferença de arredondamento em qualquer linguagem — inclusive PHP. * • Campo desconhecido é recusado com 422 de propósito. Se você mandar * "telefone" onde esperamos "phone", você fica sabendo na hora. * • 429 e erros temporários são repetidos sozinhos, com espera crescente. * • As operações que mexem em dinheiro (baixa de conta e registro de venda) * mandam uma Idempotency-Key automática: se a conexão cair depois de o * servidor ter processado, repetir devolve o mesmo resultado em vez de * cobrar duas vezes. * * Requisitos: PHP 8.0+ com a extensão cURL. * Licença: uso livre pelos assinantes do Mais PDV e seus desenvolvedores. */ class MaisPdv { public const VERSAO = '1.0.0'; private const URL_PADRAO = 'https://maispdv.com/api/v1'; /** Teto por página aceito pela API. */ private const LIMITE_MAXIMO = 200; private string $token; private string $urlBase; private int $timeoutConexao; private int $timeoutTotal; private int $tentativas; /** * Logger opcional: recebe a mensagem e um array de contexto. * * @var callable|null */ private $log; /** * @param string $token a chave gerada em Empresa → Integrações (aparece uma vez só) * @param array $opcoes url_base, timeout_conexao, timeout_total, tentativas, log */ public function __construct(string $token, array $opcoes = []) { if (! extension_loaded('curl')) { throw new MaisPdvErro('A extensão cURL do PHP não está instalada — ela é necessária para falar com a API.'); } $token = trim($token); if ($token === '') { throw new MaisPdvErro('Informe a chave de API. Ela é gerada no Mais PDV, em Empresa → Integrações.'); } $this->token = $token; $this->urlBase = rtrim($opcoes['url_base'] ?? self::URL_PADRAO, '/'); $this->timeoutConexao = (int) ($opcoes['timeout_conexao'] ?? 5); $this->timeoutTotal = (int) ($opcoes['timeout_total'] ?? 30); $this->tentativas = max(1, (int) ($opcoes['tentativas'] ?? 3)); $this->log = $opcoes['log'] ?? null; } // ========================================================================= // Identidade // ========================================================================= /** Empresa, escopos e limites da chave. Bom para conferir se está tudo certo. */ public function eu(): array { return $this->item($this->obter('/me')); } /** `true` se a chave autentica agora. Não lança exceção — serve para tela de configuração. */ public function conexaoOk(): bool { try { $this->eu(); return true; } catch (MaisPdvErro $e) { return false; } } // ========================================================================= // Clientes // ========================================================================= /** Uma página de clientes. Para varrer tudo, use `todosClientes()`. */ public function listarClientes(array $filtros = []): array { return $this->obter('/customers', $filtros); } /** * Todos os clientes, virando as páginas sozinho. * * @return \Generator */ public function todosClientes(array $filtros = []): Generator { yield from $this->paginar('/customers', $filtros); } public function buscarCliente(int $id): array { return $this->item($this->obter("/customers/{$id}")); } public function criarCliente(array $dados): array { return $this->item($this->enviar('POST', '/customers', $dados)); } public function atualizarCliente(int $id, array $dados): array { return $this->item($this->enviar('PATCH', "/customers/{$id}", $dados)); } /** * Cria ou atualiza pelo SEU identificador — é o jeito recomendado. * * Rodar dez vezes cria uma vez e atualiza nas outras nove. */ public function salvarClientePorExterno(string $externalId, array $dados): array { return $this->item($this->enviar('PUT', '/customers/by-external/'.rawurlencode($externalId), $dados)); } /** Até 500 por chamada. Cada item precisa de `external_id`. */ public function enviarClientesEmLote(array $clientes): array { return $this->lote('/customers/batch', $clientes); } /** O que foi apagado (inclusive pela tela do assinante), para você apagar do seu lado. */ public function clientesExcluidos(?string $desde = null): array { return $this->obter('/customers/deleted', $desde ? ['since' => $desde] : []); } public function excluirCliente(int $id): array { return $this->item($this->enviar('DELETE', "/customers/{$id}")); } // ========================================================================= // Catálogo (produtos e serviços) // ========================================================================= public function listarItens(array $filtros = []): array { return $this->obter('/catalog/items', $filtros); } /** @return \Generator */ public function todosItens(array $filtros = []): Generator { yield from $this->paginar('/catalog/items', $filtros); } public function buscarItem(int $id): array { return $this->item($this->obter("/catalog/items/{$id}")); } public function criarItem(array $dados): array { return $this->item($this->enviar('POST', '/catalog/items', $dados)); } public function atualizarItem(int $id, array $dados): array { return $this->item($this->enviar('PATCH', "/catalog/items/{$id}", $dados)); } public function salvarItemPorExterno(string $externalId, array $dados): array { return $this->item($this->enviar('PUT', '/catalog/items/by-external/'.rawurlencode($externalId), $dados)); } public function enviarItensEmLote(array $itens): array { return $this->lote('/catalog/items/batch', $itens); } public function itensExcluidos(?string $desde = null): array { return $this->obter('/catalog/items/deleted', $desde ? ['since' => $desde] : []); } /** Desativa o item (o Mais PDV não apaga produto com histórico de venda). */ public function desativarItem(int $id): array { return $this->item($this->enviar('DELETE', "/catalog/items/{$id}")); } public function listarCategorias(): array { return $this->obter('/catalog/categories'); } public function criarCategoria(array $dados): array { return $this->item($this->enviar('POST', '/catalog/categories', $dados)); } // ========================================================================= // Fornecedores // ========================================================================= public function listarFornecedores(array $filtros = []): array { return $this->obter('/suppliers', $filtros); } /** @return \Generator */ public function todosFornecedores(array $filtros = []): Generator { yield from $this->paginar('/suppliers', $filtros); } public function criarFornecedor(array $dados): array { return $this->item($this->enviar('POST', '/suppliers', $dados)); } public function salvarFornecedorPorExterno(string $externalId, array $dados): array { return $this->item($this->enviar('PUT', '/suppliers/by-external/'.rawurlencode($externalId), $dados)); } public function enviarFornecedoresEmLote(array $fornecedores): array { return $this->lote('/suppliers/batch', $fornecedores); } public function excluirFornecedor(int $id): array { return $this->item($this->enviar('DELETE', "/suppliers/{$id}")); } // ========================================================================= // Financeiro — contas a receber e a pagar // ========================================================================= public function listarRecebiveis(array $filtros = []): array { return $this->obter('/finance/receivables', $filtros); } /** @return \Generator */ public function todosRecebiveis(array $filtros = []): Generator { yield from $this->paginar('/finance/receivables', $filtros); } public function criarRecebivel(array $dados): array { return $this->item($this->enviar('POST', '/finance/receivables', $dados)); } public function atualizarRecebivel(int $id, array $dados): array { return $this->item($this->enviar('PATCH', "/finance/receivables/{$id}", $dados)); } public function salvarRecebivelPorExterno(string $externalId, array $dados): array { return $this->item($this->enviar('PUT', '/finance/receivables/by-external/'.rawurlencode($externalId), $dados)); } public function enviarRecebiveisEmLote(array $contas): array { return $this->lote('/finance/receivables/batch', $contas); } public function excluirRecebivel(int $id): array { return $this->item($this->enviar('DELETE', "/finance/receivables/{$id}")); } /** * Baixa a conta a receber. * * `paid_at` é o que manda: o painel do assinante trabalha em regime de * caixa, então uma baixa com data retroativa entra no mês certo. * * A chave de idempotência é gerada aqui — se a sua chamada cair depois de o * servidor processar, repetir devolve o mesmo resultado em vez de baixar de * novo. Passe a sua se quiser controlar isso (ex.: guardar no seu banco). */ public function darBaixaRecebivel(int $id, array $dados = [], ?string $chaveIdempotencia = null): array { return $this->item($this->enviar('POST', "/finance/receivables/{$id}/settle", $dados, [ 'Idempotency-Key: '.($chaveIdempotencia ?: self::uuid()), ])); } public function listarPagaveis(array $filtros = []): array { return $this->obter('/finance/payables', $filtros); } /** @return \Generator */ public function todosPagaveis(array $filtros = []): Generator { yield from $this->paginar('/finance/payables', $filtros); } public function criarPagavel(array $dados): array { return $this->item($this->enviar('POST', '/finance/payables', $dados)); } public function atualizarPagavel(int $id, array $dados): array { return $this->item($this->enviar('PATCH', "/finance/payables/{$id}", $dados)); } public function salvarPagavelPorExterno(string $externalId, array $dados): array { return $this->item($this->enviar('PUT', '/finance/payables/by-external/'.rawurlencode($externalId), $dados)); } public function enviarPagaveisEmLote(array $contas): array { return $this->lote('/finance/payables/batch', $contas); } public function excluirPagavel(int $id): array { return $this->item($this->enviar('DELETE', "/finance/payables/{$id}")); } public function darBaixaPagavel(int $id, array $dados = [], ?string $chaveIdempotencia = null): array { return $this->item($this->enviar('POST', "/finance/payables/{$id}/settle", $dados, [ 'Idempotency-Key: '.($chaveIdempotencia ?: self::uuid()), ])); } /** Recebido e pago no período (por data de pagamento) + o que está em aberto. */ public function resumoFinanceiro(string $de, string $ate): array { return $this->item($this->obter('/finance/summary', ['from' => $de, 'to' => $ate])); } /** Chaves aceitas em `payment_method` das contas (não confundir com as formas da venda). */ public function formasDePagamentoDoFinanceiro(): array { return $this->obter('/finance/payment-methods'); } // ========================================================================= // Vendas // ========================================================================= public function listarVendas(array $filtros = []): array { return $this->obter('/sales', $filtros); } /** @return \Generator */ public function todasVendas(array $filtros = []): Generator { yield from $this->paginar('/sales', $filtros); } public function buscarVenda(int $id): array { return $this->item($this->obter("/sales/{$id}")); } /** A venda pelo número do pedido do SEU sistema. */ public function buscarVendaPorExterno(string $externalId): array { return $this->item($this->obter('/sales/by-external/'.rawurlencode($externalId))); } /** * Formas de pagamento desta empresa — o `payment_type_id` que a venda pede. * * Cada assinante cadastra as suas, então descubra uma vez e guarde. Forma * com `accepts_api: false` traz o motivo (convênio é fiado; crédito de * troca, promoção e cashback são descontos internos do PDV). */ public function formasDePagamento(): array { return $this->obter('/sales/payment-types'); } /** * Registra uma venda que JÁ aconteceu no seu sistema: baixa estoque e entra * no faturamento do assinante. * * O que costuma dar 422 na primeira tentativa: * • a soma de `payments` tem que fechar com o total (sobra só com dinheiro * em espécie, que vira troco) — a API não registra venda a prazo; * • faltou estoque de um item ⇒ a venda INTEIRA é recusada; * • cada item usa UM identificador: `catalog_item_id`, * `catalog_item_external_id` ou `description` (item avulso). * * A Idempotency-Key vai automática: reenviar depois de um timeout devolve a * MESMA venda em vez de criar outra. * * ⚠️ Para o reenvio funcionar, mande a MESMA chave com o MESMO corpo. Chave * repetida com corpo diferente (uma data reformatada já basta) é tratada * como erro de propósito — `ehChaveReutilizada()`. Na prática: guarde a * chave junto do pedido no seu banco e reenvie o payload idêntico. Se você * perdeu a chave, não tem problema: mande sem ela, e o `external_id` * repetido devolve `ehDuplicado()` com o id da venda que já entrou. */ public function registrarVenda(array $venda, ?string $chaveIdempotencia = null): array { return $this->item($this->enviar('POST', '/sales', $venda, [ 'Idempotency-Key: '.($chaveIdempotencia ?: self::uuid()), ])); } /** * Cancela a venda e devolve o estoque. * * Só vale para venda registrada pela API: venda feita no caixa é cancelada * no caixa, e venda com nota autorizada precisa do cancelamento fiscal, * feito pelo assinante dentro do sistema. */ public function cancelarVenda(int $id, string $motivo): array { return $this->item($this->enviar('POST', "/sales/{$id}/cancel", ['reason' => $motivo])); } // ========================================================================= // Webhooks // ========================================================================= public function listarWebhooks(): array { return $this->obter('/webhooks'); } /** * Cadastra o seu endereço. A URL precisa ser HTTPS. * * Guarde o `secret` que volta: é com ele que você confere a assinatura. * O endereço criado por aqui NÃO recebe de volta os eventos causados por * esta mesma chave — é o que evita os dois sistemas se cutucarem em loop. */ public function criarWebhook(string $url, array $eventos): array { return $this->item($this->enviar('POST', '/webhooks', ['url' => $url, 'events' => $eventos])); } public function atualizarWebhook(int $id, array $dados): array { return $this->item($this->enviar('PATCH', "/webhooks/{$id}", $dados)); } public function excluirWebhook(int $id): array { return $this->item($this->enviar('DELETE', "/webhooks/{$id}")); } /** Manda uma entrega de teste pelo mesmo caminho das de verdade. */ public function testarWebhook(int $id): array { return $this->item($this->enviar('POST', "/webhooks/{$id}/test")); } /** Últimas tentativas de entrega — onde descobrir por que não chegou. */ public function entregasDoWebhook(int $id): array { return $this->obter("/webhooks/{$id}/deliveries"); } /** * Lê o webhook que acabou de chegar, conferindo a assinatura. * * Use no arquivo que recebe o POST. Devolve o evento já decodificado * (`event`, `occurred_at`, `origin`, `data`) ou lança `MaisPdvErro` quando a * assinatura não confere — nesse caso, responda 401 e ignore. * * $evento = MaisPdv::lerWebhook('segredo_do_endereco'); * * ⚠️ Precisa do corpo CRU. Se o seu framework já leu o `php://input` * (Laravel, Symfony…), passe o corpo original no segundo parâmetro: * * $evento = MaisPdv::lerWebhook($segredo, $request->getContent(), $request->headers->all()); */ public static function lerWebhook(string $segredo, ?string $corpoBruto = null, array $headers = []): array { $corpo = $corpoBruto ?? (string) file_get_contents('php://input'); $timestamp = self::header($headers, 'X-MaisPDV-Timestamp') ?? ($_SERVER['HTTP_X_MAISPDV_TIMESTAMP'] ?? ''); $assinatura = self::header($headers, 'X-MaisPDV-Signature') ?? ($_SERVER['HTTP_X_MAISPDV_SIGNATURE'] ?? ''); if (! self::assinaturaConfere($corpo, (string) $timestamp, (string) $assinatura, $segredo)) { throw new MaisPdvErro('Assinatura do webhook não confere — responda 401 e ignore este envio.', 'invalid_signature', 401); } $evento = json_decode($corpo, true); if (! is_array($evento)) { throw new MaisPdvErro('O corpo do webhook não é um JSON válido.', 'malformed_json', 400); } return $evento; } /** * Confere a assinatura por conta própria (quando você já tem corpo, * timestamp e assinatura em mãos). * * A conta é `HMAC-SHA256(timestamp + "." + corpo_cru)` com o segredo do * endereço. Use o corpo **cru**: reserializar o JSON muda um byte e a * assinatura não fecha. A janela de 5 minutos existe contra replay. */ public static function assinaturaConfere( string $corpoBruto, string $timestamp, string $assinatura, string $segredo, int $toleranciaSegundos = 300 ): bool { if ($timestamp === '' || $assinatura === '') { return false; } if ($toleranciaSegundos > 0 && abs(time() - (int) $timestamp) > $toleranciaSegundos) { return false; } $esperada = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$corpoBruto, $segredo); return hash_equals($esperada, $assinatura); } // ========================================================================= // Chamadas cruas — para endpoints novos, antes de este arquivo ser atualizado // ========================================================================= public function obter(string $caminho, array $query = []): array { return $this->requisicao('GET', $caminho, null, $query); } public function enviar(string $metodo, string $caminho, ?array $corpo = null, array $headers = []): array { return $this->requisicao($metodo, $caminho, $corpo, [], $headers); } // ========================================================================= // Motor // ========================================================================= /** * Vira as páginas sozinho, seguindo o cursor até acabar. * * Use `updated_since` para trazer só o que mudou — é isso que transforma * uma sincronização de minutos em uma de segundos. * * @return \Generator */ private function paginar(string $caminho, array $filtros): Generator { $filtros['limit'] = min((int) ($filtros['limit'] ?? self::LIMITE_MAXIMO), self::LIMITE_MAXIMO); $cursor = null; do { if ($cursor !== null) { $filtros['cursor'] = $cursor; } $resposta = $this->obter($caminho, $filtros); foreach ($resposta['data'] ?? [] as $registro) { yield $registro; } $cursor = $resposta['meta']['next_cursor'] ?? null; } while ($cursor !== null); } /** * Tira o envelope `{ "data": ... }` dos métodos que devolvem UM registro. * * As listas continuam com o envelope inteiro de propósito: sem o `meta` * você não tem o cursor da próxima página, e um SDK que esconde isso faz * o integrador achar que a base dele tem 50 clientes. */ private function item(array $resposta): array { return $resposta['data'] ?? $resposta; } /** Lote: até 500 itens, cada um com `external_id`. Resposta 207 traz sucesso e falha juntos. */ private function lote(string $caminho, array $itens): array { if (count($itens) > 500) { throw new MaisPdvErro('O lote aceita no máximo 500 itens por chamada. Quebre a sua lista em partes.'); } return $this->enviar('POST', $caminho, ['items' => array_values($itens)]); } /** * Faz a chamada, repetindo o que vale a pena repetir. * * São repetidos: falha de conexão, 429 (respeitando o `Retry-After`) e 5xx. * POST só é repetido quando leva `Idempotency-Key` — sem ela, repetir * poderia criar o registro duas vezes, que é justamente o que queremos * evitar. */ private function requisicao(string $metodo, string $caminho, ?array $corpo, array $query = [], array $headersExtras = []): array { $url = $this->urlBase.'/'.ltrim($caminho, '/'); if ($query) { $url .= (strpos($url, '?') === false ? '?' : '&').http_build_query($query); } $temIdempotencia = (bool) preg_grep('/^Idempotency-Key:/i', $headersExtras); $podeRepetir = $metodo === 'GET' || $temIdempotencia; $headers = array_merge([ 'Authorization: Bearer '.$this->token, 'Accept: application/json', 'Content-Type: application/json', 'User-Agent: MaisPDV-PHP/'.self::VERSAO.' (PHP '.PHP_VERSION.')', ], $headersExtras); $tentativa = 0; while (true) { $tentativa++; [$corpoResposta, $status, $erroCurl, $cabecalhos] = $this->executar($metodo, $url, $corpo, $headers); // Falha de rede: a resposta não chegou. Repetir é seguro em GET e // em qualquer chamada com Idempotency-Key. if ($erroCurl !== null) { if ($podeRepetir && $tentativa < $this->tentativas) { $this->esperar($tentativa); continue; } throw new MaisPdvErro( 'Não foi possível falar com o Mais PDV: '.$erroCurl, 'connection_error', 0 ); } $dados = $corpoResposta === '' ? [] : json_decode($corpoResposta, true); if (! is_array($dados)) { $dados = ['error' => ['code' => 'malformed_response', 'message' => 'Resposta inesperada do servidor.']]; } if ($status >= 200 && $status < 300) { return $dados; } // 207: lote com sucesso e falha juntos. Não é erro — o chamador lê // `meta.errors` e decide o que reprocessar. if ($status === 207) { return $dados; } $repetivel = $status === 429 || $status >= 500; if ($repetivel && $podeRepetir && $tentativa < $this->tentativas) { $this->esperar($tentativa, $this->retryAfter($cabecalhos)); continue; } throw MaisPdvErro::daResposta($dados, $status); } } /** @return array{0: string, 1: int, 2: string|null, 3: array} */ private function executar(string $metodo, string $url, ?array $corpo, array $headers): array { $ch = curl_init(); $cabecalhos = []; curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_CUSTOMREQUEST => $metodo, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => $this->timeoutConexao, CURLOPT_TIMEOUT => $this->timeoutTotal, CURLOPT_HTTPHEADER => $headers, CURLOPT_HEADERFUNCTION => function ($ch, $linha) use (&$cabecalhos) { $partes = explode(':', $linha, 2); if (count($partes) === 2) { $cabecalhos[strtolower(trim($partes[0]))] = trim($partes[1]); } return strlen($linha); }, ]); if ($corpo !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($corpo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); } $resposta = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $erro = curl_errno($ch) ? curl_error($ch) : null; curl_close($ch); $this->registrar($metodo.' '.$url, ['status' => $status, 'erro' => $erro]); return [is_string($resposta) ? $resposta : '', $status, $erro, $cabecalhos]; } /** Espera crescente: 1s, 2s, 4s… respeitando o `Retry-After` quando ele vem. */ private function esperar(int $tentativa, ?int $retryAfter = null): void { $segundos = $retryAfter ?? (2 ** ($tentativa - 1)); sleep(max(1, min($segundos, 60))); } private function retryAfter(array $cabecalhos): ?int { $valor = $cabecalhos['retry-after'] ?? null; return is_numeric($valor) ? (int) $valor : null; } private function registrar(string $mensagem, array $contexto = []): void { if ($this->log) { ($this->log)($mensagem, $contexto); } } private static function header(array $headers, string $nome): ?string { foreach ($headers as $chave => $valor) { if (strcasecmp((string) $chave, $nome) === 0) { return is_array($valor) ? (string) reset($valor) : (string) $valor; } } return null; } /** UUID v4 para a Idempotency-Key. */ public static function uuid(): string { $bytes = random_bytes(16); $bytes[6] = chr((ord($bytes[6]) & 0x0F) | 0x40); $bytes[8] = chr((ord($bytes[8]) & 0x3F) | 0x80); return implode('-', [ bin2hex(substr($bytes, 0, 4)), bin2hex(substr($bytes, 4, 2)), bin2hex(substr($bytes, 6, 2)), bin2hex(substr($bytes, 8, 2)), bin2hex(substr($bytes, 10, 6)), ]); } } /** * Erro devolvido pela API (ou pela própria biblioteca). * * O `code` é contrato público e estável — trate por ele, nunca pela mensagem. * O `request_id` é o que o suporte usa para achar a sua chamada em segundos. */ class MaisPdvErro extends RuntimeException { private string $codigo; private int $status; private array $detalhes; private ?string $requestId; public function __construct( string $mensagem, string $codigo = 'client_error', int $status = 0, array $detalhes = [], ?string $requestId = null ) { parent::__construct($mensagem, $status); $this->codigo = $codigo; $this->status = $status; $this->detalhes = $detalhes; $this->requestId = $requestId; } /** @param array $dados corpo já decodificado da resposta de erro */ public static function daResposta(array $dados, int $status): self { $erro = $dados['error'] ?? []; return new self( $erro['message'] ?? 'Erro na chamada à API do Mais PDV.', $erro['code'] ?? 'unknown_error', $status, $erro['details'] ?? [], $erro['request_id'] ?? null ); } public function codigo(): string { return $this->codigo; } public function status(): int { return $this->status; } /** Campo a campo, quando o erro é de validação. */ public function detalhes(): array { return $this->detalhes; } /** Guarde este código no seu log: é com ele que o suporte acha a chamada. */ public function requestId(): ?string { return $this->requestId; } // --------------------------------------------------------------------- // Atalhos para os casos que o seu código realmente trata diferente // --------------------------------------------------------------------- /** Chave errada, revogada ou expirada — gere outra no painel. */ public function ehAutenticacao(): bool { return $this->status === 401; } /** Falta escopo na chave. `detalhes()['required_scopes']` diz qual. */ public function ehPermissao(): bool { return $this->codigo === 'insufficient_scope'; } /** A conta do assinante está sem o adicional de API ativo. */ public function ehPlano(): bool { return $this->codigo === 'plan_required'; } public function ehNaoEncontrado(): bool { return $this->status === 404; } /** Já existe um registro com esse identificador. Veja `idConflitante()`. */ public function ehDuplicado(): bool { return $this->codigo === 'duplicate_resource'; } /** O id do registro que já existia — evita você criar um irmão gêmeo. */ public function idConflitante(): ?int { return isset($this->detalhes['conflicting_id']) ? (int) $this->detalhes['conflicting_id'] : null; } /** * A mesma Idempotency-Key foi usada com um corpo diferente. * * Quase sempre é o reenvio de algo que você montou de novo em vez de * guardar: uma data reformatada, um valor com casas diferentes. Reenvie o * payload idêntico, ou mande sem chave e trate o `ehDuplicado()`. */ public function ehChaveReutilizada(): bool { return $this->codigo === 'idempotency_key_reused'; } /** Faltou estoque: nada foi gravado e nada saiu do estoque. */ public function ehEstoqueInsuficiente(): bool { return $this->codigo === 'insufficient_stock'; } /** A soma dos pagamentos não fechou com o total da venda. */ public function ehPagamentoDivergente(): bool { return $this->codigo === 'payment_mismatch'; } /** Registro mantido pelo sistema (venda do balcão, conta gerada por venda). */ public function ehDoPdv(): bool { return $this->codigo === 'managed_by_pdv'; } public function ehValidacao(): bool { return $this->codigo === 'validation_failed'; } public function ehLimiteDeUso(): bool { return $this->status === 429; } /** Vale tentar de novo mais tarde (rede, limite de uso ou erro nosso). */ public function ehTemporario(): bool { return $this->status === 0 || $this->status === 429 || $this->status >= 500; } }