diff --git a/.gitignore b/.gitignore index 7d0474a..41addb3 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ examples/mercadopago/credentials.php examples/pagbank/credentials.php examples/pagarme/credentials.php examples/cielo/credentials.php +examples/rede/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 a358cea..525ef7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ 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) e **Efí** (cobranças). +assinaturas), **Cielo** (cobranças e recorrência), **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 +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`. +- **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 + único gateway com ciclo de vida de credencial, tratado em `Resources/Authorization` + com margem de 30s antes do vencimento. O `Charge` pede um token a cada chamada e + injeta como Bearer por requisição, em vez de fixar no header do client. - **Cielo** — **dois hosts separados por tipo de operação**, não por domínio: escritas em `api.cieloecommerce...`, consultas em `apiquery.cieloecommerce...`. O **mesmo recurso** usa os dois, por isso `HasHttpClient::request()` aceita um client opcional diff --git a/README.md b/README.md index c55f9a1..61897f5 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ - [PagBank](#pagbank) - [Pagar.me](#pagarme) - [Cielo](#cielo) + - [Rede](#rede) - [Efí](#efí) - [Exemplos executáveis](#exemplos-executáveis) - [Migrando da v1](#migrando-da-v1) @@ -68,13 +69,13 @@ Trocar de gateway é trocar a linha do construtor. ## Gateways suportados -| Capacidade | Interface | Asaas | Mercado Pago | PagBank | Pagar.me | Cielo | Efí | -| --- | --- | :---: | :---: | :---: | :---: | :---: | :---: | -| Clientes | `SupportsCustomers` | ✅ | ✅ | ✅ | ✅ | — | — | -| Cobranças | `SupportsCharges` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Assinaturas | `SupportsSubscriptions` | ✅ | ✅ | ✅ | ✅ | ✅ | — | -| Webhooks | `SupportsWebhooks` | ✅ | — | — | — | — | — | -| Chaves Pix | `SupportsPixKeys` | ✅ | — | — | — | — | — | +| Capacidade | Interface | Asaas | Mercado Pago | PagBank | Pagar.me | Cielo | Rede | 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: @@ -235,6 +236,7 @@ que cada um faz em vez de inventar um padrão: | **PagBank** | `$sandbox` no construtor — troca a URL | | **Cielo** | `$sandbox` no construtor — troca **as duas** URLs | | **Efí** | `$sandbox` no construtor — troca a URL | +| **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` | @@ -260,6 +262,7 @@ em vez de falhar: | **PagBank** | Centavos (inteiro) | `10050` | | **Pagar.me** | Centavos (inteiro) | `10050` | | **Cielo** | Centavos (inteiro) | `10050` | +| **Rede** | Centavos (inteiro) | `10050` | | **Efí** | Centavos (inteiro) | `10050` | Nos gateways que usam centavos, o PHPay **recusa valor decimal na validação**, @@ -601,6 +604,54 @@ $id = $recorrencia['Payment']['RecurrentPayment']['RecurrentPaymentId']; $phpay->updateAmount($id, 19900); $phpay->deactivate($id); $phpay->reactivate($id); +### Rede + +Adquirente, e a forma mais estreita da biblioteca junto com o Efí: **só +cobranças**. Não há recurso de cliente nem assinatura gerenciável — a +transação tem um campo `subscription`, mas é uma flag para a adquirente, não +algo que você liste ou cancele. + +A particularidade é a autenticação: **OAuth2 `client_credentials` num host +separado do de API**, com o caminho do token diferente em cada ambiente. E o +token **expira** — o PHPay renegocia sozinho quando isso acontece. + +```php +use PHPay\Rede\Enums\TransactionKindEnum; +use PHPay\Rede\RedeGateway; + +/* nenhuma chamada de rede aqui: o token é negociado no primeiro uso */ +$gateway = new RedeGateway(REDE_PV, REDE_TOKEN); + +$transacao = PHPay::gateway($gateway)->charge() + ->setReference('pedido-1') + ->setCard('5448280000000007', 'MARIO LUCAS', '12', '2030', '123') + ->setPayment(2099, TransactionKindEnum::CREDIT) // R$ 20,99 + ->setSoftDescriptor('PHPAY') + ->create(); +``` + +Fluxo em duas etapas — autoriza agora, captura quando o pedido for separado: + +```php +$phpay->setPayment(5000, capture: false)->create(); + +$phpay->capture($tid); +$phpay->refund($tid, 1000); // estorna R$ 10,00 +``` + +O código de retorno `"00"` significa aprovada: + +```php +use PHPay\Rede\Enums\TransactionStatusEnum; + +TransactionStatusEnum::approved($phpay->getStatus($tid)); +``` + +Num processo longo, dá para inspecionar ou descartar o token em mãos: + +```php +$gateway->authorization()->hasValidToken(); +$gateway->authorization()->forget(); ``` ### Efí @@ -679,13 +730,13 @@ Dois pontos merecem auditoria de quem vem da v1: ### Cobertura por gateway -| | Asaas | Mercado Pago | PagBank | Pagar.me | Cielo | Efí | -| --- | :---: | :---: | :---: | :---: | :---: | :---: | -| Cobranças | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Clientes | ✅ | ✅ | ✅ | ✅ | — | 🕥 | -| Assinaturas | ✍️ | ✅ | ✅ | ✅ | ✅ | 🕥 | -| Webhooks | ✅ | — | — | leitura ✅ | — | 🕥 | -| Pix | ✅ | ✅ | ✅ | ✅ | ✅ | 🕥 | +| | Asaas | Mercado Pago | PagBank | Pagar.me | Cielo | Rede | 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 64f64a3..749ccb5 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,8 @@ "PHPay\\MercadoPago\\": "src/Gateways/MercadoPago/", "PHPay\\PagBank\\": "src/Gateways/PagBank/", "PHPay\\PagarMe\\": "src/Gateways/PagarMe/", - "PHPay\\Cielo\\": "src/Gateways/Cielo/" + "PHPay\\Cielo\\": "src/Gateways/Cielo/", + "PHPay\\Rede\\": "src/Gateways/Rede/" } }, "autoload-dev": { diff --git a/examples/rede/charges.php b/examples/rede/charges.php new file mode 100644 index 0000000..dc7cd0c --- /dev/null +++ b/examples/rede/charges.php @@ -0,0 +1,71 @@ +charge(); + +try { + /* + | Autoriza e captura de uma vez. Passe capture: false para autorizar agora + | e capturar depois. + | + | Todo valor é inteiro em CENTAVOS: R$ 20,99 é 2099. + */ + $transacao = $phpay + ->setReference('pedido-' . time()) + ->setCard('5448280000000007', 'MARIO LUCAS', '12', '2030', '123') + ->setPayment(2099, TransactionKindEnum::CREDIT, installments: 1, capture: true) + ->setSoftDescriptor('PHPAY') + ->create(); + + $tid = (string) $transacao['tid']; + + /* "00" significa aprovada */ + $codigo = $phpay->getStatus($tid); + + if ($codigo !== null && TransactionStatusEnum::approved($codigo)) { + echo "Aprovada\n"; + } + + /* consulta pela referência do seu sistema */ + $phpay->findByReference('pedido-1'); + + /* estorno parcial e total, em centavos */ + $phpay->refund($tid, 1000); + $phpay->refund($tid); + + /* + | Fluxo em duas etapas: autoriza agora, captura quando o pedido for + | separado. Entre as duas, o valor fica reservado no limite do portador + | mas não vira cobrança. + */ + $emDuasEtapas = PHPay::gateway($gateway)->charge() + ->setReference('pedido-2-etapas-' . time()) + ->setCard('5448280000000007', 'MARIO LUCAS', '12', '2030', '123') + ->setPayment(5000, TransactionKindEnum::CREDIT, capture: false) + ->create(); + + $phpay->capture((string) $emDuasEtapas['tid']); + + /* num processo longo, dá para inspecionar ou descartar o token em mãos */ + var_dump($gateway->authorization()->hasValidToken()); +} catch (PHPayException $exception) { + echo $exception->getMessage() . PHP_EOL; +} diff --git a/examples/rede/credentials.example.php b/examples/rede/credentials.example.php new file mode 100644 index 0000000..4cc508d --- /dev/null +++ b/examples/rede/credentials.example.php @@ -0,0 +1,13 @@ +value; + } +} diff --git a/src/Gateways/Rede/Interface/RedeGatewayInterface.php b/src/Gateways/Rede/Interface/RedeGatewayInterface.php new file mode 100644 index 0000000..559cb5a --- /dev/null +++ b/src/Gateways/Rede/Interface/RedeGatewayInterface.php @@ -0,0 +1,34 @@ +authorization === null) { + $this->authorization = new Authorization( + $this->affiliation, + $this->secret, + RedeEnvironment::tokenPath($this->sandbox), + $this->oauthClient ?? new Client([ + 'base_uri' => RedeEnvironment::oauth($this->sandbox), + 'headers' => ['accept' => 'application/json', 'user-agent' => 'PHPay'], + ]) + ); + } + + return $this->authorization; + } + + /** + * charge + * + * @return Charge + */ + public function charge(): Charge + { + return new Charge($this->authorization(), $this->sandbox, $this->client); + } +} diff --git a/src/Gateways/Rede/Requests/RedeTransactionRequest.php b/src/Gateways/Rede/Requests/RedeTransactionRequest.php new file mode 100644 index 0000000..5267cf9 --- /dev/null +++ b/src/Gateways/Rede/Requests/RedeTransactionRequest.php @@ -0,0 +1,71 @@ + $transaction + * @return void + * @throws ValidationException + */ + public static function validate(array $transaction): void + { + $messages = self::messages(); + + if (!isset($transaction['reference']) + || !is_string($transaction['reference']) + || trim($transaction['reference']) === '' + ) { + throw ValidationException::make('Rede', $messages->reference); + } + + if (!isset($transaction['amount']) + || !is_int($transaction['amount']) + || $transaction['amount'] < 1 + ) { + throw ValidationException::make('Rede', $messages->amount); + } + + if (!isset($transaction['kind']) + || !is_string($transaction['kind']) + || !TransactionKindEnum::tryFrom($transaction['kind']) instanceof TransactionKindEnum + ) { + throw ValidationException::make('Rede', $messages->kind); + } + + if (!isset($transaction['installments']) + || !is_int($transaction['installments']) + || $transaction['installments'] < 1 + ) { + throw ValidationException::make('Rede', $messages->installments); + } + + foreach (['cardNumber', 'cardHolderName', 'expirationMonth', 'expirationYear', 'securityCode'] as $field) { + if (!isset($transaction[$field])) { + throw ValidationException::make('Rede', $messages->card); + } + } + } + + /** + * messages for validation + * + * @return object{reference: string, amount: string, kind: string, installments: string, card: string} + */ + public static function messages(): object + { + return (object) [ + 'reference' => 'O campo reference é obrigatório — é o identificador do pedido no seu sistema.', + 'amount' => 'O campo amount é obrigatório e deve ser um inteiro em CENTAVOS maior que zero. A Rede não aceita valor decimal: R$ 20,99 é 2099.', + 'kind' => 'O campo kind é obrigatório e aceita apenas: credit, debit.', + 'installments' => 'O campo installments é obrigatório e deve ser um inteiro maior ou igual a 1.', + 'card' => 'Os dados do cartão são obrigatórios: cardNumber, cardHolderName, expirationMonth, expirationYear e securityCode. Use setCard().', + ]; + } +} diff --git a/src/Gateways/Rede/Resources/Authorization/Authorization.php b/src/Gateways/Rede/Resources/Authorization/Authorization.php new file mode 100644 index 0000000..5eaecdc --- /dev/null +++ b/src/Gateways/Rede/Resources/Authorization/Authorization.php @@ -0,0 +1,143 @@ +client = $client ?? new Client(); + } + + /** + * get a valid access token, renegotiating only when needed. + * + * @return string + * @throws ApiException + */ + public function token(): string + { + if ($this->token !== null && time() < $this->expiresAt) { + return $this->token; + } + + return $this->negotiate(); + } + + /** + * whether a token is held and still valid. + * + * @return bool + */ + public function hasValidToken(): bool + { + return $this->token !== null && time() < $this->expiresAt; + } + + /** + * drop the token in hand, forcing the next call to renegotiate. + * + * @return void + */ + public function forget(): void + { + $this->token = null; + $this->expiresAt = 0; + } + + /** + * gateway name used in exception messages. + * + * @return string + */ + protected function gatewayName(): string + { + return 'Rede'; + } + + /** + * exchange the credentials for a fresh token. + * + * @return string + * @throws ApiException + */ + private function negotiate(): string + { + $response = $this->request('POST', $this->tokenPath, [ + 'form_params' => ['grant_type' => 'client_credentials'], + 'headers' => [ + 'Authorization' => 'Basic ' . base64_encode("{$this->affiliation}:{$this->secret}"), + 'content-type' => 'application/x-www-form-urlencoded', + ], + ]); + + $token = $response['access_token'] ?? null; + + if (!is_string($token) || $token === '') { + throw new ApiException( + 'Rede: a autorização não retornou access_token.', + 'Rede', + 0, + $response + ); + } + + $expiresIn = $response['expires_in'] ?? null; + + $lifetime = is_numeric($expiresIn) ? (int) $expiresIn : 300; + + $this->token = $token; + $this->expiresAt = time() + max(0, $lifetime - self::EXPIRY_MARGIN); + + return $token; + } +} diff --git a/src/Gateways/Rede/Resources/Charge/Charge.php b/src/Gateways/Rede/Resources/Charge/Charge.php new file mode 100644 index 0000000..b31b7d6 --- /dev/null +++ b/src/Gateways/Rede/Resources/Charge/Charge.php @@ -0,0 +1,254 @@ + + */ + private array $transaction = []; + + /** + * construct + * + * @param Authorization $authorization + * @param bool $sandbox + * @param Client|null $client injected http client, mainly for tests + */ + public function __construct( + private Authorization $authorization, + private bool $sandbox = true, + ?Client $client = null, + ) { + $this->client = $client ?? $this->clientRedeBoot(); + } + + /** + * set the whole transaction payload + * + * @param array $transaction + * @return ChargeInterface + */ + public function setTransaction(array $transaction): ChargeInterface + { + $this->transaction = $transaction; + + return $this; + } + + /** + * set the order identifier of your own system + * + * @param string $reference + * @return ChargeInterface + */ + public function setReference(string $reference): ChargeInterface + { + $this->transaction['reference'] = $reference; + + return $this; + } + + /** + * set the card being charged + * + * @param string $number + * @param string $holderName + * @param string $expirationMonth + * @param string $expirationYear + * @param string $securityCode + * @return ChargeInterface + */ + public function setCard( + string $number, + string $holderName, + string $expirationMonth, + string $expirationYear, + string $securityCode + ): ChargeInterface { + $this->transaction['cardNumber'] = $number; + $this->transaction['cardHolderName'] = $holderName; + $this->transaction['expirationMonth'] = $expirationMonth; + $this->transaction['expirationYear'] = $expirationYear; + $this->transaction['securityCode'] = $securityCode; + + return $this; + } + + /** + * set how the card is charged + * + * @param int $amount amount in cents + * @param TransactionKindEnum $kind + * @param int $installments + * @param bool $capture false authorizes only — capture later with capture() + * @return ChargeInterface + */ + public function setPayment( + int $amount, + TransactionKindEnum $kind = TransactionKindEnum::CREDIT, + int $installments = 1, + bool $capture = true + ): ChargeInterface { + $this->transaction['amount'] = $amount; + $this->transaction['kind'] = $kind->value; + $this->transaction['installments'] = $installments; + $this->transaction['capture'] = $capture; + + return $this; + } + + /** + * set what shows on the cardholder statement + * + * @param string $softDescriptor + * @return ChargeInterface + */ + public function setSoftDescriptor(string $softDescriptor): ChargeInterface + { + $this->transaction['softDescriptor'] = $softDescriptor; + + return $this; + } + + /** + * create the transaction + * + * @return array + * @throws ValidationException|ApiException + */ + public function create(): array + { + $this->transaction['reference'] = $this->transaction['reference'] ?? uniqid('phpay_'); + + RedeTransactionRequest::validate($this->transaction); + + return $this->request('POST', 'transactions', $this->authorized([ + 'json' => $this->transaction, + ])); + } + + /** + * find a transaction by its tid + * + * @param string $tid + * @return array + * @throws ApiException + */ + public function find(string $tid): array + { + return $this->request('GET', "transactions/{$tid}", $this->authorized()); + } + + /** + * find a transaction by the reference of your own system + * + * @param string $reference + * @return array + * @throws ApiException + */ + public function findByReference(string $reference): array + { + return $this->request('GET', 'transactions', $this->authorized([ + 'query' => ['reference' => $reference], + ])); + } + + /** + * get the return code of a transaction. + * + * "00" means approved — see TransactionStatusEnum::approved(). + * + * @param string $tid + * @return string|null + * @throws ApiException + */ + public function getStatus(string $tid): ?string + { + $transaction = $this->find($tid); + + $code = $transaction['returnCode'] ?? null; + + return is_string($code) ? $code : null; + } + + /** + * capture a previously authorized transaction + * + * @param string $tid + * @param int|null $amount amount in cents; null captures the full value + * @return array + * @throws ApiException + */ + public function capture(string $tid, ?int $amount = null): array + { + return $this->request('PUT', "transactions/{$tid}", $this->authorized([ + 'json' => $amount === null ? [] : ['amount' => $amount], + ])); + } + + /** + * refund a transaction, fully or partially + * + * @param string $tid + * @param int|null $amount amount in cents; null refunds the full value + * @return array + * @throws ApiException + */ + public function refund(string $tid, ?int $amount = null): array + { + return $this->request('POST', "transactions/{$tid}/refunds", $this->authorized([ + 'json' => $amount === null ? [] : ['amount' => $amount], + ])); + } + + /** + * add the Bearer token to the request options. + * + * asked for on every call: the Authorization resource hands back the + * token it holds, and renegotiates only when that one has expired. + * + * @param array $options + * @return array + * @throws ApiException + */ + private function authorized(array $options = []): array + { + $headers = $options['headers'] ?? []; + + $options['headers'] = array_merge( + is_array($headers) ? $headers : [], + ['Authorization' => 'Bearer ' . $this->authorization->token()] + ); + + return $options; + } +} diff --git a/src/Gateways/Rede/Resources/Charge/Interface/ChargeInterface.php b/src/Gateways/Rede/Resources/Charge/Interface/ChargeInterface.php new file mode 100644 index 0000000..a9b529c --- /dev/null +++ b/src/Gateways/Rede/Resources/Charge/Interface/ChargeInterface.php @@ -0,0 +1,115 @@ + $transaction + * @return ChargeInterface + */ + public function setTransaction(array $transaction): ChargeInterface; + + /** + * set the order identifier of your own system + * + * @param string $reference + * @return ChargeInterface + */ + public function setReference(string $reference): ChargeInterface; + + /** + * set the card being charged + * + * @param string $number + * @param string $holderName + * @param string $expirationMonth + * @param string $expirationYear + * @param string $securityCode + * @return ChargeInterface + */ + public function setCard( + string $number, + string $holderName, + string $expirationMonth, + string $expirationYear, + string $securityCode + ): ChargeInterface; + + /** + * set how the card is charged + * + * @param int $amount amount in cents + * @param TransactionKindEnum $kind + * @param int $installments + * @param bool $capture true authorizes and captures in one step + * @return ChargeInterface + */ + public function setPayment( + int $amount, + TransactionKindEnum $kind = TransactionKindEnum::CREDIT, + int $installments = 1, + bool $capture = true + ): ChargeInterface; + + /** + * set what shows on the cardholder statement + * + * @param string $softDescriptor + * @return ChargeInterface + */ + public function setSoftDescriptor(string $softDescriptor): ChargeInterface; + + /** + * create the transaction + * + * @return array + */ + public function create(): array; + + /** + * find a transaction by its tid + * + * @param string $tid + * @return array + */ + public function find(string $tid): array; + + /** + * find a transaction by the reference of your own system + * + * @param string $reference + * @return array + */ + public function findByReference(string $reference): array; + + /** + * get the return code of a transaction + * + * @param string $tid + * @return string|null + */ + public function getStatus(string $tid): ?string; + + /** + * capture a previously authorized transaction + * + * @param string $tid + * @param int|null $amount amount in cents + * @return array + */ + public function capture(string $tid, ?int $amount = null): array; + + /** + * refund a transaction, fully or partially + * + * @param string $tid + * @param int|null $amount amount in cents + * @return array + */ + public function refund(string $tid, ?int $amount = null): array; +} diff --git a/src/Gateways/Rede/Traits/HasRedeClient.php b/src/Gateways/Rede/Traits/HasRedeClient.php new file mode 100644 index 0000000..79960f0 --- /dev/null +++ b/src/Gateways/Rede/Traits/HasRedeClient.php @@ -0,0 +1,93 @@ + $this->baseUri(), + 'headers' => [ + 'content-type' => 'application/json', + 'accept' => 'application/json', + 'user-agent' => 'PHPay', + ], + ]); + } + + /** + * boot client for the authorization host + * + * @return Client + */ + protected function clientRedeOauthBoot(): Client + { + return new Client([ + 'base_uri' => $this->oauthUri(), + 'headers' => [ + 'accept' => 'application/json', + 'user-agent' => 'PHPay', + ], + ]); + } + + /** + * base uri of the transactions API + * + * @return string + */ + protected function baseUri(): string + { + return RedeEnvironment::api($this->sandbox); + } + + /** + * base uri of the authorization host + * + * @return string + */ + protected function oauthUri(): string + { + return RedeEnvironment::oauth($this->sandbox); + } + + /** + * path of the token endpoint, which differs between environments + * + * @return string + */ + protected function oauthTokenPath(): string + { + return RedeEnvironment::tokenPath($this->sandbox); + } + + /** + * gateway name used in exception messages. + * + * @return string + */ + protected function gatewayName(): string + { + return 'Rede'; + } +} diff --git a/tests/Pest.php b/tests/Pest.php index 187f8ee..a197e81 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -122,3 +122,27 @@ function cieloQueryClient(array $responses, array &$history = []): Client { return mockClient($responses, $history, 'https://apiquerysandbox.cieloecommerce.cielo.com.br/'); } + +/** + * mock client already pointed at the Rede sandbox transactions host. + * + * @param array $responses + * @param array $history filled with the recorded transactions + * @return Client + */ +function redeClient(array $responses, array &$history = []): Client +{ + return mockClient($responses, $history, 'https://sandbox-erede.useredecloud.com.br/v2/'); +} + +/** + * mock client already pointed at the Rede sandbox OAuth host. + * + * @param array $responses + * @param array $history filled with the recorded transactions + * @return Client + */ +function redeOauthClient(array $responses, array &$history = []): Client +{ + return mockClient($responses, $history, 'https://rl7-sandbox-api.useredecloud.com.br/'); +} diff --git a/tests/Unit/Rede/AuthorizationTest.php b/tests/Unit/Rede/AuthorizationTest.php new file mode 100644 index 0000000..4339a53 --- /dev/null +++ b/tests/Unit/Rede/AuthorizationTest.php @@ -0,0 +1,72 @@ + 'tok_1', 'expires_in' => 3600])], $history); + + $auth = new Authorization('pv', 'segredo', 'oauth2/token', $client); + + expect($auth->token())->toBe('tok_1') + ->and($auth->token())->toBe('tok_1') + ->and($auth->token())->toBe('tok_1') + ->and($history)->toHaveCount(1); +})->group('rede'); + +it('autentica a negociação com basic auth de PV e token', function () { + $history = []; + $client = redeOauthClient([jsonResponse(['access_token' => 'tok_1', 'expires_in' => 3600])], $history); + + (new Authorization('minha-pv', 'meu-segredo', 'oauth2/token', $client))->token(); + + $request = $history[0]['request']; + + expect($request->getMethod())->toBe('POST') + ->and((string) $request->getUri())->toEndWith('/oauth2/token') + ->and($request->getHeaderLine('Authorization')) + ->toBe('Basic ' . base64_encode('minha-pv:meu-segredo')) + ->and((string) $request->getBody())->toBe('grant_type=client_credentials'); +})->group('rede'); + +it('renegocia quando o token expira', function () { + $history = []; + $client = redeOauthClient([ + /* expires_in menor que a margem de 30s: nasce já vencido */ + jsonResponse(['access_token' => 'tok_1', 'expires_in' => 10]), + jsonResponse(['access_token' => 'tok_2', 'expires_in' => 3600]), + ], $history); + + $auth = new Authorization('pv', 'segredo', 'oauth2/token', $client); + + expect($auth->token())->toBe('tok_1') + ->and($auth->hasValidToken())->toBeFalse() + ->and($auth->token())->toBe('tok_2') + ->and($auth->hasValidToken())->toBeTrue() + ->and($history)->toHaveCount(2); +})->group('rede'); + +it('permite descartar o token em mãos', function () { + $history = []; + $client = redeOauthClient([ + jsonResponse(['access_token' => 'tok_1', 'expires_in' => 3600]), + jsonResponse(['access_token' => 'tok_2', 'expires_in' => 3600]), + ], $history); + + $auth = new Authorization('pv', 'segredo', 'oauth2/token', $client); + + $auth->token(); + $auth->forget(); + + expect($auth->hasValidToken())->toBeFalse() + ->and($auth->token())->toBe('tok_2') + ->and($history)->toHaveCount(2); +})->group('rede'); + +it('falha com ApiException quando a negociação não devolve access_token', function () { + $client = redeOauthClient([jsonResponse(['error' => 'invalid_client'])]); + + expect(fn () => (new Authorization('pv', 'segredo', 'oauth2/token', $client))->token()) + ->toThrow(ApiException::class, 'access_token'); +})->group('rede'); diff --git a/tests/Unit/Rede/RedeGatewayTest.php b/tests/Unit/Rede/RedeGatewayTest.php new file mode 100644 index 0000000..f9b160e --- /dev/null +++ b/tests/Unit/Rede/RedeGatewayTest.php @@ -0,0 +1,156 @@ + $api + * @param array $historicoApi + * @return RedeGateway + */ +function redeGateway(array $api = [], array &$historicoApi = []): RedeGateway +{ + $oauth = []; + + return new RedeGateway( + 'pv', + 'segredo', + true, + redeClient($api, $historicoApi), + redeOauthClient([jsonResponse(['access_token' => 'tok_1', 'expires_in' => 3600])], $oauth) + ); +} + +it('declara apenas cobranças', function () { + expect(Capability::of(redeGateway()))->toBe([Capability::CHARGES]); +})->group('rede'); + +it('recusa as outras quatro capacidades pela facade', function (Capability $capability) { + $phpay = PHPay::gateway(redeGateway()); + + expect($phpay->supports($capability))->toBeFalse(); + + expect(fn () => match ($capability) { + Capability::CUSTOMERS => $phpay->customer(), + Capability::WEBHOOKS => $phpay->webhook(), + Capability::SUBSCRIPTIONS => $phpay->subscription(), + default => $phpay->pix(), + })->toThrow(NotImplementedException::class, 'Rede não suporta'); +})->with([ + Capability::CUSTOMERS, + Capability::WEBHOOKS, + Capability::SUBSCRIPTIONS, + Capability::PIX_KEYS, +])->group('rede'); + +it('separa o host de autorização do host de api, com caminho de token por ambiente', function () { + expect(RedeEnvironment::api(true))->toBe('https://sandbox-erede.useredecloud.com.br/v2/') + ->and(RedeEnvironment::api(false))->toBe('https://api.userede.com.br/erede/v2/') + ->and(RedeEnvironment::oauth(true))->toBe('https://rl7-sandbox-api.useredecloud.com.br/') + ->and(RedeEnvironment::oauth(false))->toBe('https://api.userede.com.br/') + ->and(RedeEnvironment::tokenPath(true))->toBe('oauth2/token') + ->and(RedeEnvironment::tokenPath(false))->toBe('redelabs/oauth2/token'); +})->group('rede'); + +it('não faz chamada de rede ao instanciar o gateway', function () { + $api = []; + $oauth = []; + + new RedeGateway('pv', 'segredo', true, redeClient([], $api), redeOauthClient([], $oauth)); + + expect($api)->toBeEmpty()->and($oauth)->toBeEmpty(); +})->group('rede'); + +it('compartilha a mesma autorização entre os recursos', function () { + $gateway = redeGateway(); + + expect($gateway->authorization())->toBe($gateway->authorization()); +})->group('rede'); + +it('cria a transação com bearer token e valor em centavos', function () { + $api = []; + $phpay = redeGateway([jsonResponse(['tid' => 'tid_1', 'returnCode' => '00'])], $api); + $charge = $phpay->charge(); + + $charge + ->setReference('pedido-1') + ->setCard('5448280000000007', 'MARIO LUCAS', '12', '2030', '123') + ->setPayment(2099, TransactionKindEnum::CREDIT, 1, true) + ->setSoftDescriptor('PHPAY') + ->create(); + + $request = $api[0]['request']; + $body = recordedBody($api); + + expect((string) $request->getUri())->toEndWith('/v2/transactions') + ->and($request->getHeaderLine('Authorization'))->toBe('Bearer tok_1') + ->and($body['amount'])->toBe(2099) + ->and($body['kind'])->toBe('credit') + ->and($body['capture'])->toBeTrue() + ->and($body['softDescriptor'])->toBe('PHPAY'); +})->group('rede'); + +it('captura, estorna e consulta nos endpoints certos', function () { + $api = []; + $charge = redeGateway([ + jsonResponse([]), jsonResponse([]), jsonResponse(['returnCode' => '00']), + ], $api)->charge(); + + $charge->capture('tid_1', 1000); + $charge->refund('tid_1'); + $status = $charge->getStatus('tid_1'); + + expect($api[0]['request']->getMethod())->toBe('PUT') + ->and((string) $api[0]['request']->getUri())->toEndWith('/transactions/tid_1') + ->and(recordedBody($api, 0))->toBe(['amount' => 1000]) + ->and($api[1]['request']->getMethod())->toBe('POST') + ->and((string) $api[1]['request']->getUri())->toEndWith('/transactions/tid_1/refunds') + ->and($api[2]['request']->getMethod())->toBe('GET') + ->and($status)->toBe('00') + ->and(TransactionStatusEnum::approved('00'))->toBeTrue() + ->and(TransactionStatusEnum::approved('51'))->toBeFalse(); +})->group('rede'); + +it('busca pela referência do seu sistema', function () { + $api = []; + $charge = redeGateway([jsonResponse([])], $api)->charge(); + + $charge->findByReference('pedido-1'); + + expect((string) $api[0]['request']->getUri())->toContain('reference=pedido-1'); +})->group('rede'); + +it('valida a transação antes de chamar a API', function (callable $montar, string $esperado) { + $api = []; + $charge = redeGateway([jsonResponse([])], $api)->charge(); + + expect(fn () => $montar($charge)->create()) + ->toThrow(ValidationException::class, $esperado); + + expect($api)->toBeEmpty(); +})->with([ + 'sem cartão' => [ + fn (Charge $c) => $c->setReference('p1')->setPayment(100), + 'dados do cartão são obrigatórios', + ], + 'sem valor' => [ + fn (Charge $c) => $c->setReference('p1')->setCard('5448', 'M L', '12', '2030', '123'), + 'O campo amount é obrigatório', + ], + 'valor decimal' => [ + fn (Charge $c) => $c->setTransaction([ + 'reference' => 'p1', 'amount' => 20.99, 'kind' => 'credit', 'installments' => 1, + ]), + 'CENTAVOS', + ], + 'kind fora do enum' => [ + fn (Charge $c) => $c->setTransaction([ + 'reference' => 'p1', 'amount' => 2099, 'kind' => 'voucher', 'installments' => 1, + ]), + 'credit, debit', + ], +])->group('rede');