Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** | — | ✅ | — | ✅ | — | — | — | ✅ | ✅ |
<!-- capacidades:fim -->

Expand Down
64 changes: 64 additions & 0 deletions docs/cobranca-portavel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
55 changes: 53 additions & 2 deletions src/Gateways/AbacatePay/AbacatePayGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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<int, Method>
*/
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<mixed>
* @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
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -23,6 +23,7 @@
interface AbacatePayGatewayInterface extends
SupportsCustomers,
SupportsCharges,
SupportsChargeIntents,
SupportsWebhookSignature
{
/**
Expand Down
59 changes: 46 additions & 13 deletions src/Gateways/AbacatePay/Requests/AbacatePayBillingRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<mixed>
* @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
*/
Expand All @@ -31,7 +63,7 @@ public static function validate(array $billing): void

/**
* @param array<mixed> $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
*/
Expand Down Expand Up @@ -59,7 +91,7 @@ private static function validateFrequencyAndMethods(array $billing, object $mess

/**
* @param array<mixed> $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
*/
Expand Down Expand Up @@ -97,7 +129,7 @@ private static function validateProducts(array $billing, object $messages): void

/**
* @param array<mixed> $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
*/
Expand All @@ -115,7 +147,7 @@ private static function validateUrls(array $billing, object $messages): void

/**
* @param array<mixed> $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
*/
Expand All @@ -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().',
];
}
}
3 changes: 2 additions & 1 deletion src/Gateways/PagBank/Interface/PagBankGatewayInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,6 +22,7 @@
interface PagBankGatewayInterface extends
SupportsCustomers,
SupportsCharges,
SupportsChargeIntents,
SupportsRefunds,
SupportsSubscriptions,
SupportsWebhookSignature,
Expand Down
Loading
Loading