From 8cbb514de95f791007de892cfb314a55916bda0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rio=20Lucas?= Date: Sun, 20 Sep 2026 22:28:49 -0300 Subject: [PATCH] PHPAY-85: feat(rede): adicionar o gateway Rede (e.Rede v2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sétimo gateway, e a segunda adquirente. A Rede lidera o mercado por volume transacionado (18%), à frente de Cielo e PagBank. Declara UMA das cinco capacidades: SupportsCharges. É a forma mais estreita da biblioteca, ao lado do Efí, e é a esperada para uma adquirente. Não declarei SupportsSubscriptions de propósito: a transação tem um campo `subscription`, mas ele é uma flag para a adquirente, não um recurso que se liste, altere ou cancele. Duas particularidades, ambas novas: 1. O host de autorização é separado do host de API, e o caminho do token muda por ambiente — oauth2/token no sandbox, redelabs/oauth2/token em produção. Isso ficou em RedeEnvironment, fora do trait, para o gateway poder ler a configuração sem puxar os verbos HTTP que não usa. 2. O token expira. É o primeiro gateway com ciclo de vida de credencial: o Efí autoriza sob demanda mas nunca renova, porque o token dele não vence em uso normal. Aqui a resposta traz expires_in, então Resources/Authorization guarda o token com validade e renegocia sozinho, com margem de 30 segundos para o token nunca vencer entre ser entregue e ser usado. O gateway expõe authorization() para um processo longo inspecionar ou descartar o que está em mãos. O Charge pede um token a cada chamada e injeta como Bearer por requisição, em vez de fixar no header do client — é o que permite a renovação ser invisível para quem usa. CONFIANÇA MENOR QUE NOS GATEWAYS ANTERIORES. O portal da Rede publica a documentação apenas em PDF, então as URLs, os caminhos de token e o fluxo de OAuth vieram de um SDK PHP de terceiros em funcionamento (filipegar/eRede), não da documentação oficial. Os endpoints estão bem evidenciados; os nomes exatos de alguns campos do payload e o suporte a Pix na v2, não. Por isso o escopo ficou em cartão, que é o núcleo de uma adquirente. 171 testes nesta branch (a Cielo está no #84, ainda não mergeada). Os da Rede cobrem o reaproveitamento do token, a renegociação quando expira, o basic auth da negociação, o Bearer por requisição, e o roteamento entre os dois hosts. --- .gitignore | 1 + README.md | 81 +++++- composer.json | 3 +- examples/rede/charges.php | 71 +++++ examples/rede/credentials.example.php | 13 + .../Rede/Enums/TransactionKindEnum.php | 9 + .../Rede/Enums/TransactionStatusEnum.php | 27 ++ .../Rede/Interface/RedeGatewayInterface.php | 34 +++ src/Gateways/Rede/RedeEnvironment.php | 50 ++++ src/Gateways/Rede/RedeGateway.php | 78 ++++++ .../Rede/Requests/RedeTransactionRequest.php | 71 +++++ .../Resources/Authorization/Authorization.php | 143 ++++++++++ src/Gateways/Rede/Resources/Charge/Charge.php | 254 ++++++++++++++++++ .../Charge/Interface/ChargeInterface.php | 115 ++++++++ src/Gateways/Rede/Traits/HasRedeClient.php | 93 +++++++ tests/Pest.php | 24 ++ tests/Unit/Rede/AuthorizationTest.php | 72 +++++ tests/Unit/Rede/RedeGatewayTest.php | 156 +++++++++++ 18 files changed, 1280 insertions(+), 15 deletions(-) create mode 100644 examples/rede/charges.php create mode 100644 examples/rede/credentials.example.php create mode 100644 src/Gateways/Rede/Enums/TransactionKindEnum.php create mode 100644 src/Gateways/Rede/Enums/TransactionStatusEnum.php create mode 100644 src/Gateways/Rede/Interface/RedeGatewayInterface.php create mode 100644 src/Gateways/Rede/RedeEnvironment.php create mode 100644 src/Gateways/Rede/RedeGateway.php create mode 100644 src/Gateways/Rede/Requests/RedeTransactionRequest.php create mode 100644 src/Gateways/Rede/Resources/Authorization/Authorization.php create mode 100644 src/Gateways/Rede/Resources/Charge/Charge.php create mode 100644 src/Gateways/Rede/Resources/Charge/Interface/ChargeInterface.php create mode 100644 src/Gateways/Rede/Traits/HasRedeClient.php create mode 100644 tests/Unit/Rede/AuthorizationTest.php create mode 100644 tests/Unit/Rede/RedeGatewayTest.php diff --git a/.gitignore b/.gitignore index 81af05d..fba3a16 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ examples/efi/credentials.php examples/mercadopago/credentials.php examples/pagbank/credentials.php examples/pagarme/credentials.php +examples/rede/credentials.php # configurações locais do Claude Code (pessoais, não versionar) .claude/settings.local.json diff --git a/README.md b/README.md index 64f5141..4310502 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ - [Mercado Pago](#mercado-pago) - [PagBank](#pagbank) - [Pagar.me](#pagarme) + - [Rede](#rede) - [Efí](#efí) - [Exemplos executáveis](#exemplos-executáveis) - [Migrando da v1](#migrando-da-v1) @@ -67,13 +68,13 @@ Trocar de gateway é trocar a linha do construtor. ## Gateways suportados -| Capacidade | Interface | Asaas | Mercado Pago | PagBank | Pagar.me | Efí | -| --- | --- | :---: | :---: | :---: | :---: | :---: | -| Clientes | `SupportsCustomers` | ✅ | ✅ | ✅ | ✅ | — | -| Cobranças | `SupportsCharges` | ✅ | ✅ | ✅ | ✅ | ✅ | -| Assinaturas | `SupportsSubscriptions` | ✅ | ✅ | ✅ | ✅ | — | -| Webhooks | `SupportsWebhooks` | ✅ | — | — | — | — | -| Chaves Pix | `SupportsPixKeys` | ✅ | — | — | — | — | +| Capacidade | Interface | Asaas | Mercado Pago | PagBank | Pagar.me | 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: @@ -233,6 +234,7 @@ que cada um faz em vez de inventar um padrão: | **Asaas** | `$sandbox` no construtor — troca a URL | | **PagBank** | `$sandbox` no construtor — troca a URL | | **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` | @@ -257,6 +259,7 @@ em vez de falhar: | **Mercado Pago** | Reais (decimal) | `100.50` | | **PagBank** | Centavos (inteiro) | `10050` | | **Pagar.me** | Centavos (inteiro) | `10050` | +| **Rede** | Centavos (inteiro) | `10050` | | **Efí** | Centavos (inteiro) | `10050` | Nos gateways que usam centavos, o PHPay **recusa valor decimal na validação**, @@ -538,6 +541,56 @@ $gateway->webhookDeliveries()->resend($hookId); É assim que o modelo de capacidades abre espaço para o que só um gateway oferece: quem segura `PagarMeGateway` alcança, quem tipa uma capacidade não. +### 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í Só cobranças, por enquanto. O gateway **não faz chamada de rede no construtor** @@ -614,13 +667,13 @@ Dois pontos merecem auditoria de quem vem da v1: ### Cobertura por gateway -| | Asaas | Mercado Pago | PagBank | Pagar.me | Efí | -| --- | :---: | :---: | :---: | :---: | :---: | -| Cobranças | ✅ | ✅ | ✅ | ✅ | ✅ | -| Clientes | ✅ | ✅ | ✅ | ✅ | 🕥 | -| Assinaturas | ✍️ | ✅ | ✅ | ✅ | 🕥 | -| Webhooks | ✅ | — | — | leitura ✅ | 🕥 | -| Pix | ✅ | ✅ | ✅ | ✅ | 🕥 | +| | Asaas | Mercado Pago | PagBank | Pagar.me | 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 736570d..771252a 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,8 @@ "PHPay\\Efi\\": "src/Gateways/Efi/", "PHPay\\MercadoPago\\": "src/Gateways/MercadoPago/", "PHPay\\PagBank\\": "src/Gateways/PagBank/", - "PHPay\\PagarMe\\": "src/Gateways/PagarMe/" + "PHPay\\PagarMe\\": "src/Gateways/PagarMe/", + "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 28c7deb..daa93fc 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -98,3 +98,27 @@ function pagarmeClient(array $responses, array &$history = []): Client { return mockClient($responses, $history, 'https://api.pagar.me/core/v5/'); } + +/** + * 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');