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
35 changes: 35 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,10 @@ descrição de cobrança que vale em mais de um gateway, com o mapeamento no
**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.
- **A Cielo não faz boleto pelo caminho portável**: o `Payment` do Pix dela não
tem onde levar vencimento e o campo de vencimento do boleto não está
confirmado — engolir a data cobraria com prazo que ninguém escolheu, então
ela recusa. Volta quando alguém confirmar o campo (#182).
- **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 é
Expand Down Expand Up @@ -441,6 +445,37 @@ mandar chave em toda requisição esbarraria no `409 idempotency_key_in_use`. Ga
novo só ganha setter se a documentação dele descrever o mecanismo — e a geração da
chave vem de `PHPay\Http\Idempotency::key()`, não de `random_bytes` espalhado.

## Fallback entre gateways

Use **`PHPay\Support\FallbackGateway`**. É o irmão do retry, e **pior**: se o
erro não prova que a requisição não chegou, cair para o próximo cria uma
**segunda cobrança** — e entre dois PSPs não existe chave de idempotência que
desfaça isso.

- **Quem responde "saiu ou não?" é `PHPay\Http\Delivery`**, extraído do
`RetryPolicy` justamente para não haver duas cópias. **Não reimplemente** a
classificação: duas cópias divergem, e a divergência aparece como alguém
pagando duas vezes.
- Cai em: erro que provou não ter saído, timeout antes de conectar, `401`,
`403`, `429`, e regra do próprio gateway recusada **antes** de qualquer HTTP.
**Não cai** em `4xx` de payload, `5xx` já na linha, nem timeout com conexão
aberta.
- **`supportedMethods()` devolve a união**, porque `createCharge()` filtra por
cobrança antes de tentar. Para **capacidade** vale o contrário: o decorator
declara só cobrança e cobrança portável, nunca as dos gateways de dentro —
capacidade é perguntada uma vez contra este objeto, meio é escolhido por
cobrança.
- **Retry roda dentro do gateway, fallback fora dele**: três tentativas no
mesmo antes de o próximo entrar. Teste que simule erro de transporte precisa
de três respostas na fila, não uma.
- **`charge()` é do primário e não tem fallback**, porque devolve recurso com
estado. Recusá-lo seria declarar capacidade e lançar de dentro.
- **Uma cadeia pode ser inútil sem avisar**: Asaas exige vencimento no Pix e a
Woovi o recusa, então aquela dupla nunca tem as duas como candidatas para a
mesma cobrança. Monte a cadeia com gateways que aceitam o mesmo intent.
- Circuit breaker fica fora: precisa de estado entre requisições, e o núcleo é
sem estado de propósito.

## QR Code estático

Use **`PHPay\Support\StaticPixCode`**. Pelo padrão do BACEN um QR Code estático é
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ específico dele.
| [Resolvendo o gateway](resolvendo-o-gateway.md) | `PHPay::gateway()` quando você sabe qual, `PHPay::make()` quando ele vem de configuração |
| [Tratamento de erros](erros.md) | array é sucesso, falha é exceção — e qual exceção |
| [Retry e idempotência](retry-e-idempotencia.md) | o que a biblioteca repete, o que ela recusa a repetir, e por quê |
| [Fallback entre gateways](fallback.md) | tentar o próximo gateway, e só quando cair não cria cobrança dupla |
| [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 |
Expand Down
126 changes: 126 additions & 0 deletions docs/fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Fallback entre gateways

`FallbackGateway` tenta uma lista de gateways em ordem — **mas só cai para o
próximo quando cair é seguro.**

[← voltar à documentação](README.md)

```php
use PHPay\Support\{FallbackGateway, Gateway};

$gateway = new FallbackGateway([
Gateway::WOOVI->make(['appId' => $appId]),
Gateway::MERCADO_PAGO->make(['accessToken' => $token]),
]);

PHPay::gateway($gateway)->createCharge($intent);
```

## A regra, e por que ela é assim

Cair para o próximo gateway é o irmão do [retry](retry-e-idempotencia.md), e é
**pior**:

> Se a requisição falhou de um jeito que **não prova** que não chegou, cair
> para o próximo cria uma **segunda cobrança**. O cliente pode receber dois QR
> Codes e pagar os dois.

Um retry repetido o próprio gateway pode deduplicar por chave de idempotência.
**Entre dois PSPs não existe chave de idempotência** — são dois sistemas sem
nada em comum. Ninguém dedupe por você.

| situação | cai? | por quê |
| --- | :-: | --- |
| DNS, conexão recusada, TLS | ✅ | provadamente não saiu |
| timeout **antes** de conectar | ✅ | idem |
| 401 / 403 credencial inválida | ✅ | recusou antes de processar |
| 429 | ✅ | recusou antes de processar |
| regra do próprio gateway (sem HTTP) | ✅ | nada saiu, e a regra do próximo é outra |
| 4xx de payload | ❌ | erraria igual no próximo |
| 5xx já com a requisição na linha | ❌ | **pode ter criado a cobrança** |
| timeout com a conexão aberta | ❌ | idem |

Quem responde "saiu ou não?" é `PHPay\Http\Delivery` — **a mesma resposta que
o `RetryPolicy` usa**, nunca uma segunda cópia. Duas cópias divergiriam, e a
divergência apareceria como alguém pagando duas vezes.

## Retry vem antes, e as camadas são diferentes

```
FallbackGateway ← tenta OUTRO gateway
│
▼
RetryPolicy ← repete a MESMA requisição, dentro de um gateway
```

Um erro que provadamente não saiu é repetido **três vezes no mesmo gateway**,
e só depois de esgotado é que o próximo da lista entra.

## Ele pergunta o meio antes de tentar

```php
$cadeia = new FallbackGateway([
Gateway::WOOVI->make([...]), // Pix
Gateway::REDE->make([...]), // cartão de crédito
]);

$cadeia->supportedMethods(); // [PIX, CREDIT_CARD] — a união
```

A **união**, e não a interseção, porque `createCharge()` filtra por cobrança:
uma cobrança Pix só considera a Woovi, uma de crédito só considera a Rede.
Sem essa filtragem, uma cobrança Pix seria entregue a um gateway que não faz
Pix — depois de já ter desistido do que fazia.

Quando ninguém da lista faz o meio pedido, a recusa vem **antes de qualquer
HTTP**, nomeando o que cada um faz.

> A união vale para **meios**. Para **capacidades** vale o contrário: o
> decorator declara apenas cobrança e cobrança portável, nunca clientes,
> webhooks ou estornos dos gateways de dentro. Capacidade é perguntada uma vez,
> contra este objeto; meio é escolhido por cobrança.

## Uma cadeia pode ser inútil sem avisar

As regras de payload são **por gateway**, e podem ser incompatíveis:

```php
new FallbackGateway([
Gateway::ASAAS->make([...]), // EXIGE vencimento no Pix
Gateway::WOOVI->make([...]), // RECUSA vencimento
]);
```

Essa dupla nunca tem os dois como candidatos reais para a mesma cobrança: com
vencimento, a Woovi recusa; sem, o Asaas recusa. O fallback funciona — sempre
cai —, mas a lista não dá a redundância que aparenta. Vale montar a cadeia com
gateways que aceitam o **mesmo** intent.

## Saber o que aconteceu

```php
new FallbackGateway($gateways, function (string $gateway, ?Throwable $falha) {
logger()->warning('phpay', ['gateway' => $gateway, 'falhou' => $falha?->getMessage()]);
});
```

Chamado depois de cada tentativa: com `null` em quem atendeu, com a exceção em
quem falhou. Sem isso, um primário fora do ar fica invisível.

## O que ele não faz

- **Não cai em leitura** (`find`, `getAll`): a cobrança só existe no gateway
que a criou. Procurar noutro devolve 404 sempre.
- **Não cai em estorno**: endereçado pelo id do gateway, pela mesma razão.
- **`charge()` é do primário, e não tem fallback.** Ele devolve um recurso com
estado, e qual gateway atende só se decide na chamada terminal, dentro de um
objeto que o decorator já não segura. Use `createCharge()`.
- **Não é circuit breaker**: não guarda que um gateway anda falhando. Isso
precisa de estado entre requisições, e o núcleo é sem estado de propósito.

## Um efeito colateral que vale saber

No Asaas, `createCharge()` cria o cliente antes da cobrança. Se a cobrança
falhar de um jeito que permita cair, o cliente **já foi criado lá** e fica
órfão. É registro sem dinheiro, bem menos grave que cobrança dupla, mas está
aqui para não surpreender.
19 changes: 12 additions & 7 deletions src/Gateways/Cielo/CieloGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,16 +78,23 @@ public function charge(): Charge
/**
* 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.
* the Cielo takes the card number on the server, so credit is reachable.
*
* the billet is NOT here, and it was until the due date was looked at:
* ChargeIntent::boleto() requires one, the Cielo's Payment takes it under
* a field this library has not confirmed, and the portable path was
* dropping it silently. Guessing the field name is what the project
* refuses to do with the Rede and the AbacatePay webhooks, and this is
* the same thing. Whoever confirms it can put the billet back.
*
* Debit is absent because it needs 3DS authentication, which the library
* does not model at all.
*
* @return array<int, Method>
*/
public function supportedMethods(): array
{
return [Method::PIX, Method::BOLETO, Method::CREDIT_CARD];
return [Method::PIX, Method::CREDIT_CARD];
}

/**
Expand All @@ -109,8 +116,6 @@ public function createCharge(ChargeIntent $intent): array

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);
Expand Down
7 changes: 6 additions & 1 deletion src/Gateways/Cielo/Requests/CieloSaleRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ class CieloSaleRequest
*/
public static function fromIntent(ChargeIntent $intent): array
{
if ($intent->dueDate !== null) {
throw ValidationException::make('Cielo', self::messages()->dueDate);
}

$sale = $intent->reference !== null ? ['MerchantOrderId' => $intent->reference] : [];

return $sale + $intent->extra();
Expand Down Expand Up @@ -99,7 +103,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, brand: string}
* @return object{merchantOrderId: string, customer: string, customerName: string, payment: string, paymentType: string, amount: string, brand: string, dueDate: string}
*/
public static function messages(): object
{
Expand All @@ -110,6 +114,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.',
'dueDate' => 'O Payment do Pix da Cielo não tem onde levar vencimento, e o campo de vencimento do boleto dela não está confirmado — engolir a data em silêncio cobraria com prazo que ninguém escolheu. Para boleto com vencimento, use charge()->setBoleto($valor, $options) com o campo que a documentação da Cielo nomear.',
'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
88 changes: 88 additions & 0 deletions src/Http/Delivery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

namespace PHPay\Http;

use GuzzleHttp\Exception\ConnectException;
use Throwable;

/**
* whether a failed request provably never left this machine.
*
* it is the one question two different policies need answered, and they must
* never answer it twice: `RetryPolicy` asks it to decide whether repeating
* the same request can double charge, and `FallbackGateway` asks it to
* decide whether handing the same charge to another gateway can. A second
* copy of this logic would drift from the first, and the drift would show up
* as a customer paying twice.
*
* The two Guzzle majors answer the question differently, and this class asks
* each one in its own language. Guzzle 7 throws a single ConnectException for
* everything from a failed DNS lookup to a read timeout, so only the cURL
* errno in its handler context separates them. Guzzle 8 removed that accessor
* and split the cases into classes instead, with its own upgrade guide saying
* to classify by class and not by error code — there, ConnectException means
* the connection never happened, and that is the whole question. Neither path
* names a class that exists in only one major.
*
* @see RetryPolicy
* @see https://github.com/guzzle/guzzle/blob/8.3.0/UPGRADING.md
*/
final class Delivery
{
/**
* cURL errors that prove nothing left: DNS, host, connection, TLS.
*
* @var array<int, int>
*/
private const NEVER_SENT = [5, 6, 7, 35];

/**
* cURL timeout, which may or may not have left — see below.
*/
private const TIMED_OUT = 28;

/**
* whether the request provably never reached the gateway.
*
* @param Throwable $exception a transport failure, in either Guzzle major
* @return bool
*/
public static function neverSent(Throwable $exception): bool
{
/*
* Guzzle 8 não tem getHandlerContext(): ele trocou o errno por
* classes, e a própria migração manda classificar pela classe. Lá a
* ConnectException significa o que o nome diz — a conexão não se
* estabeleceu, nada saiu — e a ConnectTimeoutException herda dela.
* Todo o resto (NetworkException, NetworkTimeoutException e a família
* Response*) aconteceu com a requisição já na linha.
*/
if (!method_exists($exception, 'getHandlerContext')) {
return $exception instanceof ConnectException;
}

$context = $exception->getHandlerContext();
$errno = $context['errno'] ?? null;

if (!is_int($errno)) {
return false;
}

if (in_array($errno, self::NEVER_SENT, true)) {
return true;
}

if ($errno !== self::TIMED_OUT) {
return false;
}

/*
* a timeout that happened before the connection was established is a
* connect timeout: nothing was sent. with a connection open, the body
* may well have been processed.
*/
$connectTime = $context['connect_time'] ?? null;

return is_numeric($connectTime) && (float) $connectTime === 0.0;
}
}
Loading
Loading