From 4577cb360e423671808741f1abb731f27e44eaa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rio=20Lucas?= Date: Mon, 26 Oct 2026 19:07:43 -0300 Subject: [PATCH] PHPAY-173: feat: bill by items, where the gateway has no amount Three of the ten have no amount on the charge at all: the PagBank and the Pagar.me build it from items, the AbacatePay from products. Item is the one shape for a line that is spelled name/unit_amount, then description/amount, then name/price. A scalar intent becomes a single line, which is what the Efi charge already did for billets, and an itemised intent on the seven that take a scalar sends the total and drops the line detail. That second direction is a limit of those APIs, so it is written down rather than silent - the amount never changes, because it is the sum and it is authoritative. The AbacatePay refuses a product under one real before any HTTP instead of rounding up, since rounding charges the wrong value. Its externalId comes from the item's reference and, without one, is derived from the name: drawing a random id would give the same product a new identity on every charge. It is also the only one of the ten that needs a return and a completion URL, because its charge is a checkout link - those belong to the application, so they arrive through withExtra() and are refused when missing. --- CLAUDE.md | 23 ++- README.md | 6 +- docs/cobranca-portavel.md | 64 +++++++ src/Gateways/AbacatePay/AbacatePayGateway.php | 55 +++++- .../Interface/AbacatePayGatewayInterface.php | 3 +- .../Requests/AbacatePayBillingRequest.php | 59 ++++-- .../Interface/PagBankGatewayInterface.php | 3 +- src/Gateways/PagBank/PagBankGateway.php | 45 ++++- .../PagBank/Requests/PagBankOrderRequest.php | 17 ++ .../Interface/PagarMeGatewayInterface.php | 3 +- src/Gateways/PagarMe/PagarMeGateway.php | 44 ++++- .../PagarMe/Requests/PagarMeOrderRequest.php | 15 ++ src/Support/ChargeIntent.php | 75 +++++++- src/Support/Item.php | 106 +++++++++++ .../Unit/AbacatePay/AbacatePayGatewayTest.php | 1 + tests/Unit/PagBank/PagBankGatewayTest.php | 1 + tests/Unit/PagarMe/PagarMeGatewayTest.php | 1 + .../ChargeIntentAcrossGatewaysTest.php | 2 +- tests/Unit/Support/ChargeIntentItemsTest.php | 180 ++++++++++++++++++ tests/Unit/Support/ItemTest.php | 42 ++++ 20 files changed, 711 insertions(+), 34 deletions(-) create mode 100644 src/Support/Item.php create mode 100644 tests/Unit/Support/ChargeIntentItemsTest.php create mode 100644 tests/Unit/Support/ItemTest.php 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');