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..4925b05 --- /dev/null +++ b/tests/Unit/Support/FallbackGatewayTest.php @@ -0,0 +1,235 @@ +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 () { + /* 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); +})->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([ + 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');