Skip to content
Closed
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
12 changes: 7 additions & 5 deletions .github/scripts/smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -271,15 +271,17 @@ function () use ($phpay, $gateway, $declared, $ensure, $expect): void {
$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.
// nenhum dos dez faz débito pelo caminho portável — a
// Cielo e o Pagar.me o conhecem, mas ele pede 3DS, que a
// biblioteca não modela. Serve de meio sempre recusado,
// sem depender de qual fase já entrou.
$ensure(
!in_array(Method::BOLETO, $phpay->supportedMethods(), true),
'boleto ainda não existe no caminho portável'
!in_array(Method::DEBIT_CARD, $phpay->supportedMethods(), true),
'débito não existe no caminho portável'
);
$expect(
ValidationException::class,
static fn () => $intent->ensureSupportedBy('x', [Method::BOLETO])
static fn () => $intent->ensureSupportedBy('x', [Method::DEBIT_CARD])
);
}
);
Expand Down
28 changes: 24 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,25 @@ descrição de cobrança que vale em mais de um gateway, com o mapeamento no
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()`.
- **`Card` não cobre cartão em todo lugar, e não teria como.** Cielo e Rede
recebem o número no servidor; o **Mercado Pago recebe um token** do SDK de
navegador dele, então o PAN nunca chega lá; PagBank e Pagar.me querem
cifrado ou tokenizado; o Asaas aceita os dois, mas só tem isso modelado em
**assinatura**. Por isso só Cielo e Rede declaram `CREDIT_CARD` — e é
exatamente para isso que `supportedMethods()` serve. **Não invente mapeamento
de cartão** para os outros: no Mercado Pago seria impossível, não só errado.
- **O `Card` confere Luhn e mascara no `__debugInfo()`.** Dígito trocado pego
aqui custa nada; pego no gateway custa recusa no checkout. Número e CVV levam
`#[\SensitiveParameter]` e nunca aparecem em dump. **A bandeira não é
derivada da faixa do número** — a Cielo a exige e adivinhar seria inventar um
fato sobre o cartão.
- **Débito não existe no caminho portável.** Cielo e Pagar.me conhecem o tipo,
mas ele pede 3DS, que a lib não modela. Nenhum dos dez declara
`Method::DEBIT_CARD`, e há teste varrendo isso.
- **Na Efí o caminho portável faz boleto, não Pix**: lá `charge()` **é** o
boleto e o Pix é `pixCharge()`, noutra API. Rotear Pix aqui mudaria o retorno
de `charge()` e quebraria a v2, então `supportedMethods()` dela diz `BOLETO`
e pronto.
- **`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
Expand Down Expand Up @@ -609,10 +628,11 @@ a notificação veio do gateway é `SupportsWebhookSignature`, e são coisas dif

## O que ainda está em aberto

- `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.
- `ChargeIntent` está nos **dez**, com `pix()`, `boleto()`, `comItens()` e
`credito()`. O que falta não é gateway, é meio: **cartão nos cinco que pedem
token ou blob cifrado** (Mercado Pago, PagBank, Pagar.me, Asaas) e **débito
em qualquer um** (pede 3DS). Os dois precisam de desenho próprio, não de mais
um `fromIntent()`.
**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 @@ -71,12 +71,12 @@ Trocar de gateway é trocar a linha do construtor.
| --- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
| **Asaas** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **Woovi/OpenPix** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **Efí** | — | ✅ | — | ✅ | ✅ | ✅ | — | ✅ | — |
| **Efí** | — | ✅ | — | ✅ | ✅ | ✅ | — | ✅ | ✅ |
| **Mercado Pago** | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ |
| **PagBank** | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ |
| **Pagar.me** | ✅ | ✅ | ✅ | — | — | ✅ | — | ✅ | ✅ |
| **Cielo** | — | ✅ | ✅ | — | — | ✅ | — | ✅ | — |
| **Rede** | — | ✅ | ✅ | — | — | — | — | — | — |
| **Cielo** | — | ✅ | ✅ | — | — | ✅ | — | ✅ | ✅ |
| **Rede** | — | ✅ | ✅ | — | — | — | — | — | ✅ |
| **AbacatePay** | ✅ | ✅ | — | — | — | — | ✅ | — | ✅ |
| **Inter** | — | ✅ | — | ✅ | — | — | — | ✅ | ✅ |
<!-- capacidades:fim -->
Expand Down
55 changes: 53 additions & 2 deletions docs/cobranca-portavel.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ $intent = ChargeIntent::pix(
$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:
Os **dez** declaram a capacidade, e cada um aceita os meios que sabe fazer —
pergunte com `supportedMethods()`. O **mesmo** `$intent` produz um corpo
diferente em cada um, na unidade e no vocabulário dele:

| gateway | o que sai no corpo |
| --- | --- |
Expand Down Expand Up @@ -131,6 +132,56 @@ aceita — como a `NotImplementedException` já faz com capacidades.
> declarar um meio que a chamada recusaria é o mesmo erro que declarar uma
> capacidade e lançar de dentro dela.

## Boleto e cartão

```php
ChargeIntent::boleto($valor, $cliente, '2026-11-10');

ChargeIntent::credito($valor, $cliente, Card::make(
number: '4111 1111 1111 1111',
holderName: 'MARIA OLIVEIRA',
expirationMonth: '07',
expirationYear: '2030',
securityCode: '123',
brand: 'Visa',
), installments: 3);
```

O vencimento é **obrigatório no boleto** e opcional no Pix, porque é assim que
os gateways são: boleto sem vencimento não tem o que imprimir.

### `Card` não cobre cartão em todo lugar, e não teria como

| gateway | como recebe o cartão | declara crédito? |
| --- | --- | :-: |
| Cielo | o número, em `Payment.CreditCard` | ✅ |
| Rede | o número, em campos planos | ✅ |
| Mercado Pago | **um token** do SDK de navegador deles — o número nunca chega ao seu servidor | — |
| PagBank, Pagar.me | cifrado ou tokenizado | — |
| Asaas | token **ou** número, mas só modelado em assinatura | — |

Não existe um objeto honesto sobre todos esses casos ao mesmo tempo. O `Card`
descreve o primeiro, e **quem não consegue usá-lo simplesmente não declara o
método** — que é exatamente para isso que `supportedMethods()` existe.

### O número é conferido com Luhn antes de sair

Um dígito trocado custa nada se for pego aqui, e custa uma recusa no checkout
de alguém se for pego no gateway. O `Card` também **nunca aparece num dump**:
`print_r` mostra `**** **** **** 1111` e `***` no CVV.

### A bandeira não é adivinhada

A Cielo exige a bandeira, e o `Card` não a deriva da faixa do número — isso
seria a biblioteca inventando um fato sobre o cartão. Sem ela, a chamada
recusa antes de qualquer HTTP.

### Débito não existe no caminho portável

A Cielo e o Pagar.me conhecem `DebitCard`/`debit_card`, mas débito pede
autenticação 3DS, que a biblioteca não modela. Nenhum dos dez declara
`Method::DEBIT_CARD` — e há teste varrendo isso.

## Vencimento

`dueDate` é `Y-m-d`, e é **opcional**: uma cobrança Pix sem vencimento é válida
Expand Down
11 changes: 6 additions & 5 deletions src/Gateways/Asaas/AsaasGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,16 +93,17 @@ public function refund(string $id, Money|int|float|null $amount = null): array
/**
* 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.
* the card is absent: the Asaas takes it as `creditCard` plus
* `creditCardHolderInfo`, or as a `creditCardToken`, and neither is
* modelled on the charge today — only on the subscription. Declaring a
* method this call would refuse is the same mistake as declaring a
* capability and throwing from inside it.
*
* @return array<int, Method>
*/
public function supportedMethods(): array
{
return [Method::PIX];
return [Method::PIX, Method::BOLETO];
}

/**
Expand Down
27 changes: 24 additions & 3 deletions src/Gateways/Asaas/Requests/AsaasChargeRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

use PHPay\Asaas\Enums\BillingTypeEnum;
use PHPay\Exceptions\ValidationException;
use PHPay\Support\ChargeIntent;
use PHPay\Support\{ChargeIntent, Method};

class AsaasChargeRequest
{
Expand All @@ -29,13 +29,33 @@ public static function fromIntent(ChargeIntent $intent): array
}

return [
'billingType' => BillingTypeEnum::PIX->value,
'billingType' => self::billingType($intent->method),
'value' => $intent->amount->toReais(),
'dueDate' => $intent->dueDate,
'description' => $intent->descriptionOrDefault(),
] + $intent->extra();
}

/**
* the Asaas name for a payment method.
*
* the card is absent: the Asaas takes it as `creditCard` plus
* `creditCardHolderInfo`, or as a `creditCardToken`, and neither is
* modelled on the charge today — only on the subscription.
*
* @param Method $method
* @return string
* @throws ValidationException
*/
private static function billingType(Method $method): string
{
return match ($method) {
Method::PIX => BillingTypeEnum::PIX->value,
Method::BOLETO => BillingTypeEnum::BOLETO->value,
default => throw ValidationException::make('Asaas', self::messages()->method),
};
}

/**
* validate charge payload before sending it to the gateway.
*
Expand Down Expand Up @@ -70,7 +90,7 @@ public static function validate(array $charge): void
/**
* messages for validation
*
* @return object{customer: string, billingType: string, value: string, dueDate: string, dueDateRequired: string}
* @return object{customer: string, billingType: string, value: string, dueDate: string, dueDateRequired: string, method: string}
*/
public static function messages(): object
{
Expand All @@ -79,6 +99,7 @@ public static function messages(): object
'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.',
'method' => 'O caminho portável do Asaas cria Pix e boleto. O cartão dele pede creditCard com creditCardHolderInfo, ou um creditCardToken, e isso ainda não está modelado na cobrança.',
'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.',
];
}
Expand Down
55 changes: 53 additions & 2 deletions src/Gateways/Cielo/CieloGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@

use GuzzleHttp\Client;
use PHPay\Cielo\Interface\CieloGatewayInterface;
use PHPay\Cielo\Requests\CieloWebhookEventRequest;
use PHPay\Cielo\Requests\{CieloSaleRequest, CieloWebhookEventRequest};
use PHPay\Cielo\Resources\Charge\Charge;
use PHPay\Cielo\Resources\Subscription\Subscription;
use PHPay\Exceptions\{ApiException, ValidationException};
use PHPay\Http\MasksCredentials;
use PHPay\Support\Money;
use PHPay\Support\{Card, ChargeIntent, Method, Money};
use PHPay\Webhooks\Event;

class CieloGateway implements CieloGatewayInterface
Expand Down Expand Up @@ -75,6 +75,57 @@ public function charge(): Charge
* @return array<mixed>
* @throws ApiException
*/
/**
* the payment methods createCharge() accepts here.
*
* the Cielo is the widest of the ten on the portable path: it takes the
* card number on the server, so the credit method is reachable. Debit is
* absent because it needs 3DS authentication, which the library does not
* model.
*
* @return array<int, Method>
*/
public function supportedMethods(): array
{
return [Method::PIX, Method::BOLETO, Method::CREDIT_CARD];
}

/**
* create a charge from a portable description.
*
* @param ChargeIntent $intent
* @return array<mixed>
* @throws ValidationException|ApiException
*/
public function createCharge(ChargeIntent $intent): array
{
$intent->ensureSupportedBy('Cielo', $this->supportedMethods());

$charge = $this->charge()->setSale(CieloSaleRequest::fromIntent($intent));

if ($intent->customer !== null) {
$charge->setCustomer($intent->customer);
}

if ($intent->method === Method::PIX) {
$charge->setPix($intent->amount);
} elseif ($intent->method === Method::BOLETO) {
$charge->setBoleto($intent->amount);
} else {
if (!$intent->card instanceof Card) {
throw ValidationException::make('Cielo', CieloSaleRequest::messages()->payment);
}

$charge->setCreditCard(
$intent->amount,
CieloSaleRequest::cardFromIntent($intent->card),
$intent->installments
);
}

return $charge->create();
}

public function refund(string $id, Money|int|null $amount = null): array
{
return $this->charge()->refund($id, $amount);
Expand Down
3 changes: 2 additions & 1 deletion src/Gateways/Cielo/Interface/CieloGatewayInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

use PHPay\Cielo\Resources\Charge\Charge;
use PHPay\Cielo\Resources\Subscription\Subscription;
use PHPay\Contracts\{SupportsCharges, SupportsRefunds, SupportsSubscriptions, SupportsWebhookEvents};
use PHPay\Contracts\{SupportsChargeIntents, SupportsCharges, SupportsRefunds, SupportsSubscriptions, SupportsWebhookEvents};

/**
* the Cielo gateway offers charges and recurrences.
Expand All @@ -16,6 +16,7 @@
*/
interface CieloGatewayInterface extends
SupportsCharges,
SupportsChargeIntents,
SupportsRefunds,
SupportsSubscriptions,
SupportsWebhookEvents
Expand Down
43 changes: 42 additions & 1 deletion src/Gateways/Cielo/Requests/CieloSaleRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,50 @@

use PHPay\Cielo\Enums\PaymentTypeEnum;
use PHPay\Exceptions\ValidationException;
use PHPay\Support\{Card, ChargeIntent};
use PHPay\Support\Customer;

class CieloSaleRequest
{
/**
* the Cielo envelope for a portable charge description.
*
* @param ChargeIntent $intent
* @return array<mixed>
*/
public static function fromIntent(ChargeIntent $intent): array
{
$sale = $intent->reference !== null ? ['MerchantOrderId' => $intent->reference] : [];

return $sale + $intent->extra();
}

/**
* the Cielo shape for the card itself.
*
* the brand is required and is NOT guessed from the number: a wrong
* brand is refused by the Cielo, and deriving it from the BIN range
* would be the library inventing a fact about the card.
*
* @param Card $card
* @return array<string, string>
* @throws ValidationException
*/
public static function cardFromIntent(Card $card): array
{
if ($card->brand === null || trim($card->brand) === '') {
throw ValidationException::make('Cielo', self::messages()->brand);
}

return [
'CardNumber' => $card->number,
'Holder' => $card->holderName,
'ExpirationDate' => $card->expiration(),
'SecurityCode' => $card->securityCode,
'Brand' => $card->brand,
];
}

/**
* validate sale payload before sending it to the gateway.
*
Expand Down Expand Up @@ -59,7 +99,7 @@ public static function validate(array $sale): void
/**
* messages for validation
*
* @return object{merchantOrderId: string, customer: string, customerName: string, payment: string, paymentType: string, amount: string}
* @return object{merchantOrderId: string, customer: string, customerName: string, payment: string, paymentType: string, amount: string, brand: string}
*/
public static function messages(): object
{
Expand All @@ -70,6 +110,7 @@ public static function messages(): object
'payment' => 'O campo Payment é obrigatório. Use setPix(), setBoleto() ou setCreditCard().',
'paymentType' => 'O campo Payment.Type é obrigatório e aceita apenas: CreditCard, DebitCard, Pix, Boleto.',
'amount' => 'O campo Payment.Amount é obrigatório e deve ser um inteiro em CENTAVOS maior que zero. A Cielo não aceita valor decimal: R$ 157,00 é 15700.',
'brand' => 'A Cielo exige a bandeira do cartão (Visa, Master, Elo…). Informe-a no Card: a biblioteca não a adivinha pela faixa do número, porque isso seria inventar um fato sobre o cartão.',
];
}

Expand Down
Loading
Loading