diff --git a/.github/scripts/smoke.php b/.github/scripts/smoke.php index aed4869..c25df26 100644 --- a/.github/scripts/smoke.php +++ b/.github/scripts/smoke.php @@ -271,15 +271,17 @@ function () use ($phpay, $gateway, $declared, $ensure, $expect): void { $ensure($gateway instanceof SupportsChargeIntents, 'deveria implementar SupportsChargeIntents'); $ensure($phpay->supportedMethods() !== [], 'deveria declarar ao menos um meio'); - // nenhum deles faz boleto pelo caminho portável na fase - // 1, então a recusa acontece antes de qualquer rede. + // nenhum dos dez faz débito pelo caminho portável — a + // Cielo e o Pagar.me o conhecem, mas ele pede 3DS, que a + // biblioteca não modela. Serve de meio sempre recusado, + // sem depender de qual fase já entrou. $ensure( - !in_array(Method::BOLETO, $phpay->supportedMethods(), true), - 'boleto ainda não existe no caminho portável' + !in_array(Method::DEBIT_CARD, $phpay->supportedMethods(), true), + 'débito não existe no caminho portável' ); $expect( ValidationException::class, - static fn () => $intent->ensureSupportedBy('x', [Method::BOLETO]) + static fn () => $intent->ensureSupportedBy('x', [Method::DEBIT_CARD]) ); } ); diff --git a/CLAUDE.md b/CLAUDE.md index ee6f07c..60a5a50 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -281,6 +281,25 @@ descrição de cobrança que vale em mais de um gateway, com o mapeamento no a Woovi usa `expiresIn` em segundos e o Mercado Pago usa `date_of_expiration` com fuso, e converter exigiria assumir um fuso que a lib não assume em lugar nenhum. O campo nativo vai por `withExtra()`. +- **`Card` não cobre cartão em todo lugar, e não teria como.** Cielo e Rede + recebem o número no servidor; o **Mercado Pago recebe um token** do SDK de + navegador dele, então o PAN nunca chega lá; PagBank e Pagar.me querem + cifrado ou tokenizado; o Asaas aceita os dois, mas só tem isso modelado em + **assinatura**. Por isso só Cielo e Rede declaram `CREDIT_CARD` — e é + exatamente para isso que `supportedMethods()` serve. **Não invente mapeamento + de cartão** para os outros: no Mercado Pago seria impossível, não só errado. +- **O `Card` confere Luhn e mascara no `__debugInfo()`.** Dígito trocado pego + aqui custa nada; pego no gateway custa recusa no checkout. Número e CVV levam + `#[\SensitiveParameter]` e nunca aparecem em dump. **A bandeira não é + derivada da faixa do número** — a Cielo a exige e adivinhar seria inventar um + fato sobre o cartão. +- **Débito não existe no caminho portável.** Cielo e Pagar.me conhecem o tipo, + mas ele pede 3DS, que a lib não modela. Nenhum dos dez declara + `Method::DEBIT_CARD`, e há teste varrendo isso. +- **Na Efí o caminho portável faz boleto, não Pix**: lá `charge()` **é** o + boleto e o Pix é `pixCharge()`, noutra API. Rotear Pix aqui mudaria o retorno + de `charge()` e quebraria a v2, então `supportedMethods()` dela diz `BOLETO` + e pronto. - **`setCharge(array)` não é substituído** e não deve ser. Continua sendo o caminho de quem quer o corpo cru daquele gateway. - **A chave Pix do Inter é da conta**, por isso entra em `options()` do registry @@ -609,10 +628,11 @@ a notificação veio do gateway é `SupportsWebhookSignature`, e são coisas dif ## O que ainda está em aberto -- `ChargeIntent` cobre **Pix em sete dos dez**: Asaas, Woovi, Mercado Pago e - Inter por valor escalar, PagBank, Pagar.me e AbacatePay por itens. Falta a - fase 3 — `boleto()`, `cartao()` e um VO `Card`, para Cielo, Rede e o boleto - da Efí. A **Rede não entra em Pix**: ela só faz crédito. +- `ChargeIntent` está nos **dez**, com `pix()`, `boleto()`, `comItens()` e + `credito()`. O que falta não é gateway, é meio: **cartão nos cinco que pedem + token ou blob cifrado** (Mercado Pago, PagBank, Pagar.me, Asaas) e **débito + em qualquer um** (pede 3DS). Os dois precisam de desenho próprio, não de mais + um `fromIntent()`. **A Efí entra pela fase 3 roteando por dentro**: lá `charge()` é boleto e `pixCharge()` é a cobrança Pix, e é o `createCharge()` que decide — mudar o retorno de `charge()` quebraria a v2. diff --git a/README.md b/README.md index 775c4bd..30dca2f 100644 --- a/README.md +++ b/README.md @@ -71,12 +71,12 @@ Trocar de gateway é trocar a linha do construtor. | --- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | | **Asaas** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | **Woovi/OpenPix** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| **Efí** | — | ✅ | — | ✅ | ✅ | ✅ | — | ✅ | — | +| **Efí** | — | ✅ | — | ✅ | ✅ | ✅ | — | ✅ | ✅ | | **Mercado Pago** | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | | **PagBank** | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | | **Pagar.me** | ✅ | ✅ | ✅ | — | — | ✅ | — | ✅ | ✅ | -| **Cielo** | — | ✅ | ✅ | — | — | ✅ | — | ✅ | — | -| **Rede** | — | ✅ | ✅ | — | — | — | — | — | — | +| **Cielo** | — | ✅ | ✅ | — | — | ✅ | — | ✅ | ✅ | +| **Rede** | — | ✅ | ✅ | — | — | — | — | — | ✅ | | **AbacatePay** | ✅ | ✅ | — | — | — | — | ✅ | — | ✅ | | **Inter** | — | ✅ | — | ✅ | — | — | — | ✅ | ✅ | diff --git a/docs/cobranca-portavel.md b/docs/cobranca-portavel.md index 3453313..0267725 100644 --- a/docs/cobranca-portavel.md +++ b/docs/cobranca-portavel.md @@ -21,8 +21,9 @@ $intent = ChargeIntent::pix( $phpay->createCharge($intent); ``` -O **mesmo** `$intent` nos três gateways da primeira fase produz três corpos -diferentes, cada um na unidade e no vocabulário daquele gateway: +Os **dez** declaram a capacidade, e cada um aceita os meios que sabe fazer — +pergunte com `supportedMethods()`. O **mesmo** `$intent` produz um corpo +diferente em cada um, na unidade e no vocabulário dele: | gateway | o que sai no corpo | | --- | --- | @@ -131,6 +132,56 @@ aceita — como a `NotImplementedException` já faz com capacidades. > declarar um meio que a chamada recusaria é o mesmo erro que declarar uma > capacidade e lançar de dentro dela. +## Boleto e cartão + +```php +ChargeIntent::boleto($valor, $cliente, '2026-11-10'); + +ChargeIntent::credito($valor, $cliente, Card::make( + number: '4111 1111 1111 1111', + holderName: 'MARIA OLIVEIRA', + expirationMonth: '07', + expirationYear: '2030', + securityCode: '123', + brand: 'Visa', +), installments: 3); +``` + +O vencimento é **obrigatório no boleto** e opcional no Pix, porque é assim que +os gateways são: boleto sem vencimento não tem o que imprimir. + +### `Card` não cobre cartão em todo lugar, e não teria como + +| gateway | como recebe o cartão | declara crédito? | +| --- | --- | :-: | +| Cielo | o número, em `Payment.CreditCard` | ✅ | +| Rede | o número, em campos planos | ✅ | +| Mercado Pago | **um token** do SDK de navegador deles — o número nunca chega ao seu servidor | — | +| PagBank, Pagar.me | cifrado ou tokenizado | — | +| Asaas | token **ou** número, mas só modelado em assinatura | — | + +Não existe um objeto honesto sobre todos esses casos ao mesmo tempo. O `Card` +descreve o primeiro, e **quem não consegue usá-lo simplesmente não declara o +método** — que é exatamente para isso que `supportedMethods()` existe. + +### O número é conferido com Luhn antes de sair + +Um dígito trocado custa nada se for pego aqui, e custa uma recusa no checkout +de alguém se for pego no gateway. O `Card` também **nunca aparece num dump**: +`print_r` mostra `**** **** **** 1111` e `***` no CVV. + +### A bandeira não é adivinhada + +A Cielo exige a bandeira, e o `Card` não a deriva da faixa do número — isso +seria a biblioteca inventando um fato sobre o cartão. Sem ela, a chamada +recusa antes de qualquer HTTP. + +### Débito não existe no caminho portável + +A Cielo e o Pagar.me conhecem `DebitCard`/`debit_card`, mas débito pede +autenticação 3DS, que a biblioteca não modela. Nenhum dos dez declara +`Method::DEBIT_CARD` — e há teste varrendo isso. + ## Vencimento `dueDate` é `Y-m-d`, e é **opcional**: uma cobrança Pix sem vencimento é válida diff --git a/src/Gateways/Asaas/AsaasGateway.php b/src/Gateways/Asaas/AsaasGateway.php index 961e9e2..f23fdd0 100644 --- a/src/Gateways/Asaas/AsaasGateway.php +++ b/src/Gateways/Asaas/AsaasGateway.php @@ -93,16 +93,17 @@ public function refund(string $id, Money|int|float|null $amount = null): array /** * the payment methods createCharge() accepts here. * - * the Asaas also sells billets and credit card through `setCharge()`; - * they are absent here because the portable path cannot build them yet, - * and declaring a method this call would refuse is the same mistake as - * declaring a capability and throwing from inside it. + * the card is absent: the Asaas takes it as `creditCard` plus + * `creditCardHolderInfo`, or as a `creditCardToken`, and neither is + * modelled on the charge today — only on the subscription. Declaring a + * method this call would refuse is the same mistake as declaring a + * capability and throwing from inside it. * * @return array */ public function supportedMethods(): array { - return [Method::PIX]; + return [Method::PIX, Method::BOLETO]; } /** diff --git a/src/Gateways/Asaas/Requests/AsaasChargeRequest.php b/src/Gateways/Asaas/Requests/AsaasChargeRequest.php index c5dc34d..5048ae7 100644 --- a/src/Gateways/Asaas/Requests/AsaasChargeRequest.php +++ b/src/Gateways/Asaas/Requests/AsaasChargeRequest.php @@ -4,7 +4,7 @@ use PHPay\Asaas\Enums\BillingTypeEnum; use PHPay\Exceptions\ValidationException; -use PHPay\Support\ChargeIntent; +use PHPay\Support\{ChargeIntent, Method}; class AsaasChargeRequest { @@ -29,13 +29,33 @@ public static function fromIntent(ChargeIntent $intent): array } return [ - 'billingType' => BillingTypeEnum::PIX->value, + 'billingType' => self::billingType($intent->method), 'value' => $intent->amount->toReais(), 'dueDate' => $intent->dueDate, 'description' => $intent->descriptionOrDefault(), ] + $intent->extra(); } + /** + * the Asaas name for a payment method. + * + * the card is absent: the Asaas takes it as `creditCard` plus + * `creditCardHolderInfo`, or as a `creditCardToken`, and neither is + * modelled on the charge today — only on the subscription. + * + * @param Method $method + * @return string + * @throws ValidationException + */ + private static function billingType(Method $method): string + { + return match ($method) { + Method::PIX => BillingTypeEnum::PIX->value, + Method::BOLETO => BillingTypeEnum::BOLETO->value, + default => throw ValidationException::make('Asaas', self::messages()->method), + }; + } + /** * validate charge payload before sending it to the gateway. * @@ -70,7 +90,7 @@ public static function validate(array $charge): void /** * messages for validation * - * @return object{customer: string, billingType: string, value: string, dueDate: string, dueDateRequired: string} + * @return object{customer: string, billingType: string, value: string, dueDate: string, dueDateRequired: string, method: string} */ public static function messages(): object { @@ -79,6 +99,7 @@ public static function messages(): object 'billingType' => 'O campo billingType é obrigatório, e tem como disponível as seguintes opções: UNDEFINED, BOLETO, CREDIT_CARD, PIX.', 'value' => 'O campo value é obrigatório, deve ser numérico e maior que zero.', 'dueDate' => 'O campo dueDate é obrigatório e deve ser do tipo string.', + 'method' => 'O caminho portável do Asaas cria Pix e boleto. O cartão dele pede creditCard com creditCardHolderInfo, ou um creditCardToken, e isso ainda não está modelado na cobrança.', 'dueDateRequired' => 'O Asaas exige data de vencimento mesmo no Pix, então informe dueDate no ChargeIntent. É limite do gateway, não da biblioteca: inventar uma data aqui criaria cobrança com vencimento que ninguém escolheu.', ]; } diff --git a/src/Gateways/Cielo/CieloGateway.php b/src/Gateways/Cielo/CieloGateway.php index 5a81b3e..37bda51 100644 --- a/src/Gateways/Cielo/CieloGateway.php +++ b/src/Gateways/Cielo/CieloGateway.php @@ -4,12 +4,12 @@ use GuzzleHttp\Client; use PHPay\Cielo\Interface\CieloGatewayInterface; -use PHPay\Cielo\Requests\CieloWebhookEventRequest; +use PHPay\Cielo\Requests\{CieloSaleRequest, CieloWebhookEventRequest}; use PHPay\Cielo\Resources\Charge\Charge; use PHPay\Cielo\Resources\Subscription\Subscription; use PHPay\Exceptions\{ApiException, ValidationException}; use PHPay\Http\MasksCredentials; -use PHPay\Support\Money; +use PHPay\Support\{Card, ChargeIntent, Method, Money}; use PHPay\Webhooks\Event; class CieloGateway implements CieloGatewayInterface @@ -75,6 +75,57 @@ public function charge(): Charge * @return array * @throws ApiException */ + /** + * the payment methods createCharge() accepts here. + * + * the Cielo is the widest of the ten on the portable path: it takes the + * card number on the server, so the credit method is reachable. Debit is + * absent because it needs 3DS authentication, which the library does not + * model. + * + * @return array + */ + public function supportedMethods(): array + { + return [Method::PIX, Method::BOLETO, Method::CREDIT_CARD]; + } + + /** + * create a charge from a portable description. + * + * @param ChargeIntent $intent + * @return array + * @throws ValidationException|ApiException + */ + public function createCharge(ChargeIntent $intent): array + { + $intent->ensureSupportedBy('Cielo', $this->supportedMethods()); + + $charge = $this->charge()->setSale(CieloSaleRequest::fromIntent($intent)); + + if ($intent->customer !== null) { + $charge->setCustomer($intent->customer); + } + + if ($intent->method === Method::PIX) { + $charge->setPix($intent->amount); + } elseif ($intent->method === Method::BOLETO) { + $charge->setBoleto($intent->amount); + } else { + if (!$intent->card instanceof Card) { + throw ValidationException::make('Cielo', CieloSaleRequest::messages()->payment); + } + + $charge->setCreditCard( + $intent->amount, + CieloSaleRequest::cardFromIntent($intent->card), + $intent->installments + ); + } + + return $charge->create(); + } + public function refund(string $id, Money|int|null $amount = null): array { return $this->charge()->refund($id, $amount); diff --git a/src/Gateways/Cielo/Interface/CieloGatewayInterface.php b/src/Gateways/Cielo/Interface/CieloGatewayInterface.php index 0fcda2c..50214bc 100644 --- a/src/Gateways/Cielo/Interface/CieloGatewayInterface.php +++ b/src/Gateways/Cielo/Interface/CieloGatewayInterface.php @@ -4,7 +4,7 @@ use PHPay\Cielo\Resources\Charge\Charge; use PHPay\Cielo\Resources\Subscription\Subscription; -use PHPay\Contracts\{SupportsCharges, SupportsRefunds, SupportsSubscriptions, SupportsWebhookEvents}; +use PHPay\Contracts\{SupportsChargeIntents, SupportsCharges, SupportsRefunds, SupportsSubscriptions, SupportsWebhookEvents}; /** * the Cielo gateway offers charges and recurrences. @@ -16,6 +16,7 @@ */ interface CieloGatewayInterface extends SupportsCharges, + SupportsChargeIntents, SupportsRefunds, SupportsSubscriptions, SupportsWebhookEvents diff --git a/src/Gateways/Cielo/Requests/CieloSaleRequest.php b/src/Gateways/Cielo/Requests/CieloSaleRequest.php index 9cf8929..3403bc6 100644 --- a/src/Gateways/Cielo/Requests/CieloSaleRequest.php +++ b/src/Gateways/Cielo/Requests/CieloSaleRequest.php @@ -4,10 +4,50 @@ use PHPay\Cielo\Enums\PaymentTypeEnum; use PHPay\Exceptions\ValidationException; +use PHPay\Support\{Card, ChargeIntent}; use PHPay\Support\Customer; class CieloSaleRequest { + /** + * the Cielo envelope for a portable charge description. + * + * @param ChargeIntent $intent + * @return array + */ + public static function fromIntent(ChargeIntent $intent): array + { + $sale = $intent->reference !== null ? ['MerchantOrderId' => $intent->reference] : []; + + return $sale + $intent->extra(); + } + + /** + * the Cielo shape for the card itself. + * + * the brand is required and is NOT guessed from the number: a wrong + * brand is refused by the Cielo, and deriving it from the BIN range + * would be the library inventing a fact about the card. + * + * @param Card $card + * @return array + * @throws ValidationException + */ + public static function cardFromIntent(Card $card): array + { + if ($card->brand === null || trim($card->brand) === '') { + throw ValidationException::make('Cielo', self::messages()->brand); + } + + return [ + 'CardNumber' => $card->number, + 'Holder' => $card->holderName, + 'ExpirationDate' => $card->expiration(), + 'SecurityCode' => $card->securityCode, + 'Brand' => $card->brand, + ]; + } + /** * validate sale payload before sending it to the gateway. * @@ -59,7 +99,7 @@ public static function validate(array $sale): void /** * messages for validation * - * @return object{merchantOrderId: string, customer: string, customerName: string, payment: string, paymentType: string, amount: string} + * @return object{merchantOrderId: string, customer: string, customerName: string, payment: string, paymentType: string, amount: string, brand: string} */ public static function messages(): object { @@ -70,6 +110,7 @@ public static function messages(): object '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.', + 'brand' => 'A Cielo exige a bandeira do cartão (Visa, Master, Elo…). Informe-a no Card: a biblioteca não a adivinha pela faixa do número, porque isso seria inventar um fato sobre o cartão.', ]; } diff --git a/src/Gateways/Efi/EfiGateway.php b/src/Gateways/Efi/EfiGateway.php index 33bfc32..587b284 100644 --- a/src/Gateways/Efi/EfiGateway.php +++ b/src/Gateways/Efi/EfiGateway.php @@ -4,7 +4,7 @@ use GuzzleHttp\Client; use PHPay\Efi\Interface\EfiGatewayInterface; -use PHPay\Efi\Requests\EfiWebhookEventRequest; +use PHPay\Efi\Requests\{EfiChargeRequest, EfiWebhookEventRequest}; use PHPay\Efi\Resources\Authorization\Authorization; use PHPay\Efi\Resources\Charge\Charge; use PHPay\Efi\Resources\Pix\Pix; @@ -14,6 +14,7 @@ use PHPay\Efi\Resources\Webhook\Webhook; use PHPay\Exceptions\{ApiException, ValidationException}; use PHPay\Http\{Certificate, MasksCredentials}; +use PHPay\Support\{ChargeIntent, Method}; use PHPay\Webhooks\Event; class EfiGateway implements EfiGatewayInterface @@ -158,6 +159,42 @@ public function charge(array $charge = []): Charge * @return PixCharge * @throws ValidationException|ApiException */ + /** + * the payment methods createCharge() accepts here. + * + * only the billet. On the Efí, `charge()` IS the billet and the Pix + * charge lives in `pixCharge()`, on another API — so routing PIX here + * would mean changing what `charge()` returns, which would break the v2. + * Declaring PIX and refusing it would be worse: the honest answer is + * that the portable path does billets on this gateway. + * + * @return array + */ + public function supportedMethods(): array + { + return [Method::BOLETO]; + } + + /** + * create a charge from a portable description. + * + * @param ChargeIntent $intent + * @return array + * @throws ValidationException|ApiException + */ + public function createCharge(ChargeIntent $intent): array + { + $intent->ensureSupportedBy('Efí', $this->supportedMethods()); + + $charge = $this->charge(EfiChargeRequest::fromIntent($intent)); + + if ($intent->customer !== null) { + $charge->setCustomer($intent->customer); + } + + return $charge->create(); + } + public function pixCharge(): PixCharge { return new PixCharge($this->getPixToken(), $this->certificate, $this->sandbox, $this->pixClient); diff --git a/src/Gateways/Efi/Interface/EfiGatewayInterface.php b/src/Gateways/Efi/Interface/EfiGatewayInterface.php index 469eb93..f03c991 100644 --- a/src/Gateways/Efi/Interface/EfiGatewayInterface.php +++ b/src/Gateways/Efi/Interface/EfiGatewayInterface.php @@ -2,7 +2,7 @@ namespace PHPay\Efi\Interface; -use PHPay\Contracts\{SupportsCharges, SupportsPixKeys, SupportsSubscriptions, SupportsWebhookEvents, SupportsWebhooks}; +use PHPay\Contracts\{SupportsChargeIntents, SupportsCharges, SupportsPixKeys, SupportsSubscriptions, SupportsWebhookEvents, SupportsWebhooks}; use PHPay\Efi\Resources\Charge\Charge; use PHPay\Efi\Resources\Pix\Pix; use PHPay\Efi\Resources\PixCharge\PixCharge; @@ -21,6 +21,7 @@ */ interface EfiGatewayInterface extends SupportsCharges, + SupportsChargeIntents, SupportsWebhooks, SupportsPixKeys, SupportsSubscriptions, diff --git a/src/Gateways/Efi/Requests/EfiChargeRequest.php b/src/Gateways/Efi/Requests/EfiChargeRequest.php index ce99d78..ed3ae8e 100644 --- a/src/Gateways/Efi/Requests/EfiChargeRequest.php +++ b/src/Gateways/Efi/Requests/EfiChargeRequest.php @@ -3,10 +3,31 @@ namespace PHPay\Efi\Requests; use PHPay\Exceptions\ValidationException; -use PHPay\Support\Money; +use PHPay\Support\{ChargeIntent, Money}; class EfiChargeRequest { + /** + * the Efí payload for a portable charge description. + * + * `charge()` on the Efí is the BILLET — the Pix charge is `pixCharge()`, + * on a different API with different credentials and mTLS — so this maps + * a billet and nothing else. The value is cents here, while the same + * gateway's Pix API wants reais in a string; `Money` is what keeps the + * two apart. + * + * @param ChargeIntent $intent + * @return array + */ + public static function fromIntent(ChargeIntent $intent): array + { + return [ + 'description' => $intent->descriptionOrDefault(), + 'value' => $intent->amount->toCentavos(), + 'expire_at' => $intent->dueDate, + ] + $intent->extra(); + } + /** * validate charge payload before sending it to the gateway. * diff --git a/src/Gateways/PagarMe/PagarMeGateway.php b/src/Gateways/PagarMe/PagarMeGateway.php index 94d9374..923fdff 100644 --- a/src/Gateways/PagarMe/PagarMeGateway.php +++ b/src/Gateways/PagarMe/PagarMeGateway.php @@ -101,14 +101,14 @@ public function charge(): Charge /** * the payment methods createCharge() accepts here. * - * the Pagar.me is the gateway with the widest menu — card, debit, billet - * and Pix — and only Pix is reachable from the portable path so far. + * the card is absent: the Pagar.me takes it encrypted or tokenized, and + * neither is modelled on the charge today. * * @return array */ public function supportedMethods(): array { - return [Method::PIX]; + return [Method::PIX, Method::BOLETO]; } /** @@ -135,7 +135,13 @@ public function createCharge(ChargeIntent $intent): array $charge->setCustomer($intent->customer); } - return $charge->setPix()->create(); + if ($intent->method === Method::BOLETO) { + $charge->setBoleto($intent->dueDate); + } else { + $charge->setPix(); + } + + return $charge->create(); } public function refund(string $id, Money|int|null $amount = null): array diff --git a/src/Gateways/Rede/Interface/RedeGatewayInterface.php b/src/Gateways/Rede/Interface/RedeGatewayInterface.php index 9da9f2a..9a9bffe 100644 --- a/src/Gateways/Rede/Interface/RedeGatewayInterface.php +++ b/src/Gateways/Rede/Interface/RedeGatewayInterface.php @@ -2,7 +2,7 @@ namespace PHPay\Rede\Interface; -use PHPay\Contracts\{SupportsCharges, SupportsRefunds}; +use PHPay\Contracts\{SupportsChargeIntents, SupportsCharges, SupportsRefunds}; use PHPay\Rede\Resources\Authorization\Authorization; use PHPay\Rede\Resources\Charge\Charge; @@ -15,7 +15,7 @@ * recurring charge for the acquirer — it is not a subscription you can list, * change or cancel, so SupportsSubscriptions is deliberately not declared. */ -interface RedeGatewayInterface extends SupportsCharges, SupportsRefunds +interface RedeGatewayInterface extends SupportsCharges, SupportsChargeIntents, SupportsRefunds { /** * get resource charge from gateway. diff --git a/src/Gateways/Rede/RedeGateway.php b/src/Gateways/Rede/RedeGateway.php index cf83b3d..bbdbfc0 100644 --- a/src/Gateways/Rede/RedeGateway.php +++ b/src/Gateways/Rede/RedeGateway.php @@ -3,12 +3,14 @@ namespace PHPay\Rede; use GuzzleHttp\Client; -use PHPay\Exceptions\ApiException; -use PHPay\Http\{ClientFactory, MasksCredentials}; +use PHPay\Exceptions\{ApiException, ValidationException}; +use PHPay\Http\{ClientFactory, Idempotency, MasksCredentials}; +use PHPay\Rede\Enums\TransactionKindEnum; use PHPay\Rede\Interface\RedeGatewayInterface; +use PHPay\Rede\Requests\RedeTransactionRequest; use PHPay\Rede\Resources\Authorization\Authorization; use PHPay\Rede\Resources\Charge\Charge; -use PHPay\Support\Money; +use PHPay\Support\{Card, ChargeIntent, Method, Money}; class RedeGateway implements RedeGatewayInterface { @@ -96,6 +98,50 @@ public function charge(): Charge * @return array * @throws ApiException */ + /** + * the payment methods createCharge() accepts here. + * + * the Rede is a credit acquirer and nothing else in this library: its + * TransactionKindEnum has one case. This is the gateway that makes + * supportedMethods() necessary — it declares SupportsCharges like the + * other nine, and a Pix charge handed to it would never work. + * + * @return array + */ + public function supportedMethods(): array + { + return [Method::CREDIT_CARD]; + } + + /** + * create a charge from a portable description. + * + * @param ChargeIntent $intent + * @return array + * @throws ValidationException|ApiException + */ + public function createCharge(ChargeIntent $intent): array + { + $intent->ensureSupportedBy('Rede', $this->supportedMethods()); + + if (!$intent->card instanceof Card) { + throw ValidationException::make('Rede', RedeTransactionRequest::messages()->card); + } + + return $this->charge() + ->setTransaction($intent->extra()) + ->setReference($intent->reference ?? Idempotency::key()) + ->setCard( + $intent->card->number, + $intent->card->holderName, + $intent->card->expirationMonth, + $intent->card->expirationYear, + $intent->card->securityCode + ) + ->setPayment($intent->amount, TransactionKindEnum::CREDIT, $intent->installments) + ->create(); + } + public function refund(string $id, Money|int|null $amount = null): array { return $this->charge()->refund($id, $amount); diff --git a/src/Support/Card.php b/src/Support/Card.php new file mode 100644 index 0000000..658055f --- /dev/null +++ b/src/Support/Card.php @@ -0,0 +1,180 @@ +number); + } + + if (trim($holderName) === '') { + throw ValidationException::make('PHPay', $messages->holderName); + } + + if (preg_match('/^(0[1-9]|1[0-2])$/', $expirationMonth) !== 1) { + throw ValidationException::make('PHPay', $messages->expirationMonth); + } + + if (preg_match('/^\d{4}$/', $expirationYear) !== 1) { + throw ValidationException::make('PHPay', $messages->expirationYear); + } + + if (preg_match('/^\d{3,4}$/', $securityCode) !== 1) { + throw ValidationException::make('PHPay', $messages->securityCode); + } + } + + /** + * build a Card, stripping spaces and punctuation from the number. + * + * use this when the value comes from a form: `'4111 1111 1111 1111'` + * becomes digits. + * + * @param string $number + * @param string $holderName + * @param string $expirationMonth + * @param string $expirationYear + * @param string $securityCode + * @param string|null $brand + * @return self + * @throws ValidationException + */ + public static function make( + #[\SensitiveParameter] string $number, + string $holderName, + string $expirationMonth, + string $expirationYear, + #[\SensitiveParameter] string $securityCode, + ?string $brand = null, + ): self { + return new self( + (string) preg_replace('/\D/', '', $number), + $holderName, + str_pad($expirationMonth, 2, '0', STR_PAD_LEFT), + $expirationYear, + $securityCode, + $brand + ); + } + + /** + * the last four digits, which is what a receipt may show. + * + * @return string + */ + public function lastFour(): string + { + return substr($this->number, -4); + } + + /** + * `MM/YYYY`, which is how Cielo wants the expiry. + * + * @return string + */ + public function expiration(): string + { + return $this->expirationMonth . '/' . $this->expirationYear; + } + + /** + * never let the number or the CVV into a dump. + * + * @return array + */ + public function __debugInfo(): array + { + return [ + 'number' => '**** **** **** ' . $this->lastFour(), + 'holderName' => $this->holderName, + 'expirationMonth' => $this->expirationMonth, + 'expirationYear' => $this->expirationYear, + 'securityCode' => '***', + 'brand' => $this->brand, + ]; + } + + /** + * messages for every refusal above. + * + * @return object{number: string, holderName: string, expirationMonth: string, expirationYear: string, securityCode: string} + */ + public static function messages(): object + { + return (object) [ + 'number' => 'O número do cartão é inválido: ele não passa no dígito verificador (Luhn). Conferir aqui custa nada; conferir no gateway custa uma recusa no checkout de alguém.', + 'holderName' => 'O nome impresso no cartão é obrigatório.', + 'expirationMonth' => 'O mês de validade precisa ser de "01" a "12".', + 'expirationYear' => 'O ano de validade precisa ter quatro dígitos, como "2030".', + 'securityCode' => 'O código de segurança precisa ter três ou quatro dígitos.', + ]; + } + + /** + * @param string $number + * @return bool + */ + private static function passesLuhn(string $number): bool + { + if (preg_match('/^\d{13,19}$/', $number) !== 1) { + return false; + } + + $sum = 0; + $double = false; + + for ($i = strlen($number) - 1; $i >= 0; $i--) { + $digit = (int) $number[$i]; + + if ($double) { + $digit *= 2; + + if ($digit > 9) { + $digit -= 9; + } + } + + $sum += $digit; + $double = !$double; + } + + return $sum % 10 === 0; + } +} diff --git a/src/Support/ChargeIntent.php b/src/Support/ChargeIntent.php index 85f40fc..0af9951 100644 --- a/src/Support/ChargeIntent.php +++ b/src/Support/ChargeIntent.php @@ -37,6 +37,8 @@ final class ChargeIntent * @param string|null $description * @param string|null $reference the integrator's own id for this charge * @param array $items empty when the amount is a scalar + * @param Card|null $card only on card methods + * @param int $installments only meaningful on credit * @param array $extra fields specific to one gateway */ private function __construct( @@ -47,6 +49,8 @@ private function __construct( public readonly ?string $description = null, public readonly ?string $reference = null, array $items = [], + public readonly ?Card $card = null, + public readonly int $installments = 1, array $extra = [], ) { $this->items = $items; @@ -79,7 +83,81 @@ public static function pix( ): self { self::validate($amount, $dueDate); - return new self(Method::PIX, $amount, $customer, $dueDate, $description, $reference, [], $extra); + return new self(Method::PIX, $amount, $customer, $dueDate, $description, $reference, [], null, 1, $extra); + } + + /** + * a billet. + * + * the due date is required here and optional on Pix, because that is how + * the gateways are: a billet without one has nothing to print. + * + * @param Money $amount + * @param Customer $customer + * @param string $dueDate `Y-m-d` + * @param string|null $description + * @param string|null $reference + * @param array $extra + * @return self + * @throws ValidationException + */ + public static function boleto( + Money $amount, + Customer $customer, + string $dueDate, + ?string $description = null, + ?string $reference = null, + array $extra = [], + ): self { + self::validate($amount, $dueDate); + + return new self(Method::BOLETO, $amount, $customer, $dueDate, $description, $reference, [], null, 1, $extra); + } + + /** + * a credit card charge, with the number on the server. + * + * only the gateways that take the number itself declare this method — + * see `Card` for why there is no single object that covers the ones + * that want a token instead. + * + * @param Money $amount + * @param Customer $customer + * @param Card $card + * @param int $installments + * @param string|null $description + * @param string|null $reference + * @param array $extra + * @return self + * @throws ValidationException + */ + public static function credito( + Money $amount, + Customer $customer, + Card $card, + int $installments = 1, + ?string $description = null, + ?string $reference = null, + array $extra = [], + ): self { + self::validate($amount, null); + + if ($installments < 1) { + throw ValidationException::make('PHPay', self::messages()->installments); + } + + return new self( + Method::CREDIT_CARD, + $amount, + $customer, + null, + $description, + $reference, + [], + $card, + $installments, + $extra + ); } /** @@ -114,7 +192,7 @@ public static function comItens( self::validate($amount, $dueDate); - return new self($method, $amount, $customer, $dueDate, $description, $reference, array_values($items), $extra); + return new self($method, $amount, $customer, $dueDate, $description, $reference, array_values($items), null, 1, $extra); } /** @@ -175,6 +253,8 @@ public function withExtra(array $extra): self $this->description, $this->reference, $this->items, + $this->card, + $this->installments, array_merge($this->extra, $extra) ); } @@ -200,6 +280,8 @@ public function withDefaultReference(string $reference): self $this->description, $this->reference ?? $reference, $this->items, + $this->card, + $this->installments, $this->extra ); } @@ -246,15 +328,16 @@ public function ensureSupportedBy(string $gateway, array $supported): void /** * messages for every refusal above. * - * @return object{amount: string, dueDate: string, dueDatePast: string, method: string} + * @return object{amount: string, dueDate: string, dueDatePast: string, method: string, installments: string} */ public static function messages(): object { return (object) [ - 'amount' => 'O valor da cobrança precisa ser maior que zero.', - 'dueDate' => 'A data de vencimento precisa ser uma data no formato Y-m-d, como "2026-11-10".', - 'dueDatePast' => 'A data de vencimento não pode estar no passado.', - 'method' => 'Este gateway não cria cobrança por %s a partir de um ChargeIntent. Aceita: %s.', + 'amount' => 'O valor da cobrança precisa ser maior que zero.', + 'dueDate' => 'A data de vencimento precisa ser uma data no formato Y-m-d, como "2026-11-10".', + 'dueDatePast' => 'A data de vencimento não pode estar no passado.', + 'method' => 'Este gateway não cria cobrança por %s a partir de um ChargeIntent. Aceita: %s.', + 'installments' => 'O número de parcelas precisa ser um inteiro maior que zero.', ]; } diff --git a/tests/Unit/CapabilityTest.php b/tests/Unit/CapabilityTest.php index 8ae36d0..9161b13 100644 --- a/tests/Unit/CapabilityTest.php +++ b/tests/Unit/CapabilityTest.php @@ -40,6 +40,7 @@ Capability::PIX_KEYS, Capability::SUBSCRIPTIONS, Capability::WEBHOOK_EVENTS, + Capability::CHARGE_INTENTS, ]); })->group('phpay'); diff --git a/tests/Unit/Cielo/CieloGatewayTest.php b/tests/Unit/Cielo/CieloGatewayTest.php index f31f547..adc2e0e 100644 --- a/tests/Unit/Cielo/CieloGatewayTest.php +++ b/tests/Unit/Cielo/CieloGatewayTest.php @@ -15,6 +15,7 @@ Capability::REFUNDS, Capability::SUBSCRIPTIONS, Capability::WEBHOOK_EVENTS, + Capability::CHARGE_INTENTS, ]); })->group('cielo'); diff --git a/tests/Unit/Efi/EfiGatewayTest.php b/tests/Unit/Efi/EfiGatewayTest.php index 3a8e4de..f9b8161 100644 --- a/tests/Unit/Efi/EfiGatewayTest.php +++ b/tests/Unit/Efi/EfiGatewayTest.php @@ -84,6 +84,7 @@ Capability::PIX_KEYS, Capability::SUBSCRIPTIONS, Capability::WEBHOOK_EVENTS, + Capability::CHARGE_INTENTS, ]); })->group('efi'); @@ -92,7 +93,7 @@ expect($phpay->supports(Capability::CUSTOMERS))->toBeFalse() ->and(fn () => $phpay->customer()) - ->toThrow(NotImplementedException::class, 'Capacidades disponíveis: cobranças, webhooks, chaves Pix, assinaturas, leitura de webhook.'); + ->toThrow(NotImplementedException::class, 'Capacidades disponíveis: cobranças, webhooks, chaves Pix, assinaturas, leitura de webhook, cobrança portável.'); })->group('efi'); it('monta o payload de pessoa física na cobrança', function () { diff --git a/tests/Unit/Rede/RedeGatewayTest.php b/tests/Unit/Rede/RedeGatewayTest.php index 111cf3f..fb340b3 100644 --- a/tests/Unit/Rede/RedeGatewayTest.php +++ b/tests/Unit/Rede/RedeGatewayTest.php @@ -26,7 +26,7 @@ function redeGateway(array $api = [], array &$historicoApi = []): RedeGateway } it('declara cobranças e estornos', function () { - expect(Capability::of(redeGateway()))->toBe([Capability::CHARGES, Capability::REFUNDS]); + expect(Capability::of(redeGateway()))->toBe([Capability::CHARGES, Capability::REFUNDS, Capability::CHARGE_INTENTS]); })->group('rede'); it('recusa as outras cinco capacidades pela facade', function (Capability $capability) { diff --git a/tests/Unit/Support/CardTest.php b/tests/Unit/Support/CardTest.php new file mode 100644 index 0000000..ce7e649 --- /dev/null +++ b/tests/Unit/Support/CardTest.php @@ -0,0 +1,46 @@ + Card::make('4111 1111 1111 1112', 'MARIA OLIVEIRA', '07', '2030', '123')) + ->toThrow(ValidationException::class, Card::messages()->number); +})->group('support'); + +it('aceita o número com a pontuação que veio do formulário', function () { + expect(Card::make('4111 1111 1111 1111', 'MARIA OLIVEIRA', '07', '2030', '123')->number) + ->toBe('4111111111111111'); +})->group('support'); + +it('completa o mês com zero à esquerda', function () { + expect(Card::make('4111111111111111', 'MARIA', '7', '2030', '123')->expirationMonth)->toBe('07'); +})->group('support'); + +it('recusa mês, ano e cvv fora do formato', function (string $mes, string $ano, string $cvv) { + expect(fn () => new Card('4111111111111111', 'MARIA', $mes, $ano, $cvv)) + ->toThrow(ValidationException::class); +})->with([ + ['13', '2030', '123'], + ['00', '2030', '123'], + ['07', '30', '123'], + ['07', '2030', '12'], + ['07', '2030', '12345'], +])->group('support'); + +it('recusa cartão sem nome impresso', function () { + expect(fn () => new Card('4111111111111111', ' ', '07', '2030', '123')) + ->toThrow(ValidationException::class, Card::messages()->holderName); +})->group('support'); + +it('nunca deixa o número nem o cvv num dump', function () { + $saida = print_r(Card::make('4111111111111111', 'MARIA OLIVEIRA', '07', '2030', '123'), true); + + expect($saida)->not->toContain('4111111111111111') + ->and($saida)->not->toContain('123') + ->and($saida)->toContain('**** **** **** 1111'); +})->group('support'); + +it('entrega a validade como a cielo quer', function () { + expect(Card::make('4111111111111111', 'MARIA', '07', '2030', '123')->expiration())->toBe('07/2030'); +})->group('support'); diff --git a/tests/Unit/Support/ChargeIntentAcrossGatewaysTest.php b/tests/Unit/Support/ChargeIntentAcrossGatewaysTest.php index ab82055..1a13a0a 100644 --- a/tests/Unit/Support/ChargeIntentAcrossGatewaysTest.php +++ b/tests/Unit/Support/ChargeIntentAcrossGatewaysTest.php @@ -1,6 +1,7 @@ group('support'); it('recusa a capacidade em quem não declara, antes de qualquer HTTP', function () { - ClientFactory::fake([]); - - expect(fn () => PHPay::make('rede', ['affiliation' => 'pv', 'secret' => 's'])->createCharge(intentDeTeste())) - ->toThrow(NotImplementedException::class) - ->and(ClientFactory::recorded())->toBe([]); + /* + * depois da fase 3 os DEZ declaram a capacidade, então não há gateway da + * biblioteca para exercitar a recusa — e é a facade que está sendo + * testada, não um gateway. Um que implemente só SupportsCharges prova o + * guard sem depender de qual gateway ficou de fora. + */ + $semIntent = new class () implements SupportsCharges { + public function name(): string + { + return 'Minimo'; + } + + public function charge(): object + { + return new stdClass(); + } + }; + + expect(fn () => PHPay::gateway($semIntent)->createCharge(intentDeTeste())) + ->toThrow(NotImplementedException::class, 'Minimo não suporta cobrança portável.') + ->and(fn () => PHPay::gateway($semIntent)->supportedMethods()) + ->toThrow(NotImplementedException::class); })->group('support'); -it('declara só o que createCharge() aceita hoje', function (string $gateway, array $config) { - expect(PHPay::make($gateway, $config)->supportedMethods())->toBe([Method::PIX]); +it('declara só o que createCharge() aceita hoje', function (string $gateway, array $config, array $metodos) { + expect(PHPay::make($gateway, $config)->supportedMethods())->toBe($metodos); })->with([ - ['asaas', ['token' => 't']], - ['woovi', ['appId' => 'a']], - ['mercadopago', ['accessToken' => 'TEST-1']], + ['asaas', ['token' => 't'], [Method::PIX, Method::BOLETO]], + ['woovi', ['appId' => 'a'], [Method::PIX]], + ['mercadopago', ['accessToken' => 'TEST-1'], [Method::PIX]], + ['efi', ['clientId' => 'i', 'clientSecret' => 's'], [Method::BOLETO]], + ['cielo', ['merchantId' => 'i', 'merchantKey' => 'k'], [Method::PIX, Method::BOLETO, Method::CREDIT_CARD]], + ['rede', ['affiliation' => 'pv', 'secret' => 's'], [Method::CREDIT_CARD]], ])->group('support'); diff --git a/tests/Unit/Support/ChargeIntentCardTest.php b/tests/Unit/Support/ChargeIntentCardTest.php new file mode 100644 index 0000000..1faa017 --- /dev/null +++ b/tests/Unit/Support/ChargeIntentCardTest.php @@ -0,0 +1,152 @@ + 'i', 'merchantKey' => 'k'])->createCharge($intent); + + $tipos[] = json_decode((string) ClientFactory::recorded()[0]['request']->getBody(), true)['Payment']['Type']; + + ClientFactory::stopFaking(); + } + + expect($tipos)->toBe($esperado); +})->group('support'); + +it('manda o cartão para a cielo com as chaves dela e as parcelas', function () { + ClientFactory::fake([new Response(200, [], '{"Payment":{"PaymentId":"1"}}')]); + + PHPay::make('cielo', ['merchantId' => 'i', 'merchantKey' => 'k'])->createCharge( + ChargeIntent::credito(Money::reais('149,90'), clienteSimples(), cartaoDeTeste('Visa'), 3) + ); + + $pagamento = json_decode((string) ClientFactory::recorded()[0]['request']->getBody(), true)['Payment']; + + expect($pagamento['CreditCard']['CardNumber'])->toBe('4111111111111111') + ->and($pagamento['CreditCard']['ExpirationDate'])->toBe('07/2030') + ->and($pagamento['CreditCard']['Brand'])->toBe('Visa') + ->and($pagamento['Installments'])->toBe(3); +})->group('support'); + +it('a cielo recusa cartão sem bandeira, em vez de adivinhar pela faixa do número', function () { + ClientFactory::fake([]); + + expect(fn () => PHPay::make('cielo', ['merchantId' => 'i', 'merchantKey' => 'k']) + ->createCharge(ChargeIntent::credito(Money::reais('149,90'), clienteSimples(), cartaoDeTeste()))) + ->toThrow(ValidationException::class) + ->and(ClientFactory::recorded())->toBe([]); +})->group('support'); + +it('manda o cartão para a rede com os campos planos dela', function () { + ClientFactory::fake([ + new Response(200, [], '{"access_token":"t","expires_in":3600}'), + new Response(200, [], '{"tid":"1","returnCode":"00"}'), + ]); + + PHPay::make('rede', ['affiliation' => 'pv', 'secret' => 's'])->createCharge( + ChargeIntent::credito(Money::reais('149,90'), clienteSimples(), cartaoDeTeste(), 3) + ); + + $gravadas = ClientFactory::recorded(); + $corpo = json_decode((string) $gravadas[count($gravadas) - 1]['request']->getBody(), true); + + expect($corpo['cardNumber'])->toBe('4111111111111111') + ->and($corpo['expirationMonth'])->toBe('07') + ->and($corpo['kind'])->toBe('credit') + ->and($corpo['installments'])->toBe(3) + ->and($corpo['amount'])->toBe(14990); +})->group('support'); + +it('a rede recusa pix nomeando o que faz, antes de qualquer HTTP', function () { + ClientFactory::fake([]); + + expect(fn () => PHPay::make('rede', ['affiliation' => 'pv', 'secret' => 's']) + ->createCharge(ChargeIntent::pix(Money::reais('10,00')))) + ->toThrow(ValidationException::class, 'Aceita: cartão de crédito.') + ->and(ClientFactory::recorded())->toBe([]); +})->group('support'); + +it('o boleto da efí é o charge(), não o pixCharge()', function () { + ClientFactory::fake([ + new Response(200, [], '{"access_token":"t","token_type":"Bearer","expires_in":3600}'), + new Response(200, [], '{"data":{"charge_id":1}}'), + ]); + + PHPay::make('efi', ['clientId' => 'i', 'clientSecret' => 's'])->createCharge( + ChargeIntent::boleto(Money::reais('149,90'), clienteSimples(), '2099-11-10', 'Plano anual') + ); + + $gravadas = ClientFactory::recorded(); + $corpo = json_decode((string) $gravadas[count($gravadas) - 1]['request']->getBody(), true); + + /* centavos aqui, enquanto a API Pix do MESMO gateway quer reais em string */ + expect($corpo['items'][0]['value'])->toBe(14990) + ->and($corpo['payment']['banking_billet']['expire_at'])->toBe('2099-11-10'); +})->group('support'); + +it('a efí recusa pix no caminho portável, porque lá charge() é o boleto', function () { + ClientFactory::fake([]); + + expect(fn () => PHPay::make('efi', ['clientId' => 'i', 'clientSecret' => 's']) + ->createCharge(ChargeIntent::pix(Money::reais('10,00')))) + ->toThrow(ValidationException::class, 'Aceita: boleto.') + ->and(ClientFactory::recorded())->toBe([]); +})->group('support'); + +it('o boleto exige vencimento, e o pix não', function () { + expect(ChargeIntent::pix(Money::reais('10,00'))->dueDate)->toBeNull() + ->and(ChargeIntent::boleto(Money::reais('10,00'), clienteSimples(), '2099-11-10')->dueDate) + ->toBe('2099-11-10'); +})->group('support'); + +it('recusa parcela zero', function () { + expect(fn () => ChargeIntent::credito(Money::reais('10,00'), clienteSimples(), cartaoDeTeste('Visa'), 0)) + ->toThrow(ValidationException::class, ChargeIntent::messages()->installments); +})->group('support'); + +it('nenhum dos dez faz débito pelo caminho portável, e nenhum finge que faz', function () { + foreach (['cielo', 'rede', 'asaas', 'pagarme'] as $nome) { + $config = match ($nome) { + 'cielo' => ['merchantId' => 'i', 'merchantKey' => 'k'], + 'rede' => ['affiliation' => 'pv', 'secret' => 's'], + 'asaas' => ['token' => 't'], + 'pagarme' => ['secretKey' => 'sk_test_1'], + }; + + expect(PHPay::make($nome, $config)->supportedMethods())->not->toContain(Method::DEBIT_CARD); + } +})->group('support'); diff --git a/tests/Unit/Support/ChargeIntentItemsTest.php b/tests/Unit/Support/ChargeIntentItemsTest.php index 5ac1db9..d21e636 100644 --- a/tests/Unit/Support/ChargeIntentItemsTest.php +++ b/tests/Unit/Support/ChargeIntentItemsTest.php @@ -171,10 +171,10 @@ function urls(ChargeIntent $intent): ChargeIntent ->and($corpo)->not->toHaveKey('items'); })->group('support'); -it('declara só pix nos três que cobram por item', function (string $gateway, array $config) { - expect(PHPay::make($gateway, $config)->supportedMethods())->toBe([Method::PIX]); +it('declara o que cada um dos três que cobram por item aceita', function (string $gateway, array $config, array $metodos) { + expect(PHPay::make($gateway, $config)->supportedMethods())->toBe($metodos); })->with([ - ['pagbank', ['token' => 't']], - ['pagarme', ['secretKey' => 'sk_test_1']], - ['abacatepay', ['token' => 't']], + ['pagbank', ['token' => 't'], [Method::PIX]], + ['pagarme', ['secretKey' => 'sk_test_1'], [Method::PIX, Method::BOLETO]], + ['abacatepay', ['token' => 't'], [Method::PIX]], ])->group('support');