From fd9ca4b116632900deb114c0b085b49791053ae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rio=20Lucas?= Date: Mon, 26 Oct 2026 20:27:18 -0300 Subject: [PATCH 1/2] PHPAY-174: feat: fall back only when nothing provably happened FallbackGateway tries a list of gateways in order, and the question it asks is never 'did it fail?' - it is 'did it provably not happen?'. Handing the same charge to another gateway after an ambiguous failure creates a second charge, and that is worse than a repeated retry: a retry can be deduplicated by an idempotency key, and two different PSPs share nothing that could deduplicate them. The answer comes from Http\Delivery, extracted from RetryPolicy so the two policies consult one copy. A second copy would drift, and the drift would show up as somebody paying twice. supportedMethods() returns the union, not the intersection, and that is not the mistake declaring the union of capabilities would be: a capability is asked once against this object, while a method is chosen per charge, and createCharge() filters the list before trying anything. Without that filter a Pix charge would reach a gateway that cannot do Pix, after the one that could had already been given up on. Testing this found the Cielo dropping a due date in silence on the portable path. It now refuses it, and the billet left its supportedMethods() until the Cielo's own field name is confirmed - guessing it is what this project refuses to do with the Rede webhook envelope. --- CLAUDE.md | 35 +++ docs/README.md | 1 + docs/fallback.md | 126 +++++++++ src/Gateways/Cielo/CieloGateway.php | 19 +- .../Cielo/Requests/CieloSaleRequest.php | 7 +- src/Http/Delivery.php | 88 +++++++ src/Http/RetryPolicy.php | 70 +---- src/Support/FallbackGateway.php | 245 ++++++++++++++++++ .../ChargeIntentAcrossGatewaysTest.php | 2 +- tests/Unit/Support/ChargeIntentCardTest.php | 19 +- tests/Unit/Support/FallbackGatewayTest.php | 217 ++++++++++++++++ 11 files changed, 748 insertions(+), 81 deletions(-) create mode 100644 docs/fallback.md create mode 100644 src/Http/Delivery.php create mode 100644 src/Support/FallbackGateway.php create mode 100644 tests/Unit/Support/FallbackGatewayTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 60a5a50..37c73fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 é @@ -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 é diff --git a/docs/README.md b/docs/README.md index bf18ce6..0972161 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 | diff --git a/docs/fallback.md b/docs/fallback.md new file mode 100644 index 0000000..d342a43 --- /dev/null +++ b/docs/fallback.md @@ -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. diff --git a/src/Gateways/Cielo/CieloGateway.php b/src/Gateways/Cielo/CieloGateway.php index 37bda51..f72b421 100644 --- a/src/Gateways/Cielo/CieloGateway.php +++ b/src/Gateways/Cielo/CieloGateway.php @@ -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 */ public function supportedMethods(): array { - return [Method::PIX, Method::BOLETO, Method::CREDIT_CARD]; + return [Method::PIX, Method::CREDIT_CARD]; } /** @@ -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); diff --git a/src/Gateways/Cielo/Requests/CieloSaleRequest.php b/src/Gateways/Cielo/Requests/CieloSaleRequest.php index 3403bc6..ea7df7c 100644 --- a/src/Gateways/Cielo/Requests/CieloSaleRequest.php +++ b/src/Gateways/Cielo/Requests/CieloSaleRequest.php @@ -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(); @@ -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 { @@ -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.', ]; } diff --git a/src/Http/Delivery.php b/src/Http/Delivery.php new file mode 100644 index 0000000..93b2e09 --- /dev/null +++ b/src/Http/Delivery.php @@ -0,0 +1,88 @@ + + */ + 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; + } +} diff --git a/src/Http/RetryPolicy.php b/src/Http/RetryPolicy.php index 7af2d7c..d475621 100644 --- a/src/Http/RetryPolicy.php +++ b/src/Http/RetryPolicy.php @@ -2,7 +2,6 @@ namespace PHPay\Http; -use GuzzleHttp\Exception\ConnectException; use Psr\Http\Client\NetworkExceptionInterface; use Psr\Http\Message\{RequestInterface, ResponseInterface}; use Throwable; @@ -22,14 +21,8 @@ * idempotent without checking the response, and that warning is worth more * than the spec. * - * The two Guzzle majors answer "did it leave the machine?" 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. + * Whether a failure left the machine is asked of {@see Delivery}, which is + * also what FallbackGateway consults — the answer must never have two copies. * * @see https://docs.asaas.com/reference/excluir-cobranca * @see https://github.com/guzzle/guzzle/blob/8.3.0/UPGRADING.md @@ -56,18 +49,6 @@ final class RetryPolicy */ private const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS']; - /** - * cURL error numbers where the request never left this machine: proxy and - * host resolution, the connection itself, and the TLS handshake. - */ - private const NEVER_SENT = [5, 6, 7, 35]; - - /** - * CURLE_OPERATION_TIMEDOUT. Guzzle reports it as a ConnectException even - * when the body was already on the wire, so it needs a second look. - */ - private const TIMED_OUT = 28; - /** * construct. * @@ -117,7 +98,7 @@ public function shouldRetry( * O contrato é o mesmo nas duas majors, a classe não. */ if ($exception instanceof NetworkExceptionInterface) { - return $this->neverSent($exception) || $this->repeatable($request); + return Delivery::neverSent($exception) || $this->repeatable($request); } /* another transport failure: no idea how far it got, so no guessing */ @@ -225,51 +206,6 @@ private function repeatable(RequestInterface $request): bool || Idempotency::present($request); } - /** - * whether the request provably never reached the gateway. - * - * @param Throwable $exception a transport failure, in either Guzzle major - * @return bool - */ - private 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; - } - /** * Retry-After, in milliseconds, when the gateway sent one. * diff --git a/src/Support/FallbackGateway.php b/src/Support/FallbackGateway.php new file mode 100644 index 0000000..c4e3840 --- /dev/null +++ b/src/Support/FallbackGateway.php @@ -0,0 +1,245 @@ +make(['token' => $token]), + * Gateway::WOOVI->make(['appId' => $appId]), + * ]); + * + * PHPay::gateway($gateway)->createCharge($intent); + * ``` + */ +final class FallbackGateway implements SupportsChargeIntents +{ + /** @var array */ + private array $gateways; + + /** + * @param array $gateways tried in order; the first is the primary + * @param Closure|null $onAttempt `fn (string $gateway, ?Throwable $failure)`, after each try + * @throws ValidationException + */ + public function __construct(array $gateways, private ?Closure $onAttempt = null) + { + if ($gateways === []) { + throw ValidationException::make('Fallback', self::messages()->empty); + } + + foreach ($gateways as $gateway) { + if (!$gateway instanceof SupportsChargeIntents) { + throw ValidationException::make('Fallback', self::messages()->contract); + } + } + + /** @var array $lista */ + $lista = array_values($gateways); + + $this->gateways = $lista; + } + + /** + * the chain, as it reads in an exception message. + * + * @return string + */ + public function name(): string + { + return 'Fallback (' . implode(' → ', array_map( + static fn (SupportsChargeIntents $gateway): string => $gateway->name(), + $this->gateways + )) . ')'; + } + + /** + * every method ANY gateway in the chain can do. + * + * the union, not the intersection — and that is not the same mistake as + * declaring the union of *capabilities* would be. A capability is checked + * once, against this object; a method is checked per charge, and + * createCharge() picks the gateways that do that one before trying any. + * So a chain of Asaas and Rede really does both Pix and credit: just + * never on the same gateway. + * + * @return array + */ + public function supportedMethods(): array + { + $metodos = []; + + foreach ($this->gateways as $gateway) { + foreach ($gateway->supportedMethods() as $metodo) { + $metodos[$metodo->value] = $metodo; + } + } + + return array_values($metodos); + } + + /** + * the PRIMARY gateway's charge resource. + * + * there is no fallback on it, and there cannot be: it hands back a + * stateful resource, and which gateway answers is only decided when the + * terminal call happens, inside an object this one no longer holds. Use + * createCharge() for the fallback; this is here because + * SupportsChargeIntents extends SupportsCharges, and refusing it would be + * declaring a capability and throwing from inside it. + * + * @return object + */ + public function charge(): object + { + return $this->gateways[0]->charge(); + } + + /** + * create the charge on the first gateway that can, and may. + * + * @param ChargeIntent $intent + * @return array + * @throws ValidationException|ApiException + */ + public function createCharge(ChargeIntent $intent): array + { + $candidatos = array_values(array_filter( + $this->gateways, + static fn (SupportsChargeIntents $gateway): bool => in_array($intent->method, $gateway->supportedMethods(), true) + )); + + if ($candidatos === []) { + throw ValidationException::make('Fallback', sprintf( + self::messages()->noCandidate, + $intent->method->label(), + $this->chain() + )); + } + + $ultima = null; + + foreach ($candidatos as $gateway) { + try { + $cobranca = $gateway->createCharge($intent); + + $this->report($gateway->name(), null); + + return $cobranca; + } catch (ValidationException $failure) { + /* + * a refusal of this gateway's own rules, raised before any + * HTTP — the Asaas wants a due date, the Woovi refuses one, + * the Cielo wants a brand. Nothing was sent, so the next one + * is safe, and its rules are different by definition. + */ + $this->report($gateway->name(), $failure); + + $ultima = $failure; + } catch (ApiException $failure) { + $this->report($gateway->name(), $failure); + + if (!self::mayFallBack($failure)) { + throw $failure; + } + + $ultima = $failure; + } + } + + /* every candidate refused, and each one allowed the next to be tried */ + throw $ultima; + } + + /** + * whether this failure allows handing the charge to another gateway. + * + * | situação | cai? | | + * | --- | :-: | --- | + * | DNS, conexão, TLS | sim | provadamente não saiu | + * | 401 / 403 | sim | recusou antes de processar | + * | 429 | sim | recusou antes de processar | + * | 4xx de payload | não | erraria igual no próximo | + * | 5xx já na linha | não | pode ter criado a cobrança | + * | timeout com conexão aberta | não | idem | + * + * @param ApiException $failure + * @return bool + */ + private static function mayFallBack(ApiException $failure): bool + { + $status = $failure->getStatusCode(); + + /* a credential or a rate limit is refused before anything is done */ + if (in_array($status, [401, 403, 429], true)) { + return true; + } + + /* the gateway answered at all: it had the request, so it may have acted */ + if ($status !== 0) { + return false; + } + + $transporte = $failure->getPrevious(); + + return $transporte !== null && Delivery::neverSent($transporte); + } + + /** + * @param string $gateway + * @param Throwable|null $failure + * @return void + */ + private function report(string $gateway, ?Throwable $failure): void + { + if ($this->onAttempt !== null) { + ($this->onAttempt)($gateway, $failure); + } + } + + /** + * what each gateway in the chain does, for the message. + * + * @return string + */ + private function chain(): string + { + return implode('; ', array_map( + static fn (SupportsChargeIntents $gateway): string => $gateway->name() . ': ' . Method::labels($gateway->supportedMethods()), + $this->gateways + )); + } + + /** + * messages for every refusal above. + * + * @return object{empty: string, contract: string, noCandidate: string} + */ + public static function messages(): object + { + return (object) [ + 'empty' => 'A lista de gateways do fallback não pode estar vazia.', + 'contract' => 'Todo gateway da lista precisa implementar SupportsChargeIntents. Sem ele não dá para perguntar quais meios o gateway faz, e uma lista que não sabe isso entrega uma cobrança Pix a quem só faz cartão.', + 'noCandidate' => 'Nenhum gateway desta lista cria cobrança por %s. O que cada um faz: %s.', + ]; + } +} diff --git a/tests/Unit/Support/ChargeIntentAcrossGatewaysTest.php b/tests/Unit/Support/ChargeIntentAcrossGatewaysTest.php index 1a13a0a..ed89fdb 100644 --- a/tests/Unit/Support/ChargeIntentAcrossGatewaysTest.php +++ b/tests/Unit/Support/ChargeIntentAcrossGatewaysTest.php @@ -143,6 +143,6 @@ public function charge(): object ['woovi', ['appId' => 'a'], [Method::PIX]], ['mercadopago', ['accessToken' => 'TEST-1'], [Method::PIX]], ['efi', ['clientId' => 'i', 'clientSecret' => 's'], [Method::BOLETO]], - ['cielo', ['merchantId' => 'i', 'merchantKey' => 'k'], [Method::PIX, Method::BOLETO, Method::CREDIT_CARD]], + ['cielo', ['merchantId' => 'i', 'merchantKey' => 'k'], [Method::PIX, Method::CREDIT_CARD]], ['rede', ['affiliation' => 'pv', 'secret' => 's'], [Method::CREDIT_CARD]], ])->group('support'); diff --git a/tests/Unit/Support/ChargeIntentCardTest.php b/tests/Unit/Support/ChargeIntentCardTest.php index 1faa017..de4374a 100644 --- a/tests/Unit/Support/ChargeIntentCardTest.php +++ b/tests/Unit/Support/ChargeIntentCardTest.php @@ -26,13 +26,11 @@ function clienteSimples(): Customer return Customer::make(name: 'Maria Oliveira', document: '123.456.789-09', email: 'maria@exemplo.com'); } -it('a cielo faz os três meios com o mesmo intent, mudando só a fábrica', function () { - $esperado = ['Pix', 'Boleto', 'CreditCard']; - $tipos = []; +it('a cielo faz pix e crédito com o mesmo intent, mudando só a fábrica', function () { + $tipos = []; foreach ([ ChargeIntent::pix(Money::reais('149,90'), clienteSimples()), - ChargeIntent::boleto(Money::reais('149,90'), clienteSimples(), '2099-11-10'), ChargeIntent::credito(Money::reais('149,90'), clienteSimples(), cartaoDeTeste('Visa'), 3), ] as $intent) { ClientFactory::fake([new Response(200, [], '{"Payment":{"PaymentId":"1"}}')]); @@ -44,7 +42,18 @@ function clienteSimples(): Customer ClientFactory::stopFaking(); } - expect($tipos)->toBe($esperado); + expect($tipos)->toBe(['Pix', 'CreditCard']); +})->group('support'); + +it('a cielo recusa vencimento em vez de engoli-lo', function () { + ClientFactory::fake([]); + + expect(fn () => PHPay::make('cielo', ['merchantId' => 'i', 'merchantKey' => 'k']) + ->createCharge(ChargeIntent::pix(Money::reais('10,00'), clienteSimples(), dueDate: '2099-12-31'))) + ->toThrow(ValidationException::class) + ->and(ClientFactory::recorded())->toBe([]) + ->and(PHPay::make('cielo', ['merchantId' => 'i', 'merchantKey' => 'k'])->supportedMethods()) + ->not->toContain(Method::BOLETO); })->group('support'); it('manda o cartão para a cielo com as chaves dela e as parcelas', function () { diff --git a/tests/Unit/Support/FallbackGatewayTest.php b/tests/Unit/Support/FallbackGatewayTest.php new file mode 100644 index 0000000..d04e0f1 --- /dev/null +++ b/tests/Unit/Support/FallbackGatewayTest.php @@ -0,0 +1,217 @@ +make(['appId' => 'a']), + Gateway::MERCADO_PAGO->make(['accessToken' => 'TEST-1']), + ], $aoTentar); +} + +function intentPix(): ChargeIntent +{ + return ChargeIntent::pix( + Money::reais('149,90'), + Customer::make(name: 'Maria Oliveira', document: '123.456.789-09', email: 'maria@exemplo.com'), + ); +} + +/** + * a falha de transporte que o Guzzle 7 levanta, com o errno que diz se saiu. + * + * @param int $errno + * @param float $connectTime + * @return ConnectException + */ +function transporte(int $errno, float $connectTime = 0.0): ConnectException +{ + return new ConnectException( + 'cURL error ' . $errno, + new Request('POST', 'payments'), + null, + ['errno' => $errno, 'connect_time' => $connectTime] + ); +} + +it('cai quando ficou provado que a requisição não saiu', function (int $errno) { + /* + * o retry vem ANTES do fallback, e as camadas são diferentes: um erro que + * provadamente não saiu é repetido no MESMO gateway três vezes, e só + * depois de esgotado é que o próximo da lista entra. Por isso três falhas + * na fila, não uma. + */ + ClientFactory::fake([ + transporte($errno), + transporte($errno), + transporte($errno), + new Response(200, [], '{"id":"pay_1"}'), + ]); + + $cobranca = PHPay::gateway(cadeia())->createCharge(intentPix()); + + expect($cobranca)->toHaveKey('id') + ->and((string) ClientFactory::recorded()[1]['request']->getUri())->toContain('mercadopago'); +})->with([5, 6, 7, 35])->group('phpay'); + +it('NÃO cai num 5xx, porque o gateway pode ter criado a cobrança', function () { + ClientFactory::fake([ + new Response(500, [], '{"errors":[{"description":"erro interno"}]}'), + new Response(200, [], '{"id":"pay_1"}'), + ]); + + expect(fn () => PHPay::gateway(cadeia())->createCharge(intentPix())) + ->toThrow(ApiException::class); + + /* uma tentativa só: a segunda resposta nem foi consumida */ + expect(ClientFactory::recorded())->toHaveCount(1); +})->group('phpay'); + +it('NÃO cai num timeout com a conexão já aberta', function () { + ClientFactory::fake([transporte(28, 0.4), new Response(200, [], '{"id":"pay_1"}')]); + + expect(fn () => PHPay::gateway(cadeia())->createCharge(intentPix())) + ->toThrow(ApiException::class); +})->group('phpay'); + +it('cai num timeout que nem chegou a conectar, depois do retry', function () { + ClientFactory::fake([ + transporte(28, 0.0), + transporte(28, 0.0), + transporte(28, 0.0), + new Response(200, [], '{"id":"pay_1"}'), + ]); + + expect(PHPay::gateway(cadeia())->createCharge(intentPix()))->toHaveKey('id'); +})->group('phpay'); + +it('cai numa credencial recusada, que não processou nada', function (int $status) { + ClientFactory::fake([ + new Response($status, [], '{"errors":[{"description":"credencial inválida"}]}'), + new Response(200, [], '{"id":"pay_1"}'), + ]); + + expect(PHPay::gateway(cadeia())->createCharge(intentPix()))->toHaveKey('id') + ->and(ClientFactory::recorded())->toHaveCount(2); +})->with([401, 403])->group('phpay'); + +it('NÃO cai num 4xx de payload, que erraria igual no próximo', function () { + ClientFactory::fake([ + new Response(422, [], '{"errors":[{"description":"payload inválido"}]}'), + new Response(200, [], '{"id":"pay_1"}'), + ]); + + expect(fn () => PHPay::gateway(cadeia())->createCharge(intentPix())) + ->toThrow(ApiException::class) + ->and(ClientFactory::recorded())->toHaveCount(1); +})->group('phpay'); + +it('cai quando a regra do gateway recusa antes de qualquer HTTP', function () { + /* + * a Woovi recusa vencimento (ela expira por duração) e o Asaas o exige. + * Com uma cobrança COM vencimento, a Woovi recusa sem mandar nada, e o + * Asaas atende — uma requisição só, porque a primeira nunca saiu. + */ + ClientFactory::fake([ + new Response(200, [], '{"id":"cus_1"}'), + new Response(200, [], '{"id":"pay_1"}'), + ]); + + $cadeia = new FallbackGateway([ + Gateway::WOOVI->make(['appId' => 'a']), + Gateway::ASAAS->make(['token' => 't']), + ]); + + $cobranca = $cadeia->createCharge(ChargeIntent::pix( + Money::reais('149,90'), + Customer::make(name: 'Maria Oliveira', document: '123.456.789-09', email: 'maria@exemplo.com'), + dueDate: '2099-11-10', + )); + + expect($cobranca['id'])->toBe('pay_1') + ->and((string) ClientFactory::recorded()[0]['request']->getUri())->toContain('asaas'); +})->group('phpay'); + +it('recusa antes de tentar quando nenhum da lista faz aquele meio', function () { + ClientFactory::fake([]); + + $cadeia = new FallbackGateway([ + Gateway::WOOVI->make(['appId' => 'a']), + Gateway::INTER->make(['clientId' => 'i', 'clientSecret' => 's']), + ]); + + expect(fn () => $cadeia->createCharge( + ChargeIntent::credito( + Money::reais('10,00'), + Customer::make(name: 'Maria'), + Card::make('4111111111111111', 'MARIA', '07', '2030', '123'), + ) + )) + ->toThrow(ValidationException::class, 'Nenhum gateway desta lista cria cobrança por cartão de crédito') + ->and(ClientFactory::recorded())->toBe([]); +})->group('phpay'); + +it('declara a união dos meios, porque escolhe por cobrança', function () { + $cadeia = new FallbackGateway([ + Gateway::ASAAS->make(['token' => 't']), + Gateway::REDE->make(['affiliation' => 'pv', 'secret' => 's']), + ]); + + expect($cadeia->supportedMethods()) + ->toBe([Method::PIX, Method::BOLETO, Method::CREDIT_CARD]); +})->group('phpay'); + +it('declara só as capacidades de cobrança, nunca as dos gateways de dentro', function () { + $phpay = PHPay::gateway(cadeia()); + + expect($phpay->supports(Capability::CHARGE_INTENTS))->toBeTrue() + ->and($phpay->supports(Capability::CHARGES))->toBeTrue() + ->and($phpay->supports(Capability::CUSTOMERS))->toBeFalse() + ->and($phpay->supports(Capability::REFUNDS))->toBeFalse() + ->and(fn () => $phpay->refund('qualquer'))->toThrow(NotImplementedException::class); +})->group('phpay'); + +it('conta o que tentou e quem atendeu', function () { + $tentativas = []; + + ClientFactory::fake([ + transporte(7), + transporte(7), + transporte(7), + new Response(200, [], '{"id":"pay_1"}'), + ]); + + PHPay::gateway(cadeia(function (string $gateway, ?Throwable $falha) use (&$tentativas) { + $tentativas[] = $gateway . ': ' . ($falha === null ? 'ok' : 'falhou'); + }))->createCharge(intentPix()); + + expect($tentativas)->toBe(['Woovi: falhou', 'Mercado Pago: ok']); +})->group('phpay'); + +it('recusa lista vazia e gateway que não declara a capacidade', function () { + expect(fn () => new FallbackGateway([])) + ->toThrow(ValidationException::class, FallbackGateway::messages()->empty) + ->and(fn () => new FallbackGateway([new stdClass()])) + ->toThrow(ValidationException::class, FallbackGateway::messages()->contract); +})->group('phpay'); + +it('nomeia a cadeia inteira nas mensagens', function () { + expect(cadeia()->name())->toBe('Fallback (Woovi → Mercado Pago)'); +})->group('phpay'); From aea56c9fc394163212fc2f018846eac9efc5c4cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rio=20Lucas?= Date: Mon, 26 Oct 2026 20:31:04 -0300 Subject: [PATCH 2/2] PHPAY-174: test: skip the transport scenarios that belong to one major A timeout with the connection already open is a ConnectException in Guzzle 7 and a NetworkTimeoutException in Guzzle 8, so the same test cannot hold in both: on 8 the class alone already means nothing was sent, and the fallback is right to take it. Each case now skips on the major where it does not exist, with the reason, the way RetryPolicyTest already does - and the Guzzle 8 side gets its own test instead of a gap. Verified by installing Guzzle 8 locally and running the whole suite and PHPStan against it, not by reasoning about it. --- tests/Unit/Support/FallbackGatewayTest.php | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/Unit/Support/FallbackGatewayTest.php b/tests/Unit/Support/FallbackGatewayTest.php index d04e0f1..4925b05 100644 --- a/tests/Unit/Support/FallbackGatewayTest.php +++ b/tests/Unit/Support/FallbackGatewayTest.php @@ -85,11 +85,29 @@ function transporte(int $errno, float $connectTime = 0.0): ConnectException })->group('phpay'); it('NÃO cai num timeout com a conexão já aberta', function () { + /* o corpo pode ter sido processado: cair aqui cobra de novo, noutro PSP */ ClientFactory::fake([transporte(28, 0.4), new Response(200, [], '{"id":"pay_1"}')]); expect(fn () => PHPay::gateway(cadeia())->createCharge(intentPix())) ->toThrow(ApiException::class); -})->group('phpay'); +})->skip( + !method_exists(ConnectException::class, 'getHandlerContext'), + 'cenário só existe no Guzzle 7: no 8, timeout com conexão aberta é NetworkTimeoutException, não ConnectException' +)->group('phpay'); + +it('no Guzzle 8, ConnectException por si já autoriza cair', function () { + ClientFactory::fake([ + transporte(28, 0.42), + transporte(28, 0.42), + transporte(28, 0.42), + new Response(200, [], '{"id":"pay_1"}'), + ]); + + expect(PHPay::gateway(cadeia())->createCharge(intentPix()))->toHaveKey('id'); +})->skip( + method_exists(ConnectException::class, 'getHandlerContext'), + 'só vale no Guzzle 8: no 7 o errno é que decide, e 28 com conexão aberta não autoriza' +)->group('phpay'); it('cai num timeout que nem chegou a conectar, depois do retry', function () { ClientFactory::fake([