From d7e35b70da8b3f0966d029d33ed6e51b7bc7e8ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rio=20Lucas?= Date: Mon, 26 Oct 2026 19:39:20 -0300 Subject: [PATCH] PHPAY-173: feat: billets everywhere they exist, cards where the number fits boleto() and credito() finish the portable path on all ten gateways, and Card describes a card for the two that take the number on the server. Card cannot cover every gateway, and pretending otherwise would be the lie: the Cielo and the Rede take the number, but the Mercado Pago takes a token minted by its own browser SDK, so the number never reaches the server at all, and the PagBank and the Pagar.me want it encrypted or tokenized. Those simply do not declare CREDIT_CARD, which is what supportedMethods() is for. The number is checked with Luhn before any request, because a wrong digit costs nothing here and a refusal on someone's checkout there. The number and the CVV take SensitiveParameter and never reach a dump. The brand is not derived from the number range: the Cielo requires it and guessing would be the library inventing a fact about the card. Debit is absent from all ten. The Cielo and the Pagar.me know the type, but it needs 3DS authentication, which the library does not model, and declaring a method the call would refuse is the mistake this capability was built to avoid. On the Efi the portable path does billets, not Pix: charge() there IS the billet and the Pix charge is pixCharge(), on another API, so routing Pix would change what charge() returns and break the v2. --- .github/scripts/smoke.php | 12 +- CLAUDE.md | 28 ++- README.md | 6 +- docs/cobranca-portavel.md | 55 +++++- src/Gateways/Asaas/AsaasGateway.php | 11 +- .../Asaas/Requests/AsaasChargeRequest.php | 27 ++- src/Gateways/Cielo/CieloGateway.php | 55 +++++- .../Cielo/Interface/CieloGatewayInterface.php | 3 +- .../Cielo/Requests/CieloSaleRequest.php | 43 ++++- src/Gateways/Efi/EfiGateway.php | 39 +++- .../Efi/Interface/EfiGatewayInterface.php | 3 +- .../Efi/Requests/EfiChargeRequest.php | 23 ++- src/Gateways/PagarMe/PagarMeGateway.php | 14 +- .../Rede/Interface/RedeGatewayInterface.php | 4 +- src/Gateways/Rede/RedeGateway.php | 52 ++++- src/Support/Card.php | 180 ++++++++++++++++++ src/Support/ChargeIntent.php | 97 +++++++++- tests/Unit/CapabilityTest.php | 1 + tests/Unit/Cielo/CieloGatewayTest.php | 1 + tests/Unit/Efi/EfiGatewayTest.php | 3 +- tests/Unit/Rede/RedeGatewayTest.php | 2 +- tests/Unit/Support/CardTest.php | 46 +++++ .../ChargeIntentAcrossGatewaysTest.php | 41 +++- tests/Unit/Support/ChargeIntentCardTest.php | 152 +++++++++++++++ tests/Unit/Support/ChargeIntentItemsTest.php | 10 +- 25 files changed, 846 insertions(+), 62 deletions(-) create mode 100644 src/Support/Card.php create mode 100644 tests/Unit/Support/CardTest.php create mode 100644 tests/Unit/Support/ChargeIntentCardTest.php 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');