diff --git a/.gitignore b/.gitignore index 81af05d..7d0474a 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/cielo/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 b17ef77..a358cea 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) e **Efí** (cobranças). +assinaturas), **Cielo** (cobranças e recorrência) 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,13 @@ 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`. +- **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 + e o trait expõe `queryGet()`. Autenticação por headers `MerchantId`/`MerchantKey`. + Valores em centavos. Recorrência **não tem endpoint de criação**: nasce de uma venda + com bloco `RecurrentPayment`. Os endpoints de update da recorrência recebem um valor + JSON puro no corpo (`19900`, `"Monthly"`), não um objeto — daí o `putValue()`. - **Pagar.me** — autenticação **Basic** (secret key como usuário, senha vazia), não Bearer. Ambiente pelo prefixo `sk_test_`, host único, então sem `$sandbox`. Valores em centavos. Cancelamento é `DELETE /charges/{id}` com valor opcional no corpo — diff --git a/README.md b/README.md index 1a1ddb9..03ad47c 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ - [Mercado Pago](#mercado-pago) - [PagBank](#pagbank) - [Pagar.me](#pagarme) + - [Cielo](#cielo) - [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 | Cielo | 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: @@ -232,6 +233,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 | +| **Cielo** | `$sandbox` no construtor — troca **as duas** URLs | | **Efí** | `$sandbox` no construtor — troca a URL | | **Mercado Pago** | Prefixo do token (`TEST-`); host único, sem `$sandbox` | | **Pagar.me** | Prefixo da chave (`sk_test_`); host único, sem `$sandbox` | @@ -257,6 +259,7 @@ em vez de falhar: | **Mercado Pago** | Reais (decimal) | `100.50` | | **PagBank** | Centavos (inteiro) | `10050` | | **Pagar.me** | Centavos (inteiro) | `10050` | +| **Cielo** | Centavos (inteiro) | `10050` | | **Efí** | Centavos (inteiro) | `10050` | Nos gateways que usam centavos, o PHPay **recusa valor decimal na validação**, @@ -538,6 +541,68 @@ $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. +### Cielo + +A primeira **adquirente** da biblioteca, e a forma mostra: não há recurso de +cliente — ele é um campo da venda. Daí as duas capacidades. + +A particularidade é que a Cielo separa **dois hosts por tipo de operação**: +escritas vão para `api.cieloecommerce...`, consultas para +`apiquery.cieloecommerce...`. O mesmo recurso usa os dois, e o PHPay roteia +sozinho — `create()` vai num, `find()` no outro. + +```php +use PHPay\Cielo\CieloGateway; + +$phpay = PHPay::gateway(new CieloGateway(MERCHANT_ID, MERCHANT_KEY))->charge(); + +$venda = $phpay + ->setOrderId('pedido-1') + ->setCustomer(['Name' => 'Mário Lucas']) + ->setPix(15700) // R$ 157,00 + ->setRequestId('pedido-1') // idempotência + ->create(); + +$phpay->getPixCode($venda['Payment']['PaymentId']); +``` + +Cartão em duas etapas — autoriza agora, captura depois: + +```php +$phpay + ->setCustomer(['Name' => 'Mário Lucas']) + ->setCreditCard(15700, $cartao, installments: 3) // capture: false por padrão + ->create(); + +$phpay->capture($paymentId); +$phpay->cancel($paymentId, 2500); // estorna R$ 25,00 +``` + +#### Recorrência + +A Cielo **não tem endpoint de criar assinatura**: a recorrência nasce de uma +venda com um bloco `RecurrentPayment`, e só então ganha um `RecurrentPaymentId` +próprio. Sempre cobra cartão. + +```php +use PHPay\Cielo\Enums\RecurrentIntervalEnum; + +$phpay = PHPay::gateway(new CieloGateway(MERCHANT_ID, MERCHANT_KEY))->subscription(); + +$recorrencia = $phpay + ->setCustomer(['Name' => 'Mário Lucas']) + ->setCard($cartao) + ->setInterval(RecurrentIntervalEnum::MONTHLY) + ->setEndDate('2027-12-31') + ->create(15700); + +$id = $recorrencia['Payment']['RecurrentPayment']['RecurrentPaymentId']; + +$phpay->updateAmount($id, 19900); +$phpay->deactivate($id); +$phpay->reactivate($id); +``` + ### Efí Só cobranças, por enquanto. O gateway **não faz chamada de rede no construtor** @@ -614,13 +679,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 | Cielo | 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 39f6d25..3c39d5b 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\\Cielo\\": "src/Gateways/Cielo/" } }, "autoload-dev": { diff --git a/examples/cielo/charges.php b/examples/cielo/charges.php new file mode 100644 index 0000000..6c872c2 --- /dev/null +++ b/examples/cielo/charges.php @@ -0,0 +1,71 @@ +charge(); + +try { + /* + | Venda com Pix. Todo valor é inteiro em CENTAVOS: R$ 157,00 é 15700. + | + | A criação vai para o host de escrita; as consultas abaixo vão para o de + | query. O PHPay roteia sozinho. + */ + $venda = $phpay + ->setOrderId('pedido-' . time()) + ->setCustomer(['Name' => NAME]) + ->setPix(15700) + /* use uma chave estável do seu domínio para tornar o retry seguro */ + ->setRequestId('pedido-123456') + ->create(); + + $paymentId = (string) $venda['Payment']['PaymentId']; + + /* copia-e-cola do Pix */ + echo $phpay->getPixCode($paymentId) . PHP_EOL; + + /* status, como enum */ + $status = $phpay->getStatus($paymentId); + + if ($status !== null) { + echo SaleStatusEnum::from($status)->name . PHP_EOL; + } + + /* consulta pelas vendas de um pedido do seu sistema */ + $phpay->findByOrderId('pedido-123456'); + + /* + | Venda com cartão em duas etapas: autoriza agora, captura depois. + | Passe capture: true para autorizar e capturar de uma vez. + */ + $comCartao = PHPay::gateway(new CieloGateway(CIELO_MERCHANT_ID, CIELO_MERCHANT_KEY)) + ->charge() + ->setCustomer(['Name' => NAME]) + ->setCreditCard(15700, [ + 'CardNumber' => '0000000000000001', + 'Holder' => 'Mario Lucas', + 'ExpirationDate' => '12/2030', + 'SecurityCode' => '123', + 'Brand' => 'Visa', + ], installments: 3) + ->create(); + + $cartaoId = (string) $comCartao['Payment']['PaymentId']; + + $phpay->capture($cartaoId); /* captura total */ + $phpay->cancel($cartaoId, 2500); /* estorna R$ 25,00 */ + $phpay->cancel($cartaoId); /* estorna o restante */ +} catch (PHPayException $exception) { + echo $exception->getMessage() . PHP_EOL; +} diff --git a/examples/cielo/credentials.example.php b/examples/cielo/credentials.example.php new file mode 100644 index 0000000..0dc10b5 --- /dev/null +++ b/examples/cielo/credentials.example.php @@ -0,0 +1,15 @@ +subscription(); + +try { + $recorrencia = $phpay + ->setOrderId('assinatura-' . time()) + ->setCustomer(['Name' => NAME]) + ->setCard([ + 'CardNumber' => '0000000000000001', + 'Holder' => 'Mario Lucas', + 'ExpirationDate' => '12/2030', + 'SecurityCode' => '123', + 'Brand' => 'Visa', + ]) + ->setInterval(RecurrentIntervalEnum::MONTHLY) + ->setEndDate('2027-12-31') + ->create(15700); /* R$ 157,00 */ + + $recorrenciaId = (string) $recorrencia['Payment']['RecurrentPayment']['RecurrentPaymentId']; + + $phpay->find($recorrenciaId); + + /* reajuste e mudança de periodicidade */ + $phpay->updateAmount($recorrenciaId, 19900); + $phpay->updateInterval($recorrenciaId, RecurrentIntervalEnum::ANNUAL); + $phpay->updateNextPaymentDate($recorrenciaId, date('Y-m-d', strtotime('+30 days'))); + $phpay->updateEndDate($recorrenciaId, '2028-01-31'); + + /* suspender e retomar */ + $phpay->deactivate($recorrenciaId); + $phpay->reactivate($recorrenciaId); +} catch (PHPayException $exception) { + echo $exception->getMessage() . PHP_EOL; +} diff --git a/src/Gateways/Cielo/CieloGateway.php b/src/Gateways/Cielo/CieloGateway.php new file mode 100644 index 0000000..6eb6088 --- /dev/null +++ b/src/Gateways/Cielo/CieloGateway.php @@ -0,0 +1,71 @@ +merchantId, + $this->merchantKey, + $this->sandbox, + $this->client, + $this->queryClient + ); + } + + /** + * subscription + * + * @return Subscription + */ + public function subscription(): Subscription + { + return new Subscription( + $this->merchantId, + $this->merchantKey, + $this->sandbox, + $this->client, + $this->queryClient + ); + } +} diff --git a/src/Gateways/Cielo/Enums/PaymentTypeEnum.php b/src/Gateways/Cielo/Enums/PaymentTypeEnum.php new file mode 100644 index 0000000..b23130f --- /dev/null +++ b/src/Gateways/Cielo/Enums/PaymentTypeEnum.php @@ -0,0 +1,11 @@ + $sale + * @return void + * @throws ValidationException + */ + public static function validate(array $sale): void + { + $messages = self::messages(); + + CieloSaleRequest::validate($sale); + + $payment = $sale['Payment'] ?? null; + + if (!is_array($payment)) { + throw ValidationException::make('Cielo', CieloSaleRequest::messages()->payment); + } + + $card = $payment['CreditCard'] ?? null; + + if (!is_array($card) || empty($card)) { + throw ValidationException::make('Cielo', $messages->card); + } + + $recurrent = $payment['RecurrentPayment'] ?? null; + + if (!is_array($recurrent)) { + throw ValidationException::make('Cielo', $messages->recurrent); + } + + if (!isset($recurrent['Interval']) + || !is_string($recurrent['Interval']) + || !RecurrentIntervalEnum::tryFrom($recurrent['Interval']) instanceof RecurrentIntervalEnum + ) { + throw ValidationException::make('Cielo', $messages->interval); + } + } + + /** + * validate an amount given in cents. + * + * @param int $amount + * @return void + * @throws ValidationException + */ + public static function validateAmount(int $amount): void + { + if ($amount < 1) { + throw ValidationException::make('Cielo', self::messages()->amount); + } + } + + /** + * messages for validation + * + * @return object{card: string, recurrent: string, interval: string, amount: string} + */ + public static function messages(): object + { + return (object) [ + 'card' => 'A recorrência da Cielo exige cartão de crédito. Use setCard() antes de criar.', + 'recurrent' => 'O bloco Payment.RecurrentPayment é obrigatório numa recorrência.', + 'interval' => 'O campo Interval aceita apenas: Monthly, Bimonthly, Quarterly, SemiAnnual, Annual.', + 'amount' => 'O valor deve ser um inteiro em CENTAVOS maior que zero. R$ 157,00 é 15700.', + ]; + } +} diff --git a/src/Gateways/Cielo/Requests/CieloSaleRequest.php b/src/Gateways/Cielo/Requests/CieloSaleRequest.php new file mode 100644 index 0000000..11dd9ab --- /dev/null +++ b/src/Gateways/Cielo/Requests/CieloSaleRequest.php @@ -0,0 +1,74 @@ + $sale + * @return void + * @throws ValidationException + * @see https://developercielo.github.io/manual/cielo-ecommerce + */ + public static function validate(array $sale): void + { + $messages = self::messages(); + + if (!isset($sale['MerchantOrderId']) + || !is_string($sale['MerchantOrderId']) + || trim($sale['MerchantOrderId']) === '' + ) { + throw ValidationException::make('Cielo', $messages->merchantOrderId); + } + + if (!isset($sale['Customer']) || !is_array($sale['Customer'])) { + throw ValidationException::make('Cielo', $messages->customer); + } + + if (!isset($sale['Customer']['Name']) + || !is_string($sale['Customer']['Name']) + || trim($sale['Customer']['Name']) === '' + ) { + throw ValidationException::make('Cielo', $messages->customerName); + } + + if (!isset($sale['Payment']) || !is_array($sale['Payment'])) { + throw ValidationException::make('Cielo', $messages->payment); + } + + $payment = $sale['Payment']; + + if (!isset($payment['Type']) + || !is_string($payment['Type']) + || !PaymentTypeEnum::tryFrom($payment['Type']) instanceof PaymentTypeEnum + ) { + throw ValidationException::make('Cielo', $messages->paymentType); + } + + if (!isset($payment['Amount']) || !is_int($payment['Amount']) || $payment['Amount'] < 1) { + throw ValidationException::make('Cielo', $messages->amount); + } + } + + /** + * messages for validation + * + * @return object{merchantOrderId: string, customer: string, customerName: string, payment: string, paymentType: string, amount: string} + */ + public static function messages(): object + { + return (object) [ + 'merchantOrderId' => 'O campo MerchantOrderId é obrigatório — é o identificador do pedido no seu sistema.', + 'customer' => 'O campo Customer é obrigatório e deve ser um array. Use setCustomer().', + 'customerName' => 'O campo Customer.Name é obrigatório e deve ser uma string não vazia.', + 'payment' => 'O campo Payment é obrigatório. Use setPix(), setBoleto() ou setCreditCard().', + 'paymentType' => 'O campo Payment.Type é obrigatório e aceita apenas: CreditCard, DebitCard, Pix, Boleto.', + 'amount' => 'O campo Payment.Amount é obrigatório e deve ser um inteiro em CENTAVOS maior que zero. A Cielo não aceita valor decimal: R$ 157,00 é 15700.', + ]; + } +} diff --git a/src/Gateways/Cielo/Resources/Charge/Charge.php b/src/Gateways/Cielo/Resources/Charge/Charge.php new file mode 100644 index 0000000..e3ca83b --- /dev/null +++ b/src/Gateways/Cielo/Resources/Charge/Charge.php @@ -0,0 +1,312 @@ + + */ + private array $sale = []; + + /** + * idempotency key sent as RequestId + */ + private ?string $requestId = null; + + /** + * construct + * + * @param string $merchantId + * @param string $merchantKey + * @param bool $sandbox + * @param Client|null $client injected write client, mainly for tests + * @param Client|null $queryClient injected query client, mainly for tests + */ + public function __construct( + private string $merchantId, + private string $merchantKey, + private bool $sandbox = true, + ?Client $client = null, + ?Client $queryClient = null, + ) { + $this->client = $client ?? $this->clientCieloBoot(); + $this->queryClient = $queryClient ?? $client ?? $this->clientCieloQueryBoot(); + } + + /** + * set the whole sale payload + * + * @param array $sale + * @return ChargeInterface + */ + public function setSale(array $sale): ChargeInterface + { + $this->sale = $sale; + + return $this; + } + + /** + * set the order identifier of your own system + * + * @param string $merchantOrderId + * @return ChargeInterface + */ + public function setOrderId(string $merchantOrderId): ChargeInterface + { + $this->sale['MerchantOrderId'] = $merchantOrderId; + + return $this; + } + + /** + * set the customer of the sale. + * + * the Cielo E-commerce API has no customer resource — the customer lives + * inside the sale, which is why this gateway does not declare + * SupportsCustomers. + * + * @param array $customer + * @return ChargeInterface + */ + public function setCustomer(array $customer): ChargeInterface + { + $this->sale['Customer'] = $customer; + + return $this; + } + + /** + * pay with Pix + * + * @param int $amount amount in cents + * @return ChargeInterface + */ + public function setPix(int $amount): ChargeInterface + { + $this->sale['Payment'] = [ + 'Type' => PaymentTypeEnum::PIX->value, + 'Amount' => $amount, + ]; + + return $this; + } + + /** + * pay with boleto + * + * @param int $amount amount in cents + * @param array $options extra Payment fields, such as Demonstrative + * @return ChargeInterface + */ + public function setBoleto(int $amount, array $options = []): ChargeInterface + { + $this->sale['Payment'] = array_merge([ + 'Type' => PaymentTypeEnum::BOLETO->value, + 'Amount' => $amount, + ], $options); + + return $this; + } + + /** + * pay with a credit card + * + * @param int $amount amount in cents + * @param array $card + * @param int $installments + * @param bool $capture false authorizes only — capture later with capture() + * @return ChargeInterface + */ + public function setCreditCard( + int $amount, + array $card, + int $installments = 1, + bool $capture = false + ): ChargeInterface { + $this->sale['Payment'] = [ + 'Type' => PaymentTypeEnum::CREDIT_CARD->value, + 'Amount' => $amount, + 'Installments' => $installments, + 'Capture' => $capture, + 'CreditCard' => $card, + ]; + + return $this; + } + + /** + * set the idempotency key sent as RequestId. + * + * when not set, a random key is generated per create() call. + * + * @param string $requestId + * @return ChargeInterface + */ + public function setRequestId(string $requestId): ChargeInterface + { + $this->requestId = $requestId; + + return $this; + } + + /** + * create the sale + * + * @return array + * @throws ValidationException|ApiException + */ + public function create(): array + { + $this->sale['MerchantOrderId'] = $this->sale['MerchantOrderId'] ?? uniqid('phpay_'); + + CieloSaleRequest::validate($this->sale); + + return $this->post('1/sales', $this->sale, [ + 'RequestId' => $this->requestId ?? $this->generateRequestId(), + ]); + } + + /** + * find a sale by its payment id + * + * @param string $paymentId + * @return array + * @throws ApiException + */ + public function find(string $paymentId): array + { + return $this->queryGet("1/sales/{$paymentId}"); + } + + /** + * find the sales of one order of your own system + * + * @param string $merchantOrderId + * @return array + * @throws ApiException + */ + public function findByOrderId(string $merchantOrderId): array + { + return $this->queryGet('1/sales', ['merchantOrderId' => $merchantOrderId]); + } + + /** + * get the status of a sale + * + * @param string $paymentId + * @return int|null + * @throws ApiException + */ + public function getStatus(string $paymentId): ?int + { + $sale = $this->find($paymentId); + + $payment = $sale['Payment'] ?? null; + + if (!is_array($payment)) { + return null; + } + + $status = $payment['Status'] ?? null; + + return is_int($status) ? $status : null; + } + + /** + * get the Pix copy-and-paste code of a sale + * + * @param string $paymentId + * @return string|null + * @throws ApiException + */ + public function getPixCode(string $paymentId): ?string + { + $sale = $this->find($paymentId); + + $payment = $sale['Payment'] ?? null; + + if (!is_array($payment)) { + return null; + } + + $code = $payment['QrCodeString'] ?? null; + + return is_string($code) ? $code : null; + } + + /** + * capture a previously authorized sale + * + * @param string $paymentId + * @param int|null $amount amount in cents; null captures the full value + * @return array + * @throws ApiException + */ + public function capture(string $paymentId, ?int $amount = null): array + { + $query = $amount === null ? '' : '?amount=' . $amount; + + return $this->put("1/sales/{$paymentId}/capture{$query}"); + } + + /** + * cancel or refund a sale. + * + * the same endpoint undoes an authorization and refunds a captured sale — + * what changes is the state the sale was in. + * + * @param string $paymentId + * @param int|null $amount amount in cents; null cancels the full value + * @return array + * @throws ApiException + */ + public function cancel(string $paymentId, ?int $amount = null): array + { + $query = $amount === null ? '' : '?amount=' . $amount; + + return $this->put("1/sales/{$paymentId}/void{$query}"); + } + + /** + * generate a RequestId for a write that must not be duplicated. + * + * @return string + */ + private function generateRequestId(): string + { + return sprintf( + '%s-%s-%s-%s-%s', + bin2hex(random_bytes(4)), + bin2hex(random_bytes(2)), + bin2hex(random_bytes(2)), + bin2hex(random_bytes(2)), + bin2hex(random_bytes(6)) + ); + } +} diff --git a/src/Gateways/Cielo/Resources/Charge/Interface/ChargeInterface.php b/src/Gateways/Cielo/Resources/Charge/Interface/ChargeInterface.php new file mode 100644 index 0000000..cc01533 --- /dev/null +++ b/src/Gateways/Cielo/Resources/Charge/Interface/ChargeInterface.php @@ -0,0 +1,128 @@ + $sale + * @return ChargeInterface + */ + public function setSale(array $sale): ChargeInterface; + + /** + * set the order identifier of your own system + * + * @param string $merchantOrderId + * @return ChargeInterface + */ + public function setOrderId(string $merchantOrderId): ChargeInterface; + + /** + * set the customer of the sale + * + * @param array $customer + * @return ChargeInterface + */ + public function setCustomer(array $customer): ChargeInterface; + + /** + * pay with Pix + * + * @param int $amount amount in cents + * @return ChargeInterface + */ + public function setPix(int $amount): ChargeInterface; + + /** + * pay with boleto + * + * @param int $amount amount in cents + * @param array $options + * @return ChargeInterface + */ + public function setBoleto(int $amount, array $options = []): ChargeInterface; + + /** + * pay with a credit card + * + * @param int $amount amount in cents + * @param array $card + * @param int $installments + * @param bool $capture + * @return ChargeInterface + */ + public function setCreditCard( + int $amount, + array $card, + int $installments = 1, + bool $capture = false + ): ChargeInterface; + + /** + * set the idempotency key sent as RequestId + * + * @param string $requestId + * @return ChargeInterface + */ + public function setRequestId(string $requestId): ChargeInterface; + + /** + * create the sale + * + * @return array + */ + public function create(): array; + + /** + * find a sale by its payment id + * + * @param string $paymentId + * @return array + */ + public function find(string $paymentId): array; + + /** + * find the sales of one order of your own system + * + * @param string $merchantOrderId + * @return array + */ + public function findByOrderId(string $merchantOrderId): array; + + /** + * get the status of a sale + * + * @param string $paymentId + * @return int|null + */ + public function getStatus(string $paymentId): ?int; + + /** + * get the Pix copy-and-paste code of a sale + * + * @param string $paymentId + * @return string|null + */ + public function getPixCode(string $paymentId): ?string; + + /** + * capture a previously authorized sale + * + * @param string $paymentId + * @param int|null $amount amount in cents + * @return array + */ + public function capture(string $paymentId, ?int $amount = null): array; + + /** + * cancel or refund a sale + * + * @param string $paymentId + * @param int|null $amount amount in cents + * @return array + */ + public function cancel(string $paymentId, ?int $amount = null): array; +} diff --git a/src/Gateways/Cielo/Resources/Subscription/Interface/SubscriptionInterface.php b/src/Gateways/Cielo/Resources/Subscription/Interface/SubscriptionInterface.php new file mode 100644 index 0000000..513ef53 --- /dev/null +++ b/src/Gateways/Cielo/Resources/Subscription/Interface/SubscriptionInterface.php @@ -0,0 +1,116 @@ + $customer + * @return SubscriptionInterface + */ + public function setCustomer(array $customer): SubscriptionInterface; + + /** + * set the credit card the recurrence charges + * + * @param array $card + * @return SubscriptionInterface + */ + public function setCard(array $card): SubscriptionInterface; + + /** + * set how often the recurrence charges + * + * @param RecurrentIntervalEnum $interval + * @return SubscriptionInterface + */ + public function setInterval(RecurrentIntervalEnum $interval): SubscriptionInterface; + + /** + * set when the recurrence stops + * + * @param string $endDate + * @return SubscriptionInterface + */ + public function setEndDate(string $endDate): SubscriptionInterface; + + /** + * create the recurrence + * + * @param int $amount amount in cents + * @return array + */ + public function create(int $amount): array; + + /** + * find a recurrence by id + * + * @param string $recurrentPaymentId + * @return array + */ + public function find(string $recurrentPaymentId): array; + + /** + * suspend a recurrence + * + * @param string $recurrentPaymentId + * @return array + */ + public function deactivate(string $recurrentPaymentId): array; + + /** + * resume a suspended recurrence + * + * @param string $recurrentPaymentId + * @return array + */ + public function reactivate(string $recurrentPaymentId): array; + + /** + * change the charged amount + * + * @param string $recurrentPaymentId + * @param int $amount amount in cents + * @return array + */ + public function updateAmount(string $recurrentPaymentId, int $amount): array; + + /** + * change how often the recurrence charges + * + * @param string $recurrentPaymentId + * @param RecurrentIntervalEnum $interval + * @return array + */ + public function updateInterval(string $recurrentPaymentId, RecurrentIntervalEnum $interval): array; + + /** + * change when the recurrence stops + * + * @param string $recurrentPaymentId + * @param string $endDate + * @return array + */ + public function updateEndDate(string $recurrentPaymentId, string $endDate): array; + + /** + * change the date of the next charge + * + * @param string $recurrentPaymentId + * @param string $nextPaymentDate + * @return array + */ + public function updateNextPaymentDate(string $recurrentPaymentId, string $nextPaymentDate): array; +} diff --git a/src/Gateways/Cielo/Resources/Subscription/Subscription.php b/src/Gateways/Cielo/Resources/Subscription/Subscription.php new file mode 100644 index 0000000..f5438ed --- /dev/null +++ b/src/Gateways/Cielo/Resources/Subscription/Subscription.php @@ -0,0 +1,268 @@ + + */ + private array $sale = []; + + /** + * @var array + */ + private array $card = []; + + /** + * @var array + */ + private array $recurrent = []; + + /** + * construct + * + * @param string $merchantId + * @param string $merchantKey + * @param bool $sandbox + * @param Client|null $client injected write client, mainly for tests + * @param Client|null $queryClient injected query client, mainly for tests + */ + public function __construct( + private string $merchantId, + private string $merchantKey, + private bool $sandbox = true, + ?Client $client = null, + ?Client $queryClient = null, + ) { + $this->client = $client ?? $this->clientCieloBoot(); + $this->queryClient = $queryClient ?? $client ?? $this->clientCieloQueryBoot(); + } + + /** + * set the order identifier of your own system + * + * @param string $merchantOrderId + * @return SubscriptionInterface + */ + public function setOrderId(string $merchantOrderId): SubscriptionInterface + { + $this->sale['MerchantOrderId'] = $merchantOrderId; + + return $this; + } + + /** + * set the customer of the recurrence + * + * @param array $customer + * @return SubscriptionInterface + */ + public function setCustomer(array $customer): SubscriptionInterface + { + $this->sale['Customer'] = $customer; + + return $this; + } + + /** + * set the credit card the recurrence charges + * + * @param array $card + * @return SubscriptionInterface + */ + public function setCard(array $card): SubscriptionInterface + { + $this->card = $card; + + return $this; + } + + /** + * set how often the recurrence charges + * + * @param RecurrentIntervalEnum $interval + * @return SubscriptionInterface + */ + public function setInterval(RecurrentIntervalEnum $interval): SubscriptionInterface + { + $this->recurrent['Interval'] = $interval->value; + + return $this; + } + + /** + * set when the recurrence stops + * + * @param string $endDate + * @return SubscriptionInterface + */ + public function setEndDate(string $endDate): SubscriptionInterface + { + $this->recurrent['EndDate'] = $endDate; + + return $this; + } + + /** + * create the recurrence + * + * @param int $amount amount in cents + * @return array + * @throws ValidationException|ApiException + */ + public function create(int $amount): array + { + $this->sale['MerchantOrderId'] = $this->sale['MerchantOrderId'] ?? uniqid('phpay_'); + + $recurrent = $this->recurrent; + + $recurrent['AuthorizeNow'] = $recurrent['AuthorizeNow'] ?? true; + $recurrent['Interval'] = $recurrent['Interval'] ?? RecurrentIntervalEnum::MONTHLY->value; + + $this->sale['Payment'] = [ + 'Type' => PaymentTypeEnum::CREDIT_CARD->value, + 'Amount' => $amount, + 'Installments' => 1, + 'CreditCard' => $this->card, + 'RecurrentPayment' => $recurrent, + ]; + + CieloRecurrentRequest::validate($this->sale); + + return $this->post('1/sales', $this->sale); + } + + /** + * find a recurrence by id + * + * @param string $recurrentPaymentId + * @return array + * @throws ApiException + */ + public function find(string $recurrentPaymentId): array + { + return $this->queryGet("1/RecurrentPayment/{$recurrentPaymentId}"); + } + + /** + * suspend a recurrence + * + * @param string $recurrentPaymentId + * @return array + * @throws ApiException + */ + public function deactivate(string $recurrentPaymentId): array + { + return $this->put("1/RecurrentPayment/{$recurrentPaymentId}/Deactivate"); + } + + /** + * resume a suspended recurrence + * + * @param string $recurrentPaymentId + * @return array + * @throws ApiException + */ + public function reactivate(string $recurrentPaymentId): array + { + return $this->put("1/RecurrentPayment/{$recurrentPaymentId}/Reactivate"); + } + + /** + * change the charged amount + * + * @param string $recurrentPaymentId + * @param int $amount amount in cents + * @return array + * @throws ValidationException|ApiException + */ + public function updateAmount(string $recurrentPaymentId, int $amount): array + { + CieloRecurrentRequest::validateAmount($amount); + + return $this->putValue("1/RecurrentPayment/{$recurrentPaymentId}/Amount", $amount); + } + + /** + * change how often the recurrence charges + * + * @param string $recurrentPaymentId + * @param RecurrentIntervalEnum $interval + * @return array + * @throws ApiException + */ + public function updateInterval(string $recurrentPaymentId, RecurrentIntervalEnum $interval): array + { + return $this->putValue("1/RecurrentPayment/{$recurrentPaymentId}/Interval", $interval->value); + } + + /** + * change when the recurrence stops + * + * @param string $recurrentPaymentId + * @param string $endDate + * @return array + * @throws ApiException + */ + public function updateEndDate(string $recurrentPaymentId, string $endDate): array + { + return $this->putValue("1/RecurrentPayment/{$recurrentPaymentId}/EndDate", $endDate); + } + + /** + * change the date of the next charge + * + * @param string $recurrentPaymentId + * @param string $nextPaymentDate + * @return array + * @throws ApiException + */ + public function updateNextPaymentDate(string $recurrentPaymentId, string $nextPaymentDate): array + { + return $this->putValue( + "1/RecurrentPayment/{$recurrentPaymentId}/NextPaymentDate", + $nextPaymentDate + ); + } + + /** + * send a PUT whose body is a bare JSON value. + * + * the recurrence update endpoints do not take an object — they take the + * new value on its own, such as `15700` or `"Monthly"`. + * + * @param string $endpoint + * @param string|int $value + * @return array + * @throws ApiException + */ + private function putValue(string $endpoint, string|int $value): array + { + return $this->request('PUT', $endpoint, ['json' => $value]); + } +} diff --git a/src/Gateways/Cielo/Traits/HasCieloClient.php b/src/Gateways/Cielo/Traits/HasCieloClient.php new file mode 100644 index 0000000..890e5c6 --- /dev/null +++ b/src/Gateways/Cielo/Traits/HasCieloClient.php @@ -0,0 +1,112 @@ +bootClient($this->baseUri()); + } + + /** + * boot the client that takes queries + * + * @return Client + */ + protected function clientCieloQueryBoot(): Client + { + return $this->bootClient($this->queryBaseUri()); + } + + /** + * base uri for writes + * + * @return string + */ + protected function baseUri(): string + { + return $this->sandbox + ? 'https://apisandbox.cieloecommerce.cielo.com.br/' + : 'https://api.cieloecommerce.cielo.com.br/'; + } + + /** + * base uri for queries + * + * @return string + */ + protected function queryBaseUri(): string + { + return $this->sandbox + ? 'https://apiquerysandbox.cieloecommerce.cielo.com.br/' + : 'https://apiquery.cieloecommerce.cielo.com.br/'; + } + + /** + * read from the query host + * + * @param string $endpoint + * @param array $filters + * @return array + * @throws ApiException + */ + protected function queryGet(string $endpoint, array $filters = []): array + { + return $this->request('GET', $endpoint, ['query' => $filters], $this->queryClient); + } + + /** + * gateway name used in exception messages. + * + * @return string + */ + protected function gatewayName(): string + { + return 'Cielo'; + } + + /** + * build a client for the given host + * + * @param string $baseUri + * @return Client + */ + private function bootClient(string $baseUri): Client + { + return new Client([ + 'base_uri' => $baseUri, + 'headers' => [ + 'content-type' => 'application/json', + 'accept' => 'application/json', + 'user-agent' => 'PHPay', + 'MerchantId' => $this->merchantId, + 'MerchantKey' => $this->merchantKey, + ], + ]); + } +} diff --git a/src/Http/HasHttpClient.php b/src/Http/HasHttpClient.php index d981f10..e95dd52 100644 --- a/src/Http/HasHttpClient.php +++ b/src/Http/HasHttpClient.php @@ -2,6 +2,7 @@ namespace PHPay\Http; +use GuzzleHttp\Client; use PHPay\Exceptions\ApiException; use Throwable; @@ -87,13 +88,19 @@ protected function delete(string $endpoint): bool * @param string $method * @param string $endpoint * @param array $options + * @param Client|null $client overrides $this->client — for gateways that + * split operations across more than one host * @return array * @throws ApiException */ - protected function request(string $method, string $endpoint, array $options = []): array - { + protected function request( + string $method, + string $endpoint, + array $options = [], + ?Client $client = null + ): array { try { - $response = $this->client->request($method, $endpoint, $options); + $response = ($client ?? $this->client)->request($method, $endpoint, $options); } catch (Throwable $exception) { throw ApiException::fromThrowable( $exception, diff --git a/tests/Pest.php b/tests/Pest.php index 28c7deb..187f8ee 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 Cielo sandbox write host. + * + * @param array $responses + * @param array $history filled with the recorded transactions + * @return Client + */ +function cieloClient(array $responses, array &$history = []): Client +{ + return mockClient($responses, $history, 'https://apisandbox.cieloecommerce.cielo.com.br/'); +} + +/** + * mock client already pointed at the Cielo sandbox query host. + * + * @param array $responses + * @param array $history filled with the recorded transactions + * @return Client + */ +function cieloQueryClient(array $responses, array &$history = []): Client +{ + return mockClient($responses, $history, 'https://apiquerysandbox.cieloecommerce.cielo.com.br/'); +} diff --git a/tests/Unit/Cielo/ChargeTest.php b/tests/Unit/Cielo/ChargeTest.php new file mode 100644 index 0000000..4a27105 --- /dev/null +++ b/tests/Unit/Cielo/ChargeTest.php @@ -0,0 +1,159 @@ + $escritas + * @param array $consultas + * @param array $historicoEscrita + * @param array $historicoConsulta + * @return Charge + */ +function cieloCharge( + array $escritas = [], + array $consultas = [], + array &$historicoEscrita = [], + array &$historicoConsulta = [] +): Charge { + return new Charge( + 'merchant-id', + 'merchant-key', + true, + cieloClient($escritas, $historicoEscrita), + cieloQueryClient($consultas, $historicoConsulta) + ); +} + +it('cria a venda no host de escrita, com RequestId', function () { + $escrita = []; + $charge = cieloCharge([jsonResponse(['Payment' => ['PaymentId' => 'pay_1']])], [], $escrita); + + $charge + ->setOrderId('pedido-1') + ->setCustomer(['Name' => 'Mário Lucas']) + ->setPix(15700) + ->create(); + + $request = $escrita[0]['request']; + + expect((string) $request->getUri())->toBe('https://apisandbox.cieloecommerce.cielo.com.br/1/sales') + ->and($request->hasHeader('RequestId'))->toBeTrue() + ->and(recordedBody($escrita)['Payment'])->toBe(['Type' => 'Pix', 'Amount' => 15700]); +})->group('cielo'); + +it('consulta no host de query, não no de escrita', function () { + $escrita = []; + $consulta = []; + $charge = cieloCharge([], [jsonResponse(['Payment' => ['Status' => 2]])], $escrita, $consulta); + + $charge->find('pay_1'); + + expect($escrita)->toBeEmpty() + ->and((string) $consulta[0]['request']->getUri()) + ->toBe('https://apiquerysandbox.cieloecommerce.cielo.com.br/1/sales/pay_1'); +})->group('cielo'); + +it('busca pelas vendas de um pedido do seu sistema', function () { + $escrita = []; + $consulta = []; + $charge = cieloCharge([], [jsonResponse([])], $escrita, $consulta); + + $charge->findByOrderId('pedido-1'); + + expect((string) $consulta[0]['request']->getUri())->toContain('merchantOrderId=pedido-1'); +})->group('cielo'); + +it('lê o status da venda', function () { + $charge = cieloCharge([], [jsonResponse(['Payment' => ['Status' => SaleStatusEnum::PAYMENT_CONFIRMED->value]])]); + + expect($charge->getStatus('pay_1'))->toBe(2) + ->and(SaleStatusEnum::from(2))->toBe(SaleStatusEnum::PAYMENT_CONFIRMED); +})->group('cielo'); + +it('extrai o copia-e-cola do Pix', function () { + $charge = cieloCharge([], [jsonResponse(['Payment' => ['QrCodeString' => '00020126...']])]); + + expect($charge->getPixCode('pay_1'))->toBe('00020126...'); +})->group('cielo'); + +it('devolve null quando a venda não tem código pix', function () { + $charge = cieloCharge([], [jsonResponse(['Payment' => ['Status' => 1]])]); + + expect($charge->getPixCode('pay_1'))->toBeNull(); +})->group('cielo'); + +it('captura e cancela com valor opcional na query string', function () { + $escrita = []; + $charge = cieloCharge([jsonResponse([]), jsonResponse([]), jsonResponse([])], [], $escrita); + + $charge->capture('pay_1'); + $charge->capture('pay_1', 5000); + $charge->cancel('pay_1', 2500); + + expect($escrita[0]['request']->getMethod())->toBe('PUT') + ->and((string) $escrita[0]['request']->getUri())->toEndWith('/1/sales/pay_1/capture') + ->and((string) $escrita[1]['request']->getUri())->toEndWith('/1/sales/pay_1/capture?amount=5000') + ->and((string) $escrita[2]['request']->getUri())->toEndWith('/1/sales/pay_1/void?amount=2500'); +})->group('cielo'); + +it('monta venda com cartão de crédito sem capturar', function () { + $escrita = []; + $charge = cieloCharge([jsonResponse([])], [], $escrita); + + $charge + ->setCustomer(['Name' => 'Mário Lucas']) + ->setCreditCard(15700, ['CardNumber' => '0000000000000001', 'Brand' => 'Visa'], 3) + ->create(); + + $payment = recordedBody($escrita)['Payment']; + + expect($payment['Type'])->toBe('CreditCard') + ->and($payment['Installments'])->toBe(3) + ->and($payment['Capture'])->toBeFalse(); +})->group('cielo'); + +it('respeita o RequestId informado', function () { + $escrita = []; + $charge = cieloCharge([jsonResponse([])], [], $escrita); + + $charge + ->setCustomer(['Name' => 'Mário']) + ->setPix(100) + ->setRequestId('pedido-42') + ->create(); + + expect($escrita[0]['request']->getHeaderLine('RequestId'))->toBe('pedido-42'); +})->group('cielo'); + +it('valida a venda antes de chamar a API', function (callable $montar, string $esperado) { + $escrita = []; + $charge = cieloCharge([jsonResponse([])], [], $escrita); + + expect(fn () => $montar($charge)->create()) + ->toThrow(ValidationException::class, $esperado); + + expect($escrita)->toBeEmpty(); +})->with([ + 'sem cliente' => [ + fn (Charge $c) => $c->setPix(100), + 'O campo Customer é obrigatório', + ], + 'sem forma de pagamento' => [ + fn (Charge $c) => $c->setCustomer(['Name' => 'Mário']), + 'O campo Payment é obrigatório', + ], + 'valor decimal' => [ + fn (Charge $c) => $c->setCustomer(['Name' => 'Mário']) + ->setSale(['Customer' => ['Name' => 'Mário'], 'Payment' => ['Type' => 'Pix', 'Amount' => 157.00]]), + 'CENTAVOS', + ], + 'tipo de pagamento fora do enum' => [ + fn (Charge $c) => $c->setSale([ + 'Customer' => ['Name' => 'Mário'], + 'Payment' => ['Type' => 'Cheque', 'Amount' => 100], + ]), + 'CreditCard, DebitCard, Pix, Boleto', + ], +])->group('cielo'); diff --git a/tests/Unit/Cielo/CieloGatewayTest.php b/tests/Unit/Cielo/CieloGatewayTest.php new file mode 100644 index 0000000..6674d70 --- /dev/null +++ b/tests/Unit/Cielo/CieloGatewayTest.php @@ -0,0 +1,71 @@ +toBe([ + Capability::CHARGES, + Capability::SUBSCRIPTIONS, + ]); +})->group('cielo'); + +it('não declara clientes, porque a venda carrega o cliente embutido', function (Capability $capability) { + $phpay = PHPay::gateway(new CieloGateway('merchant-id', 'merchant-key', true, cieloClient([]))); + + expect($phpay->supports($capability))->toBeFalse(); + + expect(fn () => match ($capability) { + Capability::CUSTOMERS => $phpay->customer(), + Capability::WEBHOOKS => $phpay->webhook(), + default => $phpay->pix(), + })->toThrow(NotImplementedException::class, 'Cielo não suporta'); +})->with([Capability::CUSTOMERS, Capability::WEBHOOKS, Capability::PIX_KEYS])->group('cielo'); + +it('devolve a instância de cada recurso suportado', function () { + $phpay = PHPay::gateway(new CieloGateway('merchant-id', 'merchant-key', true, cieloClient([]))); + + expect($phpay->charge())->toBeInstanceOf(Charge::class) + ->and($phpay->subscription())->toBeInstanceOf(Subscription::class); +})->group('cielo'); + +it('separa os hosts por tipo de operação, não por domínio', function () { + $lerUri = function (object $recurso, string $propriedade): string { + $property = new ReflectionProperty($recurso, $propriedade); + + return (string) $property->getValue($recurso)->getConfig('base_uri'); + }; + + $sandbox = (new CieloGateway('id', 'key'))->charge(); + $producao = (new CieloGateway('id', 'key', false))->charge(); + + expect($lerUri($sandbox, 'client'))->toBe('https://apisandbox.cieloecommerce.cielo.com.br/') + ->and($lerUri($sandbox, 'queryClient'))->toBe('https://apiquerysandbox.cieloecommerce.cielo.com.br/') + ->and($lerUri($producao, 'client'))->toBe('https://api.cieloecommerce.cielo.com.br/') + ->and($lerUri($producao, 'queryClient'))->toBe('https://apiquery.cieloecommerce.cielo.com.br/'); +})->group('cielo'); + +it('autentica por headers MerchantId e MerchantKey', function () { + $charge = (new CieloGateway('minha-loja', 'minha-chave'))->charge(); + + $property = new ReflectionProperty($charge, 'client'); + $headers = $property->getValue($charge)->getConfig('headers'); + + expect($headers['MerchantId'])->toBe('minha-loja') + ->and($headers['MerchantKey'])->toBe('minha-chave') + ->and($headers)->not->toHaveKey('Authorization'); +})->group('cielo'); + +it('não faz chamada de rede ao instanciar o gateway', function () { + $history = []; + + new CieloGateway('id', 'key', true, cieloClient([], $history)); + + expect($history)->toBeEmpty(); +})->group('cielo'); diff --git a/tests/Unit/Cielo/SubscriptionTest.php b/tests/Unit/Cielo/SubscriptionTest.php new file mode 100644 index 0000000..6ddb657 --- /dev/null +++ b/tests/Unit/Cielo/SubscriptionTest.php @@ -0,0 +1,129 @@ + $escritas + * @param array $consultas + * @param array $historicoEscrita + * @param array $historicoConsulta + * @return Subscription + */ +function cieloSubscription( + array $escritas = [], + array $consultas = [], + array &$historicoEscrita = [], + array &$historicoConsulta = [] +): Subscription { + return new Subscription( + 'merchant-id', + 'merchant-key', + true, + cieloClient($escritas, $historicoEscrita), + cieloQueryClient($consultas, $historicoConsulta) + ); +} + +/** + * @return array + */ +function cieloCard(): array +{ + return [ + 'CardNumber' => '0000000000000001', + 'Holder' => 'Mario Lucas', + 'ExpirationDate' => '12/2030', + 'SecurityCode' => '123', + 'Brand' => 'Visa', + ]; +} + +it('nasce de uma venda com bloco RecurrentPayment, não de um endpoint próprio', function () { + $escrita = []; + $sub = cieloSubscription([jsonResponse(['Payment' => ['RecurrentPayment' => ['RecurrentPaymentId' => 'rec_1']]])], [], $escrita); + + $sub + ->setOrderId('assinatura-1') + ->setCustomer(['Name' => 'Mário Lucas']) + ->setCard(cieloCard()) + ->setInterval(RecurrentIntervalEnum::MONTHLY) + ->setEndDate('2027-12-31') + ->create(15700); + + $body = recordedBody($escrita); + + expect((string) $escrita[0]['request']->getUri())->toEndWith('/1/sales') + ->and($body['Payment']['Type'])->toBe('CreditCard') + ->and($body['Payment']['Amount'])->toBe(15700) + ->and($body['Payment']['RecurrentPayment']['Interval'])->toBe('Monthly') + ->and($body['Payment']['RecurrentPayment']['EndDate'])->toBe('2027-12-31') + ->and($body['Payment']['RecurrentPayment']['AuthorizeNow'])->toBeTrue(); +})->group('cielo'); + +it('usa recorrência mensal quando o intervalo não é informado', function () { + $escrita = []; + $sub = cieloSubscription([jsonResponse([])], [], $escrita); + + $sub->setCustomer(['Name' => 'Mário'])->setCard(cieloCard())->create(1000); + + expect(recordedBody($escrita)['Payment']['RecurrentPayment']['Interval'])->toBe('Monthly'); +})->group('cielo'); + +it('consulta a recorrência no host de query', function () { + $escrita = []; + $consulta = []; + $sub = cieloSubscription([], [jsonResponse(['RecurrentPaymentId' => 'rec_1'])], $escrita, $consulta); + + $sub->find('rec_1'); + + expect($escrita)->toBeEmpty() + ->and((string) $consulta[0]['request']->getUri())->toEndWith('/1/RecurrentPayment/rec_1'); +})->group('cielo'); + +it('suspende e reativa pela rota de cada ação', function () { + $escrita = []; + $sub = cieloSubscription([jsonResponse([]), jsonResponse([])], [], $escrita); + + $sub->deactivate('rec_1'); + $sub->reactivate('rec_1'); + + expect($escrita[0]['request']->getMethod())->toBe('PUT') + ->and((string) $escrita[0]['request']->getUri())->toEndWith('/1/RecurrentPayment/rec_1/Deactivate') + ->and((string) $escrita[1]['request']->getUri())->toEndWith('/1/RecurrentPayment/rec_1/Reactivate'); +})->group('cielo'); + +it('manda o novo valor como json puro, não como objeto', function () { + $escrita = []; + $sub = cieloSubscription([jsonResponse([]), jsonResponse([]), jsonResponse([])], [], $escrita); + + $sub->updateAmount('rec_1', 19900); + $sub->updateInterval('rec_1', RecurrentIntervalEnum::ANNUAL); + $sub->updateEndDate('rec_1', '2028-01-31'); + + expect((string) $escrita[0]['request']->getBody())->toBe('19900') + ->and((string) $escrita[1]['request']->getBody())->toBe('"Annual"') + ->and((string) $escrita[2]['request']->getBody())->toBe('"2028-01-31"') + ->and((string) $escrita[0]['request']->getUri())->toEndWith('/1/RecurrentPayment/rec_1/Amount'); +})->group('cielo'); + +it('exige cartão de crédito na recorrência', function () { + $escrita = []; + $sub = cieloSubscription([jsonResponse([])], [], $escrita); + + expect(fn () => $sub->setCustomer(['Name' => 'Mário'])->create(1000)) + ->toThrow(ValidationException::class, 'exige cartão de crédito'); + + expect($escrita)->toBeEmpty(); +})->group('cielo'); + +it('recusa valor decimal ao atualizar', function () { + $escrita = []; + $sub = cieloSubscription([jsonResponse([])], [], $escrita); + + expect(fn () => $sub->updateAmount('rec_1', 0)) + ->toThrow(ValidationException::class, 'CENTAVOS'); + + expect($escrita)->toBeEmpty(); +})->group('cielo');