From 18db50c776478dee1feebeed47b10649346e0135 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rio=20Lucas?= Date: Mon, 21 Sep 2026 04:04:42 -0300 Subject: [PATCH] PHPAY-89: feat(woovi): adicionar o gateway Woovi/OpenPix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nono gateway, e o segundo da biblioteca a declarar as CINCO capacidades. Isso importa além da cobertura. Até aqui só o Asaas populava as cinco, o que deixava em aberto se o modelo de capacidades introduzido em PHPAY-72 generalizava ou tinha sido modelado em cima de um caso único. Dois gateways independentes, de empresas independentes, preenchendo o mesmo contrato é evidência de que a abstração descreve o domínio. SupportsCustomers api/v1/customer SupportsCharges api/v1/charge SupportsSubscriptions api/v1/subscriptions SupportsWebhooks api/openpix/v1/webhook SupportsPixKeys api/v1/pix-keys e api/v1/pixQrCode Particularidades: - O AppID vai cru no header Authorization, sem Bearer nem Basic. É o único assim. - O sandbox tem domínio próprio, api.woovi-sandbox.com, em vez de subdomínio ou caminho de produção. - O webhook fica em api/openpix/v1/ enquanto os demais recursos ficam em api/v1/. Não é engano: é herança da fusão das duas marcas, e está anotado no código para ninguém "corrigir" depois. - Todo objeto é endereçável pelo correlationID, o id no sistema de quem integra, em vez do id do gateway. Nenhum outro gateway da biblioteca oferece isso, então find() e destroy() aceitam os dois e a documentação mostra o caminho pelo correlationID. - Valores em centavos inteiros. O README transpõe as duas tabelas de capacidade: com nove gateways, colunas por gateway não renderiam. Agora gateway é linha e capacidade é coluna, o que também escala melhor daqui pra frente. 240 testes no total. Os do Woovi cobrem as cinco capacidades declaradas, a paridade com o Asaas, o AppID sem esquema, o domínio de sandbox, o prefixo próprio do webhook, e o endereçamento por correlationID. --- .gitignore | 1 + CLAUDE.md | 6 + README.md | 99 ++++++++-- composer.json | 3 +- examples/woovi/charges.php | 70 +++++++ examples/woovi/credentials.example.php | 14 ++ src/Gateways/Woovi/Enums/ChargeStatusEnum.php | 12 ++ src/Gateways/Woovi/Enums/PixKeyTypeEnum.php | 17 ++ .../Woovi/Interface/WooviGatewayInterface.php | 62 ++++++ .../Woovi/Requests/WooviChargeRequest.php | 53 ++++++ .../Woovi/Requests/WooviCustomerRequest.php | 57 ++++++ .../Woovi/Requests/WooviPixKeyRequest.php | 71 +++++++ .../Requests/WooviSubscriptionRequest.php | 52 +++++ .../Woovi/Requests/WooviWebhookRequest.php | 44 +++++ .../Woovi/Resources/Charge/Charge.php | 180 ++++++++++++++++++ .../Charge/Interface/ChargeInterface.php | 77 ++++++++ .../Woovi/Resources/Customer/Customer.php | 93 +++++++++ .../Customer/Interface/CustomerInterface.php | 36 ++++ .../Resources/Pix/Interface/PixInterface.php | 57 ++++++ src/Gateways/Woovi/Resources/Pix/Pix.php | 147 ++++++++++++++ .../Interface/SubscriptionInterface.php | 38 ++++ .../Resources/Subscription/Subscription.php | 100 ++++++++++ .../Webhook/Interface/WebhookInterface.php | 37 ++++ .../Woovi/Resources/Webhook/Webhook.php | 109 +++++++++++ src/Gateways/Woovi/Traits/HasWooviClient.php | 59 ++++++ src/Gateways/Woovi/WooviGateway.php | 90 +++++++++ tests/Pest.php | 12 ++ tests/Unit/Woovi/ResourcesTest.php | 167 ++++++++++++++++ tests/Unit/Woovi/WooviGatewayTest.php | 66 +++++++ 29 files changed, 1814 insertions(+), 15 deletions(-) create mode 100644 examples/woovi/charges.php create mode 100644 examples/woovi/credentials.example.php create mode 100644 src/Gateways/Woovi/Enums/ChargeStatusEnum.php create mode 100644 src/Gateways/Woovi/Enums/PixKeyTypeEnum.php create mode 100644 src/Gateways/Woovi/Interface/WooviGatewayInterface.php create mode 100644 src/Gateways/Woovi/Requests/WooviChargeRequest.php create mode 100644 src/Gateways/Woovi/Requests/WooviCustomerRequest.php create mode 100644 src/Gateways/Woovi/Requests/WooviPixKeyRequest.php create mode 100644 src/Gateways/Woovi/Requests/WooviSubscriptionRequest.php create mode 100644 src/Gateways/Woovi/Requests/WooviWebhookRequest.php create mode 100644 src/Gateways/Woovi/Resources/Charge/Charge.php create mode 100644 src/Gateways/Woovi/Resources/Charge/Interface/ChargeInterface.php create mode 100644 src/Gateways/Woovi/Resources/Customer/Customer.php create mode 100644 src/Gateways/Woovi/Resources/Customer/Interface/CustomerInterface.php create mode 100644 src/Gateways/Woovi/Resources/Pix/Interface/PixInterface.php create mode 100644 src/Gateways/Woovi/Resources/Pix/Pix.php create mode 100644 src/Gateways/Woovi/Resources/Subscription/Interface/SubscriptionInterface.php create mode 100644 src/Gateways/Woovi/Resources/Subscription/Subscription.php create mode 100644 src/Gateways/Woovi/Resources/Webhook/Interface/WebhookInterface.php create mode 100644 src/Gateways/Woovi/Resources/Webhook/Webhook.php create mode 100644 src/Gateways/Woovi/Traits/HasWooviClient.php create mode 100644 src/Gateways/Woovi/WooviGateway.php create mode 100644 tests/Unit/Woovi/ResourcesTest.php create mode 100644 tests/Unit/Woovi/WooviGatewayTest.php diff --git a/.gitignore b/.gitignore index a97bf67..c06e5a9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules/ # credenciais dos exemplos (nunca versionar) examples/abacatepay/credentials.php +examples/woovi/credentials.php examples/asaas/credentials.php examples/efi/credentials.php examples/mercadopago/credentials.php diff --git a/CLAUDE.md b/CLAUDE.md index 2a580cc..3995369 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,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`. +- **Woovi/OpenPix** — **segundo gateway com as cinco capacidades**, junto com o Asaas. + AppID vai **cru** no `Authorization`, sem esquema. Sandbox tem **domínio próprio** + (`api.woovi-sandbox.com`). O webhook fica em `api/openpix/v1/` enquanto os demais + recursos ficam em `api/v1/` — herança da fusão das marcas, não erro. Todo objeto é + endereçável pelo `correlationID` (id do sistema de quem integra), então `find()` e + `destroy()` aceitam os dois ids. Valores em centavos. - **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ê. diff --git a/README.md b/README.md index 5467ce7..ca63b8c 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ - [Cielo](#cielo) - [Rede](#rede) - [AbacatePay](#abacatepay) + - [Woovi/OpenPix](#wooviopenpix) - [Efí](#efí) - [Exemplos executáveis](#exemplos-executáveis) - [Migrando da v1](#migrando-da-v1) @@ -70,13 +71,20 @@ Trocar de gateway é trocar a linha do construtor. ## Gateways suportados -| Capacidade | Interface | Asaas | Mercado Pago | PagBank | Pagar.me | Cielo | Rede | Abacate | Efí | -| --- | --- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | -| Clientes | `SupportsCustomers` | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | — | -| Cobranças | `SupportsCharges` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Assinaturas | `SupportsSubscriptions` | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | -| Webhooks | `SupportsWebhooks` | ✅ | — | — | — | — | — | — | — | -| Chaves Pix | `SupportsPixKeys` | ✅ | — | — | — | — | — | — | — | +| Gateway | Clientes | Cobranças | Assinaturas | Webhooks | Chaves Pix | +| --- | :---: | :---: | :---: | :---: | :---: | +| **Asaas** | ✅ | ✅ | ✅ | ✅ | ✅ | +| **Woovi/OpenPix** | ✅ | ✅ | ✅ | ✅ | ✅ | +| **Mercado Pago** | ✅ | ✅ | ✅ | — | — | +| **PagBank** | ✅ | ✅ | ✅ | — | — | +| **Pagar.me** | ✅ | ✅ | ✅ | — | — | +| **AbacatePay** | ✅ | ✅ | — | — | — | +| **Cielo** | — | ✅ | ✅ | — | — | +| **Rede** | — | ✅ | — | — | — | +| **Efí** | — | ✅ | — | — | — | + +As interfaces correspondentes são `SupportsCustomers`, `SupportsCharges`, +`SupportsSubscriptions`, `SupportsWebhooks` e `SupportsPixKeys`. Duas colunas merecem explicação, porque a ausência de ✅ **não** quer dizer que o gateway não aceita Pix ou não manda webhook: @@ -240,6 +248,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` | +| **Woovi** | `$sandbox` no construtor — o sandbox tem **domínio próprio** | | **AbacatePay** | Pela chave usada; host único, **sem prefixo** — a cobrança informa em `devMode` | Nos dois últimos, `isSandbox()` diz em qual ambiente você está: @@ -266,6 +275,7 @@ em vez de falhar: | **Cielo** | Centavos (inteiro) | `10050` | | **Rede** | Centavos (inteiro) | `10050` | | **AbacatePay** | Centavos (inteiro, mín. 100) | `10050` | +| **Woovi** | Centavos (inteiro) | `10050` | | **Efí** | Centavos (inteiro) | `10050` | Nos gateways que usam centavos, o PHPay **recusa valor decimal na validação**, @@ -709,6 +719,63 @@ $gateway->coupons()->create([ ]); ``` +### Woovi/OpenPix + +**O segundo gateway com as cinco capacidades**, ao lado do Asaas — e o que +confirma que o modelo descreve o domínio, não um fornecedor: são duas empresas +independentes, com APIs independentes, preenchendo o mesmo contrato. + +Sendo PSP Pix-nativo, ele gerencia chaves e QR Code estático de verdade. + +```php +use PHPay\Woovi\Enums\PixKeyTypeEnum; +use PHPay\Woovi\WooviGateway; + +$phpay = PHPay::gateway(new WooviGateway(WOOVI_APP_ID)); + +/* chaves Pix da conta */ +$phpay->pix()->createKey(PixKeyTypeEnum::RANDOM); +$phpay->pix()->getAll(); + +/* consulta uma chave antes de pagar */ +$phpay->pix()->verifyKey('fale@phpay.io'); + +/* QR Code estático, com ou sem valor */ +$phpay->pix()->staticQrCode('Caixa 1'); +$phpay->pix()->staticQrCode('Caixa 2', 2500); +``` + +Três particularidades: + +**O AppID vai cru no `Authorization`** — sem `Bearer`, sem `Basic`. + +**O sandbox tem domínio próprio**: `api.woovi-sandbox.com` contra +`api.openpix.com.br`. + +**Todo objeto é endereçável pelo `correlationID`**, o id no *seu* sistema — +nenhum outro gateway da biblioteca oferece isso: + +```php +$cobranca = $phpay->charge() + ->setCorrelationId('pedido-1') + ->setCustomer(['name' => 'Mário Lucas', 'email' => 'fale@phpay.io']) + ->create(10050); // R$ 100,50 + +$phpay->charge()->getPixCode($cobranca); +$phpay->charge()->find('pedido-1'); // pelo SEU id, não pelo do gateway +``` + +Webhooks têm CRUD por API — junto com o Asaas, os únicos: + +```php +$phpay->webhook(['name' => 'PHPay', 'url' => 'https://exemplo.test/webhook'])->create(); +$phpay->webhook()->getAll(); +``` + +> Repare que o webhook fica em `api/openpix/v1/`, enquanto os demais recursos +> ficam em `api/v1/` — herança da fusão das duas marcas. O PHPay trata isso +> internamente. + ### Efí Só cobranças, por enquanto. O gateway **não faz chamada de rede no construtor** @@ -785,13 +852,17 @@ Dois pontos merecem auditoria de quem vem da v1: ### Cobertura por gateway -| | Asaas | Mercado Pago | PagBank | Pagar.me | Cielo | Rede | Abacate | Efí | -| --- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | -| Cobranças | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Clientes | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | 🕥 | -| Assinaturas | ✍️ | ✅ | ✅ | ✅ | ✅ | — | — | 🕥 | -| Webhooks | ✅ | — | — | leitura ✅ | — | — | — | 🕥 | -| Pix | ✅ | ✅ | ✅ | ✅ | ✅ | 🕥 | ✅ | 🕥 | +| Gateway | Cobranças | Clientes | Assinaturas | Webhooks | Pix | +| --- | :---: | :---: | :---: | :---: | :---: | +| **Asaas** | ✅ | ✅ | ✍️ | ✅ | ✅ | +| **Woovi/OpenPix** | ✅ | ✅ | ✅ | ✅ | ✅ | +| **Mercado Pago** | ✅ | ✅ | ✅ | — | ✅ | +| **PagBank** | ✅ | ✅ | ✅ | — | ✅ | +| **Pagar.me** | ✅ | ✅ | ✅ | leitura ✅ | ✅ | +| **AbacatePay** | ✅ | ✅ | — | — | ✅ | +| **Cielo** | ✅ | — | ✅ | — | ✅ | +| **Rede** | ✅ | — | — | — | 🕥 | +| **Efí** | ✅ | 🕥 | 🕥 | 🕥 | 🕥 | **✅** pronto · **✍️** parcial · **🕥** planejado · **—** não existe na API do gateway diff --git a/composer.json b/composer.json index 0eec23b..e43f991 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,8 @@ "PHPay\\PagarMe\\": "src/Gateways/PagarMe/", "PHPay\\Cielo\\": "src/Gateways/Cielo/", "PHPay\\Rede\\": "src/Gateways/Rede/", - "PHPay\\AbacatePay\\": "src/Gateways/AbacatePay/" + "PHPay\\AbacatePay\\": "src/Gateways/AbacatePay/", + "PHPay\\Woovi\\": "src/Gateways/Woovi/" } }, "autoload-dev": { diff --git a/examples/woovi/charges.php b/examples/woovi/charges.php new file mode 100644 index 0000000..aca9f1d --- /dev/null +++ b/examples/woovi/charges.php @@ -0,0 +1,70 @@ +charge() + ->setCorrelationId('pedido-' . time()) + ->setCustomer(['name' => NAME, 'email' => EMAIL]) + ->create(10050); /* R$ 100,50 */ + + echo $phpay->charge()->getPixCode($cobranca) . PHP_EOL; + + $phpay->charge()->find('pedido-1'); + $phpay->charge()->setQueryParams(['status' => 'ACTIVE'])->getAll(); + + /* + | Chaves Pix. Junto com o Asaas, é o único gateway da biblioteca que + | gerencia chaves de verdade — por ser PSP. + */ + $chave = $phpay->pix()->createKey(PixKeyTypeEnum::RANDOM); + + $phpay->pix()->getAll(); + + /* consulta uma chave de terceiro antes de pagar */ + $phpay->pix()->verifyKey('destinatario@exemplo.test'); + + /* QR Code estático: sem valor, o pagador escolhe quanto pagar */ + $phpay->pix()->staticQrCode('Caixa 1'); + $phpay->pix()->staticQrCode('Mensalidade', 4990, 'mensalidade-2026'); + + /* Webhooks com CRUD por API — também só aqui e no Asaas */ + $phpay->webhook([ + 'name' => 'PHPay', + 'url' => 'https://exemplo.test/webhook/woovi', + ])->create(); + + $phpay->webhook()->getAll(); + + /* Assinatura: cobrança recorrente por Pix */ + $phpay->subscription() + ->setCustomer(['name' => NAME, 'email' => EMAIL]) + ->setDayGenerateCharge(10) + ->create(4990); + + /* Cliente avulso */ + $phpay->customer(['name' => NAME, 'email' => EMAIL])->create(); +} catch (PHPayException $exception) { + echo $exception->getMessage() . PHP_EOL; +} diff --git a/examples/woovi/credentials.example.php b/examples/woovi/credentials.example.php new file mode 100644 index 0000000..fc08a63 --- /dev/null +++ b/examples/woovi/credentials.example.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; + + /** + * get resource webhook from gateway. + * + * @param array $webhook + * @return Webhook + */ + public function webhook(array $webhook = []): Webhook; + + /** + * get resource pix from gateway. + * + * @return Pix + */ + public function pix(): Pix; + + /** + * get resource subscription from gateway. + * + * @return Subscription + */ + public function subscription(): Subscription; +} diff --git a/src/Gateways/Woovi/Requests/WooviChargeRequest.php b/src/Gateways/Woovi/Requests/WooviChargeRequest.php new file mode 100644 index 0000000..c7d8fc4 --- /dev/null +++ b/src/Gateways/Woovi/Requests/WooviChargeRequest.php @@ -0,0 +1,53 @@ + $charge + * @return void + * @throws ValidationException + */ + public static function validate(array $charge): void + { + $messages = self::messages(); + + if (!isset($charge['correlationID']) + || !is_string($charge['correlationID']) + || trim($charge['correlationID']) === '' + ) { + throw ValidationException::make('Woovi', $messages->correlationId); + } + + if (!isset($charge['value']) || !is_int($charge['value']) || $charge['value'] < 1) { + throw ValidationException::make('Woovi', $messages->value); + } + + if (isset($charge['customer'])) { + if (!is_array($charge['customer'])) { + throw ValidationException::make('Woovi', $messages->customer); + } + + WooviCustomerRequest::validate($charge['customer']); + } + } + + /** + * messages for validation + * + * @return object{correlationId: string, value: string, customer: string} + */ + public static function messages(): object + { + return (object) [ + 'correlationId' => 'O campo correlationID é obrigatório — é o identificador da cobrança no SEU sistema, e é por ele que você consulta depois.', + 'value' => 'O campo value é obrigatório e deve ser um inteiro em CENTAVOS maior que zero. O Woovi não aceita valor decimal: R$ 100,50 é 10050.', + 'customer' => 'O campo customer, quando informado, deve ser um array.', + ]; + } +} diff --git a/src/Gateways/Woovi/Requests/WooviCustomerRequest.php b/src/Gateways/Woovi/Requests/WooviCustomerRequest.php new file mode 100644 index 0000000..de617f6 --- /dev/null +++ b/src/Gateways/Woovi/Requests/WooviCustomerRequest.php @@ -0,0 +1,57 @@ + $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('Woovi', $messages->name); + } + + $temEmail = isset($customer['email']) + && is_string($customer['email']) + && filter_var($customer['email'], FILTER_VALIDATE_EMAIL) !== false; + + $temTaxId = isset($customer['taxID']) + && (is_string($customer['taxID']) || is_array($customer['taxID'])); + + $temTelefone = isset($customer['phone']) + && is_string($customer['phone']) + && trim($customer['phone']) !== ''; + + if (!$temEmail && !$temTaxId && !$temTelefone) { + throw ValidationException::make('Woovi', $messages->identificador); + } + + if (isset($customer['email']) && !$temEmail) { + throw ValidationException::make('Woovi', $messages->email); + } + } + + /** + * messages for validation + * + * @return object{name: string, email: string, identificador: 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, quando informado, deve ser um e-mail válido.', + 'identificador' => 'O cliente precisa de ao menos um identificador: email, taxID ou phone.', + ]; + } +} diff --git a/src/Gateways/Woovi/Requests/WooviPixKeyRequest.php b/src/Gateways/Woovi/Requests/WooviPixKeyRequest.php new file mode 100644 index 0000000..22e4471 --- /dev/null +++ b/src/Gateways/Woovi/Requests/WooviPixKeyRequest.php @@ -0,0 +1,71 @@ + $key + * @return void + * @throws ValidationException + */ + public static function validate(array $key): void + { + $messages = self::messages(); + + $type = $key['type'] ?? null; + + if (!is_string($type) || !PixKeyTypeEnum::tryFrom($type) instanceof PixKeyTypeEnum) { + throw ValidationException::make('Woovi', $messages->type); + } + + /* a chave aleatória é gerada pelo banco, então não vem no payload */ + if ($type === PixKeyTypeEnum::RANDOM->value) { + return; + } + + if (!isset($key['key']) || !is_string($key['key']) || trim($key['key']) === '') { + throw ValidationException::make('Woovi', $messages->key); + } + } + + /** + * validate the payload of a static QR Code. + * + * @param array $qrCode + * @return void + * @throws ValidationException + */ + public static function validateStaticQrCode(array $qrCode): void + { + $messages = self::messages(); + + if (!isset($qrCode['name']) || !is_string($qrCode['name']) || trim($qrCode['name']) === '') { + throw ValidationException::make('Woovi', $messages->qrCodeName); + } + + if (isset($qrCode['value']) && (!is_int($qrCode['value']) || $qrCode['value'] < 1)) { + throw ValidationException::make('Woovi', $messages->qrCodeValue); + } + } + + /** + * messages for validation + * + * @return object{type: string, key: string, qrCodeName: string, qrCodeValue: string} + */ + public static function messages(): object + { + return (object) [ + 'type' => 'O campo type é obrigatório e aceita apenas: CPF, CNPJ, EMAIL, PHONE, EVP.', + 'key' => 'O campo key é obrigatório para todo tipo exceto EVP, cuja chave aleatória é gerada pelo banco.', + 'qrCodeName' => 'O QR Code estático precisa de um name para identificá-lo.', + 'qrCodeValue' => 'O campo value do QR Code, quando informado, deve ser um inteiro em CENTAVOS maior que zero. Sem ele, o pagador escolhe o valor.', + ]; + } +} diff --git a/src/Gateways/Woovi/Requests/WooviSubscriptionRequest.php b/src/Gateways/Woovi/Requests/WooviSubscriptionRequest.php new file mode 100644 index 0000000..770a203 --- /dev/null +++ b/src/Gateways/Woovi/Requests/WooviSubscriptionRequest.php @@ -0,0 +1,52 @@ + $subscription + * @return void + * @throws ValidationException + */ + public static function validate(array $subscription): void + { + $messages = self::messages(); + + if (!isset($subscription['value']) || !is_int($subscription['value']) || $subscription['value'] < 1) { + throw ValidationException::make('Woovi', $messages->value); + } + + $customer = $subscription['customer'] ?? null; + + if (!is_array($customer)) { + throw ValidationException::make('Woovi', $messages->customer); + } + + WooviCustomerRequest::validate($customer); + + $dia = $subscription['dayGenerateCharge'] ?? null; + + if ($dia !== null && (!is_int($dia) || $dia < 1 || $dia > 31)) { + throw ValidationException::make('Woovi', $messages->dayGenerateCharge); + } + } + + /** + * messages for validation + * + * @return object{value: string, customer: string, dayGenerateCharge: string} + */ + public static function messages(): object + { + return (object) [ + 'value' => 'O campo value é obrigatório e deve ser um inteiro em CENTAVOS maior que zero.', + 'customer' => 'A assinatura precisa de um customer. Use setCustomer().', + 'dayGenerateCharge' => 'O campo dayGenerateCharge deve ser um inteiro entre 1 e 31 — é o dia do mês em que a cobrança é gerada.', + ]; + } +} diff --git a/src/Gateways/Woovi/Requests/WooviWebhookRequest.php b/src/Gateways/Woovi/Requests/WooviWebhookRequest.php new file mode 100644 index 0000000..9b9ff25 --- /dev/null +++ b/src/Gateways/Woovi/Requests/WooviWebhookRequest.php @@ -0,0 +1,44 @@ + $webhook + * @return void + * @throws ValidationException + */ + public static function validate(array $webhook): void + { + $messages = self::messages(); + + if (!isset($webhook['name']) || !is_string($webhook['name']) || trim($webhook['name']) === '') { + throw ValidationException::make('Woovi', $messages->name); + } + + if (!isset($webhook['url']) + || !is_string($webhook['url']) + || filter_var($webhook['url'], FILTER_VALIDATE_URL) === false + ) { + throw ValidationException::make('Woovi', $messages->url); + } + } + + /** + * messages for validation + * + * @return object{name: string, url: string} + */ + public static function messages(): object + { + return (object) [ + 'name' => 'O campo name é obrigatório — serve para você identificar o webhook.', + 'url' => 'O campo url é obrigatório e deve ser uma URL válida.', + ]; + } +} diff --git a/src/Gateways/Woovi/Resources/Charge/Charge.php b/src/Gateways/Woovi/Resources/Charge/Charge.php new file mode 100644 index 0000000..e040604 --- /dev/null +++ b/src/Gateways/Woovi/Resources/Charge/Charge.php @@ -0,0 +1,180 @@ + + */ + private array $charge = []; + + /** + * @var array + */ + private array $queryParams = []; + + /** + * construct + * + * @param string $appId + * @param bool $sandbox + * @param Client|null $client injected http client, mainly for tests + */ + public function __construct( + private string $appId, + private bool $sandbox = true, + ?Client $client = null, + ) { + $this->client = $client ?? $this->clientWooviBoot(); + } + + /** + * set the whole charge payload + * + * @param array $charge + * @return ChargeInterface + */ + public function setCharge(array $charge): ChargeInterface + { + $this->charge = $charge; + + return $this; + } + + /** + * set the identifier of this charge in your own system. + * + * it is not optional on Woovi: the API requires it, and it is what you + * use to look the charge up later. + * + * @param string $correlationId + * @return ChargeInterface + */ + public function setCorrelationId(string $correlationId): ChargeInterface + { + $this->charge['correlationID'] = $correlationId; + + return $this; + } + + /** + * set the customer of the charge + * + * @param array $customer + * @return ChargeInterface + */ + public function setCustomer(array $customer): ChargeInterface + { + $this->charge['customer'] = $customer; + + return $this; + } + + /** + * set list query params + * + * @param array $queryParams + * @return ChargeInterface + */ + public function setQueryParams(array $queryParams): ChargeInterface + { + $this->queryParams = $queryParams; + + return $this; + } + + /** + * create the charge + * + * @param int $value amount in cents + * @return array + * @throws ValidationException|ApiException + */ + public function create(int $value): array + { + $this->charge['value'] = $value; + $this->charge['correlationID'] = $this->charge['correlationID'] ?? uniqid('phpay_'); + + WooviChargeRequest::validate($this->charge); + + return $this->post('api/v1/charge', $this->charge); + } + + /** + * find a charge by correlationID or by the gateway id + * + * @param string $id + * @return array + * @throws ApiException + */ + public function find(string $id): array + { + return $this->get("api/v1/charge/{$id}"); + } + + /** + * list charges + * + * @return array + * @throws ApiException + */ + public function getAll(): array + { + return $this->get('api/v1/charge', $this->queryParams); + } + + /** + * delete a charge + * + * @param string $id + * @return array + * @throws ApiException + */ + public function destroy(string $id): array + { + return $this->request('DELETE', "api/v1/charge/{$id}"); + } + + /** + * get the Pix copy-and-paste code of a created charge + * + * @param array $charge the response of create() + * @return string|null + */ + public function getPixCode(array $charge): ?string + { + $data = $charge['charge'] ?? $charge; + + if (!is_array($data)) { + return null; + } + + $code = $data['brCode'] ?? null; + + return is_string($code) ? $code : null; + } +} diff --git a/src/Gateways/Woovi/Resources/Charge/Interface/ChargeInterface.php b/src/Gateways/Woovi/Resources/Charge/Interface/ChargeInterface.php new file mode 100644 index 0000000..4374d88 --- /dev/null +++ b/src/Gateways/Woovi/Resources/Charge/Interface/ChargeInterface.php @@ -0,0 +1,77 @@ + $charge + * @return ChargeInterface + */ + public function setCharge(array $charge): ChargeInterface; + + /** + * set the identifier of this charge in your own system + * + * @param string $correlationId + * @return ChargeInterface + */ + public function setCorrelationId(string $correlationId): ChargeInterface; + + /** + * set the customer of the charge + * + * @param array $customer + * @return ChargeInterface + */ + public function setCustomer(array $customer): ChargeInterface; + + /** + * set list query params + * + * @param array $queryParams + * @return ChargeInterface + */ + public function setQueryParams(array $queryParams): ChargeInterface; + + /** + * create the charge + * + * @param int $value amount in cents + * @return array + */ + public function create(int $value): array; + + /** + * find a charge by correlationID or by the gateway id + * + * @param string $id + * @return array + */ + public function find(string $id): array; + + /** + * list charges + * + * @return array + */ + public function getAll(): array; + + /** + * delete a charge + * + * @param string $id + * @return array + */ + public function destroy(string $id): array; + + /** + * get the Pix copy-and-paste code of a created charge + * + * @param array $charge + * @return string|null + */ + public function getPixCode(array $charge): ?string; +} diff --git a/src/Gateways/Woovi/Resources/Customer/Customer.php b/src/Gateways/Woovi/Resources/Customer/Customer.php new file mode 100644 index 0000000..661cd2e --- /dev/null +++ b/src/Gateways/Woovi/Resources/Customer/Customer.php @@ -0,0 +1,93 @@ + + */ + private array $queryParams = []; + + /** + * construct + * + * @param string $appId + * @param array $customer + * @param bool $sandbox + * @param Client|null $client injected http client, mainly for tests + */ + public function __construct( + private string $appId, + private array $customer = [], + private bool $sandbox = true, + ?Client $client = null, + ) { + $this->client = $client ?? $this->clientWooviBoot(); + } + + /** + * create customer + * + * @return array + * @throws ValidationException|ApiException + */ + public function create(): array + { + WooviCustomerRequest::validate($this->customer); + + return $this->post('api/v1/customer', $this->customer); + } + + /** + * find a customer by correlationID or by the gateway id + * + * @param string $id + * @return array + * @throws ApiException + */ + public function find(string $id): array + { + return $this->get("api/v1/customer/{$id}"); + } + + /** + * list customers + * + * @return array + * @throws ApiException + */ + public function getAll(): array + { + return $this->get('api/v1/customer', $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/Woovi/Resources/Customer/Interface/CustomerInterface.php b/src/Gateways/Woovi/Resources/Customer/Interface/CustomerInterface.php new file mode 100644 index 0000000..3f26a65 --- /dev/null +++ b/src/Gateways/Woovi/Resources/Customer/Interface/CustomerInterface.php @@ -0,0 +1,36 @@ + + */ + public function create(): array; + + /** + * find a customer by correlationID or by the gateway id + * + * @param string $id + * @return array + */ + public function find(string $id): 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/Woovi/Resources/Pix/Interface/PixInterface.php b/src/Gateways/Woovi/Resources/Pix/Interface/PixInterface.php new file mode 100644 index 0000000..173c4d6 --- /dev/null +++ b/src/Gateways/Woovi/Resources/Pix/Interface/PixInterface.php @@ -0,0 +1,57 @@ + + */ + public function createKey(PixKeyTypeEnum $type, ?string $key = null): array; + + /** + * list the Pix keys of the account + * + * @return array + */ + public function getAll(): array; + + /** + * look a Pix key up before paying it + * + * @param string $key + * @return array + */ + public function verifyKey(string $key): array; + + /** + * create a static QR Code + * + * @param string $name + * @param int|null $value amount in cents; null lets the payer choose + * @param string|null $correlationId + * @return array + */ + public function staticQrCode(string $name, ?int $value = null, ?string $correlationId = null): array; + + /** + * list static QR Codes + * + * @return array + */ + public function getAllStaticQrCodes(): array; + + /** + * set list query params + * + * @param array $queryParams + * @return PixInterface + */ + public function setQueryParams(array $queryParams): PixInterface; +} diff --git a/src/Gateways/Woovi/Resources/Pix/Pix.php b/src/Gateways/Woovi/Resources/Pix/Pix.php new file mode 100644 index 0000000..37bc3cc --- /dev/null +++ b/src/Gateways/Woovi/Resources/Pix/Pix.php @@ -0,0 +1,147 @@ + + */ + private array $queryParams = []; + + /** + * construct + * + * @param string $appId + * @param bool $sandbox + * @param Client|null $client injected http client, mainly for tests + */ + public function __construct( + private string $appId, + private bool $sandbox = true, + ?Client $client = null, + ) { + $this->client = $client ?? $this->clientWooviBoot(); + } + + /** + * register a Pix key on the account. + * + * PHONE and EMAIL need extra permission on the account. + * + * @param PixKeyTypeEnum $type + * @param string|null $key null for EVP, whose key the bank generates + * @return array + * @throws ValidationException|ApiException + */ + public function createKey(PixKeyTypeEnum $type, ?string $key = null): array + { + $payload = ['type' => $type->value]; + + if ($key !== null) { + $payload['key'] = $key; + } + + WooviPixKeyRequest::validate($payload); + + return $this->post('api/v1/pix-keys', $payload); + } + + /** + * list the Pix keys of the account + * + * @return array + * @throws ApiException + */ + public function getAll(): array + { + return $this->get('api/v1/pix-keys', $this->queryParams); + } + + /** + * look a Pix key up before paying it. + * + * returns the owner data and the pixKeyEndToEndId a payout needs. + * + * @param string $key + * @return array + * @throws ApiException + */ + public function verifyKey(string $key): array + { + return $this->get('api/v1/pix-key-check/' . rawurlencode($key)); + } + + /** + * create a static QR Code + * + * @param string $name + * @param int|null $value amount in cents; null lets the payer choose + * @param string|null $correlationId + * @return array + * @throws ValidationException|ApiException + */ + public function staticQrCode(string $name, ?int $value = null, ?string $correlationId = null): array + { + $payload = ['name' => $name]; + + if ($value !== null) { + $payload['value'] = $value; + } + + if ($correlationId !== null) { + $payload['correlationID'] = $correlationId; + } + + WooviPixKeyRequest::validateStaticQrCode($payload); + + return $this->post('api/v1/pixQrCode', $payload); + } + + /** + * list static QR Codes + * + * @return array + * @throws ApiException + */ + public function getAllStaticQrCodes(): array + { + return $this->get('api/v1/pixQrCode', $this->queryParams); + } + + /** + * set list query params + * + * @param array $queryParams + * @return PixInterface + */ + public function setQueryParams(array $queryParams): PixInterface + { + $this->queryParams = $queryParams; + + return $this; + } +} diff --git a/src/Gateways/Woovi/Resources/Subscription/Interface/SubscriptionInterface.php b/src/Gateways/Woovi/Resources/Subscription/Interface/SubscriptionInterface.php new file mode 100644 index 0000000..19e1b62 --- /dev/null +++ b/src/Gateways/Woovi/Resources/Subscription/Interface/SubscriptionInterface.php @@ -0,0 +1,38 @@ + $customer + * @return SubscriptionInterface + */ + public function setCustomer(array $customer): SubscriptionInterface; + + /** + * set the day of the month the charge is generated + * + * @param int $day + * @return SubscriptionInterface + */ + public function setDayGenerateCharge(int $day): SubscriptionInterface; + + /** + * create the subscription + * + * @param int $value amount in cents + * @return array + */ + public function create(int $value): array; + + /** + * find a subscription by id + * + * @param string $id + * @return array + */ + public function find(string $id): array; +} diff --git a/src/Gateways/Woovi/Resources/Subscription/Subscription.php b/src/Gateways/Woovi/Resources/Subscription/Subscription.php new file mode 100644 index 0000000..28ee532 --- /dev/null +++ b/src/Gateways/Woovi/Resources/Subscription/Subscription.php @@ -0,0 +1,100 @@ + + */ + private array $subscription = []; + + /** + * construct + * + * @param string $appId + * @param bool $sandbox + * @param Client|null $client injected http client, mainly for tests + */ + public function __construct( + private string $appId, + private bool $sandbox = true, + ?Client $client = null, + ) { + $this->client = $client ?? $this->clientWooviBoot(); + } + + /** + * set the customer of the subscription + * + * @param array $customer + * @return SubscriptionInterface + */ + public function setCustomer(array $customer): SubscriptionInterface + { + $this->subscription['customer'] = $customer; + + return $this; + } + + /** + * set the day of the month the charge is generated + * + * @param int $day + * @return SubscriptionInterface + */ + public function setDayGenerateCharge(int $day): SubscriptionInterface + { + $this->subscription['dayGenerateCharge'] = $day; + + return $this; + } + + /** + * create the subscription + * + * @param int $value amount in cents + * @return array + * @throws ValidationException|ApiException + */ + public function create(int $value): array + { + $this->subscription['value'] = $value; + + WooviSubscriptionRequest::validate($this->subscription); + + return $this->post('api/v1/subscriptions', $this->subscription); + } + + /** + * find a subscription by id + * + * @param string $id + * @return array + * @throws ApiException + */ + public function find(string $id): array + { + return $this->get("api/v1/subscriptions/{$id}"); + } +} diff --git a/src/Gateways/Woovi/Resources/Webhook/Interface/WebhookInterface.php b/src/Gateways/Woovi/Resources/Webhook/Interface/WebhookInterface.php new file mode 100644 index 0000000..d6e0578 --- /dev/null +++ b/src/Gateways/Woovi/Resources/Webhook/Interface/WebhookInterface.php @@ -0,0 +1,37 @@ + $webhook + * @return array + */ + public function create(array $webhook = []): array; + + /** + * list webhooks + * + * @return array + */ + public function getAll(): array; + + /** + * delete webhook by id + * + * @param string $id + * @return bool + */ + public function destroy(string $id): bool; + + /** + * set list query params + * + * @param array $queryParams + * @return WebhookInterface + */ + public function setQueryParams(array $queryParams): WebhookInterface; +} diff --git a/src/Gateways/Woovi/Resources/Webhook/Webhook.php b/src/Gateways/Woovi/Resources/Webhook/Webhook.php new file mode 100644 index 0000000..2e358c5 --- /dev/null +++ b/src/Gateways/Woovi/Resources/Webhook/Webhook.php @@ -0,0 +1,109 @@ + + */ + private array $queryParams = []; + + /** + * construct + * + * @param string $appId + * @param array $webhook + * @param bool $sandbox + * @param Client|null $client injected http client, mainly for tests + */ + public function __construct( + private string $appId, + private array $webhook = [], + private bool $sandbox = true, + ?Client $client = null, + ) { + $this->client = $client ?? $this->clientWooviBoot(); + } + + /** + * create webhook + * + * @param array $webhook overrides the payload given to the gateway + * @return array + * @throws ValidationException|ApiException + */ + public function create(array $webhook = []): array + { + if (!empty($webhook)) { + $this->webhook = $webhook; + } + + $this->webhook['isActive'] = $this->webhook['isActive'] ?? true; + + WooviWebhookRequest::validate($this->webhook); + + return $this->post('api/openpix/v1/webhook', ['webhook' => $this->webhook]); + } + + /** + * list webhooks + * + * @return array + * @throws ApiException + */ + public function getAll(): array + { + return $this->get('api/openpix/v1/webhook', $this->queryParams); + } + + /** + * delete webhook by id + * + * @param string $id + * @return bool + * @throws ApiException + */ + public function destroy(string $id): bool + { + return $this->delete("api/openpix/v1/webhook/{$id}"); + } + + /** + * set list query params + * + * @param array $queryParams + * @return WebhookInterface + */ + public function setQueryParams(array $queryParams): WebhookInterface + { + $this->queryParams = $queryParams; + + return $this; + } +} diff --git a/src/Gateways/Woovi/Traits/HasWooviClient.php b/src/Gateways/Woovi/Traits/HasWooviClient.php new file mode 100644 index 0000000..c0cc581 --- /dev/null +++ b/src/Gateways/Woovi/Traits/HasWooviClient.php @@ -0,0 +1,59 @@ + $this->baseUri(), + 'headers' => [ + 'content-type' => 'application/json', + 'accept' => 'application/json', + 'user-agent' => 'PHPay', + 'Authorization' => $this->appId, + ], + ]); + } + + /** + * base uri + * + * sandbox lives on a domain of its own, not on a path or subdomain of + * production. + * + * @return string + */ + protected function baseUri(): string + { + return $this->sandbox + ? 'https://api.woovi-sandbox.com/' + : 'https://api.openpix.com.br/'; + } + + /** + * gateway name used in exception messages. + * + * @return string + */ + protected function gatewayName(): string + { + return 'Woovi'; + } +} diff --git a/src/Gateways/Woovi/WooviGateway.php b/src/Gateways/Woovi/WooviGateway.php new file mode 100644 index 0000000..43d6769 --- /dev/null +++ b/src/Gateways/Woovi/WooviGateway.php @@ -0,0 +1,90 @@ + $customer + * @return Customer + */ + public function customer(array $customer = []): Customer + { + return new Customer($this->appId, $customer, $this->sandbox, $this->client); + } + + /** + * charge + * + * @return Charge + */ + public function charge(): Charge + { + return new Charge($this->appId, $this->sandbox, $this->client); + } + + /** + * webhook + * + * @param array $webhook + * @return Webhook + */ + public function webhook(array $webhook = []): Webhook + { + return new Webhook($this->appId, $webhook, $this->sandbox, $this->client); + } + + /** + * pix + * + * @return Pix + */ + public function pix(): Pix + { + return new Pix($this->appId, $this->sandbox, $this->client); + } + + /** + * subscription + * + * @return Subscription + */ + public function subscription(): Subscription + { + return new Subscription($this->appId, $this->sandbox, $this->client); + } +} diff --git a/tests/Pest.php b/tests/Pest.php index b95b57d..c750cd8 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -158,3 +158,15 @@ function abacateClient(array $responses, array &$history = []): Client { return mockClient($responses, $history, 'https://api.abacatepay.com/v1/'); } + +/** + * mock client already pointed at the Woovi sandbox host. + * + * @param array $responses + * @param array $history filled with the recorded transactions + * @return Client + */ +function wooviClient(array $responses, array &$history = []): Client +{ + return mockClient($responses, $history, 'https://api.woovi-sandbox.com/'); +} diff --git a/tests/Unit/Woovi/ResourcesTest.php b/tests/Unit/Woovi/ResourcesTest.php new file mode 100644 index 0000000..30d919a --- /dev/null +++ b/tests/Unit/Woovi/ResourcesTest.php @@ -0,0 +1,167 @@ + ['brCode' => '00020126...']])], $history); + + (new Charge('app-id', true, $client)) + ->setCorrelationId('pedido-1') + ->setCustomer(['name' => 'Mário Lucas', 'email' => 'fale@phpay.io']) + ->create(10050); + + $body = recordedBody($history); + + expect((string) $history[0]['request']->getUri())->toEndWith('/api/v1/charge') + ->and($body['value'])->toBe(10050) + ->and($body['correlationID'])->toBe('pedido-1'); +})->group('woovi'); + +it('gera correlationID quando não informado', function () { + $history = []; + $client = wooviClient([jsonResponse([])], $history); + + (new Charge('app-id', true, $client))->create(500); + + expect(recordedBody($history)['correlationID'])->toStartWith('phpay_'); +})->group('woovi'); + +it('extrai o copia-e-cola da cobrança', function () { + $charge = new Charge('app-id', true, wooviClient([])); + + expect($charge->getPixCode(['charge' => ['brCode' => '00020126...']]))->toBe('00020126...') + ->and($charge->getPixCode(['brCode' => 'direto']))->toBe('direto') + ->and($charge->getPixCode(['charge' => []]))->toBeNull(); +})->group('woovi'); + +it('endereça a cobrança pelo id do seu sistema', function () { + $history = []; + $client = wooviClient([jsonResponse([]), jsonResponse([])], $history); + + $charge = new Charge('app-id', true, $client); + $charge->find('pedido-1'); + $charge->destroy('pedido-1'); + + expect((string) $history[0]['request']->getUri())->toEndWith('/api/v1/charge/pedido-1') + ->and($history[1]['request']->getMethod())->toBe('DELETE'); +})->group('woovi'); + +it('registra chave pix aleatória sem informar a chave', function () { + $history = []; + $client = wooviClient([jsonResponse([])], $history); + + (new Pix('app-id', true, $client))->createKey(PixKeyTypeEnum::RANDOM); + + expect((string) $history[0]['request']->getUri())->toEndWith('/api/v1/pix-keys') + ->and(recordedBody($history))->toBe(['type' => 'EVP']); +})->group('woovi'); + +it('exige a chave nos tipos que não são aleatórios', function () { + $history = []; + $client = wooviClient([jsonResponse([])], $history); + + expect(fn () => (new Pix('app-id', true, $client))->createKey(PixKeyTypeEnum::CPF)) + ->toThrow(ValidationException::class, 'exceto EVP'); + + expect($history)->toBeEmpty(); +})->group('woovi'); + +it('cria QR Code estático com e sem valor', function () { + $history = []; + $client = wooviClient([jsonResponse([]), jsonResponse([])], $history); + + $pix = new Pix('app-id', true, $client); + $pix->staticQrCode('Caixa 1'); + $pix->staticQrCode('Caixa 2', 2500, 'caixa-2'); + + expect((string) $history[0]['request']->getUri())->toEndWith('/api/v1/pixQrCode') + ->and(recordedBody($history, 0))->toBe(['name' => 'Caixa 1']) + ->and(recordedBody($history, 1))->toBe([ + 'name' => 'Caixa 2', 'value' => 2500, 'correlationID' => 'caixa-2', + ]); +})->group('woovi'); + +it('consulta uma chave pix antes de pagar', function () { + $history = []; + $client = wooviClient([jsonResponse([])], $history); + + (new Pix('app-id', true, $client))->verifyKey('fale@phpay.io'); + + expect((string) $history[0]['request']->getUri()) + ->toEndWith('/api/v1/pix-key-check/fale%40phpay.io'); +})->group('woovi'); + +it('cadastra webhook por API, no prefixo de caminho próprio', function () { + $history = []; + $client = wooviClient([jsonResponse([]), jsonResponse([])], $history); + + (new Webhook('app-id', [], true, $client))->create([ + 'name' => 'PHPay', 'url' => 'https://exemplo.test/webhook', + ]); + + (new Webhook('app-id', [], true, $client))->getAll(); + + $body = recordedBody($history); + + /* repare no api/openpix/v1, diferente do api/v1 dos outros recursos */ + expect((string) $history[0]['request']->getUri())->toEndWith('/api/openpix/v1/webhook') + ->and($body['webhook']['isActive'])->toBeTrue() + ->and($body['webhook']['url'])->toBe('https://exemplo.test/webhook') + ->and((string) $history[1]['request']->getUri())->toContain('/api/openpix/v1/webhook'); +})->group('woovi'); + +it('cria assinatura com dia de cobrança', function () { + $history = []; + $client = wooviClient([jsonResponse([])], $history); + + (new Subscription('app-id', true, $client)) + ->setCustomer(['name' => 'Mário Lucas', 'email' => 'fale@phpay.io']) + ->setDayGenerateCharge(10) + ->create(4990); + + $body = recordedBody($history); + + expect((string) $history[0]['request']->getUri())->toEndWith('/api/v1/subscriptions') + ->and($body['value'])->toBe(4990) + ->and($body['dayGenerateCharge'])->toBe(10); +})->group('woovi'); + +it('valida os payloads antes de chamar a API', function (callable $acao, string $esperado) { + $history = []; + $client = wooviClient([jsonResponse([])], $history); + + expect(fn () => $acao($client))->toThrow(ValidationException::class, $esperado); + + expect($history)->toBeEmpty(); +})->with([ + 'cobrança com valor decimal' => [ + fn ($c) => (new Charge('app-id', true, $c))->setCharge(['value' => 100.50])->create(0), + 'CENTAVOS', + ], + 'cliente sem identificador' => [ + fn ($c) => (new Customer('app-id', ['name' => 'Mário'], true, $c))->create(), + 'email, taxID ou phone', + ], + 'webhook sem url' => [ + fn ($c) => (new Webhook('app-id', ['name' => 'X'], true, $c))->create(), + 'url é obrigatório', + ], + 'assinatura sem cliente' => [ + fn ($c) => (new Subscription('app-id', true, $c))->create(1000), + 'precisa de um customer', + ], + 'dia de cobrança fora do mês' => [ + fn ($c) => (new Subscription('app-id', true, $c)) + ->setCustomer(['name' => 'M', 'email' => 'a@b.com']) + ->setDayGenerateCharge(45) + ->create(1000), + 'entre 1 e 31', + ], +])->group('woovi'); diff --git a/tests/Unit/Woovi/WooviGatewayTest.php b/tests/Unit/Woovi/WooviGatewayTest.php new file mode 100644 index 0000000..f77d1e4 --- /dev/null +++ b/tests/Unit/Woovi/WooviGatewayTest.php @@ -0,0 +1,66 @@ +toBe(Capability::cases()); +})->group('woovi'); + +it('é o segundo gateway completo, junto com o asaas', function () { + $woovi = new WooviGateway('app-id', true, wooviClient([])); + $asaas = new AsaasGateway('token', true, mockClient([])); + + /* duas empresas independentes preenchendo o mesmo contrato */ + expect(Capability::of($woovi))->toBe(Capability::of($asaas)) + ->and(Capability::of($woovi))->toHaveCount(5); +})->group('woovi'); + +it('devolve a instância de cada um dos cinco recursos', function () { + $phpay = PHPay::gateway(new WooviGateway('app-id', true, wooviClient([]))); + + expect($phpay->customer([]))->toBeInstanceOf(Customer::class) + ->and($phpay->charge())->toBeInstanceOf(Charge::class) + ->and($phpay->webhook())->toBeInstanceOf(Webhook::class) + ->and($phpay->pix())->toBeInstanceOf(Pix::class) + ->and($phpay->subscription())->toBeInstanceOf(Subscription::class); +})->group('woovi'); + +it('manda o AppID cru no Authorization, sem esquema', function () { + $charge = (new WooviGateway('meu-app-id'))->charge(); + + $property = new ReflectionProperty($charge, 'client'); + $headers = $property->getValue($charge)->getConfig('headers'); + + expect($headers['Authorization'])->toBe('meu-app-id') + ->and($headers['Authorization'])->not->toStartWith('Bearer') + ->and($headers['Authorization'])->not->toStartWith('Basic'); +})->group('woovi'); + +it('usa domínio próprio no sandbox', function () { + $lerUri = function (object $recurso): string { + $property = new ReflectionProperty($recurso, 'client'); + + return (string) $property->getValue($recurso)->getConfig('base_uri'); + }; + + expect($lerUri((new WooviGateway('id'))->charge()))->toBe('https://api.woovi-sandbox.com/') + ->and($lerUri((new WooviGateway('id', false))->charge()))->toBe('https://api.openpix.com.br/'); +})->group('woovi'); + +it('não faz chamada de rede ao instanciar o gateway', function () { + $history = []; + + new WooviGateway('app-id', true, wooviClient([], $history)); + + expect($history)->toBeEmpty(); +})->group('woovi');