diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md index 61102d1..7d7b166 100644 --- a/.github/SUPPORT.md +++ b/.github/SUPPORT.md @@ -8,7 +8,7 @@ Se você encontrar problemas, dúvidas ou precisa de ajuda, siga estas etapas: 1. **Consulte a documentação**: - - Leia o [README.md](./README.md) do repositório para instruções de uso e soluções comuns. + - Leia o [README.md](../README.md) do repositório para instruções de uso e soluções comuns. 2. **Pesquise nas issues**: diff --git a/CLAUDE.md b/CLAUDE.md index 5f225bb..6f9eb3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -187,7 +187,7 @@ quebra a integração, cobra o valor errado. ## Retry e idempotência A `PHPay\Http\RetryPolicy` decide o que pode ser repetido, e o middleware entra na -`ClientFactory` — então os dez gateways herdam a política de uma vez. +`ClientFactory` — então os nove gateways herdam a política de uma vez. - **Repetir é sobre dinheiro, não sobre resiliência.** Um `POST` repetido que o gateway já processou cobra o cliente duas vezes. Só é repetido: leitura @@ -316,7 +316,7 @@ a notificação veio do gateway é `SupportsWebhookSignature`, e são coisas dif header, então o middleware não o vê. - `SupportsWebhookSignature` fica em cinco gateways: Asaas, PagBank, Mercado Pago, Woovi e AbacatePay. Efí (mTLS), Cielo, Rede e Pagar.me ficam fora, cada um pelo - seu motivo, e os motivos estão no README. O HMAC-SHA1 da Woovi é aceito, mas é + seu motivo, e os motivos estão em `docs/webhooks.md`. O HMAC-SHA1 da Woovi é aceito, mas é depreciado pela própria Woovi. - `SupportsWebhooks` e `SupportsPixKeys` só no Asaas, no Woovi e na Efí — os três são PSP. Mercado Pago, PagBank e Pagar.me registram endpoints por painel, e Pix neles é diff --git a/README.md b/README.md index e3480cc..be0e824 100644 --- a/README.md +++ b/README.md @@ -21,23 +21,7 @@ - [Requisitos](#requisitos) - [Instalação](#instalação) - [Início rápido](#início-rápido) -- [Conceitos](#conceitos) - - [Capacidades](#capacidades) - - [Tratamento de erros](#tratamento-de-erros) - - [Retry e idempotência](#retry-e-idempotência) - - [Ambientes e credenciais](#ambientes-e-credenciais) - - [Unidade monetária](#unidade-monetária) - - [Verificando o webhook que chega](#verificando-o-webhook-que-chega) -- [Gateways](#gateways) - - [Asaas](#asaas) - - [Mercado Pago](#mercado-pago) - - [PagBank](#pagbank) - - [Pagar.me](#pagarme) - - [Cielo](#cielo) - - [Rede](#rede) - - [AbacatePay](#abacatepay) - - [Woovi/OpenPix](#wooviopenpix) - - [Efí](#efí) +- [Documentação](#documentação) - [Exemplos executáveis](#exemplos-executáveis) - [Migrando da v1](#migrando-da-v1) - [Roadmap](#roadmap) @@ -73,34 +57,42 @@ Trocar de gateway é trocar a linha do construtor. ## Gateways suportados -| Gateway | Clientes | Cobranças | Assinaturas | Webhooks | Chaves Pix | -| --- | :---: | :---: | :---: | :---: | :---: | -| **Asaas** | ✅ | ✅ | ✅ | ✅ | ✅ | -| **Woovi/OpenPix** | ✅ | ✅ | ✅ | ✅ | ✅ | -| **Efí** | — | ✅ | ✅ | ✅ | ✅ | -| **Mercado Pago** | ✅ | ✅ | ✅ | — | — | -| **PagBank** | ✅ | ✅ | ✅ | — | — | -| **Pagar.me** | ✅ | ✅ | ✅ | — | — | -| **AbacatePay** | ✅ | ✅ | — | — | — | -| **Cielo** | — | ✅ | ✅ | — | — | -| **Rede** | — | ✅ | — | — | — | +| Gateway | Clientes | Cobranças | Assinaturas | Webhooks | Webhook assinado | Chaves Pix | +| --- | :---: | :---: | :---: | :---: | :---: | :---: | +| **Asaas** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| **Woovi/OpenPix** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| **Efí** | — | ✅ | ✅ | ✅ | — | ✅ | +| **Mercado Pago** | ✅ | ✅ | ✅ | — | ✅ | — | +| **PagBank** | ✅ | ✅ | ✅ | — | ✅ | — | +| **Pagar.me** | ✅ | ✅ | ✅ | — | — | — | +| **AbacatePay** | ✅ | ✅ | — | — | ✅ | — | +| **Cielo** | — | ✅ | ✅ | — | — | — | +| **Rede** | — | ✅ | — | — | — | — | As interfaces correspondentes são `SupportsCustomers`, `SupportsCharges`, -`SupportsSubscriptions`, `SupportsWebhooks` e `SupportsPixKeys`. +`SupportsSubscriptions`, `SupportsWebhooks`, `SupportsWebhookSignature` e +`SupportsPixKeys`. -Duas colunas merecem explicação, porque a ausência de ✅ **não** quer dizer que o -gateway não aceita Pix ou não manda webhook: +Três colunas merecem explicação, porque a ausência de ✅ **não** quer dizer que o +gateway não aceita Pix, não manda webhook ou não assina nada: - **`SupportsPixKeys`** significa *gerenciar chaves Pix e QR Code estático*, o que só um PSP que emite chave própria oferece. Nos outros gateways, Pix é forma de pagamento de uma cobrança — e todos aceitam. As duas metades não andam juntas em todo PSP: o QR Code estático está no Asaas e na Woovi, que - expõem endpoint para ele, e **não** na [Efí](#efí), cuja API Pix não tem + expõem endpoint para ele, e **não** na [Efí](docs/gateways/efi.md), cuja API Pix não tem essa rota. - **`SupportsWebhooks`** significa *cadastrar endpoints pela API*. Nos outros, o cadastro é no painel; a notificação vai por cobrança, no campo `notification_url`. O Pagar.me ainda deixa **consultar e reenviar entregas**, - através de [`webhookDeliveries()`](#consultando-entregas-de-webhook). + através de [`webhookDeliveries()`](docs/gateways/pagarme.md#consultando-entregas-de-webhook). +- **`SupportsWebhookSignature`** significa *provar que a notificação recebida + veio do gateway*. É outra coisa que cadastrar endpoint, e as duas não andam + juntas: Mercado Pago, PagBank e AbacatePay assinam sem deixar cadastrar pela + API, enquanto Efí, Cielo, Rede e Pagar.me não dão o que verificar — a Efí + notifica por mTLS, Cielo e Rede não mandam assinatura, e a v5 do Pagar.me não + documenta esquema. O motivo de cada um está em + [Verificando o webhook que chega](docs/webhooks.md). --- @@ -160,1050 +152,35 @@ try { ``` Um array devolvido é **sempre** uma resposta de sucesso. Qualquer falha vira -exceção — veja [Tratamento de erros](#tratamento-de-erros). +exceção — veja [Tratamento de erros](docs/erros.md). --- -## Conceitos - -Três coisas valem entender uma vez; depois todo gateway se comporta igual. - -### Capacidades - -`GatewayInterface` carrega só a identidade do gateway. Cada recurso é uma -interface que o gateway implementa **se, e só se,** oferecer: - -```php -use PHPay\Contracts\Capability; - -$phpay = PHPay::gateway(new RedeGateway(REDE_PV, REDE_TOKEN)); - -$phpay->name(); // 'Rede' -$phpay->supports(Capability::SUBSCRIPTIONS); // false -$phpay->capabilities(); // [Capability::CHARGES] -``` - -Chamar um recurso que o gateway não oferece lança uma exceção que diz o que ele -oferece: - -```php -$phpay->pix(); -// NotImplementedException: Rede não suporta chaves Pix. -// Capacidades disponíveis: cobranças. -``` - -Se você segurar o **gateway concreto** em vez da facade, o erro sobe para tempo -de análise — o PHPStan acusa que o método não existe naquele tipo: - -```php -$rede = new RedeGateway(REDE_PV, REDE_TOKEN); - -$rede->charge(); // ✅ -$rede->pix(); // ❌ o método não existe nesse gateway -``` - -Para injeção de dependência, tipe a capacidade em vez do gateway: - -```php -use PHPay\Contracts\SupportsCharges; - -public function __construct(private SupportsCharges $gateway) {} -``` - -### Tratamento de erros - -Toda exceção da biblioteca implementa `PHPay\Exceptions\PHPayException`, então -um `catch` cobre a integração inteira: - -| Exceção | Estende | Quando acontece | -| --- | --- | --- | -| `ValidationException` | `InvalidArgumentException` | Payload inválido, **antes** de qualquer HTTP | -| `ApiException` | `RuntimeException` | O gateway recusou, ou está inacessível | -| `NotImplementedException` | `BadMethodCallException` | O gateway não oferece o recurso | - -```php -use PHPay\Exceptions\ApiException; -use PHPay\Exceptions\PHPayException; -use PHPay\Exceptions\ValidationException; - -try { - $cobranca = $phpay->charge()->setCharge($dados)->create(); -} catch (ValidationException $e) { - // payload inválido: nenhuma requisição foi feita -} catch (ApiException $e) { - $e->getStatusCode(); // 400, 401, 404… ou 0 se nem chegou ao gateway - $e->getResponse(); // corpo devolvido pelo gateway - $e->getGateway(); // 'Asaas', 'Pagar.me', … - $e->isConnectionError(); // true em timeout, DNS, TLS — vale retry -} catch (PHPayException $e) { - // qualquer outra falha do PHPay -} -``` - -`ApiException` já resume os formatos de erro de cada gateway, então -`getMessage()` traz a descrição legível, não um dump. - -### Retry e idempotência - -A biblioteca repete uma requisição **só quando repetir não pode cobrar de novo**. -Vale para os dez gateways, porque o middleware vive na -`PHPay\Http\ClientFactory`, o único lugar onde um client nasce. - -| Situação | Repete | -| --- | --- | -| Erro antes de a requisição sair (DNS, connect, handshake TLS) | sempre — nada chegou ao gateway | -| Timeout sem conexão estabelecida | sempre — nada chegou | -| Timeout com conexão estabelecida | só leitura, ou com chave de idempotência | -| `429` | sempre, respeitando `Retry-After` | -| `5xx` | só leitura, ou com chave de idempotência | -| `4xx` fora do 429 | nunca — o gateway entendeu e recusou | - -Leitura é `GET`, `HEAD` e `OPTIONS`. `PUT`, `PATCH` e `DELETE` **não** são -repetidos, apesar de o HTTP chamar `PUT` e `DELETE` de idempotentes: a -documentação do Asaas pede para -[não tratar a exclusão de cobrança como idempotente](https://docs.asaas.com/reference/excluir-cobranca) -sem conferir a resposta, e esse aviso vale mais que a teoria. - -São 3 tentativas no total — 200ms, 400ms, teto de 2s, com jitter. Se o gateway -mandar um `Retry-After` maior que o teto, a biblioteca **desiste** em vez de -repetir antes da janela pedida, porque repetir cedo só seria recusado de novo. -E lembre que retry multiplica o pior caso de tempo de parede: 3 tentativas com -`timeout` de 60s é o limite superior. - -Para mudar a política, passe uma à fábrica: - -```php -use PHPay\Http\{ClientFactory, RetryPolicy}; - -$maisTentativas = ClientFactory::make(['base_uri' => $uri], new RetryPolicy(5)); -$semRetry = ClientFactory::make(['base_uri' => $uri], RetryPolicy::disabled()); -``` - -#### Chave de idempotência - -Com uma chave, o `POST` passa a ser repetível: o gateway reconhece a segunda -requisição como a mesma operação e devolve o resultado da primeira em vez de -cobrar outra vez. - -| Gateway | Header | Como | -| --- | --- | --- | -| Mercado Pago | `X-Idempotency-Key` | sempre enviada — a API de pagamentos exige | -| Pagar.me | `Idempotency-Key` | você informa; vale 24h | -| PagBank | `x-idempotency-key` | você informa; vale 48h | - -```php -$pedido = $pagbank->charge() - ->setCustomer($cliente) - ->addItem('Assinatura PHPay', 10050) - ->setIdempotencyKey($idDoSeuPedido) - ->create(); -``` - -**Use uma chave sua, guardada junto do registro pendente.** Chave gerada pela -biblioteca cobre só as tentativas de dentro da mesma chamada — o retry reenvia -a mesma requisição, com o mesmo header. O retry do seu lado, em outro processo -ou em outra chamada a `create()`, chega com chave nova e cobra de novo. +## Documentação -Os outros gateways não documentam chave de idempotência. Neles um `POST` só é -repetido quando o erro provou que a requisição não saiu da máquina — e no Asaas -a orientação da própria documentação é -[consultar a cobrança antes de repetir](https://docs.asaas.com/docs/cobrancas-via-cartao-de-credito). +A documentação completa está em [`docs/`](docs/). O que vale para todos os +gateways está nos conceitos; cada página de gateway cobre só o que é +específico dele. ---- - -### Ambientes e credenciais - -Os gateways discordam sobre como separar teste de produção, e o PHPay segue o -que cada um faz em vez de inventar um padrão: +**Conceitos** -| Gateway | Como o ambiente é decidido | +| | | | --- | --- | -| **Asaas** | `$sandbox` no construtor — troca a URL | -| **PagBank** | `$sandbox` no construtor — troca a URL | -| **Cielo** | `$sandbox` no construtor — troca **as duas** URLs | -| **Efí** | `$sandbox` no construtor — troca **as duas** URLs (Cobranças e Pix) | -| **Rede** | `$sandbox` no construtor — troca **as duas** URLs e o caminho do token | -| **Mercado Pago** | Prefixo do token (`TEST-`); host único, sem `$sandbox` | -| **Pagar.me** | Prefixo da chave (`sk_test_`); host único, sem `$sandbox` | -| **Woovi** | `$sandbox` no construtor — o sandbox tem **domínio próprio** | -| **AbacatePay** | Pela chave usada; host único, **sem prefixo** — a cobrança informa em `devMode` | - -Nos dois últimos, `isSandbox()` diz em qual ambiente você está: - -```php -(new MercadoPagoGateway($token))->isSandbox(); -(new PagarMeGateway($secretKey))->isSandbox(); -``` - -> **Nunca** versione credenciais. Os arquivos `examples/*/credentials.php` são -> ignorados pelo git por padrão. - -`var_dump()` e `print_r()` de um gateway ou recurso mostram `********` no lugar -da credencial, e o client HTTP, que a carrega nos headers, aparece oculto. O -`dd()` do Symfony VarDumper lê as propriedades por conta própria e **não** passa -por essa máscara: não despeje o gateway em log. - -Toda requisição tem prazo: 60 segundos no total e 10 para conectar. Sem isso, um -gateway que trava prenderia o worker indefinidamente. Um estouro de prazo chega -como `ApiException` com `isConnectionError()` verdadeiro. - -### Cliente - -O mesmo campo tem **seis grafias** entre os gateways: `cpfCnpj` no Asaas, -`tax_id` no PagBank, `document` no Pagar.me, `taxId` no AbacatePay, `taxID` no -Woovi, `cpf_cnpj` no Efí. Código escrito para um não migra para outro, e nada -no tipo avisa. - -`Customer` é a forma única. Cada gateway mapeia para o formato dele: - -```php -use PHPay\Support\Customer; - -$cliente = Customer::make( - name: 'Mário Lucas', - document: '123.456.789-01', // pontuação é limpa - email: 'fale@phpay.io', - phone: '(11) 94002-8922', -); - -$phpay->charge()->setCustomer($cliente); // funciona nos nove -``` - -Ele também carrega o que cada gateway deriva do cliente, e que antes ficava -espalhado: - -```php -$cliente->isIndividual(); // CPF: o Pagar.me precisa como type: 'individual' -$cliente->documentType(); // 'CPF' | 'CNPJ': a Cielo quer em IdentityType -$cliente->firstName(); // o Mercado Pago quer nome e sobrenome separados -$cliente->phoneParts(); // ['country' => '55', 'area' => '11', ...] para o PagBank -``` - -Para um cliente que já existe no gateway, ou para campos que só aquele gateway -tem: - -```php -$cliente->withId('cus_000006337812'); // reaproveita em vez de criar -$cliente->withExtra(['externalReference' => 'x']); // vai junto no payload -``` - -> O `withExtra()` existe para o que é específico de um gateway — endereço, -> data de nascimento, referência externa. **Nenhum campo obrigatório de -> nenhum dos nove gateways precisa dele**: o value object cobre todos. - -### Unidade monetária - -Os gateways discordam sobre a unidade, e **errar não quebra a integração — ela -cobra o valor errado**. Mandar `100.50` num gateway de centavos cobra R$ 1,00, -e você só descobre na conciliação. - -Use `Money` e o problema deixa de existir: você diz a unidade que tem, o -gateway pede a unidade que precisa, e nenhum dos dois pode errar. - -```php -use PHPay\Support\Money; +| [Capacidades](docs/capacidades.md) | o que cada gateway declara suportar, e o que acontece quando você pede o que ele não tem | +| [Tratamento de erros](docs/erros.md) | array é sucesso, falha é exceção — e qual exceção | +| [Retry e idempotência](docs/retry-e-idempotencia.md) | o que a biblioteca repete, o que ela recusa a repetir, e por quê | +| [Ambientes e credenciais](docs/ambientes-e-credenciais.md) | sandbox e produção em cada gateway, e como a credencial fica fora de dump | +| [Cliente](docs/cliente.md) | o VO `Customer`, porque o mesmo campo tem seis grafias entre os gateways | +| [Valores monetários](docs/valores-monetarios.md) | `Money`, porque reais e centavos não se misturam | +| [Verificando o webhook que chega](docs/webhooks.md) | provar que a notificação veio do gateway | -$valor = Money::reais(100.50); // ou Money::centavos(10050) +**Gateways** -$asaas->charge()->setAmount($valor); // vira 100.50 -$pagbank->charge()->addItem('Item', $valor); // vira 10050 -``` - -Também aceita string, que é o que um campo de formulário costuma entregar: - -```php -Money::reais('100,50'); // notação brasileira -Money::reais('1.234,56'); // com separador de milhar -Money::reais('100.50'); // notação com ponto -``` - -E tem o que um total precisa: - -```php -$unitario = Money::reais(59.90); - -$unitario->multiply(2); // R$ 119,80 -$unitario->plus(Money::reais(10)); // R$ 69,90 -$unitario->format(); // 'R$ 59,90' -``` - -> `Money::reais(10 / 3)` **lança exceção** em vez de arredondar. Arredondamento -> silencioso é como nascem erros de um centavo na conciliação — arredonde você -> mesmo, ou use `Money::centavos()` para ser exato. - -#### Passando número cru - -Continua funcionando, e cada gateway lê na unidade que sempre esperou: - -| Gateway | Unidade do número cru | R$ 100,50 | +| | | | | --- | --- | --- | -| **Asaas**, **Mercado Pago** | Reais (decimal) | `100.50` | -| **PagBank**, **Pagar.me**, **Cielo**, **Rede**, **AbacatePay**, **Woovi**, **Efí** | Centavos (inteiro) | `10050` | - -Nos que usam centavos, o PHPay recusa decimal na validação. Mas é justamente -essa tabela que o `Money` torna desnecessária — **prefira o value object**. - -A exceção é a [API Pix do Efí](#api-pix), que **não aceita número cru, só -`Money`**. Ela quer reais (`"100.50"`) enquanto a API de Cobranças do mesmo -gateway quer centavos, e um número solto ali seria a ambiguidade que o value -object existe para eliminar. - ---- - -### Verificando o webhook que chega - -Cadastrar o endpoint é metade do caminho. A outra é provar que a notificação -veio do gateway: sem isso, quem descobrir a URL confirma uma cobrança. Cada -gateway assina de um jeito, e `webhookSignature()` dá a todos a mesma forma. - -```php -$ok = $gateway->webhookSignature() - ->setSecret($segredoDoWebhook) - ->setPayload(file_get_contents('php://input')) - ->setHeaders(getallheaders()) - ->verify(); - -if (!$ok) { - http_response_code(401); - exit; -} -``` - -`verify()` devolve `bool` — assinatura que não bate é resposta, não erro. O que -levanta `ValidationException` é uso errado: segredo ausente, corpo vazio ou -header de assinatura que não chegou. - -**O corpo precisa ser o cru.** `file_get_contents('php://input')`, nunca o array -decodificado e reserializado: um espaço a mais muda o hash. Em Laravel, -`$request->getContent()`. - -| Gateway | O que é verificado | O que isso prova | -| --- | --- | --- | -| Asaas | token no header `asaas-access-token` | que o remetente conhece o token — o corpo não é assinado | -| PagBank | `x-authenticity-token`: SHA-256 de `{token}-{corpo}` | que o corpo chegou inteiro | -| Mercado Pago | `x-signature`: HMAC-SHA256 de `data.id` + `x-request-id` + `ts` | que a notificação é da sua aplicação e aponta para aquele recurso | -| Woovi/OpenPix | `x-webhook-signature`: RSA-SHA256 do corpo, conferido com a chave pública da Woovi | que o corpo veio da Woovi | -| AbacatePay | `webhookSecret` na query e, com a chave, `X-Webhook-Signature` | que a URL é a sua e, com a chave, que o corpo não mudou | - -No Mercado Pago, informe o `data.id` da query com `setDataId()`: ele entra no -cálculo, e omiti-lo muda o manifesto. Na Woovi, a chave pública é buscada em -`api/v1/webhook/public-keys` na primeira verificação — passe `setPublicKeys()` -para evitar a chamada — e a lista inteira é aceita, porque durante uma rotação -ainda chegam notificações assinadas pela chave anterior. - -Quem **não** declara a capacidade: **Efí**, porque a notificação Pix chega por -mTLS e o certificado já cobre isso; **Cielo** e **Rede**, porque a notificação -não traz assinatura e o caminho que a própria documentação indica é consultar a -transação de volta; e **Pagar.me**, porque a v5 não documenta esquema de -assinatura. Nos quatro, `webhookSignature()` levanta `NotImplementedException` -em vez de devolver um verificador que sempre diz sim. - ---- - -## Gateways - -Cada seção cobre só o que é específico daquele gateway. Tudo que vale para -todos está em [Conceitos](#conceitos). - -### Asaas - -Um dos dois com as cinco capacidades, ao lado do Woovi. É PSP, então emite -chave Pix própria e gerencia webhooks por API. - -```php -use PHPay\Asaas\AsaasGateway; - -$phpay = PHPay::gateway(new AsaasGateway(TOKEN_ASAAS_SANDBOX)); -``` - -#### Cobranças - -```php -$phpay = PHPay::gateway(new AsaasGateway(TOKEN_ASAAS_SANDBOX))->charge(); - -/* cria a cobrança, criando também o cliente */ -$phpay->setCharge($cobranca)->setCustomer($cliente)->create(); - -/* reaproveita um cliente que já existe — evita cadastro duplicado */ -$phpay->setCharge($cobranca)->setCustomerId('cus_000006337812')->create(); - -$phpay->find($id); -$phpay->getAll(); -$phpay->setQueryParams(['limit' => 2])->getAll(); -$phpay->update($id, $dados); -$phpay->destroy($id); -$phpay->restore($id); - -$phpay->getStatus($id); -$phpay->getDigitableLine($id); -$phpay->getQrCodePix($id); - -$phpay->confirmReceipt($id, [ - 'paymentDate' => date('Y-m-d'), - 'value' => 100.00, - 'notifyCustomer' => true, -]); -$phpay->undoConfirmReceipt($id); -``` - -#### Clientes - -```php -$phpay = PHPay::gateway(new AsaasGateway(TOKEN_ASAAS_SANDBOX)); - -$cliente = $phpay->customer(['name' => 'Mário Lucas', 'cpfCnpj' => '12345678901'])->create(); - -$phpay->customer()->find($cliente['id']); -$phpay->customer()->setFilter(['cpfCnpj' => '12345678901'])->getAll(); -$phpay->customer(['name' => 'Novo Nome'])->update($cliente['id']); -$phpay->customer()->getNotifications($cliente['id']); -$phpay->customer()->destroy($cliente['id']); -$phpay->customer()->restore($cliente['id']); -``` - -#### Assinaturas - -A assinatura gera uma cobrança por ciclo, e cada uma é uma cobrança comum: -aparece em `getPayments()` e é tratada pelo recurso de cobrança. - -```php -use PHPay\Asaas\Enums\SubscriptionCycleEnum; - -$assinaturas = PHPay::gateway(new AsaasGateway(TOKEN_ASAAS_SANDBOX))->subscription(); - -$assinatura = $assinaturas - ->setCustomer($cliente) // ou setCustomerId('cus_...') - ->setAmount(Money::reais('49,90')) - ->setCycle(SubscriptionCycleEnum::MONTHLY) - ->setSubscription([ - 'billingType' => 'BOLETO', - 'nextDueDate' => '2026-10-10', - 'description' => 'Plano mensal', - ]) - ->create(); -``` - -> O `create([...])` com o payload inteiro continua funcionando, e o array -> sobrescreve o que os setters montaram. O `cycle` é obrigatório: sem ele o -> Asaas recusa, e o PHPay barra antes. - -Consulta e ciclo de vida: - -```php -$assinaturas->setQueryParams(['customer' => 'cus_...', 'status' => 'ACTIVE'])->getAll(); -$assinaturas->find($id); - -/* muda as próximas cobranças; com updatePendingPayments, as pendentes também */ -$assinaturas->update($id, ['description' => 'Plano anual', 'updatePendingPayments' => true]); - -/* pausa: para de gerar cobranças e mantém as que existem */ -$assinaturas->deactivate($id); -$assinaturas->reactivate($id, '2026-11-10'); // o Asaas exige um novo vencimento - -/* remove: as cobranças pendentes e vencidas vão junto; as pagas ficam */ -$assinaturas->destroy($id); -``` - -Cobranças, carnê e cartão: - -```php -$assinaturas->getPayments($id, ['status' => 'PENDING']); - -/* o carnê vem como os bytes do PDF */ -file_put_contents('carne.pdf', $assinaturas->paymentBook($id, month: 12, year: 2026)); - -/* troca o cartão sem cobrar — as cobranças pendentes passam para o novo */ -$assinaturas->updateCreditCard($id, [ - 'creditCardToken' => $token, // ou creditCard + creditCardHolderInfo - 'remoteIp' => $ipDoComprador, -]); -``` - -Nota fiscal emitida automaticamente para cada cobrança: - -```php -$assinaturas->createInvoiceSettings($id, [ - 'municipalServiceName' => 'Desenvolvimento de software', - 'effectiveDatePeriod' => 'ON_PAYMENT_CONFIRMATION', - 'taxes' => [ // os sete são obrigatórios; 0 quando não houver - 'retainIss' => false, - 'iss' => 2, - 'pis' => 0.65, - 'cofins' => 3, - 'csll' => 0, - 'inss' => 0, - 'ir' => 0, - ], -]); - -$assinaturas->getInvoiceSettings($id); -$assinaturas->updateInvoiceSettings($id, [...]); -$assinaturas->destroyInvoiceSettings($id); -$assinaturas->getInvoices($id); // as notas já emitidas -``` - -#### Webhooks e chaves Pix - -```php -$phpay = PHPay::gateway(new AsaasGateway(TOKEN_ASAAS_SANDBOX)); - -/* webhooks com CRUD completo — exclusividade do Asaas */ -$phpay->webhook(WEBHOOK)->create(); -$phpay->webhook()->getAll(); -$phpay->webhook()->update($id, $dados); -$phpay->webhook()->destroy($id); - -/* chaves Pix e QR Code estático */ -$chave = $phpay->pix()->createKey(); -$phpay->pix()->getAll(); -$phpay->pix()->staticQrCode(['addressKey' => $chave['key'], 'value' => 25.00]); -$phpay->pix()->destroy($chave['id']); -``` - -### Mercado Pago - -Ambiente pelo prefixo do token. `POST /v1/payments` exige o header -`X-Idempotency-Key`: o PHPay gera uma chave por chamada, e -`setIdempotencyKey()` deixa você fixar a sua — assim um retry da mesma operação -de negócio não cria duas cobranças. - -```php -use PHPay\MercadoPago\Enums\PaymentMethodEnum; -use PHPay\MercadoPago\MercadoPagoGateway; - -$gateway = new MercadoPagoGateway(ACCESS_TOKEN); - -$cobranca = PHPay::gateway($gateway)->charge() - ->setCharge([ - 'transaction_amount' => 100.50, - 'payment_method_id' => PaymentMethodEnum::PIX->value, - 'notification_url' => 'https://exemplo.test/webhook/mercadopago', - ]) - ->setPayer(['email' => 'comprador@exemplo.test']) - ->setIdempotencyKey('pedido-123456') - ->create(); - -$phpay->getPixCode($cobranca['id']); -``` - -Assinaturas usam `/preapproval`, com ou sem plano associado: - -```php -$phpay = PHPay::gateway($gateway)->subscription(); - -$phpay->setPayerEmail('comprador@exemplo.test')->create([ - 'reason' => 'Assinatura PHPay', - 'back_url' => 'https://exemplo.test/retorno', - 'auto_recurring' => [ - 'frequency' => 1, - 'frequency_type' => 'months', - 'transaction_amount' => 100.50, - 'currency_id' => 'BRL', - ], -]); - -/* com plano, a recorrência vem do plano */ -$phpay->setPayerEmail('comprador@exemplo.test') - ->setPlan('2c938084726fca480172750000000000') - ->create(['back_url' => 'https://exemplo.test/retorno']); -``` - -> O recurso `Customer` do Mercado Pago existe para cartões salvos — **não** é -> pré-requisito para cobrar, já que o pagamento carrega `payer.email` direto. A -> API também não oferece exclusão de cliente. - -### PagBank - -Duas particularidades, ambas resolvidas pela biblioteca. - -**Duas APIs em hosts diferentes.** Pedidos vivem em `api.pagseguro.com`, -assinaturas em `api.assinaturas.pagseguro.com`. Cada recurso boota o client da -API certa — você não precisa saber disso. - -**Pix não é uma cobrança.** Entra como `qr_codes` do pedido, e só um por pedido. -A conta precisa ter uma chave Pix ativa. - -```php -use PHPay\PagBank\PagBankGateway; - -$phpay = PHPay::gateway(new PagBankGateway(TOKEN_PAGBANK_SANDBOX))->charge(); - -$pedido = $phpay - ->setCustomer(['name' => 'Mário', 'email' => 'fale@phpay.io', 'tax_id' => '12345678901']) - ->addItem('Assinatura PHPay', 10050) // R$ 100,50 - ->setQrCode(10050) - ->setNotificationUrls(['https://exemplo.test/webhook/pagbank']) - ->create(); - -$phpay->getPixCode($pedido['id']); // de qr_codes[0].text -``` - -Cartão e boleto, aí sim, vão em `charges`: - -```php -$phpay - ->setCustomer($cliente) - ->addItem('Camiseta', 5990, 2) - ->setCharges([[ - 'reference_id' => 'cobranca-1', - 'amount' => ['value' => 11980, 'currency' => 'BRL'], - 'payment_method' => ['type' => 'CREDIT_CARD', 'installments' => 1, 'capture' => true], - ]]) - ->create(); -``` - -Assinaturas sempre pertencem a um plano, e o assinante pode nascer junto: - -```php -$phpay = PHPay::gateway(new PagBankGateway(TOKEN_PAGBANK_SANDBOX))->subscription(); - -$plano = $phpay->createPlan([ - 'name' => 'Plano PHPay Mensal', - 'amount' => ['value' => 4990, 'currency' => 'BRL'], // R$ 49,90 - 'interval' => ['unit' => 'MONTHS', 'length' => 1], -]); - -$phpay->setPlan($plano['id']) - ->setCustomer(['name' => 'Mário', 'email' => 'fale@phpay.io', 'tax_id' => '12345678901']) - ->create(); -``` - -### Pagar.me - -Autenticação Basic com a secret key, ambiente pelo prefixo da chave, e Pix como -forma de pagamento do pedido. - -```php -use PHPay\PagarMe\PagarMeGateway; - -$gateway = new PagarMeGateway(SECRET_KEY_PAGARME); - -$pedido = PHPay::gateway($gateway)->charge() - ->setCustomer([ - 'name' => 'Mário Lucas', - 'email' => 'fale@phpay.io', - 'document' => '12345678901', - ]) - ->addItem('Assinatura PHPay', 10050) // R$ 100,50 - ->setPix(1800) // expira em 30 minutos - ->create(); - -$phpay->getPixCode($pedido['id']); // de charges[0].last_transaction.qr_code -``` - -O cancelamento é `DELETE`, com valor opcional para estorno parcial: - -```php -$phpay->cancel($cobrancaId, 2500); // estorna R$ 25,00 -$phpay->cancel($cobrancaId); // estorna tudo -``` - -Assinaturas aceitam um plano ou a recorrência no próprio payload: - -```php -$phpay = PHPay::gateway($gateway)->subscription(); - -$plano = $phpay->createPlan([ - 'name' => 'Plano PHPay Mensal', - 'interval' => 'month', - 'interval_count' => 1, - 'items' => [[ - 'name' => 'Mensalidade', - 'quantity' => 1, - 'pricing_scheme' => ['price' => 4990], // R$ 49,90 - ]], -]); - -$phpay->setPlan($plano['id']) - ->setCustomerId($clienteId) - ->create(['payment_method' => 'pix']); -``` - -#### Consultando entregas de webhook - -O Pagar.me deixa ler e reenviar os eventos que já despachou. Isso **não** é a -capacidade `SupportsWebhooks` — o cadastro dos endpoints é no dashboard — então -vive no gateway concreto, não na facade: - -```php -$gateway->webhookDeliveries()->setFilter(['size' => 10])->getAll(); -$gateway->webhookDeliveries()->resend($hookId); -``` - -É assim que o modelo de capacidades abre espaço para o que só um gateway -oferece: quem segura `PagarMeGateway` alcança, quem tipa uma capacidade não. - -### Cielo - -A primeira **adquirente** da biblioteca, e a forma mostra: não há recurso de -cliente — ele é um campo da venda. Daí as duas capacidades. - -A particularidade é que a Cielo separa **dois hosts por tipo de operação**: -escritas vão para `api.cieloecommerce...`, consultas para -`apiquery.cieloecommerce...`. O mesmo recurso usa os dois, e o PHPay roteia -sozinho — `create()` vai num, `find()` no outro. - -```php -use PHPay\Cielo\CieloGateway; - -$phpay = PHPay::gateway(new CieloGateway(MERCHANT_ID, MERCHANT_KEY))->charge(); - -$venda = $phpay - ->setOrderId('pedido-1') - ->setCustomer(['Name' => 'Mário Lucas']) - ->setPix(15700) // R$ 157,00 - ->setRequestId('pedido-1') // idempotência - ->create(); - -$phpay->getPixCode($venda['Payment']['PaymentId']); -``` - -Cartão em duas etapas — autoriza agora, captura depois: - -```php -$phpay - ->setCustomer(['Name' => 'Mário Lucas']) - ->setCreditCard(15700, $cartao, installments: 3) // capture: false por padrão - ->create(); - -$phpay->capture($paymentId); -$phpay->cancel($paymentId, 2500); // estorna R$ 25,00 -``` - -#### Recorrência - -A Cielo **não tem endpoint de criar assinatura**: a recorrência nasce de uma -venda com um bloco `RecurrentPayment`, e só então ganha um `RecurrentPaymentId` -próprio. Sempre cobra cartão. - -```php -use PHPay\Cielo\Enums\RecurrentIntervalEnum; - -$phpay = PHPay::gateway(new CieloGateway(MERCHANT_ID, MERCHANT_KEY))->subscription(); - -$recorrencia = $phpay - ->setCustomer(['Name' => 'Mário Lucas']) - ->setCard($cartao) - ->setInterval(RecurrentIntervalEnum::MONTHLY) - ->setEndDate('2027-12-31') - ->create(15700); - -$id = $recorrencia['Payment']['RecurrentPayment']['RecurrentPaymentId']; - -$phpay->updateAmount($id, 19900); -$phpay->deactivate($id); -$phpay->reactivate($id); -### Rede - -Adquirente, e a forma mais estreita da biblioteca: **só -cobranças**. Não há recurso de cliente nem assinatura gerenciável — a -transação tem um campo `subscription`, mas é uma flag para a adquirente, não -algo que você liste ou cancele. - -A particularidade é a autenticação: **OAuth2 `client_credentials` num host -separado do de API**, com o caminho do token diferente em cada ambiente. E o -token **expira** — o PHPay renegocia sozinho quando isso acontece. - -```php -use PHPay\Rede\Enums\TransactionKindEnum; -use PHPay\Rede\RedeGateway; - -/* nenhuma chamada de rede aqui: o token é negociado no primeiro uso */ -$gateway = new RedeGateway(REDE_PV, REDE_TOKEN); - -$transacao = PHPay::gateway($gateway)->charge() - ->setReference('pedido-1') - ->setCard('5448280000000007', 'MARIO LUCAS', '12', '2030', '123') - ->setPayment(2099, TransactionKindEnum::CREDIT) // R$ 20,99 - ->setSoftDescriptor('PHPAY') - ->create(); -``` - -Fluxo em duas etapas — autoriza agora, captura quando o pedido for separado: - -```php -$phpay->setPayment(5000, capture: false)->create(); - -$phpay->capture($tid); -$phpay->refund($tid, 1000); // estorna R$ 10,00 -``` - -O código de retorno `"00"` significa aprovada: - -```php -use PHPay\Rede\Enums\TransactionStatusEnum; - -TransactionStatusEnum::approved($phpay->getStatus($tid)); -``` - -Num processo longo, dá para inspecionar ou descartar o token em mãos: - -```php -$gateway->authorization()->hasValidToken(); -$gateway->authorization()->forget(); -``` - -### AbacatePay - -Pix nativo, e **mesmo assim não declara `SupportsPixKeys`** — aquela capacidade -é sobre gerenciar chaves e QR Code estático, coisa de PSP. Aqui Pix é o método -de pagamento da cobrança, e o único aceito. - -Também não declara assinaturas: a API documenta `ONE_TIME` como a única -frequência aceita. - -A cobrança é um **link de pagamento montado a partir de produtos**, não de um -valor solto — o total vem calculado em `amount`. Preço em centavos, com -**mínimo de 100** (R$ 1,00) por produto. - -```php -use PHPay\AbacatePay\AbacatePayGateway; - -$gateway = new AbacatePayGateway(ABACATEPAY_TOKEN); - -$cobranca = PHPay::gateway($gateway)->charge() - ->setCustomer([ - 'name' => 'Mário Lucas', - 'email' => 'fale@phpay.io', - 'cellphone' => '(11) 4002-8922', - 'taxId' => '12345678901', - ]) - ->addProduct('prod-1234', 'Assinatura PHPay', 2000) // R$ 20,00 - ->setUrls( - completionUrl: 'https://exemplo.test/obrigado', - returnUrl: 'https://exemplo.test/loja' - ) - ->create(); - -$phpay->getPaymentUrl($cobranca); // o link para onde mandar o cliente -$phpay->isDevMode($cobranca); // em qual ambiente a cobrança nasceu -``` - -O `externalId` do produto é o id **no seu sistema** — o AbacatePay cria o -produto do lado dele a partir dele, então precisa ser único. - -#### Cupons de desconto - -Nenhum outro gateway da biblioteca tem isso, então não é capacidade: vive no -gateway concreto, como o `webhookDeliveries()` do Pagar.me. - -```php -$gateway->coupons()->create([ - 'code' => 'PHPAY10', - 'discountKind' => 'PERCENTAGE', - 'discount' => 10, -]); -``` - -### Woovi/OpenPix - -**O segundo gateway com as cinco capacidades**, ao lado do Asaas — e o que -confirma que o modelo descreve o domínio, não um fornecedor: são duas empresas -independentes, com APIs independentes, preenchendo o mesmo contrato. - -Sendo PSP Pix-nativo, ele gerencia chaves e QR Code estático de verdade. - -```php -use PHPay\Woovi\Enums\PixKeyTypeEnum; -use PHPay\Woovi\WooviGateway; - -$phpay = PHPay::gateway(new WooviGateway(WOOVI_APP_ID)); - -/* chaves Pix da conta */ -$phpay->pix()->createKey(PixKeyTypeEnum::RANDOM); -$phpay->pix()->getAll(); - -/* consulta uma chave antes de pagar */ -$phpay->pix()->verifyKey('fale@phpay.io'); - -/* QR Code estático, com ou sem valor */ -$phpay->pix()->staticQrCode('Caixa 1'); -$phpay->pix()->staticQrCode('Caixa 2', 2500); -``` - -Três particularidades: - -**O AppID vai cru no `Authorization`** — sem `Bearer`, sem `Basic`. - -**O sandbox tem domínio próprio**: `api.woovi-sandbox.com` contra -`api.openpix.com.br`. - -**Todo objeto é endereçável pelo `correlationID`**, o id no *seu* sistema — -nenhum outro gateway da biblioteca oferece isso: - -```php -$cobranca = $phpay->charge() - ->setCorrelationId('pedido-1') - ->setCustomer(['name' => 'Mário Lucas', 'email' => 'fale@phpay.io']) - ->create(10050); // R$ 100,50 - -$phpay->charge()->getPixCode($cobranca); -$phpay->charge()->find('pedido-1'); // pelo SEU id, não pelo do gateway -``` - -Webhooks têm CRUD por API — junto com o Asaas, os únicos: - -```php -$phpay->webhook(['name' => 'PHPay', 'url' => 'https://exemplo.test/webhook'])->create(); -$phpay->webhook()->getAll(); -``` - -> Repare que o webhook fica em `api/openpix/v1/`, enquanto os demais recursos -> ficam em `api/v1/` — herança da fusão das duas marcas. O PHPay trata isso -> internamente. - -### Efí - -**Duas APIs com as mesmas credenciais**, e é isso que dá ao Efí quatro das -cinco capacidades: - -| API | Host | Autenticação | Recursos | -| --- | --- | --- | --- | -| **Cobranças** | `cobrancas.api.efipay.com.br` | OAuth2 | `charge()` — boleto | -| **Pix** | `pix.api.efipay.com.br` | OAuth2 **+ mTLS** | `pix()`, `webhook()`, `subscription()`, `pixCharge()` | - -Clientes ficam de fora: nenhuma das duas APIs mantém cadastro de cliente. - -O gateway **não faz chamada de rede no construtor**. Cada API tem o seu token, -pedido na primeira vez que é necessário e **renovado sozinho quando expira** — -um gateway vivo num worker de fila não passa a tomar 401. - -#### Cobranças (boleto) - -```php -use PHPay\Efi\EfiGateway; -use PHPay\Support\{Customer, Money}; - -$gateway = new EfiGateway(CLIENT_ID, CLIENT_SECRET); - -$cobranca = PHPay::gateway($gateway)->charge([ - 'description' => 'Assinatura PHPay', - 'expire_at' => date('Y-m-d', strtotime('+3 days')), -]) - ->setAmount(Money::reais('100,50')) - ->setCustomer(new Customer('Mário Lucas', '12345678909')) - ->create(); -``` - -#### API Pix - -**Toda requisição da API Pix é por mTLS**, inclusive a do token. Passe o -certificado `.p12` (ou `.pem`) da aplicação, que você baixa no painel do Efí: - -```php -$gateway = new EfiGateway(CLIENT_ID, CLIENT_SECRET, certificate: '/caminho/certificado.p12'); -``` - -Com senha, ou vindo de uma variável de ambiente — o comum em container e -serverless: - -```php -use PHPay\Http\Certificate; - -new EfiGateway(CLIENT_ID, CLIENT_SECRET, certificate: new Certificate('/caminho/certificado.p12', 'senha')); - -new EfiGateway(CLIENT_ID, CLIENT_SECRET, certificate: Certificate::fromBase64(getenv('EFI_CERTIFICATE_BASE64'))); -``` - -> O `fromBase64()` grava o certificado num arquivo temporário com permissão -> `0600`, apagado quando o processo termina. A senha nunca aparece num -> `var_dump()`. - -Certificado de homologação só funciona com `$sandbox = true`, e o de produção, -com `false`. Sem certificado, a API de Cobranças continua funcionando e a API -Pix responde com uma `ValidationException` clara, não com um erro de TLS. - -**Cobrança Pix** — imediata por padrão, com vencimento quando há `setDueDate()`: - -```php -$cobranca = $gateway->pixCharge() - ->setAmount(Money::reais('123,45')) - ->setKey('sua-chave-pix') // chave da conta Efí que recebe - ->setCustomer(new Customer('Mário Lucas', '12345678909')) - ->setDescription('Pedido 1234') - ->setExpiration(3600) // segundos - ->create(); - -$qr = $gateway->pixCharge()->qrCode($cobranca['loc']['id']); -$qr['qrcode']; // copia e cola -$qr['imagemQrcode']; // PNG em base64 - -/* com vencimento: multa, juros e desconto como num boleto */ -$gateway->pixCharge() - ->setAmount(Money::reais(250)) - ->setKey('sua-chave-pix') - ->setCustomer(new Customer('Sixtec LTDA', '12345678000199')) - ->setDueDate('2026-12-31', validityAfterDue: 15) - ->create(); - -/* devolução, total ou parcial */ -$gateway->pixCharge()->refund($endToEndId, Money::reais(10)); -``` - -`pixCharge()` é um **extra do gateway concreto**, como o `webhookDeliveries()` -do Pagar.me: o `charge()` da facade já é o boleto, e mudar o retorno dele -quebraria quem está na v2. - -**Chaves Pix** — só chaves aleatórias (EVP) são gerenciáveis pela API: - -```php -$chave = PHPay::gateway($gateway)->pix()->createKey()['chave']; - -PHPay::gateway($gateway)->pix()->getAll(); -PHPay::gateway($gateway)->pix()->destroy($chave); -``` - -> **Sem QR Code estático.** A API Pix da Efí não expõe endpoint para criar um, -> então aqui não existe o `staticQrCode()` do Asaas e da Woovi. Pelo padrão do -> BACEN, um QR Code estático é um BR Code composto a partir da chave, de um -> valor opcional e de um identificador livre — quem oferece endpoint faz por -> conveniência, não por necessidade. O `qrCode()` do `pixCharge()`, acima, é o -> QR Code **de uma cobrança**, que é outra coisa. - -**Webhooks** — um por chave Pix, endereçado pela própria chave: - -```php -PHPay::gateway($gateway) - ->webhook(['chave' => $chave, 'webhookUrl' => 'https://loja.com/webhook/pix']) - ->create(); -``` - -> O mTLS vale **nos dois sentidos**: por padrão o Efí só entrega para um -> servidor que valide o certificado dele. Se o seu não consegue (hospedagem -> compartilhada, balanceador que termina o TLS), `skipMtlsChecking()` desliga a -> checagem — e aí valide a origem de outro jeito, como um `hmac` na URL. - -**Pix Automático** — o pagador autoriza uma vez no app do banco, e cada ciclo -é debitado sem nova aprovação: - -```php -use PHPay\Efi\Enums\{AccountTypeEnum, PeriodicityEnum}; - -$assinaturas = PHPay::gateway($gateway)->subscription(); - -/* 1. o location que o QR Code de autorização aponta */ -$location = $assinaturas->createLocation(); - -/* 2. a recorrência: o que o pagador autoriza */ -$recorrencia = $assinaturas - ->setCustomer(new Customer('Mário Lucas', '12345678909')) - ->setContract('CONTRATO-2026-001') // até 35 caracteres - ->setDescription('Plano mensal') - ->setAmount(Money::reais('49,90')) // ou setMinimumAmount(), para valor variável - ->setPeriodicity(PeriodicityEnum::MONTHLY, '2026-10-01') - ->allowRetries() // até 3 tentativas em 7 dias - ->setLocation($location['id']) - ->create(); - -$assinaturas->find($recorrencia['idRec'])['dadosQR']; // copia e cola para o pagador autorizar - -/* 3. a cobrança de cada ciclo */ -$assinaturas - ->setReceiver('12345-6', AccountTypeEnum::CHECKING, '0001') - ->createCharge($recorrencia['idRec'], Money::reais('49,90'), '2026-11-05'); - -$assinaturas->cancel($recorrencia['idRec']); -``` +| [Asaas](docs/gateways/asaas.md) | [Woovi/OpenPix](docs/gateways/woovi.md) | [Efí](docs/gateways/efi.md) | +| [Mercado Pago](docs/gateways/mercado-pago.md) | [PagBank](docs/gateways/pagbank.md) | [Pagar.me](docs/gateways/pagarme.md) | +| [AbacatePay](docs/gateways/abacatepay.md) | [Cielo](docs/gateways/cielo.md) | [Rede](docs/gateways/rede.md) | --- diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..683bb7a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,27 @@ +# Documentação do PHPay + +O que vale para todos os gateways está nos conceitos: capacidade, erro, +unidade monetária, retry. Cada página de gateway cobre só o que é +específico dele. + +[← voltar ao README](../README.md) + +## Conceitos + +| | | +| --- | --- | +| [Capacidades](capacidades.md) | o que cada gateway declara suportar, e o que acontece quando você pede o que ele não tem | +| [Tratamento de erros](erros.md) | array é sucesso, falha é exceção — e qual exceção | +| [Retry e idempotência](retry-e-idempotencia.md) | o que a biblioteca repete, o que ela recusa a repetir, e por quê | +| [Ambientes e credenciais](ambientes-e-credenciais.md) | sandbox e produção em cada gateway, e como a credencial fica fora de dump | +| [Cliente](cliente.md) | o VO `Customer`, porque o mesmo campo tem seis grafias entre os gateways | +| [Valores monetários](valores-monetarios.md) | `Money`, porque reais e centavos não se misturam | +| [Verificando o webhook que chega](webhooks.md) | provar que a notificação veio do gateway | + +## Gateways + +| | | | +| --- | --- | --- | +| [Asaas](gateways/asaas.md) | [Woovi/OpenPix](gateways/woovi.md) | [Efí](gateways/efi.md) | +| [Mercado Pago](gateways/mercado-pago.md) | [PagBank](gateways/pagbank.md) | [Pagar.me](gateways/pagarme.md) | +| [AbacatePay](gateways/abacatepay.md) | [Cielo](gateways/cielo.md) | [Rede](gateways/rede.md) | diff --git a/docs/ambientes-e-credenciais.md b/docs/ambientes-e-credenciais.md new file mode 100644 index 0000000..3722f1c --- /dev/null +++ b/docs/ambientes-e-credenciais.md @@ -0,0 +1,35 @@ +# Ambientes e credenciais + +Os gateways discordam sobre como separar teste de produção, e o PHPay segue o +que cada um faz em vez de inventar um padrão: + +| Gateway | Como o ambiente é decidido | +| --- | --- | +| **Asaas** | `$sandbox` no construtor — troca a URL | +| **PagBank** | `$sandbox` no construtor — troca a URL | +| **Cielo** | `$sandbox` no construtor — troca **as duas** URLs | +| **Efí** | `$sandbox` no construtor — troca **as duas** URLs (Cobranças e Pix) | +| **Rede** | `$sandbox` no construtor — troca **as duas** URLs e o caminho do token | +| **Mercado Pago** | Prefixo do token (`TEST-`); host único, sem `$sandbox` | +| **Pagar.me** | Prefixo da chave (`sk_test_`); host único, sem `$sandbox` | +| **Woovi** | `$sandbox` no construtor — o sandbox tem **domínio próprio** | +| **AbacatePay** | Pela chave usada; host único, **sem prefixo** — a cobrança informa em `devMode` | + +Nos dois últimos, `isSandbox()` diz em qual ambiente você está: + +```php +(new MercadoPagoGateway($token))->isSandbox(); +(new PagarMeGateway($secretKey))->isSandbox(); +``` + +> **Nunca** versione credenciais. Os arquivos `examples/*/credentials.php` são +> ignorados pelo git por padrão. + +`var_dump()` e `print_r()` de um gateway ou recurso mostram `********` no lugar +da credencial, e o client HTTP, que a carrega nos headers, aparece oculto. O +`dd()` do Symfony VarDumper lê as propriedades por conta própria e **não** passa +por essa máscara: não despeje o gateway em log. + +Toda requisição tem prazo: 60 segundos no total e 10 para conectar. Sem isso, um +gateway que trava prenderia o worker indefinidamente. Um estouro de prazo chega +como `ApiException` com `isConnectionError()` verdadeiro. diff --git a/docs/capacidades.md b/docs/capacidades.md new file mode 100644 index 0000000..445e841 --- /dev/null +++ b/docs/capacidades.md @@ -0,0 +1,41 @@ +# Capacidades + +`GatewayInterface` carrega só a identidade do gateway. Cada recurso é uma +interface que o gateway implementa **se, e só se,** oferecer: + +```php +use PHPay\Contracts\Capability; + +$phpay = PHPay::gateway(new RedeGateway(REDE_PV, REDE_TOKEN)); + +$phpay->name(); // 'Rede' +$phpay->supports(Capability::SUBSCRIPTIONS); // false +$phpay->capabilities(); // [Capability::CHARGES] +``` + +Chamar um recurso que o gateway não oferece lança uma exceção que diz o que ele +oferece: + +```php +$phpay->pix(); +// NotImplementedException: Rede não suporta chaves Pix. +// Capacidades disponíveis: cobranças. +``` + +Se você segurar o **gateway concreto** em vez da facade, o erro sobe para tempo +de análise — o PHPStan acusa que o método não existe naquele tipo: + +```php +$rede = new RedeGateway(REDE_PV, REDE_TOKEN); + +$rede->charge(); // ✅ +$rede->pix(); // ❌ o método não existe nesse gateway +``` + +Para injeção de dependência, tipe a capacidade em vez do gateway: + +```php +use PHPay\Contracts\SupportsCharges; + +public function __construct(private SupportsCharges $gateway) {} +``` diff --git a/docs/cliente.md b/docs/cliente.md new file mode 100644 index 0000000..fc43824 --- /dev/null +++ b/docs/cliente.md @@ -0,0 +1,43 @@ +# Cliente + +O mesmo campo tem **seis grafias** entre os gateways: `cpfCnpj` no Asaas, +`tax_id` no PagBank, `document` no Pagar.me, `taxId` no AbacatePay, `taxID` no +Woovi, `cpf_cnpj` no Efí. Código escrito para um não migra para outro, e nada +no tipo avisa. + +`Customer` é a forma única. Cada gateway mapeia para o formato dele: + +```php +use PHPay\Support\Customer; + +$cliente = Customer::make( + name: 'Mário Lucas', + document: '123.456.789-01', // pontuação é limpa + email: 'fale@phpay.io', + phone: '(11) 94002-8922', +); + +$phpay->charge()->setCustomer($cliente); // funciona nos nove +``` + +Ele também carrega o que cada gateway deriva do cliente, e que antes ficava +espalhado: + +```php +$cliente->isIndividual(); // CPF: o Pagar.me precisa como type: 'individual' +$cliente->documentType(); // 'CPF' | 'CNPJ': a Cielo quer em IdentityType +$cliente->firstName(); // o Mercado Pago quer nome e sobrenome separados +$cliente->phoneParts(); // ['country' => '55', 'area' => '11', ...] para o PagBank +``` + +Para um cliente que já existe no gateway, ou para campos que só aquele gateway +tem: + +```php +$cliente->withId('cus_000006337812'); // reaproveita em vez de criar +$cliente->withExtra(['externalReference' => 'x']); // vai junto no payload +``` + +> O `withExtra()` existe para o que é específico de um gateway — endereço, +> data de nascimento, referência externa. **Nenhum campo obrigatório de +> nenhum dos nove gateways precisa dele**: o value object cobre todos. diff --git a/docs/erros.md b/docs/erros.md new file mode 100644 index 0000000..0bc3f53 --- /dev/null +++ b/docs/erros.md @@ -0,0 +1,32 @@ +# Tratamento de erros + +Toda exceção da biblioteca implementa `PHPay\Exceptions\PHPayException`, então +um `catch` cobre a integração inteira: + +| Exceção | Estende | Quando acontece | +| --- | --- | --- | +| `ValidationException` | `InvalidArgumentException` | Payload inválido, **antes** de qualquer HTTP | +| `ApiException` | `RuntimeException` | O gateway recusou, ou está inacessível | +| `NotImplementedException` | `BadMethodCallException` | O gateway não oferece o recurso | + +```php +use PHPay\Exceptions\ApiException; +use PHPay\Exceptions\PHPayException; +use PHPay\Exceptions\ValidationException; + +try { + $cobranca = $phpay->charge()->setCharge($dados)->create(); +} catch (ValidationException $e) { + // payload inválido: nenhuma requisição foi feita +} catch (ApiException $e) { + $e->getStatusCode(); // 400, 401, 404… ou 0 se nem chegou ao gateway + $e->getResponse(); // corpo devolvido pelo gateway + $e->getGateway(); // 'Asaas', 'Pagar.me', … + $e->isConnectionError(); // true em timeout, DNS, TLS — vale retry +} catch (PHPayException $e) { + // qualquer outra falha do PHPay +} +``` + +`ApiException` já resume os formatos de erro de cada gateway, então +`getMessage()` traz a descrição legível, não um dump. diff --git a/docs/gateways/abacatepay.md b/docs/gateways/abacatepay.md new file mode 100644 index 0000000..a4d42ec --- /dev/null +++ b/docs/gateways/abacatepay.md @@ -0,0 +1,51 @@ +# AbacatePay + +Pix nativo, e **mesmo assim não declara `SupportsPixKeys`** — aquela capacidade +é sobre gerenciar chaves e QR Code estático, coisa de PSP. Aqui Pix é o método +de pagamento da cobrança, e o único aceito. + +Também não declara assinaturas: a API documenta `ONE_TIME` como a única +frequência aceita. + +A cobrança é um **link de pagamento montado a partir de produtos**, não de um +valor solto — o total vem calculado em `amount`. Preço em centavos, com +**mínimo de 100** (R$ 1,00) por produto. + +```php +use PHPay\AbacatePay\AbacatePayGateway; + +$gateway = new AbacatePayGateway(ABACATEPAY_TOKEN); + +$cobranca = PHPay::gateway($gateway)->charge() + ->setCustomer([ + 'name' => 'Mário Lucas', + 'email' => 'fale@phpay.io', + 'cellphone' => '(11) 4002-8922', + 'taxId' => '12345678901', + ]) + ->addProduct('prod-1234', 'Assinatura PHPay', 2000) // R$ 20,00 + ->setUrls( + completionUrl: 'https://exemplo.test/obrigado', + returnUrl: 'https://exemplo.test/loja' + ) + ->create(); + +$phpay->getPaymentUrl($cobranca); // o link para onde mandar o cliente +$phpay->isDevMode($cobranca); // em qual ambiente a cobrança nasceu +``` + +O `externalId` do produto é o id **no seu sistema** — o AbacatePay cria o +produto do lado dele a partir dele, então precisa ser único. + +## Cupons de desconto + +Nenhum outro gateway da biblioteca tem isso, então não é capacidade: vive no +gateway concreto, como o `webhookDeliveries()` do Pagar.me. + +```php +$gateway->coupons()->create([ + 'code' => 'PHPAY10', + 'discountKind' => 'PERCENTAGE', + 'discount' => 10, +]); +``` diff --git a/docs/gateways/asaas.md b/docs/gateways/asaas.md new file mode 100644 index 0000000..9ad9f25 --- /dev/null +++ b/docs/gateways/asaas.md @@ -0,0 +1,154 @@ +# Asaas + +Um dos dois com as cinco capacidades, ao lado do Woovi. É PSP, então emite +chave Pix própria e gerencia webhooks por API. + +```php +use PHPay\Asaas\AsaasGateway; + +$phpay = PHPay::gateway(new AsaasGateway(TOKEN_ASAAS_SANDBOX)); +``` + +## Cobranças + +```php +$phpay = PHPay::gateway(new AsaasGateway(TOKEN_ASAAS_SANDBOX))->charge(); + +/* cria a cobrança, criando também o cliente */ +$phpay->setCharge($cobranca)->setCustomer($cliente)->create(); + +/* reaproveita um cliente que já existe — evita cadastro duplicado */ +$phpay->setCharge($cobranca)->setCustomerId('cus_000006337812')->create(); + +$phpay->find($id); +$phpay->getAll(); +$phpay->setQueryParams(['limit' => 2])->getAll(); +$phpay->update($id, $dados); +$phpay->destroy($id); +$phpay->restore($id); + +$phpay->getStatus($id); +$phpay->getDigitableLine($id); +$phpay->getQrCodePix($id); + +$phpay->confirmReceipt($id, [ + 'paymentDate' => date('Y-m-d'), + 'value' => 100.00, + 'notifyCustomer' => true, +]); +$phpay->undoConfirmReceipt($id); +``` + +## Clientes + +```php +$phpay = PHPay::gateway(new AsaasGateway(TOKEN_ASAAS_SANDBOX)); + +$cliente = $phpay->customer(['name' => 'Mário Lucas', 'cpfCnpj' => '12345678901'])->create(); + +$phpay->customer()->find($cliente['id']); +$phpay->customer()->setFilter(['cpfCnpj' => '12345678901'])->getAll(); +$phpay->customer(['name' => 'Novo Nome'])->update($cliente['id']); +$phpay->customer()->getNotifications($cliente['id']); +$phpay->customer()->destroy($cliente['id']); +$phpay->customer()->restore($cliente['id']); +``` + +## Assinaturas + +A assinatura gera uma cobrança por ciclo, e cada uma é uma cobrança comum: +aparece em `getPayments()` e é tratada pelo recurso de cobrança. + +```php +use PHPay\Asaas\Enums\SubscriptionCycleEnum; + +$assinaturas = PHPay::gateway(new AsaasGateway(TOKEN_ASAAS_SANDBOX))->subscription(); + +$assinatura = $assinaturas + ->setCustomer($cliente) // ou setCustomerId('cus_...') + ->setAmount(Money::reais('49,90')) + ->setCycle(SubscriptionCycleEnum::MONTHLY) + ->setSubscription([ + 'billingType' => 'BOLETO', + 'nextDueDate' => '2026-10-10', + 'description' => 'Plano mensal', + ]) + ->create(); +``` + +> O `create([...])` com o payload inteiro continua funcionando, e o array +> sobrescreve o que os setters montaram. O `cycle` é obrigatório: sem ele o +> Asaas recusa, e o PHPay barra antes. + +Consulta e ciclo de vida: + +```php +$assinaturas->setQueryParams(['customer' => 'cus_...', 'status' => 'ACTIVE'])->getAll(); +$assinaturas->find($id); + +/* muda as próximas cobranças; com updatePendingPayments, as pendentes também */ +$assinaturas->update($id, ['description' => 'Plano anual', 'updatePendingPayments' => true]); + +/* pausa: para de gerar cobranças e mantém as que existem */ +$assinaturas->deactivate($id); +$assinaturas->reactivate($id, '2026-11-10'); // o Asaas exige um novo vencimento + +/* remove: as cobranças pendentes e vencidas vão junto; as pagas ficam */ +$assinaturas->destroy($id); +``` + +Cobranças, carnê e cartão: + +```php +$assinaturas->getPayments($id, ['status' => 'PENDING']); + +/* o carnê vem como os bytes do PDF */ +file_put_contents('carne.pdf', $assinaturas->paymentBook($id, month: 12, year: 2026)); + +/* troca o cartão sem cobrar — as cobranças pendentes passam para o novo */ +$assinaturas->updateCreditCard($id, [ + 'creditCardToken' => $token, // ou creditCard + creditCardHolderInfo + 'remoteIp' => $ipDoComprador, +]); +``` + +Nota fiscal emitida automaticamente para cada cobrança: + +```php +$assinaturas->createInvoiceSettings($id, [ + 'municipalServiceName' => 'Desenvolvimento de software', + 'effectiveDatePeriod' => 'ON_PAYMENT_CONFIRMATION', + 'taxes' => [ // os sete são obrigatórios; 0 quando não houver + 'retainIss' => false, + 'iss' => 2, + 'pis' => 0.65, + 'cofins' => 3, + 'csll' => 0, + 'inss' => 0, + 'ir' => 0, + ], +]); + +$assinaturas->getInvoiceSettings($id); +$assinaturas->updateInvoiceSettings($id, [...]); +$assinaturas->destroyInvoiceSettings($id); +$assinaturas->getInvoices($id); // as notas já emitidas +``` + +## Webhooks e chaves Pix + +```php +$phpay = PHPay::gateway(new AsaasGateway(TOKEN_ASAAS_SANDBOX)); + +/* webhooks com CRUD completo — exclusividade do Asaas */ +$phpay->webhook(WEBHOOK)->create(); +$phpay->webhook()->getAll(); +$phpay->webhook()->update($id, $dados); +$phpay->webhook()->destroy($id); + +/* chaves Pix e QR Code estático */ +$chave = $phpay->pix()->createKey(); +$phpay->pix()->getAll(); +$phpay->pix()->staticQrCode(['addressKey' => $chave['key'], 'value' => 25.00]); +$phpay->pix()->destroy($chave['id']); +``` diff --git a/docs/gateways/cielo.md b/docs/gateways/cielo.md new file mode 100644 index 0000000..00fa65f --- /dev/null +++ b/docs/gateways/cielo.md @@ -0,0 +1,61 @@ +# Cielo + +A primeira **adquirente** da biblioteca, e a forma mostra: não há recurso de +cliente — ele é um campo da venda. Daí as duas capacidades. + +A particularidade é que a Cielo separa **dois hosts por tipo de operação**: +escritas vão para `api.cieloecommerce...`, consultas para +`apiquery.cieloecommerce...`. O mesmo recurso usa os dois, e o PHPay roteia +sozinho — `create()` vai num, `find()` no outro. + +```php +use PHPay\Cielo\CieloGateway; + +$phpay = PHPay::gateway(new CieloGateway(MERCHANT_ID, MERCHANT_KEY))->charge(); + +$venda = $phpay + ->setOrderId('pedido-1') + ->setCustomer(['Name' => 'Mário Lucas']) + ->setPix(15700) // R$ 157,00 + ->setRequestId('pedido-1') // idempotência + ->create(); + +$phpay->getPixCode($venda['Payment']['PaymentId']); +``` + +Cartão em duas etapas — autoriza agora, captura depois: + +```php +$phpay + ->setCustomer(['Name' => 'Mário Lucas']) + ->setCreditCard(15700, $cartao, installments: 3) // capture: false por padrão + ->create(); + +$phpay->capture($paymentId); +$phpay->cancel($paymentId, 2500); // estorna R$ 25,00 +``` + +## Recorrência + +A Cielo **não tem endpoint de criar assinatura**: a recorrência nasce de uma +venda com um bloco `RecurrentPayment`, e só então ganha um `RecurrentPaymentId` +próprio. Sempre cobra cartão. + +```php +use PHPay\Cielo\Enums\RecurrentIntervalEnum; + +$phpay = PHPay::gateway(new CieloGateway(MERCHANT_ID, MERCHANT_KEY))->subscription(); + +$recorrencia = $phpay + ->setCustomer(['Name' => 'Mário Lucas']) + ->setCard($cartao) + ->setInterval(RecurrentIntervalEnum::MONTHLY) + ->setEndDate('2027-12-31') + ->create(15700); + +$id = $recorrencia['Payment']['RecurrentPayment']['RecurrentPaymentId']; + +$phpay->updateAmount($id, 19900); +$phpay->deactivate($id); +$phpay->reactivate($id); +``` diff --git a/docs/gateways/efi.md b/docs/gateways/efi.md new file mode 100644 index 0000000..10d3553 --- /dev/null +++ b/docs/gateways/efi.md @@ -0,0 +1,152 @@ +# Efí + +**Duas APIs com as mesmas credenciais**, e é isso que dá ao Efí quatro das +cinco capacidades: + +| API | Host | Autenticação | Recursos | +| --- | --- | --- | --- | +| **Cobranças** | `cobrancas.api.efipay.com.br` | OAuth2 | `charge()` — boleto | +| **Pix** | `pix.api.efipay.com.br` | OAuth2 **+ mTLS** | `pix()`, `webhook()`, `subscription()`, `pixCharge()` | + +Clientes ficam de fora: nenhuma das duas APIs mantém cadastro de cliente. + +O gateway **não faz chamada de rede no construtor**. Cada API tem o seu token, +pedido na primeira vez que é necessário e **renovado sozinho quando expira** — +um gateway vivo num worker de fila não passa a tomar 401. + +## Cobranças (boleto) + +```php +use PHPay\Efi\EfiGateway; +use PHPay\Support\{Customer, Money}; + +$gateway = new EfiGateway(CLIENT_ID, CLIENT_SECRET); + +$cobranca = PHPay::gateway($gateway)->charge([ + 'description' => 'Assinatura PHPay', + 'expire_at' => date('Y-m-d', strtotime('+3 days')), +]) + ->setAmount(Money::reais('100,50')) + ->setCustomer(new Customer('Mário Lucas', '12345678909')) + ->create(); +``` + +## API Pix + +**Toda requisição da API Pix é por mTLS**, inclusive a do token. Passe o +certificado `.p12` (ou `.pem`) da aplicação, que você baixa no painel do Efí: + +```php +$gateway = new EfiGateway(CLIENT_ID, CLIENT_SECRET, certificate: '/caminho/certificado.p12'); +``` + +Com senha, ou vindo de uma variável de ambiente — o comum em container e +serverless: + +```php +use PHPay\Http\Certificate; + +new EfiGateway(CLIENT_ID, CLIENT_SECRET, certificate: new Certificate('/caminho/certificado.p12', 'senha')); + +new EfiGateway(CLIENT_ID, CLIENT_SECRET, certificate: Certificate::fromBase64(getenv('EFI_CERTIFICATE_BASE64'))); +``` + +> O `fromBase64()` grava o certificado num arquivo temporário com permissão +> `0600`, apagado quando o processo termina. A senha nunca aparece num +> `var_dump()`. + +Certificado de homologação só funciona com `$sandbox = true`, e o de produção, +com `false`. Sem certificado, a API de Cobranças continua funcionando e a API +Pix responde com uma `ValidationException` clara, não com um erro de TLS. + +**Cobrança Pix** — imediata por padrão, com vencimento quando há `setDueDate()`: + +```php +$cobranca = $gateway->pixCharge() + ->setAmount(Money::reais('123,45')) + ->setKey('sua-chave-pix') // chave da conta Efí que recebe + ->setCustomer(new Customer('Mário Lucas', '12345678909')) + ->setDescription('Pedido 1234') + ->setExpiration(3600) // segundos + ->create(); + +$qr = $gateway->pixCharge()->qrCode($cobranca['loc']['id']); +$qr['qrcode']; // copia e cola +$qr['imagemQrcode']; // PNG em base64 + +/* com vencimento: multa, juros e desconto como num boleto */ +$gateway->pixCharge() + ->setAmount(Money::reais(250)) + ->setKey('sua-chave-pix') + ->setCustomer(new Customer('Sixtec LTDA', '12345678000199')) + ->setDueDate('2026-12-31', validityAfterDue: 15) + ->create(); + +/* devolução, total ou parcial */ +$gateway->pixCharge()->refund($endToEndId, Money::reais(10)); +``` + +`pixCharge()` é um **extra do gateway concreto**, como o `webhookDeliveries()` +do Pagar.me: o `charge()` da facade já é o boleto, e mudar o retorno dele +quebraria quem está na v2. + +**Chaves Pix** — só chaves aleatórias (EVP) são gerenciáveis pela API: + +```php +$chave = PHPay::gateway($gateway)->pix()->createKey()['chave']; + +PHPay::gateway($gateway)->pix()->getAll(); +PHPay::gateway($gateway)->pix()->destroy($chave); +``` + +> **Sem QR Code estático.** A API Pix da Efí não expõe endpoint para criar um, +> então aqui não existe o `staticQrCode()` do Asaas e da Woovi. Pelo padrão do +> BACEN, um QR Code estático é um BR Code composto a partir da chave, de um +> valor opcional e de um identificador livre — quem oferece endpoint faz por +> conveniência, não por necessidade. O `qrCode()` do `pixCharge()`, acima, é o +> QR Code **de uma cobrança**, que é outra coisa. + +**Webhooks** — um por chave Pix, endereçado pela própria chave: + +```php +PHPay::gateway($gateway) + ->webhook(['chave' => $chave, 'webhookUrl' => 'https://loja.com/webhook/pix']) + ->create(); +``` + +> O mTLS vale **nos dois sentidos**: por padrão o Efí só entrega para um +> servidor que valide o certificado dele. Se o seu não consegue (hospedagem +> compartilhada, balanceador que termina o TLS), `skipMtlsChecking()` desliga a +> checagem — e aí valide a origem de outro jeito, como um `hmac` na URL. + +**Pix Automático** — o pagador autoriza uma vez no app do banco, e cada ciclo +é debitado sem nova aprovação: + +```php +use PHPay\Efi\Enums\{AccountTypeEnum, PeriodicityEnum}; + +$assinaturas = PHPay::gateway($gateway)->subscription(); + +/* 1. o location que o QR Code de autorização aponta */ +$location = $assinaturas->createLocation(); + +/* 2. a recorrência: o que o pagador autoriza */ +$recorrencia = $assinaturas + ->setCustomer(new Customer('Mário Lucas', '12345678909')) + ->setContract('CONTRATO-2026-001') // até 35 caracteres + ->setDescription('Plano mensal') + ->setAmount(Money::reais('49,90')) // ou setMinimumAmount(), para valor variável + ->setPeriodicity(PeriodicityEnum::MONTHLY, '2026-10-01') + ->allowRetries() // até 3 tentativas em 7 dias + ->setLocation($location['id']) + ->create(); + +$assinaturas->find($recorrencia['idRec'])['dadosQR']; // copia e cola para o pagador autorizar + +/* 3. a cobrança de cada ciclo */ +$assinaturas + ->setReceiver('12345-6', AccountTypeEnum::CHECKING, '0001') + ->createCharge($recorrencia['idRec'], Money::reais('49,90'), '2026-11-05'); + +$assinaturas->cancel($recorrencia['idRec']); +``` diff --git a/docs/gateways/mercado-pago.md b/docs/gateways/mercado-pago.md new file mode 100644 index 0000000..16f765a --- /dev/null +++ b/docs/gateways/mercado-pago.md @@ -0,0 +1,51 @@ +# Mercado Pago + +Ambiente pelo prefixo do token. `POST /v1/payments` exige o header +`X-Idempotency-Key`: o PHPay gera uma chave por chamada, e +`setIdempotencyKey()` deixa você fixar a sua — assim um retry da mesma operação +de negócio não cria duas cobranças. + +```php +use PHPay\MercadoPago\Enums\PaymentMethodEnum; +use PHPay\MercadoPago\MercadoPagoGateway; + +$gateway = new MercadoPagoGateway(ACCESS_TOKEN); + +$cobranca = PHPay::gateway($gateway)->charge() + ->setCharge([ + 'transaction_amount' => 100.50, + 'payment_method_id' => PaymentMethodEnum::PIX->value, + 'notification_url' => 'https://exemplo.test/webhook/mercadopago', + ]) + ->setPayer(['email' => 'comprador@exemplo.test']) + ->setIdempotencyKey('pedido-123456') + ->create(); + +$phpay->getPixCode($cobranca['id']); +``` + +Assinaturas usam `/preapproval`, com ou sem plano associado: + +```php +$phpay = PHPay::gateway($gateway)->subscription(); + +$phpay->setPayerEmail('comprador@exemplo.test')->create([ + 'reason' => 'Assinatura PHPay', + 'back_url' => 'https://exemplo.test/retorno', + 'auto_recurring' => [ + 'frequency' => 1, + 'frequency_type' => 'months', + 'transaction_amount' => 100.50, + 'currency_id' => 'BRL', + ], +]); + +/* com plano, a recorrência vem do plano */ +$phpay->setPayerEmail('comprador@exemplo.test') + ->setPlan('2c938084726fca480172750000000000') + ->create(['back_url' => 'https://exemplo.test/retorno']); +``` + +> O recurso `Customer` do Mercado Pago existe para cartões salvos — **não** é +> pré-requisito para cobrar, já que o pagamento carrega `payer.email` direto. A +> API também não oferece exclusão de cliente. diff --git a/docs/gateways/pagarme.md b/docs/gateways/pagarme.md new file mode 100644 index 0000000..57d0d42 --- /dev/null +++ b/docs/gateways/pagarme.md @@ -0,0 +1,64 @@ +# Pagar.me + +Autenticação Basic com a secret key, ambiente pelo prefixo da chave, e Pix como +forma de pagamento do pedido. + +```php +use PHPay\PagarMe\PagarMeGateway; + +$gateway = new PagarMeGateway(SECRET_KEY_PAGARME); + +$pedido = PHPay::gateway($gateway)->charge() + ->setCustomer([ + 'name' => 'Mário Lucas', + 'email' => 'fale@phpay.io', + 'document' => '12345678901', + ]) + ->addItem('Assinatura PHPay', 10050) // R$ 100,50 + ->setPix(1800) // expira em 30 minutos + ->create(); + +$phpay->getPixCode($pedido['id']); // de charges[0].last_transaction.qr_code +``` + +O cancelamento é `DELETE`, com valor opcional para estorno parcial: + +```php +$phpay->cancel($cobrancaId, 2500); // estorna R$ 25,00 +$phpay->cancel($cobrancaId); // estorna tudo +``` + +Assinaturas aceitam um plano ou a recorrência no próprio payload: + +```php +$phpay = PHPay::gateway($gateway)->subscription(); + +$plano = $phpay->createPlan([ + 'name' => 'Plano PHPay Mensal', + 'interval' => 'month', + 'interval_count' => 1, + 'items' => [[ + 'name' => 'Mensalidade', + 'quantity' => 1, + 'pricing_scheme' => ['price' => 4990], // R$ 49,90 + ]], +]); + +$phpay->setPlan($plano['id']) + ->setCustomerId($clienteId) + ->create(['payment_method' => 'pix']); +``` + +## Consultando entregas de webhook + +O Pagar.me deixa ler e reenviar os eventos que já despachou. Isso **não** é a +capacidade `SupportsWebhooks` — o cadastro dos endpoints é no dashboard — então +vive no gateway concreto, não na facade: + +```php +$gateway->webhookDeliveries()->setFilter(['size' => 10])->getAll(); +$gateway->webhookDeliveries()->resend($hookId); +``` + +É assim que o modelo de capacidades abre espaço para o que só um gateway +oferece: quem segura `PagarMeGateway` alcança, quem tipa uma capacidade não. diff --git a/docs/gateways/pagbank.md b/docs/gateways/pagbank.md new file mode 100644 index 0000000..2f4a356 --- /dev/null +++ b/docs/gateways/pagbank.md @@ -0,0 +1,55 @@ +# PagBank + +Duas particularidades, ambas resolvidas pela biblioteca. + +**Duas APIs em hosts diferentes.** Pedidos vivem em `api.pagseguro.com`, +assinaturas em `api.assinaturas.pagseguro.com`. Cada recurso boota o client da +API certa — você não precisa saber disso. + +**Pix não é uma cobrança.** Entra como `qr_codes` do pedido, e só um por pedido. +A conta precisa ter uma chave Pix ativa. + +```php +use PHPay\PagBank\PagBankGateway; + +$phpay = PHPay::gateway(new PagBankGateway(TOKEN_PAGBANK_SANDBOX))->charge(); + +$pedido = $phpay + ->setCustomer(['name' => 'Mário', 'email' => 'fale@phpay.io', 'tax_id' => '12345678901']) + ->addItem('Assinatura PHPay', 10050) // R$ 100,50 + ->setQrCode(10050) + ->setNotificationUrls(['https://exemplo.test/webhook/pagbank']) + ->create(); + +$phpay->getPixCode($pedido['id']); // de qr_codes[0].text +``` + +Cartão e boleto, aí sim, vão em `charges`: + +```php +$phpay + ->setCustomer($cliente) + ->addItem('Camiseta', 5990, 2) + ->setCharges([[ + 'reference_id' => 'cobranca-1', + 'amount' => ['value' => 11980, 'currency' => 'BRL'], + 'payment_method' => ['type' => 'CREDIT_CARD', 'installments' => 1, 'capture' => true], + ]]) + ->create(); +``` + +Assinaturas sempre pertencem a um plano, e o assinante pode nascer junto: + +```php +$phpay = PHPay::gateway(new PagBankGateway(TOKEN_PAGBANK_SANDBOX))->subscription(); + +$plano = $phpay->createPlan([ + 'name' => 'Plano PHPay Mensal', + 'amount' => ['value' => 4990, 'currency' => 'BRL'], // R$ 49,90 + 'interval' => ['unit' => 'MONTHS', 'length' => 1], +]); + +$phpay->setPlan($plano['id']) + ->setCustomer(['name' => 'Mário', 'email' => 'fale@phpay.io', 'tax_id' => '12345678901']) + ->create(); +``` diff --git a/docs/gateways/rede.md b/docs/gateways/rede.md new file mode 100644 index 0000000..f9f94ee --- /dev/null +++ b/docs/gateways/rede.md @@ -0,0 +1,49 @@ +# Rede + +Adquirente, e a forma mais estreita da biblioteca: **só +cobranças**. Não há recurso de cliente nem assinatura gerenciável — a +transação tem um campo `subscription`, mas é uma flag para a adquirente, não +algo que você liste ou cancele. + +A particularidade é a autenticação: **OAuth2 `client_credentials` num host +separado do de API**, com o caminho do token diferente em cada ambiente. E o +token **expira** — o PHPay renegocia sozinho quando isso acontece. + +```php +use PHPay\Rede\Enums\TransactionKindEnum; +use PHPay\Rede\RedeGateway; + +/* nenhuma chamada de rede aqui: o token é negociado no primeiro uso */ +$gateway = new RedeGateway(REDE_PV, REDE_TOKEN); + +$transacao = PHPay::gateway($gateway)->charge() + ->setReference('pedido-1') + ->setCard('5448280000000007', 'MARIO LUCAS', '12', '2030', '123') + ->setPayment(2099, TransactionKindEnum::CREDIT) // R$ 20,99 + ->setSoftDescriptor('PHPAY') + ->create(); +``` + +Fluxo em duas etapas — autoriza agora, captura quando o pedido for separado: + +```php +$phpay->setPayment(5000, capture: false)->create(); + +$phpay->capture($tid); +$phpay->refund($tid, 1000); // estorna R$ 10,00 +``` + +O código de retorno `"00"` significa aprovada: + +```php +use PHPay\Rede\Enums\TransactionStatusEnum; + +TransactionStatusEnum::approved($phpay->getStatus($tid)); +``` + +Num processo longo, dá para inspecionar ou descartar o token em mãos: + +```php +$gateway->authorization()->hasValidToken(); +$gateway->authorization()->forget(); +``` diff --git a/docs/gateways/woovi.md b/docs/gateways/woovi.md new file mode 100644 index 0000000..a60ef3c --- /dev/null +++ b/docs/gateways/woovi.md @@ -0,0 +1,56 @@ +# Woovi/OpenPix + +**O segundo gateway com as cinco capacidades**, ao lado do Asaas — e o que +confirma que o modelo descreve o domínio, não um fornecedor: são duas empresas +independentes, com APIs independentes, preenchendo o mesmo contrato. + +Sendo PSP Pix-nativo, ele gerencia chaves e QR Code estático de verdade. + +```php +use PHPay\Woovi\Enums\PixKeyTypeEnum; +use PHPay\Woovi\WooviGateway; + +$phpay = PHPay::gateway(new WooviGateway(WOOVI_APP_ID)); + +/* chaves Pix da conta */ +$phpay->pix()->createKey(PixKeyTypeEnum::RANDOM); +$phpay->pix()->getAll(); + +/* consulta uma chave antes de pagar */ +$phpay->pix()->verifyKey('fale@phpay.io'); + +/* QR Code estático, com ou sem valor */ +$phpay->pix()->staticQrCode('Caixa 1'); +$phpay->pix()->staticQrCode('Caixa 2', 2500); +``` + +Três particularidades: + +**O AppID vai cru no `Authorization`** — sem `Bearer`, sem `Basic`. + +**O sandbox tem domínio próprio**: `api.woovi-sandbox.com` contra +`api.openpix.com.br`. + +**Todo objeto é endereçável pelo `correlationID`**, o id no *seu* sistema — +nenhum outro gateway da biblioteca oferece isso: + +```php +$cobranca = $phpay->charge() + ->setCorrelationId('pedido-1') + ->setCustomer(['name' => 'Mário Lucas', 'email' => 'fale@phpay.io']) + ->create(10050); // R$ 100,50 + +$phpay->charge()->getPixCode($cobranca); +$phpay->charge()->find('pedido-1'); // pelo SEU id, não pelo do gateway +``` + +Webhooks têm CRUD por API — junto com o Asaas, os únicos: + +```php +$phpay->webhook(['name' => 'PHPay', 'url' => 'https://exemplo.test/webhook'])->create(); +$phpay->webhook()->getAll(); +``` + +> Repare que o webhook fica em `api/openpix/v1/`, enquanto os demais recursos +> ficam em `api/v1/` — herança da fusão das duas marcas. O PHPay trata isso +> internamente. diff --git a/docs/retry-e-idempotencia.md b/docs/retry-e-idempotencia.md new file mode 100644 index 0000000..9174e1c --- /dev/null +++ b/docs/retry-e-idempotencia.md @@ -0,0 +1,65 @@ +# Retry e idempotência + +A biblioteca repete uma requisição **só quando repetir não pode cobrar de novo**. +Vale para os nove gateways, porque o middleware vive na +`PHPay\Http\ClientFactory`, o único lugar onde um client nasce. + +| Situação | Repete | +| --- | --- | +| Erro antes de a requisição sair (DNS, connect, handshake TLS) | sempre — nada chegou ao gateway | +| Timeout sem conexão estabelecida | sempre — nada chegou | +| Timeout com conexão estabelecida | só leitura, ou com chave de idempotência | +| `429` | sempre, respeitando `Retry-After` | +| `5xx` | só leitura, ou com chave de idempotência | +| `4xx` fora do 429 | nunca — o gateway entendeu e recusou | + +Leitura é `GET`, `HEAD` e `OPTIONS`. `PUT`, `PATCH` e `DELETE` **não** são +repetidos, apesar de o HTTP chamar `PUT` e `DELETE` de idempotentes: a +documentação do Asaas pede para +[não tratar a exclusão de cobrança como idempotente](https://docs.asaas.com/reference/excluir-cobranca) +sem conferir a resposta, e esse aviso vale mais que a teoria. + +São 3 tentativas no total — 200ms, 400ms, teto de 2s, com jitter. Se o gateway +mandar um `Retry-After` maior que o teto, a biblioteca **desiste** em vez de +repetir antes da janela pedida, porque repetir cedo só seria recusado de novo. +E lembre que retry multiplica o pior caso de tempo de parede: 3 tentativas com +`timeout` de 60s é o limite superior. + +Para mudar a política, passe uma à fábrica: + +```php +use PHPay\Http\{ClientFactory, RetryPolicy}; + +$maisTentativas = ClientFactory::make(['base_uri' => $uri], new RetryPolicy(5)); +$semRetry = ClientFactory::make(['base_uri' => $uri], RetryPolicy::disabled()); +``` + +## Chave de idempotência + +Com uma chave, o `POST` passa a ser repetível: o gateway reconhece a segunda +requisição como a mesma operação e devolve o resultado da primeira em vez de +cobrar outra vez. + +| Gateway | Header | Como | +| --- | --- | --- | +| Mercado Pago | `X-Idempotency-Key` | sempre enviada — a API de pagamentos exige | +| Pagar.me | `Idempotency-Key` | você informa; vale 24h | +| PagBank | `x-idempotency-key` | você informa; vale 48h | + +```php +$pedido = $pagbank->charge() + ->setCustomer($cliente) + ->addItem('Assinatura PHPay', 10050) + ->setIdempotencyKey($idDoSeuPedido) + ->create(); +``` + +**Use uma chave sua, guardada junto do registro pendente.** Chave gerada pela +biblioteca cobre só as tentativas de dentro da mesma chamada — o retry reenvia +a mesma requisição, com o mesmo header. O retry do seu lado, em outro processo +ou em outra chamada a `create()`, chega com chave nova e cobra de novo. + +Os outros gateways não documentam chave de idempotência. Neles um `POST` só é +repetido quando o erro provou que a requisição não saiu da máquina — e no Asaas +a orientação da própria documentação é +[consultar a cobrança antes de repetir](https://docs.asaas.com/docs/cobrancas-via-cartao-de-credito). diff --git a/docs/valores-monetarios.md b/docs/valores-monetarios.md new file mode 100644 index 0000000..7c7fcdf --- /dev/null +++ b/docs/valores-monetarios.md @@ -0,0 +1,56 @@ +# Unidade monetária + +Os gateways discordam sobre a unidade, e **errar não quebra a integração — ela +cobra o valor errado**. Mandar `100.50` num gateway de centavos cobra R$ 1,00, +e você só descobre na conciliação. + +Use `Money` e o problema deixa de existir: você diz a unidade que tem, o +gateway pede a unidade que precisa, e nenhum dos dois pode errar. + +```php +use PHPay\Support\Money; + +$valor = Money::reais(100.50); // ou Money::centavos(10050) + +$asaas->charge()->setAmount($valor); // vira 100.50 +$pagbank->charge()->addItem('Item', $valor); // vira 10050 +``` + +Também aceita string, que é o que um campo de formulário costuma entregar: + +```php +Money::reais('100,50'); // notação brasileira +Money::reais('1.234,56'); // com separador de milhar +Money::reais('100.50'); // notação com ponto +``` + +E tem o que um total precisa: + +```php +$unitario = Money::reais(59.90); + +$unitario->multiply(2); // R$ 119,80 +$unitario->plus(Money::reais(10)); // R$ 69,90 +$unitario->format(); // 'R$ 59,90' +``` + +> `Money::reais(10 / 3)` **lança exceção** em vez de arredondar. Arredondamento +> silencioso é como nascem erros de um centavo na conciliação — arredonde você +> mesmo, ou use `Money::centavos()` para ser exato. + +## Passando número cru + +Continua funcionando, e cada gateway lê na unidade que sempre esperou: + +| Gateway | Unidade do número cru | R$ 100,50 | +| --- | --- | --- | +| **Asaas**, **Mercado Pago** | Reais (decimal) | `100.50` | +| **PagBank**, **Pagar.me**, **Cielo**, **Rede**, **AbacatePay**, **Woovi**, **Efí** | Centavos (inteiro) | `10050` | + +Nos que usam centavos, o PHPay recusa decimal na validação. Mas é justamente +essa tabela que o `Money` torna desnecessária — **prefira o value object**. + +A exceção é a [API Pix do Efí](gateways/efi.md#api-pix), que **não aceita número cru, só +`Money`**. Ela quer reais (`"100.50"`) enquanto a API de Cobranças do mesmo +gateway quer centavos, e um número solto ali seria a ambiguidade que o value +object existe para eliminar. diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 0000000..0cab7be --- /dev/null +++ b/docs/webhooks.md @@ -0,0 +1,47 @@ +# Verificando o webhook que chega + +Cadastrar o endpoint é metade do caminho. A outra é provar que a notificação +veio do gateway: sem isso, quem descobrir a URL confirma uma cobrança. Cada +gateway assina de um jeito, e `webhookSignature()` dá a todos a mesma forma. + +```php +$ok = $gateway->webhookSignature() + ->setSecret($segredoDoWebhook) + ->setPayload(file_get_contents('php://input')) + ->setHeaders(getallheaders()) + ->verify(); + +if (!$ok) { + http_response_code(401); + exit; +} +``` + +`verify()` devolve `bool` — assinatura que não bate é resposta, não erro. O que +levanta `ValidationException` é uso errado: segredo ausente, corpo vazio ou +header de assinatura que não chegou. + +**O corpo precisa ser o cru.** `file_get_contents('php://input')`, nunca o array +decodificado e reserializado: um espaço a mais muda o hash. Em Laravel, +`$request->getContent()`. + +| Gateway | O que é verificado | O que isso prova | +| --- | --- | --- | +| Asaas | token no header `asaas-access-token` | que o remetente conhece o token — o corpo não é assinado | +| PagBank | `x-authenticity-token`: SHA-256 de `{token}-{corpo}` | que o corpo chegou inteiro | +| Mercado Pago | `x-signature`: HMAC-SHA256 de `data.id` + `x-request-id` + `ts` | que a notificação é da sua aplicação e aponta para aquele recurso | +| Woovi/OpenPix | `x-webhook-signature`: RSA-SHA256 do corpo, conferido com a chave pública da Woovi | que o corpo veio da Woovi | +| AbacatePay | `webhookSecret` na query e, com a chave, `X-Webhook-Signature` | que a URL é a sua e, com a chave, que o corpo não mudou | + +No Mercado Pago, informe o `data.id` da query com `setDataId()`: ele entra no +cálculo, e omiti-lo muda o manifesto. Na Woovi, a chave pública é buscada em +`api/v1/webhook/public-keys` na primeira verificação — passe `setPublicKeys()` +para evitar a chamada — e a lista inteira é aceita, porque durante uma rotação +ainda chegam notificações assinadas pela chave anterior. + +Quem **não** declara a capacidade: **Efí**, porque a notificação Pix chega por +mTLS e o certificado já cobre isso; **Cielo** e **Rede**, porque a notificação +não traz assinatura e o caminho que a própria documentação indica é consultar a +transação de volta; e **Pagar.me**, porque a v5 não documenta esquema de +assinatura. Nos quatro, `webhookSignature()` levanta `NotImplementedException` +em vez de devolver um verificador que sempre diz sim.