diff --git a/CLAUDE.md b/CLAUDE.md index c25a57d..ee6f07c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -264,7 +264,19 @@ descrição de cobrança que vale em mais de um gateway, com o mapeamento no montá-los, eles **não** entram em `supportedMethods()` — declarar meio que a chamada recusaria é o mesmo erro de declarar capacidade e lançar de dentro. - **`amount` é sempre `Money` e sempre autoritativo.** Não existe intent em que - o valor precise ser descoberto. + o valor precise ser descoberto. Com `comItens()` ele é a soma das linhas, e + `Item` guarda **preço unitário**, não o da linha. +- **Três gateways não têm valor na cobrança**: PagBank e Pagar.me somam + `items`, AbacatePay soma `products`. Intent escalar vira **uma linha só** — + o mesmo que o `getItems()` da Efí já fazia no boleto —, e intent por itens + nos de valor escalar manda o total e **perde o detalhe das linhas**, que é + limite da API deles e está documentado em vez de acontecer calado. +- **A AbacatePay recusa produto abaixo de 100 centavos**, e a recusa acontece + antes de qualquer HTTP. Não arredonde: arredondar cobra o valor errado. O + `externalId` dela sai do `reference` do `Item` e, sem ele, é **derivado do + nome** — sortear daria id novo ao mesmo produto a cada cobrança. E ela é a + única dos dez que exige `returnUrl` e `completionUrl`, porque a cobrança + dela é link de checkout; as duas são da aplicação e vêm por `withExtra()`. - **Onde o gateway expira por duração, `dueDate` é recusado**, não convertido: a Woovi usa `expiresIn` em segundos e o Mercado Pago usa `date_of_expiration` com fuso, e converter exigiria assumir um fuso que a @@ -597,11 +609,10 @@ a notificação veio do gateway é `SupportsWebhookSignature`, e são coisas dif ## O que ainda está em aberto -- `ChargeIntent` está na **fase 1**: só `pix()`, e só nos quatro de valor - escalar que fazem Pix (Asaas, Woovi, Mercado Pago, Inter). Faltam a fase 2 - (`comItens()` para PagBank, Pagar.me e AbacatePay, que montam cobrança por - itens e não por valor — e o mínimo de 100 centavos da AbacatePay) e a fase 3 - (`boleto()`, `cartao()` e um VO `Card`, para Cielo, Rede e o boleto da Efí). +- `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. **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 c3449ca..775c4bd 100644 --- a/README.md +++ b/README.md @@ -73,11 +73,11 @@ Trocar de gateway é trocar a linha do construtor. | **Woovi/OpenPix** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | **Efí** | — | ✅ | — | ✅ | ✅ | ✅ | — | ✅ | — | | **Mercado Pago** | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | -| **PagBank** | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | — | -| **Pagar.me** | ✅ | ✅ | ✅ | — | — | ✅ | — | ✅ | — | +| **PagBank** | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | +| **Pagar.me** | ✅ | ✅ | ✅ | — | — | ✅ | — | ✅ | ✅ | | **Cielo** | — | ✅ | ✅ | — | — | ✅ | — | ✅ | — | | **Rede** | — | ✅ | ✅ | — | — | — | — | — | — | -| **AbacatePay** | ✅ | ✅ | — | — | — | — | ✅ | — | — | +| **AbacatePay** | ✅ | ✅ | — | — | — | — | ✅ | — | ✅ | | **Inter** | — | ✅ | — | ✅ | — | — | — | ✅ | ✅ | diff --git a/docs/cobranca-portavel.md b/docs/cobranca-portavel.md index 05b6ed0..3453313 100644 --- a/docs/cobranca-portavel.md +++ b/docs/cobranca-portavel.md @@ -29,6 +29,9 @@ diferentes, cada um na unidade e no vocabulário daquele gateway: | Asaas | `"value": 149.9` — reais, porque o Asaas é de reais | | Woovi | `"value": 14990` — centavos | | Inter | `"valor": {"original": "149.90"}` — reais em **string**, que é o terceiro formato | +| PagBank | `items[].unit_amount` em centavos, e o total repetido em `qr_codes[0]` | +| Pagar.me | `items[].amount` em centavos, com o meio em `payments[0]` | +| AbacatePay | `products[].price` em centavos, com mínimo de 100 | E o cliente, que tem seis grafias entre os gateways, sai certo em cada um: `cpfCnpj` no Asaas, `taxID` na Woovi, `payer.identification.number` no Mercado @@ -44,6 +47,67 @@ caminho de quem quer o corpo cru daquele gateway — nada aqui o substitui. O que a capacidade fecha é a **chamada**: quem integra escreve uma, e não dez. +## Três gateways não têm valor na cobrança + +O PagBank e o Pagar.me somam `items`; a AbacatePay soma `products`. Neles não +existe "o valor da cobrança" — existe a conta das linhas. Por isso o +`ChargeIntent` tem as duas formas, e as duas funcionam nos dez: + +```php +use PHPay\Support\Item; + +$intent = ChargeIntent::comItens(Method::PIX, [ + new Item('Camiseta', Money::reais('54,90'), 2), + new Item('Caneca', Money::reais('40,00')), +], $cliente); + +$intent->amount->toCentavos(); // 14980 — a soma, e ela é autoritativa +``` + +| de | para | o que acontece | +| --- | --- | --- | +| valor escalar | gateway de itens | vira **uma linha só**, com o `description` por nome. A Efí já fazia isso no boleto desde antes | +| itens | gateway de valor escalar | sai o **total**. O detalhe das linhas se perde, porque a API deles não tem onde guardá-lo | + +A segunda linha é limite da API, não descuido — e por isso está escrita aqui +em vez de acontecer calada. O valor nunca se perde: `amount` é a soma e é o +que vai. + +### A AbacatePay recusa produto abaixo de R$ 1,00 + +```php +ChargeIntent::pix(Money::centavos(50)); // na AbacatePay: ValidationException +``` + +Ela exige 100 centavos por produto. A cobrança é **recusada antes de qualquer +HTTP**, nunca arredondada para cima — arredondar cobraria o valor errado, que é +o erro que o `Money` existe para impedir. + +### O `externalId` da AbacatePay + +É o id do produto **no seu sistema**, e ela o exige. Quando o `Item` traz +`reference`, é ele que vai. Quando não traz, o id é **derivado do nome** em vez +de sorteado, para o mesmo produto não aparecer com id diferente a cada +cobrança: + +```php +new Item('Camiseta', Money::reais('54,90'), 2, reference: 'sku-123'); +``` + +### A AbacatePay precisa de duas URLs + +É a única dos dez cuja cobrança **é um link de checkout**, então ela precisa +saber para onde mandar o pagador de volta. As duas são da sua aplicação, não da +descrição da cobrança, então vêm por `withExtra()` — e a chamada recusa quando +faltam, em vez de inventar: + +```php +$intent->withExtra([ + 'returnUrl' => 'https://loja.exemplo.com/volta', + 'completionUrl' => 'https://loja.exemplo.com/obrigado', +]); +``` + ## Pergunte o meio antes, ou deixe a chamada recusar `SupportsCharges` responde *"este gateway cobra?"*. Nunca respondeu *"cobra diff --git a/src/Gateways/AbacatePay/AbacatePayGateway.php b/src/Gateways/AbacatePay/AbacatePayGateway.php index fd9964c..f6d63ab 100644 --- a/src/Gateways/AbacatePay/AbacatePayGateway.php +++ b/src/Gateways/AbacatePay/AbacatePayGateway.php @@ -4,13 +4,14 @@ use GuzzleHttp\Client; use PHPay\AbacatePay\Interface\AbacatePayGatewayInterface; -use PHPay\AbacatePay\Requests\AbacatePayCustomerRequest; +use PHPay\AbacatePay\Requests\{AbacatePayBillingRequest, AbacatePayCustomerRequest}; use PHPay\AbacatePay\Resources\Charge\Charge; use PHPay\AbacatePay\Resources\Coupon\Coupon; use PHPay\AbacatePay\Resources\Customer\Customer; use PHPay\AbacatePay\Resources\WebhookSignature\WebhookSignature; +use PHPay\Exceptions\{ApiException, ValidationException}; use PHPay\Http\MasksCredentials; -use PHPay\Support\Customer as CustomerData; +use PHPay\Support\{ChargeIntent, Customer as CustomerData, Method}; class AbacatePayGateway implements AbacatePayGatewayInterface { @@ -72,6 +73,56 @@ public function charge(): Charge return new Charge($this->token, $this->client); } + /** + * the payment methods createCharge() accepts here. + * + * the AbacatePay only does Pix: `frequency` accepts ONE_TIME alone, and + * `methods` accepts PIX alone. + * + * @return array + */ + public function supportedMethods(): array + { + return [Method::PIX]; + } + + /** + * create a charge from a portable description. + * + * the AbacatePay bills by products, never by an amount, and refuses a + * product under one real — so a cheap scalar intent is refused here + * rather than rounded up, which would charge the wrong value. + * + * the externalId is the product's id in YOUR system. When the item + * carries no reference, it is derived from the name instead of being + * random, so the same product does not get a new id on every charge. + * + * @param ChargeIntent $intent + * @return array + * @throws ValidationException|ApiException + */ + public function createCharge(ChargeIntent $intent): array + { + $intent->ensureSupportedBy('AbacatePay', $this->supportedMethods()); + + $charge = $this->charge()->setBilling(AbacatePayBillingRequest::fromIntent($intent)); + + foreach ($intent->itemsOrOne() as $item) { + $charge->addProduct( + $item->reference ?? substr(sha1($item->name), 0, 16), + $item->name, + $item->unitAmount, + $item->quantity + ); + } + + if ($intent->customer !== null) { + $charge->setCustomer($intent->customer); + } + + return $charge->create(); + } + /** * discount coupons * diff --git a/src/Gateways/AbacatePay/Interface/AbacatePayGatewayInterface.php b/src/Gateways/AbacatePay/Interface/AbacatePayGatewayInterface.php index ba60259..40cb242 100644 --- a/src/Gateways/AbacatePay/Interface/AbacatePayGatewayInterface.php +++ b/src/Gateways/AbacatePay/Interface/AbacatePayGatewayInterface.php @@ -6,7 +6,7 @@ use PHPay\AbacatePay\Resources\Coupon\Coupon; use PHPay\AbacatePay\Resources\Customer\Customer; use PHPay\AbacatePay\Resources\WebhookSignature\WebhookSignature; -use PHPay\Contracts\{SupportsCharges, SupportsCustomers, SupportsWebhookSignature}; +use PHPay\Contracts\{SupportsChargeIntents, SupportsCharges, SupportsCustomers, SupportsWebhookSignature}; use PHPay\Support\Customer as CustomerData; /** @@ -23,6 +23,7 @@ interface AbacatePayGatewayInterface extends SupportsCustomers, SupportsCharges, + SupportsChargeIntents, SupportsWebhookSignature { /** diff --git a/src/Gateways/AbacatePay/Requests/AbacatePayBillingRequest.php b/src/Gateways/AbacatePay/Requests/AbacatePayBillingRequest.php index 3bdd674..edbf046 100644 --- a/src/Gateways/AbacatePay/Requests/AbacatePayBillingRequest.php +++ b/src/Gateways/AbacatePay/Requests/AbacatePayBillingRequest.php @@ -4,9 +4,41 @@ use PHPay\AbacatePay\Enums\{BillingFrequencyEnum, BillingMethodEnum}; use PHPay\Exceptions\ValidationException; +use PHPay\Support\ChargeIntent; class AbacatePayBillingRequest { + /** + * the AbacatePay billing for a portable charge description. + * + * the AbacatePay is the only one of the ten that cannot create a charge + * without two URLs: its v1 charge IS a checkout link, so it needs + * somewhere to send the payer back to. They have no place in a portable + * description — they belong to the application — so they come through + * withExtra() and are refused when absent, rather than invented. + * + * @param ChargeIntent $intent + * @return array + * @throws ValidationException + */ + public static function fromIntent(ChargeIntent $intent): array + { + $extra = $intent->extra(); + + foreach (['returnUrl', 'completionUrl'] as $field) { + $url = $extra[$field] ?? null; + + if (!is_string($url) || filter_var($url, FILTER_VALIDATE_URL) === false) { + throw ValidationException::make('AbacatePay', self::messages()->urlsFromIntent); + } + } + + return [ + 'frequency' => BillingFrequencyEnum::ONE_TIME->value, + 'methods' => [BillingMethodEnum::PIX->value], + ] + $extra; + } + /** * smallest price the API accepts per product, in cents */ @@ -31,7 +63,7 @@ public static function validate(array $billing): void /** * @param array $billing - * @param object{frequency: string, methods: string, products: string, productFields: string, price: string, returnUrl: string, completionUrl: string, customer: string} $messages + * @param object{frequency: string, methods: string, products: string, productFields: string, price: string, returnUrl: string, completionUrl: string, customer: string, urlsFromIntent: string} $messages * @return void * @throws ValidationException */ @@ -59,7 +91,7 @@ private static function validateFrequencyAndMethods(array $billing, object $mess /** * @param array $billing - * @param object{frequency: string, methods: string, products: string, productFields: string, price: string, returnUrl: string, completionUrl: string, customer: string} $messages + * @param object{frequency: string, methods: string, products: string, productFields: string, price: string, returnUrl: string, completionUrl: string, customer: string, urlsFromIntent: string} $messages * @return void * @throws ValidationException */ @@ -97,7 +129,7 @@ private static function validateProducts(array $billing, object $messages): void /** * @param array $billing - * @param object{frequency: string, methods: string, products: string, productFields: string, price: string, returnUrl: string, completionUrl: string, customer: string} $messages + * @param object{frequency: string, methods: string, products: string, productFields: string, price: string, returnUrl: string, completionUrl: string, customer: string, urlsFromIntent: string} $messages * @return void * @throws ValidationException */ @@ -115,7 +147,7 @@ private static function validateUrls(array $billing, object $messages): void /** * @param array $billing - * @param object{frequency: string, methods: string, products: string, productFields: string, price: string, returnUrl: string, completionUrl: string, customer: string} $messages + * @param object{frequency: string, methods: string, products: string, productFields: string, price: string, returnUrl: string, completionUrl: string, customer: string, urlsFromIntent: string} $messages * @return void * @throws ValidationException */ @@ -141,19 +173,20 @@ private static function validateCustomer(array $billing, object $messages): void /** * messages for validation * - * @return object{frequency: string, methods: string, products: string, productFields: string, price: string, returnUrl: string, completionUrl: string, customer: string} + * @return object{frequency: string, methods: string, products: string, productFields: string, price: string, returnUrl: string, completionUrl: string, customer: string, urlsFromIntent: string} */ public static function messages(): object { return (object) [ - 'frequency' => 'O campo frequency aceita apenas ONE_TIME — o AbacatePay não tem cobrança recorrente.', - 'methods' => 'O campo methods aceita exatamente um método, e hoje só PIX é suportado.', - 'products' => 'A cobrança precisa de ao menos um produto em products. Use addProduct().', - 'productFields' => 'Cada produto precisa de externalId, name e quantity — o externalId é o id do produto no SEU sistema, e precisa ser único.', - 'price' => 'O campo products[].price é obrigatório e deve ser um inteiro em CENTAVOS de no mínimo 100 (R$ 1,00). O AbacatePay não aceita valor decimal: R$ 20,00 é 2000.', - 'returnUrl' => 'O campo returnUrl é obrigatório e deve ser uma URL válida — para onde o cliente volta se desistir.', - 'completionUrl' => 'O campo completionUrl é obrigatório e deve ser uma URL válida — para onde o cliente vai após pagar.', - 'customer' => 'A cobrança precisa de customerId ou de um customer completo. Use setCustomerId() ou setCustomer().', + 'frequency' => 'O campo frequency aceita apenas ONE_TIME — o AbacatePay não tem cobrança recorrente.', + 'methods' => 'O campo methods aceita exatamente um método, e hoje só PIX é suportado.', + 'products' => 'A cobrança precisa de ao menos um produto em products. Use addProduct().', + 'productFields' => 'Cada produto precisa de externalId, name e quantity — o externalId é o id do produto no SEU sistema, e precisa ser único.', + 'price' => 'O campo products[].price é obrigatório e deve ser um inteiro em CENTAVOS de no mínimo 100 (R$ 1,00). O AbacatePay não aceita valor decimal: R$ 20,00 é 2000.', + 'returnUrl' => 'O campo returnUrl é obrigatório e deve ser uma URL válida — para onde o cliente volta se desistir.', + 'urlsFromIntent' => 'A cobrança da AbacatePay é um link de checkout, então ela precisa de returnUrl e completionUrl — e eles são da sua aplicação, não da descrição da cobrança. Informe os dois por withExtra([\'returnUrl\' => ..., \'completionUrl\' => ...]).', + 'completionUrl' => 'O campo completionUrl é obrigatório e deve ser uma URL válida — para onde o cliente vai após pagar.', + 'customer' => 'A cobrança precisa de customerId ou de um customer completo. Use setCustomerId() ou setCustomer().', ]; } } diff --git a/src/Gateways/PagBank/Interface/PagBankGatewayInterface.php b/src/Gateways/PagBank/Interface/PagBankGatewayInterface.php index a4df76c..a8757cc 100644 --- a/src/Gateways/PagBank/Interface/PagBankGatewayInterface.php +++ b/src/Gateways/PagBank/Interface/PagBankGatewayInterface.php @@ -2,7 +2,7 @@ namespace PHPay\PagBank\Interface; -use PHPay\Contracts\{SupportsCharges, SupportsCustomers, SupportsRefunds, SupportsSubscriptions, SupportsWebhookEvents, SupportsWebhookSignature}; +use PHPay\Contracts\{SupportsChargeIntents, SupportsCharges, SupportsCustomers, SupportsRefunds, SupportsSubscriptions, SupportsWebhookEvents, SupportsWebhookSignature}; use PHPay\PagBank\Resources\Charge\Charge; use PHPay\PagBank\Resources\Customer\Customer; use PHPay\PagBank\Resources\Subscription\Subscription; @@ -22,6 +22,7 @@ interface PagBankGatewayInterface extends SupportsCustomers, SupportsCharges, + SupportsChargeIntents, SupportsRefunds, SupportsSubscriptions, SupportsWebhookSignature, diff --git a/src/Gateways/PagBank/PagBankGateway.php b/src/Gateways/PagBank/PagBankGateway.php index 8e358d9..1ce3266 100644 --- a/src/Gateways/PagBank/PagBankGateway.php +++ b/src/Gateways/PagBank/PagBankGateway.php @@ -6,12 +6,12 @@ use PHPay\Exceptions\{ApiException, ValidationException}; use PHPay\Http\MasksCredentials; use PHPay\PagBank\Interface\PagBankGatewayInterface; -use PHPay\PagBank\Requests\{PagBankCustomerRequest, PagBankWebhookEventRequest}; +use PHPay\PagBank\Requests\{PagBankCustomerRequest, PagBankOrderRequest, PagBankWebhookEventRequest}; use PHPay\PagBank\Resources\Charge\Charge; use PHPay\PagBank\Resources\Customer\Customer; use PHPay\PagBank\Resources\Subscription\Subscription; use PHPay\PagBank\Resources\WebhookSignature\WebhookSignature; -use PHPay\Support\{Customer as CustomerData, Money}; +use PHPay\Support\{ChargeIntent, Customer as CustomerData, Method, Money}; use PHPay\Webhooks\Event; class PagBankGateway implements PagBankGatewayInterface @@ -82,6 +82,47 @@ public function charge(): Charge * @return array * @throws ApiException */ + /** + * the payment methods createCharge() accepts here. + * + * the PagBank also takes card through `charges`; it is absent because + * the portable path cannot build it yet. + * + * @return array + */ + public function supportedMethods(): array + { + return [Method::PIX]; + } + + /** + * create a charge from a portable description. + * + * the PagBank has no amount on the order: it is the sum of `items`, and + * the Pix amount is repeated in `qr_codes[0]`. A scalar intent becomes a + * single line, which is what itemsOrOne() is for. + * + * @param ChargeIntent $intent + * @return array + * @throws ValidationException|ApiException + */ + public function createCharge(ChargeIntent $intent): array + { + $intent->ensureSupportedBy('PagBank', $this->supportedMethods()); + + $charge = $this->charge()->setOrder(PagBankOrderRequest::fromIntent($intent)); + + foreach ($intent->itemsOrOne() as $item) { + $charge->addItem($item->name, $item->unitAmount, $item->quantity); + } + + if ($intent->customer !== null) { + $charge->setCustomer($intent->customer); + } + + return $charge->setQrCode($intent->amount)->create(); + } + public function refund(string $id, Money|int|null $amount = null): array { return $this->charge()->refund($id, $amount); diff --git a/src/Gateways/PagBank/Requests/PagBankOrderRequest.php b/src/Gateways/PagBank/Requests/PagBankOrderRequest.php index f738abc..93bb81c 100644 --- a/src/Gateways/PagBank/Requests/PagBankOrderRequest.php +++ b/src/Gateways/PagBank/Requests/PagBankOrderRequest.php @@ -3,9 +3,26 @@ namespace PHPay\PagBank\Requests; use PHPay\Exceptions\ValidationException; +use PHPay\Support\ChargeIntent; class PagBankOrderRequest { + /** + * the PagBank order for a portable charge description. + * + * only the envelope: the items, the customer and the QR Code go in + * through the Charge's own setters, which already know the shapes. + * + * @param ChargeIntent $intent + * @return array + */ + public static function fromIntent(ChargeIntent $intent): array + { + $order = $intent->reference !== null ? ['reference_id' => $intent->reference] : []; + + return $order + $intent->extra(); + } + /** * validate order payload before sending it to the gateway. * diff --git a/src/Gateways/PagarMe/Interface/PagarMeGatewayInterface.php b/src/Gateways/PagarMe/Interface/PagarMeGatewayInterface.php index cd7d190..b3d6e54 100644 --- a/src/Gateways/PagarMe/Interface/PagarMeGatewayInterface.php +++ b/src/Gateways/PagarMe/Interface/PagarMeGatewayInterface.php @@ -2,7 +2,7 @@ namespace PHPay\PagarMe\Interface; -use PHPay\Contracts\{SupportsCharges, SupportsCustomers, SupportsRefunds, SupportsSubscriptions, SupportsWebhookEvents}; +use PHPay\Contracts\{SupportsChargeIntents, SupportsCharges, SupportsCustomers, SupportsRefunds, SupportsSubscriptions, SupportsWebhookEvents}; use PHPay\PagarMe\Resources\Charge\Charge; use PHPay\PagarMe\Resources\Customer\Customer; use PHPay\PagarMe\Resources\Subscription\Subscription; @@ -22,6 +22,7 @@ interface PagarMeGatewayInterface extends SupportsCustomers, SupportsCharges, + SupportsChargeIntents, SupportsRefunds, SupportsSubscriptions, SupportsWebhookEvents diff --git a/src/Gateways/PagarMe/PagarMeGateway.php b/src/Gateways/PagarMe/PagarMeGateway.php index bdd486e..94d9374 100644 --- a/src/Gateways/PagarMe/PagarMeGateway.php +++ b/src/Gateways/PagarMe/PagarMeGateway.php @@ -6,12 +6,12 @@ use PHPay\Exceptions\{ApiException, ValidationException}; use PHPay\Http\MasksCredentials; use PHPay\PagarMe\Interface\PagarMeGatewayInterface; -use PHPay\PagarMe\Requests\{PagarMeCustomerRequest, PagarMeWebhookEventRequest}; +use PHPay\PagarMe\Requests\{PagarMeCustomerRequest, PagarMeOrderRequest, PagarMeWebhookEventRequest}; use PHPay\PagarMe\Resources\Charge\Charge; use PHPay\PagarMe\Resources\Customer\Customer; use PHPay\PagarMe\Resources\Subscription\Subscription; use PHPay\PagarMe\Resources\WebhookDelivery\WebhookDelivery; -use PHPay\Support\{Customer as CustomerData, Money}; +use PHPay\Support\{ChargeIntent, Customer as CustomerData, Method, Money}; use PHPay\Webhooks\Event; class PagarMeGateway implements PagarMeGatewayInterface @@ -98,6 +98,46 @@ public function charge(): Charge * @return array * @throws ApiException */ + /** + * 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. + * + * @return array + */ + public function supportedMethods(): array + { + return [Method::PIX]; + } + + /** + * create a charge from a portable description. + * + * the Pagar.me has no amount on the order either: it is the sum of + * `items`, and the payment method rides in `payments[]`. + * + * @param ChargeIntent $intent + * @return array + * @throws ValidationException|ApiException + */ + public function createCharge(ChargeIntent $intent): array + { + $intent->ensureSupportedBy('Pagar.me', $this->supportedMethods()); + + $charge = $this->charge()->setOrder(PagarMeOrderRequest::fromIntent($intent)); + + foreach ($intent->itemsOrOne() as $item) { + $charge->addItem($item->name, $item->unitAmount, $item->quantity); + } + + if ($intent->customer !== null) { + $charge->setCustomer($intent->customer); + } + + return $charge->setPix()->create(); + } + public function refund(string $id, Money|int|null $amount = null): array { return $this->charge()->refund($id, $amount); diff --git a/src/Gateways/PagarMe/Requests/PagarMeOrderRequest.php b/src/Gateways/PagarMe/Requests/PagarMeOrderRequest.php index dc297b9..e5c1766 100644 --- a/src/Gateways/PagarMe/Requests/PagarMeOrderRequest.php +++ b/src/Gateways/PagarMe/Requests/PagarMeOrderRequest.php @@ -4,9 +4,24 @@ use PHPay\Exceptions\ValidationException; use PHPay\PagarMe\Enums\PaymentMethodEnum; +use PHPay\Support\ChargeIntent; class PagarMeOrderRequest { + /** + * the Pagar.me order for a portable charge description. + * + * only the envelope: the items, the customer and the payment go in + * through the Charge's own setters. + * + * @param ChargeIntent $intent + * @return array + */ + public static function fromIntent(ChargeIntent $intent): array + { + return $intent->extra(); + } + /** * validate order payload before sending it to the gateway. * diff --git a/src/Support/ChargeIntent.php b/src/Support/ChargeIntent.php index c0a1ea7..85f40fc 100644 --- a/src/Support/ChargeIntent.php +++ b/src/Support/ChargeIntent.php @@ -23,6 +23,9 @@ */ final class ChargeIntent { + /** @var array */ + private array $items; + /** @var array */ private array $extra; @@ -33,6 +36,7 @@ final class ChargeIntent * @param string|null $dueDate `Y-m-d` * @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 array $extra fields specific to one gateway */ private function __construct( @@ -42,8 +46,10 @@ private function __construct( public readonly ?string $dueDate = null, public readonly ?string $description = null, public readonly ?string $reference = null, + array $items = [], array $extra = [], ) { + $this->items = $items; $this->extra = $extra; } @@ -73,7 +79,70 @@ 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, [], $extra); + } + + /** + * a charge billed by items, where the amount is their sum. + * + * three gateways have no amount on the charge at all — the PagBank and + * the Pagar.me build it from `items`, the AbacatePay from `products` — + * so for those this is the shape, not a convenience. On the other seven + * the sum is what goes out, and the line detail is dropped: that is a + * limit of those APIs, and it is written down rather than silent. + * + * @param Method $method + * @param array $items + * @param Customer|null $customer + * @param string|null $dueDate `Y-m-d` + * @param string|null $description defaults to the first item's name + * @param string|null $reference + * @param array $extra + * @return self + * @throws ValidationException + */ + public static function comItens( + Method $method, + array $items, + ?Customer $customer = null, + ?string $dueDate = null, + ?string $description = null, + ?string $reference = null, + array $extra = [], + ): self { + $amount = Item::sum($items); + + self::validate($amount, $dueDate); + + return new self($method, $amount, $customer, $dueDate, $description, $reference, array_values($items), $extra); + } + + /** + * the items, empty when the amount was given as a scalar. + * + * @return array + */ + public function items(): array + { + return $this->items; + } + + /** + * the items, or the whole charge as a single one. + * + * the three gateways that bill by item refuse a charge with none, so a + * scalar intent has to become a line. The library already does this in + * one other place — `Efi\Resources\Charge\Charge::getItems()` builds + * one item from the description and the value — and doing it here keeps + * it written once instead of three times. + * + * @return array + */ + public function itemsOrOne(): array + { + return $this->items !== [] + ? $this->items + : [new Item($this->descriptionOrDefault(), $this->amount)]; } /** @@ -105,6 +174,7 @@ public function withExtra(array $extra): self $this->dueDate, $this->description, $this->reference, + $this->items, array_merge($this->extra, $extra) ); } @@ -129,6 +199,7 @@ public function withDefaultReference(string $reference): self $this->dueDate, $this->description, $this->reference ?? $reference, + $this->items, $this->extra ); } @@ -144,7 +215,7 @@ public function withDefaultReference(string $reference): self */ public function descriptionOrDefault(): string { - return $this->description ?? 'Cobrança'; + return $this->description ?? ($this->items[0]->name ?? 'Cobrança'); } /** diff --git a/src/Support/Item.php b/src/Support/Item.php new file mode 100644 index 0000000..007e450 --- /dev/null +++ b/src/Support/Item.php @@ -0,0 +1,106 @@ +name); + } + + if ($quantity < 1) { + throw ValidationException::make('PHPay', $messages->quantity); + } + + if ($unitAmount->isZero()) { + throw ValidationException::make('PHPay', $messages->unitAmount); + } + } + + /** + * what this line costs: unit price times quantity. + * + * @return Money + */ + public function total(): Money + { + return $this->unitAmount->multiply($this->quantity); + } + + /** + * what the whole list costs. + * + * this is the boundary where a caller's array becomes items, so the + * parameter is described as what actually arrives — unvalidated — and + * the check below is what makes the promise true. It is the same shape + * the `*Request::validate()` classes use. + * + * @param array $items + * @return Money + * @throws ValidationException + */ + public static function sum(array $items): Money + { + if ($items === []) { + throw ValidationException::make('PHPay', self::messages()->empty); + } + + $total = Money::centavos(0); + + foreach ($items as $item) { + if (!$item instanceof self) { + throw ValidationException::make('PHPay', self::messages()->type); + } + + $total = $total->plus($item->total()); + } + + return $total; + } + + /** + * messages for every refusal above. + * + * @return object{name: string, quantity: string, unitAmount: string, empty: string, type: string} + */ + public static function messages(): object + { + return (object) [ + 'name' => 'O item precisa de um nome, porque os três gateways que cobram por item exigem um.', + 'quantity' => 'A quantidade do item precisa ser um inteiro maior que zero.', + 'unitAmount' => 'O preço unitário do item precisa ser maior que zero. É o preço de UMA unidade, não o da linha.', + 'empty' => 'A lista de itens não pode estar vazia: os três gateways que cobram por item recusam cobrança sem nenhum.', + 'type' => 'Cada item precisa ser um PHPay\Support\Item.', + ]; + } +} diff --git a/tests/Unit/AbacatePay/AbacatePayGatewayTest.php b/tests/Unit/AbacatePay/AbacatePayGatewayTest.php index 41bad57..65f1623 100644 --- a/tests/Unit/AbacatePay/AbacatePayGatewayTest.php +++ b/tests/Unit/AbacatePay/AbacatePayGatewayTest.php @@ -15,6 +15,7 @@ Capability::CUSTOMERS, Capability::CHARGES, Capability::WEBHOOK_SIGNATURE, + Capability::CHARGE_INTENTS, ]); })->group('abacatepay'); diff --git a/tests/Unit/PagBank/PagBankGatewayTest.php b/tests/Unit/PagBank/PagBankGatewayTest.php index c0c33ab..ed4e44b 100644 --- a/tests/Unit/PagBank/PagBankGatewayTest.php +++ b/tests/Unit/PagBank/PagBankGatewayTest.php @@ -18,6 +18,7 @@ Capability::SUBSCRIPTIONS, Capability::WEBHOOK_SIGNATURE, Capability::WEBHOOK_EVENTS, + Capability::CHARGE_INTENTS, ]); })->group('pagbank'); diff --git a/tests/Unit/PagarMe/PagarMeGatewayTest.php b/tests/Unit/PagarMe/PagarMeGatewayTest.php index fe2011d..adaea50 100644 --- a/tests/Unit/PagarMe/PagarMeGatewayTest.php +++ b/tests/Unit/PagarMe/PagarMeGatewayTest.php @@ -18,6 +18,7 @@ Capability::REFUNDS, Capability::SUBSCRIPTIONS, Capability::WEBHOOK_EVENTS, + Capability::CHARGE_INTENTS, ]); })->group('pagarme'); diff --git a/tests/Unit/Support/ChargeIntentAcrossGatewaysTest.php b/tests/Unit/Support/ChargeIntentAcrossGatewaysTest.php index 1212c64..ab82055 100644 --- a/tests/Unit/Support/ChargeIntentAcrossGatewaysTest.php +++ b/tests/Unit/Support/ChargeIntentAcrossGatewaysTest.php @@ -113,7 +113,7 @@ function intentDeTeste(?string $vencimento = null): ChargeIntent it('recusa a capacidade em quem não declara, antes de qualquer HTTP', function () { ClientFactory::fake([]); - expect(fn () => PHPay::make('pagbank', ['token' => 't'])->createCharge(intentDeTeste())) + expect(fn () => PHPay::make('rede', ['affiliation' => 'pv', 'secret' => 's'])->createCharge(intentDeTeste())) ->toThrow(NotImplementedException::class) ->and(ClientFactory::recorded())->toBe([]); })->group('support'); diff --git a/tests/Unit/Support/ChargeIntentItemsTest.php b/tests/Unit/Support/ChargeIntentItemsTest.php new file mode 100644 index 0000000..5ac1db9 --- /dev/null +++ b/tests/Unit/Support/ChargeIntentItemsTest.php @@ -0,0 +1,180 @@ + */ +function itensDeTeste(): array +{ + return [ + new Item('Camiseta', Money::reais('54,90'), 2), + new Item('Caneca', Money::reais('40,00')), + ]; +} + +function urls(ChargeIntent $intent): ChargeIntent +{ + return $intent->withExtra([ + 'returnUrl' => 'https://loja.exemplo.com/volta', + 'completionUrl' => 'https://loja.exemplo.com/obrigado', + ]); +} + +it('o valor do intent é a soma das linhas', function () { + $intent = ChargeIntent::comItens(Method::PIX, itensDeTeste()); + + expect($intent->amount->toCentavos())->toBe(14980) + ->and($intent->items())->toHaveCount(2); +})->group('support'); + +it('descreve a cobrança com o nome do primeiro item quando ninguém descreveu', function () { + expect(ChargeIntent::comItens(Method::PIX, itensDeTeste())->descriptionOrDefault())->toBe('Camiseta') + ->and(ChargeIntent::comItens(Method::PIX, itensDeTeste(), description: 'Pedido 42')->descriptionOrDefault()) + ->toBe('Pedido 42'); +})->group('support'); + +it('transforma valor escalar numa linha só, como a efí já fazia', function () { + $linhas = ChargeIntent::pix(Money::reais('149,90'), description: 'Plano anual')->itemsOrOne(); + + expect($linhas)->toHaveCount(1) + ->and($linhas[0]->name)->toBe('Plano anual') + ->and($linhas[0]->quantity)->toBe(1) + ->and($linhas[0]->unitAmount->toCentavos())->toBe(14990); +})->group('support'); + +it('manda as linhas para o pagbank, e o total no qr code', function () { + ClientFactory::fake([new Response(200, [], '{"id":"ord_1"}')]); + + PHPay::make('pagbank', ['token' => 't']) + ->createCharge(ChargeIntent::comItens(Method::PIX, itensDeTeste(), clienteCompleto())); + + $corpo = json_decode((string) ClientFactory::recorded()[0]['request']->getBody(), true); + + expect($corpo['items'])->toHaveCount(2) + ->and($corpo['items'][0]['unit_amount'])->toBe(5490) + ->and($corpo['items'][0]['quantity'])->toBe(2) + ->and($corpo['qr_codes'][0]['amount']['value'])->toBe(14980); +})->group('support'); + +it('manda as linhas para o pagar.me, com o pix em payments', function () { + ClientFactory::fake([new Response(200, [], '{"id":"or_1"}')]); + + PHPay::make('pagarme', ['secretKey' => 'sk_test_1']) + ->createCharge(ChargeIntent::comItens(Method::PIX, itensDeTeste(), clienteCompleto())); + + $corpo = json_decode((string) ClientFactory::recorded()[0]['request']->getBody(), true); + + expect($corpo['items'])->toHaveCount(2) + ->and($corpo['items'][0]['amount'])->toBe(5490) + ->and($corpo['payments'][0]['payment_method'])->toBe('pix'); +})->group('support'); + +it('manda os produtos para a abacatepay, com externalId derivado do nome', function () { + ClientFactory::fake([new Response(200, [], '{"data":{"id":"bill_1"}}')]); + + PHPay::make('abacatepay', ['token' => 't'])->createCharge( + urls(ChargeIntent::comItens(Method::PIX, itensDeTeste(), clienteCompleto())) + ); + + $corpo = json_decode((string) ClientFactory::recorded()[0]['request']->getBody(), true); + + expect($corpo['products'])->toHaveCount(2) + ->and($corpo['products'][0]['price'])->toBe(5490) + ->and($corpo['frequency'])->toBe('ONE_TIME') + ->and($corpo['methods'])->toBe(['PIX']) + ->and($corpo['products'][0]['externalId'])->toBe(substr(sha1('Camiseta'), 0, 16)); +})->group('support'); + +it('o mesmo produto não ganha um externalId novo a cada cobrança', function () { + ClientFactory::fake([ + new Response(200, [], '{"data":{"id":"bill_1"}}'), + new Response(200, [], '{"data":{"id":"bill_2"}}'), + ]); + + $gateway = PHPay::make('abacatepay', ['token' => 't']); + $intent = urls(ChargeIntent::comItens(Method::PIX, itensDeTeste(), clienteCompleto())); + + $gateway->createCharge($intent); + $gateway->createCharge($intent); + + $um = json_decode((string) ClientFactory::recorded()[0]['request']->getBody(), true); + $dois = json_decode((string) ClientFactory::recorded()[1]['request']->getBody(), true); + + expect($um['products'][0]['externalId'])->toBe($dois['products'][0]['externalId']); +})->group('support'); + +it('respeita a referência do item quando quem integra deu uma', function () { + ClientFactory::fake([new Response(200, [], '{"data":{"id":"bill_1"}}')]); + + PHPay::make('abacatepay', ['token' => 't'])->createCharge( + urls(ChargeIntent::comItens(Method::PIX, [ + new Item('Camiseta', Money::reais('54,90'), 1, reference: 'sku-123'), + ], clienteCompleto())) + ); + + $corpo = json_decode((string) ClientFactory::recorded()[0]['request']->getBody(), true); + + expect($corpo['products'][0]['externalId'])->toBe('sku-123'); +})->group('support'); + +it('recusa a abacatepay sem as urls, que são da aplicação e não da cobrança', function () { + ClientFactory::fake([]); + + expect(fn () => PHPay::make('abacatepay', ['token' => 't']) + ->createCharge(ChargeIntent::comItens(Method::PIX, itensDeTeste(), clienteCompleto()))) + ->toThrow(ValidationException::class) + ->and(ClientFactory::recorded())->toBe([]); +})->group('support'); + +it('recusa produto abaixo de um real em vez de arredondar', function () { + ClientFactory::fake([]); + + expect(fn () => PHPay::make('abacatepay', ['token' => 't']) + ->createCharge(urls(ChargeIntent::pix(Money::centavos(50), clienteCompleto())))) + ->toThrow(ValidationException::class) + ->and(ClientFactory::recorded())->toBe([]); +})->group('support'); + +it('um intent por itens também serve nos gateways de valor escalar, somando', function () { + ClientFactory::fake([new Response(200, [], '{"charge":{}}')]); + + PHPay::make('woovi', ['appId' => 'a']) + ->createCharge(ChargeIntent::comItens(Method::PIX, itensDeTeste(), clienteCompleto())); + + $corpo = json_decode((string) ClientFactory::recorded()[0]['request']->getBody(), true); + + /* a Woovi não tem linhas: sai o total, e o detalhe se perde — é limite da API dela */ + expect($corpo['value'])->toBe(14980) + ->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]); +})->with([ + ['pagbank', ['token' => 't']], + ['pagarme', ['secretKey' => 'sk_test_1']], + ['abacatepay', ['token' => 't']], +])->group('support'); diff --git a/tests/Unit/Support/ItemTest.php b/tests/Unit/Support/ItemTest.php new file mode 100644 index 0000000..0896a99 --- /dev/null +++ b/tests/Unit/Support/ItemTest.php @@ -0,0 +1,42 @@ + new Item(' ', Money::reais('10,00'))) + ->toThrow(ValidationException::class, Item::messages()->name); +})->group('support'); + +it('recusa quantidade menor que um', function () { + expect(fn () => new Item('Camiseta', Money::reais('10,00'), 0)) + ->toThrow(ValidationException::class, Item::messages()->quantity); +})->group('support'); + +it('recusa preço unitário zerado', function () { + expect(fn () => new Item('Camiseta', Money::centavos(0))) + ->toThrow(ValidationException::class, Item::messages()->unitAmount); +})->group('support'); + +it('cobra a linha, não a unidade', function () { + expect((new Item('Camiseta', Money::reais('54,90'), 2))->total()->toCentavos())->toBe(10980); +})->group('support'); + +it('soma a lista inteira', function () { + $total = Item::sum([ + new Item('Camiseta', Money::reais('54,90'), 2), + new Item('Caneca', Money::reais('40,00')), + ]); + + expect($total->toCentavos())->toBe(14980) + ->and($total->toReais())->toBe(149.80); +})->group('support'); + +it('recusa lista vazia, porque três gateways recusam cobrança sem item', function () { + expect(fn () => Item::sum([]))->toThrow(ValidationException::class, Item::messages()->empty); +})->group('support'); + +it('recusa o que não é Item', function () { + expect(fn () => Item::sum([['name' => 'Camiseta']])) + ->toThrow(ValidationException::class, Item::messages()->type); +})->group('support');