From 2f008eac776c3fa233868b4324d2b171c5aca691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rio=20Lucas?= Date: Sun, 20 Sep 2026 20:11:09 -0300 Subject: [PATCH] PHPAY-76: feat(pagbank): adicionar o gateway PagBank (PagSeguro) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quarto gateway da biblioteca. Declara três das cinco capacidades: SupportsCustomers /customers (assinantes) SupportsCharges /orders, /charges SupportsSubscriptions /plans, /subscriptions Não declara SupportsWebhooks porque o PagBank não expõe CRUD de webhooks por API — eles são registrados no painel, ou por pedido via notification_urls. Não declara SupportsPixKeys porque lá o Pix não é sequer uma cobrança. Três particularidades que nenhum gateway anterior tinha: 1. Duas APIs em hosts diferentes. Pedidos e cobranças vivem em api.pagseguro.com; planos, assinantes e assinaturas em api.assinaturas.pagseguro.com. O trait expõe clientPagBankBoot() e clientPagBankSubscriptionsBoot(), e cada recurso boota o da sua API — quem usa a biblioteca não precisa saber que são duas. 2. Pix não é uma charge. O pedido carrega qr_codes: [{amount: {value}}] em vez de charges, o copia-e-cola volta em qr_codes[0].text, e só um QR Code por pedido é aceito. setQrCode() e setCharges() expressam essa diferença em vez de escondê-la atrás de um "billingType". 3. Todo valor é inteiro em centavos. R$ 100,50 é 10050, e mandar 100.50 cobraria um real. Os validadores recusam decimal antes de qualquer chamada, com mensagem dizendo a conversão — é o erro mais fácil de cometer com essa API e o mais caro de descobrir em produção. O cliente do pedido vem embutido nele; o CRUD de /customers é de assinantes, do domínio de recorrência, e por isso vive no host de assinaturas. Planos ficam no recurso Subscription em vez de virarem recurso próprio: uma assinatura sempre pertence a um plano, é um fluxo só. 123 testes no total. Os do PagBank cobrem o roteamento de cada recurso para o host certo, o Pix indo como qr_code e não como charge, a recusa de valor decimal, a recusa de mais de um QR Code, e as rotas de suspender, reativar e cancelar assinatura. Inclui também examples/pagbank/sandbox-check.php, que roda contra o sandbox de verdade e relata cada operação — teste com HTTP mockado prova que montamos o payload que decidimos, não que o gateway o aceita. --- .gitignore | 5 +- CLAUDE.md | 15 +- README.md | 95 +++++- composer.json | 3 +- examples/pagbank/charges.php | 78 +++++ examples/pagbank/credentials.example.php | 16 + examples/pagbank/sandbox-check.php | 138 +++++++++ examples/pagbank/subscriptions.php | 59 ++++ .../PagBank/Enums/IntervalUnitEnum.php | 10 + .../PagBank/Enums/PaymentMethodEnum.php | 16 + .../PagBank/Enums/SubscriptionStatusEnum.php | 11 + .../Interface/PagBankGatewayInterface.php | 46 +++ src/Gateways/PagBank/PagBankGateway.php | 67 +++++ .../Requests/PagBankCustomerRequest.php | 52 ++++ .../PagBank/Requests/PagBankOrderRequest.php | 124 ++++++++ .../Requests/PagBankSubscriptionRequest.php | 107 +++++++ .../PagBank/Resources/Charge/Charge.php | 275 ++++++++++++++++++ .../Charge/Interface/ChargeInterface.php | 113 +++++++ .../PagBank/Resources/Customer/Customer.php | 112 +++++++ .../Customer/Interface/CustomerInterface.php | 44 +++ .../Interface/SubscriptionInterface.php | 108 +++++++ .../Resources/Subscription/Subscription.php | 222 ++++++++++++++ .../PagBank/Traits/HasPagBankClient.php | 93 ++++++ tests/Pest.php | 12 + tests/Unit/PagBank/ChargeTest.php | 170 +++++++++++ tests/Unit/PagBank/PagBankGatewayTest.php | 61 ++++ tests/Unit/PagBank/SubscriptionTest.php | 137 +++++++++ 27 files changed, 2174 insertions(+), 15 deletions(-) create mode 100644 examples/pagbank/charges.php create mode 100644 examples/pagbank/credentials.example.php create mode 100644 examples/pagbank/sandbox-check.php create mode 100644 examples/pagbank/subscriptions.php create mode 100644 src/Gateways/PagBank/Enums/IntervalUnitEnum.php create mode 100644 src/Gateways/PagBank/Enums/PaymentMethodEnum.php create mode 100644 src/Gateways/PagBank/Enums/SubscriptionStatusEnum.php create mode 100644 src/Gateways/PagBank/Interface/PagBankGatewayInterface.php create mode 100644 src/Gateways/PagBank/PagBankGateway.php create mode 100644 src/Gateways/PagBank/Requests/PagBankCustomerRequest.php create mode 100644 src/Gateways/PagBank/Requests/PagBankOrderRequest.php create mode 100644 src/Gateways/PagBank/Requests/PagBankSubscriptionRequest.php create mode 100644 src/Gateways/PagBank/Resources/Charge/Charge.php create mode 100644 src/Gateways/PagBank/Resources/Charge/Interface/ChargeInterface.php create mode 100644 src/Gateways/PagBank/Resources/Customer/Customer.php create mode 100644 src/Gateways/PagBank/Resources/Customer/Interface/CustomerInterface.php create mode 100644 src/Gateways/PagBank/Resources/Subscription/Interface/SubscriptionInterface.php create mode 100644 src/Gateways/PagBank/Resources/Subscription/Subscription.php create mode 100644 src/Gateways/PagBank/Traits/HasPagBankClient.php create mode 100644 tests/Unit/PagBank/ChargeTest.php create mode 100644 tests/Unit/PagBank/PagBankGatewayTest.php create mode 100644 tests/Unit/PagBank/SubscriptionTest.php diff --git a/.gitignore b/.gitignore index 300d312..fbac00b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,12 @@ vendor/ node_modules/ +.idea/ + +# credenciais dos exemplos (nunca versionar) examples/asaas/credentials.php examples/efi/credentials.php -.idea/ examples/mercadopago/credentials.php +examples/pagbank/credentials.php # configurações locais do Claude Code (pessoais, não versionar) .claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md index 89dd989..647274c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,8 @@ Orientações para o Claude Code trabalhar neste repositório. PHPay (`phpay-io/phpay`) é uma **biblioteca PHP** (não uma aplicação) que padroniza a integração com gateways de pagamento brasileiros. Hoje suporta **Asaas** (as cinco -capacidades), **Mercado Pago** (clientes, cobranças, assinaturas) e **Efí** (cobranças). +capacidades), **Mercado Pago** e **PagBank** (clientes, cobranças, assinaturas) e +**Efí** (cobranças). Requisitos: PHP `^8.1` para consumir a lib; `^8.2` para rodar o ambiente de dev (Pest 3 e Termwind 2 exigem 8.2+). Dependências de runtime: `ext-curl`, `ext-json`, @@ -137,6 +138,12 @@ e rode `php examples/asaas/charges.php` (ou `make asaas resource=charges`). - **Asaas** — `$sandbox` troca a base URL. Único com chaves Pix, porque é PSP. - **Efí** — autoriza sob demanda (token em cache no gateway); `$sandbox` troca a base URL. +- **PagBank** — **duas APIs em hosts diferentes**: pedidos em `api.pagseguro.com`, + assinaturas em `api.assinaturas.pagseguro.com`. O trait expõe `clientPagBankBoot()` + e `clientPagBankSubscriptionsBoot()`; cada recurso boota o seu. **Todo valor é + inteiro em centavos** — os validadores recusam decimal, porque mandar `10.50` onde + se espera `1050` cobra onze centavos. Pix é `qr_codes` do pedido (um só por pedido, + copia-e-cola em `qr_codes[0].text`), não uma `charge`. - **Mercado Pago** — **não tem URL de sandbox**: o ambiente vem do prefixo `TEST-` do access token, então o construtor não recebe `$sandbox`. `POST /v1/payments` exige `X-Idempotency-Key` (por isso `HasHttpClient::post()` aceita headers por requisição). @@ -148,9 +155,9 @@ e rode `php examples/asaas/charges.php` (ou `make asaas resource=charges`). carnê e NFe seguem pendentes na API do Asaas. - A Efí só tem autorização e cobranças; `customer`, `webhook`, `pix` e `subscription` lançam `NotImplementedException`. -- O Mercado Pago não implementa `SupportsWebhooks` nem `SupportsPixKeys`, e isso é - correto: webhooks só têm configuração por painel ou `notification_url` por pagamento, - e Pix lá é forma de pagamento. Não "resolva" isso criando stubs. +- Nem o Mercado Pago nem o PagBank implementam `SupportsWebhooks` ou `SupportsPixKeys`, + e isso é correto: webhooks só têm configuração por painel ou `notification_url(s)` por + cobrança, e Pix nos dois é forma de pagamento. Não "resolva" isso criando stubs. - `Efi\Resources\Charge\Charge` tem `$items` e `$configuration` privados sem setter — hoje sempre caem no fallback (`getItems()` monta um item a partir de `description`/`value`; `getConfigurations()` usa fine 200 / interest 33). diff --git a/README.md b/README.md index 8cfc49c..21c3330 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ O PHPay é uma biblioteca PHP que tem o objetivo tornar o trabalho de integraç - Asaas (cobranças, clientes, webhooks, chaves Pix e assinaturas) - Mercado Pago (cobranças, clientes e assinaturas) +- PagBank / PagSeguro (cobranças, assinantes e assinaturas) - Efí (cobranças) ## ⬆️ Vindo da v1? @@ -164,16 +165,17 @@ $phpay Nem todo gateway oferece todo recurso. Cada gateway **declara** o que suporta através de interfaces de capacidade, em vez de o contrato ser a união de tudo: -| Capacidade | Interface | Asaas | Mercado Pago | Efí | -| --- | --- | :---: | :---: | :---: | -| Clientes | `SupportsCustomers` | ✅ | ✅ | — | -| Cobranças | `SupportsCharges` | ✅ | ✅ | ✅ | -| Webhooks | `SupportsWebhooks` | ✅ | — | — | -| Chaves Pix | `SupportsPixKeys` | ✅ | — | — | -| Assinaturas | `SupportsSubscriptions` | ✅ | ✅ | — | +| Capacidade | Interface | Asaas | Mercado Pago | PagBank | Efí | +| --- | --- | :---: | :---: | :---: | :---: | +| Clientes | `SupportsCustomers` | ✅ | ✅ | ✅ | — | +| Cobranças | `SupportsCharges` | ✅ | ✅ | ✅ | ✅ | +| Webhooks | `SupportsWebhooks` | ✅ | — | — | — | +| Chaves Pix | `SupportsPixKeys` | ✅ | — | — | — | +| Assinaturas | `SupportsSubscriptions` | ✅ | ✅ | ✅ | — | -> O Mercado Pago não expõe CRUD de webhooks por API: eles são configurados no -> painel "Suas integrações", ou por pagamento através do campo `notification_url`. +> Nem Mercado Pago nem PagBank expõem CRUD de webhooks por API: eles são +> registrados no painel, ou por cobrança através de `notification_url` / +> `notification_urls`. > `SupportsPixKeys` é mais estreito que "aceita Pix": ele significa gerenciar > chaves e QR Code estático, algo que só um PSP que emite chave própria oferece. @@ -310,6 +312,73 @@ $phpay->setPayerEmail('comprador@exemplo.test') ->create(['back_url' => 'https://exemplo.test/retorno']); ``` +## 🏦 PagBank (PagSeguro) + +Duas particularidades que o PHPay resolve por você. + +**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. + +**Todo valor é inteiro em centavos.** R$ 100,50 é `10050`. Mandar `100.50` +cobraria um real. O PHPay recusa decimal na validação, antes de chegar na API. + +```php +use PHPay\PagBank\PagBankGateway; + +$phpay = PHPay::gateway(new PagBankGateway(TOKEN_PAGBANK_SANDBOX))->charge(); +``` + +No PagBank o **Pix não é uma cobrança**: ele entra como `qr_codes` do pedido, e +só um por pedido. A conta precisa ter uma chave Pix ativa. + +```php +$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']); // copia-e-cola, de qr_codes[0].text +``` + +Cartão e boleto, aí sim, vão em `charges`: + +```php +$phpay + ->setCustomer($customer) + ->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(); +``` + +Para conferir contra o sandbox de verdade: + +```bash +PAGBANK_TOKEN='...' php examples/pagbank/sandbox-check.php +``` + ## 📝 Roadmap - Definições de Arquitetura ✅ @@ -337,6 +406,14 @@ $phpay->setPayerEmail('comprador@exemplo.test') - Webhook — sem CRUD por API - Pix ✅ (como forma de pagamento) + - PagBank. + + - Cobranças ✅ + - Assinantes ✅ + - Assinaturas ✅ (com planos) + - Webhook — sem CRUD por API + - Pix ✅ (como QR Code do pedido) + - Efí. - Autorização ✅ diff --git a/composer.json b/composer.json index 87e4561..6b49196 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,8 @@ "PHPay\\": "src/", "PHPay\\Asaas\\": "src/Gateways/Asaas/", "PHPay\\Efi\\": "src/Gateways/Efi/", - "PHPay\\MercadoPago\\": "src/Gateways/MercadoPago/" + "PHPay\\MercadoPago\\": "src/Gateways/MercadoPago/", + "PHPay\\PagBank\\": "src/Gateways/PagBank/" } }, "autoload-dev": { diff --git a/examples/pagbank/charges.php b/examples/pagbank/charges.php new file mode 100644 index 0000000..c32891d --- /dev/null +++ b/examples/pagbank/charges.php @@ -0,0 +1,78 @@ +charge(); + +$customer = [ + 'name' => NAME, + 'email' => EMAIL, + 'tax_id' => TAX_ID, +]; + +try { + /* + | Pedido com Pix. Repare que o Pix NÃO é uma charge: ele entra como + | qr_codes do pedido, e só um por pedido. A conta precisa ter uma chave + | Pix ativa no PagBank. + | + | Todo valor é inteiro em CENTAVOS: R$ 100,50 é 10050. + */ + $pedido = $phpay + ->setCustomer($customer) + ->addItem('Assinatura PHPay', 10050) + ->setQrCode(10050) + /* sem CRUD de webhook na API: a notificação é por pedido */ + ->setNotificationUrls(['https://exemplo.test/webhook/pagbank']) + ->create(); + + $pedidoId = (string) $pedido['id']; + + /* código copia-e-cola, que vem em qr_codes[0].text */ + echo $phpay->getPixCode($pedidoId) . PHP_EOL; + + /* consulta do pedido */ + $phpay->find($pedidoId); + + /* + | Pedido com cartão. Aqui sim a cobrança vai em charges. + | O card exige tokenização — veja a documentação do PagBank. + */ + $comCartao = PHPay::gateway(new PagBankGateway(TOKEN_PAGBANK_SANDBOX)) + ->charge() + ->setCustomer($customer) + ->addItem('Camiseta', 5990, 2) + ->setCharges([[ + 'reference_id' => 'cobranca-1', + 'description' => 'Camiseta', + 'amount' => ['value' => 11980, 'currency' => 'BRL'], + 'payment_method' => [ + 'type' => PaymentMethodEnum::CREDIT_CARD->value, + 'installments' => 1, + 'capture' => true, + /* 'card' => ['encrypted' => '...'] */ + ], + ]]) + ->create(); + + $cobrancaId = (string) $comCartao['charges'][0]['id']; + + echo $phpay->getStatus($cobrancaId) . PHP_EOL; + + /* estorno parcial e total, também em centavos */ + $phpay->refund($cobrancaId, 2500); + $phpay->refund($cobrancaId); +} catch (PHPayException $exception) { + echo $exception->getMessage() . PHP_EOL; +} diff --git a/examples/pagbank/credentials.example.php b/examples/pagbank/credentials.example.php new file mode 100644 index 0000000..858d563 --- /dev/null +++ b/examples/pagbank/credentials.example.php @@ -0,0 +1,16 @@ +getStatusCode()} — {$e->getMessage()}\n"; + + if (!empty($e->getResponse())) { + echo ' corpo: ' . json_encode($e->getResponse(), JSON_UNESCAPED_UNICODE) . "\n"; + } + + return null; + } catch (PHPayException $e) { + $falhou++; + echo " FALHA {$titulo}\n"; + echo " {$e->getMessage()}\n"; + + return null; + } +} + +echo "Conferência de conformidade — PagBank (sandbox)\n\n"; + +$customer = [ + 'name' => 'PHPay Sandbox', + 'email' => 'comprador@sandbox.pagseguro.com.br', + 'tax_id' => '12345678909', +]; + +echo "Pedidos (api.pagseguro.com)\n"; + +$pedido = checar('criar pedido com Pix', fn () => $phpay->charge() + ->setCustomer($customer) + ->addItem('PHPay sandbox check', 100) + ->setQrCode(100) + ->create()); + +if (is_array($pedido) && isset($pedido['id'])) { + $pedidoId = (string) $pedido['id']; + + checar('buscar pedido por id', fn () => $phpay->charge()->find($pedidoId)); + + $codigo = checar('código Pix copia-e-cola', fn () => $phpay->charge()->getPixCode($pedidoId)); + + if ($codigo === null) { + echo " atenção: veio null — a conta de sandbox precisa de uma chave Pix ativa\n"; + } +} + +echo "\nAssinaturas (api.assinaturas.pagseguro.com)\n"; + +$plano = checar('criar plano', fn () => $phpay->subscription()->createPlan([ + 'name' => 'PHPay sandbox check', + 'description' => 'Plano de verificação', + 'amount' => ['value' => 100, 'currency' => 'BRL'], + 'interval' => ['unit' => IntervalUnitEnum::MONTHS->value, 'length' => 1], +])); + +if (is_array($plano) && isset($plano['id'])) { + $planoId = (string) $plano['id']; + + checar('buscar plano por id', fn () => $phpay->subscription()->findPlan($planoId)); + + $assinatura = checar('criar assinatura com assinante embutido', fn () => $phpay->subscription() + ->setPlan($planoId) + ->setCustomer($customer) + ->create()); + + if (is_array($assinatura) && isset($assinatura['id'])) { + $assinaturaId = (string) $assinatura['id']; + + checar('buscar assinatura por id', fn () => $phpay->subscription()->find($assinaturaId)); + checar('cancelar assinatura', fn () => $phpay->subscription()->cancel($assinaturaId)); + } +} + +checar('listar assinantes', fn () => $phpay->customer()->setFilter(['offset' => 0, 'limit' => 5])->getAll()); + +echo "\n"; +echo "Resultado: {$passou} ok, {$falhou} falha(s).\n"; + +exit($falhou > 0 ? 1 : 0); diff --git a/examples/pagbank/subscriptions.php b/examples/pagbank/subscriptions.php new file mode 100644 index 0000000..d21196e --- /dev/null +++ b/examples/pagbank/subscriptions.php @@ -0,0 +1,59 @@ +subscription(); + +try { + /* toda assinatura pertence a um plano; valor em CENTAVOS */ + $plano = $phpay->createPlan([ + 'name' => 'Plano PHPay Mensal', + 'description' => 'Acesso mensal', + 'amount' => ['value' => 4990, 'currency' => 'BRL'], + 'interval' => ['unit' => IntervalUnitEnum::MONTHS->value, 'length' => 1], + ]); + + $planoId = (string) $plano['id']; + + /* o assinante pode ser criado junto com a assinatura */ + $assinatura = $phpay + ->setPlan($planoId) + ->setCustomer([ + 'name' => NAME, + 'email' => EMAIL, + 'tax_id' => TAX_ID, + ]) + ->create(); + + $assinaturaId = (string) $assinatura['id']; + + /* ou reaproveitado pelo id, se já existir */ + PHPay::gateway(new PagBankGateway(TOKEN_PAGBANK_SANDBOX)) + ->subscription() + ->setPlan($planoId) + ->setCustomerId((string) $assinatura['customer']['id']) + ->create(); + + $phpay->find($assinaturaId); + $phpay->setFilter(['offset' => 0, 'limit' => 10])->getAll(); + + $phpay->suspend($assinaturaId); + $phpay->activate($assinaturaId); + $phpay->cancel($assinaturaId); +} catch (PHPayException $exception) { + echo $exception->getMessage() . PHP_EOL; +} diff --git a/src/Gateways/PagBank/Enums/IntervalUnitEnum.php b/src/Gateways/PagBank/Enums/IntervalUnitEnum.php new file mode 100644 index 0000000..a6cd88f --- /dev/null +++ b/src/Gateways/PagBank/Enums/IntervalUnitEnum.php @@ -0,0 +1,10 @@ + $customer + * @return Customer + */ + public function customer(array $customer = []): Customer; + + /** + * get resource charge from gateway. + * + * @return Charge + */ + public function charge(): Charge; + + /** + * get resource subscription from gateway. + * + * @return Subscription + */ + public function subscription(): Subscription; +} diff --git a/src/Gateways/PagBank/PagBankGateway.php b/src/Gateways/PagBank/PagBankGateway.php new file mode 100644 index 0000000..570c26c --- /dev/null +++ b/src/Gateways/PagBank/PagBankGateway.php @@ -0,0 +1,67 @@ + $customer + * @return Customer + */ + public function customer(array $customer = []): Customer + { + return new Customer($this->token, $customer, $this->sandbox, $this->client); + } + + /** + * charge + * + * @return Charge + */ + public function charge(): Charge + { + return new Charge($this->token, $this->sandbox, $this->client); + } + + /** + * subscription + * + * @return Subscription + */ + public function subscription(): Subscription + { + return new Subscription($this->token, $this->sandbox, $this->client); + } +} diff --git a/src/Gateways/PagBank/Requests/PagBankCustomerRequest.php b/src/Gateways/PagBank/Requests/PagBankCustomerRequest.php new file mode 100644 index 0000000..d4fd7f8 --- /dev/null +++ b/src/Gateways/PagBank/Requests/PagBankCustomerRequest.php @@ -0,0 +1,52 @@ + $customer + * @return void + * @throws ValidationException + */ + public static function validate(array $customer): void + { + $messages = self::messages(); + + if (!isset($customer['name']) || !is_string($customer['name']) || trim($customer['name']) === '') { + throw ValidationException::make('PagBank', $messages->name); + } + + if (!isset($customer['email']) + || !is_string($customer['email']) + || filter_var($customer['email'], FILTER_VALIDATE_EMAIL) === false + ) { + throw ValidationException::make('PagBank', $messages->email); + } + + if (!isset($customer['tax_id']) + || !is_string($customer['tax_id']) + || !in_array(strlen($customer['tax_id']), [11, 14], true) + ) { + throw ValidationException::make('PagBank', $messages->taxId); + } + } + + /** + * messages for validation + * + * @return object{name: string, email: string, taxId: string} + */ + public static function messages(): object + { + return (object) [ + 'name' => 'O campo name é obrigatório e deve ser uma string não vazia.', + 'email' => 'O campo email é obrigatório e deve ser um e-mail válido.', + 'taxId' => 'O campo tax_id é obrigatório e deve ter 11 dígitos (CPF) ou 14 (CNPJ), somente números.', + ]; + } +} diff --git a/src/Gateways/PagBank/Requests/PagBankOrderRequest.php b/src/Gateways/PagBank/Requests/PagBankOrderRequest.php new file mode 100644 index 0000000..08aaeb7 --- /dev/null +++ b/src/Gateways/PagBank/Requests/PagBankOrderRequest.php @@ -0,0 +1,124 @@ + $order + * @return void + * @throws ValidationException + * @see https://developer.pagbank.com.br/reference/criar-pedido-simples + */ + public static function validate(array $order): void + { + $messages = self::messages(); + + self::validateCustomer($order, $messages); + self::validateItems($order, $messages); + + $hasCharges = isset($order['charges']) && is_array($order['charges']) && !empty($order['charges']); + $hasQrCodes = isset($order['qr_codes']) && is_array($order['qr_codes']) && !empty($order['qr_codes']); + + if (!$hasCharges && !$hasQrCodes) { + throw ValidationException::make('PagBank', $messages->payment); + } + + if ($hasQrCodes && count($order['qr_codes']) > 1) { + throw ValidationException::make('PagBank', $messages->singleQrCode); + } + } + + /** + * validate the customer embedded in the order. + * + * @param array $order + * @param object{customer: string, customerName: string, customerEmail: string, customerTaxId: string, items: string, itemName: string, itemQuantity: string, itemAmount: string, payment: string, singleQrCode: string} $messages + * @return void + * @throws ValidationException + */ + private static function validateCustomer(array $order, object $messages): void + { + if (!isset($order['customer']) || !is_array($order['customer'])) { + throw ValidationException::make('PagBank', $messages->customer); + } + + $customer = $order['customer']; + + if (!isset($customer['name']) || !is_string($customer['name']) || trim($customer['name']) === '') { + throw ValidationException::make('PagBank', $messages->customerName); + } + + if (!isset($customer['email']) + || !is_string($customer['email']) + || filter_var($customer['email'], FILTER_VALIDATE_EMAIL) === false + ) { + throw ValidationException::make('PagBank', $messages->customerEmail); + } + + if (!isset($customer['tax_id']) + || !is_string($customer['tax_id']) + || !in_array(strlen($customer['tax_id']), [11, 14], true) + ) { + throw ValidationException::make('PagBank', $messages->customerTaxId); + } + } + + /** + * validate the items of the order. + * + * @param array $order + * @param object{customer: string, customerName: string, customerEmail: string, customerTaxId: string, items: string, itemName: string, itemQuantity: string, itemAmount: string, payment: string, singleQrCode: string} $messages + * @return void + * @throws ValidationException + */ + private static function validateItems(array $order, object $messages): void + { + if (!isset($order['items']) || !is_array($order['items']) || empty($order['items'])) { + throw ValidationException::make('PagBank', $messages->items); + } + + foreach ($order['items'] as $item) { + if (!is_array($item)) { + throw ValidationException::make('PagBank', $messages->items); + } + + if (!isset($item['name']) || !is_string($item['name']) || trim($item['name']) === '') { + throw ValidationException::make('PagBank', $messages->itemName); + } + + if (!isset($item['quantity']) || !is_int($item['quantity']) || $item['quantity'] < 1) { + throw ValidationException::make('PagBank', $messages->itemQuantity); + } + + if (!isset($item['unit_amount']) || !is_int($item['unit_amount']) || $item['unit_amount'] < 1) { + throw ValidationException::make('PagBank', $messages->itemAmount); + } + } + } + + /** + * messages for validation + * + * @return object{customer: string, customerName: string, customerEmail: string, customerTaxId: string, items: string, itemName: string, itemQuantity: string, itemAmount: string, payment: string, singleQrCode: string} + */ + public static function messages(): object + { + return (object) [ + 'customer' => 'O campo customer é obrigatório e deve ser um array. Use setCustomer().', + 'customerName' => 'O campo customer.name é obrigatório e deve ser uma string não vazia.', + 'customerEmail' => 'O campo customer.email é obrigatório e deve ser um e-mail válido.', + 'customerTaxId' => 'O campo customer.tax_id é obrigatório e deve ter 11 dígitos (CPF) ou 14 (CNPJ), somente números.', + 'items' => 'O pedido precisa de ao menos um item em items. Use setItems() ou addItem().', + 'itemName' => 'O campo items[].name é obrigatório e deve ser uma string não vazia.', + 'itemQuantity' => 'O campo items[].quantity é obrigatório e deve ser um inteiro maior que zero.', + 'itemAmount' => 'O campo items[].unit_amount é obrigatório e deve ser um inteiro em CENTAVOS maior que zero. O PagBank não aceita valor decimal: R$ 10,50 é 1050.', + 'payment' => 'O pedido precisa de charges (cartão ou boleto) ou de um qr_code (Pix). Use setCharges() ou setQrCode().', + 'singleQrCode' => 'O PagBank aceita apenas um QR Code por pedido.', + ]; + } +} diff --git a/src/Gateways/PagBank/Requests/PagBankSubscriptionRequest.php b/src/Gateways/PagBank/Requests/PagBankSubscriptionRequest.php new file mode 100644 index 0000000..dc28112 --- /dev/null +++ b/src/Gateways/PagBank/Requests/PagBankSubscriptionRequest.php @@ -0,0 +1,107 @@ + $subscription + * @return void + * @throws ValidationException + * @see https://developer.pagbank.com.br/reference/criar-assinatura + */ + public static function validate(array $subscription): void + { + $messages = self::messages(); + + $plan = $subscription['plan'] ?? null; + + if (!is_array($plan) || !isset($plan['id']) || !is_string($plan['id']) || $plan['id'] === '') { + throw ValidationException::make('PagBank', $messages->plan); + } + + $customer = $subscription['customer'] ?? null; + + if (!is_array($customer)) { + throw ValidationException::make('PagBank', $messages->customer); + } + + /* um assinante já criado entra só pelo id */ + if (isset($customer['id'])) { + if (!is_string($customer['id']) || $customer['id'] === '') { + throw ValidationException::make('PagBank', $messages->customerId); + } + + return; + } + + PagBankCustomerRequest::validate($customer); + } + + /** + * validate plan payload before sending it to the gateway. + * + * @param array $plan + * @return void + * @throws ValidationException + */ + public static function validatePlan(array $plan): void + { + $messages = self::messages(); + + if (!isset($plan['name']) || !is_string($plan['name']) || trim($plan['name']) === '') { + throw ValidationException::make('PagBank', $messages->planName); + } + + $amount = $plan['amount'] ?? null; + + if (!is_array($amount) + || !isset($amount['value']) + || !is_int($amount['value']) + || $amount['value'] < 1 + ) { + throw ValidationException::make('PagBank', $messages->planAmount); + } + + $interval = $plan['interval'] ?? null; + + if (!is_array($interval)) { + throw ValidationException::make('PagBank', $messages->planInterval); + } + + if (!isset($interval['unit']) + || !is_string($interval['unit']) + || !IntervalUnitEnum::tryFrom($interval['unit']) instanceof IntervalUnitEnum + ) { + throw ValidationException::make('PagBank', $messages->planIntervalUnit); + } + + if (!isset($interval['length']) || !is_int($interval['length']) || $interval['length'] < 1) { + throw ValidationException::make('PagBank', $messages->planIntervalLength); + } + } + + /** + * messages for validation + * + * @return object{plan: string, customer: string, customerId: string, planName: string, planAmount: string, planInterval: string, planIntervalUnit: string, planIntervalLength: string} + */ + public static function messages(): object + { + return (object) [ + 'plan' => 'A assinatura precisa de um plano. Use setPlan() com o id de um plano existente.', + 'customer' => 'A assinatura precisa de um assinante. Use setCustomerId() ou setCustomer().', + 'customerId' => 'O campo customer.id deve ser uma string não vazia.', + 'planName' => 'O campo name do plano é obrigatório e deve ser uma string não vazia.', + 'planAmount' => 'O campo amount.value do plano é obrigatório e deve ser um inteiro em CENTAVOS maior que zero. R$ 49,90 é 4990.', + 'planInterval' => 'O campo interval do plano é obrigatório e deve ser um array.', + 'planIntervalUnit' => 'O campo interval.unit do plano aceita apenas: DAYS, MONTHS, YEARS.', + 'planIntervalLength' => 'O campo interval.length do plano é obrigatório e deve ser um inteiro maior que zero.', + ]; + } +} diff --git a/src/Gateways/PagBank/Resources/Charge/Charge.php b/src/Gateways/PagBank/Resources/Charge/Charge.php new file mode 100644 index 0000000..14fd37b --- /dev/null +++ b/src/Gateways/PagBank/Resources/Charge/Charge.php @@ -0,0 +1,275 @@ + + */ + private array $order = []; + + /** + * construct + * + * @param string $token + * @param bool $sandbox + * @param Client|null $client injected http client, mainly for tests + */ + public function __construct( + private string $token, + private bool $sandbox = true, + ?Client $client = null, + ) { + $this->client = $client ?? $this->clientPagBankBoot(); + } + + /** + * set the whole order payload + * + * @param array $order + * @return ChargeInterface + */ + public function setOrder(array $order): ChargeInterface + { + $this->order = $order; + + return $this; + } + + /** + * set the customer of the order. + * + * on the Orders API the customer travels inside the order — it is not a + * resource of its own. The /customers CRUD belongs to subscriptions. + * + * @param array $customer + * @return ChargeInterface + */ + public function setCustomer(array $customer): ChargeInterface + { + $this->order['customer'] = $customer; + + return $this; + } + + /** + * set the items of the order + * + * @param array $items + * @return ChargeInterface + */ + public function setItems(array $items): ChargeInterface + { + $this->order['items'] = $items; + + return $this; + } + + /** + * append a single item to the order + * + * @param string $name + * @param int $unitAmount amount in cents + * @param int $quantity + * @return ChargeInterface + */ + public function addItem(string $name, int $unitAmount, int $quantity = 1): ChargeInterface + { + $items = $this->order['items'] ?? []; + + if (!is_array($items)) { + $items = []; + } + + $items[] = [ + 'reference_id' => uniqid('item_'), + 'name' => $name, + 'quantity' => $quantity, + 'unit_amount' => $unitAmount, + ]; + + $this->order['items'] = $items; + + return $this; + } + + /** + * set the charges of the order (card or boleto) + * + * @param array $charges + * @return ChargeInterface + */ + public function setCharges(array $charges): ChargeInterface + { + $this->order['charges'] = $charges; + + return $this; + } + + /** + * request a Pix QR Code for the order. + * + * Pix does not travel as a charge on PagBank: the order carries a qr_codes + * entry, and the copy-and-paste code comes back in qr_codes[0].text. Only + * one QR Code per order is supported, and the account needs an active Pix + * key. + * + * @param int $amount amount in cents + * @param string|null $expiresAt defaults to 23:59:59 of the next day + * @return ChargeInterface + */ + public function setQrCode(int $amount, ?string $expiresAt = null): ChargeInterface + { + $qrCode = ['amount' => ['value' => $amount]]; + + if ($expiresAt !== null) { + $qrCode['expiration_date'] = $expiresAt; + } + + $this->order['qr_codes'] = [$qrCode]; + + return $this; + } + + /** + * set the urls notified about order events. + * + * PagBank has no webhook CRUD — this is the per-order way to be notified. + * + * @param array $urls + * @return ChargeInterface + */ + public function setNotificationUrls(array $urls): ChargeInterface + { + $this->order['notification_urls'] = array_values($urls); + + return $this; + } + + /** + * create the order + * + * @return array + * @throws ValidationException|ApiException + * @see https://developer.pagbank.com.br/reference/criar-pedido-simples + */ + public function create(): array + { + $this->order['reference_id'] = $this->order['reference_id'] ?? uniqid('phpay_'); + + PagBankOrderRequest::validate($this->order); + + return $this->post('orders', $this->order); + } + + /** + * find order by id + * + * @param string $id + * @return array + * @throws ApiException + */ + public function find(string $id): array + { + return $this->get("orders/{$id}"); + } + + /** + * find charge by id + * + * @param string $id + * @return array + * @throws ApiException + */ + public function findCharge(string $id): array + { + return $this->get("charges/{$id}"); + } + + /** + * get the status of a charge + * + * @param string $id + * @return string|null + * @throws ApiException + */ + public function getStatus(string $id): ?string + { + $charge = $this->findCharge($id); + + return isset($charge['status']) && is_string($charge['status']) + ? $charge['status'] + : null; + } + + /** + * get the Pix copy-and-paste code of an order + * + * @param string $id + * @return string|null + * @throws ApiException + */ + public function getPixCode(string $id): ?string + { + $order = $this->find($id); + + $qrCodes = $order['qr_codes'] ?? null; + + if (!is_array($qrCodes) || empty($qrCodes)) { + return null; + } + + $first = reset($qrCodes); + + if (!is_array($first)) { + return null; + } + + $text = $first['text'] ?? null; + + return is_string($text) ? $text : null; + } + + /** + * refund a charge, fully or partially. + * + * undoes a pre-authorization or gives back a captured payment. PagBank + * accepts refunds for up to 350 days after authorization. + * + * @param string $id + * @param int|null $amount amount in cents; null refunds the full value + * @return array + * @throws ApiException + * @see https://developer.pagbank.com.br/reference/cancelar-pagamento + */ + public function refund(string $id, ?int $amount = null): array + { + return $this->post( + "charges/{$id}/cancel", + $amount === null ? [] : ['amount' => ['value' => $amount]] + ); + } +} diff --git a/src/Gateways/PagBank/Resources/Charge/Interface/ChargeInterface.php b/src/Gateways/PagBank/Resources/Charge/Interface/ChargeInterface.php new file mode 100644 index 0000000..580c5cb --- /dev/null +++ b/src/Gateways/PagBank/Resources/Charge/Interface/ChargeInterface.php @@ -0,0 +1,113 @@ + $order + * @return ChargeInterface + */ + public function setOrder(array $order): ChargeInterface; + + /** + * set the customer of the order + * + * @param array $customer + * @return ChargeInterface + */ + public function setCustomer(array $customer): ChargeInterface; + + /** + * set the items of the order + * + * @param array $items + * @return ChargeInterface + */ + public function setItems(array $items): ChargeInterface; + + /** + * append a single item to the order + * + * @param string $name + * @param int $unitAmount amount in cents + * @param int $quantity + * @return ChargeInterface + */ + public function addItem(string $name, int $unitAmount, int $quantity = 1): ChargeInterface; + + /** + * set the charges of the order (card or boleto) + * + * @param array $charges + * @return ChargeInterface + */ + public function setCharges(array $charges): ChargeInterface; + + /** + * request a Pix QR Code for the order + * + * @param int $amount amount in cents + * @param string|null $expiresAt + * @return ChargeInterface + */ + public function setQrCode(int $amount, ?string $expiresAt = null): ChargeInterface; + + /** + * set the urls notified about order events + * + * @param array $urls + * @return ChargeInterface + */ + public function setNotificationUrls(array $urls): ChargeInterface; + + /** + * create the order + * + * @return array + */ + public function create(): array; + + /** + * find order by id + * + * @param string $id + * @return array + */ + public function find(string $id): array; + + /** + * find charge by id + * + * @param string $id + * @return array + */ + public function findCharge(string $id): array; + + /** + * get the status of a charge + * + * @param string $id + * @return string|null + */ + public function getStatus(string $id): ?string; + + /** + * get the Pix copy-and-paste code of an order + * + * @param string $id + * @return string|null + */ + public function getPixCode(string $id): ?string; + + /** + * refund a charge, fully or partially + * + * @param string $id + * @param int|null $amount amount in cents + * @return array + */ + public function refund(string $id, ?int $amount = null): array; +} diff --git a/src/Gateways/PagBank/Resources/Customer/Customer.php b/src/Gateways/PagBank/Resources/Customer/Customer.php new file mode 100644 index 0000000..c0d421d --- /dev/null +++ b/src/Gateways/PagBank/Resources/Customer/Customer.php @@ -0,0 +1,112 @@ + + */ + private array $filter = []; + + /** + * construct + * + * @param string $token + * @param array $customer + * @param bool $sandbox + * @param Client|null $client injected http client, mainly for tests + */ + public function __construct( + private string $token, + private array $customer = [], + private bool $sandbox = true, + ?Client $client = null, + ) { + $this->client = $client ?? $this->clientPagBankSubscriptionsBoot(); + } + + /** + * create subscriber + * + * @return array + * @throws ValidationException|ApiException + */ + public function create(): array + { + PagBankCustomerRequest::validate($this->customer); + + return $this->post('customers', $this->customer); + } + + /** + * find subscriber by id + * + * @param string $id + * @return array + * @throws ApiException + */ + public function find(string $id): array + { + return $this->get("customers/{$id}"); + } + + /** + * update subscriber by id + * + * @param string $id + * @return array + * @throws ApiException + */ + public function update(string $id): array + { + return $this->put("customers/{$id}", $this->customer); + } + + /** + * list subscribers + * + * @return array + * @throws ApiException + */ + public function getAll(): array + { + return $this->get('customers', $this->filter); + } + + /** + * set list filter + * + * @param array $filter + * @return CustomerInterface + */ + public function setFilter(array $filter = []): CustomerInterface + { + $this->filter = $filter; + + return $this; + } +} diff --git a/src/Gateways/PagBank/Resources/Customer/Interface/CustomerInterface.php b/src/Gateways/PagBank/Resources/Customer/Interface/CustomerInterface.php new file mode 100644 index 0000000..b93fedd --- /dev/null +++ b/src/Gateways/PagBank/Resources/Customer/Interface/CustomerInterface.php @@ -0,0 +1,44 @@ + + */ + public function create(): array; + + /** + * find subscriber by id + * + * @param string $id + * @return array + */ + public function find(string $id): array; + + /** + * update subscriber by id + * + * @param string $id + * @return array + */ + public function update(string $id): array; + + /** + * list subscribers + * + * @return array + */ + public function getAll(): array; + + /** + * set list filter + * + * @param array $filter + * @return CustomerInterface + */ + public function setFilter(array $filter = []): CustomerInterface; +} diff --git a/src/Gateways/PagBank/Resources/Subscription/Interface/SubscriptionInterface.php b/src/Gateways/PagBank/Resources/Subscription/Interface/SubscriptionInterface.php new file mode 100644 index 0000000..456a011 --- /dev/null +++ b/src/Gateways/PagBank/Resources/Subscription/Interface/SubscriptionInterface.php @@ -0,0 +1,108 @@ + $customer + * @return SubscriptionInterface + */ + public function setCustomer(array $customer): SubscriptionInterface; + + /** + * set list filter + * + * @param array $filter + * @return SubscriptionInterface + */ + public function setFilter(array $filter = []): SubscriptionInterface; + + /** + * create subscription + * + * @param array $subscription + * @return array + */ + public function create(array $subscription = []): array; + + /** + * find subscription by id + * + * @param string $id + * @return array + */ + public function find(string $id): array; + + /** + * list subscriptions + * + * @return array + */ + public function getAll(): array; + + /** + * suspend subscription by id + * + * @param string $id + * @return array + */ + public function suspend(string $id): array; + + /** + * reactivate a suspended subscription + * + * @param string $id + * @return array + */ + public function activate(string $id): array; + + /** + * cancel subscription by id + * + * @param string $id + * @return array + */ + public function cancel(string $id): array; + + /** + * create a recurring plan + * + * @param array $plan + * @return array + */ + public function createPlan(array $plan): array; + + /** + * find plan by id + * + * @param string $id + * @return array + */ + public function findPlan(string $id): array; + + /** + * list plans + * + * @return array + */ + public function getAllPlans(): array; +} diff --git a/src/Gateways/PagBank/Resources/Subscription/Subscription.php b/src/Gateways/PagBank/Resources/Subscription/Subscription.php new file mode 100644 index 0000000..d720ca8 --- /dev/null +++ b/src/Gateways/PagBank/Resources/Subscription/Subscription.php @@ -0,0 +1,222 @@ + + */ + private array $subscription = []; + + /** + * @var array + */ + private array $filter = []; + + /** + * construct + * + * @param string $token + * @param bool $sandbox + * @param Client|null $client injected http client, mainly for tests + */ + public function __construct( + private string $token, + private bool $sandbox = true, + ?Client $client = null, + ) { + $this->client = $client ?? $this->clientPagBankSubscriptionsBoot(); + } + + /** + * attach an existing plan to the subscription + * + * @param string $planId + * @return SubscriptionInterface + */ + public function setPlan(string $planId): SubscriptionInterface + { + $this->subscription['plan'] = ['id' => $planId]; + + return $this; + } + + /** + * attach an existing subscriber to the subscription + * + * @param string $customerId + * @return SubscriptionInterface + */ + public function setCustomerId(string $customerId): SubscriptionInterface + { + $this->subscription['customer'] = ['id' => $customerId]; + + return $this; + } + + /** + * attach a subscriber, created along with the subscription. + * + * PagBank accepts creating the subscriber inline, so no extra call is + * needed — pass an array carrying `id` to reuse an existing one instead. + * + * @param array $customer + * @return SubscriptionInterface + */ + public function setCustomer(array $customer): SubscriptionInterface + { + $this->subscription['customer'] = $customer; + + return $this; + } + + /** + * set list filter + * + * @param array $filter + * @return SubscriptionInterface + */ + public function setFilter(array $filter = []): SubscriptionInterface + { + $this->filter = $filter; + + return $this; + } + + /** + * create subscription + * + * @param array $subscription merged over what the setters built + * @return array + * @throws ValidationException|ApiException + * @see https://developer.pagbank.com.br/reference/criar-assinatura + */ + public function create(array $subscription = []): array + { + $payload = array_merge($this->subscription, $subscription); + + PagBankSubscriptionRequest::validate($payload); + + return $this->post('subscriptions', $payload); + } + + /** + * find subscription by id + * + * @param string $id + * @return array + * @throws ApiException + */ + public function find(string $id): array + { + return $this->get("subscriptions/{$id}"); + } + + /** + * list subscriptions + * + * @return array + * @throws ApiException + */ + public function getAll(): array + { + return $this->get('subscriptions', $this->filter); + } + + /** + * suspend subscription by id + * + * @param string $id + * @return array + * @throws ApiException + */ + public function suspend(string $id): array + { + return $this->put("subscriptions/{$id}/suspend"); + } + + /** + * reactivate a suspended subscription + * + * @param string $id + * @return array + * @throws ApiException + */ + public function activate(string $id): array + { + return $this->put("subscriptions/{$id}/activate"); + } + + /** + * cancel subscription by id + * + * @param string $id + * @return array + * @throws ApiException + */ + public function cancel(string $id): array + { + return $this->put("subscriptions/{$id}/cancel"); + } + + /** + * create a recurring plan + * + * @param array $plan + * @return array + * @throws ValidationException|ApiException + */ + public function createPlan(array $plan): array + { + PagBankSubscriptionRequest::validatePlan($plan); + + return $this->post('plans', $plan); + } + + /** + * find plan by id + * + * @param string $id + * @return array + * @throws ApiException + */ + public function findPlan(string $id): array + { + return $this->get("plans/{$id}"); + } + + /** + * list plans + * + * @return array + * @throws ApiException + */ + public function getAllPlans(): array + { + return $this->get('plans', $this->filter); + } +} diff --git a/src/Gateways/PagBank/Traits/HasPagBankClient.php b/src/Gateways/PagBank/Traits/HasPagBankClient.php new file mode 100644 index 0000000..f9495d3 --- /dev/null +++ b/src/Gateways/PagBank/Traits/HasPagBankClient.php @@ -0,0 +1,93 @@ +bootClient($this->baseUri()); + } + + /** + * boot client for the subscriptions API + * + * @return Client + */ + protected function clientPagBankSubscriptionsBoot(): Client + { + return $this->bootClient($this->subscriptionsBaseUri()); + } + + /** + * base uri of the orders API + * + * @return string + */ + protected function baseUri(): string + { + return $this->sandbox + ? 'https://sandbox.api.pagseguro.com/' + : 'https://api.pagseguro.com/'; + } + + /** + * base uri of the subscriptions API + * + * @return string + */ + protected function subscriptionsBaseUri(): string + { + return $this->sandbox + ? 'https://sandbox.api.assinaturas.pagseguro.com/' + : 'https://api.assinaturas.pagseguro.com/'; + } + + /** + * gateway name used in exception messages. + * + * @return string + */ + protected function gatewayName(): string + { + return 'PagBank'; + } + + /** + * build a client for the given host + * + * @param string $baseUri + * @return Client + */ + private function bootClient(string $baseUri): Client + { + return new Client([ + 'base_uri' => $baseUri, + 'headers' => [ + 'content-type' => 'application/json', + 'accept' => 'application/json', + 'user-agent' => 'PHPay', + 'Authorization' => "Bearer {$this->token}", + ], + ]); + } +} diff --git a/tests/Pest.php b/tests/Pest.php index 30e4ff7..c1bf67a 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -74,3 +74,15 @@ function mpClient(array $responses, array &$history = []): Client { return mockClient($responses, $history, 'https://api.mercadopago.com/'); } + +/** + * mock client already pointed at the PagBank orders host. + * + * @param array $responses + * @param array $history filled with the recorded transactions + * @return Client + */ +function pagbankClient(array $responses, array &$history = []): Client +{ + return mockClient($responses, $history, 'https://sandbox.api.pagseguro.com/'); +} diff --git a/tests/Unit/PagBank/ChargeTest.php b/tests/Unit/PagBank/ChargeTest.php new file mode 100644 index 0000000..363992c --- /dev/null +++ b/tests/Unit/PagBank/ChargeTest.php @@ -0,0 +1,170 @@ + + */ +function pagbankCustomer(): array +{ + return [ + 'name' => 'Mário Lucas', + 'email' => 'fale@phpay.io', + 'tax_id' => '12345678901', + ]; +} + +it('pede o pix como qr_code do pedido, não como charge', function () { + $history = []; + $client = pagbankClient([jsonResponse(['id' => 'ORDE_1'])], $history); + + (new Charge('token', true, $client)) + ->setCustomer(pagbankCustomer()) + ->addItem('Assinatura PHPay', 10050) + ->setQrCode(10050) + ->create(); + + $body = recordedBody($history); + + expect((string) $history[0]['request']->getUri())->toEndWith('/orders') + ->and($body)->toHaveKey('qr_codes') + ->and($body)->not->toHaveKey('charges') + ->and($body['qr_codes'])->toHaveCount(1) + ->and($body['qr_codes'][0]['amount']['value'])->toBe(10050); +})->group('pagbank'); + +it('aceita data de expiração no qr code', function () { + $history = []; + $client = pagbankClient([jsonResponse(['id' => 'ORDE_1'])], $history); + + (new Charge('token', true, $client)) + ->setCustomer(pagbankCustomer()) + ->addItem('Item', 100) + ->setQrCode(100, '2026-12-31T23:59:59-03:00') + ->create(); + + expect(recordedBody($history)['qr_codes'][0]['expiration_date'])->toBe('2026-12-31T23:59:59-03:00'); +})->group('pagbank'); + +it('extrai o copia-e-cola de qr_codes[0].text', function () { + $client = pagbankClient([jsonResponse([ + 'id' => 'ORDE_1', + 'qr_codes' => [['id' => 'QRCO_1', 'text' => '00020101021226...']], + ])]); + + expect((new Charge('token', true, $client))->getPixCode('ORDE_1'))->toBe('00020101021226...'); +})->group('pagbank'); + +it('devolve null quando o pedido não tem qr code', function () { + $client = pagbankClient([jsonResponse(['id' => 'ORDE_1', 'charges' => []])]); + + expect((new Charge('token', true, $client))->getPixCode('ORDE_1'))->toBeNull(); +})->group('pagbank'); + +it('monta um pedido com cobrança de cartão', function () { + $history = []; + $client = pagbankClient([jsonResponse(['id' => 'ORDE_1'])], $history); + + (new Charge('token', true, $client)) + ->setCustomer(pagbankCustomer()) + ->addItem('Camiseta', 5990, 2) + ->setCharges([[ + 'reference_id' => 'cobranca-1', + 'description' => 'Camiseta', + 'amount' => ['value' => 11980, 'currency' => 'BRL'], + 'payment_method' => [ + 'type' => PaymentMethodEnum::CREDIT_CARD->value, + 'installments' => 1, + 'capture' => true, + ], + ]]) + ->setNotificationUrls(['https://exemplo.test/webhook/pagbank']) + ->create(); + + $body = recordedBody($history); + + expect($body['items'][0]['unit_amount'])->toBe(5990) + ->and($body['items'][0]['quantity'])->toBe(2) + ->and($body['charges'][0]['payment_method']['type'])->toBe('CREDIT_CARD') + ->and($body['notification_urls'])->toBe(['https://exemplo.test/webhook/pagbank']) + ->and($body)->toHaveKey('reference_id'); +})->group('pagbank'); + +it('estorna total ou parcialmente em centavos', function () { + $history = []; + $client = pagbankClient([jsonResponse(['id' => 1]), jsonResponse(['id' => 2])], $history); + + $charge = new Charge('token', true, $client); + $charge->refund('CHAR_1'); + $charge->refund('CHAR_1', 2500); + + expect((string) $history[0]['request']->getUri())->toEndWith('/charges/CHAR_1/cancel') + ->and(recordedBody($history, 0))->toBe([]) + ->and(recordedBody($history, 1))->toBe(['amount' => ['value' => 2500]]); +})->group('pagbank'); + +it('consulta a cobrança e o status no endpoint de charges', function () { + $history = []; + $client = pagbankClient([jsonResponse(['id' => 'CHAR_1', 'status' => 'PAID'])], $history); + + expect((new Charge('token', true, $client))->getStatus('CHAR_1'))->toBe('PAID') + ->and((string) $history[0]['request']->getUri())->toEndWith('/charges/CHAR_1'); +})->group('pagbank'); + +it('valida o pedido antes de chamar a API', function (callable $montar, string $esperado) { + $history = []; + $client = pagbankClient([jsonResponse([])], $history); + + expect(fn () => $montar(new Charge('token', true, $client))->create()) + ->toThrow(ValidationException::class, $esperado); + + expect($history)->toBeEmpty(); +})->with([ + 'sem cliente' => [ + fn (Charge $c) => $c->addItem('Item', 100)->setQrCode(100), + 'O campo customer é obrigatório', + ], + 'cpf inválido' => [ + fn (Charge $c) => $c->setCustomer(['name' => 'X', 'email' => 'a@b.com', 'tax_id' => '123']) + ->addItem('Item', 100)->setQrCode(100), + 'tax_id', + ], + 'sem itens' => [ + fn (Charge $c) => $c->setCustomer(pagbankCustomer())->setQrCode(100), + 'ao menos um item', + ], + 'sem forma de pagamento' => [ + fn (Charge $c) => $c->setCustomer(pagbankCustomer())->addItem('Item', 100), + 'charges (cartão ou boleto) ou de um qr_code', + ], +])->group('pagbank'); + +it('recusa valor decimal, que o pagbank cobraria errado', function () { + $history = []; + $client = pagbankClient([jsonResponse([])], $history); + + /* R$ 10,50 tem que ser 1050 — mandar 10.50 cobraria onze centavos */ + expect(fn () => (new Charge('token', true, $client)) + ->setCustomer(pagbankCustomer()) + ->setItems([['name' => 'Item', 'quantity' => 1, 'unit_amount' => 10.50]]) + ->setQrCode(1050) + ->create()) + ->toThrow(ValidationException::class, 'CENTAVOS'); + + expect($history)->toBeEmpty(); +})->group('pagbank'); + +it('recusa mais de um qr code por pedido', function () { + $client = pagbankClient([jsonResponse([])]); + + expect(fn () => (new Charge('token', true, $client)) + ->setOrder([ + 'customer' => pagbankCustomer(), + 'items' => [['name' => 'Item', 'quantity' => 1, 'unit_amount' => 100]], + 'qr_codes' => [['amount' => ['value' => 100]], ['amount' => ['value' => 200]]], + ]) + ->create()) + ->toThrow(ValidationException::class, 'apenas um QR Code'); +})->group('pagbank'); diff --git a/tests/Unit/PagBank/PagBankGatewayTest.php b/tests/Unit/PagBank/PagBankGatewayTest.php new file mode 100644 index 0000000..8de2e96 --- /dev/null +++ b/tests/Unit/PagBank/PagBankGatewayTest.php @@ -0,0 +1,61 @@ +toBe([ + Capability::CUSTOMERS, + Capability::CHARGES, + Capability::SUBSCRIPTIONS, + ]); +})->group('pagbank'); + +it('não declara webhooks nem chaves pix', function (Capability $capability) { + $phpay = PHPay::gateway(new PagBankGateway('token', true, pagbankClient([]))); + + expect($phpay->supports($capability))->toBeFalse(); + + expect(fn () => $capability === Capability::WEBHOOKS ? $phpay->webhook() : $phpay->pix()) + ->toThrow(NotImplementedException::class, 'PagBank não suporta'); +})->with([Capability::WEBHOOKS, Capability::PIX_KEYS])->group('pagbank'); + +it('devolve a instância de cada recurso suportado', function () { + $phpay = PHPay::gateway(new PagBankGateway('token', true, pagbankClient([]))); + + expect($phpay->customer([]))->toBeInstanceOf(Customer::class) + ->and($phpay->charge())->toBeInstanceOf(Charge::class) + ->and($phpay->subscription())->toBeInstanceOf(Subscription::class); +})->group('pagbank'); + +it('aponta cada recurso para o host da sua api', function () { + $baseUri = function (object $resource): string { + $property = new ReflectionProperty($resource, 'client'); + + return (string) $property->getValue($resource)->getConfig('base_uri'); + }; + + $sandbox = new PagBankGateway('token'); + $producao = new PagBankGateway('token', false); + + expect($baseUri($sandbox->charge()))->toBe('https://sandbox.api.pagseguro.com/') + ->and($baseUri($sandbox->customer()))->toBe('https://sandbox.api.assinaturas.pagseguro.com/') + ->and($baseUri($sandbox->subscription()))->toBe('https://sandbox.api.assinaturas.pagseguro.com/') + ->and($baseUri($producao->charge()))->toBe('https://api.pagseguro.com/') + ->and($baseUri($producao->subscription()))->toBe('https://api.assinaturas.pagseguro.com/'); +})->group('pagbank'); + +it('não faz chamada de rede ao instanciar o gateway', function () { + $history = []; + + new PagBankGateway('token', true, pagbankClient([], $history)); + + expect($history)->toBeEmpty(); +})->group('pagbank'); diff --git a/tests/Unit/PagBank/SubscriptionTest.php b/tests/Unit/PagBank/SubscriptionTest.php new file mode 100644 index 0000000..9941a6c --- /dev/null +++ b/tests/Unit/PagBank/SubscriptionTest.php @@ -0,0 +1,137 @@ + $responses + * @param array $history + * @return GuzzleHttp\Client + */ +function assinaturasClient(array $responses, array &$history = []): GuzzleHttp\Client +{ + return mockClient($responses, $history, 'https://sandbox.api.assinaturas.pagseguro.com/'); +} + +it('cria um plano com valor em centavos', function () { + $history = []; + $client = assinaturasClient([jsonResponse(['id' => 'PLAN_1'])], $history); + + (new Subscription('token', true, $client))->createPlan([ + 'name' => 'Plano PHPay', + 'amount' => ['value' => 4990, 'currency' => 'BRL'], + 'interval' => ['unit' => IntervalUnitEnum::MONTHS->value, 'length' => 1], + ]); + + expect((string) $history[0]['request']->getUri())->toEndWith('/plans') + ->and(recordedBody($history)['amount']['value'])->toBe(4990); +})->group('pagbank'); + +it('cria a assinatura com plano e assinante existente', function () { + $history = []; + $client = assinaturasClient([jsonResponse(['id' => 'SUBS_1'])], $history); + + (new Subscription('token', true, $client)) + ->setPlan('PLAN_1') + ->setCustomerId('CUST_1') + ->create(); + + $body = recordedBody($history); + + expect((string) $history[0]['request']->getUri())->toEndWith('/subscriptions') + ->and($body['plan'])->toBe(['id' => 'PLAN_1']) + ->and($body['customer'])->toBe(['id' => 'CUST_1']); +})->group('pagbank'); + +it('cria o assinante junto com a assinatura', function () { + $history = []; + $client = assinaturasClient([jsonResponse(['id' => 'SUBS_1'])], $history); + + (new Subscription('token', true, $client)) + ->setPlan('PLAN_1') + ->setCustomer([ + 'name' => 'Mário Lucas', + 'email' => 'fale@phpay.io', + 'tax_id' => '12345678901', + ]) + ->create(); + + /* uma única requisição: o pagbank aceita o assinante embutido */ + expect($history)->toHaveCount(1) + ->and(recordedBody($history)['customer']['email'])->toBe('fale@phpay.io'); +})->group('pagbank'); + +it('suspende, reativa e cancela pela rota de cada ação', function () { + $history = []; + $client = assinaturasClient([ + jsonResponse(['id' => 1]), jsonResponse(['id' => 1]), jsonResponse(['id' => 1]), + ], $history); + + $subscription = new Subscription('token', true, $client); + $subscription->suspend('SUBS_1'); + $subscription->activate('SUBS_1'); + $subscription->cancel('SUBS_1'); + + expect($history[0]['request']->getMethod())->toBe('PUT') + ->and((string) $history[0]['request']->getUri())->toEndWith('/subscriptions/SUBS_1/suspend') + ->and((string) $history[1]['request']->getUri())->toEndWith('/subscriptions/SUBS_1/activate') + ->and((string) $history[2]['request']->getUri())->toEndWith('/subscriptions/SUBS_1/cancel'); +})->group('pagbank'); + +it('valida plano e assinatura antes de chamar a API', function () { + $history = []; + $client = assinaturasClient([jsonResponse([])], $history); + + $subscription = new Subscription('token', true, $client); + + expect(fn () => $subscription->create()) + ->toThrow(ValidationException::class, 'precisa de um plano'); + + expect(fn () => (new Subscription('token', true, $client))->setPlan('PLAN_1')->create()) + ->toThrow(ValidationException::class, 'precisa de um assinante'); + + expect(fn () => $subscription->createPlan([ + 'name' => 'Plano', + 'amount' => ['value' => 49.90], + 'interval' => ['unit' => 'MONTHS', 'length' => 1], + ]))->toThrow(ValidationException::class, 'CENTAVOS'); + + expect(fn () => $subscription->createPlan([ + 'name' => 'Plano', + 'amount' => ['value' => 4990], + 'interval' => ['unit' => 'WEEKS', 'length' => 1], + ]))->toThrow(ValidationException::class, 'DAYS, MONTHS, YEARS'); + + expect($history)->toBeEmpty(); +})->group('pagbank'); + +it('cria e lista assinantes no host de assinaturas', function () { + $history = []; + $client = assinaturasClient([jsonResponse(['id' => 'CUST_1']), jsonResponse(['data' => []])], $history); + + (new Customer('token', [ + 'name' => 'Mário Lucas', + 'email' => 'fale@phpay.io', + 'tax_id' => '12345678901', + ], true, $client))->create(); + + (new Customer('token', [], true, $client))->setFilter(['offset' => 0, 'limit' => 10])->getAll(); + + expect((string) $history[0]['request']->getUri())->toEndWith('/customers') + ->and((string) $history[1]['request']->getUri())->toContain('limit=10'); +})->group('pagbank'); + +it('exige e-mail e documento válidos do assinante', function () { + $history = []; + $client = assinaturasClient([jsonResponse([])], $history); + + expect(fn () => (new Customer('token', ['name' => 'X', 'email' => 'nao-e-email', 'tax_id' => '12345678901'], true, $client))->create()) + ->toThrow(ValidationException::class, 'e-mail válido'); + + expect(fn () => (new Customer('token', ['name' => 'X', 'email' => 'a@b.com', 'tax_id' => '123'], true, $client))->create()) + ->toThrow(ValidationException::class, 'tax_id'); + + expect($history)->toBeEmpty(); +})->group('pagbank');