From cc53fde1dc4560b71418e7f1c80c4dfd06917c38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rio=20Lucas?= Date: Mon, 26 Oct 2026 18:28:07 -0300 Subject: [PATCH] PHPAY-173: feat: describe a charge once, for more than one gateway ChargeIntent is to a charge what Customer is to the payer: one description, and each gateway translates it in its own *ChargeRequest::fromIntent(), where the knowledge of that schema already lives. SupportsChargeIntents declares an operation rather than a resource getter, the shape SupportsRefunds already uses. It carries two methods because SupportsCharges answers 'does this gateway charge?' and never 'does it charge that way?' - all ten declare charges and even so the Rede only does credit, so supportedMethods() is what keeps a Pix charge from being handed to a gateway that cannot do Pix. supportedMethods() describes the portable path, not the gateway's catalogue: the Asaas sells billets through setCharge() and they are absent here, because declaring a method the call would refuse is the same mistake as declaring a capability and throwing from inside it. Where a gateway expires a charge by duration instead of by date, a due date is refused rather than converted. The Woovi takes expiresIn in seconds and the Mercado Pago takes date_of_expiration with an offset; converting would mean assuming a timezone, and the library assumes none anywhere. The Inter's Pix key moved to the gateway configuration, since the same key answers for every charge of that clientId. It sits after $client in the constructor because inserting it earlier would break positional construction. setCharge(array) is untouched and stays the way to send that gateway's raw payload. --- .github/scripts/smoke.php | 39 +++- CLAUDE.md | 43 +++- README.md | 24 +- docs/README.md | 1 + docs/capacidades.md | 21 +- docs/cobranca-portavel.md | 128 ++++++++++ src/Contracts/Capability.php | 3 + src/Contracts/SupportsChargeIntents.php | 45 ++++ src/Gateways/Asaas/AsaasGateway.php | 39 +++- .../Asaas/Interface/AsaasGatewayInterface.php | 3 +- .../Asaas/Requests/AsaasChargeRequest.php | 40 +++- src/Gateways/Inter/InterGateway.php | 75 ++++++ .../Inter/Interface/InterGatewayInterface.php | 3 +- .../Interface/MercadoPagoGatewayInterface.php | 3 +- .../MercadoPago/MercadoPagoGateway.php | 39 +++- .../Requests/MercadoPagoChargeRequest.php | 27 ++- .../Woovi/Interface/WooviGatewayInterface.php | 3 +- .../Woovi/Requests/WooviChargeRequest.php | 27 ++- src/Gateways/Woovi/WooviGateway.php | 40 +++- src/PHPay.php | 45 +++- src/Support/ChargeIntent.php | 218 ++++++++++++++++++ src/Support/Gateway.php | 6 +- src/Support/Method.php | 66 ++++++ tests/Unit/CapabilityTest.php | 2 +- tests/Unit/Inter/ChargeIntentTest.php | 103 +++++++++ .../MercadoPago/MercadoPagoGatewayTest.php | 1 + .../ChargeIntentAcrossGatewaysTest.php | 127 ++++++++++ tests/Unit/Support/ChargeIntentTest.php | 67 ++++++ tests/Unit/Support/GatewayTest.php | 2 +- tests/Unit/Woovi/WooviGatewayTest.php | 4 +- 30 files changed, 1199 insertions(+), 45 deletions(-) create mode 100644 docs/cobranca-portavel.md create mode 100644 src/Contracts/SupportsChargeIntents.php create mode 100644 src/Support/ChargeIntent.php create mode 100644 src/Support/Method.php create mode 100644 tests/Unit/Inter/ChargeIntentTest.php create mode 100644 tests/Unit/Support/ChargeIntentAcrossGatewaysTest.php create mode 100644 tests/Unit/Support/ChargeIntentTest.php diff --git a/.github/scripts/smoke.php b/.github/scripts/smoke.php index 4a8471c..aed4869 100644 --- a/.github/scripts/smoke.php +++ b/.github/scripts/smoke.php @@ -34,7 +34,7 @@ use PHPay\Asaas\Enums\InvoiceStatusEnum; use PHPay\Cielo\CieloGateway; use PHPay\Cielo\Enums\SaleStatusEnum; -use PHPay\Contracts\{Capability, GatewayInterface, SupportsRefunds}; +use PHPay\Contracts\{Capability, GatewayInterface, SupportsChargeIntents, SupportsRefunds}; use PHPay\Efi\EfiGateway; use PHPay\Efi\Enums\{ChargeStatusEnum as EfiChargeStatusEnum, PixChargeStatusEnum}; use PHPay\Efi\Resources\Charge\Charge as EfiCharge; @@ -57,7 +57,7 @@ use PHPay\PHPay; use PHPay\Rede\Enums\TransactionStatusEnum; use PHPay\Rede\RedeGateway; -use PHPay\Support\{Customer, Money, StaticPixCode}; +use PHPay\Support\{ChargeIntent, Customer, Method, Money, StaticPixCode}; use PHPay\Support\{Gateway, PaymentStatus}; use PHPay\Webhooks\{Event, EventType}; use PHPay\Woovi\Enums\ChargeStatusEnum as WooviChargeStatusEnum; @@ -252,6 +252,41 @@ function () use ($phpay, $declared, $ensure, $expect): void { continue; } + // cobrança portável é operação, não getter, como o estorno. Mas aqui + // dá para exercitar os DOIS sentidos sem rede: nos que declaram, o + // método que o gateway não faz é recusado antes de qualquer HTTP; + // nos que não declaram, quem recusa é a facade. + if ($capability === Capability::CHARGE_INTENTS) { + $check( + sprintf('%s: createCharge() %s', $label, $declared ? 'recusa o método que não faz' : 'recusa a capacidade'), + function () use ($phpay, $gateway, $declared, $ensure, $expect): void { + $intent = ChargeIntent::pix(Money::reais('10,00')); + + if (!$declared) { + $expect(NotImplementedException::class, static fn () => $phpay->createCharge($intent)); + + return; + } + + $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. + $ensure( + !in_array(Method::BOLETO, $phpay->supportedMethods(), true), + 'boleto ainda não existe no caminho portável' + ); + $expect( + ValidationException::class, + static fn () => $intent->ensureSupportedBy('x', [Method::BOLETO]) + ); + } + ); + + continue; + } + // declarado, só o contrato: a chamada iria à rede. Não declarado, a // facade recusa antes de qualquer HTTP, e isso dá para exercitar. if ($capability === Capability::REFUNDS) { diff --git a/CLAUDE.md b/CLAUDE.md index 1e279fd..c25a57d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Orientações para o Claude Code trabalhar neste repositório. PHPay (`phpay-io/phpay`) é uma **biblioteca PHP** (não uma aplicação) que padroniza a integração com gateways de pagamento brasileiros. Hoje suporta **Asaas** e -**Woovi/OpenPix** (as sete capacidades), **Efí** (todas menos clientes e +**Woovi/OpenPix** (as nove capacidades), **Efí** (todas menos clientes e estornos), **Mercado Pago**, **PagBank** e **Pagar.me** (clientes, cobranças, estornos, assinaturas), **Cielo** (cobranças, estornos e recorrência), **AbacatePay** (clientes e cobranças), **Rede** (cobranças e estornos) e @@ -38,7 +38,7 @@ Regras que valem para todo código novo: - **Capacidade, não contrato único.** `GatewayInterface` carrega só `name()`. Cada recurso é uma interface em `src/Contracts/` (`SupportsCustomers`, `SupportsCharges`, `SupportsRefunds`, `SupportsWebhooks`, `SupportsPixKeys`, `SupportsSubscriptions`, - `SupportsWebhookSignature`, `SupportsWebhookEvents`) que o gateway + `SupportsWebhookSignature`, `SupportsWebhookEvents`, `SupportsChargeIntents`) que o gateway implementa se — e só se — oferecer aquele recurso. **Nunca** declare um recurso para depois lançar de dentro dele. - **Recurso novo = interface nova + case na enum `Capability` + método guardado na @@ -247,6 +247,34 @@ quebra a integração, cobra o valor errado. - `Money::reais()` **recusa mais de duas casas decimais** de propósito. Não "conserte" isso com arredondamento: é o que impede erro de um centavo na conciliação. +## Cobrança portável + +Use **`PHPay\Support\ChargeIntent`**. É o `Customer` do outro lado: uma +descrição de cobrança que vale em mais de um gateway, com o mapeamento no +`fromIntent()` de cada `*ChargeRequest` — mesmo lugar do `fromCustomer()`. + +- **Capacidade, e declara operação**: `SupportsChargeIntents extends + SupportsCharges`, com `Capability::CHARGE_INTENTS` e guard na facade. É a + forma do `SupportsRefunds`, não invenção. +- **`supportedMethods()` existe porque `SupportsCharges` não diz o meio.** Os + dez declaram cobrança e mesmo assim a Rede só faz crédito, e só quatro fazem + boleto. Sem a pergunta, um fallback cairia de um Pix para quem não faz Pix. +- **Ele descreve o caminho portável, não o catálogo do gateway.** O Asaas vende + boleto e cartão por `setCharge()`; enquanto `createCharge()` não souber + 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. +- **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 + lib não assume em lugar nenhum. O campo nativo vai por `withExtra()`. +- **`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 + (`pixKey`) e no construtor **depois** de `$client` — inserir antes quebraria + construção posicional. `setKey()` fica para quem atende mais de uma chave. + ## Status de pagamento Use **`PHPay\Support\PaymentStatus`**. É o `Money` do outro lado da chamada: dez @@ -507,7 +535,7 @@ a notificação veio do gateway é `SupportsWebhookSignature`, e são coisas dif inteiro em centavos** — os validadores recusam decimal, porque mandar `10.50` onde se espera `1050` cobra onze centavos. Pix é `qr_codes` do pedido (um só por pedido, copia-e-cola em `qr_codes[0].text`), não uma `charge`. -- **Woovi/OpenPix** — **segundo gateway com as cinco capacidades**, junto com o Asaas. +- **Woovi/OpenPix** — **segundo gateway completo**, junto com o Asaas. AppID vai **cru** no `Authorization`, sem esquema. Sandbox tem **domínio próprio** (`api.woovi-sandbox.com`). O webhook fica em `api/openpix/v1/` enquanto os demais recursos ficam em `api/v1/` — herança da fusão das marcas, não erro. Todo objeto é @@ -569,6 +597,15 @@ 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í). + **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. + - Da API Pix da Efí ficaram de fora: Pix Automático pela jornada 1 (`solicrec`, notificação no app do pagador), webhooks de recorrência e de cobrança recorrente (`webhookrec`, `webhookcobr`), envio de Pix e split. diff --git a/README.md b/README.md index 510c8ba..c3449ca 100644 --- a/README.md +++ b/README.md @@ -67,18 +67,18 @@ Trocar de gateway é trocar a linha do construtor. ## Gateways suportados -| Gateway | Clientes | Cobranças | Estornos | Webhooks | Chaves Pix | Assinaturas | Assinatura de webhook | Leitura de webhook | -| --- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | -| **Asaas** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| **Woovi/OpenPix** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| **Efí** | — | ✅ | — | ✅ | ✅ | ✅ | — | ✅ | -| **Mercado Pago** | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | -| **PagBank** | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | -| **Pagar.me** | ✅ | ✅ | ✅ | — | — | ✅ | — | ✅ | -| **Cielo** | — | ✅ | ✅ | — | — | ✅ | — | ✅ | -| **Rede** | — | ✅ | ✅ | — | — | — | — | — | -| **AbacatePay** | ✅ | ✅ | — | — | — | — | ✅ | — | -| **Inter** | — | ✅ | — | ✅ | — | — | — | ✅ | +| Gateway | Clientes | Cobranças | Estornos | Webhooks | Chaves Pix | Assinaturas | Assinatura de webhook | Leitura de webhook | Cobrança portável | +| --- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | +| **Asaas** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| **Woovi/OpenPix** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| **Efí** | — | ✅ | — | ✅ | ✅ | ✅ | — | ✅ | — | +| **Mercado Pago** | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | +| **PagBank** | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | — | +| **Pagar.me** | ✅ | ✅ | ✅ | — | — | ✅ | — | ✅ | — | +| **Cielo** | — | ✅ | ✅ | — | — | ✅ | — | ✅ | — | +| **Rede** | — | ✅ | ✅ | — | — | — | — | — | — | +| **AbacatePay** | ✅ | ✅ | — | — | — | — | ✅ | — | — | +| **Inter** | — | ✅ | — | ✅ | — | — | — | ✅ | ✅ | As interfaces correspondentes são `SupportsCustomers`, `SupportsCharges`, diff --git a/docs/README.md b/docs/README.md index 6fddb0e..bf18ce6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,7 @@ específico dele. | [Retry e idempotência](retry-e-idempotencia.md) | o que a biblioteca repete, o que ela recusa a repetir, e por quê | | [Testando](testando.md) | `ClientFactory::fake()`, para quem usa a biblioteca testar sem sair para a rede | | [Ambientes e credenciais](ambientes-e-credenciais.md) | sandbox e produção em cada gateway, e como a credencial fica fora de dump | +| [Cobrança portável](cobranca-portavel.md) | `ChargeIntent`, uma descrição de cobrança que vale em mais de um gateway | | [Cliente](cliente.md) | o VO `Customer`, porque o mesmo campo tem seis grafias entre os gateways | | [Valores monetários](valores-monetarios.md) | `Money`, porque reais e centavos não se misturam | | [Status de pagamento](status-de-pagamento.md) | `PaymentStatus`, porque "paga" tem dez grafias entre os gateways | diff --git a/docs/capacidades.md b/docs/capacidades.md index 9605c61..16f008b 100644 --- a/docs/capacidades.md +++ b/docs/capacidades.md @@ -40,15 +40,16 @@ use PHPay\Contracts\SupportsCharges; public function __construct(private SupportsCharges $gateway) {} ``` -## Duas capacidades executam, em vez de devolver recurso +## Três capacidades executam, em vez de devolver recurso Seis capacidades devolvem um **recurso** — `customer()`, `charge()`, `pix()`, -`subscription()`, `webhook()`, `webhookSignature()`. O estorno e a leitura de -webhook **executam a operação**: +`subscription()`, `webhook()`, `webhookSignature()`. O estorno, a leitura de +webhook e a cobrança portável **executam a operação**: ```php $phpay->refund($id); // devolve o dinheiro $phpay->webhookEvents($corpo); // lê a notificação +$phpay->createCharge($intent); // cria a cobrança ``` No estorno: @@ -77,3 +78,17 @@ $phpay->refund($id, Money::reais(25.50)); // o mesmo valor nos dez A [leitura de webhook](webhooks.md) é igual: o que divergia era a leitura, não um handle para alcançar. Ela devolve **lista** de eventos, nunca um — a API Pix da Efí manda vários pagamentos numa notificação só. + +A [cobrança portável](cobranca-portavel.md) pela mesma razão: `charge()` já +devolve o recurso daquele gateway, com o payload daquele gateway. O que faltava +era a **chamada** que aceita uma descrição e traduz. + +### Uma capacidade que não basta sozinha + +`SupportsCharges` responde *"este gateway cobra?"*. Não responde *"cobra +assim?"* — os dez declaram cobrança, e mesmo assim a Rede só faz cartão de +crédito. Por isso a cobrança portável traz uma segunda pergunta: + +```php +$phpay->supportedMethods(); // [Method::PIX] +``` diff --git a/docs/cobranca-portavel.md b/docs/cobranca-portavel.md new file mode 100644 index 0000000..05b6ed0 --- /dev/null +++ b/docs/cobranca-portavel.md @@ -0,0 +1,128 @@ +# Cobrança portável + +`ChargeIntent` descreve uma cobrança **uma vez**, e cada gateway que declara a +capacidade sabe traduzir. É para a cobrança o que o [`Customer`](cliente.md) é +para o cliente e o [`Money`](valores-monetarios.md) é para o valor. + +[← voltar à documentação](README.md) + +```php +use PHPay\Support\{ChargeIntent, Customer, Money}; + +$intent = ChargeIntent::pix( + amount: Money::reais('149,90'), + customer: Customer::make( + name: 'Maria Oliveira', + document: '123.456.789-09', + email: 'maria@exemplo.com', + ), +); + +$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: + +| gateway | o que sai no corpo | +| --- | --- | +| 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 | + +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 +Pago, `devedor.cpf` no Inter. + +## O que ela não resolve, e é de propósito + +**O payload da cobrança diverge, e isso é a API de cada gateway, não uma +escolha da biblioteca.** O Asaas exige `billingType` e `dueDate`; a Woovi exige +`correlationID`; o Mercado Pago exige `payment_method_id` e +`transaction_amount`. `setCharge()` continua existindo e continua sendo o +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. + +## Pergunte o meio antes, ou deixe a chamada recusar + +`SupportsCharges` responde *"este gateway cobra?"*. Nunca respondeu *"cobra +assim?"* — e a diferença importa: os dez declaram cobrança, e mesmo assim a +Rede só faz cartão de crédito e só quatro fazem boleto. + +```php +use PHPay\Support\Method; + +if (in_array(Method::PIX, $phpay->supportedMethods(), true)) { + $phpay->createCharge($intent); +} +``` + +Sem perguntar, a chamada recusa com uma mensagem que nomeia o que o gateway +aceita — como a `NotImplementedException` já faz com capacidades. + +> `supportedMethods()` descreve **o caminho portável**, não tudo o que o +> gateway vende. O Asaas também emite boleto e cartão por `setCharge()`; eles +> não aparecem aqui porque `createCharge()` ainda não sabe montá-los, e +> declarar um meio que a chamada recusaria é o mesmo erro que declarar uma +> capacidade e lançar de dentro dela. + +## Vencimento + +`dueDate` é `Y-m-d`, e é **opcional**: uma cobrança Pix sem vencimento é válida +— é a diferença entre `cob` e `cobv` na especificação do BACEN. + +Onde o gateway expira por **duração** em vez de data, a chamada recusa em vez +de converter: + +| gateway | vencimento | +| --- | --- | +| Asaas | **exige** `dueDate`, mesmo no Pix | +| Inter | opcional; com ele a cobrança vai para `cobv`, sem ele para `cob` | +| Woovi | expira por `expiresIn`, em segundos — **recusa** `dueDate` | +| Mercado Pago | expira por `date_of_expiration`, data e hora com fuso — **recusa** `dueDate` | + +Converter uma data em duração exigiria assumir um fuso horário, e a biblioteca +não assume nenhum. Quem precisa disso informa o campo nativo: + +```php +$intent->withExtra(['expiresIn' => 3600]); +``` + +## Referência + +Quatro gateways querem o id da cobrança no **seu** sistema, sob quatro nomes: +`correlationID` na Woovi, `MerchantOrderId` na Cielo, `reference` na Rede, +`txid` no Inter. `reference` é o nome único: + +```php +ChargeIntent::pix(Money::reais('149,90'), reference: 'pedido-42'); +``` + +Sem ela, quem precisa gera uma com `Idempotency::key()`. + +## O que é específico de um gateway + +`withExtra()`, como no `Customer` — e com o mesmo aviso: um campo +**obrigatório** caindo ali é sinal de que falta algo ao value object, não +solução. + +```php +$intent->withExtra(['soft_descriptor' => 'MINHA LOJA']); +``` + +## O Inter endereça por chave Pix + +A chave é da **conta**, não da cobrança: a mesma responde por todas as +cobranças daquele `clientId`. Por isso ela vive na configuração do gateway: + +```php +PHPay::make('inter', [ + 'clientId' => getenv('INTER_CLIENT_ID'), + 'clientSecret' => getenv('INTER_CLIENT_SECRET'), + 'certificate' => Certificate::pair('/certificado.crt', '/chave.key'), + 'pixKey' => getenv('INTER_PIX_KEY'), +]); +``` + +Quem atende mais de uma chave continua com `charge()->setKey()`. diff --git a/src/Contracts/Capability.php b/src/Contracts/Capability.php index 956933a..f12a821 100644 --- a/src/Contracts/Capability.php +++ b/src/Contracts/Capability.php @@ -18,6 +18,7 @@ enum Capability: string case SUBSCRIPTIONS = 'subscriptions'; case WEBHOOK_SIGNATURE = 'webhook-signature'; case WEBHOOK_EVENTS = 'webhook-events'; + case CHARGE_INTENTS = 'charge-intents'; /** * interface that declares this capability. @@ -35,6 +36,7 @@ public function contract(): string self::SUBSCRIPTIONS => SupportsSubscriptions::class, self::WEBHOOK_SIGNATURE => SupportsWebhookSignature::class, self::WEBHOOK_EVENTS => SupportsWebhookEvents::class, + self::CHARGE_INTENTS => SupportsChargeIntents::class, }; } @@ -54,6 +56,7 @@ public function label(): string self::SUBSCRIPTIONS => 'assinaturas', self::WEBHOOK_SIGNATURE => 'assinatura de webhook', self::WEBHOOK_EVENTS => 'leitura de webhook', + self::CHARGE_INTENTS => 'cobrança portável', }; } diff --git a/src/Contracts/SupportsChargeIntents.php b/src/Contracts/SupportsChargeIntents.php new file mode 100644 index 0000000..0826c07 --- /dev/null +++ b/src/Contracts/SupportsChargeIntents.php @@ -0,0 +1,45 @@ + + */ + public function supportedMethods(): array; + + /** + * create a charge from a portable description. + * + * @param ChargeIntent $intent + * @return array + * @throws ValidationException|ApiException + */ + public function createCharge(ChargeIntent $intent): array; +} diff --git a/src/Gateways/Asaas/AsaasGateway.php b/src/Gateways/Asaas/AsaasGateway.php index b5fe19e..961e9e2 100644 --- a/src/Gateways/Asaas/AsaasGateway.php +++ b/src/Gateways/Asaas/AsaasGateway.php @@ -4,7 +4,7 @@ use GuzzleHttp\Client; use PHPay\Asaas\Interface\AsaasGatewayInterface; -use PHPay\Asaas\Requests\{AsaasCustomerRequest, AsaasWebhookEventRequest}; +use PHPay\Asaas\Requests\{AsaasChargeRequest, AsaasCustomerRequest, AsaasWebhookEventRequest}; use PHPay\Asaas\Resources\Charge\Charge; use PHPay\Asaas\Resources\Customer\Customer; use PHPay\Asaas\Resources\Pix\Pix; @@ -13,7 +13,7 @@ use PHPay\Asaas\Resources\WebhookSignature\WebhookSignature; use PHPay\Exceptions\{ApiException, ValidationException}; use PHPay\Http\MasksCredentials; -use PHPay\Support\{Customer as CustomerData, Money}; +use PHPay\Support\{ChargeIntent, Customer as CustomerData, Method, Money}; use PHPay\Webhooks\Event; class AsaasGateway implements AsaasGatewayInterface @@ -90,6 +90,41 @@ public function refund(string $id, Money|int|float|null $amount = null): array return $this->charge()->refund($id, $amount); } + /** + * 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. + * + * @return array + */ + public function supportedMethods(): array + { + return [Method::PIX]; + } + + /** + * create a charge from a portable description. + * + * @param ChargeIntent $intent + * @return array + * @throws ValidationException|ApiException + */ + public function createCharge(ChargeIntent $intent): array + { + $intent->ensureSupportedBy('Asaas', $this->supportedMethods()); + + $charge = $this->charge()->setCharge(AsaasChargeRequest::fromIntent($intent)); + + if ($intent->customer !== null) { + $charge->setCustomer($intent->customer); + } + + return $charge->create(); + } + /** * webhook * diff --git a/src/Gateways/Asaas/Interface/AsaasGatewayInterface.php b/src/Gateways/Asaas/Interface/AsaasGatewayInterface.php index 5647de4..de0737f 100644 --- a/src/Gateways/Asaas/Interface/AsaasGatewayInterface.php +++ b/src/Gateways/Asaas/Interface/AsaasGatewayInterface.php @@ -8,7 +8,7 @@ use PHPay\Asaas\Resources\Subscription\Subscription; use PHPay\Asaas\Resources\Webhook\Webhook; use PHPay\Asaas\Resources\WebhookSignature\WebhookSignature; -use PHPay\Contracts\{SupportsCharges, SupportsCustomers, SupportsPixKeys, SupportsRefunds, SupportsSubscriptions, SupportsWebhookEvents, SupportsWebhookSignature, SupportsWebhooks}; +use PHPay\Contracts\{SupportsChargeIntents, SupportsCharges, SupportsCustomers, SupportsPixKeys, SupportsRefunds, SupportsSubscriptions, SupportsWebhookEvents, SupportsWebhookSignature, SupportsWebhooks}; use PHPay\Support\Customer as CustomerData; /** @@ -17,6 +17,7 @@ interface AsaasGatewayInterface extends SupportsCustomers, SupportsCharges, + SupportsChargeIntents, SupportsRefunds, SupportsWebhooks, SupportsPixKeys, diff --git a/src/Gateways/Asaas/Requests/AsaasChargeRequest.php b/src/Gateways/Asaas/Requests/AsaasChargeRequest.php index 614c3ea..c5dc34d 100644 --- a/src/Gateways/Asaas/Requests/AsaasChargeRequest.php +++ b/src/Gateways/Asaas/Requests/AsaasChargeRequest.php @@ -4,9 +4,38 @@ use PHPay\Asaas\Enums\BillingTypeEnum; use PHPay\Exceptions\ValidationException; +use PHPay\Support\ChargeIntent; class AsaasChargeRequest { + /** + * the Asaas payload for a portable charge description. + * + * the amount goes out in reais, because the Asaas is one of the two + * gateways that want a decimal — `Money` is what keeps that from being + * a guess. the customer is NOT here: the Asaas attaches it by id, and + * the Charge resource already knows how to reuse or create one. + * + * @param ChargeIntent $intent + * @return array + * @throws ValidationException + */ + public static function fromIntent(ChargeIntent $intent): array + { + $messages = self::messages(); + + if ($intent->dueDate === null) { + throw ValidationException::make('Asaas', $messages->dueDateRequired); + } + + return [ + 'billingType' => BillingTypeEnum::PIX->value, + 'value' => $intent->amount->toReais(), + 'dueDate' => $intent->dueDate, + 'description' => $intent->descriptionOrDefault(), + ] + $intent->extra(); + } + /** * validate charge payload before sending it to the gateway. * @@ -41,15 +70,16 @@ public static function validate(array $charge): void /** * messages for validation * - * @return object{customer: string, billingType: string, value: string, dueDate: string} + * @return object{customer: string, billingType: string, value: string, dueDate: string, dueDateRequired: string} */ public static function messages(): object { return (object) [ - 'customer' => 'O campo customer é obrigatório e deve ser do tipo string.', - '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.', + 'customer' => 'O campo customer é obrigatório e deve ser do tipo string.', + '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.', + '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/Inter/InterGateway.php b/src/Gateways/Inter/InterGateway.php index 356ea67..7c36749 100644 --- a/src/Gateways/Inter/InterGateway.php +++ b/src/Gateways/Inter/InterGateway.php @@ -10,6 +10,7 @@ use PHPay\Inter\Resources\Authorization\Authorization; use PHPay\Inter\Resources\Charge\Charge; use PHPay\Inter\Resources\Webhook\Webhook; +use PHPay\Support\{ChargeIntent, Method}; use PHPay\Webhooks\Event; /** @@ -97,6 +98,15 @@ public function __construct( ?string $scopes = null, private ?string $account = null, private ?Client $client = null, + /* + * the Pix key belongs to the account, not to the charge: the same key + * answers for every charge of that clientId. it sits after $client, + * which is the odd place, because $client is already the seventh + * parameter and inserting before it would break positional + * construction. whoever resolves through the registry never sees the + * order. + */ + private ?string $pixKey = null, ) { $this->certificate = is_string($certificate) ? new Certificate($certificate) : $certificate; $this->scopes = $scopes !== null && trim($scopes) !== '' ? trim($scopes) : self::SCOPES; @@ -144,6 +154,71 @@ public function charge(): Charge return new Charge($this->getToken(), $this->certificate, $this->sandbox, $this->account, $this->client); } + /** + * the payment methods createCharge() accepts here. + * + * the Inter's billet is another API (`/cobranca/v3`) that the library + * does not speak, so Pix is all there is. + * + * @return array + */ + public function supportedMethods(): array + { + return [Method::PIX]; + } + + /** + * create a charge from a portable description. + * + * the Inter is the only gateway here with no `setCharge()`: its Charge + * builds the nested BACEN payload through its own setters, and those + * setters already are the mapping. The Pix key comes from the account, + * not from the intent — a charge description has no business carrying + * account configuration. + * + * @param ChargeIntent $intent + * @return array + * @throws ValidationException|ApiException + */ + public function createCharge(ChargeIntent $intent): array + { + $intent->ensureSupportedBy('Inter', $this->supportedMethods()); + + if ($this->pixKey === null || trim($this->pixKey) === '') { + throw ValidationException::make('Inter', self::messages()->pixKey); + } + + $charge = $this->charge() + ->setAmount($intent->amount) + ->setKey($this->pixKey); + + if ($intent->customer !== null) { + $charge->setCustomer($intent->customer); + } + + if ($intent->description !== null) { + $charge->setDescription($intent->description); + } + + if ($intent->dueDate !== null) { + $charge->setDueDate($intent->dueDate); + } + + return $charge->create($intent->reference); + } + + /** + * messages for what this gateway refuses before any HTTP. + * + * @return object{pixKey: string} + */ + public static function messages(): object + { + return (object) [ + 'pixKey' => 'O Inter endereça a cobrança a uma chave Pix, e ela é da conta, não da cobrança. Informe "pixKey" na configuração do gateway, ou use charge()->setKey() se a aplicação atende mais de uma chave.', + ]; + } + /** * webhooks — one per Pix key. * diff --git a/src/Gateways/Inter/Interface/InterGatewayInterface.php b/src/Gateways/Inter/Interface/InterGatewayInterface.php index 81370f9..6385866 100644 --- a/src/Gateways/Inter/Interface/InterGatewayInterface.php +++ b/src/Gateways/Inter/Interface/InterGatewayInterface.php @@ -2,7 +2,7 @@ namespace PHPay\Inter\Interface; -use PHPay\Contracts\{SupportsCharges, SupportsWebhookEvents, SupportsWebhooks}; +use PHPay\Contracts\{SupportsChargeIntents, SupportsCharges, SupportsWebhookEvents, SupportsWebhooks}; use PHPay\Inter\Resources\Charge\Charge; use PHPay\Inter\Resources\Webhook\Webhook; @@ -27,6 +27,7 @@ */ interface InterGatewayInterface extends SupportsCharges, + SupportsChargeIntents, SupportsWebhooks, SupportsWebhookEvents { diff --git a/src/Gateways/MercadoPago/Interface/MercadoPagoGatewayInterface.php b/src/Gateways/MercadoPago/Interface/MercadoPagoGatewayInterface.php index 1dc75b5..48f47a5 100644 --- a/src/Gateways/MercadoPago/Interface/MercadoPagoGatewayInterface.php +++ b/src/Gateways/MercadoPago/Interface/MercadoPagoGatewayInterface.php @@ -2,7 +2,7 @@ namespace PHPay\MercadoPago\Interface; -use PHPay\Contracts\{SupportsCharges, SupportsCustomers, SupportsRefunds, SupportsSubscriptions, SupportsWebhookEvents, SupportsWebhookSignature}; +use PHPay\Contracts\{SupportsChargeIntents, SupportsCharges, SupportsCustomers, SupportsRefunds, SupportsSubscriptions, SupportsWebhookEvents, SupportsWebhookSignature}; use PHPay\MercadoPago\Resources\Charge\Charge; use PHPay\MercadoPago\Resources\Customer\Customer; use PHPay\MercadoPago\Resources\Subscription\Subscription; @@ -22,6 +22,7 @@ interface MercadoPagoGatewayInterface extends SupportsCustomers, SupportsCharges, + SupportsChargeIntents, SupportsRefunds, SupportsSubscriptions, SupportsWebhookSignature, diff --git a/src/Gateways/MercadoPago/MercadoPagoGateway.php b/src/Gateways/MercadoPago/MercadoPagoGateway.php index ec18ee4..38458c6 100644 --- a/src/Gateways/MercadoPago/MercadoPagoGateway.php +++ b/src/Gateways/MercadoPago/MercadoPagoGateway.php @@ -6,12 +6,12 @@ use PHPay\Exceptions\{ApiException, ValidationException}; use PHPay\Http\MasksCredentials; use PHPay\MercadoPago\Interface\MercadoPagoGatewayInterface; -use PHPay\MercadoPago\Requests\{MercadoPagoCustomerRequest, MercadoPagoWebhookEventRequest}; +use PHPay\MercadoPago\Requests\{MercadoPagoChargeRequest, MercadoPagoCustomerRequest, MercadoPagoWebhookEventRequest}; use PHPay\MercadoPago\Resources\Charge\Charge; use PHPay\MercadoPago\Resources\Customer\Customer; use PHPay\MercadoPago\Resources\Subscription\Subscription; use PHPay\MercadoPago\Resources\WebhookSignature\WebhookSignature; -use PHPay\Support\{Customer as CustomerData, Money}; +use PHPay\Support\{ChargeIntent, Customer as CustomerData, Method, Money}; use PHPay\Webhooks\Event; class MercadoPagoGateway implements MercadoPagoGatewayInterface @@ -99,6 +99,41 @@ public function charge(): Charge * @return array * @throws ApiException */ + /** + * the payment methods createCharge() accepts here. + * + * the Mercado Pago also sells billets and card through `setCharge()`; + * they are absent because the portable path cannot build them yet. + * + * @return array + */ + public function supportedMethods(): array + { + return [Method::PIX]; + } + + /** + * create a charge from a portable description. + * + * @param ChargeIntent $intent + * @return array + * @throws ValidationException|ApiException + */ + public function createCharge(ChargeIntent $intent): array + { + $intent->ensureSupportedBy('Mercado Pago', $this->supportedMethods()); + + $charge = $this->charge() + ->setCharge(MercadoPagoChargeRequest::fromIntent($intent)) + ->setAmount($intent->amount); + + if ($intent->customer !== null) { + $charge->setPayer($intent->customer); + } + + return $charge->create(); + } + public function refund(string $id, Money|int|float|null $amount = null): array { return $this->charge()->refund($id, $amount); diff --git a/src/Gateways/MercadoPago/Requests/MercadoPagoChargeRequest.php b/src/Gateways/MercadoPago/Requests/MercadoPagoChargeRequest.php index 48f5422..6ca718a 100644 --- a/src/Gateways/MercadoPago/Requests/MercadoPagoChargeRequest.php +++ b/src/Gateways/MercadoPago/Requests/MercadoPagoChargeRequest.php @@ -3,9 +3,33 @@ namespace PHPay\MercadoPago\Requests; use PHPay\Exceptions\ValidationException; +use PHPay\Support\ChargeIntent; class MercadoPagoChargeRequest { + /** + * the Mercado Pago payload for a portable charge description. + * + * the amount is absent here because `setAmount()` normalizes it into the + * array — the Mercado Pago is the other gateway that wants reais, and + * reading the number straight from the intent would skip that. + * + * @param ChargeIntent $intent + * @return array + * @throws ValidationException + */ + public static function fromIntent(ChargeIntent $intent): array + { + if ($intent->dueDate !== null) { + throw ValidationException::make('Mercado Pago', self::messages()->dueDate); + } + + return [ + 'payment_method_id' => 'pix', + 'description' => $intent->descriptionOrDefault(), + ] + $intent->extra(); + } + /** * validate charge payload before sending it to the gateway. * @@ -47,7 +71,7 @@ public static function validate(array $charge): void /** * messages for validation * - * @return object{transactionAmount: string, paymentMethodId: string, payer: string, payerEmail: string} + * @return object{transactionAmount: string, paymentMethodId: string, payer: string, payerEmail: string, dueDate: string} */ public static function messages(): object { @@ -56,6 +80,7 @@ public static function messages(): object 'paymentMethodId' => 'O campo payment_method_id é obrigatório. Use "pix", "bolbradesco" ou o id devolvido pela tokenização do cartão.', 'payer' => 'O campo payer é obrigatório e deve ser um array.', 'payerEmail' => 'O campo payer.email é obrigatório e deve ser um e-mail válido.', + 'dueDate' => 'O Mercado Pago expira cobrança por date_of_expiration, que é data E hora com fuso. Converter dueDate aqui exigiria assumir um fuso horário, e a biblioteca não assume nenhum — informe date_of_expiration por withExtra().', ]; } } diff --git a/src/Gateways/Woovi/Interface/WooviGatewayInterface.php b/src/Gateways/Woovi/Interface/WooviGatewayInterface.php index 1a67441..db154ed 100644 --- a/src/Gateways/Woovi/Interface/WooviGatewayInterface.php +++ b/src/Gateways/Woovi/Interface/WooviGatewayInterface.php @@ -2,7 +2,7 @@ namespace PHPay\Woovi\Interface; -use PHPay\Contracts\{SupportsCharges, SupportsCustomers, SupportsPixKeys, SupportsRefunds, SupportsSubscriptions, SupportsWebhookEvents, SupportsWebhookSignature, SupportsWebhooks}; +use PHPay\Contracts\{SupportsChargeIntents, SupportsCharges, SupportsCustomers, SupportsPixKeys, SupportsRefunds, SupportsSubscriptions, SupportsWebhookEvents, SupportsWebhookSignature, SupportsWebhooks}; use PHPay\Support\Customer as CustomerData; use PHPay\Woovi\Resources\Charge\Charge; use PHPay\Woovi\Resources\Customer\Customer; @@ -21,6 +21,7 @@ interface WooviGatewayInterface extends SupportsCustomers, SupportsCharges, + SupportsChargeIntents, SupportsRefunds, SupportsWebhooks, SupportsPixKeys, diff --git a/src/Gateways/Woovi/Requests/WooviChargeRequest.php b/src/Gateways/Woovi/Requests/WooviChargeRequest.php index c7d8fc4..cca772c 100644 --- a/src/Gateways/Woovi/Requests/WooviChargeRequest.php +++ b/src/Gateways/Woovi/Requests/WooviChargeRequest.php @@ -3,9 +3,33 @@ namespace PHPay\Woovi\Requests; use PHPay\Exceptions\ValidationException; +use PHPay\Http\Idempotency; +use PHPay\Support\ChargeIntent; class WooviChargeRequest { + /** + * the Woovi payload for a portable charge description. + * + * the amount is absent on purpose: the Woovi takes it in `create()`, not + * in the body. the `correlationID` is filled when the caller gave no + * reference, because the Woovi addresses every object by it. + * + * @param ChargeIntent $intent + * @return array + * @throws ValidationException + */ + public static function fromIntent(ChargeIntent $intent): array + { + if ($intent->dueDate !== null) { + throw ValidationException::make('Woovi', self::messages()->dueDate); + } + + return [ + 'correlationID' => $intent->reference ?? Idempotency::key(), + ] + $intent->extra(); + } + /** * validate charge payload before sending it to the gateway. * @@ -40,7 +64,7 @@ public static function validate(array $charge): void /** * messages for validation * - * @return object{correlationId: string, value: string, customer: string} + * @return object{correlationId: string, value: string, customer: string, dueDate: string} */ public static function messages(): object { @@ -48,6 +72,7 @@ public static function messages(): object 'correlationId' => 'O campo correlationID é obrigatório — é o identificador da cobrança no SEU sistema, e é por ele que você consulta depois.', 'value' => 'O campo value é obrigatório e deve ser um inteiro em CENTAVOS maior que zero. O Woovi não aceita valor decimal: R$ 100,50 é 10050.', 'customer' => 'O campo customer, quando informado, deve ser um array.', + 'dueDate' => 'A Woovi expira cobrança por DURAÇÃO (expiresIn, em segundos), não por data. Converter dueDate aqui exigiria assumir um fuso horário, e a biblioteca não assume nenhum — informe expiresIn por withExtra().', ]; } } diff --git a/src/Gateways/Woovi/WooviGateway.php b/src/Gateways/Woovi/WooviGateway.php index 9f85af4..5421d02 100644 --- a/src/Gateways/Woovi/WooviGateway.php +++ b/src/Gateways/Woovi/WooviGateway.php @@ -5,10 +5,10 @@ use GuzzleHttp\Client; use PHPay\Exceptions\{ApiException, ValidationException}; use PHPay\Http\MasksCredentials; -use PHPay\Support\{Customer as CustomerData, Money}; +use PHPay\Support\{ChargeIntent, Customer as CustomerData, Method, Money}; use PHPay\Webhooks\Event; use PHPay\Woovi\Interface\WooviGatewayInterface; -use PHPay\Woovi\Requests\{WooviCustomerRequest, WooviWebhookEventRequest}; +use PHPay\Woovi\Requests\{WooviChargeRequest, WooviCustomerRequest, WooviWebhookEventRequest}; use PHPay\Woovi\Resources\Charge\Charge; use PHPay\Woovi\Resources\Customer\Customer; use PHPay\Woovi\Resources\Pix\Pix; @@ -84,6 +84,42 @@ public function charge(): Charge * @return array * @throws ApiException */ + /** + * the payment methods createCharge() accepts here. + * + * the Woovi only does Pix, so there is nothing else to list. + * + * @return array + */ + public function supportedMethods(): array + { + return [Method::PIX]; + } + + /** + * create a charge from a portable description. + * + * the amount goes to create(), not to the body: it is the Woovi's own + * shape, and the one place in the library where a gateway takes the + * value as an argument. + * + * @param ChargeIntent $intent + * @return array + * @throws ValidationException|ApiException + */ + public function createCharge(ChargeIntent $intent): array + { + $intent->ensureSupportedBy('Woovi', $this->supportedMethods()); + + $charge = $this->charge()->setCharge(WooviChargeRequest::fromIntent($intent)); + + if ($intent->customer !== null) { + $charge->setCustomer($intent->customer); + } + + return $charge->create($intent->amount); + } + public function refund(string $id, Money|int|null $amount = null): array { return $this->charge()->refund($id, $amount); diff --git a/src/PHPay.php b/src/PHPay.php index d43438d..5e1d523 100644 --- a/src/PHPay.php +++ b/src/PHPay.php @@ -2,9 +2,9 @@ namespace PHPay; -use PHPay\Contracts\{Capability, GatewayInterface, SupportsCharges, SupportsCustomers, SupportsPixKeys, SupportsRefunds, SupportsSubscriptions, SupportsWebhookEvents, SupportsWebhookSignature, SupportsWebhooks}; +use PHPay\Contracts\{Capability, GatewayInterface, SupportsChargeIntents, SupportsCharges, SupportsCustomers, SupportsPixKeys, SupportsRefunds, SupportsSubscriptions, SupportsWebhookEvents, SupportsWebhookSignature, SupportsWebhooks}; use PHPay\Exceptions\{ApiException, NotImplementedException, ValidationException}; -use PHPay\Support\{Customer as CustomerData, Gateway, Money}; +use PHPay\Support\{ChargeIntent, Customer as CustomerData, Gateway, Method, Money}; use PHPay\Webhooks\{Event, VerifiesSignature}; /** @@ -159,6 +159,47 @@ public function refund(string $id, Money|int|null $amount = null): array return $gateway->refund($id, $amount); } + /** + * create a charge from a portable description. + * + * the same ChargeIntent reaches every gateway that declares the + * capability; each one translates it into its own payload. use + * supportedMethods() to ask whether this gateway does that method before + * building the intent, or let the call refuse with a message naming what + * it does accept. + * + * @param ChargeIntent $intent + * @return array + * @throws NotImplementedException|ValidationException|ApiException + */ + public function createCharge(ChargeIntent $intent): array + { + $gateway = $this->gateway; + + if (!$gateway instanceof SupportsChargeIntents) { + throw NotImplementedException::forCapability($gateway, Capability::CHARGE_INTENTS); + } + + return $gateway->createCharge($intent); + } + + /** + * the payment methods createCharge() accepts on this gateway. + * + * @return array + * @throws NotImplementedException + */ + public function supportedMethods(): array + { + $gateway = $this->gateway; + + if (!$gateway instanceof SupportsChargeIntents) { + throw NotImplementedException::forCapability($gateway, Capability::CHARGE_INTENTS); + } + + return $gateway->supportedMethods(); + } + /** * get resource webhook from gateway. * diff --git a/src/Support/ChargeIntent.php b/src/Support/ChargeIntent.php new file mode 100644 index 0000000..c0a1ea7 --- /dev/null +++ b/src/Support/ChargeIntent.php @@ -0,0 +1,218 @@ + */ + private array $extra; + + /** + * @param Method $method + * @param Money $amount the amount, always authoritative + * @param Customer|null $customer null where the gateway has no customer + * @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 $extra fields specific to one gateway + */ + private function __construct( + public readonly Method $method, + public readonly Money $amount, + public readonly ?Customer $customer = null, + public readonly ?string $dueDate = null, + public readonly ?string $description = null, + public readonly ?string $reference = null, + array $extra = [], + ) { + $this->extra = $extra; + } + + /** + * a Pix charge. + * + * `dueDate` is optional because a Pix charge without one is valid: it is + * the difference between `cob` and `cobv` in the BACEN spec, and between + * a QR Code that never expires and one that does. + * + * @param Money $amount + * @param Customer|null $customer + * @param string|null $dueDate `Y-m-d` + * @param string|null $description + * @param string|null $reference + * @param array $extra + * @return self + * @throws ValidationException + */ + public static function pix( + Money $amount, + ?Customer $customer = null, + ?string $dueDate = null, + ?string $description = null, + ?string $reference = null, + array $extra = [], + ): self { + self::validate($amount, $dueDate); + + return new self(Method::PIX, $amount, $customer, $dueDate, $description, $reference, $extra); + } + + /** + * extra fields for one gateway. + * + * it is the escape hatch for what no other gateway has — Cielo's + * `SoftDescriptor`, a split. a **required** field landing here is a sign + * the value object is missing something, not a solution. + * + * @return array + */ + public function extra(): array + { + return $this->extra; + } + + /** + * a copy carrying extra fields for one gateway. + * + * @param array $extra + * @return self + */ + public function withExtra(array $extra): self + { + return new self( + $this->method, + $this->amount, + $this->customer, + $this->dueDate, + $this->description, + $this->reference, + array_merge($this->extra, $extra) + ); + } + + /** + * a copy carrying this reference, when the intent does not have one. + * + * four gateways need the integrator's own id for the charge, under four + * names — `correlationID`, `MerchantOrderId`, `reference`, `txid` — and + * generate one when the caller gave none. filling it here keeps what the + * caller chose and never overwrites it. + * + * @param string $reference + * @return self + */ + public function withDefaultReference(string $reference): self + { + return new self( + $this->method, + $this->amount, + $this->customer, + $this->dueDate, + $this->description, + $this->reference ?? $reference, + $this->extra + ); + } + + /** + * the description, or a generic one. + * + * three gateways require a description and the other seven do not, so + * leaving it null would make the intent unusable on those three for a + * field nobody reads. + * + * @return string + */ + public function descriptionOrDefault(): string + { + return $this->description ?? 'Cobrança'; + } + + /** + * refuse the intent when this gateway does not do that method. + * + * it lives here, and not in each gateway, so the message is written once + * and always names what the gateway *does* accept — the same courtesy + * NotImplementedException already does for capabilities. + * + * @param string $gateway the gateway label, for the message prefix + * @param array $supported + * @return void + * @throws ValidationException + */ + public function ensureSupportedBy(string $gateway, array $supported): void + { + if (in_array($this->method, $supported, true)) { + return; + } + + throw ValidationException::make($gateway, sprintf( + self::messages()->method, + $this->method->label(), + Method::labels($supported) + )); + } + + /** + * messages for every refusal above. + * + * @return object{amount: string, dueDate: string, dueDatePast: string, method: 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.', + ]; + } + + /** + * @param Money $amount + * @param string|null $dueDate + * @return void + * @throws ValidationException + */ + private static function validate(Money $amount, ?string $dueDate): void + { + $messages = self::messages(); + + if ($amount->isZero()) { + throw ValidationException::make('PHPay', $messages->amount); + } + + if ($dueDate === null) { + return; + } + + $parsed = \DateTimeImmutable::createFromFormat('!Y-m-d', $dueDate); + + if ($parsed === false || $parsed->format('Y-m-d') !== $dueDate) { + throw ValidationException::make('PHPay', $messages->dueDate); + } + + if ($dueDate < date('Y-m-d')) { + throw ValidationException::make('PHPay', $messages->dueDatePast); + } + } +} diff --git a/src/Support/Gateway.php b/src/Support/Gateway.php index 0ba34bb..c2f404c 100644 --- a/src/Support/Gateway.php +++ b/src/Support/Gateway.php @@ -126,7 +126,7 @@ public function options(): array * than one account. */ if ($this === self::INTER) { - $options = [...$options, 'certificate', 'scopes', 'account']; + $options = [...$options, 'certificate', 'scopes', 'account', 'pixKey']; } return $options; @@ -180,7 +180,9 @@ public function make(array $config = []): GatewayInterface $sandbox, $this->certificateOf($config), $this->optional($config, 'scopes'), - $this->optional($config, 'account') + $this->optional($config, 'account'), + null, + $this->optional($config, 'pixKey') ), }; } diff --git a/src/Support/Method.php b/src/Support/Method.php new file mode 100644 index 0000000..c9a7ee2 --- /dev/null +++ b/src/Support/Method.php @@ -0,0 +1,66 @@ + 'Pix', + self::BOLETO => 'boleto', + self::CREDIT_CARD => 'cartão de crédito', + self::DEBIT_CARD => 'cartão de débito', + }; + } + + /** + * whether paying it needs card data. + * + * @return bool + */ + public function isCard(): bool + { + return $this === self::CREDIT_CARD || $this === self::DEBIT_CARD; + } + + /** + * the labels of the given methods, for a message that lists them. + * + * @param array $methods + * @return string + */ + public static function labels(array $methods): string + { + if ($methods === []) { + return 'nenhum'; + } + + return implode(', ', array_map(static fn (self $method): string => $method->label(), $methods)); + } +} diff --git a/tests/Unit/CapabilityTest.php b/tests/Unit/CapabilityTest.php index ad25f71..8ae36d0 100644 --- a/tests/Unit/CapabilityTest.php +++ b/tests/Unit/CapabilityTest.php @@ -31,7 +31,7 @@ $efi = PHPay::gateway(new EfiGateway('id', 'secret', true, mockClient([]))); expect($asaas->supports(Capability::PIX_KEYS))->toBeTrue() - ->and($asaas->capabilities())->toHaveCount(8) + ->and($asaas->capabilities())->toHaveCount(9) ->and($efi->supports(Capability::PIX_KEYS))->toBeTrue() ->and($efi->supports(Capability::CUSTOMERS))->toBeFalse() ->and($efi->capabilities())->toBe([ diff --git a/tests/Unit/Inter/ChargeIntentTest.php b/tests/Unit/Inter/ChargeIntentTest.php new file mode 100644 index 0000000..5b1cf21 --- /dev/null +++ b/tests/Unit/Inter/ChargeIntentTest.php @@ -0,0 +1,103 @@ + @unlink($crt) && @unlink($key)); + + return Certificate::pair($crt, $key); +} + +it('declara cobrança portável, só por Pix', function () { + $gateway = new InterGateway('id', 'secret'); + + expect($gateway)->toBeInstanceOf(SupportsChargeIntents::class) + ->and($gateway->supportedMethods())->toBe([Method::PIX]) + ->and(PHPay::gateway($gateway)->supports(Capability::CHARGE_INTENTS))->toBeTrue(); +})->group('inter'); + +it('recusa sem chave Pix, antes de qualquer HTTP', function () { + ClientFactory::fake([]); + + $gateway = new InterGateway('id', 'secret', true, interParDeTeste()); + + expect(fn () => $gateway->createCharge(ChargeIntent::pix(Money::reais('149,90')))) + ->toThrow(ValidationException::class, InterGateway::messages()->pixKey) + ->and(ClientFactory::recorded())->toBe([]); +})->group('inter'); + +it('monta o payload do bacen a partir do intent', function () { + ClientFactory::fake([ + new Response(200, [], '{"access_token":"t","token_type":"Bearer","expires_in":3600}'), + new Response(200, [], '{"txid":"abc"}'), + ]); + + $gateway = new InterGateway('id', 'secret', true, interParDeTeste(), null, null, null, 'chave@exemplo.com'); + + $gateway->createCharge(ChargeIntent::pix( + amount: Money::reais('149,90'), + customer: Customer::make(name: 'Maria Oliveira', document: '123.456.789-09'), + description: 'Plano anual', + )); + + $corpo = json_decode((string) ClientFactory::recorded()[1]['request']->getBody(), true); + + /* reais em STRING, que é o terceiro formato de valor entre os dez */ + expect($corpo['valor']['original'])->toBe('149.90') + ->and($corpo['chave'])->toBe('chave@exemplo.com') + ->and($corpo['devedor']['cpf'])->toBe('12345678909') + ->and($corpo['solicitacaoPagador'])->toBe('Plano anual'); +})->group('inter'); + +it('com vencimento, vai para cobv e não para cob', function () { + ClientFactory::fake([ + new Response(200, [], '{"access_token":"t","token_type":"Bearer","expires_in":3600}'), + new Response(200, [], '{"txid":"abc"}'), + ]); + + $gateway = new InterGateway('id', 'secret', true, interParDeTeste(), null, null, null, 'chave@exemplo.com'); + + $gateway->createCharge(ChargeIntent::pix( + amount: Money::reais('149,90'), + customer: Customer::make(name: 'Maria Oliveira', document: '123.456.789-09'), + dueDate: '2099-11-10', + )); + + $uri = (string) ClientFactory::recorded()[1]['request']->getUri(); + + expect($uri)->toContain('/cobv/') + ->and($uri)->not->toContain('/cob/'); +})->group('inter'); + +it('recebe a chave pela configuração, pelo registry', function () { + $gateway = PHPay::make('inter', [ + 'clientId' => 'id', + 'clientSecret' => 'secret', + 'pixKey' => 'chave@exemplo.com', + ]); + + expect($gateway->supportedMethods())->toBe([Method::PIX]); +})->group('inter'); diff --git a/tests/Unit/MercadoPago/MercadoPagoGatewayTest.php b/tests/Unit/MercadoPago/MercadoPagoGatewayTest.php index d0eb64d..0cba553 100644 --- a/tests/Unit/MercadoPago/MercadoPagoGatewayTest.php +++ b/tests/Unit/MercadoPago/MercadoPagoGatewayTest.php @@ -18,6 +18,7 @@ Capability::SUBSCRIPTIONS, Capability::WEBHOOK_SIGNATURE, Capability::WEBHOOK_EVENTS, + Capability::CHARGE_INTENTS, ]); })->group('mercadopago'); diff --git a/tests/Unit/Support/ChargeIntentAcrossGatewaysTest.php b/tests/Unit/Support/ChargeIntentAcrossGatewaysTest.php new file mode 100644 index 0000000..1212c64 --- /dev/null +++ b/tests/Unit/Support/ChargeIntentAcrossGatewaysTest.php @@ -0,0 +1,127 @@ + 't'])->createCharge(intentDeTeste('2099-11-10')); + $asaas = json_decode((string) ClientFactory::recorded()[1]['request']->getBody(), true); + ClientFactory::stopFaking(); + + ClientFactory::fake([new Response(200, [], '{"charge":{}}')]); + PHPay::make('woovi', ['appId' => 'a'])->createCharge(intentDeTeste()); + $woovi = json_decode((string) ClientFactory::recorded()[0]['request']->getBody(), true); + ClientFactory::stopFaking(); + + ClientFactory::fake([new Response(200, [], '{"id":1}')]); + PHPay::make('mercadopago', ['accessToken' => 'TEST-1'])->createCharge(intentDeTeste()); + $mp = json_decode((string) ClientFactory::recorded()[0]['request']->getBody(), true); + + /* reais nos dois de reais, centavos no de centavos, do mesmo Money */ + expect($asaas['value'])->toBe(149.9) + ->and($mp['transaction_amount'])->toBe(149.9) + ->and($woovi['value'])->toBe(14990); +})->group('support'); + +it('traduz o mesmo cliente para o schema de cada um', function () { + ClientFactory::fake([new Response(200, [], '{"charge":{}}')]); + PHPay::make('woovi', ['appId' => 'a'])->createCharge(intentDeTeste()); + $woovi = json_decode((string) ClientFactory::recorded()[0]['request']->getBody(), true); + ClientFactory::stopFaking(); + + ClientFactory::fake([new Response(200, [], '{"id":1}')]); + PHPay::make('mercadopago', ['accessToken' => 'TEST-1'])->createCharge(intentDeTeste()); + $mp = json_decode((string) ClientFactory::recorded()[0]['request']->getBody(), true); + + /* seis grafias do mesmo campo, e quem integra escreveu uma */ + expect($woovi['customer']['taxID'])->toBe('12345678909') + ->and($mp['payer']['identification']['number'])->toBe('12345678909') + ->and($mp['payer']['first_name'])->toBe('Maria'); +})->group('support'); + +it('gera o correlationID da woovi quando ninguém deu referência', function () { + ClientFactory::fake([new Response(200, [], '{"charge":{}}'), new Response(200, [], '{"charge":{}}')]); + + $woovi = PHPay::make('woovi', ['appId' => 'a']); + $woovi->createCharge(intentDeTeste()); + $woovi->createCharge(intentDeTeste()); + + $um = json_decode((string) ClientFactory::recorded()[0]['request']->getBody(), true); + $dois = json_decode((string) ClientFactory::recorded()[1]['request']->getBody(), true); + + expect($um['correlationID'])->not->toBeEmpty() + ->and($um['correlationID'])->not->toBe($dois['correlationID']); +})->group('support'); + +it('respeita a referência que quem integra escolheu', function () { + ClientFactory::fake([new Response(200, [], '{"charge":{}}')]); + + PHPay::make('woovi', ['appId' => 'a'])->createCharge( + ChargeIntent::pix(Money::reais('10,00'), reference: 'pedido-42') + ); + + expect(json_decode((string) ClientFactory::recorded()[0]['request']->getBody(), true)['correlationID']) + ->toBe('pedido-42'); +})->group('support'); + +it('recusa vencimento onde o gateway expira por duração, em vez de inventar fuso', function (string $gateway, array $config) { + ClientFactory::fake([]); + + expect(fn () => PHPay::make($gateway, $config)->createCharge(intentDeTeste('2099-11-10'))) + ->toThrow(ValidationException::class) + ->and(ClientFactory::recorded())->toBe([]); +})->with([ + ['woovi', ['appId' => 'a']], + ['mercadopago', ['accessToken' => 'TEST-1']], +])->group('support'); + +it('exige vencimento no asaas, que não cria cobrança sem ele', function () { + ClientFactory::fake([]); + + expect(fn () => PHPay::make('asaas', ['token' => 't'])->createCharge(intentDeTeste())) + ->toThrow(ValidationException::class) + ->and(ClientFactory::recorded())->toBe([]); +})->group('support'); + +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())) + ->toThrow(NotImplementedException::class) + ->and(ClientFactory::recorded())->toBe([]); +})->group('support'); + +it('declara só o que createCharge() aceita hoje', function (string $gateway, array $config) { + expect(PHPay::make($gateway, $config)->supportedMethods())->toBe([Method::PIX]); +})->with([ + ['asaas', ['token' => 't']], + ['woovi', ['appId' => 'a']], + ['mercadopago', ['accessToken' => 'TEST-1']], +])->group('support'); diff --git a/tests/Unit/Support/ChargeIntentTest.php b/tests/Unit/Support/ChargeIntentTest.php new file mode 100644 index 0000000..1158591 --- /dev/null +++ b/tests/Unit/Support/ChargeIntentTest.php @@ -0,0 +1,67 @@ + ChargeIntent::pix(Money::centavos(0))) + ->toThrow(ValidationException::class, ChargeIntent::messages()->amount); +})->group('support'); + +it('recusa data que não é Y-m-d', function (string $data) { + expect(fn () => ChargeIntent::pix(Money::reais('10,00'), dueDate: $data)) + ->toThrow(ValidationException::class, ChargeIntent::messages()->dueDate); +})->with(['10/11/2026', '2026-13-01', '2026-11-31', 'amanhã'])->group('support'); + +it('recusa vencimento no passado', function () { + expect(fn () => ChargeIntent::pix(Money::reais('10,00'), dueDate: '2020-01-01')) + ->toThrow(ValidationException::class, ChargeIntent::messages()->dueDatePast); +})->group('support'); + +it('aceita Pix sem vencimento, porque cob sem vencimento é válido', function () { + $intent = ChargeIntent::pix(Money::reais('149,90')); + + expect($intent->dueDate)->toBeNull() + ->and($intent->method)->toBe(Method::PIX) + ->and($intent->amount->toCentavos())->toBe(14990); +})->group('support'); + +it('é imutável: withExtra e withDefaultReference devolvem outro', function () { + $intent = ChargeIntent::pix(Money::reais('10,00')); + $outro = $intent->withExtra(['soft' => 'LOJA'])->withDefaultReference('abc'); + + expect($intent->extra())->toBe([]) + ->and($intent->reference)->toBeNull() + ->and($outro->extra())->toBe(['soft' => 'LOJA']) + ->and($outro->reference)->toBe('abc'); +})->group('support'); + +it('withDefaultReference não sobrescreve a referência escolhida por quem chama', function () { + $intent = ChargeIntent::pix(Money::reais('10,00'), reference: 'meu-id'); + + expect($intent->withDefaultReference('gerado')->reference)->toBe('meu-id'); +})->group('support'); + +it('descreve a cobrança quando ninguém descreveu', function () { + expect(ChargeIntent::pix(Money::reais('10,00'))->descriptionOrDefault())->toBe('Cobrança') + ->and(ChargeIntent::pix(Money::reais('10,00'), description: 'Plano')->descriptionOrDefault())->toBe('Plano'); +})->group('support'); + +it('recusa o método que o gateway não faz, nomeando o que ele faz', function () { + $intent = ChargeIntent::pix(Money::reais('10,00')); + + expect(fn () => $intent->ensureSupportedBy('Rede', [Method::CREDIT_CARD])) + ->toThrow(ValidationException::class, 'Aceita: cartão de crédito.'); +})->group('support'); + +it('aceita o método que o gateway faz', function () { + $intent = ChargeIntent::pix(Money::reais('10,00')); + + expect(fn () => $intent->ensureSupportedBy('Woovi', [Method::PIX]))->not->toThrow(ValidationException::class); +})->group('support'); + +it('guarda o cliente como veio, sem remapear', function () { + $cliente = Customer::make(name: 'Maria', document: '123.456.789-09'); + + expect(ChargeIntent::pix(Money::reais('10,00'), customer: $cliente)->customer?->document)->toBe('12345678909'); +})->group('support'); diff --git a/tests/Unit/Support/GatewayTest.php b/tests/Unit/Support/GatewayTest.php index 71e9a8e..daf179a 100644 --- a/tests/Unit/Support/GatewayTest.php +++ b/tests/Unit/Support/GatewayTest.php @@ -170,7 +170,7 @@ function configMinima(Gateway $gateway): array * o token é emitido por `scopes` e o `account` preenche x-conta-corrente. */ expect(Gateway::INTER->credentials())->toBe(['clientId', 'clientSecret']) - ->and(Gateway::INTER->options())->toBe(['sandbox', 'certificate', 'scopes', 'account']); + ->and(Gateway::INTER->options())->toBe(['sandbox', 'certificate', 'scopes', 'account', 'pixKey']); })->group('support'); it('todo gateway de src/Gateways está no enum', function () { diff --git a/tests/Unit/Woovi/WooviGatewayTest.php b/tests/Unit/Woovi/WooviGatewayTest.php index 05199ae..7e0fe87 100644 --- a/tests/Unit/Woovi/WooviGatewayTest.php +++ b/tests/Unit/Woovi/WooviGatewayTest.php @@ -10,7 +10,7 @@ use PHPay\Woovi\Resources\Webhook\Webhook; use PHPay\Woovi\WooviGateway; -it('declara as oito capacidades', function () { +it('declara as nove capacidades', function () { $gateway = new WooviGateway('app-id', true, wooviClient([])); expect(Capability::of($gateway))->toBe(Capability::cases()); @@ -22,7 +22,7 @@ /* duas empresas independentes preenchendo o mesmo contrato */ expect(Capability::of($woovi))->toBe(Capability::of($asaas)) - ->and(Capability::of($woovi))->toHaveCount(8); + ->and(Capability::of($woovi))->toHaveCount(9); })->group('woovi'); it('devolve a instância de cada um dos seis recursos', function () {