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
39 changes: 37 additions & 2 deletions .github/scripts/smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
43 changes: 40 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 é
Expand Down Expand Up @@ -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.
Expand Down
24 changes: 12 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,18 +67,18 @@ Trocar de gateway é trocar a linha do construtor.
## Gateways suportados

<!-- capacidades:inicio -->
| 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** | — | ✅ | — | ✅ | — | — | — | ✅ | ✅ |
<!-- capacidades:fim -->

As interfaces correspondentes são `SupportsCustomers`, `SupportsCharges`,
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
21 changes: 18 additions & 3 deletions docs/capacidades.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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]
```
128 changes: 128 additions & 0 deletions docs/cobranca-portavel.md
Original file line number Diff line number Diff line change
@@ -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()`.
3 changes: 3 additions & 0 deletions src/Contracts/Capability.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
};
}

Expand All @@ -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',
};
}

Expand Down
Loading
Loading