diff --git a/.gitignore b/.gitignore index 41addb3..a97bf67 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ .idea/ # credenciais dos exemplos (nunca versionar) +examples/abacatepay/credentials.php examples/asaas/credentials.php examples/efi/credentials.php examples/mercadopago/credentials.php diff --git a/CLAUDE.md b/CLAUDE.md index 525ef7c..2a580cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,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**, **PagBank** e **Pagar.me** (clientes, cobranças, -assinaturas), **Cielo** (cobranças e recorrência), **Rede** e **Efí** (cobranças). +assinaturas), **Cielo** (cobranças e recorrência), **AbacatePay** (clientes e +cobranças), **Rede** 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 +145,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`. +- **AbacatePay** — host único e **sem prefixo de chave**: não dá para derivar o + ambiente da credencial, então **não existe `isSandbox()`** — inventar convenção aqui + seria mentira. A resposta da cobrança traz `devMode`, e é isso que `isDevMode()` lê. + Cobrança é montada por **produtos**, não por valor; preço em centavos com mínimo de + 100. `frequency` só aceita `ONE_TIME`, por isso sem assinaturas. Cupons são extra do + gateway concreto, como o `webhookDeliveries()` do Pagar.me. - **Rede** — **host de OAuth separado do host de API**, e o caminho do token muda por ambiente (`oauth2/token` vs `redelabs/oauth2/token`) — está em `RedeEnvironment`, fora do trait, para o gateway ler sem puxar os verbos HTTP. **O token expira**: é o diff --git a/README.md b/README.md index 61897f5..5467ce7 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ - [Pagar.me](#pagarme) - [Cielo](#cielo) - [Rede](#rede) + - [AbacatePay](#abacatepay) - [Efí](#efí) - [Exemplos executáveis](#exemplos-executáveis) - [Migrando da v1](#migrando-da-v1) @@ -69,13 +70,13 @@ Trocar de gateway é trocar a linha do construtor. ## Gateways suportados -| Capacidade | Interface | Asaas | Mercado Pago | PagBank | Pagar.me | Cielo | Rede | Efí | -| --- | --- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | -| Clientes | `SupportsCustomers` | ✅ | ✅ | ✅ | ✅ | — | — | — | -| Cobranças | `SupportsCharges` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Assinaturas | `SupportsSubscriptions` | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | -| Webhooks | `SupportsWebhooks` | ✅ | — | — | — | — | — | — | -| Chaves Pix | `SupportsPixKeys` | ✅ | — | — | — | — | — | — | +| Capacidade | Interface | Asaas | Mercado Pago | PagBank | Pagar.me | Cielo | Rede | Abacate | Efí | +| --- | --- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | +| Clientes | `SupportsCustomers` | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | — | +| Cobranças | `SupportsCharges` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Assinaturas | `SupportsSubscriptions` | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | +| Webhooks | `SupportsWebhooks` | ✅ | — | — | — | — | — | — | — | +| Chaves Pix | `SupportsPixKeys` | ✅ | — | — | — | — | — | — | — | Duas colunas merecem explicação, porque a ausência de ✅ **não** quer dizer que o gateway não aceita Pix ou não manda webhook: @@ -239,6 +240,7 @@ que cada um faz em vez de inventar um padrão: | **Rede** | `$sandbox` no construtor — troca **as duas** URLs e o caminho do token | | **Mercado Pago** | Prefixo do token (`TEST-`); host único, sem `$sandbox` | | **Pagar.me** | Prefixo da chave (`sk_test_`); host único, sem `$sandbox` | +| **AbacatePay** | Pela chave usada; host único, **sem prefixo** — a cobrança informa em `devMode` | Nos dois últimos, `isSandbox()` diz em qual ambiente você está: @@ -263,6 +265,7 @@ em vez de falhar: | **Pagar.me** | Centavos (inteiro) | `10050` | | **Cielo** | Centavos (inteiro) | `10050` | | **Rede** | Centavos (inteiro) | `10050` | +| **AbacatePay** | Centavos (inteiro, mín. 100) | `10050` | | **Efí** | Centavos (inteiro) | `10050` | Nos gateways que usam centavos, o PHPay **recusa valor decimal na validação**, @@ -654,6 +657,58 @@ $gateway->authorization()->hasValidToken(); $gateway->authorization()->forget(); ``` +### AbacatePay + +Pix nativo, e **mesmo assim não declara `SupportsPixKeys`** — aquela capacidade +é sobre gerenciar chaves e QR Code estático, coisa de PSP. Aqui Pix é o método +de pagamento da cobrança, e o único aceito. + +Também não declara assinaturas: a API documenta `ONE_TIME` como a única +frequência aceita. + +A cobrança é um **link de pagamento montado a partir de produtos**, não de um +valor solto — o total vem calculado em `amount`. Preço em centavos, com +**mínimo de 100** (R$ 1,00) por produto. + +```php +use PHPay\AbacatePay\AbacatePayGateway; + +$gateway = new AbacatePayGateway(ABACATEPAY_TOKEN); + +$cobranca = PHPay::gateway($gateway)->charge() + ->setCustomer([ + 'name' => 'Mário Lucas', + 'email' => 'fale@phpay.io', + 'cellphone' => '(11) 4002-8922', + 'taxId' => '12345678901', + ]) + ->addProduct('prod-1234', 'Assinatura PHPay', 2000) // R$ 20,00 + ->setUrls( + completionUrl: 'https://exemplo.test/obrigado', + returnUrl: 'https://exemplo.test/loja' + ) + ->create(); + +$phpay->getPaymentUrl($cobranca); // o link para onde mandar o cliente +$phpay->isDevMode($cobranca); // em qual ambiente a cobrança nasceu +``` + +O `externalId` do produto é o id **no seu sistema** — o AbacatePay cria o +produto do lado dele a partir dele, então precisa ser único. + +#### Cupons de desconto + +Nenhum outro gateway da biblioteca tem isso, então não é capacidade: vive no +gateway concreto, como o `webhookDeliveries()` do Pagar.me. + +```php +$gateway->coupons()->create([ + 'code' => 'PHPAY10', + 'discountKind' => 'PERCENTAGE', + 'discount' => 10, +]); +``` + ### Efí Só cobranças, por enquanto. O gateway **não faz chamada de rede no construtor** @@ -730,13 +785,13 @@ Dois pontos merecem auditoria de quem vem da v1: ### Cobertura por gateway -| | Asaas | Mercado Pago | PagBank | Pagar.me | Cielo | Rede | Efí | -| --- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | -| Cobranças | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Clientes | ✅ | ✅ | ✅ | ✅ | — | — | 🕥 | -| Assinaturas | ✍️ | ✅ | ✅ | ✅ | ✅ | — | 🕥 | -| Webhooks | ✅ | — | — | leitura ✅ | — | — | 🕥 | -| Pix | ✅ | ✅ | ✅ | ✅ | ✅ | 🕥 | 🕥 | +| | Asaas | Mercado Pago | PagBank | Pagar.me | Cielo | Rede | Abacate | Efí | +| --- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | +| Cobranças | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Clientes | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | 🕥 | +| Assinaturas | ✍️ | ✅ | ✅ | ✅ | ✅ | — | — | 🕥 | +| Webhooks | ✅ | — | — | leitura ✅ | — | — | — | 🕥 | +| Pix | ✅ | ✅ | ✅ | ✅ | ✅ | 🕥 | ✅ | 🕥 | **✅** pronto · **✍️** parcial · **🕥** planejado · **—** não existe na API do gateway diff --git a/composer.json b/composer.json index 749ccb5..0eec23b 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,8 @@ "PHPay\\PagBank\\": "src/Gateways/PagBank/", "PHPay\\PagarMe\\": "src/Gateways/PagarMe/", "PHPay\\Cielo\\": "src/Gateways/Cielo/", - "PHPay\\Rede\\": "src/Gateways/Rede/" + "PHPay\\Rede\\": "src/Gateways/Rede/", + "PHPay\\AbacatePay\\": "src/Gateways/AbacatePay/" } }, "autoload-dev": { diff --git a/examples/abacatepay/charges.php b/examples/abacatepay/charges.php new file mode 100644 index 0000000..f98f66d --- /dev/null +++ b/examples/abacatepay/charges.php @@ -0,0 +1,79 @@ +charge(); + +$cliente = [ + 'name' => NAME, + 'email' => EMAIL, + 'cellphone' => CELLPHONE, + 'taxId' => TAX_ID, +]; + +try { + /* + | A cobrança é um link de pagamento montado a partir de PRODUTOS, não de um + | valor solto — o total vem calculado na resposta, em `amount`. + | + | Preço em CENTAVOS, com mínimo de 100 (R$ 1,00) por produto. + | O externalId é o id do produto no SEU sistema, e precisa ser único. + */ + $cobranca = $phpay + ->setCustomer($cliente) + ->addProduct('prod-1234', 'Assinatura PHPay', 2000) /* R$ 20,00 */ + ->addProduct('prod-5678', 'Camiseta', 5990, 2, 'Tamanho M') /* R$ 59,90 cada */ + ->setUrls( + completionUrl: 'https://exemplo.test/obrigado', + returnUrl: 'https://exemplo.test/loja' + ) + ->create(); + + /* o link para onde você manda o cliente */ + echo $phpay->getPaymentUrl($cobranca) . PHP_EOL; + + /* + | Como a URL é única para os dois ambientes, é a cobrança que diz em qual + | deles ela nasceu. + */ + var_dump($phpay->isDevMode($cobranca)); + + /* listagem */ + $phpay->setQueryParams(['limit' => 10])->getAll(); + + /* cliente avulso, para reaproveitar em cobranças futuras */ + $criado = PHPay::gateway($gateway)->customer($cliente)->create(); + + PHPay::gateway($gateway)->charge() + ->setCustomerId((string) ($criado['data']['id'] ?? '')) + ->addProduct('prod-1234', 'Assinatura PHPay', 2000) + ->setUrls('https://exemplo.test/obrigado', 'https://exemplo.test/loja') + ->create(); + + /* + | Cupons de desconto não passam pela facade: nenhum outro gateway da + | biblioteca tem isso, então vive no gateway concreto. + */ + $gateway->coupons()->create([ + 'code' => 'PHPAY10', + 'discountKind' => 'PERCENTAGE', + 'discount' => 10, + 'maxRedeems' => 100, + ]); + + $gateway->coupons()->getAll(); +} catch (PHPayException $exception) { + echo $exception->getMessage() . PHP_EOL; +} diff --git a/examples/abacatepay/credentials.example.php b/examples/abacatepay/credentials.example.php new file mode 100644 index 0000000..c96d3fd --- /dev/null +++ b/examples/abacatepay/credentials.example.php @@ -0,0 +1,17 @@ + $customer + * @return Customer + */ + public function customer(array $customer = []): Customer + { + return new Customer($this->token, $customer, $this->client); + } + + /** + * charge + * + * @return Charge + */ + public function charge(): Charge + { + return new Charge($this->token, $this->client); + } + + /** + * discount coupons + * + * @return Coupon + */ + public function coupons(): Coupon + { + return new Coupon($this->token, $this->client); + } +} diff --git a/src/Gateways/AbacatePay/Enums/BillingFrequencyEnum.php b/src/Gateways/AbacatePay/Enums/BillingFrequencyEnum.php new file mode 100644 index 0000000..2ebad45 --- /dev/null +++ b/src/Gateways/AbacatePay/Enums/BillingFrequencyEnum.php @@ -0,0 +1,14 @@ + $customer + * @return Customer + */ + public function customer(array $customer = []): Customer; + + /** + * get resource charge from gateway. + * + * @return Charge + */ + public function charge(): Charge; + + /** + * discount coupons. + * + * gateway specific: no other gateway in the library has them, so this is + * not a capability and is reachable only from the concrete gateway. + * + * @return Coupon + */ + public function coupons(): Coupon; +} diff --git a/src/Gateways/AbacatePay/Requests/AbacatePayBillingRequest.php b/src/Gateways/AbacatePay/Requests/AbacatePayBillingRequest.php new file mode 100644 index 0000000..3bdd674 --- /dev/null +++ b/src/Gateways/AbacatePay/Requests/AbacatePayBillingRequest.php @@ -0,0 +1,159 @@ + $billing + * @return void + * @throws ValidationException + */ + public static function validate(array $billing): void + { + $messages = self::messages(); + + self::validateFrequencyAndMethods($billing, $messages); + self::validateProducts($billing, $messages); + self::validateUrls($billing, $messages); + self::validateCustomer($billing, $messages); + } + + /** + * @param array $billing + * @param object{frequency: string, methods: string, products: string, productFields: string, price: string, returnUrl: string, completionUrl: string, customer: string} $messages + * @return void + * @throws ValidationException + */ + private static function validateFrequencyAndMethods(array $billing, object $messages): void + { + if (!isset($billing['frequency']) + || !is_string($billing['frequency']) + || !BillingFrequencyEnum::tryFrom($billing['frequency']) instanceof BillingFrequencyEnum + ) { + throw ValidationException::make('AbacatePay', $messages->frequency); + } + + $methods = $billing['methods'] ?? null; + + if (!is_array($methods) || count($methods) !== 1) { + throw ValidationException::make('AbacatePay', $messages->methods); + } + + foreach ($methods as $method) { + if (!is_string($method) || !BillingMethodEnum::tryFrom($method) instanceof BillingMethodEnum) { + throw ValidationException::make('AbacatePay', $messages->methods); + } + } + } + + /** + * @param array $billing + * @param object{frequency: string, methods: string, products: string, productFields: string, price: string, returnUrl: string, completionUrl: string, customer: string} $messages + * @return void + * @throws ValidationException + */ + private static function validateProducts(array $billing, object $messages): void + { + $products = $billing['products'] ?? null; + + if (!is_array($products) || empty($products)) { + throw ValidationException::make('AbacatePay', $messages->products); + } + + foreach ($products as $product) { + if (!is_array($product)) { + throw ValidationException::make('AbacatePay', $messages->products); + } + + foreach (['externalId', 'name'] as $field) { + if (!isset($product[$field]) || !is_string($product[$field]) || trim($product[$field]) === '') { + throw ValidationException::make('AbacatePay', $messages->productFields); + } + } + + if (!isset($product['quantity']) || !is_int($product['quantity']) || $product['quantity'] < 1) { + throw ValidationException::make('AbacatePay', $messages->productFields); + } + + if (!isset($product['price']) + || !is_int($product['price']) + || $product['price'] < self::MINIMUM_PRICE + ) { + throw ValidationException::make('AbacatePay', $messages->price); + } + } + } + + /** + * @param array $billing + * @param object{frequency: string, methods: string, products: string, productFields: string, price: string, returnUrl: string, completionUrl: string, customer: string} $messages + * @return void + * @throws ValidationException + */ + private static function validateUrls(array $billing, object $messages): void + { + foreach (['returnUrl' => 'returnUrl', 'completionUrl' => 'completionUrl'] as $field => $message) { + if (!isset($billing[$field]) + || !is_string($billing[$field]) + || filter_var($billing[$field], FILTER_VALIDATE_URL) === false + ) { + throw ValidationException::make('AbacatePay', $messages->{$message}); + } + } + } + + /** + * @param array $billing + * @param object{frequency: string, methods: string, products: string, productFields: string, price: string, returnUrl: string, completionUrl: string, customer: string} $messages + * @return void + * @throws ValidationException + */ + private static function validateCustomer(array $billing, object $messages): void + { + $hasCustomerId = isset($billing['customerId']) + && is_string($billing['customerId']) + && $billing['customerId'] !== ''; + + if ($hasCustomerId) { + return; + } + + $customer = $billing['customer'] ?? null; + + if (!is_array($customer)) { + throw ValidationException::make('AbacatePay', $messages->customer); + } + + AbacatePayCustomerRequest::validate($customer); + } + + /** + * messages for validation + * + * @return object{frequency: string, methods: string, products: string, productFields: string, price: string, returnUrl: string, completionUrl: string, customer: string} + */ + public static function messages(): object + { + return (object) [ + 'frequency' => 'O campo frequency aceita apenas ONE_TIME — o AbacatePay não tem cobrança recorrente.', + 'methods' => 'O campo methods aceita exatamente um método, e hoje só PIX é suportado.', + 'products' => 'A cobrança precisa de ao menos um produto em products. Use addProduct().', + 'productFields' => 'Cada produto precisa de externalId, name e quantity — o externalId é o id do produto no SEU sistema, e precisa ser único.', + 'price' => 'O campo products[].price é obrigatório e deve ser um inteiro em CENTAVOS de no mínimo 100 (R$ 1,00). O AbacatePay não aceita valor decimal: R$ 20,00 é 2000.', + 'returnUrl' => 'O campo returnUrl é obrigatório e deve ser uma URL válida — para onde o cliente volta se desistir.', + 'completionUrl' => 'O campo completionUrl é obrigatório e deve ser uma URL válida — para onde o cliente vai após pagar.', + 'customer' => 'A cobrança precisa de customerId ou de um customer completo. Use setCustomerId() ou setCustomer().', + ]; + } +} diff --git a/src/Gateways/AbacatePay/Requests/AbacatePayCustomerRequest.php b/src/Gateways/AbacatePay/Requests/AbacatePayCustomerRequest.php new file mode 100644 index 0000000..c5cb752 --- /dev/null +++ b/src/Gateways/AbacatePay/Requests/AbacatePayCustomerRequest.php @@ -0,0 +1,60 @@ + $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('AbacatePay', $messages->name); + } + + if (!isset($customer['email']) + || !is_string($customer['email']) + || filter_var($customer['email'], FILTER_VALIDATE_EMAIL) === false + ) { + throw ValidationException::make('AbacatePay', $messages->email); + } + + if (!isset($customer['cellphone']) + || !is_string($customer['cellphone']) + || trim($customer['cellphone']) === '' + ) { + throw ValidationException::make('AbacatePay', $messages->cellphone); + } + + if (!isset($customer['taxId']) || !is_string($customer['taxId']) || trim($customer['taxId']) === '') { + throw ValidationException::make('AbacatePay', $messages->taxId); + } + } + + /** + * messages for validation + * + * @return object{name: string, email: string, cellphone: string, taxId: string} + */ + public static function messages(): object + { + return (object) [ + 'name' => 'O campo name é obrigatório e deve ser uma string não vazia.', + 'email' => 'O campo email é obrigatório e deve ser um e-mail válido.', + 'cellphone' => 'O campo cellphone é obrigatório — o AbacatePay exige celular do cliente.', + 'taxId' => 'O campo taxId é obrigatório — é o CPF ou CNPJ do cliente.', + ]; + } +} diff --git a/src/Gateways/AbacatePay/Resources/Charge/Charge.php b/src/Gateways/AbacatePay/Resources/Charge/Charge.php new file mode 100644 index 0000000..320b0fd --- /dev/null +++ b/src/Gateways/AbacatePay/Resources/Charge/Charge.php @@ -0,0 +1,260 @@ + + */ + private array $billing = []; + + /** + * @var array + */ + private array $queryParams = []; + + /** + * construct + * + * @param string $token + * @param Client|null $client injected http client, mainly for tests + */ + public function __construct( + private string $token, + ?Client $client = null, + ) { + $this->client = $client ?? $this->clientAbacatePayBoot(); + } + + /** + * set the whole billing payload + * + * @param array $billing + * @return ChargeInterface + */ + public function setBilling(array $billing): ChargeInterface + { + $this->billing = $billing; + + return $this; + } + + /** + * attach an existing customer to the billing + * + * @param string $customerId + * @return ChargeInterface + */ + public function setCustomerId(string $customerId): ChargeInterface + { + $this->billing['customerId'] = $customerId; + + unset($this->billing['customer']); + + return $this; + } + + /** + * attach a customer created along with the billing + * + * @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->billing['customer'] = $customer; + + unset($this->billing['customerId']); + + return $this; + } + + /** + * set the products being charged + * + * @param array $products + * @return ChargeInterface + */ + public function setProducts(array $products): ChargeInterface + { + $this->billing['products'] = $products; + + return $this; + } + + /** + * append a single product to the billing. + * + * the externalId is the product id in YOUR system: AbacatePay creates the + * product on its side from it, so it has to be unique. + * + * @param string $externalId + * @param string $name + * @param int $price price per unit in cents, minimum 100 + * @param int $quantity + * @param string|null $description + * @return ChargeInterface + */ + public function addProduct( + string $externalId, + string $name, + int $price, + int $quantity = 1, + ?string $description = null + ): ChargeInterface { + $products = $this->billing['products'] ?? []; + + if (!is_array($products)) { + $products = []; + } + + $product = [ + 'externalId' => $externalId, + 'name' => $name, + 'quantity' => $quantity, + 'price' => $price, + ]; + + if ($description !== null) { + $product['description'] = $description; + } + + $products[] = $product; + + $this->billing['products'] = $products; + + return $this; + } + + /** + * set where the customer goes after paying, and if they give up. + * + * both are required by the API — a billing is a hosted payment link. + * + * @param string $completionUrl + * @param string $returnUrl + * @return ChargeInterface + */ + public function setUrls(string $completionUrl, string $returnUrl): ChargeInterface + { + $this->billing['completionUrl'] = $completionUrl; + $this->billing['returnUrl'] = $returnUrl; + + return $this; + } + + /** + * set list query params + * + * @param array $queryParams + * @return ChargeInterface + */ + public function setQueryParams(array $queryParams): ChargeInterface + { + $this->queryParams = $queryParams; + + return $this; + } + + /** + * create the billing + * + * @return array + * @throws ValidationException|ApiException + */ + public function create(): array + { + $this->billing['frequency'] = $this->billing['frequency'] ?? BillingFrequencyEnum::ONE_TIME->value; + $this->billing['methods'] = $this->billing['methods'] ?? [BillingMethodEnum::PIX->value]; + + AbacatePayBillingRequest::validate($this->billing); + + return $this->post('billing/create', $this->billing); + } + + /** + * list billings + * + * @return array + * @throws ApiException + */ + public function getAll(): array + { + return $this->get('billing/list', $this->queryParams); + } + + /** + * get the payment link of a created billing + * + * @param array $billing the response of create() + * @return string|null + */ + public function getPaymentUrl(array $billing): ?string + { + $data = $this->payload($billing); + + $url = $data['url'] ?? null; + + return is_string($url) ? $url : null; + } + + /** + * whether a created billing was made with a dev mode key. + * + * the gateway has a single host and the key carries no prefix, so this is + * the only honest way to know which environment answered. + * + * @param array $billing the response of create() + * @return bool|null + */ + public function isDevMode(array $billing): ?bool + { + $data = $this->payload($billing); + + $devMode = $data['devMode'] ?? null; + + return is_bool($devMode) ? $devMode : null; + } + + /** + * unwrap the `data` envelope the API answers with, when it is there. + * + * @param array $response + * @return array + */ + private function payload(array $response): array + { + $data = $response['data'] ?? null; + + return is_array($data) ? $data : $response; + } +} diff --git a/src/Gateways/AbacatePay/Resources/Charge/Interface/ChargeInterface.php b/src/Gateways/AbacatePay/Resources/Charge/Interface/ChargeInterface.php new file mode 100644 index 0000000..32b64e2 --- /dev/null +++ b/src/Gateways/AbacatePay/Resources/Charge/Interface/ChargeInterface.php @@ -0,0 +1,103 @@ + $billing + * @return ChargeInterface + */ + public function setBilling(array $billing): ChargeInterface; + + /** + * attach an existing customer to the billing + * + * @param string $customerId + * @return ChargeInterface + */ + public function setCustomerId(string $customerId): ChargeInterface; + + /** + * attach a customer created along with the billing + * + * @param array $customer + * @return ChargeInterface + */ + public function setCustomer(array $customer): ChargeInterface; + + /** + * set the products being charged + * + * @param array $products + * @return ChargeInterface + */ + public function setProducts(array $products): ChargeInterface; + + /** + * append a single product to the billing + * + * @param string $externalId id of the product in your own system + * @param string $name + * @param int $price price per unit in cents, minimum 100 + * @param int $quantity + * @param string|null $description + * @return ChargeInterface + */ + public function addProduct( + string $externalId, + string $name, + int $price, + int $quantity = 1, + ?string $description = null + ): ChargeInterface; + + /** + * set where the customer goes after paying, and if they give up + * + * @param string $completionUrl + * @param string $returnUrl + * @return ChargeInterface + */ + public function setUrls(string $completionUrl, string $returnUrl): ChargeInterface; + + /** + * set list query params + * + * @param array $queryParams + * @return ChargeInterface + */ + public function setQueryParams(array $queryParams): ChargeInterface; + + /** + * create the billing + * + * @return array + */ + public function create(): array; + + /** + * list billings + * + * @return array + */ + public function getAll(): array; + + /** + * get the payment link of a created billing + * + * @param array $billing + * @return string|null + */ + public function getPaymentUrl(array $billing): ?string; + + /** + * whether a created billing was made with a dev mode key + * + * @param array $billing + * @return bool|null + */ + public function isDevMode(array $billing): ?bool; +} diff --git a/src/Gateways/AbacatePay/Resources/Coupon/Coupon.php b/src/Gateways/AbacatePay/Resources/Coupon/Coupon.php new file mode 100644 index 0000000..9f8444a --- /dev/null +++ b/src/Gateways/AbacatePay/Resources/Coupon/Coupon.php @@ -0,0 +1,82 @@ + + */ + private array $queryParams = []; + + /** + * construct + * + * @param string $token + * @param Client|null $client injected http client, mainly for tests + */ + public function __construct( + private string $token, + ?Client $client = null, + ) { + $this->client = $client ?? $this->clientAbacatePayBoot(); + } + + /** + * create coupon + * + * @param array $coupon + * @return array + * @throws ApiException + */ + public function create(array $coupon): array + { + return $this->post('coupon/create', $coupon); + } + + /** + * list coupons + * + * @return array + * @throws ApiException + */ + public function getAll(): array + { + return $this->get('coupon/list', $this->queryParams); + } + + /** + * set list query params + * + * @param array $queryParams + * @return CouponInterface + */ + public function setQueryParams(array $queryParams): CouponInterface + { + $this->queryParams = $queryParams; + + return $this; + } +} diff --git a/src/Gateways/AbacatePay/Resources/Coupon/Interface/CouponInterface.php b/src/Gateways/AbacatePay/Resources/Coupon/Interface/CouponInterface.php new file mode 100644 index 0000000..6755449 --- /dev/null +++ b/src/Gateways/AbacatePay/Resources/Coupon/Interface/CouponInterface.php @@ -0,0 +1,29 @@ + $coupon + * @return array + */ + public function create(array $coupon): array; + + /** + * list coupons + * + * @return array + */ + public function getAll(): array; + + /** + * set list query params + * + * @param array $queryParams + * @return CouponInterface + */ + public function setQueryParams(array $queryParams): CouponInterface; +} diff --git a/src/Gateways/AbacatePay/Resources/Customer/Customer.php b/src/Gateways/AbacatePay/Resources/Customer/Customer.php new file mode 100644 index 0000000..b8235fe --- /dev/null +++ b/src/Gateways/AbacatePay/Resources/Customer/Customer.php @@ -0,0 +1,85 @@ + + */ + private array $queryParams = []; + + /** + * construct + * + * @param string $token + * @param array $customer + * @param Client|null $client injected http client, mainly for tests + */ + public function __construct( + private string $token, + private array $customer = [], + ?Client $client = null, + ) { + $this->client = $client ?? $this->clientAbacatePayBoot(); + } + + /** + * create customer + * + * @return array + * @throws ValidationException|ApiException + */ + public function create(): array + { + AbacatePayCustomerRequest::validate($this->customer); + + return $this->post('customer/create', $this->customer); + } + + /** + * list customers + * + * @return array + * @throws ApiException + */ + public function getAll(): array + { + return $this->get('customer/list', $this->queryParams); + } + + /** + * set list query params + * + * @param array $queryParams + * @return CustomerInterface + */ + public function setQueryParams(array $queryParams): CustomerInterface + { + $this->queryParams = $queryParams; + + return $this; + } +} diff --git a/src/Gateways/AbacatePay/Resources/Customer/Interface/CustomerInterface.php b/src/Gateways/AbacatePay/Resources/Customer/Interface/CustomerInterface.php new file mode 100644 index 0000000..98ca99f --- /dev/null +++ b/src/Gateways/AbacatePay/Resources/Customer/Interface/CustomerInterface.php @@ -0,0 +1,28 @@ + + */ + public function create(): array; + + /** + * list customers + * + * @return array + */ + public function getAll(): array; + + /** + * set list query params + * + * @param array $queryParams + * @return CustomerInterface + */ + public function setQueryParams(array $queryParams): CustomerInterface; +} diff --git a/src/Gateways/AbacatePay/Traits/HasAbacatePayClient.php b/src/Gateways/AbacatePay/Traits/HasAbacatePayClient.php new file mode 100644 index 0000000..6d9c7ef --- /dev/null +++ b/src/Gateways/AbacatePay/Traits/HasAbacatePayClient.php @@ -0,0 +1,57 @@ + $this->baseUri(), + 'headers' => [ + 'content-type' => 'application/json', + 'accept' => 'application/json', + 'user-agent' => 'PHPay', + 'Authorization' => "Bearer {$this->token}", + ], + ]); + } + + /** + * base uri + * + * @return string + */ + protected function baseUri(): string + { + return 'https://api.abacatepay.com/v1/'; + } + + /** + * gateway name used in exception messages. + * + * @return string + */ + protected function gatewayName(): string + { + return 'AbacatePay'; + } +} diff --git a/tests/Pest.php b/tests/Pest.php index a197e81..b95b57d 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -146,3 +146,15 @@ function redeOauthClient(array $responses, array &$history = []): Client { return mockClient($responses, $history, 'https://rl7-sandbox-api.useredecloud.com.br/'); } + +/** + * mock client already pointed at the AbacatePay host. + * + * @param array $responses + * @param array $history filled with the recorded transactions + * @return Client + */ +function abacateClient(array $responses, array &$history = []): Client +{ + return mockClient($responses, $history, 'https://api.abacatepay.com/v1/'); +} diff --git a/tests/Unit/AbacatePay/AbacatePayGatewayTest.php b/tests/Unit/AbacatePay/AbacatePayGatewayTest.php new file mode 100644 index 0000000..4786b7f --- /dev/null +++ b/tests/Unit/AbacatePay/AbacatePayGatewayTest.php @@ -0,0 +1,69 @@ +toBe([ + Capability::CUSTOMERS, + Capability::CHARGES, + ]); +})->group('abacatepay'); + +it('não declara chaves pix, mesmo sendo um gateway pix-nativo', function () { + $phpay = PHPay::gateway(new AbacatePayGateway('token', abacateClient([]))); + + expect($phpay->supports(Capability::PIX_KEYS))->toBeFalse(); + + expect(fn () => $phpay->pix()) + ->toThrow(NotImplementedException::class, 'AbacatePay não suporta chaves Pix'); +})->group('abacatepay'); + +it('não declara assinaturas, porque a api só aceita ONE_TIME', function () { + $phpay = PHPay::gateway(new AbacatePayGateway('token', abacateClient([]))); + + expect($phpay->supports(Capability::SUBSCRIPTIONS))->toBeFalse() + ->and($phpay->supports(Capability::WEBHOOKS))->toBeFalse(); + + expect(fn () => $phpay->subscription())->toThrow(NotImplementedException::class); +})->group('abacatepay'); + +it('expõe cupons só no gateway concreto, fora da facade', function () { + $gateway = new AbacatePayGateway('token', abacateClient([])); + + expect($gateway->coupons())->toBeInstanceOf(Coupon::class) + ->and(method_exists(PHPay::class, 'coupons'))->toBeFalse(); +})->group('abacatepay'); + +it('devolve a instância de cada recurso suportado', function () { + $phpay = PHPay::gateway(new AbacatePayGateway('token', abacateClient([]))); + + expect($phpay->customer([]))->toBeInstanceOf(Customer::class) + ->and($phpay->charge())->toBeInstanceOf(Charge::class); +})->group('abacatepay'); + +it('usa host único com bearer, sem flag de sandbox', function () { + $charge = (new AbacatePayGateway('minha-chave'))->charge(); + + $property = new ReflectionProperty($charge, 'client'); + $client = $property->getValue($charge); + + expect((string) $client->getConfig('base_uri'))->toBe('https://api.abacatepay.com/v1/') + ->and($client->getConfig('headers')['Authorization'])->toBe('Bearer minha-chave') + ->and(method_exists(AbacatePayGateway::class, 'isSandbox'))->toBeFalse(); +})->group('abacatepay'); + +it('não faz chamada de rede ao instanciar o gateway', function () { + $history = []; + + new AbacatePayGateway('token', abacateClient([], $history)); + + expect($history)->toBeEmpty(); +})->group('abacatepay'); diff --git a/tests/Unit/AbacatePay/ChargeTest.php b/tests/Unit/AbacatePay/ChargeTest.php new file mode 100644 index 0000000..59450f2 --- /dev/null +++ b/tests/Unit/AbacatePay/ChargeTest.php @@ -0,0 +1,176 @@ + + */ +function abacateCustomer(): array +{ + return [ + 'name' => 'Mário Lucas', + 'email' => 'fale@phpay.io', + 'cellphone' => '(11) 4002-8922', + 'taxId' => '12345678901', + ]; +} + +/** + * @param Charge $charge + * @return Charge + */ +function abacateBillingValido(Charge $charge): Charge +{ + return $charge + ->setCustomer(abacateCustomer()) + ->addProduct('prod-1', 'Assinatura PHPay', 2000) + ->setUrls('https://exemplo.test/obrigado', 'https://exemplo.test/loja'); +} + +it('cria a cobrança com frequência e método preenchidos por padrão', function () { + $history = []; + $client = abacateClient([jsonResponse(['data' => ['id' => 'bill_1', 'url' => 'https://pay.test/x']])], $history); + + abacateBillingValido(new Charge('token', $client))->create(); + + $body = recordedBody($history); + + expect((string) $history[0]['request']->getUri())->toEndWith('/v1/billing/create') + ->and($body['frequency'])->toBe('ONE_TIME') + ->and($body['methods'])->toBe(['PIX']) + ->and($body['products'][0]['price'])->toBe(2000) + ->and($body['products'][0]['externalId'])->toBe('prod-1'); +})->group('abacatepay'); + +it('reaproveita o cliente quando o array traz um id', function () { + $history = []; + $client = abacateClient([jsonResponse(['data' => []])], $history); + + (new Charge('token', $client)) + ->setCustomer(['id' => 'cust_existente']) + ->addProduct('prod-1', 'Item', 500) + ->setUrls('https://exemplo.test/ok', 'https://exemplo.test/volta') + ->create(); + + $body = recordedBody($history); + + expect($history)->toHaveCount(1) + ->and($body['customerId'])->toBe('cust_existente') + ->and($body)->not->toHaveKey('customer'); +})->group('abacatepay'); + +it('lê o link de pagamento e o devMode da resposta', function () { + $charge = new Charge('token', abacateClient([])); + + $resposta = ['data' => ['id' => 'bill_1', 'url' => 'https://pay.test/abc', 'devMode' => true]]; + + expect($charge->getPaymentUrl($resposta))->toBe('https://pay.test/abc') + ->and($charge->isDevMode($resposta))->toBeTrue(); +})->group('abacatepay'); + +it('funciona mesmo sem o envelope data', function () { + $charge = new Charge('token', abacateClient([])); + + expect($charge->getPaymentUrl(['url' => 'https://pay.test/abc']))->toBe('https://pay.test/abc') + ->and($charge->isDevMode(['id' => 'bill_1']))->toBeNull(); +})->group('abacatepay'); + +it('acumula produtos e aceita descrição opcional', function () { + $history = []; + $client = abacateClient([jsonResponse(['data' => []])], $history); + + (new Charge('token', $client)) + ->setCustomerId('cust_1') + ->addProduct('prod-1', 'Camiseta', 5990, 2) + ->addProduct('prod-2', 'Caneca', 1990, 1, 'Caneca de cerâmica') + ->setUrls('https://exemplo.test/ok', 'https://exemplo.test/volta') + ->create(); + + $produtos = recordedBody($history)['products']; + + expect($produtos)->toHaveCount(2) + ->and($produtos[0])->not->toHaveKey('description') + ->and($produtos[1]['description'])->toBe('Caneca de cerâmica') + ->and($produtos[0]['quantity'])->toBe(2); +})->group('abacatepay'); + +it('lista cobranças, clientes e cupons nos endpoints certos', function () { + $history = []; + $client = abacateClient([jsonResponse([]), jsonResponse([]), jsonResponse([])], $history); + + (new Charge('token', $client))->setQueryParams(['limit' => 5])->getAll(); + (new Customer('token', [], $client))->getAll(); + (new Coupon('token', $client))->getAll(); + + expect((string) $history[0]['request']->getUri())->toContain('/billing/list') + ->and((string) $history[0]['request']->getUri())->toContain('limit=5') + ->and((string) $history[1]['request']->getUri())->toEndWith('/customer/list') + ->and((string) $history[2]['request']->getUri())->toEndWith('/coupon/list'); +})->group('abacatepay'); + +it('recusa preço abaixo do mínimo de R$ 1,00', function () { + $history = []; + $client = abacateClient([jsonResponse([])], $history); + + expect(fn () => (new Charge('token', $client)) + ->setCustomerId('cust_1') + ->addProduct('prod-1', 'Item', 99) + ->setUrls('https://exemplo.test/ok', 'https://exemplo.test/volta') + ->create()) + ->toThrow(ValidationException::class, 'no mínimo 100'); + + expect($history)->toBeEmpty(); +})->group('abacatepay'); + +it('valida a cobrança antes de chamar a API', function (callable $montar, string $esperado) { + $history = []; + $client = abacateClient([jsonResponse([])], $history); + + expect(fn () => $montar(new Charge('token', $client))->create()) + ->toThrow(ValidationException::class, $esperado); + + expect($history)->toBeEmpty(); +})->with([ + 'sem produtos' => [ + fn (Charge $c) => $c->setCustomerId('cust_1')->setUrls('https://a.test/ok', 'https://a.test/volta'), + 'ao menos um produto', + ], + 'sem urls' => [ + fn (Charge $c) => $c->setCustomerId('cust_1')->addProduct('p1', 'Item', 500), + 'returnUrl', + ], + 'sem cliente' => [ + fn (Charge $c) => $c->addProduct('p1', 'Item', 500)->setUrls('https://a.test/ok', 'https://a.test/volta'), + 'customerId ou de um customer completo', + ], + 'preço decimal' => [ + fn (Charge $c) => $c->setCustomerId('cust_1') + ->setProducts([['externalId' => 'p1', 'name' => 'Item', 'quantity' => 1, 'price' => 20.00]]) + ->setUrls('https://a.test/ok', 'https://a.test/volta'), + 'CENTAVOS', + ], + 'frequência recorrente' => [ + fn (Charge $c) => $c->setBilling([ + 'frequency' => 'MONTHLY', 'methods' => ['PIX'], 'customerId' => 'cust_1', + 'products' => [['externalId' => 'p1', 'name' => 'i', 'quantity' => 1, 'price' => 500]], + 'returnUrl' => 'https://a.test/v', 'completionUrl' => 'https://a.test/o', + ]), + 'não tem cobrança recorrente', + ], +])->group('abacatepay'); + +it('exige celular e documento do cliente', function () { + $history = []; + $client = abacateClient([jsonResponse([])], $history); + + expect(fn () => (new Customer('token', ['name' => 'X', 'email' => 'a@b.com', 'taxId' => '123'], $client))->create()) + ->toThrow(ValidationException::class, 'cellphone'); + + expect(fn () => (new Customer('token', ['name' => 'X', 'email' => 'a@b.com', 'cellphone' => '11'], $client))->create()) + ->toThrow(ValidationException::class, 'taxId'); + + expect($history)->toBeEmpty(); +})->group('abacatepay');