From bc26436bc8dcfb3be08d54a51e1d62be8e23ebeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rio=20Lucas?= Date: Sun, 20 Sep 2026 21:38:39 -0300 Subject: [PATCH] PHPAY-78: feat(pagarme): adicionar o gateway Pagar.me (Core API v5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quinto gateway da biblioteca. Declara três das cinco capacidades: SupportsCustomers /customers (CRUD completo, com cartões salvos) SupportsCharges /orders, /charges SupportsSubscriptions /plans, /subscriptions Correção de uma avaliação anterior: o Pagar.me NÃO encaixa nas cinco. Eu tinha afirmado que sim por causa do endpoint /hooks, mas ele lista as entregas de webhook já despachadas — o cadastro dos endpoints que as recebem é feito no dashboard. Não é a capacidade SupportsWebhooks, que nasceu do CRUD de endpoints do Asaas. Particularidades: - Autenticação Basic, com a secret key como usuário e senha vazia, diferente do Bearer dos outros gateways. - Ambiente pelo prefixo da chave (sk_test_), não por host: teste e produção compartilham api.pagar.me/core/v5. Mesmo modelo do Mercado Pago, então o construtor não recebe $sandbox. - Valores em centavos inteiros, como no PagBank. - Pix é payments[].payment_method com um objeto pix: {expires_in}; o copia-e-cola volta em charges[0].last_transaction.qr_code. - Cancelamento é DELETE /charges/{id}, com o valor no corpo para estorno parcial — o delete() do trait não manda corpo, então usa request(). O /hooks vira o recurso webhookDeliveries(), exposto só no gateway concreto e fora do modelo de capacidades. É o caminho que o modelo abre para o que um gateway oferece sozinho: quem segura PagarMeGateway alcança, quem tipa uma capacidade não. Um teste garante que a facade não ganhou esse método. Cliente e assinatura aceitam o recurso embutido ou por id — passar um array com `id` troca customer por customer_id, para não criar cadastro duplicado. 151 testes no total. --- .gitignore | 1 + CLAUDE.md | 18 +- README.md | 107 +++++- composer.json | 3 +- examples/pagarme/charges.php | 73 ++++ examples/pagarme/credentials.example.php | 15 + examples/pagarme/subscriptions.php | 70 ++++ .../PagarMe/Enums/CustomerTypeEnum.php | 9 + src/Gateways/PagarMe/Enums/IntervalEnum.php | 11 + .../PagarMe/Enums/PaymentMethodEnum.php | 11 + .../Interface/PagarMeGatewayInterface.php | 64 ++++ src/Gateways/PagarMe/PagarMeGateway.php | 94 +++++ .../Requests/PagarMeCustomerRequest.php | 61 +++ .../PagarMe/Requests/PagarMeOrderRequest.php | 124 +++++++ .../Requests/PagarMeSubscriptionRequest.php | 135 +++++++ .../PagarMe/Resources/Charge/Charge.php | 348 ++++++++++++++++++ .../Charge/Interface/ChargeInterface.php | 145 ++++++++ .../PagarMe/Resources/Customer/Customer.php | 121 ++++++ .../Customer/Interface/CustomerInterface.php | 52 +++ .../Interface/SubscriptionInterface.php | 100 +++++ .../Resources/Subscription/Subscription.php | 211 +++++++++++ .../Interface/WebhookDeliveryInterface.php | 37 ++ .../WebhookDelivery/WebhookDelivery.php | 97 +++++ .../PagarMe/Traits/HasPagarMeClient.php | 56 +++ tests/Pest.php | 12 + tests/Unit/PagarMe/ChargeTest.php | 180 +++++++++ tests/Unit/PagarMe/PagarMeGatewayTest.php | 74 ++++ tests/Unit/PagarMe/SubscriptionTest.php | 136 +++++++ 28 files changed, 2348 insertions(+), 17 deletions(-) create mode 100644 examples/pagarme/charges.php create mode 100644 examples/pagarme/credentials.example.php create mode 100644 examples/pagarme/subscriptions.php create mode 100644 src/Gateways/PagarMe/Enums/CustomerTypeEnum.php create mode 100644 src/Gateways/PagarMe/Enums/IntervalEnum.php create mode 100644 src/Gateways/PagarMe/Enums/PaymentMethodEnum.php create mode 100644 src/Gateways/PagarMe/Interface/PagarMeGatewayInterface.php create mode 100644 src/Gateways/PagarMe/PagarMeGateway.php create mode 100644 src/Gateways/PagarMe/Requests/PagarMeCustomerRequest.php create mode 100644 src/Gateways/PagarMe/Requests/PagarMeOrderRequest.php create mode 100644 src/Gateways/PagarMe/Requests/PagarMeSubscriptionRequest.php create mode 100644 src/Gateways/PagarMe/Resources/Charge/Charge.php create mode 100644 src/Gateways/PagarMe/Resources/Charge/Interface/ChargeInterface.php create mode 100644 src/Gateways/PagarMe/Resources/Customer/Customer.php create mode 100644 src/Gateways/PagarMe/Resources/Customer/Interface/CustomerInterface.php create mode 100644 src/Gateways/PagarMe/Resources/Subscription/Interface/SubscriptionInterface.php create mode 100644 src/Gateways/PagarMe/Resources/Subscription/Subscription.php create mode 100644 src/Gateways/PagarMe/Resources/WebhookDelivery/Interface/WebhookDeliveryInterface.php create mode 100644 src/Gateways/PagarMe/Resources/WebhookDelivery/WebhookDelivery.php create mode 100644 src/Gateways/PagarMe/Traits/HasPagarMeClient.php create mode 100644 tests/Unit/PagarMe/ChargeTest.php create mode 100644 tests/Unit/PagarMe/PagarMeGatewayTest.php create mode 100644 tests/Unit/PagarMe/SubscriptionTest.php diff --git a/.gitignore b/.gitignore index fbac00b..81af05d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ examples/asaas/credentials.php examples/efi/credentials.php examples/mercadopago/credentials.php examples/pagbank/credentials.php +examples/pagarme/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 647274c..b17ef77 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,8 +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** e **PagBank** (clientes, cobranças, assinaturas) e -**Efí** (cobranças). +capacidades), **Mercado Pago**, **PagBank** e **Pagar.me** (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`, @@ -144,6 +144,12 @@ e rode `php examples/asaas/charges.php` (ou `make asaas resource=charges`). 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`. +- **Pagar.me** — autenticação **Basic** (secret key como usuário, senha vazia), não + Bearer. Ambiente pelo prefixo `sk_test_`, host único, então sem `$sandbox`. Valores + em centavos. Cancelamento é `DELETE /charges/{id}` com valor opcional no corpo — + use `request('DELETE', ...)`, porque `delete()` do trait não manda corpo. + `webhookDeliveries()` é **extra do gateway concreto**, não capacidade: `/hooks` lê + entregas, não cadastra endpoints. - **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). @@ -155,9 +161,11 @@ 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`. -- 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. +- Só o Asaas implementa `SupportsWebhooks` e `SupportsPixKeys`. Mercado Pago, PagBank e + Pagar.me registram endpoints por painel, e Pix neles é forma de pagamento. Não + "resolva" isso criando stubs — e não declare a capacidade por causa de uma API + parecida: o `/hooks` do Pagar.me lê entregas, é outra coisa, e por isso virou um + recurso fora do modelo. - `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 21c3330..4554996 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,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) +- Pagar.me (cobranças, clientes e assinaturas) - Efí (cobranças) ## ⬆️ Vindo da v1? @@ -165,17 +166,22 @@ $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 | PagBank | Efí | -| --- | --- | :---: | :---: | :---: | :---: | -| Clientes | `SupportsCustomers` | ✅ | ✅ | ✅ | — | -| Cobranças | `SupportsCharges` | ✅ | ✅ | ✅ | ✅ | -| Webhooks | `SupportsWebhooks` | ✅ | — | — | — | -| Chaves Pix | `SupportsPixKeys` | ✅ | — | — | — | -| Assinaturas | `SupportsSubscriptions` | ✅ | ✅ | ✅ | — | - -> 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`. +| Capacidade | Interface | Asaas | Mercado Pago | PagBank | Pagar.me | Efí | +| --- | --- | :---: | :---: | :---: | :---: | :---: | +| Clientes | `SupportsCustomers` | ✅ | ✅ | ✅ | ✅ | — | +| Cobranças | `SupportsCharges` | ✅ | ✅ | ✅ | ✅ | ✅ | +| Webhooks | `SupportsWebhooks` | ✅ | — | — | — | — | +| Chaves Pix | `SupportsPixKeys` | ✅ | — | — | — | — | +| Assinaturas | `SupportsSubscriptions` | ✅ | ✅ | ✅ | ✅ | — | + +> Só o Asaas expõe CRUD de webhooks por API. Nos outros, os endpoints são +> registrados no painel — a notificação vai por cobrança +> (`notification_url` / `notification_urls`), e o Pagar.me ainda deixa +> **consultar e reenviar entregas** via `webhookDeliveries()`, fora do modelo +> de capacidades. +> +> `SupportsPixKeys` significa gerenciar chaves e QR Code estático, o que só um +> PSP que emite chave própria oferece. Nos demais, Pix é forma de pagamento. > `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. @@ -379,6 +385,77 @@ Para conferir contra o sandbox de verdade: PAGBANK_TOKEN='...' php examples/pagbank/sandbox-check.php ``` +## 💠 Pagar.me + +Autenticação Basic com a secret key, e ambiente pelo prefixo da chave — teste e +produção compartilham `api.pagar.me/core/v5`: + +```php +use PHPay\PagarMe\PagarMeGateway; + +$gateway = new PagarMeGateway(SECRET_KEY_PAGARME); + +$gateway->isSandbox(); // true para chaves sk_test_ +``` + +Valores em **centavos inteiros**, e Pix como forma de pagamento do pedido: + +```php +$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($customerId) + ->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. + ## 📝 Roadmap - Definições de Arquitetura ✅ @@ -414,6 +491,14 @@ PAGBANK_TOKEN='...' php examples/pagbank/sandbox-check.php - Webhook — sem CRUD por API - Pix ✅ (como QR Code do pedido) + - Pagar.me. + + - Cobranças ✅ + - Clientes ✅ (com cartões salvos) + - Assinaturas ✅ (com ou sem plano) + - Webhook — leitura de entregas ✅, cadastro só no dashboard + - Pix ✅ (como forma de pagamento) + - Efí. - Autorização ✅ diff --git a/composer.json b/composer.json index 6b49196..39f6d25 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,8 @@ "PHPay\\Asaas\\": "src/Gateways/Asaas/", "PHPay\\Efi\\": "src/Gateways/Efi/", "PHPay\\MercadoPago\\": "src/Gateways/MercadoPago/", - "PHPay\\PagBank\\": "src/Gateways/PagBank/" + "PHPay\\PagBank\\": "src/Gateways/PagBank/", + "PHPay\\PagarMe\\": "src/Gateways/PagarMe/" } }, "autoload-dev": { diff --git a/examples/pagarme/charges.php b/examples/pagarme/charges.php new file mode 100644 index 0000000..4f79ce5 --- /dev/null +++ b/examples/pagarme/charges.php @@ -0,0 +1,73 @@ +isSandbox()); + +/** + * @var Charge $phpay + */ +$phpay = PHPay::gateway($gateway)->charge(); + +$customer = [ + 'name' => NAME, + 'email' => EMAIL, + 'document' => DOCUMENT, + 'type' => 'individual', +]; + +try { + /* + | Pix é forma de pagamento do pedido. Todo valor é inteiro em CENTAVOS: + | R$ 100,50 é 10050. + */ + $pedido = $phpay + ->setCustomer($customer) + ->addItem('Assinatura PHPay', 10050) + ->setPix(1800) + ->create(); + + $pedidoId = (string) $pedido['id']; + + /* copia-e-cola, que vem em charges[0].last_transaction.qr_code */ + echo $phpay->getPixCode($pedidoId) . PHP_EOL; + + $phpay->find($pedidoId); + $phpay->setQueryParams(['size' => 10])->getAll(); + + $cobrancaId = (string) $pedido['charges'][0]['id']; + + echo $phpay->getStatus($cobrancaId) . PHP_EOL; + + /* estorno parcial e total, em centavos, via DELETE */ + $phpay->cancel($cobrancaId, 2500); + $phpay->cancel($cobrancaId); + + /* boleto, reaproveitando um cliente que já existe */ + PHPay::gateway($gateway)->charge() + ->setCustomerId((string) $pedido['customer']['id']) + ->addItem('Camiseta', 5990, 2) + ->setBoleto(date('Y-m-d', strtotime('+5 days')), ['Não receber após o vencimento']) + ->create(); + + /* + | Leitura de entregas de webhook. Não passa pela facade: é específico do + | Pagar.me, então vive no gateway concreto. + | + | O cadastro dos endpoints que recebem esses eventos é feito no dashboard, + | não pela API — por isso o gateway não declara SupportsWebhooks. + */ + $gateway->webhookDeliveries()->setFilter(['size' => 10])->getAll(); +} catch (PHPayException $exception) { + echo $exception->getMessage() . PHP_EOL; +} diff --git a/examples/pagarme/credentials.example.php b/examples/pagarme/credentials.example.php new file mode 100644 index 0000000..6822a34 --- /dev/null +++ b/examples/pagarme/credentials.example.php @@ -0,0 +1,15 @@ +subscription(); + +try { + /* preço do plano em CENTAVOS */ + $plano = $phpay->createPlan([ + 'name' => 'Plano PHPay Mensal', + 'interval' => IntervalEnum::MONTH->value, + 'interval_count' => 1, + 'payment_methods' => [PaymentMethodEnum::CREDIT_CARD->value, PaymentMethodEnum::PIX->value], + 'items' => [[ + 'name' => 'Mensalidade', + 'quantity' => 1, + 'pricing_scheme' => ['price' => 4990], + ]], + ]); + + $planoId = (string) $plano['id']; + + /* o cliente pode nascer junto com a assinatura */ + $assinatura = $phpay + ->setPlan($planoId) + ->setCustomer([ + 'name' => NAME, + 'email' => EMAIL, + 'document' => DOCUMENT, + 'type' => 'individual', + ]) + ->create(['payment_method' => PaymentMethodEnum::PIX->value]); + + $assinaturaId = (string) $assinatura['id']; + + $phpay->find($assinaturaId); + $phpay->setFilter(['size' => 10])->getAll(); + + /* assinatura sem plano: a recorrência vai no próprio payload */ + PHPay::gateway($gateway)->subscription() + ->setCustomerId((string) $assinatura['customer']['id']) + ->create([ + 'payment_method' => PaymentMethodEnum::PIX->value, + 'interval' => IntervalEnum::MONTH->value, + 'interval_count' => 1, + 'items' => [[ + 'name' => 'Avulso mensal', + 'quantity' => 1, + 'pricing_scheme' => ['price' => 2990], + ]], + ]); + + $phpay->cancel($assinaturaId); + $phpay->destroyPlan($planoId); +} catch (PHPayException $exception) { + echo $exception->getMessage() . PHP_EOL; +} diff --git a/src/Gateways/PagarMe/Enums/CustomerTypeEnum.php b/src/Gateways/PagarMe/Enums/CustomerTypeEnum.php new file mode 100644 index 0000000..a26927f --- /dev/null +++ b/src/Gateways/PagarMe/Enums/CustomerTypeEnum.php @@ -0,0 +1,9 @@ + $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; + + /** + * read the webhook events already delivered by the gateway. + * + * gateway specific: not part of any capability, so it is reachable only + * from the concrete gateway, never through the PHPay facade. + * + * @return WebhookDelivery + */ + public function webhookDeliveries(): WebhookDelivery; + + /** + * whether the credential in use is a test credential. + * + * @return bool + */ + public function isSandbox(): bool; +} diff --git a/src/Gateways/PagarMe/PagarMeGateway.php b/src/Gateways/PagarMe/PagarMeGateway.php new file mode 100644 index 0000000..97f611d --- /dev/null +++ b/src/Gateways/PagarMe/PagarMeGateway.php @@ -0,0 +1,94 @@ +secretKey, self::TEST_KEY_PREFIX); + } + + /** + * customer + * + * @param array $customer + * @return Customer + */ + public function customer(array $customer = []): Customer + { + return new Customer($this->secretKey, $customer, $this->client); + } + + /** + * charge + * + * @return Charge + */ + public function charge(): Charge + { + return new Charge($this->secretKey, $this->client); + } + + /** + * subscription + * + * @return Subscription + */ + public function subscription(): Subscription + { + return new Subscription($this->secretKey, $this->client); + } + + /** + * read the webhook events already delivered by the gateway. + * + * @return WebhookDelivery + */ + public function webhookDeliveries(): WebhookDelivery + { + return new WebhookDelivery($this->secretKey, $this->client); + } +} diff --git a/src/Gateways/PagarMe/Requests/PagarMeCustomerRequest.php b/src/Gateways/PagarMe/Requests/PagarMeCustomerRequest.php new file mode 100644 index 0000000..abbfe22 --- /dev/null +++ b/src/Gateways/PagarMe/Requests/PagarMeCustomerRequest.php @@ -0,0 +1,61 @@ + $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('Pagar.me', $messages->name); + } + + if (!isset($customer['email']) + || !is_string($customer['email']) + || filter_var($customer['email'], FILTER_VALIDATE_EMAIL) === false + ) { + throw ValidationException::make('Pagar.me', $messages->email); + } + + if (!isset($customer['document']) + || !is_string($customer['document']) + || !in_array(strlen($customer['document']), [11, 14], true) + ) { + throw ValidationException::make('Pagar.me', $messages->document); + } + + if (isset($customer['type']) + && (!is_string($customer['type']) + || !CustomerTypeEnum::tryFrom($customer['type']) instanceof CustomerTypeEnum) + ) { + throw ValidationException::make('Pagar.me', $messages->type); + } + } + + /** + * messages for validation + * + * @return object{name: string, email: string, document: string, type: 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.', + 'document' => 'O campo document é obrigatório e deve ter 11 dígitos (CPF) ou 14 (CNPJ), somente números.', + 'type' => 'O campo type aceita apenas: individual, company.', + ]; + } +} diff --git a/src/Gateways/PagarMe/Requests/PagarMeOrderRequest.php b/src/Gateways/PagarMe/Requests/PagarMeOrderRequest.php new file mode 100644 index 0000000..0593032 --- /dev/null +++ b/src/Gateways/PagarMe/Requests/PagarMeOrderRequest.php @@ -0,0 +1,124 @@ + $order + * @return void + * @throws ValidationException + * @see https://docs.pagar.me/reference/criar-pedido-2 + */ + public static function validate(array $order): void + { + $messages = self::messages(); + + self::validateItems($order, $messages); + self::validateCustomer($order, $messages); + self::validatePayments($order, $messages); + } + + /** + * @param array $order + * @param object{items: string, itemDescription: string, itemQuantity: string, itemAmount: string, customer: string, payments: string, paymentMethod: 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('Pagar.me', $messages->items); + } + + foreach ($order['items'] as $item) { + if (!is_array($item)) { + throw ValidationException::make('Pagar.me', $messages->items); + } + + if (!isset($item['description']) + || !is_string($item['description']) + || trim($item['description']) === '' + ) { + throw ValidationException::make('Pagar.me', $messages->itemDescription); + } + + if (!isset($item['quantity']) || !is_int($item['quantity']) || $item['quantity'] < 1) { + throw ValidationException::make('Pagar.me', $messages->itemQuantity); + } + + if (!isset($item['amount']) || !is_int($item['amount']) || $item['amount'] < 1) { + throw ValidationException::make('Pagar.me', $messages->itemAmount); + } + } + } + + /** + * @param array $order + * @param object{items: string, itemDescription: string, itemQuantity: string, itemAmount: string, customer: string, payments: string, paymentMethod: string} $messages + * @return void + * @throws ValidationException + */ + private static function validateCustomer(array $order, object $messages): void + { + $hasCustomerId = isset($order['customer_id']) + && is_string($order['customer_id']) + && $order['customer_id'] !== ''; + + if ($hasCustomerId) { + return; + } + + if (!isset($order['customer']) || !is_array($order['customer'])) { + throw ValidationException::make('Pagar.me', $messages->customer); + } + + PagarMeCustomerRequest::validate($order['customer']); + } + + /** + * @param array $order + * @param object{items: string, itemDescription: string, itemQuantity: string, itemAmount: string, customer: string, payments: string, paymentMethod: string} $messages + * @return void + * @throws ValidationException + */ + private static function validatePayments(array $order, object $messages): void + { + if (!isset($order['payments']) || !is_array($order['payments']) || empty($order['payments'])) { + throw ValidationException::make('Pagar.me', $messages->payments); + } + + foreach ($order['payments'] as $payment) { + if (!is_array($payment) + || !isset($payment['payment_method']) + || !is_string($payment['payment_method']) + || !PaymentMethodEnum::tryFrom($payment['payment_method']) instanceof PaymentMethodEnum + ) { + throw ValidationException::make('Pagar.me', $messages->paymentMethod); + } + } + } + + /** + * messages for validation + * + * @return object{items: string, itemDescription: string, itemQuantity: string, itemAmount: string, customer: string, payments: string, paymentMethod: string} + */ + public static function messages(): object + { + return (object) [ + 'items' => 'O pedido precisa de ao menos um item em items. Use setItems() ou addItem().', + 'itemDescription' => 'O campo items[].description é 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[].amount é obrigatório e deve ser um inteiro em CENTAVOS maior que zero. O Pagar.me não aceita valor decimal: R$ 10,50 é 1050.', + 'customer' => 'O pedido precisa de customer_id ou de um customer completo. Use setCustomerId() ou setCustomer().', + 'payments' => 'O pedido precisa de ao menos uma forma de pagamento em payments. Use setPix(), setBoleto() ou setPayments().', + 'paymentMethod' => 'O campo payments[].payment_method é obrigatório e aceita apenas: credit_card, debit_card, boleto, pix.', + ]; + } +} diff --git a/src/Gateways/PagarMe/Requests/PagarMeSubscriptionRequest.php b/src/Gateways/PagarMe/Requests/PagarMeSubscriptionRequest.php new file mode 100644 index 0000000..1d035e5 --- /dev/null +++ b/src/Gateways/PagarMe/Requests/PagarMeSubscriptionRequest.php @@ -0,0 +1,135 @@ + $subscription + * @return void + * @throws ValidationException + */ + public static function validate(array $subscription): void + { + $messages = self::messages(); + + $hasPlan = isset($subscription['plan_id']) + && is_string($subscription['plan_id']) + && $subscription['plan_id'] !== ''; + + $hasItems = isset($subscription['items']) + && is_array($subscription['items']) + && !empty($subscription['items']); + + if (!$hasPlan && !$hasItems) { + throw ValidationException::make('Pagar.me', $messages->plan); + } + + $hasCustomerId = isset($subscription['customer_id']) + && is_string($subscription['customer_id']) + && $subscription['customer_id'] !== ''; + + if (!$hasCustomerId) { + if (!isset($subscription['customer']) || !is_array($subscription['customer'])) { + throw ValidationException::make('Pagar.me', $messages->customer); + } + + PagarMeCustomerRequest::validate($subscription['customer']); + } + + if (!isset($subscription['payment_method']) + || !is_string($subscription['payment_method']) + || !PaymentMethodEnum::tryFrom($subscription['payment_method']) instanceof PaymentMethodEnum + ) { + throw ValidationException::make('Pagar.me', $messages->paymentMethod); + } + + /* sem plano, a recorrência precisa vir descrita no próprio payload */ + if (!$hasPlan) { + self::validateRecurrence($subscription, $messages); + } + } + + /** + * 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('Pagar.me', $messages->planName); + } + + self::validateRecurrence($plan, $messages); + + if (!isset($plan['items']) || !is_array($plan['items']) || empty($plan['items'])) { + throw ValidationException::make('Pagar.me', $messages->planItems); + } + + foreach ($plan['items'] as $item) { + $scheme = is_array($item) ? ($item['pricing_scheme'] ?? null) : null; + + if (!is_array($scheme) + || !isset($scheme['price']) + || !is_int($scheme['price']) + || $scheme['price'] < 1 + ) { + throw ValidationException::make('Pagar.me', $messages->planPrice); + } + } + } + + /** + * validate the interval fields shared by plans and plan-less subscriptions. + * + * @param array $payload + * @param object{plan: string, customer: string, paymentMethod: string, interval: string, intervalCount: string, planName: string, planItems: string, planPrice: string} $messages + * @return void + * @throws ValidationException + */ + private static function validateRecurrence(array $payload, object $messages): void + { + if (!isset($payload['interval']) + || !is_string($payload['interval']) + || !IntervalEnum::tryFrom($payload['interval']) instanceof IntervalEnum + ) { + throw ValidationException::make('Pagar.me', $messages->interval); + } + + if (!isset($payload['interval_count']) + || !is_int($payload['interval_count']) + || $payload['interval_count'] < 1 + ) { + throw ValidationException::make('Pagar.me', $messages->intervalCount); + } + } + + /** + * messages for validation + * + * @return object{plan: string, customer: string, paymentMethod: string, interval: string, intervalCount: string, planName: string, planItems: string, planPrice: string} + */ + public static function messages(): object + { + return (object) [ + 'plan' => 'A assinatura precisa de plan_id ou de items próprios. Use setPlan() ou setItems().', + 'customer' => 'A assinatura precisa de customer_id ou de um customer completo. Use setCustomerId() ou setCustomer().', + 'paymentMethod' => 'O campo payment_method é obrigatório e aceita apenas: credit_card, debit_card, boleto, pix.', + 'interval' => 'O campo interval é obrigatório e aceita apenas: day, week, month, year.', + 'intervalCount' => 'O campo interval_count é obrigatório e deve ser um inteiro maior que zero.', + 'planName' => 'O campo name do plano é obrigatório e deve ser uma string não vazia.', + 'planItems' => 'O plano precisa de ao menos um item em items.', + 'planPrice' => 'O campo items[].pricing_scheme.price do plano é obrigatório e deve ser um inteiro em CENTAVOS maior que zero. R$ 49,90 é 4990.', + ]; + } +} diff --git a/src/Gateways/PagarMe/Resources/Charge/Charge.php b/src/Gateways/PagarMe/Resources/Charge/Charge.php new file mode 100644 index 0000000..a5d7ea2 --- /dev/null +++ b/src/Gateways/PagarMe/Resources/Charge/Charge.php @@ -0,0 +1,348 @@ + + */ + private array $order = []; + + /** + * @var array + */ + private array $queryParams = []; + + /** + * construct + * + * @param string $secretKey + * @param Client|null $client injected http client, mainly for tests + */ + public function __construct( + private string $secretKey, + ?Client $client = null, + ) { + $this->client = $client ?? $this->clientPagarMeBoot(); + } + + /** + * set the whole order payload + * + * @param array $order + * @return ChargeInterface + */ + public function setOrder(array $order): ChargeInterface + { + $this->order = $order; + + return $this; + } + + /** + * attach an existing customer to the order + * + * @param string $customerId + * @return ChargeInterface + */ + public function setCustomerId(string $customerId): ChargeInterface + { + $this->order['customer_id'] = $customerId; + + unset($this->order['customer']); + + return $this; + } + + /** + * attach a customer created along with the order. + * + * Pagar.me accepts the customer inline, so no extra call is needed — pass + * an array carrying `id` to reuse an existing one instead. + * + * @param array $customer + * @return ChargeInterface + */ + public function setCustomer(array $customer): ChargeInterface + { + if (isset($customer['id']) && is_string($customer['id']) && $customer['id'] !== '') { + return $this->setCustomerId($customer['id']); + } + + $this->order['customer'] = $customer; + + unset($this->order['customer_id']); + + 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 $description + * @param int $amount amount in cents + * @param int $quantity + * @return ChargeInterface + */ + public function addItem(string $description, int $amount, int $quantity = 1): ChargeInterface + { + $items = $this->order['items'] ?? []; + + if (!is_array($items)) { + $items = []; + } + + $items[] = [ + 'code' => uniqid('item_'), + 'description' => $description, + 'amount' => $amount, + 'quantity' => $quantity, + ]; + + $this->order['items'] = $items; + + return $this; + } + + /** + * set the payments of the order + * + * @param array $payments + * @return ChargeInterface + */ + public function setPayments(array $payments): ChargeInterface + { + $this->order['payments'] = $payments; + + return $this; + } + + /** + * pay the order with Pix. + * + * on Pagar.me Pix is a payment method of the order, not a resource of its + * own — the copy-and-paste code comes back inside the charge's last + * transaction. + * + * @param int $expiresIn seconds until the QR Code expires + * @return ChargeInterface + */ + public function setPix(int $expiresIn = 3600): ChargeInterface + { + return $this->setPayments([[ + 'payment_method' => PaymentMethodEnum::PIX->value, + 'pix' => ['expires_in' => $expiresIn], + ]]); + } + + /** + * pay the order with boleto + * + * @param string|null $dueAt + * @param array $instructions + * @return ChargeInterface + */ + public function setBoleto(?string $dueAt = null, array $instructions = []): ChargeInterface + { + $boleto = []; + + if ($dueAt !== null) { + $boleto['due_at'] = $dueAt; + } + + if (!empty($instructions)) { + $boleto['instructions'] = $instructions; + } + + return $this->setPayments([[ + 'payment_method' => PaymentMethodEnum::BOLETO->value, + 'boleto' => $boleto, + ]]); + } + + /** + * set list query params + * + * @param array $queryParams + * @return ChargeInterface + */ + public function setQueryParams(array $queryParams): ChargeInterface + { + $this->queryParams = $queryParams; + + return $this; + } + + /** + * create the order + * + * @return array + * @throws ValidationException|ApiException + * @see https://docs.pagar.me/reference/criar-pedido-2 + */ + public function create(): array + { + PagarMeOrderRequest::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}"); + } + + /** + * list orders + * + * @return array + * @throws ApiException + */ + public function getAll(): array + { + return $this->get('orders', $this->queryParams); + } + + /** + * 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. + * + * it travels in charges[0].last_transaction.qr_code. + * + * @param string $id + * @return string|null + * @throws ApiException + */ + public function getPixCode(string $id): ?string + { + $order = $this->find($id); + + $charges = $order['charges'] ?? null; + + if (!is_array($charges) || empty($charges)) { + return null; + } + + $charge = reset($charges); + + if (!is_array($charge)) { + return null; + } + + $transaction = $charge['last_transaction'] ?? null; + + if (!is_array($transaction)) { + return null; + } + + $code = $transaction['qr_code'] ?? null; + + return is_string($code) ? $code : null; + } + + /** + * capture a previously authorized charge + * + * @param string $id + * @param int|null $amount amount in cents + * @return array + * @throws ApiException + */ + public function capture(string $id, ?int $amount = null): array + { + return $this->post( + "charges/{$id}/capture", + $amount === null ? [] : ['amount' => $amount] + ); + } + + /** + * cancel a charge, refunding fully or partially. + * + * Pagar.me cancels through DELETE, with the amount in the body for a + * partial refund. + * + * @param string $id + * @param int|null $amount amount in cents; null refunds the full value + * @return array + * @throws ApiException + */ + public function cancel(string $id, ?int $amount = null): array + { + return $this->request( + 'DELETE', + "charges/{$id}", + ['json' => $amount === null ? [] : ['amount' => $amount]] + ); + } +} diff --git a/src/Gateways/PagarMe/Resources/Charge/Interface/ChargeInterface.php b/src/Gateways/PagarMe/Resources/Charge/Interface/ChargeInterface.php new file mode 100644 index 0000000..35ac7d9 --- /dev/null +++ b/src/Gateways/PagarMe/Resources/Charge/Interface/ChargeInterface.php @@ -0,0 +1,145 @@ + $order + * @return ChargeInterface + */ + public function setOrder(array $order): ChargeInterface; + + /** + * attach an existing customer to the order + * + * @param string $customerId + * @return ChargeInterface + */ + public function setCustomerId(string $customerId): ChargeInterface; + + /** + * attach a customer created along with 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 $description + * @param int $amount amount in cents + * @param int $quantity + * @return ChargeInterface + */ + public function addItem(string $description, int $amount, int $quantity = 1): ChargeInterface; + + /** + * set the payments of the order + * + * @param array $payments + * @return ChargeInterface + */ + public function setPayments(array $payments): ChargeInterface; + + /** + * pay the order with Pix + * + * @param int $expiresIn seconds until the QR Code expires + * @return ChargeInterface + */ + public function setPix(int $expiresIn = 3600): ChargeInterface; + + /** + * pay the order with boleto + * + * @param string|null $dueAt + * @param array $instructions + * @return ChargeInterface + */ + public function setBoleto(?string $dueAt = null, array $instructions = []): ChargeInterface; + + /** + * set list query params + * + * @param array $queryParams + * @return ChargeInterface + */ + public function setQueryParams(array $queryParams): 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; + + /** + * list orders + * + * @return array + */ + public function getAll(): 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; + + /** + * capture a previously authorized charge + * + * @param string $id + * @param int|null $amount amount in cents + * @return array + */ + public function capture(string $id, ?int $amount = null): array; + + /** + * cancel a charge, refunding fully or partially + * + * @param string $id + * @param int|null $amount amount in cents + * @return array + */ + public function cancel(string $id, ?int $amount = null): array; +} diff --git a/src/Gateways/PagarMe/Resources/Customer/Customer.php b/src/Gateways/PagarMe/Resources/Customer/Customer.php new file mode 100644 index 0000000..0906b4f --- /dev/null +++ b/src/Gateways/PagarMe/Resources/Customer/Customer.php @@ -0,0 +1,121 @@ + + */ + private array $filter = []; + + /** + * construct + * + * @param string $secretKey + * @param array $customer + * @param Client|null $client injected http client, mainly for tests + */ + public function __construct( + private string $secretKey, + private array $customer = [], + ?Client $client = null, + ) { + $this->client = $client ?? $this->clientPagarMeBoot(); + } + + /** + * create customer + * + * @return array + * @throws ValidationException|ApiException + */ + public function create(): array + { + PagarMeCustomerRequest::validate($this->customer); + + return $this->post('customers', $this->customer); + } + + /** + * find customer by id + * + * @param string $id + * @return array + * @throws ApiException + */ + public function find(string $id): array + { + return $this->get("customers/{$id}"); + } + + /** + * update customer by id + * + * @param string $id + * @return array + * @throws ApiException + */ + public function update(string $id): array + { + return $this->put("customers/{$id}", $this->customer); + } + + /** + * list customers + * + * @return array + * @throws ApiException + */ + public function getAll(): array + { + return $this->get('customers', $this->filter); + } + + /** + * list the saved cards of a customer + * + * @param string $id + * @return array + * @throws ApiException + */ + public function cards(string $id): array + { + return $this->get("customers/{$id}/cards"); + } + + /** + * set list filter + * + * @param array $filter + * @return CustomerInterface + */ + public function setFilter(array $filter = []): CustomerInterface + { + $this->filter = $filter; + + return $this; + } +} diff --git a/src/Gateways/PagarMe/Resources/Customer/Interface/CustomerInterface.php b/src/Gateways/PagarMe/Resources/Customer/Interface/CustomerInterface.php new file mode 100644 index 0000000..08eb0bf --- /dev/null +++ b/src/Gateways/PagarMe/Resources/Customer/Interface/CustomerInterface.php @@ -0,0 +1,52 @@ + + */ + public function create(): array; + + /** + * find customer by id + * + * @param string $id + * @return array + */ + public function find(string $id): array; + + /** + * update customer by id + * + * @param string $id + * @return array + */ + public function update(string $id): array; + + /** + * list customers + * + * @return array + */ + public function getAll(): array; + + /** + * list the saved cards of a customer + * + * @param string $id + * @return array + */ + public function cards(string $id): array; + + /** + * set list filter + * + * @param array $filter + * @return CustomerInterface + */ + public function setFilter(array $filter = []): CustomerInterface; +} diff --git a/src/Gateways/PagarMe/Resources/Subscription/Interface/SubscriptionInterface.php b/src/Gateways/PagarMe/Resources/Subscription/Interface/SubscriptionInterface.php new file mode 100644 index 0000000..a9f779e --- /dev/null +++ b/src/Gateways/PagarMe/Resources/Subscription/Interface/SubscriptionInterface.php @@ -0,0 +1,100 @@ + $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; + + /** + * 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; + + /** + * delete plan by id + * + * @param string $id + * @return array + */ + public function destroyPlan(string $id): array; +} diff --git a/src/Gateways/PagarMe/Resources/Subscription/Subscription.php b/src/Gateways/PagarMe/Resources/Subscription/Subscription.php new file mode 100644 index 0000000..cfd8efa --- /dev/null +++ b/src/Gateways/PagarMe/Resources/Subscription/Subscription.php @@ -0,0 +1,211 @@ + + */ + private array $subscription = []; + + /** + * @var array + */ + private array $filter = []; + + /** + * construct + * + * @param string $secretKey + * @param Client|null $client injected http client, mainly for tests + */ + public function __construct( + private string $secretKey, + ?Client $client = null, + ) { + $this->client = $client ?? $this->clientPagarMeBoot(); + } + + /** + * 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 customer to the subscription + * + * @param string $customerId + * @return SubscriptionInterface + */ + public function setCustomerId(string $customerId): SubscriptionInterface + { + $this->subscription['customer_id'] = $customerId; + + unset($this->subscription['customer']); + + return $this; + } + + /** + * attach a customer created along with the subscription + * + * @param array $customer + * @return SubscriptionInterface + */ + public function setCustomer(array $customer): SubscriptionInterface + { + if (isset($customer['id']) && is_string($customer['id']) && $customer['id'] !== '') { + return $this->setCustomerId($customer['id']); + } + + $this->subscription['customer'] = $customer; + + unset($this->subscription['customer_id']); + + 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 + */ + public function create(array $subscription = []): array + { + $payload = array_merge($this->subscription, $subscription); + + PagarMeSubscriptionRequest::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); + } + + /** + * cancel subscription by id + * + * @param string $id + * @return array + * @throws ApiException + */ + public function cancel(string $id): array + { + return $this->request('DELETE', "subscriptions/{$id}"); + } + + /** + * create a recurring plan + * + * @param array $plan + * @return array + * @throws ValidationException|ApiException + */ + public function createPlan(array $plan): array + { + PagarMeSubscriptionRequest::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); + } + + /** + * delete plan by id + * + * @param string $id + * @return array + * @throws ApiException + */ + public function destroyPlan(string $id): array + { + return $this->request('DELETE', "plans/{$id}"); + } +} diff --git a/src/Gateways/PagarMe/Resources/WebhookDelivery/Interface/WebhookDeliveryInterface.php b/src/Gateways/PagarMe/Resources/WebhookDelivery/Interface/WebhookDeliveryInterface.php new file mode 100644 index 0000000..ec19e93 --- /dev/null +++ b/src/Gateways/PagarMe/Resources/WebhookDelivery/Interface/WebhookDeliveryInterface.php @@ -0,0 +1,37 @@ + + */ + public function getAll(): array; + + /** + * find a webhook delivery by id + * + * @param string $id + * @return array + */ + public function find(string $id): array; + + /** + * resend a webhook delivery + * + * @param string $id + * @return array + */ + public function resend(string $id): array; + + /** + * set list filter + * + * @param array $filter + * @return WebhookDeliveryInterface + */ + public function setFilter(array $filter = []): WebhookDeliveryInterface; +} diff --git a/src/Gateways/PagarMe/Resources/WebhookDelivery/WebhookDelivery.php b/src/Gateways/PagarMe/Resources/WebhookDelivery/WebhookDelivery.php new file mode 100644 index 0000000..b69c15e --- /dev/null +++ b/src/Gateways/PagarMe/Resources/WebhookDelivery/WebhookDelivery.php @@ -0,0 +1,97 @@ + + */ + private array $filter = []; + + /** + * construct + * + * @param string $secretKey + * @param Client|null $client injected http client, mainly for tests + */ + public function __construct( + private string $secretKey, + ?Client $client = null, + ) { + $this->client = $client ?? $this->clientPagarMeBoot(); + } + + /** + * list webhook deliveries + * + * @return array + * @throws ApiException + */ + public function getAll(): array + { + return $this->get('hooks', $this->filter); + } + + /** + * find a webhook delivery by id + * + * @param string $id + * @return array + * @throws ApiException + */ + public function find(string $id): array + { + return $this->get("hooks/{$id}"); + } + + /** + * resend a webhook delivery + * + * @param string $id + * @return array + * @throws ApiException + */ + public function resend(string $id): array + { + return $this->post("hooks/{$id}/resend"); + } + + /** + * set list filter + * + * @param array $filter + * @return WebhookDeliveryInterface + */ + public function setFilter(array $filter = []): WebhookDeliveryInterface + { + $this->filter = $filter; + + return $this; + } +} diff --git a/src/Gateways/PagarMe/Traits/HasPagarMeClient.php b/src/Gateways/PagarMe/Traits/HasPagarMeClient.php new file mode 100644 index 0000000..dfaa939 --- /dev/null +++ b/src/Gateways/PagarMe/Traits/HasPagarMeClient.php @@ -0,0 +1,56 @@ + $this->baseUri(), + 'headers' => [ + 'content-type' => 'application/json', + 'accept' => 'application/json', + 'user-agent' => 'PHPay', + 'Authorization' => 'Basic ' . base64_encode("{$this->secretKey}:"), + ], + ]); + } + + /** + * base uri + * + * @return string + */ + protected function baseUri(): string + { + return 'https://api.pagar.me/core/v5/'; + } + + /** + * gateway name used in exception messages. + * + * @return string + */ + protected function gatewayName(): string + { + return 'Pagar.me'; + } +} diff --git a/tests/Pest.php b/tests/Pest.php index c1bf67a..28c7deb 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -86,3 +86,15 @@ function pagbankClient(array $responses, array &$history = []): Client { return mockClient($responses, $history, 'https://sandbox.api.pagseguro.com/'); } + +/** + * mock client already pointed at the Pagar.me host. + * + * @param array $responses + * @param array $history filled with the recorded transactions + * @return Client + */ +function pagarmeClient(array $responses, array &$history = []): Client +{ + return mockClient($responses, $history, 'https://api.pagar.me/core/v5/'); +} diff --git a/tests/Unit/PagarMe/ChargeTest.php b/tests/Unit/PagarMe/ChargeTest.php new file mode 100644 index 0000000..34e5307 --- /dev/null +++ b/tests/Unit/PagarMe/ChargeTest.php @@ -0,0 +1,180 @@ + + */ +function pagarmeCustomer(): array +{ + return [ + 'name' => 'Mário Lucas', + 'email' => 'fale@phpay.io', + 'document' => '12345678901', + 'type' => 'individual', + ]; +} + +it('manda o pix como forma de pagamento do pedido', function () { + $history = []; + $client = pagarmeClient([jsonResponse(['id' => 'or_1'])], $history); + + (new Charge('sk_test_abc', $client)) + ->setCustomer(pagarmeCustomer()) + ->addItem('Assinatura PHPay', 10050) + ->setPix(1800) + ->create(); + + $body = recordedBody($history); + + expect((string) $history[0]['request']->getUri())->toEndWith('/orders') + ->and($body['payments'][0]['payment_method'])->toBe('pix') + ->and($body['payments'][0]['pix']['expires_in'])->toBe(1800) + ->and($body['items'][0]['amount'])->toBe(10050); +})->group('pagarme'); + +it('extrai o copia-e-cola de charges[0].last_transaction.qr_code', function () { + $client = pagarmeClient([jsonResponse([ + 'id' => 'or_1', + 'charges' => [[ + 'id' => 'ch_1', + 'last_transaction' => ['qr_code' => '00020126580014br.gov.bcb.pix'], + ]], + ])]); + + expect((new Charge('sk_test_abc', $client))->getPixCode('or_1')) + ->toBe('00020126580014br.gov.bcb.pix'); +})->group('pagarme'); + +it('devolve null quando o pedido não tem qr code', function () { + $client = pagarmeClient([jsonResponse(['id' => 'or_1', 'charges' => [['id' => 'ch_1']]])]); + + expect((new Charge('sk_test_abc', $client))->getPixCode('or_1'))->toBeNull(); +})->group('pagarme'); + +it('reaproveita o cliente quando o array traz um id', function () { + $history = []; + $client = pagarmeClient([jsonResponse(['id' => 'or_1'])], $history); + + (new Charge('sk_test_abc', $client)) + ->setCustomer(['id' => 'cus_existente']) + ->addItem('Item', 100) + ->setPix() + ->create(); + + $body = recordedBody($history); + + /* uma só requisição, e o customer completo dá lugar ao id */ + expect($history)->toHaveCount(1) + ->and($body['customer_id'])->toBe('cus_existente') + ->and($body)->not->toHaveKey('customer'); +})->group('pagarme'); + +it('monta boleto com vencimento', function () { + $history = []; + $client = pagarmeClient([jsonResponse(['id' => 'or_1'])], $history); + + (new Charge('sk_test_abc', $client)) + ->setCustomerId('cus_1') + ->addItem('Item', 5000) + ->setBoleto('2026-12-31', ['Não receber após o vencimento']) + ->create(); + + $payment = recordedBody($history)['payments'][0]; + + expect($payment['payment_method'])->toBe('boleto') + ->and($payment['boleto']['due_at'])->toBe('2026-12-31') + ->and($payment['boleto']['instructions'])->toBe(['Não receber após o vencimento']); +})->group('pagarme'); + +it('cancela a cobrança por DELETE, com valor opcional', function () { + $history = []; + $client = pagarmeClient([jsonResponse(['id' => 1]), jsonResponse(['id' => 2])], $history); + + $charge = new Charge('sk_test_abc', $client); + $charge->cancel('ch_1'); + $charge->cancel('ch_1', 2500); + + expect($history[0]['request']->getMethod())->toBe('DELETE') + ->and((string) $history[0]['request']->getUri())->toEndWith('/charges/ch_1') + ->and(recordedBody($history, 0))->toBe([]) + ->and(recordedBody($history, 1))->toBe(['amount' => 2500]); +})->group('pagarme'); + +it('captura uma autorização', function () { + $history = []; + $client = pagarmeClient([jsonResponse(['id' => 'ch_1'])], $history); + + (new Charge('sk_test_abc', $client))->capture('ch_1', 1000); + + expect($history[0]['request']->getMethod())->toBe('POST') + ->and((string) $history[0]['request']->getUri())->toEndWith('/charges/ch_1/capture') + ->and(recordedBody($history))->toBe(['amount' => 1000]); +})->group('pagarme'); + +it('valida o pedido antes de chamar a API', function (callable $montar, string $esperado) { + $history = []; + $client = pagarmeClient([jsonResponse([])], $history); + + expect(fn () => $montar(new Charge('sk_test_abc', $client))->create()) + ->toThrow(ValidationException::class, $esperado); + + expect($history)->toBeEmpty(); +})->with([ + 'sem itens' => [ + fn (Charge $c) => $c->setCustomerId('cus_1')->setPix(), + 'ao menos um item', + ], + 'sem cliente' => [ + fn (Charge $c) => $c->addItem('Item', 100)->setPix(), + 'customer_id ou de um customer completo', + ], + 'sem forma de pagamento' => [ + fn (Charge $c) => $c->setCustomerId('cus_1')->addItem('Item', 100), + 'ao menos uma forma de pagamento', + ], + 'forma de pagamento fora do enum' => [ + fn (Charge $c) => $c->setCustomerId('cus_1')->addItem('Item', 100) + ->setPayments([['payment_method' => 'cheque']]), + 'credit_card, debit_card, boleto, pix', + ], + 'documento inválido' => [ + fn (Charge $c) => $c->setCustomer(['name' => 'X', 'email' => 'a@b.com', 'document' => '123']) + ->addItem('Item', 100)->setPix(), + 'document', + ], +])->group('pagarme'); + +it('recusa valor decimal no item', function () { + $history = []; + $client = pagarmeClient([jsonResponse([])], $history); + + expect(fn () => (new Charge('sk_test_abc', $client)) + ->setCustomerId('cus_1') + ->setItems([['description' => 'Item', 'quantity' => 1, 'amount' => 10.50]]) + ->setPix() + ->create()) + ->toThrow(ValidationException::class, 'CENTAVOS'); + + expect($history)->toBeEmpty(); +})->group('pagarme'); + +it('lê e reenvia entregas de webhook', function () { + $history = []; + $client = pagarmeClient([ + jsonResponse(['data' => []]), jsonResponse(['id' => 'hook_1']), jsonResponse(['id' => 'hook_1']), + ], $history); + + $deliveries = new WebhookDelivery('sk_test_abc', $client); + $deliveries->setFilter(['size' => 10])->getAll(); + $deliveries->find('hook_1'); + $deliveries->resend('hook_1'); + + expect((string) $history[0]['request']->getUri())->toContain('/hooks') + ->and((string) $history[0]['request']->getUri())->toContain('size=10') + ->and((string) $history[1]['request']->getUri())->toEndWith('/hooks/hook_1') + ->and($history[2]['request']->getMethod())->toBe('POST') + ->and((string) $history[2]['request']->getUri())->toEndWith('/hooks/hook_1/resend'); +})->group('pagarme'); diff --git a/tests/Unit/PagarMe/PagarMeGatewayTest.php b/tests/Unit/PagarMe/PagarMeGatewayTest.php new file mode 100644 index 0000000..c5ddcc8 --- /dev/null +++ b/tests/Unit/PagarMe/PagarMeGatewayTest.php @@ -0,0 +1,74 @@ +toBe([ + Capability::CUSTOMERS, + Capability::CHARGES, + Capability::SUBSCRIPTIONS, + ]); +})->group('pagarme'); + +it('não declara webhooks, porque /hooks lê entregas e não cadastra endpoints', function (Capability $capability) { + $phpay = PHPay::gateway(new PagarMeGateway('sk_test_abc', pagarmeClient([]))); + + expect($phpay->supports($capability))->toBeFalse(); + + expect(fn () => $capability === Capability::WEBHOOKS ? $phpay->webhook() : $phpay->pix()) + ->toThrow(NotImplementedException::class, 'Pagar.me não suporta'); +})->with([Capability::WEBHOOKS, Capability::PIX_KEYS])->group('pagarme'); + +it('expõe a leitura de entregas só no gateway concreto, fora da facade', function () { + $gateway = new PagarMeGateway('sk_test_abc', pagarmeClient([])); + + expect($gateway->webhookDeliveries())->toBeInstanceOf(WebhookDelivery::class) + ->and(method_exists(PHPay::class, 'webhookDeliveries'))->toBeFalse(); +})->group('pagarme'); + +it('devolve a instância de cada recurso suportado', function () { + $phpay = PHPay::gateway(new PagarMeGateway('sk_test_abc', pagarmeClient([]))); + + expect($phpay->customer([]))->toBeInstanceOf(Customer::class) + ->and($phpay->charge())->toBeInstanceOf(Charge::class) + ->and($phpay->subscription())->toBeInstanceOf(Subscription::class); +})->group('pagarme'); + +it('identifica o ambiente pelo prefixo da chave, não por host', function () { + expect((new PagarMeGateway('sk_test_abc'))->isSandbox())->toBeTrue() + ->and((new PagarMeGateway('sk_live_abc'))->isSandbox())->toBeFalse(); +})->group('pagarme'); + +it('autentica com basic auth e senha vazia', function () { + $history = []; + $client = pagarmeClient([jsonResponse(['data' => []])], $history); + + (new Customer('sk_test_abc', [], $client))->getAll(); + + /* o mock não carrega os headers do client real, então validamos o boot */ + $charge = new Charge('sk_test_abc'); + + $property = new ReflectionProperty($charge, 'client'); + $headers = $property->getValue($charge)->getConfig('headers'); + + expect($headers['Authorization'])->toBe('Basic ' . base64_encode('sk_test_abc:')) + ->and((string) $property->getValue($charge)->getConfig('base_uri')) + ->toBe('https://api.pagar.me/core/v5/'); +})->group('pagarme'); + +it('não faz chamada de rede ao instanciar o gateway', function () { + $history = []; + + new PagarMeGateway('sk_test_abc', pagarmeClient([], $history)); + + expect($history)->toBeEmpty(); +})->group('pagarme'); diff --git a/tests/Unit/PagarMe/SubscriptionTest.php b/tests/Unit/PagarMe/SubscriptionTest.php new file mode 100644 index 0000000..d5026cc --- /dev/null +++ b/tests/Unit/PagarMe/SubscriptionTest.php @@ -0,0 +1,136 @@ + 'plan_1'])], $history); + + (new Subscription('sk_test_abc', $client))->createPlan([ + 'name' => 'Plano PHPay', + 'interval' => IntervalEnum::MONTH->value, + 'interval_count' => 1, + 'items' => [[ + 'name' => 'Mensalidade', + 'quantity' => 1, + 'pricing_scheme' => ['price' => 4990], + ]], + ]); + + expect((string) $history[0]['request']->getUri())->toEndWith('/plans') + ->and(recordedBody($history)['items'][0]['pricing_scheme']['price'])->toBe(4990); +})->group('pagarme'); + +it('cria a assinatura com plano e cliente existentes', function () { + $history = []; + $client = pagarmeClient([jsonResponse(['id' => 'sub_1'])], $history); + + (new Subscription('sk_test_abc', $client)) + ->setPlan('plan_1') + ->setCustomerId('cus_1') + ->create(['payment_method' => PaymentMethodEnum::CREDIT_CARD->value]); + + $body = recordedBody($history); + + expect((string) $history[0]['request']->getUri())->toEndWith('/subscriptions') + ->and($body['plan_id'])->toBe('plan_1') + ->and($body['customer_id'])->toBe('cus_1') + ->and($body['payment_method'])->toBe('credit_card'); +})->group('pagarme'); + +it('aceita assinatura sem plano quando a recorrência vem no payload', function () { + $history = []; + $client = pagarmeClient([jsonResponse(['id' => 'sub_1'])], $history); + + (new Subscription('sk_test_abc', $client)) + ->setCustomerId('cus_1') + ->create([ + 'payment_method' => PaymentMethodEnum::PIX->value, + 'interval' => IntervalEnum::MONTH->value, + 'interval_count' => 1, + 'items' => [[ + 'name' => 'Mensalidade', + 'quantity' => 1, + 'pricing_scheme' => ['price' => 4990], + ]], + ]); + + expect(recordedBody($history))->not->toHaveKey('plan_id'); +})->group('pagarme'); + +it('cancela assinatura e plano por DELETE', function () { + $history = []; + $client = pagarmeClient([jsonResponse(['id' => 1]), jsonResponse(['id' => 1])], $history); + + $subscription = new Subscription('sk_test_abc', $client); + $subscription->cancel('sub_1'); + $subscription->destroyPlan('plan_1'); + + expect($history[0]['request']->getMethod())->toBe('DELETE') + ->and((string) $history[0]['request']->getUri())->toEndWith('/subscriptions/sub_1') + ->and((string) $history[1]['request']->getUri())->toEndWith('/plans/plan_1'); +})->group('pagarme'); + +it('valida plano e assinatura antes de chamar a API', function () { + $history = []; + $client = pagarmeClient([jsonResponse([])], $history); + + $subscription = new Subscription('sk_test_abc', $client); + + expect(fn () => $subscription->create(['payment_method' => 'pix'])) + ->toThrow(ValidationException::class, 'plan_id ou de items próprios'); + + expect(fn () => (new Subscription('sk_test_abc', $client)) + ->setPlan('plan_1') + ->create()) + ->toThrow(ValidationException::class, 'customer_id ou de um customer completo'); + + expect(fn () => (new Subscription('sk_test_abc', $client)) + ->setPlan('plan_1') + ->setCustomerId('cus_1') + ->create(['payment_method' => 'cheque'])) + ->toThrow(ValidationException::class, 'credit_card, debit_card, boleto, pix'); + + expect(fn () => $subscription->createPlan([ + 'name' => 'Plano', + 'interval' => 'quinzena', + 'interval_count' => 1, + 'items' => [['pricing_scheme' => ['price' => 4990]]], + ]))->toThrow(ValidationException::class, 'day, week, month, year'); + + expect(fn () => $subscription->createPlan([ + 'name' => 'Plano', + 'interval' => 'month', + 'interval_count' => 1, + 'items' => [['pricing_scheme' => ['price' => 49.90]]], + ]))->toThrow(ValidationException::class, 'CENTAVOS'); + + expect($history)->toBeEmpty(); +})->group('pagarme'); + +it('faz o crud completo de clientes, com cartões salvos', function () { + $history = []; + $client = pagarmeClient([ + jsonResponse(['id' => 'cus_1']), jsonResponse(['id' => 'cus_1']), + jsonResponse(['data' => []]), jsonResponse(['data' => []]), + ], $history); + + (new Customer('sk_test_abc', [ + 'name' => 'Mário Lucas', + 'email' => 'fale@phpay.io', + 'document' => '12345678901', + ], $client))->create(); + + $customer = new Customer('sk_test_abc', ['name' => 'Novo Nome'], $client); + $customer->update('cus_1'); + $customer->setFilter(['size' => 10])->getAll(); + $customer->cards('cus_1'); + + expect((string) $history[0]['request']->getUri())->toEndWith('/customers') + ->and($history[1]['request']->getMethod())->toBe('PUT') + ->and((string) $history[2]['request']->getUri())->toContain('size=10') + ->and((string) $history[3]['request']->getUri())->toEndWith('/customers/cus_1/cards'); +})->group('pagarme');