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..1a1ddb9 100644
--- a/README.md
+++ b/README.md
@@ -1,295 +1,396 @@

-
-
-
+
+
+
+
+
-O PHPay é uma biblioteca PHP que tem o objetivo tornar o trabalho de integrações com gateways de pagamento mais simples e descomplicadas, facilitando a conexão entre tecnologia e negócios em produtos de software.
+
+ Uma interface só para os gateways de pagamento brasileiros.
+
-## 💸 Gateways
+---
+
+## Sumário
+
+- [Por que o PHPay](#por-que-o-phpay)
+- [Gateways suportados](#gateways-suportados)
+- [Requisitos](#requisitos)
+- [Instalação](#instalação)
+- [Início rápido](#início-rápido)
+- [Conceitos](#conceitos)
+ - [Capacidades](#capacidades)
+ - [Tratamento de erros](#tratamento-de-erros)
+ - [Ambientes e credenciais](#ambientes-e-credenciais)
+ - [Unidade monetária](#unidade-monetária)
+- [Gateways](#gateways)
+ - [Asaas](#asaas)
+ - [Mercado Pago](#mercado-pago)
+ - [PagBank](#pagbank)
+ - [Pagar.me](#pagarme)
+ - [Efí](#efí)
+- [Exemplos executáveis](#exemplos-executáveis)
+- [Migrando da v1](#migrando-da-v1)
+- [Roadmap](#roadmap)
+- [Contribuindo](#contribuindo)
+- [Segurança](#segurança)
+- [Licença](#licença)
+
+---
+
+## Por que o PHPay
+
+Cada gateway brasileiro resolve os mesmos problemas de um jeito diferente: um
+chama de `payment`, outro de `order`, outro de `charge`. Um quer reais, outro
+quer centavos. Um separa ambiente por URL, outro pelo prefixo do token.
+
+O PHPay normaliza isso numa interface só, **sem esconder o que é genuinamente
+diferente**. Quando um gateway não oferece um recurso, ele não finge que
+oferece — ele declara o que sabe fazer, e você descobre em tempo de análise
+estática, não em produção.
-- 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)
+```php
+use PHPay\Asaas\AsaasGateway;
+use PHPay\PHPay;
-## ⬆️ Vindo da v1?
+$phpay = PHPay::gateway(new AsaasGateway(TOKEN));
-A v2.0.0 tem breaking changes — a principal é que falhas passaram a ser exceção
-em vez de array de erro. O de-para completo está em
-[UPGRADE.md](./UPGRADE.md).
+$phpay->charge()->setCharge($cobranca)->setCustomerId($clienteId)->create();
+```
-## 📦 Instalação
+Trocar de gateway é trocar a linha do construtor.
-Instale via Composer:
+---
-```php
-composer require phpay-io/phpay
-```
+## Gateways suportados
-## ⚙️ Como usar o PHPay?
+| Capacidade | Interface | Asaas | Mercado Pago | PagBank | Pagar.me | Efí |
+| --- | --- | :---: | :---: | :---: | :---: | :---: |
+| Clientes | `SupportsCustomers` | ✅ | ✅ | ✅ | ✅ | — |
+| Cobranças | `SupportsCharges` | ✅ | ✅ | ✅ | ✅ | ✅ |
+| Assinaturas | `SupportsSubscriptions` | ✅ | ✅ | ✅ | ✅ | — |
+| Webhooks | `SupportsWebhooks` | ✅ | — | — | — | — |
+| Chaves Pix | `SupportsPixKeys` | ✅ | — | — | — | — |
-```php
-/**
- * instance with gateway inject
- * @var AsaasGateway $phpay
- */
-$phpay = (new PHPay(new AsaasGateway(TOKEN_ASAAS_SANDBOX)));
-```
+Duas colunas merecem explicação, porque a ausência de ✅ **não** quer dizer que o
+gateway não aceita Pix ou não manda webhook:
-### Cobranças
+- **`SupportsPixKeys`** significa *gerenciar chaves Pix e QR Code estático*, o
+ que só um PSP que emite chave própria oferece. Nos outros gateways, Pix é
+ forma de pagamento de uma cobrança — e todos aceitam.
+- **`SupportsWebhooks`** significa *cadastrar endpoints pela API*. Nos outros,
+ o cadastro é no painel; a notificação vai por cobrança, no campo
+ `notification_url`. O Pagar.me ainda deixa **consultar e reenviar entregas**,
+ através de [`webhookDeliveries()`](#consultando-entregas-de-webhook).
-```php
-/**
- * instance with gateway inject and resource call
- *
- * @var Charge $phpay
- */
-$phpay = (new PHPay(new AsaasGateway(TOKEN_ASAAS_SANDBOX)))->charge();
-
-/**
- * create charge
- */
-$phpay
- ->setCharge($charge)
- ->setCustomer($customer)
- ->create();
+---
-/**
- * reaproveitando um cliente que já existe no gateway
- * (setCustomer cria um cliente novo quando o array não traz `id`)
- */
-$phpay
- ->setCharge($charge)
- ->setCustomerId('cus_000006337812')
- ->create();
+## Requisitos
-/**
- * find charge
- */
-$phpay->find($chargeId);
+| | |
+| --- | --- |
+| **Para usar a biblioteca** | PHP `^8.1`, `ext-curl`, `ext-json` |
+| **Para desenvolver o PHPay** | PHP `^8.2` (Pest 3 e Termwind 2 exigem) |
-/**
- * get all charges
- */
-$phpay->getAll();
+A compatibilidade com PHP 8.1 é verificada estaticamente pelo PHPStan a cada
+build, com `phpVersion` mínimo configurado.
-/**
- * get all charges with filters
- */
-$phpay
- ->setQueryParams(['limit' => 2])
- ->getAll();
-
-/**
- * update charge
- */
-$phpay->update($chargeId, $data);
-
-/**
- * destroy charge
- */
-$phpay->destroy($chargeId);
-
-/**
- * restore charge
- */
-$phpay->restore($chargeId);
-
-/**
- * get status charge
- */
-$phpay->getStatus($chargeId);
-
-/**
- * get digitable line charge
- */
-$phpay->getDigitableLine($chargeId);
-
-/**
- * get qrcode charge
- */
-$phpay->getQrCodePix($chargeId);
-
-/**
- * confirm receipt charge
- */
-$phpay->confirmReceipt($chargeId, [
- 'paymentDate' => date('Y-m-d'),
- 'value' => 100.00,
- 'notifyCustomer' => true,
-]);
+---
-/**
- * undo confirm receipt
- */
-$phpay->undoConfirmReceipt($chargeId);
+## Instalação
+```bash
+composer require phpay-io/phpay
```
-### Assinaturas
+---
-```php
-/**
- * @var Subscription $phpay
- */
-$phpay = PHPay::gateway(new AsaasGateway(TOKEN_ASAAS_SANDBOX))->subscription();
-
-/**
- * create a new subscription
- */
-$phpay->setCustomer($customer)->create([
- 'billingType' => 'BOLETO',
- 'value' => 100,
- 'nextDueDate' => '2025-04-09',
-]);
-```
+## Início rápido
-### Assinaturas com cliente existente
+Uma cobrança Pix no Asaas, do zero:
```php
-$phpay
- ->setCustomerId('cus_000006337812')
- ->create([
- 'billingType' => 'BOLETO',
- 'value' => 100,
- 'nextDueDate' => '2026-04-09',
- 'cycle' => 'MONTHLY',
- ]);
+use PHPay\Asaas\AsaasGateway;
+use PHPay\Exceptions\PHPayException;
+use PHPay\PHPay;
+
+$phpay = PHPay::gateway(new AsaasGateway(TOKEN_ASAAS_SANDBOX));
+
+try {
+ $cobranca = $phpay->charge()
+ ->setCharge([
+ 'billingType' => 'PIX',
+ 'value' => 100.50,
+ 'dueDate' => date('Y-m-d', strtotime('+3 days')),
+ 'description' => 'Assinatura PHPay',
+ ])
+ ->setCustomer([
+ 'name' => 'Mário Lucas',
+ 'cpfCnpj' => '12345678901',
+ ])
+ ->create();
+
+ print_r($phpay->charge()->getQrCodePix($cobranca['id']));
+} catch (PHPayException $e) {
+ echo $e->getMessage();
+}
```
-## 🧩 Capacidades por gateway
+Um array devolvido é **sempre** uma resposta de sucesso. Qualquer falha vira
+exceção — veja [Tratamento de erros](#tratamento-de-erros).
-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` | ✅ | ✅ | ✅ | — |
+## Conceitos
-> 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`.
+Três coisas valem entender uma vez; depois todo gateway se comporta igual.
-> `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.
-> Na maioria dos gateways, Pix é uma forma de pagamento da cobrança.
+### Capacidades
-Para decidir em tempo de execução:
+`GatewayInterface` carrega só a identidade do gateway. Cada recurso é uma
+interface que o gateway implementa **se, e só se,** oferecer:
```php
use PHPay\Contracts\Capability;
-$phpay = PHPay::gateway(new AsaasGateway(TOKEN_ASAAS_SANDBOX));
+$phpay = PHPay::gateway(new EfiGateway(CLIENT_ID, CLIENT_SECRET));
-$phpay->supports(Capability::SUBSCRIPTIONS); // true
-$phpay->capabilities(); // todas as capacidades do gateway
-$phpay->name(); // 'Asaas'
+$phpay->name(); // 'Efí'
+$phpay->supports(Capability::SUBSCRIPTIONS); // false
+$phpay->capabilities(); // [Capability::CHARGES]
```
-Chamar um recurso que o gateway não oferece lança `NotImplementedException`
-dizendo o que ele oferece:
+Chamar um recurso que o gateway não oferece lança uma exceção que diz o que ele
+oferece:
```php
-PHPay::gateway(new EfiGateway(CLIENT_ID, CLIENT_SECRET))->pix();
+$phpay->pix();
// NotImplementedException: Efí não suporta chaves Pix.
// Capacidades disponíveis: cobranças.
```
-Se você segurar o gateway concreto em vez da facade, o erro sobe para tempo de
-análise — o PHPStan acusa que o método não existe:
+Se você segurar o **gateway concreto** em vez da facade, o erro sobe para tempo
+de análise — o PHPStan acusa que o método não existe naquele tipo:
```php
$efi = new EfiGateway(CLIENT_ID, CLIENT_SECRET);
+
$efi->charge(); // ✅
$efi->pix(); // ❌ o método não existe nesse gateway
```
-## 🚨 Tratamento de erros
+Para injeção de dependência, tipe a capacidade em vez do gateway:
+
+```php
+use PHPay\Contracts\SupportsCharges;
+
+public function __construct(private SupportsCharges $gateway) {}
+```
+
+### Tratamento de erros
-Toda falha vira exceção — um array de retorno é **sempre** uma resposta de sucesso.
-Todas as exceções da biblioteca implementam `PHPay\Exceptions\PHPayException`, então
-um único `catch` cobre a integração inteira:
+Toda exceção da biblioteca implementa `PHPay\Exceptions\PHPayException`, então
+um `catch` cobre a integração inteira:
+
+| Exceção | Estende | Quando acontece |
+| --- | --- | --- |
+| `ValidationException` | `InvalidArgumentException` | Payload inválido, **antes** de qualquer HTTP |
+| `ApiException` | `RuntimeException` | O gateway recusou, ou está inacessível |
+| `NotImplementedException` | `BadMethodCallException` | O gateway não oferece o recurso |
```php
use PHPay\Exceptions\ApiException;
-use PHPay\Exceptions\NotImplementedException;
use PHPay\Exceptions\PHPayException;
use PHPay\Exceptions\ValidationException;
try {
- $charge = $phpay->setCharge($charge)->setCustomer($customer)->create();
+ $cobranca = $phpay->charge()->setCharge($dados)->create();
} catch (ValidationException $e) {
- /* payload inválido: nenhuma requisição foi feita */
- echo $e->getMessage();
+ // payload inválido: nenhuma requisição foi feita
} catch (ApiException $e) {
- /* o gateway recusou a requisição ou está inacessível */
- echo $e->getMessage();
- echo $e->getStatusCode(); // 400, 401, 404... ou 0 se nem chegou ao gateway
- print_r($e->getResponse()); // corpo devolvido pelo gateway
- echo $e->getGateway(); // 'Asaas' ou 'Efí'
-
- if ($e->isConnectionError()) {
- /* timeout, DNS, TLS — vale um retry */
- }
-} catch (NotImplementedException $e) {
- /* o gateway ainda não implementa esse recurso */
+ $e->getStatusCode(); // 400, 401, 404… ou 0 se nem chegou ao gateway
+ $e->getResponse(); // corpo devolvido pelo gateway
+ $e->getGateway(); // 'Asaas', 'Pagar.me', …
+ $e->isConnectionError(); // true em timeout, DNS, TLS — vale retry
} catch (PHPayException $e) {
- /* qualquer outra falha do PHPay */
+ // qualquer outra falha do PHPay
}
```
-## 💳 Mercado Pago
+`ApiException` já resume os formatos de erro de cada gateway, então
+`getMessage()` traz a descrição legível, não um dump.
+
+### Ambientes e credenciais
+
+Os gateways discordam sobre como separar teste de produção, e o PHPay segue o
+que cada um faz em vez de inventar um padrão:
+
+| Gateway | Como o ambiente é decidido |
+| --- | --- |
+| **Asaas** | `$sandbox` no construtor — troca a URL |
+| **PagBank** | `$sandbox` no construtor — troca a URL |
+| **Efí** | `$sandbox` no construtor — troca a URL |
+| **Mercado Pago** | Prefixo do token (`TEST-`); host único, sem `$sandbox` |
+| **Pagar.me** | Prefixo da chave (`sk_test_`); host único, sem `$sandbox` |
-O Mercado Pago não tem URL de sandbox — o ambiente vem do próprio token, que é
-prefixado com `TEST-` nas credenciais de teste:
+Nos dois últimos, `isSandbox()` diz em qual ambiente você está:
```php
-use PHPay\MercadoPago\Enums\PaymentMethodEnum;
-use PHPay\MercadoPago\MercadoPagoGateway;
+(new MercadoPagoGateway($token))->isSandbox();
+(new PagarMeGateway($secretKey))->isSandbox();
+```
+
+> **Nunca** versione credenciais. Os arquivos `examples/*/credentials.php` são
+> ignorados pelo git por padrão.
+
+### Unidade monetária
+
+**Este é o erro mais caro de cometer**, porque a cobrança sai com valor errado
+em vez de falhar:
+
+| Gateway | Unidade | R$ 100,50 é |
+| --- | --- | --- |
+| **Asaas** | Reais (decimal) | `100.50` |
+| **Mercado Pago** | Reais (decimal) | `100.50` |
+| **PagBank** | Centavos (inteiro) | `10050` |
+| **Pagar.me** | Centavos (inteiro) | `10050` |
+| **Efí** | Centavos (inteiro) | `10050` |
+
+Nos gateways que usam centavos, o PHPay **recusa valor decimal na validação**,
+antes de qualquer chamada:
+
+```php
+$phpay->charge()->addItem('Item', 100.50);
+// ValidationException: ... deve ser um inteiro em CENTAVOS maior que zero.
+// R$ 10,50 é 1050.
+```
+
+---
+
+## Gateways
+
+Cada seção cobre só o que é específico daquele gateway. Tudo que vale para
+todos está em [Conceitos](#conceitos).
+
+### Asaas
+
+O único com as cinco capacidades — é PSP, então emite chave Pix própria e
+gerencia webhooks por API.
+
+```php
+use PHPay\Asaas\AsaasGateway;
+
+$phpay = PHPay::gateway(new AsaasGateway(TOKEN_ASAAS_SANDBOX));
+```
+
+#### Cobranças
+
+```php
+$phpay = PHPay::gateway(new AsaasGateway(TOKEN_ASAAS_SANDBOX))->charge();
+
+/* cria a cobrança, criando também o cliente */
+$phpay->setCharge($cobranca)->setCustomer($cliente)->create();
+
+/* reaproveita um cliente que já existe — evita cadastro duplicado */
+$phpay->setCharge($cobranca)->setCustomerId('cus_000006337812')->create();
-$gateway = new MercadoPagoGateway(ACCESS_TOKEN_MERCADO_PAGO);
+$phpay->find($id);
+$phpay->getAll();
+$phpay->setQueryParams(['limit' => 2])->getAll();
+$phpay->update($id, $dados);
+$phpay->destroy($id);
+$phpay->restore($id);
+
+$phpay->getStatus($id);
+$phpay->getDigitableLine($id);
+$phpay->getQrCodePix($id);
-$gateway->isSandbox(); // true para tokens TEST-
+$phpay->confirmReceipt($id, [
+ 'paymentDate' => date('Y-m-d'),
+ 'value' => 100.00,
+ 'notifyCustomer' => true,
+]);
+$phpay->undoConfirmReceipt($id);
```
-Cobrança via Pix — aqui o Pix é forma de pagamento, não um recurso à parte:
+#### Clientes
```php
-$charge = PHPay::gateway($gateway)
- ->charge()
+$phpay = PHPay::gateway(new AsaasGateway(TOKEN_ASAAS_SANDBOX));
+
+$cliente = $phpay->customer(['name' => 'Mário Lucas', 'cpfCnpj' => '12345678901'])->create();
+
+$phpay->customer()->find($cliente['id']);
+$phpay->customer()->setFilter(['cpfCnpj' => '12345678901'])->getAll();
+$phpay->customer(['name' => 'Novo Nome'])->update($cliente['id']);
+$phpay->customer()->getNotifications($cliente['id']);
+$phpay->customer()->destroy($cliente['id']);
+$phpay->customer()->restore($cliente['id']);
+```
+
+#### Assinaturas
+
+```php
+$phpay = PHPay::gateway(new AsaasGateway(TOKEN_ASAAS_SANDBOX))->subscription();
+
+$phpay->setCustomer($cliente)->create([
+ 'billingType' => 'BOLETO',
+ 'value' => 100,
+ 'nextDueDate' => '2026-04-09',
+ 'cycle' => 'MONTHLY',
+]);
+
+/* ou com um cliente existente */
+$phpay->setCustomerId('cus_000006337812')->create([...]);
+```
+
+#### Webhooks e chaves Pix
+
+```php
+$phpay = PHPay::gateway(new AsaasGateway(TOKEN_ASAAS_SANDBOX));
+
+/* webhooks com CRUD completo — exclusividade do Asaas */
+$phpay->webhook(WEBHOOK)->create();
+$phpay->webhook()->getAll();
+$phpay->webhook()->update($id, $dados);
+$phpay->webhook()->destroy($id);
+
+/* chaves Pix e QR Code estático */
+$chave = $phpay->pix()->createKey();
+$phpay->pix()->getAll();
+$phpay->pix()->staticQrCode(['addressKey' => $chave['key'], 'value' => 25.00]);
+$phpay->pix()->destroy($chave['id']);
+```
+
+### Mercado Pago
+
+Ambiente pelo prefixo do token. `POST /v1/payments` exige o header
+`X-Idempotency-Key`: o PHPay gera uma chave por chamada, e
+`setIdempotencyKey()` deixa você fixar a sua — assim um retry da mesma operação
+de negócio não cria duas cobranças.
+
+```php
+use PHPay\MercadoPago\Enums\PaymentMethodEnum;
+use PHPay\MercadoPago\MercadoPagoGateway;
+
+$gateway = new MercadoPagoGateway(ACCESS_TOKEN);
+
+$cobranca = PHPay::gateway($gateway)->charge()
->setCharge([
- 'transaction_amount' => 100.00,
+ 'transaction_amount' => 100.50,
'payment_method_id' => PaymentMethodEnum::PIX->value,
- 'description' => 'Cobrança de teste',
'notification_url' => 'https://exemplo.test/webhook/mercadopago',
])
->setPayer(['email' => 'comprador@exemplo.test'])
->setIdempotencyKey('pedido-123456')
->create();
-$phpay->getPixCode($charge['id']); // código copia-e-cola
-```
-
-`POST /v1/payments` exige o header `X-Idempotency-Key`. O PHPay gera uma chave
-por chamada; passe a sua com `setIdempotencyKey()` para que um retry da mesma
-operação de negócio não gere duas cobranças.
-
-Para conferir contra o sandbox de verdade — algo que teste com HTTP mockado não
-prova — rode a checagem de conformidade com um token de teste:
-
-```bash
-MP_ACCESS_TOKEN='TEST-...' php examples/mercadopago/sandbox-check.php
+$phpay->getPixCode($cobranca['id']);
```
-O script recusa credenciais de produção e nunca imprime o token.
-
Assinaturas usam `/preapproval`, com ou sem plano associado:
```php
@@ -301,7 +402,7 @@ $phpay->setPayerEmail('comprador@exemplo.test')->create([
'auto_recurring' => [
'frequency' => 1,
'frequency_type' => 'months',
- 'transaction_amount' => 100.00,
+ 'transaction_amount' => 100.50,
'currency_id' => 'BRL',
],
]);
@@ -312,27 +413,26 @@ $phpay->setPayerEmail('comprador@exemplo.test')
->create(['back_url' => 'https://exemplo.test/retorno']);
```
-## 🏦 PagBank (PagSeguro)
+> O recurso `Customer` do Mercado Pago existe para cartões salvos — **não** é
+> pré-requisito para cobrar, já que o pagamento carrega `payer.email` direto. A
+> API também não oferece exclusão de cliente.
+
+### PagBank
-Duas particularidades que o PHPay resolve por você.
+Duas particularidades, ambas resolvidas pela biblioteca.
**Duas APIs em hosts diferentes.** Pedidos vivem em `api.pagseguro.com`,
-assinaturas em `api.assinaturas.pagseguro.com`. Cada recurso boota o client
-da API certa — você não precisa saber disso.
+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.
+**Pix não é uma cobrança.** Entra como `qr_codes` do pedido, e só um por pedido.
+A conta precisa ter uma chave Pix ativa.
```php
use PHPay\PagBank\PagBankGateway;
$phpay = PHPay::gateway(new PagBankGateway(TOKEN_PAGBANK_SANDBOX))->charge();
-```
-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
@@ -340,14 +440,14 @@ $pedido = $phpay
->setNotificationUrls(['https://exemplo.test/webhook/pagbank'])
->create();
-$phpay->getPixCode($pedido['id']); // copia-e-cola, de qr_codes[0].text
+$phpay->getPixCode($pedido['id']); // de qr_codes[0].text
```
Cartão e boleto, aí sim, vão em `charges`:
```php
$phpay
- ->setCustomer($customer)
+ ->setCustomer($cliente)
->addItem('Camiseta', 5990, 2)
->setCharges([[
'reference_id' => 'cobranca-1',
@@ -373,71 +473,193 @@ $phpay->setPlan($plano['id'])
->create();
```
-Para conferir contra o sandbox de verdade:
+### Pagar.me
+
+Autenticação Basic com a secret key, ambiente pelo prefixo da chave, e Pix como
+forma de pagamento do pedido.
+
+```php
+use PHPay\PagarMe\PagarMeGateway;
+
+$gateway = new PagarMeGateway(SECRET_KEY_PAGARME);
+
+$pedido = PHPay::gateway($gateway)->charge()
+ ->setCustomer([
+ 'name' => 'Mário Lucas',
+ 'email' => 'fale@phpay.io',
+ 'document' => '12345678901',
+ ])
+ ->addItem('Assinatura PHPay', 10050) // R$ 100,50
+ ->setPix(1800) // expira em 30 minutos
+ ->create();
+
+$phpay->getPixCode($pedido['id']); // de charges[0].last_transaction.qr_code
+```
+
+O cancelamento é `DELETE`, com valor opcional para estorno parcial:
+
+```php
+$phpay->cancel($cobrancaId, 2500); // estorna R$ 25,00
+$phpay->cancel($cobrancaId); // estorna tudo
+```
+
+Assinaturas aceitam um plano ou a recorrência no próprio payload:
+
+```php
+$phpay = PHPay::gateway($gateway)->subscription();
+
+$plano = $phpay->createPlan([
+ 'name' => 'Plano PHPay Mensal',
+ 'interval' => 'month',
+ 'interval_count' => 1,
+ 'items' => [[
+ 'name' => 'Mensalidade',
+ 'quantity' => 1,
+ 'pricing_scheme' => ['price' => 4990], // R$ 49,90
+ ]],
+]);
+
+$phpay->setPlan($plano['id'])
+ ->setCustomerId($clienteId)
+ ->create(['payment_method' => 'pix']);
+```
+
+#### Consultando entregas de webhook
+
+O Pagar.me deixa ler e reenviar os eventos que já despachou. Isso **não** é a
+capacidade `SupportsWebhooks` — o cadastro dos endpoints é no dashboard — então
+vive no gateway concreto, não na facade:
+
+```php
+$gateway->webhookDeliveries()->setFilter(['size' => 10])->getAll();
+$gateway->webhookDeliveries()->resend($hookId);
+```
+
+É assim que o modelo de capacidades abre espaço para o que só um gateway
+oferece: quem segura `PagarMeGateway` alcança, quem tipa uma capacidade não.
+
+### Efí
+
+Só cobranças, por enquanto. O gateway **não faz chamada de rede no construtor**
+— a autorização acontece na primeira vez que o token é necessário, e uma vez só.
+
+```php
+use PHPay\Efi\EfiGateway;
+
+$gateway = new EfiGateway(CLIENT_ID, CLIENT_SECRET);
+
+$cobranca = PHPay::gateway($gateway)->charge([
+ 'value' => 10050, // R$ 100,50 — o Efí usa centavos
+ 'description' => 'Assinatura PHPay',
+ 'expire_at' => date('Y-m-d', strtotime('+3 days')),
+])
+ ->setCustomer(['name' => 'Mário Lucas', 'cpf_cnpj' => '12345678901'])
+ ->create();
+```
+
+---
+
+## Exemplos executáveis
+
+O diretório [`examples/`](./examples) traz scripts prontos por gateway. Copie o
+`credentials.example.php` para `credentials.php`, preencha, e rode:
```bash
-PAGBANK_TOKEN='...' php examples/pagbank/sandbox-check.php
+php examples/asaas/charges.php
```
-## 📝 Roadmap
+Dois gateways têm também uma **checagem de conformidade**, que roda contra o
+sandbox de verdade e relata cada operação. Teste com HTTP mockado prova que a
+biblioteca monta o payload que decidimos; isto prova que o gateway o aceita:
+
+```bash
+MP_ACCESS_TOKEN='TEST-...' php examples/mercadopago/sandbox-check.php
+PAGBANK_TOKEN='...' php examples/pagbank/sandbox-check.php
+```
+
+Os dois recusam credenciais de produção e nunca imprimem o token.
+
+---
+
+## Migrando da v1
+
+A v2.0.0 tem breaking changes — a principal é que falhas passaram a ser exceção
+em vez de array de erro. O de-para completo, quebra por quebra, está em
+**[UPGRADE.md](./UPGRADE.md)**.
+
+Dois pontos merecem auditoria de quem vem da v1:
+
+1. Falhas que antes voltavam como array e passavam despercebidas agora
+ **interrompem o fluxo**. É o comportamento correto, mas expõe caminhos que
+ nunca foram exercitados.
+2. Integrações que chamavam `setCustomer()` em laço **provavelmente acumularam
+ clientes duplicados** no gateway.
-- Definições de Arquitetura ✅
-- Tratamento de erros por exceção ✅
-- Testes com HTTP mockado ✅
-- CI no GitHub Actions ✅
-- Domínios ✅
-- Documentação ✍️
-- Site 🕛
-- Gateways ✍️
+---
- - Asaas.
+## Roadmap
- - Cobranças ✅
- - Clientes ✅
- - Webhook ✅
- - Pix (chaves e QR Code estático) ✅
- - Assinaturas ✍️ (criação pronta; listar/atualizar/cancelar pendentes)
+### Plataforma
- - Mercado Pago.
+| Item | Status |
+| --- | :---: |
+| Definições de arquitetura | ✅ |
+| Capacidades por gateway | ✅ |
+| Tratamento de erros por exceção | ✅ |
+| Testes com HTTP mockado | ✅ |
+| CI no GitHub Actions | ✅ |
+| Guia de migração | ✅ |
+| Documentação | ✍️ |
+| Site | 🕛 |
- - Cobranças ✅
- - Clientes ✅
- - Assinaturas ✅
- - Webhook — sem CRUD por API
- - Pix ✅ (como forma de pagamento)
+### Cobertura por gateway
- - PagBank.
+| | Asaas | Mercado Pago | PagBank | Pagar.me | Efí |
+| --- | :---: | :---: | :---: | :---: | :---: |
+| Cobranças | ✅ | ✅ | ✅ | ✅ | ✅ |
+| Clientes | ✅ | ✅ | ✅ | ✅ | 🕥 |
+| Assinaturas | ✍️ | ✅ | ✅ | ✅ | 🕥 |
+| Webhooks | ✅ | — | — | leitura ✅ | 🕥 |
+| Pix | ✅ | ✅ | ✅ | ✅ | 🕥 |
- - Cobranças ✅
- - Assinantes ✅
- - Assinaturas ✅ (com planos)
- - Webhook — sem CRUD por API
- - Pix ✅ (como QR Code do pedido)
+**✅** pronto · **✍️** parcial · **🕥** planejado · **—** não existe na API do gateway
- - Efí.
+> Assinaturas do Asaas: criação pronta; listar, atualizar e cancelar pendentes.
- - Autorização ✅
- - Cobranças ✅
- - Clientes 🕥
- - Webhook 🕥
- - Assinaturas 🕥
- - Pix 🕥
+---
-- Lançamento v2.0.0 🚀 (contém breaking changes — veja a seção de tratamento de erros)
+## Contribuindo
-## 🌟 Contribuindo
+Leia o [manual de contribuição](./CONTRIBUTING.md). Ele cobre o ambiente de
+desenvolvimento, o gate de qualidade e as convenções do projeto.
+
+```bash
+composer install
+composer test # Pint + Pest + PHPStan nível 9
+```
-Para contribuir com o PHPay, implementando melhorias e novos gateways de pagamento,
-leia nosso manual de contribuição. [MANUAL DE CONTRIBUIÇÃO PHPAY](./CONTRIBUTING.md)
+Nenhum teste pode acessar a rede: os recursos aceitam um `GuzzleHttp\Client`
+injetado, e a suíte usa mocks.
-## 📄 Licença
+---
-Este projeto está licenciado sob a MIT License. Consulte o arquivo [LICENSE](./LICENSE.md) para mais detalhes.
+## Segurança
-## 🤝 Contato
+Encontrou uma vulnerabilidade? Não abra issue pública — siga a
+[política de segurança](./.github/SECURITY.md).
-💻 GitHub: [Mário Lucas](https://github.com/mariolucasdev)
+Esta é uma biblioteca de pagamentos: nunca logue, imprima ou versione tokens,
+`access_token`, `clientSecret` ou CPF/CNPJ reais.
-📧 Email: fale@phpay.io
+---
-🎉 Comece a usar o PHPay e simplifique suas integrações com gateways de pagamento!
+## Licença
+
+MIT. Veja [LICENSE.md](./LICENSE.md).
+
+---
+
+
+ Feito por Mário Lucas ·
+ fale@phpay.io
+
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/efi/charges.php b/examples/efi/charges.php
index 6af872f..00ff989 100644
--- a/examples/efi/charges.php
+++ b/examples/efi/charges.php
@@ -14,8 +14,12 @@
'cpf_cnpj' => CPF_CNPJ,
];
+/*
+| Atenção: o Efí trabalha com valores em CENTAVOS, como inteiro.
+| R$ 100,50 é 10050 — passar 100.50 cobraria um real.
+*/
$charge = [
- 'value' => 100.00,
+ 'value' => 10050,
'description' => 'Teste de fatura',
'expire_at' => date('Y-m-d', strtotime('+1 day')),
];
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');