diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ae7cf57..976cee6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,15 @@ on: jobs: phpunit-tests: + name: PHP ${{ matrix.php }} runs-on: ubuntu-latest + strategy: + fail-fast: false + # Os jobs rodam um de cada vez: a suíte Integration bate na sandbox da Iugu, + # que tem rate limit por conta, e mais de um job em paralelo estouraria esse limite. + max-parallel: 1 + matrix: + php: ['8.3', '8.4'] steps: - name: Checkout repository uses: actions/checkout@v4 @@ -40,12 +48,12 @@ jobs: with: context: . load: true - tags: multi-payment:latest - # Define o diretório de cache do composer dentro do build + tags: multi-payment:${{ matrix.php }} build-args: | + PHP_VERSION=${{ matrix.php }} COMPOSER_CACHE_DIR=/tmp/composer-cache - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=php-${{ matrix.php }} + cache-to: type=gha,mode=max,scope=php-${{ matrix.php }} - name: Execute tests via PHPUnit env: @@ -53,10 +61,12 @@ jobs: MULTIPAYMENT_DEFAULT: iugu IUGU_ID: ${{ secrets.IUGU_ID }} IUGU_APIKEY: ${{ secrets.IUGU_APIKEY }} + STRIPE_APIKEY: ${{ secrets.STRIPE_APIKEY }} run: | docker run --rm \ --env APP_ENV \ --env MULTIPAYMENT_DEFAULT \ --env IUGU_ID \ --env IUGU_APIKEY \ - multi-payment:latest composer test + --env STRIPE_APIKEY \ + multi-payment:${{ matrix.php }} composer test diff --git a/.gitignore b/.gitignore index 995ff5b..529f84c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ vendor/ .idea/ .vscode/ +/docs/implementacoes/ /.phpunit.result.cache /.phpunit.cache/ phpunit.xml \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 86ae292..43f774e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,6 @@ -FROM php:8.3.0-cli +# Versão do PHP da imagem; a CI sobrescreve via build-arg para cada entrada da matriz. +ARG PHP_VERSION=8.3 +FROM php:${PHP_VERSION}-cli RUN apt-get update && \ apt-get install -y --no-install-recommends \ @@ -27,4 +29,4 @@ RUN mkdir -p /var/www/.composer && chown -R www-data:www-data /var/www/.composer USER www-data -CMD ["composer", "test"] \ No newline at end of file +CMD ["composer", "test"] diff --git a/README.md b/README.md index 7f36970..528d069 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,43 @@ ## Introdução -MultiPayment permite gerenciar pagamentos de diversos gateways de pagamento. Atualmente suporta o Iugu. +MultiPayment permite gerenciar pagamentos de diversos gateways de pagamento. Atualmente suporta Iugu e Stripe. - [Introdução](#introdução) - [Requisitos](#requisitos) - [Instalação](#instalação) - [Configuração](#configuração) +- [Gateways](#gateways) + - [Capabilities](#capabilities) + - [Status da fatura](#status-da-fatura) + - [Migração das constantes para enum](#migração-das-constantes-para-enum) + - [Particularidades do Stripe](#particularidades-do-stripe) + - [Opções extras do gateway](#opções-extras-do-gateway) + - [Idempotência](#idempotência) - [Utilizando](#utilizando) - [MultiPayment](#multipayment) - - [InvoiceBuilder](#invoicebuilder) + - [Criar e cobrar uma fatura (InvoiceBuilder)](#criar-e-cobrar-uma-fatura-invoicebuilder) + - [Datas da fatura](#datas-da-fatura) - [Pix Automático](#pix-automático) + - [Pix Automático: quem agenda a cobrança](#pix-automático-quem-agenda-a-cobrança) + - [Assinaturas e planos](#assinaturas-e-planos) + - [Emulações na Iugu](#emulações-na-iugu) - [CustomerBuilder](#customerbuilder) + - [Salvar cartão (CreditCardBuilder)](#salvar-cartão-creditcardbuilder) - [getInvoice](#getinvoice) - - [charge](#charge) + - [Outras operações de fatura](#outras-operações-de-fatura) + - [Estorno](#estorno) + - [charge (alternativa por array)](#charge-alternativa-por-array) - [Models](#models) - [Customer](#customer) - [Invoice](#invoice) + - [Refund](#refund) + - [Subscription](#subscription) + - [Plan](#plan) +- [Apêndice: chaves do array de `charge()`](#apêndice-chaves-do-array-de-charge) ## Requisitos - - PHP 8.0+ - - Laravel 8.0+ + - PHP 8.3+ + - Laravel 10.0+ ## Instalação @@ -29,6 +47,16 @@ Instale esse pacote pelo composer: composer require potelo/multi-payment "dev-main" ``` +O SDK da Iugu vem do fork `Potelo/iugu-php`, que não está no Packagist, e o Composer não herda +a lista de repositórios de uma dependência. Declare o fork no `composer.json` da aplicação +antes de instalar: + +```json +"repositories": [ + {"type": "git", "url": "https://github.com/Potelo/iugu-php.git"} +] +``` + ## Configuração Após instalar o pacote rode o comando abaixo para publicar as configurações no projeto Laravel ``` @@ -46,6 +74,18 @@ MULTIPAYMENT_DEFAULT=iugu #iugu IUGU_ID= IUGU_APIKEY= +IUGU_MAX_INSTALLMENTS=12 # opcional; máximo de parcelas habilitado na conta (ver Capabilities) + +#stripe +STRIPE_APIKEY= +STRIPE_PIX_MANDATE_REFERENCE= # opcional; nome exibido no aplicativo do banco no mandato de Pix Automático (ver Pix Automático) + +#idempotência (opcional; ver a seção Idempotência) +MULTIPAYMENT_IDEMPOTENCY_TTL=86400 +MULTIPAYMENT_IDEMPOTENCY_CACHE_STORE= + +#fill() estrito (opcional, padrão true; ver a seção "fill() estrito") +MULTIPAYMENT_STRICT_FILL=true ``` Opcionalmente você pode configurar o Trait, para facilitar o uso do método `charge` junto a um usuário. @@ -68,6 +108,830 @@ Também é possível utilizar o Facade: \Potelo\MultiPayment\Facades\MultiPayment::charge($options); ``` +## Gateways + +### Capabilities + +Cada driver declara o que suporta em três níveis, pelo contract `DeclaresCapabilities`: +`capabilities()` lista o que o gateway oferece e a lib implementa; `notYetImplemented()` lista o +que o gateway oferece mas a lib ainda não construiu (planejado para uma versão futura); e +`emulated()` lista o que o gateway não oferece mas a lib entrega por conta própria (ver +[Emulações na Iugu](#emulações-na-iugu)). O que não aparece em nenhuma das três listas é +limitação do gateway. `supports(Capability $c)` responde verdadeiro para capability +implementada ou emulada, `isEmulated(Capability $c)` diz se ela roda na lib, e +`supportsAll(Capability ...$c)` exige todas de uma vez. Os valores são o enum +`Potelo\MultiPayment\Enums\Capability`. + +Uma capability suportada pode valer só numa parte dos casos. `restriction(Capability $c)` devolve +um `CapabilityRestriction` (`Potelo\MultiPayment\Capabilities\CapabilityRestriction`) com a +`description` da restrição e, quando ela é enumerável, `allowedPaymentMethods`, `allowedBrands` +ou `maxInstallments`; nulo quando a capability vale em todos os casos. `restrictions()` lista +todas, com o valor da capability como chave. + +Consulte a capability **antes** de montar a interface de checkout ou de escolher o gateway, em +vez de capturar a exceção depois: + +```php +use Potelo\MultiPayment\Enums\Capability; +use Potelo\MultiPayment\Facades\MultiPayment; + +if (!MultiPayment::gateway('stripe')->supports(Capability::AUTOMATIC_PIX)) { + $gateway = 'iugu'; // roteia antes de exibir a opção +} + +MultiPayment::supports(Capability::INSTALLMENTS, 'iugu'); // true +MultiPayment::supportsAll(Capability::PIX, Capability::INVOICE_DUPLICATION); // no gateway da instância +MultiPayment::capabilities('stripe'); // Capability[] que a lib implementa +MultiPayment::notYetImplemented('stripe'); // Capability[] planejadas +MultiPayment::emulated('iugu'); // [Capability::COUPONS, Capability::CANCEL_AT_PERIOD_END] +MultiPayment::isEmulated(Capability::COUPONS, 'iugu'); // true: exige o comando de sincronização agendado + +// restrições dentro de um "sim", consultáveis antes de tokenizar ou de exibir a opção +$brands = MultiPayment::restriction(Capability::CREDIT_CARD, 'stripe'); +if ($brands && !$brands->allowsBrand($binLookup->brand)) { + $gateway = 'iugu'; // Elo, Hipercard e Amex vão para a Iugu +} +MultiPayment::restriction(Capability::INSTALLMENTS, 'iugu')->maxInstallments; // 12 por padrão +MultiPayment::restriction(Capability::PIX, 'iugu'); // null: vale em todos os casos +``` + +Toda operação fora das capabilities do gateway lança `UnsupportedOperationException` **antes de +qualquer requisição**, inclusive antes de criar o cliente que acompanha a fatura ou a +assinatura. A exceção traz `capability`, `gateway` e `reason` (`not_implemented` quando o +gateway oferece e a lib ainda não implementou; `gateway_limitation` quando o gateway não +oferece; `managed_by_gateway` quando o próprio gateway conduz a operação e a chamada pela lib +não se aplica, como o agendamento de Pix Automático no Stripe). Ver +[Tratamento de erros](#tratamento-de-erros). + +A matriz abaixo é gerada a partir das declarações dos drivers com `composer capabilities:table`; +o teste `GatewayCapabilitiesTest` falha quando o README fica defasado em relação ao código. A +coluna "Restrições" é o que `restriction()` devolve para cada gateway. + +| Capability | Significado | Iugu | Stripe | Restrições | +|---|---|---|---|---| +| `CREDIT_CARD` | Fatura paga com cartão de crédito. | sim | sim | Stripe: Na conta brasileira só cartão de crédito Visa e Mastercard; outra bandeira é recusada na cobrança com DeclineCode::BRAND_NOT_SUPPORTED. | +| `PIX` | Fatura paga com Pix avulso, com QR Code de pagamento único. | sim | sim | | +| `BANK_SLIP` | Fatura paga com boleto bancário. | sim | sim | Stripe: A Stripe aceita boleto de R$ 5,00 a R$ 49.999,99, com vencimento de hoje a 60 dias; fora dessas janelas a criação é recusada antes da requisição. | +| `AUTOMATIC_PIX` | Recorrência de Pix Automático autorizada pelo pagador; quem agenda cada cobrança depende de `MANAGES_RECURRENCE`. | sim | sim | Iugu: A recorrência nasce na fatura (Invoice com automaticPix e método pix) e a aplicação é o motor de recorrência; a assinatura não aceita paymentMethod automatic_pix.
Stripe: A recorrência é o mandato de uma assinatura (paymentMethod automatic_pix na criação) e o gateway agenda as cobranças; fatura avulsa com automaticPix não é aceita, e as operações de agendamento e de cancelamento de cobrança da lib respondem managed_by_gateway. | +| `MULTIPLE_PAYMENT_METHODS` | Fatura aberta a mais de um método de pagamento, escolhido pelo pagador na hora de pagar. | sim | não implementado | | +| `RAW_CARD_DATA` | Cartão informado com número e CVV pela API; sem ela, o cartão é tokenizado no navegador e só o token chega à lib. | sim | limitação do gateway | | +| `CARD_SETUP_AUTHENTICATION` | Autenticação do portador com o emissor (3DS) ao salvar o cartão: cartão que exige ação do pagador volta com `CreditCard::$requiresAction` verdadeiro e `id` nulo, e `confirmCreditCardSetup()` conclui o salvamento depois da autenticação. | limitação do gateway | sim | | +| `INSTALLMENTS` | Parcelamento da cobrança no cartão de crédito. | sim | limitação do gateway | Iugu: O número de parcelas vai em gatewayOptions['months'], até 12 (máximo da conta, configurável em multi-payment.gateways.iugu.max_installments); a lib não lê as parcelas da fatura paga. | +| `DELAYED_CAPTURE` | Cobrança em duas etapas no cartão: reserva do valor agora e captura depois. | não implementado | não implementado | | +| `PARTIAL_REFUND_CARD` | Estorno de parte do valor numa fatura paga com cartão. | sim | sim | | +| `PARTIAL_REFUND_PIX` | Estorno de parte do valor numa fatura paga com Pix. | limitação do gateway | sim | | +| `REFUND_BANK_SLIP` | Estorno pela API de uma fatura paga com boleto. | limitação do gateway | limitação do gateway | | +| `INVOICE_DUPLICATION` | Segunda via de uma fatura pendente com nova data de vencimento (`duplicateInvoice`). | sim | sim | Stripe: Só fatura Pix pendente de venda avulsa (PaymentIntent); cartão, boleto, outro estado ou fatura de assinatura são recusados. | +| `INVOICE_CANCELLATION` | Cancelamento de uma fatura ainda não paga (`cancelInvoice`). | sim | sim | Stripe: A fatura de assinatura (objeto Invoice) só é anulada depois de finalizada pela Stripe (rascunho é recusado), e o boleto pendente só depois de o voucher vencer. | +| `IDEMPOTENCY` | Chave de idempotência (`idempotencyKey`) honrada em toda operação de escrita, pelo gateway ou pela deduplicação da lib (`IdempotencyStore`). | sim | sim | | +| `IDEMPOTENCY_ALL_ENDPOINTS` | Chave de idempotência honrada pelo próprio gateway em toda operação de escrita, sem depender da deduplicação da lib. | limitação do gateway | sim | | +| `SUBSCRIPTIONS` | Assinatura recorrente: criar, buscar, atualizar, suspender, retomar, cancelar, trocar de plano e listar. | sim | sim | Stripe: nextBillingAt vale só na criação da assinatura; na troca de plano e na atualização a Stripe não aceita uma data arbitrária de próxima cobrança. | +| `PLANS` | Plano de assinatura: criar, buscar e listar. | sim | sim | | +| `PLAN_DEACTIVATION` | Desativar um plano sem apagá-lo (`deactivatePlan`). | limitação do gateway | sim | | +| `CANCEL_AT_PERIOD_END` | Cancelar a assinatura só no fim do período já pago (`cancel(atPeriodEnd: true)`). | emulado | sim | | +| `COUPONS` | Cupom de assinatura com prazo: desconto limitado a um número de ciclos ou válido até uma data (`validUntil`). | emulado | sim | Stripe: O cupom da Stripe dura meses inteiros (duration_in_months): cycles maior que 1 exige plano com intervalo mensal ou anual, e validUntil vira meses inteiros contados da aplicação, arredondados para cima. | +| `PERCENT_DISCOUNT` | Desconto percentual (`percentOff`) sobre o valor da assinatura. | limitação do gateway | sim | | +| `PLAN_CHANGE_PRORATION` | Crédito proporcional do período não usado, calculado pelo gateway, ao trocar de plano (`changePlan()` com `ProrationBehavior::CREDIT`). | limitação do gateway | sim | | +| `SUBSCRIPTION_CREDITS` | Assinatura com saldo de créditos consumíveis, abatidos a cada uso. | não implementado | limitação do gateway | | +| `MANAGES_RECURRENCE` | O gateway agenda as cobranças do Pix Automático por conta própria; sem ela, a aplicação é o motor de recorrência e chama as operações de `AutomaticPixContract` na periodicidade certa. | limitação do gateway | sim | | + +Sobre as restrições e algumas células: + +- **Operação fora da restrição** lança `UnsupportedOperationException::restricted()`, com a + capability, `reason` `gateway_limitation` e a mensagem que descreve a restrição: duplicação + fora de Pix pendente, cancelamento de rascunho de fatura de assinatura ou de boleto com + voucher em aberto no Stripe, cartão que pertence a outro cliente no Stripe (ver + [Particularidades do Stripe](#particularidades-do-stripe)). + A bandeira fora da restrição de `CREDIT_CARD` chega depois, na cobrança, como + `CardDeclinedException` com `DeclineCode::BRAND_NOT_SUPPORTED`; por isso vale consultar + `restriction()->allowsBrand()` antes de tokenizar. +- **`CARD_SETUP_AUTHENTICATION`** faz `newCreditCard()->create()` devolver um cartão com + `requiresAction` quando o emissor exige autenticação; na Iugu, que só valida o cartão + (Zero Auth, sem 3DS), o cartão volta sempre cobrável (ver + [Salvar cartão](#salvar-cartão-creditcardbuilder)). +- **`INSTALLMENTS` na Iugu** é informado em `gateway_options['months']`; a lib não modela parcelas + nem lê os campos da fatura parcelada. O máximo publicado em `maxInstallments` vem de + `multi-payment.gateways.iugu.max_installments` (`IUGU_MAX_INSTALLMENTS`, 12 por padrão) e + deve refletir o parcelamento habilitado na conta. +- **`IDEMPOTENCY` na Iugu** é honrada pelo gateway só na criação de fatura, cliente e assinatura e + na cobrança com cartão; nas demais operações de escrita a deduplicação é da lib, pela + `IdempotencyStore`, que exige o cache do Laravel configurado (ver [Idempotência](#idempotência)). + Por isso a Iugu não tem `IDEMPOTENCY_ALL_ENDPOINTS`. +- **`PARTIAL_REFUND_PIX` e `REFUND_BANK_SLIP`** chegam como `RefundNotSupportedException`, com + `isCapabilityLimitation()` verdadeiro; a classe fica fora da árvore de + `UnsupportedOperationException` (ver [Estorno](#estorno)). +- **`MANAGES_RECURRENCE`** é informativa: diz quem agenda a cobrança do Pix Automático (ver + [Pix Automático: quem agenda a cobrança](#pix-automático-quem-agenda-a-cobrança)). +- **`PLAN_CHANGE_PRORATION`** é o que `changePlan()` com `ProrationBehavior::CREDIT` exige; as + outras duas políticas fazem parte de `SUBSCRIPTIONS` (ver [Troca de plano](#troca-de-plano)). + +### Status da fatura + +`Invoice::$status` é o enum `Potelo\MultiPayment\Enums\InvoiceStatus`, sempre no vocabulário do +pacote; o status específico de cada gateway fica em `original`. Os treze estados: + +| `InvoiceStatus` | Significado | Iugu | Stripe | Helper que responde | +|---|---|---|---|---| +| `PENDING` | Aguardando pagamento | `pending`, `draft` | PaymentIntent em `requires_payment_method`, `requires_action`, `requires_confirmation`; Invoice `draft` ou `open` sem pagamento em curso | `isOpen()` e `isPayable()` | +| `AUTHORIZED` | Valor reservado no cartão, aguardando captura ou análise | `in_analysis`, `authorized` | PaymentIntent `requires_capture` | `isOpen()` e `isPayable()` | +| `PROCESSING` | Pagamento em processamento no gateway | (não emite) | PaymentIntent `processing` | `isOpen()` | +| `PAID` | Valor recebido | `paid` | PaymentIntent `succeeded` sem estorno nem contestação; Invoice `paid` quitado sem cobrança | `isSettled()` | +| `PARTIALLY_PAID` | Parte do valor recebida, restante em aberto | `partially_paid` | Invoice `open` com parte do `total` em `amount_paid` (não emite em venda avulsa) | `isSettled()`, `isOpen()` e `isPayable()` | +| `EXTERNALLY_PAID` | Quitada fora do gateway, por baixa manual | `externally_paid` | Invoice `paid` com pagamento registrado fora da Stripe (não emite em venda avulsa) | `isSettled()` | +| `PARTIALLY_REFUNDED` | Estorno voluntário, parcial | `partially_refunded` | charge com `amount_refunded` menor que o total | `isSettled()` | +| `REFUNDED` | Estorno voluntário, integral | `refunded` | charge com `refunded = true` | `isTerminal()` | +| `DISPUTED` | Contestação aberta sobre fatura paga, resolução pendente | `in_protest` | charge `disputed` com dispute em `warning_needs_response`, `warning_under_review`, `needs_response` ou `under_review` | `isContested()` | +| `CHARGEBACK` | Contestação perdida: valor devolvido ao cliente pelo gateway | `chargeback` | dispute em `lost` | `isContested()` e `isTerminal()` | +| `CANCELED` | Cancelada antes do pagamento | `canceled` | PaymentIntent `canceled`; Invoice `void` | `isTerminal()` | +| `EXPIRED` | Venceu sem pagamento; continua pagável | `expired` | Invoice `uncollectible` (não emite em venda avulsa) | `isPayable()` | +| `UNKNOWN` | Status que a lib não reconhece | qualquer outro | qualquer outro | nenhum responde verdadeiro | + +Dispute ganha (`won`), encerrada sem virar chargeback (`warning_closed`) ou prevenida +(`prevented`) não altera o status: a fatura volta a ler como `PAID` (ou como estornada, se +houve estorno). Um status fora do mapa vira `UNKNOWN`, com um aviso no log da aplicação (nível +`warning`) contendo o valor original e o gateway, e o valor cru continua em `original`. No +Stripe a coluna mistura as duas origens da fatura: a venda avulsa (PaymentIntent) e a fatura +de assinatura (Invoice), cuja regra de precedência está em +[Fatura no Stripe: duas origens](#fatura-no-stripe-duas-origens). + +Os helpers do enum respondem às perguntas de negócio sem comparar status um a um: + +```php +use Potelo\MultiPayment\Enums\InvoiceStatus; + +$invoice->status->isSettled(); // recebi dinheiro? PAID, PARTIALLY_PAID, EXTERNALLY_PAID, PARTIALLY_REFUNDED +$invoice->status->isOpen(); // pagamento ainda por resolver? PENDING, AUTHORIZED, PROCESSING, PARTIALLY_PAID +$invoice->status->isPayable(); // aceita um pagamento agora? PENDING, AUTHORIZED, PARTIALLY_PAID, EXPIRED +$invoice->status->isContested(); // tem briga? DISPUTED, CHARGEBACK +$invoice->status->isTerminal(); // acabou? REFUNDED, CHARGEBACK, CANCELED + +match ($invoice->status) { + InvoiceStatus::DISPUTED => $this->openDisputeTicket($invoice), + InvoiceStatus::CHARGEBACK => $this->writeOff($invoice), + InvoiceStatus::EXPIRED => $this->offerNewPix($invoice), + default => null, +}; +``` + +`PARTIALLY_PAID` responde verdadeiro a `isSettled()` e a `isOpen()` ao mesmo tempo: parte do +dinheiro entrou e o restante segue cobrável. `EXPIRED` responde verdadeiro só a `isPayable()`: +a fatura vencida continua pagável nos dois gateways (na Iugu ela segue devida até ser paga ou +cancelada, e na Stripe `uncollectible` pode voltar a `paid`), então ela fica fora de +`isTerminal()` e fora de `isOpen()`, que descreve a fatura com pagamento em curso. `PROCESSING` +responde só a `isOpen()`: há um pagamento em curso, e a fatura fica fora de `isPayable()`. Quem +decide se para de cobrar deve olhar `isTerminal()`; quem decide se oferece um novo Pix ou boleto +deve olhar `isPayable()`. +Os helpers estáticos `Invoice::isSettled()` e `Invoice::isContested()` continuam existindo, +delegam ao enum e estão obsoletos (emitem `E_USER_DEPRECATED`). + +> **Mudança de comportamento (versão 5.0.0).** `InvoiceStatus::EXPIRED->isTerminal()` passou a +> responder falso. Quem usava `isTerminal()` para parar de cobrar parava cedo demais na Iugu, +> onde a fatura vencida segue pagável. Se a aplicação tratava a fatura vencida como encerrada, +> trate `EXPIRED` explicitamente, ou use `isPayable()` para decidir se ainda cabe pagamento. + +> **Mudança de comportamento (versão 5.0.0).** Até a 4.1.0, fatura Iugu em `in_protest` lia como +> `paid` e fatura em `chargeback` lia como `refunded`; no Stripe, charge contestado lia como +> `paid`. A partir desta versão elas leem como `DISPUTED` e `CHARGEBACK`. Quem compara com +> `PAID` para decidir se recebeu **deixa de ver faturas em disputa como pagas**, e quem compara +> com `REFUNDED` deixa de confundir chargeback com estorno voluntário. Na mesma versão, a Iugu +> deixou de ser achatada: `in_analysis` lia como `pending` e agora é `AUTHORIZED`; +> `partially_paid` lia como `pending` e agora é `PARTIALLY_PAID`; `externally_paid` lia como +> `paid` e agora é `EXTERNALLY_PAID`; `expired` lia como `canceled` e agora é `EXPIRED`. No +> Stripe, `requires_capture` lia como `pending` e agora é `AUTHORIZED`; `processing` lia como +> `pending` e agora é `PROCESSING`. Status desconhecido lançava `GatewayException` e agora vira +> `UNKNOWN` com log. Se a aplicação precisava do comportamento antigo, use `isSettled()` para +> "pago", `isOpen()` para "ainda cobrável" e trate `DISPUTED` e `CHARGEBACK` explicitamente. + +### Status da assinatura + +`Subscription::$status` é o enum `Potelo\MultiPayment\Enums\SubscriptionStatus`, no mesmo +desenho do status da fatura; as flags ou o status específico do gateway ficam em `original`. +Os nove estados: + +| `SubscriptionStatus` | Significado | Iugu | Stripe | Helper que responde | +|---|---|---|---|---| +| `PENDING` | Criada e ainda sem cobrança confirmada | `active` falso com `expires_at` futuro ou ausente | `incomplete` | `isRecoverable()` | +| `TRIALING` | Em período de teste | `in_trial` | `trialing` | `isActive()` | +| `ACTIVE` | Em dia | `active` | `active` | `isActive()` | +| `PAST_DUE` | Cobrança vencida sem pagamento | derivado: `expires_at` no passado com alguma fatura de `recent_invoices` em aberto | `past_due`, `unpaid` | `isRecoverable()` | +| `PAUSED` | Cobrança pausada pelo gateway | (não emite) | `paused`; qualquer status não encerrado com `pause_collection` preenchido | `isRecoverable()` | +| `SUSPENDED` | Cobrança interrompida pela aplicação | `suspended` | (não emite; `suspend()` usa `pause_collection`, que lê como `PAUSED`) | `isRecoverable()` | +| `CANCELED` | Encerrada | `suspended` com a marca `mp_canceled_at` em `custom_variables`, gravada por `cancel()` | `canceled` | `isEnded()` | +| `EXPIRED` | Ciclo terminou sem renovação | `active` falso com `expires_at` no passado e nenhuma fatura em aberto | `incomplete_expired` | `isEnded()` | +| `UNKNOWN` | Status que a lib não reconhece | (não emite: a Iugu não tem campo de status) | qualquer outro | nenhum responde verdadeiro | + +A precedência na Iugu é a ordem em que o driver testa as flags: `suspended` (com ou sem a +marca) vence `in_trial`, que vence a derivação de `past_due`, que vence `active`. A regra de +`EXPIRED` na Iugu segue o painel do gateway (assinatura "Expirada") e o webhook +`subscription.expired`. Um status +fora do mapa vira `UNKNOWN`, com um aviso no log da aplicação (nível `warning`) contendo o valor +original e o gateway. + +```php +use Potelo\MultiPayment\Enums\SubscriptionStatus; + +if ($subscription->status->isActive()) { // TRIALING, ACTIVE + $account->grantAccess(); +} elseif ($subscription->status->isRecoverable()) { // PENDING, PAST_DUE, PAUSED, SUSPENDED + $dunning->start($subscription); +} elseif ($subscription->status->isEnded()) { // CANCELED, EXPIRED + $account->revokeAccess(); +} + +$subscription->status === SubscriptionStatus::PAST_DUE; +$subscription->status->value; // 'past_due', para gravar no banco +``` + +Cada estado responde verdadeiro a exatamente um dos três helpers (`UNKNOWN` a nenhum), então +os três `if` acima cobrem tudo que a lib produz. + +> **Mudança de comportamento (versão 5.0.0).** Até a 4.1.0, `Subscription::$status` era uma +> string e `cancel()` na Iugu devolvia `suspended`, o mesmo estado de `suspend()`. Agora a +> propriedade devolve `SubscriptionStatus` (compará-la com `Subscription::STATUS_*` é sempre +> falso; use o caso do enum ou `->value`), e `cancel()` na Iugu devolve `CANCELED`: além de +> suspender, o driver grava a data em `custom_variables` (`mp_canceled_at`), numa segunda +> requisição, e é essa marca que distingue os dois estados na leitura. `resume()` remove a +> marca. Assinaturas canceladas antes desta versão continuam lendo como `SUSPENDED`, porque não +> têm a marca. + +### Migração das constantes para enum + +Status da fatura, status da assinatura, método de pagamento e intervalo do plano são enums do +namespace `Potelo\MultiPayment\Enums`: `InvoiceStatus`, `SubscriptionStatus`, `PaymentMethod` +(`CREDIT_CARD`, `BANK_SLIP`, `PIX`, `AUTOMATIC_PIX`) e `PlanInterval` (`DAY`, `WEEK`, `MONTH`, +`YEAR`). As propriedades `Invoice::$status`, `Invoice::$paymentMethod`, +`Invoice::$availablePaymentMethods`, `Subscription::$status`, `Subscription::$paymentMethod`, +`Subscription::$availablePaymentMethods` e `Plan::$interval` devolvem o enum na leitura e +aceitam, na escrita, tanto o caso do enum quanto a string do valor (as constantes antigas). O +mesmo vale para `fill()` e para os builders. + +As constantes antigas (`Invoice::STATUS_*`, `Subscription::STATUS_*`, +`Invoice::PAYMENT_METHOD_*`, `Plan::INTERVAL_*`) continuam existindo, com os mesmos valores de +string dos enums, e estão marcadas como `@deprecated`. O que muda é a **comparação**: a +propriedade agora devolve um enum, então compará-la diretamente com a string antiga é sempre +falso. + +```php +use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\PaymentMethod; +use Potelo\MultiPayment\Enums\PlanInterval; + +// antes +if ($invoice->status === Invoice::STATUS_PAID) { ... } +if (in_array($invoice->paymentMethod, [Invoice::PAYMENT_METHOD_PIX, Invoice::PAYMENT_METHOD_BANK_SLIP])) { ... } +$invoice->paymentMethod = Invoice::PAYMENT_METHOD_CREDIT_CARD; +$plan->interval = Plan::INTERVAL_MONTH; +$model->status_pagamento = $invoice->status; // gravando no banco + +// depois (recomendado) +if ($invoice->status === InvoiceStatus::PAID) { ... } +if (in_array($invoice->paymentMethod, [PaymentMethod::PIX, PaymentMethod::BANK_SLIP], true)) { ... } +$invoice->paymentMethod = PaymentMethod::CREDIT_CARD; +$plan->interval = PlanInterval::MONTH; +$model->status_pagamento = $invoice->status->value; // 'paid' + +// transição: a constante antiga ainda vale como valor de string +if ($invoice->status->value === Invoice::STATUS_PAID) { ... } +$invoice->paymentMethod = Invoice::PAYMENT_METHOD_CREDIT_CARD; // convertido para PaymentMethod::CREDIT_CARD +``` + +Onde a string vai para fora do PHP (banco, JSON, log, comparação com valor vindo do gateway), +use `->value`. `toArray()` já emite o valor de string, e `json_encode($invoice)` também. + +Valor de string fora do enum tem dois tratamentos: em `status`, vira `InvoiceStatus::UNKNOWN` +ou `SubscriptionStatus::UNKNOWN` com aviso no log; em `paymentMethod`, +`availablePaymentMethods` e `interval`, lança +`ModelAttributeValidationException` na escrita, com a lista de valores aceitos. Em +`availablePaymentMethods` só entram `CREDIT_CARD`, `BANK_SLIP` e `PIX`; `AUTOMATIC_PIX` é +recusado na lista e em `Invoice::$paymentMethod`, porque a fatura com Pix Automático é criada +com `PIX` e o objeto `automaticPix` preenchido. Em `Subscription::$paymentMethod` ele é +aceito: no Stripe é o método da assinatura com mandato (ver +[Pix Automático](#pix-automático)). + +> **Mudança de comportamento (versão 5.0.0).** `$invoice->status === Invoice::STATUS_PAID` e +> comparações equivalentes com `paymentMethod` e `interval` passam a ser **falsas**, porque a +> propriedade devolve um enum. Revise cada comparação com constante ou string literal e troque +> pelo caso do enum ou compare `->value`. Método de pagamento e intervalo inválidos lançam já +> na escrita da propriedade (até a 4.1.0, só `validate()` acusava). + +### Particularidades do Stripe + +- **Cartão é token-only.** O Stripe não aceita dados crus de cartão pela API (exigiria + liberação de "raw card data" e escopo PCI SAQ D). Tokenize o cartão no navegador com + Stripe.js e envie o id resultante (`pm_...`) em `credit_card.token` / `CreditCard::$token` + (tokens legados `tok_...` também são aceitos). O caminho com `number`/`cvv` lança + `UnsupportedOperationException` (`RAW_CARD_DATA`, `gateway_limitation`) orientando o uso de + token. +- **Bandeiras aceitas no Brasil: somente Visa e Mastercard crédito.** Elo, Hipercard, Amex e + débito nacional não são suportados pelo Stripe BR. Para essas bandeiras, roteie a cobrança + para outro gateway (ex.: Iugu) — de preferência detectando a bandeira pelo BIN antes de + tokenizar. Para decidir o fallback programaticamente, use `CardDeclinedException::$declineCode`: + `DeclineCode::BRAND_NOT_SUPPORTED` é a recusa por bandeira (`card_not_supported` na Stripe), e + `retryable` diz se vale repetir com o mesmo cartão (ver [Códigos de recusa](#códigos-de-recusa)). + `GatewayNotAvailableException` também sinaliza "tente outro gateway"; `AuthenticationException` + sinaliza credencial errada e não deve gerar fallback (ver [Tratamento de erros](#tratamento-de-erros)). +- **Salvar cartão autentica o portador quando o emissor exige.** `newCreditCard()->create()` + cria e confirma um SetupIntent (`usage: off_session`): o cartão que o emissor aprova volta + salvo e cobrável; o cartão que exige autenticação (3DS) volta com `requiresAction` + verdadeiro, sem `id`, e só fica cobrável depois de `confirmCreditCardSetup()` (ver + [Salvar cartão](#salvar-cartão-creditcardbuilder)). Na venda avulsa com token + (`addCreditCardToken()`), um cartão que exige autenticação lança `CardDeclinedException` com + `DeclineCode::AUTHENTICATION_REQUIRED` antes de criar a fatura: a cobrança fora de sessão não + tem como atendê-la. Esse código pede ação do pagador (autenticar o cartão ou informar outro); + o gateway respondeu normalmente e não cabe fallback. +- **Pix exige `tax_document` do cliente** (CPF/CNPJ vai nos billing details do pagamento). +- **A expiração do QR Code do Pix é `pixExpiresAt`** (opcional; default do Stripe: 4 horas) e, + quando informada, deve ficar entre 10 segundos e 14 dias no futuro. Sem ela, `dueDate` faz o + QR Code expirar no fim do dia do vencimento, dentro da mesma janela (ver + [Datas da fatura](#datas-da-fatura)). Só um método por fatura: `paymentMethod`, + `availablePaymentMethods` com um único método ou só o cartão (`addCreditCardId()`, + `addCreditCardToken()`); sem nenhum dos três, `ModelAttributeValidationException` antes da rede. +- **Pix expirado continua pendente e re-cobrável.** Na Iugu, fatura expirada vira `canceled`; + no Stripe ela volta a aguardar pagamento (`pending`) e pode ser paga com cartão via + `chargeInvoiceWithCreditCard` ou duplicada com `duplicateInvoice` (nova expiração; + a original é cancelada). Só fatura Pix pendente de venda avulsa é duplicável: cartão, boleto, + fatura em outro estado ou fatura de assinatura lança `UnsupportedOperationException` + (`INVOICE_DUPLICATION`, `gateway_limitation`); `restriction(Capability::INVOICE_DUPLICATION)` + publica a regra. +- **Boleto exige `tax_document`, nome, e-mail e endereço do cliente.** O CPF/CNPJ vai no + voucher (`boleto.tax_id`) e os demais nos billing details (a sandbox aceita boleto sem + esses dados, a produção os exige); a falta de qualquer um, inclusive de rua, cidade, estado + ou CEP no endereço, lança `ModelAttributeValidationException` antes da rede. O valor deve + ficar entre R$ 5,00 e + R$ 49.999,99 e o vencimento (`dueDate`) de hoje a 60 dias, também validados antes da + requisição; sem `dueDate`, vale o prazo padrão da conta na Stripe (3 dias). A fatura volta + pendente com a página hospedada do voucher em `url`, a linha digitável em `bankSlip->number` + e o PDF em `bankSlip->url` (a Stripe só publica o número, então `barcode_data` e + `barcode_image` ficam vazios). A compensação leva até um dia útil depois do pagamento; + acompanhe por `getInvoice()`. +- **Boleto pendente não pode ser cancelado nem re-cobrado.** A Stripe não invalida o voucher + antes do vencimento: `cancelInvoice()` numa fatura com o voucher em aberto é recusado + (`UnsupportedOperationException` restrita de `INVOICE_CANCELLATION` quando o model traz o + voucher; `ValidationException` quando a recusa vem do gateway), e + `chargeInvoiceWithCreditCard()` também é recusado pelo gateway enquanto o voucher vale. + Vencido o voucher, a fatura volta a aguardar pagamento (`pending`) e pode ser cancelada ou + cobrada com cartão. Estorno de boleto fica fora da API nos dois gateways + (`REFUND_BANK_SLIP`). +- **Cartão pertence a um único cliente.** Cobrar, buscar ou excluir um `pm_` informando outro + cliente lança `UnsupportedOperationException` (`CREDIT_CARD`, `gateway_limitation`) antes da + operação. +- **A fatura tem duas origens.** A venda avulsa é um PaymentIntent (`pi_`) e a fatura de + assinatura é um objeto Invoice da Stripe (`in_`); `getInvoice()` aceita os dois ids e + `Invoice::$originType` diz qual voltou. Ver + [Fatura no Stripe: duas origens](#fatura-no-stripe-duas-origens). +- **`url` da fatura**: no pix é a página hospedada com instruções de pagamento + (`hosted_instructions_url`); em fatura de cartão é `null` — não assuma `url` preenchida + como na Iugu (`secure_url`). +- **`fee` é assíncrono para cartão**: pode vir `null` logo após a cobrança e preenchido em um + `getInvoice` posterior. +- **Contestação custa uma requisição a mais.** O charge da Stripe só traz a flag `disputed`; + quando ela é verdadeira, o pacote consulta `/v1/disputes` do charge para decidir entre + `disputed` e `chargeback` (ver [Status da fatura](#status-da-fatura)). Fatura sem contestação + não paga esse GET. +- **Idempotência em toda escrita.** A chave informada em `idempotencyKey` vai no cabeçalho + `Idempotency-Key` de toda requisição de escrita da operação, inclusive cliente, cartão, + cancelamento e as requisições secundárias, com chaves derivadas (ver + [Idempotência](#idempotência)). + +### Fatura no Stripe: duas origens + +Na Stripe a `Invoice` do pacote pode vir de dois objetos, e `Invoice::$originType` (enum +`Potelo\MultiPayment\Enums\InvoiceOriginType`) diz de qual: + +| `originType` | Objeto da Stripe | Quando | `original` | +|---|---|---|---| +| `PAYMENT_INTENT` | PaymentIntent (`pi_`) | venda avulsa criada pela lib (cartão ou Pix) | `\Stripe\PaymentIntent` | +| `INVOICE` | Invoice (`in_`) | fatura de assinatura gerada pelo Stripe Billing | `\Stripe\Invoice` | + +Na Iugu `originType` é sempre `INVOICE`: toda fatura de lá é o objeto de fatura do gateway. + +`getInvoice()` aceita os dois ids e decide pelo prefixo qual objeto ler: + +```php +use Potelo\MultiPayment\Enums\InvoiceOriginType; + +$payment = new \Potelo\MultiPayment\MultiPayment('stripe'); + +$avulsa = $payment->getInvoice('pi_3UBH...'); // originType PAYMENT_INTENT +$assinatura = $payment->getInvoice('in_1UBH...'); // originType INVOICE + +if ($assinatura->originType === InvoiceOriginType::INVOICE) { + $assinatura->original->hosted_invoice_url; // objeto cru da Stripe, quando precisar do detalhe +} +``` + +O que muda na fatura de origem `INVOICE`: + +- **Line items** vêm dos itens reais do Invoice (`lines.data`, a primeira página, de até dez + itens); na venda avulsa continuam sendo reconstruídos do `metadata` do PaymentIntent. +- **`url`** é a página hospedada da fatura (`hosted_invoice_url`), com o QR Code do Pix quando + for o caso; `pix` continua trazendo o QR Code quando o PaymentIntent tem um. +- **`dueDate`** é o `due_date` da fatura, quando ela tem um, e **`pixExpiresAt`** a expiração + do QR Code do Pix, quando o PaymentIntent tem um. +- **`paidAt`** é o instante em que a Stripe marcou a fatura como paga. +- **Uma requisição a mais** quando a fatura já teve tentativa de pagamento: o charge do + PaymentIntent fica além do limite de `expand` da Stripe e é lido num GET à parte. +- **`cancelInvoice()`** anula a fatura (`void`); a Stripe cancela sozinha o PaymentIntent + dela. Rascunho (`draft`) não é anulável e lança `UnsupportedOperationException` + (`INVOICE_CANCELLATION`, `gateway_limitation`) orientando a esperar a finalização; fatura + `paid` ou já anulada lança `ValidationException`, como o PaymentIntent já pago ou cancelado. +- **`duplicateInvoice()`** é recusado com `UnsupportedOperationException` (`INVOICE_DUPLICATION`, + `gateway_limitation`): a próxima fatura da assinatura é gerada pela Stripe, e um Pix expirado + se resolve com nova tentativa de pagamento da mesma fatura. +- **`refundInvoice()`, `refundableAmount()` e `chargeInvoiceWithCreditCard()`** sobre a fatura de + assinatura ainda não estão disponíveis (`UnsupportedOperationException`, `SUBSCRIPTIONS`, + `not_implemented`, antes de qualquer requisição); estão planejados para uma versão futura. + +**Precedência de status.** O status do Invoice da Stripe manda no ciclo de vida da fatura; o +PaymentIntent e o charge só refinam o detalhe de pagamento. Um PaymentIntent `succeeded` não +torna paga uma fatura que a Stripe ainda considera `open`, e um PaymentIntent `canceled` não +cancela uma fatura `open`. A tabela completa: + +| Invoice Stripe | PaymentIntent e charge | `InvoiceStatus` | +|---|---|---| +| `draft` | qualquer | `PENDING` | +| `open` | ausente, `requires_payment_method`, `requires_action` ou `requires_confirmation` | `PENDING` | +| `open` | `requires_capture` | `AUTHORIZED` | +| `open` | `processing` | `PROCESSING` | +| `open` | `amount_paid` do Invoice maior que zero e menor que `total` | `PARTIALLY_PAID` | +| `paid` | `succeeded`, charge sem dispute e sem refund | `PAID` | +| `paid` | `succeeded`, charge com refund parcial | `PARTIALLY_REFUNDED` | +| `paid` | `succeeded`, charge com refund total | `REFUNDED` | +| `paid` | charge com dispute aberta | `DISPUTED` | +| `paid` | charge com dispute perdida (`lost`) | `CHARGEBACK` | +| `paid` | pagamento registrado fora da Stripe (`paid_out_of_band`) | `EXTERNALLY_PAID` | +| `paid` | sem PaymentIntent `succeeded`; pagamento do tipo `charge` anexado à fatura | `PAID` | +| `paid` | sem PaymentIntent e `amount_due` zero (avaliação gratuita, saldo de crédito, valor abaixo do mínimo) | `PAID` | +| `void` | qualquer | `CANCELED` | +| `uncollectible` | qualquer | `EXPIRED` | +| qualquer combinação não listada | | `UNKNOWN`, com aviso no log contendo os três status e o id da fatura | + +Duas ressalvas para quem trata status como definitivo: + +- **`uncollectible` lê como `EXPIRED` e continua reversível na Stripe**: a fatura pode voltar a + `paid` ou ir a `void` depois. Uma fatura `EXPIRED` de origem `INVOICE` pode, portanto, ler + como `PAID` numa releitura; `isTerminal()` responde falso e `isPayable()` verdadeiro para + `EXPIRED` por isso. +- **`open` com PaymentIntent `succeeded` fica em `UNKNOWN` de propósito**: a Stripe atualiza o + Invoice no mesmo instante em que confirma o pagamento, então essa combinação é uma leitura + no meio da transição ou um pagamento fora do padrão que não quitou a fatura. Se aparecer no + log, releia a fatura. + +### Opções extras do gateway + +Todo model tem o array público `gatewayOptions`: é a válvula de escape para enviar ao gateway +uma opção que a lib não modela. O driver mescla esse array ao payload que monta a partir do +model, e as chaves daqui sobrepõem as geradas. Nos arrays de entrada (`charge()`, `fill()`) a +chave é `gateway_options`; nos builders, `setGatewayOptions()`. + +```php +$invoice = $payment->newInvoice() + ->addAvailablePaymentMethod(PaymentMethod::PIX) + ->addCustomer('Nome', 'email@example.com', '01234567891') + ->addItem('Produto', 10000, 1) + ->setGatewayOptions(['expires_in' => 3]) // opção da Iugu, sem equivalente genérico + ->create(); + +$customer->gatewayOptions = ['metadata' => ['crm_id' => '42']]; // opção da Stripe +``` + +Use com moderação: o conteúdo é específico de um gateway e não passa por validação da lib. Se +uma opção vira uso recorrente, ela deve ser modelada genericamente. + +> **Nome antigo.** Até a 4.1.0 o array se chamava `gatewayAdicionalOptions` (com +> `setGatewayAdicionalOptions()` no builder e `gateway_adicional_options` nos arrays). Os três +> continuam funcionando como alias do nome novo, lendo e escrevendo o mesmo array, e emitem um +> aviso `E_USER_DEPRECATED` a cada uso; estão marcados `@deprecated` desde 2026-09-02 e saem na +> próxima versão maior. A única diferença observável é `toArray()`, que passa a devolver a +> chave `gateway_options`. + +### `fill()` estrito + +`Model::fill()` (e, por consequência, `charge($attributes)`, `create($data)` e os arrays +aninhados de `customer`, `items`, `credit_card`...) lança `ModelAttributeValidationException` +para chave que não corresponde a nenhuma propriedade do model. A mensagem traz o model, a chave +e a lista de chaves aceitas: + +```php +$payment->charge(['amount' => 10000, 'trial_days' => 7, 'customer' => [...]]); +// ModelAttributeValidationException: The `trial_days` key is unknown for the `Invoice` model. +// Accepted keys: id, status, paid_at, amount, ..., gateway_options. +``` + +Duas exceções à regra: chave com prefixo `gateway_` (ou `gateway` em `camelCase`) continua +sendo ignorada em silêncio, e o conteúdo de `gateway_options` é livre (vai inteiro ao gateway). +As chaves aceitas de cada model estão em `Model::fillableKeys()`, em `snake_case`. Use sempre +`snake_case` nos arrays: uma chave escalar em `camelCase` (`taxDocument`) também é aceita, mas +as chaves que viram objeto ou data (`customer`, `items`, `credit_card`, `due_date`...) só são +convertidas na forma em `snake_case`. + +Para desligar temporariamente durante uma migração, use a configuração +`multi-payment.strict_fill` (variável `MULTIPAYMENT_STRICT_FILL`); com `false`, a chave +desconhecida volta a ser descartada sem erro, como nas versões anteriores. + +> **Mudança de comportamento (versão 5.0.0).** Até a 4.1.0, `fill()` descartava chave +> desconhecida em silêncio: um `trial_days` ou um `idempotency_key` fora de `gateway_options` +> simplesmente não faziam nada. Agora lançam. Se a aplicação monta os arrays a partir de dados +> externos, valide as chaves antes ou desligue `strict_fill` enquanto ajusta. + +### Idempotência + +Toda operação de escrita aceita uma chave de idempotência como último argumento +(`?string $idempotencyKey = null`), na fachada, nos models e nos drivers; nos builders ela entra +por `withIdempotencyKey()`. Duas chamadas com a mesma chave produzem um único efeito no gateway: +a segunda devolve o resultado da primeira em vez de criar outra fatura, outro estorno ou outra +troca de plano. + +```php +use Potelo\MultiPayment\Facades\MultiPayment; + +$invoice = MultiPayment::newInvoice() + ->addAvailablePaymentMethod(PaymentMethod::PIX) + ->setCustomer($customer) + ->addItem('Mensalidade', 10000, 1) + ->withIdempotencyKey($order->uuid) // uma chave por intenção de escrita + ->create(); + +MultiPayment::refundInvoice($invoice->id, 5000, idempotencyKey: "refund-{$order->uuid}"); +MultiPayment::cancelInvoice($invoice->id, idempotencyKey: "cancel-{$order->uuid}"); +$subscription->changePlan('plano_anual', idempotencyKey: "upgrade-{$order->uuid}"); +$customer->save('iugu', idempotencyKey: "customer-{$user->id}"); +``` + +**A lib nunca gera uma chave por conta própria.** Sem `idempotencyKey`, a requisição vai sem +deduplicação e um retry cria um segundo registro; é a aplicação que sabe qual pedido, assinatura +ou estorno a chamada representa, então é ela que escolhe a chave (um UUID gravado junto do +pedido, por exemplo). Gerar a chave por baixo esconderia esse risco. Regras da chave: a mesma +chave sempre com o mesmo payload; chave nova para cada nova intenção; retry com a mesma chave +só depois de uma falha em que a resposta não chegou ou o processo caiu. + +Duas regras de payload que valem para a chave: a Stripe compara o payload e recusa a mesma +chave com conteúdo diferente (`IdempotencyConflictException`), então um campo derivado do +instante da chamada, como um `pixExpiresAt` calculado de `now()`, precisa ser gravado junto da +chave e reenviado igual; a Iugu não compara e responde com o recurso original mesmo que o +payload tenha mudado (por isso o `expires_at` que `setTrialDays()` calcula a cada tentativa não +conflita com a chave na Iugu). + +Retry seguro: + +```php +use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; +use Potelo\MultiPayment\Exceptions\IdempotencyConflictException; + +$key = $order->idempotency_key ??= (string) Str::uuid(); // gravada antes da primeira tentativa + +try { + $invoice = $payment->newInvoice()->/* ... */->withIdempotencyKey($key)->create(); +} catch (GatewayNotAvailableException $e) { + // timeout ou 5xx: repetir mais tarde com a MESMA chave não cobra duas vezes + ChargeOrder::dispatch($order)->delay(now()->addMinutes(5)); +} catch (IdempotencyConflictException $e) { + // a primeira tentativa ainda está em andamento, ou a chave foi reusada com outro payload: + // consultar o resultado dela em vez de repetir +} +``` + +Quem honra a chave depende da operação e do gateway. A Stripe aceita `Idempotency-Key` em +todo POST e, na repetição, devolve a mesma resposta; a Iugu só aceita o cabeçalho em quatro +endpoints e, na repetição, responde 409 apontando o recurso original (`resource_id`): para +fatura e cobrança com cartão a lib lê essa fatura e a devolve, então a segunda chamada tem o +mesmo resultado da primeira; para cliente e assinatura a Iugu não informa o id +(`resource_id: processing`) e a segunda chamada lança `IdempotencyConflictException`, cabendo à +aplicação consultar o registro que gravou na primeira. Nos demais endpoints da Iugu a lib +deduplica por conta própria com a `IdempotencyStore` (abaixo): + +| Operação | Iugu | Stripe | +|---|---|---| +| Criar fatura (`create()`, `charge()`), com Pix, boleto ou cartão | gateway (`POST /invoices` ou `POST /charge`) | gateway | +| Cobrar fatura com cartão (`chargeInvoiceWithCreditCard`) | gateway (`POST /charge`) | gateway | +| Criar cliente | gateway | gateway | +| Criar assinatura | gateway | gateway | +| Atualizar cliente, definir cartão padrão | store da lib | gateway | +| Salvar cartão, excluir cartão | store da lib | gateway | +| Concluir o setup do cartão (`confirmCreditCardSetup`) | (limitação do gateway) | gateway, nas escritas secundárias (`{chave}:attach`, `{chave}:metadata`, `{chave}:default`); a leitura do setup não leva chave | +| Estornar, cancelar, duplicar fatura | store da lib | gateway | +| Suspender, retomar, cancelar, atualizar assinatura, trocar de plano | store da lib | gateway | +| Criar plano | store da lib | gateway | +| Desativar plano (`deactivatePlan`) | (limitação do gateway) | gateway | +| Reagendar e cancelar Pix Automático | store da lib | (não se aplica: o gateway agenda, `managed_by_gateway`) | + +Quando uma operação faz mais de uma requisição de escrita (salvar o cartão antes de cobrar, +criar o tax id ao atualizar o cliente, remover subitens antes de atualizar a assinatura), a +requisição principal leva a chave informada e as secundárias levam chaves derivadas dela +(`{chave}:card`, `{chave}:tax_id`, `{chave}:remove`...): a Stripe recusa a mesma chave em dois +endpoints, e a derivação é determinística, então um retry reproduz as mesmas chaves. O cliente +criado junto com a fatura ou a assinatura (`Invoice::save()` sem `customer.id`) recebe +`{chave}:customer`, então o retry de `charge()` com cliente novo não cria um segundo cliente +(na Iugu, onde a repetição da chave em cliente responde 409, o retry de `charge()` com cliente +novo lança `IdempotencyConflictException`; consulte a fatura pelo registro da aplicação ou +crie o cliente antes com a própria chave). + +Com chave, as guardas locais que dependem do estado do recurso deixam de recusar um retry: um +estorno sobre fatura já estornada (ou acima do restante) é enviado mesmo assim e a Stripe +repete o refund original quando a chave é a dele (senão a recusa da guarda é a que sobe); a +duplicação de uma fatura já cancelada e a exclusão de um cartão já desvinculado seguem o mesmo +caminho. Na Iugu, o estorno com chave passa inteiro (leitura, guardas e `POST`) pela store, e +o retry devolve o `Refund` da primeira execução. + +**A `IdempotencyStore`.** Nas operações da Iugu marcadas "store da lib", a requisição de escrita +passa por `Potelo\MultiPayment\Contracts\IdempotencyStore`: a primeira execução com a chave é +guardada por 24 horas (`multi-payment.idempotency.ttl`), e as seguintes devolvem a resposta +guardada sem chamar a Iugu. A mesma chave reaparecendo em outra operação (um cancelamento e +depois um estorno com a chave do cancelamento) lança `IdempotencyConflictException` em vez de +devolver a resposta errada. Duas execuções concorrentes com a mesma chave são serializadas por +lock, e a segunda recebe `IdempotencyConflictException`, como a Iugu responde 409. Uma execução +que lança não é guardada (o retry executa de novo). O service provider registra a +`CacheIdempotencyStore`, sobre o cache do Laravel, que exige um store com suporte a lock +(`redis`, `memcached`, `database`, `file`, `array` ou `dynamodb`); sem cache configurado, a +primeira operação com chave num endpoint da store lança `ConfigurationException`. Operações +sem chave nunca tocam a store, e os quatro endpoints nativos da Iugu tampouco. + +```php +// config/multi-payment.php +'idempotency' => [ + 'ttl' => env('MULTIPAYMENT_IDEMPOTENCY_TTL', 86400), // segundos + 'cache_store' => env('MULTIPAYMENT_IDEMPOTENCY_CACHE_STORE'), // nulo: o cache padrão da aplicação + 'prefix' => 'multi-payment:idempotency:', +], + +// outra store: bind próprio no service provider da aplicação +$this->app->bind(IdempotencyStore::class, fn () => new MinhaStore()); + +// testes da aplicação: store em memória, sem cache +$this->app->instance(IdempotencyStore::class, new InMemoryIdempotencyStore()); +``` + +Limite da store: ela só protege de um retry feito **depois** de uma resposta recebida. Se a +Iugu executou a escrita e a resposta se perdeu (timeout), nada foi guardado e o retry com a +mesma chave executa de novo; só o gateway conseguiria deduplicar esse caso, e nesses endpoints +a Iugu não deduplica. Para a troca de plano com cobrança e o estorno, confira o estado da +assinatura ou da fatura antes de repetir depois de um timeout. + +> **Nome antigo.** Até a 4.1.0 a chave ia em `gateway_options['idempotency_key']` e só o +> Stripe a repassava, na criação de fatura e no estorno. A chave nesse lugar continua sendo +> usada, com aviso `E_USER_DEPRECATED`, quando o argumento não é informado (o argumento tem +> precedência), e deixa de ir no corpo da requisição da Iugu, que a ecoava como campo da +> fatura. Sai na próxima versão maior. + +## Tratamento de erros + +Toda exceção do pacote herda de `MultiPaymentException`. Nenhuma exceção dos SDKs da Iugu ou da +Stripe sai do pacote: os drivers traduzem cada falha para uma das classes abaixo, anexam a +exceção original em `getPrevious()` (quando o SDK lançou uma; a Iugu devolve alguns erros como +corpo JSON sem exceção) e expõem o status HTTP da resposta em `httpStatus` (nulo quando não +houve resposta HTTP, como numa falha de rede ou numa validação local). + +A árvore, com a indentação marcando a herança: + +``` +MultiPaymentException + ConfigurationException gateway não configurado, classe inválida, driver que declara capability sem o contract ou o método, IdempotencyStore sem cache + ModelAttributeValidationException atributo obrigatório ausente ou inválido, antes da requisição + UnsupportedOperationException operação fora das capabilities do gateway, ou fora da restrição de uma capability, antes da requisição + RefundNotSupportedException estorno recusado pela lib antes da requisição (limitação do gateway ou estado da fatura) + AuthenticationException credencial recusada (401, 403) ou não configurada + GatewayNotAvailableException 5xx, falha de conexão ou timeout + CardDeclinedException cartão recusado, na cobrança ou ao salvar: declineCode, gatewayCode, retryable + ChargingException nome antigo, deprecado; é a classe que os drivers lançam + GatewayException resposta de erro do gateway: httpStatus e getErrors() + ValidationException 400 ou 422: fieldErrors por campo + NotFoundException 404: recurso inexistente no gateway + RateLimitException 429: retryAfter em segundos quando o gateway informa + IdempotencyConflictException 409 na Iugu, idempotency_error na Stripe, lock da IdempotencyStore ocupado +``` + +| Exceção | Quando | O que fazer | +|---|---|---| +| `CardDeclinedException` | O gateway respondeu e a cobrança foi recusada pelo emissor, pelo adquirente ou pelo antifraude. `declineCode` (`DeclineCode`) é o motivo normalizado, `gatewayCode` o código original (`decline_code` da Stripe, LR da Iugu), `retryable` diz se vale repetir com o mesmo cartão. Também é a recusa ao salvar o cartão (`newCreditCard()->create()`, `confirmCreditCardSetup()`); quando vem do estado do setup lido (`last_setup_error`), `httpStatus` é nulo e o SetupIntent vai em `chargeResponse` | Ramificar por `declineCode`: outro gateway em `BRAND_NOT_SUPPORTED`, nova tentativa só se `retryable`, ação do pagador nos demais (ver [Códigos de recusa](#códigos-de-recusa)) | +| `ChargingException` | Nome antigo de `CardDeclinedException`, deprecado. É a classe que os drivers lançam, então `catch` por qualquer um dos dois nomes captura a mesma exceção | Migrar o `catch` para `CardDeclinedException` | +| `ValidationException` | O gateway recusou o payload (400 ou 422 na Iugu, `invalid_request_error` na Stripe); `fieldErrors` traz as mensagens por campo (`base` para erro sem campo) | Corrigir a chamada; repetir igual falha de novo | +| `NotFoundException` | Recurso inexistente no gateway (404 na Iugu, `resource_missing` na Stripe): id errado, de outra conta ou removido | Conferir o id; não repetir | +| `RateLimitException` | O gateway limitou a taxa de requisições (429); nada foi executado. `retryAfter` traz os segundos do cabeçalho `Retry-After` quando o gateway o envia; nulo quando não envia | Esperar e repetir | +| `IdempotencyConflictException` | Chave de idempotência reutilizada (409 na Iugu em cliente e assinatura; `idempotency_error` na Stripe quando o payload mudou), a primeira requisição com a chave ainda em andamento, ou lock ocupado na `IdempotencyStore` da lib. `resourceId` traz o id do recurso original quando o gateway o informa | Consultar o resultado da primeira requisição (`resourceId` ou o registro da aplicação) ou usar chave nova; nunca repetir com a mesma chave e outro conteúdo | +| `AuthenticationException` | Chave de API inválida, revogada, sem permissão (401 ou 403) ou não configurada | Registrar e alertar. Repetir a chamada ou trocar de gateway não resolve | +| `GatewayNotAvailableException` | Erro 5xx, falha de conexão ou timeout | Repetir mais tarde ou tentar outro gateway | +| `UnsupportedOperationException` | Operação fora das capabilities do gateway, ou fora da restrição de uma capability suportada (`restriction()`), antes de qualquer requisição; `capability`, `gateway` e `reason` (`not_implemented`, `gateway_limitation` ou `managed_by_gateway`) dizem qual e por quê | Rotear para um gateway que declare a capability; melhor ainda, consultar `supports()` e `restriction()` antes (ver [Capabilities](#capabilities)). Em `managed_by_gateway`, seguir a orientação da mensagem: a operação é conduzida pelo próprio gateway | +| `RefundNotSupportedException` | Estorno recusado pela lib antes de chamar o gateway: limitação do gateway (boleto, Pix parcial; `isCapabilityLimitation()` verdadeiro e `capability` preenchida) ou estado da fatura (já estornada, valor acima do restante, prazo vencido). Herda direto de `MultiPaymentException`: `catch (UnsupportedOperationException)` não a captura | Ver [Estorno](#estorno) | +| `ModelAttributeValidationException` | Atributo obrigatório ausente ou inválido, antes de qualquer requisição, inclusive regra de valor que só um gateway impõe (`PlanInterval::DAY` e teto de 599 meses na Iugu, `nextBillingAt` diferente de `trialEndsAt`, `page` e `limit` fora da faixa, plano com `id` em `save()`, valor de estorno zero ou negativo) | Corrigir a chamada | +| `ConfigurationException` | Gateway não configurado ou classe inválida; driver que declara uma capability sem implementar o contract ou sem o método do despacho por convenção; `IdempotencyStore` sem registro no container ou sobre um cache sem lock | Corrigir a configuração ou o driver | +| `GatewayException` | Qualquer outra resposta de erro do gateway, e a classe pai das quatro de resposta acima; `httpStatus` e `getErrors()` sempre preenchidos com a resposta. Nenhuma regra local da lib a lança | Depende do caso; `httpStatus` e `getErrors()` dizem o que aconteceu | + +Um `catch` por camada. As subclasses vêm antes de `GatewayException`, senão ela captura tudo: + +```php +use Potelo\MultiPayment\Enums\DeclineCode; +use Potelo\MultiPayment\Exceptions\GatewayException; +use Potelo\MultiPayment\Exceptions\RateLimitException; +use Potelo\MultiPayment\Exceptions\ValidationException; +use Potelo\MultiPayment\Exceptions\CardDeclinedException; +use Potelo\MultiPayment\Exceptions\AuthenticationException; +use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; +use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; + +try { + $invoice = $payment->newInvoice()->/* ... */->create(); +} catch (CardDeclinedException $e) { + // o gateway respondeu; a recusa é do pagamento + if ($e->declineCode === DeclineCode::BRAND_NOT_SUPPORTED) { + return $this->chargeOn('iugu'); // outro gateway aceita a bandeira + } + if ($e->retryable) { + return $this->scheduleRetry($order, hours: 24); // falha temporária ou saldo: mesmo cartão, mais tarde + } + return back()->withErrors('Pagamento recusado. Confira os dados do cartão ou use outro.'); +} catch (ValidationException $e) { + return back()->withErrors($e->fieldErrors); // ['email' => ['não é válido']] +} catch (UnsupportedOperationException $e) { + report($e); // nada foi enviado; $e->capability e $e->gateway dizem o que faltou + return $this->chargeOn('iugu'); +} catch (AuthenticationException $e) { + report($e); // credencial errada: alerta, sem retry e sem fallback + abort(500); +} catch (RateLimitException $e) { + return $this->retryIn($e->retryAfter ?? 5); // segundos; nulo quando o gateway não informa +} catch (GatewayNotAvailableException $e) { + return $this->retryWithBackoff(); // 5xx ou timeout: repetir mais tarde +} catch (GatewayException $e) { + report($e); // 404, 409 e o restante; $e->getPrevious() é a exceção do SDK, com stack trace e corpo + throw $e; +} +``` + +### Códigos de recusa + +`CardDeclinedException::$declineCode` é um `DeclineCode`, o mesmo vocabulário nos dois gateways. +O código original fica em `$gatewayCode`; código que a tabela do pacote ainda não conhece vira +`UNKNOWN`, com o original preservado e uma linha em nível `info` no log. `$retryable` segue o +código (`DeclineCode::isRetryable()`) e, na Stripe, é sobrescrito pelo `advice_code` quando ele +diz `try_again_later` ou `do_not_try_again`. `requiresPayerAction()` diz se a recusa pede ação +do pagador antes de qualquer nova tentativa. + +| `DeclineCode` | Significado | `retryable` | Stripe (`decline_code` ou `code`) | Iugu (LR) | +|---|---|---|---|---| +| `INSUFFICIENT_FUNDS` | Saldo ou limite insuficiente | sim | `insufficient_funds`, `card_velocity_exceeded`, `withdrawal_count_limit_exceeded` | 51, 61, 65, 70, BL, DM, N4 | +| `EXPIRED_CARD` | Cartão vencido | não | `expired_card` | 54 | +| `INCORRECT_CVC` | CVC incorreto | não | `incorrect_cvc`, `invalid_cvc` | (a tabela da Iugu não tem código próprio) | +| `INCORRECT_NUMBER` | Número do cartão incorreto ou ausente | não | `incorrect_number`, `invalid_number` | 14, 25 | +| `INVALID_CARD` | Outros dados inválidos: validade, conta inexistente, cartão não desbloqueado | não | `invalid_expiry_month`, `invalid_expiry_year`, `invalid_account`, `new_account_information_available`, `incorrect_address`, `incorrect_zip` | 1, 12, 15, 30, 46, 56, 78, 101, 111, 115, 122, 6P, AV, BM, BP, BR, CF, CG, DF, DQ, G4, KA, KE, U3 | +| `LOST_OR_STOLEN` | Perdido, roubado, retido ou bloqueado pelo emissor; não exibir o motivo ao pagador | não | `lost_card`, `stolen_card`, `pickup_card`, `restricted_card` | 4, 41, 43, 62, 146, BN | +| `FRAUD_SUSPECTED` | Suspeita de fraude ou antifraude; tratar como recusa genérica diante do pagador | não | `fraudulent`, `merchant_blacklist` | 7, 59, AF01, AF02, BP171 | +| `AUTHENTICATION_REQUIRED` | O emissor exige autenticação (3DS); a cobrança fora de sessão não atende | não | `authentication_required`, `authentication_not_handled`, `mobile_device_authentication_required`, `setup_intent_authentication_failure`, `payment_intent_authentication_failure` | AI | +| `BRAND_NOT_SUPPORTED` | Bandeira, função (crédito ou débito) ou moeda não aceita nesta cobrança; candidato a outro gateway | não | `card_not_supported`, `currency_not_supported` | 39, 52, 53, 57, 79, 5C, AB, AC, AH, C1, DS, EK, G5 | +| `DO_NOT_HONOR` | O emissor recusou sem detalhar e orienta o pagador a procurá-lo | não | `do_not_honor`, `call_issuer`, `no_action_taken`, `not_permitted`, `security_violation`, `service_not_allowed`, `stop_payment_order`, `transaction_not_allowed`, `revocation_of_authorization`, `revocation_of_all_authorizations`, `do_not_try_again` | 5, 6, 60, 63, 67, 93, 99, 100, 109, 110, 116, 121, 181, 200, B1, B2, BP176, C2, C3, FC, FG, GA, GD, GF, GK, GT, N7, NR, R0, R1, R2, R3, RE, RP, SC | +| `TRY_AGAIN` | Falha temporária no emissor, no adquirente ou na comunicação | sim | `processing_error`, `issuer_not_available`, `reenter_transaction`, `try_again_later`, `approve_with_id` | 19, 28, 85, 89, 90, 91, 92, 96, 98, 911, 912, 999, 99A, 99B, 99C, 99TA, 99Z, AA, AF, AG, BD, BO, BP900, BP901, BP902 | +| `GENERIC` | Recusa sem motivo específico | não | `generic_decline`, `card_declined`, `duplicate_transaction`, `invalid_amount`, `testmode_decline` | 13, 64, 80, 94, 97, FE | +| `UNKNOWN` | Código fora da tabela; o original está em `gatewayCode` | não | qualquer outro | qualquer outro (senha, chip, saque, credenciamento do lojista) | + +As tabelas completas vivem em `src/Gateways/Stripe/DeclineCodes.php` e +`src/Gateways/Iugu/DeclineCodes.php`, com a fonte oficial no cabeçalho de cada uma. Na Iugu, LR +numérico é comparado sem zeros à esquerda (`05` e `5` são o mesmo código); `gatewayCode` guarda +o valor como veio. + +`CardDeclinedException::$reason` (string) continua preenchido e está deprecado: na Stripe traz a +normalização antiga (`card_declined`, `brand_not_supported`, `authentication_required`, +`expired_card`, `insufficient_funds`, `incorrect_cvc`, ou o `code` original); na Iugu, que antes +o deixava nulo, traz o valor de `declineCode`. Compare com `declineCode`. + +> **Mudança de comportamento (versão 5.0.0).** Até a 4.1.0, credencial inválida chegava como +> `GatewayNotAvailableException` (Stripe e chave Iugu não configurada) ou como `GatewayException` +> genérica (chave Iugu recusada com 401), e um cartão inválido no caminho de dados crus da Iugu +> podia deixar escapar uma `IuguRequestException` do SDK. Agora os três casos lançam +> `AuthenticationException` ou `GatewayException` do pacote. Quem repetia toda +> `GatewayNotAvailableException` deixa de repetir credencial errada; quem capturava +> `GatewayException` para chave recusada na Iugu precisa capturar `AuthenticationException`. +> +> Na mesma versão, operação não suportada ou ainda não implementada (fatura avulsa com Pix +> Automático no Stripe; desconto percentual e desativação +> de plano na Iugu; cartão com dados crus e duplicação fora de Pix pendente no Stripe) deixou de +> chegar como `GatewayException` (ou `GatewayException::methodNotFound`) e passou a lançar +> `UnsupportedOperationException`, que herda de `MultiPaymentException`. Um +> `catch (GatewayException $e)` sozinho deixa de capturar esses casos. +> +> Ainda na 5.0.0, `GatewayException` deixou de ser lançada por regra local, sem requisição: regra +> de valor (`page` e `limit` fora da faixa, plano com `id` em `save()`, `nextBillingAt` diferente +> de `trialEndsAt`, `PlanInterval::DAY` e teto de 599 meses na Iugu) virou +> `ModelAttributeValidationException`; restrição do gateway (cartão de outro cliente, rascunho +> não anulável, fatura sem cliente na duplicação no Stripe) virou +> `UnsupportedOperationException::restricted()`; driver que declara capability sem o contract ou +> sem o método de despacho virou `ConfigurationException`. Toda `GatewayException` que sobrou +> traz `httpStatus` da resposta. `RefundNotSupportedException` deixou de herdar de +> `UnsupportedOperationException` (ver [Estorno](#estorno)). +> +> Ainda na 5.0.0, validação (400, 422), 404, 409 e 429 passaram a chegar como +> `ValidationException`, `NotFoundException`, `IdempotencyConflictException` e +> `RateLimitException`. Todas herdam de `GatewayException`, então `catch (GatewayException $e)` +> continua capturando; um `catch` da subclasse precisa vir antes. A recusa de cartão passou a +> ser `CardDeclinedException` (lançada pelo nome antigo `ChargingException`, que herda dela), +> com a mensagem em português; salvar um cartão recusado pela Stripe (`newCreditCard()->create()`) +> também lança `CardDeclinedException`, onde antes vinha `GatewayException`. +> +> Também na 5.0.0, toda operação de escrita ganhou o último argumento `idempotencyKey` (e os +> builders, `withIdempotencyKey()`), inclusive nos contracts dos gateways; quem implementa +> `GatewayContract` fora do pacote precisa acrescentar o parâmetro. Na Iugu, `retryAfter` de +> `RateLimitException` passou a vir preenchido quando o cabeçalho `Retry-After` chega, e todas +> as chamadas ao gateway passaram a usar o requester injetável do driver (o fork +> `Potelo/iugu-php` 1.1.0), o que não muda a API pública. + ## Utilizando ### MultiPayment: @@ -80,21 +944,146 @@ $payment = new \Potelo\MultiPayment\MultiPayment('iugu'); $payment = new \Potelo\MultiPayment\MultiPayment(); $payment->setGateway('iugu'); ``` -#### InvoiceBuilder +#### Criar e cobrar uma fatura (InvoiceBuilder) + +O builder é o caminho principal: método de pagamento por enum, datas por `Carbon` e validação +antes de qualquer requisição. O mesmo código cobra o cartão nos dois gateways: + ```php -$multiPayment = new \Potelo\MultiPayment\MultiPayment('iugu'); -$invoiceBuilder = $multiPayment->newInvoice(); -$invoice = $invoiceBuilder->setPaymentMethod('payment_method') - ->addCustomer('name', 'email', 'tax_document', 'phone_area', 'phone_number') - ->addCustomerAddress('zip_code', 'street', 'number') - ->addItem('description', 'quantity', 'price') +use Potelo\MultiPayment\Enums\PaymentMethod; + +$payment = new \Potelo\MultiPayment\MultiPayment('iugu'); // ou 'stripe' + +$invoice = $payment->newInvoice() + ->setPaymentMethod(PaymentMethod::CREDIT_CARD) + ->addCustomer('Nome do cliente', 'email@example.com', '20176996915', null, '71', '999999999') + ->addItem('Produto 1', 10000, 1) + ->addItem('Produto 2', 5000, 2) + ->addCreditCardId($card->id) // cartão salvo; ou addCreditCardToken('pm_...') + ->withIdempotencyKey($order->uuid) ->create(); + +$invoice->status; // InvoiceStatus::PAID (ou CardDeclinedException) +$invoice->paymentMethod; // PaymentMethod::CREDIT_CARD ``` -Confira `src/MultiPayment/Builders/InvoiceBuilder.php` para saber quais métodos estão disponíveis. + +Pix e boleto abrem a fatura e devolvem o QR Code ou a linha digitável; as duas datas da fatura +têm um sentido só em todos os gateways (ver [Datas da fatura](#datas-da-fatura)): + +```php +$pix = $payment->newInvoice() + ->setPaymentMethod(PaymentMethod::PIX) + ->addCustomer('Nome do cliente', 'email@example.com', '20176996915') + ->addItem('Mensalidade', 10000, 1) + ->setPixExpiresAt(now()->addHours(4)) // expiração do QR Code + ->create(); +$pix->pix->qrCodeText; + +$boleto = $payment->newInvoice() + ->setPaymentMethod(PaymentMethod::BANK_SLIP) + ->addCustomer('Nome do cliente', 'email@example.com', '20176996915') + ->addCustomerAddress('41820330', 'Rua', '123', null, 'Bairro', 'Salvador', 'BA') + ->addItem('Mensalidade', 10000, 1) + ->setDueDate(today()->addDays(3)) // vencimento + ->create(); +$boleto->bankSlip->number; // linha digitável nos dois gateways +``` + +No Stripe o boleto exige CPF/CNPJ e endereço do cliente, aceita valores de R$ 5,00 a +R$ 49.999,99 e vencimento de hoje a 60 dias, tudo validado antes da requisição (ver +[Particularidades do Stripe](#particularidades-do-stripe)). + +`setPaymentMethod()` decide como a fatura é criada quando `availablePaymentMethods` fica vazia; +`setAvailablePaymentMethods([...])` (ou `addAvailablePaymentMethod()`) abre a fatura a mais de +um método na Iugu e tem precedência sobre `setPaymentMethod()`, que então precisa constar da +lista. Cartão informado sem método algum também cobra o cartão; cartão junto de um método ou de +uma lista sem `credit_card` é recusado com `ModelAttributeValidationException`, em vez de ser +ignorado. `amount` junto de `items` só é aceito quando é a soma deles. +Confira `src/Builders/InvoiceBuilder.php` para saber quais métodos estão disponíveis; a +alternativa por array está em [charge](#charge-alternativa-por-array). + +> **Mudança de comportamento (versão 5.0.0).** Até a 4.1.0 a escrita ignorava +> `Invoice::$paymentMethod` (`payment_method` no array): na Iugu, uma fatura com +> `payment_method` `credit_card` e `credit_card` preenchido, sem `available_payment_methods`, +> nascia pendente aberta a todos os métodos da conta e o cartão não era cobrado, enquanto no +> Stripe o mesmo array cobrava o cartão. Agora `paymentMethod` (ou só o cartão) vale nos dois +> gateways, e o mesmo array produz o mesmo resultado financeiro. Quem dependia da fatura +> pendente deve deixar de informar o cartão: `credit_card` junto de uma lista (ou de um +> `payment_method`) sem cartão, que antes era ignorado, passou a lançar +> `ModelAttributeValidationException`. + +#### Datas da fatura + +| Propriedade | Array / builder | Iugu | Stripe | +|---|---|---|---| +| `dueDate` | `due_date` / `setDueDate()` | `due_date` (o dia; a fatura vencida continua pagável) | `due_date` da fatura de assinatura; no boleto avulso vira `expires_after_days` (de hoje a 60 dias) e a leitura devolve o instante em que o voucher vence; na venda avulsa por Pix sem `pixExpiresAt`, o fim desse dia vira a expiração do QR Code | +| `pixExpiresAt` | `pix_expires_at` / `setPixExpiresAt()` | `pix_qr_code_expires_at` (ISO 8601); sem `dueDate`, o vencimento é o dia em que o QR Code expira; a leitura só o preenche quando a fatura o devolve | `payment_method_options.pix.expires_at`, entre 10 segundos e 14 dias no futuro (sem ele, a Stripe usa 4 horas); volta na leitura | + +As duas aceitam `Carbon` (ou `CarbonImmutable`) e, no array, string em `Y-m-d` ou ISO 8601 com +hora. `expiresAt` (`expires_at`, `setExpiresAt()`) continua funcionando como alias de `dueDate`, +com aviso `E_USER_DEPRECATED`, e sai na próxima versão maior: até a 4.1.0 era o vencimento na +Iugu e a expiração do QR Code no Stripe, então `'expires_at' => hoje` valia na Iugu e falhava no +Stripe. `toArray()` passa a emitir `due_date` e `pix_expires_at`. #### Pix Automático -O Pix Automático está disponível no gateway Iugu e é configurado como parte da fatura: +O Pix Automático está disponível nos dois gateways, com desenhos opostos (a responsabilidade +pela agenda está em +[Pix Automático: quem agenda a cobrança](#pix-automático-quem-agenda-a-cobrança)): + +| | Iugu | Stripe | +|---|---|---| +| Onde a recorrência nasce | Na fatura: `Invoice` com método `pix` e `automaticPix` preenchido | Na assinatura: `Subscription` com `paymentMethod` `automatic_pix` (mandato) | +| Quem agenda cada cobrança | A aplicação, pelas operações de `AutomaticPixContract` | O gateway (`MANAGES_RECURRENCE`), com notificação de pré-débito três dias antes | +| Reagendar e cancelar cobrança | `rescheduleAutomaticPixPayment()`, `cancelAutomaticPixScheduledPayment()` | `UnsupportedOperationException` com `reason` `managed_by_gateway` | +| Encerrar a recorrência | `cancelAutomaticPixRecurrence()` | Cancelar a assinatura (`cancelSubscription()`); a Stripe encerra o mandato | +| Consultar cancelamentos | `getAutomaticPixCancellation()`, `listAutomaticPixCancellations()` | As mesmas operações, lendo o Mandate (`mandate_...`): mandato `inactive` devolve um cancelamento `completed` | +| Estado no model | `Invoice::$automaticPix` | `Subscription::$automaticPix`, com `nextDebitAt` e `preDebitNotificationAt` | + +No Stripe, a assinatura com `paymentMethod` `automatic_pix` registra o mandato na criação +(`payment_method_options.pix.mandate_options`) e nasce com a primeira fatura em aberto: o +pagador autoriza o mandato ao pagar essa fatura (a página hospedada vem em +`latestInvoice->url`) e a Stripe cobra os ciclos seguintes sozinha. A lib deriva o mandato do +plano: o valor é a soma do plano com os itens recorrentes (com desconto na assinatura ele vira +um teto, `amount_type` `maximum`), a agenda vem do intervalo do plano (semanal, mensal, +trimestral, semestral ou anual; outro intervalo é recusado antes da requisição) e o primeiro +débito (`start_date`) do fim do trial ou de `nextBillingAt`, com o mínimo de três dias a +partir de hoje. O plano precisa de valor fixo (Price com `unit_amount`) e a assinatura com +mandato não troca de método depois de criada (cancele e crie outra). +`Subscription::$automaticPix` refina o mandato na escrita (`startsAt`, `endsAt`, `frequency`; +no builder, `setAutomaticPix()`) e volta preenchido na leitura, com as datas derivadas +`nextDebitAt` (o débito acontece três dias depois do início do ciclo) e +`preDebitNotificationAt`. O nome +exibido no aplicativo do banco vem da configuração +`multi-payment.gateways.stripe.pix_mandate_reference` (`STRIPE_PIX_MANDATE_REFERENCE`). + +```php +$subscription = (new \Potelo\MultiPayment\MultiPayment('stripe')) + ->newSubscription() + ->setPlanId('plano_mensal') + ->setCustomer($customer) + ->setPaymentMethod(\Potelo\MultiPayment\Enums\PaymentMethod::AUTOMATIC_PIX) + ->create(); + +$subscription->latestInvoice->url; // página onde o pagador autoriza o mandato +$subscription->automaticPix->startsAt; // primeiro débito (mínimo hoje mais 3 dias) +$subscription->automaticPix->nextDebitAt; // próximo débito (ciclo mais 3 dias) +$subscription->automaticPix->preDebitNotificationAt; // quando o pagador é notificado +``` + +O id e o status do mandato (`AutomaticPix::$mandateId`, `$mandateStatus`) não vêm na leitura +da assinatura: chegam pelo webhook `mandate.updated` da Stripe ou preenchidos pela consulta de +cancelamentos (`listAutomaticPixCancellations($mandateId)`). A fatura avulsa com +`automaticPix` no Stripe é recusada antes de qualquer requisição +(`UnsupportedOperationException`, `AUTOMATIC_PIX`, `not_implemented`): nesse gateway a +recorrência vive na assinatura. A conta Stripe da empresa ainda aguarda a liberação do +recurso; até lá, a criação real responde com a recusa do próprio gateway. + +Um `start_date` derivado do relógio muda entre tentativas com a mesma chave de idempotência +(a Stripe compara o payload); num retry com chave, informe `startsAt` em `automaticPix`, como +já vale para as outras datas derivadas do instante da chamada. + +Na Iugu, ele é configurado como parte da fatura: ```php use Potelo\MultiPayment\Models\AutomaticPix; @@ -128,24 +1117,442 @@ $multiPayment->getAutomaticPixCancellation($recurrenceId, $cancellationId); $multiPayment->listAutomaticPixCancellations($recurrenceId, page: 1, limit: 100); ``` -##### Testes com a sandbox da Iugu +#### Pix Automático: quem agenda a cobrança + +Os dois gateways dividem a responsabilidade pela recorrência de forma oposta, e +`supports(Capability::MANAGES_RECURRENCE)` diz de que lado cada um fica: falso na Iugu (a +aplicação agenda), verdadeiro no Stripe (o gateway agenda). A regra é esta: -A suíte `Integration` reúne todos os testes que acessam a sandbox da Iugu. Cada -teste cria durante a execução os clientes, faturas e cartões de que precisa; não -há dependência de IDs ou outros dados previamente existentes no gateway. +- **Na Iugu, a aplicação é o motor de recorrência.** A API cria a recorrência junto com a + fatura e devolve o identificador, mas não controla a periodicidade das cobranças. É a + aplicação que decide quando cobrar e usa os métodos de `AutomaticPixContract` para isso: + `rescheduleAutomaticPixPayment()` para solicitar a retentativa de um agendamento, + `cancelAutomaticPixScheduledPayment()` para cancelar uma cobrança agendada e + `cancelAutomaticPixRecurrence()` para encerrar a recorrência. Sem esse motor na aplicação, + nenhuma cobrança recorrente acontece. +- **No Stripe, o gateway agenda.** O mandato vive na Subscription e a Stripe controla o + calendário: envia ao pagador a notificação de pré-débito obrigatória três dias antes de cada + débito, cobra e faz as retentativas automáticas. A aplicação não agenda nada; a data de + início do mandato precisa respeitar esse prazo de três dias, e as operações de agendamento + da lib respondem `UnsupportedOperationException` com `reason` `managed_by_gateway`. + +Por isso a assinatura com `paymentMethod` `automatic_pix` exige `MANAGES_RECURRENCE` além de +`AUTOMATIC_PIX`: na Iugu ela é recusada antes de qualquer requisição (a recorrência nasce na +fatura), e no Stripe a fatura avulsa com `automaticPix` é recusada (a recorrência nasce na +assinatura). As duas restrições são consultáveis em +`restriction(Capability::AUTOMATIC_PIX, $gateway)`. + +**Migrar uma recorrência de um gateway para o outro exige desligar o motor da aplicação para +aquela recorrência** quando o destino é o Stripe. Se o motor continuar ativo, a aplicação e o +gateway cobram o mesmo ciclo e o cliente é debitado duas vezes. No sentido inverso, do Stripe +para a Iugu, o motor precisa ser ligado, senão a recorrência para de cobrar. + +##### Testes com as sandboxes dos gateways + +A suíte `Integration` reúne todos os testes que acessam as sandboxes reais (Iugu +e Stripe). Cada teste cria durante a execução os clientes, faturas e cartões de +que precisa; não há dependência de IDs ou outros dados previamente existentes no +gateway. ```bash IUGU_ID=seu_account_id \ IUGU_APIKEY=seu_api_token \ +STRIPE_APIKEY=sua_chave_sk_test \ ./vendor/bin/phpunit -c phpunit.xml.dist --testsuite Integration ``` -Atualmente, a sandbox responde que Pix Automático não está disponível no modo de +Atualmente, a sandbox da Iugu responde que Pix Automático não está disponível no modo de teste. Os cenários que dependem desse recurso estão identificados com o grupo `iugu-sandbox-limitation` e usam um `skip` explícito com a razão da limitação. Os testes permanecem junto das classes responsáveis pelo builder e pela facade para que possam ser reativados quando o ambiente passar a suportar o fluxo. +#### Assinaturas e planos + +Assinatura recorrente e plano estão disponíveis nos dois gateways. Na Iugu o plano é o +recurso de plano nativo; no Stripe ele vira um par Product e Price recorrente (o id do plano +é o id do Price, com prefixo `price_`), e a assinatura é a Subscription do Stripe Billing. + +O desconto (`SubscriptionDiscount`) funciona nos dois gateways. No Stripe cada desconto vira +um Coupon criado na hora e aplicado à assinatura: `cycles` 1 é `duration` `once`, `cycles` +acima de 1 e `validUntil` viram `repeating` com `duration_in_months` (o cupom da Stripe dura +meses inteiros: `cycles` acima de 1 exige plano mensal ou anual, e `validUntil` vira meses +contados da aplicação, arredondados para cima), e desconto sem prazo é `forever`; na leitura +o desconto volta com o id do Coupon e, no `repeating`, com o fim em `validUntil`. Na Iugu o +desconto é um subitem recorrente de valor negativo, e a validade (`validUntil`, ou a +calculada de `cycles` pelo intervalo do plano) é emulada pela lib (ver +[Emulações na Iugu](#emulações-na-iugu)). Desconto percentual (`percentOff`) só existe no +Stripe (`PERCENT_DISCOUNT`); a Iugu o recusa antes de qualquer requisição. + +```php +use Potelo\MultiPayment\Models\Plan; +use Potelo\MultiPayment\Enums\PlanInterval; + +$plan = new Plan(); +$plan->name = 'Mensal'; +$plan->identifier = 'plano_mensal'; +$plan->amount = 10000; // centavos +$plan->interval = PlanInterval::MONTH; // DAY, WEEK, MONTH ou YEAR (a Iugu recusa DAY) +$plan->intervalCount = 1; +$plan->save('iugu'); // ou save('stripe'): cria o Product e o Price + +$subscription = (new \Potelo\MultiPayment\MultiPayment('iugu')) + ->newSubscription() + ->setPlanId('plano_mensal') + ->setCustomerId($customer->id) + ->setCreditCard($card->id) // cartão salvo (ou um CreditCard com token); implica o método cartão + ->setTrialDays(7) // a primeira cobrança acontece no fim do teste + ->addItem('Consultas extras', 2500, 2) // item recorrente, valor em centavos + ->addAmountDiscount('Promo', 500, cycles: 1) // desconto só na próxima fatura + ->withIdempotencyKey("sub-{$order->uuid}") + ->create(); + +$subscription->status; // SubscriptionStatus; na Iugu: TRIALING, ACTIVE, SUSPENDED, PENDING, PAST_DUE, CANCELED ou EXPIRED +$subscription->paymentMethod; // PaymentMethod::CREDIT_CARD, lido do gateway +$subscription->trialEndsAt; // calculado pela lib na criação + +$porPix = (new \Potelo\MultiPayment\MultiPayment('iugu')) + ->newSubscription() + ->setPlanId('plano_mensal') + ->setCustomerId($customer->id) + ->setPaymentMethod(PaymentMethod::PIX) // ou setAvailablePaymentMethods(['pix', 'bank_slip']), que tem precedência + ->setNextBillingAt('2026-10-01') + ->create(); +``` + +Meio de pagamento e trial são conceitos da assinatura: + +- **`setPaymentMethod()`** define com que método a assinatura é cobrada; com + `availablePaymentMethods` vazia, o driver deriva a lista dele, e com ela preenchida o método + precisa constar da lista. **`setCreditCard()`** aceita o id de um cartão salvo ou um + `CreditCard` (token, ou dados crus na Iugu), implica o método cartão, e um cartão sem id é + salvo no cliente ao criar a assinatura; cartão com uma lista sem `credit_card` é recusado com + `ModelAttributeValidationException`. Na leitura, `paymentMethod` volta preenchido quando a + assinatura aceita um único método, e a lista vem preenchida: para trocar o método de uma + assinatura lida, troque `availablePaymentMethods` (ou a zere) antes de `save()`. +- **`setTrialDays()`** conta o período de teste a partir do momento em que a assinatura é criada: + o driver calcula `trialEndsAt` na hora da requisição, o deixa no model e zera `trialDays` + (o model devolvido pode ser salvo de novo). Prefira + `setTrialDays()` a `setTrialEndsAt(now()->addDays(7))`: a segunda fixa a data no instante em + que o model foi montado, e um model guardado para retry carrega uma data velha. Os dois não + podem ser informados juntos. + +Operações sobre a assinatura: + +```php +use Potelo\MultiPayment\Enums\ProrationBehavior; + +$subscription->suspend(); +$subscription->resume(); // desfaz a suspensão e o cancelamento agendado +$subscription->cancel(); // CANCELED; na Iugu, suspende e grava a marca de cancelamento +$subscription->cancel(atPeriodEnd: true); // segue ativa até o fim do período pago, com cancelAtPeriodEnd +$subscription->changePlan('plano_anual'); // ProrationBehavior::CHARGE_DIFFERENCE: cobra o plano novo agora +$subscription->changePlan('plano_anual', ProrationBehavior::NONE); // nada é cobrado agora +$preview = $subscription->previewPlanChange('plano_anual'); // simula sem aplicar (ver "Troca de plano") + +// itens e descontos são declarativos: a lista informada vira o estado da assinatura, e a lista +// que ficar em null é preservada como está no gateway +$mantido = new SubscriptionItem(); +$mantido->id = $subscription->items[0]->id; +$subscription->items = [$mantido]; +$subscription->save(); + +$payment = new \Potelo\MultiPayment\MultiPayment('iugu'); +$subscription = $payment->getSubscription($subscriptionId); // mesma forma de getInvoice() +$plan = $payment->getPlan('plano_mensal'); // identificador ou id do gateway +$assinaturas = $payment->listSubscriptions($customer->id); +$planos = $payment->listPlans(); +``` + +##### Troca de plano + +`changePlan()` recebe a política de pró-rata como `Potelo\MultiPayment\Enums\ProrationBehavior`, +e cada gateway a traduz para o próprio parâmetro: + +| `ProrationBehavior` | O que acontece | Iugu | Stripe | +|---|---|---|---| +| `CHARGE_DIFFERENCE` (padrão) | O plano novo é cobrado agora; o gateway decide o que abater do período já pago | `POST change_plan`: fatura emitida na hora, sem crédito do período anterior (a Iugu acrescenta ciclos no downgrade) | `proration_behavior: always_invoice`: as linhas de pró-rata (crédito do período não usado e cobrança do plano novo) são faturadas e cobradas na hora, e a fatura volta em `latestInvoice` | +| `NONE` | Nada é cobrado nem creditado agora; o plano novo vale a partir da próxima cobrança do ciclo | `PUT` com `skip_charge`, mantendo a data de cobrança (`nextBillingAt` preenchido vai junto) | `proration_behavior: none`; `nextBillingAt` diferente do lido é recusado (a Stripe não aceita mudar a data da próxima cobrança na troca) | +| `CREDIT` | O gateway calcula o crédito do período não usado e o aplica na próxima fatura | `UnsupportedOperationException` (`PLAN_CHANGE_PRORATION`, `gateway_limitation`), antes de qualquer requisição | `proration_behavior: create_prorations`: crédito e cobrança proporcionais entram na próxima fatura | + +Como a política que o gateway não oferece é recusada antes da rede, consulte +`supports(Capability::PLAN_CHANGE_PRORATION)` antes de oferecer a opção de crédito no +checkout. + +```php +$subscription->changePlan('plano_anual', ProrationBehavior::CHARGE_DIFFERENCE, idempotencyKey: "upgrade-{$order->uuid}"); +$subscription->changePlan('basico', ProrationBehavior::NONE); +$subscription->changePlan('plano_anual', ProrationBehavior::CREDIT); // Iugu: UnsupportedOperationException +``` + +`previewPlanChange()` simula a troca sem aplicá-la e devolve um `SubscriptionPlanChange`: + +- **`amount`**: o que a troca cobraria agora, em centavos; quando há linhas, é a soma de `items`. +- **`items`**: as linhas da fatura que a troca geraria, como `InvoiceItem` (crédito com `price` + negativo). A lista nunca é nula. No Stripe as linhas vêm reais, da prévia de fatura + (`invoices.create_preview`) com `always_invoice`, o mesmo fluxo de `CHARGE_DIFFERENCE`. A + Iugu não devolve linhas em `change_plan_simulation`, então + a lib monta uma linha de cobrança do plano novo (`Plano `, valendo `cost` mais + `discount`) e, quando `discount` é maior que zero, uma linha negativa de crédito do plano + antigo. O payload cru (o Invoice da prévia no Stripe; `cost`, `discount`, `cycles`, + `expires_at`, `old_plan` e `new_plan` na Iugu) segue em `original`. +- **`effectiveAt`**: a data em que a próxima cobrança acontece após a troca. No Stripe é o fim + de período da linha mais distante da prévia. +- **`appliesImmediately`**: se o plano novo passa a valer assim que a troca for aplicada. No + Stripe é sempre verdadeiro (a Stripe aplica o plano novo na hora, independente do + pagamento). Na + Iugu é verdadeiro quando a assinatura é paga só com cartão (o cartão padrão é cobrado na + hora) e falso quando ela aceita boleto ou Pix, porque a Iugu só efetiva a troca depois do + pagamento da fatura gerada; uma assinatura aberta a mais de um método também lê como falso. + Quando o model não traz os métodos de pagamento (só o id), o driver lê a assinatura antes + da simulação. + +```php +$preview = $subscription->previewPlanChange('plano_anual'); +foreach ($preview->items as $line) { // nunca null + echo "{$line->description}: {$line->price}"; +} +$preview->amount; // 30000 +$preview->effectiveAt; // Carbon: próxima cobrança depois da troca +$preview->appliesImmediately; // false numa assinatura paga por Pix na Iugu +``` + +> **Obsoleto (desde 2026-09-02).** O booleano `$charge` de `changePlan()` continua aceito, na +> mesma posição ou pelo nome (`charge: false`), e é traduzido (`true` para +> `CHARGE_DIFFERENCE`, `false` para `NONE`) com aviso `E_USER_DEPRECATED`; `charge` informado +> prevalece sobre a política. Nos drivers, `changeSubscriptionPlan()` aceita o booleano das +> mesmas duas formas. A remoção fica para uma versão futura. + +Particularidades da Iugu: + +- **Cancelar é suspender com uma marca.** A Iugu só suspende, então `cancel()` faz duas + requisições: `POST /suspend` e um `PUT` que grava `mp_canceled_at` (data e hora, ISO 8601) + em `custom_variables`. A assinatura lê como `CANCELED` enquanto estiver suspensa com a marca, + e `canceledAt` é preenchido a partir dela. `resume()` de + uma assinatura cancelada reativa e remove a marca (`PUT` com `_destroy`), voltando a + `ACTIVE`. Chamar `cancel()` de novo numa assinatura já cancelada só repete a suspensão e + mantém a data original. Se a segunda requisição de `cancel()` ou de `resume()` falhar, a + exceção sobe com a assinatura no estado intermediário (suspensa sem marca, ou ativa com a + marca); repita a chamada, de preferência com a mesma chave de idempotência, que a Iugu aceita + `suspend` e `activate` repetidos. Uma marca `mp_canceled_at` que não seja uma data lê + como ausente, com aviso no log. `cancel(atPeriodEnd: true)` é emulado pela lib e depende do + comando de sincronização agendado (ver [Emulações na Iugu](#emulações-na-iugu)). +- **O prefixo `mp_` em `custom_variables` é reservado à lib** para o estado das emulações: + `metadata` com uma chave assim é recusado com `ModelAttributeValidationException`, e na + leitura as variáveis `mp_` não aparecem em `metadata` (viram os campos tipados: + `canceledAt`, `cancelAtPeriodEnd`, `validUntil` do desconto). +- **Desconto é sempre valor fixo.** `percentOff` lança `UnsupportedOperationException` + (`PERCENT_DISCOUNT`, `gateway_limitation`); `cycles` aceita `1` (uma fatura, subitem sem + recorrência) ou `null` (até ser removido), e `cycles` maior que `1` ou `validUntil` são + emulados pela lib (ver [Emulações na Iugu](#emulações-na-iugu)). +- **Plano anual é 12 meses, e plano diário não existe.** A Iugu só tem intervalos em semanas e + meses, então `PlanInterval::YEAR` é enviado como `12 * intervalCount` meses e + `PlanInterval::DAY` lança `ModelAttributeValidationException` antes de chamar a API. Na leitura vale a + heurística inversa: todo plano em meses cujo intervalo é múltiplo de 12 volta como `YEAR` com + `intervalCount` dividido por 12 (um plano criado direto na Iugu com 24 meses lê como 2 anos). + Quem precisar do valor cru lê `original`. A Iugu aceita intervalo de 1 a 599, então um plano + anual vai até `intervalCount` 49; acima disso o driver lança `ModelAttributeValidationException` + antes de chamar a API. +- **Planos não são desativáveis.** `deactivatePlan` lança `UnsupportedOperationException` + (`PLAN_DEACTIVATION`, `gateway_limitation`). +- **`nextBillingAt` e `trialEndsAt` são o mesmo campo** (`expires_at`), e o que os distingue é + a cobrança do primeiro ciclo: por padrão a Iugu fatura o primeiro ciclo na criação e cobra o + cartão padrão na hora, mesmo com `expires_at` no futuro (observado na sandbox), então um + trial (`setTrialDays()` ou `setTrialEndsAt()`) vai com `only_charge_on_due_date`, e a + assinatura nasce sem fatura (`latestInvoice` nulo) e sem cobrança até o fim do teste; + `setNextBillingAt()` sozinho vai só como `expires_at`, com a fatura e a cobrança imediatas da + Iugu (`gateway_options['only_charge_on_due_date']` sobrepõe os dois). Informar `trialEndsAt` + (ou `trialDays`) e `nextBillingAt` com datas diferentes lança `ModelAttributeValidationException`. Ao prorrogar + um trial lido do gateway, zere `nextBillingAt` antes, porque a leitura preenche os dois. + `in_trial` (lido como `TRIALING`) só aparece em assinatura que a própria Iugu põe em teste; a + assinatura criada com trial pela lib lê como `ACTIVE`, com `nextBillingAt` no fim do teste. +- **O cartão da assinatura é o cartão padrão do cliente.** A Iugu não guarda cartão por + assinatura, então `setCreditCard()` torna o cartão informado o padrão do cliente (um `PUT` no + cliente, ou o `set_as_default` ao salvar um cartão novo) antes de criar a assinatura, o que + vale para as outras assinaturas do mesmo cliente. Só o método (`setPaymentMethod(CREDIT_CARD)`) + cobra o cartão padrão que o cliente já tiver. A leitura não preenche `creditCard`. +- **`past_due` é derivado.** A Iugu não tem esse estado: o pacote o reporta quando a data da + próxima cobrança já passou e **alguma** fatura de `recent_invoices` continua em aberto — + pendente, vencida (`expired`) ou parcialmente paga. Olha todas, e não só a que virou + `latestInvoice`, senão uma cancelada de vencimento posterior esconderia uma pendente anterior. + Em compensação, fatura antiga que deixou de ser dívida por fora do pacote continua contando + enquanto estiver em `recent_invoices`. +- **`latestInvoice` é a fatura mais recente, não a que gerou a inadimplência.** Vence a de maior + vencimento, com o menor id desempatando, e entrada sem id é descartada — a ordem em que a Iugu + devolve as faturas não influencia. Em `past_due` ela pode estar **quitada**, ou vir `null`: + para chegar na fatura a pagar, liste as faturas do cliente. Vem resumida — id, status, + vencimento e, quando a Iugu manda, a url; sem valor em centavos (`amount` fica `null`). Use + `getInvoice()` pelo id para a fatura completa. +- **Atualizar itens ou descontos custa chamadas extras.** A Iugu recusa remover e adicionar + subitens na mesma chamada, então o pacote lê a assinatura, envia a remoção sozinha e só depois + a atualização — até três requisições. Entre a remoção e a atualização a assinatura fica sem os + itens removidos, e se a segunda falhar eles não voltam sozinhos. +- **`cancelAtPeriodEnd` e `canceledAt` vêm das marcas da lib** (`mp_cancel_at_period_end` e + `mp_canceled_at` em `custom_variables`), gravadas por `cancel()`. `paymentMethod` vai como + `payable_with` e volta quando a assinatura aceita um único método (`all` e listas com mais + de um leem como nulo). +- **Reativar exige data de cobrança.** Assinatura criada sem `nextBillingAt` fica sem data no + gateway e, por isso, não volta com `resume()`: a Iugu responde sem erro e sem mudar nada, e o + `status` devolvido segue `SUSPENDED`. +- **`active` e `suspended` são flags independentes.** Assinatura suspensa pode continuar com + `active: true` na Iugu; o pacote dá precedência a `suspended` e reporta `SUSPENDED`. +- **A simulação de troca traz linhas montadas pela lib.** `change_plan_simulation` devolve só + totais (`cost`, `discount`, `cycles`, `expires_at`, `old_plan`, `new_plan`, todos em + `original`); `previewPlanChange()` monta `items` a partir deles (ver + [Troca de plano](#troca-de-plano)). `cost` é lido como o valor líquido da troca, então + `amount` é `cost` e, quando há `discount`, a linha do plano novo é `cost` mais `discount`. +- **Trocar de plano com cobrança gera fatura pendente, não pagamento.** `changePlan()` com + `ProrationBehavior::CHARGE_DIFFERENCE` (o padrão) faz a Iugu emitir a fatura na hora, com + vencimento imediato e sem crédito do período anterior. Ela volta resumida em + `latestInvoice`, com status `pending`; use `getInvoice()` pelo id para o valor em centavos. Com `ProrationBehavior::NONE` nada é cobrado; `ProrationBehavior::CREDIT` é + recusado antes da rede. +- **O plano de uma assinatura existente não muda por `save()`**; use `changePlan()`. +- **Plano não é atualizável.** `save()` num `Plan` que já tem `id` lança + `ModelAttributeValidationException` antes da requisição; para mudar preço ou intervalo, crie + outro plano e troque as assinaturas com `changePlan()`. +- **Fatura vencida lê como `EXPIRED` e continua sendo dívida.** A Iugu chama de `expired` a + fatura que venceu sem pagamento; ela conta como fatura em aberto na derivação de `past_due` + da assinatura, e `InvoiceStatus::EXPIRED->isPayable()` responde verdadeiro (`isOpen()` + responde falso, porque não há pagamento em curso). +- **A assinatura lida traz o cliente resumido.** `Subscription::get()` preenche `customer` com + id, nome e e-mail — documento, endereço e telefone não vêm da Iugu. Eles sobrevivem se o + `customer` local já tiver o mesmo id; se o id for outro, ou o local não tiver id, o pacote + troca o objeto para não misturar dados de dois clientes. + +No update, data de cobrança e métodos de pagamento só são enviados quando mudaram em relação +ao que veio na leitura — um `save()` que mexeu só nos itens não altera a data de cobrança. Um +`creditCard` informado no update vira o padrão do cliente antes do `PUT` da assinatura. + +> **Mudança de comportamento (versão 5.0.0).** Até a 4.1.0, `trialEndsAt` ia só como +> `expires_at`, e com cartão padrão a Iugu cobrava o primeiro ciclo na criação; agora o trial vai +> com `only_charge_on_due_date`. `Subscription::$paymentMethod` passou a ser escrito +> (`payable_with`) e lido; até a 4.1.0 era ignorado nas duas direções. O desconto de assinatura +> no Stripe deixou de ser recusado e vira Coupon; desconto percentual passou a ser recusado na +> Iugu com a capability `PERCENT_DISCOUNT` (antes `NATIVE_COUPONS`, hoje `COUPONS`); e as +> variáveis `mp_` da Iugu saíram de `metadata` (a marca de cancelamento aparecia lá) e passaram +> a ser recusadas na escrita. + +#### Emulações na Iugu + +A Iugu não oferece cupom com prazo nem cancelamento ao fim do ciclo; a lib emula os dois (a +célula da [tabela de capabilities](#capabilities) diz "emulado", e `isEmulated()` responde +verdadeiro). O estado da emulação vive em `custom_variables` da própria assinatura na Iugu, +com o prefixo reservado `mp_`, então qualquer instância da lib lê o mesmo estado sem banco +próprio: + +| Variável | Conteúdo | +|---|---| +| `mp_discount__until` | Data (`Y-m-d`) até a qual o subitem de desconto vale | +| `mp_cancel_at_period_end` | `1` quando há cancelamento agendado | +| `mp_cancel_scheduled_for` | Data (`Y-m-d`) em que a assinatura deve ser suspensa | +| `mp_canceled_at` | Data em que a lib cancelou (gravada pelo `cancel()` imediato e pelo comando ao aplicar o agendado) | + +Como funciona cada emulação: + +- **Cupom com prazo.** O desconto continua sendo o subitem negativo; a validade (`validUntil`, + ou a data calculada de `cycles` pelo intervalo do plano, contada da primeira cobrança) é + gravada na variável do subitem numa segunda requisição depois da escrita. Na leitura o + desconto volta com `validUntil` preenchido. Se essa segunda requisição falhar na criação, a + exceção sobe com a assinatura já criada e o desconto sem prazo; reaplique a validade com um + `save()` da lista de descontos, que o update regrava a variável. +- **Cancelamento ao fim do ciclo.** `cancel(atPeriodEnd: true)` não suspende: um único `PUT` + grava a intenção e a data programada (a data da próxima cobrança), a assinatura segue + `ACTIVE` com `cancelAtPeriodEnd` verdadeiro, e `resume()` limpa o agendamento. + +**A emulação só funciona se o comando `multipayment:sync-subscriptions` roda.** Ele percorre +as assinaturas do gateway, remove o subitem de desconto cuja validade passou e suspende, +gravando `mp_canceled_at`, a assinatura cujo cancelamento agendado chegou à data. Agende-o na +aplicação: + +```php +// app/Console/Kernel.php (ou routes/console.php) +$schedule->command('multipayment:sync-subscriptions')->hourly(); +``` + +O comando é idempotente (rodar duas vezes não muda nada na segunda), escreve cada ação na +saída e no log, aceita `--gateway=iugu` para um gateway só e `--dry-run` para inspecionar sem +escrever; num gateway que gerencia os dois recursos sozinho (Stripe), ele não faz nada e diz +isso no log, e a varredura sem `--gateway` pula gateway configurado sem `api_key`. Entre o vencimento (do desconto ou do agendamento) e a execução seguinte do +comando existe uma janela em que a Iugu ainda tem o subitem, ou a assinatura ativa: uma +fatura gerada nessa janela sai com o desconto vencido, e a frequência do agendamento limita a +janela. Uma aplicação que já emulava esses recursos por conta própria precisa desligar a +lógica ao migrar, no mesmo deploy, para não remover o desconto nem suspender duas vezes. + +Particularidades do Stripe: + +- **O plano é um Product mais um Price recorrente.** `createPlan()` cria os dois: o Product + guarda o nome e o identificador (`metadata.identifier`), o Price guarda o valor e o + intervalo, e o id do plano é o id do Price (`price_...`). O identificador vai também em + `lookup_key` do Price, que é como `getPlan()` o encontra (um identificador com o prefixo + `price_` é lido direto como id); identificador repetido é recusado pela Stripe. Todos os + intervalos de `PlanInterval` valem, inclusive `DAY`. `deactivatePlan()` arquiva o Price: + as assinaturas existentes continuam cobrando e assinatura nova com o plano é recusada pela + Stripe; o plano segue legível, com `active` falso, e aparece em `listPlans()`. +- **O cartão fica na assinatura.** `setCreditCard()` vira o `default_payment_method` da + Subscription; o cartão padrão do cliente não muda (na Iugu muda, porque lá a assinatura não + tem cartão próprio). Cartão sem id é salvo antes pelo fluxo de SetupIntent: se o emissor + exigir autenticação do pagador, a assinatura não é criada e sobe `ChargingException` com + `DeclineCode::AUTHENTICATION_REQUIRED` e o SetupIntent em `chargeResponse`; conclua com + `confirmCreditCardSetup()` e crie a assinatura com o id do cartão salvo. +- **Com cartão, a primeira fatura é cobrada na criação** (`payment_behavior` + `error_if_incomplete`): a recusa do cartão sobe como `ChargingException` e a assinatura não + é criada. Com trial não há cobrança e a assinatura nasce `TRIALING` (a fatura de valor zero + do trial volta em `latestInvoice`). Os dias de `setTrialDays()` vão como + `trial_period_days`, que não muda entre tentativas com a mesma chave de idempotência. +- **Com Pix, a assinatura nasce com a primeira fatura em aberto** (`payment_behavior` + `default_incomplete`, status `PENDING`): `latestInvoice` traz a fatura para o pagador + quitar (`url` é a página hospedada). O método `pix` em assinatura depende de habilitação na + conta Stripe; sem ela, a criação é recusada pelo gateway com `ValidationException` + ("The payment method type `pix` is invalid"). +- **Com Pix Automático (`setPaymentMethod(PaymentMethod::AUTOMATIC_PIX)`), a assinatura + também nasce com a primeira fatura em aberto** e registra o mandato na criação; a Stripe + agenda as cobranças seguintes. Ver [Pix Automático](#pix-automático). +- **Com boleto, a assinatura nasce ativa em modo de fatura enviada** (`collection_method` + `send_invoice`, com `days_until_due` de 3 dias, sobrescritível por + `gateway_options['days_until_due']`): a primeira fatura é finalizada na criação e volta em + `latestInvoice` aberta, com vencimento e com a página hospedada em `url`, onde o pagador + gera o voucher; as faturas dos ciclos seguintes são emitidas pela Stripe com o mesmo prazo. + A troca de método de uma assinatura existente para boleto muda o modo de cobrança para + `send_invoice`, e a troca de boleto para cartão ou Pix devolve a cobrança automática. Uma + assinatura com mandato de Pix Automático não troca de método (nem uma existente passa a + tê-lo): as duas direções são recusadas antes da requisição. +- **Itens extras criam Prices no Stripe.** Cada `SubscriptionItem` vira um subscription item + com um Product e um Price próprios, criados na hora (o item precisa de `description` e + `amount`); item com `recurring` falso vai como item avulso da primeira fatura. No update + declarativo, item novo cria Price, item com `id` tem a quantidade atualizada e mantém o + Price, item que saiu da lista é removido, e a troca não gera pró-rata. +- **Descontos criam Coupons no Stripe.** Cada `SubscriptionDiscount` novo vira um Coupon + criado na hora, aplicado via `discounts` da Subscription; a duração vem de `cycles` e + `validUntil` (ver [Assinaturas e planos](#assinaturas-e-planos)). No update a lista + informada substitui a da assinatura: desconto com `id` (o id do Coupon, como veio na + leitura) mantém o Coupon, desconto novo cria um, lista vazia remove todos, e a lista igual + à lida não é reenviada. O Coupon criado não é apagado quando sai da assinatura: ele fica na + conta, reutilizável pelo id. +- **`suspend()` pausa a cobrança** (`pause_collection` com `behavior` `void`) e a assinatura + lê como `PAUSED` (na Iugu, `SUSPENDED`); as faturas dos ciclos pausados são anuladas. + `resume()` desfaz a pausa e também o cancelamento agendado por `cancel(atPeriodEnd: true)`. + Assinatura cancelada de vez (`CANCELED`) não volta na Stripe: `resume()` é recusado pelo + gateway com `ValidationException` (na Iugu, `resume()` reativa a cancelada). +- **`cancel(atPeriodEnd: true)` é nativo**: a assinatura segue ativa até o fim do período + pago, com `cancelAtPeriodEnd` e `canceledAt` preenchidos no model. `cancel()` sem o + argumento cancela na hora, sem estorno nem fatura final. +- **`nextBillingAt` vale só na criação** (`billing_cycle_anchor`). Na troca de plano e na + atualização, um `nextBillingAt` diferente do que veio do gateway é recusado com + `UnsupportedOperationException` (restrição consultável de `SUBSCRIPTIONS`): a Stripe não + aceita uma data arbitrária de próxima cobrança fora do ciclo. Na leitura, `nextBillingAt` é + o fim do período corrente do item do plano. +- **Leituras custam requisições a mais.** `getSubscription()` (e a criação e a troca com + cobrança) relê a fatura mais recente para preencher `latestInvoice` por inteiro; + `listSubscriptions()` e `listPlans()` paginam por cursor, então uma página além da primeira + custa uma requisição por página anterior; `listSubscriptions()` traz assinaturas em + qualquer status, sem `latestInvoice`. +- **A escrita sobre a fatura de assinatura (`in_`) ainda não existe nesta lib**: estorno e + cobrança manual de uma fatura de assinatura lançam `UnsupportedOperationException` + (`SUBSCRIPTIONS`, `not_implemented`); a leitura por `getInvoice()` e o cancelamento por + `cancelInvoice()` (`void`) estão disponíveis. + +Confira `src/Builders/SubscriptionBuilder.php` para saber quais métodos estão disponíveis. + #### CustomerBuilder ```php $multiPayment = new \Potelo\MultiPayment\MultiPayment('iugu'); @@ -159,94 +1566,282 @@ $customer = $customerBuilder->setName('Nome') ``` Confira `src/MultiPayment/Builders/CustomerBuilder.php` para saber quais métodos estão disponíveis. +#### Salvar cartão (CreditCardBuilder) + +O cartão salvo fica vinculado ao cliente e pode ser cobrado depois por `addCreditCardId()`, +`setCreditCard()` na assinatura ou `chargeInvoiceWithCreditCard()`. No Stripe o token é o +PaymentMethod criado no navegador com Stripe.js (`pm_...`); na Iugu, o token do iugu.js ou os +dados do cartão (`setNumber()`, `setCvv()`...), que só a Iugu aceita (`RAW_CARD_DATA`). + +```php +$payment = new \Potelo\MultiPayment\MultiPayment('stripe'); // ou 'iugu' + +$card = $payment->newCreditCard() + ->setCustomerId($customer->id) + ->setToken($request->payment_method_id) + ->setDescription('Cartão principal') + ->setAsDefault() + ->withIdempotencyKey("card-{$request->uuid}") + ->create(); + +if ($card->requiresAction) { + // o emissor exige autenticação do pagador (3DS) antes de o cartão ficar cobrável; o cartão + // ainda não foi salvo (id nulo). O navegador conclui com o SDK do gateway: + // stripe.confirmCardSetup($card->clientSecret) e, depois, o servidor confirma abaixo + return response()->json(['setup_id' => $card->setupId, 'client_secret' => $card->clientSecret]); +} + +$card->id; // cartão salvo e cobrável +``` + +```php +// depois que o pagador autenticou no navegador +$card = $payment->confirmCreditCardSetup($setupId); // ou $card->confirmSetup() + +if ($card->requiresAction) { + // o pagador ainda não concluiu a autenticação; peça de novo ou desista do cartão +} +$card->id; // cartão salvo; a descrição e a marcação de padrão pedidas na criação já foram aplicadas +``` + +Regras do fluxo: + +- **Quem autentica é o gateway com `CARD_SETUP_AUTHENTICATION`** (Stripe). Na Iugu a + capability é limitação do gateway: o Zero Auth confere a validade do cartão com uma + autorização de valor zero e não autentica o portador, então `requiresAction` é sempre falso, + `setupId` é nulo e `confirmCreditCardSetup()` lança `UnsupportedOperationException`. +- **Página hospedada de autenticação.** No Stripe, `setGatewayOptions(['return_url' => ...])` + faz a Stripe devolver a página de 3DS em `actionUrl`; sem `return_url`, `actionUrl` fica + nulo e a autenticação é pelo Stripe.js com `clientSecret`. +- **Recusa é `CardDeclinedException`**, na criação ou na confirmação: cartão recusado pelo + emissor no setup, autenticação que falhou (`DeclineCode::AUTHENTICATION_REQUIRED`, com + `gatewayCode` `setup_intent_authentication_failure`) ou setup cancelado + (`DeclineCode::UNKNOWN`). O SetupIntent vai em `chargeResponse`. +- **Idempotência.** A chave de `withIdempotencyKey()` vai no SetupIntent; a confirmação aceita + a própria chave como segundo argumento (`confirmCreditCardSetup($setupId, $key)`). + +> **Mudança de comportamento (versão 5.0.0).** Até a 4.1.0, `newCreditCard()->create()` no +> Stripe fazia só o `attach` do PaymentMethod: um cartão que exigia autenticação era salvo +> como cobrável e recusado na primeira cobrança `off_session`, com +> `DeclineCode::AUTHENTICATION_REQUIRED`. Agora esse cartão volta com `requiresAction` e sem +> `id`, e só é salvo depois da autenticação. Quem persiste `$card->id` logo após o `create()` +> precisa tratar `requiresAction` antes. A recusa do emissor passa a chegar no setup, e +> `retryable` segue o `advice_code` que a Stripe envia nesse ponto, que pode diferir do que +> vinha na cobrança. + #### getInvoice ```php $invoiceId = '312ASDHGZXSGRTET312ASDHGZXSGRTET'; $payment = new \Potelo\MultiPayment\MultiPayment('iugu'); $foundInvoice = $payment->getInvoice($invoiceId); + +// no Stripe o id pode ser de um PaymentIntent (pi_, venda avulsa) ou de um Invoice +// (in_, fatura de assinatura); originType diz qual voltou (ver "Fatura no Stripe: duas origens") +$foundInvoice = (new \Potelo\MultiPayment\MultiPayment('stripe'))->getInvoice('in_1UBH...'); +$foundInvoice->originType; // InvoiceOriginType::INVOICE +``` + +#### getSubscription e getPlan +```php +$payment = new \Potelo\MultiPayment\MultiPayment('iugu'); +$subscription = $payment->getSubscription($subscriptionId); // Subscription, com status, latestInvoice e cliente resumido + +// getPlan() aceita o identificador definido na criação ou o id do gateway: busca primeiro pelo +// identificador e, se ele não existir, pelo id (duas requisições nesse caso) +$plan = $payment->getPlan('plano_mensal'); +$plan = $payment->getPlan('7D96C7C932F2427CAF54F042345A13C6'); ``` +Nos dois, gateway sem `SUBSCRIPTIONS` ou `PLANS` lança `UnsupportedOperationException` antes de +qualquer requisição; plano inexistente pelos dois caminhos lança `NotFoundException`. + +#### Outras operações de fatura +```php +$payment = new \Potelo\MultiPayment\MultiPayment('stripe'); + +// estorno do restante ou parcial (valor em centavos); devolve um Refund (seção "Estorno") +$refund = $payment->refundInvoice($invoiceId); +$refund = $payment->refundInvoice($invoiceId, 5000); +$payment->refundableAmount($invoiceId); // quanto ainda pode ser estornado, em centavos + +// cancelamento de fatura pendente (no Stripe, a fatura de assinatura in_ é anulada com void) +$payment->cancelInvoice($invoiceId); + +// duplicar fatura pendente com nova expiração (no Stripe: somente pix de venda avulsa; a original é cancelada) +$payment->duplicateInvoice($invoiceId, \Carbon\Carbon::now()->addDays(3)); + +// cobrar uma fatura pendente com cartão (token OU id de cartão salvo) +$payment->chargeInvoiceWithCreditCard($invoiceId, 'pm_...'); +$payment->chargeInvoiceWithCreditCard($invoiceId, null, $creditCardId); + +// toda operação de escrita aceita a chave de idempotência como último argumento (seção "Idempotência") +$payment->refundInvoice($invoiceId, 5000, idempotencyKey: $uuid); +$payment->cancelInvoice($invoiceId, idempotencyKey: $uuid); +``` + +#### Estorno + +Sem valor, o estorno é do restante estornável; com valor em centavos, é parcial. A operação +devolve um `Refund` (`Potelo\MultiPayment\Models\Refund`) com o que o gateway registrou do +estorno, e a fatura relida depois dele fica em `$refund->invoice()`: -#### charge +| Campo | Conteúdo | +|---|---| +| `id` | Id do estorno no gateway. Stripe: `re_...`; Iugu: `null`, porque a Iugu não identifica estornos | +| `invoiceId` | Id da fatura estornada | +| `amount` | Valor deste estorno, em centavos | +| `status` | `RefundStatus`: `PENDING`, `SUCCEEDED`, `FAILED`, `CANCELED` ou `UNKNOWN` (status que a lib não reconhece, com aviso no log). Na Iugu é sempre `SUCCEEDED`, porque a API só responde 200 com o estorno feito; na Stripe o estorno de cartão costuma nascer `PENDING` e virar `SUCCEEDED` depois | +| `reason` | Motivo em texto, quando o gateway devolve um (Stripe: `duplicate`, `fraudulent`, `requested_by_customer`); a lib não o envia ao gateway | +| `createdAt` | Data do estorno. Na Iugu é o momento da chamada | +| `original` | Objeto de estorno do gateway (Stripe); `null` na Iugu | +| `invoice()` | Fatura com o estado posterior ao estorno, já relida, sem requisição | -```php -$options = [ - 'amount' => 10000, +```php +use Potelo\MultiPayment\Enums\RefundStatus; + +$payment = new \Potelo\MultiPayment\MultiPayment('stripe'); + +$refund = $payment->refundInvoice($invoiceId); // o restante +$refund = $payment->refundInvoice($invoiceId, 5000); // parcial + +$refund->id; // 're_...' (Stripe) ou null (Iugu) +$refund->amount; // 5000 +$refund->status; // RefundStatus::PENDING ou RefundStatus::SUCCEEDED + +$invoice = $refund->invoice(); +$invoice->status; // InvoiceStatus::REFUNDED ou InvoiceStatus::PARTIALLY_REFUNDED +``` + +`$invoice->refund(?int $amount = null, ?string $idempotencyKey = null)` num model faz o mesmo e +atualiza a própria instância: depois da chamada `$invoice->status` já é o novo status, e +`$refund->invoice()` é a mesma instância. `$invoice->refundedAmount` é só de leitura e traz o +total já estornado que o gateway informou; num model lido do gateway que já teve estorno parcial, +`refund()` sem valor estorna o que resta. + +**Quanto ainda pode ser estornado.** `$invoice->refundableAmount()` (ou +`$payment->refundableAmount($id)`) devolve o restante em centavos, calculado pelo driver: na +Iugu é `paidAmount`, porque `paid_cents` já vem líquido do estornado; na Stripe é `paidAmount` +menos `refundedAmount`, porque o valor pago vem bruto. Zero para fatura não paga ou já +integralmente estornada. Um model que traz o valor pago não custa requisição; um model só com o +id lê a fatura. É o teto aritmético do estorno; as guardas de boleto, Pix parcial e prazo +continuam valendo. + +```php +$invoice = $payment->getInvoice($id); // já partially_refunded + +$invoice->refundableAmount(); // 5000 nos dois gateways +$refund = $invoice->refund(amount: 2000, idempotencyKey: $key); +$refund = $invoice->refund(); // o restante +``` + +**Histórico de estornos.** Toda fatura lida do gateway traz `$invoice->refunds`, uma lista de +`Refund` (vazia quando nada foi estornado). Os dois gateways preenchem a lista de formas +diferentes, e a conciliação precisa saber disso: + +- **Stripe**: um `Refund` por estorno feito, com `id`, `status` e `createdAt` próprios, lido + dos refunds do charge. +- **Iugu**: a API só informa o total estornado (`refunded_cents`), então a lista tem no máximo + um `Refund`, sem `id` e sem `createdAt`, com o acumulado. Dois estornos parciais de 2.000 e + 3.000 aparecem como um único registro de 5.000. O `Refund` devolvido por cada chamada de + `refundInvoice()` traz o valor daquela chamada. + +```php +$invoice = $payment->getInvoice($invoiceId); + +foreach ($invoice->refunds as $refund) { + $ledger->record($invoice->id, $refund->id, $refund->amount, $refund->status, $refund->createdAt); +} +``` + +**Recusa antes da requisição.** O pacote recusa, **antes de chamar o gateway**, o estorno que a +regra do gateway já garante que seria negado, e o faz com `RefundNotSupportedException` nos +dois drivers, para a aplicação não precisar interpretar a mensagem da Iugu ou da Stripe. A +classe herda direto de `MultiPaymentException`: `isCapabilityLimitation()` separa a recusa que +é limitação do gateway (boleto, Pix parcial; `capability` preenchida) da recusa por estado da +fatura (já estornada, valor acima do restante, prazo vencido; `capability` nula). Um +`catch (UnsupportedOperationException $e)` usado para rotear a operação para outro gateway não +captura estorno recusado: rotear um estorno de fatura já estornada não faria sentido. + +```php +use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; + +try { + $refund = $payment->refundInvoice($invoiceId, 5000); +} catch (RefundNotSupportedException $e) { + // a lib recusou sem chamar o gateway; $e->reason diz por quê + if ($e->manualRefundRequired) { + // boleto ou prazo vencido: o dinheiro só volta por fora do gateway + ManualRefund::dispatch($invoiceId, $e->paymentMethod, $e->reason); + } elseif ($e->isCapabilityLimitation()) { + // Pix parcial na Iugu: $e->capability é PARTIAL_REFUND_PIX; repita sem valor + } +} +``` + +| `$e->reason` | Quando | `$e->manualRefundRequired` | `$e->isCapabilityLimitation()` | +|---|---|---|---| +| `boleto_no_refund` | Fatura paga com boleto, nos dois gateways | `true` | `true` (`REFUND_BANK_SLIP`) | +| `pix_partial_not_supported` | Iugu: valor pedido diferente do valor pago numa fatura Pix. Repita sem valor para estornar o total | `false` | `true` (`PARTIAL_REFUND_PIX`) | +| `already_refunded` | Fatura já lida como `refunded` | `false` | `false` | +| `amount_exceeds_refundable` | Valor pedido acima de `refundableAmount()` (o restante vai na mensagem). Repita com valor até o restante | `false` | `false` | +| `refund_window_expired` | Iugu: depois do fim do 90º dia após `paidAt` | `true` | `false` | + +Uma fatura `partially_refunded` aceita novos estornos até zerar o restante; pedir exatamente +o que resta é estorno integral. Valor zero ou negativo é recusado com +`ModelAttributeValidationException` antes de qualquer requisição. + +**Custo da leitura prévia.** As guardas precisam do método de pagamento, do status, na Iugu da +data de pagamento e, no estorno por valor, do quanto ainda pode ser estornado. Chamar +`refundInvoice($id)` só com o id custa **um GET a mais** para ler a fatura antes do estorno, nos +dois gateways; chamar `$invoice->refund()` num model já lido do gateway não paga esse GET, +inclusive numa fatura `partially_refunded`, porque `refundedAmount` traz o acumulado que o +gateway informou. Essa leitura não altera o model do chamador: ele só muda quando o estorno +acontece. + +> **Mudança de comportamento (versão 5.0.0).** Até a 4.1.0, `refundInvoice()` e +> `$invoice->refund()` devolviam a `Invoice` atualizada; agora devolvem o `Refund`, e a fatura +> fica em `$refund->invoice()` (ou na própria instância, no caso de `$invoice->refund()`). O +> campo provisório `Invoice::$lastRefundId` foi removido: o id está em `$refund->id`. Na mesma +> versão, estorno de boleto, Pix parcial, fatura já estornada, valor acima do restante e fora do +> prazo de 90 dias na Iugu deixaram de ir até a API e voltar como `GatewayException`: lançam +> `RefundNotSupportedException`, que herda de `MultiPaymentException`, fora da árvore de +> `GatewayException` e fora da de `UnsupportedOperationException`: um `catch (GatewayException $e)` +> sozinho deixa de capturar esses casos. +> +> Ainda na 5.0.0, o valor do estorno virou argumento: `$invoice->refund(amount: 5000)` e +> `refundInvoice(Invoice $invoice, ?int $amount, ?string $idempotencyKey)` no contract. +> `Invoice::$refundedAmount` passou a ser só de leitura (o total já estornado, como o gateway +> informa); escrever nela antes de chamar `refund()` continua funcionando como pedido de estorno +> parcial, com aviso `E_USER_DEPRECATED`, e some em uma versão futura. Consequência: num model +> lido do gateway já `partially_refunded`, `refund()` sem valor passou a estornar o restante em +> vez de reenviar o acumulado como novo estorno parcial. + +#### charge (alternativa por array) + +`charge(array)` monta a mesma fatura a partir de um array em `snake_case`, para integrações que +recebem os dados prontos (o `MultiPaymentTrait` usa este caminho). As chaves espelham as +propriedades do model e seguem as mesmas regras do builder; `customer` é obrigatório e é +conferido antes de qualquer conversão. O exemplo abaixo vale para os dois gateways: + +```php +$invoice = (new \Potelo\MultiPayment\MultiPayment('iugu'))->charge([ 'customer' => [ 'name' => 'Nome do cliente', 'email' => 'email@example.com', - 'tax_document' => '12345678901', + 'tax_document' => '20176996915', 'phone_area' => '71', 'phone_number' => '999999999', - 'address' => [ - 'street' => 'Rua do cliente', - 'number' => '123', - 'complement' => 'Apto. 123', - 'district' => 'Bairro do cliente', - 'city' => 'Cidade do cliente', - 'state' => 'SP', - 'zip_code' => '12345678', - ], + 'address' => ['street' => 'Rua', 'number' => '123', 'district' => 'Bairro', 'city' => 'Salvador', 'state' => 'BA', 'zip_code' => '41820330'], ], 'items' => [ - [ - 'description' => 'Produto 1', - 'quantity' => 1, - 'price' => 10000, - ], - [ - 'description' => 'Produto 2', - 'quantity' => 2, - 'price' => 5000, - ], + ['description' => 'Produto 1', 'quantity' => 1, 'price' => 10000], + ['description' => 'Produto 2', 'quantity' => 2, 'price' => 5000], ], 'payment_method' => 'credit_card', - 'credit_card' => [ - 'number' => '1234567890123456', - 'month' => '12', - 'year' => '2022', - 'cvv' => '123', - 'first_name' => 'João', - 'last_name' => 'Maria' - ], -]; - -$payment = new \Potelo\MultiPayment\MultiPayment(); -$payment->setGateway('iugu')->charge($options); + 'credit_card' => ['token' => $token], // token do gateway; na Iugu também number, month, year, cvv, first_name, last_name +], idempotencyKey: $order->uuid); ``` -| atributo | obrigatório | tipo | descrição | exemplo | -|-------------------------------|---------------------------------------------------------------------|--------------------------------|-------------------------------------------|---------------------------------------| -| `amount` | **obrigatório** caso `items` não seja informado | int | valor em centavos | `10000` | -| `customer` | **obrigatório** | array | array com os dados do cliente | `['name' => 'Nome do cliente'...]` | -| `customer.name` | **obrigatório** | string | nome do cliente | `'Nome do cliente'` | -| `customer.email` | **obrigatório** | string | email do cliente | `'joaomaria@email.com'` | -| `customer.tax_document` | | string | cpf ou cnpj do cliente | `'12345678901'` | -| `birth_date` | | string formato `yyyy-mm-dd` | data de nascimento | `'01/01/1990'` | -| `customer.phone_number` | | string | telefone | `'999999999'` | -| `customer.phone_area` | | string | DDD | `'999999999'` | -| `customer.address` | **obrigatório** para o método de pagamento `bank_slip` | array | array com os dados do endereço do cliente | `['street' => 'Rua do cliente'...]` | -| `customer.address.street` | **obrigatório** | string | nome da rua | `'Nome da rua'` | -| `customer.address.number` | **obrigatório** | string | número da casa | `'123'` | -| `customer.address.district` | **obrigatório** | string | bairro | `'Bairro do cliente'` | -| `customer.address.city` | **obrigatório** | string | cidade | `'Salvador'` | -| `customer.address.state` | **obrigatório** | string | estado | `'Bahia'` | -| `customer.address.complement` | **obrigatório** | string | complemento | `'Apto. 123'` | -| `customer.address.zip_code` | **obrigatório** | string | cep | `'12345678'` | -| `items` | **obrigatório** caso `amount` não tenha sido informado | array | array com os itens da compra | `[['description' => 'Produto 1',...` | -| `items.description` | **obrigatório** | string | descrição do item | `'Produto 1'` | -| `items.quantity` | **obrigatório** | int | quantidade do item | `1` | -| `items.price` | **obrigatório** | int | valor do item | `10000` | -| `payment_method` | | `'credit_card'`,`'bank_slip'` | método de pagamento | `'credit_card'` | -| `expires_at` | **obrigatório** caso `payment_method` seja `'bank_slip'` ou `'pix'` | string no formato `yyyy-mm-dd` | data de expiração da fatura | `2021-10-10` | -| `credit_card` | **obrigatório** caso `payment_method` seja `'credit_card'` | array | array com os dados do cartão de crédito | `['number' => '1234567890123456',...` | -| `credit_card.token` | | string | token do cartão para o gateway escolhido | `'abcdefghijklmnopqrstuvwxyz'` | -| `credit_card.number` | **obrigatório** caso `token` não tenha sido informado | string | número do cartão de crédito | `'1234567890123456'` | -| `credit_card.month` | **obrigatório** caso `token` não tenha sido informado | string | mês de expiração do cartão de crédito | `'12'` | -| `credit_card.year` | **obrigatório** caso `token` não tenha sido informado | string | ano de expiração do cartão de crédito | `'2022'` | -| `credit_card.cvv` | **obrigatório** caso `token` não tenha sido informado | string | código de segurança do cartão de crédito | `'123'` | -| `credit_card.first_name` | | string | primeiro nome no cartão de crédito | `'João'` | -| `credit_card.last_name` | | string | último nome no cartão de crédito | `'Maria'` | -| `bank_slip` | | array | array com os dados do boleto | `['expires_at' => '2022-12-31',...` | +A lista completa de chaves está no [apêndice](#apêndice-chaves-do-array-de-charge). ### Models #### Customer @@ -260,6 +1855,8 @@ echo $customer->id; // 7D96C7C932F2427CAF54F042345A13C60CD7 ``` #### Invoice ```php +use Potelo\MultiPayment\Enums\PaymentMethod; + $invoice = new Invoice(); $invoice->customer = $customer; $item = new InvoiceItem(); @@ -267,7 +1864,7 @@ $item->description = 'Teste'; $item->price = 10000; $item->quantity = 1; $invoice->items[] = $item; -$invoice->paymentMethod = Invoice::PAYMENT_METHOD_CREDIT_CARD; +$invoice->paymentMethod = PaymentMethod::CREDIT_CARD; // a string 'credit_card' também é aceita; decide como a fatura é criada $invoice->creditCard = new CreditCard(); $invoice->creditCard->number = '4111111111111111'; $invoice->creditCard->firstName = 'João'; @@ -276,6 +1873,82 @@ $invoice->creditCard->month = '11'; $invoice->creditCard->year = '2022'; $invoice->creditCard->cvv = '123'; $invoice->creditCard->customer = $customer; -$invoice->save('iugu'); +$invoice->save('iugu'); // cobra o cartão echo $invoice->id; // CB1FA9B5BD1C42B287F4AC7F6259E45D +$invoice->originType; // InvoiceOriginType::INVOICE (na Iugu sempre; no Stripe, PAYMENT_INTENT ou INVOICE) +$invoice->dueDate; // vencimento; $invoice->pixExpiresAt é a expiração do QR Code do Pix +``` +#### Refund +```php +$invoice = $payment->getInvoice($invoiceId); +$invoice->refundableAmount(); // quanto ainda pode ser estornado, em centavos +$refund = $invoice->refund(5000); // Refund; $invoice já reflete o estado posterior +$refund = $invoice->refund(); // sem valor: o restante + +$refund->amount; // 5000 +$invoice->refundedAmount; // total já estornado, só de leitura +$invoice->refunds; // Refund[] (ver "Estorno") ``` +#### Subscription +```php +$subscription = new Subscription(); +$subscription->planId = 'plano_mensal'; +$subscription->customer = $customer; +$subscription->creditCard = $card; // ou $subscription->paymentMethod = PaymentMethod::PIX +$subscription->trialDays = 7; +$subscription->save('iugu'); +echo $subscription->id; +``` +#### Plan +```php +$plan = new Plan(); +$plan->name = 'Mensal'; +$plan->identifier = 'plano_mensal'; +$plan->amount = 10000; +$plan->interval = PlanInterval::MONTH; +$plan->save('iugu'); +echo $plan->id; +``` + +## Apêndice: chaves do array de `charge()` + +Chaves aceitas por `charge(array)` (e por `Invoice::fill()`), em `snake_case`; ver +[charge (alternativa por array)](#charge-alternativa-por-array). + +| atributo | obrigatório | tipo | descrição | exemplo | +|-------------------------------|---------------------------------------------------------------------|--------------------------------|-------------------------------------------|---------------------------------------| +| `amount` | **obrigatório** caso `items` não seja informado | int | valor em centavos; junto de `items`, precisa ser a soma deles | `10000` | +| `customer` | **obrigatório** | array | array com os dados do cliente | `['name' => 'Nome do cliente'...]` | +| `customer.name` | **obrigatório** | string | nome do cliente | `'Nome do cliente'` | +| `customer.email` | **obrigatório** | string | email do cliente | `'joaomaria@email.com'` | +| `customer.tax_document` | **obrigatório** no Stripe para faturas pix | string | cpf ou cnpj do cliente | `'12345678901'` | +| `customer.birth_date` | | string formato `yyyy-mm-dd` | data de nascimento | `'1990-01-01'` | +| `customer.phone_number` | | string | telefone | `'999999999'` | +| `customer.phone_area` | | string | DDD | `'999999999'` | +| `customer.address` | **obrigatório** para o método de pagamento `bank_slip` | array | array com os dados do endereço do cliente | `['street' => 'Rua do cliente'...]` | +| `customer.address.street` | **obrigatório** | string | nome da rua | `'Nome da rua'` | +| `customer.address.number` | **obrigatório** | string | número da casa | `'123'` | +| `customer.address.district` | **obrigatório** | string | bairro | `'Bairro do cliente'` | +| `customer.address.city` | **obrigatório** | string | cidade | `'Salvador'` | +| `customer.address.state` | **obrigatório** | string | estado | `'Bahia'` | +| `customer.address.complement` | **obrigatório** | string | complemento | `'Apto. 123'` | +| `customer.address.zip_code` | **obrigatório** | string | cep | `'12345678'` | +| `items` | **obrigatório** caso `amount` não tenha sido informado | array | array com os itens da compra | `[['description' => 'Produto 1',...` | +| `items.description` | **obrigatório** | string | descrição do item | `'Produto 1'` | +| `items.quantity` | **obrigatório** | int | quantidade do item | `1` | +| `items.price` | **obrigatório** | int | valor do item | `10000` | +| `payment_method` | **obrigatório** no Stripe quando não há `available_payment_methods` nem `credit_card` | `PaymentMethod` ou a string `'credit_card'`, `'bank_slip'`, `'pix'` | método com que a fatura é criada quando `available_payment_methods` está vazia | `'credit_card'` | +| `available_payment_methods` | | array de `PaymentMethod` ou de strings | métodos aceitos pela fatura (mais de um só na Iugu); tem precedência sobre `payment_method` | `['pix']` | +| `due_date` | | string em `yyyy-mm-dd` ou ISO 8601 | vencimento (ver [Datas da fatura](#datas-da-fatura)); na Iugu, hoje quando omitido | `'2026-10-10'` | +| `pix_expires_at` | | string em ISO 8601 | expiração do QR Code do Pix (Stripe: entre 10 segundos e 14 dias no futuro) | `'2026-10-10T18:00:00-03:00'` | +| `expires_at` | obsoleto desde 2026-09-02 | string em `yyyy-mm-dd` | alias de `due_date`, com aviso `E_USER_DEPRECATED` | `'2026-10-10'` | +| `credit_card` | **obrigatório** caso `payment_method` seja `'credit_card'`; sozinho, implica cartão | array | array com os dados do cartão de crédito | `['token' => 'pm_...']` | +| `credit_card.token` | | string | token do cartão para o gateway escolhido | `'abc123...'` (Iugu) / `'pm_...'` (Stripe) | +| `credit_card.number` | **obrigatório** caso `token` não tenha sido informado (somente Iugu — o Stripe é token-only) | string | número do cartão de crédito | `'1234567890123456'` | +| `credit_card.month` | **obrigatório** caso `token` não tenha sido informado (somente Iugu) | string | mês de expiração do cartão de crédito | `'12'` | +| `credit_card.year` | **obrigatório** caso `token` não tenha sido informado (somente Iugu) | string | ano de expiração do cartão de crédito | `'2022'` | +| `credit_card.cvv` | **obrigatório** caso `token` não tenha sido informado (somente Iugu) | string | código de segurança do cartão de crédito | `'123'` | +| `credit_card.first_name` | | string | primeiro nome no cartão de crédito | `'João'` | +| `credit_card.last_name` | | string | último nome no cartão de crédito | `'Maria'` | +| `bank_slip` | | array | dados do boleto devolvidos na leitura (`url`, `number`, `barcode_data`, `barcode_image`) | `['number' => '...', 'url' => '...']` | +| `gateway_options` | | array | opções específicas do gateway mescladas ao payload (ver [Opções extras do gateway](#opções-extras-do-gateway)) | `['expires_in' => 3]` | diff --git a/composer.json b/composer.json index acd290a..95ba054 100644 --- a/composer.json +++ b/composer.json @@ -29,11 +29,13 @@ } }, "minimum-stability": "dev", + "prefer-stable": true, "scripts": { "test": [ "Composer\\Config::disableProcessTimeout", "phpunit" - ] + ], + "capabilities:table": "php scripts/capabilities-table.php" }, "repositories": [ { @@ -42,13 +44,17 @@ } ], "require": { - "php": "^8.0|^8.1|^8.2|^8.3", - "illuminate/config": "^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0", - "iugu/iugu": "dev-master" + "php": "^8.3", + "illuminate/config": "^10.0|^11.0|^12.0", + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/cache": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "iugu/iugu": "1.1.0", + "stripe/stripe-php": "^21.3" }, "require-dev": { - "phpunit/phpunit": "^9.5", - "orchestra/testbench": "~8.0" + "phpunit/phpunit": "^12.0", + "orchestra/testbench": "^10.0" } } diff --git a/composer.lock b/composer.lock index ba8fdc6..e2778a1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,29 +4,29 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "05e0cd64d25873188e9b879f62942d57", + "content-hash": "bf4cfaeca671ab935e649bc6319d99e4", "packages": [ { "name": "brick/math", - "version": "0.12.3", + "version": "0.14.8", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba" + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/866551da34e9a618e64a819ee1e01c20d8a588ba", - "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba", + "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", "shasum": "" }, "require": { - "php": "^8.1" + "php": "^8.2" }, "require-dev": { "php-coveralls/php-coveralls": "^2.2", - "phpunit/phpunit": "^10.1", - "vimeo/psalm": "6.8.8" + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" }, "type": "library", "autoload": { @@ -56,7 +56,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.12.3" + "source": "https://github.com/brick/math/tree/0.14.8" }, "funding": [ { @@ -64,30 +64,30 @@ "type": "github" } ], - "time": "2025-02-28T13:11:00+00:00" + "time": "2026-02-10T14:33:43+00:00" }, { "name": "carbonphp/carbon-doctrine-types", - "version": "2.1.0", + "version": "3.2.0", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", - "reference": "99f76ffa36cce3b70a4a6abce41dba15ca2e84cb" + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/99f76ffa36cce3b70a4a6abce41dba15ca2e84cb", - "reference": "99f76ffa36cce3b70a4a6abce41dba15ca2e84cb", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0" + "php": "^8.1" }, "conflict": { - "doctrine/dbal": "<3.7.0 || >=4.0.0" + "doctrine/dbal": "<4.0.0 || >=5.0.0" }, "require-dev": { - "doctrine/dbal": "^3.7.0", + "doctrine/dbal": "^4.0.0", "nesbot/carbon": "^2.71.0 || ^3.0.0", "phpunit/phpunit": "^10.3" }, @@ -117,7 +117,7 @@ ], "support": { "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", - "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/2.1.0" + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" }, "funding": [ { @@ -133,11 +133,11 @@ "type": "tidelift" } ], - "time": "2023-12-11T17:09:12+00:00" + "time": "2024-02-09T16:56:22+00:00" }, { "name": "dflydev/dot-access-data", - "version": "dev-main", + "version": "v3.0.3", "source": { "type": "git", "url": "https://github.com/dflydev/dflydev-dot-access-data.git", @@ -159,7 +159,6 @@ "squizlabs/php_codesniffer": "^3.5", "vimeo/psalm": "^4.0.0" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -213,27 +212,27 @@ }, { "name": "doctrine/inflector", - "version": "2.1.x-dev", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/doctrine/inflector.git", - "reference": "02ab81d3a147210a2bb1ceaccf014aee099f54a9" + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/inflector/zipball/02ab81d3a147210a2bb1ceaccf014aee099f54a9", - "reference": "02ab81d3a147210a2bb1ceaccf014aee099f54a9", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", "shasum": "" }, "require": { "php": "^7.2 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^11.0", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", - "phpstan/phpstan-strict-rules": "^1.3", - "phpunit/phpunit": "^8.5 || ^9.5" + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" }, "type": "library", "autoload": { @@ -283,7 +282,7 @@ ], "support": { "issues": "https://github.com/doctrine/inflector/issues", - "source": "https://github.com/doctrine/inflector/tree/2.1.x" + "source": "https://github.com/doctrine/inflector/tree/2.1.0" }, "funding": [ { @@ -299,20 +298,20 @@ "type": "tidelift" } ], - "time": "2025-06-27T06:44:27+00:00" + "time": "2025-08-10T19:31:58+00:00" }, { "name": "doctrine/lexer", - "version": "3.1.x-dev", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/doctrine/lexer.git", - "reference": "042e47e28c5e03f1cf6772fdf3dd4e0785433e05" + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/lexer/zipball/042e47e28c5e03f1cf6772fdf3dd4e0785433e05", - "reference": "042e47e28c5e03f1cf6772fdf3dd4e0785433e05", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", "shasum": "" }, "require": { @@ -360,7 +359,7 @@ ], "support": { "issues": "https://github.com/doctrine/lexer/issues", - "source": "https://github.com/doctrine/lexer/tree/3.1.x" + "source": "https://github.com/doctrine/lexer/tree/3.0.1" }, "funding": [ { @@ -376,34 +375,33 @@ "type": "tidelift" } ], - "time": "2024-07-29T08:29:21+00:00" + "time": "2024-02-05T11:56:58+00:00" }, { "name": "dragonmantank/cron-expression", - "version": "dev-master", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/dragonmantank/cron-expression.git", - "reference": "f37e405332cd1cca9d287f5044ae2a72c2f53239" + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/f37e405332cd1cca9d287f5044ae2a72c2f53239", - "reference": "f37e405332cd1cca9d287f5044ae2a72c2f53239", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", "shasum": "" }, "require": { - "php": "^7.2|^8.0" + "php": "^8.2|^8.3|^8.4|^8.5" }, "replace": { "mtdowling/cron-expression": "^1.0" }, "require-dev": { - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^1.0", - "phpunit/phpunit": "^7.0|^8.0|^9.0" + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -433,7 +431,7 @@ ], "support": { "issues": "https://github.com/dragonmantank/cron-expression/issues", - "source": "https://github.com/dragonmantank/cron-expression/tree/master" + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" }, "funding": [ { @@ -441,11 +439,11 @@ "type": "github" } ], - "time": "2024-10-21T12:57:55+00:00" + "time": "2025-10-31T18:51:33+00:00" }, { "name": "egulias/email-validator", - "version": "4.x-dev", + "version": "4.0.4", "source": { "type": "git", "url": "https://github.com/egulias/EmailValidator.git", @@ -469,7 +467,6 @@ "suggest": { "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -513,28 +510,27 @@ }, { "name": "fruitcake/php-cors", - "version": "dev-master", + "version": "v1.4.0", "source": { "type": "git", "url": "https://github.com/fruitcake/php-cors.git", - "reference": "0eaf5f588eb34f15a6eca4fd8e62a35cbee43f64" + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/0eaf5f588eb34f15a6eca4fd8e62a35cbee43f64", - "reference": "0eaf5f588eb34f15a6eca4fd8e62a35cbee43f64", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", "shasum": "" }, "require": { - "php": "^7.4|^8.0", - "symfony/http-foundation": "^4.4|^5.4|^6|^7" + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" }, "require-dev": { "phpstan/phpstan": "^2", "phpunit/phpunit": "^9", - "squizlabs/php_codesniffer": "^3.5" + "squizlabs/php_codesniffer": "^4" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -569,7 +565,7 @@ ], "support": { "issues": "https://github.com/fruitcake/php-cors/issues", - "source": "https://github.com/fruitcake/php-cors/tree/master" + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" }, "funding": [ { @@ -581,30 +577,29 @@ "type": "github" } ], - "time": "2025-03-13T13:43:06+00:00" + "time": "2025-12-03T09:33:47+00:00" }, { "name": "graham-campbell/result-type", - "version": "1.1.x-dev", + "version": "v1.2.0", "source": { "type": "git", "url": "https://github.com/GrahamCampbell/Result-Type.git", - "reference": "bdd52c41913b414f4ca7dcb34482babcd0e9bd58" + "reference": "adccca3324eece92ca35463648c12b9e6293c05b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/bdd52c41913b414f4ca7dcb34482babcd0e9bd58", - "reference": "bdd52c41913b414f4ca7dcb34482babcd0e9bd58", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/adccca3324eece92ca35463648c12b9e6293c05b", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.3" + "phpoption/phpoption": "^1.10" }, "require-dev": { - "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + "phpunit/phpunit": "^8.5.52 || ^9.6.34 || ^10.5.63 || ^11.5.55 || ^12.5.14" }, - "default-branch": true, "type": "library", "autoload": { "psr-4": { @@ -632,7 +627,7 @@ ], "support": { "issues": "https://github.com/GrahamCampbell/Result-Type/issues", - "source": "https://github.com/GrahamCampbell/Result-Type/tree/1.1" + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.2.0" }, "funding": [ { @@ -644,32 +639,362 @@ "type": "tidelift" } ], - "time": "2025-03-02T21:31:24+00:00" + "time": "2026-08-24T09:06:52+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.15.5", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ee80339fd9177ba44c49cdb653ff02a4d1106b9a", + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.5.3", + "guzzlehttp/psr7": "^2.13.1", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.7", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.15.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:21:06+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.5.3", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/cde49999552d185d64715fe9c1f77a2aadd2f9f1", + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.5.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:11:28+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.13.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "95e7828100de18b4e269fb1703be530082d5166d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/95e7828100de18b4e269fb1703be530082d5166d", + "reference": "95e7828100de18b4e269fb1703be530082d5166d", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.13.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:13:11+00:00" }, { "name": "guzzlehttp/uri-template", - "version": "1.0.x-dev", + "version": "v1.0.11", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "30e286560c137526eccd4ce21b2de477ab0676d2" + "reference": "d0058dccf4299d70c3d9da3378b8908b32780368" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/30e286560c137526eccd4ce21b2de477ab0676d2", - "reference": "30e286560c137526eccd4ce21b2de477ab0676d2", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/d0058dccf4299d70c3d9da3378b8908b32780368", + "reference": "d0058dccf4299d70c3d9da3378b8908b32780368", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "symfony/polyfill-php80": "^1.24" + "symfony/polyfill-php80": "^1.25" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.36 || ^9.6.15", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", "uri-template/tests": "1.0.0" }, - "default-branch": true, "type": "library", "extra": { "bamarni-bin": { @@ -715,7 +1040,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.4" + "source": "https://github.com/guzzle/uri-template/tree/v1.0.11" }, "funding": [ { @@ -731,15 +1056,15 @@ "type": "tidelift" } ], - "time": "2025-02-03T10:55:03+00:00" + "time": "2026-08-24T09:15:32+00:00" }, { "name": "iugu/iugu", - "version": "dev-master", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/Potelo/iugu-php.git", - "reference": "1d9a34e428131f8e53e70543d347959e7f65be92" + "reference": "b8a3acfbba84caaab4ab932ec4da2cbab6239166" }, "require": { "ext-curl": "*", @@ -751,7 +1076,6 @@ "php-vcr/php-vcr": "^1.4", "phpunit/phpunit": "^6" }, - "default-branch": true, "type": "library", "autoload": { "classmap": [ @@ -779,27 +1103,27 @@ "iugu", "pagamentos" ], - "time": "2025-07-01T00:06:54+00:00" + "time": "2026-09-02T15:54:28+00:00" }, { "name": "laravel/framework", - "version": "10.x-dev", + "version": "v12.69.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "37455bbd9ece2ab48443b4ad2af85abf2140e326" + "reference": "0c07b0b1f88af44d8558ffadf66900a860f93c23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/37455bbd9ece2ab48443b4ad2af85abf2140e326", - "reference": "37455bbd9ece2ab48443b4ad2af85abf2140e326", + "url": "https://api.github.com/repos/laravel/framework/zipball/0c07b0b1f88af44d8558ffadf66900a860f93c23", + "reference": "0c07b0b1f88af44d8558ffadf66900a860f93c23", "shasum": "" }, "require": { - "brick/math": "^0.9.3|^0.10.2|^0.11|^0.12", + "brick/math": "^0.11|^0.12|^0.13|^0.14", "composer-runtime-api": "^2.2", "doctrine/inflector": "^2.0.5", - "dragonmantank/cron-expression": "^3.3.2", + "dragonmantank/cron-expression": "^3.4", "egulias/email-validator": "^3.2.1|^4.0", "ext-ctype": "*", "ext-filter": "*", @@ -808,44 +1132,47 @@ "ext-openssl": "*", "ext-session": "*", "ext-tokenizer": "*", - "fruitcake/php-cors": "^1.2", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2", "guzzlehttp/uri-template": "^1.0", - "laravel/prompts": "^0.1.9", - "laravel/serializable-closure": "^1.3", - "league/commonmark": "^2.2.1", - "league/flysystem": "^3.8.0", + "laravel/prompts": "^0.3.0", + "laravel/serializable-closure": "^1.3|^2.0", + "league/commonmark": "^2.8.1", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", "monolog/monolog": "^3.0", - "nesbot/carbon": "^2.67", - "nunomaduro/termwind": "^1.13", - "php": "^8.1", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.2", "psr/container": "^1.1.1|^2.0.1", "psr/log": "^1.0|^2.0|^3.0", "psr/simple-cache": "^1.0|^2.0|^3.0", "ramsey/uuid": "^4.7", - "symfony/console": "^6.2", - "symfony/error-handler": "^6.2", - "symfony/finder": "^6.2", - "symfony/http-foundation": "^6.4", - "symfony/http-kernel": "^6.2", - "symfony/mailer": "^6.2", - "symfony/mime": "^6.2", - "symfony/process": "^6.2", - "symfony/routing": "^6.2", - "symfony/uid": "^6.2", - "symfony/var-dumper": "^6.2", + "symfony/console": "^7.2.0", + "symfony/error-handler": "^7.2.0", + "symfony/finder": "^7.2.0", + "symfony/http-foundation": "^7.2.0", + "symfony/http-kernel": "^7.2.0", + "symfony/mailer": "^7.2.0", + "symfony/mime": "^7.2.0", + "symfony/polyfill-php83": "^1.33", + "symfony/polyfill-php84": "^1.34", + "symfony/polyfill-php85": "^1.34", + "symfony/process": "^7.2.0", + "symfony/routing": "^7.2.0", + "symfony/uid": "^7.2.0", + "symfony/var-dumper": "^7.2.0", "tijsverkoyen/css-to-inline-styles": "^2.2.5", - "vlucas/phpdotenv": "^5.4.1", - "voku/portable-ascii": "^2.0" + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" }, "conflict": { - "carbonphp/carbon-doctrine-types": ">=3.0", - "doctrine/dbal": ">=4.0", - "mockery/mockery": "1.6.8", - "phpunit/phpunit": ">=11.0.0", "tightenco/collect": "<5.5.33" }, "provide": { "psr/container-implementation": "1.1|2.0", + "psr/log-implementation": "1.0|2.0|3.0", "psr/simple-cache-implementation": "1.0|2.0|3.0" }, "replace": { @@ -854,6 +1181,7 @@ "illuminate/bus": "self.version", "illuminate/cache": "self.version", "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", "illuminate/conditionable": "self.version", "illuminate/config": "self.version", "illuminate/console": "self.version", @@ -866,6 +1194,7 @@ "illuminate/filesystem": "self.version", "illuminate/hashing": "self.version", "illuminate/http": "self.version", + "illuminate/json-schema": "self.version", "illuminate/log": "self.version", "illuminate/macroable": "self.version", "illuminate/mail": "self.version", @@ -875,42 +1204,47 @@ "illuminate/process": "self.version", "illuminate/queue": "self.version", "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", "illuminate/routing": "self.version", "illuminate/session": "self.version", "illuminate/support": "self.version", "illuminate/testing": "self.version", "illuminate/translation": "self.version", "illuminate/validation": "self.version", - "illuminate/view": "self.version" + "illuminate/view": "self.version", + "spatie/once": "*" }, "require-dev": { "ably/ably-php": "^1.0", - "aws/aws-sdk-php": "^3.235.5", - "doctrine/dbal": "^3.5.1", + "aws/aws-sdk-php": "^3.322.9", "ext-gmp": "*", - "fakerphp/faker": "^1.21", - "guzzlehttp/guzzle": "^7.5", - "league/flysystem-aws-s3-v3": "^3.0", - "league/flysystem-ftp": "^3.0", - "league/flysystem-path-prefixing": "^3.3", - "league/flysystem-read-only": "^3.3", - "league/flysystem-sftp-v3": "^3.0", - "mockery/mockery": "^1.5.1", - "nyholm/psr7": "^1.2", - "orchestra/testbench-core": "^8.23.4", - "pda/pheanstalk": "^4.0", - "phpstan/phpstan": "~1.11.11", - "phpunit/phpunit": "^10.0.7", - "predis/predis": "^2.0.2", - "symfony/cache": "^6.2", - "symfony/http-client": "^6.2.4", - "symfony/psr-http-message-bridge": "^2.0" + "fakerphp/faker": "^1.24", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/psr7": "^2.4", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^10.9.0", + "pda/pheanstalk": "^5.0.6|^7.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "^2.1.41", + "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", + "predis/predis": "^2.3|^3.0", + "resend/resend-php": "^0.10.0|^1.0", + "symfony/cache": "^7.2.0", + "symfony/http-client": "^7.2.0", + "symfony/psr-http-message-bridge": "^7.2.0", + "symfony/translation": "^7.2.0" }, "suggest": { "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", - "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.235.5).", - "brianium/paratest": "Required to run tests in parallel (^6.0).", - "doctrine/dbal": "Required to rename columns and drop SQLite columns (^3.5.1).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0|^8.0).", "ext-apcu": "Required to use the APC cache driver.", "ext-fileinfo": "Required to use the Filesystem class.", "ext-ftp": "Required to use the Flysystem FTP driver.", @@ -919,42 +1253,46 @@ "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", "ext-pdo": "Required to use all database features.", "ext-posix": "Required to use all features of the queue worker.", - "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0).", - "fakerphp/faker": "Required to use the eloquent factory builder (^1.9.1).", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", "filp/whoops": "Required for friendly error pages in development (^2.14.3).", - "guzzlehttp/guzzle": "Required to use the HTTP Client and the ping methods on schedules (^7.5).", "laravel/tinker": "Required to use the tinker console command (^2.0).", - "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.0).", - "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.0).", - "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.3).", - "league/flysystem-read-only": "Required to use read-only disks (^3.3)", - "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.0).", - "mockery/mockery": "Required to use mocking (^1.5.1).", - "nyholm/psr7": "Required to use PSR-7 bridging features (^1.2).", - "pda/pheanstalk": "Required to use the beanstalk queue driver (^4.0).", - "phpunit/phpunit": "Required to use assertions and run tests (^9.5.8|^10.0.7).", - "predis/predis": "Required to use the predis connector (^2.0.2).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", + "predis/predis": "Required to use the predis connector (^2.3|^3.0).", "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", - "symfony/cache": "Required to PSR-6 cache bridge (^6.2).", - "symfony/filesystem": "Required to enable support for relative symbolic links (^6.2).", - "symfony/http-client": "Required to enable support for the Symfony API mail transports (^6.2).", - "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^6.2).", - "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^6.2).", - "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^2.0)." + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0|^1.0).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.2).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.2).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.2).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)." }, "type": "library", "extra": { "branch-alias": { - "dev-master": "10.x-dev" + "dev-master": "12.x-dev" } }, "autoload": { "files": [ + "src/Illuminate/Collections/functions.php", "src/Illuminate/Collections/helpers.php", "src/Illuminate/Events/functions.php", "src/Illuminate/Filesystem/functions.php", "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", + "src/Illuminate/Support/functions.php", "src/Illuminate/Support/helpers.php" ], "psr-4": { @@ -962,7 +1300,8 @@ "Illuminate\\Support\\": [ "src/Illuminate/Macroable/", "src/Illuminate/Collections/", - "src/Illuminate/Conditionable/" + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" ] } }, @@ -986,37 +1325,38 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-03-24T11:51:20+00:00" + "time": "2026-09-01T21:34:37+00:00" }, { "name": "laravel/prompts", - "version": "v0.1.25", + "version": "v0.3.24", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "7b4029a84c37cb2725fc7f011586e2997040bc95" + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/7b4029a84c37cb2725fc7f011586e2997040bc95", - "reference": "7b4029a84c37cb2725fc7f011586e2997040bc95", + "url": "https://api.github.com/repos/laravel/prompts/zipball/5d3cdef29e93ca3b62b1871359db3078cd99908b", + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b", "shasum": "" }, "require": { + "composer-runtime-api": "^2.2", "ext-mbstring": "*", - "illuminate/collections": "^10.0|^11.0", "php": "^8.1", - "symfony/console": "^6.2|^7.0" + "symfony/console": "^6.2|^7.0|^8.0" }, "conflict": { "illuminate/console": ">=10.17.0 <10.25.0", "laravel/framework": ">=10.17.0 <10.25.0" }, "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", "mockery/mockery": "^1.5", - "pestphp/pest": "^2.3", - "phpstan/phpstan": "^1.11", - "phpstan/phpstan-mockery": "^1.1" + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" }, "suggest": { "ext-pcntl": "Required for the spinner to be animated." @@ -1024,7 +1364,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "0.1.x-dev" + "dev-main": "0.3.x-dev" } }, "autoload": { @@ -1042,38 +1382,38 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.1.25" + "source": "https://github.com/laravel/prompts/tree/v0.3.24" }, - "time": "2024-08-12T22:06:33+00:00" + "time": "2026-08-20T12:55:36+00:00" }, { "name": "laravel/serializable-closure", - "version": "1.x-dev", + "version": "v2.0.16", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "8be68b9a31863c40ccccac14f7e885e274b8ea62" + "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/8be68b9a31863c40ccccac14f7e885e274b8ea62", - "reference": "8be68b9a31863c40ccccac14f7e885e274b8ea62", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", + "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", "shasum": "" }, "require": { - "php": "^7.3|^8.0" + "php": "^8.1" }, "require-dev": { - "illuminate/support": "^8.0|^9.0|^10.0|^11.0", - "nesbot/carbon": "^2.61|^3.0", - "pestphp/pest": "^1.21.3", - "phpstan/phpstan": "^1.8.2", - "symfony/var-dumper": "^5.4.11|^6.2.0|^7.0.0" + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.x-dev" + "dev-master": "2.x-dev" } }, "autoload": { @@ -1105,20 +1445,20 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2024-11-19T20:48:19+00:00" + "time": "2026-08-18T20:28:54+00:00" }, { "name": "league/commonmark", - "version": "dev-main", + "version": "2.10.0", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "6fbb36d44824ed4091adbcf4c7d4a3923cdb3405" + "reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6fbb36d44824ed4091adbcf4c7d4a3923cdb3405", - "reference": "6fbb36d44824ed4091adbcf4c7d4a3923cdb3405", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4", + "reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4", "shasum": "" }, "require": { @@ -1140,14 +1480,14 @@ "github/gfm": "0.29.0", "michelf/php-markdown": "^1.4 || ^2.0", "nyholm/psr7": "^1.5", - "phpstan/phpstan": "^1.8.2", - "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0", "scrutinizer/ocular": "^1.8.1", - "symfony/finder": "^5.3 | ^6.0 | ^7.0", - "symfony/process": "^5.4 | ^6.0 | ^7.0", - "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", "unleashedtech/php-coding-standard": "^3.1.1", - "vimeo/psalm": "^4.24.0 || ^5.0.0" + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" }, "suggest": { "symfony/yaml": "v2.3+ required if using the Front Matter extension" @@ -1155,7 +1495,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.8-dev" + "dev-main": "2.11-dev" } }, "autoload": { @@ -1212,20 +1552,20 @@ "type": "tidelift" } ], - "time": "2025-05-05T12:20:28+00:00" + "time": "2026-08-11T16:06:25+00:00" }, { "name": "league/config", - "version": "dev-main", + "version": "v1.2.0", "source": { "type": "git", "url": "https://github.com/thephpleague/config.git", - "reference": "708b87250055f20a21cc6dbe232369b79a71a4fa" + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/config/zipball/708b87250055f20a21cc6dbe232369b79a71a4fa", - "reference": "708b87250055f20a21cc6dbe232369b79a71a4fa", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", "shasum": "" }, "require": { @@ -1235,12 +1575,11 @@ }, "require-dev": { "phpstan/phpstan": "^1.8.2", - "phpunit/phpunit": "^9.5.5 || ^10.0.0 || ^11.0.0", + "phpunit/phpunit": "^9.5.5", "scrutinizer/ocular": "^1.8.1", "unleashedtech/php-coding-standard": "^3.1", "vimeo/psalm": "^4.7.3" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -1295,20 +1634,20 @@ "type": "github" } ], - "time": "2024-09-23T00:17:42+00:00" + "time": "2022-12-11T20:36:23+00:00" }, { "name": "league/flysystem", - "version": "3.x-dev", + "version": "3.35.3", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "2203e3151755d874bb2943649dae1eb8533ac93e" + "reference": "5fc8404762179ae514678487b23494fd69b2309c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2203e3151755d874bb2943649dae1eb8533ac93e", - "reference": "2203e3151755d874bb2943649dae1eb8533ac93e", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/5fc8404762179ae514678487b23494fd69b2309c", + "reference": "5fc8404762179ae514678487b23494fd69b2309c", "shasum": "" }, "require": { @@ -1344,7 +1683,6 @@ "phpunit/phpunit": "^9.5.11|^10.0", "sabre/dav": "^4.6.0" }, - "default-branch": true, "type": "library", "autoload": { "psr-4": { @@ -1377,22 +1715,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.30.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.35.3" }, - "time": "2025-06-25T13:29:59+00:00" + "time": "2026-08-22T12:55:54+00:00" }, { "name": "league/flysystem-local", - "version": "3.x-dev", + "version": "3.35.3", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-local.git", - "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10" + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/6691915f77c7fb69adfb87dcd550052dc184ee10", - "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/a099b24dce160f3b2239043d13d47c4a1a214ea4", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4", "shasum": "" }, "require": { @@ -1401,7 +1739,6 @@ "league/mime-type-detection": "^1.0.0", "php": "^8.0.2" }, - "default-branch": true, "type": "library", "autoload": { "psr-4": { @@ -1427,22 +1764,22 @@ "local" ], "support": { - "source": "https://github.com/thephpleague/flysystem-local/tree/3.30.0" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.35.3" }, - "time": "2025-05-21T10:34:19+00:00" + "time": "2026-08-12T13:29:21+00:00" }, { "name": "league/mime-type-detection", - "version": "1.16.0", + "version": "1.17.0", "source": { "type": "git", "url": "https://github.com/thephpleague/mime-type-detection.git", - "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", - "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", "shasum": "" }, "require": { @@ -1452,7 +1789,7 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.2", "phpstan/phpstan": "^0.12.68", - "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0" }, "type": "library", "autoload": { @@ -1473,7 +1810,7 @@ "description": "Mime-type detection for Flysystem", "support": { "issues": "https://github.com/thephpleague/mime-type-detection/issues", - "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.17.0" }, "funding": [ { @@ -1485,27 +1822,209 @@ "type": "tidelift" } ], - "time": "2024-09-21T08:32:55+00:00" + "time": "2026-07-09T11:49:27+00:00" }, { - "name": "monolog/monolog", - "version": "dev-main", + "name": "league/uri", + "version": "7.8.1", "source": { "type": "git", - "url": "https://github.com/Seldaek/monolog.git", - "reference": "2e97231b969e0ffdeff03329b808945b4ba55e38" + "url": "https://github.com/thephpleague/uri.git", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/2e97231b969e0ffdeff03329b808945b4ba55e38", - "reference": "2e97231b969e0ffdeff03329b808945b4ba55e38", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", "shasum": "" }, "require": { - "php": ">=8.1", - "psr/log": "^2.0 || ^3.0" + "league/uri-interfaces": "^7.8.1", + "php": "^8.1", + "psr/http-factory": "^1" }, - "provide": { + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-08T20:05:35+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.10.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { "psr/log-implementation": "3.0.0" }, "require-dev": { @@ -1516,7 +2035,7 @@ "graylog2/gelf-php": "^1.4.2 || ^2.0", "guzzlehttp/guzzle": "^7.4.5", "guzzlehttp/psr7": "^2.2", - "mongodb/mongodb": "^1.8", + "mongodb/mongodb": "^1.8 || ^2.0", "php-amqplib/php-amqplib": "~2.4 || ^3", "php-console/php-console": "^3.1.8", "phpstan/phpstan": "^2", @@ -1545,7 +2064,6 @@ "rollbar/rollbar": "Allow sending log messages to Rollbar", "ruflin/elastica": "Allow sending log messages to an Elastic Search server" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -1577,7 +2095,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/main" + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" }, "funding": [ { @@ -1589,46 +2107,44 @@ "type": "tidelift" } ], - "time": "2025-04-03T11:44:45+00:00" + "time": "2026-01-02T08:56:05+00:00" }, { "name": "nesbot/carbon", - "version": "2.x-dev", + "version": "3.13.2", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "52474f548a433965fe30ff91bc2c641cdda75885" + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/52474f548a433965fe30ff91bc2c641cdda75885", - "reference": "52474f548a433965fe30ff91bc2c641cdda75885", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/a1c54919f5fff9800cd03c32bd01defd5a4061cb", + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb", "shasum": "" }, "require": { - "carbonphp/carbon-doctrine-types": "*", + "carbonphp/carbon-doctrine-types": "<100.0", "ext-json": "*", - "php": "^7.1.8 || ^8.0", + "php": "^8.1", "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", "symfony/polyfill-mbstring": "^1.0", - "symfony/polyfill-php80": "^1.16", - "symfony/translation": "^3.4 || ^4.0 || ^5.0 || ^6.0" + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" }, "provide": { "psr/clock-implementation": "1.0" }, "require-dev": { - "doctrine/dbal": "^2.0 || ^3.1.4 || ^4.0", - "doctrine/orm": "^2.7 || ^3.0", - "friendsofphp/php-cs-fixer": "^3.0", - "kylekatarnls/multi-tester": "^2.0", - "ondrejmirtes/better-reflection": "<6", - "phpmd/phpmd": "^2.9", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^0.12.99 || ^1.7.14", - "phpunit/php-file-iterator": "^2.0.5 || ^3.0.6", - "phpunit/phpunit": "^7.5.20 || ^8.5.26 || ^9.5.20", - "squizlabs/php_codesniffer": "^3.4" + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" }, "bin": [ "bin/carbon" @@ -1671,16 +2187,16 @@ } ], "description": "An API extension for DateTime that supports 281 different languages.", - "homepage": "https://carbon.nesbot.com", + "homepage": "https://carbonphp.github.io/carbon/", "keywords": [ "date", "datetime", "time" ], "support": { - "docs": "https://carbon.nesbot.com/docs", - "issues": "https://github.com/briannesbitt/Carbon/issues", - "source": "https://github.com/briannesbitt/Carbon" + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" }, "funding": [ { @@ -1696,29 +2212,31 @@ "type": "tidelift" } ], - "time": "2025-04-07T07:51:39+00:00" + "time": "2026-08-08T11:40:35+00:00" }, { "name": "nette/schema", - "version": "v1.3.x-dev", + "version": "v1.3.6", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "2c2f5e46be6f655f63a3bfbb1f91f867d6194bc6" + "reference": "c54350438cd6914616f790a49cb424605f421562" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/2c2f5e46be6f655f63a3bfbb1f91f867d6194bc6", - "reference": "2c2f5e46be6f655f63a3bfbb1f91f867d6194bc6", + "url": "https://api.github.com/repos/nette/schema/zipball/c54350438cd6914616f790a49cb424605f421562", + "reference": "c54350438cd6914616f790a49cb424605f421562", "shasum": "" }, "require": { "nette/utils": "^4.0", - "php": "8.1 - 8.4" + "php": "8.1 - 8.5" }, "require-dev": { - "nette/tester": "^2.5.2", - "phpstan/phpstan-nette": "^2.0@stable", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", "tracy/tracy": "^2.8" }, "type": "library", @@ -1759,26 +2277,26 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3" + "source": "https://github.com/nette/schema/tree/v1.3.6" }, - "time": "2025-06-19T17:50:07+00:00" + "time": "2026-08-16T21:58:41+00:00" }, { "name": "nette/utils", - "version": "dev-master", + "version": "v4.1.5", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "5ab770f2f467ef1081b40d21c3647a724b7809d7" + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/5ab770f2f467ef1081b40d21c3647a724b7809d7", - "reference": "5ab770f2f467ef1081b40d21c3647a724b7809d7", + "url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0", "shasum": "" }, "require": { - "php": "8.1 - 8.4" + "php": "8.2 - 8.5" }, "conflict": { "nette/finder": "<3", @@ -1786,19 +2304,20 @@ }, "require-dev": { "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", "nette/tester": "^2.5", - "phpstan/phpstan-nette": "^2.0@stable", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", "tracy/tracy": "^2.9" }, "suggest": { "ext-gd": "to use Image", - "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-iconv": "to use Strings::chr(), ord() and reverse()", "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", "ext-json": "to use Nette\\Utils\\Json", "ext-mbstring": "to use Strings::lower() etc...", "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -1849,38 +2368,37 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/master" + "source": "https://github.com/nette/utils/tree/v4.1.5" }, - "time": "2025-06-19T18:59:42+00:00" + "time": "2026-07-17T23:02:45+00:00" }, { "name": "nunomaduro/termwind", - "version": "1.x-dev", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "5369ef84d8142c1d87e4ec278711d4ece3cbf301" + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/5369ef84d8142c1d87e4ec278711d4ece3cbf301", - "reference": "5369ef84d8142c1d87e4ec278711d4ece3cbf301", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", "shasum": "" }, "require": { "ext-mbstring": "*", - "php": "^8.1", - "symfony/console": "^6.4.15" + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" }, "require-dev": { - "illuminate/console": "^10.48.24", - "illuminate/support": "^10.48.24", - "laravel/pint": "^1.18.2", - "pestphp/pest": "^2.36.0", - "pestphp/pest-plugin-mock": "2.0.0", - "phpstan/phpstan": "^1.12.11", - "phpstan/phpstan-strict-rules": "^1.6.1", - "symfony/var-dumper": "^6.4.15", + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -1889,6 +2407,9 @@ "providers": [ "Termwind\\Laravel\\TermwindServiceProvider" ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" } }, "autoload": { @@ -1909,7 +2430,7 @@ "email": "enunomaduro@gmail.com" } ], - "description": "Its like Tailwind CSS, but for the console.", + "description": "It's like Tailwind CSS, but for the console.", "keywords": [ "cli", "console", @@ -1920,7 +2441,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/1.x" + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" }, "funding": [ { @@ -1936,20 +2457,20 @@ "type": "github" } ], - "time": "2024-11-21T10:36:35+00:00" + "time": "2026-02-16T23:10:27+00:00" }, { "name": "phpoption/phpoption", - "version": "dev-master", + "version": "1.10.0", "source": { "type": "git", "url": "https://github.com/schmittjoh/php-option.git", - "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54" + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/e3fac8b24f56113f7cb96af14958c0dd16330f54", - "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d", "shasum": "" }, "require": { @@ -1957,9 +2478,8 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" + "phpunit/phpunit": "^8.5.54 || ^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.33" }, - "default-branch": true, "type": "library", "extra": { "bamarni-bin": { @@ -2000,7 +2520,7 @@ ], "support": { "issues": "https://github.com/schmittjoh/php-option/issues", - "source": "https://github.com/schmittjoh/php-option/tree/1.9.3" + "source": "https://github.com/schmittjoh/php-option/tree/1.10.0" }, "funding": [ { @@ -2012,7 +2532,7 @@ "type": "tidelift" } ], - "time": "2024-07-20T21:41:07+00:00" + "time": "2026-08-24T00:54:40+00:00" }, { "name": "psr/clock", @@ -2064,22 +2584,21 @@ }, { "name": "psr/container", - "version": "dev-master", + "version": "2.0.2", "source": { "type": "git", "url": "https://github.com/php-fig/container.git", - "reference": "707984727bd5b2b670e59559d3ed2500240cf875" + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/707984727bd5b2b670e59559d3ed2500240cf875", - "reference": "707984727bd5b2b670e59559d3ed2500240cf875", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", "shasum": "" }, "require": { "php": ">=7.4.0" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -2112,31 +2631,27 @@ ], "support": { "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container" + "source": "https://github.com/php-fig/container/tree/2.0.2" }, - "time": "2023-09-22T11:11:30+00:00" + "time": "2021-11-05T16:47:00+00:00" }, { "name": "psr/event-dispatcher", - "version": "dev-master", + "version": "1.0.0", "source": { "type": "git", "url": "https://github.com/php-fig/event-dispatcher.git", - "reference": "bbd9eacc080d33861e5b5c75b3b8c4d7e6d01874" + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/bbd9eacc080d33861e5b5c75b3b8c4d7e6d01874", - "reference": "bbd9eacc080d33861e5b5c75b3b8c4d7e6d01874", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", "shasum": "" }, "require": { "php": ">=7.2.0" }, - "suggest": { - "fig/event-dispatcher-util": "Provides some useful PSR-14 utilities" - }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -2155,7 +2670,7 @@ "authors": [ { "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "homepage": "http://www.php-fig.org/" } ], "description": "Standard interfaces for event handling.", @@ -2165,13 +2680,174 @@ "psr-14" ], "support": { - "source": "https://github.com/php-fig/event-dispatcher" + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" }, - "time": "2024-03-17T21:29:03+00:00" + "time": "2023-04-04T09:54:51+00:00" }, { "name": "psr/log", - "version": "dev-master", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", @@ -2186,7 +2862,6 @@ "require": { "php": ">=8.0.0" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -2222,22 +2897,21 @@ }, { "name": "psr/simple-cache", - "version": "dev-master", + "version": "3.0.0", "source": { "type": "git", "url": "https://github.com/php-fig/simple-cache.git", - "reference": "2d280c2aaa23a120f35d55cfde8581954a8e77fa" + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/2d280c2aaa23a120f35d55cfde8581954a8e77fa", - "reference": "2d280c2aaa23a120f35d55cfde8581954a8e77fa", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", "shasum": "" }, "require": { "php": ">=8.0.0" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -2268,9 +2942,53 @@ "simple-cache" ], "support": { - "source": "https://github.com/php-fig/simple-cache/tree/master" + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" }, - "time": "2022-04-08T16:41:45+00:00" + "time": "2019-03-08T08:55:37+00:00" }, { "name": "ramsey/collection", @@ -2350,20 +3068,20 @@ }, { "name": "ramsey/uuid", - "version": "4.x-dev", + "version": "4.9.3", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "f239d6c806d5cd2c006fbd40db8ed2a375a14f9a" + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/f239d6c806d5cd2c006fbd40db8ed2a375a14f9a", - "reference": "f239d6c806d5cd2c006fbd40db8ed2a375a14f9a", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", "shasum": "" }, "require": { - "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13", + "brick/math": ">=0.8.16 <=0.18", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -2396,7 +3114,6 @@ "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." }, - "default-branch": true, "type": "library", "extra": { "captainhook": { @@ -2415,61 +3132,201 @@ "license": [ "MIT" ], - "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.3" + }, + "time": "2026-06-18T03:57:49+00:00" + }, + { + "name": "stripe/stripe-php", + "version": "v21.3.1", + "source": { + "type": "git", + "url": "https://github.com/stripe/stripe-php.git", + "reference": "12986995cd5e229cc094d4b57de056f8e2e6e5a9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stripe/stripe-php/zipball/12986995cd5e229cc094d4b57de056f8e2e6e5a9", + "reference": "12986995cd5e229cc094d4b57de056f8e2e6e5a9", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": ">=7.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.94.0", + "phpstan/phpstan": "^1.2", + "phpunit/phpunit": "^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "files": [ + "lib/version_check.php" + ], + "psr-4": { + "Stripe\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Stripe and contributors", + "homepage": "https://github.com/stripe/stripe-php/contributors" + } + ], + "description": "Stripe PHP Library", + "homepage": "https://stripe.com/", + "keywords": [ + "api", + "payment processing", + "stripe" + ], + "support": { + "issues": "https://github.com/stripe/stripe-php/issues", + "source": "https://github.com/stripe/stripe-php/tree/v21.3.1" + }, + "time": "2026-09-01T18:42:58+00:00" + }, + { + "name": "symfony/clock", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", "keywords": [ - "guid", - "identifier", - "uuid" + "clock", + "psr20", + "time" ], "support": { - "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.x" + "source": "https://github.com/symfony/clock/tree/v7.4.8" }, - "time": "2025-06-27T02:21:05+00:00" + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/console", - "version": "6.4.x-dev", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "9056771b8eca08d026cd3280deeec3cfd99c4d93" + "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/9056771b8eca08d026cd3280deeec3cfd99c4d93", - "reference": "9056771b8eca08d026cd3280deeec3cfd99c4d93", + "url": "https://api.github.com/repos/symfony/console/zipball/23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", + "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^5.4|^6.0|^7.0" + "symfony/string": "^7.2|^8.0" }, "conflict": { - "symfony/dependency-injection": "<5.4", - "symfony/dotenv": "<5.4", - "symfony/event-dispatcher": "<5.4", - "symfony/lock": "<5.4", - "symfony/process": "<5.4" + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/lock": "^5.4|^6.0|^7.0", - "symfony/messenger": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.0|^7.0", - "symfony/stopwatch": "^5.4|^6.0|^7.0", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -2503,7 +3360,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/6.4" + "source": "https://github.com/symfony/console/tree/v7.4.18" }, "funding": [ { @@ -2514,25 +3371,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:37:22+00:00" + "time": "2026-08-25T14:18:37+00:00" }, { "name": "symfony/css-selector", - "version": "7.4.x-dev", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2" + "reference": "fecf40067fc8d8880ea87b8ac227600b9aadd1d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/601a5ce9aaad7bf10797e3663faefce9e26c24e2", - "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/fecf40067fc8d8880ea87b8ac227600b9aadd1d0", + "reference": "fecf40067fc8d8880ea87b8ac227600b9aadd1d0", "shasum": "" }, "require": { @@ -2568,7 +3429,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.3.0-RC1" + "source": "https://github.com/symfony/css-selector/tree/v7.4.18" }, "funding": [ { @@ -2579,31 +3440,34 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-08-23T10:03:40+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "dev-main", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { "php": ">=8.1" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -2611,7 +3475,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -2636,7 +3500,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -2647,40 +3511,47 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/error-handler", - "version": "6.4.x-dev", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "b088e0b175c30b4e06d8085200fa465b586f44fa" + "reference": "8373921e231e190a88e2ad526951bbaa791576fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/b088e0b175c30b4e06d8085200fa465b586f44fa", - "reference": "b088e0b175c30b4e06d8085200fa465b586f44fa", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8373921e231e190a88e2ad526951bbaa791576fa", + "reference": "8373921e231e190a88e2ad526951bbaa791576fa", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "psr/log": "^1|^2|^3", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "conflict": { "symfony/deprecation-contracts": "<2.5", "symfony/http-kernel": "<6.4" }, "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/serializer": "^5.4|^6.0|^7.0" + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" }, "bin": [ "Resources/bin/patch-type-declarations" @@ -2711,7 +3582,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/6.4" + "source": "https://github.com/symfony/error-handler/tree/v7.4.17" }, "funding": [ { @@ -2722,25 +3593,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-13T07:39:48+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/event-dispatcher", - "version": "7.4.x-dev", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "b1275294c9e511d5b1a507cb0c314a068d401d83" + "reference": "d269974ee93c61d03620ffee358355bfdb471d66" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/b1275294c9e511d5b1a507cb0c314a068d401d83", - "reference": "b1275294c9e511d5b1a507cb0c314a068d401d83", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d269974ee93c61d03620ffee358355bfdb471d66", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66", "shasum": "" }, "require": { @@ -2761,6 +3636,7 @@ "symfony/dependency-injection": "^6.4|^7.0|^8.0", "symfony/error-handler": "^6.4|^7.0|^8.0", "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", "symfony/http-foundation": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", "symfony/stopwatch": "^6.4|^7.0|^8.0" @@ -2791,7 +3667,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/7.4" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.17" }, "funding": [ { @@ -2802,32 +3678,35 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-02T14:13:49+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "dev-main", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { "php": ">=8.1", "psr/event-dispatcher": "^1" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -2835,7 +3714,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -2868,7 +3747,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, "funding": [ { @@ -2879,32 +3758,36 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/finder", - "version": "6.4.x-dev", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "1d0e8266248c5d9ab6a87e3789e6dc482af3c9c7" + "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/1d0e8266248c5d9ab6a87e3789e6dc482af3c9c7", - "reference": "1d0e8266248c5d9ab6a87e3789e6dc482af3c9c7", + "url": "https://api.github.com/repos/symfony/finder/zipball/5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", + "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "symfony/filesystem": "^6.0|^7.0" + "symfony/filesystem": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -2932,7 +3815,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/6.4" + "source": "https://github.com/symfony/finder/tree/v7.4.17" }, "funding": [ { @@ -2943,45 +3826,50 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-12-29T13:51:37+00:00" + "time": "2026-08-21T12:09:28+00:00" }, { "name": "symfony/http-foundation", - "version": "6.4.x-dev", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "452d19f945ee41345fd8a50c18b60783546b7bd3" + "reference": "d070b716a32fbe3bf04204db0f58ace73b86d133" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/452d19f945ee41345fd8a50c18b60783546b7bd3", - "reference": "452d19f945ee41345fd8a50c18b60783546b7bd3", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/d070b716a32fbe3bf04204db0f58ace73b86d133", + "reference": "d070b716a32fbe3bf04204db0f58ace73b86d133", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.1", - "symfony/polyfill-php83": "^1.27" + "symfony/polyfill-mbstring": "^1.1" }, "conflict": { + "doctrine/dbal": "<3.6", "symfony/cache": "<6.4.12|>=7.0,<7.1.5" }, "require-dev": { - "doctrine/dbal": "^2.13.1|^3|^4", + "doctrine/dbal": "^3.6|^4", "predis/predis": "^1.1|^2.0", - "symfony/cache": "^6.4.12|^7.1.5", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/http-kernel": "^5.4.12|^6.0.12|^6.1.4|^7.0", - "symfony/mime": "^5.4|^6.0|^7.0", - "symfony/rate-limiter": "^5.4|^6.0|^7.0" + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -3009,7 +3897,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/6.4" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.18" }, "funding": [ { @@ -3020,82 +3908,87 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-26T09:17:58+00:00" + "time": "2026-08-30T20:10:52+00:00" }, { "name": "symfony/http-kernel", - "version": "6.4.x-dev", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "9e9c725272b51aaddb863dcd59ca3ab4070a6df3" + "reference": "275d2d2d24530f2a0eaf17704a3a93860a036351" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/9e9c725272b51aaddb863dcd59ca3ab4070a6df3", - "reference": "9e9c725272b51aaddb863dcd59ca3ab4070a6df3", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/275d2d2d24530f2a0eaf17704a3a93860a036351", + "reference": "275d2d2d24530f2a0eaf17704a3a93860a036351", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^6.4|^7.0", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^6.4|^7.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/browser-kit": "<5.4", - "symfony/cache": "<5.4", - "symfony/config": "<6.1", - "symfony/console": "<5.4", + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", "symfony/dependency-injection": "<6.4", - "symfony/doctrine-bridge": "<5.4", - "symfony/form": "<5.4", - "symfony/http-client": "<5.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", "symfony/http-client-contracts": "<2.5", - "symfony/mailer": "<5.4", - "symfony/messenger": "<5.4", - "symfony/translation": "<5.4", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", "symfony/translation-contracts": "<2.5", - "symfony/twig-bridge": "<5.4", + "symfony/twig-bridge": "<6.4", "symfony/validator": "<6.4", - "symfony/var-dumper": "<6.3", - "twig/twig": "<2.13" + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/cache": "^1.0|^2.0|^3.0", - "symfony/browser-kit": "^5.4|^6.0|^7.0", - "symfony/clock": "^6.2|^7.0", - "symfony/config": "^6.1|^7.0", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/css-selector": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/dom-crawler": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", "symfony/http-client-contracts": "^2.5|^3", - "symfony/process": "^5.4|^6.0|^7.0", - "symfony/property-access": "^5.4.5|^6.0.5|^7.0", - "symfony/routing": "^5.4|^6.0|^7.0", - "symfony/serializer": "^6.4.4|^7.0.4", - "symfony/stopwatch": "^5.4|^6.0|^7.0", - "symfony/translation": "^5.4|^6.0|^7.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/uid": "^5.4|^6.0|^7.0", - "symfony/validator": "^6.4|^7.0", - "symfony/var-dumper": "^5.4|^6.4|^7.0", - "symfony/var-exporter": "^6.2|^7.0", - "twig/twig": "^2.13|^3.0.4" + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" }, "type": "library", "autoload": { @@ -3123,7 +4016,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/6.4" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.18" }, "funding": [ { @@ -3134,48 +4027,52 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-13T07:10:59+00:00" + "time": "2026-08-30T21:24:29+00:00" }, { "name": "symfony/mailer", - "version": "6.4.x-dev", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "a480322ddf8e54de262c9bca31fdcbe26b553de5" + "reference": "b17c9bf3a551d5f635638a3b6c05f06c4dc87584" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/a480322ddf8e54de262c9bca31fdcbe26b553de5", - "reference": "a480322ddf8e54de262c9bca31fdcbe26b553de5", + "url": "https://api.github.com/repos/symfony/mailer/zipball/b17c9bf3a551d5f635638a3b6c05f06c4dc87584", + "reference": "b17c9bf3a551d5f635638a3b6c05f06c4dc87584", "shasum": "" }, "require": { "egulias/email-validator": "^2.1.10|^3|^4", - "php": ">=8.1", + "php": ">=8.2", "psr/event-dispatcher": "^1", "psr/log": "^1|^2|^3", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", - "symfony/mime": "^6.2|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", "symfony/service-contracts": "^2.5|^3" }, "conflict": { "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<5.4", - "symfony/messenger": "<6.2", - "symfony/mime": "<6.2", - "symfony/twig-bridge": "<6.2.1" + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" }, "require-dev": { - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/http-client": "^5.4|^6.0|^7.0", - "symfony/messenger": "^6.2|^7.0", - "symfony/twig-bridge": "^6.2|^7.0" + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -3203,7 +4100,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/6.4" + "source": "https://github.com/symfony/mailer/tree/v7.4.17" }, "funding": [ { @@ -3214,49 +4111,53 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-26T21:24:02+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/mime", - "version": "6.4.x-dev", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "fec8aa5231f3904754955fad33c2db50594d22d1" + "reference": "bf328d82105831db3e409195db0540ff57f27c80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/fec8aa5231f3904754955fad33c2db50594d22d1", - "reference": "fec8aa5231f3904754955fad33c2db50594d22d1", + "url": "https://api.github.com/repos/symfony/mime/zipball/bf328d82105831db3e409195db0540ff57f27c80", + "reference": "bf328d82105831db3e409195db0540ff57f27c80", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "^1.0" }, "conflict": { "egulias/email-validator": "~3.0.0", - "phpdocumentor/reflection-docblock": "<3.2.2", - "phpdocumentor/type-resolver": "<1.4.0", - "symfony/mailer": "<5.4", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/mailer": "<6.4", "symfony/serializer": "<6.4.3|>7.0,<7.0.3" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", - "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.4|^7.0", - "symfony/property-access": "^5.4|^6.0|^7.0", - "symfony/property-info": "^5.4|^6.0|^7.0", - "symfony/serializer": "^6.4.3|^7.0.3" + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.44|^7.4.17|^8.1.5" }, "type": "library", "autoload": { @@ -3288,7 +4189,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/6.4" + "source": "https://github.com/symfony/mime/tree/v7.4.18" }, "funding": [ { @@ -3299,25 +4200,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-27T13:27:38+00:00" + "time": "2026-08-22T09:04:42+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "1.x-dev", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -3329,7 +4234,6 @@ "suggest": { "ext-ctype": "For best performance" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -3368,7 +4272,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -3379,25 +4283,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "1.x-dev", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", "shasum": "" }, "require": { @@ -3406,7 +4314,6 @@ "suggest": { "ext-intl": "For best performance" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -3447,7 +4354,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/1.x" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" }, "funding": [ { @@ -3458,25 +4365,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T09:58:17+00:00" + "time": "2026-07-28T08:25:59+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "1.x-dev", + "version": "v1.42.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/51b5ff5ba85452b31ec6f55490b08148612339d9", + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9", "shasum": "" }, "require": { @@ -3486,7 +4397,6 @@ "suggest": { "ext-intl": "For best performance" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -3531,7 +4441,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.42.0" }, "funding": [ { @@ -3542,25 +4452,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-10T14:38:51+00:00" + "time": "2026-08-24T10:51:20+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "1.x-dev", + "version": "v1.42.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", "shasum": "" }, "require": { @@ -3569,7 +4483,6 @@ "suggest": { "ext-intl": "For best performance" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -3613,7 +4526,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" }, "funding": [ { @@ -3624,25 +4537,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-08-07T06:33:24+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "1.x-dev", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { @@ -3655,7 +4572,6 @@ "suggest": { "ext-mbstring": "For best performance" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -3695,7 +4611,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -3706,31 +4622,198 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { "name": "symfony/polyfill-php80", - "version": "1.x-dev", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-01T12:47:55+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { "php": ">=7.2" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -3743,7 +4826,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php80\\": "" + "Symfony\\Polyfill\\Php84\\": "" }, "classmap": [ "Resources/stubs" @@ -3754,10 +4837,6 @@ "MIT" ], "authors": [ - { - "name": "Ion Bazan", - "email": "ion.bazan@gmail.com" - }, { "name": "Nicolas Grekas", "email": "p@tchwork.com" @@ -3767,7 +4846,7 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", @@ -3776,7 +4855,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { @@ -3787,31 +4866,34 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-01-02T08:10:11+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { - "name": "symfony/polyfill-php83", - "version": "1.x-dev", + "name": "symfony/polyfill-php85", + "version": "v1.41.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491" + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/2fb86d65e2d424369ad2905e83b236a8805ba491", - "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", "shasum": "" }, "require": { "php": ">=7.2" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -3824,7 +4906,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php83\\": "" + "Symfony\\Polyfill\\Php85\\": "" }, "classmap": [ "Resources/stubs" @@ -3844,7 +4926,7 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", @@ -3853,7 +4935,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" }, "funding": [ { @@ -3864,25 +4946,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { "name": "symfony/polyfill-uuid", - "version": "1.x-dev", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", "shasum": "" }, "require": { @@ -3894,7 +4980,6 @@ "suggest": { "ext-uuid": "For best performance" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -3933,7 +5018,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" }, "funding": [ { @@ -3944,29 +5029,33 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/process", - "version": "6.4.x-dev", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "e2a61c16af36c9a07e5c9906498b73e091949a20" + "reference": "058d17fc284cce14efb2385783b55014a461b176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/e2a61c16af36c9a07e5c9906498b73e091949a20", - "reference": "e2a61c16af36c9a07e5c9906498b73e091949a20", + "url": "https://api.github.com/repos/symfony/process/zipball/058d17fc284cce14efb2385783b55014a461b176", + "reference": "058d17fc284cce14efb2385783b55014a461b176", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "type": "library", "autoload": { @@ -3994,7 +5083,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/6.4" + "source": "https://github.com/symfony/process/tree/v7.4.18" }, "funding": [ { @@ -4005,45 +5094,47 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-10T17:11:00+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/routing", - "version": "6.4.x-dev", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "1f5234e8457164a3a0038a4c0a4ba27876a9c670" + "reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/1f5234e8457164a3a0038a4c0a4ba27876a9c670", - "reference": "1f5234e8457164a3a0038a4c0a4ba27876a9c670", + "url": "https://api.github.com/repos/symfony/routing/zipball/ddd558991e98f693ae6bf5063cc1b0362c6bbec3", + "reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3" }, "conflict": { - "doctrine/annotations": "<1.12", - "symfony/config": "<6.2", - "symfony/dependency-injection": "<5.4", - "symfony/yaml": "<5.4" + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" }, "require-dev": { - "doctrine/annotations": "^1.12|^2", "psr/log": "^1|^2|^3", - "symfony/config": "^6.2|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^5.4|^6.0|^7.0", - "symfony/yaml": "^5.4|^6.0|^7.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4077,7 +5168,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/6.4" + "source": "https://github.com/symfony/routing/tree/v7.4.18" }, "funding": [ { @@ -4088,25 +5179,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-27T16:08:38+00:00" + "time": "2026-08-17T13:12:36+00:00" }, { "name": "symfony/service-contracts", - "version": "dev-main", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", "shasum": "" }, "require": { @@ -4117,7 +5212,6 @@ "conflict": { "ext-psr": "<1.1|>=2" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -4125,7 +5219,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -4161,7 +5255,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" }, "funding": [ { @@ -4172,29 +5266,34 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-25T09:37:31+00:00" + "time": "2026-07-27T15:39:01+00:00" }, { "name": "symfony/string", - "version": "7.4.x-dev", + "version": "v7.4.15", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "4afdf1988ecf90190ca6704bfcb4b085a0b795e0" + "reference": "e394af32256bf9e7bf80849d95e589167c10097b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/4afdf1988ecf90190ca6704bfcb4b085a0b795e0", - "reference": "4afdf1988ecf90190ca6704bfcb4b085a0b795e0", + "url": "https://api.github.com/repos/symfony/string/zipball/e394af32256bf9e7bf80849d95e589167c10097b", + "reference": "e394af32256bf9e7bf80849d95e589167c10097b", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-intl-grapheme": "~1.33", "symfony/polyfill-intl-normalizer": "~1.0", @@ -4205,7 +5304,6 @@ }, "require-dev": { "symfony/emoji": "^7.1|^8.0", - "symfony/error-handler": "^6.4|^7.0|^8.0", "symfony/http-client": "^6.4|^7.0|^8.0", "symfony/intl": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3.0", @@ -4248,7 +5346,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/7.4" + "source": "https://github.com/symfony/string/tree/v7.4.15" }, "funding": [ { @@ -4259,60 +5357,65 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-24T08:16:47+00:00" + "time": "2026-07-28T07:33:02+00:00" }, { "name": "symfony/translation", - "version": "6.4.x-dev", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "de8afa521e04a5220e9e58a1dc99971ab7cac643" + "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/de8afa521e04a5220e9e58a1dc99971ab7cac643", - "reference": "de8afa521e04a5220e9e58a1dc99971ab7cac643", + "url": "https://api.github.com/repos/symfony/translation/zipball/2ee1e4a3b32a528a642babe041ff7c440b213b4b", + "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", - "symfony/translation-contracts": "^2.5|^3.0" + "symfony/translation-contracts": "^2.5.3|^3.3" }, "conflict": { - "symfony/config": "<5.4", - "symfony/console": "<5.4", - "symfony/dependency-injection": "<5.4", + "nikic/php-parser": "<5.0", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<5.4", + "symfony/http-kernel": "<6.4", "symfony/service-contracts": "<2.5", - "symfony/twig-bundle": "<5.4", - "symfony/yaml": "<5.4" + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" }, "provide": { "symfony/translation-implementation": "2.3|3.0" }, "require-dev": { - "nikic/php-parser": "^4.18|^5.0", + "nikic/php-parser": "^5.0", "psr/log": "^1|^2|^3", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", "symfony/http-client-contracts": "^2.5|^3.0", - "symfony/http-kernel": "^5.4|^6.0|^7.0", - "symfony/intl": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", "symfony/polyfill-intl-icu": "^1.21", - "symfony/routing": "^5.4|^6.0|^7.0", + "symfony/routing": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^5.4|^6.0|^7.0" + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4343,7 +5446,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/6.4" + "source": "https://github.com/symfony/translation/tree/v7.4.17" }, "funding": [ { @@ -4354,31 +5457,34 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-26T21:24:02+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/translation-contracts", - "version": "dev-main", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d" + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/df210c7a2573f1913b2d17cc95f90f53a73d8f7d", - "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", "shasum": "" }, "require": { "php": ">=8.1" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -4386,7 +5492,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -4422,7 +5528,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" }, "funding": [ { @@ -4433,33 +5539,37 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-27T08:32:26+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/uid", - "version": "6.4.x-dev", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "9c8592da78d7ee6af52011eef593350d87e814c0" + "reference": "69d732355a139c6f8881337d28515aa01f12b8be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/9c8592da78d7ee6af52011eef593350d87e814c0", - "reference": "9c8592da78d7ee6af52011eef593350d87e814c0", + "url": "https://api.github.com/repos/symfony/uid/zipball/69d732355a139c6f8881337d28515aa01f12b8be", + "reference": "69d732355a139c6f8881337d28515aa01f12b8be", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/polyfill-uuid": "^1.15" }, "require-dev": { - "symfony/console": "^5.4|^6.0|^7.0" + "symfony/console": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4496,7 +5606,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/6.4" + "source": "https://github.com/symfony/uid/tree/v7.4.17" }, "funding": [ { @@ -4507,43 +5617,45 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-26T08:06:12+00:00" + "time": "2026-08-11T07:38:58+00:00" }, { "name": "symfony/var-dumper", - "version": "6.4.x-dev", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "d55b1834cdbfcc31bc2cd7e095ba5ed9a88f6600" + "reference": "e088da50b813f32473a76871616cbb8fa54653a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/d55b1834cdbfcc31bc2cd7e095ba5ed9a88f6600", - "reference": "d55b1834cdbfcc31bc2cd7e095ba5ed9a88f6600", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/e088da50b813f32473a76871616cbb8fa54653a8", + "reference": "e088da50b813f32473a76871616cbb8fa54653a8", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0" }, "conflict": { - "symfony/console": "<5.4" + "symfony/console": "<6.4" }, "require-dev": { - "ext-iconv": "*", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/error-handler": "^6.3|^7.0", - "symfony/http-kernel": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.0|^7.0", - "symfony/uid": "^5.4|^6.0|^7.0", - "twig/twig": "^2.13|^3.0.4" + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" }, "bin": [ "Resources/bin/var-dump-server" @@ -4581,7 +5693,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/6.4" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.18" }, "funding": [ { @@ -4592,39 +5704,42 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T15:05:27+00:00" + "time": "2026-08-30T20:10:52+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", - "version": "dev-master", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", - "reference": "0d72ac1c00084279c1816675284073c5a337c20d" + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/0d72ac1c00084279c1816675284073c5a337c20d", - "reference": "0d72ac1c00084279c1816675284073c5a337c20d", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "php": "^7.4 || ^8.0", - "symfony/css-selector": "^5.4 || ^6.0 || ^7.0" + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" }, "require-dev": { "phpstan/phpstan": "^2.0", "phpstan/phpstan-phpunit": "^2.0", "phpunit/phpunit": "^8.5.21 || ^9.5.10" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -4651,32 +5766,32 @@ "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", "support": { "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", - "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.3.0" + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" }, - "time": "2024-12-21T16:25:41+00:00" + "time": "2025-12-02T11:56:42+00:00" }, { "name": "vlucas/phpdotenv", - "version": "dev-master", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af" + "reference": "301c07936b16d88628b126b01d082ba153cf4c40" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af", - "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/301c07936b16d88628b126b01d082ba153cf4c40", + "reference": "301c07936b16d88628b126b01d082ba153cf4c40", "shasum": "" }, "require": { "ext-pcre": "*", - "graham-campbell/result-type": "^1.1.3", + "graham-campbell/result-type": "^1.2", "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.3", - "symfony/polyfill-ctype": "^1.24", - "symfony/polyfill-mbstring": "^1.24", - "symfony/polyfill-php80": "^1.24" + "phpoption/phpoption": "^1.10", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", @@ -4686,7 +5801,6 @@ "suggest": { "ext-filter": "Required to use the boolean validator." }, - "default-branch": true, "type": "library", "extra": { "bamarni-bin": { @@ -4718,7 +5832,7 @@ "homepage": "https://github.com/vlucas" } ], - "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "description": "Loads environment variables from `.env` to `$_ENV` and `$_SERVER` automagically, and optionally to `getenv()`.", "keywords": [ "dotenv", "env", @@ -4726,7 +5840,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.7.0" }, "funding": [ { @@ -4738,27 +5852,27 @@ "type": "tidelift" } ], - "time": "2025-04-30T23:37:27+00:00" + "time": "2026-08-24T18:07:49+00:00" }, { "name": "voku/portable-ascii", - "version": "2.0.3", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/voku/portable-ascii.git", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d" + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", "shasum": "" }, "require": { - "php": ">=7.0.0" + "php": ">=7.1.0" }, "require-dev": { - "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" }, "suggest": { "ext-intl": "Use Intl for transliterator_transliterate() support" @@ -4788,7 +5902,7 @@ ], "support": { "issues": "https://github.com/voku/portable-ascii/issues", - "source": "https://github.com/voku/portable-ascii/tree/2.0.3" + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" }, "funding": [ { @@ -4812,22 +5926,22 @@ "type": "tidelift" } ], - "time": "2024-11-21T01:49:47+00:00" + "time": "2026-04-26T05:33:54+00:00" } ], "packages-dev": [ { "name": "composer/semver", - "version": "dev-main", + "version": "3.4.4", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12" + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", - "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", "shasum": "" }, "require": { @@ -4837,7 +5951,6 @@ "phpstan/phpstan": "^1.11", "symfony/phpunit-bridge": "^3 || ^7" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -4880,7 +5993,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.4.3" + "source": "https://github.com/composer/semver/tree/3.4.4" }, "funding": [ { @@ -4890,96 +6003,22 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2024-09-19T14:15:21+00:00" - }, - { - "name": "doctrine/instantiator", - "version": "2.0.x-dev", - "source": { - "type": "git", - "url": "https://github.com/doctrine/instantiator.git", - "reference": "62eb6d408ec36853962daa00d056a14276442371" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/62eb6d408ec36853962daa00d056a14276442371", - "reference": "62eb6d408ec36853962daa00d056a14276442371", - "shasum": "" - }, - "require": { - "php": "^8.1" - }, - "require-dev": { - "doctrine/coding-standard": "^12", - "ext-pdo": "*", - "ext-phar": "*", - "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "^1.9.4", - "phpstan/phpstan-phpunit": "^1.3", - "phpunit/phpunit": "^10.5" - }, - "default-branch": true, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com", - "homepage": "https://ocramius.github.io/" - } - ], - "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", - "homepage": "https://www.doctrine-project.org/projects/instantiator.html", - "keywords": [ - "constructor", - "instantiate" - ], - "support": { - "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/2.0.x" - }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", - "type": "tidelift" } ], - "time": "2025-06-09T20:15:13+00:00" + "time": "2025-08-20T19:15:30+00:00" }, { "name": "fakerphp/faker", - "version": "1.24.x-dev", + "version": "v1.24.1", "source": { "type": "git", "url": "https://github.com/FakerPHP/Faker.git", - "reference": "f7ac50712e417f008402c8fc889c964e75eecfe9" + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/f7ac50712e417f008402c8fc889c964e75eecfe9", - "reference": "f7ac50712e417f008402c8fc889c964e75eecfe9", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", "shasum": "" }, "require": { @@ -5027,22 +6066,22 @@ ], "support": { "issues": "https://github.com/FakerPHP/Faker/issues", - "source": "https://github.com/FakerPHP/Faker/tree/1.24" + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" }, - "time": "2025-02-22T09:07:46+00:00" + "time": "2024-11-21T13:46:39+00:00" }, { "name": "filp/whoops", - "version": "2.18.3", + "version": "2.18.4", "source": { "type": "git", "url": "https://github.com/filp/whoops.git", - "reference": "59a123a3d459c5a23055802237cb317f609867e5" + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/59a123a3d459c5a23055802237cb317f609867e5", - "reference": "59a123a3d459c5a23055802237cb317f609867e5", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", "shasum": "" }, "require": { @@ -5092,7 +6131,7 @@ ], "support": { "issues": "https://github.com/filp/whoops/issues", - "source": "https://github.com/filp/whoops/tree/2.18.3" + "source": "https://github.com/filp/whoops/tree/2.18.4" }, "funding": [ { @@ -5100,20 +6139,20 @@ "type": "github" } ], - "time": "2025-06-16T00:02:10+00:00" + "time": "2025-08-08T12:00:00+00:00" }, { "name": "hamcrest/hamcrest-php", - "version": "dev-master", + "version": "v3.0.0", "source": { "type": "git", "url": "https://github.com/hamcrest/hamcrest-php.git", - "reference": "2f2740a1915ea0ab998e1f46bb9deb11698c80ba" + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/2f2740a1915ea0ab998e1f46bb9deb11698c80ba", - "reference": "2f2740a1915ea0ab998e1f46bb9deb11698c80ba", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/b61cd040da1a4925bc90a51c074f5297e7c0fa52", + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52", "shasum": "" }, "require": { @@ -5127,14 +6166,15 @@ "kodova/hamcrest-php": "*" }, "require-dev": { + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.1-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -5152,22 +6192,102 @@ ], "support": { "issues": "https://github.com/hamcrest/hamcrest-php/issues", - "source": "https://github.com/hamcrest/hamcrest-php/tree/master" + "source": "https://github.com/hamcrest/hamcrest-php/tree/v3.0.0" + }, + "time": "2026-03-17T11:56:53+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", + "nunomaduro/termwind": "^1.15|^2.0", + "php": "^8.2", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", + "laravel/pint": "^1.13", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", + "pestphp/pest": "^2.20|^3.0|^4.0", + "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", + "phpstan/phpstan": "^1.12.27", + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Pail\\PailServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Pail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Easily delve into your Laravel application's log files directly from the command line.", + "homepage": "https://github.com/laravel/pail", + "keywords": [ + "dev", + "laravel", + "logs", + "php", + "tail" + ], + "support": { + "issues": "https://github.com/laravel/pail/issues", + "source": "https://github.com/laravel/pail" }, - "time": "2025-06-27T11:23:59+00:00" + "time": "2026-05-20T22:24:57+00:00" }, { "name": "laravel/tinker", - "version": "2.x-dev", + "version": "v2.11.1", "source": { "type": "git", "url": "https://github.com/laravel/tinker.git", - "reference": "102bfc19b79817022e9fb1d3dd235d43d42f1954" + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/102bfc19b79817022e9fb1d3dd235d43d42f1954", - "reference": "102bfc19b79817022e9fb1d3dd235d43d42f1954", + "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", "shasum": "" }, "require": { @@ -5176,7 +6296,7 @@ "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", "php": "^7.2.5|^8.0", "psy/psysh": "^0.11.1|^0.12.0", - "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0" + "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" }, "require-dev": { "mockery/mockery": "~1.3.3|^1.4.2", @@ -5186,7 +6306,6 @@ "suggest": { "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)." }, - "default-branch": true, "type": "library", "extra": { "laravel": { @@ -5219,34 +6338,34 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/2.x" + "source": "https://github.com/laravel/tinker/tree/v2.11.1" }, - "time": "2025-02-12T01:36:47+00:00" + "time": "2026-02-06T14:12:35+00:00" }, { "name": "mockery/mockery", - "version": "1.7.x-dev", + "version": "1.6.15", "source": { "type": "git", "url": "https://github.com/mockery/mockery.git", - "reference": "3f8d3ff1ffe4c552d45c5690c6d825e9310769bf" + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mockery/mockery/zipball/3f8d3ff1ffe4c552d45c5690c6d825e9310769bf", - "reference": "3f8d3ff1ffe4c552d45c5690c6d825e9310769bf", + "url": "https://api.github.com/repos/mockery/mockery/zipball/967a801bd188989a5669bd280f252d51c0fdc9ee", + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee", "shasum": "" }, "require": { - "hamcrest/hamcrest-php": "^2.0.1", - "lib-pcre": ">=7.0", + "hamcrest/hamcrest-php": "^2.0 || ^3.0", "php": ">=7.3" }, "conflict": { "phpunit/phpunit": "<8.0" }, "require-dev": { - "phpunit/phpunit": ">=9.6.11 <10.4" + "phpunit/phpunit": "^9.6.36", + "symplify/easy-coding-standard": "^13.2.17" }, "type": "library", "autoload": { @@ -5303,24 +6422,24 @@ "security": "https://github.com/mockery/mockery/security/advisories", "source": "https://github.com/mockery/mockery" }, - "time": "2023-10-01T17:31:30+00:00" + "time": "2026-08-19T19:37:52+00:00" }, { "name": "myclabs/deep-copy", - "version": "1.x-dev", + "version": "1.14.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c" + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/1720ddd719e16cf0db4eb1c6eca108031636d46c", - "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^8.0" }, "conflict": { "doctrine/collections": "<1.6.8", @@ -5332,7 +6451,6 @@ "phpspec/prophecy": "^1.10", "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" }, - "default-branch": true, "type": "library", "autoload": { "files": [ @@ -5356,32 +6474,31 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.1" + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" + "url": "https://github.com/mnapoli", + "type": "github" } ], - "time": "2025-04-29T12:36:36+00:00" + "time": "2026-08-11T10:17:44+00:00" }, { "name": "nikic/php-parser", - "version": "v5.5.0", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "ae59794362fe85e051a58ad36b289443f57be7a9" + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/ae59794362fe85e051a58ad36b289443f57be7a9", - "reference": "ae59794362fe85e051a58ad36b289443f57be7a9", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -5396,7 +6513,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-master": "5.x-dev" } }, "autoload": { @@ -5420,46 +6537,42 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.5.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2025-05-31T08:24:38+00:00" + "time": "2026-07-04T14:30:18+00:00" }, { "name": "nunomaduro/collision", - "version": "v7.x-dev", + "version": "v8.9.5", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "995245421d3d7593a6960822063bdba4f5d7cf1a" + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/995245421d3d7593a6960822063bdba4f5d7cf1a", - "reference": "995245421d3d7593a6960822063bdba4f5d7cf1a", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/fb53eacd509a1d303858e2d20cfebf2d630254ec", + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec", "shasum": "" }, "require": { - "filp/whoops": "^2.17.0", - "nunomaduro/termwind": "^1.17.0", - "php": "^8.1.0", - "symfony/console": "^6.4.17" + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", + "php": "^8.2.0", + "symfony/console": "^7.4.14 || ^8.1.1" }, "conflict": { - "laravel/framework": ">=11.0.0" + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" }, "require-dev": { - "brianium/paratest": "^7.4.8", - "laravel/framework": "^10.48.29", - "laravel/pint": "^1.21.2", - "laravel/sail": "^1.41.0", - "laravel/sanctum": "^3.3.3", - "laravel/tinker": "^2.10.1", - "nunomaduro/larastan": "^2.10.0", - "orchestra/testbench-core": "^8.35.0", - "pestphp/pest": "^2.36.0", - "phpunit/phpunit": "^10.5.36", - "sebastian/environment": "^6.1.0", - "spatie/laravel-ignition": "^2.9.1" + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.10.0", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.20.0", + "laravel/pint": "^1.29.3", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.3.5", + "pestphp/pest": "^3.8.5 || ^4.7.5 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.1.2 || ^9.3.2" }, "type": "library", "extra": { @@ -5467,6 +6580,9 @@ "providers": [ "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" } }, "autoload": { @@ -5493,6 +6609,7 @@ "cli", "command-line", "console", + "dev", "error", "handling", "laravel", @@ -5518,42 +6635,46 @@ "type": "patreon" } ], - "time": "2025-03-14T22:35:49+00:00" + "time": "2026-07-15T19:09:14+00:00" }, { "name": "orchestra/canvas", - "version": "8.x-dev", + "version": "v10.2.1", "source": { "type": "git", "url": "https://github.com/orchestral/canvas.git", - "reference": "76385dfcf96efae5f8533a4d522d14c3c946ac5a" + "reference": "1323620d48f2b05bc7dd99eadd0403ef29293d6f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/orchestral/canvas/zipball/76385dfcf96efae5f8533a4d522d14c3c946ac5a", - "reference": "76385dfcf96efae5f8533a4d522d14c3c946ac5a", + "url": "https://api.github.com/repos/orchestral/canvas/zipball/1323620d48f2b05bc7dd99eadd0403ef29293d6f", + "reference": "1323620d48f2b05bc7dd99eadd0403ef29293d6f", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", "composer/semver": "^3.0", - "illuminate/console": "^10.48.25", - "illuminate/database": "^10.48.25", - "illuminate/filesystem": "^10.48.25", - "illuminate/support": "^10.48.25", - "orchestra/canvas-core": "^8.10.2", - "orchestra/testbench-core": "^8.30", - "php": "^8.1", - "symfony/polyfill-php83": "^1.31", - "symfony/yaml": "^6.2" + "illuminate/console": "^12.64.0", + "illuminate/database": "^12.64.0", + "illuminate/filesystem": "^12.64.0", + "illuminate/support": "^12.64.0", + "orchestra/canvas-core": "^10.2.0", + "orchestra/sidekick": "~1.1.23|~1.2.20", + "orchestra/testbench-core": "^10.8.0", + "php": "^8.2", + "symfony/polyfill-php83": "^1.33", + "symfony/yaml": "^7.2.0" + }, + "conflict": { + "laravel/framework": "<12.40.0|>=13.0.0" }, "require-dev": { - "laravel/framework": "^10.48.25", - "laravel/pint": "^1.17", - "mockery/mockery": "^1.5.1", - "phpstan/phpstan": "^1.11", - "phpunit/phpunit": "^10.5", - "spatie/laravel-ray": "^1.33" + "laravel/framework": "^12.64.0", + "laravel/pint": "^1.24", + "mockery/mockery": "^1.6.12", + "phpstan/phpstan": "^2.1.14", + "phpunit/phpunit": "^11.5.18|^12.0", + "spatie/laravel-ray": "^1.42.0" }, "bin": [ "canvas" @@ -5588,44 +6709,42 @@ "description": "Code Generators for Laravel Applications and Packages", "support": { "issues": "https://github.com/orchestral/canvas/issues", - "source": "https://github.com/orchestral/canvas/tree/8.x" + "source": "https://github.com/orchestral/canvas/tree/v10.2.1" }, - "time": "2024-11-30T15:38:25+00:00" + "time": "2026-07-22T01:59:39+00:00" }, { "name": "orchestra/canvas-core", - "version": "8.x-dev", + "version": "v10.2.0", "source": { "type": "git", "url": "https://github.com/orchestral/canvas-core.git", - "reference": "c6cfe55bff0d9fe9485c87ce996ca72281173e27" + "reference": "11fdb579f4f2d4bd68a22bd206cabc32e7856e32" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/orchestral/canvas-core/zipball/c6cfe55bff0d9fe9485c87ce996ca72281173e27", - "reference": "c6cfe55bff0d9fe9485c87ce996ca72281173e27", + "url": "https://api.github.com/repos/orchestral/canvas-core/zipball/11fdb579f4f2d4bd68a22bd206cabc32e7856e32", + "reference": "11fdb579f4f2d4bd68a22bd206cabc32e7856e32", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", "composer/semver": "^3.0", - "illuminate/console": "^10.48.22", - "illuminate/filesystem": "^10.48.22", - "php": "^8.1", - "symfony/polyfill-php83": "^1.28" - }, - "conflict": { - "orchestra/canvas": "<8.11.0", - "orchestra/testbench-core": "<8.2.0" + "illuminate/console": "^12.40.0", + "illuminate/support": "^12.40.0", + "orchestra/sidekick": "~1.1.23|~1.2.20", + "php": "^8.2", + "symfony/polyfill-php83": "^1.33" }, "require-dev": { - "laravel/framework": "^10.48.22", - "laravel/pint": "^1.17", - "mockery/mockery": "^1.5.1", - "orchestra/testbench-core": "^8.19", - "phpstan/phpstan": "^2.0", - "phpunit/phpunit": "^10.1", - "symfony/yaml": "^6.2" + "laravel/framework": "^12.40.0", + "laravel/pint": "^1.24", + "mockery/mockery": "^1.6.10", + "orchestra/testbench-core": "^10.8.0", + "phpstan/phpstan": "^2.1.17", + "phpunit/phpunit": "^11.5.12|^12.0.1", + "spatie/laravel-ray": "^1.40.2", + "symfony/yaml": "^7.2" }, "type": "library", "extra": { @@ -5633,9 +6752,6 @@ "providers": [ "Orchestra\\Canvas\\Core\\LaravelServiceProvider" ] - }, - "branch-alias": { - "dev-master": "9.0-dev" } }, "autoload": { @@ -5660,26 +6776,27 @@ "description": "Code Generators Builder for Laravel Applications and Packages", "support": { "issues": "https://github.com/orchestral/canvas/issues", - "source": "https://github.com/orchestral/canvas-core/tree/8.x" + "source": "https://github.com/orchestral/canvas-core/tree/v10.2.0" }, - "time": "2024-12-09T02:04:59+00:00" + "time": "2026-03-06T13:48:13+00:00" }, { "name": "orchestra/sidekick", - "version": "1.2.x-dev", + "version": "v1.2.21", "source": { "type": "git", "url": "https://github.com/orchestral/sidekick.git", - "reference": "aa41994f872cc49a420da42f50886605c0d85f15" + "reference": "fcf69fe227b52beca2e0fe9777d4af4d44ab46a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/orchestral/sidekick/zipball/aa41994f872cc49a420da42f50886605c0d85f15", - "reference": "aa41994f872cc49a420da42f50886605c0d85f15", + "url": "https://api.github.com/repos/orchestral/sidekick/zipball/fcf69fe227b52beca2e0fe9777d4af4d44ab46a5", + "reference": "fcf69fe227b52beca2e0fe9777d4af4d44ab46a5", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", + "composer/semver": "^3.0", "php": "^8.1", "symfony/polyfill-php83": "^1.32" }, @@ -5688,15 +6805,16 @@ "laravel/framework": "^10.48.29|^11.44.7|^12.1.1|^13.0", "laravel/pint": "^1.4", "mockery/mockery": "^1.5.1", - "orchestra/testbench-core": "^8.37.0|^9.14.0|^10.0|^11.0", + "orchestra/testbench-core": "^8.37.0|^9.14.0|^10.2.0|^11.0", "phpstan/phpstan": "^2.1.14", - "phpunit/phpunit": "^10.0|^11.0|^12.0", + "phpunit/phpunit": "^10.0|^11.0|^12.0|^13.0", "symfony/process": "^6.0|^7.0" }, "type": "library", "autoload": { "files": [ "src/Eloquent/functions.php", + "src/Filesystem/functions.php", "src/Http/functions.php", "src/functions.php" ], @@ -5717,36 +6835,36 @@ "description": "Packages Toolkit Utilities and Helpers for Laravel", "support": { "issues": "https://github.com/orchestral/sidekick/issues", - "source": "https://github.com/orchestral/sidekick/tree/1.2.x" + "source": "https://github.com/orchestral/sidekick/tree/v1.2.21" }, - "time": "2025-06-23T05:09:50+00:00" + "time": "2026-08-04T23:01:29+00:00" }, { "name": "orchestra/testbench", - "version": "8.x-dev", + "version": "v10.11.0", "source": { "type": "git", "url": "https://github.com/orchestral/testbench.git", - "reference": "1e765c32c37ceb1acdf189c2804fa1ed738f451c" + "reference": "d73b4426dacddd2c1f5e671e0efd7665b16d2b84" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/orchestral/testbench/zipball/1e765c32c37ceb1acdf189c2804fa1ed738f451c", - "reference": "1e765c32c37ceb1acdf189c2804fa1ed738f451c", + "url": "https://api.github.com/repos/orchestral/testbench/zipball/d73b4426dacddd2c1f5e671e0efd7665b16d2b84", + "reference": "d73b4426dacddd2c1f5e671e0efd7665b16d2b84", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", - "fakerphp/faker": "^1.21", - "laravel/framework": "^10.48.29", - "mockery/mockery": "^1.5.1", - "orchestra/testbench-core": "^8.37.0", - "orchestra/workbench": "^8.17.5", - "php": "^8.1", - "phpunit/phpunit": "^9.6|^10.1", - "symfony/process": "^6.2", - "symfony/yaml": "^6.2", - "vlucas/phpdotenv": "^5.4.1" + "fakerphp/faker": "^1.23", + "laravel/framework": "^12.55.0", + "mockery/mockery": "^1.6.10", + "orchestra/testbench-core": "^10.11.0", + "orchestra/workbench": "^10.0.8", + "php": "^8.2", + "phpunit/phpunit": "^11.5.3|^12.0.1|^13.0.0", + "symfony/process": "^7.2", + "symfony/yaml": "^7.2", + "vlucas/phpdotenv": "^5.6.1" }, "type": "library", "notification-url": "https://packagist.org/downloads/", @@ -5772,66 +6890,63 @@ ], "support": { "issues": "https://github.com/orchestral/testbench/issues", - "source": "https://github.com/orchestral/testbench/tree/8.x" + "source": "https://github.com/orchestral/testbench/tree/v10.11.0" }, - "time": "2025-05-12T06:24:52+00:00" + "time": "2026-03-18T13:08:23+00:00" }, { "name": "orchestra/testbench-core", - "version": "8.x-dev", + "version": "v10.14.1", "source": { "type": "git", "url": "https://github.com/orchestral/testbench-core.git", - "reference": "88942ff0c3969553f0dc77327c9991f49abd799f" + "reference": "6b88b608ba794fcac18094c7d191591c852c286d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/orchestral/testbench-core/zipball/88942ff0c3969553f0dc77327c9991f49abd799f", - "reference": "88942ff0c3969553f0dc77327c9991f49abd799f", + "url": "https://api.github.com/repos/orchestral/testbench-core/zipball/6b88b608ba794fcac18094c7d191591c852c286d", + "reference": "6b88b608ba794fcac18094c7d191591c852c286d", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", - "orchestra/sidekick": "~1.1.14|^1.2.10", - "php": "^8.1", + "orchestra/sidekick": "~1.1.23|~1.2.20", + "php": "^8.2", "symfony/deprecation-contracts": "^2.5|^3.0", - "symfony/polyfill-php83": "^1.32" + "symfony/polyfill-php83": "^1.33" }, "conflict": { - "brianium/paratest": "<6.4.0|>=7.0.0 <7.1.4|>=8.0.0", - "laravel/framework": "<10.48.29|>=11.0.0", - "laravel/serializable-closure": "<1.3.0|>=3.0.0", - "nunomaduro/collision": "<6.4.0|>=7.0.0 <7.4.0|>=8.0.0", - "orchestra/testbench-dusk": "<8.32.0|>=9.0.0", - "orchestra/workbench": "<1.0.0", - "phpunit/phpunit": "<9.6.0|>=10.3.0 <10.3.3|>=10.6.0" + "brianium/paratest": "<7.3.0|>=8.0.0", + "laravel/framework": "<12.55.0|>=13.0.0", + "laravel/serializable-closure": "<1.3.0|>=2.0.0 <2.0.3|>=3.0.0", + "nunomaduro/collision": "<8.0.0|>=9.0.0", + "phpunit/phpunit": "<10.5.35|>=11.0.0 <11.5.3|12.0.0|>=13.2.0" }, "require-dev": { - "fakerphp/faker": "^1.21", - "laravel/framework": "^10.48.29", - "laravel/pint": "^1.20", - "laravel/serializable-closure": "^1.3|^2.0", - "mockery/mockery": "^1.5.1", - "phpstan/phpstan": "^2.1.14", - "phpunit/phpunit": "^10.1", - "spatie/laravel-ray": "^1.40.2", - "symfony/process": "^6.2", - "symfony/yaml": "^6.2", - "vlucas/phpdotenv": "^5.4.1" + "fakerphp/faker": "^1.24", + "laravel/framework": "^12.55.0", + "laravel/pint": "^1.24", + "laravel/serializable-closure": "^1.3|^2.0.4", + "mockery/mockery": "^1.6.10", + "phpstan/phpstan": "^2.1.38", + "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1|^13.0.0", + "spatie/laravel-ray": "^1.42.0", + "symfony/process": "^7.2.0", + "symfony/yaml": "^7.2.0", + "vlucas/phpdotenv": "^5.6.1" }, "suggest": { - "brianium/paratest": "Allow using parallel testing (^6.4|^7.1.4).", + "brianium/paratest": "Allow using parallel testing (^7.3).", "ext-pcntl": "Required to use all features of the console signal trapping.", - "fakerphp/faker": "Allow using Faker for testing (^1.21).", - "laravel/framework": "Required for testing (^10.48.29).", - "mockery/mockery": "Allow using Mockery for testing (^1.5.1).", - "nunomaduro/collision": "Allow using Laravel style tests output and parallel testing (^6.4|^7.4).", - "orchestra/testbench-browser-kit": "Allow using legacy Laravel BrowserKit for testing (^8.0).", - "orchestra/testbench-dusk": "Allow using Laravel Dusk for testing (^8.0).", - "phpunit/phpunit": "Allow using PHPUnit for testing (^9.6|^10.1).", - "symfony/process": "Required to use Orchestra\\Testbench\\remote function (^6.2).", - "symfony/yaml": "Required for Testbench CLI (^6.2).", - "vlucas/phpdotenv": "Required for Testbench CLI (^5.4.1)." + "fakerphp/faker": "Allow using Faker for testing (^1.23).", + "laravel/framework": "Required for testing (^12.55.0).", + "mockery/mockery": "Allow using Mockery for testing (^1.6).", + "nunomaduro/collision": "Allow using Laravel style tests output and parallel testing (^8.0).", + "orchestra/testbench-dusk": "Allow using Laravel Dusk for testing (^10.0).", + "phpunit/phpunit": "Allow using PHPUnit for testing (^10.5.35|^11.5.3|^12.0.1|^13.0.0).", + "symfony/process": "Required to use Orchestra\\Testbench\\remote function (^7.2).", + "symfony/yaml": "Required for Testbench CLI (^7.2).", + "vlucas/phpdotenv": "Required for Testbench CLI (^5.6.1)." }, "bin": [ "testbench" @@ -5870,42 +6985,44 @@ "issues": "https://github.com/orchestral/testbench/issues", "source": "https://github.com/orchestral/testbench-core" }, - "time": "2025-06-14T10:35:36+00:00" + "time": "2026-04-24T08:35:55+00:00" }, { "name": "orchestra/workbench", - "version": "8.x-dev", + "version": "v10.2.0", "source": { "type": "git", "url": "https://github.com/orchestral/workbench.git", - "reference": "04da1d8c28948e0bd78bc771db0026ccbbba449d" + "reference": "01212434bc1e3bd6c2c96f1977d0951b9fb91e97" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/orchestral/workbench/zipball/04da1d8c28948e0bd78bc771db0026ccbbba449d", - "reference": "04da1d8c28948e0bd78bc771db0026ccbbba449d", + "url": "https://api.github.com/repos/orchestral/workbench/zipball/01212434bc1e3bd6c2c96f1977d0951b9fb91e97", + "reference": "01212434bc1e3bd6c2c96f1977d0951b9fb91e97", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", - "fakerphp/faker": "^1.21", - "laravel/framework": "^10.48.28", - "laravel/tinker": "^2.8.2", - "nunomaduro/collision": "^6.4|^7.10", - "orchestra/canvas": "^8.12.0", - "orchestra/sidekick": "^1.2.0", - "orchestra/testbench-core": "^8.35.0", - "php": "^8.1", - "symfony/polyfill-php83": "^1.32", - "symfony/process": "^6.2", - "symfony/yaml": "^6.2" + "fakerphp/faker": "^1.23", + "laravel/framework": "^12.40.0", + "laravel/pail": "^1.2.2", + "laravel/tinker": "^2.10.1", + "nunomaduro/collision": "^8.6", + "orchestra/canvas": "^10.2.1", + "orchestra/canvas-core": "^10.2.0", + "orchestra/sidekick": "~1.1.23|~1.2.20", + "orchestra/testbench-core": "^10.12.0", + "php": "^8.2", + "symfony/polyfill-php83": "^1.33", + "symfony/process": "^7.2", + "symfony/yaml": "^7.2" }, "require-dev": { - "laravel/pint": "^1.20", - "mockery/mockery": "^1.5.1", - "phpstan/phpstan": "^2.1.14", - "phpunit/phpunit": "^10.1", - "spatie/laravel-ray": "^1.40.2" + "laravel/pint": "^1.22.0", + "mockery/mockery": "^1.6.12", + "phpstan/phpstan": "^2.1.33", + "phpunit/phpunit": "^11.5.3|^12.0.1|^13.0", + "spatie/laravel-ray": "^1.42.0" }, "suggest": { "ext-pcntl": "Required to use all features of the console signal trapping." @@ -5935,13 +7052,13 @@ ], "support": { "issues": "https://github.com/orchestral/workbench/issues", - "source": "https://github.com/orchestral/workbench/tree/8.x" + "source": "https://github.com/orchestral/workbench/tree/v10.2.0" }, - "time": "2025-06-14T10:53:43+00:00" + "time": "2026-07-22T02:17:12+00:00" }, { "name": "phar-io/manifest", - "version": "dev-master", + "version": "2.0.4", "source": { "type": "git", "url": "https://github.com/phar-io/manifest.git", @@ -5961,7 +7078,6 @@ "phar-io/version": "^3.0.1", "php": "^7.2 || ^8.0" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -6060,35 +7176,33 @@ }, { "name": "phpunit/php-code-coverage", - "version": "9.2.x-dev", + "version": "12.5.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "0448d60087a382392a1b2a1abe434466e03dcc87" + "reference": "186dab580576598076de6818596d12b61801880e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/0448d60087a382392a1b2a1abe434466e03dcc87", - "reference": "0448d60087a382392a1b2a1abe434466e03dcc87", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/186dab580576598076de6818596d12b61801880e", + "reference": "186dab580576598076de6818596d12b61801880e", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^4.19.1 || ^5.1.0", - "php": ">=7.3", - "phpunit/php-file-iterator": "^3.0.6", - "phpunit/php-text-template": "^2.0.4", - "sebastian/code-unit-reverse-lookup": "^2.0.3", - "sebastian/complexity": "^2.0.3", - "sebastian/environment": "^5.1.5", - "sebastian/lines-of-code": "^1.0.4", - "sebastian/version": "^3.0.2", - "theseer/tokenizer": "^1.2.3" + "nikic/php-parser": "^5.7.0", + "php": ">=8.3", + "phpunit/php-text-template": "^5.0", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.1.2", + "sebastian/lines-of-code": "^4.0.1", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^9.6" + "phpunit/phpunit": "^12.5.28" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -6097,7 +7211,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "9.2.x-dev" + "dev-main": "12.5.x-dev" } }, "autoload": { @@ -6126,40 +7240,52 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.7" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" } ], - "time": "2024-10-31T05:58:25+00:00" + "time": "2026-06-01T13:24:19+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "3.0.x-dev", + "version": "6.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "38b24367e1b340aa78b96d7cab042942d917bb84" + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/38b24367e1b340aa78b96d7cab042942d917bb84", - "reference": "38b24367e1b340aa78b96d7cab042942d917bb84", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a248d1640ab059b075f53a2ef0f9856e864e06b5", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.5.33" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -6186,36 +7312,49 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0" + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2022-02-11T16:23:04+00:00" + "time": "2026-08-25T14:40:53+00:00" }, { "name": "phpunit/php-invoker", - "version": "3.1.1", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", - "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { "ext-pcntl": "*", - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "suggest": { "ext-pcntl": "*" @@ -6223,7 +7362,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.1-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -6249,7 +7388,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-invoker/issues", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" }, "funding": [ { @@ -6257,32 +7397,32 @@ "type": "github" } ], - "time": "2020-09-28T05:58:55+00:00" + "time": "2025-02-07T04:58:58+00:00" }, { "name": "phpunit/php-text-template", - "version": "2.0.4", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", - "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -6308,7 +7448,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-text-template/issues", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" }, "funding": [ { @@ -6316,32 +7457,32 @@ "type": "github" } ], - "time": "2020-10-26T05:33:50+00:00" + "time": "2025-02-07T04:59:16+00:00" }, { "name": "phpunit/php-timer", - "version": "5.0.3", + "version": "8.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", - "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -6367,7 +7508,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-timer/issues", - "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" }, "funding": [ { @@ -6375,54 +7517,49 @@ "type": "github" } ], - "time": "2020-10-26T13:16:10+00:00" + "time": "2025-02-07T04:59:38+00:00" }, { "name": "phpunit/phpunit", - "version": "9.6.x-dev", + "version": "12.5.34", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "a9e1112505effabbc6a0b3dde09e62999280f029" + "reference": "6cbff63d670de92cb1cb3d2ff9f40327e9da9c7f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a9e1112505effabbc6a0b3dde09e62999280f029", - "reference": "a9e1112505effabbc6a0b3dde09e62999280f029", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/6cbff63d670de92cb1cb3d2ff9f40327e9da9c7f", + "reference": "6cbff63d670de92cb1cb3d2ff9f40327e9da9c7f", "shasum": "" }, "require": { - "doctrine/instantiator": "^1.5.0 || ^2", "ext-dom": "*", + "ext-filter": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", - "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.13.1", + "myclabs/deep-copy": "^1.14.0", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", - "php": ">=7.3", - "phpunit/php-code-coverage": "^9.2.32", - "phpunit/php-file-iterator": "^3.0.6", - "phpunit/php-invoker": "^3.1.1", - "phpunit/php-text-template": "^2.0.4", - "phpunit/php-timer": "^5.0.3", - "sebastian/cli-parser": "^1.0.2", - "sebastian/code-unit": "^1.0.8", - "sebastian/comparator": "^4.0.8", - "sebastian/diff": "^4.0.6", - "sebastian/environment": "^5.1.5", - "sebastian/exporter": "^4.0.6", - "sebastian/global-state": "^5.0.7", - "sebastian/object-enumerator": "^4.0.4", - "sebastian/resource-operations": "^3.0.4", - "sebastian/type": "^3.2.1", - "sebastian/version": "^3.0.2" - }, - "suggest": { - "ext-soap": "To be able to generate mocks based on WSDL files", - "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.7", + "phpunit/php-file-iterator": "^6.0.2", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.0", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", + "sebastian/diff": "^7.0.1", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.3", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.4", + "sebastian/version": "^6.0.0", + "staabm/side-effects-detector": "^1.0.5" }, "bin": [ "phpunit" @@ -6430,7 +7567,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "9.6-dev" + "dev-main": "12.5-dev" } }, "autoload": { @@ -6462,44 +7599,28 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.34" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2025-06-19T14:51:41+00:00" + "time": "2026-08-27T08:38:28+00:00" }, { "name": "psy/psysh", - "version": "dev-main", + "version": "v0.12.24", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "1b801844becfe648985372cb4b12ad6840245ace" + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/1b801844becfe648985372cb4b12ad6840245ace", - "reference": "1b801844becfe648985372cb4b12ad6840245ace", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", "shasum": "" }, "require": { @@ -6507,21 +7628,21 @@ "ext-tokenizer": "*", "nikic/php-parser": "^5.0 || ^4.0", "php": "^8.0 || ^7.4", - "symfony/console": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", - "symfony/var-dumper": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" }, "conflict": { "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.2" + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" }, "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", - "ext-pdo-sqlite": "The doc command requires SQLite to work.", "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." }, - "default-branch": true, "bin": [ "bin/psysh" ], @@ -6550,12 +7671,11 @@ "authors": [ { "name": "Justin Hileman", - "email": "justin@justinhileman.info", - "homepage": "http://justinhileman.com" + "email": "justin@justinhileman.info" } ], "description": "An interactive shell for modern PHP.", - "homepage": "http://psysh.org", + "homepage": "https://psysh.org", "keywords": [ "REPL", "console", @@ -6564,34 +7684,34 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.9" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.24" }, - "time": "2025-06-23T02:35:06+00:00" + "time": "2026-06-29T15:41:09+00:00" }, { "name": "sebastian/cli-parser", - "version": "1.0.x-dev", + "version": "4.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b" + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b", - "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-main": "4.2-dev" } }, "autoload": { @@ -6614,153 +7734,60 @@ "homepage": "https://github.com/sebastianbergmann/cli-parser", "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2" + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - } - ], - "time": "2024-03-02T06:27:43+00:00" - }, - { - "name": "sebastian/code-unit", - "version": "1.0.8", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", - "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", - "shasum": "" - }, - "require": { - "php": ">=7.3" - }, - "require-dev": { - "phpunit/phpunit": "^9.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Collection of value objects that represent the PHP code units", - "homepage": "https://github.com/sebastianbergmann/code-unit", - "support": { - "issues": "https://github.com/sebastianbergmann/code-unit/issues", - "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" - }, - "funding": [ + }, { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2020-10-26T13:08:54+00:00" - }, - { - "name": "sebastian/code-unit-reverse-lookup", - "version": "2.0.3", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", - "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", - "shasum": "" - }, - "require": { - "php": ">=7.3" - }, - "require-dev": { - "phpunit/phpunit": "^9.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Looks up which function or method a line of code belongs to", - "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", - "support": { - "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", - "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" - }, - "funding": [ + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" } ], - "time": "2020-09-28T05:30:19+00:00" + "time": "2026-05-17T05:29:34+00:00" }, { "name": "sebastian/comparator", - "version": "4.0.x-dev", + "version": "7.1.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "b247957a1c8dc81a671770f74b479c0a78a818f1" + "reference": "7c65c1e79836812819705b473a90c12399542485" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/b247957a1c8dc81a671770f74b479c0a78a818f1", - "reference": "b247957a1c8dc81a671770f74b479c0a78a818f1", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/diff": "^4.0", - "sebastian/exporter": "^4.0" + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.5.25" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "7.1-dev" } }, "autoload": { @@ -6799,41 +7826,54 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/4.0" + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" } ], - "time": "2022-09-14T12:46:14+00:00" + "time": "2026-05-21T04:45:25+00:00" }, { "name": "sebastian/complexity", - "version": "2.0.x-dev", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a" + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a", - "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", "shasum": "" }, "require": { - "nikic/php-parser": "^4.18 || ^5.0", - "php": ">=7.3" + "nikic/php-parser": "^5.0", + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -6856,7 +7896,8 @@ "homepage": "https://github.com/sebastianbergmann/complexity", "support": { "issues": "https://github.com/sebastianbergmann/complexity/issues", - "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3" + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" }, "funding": [ { @@ -6864,33 +7905,33 @@ "type": "github" } ], - "time": "2023-12-22T06:19:30+00:00" + "time": "2025-02-07T04:55:25+00:00" }, { "name": "sebastian/diff", - "version": "4.0.x-dev", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc" + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc", - "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3", - "symfony/process": "^4.2 || ^5" + "phpunit/phpunit": "^12.5.33", + "symfony/process": "^7.4.17" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -6922,35 +7963,48 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", - "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6" + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" } ], - "time": "2024-03-02T06:30:58+00:00" + "time": "2026-08-25T15:35:54+00:00" }, { "name": "sebastian/environment", - "version": "5.1.x-dev", + "version": "8.1.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed" + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", - "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.5.26" }, "suggest": { "ext-posix": "*" @@ -6958,7 +8012,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.1-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -6977,7 +8031,7 @@ } ], "description": "Provides functionality to handle HHVM/PHP environments", - "homepage": "http://www.github.com/sebastianbergmann/environment", + "homepage": "https://github.com/sebastianbergmann/environment", "keywords": [ "Xdebug", "environment", @@ -6985,42 +8039,55 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", - "source": "https://github.com/sebastianbergmann/environment/tree/5.1" + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" } ], - "time": "2023-02-03T06:03:51+00:00" + "time": "2026-05-25T13:40:20+00:00" }, { "name": "sebastian/exporter", - "version": "4.0.x-dev", + "version": "7.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72" + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/recursion-context": "^4.0" + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0.1" }, "require-dev": { - "ext-mbstring": "*", - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -7062,46 +8129,56 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6" + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2024-03-02T06:33:00+00:00" + "time": "2026-05-20T04:37:17+00:00" }, { "name": "sebastian/global-state", - "version": "5.0.x-dev", + "version": "8.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9" + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", - "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/object-reflector": "^2.0", - "sebastian/recursion-context": "^4.0" + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0.1" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^9.3" - }, - "suggest": { - "ext-uopz": "*" + "phpunit/phpunit": "^12.5.28" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -7120,47 +8197,60 @@ } ], "description": "Snapshotting of global state", - "homepage": "http://www.github.com/sebastianbergmann/global-state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", "keywords": [ "global state" ], "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", - "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.7" + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" } ], - "time": "2024-03-02T06:35:11+00:00" + "time": "2026-06-01T15:10:33+00:00" }, { "name": "sebastian/lines-of-code", - "version": "1.0.x-dev", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5" + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5", - "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", "shasum": "" }, "require": { - "nikic/php-parser": "^4.18 || ^5.0", - "php": ">=7.3" + "nikic/php-parser": "^5.7.0", + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -7183,42 +8273,55 @@ "homepage": "https://github.com/sebastianbergmann/lines-of-code", "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4" + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" } ], - "time": "2023-12-22T06:20:34+00:00" + "time": "2026-05-19T16:22:07+00:00" }, { "name": "sebastian/object-enumerator", - "version": "4.0.4", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", - "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/object-reflector": "^2.0", - "sebastian/recursion-context": "^4.0" + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -7240,7 +8343,8 @@ "homepage": "https://github.com/sebastianbergmann/object-enumerator/", "support": { "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" }, "funding": [ { @@ -7248,32 +8352,32 @@ "type": "github" } ], - "time": "2020-10-26T13:12:34+00:00" + "time": "2025-02-07T04:57:48+00:00" }, { "name": "sebastian/object-reflector", - "version": "2.0.4", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", - "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -7295,7 +8399,8 @@ "homepage": "https://github.com/sebastianbergmann/object-reflector/", "support": { "issues": "https://github.com/sebastianbergmann/object-reflector/issues", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" }, "funding": [ { @@ -7303,32 +8408,32 @@ "type": "github" } ], - "time": "2020-10-26T13:14:26+00:00" + "time": "2025-02-07T04:58:17+00:00" }, { "name": "sebastian/recursion-context", - "version": "4.0.x-dev", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1" + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -7358,41 +8463,53 @@ "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.5" + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" } ], - "time": "2023-02-03T06:07:39+00:00" + "time": "2025-08-13T04:44:59+00:00" }, { - "name": "sebastian/resource-operations", - "version": "dev-main", + "name": "sebastian/type", + "version": "6.0.4", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "ff553e7482dcee39fa4acc2b175d6ddeb0f7bc25" + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "82ff822c2edc46724be9f7411d3163021f602773" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/ff553e7482dcee39fa4acc2b175d6ddeb0f7bc25", - "reference": "ff553e7482dcee39fa4acc2b175d6ddeb0f7bc25", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.0" + "phpunit/phpunit": "^12.5.25" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -7407,46 +8524,58 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Provides a list of PHP built-in functions that operate on resources", - "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", "support": { - "source": "https://github.com/sebastianbergmann/resource-operations/tree/main" + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" } ], - "time": "2024-03-14T18:47:08+00:00" + "time": "2026-05-20T06:45:45+00:00" }, { - "name": "sebastian/type", - "version": "3.2.x-dev", + "name": "sebastian/version", + "version": "6.0.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/type.git", - "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", - "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", "shasum": "" }, "require": { - "php": ">=7.3" - }, - "require-dev": { - "phpunit/phpunit": "^9.5" + "php": ">=8.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.2-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -7465,11 +8594,12 @@ "role": "lead" } ], - "description": "Collection of value objects that represent the types of the PHP type system", - "homepage": "https://github.com/sebastianbergmann/type", + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", "support": { - "issues": "https://github.com/sebastianbergmann/type/issues", - "source": "https://github.com/sebastianbergmann/type/tree/3.2" + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" }, "funding": [ { @@ -7477,85 +8607,84 @@ "type": "github" } ], - "time": "2023-02-03T06:13:03+00:00" + "time": "2025-02-07T05:00:38+00:00" }, { - "name": "sebastian/version", - "version": "3.0.x-dev", + "name": "staabm/side-effects-detector", + "version": "1.0.5", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/version.git", - "reference": "c6c1022351a901512170118436c764e473f6de8c" + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", - "reference": "c6c1022351a901512170118436c764e473f6de8c", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", "shasum": "" }, "require": { - "php": ">=7.3" + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.0-dev" - } + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" }, + "type": "library", "autoload": { "classmap": [ - "src/" + "lib/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" ], - "description": "Library that helps with managing the version number of Git-hosted PHP projects", - "homepage": "https://github.com/sebastianbergmann/version", "support": { - "issues": "https://github.com/sebastianbergmann/version/issues", - "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://github.com/staabm", "type": "github" } ], - "time": "2020-09-28T06:39:44+00:00" + "time": "2024-10-20T05:08:20+00:00" }, { "name": "symfony/yaml", - "version": "6.4.x-dev", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "93e29e0deb5f1b2e360adfb389a20d25eb81a27b" + "reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/93e29e0deb5f1b2e360adfb389a20d25eb81a27b", - "reference": "93e29e0deb5f1b2e360adfb389a20d25eb81a27b", + "url": "https://api.github.com/repos/symfony/yaml/zipball/4cef939e55b8a21c5780418de0d98ab00d0736e1", + "reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/console": "<5.4" + "symfony/console": "<6.4" }, "require-dev": { - "symfony/console": "^5.4|^6.0|^7.0" + "symfony/console": "^6.4|^7.0|^8.0" }, "bin": [ "Resources/bin/yaml-lint" @@ -7586,7 +8715,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/6.4" + "source": "https://github.com/symfony/yaml/tree/v7.4.18" }, "funding": [ { @@ -7597,32 +8726,36 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-03T06:46:12+00:00" + "time": "2026-08-30T00:47:26+00:00" }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", "shasum": "" }, "require": { "ext-dom": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.1" }, "type": "library", "autoload": { @@ -7644,7 +8777,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" }, "funding": [ { @@ -7652,18 +8785,16 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-12-08T11:19:18+00:00" } ], "aliases": [], "minimum-stability": "dev", - "stability-flags": { - "iugu/iugu": 20 - }, - "prefer-stable": false, + "stability-flags": {}, + "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.0|^8.1|^8.2|^8.3" + "php": "^8.3" }, "platform-dev": {}, "plugin-api-version": "2.6.0" diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 7d43f7b..9acc80c 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,23 +1,21 @@ - - - src/ - - ./tests/Unit @@ -26,11 +24,19 @@ ./tests/Integration + + + src/ + + + + + diff --git a/scripts/capabilities-table.php b/scripts/capabilities-table.php new file mode 100644 index 0000000..6f99ea5 --- /dev/null +++ b/scripts/capabilities-table.php @@ -0,0 +1,31 @@ + $gateway) { + $config['gateways'][$name]['api_key'] = $gateway['api_key'] ?? 'chave-nao-usada'; +} + +$app = new Container(); +$app->instance('config', new Repository(['multi-payment' => $config])); +Facade::setFacadeApplication($app); + +$gateways = []; +foreach ($config['gateways'] as $name => $gateway) { + $gateways[$name] = new $gateway['class'](); +} + +echo CapabilitiesTable::markdown($gateways); diff --git a/src/Builders/Builder.php b/src/Builders/Builder.php index 1848593..42cf149 100644 --- a/src/Builders/Builder.php +++ b/src/Builders/Builder.php @@ -11,6 +11,13 @@ class Builder protected GatewayContract $gateway; protected Model $model; + /** + * Chave de idempotência enviada com o `create()`. + * + * @var string|null + */ + protected ?string $idempotencyKey = null; + /** * Builder constructor. * @@ -31,10 +38,24 @@ public function __construct($gateway = null) */ public function create(): Model { - $this->model->save($this->gateway, true); + $this->model->save($this->gateway, true, $this->idempotencyKey); return $this->model; } + /** + * Define a chave de idempotência que `create()` envia ao gateway (ver a seção + * "Idempotência" do README). Nula desliga a deduplicação. + * + * @param string|null $idempotencyKey + * @return $this + */ + public function withIdempotencyKey(?string $idempotencyKey): static + { + $this->idempotencyKey = $idempotencyKey; + + return $this; + } + /** * Returns the model instance. * @@ -58,7 +79,23 @@ public function setGateway($gateway = null): self } /** - * Set the gateway adicional options. + * Define as opções extras enviadas direto ao gateway (ver Model::$gatewayOptions). + * + * @param array $gatewayOptions + * + * @return $this + */ + public function setGatewayOptions(array $gatewayOptions): self + { + $this->model->gatewayOptions = $gatewayOptions; + + return $this; + } + + /** + * Set the gateway options. Old name of setGatewayOptions(). + * + * @deprecated since 2026-09-02, use setGatewayOptions() * * @param array $gatewayAdicionalOptions * @@ -66,8 +103,11 @@ public function setGateway($gateway = null): self */ public function setGatewayAdicionalOptions(array $gatewayAdicionalOptions): self { - $this->model->gatewayAdicionalOptions = $gatewayAdicionalOptions; + trigger_error( + 'Builder::setGatewayAdicionalOptions() está obsoleto desde 2026-09-02; use setGatewayOptions()', + E_USER_DEPRECATED + ); - return $this; + return $this->setGatewayOptions($gatewayAdicionalOptions); } } \ No newline at end of file diff --git a/src/Builders/InvoiceBuilder.php b/src/Builders/InvoiceBuilder.php index 14d710a..aad7119 100644 --- a/src/Builders/InvoiceBuilder.php +++ b/src/Builders/InvoiceBuilder.php @@ -3,6 +3,7 @@ namespace Potelo\MultiPayment\Builders; use Carbon\Carbon; +use Carbon\CarbonInterface; use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Models\Address; use Potelo\MultiPayment\Models\Customer; @@ -10,6 +11,7 @@ use Potelo\MultiPayment\Models\InvoiceItem; use Potelo\MultiPayment\Models\AutomaticPix; use Potelo\MultiPayment\Models\AutomaticPixCharge; +use Potelo\MultiPayment\Enums\PaymentMethod; use Potelo\MultiPayment\Contracts\GatewayContract; /** @@ -48,7 +50,7 @@ public function create(): Invoice /** * Set the invoice available payment methods * - * @param string[] $paymentMethods + * @param PaymentMethod[]|string[] $paymentMethods * * @return InvoiceBuilder */ @@ -61,11 +63,11 @@ public function setAvailablePaymentMethods(array $paymentMethods): InvoiceBuilde /** * Add the invoice available payment methods * - * @param string $paymentMethod + * @param PaymentMethod|string $paymentMethod * * @return InvoiceBuilder */ - public function addAvailablePaymentMethod(string $paymentMethod): InvoiceBuilder + public function addAvailablePaymentMethod(PaymentMethod|string $paymentMethod): InvoiceBuilder { $paymentMethods = is_array($this->model->availablePaymentMethods) ? $this->model->availablePaymentMethods : []; $paymentMethods[] = $paymentMethod; @@ -74,19 +76,81 @@ public function addAvailablePaymentMethod(string $paymentMethod): InvoiceBuilder } /** - * set invoice expiresAt + * Define o método de pagamento da fatura. Quando `availablePaymentMethods` fica vazia, os + * drivers criam a fatura com este método (ver `Invoice::resolvedPaymentMethods()`). * - * @param Carbon|string $expiresAt Carbon or string in Y-m-d format + * @param PaymentMethod|string $paymentMethod + * + * @return InvoiceBuilder + */ + public function setPaymentMethod(PaymentMethod|string $paymentMethod): InvoiceBuilder + { + $this->model->paymentMethod = $paymentMethod; + + return $this; + } + + /** + * Define a data de vencimento da fatura (ver `Invoice::$dueDate`). + * + * @param CarbonInterface|string $dueDate data, ou string em `Y-m-d` ou ISO 8601 + * + * @return InvoiceBuilder + */ + public function setDueDate(CarbonInterface|string $dueDate): InvoiceBuilder + { + $this->model->dueDate = self::toCarbon($dueDate); + + return $this; + } + + /** + * Define o instante em que o QR Code do Pix expira (ver `Invoice::$pixExpiresAt`). + * + * @param CarbonInterface|string $pixExpiresAt data e hora, ou string em ISO 8601 + * + * @return InvoiceBuilder + */ + public function setPixExpiresAt(CarbonInterface|string $pixExpiresAt): InvoiceBuilder + { + $this->model->pixExpiresAt = self::toCarbon($pixExpiresAt); + + return $this; + } + + /** + * Define a data de vencimento. Nome antigo de setDueDate(). + * + * @deprecated desde 2026-09-02, use setDueDate() (vencimento) ou setPixExpiresAt() (expiração do QR Code) + * + * @param CarbonInterface|string $expiresAt * * @return InvoiceBuilder */ public function setExpiresAt($expiresAt): InvoiceBuilder { - if (is_string($expiresAt)) { - $expiresAt = Carbon::parse($expiresAt); + trigger_error( + 'InvoiceBuilder::setExpiresAt() está obsoleto desde 2026-09-02; use setDueDate() ou setPixExpiresAt()', + E_USER_DEPRECATED + ); + + return $this->setDueDate($expiresAt); + } + + /** + * Converte data ou string numa instância de `Carbon`. + * + * @param CarbonInterface|string $date + * + * @return Carbon + */ + private static function toCarbon(CarbonInterface|string $date): Carbon + { + if ($date instanceof Carbon) { + return $date; } - $this->model->expiresAt = $expiresAt; - return $this; + + return $date instanceof CarbonInterface ? Carbon::instance($date) : Carbon::parse($date); } /** diff --git a/src/Builders/SubscriptionBuilder.php b/src/Builders/SubscriptionBuilder.php new file mode 100644 index 0000000..15971ac --- /dev/null +++ b/src/Builders/SubscriptionBuilder.php @@ -0,0 +1,320 @@ +model = new Subscription(); + } + + /** + * @return Subscription + * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException + * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException + */ + public function create(): Subscription + { + return parent::create(); + } + + /** + * Define o plano da assinatura. + * + * @param string $planId + * + * @return $this + */ + public function setPlanId(string $planId): SubscriptionBuilder + { + $this->model->planId = $planId; + + return $this; + } + + /** + * Define o cliente da assinatura. + * + * @param Customer $customer + * + * @return $this + */ + public function setCustomer(Customer $customer): SubscriptionBuilder + { + $this->model->customer = $customer; + + return $this; + } + + /** + * Define o cliente da assinatura pelo id que ele tem no gateway. + * + * @param string $customerId + * + * @return $this + */ + public function setCustomerId(string $customerId): SubscriptionBuilder + { + $this->model->customer = new Customer(); + $this->model->customer->id = $customerId; + + return $this; + } + + /** + * Define a data da próxima cobrança. + * + * @param Carbon|string $nextBillingAt + * + * @return $this + */ + public function setNextBillingAt(Carbon|string $nextBillingAt): SubscriptionBuilder + { + $this->model->nextBillingAt = $nextBillingAt instanceof Carbon + ? $nextBillingAt + : Carbon::parse($nextBillingAt); + + return $this; + } + + /** + * Define até quando vai o período de teste. + * + * @param Carbon|string $trialEndsAt + * + * @return $this + */ + public function setTrialEndsAt(Carbon|string $trialEndsAt): SubscriptionBuilder + { + $this->model->trialEndsAt = $trialEndsAt instanceof Carbon + ? $trialEndsAt + : Carbon::parse($trialEndsAt); + + return $this; + } + + /** + * Define a duração do período de teste em dias, contada do momento em que a assinatura é + * criada; o driver calcula a data de fim na hora da requisição (ver + * `Subscription::$trialDays`). + * + * @param int $trialDays + * + * @return $this + */ + public function setTrialDays(int $trialDays): SubscriptionBuilder + { + $this->model->trialDays = $trialDays; + + return $this; + } + + /** + * Define o método de pagamento da assinatura (ver `Subscription::$paymentMethod`). + * + * @param PaymentMethod|string $paymentMethod + * + * @return $this + */ + public function setPaymentMethod(PaymentMethod|string $paymentMethod): SubscriptionBuilder + { + $this->model->paymentMethod = $paymentMethod; + + return $this; + } + + /** + * Define o estado do mandato de Pix Automático (`startsAt`, `endsAt`, `frequency`) e o + * método de pagamento como Pix Automático (ver `Subscription::$automaticPix`). + * + * @param AutomaticPix $automaticPix + * + * @return $this + */ + public function setAutomaticPix(AutomaticPix $automaticPix): SubscriptionBuilder + { + $this->model->automaticPix = $automaticPix; + $this->model->paymentMethod = PaymentMethod::AUTOMATIC_PIX; + + return $this; + } + + /** + * Define o cartão que a assinatura cobra, pelo model ou pelo id de um cartão já salvo no + * cliente, e o método de pagamento como cartão (ver `Subscription::$creditCard`). + * + * @param CreditCard|string $creditCard + * + * @return $this + */ + public function setCreditCard(CreditCard|string $creditCard): SubscriptionBuilder + { + if (is_string($creditCard)) { + $id = $creditCard; + $creditCard = new CreditCard(); + $creditCard->id = $id; + } + + $this->model->creditCard = $creditCard; + $this->model->paymentMethod = PaymentMethod::CREDIT_CARD; + + return $this; + } + + /** + * Define os métodos de pagamento aceitos pela assinatura. + * + * @param \Potelo\MultiPayment\Enums\PaymentMethod[]|string[] $paymentMethods + * + * @return $this + */ + public function setAvailablePaymentMethods(array $paymentMethods): SubscriptionBuilder + { + $this->model->availablePaymentMethods = $paymentMethods; + + return $this; + } + + /** + * Substitui a lista de itens da assinatura. + * + * @param SubscriptionItem[] $items + * + * @return $this + */ + public function setItems(array $items): SubscriptionBuilder + { + $this->model->items = $items; + + return $this; + } + + /** + * Acrescenta um item à assinatura. + * + * @param string $description + * @param int $amount + * @param int $quantity + * @param bool $recurring + * + * @return $this + */ + public function addItem( + string $description, + int $amount, + int $quantity = 1, + bool $recurring = true + ): SubscriptionBuilder { + $item = new SubscriptionItem(); + $item->description = $description; + $item->amount = $amount; + $item->quantity = $quantity; + $item->recurring = $recurring; + $this->model->items[] = $item; + + return $this; + } + + /** + * Substitui a lista de descontos da assinatura. + * + * @param SubscriptionDiscount[] $discounts + * + * @return $this + */ + public function setDiscounts(array $discounts): SubscriptionBuilder + { + $this->model->discounts = $discounts; + + return $this; + } + + /** + * Acrescenta um desconto de valor fixo à assinatura. + * + * @param string $description + * @param int $amountOff Valor abatido, em centavos e positivo + * @param int|null $cycles null enquanto não for removido, 1 só na próxima fatura; exclusivo com $validUntil + * @param Carbon|null $validUntil data até a qual o desconto vale, inclusive + * + * @return $this + */ + public function addAmountDiscount( + string $description, + int $amountOff, + ?int $cycles = null, + ?Carbon $validUntil = null + ): SubscriptionBuilder { + $discount = new SubscriptionDiscount(); + $discount->description = $description; + $discount->amountOff = $amountOff; + $discount->cycles = $cycles; + $discount->validUntil = $validUntil; + $this->model->discounts[] = $discount; + + return $this; + } + + /** + * Acrescenta um desconto percentual à assinatura. + * + * @param string $description + * @param float $percentOff Percentual abatido, entre 0 e 100 + * @param int|null $cycles null enquanto não for removido, 1 só na próxima fatura; exclusivo com $validUntil + * @param Carbon|null $validUntil data até a qual o desconto vale, inclusive + * + * @return $this + */ + public function addPercentDiscount( + string $description, + float $percentOff, + ?int $cycles = null, + ?Carbon $validUntil = null + ): SubscriptionBuilder { + $discount = new SubscriptionDiscount(); + $discount->description = $description; + $discount->percentOff = $percentOff; + $discount->cycles = $cycles; + $discount->validUntil = $validUntil; + $this->model->discounts[] = $discount; + + return $this; + } + + /** + * Define os metadados enviados ao gateway junto da assinatura. + * + * @param array $metadata + * + * @return $this + */ + public function setMetadata(array $metadata): SubscriptionBuilder + { + $this->model->metadata = $metadata; + + return $this; + } +} diff --git a/src/Capabilities/CapabilityRestriction.php b/src/Capabilities/CapabilityRestriction.php new file mode 100644 index 0000000..1da2977 --- /dev/null +++ b/src/Capabilities/CapabilityRestriction.php @@ -0,0 +1,54 @@ +allowedPaymentMethods) || in_array($paymentMethod, $this->allowedPaymentMethods, true); + } + + /** + * Diz se a bandeira está dentro da restrição, sem diferenciar maiúsculas. Verdadeiro + * quando a restrição não é por bandeira. + * + * @param string $brand + * @return bool + */ + public function allowsBrand(string $brand): bool + { + return is_null($this->allowedBrands) + || in_array(strtolower($brand), array_map('strtolower', $this->allowedBrands), true); + } +} diff --git a/src/Console/SyncSubscriptionsCommand.php b/src/Console/SyncSubscriptionsCommand.php new file mode 100644 index 0000000..e8a1e37 --- /dev/null +++ b/src/Console/SyncSubscriptionsCommand.php @@ -0,0 +1,93 @@ +option('gateway') + ? [$this->option('gateway')] + : array_keys(Config::get('multi-payment.gateways', [])); + $dryRun = (bool) $this->option('dry-run'); + $prefix = $dryRun ? '[dry-run] ' : ''; + + foreach ($names as $name) { + if (!$this->option('gateway') && empty(Config::get("multi-payment.gateways.{$name}.api_key"))) { + $this->info("[{$name}] sem api_key configurada; gateway pulado."); + continue; + } + + $gateway = ConfigurationHelper::resolveGateway($name); + + $emulatesSubscriptions = $gateway->isEmulated(Capability::COUPONS) + || $gateway->isEmulated(Capability::CANCEL_AT_PERIOD_END); + if (!$emulatesSubscriptions) { + $this->info("[{$name}] o gateway gerencia cupom e cancelamento ao fim do ciclo; nada a sincronizar."); + continue; + } + + if (!$gateway instanceof SubscriptionSyncContract) { + throw ConfigurationException::GatewayMissingContract( + $gateway, + $gateway->isEmulated(Capability::COUPONS) ? Capability::COUPONS : Capability::CANCEL_AT_PERIOD_END, + SubscriptionSyncContract::class + ); + } + + $actions = $gateway->syncSubscriptions($dryRun); + foreach ($actions as $action) { + $line = "[{$name}] assinatura {$action['subscription']}: {$action['detail']}"; + $this->line($prefix . $line); + LogHelper::info("multipayment:sync-subscriptions {$prefix}{$line}", [ + 'gateway' => $name, + 'subscription' => $action['subscription'], + 'action' => $action['action'], + 'dry_run' => $dryRun, + ]); + } + + if ($actions === []) { + $this->info("[{$name}] nada a aplicar."); + } + } + + return self::SUCCESS; + } +} diff --git a/src/Contracts/AcceptsUnknownValue.php b/src/Contracts/AcceptsUnknownValue.php new file mode 100644 index 0000000..9494154 --- /dev/null +++ b/src/Contracts/AcceptsUnknownValue.php @@ -0,0 +1,21 @@ + + */ + public function restrictions(): array; + + /** + * Restrição da capability, ou nulo quando ela vale em todos os casos (ou não é suportada). + * + * @param Capability $capability + * @return CapabilityRestriction|null + */ + public function restriction(Capability $capability): ?CapabilityRestriction; +} diff --git a/src/Contracts/GatewayContract.php b/src/Contracts/GatewayContract.php index ca7e44d..4685364 100644 --- a/src/Contracts/GatewayContract.php +++ b/src/Contracts/GatewayContract.php @@ -2,7 +2,7 @@ namespace Potelo\MultiPayment\Contracts; -interface GatewayContract extends CreditCardContract, CustomerContract, InvoiceContract, AutomaticPixContract +interface GatewayContract extends CreditCardContract, CustomerContract, InvoiceContract, AutomaticPixContract, DeclaresCapabilities { public function __toString(); } diff --git a/src/Contracts/IdempotencyStore.php b/src/Contracts/IdempotencyStore.php new file mode 100644 index 0000000..814aa5c --- /dev/null +++ b/src/Contracts/IdempotencyStore.php @@ -0,0 +1,38 @@ +invoice`; o model recebido é atualizado no lugar. * * @param Invoice $invoice + * @param int|null $amount valor em centavos; nulo estorna o restante + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * - * @return Invoice - * @throws GatewayException + * @return Refund + * @throws GatewayException|GatewayNotAvailableException + * @throws \Potelo\MultiPayment\Exceptions\RefundNotSupportedException + * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException */ - public function refundInvoice(Invoice $invoice): Invoice; + public function refundInvoice(Invoice $invoice, ?int $amount = null, ?string $idempotencyKey = null): Refund; /** - * String representation of the gateway + * Valor que ainda pode ser estornado na fatura, em centavos: zero para fatura não paga ou + * já integralmente estornada. Lê a fatura (um GET) quando o model não traz o valor pago. * - * @return string + * @param Invoice $invoice + * @return int + * @throws GatewayException|GatewayNotAvailableException + * @throws \Potelo\MultiPayment\Exceptions\UnsupportedOperationException fatura que o driver não estorna + * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException `id` ausente */ + public function refundableAmount(Invoice $invoice): int; /** * Charge an invoice with a credit card * * @param \Potelo\MultiPayment\Models\Invoice $invoice + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * @return \Potelo\MultiPayment\Models\Invoice * @throws \Potelo\MultiPayment\Exceptions\ChargingException * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException */ - public function chargeInvoiceWithCreditCard(Invoice $invoice): Invoice; + public function chargeInvoiceWithCreditCard(Invoice $invoice, ?string $idempotencyKey = null): Invoice; /** * Duplicate an invoice @@ -65,17 +84,25 @@ public function chargeInvoiceWithCreditCard(Invoice $invoice): Invoice; * @param Invoice $invoice * @param \Carbon\Carbon $expiresAt * @param array $gatewayOptions + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * @return Invoice * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws \Potelo\MultiPayment\Exceptions\UnsupportedOperationException */ - public function duplicateInvoice(Invoice $invoice, Carbon $expiresAt, array $gatewayOptions = []): Invoice; + public function duplicateInvoice( + Invoice $invoice, + Carbon $expiresAt, + array $gatewayOptions = [], + ?string $idempotencyKey = null + ): Invoice; /** * Cancel an invoice. * * @param Invoice $invoice + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * @return Invoice * @throws GatewayException|GatewayNotAvailableException */ - public function cancelInvoice(Invoice $invoice): Invoice; + public function cancelInvoice(Invoice $invoice, ?string $idempotencyKey = null): Invoice; } diff --git a/src/Contracts/PlanContract.php b/src/Contracts/PlanContract.php new file mode 100644 index 0000000..15a55bf --- /dev/null +++ b/src/Contracts/PlanContract.php @@ -0,0 +1,55 @@ + + * @throws GatewayException|GatewayNotAvailableException + */ + public function syncSubscriptions(bool $dryRun = false): array; +} diff --git a/src/Enums/Capability.php b/src/Enums/Capability.php new file mode 100644 index 0000000..a553629 --- /dev/null +++ b/src/Enums/Capability.php @@ -0,0 +1,149 @@ + self::CREDIT_CARD, + PaymentMethod::PIX => self::PIX, + PaymentMethod::BANK_SLIP => self::BANK_SLIP, + PaymentMethod::AUTOMATIC_PIX => self::AUTOMATIC_PIX, + }; + } + + /** + * Descrição do que a capability significa para quem consome, lida do docblock do caso; + * um docblock de várias linhas vira uma linha só. Vazia quando o interpretador não guarda + * comentários. + * + * @return string + */ + public function description(): string + { + $docComment = (new \ReflectionEnumBackedCase(self::class, $this->name))->getDocComment(); + if ($docComment === false) { + return ''; + } + + $body = preg_replace('#^/\*\*|\*/$#', '', trim($docComment)); + $lines = array_map( + static fn (string $line) => trim(preg_replace('#^\s*\*\s?#', '', trim($line))), + preg_split('/\R/', $body) + ); + + return trim(implode(' ', array_filter($lines, static fn (string $line) => $line !== ''))); + } +} diff --git a/src/Enums/DeclineCode.php b/src/Enums/DeclineCode.php new file mode 100644 index 0000000..6073bad --- /dev/null +++ b/src/Enums/DeclineCode.php @@ -0,0 +1,84 @@ + true, + default => false, + }; + } + + /** + * Diz se a recusa pede ação do pagador antes de qualquer nova tentativa: corrigir dados, + * autenticar o cartão ou informar outro. + * + * @return bool + */ + public function requiresPayerAction(): bool + { + return match ($this) { + self::EXPIRED_CARD, + self::INCORRECT_CVC, + self::INCORRECT_NUMBER, + self::INVALID_CARD, + self::AUTHENTICATION_REQUIRED, + self::BRAND_NOT_SUPPORTED => true, + default => false, + }; + } +} diff --git a/src/Enums/InvoiceOriginType.php b/src/Enums/InvoiceOriginType.php new file mode 100644 index 0000000..9eba5c8 --- /dev/null +++ b/src/Enums/InvoiceOriginType.php @@ -0,0 +1,16 @@ + true, + default => false, + }; + } + + /** + * Diz se existe contestação sobre a fatura: `DISPUTED` (aberta) ou `CHARGEBACK` (perdida). + * + * @return bool + */ + public function isContested(): bool + { + return match ($this) { + self::DISPUTED, self::CHARGEBACK => true, + default => false, + }; + } + + /** + * Diz se a fatura chegou a um estado final, do qual o gateway não a tira: `REFUNDED`, + * `CHARGEBACK` e `CANCELED`. `EXPIRED` fica de fora (ver `isPayable()`). + * + * @return bool + */ + public function isTerminal(): bool + { + return match ($this) { + self::REFUNDED, self::CHARGEBACK, self::CANCELED => true, + default => false, + }; + } + + /** + * Diz se a fatura está em aberto, com o pagamento ainda por resolver: `PENDING`, + * `AUTHORIZED`, `PROCESSING` e `PARTIALLY_PAID`. + * + * @return bool + */ + public function isOpen(): bool + { + return match ($this) { + self::PENDING, self::AUTHORIZED, self::PROCESSING, self::PARTIALLY_PAID => true, + default => false, + }; + } + + /** + * Diz se a fatura aceita um pagamento agora: `PENDING`, `AUTHORIZED`, `PARTIALLY_PAID` e + * `EXPIRED` (a vencida segue pagável nos dois gateways). `PROCESSING` fica de fora: já há um + * pagamento em curso (ver `isOpen()`). + * + * @return bool + */ + public function isPayable(): bool + { + return match ($this) { + self::PENDING, self::AUTHORIZED, self::PARTIALLY_PAID, self::EXPIRED => true, + default => false, + }; + } + + /** + * Converte o valor de string no caso correspondente. Valor fora do enum devolve `UNKNOWN` + * e registra um aviso no log com o valor original e o gateway. + * + * @param string $value + * @param string|null $gateway + * @return static + */ + public static function fromValue(string $value, ?string $gateway = null): static + { + return self::tryFrom($value) ?? self::unknown($value, $gateway); + } + + /** + * Devolve `UNKNOWN` e registra um aviso no log com o valor original e o gateway. + * + * @param string $value + * @param string|null $gateway + * @return static + */ + public static function unknown(string $value, ?string $gateway = null): static + { + LogHelper::warning( + "Status de fatura desconhecido [{$value}] no gateway [" . ($gateway ?? 'desconhecido') . '], lido como unknown', + ['status' => $value, 'gateway' => $gateway] + ); + + return self::UNKNOWN; + } +} diff --git a/src/Enums/PaymentMethod.php b/src/Enums/PaymentMethod.php new file mode 100644 index 0000000..faade1a --- /dev/null +++ b/src/Enums/PaymentMethod.php @@ -0,0 +1,76 @@ + true, + default => false, + }; + } + + /** + * Converte o valor de string no caso correspondente. Valor fora do enum devolve `UNKNOWN` + * e registra um aviso no log com o valor original e o gateway. + * + * @param string $value + * @param string|null $gateway + * @return static + */ + public static function fromValue(string $value, ?string $gateway = null): static + { + return self::tryFrom($value) ?? self::unknown($value, $gateway); + } + + /** + * Devolve `UNKNOWN` e registra um aviso no log com o valor original e o gateway. + * + * @param string $value + * @param string|null $gateway + * @return static + */ + public static function unknown(string $value, ?string $gateway = null): static + { + LogHelper::warning( + "Status de estorno desconhecido [{$value}] no gateway [" . ($gateway ?? 'desconhecido') . '], lido como unknown', + ['status' => $value, 'gateway' => $gateway] + ); + + return self::UNKNOWN; + } +} diff --git a/src/Enums/SubscriptionStatus.php b/src/Enums/SubscriptionStatus.php new file mode 100644 index 0000000..c54c7af --- /dev/null +++ b/src/Enums/SubscriptionStatus.php @@ -0,0 +1,113 @@ + true, + default => false, + }; + } + + /** + * Diz se a assinatura ainda pode voltar a `ACTIVE` sem ser recriada: `PENDING`, + * `PAST_DUE`, `PAUSED` e `SUSPENDED`. + * + * @return bool + */ + public function isRecoverable(): bool + { + return match ($this) { + self::PENDING, self::PAST_DUE, self::PAUSED, self::SUSPENDED => true, + default => false, + }; + } + + /** + * Diz se a assinatura terminou: `CANCELED` e `EXPIRED`. + * + * @return bool + */ + public function isEnded(): bool + { + return match ($this) { + self::CANCELED, self::EXPIRED => true, + default => false, + }; + } + + /** + * Converte o valor de string no caso correspondente. Valor fora do enum devolve `UNKNOWN` + * e registra um aviso no log com o valor original e o gateway. + * + * @param string $value + * @param string|null $gateway + * @return static + */ + public static function fromValue(string $value, ?string $gateway = null): static + { + return self::tryFrom($value) ?? self::unknown($value, $gateway); + } + + /** + * Devolve `UNKNOWN` e registra um aviso no log com o valor original e o gateway. + * + * @param string $value + * @param string|null $gateway + * @return static + */ + public static function unknown(string $value, ?string $gateway = null): static + { + LogHelper::warning( + "Status de assinatura desconhecido [{$value}] no gateway [" . ($gateway ?? 'desconhecido') . '], lido como unknown', + ['status' => $value, 'gateway' => $gateway] + ); + + return self::UNKNOWN; + } +} diff --git a/src/Exceptions/AuthenticationException.php b/src/Exceptions/AuthenticationException.php new file mode 100644 index 0000000..ec879a8 --- /dev/null +++ b/src/Exceptions/AuthenticationException.php @@ -0,0 +1,36 @@ +value}, {$code})"; + if ($detail !== '') { + $message .= ": {$detail}"; + } + + $exception = new static($message, $previous, $httpStatus); + $exception->declineCode = $declineCode; + $exception->gatewayCode = $gatewayCode === '' ? null : $gatewayCode; + $exception->retryable = $retryable ?? $declineCode->isRetryable(); + $exception->reason = $declineCode->value; + + return $exception; + } +} diff --git a/src/Exceptions/ChargingException.php b/src/Exceptions/ChargingException.php index 26e0332..0a1d82b 100644 --- a/src/Exceptions/ChargingException.php +++ b/src/Exceptions/ChargingException.php @@ -2,10 +2,13 @@ namespace Potelo\MultiPayment\Exceptions; -class ChargingException extends MultiPaymentException +/** + * Nome antigo de `CardDeclinedException`. Uma recusa de cartão é capturada tanto por + * `catch (ChargingException $e)` quanto por `catch (CardDeclinedException $e)`, com + * `declineCode`, `gatewayCode` e `retryable` preenchidos. + * + * @deprecated Capture `CardDeclinedException`. Este nome deixa de ser lançado numa versão maior futura. + */ +class ChargingException extends CardDeclinedException { - /** - * @var mixed $chargeResponse The charge response from the gateway - */ - public $chargeResponse; } diff --git a/src/Exceptions/ConfigurationException.php b/src/Exceptions/ConfigurationException.php index 02c613f..9fbdb51 100644 --- a/src/Exceptions/ConfigurationException.php +++ b/src/Exceptions/ConfigurationException.php @@ -2,10 +2,43 @@ namespace Potelo\MultiPayment\Exceptions; +use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Contracts\GatewayContract; class ConfigurationException extends MultiPaymentException { + /** + * O driver declara a capability em `capabilities()` mas não implementa o contract que a + * operação exige (erro de driver, sem requisição). + * + * @param GatewayContract $gateway + * @param Capability $capability + * @param class-string $contract + * @return self + */ + public static function GatewayMissingContract(GatewayContract $gateway, Capability $capability, string $contract): self + { + $contractName = substr(strrchr($contract, '\\'), 1) ?: $contract; + + return new static( + 'Gateway [' . get_class($gateway) . "] declares the {$capability->value} capability" + . " but does not implement {$contractName}" + ); + } + + /** + * O driver passou na verificação de capability mas não tem o método que o despacho por + * convenção de nome esperava (erro de driver, sem requisição). + * + * @param string $gatewayClass + * @param string $method + * @return self + */ + public static function GatewayMethodNotFound(string $gatewayClass, string $method): self + { + return new static("Gateway [{$gatewayClass}] does not have method [{$method}]"); + } + /** * The gateway does not implement the interface. * @@ -41,4 +74,33 @@ public static function GatewayNotFound(string $gateway): self { return new static("Gateway class [{$gateway}] not found."); } -} \ No newline at end of file + + /** + * Nenhuma `IdempotencyStore` registrada no container, e a operação recebeu uma chave de + * idempotência num endpoint que o gateway não deduplica sozinho. + * + * @return self + */ + public static function IdempotencyStoreNotConfigured(): self + { + return new static( + 'Nenhuma IdempotencyStore registrada no container: registre o MultiPaymentServiceProvider ' + . '(que usa o cache do Laravel) ou faça bind de Potelo\\MultiPayment\\Contracts\\IdempotencyStore.' + ); + } + + /** + * O cache store configurado para a `CacheIdempotencyStore` não suporta lock. + * + * @param string $storeClass + * @return self + */ + public static function IdempotencyStoreWithoutLock(string $storeClass): self + { + return new static( + "O cache store [{$storeClass}] não suporta lock; a CacheIdempotencyStore exige um store " + . 'com LockProvider (redis, memcached, database, file, array ou dynamodb). Configure ' + . 'multi-payment.idempotency.cache_store com um deles.' + ); + } +} diff --git a/src/Exceptions/GatewayException.php b/src/Exceptions/GatewayException.php index 7934a98..75d98ed 100644 --- a/src/Exceptions/GatewayException.php +++ b/src/Exceptions/GatewayException.php @@ -8,12 +8,15 @@ class GatewayException extends MultiPaymentException private $errors; /** - * GatewayException constructor. + * Cria a exceção com os erros devolvidos pelo gateway, a exceção original do SDK (quando + * houver) e o status HTTP da resposta. * * @param string $message - * @param $errors + * @param mixed $errors corpo de erro do gateway: string, objeto ou array + * @param \Throwable|null $previous + * @param int|null $httpStatus */ - public function __construct(string $message = "", $errors = null) + public function __construct(string $message = "", $errors = null, ?\Throwable $previous = null, ?int $httpStatus = null) { $this->errors = $errors; $appends = $this->parseErrorsToString($errors); @@ -22,20 +25,29 @@ public function __construct(string $message = "", $errors = null) $message .= ' - ' . $appends; } - parent::__construct($message); + parent::__construct($message, $previous, $httpStatus); } /** + * Retorna os erros do gateway normalizados para array — podem ter sido + * informados como nulo, string, objeto ou array. + * * @return array */ public function getErrors(): array { - return $this->errors; + if (is_null($this->errors)) { + return []; + } + + return is_array($this->errors) ? $this->errors : (array) $this->errors; } /** - * Method not found in gateway. + * Dispatch method missing in a gateway that declares the capability (driver error). * + * @deprecated desde 2026-09-02, use `ConfigurationException::GatewayMethodNotFound()`: a falta + * do método é erro de configuração do driver, sem resposta do gateway. * @param string $gatewayClass * @param string $method * diff --git a/src/Exceptions/GatewayNotAvailableException.php b/src/Exceptions/GatewayNotAvailableException.php index bafedbd..6f755c4 100644 --- a/src/Exceptions/GatewayNotAvailableException.php +++ b/src/Exceptions/GatewayNotAvailableException.php @@ -2,6 +2,12 @@ namespace Potelo\MultiPayment\Exceptions; +/** + * Gateway fora do ar no momento da chamada: erro 5xx, falha de conexão ou timeout de rede. + * + * A aplicação pode repetir a operação mais tarde ou tentar outro gateway. Credencial recusada + * chega como `AuthenticationException`. + */ class GatewayNotAvailableException extends MultiPaymentException { diff --git a/src/Exceptions/IdempotencyConflictException.php b/src/Exceptions/IdempotencyConflictException.php new file mode 100644 index 0000000..55a553f --- /dev/null +++ b/src/Exceptions/IdempotencyConflictException.php @@ -0,0 +1,71 @@ +resourceId = $resourceId; + + return $exception; + } + + /** + * A chave já foi usada, na `IdempotencyStore` da lib, numa operação diferente desta. + * + * @param string $key + * @return static + */ + public static function reusedOnAnotherOperation(string $key): static + { + return new static( + "A chave de idempotência [{$key}] já foi usada em outra operação; use uma chave por operação." + ); + } + + /** + * Outra execução com a mesma chave está em andamento na `IdempotencyStore` da lib. + * + * @param string $key + * @return static + */ + public static function concurrent(string $key): static + { + return new static( + "Outra requisição com a chave de idempotência [{$key}] está em andamento; " + . 'aguarde o resultado dela em vez de repetir.' + ); + } +} diff --git a/src/Exceptions/ModelAttributeValidationException.php b/src/Exceptions/ModelAttributeValidationException.php index cf54e59..350e27c 100644 --- a/src/Exceptions/ModelAttributeValidationException.php +++ b/src/Exceptions/ModelAttributeValidationException.php @@ -30,4 +30,20 @@ public static function invalid(string $model, string $attribute, string $message { return new static("The `{$attribute}` attribute is invalid for the `{$model}` model. {$message}"); } + + /** + * Chave desconhecida em `fill()`: o model não tem a propriedade correspondente. A mensagem + * lista as chaves aceitas. + * + * @param string $model + * @param string $key chave recebida, como veio no array + * @param string[] $accepted chaves aceitas, em `snake_case` + * @return ModelAttributeValidationException + */ + public static function unknownAttribute(string $model, string $key, array $accepted): ModelAttributeValidationException + { + return new static( + "The `{$key}` key is unknown for the `{$model}` model. Accepted keys: " . implode(', ', $accepted) . '.' + ); + } } diff --git a/src/Exceptions/MultiPaymentException.php b/src/Exceptions/MultiPaymentException.php index 7c23d1c..ca1498c 100644 --- a/src/Exceptions/MultiPaymentException.php +++ b/src/Exceptions/MultiPaymentException.php @@ -4,5 +4,25 @@ class MultiPaymentException extends \Exception { - // + /** + * Status HTTP da resposta do gateway que originou a exceção. Nulo quando não houve + * resposta HTTP: falha de rede, validação local, chave de API não configurada. + * + * @var int|null + */ + public ?int $httpStatus = null; + + /** + * Cria a exceção anexando a original do SDK (quando houver) e o status HTTP da resposta. + * + * @param string $message + * @param \Throwable|null $previous + * @param int|null $httpStatus + */ + public function __construct(string $message = '', ?\Throwable $previous = null, ?int $httpStatus = null) + { + $this->httpStatus = $httpStatus; + + parent::__construct($message, 0, $previous); + } } diff --git a/src/Exceptions/NotFoundException.php b/src/Exceptions/NotFoundException.php new file mode 100644 index 0000000..f96800e --- /dev/null +++ b/src/Exceptions/NotFoundException.php @@ -0,0 +1,11 @@ +retryAfter = $retryAfter; + + return $exception; + } +} diff --git a/src/Exceptions/RefundNotSupportedException.php b/src/Exceptions/RefundNotSupportedException.php new file mode 100644 index 0000000..5739aec --- /dev/null +++ b/src/Exceptions/RefundNotSupportedException.php @@ -0,0 +1,238 @@ +paymentMethod = $paymentMethod; + $this->reason = $reason; + $this->manualRefundRequired = $manualRefundRequired; + $this->gateway = $gateway; + $this->capability = $capability; + + parent::__construct($message, $previous); + } + + /** + * Diz se a recusa é limitação do gateway, descrita por `$capability` (boleto sem estorno, + * Pix sem estorno parcial). Falso quando a recusa vem do estado da fatura: já estornada, + * valor acima do restante ou prazo vencido. + * + * @return bool + */ + public function isCapabilityLimitation(): bool + { + return !is_null($this->capability); + } + + /** + * Sempre falso: nenhuma recusa de estorno é uma capability que a lib ainda não implementou. + * + * @deprecated desde 2026-09-02, sem substituto; responde sempre falso. + * @return bool + */ + public function isNotImplemented(): bool + { + trigger_error( + 'RefundNotSupportedException::isNotImplemented() está obsoleto desde 2026-09-02 e responde sempre falso', + E_USER_DEPRECATED + ); + + return false; + } + + /** + * Boleto não tem estorno via API no gateway informado. + * + * @param string $gateway + * @return static + */ + public static function boletoNoRefund(string $gateway): static + { + return new static( + "O gateway {$gateway} não estorna boleto pela API; faça a devolução manualmente ao cliente.", + 'bank_slip', + self::REASON_BOLETO_NO_REFUND, + true, + null, + $gateway, + Capability::REFUND_BANK_SLIP + ); + } + + /** + * O gateway só aceita estorno integral de Pix e o valor pedido difere do valor pago. + * + * @param string $gateway + * @param int $requestedAmount valor pedido, em centavos + * @param int|null $paidAmount valor pago, em centavos + * @return static + */ + public static function pixPartialNotSupported(string $gateway, int $requestedAmount, ?int $paidAmount): static + { + $paid = is_null($paidAmount) ? 'desconhecido' : (string) $paidAmount; + + return new static( + "O gateway {$gateway} só estorna Pix integralmente (pedido: {$requestedAmount} centavos, pago: {$paid}); repita sem valor parcial.", + 'pix', + self::REASON_PIX_PARTIAL_NOT_SUPPORTED, + false, + null, + $gateway, + Capability::PARTIAL_REFUND_PIX + ); + } + + /** + * A fatura já está integralmente estornada. + * + * @param string $gateway + * @param string|null $paymentMethod + * @return static + */ + public static function alreadyRefunded(string $gateway, ?string $paymentMethod): static + { + return new static( + "A fatura já foi integralmente estornada no gateway {$gateway}.", + $paymentMethod, + self::REASON_ALREADY_REFUNDED, + false, + null, + $gateway + ); + } + + /** + * O prazo de estorno do gateway, contado a partir do pagamento, já passou. + * + * @param string $gateway + * @param string|null $paymentMethod + * @param \Carbon\Carbon $paidAt + * @param int $windowDays + * @return static + */ + public static function refundWindowExpired(string $gateway, ?string $paymentMethod, Carbon $paidAt, int $windowDays): static + { + return new static( + "O gateway {$gateway} só estorna até {$windowDays} dias após o pagamento (pago em {$paidAt->toDateString()}); faça a devolução manualmente ao cliente.", + $paymentMethod, + self::REASON_REFUND_WINDOW_EXPIRED, + true, + null, + $gateway + ); + } + + /** + * O valor pedido passa do que ainda pode ser estornado na fatura. + * + * @param string $gateway + * @param string|null $paymentMethod + * @param int $requestedAmount valor pedido, em centavos + * @param int $refundableAmount valor que ainda pode ser estornado, em centavos + * @return static + */ + public static function amountExceedsRefundable(string $gateway, ?string $paymentMethod, int $requestedAmount, int $refundableAmount): static + { + return new static( + "O valor pedido ({$requestedAmount} centavos) passa do que ainda pode ser estornado na fatura ({$refundableAmount} centavos) no gateway {$gateway}; repita com valor até o restante.", + $paymentMethod, + self::REASON_AMOUNT_EXCEEDS_REFUNDABLE, + false, + null, + $gateway + ); + } +} diff --git a/src/Exceptions/UnsupportedOperationException.php b/src/Exceptions/UnsupportedOperationException.php new file mode 100644 index 0000000..f129c63 --- /dev/null +++ b/src/Exceptions/UnsupportedOperationException.php @@ -0,0 +1,174 @@ +gateway = $gateway; + $this->capability = $capability; + $this->reason = $reason; + + parent::__construct($message, $previous, $httpStatus); + } + + /** + * O gateway oferece o recurso e a lib ainda não o implementou para ele. + * + * @param string $gateway + * @param Capability $capability + * @param string $detail orientação acrescentada ao fim da mensagem + * @return static + */ + public static function notImplemented(string $gateway, Capability $capability, string $detail = ''): static + { + $message = "A capability [{$capability->value}] ainda não está implementada nesta lib para o gateway" + . " {$gateway}; o gateway oferece o recurso."; + + return new static(self::appendDetail($message, $detail), $gateway, $capability, self::REASON_NOT_IMPLEMENTED); + } + + /** + * O gateway não oferece o recurso. + * + * @param string $gateway + * @param Capability $capability + * @param string $detail orientação acrescentada ao fim da mensagem + * @return static + */ + public static function gatewayLimitation(string $gateway, Capability $capability, string $detail = ''): static + { + $message = "O gateway {$gateway} não oferece a capability [{$capability->value}]."; + + return new static(self::appendDetail($message, $detail), $gateway, $capability, self::REASON_GATEWAY_LIMITATION); + } + + /** + * O gateway oferece a capability só numa parte dos casos e este pedido está fora dela; a + * mensagem descreve a restrição. O motivo é `gateway_limitation`. + * + * @param string $gateway + * @param Capability $capability + * @param string $message + * @return static + */ + public static function restricted(string $gateway, Capability $capability, string $message): static + { + return new static($message, $gateway, $capability, self::REASON_GATEWAY_LIMITATION); + } + + /** + * O próprio gateway conduz a operação, então a chamada pela lib não se aplica; a mensagem + * orienta o caminho que vale nesse gateway. + * + * @param string $gateway + * @param Capability $capability + * @param string $detail orientação acrescentada ao fim da mensagem + * @return static + */ + public static function managedByGateway(string $gateway, Capability $capability, string $detail = ''): static + { + $message = "No gateway {$gateway} a operação de [{$capability->value}] é conduzida pelo próprio gateway" + . ' e não se aplica pela lib.'; + + return new static(self::appendDetail($message, $detail), $gateway, $capability, self::REASON_MANAGED_BY_GATEWAY); + } + + /** + * Decide o motivo pela declaração do gateway: capability em `notYetImplemented()` é + * `not_implemented`; fora das duas listas é `gateway_limitation`. + * + * @param GatewayContract $gateway + * @param Capability $capability + * @param string $detail orientação acrescentada ao fim da mensagem + * @return static + */ + public static function forGateway(GatewayContract $gateway, Capability $capability, string $detail = ''): static + { + if (in_array($capability, $gateway->notYetImplemented(), true)) { + return self::notImplemented((string) $gateway, $capability, $detail); + } + + return self::gatewayLimitation((string) $gateway, $capability, $detail); + } + + /** + * Diz se o motivo é `not_implemented`: o gateway oferece o recurso e a lib ainda não o + * implementou para ele. + * + * @return bool + */ + public function isNotImplemented(): bool + { + return $this->reason === self::REASON_NOT_IMPLEMENTED; + } + + /** + * Acrescenta a orientação à mensagem, separada por espaço. + * + * @param string $message + * @param string $detail + * @return string + */ + private static function appendDetail(string $message, string $detail): string + { + return $detail === '' ? $message : "{$message} {$detail}"; + } +} diff --git a/src/Exceptions/ValidationException.php b/src/Exceptions/ValidationException.php new file mode 100644 index 0000000..a4930e5 --- /dev/null +++ b/src/Exceptions/ValidationException.php @@ -0,0 +1,115 @@ + [mensagens]`. Erro que o + * gateway não atribui a um campo fica na chave `base`. + * + * @var array> + */ + public array $fieldErrors = []; + + /** + * Cria a exceção com os erros por campo já normalizados. + * + * @param string $message + * @param array> $fieldErrors + * @param mixed $errors corpo de erro do gateway, como em `GatewayException` + * @param \Throwable|null $previous + * @param int|null $httpStatus + * @return static + */ + public static function withFieldErrors( + string $message, + array $fieldErrors, + $errors = null, + ?\Throwable $previous = null, + ?int $httpStatus = null + ): static { + $exception = new static($message, $errors, $previous, $httpStatus); + $exception->fieldErrors = $fieldErrors; + + return $exception; + } + + /** + * Converte o corpo de erro de um gateway para o formato `campo => [mensagens]`. String vira + * `base`; lista sem chave nomeada vira `base`; valor que não é string vira o seu JSON. + * + * @param mixed $errors string, objeto ou array com os erros do gateway + * @return array> + */ + public static function normalizeFieldErrors($errors): array + { + if ($errors === null || $errors === '' || $errors === []) { + return []; + } + + if (is_string($errors)) { + return ['base' => [$errors]]; + } + + if (is_object($errors)) { + $errors = (array) $errors; + } + + if (!is_array($errors)) { + return ['base' => [json_encode($errors)]]; + } + + $fieldErrors = []; + foreach ($errors as $field => $messages) { + $key = is_int($field) ? 'base' : (string) $field; + foreach (self::messagesOf($messages) as $message) { + $fieldErrors[$key][] = $message; + } + } + + return $fieldErrors; + } + + /** + * Achata o valor de um campo numa lista de strings. + * + * @param mixed $messages + * @return array + */ + private static function messagesOf($messages): array + { + if (is_string($messages)) { + return [$messages]; + } + + if (is_object($messages)) { + $messages = (array) $messages; + } + + if (!is_array($messages)) { + return [json_encode($messages)]; + } + + $list = []; + foreach ($messages as $message) { + if (is_string($message)) { + $list[] = $message; + } elseif (is_object($message) && isset($message->message) && is_string($message->message)) { + $list[] = $message->message; + } elseif (is_array($message) && isset($message['message']) && is_string($message['message'])) { + $list[] = $message['message']; + } else { + $list[] = json_encode($message); + } + } + + return $list; + } +} diff --git a/src/Facades/MultiPayment.php b/src/Facades/MultiPayment.php index 4a0b1c8..9267b2a 100644 --- a/src/Facades/MultiPayment.php +++ b/src/Facades/MultiPayment.php @@ -11,19 +11,39 @@ /** - * @method static invoice charge(array $attributes) + * @method static Invoice charge(array $attributes, ?string $idempotencyKey = null) * @method static InvoiceBuilder newInvoice() * @method static CustomerBuilder newCustomer() * @method static CreditCardBuilder newCreditCard() + * @method static \Potelo\MultiPayment\Builders\SubscriptionBuilder newSubscription() + * @method static \Potelo\MultiPayment\Models\Subscription[] listSubscriptions(\Potelo\MultiPayment\Models\Customer|string $customer, int $page = 1, int $limit = 100) + * @method static \Potelo\MultiPayment\Models\Plan[] listPlans(int $page = 1, int $limit = 100) + * @method static Invoice getInvoice(string $id) + * @method static \Potelo\MultiPayment\Models\Subscription getSubscription(string $id) + * @method static \Potelo\MultiPayment\Models\Plan getPlan(string $idOrIdentifier) + * @method static \Potelo\MultiPayment\Models\Customer getCustomer(string $id) + * @method static \Potelo\MultiPayment\Models\Refund refundInvoice(string $id, ?int $partialValueCents = null, ?string $idempotencyKey = null) + * @method static int refundableAmount(string $id) + * @method static Invoice duplicateInvoice(Invoice|string $invoice, \Carbon\Carbon $expiresAt, array $gatewayOptions = [], ?string $idempotencyKey = null) * @method static CreditCard getCard(string $customerId, string $creditCardId) - * @method static void deleteCard(string $customerId, string $creditCardId) + * @method static CreditCard confirmCreditCardSetup(string $setupId, ?string $idempotencyKey = null) + * @method static void deleteCard(string $customerId, string $creditCardId, ?string $idempotencyKey = null) * @method static \Potelo\MultiPayment\MultiPayment setGateway($gateway) - * @method static Invoice chargeInvoiceWithCreditCard($invoice, ?string $creditCardToken = null, ?string $creditCardId = null) - * @method static \Potelo\MultiPayment\Models\Customer setDefaultCard(string $customerId, string $creditCardId) - * @method static Invoice cancelInvoice(Invoice|string $invoice) - * @method static Invoice rescheduleAutomaticPixPayment(Invoice|string $invoice) - * @method static \Potelo\MultiPayment\Models\AutomaticPixCancellation cancelAutomaticPixRecurrence(\Potelo\MultiPayment\Models\AutomaticPix|string $automaticPix) - * @method static \Potelo\MultiPayment\Models\AutomaticPixCancellation cancelAutomaticPixScheduledPayment(\Potelo\MultiPayment\Models\AutomaticPixCharge|string $charge, ?string $endToEndId = null) + * @method static \Potelo\MultiPayment\Contracts\GatewayContract gateway($gateway = null) + * @method static bool supports(\Potelo\MultiPayment\Enums\Capability $capability, $gateway = null) + * @method static \Potelo\MultiPayment\Enums\Capability[] capabilities($gateway = null) + * @method static \Potelo\MultiPayment\Enums\Capability[] notYetImplemented($gateway = null) + * @method static \Potelo\MultiPayment\Enums\Capability[] emulated($gateway = null) + * @method static bool isEmulated(\Potelo\MultiPayment\Enums\Capability $capability, $gateway = null) + * @method static bool supportsAll(\Potelo\MultiPayment\Enums\Capability ...$capabilities) + * @method static \Potelo\MultiPayment\Capabilities\CapabilityRestriction|null restriction(\Potelo\MultiPayment\Enums\Capability $capability, $gateway = null) + * @method static array restrictions($gateway = null) + * @method static Invoice chargeInvoiceWithCreditCard($invoice, ?string $creditCardToken = null, ?string $creditCardId = null, ?string $idempotencyKey = null) + * @method static \Potelo\MultiPayment\Models\Customer setDefaultCard(string $customerId, string $creditCardId, ?string $idempotencyKey = null) + * @method static Invoice cancelInvoice(Invoice|string $invoice, ?string $idempotencyKey = null) + * @method static Invoice rescheduleAutomaticPixPayment(Invoice|string $invoice, ?string $idempotencyKey = null) + * @method static \Potelo\MultiPayment\Models\AutomaticPixCancellation cancelAutomaticPixRecurrence(\Potelo\MultiPayment\Models\AutomaticPix|string $automaticPix, ?string $idempotencyKey = null) + * @method static \Potelo\MultiPayment\Models\AutomaticPixCancellation cancelAutomaticPixScheduledPayment(\Potelo\MultiPayment\Models\AutomaticPixCharge|string $charge, ?string $endToEndId = null, ?string $idempotencyKey = null) * @method static \Potelo\MultiPayment\Models\AutomaticPixCancellation getAutomaticPixCancellation(\Potelo\MultiPayment\Models\AutomaticPixCancellation|string $cancellation, ?string $cancellationId = null) * @method static \Potelo\MultiPayment\Models\AutomaticPixCancellation[] listAutomaticPixCancellations(\Potelo\MultiPayment\Models\AutomaticPix|string $automaticPix, int $page = 1, int $limit = 100) */ diff --git a/src/Gateways/Concerns/ChecksCapabilities.php b/src/Gateways/Concerns/ChecksCapabilities.php new file mode 100644 index 0000000..f02e1a6 --- /dev/null +++ b/src/Gateways/Concerns/ChecksCapabilities.php @@ -0,0 +1,110 @@ +emulated(), true); + } + + /** + * @inheritDoc + */ + public function supports(Capability $capability): bool + { + return in_array($capability, $this->capabilities(), true) || $this->isEmulated($capability); + } + + /** + * @inheritDoc + */ + public function supportsAll(Capability ...$capabilities): bool + { + foreach ($capabilities as $capability) { + if (!$this->supports($capability)) { + return false; + } + } + + return true; + } + + /** + * @inheritDoc + */ + public function restrictions(): array + { + return []; + } + + /** + * @inheritDoc + */ + public function restriction(Capability $capability): ?CapabilityRestriction + { + return $this->restrictions()[$capability->value] ?? null; + } + + /** + * Lança `UnsupportedOperationException` quando a capability não está em `capabilities()`. + * + * @param Capability $capability + * @param string $detail orientação acrescentada ao fim da mensagem + * @return void + * @throws UnsupportedOperationException + */ + protected function assertSupports(Capability $capability, string $detail = ''): void + { + if (!$this->supports($capability)) { + throw UnsupportedOperationException::forGateway($this, $capability, $detail); + } + } + + /** + * Lança `UnsupportedOperationException` na primeira capability da lista que não está em + * `capabilities()`. + * + * @param Capability[] $capabilities + * @return void + * @throws UnsupportedOperationException + */ + protected function assertSupportsAll(array $capabilities): void + { + foreach ($capabilities as $capability) { + $this->assertSupports($capability); + } + } +} diff --git a/src/Gateways/Concerns/ResolvesIdempotencyKey.php b/src/Gateways/Concerns/ResolvesIdempotencyKey.php new file mode 100644 index 0000000..3118730 --- /dev/null +++ b/src/Gateways/Concerns/ResolvesIdempotencyKey.php @@ -0,0 +1,75 @@ +gatewayOptions[self::LEGACY_IDEMPOTENCY_OPTION] + ?? $extraOptions[self::LEGACY_IDEMPOTENCY_OPTION] + ?? null; + + if (!is_null($legacy)) { + trigger_error( + "gateway_options['idempotency_key'] está obsoleto desde 2026-09-02; passe idempotencyKey como argumento da operação", + E_USER_DEPRECATED + ); + } + + if (!is_null($idempotencyKey)) { + return $idempotencyKey; + } + + return is_scalar($legacy) && (string) $legacy !== '' ? (string) $legacy : null; + } + + /** + * Opções do gateway sem a chave antiga de idempotência, para ela não ir no corpo da + * requisição. + * + * @param array $gatewayOptions + * @return array + */ + protected static function withoutIdempotencyKey(array $gatewayOptions): array + { + unset($gatewayOptions[self::LEGACY_IDEMPOTENCY_OPTION]); + + return $gatewayOptions; + } + + /** + * Chave derivada `{chave}:{sufixo}` para uma requisição secundária da mesma operação (o + * cartão salvo antes da cobrança, a remoção de itens antes do update). A derivação é + * determinística, então um retry reproduz as mesmas chaves. + * + * @param string|null $idempotencyKey + * @param string $suffix + * @return string|null nulo quando não há chave + */ + protected static function derivedIdempotencyKey(?string $idempotencyKey, string $suffix): ?string + { + return IdempotencyKey::derive($idempotencyKey, $suffix); + } +} diff --git a/src/Gateways/Iugu/DeclineCodes.php b/src/Gateways/Iugu/DeclineCodes.php new file mode 100644 index 0000000..0f8df83 --- /dev/null +++ b/src/Gateways/Iugu/DeclineCodes.php @@ -0,0 +1,219 @@ + */ + private const MAP = [ + // saldo ou limite + '51' => DeclineCode::INSUFFICIENT_FUNDS, + '61' => DeclineCode::INSUFFICIENT_FUNDS, + '65' => DeclineCode::INSUFFICIENT_FUNDS, + '70' => DeclineCode::INSUFFICIENT_FUNDS, + 'BL' => DeclineCode::INSUFFICIENT_FUNDS, + 'DM' => DeclineCode::INSUFFICIENT_FUNDS, + 'N4' => DeclineCode::INSUFFICIENT_FUNDS, + + // cartão vencido + '54' => DeclineCode::EXPIRED_CARD, + + // número do cartão + '14' => DeclineCode::INCORRECT_NUMBER, + '25' => DeclineCode::INCORRECT_NUMBER, + + // dados do cartão ou do pagador ("verifique os dados", "dados inválidos", conta inexistente, + // cartão novo não desbloqueado, emissor não localizado pelo BIN) + '1' => DeclineCode::INVALID_CARD, + '12' => DeclineCode::INVALID_CARD, + '15' => DeclineCode::INVALID_CARD, + '30' => DeclineCode::INVALID_CARD, + '46' => DeclineCode::INVALID_CARD, + '56' => DeclineCode::INVALID_CARD, + '78' => DeclineCode::INVALID_CARD, + '101' => DeclineCode::INVALID_CARD, + '111' => DeclineCode::INVALID_CARD, + '115' => DeclineCode::INVALID_CARD, + '122' => DeclineCode::INVALID_CARD, + '6P' => DeclineCode::INVALID_CARD, + 'AV' => DeclineCode::INVALID_CARD, + 'BM' => DeclineCode::INVALID_CARD, + 'BP' => DeclineCode::INVALID_CARD, + 'BR' => DeclineCode::INVALID_CARD, + 'CF' => DeclineCode::INVALID_CARD, + 'CG' => DeclineCode::INVALID_CARD, + 'DF' => DeclineCode::INVALID_CARD, + 'DQ' => DeclineCode::INVALID_CARD, + 'G4' => DeclineCode::INVALID_CARD, + 'KA' => DeclineCode::INVALID_CARD, + 'KE' => DeclineCode::INVALID_CARD, + 'U3' => DeclineCode::INVALID_CARD, + + // cartão perdido, roubado, retido ou bloqueado pelo emissor + '4' => DeclineCode::LOST_OR_STOLEN, + '41' => DeclineCode::LOST_OR_STOLEN, + '43' => DeclineCode::LOST_OR_STOLEN, + '62' => DeclineCode::LOST_OR_STOLEN, + '146' => DeclineCode::LOST_OR_STOLEN, + 'BN' => DeclineCode::LOST_OR_STOLEN, + + // fraude confirmada, suspeita ou antifraude + '7' => DeclineCode::FRAUD_SUSPECTED, + '59' => DeclineCode::FRAUD_SUSPECTED, + 'AF01' => DeclineCode::FRAUD_SUSPECTED, + 'AF02' => DeclineCode::FRAUD_SUSPECTED, + 'BP171' => DeclineCode::FRAUD_SUSPECTED, + + // autenticação do pagador não realizada + 'AI' => DeclineCode::AUTHENTICATION_REQUIRED, + + // transação não permitida para o cartão, função incorreta (crédito ou débito), produto não habilitado + '39' => DeclineCode::BRAND_NOT_SUPPORTED, + '52' => DeclineCode::BRAND_NOT_SUPPORTED, + '53' => DeclineCode::BRAND_NOT_SUPPORTED, + '57' => DeclineCode::BRAND_NOT_SUPPORTED, + '79' => DeclineCode::BRAND_NOT_SUPPORTED, + '5C' => DeclineCode::BRAND_NOT_SUPPORTED, + 'AB' => DeclineCode::BRAND_NOT_SUPPORTED, + 'AC' => DeclineCode::BRAND_NOT_SUPPORTED, + 'AH' => DeclineCode::BRAND_NOT_SUPPORTED, + 'C1' => DeclineCode::BRAND_NOT_SUPPORTED, + 'DS' => DeclineCode::BRAND_NOT_SUPPORTED, + 'EK' => DeclineCode::BRAND_NOT_SUPPORTED, + 'G5' => DeclineCode::BRAND_NOT_SUPPORTED, + + // "contate a central do seu cartão", "não tente novamente", transação negada pelo emissor, + // suspensão de pagamento recorrente pelo emissor, violação de segurança + '5' => DeclineCode::DO_NOT_HONOR, + '6' => DeclineCode::DO_NOT_HONOR, + '60' => DeclineCode::DO_NOT_HONOR, + '63' => DeclineCode::DO_NOT_HONOR, + '67' => DeclineCode::DO_NOT_HONOR, + '93' => DeclineCode::DO_NOT_HONOR, + '99' => DeclineCode::DO_NOT_HONOR, + '100' => DeclineCode::DO_NOT_HONOR, + '109' => DeclineCode::DO_NOT_HONOR, + '110' => DeclineCode::DO_NOT_HONOR, + '116' => DeclineCode::DO_NOT_HONOR, + '121' => DeclineCode::DO_NOT_HONOR, + '181' => DeclineCode::DO_NOT_HONOR, + '200' => DeclineCode::DO_NOT_HONOR, + 'B1' => DeclineCode::DO_NOT_HONOR, + 'B2' => DeclineCode::DO_NOT_HONOR, + 'BP176' => DeclineCode::DO_NOT_HONOR, + 'C2' => DeclineCode::DO_NOT_HONOR, + 'C3' => DeclineCode::DO_NOT_HONOR, + 'FC' => DeclineCode::DO_NOT_HONOR, + 'FG' => DeclineCode::DO_NOT_HONOR, + 'GA' => DeclineCode::DO_NOT_HONOR, + 'GD' => DeclineCode::DO_NOT_HONOR, + 'GF' => DeclineCode::DO_NOT_HONOR, + 'GK' => DeclineCode::DO_NOT_HONOR, + 'GT' => DeclineCode::DO_NOT_HONOR, + 'N7' => DeclineCode::DO_NOT_HONOR, + 'NR' => DeclineCode::DO_NOT_HONOR, + 'R0' => DeclineCode::DO_NOT_HONOR, + 'R1' => DeclineCode::DO_NOT_HONOR, + 'R2' => DeclineCode::DO_NOT_HONOR, + 'R3' => DeclineCode::DO_NOT_HONOR, + 'RE' => DeclineCode::DO_NOT_HONOR, + 'RP' => DeclineCode::DO_NOT_HONOR, + 'SC' => DeclineCode::DO_NOT_HONOR, + + // emissor fora do ar, falha de sistema ou de comunicação, timeout, problema no adquirente + '19' => DeclineCode::TRY_AGAIN, + '28' => DeclineCode::TRY_AGAIN, + '85' => DeclineCode::TRY_AGAIN, + '89' => DeclineCode::TRY_AGAIN, + '90' => DeclineCode::TRY_AGAIN, + '91' => DeclineCode::TRY_AGAIN, + '92' => DeclineCode::TRY_AGAIN, + '96' => DeclineCode::TRY_AGAIN, + '98' => DeclineCode::TRY_AGAIN, + '911' => DeclineCode::TRY_AGAIN, + '912' => DeclineCode::TRY_AGAIN, + '999' => DeclineCode::TRY_AGAIN, + '99A' => DeclineCode::TRY_AGAIN, + '99B' => DeclineCode::TRY_AGAIN, + '99C' => DeclineCode::TRY_AGAIN, + '99TA' => DeclineCode::TRY_AGAIN, + '99Z' => DeclineCode::TRY_AGAIN, + 'AA' => DeclineCode::TRY_AGAIN, + 'AF' => DeclineCode::TRY_AGAIN, + 'AG' => DeclineCode::TRY_AGAIN, + 'BD' => DeclineCode::TRY_AGAIN, + 'BO' => DeclineCode::TRY_AGAIN, + 'BP900' => DeclineCode::TRY_AGAIN, + 'BP901' => DeclineCode::TRY_AGAIN, + 'BP902' => DeclineCode::TRY_AGAIN, + + // valor ou data inválidos para a transação, transação duplicada + '13' => DeclineCode::GENERIC, + '64' => DeclineCode::GENERIC, + '80' => DeclineCode::GENERIC, + '94' => DeclineCode::GENERIC, + '97' => DeclineCode::GENERIC, + 'FE' => DeclineCode::GENERIC, + ]; + + /** + * Traduz o código LR para o vocabulário do pacote. A tabela oficial lista os códigos de um + * dígito com e sem zero à esquerda (`5` e `05`), então código só de dígitos é comparado sem + * os zeros iniciais. Nulo quando o código não está na tabela, para o driver preservar o + * original e registrar no log. + * + * @param string|null $lr + * @return DeclineCode|null + */ + public static function toDeclineCode(?string $lr): ?DeclineCode + { + if ($lr === null || $lr === '') { + return null; + } + + $key = strtoupper($lr); + if (ctype_digit($key)) { + $key = ltrim($key, '0') ?: '0'; + } + + return self::MAP[$key] ?? null; + } + + /** + * Lê o código LR de uma resposta de cobrança recusada: o campo `LR` quando existe, senão o + * trecho `LR: xx` de `info_message` ou `message`. Nulo quando a resposta não traz código. + * + * @param object $charge resposta de `POST /v1/charge` + * @return string|null + */ + public static function extractLr(object $charge): ?string + { + $lr = $charge->LR ?? null; + if (is_string($lr) && trim($lr) !== '') { + return strtoupper(trim($lr)); + } + if (is_int($lr)) { + return (string) $lr; + } + + foreach (['info_message', 'message'] as $field) { + $text = $charge->{$field} ?? null; + if (is_string($text) && preg_match('/\bLR:?\s*([A-Za-z0-9]{1,5})\b/', $text, $matches)) { + return strtoupper($matches[1]); + } + } + + return null; + } +} diff --git a/src/Gateways/IuguGateway.php b/src/Gateways/IuguGateway.php index 7264c97..9276948 100644 --- a/src/Gateways/IuguGateway.php +++ b/src/Gateways/IuguGateway.php @@ -3,15 +3,14 @@ namespace Potelo\MultiPayment\Gateways; use Iugu; -use Iugu_Customer; +use APIResource; use Carbon\Carbon; use Iugu_APIRequest; -use Iugu_PaymentToken; -use Iugu_PaymentMethod; use IuguObjectNotFound; use Potelo\MultiPayment\Models\Pix; use Illuminate\Support\Facades\Config; use Potelo\MultiPayment\Models\Invoice; +use Potelo\MultiPayment\Models\Refund; use Potelo\MultiPayment\Models\Address; use Potelo\MultiPayment\Models\Customer; use Potelo\MultiPayment\Models\BankSlip; @@ -19,15 +18,50 @@ use Potelo\MultiPayment\Models\InvoiceItem; use Potelo\MultiPayment\Models\AutomaticPix; use Potelo\MultiPayment\Models\AutomaticPixCharge; +use Potelo\MultiPayment\Models\Plan; +use Potelo\MultiPayment\Models\Subscription; +use Potelo\MultiPayment\Models\SubscriptionItem; +use Potelo\MultiPayment\Models\SubscriptionDiscount; +use Potelo\MultiPayment\Models\SubscriptionPlanChange; use Potelo\MultiPayment\Models\AutomaticPixCancellation; +use Potelo\MultiPayment\Enums\Capability; +use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\InvoiceOriginType; +use Potelo\MultiPayment\Enums\RefundStatus; +use Potelo\MultiPayment\Enums\PaymentMethod; +use Potelo\MultiPayment\Enums\ProrationBehavior; +use Potelo\MultiPayment\Enums\SubscriptionStatus; +use Potelo\MultiPayment\Enums\PlanInterval; +use Potelo\MultiPayment\Enums\DeclineCode; +use Potelo\MultiPayment\Helpers\LogHelper; +use Potelo\MultiPayment\Capabilities\CapabilityRestriction; +use Potelo\MultiPayment\Helpers\ConfigurationHelper; +use Potelo\MultiPayment\Contracts\PlanContract; use Potelo\MultiPayment\Contracts\GatewayContract; +use Potelo\MultiPayment\Contracts\IdempotencyStore; +use Potelo\MultiPayment\Contracts\SubscriptionContract; +use Potelo\MultiPayment\Contracts\SubscriptionSyncContract; +use Potelo\MultiPayment\Gateways\Concerns\ChecksCapabilities; +use Potelo\MultiPayment\Gateways\Concerns\ResolvesIdempotencyKey; +use Potelo\MultiPayment\Gateways\Iugu\DeclineCodes as IuguDeclineCodes; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\ChargingException; +use Potelo\MultiPayment\Exceptions\NotFoundException; +use Potelo\MultiPayment\Exceptions\RateLimitException; +use Potelo\MultiPayment\Exceptions\ValidationException; +use Potelo\MultiPayment\Exceptions\MultiPaymentException; +use Potelo\MultiPayment\Exceptions\AuthenticationException; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; +use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; +use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; +use Potelo\MultiPayment\Exceptions\IdempotencyConflictException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; -class IuguGateway implements GatewayContract +class IuguGateway implements GatewayContract, SubscriptionContract, PlanContract, SubscriptionSyncContract { + use ChecksCapabilities; + use ResolvesIdempotencyKey; + private const STATUS_PENDING = 'pending'; private const STATUS_PAID = 'paid'; private const STATUS_EXTERNALLY_PAID = 'externally_paid'; @@ -42,23 +76,161 @@ class IuguGateway implements GatewayContract private const STATUS_CHARGEBACK = 'chargeback'; private const STATUS_AUTHORIZED = 'authorized'; + /** + * Prefixo reservado em `custom_variables` da assinatura para o estado que a lib grava + * (cancelamento e validade de desconto); `metadata` com uma chave assim é recusado. + */ + private const RESERVED_VARIABLE_PREFIX = 'mp_'; + + /** Variável que marca o cancelamento agendado para o fim do período (`1` quando há). */ + private const CANCEL_AT_PERIOD_END_VARIABLE = 'mp_cancel_at_period_end'; + + /** Variável com a data (`Y-m-d`) em que a assinatura agendada deve ser suspensa. */ + private const CANCEL_SCHEDULED_FOR_VARIABLE = 'mp_cancel_scheduled_for'; + + /** Prefixo da variável de validade de um desconto: `mp_discount__until`. */ + private const DISCOUNT_UNTIL_PREFIX = 'mp_discount_'; + + /** Sufixo da variável de validade de um desconto: `mp_discount__until`. */ + private const DISCOUNT_UNTIL_SUFFIX = '_until'; + + /** + * Variável de `custom_variables` da assinatura em que a lib grava a data do cancelamento. + * A Iugu só suspende, então é essa marca que distingue `CANCELED` de `SUSPENDED`. O + * prefixo `mp_` é reservado à lib. + */ + private const CANCELED_AT_VARIABLE = 'mp_canceled_at'; + + /** Faixa de `interval` aceita pela Iugu na criação de plano. */ + private const PLAN_INTERVAL_MIN = 1; + private const PLAN_INTERVAL_MAX = 599; + + /** Prazo, em dias após o pagamento, em que a Iugu ainda aceita estorno pela API. */ + private const REFUND_WINDOW_DAYS = 90; + + /** + * Máximo de parcelas declarado em `restriction(INSTALLMENTS)` quando a configuração + * `multi-payment.gateways.iugu.max_installments` não informa o da conta; é o teto da Iugu. + */ + private const DEFAULT_MAX_INSTALLMENTS = 12; + + /** Prefixo das chaves deste driver na `IdempotencyStore`. */ + private const IDEMPOTENCY_STORE_PREFIX = 'iugu:'; + private Iugu_APIRequest $apiRequest; + private ?IdempotencyStore $idempotencyStore; + /** - * Set iugu api key. + * Configura a chave de API da Iugu e o requester HTTP. Sem requester, usa o compartilhado + * do SDK (`APIResource::API()`); sem store, a `IdempotencyStore` registrada no container é + * resolvida na primeira operação que precisar dela. + * + * @param Iugu_APIRequest|null $apiRequest + * @param IdempotencyStore|null $idempotencyStore */ - public function __construct(?Iugu_APIRequest $apiRequest = null) + public function __construct(?Iugu_APIRequest $apiRequest = null, ?IdempotencyStore $idempotencyStore = null) { Iugu::setApiKey(Config::get('multi-payment.gateways.iugu.api_key')); - $this->apiRequest = $apiRequest ?? new Iugu_APIRequest(); + $this->apiRequest = $apiRequest ?? APIResource::API(); + $this->idempotencyStore = $idempotencyStore; + } + + /** + * @inheritDoc + */ + public function capabilities(): array + { + return [ + Capability::CREDIT_CARD, + Capability::PIX, + Capability::BANK_SLIP, + Capability::AUTOMATIC_PIX, + Capability::MULTIPLE_PAYMENT_METHODS, + Capability::RAW_CARD_DATA, + Capability::INSTALLMENTS, + Capability::PARTIAL_REFUND_CARD, + Capability::INVOICE_DUPLICATION, + Capability::INVOICE_CANCELLATION, + Capability::IDEMPOTENCY, + Capability::SUBSCRIPTIONS, + Capability::PLANS, + ]; + } + + /** + * @inheritDoc + */ + public function notYetImplemented(): array + { + return [ + Capability::DELAYED_CAPTURE, + Capability::SUBSCRIPTION_CREDITS, + ]; + } + + /** + * @inheritDoc + * + * A Iugu não tem cupom com prazo nem cancelamento ao fim do ciclo; a lib emula os dois com + * estado em `custom_variables` da assinatura, aplicado pelo comando + * `multipayment:sync-subscriptions` agendado pela aplicação. + */ + public function emulated(): array + { + return [ + Capability::COUPONS, + Capability::CANCEL_AT_PERIOD_END, + ]; + } + + /** + * @inheritDoc + * + * `INSTALLMENTS`: o número de parcelas vai em `gatewayOptions['months']`, até o máximo da + * conta (`multi-payment.gateways.iugu.max_installments`, 12 por padrão), e a lib não lê as + * parcelas da fatura paga. `AUTOMATIC_PIX`: a recorrência nasce na fatura e a aplicação é + * o motor de recorrência; a assinatura não aceita o método. + */ + public function restrictions(): array + { + // variável de ambiente vazia chega como string vazia; vale o padrão da Iugu + $maxInstallments = (int) Config::get('multi-payment.gateways.iugu.max_installments') + ?: self::DEFAULT_MAX_INSTALLMENTS; + + return [ + Capability::INSTALLMENTS->value => new CapabilityRestriction( + description: "O número de parcelas vai em gatewayOptions['months'], até {$maxInstallments}" + . ' (máximo da conta, configurável em multi-payment.gateways.iugu.max_installments);' + . ' a lib não lê as parcelas da fatura paga.', + maxInstallments: $maxInstallments, + ), + Capability::AUTOMATIC_PIX->value => new CapabilityRestriction( + description: 'A recorrência nasce na fatura (Invoice com automaticPix e método pix) e' + . ' a aplicação é o motor de recorrência; a assinatura não aceita paymentMethod' + . ' automatic_pix.', + ), + ]; } /** * @inheritDoc - * @throws ModelAttributeValidationException|ChargingException + * + * Os métodos da fatura vêm de `Invoice::resolvedPaymentMethods()` (`payable_with`); com + * cartão entre eles e `creditCard` preenchido, a fatura é cobrada por `POST /charge`. + * `dueDate` vai em `due_date` (sem ele, o dia de `pixExpiresAt`, ou hoje) e `pixExpiresAt` + * em `pix_qr_code_expires_at`. A chave de idempotência vai no cabeçalho `Idempotency-Key` de `POST /invoices` ou de `POST /charge`; + * o cartão salvo antes da cobrança usa a chave derivada `{chave}:card` pela + * `IdempotencyStore`. Na reutilização da chave, a Iugu responde 409 com o id da fatura + * original, que o driver lê e devolve. + * + * @throws ModelAttributeValidationException|ChargingException|UnsupportedOperationException */ - public function createInvoice(Invoice $invoice): Invoice + public function createInvoice(Invoice $invoice, ?string $idempotencyKey = null): Invoice { + $this->assertSupportsAll($invoice->requiredCapabilities()); + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $invoice); + $iuguInvoiceData = []; $iuguInvoiceData['customer_id'] = $invoice->customer->id; @@ -74,10 +246,12 @@ public function createInvoice(Invoice $invoice): Invoice 'price_cents' => $item->price, ]; } - $iuguInvoiceData['due_date'] = !empty($invoice->expiresAt) - ? $invoice->expiresAt->format('Y-m-d') - : Carbon::now()->format('Y-m-d'); + $dueDate = $invoice->dueDate ?? $invoice->pixExpiresAt ?? Carbon::now(); + $iuguInvoiceData['due_date'] = $dueDate->format('Y-m-d'); $iuguInvoiceData['expires_in'] = 0; + if (!empty($invoice->pixExpiresAt)) { + $iuguInvoiceData['pix_qr_code_expires_at'] = $invoice->pixExpiresAt->toIso8601String(); + } if (!empty($invoice->customer->address)) { $iuguInvoiceData['payer']['address'] = $invoice->customer->address->toArray(); @@ -86,8 +260,10 @@ public function createInvoice(Invoice $invoice): Invoice } } - if (!empty($invoice->availablePaymentMethods)) { - $iuguInvoiceData['payable_with'] = $invoice->availablePaymentMethods; + $payableWith = $invoice->resolvedPaymentMethods(); + + if (!empty($payableWith)) { + $iuguInvoiceData['payable_with'] = self::paymentMethodsToIuguPayableWith($payableWith); } if (!empty($invoice->automaticPix)) { @@ -101,109 +277,309 @@ public function createInvoice(Invoice $invoice): Invoice ); } - if (!empty($invoice->gatewayAdicionalOptions)) { - foreach ($invoice->gatewayAdicionalOptions as $option => $value) { - $iuguInvoiceData[$option] = $value; - } + foreach (self::withoutIdempotencyKey($invoice->gatewayOptions) as $option => $value) { + $iuguInvoiceData[$option] = $value; } - if ( - !empty($invoice->availablePaymentMethods) && - in_array(Invoice::PAYMENT_METHOD_CREDIT_CARD, $invoice->availablePaymentMethods) && - !empty($invoice->creditCard) - ) { + if (in_array(PaymentMethod::CREDIT_CARD, $payableWith, true) && !empty($invoice->creditCard)) { if (empty($invoice->creditCard->id)) { - $invoice->creditCard = $this->createCreditCard($invoice->creditCard); + if (empty($invoice->creditCard->customer)) { + $invoice->creditCard->customer = $invoice->customer; + } + $invoice->creditCard = $this->createCreditCard( + $invoice->creditCard, + self::derivedIdempotencyKey($idempotencyKey, 'card') + ); } $iuguInvoiceData['customer_payment_method_id'] = $invoice->creditCard->id; - $iuguInvoice = $this->chargeIuguInvoice($iuguInvoiceData); + $iuguInvoice = $this->chargeIuguInvoice($iuguInvoiceData, $idempotencyKey); } else { - try { - $iuguInvoice = \Iugu_Invoice::create($iuguInvoiceData); - } catch (\IuguRequestException|IuguObjectNotFound $e) { - if (str_contains($e->getMessage(), '502 Bad Gateway')) { - throw new GatewayNotAvailableException($e->getMessage()); - } else { - throw new GatewayException($e->getMessage()); - } - } catch (\IuguAuthenticationException $e) { - throw new GatewayNotAvailableException($e->getMessage()); - } catch (\Exception $e) { - throw new GatewayException($e->getMessage()); - } - if ($iuguInvoice->errors) { - throw new GatewayException('Error creating invoice', $iuguInvoice->errors); - } + $iuguInvoice = $this->iuguIdempotentRequest( + 'POST', + Iugu::getBaseURI() . '/invoices', + $iuguInvoiceData, + 'creating invoice', + $idempotencyKey, + true, + fn (string $originalId) => $this->fetchIuguInvoice($originalId, 'getting invoice') + ); } return $this->parseInvoice($iuguInvoice, $invoice); } /** - * @inheritDoc + * Lê uma fatura da Iugu pelo id. + * + * @param string $id + * @param string $operation descrição da operação, em inglês, para a mensagem + * @return object|array + * @throws MultiPaymentException */ - public function createCustomer(Customer $customer): Customer + private function fetchIuguInvoice(string $id, string $operation): object|array { - $iuguCustomerData = $this->customerToIuguData($customer); + return $this->iuguRequest('GET', Iugu::getBaseURI() . '/invoices/' . rawurlencode($id), [], $operation); + } - try { - $iuguCustomer = Iugu_Customer::create($iuguCustomerData); - } catch (\IuguRequestException | IuguObjectNotFound $e) { - if (str_contains($e->getMessage(), '502 Bad Gateway')) { - throw new GatewayNotAvailableException($e->getMessage()); - } else { - throw new GatewayException($e->getMessage()); + /** + * Tokeniza os dados crus do cartão na Iugu (`POST /payment_token`, deduplicado pela + * `IdempotencyStore` quando há chave) e devolve o token gerado. + * + * @param CreditCard $creditCard + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return string + * @throws GatewayException|GatewayNotAvailableException|AuthenticationException + */ + private function createIuguPaymentToken(CreditCard $creditCard, ?string $idempotencyKey): string + { + $iuguToken = $this->iuguIdempotentRequest( + 'POST', + Iugu::getBaseURI() . '/payment_token', + [ + 'account_id' => Config::get('multi-payment.gateways.iugu.id'), + 'method' => 'credit_card', + 'test' => Config::get('multi-payment.environment') != 'production', + 'data' => [ + 'number' => $creditCard->number, + 'verification_value' => $creditCard->cvv, + 'first_name' => $creditCard->firstName, + 'last_name' => $creditCard->lastName, + 'month' => $creditCard->month, + 'year' => $creditCard->year, + ], + ], + 'creating payment token', + $idempotencyKey + ); + + if (empty($iuguToken->id)) { + throw $this->iuguResponseException('Error creating payment token', null); + } + + return $iuguToken->id; + } + + /** + * Traduz uma exceção capturada numa chamada à Iugu para a hierarquia do pacote, anexando a + * original como `previous` e o status HTTP quando o SDK o informa. + * + * O SDK lança `IuguRequestException` com o status HTTP em `getCode()` quando a resposta não é + * JSON (páginas de erro 5xx de proxy, corpo vazio de timeout com código 0) e + * `IuguObjectNotFound` para 404; `IuguAuthenticationException` só quando a chave não foi + * configurada. Erro com corpo JSON passa por `iuguResponseException()`. + * + * Regras: 401 e 403 viram `AuthenticationException`; 5xx e falha de rede viram + * `GatewayNotAvailableException`; 404 vira `NotFoundException`; os demais status passam por + * `classifyIuguFailure()` (400 e 422 `ValidationException`, 409 + * `IdempotencyConflictException`, 429 `RateLimitException`, o restante `GatewayException`), + * sempre com o status em `httpStatus`. Exceção do próprio pacote passa intacta. + * + * @param \Throwable $e + * @param string $operation descrição da operação, em inglês, para a mensagem + * @return MultiPaymentException + */ + private function translateIuguException(\Throwable $e, string $operation): MultiPaymentException + { + if ($e instanceof MultiPaymentException) { + return $e; + } + + if ($e instanceof \IuguAuthenticationException) { + return AuthenticationException::invalidCredentials('iugu', $e->getMessage(), $e); + } + + if ($e instanceof IuguObjectNotFound) { + // o SDK lança essa classe para 404 e fetchAPI() a relança sem o código HTTP + return new NotFoundException("Error {$operation}: {$e->getMessage()}", null, $e, 404); + } + + if ($e instanceof \IuguRequestException) { + // corpo vazio e código 0: o cURL não obteve resposta (falha de conexão ou timeout) + if ($e->getCode() <= 0 && trim($e->getMessage()) === '') { + return new GatewayNotAvailableException( + "Error {$operation}: no response from the gateway (network failure or timeout)", + $e + ); } - } catch (\IuguAuthenticationException $e) { - throw new GatewayNotAvailableException($e->getMessage()); - } catch (\Exception $e) { - throw new GatewayException($e->getMessage()); + + // fetchAPI() do SDK também lança esta classe, sem código, para resposta com `error` + return $this->classifyIuguFailure( + "Error {$operation}: {$e->getMessage()}", + $e->getMessage(), + null, + $e, + $e->getCode() > 0 ? (int) $e->getCode() : null + ); } - if ($iuguCustomer->errors) { - throw new GatewayException('Error creating customer', $iuguCustomer->errors); + return new GatewayException("Error {$operation}: {$e->getMessage()}", null, $e); + } + + /** + * Traduz um corpo de erro devolvido pela Iugu numa resposta HTTP válida (o SDK não lança + * nesse caso) para a hierarquia do pacote, usando o status HTTP da última resposta. + * + * @param string $message + * @param mixed $errors corpo de `errors` da resposta + * @return MultiPaymentException + */ + private function iuguResponseException(string $message, $errors): MultiPaymentException + { + $detail = is_string($errors) ? $errors : json_encode($errors); + + return $this->classifyIuguFailure($message, (string) $detail, $errors, null, $this->lastIuguHttpStatus()); + } + + /** + * Escolhe a exceção do pacote pelo status HTTP da falha: 401 e 403 `AuthenticationException`, + * 5xx `GatewayNotAvailableException`, 400 e 422 `ValidationException` (com os erros por + * campo, nos dois formatos que a Iugu usa: objeto por campo ou string), 404 + * `NotFoundException`, 409 `IdempotencyConflictException`, 429 `RateLimitException` (com + * `retryAfter` lido do cabeçalho `Retry-After` quando a Iugu o envia) e o restante + * `GatewayException`. + * + * @param string $message + * @param string $detail texto da resposta, para a mensagem de autenticação + * @param mixed $errors + * @param \Throwable|null $previous + * @param int|null $httpStatus nulo quando o SDK não informou o status + * @return MultiPaymentException + */ + private function classifyIuguFailure( + string $message, + string $detail, + $errors, + ?\Throwable $previous, + ?int $httpStatus + ): MultiPaymentException { + if (in_array($httpStatus, [401, 403], true)) { + return AuthenticationException::invalidCredentials('iugu', $detail, $previous, $httpStatus); + } + + if ($httpStatus >= 500) { + return new GatewayNotAvailableException($message, $previous, $httpStatus); + } + + return match ($httpStatus) { + 400, 422 => ValidationException::withFieldErrors( + $message, + ValidationException::normalizeFieldErrors($errors ?? $detail), + $errors, + $previous, + $httpStatus + ), + 404 => new NotFoundException($message, $errors, $previous, $httpStatus), + 409 => IdempotencyConflictException::withResourceId( + $message, + $errors, + $previous, + $httpStatus, + self::iuguConflictResourceId($errors ?? $detail) + ), + 429 => RateLimitException::withRetryAfter($message, $errors, $previous, $httpStatus, $this->lastIuguRetryAfter()), + default => new GatewayException($message, $errors, $previous, $httpStatus), + }; + } + + /** + * Status HTTP da última resposta recebida pelo requester deste driver (JSON ou não), que o + * SDK grava em `Iugu_APIRequest::$lastResponseCode`. Nulo antes da primeira requisição e + * quando não houve resposta. + * + * @return int|null + */ + private function lastIuguHttpStatus(): ?int + { + $code = $this->apiRequest->lastResponseCode; + + return is_int($code) && $code > 0 ? $code : null; + } + + /** + * Segundos do cabeçalho `Retry-After` da última resposta, lidos de + * `Iugu_APIRequest::$lastResponseHeaders`. Nulo quando ausente ou quando não é um inteiro. + * + * @return int|null + */ + private function lastIuguRetryAfter(): ?int + { + $value = $this->apiRequest->lastResponseHeaders['retry-after'] ?? null; + $value = is_array($value) ? reset($value) : $value; + + return is_numeric($value) ? (int) $value : null; + } + + /** + * Id do recurso original numa resposta 409 de chave de idempotência reutilizada. A Iugu + * responde "Essa chave de idempotência já esta em uso: idempotency_key: ..., resource_id: X", + * com o id da fatura em fatura e cobrança e `processing` em cliente e assinatura, que aqui + * vira nulo. + * + * @param mixed $errors corpo de `errors` ou texto da resposta + * @return string|null + */ + private static function iuguConflictResourceId(mixed $errors): ?string + { + $text = is_string($errors) ? $errors : json_encode($errors, JSON_UNESCAPED_UNICODE); + if (!is_string($text) || !preg_match('/resource_id:\s*([A-Za-z0-9_-]+)/', $text, $match)) { + return null; } - $customer->id = $iuguCustomer->id; + return strtolower($match[1]) === 'processing' ? null : $match[1]; + } + + /** + * @inheritDoc + * + * A chave de idempotência vai no cabeçalho `Idempotency-Key` de `POST /customers`. Na + * reutilização da chave a Iugu responde 409 sem o id do cliente original + * (`resource_id: processing`), que chega como `IdempotencyConflictException`. + */ + public function createCustomer(Customer $customer, ?string $idempotencyKey = null): Customer + { + $iuguCustomer = $this->iuguIdempotentRequest( + 'POST', + Iugu::getBaseURI() . '/customers', + $this->customerToIuguData($customer), + 'creating customer', + $this->idempotencyKeyFor($idempotencyKey, $customer), + true + ); + + $customer->id = $iuguCustomer->id ?? null; $customer->gateway = 'iugu'; - $customer->createdAt = new Carbon($iuguCustomer->created_at); + $customer->createdAt = !empty($iuguCustomer->created_at) ? new Carbon($iuguCustomer->created_at) : null; $customer->original = $iuguCustomer; return $customer; } /** - * Convert Iugu status to MultiPayment status. + * Converte o status da fatura Iugu no status genérico. Cada status lê como o caso + * homônimo, com duas exceções: `draft` lê como `PENDING` e `in_analysis` (primeira etapa + * da cobrança em duas etapas) como `AUTHORIZED`. Status fora do mapa devolve `UNKNOWN` e + * registra um aviso no log. * - * @param $iuguStatus + * @param string|null $iuguStatus * - * @return string - * @throws GatewayException + * @return InvoiceStatus */ - private static function iuguStatusToMultiPayment($iuguStatus): string - { - switch ($iuguStatus) { - case self::STATUS_PENDING: - case self::STATUS_IN_ANALYSIS: - case self::STATUS_DRAFT: - case self::STATUS_PARTIALLY_PAID: - return Invoice::STATUS_PENDING; - case self::STATUS_PAID: - case self::STATUS_EXTERNALLY_PAID: - case self::STATUS_AUTHORIZED: - case self::STATUS_IN_PROTEST: - return Invoice::STATUS_PAID; - case self::STATUS_CANCELED: - case self::STATUS_EXPIRED: - return Invoice::STATUS_CANCELED; - case self::STATUS_REFUNDED: - case self::STATUS_CHARGEBACK: - return Invoice::STATUS_REFUNDED; - case self::STATUS_PARTIALLY_REFUNDED: - return Invoice::STATUS_PARTIALLY_REFUNDED; - default: - throw new GatewayException('Unexpected Iugu status: ' . $iuguStatus); - } + private static function iuguStatusToMultiPayment(?string $iuguStatus): InvoiceStatus + { + return match ($iuguStatus) { + self::STATUS_PENDING, self::STATUS_DRAFT => InvoiceStatus::PENDING, + self::STATUS_IN_ANALYSIS, self::STATUS_AUTHORIZED => InvoiceStatus::AUTHORIZED, + self::STATUS_PAID => InvoiceStatus::PAID, + self::STATUS_PARTIALLY_PAID => InvoiceStatus::PARTIALLY_PAID, + self::STATUS_EXTERNALLY_PAID => InvoiceStatus::EXTERNALLY_PAID, + self::STATUS_PARTIALLY_REFUNDED => InvoiceStatus::PARTIALLY_REFUNDED, + self::STATUS_REFUNDED => InvoiceStatus::REFUNDED, + self::STATUS_IN_PROTEST => InvoiceStatus::DISPUTED, + self::STATUS_CHARGEBACK => InvoiceStatus::CHARGEBACK, + self::STATUS_CANCELED => InvoiceStatus::CANCELED, + self::STATUS_EXPIRED => InvoiceStatus::EXPIRED, + default => InvoiceStatus::unknown((string) $iuguStatus, 'iugu'), + }; } /** @@ -240,38 +616,31 @@ private function multiPaymentToIuguData(array $data): array } /** - * Create a new Credit Card + * @inheritDoc * - * @param CreditCard $creditCard + * Salva o cartão no cliente (`POST /customers/{id}/payment_methods`), tokenizando antes os + * dados crus quando não há token. A Iugu não aceita `Idempotency-Key` nesses endpoints, então + * a chave passa pela `IdempotencyStore`: a informada no `POST` do cartão e `{chave}:token` + * na tokenização. * - * @return CreditCard * @throws GatewayException|ModelAttributeValidationException * @throws GatewayNotAvailableException */ - public function createCreditCard(CreditCard $creditCard): CreditCard + public function createCreditCard(CreditCard $creditCard, ?string $idempotencyKey = null): CreditCard { if (empty($creditCard->customer) || empty($creditCard->customer->id)) { throw ModelAttributeValidationException::required('CreditCard', 'customer'); } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $creditCard); if (empty($creditCard->token)) { - $creditCard->token = Iugu_PaymentToken::create([ - 'account_id' => Config::get('multi-payment.gateways.iugu.id'), - 'method' => 'credit_card', - 'test' => Config::get('multi-payment.environment') != 'production', - 'data' => [ - 'number' => $creditCard->number, - 'verification_value' => $creditCard->cvv, - 'first_name' => $creditCard->firstName, - 'last_name' => $creditCard->lastName, - 'month' => $creditCard->month, - 'year' => $creditCard->year, - ], - ]); + $creditCard->token = $this->createIuguPaymentToken( + $creditCard, + self::derivedIdempotencyKey($idempotencyKey, 'token') + ); } $options = [ 'token' => $creditCard->token, - 'customer_id' => $creditCard->customer->id, 'description' => $creditCard->description ?? 'CREDIT CARD', ]; @@ -279,174 +648,343 @@ public function createCreditCard(CreditCard $creditCard): CreditCard $options['set_as_default'] = $creditCard->default; } - try { - $iuguCreditCard = Iugu_PaymentMethod::create($options); - } catch (\IuguRequestException | IuguObjectNotFound $e) { - if (str_contains($e->getMessage(), '502 Bad Gateway')) { - throw new GatewayNotAvailableException($e->getMessage()); - } else { - throw new GatewayException($e->getMessage()); - } - } catch (\IuguAuthenticationException $e) { - throw new GatewayNotAvailableException($e->getMessage()); - } catch (\Exception $e) { - throw new GatewayException($e->getMessage()); - } - if ($iuguCreditCard->errors) { - throw new GatewayException('Error creating creditCard: ', $iuguCreditCard->errors); - } + $iuguCreditCard = $this->iuguIdempotentRequest( + 'POST', + Iugu::getBaseURI() . '/customers/' . rawurlencode($creditCard->customer->id) . '/payment_methods', + $options, + 'creating credit card', + $idempotencyKey + ); return $this->parseIuguCard($iuguCreditCard, $creditCard); } /** * @inheritDoc + * + * Na Iugu o cartão salvo por `createCreditCard()` já volta cobrável (o Zero Auth só + * confere a validade do cartão, com uma autorização de valor zero, sem autenticar o + * portador), então `CARD_SETUP_AUTHENTICATION` fica fora das listas do driver e este + * método lança sempre `UnsupportedOperationException` com `reason` `gateway_limitation`. */ - public function getInvoice(Invoice $invoice): Invoice + public function confirmCreditCardSetup(string $setupId, ?string $idempotencyKey = null): CreditCard { - try { - $iuguInvoice = \Iugu_Invoice::fetch($invoice->id); - } catch (\IuguRequestException | IuguObjectNotFound $e) { - if (str_contains($e->getMessage(), '502 Bad Gateway')) { - throw new GatewayNotAvailableException($e->getMessage()); - } else { - throw new GatewayException($e->getMessage()); - } - } catch (\Exception $e) { - throw new GatewayException("Error getting invoice: {$e->getMessage()}"); - } - if (!empty($iuguInvoice->errors)) { - throw new GatewayException('Error getting invoice', $iuguInvoice->errors); - } + throw UnsupportedOperationException::forGateway( + $this, + Capability::CARD_SETUP_AUTHENTICATION, + 'Na Iugu o cartão salvo por createCreditCard() já é cobrável; não há setup a confirmar.' + ); + } - return $this->parseInvoice($iuguInvoice, $invoice); + /** + * @inheritDoc + */ + public function getInvoice(Invoice $invoice): Invoice + { + return $this->parseInvoice($this->fetchIuguInvoice((string) $invoice->id, 'getting invoice'), $invoice); } /** * Convert the iugu payment method to the MultiPayment payment method * - * @param $iuguPaymentMethod + * @param mixed $iuguPaymentMethod * - * @return string|null + * @return PaymentMethod|null */ - private function iuguToMultiPaymentPaymentMethod($iuguPaymentMethod): ?string + private function iuguToMultiPaymentPaymentMethod($iuguPaymentMethod): ?PaymentMethod { - $multiPaymentPaymentMethod = [ - Invoice::PAYMENT_METHOD_PIX, - Invoice::PAYMENT_METHOD_BANK_SLIP, - Invoice::PAYMENT_METHOD_CREDIT_CARD - ]; - if (!empty($iuguPaymentMethod)) { - foreach ($multiPaymentPaymentMethod as $paymentMethod) { - if (str_contains($iuguPaymentMethod, $paymentMethod)) { - return $paymentMethod; - } + if (empty($iuguPaymentMethod) || !is_string($iuguPaymentMethod)) { + return null; + } + + // o nome da Iugu carrega o genérico como sufixo (`iugu_credit_card`, `iugu_pix`) + foreach ([PaymentMethod::PIX, PaymentMethod::BANK_SLIP, PaymentMethod::CREDIT_CARD] as $paymentMethod) { + if (str_contains($iuguPaymentMethod, $paymentMethod->value)) { + return $paymentMethod; } } + return null; } /** - * @inheritDoc + * Converte a lista genérica de métodos de pagamento nos valores de `payable_with` da Iugu. + * + * @param PaymentMethod[] $paymentMethods + * + * @return string[] */ - public function refundInvoice(Invoice $invoice): Invoice + private static function paymentMethodsToIuguPayableWith(array $paymentMethods): array { - $iuguInvoice = new \Iugu_Invoice(['id' => $invoice->id]); - - try { - $refunded = $iuguInvoice->refund($invoice->refundedAmount ?? null); - if (!$refunded) { - throw new GatewayException("Error refunding invoice", $iuguInvoice->errors ?? []); - } - } catch (GatewayException $e) { - throw $e; - } catch (\Exception $e) { - throw new GatewayException("Error refunding invoice: {$e->getMessage()}"); - } - - return $this->parseInvoice($iuguInvoice, $invoice); + return array_values(array_map( + static fn (PaymentMethod $paymentMethod) => $paymentMethod->value, + $paymentMethods + )); } /** * @inheritDoc + * + * As guardas de estorno precisam do método de pagamento, do status, da data de pagamento e, + * no estorno por valor, do valor pago. Um model que traz só o `id` custa um GET a mais para + * ler a fatura antes do estorno; um model lido do gateway e já pago não paga esse GET. A + * leitura prévia acontece numa cópia: o model do chamador só é alterado se o estorno + * acontecer. A Iugu não devolve um objeto de estorno, então o `Refund` é montado pela lib: + * sem id, com o valor pedido (ou, no estorno integral, o `paid_cents` anterior ao estorno, + * que a Iugu devolve líquido do já estornado) e status `SUCCEEDED`, porque a Iugu só + * responde 200 com o estorno feito. + * + * A Iugu não aceita `Idempotency-Key` no estorno, então com chave a operação inteira + * (leitura prévia, guardas e `POST /refund`) passa pela `IdempotencyStore`: a chamada + * seguinte com a mesma chave devolve o `Refund` da primeira sem reler a fatura, que a essa + * altura já estaria estornada e faria a guarda recusar o retry. + * + * O valor vem de `$amount`; sem ele, do caminho antigo de escrever `refundedAmount` antes + * de estornar (`Invoice::resolveRefundAmount()`); sem os dois, estorna o restante. + * + * @throws ModelAttributeValidationException|RefundNotSupportedException */ - public function cancelInvoice(Invoice $invoice): Invoice + public function refundInvoice(Invoice $invoice, ?int $amount = null, ?string $idempotencyKey = null): Refund { - $url = Iugu::getBaseURI() . '/invoices/' . rawurlencode($invoice->id) . '/cancel'; - - try { - $response = $this->apiRequest->request('PUT', $url); - } catch (\IuguRequestException | IuguObjectNotFound $e) { - if (str_contains($e->getMessage(), '502 Bad Gateway')) { - throw new GatewayNotAvailableException($e->getMessage()); - } - - throw new GatewayException($e->getMessage()); - } catch (\IuguAuthenticationException $e) { - throw new GatewayNotAvailableException($e->getMessage()); - } catch (\Exception $e) { - throw new GatewayException("Error cancelling invoice: {$e->getMessage()}"); + if (empty($invoice->id)) { + throw ModelAttributeValidationException::required('Invoice', 'id'); } + $requestedAmount = $invoice->resolveRefundAmount($amount); + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $invoice); - if (!empty($response->errors)) { - throw new GatewayException('Error cancelling invoice', (array) $response->errors); + if (is_null($idempotencyKey)) { + return $this->performIuguRefund($invoice, $requestedAmount); } - return $this->parseInvoice($response, $invoice); + return $this->rememberIuguOperation( + $idempotencyKey, + 'POST ' . Iugu::getBaseURI() . '/invoices/' . rawurlencode($invoice->id) . '/refund', + fn () => $this->performIuguRefund($invoice, $requestedAmount) + ); } /** * @inheritDoc + * + * Na Iugu o restante é `paid_cents`, que a API devolve líquido do que já foi estornado + * (`Invoice::$paidAmount`); a fatura é lida quando o model não o traz. */ - public function duplicateInvoice(Invoice $invoice, Carbon $expiresAt, array $gatewayOptions = []): Invoice + public function refundableAmount(Invoice $invoice): int { - $iuguInvoice = new \Iugu_Invoice(['id' => $invoice->id]); - - $params = array_merge($gatewayOptions, [ - 'due_date' => $expiresAt->format('Y-m-d'), - ]); - try { - $iuguInvoice = $iuguInvoice->duplicate($params); - } catch (\IuguRequestException | IuguObjectNotFound $e) { - if (str_contains($e->getMessage(), '502 Bad Gateway')) { - throw new GatewayNotAvailableException($e->getMessage()); - } else { - throw new GatewayException($e->getMessage()); - } - } catch (\Exception $e) { - throw new GatewayException("Error getting invoice: {$e->getMessage()}"); - } - if (!empty($iuguInvoice->errors)) { - throw new GatewayException('Error getting invoice', $iuguInvoice->errors); + if (empty($invoice->id)) { + throw ModelAttributeValidationException::required('Invoice', 'id'); } - return $this->parseInvoice($iuguInvoice); + $current = is_null($invoice->paidAmount) ? $this->getInvoice(clone $invoice) : $invoice; + + return self::iuguRefundableAmount($current); } - /** @inheritDoc */ - public function rescheduleAutomaticPixPayment(Invoice $invoice): Invoice + /** + * Restante estornável de uma fatura já lida: `paid_cents`, líquido do estornado; zero + * quando nada foi pago. + * + * @param Invoice $invoice + * @return int + */ + private static function iuguRefundableAmount(Invoice $invoice): int { - if (empty($invoice->id)) { - throw ModelAttributeValidationException::required('Invoice', 'id'); + return max(0, (int) ($invoice->paidAmount ?? 0)); + } + + /** + * Lê a fatura quando o model não traz o que as guardas precisam, aplica as guardas e faz o + * `POST /refund`, devolvendo o `Refund` montado pela lib. + * + * @param Invoice $invoice + * @param int|null $requestedAmount valor pedido em centavos; nulo é estorno do restante + * @return Refund + * @throws RefundNotSupportedException + */ + private function performIuguRefund(Invoice $invoice, ?int $requestedAmount): Refund + { + $current = $invoice; + if ( + empty($invoice->paymentMethod) + || empty($invoice->status) + || is_null($invoice->paidAt) + || (!is_null($requestedAmount) && is_null($invoice->paidAmount)) + ) { + $current = $this->getInvoice(clone $invoice); } - $url = Iugu::getBaseURI() . '/invoices/' . rawurlencode($invoice->id) - . '/reschedule_automatic_pix_payment'; - $response = $this->automaticPixRequest('POST', $url, [], 'rescheduling automatic pix payment'); + $this->assertInvoiceIsRefundable($current, $requestedAmount); - if (!empty($response->id) && !empty($response->status) && isset($response->total_cents)) { - return $this->parseInvoice($response, $invoice); + $data = []; + // valor igual ao pago é estorno integral e vai sem partial_value_refund_cents; assim o + // Pix, que só aceita integral, não é recusado por um "parcial" do valor cheio + if (!is_null($requestedAmount) && $requestedAmount !== $current->paidAmount) { + $data['partial_value_refund_cents'] = $requestedAmount; + } + // o estorno integral devolve o paid_cents anterior, que parseInvoice() vai sobrescrever + $refundableBefore = $current->paidAmount; + + $url = Iugu::getBaseURI() . '/invoices/' . rawurlencode($invoice->id) . '/refund'; + $iuguInvoice = $this->iuguRequest('POST', $url, $data, 'refunding invoice'); + + $invoice = $this->parseInvoice($iuguInvoice, $invoice); + + $refund = new Refund(); + $refund->invoiceId = $invoice->id; + $refund->amount = $requestedAmount ?? $refundableBefore ?? $invoice->refundedAmount; + $refund->status = RefundStatus::SUCCEEDED; + $refund->createdAt = Carbon::now(); + $refund->gateway = 'iugu'; + $refund->invoice = $invoice; + + return $refund; + } + + /** + * Lança antes da rede quando a Iugu certamente recusaria o estorno: boleto não tem estorno + * pela API, fatura em `refunded` é terminal, o valor pedido não pode passar do que resta + * (`paid_cents`, que a Iugu já devolve líquido do que foi estornado), Pix só estorna o valor + * integral e o prazo de estorno termina no fim do 90º dia após o pagamento. + * + * @param Invoice $invoice + * @param int|null $requestedAmount valor pedido em centavos; nulo é estorno integral + * @return void + * @throws RefundNotSupportedException + */ + private function assertInvoiceIsRefundable(Invoice $invoice, ?int $requestedAmount): void + { + if ($invoice->paymentMethod === PaymentMethod::BANK_SLIP) { + throw RefundNotSupportedException::boletoNoRefund('iugu'); + } + + if ($invoice->status === InvoiceStatus::REFUNDED) { + throw RefundNotSupportedException::alreadyRefunded('iugu', $invoice->paymentMethod?->value); + } + + if (!is_null($requestedAmount) && !is_null($invoice->paidAmount) && $requestedAmount > self::iuguRefundableAmount($invoice)) { + throw RefundNotSupportedException::amountExceedsRefundable( + 'iugu', + $invoice->paymentMethod?->value, + $requestedAmount, + self::iuguRefundableAmount($invoice) + ); + } + + if ( + $invoice->paymentMethod === PaymentMethod::PIX + && !is_null($requestedAmount) + && $requestedAmount !== $invoice->paidAmount + ) { + throw RefundNotSupportedException::pixPartialNotSupported('iugu', $requestedAmount, $invoice->paidAmount); + } + + // a Iugu conta o prazo em dias; até o fim do 90º dia a chamada segue e a API decide + if ( + !is_null($invoice->paidAt) + && $invoice->paidAt->copy()->addDays(self::REFUND_WINDOW_DAYS)->endOfDay()->isPast() + ) { + throw RefundNotSupportedException::refundWindowExpired( + 'iugu', + $invoice->paymentMethod?->value, + $invoice->paidAt, + self::REFUND_WINDOW_DAYS + ); + } + } + + /** + * @inheritDoc + * + * A chave de idempotência passa pela `IdempotencyStore` (a Iugu não aceita o cabeçalho + * neste endpoint). + */ + public function cancelInvoice(Invoice $invoice, ?string $idempotencyKey = null): Invoice + { + $url = Iugu::getBaseURI() . '/invoices/' . rawurlencode((string) $invoice->id) . '/cancel'; + + $response = $this->iuguIdempotentRequest( + 'PUT', + $url, + [], + 'cancelling invoice', + $this->idempotencyKeyFor($idempotencyKey, $invoice) + ); + + return $this->parseInvoice($response, $invoice); + } + + /** + * @inheritDoc + * + * A chave de idempotência passa pela `IdempotencyStore` (a Iugu não aceita o cabeçalho + * neste endpoint). + */ + public function duplicateInvoice( + Invoice $invoice, + Carbon $expiresAt, + array $gatewayOptions = [], + ?string $idempotencyKey = null + ): Invoice { + if (empty($invoice->id)) { + throw ModelAttributeValidationException::required('Invoice', 'id'); + } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $invoice, $gatewayOptions); + + $params = array_merge(self::withoutIdempotencyKey($gatewayOptions), [ + 'due_date' => $expiresAt->format('Y-m-d'), + ]); + + $iuguInvoice = $this->iuguIdempotentRequest( + 'POST', + Iugu::getBaseURI() . '/invoices/' . rawurlencode($invoice->id) . '/duplicate', + $params, + 'duplicating invoice', + $idempotencyKey + ); + + return $this->parseInvoice($iuguInvoice); + } + + /** + * @inheritDoc + * + * A chave de idempotência passa pela `IdempotencyStore`. + */ + public function rescheduleAutomaticPixPayment(Invoice $invoice, ?string $idempotencyKey = null): Invoice + { + if (empty($invoice->id)) { + throw ModelAttributeValidationException::required('Invoice', 'id'); + } + + $url = Iugu::getBaseURI() . '/invoices/' . rawurlencode($invoice->id) + . '/reschedule_automatic_pix_payment'; + $response = $this->iuguIdempotentRequest( + 'POST', + $url, + [], + 'rescheduling automatic pix payment', + $this->idempotencyKeyFor($idempotencyKey, $invoice) + ); + + if (!empty($response->id) && !empty($response->status) && isset($response->total_cents)) { + return $this->parseInvoice($response, $invoice); } $invoice->gateway = 'iugu'; + $invoice->originType = InvoiceOriginType::INVOICE; $invoice->original = $response; return $invoice; } - /** @inheritDoc */ + /** + * @inheritDoc + * + * A chave de idempotência passa pela `IdempotencyStore`. + */ public function cancelAutomaticPixRecurrence( - AutomaticPix $automaticPix + AutomaticPix $automaticPix, + ?string $idempotencyKey = null ): AutomaticPixCancellation { if (empty($automaticPix->id)) { throw ModelAttributeValidationException::required('AutomaticPix', 'id'); @@ -454,7 +992,13 @@ public function cancelAutomaticPixRecurrence( $url = Iugu::getBaseURI() . '/automatic_pix/receiver_recurrences/' . rawurlencode($automaticPix->id) . '/cancel'; - $response = $this->automaticPixRequest('PUT', $url, [], 'cancelling automatic pix recurrence'); + $response = $this->iuguIdempotentRequest( + 'PUT', + $url, + [], + 'cancelling automatic pix recurrence', + $this->idempotencyKeyFor($idempotencyKey, $automaticPix) + ); $cancellation = $this->parseAutomaticPixCancellation($response); $cancellation->recurrenceId = $automaticPix->id; @@ -463,9 +1007,14 @@ public function cancelAutomaticPixRecurrence( return $cancellation; } - /** @inheritDoc */ + /** + * @inheritDoc + * + * A chave de idempotência passa pela `IdempotencyStore`. + */ public function cancelAutomaticPixScheduledPayment( - AutomaticPixCharge $charge + AutomaticPixCharge $charge, + ?string $idempotencyKey = null ): AutomaticPixCancellation { if (empty($charge->id)) { throw ModelAttributeValidationException::required('AutomaticPixCharge', 'id'); @@ -479,11 +1028,12 @@ public function cancelAutomaticPixScheduledPayment( 'end_to_end_id' => $charge->endToEndId, ], '', '&', PHP_QUERY_RFC3986); $url = Iugu::getBaseURI() . '/automatic_pix/receiver_recurrence_payments/cancel?' . $query; - $response = $this->automaticPixRequest( + $response = $this->iuguIdempotentRequest( 'POST', $url, [], - 'cancelling automatic pix scheduled payment' + 'cancelling automatic pix scheduled payment', + $this->idempotencyKeyFor($idempotencyKey, $charge) ); $cancellation = $this->parseAutomaticPixCancellation($response); @@ -508,7 +1058,7 @@ public function getAutomaticPixCancellation( $url = Iugu::getBaseURI() . '/automatic_pix/receiver_recurrences/' . rawurlencode($cancellation->recurrenceId) . '/cancellations/' . rawurlencode($cancellation->id); - $response = $this->automaticPixRequest('GET', $url, [], 'getting automatic pix cancellation'); + $response = $this->iuguRequest('GET', $url, [], 'getting automatic pix cancellation'); return $this->parseAutomaticPixCancellation($response, $cancellation); } @@ -523,16 +1073,16 @@ public function listAutomaticPixCancellations( throw ModelAttributeValidationException::required('AutomaticPix', 'id'); } if ($page < 1) { - throw new GatewayException('Automatic Pix cancellation page must be at least 1'); + throw ModelAttributeValidationException::invalid('AutomaticPix', 'page', 'Automatic Pix cancellation page must be at least 1'); } if ($limit < 1 || $limit > 100) { - throw new GatewayException('Automatic Pix cancellation limit must be between 1 and 100'); + throw ModelAttributeValidationException::invalid('AutomaticPix', 'limit', 'Automatic Pix cancellation limit must be between 1 and 100'); } $query = http_build_query(['limit' => $limit, 'page' => $page], '', '&', PHP_QUERY_RFC3986); $url = Iugu::getBaseURI() . '/automatic_pix/receiver_recurrences/' . rawurlencode($automaticPix->id) . '/cancellations?' . $query; - $response = $this->automaticPixRequest('GET', $url, [], 'listing automatic pix cancellations'); + $response = $this->iuguRequest('GET', $url, [], 'listing automatic pix cancellations'); $items = $this->automaticPixCancellationItems($response); @@ -661,26 +1211,28 @@ private function parseAutomaticPixCharge( } /** - * Perform a raw Iugu request while preserving the package exception contract. + * Executa uma requisição à Iugu pelo requester do driver e traduz a falha (exceção do SDK, + * corpo com `errors` ou `success` falso) para a hierarquia do pacote. + * + * @param string $method + * @param string $url + * @param array $data + * @param string $operation descrição da operação, em inglês, para a mensagem + * @param array $headers cabeçalhos extras, no formato 'Nome: valor' + * @return object|array + * @throws MultiPaymentException */ - private function automaticPixRequest( + private function iuguRequest( string $method, string $url, array $data, - string $operation + string $operation, + array $headers = [] ): object|array { try { - $response = $this->apiRequest->request($method, $url, $data); - } catch (\IuguRequestException | IuguObjectNotFound $e) { - if (str_contains($e->getMessage(), '502 Bad Gateway')) { - throw new GatewayNotAvailableException($e->getMessage()); - } - - throw new GatewayException($e->getMessage()); - } catch (\IuguAuthenticationException $e) { - throw new GatewayNotAvailableException($e->getMessage()); + $response = $this->apiRequest->request($method, $url, $data, $headers); } catch (\Exception $e) { - throw new GatewayException("Error {$operation}: {$e->getMessage()}"); + throw $this->translateIuguException($e, $operation); } $responseObject = is_array($response) ? (object) $response : $response; @@ -688,12 +1240,105 @@ private function automaticPixRequest( !empty($responseObject->errors) || (isset($responseObject->success) && $responseObject->success !== true) ) { - throw new GatewayException("Error {$operation}", (array) ($responseObject->errors ?? [])); + throw $this->iuguResponseException("Error {$operation}", (array) ($responseObject->errors ?? [])); } return $response; } + /** + * Executa uma requisição de escrita com chave de idempotência. Nos endpoints em que a Iugu + * aceita o cabeçalho (`$nativeSupport`: criar fatura, cliente, assinatura e cobrança direta) + * a chave vai em `Idempotency-Key`; nos demais, a requisição passa pela `IdempotencyStore`, + * que devolve a resposta guardada nas chamadas seguintes com a mesma chave. Sem chave, é + * uma requisição comum. + * + * Na reutilização de uma chave, a Iugu responde 409 com o id do recurso original em + * `resource_id` (fatura e cobrança); com `$fetchOriginal`, o driver lê esse recurso e o + * devolve no lugar da exceção, para a segunda chamada ter o mesmo resultado da primeira. + * + * @param string $method + * @param string $url + * @param array $data + * @param string $operation descrição da operação, em inglês, para a mensagem + * @param string|null $idempotencyKey + * @param bool $nativeSupport a Iugu aceita `Idempotency-Key` neste endpoint + * @param \Closure|null $fetchOriginal recebe o `resource_id` do 409 e devolve o recurso original + * @return object|array + * @throws MultiPaymentException + */ + private function iuguIdempotentRequest( + string $method, + string $url, + array $data, + string $operation, + ?string $idempotencyKey, + bool $nativeSupport = false, + ?\Closure $fetchOriginal = null + ): object|array { + if (is_null($idempotencyKey)) { + return $this->iuguRequest($method, $url, $data, $operation); + } + + if ($nativeSupport) { + try { + return $this->iuguRequest($method, $url, $data, $operation, ['Idempotency-Key: ' . $idempotencyKey]); + } catch (IdempotencyConflictException $e) { + if (is_null($fetchOriginal) || is_null($e->resourceId)) { + throw $e; + } + + return $fetchOriginal($e->resourceId); + } + } + + return $this->rememberIuguOperation( + $idempotencyKey, + $method . ' ' . $url, + fn () => $this->iuguRequest($method, $url, $data, $operation) + ); + } + + /** + * Executa a operação uma única vez por chave pela `IdempotencyStore` e devolve o resultado + * guardado nas chamadas seguintes. O resultado é guardado junto com a assinatura da + * operação (método e url, ou nome da operação); a mesma chave reaparecendo em outra + * operação lança `IdempotencyConflictException` em vez de devolver o resultado errado. + * + * @param string $idempotencyKey + * @param string $fingerprint identifica a operação que a chave cobre + * @param \Closure $operation + * @return mixed + * @throws IdempotencyConflictException + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + */ + private function rememberIuguOperation(string $idempotencyKey, string $fingerprint, \Closure $operation): mixed + { + $stored = $this->idempotencyStore()->remember( + self::IDEMPOTENCY_STORE_PREFIX . $idempotencyKey, + fn () => ['fingerprint' => $fingerprint, 'result' => $operation()], + ConfigurationHelper::idempotencyTtl() + ); + + if (!is_array($stored) || ($stored['fingerprint'] ?? null) !== $fingerprint) { + throw IdempotencyConflictException::reusedOnAnotherOperation($idempotencyKey); + } + + return $stored['result']; + } + + /** + * `IdempotencyStore` deste driver: a injetada no construtor ou a registrada no container, + * resolvida na primeira vez que uma operação precisa dela. + * + * @return IdempotencyStore + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + */ + private function idempotencyStore(): IdempotencyStore + { + return $this->idempotencyStore ??= ConfigurationHelper::resolveIdempotencyStore(); + } + /** * @return array */ @@ -745,6 +1390,29 @@ public function __toString() return 'iugu'; } + /** + * Monta a lista de estornos da fatura a partir de `refunded_cents`. A Iugu não lista os + * estornos nem os identifica, então a lista tem no máximo um `Refund`, sem id, com o + * acumulado estornado; sem estorno a lista é vazia. + * + * @param Invoice $invoice fatura já parseada, com `id`, `refundedAmount` e `paidAt` + * @return Refund[] + */ + private function parseRefunds(Invoice $invoice): array + { + if (empty($invoice->refundedAmount)) { + return []; + } + + $refund = new Refund(); + $refund->invoiceId = $invoice->id; + $refund->amount = $invoice->refundedAmount; + $refund->status = RefundStatus::SUCCEEDED; + $refund->gateway = 'iugu'; + + return [$refund]; + } + /** * Convert the iugu invoice into a MultiPayment invoice * @@ -758,60 +1426,55 @@ private function parseInvoice($iuguInvoice, ?Invoice $invoice = null): Invoice { $invoice = $invoice ?? new Invoice(); - $invoice->id = $iuguInvoice->id; + // leituras com `??`: a resposta é stdClass do request cru, que avisa em campo ausente + $iuguInvoice = (object) $iuguInvoice; + $invoice->id = $iuguInvoice->id ?? null; $invoice->gateway = 'iugu'; - $invoice->status = self::iuguStatusToMultiPayment($iuguInvoice->status); - $invoice->amount = $iuguInvoice->total_cents; - $invoice->paidAt = $iuguInvoice->paid_at ? new Carbon($iuguInvoice->paid_at) : null; - $invoice->url = $iuguInvoice->secure_url; + $invoice->originType = InvoiceOriginType::INVOICE; + $invoice->status = self::iuguStatusToMultiPayment($iuguInvoice->status ?? null); + $invoice->amount = $iuguInvoice->total_cents ?? null; + $invoice->paidAt = !empty($iuguInvoice->paid_at) ? new Carbon($iuguInvoice->paid_at) : null; + $invoice->url = $iuguInvoice->secure_url ?? null; $invoice->fee = $iuguInvoice->taxes_paid_cents ?? null; $invoice->original = $iuguInvoice; - $invoice->createdAt = new Carbon($iuguInvoice->created_at_iso); - $invoice->paidAmount = $iuguInvoice->paid_cents; - $invoice->refundedAmount = $iuguInvoice->refunded_cents; - $invoice->expiresAt = !empty($iuguInvoice->due_date) ? new Carbon($iuguInvoice->due_date) : null; - - if (empty($invoice->paymentMethod)) { - $invoice->paymentMethod = $this->iuguToMultiPaymentPaymentMethod($iuguInvoice->payment_method); - } - - if (!empty(($iuguInvoice->payable_with))) { - - $payableWith = $iuguInvoice->payable_with; - if (is_string($payableWith)) { - $payableWith = [$payableWith]; - } - - foreach ($payableWith as $pm) { - $method = $this->iuguToMultiPaymentPaymentMethod($pm); - if (is_null($method) && $pm === 'all') { - $invoice->availablePaymentMethods = [ - Invoice::PAYMENT_METHOD_CREDIT_CARD, - Invoice::PAYMENT_METHOD_BANK_SLIP, - Invoice::PAYMENT_METHOD_PIX, - ]; - } - } + $invoice->createdAt = !empty($iuguInvoice->created_at_iso) ? new Carbon($iuguInvoice->created_at_iso) : null; + $invoice->paidAmount = $iuguInvoice->paid_cents ?? null; + $invoice->setRefundedAmountFromGateway($iuguInvoice->refunded_cents ?? null); + $invoice->refunds = $this->parseRefunds($invoice); + $invoice->dueDate = !empty($iuguInvoice->due_date) ? new Carbon($iuguInvoice->due_date) : null; + // a Iugu não documenta a expiração do QR Code na fatura; quando vier, ela vale, senão + // fica o que o model já tinha + $invoice->pixExpiresAt = !empty($iuguInvoice->pix_qr_code_expires_at) + ? new Carbon($iuguInvoice->pix_qr_code_expires_at) + : $invoice->pixExpiresAt; + + // a resposta manda: o método pedido na escrita só fica enquanto a Iugu não informa o + // método com que a fatura foi paga + $invoice->paymentMethod = $this->iuguToMultiPaymentPaymentMethod($iuguInvoice->payment_method ?? null) + ?? $invoice->paymentMethod; + + if (!empty($iuguInvoice->payable_with)) { + $invoice->availablePaymentMethods = $this->iuguPayableWithToPaymentMethods($iuguInvoice->payable_with); } if (empty($invoice->customer)) { $invoice->customer = new Customer(); } - $invoice->customer->id = $iuguInvoice->customer_id; - $invoice->customer->name = $iuguInvoice->customer_name; - $invoice->customer->email = $iuguInvoice->email; - $invoice->customer->phoneNumber = $iuguInvoice->payer_phone; - $invoice->customer->phoneArea = $iuguInvoice->payer_phone_prefix; + $invoice->customer->id = $iuguInvoice->customer_id ?? null; + $invoice->customer->name = $iuguInvoice->customer_name ?? null; + $invoice->customer->email = $iuguInvoice->email ?? null; + $invoice->customer->phoneNumber = $iuguInvoice->payer_phone ?? null; + $invoice->customer->phoneArea = $iuguInvoice->payer_phone_prefix ?? null; $invoice->items = []; - foreach ($iuguInvoice->items as $itemIugu) { + foreach ((array) ($iuguInvoice->items ?? []) as $itemIugu) { $invoiceItem = new InvoiceItem(); $itemIugu = (object) $itemIugu; - $invoiceItem->description = $itemIugu->description; - $invoiceItem->price = $itemIugu->price_cents; - $invoiceItem->quantity = $itemIugu->quantity; + $invoiceItem->description = $itemIugu->description ?? null; + $invoiceItem->price = $itemIugu->price_cents ?? null; + $invoiceItem->quantity = $itemIugu->quantity ?? null; $invoice->items[] = $invoiceItem; } @@ -820,31 +1483,33 @@ private function parseInvoice($iuguInvoice, ?Invoice $invoice = null): Invoice $invoice->customer->address = new Address(); } $invoice->customer->address->zipCode = $iuguInvoice->payer_address_zip_code; - $invoice->customer->address->street = $iuguInvoice->payer_address_street; - $invoice->customer->address->number = $iuguInvoice->payer_address_number; - $invoice->customer->address->district = $iuguInvoice->payer_address_district; - $invoice->customer->address->city = $iuguInvoice->payer_address_city; - $invoice->customer->address->state = $iuguInvoice->payer_address_state; - $invoice->customer->address->complement = $iuguInvoice->payer_address_complement; - $invoice->customer->address->country = $iuguInvoice->payer_address_country; + $invoice->customer->address->street = $iuguInvoice->payer_address_street ?? null; + $invoice->customer->address->number = $iuguInvoice->payer_address_number ?? null; + $invoice->customer->address->district = $iuguInvoice->payer_address_district ?? null; + $invoice->customer->address->city = $iuguInvoice->payer_address_city ?? null; + $invoice->customer->address->state = $iuguInvoice->payer_address_state ?? null; + $invoice->customer->address->complement = $iuguInvoice->payer_address_complement ?? null; + $invoice->customer->address->country = $iuguInvoice->payer_address_country ?? null; } if (!empty($iuguInvoice->bank_slip)) { if (empty($invoice->bankSlip)) { $invoice->bankSlip = new BankSlip(); } - $invoice->bankSlip->url = $iuguInvoice->secure_url . '.pdf'; - $invoice->bankSlip->number = $iuguInvoice->bank_slip->digitable_line; - $invoice->bankSlip->barcodeData = $iuguInvoice->bank_slip->barcode_data; - $invoice->bankSlip->barcodeImage = $iuguInvoice->bank_slip->barcode; + $bankSlip = (object) $iuguInvoice->bank_slip; + $invoice->bankSlip->url = ($iuguInvoice->secure_url ?? '') . '.pdf'; + $invoice->bankSlip->number = $bankSlip->digitable_line ?? null; + $invoice->bankSlip->barcodeData = $bankSlip->barcode_data ?? null; + $invoice->bankSlip->barcodeImage = $bankSlip->barcode ?? null; } if (!empty($iuguInvoice->pix)) { if (empty($invoice->pix)) { $invoice->pix = new Pix(); } - $invoice->pix->qrCodeImageUrl = $iuguInvoice->pix->qrcode; - $invoice->pix->qrCodeText = $iuguInvoice->pix->qrcode_text; + $pix = (object) $iuguInvoice->pix; + $invoice->pix->qrCodeImageUrl = $pix->qrcode ?? null; + $invoice->pix->qrCodeText = $pix->qrcode_text ?? null; } if (!empty($iuguInvoice->automatic_pix)) { @@ -868,15 +1533,18 @@ private function parseInvoice($iuguInvoice, ?Invoice $invoice = null): Invoice if (empty($invoice->creditCard)) { $invoice->creditCard = new CreditCard(); } + $transaction = (object) $iuguInvoice->credit_card_transaction; $invoice->creditCard->brand = $iuguInvoice->credit_card_brand ?? null; - $invoice->creditCard->lastDigits = $iuguInvoice->credit_card_last_4 ?? $iuguInvoice->credit_card_transaction->last4; + $invoice->creditCard->lastDigits = $iuguInvoice->credit_card_last_4 ?? $transaction->last4 ?? null; $holderName = null; - foreach ($iuguInvoice->variables as $iuguInvoiceVariable) { - if ($iuguInvoiceVariable->variable == 'payment_data.holder_name') { - $holderName = $iuguInvoiceVariable->value; - } else if (empty($invoice->creditCard->lastDigits) && $iuguInvoiceVariable->variable == 'payment_data.display_number') { - $invoice->creditCard->lastDigits = substr($iuguInvoiceVariable->value, -4); + foreach ((array) ($iuguInvoice->variables ?? []) as $iuguInvoiceVariable) { + $iuguInvoiceVariable = (object) $iuguInvoiceVariable; + $variableName = $iuguInvoiceVariable->variable ?? null; + if ($variableName == 'payment_data.holder_name') { + $holderName = $iuguInvoiceVariable->value ?? null; + } else if (empty($invoice->creditCard->lastDigits) && $variableName == 'payment_data.display_number') { + $invoice->creditCard->lastDigits = substr((string) ($iuguInvoiceVariable->value ?? ''), -4); } } @@ -900,7 +1568,7 @@ private function parseInvoice($iuguInvoice, ?Invoice $invoice = null): Invoice * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException */ - public function chargeInvoiceWithCreditCard(Invoice $invoice): Invoice + public function chargeInvoiceWithCreditCard(Invoice $invoice, ?string $idempotencyKey = null): Invoice { if (empty($invoice->id)) { throw ModelAttributeValidationException::required('Invoice', 'id'); @@ -923,87 +1591,126 @@ public function chargeInvoiceWithCreditCard(Invoice $invoice): Invoice $iuguInvoiceData['token'] = $invoice->creditCard->token; } - $iuguInvoice = $this->chargeIuguInvoice($iuguInvoiceData); + $iuguInvoice = $this->chargeIuguInvoice($iuguInvoiceData, $this->idempotencyKeyFor($idempotencyKey, $invoice)); return $this->parseInvoice($iuguInvoice, $invoice); } /** + * Cobra pela cobrança direta da Iugu (`POST /charge`, com a chave de idempotência no + * cabeçalho `Idempotency-Key`) e lê a fatura cobrada, que a resposta só identifica pelo id. + * Recusa de cartão (`success` falso) vira `ChargingException`; chave reutilizada (409 com + * `resource_id`) devolve a fatura da primeira cobrança. + * * @param array $iuguInvoiceData - * @return mixed + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return object * @throws \Potelo\MultiPayment\Exceptions\ChargingException * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException + * @throws \Potelo\MultiPayment\Exceptions\AuthenticationException */ - private function chargeIuguInvoice(array $iuguInvoiceData) + private function chargeIuguInvoice(array $iuguInvoiceData, ?string $idempotencyKey): object { + $headers = is_null($idempotencyKey) ? [] : ['Idempotency-Key: ' . $idempotencyKey]; + try { - $iuguCharge = \Iugu_Charge::create($iuguInvoiceData); + $iuguCharge = $this->apiRequest->request('POST', Iugu::getBaseURI() . '/charge', $iuguInvoiceData, $headers); } catch (\Exception $e) { - throw new GatewayException($e->getMessage()); + throw $this->translateIuguException($e, 'charging invoice'); } - if ($iuguCharge->errors) { - throw new GatewayException('Error charging invoice', $iuguCharge->errors); - } elseif (!$iuguCharge->success) { - $exception = new ChargingException('Error charging invoice: ' . $iuguCharge->info_message); - $exception->chargeResponse = $iuguCharge; + + $iuguCharge = is_array($iuguCharge) ? (object) $iuguCharge : $iuguCharge; + if (!empty($iuguCharge->errors)) { + $exception = $this->iuguResponseException('Error charging invoice', $iuguCharge->errors); + // chave reutilizada: a Iugu aponta a fatura da primeira cobrança, que é o resultado + if ($exception instanceof IdempotencyConflictException && !is_null($exception->resourceId)) { + return $this->fetchIuguInvoice($exception->resourceId, 'getting charged invoice'); + } + throw $exception; } - return $iuguCharge->invoice(); + if (empty($iuguCharge->success)) { + throw $this->cardDeclined($iuguCharge); + } + + // a cobrança devolve só o id; a leitura da fatura é outra requisição e falha como tal + $invoiceId = $iuguCharge->invoice_id ?? null; + if (empty($invoiceId)) { + throw $this->iuguResponseException('Error getting charged invoice: the charge response has no invoice_id', null); + } + + return $this->fetchIuguInvoice((string) $invoiceId, 'getting charged invoice'); } /** - * @inheritDoc + * Traduz uma cobrança recusada (`success` falso em `POST /v1/charge`) para + * `ChargingException`, com o LR lido da resposta e traduzido para `DeclineCode`. LR fora da + * tabela vira `DeclineCode::UNKNOWN`, com o código preservado em `gatewayCode` e registro em + * nível `info`; resposta sem LR também vira `UNKNOWN`, sem registro. + * + * @param object $iuguCharge resposta da cobrança + * @return ChargingException */ - public function getCustomer(Customer $customer): Customer + private function cardDeclined(object $iuguCharge): ChargingException { - try { - $iuguCustomer = Iugu_Customer::fetch($customer->id); - } catch (\IuguRequestException | IuguObjectNotFound $e) { - if (str_contains($e->getMessage(), '502 Bad Gateway')) { - throw new GatewayNotAvailableException($e->getMessage()); - } else { - throw new GatewayException($e->getMessage()); + $lr = IuguDeclineCodes::extractLr($iuguCharge); + $declineCode = IuguDeclineCodes::toDeclineCode($lr); + if ($declineCode === null) { + $declineCode = DeclineCode::UNKNOWN; + if ($lr !== null) { + LogHelper::info('Código LR da Iugu sem tradução para DeclineCode', ['gateway' => 'iugu', 'lr' => $lr]); } - } catch (\Exception $e) { - throw new GatewayException("Error getting customer: {$e->getMessage()}"); } - if (!empty($iuguCustomer->errors)) { - throw new GatewayException('Error getting customer', $iuguCustomer->errors); - } + $detail = $iuguCharge->info_message ?? $iuguCharge->message ?? ''; + $exception = ChargingException::declined( + 'iugu', + $declineCode, + $lr, + is_string($detail) ? $detail : '', + null, + $this->lastIuguHttpStatus() + ); + $exception->chargeResponse = $iuguCharge; + + return $exception; + } + + /** + * @inheritDoc + */ + public function getCustomer(Customer $customer): Customer + { + $iuguCustomer = $this->iuguRequest( + 'GET', + Iugu::getBaseURI() . '/customers/' . rawurlencode((string) $customer->id), + [], + 'getting customer' + ); return $this->parseCustomer($iuguCustomer, $customer); } - public function updateCustomer(Customer $customer): Customer + /** + * @inheritDoc + * + * A chave de idempotência passa pela `IdempotencyStore` (a Iugu não aceita o cabeçalho + * neste endpoint). + */ + public function updateCustomer(Customer $customer, ?string $idempotencyKey = null): Customer { if (empty($customer->id)) { throw ModelAttributeValidationException::required('Customer', 'id'); } - $iuguCustomerData = $this->customerToIuguData($customer); - - try { - $iuguCustomer = Iugu_Customer::fetch($customer->id); - foreach ($iuguCustomerData as $key => $value) { - $iuguCustomer->{$key} = $value; - } - $iuguCustomer->save(); - } catch (\IuguRequestException | IuguObjectNotFound $e) { - if (str_contains($e->getMessage(), '502 Bad Gateway')) { - throw new GatewayNotAvailableException($e->getMessage()); - } else { - throw new GatewayException($e->getMessage()); - } - } catch (\IuguAuthenticationException $e) { - throw new GatewayNotAvailableException($e->getMessage()); - } catch (\Exception $e) { - throw new GatewayException($e->getMessage()); - } - - if ($iuguCustomer->errors) { - throw new GatewayException('Error updating customer', $iuguCustomer->errors); - } + $iuguCustomer = $this->iuguIdempotentRequest( + 'PUT', + Iugu::getBaseURI() . '/customers/' . rawurlencode($customer->id), + $this->customerToIuguData($customer), + 'updating customer', + $this->idempotencyKeyFor($idempotencyKey, $customer) + ); return $this->parseCustomer($iuguCustomer, $customer); } @@ -1019,6 +1726,7 @@ public function updateCustomer(Customer $customer): Customer private function parseCustomer($iuguCustomer, ?Customer $customer = null): Customer { $customer = $customer ?? new Customer(); + $iuguCustomer = (object) $iuguCustomer; $valuesInsideCustomVariables = ['birth_date' => null, 'country' => null]; @@ -1030,17 +1738,20 @@ private function parseCustomer($iuguCustomer, ?Customer $customer = null): Custo } } - $customer->id = $iuguCustomer->id; - $customer->name = $iuguCustomer->name; - $customer->email = $iuguCustomer->email; - $customer->taxDocument = $iuguCustomer->cpf_cnpj; - $customer->phoneNumber = $iuguCustomer->phone; - $customer->phoneArea = $iuguCustomer->phone_prefix; + // leituras com `??`: a resposta pode ser stdClass (request cru), que avisa em campo ausente + $customer->id = $iuguCustomer->id ?? null; + $customer->name = $iuguCustomer->name ?? null; + $customer->email = $iuguCustomer->email ?? null; + $customer->taxDocument = $iuguCustomer->cpf_cnpj ?? null; + $customer->phoneNumber = $iuguCustomer->phone ?? null; + $customer->phoneArea = $iuguCustomer->phone_prefix ?? null; $customer->birthDate = !empty($valuesInsideCustomVariables['birth_date']) ? Carbon::createFromFormat('Y-m-d', $valuesInsideCustomVariables['birth_date']) : null; $customer->gateway = 'iugu'; - $customer->createdAt = new Carbon($iuguCustomer->created_at_iso); + // o recurso de cliente da Iugu devolve `created_at` (a fatura é que tem `created_at_iso`) + $createdAt = $iuguCustomer->created_at ?? $iuguCustomer->created_at_iso ?? null; + $customer->createdAt = !empty($createdAt) ? new Carbon($createdAt) : null; $customer->original = $iuguCustomer; if (!empty($iuguCustomer->zip_code) || !empty($iuguCustomer->street) || !empty($iuguCustomer->number) || !empty($iuguCustomer->district) || !empty($iuguCustomer->city) || !empty($iuguCustomer->state) || !empty($iuguCustomer->complement) || !empty($iuguCustomer->country)) { @@ -1108,10 +1819,8 @@ private function customerToIuguData(Customer $customer): array ]; } - if (!empty($customer->gatewayAdicionalOptions)) { - foreach ($customer->gatewayAdicionalOptions as $option => $value) { - $iuguCustomerData[$option] = $value; - } + foreach (self::withoutIdempotencyKey($customer->gatewayOptions) as $option => $value) { + $iuguCustomerData[$option] = $value; } if (!empty($customer->defaultCard) && !empty($customer->defaultCard->id)) { @@ -1124,36 +1833,36 @@ private function customerToIuguData(Customer $customer): array /** * @inheritDoc */ - public function setCustomerDefaultCard(Customer $customer, string $cardId): Customer + public function setCustomerDefaultCard(Customer $customer, string $cardId, ?string $idempotencyKey = null): Customer { $customer->defaultCard = new CreditCard(); $customer->defaultCard->id = $cardId; - return $this->updateCustomer($customer); + return $this->updateCustomer($customer, $idempotencyKey); } /** * @inheritDoc + * + * A chave de idempotência passa pela `IdempotencyStore`. */ - public function deleteCreditCard(CreditCard $creditCard): void + public function deleteCreditCard(CreditCard $creditCard, ?string $idempotencyKey = null): void { - try { - $iuguCreditCard = new Iugu_PaymentMethod([ - 'id' => $creditCard->id, - 'customer_id' => $creditCard->customer->id - ]); - $iuguCreditCard->delete(); - } catch (\IuguRequestException | IuguObjectNotFound $e) { - if (str_contains($e->getMessage(), '502 Bad Gateway')) { - throw new GatewayNotAvailableException($e->getMessage()); - } else { - throw new GatewayException($e->getMessage()); - } - } catch (\IuguAuthenticationException $e) { - throw new GatewayNotAvailableException($e->getMessage()); - } catch (\Exception $e) { - throw new GatewayException($e->getMessage()); + if (empty($creditCard->id)) { + throw ModelAttributeValidationException::required('CreditCard', 'id'); + } + if (empty($creditCard->customer) || empty($creditCard->customer->id)) { + throw ModelAttributeValidationException::required('CreditCard', 'customer'); } + + $this->iuguIdempotentRequest( + 'DELETE', + Iugu::getBaseURI() . '/customers/' . rawurlencode($creditCard->customer->id) + . '/payment_methods/' . rawurlencode($creditCard->id), + [], + 'deleting credit card', + $this->idempotencyKeyFor($idempotencyKey, $creditCard) + ); } /** @@ -1161,24 +1870,18 @@ public function deleteCreditCard(CreditCard $creditCard): void */ public function getCreditCard(CreditCard $creditCard): CreditCard { - try { - $iuguCustomer = new Iugu_Customer(['id' => $creditCard->customer->id]); - $iuguCreditCard = $iuguCustomer->payment_methods()->fetch($creditCard->id); - } catch (\IuguRequestException | IuguObjectNotFound $e) { - if (str_contains($e->getMessage(), '502 Bad Gateway')) { - throw new GatewayNotAvailableException($e->getMessage()); - } else { - throw new GatewayException($e->getMessage()); - } - } catch (\IuguAuthenticationException $e) { - throw new GatewayNotAvailableException($e->getMessage()); - } catch (\Exception $e) { - throw new GatewayException($e->getMessage()); - } - if ($iuguCreditCard->errors) { - throw new GatewayException('Error getting creditCard: ', $iuguCreditCard->errors); + if (empty($creditCard->customer) || empty($creditCard->customer->id)) { + throw ModelAttributeValidationException::required('CreditCard', 'customer'); } + $iuguCreditCard = $this->iuguRequest( + 'GET', + Iugu::getBaseURI() . '/customers/' . rawurlencode($creditCard->customer->id) + . '/payment_methods/' . rawurlencode((string) $creditCard->id), + [], + 'getting credit card' + ); + return $this->parseIuguCard($iuguCreditCard, $creditCard); } @@ -1192,6 +1895,7 @@ private function parseIuguCard(mixed $iuguCreditCard, ?CreditCard $creditCard = if (is_null($creditCard)) { $creditCard = new CreditCard(); } + $iuguCreditCard = (object) $iuguCreditCard; $creditCard->id = $iuguCreditCard->id ?? null; $creditCard->brand = $iuguCreditCard->data->brand ?? null; @@ -1204,10 +1908,2020 @@ private function parseIuguCard(mixed $iuguCreditCard, ?CreditCard $creditCard = $creditCard->firstName = $names[0] ?? null; $creditCard->lastName = $names[array_key_last($names)] ?? null; } - $creditCard->lastDigits = $iuguCreditCard->data->last_digits ?? substr($iuguCreditCard->data->display_number, -4); + $displayNumber = $iuguCreditCard->data->display_number ?? null; + $creditCard->lastDigits = $iuguCreditCard->data->last_digits + ?? (is_string($displayNumber) ? substr($displayNumber, -4) : null); $creditCard->gateway = 'iugu'; $creditCard->original = $iuguCreditCard; - $creditCard->createdAt = new Carbon($iuguCreditCard->created_at_iso) ?? null; + $creditCard->createdAt = !empty($iuguCreditCard->created_at_iso) ? new Carbon($iuguCreditCard->created_at_iso) : null; return $creditCard; } + + /** + * @inheritDoc + * + * `payable_with` vem de `availablePaymentMethods` ou, com ela vazia, de `paymentMethod` + * (cartão quando só `creditCard` foi informado); o cartão informado vira o padrão do + * cliente antes da criação, porque a assinatura da Iugu cobra o cartão padrão. `trialDays` + * vira `trialEndsAt` contado de hoje, e um trial (`trialEndsAt`) vai como `expires_at` com + * `only_charge_on_due_date`, para o primeiro ciclo só ser cobrado no fim do teste; + * `nextBillingAt` sozinho vai só como `expires_at`. Desconto com validade (`validUntil`, + * ou `cycles` acima de 1, convertido pela duração do plano) grava + * `mp_discount__until` em `custom_variables` numa segunda requisição (`PUT`, + * chave derivada `{chave}:discounts`); o comando `multipayment:sync-subscriptions` remove + * o subitem quando a data passa. A chave de idempotência vai no cabeçalho + * `Idempotency-Key` de `POST /subscriptions`. Na reutilização da chave a Iugu responde 409 + * sem o id da assinatura original (`resource_id: processing`), que chega como + * `IdempotencyConflictException`. + */ + public function createSubscription(Subscription $subscription, ?string $idempotencyKey = null): Subscription + { + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); + + $requestedDiscounts = $subscription->discounts ?? []; + $data = array_merge( + $this->subscriptionToIuguData($subscription), + self::withoutIdempotencyKey($subscription->gatewayOptions) + ); + + $this->applySubscriptionCreditCard($subscription, $idempotencyKey); + + $response = $this->iuguIdempotentRequest( + 'POST', + Iugu::getBaseURI() . '/subscriptions', + $data, + 'creating subscription', + $idempotencyKey, + true + ); + + $parsed = $this->parseIuguSubscription($response, $subscription); + + return empty($requestedDiscounts) + ? $parsed + : $this->applyIuguDiscountValidities($requestedDiscounts, $parsed, true, $idempotencyKey); + } + + /** + * Torna o cartão de `Subscription::$creditCard` o cartão padrão do cliente, que é o que a + * Iugu cobra numa assinatura paga com cartão. Cartão sem `id` (token ou dados crus) é salvo + * no cliente já como padrão (`{chave}:card`); cartão com `id` é marcado como padrão por um + * `PUT` no cliente (`{chave}:default`). Sem cartão no model, nada é feito. + * + * @param Subscription $subscription + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return void + * @throws GatewayException|GatewayNotAvailableException|ModelAttributeValidationException + */ + private function applySubscriptionCreditCard(Subscription $subscription, ?string $idempotencyKey): void + { + $creditCard = $subscription->creditCard; + if (empty($creditCard)) { + return; + } + if (empty($subscription->customer) || empty($subscription->customer->id)) { + throw ModelAttributeValidationException::required('Subscription', 'customer'); + } + + if (empty($creditCard->id)) { + if (empty($creditCard->customer)) { + $creditCard->customer = $subscription->customer; + } + $creditCard->default = true; + $subscription->creditCard = $this->createCreditCard( + $creditCard, + self::derivedIdempotencyKey($idempotencyKey, 'card') + ); + + return; + } + + $this->iuguIdempotentRequest( + 'PUT', + Iugu::getBaseURI() . '/customers/' . rawurlencode($subscription->customer->id), + ['default_payment_method_id' => $creditCard->id], + 'setting the subscription card as the customer default', + self::derivedIdempotencyKey($idempotencyKey, 'default') + ); + $creditCard->default = true; + } + + /** + * @inheritDoc + */ + public function getSubscription(Subscription $subscription): Subscription + { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + + $response = $this->iuguRequest( + 'GET', + $this->subscriptionUrl($subscription->id), + [], + 'getting subscription' + ); + + return $this->parseIuguSubscription($response, $subscription); + } + + /** + * @inheritDoc + * + * A chave de idempotência passa pela `IdempotencyStore`: a informada no `PUT` da + * atualização, `{chave}:remove` na remoção de subitens que a antecede, `{chave}:card` ou + * `{chave}:default` no cartão que passa a ser o padrão do cliente e `{chave}:discounts` + * na escrita da validade dos descontos (`mp_discount__until`), que também + * remove a variável de desconto que saiu da lista. + */ + public function updateSubscription(Subscription $subscription, ?string $idempotencyKey = null): Subscription + { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); + + $requestedDiscounts = $subscription->discounts; + $data = array_merge( + $this->subscriptionToIuguData($subscription, false), + self::withoutIdempotencyKey($subscription->gatewayOptions) + ); + $subitems = $data['subitems'] ?? null; + unset($data['subitems']); + + $this->applySubscriptionCreditCard($subscription, $idempotencyKey); + + if (!is_null($subitems)) { + // a Iugu recusa remover e adicionar subitens na mesma requisição, então a remoção + // vai sozinha e antes; entre as duas a assinatura fica sem os itens removidos + $toDestroy = $this->iuguSubitemsToDestroy( + $subscription->id, + $subitems, + !is_null($subscription->items), + !is_null($subscription->discounts) + ); + + if (!empty($toDestroy)) { + $this->iuguIdempotentRequest( + 'PUT', + $this->subscriptionUrl($subscription->id), + ['subitems' => $toDestroy], + 'removing subscription items', + self::derivedIdempotencyKey($idempotencyKey, 'remove') + ); + } + + if (!empty($subitems)) { + $data['subitems'] = $subitems; + } + } + + $response = $this->iuguIdempotentRequest( + 'PUT', + $this->subscriptionUrl($subscription->id), + $data, + 'updating subscription', + $idempotencyKey + ); + + $parsed = $this->parseIuguSubscription($response, $subscription); + + return is_null($requestedDiscounts) + ? $parsed + : $this->applyIuguDiscountValidities($requestedDiscounts, $parsed, false, $idempotencyKey); + } + + /** + * @inheritDoc + * + * A chave de idempotência passa pela `IdempotencyStore`. + */ + public function suspendSubscription(Subscription $subscription, ?string $idempotencyKey = null): Subscription + { + $response = $this->iuguSubscriptionAction( + $subscription, + 'suspend', + 'suspending subscription', + $this->idempotencyKeyFor($idempotencyKey, $subscription) + ); + + return $this->parseIuguSubscription($response, $subscription); + } + + /** + * @inheritDoc + * + * Reativa também uma assinatura cancelada por `cancelSubscription()`, imediato ou + * agendado: a marca de cancelamento (`mp_canceled_at`) e o agendamento + * (`mp_cancel_at_period_end`, `mp_cancel_scheduled_for`) são removidos de + * `custom_variables` numa segunda requisição (`PUT` com `_destroy`, chave derivada + * `{chave}:uncancel`), para a assinatura voltar a ler como `ACTIVE` sem cancelamento + * pendente. A chave de idempotência passa pela `IdempotencyStore`. + */ + public function resumeSubscription(Subscription $subscription, ?string $idempotencyKey = null): Subscription + { + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); + + $response = $this->iuguSubscriptionAction($subscription, 'activate', 'resuming subscription', $idempotencyKey); + $resumed = $this->parseIuguSubscription($response, $subscription); + + if (is_null($resumed->canceledAt) && !$resumed->cancelAtPeriodEnd) { + return $resumed; + } + + $response = $this->iuguIdempotentRequest( + 'PUT', + $this->subscriptionUrl($subscription->id), + ['custom_variables' => [ + ['name' => self::CANCELED_AT_VARIABLE, '_destroy' => true], + ['name' => self::CANCEL_AT_PERIOD_END_VARIABLE, '_destroy' => true], + ['name' => self::CANCEL_SCHEDULED_FOR_VARIABLE, '_destroy' => true], + ]], + 'clearing the subscription cancellation', + self::derivedIdempotencyKey($idempotencyKey, 'uncancel') + ); + + return $this->parseIuguSubscription($response, $resumed); + } + + /** + * @inheritDoc + * + * Na Iugu o cancelamento é uma suspensão com marca: a assinatura é suspensa e recebe a + * data do cancelamento em `custom_variables` (`mp_canceled_at`), numa segunda requisição + * (`PUT`, chave derivada `{chave}:cancel`); é essa marca que faz a leitura devolver + * `CANCELED`. Assinatura que já tem a marca é só suspensa de novo, e a data original fica. + * Com `$atPeriodEnd`, a assinatura não é suspensa: um único `PUT` grava + * `mp_cancel_at_period_end` e `mp_cancel_scheduled_for` (a data da próxima cobrança), ela + * segue ativa com `cancelAtPeriodEnd` verdadeiro, e o comando + * `multipayment:sync-subscriptions` a suspende quando a data chega, gravando + * `mp_canceled_at`. `resumeSubscription()` desfaz as duas formas. A chave de idempotência + * passa pela `IdempotencyStore`. + */ + public function cancelSubscription( + Subscription $subscription, + bool $atPeriodEnd = false, + ?string $idempotencyKey = null + ): Subscription { + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); + + if ($atPeriodEnd) { + return $this->scheduleIuguCancellation($subscription, $idempotencyKey); + } + + $response = $this->iuguSubscriptionAction($subscription, 'suspend', 'suspending subscription', $idempotencyKey); + $suspended = $this->parseIuguSubscription($response, $subscription); + + if (!is_null($suspended->canceledAt)) { + return $suspended; + } + + $response = $this->iuguIdempotentRequest( + 'PUT', + $this->subscriptionUrl($subscription->id), + ['custom_variables' => [[ + 'name' => self::CANCELED_AT_VARIABLE, + 'value' => Carbon::now()->toIso8601String(), + ]]], + 'marking the subscription as canceled', + self::derivedIdempotencyKey($idempotencyKey, 'cancel') + ); + + return $this->parseIuguSubscription($response, $suspended); + } + + /** + * Agenda o cancelamento para o fim do período corrente: grava em `custom_variables` a + * intenção (`mp_cancel_at_period_end`) e a data programada (`mp_cancel_scheduled_for`, a + * data da próxima cobrança), sem suspender. A data vem de `nextBillingAt` do model ou de + * uma leitura da assinatura; sem data de cobrança não há fim de período e a operação é + * recusada. Chamada repetida atualiza a data programada para a próxima cobrança atual. + * + * @param Subscription $subscription + * @param string|null $idempotencyKey chave já resolvida; passa pela `IdempotencyStore` + * + * @return Subscription + * @throws GatewayException|GatewayNotAvailableException|ModelAttributeValidationException + */ + private function scheduleIuguCancellation(Subscription $subscription, ?string $idempotencyKey): Subscription + { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + + $scheduledFor = $subscription->nextBillingAt; + if (empty($scheduledFor)) { + $current = $this->iuguRequest( + 'GET', + $this->subscriptionUrl($subscription->id), + [], + 'getting subscription' + ); + $expiresAt = ((object) $current)->expires_at ?? null; + $scheduledFor = empty($expiresAt) ? null : new Carbon($expiresAt); + } + + if (empty($scheduledFor)) { + throw ModelAttributeValidationException::invalid( + 'Subscription', + 'nextBillingAt', + 'the subscription has no billing date, so there is no period end to schedule' + . ' the cancellation at; cancel it immediately instead.' + ); + } + + $response = $this->iuguIdempotentRequest( + 'PUT', + $this->subscriptionUrl($subscription->id), + ['custom_variables' => [ + ['name' => self::CANCEL_AT_PERIOD_END_VARIABLE, 'value' => '1'], + ['name' => self::CANCEL_SCHEDULED_FOR_VARIABLE, 'value' => $scheduledFor->format('Y-m-d')], + ]], + 'scheduling the subscription cancellation', + $idempotencyKey + ); + + return $this->parseIuguSubscription($response, $subscription); + } + + /** + * @inheritDoc + * + * Percorre todas as assinaturas da conta em páginas de 100. Assinatura suspensa é pulada: + * ela não gera fatura, e a que o comando suspendeu na rodada anterior já está aplicada. O + * desconto vencido e a variável dele saem num único `PUT`; a variável de desconto sem + * subitem correspondente (sobra de uma escrita interrompida) também é removida. O + * cancelamento agendado que chegou à data grava a marca `mp_canceled_at` e suspende + * (`POST /suspend`), nessa ordem, e o agendamento fica gravado. + */ + public function syncSubscriptions(bool $dryRun = false): array + { + $actions = []; + $limit = 100; + $start = 0; + + do { + $query = http_build_query(['limit' => $limit, 'start' => $start], '', '&', PHP_QUERY_RFC3986); + $response = $this->iuguRequest( + 'GET', + Iugu::getBaseURI() . '/subscriptions?' . $query, + [], + 'listing subscriptions' + ); + $items = (array) (is_array($response) ? $response : ($response->items ?? [])); + + foreach ($items as $item) { + $item = (object) $item; + // uma assinatura com problema não derruba a varredura das demais; a rodada + // seguinte tenta de novo + try { + array_push($actions, ...$this->syncIuguSubscription($item, $dryRun)); + } catch (MultiPaymentException $e) { + LogHelper::warning( + 'Sincronização da assinatura [' . ($item->id ?? '?') . '] da Iugu falhou: ' + . $e->getMessage(), + ['subscription' => $item->id ?? null, 'gateway' => 'iugu'] + ); + } + } + + $start += $limit; + } while (count($items) === $limit); + + return $actions; + } + + /** + * Aplica numa assinatura o que a emulação deixou agendado e devolve as ações, no formato + * de `SubscriptionSyncContract::syncSubscriptions()`. Com `$dryRun`, só as devolve. + * + * @param object $iuguSubscription + * @param bool $dryRun + * + * @return array + * @throws GatewayException|GatewayNotAvailableException + */ + private function syncIuguSubscription(object $iuguSubscription, bool $dryRun): array + { + if (!empty($iuguSubscription->suspended) || empty($iuguSubscription->id)) { + return []; + } + $id = (string) $iuguSubscription->id; + + $discountSubitemIds = []; + foreach ((array) ($iuguSubscription->subitems ?? []) as $subitem) { + $subitem = (object) $subitem; + if (($subitem->price_cents ?? 0) < 0 && !empty($subitem->id)) { + $discountSubitemIds[] = (string) $subitem->id; + } + } + + $actions = []; + $subitemDestroys = []; + $variableDestroys = []; + foreach (array_keys($this->iuguCustomVariables($iuguSubscription)) as $name) { + $subitemId = self::discountSubitemIdFromVariable((string) $name); + if (is_null($subitemId)) { + continue; + } + + if (!in_array($subitemId, $discountSubitemIds, true)) { + $variableDestroys[] = ['name' => (string) $name, '_destroy' => true]; + $actions[] = [ + 'subscription' => $id, + 'action' => 'remove_orphan_discount_variable', + 'detail' => "variável {$name} sem subitem de desconto correspondente removida", + ]; + continue; + } + + $until = $this->iuguDiscountUntil($iuguSubscription, $subitemId); + if (is_null($until) || !$until->copy()->endOfDay()->isPast()) { + continue; + } + + $subitemDestroys[] = ['id' => $subitemId, '_destroy' => true]; + $variableDestroys[] = ['name' => (string) $name, '_destroy' => true]; + $actions[] = [ + 'subscription' => $id, + 'action' => 'remove_discount', + 'detail' => "desconto {$subitemId} vencido em {$until->format('Y-m-d')} removido", + ]; + } + + $cancelDue = $this->iuguScheduledCancellationDue($iuguSubscription); + if (!is_null($cancelDue)) { + $actions[] = [ + 'subscription' => $id, + 'action' => 'cancel', + 'detail' => "cancelamento agendado para {$cancelDue->format('Y-m-d')} aplicado:" + . ' assinatura suspensa e marcada como cancelada', + ]; + } + + if ($dryRun || $actions === []) { + return $actions; + } + + if ($variableDestroys !== []) { + $data = ['custom_variables' => $variableDestroys]; + if ($subitemDestroys !== []) { + $data['subitems'] = $subitemDestroys; + } + $this->iuguRequest( + 'PUT', + $this->subscriptionUrl($id), + $data, + 'removing expired subscription discounts' + ); + } + + if (!is_null($cancelDue)) { + // a marca vai antes da suspensão: uma falha entre as duas deixa a assinatura + // ativa com a marca, que a rodada seguinte reprocessa (suspensa sem a marca + // seria pulada e leria SUSPENDED em vez de CANCELED para sempre) + $this->iuguRequest( + 'PUT', + $this->subscriptionUrl($id), + ['custom_variables' => [[ + 'name' => self::CANCELED_AT_VARIABLE, + 'value' => Carbon::now()->toIso8601String(), + ]]], + 'marking the subscription as canceled' + ); + $this->iuguRequest('POST', $this->subscriptionUrl($id) . '/suspend', [], 'suspending subscription'); + } + + return $actions; + } + + /** + * Data do cancelamento agendado que já chegou (`mp_cancel_at_period_end` com + * `mp_cancel_scheduled_for` de hoje ou anterior); nulo quando não há agendamento, a data + * ainda não chegou ou a data gravada não é legível (com aviso no log). + * + * @param object $iuguSubscription + * + * @return Carbon|null + */ + private function iuguScheduledCancellationDue(object $iuguSubscription): ?Carbon + { + $flag = $this->iuguCustomVariable($iuguSubscription, self::CANCEL_AT_PERIOD_END_VARIABLE); + if (is_null($flag) || $flag === '0') { + return null; + } + + $scheduled = $this->iuguCustomVariable($iuguSubscription, self::CANCEL_SCHEDULED_FOR_VARIABLE); + if (is_null($scheduled)) { + return null; + } + + try { + $date = new Carbon($scheduled); + } catch (\Throwable) { + LogHelper::warning( + 'Data de cancelamento agendado [' . self::CANCEL_SCHEDULED_FOR_VARIABLE + . "] ilegível [{$scheduled}] na assinatura [" . ($iuguSubscription->id ?? '?') + . '] da Iugu, tratada como ausente', + ['subscription' => $iuguSubscription->id ?? null, 'value' => $scheduled, 'gateway' => 'iugu'] + ); + + return null; + } + + return $date->copy()->startOfDay()->lte(Carbon::now()) ? $date : null; + } + + /** + * Faz o `POST` de uma ação da assinatura (`suspend`, `activate`) e devolve a resposta crua. + * + * @param Subscription $subscription + * @param string $action + * @param string $operation + * @param string|null $idempotencyKey chave já resolvida; passa pela `IdempotencyStore` + * + * @return object|array + * @throws ModelAttributeValidationException + */ + private function iuguSubscriptionAction( + Subscription $subscription, + string $action, + string $operation, + ?string $idempotencyKey + ): object|array { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + + return $this->iuguIdempotentRequest( + 'POST', + $this->subscriptionUrl($subscription->id) . '/' . $action, + [], + $operation, + $idempotencyKey + ); + } + + /** + * @inheritDoc + * + * `CHARGE_DIFFERENCE` vai para `POST change_plan`, que gera a fatura da troca na hora; + * `NONE` vai num `PUT` com `skip_charge`; `CREDIT` é recusado antes da rede + * (`PLAN_CHANGE_PRORATION` é limitação da Iugu, que não gera crédito ao trocar de plano). + * A chave de idempotência passa pela `IdempotencyStore` na requisição que aplica a troca + * (`POST change_plan` ou `PUT`); a releitura da assinatura que segue a troca com cobrança + * não a usa. + */ + public function changeSubscriptionPlan( + Subscription $subscription, + string $planId, + ProrationBehavior|bool $proration = ProrationBehavior::CHARGE_DIFFERENCE, + ?string $idempotencyKey = null, + ?bool $charge = null + ): Subscription { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + + $proration = ProrationBehavior::resolve($charge ?? $proration); + if (!is_null($proration->requiredCapability())) { + $this->assertSupports( + $proration->requiredCapability(), + 'A Iugu não gera crédito do período não usado ao trocar de plano; use' + . ' ProrationBehavior::CHARGE_DIFFERENCE ou ProrationBehavior::NONE.' + ); + } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); + + if ($proration === ProrationBehavior::CHARGE_DIFFERENCE) { + $this->iuguIdempotentRequest( + 'POST', + $this->subscriptionUrl($subscription->id) . '/change_plan/' . rawurlencode($planId), + [], + 'changing subscription plan', + $idempotencyKey + ); + + $subscription->planId = $planId; + + return $this->getSubscription($subscription); + } + + $data = ['plan_identifier' => $planId, 'skip_charge' => true]; + + if (!empty($subscription->nextBillingAt)) { + $data['expires_at'] = $subscription->nextBillingAt->format('Y-m-d'); + } + + $response = $this->iuguIdempotentRequest( + 'PUT', + $this->subscriptionUrl($subscription->id), + $data, + 'changing subscription plan', + $idempotencyKey + ); + + return $this->parseIuguSubscription($response, $subscription); + } + + /** + * @inheritDoc + * + * Simula o fluxo de `ProrationBehavior::CHARGE_DIFFERENCE` (`change_plan_simulation`). + * `appliesImmediately` depende de como a assinatura é paga: verdadeiro quando o único + * método é cartão (a Iugu cobra o cartão padrão na hora); falso quando há boleto ou Pix, + * porque a Iugu só efetiva a troca depois do pagamento da fatura gerada. Quando o model não + * traz `paymentMethod` nem `availablePaymentMethods`, o driver lê a assinatura antes da + * simulação (num model à parte, sem tocar no do chamador), o que custa uma requisição a + * mais; `creditCard` sozinho não conta, porque é atributo de escrita. + */ + public function previewSubscriptionPlanChange( + Subscription $subscription, + string $planId + ): SubscriptionPlanChange { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + + $source = $subscription; + if (empty($subscription->availablePaymentMethods) && is_null($subscription->paymentMethod)) { + $source = new Subscription(); + $source->id = $subscription->id; + $source = $this->getSubscription($source); + } + $paymentMethods = $source->resolvedPaymentMethods(); + + $response = $this->iuguRequest( + 'GET', + $this->subscriptionUrl($subscription->id) + . '/change_plan_simulation/' . rawurlencode($planId), + [], + 'simulating subscription plan change' + ); + + $planChange = $this->parseIuguPlanChange($response); + $planChange->appliesImmediately = $paymentMethods === [PaymentMethod::CREDIT_CARD]; + + return $planChange; + } + + /** + * @inheritDoc + */ + public function listSubscriptions(Customer $customer, int $page = 1, int $limit = 100): array + { + if (empty($customer->id)) { + throw ModelAttributeValidationException::required('Customer', 'id'); + } + + if ($page < 1) { + throw ModelAttributeValidationException::invalid('Subscription', 'page', 'Subscription page must be at least 1'); + } + + if ($limit < 1 || $limit > 100) { + throw ModelAttributeValidationException::invalid('Subscription', 'limit', 'Subscription limit must be between 1 and 100'); + } + + $query = http_build_query([ + 'customer_id' => $customer->id, + 'limit' => $limit, + 'start' => ($page - 1) * $limit, + ], '', '&', PHP_QUERY_RFC3986); + + $response = $this->iuguRequest( + 'GET', + Iugu::getBaseURI() . '/subscriptions?' . $query, + [], + 'listing subscriptions' + ); + + $items = is_array($response) ? $response : ($response->items ?? []); + + return array_map(fn($item) => $this->parseIuguSubscription($item), $items); + } + + /** + * @inheritDoc + * + * A chave de idempotência passa pela `IdempotencyStore` (a Iugu não aceita o cabeçalho + * neste endpoint). + */ + public function createPlan(Plan $plan, ?string $idempotencyKey = null): Plan + { + $response = $this->iuguIdempotentRequest( + 'POST', + Iugu::getBaseURI() . '/plans', + array_merge($this->planToIuguData($plan), self::withoutIdempotencyKey($plan->gatewayOptions)), + 'creating plan', + $this->idempotencyKeyFor($idempotencyKey, $plan) + ); + + return $this->parseIuguPlan($response, $plan); + } + + /** + * @inheritDoc + */ + public function getPlan(Plan $plan): Plan + { + if (!empty($plan->id)) { + $url = Iugu::getBaseURI() . '/plans/' . rawurlencode($plan->id); + } elseif (!empty($plan->identifier)) { + $url = Iugu::getBaseURI() . '/plans/identifier/' . rawurlencode($plan->identifier); + } else { + throw ModelAttributeValidationException::required('Plan', 'id or identifier'); + } + + return $this->parseIuguPlan($this->iuguRequest('GET', $url, [], 'getting plan'), $plan); + } + + /** + * @inheritDoc + */ + public function listPlans(int $page = 1, int $limit = 100): array + { + if ($page < 1) { + throw ModelAttributeValidationException::invalid('Plan', 'page', 'Plan page must be at least 1'); + } + + if ($limit < 1 || $limit > 100) { + throw ModelAttributeValidationException::invalid('Plan', 'limit', 'Plan limit must be between 1 and 100'); + } + + $query = http_build_query([ + 'limit' => $limit, + 'start' => ($page - 1) * $limit, + ], '', '&', PHP_QUERY_RFC3986); + + $response = $this->iuguRequest( + 'GET', + Iugu::getBaseURI() . '/plans?' . $query, + [], + 'listing plans' + ); + + $items = is_array($response) ? $response : ($response->items ?? []); + + return array_map(fn($item) => $this->parseIuguPlan($item), $items); + } + + /** + * Sempre lança: a Iugu não tem desativação de plano. + * + * @param Plan $plan + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * + * @return Plan + * @throws UnsupportedOperationException + */ + public function deactivatePlan(Plan $plan, ?string $idempotencyKey = null): Plan + { + throw UnsupportedOperationException::forGateway( + $this, + Capability::PLAN_DEACTIVATION, + 'Planos da Iugu não têm flag de ativo; deixe de referenciar o plano ao criar assinaturas.' + ); + } + + /** + * Monta o payload de assinatura da Iugu a partir do model. + * + * Itens e descontos viram uma única lista de `subitems`: desconto é subitem de `price_cents` + * negativo. Com $creating falso, só os atributos preenchidos entram no payload. + * + * @param Subscription $subscription + * @param bool $creating + * + * @return array + * @throws GatewayException|ModelAttributeValidationException|UnsupportedOperationException + */ + private function subscriptionToIuguData(Subscription $subscription, bool $creating = true): array + { + $data = []; + + if ($creating) { + if (empty($subscription->customer) || empty($subscription->customer->id)) { + throw ModelAttributeValidationException::required('Subscription', 'customer'); + } + + $data['customer_id'] = $subscription->customer->id; + $data['plan_identifier'] = $subscription->planId; + } + + // o fim do trial em dias é calculado agora, no momento da requisição, e substitui os + // dias no model, para o próximo save() não os recontar + if (empty($subscription->trialEndsAt) && !empty($subscription->trialDays)) { + $subscription->trialEndsAt = Carbon::now()->addDays($subscription->trialDays); + $subscription->trialDays = null; + } + $trialEndsAt = $subscription->trialEndsAt; + + if ( + !empty($subscription->nextBillingAt) + && !empty($trialEndsAt) + && !$subscription->nextBillingAt->isSameDay($trialEndsAt) + ) { + throw ModelAttributeValidationException::invalid( + 'Subscription', + 'nextBillingAt', + 'Iugu stores the trial end and the next billing date in the same field, so ' + . 'nextBillingAt and trialEndsAt (or trialDays) cannot hold different dates.' + ); + } + + $expiresAt = $subscription->nextBillingAt ?? $trialEndsAt; + + if (!empty($expiresAt) && ($creating || !$this->isOriginalExpiresAt($subscription, $expiresAt))) { + $data['expires_at'] = $expiresAt->format('Y-m-d'); + } + + // por padrão a Iugu cobra o primeiro ciclo na criação, mesmo com `expires_at` no + // futuro; num trial a primeira cobrança só pode acontecer no fim dele + if ($creating && !empty($trialEndsAt)) { + $data['only_charge_on_due_date'] = true; + } + + // lista vazia: a Iugu usa os métodos habilitados na conta + $payableWith = $subscription->resolvedPaymentMethods(); + // a mesma recusa do guard de capabilities do model, para a chamada direta ao driver: + // a assinatura com o método exige que o gateway agende as cobranças + if (in_array(PaymentMethod::AUTOMATIC_PIX, $payableWith, true)) { + throw UnsupportedOperationException::forGateway( + $this, + Capability::MANAGES_RECURRENCE, + 'Na Iugu a recorrência de Pix Automático nasce na fatura (Invoice com automaticPix' + . ' e método pix); a assinatura não aceita paymentMethod automatic_pix.' + ); + } + if ( + !empty($payableWith) + && ($creating || !$this->isOriginalPayableWith($subscription, $payableWith)) + ) { + $data['payable_with'] = self::paymentMethodsToIuguPayableWith($payableWith); + } + + if (!empty($subscription->metadata)) { + foreach (array_keys($subscription->metadata) as $name) { + if (str_starts_with((string) $name, self::RESERVED_VARIABLE_PREFIX)) { + throw ModelAttributeValidationException::invalid( + 'Subscription', + 'metadata', + 'metadata keys with the mp_ prefix are reserved for the library state' + . ' in the Iugu custom_variables.' + ); + } + } + + $data['custom_variables'] = array_map( + fn($name, $value) => ['name' => $name, 'value' => $value], + array_keys($subscription->metadata), + array_values($subscription->metadata) + ); + } + + if (!is_null($subscription->items) || !is_null($subscription->discounts)) { + $data['subitems'] = array_merge( + array_map( + fn(SubscriptionItem $item) => $this->subscriptionItemToIuguData($item), + $subscription->items ?? [] + ), + array_map( + fn(SubscriptionDiscount $discount) => $this->subscriptionDiscountToIuguData($discount), + $subscription->discounts ?? [] + ) + ); + } + + return $data; + } + + /** + * Diz se a data informada é a mesma que veio do gateway na leitura. + * + * @param Subscription $subscription + * @param Carbon $expiresAt + * + * @return bool + */ + private function isOriginalExpiresAt(Subscription $subscription, Carbon $expiresAt): bool + { + $original = $subscription->original->expires_at ?? null; + + return !empty($original) + && (new Carbon($original))->format('Y-m-d') === $expiresAt->format('Y-m-d'); + } + + /** + * Diz se os métodos de pagamento informados são os mesmos que vieram do gateway na leitura. + * + * A comparação é feita depois da expansão, para que `all` não seja reenviado como a lista + * dos três métodos. + * + * @param Subscription $subscription + * @param PaymentMethod[] $payableWith + * + * @return bool + */ + private function isOriginalPayableWith(Subscription $subscription, array $payableWith): bool + { + $original = $subscription->original->payable_with ?? null; + + if (empty($original)) { + return false; + } + + return $this->iuguPayableWithToPaymentMethods($original) === array_values($payableWith); + } + + /** + * Monta um subitem da Iugu a partir de um item de assinatura. + * + * @param SubscriptionItem $item + * + * @return array + */ + private function subscriptionItemToIuguData(SubscriptionItem $item): array + { + if (is_null($item->amount)) { + throw ModelAttributeValidationException::required('SubscriptionItem', 'amount'); + } + + $data = [ + 'description' => $item->description, + 'price_cents' => $item->amount, + 'quantity' => $item->quantity ?? 1, + // o encoder do SDK transforma false em string vazia, então vai como inteiro + 'recurrent' => (int) $item->recurring, + ]; + + if (!empty($item->id)) { + $data['id'] = $item->id; + } + + return $data; + } + + /** + * Monta um subitem da Iugu a partir de um desconto de assinatura. + * + * Desconto percentual não tem equivalente na Iugu. `cycles` 1 vira um subitem sem + * recorrência (vale só para a próxima fatura); os demais casos vão como subitem + * recorrente, e a validade (`validUntil`, ou a calculada de `cycles`) é gravada em + * `custom_variables` depois da criação, para o comando de sincronização remover o subitem + * vencido. + * + * @param SubscriptionDiscount $discount + * + * @return array + * @throws UnsupportedOperationException|ModelAttributeValidationException + */ + private function subscriptionDiscountToIuguData(SubscriptionDiscount $discount): array + { + if (!is_null($discount->percentOff)) { + throw UnsupportedOperationException::forGateway( + $this, + Capability::PERCENT_DISCOUNT, + 'A Iugu não tem desconto percentual em assinatura; use amountOff.' + ); + } + + if (is_null($discount->amountOff)) { + throw ModelAttributeValidationException::required('SubscriptionDiscount', 'amountOff'); + } + + $data = [ + 'description' => $discount->description, + 'price_cents' => -abs($discount->amountOff), + 'quantity' => 1, + 'recurrent' => (int) ($discount->cycles !== 1), + ]; + + if (!empty($discount->id)) { + $data['id'] = $discount->id; + } + + return $data; + } + + /** + * Lista os subitens a destruir: os que a assinatura tem no gateway e que não aparecem, por + * id, na lista desejada. + * + * Só entram os subitens do tipo que está sendo substituído — item quando $replacingItems, e + * desconto, que na Iugu é subitem de `price_cents` negativo, quando $replacingDiscounts. + * Faz um GET na assinatura para descobrir o estado atual. + * + * @param string $subscriptionId + * @param array $desiredSubitems + * @param bool $replacingItems + * @param bool $replacingDiscounts + * + * @return array + * @throws GatewayException|GatewayNotAvailableException + */ + private function iuguSubitemsToDestroy( + string $subscriptionId, + array $desiredSubitems, + bool $replacingItems, + bool $replacingDiscounts + ): array { + $current = $this->iuguRequest( + 'GET', + $this->subscriptionUrl($subscriptionId), + [], + 'getting subscription items' + ); + + $keptIds = array_map('strval', array_filter(array_column($desiredSubitems, 'id'))); + + $toDestroy = []; + foreach ((array) ($current->subitems ?? []) as $subitem) { + $subitem = (object) $subitem; + $id = $subitem->id ?? null; + + if (empty($id) || in_array((string) $id, $keptIds, true)) { + continue; + } + + $replacing = ($subitem->price_cents ?? 0) < 0 ? $replacingDiscounts : $replacingItems; + + if ($replacing) { + $toDestroy[] = ['id' => $id, '_destroy' => true]; + } + } + + return $toDestroy; + } + + /** + * Converte a assinatura da Iugu numa assinatura do MultiPayment. + * + * Subitem de `price_cents` negativo vira desconto, não item. + * + * @param mixed $iuguSubscription + * @param Subscription|null $subscription + * + * @return Subscription + */ + private function parseIuguSubscription($iuguSubscription, ?Subscription $subscription = null): Subscription + { + $iuguSubscription = (object) $iuguSubscription; + $subscription = $subscription ?? new Subscription(); + + $subscription->id = $iuguSubscription->id ?? $subscription->id; + if (isset($iuguSubscription->recent_invoices)) { + $subscription->latestInvoice = $this->parseIuguRecentInvoice($iuguSubscription); + } + $subscription->status = $this->iuguToMultiPaymentSubscriptionStatus($iuguSubscription) + ?? $subscription->status; + $subscription->planId = $iuguSubscription->plan_identifier ?? $subscription->planId; + $subscription->amount = $iuguSubscription->price_cents ?? $subscription->amount; + + if (!empty($iuguSubscription->customer_id)) { + // cliente de outro id não é o mesmo cliente: manter os atributos antigos produziria + // um Customer com id de um e documento de outro + if ( + is_null($subscription->customer) + || $subscription->customer->id !== $iuguSubscription->customer_id + ) { + $subscription->customer = new Customer(); + } + + $subscription->customer->id = $iuguSubscription->customer_id; + $subscription->customer->name = $iuguSubscription->customer_name + ?? $subscription->customer->name; + $subscription->customer->email = $iuguSubscription->customer_email + ?? $subscription->customer->email; + } + + if (!empty($iuguSubscription->expires_at)) { + $subscription->nextBillingAt = new Carbon($iuguSubscription->expires_at); + + if (!empty($iuguSubscription->in_trial)) { + $subscription->trialEndsAt = $subscription->nextBillingAt->copy(); + } + } + + if (!empty($iuguSubscription->created_at)) { + $subscription->createdAt = new Carbon($iuguSubscription->created_at); + } + + if (isset($iuguSubscription->subitems)) { + $subscription->items = []; + $subscription->discounts = []; + + foreach ((array) $iuguSubscription->subitems as $iuguSubitem) { + $iuguSubitem = (object) $iuguSubitem; + + if (($iuguSubitem->price_cents ?? 0) < 0) { + $discount = $this->parseIuguSubscriptionDiscount($iuguSubitem); + $discount->validUntil = $this->iuguDiscountUntil($iuguSubscription, $discount->id); + $subscription->discounts[] = $discount; + } else { + $subscription->items[] = $this->parseIuguSubscriptionItem($iuguSubitem); + } + } + } + + if (!empty($iuguSubscription->payable_with)) { + $subscription->availablePaymentMethods = $this->iuguPayableWithToPaymentMethods( + $iuguSubscription->payable_with + ); + // a Iugu não diz com qual método a assinatura é cobrada: só há um quando ela + // aceita um único método + $subscription->paymentMethod = count($subscription->availablePaymentMethods) === 1 + ? $subscription->availablePaymentMethods[0] + : null; + } + + // lista vazia também conta: é o que a Iugu devolve depois de remover a última variável; + // as variáveis mp_ são estado da lib e viram os campos tipados, fora de metadata + if (isset($iuguSubscription->custom_variables)) { + $subscription->metadata = array_filter( + $this->iuguCustomVariables($iuguSubscription), + static fn ($name) => !str_starts_with((string) $name, self::RESERVED_VARIABLE_PREFIX), + ARRAY_FILTER_USE_KEY + ); + $subscription->canceledAt = $this->iuguCanceledAt($iuguSubscription); + // `0` conta como sem agendamento, a mesma leitura do comando de sincronização + $flag = $this->iuguCustomVariable($iuguSubscription, self::CANCEL_AT_PERIOD_END_VARIABLE); + $subscription->cancelAtPeriodEnd = !is_null($flag) && $flag !== '0'; + } + + $subscription->gateway = 'iugu'; + $subscription->original = $iuguSubscription; + + return $subscription; + } + + /** + * Lê `custom_variables` da assinatura como um mapa nome para valor. + * + * @param object $iuguSubscription + * + * @return array + */ + private function iuguCustomVariables(object $iuguSubscription): array + { + $variables = []; + + foreach ((array) ($iuguSubscription->custom_variables ?? []) as $variable) { + $variable = (object) $variable; + if (isset($variable->name)) { + $variables[$variable->name] = $variable->value ?? null; + } + } + + return $variables; + } + + /** + * Data do cancelamento gravada pela lib em `custom_variables` (`mp_canceled_at`). Nulo + * quando a marca não existe ou não é uma data legível; neste último caso registra um aviso + * no log e a marca é tratada como ausente. + * + * @param object $iuguSubscription + * + * @return Carbon|null + */ + private function iuguCanceledAt(object $iuguSubscription): ?Carbon + { + $value = $this->iuguCustomVariable($iuguSubscription, self::CANCELED_AT_VARIABLE); + + if (is_null($value)) { + return null; + } + + try { + return new Carbon($value); + } catch (\Throwable) { + LogHelper::warning( + 'Marca de cancelamento [' . self::CANCELED_AT_VARIABLE . "] ilegível [{$value}] na assinatura [" + . ($iuguSubscription->id ?? '?') . '] da Iugu, tratada como ausente', + ['subscription' => $iuguSubscription->id ?? null, 'value' => $value, 'gateway' => 'iugu'] + ); + + return null; + } + } + + /** + * Valor de uma variável de `custom_variables` da assinatura; nulo quando ausente ou vazia. + * + * @param object $iuguSubscription + * @param string $name + * + * @return string|null + */ + private function iuguCustomVariable(object $iuguSubscription, string $name): ?string + { + $value = $this->iuguCustomVariables($iuguSubscription)[$name] ?? null; + + return is_scalar($value) && (string) $value !== '' ? (string) $value : null; + } + + /** + * Nome da variável de validade de um subitem de desconto (`mp_discount__until`). + * + * @param string $subitemId + * + * @return string + */ + private static function discountUntilVariable(string $subitemId): string + { + return self::DISCOUNT_UNTIL_PREFIX . $subitemId . self::DISCOUNT_UNTIL_SUFFIX; + } + + /** + * Id do subitem de desconto de uma variável `mp_discount__until`; nulo quando + * o nome não segue o padrão. + * + * @param string $name + * + * @return string|null + */ + private static function discountSubitemIdFromVariable(string $name): ?string + { + if ( + !str_starts_with($name, self::DISCOUNT_UNTIL_PREFIX) + || !str_ends_with($name, self::DISCOUNT_UNTIL_SUFFIX) + ) { + return null; + } + + $id = substr( + $name, + strlen(self::DISCOUNT_UNTIL_PREFIX), + -strlen(self::DISCOUNT_UNTIL_SUFFIX) + ); + + return $id === '' ? null : $id; + } + + /** + * Validade gravada pela lib para um subitem de desconto + * (`mp_discount__until`). Nulo quando a variável não existe ou não é uma data + * legível; neste último caso registra um aviso no log e a variável é tratada como ausente. + * + * @param object $iuguSubscription + * @param string|null $subitemId + * + * @return Carbon|null + */ + private function iuguDiscountUntil(object $iuguSubscription, ?string $subitemId): ?Carbon + { + if (empty($subitemId)) { + return null; + } + + $name = self::discountUntilVariable($subitemId); + $value = $this->iuguCustomVariable($iuguSubscription, $name); + if (is_null($value)) { + return null; + } + + try { + return new Carbon($value); + } catch (\Throwable) { + LogHelper::warning( + "Validade de desconto [{$name}] ilegível [{$value}] na assinatura [" + . ($iuguSubscription->id ?? '?') . '] da Iugu, tratada como ausente', + ['subscription' => $iuguSubscription->id ?? null, 'value' => $value, 'gateway' => 'iugu'] + ); + + return null; + } + } + + /** + * Grava em `custom_variables` a validade dos descontos que acabaram de ser escritos + * (`mp_discount__until`) e remove a variável de desconto que saiu da lista, + * num único `PUT` (chave derivada `{chave}:discounts`). Sem mudança a fazer, nenhuma + * requisição sai. O model devolvido traz `validUntil` aplicado em cada desconto. + * + * @param SubscriptionDiscount[] $requestedDiscounts descontos como o chamador os informou + * @param Subscription $parsed model já preenchido com a resposta da escrita + * @param bool $creating + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * + * @return Subscription + * @throws GatewayException|GatewayNotAvailableException + */ + private function applyIuguDiscountValidities( + array $requestedDiscounts, + Subscription $parsed, + bool $creating, + ?string $idempotencyKey + ): Subscription { + $changes = $this->iuguDiscountVariableChanges($requestedDiscounts, $parsed, $creating); + if ($changes === []) { + return $parsed; + } + + $response = $this->iuguIdempotentRequest( + 'PUT', + $this->subscriptionUrl($parsed->id), + ['custom_variables' => $changes], + 'writing the subscription discount validity', + self::derivedIdempotencyKey($idempotencyKey, 'discounts') + ); + + return $this->parseIuguSubscription($response, $parsed); + } + + /** + * Mudanças de `custom_variables` que deixam as validades de desconto iguais às pedidas: + * uma escrita por desconto com validade nova ou diferente da gravada, e um `_destroy` por + * variável de desconto que não corresponde mais a um desconto com validade. O desconto + * pedido é casado com o subitem da resposta pelo `id`; os sem `id` casam pela descrição e + * pelo valor e, sem par assim, na ordem em que foram enviados. + * + * @param SubscriptionDiscount[] $requestedDiscounts + * @param Subscription $parsed + * @param bool $creating + * + * @return array + * @throws GatewayException|GatewayNotAvailableException + */ + private function iuguDiscountVariableChanges(array $requestedDiscounts, Subscription $parsed, bool $creating): array + { + $existing = $this->iuguCustomVariables((object) ($parsed->original ?? new \stdClass())); + + $requestedIds = array_map('strval', array_filter(array_column($requestedDiscounts, 'id'))); + $byId = []; + $unmatched = []; + foreach ($parsed->discounts ?? [] as $parsedDiscount) { + if (!empty($parsedDiscount->id) && in_array((string) $parsedDiscount->id, $requestedIds, true)) { + $byId[(string) $parsedDiscount->id] = $parsedDiscount; + } else { + $unmatched[] = $parsedDiscount; + } + } + + $planCycle = null; + $planCycleResolver = function () use (&$planCycle, $parsed): array { + return $planCycle ??= $this->iuguPlanCycle($parsed->planId); + }; + + $desired = []; + foreach ($requestedDiscounts as $requested) { + if (!$requested instanceof SubscriptionDiscount) { + continue; + } + + $target = !empty($requested->id) + ? ($byId[(string) $requested->id] ?? null) + : $this->shiftMatchingDiscount($unmatched, $requested); + if (is_null($target) || empty($target->id)) { + continue; + } + + $until = $this->iuguDiscountValidUntil($requested, $parsed, $creating, $planCycleResolver); + if (is_null($until)) { + continue; + } + + $desired[self::discountUntilVariable((string) $target->id)] = $until->format('Y-m-d'); + $target->validUntil = $until; + } + + $changes = []; + foreach ($desired as $name => $value) { + if (($existing[$name] ?? null) !== $value) { + $changes[] = ['name' => $name, 'value' => $value]; + } + } + foreach (array_keys($existing) as $name) { + if ( + !is_null(self::discountSubitemIdFromVariable((string) $name)) + && !array_key_exists($name, $desired) + ) { + $changes[] = ['name' => (string) $name, '_destroy' => true]; + } + } + + return $changes; + } + + /** + * Tira da lista o primeiro desconto da resposta com a mesma descrição e o mesmo valor do + * pedido; sem um par assim, o primeiro da lista. A ordem dos subitens na resposta da Iugu + * não é garantida, então a igualdade de conteúdo vem antes da posição. + * + * @param SubscriptionDiscount[] $unmatched descontos da resposta ainda sem par; o escolhido sai da lista + * @param SubscriptionDiscount $requested + * + * @return SubscriptionDiscount|null + */ + private function shiftMatchingDiscount(array &$unmatched, SubscriptionDiscount $requested): ?SubscriptionDiscount + { + foreach ($unmatched as $index => $candidate) { + if ( + $candidate->description === $requested->description + && $candidate->amountOff === abs((int) $requested->amountOff) + ) { + unset($unmatched[$index]); + $unmatched = array_values($unmatched); + + return $candidate; + } + } + + return array_shift($unmatched); + } + + /** + * Data até a qual um desconto pedido vale: `validUntil` quando informado; com `cycles` + * acima de 1, a data da fatura de número `cycles`, com um intervalo do plano entre + * faturas. A contagem parte da próxima cobrança lida da resposta: numa criação sem trial + * a primeira fatura já foi cobrada e a segunda sai na próxima cobrança, então faltam + * `cycles - 2` intervalos; com trial, e no update, a próxima cobrança é a primeira fatura + * coberta e faltam `cycles - 1`. Nulo quando o desconto não tem prazo (`cycles` 1 vale só + * para a próxima fatura e é o próprio subitem sem recorrência). + * + * @param SubscriptionDiscount $discount + * @param Subscription $parsed + * @param bool $creating + * @param callable $planCycle devolve `[PlanInterval, int]` do plano, lido sob demanda + * + * @return Carbon|null + */ + private function iuguDiscountValidUntil( + SubscriptionDiscount $discount, + Subscription $parsed, + bool $creating, + callable $planCycle + ): ?Carbon { + if (!empty($discount->validUntil)) { + return $discount->validUntil->copy(); + } + + if (is_null($discount->cycles) || $discount->cycles <= 1) { + return null; + } + + [$interval, $intervalCount] = $planCycle(); + + if ($creating && !empty($parsed->trialEndsAt)) { + $base = $parsed->trialEndsAt; + $remaining = $discount->cycles - 1; + } elseif ($creating && !empty($parsed->nextBillingAt)) { + $base = $parsed->nextBillingAt; + $remaining = $discount->cycles - 2; + } elseif (!$creating && !empty($parsed->nextBillingAt)) { + $base = $parsed->nextBillingAt; + $remaining = $discount->cycles - 1; + } else { + $base = Carbon::now(); + $remaining = $discount->cycles - 1; + } + + return self::addPlanCycles($base, $interval, $intervalCount, max(0, $remaining)); + } + + /** + * Intervalo de cobrança do plano, para converter `cycles` em data. Sem plano legível + * (identificador vazio, plano removido ou sem intervalo), assume mensal. + * + * @param string|null $planId + * + * @return array{0: PlanInterval, 1: int} + * @throws GatewayException|GatewayNotAvailableException + */ + private function iuguPlanCycle(?string $planId): array + { + if (empty($planId)) { + return [PlanInterval::MONTH, 1]; + } + + $plan = new Plan(); + $plan->identifier = $planId; + + try { + $plan = $this->getPlan($plan); + } catch (NotFoundException) { + return [PlanInterval::MONTH, 1]; + } + + return [$plan->interval ?? PlanInterval::MONTH, $plan->intervalCount ?? 1]; + } + + /** + * Soma ciclos do plano a uma data. + * + * @param Carbon $date + * @param PlanInterval $interval + * @param int $intervalCount + * @param int $cycles + * + * @return Carbon + */ + private static function addPlanCycles(Carbon $date, PlanInterval $interval, int $intervalCount, int $cycles): Carbon + { + $units = $intervalCount * $cycles; + + return match ($interval) { + PlanInterval::DAY => $date->copy()->addDays($units), + PlanInterval::WEEK => $date->copy()->addWeeks($units), + PlanInterval::MONTH => $date->copy()->addMonths($units), + PlanInterval::YEAR => $date->copy()->addYears($units), + }; + } + + /** + * Converte um subitem de valor não negativo da Iugu num item de assinatura. + * + * @param object $iuguSubitem + * + * @return SubscriptionItem + */ + private function parseIuguSubscriptionItem(object $iuguSubitem): SubscriptionItem + { + $item = new SubscriptionItem(); + $item->id = $iuguSubitem->id ?? null; + $item->description = $iuguSubitem->description ?? null; + $item->amount = $iuguSubitem->price_cents ?? null; + $item->quantity = $iuguSubitem->quantity ?? null; + $item->recurring = (bool) ($iuguSubitem->recurrent ?? false); + + return $item; + } + + /** + * Converte um subitem de valor negativo da Iugu num desconto de assinatura. + * + * @param object $iuguSubitem + * + * @return SubscriptionDiscount + */ + private function parseIuguSubscriptionDiscount(object $iuguSubitem): SubscriptionDiscount + { + $discount = new SubscriptionDiscount(); + $discount->id = $iuguSubitem->id ?? null; + $discount->description = $iuguSubitem->description ?? null; + $discount->amountOff = abs($iuguSubitem->price_cents) * (int) ($iuguSubitem->quantity ?? 1); + $discount->cycles = empty($iuguSubitem->recurrent) ? 1 : null; + + return $discount; + } + + /** + * Converte as flags de estado da assinatura da Iugu no status do MultiPayment. + * + * A Iugu descreve a assinatura por flags, sem campo de status; a regra, na ordem: + * `suspended` com a marca `mp_canceled_at` legível em `custom_variables` é `CANCELED`; + * `suspended` sem a marca é `SUSPENDED`; `in_trial` é `TRIALING`; `expires_at` no passado com alguma + * fatura de `recent_invoices` em aberto é `PAST_DUE` (a Iugu não tem inadimplência: a + * assinatura segue `active` com a data vencida); `active` é `ACTIVE`; sem `active`, + * `expires_at` no passado é `EXPIRED` (o ciclo terminou sem renovação e sem fatura a + * receber) e `expires_at` futuro ou ausente é `PENDING` (criada e ainda não ativada). + * Resposta sem a flag `active` devolve nulo e o status anterior do model é mantido. + * + * @param object $iuguSubscription + * + * @return SubscriptionStatus|null + */ + private function iuguToMultiPaymentSubscriptionStatus(object $iuguSubscription): ?SubscriptionStatus + { + if (!empty($iuguSubscription->suspended)) { + return is_null($this->iuguCanceledAt($iuguSubscription)) + ? SubscriptionStatus::SUSPENDED + : SubscriptionStatus::CANCELED; + } + + if (!empty($iuguSubscription->in_trial)) { + return SubscriptionStatus::TRIALING; + } + + if ($this->iuguSubscriptionIsPastDue($iuguSubscription)) { + return SubscriptionStatus::PAST_DUE; + } + + if (!isset($iuguSubscription->active)) { + return null; + } + + if ($iuguSubscription->active) { + return SubscriptionStatus::ACTIVE; + } + + return $this->iuguSubscriptionExpiresAtHasPassed($iuguSubscription) + ? SubscriptionStatus::EXPIRED + : SubscriptionStatus::PENDING; + } + + /** + * Diz se a data da próxima cobrança da assinatura já passou (fim do dia de `expires_at`). + * + * @param object $iuguSubscription + * + * @return bool + */ + private function iuguSubscriptionExpiresAtHasPassed(object $iuguSubscription): bool + { + return !empty($iuguSubscription->expires_at) + && (new Carbon($iuguSubscription->expires_at))->endOfDay()->isPast(); + } + + /** + * Diz se o resumo de fatura ainda tem valor a receber: `pending`, `partially_paid` e + * `expired` (na Iugu a fatura vencida segue devida até ser paga ou cancelada). + * + * @param object $iuguInvoice + * + * @return bool + */ + private function iuguInvoiceIsOpen(object $iuguInvoice): bool + { + return in_array( + $iuguInvoice->status ?? null, + [self::STATUS_PENDING, self::STATUS_EXPIRED, self::STATUS_PARTIALLY_PAID], + true + ); + } + + /** + * Diz se a assinatura está com cobrança vencida: data da próxima cobrança no passado e + * alguma fatura ainda em aberto. + * + * Olha todas as faturas, e não só a escolhida como `latestInvoice`: uma fatura cancelada de + * vencimento posterior esconderia uma pendente anterior. + * + * @param object $iuguSubscription + * + * @return bool + */ + private function iuguSubscriptionIsPastDue(object $iuguSubscription): bool + { + if (!$this->iuguSubscriptionExpiresAtHasPassed($iuguSubscription)) { + return false; + } + + foreach ($this->iuguRecentInvoices($iuguSubscription) as $entrada) { + if ($this->iuguInvoiceIsOpen($entrada)) { + return true; + } + } + + return false; + } + + /** + * Normaliza `recent_invoices` numa lista de objetos. + * + * @param object $iuguSubscription + * + * @return object[] + */ + private function iuguRecentInvoices(object $iuguSubscription): array + { + $recent = $iuguSubscription->recent_invoices ?? null; + + if (empty($recent) || !is_array($recent)) { + return []; + } + + return array_map(fn($entrada) => (object) $entrada, array_values($recent)); + } + + /** + * Escolhe a entrada mais recente de `recent_invoices`. + * + * Vence a de maior `due_date`. Entrada sem `due_date` perde para qualquer uma com data, e + * empate é resolvido pelo menor id, para que a escolha não dependa da ordem da resposta. + * + * Entrada sem `id` é ignorada, porque não daria para buscar a fatura depois. + * + * @param array $recent + * + * @return object|null + */ + private function latestIuguRecentInvoice(array $recent): ?object + { + $escolhida = null; + + foreach ($recent as $entrada) { + if (empty($entrada->id)) { + continue; + } + + if (is_null($escolhida)) { + $escolhida = $entrada; + continue; + } + + $data = $entrada->due_date ?? null; + $atual = $escolhida->due_date ?? null; + + if ($data === $atual) { + if ((string) $entrada->id < (string) $escolhida->id) { + $escolhida = $entrada; + } + + continue; + } + + if (is_null($atual) || (!is_null($data) && $data > $atual)) { + $escolhida = $entrada; + } + } + + return $escolhida; + } + + /** + * Converte a entrada mais recente de `recent_invoices` numa fatura do MultiPayment. + * + * A Iugu devolve essas faturas resumidas, sem itens nem valores em centavos, então só os + * campos presentes são preenchidos; `original` guarda o resumo cru. + * + * @param object $iuguSubscription + * + * @return Invoice|null + */ + private function parseIuguRecentInvoice(object $iuguSubscription): ?Invoice + { + $iuguInvoice = $this->latestIuguRecentInvoice( + $this->iuguRecentInvoices($iuguSubscription) + ); + + if (is_null($iuguInvoice)) { + return null; + } + + $invoice = new Invoice(); + $invoice->id = $iuguInvoice->id; + $invoice->status = isset($iuguInvoice->status) + ? self::iuguStatusToMultiPayment($iuguInvoice->status) + : null; + + $invoice->dueDate = !empty($iuguInvoice->due_date) + ? new Carbon($iuguInvoice->due_date) + : null; + $invoice->url = $iuguInvoice->secure_url ?? null; + $invoice->gateway = 'iugu'; + $invoice->originType = InvoiceOriginType::INVOICE; + $invoice->original = $iuguInvoice; + + return $invoice; + } + + /** + * Converte a simulação de troca de plano da Iugu no model do MultiPayment. + * + * @param mixed $response + * + * @return SubscriptionPlanChange + */ + private function parseIuguPlanChange($response): SubscriptionPlanChange + { + $response = (object) $response; + $planChange = new SubscriptionPlanChange(); + + // cost e total_cents podem vir formatados ("R$ 300,00"), então só numérico é aceito + foreach (['cost', 'price_cents', 'total_cents', 'cost_cents'] as $field) { + if (isset($response->{$field}) && is_numeric($response->{$field})) { + $planChange->amount = (int) $response->{$field}; + break; + } + } + + foreach (['subitems', 'items'] as $field) { + if (!empty($response->{$field}) && is_array($response->{$field})) { + $planChange->items = array_map(function ($line) { + $line = (object) $line; + $item = new InvoiceItem(); + $item->description = $line->description ?? null; + $item->price = $line->price_cents ?? null; + $item->quantity = $line->quantity ?? null; + + return $item; + }, $response->{$field}); + break; + } + } + + if (empty($planChange->items) && !is_null($planChange->amount)) { + $planChange->items = $this->synthesizeIuguPlanChangeItems($planChange->amount, $response); + } + + if (!empty($response->expires_at)) { + $planChange->effectiveAt = new Carbon($response->expires_at); + } + + $planChange->gateway = 'iugu'; + $planChange->original = $response; + + return $planChange; + } + + /** + * Monta as linhas da simulação de troca de plano a partir dos totais, porque a Iugu não + * devolve linhas em `change_plan_simulation`: uma de cobrança do plano novo e, quando + * `discount` é maior que zero, uma negativa de crédito do plano antigo. `cost` é lido como + * o valor líquido da troca, então a linha do plano novo é `cost` mais `discount` e a soma + * das linhas é igual a `cost`. + * + * @param int $amount valor líquido da troca (`cost`) + * @param object $response + * + * @return InvoiceItem[] + */ + private function synthesizeIuguPlanChangeItems(int $amount, object $response): array + { + $discount = isset($response->discount) && is_numeric($response->discount) + ? (int) $response->discount + : 0; + $newPlan = isset($response->new_plan) && is_scalar($response->new_plan) ? (string) $response->new_plan : null; + $oldPlan = isset($response->old_plan) && is_scalar($response->old_plan) ? (string) $response->old_plan : null; + + $charge = new InvoiceItem(); + $charge->description = is_null($newPlan) ? 'Plano novo' : "Plano {$newPlan}"; + $charge->price = $amount + max($discount, 0); + $charge->quantity = 1; + + $items = [$charge]; + + if ($discount > 0) { + $credit = new InvoiceItem(); + $credit->description = is_null($oldPlan) ? 'Crédito do plano anterior' : "Crédito do plano {$oldPlan}"; + $credit->price = -$discount; + $credit->quantity = 1; + $items[] = $credit; + } + + return $items; + } + + /** + * Monta o payload de plano da Iugu a partir do model. + * + * @param Plan $plan + * + * @return array + * @throws GatewayException + */ + private function planToIuguData(Plan $plan): array + { + $data = array_merge( + [ + 'name' => $plan->name, + 'identifier' => $plan->identifier ?? $plan->name, + ], + $this->intervalToIuguData($plan->interval, $plan->intervalCount ?? 1), + ['value_cents' => $plan->amount] + ); + + if (!empty($plan->currency)) { + $data['currency'] = $plan->currency; + } + + return $data; + } + + /** + * Converte o intervalo genérico no par `interval` e `interval_type` da Iugu. + * + * A Iugu só tem `weeks` e `months`, então o intervalo anual é enviado como múltiplo de 12 + * meses; o diário, o intervalo ausente e a contagem fora da faixa da Iugu lançam + * `ModelAttributeValidationException` antes da requisição. A leitura inversa fica em + * `iuguIntervalToMultiPayment()`. + * + * @param PlanInterval|null $interval + * @param int $intervalCount + * + * @return array{interval: int, interval_type: string} + * @throws ModelAttributeValidationException + */ + private function intervalToIuguData(?PlanInterval $interval, int $intervalCount): array + { + $data = match ($interval) { + PlanInterval::WEEK => ['interval' => $intervalCount, 'interval_type' => 'weeks'], + PlanInterval::MONTH => ['interval' => $intervalCount, 'interval_type' => 'months'], + PlanInterval::YEAR => ['interval' => 12 * $intervalCount, 'interval_type' => 'months'], + default => throw ModelAttributeValidationException::invalid( + 'Plan', + 'interval', + 'Iugu driver does not support the `' . ($interval?->value ?? 'null') . '` plan interval; ' + . 'use week, month or year (sent as 12 months).' + ), + }; + + // a Iugu aceita interval de 1 a 599; a tradução de ano pode estourar o teto + if ($data['interval'] < self::PLAN_INTERVAL_MIN || $data['interval'] > self::PLAN_INTERVAL_MAX) { + throw ModelAttributeValidationException::invalid( + 'Plan', + 'intervalCount', + "Iugu accepts a plan interval from " . self::PLAN_INTERVAL_MIN . ' to ' + . self::PLAN_INTERVAL_MAX . " {$data['interval_type']}, {$data['interval']} given." + ); + } + + return $data; + } + + /** + * Converte o par `interval_type` e `interval` da Iugu no intervalo genérico e na contagem. + * + * Heurística de leitura: como `year` vai para a Iugu como múltiplo de 12 meses, todo plano + * em `months` cujo `interval` é múltiplo de 12 volta como `year` com `intervalCount` igual + * a `interval / 12`. Um plano criado direto na Iugu com 24 meses é lido como 2 anos; quem + * precisar do valor cru lê `original`. + * + * @param string|null $intervalType + * @param int|null $interval + * + * @return array{0: PlanInterval|null, 1: int|null} + */ + private function iuguIntervalToMultiPayment(?string $intervalType, ?int $interval): array + { + if ($intervalType === 'months' && $interval > 0 && $interval % 12 === 0) { + return [PlanInterval::YEAR, intdiv($interval, 12)]; + } + + $genericInterval = match ($intervalType) { + 'weeks' => PlanInterval::WEEK, + 'months' => PlanInterval::MONTH, + default => null, + }; + + return [$genericInterval, $interval]; + } + + /** + * Converte o plano da Iugu num plano do MultiPayment. + * + * @param mixed $iuguPlan + * @param Plan|null $plan + * + * @return Plan + */ + private function parseIuguPlan($iuguPlan, ?Plan $plan = null): Plan + { + $iuguPlan = (object) $iuguPlan; + $plan = $plan ?? new Plan(); + + $plan->id = $iuguPlan->id ?? $plan->id; + $plan->identifier = $iuguPlan->identifier ?? $plan->identifier; + $plan->name = $iuguPlan->name ?? $plan->name; + [$interval, $intervalCount] = $this->iuguIntervalToMultiPayment( + $iuguPlan->interval_type ?? null, + isset($iuguPlan->interval) ? (int) $iuguPlan->interval : null + ); + $plan->interval = $interval ?? $plan->interval; + $plan->intervalCount = $intervalCount ?? $plan->intervalCount; + + // o create recebe value_cents, mas a resposta traz os valores em prices[], um por moeda + if (isset($iuguPlan->value_cents)) { + $plan->amount = $iuguPlan->value_cents; + } elseif (!empty($iuguPlan->prices)) { + $price = (object) ((array) $iuguPlan->prices)[0]; + $plan->amount = $price->value_cents ?? $plan->amount; + $plan->currency = $price->currency ?? $plan->currency; + } + + $plan->gateway = 'iugu'; + $plan->original = $iuguPlan; + + return $plan; + } + + /** + * Converte o `payable_with` da Iugu na lista de métodos de pagamento do MultiPayment. + * + * O valor `all` expande para os três métodos selecionáveis; valor desconhecido é ignorado. + * + * @param mixed $payableWith + * + * @return PaymentMethod[] + */ + private function iuguPayableWithToPaymentMethods($payableWith): array + { + $methods = []; + foreach ((array) $payableWith as $iuguMethod) { + if ($iuguMethod === 'all') { + return PaymentMethod::selectable(); + } + + $method = $this->iuguToMultiPaymentPaymentMethod($iuguMethod); + + if (!is_null($method)) { + $methods[] = $method; + } + } + + return $methods; + } + + /** + * Monta a url de uma assinatura na Iugu. + * + * @param string $id + * + * @return string + */ + private function subscriptionUrl(string $id): string + { + return Iugu::getBaseURI() . '/subscriptions/' . rawurlencode($id); + } } diff --git a/src/Gateways/Stripe/DeclineCodes.php b/src/Gateways/Stripe/DeclineCodes.php new file mode 100644 index 0000000..fcc89be --- /dev/null +++ b/src/Gateways/Stripe/DeclineCodes.php @@ -0,0 +1,113 @@ + */ + private const MAP = [ + 'insufficient_funds' => DeclineCode::INSUFFICIENT_FUNDS, + 'card_velocity_exceeded' => DeclineCode::INSUFFICIENT_FUNDS, + 'withdrawal_count_limit_exceeded' => DeclineCode::INSUFFICIENT_FUNDS, + + 'expired_card' => DeclineCode::EXPIRED_CARD, + + 'incorrect_cvc' => DeclineCode::INCORRECT_CVC, + 'invalid_cvc' => DeclineCode::INCORRECT_CVC, + + 'incorrect_number' => DeclineCode::INCORRECT_NUMBER, + 'invalid_number' => DeclineCode::INCORRECT_NUMBER, + + 'invalid_expiry_month' => DeclineCode::INVALID_CARD, + 'invalid_expiry_year' => DeclineCode::INVALID_CARD, + 'invalid_account' => DeclineCode::INVALID_CARD, + 'new_account_information_available' => DeclineCode::INVALID_CARD, + 'incorrect_address' => DeclineCode::INVALID_CARD, + 'incorrect_zip' => DeclineCode::INVALID_CARD, + + 'lost_card' => DeclineCode::LOST_OR_STOLEN, + 'stolen_card' => DeclineCode::LOST_OR_STOLEN, + 'pickup_card' => DeclineCode::LOST_OR_STOLEN, + 'restricted_card' => DeclineCode::LOST_OR_STOLEN, + + 'fraudulent' => DeclineCode::FRAUD_SUSPECTED, + 'merchant_blacklist' => DeclineCode::FRAUD_SUSPECTED, + + 'authentication_required' => DeclineCode::AUTHENTICATION_REQUIRED, + 'authentication_not_handled' => DeclineCode::AUTHENTICATION_REQUIRED, + 'mobile_device_authentication_required' => DeclineCode::AUTHENTICATION_REQUIRED, + 'setup_intent_authentication_failure' => DeclineCode::AUTHENTICATION_REQUIRED, + 'payment_intent_authentication_failure' => DeclineCode::AUTHENTICATION_REQUIRED, + + 'card_not_supported' => DeclineCode::BRAND_NOT_SUPPORTED, + 'currency_not_supported' => DeclineCode::BRAND_NOT_SUPPORTED, + + 'do_not_honor' => DeclineCode::DO_NOT_HONOR, + 'do_not_try_again' => DeclineCode::DO_NOT_HONOR, + 'call_issuer' => DeclineCode::DO_NOT_HONOR, + 'no_action_taken' => DeclineCode::DO_NOT_HONOR, + 'not_permitted' => DeclineCode::DO_NOT_HONOR, + 'revocation_of_all_authorizations' => DeclineCode::DO_NOT_HONOR, + 'revocation_of_authorization' => DeclineCode::DO_NOT_HONOR, + 'security_violation' => DeclineCode::DO_NOT_HONOR, + 'service_not_allowed' => DeclineCode::DO_NOT_HONOR, + 'stop_payment_order' => DeclineCode::DO_NOT_HONOR, + 'transaction_not_allowed' => DeclineCode::DO_NOT_HONOR, + + 'processing_error' => DeclineCode::TRY_AGAIN, + 'issuer_not_available' => DeclineCode::TRY_AGAIN, + 'reenter_transaction' => DeclineCode::TRY_AGAIN, + 'try_again_later' => DeclineCode::TRY_AGAIN, + 'approve_with_id' => DeclineCode::TRY_AGAIN, + + 'generic_decline' => DeclineCode::GENERIC, + 'card_declined' => DeclineCode::GENERIC, + 'duplicate_transaction' => DeclineCode::GENERIC, + 'invalid_amount' => DeclineCode::GENERIC, + 'testmode_decline' => DeclineCode::GENERIC, + ]; + + /** + * Traduz o código da Stripe para o vocabulário do pacote. Nulo quando o código não está + * na tabela, para o driver preservar o original e registrar no log. + * + * @param string|null $stripeCode `decline_code` ou, na falta dele, `code` do erro + * @return DeclineCode|null + */ + public static function toDeclineCode(?string $stripeCode): ?DeclineCode + { + if ($stripeCode === null || $stripeCode === '') { + return null; + } + + return self::MAP[$stripeCode] ?? null; + } + + /** + * Lê a orientação de nova tentativa que a Stripe envia em `advice_code` junto com a + * recusa. Nulo quando não há orientação ou ela não fala de nova tentativa + * (`confirm_card_data`), caso em que vale o padrão do `DeclineCode`. + * + * @param string|null $adviceCode + * @return bool|null + */ + public static function retryableFromAdvice(?string $adviceCode): ?bool + { + return match ($adviceCode) { + 'try_again_later' => true, + 'do_not_try_again' => false, + default => null, + }; + } +} diff --git a/src/Gateways/Stripe/ProrationBehaviors.php b/src/Gateways/Stripe/ProrationBehaviors.php new file mode 100644 index 0000000..a1f5f6b --- /dev/null +++ b/src/Gateways/Stripe/ProrationBehaviors.php @@ -0,0 +1,31 @@ + 'always_invoice', + ProrationBehavior::NONE => 'none', + ProrationBehavior::CREDIT => 'create_prorations', + }; + } +} diff --git a/src/Gateways/Stripe/SubscriptionStatuses.php b/src/Gateways/Stripe/SubscriptionStatuses.php new file mode 100644 index 0000000..578b1e5 --- /dev/null +++ b/src/Gateways/Stripe/SubscriptionStatuses.php @@ -0,0 +1,55 @@ + */ + private const MAP = [ + 'incomplete' => SubscriptionStatus::PENDING, + 'incomplete_expired' => SubscriptionStatus::EXPIRED, + 'trialing' => SubscriptionStatus::TRIALING, + 'active' => SubscriptionStatus::ACTIVE, + 'past_due' => SubscriptionStatus::PAST_DUE, + 'unpaid' => SubscriptionStatus::PAST_DUE, + 'canceled' => SubscriptionStatus::CANCELED, + 'paused' => SubscriptionStatus::PAUSED, + ]; + + /** + * Status genérico de uma Subscription da Stripe, a partir de `status` e de + * `pause_collection`. `pause_collection` preenchido devolve `PAUSED` a menos que a + * assinatura já tenha terminado (`canceled`, `incomplete_expired`). Status fora do mapa + * devolve `UNKNOWN` com aviso no log. + * + * @param object $stripeSubscription `\Stripe\Subscription` ou objeto com os mesmos campos + * @return SubscriptionStatus + */ + public static function toSubscriptionStatus(object $stripeSubscription): SubscriptionStatus + { + $status = (string) ($stripeSubscription->status ?? ''); + $mapped = self::MAP[$status] ?? SubscriptionStatus::unknown($status, 'stripe'); + + if ( + $mapped !== SubscriptionStatus::UNKNOWN + && !$mapped->isEnded() + && !empty($stripeSubscription->pause_collection) + ) { + return SubscriptionStatus::PAUSED; + } + + return $mapped; + } +} diff --git a/src/Gateways/StripeGateway.php b/src/Gateways/StripeGateway.php new file mode 100644 index 0000000..926fb6c --- /dev/null +++ b/src/Gateways/StripeGateway.php @@ -0,0 +1,4607 @@ + AutomaticPix::FREQUENCY_WEEKLY, + 'month:1' => AutomaticPix::FREQUENCY_MONTHLY, + 'month:3' => AutomaticPix::FREQUENCY_QUARTERLY, + 'month:6' => AutomaticPix::FREQUENCY_SEMIANNUAL, + 'month:12' => AutomaticPix::FREQUENCY_ANNUAL, + 'year:1' => AutomaticPix::FREQUENCY_ANNUAL, + ]; + + /** Status do Mandate da Stripe que encerra a recorrência. */ + private const MANDATE_STATUS_INACTIVE = 'inactive'; + + /** Tipo de InvoicePayment cujo pagamento é um PaymentIntent. */ + private const INVOICE_PAYMENT_TYPE_PAYMENT_INTENT = 'payment_intent'; + + /** Tipo de InvoicePayment registrado quando a fatura é paga fora da Stripe (`paid_out_of_band`). */ + private const INVOICE_PAYMENT_TYPE_PAYMENT_RECORD = 'payment_record'; + + /** + * Mapa de status do objeto Refund da Stripe para os genéricos do pacote. Lista oficial em + * https://docs.stripe.com/api/refunds/object#refund_object-status; `requires_action` + * (estorno aguardando ação do cliente) lê como pendente. + */ + private const REFUND_STATUSES = [ + 'pending' => RefundStatus::PENDING, + 'requires_action' => RefundStatus::PENDING, + 'succeeded' => RefundStatus::SUCCEEDED, + 'failed' => RefundStatus::FAILED, + 'canceled' => RefundStatus::CANCELED, + ]; + + /** + * Status de dispute da Stripe que significam contestação em aberto: inquiry ou chargeback + * formal aguardando resposta ou em análise. Lista oficial dos oito status em + * https://docs.stripe.com/api/disputes/object#dispute_object-status; os demais são `won`, + * `lost`, `warning_closed` e `prevented`. + */ + private const OPEN_DISPUTE_STATUSES = [ + 'warning_needs_response', + 'warning_under_review', + 'needs_response', + 'under_review', + ]; + + /** Dispute resolvida a favor do cliente: a Stripe devolveu o valor. */ + private const LOST_DISPUTE_STATUS = 'lost'; + + /** Mapa de tipos de PaymentMethod da Stripe para os métodos genéricos do pacote. */ + private const PAYMENT_METHOD_TYPES = [ + 'card' => PaymentMethod::CREDIT_CARD, + 'pix' => PaymentMethod::PIX, + 'boleto' => PaymentMethod::BANK_SLIP, + ]; + + private StripeClient $client; + + /** + * Configura o client da Stripe. + * + * @param StripeClient|null $client + */ + public function __construct(?StripeClient $client = null) + { + $this->client = $client ?? new StripeClient([ + 'api_key' => Config::get('multi-payment.gateways.stripe.api_key'), + 'stripe_version' => self::STRIPE_API_VERSION, + ]); + } + + /** + * @inheritDoc + */ + public function capabilities(): array + { + return [ + Capability::CREDIT_CARD, + Capability::PIX, + Capability::BANK_SLIP, + Capability::CARD_SETUP_AUTHENTICATION, + Capability::PARTIAL_REFUND_CARD, + Capability::PARTIAL_REFUND_PIX, + Capability::INVOICE_DUPLICATION, + Capability::INVOICE_CANCELLATION, + Capability::IDEMPOTENCY, + Capability::IDEMPOTENCY_ALL_ENDPOINTS, + Capability::SUBSCRIPTIONS, + Capability::PLANS, + Capability::PLAN_DEACTIVATION, + Capability::CANCEL_AT_PERIOD_END, + Capability::COUPONS, + Capability::PERCENT_DISCOUNT, + Capability::PLAN_CHANGE_PRORATION, + Capability::MANAGES_RECURRENCE, + Capability::AUTOMATIC_PIX, + ]; + } + + /** + * @inheritDoc + */ + public function notYetImplemented(): array + { + return [ + Capability::MULTIPLE_PAYMENT_METHODS, + Capability::DELAYED_CAPTURE, + ]; + } + + /** + * @inheritDoc + * + * `CREDIT_CARD`: a conta brasileira só aceita crédito Visa e Mastercard, e outra bandeira + * é recusada na cobrança com `DeclineCode::BRAND_NOT_SUPPORTED`. `BANK_SLIP`: valor entre + * R$ 5,00 e R$ 49.999,99 e vencimento em até 60 dias, validados antes da requisição. + * `INVOICE_DUPLICATION`: só fatura Pix pendente de venda avulsa. `INVOICE_CANCELLATION`: a + * fatura de assinatura (`in_`) só é anulada depois de finalizada pela Stripe (rascunho é + * recusado), e o boleto pendente só depois de o voucher vencer. + * `SUBSCRIPTIONS`: a Stripe só aceita `nextBillingAt` na criação da assinatura; na troca de + * plano e na atualização a data da próxima cobrança segue o ciclo. `COUPONS`: o cupom da + * Stripe dura meses inteiros (`duration_in_months`), então `cycles` maior que 1 exige plano + * com intervalo mensal ou anual, e `validUntil` vira meses inteiros contados da aplicação, + * arredondados para cima. `AUTOMATIC_PIX`: a recorrência é o mandato da assinatura, + * agendado pelo gateway; fatura avulsa com `automaticPix` e as operações de agendamento da + * lib não se aplicam. + */ + public function restrictions(): array + { + return [ + Capability::COUPONS->value => new CapabilityRestriction( + description: 'O cupom da Stripe dura meses inteiros (duration_in_months): cycles maior que 1' + . ' exige plano com intervalo mensal ou anual, e validUntil vira meses inteiros contados' + . ' da aplicação, arredondados para cima.', + ), + Capability::CREDIT_CARD->value => new CapabilityRestriction( + description: 'Na conta brasileira só cartão de crédito Visa e Mastercard; outra bandeira é' + . ' recusada na cobrança com DeclineCode::BRAND_NOT_SUPPORTED.', + allowedBrands: ['visa', 'mastercard'], + ), + Capability::BANK_SLIP->value => new CapabilityRestriction( + description: 'A Stripe aceita boleto de R$ 5,00 a R$ 49.999,99, com vencimento de hoje a' + . ' 60 dias; fora dessas janelas a criação é recusada antes da requisição.', + ), + Capability::INVOICE_DUPLICATION->value => new CapabilityRestriction( + description: 'Só fatura Pix pendente de venda avulsa (PaymentIntent); cartão, boleto,' + . ' outro estado ou fatura de assinatura são recusados.', + allowedPaymentMethods: [PaymentMethod::PIX], + ), + Capability::INVOICE_CANCELLATION->value => new CapabilityRestriction( + description: 'A fatura de assinatura (objeto Invoice) só é anulada depois de finalizada' + . ' pela Stripe (rascunho é recusado), e o boleto pendente só depois de o voucher' + . ' vencer.', + ), + Capability::SUBSCRIPTIONS->value => new CapabilityRestriction( + description: 'nextBillingAt vale só na criação da assinatura; na troca de plano e na' + . ' atualização a Stripe não aceita uma data arbitrária de próxima cobrança.', + ), + Capability::AUTOMATIC_PIX->value => new CapabilityRestriction( + description: 'A recorrência é o mandato de uma assinatura (paymentMethod automatic_pix' + . ' na criação) e o gateway agenda as cobranças; fatura avulsa com automaticPix não' + . ' é aceita, e as operações de agendamento e de cancelamento de cobrança da lib' + . ' respondem managed_by_gateway.', + ), + ]; + } + + /** + * @inheritDoc + * + * A chave de idempotência vai no cabeçalho `Idempotency-Key` da criação. + */ + public function createCustomer(Customer $customer, ?string $idempotencyKey = null): Customer + { + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $customer); + $stripeCustomerData = $this->customerToStripeData($customer); + + if (!empty($customer->taxDocument)) { + $stripeCustomerData['tax_id_data'] = [[ + 'type' => $this->taxDocumentType($customer->taxDocument), + 'value' => $customer->taxDocument, + ]]; + } + + $stripeCustomer = $this->stripeRequest(function () use ($stripeCustomerData, $idempotencyKey) { + return $this->client->customers->create( + $this->withTaxIdsExpanded($stripeCustomerData), + self::stripeOptions($idempotencyKey) + ); + }); + + return $this->parseCustomer($stripeCustomer, $customer); + } + + /** + * @inheritDoc + * + * A chave de idempotência vai no cabeçalho `Idempotency-Key` do update; a criação de um tax + * id novo, quando o documento mudou, usa a chave derivada `{chave}:tax_id`. + * + * @throws ModelAttributeValidationException + */ + public function updateCustomer(Customer $customer, ?string $idempotencyKey = null): Customer + { + if (empty($customer->id)) { + throw ModelAttributeValidationException::required('Customer', 'id'); + } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $customer); + + $stripeCustomerData = $this->customerToStripeData($customer); + + $stripeCustomer = $this->stripeRequest(function () use ($customer, $stripeCustomerData, $idempotencyKey) { + $stripeCustomer = $this->client->customers->update( + $customer->id, + $this->withTaxIdsExpanded($stripeCustomerData), + self::stripeOptions($idempotencyKey) + ); + + if ($this->syncCustomerTaxDocument( + $stripeCustomer, + $customer->taxDocument, + self::derivedIdempotencyKey($idempotencyKey, 'tax_id') + )) { + $stripeCustomer = $this->client->customers->retrieve( + $customer->id, + ['expand' => ['tax_ids']] + ); + } + + return $stripeCustomer; + }); + + return $this->parseCustomer($stripeCustomer, $customer); + } + + /** + * @inheritDoc + */ + public function getCustomer(Customer $customer): Customer + { + $stripeCustomer = $this->stripeRequest(function () use ($customer) { + return $this->client->customers->retrieve($customer->id, ['expand' => ['tax_ids']]); + }); + + return $this->parseCustomer($stripeCustomer, $customer); + } + + /** + * @inheritDoc + * @throws ModelAttributeValidationException + */ + public function setCustomerDefaultCard(Customer $customer, string $cardId, ?string $idempotencyKey = null): Customer + { + $customer->defaultCard = new CreditCard(); + $customer->defaultCard->id = $cardId; + + return $this->updateCustomer($customer, $idempotencyKey); + } + + /** + * Garante os expands exigidos pelo parse no payload sem descartar um expand vindo de + * gatewayOptions. Sem eles a Stripe omite dados (tax ids, charge) e o parse/sync + * corromperia silenciosamente. + * + * @param array $stripeData + * @param array $expand + * @return array + */ + private function withExpand(array $stripeData, array $expand): array + { + $stripeData['expand'] = array_values(array_unique(array_merge( + $stripeData['expand'] ?? [], + $expand + ))); + + return $stripeData; + } + + /** + * Garante o expand de tax_ids no payload de customer. + * + * @param array $stripeCustomerData + * @return array + */ + private function withTaxIdsExpanded(array $stripeCustomerData): array + { + return $this->withExpand($stripeCustomerData, ['tax_ids']); + } + + /** + * Converte o model Customer do MultiPayment para o formato de dados da Stripe. + * + * O Stripe não tem campos nativos para bairro, país em texto livre e data de nascimento — + * esses valores vão para metadata (mesma solução das custom_variables da Iugu) e voltam + * de lá no parseCustomer. + * + * @param \Potelo\MultiPayment\Models\Customer $customer + * @return array + */ + private function customerToStripeData(Customer $customer): array + { + $stripeCustomerData = []; + $metadata = []; + + if (!is_null($customer->name)) { + $stripeCustomerData['name'] = $customer->name; + } + if (!is_null($customer->email)) { + $stripeCustomerData['email'] = $customer->email; + } + if (!empty($customer->phoneArea) && !empty($customer->phoneNumber)) { + $stripeCustomerData['phone'] = '+' + . ($customer->phoneCountryCode ?? self::DEFAULT_PHONE_COUNTRY_CODE) + . $customer->phoneArea + . $customer->phoneNumber; + } + if (!empty($customer->birthDate)) { + $metadata['birth_date'] = $customer->birthDate->format('Y-m-d'); + } + + if (!empty($customer->address)) { + $address = $customer->address; + $line1 = trim(($address->street ?? '') . ', ' . ($address->number ?: 'S/N'), ', '); + $stripeCustomerData['address'] = array_filter([ + 'line1' => $line1, + 'line2' => $address->complement, + 'city' => $address->city, + 'state' => $address->state, + 'postal_code' => $address->zipCode, + ], static fn ($value) => !is_null($value) && $value !== ''); + + // string vazia limpa a chave no metadata da Stripe (que faz merge por chave, + // diferente do hash address, que é substituído inteiro) — mantém a semântica + // de replace do endereço para os campos que vivem no metadata + $metadata['district'] = $address->district ?? ''; + $metadata['country'] = $address->country ?? ''; + } + + if (!empty($metadata)) { + $stripeCustomerData['metadata'] = $metadata; + } + + if (!empty($customer->defaultCard) && !empty($customer->defaultCard->id)) { + $stripeCustomerData['invoice_settings']['default_payment_method'] = $customer->defaultCard->id; + } + + foreach (self::withoutIdempotencyKey($customer->gatewayOptions) as $option => $value) { + $stripeCustomerData[$option] = $value; + } + + return $stripeCustomerData; + } + + /** + * Converte o customer da Stripe em um Customer do MultiPayment. + * + * @param \Stripe\Customer $stripeCustomer + * @param \Potelo\MultiPayment\Models\Customer|null $customer + * @return \Potelo\MultiPayment\Models\Customer + */ + private function parseCustomer(StripeCustomer $stripeCustomer, ?Customer $customer = null): Customer + { + $customer = $customer ?? new Customer(); + $metadata = !empty($stripeCustomer->metadata) ? $stripeCustomer->metadata->toArray() : []; + + $customer->id = $stripeCustomer->id; + $customer->name = $stripeCustomer->name; + $customer->email = $stripeCustomer->email; + + foreach ($stripeCustomer->tax_ids->data ?? [] as $taxId) { + if (in_array($taxId->type, ['br_cpf', 'br_cnpj'], true)) { + // a Stripe devolve o documento formatado (201.769.969-15) mesmo recebendo dígitos + $customer->taxDocument = preg_replace('/\D/', '', $taxId->value); + break; + } + } + + // desfaz a concatenação +{país}{DDD}{número} feita no customerToStripeData; + // o reset evita reter dados velhos do model quando o telefone da Stripe está + // ausente ou fora do formato gravado pelo pacote + $customer->phoneCountryCode = null; + $customer->phoneArea = null; + $customer->phoneNumber = null; + if (!empty($stripeCustomer->phone) + && preg_match('/^\+(\d{2})(\d{2})(\d{8,9})$/', $stripeCustomer->phone, $phoneParts)) { + $customer->phoneCountryCode = $phoneParts[1]; + $customer->phoneArea = $phoneParts[2]; + $customer->phoneNumber = $phoneParts[3]; + } + + $customer->birthDate = null; + if (!empty($metadata['birth_date'])) { + try { + $customer->birthDate = Carbon::createFromFormat('Y-m-d', $metadata['birth_date']); + } catch (\Exception $e) { + // metadata pode ter sido editado fora do pacote; data ilegível vira null + } + } + + if (!empty($stripeCustomer->address)) { + if (empty($customer->address)) { + $customer->address = new Address(); + } + $stripeAddress = $stripeCustomer->address; + if (!empty($stripeAddress->line1)) { + // desfaz a concatenação "rua, número" feita no customerToStripeData; + // line1 sozinho no formato de número (endereço sem rua) volta para number + $separatorPosition = strrpos($stripeAddress->line1, ', '); + if ($separatorPosition !== false) { + $customer->address->street = substr($stripeAddress->line1, 0, $separatorPosition); + $customer->address->number = substr($stripeAddress->line1, $separatorPosition + 2); + } elseif (preg_match('/^([0-9]+[a-zA-Z]*|S\/N)$/', $stripeAddress->line1)) { + $customer->address->number = $stripeAddress->line1; + } else { + $customer->address->street = $stripeAddress->line1; + } + } + $customer->address->complement = $stripeAddress->line2 ?? null; + $customer->address->city = $stripeAddress->city ?? null; + $customer->address->state = $stripeAddress->state ?? null; + $customer->address->zipCode = $stripeAddress->postal_code ?? null; + $customer->address->district = !empty($metadata['district']) ? $metadata['district'] : null; + $customer->address->country = !empty($metadata['country']) ? $metadata['country'] : null; + } + + if (!empty($stripeCustomer->invoice_settings?->default_payment_method)) { + $customer->defaultCard = new CreditCard(); + $customer->defaultCard->id = $stripeCustomer->invoice_settings->default_payment_method; + } + + $customer->gateway = 'stripe'; + $customer->createdAt = Carbon::createFromTimestamp($stripeCustomer->created); + $customer->original = $stripeCustomer; + + return $customer; + } + + /** + * Garante que o tax id brasileiro do customer na Stripe reflita o taxDocument do model — + * tax ids não são atualizáveis pelo update de customer, só criados/excluídos à parte. + * + * @param \Stripe\Customer $stripeCustomer customer com `tax_ids` expandido + * @param string|null $taxDocument + * @param string|null $idempotencyKey chave da criação do tax id novo + * @return bool true se algum tax id foi criado/excluído (o customer precisa de refetch) + * @throws ApiErrorException + */ + private function syncCustomerTaxDocument( + StripeCustomer $stripeCustomer, + ?string $taxDocument, + ?string $idempotencyKey = null + ): bool { + if (empty($taxDocument)) { + return false; + } + + $alreadyPresent = false; + $staleTaxIds = []; + foreach ($stripeCustomer->tax_ids->data ?? [] as $stripeTaxId) { + if (!in_array($stripeTaxId->type, ['br_cpf', 'br_cnpj'], true)) { + continue; + } + // comparação por dígitos: a Stripe devolve o documento formatado (201.769.969-15) + if (preg_replace('/\D/', '', $stripeTaxId->value) === preg_replace('/\D/', '', $taxDocument)) { + $alreadyPresent = true; + continue; + } + $staleTaxIds[] = $stripeTaxId->id; + } + + // cria o novo antes de excluir o antigo: se a criação falhar, o customer + // não fica sem documento na Stripe + if (!$alreadyPresent) { + $this->client->customers->createTaxId($stripeCustomer->id, [ + 'type' => $this->taxDocumentType($taxDocument), + 'value' => $taxDocument, + ], self::stripeOptions($idempotencyKey)); + } + foreach ($staleTaxIds as $staleTaxIdId) { + try { + $this->client->customers->deleteTaxId($stripeCustomer->id, $staleTaxIdId); + } catch (InvalidRequestException $e) { + // já excluído por uma tentativa anterior (a Stripe repete o update com o + // tax_ids antigo quando a chave é a mesma): o objetivo já foi atingido + if (($e->getError()?->code ?? null) !== 'resource_missing') { + throw $e; + } + } + } + + return !$alreadyPresent || !empty($staleTaxIds); + } + + /** + * Decide o tipo de tax id da Stripe pelo tamanho do documento (14 dígitos = CNPJ). + * + * @param string $taxDocument + * @return string + */ + private function taxDocumentType(string $taxDocument): string + { + return strlen(preg_replace('/\D/', '', $taxDocument)) === 14 ? 'br_cnpj' : 'br_cpf'; + } + + /** + * Executa uma chamada à API da Stripe traduzindo as exceções para o contrato do pacote. + * + * @param callable $request + * @return mixed + * @throws GatewayException|GatewayNotAvailableException|AuthenticationException + */ + private function stripeRequest(callable $request) + { + try { + return $request(); + } catch (\Exception $e) { + throw $this->translateStripeException($e); + } + } + + /** + * Traduz uma exceção do stripe-php para a hierarquia do pacote, anexando a original como + * `previous` e o status HTTP da resposta. + * + * Regras: 401 (`AuthenticationException` do SDK, inclusive chave não configurada) e 403 + * (`PermissionException`) viram `AuthenticationException`; falha de conexão + * (`ApiConnectionException`) e 5xx viram `GatewayNotAvailableException`, inclusive o 5xx + * com corpo não JSON, que o SDK lança como `UnexpectedValueException`; recusa de cartão + * (`CardException`, `type` `card_error`, em qualquer operação, inclusive o attach do cartão) + * vira `ChargingException` com `declineCode`; `rate_limit_error` vira `RateLimitException`; + * `idempotency_error` vira `IdempotencyConflictException`; `invalid_request_error` vira + * `NotFoundException` quando o `code` é `resource_missing` e `ValidationException` nos demais + * casos; o restante vira `GatewayException`. Todas trazem `type`, `code`, `decline_code` e + * `param` em `getErrors()` e o status em `httpStatus`. Exceção do próprio pacote passa + * intacta. + * + * @param \Throwable $e + * @return MultiPaymentException + */ + private function translateStripeException(\Throwable $e): MultiPaymentException + { + if ($e instanceof MultiPaymentException) { + return $e; + } + + if ($e instanceof StripeAuthenticationException || $e instanceof PermissionException) { + return AuthenticationException::invalidCredentials('stripe', $e->getMessage(), $e, $e->getHttpStatus()); + } + + if ($e instanceof ApiConnectionException) { + return new GatewayNotAvailableException($e->getMessage(), $e, $e->getHttpStatus()); + } + + // corpo que não é JSON (página HTML de proxy num 5xx): o SDK lança esta classe com o + // status HTTP em getCode(), ou sem código quando o JSON veio sem a chave `error` + if ($e instanceof StripeUnexpectedValueException) { + $httpStatus = $e->getCode() > 0 ? (int) $e->getCode() : null; + if ($httpStatus >= 500) { + return new GatewayNotAvailableException($e->getMessage(), $e, $httpStatus); + } + + return new GatewayException($e->getMessage(), null, $e, $httpStatus); + } + + if ($e instanceof ApiErrorException) { + $httpStatus = $e->getHttpStatus(); + if ($httpStatus >= 500) { + return new GatewayNotAvailableException($e->getMessage(), $e, $httpStatus); + } + + $error = $e->getError(); + $errors = array_filter([ + 'type' => $error?->type, + 'code' => $error?->code, + 'decline_code' => $error?->decline_code ?? null, + 'param' => $error?->param, + ]); + + if ($e instanceof CardException) { + return $this->cardDeclined($e); + } + + if ($e instanceof StripeRateLimitException) { + return RateLimitException::withRetryAfter( + $e->getMessage(), + $errors, + $e, + $httpStatus, + self::retryAfterFromHeaders($e->getHttpHeaders()) + ); + } + + if ($e instanceof IdempotencyException) { + return new IdempotencyConflictException($e->getMessage(), $errors, $e, $httpStatus); + } + + if ($e instanceof InvalidRequestException) { + if (($error?->code ?? null) === 'resource_missing' || $httpStatus === 404) { + return new NotFoundException($e->getMessage(), $errors, $e, $httpStatus); + } + + $field = is_string($error?->param) && $error->param !== '' ? $error->param : 'base'; + + return ValidationException::withFieldErrors( + $e->getMessage(), + [$field => [$e->getMessage()]], + $errors, + $e, + $httpStatus + ); + } + + return new GatewayException($e->getMessage(), $errors, $e, $httpStatus); + } + + return new GatewayException($e->getMessage(), null, $e); + } + + /** + * Traduz uma recusa de cartão do stripe-php para `ChargingException` a partir do erro da + * exceção, com ela em `previous` e o status HTTP da resposta. + * + * @param CardException $e + * @return ChargingException + */ + private function cardDeclined(CardException $e): ChargingException + { + return $this->declinedFromStripeError($e->getError(), $e->getMessage(), $e, $e->getHttpStatus()); + } + + /** + * Monta a `ChargingException` de uma recusa de cartão a partir do objeto de erro da Stripe + * (o de uma `CardException` ou o `last_setup_error` de um SetupIntent): o `decline_code` + * (ou, na falta dele, o `code`) vira `DeclineCode`, o `advice_code` decide `retryable` + * quando presente, e o erro bruto vai em `chargeResponse`. Código fora da tabela vira + * `DeclineCode::UNKNOWN`, com o original preservado em `gatewayCode` e registro em nível + * `info`. + * + * @param object|null $error `\Stripe\ErrorObject` ou `\Stripe\StripeObject` com `code`, `decline_code` e `advice_code` + * @param string $message + * @param \Throwable|null $previous + * @param int|null $httpStatus + * @return ChargingException + */ + private function declinedFromStripeError(?object $error, string $message, ?\Throwable $previous, ?int $httpStatus): ChargingException + { + $code = $error?->code ?? null; + $stripeDeclineCode = $error?->decline_code ?? null; + $gatewayCode = $stripeDeclineCode ?: $code; + + $declineCode = StripeDeclineCodes::toDeclineCode($gatewayCode); + if ($declineCode === null) { + $declineCode = DeclineCode::UNKNOWN; + if (!empty($gatewayCode)) { + LogHelper::info('Código de recusa da Stripe sem tradução para DeclineCode', ['gateway' => 'stripe', 'code' => $gatewayCode]); + } + } + + $exception = ChargingException::declined( + 'stripe', + $declineCode, + $gatewayCode, + $message, + $previous, + $httpStatus, + StripeDeclineCodes::retryableFromAdvice($error?->advice_code ?? null) + ); + // array em vez do ErrorObject, para o formato ser o mesmo em qualquer operação + $exception->chargeResponse = is_object($error) && method_exists($error, 'toArray') ? $error->toArray() : $error; + $exception->reason = self::chargeFailureReason($code, $stripeDeclineCode); + + return $exception; + } + + /** + * Lê o cabeçalho `Retry-After` da resposta, em segundos. Nulo quando ausente ou quando não + * é um número inteiro (a forma em data HTTP não é interpretada). O SDK entrega os + * cabeçalhos como `\Stripe\Util\CaseInsensitiveArray`, iterável; um array simples também + * é aceito. + * + * @param iterable|null $headers + * @return int|null + */ + private static function retryAfterFromHeaders(?iterable $headers): ?int + { + foreach ($headers ?? [] as $name => $value) { + if (strtolower((string) $name) !== 'retry-after') { + continue; + } + $value = is_array($value) ? reset($value) : $value; + + return is_numeric($value) ? (int) $value : null; + } + + return null; + } + + /** + * @inheritDoc + * + * A chave de idempotência vai no cabeçalho `Idempotency-Key` da criação do PaymentIntent; + * o cartão salvo antes da cobrança usa a chave derivada `{chave}:card`. + * + * @throws ChargingException|ModelAttributeValidationException|UnsupportedOperationException + */ + public function createInvoice(Invoice $invoice, ?string $idempotencyKey = null): Invoice + { + $this->assertSupportsAll($invoice->requiredCapabilities()); + // sem a recusa, a fatura seria criada em silêncio sem a recorrência pedida + if (!empty($invoice->automaticPix) || !empty($invoice->automaticPixCharge)) { + throw UnsupportedOperationException::notImplemented( + (string) $this, + Capability::AUTOMATIC_PIX, + 'Nesse gateway a recorrência de Pix Automático vive na assinatura: crie uma' + . ' Subscription com paymentMethod automatic_pix. A fatura avulsa com automaticPix' + . ' ainda não é suportada pela lib.' + ); + } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $invoice); + + $paymentMethod = $this->invoicePaymentMethod($invoice); + + return match ($paymentMethod) { + PaymentMethod::CREDIT_CARD => $this->createCreditCardInvoice($invoice, $idempotencyKey), + PaymentMethod::PIX => $this->createPixInvoice($invoice, $idempotencyKey), + PaymentMethod::BANK_SLIP => $this->createBankSlipInvoice($invoice, $idempotencyKey), + default => throw UnsupportedOperationException::forGateway($this, Capability::forPaymentMethod($paymentMethod)), + }; + } + + /** + * Resolve o único método de pagamento da fatura — no Stripe um PaymentIntent confirmado + * server-side materializa a cobrança de um método só, então a fatura multi-método da + * Iugu não tem equivalente aqui e este gateway é restrito a um método por fatura. + * + * @param \Potelo\MultiPayment\Models\Invoice $invoice + * @return PaymentMethod + * @throws ModelAttributeValidationException|UnsupportedOperationException + */ + private function invoicePaymentMethod(Invoice $invoice): PaymentMethod + { + $methods = $invoice->resolvedPaymentMethods(); + + if (count($methods) > 1) { + throw UnsupportedOperationException::forGateway( + $this, + Capability::MULTIPLE_PAYMENT_METHODS, + 'Informe exatamente um método em availablePaymentMethods.' + ); + } + + if (!empty($methods)) { + return reset($methods); + } + + throw ModelAttributeValidationException::required('Invoice', 'paymentMethod or availablePaymentMethods'); + } + + /** + * Cria e confirma um PaymentIntent de cartão (síncrono: succeeded ou recusa na hora). Um + * cartão informado por token é salvo antes por `createCreditCard()`; se o emissor exigir + * autenticação do pagador para salvá-lo, a cobrança fora de sessão não tem como atendê-la + * e a fatura não é criada: `ChargingException` com `DeclineCode::AUTHENTICATION_REQUIRED` + * e o SetupIntent em `chargeResponse`. + * + * @param \Potelo\MultiPayment\Models\Invoice $invoice + * @param string|null $idempotencyKey + * @return \Potelo\MultiPayment\Models\Invoice + * @throws ChargingException|GatewayException|ModelAttributeValidationException + */ + private function createCreditCardInvoice(Invoice $invoice, ?string $idempotencyKey): Invoice + { + if (empty($invoice->creditCard)) { + throw ModelAttributeValidationException::required('Invoice', 'creditCard'); + } + + if (empty($invoice->creditCard->id)) { + if (empty($invoice->creditCard->customer)) { + $invoice->creditCard->customer = $invoice->customer; + } + // a Stripe valida o cartão já no setup; a recusa nesse ponto é ChargingException + $invoice->creditCard = $this->createCreditCard( + $invoice->creditCard, + self::derivedIdempotencyKey($idempotencyKey, 'card') + ); + if ($invoice->creditCard->requiresAction) { + $exception = ChargingException::declined( + 'stripe', + DeclineCode::AUTHENTICATION_REQUIRED, + 'authentication_required', + 'O emissor exige autenticação do pagador para este cartão; salve-o com createCreditCard(),' + . ' conclua a autenticação com confirmCreditCardSetup() e cobre pelo id do cartão salvo.' + ); + $exception->chargeResponse = $invoice->creditCard->original?->toArray(); + + throw $exception; + } + } + + $stripePaymentIntentData = $this->invoiceToStripeData($invoice); + $stripePaymentIntentData['payment_method_types'] = ['card']; + $stripePaymentIntentData['payment_method'] = $invoice->creditCard->id; + $stripePaymentIntentData['confirm'] = true; + $stripePaymentIntentData['off_session'] = true; + $stripePaymentIntentData = $this->mergeGatewayOptions($stripePaymentIntentData, $invoice); + + $stripePaymentIntent = $this->stripeRequest(function () use ($stripePaymentIntentData, $idempotencyKey) { + return $this->client->paymentIntents->create( + $this->withExpand($stripePaymentIntentData, self::PAYMENT_INTENT_EXPAND), + self::stripeOptions($idempotencyKey) + ); + }); + + return $this->parseInvoice($stripePaymentIntent, $invoice); + } + + /** + * Cria e confirma um PaymentIntent de pix 100% server-side. A fatura volta pendente + * com o QR code em next_action; o pagamento é assíncrono (acompanhar via getInvoice). + * + * @param \Potelo\MultiPayment\Models\Invoice $invoice + * @param string|null $idempotencyKey + * @return \Potelo\MultiPayment\Models\Invoice + * @throws GatewayException|ModelAttributeValidationException + */ + private function createPixInvoice(Invoice $invoice, ?string $idempotencyKey): Invoice + { + // o pix exige CPF/CNPJ no billing_details em produção — falhar cedo evita um + // erro obscuro da API (a sandbox não valida, produção sim) + if (empty($invoice->customer) || empty($invoice->customer->taxDocument)) { + throw ModelAttributeValidationException::required('Customer', 'taxDocument'); + } + + $stripePaymentIntentData = $this->invoiceToStripeData($invoice); + $stripePaymentIntentData['payment_method_types'] = ['pix']; + $stripePaymentIntentData['payment_method_data'] = [ + 'type' => 'pix', + 'billing_details' => array_filter([ + 'name' => $invoice->customer->name, + 'email' => $invoice->customer->email, + 'tax_id' => $invoice->customer->taxDocument, + ]), + ]; + $stripePaymentIntentData['confirm'] = true; + $pixExpiresAt = $this->pixExpiresAt($invoice); + if (!empty($pixExpiresAt)) { + $stripePaymentIntentData['payment_method_options']['pix']['expires_at'] = $pixExpiresAt->getTimestamp(); + } + $stripePaymentIntentData = $this->mergeGatewayOptions($stripePaymentIntentData, $invoice); + + $stripePaymentIntent = $this->stripeRequest(function () use ($stripePaymentIntentData, $idempotencyKey) { + return $this->client->paymentIntents->create( + $this->withExpand($stripePaymentIntentData, self::PAYMENT_INTENT_EXPAND), + self::stripeOptions($idempotencyKey) + ); + }); + + return $this->parseInvoice($stripePaymentIntent, $invoice); + } + + /** + * Instante em que o QR Code do Pix expira: `pixExpiresAt` ou, na falta dele, o fim do dia + * de `dueDate` (o vencimento vale o dia inteiro, como na Iugu). Nulo quando a fatura não + * informa nenhum dos dois (a Stripe usa o padrão de 4 horas). A janela aceita pela Stripe + * (mais de 10 segundos e menos de 14 dias no futuro) é validada antes da requisição. + * + * @param \Potelo\MultiPayment\Models\Invoice $invoice + * @return Carbon|null + * @throws ModelAttributeValidationException + */ + private function pixExpiresAt(Invoice $invoice): ?Carbon + { + $attribute = !empty($invoice->pixExpiresAt) ? 'pixExpiresAt' : 'dueDate'; + $expiresAt = $invoice->pixExpiresAt ?? $invoice->dueDate?->copy()->endOfDay(); + if (empty($expiresAt)) { + return null; + } + + if ($expiresAt->lessThan(Carbon::now()->addSeconds(10)) + || $expiresAt->greaterThan(Carbon::now()->addDays(14))) { + throw ModelAttributeValidationException::invalid( + 'Invoice', + $attribute, + "{$attribute} must be more than 10 seconds and less than 14 days in the future for pix invoices on the stripe gateway" + . ($attribute === 'dueDate' ? ' (the QR Code expires at the end of the due date)' : '') + ); + } + + return $expiresAt; + } + + /** + * Cria e confirma um PaymentIntent de boleto 100% server-side. A fatura volta pendente com + * o voucher em `next_action.boleto_display_details`: `Invoice::$url` é a página hospedada, + * `bankSlip->number` é a linha digitável e `bankSlip->url` é o PDF. O pagamento é + * assíncrono (a compensação leva até um dia útil; acompanhar via `getInvoice()`). + * + * A Stripe exige CPF/CNPJ (`boleto.tax_id`), nome, e-mail e endereço completo do pagador + * (`billing_details`), valor entre R$ 5,00 e R$ 49.999,99 e vencimento + * (`expires_after_days`, derivado de `dueDate`) de hoje a 60 dias; tudo é validado antes + * da requisição. Sem `dueDate`, vale o prazo padrão da conta na Stripe (3 dias). + * + * @param \Potelo\MultiPayment\Models\Invoice $invoice + * @param string|null $idempotencyKey + * @return \Potelo\MultiPayment\Models\Invoice + * @throws GatewayException|ModelAttributeValidationException + */ + private function createBankSlipInvoice(Invoice $invoice, ?string $idempotencyKey): Invoice + { + // a sandbox aceita boleto sem os dados do pagador, mas a produção exige documento, + // nome, e-mail e endereço completo; falhar cedo evita um erro obscuro da API + if (empty($invoice->customer) || empty($invoice->customer->taxDocument)) { + throw ModelAttributeValidationException::required('Customer', 'taxDocument'); + } + foreach (['name', 'email'] as $attribute) { + if (empty($invoice->customer->{$attribute})) { + throw ModelAttributeValidationException::required('Customer', $attribute); + } + } + $address = $invoice->customer->address; + if (empty($address) || empty($address->street) || empty($address->city) + || empty($address->state) || empty($address->zipCode)) { + throw ModelAttributeValidationException::required('Customer', 'address (street, city, state and zipCode)'); + } + + $stripePaymentIntentData = $this->invoiceToStripeData($invoice); + $amount = $stripePaymentIntentData['amount']; + if ($amount < self::BOLETO_MIN_AMOUNT || $amount > self::BOLETO_MAX_AMOUNT) { + throw ModelAttributeValidationException::invalid( + 'Invoice', + 'amount', + 'amount must be between ' . self::BOLETO_MIN_AMOUNT . ' and ' . self::BOLETO_MAX_AMOUNT + . ' cents for bank slip invoices on the stripe gateway' + ); + } + + $stripePaymentIntentData['payment_method_types'] = ['boleto']; + $stripePaymentIntentData['payment_method_data'] = [ + 'type' => 'boleto', + 'boleto' => ['tax_id' => $invoice->customer->taxDocument], + 'billing_details' => array_filter([ + 'name' => $invoice->customer->name, + 'email' => $invoice->customer->email, + 'address' => array_filter([ + 'line1' => trim(($address->street ?? '') . ', ' . ($address->number ?: 'S/N'), ', '), + 'line2' => $address->complement, + 'city' => $address->city, + 'state' => $address->state, + 'postal_code' => $address->zipCode, + // a Stripe exige o código ISO de duas letras, e o boleto é só do Brasil + 'country' => 'BR', + ], static fn ($value) => !is_null($value) && $value !== ''), + ]), + ]; + $stripePaymentIntentData['confirm'] = true; + $expiresAfterDays = $this->boletoExpiresAfterDays($invoice); + if (!is_null($expiresAfterDays)) { + $stripePaymentIntentData['payment_method_options']['boleto']['expires_after_days'] = $expiresAfterDays; + } + $stripePaymentIntentData = $this->mergeGatewayOptions($stripePaymentIntentData, $invoice); + + $stripePaymentIntent = $this->stripeRequest(function () use ($stripePaymentIntentData, $idempotencyKey) { + return $this->client->paymentIntents->create( + $this->withExpand($stripePaymentIntentData, self::PAYMENT_INTENT_EXPAND), + self::stripeOptions($idempotencyKey) + ); + }); + + return $this->parseInvoice($stripePaymentIntent, $invoice); + } + + /** + * Dias corridos até o vencimento do boleto (`expires_after_days`), derivados de `dueDate`: + * zero vence hoje às 23h59 (fuso de São Paulo) e o teto da Stripe é 60. Nulo quando a + * fatura não informa `dueDate` (vale o prazo padrão da conta). Vencimento no passado ou + * além do teto é recusado antes da requisição. A contagem parte da data corrente em São + * Paulo, o fuso em que a Stripe vira o dia do boleto, e trata `dueDate` como a data de + * calendário que o consumidor informou, qualquer que seja o fuso dela. + * + * @param \Potelo\MultiPayment\Models\Invoice $invoice + * @return int|null + * @throws ModelAttributeValidationException + */ + private function boletoExpiresAfterDays(Invoice $invoice): ?int + { + if (empty($invoice->dueDate)) { + return null; + } + + $days = Carbon::now('America/Sao_Paulo')->startOfDay()->diffInDays( + Carbon::parse($invoice->dueDate->format('Y-m-d'), 'America/Sao_Paulo'), + false + ); + if ($days < 0 || $days > self::BOLETO_MAX_EXPIRES_AFTER_DAYS) { + throw ModelAttributeValidationException::invalid( + 'Invoice', + 'dueDate', + 'dueDate must be between today and ' . self::BOLETO_MAX_EXPIRES_AFTER_DAYS + . ' days in the future for bank slip invoices on the stripe gateway' + ); + } + + return (int) $days; + } + + /** + * Recupera o CPF/CNPJ dos billing_details do PaymentMethod de um PaymentIntent pix — + * o PaymentMethod pode já ter sido consumido (pix expirado), sobrando só a cópia + * embutida em last_payment_error. + * + * @param \Stripe\PaymentIntent $stripePaymentIntent com `payment_method` expandido + * @return string|null + */ + private function pixBillingTaxId(StripePaymentIntent $stripePaymentIntent): ?string + { + $stripePaymentMethod = $stripePaymentIntent->payment_method; + if (is_object($stripePaymentMethod) && !empty($stripePaymentMethod->billing_details?->tax_id)) { + return $stripePaymentMethod->billing_details->tax_id; + } + + return $stripePaymentIntent->last_payment_error?->payment_method?->billing_details?->tax_id ?? null; + } + + /** + * Opções de requisição do stripe-php com a chave de idempotência, que o SDK envia no + * cabeçalho `Idempotency-Key`. Sem chave, nenhuma opção. + * + * @param string|null $idempotencyKey + * @return array + */ + private static function stripeOptions(?string $idempotencyKey): array + { + return is_null($idempotencyKey) ? [] : ['idempotency_key' => $idempotencyKey]; + } + + /** + * Converte os campos comuns da fatura para o payload de PaymentIntent da Stripe. + * + * O PaymentIntent não tem line items: os items são serializados em metadata + * (item_N_description/price/quantity) e reconstruídos no parseInvoice. + * + * @param \Potelo\MultiPayment\Models\Invoice $invoice + * @return array + */ + private function invoiceToStripeData(Invoice $invoice): array + { + $amount = $invoice->amount; + if (empty($amount)) { + $amount = array_sum(array_map( + static fn (InvoiceItem $item) => $item->price * ($item->quantity ?? 1), + $invoice->items ?? [] + )); + } + + $stripePaymentIntentData = [ + 'amount' => $amount, + 'currency' => 'brl', // o pacote inteiro é BRL implícito (valores em centavos) + ]; + + if (!empty($invoice->customer) && !empty($invoice->customer->id)) { + $stripePaymentIntentData['customer'] = $invoice->customer->id; + } + + foreach ($invoice->items ?? [] as $index => $item) { + $stripePaymentIntentData['metadata']["item_{$index}_description"] = $item->description; + $stripePaymentIntentData['metadata']["item_{$index}_price"] = $item->price; + $stripePaymentIntentData['metadata']["item_{$index}_quantity"] = $item->quantity; + } + + return $stripePaymentIntentData; + } + + /** + * Mescla as opções extras/override do consumidor por último, para que possam + * sobrescrever qualquer chave montada pelo gateway (válvula de escape do pacote). A chave + * antiga de idempotência fica de fora do payload. + * + * @param array $stripeData + * @param Model $model + * @return array + */ + private function mergeGatewayOptions(array $stripeData, Model $model): array + { + foreach (self::withoutIdempotencyKey($model->gatewayOptions) as $option => $value) { + $stripeData[$option] = $value; + } + + return $stripeData; + } + + /** + * @inheritDoc + * + * Aceita o id de um PaymentIntent (`pi_`, cobrança avulsa) ou de um Invoice da Stripe + * (`in_`, fatura de assinatura): o prefixo decide qual objeto é lido e + * `Invoice::$originType` diz qual voltou. A leitura de um Invoice custa um GET a mais + * quando o PaymentIntent dele já teve tentativa de pagamento, porque o charge fica fora do + * limite de níveis do `expand`. + */ + public function getInvoice(Invoice $invoice): Invoice + { + if (self::isStripeInvoiceId($invoice->id)) { + return $this->parseInvoice($this->retrieveStripeInvoice($invoice->id), $invoice); + } + + $stripePaymentIntent = $this->stripeRequest(function () use ($invoice) { + return $this->client->paymentIntents->retrieve( + $invoice->id, + ['expand' => self::PAYMENT_INTENT_EXPAND] + ); + }); + + return $this->parseInvoice($stripePaymentIntent, $invoice); + } + + /** + * Diz se o id é de um objeto Invoice da Stripe (prefixo `in_`). + * + * @param string|null $id + * @return bool + */ + private static function isStripeInvoiceId(?string $id): bool + { + return is_string($id) && str_starts_with($id, self::STRIPE_INVOICE_ID_PREFIX); + } + + /** + * Lê um objeto Invoice da Stripe com os pagamentos e o PaymentIntent deles expandidos. + * + * @param string $id + * @return StripeInvoice + * @throws GatewayException|GatewayNotAvailableException + */ + private function retrieveStripeInvoice(string $id): StripeInvoice + { + return $this->stripeRequest(function () use ($id) { + return $this->client->invoices->retrieve($id, ['expand' => self::INVOICE_EXPAND]); + }); + } + + /** + * Lança `UnsupportedOperationException` (`SUBSCRIPTIONS`, `not_implemented`) quando a fatura + * é um Invoice da Stripe (id `in_`): a lib ainda não implementa escrita sobre a fatura de + * assinatura. + * + * @param Invoice $invoice + * @param string $operation nome da operação, para a mensagem + * @return void + * @throws UnsupportedOperationException + */ + private function assertPaymentIntentOrigin(Invoice $invoice, string $operation): void + { + if (self::isStripeInvoiceId($invoice->id)) { + // reason fixado em not_implemented: SUBSCRIPTIONS está em capabilities(), mas a + // escrita sobre a fatura de assinatura ainda não foi construída nesta lib + throw new UnsupportedOperationException( + "A operação {$operation} sobre a fatura de assinatura [{$invoice->id}] (objeto Invoice da Stripe)" + . ' ainda não está implementada nesta lib; a leitura por getInvoice() está disponível.', + (string) $this, + Capability::SUBSCRIPTIONS, + UnsupportedOperationException::REASON_NOT_IMPLEMENTED + ); + } + } + + /** + * @inheritDoc + * + * As guardas de estorno precisam do método de pagamento, do status e, no estorno por valor, + * do quanto ainda pode ser estornado. Um model que traz só o `id` custa um GET a mais para + * ler a fatura antes do estorno; um model lido do gateway, pago e sem estorno anterior não + * paga esse GET. No estorno por valor sobre uma fatura fora de `PAID` a leitura acontece + * mesmo com o model preenchido, porque o restante estornável depende do acumulado que o + * gateway guarda. A leitura prévia acontece numa cópia: o model do chamador só é alterado se + * o estorno acontecer. + * + * A chave de idempotência vai no cabeçalho `Idempotency-Key` da criação do refund; as + * leituras do PaymentIntent não a usam. Com chave, uma guarda de estado (fatura já + * estornada, valor acima do restante) não recusa de imediato: a mesma chave pode ser a de um + * estorno já feito, então o driver envia o refund e deixa a Stripe repetir a resposta + * original; se ela recusar, a recusa da guarda é a que sobe. + * + * O valor vem de `$amount`; sem ele, do caminho antigo de escrever `refundedAmount` antes + * de estornar (`Invoice::resolveRefundAmount()`); sem os dois, estorna o restante. No + * estorno por valor a fatura é relida quando o model não traz o valor pago ou quando o + * acumulado estornado que ele traz não é confiável (escrito pelo caminho antigo, ou ausente + * numa fatura fora de `PAID`). + * + * @throws ModelAttributeValidationException|RefundNotSupportedException + */ + public function refundInvoice(Invoice $invoice, ?int $amount = null, ?string $idempotencyKey = null): Refund + { + if (empty($invoice->id)) { + throw ModelAttributeValidationException::required('Invoice', 'id'); + } + $this->assertPaymentIntentOrigin($invoice, 'refundInvoice'); + $requestedAmount = $invoice->resolveRefundAmount($amount); + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $invoice); + + $current = $invoice; + if ( + empty($invoice->paymentMethod) + || empty($invoice->status) + || (!is_null($requestedAmount) && !self::hasReliableRefundableAmount($invoice)) + ) { + $current = $this->getInvoice(clone $invoice); + } + + $stripeRefundData = ['payment_intent' => $invoice->id]; + if (!is_null($requestedAmount)) { + $stripeRefundData['amount'] = $requestedAmount; + } + $stripeRefundData = $this->mergeGatewayOptions($stripeRefundData, $invoice); + + try { + $this->assertInvoiceIsRefundable($current, $requestedAmount); + $stripeRefund = $this->stripeRequest(function () use ($stripeRefundData, $idempotencyKey) { + return $this->client->refunds->create($stripeRefundData, self::stripeOptions($idempotencyKey)); + }); + } catch (RefundNotSupportedException $refusal) { + $stripeRefund = $this->replayStripeRefund($refusal, $stripeRefundData, $idempotencyKey); + } + + // o refund não devolve o PaymentIntent: refetch para reparse com o charge atualizado + $invoice = $this->getInvoice($invoice); + + $refund = $this->parseRefund($stripeRefund, $invoice->id); + $refund->invoice = $invoice; + + return $refund; + } + + /** + * Tenta repetir, pela chave de idempotência, um estorno que a guarda de estado recusou: + * a Stripe devolve o refund original quando a chave é a dele. Sem chave, ou quando a recusa + * não é de estado (boleto), ou quando a Stripe também recusa, sobe a recusa da guarda. + * + * @param RefundNotSupportedException $refusal + * @param array $stripeRefundData + * @param string|null $idempotencyKey + * @return object o objeto Refund devolvido pela Stripe + * @throws RefundNotSupportedException + */ + private function replayStripeRefund(RefundNotSupportedException $refusal, array $stripeRefundData, ?string $idempotencyKey): object + { + $stateReasons = [ + RefundNotSupportedException::REASON_ALREADY_REFUNDED, + RefundNotSupportedException::REASON_AMOUNT_EXCEEDS_REFUNDABLE, + ]; + if (is_null($idempotencyKey) || !in_array($refusal->reason, $stateReasons, true)) { + throw $refusal; + } + + try { + return $this->client->refunds->create($stripeRefundData, self::stripeOptions($idempotencyKey)); + } catch (\Exception $e) { + throw $refusal; + } + } + + /** + * @inheritDoc + * + * Na Stripe o restante é `amount_captured` menos `amount_refunded` do charge + * (`Invoice::$paidAmount` menos `Invoice::$refundedAmount`, porque o valor pago vem bruto); a + * fatura é lida quando o model não traz o valor pago ou o acumulado estornado confiável. A + * fatura de assinatura (`in_`) é recusada como em `refundInvoice()`, antes da leitura. + * + * @throws UnsupportedOperationException + */ + public function refundableAmount(Invoice $invoice): int + { + if (empty($invoice->id)) { + throw ModelAttributeValidationException::required('Invoice', 'id'); + } + $this->assertPaymentIntentOrigin($invoice, 'refundableAmount'); + + $current = self::hasReliableRefundableAmount($invoice) ? $invoice : $this->getInvoice(clone $invoice); + + return self::stripeRefundableAmount($current); + } + + /** + * Restante estornável de uma fatura já lida: `paidAmount` menos `refundedAmount`; zero + * quando nada foi pago. + * + * @param Invoice $invoice + * @return int + */ + private static function stripeRefundableAmount(Invoice $invoice): int + { + return max(0, (int) ($invoice->paidAmount ?? 0) - (int) ($invoice->refundedAmount ?? 0)); + } + + /** + * Diz se o model traz o que basta para calcular o restante estornável sem reler a fatura: + * valor pago presente e `refundedAmount` sendo o acumulado do gateway, ou seja, sem valor + * pedido pelo caminho antigo e preenchido sempre que a fatura está fora de `PAID`. + * + * @param Invoice $invoice + * @return bool + */ + private static function hasReliableRefundableAmount(Invoice $invoice): bool + { + if (is_null($invoice->paidAmount) || !is_null($invoice->requestedRefundAmount())) { + return false; + } + + return !is_null($invoice->refundedAmount) || $invoice->status === InvoiceStatus::PAID; + } + + /** + * Lança antes da rede quando a Stripe certamente recusaria o estorno: boleto não tem estorno + * pela API, fatura em `refunded` é terminal e o valor pedido não pode passar do que resta + * (`amount_captured` menos `amount_refunded` do charge). + * + * @param Invoice $invoice fatura com `paidAmount` e `refundedAmount` confiáveis + * @param int|null $requestedAmount valor pedido em centavos; nulo é estorno do restante + * @return void + * @throws RefundNotSupportedException + */ + private function assertInvoiceIsRefundable(Invoice $invoice, ?int $requestedAmount): void + { + if ($invoice->paymentMethod === PaymentMethod::BANK_SLIP) { + throw RefundNotSupportedException::boletoNoRefund('stripe'); + } + + if ($invoice->status === InvoiceStatus::REFUNDED) { + throw RefundNotSupportedException::alreadyRefunded('stripe', $invoice->paymentMethod?->value); + } + + if (is_null($requestedAmount) || is_null($invoice->paidAmount)) { + return; + } + + $refundable = self::stripeRefundableAmount($invoice); + if ($requestedAmount > $refundable) { + throw RefundNotSupportedException::amountExceedsRefundable( + 'stripe', + $invoice->paymentMethod?->value, + $requestedAmount, + $refundable + ); + } + } + + /** + * @inheritDoc + * + * A chave de idempotência vai no cabeçalho `Idempotency-Key` do confirm; o update que + * antecede o confirm usa `{chave}:update` e a conversão de token legado em PaymentMethod + * usa `{chave}:payment_method`. + * + * @throws ChargingException|ModelAttributeValidationException + */ + public function chargeInvoiceWithCreditCard(Invoice $invoice, ?string $idempotencyKey = null): Invoice + { + if (empty($invoice->id)) { + throw ModelAttributeValidationException::required('Invoice', 'id'); + } + if (empty($invoice->creditCard)) { + throw ModelAttributeValidationException::required('Invoice', 'creditCard'); + } + if (empty($invoice->creditCard->token) && empty($invoice->creditCard->id)) { + throw new ModelAttributeValidationException('Credit card token or id is required'); + } + $this->assertPaymentIntentOrigin($invoice, 'chargeInvoiceWithCreditCard'); + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $invoice); + + // id = PaymentMethod salvo no customer; token = PaymentMethod criado client-side + $paymentMethodId = !empty($invoice->creditCard->id) + ? $invoice->creditCard->id + : $invoice->creditCard->token; + + $stripePaymentIntent = $this->stripeRequest(function () use ($invoice, $paymentMethodId, $idempotencyKey) { + $paymentMethodId = $this->resolvePaymentMethodId( + $paymentMethodId, + self::derivedIdempotencyKey($idempotencyKey, 'payment_method') + ); + $stripePaymentMethod = $this->client->paymentMethods->retrieve($paymentMethodId); + + // o PaymentIntent pode ter sido criado para outro método (ex.: pix expirado): + // é preciso aceitar cartão nos types — e, quando o PaymentMethod é salvo, + // vincular o customer dele ao PaymentIntent antes do confirm; um PaymentIntent + // que já pertence a outro customer não pode ser reatribuído silenciosamente + $stripePaymentIntent = $this->client->paymentIntents->retrieve($invoice->id); + $paymentIntentCustomer = is_object($stripePaymentIntent->customer) + ? $stripePaymentIntent->customer->id + : $stripePaymentIntent->customer; + if (!empty($paymentIntentCustomer) + && !empty($stripePaymentMethod->customer) + && $stripePaymentMethod->customer !== $paymentIntentCustomer) { + throw UnsupportedOperationException::restricted( + (string) $this, + Capability::CREDIT_CARD, + "Credit card [{$paymentMethodId}] does not belong to customer [{$paymentIntentCustomer}];" + . ' the Stripe PaymentMethod is bound to one customer and cannot pay another customer\'s invoice.' + ); + } + + // o customer vai sempre que o cartão tem um (igual ao do PaymentIntent, ou o + // PaymentIntent ainda sem cliente): o payload fica o mesmo num retry com a mesma chave + $updateParams = ['payment_method_types' => ['card']]; + if (!empty($stripePaymentMethod->customer)) { + $updateParams['customer'] = $stripePaymentMethod->customer; + } + $this->client->paymentIntents->update( + $invoice->id, + $updateParams, + self::stripeOptions(self::derivedIdempotencyKey($idempotencyKey, 'update')) + ); + + return $this->client->paymentIntents->confirm($invoice->id, [ + 'payment_method' => $paymentMethodId, + 'off_session' => true, + 'expand' => self::PAYMENT_INTENT_EXPAND, + ], self::stripeOptions($idempotencyKey)); + }); + + return $this->parseInvoice($stripePaymentIntent, $invoice); + } + + /** + * Converte o objeto de origem da Stripe em uma Invoice do MultiPayment: PaymentIntent + * (cobrança avulsa, origem `PAYMENT_INTENT`) ou Invoice da Stripe (fatura de assinatura, + * origem `INVOICE`). `Invoice::$originType` diz qual foi e `original` guarda o objeto. + * + * @param \Stripe\PaymentIntent|\Stripe\Invoice $stripeObject + * @param \Potelo\MultiPayment\Models\Invoice|null $invoice + * @return \Potelo\MultiPayment\Models\Invoice + * @throws GatewayException|GatewayNotAvailableException + */ + private function parseInvoice(StripePaymentIntent|StripeInvoice $stripeObject, ?Invoice $invoice = null): Invoice + { + return $stripeObject instanceof StripeInvoice + ? $this->parseFromStripeInvoice($stripeObject, $invoice) + : $this->parseFromPaymentIntent($stripeObject, $invoice); + } + + /** + * Converte o PaymentIntent da Stripe em uma Invoice do MultiPayment (origem + * `PAYMENT_INTENT`). Os line items vêm de `metadata`, onde `invoiceToStripeData()` os + * serializou; `url` é a página de instruções do Pix, nula em cartão. + * + * @param \Stripe\PaymentIntent $stripePaymentIntent + * @param \Potelo\MultiPayment\Models\Invoice|null $invoice + * @return \Potelo\MultiPayment\Models\Invoice + * @throws GatewayException|GatewayNotAvailableException + */ + private function parseFromPaymentIntent(StripePaymentIntent $stripePaymentIntent, ?Invoice $invoice = null): Invoice + { + $invoice = $invoice ?? new Invoice(); + + // sem expand o latest_charge vem só como id; um charge failed (ex.: pix expirado) + // não pode alimentar paidAmount/refundedAmount + $stripeCharge = is_object($stripePaymentIntent->latest_charge) ? $stripePaymentIntent->latest_charge : null; + $paidCharge = ($stripeCharge && $stripeCharge->status === 'succeeded') ? $stripeCharge : null; + + $invoice->id = $stripePaymentIntent->id; + $invoice->gateway = 'stripe'; + $invoice->originType = InvoiceOriginType::PAYMENT_INTENT; + $invoice->status = $this->deriveStatus(null, $stripePaymentIntent, $paidCharge); + $invoice->amount = $stripePaymentIntent->amount; + $invoice->paidAmount = $paidCharge?->amount_captured; + $invoice->setRefundedAmountFromGateway($paidCharge?->amount_refunded); + $invoice->refunds = $this->parseRefunds($paidCharge, $stripePaymentIntent->id); + $invoice->paidAt = $paidCharge ? Carbon::createFromTimestamp($paidCharge->created) : null; + $invoice->fee = self::chargeFee($paidCharge); + $invoice->createdAt = Carbon::createFromTimestamp($stripePaymentIntent->created); + $invoice->original = $stripePaymentIntent; + + $this->parseInvoiceCustomer($invoice, $stripePaymentIntent->customer ?? null); + $this->parsePaymentMethod($invoice, $stripePaymentIntent, $stripeCharge); + + // reconstrói os items serializados em metadata pelo invoiceToStripeData + $metadata = !empty($stripePaymentIntent->metadata) ? $stripePaymentIntent->metadata->toArray() : []; + $items = []; + for ($index = 0; isset($metadata["item_{$index}_price"]); $index++) { + $invoiceItem = new InvoiceItem(); + $invoiceItem->description = $metadata["item_{$index}_description"] ?? null; + $invoiceItem->price = (int) $metadata["item_{$index}_price"]; + $invoiceItem->quantity = (int) ($metadata["item_{$index}_quantity"] ?? 1); + $items[] = $invoiceItem; + } + if (!empty($items)) { + $invoice->items = $items; + } + + $this->parseCardDetails($invoice, $stripeCharge); + + // sem next_action não há QR nem voucher utilizável: a página hospedada some junto, + // inclusive num model reutilizado (ex.: fatura pix expirada re-cobrada com cartão); + // os dois parses rodam sempre, para limpar o que sobrou do outro método + $pixUrl = $this->parsePixDisplay($invoice, $stripePaymentIntent); + $boletoUrl = $this->parseBoletoDisplay($invoice, $stripePaymentIntent); + $invoice->url = $pixUrl ?? $boletoUrl; + + return $invoice; + } + + /** + * Converte um objeto Invoice da Stripe (fatura de assinatura) em uma Invoice do + * MultiPayment (origem `INVOICE`). O PaymentIntent da fatura vem de `payments` + * (`invoicePaymentIntent()`), e o charge dele alimenta valores, estornos e contestação como + * na cobrança avulsa. Os line items vêm de `lines.data` (a primeira página, de até dez + * itens); `url` é a página hospedada da fatura; `dueDate` é o `due_date`, quando a fatura + * tem um, e `pixExpiresAt` a expiração do QR Code do Pix. + * + * @param \Stripe\Invoice $stripeInvoice + * @param \Potelo\MultiPayment\Models\Invoice|null $invoice + * @return \Potelo\MultiPayment\Models\Invoice + * @throws GatewayException|GatewayNotAvailableException + */ + private function parseFromStripeInvoice(StripeInvoice $stripeInvoice, ?Invoice $invoice = null): Invoice + { + $invoice = $invoice ?? new Invoice(); + + $stripePaymentIntent = $this->invoicePaymentIntent($stripeInvoice); + $stripeCharge = is_object($stripePaymentIntent?->latest_charge) ? $stripePaymentIntent->latest_charge : null; + $paidCharge = ($stripeCharge && $stripeCharge->status === 'succeeded') ? $stripeCharge : null; + + $invoice->id = $stripeInvoice->id; + $invoice->gateway = 'stripe'; + $invoice->originType = InvoiceOriginType::INVOICE; + $invoice->status = $this->deriveStatus($stripeInvoice, $stripePaymentIntent, $paidCharge); + $invoice->amount = $stripeInvoice->total; + // sem charge pago, o valor recebido é o que o Invoice registra (pagamento externo, + // fatura parcialmente paga ou quitada sem cobrança); fatura em aberto sem nada pago fica nula + $amountPaid = $stripeInvoice->amount_paid ?? 0; + $invoice->paidAmount = $paidCharge?->amount_captured + ?? ($stripeInvoice->status === 'paid' || $amountPaid > 0 ? $amountPaid : null); + $invoice->setRefundedAmountFromGateway($paidCharge?->amount_refunded); + $invoice->refunds = $this->parseRefunds($paidCharge, $stripeInvoice->id); + $paidAt = $stripeInvoice->status_transitions->paid_at ?? $paidCharge?->created; + $invoice->paidAt = !empty($paidAt) ? Carbon::createFromTimestamp($paidAt) : null; + $invoice->fee = self::chargeFee($paidCharge); + $invoice->createdAt = Carbon::createFromTimestamp($stripeInvoice->created); + $invoice->dueDate = !empty($stripeInvoice->due_date) + ? Carbon::createFromTimestamp($stripeInvoice->due_date) + : null; + $invoice->url = $stripeInvoice->hosted_invoice_url ?? null; + $invoice->original = $stripeInvoice; + + $this->parseInvoiceCustomer($invoice, $stripeInvoice->customer ?? null); + // paga fora da Stripe, o método oferecido pelo PaymentIntent cancelado não diz como + // o dinheiro entrou + if ($invoice->status !== InvoiceStatus::EXTERNALLY_PAID) { + $this->parsePaymentMethod($invoice, $stripePaymentIntent, $stripeCharge); + } + + $items = []; + foreach ($stripeInvoice->lines->data ?? [] as $line) { + $invoiceItem = new InvoiceItem(); + $invoiceItem->description = $line->description ?? null; + $invoiceItem->quantity = isset($line->quantity) ? (int) $line->quantity : 1; + $invoiceItem->price = self::lineItemUnitAmount($line, $invoiceItem->quantity); + $items[] = $invoiceItem; + } + if (!empty($items)) { + $invoice->items = $items; + } + + $this->parseCardDetails($invoice, $stripeCharge); + $this->parsePixDisplay($invoice, $stripePaymentIntent); + $this->parseBoletoDisplay($invoice, $stripePaymentIntent); + + return $invoice; + } + + /** + * Escolhe o PaymentIntent da fatura entre os pagamentos do Invoice (`payments.data`): o + * que está pago, senão o pagamento padrão (`is_default`), senão o primeiro do tipo + * PaymentIntent. Devolve nulo quando a fatura não tem PaymentIntent (rascunho, quitada sem + * cobrança, paga fora da Stripe). Um PaymentIntent que já tem charge, ou que veio só como + * id, é relido com o expand de PaymentIntent, para o charge trazer valores, estornos e a + * flag de contestação. + * + * @param \Stripe\Invoice $stripeInvoice + * @return \Stripe\PaymentIntent|null + * @throws GatewayException|GatewayNotAvailableException + */ + private function invoicePaymentIntent(StripeInvoice $stripeInvoice): ?StripePaymentIntent + { + $candidates = []; + foreach ($stripeInvoice->payments->data ?? [] as $invoicePayment) { + if (($invoicePayment->payment->type ?? null) === self::INVOICE_PAYMENT_TYPE_PAYMENT_INTENT) { + $candidates[] = $invoicePayment; + } + } + + $chosen = null; + foreach ($candidates as $candidate) { + if (($candidate->status ?? null) === 'paid') { + $chosen = $candidate; + break; + } + } + if (is_null($chosen)) { + foreach ($candidates as $candidate) { + if (!empty($candidate->is_default)) { + $chosen = $candidate; + break; + } + } + } + $chosen = $chosen ?? ($candidates[0] ?? null); + if (is_null($chosen)) { + return null; + } + + $stripePaymentIntent = $chosen->payment->payment_intent ?? null; + if ($stripePaymentIntent instanceof StripePaymentIntent && empty($stripePaymentIntent->latest_charge)) { + return $stripePaymentIntent; + } + + $id = is_object($stripePaymentIntent) ? ($stripePaymentIntent->id ?? '') : (string) $stripePaymentIntent; + if ($id === '') { + return null; + } + + return $this->stripeRequest(function () use ($id) { + return $this->client->paymentIntents->retrieve($id, ['expand' => self::PAYMENT_INTENT_EXPAND]); + }); + } + + /** + * Tipo do primeiro pagamento do Invoice com status `paid` (`payment_intent`, `charge` ou + * `payment_record`), ou nulo quando nenhum está pago. + * + * @param \Stripe\Invoice $stripeInvoice + * @return string|null + */ + private static function paidInvoicePaymentType(StripeInvoice $stripeInvoice): ?string + { + foreach ($stripeInvoice->payments->data ?? [] as $invoicePayment) { + if (($invoicePayment->status ?? null) === 'paid') { + return $invoicePayment->payment->type ?? null; + } + } + + return null; + } + + /** + * Valor unitário de um line item do Invoice: `pricing.unit_amount_decimal` quando existe, + * senão o `amount` da linha dividido pela quantidade. + * + * @param object $line + * @param int $quantity + * @return int|null + */ + private static function lineItemUnitAmount(object $line, int $quantity): ?int + { + $unitAmount = $line->pricing->unit_amount_decimal ?? null; + if (is_numeric($unitAmount)) { + return (int) round((float) $unitAmount); + } + + if (!isset($line->amount)) { + return null; + } + + return $quantity > 1 ? intdiv((int) $line->amount, $quantity) : (int) $line->amount; + } + + /** + * Taxa da Stripe no charge pago, lida da balance transaction expandida. A balance + * transaction do cartão é assíncrona: pode vir nula logo após o confirm e preenchida numa + * leitura posterior. + * + * @param object|null $paidCharge + * @return int|null + */ + private static function chargeFee(?object $paidCharge): ?int + { + $balanceTransaction = $paidCharge?->balance_transaction; + + return is_object($balanceTransaction) ? $balanceTransaction->fee : null; + } + + /** + * Preenche o id do cliente da fatura a partir do `customer` do objeto da Stripe (id ou + * objeto expandido); sem cliente no objeto, o model fica como estava. + * + * @param Invoice $invoice + * @param object|string|null $stripeCustomer + * @return void + */ + private function parseInvoiceCustomer(Invoice $invoice, object|string|null $stripeCustomer): void + { + if (empty($stripeCustomer)) { + return; + } + if (empty($invoice->customer)) { + $invoice->customer = new Customer(); + } + $invoice->customer->id = is_object($stripeCustomer) ? $stripeCustomer->id : $stripeCustomer; + } + + /** + * Preenche `paymentMethod` e `availablePaymentMethods` a partir do tipo do charge + * (`payment_method_details.type`) ou, sem charge, do único tipo aceito pelo PaymentIntent. + * + * @param Invoice $invoice + * @param \Stripe\PaymentIntent|null $stripePaymentIntent + * @param object|null $stripeCharge + * @return void + */ + private function parsePaymentMethod(Invoice $invoice, ?StripePaymentIntent $stripePaymentIntent, ?object $stripeCharge): void + { + $detailsType = $stripeCharge?->payment_method_details?->type; + if (!empty($detailsType)) { + $invoice->paymentMethod = self::PAYMENT_METHOD_TYPES[$detailsType] ?? null; + } elseif (count($stripePaymentIntent?->payment_method_types ?? []) === 1) { + $invoice->paymentMethod = self::PAYMENT_METHOD_TYPES[$stripePaymentIntent->payment_method_types[0]] ?? null; + } + if (!empty($invoice->paymentMethod)) { + $invoice->availablePaymentMethods = [$invoice->paymentMethod]; + } + } + + /** + * Preenche bandeira e últimos dígitos do cartão a partir de `payment_method_details.card` + * do charge, quando existe. + * + * @param Invoice $invoice + * @param object|null $stripeCharge + * @return void + */ + private function parseCardDetails(Invoice $invoice, ?object $stripeCharge): void + { + // `?->` não basta: em cobrança pix o payment_method_details existe e apenas não tem + // a chave `card`, e o StripeObject loga "Undefined property" via Stripe::getLogger() + // ao ler propriedade ausente. isset() passa pelo __isset e não polui o log. + $paymentMethodDetails = $stripeCharge?->payment_method_details; + $cardDetails = isset($paymentMethodDetails->card) ? $paymentMethodDetails->card : null; + if (empty($cardDetails)) { + return; + } + if (empty($invoice->creditCard)) { + $invoice->creditCard = new CreditCard(); + } + $invoice->creditCard->brand = $cardDetails->brand ?? null; + $invoice->creditCard->lastDigits = $cardDetails->last4 ?? null; + $invoice->creditCard->gateway = 'stripe'; + } + + /** + * Preenche `pix` (QR Code) e `pixExpiresAt` a partir de `next_action.pix_display_qr_code` do + * PaymentIntent e devolve a página hospedada de instruções. Sem QR Code, limpa `pix` e + * devolve nulo. + * + * @param Invoice $invoice + * @param \Stripe\PaymentIntent|null $stripePaymentIntent + * @return string|null + */ + private function parsePixDisplay(Invoice $invoice, ?StripePaymentIntent $stripePaymentIntent): ?string + { + // isset() passa pelo __isset: um next_action de outro tipo (3DS) não tem a chave e o + // StripeObject registraria "Undefined property" no log ao lê-la + $nextAction = $stripePaymentIntent?->next_action; + $qrCode = isset($nextAction->pix_display_qr_code) ? $nextAction->pix_display_qr_code : null; + if (empty($qrCode)) { + $invoice->pix = null; + + return null; + } + + if (empty($invoice->pix)) { + $invoice->pix = new Pix(); + } + $invoice->pix->qrCodeText = $qrCode->data ?? null; + $invoice->pix->qrCodeImageUrl = $qrCode->image_url_png ?? null; + $invoice->pixExpiresAt = !empty($qrCode->expires_at) + ? Carbon::createFromTimestamp($qrCode->expires_at) + : $invoice->pixExpiresAt; + + return $qrCode->hosted_instructions_url ?? null; + } + + /** + * Preenche `bankSlip` (linha digitável em `number`, PDF em `url`) a partir de + * `next_action.boleto_display_details` do PaymentIntent e devolve a página hospedada do + * voucher; `dueDate`, quando vazio, recebe o instante em que o voucher vence. Sem voucher + * (boleto pago, vencido ou outro método), limpa `bankSlip` e devolve nulo. + * + * @param Invoice $invoice + * @param \Stripe\PaymentIntent|null $stripePaymentIntent + * @return string|null + */ + private function parseBoletoDisplay(Invoice $invoice, ?StripePaymentIntent $stripePaymentIntent): ?string + { + // isset() passa pelo __isset: um next_action de outro tipo não tem a chave e o + // StripeObject registraria "Undefined property" no log ao lê-la + $nextAction = $stripePaymentIntent?->next_action; + $voucher = isset($nextAction->boleto_display_details) ? $nextAction->boleto_display_details : null; + if (empty($voucher)) { + $invoice->bankSlip = null; + + return null; + } + + if (empty($invoice->bankSlip)) { + $invoice->bankSlip = new BankSlip(); + } + $invoice->bankSlip->number = $voucher->number ?? null; + $invoice->bankSlip->url = $voucher->pdf ?? null; + if (empty($invoice->dueDate) && !empty($voucher->expires_at)) { + $invoice->dueDate = Carbon::createFromTimestamp($voucher->expires_at); + } + + return $voucher->hosted_voucher_url ?? null; + } + + /** + * Monta a lista de estornos da fatura a partir de `refunds` do charge pago, um `Refund` + * por estorno. Sem charge pago ou sem estorno a lista é vazia. Numa resposta em que a + * lista não veio expandida mas `amount_refunded` é maior que zero, devolve um único + * `Refund` sem id com o acumulado, para a lista nunca contradizer `refundedAmount`. + * + * @param object|null $paidCharge + * @param string $invoiceId + * @return Refund[] + */ + private function parseRefunds(?object $paidCharge, string $invoiceId): array + { + if (!$paidCharge || empty($paidCharge->amount_refunded)) { + return []; + } + + // isset() passa pelo __isset e não loga "Undefined property" quando a chave falta + $stripeRefunds = isset($paidCharge->refunds) ? $paidCharge->refunds : null; + if (!is_object($stripeRefunds) || !isset($stripeRefunds->data)) { + $refund = new Refund(); + $refund->invoiceId = $invoiceId; + $refund->amount = $paidCharge->amount_refunded; + $refund->status = RefundStatus::SUCCEEDED; + $refund->gateway = 'stripe'; + + return [$refund]; + } + + return array_map( + fn (object $stripeRefund) => $this->parseRefund($stripeRefund, $invoiceId), + $stripeRefunds->data + ); + } + + /** + * Converte o objeto Refund da Stripe em um `Refund` do MultiPayment. Status fora do mapa + * vira `UNKNOWN` com aviso no log. + * + * @param object $stripeRefund + * @param string $invoiceId + * @return Refund + */ + private function parseRefund(object $stripeRefund, string $invoiceId): Refund + { + $refund = new Refund(); + $refund->id = $stripeRefund->id; + $refund->invoiceId = $invoiceId; + $refund->amount = $stripeRefund->amount; + $refund->status = self::REFUND_STATUSES[$stripeRefund->status ?? ''] + ?? RefundStatus::unknown((string) $stripeRefund->status, 'stripe'); + $refund->reason = $stripeRefund->reason ?? null; + $refund->createdAt = !empty($stripeRefund->created) + ? Carbon::createFromTimestamp($stripeRefund->created) + : null; + $refund->gateway = 'stripe'; + $refund->original = $stripeRefund; + + return $refund; + } + + /** + * Deriva o status genérico de contestação de um charge pago. Quando a flag `disputed` do + * charge é verdadeira, lista as disputes dele em /v1/disputes (um GET a mais). + * Contestação em aberto tem precedência sobre perdida; dispute ganha, encerrada sem virar + * chargeback (`warning_closed`) ou prevenida não altera o status da fatura. + * + * @param object $stripeCharge + * @return InvoiceStatus|null `DISPUTED`, `CHARGEBACK` ou null + * @throws GatewayException|GatewayNotAvailableException + */ + private function disputeStatus(object $stripeCharge): ?InvoiceStatus + { + // isset() passa pelo __isset e não loga "Undefined property" quando a chave falta + if (!isset($stripeCharge->disputed) || !$stripeCharge->disputed) { + return null; + } + + $disputes = $this->stripeRequest(function () use ($stripeCharge) { + // uma página basta: um charge não acumula dezenas de disputes + return $this->client->disputes->all(['charge' => $stripeCharge->id, 'limit' => 100]); + }); + + $statuses = array_map(static fn ($dispute) => $dispute->status, $disputes->data ?? []); + if (!empty(array_intersect($statuses, self::OPEN_DISPUTE_STATUSES))) { + return InvoiceStatus::DISPUTED; + } + if (in_array(self::LOST_DISPUTE_STATUS, $statuses, true)) { + return InvoiceStatus::CHARGEBACK; + } + + return null; + } + + /** + * Deriva o `InvoiceStatus` do trio Invoice da Stripe, PaymentIntent e charge pago; os dois + * `parse*` de fatura obtêm o status por aqui. + * + * Sem Invoice (origem `PAYMENT_INTENT`) o status vem do PaymentIntent e o charge refina + * estorno e contestação: estorno não muda o status do PaymentIntent na Stripe; + * `requires_capture` lê como `AUTHORIZED`, `processing` como `PROCESSING` e status fora do + * mapa devolve `UNKNOWN` com aviso no log. + * + * Com Invoice (origem `INVOICE`) o status do Invoice manda no ciclo de vida e PaymentIntent + * e charge só refinam o detalhe de pagamento: `draft` é `PENDING`; `open` é `PENDING`, + * `AUTHORIZED`, `PROCESSING` ou `PARTIALLY_PAID` conforme o PaymentIntent e o + * `amount_paid`; `paid` é `PAID`, estornada ou contestada conforme o charge, + * `EXTERNALLY_PAID` quando o pagamento foi registrado fora da Stripe e `PAID` quando não + * houve cobrança (`amount_due` zero); `void` é `CANCELED` e `uncollectible` é `EXPIRED`. + * Combinação fora dessa tabela devolve `UNKNOWN` com aviso no log contendo os três status + * e o id da fatura. + * + * Contestação, quando existe, vence estorno e status de pagamento nas duas origens. + * + * @param \Stripe\Invoice|null $stripeInvoice + * @param \Stripe\PaymentIntent|null $stripePaymentIntent + * @param object|null $paidCharge charge em `succeeded`, expandido + * @return InvoiceStatus + * @throws GatewayException|GatewayNotAvailableException + */ + private function deriveStatus(?StripeInvoice $stripeInvoice, ?StripePaymentIntent $stripePaymentIntent, ?object $paidCharge): InvoiceStatus + { + $disputeStatus = $paidCharge ? $this->disputeStatus($paidCharge) : null; + + if (is_null($stripeInvoice)) { + return self::paymentIntentStatus($stripePaymentIntent, $paidCharge, $disputeStatus); + } + + return match ($stripeInvoice->status) { + 'draft' => InvoiceStatus::PENDING, + 'open' => $this->openInvoiceStatus($stripeInvoice, $stripePaymentIntent), + 'paid' => $this->paidInvoiceStatus($stripeInvoice, $stripePaymentIntent, $paidCharge, $disputeStatus), + 'void' => InvoiceStatus::CANCELED, + 'uncollectible' => InvoiceStatus::EXPIRED, + default => self::unknownInvoiceStatus($stripeInvoice, $stripePaymentIntent), + }; + } + + /** + * Status de um PaymentIntent sem Invoice (origem `PAYMENT_INTENT`). + * + * @param \Stripe\PaymentIntent|null $stripePaymentIntent + * @param object|null $paidCharge + * @param InvoiceStatus|null $disputeStatus + * @return InvoiceStatus + */ + private static function paymentIntentStatus(?StripePaymentIntent $stripePaymentIntent, ?object $paidCharge, ?InvoiceStatus $disputeStatus): InvoiceStatus + { + if ($disputeStatus !== null) { + return $disputeStatus; + } + + if ($paidCharge && $paidCharge->amount_refunded > 0) { + return $paidCharge->refunded + ? InvoiceStatus::REFUNDED + : InvoiceStatus::PARTIALLY_REFUNDED; + } + + return match ($stripePaymentIntent?->status) { + 'succeeded' => InvoiceStatus::PAID, + 'canceled' => InvoiceStatus::CANCELED, + 'requires_capture' => InvoiceStatus::AUTHORIZED, + 'processing' => InvoiceStatus::PROCESSING, + // pix expirado volta a requires_payment_method (não vira canceled) e segue + // re-cobrável; reportar PENDING preserva essa funcionalidade + 'requires_action', 'requires_confirmation', 'requires_payment_method' => InvoiceStatus::PENDING, + default => InvoiceStatus::unknown((string) $stripePaymentIntent?->status, 'stripe'), + }; + } + + /** + * Status de um Invoice da Stripe em `open`: parcialmente paga quando `amount_paid` está + * entre zero e o `total`; senão o PaymentIntent decide (ausente ou aguardando o cliente é + * `PENDING`, `requires_capture` é `AUTHORIZED`, `processing` é `PROCESSING`). PaymentIntent + * em `succeeded` ou `canceled` numa fatura ainda aberta é transição ou pagamento fora do + * padrão e fica em `UNKNOWN`. + * + * @param \Stripe\Invoice $stripeInvoice + * @param \Stripe\PaymentIntent|null $stripePaymentIntent + * @return InvoiceStatus + */ + private function openInvoiceStatus(StripeInvoice $stripeInvoice, ?StripePaymentIntent $stripePaymentIntent): InvoiceStatus + { + $amountPaid = $stripeInvoice->amount_paid ?? 0; + if ($amountPaid > 0 && $amountPaid < ($stripeInvoice->total ?? 0)) { + return InvoiceStatus::PARTIALLY_PAID; + } + + return match ($stripePaymentIntent?->status) { + null, 'requires_payment_method', 'requires_action', 'requires_confirmation' => InvoiceStatus::PENDING, + 'requires_capture' => InvoiceStatus::AUTHORIZED, + 'processing' => InvoiceStatus::PROCESSING, + default => self::unknownInvoiceStatus($stripeInvoice, $stripePaymentIntent), + }; + } + + /** + * Status de um Invoice da Stripe em `paid`. Com o PaymentIntent em `succeeded`, o charge + * refina: contestação, estorno parcial ou total, senão `PAID`. Sem ele, o pagamento + * registrado fora da Stripe (`amount_paid_off_stripe`, ou um InvoicePayment pago do tipo + * `payment_record`) lê como `EXTERNALLY_PAID`; um InvoicePayment pago do tipo `charge` lê + * como `PAID`; e a fatura sem PaymentIntent com `amount_due` zero (avaliação gratuita, + * saldo de crédito, valor abaixo do mínimo) lê como `PAID`. O restante é `UNKNOWN`. + * + * @param \Stripe\Invoice $stripeInvoice + * @param \Stripe\PaymentIntent|null $stripePaymentIntent + * @param object|null $paidCharge + * @param InvoiceStatus|null $disputeStatus + * @return InvoiceStatus + */ + private function paidInvoiceStatus( + StripeInvoice $stripeInvoice, + ?StripePaymentIntent $stripePaymentIntent, + ?object $paidCharge, + ?InvoiceStatus $disputeStatus + ): InvoiceStatus { + if ($stripePaymentIntent?->status === 'succeeded') { + return self::paymentIntentStatus($stripePaymentIntent, $paidCharge, $disputeStatus); + } + + if (($stripeInvoice->amount_paid_off_stripe ?? 0) > 0) { + return InvoiceStatus::EXTERNALLY_PAID; + } + + $paidPaymentType = self::paidInvoicePaymentType($stripeInvoice); + if ($paidPaymentType === self::INVOICE_PAYMENT_TYPE_PAYMENT_RECORD) { + return InvoiceStatus::EXTERNALLY_PAID; + } + if ($paidPaymentType === 'charge') { + return InvoiceStatus::PAID; + } + + if (is_null($stripePaymentIntent) && (int) ($stripeInvoice->amount_due ?? 0) === 0) { + return InvoiceStatus::PAID; + } + + return self::unknownInvoiceStatus($stripeInvoice, $stripePaymentIntent); + } + + /** + * Devolve `UNKNOWN` e registra um aviso no log com o id da fatura e os status do Invoice, + * do PaymentIntent e do charge. + * + * @param \Stripe\Invoice $stripeInvoice + * @param \Stripe\PaymentIntent|null $stripePaymentIntent + * @return InvoiceStatus + */ + private static function unknownInvoiceStatus(StripeInvoice $stripeInvoice, ?StripePaymentIntent $stripePaymentIntent): InvoiceStatus + { + $stripeCharge = is_object($stripePaymentIntent?->latest_charge) ? $stripePaymentIntent->latest_charge : null; + $context = [ + 'gateway' => 'stripe', + 'invoice_id' => $stripeInvoice->id, + 'invoice_status' => $stripeInvoice->status, + 'payment_intent_status' => $stripePaymentIntent?->status, + 'charge_status' => $stripeCharge?->status, + ]; + + LogHelper::warning( + "Combinação de status sem tradução na fatura [{$stripeInvoice->id}] do gateway [stripe]" + . " (invoice [{$stripeInvoice->status}], payment_intent [" . ($stripePaymentIntent?->status ?? 'ausente') + . '], charge [' . ($stripeCharge?->status ?? 'ausente') . ']), lida como unknown', + $context + ); + + return InvoiceStatus::UNKNOWN; + } + + /** + * Normaliza o código de recusa da Stripe para o valor de `CardDeclinedException::$reason`, que + * mantém o vocabulário das versões anteriores; `declineCode` é a normalização atual. + * + * @param string|null $code + * @param string|null $declineCode + * @return string|null + */ + private static function chargeFailureReason(?string $code, ?string $declineCode): ?string + { + $normalized = [ + 'card_not_supported' => 'brand_not_supported', + 'authentication_required' => 'authentication_required', + 'expired_card' => 'expired_card', + 'insufficient_funds' => 'insufficient_funds', + 'incorrect_cvc' => 'incorrect_cvc', + ]; + + return $normalized[$declineCode ?? ''] + ?? $normalized[$code ?? ''] + ?? $code; + } + + /** + * @inheritDoc + * + * O PaymentIntent não tem duplicate nativo: a fatura nova é criada com os dados da + * original (customer, items, valor) e a nova expiração, e só então a original é + * cancelada — se a criação falhar, o consumidor não fica sem fatura nenhuma. + * Restrito a faturas pix pendentes (cartão é síncrono, não há o que duplicar). A chave de + * idempotência vai na criação da nova fatura; o cancelamento da original usa + * `{chave}:cancel_original`. Com chave, uma original já cancelada é aceita, porque pode ser + * o resultado de uma tentativa anterior com a mesma chave, que a Stripe repete. Fatura de + * origem `INVOICE` (id `in_`) é recusada antes da rede: a próxima fatura da assinatura é + * gerada pela Stripe. + * + * @throws ModelAttributeValidationException|UnsupportedOperationException + */ + public function duplicateInvoice( + Invoice $invoice, + Carbon $expiresAt, + array $gatewayOptions = [], + ?string $idempotencyKey = null + ): Invoice { + if (empty($invoice->id)) { + throw ModelAttributeValidationException::required('Invoice', 'id'); + } + if (self::isStripeInvoiceId($invoice->id)) { + throw UnsupportedOperationException::restricted( + (string) $this, + Capability::INVOICE_DUPLICATION, + "No Stripe a fatura de assinatura [{$invoice->id}] (objeto Invoice) não pode ser duplicada:" + . ' a próxima fatura é gerada pela Stripe, e um Pix expirado se resolve com nova tentativa' + . ' de pagamento da mesma fatura.' + ); + } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $invoice, $gatewayOptions); + $gatewayOptions = self::withoutIdempotencyKey($gatewayOptions); + + $original = $this->stripeRequest(function () use ($invoice) { + return $this->client->paymentIntents->retrieve( + $invoice->id, + ['expand' => array_merge(self::PAYMENT_INTENT_EXPAND, ['payment_method'])] + ); + }); + $parsedOriginal = $this->parseInvoice($original, new Invoice()); + + // com chave, a original cancelada pode ser obra de uma tentativa anterior com a mesma + // chave: a Stripe repete a criação da duplicata e o cancelamento + $replayable = !is_null($idempotencyKey) && $parsedOriginal->status === InvoiceStatus::CANCELED; + if ($parsedOriginal->status !== InvoiceStatus::PENDING && !$replayable) { + throw UnsupportedOperationException::restricted( + (string) $this, + Capability::INVOICE_DUPLICATION, + "No Stripe só uma fatura Pix pendente pode ser duplicada; a fatura [{$invoice->id}] está [{$parsedOriginal->status->value}]." + ); + } + if ($parsedOriginal->paymentMethod !== PaymentMethod::PIX) { + throw UnsupportedOperationException::restricted( + (string) $this, + Capability::INVOICE_DUPLICATION, + "No Stripe só uma fatura Pix pendente pode ser duplicada; a fatura [{$invoice->id}] não é Pix." + ); + } + if (empty($parsedOriginal->customer) || empty($parsedOriginal->customer->id)) { + throw UnsupportedOperationException::restricted( + (string) $this, + Capability::INVOICE_DUPLICATION, + "Invoice [{$invoice->id}] has no customer on the stripe gateway and cannot be duplicated" + ); + } + + // o pix precisa dos billing_details (nome, e-mail, CPF/CNPJ), que vivem no customer + $customer = new Customer(); + $customer->id = $parsedOriginal->customer->id; + $customer = $this->getCustomer($customer); + if (empty($customer->taxDocument)) { + // a original pode ter sido criada com o CPF/CNPJ só no model (billing_details + // do PaymentMethod), sem tax id no customer da Stripe — recupera de lá + $customer->taxDocument = $this->pixBillingTaxId($original); + } + + $duplicated = new Invoice(); + $duplicated->customer = $customer; + $duplicated->amount = $parsedOriginal->amount; + $duplicated->items = $parsedOriginal->items; + $duplicated->availablePaymentMethods = [PaymentMethod::PIX]; + $duplicated->pixExpiresAt = $expiresAt; + // preserva o metadata da original (inclusive chaves custom do consumidor); + // as gatewayOptions do chamador vêm por último e podem sobrescrever + $originalMetadata = !empty($original->metadata) ? $original->metadata->toArray() : []; + if (!empty($originalMetadata)) { + $duplicated->gatewayOptions['metadata'] = $originalMetadata; + } + if (!empty($gatewayOptions)) { + $duplicated->gatewayOptions = array_merge($duplicated->gatewayOptions, $gatewayOptions); + } + $duplicated = $this->createPixInvoice($duplicated, $idempotencyKey); + + try { + $this->cancelInvoice($parsedOriginal, self::derivedIdempotencyKey($idempotencyKey, 'cancel_original')); + } catch (MultiPaymentException $e) { + // a duplicata já existe — propaga o id dela para o consumidor não a perder + throw new GatewayException( + "Invoice duplicated as [{$duplicated->id}] but the original [{$invoice->id}] could not be canceled: " + . $e->getMessage(), + null, + $e, + $e->httpStatus + ); + } + + return $duplicated; + } + + /** + * @inheritDoc + * + * Na origem `PAYMENT_INTENT` cancela o PaymentIntent; na origem `INVOICE` (id `in_`) anula o + * Invoice da Stripe (`void`), e a Stripe cancela sozinha o PaymentIntent padrão dele. O + * boleto com voucher em aberto não pode ser cancelado na Stripe: quando o model traz o + * voucher (`bankSlip` numa fatura pendente), a recusa acontece antes da requisição; sem + * ele, a recusa da Stripe chega como `ValidationException`. Depois que o voucher vence, a + * fatura volta a ser cancelável. A chave de idempotência vai no cabeçalho + * `Idempotency-Key` do cancelamento. + * + * @throws ModelAttributeValidationException|UnsupportedOperationException + */ + public function cancelInvoice(Invoice $invoice, ?string $idempotencyKey = null): Invoice + { + if (empty($invoice->id)) { + throw ModelAttributeValidationException::required('Invoice', 'id'); + } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $invoice); + + if (self::isStripeInvoiceId($invoice->id)) { + return $this->voidStripeInvoice($invoice, $idempotencyKey); + } + + if ($invoice->paymentMethod === PaymentMethod::BANK_SLIP + && $invoice->status === InvoiceStatus::PENDING + && !empty($invoice->bankSlip)) { + throw UnsupportedOperationException::restricted( + (string) $this, + Capability::INVOICE_CANCELLATION, + "O boleto pendente [{$invoice->id}] não pode ser cancelado na Stripe enquanto o voucher" + . ' não vence; aguarde o vencimento (a fatura volta a ser cancelável) ou o pagamento.' + ); + } + + // só estados não-terminais são canceláveis; PaymentIntent pago recusa o cancel + // com payment_intent_unexpected_state (vira GatewayException) + $stripePaymentIntent = $this->stripeRequest(function () use ($invoice, $idempotencyKey) { + return $this->client->paymentIntents->cancel( + $invoice->id, + ['expand' => self::PAYMENT_INTENT_EXPAND], + self::stripeOptions($idempotencyKey) + ); + }); + + return $this->parseInvoice($stripePaymentIntent, $invoice); + } + + /** + * Anula um Invoice da Stripe. A fatura é lida antes: `draft` lança + * `UnsupportedOperationException::restricted()` (`INVOICE_CANCELLATION`) orientando a + * esperar a finalização; nos demais estados a Stripe decide, e `paid` ou `void` recusam + * com `ValidationException`, como o PaymentIntent já pago ou cancelado. + * + * @param Invoice $invoice + * @param string|null $idempotencyKey + * @return Invoice + * @throws GatewayException|GatewayNotAvailableException|UnsupportedOperationException + */ + private function voidStripeInvoice(Invoice $invoice, ?string $idempotencyKey): Invoice + { + $current = $this->retrieveStripeInvoice($invoice->id); + if ($current->status === 'draft') { + throw UnsupportedOperationException::restricted( + (string) $this, + Capability::INVOICE_CANCELLATION, + "A fatura [{$invoice->id}] ainda é um rascunho na Stripe e não pode ser cancelada;" + . ' aguarde a finalização dela pela Stripe.' + ); + } + + $stripeInvoice = $this->stripeRequest(function () use ($invoice, $idempotencyKey) { + return $this->client->invoices->voidInvoice( + $invoice->id, + ['expand' => self::INVOICE_EXPAND], + self::stripeOptions($idempotencyKey) + ); + }); + + return $this->parseInvoice($stripeInvoice, $invoice); + } + + /** + * @inheritDoc + * + * O cartão é salvo por um SetupIntent criado e confirmado na mesma requisição + * (`usage: off_session`), que autentica o portador com o emissor quando ele exige. Em + * `succeeded` a Stripe anexa o PaymentMethod ao cliente e o cartão volta cobrável, com + * `id`. Em `requires_action` nada é anexado: o cartão volta com `requiresAction` + * verdadeiro, `setupId`, `clientSecret` (para `stripe.confirmCardSetup()` no navegador) e + * `actionUrl` quando `gatewayOptions['return_url']` foi informado (página hospedada de + * 3DS), com `id` nulo até `confirmCreditCardSetup()`. Recusa no setup é + * `ChargingException`. A descrição e a marcação de padrão vão em `metadata` do SetupIntent + * (mesclado ao `metadata` de `gatewayOptions`, quando há) e são aplicadas quando o setup + * conclui, nesta chamada ou na confirmação. + * + * A chave de idempotência vai no cabeçalho `Idempotency-Key` do SetupIntent; as requisições + * secundárias usam chaves derivadas: `{chave}:payment_method` na conversão de token legado, + * `{chave}:attach` no anexo (só quando a Stripe devolve o PaymentMethod sem cliente), + * `{chave}:metadata` na descrição e `{chave}:default` ao marcar como padrão. + * + * @throws ChargingException|ModelAttributeValidationException|UnsupportedOperationException + */ + public function createCreditCard(CreditCard $creditCard, ?string $idempotencyKey = null): CreditCard + { + if (empty($creditCard->customer) || empty($creditCard->customer->id)) { + throw ModelAttributeValidationException::required('CreditCard', 'customer'); + } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $creditCard); + if (empty($creditCard->token)) { + // token-only: dados crus exigiriam a liberação de raw card data APIs pela + // Stripe e escopo PCI SAQ D; o cartão é tokenizado client-side + $this->assertSupports( + Capability::RAW_CARD_DATA, + 'Tokenize o cartão no navegador com Stripe.js e informe o id resultante em CreditCard::$token.' + ); + } + + $stripeSetupIntent = $this->stripeRequest(function () use ($creditCard, $idempotencyKey) { + $paymentMethodId = $this->resolvePaymentMethodId( + $creditCard->token, + self::derivedIdempotencyKey($idempotencyKey, 'payment_method') + ); + + $stripeSetupIntentData = [ + 'customer' => $creditCard->customer->id, + 'payment_method' => $paymentMethodId, + 'payment_method_types' => ['card'], + 'usage' => 'off_session', + 'confirm' => true, + ]; + $stripeSetupIntentData = $this->mergeGatewayOptions($stripeSetupIntentData, $creditCard); + // o metadata do consumidor (gatewayOptions) convive com as chaves do setup + $metadata = array_merge($stripeSetupIntentData['metadata'] ?? [], self::cardSetupMetadata($creditCard)); + if (!empty($metadata)) { + $stripeSetupIntentData['metadata'] = $metadata; + } + + return $this->client->setupIntents->create( + $this->withExpand($stripeSetupIntentData, self::SETUP_INTENT_EXPAND), + self::stripeOptions($idempotencyKey) + ); + }); + + return $this->finishCardSetup($stripeSetupIntent, $creditCard, $idempotencyKey); + } + + /** + * @inheritDoc + * + * Lê o SetupIntent e aplica o desfecho: `succeeded` anexa o cartão ao cliente quando a + * Stripe ainda não o fez, aplica a descrição e a marcação de padrão guardadas em `metadata` + * do setup e devolve o cartão cobrável; `requires_action` devolve o cartão ainda com + * `requiresAction` (o pagador não concluiu a autenticação); os demais estados lançam + * `ChargingException` com o `last_setup_error` quando há um. Um SetupIntent sem cliente + * (criado fora de `createCreditCard()`) é recusado com `ModelAttributeValidationException` + * antes de qualquer escrita. A chave de idempotência vai nas escritas secundárias, + * derivada: `{chave}:attach`, `{chave}:metadata` e `{chave}:default`. + * + * @throws ModelAttributeValidationException + */ + public function confirmCreditCardSetup(string $setupId, ?string $idempotencyKey = null): CreditCard + { + $stripeSetupIntent = $this->stripeRequest(function () use ($setupId) { + return $this->client->setupIntents->retrieve($setupId, ['expand' => self::SETUP_INTENT_EXPAND]); + }); + + $customerId = is_object($stripeSetupIntent->customer) + ? $stripeSetupIntent->customer->id + : $stripeSetupIntent->customer; + if (empty($customerId)) { + throw ModelAttributeValidationException::invalid( + 'CreditCard', + 'setupId', + "SetupIntent [{$setupId}] has no customer; only a setup created by createCreditCard() can be confirmed as a saved card" + ); + } + + $creditCard = new CreditCard(); + $creditCard->customer = new Customer(); + $creditCard->customer->id = $customerId; + + return $this->finishCardSetup($stripeSetupIntent, $creditCard, $idempotencyKey); + } + + /** + * `metadata` do SetupIntent com o que aplicar ao cartão quando o setup conclui: a + * descrição (o PaymentMethod da Stripe não tem campo de descrição) e a marcação de padrão. + * Vazio quando o model não informa nenhum dos dois. + * + * @param \Potelo\MultiPayment\Models\CreditCard $creditCard + * @return array + */ + private static function cardSetupMetadata(CreditCard $creditCard): array + { + $metadata = []; + if (!empty($creditCard->description)) { + $metadata[self::SETUP_METADATA_DESCRIPTION] = $creditCard->description; + } + if (!empty($creditCard->default)) { + $metadata[self::SETUP_METADATA_DEFAULT] = '1'; + } + + return $metadata; + } + + /** + * Converte o SetupIntent, com `payment_method` expandido, no `CreditCard` que a operação + * devolve. `succeeded`: anexa o PaymentMethod ao cliente quando ele voltou sem cliente (a + * Stripe anexa ao confirmar um SetupIntent com cliente, e este anexo só cobre a resposta + * em que isso não aconteceu), grava a descrição em `metadata` do PaymentMethod e o marca + * como padrão do cliente (`default` verdadeiro no model), conforme `metadata` do setup. + * `requires_action`: cartão com + * `requiresAction`, `setupId`, `clientSecret`, `actionUrl` (quando `next_action` é + * `redirect_to_url`), os dados do cartão para exibição e `id` nulo. Os demais estados + * (`requires_payment_method` depois de uma autenticação que falhou, `canceled`, + * `processing`, `requires_confirmation`) lançam `ChargingException`. + * + * @param \Stripe\SetupIntent $stripeSetupIntent + * @param \Potelo\MultiPayment\Models\CreditCard $creditCard model a preencher, com `customer->id` + * @param string|null $idempotencyKey + * @return \Potelo\MultiPayment\Models\CreditCard + * @throws ChargingException|GatewayException|GatewayNotAvailableException + */ + private function finishCardSetup(StripeSetupIntent $stripeSetupIntent, CreditCard $creditCard, ?string $idempotencyKey): CreditCard + { + $stripePaymentMethod = is_object($stripeSetupIntent->payment_method) ? $stripeSetupIntent->payment_method : null; + $metadata = !empty($stripeSetupIntent->metadata) ? $stripeSetupIntent->metadata->toArray() : []; + + if ($stripeSetupIntent->status === StripeSetupIntent::STATUS_SUCCEEDED) { + $stripePaymentMethod = $this->stripeRequest(function () use ($stripeSetupIntent, $stripePaymentMethod, $metadata, $creditCard, $idempotencyKey) { + $paymentMethodId = $stripePaymentMethod?->id ?? $stripeSetupIntent->payment_method; + if (empty($stripePaymentMethod?->customer)) { + $stripePaymentMethod = $this->client->paymentMethods->attach( + $paymentMethodId, + ['customer' => $creditCard->customer->id], + self::stripeOptions(self::derivedIdempotencyKey($idempotencyKey, 'attach')) + ); + } + + if (!empty($metadata[self::SETUP_METADATA_DESCRIPTION])) { + $stripePaymentMethod = $this->client->paymentMethods->update( + $paymentMethodId, + ['metadata' => [self::SETUP_METADATA_DESCRIPTION => $metadata[self::SETUP_METADATA_DESCRIPTION]]], + self::stripeOptions(self::derivedIdempotencyKey($idempotencyKey, 'metadata')) + ); + } + + if (!empty($metadata[self::SETUP_METADATA_DEFAULT])) { + $this->client->customers->update($creditCard->customer->id, [ + 'invoice_settings' => ['default_payment_method' => $paymentMethodId], + ], self::stripeOptions(self::derivedIdempotencyKey($idempotencyKey, 'default'))); + } + + return $stripePaymentMethod; + }); + + $creditCard = $this->parseStripeCard($stripePaymentMethod, $creditCard); + if (!empty($metadata[self::SETUP_METADATA_DEFAULT])) { + $creditCard->default = true; + } + $creditCard->setupId = $stripeSetupIntent->id; + $creditCard->requiresAction = false; + $creditCard->actionUrl = null; + $creditCard->clientSecret = null; + + return $creditCard; + } + + if ($stripeSetupIntent->status === StripeSetupIntent::STATUS_REQUIRES_ACTION) { + $this->fillCardFields($creditCard, $stripePaymentMethod?->card ?? null); + $creditCard->id = null; + $creditCard->description = $metadata[self::SETUP_METADATA_DESCRIPTION] ?? $creditCard->description; + $creditCard->requiresAction = true; + $creditCard->setupId = $stripeSetupIntent->id; + $creditCard->clientSecret = $stripeSetupIntent->client_secret; + $creditCard->actionUrl = $stripeSetupIntent->next_action->redirect_to_url->url ?? null; + $creditCard->gateway = 'stripe'; + $creditCard->original = $stripeSetupIntent; + $creditCard->createdAt = Carbon::createFromTimestamp($stripeSetupIntent->created); + + return $creditCard; + } + + throw $this->cardSetupFailed($stripeSetupIntent); + } + + /** + * `ChargingException` de um SetupIntent que não chegou a `succeeded` nem parou em + * `requires_action`: com `last_setup_error`, a recusa segue a tradução normal do código + * (`setup_intent_authentication_failure` vira `DeclineCode::AUTHENTICATION_REQUIRED`); sem + * ele (setup cancelado, por exemplo), `DeclineCode::UNKNOWN` com `cancellation_reason` ou + * o status em `gatewayCode`. O SetupIntent vai em `chargeResponse` nos dois casos. + * + * @param \Stripe\SetupIntent $stripeSetupIntent + * @return ChargingException + */ + private function cardSetupFailed(StripeSetupIntent $stripeSetupIntent): ChargingException + { + $error = $stripeSetupIntent->last_setup_error ?? null; + if (is_object($error)) { + $exception = $this->declinedFromStripeError( + $error, + (string) ($error->message ?? "SetupIntent {$stripeSetupIntent->id} em {$stripeSetupIntent->status}"), + null, + null + ); + } else { + $exception = ChargingException::declined( + 'stripe', + DeclineCode::UNKNOWN, + $stripeSetupIntent->cancellation_reason ?? $stripeSetupIntent->status, + "SetupIntent {$stripeSetupIntent->id} em {$stripeSetupIntent->status}; o cartão não foi salvo." + ); + } + $exception->chargeResponse = $stripeSetupIntent->toArray(); + + return $exception; + } + + /** + * @inheritDoc + */ + public function getCreditCard(CreditCard $creditCard): CreditCard + { + $stripePaymentMethod = $this->stripeRequest(function () use ($creditCard) { + $stripePaymentMethod = $this->client->paymentMethods->retrieve($creditCard->id); + $this->assertCardBelongsToCustomer($stripePaymentMethod, $creditCard); + + return $stripePaymentMethod; + }); + + return $this->parseStripeCard($stripePaymentMethod, $creditCard); + } + + /** + * @inheritDoc + * + * A chave de idempotência vai no cabeçalho `Idempotency-Key` do detach. Com chave, um cartão + * já sem cliente passa pela checagem de posse, porque pode ter sido desvinculado por uma + * tentativa anterior com a mesma chave. + */ + public function deleteCreditCard(CreditCard $creditCard, ?string $idempotencyKey = null): void + { + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $creditCard); + + $this->stripeRequest(function () use ($creditCard, $idempotencyKey) { + $stripePaymentMethod = $this->client->paymentMethods->retrieve($creditCard->id); + // cartão já sem cliente com chave informada: pode ter sido desvinculado por uma + // tentativa anterior com a mesma chave, que a Stripe repete + if (!empty($stripePaymentMethod->customer) || is_null($idempotencyKey)) { + $this->assertCardBelongsToCustomer($stripePaymentMethod, $creditCard); + } + + return $this->client->paymentMethods->detach($creditCard->id, null, self::stripeOptions($idempotencyKey)); + }); + } + + /** + * Resolve o token do consumidor para um id de PaymentMethod: tokens legados da Stripe + * (tok_...) não são utilizáveis diretamente e viram PaymentMethod antes. + * + * @param string $token + * @param string|null $idempotencyKey chave da criação do PaymentMethod a partir do token + * @return string + * @throws ApiErrorException + */ + private function resolvePaymentMethodId(string $token, ?string $idempotencyKey = null): string + { + if (str_starts_with($token, 'tok_')) { + return $this->client->paymentMethods->create([ + 'type' => 'card', + 'card' => ['token' => $token], + ], self::stripeOptions($idempotencyKey))->id; + } + + return $token; + } + + /** + * Espelha a semântica da Iugu (cartão buscado/excluído via customer): quando o model + * informa o customer, a posse do PaymentMethod é validada antes da operação. + * + * @param \Stripe\PaymentMethod $stripePaymentMethod + * @param \Potelo\MultiPayment\Models\CreditCard $creditCard + * @return void + * @throws UnsupportedOperationException + */ + private function assertCardBelongsToCustomer(StripePaymentMethod $stripePaymentMethod, CreditCard $creditCard): void + { + $customerId = $creditCard->customer->id ?? null; + if (!empty($customerId) && $stripePaymentMethod->customer !== $customerId) { + throw UnsupportedOperationException::restricted( + (string) $this, + Capability::CREDIT_CARD, + "Credit card [{$stripePaymentMethod->id}] does not belong to customer [{$customerId}];" + . ' the Stripe PaymentMethod is bound to one customer.' + ); + } + } + + /** + * Converte o PaymentMethod de cartão da Stripe em um CreditCard do MultiPayment. + * + * @param \Stripe\PaymentMethod $stripePaymentMethod + * @param \Potelo\MultiPayment\Models\CreditCard|null $creditCard + * @return \Potelo\MultiPayment\Models\CreditCard + */ + private function parseStripeCard(StripePaymentMethod $stripePaymentMethod, ?CreditCard $creditCard = null): CreditCard + { + if (is_null($creditCard)) { + $creditCard = new CreditCard(); + } + + $creditCard->id = $stripePaymentMethod->id; + $this->fillCardFields($creditCard, isset($stripePaymentMethod->card) ? $stripePaymentMethod->card : null); + + $metadata = !empty($stripePaymentMethod->metadata) ? $stripePaymentMethod->metadata->toArray() : []; + $creditCard->description = $metadata['description'] ?? $creditCard->description; + + if (!empty($stripePaymentMethod->billing_details?->name)) { + $names = explode(' ', $stripePaymentMethod->billing_details->name); + $creditCard->firstName = $names[array_key_first($names)] ?? null; + $creditCard->lastName = $names[array_key_last($names)] ?? null; + } + + $creditCard->gateway = 'stripe'; + $creditCard->original = $stripePaymentMethod; + $creditCard->createdAt = Carbon::createFromTimestamp($stripePaymentMethod->created); + + return $creditCard; + } + + /** + * Preenche bandeira, últimos dígitos e validade a partir do objeto `card` de um + * PaymentMethod da Stripe; nulos quando o objeto não veio. + * + * @param \Potelo\MultiPayment\Models\CreditCard $creditCard + * @param object|null $card + * @return void + */ + private function fillCardFields(CreditCard $creditCard, ?object $card): void + { + $creditCard->brand = $card->brand ?? null; + $creditCard->lastDigits = $card->last4 ?? null; + $creditCard->month = isset($card->exp_month) + ? str_pad((string) $card->exp_month, 2, '0', STR_PAD_LEFT) + : null; + $creditCard->year = isset($card->exp_year) ? (string) $card->exp_year : null; + } + + /** + * @inheritDoc + * + * O plano vira um par Product e Price recorrente: o Product guarda o nome e o + * identificador (`metadata.identifier`), o Price guarda o valor e o intervalo, e o id do + * Price é o id do plano. O identificador vai também em `lookup_key` do Price, que é como + * `getPlan()` o encontra; identificador repetido é recusado pela Stripe. A chave de + * idempotência vai no cabeçalho `Idempotency-Key` da criação do Price; o Product usa a + * derivada `{chave}:product`. + */ + public function createPlan(Plan $plan, ?string $idempotencyKey = null): Plan + { + if (is_null($plan->interval)) { + throw ModelAttributeValidationException::required('Plan', 'interval'); + } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $plan); + $identifier = $plan->identifier ?? $plan->name; + + $stripeProduct = $this->stripeRequest(function () use ($plan, $identifier, $idempotencyKey) { + return $this->client->products->create([ + 'name' => $plan->name, + 'metadata' => ['identifier' => $identifier], + ], self::stripeOptions(self::derivedIdempotencyKey($idempotencyKey, 'product'))); + }); + + $stripePriceData = [ + 'product' => $stripeProduct->id, + 'unit_amount' => $plan->amount, + 'currency' => strtolower($plan->currency ?? 'brl'), + 'recurring' => [ + 'interval' => $plan->interval->value, + 'interval_count' => $plan->intervalCount ?? 1, + ], + 'lookup_key' => $identifier, + 'nickname' => $plan->name, + ]; + $stripePriceData = $this->mergeGatewayOptions($stripePriceData, $plan); + + $stripePrice = $this->stripeRequest(function () use ($stripePriceData, $idempotencyKey) { + return $this->client->prices->create( + $this->withExpand($stripePriceData, self::PRICE_EXPAND), + self::stripeOptions($idempotencyKey) + ); + }); + + return $this->parseStripePlan($stripePrice, $plan); + } + + /** + * @inheritDoc + * + * Busca pelo `id` (id de Price, prefixo `price_`) ou pelo `identifier` (`lookup_key` do + * Price). Um `identifier` com o prefixo `price_` é tratado como id, o que poupa a segunda + * busca de `MultiPayment::getPlan()`; identificador sem Price correspondente lança + * `NotFoundException`. + */ + public function getPlan(Plan $plan): Plan + { + if (!empty($plan->id)) { + $priceId = $plan->id; + } elseif (!empty($plan->identifier)) { + if (!str_starts_with($plan->identifier, self::STRIPE_PRICE_ID_PREFIX)) { + return $this->parseStripePlan($this->findStripePriceByLookupKey($plan->identifier), $plan); + } + $priceId = $plan->identifier; + } else { + throw ModelAttributeValidationException::required('Plan', 'id or identifier'); + } + + $stripePrice = $this->stripeRequest(function () use ($priceId) { + return $this->client->prices->retrieve($priceId, ['expand' => self::PRICE_EXPAND]); + }); + + return $this->parseStripePlan($stripePrice, $plan); + } + + /** + * @inheritDoc + * + * Lista os Prices recorrentes, ativos e arquivados. A paginação da Stripe é por cursor, + * então uma página além da primeira custa uma requisição por página anterior; página além + * do fim devolve lista vazia. + */ + public function listPlans(int $page = 1, int $limit = 100): array + { + if ($page < 1) { + throw ModelAttributeValidationException::invalid('Plan', 'page', 'Plan page must be at least 1'); + } + + if ($limit < 1 || $limit > 100) { + throw ModelAttributeValidationException::invalid('Plan', 'limit', 'Plan limit must be between 1 and 100'); + } + + $stripePrices = $this->stripeListPage( + fn (array $params) => $this->client->prices->all($params), + ['type' => 'recurring', 'limit' => $limit, 'expand' => ['data.product']], + $page + ); + + return array_map(fn ($stripePrice) => $this->parseStripePlan($stripePrice), $stripePrices); + } + + /** + * @inheritDoc + * + * Arquiva o Price (`active` falso): as assinaturas existentes continuam cobrando e uma + * assinatura nova com esse plano é recusada pela Stripe; o Product fica ativo. A chave de + * idempotência vai no cabeçalho `Idempotency-Key` da atualização. + */ + public function deactivatePlan(Plan $plan, ?string $idempotencyKey = null): Plan + { + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $plan); + + if (!empty($plan->id)) { + $priceId = $plan->id; + } elseif (!empty($plan->identifier)) { + $priceId = $this->resolveStripePriceId($plan->identifier); + } else { + throw ModelAttributeValidationException::required('Plan', 'id or identifier'); + } + + $stripePrice = $this->stripeRequest(function () use ($priceId, $idempotencyKey) { + return $this->client->prices->update( + $priceId, + ['active' => false, 'expand' => self::PRICE_EXPAND], + self::stripeOptions($idempotencyKey) + ); + }); + + return $this->parseStripePlan($stripePrice, $plan); + } + + /** + * Converte um Price da Stripe (com o Product expandido, quando veio) num plano do + * MultiPayment. O id do plano é o do Price; `identifier` vem de `lookup_key`, senão de + * `metadata.identifier` do Product; a moeda volta em maiúsculas, como na leitura da Iugu. + * + * @param \Stripe\Price $stripePrice + * @param Plan|null $plan + * @return Plan + */ + private function parseStripePlan(StripePrice $stripePrice, ?Plan $plan = null): Plan + { + $plan = $plan ?? new Plan(); + $stripeProduct = is_object($stripePrice->product ?? null) ? $stripePrice->product : null; + + $plan->id = $stripePrice->id; + $plan->identifier = $stripePrice->lookup_key + ?? $stripeProduct?->metadata['identifier'] + ?? $plan->identifier; + $plan->name = $stripeProduct?->name ?? $stripePrice->nickname ?? $plan->name; + $plan->amount = $stripePrice->unit_amount ?? $plan->amount; + $plan->interval = PlanInterval::tryFrom($stripePrice->recurring?->interval ?? '') ?? $plan->interval; + $plan->intervalCount = $stripePrice->recurring?->interval_count ?? $plan->intervalCount; + $plan->currency = isset($stripePrice->currency) ? strtoupper($stripePrice->currency) : $plan->currency; + $plan->active = $stripePrice->active ?? $plan->active; + $plan->gateway = 'stripe'; + $plan->original = $stripePrice; + + return $plan; + } + + /** + * Busca o Price de um `lookup_key`, com o Product expandido. A Stripe responde 200 com a + * lista vazia quando o identificador não existe; a lista vazia é traduzida em + * `NotFoundException`, como um 404 seria. + * + * @param string $lookupKey + * @return \Stripe\Price + * @throws NotFoundException|GatewayException|GatewayNotAvailableException + */ + private function findStripePriceByLookupKey(string $lookupKey): StripePrice + { + $stripePrices = $this->stripeRequest(function () use ($lookupKey) { + return $this->client->prices->all([ + 'lookup_keys' => [$lookupKey], + 'limit' => 1, + 'expand' => ['data.product'], + ]); + }); + + $stripePrice = $stripePrices->data[0] ?? null; + if (is_null($stripePrice)) { + throw new NotFoundException("No plan found with identifier [{$lookupKey}] on stripe."); + } + + return $stripePrice; + } + + /** + * Resolve o plano apontado pelo consumidor para um id de Price: um valor com o prefixo + * `price_` já é o id; outro valor é procurado como `lookup_key`. + * + * @param string $planId + * @return string + * @throws NotFoundException|GatewayException|GatewayNotAvailableException + */ + private function resolveStripePriceId(string $planId): string + { + if (str_starts_with($planId, self::STRIPE_PRICE_ID_PREFIX)) { + return $planId; + } + + return $this->findStripePriceByLookupKey($planId)->id; + } + + /** + * Uma página de uma lista da Stripe no modelo página e limite do pacote. A Stripe pagina + * por cursor (`starting_after`), então as páginas anteriores à pedida são percorridas, uma + * requisição por página. Devolve os objetos da página, vazia quando a lista acabou antes. + * + * @param callable $fetch recebe os parâmetros da listagem e devolve a `\Stripe\Collection` + * @param array $params + * @param int $page + * @return array + */ + private function stripeListPage(callable $fetch, array $params, int $page): array + { + for ($current = 1; ; $current++) { + $collection = $this->stripeRequest(fn () => $fetch($params)); + $data = $collection->data ?? []; + if ($current === $page) { + return $data; + } + if (empty($data) || empty($collection->has_more)) { + return []; + } + $params['starting_after'] = end($data)->id; + } + } + + /** + * @inheritDoc + * + * O plano (`planId`) é o `lookup_key` ou o id de um Price. Itens extras viram subscription + * items com Price criado sob demanda no intervalo do plano (um Product e um Price novos + * por item); item com `recurring` falso vai como item avulso da primeira fatura. O cartão + * de `creditCard` vira o `default_payment_method` da assinatura, sem mudar o cartão + * padrão do cliente; cartão sem `id` é salvo antes pelo fluxo de SetupIntent, e um + * emissor que exija autenticação interrompe a criação com `ChargingException` + * (`AUTHENTICATION_REQUIRED`). Com cartão ou sem método informado, a primeira fatura é + * cobrada na criação e a recusa sobe como `ChargingException` (`payment_behavior` + * `error_if_incomplete`); com Pix a assinatura nasce com a primeira fatura em aberto até o + * pagamento (`default_incomplete`), lida em `latestInvoice`, com a página hospedada em + * `url` para o pagador quitar. Com Pix Automático (`paymentMethod` `AUTOMATIC_PIX`) a + * assinatura também nasce com a primeira fatura em aberto e registra o mandato em + * `payment_method_options.pix.mandate_options`, derivado do plano e de + * `Subscription::$automaticPix` (ver `pixMandateOptions()`); o pagador autoriza o mandato + * ao pagar a primeira fatura e a Stripe agenda as cobranças seguintes + * (`MANAGES_RECURRENCE`). Com boleto a assinatura nasce ativa em modo de fatura + * enviada (`send_invoice`, com `days_until_due` de 3 dias, sobrescritível por + * `gatewayOptions['days_until_due']`): a primeira fatura é finalizada na hora e volta em + * `latestInvoice` aberta, com a página hospedada em `url`, onde o pagador gera o voucher; + * as faturas dos ciclos seguintes seguem o mesmo prazo. Cada desconto de `discounts` vira + * um Coupon criado na hora e aplicado à assinatura: `cycles` 1 é `duration` `once`, mais + * de um ciclo ou `validUntil` viram `repeating` com `duration_in_months`, e desconto sem + * prazo é `forever`. Os dias de `trialDays` vão como + * `trial_period_days`, que não muda entre tentativas com a mesma chave de idempotência; + * o model devolvido traz a data em `trialEndsAt` e `trialDays` zerado. `nextBillingAt` + * vira `billing_cycle_anchor`. + * + * A chave de idempotência vai no cabeçalho `Idempotency-Key` da criação; as requisições + * secundárias usam derivadas (`{chave}:card` no cartão salvo antes, + * `{chave}:item{N}_product` no Product de cada item extra, `{chave}:discount{N}_coupon` + * no Coupon de cada desconto, `{chave}:finalize` na finalização da fatura de boleto). + * + * @throws ChargingException|NotFoundException|UnsupportedOperationException + */ + public function createSubscription(Subscription $subscription, ?string $idempotencyKey = null): Subscription + { + $this->assertSupportsAll($subscription->requiredCapabilities()); + if (empty($subscription->customer) || empty($subscription->customer->id)) { + throw ModelAttributeValidationException::required('Subscription', 'customer'); + } + if (empty($subscription->planId)) { + throw ModelAttributeValidationException::required('Subscription', 'planId'); + } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); + + $paymentMethod = $this->subscriptionPaymentMethod($subscription); + $priceId = $this->resolveStripePriceId($subscription->planId); + + $stripeSubscriptionData = [ + 'customer' => $subscription->customer->id, + 'items' => [['price' => $priceId]], + ]; + if ($paymentMethod === PaymentMethod::BANK_SLIP) { + // boleto é assíncrono demais para a cobrança automática na criação (a janela de + // 23 horas de `incomplete` venceria antes da compensação): a assinatura nasce + // ativa e cada ciclo emite uma fatura em aberto com prazo de pagamento + $stripeSubscriptionData['collection_method'] = 'send_invoice'; + $stripeSubscriptionData['days_until_due'] = self::BOLETO_DAYS_UNTIL_DUE; + } else { + $stripeSubscriptionData['collection_method'] = 'charge_automatically'; + // no Pix (avulso ou com mandato) o pagador precisa agir para a primeira fatura + $stripeSubscriptionData['payment_behavior'] = in_array( + $paymentMethod, + [PaymentMethod::PIX, PaymentMethod::AUTOMATIC_PIX], + true + ) ? 'default_incomplete' : 'error_if_incomplete'; + } + + if ($paymentMethod === PaymentMethod::AUTOMATIC_PIX) { + $stripeSubscriptionData['payment_settings'] = [ + 'payment_method_types' => ['pix'], + 'payment_method_options' => [ + 'pix' => ['mandate_options' => $this->pixMandateOptions($subscription, $priceId)], + ], + ]; + } elseif (!is_null($paymentMethod)) { + $stripeSubscriptionData['payment_settings'] = [ + 'payment_method_types' => [self::paymentMethodToStripeType($paymentMethod)], + ]; + } + + $defaultPaymentMethodId = $this->applyStripeSubscriptionCard($subscription, $idempotencyKey); + if (!is_null($defaultPaymentMethodId)) { + $stripeSubscriptionData['default_payment_method'] = $defaultPaymentMethodId; + } + + $trialDays = null; + if (empty($subscription->trialEndsAt) && !empty($subscription->trialDays)) { + $trialDays = $subscription->trialDays; + $stripeSubscriptionData['trial_period_days'] = $trialDays; + } elseif (!empty($subscription->trialEndsAt)) { + $stripeSubscriptionData['trial_end'] = $subscription->trialEndsAt->getTimestamp(); + } + + if (!empty($subscription->nextBillingAt)) { + $stripeSubscriptionData['billing_cycle_anchor'] = $subscription->nextBillingAt->getTimestamp(); + } + + $itemsData = $this->subscriptionItemsData($subscription->items ?? [], $priceId, $idempotencyKey); + $stripeSubscriptionData['items'] = array_merge($stripeSubscriptionData['items'], $itemsData['items']); + if (!empty($itemsData['add_invoice_items'])) { + $stripeSubscriptionData['add_invoice_items'] = $itemsData['add_invoice_items']; + } + + if (!empty($subscription->discounts)) { + $stripeSubscriptionData['discounts'] = $this->stripeSubscriptionDiscountsData( + $subscription->discounts, + fn () => $this->stripePriceRecurring($priceId), + $idempotencyKey + ); + } + + if (!empty($subscription->metadata)) { + $stripeSubscriptionData['metadata'] = $subscription->metadata; + } + + $stripeSubscriptionData = $this->mergeGatewayOptions($stripeSubscriptionData, $subscription); + + $stripeSubscription = $this->stripeRequest(function () use ($stripeSubscriptionData, $idempotencyKey) { + return $this->client->subscriptions->create( + $this->withExpand($stripeSubscriptionData, self::SUBSCRIPTION_EXPAND), + self::stripeOptions($idempotencyKey) + ); + }); + + if ($paymentMethod === PaymentMethod::BANK_SLIP) { + $this->finalizeFirstBoletoInvoice($stripeSubscription, self::derivedIdempotencyKey($idempotencyKey, 'finalize')); + } + + if (!is_null($trialDays)) { + $subscription->trialDays = null; + } + + return $this->parseStripeSubscription($stripeSubscription, $subscription, true); + } + + /** + * Finaliza a primeira fatura de uma assinatura de boleto: no modo `send_invoice` ela nasce + * rascunho, sem página hospedada, e a Stripe só a finalizaria sozinha cerca de uma hora + * depois. Sem fatura na assinatura (trial), o método não faz nada. Num retry com a mesma + * chave de idempotência a Stripe repete a finalização original. + * + * @param \Stripe\Subscription $stripeSubscription + * @param string|null $idempotencyKey + * @return void + * @throws GatewayException|GatewayNotAvailableException + */ + private function finalizeFirstBoletoInvoice(StripeSubscription $stripeSubscription, ?string $idempotencyKey): void + { + $latestInvoiceId = is_object($stripeSubscription->latest_invoice ?? null) + ? $stripeSubscription->latest_invoice->id + : ($stripeSubscription->latest_invoice ?? null); + if (empty($latestInvoiceId)) { + return; + } + + $this->stripeRequest(function () use ($latestInvoiceId, $idempotencyKey) { + return $this->client->invoices->finalizeInvoice( + $latestInvoiceId, + [], + self::stripeOptions($idempotencyKey) + ); + }); + } + + /** + * @inheritDoc + * + * `latestInvoice` volta lido por inteiro (`parseFromStripeInvoice()`), o que custa a + * leitura da fatura além da assinatura. + */ + public function getSubscription(Subscription $subscription): Subscription + { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + + $stripeSubscription = $this->stripeRequest(function () use ($subscription) { + return $this->client->subscriptions->retrieve( + $subscription->id, + ['expand' => self::SUBSCRIPTION_EXPAND] + ); + }); + + return $this->parseStripeSubscription($stripeSubscription, $subscription, true); + } + + /** + * @inheritDoc + * + * Escreve o cartão (`default_payment_method`), o método de pagamento + * (`payment_settings`; a troca para boleto muda a assinatura para o modo de fatura enviada + * com prazo de 3 dias, e a troca de boleto para outro método a devolve à cobrança + * automática), o trial (`trial_end`; os dias de `trialDays` viram a data agora), + * `metadata` e os itens. `nextBillingAt` diferente do que veio do gateway é recusado: a + * Stripe não aceita mudar a data da próxima cobrança fora do ciclo. Nos itens, item novo + * cria Price sob demanda, item com `id` tem a quantidade atualizada e mantém o Price, e a + * troca não gera pró-rata (`proration_behavior` `none`). Nos descontos, a lista informada + * substitui a da assinatura: desconto com `id` mantém o Coupon, desconto novo cria um, e + * lista vazia remove todos. A chave de idempotência vai no cabeçalho da atualização e, + * derivada, nas requisições que a antecedem (`{chave}:card`, `{chave}:item{N}_product`, + * `{chave}:discount{N}_coupon`). + * + * @throws ChargingException|UnsupportedOperationException + */ + public function updateSubscription(Subscription $subscription, ?string $idempotencyKey = null): Subscription + { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); + + $data = []; + + // o método é validado antes de o cartão ser salvo, para a recusa (boleto, mais de um + // método, cartão fora da lista) não deixar um cartão anexado ao cliente + $paymentMethod = $this->subscriptionPaymentMethod($subscription); + + $defaultPaymentMethodId = $this->applyStripeSubscriptionCard($subscription, $idempotencyKey); + if (!is_null($defaultPaymentMethodId)) { + $data['default_payment_method'] = $defaultPaymentMethodId; + } + + // o mandato de Pix Automático é registrado na criação: numa assinatura que já o tem, + // o método lido do gateway não é uma troca (e sair dele exige encerrar o mandato); + // sem ele, a troca para o método ainda não é suportada + $originalHasPixMandate = is_object( + $subscription->original->payment_settings->payment_method_options->pix->mandate_options ?? null + ); + if ($paymentMethod === PaymentMethod::AUTOMATIC_PIX) { + if (!$originalHasPixMandate) { + throw UnsupportedOperationException::notImplemented( + (string) $this, + Capability::AUTOMATIC_PIX, + 'A lib só registra o mandato de Pix Automático na criação da assinatura.' + ); + } + } elseif (!is_null($paymentMethod) && $originalHasPixMandate) { + // sem a recusa, a comparação com os types originais (['pix']) engoliria a troca + // em silêncio e o mandato continuaria valendo + throw UnsupportedOperationException::notImplemented( + (string) $this, + Capability::AUTOMATIC_PIX, + 'A lib não implementa trocar o método de uma assinatura com mandato de Pix' + . ' Automático; cancele a assinatura e crie outra com o método desejado.' + ); + } elseif (!is_null($paymentMethod) && !$this->isOriginalStripePaymentMethod($subscription, $paymentMethod)) { + $data['payment_settings'] = [ + 'payment_method_types' => [self::paymentMethodToStripeType($paymentMethod)], + ]; + // a troca de método muda também o modo de cobrança: boleto exige fatura enviada + // com prazo (send_invoice), os demais cobram automaticamente. O modo vai sempre + // que o método muda, sem depender do estado lido do gateway: num model fresco + // (só o id) o driver não sabe o modo atual, e reescrever o mesmo modo na Stripe + // não tem efeito + if ($paymentMethod === PaymentMethod::BANK_SLIP) { + $data['collection_method'] = 'send_invoice'; + $data['days_until_due'] = self::BOLETO_DAYS_UNTIL_DUE; + } else { + $data['collection_method'] = 'charge_automatically'; + } + } + + // a atualização só aceita a data do fim do trial, então os dias viram a data agora + if (empty($subscription->trialEndsAt) && !empty($subscription->trialDays)) { + $subscription->trialEndsAt = Carbon::now()->addDays($subscription->trialDays); + $subscription->trialDays = null; + } + if (!empty($subscription->trialEndsAt) && !$this->isOriginalStripeTrialEnd($subscription)) { + $data['trial_end'] = $subscription->trialEndsAt->getTimestamp(); + } + + $this->assertNextBillingAtIsUnchanged($subscription); + + if (!is_null($subscription->items)) { + $declared = $this->declarativeStripeItems($subscription, $idempotencyKey); + if (!empty($declared['items'])) { + $data['items'] = $declared['items']; + $data['proration_behavior'] = 'none'; + } + if (!empty($declared['add_invoice_items'])) { + $data['add_invoice_items'] = $declared['add_invoice_items']; + } + } + + if (!is_null($subscription->discounts) && !$this->isOriginalStripeDiscounts($subscription)) { + // lista vazia remove todos os descontos; a Stripe limpa o campo com string vazia + $data['discounts'] = empty($subscription->discounts) + ? '' + : $this->stripeSubscriptionDiscountsData( + $subscription->discounts, + fn () => $this->stripePlanRecurringForUpdate($subscription), + $idempotencyKey + ); + } + + if (!empty($subscription->metadata)) { + $data['metadata'] = $subscription->metadata; + } + + $data = $this->mergeGatewayOptions($data, $subscription); + + $stripeSubscription = $this->stripeRequest(function () use ($subscription, $data, $idempotencyKey) { + return $this->client->subscriptions->update( + $subscription->id, + $this->withExpand($data, self::SUBSCRIPTION_EXPAND), + self::stripeOptions($idempotencyKey) + ); + }); + + return $this->parseStripeSubscription($stripeSubscription, $subscription); + } + + /** + * @inheritDoc + * + * Pausa a cobrança (`pause_collection` com `behavior` `void`): a assinatura continua + * existindo na Stripe e lê como `PAUSED` nesta lib, e as faturas dos ciclos pausados são + * anuladas. A chave de idempotência vai no cabeçalho `Idempotency-Key` da atualização. + */ + public function suspendSubscription(Subscription $subscription, ?string $idempotencyKey = null): Subscription + { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); + + $stripeSubscription = $this->stripeRequest(function () use ($subscription, $idempotencyKey) { + return $this->client->subscriptions->update( + $subscription->id, + $this->withExpand(['pause_collection' => ['behavior' => 'void']], self::SUBSCRIPTION_EXPAND), + self::stripeOptions($idempotencyKey) + ); + }); + + return $this->parseStripeSubscription($stripeSubscription, $subscription); + } + + /** + * @inheritDoc + * + * Desfaz a pausa (`pause_collection`) e o cancelamento agendado (`cancel_at_period_end`) + * numa única atualização. Assinatura cancelada de vez (`CANCELED`) não volta na Stripe: a + * atualização é recusada pelo gateway e chega como `ValidationException`. A chave de + * idempotência vai no cabeçalho `Idempotency-Key` da atualização. + */ + public function resumeSubscription(Subscription $subscription, ?string $idempotencyKey = null): Subscription + { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); + + $stripeSubscription = $this->stripeRequest(function () use ($subscription, $idempotencyKey) { + return $this->client->subscriptions->update( + $subscription->id, + $this->withExpand([ + 'pause_collection' => '', + 'cancel_at_period_end' => false, + ], self::SUBSCRIPTION_EXPAND), + self::stripeOptions($idempotencyKey) + ); + }); + + return $this->parseStripeSubscription($stripeSubscription, $subscription); + } + + /** + * @inheritDoc + * + * Sem `atPeriodEnd`, cancela na hora (a assinatura lê como `CANCELED` e não volta). Com + * `atPeriodEnd`, grava `cancel_at_period_end`: a assinatura segue ativa até o fim do + * período pago e o model volta com `cancelAtPeriodEnd` e `canceledAt` preenchidos; + * `resumeSubscription()` desfaz. A chave de idempotência vai no cabeçalho das duas formas + * (no cancelamento imediato, um `DELETE`, a Stripe a ignora). + */ + public function cancelSubscription( + Subscription $subscription, + bool $atPeriodEnd = false, + ?string $idempotencyKey = null + ): Subscription { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); + + $stripeSubscription = $this->stripeRequest(function () use ($subscription, $atPeriodEnd, $idempotencyKey) { + if ($atPeriodEnd) { + return $this->client->subscriptions->update( + $subscription->id, + $this->withExpand(['cancel_at_period_end' => true], self::SUBSCRIPTION_EXPAND), + self::stripeOptions($idempotencyKey) + ); + } + + return $this->client->subscriptions->cancel( + $subscription->id, + ['expand' => self::SUBSCRIPTION_EXPAND], + self::stripeOptions($idempotencyKey) + ); + }); + + return $this->parseStripeSubscription($stripeSubscription, $subscription); + } + + /** + * @inheritDoc + * + * A troca escreve o Price novo no item do plano. `CHARGE_DIFFERENCE` vai como + * `always_invoice` (a pró-rata é faturada e cobrada na hora, e a fatura volta em + * `latestInvoice`); `NONE` como `none`; `CREDIT` como `create_prorations` (crédito e + * cobrança proporcionais ficam para a próxima fatura). `nextBillingAt` diferente do que + * veio do gateway é recusado: a Stripe não aceita mudar a data da próxima cobrança na + * troca. A chave de idempotência vai no cabeçalho da atualização; a leitura que acha o + * item do plano não a usa. + * + * @throws NotFoundException|UnsupportedOperationException + */ + public function changeSubscriptionPlan( + Subscription $subscription, + string $planId, + ProrationBehavior|bool $proration = ProrationBehavior::CHARGE_DIFFERENCE, + ?string $idempotencyKey = null, + ?bool $charge = null + ): Subscription { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + $proration = ProrationBehavior::resolve($charge ?? $proration); + if (!is_null($proration->requiredCapability())) { + $this->assertSupports($proration->requiredCapability()); + } + $this->assertNextBillingAtIsUnchanged($subscription); + $idempotencyKey = $this->idempotencyKeyFor($idempotencyKey, $subscription); + + $priceId = $this->resolveStripePriceId($planId); + $planItem = $this->currentStripePlanItem($subscription); + + $stripeSubscription = $this->stripeRequest(function () use ($subscription, $planItem, $priceId, $proration, $idempotencyKey) { + return $this->client->subscriptions->update( + $subscription->id, + $this->withExpand([ + 'items' => [['id' => $planItem->id, 'price' => $priceId]], + 'proration_behavior' => ProrationBehaviors::toStripe($proration), + ], self::SUBSCRIPTION_EXPAND), + self::stripeOptions($idempotencyKey) + ); + }); + + $subscription->planId = $planId; + + return $this->parseStripeSubscription( + $stripeSubscription, + $subscription, + $proration === ProrationBehavior::CHARGE_DIFFERENCE + ); + } + + /** + * @inheritDoc + * + * Usa a prévia de fatura da Stripe (`invoices.create_preview`) com o item do plano + * apontando o Price novo e `always_invoice`, o mesmo fluxo de + * `ProrationBehavior::CHARGE_DIFFERENCE`; as linhas voltam reais em `items`, com o + * crédito do período não usado em `price` negativo. `effectiveAt` é o fim de período da + * linha mais distante, quando acontece a próxima cobrança normal; `appliesImmediately` é + * verdadeiro, porque a Stripe aplica o plano novo na hora, independente do pagamento. + * Além da prévia, custa a leitura da assinatura (o item do plano) e, quando `planId` é um + * identificador, a busca do Price. + */ + public function previewSubscriptionPlanChange(Subscription $subscription, string $planId): SubscriptionPlanChange + { + if (empty($subscription->id)) { + throw ModelAttributeValidationException::required('Subscription', 'id'); + } + + $priceId = $this->resolveStripePriceId($planId); + $planItem = $this->currentStripePlanItem($subscription); + + $preview = $this->stripeRequest(function () use ($subscription, $planItem, $priceId) { + return $this->client->invoices->createPreview([ + 'subscription' => $subscription->id, + 'subscription_details' => [ + 'items' => [['id' => $planItem->id, 'price' => $priceId]], + 'proration_behavior' => 'always_invoice', + ], + ]); + }); + + $planChange = new SubscriptionPlanChange(); + $planChange->amount = $preview->total ?? null; + $items = []; + $effectiveAt = null; + foreach ($preview->lines->data ?? [] as $line) { + $invoiceItem = new InvoiceItem(); + $invoiceItem->description = $line->description ?? null; + $invoiceItem->quantity = isset($line->quantity) ? (int) $line->quantity : 1; + // usa o valor total da linha, que carrega o sinal: a linha de crédito vem negativa + $amount = isset($line->amount) ? (int) $line->amount : null; + $invoiceItem->price = $amount; + if (!is_null($amount) && $invoiceItem->quantity > 1) { + if ($amount % $invoiceItem->quantity === 0) { + $invoiceItem->price = intdiv($amount, $invoiceItem->quantity); + } else { + // valor que não divide pela quantidade vira uma linha de valor total, + // para a soma dos itens continuar igual a amount + $invoiceItem->quantity = 1; + } + } + $items[] = $invoiceItem; + + $periodEnd = $line->period->end ?? null; + if (!empty($periodEnd) && (is_null($effectiveAt) || $periodEnd > $effectiveAt)) { + $effectiveAt = $periodEnd; + } + } + $planChange->items = $items; + if (!is_null($effectiveAt)) { + $planChange->effectiveAt = Carbon::createFromTimestamp($effectiveAt); + } + $planChange->appliesImmediately = true; + $planChange->gateway = 'stripe'; + $planChange->original = $preview; + + return $planChange; + } + + /** + * @inheritDoc + * + * Traz assinaturas em qualquer status (`status` `all`), sem `latestInvoice` (use + * `getSubscription()` para a fatura). A paginação da Stripe é por cursor, então uma + * página além da primeira custa uma requisição por página anterior. + */ + public function listSubscriptions(Customer $customer, int $page = 1, int $limit = 100): array + { + if (empty($customer->id)) { + throw ModelAttributeValidationException::required('Customer', 'id'); + } + + if ($page < 1) { + throw ModelAttributeValidationException::invalid('Subscription', 'page', 'Subscription page must be at least 1'); + } + + if ($limit < 1 || $limit > 100) { + throw ModelAttributeValidationException::invalid('Subscription', 'limit', 'Subscription limit must be between 1 and 100'); + } + + $stripeSubscriptions = $this->stripeListPage( + fn (array $params) => $this->client->subscriptions->all($params), + [ + 'customer' => $customer->id, + 'status' => 'all', + 'limit' => $limit, + 'expand' => ['data.default_payment_method', 'data.discounts.source.coupon'], + ], + $page + ); + + return array_map( + fn ($stripeSubscription) => $this->parseStripeSubscription($stripeSubscription), + $stripeSubscriptions + ); + } + + /** + * Resolve o único método de pagamento da assinatura, como `invoicePaymentMethod()` faz + * para a fatura; nulo quando o model não aponta método (a Stripe cobra o método padrão do + * cliente). Pix Automático é aceito (o mandato da assinatura); mais de um método é + * recusado (`MULTIPLE_PAYMENT_METHODS`), e um método fora do mapa do driver é recusado + * pela capability dele. + * + * @param Subscription $subscription + * @return PaymentMethod|null + * @throws ModelAttributeValidationException|UnsupportedOperationException + */ + private function subscriptionPaymentMethod(Subscription $subscription): ?PaymentMethod + { + $methods = $subscription->resolvedPaymentMethods(); + + if (count($methods) > 1) { + throw UnsupportedOperationException::forGateway( + $this, + Capability::MULTIPLE_PAYMENT_METHODS, + 'Informe exatamente um método em availablePaymentMethods.' + ); + } + + $method = empty($methods) ? null : reset($methods); + if ( + !is_null($method) + && $method !== PaymentMethod::AUTOMATIC_PIX + && !in_array($method, self::PAYMENT_METHOD_TYPES, true) + ) { + throw UnsupportedOperationException::forGateway($this, Capability::forPaymentMethod($method)); + } + + return $method; + } + + /** + * Tipo de PaymentMethod da Stripe para um método do pacote (o inverso de + * `PAYMENT_METHOD_TYPES`). + * + * @param PaymentMethod $paymentMethod + * @return string + */ + private static function paymentMethodToStripeType(PaymentMethod $paymentMethod): string + { + return (string) array_search($paymentMethod, self::PAYMENT_METHOD_TYPES, true); + } + + /** + * Payload de `discounts` da assinatura: desconto com `id` mantém o Coupon existente; + * desconto novo cria um Coupon (`{chave}:discount{N}_coupon`) com a duração derivada de + * `cycles` e `validUntil`. + * + * @param SubscriptionDiscount[] $discounts + * @param callable $planRecurring devolve `{interval, interval_count}` do plano, lido sob demanda + * @param string|null $idempotencyKey + * @return array + * @throws ModelAttributeValidationException|UnsupportedOperationException + */ + private function stripeSubscriptionDiscountsData(array $discounts, callable $planRecurring, ?string $idempotencyKey): array + { + $data = []; + foreach (array_values($discounts) as $index => $discount) { + if (!empty($discount->id)) { + $data[] = ['coupon' => $discount->id]; + continue; + } + + $couponData = $this->stripeCouponData($discount, $planRecurring); + $stripeCoupon = $this->stripeRequest(function () use ($couponData, $index, $idempotencyKey) { + return $this->client->coupons->create( + $couponData, + self::stripeOptions(self::derivedIdempotencyKey($idempotencyKey, "discount{$index}_coupon")) + ); + }); + + $data[] = ['coupon' => $stripeCoupon->id]; + } + + return $data; + } + + /** + * Payload de um Coupon a partir de um desconto de assinatura: `percent_off` ou + * `amount_off` em `brl`, e a duração de `stripeCouponDuration()`. + * + * @param SubscriptionDiscount $discount + * @param callable $planRecurring devolve `{interval, interval_count}` do plano, lido sob demanda + * @return array + * @throws ModelAttributeValidationException|UnsupportedOperationException + */ + private function stripeCouponData(SubscriptionDiscount $discount, callable $planRecurring): array + { + if (empty($discount->description)) { + throw ModelAttributeValidationException::required('SubscriptionDiscount', 'description'); + } + if (is_null($discount->percentOff) && is_null($discount->amountOff)) { + throw ModelAttributeValidationException::required('SubscriptionDiscount', 'amountOff or percentOff'); + } + + $couponData = ['name' => $discount->description]; + if (!is_null($discount->percentOff)) { + $couponData['percent_off'] = $discount->percentOff; + } else { + $couponData['amount_off'] = $discount->amountOff; + $couponData['currency'] = 'brl'; + } + + return array_merge($couponData, $this->stripeCouponDuration($discount, $planRecurring)); + } + + /** + * Duração do Coupon: `cycles` 1 é `once`; desconto sem prazo é `forever`; `validUntil` e + * `cycles` acima de 1 viram `repeating` com `duration_in_months` (meses até `validUntil`, + * arredondados para cima, ou os meses de `cycles` ciclos do plano). `validUntil` no + * passado é recusado com `ModelAttributeValidationException`, e plano com intervalo + * fora de mês e ano com `cycles` acima de 1 é recusado, porque a duração do Coupon só + * conta em meses. + * + * @param SubscriptionDiscount $discount + * @param callable $planRecurring devolve `{interval, interval_count}` do plano, lido sob demanda + * @return array + * @throws ModelAttributeValidationException|UnsupportedOperationException + */ + private function stripeCouponDuration(SubscriptionDiscount $discount, callable $planRecurring): array + { + if ($discount->cycles === 1) { + return ['duration' => 'once']; + } + + if (!empty($discount->validUntil)) { + $now = Carbon::now(); + if ($discount->validUntil->lessThanOrEqualTo($now)) { + throw ModelAttributeValidationException::invalid( + 'SubscriptionDiscount', + 'validUntil', + 'validUntil must be a future date to create the coupon.' + ); + } + + // diffInMonths trunca em algumas versões do Carbon; o mês parcial conta inteiro + $months = (int) $now->diffInMonths($discount->validUntil); + if ($now->copy()->addMonths($months)->lessThan($discount->validUntil)) { + $months++; + } + + return ['duration' => 'repeating', 'duration_in_months' => max(1, $months)]; + } + + if (is_null($discount->cycles)) { + return ['duration' => 'forever']; + } + + $recurring = $planRecurring(); + $monthsPerCycle = match ($recurring['interval'] ?? null) { + 'month' => (int) ($recurring['interval_count'] ?? 1), + 'year' => 12 * (int) ($recurring['interval_count'] ?? 1), + default => throw UnsupportedOperationException::restricted( + (string) $this, + Capability::COUPONS, + 'O cupom da Stripe dura meses inteiros, então cycles acima de 1 exige plano com' + . ' intervalo mensal ou anual; use validUntil.' + ), + }; + + return ['duration' => 'repeating', 'duration_in_months' => $discount->cycles * $monthsPerCycle]; + } + + /** + * Intervalo de cobrança do plano de uma assinatura existente, no formato de + * `price_data.recurring`: lido de `original` quando a assinatura veio do gateway, senão do + * item do plano na Stripe (uma leitura). + * + * @param Subscription $subscription + * @return array{interval: string|null, interval_count: int} + * @throws GatewayException|NotFoundException + */ + private function stripePlanRecurringForUpdate(Subscription $subscription): array + { + $stripeItems = $subscription->original->items->data ?? null; + $planItem = is_null($stripeItems) + ? $this->currentStripePlanItem($subscription) + : self::stripePlanItem((array) $stripeItems, $subscription->planId); + $recurring = $planItem->price->recurring ?? null; + + return [ + 'interval' => $recurring->interval ?? null, + 'interval_count' => (int) ($recurring->interval_count ?? 1), + ]; + } + + /** + * Diz se os descontos informados são os mesmos que vieram do gateway na leitura: todos com + * `id` e na mesma ordem dos Coupons da assinatura. + * + * @param Subscription $subscription + * @return bool + */ + private function isOriginalStripeDiscounts(Subscription $subscription): bool + { + $original = $subscription->original->discounts ?? null; + if (!is_array($original) && !$original instanceof \Traversable) { + return false; + } + + $originalCoupons = []; + foreach ($original as $stripeDiscount) { + $coupon = is_object($stripeDiscount) ? ($stripeDiscount->source->coupon ?? null) : null; + $originalCoupons[] = is_object($coupon) ? ($coupon->id ?? null) : (is_string($coupon) ? $coupon : null); + } + + $modelCoupons = array_map( + static fn (SubscriptionDiscount $discount) => $discount->id, + array_values($subscription->discounts) + ); + + return $modelCoupons === $originalCoupons && !in_array(null, $modelCoupons, true); + } + + /** + * Converte um discount da Stripe (com o Coupon expandido em `source.coupon`) num desconto + * de assinatura. O `id` é o do Coupon; `cycles` volta 1 para `once` e nulo nos demais + * casos, e a duração `repeating` traz o fim em `validUntil`. + * + * @param object $stripeDiscount + * @return SubscriptionDiscount + */ + private function parseStripeSubscriptionDiscount(object $stripeDiscount): SubscriptionDiscount + { + $coupon = is_object($stripeDiscount->source->coupon ?? null) ? $stripeDiscount->source->coupon : null; + + $discount = new SubscriptionDiscount(); + $discount->id = $coupon->id ?? null; + $discount->description = $coupon->name ?? null; + $discount->amountOff = isset($coupon->amount_off) ? (int) $coupon->amount_off : null; + $discount->percentOff = isset($coupon->percent_off) ? (float) $coupon->percent_off : null; + $discount->cycles = ($coupon->duration ?? null) === 'once' ? 1 : null; + $discount->validUntil = !empty($stripeDiscount->end ?? null) + ? Carbon::createFromTimestamp($stripeDiscount->end) + : null; + + return $discount; + } + + /** + * Cartão que a assinatura cobra: devolve o id do PaymentMethod para + * `default_payment_method`. Cartão sem `id` é salvo antes por `createCreditCard()` (chave + * derivada `{chave}:card`); um emissor que exija autenticação do pagador interrompe a + * operação com `ChargingException` (`AUTHENTICATION_REQUIRED`), com o SetupIntent em + * `chargeResponse`; conclua com `confirmCreditCardSetup()` e use o id do cartão salvo. O + * cartão padrão do cliente não muda (na Iugu muda, porque lá a assinatura não tem cartão + * próprio). Sem cartão no model devolve nulo. + * + * @param Subscription $subscription + * @param string|null $idempotencyKey + * @return string|null + * @throws ChargingException|ModelAttributeValidationException + */ + private function applyStripeSubscriptionCard(Subscription $subscription, ?string $idempotencyKey): ?string + { + $creditCard = $subscription->creditCard; + if (empty($creditCard)) { + return null; + } + if (!empty($creditCard->id)) { + return $creditCard->id; + } + + if (empty($creditCard->customer)) { + $creditCard->customer = $subscription->customer; + } + $subscription->creditCard = $this->createCreditCard( + $creditCard, + self::derivedIdempotencyKey($idempotencyKey, 'card') + ); + if ($subscription->creditCard->requiresAction) { + $exception = ChargingException::declined( + 'stripe', + DeclineCode::AUTHENTICATION_REQUIRED, + 'authentication_required', + 'O emissor exige autenticação do pagador para este cartão; salve-o com createCreditCard(),' + . ' conclua a autenticação com confirmCreditCardSetup() e use o id do cartão salvo na assinatura.' + ); + $exception->chargeResponse = $subscription->creditCard->original?->toArray(); + + throw $exception; + } + + return $subscription->creditCard->id; + } + + /** + * Payload dos itens extras da assinatura: item com `recurring` verdadeiro vira um + * subscription item com Price recorrente criado sob demanda no intervalo do plano; item + * com `recurring` falso vira um item avulso da primeira fatura (`add_invoice_items`). + * Cada item cria um Product no Stripe (`price_data` exige um Product existente), com a + * chave derivada `{chave}:item{N}_product`. + * + * @param SubscriptionItem[] $items + * @param string $planPriceId + * @param string|null $idempotencyKey + * @return array{items: array, add_invoice_items: array} + * @throws ModelAttributeValidationException + */ + private function subscriptionItemsData(array $items, string $planPriceId, ?string $idempotencyKey): array + { + $data = ['items' => [], 'add_invoice_items' => []]; + $recurring = null; + + foreach (array_values($items) as $index => $item) { + if (empty($item->description)) { + throw ModelAttributeValidationException::required('SubscriptionItem', 'description'); + } + if (is_null($item->amount)) { + throw ModelAttributeValidationException::required('SubscriptionItem', 'amount'); + } + + $stripeProduct = $this->stripeRequest(function () use ($item, $index, $idempotencyKey) { + return $this->client->products->create( + ['name' => $item->description], + self::stripeOptions(self::derivedIdempotencyKey($idempotencyKey, "item{$index}_product")) + ); + }); + + $priceData = [ + 'currency' => 'brl', + 'product' => $stripeProduct->id, + 'unit_amount' => $item->amount, + ]; + + if ($item->recurring) { + // todo item recorrente precisa do mesmo intervalo de cobrança do plano + $recurring = $recurring ?? $this->stripePriceRecurring($planPriceId); + $priceData['recurring'] = $recurring; + $data['items'][] = ['price_data' => $priceData, 'quantity' => $item->quantity ?? 1]; + } else { + $data['add_invoice_items'][] = ['price_data' => $priceData, 'quantity' => $item->quantity ?? 1]; + } + } + + return $data; + } + + /** + * Intervalo de cobrança de um Price, no formato de `price_data.recurring`. + * + * @param string $priceId + * @return array{interval: string, interval_count: int} + */ + private function stripePriceRecurring(string $priceId): array + { + $stripePrice = $this->stripeRequest(function () use ($priceId) { + return $this->client->prices->retrieve($priceId); + }); + + return [ + 'interval' => $stripePrice->recurring->interval ?? 'month', + 'interval_count' => $stripePrice->recurring->interval_count ?? 1, + ]; + } + + /** + * Monta o `mandate_options` do Pix Automático de uma assinatura, a partir do plano e de + * `Subscription::$automaticPix`. A agenda (`payment_schedule`) vem da frequência informada + * em `automaticPix` ou do intervalo do plano (`PIX_MANDATE_SCHEDULES`); intervalo sem + * agenda é recusado antes da requisição. O valor é a soma do plano com os itens + * recorrentes; com desconto na assinatura o débito varia entre ciclos e o valor vira um + * teto (`amount_type` `maximum`). O `start_date` vem de `automaticPix->startsAt`, senão do + * fim do trial ou de `nextBillingAt`, com o mínimo de três dias a partir de hoje (data + * derivada anterior ao mínimo é elevada a ele; data informada anterior é recusada). O + * `end_date` vem de `automaticPix->endsAt` e o `reference` (nome exibido no aplicativo do + * banco) da configuração `multi-payment.gateways.stripe.pix_mandate_reference`. + * + * @param Subscription $subscription + * @param string $priceId + * @return array + * @throws ModelAttributeValidationException|UnsupportedOperationException + * @throws GatewayException|GatewayNotAvailableException + */ + private function pixMandateOptions(Subscription $subscription, string $priceId): array + { + $automaticPix = $subscription->automaticPix; + $stripePrice = $this->stripeRequest(function () use ($priceId) { + return $this->client->prices->retrieve($priceId); + }); + + $schedule = $automaticPix?->frequency; + if (is_null($schedule)) { + $interval = ($stripePrice->recurring->interval ?? 'month') + . ':' . ($stripePrice->recurring->interval_count ?? 1); + $schedule = self::PIX_MANDATE_SCHEDULES[$interval] ?? null; + if (is_null($schedule)) { + throw UnsupportedOperationException::restricted( + (string) $this, + Capability::AUTOMATIC_PIX, + 'O Pix Automático aceita agenda semanal, mensal, trimestral, semestral ou anual,' + . " e o intervalo do plano [{$interval}] não corresponde a nenhuma delas;" + . ' informe a frequência em automaticPix.' + ); + } + } + + if (is_null($stripePrice->unit_amount ?? null)) { + throw UnsupportedOperationException::restricted( + (string) $this, + Capability::AUTOMATIC_PIX, + 'O mandato de Pix Automático precisa do valor por ciclo, e o Price do plano' + . " [{$priceId}] não tem unit_amount fixo (preço por camadas ou por uso);" + . ' use um plano de valor fixo.' + ); + } + $amount = (int) $stripePrice->unit_amount; + foreach ($subscription->items ?? [] as $item) { + if ($item instanceof SubscriptionItem && $item->recurring && !is_null($item->amount)) { + $amount += (int) $item->amount * (int) ($item->quantity ?? 1); + } + } + + $minimumStart = Carbon::now()->addDays(self::PIX_MANDATE_DEBIT_OFFSET_DAYS)->startOfDay(); + // as datas derivadas usam o início do dia: dentro do mesmo dia, o retry com a mesma + // chave de idempotência reproduz o payload + $startsAt = $automaticPix?->startsAt + ?? $subscription->trialEndsAt + ?? (!empty($subscription->trialDays) ? Carbon::now()->addDays($subscription->trialDays)->startOfDay() : null) + ?? $subscription->nextBillingAt; + if (!is_null($automaticPix?->startsAt) && $automaticPix->startsAt->lt($minimumStart)) { + throw ModelAttributeValidationException::invalid( + 'AutomaticPix', + 'startsAt', + 'startsAt must be at least ' . self::PIX_MANDATE_DEBIT_OFFSET_DAYS + . ' days from now for automatic pix on the stripe gateway' + ); + } + if (is_null($startsAt) || $startsAt->lt($minimumStart)) { + $startsAt = $minimumStart; + } + + $mandateOptions = [ + 'amount' => $amount, + 'amount_type' => empty($subscription->discounts) ? 'fixed' : 'maximum', + 'payment_schedule' => $schedule, + 'start_date' => $startsAt->getTimestamp(), + ]; + + $reference = Config::get('multi-payment.gateways.stripe.pix_mandate_reference'); + if (!empty($reference)) { + $mandateOptions['reference'] = $reference; + } + if (!is_null($automaticPix?->endsAt)) { + $mandateOptions['end_date'] = $automaticPix->endsAt->getTimestamp(); + } + + return $mandateOptions; + } + + /** + * Itens da atualização declarativa: os subscription items atuais fora da lista desejada + * são removidos (o item do plano fica), item com `id` tem a quantidade atualizada e item + * novo cria Price sob demanda no intervalo do plano; item com `recurring` falso vai como + * item avulso da próxima fatura. Faz um GET na assinatura para conhecer o estado atual. + * + * @param Subscription $subscription + * @param string|null $idempotencyKey + * @return array{items: array, add_invoice_items: array} + */ + private function declarativeStripeItems(Subscription $subscription, ?string $idempotencyKey): array + { + $current = $this->stripeRequest(function () use ($subscription) { + return $this->client->subscriptions->retrieve($subscription->id); + }); + $currentItems = $current->items->data ?? []; + $planItem = self::stripePlanItem($currentItems, $subscription->planId); + if (is_null($planItem) || empty($planItem->price->id ?? null)) { + throw new NotFoundException("No plan item found on subscription [{$subscription->id}] on stripe."); + } + + $keptIds = []; + foreach ($subscription->items as $item) { + if (!empty($item->id)) { + $keptIds[] = (string) $item->id; + } + } + + $entries = []; + foreach ($currentItems as $stripeItem) { + $id = $stripeItem->id ?? null; + if (empty($id) || $id === ($planItem->id ?? null) || in_array((string) $id, $keptIds, true)) { + continue; + } + $entries[] = ['id' => $id, 'deleted' => true]; + } + + $newItems = []; + foreach ($subscription->items as $item) { + if (!empty($item->id)) { + if (!is_null($item->quantity)) { + $entries[] = ['id' => $item->id, 'quantity' => $item->quantity]; + } + continue; + } + $newItems[] = $item; + } + + $created = $this->subscriptionItemsData($newItems, $planItem->price->id, $idempotencyKey); + + return [ + 'items' => array_merge($entries, $created['items']), + 'add_invoice_items' => $created['add_invoice_items'], + ]; + } + + /** + * Item do plano da assinatura, lido do gateway. + * + * @param Subscription $subscription + * @return object + * @throws NotFoundException|GatewayException|GatewayNotAvailableException + */ + private function currentStripePlanItem(Subscription $subscription): object + { + $current = $this->stripeRequest(function () use ($subscription) { + return $this->client->subscriptions->retrieve($subscription->id); + }); + + $planItem = self::stripePlanItem($current->items->data ?? [], $subscription->planId); + if (is_null($planItem) || empty($planItem->id)) { + throw new NotFoundException("No plan item found on subscription [{$subscription->id}] on stripe."); + } + + return $planItem; + } + + /** + * Item do plano entre os subscription items: o que aponta o Price cujo `lookup_key` ou id + * é o `planId` conhecido. Sem correspondência, o único item cujo Price tem `lookup_key` + * (os Prices criados sob demanda para itens extras não têm um); em último caso, o item + * mais antigo, porque o do plano nasce com a assinatura e os extras entram depois. + * + * @param array $stripeItems + * @param string|null $planId + * @return object|null + */ + private static function stripePlanItem(array $stripeItems, ?string $planId): ?object + { + if (!is_null($planId)) { + foreach ($stripeItems as $stripeItem) { + $price = $stripeItem->price ?? null; + if (($price->lookup_key ?? null) === $planId || ($price->id ?? null) === $planId) { + return $stripeItem; + } + } + } + + $withLookupKey = array_values(array_filter( + $stripeItems, + static fn ($stripeItem) => !empty($stripeItem->price->lookup_key ?? null) + )); + if (count($withLookupKey) === 1) { + return $withLookupKey[0]; + } + + $oldest = null; + foreach ($stripeItems as $stripeItem) { + if (is_null($oldest) || ($stripeItem->created ?? PHP_INT_MAX) < ($oldest->created ?? PHP_INT_MAX)) { + $oldest = $stripeItem; + } + } + + return $oldest; + } + + /** + * Recusa `nextBillingAt` diferente do que veio do gateway: fora da criação, a Stripe não + * aceita uma data arbitrária de próxima cobrança (a restrição consultável de + * `SUBSCRIPTIONS`). Um model lido do gateway, com a data que ele mesmo devolveu, passa. + * + * @param Subscription $subscription + * @return void + * @throws UnsupportedOperationException + */ + private function assertNextBillingAtIsUnchanged(Subscription $subscription): void + { + if (empty($subscription->nextBillingAt)) { + return; + } + + $original = $subscription->original->items->data ?? []; + $planItem = self::stripePlanItem(is_array($original) ? $original : [], $subscription->planId); + $originalPeriodEnd = $planItem->current_period_end ?? null; + // igualdade exata: a leitura preenche nextBillingAt com este mesmo timestamp, então + // qualquer diferença é uma mudança pedida pelo consumidor + if (!empty($originalPeriodEnd) && (int) $originalPeriodEnd === $subscription->nextBillingAt->getTimestamp()) { + return; + } + + throw UnsupportedOperationException::restricted( + (string) $this, + Capability::SUBSCRIPTIONS, + 'A Stripe não aceita definir a data da próxima cobrança de uma assinatura existente;' + . ' nextBillingAt vale só na criação (billing_cycle_anchor).' + ); + } + + /** + * Diz se o método informado é o mesmo que veio do gateway na leitura + * (`payment_settings.payment_method_types`). + * + * @param Subscription $subscription + * @param PaymentMethod $paymentMethod + * @return bool + */ + private function isOriginalStripePaymentMethod(Subscription $subscription, PaymentMethod $paymentMethod): bool + { + $original = $subscription->original->payment_settings->payment_method_types ?? null; + + return is_array($original) && $original === [self::paymentMethodToStripeType($paymentMethod)]; + } + + /** + * Diz se o fim do trial informado é o mesmo que veio do gateway na leitura (`trial_end`). + * + * @param Subscription $subscription + * @return bool + */ + private function isOriginalStripeTrialEnd(Subscription $subscription): bool + { + $original = $subscription->original->trial_end ?? null; + + return !empty($original) && (int) $original === $subscription->trialEndsAt->getTimestamp(); + } + + /** + * Converte a Subscription da Stripe numa assinatura do MultiPayment. + * + * O item cujo Price é o plano (`stripePlanItem()`) dá o `planId` (`lookup_key`, senão o + * id do Price) e a próxima cobrança (`current_period_end`); os demais itens viram + * `items`, e `amount` é a soma dos itens por ciclo. Os discounts expandidos viram + * `discounts`, com o id do Coupon. `paymentMethod` vem do PaymentMethod + * padrão expandido, senão do único tipo em `payment_settings`. Com $withLatestInvoice, a + * fatura mais recente é lida por inteiro (`parseFromStripeInvoice()`), uma leitura a + * mais. + * + * @param \Stripe\Subscription $stripeSubscription + * @param Subscription|null $subscription + * @param bool $withLatestInvoice + * @return Subscription + * @throws GatewayException|GatewayNotAvailableException + */ + private function parseStripeSubscription( + StripeSubscription $stripeSubscription, + ?Subscription $subscription = null, + bool $withLatestInvoice = false + ): Subscription { + $subscription = $subscription ?? new Subscription(); + + $subscription->id = $stripeSubscription->id ?? $subscription->id; + $subscription->status = SubscriptionStatuses::toSubscriptionStatus($stripeSubscription); + + $stripeItems = $stripeSubscription->items->data ?? []; + $planItem = self::stripePlanItem($stripeItems, $subscription->planId); + $planPrice = $planItem->price ?? null; + $subscription->planId = $planPrice->lookup_key ?? $planPrice->id ?? $subscription->planId; + + $amount = 0; + $hasAmount = false; + $items = []; + foreach ($stripeItems as $stripeItem) { + $unitAmount = $stripeItem->price->unit_amount ?? null; + $quantity = (int) ($stripeItem->quantity ?? 1); + if (!is_null($unitAmount)) { + $amount += $unitAmount * $quantity; + $hasAmount = true; + } + if (($stripeItem->id ?? null) === ($planItem->id ?? null)) { + continue; + } + $items[] = $this->parseStripeSubscriptionItem($stripeItem); + } + if ($hasAmount) { + $subscription->amount = $amount; + } + if (!empty($stripeItems)) { + $subscription->items = $items; + } + + $stripeDiscounts = $stripeSubscription->discounts ?? null; + if (is_array($stripeDiscounts) || $stripeDiscounts instanceof \Traversable) { + $discounts = []; + $unexpanded = false; + foreach ($stripeDiscounts as $stripeDiscount) { + // sem expand o discount vem como id (string), que não tem o Coupon para ler + if (is_object($stripeDiscount)) { + $discounts[] = $this->parseStripeSubscriptionDiscount($stripeDiscount); + } else { + $unexpanded = true; + } + } + // com um discount ilegível, a lista fica como está: gravar uma lista incompleta + // faria um save() posterior remover da assinatura os descontos que existem + if (!$unexpanded) { + $subscription->discounts = $discounts; + } + } + + $customerId = is_object($stripeSubscription->customer ?? null) + ? $stripeSubscription->customer->id + : ($stripeSubscription->customer ?? null); + if (!empty($customerId)) { + // com um id diferente, manter os atributos antigos produziria um Customer com id + // de um e documento de outro + if (is_null($subscription->customer) || $subscription->customer->id !== $customerId) { + $subscription->customer = new Customer(); + } + $subscription->customer->id = $customerId; + } + + if (!empty($planItem->current_period_end ?? null)) { + $subscription->nextBillingAt = Carbon::createFromTimestamp($planItem->current_period_end); + } + if (!empty($stripeSubscription->trial_end ?? null)) { + $subscription->trialEndsAt = Carbon::createFromTimestamp($stripeSubscription->trial_end); + } + $subscription->cancelAtPeriodEnd = (bool) ($stripeSubscription->cancel_at_period_end ?? false); + $subscription->canceledAt = !empty($stripeSubscription->canceled_at ?? null) + ? Carbon::createFromTimestamp($stripeSubscription->canceled_at) + : null; + if (!empty($stripeSubscription->created ?? null)) { + $subscription->createdAt = Carbon::createFromTimestamp($stripeSubscription->created); + } + + if (isset($stripeSubscription->metadata)) { + $metadata = $stripeSubscription->metadata; + $subscription->metadata = is_object($metadata) && method_exists($metadata, 'toArray') + ? $metadata->toArray() + : (array) $metadata; + } + + $pixMandateOptions = $stripeSubscription->payment_settings->payment_method_options->pix->mandate_options ?? null; + if (is_object($pixMandateOptions)) { + // o mandato faz do Pix Automático o método da assinatura; a lista fica de fora + // para um save() posterior não recusar o método como ausente dela + $subscription->paymentMethod = PaymentMethod::AUTOMATIC_PIX; + $subscription->automaticPix = $this->parsePixMandateOptions( + $pixMandateOptions, + $subscription->automaticPix, + $planItem->current_period_end ?? null + ); + } else { + $defaultPaymentMethod = $stripeSubscription->default_payment_method ?? null; + $method = is_object($defaultPaymentMethod) + ? (self::PAYMENT_METHOD_TYPES[$defaultPaymentMethod->type ?? ''] ?? null) + : null; + $types = $stripeSubscription->payment_settings->payment_method_types ?? null; + if (is_array($types)) { + $methods = array_values(array_filter(array_map( + static fn ($type) => self::PAYMENT_METHOD_TYPES[$type] ?? null, + $types + ))); + if (!empty($methods)) { + $subscription->availablePaymentMethods = $methods; + $method = $method ?? (count($methods) === 1 ? $methods[0] : null); + } + } + if (!is_null($method)) { + $subscription->paymentMethod = $method; + } + } + + if ($withLatestInvoice) { + $latestInvoiceId = is_object($stripeSubscription->latest_invoice ?? null) + ? $stripeSubscription->latest_invoice->id + : ($stripeSubscription->latest_invoice ?? null); + if (!empty($latestInvoiceId)) { + $subscription->latestInvoice = $this->parseInvoice($this->retrieveStripeInvoice($latestInvoiceId)); + } + } + + $subscription->gateway = 'stripe'; + $subscription->original = $stripeSubscription; + + return $subscription; + } + + /** + * Converte o `mandate_options` do Pix Automático de uma assinatura no model genérico. A + * frequência é a agenda (`payment_schedule`), as datas vêm de `start_date` e `end_date` e + * as duas datas derivadas seguem o ciclo: a notificação de pré-débito sai no início do + * ciclo (`current_period_end` da leitura) e o débito acontece três dias depois. O id e o + * status do mandato não vêm na assinatura; chegam pelo webhook `mandate.updated` ou pela + * consulta de cancelamentos. + * + * @param object $mandateOptions + * @param AutomaticPix|null $automaticPix + * @param int|null $currentPeriodEnd + * @return AutomaticPix + */ + private function parsePixMandateOptions( + object $mandateOptions, + ?AutomaticPix $automaticPix, + ?int $currentPeriodEnd + ): AutomaticPix { + $automaticPix ??= new AutomaticPix(); + + $automaticPix->frequency = $mandateOptions->payment_schedule ?? $automaticPix->frequency; + if (!empty($mandateOptions->start_date)) { + $automaticPix->startsAt = Carbon::createFromTimestamp($mandateOptions->start_date); + } + if (!empty($mandateOptions->end_date)) { + $automaticPix->endsAt = Carbon::createFromTimestamp($mandateOptions->end_date); + } + if (!empty($currentPeriodEnd)) { + $automaticPix->preDebitNotificationAt = Carbon::createFromTimestamp($currentPeriodEnd); + $automaticPix->nextDebitAt = Carbon::createFromTimestamp($currentPeriodEnd) + ->addDays(self::PIX_MANDATE_DEBIT_OFFSET_DAYS); + } + $automaticPix->gateway = 'stripe'; + $automaticPix->original = $mandateOptions; + + return $automaticPix; + } + + /** + * Converte um subscription item da Stripe (fora o do plano) num item de assinatura. A + * descrição vem do Product expandido, senão do apelido do Price. + * + * @param object $stripeItem + * @return SubscriptionItem + */ + private function parseStripeSubscriptionItem(object $stripeItem): SubscriptionItem + { + $price = $stripeItem->price ?? null; + $product = is_object($price->product ?? null) ? $price->product : null; + + $item = new SubscriptionItem(); + $item->id = $stripeItem->id ?? null; + $item->description = $product?->name ?? $price->nickname ?? null; + $item->amount = $price->unit_amount ?? null; + $item->quantity = isset($stripeItem->quantity) ? (int) $stripeItem->quantity : null; + $item->recurring = true; + + return $item; + } + + /** + * @inheritDoc + * + * No Stripe a Stripe agenda e retenta cada débito do mandato; a operação lança + * `UnsupportedOperationException` com `reason` `managed_by_gateway` sem nenhuma + * requisição. + */ + public function rescheduleAutomaticPixPayment(Invoice $invoice, ?string $idempotencyKey = null): Invoice + { + throw UnsupportedOperationException::managedByGateway( + (string) $this, + Capability::AUTOMATIC_PIX, + 'A Stripe agenda e retenta as cobranças do mandato; não há reagendamento pela lib.' + ); + } + + /** + * @inheritDoc + * + * No Stripe cada débito do mandato é conduzido pela Stripe; a operação lança + * `UnsupportedOperationException` com `reason` `managed_by_gateway` sem nenhuma + * requisição. + */ + public function cancelAutomaticPixScheduledPayment( + AutomaticPixCharge $charge, + ?string $idempotencyKey = null + ): AutomaticPixCancellation { + throw UnsupportedOperationException::managedByGateway( + (string) $this, + Capability::AUTOMATIC_PIX, + 'A Stripe conduz cada débito do mandato; não há cancelamento de um agendamento pela lib.' + ); + } + + /** + * @inheritDoc + * + * No Stripe o mandato vive na assinatura e é encerrado com ela; a operação lança + * `UnsupportedOperationException` com `reason` `managed_by_gateway` orientando o + * cancelamento da assinatura. + */ + public function cancelAutomaticPixRecurrence( + AutomaticPix $automaticPix, + ?string $idempotencyKey = null + ): AutomaticPixCancellation { + throw UnsupportedOperationException::managedByGateway( + (string) $this, + Capability::AUTOMATIC_PIX, + 'O mandato vive na assinatura: cancele a assinatura (cancelSubscription) e a Stripe o encerra.' + ); + } + + /** + * @inheritDoc + * + * No Stripe não existe um objeto de cancelamento: a consulta lê o Mandate + * (`recurrenceId`, id `mandate_`) e responde pelo status dele. Mandato `inactive` devolve + * o cancelamento como `completed`; mandato ainda ativo lança `NotFoundException`. + */ + public function getAutomaticPixCancellation(AutomaticPixCancellation $cancellation): AutomaticPixCancellation + { + if (empty($cancellation->recurrenceId)) { + throw ModelAttributeValidationException::required('AutomaticPixCancellation', 'recurrenceId'); + } + + $stripeMandate = $this->retrieveStripeMandate($cancellation->recurrenceId); + if (($stripeMandate->status ?? null) !== self::MANDATE_STATUS_INACTIVE) { + throw new NotFoundException( + "The automatic pix recurrence [{$cancellation->recurrenceId}] has no cancellation on stripe:" + . ' the mandate is still active.' + ); + } + + return $this->parseMandateCancellation($stripeMandate, $cancellation); + } + + /** + * @inheritDoc + * + * No Stripe não existe um objeto de cancelamento: a consulta lê o Mandate (`id` do model, + * `mandate_`) e devolve no máximo um item, `completed`, quando o mandato está `inactive` + * (lista vazia com o mandato ativo ou fora da primeira página). A leitura também preenche + * `mandateId` e `mandateStatus` no model informado. + */ + public function listAutomaticPixCancellations(AutomaticPix $automaticPix, int $page = 1, int $limit = 100): array + { + if (empty($automaticPix->id)) { + throw ModelAttributeValidationException::required('AutomaticPix', 'id'); + } + + $stripeMandate = $this->retrieveStripeMandate($automaticPix->id); + $automaticPix->mandateId = $stripeMandate->id ?? $automaticPix->id; + $automaticPix->mandateStatus = $stripeMandate->status ?? null; + + if ($page > 1 || ($stripeMandate->status ?? null) !== self::MANDATE_STATUS_INACTIVE) { + return []; + } + + return [$this->parseMandateCancellation($stripeMandate)]; + } + + /** + * Lê um Mandate da Stripe pelo id. + * + * @param string $mandateId + * @return \Stripe\Mandate + * @throws GatewayException|GatewayNotAvailableException + */ + private function retrieveStripeMandate(string $mandateId) + { + return $this->stripeRequest(function () use ($mandateId) { + return $this->client->mandates->retrieve($mandateId); + }); + } + + /** + * Converte um Mandate `inactive` no cancelamento genérico: `completed`, com o id do + * mandato como recorrência. A Stripe não informa a data do encerramento. + * + * @param object $stripeMandate + * @param AutomaticPixCancellation|null $cancellation + * @return AutomaticPixCancellation + */ + private function parseMandateCancellation( + object $stripeMandate, + ?AutomaticPixCancellation $cancellation = null + ): AutomaticPixCancellation { + $cancellation ??= new AutomaticPixCancellation(); + + $cancellation->id ??= $stripeMandate->id ?? null; + $cancellation->recurrenceId ??= $stripeMandate->id ?? null; + $cancellation->status = AutomaticPixCancellation::STATUS_COMPLETED; + $cancellation->gateway = 'stripe'; + $cancellation->original = $stripeMandate; + + return $cancellation; + } + + /** + * @inheritDoc + */ + public function __toString() + { + return 'stripe'; + } +} diff --git a/src/Helpers/CapabilitiesTable.php b/src/Helpers/CapabilitiesTable.php new file mode 100644 index 0000000..357b2cd --- /dev/null +++ b/src/Helpers/CapabilitiesTable.php @@ -0,0 +1,100 @@ + $gateways nome do gateway como chave + * @return string + */ + public static function markdown(array $gateways): string + { + $header = '| Capability | Significado | ' + . implode(' | ', array_map('ucfirst', array_keys($gateways))) + . ' | Restrições |'; + $separator = '|---|---|' . str_repeat('---|', count($gateways)) . '---|'; + + $rows = []; + foreach (Capability::cases() as $capability) { + $cells = array_map( + static fn (DeclaresCapabilities $gateway) => self::cell($gateway, $capability), + array_values($gateways) + ); + $rows[] = "| `{$capability->name}` | {$capability->description()} | " + . implode(' | ', $cells) + . ' | ' . self::restrictionsCell($gateways, $capability) . ' |'; + } + + return implode("\n", array_merge([$header, $separator], $rows)) . "\n"; + } + + /** + * Célula da matriz para um gateway e uma capability. + * + * @param DeclaresCapabilities $gateway + * @param Capability $capability + * @return string + */ + public static function cell(DeclaresCapabilities $gateway, Capability $capability): string + { + if (in_array($capability, $gateway->capabilities(), true)) { + return self::SUPPORTED; + } + + if ($gateway->isEmulated($capability)) { + return self::EMULATED; + } + + if (in_array($capability, $gateway->notYetImplemented(), true)) { + return self::NOT_IMPLEMENTED; + } + + return self::GATEWAY_LIMITATION; + } + + /** + * Célula de restrições de uma capability: a descrição declarada por cada gateway, + * prefixada pelo nome dele, separadas por quebra de linha HTML. Vazia quando nenhum + * gateway restringe a capability. + * + * @param array $gateways nome do gateway como chave + * @param Capability $capability + * @return string + */ + public static function restrictionsCell(array $gateways, Capability $capability): string + { + $parts = []; + foreach ($gateways as $name => $gateway) { + $restriction = $gateway->restriction($capability); + if (!is_null($restriction)) { + $parts[] = ucfirst($name) . ': ' . str_replace('|', '\\|', $restriction->description); + } + } + + return implode('
', $parts); + } +} diff --git a/src/Helpers/ConfigurationHelper.php b/src/Helpers/ConfigurationHelper.php index 324b499..6b70e68 100644 --- a/src/Helpers/ConfigurationHelper.php +++ b/src/Helpers/ConfigurationHelper.php @@ -3,7 +3,10 @@ namespace Potelo\MultiPayment\Helpers; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Facade; +use Illuminate\Contracts\Container\Container; use Potelo\MultiPayment\Contracts\GatewayContract; +use Potelo\MultiPayment\Contracts\IdempotencyStore; use Potelo\MultiPayment\Exceptions\ConfigurationException; class ConfigurationHelper @@ -34,4 +37,51 @@ public static function resolveGateway($gateway): GatewayContract } return $gateway; } + + /** + * Resolve a `IdempotencyStore` registrada no container do Laravel (o service provider + * registra a `CacheIdempotencyStore`; a aplicação pode substituí-la por um bind próprio). + * + * @return IdempotencyStore + * @throws ConfigurationException sem container ou sem a store registrada nele + */ + public static function resolveIdempotencyStore(): IdempotencyStore + { + $app = Facade::getFacadeApplication(); + if ($app instanceof Container && $app->bound(IdempotencyStore::class)) { + $store = $app->make(IdempotencyStore::class); + if ($store instanceof IdempotencyStore) { + return $store; + } + } + + throw ConfigurationException::IdempotencyStoreNotConfigured(); + } + + /** + * Prazo, em segundos, em que a `IdempotencyStore` devolve o resultado guardado + * (`multi-payment.idempotency.ttl`, padrão de 24 horas). + * + * @return int + */ + public static function idempotencyTtl(): int + { + return (int) (Config::get('multi-payment.idempotency.ttl') ?? 86400); + } + + /** + * Diz se `Model::fill()` recusa chave desconhecida (`multi-payment.strict_fill`, padrão + * verdadeiro). Sem container do Laravel, ou sem a chave na configuração, vale o padrão. + * + * @return bool + */ + public static function strictFill(): bool + { + $app = Facade::getFacadeApplication(); + if (!$app instanceof Container || !$app->bound('config')) { + return true; + } + + return (bool) ($app->make('config')->get('multi-payment.strict_fill') ?? true); + } } \ No newline at end of file diff --git a/src/Helpers/LogHelper.php b/src/Helpers/LogHelper.php new file mode 100644 index 0000000..bc15901 --- /dev/null +++ b/src/Helpers/LogHelper.php @@ -0,0 +1,59 @@ +bound('log')) { + $app->make('log')->{$level}($message, $context); + + return; + } + + error_log(trim($message . ' ' . json_encode($context))); + } +} diff --git a/src/Idempotency/CacheIdempotencyStore.php b/src/Idempotency/CacheIdempotencyStore.php new file mode 100644 index 0000000..d8eed9e --- /dev/null +++ b/src/Idempotency/CacheIdempotencyStore.php @@ -0,0 +1,120 @@ +cacheKey($key); + + $cached = $this->cache->get($cacheKey); + if ($this->isHit($cached)) { + return $cached['result']; + } + + $lock = $this->lockProvider()->lock($cacheKey . ':lock', $this->lockSeconds); + if (!$lock->get()) { + throw IdempotencyConflictException::concurrent($key); + } + + try { + // outra execução pode ter terminado entre a leitura acima e a obtenção do lock + $cached = $this->cache->get($cacheKey); + if ($this->isHit($cached)) { + return $cached['result']; + } + + $result = $operation(); + $this->cache->put($cacheKey, ['result' => $result], $ttlSeconds); + + return $result; + } finally { + $lock->release(); + } + } + + /** + * @inheritDoc + */ + public function has(string $key): bool + { + return $this->isHit($this->cache->get($this->cacheKey($key))); + } + + /** + * Chave no cache: prefixo mais a chave da operação. + * + * @param string $key + * @return string + */ + private function cacheKey(string $key): string + { + return $this->prefix . $key; + } + + /** + * Diz se o valor lido do cache é um resultado guardado. O resultado viaja num envelope para + * que uma operação que devolve nulo também conte como guardada. + * + * @param mixed $cached + * @return bool + */ + private function isHit(mixed $cached): bool + { + return is_array($cached) && array_key_exists('result', $cached); + } + + /** + * Cache store por trás do repositório, que precisa suportar lock. + * + * @return LockProvider + * @throws ConfigurationException + */ + private function lockProvider(): LockProvider + { + $store = $this->cache->getStore(); + if (!$store instanceof LockProvider) { + throw ConfigurationException::IdempotencyStoreWithoutLock(get_class($store)); + } + + return $store; + } +} diff --git a/src/Idempotency/IdempotencyKey.php b/src/Idempotency/IdempotencyKey.php new file mode 100644 index 0000000..da6bb64 --- /dev/null +++ b/src/Idempotency/IdempotencyKey.php @@ -0,0 +1,23 @@ + */ + private array $results = []; + + /** @var array */ + private array $inProgress = []; + + /** + * @inheritDoc + */ + public function remember(string $key, callable $operation, int $ttlSeconds): mixed + { + if ($this->has($key)) { + return $this->results[$key]['result']; + } + + if (isset($this->inProgress[$key])) { + throw IdempotencyConflictException::concurrent($key); + } + + $this->inProgress[$key] = true; + try { + $result = $operation(); + } finally { + unset($this->inProgress[$key]); + } + + $this->results[$key] = ['result' => $result, 'expiresAt' => Carbon::now()->getTimestamp() + $ttlSeconds]; + + return $result; + } + + /** + * @inheritDoc + */ + public function has(string $key): bool + { + if (!isset($this->results[$key])) { + return false; + } + + if ($this->results[$key]['expiresAt'] <= Carbon::now()->getTimestamp()) { + unset($this->results[$key]); + + return false; + } + + return true; + } + + /** + * Esquece o resultado guardado para a chave. + * + * @param string $key + * @return void + */ + public function forget(string $key): void + { + unset($this->results[$key]); + } +} diff --git a/src/Models/AutomaticPix.php b/src/Models/AutomaticPix.php index 2b90866..c854393 100644 --- a/src/Models/AutomaticPix.php +++ b/src/Models/AutomaticPix.php @@ -30,13 +30,53 @@ class AutomaticPix extends Model public ?Carbon $endsAt = null; public string $retryPolicy = self::RETRY_POLICY_NOT_ALLOWED; public ?string $status = null; + + /** + * Id do mandato no gateway, quando a recorrência é registrada como mandato (Stripe). No + * Stripe coincide com `id`; a leitura da assinatura não o traz, então ele chega pelo + * webhook `mandate.updated` ou preenchido pela consulta de cancelamentos. + * + * @var string|null + */ + public ?string $mandateId = null; + + /** + * Status do mandato no gateway (`active`, `inactive`, `pending`), preenchido quando o + * mandato é lido. + * + * @var string|null + */ + public ?string $mandateStatus = null; + + /** + * Data prevista do próximo débito na conta do pagador. No Stripe o débito acontece três + * dias depois do início do ciclo de cobrança. + * + * @var Carbon|null + */ + public ?Carbon $nextDebitAt = null; + + /** + * Data em que o pagador recebe a notificação de pré-débito, três dias antes do débito. + * + * @var Carbon|null + */ + public ?Carbon $preDebitNotificationAt = null; + public ?string $gateway = null; public $original = null; /** @inheritDoc */ public function fill(array $data): void { - foreach (['starts_at' => 'startsAt', 'ends_at' => 'endsAt'] as $key => $attribute) { + foreach ( + [ + 'starts_at' => 'startsAt', + 'ends_at' => 'endsAt', + 'next_debit_at' => 'nextDebitAt', + 'pre_debit_notification_at' => 'preDebitNotificationAt', + ] as $key => $attribute + ) { if (!empty($data[$key])) { $this->{$attribute} = $data[$key] instanceof Carbon ? $data[$key] diff --git a/src/Models/AutomaticPixCancellation.php b/src/Models/AutomaticPixCancellation.php index 621b8bc..80ce86c 100644 --- a/src/Models/AutomaticPixCancellation.php +++ b/src/Models/AutomaticPixCancellation.php @@ -11,6 +11,9 @@ class AutomaticPixCancellation extends Model { public const STATUS_REQUESTED = 'requested'; + /** Cancelamento concluído: a recorrência não gera mais cobranças (no Stripe, mandato `inactive`). */ + public const STATUS_COMPLETED = 'completed'; + public ?string $id = null; public ?string $recurrenceId = null; public ?string $paymentId = null; diff --git a/src/Models/CreditCard.php b/src/Models/CreditCard.php index 310caf0..8a9e662 100644 --- a/src/Models/CreditCard.php +++ b/src/Models/CreditCard.php @@ -3,6 +3,8 @@ namespace Potelo\MultiPayment\Models; use Carbon\Carbon; +use Potelo\MultiPayment\Contracts\GatewayContract; +use Potelo\MultiPayment\Helpers\ConfigurationHelper; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; /** @@ -76,6 +78,41 @@ class CreditCard extends Model */ public ?bool $default = null; + /** + * Verdadeiro quando o gateway exige ação do pagador (autenticação com o emissor) antes de + * o cartão ficar cobrável: `id` fica nulo, `setupId` e `clientSecret` (e `actionUrl`, + * quando há) dizem como concluir, e `confirmCreditCardSetup()` termina o salvamento. + * Falso por padrão e sempre falso na Iugu. + * + * @var bool|null + */ + public ?bool $requiresAction = false; + + /** + * Página hospedada pelo gateway para o pagador autenticar o cartão, quando ele a oferece + * (no Stripe, só com `return_url` em `gatewayOptions`). Nula quando a autenticação é feita + * pelo SDK do gateway no navegador, com `clientSecret`. + * + * @var string|null + */ + public ?string $actionUrl = null; + + /** + * Segredo do setup para o SDK do gateway no navegador concluir a autenticação (no Stripe, + * `stripe.confirmCardSetup(clientSecret)`). Preenchido só quando `requiresAction`. + * + * @var string|null + */ + public ?string $clientSecret = null; + + /** + * Id do setup que salva o cartão no gateway (SetupIntent `seti_` no Stripe), argumento de + * `confirmCreditCardSetup()`. Nulo na Iugu. + * + * @var string|null + */ + public ?string $setupId = null; + /** * @var string|null */ @@ -173,6 +210,40 @@ public function attributesExtraValidation(array $attributes): void } } + /** + * Conclui o salvamento deste cartão depois que o pagador autenticou, pelo `setupId` que + * `create()` devolveu com `requiresAction`. Atualiza este model com o que o gateway + * devolveu (cartão cobrável, ou ainda com `requiresAction`) e o devolve; o `customer` já + * preenchido é mantido. Recusa do gateway é `CardDeclinedException` (ver + * `CreditCardContract::confirmCreditCardSetup()`). + * + * @param GatewayContract|string|null $gateway + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return $this + * @throws ModelAttributeValidationException `setupId` vazio + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws \Potelo\MultiPayment\Exceptions\CardDeclinedException + * @throws \Potelo\MultiPayment\Exceptions\UnsupportedOperationException + */ + public function confirmSetup(GatewayContract|string|null $gateway = null, ?string $idempotencyKey = null): static + { + if (empty($this->setupId)) { + throw ModelAttributeValidationException::required($this->getClassName(), 'setupId'); + } + $gateway = ConfigurationHelper::resolveGateway($gateway ?? $this->gateway); + + $confirmed = $gateway->confirmCreditCardSetup($this->setupId, $idempotencyKey); + foreach (get_object_vars($confirmed) as $property => $value) { + if ($property === 'customer' && !empty($this->customer)) { + continue; + } + $this->{$property} = $value; + } + + return $this; + } + /** * @inheritDoc */ diff --git a/src/Models/Customer.php b/src/Models/Customer.php index fc1babb..fd59966 100644 --- a/src/Models/Customer.php +++ b/src/Models/Customer.php @@ -170,10 +170,19 @@ public function toArray(): array return $array; } - public function setDefaultCard(string $cardId): Customer + /** + * Define o cartão padrão do cliente no gateway. + * + * @param string $cardId + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return Customer + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws \Potelo\MultiPayment\Exceptions\GatewayException + */ + public function setDefaultCard(string $cardId, ?string $idempotencyKey = null): Customer { $gateway = ConfigurationHelper::resolveGateway($this->gateway); - return $gateway->setCustomerDefaultCard($this, $cardId); + return $gateway->setCustomerDefaultCard($this, $cardId, $idempotencyKey); } /** @@ -182,12 +191,12 @@ public function setDefaultCard(string $cardId): Customer * @param string $creditCardId * @param string|GatewayContract|null $gateway * - * @return static + * @return CreditCard * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException */ - public function getCreditCard(string $creditCardId, GatewayContract|string $gateway = null): CreditCard + public function getCreditCard(string $creditCardId, GatewayContract|string|null $gateway = null): CreditCard { $gateway = ConfigurationHelper::resolveGateway($gateway); $creditCard = new CreditCard(); @@ -202,18 +211,22 @@ public function getCreditCard(string $creditCardId, GatewayContract|string $gate * * @param string $creditCardId * @param \Potelo\MultiPayment\Contracts\GatewayContract|string|null $gateway + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * @return void * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException */ - public function deleteCreditCard(string $creditCardId, GatewayContract|string $gateway = null): void - { + public function deleteCreditCard( + string $creditCardId, + GatewayContract|string|null $gateway = null, + ?string $idempotencyKey = null + ): void { $gateway = ConfigurationHelper::resolveGateway($gateway); $creditCard = new CreditCard(); $creditCard->customer = $this; $creditCard->id = $creditCardId; - $gateway->deleteCreditCard($creditCard); + $gateway->deleteCreditCard($creditCard, $idempotencyKey); } } diff --git a/src/Models/Invoice.php b/src/Models/Invoice.php index b9c3848..6bbeeb8 100644 --- a/src/Models/Invoice.php +++ b/src/Models/Invoice.php @@ -3,34 +3,79 @@ namespace Potelo\MultiPayment\Models; use Carbon\Carbon; +use Potelo\MultiPayment\Enums\Capability; +use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\PaymentMethod; +use Potelo\MultiPayment\Enums\InvoiceOriginType; use Potelo\MultiPayment\Contracts\GatewayContract; use Potelo\MultiPayment\Helpers\ConfigurationHelper; +use Potelo\MultiPayment\Idempotency\IdempotencyKey; +use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; /** - * Invoice class + * Fatura. + * + * As quatro propriedades abaixo são enums: aceitam na escrita a string do valor ou o caso do + * enum e devolvem sempre o enum (ver `Model::ENUM_CASTS`). + * + * @property InvoiceStatus|null $status Status genérico; `UNKNOWN` para status que a lib não reconhece. + * @property PaymentMethod|null $paymentMethod Método com que a fatura foi (ou será) paga. + * @property PaymentMethod[]|null $availablePaymentMethods Métodos aceitos pela fatura. + * @property InvoiceOriginType|null $originType Objeto do gateway de onde a fatura foi lida (`PAYMENT_INTENT` ou `INVOICE`); `original` guarda esse objeto. + * @property-read int|null $refundedAmount Total já estornado, em centavos, preenchido na leitura. Escrever nela é o caminho antigo de pedir um estorno parcial, obsoleto desde 2026-09-02: use `refund(amount:)`. + * @property Carbon|null $expiresAt Obsoleto desde 2026-09-02, use `$dueDate`. Alias que lê e escreve a mesma data, com aviso de deprecação. */ class Invoice extends Model { + /** @deprecated desde 2026-09-02, use `InvoiceStatus::PENDING`. */ public const STATUS_PENDING = 'pending'; + + /** @deprecated desde 2026-09-02, use `InvoiceStatus::PAID`. */ public const STATUS_PAID = 'paid'; + + /** @deprecated desde 2026-09-02, use `InvoiceStatus::CANCELED`. */ public const STATUS_CANCELED = 'canceled'; + + /** @deprecated desde 2026-09-02, use `InvoiceStatus::REFUNDED`. */ public const STATUS_REFUNDED = 'refunded'; + + /** @deprecated desde 2026-09-02, use `InvoiceStatus::PARTIALLY_REFUNDED`. */ public const STATUS_PARTIALLY_REFUNDED = 'partially_refunded'; + /** @deprecated desde 2026-09-02, use `InvoiceStatus::DISPUTED`. */ + public const STATUS_DISPUTED = 'disputed'; + + /** @deprecated desde 2026-09-02, use `InvoiceStatus::CHARGEBACK`. */ + public const STATUS_CHARGEBACK = 'chargeback'; + + /** @deprecated desde 2026-09-02, use `PaymentMethod::CREDIT_CARD`. */ public const PAYMENT_METHOD_CREDIT_CARD = 'credit_card'; + + /** @deprecated desde 2026-09-02, use `PaymentMethod::BANK_SLIP`. */ public const PAYMENT_METHOD_BANK_SLIP = 'bank_slip'; + + /** @deprecated desde 2026-09-02, use `PaymentMethod::PIX`. */ public const PAYMENT_METHOD_PIX = 'pix'; + protected const ENUM_CASTS = [ + 'status' => InvoiceStatus::class, + 'paymentMethod' => PaymentMethod::class, + 'availablePaymentMethods' => [PaymentMethod::class], + 'originType' => InvoiceOriginType::class, + ]; + + protected const MAGIC_PROPERTIES = ['refundedAmount']; + /** * @var string|null */ public ?string $id = null; /** - * @var string|null + * @var InvoiceStatus|null */ - public ?string $status = null; + protected ?InvoiceStatus $status = null; /** * @var Carbon|null @@ -48,9 +93,30 @@ class Invoice extends Model public ?int $paidAmount = null; /** + * Total já estornado, em centavos, como o gateway informa na leitura (`refunded_cents` na + * Iugu, `amount_refunded` do charge na Stripe). Só os drivers escrevem aqui, por + * `setRefundedAmountFromGateway()`. + * * @var int|null */ - public ?int $refundedAmount = null; + protected ?int $refundedAmount = null; + + /** + * Valor escrito em `refundedAmount` de fora do model pelo caminho antigo de pedir estorno + * parcial, ainda não enviado ao gateway; nulo quando `refundedAmount` só reflete a leitura. + * + * @var int|null + */ + private ?int $requestedRefundAmount = null; + + /** + * Estornos da fatura, preenchidos na leitura. No Stripe é um `Refund` por estorno feito, + * com id; na Iugu, que só informa o total estornado, é um único `Refund` sem id com o + * acumulado, ou lista vazia quando nada foi estornado. + * + * @var Refund[]|null + */ + public ?array $refunds = null; /** * @var Customer|null @@ -63,14 +129,22 @@ class Invoice extends Model public ?array $items = null; /** - * @var string|null + * @var PaymentMethod|null */ - public ?string $paymentMethod = null; + protected ?PaymentMethod $paymentMethod = null; /** - * @var string[]|null + * @var PaymentMethod[]|null */ - public ?array $availablePaymentMethods = null; + protected ?array $availablePaymentMethods = null; + + /** + * Preenchido pelo driver na leitura. Na Iugu é sempre `INVOICE`; na Stripe é + * `PAYMENT_INTENT` na cobrança avulsa e `INVOICE` na fatura de assinatura. + * + * @var InvoiceOriginType|null + */ + protected ?InvoiceOriginType $originType = null; /** * @var CreditCard|null @@ -98,9 +172,24 @@ class Invoice extends Model public ?AutomaticPixCharge $automaticPixCharge = null; /** + * Data de vencimento da fatura. Na Iugu é o `due_date` (o dia; a fatura vencida continua + * pagável); no Stripe é o `due_date` da fatura de assinatura, no boleto avulso vira os + * dias até o vencimento do voucher (a leitura devolve o instante em que ele vence) e, na + * venda avulsa por Pix sem `pixExpiresAt`, o fim desse dia vira a expiração do QR Code. + * + * @var Carbon|null + */ + public ?Carbon $dueDate = null; + + /** + * Instante em que o QR Code do Pix deixa de aceitar pagamento. No Stripe vai em + * `payment_method_options.pix.expires_at` (entre 10 segundos e 14 dias no futuro) e volta + * na leitura; na Iugu vai em `pix_qr_code_expires_at` e a leitura só o preenche quando a + * fatura o devolve. + * * @var Carbon|null */ - public ?Carbon $expiresAt = null; + public ?Carbon $pixExpiresAt = null; /** * @var int|null @@ -136,14 +225,14 @@ public function fill(array $data): void { if (empty($data['items']) && !empty($data['amount'])) { $invoiceItem = new InvoiceItem(); - $data['items'] = []; $invoiceItem->fill([ 'description' => 'Nova cobrança', 'quantity' => 1, 'price' => $data['amount'], ]); $this->items[] = $invoiceItem; - unset($data['amount']); + // a chave items não pode chegar ao parent::fill(), que sobrescreveria a lista + unset($data['amount'], $data['items']); } elseif (!empty($data['items'])) { $this->items = []; foreach ($data['items'] as $item) { @@ -164,10 +253,27 @@ public function fill(array $data): void } if (!empty($data['expires_at'])) { - $this->expiresAt = Carbon::createFromFormat('Y-m-d', $data['expires_at']); + self::warnExpiresAtDeprecated(); + $data['due_date'] = $data['due_date'] ?? $data['expires_at']; unset($data['expires_at']); } + foreach (['refunded_amount', 'refundedAmount'] as $key) { + if (array_key_exists($key, $data)) { + $this->__set('refundedAmount', $data[$key]); + unset($data[$key]); + } + } + + foreach (['due_date' => 'dueDate', 'pix_expires_at' => 'pixExpiresAt'] as $key => $attribute) { + if (!empty($data[$key])) { + $this->{$attribute} = $data[$key] instanceof Carbon + ? $data[$key] + : Carbon::parse($data[$key]); + unset($data[$key]); + } + } + if (!empty($data['credit_card']) && is_array($data['credit_card'])) { $this->creditCard = new CreditCard(); $this->creditCard->fill($data['credit_card']); @@ -203,6 +309,61 @@ public function attributesExtraValidation($attributes): void if (in_array('amount', $attributes) && in_array('items', $attributes) && empty($this->amount) && empty($this->items)) { throw ModelAttributeValidationException::required($model, 'amount or items'); } + + if (in_array('paymentMethod', $attributes) && in_array('creditCard', $attributes)) { + $this->resolvedPaymentMethods(); + } + + if ( + in_array('amount', $attributes) + && in_array('items', $attributes) + && !empty($this->amount) + && !empty($this->items) + && $this->amount !== $this->itemsTotal() + ) { + throw ModelAttributeValidationException::invalid( + $model, + 'amount', + "amount [{$this->amount}] must equal the sum of the items [{$this->itemsTotal()}]; omit it when items are given" + ); + } + } + + /** + * Soma dos itens (`price` vezes `quantity`, com quantidade 1 quando ausente), em centavos. + * + * @return int + */ + public function itemsTotal(): int + { + $total = 0; + foreach ($this->items ?? [] as $item) { + if ($item instanceof InvoiceItem) { + $total += (int) $item->price * (int) ($item->quantity ?? 1); + } + } + + return $total; + } + + /** + * Na escrita, `paymentMethod` precisa ser um método selecionável + * (`PaymentMethod::selectable()`); Pix Automático entra pela lista com `PIX` e por + * `automaticPix`. + * + * @return void + * @throws ModelAttributeValidationException + */ + public function validatePaymentMethodAttribute(): void + { + if (!in_array($this->paymentMethod, PaymentMethod::selectable(), true)) { + $accepted = implode(', ', array_column(PaymentMethod::selectable(), 'value')); + throw ModelAttributeValidationException::invalid( + $this->getClassName(), + 'paymentMethod', + "paymentMethod must be one of: {$accepted}" + ); + } } /** @@ -230,25 +391,19 @@ public function validateItemsAttribute() } /** + * Garante que `availablePaymentMethods` é uma lista de métodos selecionáveis + * (`PaymentMethod::selectable()`), convertendo string que tenha entrado por escrita + * indireta no array. + * * @return void * @throws ModelAttributeValidationException */ public function validateAvailablePaymentMethodsAttribute() { - $meethods = [ - self::PAYMENT_METHOD_CREDIT_CARD, - self::PAYMENT_METHOD_BANK_SLIP, - self::PAYMENT_METHOD_PIX, - ]; - - if (!is_array($this->availablePaymentMethods)) { - throw ModelAttributeValidationException::invalid('Invoice', 'availablePaymentMethods', 'availablePaymentMethods must be an array of payment methods'); - } - foreach ($this->availablePaymentMethods as $method) { - if (!in_array($method, $meethods)) { - throw ModelAttributeValidationException::invalid('Invoice', 'availablePaymentMethods', 'availablePaymentMethods must be one of: ' . implode(', ', $meethods)); - } - } + $this->availablePaymentMethods = PaymentMethod::normalizeSelectable( + $this->availablePaymentMethods, + $this->getClassName() + ); } /** @@ -271,41 +426,429 @@ public function validateAutomaticPixAttribute(): void /** * @inheritDoc */ - public function save(GatewayContract|string $gateway = null, bool $validate = true): void + public function save(GatewayContract|string|null $gateway = null, bool $validate = true, ?string $idempotencyKey = null): void { if ($validate) { $this->validate(); } + // resolvido e verificado antes de salvar o cliente, para nenhuma requisição sair + // quando o gateway não suporta a fatura; no update vale a regra do Model (o gateway + // gravado no model prevalece) + $gateway = ConfigurationHelper::resolveGateway($this->gatewayForSave($gateway)); + $this->assertGatewaySupports($gateway); + $this->assertAutomaticPixBelongsToTheInvoice($gateway); if (empty($this->customer->id)) { - $this->customer->save($gateway, $validate); + $this->customer->save($gateway, $validate, IdempotencyKey::derive($idempotencyKey, 'customer')); } if (!empty($this->creditCard) && empty($this->creditCard->id)) { $this->creditCard->customer = $this->customer; } - parent::save($gateway, false); + parent::save($gateway, false, $idempotencyKey); } /** - * Refund the invoice + * Métodos de pagamento com que a fatura será criada, na ordem de precedência que os + * drivers seguem: `availablePaymentMethods` quando preenchida (normalizada, porque uma + * string apensada por `[]=` entra no array sem conversão); senão `paymentMethod`; senão + * cartão, quando só `creditCard` foi informado. Lista vazia quando nada foi informado (a + * Iugu abre a fatura a todos os métodos da conta; o Stripe exige um). Lança + * `ModelAttributeValidationException` para valor fora de `PaymentMethod::selectable()`, + * para `paymentMethod` fora da lista informada e para `creditCard` sem cartão entre os + * métodos resultantes. * - * @return \Potelo\MultiPayment\Models\Invoice + * @return PaymentMethod[] + * @throws ModelAttributeValidationException + */ + public function resolvedPaymentMethods(): array + { + if (!is_null($this->paymentMethod)) { + $this->validatePaymentMethodAttribute(); + } + + if (!empty($this->availablePaymentMethods)) { + $methods = array_values(array_unique( + PaymentMethod::normalizeSelectable($this->availablePaymentMethods, $this->getClassName()), + SORT_REGULAR + )); + self::assertPaymentMethodIsListed($this->getClassName(), $this->paymentMethod, $methods); + } elseif (!is_null($this->paymentMethod)) { + $methods = [$this->paymentMethod]; + } else { + $methods = !empty($this->creditCard) ? [PaymentMethod::CREDIT_CARD] : []; + } + + self::assertCreditCardIsPayable($this->getClassName(), $this->creditCard, $methods); + + return $methods; + } + + /** + * Lança `ModelAttributeValidationException` quando `paymentMethod` está preenchido e não + * consta da lista de métodos informada. + * + * @param string $model + * @param PaymentMethod|null $paymentMethod + * @param PaymentMethod[] $methods + * @return void + * @throws ModelAttributeValidationException + */ + public static function assertPaymentMethodIsListed(string $model, ?PaymentMethod $paymentMethod, array $methods): void + { + if (!is_null($paymentMethod) && !in_array($paymentMethod, $methods, true)) { + throw ModelAttributeValidationException::invalid( + $model, + 'paymentMethod', + "paymentMethod [{$paymentMethod->value}] must be one of availablePaymentMethods; change the list or leave it empty" + ); + } + } + + /** + * Lança `ModelAttributeValidationException` quando há cartão informado e cartão não está + * entre os métodos com que a fatura ou a assinatura será criada. + * + * @param string $model + * @param CreditCard|null $creditCard + * @param PaymentMethod[] $methods + * @return void + * @throws ModelAttributeValidationException + */ + public static function assertCreditCardIsPayable(string $model, ?CreditCard $creditCard, array $methods): void + { + if (!empty($creditCard) && !in_array(PaymentMethod::CREDIT_CARD, $methods, true)) { + throw ModelAttributeValidationException::invalid( + $model, + 'creditCard', + 'creditCard was given but credit_card is not among the payment methods; add it or remove the card' + ); + } + } + + /** + * Na criação, além do que o `Model` exige, a fatura precisa da capability de cada método + * de `resolvedPaymentMethods()`, de `MULTIPLE_PAYMENT_METHODS` quando há mais de um, de + * `AUTOMATIC_PIX` quando `automaticPix` ou `automaticPixCharge` está preenchido e de + * `RAW_CARD_DATA` quando o cartão vem com os dados crus (sem `id` nem `token`). Com `id` + * preenchido, só o que o `Model` exige. + * + * @return Capability[] + * @throws ModelAttributeValidationException método de pagamento fora de `PaymentMethod::selectable()` + */ + public function requiredCapabilities(): array + { + $capabilities = parent::requiredCapabilities(); + if (!empty($this->id)) { + return $capabilities; + } + + $methods = $this->resolvedPaymentMethods(); + + foreach ($methods as $method) { + $capabilities[] = Capability::forPaymentMethod($method); + } + if (count($methods) > 1) { + $capabilities[] = Capability::MULTIPLE_PAYMENT_METHODS; + } + if (!empty($this->automaticPix) || !empty($this->automaticPixCharge)) { + $capabilities[] = Capability::AUTOMATIC_PIX; + } + if (!empty($this->creditCard) && empty($this->creditCard->id) && empty($this->creditCard->token)) { + $capabilities[] = Capability::RAW_CARD_DATA; + } + + return array_values(array_unique($capabilities, SORT_REGULAR)); + } + + /** + * Recusa `automaticPix` e `automaticPixCharge` num gateway que gerencia a recorrência + * (`MANAGES_RECURRENCE`): nele o mandato vive na assinatura, e a fatura avulsa com + * recorrência ainda não é suportada pela lib. A recusa acontece antes de qualquer + * requisição, inclusive antes de criar o cliente. + * + * @param GatewayContract $gateway + * @return void + * @throws UnsupportedOperationException + */ + private function assertAutomaticPixBelongsToTheInvoice(GatewayContract $gateway): void + { + if ( + (empty($this->automaticPix) && empty($this->automaticPixCharge)) + || !$gateway->supports(Capability::MANAGES_RECURRENCE) + ) { + return; + } + + throw UnsupportedOperationException::notImplemented( + (string) $gateway, + Capability::AUTOMATIC_PIX, + 'Nesse gateway a recorrência de Pix Automático vive na assinatura: crie uma' + . ' Subscription com paymentMethod automatic_pix. A fatura avulsa com automaticPix' + . ' ainda não é suportada pela lib.' + ); + } + + /** + * Diz se o dinheiro da fatura foi recebido; delega a `InvoiceStatus::isSettled()`. String + * fora do enum devolve falso. + * + * @deprecated desde 2026-09-02, use `$invoice->status->isSettled()`. + * @param InvoiceStatus|string $status + * @return bool + */ + public static function isSettled(InvoiceStatus|string $status): bool + { + trigger_error( + 'Invoice::isSettled() está obsoleto desde 2026-09-02; use $invoice->status->isSettled()', + E_USER_DEPRECATED + ); + + return self::statusFromHelperArgument($status)?->isSettled() ?? false; + } + + /** + * Diz se existe contestação sobre a fatura; delega a `InvoiceStatus::isContested()`. + * String fora do enum devolve falso. + * + * @deprecated desde 2026-09-02, use `$invoice->status->isContested()`. + * @param InvoiceStatus|string $status + * @return bool + */ + public static function isContested(InvoiceStatus|string $status): bool + { + trigger_error( + 'Invoice::isContested() está obsoleto desde 2026-09-02; use $invoice->status->isContested()', + E_USER_DEPRECATED + ); + + return self::statusFromHelperArgument($status)?->isContested() ?? false; + } + + /** + * Resolve a leitura do nome antigo `expiresAt` para `dueDate`, com aviso de deprecação; + * os demais nomes seguem o `Model`. + * + * @param string $name + * @return mixed + */ + public function &__get(string $name): mixed + { + if ($name === 'expiresAt') { + self::warnExpiresAtDeprecated(); + + return $this->dueDate; + } + + if ($name === 'refundedAmount') { + return $this->refundedAmount; + } + + $value = &parent::__get($name); + + return $value; + } + + /** + * Resolve a escrita no nome antigo `expiresAt` para `dueDate`, com aviso de deprecação, e a + * escrita em `refundedAmount`, que é o caminho antigo de pedir um estorno parcial: o valor + * fica guardado como pedido para o próximo `refund()` sem valor, com aviso de deprecação. + * Os demais nomes seguem o `Model`. + * + * @param string $name + * @param mixed $value + * @return void + */ + public function __set(string $name, mixed $value): void + { + if ($name === 'expiresAt') { + self::warnExpiresAtDeprecated(); + $this->dueDate = $value; + + return; + } + + if ($name === 'refundedAmount') { + trigger_error( + 'Invoice::$refundedAmount é só de leitura desde 2026-09-02; passe o valor do estorno em refund(amount:) ou refundInvoice($id, $amount)', + E_USER_DEPRECATED + ); + $this->refundedAmount = is_null($value) ? null : (int) $value; + $this->requestedRefundAmount = $this->refundedAmount ?: null; + + return; + } + + parent::__set($name, $value); + } + + /** + * Mantém `isset()` e `empty()` funcionando sobre o nome antigo `expiresAt` e sobre + * `refundedAmount`. + * + * @param string $name + * @return bool + */ + public function __isset(string $name): bool + { + if ($name === 'expiresAt') { + return isset($this->dueDate); + } + + if ($name === 'refundedAmount') { + return isset($this->refundedAmount); + } + + return parent::__isset($name); + } + + /** + * Grava o total já estornado informado pelo gateway e apaga o valor pedido pelo caminho + * antigo (`requestedRefundAmount()` volta a nulo). É a escrita que os drivers fazem ao + * parsear a fatura. + * + * @internal usado pelos drivers ao parsear a fatura + * @param int|null $refundedAmount total estornado, em centavos + * @return void + */ + public function setRefundedAmountFromGateway(?int $refundedAmount): void + { + $this->refundedAmount = $refundedAmount; + $this->requestedRefundAmount = null; + } + + /** + * Valor de estorno parcial pedido pelo caminho antigo (escrita em `refundedAmount`) e ainda + * não enviado ao gateway; nulo quando `refundedAmount` só reflete a leitura do gateway. + * Enquanto não é nulo, `refundedAmount` não é o total já estornado. + * + * @internal usado pelos drivers + * @return int|null + */ + public function requestedRefundAmount(): ?int + { + return $this->requestedRefundAmount; + } + + /** + * Resolve o valor de um estorno como os drivers o usam: o argumento, senão o valor pedido + * pelo caminho antigo (`requestedRefundAmount()`), senão nulo, que é estorno do restante. + * Zero ou negativo lança `ModelAttributeValidationException`. + * + * @internal usado pelos drivers + * @param int|null $amount valor em centavos + * @return int|null + * @throws ModelAttributeValidationException + */ + public function resolveRefundAmount(?int $amount): ?int + { + $amount ??= $this->requestedRefundAmount; + + if (!is_null($amount) && $amount <= 0) { + throw ModelAttributeValidationException::invalid( + $this->getClassName(), + 'amount', + 'The refund amount must be a positive number of cents; omit it to refund the remainder.' + ); + } + + return $amount; + } + + /** + * Emite o aviso de deprecação do nome antigo `expiresAt`. + * + * @return void + */ + private static function warnExpiresAtDeprecated(): void + { + trigger_error( + 'Invoice::$expiresAt está obsoleto desde 2026-09-02; use $dueDate (vencimento) ou $pixExpiresAt (expiração do QR Code)', + E_USER_DEPRECATED + ); + } + + /** + * Copia também os objetos aninhados que os drivers preenchem na leitura (`customer` e seu + * `address`, `creditCard`, `bankSlip`, `pix`, `automaticPix`, `automaticPixCharge`), para + * que parsear a cópia não altere o model original. + * + * @return void + */ + public function __clone(): void + { + foreach (['customer', 'creditCard', 'bankSlip', 'pix', 'automaticPix', 'automaticPixCharge'] as $property) { + if (is_object($this->{$property})) { + $this->{$property} = clone $this->{$property}; + } + } + + if (is_object($this->customer?->address)) { + $this->customer->address = clone $this->customer->address; + } + } + + /** + * Converte o argumento dos helpers estáticos obsoletos em `InvoiceStatus`, sem log para + * string fora do enum. + * + * @param InvoiceStatus|string $status + * @return InvoiceStatus|null + */ + private static function statusFromHelperArgument(InvoiceStatus|string $status): ?InvoiceStatus + { + return $status instanceof InvoiceStatus ? $status : InvoiceStatus::tryFrom($status); + } + + /** + * Estorna a fatura: o restante estornável quando `$amount` é nulo, ou o valor informado em + * centavos. Devolve o `Refund` criado e atualiza esta instância com o estado posterior ao + * estorno (`$refund->invoice()` é esta instância). Zero ou negativo lança + * `ModelAttributeValidationException` antes da requisição. + * + * @param int|null $amount valor em centavos; nulo estorna o restante + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return Refund * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws \Potelo\MultiPayment\Exceptions\RefundNotSupportedException + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws ModelAttributeValidationException */ - public function refund(): Invoice + public function refund(?int $amount = null, ?string $idempotencyKey = null): Refund { $gateway = ConfigurationHelper::resolveGateway($this->gateway); - return $gateway->refundInvoice($this); + return $gateway->refundInvoice($this, $amount, $idempotencyKey); } /** - * Charge invoice with credit card + * Valor que ainda pode ser estornado na fatura, em centavos, calculado pelo driver: zero para + * fatura não paga ou já integralmente estornada. Lê a fatura (um GET) quando o model não traz + * o valor pago. É o teto aritmético de `refund()`; as guardas de boleto, Pix parcial e prazo + * continuam valendo. * + * @return int + * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws \Potelo\MultiPayment\Exceptions\UnsupportedOperationException fatura que o driver não estorna (fatura de assinatura no Stripe) + * @throws ModelAttributeValidationException `id` ausente + */ + public function refundableAmount(): int + { + $gateway = ConfigurationHelper::resolveGateway($this->gateway); + return $gateway->refundableAmount($this); + } + + /** + * Cobra a fatura com o cartão informado (ou com o já preenchido em `creditCard`). + * + * @param CreditCard|null $creditCard + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return Invoice * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException * @throws \Potelo\MultiPayment\Exceptions\ChargingException * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException */ - public function chargeInvoiceWithCreditCard(?CreditCard $creditCard = null): Invoice + public function chargeInvoiceWithCreditCard(?CreditCard $creditCard = null, ?string $idempotencyKey = null): Invoice { if (!empty($creditCard)) { $this->creditCard = $creditCard; @@ -313,7 +856,7 @@ public function chargeInvoiceWithCreditCard(?CreditCard $creditCard = null): Inv $gateway = ConfigurationHelper::resolveGateway($this->gateway); - return $gateway->chargeInvoiceWithCreditCard($this); + return $gateway->chargeInvoiceWithCreditCard($this, $idempotencyKey); } /** @@ -321,33 +864,46 @@ public function chargeInvoiceWithCreditCard(?CreditCard $creditCard = null): Inv * * @param \Carbon\Carbon $expiresAt * @param array $gatewayOptions + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * @return \Potelo\MultiPayment\Models\Invoice * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException * @throws \Potelo\MultiPayment\Exceptions\GatewayException */ - public function duplicate(Carbon $expiresAt, array $gatewayOptions = []): Invoice + public function duplicate(Carbon $expiresAt, array $gatewayOptions = [], ?string $idempotencyKey = null): Invoice { $gateway = ConfigurationHelper::resolveGateway($this->gateway); - return $gateway->duplicateInvoice($this, $expiresAt, $gatewayOptions); + return $gateway->duplicateInvoice($this, $expiresAt, $gatewayOptions, $idempotencyKey); } /** - * Cancel the invoice. + * Cancela a fatura. + * + * @param GatewayContract|string|null $gateway + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return Invoice + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws \Potelo\MultiPayment\Exceptions\GatewayException */ - public function cancel(GatewayContract|string|null $gateway = null): Invoice + public function cancel(GatewayContract|string|null $gateway = null, ?string $idempotencyKey = null): Invoice { $gateway = ConfigurationHelper::resolveGateway($gateway ?? $this->gateway); - return $gateway->cancelInvoice($this); + return $gateway->cancelInvoice($this, $idempotencyKey); } /** - * Request a new debit schedule after a failed Automatic Pix payment. + * Pede um novo agendamento de débito depois de um pagamento de Pix Automático que falhou. + * + * @param GatewayContract|string|null $gateway + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return Invoice + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws \Potelo\MultiPayment\Exceptions\GatewayException */ - public function rescheduleAutomaticPixPayment(GatewayContract|string|null $gateway = null): Invoice + public function rescheduleAutomaticPixPayment(GatewayContract|string|null $gateway = null, ?string $idempotencyKey = null): Invoice { $gateway = ConfigurationHelper::resolveGateway($gateway ?? $this->gateway); - return $gateway->rescheduleAutomaticPixPayment($this); + return $gateway->rescheduleAutomaticPixPayment($this, $idempotencyKey); } } diff --git a/src/Models/Model.php b/src/Models/Model.php index 36db79b..039d5f2 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -2,67 +2,338 @@ namespace Potelo\MultiPayment\Models; +use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Contracts\GatewayContract; +use Potelo\MultiPayment\Contracts\AcceptsUnknownValue; use Potelo\MultiPayment\Helpers\ConfigurationHelper; use Potelo\MultiPayment\Exceptions\GatewayException; +use Potelo\MultiPayment\Exceptions\ConfigurationException; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; +use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; -abstract class Model +/** + * @property array $gatewayAdicionalOptions Obsoleto desde 2026-09-02, use $gatewayOptions. Alias + * que lê e escreve o mesmo array, com aviso de deprecação. + */ +abstract class Model implements \JsonSerializable { + /** + * Propriedades convertidas para enum ao serem escritas, por nome. O valor é a classe do + * enum ou, para uma lista de enums, a classe dentro de um array (`[PaymentMethod::class]`). + * Essas propriedades são declaradas `protected` no model e passam pelos métodos mágicos, + * que aceitam a string do valor ou o próprio caso do enum e devolvem sempre o enum. + * + * @var array|array{0: class-string<\BackedEnum>}> + */ + protected const ENUM_CASTS = []; /** - * @var array $gatewayAdicionalOptions Gateway adicional options Can be used to send adicional options to the gateway and override the default options + * Propriedades `protected` que o model lê e escreve pelos próprios métodos mágicos, fora de + * `ENUM_CASTS`, e que `fill()`, `toArray()` e `fillableKeys()` tratam como públicas. + * + * @var string[] */ - public array $gatewayAdicionalOptions = []; + protected const MAGIC_PROPERTIES = []; + + /** + * Capability que o gateway precisa declarar para operar este model, ou nulo quando qualquer + * gateway serve. `requiredCapabilities()` a devolve junto com as derivadas dos atributos. + * + * @var Capability|null + */ + protected const REQUIRED_CAPABILITY = null; + + /** + * Opções extras enviadas direto ao gateway. Cada driver mescla este array ao payload que + * monta a partir do model, e as chaves daqui sobrepõem as geradas. + * + * @var array + */ + public array $gatewayOptions = []; + + /** + * Devolve uma propriedade de enum (ver `ENUM_CASTS`) ou resolve a leitura do nome antigo + * `gatewayAdicionalOptions` para `gatewayOptions`. + * + * Devolve por referência para que `$model->gatewayAdicionalOptions['chave'] = 'valor'` + * continue alterando o array, como fazia quando a propriedade existia. + * + * @param string $name + * @return mixed + */ + public function &__get(string $name): mixed + { + if (isset(static::ENUM_CASTS[$name])) { + return $this->{$name}; + } + + if ($name === 'gatewayAdicionalOptions') { + self::warnGatewayAdicionalOptionsDeprecated(); + + return $this->gatewayOptions; + } + + trigger_error('Undefined property: ' . static::class . '::$' . $name, E_USER_WARNING); + $undefined = null; + + return $undefined; + } + + /** + * Escreve numa propriedade de enum (ver `ENUM_CASTS`), convertendo string no caso do enum, + * ou resolve a escrita no nome antigo `gatewayAdicionalOptions` para `gatewayOptions`. + * Qualquer outro nome segue o comportamento padrão do PHP (propriedade dinâmica). + * + * @param string $name + * @param mixed $value + * @return void + * @throws ModelAttributeValidationException + */ + public function __set(string $name, mixed $value): void + { + if (isset(static::ENUM_CASTS[$name])) { + $this->{$name} = $this->castToEnum($name, $value); + + return; + } + + if ($name === 'gatewayAdicionalOptions') { + self::warnGatewayAdicionalOptionsDeprecated(); + $this->gatewayOptions = $value; + + return; + } + + $this->{$name} = $value; + } + + /** + * Mantém `isset()` e `empty()` funcionando sobre as propriedades de enum e sobre o nome + * antigo `gatewayAdicionalOptions`. + * + * @param string $name + * @return bool + */ + public function __isset(string $name): bool + { + if (isset(static::ENUM_CASTS[$name])) { + return isset($this->{$name}); + } + + return $name === 'gatewayAdicionalOptions'; + } + + /** + * Emite o aviso de deprecação do nome antigo `gatewayAdicionalOptions`. + * + * @return void + */ + private static function warnGatewayAdicionalOptionsDeprecated(): void + { + trigger_error( + 'Model::$gatewayAdicionalOptions está obsoleto desde 2026-09-02; use $gatewayOptions', + E_USER_DEPRECATED + ); + } + + /** + * Converte o valor escrito numa propriedade de `ENUM_CASTS` para o enum declarado. + * + * Aceita o caso do enum, a string do valor ou nulo; numa lista, um array desses. Enum que + * implementa `AcceptsUnknownValue` recebe a string desconhecida e decide o que fazer; nos + * demais, string fora do enum lança `ModelAttributeValidationException` com os valores + * aceitos. + * + * @param string $property + * @param mixed $value + * @return \BackedEnum|\BackedEnum[]|null + * @throws ModelAttributeValidationException + */ + private function castToEnum(string $property, mixed $value): mixed + { + $cast = static::ENUM_CASTS[$property]; + + if (!is_array($cast)) { + return $this->castScalarToEnum($property, $cast, $value); + } + + if (is_null($value)) { + return null; + } + + if (!is_array($value)) { + throw ModelAttributeValidationException::invalid( + static::getClassName(), + $property, + "{$property} must be an array" + ); + } + + return array_map(fn ($item) => $this->castScalarToEnum($property, $cast[0], $item), $value); + } + + /** + * Converte um único valor no caso do enum informado. + * + * @param string $property + * @param class-string<\BackedEnum> $enumClass + * @param mixed $value + * @return \BackedEnum|null + * @throws ModelAttributeValidationException + */ + private function castScalarToEnum(string $property, string $enumClass, mixed $value): ?\BackedEnum + { + if (is_null($value) || $value instanceof $enumClass) { + return $value; + } + + $accepted = implode(', ', array_column($enumClass::cases(), 'value')); + + if (!is_string($value)) { + throw ModelAttributeValidationException::invalid( + static::getClassName(), + $property, + "{$property} must be one of: {$accepted}" + ); + } + + if (is_subclass_of($enumClass, AcceptsUnknownValue::class)) { + $gateway = property_exists($this, 'gateway') ? $this->gateway : null; + + return $enumClass::fromValue($value, $gateway); + } + + return $enumClass::tryFrom($value) ?? throw ModelAttributeValidationException::invalid( + static::getClassName(), + $property, + "{$property} must be one of: {$accepted}" + ); + } + + /** + * Converte enum em valor de string, inclusive dentro de uma lista; qualquer outro valor + * passa intacto. + * + * @param mixed $value + * @return mixed + */ + private static function enumToValue(mixed $value): mixed + { + if ($value instanceof \BackedEnum) { + return $value->value; + } + + if (is_array($value)) { + return array_map( + static fn ($item) => $item instanceof \BackedEnum ? $item->value : $item, + $value + ); + } + + return $value; + } /** * Create a new instance of the model with an array of attributes. * * @param array $data - * @param null $gateway + * @param string|GatewayContract|null $gateway + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * * @return void * @throws GatewayException * @throws GatewayNotAvailableException * @throws ModelAttributeValidationException */ - public function create(array $data, $gateway = null): void + public function create(array $data, $gateway = null, ?string $idempotencyKey = null): void { $this->fill($data); - $this->save($gateway); + $this->save($gateway, true, $idempotencyKey); } /** - * If gateway is set, then we will use it to save the model + * Salva o model no gateway: `create{Model}` sem `id`, `update{Model}` com `id` (sem + * validação). A chave de idempotência vai para essa operação; o cliente salvo antes de uma + * fatura ou assinatura recebe a chave derivada `{chave}:customer`. Driver que declara a + * capability sem ter o método de despacho lança `ConfigurationException` antes da rede. * * @param string|GatewayContract|null $gateway * @param bool $validate + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * * @return void - * @throws GatewayException|GatewayNotAvailableException|ModelAttributeValidationException|\Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws GatewayException|GatewayNotAvailableException|ModelAttributeValidationException|ConfigurationException + * @throws UnsupportedOperationException */ - public function save(GatewayContract|string $gateway = null, bool $validate = true): void + public function save(GatewayContract|string|null $gateway = null, bool $validate = true, ?string $idempotencyKey = null): void { $class = $this->getClassName(); if (property_exists($this, 'id') && !empty($this->id)) { $method = 'update'; $validate = false; - // If gateway from the model is set, we will use it - $gateway = property_exists($this, 'gateway') && !empty($this->gateway) ? $this->gateway : $gateway; } else { $method = 'create'; } $method = $method . $class; + $gateway = $this->gatewayForSave($gateway); if ($validate) { $this->validate(); } $gatewayClass = ConfigurationHelper::resolveGateway($gateway); + $this->assertGatewaySupports($gatewayClass); if (!method_exists($gatewayClass, $method)) { - throw GatewayException::methodNotFound(get_class($gatewayClass), $method); + throw ConfigurationException::GatewayMethodNotFound(get_class($gatewayClass), $method); + } + $gatewayClass->$method($this, $idempotencyKey); + } + + /** + * Gateway que `save()` usa: no update (com `id`), o gateway gravado no model prevalece sobre + * o informado; na criação, o informado prevalece e o do model é o segundo candidato. Nulo + * deixa `ConfigurationHelper` escolher o default. + * + * @param GatewayContract|string|null $gateway + * @return GatewayContract|string|null + */ + protected function gatewayForSave(GatewayContract|string|null $gateway): GatewayContract|string|null + { + $own = property_exists($this, 'gateway') && !empty($this->gateway) ? $this->gateway : null; + + if (property_exists($this, 'id') && !empty($this->id)) { + return $own ?? $gateway; + } + + return $gateway ?? $own; + } + + /** + * Capabilities que o gateway precisa declarar para a operação em curso sobre este model: + * `REQUIRED_CAPABILITY`, quando definida, mais as que o model derivar dos seus atributos. + * + * @return Capability[] + */ + public function requiredCapabilities(): array + { + return is_null(static::REQUIRED_CAPABILITY) ? [] : [static::REQUIRED_CAPABILITY]; + } + + /** + * Lança `UnsupportedOperationException` na primeira capability de `requiredCapabilities()` + * que o gateway não declara, antes de qualquer requisição. + * + * @param GatewayContract $gateway + * @return void + * @throws UnsupportedOperationException + */ + protected function assertGatewaySupports(GatewayContract $gateway): void + { + foreach ($this->requiredCapabilities() as $capability) { + if (!$gateway->supports($capability)) { + throw UnsupportedOperationException::forGateway($gateway, $capability); + } } - $gatewayClass->$method($this); } /** @@ -105,24 +376,105 @@ protected function attributesExtraValidation(array $attributes): void } /** - * Fill the model with an array of attributes. + * Fill the model with an array of attributes. Chave em `snake_case` vira a propriedade em + * `camelCase`; valor de propriedade de enum (ver `ENUM_CASTS`) pode vir como string. + * + * Chave que não corresponde a nenhuma propriedade do model lança + * `ModelAttributeValidationException` com a lista das chaves aceitas, exceto chave com + * prefixo `gateway_` (ou `gateway` em `camelCase`; o conteúdo de `gateway_options` é livre + * e chega inteiro ao gateway). + * Com `multi-payment.strict_fill` em falso, a chave desconhecida é ignorada em silêncio. * * @param array $data * * @return void + * @throws ModelAttributeValidationException */ public function fill(array $data): void { foreach ($data as $key => $value) { - $key = lcfirst(str_replace('_', '', ucwords($key, '_'))); - if (property_exists($this, $key)) { - $this->{$key} = $value; + $property = lcfirst(str_replace('_', '', ucwords($key, '_'))); + if ($property === 'gatewayAdicionalOptions') { + self::warnGatewayAdicionalOptionsDeprecated(); + $property = 'gatewayOptions'; + } + if (!static::isFillableProperty($property)) { + if (!str_starts_with($property, 'gateway') && ConfigurationHelper::strictFill()) { + throw ModelAttributeValidationException::unknownAttribute( + static::getClassName(), + (string) $key, + static::fillableKeys() + ); + } + continue; + } + $this->{$property} = isset(static::ENUM_CASTS[$property]) ? $this->castToEnum($property, $value) : $value; + } + } + + /** + * Chaves que `fill()` aceita, em `snake_case`: as propriedades públicas do model, as de + * enum (ver `ENUM_CASTS`) e as de `MAGIC_PROPERTIES`, na ordem de declaração. + * + * @return string[] + */ + public static function fillableKeys(): array + { + $keys = []; + $reflect = new \ReflectionClass(static::class); + foreach ($reflect->getProperties(\ReflectionProperty::IS_PUBLIC | \ReflectionProperty::IS_PROTECTED) as $prop) { + $name = $prop->getName(); + if ($prop->isStatic() || ($prop->isProtected() && !static::isMagicProperty($name))) { + continue; } + $keys[] = self::snakeCase($name); } + + return $keys; + } + + /** + * Diz se `fill()` pode escrever na propriedade: ela existe no model e é pública ou protegida + * (propriedade privada é estado interno e conta como chave desconhecida). + * + * @param string $property + * @return bool + */ + private static function isFillableProperty(string $property): bool + { + if (!property_exists(static::class, $property)) { + return false; + } + + return !(new \ReflectionProperty(static::class, $property))->isPrivate(); + } + + /** + * Diz se a propriedade `protected` é exposta pelos métodos mágicos do model (enum de + * `ENUM_CASTS` ou nome em `MAGIC_PROPERTIES`). + * + * @param string $name + * @return bool + */ + protected static function isMagicProperty(string $name): bool + { + return isset(static::ENUM_CASTS[$name]) || in_array($name, static::MAGIC_PROPERTIES, true); + } + + /** + * Converte o nome de uma propriedade em `camelCase` para a chave em `snake_case`. + * + * @param string $name + * @return string + */ + private static function snakeCase(string $name): string + { + return strtolower(preg_replace('/(?getProperties(\ReflectionProperty::IS_PUBLIC); + $props = $reflect->getProperties(\ReflectionProperty::IS_PUBLIC | \ReflectionProperty::IS_PROTECTED); foreach ($props as $prop) { - if (!empty($this->{$prop->getName()})) { - $key = strtolower(preg_replace('/(?getName())); - $array[$key] = $this->{$prop->getName()}; + $name = $prop->getName(); + if ($prop->isProtected() && !static::isMagicProperty($name)) { + continue; + } + if (!empty($this->{$name})) { + $array[self::snakeCase($name)] = self::enumToValue($this->{$name}); } } return $array; } + /** + * Serializa o model para `json_encode()` com o nome da propriedade em `camelCase` como + * chave, incluindo as propriedades de enum, que saem como valor de string. + * + * @return array + */ + public function jsonSerialize(): array + { + return get_object_vars($this); + } + /** * Return the class name of the model without namespace. * @@ -154,19 +520,20 @@ protected static function getClassName(): string /** * Get the model instance by id in the gateway. * - * @param string $id * @param string|GatewayContract|null $gateway * * @return static * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws UnsupportedOperationException */ - public function get(GatewayContract|string $gateway = null): static + public function get(GatewayContract|string|null $gateway = null): static { $method = 'get' . static::getClassName(); $gateway = ConfigurationHelper::resolveGateway($gateway); + $this->assertGatewaySupports($gateway); if (!method_exists($gateway, $method)) { - throw GatewayException::methodNotFound(get_class($gateway), $method); + throw ConfigurationException::GatewayMethodNotFound(get_class($gateway), $method); } return $gateway->$method($this); } @@ -175,24 +542,27 @@ public function get(GatewayContract|string $gateway = null): static * Delete the model instance by id in the gateway. * * @param \Potelo\MultiPayment\Contracts\GatewayContract|string|null $gateway + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * @return void * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws UnsupportedOperationException */ - public function delete(GatewayContract|string $gateway = null): void + public function delete(GatewayContract|string|null $gateway = null, ?string $idempotencyKey = null): void { $method = 'delete' . static::getClassName(); $gateway = ConfigurationHelper::resolveGateway($gateway); + $this->assertGatewaySupports($gateway); if (!method_exists($gateway, $method)) { - throw GatewayException::methodNotFound(get_class($gateway), $method); + throw ConfigurationException::GatewayMethodNotFound(get_class($gateway), $method); } - $gateway->$method($this); + $gateway->$method($this, $idempotencyKey); } /** * Refresh the model instance with the latest data from the gateway. */ - public function refresh(GatewayContract|string $gateway = null): static + public function refresh(GatewayContract|string|null $gateway = null): static { $gateway = ConfigurationHelper::resolveGateway($gateway); return $this->get($gateway); diff --git a/src/Models/Plan.php b/src/Models/Plan.php new file mode 100644 index 0000000..4d0cc16 --- /dev/null +++ b/src/Models/Plan.php @@ -0,0 +1,155 @@ + PlanInterval::class, + ]; + + protected const REQUIRED_CAPABILITY = Capability::PLANS; + + /** + * @var string|null + */ + public ?string $id = null; + + /** + * Identificador do plano no gateway, quando ele aceita um definido por quem cria. + * + * @var string|null + */ + public ?string $identifier = null; + + /** + * @var string|null + */ + public ?string $name = null; + + /** + * @var int|null + */ + public ?int $amount = null; + + /** + * @var PlanInterval|null + */ + protected ?PlanInterval $interval = null; + + /** + * @var int|null + */ + public ?int $intervalCount = null; + + /** + * @var string|null + */ + public ?string $currency = null; + + /** + * @var bool|null + */ + public ?bool $active = null; + + /** + * @var string|null + */ + public ?string $gateway = null; + + /** + * A resposta original do gateway, caso seja necessária alguma informação adicional. + * + * @var mixed|null + */ + public $original = null; + + /** + * Cria o plano no gateway. Plano com `id` preenchido lança + * `ModelAttributeValidationException` antes da requisição: plano não é atualizável. + * + * @param GatewayContract|string|null $gateway + * @param bool $validate + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * + * @return void + * @throws GatewayException|\Potelo\MultiPayment\Exceptions\GatewayNotAvailableException + * @throws ModelAttributeValidationException|\Potelo\MultiPayment\Exceptions\ConfigurationException + */ + public function save(GatewayContract|string|null $gateway = null, bool $validate = true, ?string $idempotencyKey = null): void + { + if (!empty($this->id)) { + throw ModelAttributeValidationException::invalid( + 'Plan', + 'id', + 'A plan cannot be updated. Create a new plan instead.' + ); + } + + parent::save($gateway, $validate, $idempotencyKey); + } + + /** + * @return void + * @throws ModelAttributeValidationException + */ + protected function validateAmountAttribute(): void + { + if ($this->amount < 0) { + throw ModelAttributeValidationException::invalid( + $this->getClassName(), + 'amount', + 'amount must not be negative.' + ); + } + } + + /** + * @inheritDoc + */ + protected function attributesExtraValidation(array $attributes): void + { + $model = $this->getClassName(); + + foreach (['name', 'amount', 'interval'] as $attribute) { + if (in_array($attribute, $attributes) && is_null($this->{$attribute})) { + throw ModelAttributeValidationException::required($model, $attribute); + } + } + + // validate() pula atributo vazio, então zero e negativo de intervalCount não chegariam + // a um validateIntervalCountAttribute() + if ( + in_array('intervalCount', $attributes) + && !is_null($this->intervalCount) + && $this->intervalCount < 1 + ) { + throw ModelAttributeValidationException::invalid( + $model, + 'intervalCount', + 'intervalCount must be at least 1.' + ); + } + } +} diff --git a/src/Models/Refund.php b/src/Models/Refund.php new file mode 100644 index 0000000..7a3303e --- /dev/null +++ b/src/Models/Refund.php @@ -0,0 +1,110 @@ + RefundStatus::class, + ]; + + /** + * Id do estorno no gateway. Nulo quando o gateway não identifica o estorno (a Iugu só + * informa o total estornado da fatura). + * + * @var string|null + */ + public ?string $id = null; + + /** + * Id da fatura estornada. + * + * @var string|null + */ + public ?string $invoiceId = null; + + /** + * Valor estornado, em centavos. + * + * @var int|null + */ + public ?int $amount = null; + + /** + * @var RefundStatus|null + */ + protected ?RefundStatus $status = null; + + /** + * Motivo do estorno, em texto livre. Preenchido quando o gateway devolve um; a lib não o + * envia ao gateway. + * + * @var string|null + */ + public ?string $reason = null; + + /** + * @var Carbon|null + */ + public ?Carbon $createdAt = null; + + /** + * @var string|null + */ + public ?string $gateway = null; + + /** + * Objeto de estorno devolvido pelo gateway; nulo quando o gateway não tem um. + * + * @var mixed + */ + public mixed $original = null; + + /** + * Fatura com o estado posterior ao estorno. Preenchida pela operação de estorno; fica + * nula num `Refund` lido de `Invoice::$refunds`. + * + * @var Invoice|null + */ + public ?Invoice $invoice = null; + + /** + * Devolve a fatura estornada. Num `Refund` devolvido pela operação de estorno é a fatura + * já relida, sem requisição; num `Refund` lido de `Invoice::$refunds` custa um GET na + * primeira chamada, e o resultado fica guardado em `$invoice`. + * + * @return Invoice + * @throws ModelAttributeValidationException + * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + */ + public function invoice(): Invoice + { + if (!empty($this->invoice)) { + return $this->invoice; + } + + if (empty($this->invoiceId)) { + throw ModelAttributeValidationException::required('Refund', 'invoiceId'); + } + + $invoice = new Invoice(); + $invoice->id = $this->invoiceId; + $invoice->gateway = $this->gateway; + + return $this->invoice = $invoice->get($this->gateway); + } +} diff --git a/src/Models/Subscription.php b/src/Models/Subscription.php new file mode 100644 index 0000000..a532adb --- /dev/null +++ b/src/Models/Subscription.php @@ -0,0 +1,739 @@ + SubscriptionStatus::class, + 'paymentMethod' => PaymentMethod::class, + 'availablePaymentMethods' => [PaymentMethod::class], + ]; + + protected const REQUIRED_CAPABILITY = Capability::SUBSCRIPTIONS; + + /** + * Além de `SUBSCRIPTIONS`, a assinatura precisa da capability de cada método de + * `resolvedPaymentMethods()` (e de `MULTIPLE_PAYMENT_METHODS` quando há mais de um), de + * `MANAGES_RECURRENCE` quando o método é Pix Automático (a assinatura com mandato só + * existe onde o gateway agenda as cobranças), de `RAW_CARD_DATA` quando o cartão vem com + * os dados crus (sem `id` nem `token`), de `PERCENT_DISCOUNT` quando algum desconto é + * percentual e de `COUPONS` quando algum desconto é limitado a mais de um ciclo ou tem + * data de validade. + * + * @return Capability[] + * @throws ModelAttributeValidationException método de pagamento que a assinatura não aceita + */ + public function requiredCapabilities(): array + { + $capabilities = parent::requiredCapabilities(); + + $methods = $this->resolvedPaymentMethods(); + foreach ($methods as $method) { + $capabilities[] = Capability::forPaymentMethod($method); + } + if (in_array(PaymentMethod::AUTOMATIC_PIX, $methods, true)) { + $capabilities[] = Capability::MANAGES_RECURRENCE; + } + if (count($methods) > 1) { + $capabilities[] = Capability::MULTIPLE_PAYMENT_METHODS; + } + if (!empty($this->creditCard) && empty($this->creditCard->id) && empty($this->creditCard->token)) { + $capabilities[] = Capability::RAW_CARD_DATA; + } + + foreach ($this->discounts ?? [] as $discount) { + if (!$discount instanceof SubscriptionDiscount) { + continue; + } + if (!is_null($discount->percentOff) && !in_array(Capability::PERCENT_DISCOUNT, $capabilities, true)) { + $capabilities[] = Capability::PERCENT_DISCOUNT; + } + $hasTerm = !empty($discount->validUntil) || (!is_null($discount->cycles) && $discount->cycles > 1); + if ($hasTerm && !in_array(Capability::COUPONS, $capabilities, true)) { + $capabilities[] = Capability::COUPONS; + } + } + + return $capabilities; + } + + /** + * @var string|null + */ + public ?string $id = null; + + /** + * @var SubscriptionStatus|null + */ + protected ?SubscriptionStatus $status = null; + + /** + * @var Customer|null + */ + public ?Customer $customer = null; + + /** + * @var string|null + */ + public ?string $planId = null; + + /** + * @var SubscriptionItem[]|null + */ + public ?array $items = null; + + /** + * Descontos aplicados sobre o valor da assinatura. `amountOff` é o total abatido, ao + * contrário de `SubscriptionItem::$amount`, que é unitário. + * + * @var SubscriptionDiscount[]|null + */ + public ?array $discounts = null; + + /** + * @var int|null + */ + public ?int $amount = null; + + /** + * Método com que a assinatura é cobrada. Na escrita, quando `availablePaymentMethods` fica + * vazia, o driver a deriva dele; na leitura é preenchido quando a assinatura aceita um + * único método. + * + * @var PaymentMethod|null + */ + protected ?PaymentMethod $paymentMethod = null; + + /** + * @var PaymentMethod[]|null + */ + protected ?array $availablePaymentMethods = null; + + /** + * Cartão que a assinatura cobra. Informar o cartão implica `paymentMethod` de cartão. + * Cartão sem `id` (token ou dados crus) é salvo no cliente ao criar a assinatura. Na Iugu + * a assinatura cobra o cartão padrão do cliente, então o cartão informado passa a ser o + * padrão; a leitura não o preenche. + * + * @var CreditCard|null + */ + public ?CreditCard $creditCard = null; + + /** + * Estado da recorrência de Pix Automático da assinatura, preenchido na leitura quando ela + * é cobrada por mandato (Stripe). Na escrita é opcional e refina o mandato: `startsAt` + * (primeiro débito, no mínimo três dias à frente), `endsAt` (fim do mandato) e `frequency` + * (agenda, derivada do intervalo do plano quando ausente). + * + * @var AutomaticPix|null + */ + public ?AutomaticPix $automaticPix = null; + + /** + * Duração do período de teste em dias, contada do momento da requisição. O driver a + * converte em `trialEndsAt` e a zera, então o model devolvido traz a data; incompatível + * com `trialEndsAt` preenchido. + * + * @var int|null + */ + public ?int $trialDays = null; + + /** + * @var Carbon|null + */ + public ?Carbon $trialEndsAt = null; + + /** + * Data da próxima cobrança. Preenchida ao ler a assinatura e, quando informada, aplicada na + * criação e na troca de plano. + * + * @var Carbon|null + */ + public ?Carbon $nextBillingAt = null; + + /** + * Diz se há cancelamento agendado para o fim do período corrente. No Stripe vem do + * gateway; na Iugu vem da marca `mp_cancel_at_period_end` que `cancel(atPeriodEnd: true)` + * grava em `custom_variables`, aplicada na data pelo comando + * `multipayment:sync-subscriptions`. + * + * @var bool|null + */ + public ?bool $cancelAtPeriodEnd = null; + + /** + * Momento em que a assinatura foi cancelada. Na Iugu vem da marca `mp_canceled_at` que + * `cancel()` grava em `custom_variables`. + * + * @var Carbon|null + */ + public ?Carbon $canceledAt = null; + + /** + * @var Carbon|null + */ + public ?Carbon $createdAt = null; + + /** + * Fatura mais recente gerada pela assinatura. Não é necessariamente a que está em aberto: + * assinatura em `past_due` pode trazer aqui uma fatura já quitada. Pode vir resumida, porque + * nem todo gateway devolve a fatura inteira junto da assinatura. + * + * @var Invoice|null + */ + public ?Invoice $latestInvoice = null; + + /** + * @var array|null + */ + public ?array $metadata = null; + + /** + * @var string|null + */ + public ?string $gateway = null; + + /** + * A resposta original do gateway, caso seja necessária alguma informação adicional. + * + * @var mixed|null + */ + public $original = null; + + /** + * @inheritDoc + */ + public function fill(array $data): void + { + foreach ( + [ + 'trial_ends_at' => 'trialEndsAt', + 'next_billing_at' => 'nextBillingAt', + 'canceled_at' => 'canceledAt', + 'created_at' => 'createdAt', + ] as $key => $attribute + ) { + if (!empty($data[$key])) { + $this->{$attribute} = $data[$key] instanceof Carbon + ? $data[$key] + : Carbon::parse($data[$key]); + unset($data[$key]); + } + } + + if (!empty($data['customer']) && is_array($data['customer'])) { + $customer = new Customer(); + $customer->fill($data['customer']); + $data['customer'] = $customer; + } + + if (!empty($data['credit_card']) && is_array($data['credit_card'])) { + $creditCard = new CreditCard(); + $creditCard->fill($data['credit_card']); + $data['credit_card'] = $creditCard; + } + + if (!empty($data['latest_invoice']) && is_array($data['latest_invoice'])) { + $invoice = new Invoice(); + $invoice->fill($data['latest_invoice']); + $data['latest_invoice'] = $invoice; + } + + if (!empty($data['automatic_pix']) && is_array($data['automatic_pix'])) { + $automaticPix = new AutomaticPix(); + $automaticPix->fill($data['automatic_pix']); + $data['automatic_pix'] = $automaticPix; + } + + $data['items'] = $this->fillCollection($data['items'] ?? null, SubscriptionItem::class); + $data['discounts'] = $this->fillCollection($data['discounts'] ?? null, SubscriptionDiscount::class); + + foreach (['items', 'discounts'] as $key) { + if (is_null($data[$key])) { + unset($data[$key]); + } + } + + parent::fill($data); + } + + /** + * Converte cada entrada de uma lista em instância da classe informada, mantendo as que já + * são instâncias. Devolve null quando a lista não é um array. + * + * @param mixed $values + * @param class-string $class + * + * @return array|null + */ + private function fillCollection($values, string $class): ?array + { + if (!is_array($values)) { + return null; + } + + return array_map(function ($value) use ($class) { + if ($value instanceof $class) { + return $value; + } + + /** @var Model $model */ + $model = new $class(); + $model->fill($value); + + return $model; + }, $values); + } + + /** + * @inheritDoc + */ + public function toArray(): array + { + $array = parent::toArray(); + + foreach (['items', 'discounts'] as $key) { + if (!empty($array[$key])) { + $array[$key] = array_map(fn(Model $model) => $model->toArray(), $array[$key]); + } + } + + foreach (['customer', 'credit_card', 'latest_invoice', 'automatic_pix'] as $key) { + if (!empty($array[$key])) { + $array[$key] = $array[$key]->toArray(); + } + } + + return $array; + } + + /** + * Método de pagamento com que a assinatura será criada quando `availablePaymentMethods` + * está vazia: `paymentMethod` quando informado; senão cartão, quando `creditCard` foi + * informado; senão nulo (o gateway usa o padrão dele). + * + * @return PaymentMethod|null + */ + public function resolvedPaymentMethod(): ?PaymentMethod + { + if (!is_null($this->paymentMethod)) { + return $this->paymentMethod; + } + + return !empty($this->creditCard) ? PaymentMethod::CREDIT_CARD : null; + } + + /** + * Métodos de pagamento com que a assinatura será criada, na ordem de precedência que os + * drivers seguem: `availablePaymentMethods` quando preenchida (normalizada, porque uma + * string apensada por `[]=` entra no array sem conversão); senão o método de + * `resolvedPaymentMethod()`; senão lista vazia. Lança `ModelAttributeValidationException` + * para valor fora de `PaymentMethod::selectable()`, para `paymentMethod` fora da lista + * informada, para `creditCard` sem cartão entre os métodos resultantes (num model lido do + * gateway a lista vem preenchida: para trocar o método, troque a lista ou a zere) e para + * `automaticPix` sem Pix Automático como método (o estado do mandato só existe nesse + * método; sem a recusa, ele seria descartado em silêncio). + * + * @return PaymentMethod[] + * @throws ModelAttributeValidationException + */ + public function resolvedPaymentMethods(): array + { + if (!is_null($this->paymentMethod)) { + $this->validatePaymentMethodAttribute(); + } + + if (!empty($this->availablePaymentMethods)) { + $methods = array_values(array_unique( + PaymentMethod::normalizeSelectable($this->availablePaymentMethods, $this->getClassName()), + SORT_REGULAR + )); + Invoice::assertPaymentMethodIsListed($this->getClassName(), $this->paymentMethod, $methods); + } else { + $method = $this->resolvedPaymentMethod(); + $methods = is_null($method) ? [] : [$method]; + } + + Invoice::assertCreditCardIsPayable($this->getClassName(), $this->creditCard, $methods); + + if (!empty($this->automaticPix) && !in_array(PaymentMethod::AUTOMATIC_PIX, $methods, true)) { + throw ModelAttributeValidationException::invalid( + $this->getClassName(), + 'automaticPix', + 'automaticPix was given but automatic_pix is not the payment method;' + . ' set paymentMethod to automatic_pix or remove it' + ); + } + + return $methods; + } + + /** + * @return void + * @throws ModelAttributeValidationException + */ + protected function validateCreditCardAttribute(): void + { + $this->creditCard->validate(); + } + + /** + * Na escrita, `paymentMethod` precisa ser um método selecionável + * (`PaymentMethod::selectable()`) ou Pix Automático, que na assinatura é um método de + * primeira classe (mandato). + * + * @return void + * @throws ModelAttributeValidationException + */ + protected function validatePaymentMethodAttribute(): void + { + $accepted = [...PaymentMethod::selectable(), PaymentMethod::AUTOMATIC_PIX]; + if (!in_array($this->paymentMethod, $accepted, true)) { + $acceptedValues = implode(', ', array_column($accepted, 'value')); + throw ModelAttributeValidationException::invalid( + $this->getClassName(), + 'paymentMethod', + "paymentMethod must be one of: {$acceptedValues}" + ); + } + } + + /** + * @return void + * @throws ModelAttributeValidationException + */ + protected function validateAutomaticPixAttribute(): void + { + $this->automaticPix->validate(); + } + + /** + * @return void + * @throws ModelAttributeValidationException + */ + protected function validateCustomerAttribute(): void + { + $this->customer->validate(); + } + + /** + * @return void + * @throws ModelAttributeValidationException + */ + protected function validateItemsAttribute(): void + { + foreach ($this->items as $item) { + if (!$item instanceof SubscriptionItem) { + throw ModelAttributeValidationException::invalid( + $this->getClassName(), + 'items', + 'items must be an array of SubscriptionItem' + ); + } + + $item->validate(); + } + } + + /** + * @return void + * @throws ModelAttributeValidationException + */ + protected function validateDiscountsAttribute(): void + { + foreach ($this->discounts as $discount) { + if (!$discount instanceof SubscriptionDiscount) { + throw ModelAttributeValidationException::invalid( + $this->getClassName(), + 'discounts', + 'discounts must be an array of SubscriptionDiscount' + ); + } + + $discount->validate(); + } + } + + /** + * Garante que `availablePaymentMethods` é uma lista de métodos selecionáveis + * (`PaymentMethod::selectable()`), convertendo string que tenha entrado por escrita + * indireta no array. + * + * @return void + * @throws ModelAttributeValidationException + */ + protected function validateAvailablePaymentMethodsAttribute(): void + { + $this->availablePaymentMethods = PaymentMethod::normalizeSelectable( + $this->availablePaymentMethods, + $this->getClassName() + ); + } + + /** + * @inheritDoc + */ + protected function attributesExtraValidation(array $attributes): void + { + $model = $this->getClassName(); + + // zero é vazio para o validate() do Model, então o mínimo não pode depender de + // validateTrialDaysAttribute() + if (in_array('trialDays', $attributes) && !is_null($this->trialDays) && $this->trialDays < 1) { + throw ModelAttributeValidationException::invalid($model, 'trialDays', 'trialDays must be at least 1'); + } + + if ( + in_array('trialDays', $attributes) + && in_array('trialEndsAt', $attributes) + && !is_null($this->trialDays) + && !empty($this->trialEndsAt) + ) { + throw ModelAttributeValidationException::invalid( + $model, + 'trialDays', + 'trialDays and trialEndsAt are mutually exclusive' + ); + } + + if ( + in_array('automaticPix', $attributes) + || (in_array('paymentMethod', $attributes) && in_array('creditCard', $attributes)) + ) { + $this->resolvedPaymentMethods(); + } + + // com id preenchido é update, que aceita atributo parcial: cliente e plano não vão no + // payload e não são exigidos + if (!empty($this->id)) { + return; + } + + if (in_array('customer', $attributes) && empty($this->customer)) { + throw ModelAttributeValidationException::required($model, 'customer'); + } + + if (in_array('planId', $attributes) && empty($this->planId)) { + throw ModelAttributeValidationException::required($model, 'planId'); + } + } + + /** + * Salva a assinatura, criando antes o cliente quando ele ainda não tem id. + * + * Com `id` preenchido é update: o cliente não é tocado, e cliente e plano deixam de ser + * obrigatórios — as demais validações continuam valendo. + * + * @param GatewayContract|string|null $gateway + * @param bool $validate + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * + * @return void + * @throws GatewayException|\Potelo\MultiPayment\Exceptions\GatewayNotAvailableException + * @throws ModelAttributeValidationException|\Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws UnsupportedOperationException + */ + public function save(GatewayContract|string|null $gateway = null, bool $validate = true, ?string $idempotencyKey = null): void + { + if ($validate) { + $this->validate(); + } + + // resolvido e verificado antes de salvar o cliente, para nenhuma requisição sair + // quando o gateway não suporta assinatura; no update vale a regra do Model (o gateway + // gravado no model prevalece) + $gateway = $this->resolveSubscriptionGateway($this->gatewayForSave($gateway)); + + if (empty($this->id) && !empty($this->customer) && empty($this->customer->id)) { + $this->customer->save($gateway, $validate, IdempotencyKey::derive($idempotencyKey, 'customer')); + } + + parent::save($gateway, false, $idempotencyKey); + } + + /** + * Resolve o gateway e garante que ele declara `Capability::SUBSCRIPTIONS` e implementa + * `SubscriptionContract`. + * + * @param GatewayContract|string|null $gateway + * + * @return GatewayContract&SubscriptionContract + * @throws ConfigurationException driver que declara a capability sem implementar o contract + * @throws UnsupportedOperationException + */ + private function resolveSubscriptionGateway(GatewayContract|string|null $gateway) + { + $resolved = ConfigurationHelper::resolveGateway($gateway ?? $this->gateway); + $this->assertGatewaySupports($resolved); + + if (!$resolved instanceof SubscriptionContract) { + throw ConfigurationException::GatewayMissingContract($resolved, Capability::SUBSCRIPTIONS, SubscriptionContract::class); + } + + return $resolved; + } + + /** + * Suspende a cobrança da assinatura, mantendo-a reativável por resume(). + * + * @param GatewayContract|string|null $gateway + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * + * @return Subscription + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException + * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException + * @throws UnsupportedOperationException + */ + public function suspend(GatewayContract|string|null $gateway = null, ?string $idempotencyKey = null): Subscription + { + return $this->resolveSubscriptionGateway($gateway)->suspendSubscription($this, $idempotencyKey); + } + + /** + * Volta a cobrar uma assinatura suspensa e desfaz um cancelamento agendado + * (`cancel(atPeriodEnd: true)`); na Iugu, também reativa uma cancelada por `cancel()` (as + * marcas de cancelamento são removidas de `custom_variables`). + * + * @param GatewayContract|string|null $gateway + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * + * @return Subscription + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException + * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException + * @throws UnsupportedOperationException + */ + public function resume(GatewayContract|string|null $gateway = null, ?string $idempotencyKey = null): Subscription + { + return $this->resolveSubscriptionGateway($gateway)->resumeSubscription($this, $idempotencyKey); + } + + /** + * Cancela a assinatura, imediatamente ou ao fim do período corrente. + * + * @param bool $atPeriodEnd + * @param GatewayContract|string|null $gateway + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * + * @return Subscription + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException + * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException + * @throws UnsupportedOperationException + */ + public function cancel( + bool $atPeriodEnd = false, + GatewayContract|string|null $gateway = null, + ?string $idempotencyKey = null + ): Subscription { + return $this->resolveSubscriptionGateway($gateway)->cancelSubscription($this, $atPeriodEnd, $idempotencyKey); + } + + /** + * Troca o plano da assinatura com a política de pró-rata informada. + * + * Com `ProrationBehavior::CHARGE_DIFFERENCE` (o padrão) a troca gera a cobrança na hora e a + * fatura resultante volta em `latestInvoice`; com `NONE` nada é cobrado nem creditado agora; + * com `CREDIT` o gateway calcula o crédito do período não usado, e gateway sem + * `Capability::PLAN_CHANGE_PRORATION` lança `UnsupportedOperationException` antes de + * qualquer requisição. O booleano antigo continua aceito na mesma posição, e o argumento + * nomeado `charge` também; os dois são traduzidos (`true` é `CHARGE_DIFFERENCE`, `false` é + * `NONE`) com aviso `E_USER_DEPRECATED`, e `charge` informado prevalece sobre `$proration`. + * + * @param string $planId + * @param ProrationBehavior|bool $proration política de pró-rata; o booleano é o `$charge` antigo, obsoleto + * @param GatewayContract|string|null $gateway + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @param bool|null $charge obsoleto desde 2026-09-02; use `$proration` + * + * @return Subscription + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException + * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException + * @throws UnsupportedOperationException + */ + public function changePlan( + string $planId, + ProrationBehavior|bool $proration = ProrationBehavior::CHARGE_DIFFERENCE, + GatewayContract|string|null $gateway = null, + ?string $idempotencyKey = null, + ?bool $charge = null + ): Subscription { + $proration = ProrationBehavior::resolve($charge ?? $proration); + + return $this->resolveSubscriptionGateway($gateway) + ->changeSubscriptionPlan($this, $planId, $proration, $idempotencyKey); + } + + /** + * Simula a troca de plano sem aplicá-la. + * + * @param string $planId + * @param GatewayContract|string|null $gateway + * + * @return SubscriptionPlanChange + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException + * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException + * @throws UnsupportedOperationException + */ + public function previewPlanChange( + string $planId, + GatewayContract|string|null $gateway = null + ): SubscriptionPlanChange { + return $this->resolveSubscriptionGateway($gateway) + ->previewSubscriptionPlanChange($this, $planId); + } +} diff --git a/src/Models/SubscriptionDiscount.php b/src/Models/SubscriptionDiscount.php new file mode 100644 index 0000000..725ce24 --- /dev/null +++ b/src/Models/SubscriptionDiscount.php @@ -0,0 +1,150 @@ +validUntil = $data['valid_until'] instanceof Carbon + ? $data['valid_until'] + : Carbon::parse($data['valid_until']); + } + unset($data['valid_until']); + } + + parent::fill($data); + } + + /** + * @return void + * @throws ModelAttributeValidationException + */ + protected function validateAmountOffAttribute(): void + { + if ($this->amountOff <= 0) { + throw ModelAttributeValidationException::invalid( + $this->getClassName(), + 'amountOff', + 'amountOff must be a positive amount in cents.' + ); + } + } + + /** + * @return void + * @throws ModelAttributeValidationException + */ + protected function validatePercentOffAttribute(): void + { + if ($this->percentOff <= 0 || $this->percentOff > 100) { + throw ModelAttributeValidationException::invalid( + $this->getClassName(), + 'percentOff', + 'percentOff must be greater than 0 and at most 100.' + ); + } + } + + /** + * @inheritDoc + */ + protected function attributesExtraValidation(array $attributes): void + { + $model = $this->getClassName(); + + if (in_array('amountOff', $attributes) && in_array('percentOff', $attributes)) { + if (is_null($this->amountOff) && is_null($this->percentOff)) { + throw ModelAttributeValidationException::required($model, 'amountOff or percentOff'); + } + + if (!is_null($this->amountOff) && !is_null($this->percentOff)) { + throw ModelAttributeValidationException::invalid( + $model, + 'amountOff', + 'amountOff and percentOff are mutually exclusive.' + ); + } + } + + if (in_array('description', $attributes) && empty($this->description)) { + throw ModelAttributeValidationException::required($model, 'description'); + } + + // validate() pula atributo vazio, então zero e negativo de cycles não chegariam a um + // validateCyclesAttribute() + if (in_array('cycles', $attributes) && !is_null($this->cycles) && $this->cycles < 1) { + throw ModelAttributeValidationException::invalid( + $model, + 'cycles', + 'cycles must be null or at least 1.' + ); + } + + if ( + in_array('cycles', $attributes) + && in_array('validUntil', $attributes) + && !is_null($this->cycles) + && !empty($this->validUntil) + ) { + throw ModelAttributeValidationException::invalid( + $model, + 'cycles', + 'cycles and validUntil are mutually exclusive.' + ); + } + } +} diff --git a/src/Models/SubscriptionItem.php b/src/Models/SubscriptionItem.php new file mode 100644 index 0000000..264060b --- /dev/null +++ b/src/Models/SubscriptionItem.php @@ -0,0 +1,81 @@ +amount < 0) { + throw ModelAttributeValidationException::invalid( + $this->getClassName(), + 'amount', + 'amount must not be negative. Use SubscriptionDiscount to reduce the subscription value.' + ); + } + } + + /** + * @inheritDoc + */ + protected function attributesExtraValidation(array $attributes): void + { + $model = $this->getClassName(); + + if (in_array('description', $attributes) && empty($this->description)) { + throw ModelAttributeValidationException::required($model, 'description'); + } + + if (in_array('amount', $attributes) && is_null($this->amount)) { + throw ModelAttributeValidationException::required($model, 'amount'); + } + + // validate() só chama validate{Attr}Attribute() para atributo não vazio, então zero + // e negativo de quantity não chegariam a um validateQuantityAttribute() + if (in_array('quantity', $attributes) && !is_null($this->quantity) && $this->quantity < 1) { + throw ModelAttributeValidationException::invalid( + $model, + 'quantity', + 'quantity must be at least 1.' + ); + } + } +} diff --git a/src/Models/SubscriptionPlanChange.php b/src/Models/SubscriptionPlanChange.php new file mode 100644 index 0000000..09873a3 --- /dev/null +++ b/src/Models/SubscriptionPlanChange.php @@ -0,0 +1,89 @@ +fill($item); + + return $invoiceItem; + }, $data['items']); + } + + parent::fill($data); + } +} diff --git a/src/MultiPayment.php b/src/MultiPayment.php index 048157d..7be25f5 100644 --- a/src/MultiPayment.php +++ b/src/MultiPayment.php @@ -3,18 +3,29 @@ namespace Potelo\MultiPayment; use Carbon\Carbon; +use Potelo\MultiPayment\Enums\Capability; use Potelo\MultiPayment\Exceptions\MultiPaymentException; +use Potelo\MultiPayment\Exceptions\UnsupportedOperationException; use Potelo\MultiPayment\Models\CreditCard; use Potelo\MultiPayment\Models\Invoice; +use Potelo\MultiPayment\Models\Refund; use Potelo\MultiPayment\Models\Customer; +use Potelo\MultiPayment\Models\Plan; +use Potelo\MultiPayment\Models\Subscription; use Potelo\MultiPayment\Models\AutomaticPix; use Potelo\MultiPayment\Models\AutomaticPixCharge; use Potelo\MultiPayment\Models\AutomaticPixCancellation; +use Potelo\MultiPayment\Contracts\PlanContract; use Potelo\MultiPayment\Contracts\GatewayContract; +use Potelo\MultiPayment\Contracts\SubscriptionContract; use Potelo\MultiPayment\Builders\InvoiceBuilder; use Potelo\MultiPayment\Builders\CustomerBuilder; use Potelo\MultiPayment\Builders\CreditCardBuilder; +use Potelo\MultiPayment\Builders\SubscriptionBuilder; use Potelo\MultiPayment\Exceptions\GatewayException; +use Potelo\MultiPayment\Exceptions\NotFoundException; +use Potelo\MultiPayment\Exceptions\ConfigurationException; +use Potelo\MultiPayment\Capabilities\CapabilityRestriction; use Potelo\MultiPayment\Helpers\ConfigurationHelper; use Potelo\MultiPayment\Exceptions\GatewayNotAvailableException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; @@ -48,21 +59,142 @@ public function setGateway($gateway): MultiPayment } /** - * Charge a customer + * Devolve o driver do gateway informado, ou o desta instância quando nenhum é informado. + * + * @param GatewayContract|string|null $gateway + * @return GatewayContract + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + */ + public function gateway($gateway = null): GatewayContract + { + return is_null($gateway) ? $this->gateway : ConfigurationHelper::resolveGateway($gateway); + } + + /** + * Diz se o gateway (o desta instância, por padrão) suporta a capability. + * + * @param Capability $capability + * @param GatewayContract|string|null $gateway + * @return bool + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + */ + public function supports(Capability $capability, $gateway = null): bool + { + return $this->gateway($gateway)->supports($capability); + } + + /** + * Capabilities que o gateway (o desta instância, por padrão) oferece e a lib implementa. + * + * @param GatewayContract|string|null $gateway + * @return Capability[] + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + */ + public function capabilities($gateway = null): array + { + return $this->gateway($gateway)->capabilities(); + } + + /** + * Capabilities que o gateway (o desta instância, por padrão) oferece mas a lib ainda não + * implementa. + * + * @param GatewayContract|string|null $gateway + * @return Capability[] + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + */ + public function notYetImplemented($gateway = null): array + { + return $this->gateway($gateway)->notYetImplemented(); + } + + /** + * Capabilities que o gateway (o desta instância, por padrão) não oferece mas a lib entrega + * por emulação. + * + * @param GatewayContract|string|null $gateway + * @return Capability[] + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + */ + public function emulated($gateway = null): array + { + return $this->gateway($gateway)->emulated(); + } + + /** + * Diz se a lib entrega a capability por emulação no gateway (o desta instância, por + * padrão). + * + * @param Capability $capability + * @param GatewayContract|string|null $gateway + * @return bool + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + */ + public function isEmulated(Capability $capability, $gateway = null): bool + { + return $this->gateway($gateway)->isEmulated($capability); + } + + /** + * Diz se o gateway desta instância suporta todas as capabilities informadas. Para outro + * gateway, use `gateway($nome)->supportsAll(...)`. + * + * @param Capability ...$capabilities + * @return bool + */ + public function supportsAll(Capability ...$capabilities): bool + { + return $this->gateway->supportsAll(...$capabilities); + } + + /** + * Restrição que o gateway (o desta instância, por padrão) impõe a uma capability que + * suporta, ou nulo quando ela vale em todos os casos. + * + * @param Capability $capability + * @param GatewayContract|string|null $gateway + * @return CapabilityRestriction|null + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + */ + public function restriction(Capability $capability, $gateway = null): ?CapabilityRestriction + { + return $this->gateway($gateway)->restriction($capability); + } + + /** + * Restrições do gateway (o desta instância, por padrão), com o valor da capability como + * chave. + * + * @param GatewayContract|string|null $gateway + * @return array + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + */ + public function restrictions($gateway = null): array + { + return $this->gateway($gateway)->restrictions(); + } + + /** + * Cria e cobra uma fatura a partir de um array em `snake_case` (as chaves aceitas estão no + * README, no apêndice "Chaves do array de charge()"). `customer` é obrigatório e é + * conferido antes de qualquer conversão. * * @param array $attributes + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * * @return Invoice * @throws GatewayException|ModelAttributeValidationException|GatewayNotAvailableException */ - public function charge(array $attributes): Invoice + public function charge(array $attributes, ?string $idempotencyKey = null): Invoice { + if (empty($attributes['customer'])) { + throw ModelAttributeValidationException::required('Invoice', 'customer'); + } + $invoice = new Invoice(); $invoice->fill($attributes); - $invoice->customer = new Customer(); - $invoice->customer->fill($attributes['customer']); - $invoice->save($this->gateway); + $invoice->save($this->gateway, true, $idempotencyKey); return $invoice; } @@ -96,6 +228,75 @@ public function newCreditCard(): CreditCardBuilder return new CreditCardBuilder($this->gateway); } + /** + * Return a SubscriptionBuilder instance + * + * @return SubscriptionBuilder + */ + public function newSubscription(): SubscriptionBuilder + { + return new SubscriptionBuilder($this->gateway); + } + + /** + * List the subscriptions of a customer + * + * @param Customer|string $customer + * @param int $page + * @param int $limit + * + * @return Subscription[] + * @throws GatewayException|GatewayNotAvailableException|UnsupportedOperationException + */ + public function listSubscriptions(Customer|string $customer, int $page = 1, int $limit = 100): array + { + if (is_string($customer)) { + $customerModel = new Customer(); + $customerModel->id = $customer; + $customer = $customerModel; + } + + return $this->gatewayImplementing(SubscriptionContract::class, Capability::SUBSCRIPTIONS) + ->listSubscriptions($customer, $page, $limit); + } + + /** + * List the gateway plans + * + * @param int $page + * @param int $limit + * + * @return Plan[] + * @throws GatewayException|GatewayNotAvailableException|UnsupportedOperationException + */ + public function listPlans(int $page = 1, int $limit = 100): array + { + return $this->gatewayImplementing(PlanContract::class, Capability::PLANS)->listPlans($page, $limit); + } + + /** + * Ensure this instance's gateway declares the capability and implements the contract behind it. + * + * @param class-string $contract + * @param Capability $capability + * + * @return GatewayContract + * @throws UnsupportedOperationException + * @throws ConfigurationException driver que declara a capability sem implementar o contract + */ + private function gatewayImplementing(string $contract, Capability $capability): GatewayContract + { + if (!$this->gateway->supports($capability)) { + throw UnsupportedOperationException::forGateway($this->gateway, $capability); + } + + if (!$this->gateway instanceof $contract) { + throw ConfigurationException::GatewayMissingContract($this->gateway, $capability, $contract); + } + + return $this->gateway; + } + /** * Return an invoice based on the invoice ID * @@ -112,26 +313,80 @@ public function getInvoice(string $id): Invoice return $invoice->get($this->gateway); } + /** + * Busca a assinatura pelo id no gateway desta instância. + * + * @param string $id + * + * @return Subscription + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws GatewayException|GatewayNotAvailableException|UnsupportedOperationException + */ + public function getSubscription(string $id): Subscription + { + $subscription = new Subscription(); + $subscription->id = $id; + + return $subscription->get($this->gateway); + } + + /** + * Busca o plano pelo identificador definido por quem o criou ou pelo id do gateway. A + * primeira busca usa o valor como `identifier`; se o gateway responder que não existe + * (`NotFoundException`), a segunda usa o valor como `id`. Plano inexistente nos dois + * lança a `NotFoundException` da segunda busca. + * + * @param string $idOrIdentifier + * + * @return Plan + * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException + * @throws GatewayException|GatewayNotAvailableException|UnsupportedOperationException + */ + public function getPlan(string $idOrIdentifier): Plan + { + $plan = new Plan(); + $plan->identifier = $idOrIdentifier; + + try { + return $plan->get($this->gateway); + } catch (NotFoundException) { + $plan = new Plan(); + $plan->id = $idOrIdentifier; + + return $plan->get($this->gateway); + } + } + /** * Duplicate an invoice * * @param \Potelo\MultiPayment\Models\Invoice|string $invoice * @param \Carbon\Carbon $expiresAt * @param array $gatewayOptions + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * * @return \Potelo\MultiPayment\Models\Invoice * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException * @throws \Potelo\MultiPayment\Exceptions\GatewayException */ - public function duplicateInvoice(Invoice|string $invoice, Carbon $expiresAt, array $gatewayOptions = []): Invoice - { + public function duplicateInvoice( + Invoice|string $invoice, + Carbon $expiresAt, + array $gatewayOptions = [], + ?string $idempotencyKey = null + ): Invoice { if (is_string($invoice)) { $invoiceInstance = new Invoice(); $invoiceInstance->id = $invoice; $invoice = $invoiceInstance; } - return $invoice->duplicate($expiresAt, $gatewayOptions); + // sem isso o model resolveria o gateway default, ignorando o setGateway() desta instância + if (empty($invoice->gateway)) { + $invoice->gateway = $this->gateway; + } + + return $invoice->duplicate($expiresAt, $gatewayOptions, $idempotencyKey); } /** @@ -151,37 +406,53 @@ public function getCustomer(string $id): Customer } /** - * Refund an invoice + * Estorna uma fatura pelo id: o restante estornável sem valor, ou o valor em centavos. + * Devolve o `Refund` criado; a fatura relida após o estorno está em `$refund->invoice()`. * * @param string $id - * @param int|null $partialValueCents + * @param int|null $partialValueCents valor em centavos; nulo estorna o restante + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica * - * @return void + * @return \Potelo\MultiPayment\Models\Refund * @throws \Potelo\MultiPayment\Exceptions\GatewayException + * @throws \Potelo\MultiPayment\Exceptions\RefundNotSupportedException + * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException valor zero ou negativo */ - public function refundInvoice(string $id, ?int $partialValueCents = null): Invoice + public function refundInvoice(string $id, ?int $partialValueCents = null, ?string $idempotencyKey = null): Refund { $invoice = new Invoice(); $invoice->id = $id; $invoice->gateway = $this->gateway; - if ($partialValueCents) { - $invoice->refundedAmount = $partialValueCents; - } + return $invoice->refund($partialValueCents, $idempotencyKey); + } - return $invoice->refund(); + /** + * Valor que ainda pode ser estornado na fatura, em centavos; lê a fatura no gateway. + * + * @param string $id + * @return int + * @throws \Potelo\MultiPayment\Exceptions\GatewayException + */ + public function refundableAmount(string $id): int + { + $invoice = new Invoice(); + $invoice->id = $id; + $invoice->gateway = $this->gateway; + return $invoice->refundableAmount(); } /** * Cancel an invoice. * * @param Invoice|string $invoice + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * @return Invoice * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException */ - public function cancelInvoice(Invoice|string $invoice): Invoice + public function cancelInvoice(Invoice|string $invoice, ?string $idempotencyKey = null): Invoice { if (is_string($invoice)) { $invoiceInstance = new Invoice(); @@ -189,7 +460,7 @@ public function cancelInvoice(Invoice|string $invoice): Invoice $invoice = $invoiceInstance; } - return $invoice->cancel($this->gateway); + return $invoice->cancel($this->gateway, $idempotencyKey); } /** @@ -198,6 +469,7 @@ public function cancelInvoice(Invoice|string $invoice): Invoice * @param Invoice|string $invoice * @param string|null $creditCardToken * @param string|null $creditCardId + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * * @return \Potelo\MultiPayment\Models\Invoice * @@ -207,8 +479,12 @@ public function cancelInvoice(Invoice|string $invoice): Invoice * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException * @throws \Potelo\MultiPayment\Exceptions\MultiPaymentException */ - public function chargeInvoiceWithCreditCard($invoice, ?string $creditCardToken = null, ?string $creditCardId = null): Invoice - { + public function chargeInvoiceWithCreditCard( + $invoice, + ?string $creditCardToken = null, + ?string $creditCardId = null, + ?string $idempotencyKey = null + ): Invoice { if (is_string($invoice)) { $invoiceInstance = new Invoice(); $invoiceInstance->id = $invoice; @@ -234,7 +510,7 @@ public function chargeInvoiceWithCreditCard($invoice, ?string $creditCardToken = $invoice->gateway = $this->gateway; $invoice->creditCard->gateway = $this->gateway; - return $invoice->chargeInvoiceWithCreditCard(); + return $invoice->chargeInvoiceWithCreditCard(null, $idempotencyKey); } /** @@ -256,23 +532,40 @@ public function getCard(string $customerId, string $creditCardId): CreditCard return $creditCard->get($this->gateway); } + /** + * Conclui o salvamento de um cartão que voltou de `newCreditCard()->create()` com + * `requiresAction`, depois que o pagador autenticou (ver + * `CreditCardContract::confirmCreditCardSetup()`). + * + * @param string $setupId `CreditCard::$setupId` + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return CreditCard + * @throws GatewayException|GatewayNotAvailableException|UnsupportedOperationException + * @throws \Potelo\MultiPayment\Exceptions\CardDeclinedException + */ + public function confirmCreditCardSetup(string $setupId, ?string $idempotencyKey = null): CreditCard + { + return $this->gateway->confirmCreditCardSetup($setupId, $idempotencyKey); + } + /** * Delete a credit card * * @param string $customerId * @param string $creditCardId + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * @return void * @throws \Potelo\MultiPayment\Exceptions\ConfigurationException * @throws \Potelo\MultiPayment\Exceptions\GatewayException */ - public function deleteCard(string $customerId, string $creditCardId): void + public function deleteCard(string $customerId, string $creditCardId, ?string $idempotencyKey = null): void { $creditCard = new CreditCard(); $creditCard->customer = new Customer(); $creditCard->customer->id = $customerId; $creditCard->id = $creditCardId; - $creditCard->delete($this->gateway); + $creditCard->delete($this->gateway, $idempotencyKey); } /** @@ -280,33 +573,38 @@ public function deleteCard(string $customerId, string $creditCardId): void * * @param string $customerId * @param string $creditCardId + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * @return \Potelo\MultiPayment\Models\Customer */ - public function setDefaultCard(string $customerId, string $creditCardId): Customer + public function setDefaultCard(string $customerId, string $creditCardId, ?string $idempotencyKey = null): Customer { $customer = new Customer(); $customer->id = $customerId; - return $customer->setDefaultCard($creditCardId); + // sem isso o model resolveria o gateway default, ignorando o setGateway() desta instância + $customer->gateway = $this->gateway; + return $customer->setDefaultCard($creditCardId, $idempotencyKey); } /** * Cancela uma recorrência de Pix Automático no gateway. * * @param AutomaticPix|string $automaticPix + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return AutomaticPixCancellation * @throws GatewayException * @throws GatewayNotAvailableException */ public function cancelAutomaticPixRecurrence( - AutomaticPix|string $automaticPix - ): AutomaticPixCancellation - { + AutomaticPix|string $automaticPix, + ?string $idempotencyKey = null + ): AutomaticPixCancellation { if (is_string($automaticPix)) { $automaticPixModel = new AutomaticPix(); $automaticPixModel->id = $automaticPix; $automaticPix = $automaticPixModel; } - return $this->gateway->cancelAutomaticPixRecurrence($automaticPix); + return $this->gateway->cancelAutomaticPixRecurrence($automaticPix, $idempotencyKey); } /** @@ -314,12 +612,15 @@ public function cancelAutomaticPixRecurrence( * * @param AutomaticPixCharge|string $charge * @param string|null $endToEndId + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return AutomaticPixCancellation * @throws GatewayException * @throws GatewayNotAvailableException */ public function cancelAutomaticPixScheduledPayment( AutomaticPixCharge|string $charge, - ?string $endToEndId = null + ?string $endToEndId = null, + ?string $idempotencyKey = null ): AutomaticPixCancellation { if (is_string($charge)) { $chargeModel = new AutomaticPixCharge(); @@ -328,13 +629,19 @@ public function cancelAutomaticPixScheduledPayment( $charge = $chargeModel; } - return $this->gateway->cancelAutomaticPixScheduledPayment($charge); + return $this->gateway->cancelAutomaticPixScheduledPayment($charge, $idempotencyKey); } /** - * Request a new Automatic Pix debit schedule for an expired invoice. + * Pede um novo agendamento de débito de Pix Automático para uma fatura que expirou. + * + * @param Invoice|string $invoice + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return Invoice + * @throws GatewayException + * @throws GatewayNotAvailableException */ - public function rescheduleAutomaticPixPayment(Invoice|string $invoice): Invoice + public function rescheduleAutomaticPixPayment(Invoice|string $invoice, ?string $idempotencyKey = null): Invoice { if (is_string($invoice)) { $invoiceModel = new Invoice(); @@ -342,7 +649,7 @@ public function rescheduleAutomaticPixPayment(Invoice|string $invoice): Invoice $invoice = $invoiceModel; } - return $invoice->rescheduleAutomaticPixPayment($this->gateway); + return $invoice->rescheduleAutomaticPixPayment($this->gateway, $idempotencyKey); } /** diff --git a/src/Providers/MultiPaymentServiceProvider.php b/src/Providers/MultiPaymentServiceProvider.php index 7bbcb20..400eeca 100644 --- a/src/Providers/MultiPaymentServiceProvider.php +++ b/src/Providers/MultiPaymentServiceProvider.php @@ -4,6 +4,9 @@ use Potelo\MultiPayment\MultiPayment; use Illuminate\Support\ServiceProvider; +use Potelo\MultiPayment\Contracts\IdempotencyStore; +use Potelo\MultiPayment\Console\SyncSubscriptionsCommand; +use Potelo\MultiPayment\Idempotency\CacheIdempotencyStore; class MultiPaymentServiceProvider extends ServiceProvider { @@ -26,6 +29,10 @@ public function boot() $this->publishes([ $configFile => config_path('multi-payment.php'), ], 'config'); + + if ($this->app->runningInConsole()) { + $this->commands([SyncSubscriptionsCommand::class]); + } } /** @@ -41,5 +48,15 @@ public function register() $this->app->bind('multiPayment', function ($app) { return $app->make(MultiPayment::class); }); + + // a aplicação troca a store com um bind próprio de IdempotencyStore depois deste + $this->app->bind(IdempotencyStore::class, function ($app) { + $config = $app['config']->get('multi-payment.idempotency', []); + + return new CacheIdempotencyStore( + $app['cache']->store($config['cache_store'] ?? null), + $config['prefix'] ?? CacheIdempotencyStore::DEFAULT_PREFIX + ); + }); } } diff --git a/src/Traits/MultiPaymentTrait.php b/src/Traits/MultiPaymentTrait.php index 15abc59..1b26ad6 100644 --- a/src/Traits/MultiPaymentTrait.php +++ b/src/Traits/MultiPaymentTrait.php @@ -18,11 +18,12 @@ trait MultiPaymentTrait * @param array $options * @param string|null $gatewayName * @param int|null $amount + * @param string|null $idempotencyKey idempotency key of the operation; null disables deduplication * * @return Invoice * @throws GatewayException|ModelAttributeValidationException */ - public function charge(array $options, ?string $gatewayName = null, ?int $amount = null): Invoice + public function charge(array $options, ?string $gatewayName = null, ?int $amount = null, ?string $idempotencyKey = null): Invoice { $payment = new MultiPayment($gatewayName); @@ -33,7 +34,7 @@ public function charge(array $options, ?string $gatewayName = null, ?int $amount if (!empty($amount)) { $options['amount'] = $amount; } - $invoice = $payment->charge($options); + $invoice = $payment->charge($options, $idempotencyKey); if (empty($customerId)) { $this->setCustomerId($gatewayName, $invoice->customer->id); } @@ -82,19 +83,29 @@ private function getGatewayCustomerColumn($gatewayName) } /** - * Set the default credit card of the customer + * Define o cartão padrão do cliente no gateway. + * + * @param string $gatewayName + * @param string $cardId + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return void */ - public function setDefaultCreditCard(string $gatewayName, string $cardId): void + public function setDefaultCreditCard(string $gatewayName, string $cardId, ?string $idempotencyKey = null): void { - MultiPayment::setGateway($gatewayName)->setDefaultCard($this->getGatewayCustomerId($gatewayName), $cardId); + MultiPayment::setGateway($gatewayName)->setDefaultCard($this->getGatewayCustomerId($gatewayName), $cardId, $idempotencyKey); } /** - * Delete a credit card of the customer + * Exclui um cartão do cliente no gateway. + * + * @param string $gatewayName + * @param string $cardId + * @param string|null $idempotencyKey chave de idempotência da operação; nula não deduplica + * @return void */ - public function deleteCreditCard(string $gatewayName, string $cardId): void + public function deleteCreditCard(string $gatewayName, string $cardId, ?string $idempotencyKey = null): void { - MultiPayment::setGateway($gatewayName)->deleteCard($this->getGatewayCustomerId($gatewayName), $cardId); + MultiPayment::setGateway($gatewayName)->deleteCard($this->getGatewayCustomerId($gatewayName), $cardId, $idempotencyKey); } /** diff --git a/src/config/multi-payment.php b/src/config/multi-payment.php index 3add0d0..23dfb46 100644 --- a/src/config/multi-payment.php +++ b/src/config/multi-payment.php @@ -21,6 +21,38 @@ */ 'environment' => env('APP_ENV', 'production'), + /* + |-------------------------------------------------------------------------- + | fill() estrito + |-------------------------------------------------------------------------- + | + | Com true (padrão), Model::fill() lança ModelAttributeValidationException para chave + | que não corresponde a nenhuma propriedade do model (chaves com prefixo gateway_ e o + | conteúdo de gateway_options ficam livres). Com false, a chave desconhecida é ignorada + | em silêncio, como nas versões anteriores; use só durante a migração. + | + */ + 'strict_fill' => env('MULTIPAYMENT_STRICT_FILL', true), + + /* + |-------------------------------------------------------------------------- + | Idempotência + |-------------------------------------------------------------------------- + | + | Deduplicação feita pela lib (IdempotencyStore) nas operações de escrita em que o + | gateway não aceita o cabeçalho Idempotency-Key. A store padrão usa o cache do Laravel; + | para trocar, faça bind de Potelo\MultiPayment\Contracts\IdempotencyStore no container. + | + */ + 'idempotency' => [ + // prazo, em segundos, em que a mesma chave devolve o resultado guardado + 'ttl' => env('MULTIPAYMENT_IDEMPOTENCY_TTL', 86400), + // store de cache do Laravel usada pela CacheIdempotencyStore; nulo usa a padrão da aplicação + 'cache_store' => env('MULTIPAYMENT_IDEMPOTENCY_CACHE_STORE'), + // prefixo das chaves no cache + 'prefix' => 'multi-payment:idempotency:', + ], + /* |-------------------------------------------------------------------------- | Available gateways @@ -37,6 +69,15 @@ 'api_key' => env('IUGU_APIKEY'), 'customer_column' => 'iugu_id', 'class' => \Potelo\MultiPayment\Gateways\IuguGateway::class, + // máximo de parcelas habilitado na conta; a lib o publica em restriction(INSTALLMENTS) + 'max_installments' => env('IUGU_MAX_INSTALLMENTS', 12), + ], + 'stripe' => [ + 'api_key' => env('STRIPE_APIKEY'), + 'customer_column' => 'stripe_id', + 'class' => \Potelo\MultiPayment\Gateways\StripeGateway::class, + // nome exibido no aplicativo do banco do pagador no mandato de Pix Automático + 'pix_mandate_reference' => env('STRIPE_PIX_MANDATE_REFERENCE'), ], ], ]; \ No newline at end of file diff --git a/tests/Integration/Builders/CreditCardBuilderTest.php b/tests/Integration/Builders/CreditCardBuilderTest.php index e618b2b..c9f950b 100644 --- a/tests/Integration/Builders/CreditCardBuilderTest.php +++ b/tests/Integration/Builders/CreditCardBuilderTest.php @@ -4,23 +4,16 @@ use Potelo\MultiPayment\Tests\TestCase; use Potelo\MultiPayment\Facades\MultiPayment; +use PHPUnit\Framework\Attributes\DataProvider; class CreditCardBuilderTest extends TestCase { - - public function __construct(?string $name = null, array $data = [], $dataName = '') - { - parent::__construct($name, $data, $dataName); - $this->createApplication(); - } - /** * Should create a credit card. * - * @dataProvider shouldCreateACreditCardDataProvider - * * @return void */ + #[DataProvider('shouldCreateACreditCardDataProvider')] public function testShouldCreateACreditCard($gateway, $data) { @@ -77,7 +70,7 @@ public function testShouldCreateACreditCard($gateway, $data) $this->assertEquals($gateway, $creditCard->gateway); } - public function shouldCreateACreditCardDataProvider(): array + public static function shouldCreateACreditCardDataProvider(): array { return [ 'iugu - with credit card data' => [ @@ -92,7 +85,7 @@ public function shouldCreateACreditCardDataProvider(): array * * @return array[] */ - public function shouldCreateACreditCardWithHashDataProvider(): array + public static function shouldCreateACreditCardWithHashDataProvider(): array { return [ 'iugu - with hash' => [ @@ -109,13 +102,12 @@ public function shouldCreateACreditCardWithHashDataProvider(): array /** * Should create a credit card using token. * - * @dataProvider shouldCreateACreditCardWithHashDataProvider - * * @param $gateway * @param $data * * @return void */ + #[DataProvider('shouldCreateACreditCardWithHashDataProvider')] public function testShouldCreateACreditCardWithHash($gateway, $data) { diff --git a/tests/Integration/Builders/CustomerBuilderTest.php b/tests/Integration/Builders/CustomerBuilderTest.php index 9721345..bd450b0 100644 --- a/tests/Integration/Builders/CustomerBuilderTest.php +++ b/tests/Integration/Builders/CustomerBuilderTest.php @@ -5,6 +5,7 @@ use Carbon\Carbon; use Potelo\MultiPayment\Tests\TestCase; use Potelo\MultiPayment\Facades\MultiPayment; +use PHPUnit\Framework\Attributes\DataProvider; class CustomerBuilderTest extends TestCase { @@ -18,16 +19,16 @@ public static function shouldCreateACustomerDataProvider(): array { return [ ['iugu'], + ['stripe'], ]; } /** * Should create a credit card. * - * @dataProvider shouldCreateACustomerDataProvider - * * @return void */ + #[DataProvider('shouldCreateACustomerDataProvider')] public function testShouldCreateACustomer($gateway) { $data = self::customerWithAddress(); @@ -108,10 +109,9 @@ public function testShouldCreateACustomer($gateway) /** * Should create a customer without address. * - * @dataProvider shouldCreateACustomerDataProvider - * * @return void */ + #[DataProvider('shouldCreateACustomerDataProvider')] public function testShouldCreateACustomerWithoutAddress($gateway) { $data = self::customerWithoutAddress(); @@ -156,10 +156,10 @@ public function testShouldCreateACustomerWithoutAddress($gateway) /** * Should update a customer. * - * @dataProvider shouldCreateACustomerDataProvider * @param string $gateway * @return void */ + #[DataProvider('shouldCreateACustomerDataProvider')] public function testShouldUpdateACustomer($gateway) { $data = self::customerWithAddress(); diff --git a/tests/Integration/Builders/InvoiceBuilderTest.php b/tests/Integration/Builders/InvoiceBuilderTest.php index 50aa027..27a0f4f 100644 --- a/tests/Integration/Builders/InvoiceBuilderTest.php +++ b/tests/Integration/Builders/InvoiceBuilderTest.php @@ -7,17 +7,20 @@ use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Models\AutomaticPix; use Potelo\MultiPayment\Exceptions\ChargingException; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\PaymentMethod; class InvoiceBuilderTest extends TestCase { /** - * @group iugu-sandbox-limitation - * * A sandbox da Iugu rejeita a criação de faturas com Pix Automático. O * cenário permanece completo para ser reativado quando o recurso estiver * disponível no ambiente de testes. */ + #[Group('iugu-sandbox-limitation')] public function testShouldCreateAutomaticPixInvoice(): void { $this->markTestSkipped( @@ -26,7 +29,7 @@ public function testShouldCreateAutomaticPixInvoice(): void $reference = 'multipayment-' . Carbon::now()->format('YmdHis'); $invoice = (new \Potelo\MultiPayment\MultiPayment('iugu'))->newInvoice() - ->addAvailablePaymentMethod(Invoice::PAYMENT_METHOD_PIX) + ->addAvailablePaymentMethod(PaymentMethod::PIX) ->addCustomer( 'Automatic Pix Sandbox', "{$reference}@example.com", @@ -36,7 +39,7 @@ public function testShouldCreateAutomaticPixInvoice(): void '982345678' ) ->addItem('Automatic Pix sandbox test', 100, 1) - ->setExpiresAt(Carbon::now()->addDays(2)) + ->setDueDate(Carbon::now()->addDays(2)) ->addAutomaticPix( AutomaticPix::AUTHORIZATION_TYPE_QR_CODE_WITH_PAYMENT, AutomaticPix::FREQUENCY_MONTHLY, @@ -91,8 +94,8 @@ private function createInvoice(string $gateway, array $data): Invoice foreach ($data['items'] as $item) { $invoiceBuilder->addItem($item['description'], $item['price'], $item['quantity']); } - if (isset($data['expiresAt'])) { - $invoiceBuilder->setExpiresAt($data['expiresAt']); + if (isset($data['dueDate'])) { + $invoiceBuilder->setDueDate($data['dueDate']); } if (isset($data['availablePaymentMethods'])) { $invoiceBuilder->setAvailablePaymentMethods($data['availablePaymentMethods']); @@ -109,8 +112,8 @@ private function createInvoice(string $gateway, array $data): Invoice ); } - if (isset($data['gatewayAdicionalOptions'])) { - $invoiceBuilder->setGatewayAdicionalOptions($data['gatewayAdicionalOptions']); + if (isset($data['gatewayOptions'])) { + $invoiceBuilder->setGatewayOptions($data['gatewayOptions']); } return $invoiceBuilder->create(); @@ -119,8 +122,6 @@ private function createInvoice(string $gateway, array $data): Invoice /** * Create invoice test. * - * @dataProvider shouldCreateInvoiceDataProvider - * * @param string $gateway * @param array $data * @@ -128,6 +129,7 @@ private function createInvoice(string $gateway, array $data): Invoice * @throws \Potelo\MultiPayment\Exceptions\GatewayException * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException */ + #[DataProvider('shouldCreateInvoiceDataProvider')] public function testShouldCreateInvoice(string $gateway, array $data): void { $invoice = $this->createInvoice($gateway, $data); @@ -163,12 +165,12 @@ public function testShouldCreateInvoice(string $gateway, array $data): void $this->assertEquals($item['quantity'], $invoice->items[$key]->quantity); } - if (isset($data['expiresAt'])) { - $this->assertEquals($data['expiresAt'], $invoice->expiresAt->format('Y-m-d')); + if (isset($data['dueDate'])) { + $this->assertEquals($data['dueDate'], $invoice->dueDate->format('Y-m-d')); } if (isset($data['paymentMethod'])) { - $this->assertEquals($data['paymentMethod'], $invoice->paymentMethod); + $this->assertEquals($data['paymentMethod'], $invoice->paymentMethod?->value); } if (isset($data['creditCard'])) { @@ -183,7 +185,7 @@ public function testShouldCreateInvoice(string $gateway, array $data): void $this->assertNotEmpty($invoice->creditCard->id); } - if ((isset($data['availablePaymentMethods']) && in_array('bank_slip', $data['availablePaymentMethods'])) || (isset($data['paymentMethod']) && $data['paymentMethod'] === 'bank_slip') || (isset($data['gatewayAdicionalOptions']) && in_array('payable_with', $data['gatewayAdicionalOptions']) && in_array('bank_slip', $data['gatewayAdicionalOptions']['payable_with']))) { + if ((isset($data['availablePaymentMethods']) && in_array('bank_slip', $data['availablePaymentMethods'])) || (isset($data['paymentMethod']) && $data['paymentMethod'] === 'bank_slip') || (isset($data['gatewayOptions']) && array_key_exists('payable_with', $data['gatewayOptions']) && in_array('bank_slip', $data['gatewayOptions']['payable_with']))) { $this->assertNotEmpty($invoice->bankSlip); $this->assertNotEmpty($invoice->bankSlip->url); $this->assertNotEmpty($invoice->bankSlip->number); @@ -191,23 +193,24 @@ public function testShouldCreateInvoice(string $gateway, array $data): void $this->assertNotEmpty($invoice->bankSlip->barcodeImage); } - if ((isset($data['availablePaymentMethods']) && in_array('pix', $data['availablePaymentMethods'])) || (isset($data['paymentMethod']) && $data['paymentMethod'] === 'pix') || (isset($data['gatewayAdicionalOptions']) && in_array('payable_with', $data['gatewayAdicionalOptions']) && in_array('pix', $data['gatewayAdicionalOptions']['payable_with']))) { + if ((isset($data['availablePaymentMethods']) && in_array('pix', $data['availablePaymentMethods'])) || (isset($data['paymentMethod']) && $data['paymentMethod'] === 'pix') || (isset($data['gatewayOptions']) && array_key_exists('payable_with', $data['gatewayOptions']) && in_array('pix', $data['gatewayOptions']['payable_with']))) { $this->assertNotEmpty($invoice->pix); $this->assertNotEmpty($invoice->pix->qrCodeImageUrl); $this->assertNotEmpty($invoice->pix->qrCodeText); } - if (isset($data['gatewayAdicionalOptions'])) { - if (in_array('payable_with', $data['gatewayAdicionalOptions']) && $gateway == 'iugu') { - foreach ($invoice->original->payable_with as $value) { - $this->assertContains($value, $data['gatewayAdicionalOptions']['payable_with']); - } + if (isset($data['gatewayOptions'])) { + if (array_key_exists('payable_with', $data['gatewayOptions']) && $gateway == 'iugu') { + $this->assertEqualsCanonicalizing( + $data['gatewayOptions']['payable_with'], + array_map(fn (PaymentMethod $method) => $method->value, $invoice->availablePaymentMethods) + ); } - if (in_array('expires_in', $data['gatewayAdicionalOptions'])) { - $this->assertEquals($data['gatewayAdicionalOptions'], $invoice->gatewayAdicionalOptions); + if (array_key_exists('expires_in', $data['gatewayOptions'])) { + $this->assertEquals($data['gatewayOptions'], $invoice->gatewayOptions); if ($gateway == 'iugu') { - foreach ($invoice->gatewayAdicionalOptions as $key => $value) { + foreach ($invoice->gatewayOptions as $key => $value) { $this->assertNotEmpty(array_filter($invoice->original->variables, function ($variable) use ($key, $value) { return $variable->variable == $key && $variable->value == $value; })); @@ -232,12 +235,12 @@ public function testShouldCreateInvoice(string $gateway, array $data): void $this->assertEquals($item['quantity'], $invoice->items[$key]->quantity); } - if (isset($data['expiresAt'])) { - $this->assertEquals($data['expiresAt'], $invoice->expiresAt->format('Y-m-d')); + if (isset($data['dueDate'])) { + $this->assertEquals($data['dueDate'], $invoice->dueDate->format('Y-m-d')); } - if (isset($data['paymentMethod']) && $invoice->status === $invoice::STATUS_PAID) { - $this->assertEquals($data['paymentMethod'], $invoice->paymentMethod); + if (isset($data['paymentMethod']) && $invoice->status === InvoiceStatus::PAID) { + $this->assertEquals($data['paymentMethod'], $invoice->paymentMethod?->value); } if (isset($data['customer']['address'])) { @@ -259,13 +262,13 @@ public function testShouldCreateInvoice(string $gateway, array $data): void /** * @return array[] */ - public function shouldCreateInvoiceDataProvider(): array + public static function shouldCreateInvoiceDataProvider(): array { return [ 'iugu - without payment method' => [ 'gateway' => 'iugu', 'data' => [ - 'expiresAt' => Carbon::now()->addWeekday()->format('Y-m-d'), + 'dueDate' => Carbon::now()->addWeekday()->format('Y-m-d'), 'items' => [['description' => 'Teste', 'quantity' => 1, 'price' => 10000,]], 'customer' => self::customerWithAddress(), ] @@ -273,10 +276,10 @@ public function shouldCreateInvoiceDataProvider(): array 'iugu - without payment method - with adicional options' => [ 'gateway' => 'iugu', 'data' => [ - 'expiresAt' => Carbon::now()->addWeekday()->format('Y-m-d'), + 'dueDate' => Carbon::now()->addWeekday()->format('Y-m-d'), 'items' => [['description' => 'Teste', 'quantity' => 1, 'price' => 10000,]], 'customer' => self::customerWithAddress(), - 'gatewayAdicionalOptions' => [ + 'gatewayOptions' => [ 'expires_in' => 5, ] ] @@ -284,10 +287,10 @@ public function shouldCreateInvoiceDataProvider(): array 'iugu - without payment method - with payable_with' => [ 'gateway' => 'iugu', 'data' => [ - 'expiresAt' => Carbon::now()->addWeekday()->format('Y-m-d'), + 'dueDate' => Carbon::now()->addWeekday()->format('Y-m-d'), 'items' => [['description' => 'Teste', 'quantity' => 1, 'price' => 10000,]], 'customer' => self::customerWithAddress(), - 'gatewayAdicionalOptions' => [ + 'gatewayOptions' => [ 'payable_with' => ['bank_slip', 'pix'], ] ] @@ -295,7 +298,7 @@ public function shouldCreateInvoiceDataProvider(): array 'iugu - company with address without payment method' => [ 'gateway' => 'iugu', 'data' => [ - 'expiresAt' => Carbon::now()->addWeekday()->format('Y-m-d'), + 'dueDate' => Carbon::now()->addWeekday()->format('Y-m-d'), 'items' => [['description' => 'Teste', 'quantity' => 1, 'price' => 10000,]], 'customer' => self::companyWithAddress(), ] @@ -321,7 +324,7 @@ public function shouldCreateInvoiceDataProvider(): array 'iugu - bank slip with address' => [ 'gateway' => 'iugu', 'data' => [ - 'expiresAt' => Carbon::now()->addWeekday()->format('Y-m-d'), + 'dueDate' => Carbon::now()->addWeekday()->format('Y-m-d'), 'items' => [['description' => 'Teste', 'quantity' => 1, 'price' => 10000,]], 'customer' => self::customerWithAddress(), 'availablePaymentMethods' => ['bank_slip'], @@ -330,7 +333,7 @@ public function shouldCreateInvoiceDataProvider(): array 'iugu - pix with address' => [ 'gateway' => 'iugu', 'data' => [ - 'expiresAt' => Carbon::now()->addWeekday()->format('Y-m-d'), + 'dueDate' => Carbon::now()->addWeekday()->format('Y-m-d'), 'items' => [['description' => 'Teste', 'quantity' => 1, 'price' => 10000,]], 'customer' => self::customerWithAddress(), 'availablePaymentMethods' => ['pix'], @@ -339,7 +342,7 @@ public function shouldCreateInvoiceDataProvider(): array 'iugu - pix without address' => [ 'gateway' => 'iugu', 'data' => [ - 'expiresAt' => Carbon::now()->addWeekday()->format('Y-m-d'), + 'dueDate' => Carbon::now()->addWeekday()->format('Y-m-d'), 'items' => [['description' => 'Teste', 'quantity' => 1, 'price' => 10000,]], 'customer' => self::customerWithoutAddress(), 'availablePaymentMethods' => ['pix'], @@ -351,20 +354,19 @@ public function shouldCreateInvoiceDataProvider(): array /** * Fail to create invoice test. * - * @dataProvider shouldNotCreateInvoiceDataProvider - * * @param string $gateway * @param array $data * * @return void */ + #[DataProvider('shouldNotCreateInvoiceDataProvider')] public function testShouldNotCreateInvoice(string $gateway, array $data): void { $this->expectException(ChargingException::class); $this->createInvoice($gateway, $data); } - public function shouldNotCreateInvoiceDataProvider(): array + public static function shouldNotCreateInvoiceDataProvider(): array { return [ 'iugu - credit card - charge fail' => [ diff --git a/tests/Integration/IdempotencyTest.php b/tests/Integration/IdempotencyTest.php new file mode 100644 index 0000000..b245722 --- /dev/null +++ b/tests/Integration/IdempotencyTest.php @@ -0,0 +1,142 @@ +set('cache.default', 'array'); + } + + #[DataProvider('gatewayProvider')] + public function testCreatingAnInvoiceTwiceWithTheSameKeyReturnsTheSameInvoice(string $gateway): void + { + $customer = $this->createCustomer($gateway, self::customerWithoutAddress()); + $key = 'multipayment-idem-' . bin2hex(random_bytes(8)); + + $first = $this->pixInvoiceBuilder($gateway, $customer->id)->withIdempotencyKey($key)->create(); + $second = $this->pixInvoiceBuilder($gateway, $customer->id)->withIdempotencyKey($key)->create(); + + $this->assertNotEmpty($first->id); + $this->assertSame($first->id, $second->id); + $this->assertSame(InvoiceStatus::PENDING, $second->status); + } + + #[DataProvider('gatewayProvider')] + public function testCreatingAnInvoiceTwiceWithoutAKeyCreatesTwoInvoices(string $gateway): void + { + $customer = $this->createCustomer($gateway, self::customerWithoutAddress()); + + $first = $this->pixInvoiceBuilder($gateway, $customer->id)->create(); + $second = $this->pixInvoiceBuilder($gateway, $customer->id)->create(); + + $this->assertNotSame($first->id, $second->id); + } + + /** + * Na Iugu o gateway responde 409 sem o id do cliente original (`resource_id: processing`), + * então a segunda chamada lança em vez de devolver o cliente. + */ + public function testIuguRejectsTheSameKeyOnCustomerCreation(): void + { + $key = 'multipayment-idem-' . bin2hex(random_bytes(8)); + $data = self::customerWithoutAddress(); + $data['email'] = "idem-{$key}@example.com"; + + $first = MultiPayment::setGateway('iugu')->newCustomer() + ->setName($data['name'])->setEmail($data['email'])->setTaxDocument($data['taxDocument']) + ->withIdempotencyKey($key) + ->create(); + $this->assertNotEmpty($first->id); + + try { + MultiPayment::setGateway('iugu')->newCustomer() + ->setName($data['name'])->setEmail($data['email'])->setTaxDocument($data['taxDocument']) + ->withIdempotencyKey($key) + ->create(); + $this->fail('Esperava IdempotencyConflictException'); + } catch (IdempotencyConflictException $e) { + $this->assertSame(409, $e->httpStatus); + $this->assertNull($e->resourceId); + } + } + + /** + * O provider só existe para o `TestCase` reconhecer o gateway e pular a pausa da Iugu. + */ + #[DataProvider('stripeProvider')] + public function testStripeRejectsTheSameKeyWithAnotherPayload(string $gateway): void + { + $customer = $this->createCustomer('stripe', self::customerWithoutAddress()); + $key = 'multipayment-idem-' . bin2hex(random_bytes(8)); + + $this->pixInvoiceBuilder('stripe', $customer->id)->withIdempotencyKey($key)->create(); + + $this->expectException(IdempotencyConflictException::class); + + $this->pixInvoiceBuilder('stripe', $customer->id, 2000)->withIdempotencyKey($key)->create(); + } + + public function testIuguCancellationIsDeduplicatedByTheStore(): void + { + $customer = $this->createCustomer('iugu', self::customerWithoutAddress()); + $invoice = $this->pixInvoiceBuilder('iugu', $customer->id)->create(); + $key = 'multipayment-idem-' . bin2hex(random_bytes(8)); + + $first = MultiPayment::setGateway('iugu')->cancelInvoice($invoice->id, $key); + $second = MultiPayment::setGateway('iugu')->cancelInvoice($invoice->id, $key); + + $this->assertSame(InvoiceStatus::CANCELED, $first->status); + $this->assertSame(InvoiceStatus::CANCELED, $second->status); + $this->assertTrue($this->app->make(IdempotencyStore::class)->has('iugu:' . $key)); + } + + public static function gatewayProvider(): array + { + return ['iugu' => ['iugu'], 'stripe' => ['stripe']]; + } + + public static function stripeProvider(): array + { + return ['stripe' => ['stripe']]; + } + + private function pixInvoiceBuilder(string $gateway, string $customerId, int $amount = 1000): \Potelo\MultiPayment\Builders\InvoiceBuilder + { + return MultiPayment::setGateway($gateway)->newInvoice() + ->addAvailablePaymentMethod(PaymentMethod::PIX) + ->setCustomer($this->customerWithId($gateway, $customerId)) + ->addItem('Idempotency sandbox test', $amount, 1) + // data fixa: uma expiração derivada do instante da chamada mudaria o payload entre + // as tentativas, e a Stripe recusa a mesma chave com payload diferente + ->setDueDate(now()->addDays(2)->startOfDay()); + } + + private function customerWithId(string $gateway, string $customerId): \Potelo\MultiPayment\Models\Customer + { + $customer = new \Potelo\MultiPayment\Models\Customer(); + $customer->id = $customerId; + $customer->gateway = $gateway; + $customer->name = 'Fake Customer'; + $customer->email = 'email@exemplo.com'; + $customer->taxDocument = '20176996915'; + + return $customer; + } +} diff --git a/tests/Integration/MultiPaymentTest.php b/tests/Integration/MultiPaymentTest.php index c6badcc..e161f24 100644 --- a/tests/Integration/MultiPaymentTest.php +++ b/tests/Integration/MultiPaymentTest.php @@ -7,16 +7,22 @@ use Potelo\MultiPayment\Models\Invoice; use Potelo\MultiPayment\Models\AutomaticPix; use Potelo\MultiPayment\Facades\MultiPayment; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use Potelo\MultiPayment\Exceptions\RefundNotSupportedException; +use Potelo\MultiPayment\Enums\InvoiceStatus; +use Potelo\MultiPayment\Enums\RefundStatus; +use Potelo\MultiPayment\Models\Refund; +use Potelo\MultiPayment\Enums\PaymentMethod; class MultiPaymentTest extends TestCase { /** - * @group iugu-sandbox-limitation - * * A consulta depende de uma fatura com Pix Automático criada no próprio * teste, mas a sandbox da Iugu ainda rejeita essa criação. */ + #[Group('iugu-sandbox-limitation')] public function testShouldGetAutomaticPixInvoice(): void { $this->markTestSkipped( @@ -25,7 +31,7 @@ public function testShouldGetAutomaticPixInvoice(): void $reference = 'multipayment-' . now()->format('YmdHis'); $invoice = MultiPayment::setGateway('iugu')->newInvoice() - ->addAvailablePaymentMethod(Invoice::PAYMENT_METHOD_PIX) + ->addAvailablePaymentMethod(PaymentMethod::PIX) ->addCustomer( 'Automatic Pix Sandbox', "{$reference}@example.com", @@ -35,7 +41,7 @@ public function testShouldGetAutomaticPixInvoice(): void '982345678' ) ->addItem('Automatic Pix sandbox test', 100, 1) - ->setExpiresAt(now()->addDays(2)) + ->setDueDate(now()->addDays(2)) ->addAutomaticPix( AutomaticPix::AUTHORIZATION_TYPE_QR_CODE_WITH_PAYMENT, AutomaticPix::FREQUENCY_MONTHLY, @@ -58,11 +64,10 @@ public function testShouldGetAutomaticPixInvoice(): void } /** - * @group iugu-sandbox-limitation - * * A retentativa exige uma fatura expirada após falha de débito de uma * recorrência autorizada, estado que não pode ser criado na sandbox. */ + #[Group('iugu-sandbox-limitation')] public function testShouldRescheduleAutomaticPixPayment(): void { $this->markTestSkipped( @@ -71,11 +76,10 @@ public function testShouldRescheduleAutomaticPixPayment(): void } /** - * @group iugu-sandbox-limitation - * * O cancelamento exige uma recorrência autorizada criada durante o teste, * mas a sandbox não oferece suporte à criação de Pix Automático. */ + #[Group('iugu-sandbox-limitation')] public function testShouldCancelAutomaticPixRecurrence(): void { $this->markTestSkipped( @@ -84,11 +88,10 @@ public function testShouldCancelAutomaticPixRecurrence(): void } /** - * @group iugu-sandbox-limitation - * * O cancelamento de agendamento exige um débito agendado e seu end-to-end * ID, que não podem ser produzidos pela sandbox no fluxo do teste. */ + #[Group('iugu-sandbox-limitation')] public function testShouldCancelAutomaticPixScheduledPayment(): void { $this->markTestSkipped( @@ -97,11 +100,10 @@ public function testShouldCancelAutomaticPixScheduledPayment(): void } /** - * @group iugu-sandbox-limitation - * * A consulta exige que uma recorrência seja criada e cancelada no próprio * teste; a sandbox bloqueia a etapa inicial desse fluxo. */ + #[Group('iugu-sandbox-limitation')] public function testShouldGetAutomaticPixCancellation(): void { $this->markTestSkipped( @@ -110,11 +112,10 @@ public function testShouldGetAutomaticPixCancellation(): void } /** - * @group iugu-sandbox-limitation - * * A listagem exige uma recorrência com cancelamentos criados durante o * teste; a sandbox bloqueia a criação dessa recorrência. */ + #[Group('iugu-sandbox-limitation')] public function testShouldListAutomaticPixCancellations(): void { $this->markTestSkipped( @@ -132,7 +133,7 @@ public function testShouldGetInvoice() { $gateway = 'iugu'; $invoice = MultiPayment::setGateway($gateway)->newInvoice() - ->addAvailablePaymentMethod(Invoice::PAYMENT_METHOD_CREDIT_CARD) + ->addAvailablePaymentMethod(PaymentMethod::CREDIT_CARD) ->addCustomer('Fake Customer', 'email@exemplo.com', '20176996915') ->addItem('teste', 1000, 1) ->addCreditCardToken(self::iuguCreditCardToken()) @@ -206,8 +207,8 @@ public function testShouldDeleteCard() $multiPayment = new \Potelo\MultiPayment\MultiPayment($gateway); $multiPayment->deleteCard($customer->id, $creditCard->id); - $this->expectException(\Potelo\MultiPayment\Exceptions\GatewayException::class); - $this->expectExceptionMessage('payment_method: not found'); + $this->expectException(\Potelo\MultiPayment\Exceptions\NotFoundException::class); + $this->expectExceptionMessageMatches('/not found/i'); $multiPayment->getCard($customer->id, $creditCard->id); } @@ -270,7 +271,7 @@ public function testShouldDuplicateInvoice() { $gateway = 'iugu'; $invoice = MultiPayment::setGateway($gateway)->newInvoice() - ->addAvailablePaymentMethod(Invoice::PAYMENT_METHOD_PIX) + ->addAvailablePaymentMethod(PaymentMethod::PIX) ->addCustomer('Fake Customer', 'email@exemplo.com', '20176996915') ->addItem('teste', 1000, 1) ->create(); @@ -278,25 +279,24 @@ public function testShouldDuplicateInvoice() $multiPayment = new \Potelo\MultiPayment\MultiPayment($gateway); $new = $multiPayment->duplicateInvoice($invoice->id, now()->addDays(7)); $this->assertNotEquals($new->id, $invoice->id); - $this->assertEquals($new->status, Invoice::STATUS_PENDING); - $this->assertTrue($new->expiresAt->isSameDay((now()->addDays(7)))); + $this->assertEquals($new->status, InvoiceStatus::PENDING); + $this->assertTrue($new->dueDate->isSameDay((now()->addDays(7)))); } /** * Test if thorws an exception when not find the invoice * - * @dataProvider shouldNotGetInvoiceDataProvider - * * @param $gateway * @param $id * * @return void * @throws \Potelo\MultiPayment\Exceptions\GatewayException */ + #[DataProvider('shouldNotGetInvoiceDataProvider')] public function testShouldNotGetInvoice($gateway, $id) { - $this->expectException(\Potelo\MultiPayment\Exceptions\GatewayException::class); + $this->expectException(\Potelo\MultiPayment\Exceptions\NotFoundException::class); $multiPayment = new \Potelo\MultiPayment\MultiPayment($gateway); $multiPayment->getInvoice($id); } @@ -304,7 +304,7 @@ public function testShouldNotGetInvoice($gateway, $id) /** * @return array */ - public function shouldNotGetInvoiceDataProvider(): array + public static function shouldNotGetInvoiceDataProvider(): array { return [ 'iugu' => ['iugu', '4DAF50DDAA1E461CBA9ECF813111FC0B'], @@ -314,8 +314,6 @@ public function shouldNotGetInvoiceDataProvider(): array /** * Test if can refund the invoice * - * @dataProvider shouldRefundInvoiceDataProvider - * * @param string $gateway * @param array $data * @@ -324,7 +322,8 @@ public function shouldNotGetInvoiceDataProvider(): array * @throws \Potelo\MultiPayment\Exceptions\GatewayNotAvailableException * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException */ - public function testShouldRefundInvoice(string $gateway, array $data, string $status, ?int $refundedAmount) + #[DataProvider('shouldRefundInvoiceDataProvider')] + public function testShouldRefundInvoice(string $gateway, array $data, InvoiceStatus $status, ?int $refundedAmount) { $multiPayment = new \Potelo\MultiPayment\MultiPayment($gateway); @@ -355,20 +354,39 @@ public function testShouldRefundInvoice(string $gateway, array $data, string $st $invoice = $invoiceBuilder->create(); sleep(3); - $refundedInvoice = $multiPayment->refundInvoice($invoice->id, $refundedAmount); + $refund = $multiPayment->refundInvoice($invoice->id, $refundedAmount); if (is_null($refundedAmount)) { $refundedAmount = $total; } - $this->assertEquals($status, $refundedInvoice->status); + $this->assertInstanceOf(Refund::class, $refund); + $this->assertSame($refundedAmount, $refund->amount); + $this->assertSame(RefundStatus::SUCCEEDED, $refund->status); + $this->assertSame($invoice->id, $refund->invoiceId); + + $refundedInvoice = $refund->invoice(); + $this->assertSame($status, $refundedInvoice->status); $this->assertEquals($refundedAmount, $refundedInvoice->refundedAmount); $this->assertEquals($total - $refundedAmount, $refundedInvoice->paidAmount); + $this->assertCount(1, $refundedInvoice->refunds); + $this->assertEquals($refundedAmount, $refundedInvoice->refunds[0]->amount); + + // na Iugu a guarda lê a fatura real antes: já estornada é recusada sem novo POST + if ($gateway === 'iugu' && $status === InvoiceStatus::REFUNDED) { + try { + $multiPayment->refundInvoice($invoice->id); + $this->fail('Esperava RefundNotSupportedException'); + } catch (RefundNotSupportedException $e) { + $this->assertSame(RefundNotSupportedException::REASON_ALREADY_REFUNDED, $e->reason); + $this->assertFalse($e->manualRefundRequired); + } + } } /** * @return array */ - public function shouldRefundInvoiceDataProvider(): array + public static function shouldRefundInvoiceDataProvider(): array { return [ 'iugu - credit card - full refund' => [ @@ -379,7 +397,7 @@ public function shouldRefundInvoiceDataProvider(): array 'paymentMethod' => 'credit_card', 'creditCard' => self::creditCard(), ], - 'status' => Invoice::STATUS_REFUNDED, + 'status' => InvoiceStatus::REFUNDED, 'refundedAmount' => null, ], ]; @@ -389,11 +407,9 @@ public function shouldRefundInvoiceDataProvider(): array /** * Test if can refund the invoice * - * @dataProvider shouldChargeInvoiceWithCreditCard - * * @param string $gateway * @param array $data - * @param string $status + * @param InvoiceStatus $status * @param string $creditCardDataMethod * @return void * @throws \Potelo\MultiPayment\Exceptions\ChargingException @@ -403,7 +419,8 @@ public function shouldRefundInvoiceDataProvider(): array * @throws \Potelo\MultiPayment\Exceptions\ModelAttributeValidationException * @throws \Potelo\MultiPayment\Exceptions\MultiPaymentException */ - public function testShouldChargeInvoiceWithCreditCard(string $gateway, array $data, string $status, string $creditCardDataMethod) + #[DataProvider('shouldChargeInvoiceWithCreditCard')] + public function testShouldChargeInvoiceWithCreditCard(string $gateway, array $data, InvoiceStatus $status, string $creditCardDataMethod) { $multiPayment = new \Potelo\MultiPayment\MultiPayment($gateway); @@ -440,13 +457,13 @@ public function testShouldChargeInvoiceWithCreditCard(string $gateway, array $da $invoice = $multiPayment->chargeInvoiceWithCreditCard($invoice->id, null, $creditCard->id); } - $this->assertEquals($status, $invoice->status); + $this->assertSame($status, $invoice->status); } /** * @return array */ - public function shouldChargeInvoiceWithCreditCard(): array + public static function shouldChargeInvoiceWithCreditCard(): array { return [ 'iugu - credit card object' => [ @@ -456,7 +473,7 @@ public function shouldChargeInvoiceWithCreditCard(): array 'customer' => self::customerWithoutAddress(), 'paymentMethod' => 'credit_card', ], - 'status' => Invoice::STATUS_PAID, + 'status' => InvoiceStatus::PAID, 'creditCardDataMethod' => 'creditCard', ], 'iugu - credit card token' => [ @@ -466,7 +483,7 @@ public function shouldChargeInvoiceWithCreditCard(): array 'customer' => self::customerWithoutAddress(), 'paymentMethod' => 'credit_card', ], - 'status' => Invoice::STATUS_PAID, + 'status' => InvoiceStatus::PAID, 'creditCardDataMethod' => 'token', ], 'iugu - credit card id' => [ @@ -476,9 +493,37 @@ public function shouldChargeInvoiceWithCreditCard(): array 'customer' => self::customerWithoutAddress(), 'paymentMethod' => 'credit_card', ], - 'status' => Invoice::STATUS_PAID, + 'status' => InvoiceStatus::PAID, 'creditCardDataMethod' => 'id', ], ]; } + + /** + * `pixExpiresAt` vai em `pix_qr_code_expires_at` e a Iugu aceita a fatura; `dueDate` + * ausente vira o dia em que o QR Code expira. A Iugu não devolve o campo na fatura, então + * a releitura só confirma o vencimento. + * + * @return void + */ + public function testShouldCreateAPixInvoiceWithItsOwnQrCodeExpiryOnIugu(): void + { + $pixExpiresAt = now()->addDays(2)->setTime(18, 0); + $invoice = MultiPayment::setGateway('iugu')->newInvoice() + ->setPaymentMethod(PaymentMethod::PIX) + ->addCustomer('Fake Customer', 'email@exemplo.com', '20176996915') + ->addItem('teste', 1000, 1) + ->setPixExpiresAt($pixExpiresAt) + ->create(); + + $this->assertSame(InvoiceStatus::PENDING, $invoice->status); + $this->assertSame([PaymentMethod::PIX], $invoice->availablePaymentMethods); + $this->assertNotEmpty($invoice->pix->qrCodeText); + $this->assertSame($pixExpiresAt->format('Y-m-d'), $invoice->dueDate->format('Y-m-d')); + + $relida = MultiPayment::setGateway('iugu')->getInvoice($invoice->id); + $this->assertSame($pixExpiresAt->format('Y-m-d'), $relida->dueDate->format('Y-m-d')); + + MultiPayment::setGateway('iugu')->cancelInvoice($invoice->id); + } } diff --git a/tests/Integration/StripeAutomaticPixTest.php b/tests/Integration/StripeAutomaticPixTest.php new file mode 100644 index 0000000..dd5ea05 --- /dev/null +++ b/tests/Integration/StripeAutomaticPixTest.php @@ -0,0 +1,114 @@ + Config::get('multi-payment.gateways.stripe.api_key'), + 'stripe_version' => StripeGateway::STRIPE_API_VERSION, + ]); + + foreach ($this->subscriptionsCriadas as $id) { + try { + $client->subscriptions->cancel($id); + } catch (\Throwable $e) { + // limpeza é best effort: assinatura já cancelada pelo teste + } + } + + foreach ($this->pricesCriados as $id) { + try { + $client->prices->update($id, ['active' => false]); + } catch (\Throwable $e) { + // limpeza é best effort: falha ao arquivar não invalida o teste + } + } + + parent::tearDown(); + } + + /** + * A assinatura com Pix Automático nasce com a primeira fatura em aberto + * (`default_incomplete`) e o mandato registrado: o model volta com o método + * `AUTOMATIC_PIX`, `automaticPix` preenchido e `startsAt` no mínimo três dias à frente. + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldCreateASubscriptionWithAPixMandate(string $gateway): void + { + $identifier = 'multipayment-pix-automatico-' . uniqid(); + $plan = new Plan(); + $plan->name = 'Plano Pix Automático'; + $plan->identifier = $identifier; + $plan->amount = 10000; + $plan->interval = PlanInterval::MONTH; + $plan->save($gateway); + $this->pricesCriados[] = $plan->id; + + $customer = $this->createCustomer($gateway, self::customerWithoutAddress()); + + $subscription = MultiPayment::setGateway($gateway)->newSubscription() + ->setPlanId($identifier) + ->setCustomer($customer) + ->setPaymentMethod(PaymentMethod::AUTOMATIC_PIX) + ->create(); + $this->subscriptionsCriadas[] = $subscription->id; + + $this->assertSame(SubscriptionStatus::PENDING, $subscription->status); + $this->assertSame(PaymentMethod::AUTOMATIC_PIX, $subscription->paymentMethod); + $this->assertInstanceOf(AutomaticPix::class, $subscription->automaticPix); + $this->assertSame(AutomaticPix::FREQUENCY_MONTHLY, $subscription->automaticPix->frequency); + $this->assertTrue( + $subscription->automaticPix->startsAt->greaterThanOrEqualTo(Carbon::now()->addDays(3)->startOfDay()) + ); + $this->assertNotNull($subscription->latestInvoice); + $this->assertNotEmpty($subscription->latestInvoice->url); + + $read = MultiPayment::setGateway($gateway)->getSubscription($subscription->id); + $this->assertSame(PaymentMethod::AUTOMATIC_PIX, $read->paymentMethod); + $this->assertInstanceOf(AutomaticPix::class, $read->automaticPix); + } +} diff --git a/tests/Integration/StripeGatewayTest.php b/tests/Integration/StripeGatewayTest.php new file mode 100644 index 0000000..b4fa91d --- /dev/null +++ b/tests/Integration/StripeGatewayTest.php @@ -0,0 +1,652 @@ +newInvoice() + ->addCustomer( + $customerData['name'], + $customerData['email'], + $customerData['taxDocument'], + $customerData['birthDate'], + $customerData['phoneArea'], + $customerData['phoneNumber'] + ) + ->addItem('Assinatura mensal', 12345, 1) + ->setAvailablePaymentMethods([PaymentMethod::CREDIT_CARD]) + ->addCreditCardToken('pm_card_visa') + ->create(); + + $this->assertNotNull($invoice->id); + $this->assertEquals(InvoiceStatus::PAID, $invoice->status); + $this->assertEquals(12345, $invoice->amount); + $this->assertEquals(12345, $invoice->paidAmount); + $this->assertEquals(PaymentMethod::CREDIT_CARD, $invoice->paymentMethod); + $this->assertEquals('4242', $invoice->creditCard->lastDigits); + $this->assertNotNull($invoice->paidAt); + $this->assertCount(1, $invoice->items); + $this->assertEquals('Assinatura mensal', $invoice->items[0]->description); + + sleep(3); // a balance transaction (fee) do cartão é assíncrona logo após o confirm + + $invoiceFetched = MultiPayment::setGateway($gateway)->getInvoice($invoice->id); + $this->assertEquals(InvoiceStatus::PAID, $invoiceFetched->status); + $this->assertEquals(12345, $invoiceFetched->paidAmount); + $this->assertNotNull($invoiceFetched->fee); + $this->assertEquals($invoice->id, $invoiceFetched->id); + } + + /** + * Recusa de cartão deve virar ChargingException com resposta bruta e razão normalizada. + * + * @return void + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldRaiseChargingExceptionOnDeclinedCard($gateway) + { + $customerData = self::customerWithoutAddress(); + $invoiceBuilder = MultiPayment::setGateway($gateway)->newInvoice() + ->addCustomer( + $customerData['name'], + $customerData['email'], + $customerData['taxDocument'], + $customerData['birthDate'], + $customerData['phoneArea'], + $customerData['phoneNumber'] + ) + ->addItem('Assinatura mensal', 9900, 1) + ->setAvailablePaymentMethods([PaymentMethod::CREDIT_CARD]) + ->addCreditCardToken('pm_card_chargeDeclined'); + + try { + $invoiceBuilder->create(); + $this->fail('Expected ChargingException was not thrown'); + } catch (ChargingException $exception) { + $this->assertInstanceOf(CardDeclinedException::class, $exception); + $this->assertEquals('card_declined', $exception->reason); + $this->assertSame(DeclineCode::GENERIC, $exception->declineCode); + $this->assertSame('generic_decline', $exception->gatewayCode); + // a recusa acontece no SetupIntent que salva o token, onde a Stripe envia + // advice_code try_again_later para este cartão de teste + $this->assertTrue($exception->retryable); + $this->assertNotEmpty($exception->chargeResponse); + } + } + + /** + * Recusa por saldo insuficiente chega com o decline_code da Stripe traduzido para + * INSUFFICIENT_FUNDS e com nova tentativa permitida. + * + * @return void + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testInsufficientFundsDeclineIsTranslatedToDeclineCode($gateway) + { + $customerData = self::customerWithoutAddress(); + $invoiceBuilder = MultiPayment::setGateway($gateway)->newInvoice() + ->addCustomer( + $customerData['name'], + $customerData['email'], + $customerData['taxDocument'], + $customerData['birthDate'], + $customerData['phoneArea'], + $customerData['phoneNumber'] + ) + ->addItem('Assinatura mensal', 9900, 1) + ->setAvailablePaymentMethods([PaymentMethod::CREDIT_CARD]) + ->addCreditCardToken('pm_card_chargeDeclinedInsufficientFunds'); + + try { + $invoiceBuilder->create(); + $this->fail('Expected CardDeclinedException was not thrown'); + } catch (CardDeclinedException $exception) { + $this->assertSame(DeclineCode::INSUFFICIENT_FUNDS, $exception->declineCode); + $this->assertSame('insufficient_funds', $exception->gatewayCode); + $this->assertTrue($exception->retryable); + $this->assertSame('insufficient_funds', $exception->reason); + } + } + + /** + * Deve salvar, buscar, definir como padrão e excluir um cartão tokenizado. + * + * @return void + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldManageCreditCardLifecycle($gateway) + { + $customer = $this->createCustomer($gateway, self::customerWithoutAddress()); + + $creditCard = MultiPayment::setGateway($gateway)->newCreditCard() + ->setCustomerId($customer->id) + ->setToken('pm_card_visa') + ->setDescription('cartão de teste') + ->setAsDefault() + ->create(); + + $this->assertNotNull($creditCard->id); + $this->assertFalse($creditCard->requiresAction); + $this->assertStringStartsWith('seti_', $creditCard->setupId); + $this->assertEquals('visa', $creditCard->brand); + $this->assertEquals('4242', $creditCard->lastDigits); + $this->assertEquals('cartão de teste', $creditCard->description); + $this->assertEquals($gateway, $creditCard->gateway); + + // a descrição e a marcação de padrão viajam em metadata do SetupIntent e são aplicadas no setup concluído + $cardFetched = MultiPayment::setGateway($gateway)->getCard($customer->id, $creditCard->id); + $this->assertEquals($creditCard->id, $cardFetched->id); + $this->assertEquals('4242', $cardFetched->lastDigits); + $this->assertEquals('cartão de teste', $cardFetched->description); + $this->assertEquals($creditCard->id, MultiPayment::setGateway($gateway)->getCustomer($customer->id)->defaultCard->id); + + $customerUpdated = MultiPayment::setGateway($gateway)->setDefaultCard($customer->id, $creditCard->id); + $this->assertEquals($creditCard->id, $customerUpdated->defaultCard->id); + + MultiPayment::setGateway($gateway)->deleteCard($customer->id, $creditCard->id); + + // após o detach o PaymentMethod não pertence mais ao customer + $this->expectException(UnsupportedOperationException::class); + MultiPayment::setGateway($gateway)->getCard($customer->id, $creditCard->id); + } + + /** + * Cartão que exige autenticação do portador (`pm_card_authenticationRequired`) volta com + * `requiresAction` e sem id, e a confirmação antes de o pagador autenticar devolve o mesmo + * estado; nada é anexado ao cliente. + * + * @return void + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldReturnRequiresActionForACardThatNeedsAuthentication($gateway) + { + $customer = $this->createCustomer($gateway, self::customerWithoutAddress()); + + $creditCard = MultiPayment::setGateway($gateway)->newCreditCard() + ->setCustomerId($customer->id) + ->setToken('pm_card_authenticationRequired') + ->setDescription('cartão com autenticação') + ->create(); + + $this->assertTrue($creditCard->requiresAction); + $this->assertNull($creditCard->id); + $this->assertStringStartsWith('seti_', $creditCard->setupId); + $this->assertNotEmpty($creditCard->clientSecret); + $this->assertNull($creditCard->actionUrl, 'sem return_url a autenticação é pelo Stripe.js'); + $this->assertEquals('3184', $creditCard->lastDigits); + $this->assertEquals('cartão com autenticação', $creditCard->description); + + $confirmed = MultiPayment::setGateway($gateway)->confirmCreditCardSetup($creditCard->setupId); + $this->assertTrue($confirmed->requiresAction); + $this->assertNull($confirmed->id); + $this->assertEquals($creditCard->setupId, $confirmed->setupId); + $this->assertEquals($customer->id, $confirmed->customer->id); + + $attached = $this->stripeClient()->paymentMethods->all(['customer' => $customer->id, 'type' => 'card']); + $this->assertCount(0, $attached->data, 'o cartão só é anexado depois da autenticação'); + } + + /** + * Deve criar fatura pix server-side com QR code e refletir o pagamento mágico da sandbox. + * + * @return void + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldCreatePixInvoiceAndReceiveMagicPayment($gateway) + { + $customerData = self::customerWithoutAddress(); + $invoice = MultiPayment::setGateway($gateway)->newInvoice() + ->addCustomer( + $customerData['name'], + 'succeed_immediately@example.com', + $customerData['taxDocument'] + ) + ->addItem('Assinatura mensal', 12345, 1) + ->setAvailablePaymentMethods([PaymentMethod::PIX]) + ->setPixExpiresAt(\Carbon\Carbon::now()->addHour()) + ->create(); + + $this->assertNotNull($invoice->id); + $this->assertEquals(InvoiceStatus::PENDING, $invoice->status); + $this->assertEquals(PaymentMethod::PIX, $invoice->paymentMethod); + $this->assertNotNull($invoice->pix); + $this->assertNotNull($invoice->pix->qrCodeText); + $this->assertNotNull($invoice->pix->qrCodeImageUrl); + $this->assertNotNull($invoice->url); + $this->assertNotNull($invoice->pixExpiresAt); + $this->assertNull($invoice->dueDate); + + // além do pagamento mágico, espera a balance transaction (fee) materializar + $invoiceFetched = $this->waitForInvoiceCondition($gateway, $invoice->id, function (Invoice $fetched) { + return $fetched->status === InvoiceStatus::PAID && !is_null($fetched->fee); + }); + $this->assertEquals(InvoiceStatus::PAID, $invoiceFetched->status); + $this->assertEquals(12345, $invoiceFetched->paidAmount); + $this->assertEquals(PaymentMethod::PIX, $invoiceFetched->paymentMethod); + $this->assertNotNull($invoiceFetched->fee); + } + + /** + * Deve cancelar uma fatura pix pendente. + * + * @return void + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldCancelPendingPixInvoice($gateway) + { + $customerData = self::customerWithoutAddress(); + $invoice = MultiPayment::setGateway($gateway)->newInvoice() + ->addCustomer($customerData['name'], $customerData['email'], $customerData['taxDocument']) + ->addItem('Assinatura mensal', 5000, 1) + ->setAvailablePaymentMethods([PaymentMethod::PIX]) + ->create(); + + $this->assertEquals(InvoiceStatus::PENDING, $invoice->status); + + $invoiceCanceled = MultiPayment::setGateway($gateway)->cancelInvoice($invoice->id); + $this->assertEquals(InvoiceStatus::CANCELED, $invoiceCanceled->status); + } + + /** + * Fatura pix expirada volta a pendente e deve aceitar cobrança com cartão. + * + * @return void + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldChargeExpiredPixInvoiceWithCreditCard($gateway) + { + $customerData = self::customerWithoutAddress(); + $invoice = MultiPayment::setGateway($gateway)->newInvoice() + ->addCustomer($customerData['name'], 'expire_immediately@example.com', $customerData['taxDocument']) + ->addItem('Assinatura mensal', 9900, 1) + ->setAvailablePaymentMethods([PaymentMethod::PIX]) + ->create(); + + $this->assertEquals(InvoiceStatus::PENDING, $invoice->status); + + // aguarda a sandbox processar a expiração mágica (o PI segue pendente e re-cobrável) + $invoiceExpired = $this->waitForInvoiceCondition($gateway, $invoice->id, function (Invoice $fetched) { + return empty($fetched->pix); + }); + $this->assertEquals(InvoiceStatus::PENDING, $invoiceExpired->status); + + $invoicePaid = MultiPayment::setGateway($gateway) + ->chargeInvoiceWithCreditCard($invoice->id, 'pm_card_visa'); + $this->assertEquals(InvoiceStatus::PAID, $invoicePaid->status); + $this->assertEquals(PaymentMethod::CREDIT_CARD, $invoicePaid->paymentMethod); + $this->assertEquals('4242', $invoicePaid->creditCard->lastDigits); + } + + /** + * Busca a fatura até a condição ser satisfeita ou o tempo limite estourar — a sandbox + * processa os e-mails mágicos e a balance transaction de forma assíncrona. + * + * @param string $gateway + * @param string $invoiceId + * @param callable $condition + * @return \Potelo\MultiPayment\Models\Invoice + */ + private function waitForInvoiceCondition(string $gateway, string $invoiceId, callable $condition): Invoice + { + $invoice = MultiPayment::setGateway($gateway)->getInvoice($invoiceId); + foreach (range(1, 10) as $attempt) { + if ($condition($invoice)) { + return $invoice; + } + sleep(3); + $invoice = MultiPayment::setGateway($gateway)->getInvoice($invoiceId); + } + + $this->fail("Timeout aguardando a condição da fatura [{$invoiceId}] na sandbox"); + } + + /** + * Deve estornar integralmente uma fatura de cartão paga. + * + * @return void + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldRefundCreditCardInvoiceTotally($gateway) + { + $customerData = self::customerWithoutAddress(); + $invoice = MultiPayment::setGateway($gateway)->newInvoice() + ->addCustomer($customerData['name'], $customerData['email'], $customerData['taxDocument']) + ->addItem('Assinatura mensal', 9900, 1) + ->setAvailablePaymentMethods([PaymentMethod::CREDIT_CARD]) + ->addCreditCardToken('pm_card_visa') + ->create(); + + $this->assertEquals(InvoiceStatus::PAID, $invoice->status); + + $refund = MultiPayment::setGateway($gateway)->refundInvoice($invoice->id); + $this->assertStringStartsWith('re_', $refund->id); + $this->assertEquals(9900, $refund->amount); + $this->assertContains($refund->status, [RefundStatus::PENDING, RefundStatus::SUCCEEDED]); + + $invoiceRefunded = $refund->invoice(); + $this->assertEquals(InvoiceStatus::REFUNDED, $invoiceRefunded->status); + $this->assertEquals(9900, $invoiceRefunded->refundedAmount); + $this->assertCount(1, $invoiceRefunded->refunds); + $this->assertEquals($refund->id, $invoiceRefunded->refunds[0]->id); + } + + /** + * Deve estornar parcialmente uma fatura pix paga. + * + * @return void + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldRefundPixInvoicePartially($gateway) + { + $customerData = self::customerWithoutAddress(); + $invoice = MultiPayment::setGateway($gateway)->newInvoice() + ->addCustomer($customerData['name'], 'succeed_immediately@example.com', $customerData['taxDocument']) + ->addItem('Assinatura mensal', 12345, 1) + ->setAvailablePaymentMethods([PaymentMethod::PIX]) + ->create(); + + $invoicePaid = $this->waitForInvoiceCondition($gateway, $invoice->id, function (Invoice $fetched) { + return $fetched->status === InvoiceStatus::PAID; + }); + $this->assertEquals(InvoiceStatus::PAID, $invoicePaid->status); + + $refund = MultiPayment::setGateway($gateway)->refundInvoice($invoice->id, 2345); + $this->assertEquals(2345, $refund->amount); + + $invoiceRefunded = $refund->invoice(); + $this->assertEquals(InvoiceStatus::PARTIALLY_REFUNDED, $invoiceRefunded->status); + $this->assertEquals(2345, $invoiceRefunded->refundedAmount); + $this->assertEquals(12345, $invoiceRefunded->paidAmount); + $this->assertCount(1, $invoiceRefunded->refunds); + $this->assertEquals($refund->id, $invoiceRefunded->refunds[0]->id); + + // segundo estorno acima do restante é recusado sem chamar a Stripe + try { + MultiPayment::setGateway($gateway)->refundInvoice($invoice->id, 10001); + $this->fail('Esperava RefundNotSupportedException'); + } catch (RefundNotSupportedException $e) { + $this->assertSame(RefundNotSupportedException::REASON_AMOUNT_EXCEEDS_REFUNDABLE, $e->reason); + } + $this->assertEquals(2345, MultiPayment::setGateway($gateway)->getInvoice($invoice->id)->refundedAmount); + } + + /** + * Deve duplicar uma fatura pix pendente com nova expiração, cancelando a original. + * + * @return void + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldDuplicatePendingPixInvoice($gateway) + { + $customerData = self::customerWithoutAddress(); + $invoice = MultiPayment::setGateway($gateway)->newInvoice() + ->addCustomer($customerData['name'], $customerData['email'], $customerData['taxDocument']) + ->addItem('Assinatura mensal', 5000, 1) + ->setAvailablePaymentMethods([PaymentMethod::PIX]) + ->setPixExpiresAt(\Carbon\Carbon::now()->addHour()) + ->create(); + + $this->assertEquals(InvoiceStatus::PENDING, $invoice->status); + + $newExpiresAt = \Carbon\Carbon::now()->addDays(2); + $invoiceDuplicated = MultiPayment::setGateway($gateway) + ->duplicateInvoice($invoice->id, $newExpiresAt); + + $this->assertNotEquals($invoice->id, $invoiceDuplicated->id); + $this->assertEquals(InvoiceStatus::PENDING, $invoiceDuplicated->status); + $this->assertEquals(5000, $invoiceDuplicated->amount); + $this->assertNotNull($invoiceDuplicated->pix->qrCodeText); + $this->assertEqualsWithDelta( + $newExpiresAt->getTimestamp(), + $invoiceDuplicated->pixExpiresAt->getTimestamp(), + 60 + ); + $this->assertEquals($invoice->customer->id, $invoiceDuplicated->customer->id); + + $originalFetched = MultiPayment::setGateway($gateway)->getInvoice($invoice->id); + $this->assertEquals(InvoiceStatus::CANCELED, $originalFetched->status); + } + + /** + * Fatura de assinatura (objeto Invoice da Stripe) lida por `getInvoice()` com o id `in_`, + * recusada na duplicação e cancelada por `void`. O Invoice é criado direto no SDK porque a + * lib ainda não cria fatura nem assinatura no Stripe. + * + * @return void + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldReadAndVoidAStripeInvoiceByItsId($gateway) + { + $customer = $this->createCustomer($gateway, self::customerWithoutAddress()); + $stripeInvoiceId = $this->createOpenStripeInvoice($customer->id); + + $invoice = MultiPayment::setGateway($gateway)->getInvoice($stripeInvoiceId); + + $this->assertSame($stripeInvoiceId, $invoice->id); + $this->assertSame(InvoiceOriginType::INVOICE, $invoice->originType); + $this->assertSame(InvoiceStatus::PENDING, $invoice->status); + $this->assertSame(12345, $invoice->amount); + $this->assertNull($invoice->paidAmount); + $this->assertSame($customer->id, $invoice->customer->id); + $this->assertSame('Assinatura mensal', $invoice->items[0]->description); + $this->assertSame(12345, $invoice->items[0]->price); + $this->assertStringStartsWith('https://invoice.stripe.com/', $invoice->url); + $this->assertInstanceOf(\Stripe\Invoice::class, $invoice->original); + + try { + MultiPayment::setGateway($gateway)->duplicateInvoice($stripeInvoiceId, Carbon::now()->addDay()); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::INVOICE_DUPLICATION, $e->capability); + } + + $canceled = MultiPayment::setGateway($gateway)->cancelInvoice($stripeInvoiceId); + $this->assertSame(InvoiceStatus::CANCELED, $canceled->status); + $this->assertSame(InvoiceOriginType::INVOICE, $canceled->originType); + $this->assertSame('void', $canceled->original->status); + + $this->assertSame(InvoiceStatus::CANCELED, MultiPayment::setGateway($gateway)->getInvoice($stripeInvoiceId)->status); + } + + /** + * Fatura de assinatura paga: o charge do PaymentIntent, lido num GET à parte, alimenta + * valor pago, taxa e cartão. + * + * @return void + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldReadAPaidStripeInvoiceWithItsCharge($gateway) + { + $customer = $this->createCustomer($gateway, self::customerWithoutAddress()); + $stripeInvoiceId = $this->createOpenStripeInvoice($customer->id); + + $client = $this->stripeClient(); + $paymentMethod = $client->paymentMethods->attach('pm_card_visa', ['customer' => $customer->id]); + $client->invoices->pay($stripeInvoiceId, ['payment_method' => $paymentMethod->id]); + + $invoice = MultiPayment::setGateway($gateway)->getInvoice($stripeInvoiceId); + + $this->assertSame(InvoiceStatus::PAID, $invoice->status); + $this->assertSame(InvoiceOriginType::INVOICE, $invoice->originType); + $this->assertSame(12345, $invoice->paidAmount); + $this->assertSame(0, $invoice->refundedAmount); + $this->assertSame([], $invoice->refunds); + $this->assertNotNull($invoice->paidAt); + $this->assertSame(PaymentMethod::CREDIT_CARD, $invoice->paymentMethod); + $this->assertSame('visa', $invoice->creditCard->brand); + $this->assertSame('4242', $invoice->creditCard->lastDigits); + } + + /** + * Cria um Invoice `open` de 12345 centavos na sandbox, direto no SDK. + * + * @param string $customerId + * @return string id do Invoice (`in_`) + */ + private function createOpenStripeInvoice(string $customerId): string + { + $client = $this->stripeClient(); + $stripeInvoice = $client->invoices->create([ + 'customer' => $customerId, + 'collection_method' => 'charge_automatically', + 'currency' => 'brl', + 'auto_advance' => false, + ]); + $client->invoiceItems->create([ + 'customer' => $customerId, + 'invoice' => $stripeInvoice->id, + 'amount' => 12345, + 'currency' => 'brl', + 'description' => 'Assinatura mensal', + ]); + $client->invoices->finalizeInvoice($stripeInvoice->id, ['auto_advance' => false]); + + return $stripeInvoice->id; + } + + private function stripeClient(): StripeClient + { + return new StripeClient([ + 'api_key' => Config::get('multi-payment.gateways.stripe.api_key'), + 'stripe_version' => StripeGateway::STRIPE_API_VERSION, + ]); + } + + /** + * Deve criar uma fatura de boleto pendente com o voucher hospedado, a linha digitável e o + * PDF; o voucher em aberto não pode ser cancelado, e o estorno de boleto é recusado antes + * da rede. + * + * @return void + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldCreateBankSlipInvoiceWithHostedVoucher($gateway) + { + $customerData = self::customerWithoutAddress(); + $addressData = self::address(); + $invoice = MultiPayment::setGateway($gateway)->newInvoice() + ->addCustomer( + $customerData['name'], + $customerData['email'], + $customerData['taxDocument'] + ) + ->addCustomerAddress( + $addressData['zipCode'], + $addressData['street'], + $addressData['number'], + $addressData['complement'], + $addressData['district'], + $addressData['city'], + $addressData['state'], + $addressData['country'] + ) + ->addItem('Assinatura mensal', 12345, 1) + ->setAvailablePaymentMethods([PaymentMethod::BANK_SLIP]) + ->setDueDate(Carbon::today()->addDays(3)) + ->create(); + + $this->assertNotNull($invoice->id); + $this->assertEquals(InvoiceStatus::PENDING, $invoice->status); + $this->assertEquals(PaymentMethod::BANK_SLIP, $invoice->paymentMethod); + $this->assertStringContainsString('boleto/voucher', $invoice->url); + $this->assertNotEmpty($invoice->bankSlip->number); + $this->assertStringEndsWith('/pdf', $invoice->bankSlip->url); + $this->assertSame(Carbon::today()->addDays(3)->format('Y-m-d'), $invoice->dueDate->format('Y-m-d')); + + $invoiceFetched = MultiPayment::setGateway($gateway)->getInvoice($invoice->id); + $this->assertEquals(InvoiceStatus::PENDING, $invoiceFetched->status); + $this->assertEquals(PaymentMethod::BANK_SLIP, $invoiceFetched->paymentMethod); + $this->assertEquals($invoice->bankSlip->number, $invoiceFetched->bankSlip->number); + + try { + $invoiceFetched->cancel($gateway); + $this->fail('Esperava UnsupportedOperationException ao cancelar boleto pendente'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::INVOICE_CANCELLATION, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + } + + try { + MultiPayment::setGateway($gateway)->refundInvoice($invoice->id); + $this->fail('Esperava RefundNotSupportedException para estorno de boleto'); + } catch (RefundNotSupportedException $e) { + $this->assertSame(RefundNotSupportedException::REASON_BOLETO_NO_REFUND, $e->reason); + $this->assertTrue($e->manualRefundRequired); + } + } + + /** + * Sem `pixExpiresAt`, o QR Code expira no fim do dia de `dueDate`, e a Stripe aceita um + * vencimento de hoje. + * + * @param string $gateway + * @return void + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldCreateAPixInvoiceExpiringAtTheEndOfTheDueDate(string $gateway): void + { + $customerData = self::customerWithoutAddress(); + $invoice = MultiPayment::setGateway($gateway)->newInvoice() + ->addCustomer($customerData['name'], $customerData['email'], $customerData['taxDocument']) + ->addItem('Assinatura mensal', 5000, 1) + ->setPaymentMethod(PaymentMethod::PIX) + ->setDueDate(\Carbon\Carbon::today()) + ->create(); + + $this->assertEquals(InvoiceStatus::PENDING, $invoice->status); + $this->assertEquals(PaymentMethod::PIX, $invoice->paymentMethod); + $this->assertEqualsWithDelta( + \Carbon\Carbon::today()->endOfDay()->getTimestamp(), + $invoice->pixExpiresAt->getTimestamp(), + 60 + ); + $this->assertSame(\Carbon\Carbon::today()->format('Y-m-d'), $invoice->dueDate->format('Y-m-d')); + + MultiPayment::setGateway($gateway)->cancelInvoice($invoice->id); + } +} diff --git a/tests/Integration/StripeSubscriptionTest.php b/tests/Integration/StripeSubscriptionTest.php new file mode 100644 index 0000000..552ba34 --- /dev/null +++ b/tests/Integration/StripeSubscriptionTest.php @@ -0,0 +1,310 @@ + Config::get('multi-payment.gateways.stripe.api_key'), + 'stripe_version' => StripeGateway::STRIPE_API_VERSION, + ]); + + foreach ($this->subscriptionsCriadas as $id) { + try { + $client->subscriptions->cancel($id); + } catch (\Throwable $e) { + // limpeza é best effort: assinatura já cancelada pelo teste + } + } + + foreach ($this->pricesCriados as $id) { + try { + $client->prices->update($id, ['active' => false]); + } catch (\Throwable $e) { + // limpeza é best effort: falha ao arquivar não invalida o teste + } + } + + foreach ($this->couponsCriados as $id) { + try { + $client->coupons->delete($id); + } catch (\Throwable $e) { + // limpeza é best effort, como acima + } + } + + parent::tearDown(); + } + + private function createPlan(string $gateway, int $amount, string $sufixo, PlanInterval $interval = PlanInterval::MONTH): Plan + { + $plan = new Plan(); + $plan->name = 'MultiPayment teste ' . $sufixo; + $plan->identifier = 'multipayment-teste-' . $sufixo . '-' . now()->format('YmdHisu'); + $plan->amount = $amount; + $plan->interval = $interval; + $plan->save($gateway); + $this->pricesCriados[] = $plan->id; + + return $plan; + } + + /** + * Deve criar o plano como Price recorrente, buscá-lo pelo identificador e arquivá-lo. + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldCreateGetAndDeactivateAPlan($gateway) + { + $plan = $this->createPlan($gateway, 10000, 'plano'); + + $this->assertStringStartsWith('price_', $plan->id); + $this->assertSame(PlanInterval::MONTH, $plan->interval); + $this->assertSame('BRL', $plan->currency); + $this->assertTrue($plan->active); + + $found = MultiPayment::setGateway($gateway)->getPlan($plan->identifier); + $this->assertSame($plan->id, $found->id); + $this->assertSame($plan->identifier, $found->identifier); + $this->assertSame(10000, $found->amount); + } + + /** + * Deve arquivar o plano por deactivatePlan, mantendo-o legível. + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldDeactivateAPlan($gateway) + { + $plan = $this->createPlan($gateway, 10000, 'arquivar'); + + $deactivated = MultiPayment::setGateway($gateway)->gateway()->deactivatePlan($plan); + + $this->assertFalse($deactivated->active); + + $found = MultiPayment::setGateway($gateway)->getPlan($plan->id); + $this->assertFalse($found->active); + } + + /** + * Deve criar a assinatura com trial em dias no cartão de teste, trocar o plano com + * crédito, agendar o cancelamento ao fim do período, desfazê-lo e cancelar de vez. + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldRunTheSubscriptionLifecycleWithTrialAndCredit($gateway) + { + $mensal = $this->createPlan($gateway, 10000, 'mensal'); + $anual = $this->createPlan($gateway, 90000, 'anual', PlanInterval::YEAR); + + $customerData = self::customerWithoutAddress(); + $customer = new Customer(); + $customer->name = $customerData['name']; + $customer->email = $customerData['email']; + $customer->taxDocument = $customerData['taxDocument']; + + $creditCard = new CreditCard(); + $creditCard->token = 'pm_card_visa'; + + $subscription = MultiPayment::setGateway($gateway)->newSubscription() + ->setPlanId($mensal->identifier) + ->setCustomer($customer) + ->setCreditCard($creditCard) + ->setTrialDays(7) + ->create(); + $this->subscriptionsCriadas[] = $subscription->id; + + $this->assertSame(SubscriptionStatus::TRIALING, $subscription->status); + $this->assertNull($subscription->trialDays); + // uma hora de tolerância, para o teste não depender de fuso nem da virada do dia + $this->assertEqualsWithDelta( + now()->addDays(7)->getTimestamp(), + $subscription->trialEndsAt->getTimestamp(), + 3600 + ); + $this->assertSame(PaymentMethod::CREDIT_CARD, $subscription->paymentMethod); + $this->assertSame($mensal->identifier, $subscription->planId); + + // troca com crédito: a pró-rata fica para a próxima fatura, nada é cobrado agora + $subscription = $subscription->changePlan($anual->identifier, ProrationBehavior::CREDIT, $gateway); + $this->assertSame($anual->identifier, $subscription->planId); + + $subscription = $subscription->cancel(true, $gateway); + $this->assertTrue($subscription->cancelAtPeriodEnd); + $this->assertNotNull($subscription->canceledAt); + $this->assertNotSame(SubscriptionStatus::CANCELED, $subscription->status); + + $subscription = $subscription->resume($gateway); + $this->assertFalse($subscription->cancelAtPeriodEnd); + $this->assertNull($subscription->canceledAt); + + $subscription = $subscription->cancel(false, $gateway); + $this->assertSame(SubscriptionStatus::CANCELED, $subscription->status); + $this->assertNotNull($subscription->canceledAt); + } + + /** + * O desconto vira um Coupon aplicado à assinatura: `cycles` acima de 1 num plano mensal é + * `repeating` com o fim em `validUntil`, a primeira fatura sai com o abatimento e a + * leitura devolve o desconto com o id do Coupon. + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldCreateASubscriptionWithACouponDiscount($gateway) + { + $mensal = $this->createPlan($gateway, 10000, 'cupom'); + + $customerData = self::customerWithoutAddress(); + $customer = new Customer(); + $customer->name = $customerData['name']; + $customer->email = $customerData['email']; + $customer->taxDocument = $customerData['taxDocument']; + + $creditCard = new CreditCard(); + $creditCard->token = 'pm_card_visa'; + + $subscription = MultiPayment::setGateway($gateway)->newSubscription() + ->setPlanId($mensal->identifier) + ->setCustomer($customer) + ->setCreditCard($creditCard) + ->addAmountDiscount('Promo', 500, 3) + ->create(); + $this->subscriptionsCriadas[] = $subscription->id; + + $this->assertCount(1, $subscription->discounts); + $discount = $subscription->discounts[0]; + $this->couponsCriados[] = $discount->id; + $this->assertNotEmpty($discount->id); + $this->assertSame('Promo', $discount->description); + $this->assertSame(500, $discount->amountOff); + // duração repeating de 3 meses: a Stripe informa o fim do desconto + $this->assertNotNull($discount->validUntil); + $this->assertEqualsWithDelta( + now()->addMonths(3)->getTimestamp(), + $discount->validUntil->getTimestamp(), + 86400 * 4 + ); + + $this->assertSame(9500, $subscription->latestInvoice->paidAmount); + + $lida = MultiPayment::setGateway($gateway)->getSubscription($subscription->id); + $this->assertSame($discount->id, $lida->discounts[0]->id); + $this->assertSame(500, $lida->discounts[0]->amountOff); + } + + /** + * Deve simular a troca de plano com as linhas reais de pró-rata da Stripe. + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldPreviewAPlanChangeWithRealLines($gateway) + { + $mensal = $this->createPlan($gateway, 10000, 'mensal-preview'); + $anual = $this->createPlan($gateway, 90000, 'anual-preview', PlanInterval::YEAR); + + $customerData = self::customerWithoutAddress(); + $customer = new Customer(); + $customer->name = $customerData['name']; + $customer->email = $customerData['email']; + $customer->taxDocument = $customerData['taxDocument']; + + $creditCard = new CreditCard(); + $creditCard->token = 'pm_card_visa'; + + $subscription = MultiPayment::setGateway($gateway)->newSubscription() + ->setPlanId($mensal->identifier) + ->setCustomer($customer) + ->setCreditCard($creditCard) + ->create(); + $this->subscriptionsCriadas[] = $subscription->id; + + $preview = $subscription->previewPlanChange($anual->identifier, $gateway); + + $this->assertNotEmpty($preview->items); + $this->assertTrue($preview->appliesImmediately); + $this->assertSame($preview->amount, array_sum(array_map( + static fn ($item) => $item->price * ($item->quantity ?? 1), + $preview->items + ))); + $credito = array_filter($preview->items, static fn ($item) => $item->price < 0); + $this->assertNotEmpty($credito, 'a prévia deve trazer a linha de crédito do período não usado'); + } + + /** + * A assinatura de boleto nasce ativa em modo de fatura enviada, com a primeira fatura já + * finalizada: aberta, com vencimento e com a página hospedada onde o pagador gera o + * voucher. + */ + #[DataProvider('stripeGatewayDataProvider')] + public function testShouldCreateASubscriptionPaidWithBankSlip($gateway) + { + $mensal = $this->createPlan($gateway, 10000, 'boleto'); + + $customerData = self::customerWithoutAddress(); + $customer = new Customer(); + $customer->name = $customerData['name']; + $customer->email = $customerData['email']; + $customer->taxDocument = $customerData['taxDocument']; + + $subscription = MultiPayment::setGateway($gateway)->newSubscription() + ->setPlanId($mensal->identifier) + ->setCustomer($customer) + ->setAvailablePaymentMethods([PaymentMethod::BANK_SLIP]) + ->create(); + $this->subscriptionsCriadas[] = $subscription->id; + + $this->assertSame(SubscriptionStatus::ACTIVE, $subscription->status); + $this->assertSame(PaymentMethod::BANK_SLIP, $subscription->paymentMethod); + + $latestInvoice = $subscription->latestInvoice; + $this->assertNotNull($latestInvoice); + $this->assertSame(\Potelo\MultiPayment\Enums\InvoiceStatus::PENDING, $latestInvoice->status); + $this->assertStringContainsString('invoice.stripe.com', $latestInvoice->url); + // days_until_due padrão de 3 dias, com uma hora de tolerância de fuso + $this->assertEqualsWithDelta( + now()->addDays(3)->getTimestamp(), + $latestInvoice->dueDate->getTimestamp(), + 3600 + ); + } +} diff --git a/tests/Integration/SubscriptionTest.php b/tests/Integration/SubscriptionTest.php new file mode 100644 index 0000000..0d59e6b --- /dev/null +++ b/tests/Integration/SubscriptionTest.php @@ -0,0 +1,487 @@ + ids criados, removidos no tearDown */ + private array $criados = ['subscriptions' => [], 'plans' => []]; + + /** @var string[] ids de fatura, que a Iugu cancela em vez de apagar */ + private array $faturasCriadas = []; + + /** + * @inheritDoc + */ + protected function tearDown(): void + { + foreach ($this->criados as $recurso => $ids) { + foreach ($ids as $id) { + try { + (new Iugu_APIRequest())->request( + 'DELETE', + Iugu::getBaseURI() . "/{$recurso}/" . rawurlencode($id), + [] + ); + } catch (\Throwable $e) { + // limpeza é best effort: falha ao remover não invalida o teste + } + } + } + + foreach ($this->faturasCriadas as $id) { + try { + (new Iugu_APIRequest())->request( + 'PUT', + Iugu::getBaseURI() . '/invoices/' . rawurlencode($id) . '/cancel', + [] + ); + } catch (\Throwable $e) { + // limpeza é best effort, como acima + } + } + + parent::tearDown(); + } + + private function createPlan( + int $amount, + string $sufixo, + PlanInterval $interval = PlanInterval::MONTH, + int $intervalCount = 1 + ): Plan { + $plan = new Plan(); + $plan->name = 'MultiPayment teste ' . $sufixo; + $plan->identifier = 'multipayment-teste-' . $sufixo . '-' . now()->format('YmdHisu'); + $plan->amount = $amount; + $plan->interval = $interval; + $plan->intervalCount = $intervalCount; + $plan->save(self::GATEWAY); + $this->criados['plans'][] = $plan->id; + + return $plan; + } + + private function createSubscription(Plan $plan, ?Carbon $nextBillingAt = null): Subscription + { + $customer = $this->createCustomer(self::GATEWAY, $this->customerWithoutAddress()); + + $builder = MultiPayment::setGateway(self::GATEWAY)->newSubscription() + ->setPlanId($plan->identifier) + ->setCustomerId($customer->id) + ->setAvailablePaymentMethods([PaymentMethod::PIX]); + + if ($nextBillingAt) { + $builder->setNextBillingAt($nextBillingAt); + } + + $subscription = $builder->create(); + $this->criados['subscriptions'][] = $subscription->id; + + return $subscription; + } + + /** + * Deve criar o plano, buscá-lo por id e por identifier e paginar a listagem. + * + * @return void + */ + public function testShouldCreateGetAndListPlans(): void + { + $plan = $this->createPlan(12345, 'plano'); + + $this->assertNotEmpty($plan->id); + $this->assertSame(12345, $plan->amount); + $this->assertSame(PlanInterval::MONTH, $plan->interval); + $this->assertSame('iugu', $plan->gateway); + + $porIdentifier = new Plan(); + $porIdentifier->identifier = $plan->identifier; + $this->assertSame($plan->id, $porIdentifier->get(self::GATEWAY)->id); + + $porId = new Plan(); + $porId->id = $plan->id; + $this->assertSame($plan->identifier, $porId->get(self::GATEWAY)->identifier); + + // a fachada aceita o identificador (uma requisição) ou o id do gateway (duas) + $this->assertSame($plan->id, MultiPayment::setGateway(self::GATEWAY)->getPlan($plan->identifier)->id); + $this->assertSame($plan->identifier, MultiPayment::setGateway(self::GATEWAY)->getPlan($plan->id)->identifier); + + $this->createPlan(500, 'plano2'); + + $primeira = MultiPayment::setGateway(self::GATEWAY)->listPlans(1, 1); + $segunda = MultiPayment::setGateway(self::GATEWAY)->listPlans(2, 1); + + $this->assertCount(1, $primeira); + $this->assertCount(1, $segunda); + $this->assertInstanceOf(Plan::class, $primeira[0]); + $this->assertNotSame($primeira[0]->id, $segunda[0]->id); + } + + /** + * A Iugu não tem intervalo anual; o plano anual deve ser aceito como 12 meses e voltar como + * `year` tanto na resposta da criação quanto numa leitura posterior. + * + * @return void + */ + public function testShouldCreateAYearlyPlanAsTwelveMonths(): void + { + $plan = $this->createPlan(120000, 'anual', PlanInterval::YEAR); + + $this->assertNotEmpty($plan->id); + $this->assertSame(PlanInterval::YEAR, $plan->interval); + $this->assertSame(1, $plan->intervalCount); + $this->assertSame(12, $plan->original->interval); + $this->assertSame('months', $plan->original->interval_type); + + $lido = new Plan(); + $lido->id = $plan->id; + $lido = $lido->get(self::GATEWAY); + + $this->assertSame(PlanInterval::YEAR, $lido->interval); + $this->assertSame(1, $lido->intervalCount); + } + + /** + * Deve criar, ler, suspender, reativar, cancelar e listar a assinatura. + * + * @return void + */ + public function testShouldRunTheSubscriptionLifecycle(): void + { + $nextBillingAt = now()->addMonth(); + $plan = $this->createPlan(10000, 'ciclo'); + $subscription = $this->createSubscription($plan, $nextBillingAt); + + $this->assertNotEmpty($subscription->id); + $this->assertSame($plan->identifier, $subscription->planId); + $this->assertSame('iugu', $subscription->gateway); + $this->assertSame( + $nextBillingAt->format('Y-m-d'), + $subscription->nextBillingAt->format('Y-m-d') + ); + $this->assertSame(SubscriptionStatus::ACTIVE, $subscription->status); + + $lida = MultiPayment::setGateway(self::GATEWAY)->getSubscription($subscription->id); + $this->assertSame($subscription->id, $lida->id); + $this->assertSame($subscription->planId, $lida->planId); + + $suspensa = $lida->suspend(self::GATEWAY); + $this->assertSame(SubscriptionStatus::SUSPENDED, $suspensa->status); + + $reativada = $suspensa->resume(self::GATEWAY); + $this->assertSame(SubscriptionStatus::ACTIVE, $reativada->status); + + $cancelada = $reativada->cancel(false, self::GATEWAY); + $this->assertSame(SubscriptionStatus::CANCELED, $cancelada->status); + $this->assertNotNull($cancelada->canceledAt); + $this->assertLessThan(5, abs(now()->diffInMinutes($cancelada->canceledAt))); + // a marca mp_canceled_at vive em custom_variables e vira o campo tipado; metadata + // não expõe as variáveis reservadas da lib + $this->assertArrayNotHasKey('mp_canceled_at', $cancelada->metadata ?? []); + + $relida = new Subscription(); + $relida->id = $subscription->id; + $relida = $relida->get(self::GATEWAY); + $this->assertSame(SubscriptionStatus::CANCELED, $relida->status); + + $descancelada = $relida->resume(self::GATEWAY); + $this->assertSame(SubscriptionStatus::ACTIVE, $descancelada->status); + $this->assertNull($descancelada->canceledAt); + $this->assertArrayNotHasKey('mp_canceled_at', $descancelada->metadata ?? []); + + $doCliente = MultiPayment::setGateway(self::GATEWAY) + ->listSubscriptions($subscription->customer->id); + $this->assertCount(1, $doCliente); + $this->assertSame($subscription->id, $doCliente[0]->id); + } + + /** + * O cancelamento ao fim do ciclo grava a intenção em `custom_variables` sem suspender, a + * leitura devolve `cancelAtPeriodEnd` e `resume()` desfaz o agendamento. + * + * @return void + */ + public function testShouldScheduleAndUndoACancellationAtPeriodEnd(): void + { + $plan = $this->createPlan(10000, 'agendado'); + $subscription = $this->createSubscription($plan, now()->addMonth()); + + $agendada = $subscription->cancel(true, self::GATEWAY); + $this->assertSame(SubscriptionStatus::ACTIVE, $agendada->status); + $this->assertTrue($agendada->cancelAtPeriodEnd); + $this->assertNull($agendada->canceledAt); + $this->assertArrayNotHasKey('mp_cancel_at_period_end', $agendada->metadata ?? []); + + $lida = MultiPayment::setGateway(self::GATEWAY)->getSubscription($subscription->id); + $this->assertTrue($lida->cancelAtPeriodEnd); + $this->assertSame(SubscriptionStatus::ACTIVE, $lida->status); + + $reativada = $lida->resume(self::GATEWAY); + $this->assertFalse($reativada->cancelAtPeriodEnd); + $this->assertSame(SubscriptionStatus::ACTIVE, $reativada->status); + } + + /** + * A validade de um desconto vira a variável `mp_discount__until` na Iugu, e a + * leitura a devolve em `validUntil`, fora de `metadata`. + * + * @return void + */ + public function testShouldStoreTheDiscountValidityInCustomVariables(): void + { + $plan = $this->createPlan(10000, 'validade'); + $customer = $this->createCustomer(self::GATEWAY, $this->customerWithoutAddress()); + $validUntil = now()->addMonths(2)->startOfDay(); + + $subscription = MultiPayment::setGateway(self::GATEWAY)->newSubscription() + ->setPlanId($plan->identifier) + ->setCustomerId($customer->id) + ->setAvailablePaymentMethods([PaymentMethod::PIX]) + ->setNextBillingAt(now()->addMonth()) + ->addAmountDiscount('Promo', 500, null, $validUntil) + ->create(); + $this->criados['subscriptions'][] = $subscription->id; + + $this->assertCount(1, $subscription->discounts); + $this->assertNotEmpty($subscription->discounts[0]->id); + $this->assertSame( + $validUntil->format('Y-m-d'), + $subscription->discounts[0]->validUntil->format('Y-m-d') + ); + + $lida = MultiPayment::setGateway(self::GATEWAY)->getSubscription($subscription->id); + $this->assertSame( + $validUntil->format('Y-m-d'), + $lida->discounts[0]->validUntil->format('Y-m-d') + ); + $this->assertArrayNotHasKey( + 'mp_discount_' . $lida->discounts[0]->id . '_until', + $lida->metadata ?? [] + ); + } + + /** + * Assinatura sem data de cobrança não volta com resume(): a Iugu responde sem erro e sem + * mudar o estado. + * + * @return void + */ + public function testShouldNotResumeASubscriptionWithoutABillingDate(): void + { + $plan = $this->createPlan(10000, 'semdata'); + $subscription = $this->createSubscription($plan); + + $this->assertNull($subscription->nextBillingAt); + + $suspensa = $subscription->suspend(self::GATEWAY); + $this->assertSame(SubscriptionStatus::SUSPENDED, $suspensa->status); + + $reativada = $suspensa->resume(self::GATEWAY); + $this->assertSame(SubscriptionStatus::SUSPENDED, $reativada->status); + } + + /** + * Itens e descontos são declarativos: a lista informada vira o estado da assinatura, e a + * lista que ficar em `null` é preservada. + * + * @return void + */ + public function testShouldReplaceItemsWithoutTouchingDiscounts(): void + { + $plan = $this->createPlan(10000, 'itens'); + $subscription = $this->createSubscription($plan, now()->addMonth()); + + $avulso = $this->item('Setup', 900, 1); + $avulso->recurring = false; + + $subscription->items = [$this->item('Consultas', 2500, 2), $avulso]; + $subscription->discounts = [$this->discount('Promo', 500)]; + $subscription->save(self::GATEWAY); + + $lida = new Subscription(); + $lida->id = $subscription->id; + $lida = $lida->get(self::GATEWAY); + + $this->assertCount(2, $lida->items); + $porDescricao = []; + foreach ($lida->items as $item) { + $porDescricao[$item->description] = $item; + } + $this->assertSame(2500, $porDescricao['Consultas']->amount); + $this->assertSame(2, $porDescricao['Consultas']->quantity); + $this->assertTrue($porDescricao['Consultas']->recurring); + // o encoder do SDK transforma false em string vazia, por isso o gateway manda inteiro + $this->assertFalse($porDescricao['Setup']->recurring); + $this->assertCount(1, $lida->discounts); + $this->assertSame(500, $lida->discounts[0]->amountOff); + + $idDoDesconto = $lida->discounts[0]->id; + + $lida->items = [$this->item('Monitoramentos', 700, 1)]; + $lida->discounts = null; + $lida->save(self::GATEWAY); + + $depois = new Subscription(); + $depois->id = $subscription->id; + $depois = $depois->get(self::GATEWAY); + + $this->assertCount(1, $depois->items); + $this->assertSame('Monitoramentos', $depois->items[0]->description); + $this->assertCount(1, $depois->discounts); + $this->assertSame($idDoDesconto, $depois->discounts[0]->id); + $this->assertSame(500, $depois->discounts[0]->amountOff); + } + + /** + * Deve simular a troca de plano e aplicá-la sem gerar cobrança. + * + * @return void + */ + public function testShouldChangePlanAndPreviewIt(): void + { + $plan = $this->createPlan(10000, 'origem'); + $planoNovo = $this->createPlan(30000, 'destino'); + $subscription = $this->createSubscription($plan, now()->addMonth()); + + $preview = $subscription->previewPlanChange($planoNovo->identifier, self::GATEWAY); + $this->assertSame('iugu', $preview->gateway); + $this->assertSame(30000, $preview->amount); + // a Iugu não devolve linhas; a lib monta a de cobrança do plano novo a partir de cost + $this->assertCount(1, $preview->items); + $this->assertSame(30000, $preview->items[0]->price); + $this->assertSame("Plano {$planoNovo->identifier}", $preview->items[0]->description); + // assinatura paga por Pix: a troca com cobrança só vale depois do pagamento + $this->assertFalse($preview->appliesImmediately); + $this->assertSame($planoNovo->identifier, $preview->original->new_plan); + $this->assertSame($plan->identifier, $preview->original->old_plan); + + $trocada = $subscription->changePlan($planoNovo->identifier, ProrationBehavior::NONE, self::GATEWAY); + $this->assertSame($planoNovo->identifier, $trocada->planId); + $this->assertSame(30000, $trocada->amount); + } + + /** + * Deve trocar o plano pelo endpoint change_plan, que gera na hora uma fatura pendente, + * resumida e com vencimento anterior ao próximo ciclo. + * + * @return void + */ + public function testShouldChangePlanGeneratingTheCharge(): void + { + $proximaCobranca = now()->addMonth(); + $plan = $this->createPlan(10000, 'cobra-origem'); + $planoNovo = $this->createPlan(30000, 'cobra-destino'); + $subscription = $this->createSubscription($plan, $proximaCobranca); + + // guarda: sem isto a asserção de latestInvoice abaixo passaria com a da leitura anterior + $this->assertNull($subscription->latestInvoice); + + $trocada = $subscription->changePlan($planoNovo->identifier, ProrationBehavior::CHARGE_DIFFERENCE, self::GATEWAY); + + $this->assertSame($planoNovo->identifier, $trocada->planId); + $this->assertSame(30000, $trocada->amount); + $this->assertSame(SubscriptionStatus::ACTIVE, $trocada->status); + + $this->assertNotNull($trocada->latestInvoice); + $this->faturasCriadas[] = $trocada->latestInvoice->id; + + $this->assertSame(InvoiceStatus::PENDING, $trocada->latestInvoice->status); + // a cobrança é imediata, e não a do próximo ciclo; comparar contra now() traria o fuso + // do gateway para dentro do teste + $this->assertTrue($trocada->latestInvoice->dueDate->lessThan($proximaCobranca)); + // o resumo de recent_invoices traz o valor formatado, sem centavos, e sem secure_url + $this->assertSame('R$ 300,00', $trocada->latestInvoice->original->total); + $this->assertNull($trocada->latestInvoice->amount); + $this->assertNull($trocada->latestInvoice->url); + } + + private function item(string $description, int $amount, int $quantity): SubscriptionItem + { + $item = new SubscriptionItem(); + $item->description = $description; + $item->amount = $amount; + $item->quantity = $quantity; + + return $item; + } + + private function discount(string $description, int $amountOff): SubscriptionDiscount + { + $discount = new SubscriptionDiscount(); + $discount->description = $description; + $discount->amountOff = $amountOff; + + return $discount; + } + + /** + * Assinatura com cartão e trial em dias: o cartão informado sem id é salvo no cliente como + * padrão, `payable_with` fica só com cartão, `paymentMethod` volta preenchido e o trial + * termina no dia calculado pela lib. + * + * @return void + */ + public function testShouldCreateASubscriptionWithACardAndTrialDays(): void + { + $plan = $this->createPlan(10000, 'cartao'); + $customer = $this->createCustomer(self::GATEWAY, $this->customerWithoutAddress()); + $data = $this->creditCard(); + + $card = new CreditCard(); + $card->number = $data['number']; + $card->month = $data['month']; + $card->year = $data['year']; + $card->cvv = $data['cvv']; + $card->firstName = $data['firstName']; + $card->lastName = $data['lastName']; + + $subscription = MultiPayment::setGateway(self::GATEWAY)->newSubscription() + ->setPlanId($plan->identifier) + ->setCustomerId($customer->id) + ->setCreditCard($card) + ->setTrialDays(7) + ->withIdempotencyKey('multipayment-teste-cartao-' . now()->format('YmdHisu')) + ->create(); + $this->criados['subscriptions'][] = $subscription->id; + + $this->assertNotEmpty($subscription->creditCard->id); + $this->assertSame(PaymentMethod::CREDIT_CARD, $subscription->paymentMethod); + $this->assertSame([PaymentMethod::CREDIT_CARD], $subscription->availablePaymentMethods); + $this->assertTrue($subscription->status->isActive()); + $this->assertSame(now()->addDays(7)->format('Y-m-d'), $subscription->trialEndsAt->format('Y-m-d')); + $this->assertSame(now()->addDays(7)->format('Y-m-d'), $subscription->nextBillingAt->format('Y-m-d')); + // com only_charge_on_due_date a Iugu não cobra o cartão na criação: a fatura do primeiro + // ciclo, quando já existe, fica em aberto + if (!is_null($subscription->latestInvoice)) { + $this->assertFalse($subscription->latestInvoice->status->isSettled()); + } + + $relido = MultiPayment::setGateway(self::GATEWAY)->getCustomer($customer->id); + $this->assertSame($subscription->creditCard->id, $relido->defaultCard->id); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 9d9c1b1..60179d2 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -8,22 +8,34 @@ class TestCase extends \Orchestra\Testbench\TestCase { - public function __construct(?string $name = null, array $data = [], $dataName = '') - { - parent::__construct($name, $data, $dataName); - \Iugu::setLogErrors(false); - } - protected function setUp(): void { parent::setUp(); + \Iugu::setLogErrors(false); - if (in_array('iugu-sandbox-limitation', $this->getGroups(), true)) { + if (in_array('iugu-sandbox-limitation', $this->groups(), true)) { return; } - // pausa para evitar problemas com o Iugu - sleep(12); + // Pix Automático aguarda liberação na conta Stripe; os testes do grupo só rodam com a + // flag ligada no ambiente (e dispensam o sleep, porque não tocam na Iugu) + if (in_array('pix-automatico-stripe', $this->groups(), true)) { + if (!filter_var(env('STRIPE_PIX_AUTOMATICO_ENABLED'), FILTER_VALIDATE_BOOL)) { + $this->markTestSkipped( + 'A conta Stripe ainda não tem Pix Automático liberado;' + . ' defina STRIPE_PIX_AUTOMATICO_ENABLED=true para rodar.' + ); + } + + return; + } + + // pausa para respeitar o rate limit da sandbox da Iugu — a da Stripe não tem esse limite; + // o gateway do teste vem do dataProvider (primeiro argumento, posicional ou chave 'gateway') + $providedData = $this->providedData(); + if (($providedData[0] ?? $providedData['gateway'] ?? null) !== 'stripe') { + sleep(12); + } } protected function getPackageProviders($app): array diff --git a/tests/Unit/AutomaticPixTest.php b/tests/Unit/AutomaticPixTest.php index a199adc..5ca3352 100644 --- a/tests/Unit/AutomaticPixTest.php +++ b/tests/Unit/AutomaticPixTest.php @@ -11,6 +11,7 @@ use Potelo\MultiPayment\Models\AutomaticPixCharge; use Potelo\MultiPayment\Models\AutomaticPixCancellation; use Potelo\MultiPayment\Contracts\GatewayContract; +use Potelo\MultiPayment\Enums\InvoiceStatus; class AutomaticPixTest extends TestCase { @@ -120,7 +121,7 @@ public function testCancelsScheduledAutomaticPixPaymentThroughScalarContract(): ->once() ->with(Mockery::on(fn (AutomaticPixCharge $charge) => $charge->id === 'payment-id' && $charge->endToEndId === 'end-to-end-id' - )) + ), null) ->andReturn($cancellation); $result = (new MultiPayment($gateway)) @@ -135,7 +136,7 @@ public function testCancelsAutomaticPixRecurrenceThroughModelContract(): void $gateway = Mockery::mock(GatewayContract::class); $gateway->shouldReceive('cancelAutomaticPixRecurrence') ->once() - ->with(Mockery::on(fn (AutomaticPix $automaticPix) => $automaticPix->id === 'recurrence-id')) + ->with(Mockery::on(fn (AutomaticPix $automaticPix) => $automaticPix->id === 'recurrence-id'), null) ->andReturn($cancellation); $result = (new MultiPayment($gateway))->cancelAutomaticPixRecurrence('recurrence-id'); @@ -151,7 +152,7 @@ public function testReschedulesAutomaticPixPaymentThroughInvoiceContract(): void $gateway = Mockery::mock(GatewayContract::class); $gateway->shouldReceive('rescheduleAutomaticPixPayment') ->once() - ->with(Mockery::on(fn (Invoice $model) => $model->id === 'invoice-id')) + ->with(Mockery::on(fn (Invoice $model) => $model->id === 'invoice-id'), null) ->andReturn($invoice); $result = (new MultiPayment($gateway))->rescheduleAutomaticPixPayment('invoice-id'); @@ -193,12 +194,12 @@ public function testCancelsInvoiceThroughGateway(): void { $cancelledInvoice = new Invoice(); $cancelledInvoice->id = 'invoice-id'; - $cancelledInvoice->status = Invoice::STATUS_CANCELED; + $cancelledInvoice->status = InvoiceStatus::CANCELED; $gateway = Mockery::mock(GatewayContract::class); $gateway->shouldReceive('cancelInvoice') ->once() - ->with(Mockery::on(fn (Invoice $invoice) => $invoice->id === 'invoice-id')) + ->with(Mockery::on(fn (Invoice $invoice) => $invoice->id === 'invoice-id'), null) ->andReturn($cancelledInvoice); $result = (new MultiPayment($gateway))->cancelInvoice('invoice-id'); diff --git a/tests/Unit/Capabilities/CapabilityRestrictionTest.php b/tests/Unit/Capabilities/CapabilityRestrictionTest.php new file mode 100644 index 0000000..01fe49f --- /dev/null +++ b/tests/Unit/Capabilities/CapabilityRestrictionTest.php @@ -0,0 +1,50 @@ +assertSame('Só numa parte dos casos.', $restriction->description); + $this->assertNull($restriction->allowedPaymentMethods); + $this->assertNull($restriction->allowedBrands); + $this->assertNull($restriction->maxInstallments); + } + + public function testAllowsPaymentMethodFollowsTheListAndIsTrueWithoutOne(): void + { + $byMethod = new CapabilityRestriction('Só Pix.', allowedPaymentMethods: [PaymentMethod::PIX]); + $unrestricted = new CapabilityRestriction('Outra restrição.', maxInstallments: 12); + + $this->assertTrue($byMethod->allowsPaymentMethod(PaymentMethod::PIX)); + $this->assertFalse($byMethod->allowsPaymentMethod(PaymentMethod::CREDIT_CARD)); + $this->assertTrue($unrestricted->allowsPaymentMethod(PaymentMethod::CREDIT_CARD)); + } + + public function testAllowsBrandIgnoresCaseAndIsTrueWithoutAList(): void + { + $byBrand = new CapabilityRestriction('Só Visa e Mastercard.', allowedBrands: ['visa', 'mastercard']); + $unrestricted = new CapabilityRestriction('Outra restrição.'); + + $this->assertTrue($byBrand->allowsBrand('visa')); + $this->assertTrue($byBrand->allowsBrand('Mastercard')); + $this->assertFalse($byBrand->allowsBrand('elo')); + $this->assertTrue($unrestricted->allowsBrand('elo')); + } + + public function testIsReadOnly(): void + { + $restriction = new CapabilityRestriction('Só numa parte dos casos.', maxInstallments: 12); + + $this->expectException(\Error::class); + + $restriction->maxInstallments = 6; + } +} diff --git a/tests/Unit/CapabilityGuardsTest.php b/tests/Unit/CapabilityGuardsTest.php new file mode 100644 index 0000000..e4f15fe --- /dev/null +++ b/tests/Unit/CapabilityGuardsTest.php @@ -0,0 +1,481 @@ +instance('config', new Repository([ + 'multi-payment' => [ + 'default' => 'iugu', + 'gateways' => [ + 'iugu' => ['api_key' => 'iugu-key', 'class' => IuguGateway::class], + 'stripe' => ['api_key' => 'sk_test_fake', 'class' => StripeGateway::class], + ], + ], + ])); + Facade::setFacadeApplication($app); + + $this->stripeHttp = RecordingStripeHttpClient::withResponses([]); + } + + protected function tearDown(): void + { + ApiRequestor::setHttpClient(null); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testMultiMethodSubscriptionOnStripeFailsBeforeCreatingTheCustomer(): void + { + $customer = new Customer(); + $customer->name = 'Fulano'; + $customer->email = 'fulano@exemplo.com'; + + $builder = (new MultiPayment('stripe'))->newSubscription() + ->setPlanId('plano_mensal') + ->setCustomer($customer) + ->setAvailablePaymentMethods([PaymentMethod::PIX, PaymentMethod::CREDIT_CARD]); + + $this->assertNotImplemented(Capability::MULTIPLE_PAYMENT_METHODS, fn () => $builder->create()); + } + + /** + * No update, o gateway gravado no model prevalece sobre o informado, como em `Model::save()`: + * a recusa vem do stripe gravado no model, com o motivo dele, e a instância da Iugu não é + * tocada. + */ + public function testUpdateUsesTheGatewayStoredInTheModel(): void + { + $api = new QueuedIuguApiRequest([]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->gateway = 'stripe'; + $subscription->availablePaymentMethods = [PaymentMethod::PIX, PaymentMethod::CREDIT_CARD]; + + $this->assertNotImplemented(Capability::MULTIPLE_PAYMENT_METHODS, fn () => $subscription->save(new IuguGateway($api))); + $this->assertCount(0, $api->calls); + } + + /** + * `Model::delete()` confere a capability antes de procurar o método de despacho: sem a + * guarda, o despacho falharia com `ConfigurationException` por não existir + * `deleteSubscription` no driver. + */ + public function testDeleteChecksTheCapabilityBeforeTheDispatchMethod(): void + { + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->availablePaymentMethods = [PaymentMethod::PIX, PaymentMethod::CREDIT_CARD]; + + $this->assertNotImplemented(Capability::MULTIPLE_PAYMENT_METHODS, fn () => $subscription->delete('stripe')); + } + + public function testMultiMethodChargeOnStripeFailsBeforeCreatingTheCustomer(): void + { + $multiPayment = new MultiPayment('stripe'); + + $this->assertNotImplemented(Capability::MULTIPLE_PAYMENT_METHODS, fn () => $multiPayment->charge([ + 'items' => [['description' => 'Mensalidade', 'price' => 10000, 'quantity' => 1]], + 'available_payment_methods' => [PaymentMethod::BANK_SLIP->value, PaymentMethod::PIX->value], + 'customer' => ['name' => 'Fulano', 'email' => 'fulano@exemplo.com', 'tax_document' => '20176996915'], + ])); + } + + public function testAutomaticPixInvoiceOnStripeFailsBeforeCreatingTheCustomer(): void + { + $automaticPix = new AutomaticPix(); + $automaticPix->id = 'recurrence-id'; + + $builder = (new MultiPayment('stripe'))->newInvoice() + ->addCustomer('Fulano', 'fulano@exemplo.com', '20176996915') + ->addItem('Mensalidade', 10000, 1) + ->addAvailablePaymentMethod(PaymentMethod::PIX) + ->setAutomaticPix($automaticPix); + + $this->assertNotImplemented(Capability::AUTOMATIC_PIX, fn () => $builder->create()); + } + + /** + * A cobrança sobre uma recorrência existente (`automaticPixCharge` sem `automaticPix`) + * recebe a mesma recusa da recorrência nova, antes de criar o cliente. + */ + public function testAutomaticPixChargeInvoiceOnStripeFailsBeforeCreatingTheCustomer(): void + { + $builder = (new MultiPayment('stripe'))->newInvoice() + ->addCustomer('Fulano', 'fulano@exemplo.com', '20176996915') + ->addItem('Mensalidade', 10000, 1) + ->addAvailablePaymentMethod(PaymentMethod::PIX) + ->addAutomaticPixCharge('Mensalidade do plano'); + + $this->assertNotImplemented(Capability::AUTOMATIC_PIX, fn () => $builder->create()); + } + + public function testRawCardInvoiceOnStripeFailsBeforeCreatingTheCustomer(): void + { + $builder = (new MultiPayment('stripe'))->newInvoice() + ->addCustomer('Fulano', 'fulano@exemplo.com', '20176996915') + ->addItem('Mensalidade', 10000, 1) + ->addAvailablePaymentMethod(PaymentMethod::CREDIT_CARD) + ->addCreditCard('4111111111111111', '12', '2030', '123', 'Fulano', 'Silva'); + + $this->assertUnsupported( + Capability::RAW_CARD_DATA, + UnsupportedOperationException::REASON_GATEWAY_LIMITATION, + 'stripe', + fn () => $builder->create() + ); + $this->assertSame([], $this->stripeHttp->calls); + } + + /** + * A assinatura com Pix Automático exige `MANAGES_RECURRENCE`, então na Iugu (onde a + * aplicação é o motor de recorrência) ela é recusada antes de criar o cliente. + */ + public function testAutomaticPixSubscriptionOnIuguFailsBeforeCreatingTheCustomer(): void + { + $api = new QueuedIuguApiRequest([]); + $customer = new Customer(); + $customer->name = 'Fulano'; + $customer->email = 'fulano@exemplo.com'; + + $builder = (new MultiPayment(new IuguGateway($api)))->newSubscription() + ->setPlanId('plano_mensal') + ->setCustomer($customer) + ->setPaymentMethod(PaymentMethod::AUTOMATIC_PIX); + + $this->assertUnsupported( + Capability::MANAGES_RECURRENCE, + UnsupportedOperationException::REASON_GATEWAY_LIMITATION, + 'iugu', + fn () => $builder->create() + ); + $this->assertCount(0, $api->calls); + } + + public function testPercentDiscountSubscriptionOnIuguFailsBeforeCreatingTheCustomer(): void + { + $api = new QueuedIuguApiRequest([]); + $customer = new Customer(); + $customer->name = 'Fulano'; + $customer->email = 'fulano@exemplo.com'; + + $builder = (new MultiPayment(new IuguGateway($api)))->newSubscription() + ->setPlanId('plano_mensal') + ->setCustomer($customer) + ->addPercentDiscount('Anual', 10.0); + + $this->assertUnsupported( + Capability::PERCENT_DISCOUNT, + UnsupportedOperationException::REASON_GATEWAY_LIMITATION, + 'iugu', + fn () => $builder->create() + ); + $this->assertCount(0, $api->calls); + } + + public function testMultiMethodInvoiceOnStripeFailsBeforeCreatingTheCustomer(): void + { + $builder = (new MultiPayment('stripe'))->newInvoice() + ->addCustomer('Fulano', 'fulano@exemplo.com', '20176996915') + ->addItem('Mensalidade', 10000, 1) + ->setAvailablePaymentMethods([PaymentMethod::PIX, PaymentMethod::CREDIT_CARD]); + + $this->assertNotImplemented(Capability::MULTIPLE_PAYMENT_METHODS, fn () => $builder->create()); + } + + /** + * A capability de multi-método é recusada antes da de cartão com dados crus, na ordem em + * que `requiredCapabilities()` as declara. + */ + public function testTheFirstMissingCapabilityIsTheOneDeclaredFirst(): void + { + $builder = (new MultiPayment('stripe'))->newInvoice() + ->addCustomer('Fulano', 'fulano@exemplo.com', '20176996915') + ->addItem('Mensalidade', 10000, 1) + ->setAvailablePaymentMethods([PaymentMethod::PIX, PaymentMethod::CREDIT_CARD]) + ->addCreditCard('4111111111111111', '12', '2030', '123', 'Fulano', 'Silva'); + + $this->assertNotImplemented(Capability::MULTIPLE_PAYMENT_METHODS, fn () => $builder->create()); + } + + /** + * `requiredCapabilities()` recusa método fora de `PaymentMethod::selectable()`, como + * `AUTOMATIC_PIX` em `availablePaymentMethods` ou em `paymentMethod`, com + * `ModelAttributeValidationException`. + */ + public function testInvoiceRequiredCapabilitiesRejectNonSelectableMethods(): void + { + $invoice = new Invoice(); + $invoice->availablePaymentMethods = [PaymentMethod::AUTOMATIC_PIX]; + + try { + $invoice->requiredCapabilities(); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('availablePaymentMethods must be one of', $e->getMessage()); + } + + $byMethod = new Invoice(); + $byMethod->paymentMethod = PaymentMethod::AUTOMATIC_PIX; + + try { + $byMethod->requiredCapabilities(); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('paymentMethod must be one of', $e->getMessage()); + } + } + + /** + * Com a lista vazia, `paymentMethod` decide a capability exigida, e a fatura multi-método + * no Stripe falha pelo array de `charge()` antes de criar o cliente. + */ + public function testInvoiceRequiredCapabilitiesDeriveFromPaymentMethodWhenTheListIsEmpty(): void + { + $invoice = new Invoice(); + $invoice->paymentMethod = PaymentMethod::BANK_SLIP; + $this->assertSame([Capability::BANK_SLIP], $invoice->requiredCapabilities()); + + // a lista tem precedência sobre o método, que precisa constar dela + $invoice->availablePaymentMethods = [PaymentMethod::PIX, PaymentMethod::BANK_SLIP]; + $this->assertSame( + [Capability::PIX, Capability::BANK_SLIP, Capability::MULTIPLE_PAYMENT_METHODS], + $invoice->requiredCapabilities() + ); + + $multiPayment = new MultiPayment('stripe'); + $this->assertNotImplemented(Capability::MULTIPLE_PAYMENT_METHODS, fn () => $multiPayment->charge([ + 'items' => [['description' => 'Mensalidade', 'price' => 10000, 'quantity' => 1]], + 'available_payment_methods' => ['pix', 'bank_slip'], + 'customer' => ['name' => 'Fulano', 'email' => 'fulano@exemplo.com'], + ])); + $this->assertSame([], $this->stripeHttp->calls); + } + + public function testInvoiceRequiredCapabilitiesDeriveFromTheAttributes(): void + { + $invoice = new Invoice(); + $invoice->availablePaymentMethods = [PaymentMethod::PIX, PaymentMethod::BANK_SLIP]; + $invoice->automaticPix = new AutomaticPix(); + + $this->assertSame( + [Capability::PIX, Capability::BANK_SLIP, Capability::MULTIPLE_PAYMENT_METHODS, Capability::AUTOMATIC_PIX], + $invoice->requiredCapabilities() + ); + + $cardOnly = new Invoice(); + $cardOnly->creditCard = new \Potelo\MultiPayment\Models\CreditCard(); + $cardOnly->creditCard->id = 'pm_1'; + $this->assertSame([Capability::CREDIT_CARD], $cardOnly->requiredCapabilities()); + + $rawCard = new Invoice(); + $rawCard->creditCard = new \Potelo\MultiPayment\Models\CreditCard(); + $rawCard->creditCard->number = '4111111111111111'; + $this->assertSame([Capability::CREDIT_CARD, Capability::RAW_CARD_DATA], $rawCard->requiredCapabilities()); + + $tokenized = new Invoice(); + $tokenized->creditCard = new \Potelo\MultiPayment\Models\CreditCard(); + $tokenized->creditCard->token = 'pm_tok'; + $this->assertSame([Capability::CREDIT_CARD], $tokenized->requiredCapabilities()); + + $subscription = new Subscription(); + $this->assertSame([Capability::SUBSCRIPTIONS], $subscription->requiredCapabilities()); + $discount = new \Potelo\MultiPayment\Models\SubscriptionDiscount(); + $discount->percentOff = 10.0; + $subscription->discounts = [$discount]; + $this->assertSame([Capability::SUBSCRIPTIONS, Capability::PERCENT_DISCOUNT], $subscription->requiredCapabilities()); + + $withTerm = new Subscription(); + $limited = new \Potelo\MultiPayment\Models\SubscriptionDiscount(); + $limited->amountOff = 500; + $limited->cycles = 3; + $dated = new \Potelo\MultiPayment\Models\SubscriptionDiscount(); + $dated->amountOff = 500; + $dated->validUntil = Carbon::parse('2027-01-01'); + $withTerm->discounts = [$limited, $dated]; + $this->assertSame([Capability::SUBSCRIPTIONS, Capability::COUPONS], $withTerm->requiredCapabilities()); + + $existing = new Invoice(); + $existing->id = 'inv_1'; + $existing->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; + $this->assertSame([], $existing->requiredCapabilities()); + } + + public function testIuguInvoiceWithEveryMethodPassesTheGuardAndCreatesTheCustomerFirst(): void + { + $api = (new QueuedIuguApiRequest([ + (object) [ + 'id' => 'cus_novo', + 'name' => 'Fulano', + 'email' => 'fulano@exemplo.com', + 'cpf_cnpj' => '20176996915', + 'phone' => null, + 'phone_prefix' => null, + 'created_at' => '2026-09-02T09:00:00-03:00', + 'custom_variables' => [], + 'default_payment_method_id' => null, + 'errors' => null, + ], + (object) [ + 'id' => 'inv_1', + 'status' => 'pending', + 'total_cents' => 10000, + 'paid_at' => null, + 'secure_url' => 'https://faturas.iugu.com/inv_1', + 'taxes_paid_cents' => null, + 'created_at_iso' => '2026-09-02T09:00:00-03:00', + 'paid_cents' => 0, + 'refunded_cents' => 0, + 'due_date' => '2026-10-01', + 'payment_method' => null, + 'payable_with' => ['credit_card', 'bank_slip', 'pix'], + 'customer_id' => 'cus_novo', + 'customer_name' => 'Fulano', + 'email' => 'fulano@exemplo.com', + 'payer_phone' => null, + 'payer_phone_prefix' => null, + 'items' => [(object) ['description' => 'Mensalidade', 'price_cents' => 10000, 'quantity' => 1]], + 'payer_address_zip_code' => null, + 'bank_slip' => null, + 'pix' => null, + 'automatic_pix' => null, + 'credit_card_transaction' => null, + 'variables' => [], + 'errors' => null, + ], + ]))->installAsSdkRequester(); + + try { + $invoice = (new MultiPayment('iugu'))->newInvoice() + ->addCustomer('Fulano', 'fulano@exemplo.com', '20176996915') + ->addItem('Mensalidade', 10000, 1) + ->setAvailablePaymentMethods([PaymentMethod::CREDIT_CARD, PaymentMethod::BANK_SLIP, PaymentMethod::PIX]) + ->create(); + } finally { + QueuedIuguApiRequest::restoreSdkRequester(); + } + + $this->assertCount(2, $api->calls); + $this->assertStringEndsWith('/customers', $api->calls[0]['url']); + $this->assertStringEndsWith('/invoices', $api->calls[1]['url']); + $this->assertSame('cus_novo', $invoice->customer->id); + $this->assertSame('inv_1', $invoice->id); + } + + /** + * A Iugu não autentica o portador ao salvar o cartão: concluir um setup é limitação do + * gateway, recusada antes de qualquer requisição. + */ + public function testConfirmCreditCardSetupOnIuguIsAGatewayLimitation(): void + { + $this->assertUnsupported( + Capability::CARD_SETUP_AUTHENTICATION, + UnsupportedOperationException::REASON_GATEWAY_LIMITATION, + 'iugu', + fn () => (new MultiPayment('iugu'))->confirmCreditCardSetup('seti_1') + ); + $this->assertFalse((new MultiPayment('iugu'))->supports(Capability::CARD_SETUP_AUTHENTICATION)); + $this->assertTrue((new MultiPayment('stripe'))->supports(Capability::CARD_SETUP_AUTHENTICATION)); + } + + public function testFacadeExposesTheDeclarations(): void + { + $multiPayment = new MultiPayment('stripe'); + + $iugu = new IuguGateway(new QueuedIuguApiRequest([])); + $this->assertInstanceOf(StripeGateway::class, $multiPayment->gateway()); + $this->assertInstanceOf(IuguGateway::class, $multiPayment->gateway('iugu')); + $this->assertSame($iugu, $multiPayment->gateway($iugu)); + $this->assertTrue($multiPayment->supports(Capability::PIX)); + $this->assertTrue($multiPayment->supports(Capability::BANK_SLIP)); + $this->assertTrue($multiPayment->supports(Capability::AUTOMATIC_PIX)); + $this->assertFalse($multiPayment->supports(Capability::DELAYED_CAPTURE)); + $this->assertTrue($multiPayment->supports(Capability::AUTOMATIC_PIX, 'iugu')); + $this->assertTrue($multiPayment->gateway('iugu')->supports(Capability::INSTALLMENTS)); + $this->assertSame((new StripeGateway())->capabilities(), $multiPayment->capabilities()); + $this->assertSame((new StripeGateway())->notYetImplemented(), $multiPayment->notYetImplemented('stripe')); + $this->assertContains(Capability::SUBSCRIPTIONS, $multiPayment->capabilities('iugu')); + $this->assertSame([], $this->stripeHttp->calls); + } + + public function testFacadeDocblockAnnotatesTheCapabilityMethods(): void + { + $docblock = (new \ReflectionClass(\Potelo\MultiPayment\Facades\MultiPayment::class))->getDocComment(); + + foreach (['gateway(', 'supports(', 'capabilities(', 'notYetImplemented(', 'confirmCreditCardSetup('] as $method) { + $this->assertMatchesRegularExpression('/@method static .*' . preg_quote($method, '/') . '/', $docblock, $method); + } + } + + public function testLaravelFacadeResolvesTheCapabilityMethods(): void + { + Facade::getFacadeApplication()->bind('multiPayment', fn () => new MultiPayment('stripe')); + + $this->assertTrue(\Potelo\MultiPayment\Facades\MultiPayment::supports(Capability::PIX)); + $this->assertFalse(\Potelo\MultiPayment\Facades\MultiPayment::supports(Capability::DELAYED_CAPTURE)); + $this->assertContains(Capability::SUBSCRIPTIONS, \Potelo\MultiPayment\Facades\MultiPayment::capabilities('iugu')); + $this->assertInstanceOf(StripeGateway::class, \Potelo\MultiPayment\Facades\MultiPayment::gateway()); + } + + /** + * Executa a operação esperando `UnsupportedOperationException` com `not_implemented` para + * a capability informada, e afirma que nenhuma requisição saiu para a Stripe. + */ + private function assertNotImplemented(Capability $capability, callable $operation): void + { + $this->assertUnsupported($capability, UnsupportedOperationException::REASON_NOT_IMPLEMENTED, 'stripe', $operation); + $this->assertSame([], $this->stripeHttp->calls, 'a guarda deixou uma requisição sair'); + } + + /** + * Executa a operação esperando `UnsupportedOperationException` com a capability, o motivo + * e o gateway informados. + */ + private function assertUnsupported(Capability $capability, string $reason, string $gateway, callable $operation): void + { + try { + $operation(); + $this->fail("Esperava UnsupportedOperationException para {$capability->name}"); + } catch (UnsupportedOperationException $e) { + $this->assertSame($capability, $e->capability); + $this->assertSame($gateway, $e->gateway); + $this->assertSame($reason, $e->reason); + } + } +} diff --git a/tests/Unit/Console/SyncSubscriptionsCommandTest.php b/tests/Unit/Console/SyncSubscriptionsCommandTest.php new file mode 100644 index 0000000..ad7137a --- /dev/null +++ b/tests/Unit/Console/SyncSubscriptionsCommandTest.php @@ -0,0 +1,177 @@ +instance('config', new Repository([ + 'multi-payment' => [ + 'default' => 'iugu', + 'gateways' => [ + 'iugu' => ['api_key' => 'test-api-key', 'class' => IuguGateway::class], + 'stripe' => ['api_key' => 'sk_test_fake', 'class' => StripeGateway::class], + ], + ], + ])); + $app->instance('log', $this->logger = new RecordingLogger()); + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + QueuedIuguApiRequest::restoreSdkRequester(); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + /** + * Executa o comando com o container mínimo dos testes unitários e devolve a saída. + * + * @param array $input + * @return string + */ + private function runCommand(array $input): string + { + $command = new SyncSubscriptionsCommand(); + $command->setLaravel(Facade::getFacadeApplication()); + $output = new BufferedOutput(); + + $this->assertSame(0, $command->run(new ArrayInput($input), $output)); + + return $output->fetch(); + } + + private static function listResponse(array $items): object + { + return (object) ['totalItems' => count($items), 'items' => $items]; + } + + private static function expiredDiscountSubscription(): object + { + return (object) [ + 'id' => 'sub_expirado', + 'plan_identifier' => 'plano_mensal', + 'expires_at' => '2026-10-01', + 'active' => true, + 'suspended' => false, + 'subitems' => [(object) ['id' => 'si_d1', 'description' => 'Promo', 'price_cents' => -500, 'quantity' => 1, 'recurrent' => true]], + 'custom_variables' => [(object) ['name' => 'mp_discount_si_d1_until', 'value' => '2026-09-03']], + ]; + } + + public function testTheCommandAppliesTheIuguEmulationsAndLogsEachAction(): void + { + $api = (new QueuedIuguApiRequest([ + self::listResponse([self::expiredDiscountSubscription()]), + (object) ['id' => 'sub_expirado'], + ]))->installAsSdkRequester(); + + $output = $this->runCommand(['--gateway' => 'iugu']); + + $this->assertStringContainsString('assinatura sub_expirado', $output); + $this->assertStringContainsString('desconto si_d1 vencido em 2026-09-03 removido', $output); + $this->assertCount(2, $api->calls); + $this->assertSame('PUT', $api->calls[1]['method']); + + $logged = array_filter( + $this->logger->records, + static fn (array $record) => str_contains($record['message'], 'multipayment:sync-subscriptions') + ); + $this->assertCount(1, $logged); + $record = array_values($logged)[0]; + $this->assertSame('info', $record['level']); + $this->assertSame('remove_discount', $record['context']['action']); + $this->assertSame('sub_expirado', $record['context']['subscription']); + } + + public function testDryRunReportsWithoutWriting(): void + { + $api = (new QueuedIuguApiRequest([ + self::listResponse([self::expiredDiscountSubscription()]), + ]))->installAsSdkRequester(); + + $output = $this->runCommand(['--gateway' => 'iugu', '--dry-run' => true]); + + $this->assertStringContainsString('[dry-run]', $output); + $this->assertStringContainsString('desconto si_d1 vencido', $output); + $this->assertCount(1, $api->calls); + $this->assertSame('GET', $api->calls[0]['method']); + } + + public function testAGatewayThatManagesTheFeaturesItselfIsSkippedWithAMessage(): void + { + $output = $this->runCommand(['--gateway' => 'stripe']); + + $this->assertStringContainsString('[stripe] o gateway gerencia cupom e cancelamento ao fim do ciclo', $output); + } + + /** + * Sem `--gateway`, o comando percorre todos os gateways configurados: sincroniza a Iugu e + * pula o Stripe com a mensagem. + */ + public function testWithoutTheOptionEveryConfiguredGatewayIsVisited(): void + { + $api = (new QueuedIuguApiRequest([ + self::listResponse([]), + ]))->installAsSdkRequester(); + + $output = $this->runCommand([]); + + $this->assertStringContainsString('[iugu] nada a aplicar.', $output); + $this->assertStringContainsString('[stripe] o gateway gerencia', $output); + $this->assertCount(1, $api->calls); + } + + /** + * A configuração padrão do pacote registra os dois gateways mesmo quando a aplicação só + * usa um; o gateway sem `api_key` é pulado na varredura sem `--gateway`. + */ + public function testAGatewayWithoutAnApiKeyIsSkippedInTheSweep(): void + { + Facade::getFacadeApplication()['config']->set('multi-payment.gateways.iugu.api_key', null); + $api = (new QueuedIuguApiRequest([]))->installAsSdkRequester(); + + $output = $this->runCommand([]); + + $this->assertStringContainsString('[iugu] sem api_key configurada; gateway pulado.', $output); + $this->assertStringContainsString('[stripe] o gateway gerencia', $output); + $this->assertCount(0, $api->calls); + } +} diff --git a/tests/Unit/Enums/CapabilityTest.php b/tests/Unit/Enums/CapabilityTest.php new file mode 100644 index 0000000..9b9b6a4 --- /dev/null +++ b/tests/Unit/Enums/CapabilityTest.php @@ -0,0 +1,73 @@ +description(); + + $this->assertNotSame('', $description, "{$capability->name} sem docblock"); + $this->assertStringNotContainsString("\n", $description, "{$capability->name} com quebra de linha na descrição"); + $this->assertStringNotContainsString('*', $description, "{$capability->name} com resto de docblock na descrição"); + $this->assertStringEndsWith('.', $description, "{$capability->name} sem ponto final"); + } + + #[DataProvider('capabilityProvider')] + public function testValueIsTheSnakeCaseOfTheCaseName(Capability $capability): void + { + $this->assertSame(strtolower($capability->name), $capability->value); + } + + public static function capabilityProvider(): array + { + $cases = []; + foreach (Capability::cases() as $capability) { + $cases[$capability->name] = [$capability]; + } + + return $cases; + } + + #[DataProvider('paymentMethodProvider')] + public function testEveryPaymentMethodMapsToACapability(PaymentMethod $paymentMethod, Capability $expected): void + { + $this->assertSame($expected, Capability::forPaymentMethod($paymentMethod)); + } + + public static function paymentMethodProvider(): array + { + return [ + 'cartão' => [PaymentMethod::CREDIT_CARD, Capability::CREDIT_CARD], + 'pix' => [PaymentMethod::PIX, Capability::PIX], + 'boleto' => [PaymentMethod::BANK_SLIP, Capability::BANK_SLIP], + 'pix automático' => [PaymentMethod::AUTOMATIC_PIX, Capability::AUTOMATIC_PIX], + ]; + } + + public function testDescriptionJoinsAMultilineDocblockIntoOneLine(): void + { + $this->assertSame( + 'O gateway agenda as cobranças do Pix Automático por conta própria; sem ela, a aplicação é' + . ' o motor de recorrência e chama as operações de `AutomaticPixContract` na periodicidade' + . ' certa.', + Capability::MANAGES_RECURRENCE->description() + ); + } + + public function testPaymentMethodMappingCoversEveryCase(): void + { + $this->assertCount(count(PaymentMethod::cases()), self::paymentMethodProvider()); + } +} diff --git a/tests/Unit/Enums/DeclineCodeTest.php b/tests/Unit/Enums/DeclineCodeTest.php new file mode 100644 index 0000000..e9437bb --- /dev/null +++ b/tests/Unit/Enums/DeclineCodeTest.php @@ -0,0 +1,89 @@ +assertSame([ + 'insufficient_funds', + 'expired_card', + 'incorrect_cvc', + 'incorrect_number', + 'invalid_card', + 'lost_or_stolen', + 'fraud_suspected', + 'authentication_required', + 'brand_not_supported', + 'do_not_honor', + 'try_again', + 'generic', + 'unknown', + ], array_map(fn (DeclineCode $code) => $code->value, DeclineCode::cases())); + } + + #[DataProvider('retryableProvider')] + public function testIsRetryableTruthTable(DeclineCode $code, bool $expected): void + { + $this->assertSame($expected, $code->isRetryable()); + } + + public static function retryableProvider(): array + { + return [ + 'insufficient_funds' => [DeclineCode::INSUFFICIENT_FUNDS, true], + 'expired_card' => [DeclineCode::EXPIRED_CARD, false], + 'incorrect_cvc' => [DeclineCode::INCORRECT_CVC, false], + 'incorrect_number' => [DeclineCode::INCORRECT_NUMBER, false], + 'invalid_card' => [DeclineCode::INVALID_CARD, false], + 'lost_or_stolen' => [DeclineCode::LOST_OR_STOLEN, false], + 'fraud_suspected' => [DeclineCode::FRAUD_SUSPECTED, false], + 'authentication_required' => [DeclineCode::AUTHENTICATION_REQUIRED, false], + 'brand_not_supported' => [DeclineCode::BRAND_NOT_SUPPORTED, false], + 'do_not_honor' => [DeclineCode::DO_NOT_HONOR, false], + 'try_again' => [DeclineCode::TRY_AGAIN, true], + 'generic' => [DeclineCode::GENERIC, false], + 'unknown' => [DeclineCode::UNKNOWN, false], + ]; + } + + #[DataProvider('payerActionProvider')] + public function testRequiresPayerActionTruthTable(DeclineCode $code, bool $expected): void + { + $this->assertSame($expected, $code->requiresPayerAction()); + } + + public static function payerActionProvider(): array + { + return [ + 'insufficient_funds' => [DeclineCode::INSUFFICIENT_FUNDS, false], + 'expired_card' => [DeclineCode::EXPIRED_CARD, true], + 'incorrect_cvc' => [DeclineCode::INCORRECT_CVC, true], + 'incorrect_number' => [DeclineCode::INCORRECT_NUMBER, true], + 'invalid_card' => [DeclineCode::INVALID_CARD, true], + 'lost_or_stolen' => [DeclineCode::LOST_OR_STOLEN, false], + 'fraud_suspected' => [DeclineCode::FRAUD_SUSPECTED, false], + 'authentication_required' => [DeclineCode::AUTHENTICATION_REQUIRED, true], + 'brand_not_supported' => [DeclineCode::BRAND_NOT_SUPPORTED, true], + 'do_not_honor' => [DeclineCode::DO_NOT_HONOR, false], + 'try_again' => [DeclineCode::TRY_AGAIN, false], + 'generic' => [DeclineCode::GENERIC, false], + 'unknown' => [DeclineCode::UNKNOWN, false], + ]; + } + + public function testEveryCaseHasAOneLineDocblock(): void + { + $reflection = new \ReflectionEnum(DeclineCode::class); + foreach ($reflection->getCases() as $case) { + $doc = $case->getDocComment(); + $this->assertIsString($doc, "{$case->getName()} sem docblock"); + $this->assertMatchesRegularExpression('#^/\*\* .+ \*/$#', $doc, "{$case->getName()} com docblock de mais de uma linha"); + } + } +} diff --git a/tests/Unit/Enums/InvoiceOriginTypeTest.php b/tests/Unit/Enums/InvoiceOriginTypeTest.php new file mode 100644 index 0000000..5298936 --- /dev/null +++ b/tests/Unit/Enums/InvoiceOriginTypeTest.php @@ -0,0 +1,39 @@ +assertSame(['payment_intent', 'invoice'], array_column(InvoiceOriginType::cases(), 'value')); + } + + public function testInvoiceAcceptsTheStringOrTheCaseAndAlwaysReturnsTheCase(): void + { + $invoice = new Invoice(); + $this->assertNull($invoice->originType); + + $invoice->originType = 'payment_intent'; + $this->assertSame(InvoiceOriginType::PAYMENT_INTENT, $invoice->originType); + + $invoice->originType = InvoiceOriginType::INVOICE; + $this->assertSame(InvoiceOriginType::INVOICE, $invoice->originType); + $this->assertSame('invoice', $invoice->toArray()['origin_type']); + $this->assertSame('invoice', json_decode(json_encode($invoice), true)['originType']); + } + + public function testUnknownOriginIsRejectedOnWrite(): void + { + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('originType must be one of: payment_intent, invoice'); + + $invoice = new Invoice(); + $invoice->originType = 'checkout_session'; + } +} diff --git a/tests/Unit/Enums/InvoiceStatusTest.php b/tests/Unit/Enums/InvoiceStatusTest.php new file mode 100644 index 0000000..2fb55c5 --- /dev/null +++ b/tests/Unit/Enums/InvoiceStatusTest.php @@ -0,0 +1,148 @@ +assertSame([ + 'pending', + 'authorized', + 'processing', + 'paid', + 'partially_paid', + 'externally_paid', + 'partially_refunded', + 'refunded', + 'disputed', + 'chargeback', + 'canceled', + 'expired', + 'unknown', + ], array_column(InvoiceStatus::cases(), 'value')); + } + + /** + * Tabela verdade completa dos cinco helpers, um caso por linha. + * + * @return array + */ + public static function helperTruthTableProvider(): array + { + // [status, isSettled, isContested, isTerminal, isOpen, isPayable] + return [ + 'pending' => [InvoiceStatus::PENDING, false, false, false, true, true], + 'authorized' => [InvoiceStatus::AUTHORIZED, false, false, false, true, true], + 'processing' => [InvoiceStatus::PROCESSING, false, false, false, true, false], + 'paid' => [InvoiceStatus::PAID, true, false, false, false, false], + 'partially_paid' => [InvoiceStatus::PARTIALLY_PAID, true, false, false, true, true], + 'externally_paid' => [InvoiceStatus::EXTERNALLY_PAID, true, false, false, false, false], + 'partially_refunded' => [InvoiceStatus::PARTIALLY_REFUNDED, true, false, false, false, false], + 'refunded' => [InvoiceStatus::REFUNDED, false, false, true, false, false], + 'disputed' => [InvoiceStatus::DISPUTED, false, true, false, false, false], + 'chargeback' => [InvoiceStatus::CHARGEBACK, false, true, true, false, false], + 'canceled' => [InvoiceStatus::CANCELED, false, false, true, false, false], + 'expired' => [InvoiceStatus::EXPIRED, false, false, false, false, true], + 'unknown' => [InvoiceStatus::UNKNOWN, false, false, false, false, false], + ]; + } + + #[DataProvider('helperTruthTableProvider')] + public function testHelpersAnswerEachBusinessQuestion( + InvoiceStatus $status, + bool $settled, + bool $contested, + bool $terminal, + bool $open, + bool $payable + ): void { + $this->assertSame($settled, $status->isSettled(), 'isSettled'); + $this->assertSame($contested, $status->isContested(), 'isContested'); + $this->assertSame($terminal, $status->isTerminal(), 'isTerminal'); + $this->assertSame($open, $status->isOpen(), 'isOpen'); + $this->assertSame($payable, $status->isPayable(), 'isPayable'); + $this->assertFalse($terminal && $payable, 'nenhum status é terminal e pagável ao mesmo tempo'); + } + + /** + * `EXPIRED` responde verdadeiro a `isPayable()` e falso a `isTerminal()` e a `isOpen()`. + */ + public function testExpiredIsPayableAndNotTerminal(): void + { + $this->assertFalse(InvoiceStatus::EXPIRED->isTerminal()); + $this->assertTrue(InvoiceStatus::EXPIRED->isPayable()); + $this->assertFalse(InvoiceStatus::EXPIRED->isOpen()); + } + + public function testFromValueReturnsTheMatchingCaseWithoutLogging(): void + { + $logger = $this->bindLogger(); + + $this->assertSame(InvoiceStatus::PAID, InvoiceStatus::fromValue('paid', 'iugu')); + $this->assertSame(InvoiceStatus::PARTIALLY_PAID, InvoiceStatus::fromValue('partially_paid')); + $this->assertSame([], $logger->records); + } + + public function testFromValueTurnsAnUnknownStringIntoUnknownWithAWarning(): void + { + $logger = $this->bindLogger(); + + $status = InvoiceStatus::fromValue('status_novo', 'iugu'); + + $this->assertSame(InvoiceStatus::UNKNOWN, $status); + $this->assertCount(1, $logger->records); + $this->assertSame('warning', $logger->records[0]['level']); + $this->assertStringContainsString('status_novo', $logger->records[0]['message']); + $this->assertStringContainsString('iugu', $logger->records[0]['message']); + $this->assertSame(['status' => 'status_novo', 'gateway' => 'iugu'], $logger->records[0]['context']); + } + + public function testUnknownLogsAGatewaylessValue(): void + { + $logger = $this->bindLogger(); + + $this->assertSame(InvoiceStatus::UNKNOWN, InvoiceStatus::unknown('x')); + $this->assertSame(['status' => 'x', 'gateway' => null], $logger->records[0]['context']); + $this->assertStringContainsString('desconhecido', $logger->records[0]['message']); + } + + public function testUnknownFallsBackToErrorLogWithoutALoggerInTheContainer(): void + { + Facade::setFacadeApplication(new Container()); + $previous = ini_set('error_log', $file = tempnam(sys_get_temp_dir(), 'multipayment-log')); + + try { + $this->assertSame(InvoiceStatus::UNKNOWN, InvoiceStatus::unknown('sem_logger', 'stripe')); + } finally { + ini_set('error_log', $previous); + } + + $this->assertStringContainsString('sem_logger', file_get_contents($file)); + unlink($file); + } + + private function bindLogger(): RecordingLogger + { + $app = new Container(); + $app->instance('log', $logger = new RecordingLogger()); + Facade::setFacadeApplication($app); + + return $logger; + } +} diff --git a/tests/Unit/Enums/PaymentMethodTest.php b/tests/Unit/Enums/PaymentMethodTest.php new file mode 100644 index 0000000..adad489 --- /dev/null +++ b/tests/Unit/Enums/PaymentMethodTest.php @@ -0,0 +1,52 @@ +assertSame(Invoice::PAYMENT_METHOD_CREDIT_CARD, PaymentMethod::CREDIT_CARD->value); + $this->assertSame(Invoice::PAYMENT_METHOD_BANK_SLIP, PaymentMethod::BANK_SLIP->value); + $this->assertSame(Invoice::PAYMENT_METHOD_PIX, PaymentMethod::PIX->value); + $this->assertSame('automatic_pix', PaymentMethod::AUTOMATIC_PIX->value); + $this->assertCount(4, PaymentMethod::cases()); + } + + public function testSelectableExcludesAutomaticPix(): void + { + $this->assertSame( + [PaymentMethod::CREDIT_CARD, PaymentMethod::BANK_SLIP, PaymentMethod::PIX], + PaymentMethod::selectable() + ); + } + + public function testNormalizeSelectableConvertsStringsAndKeepsCases(): void + { + $this->assertSame( + [PaymentMethod::PIX, PaymentMethod::CREDIT_CARD], + PaymentMethod::normalizeSelectable(['pix', PaymentMethod::CREDIT_CARD], 'Invoice') + ); + } + + public function testNormalizeSelectableRejectsAutomaticPixAndUnknownValues(): void + { + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('availablePaymentMethods must be one of: credit_card, bank_slip, pix'); + + PaymentMethod::normalizeSelectable([PaymentMethod::AUTOMATIC_PIX], 'Invoice'); + } + + public function testNormalizeSelectableRejectsANonArray(): void + { + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('availablePaymentMethods must be an array of payment methods'); + + PaymentMethod::normalizeSelectable('pix', 'Subscription'); + } +} diff --git a/tests/Unit/Enums/PlanIntervalTest.php b/tests/Unit/Enums/PlanIntervalTest.php new file mode 100644 index 0000000..728ffc8 --- /dev/null +++ b/tests/Unit/Enums/PlanIntervalTest.php @@ -0,0 +1,19 @@ +assertSame(Plan::INTERVAL_WEEK, PlanInterval::WEEK->value); + $this->assertSame(Plan::INTERVAL_MONTH, PlanInterval::MONTH->value); + $this->assertSame(Plan::INTERVAL_YEAR, PlanInterval::YEAR->value); + $this->assertSame('day', PlanInterval::DAY->value); + $this->assertSame(['day', 'week', 'month', 'year'], array_column(PlanInterval::cases(), 'value')); + } +} diff --git a/tests/Unit/Enums/ProrationBehaviorTest.php b/tests/Unit/Enums/ProrationBehaviorTest.php new file mode 100644 index 0000000..70b2670 --- /dev/null +++ b/tests/Unit/Enums/ProrationBehaviorTest.php @@ -0,0 +1,62 @@ +assertSame( + ['charge_difference', 'none', 'credit'], + array_column(ProrationBehavior::cases(), 'value') + ); + } + + #[DataProvider('chargeProvider')] + public function testFromChargeTranslatesTheOldBoolean(bool $charge, ProrationBehavior $expected): void + { + $this->assertSame($expected, ProrationBehavior::fromCharge($charge)); + } + + public static function chargeProvider(): array + { + return [ + 'true cobra a diferença' => [true, ProrationBehavior::CHARGE_DIFFERENCE], + 'false não cobra' => [false, ProrationBehavior::NONE], + ]; + } + + public function testResolveReturnsTheEnumAsIsWithoutDeprecation(): void + { + foreach (ProrationBehavior::cases() as $behavior) { + $this->assertSame($behavior, ProrationBehavior::resolve($behavior)); + } + } + + #[IgnoreDeprecations] + public function testResolveTranslatesTheBooleanWithADeprecationNotice(): void + { + $this->expectUserDeprecationMessage( + 'O booleano $charge de changePlan() está obsoleto desde 2026-09-02; passe' + . ' ProrationBehavior::CHARGE_DIFFERENCE ou ProrationBehavior::NONE' + ); + + $this->assertSame(ProrationBehavior::NONE, ProrationBehavior::resolve(false)); + } + + /** + * Só `CREDIT` depende de uma capability além de `SUBSCRIPTIONS`. + */ + public function testOnlyCreditRequiresACapability(): void + { + $this->assertSame(Capability::PLAN_CHANGE_PRORATION, ProrationBehavior::CREDIT->requiredCapability()); + $this->assertNull(ProrationBehavior::CHARGE_DIFFERENCE->requiredCapability()); + $this->assertNull(ProrationBehavior::NONE->requiredCapability()); + } +} diff --git a/tests/Unit/Enums/RefundStatusTest.php b/tests/Unit/Enums/RefundStatusTest.php new file mode 100644 index 0000000..429a7e3 --- /dev/null +++ b/tests/Unit/Enums/RefundStatusTest.php @@ -0,0 +1,81 @@ +assertSame([ + 'pending', + 'succeeded', + 'failed', + 'canceled', + 'unknown', + ], array_column(RefundStatus::cases(), 'value')); + } + + public static function isFailedProvider(): array + { + return [ + 'pending' => [RefundStatus::PENDING, false], + 'succeeded' => [RefundStatus::SUCCEEDED, false], + 'failed' => [RefundStatus::FAILED, true], + 'canceled' => [RefundStatus::CANCELED, true], + 'unknown' => [RefundStatus::UNKNOWN, false], + ]; + } + + #[DataProvider('isFailedProvider')] + public function testIsFailedIsTrueOnlyWhenTheMoneyDidNotGoBack(RefundStatus $status, bool $failed): void + { + $this->assertSame($failed, $status->isFailed()); + } + + public function testFromValueReturnsTheMatchingCaseWithoutLogging(): void + { + $logger = $this->bindLogger(); + + $this->assertSame(RefundStatus::SUCCEEDED, RefundStatus::fromValue('succeeded', 'stripe')); + $this->assertSame(RefundStatus::PENDING, RefundStatus::fromValue('pending')); + $this->assertSame([], $logger->records); + } + + public function testFromValueTurnsAnUnknownStringIntoUnknownWithAWarning(): void + { + $logger = $this->bindLogger(); + + $status = RefundStatus::fromValue('status_novo', 'stripe'); + + $this->assertSame(RefundStatus::UNKNOWN, $status); + $this->assertCount(1, $logger->records); + $this->assertSame('warning', $logger->records[0]['level']); + $this->assertStringContainsString('status_novo', $logger->records[0]['message']); + $this->assertStringContainsString('stripe', $logger->records[0]['message']); + $this->assertSame(['status' => 'status_novo', 'gateway' => 'stripe'], $logger->records[0]['context']); + } + + private function bindLogger(): RecordingLogger + { + $app = new Container(); + $app->instance('log', $logger = new RecordingLogger()); + Facade::setFacadeApplication($app); + + return $logger; + } +} diff --git a/tests/Unit/Enums/SubscriptionStatusTest.php b/tests/Unit/Enums/SubscriptionStatusTest.php new file mode 100644 index 0000000..36875d5 --- /dev/null +++ b/tests/Unit/Enums/SubscriptionStatusTest.php @@ -0,0 +1,139 @@ +assertSame([ + 'pending', + 'trialing', + 'active', + 'past_due', + 'paused', + 'suspended', + 'canceled', + 'expired', + 'unknown', + ], array_column(SubscriptionStatus::cases(), 'value')); + } + + /** + * Tabela verdade completa dos três helpers, um caso por linha. + * + * @return array + */ + public static function helperTruthTableProvider(): array + { + // [status, isActive, isRecoverable, isEnded] + return [ + 'pending' => [SubscriptionStatus::PENDING, false, true, false], + 'trialing' => [SubscriptionStatus::TRIALING, true, false, false], + 'active' => [SubscriptionStatus::ACTIVE, true, false, false], + 'past_due' => [SubscriptionStatus::PAST_DUE, false, true, false], + 'paused' => [SubscriptionStatus::PAUSED, false, true, false], + 'suspended' => [SubscriptionStatus::SUSPENDED, false, true, false], + 'canceled' => [SubscriptionStatus::CANCELED, false, false, true], + 'expired' => [SubscriptionStatus::EXPIRED, false, false, true], + 'unknown' => [SubscriptionStatus::UNKNOWN, false, false, false], + ]; + } + + #[DataProvider('helperTruthTableProvider')] + public function testHelpersAnswerEachBusinessQuestion( + SubscriptionStatus $status, + bool $active, + bool $recoverable, + bool $ended + ): void { + $this->assertSame($active, $status->isActive(), 'isActive'); + $this->assertSame($recoverable, $status->isRecoverable(), 'isRecoverable'); + $this->assertSame($ended, $status->isEnded(), 'isEnded'); + } + + /** + * Cada estado responde verdadeiro a exatamente um helper, exceto `UNKNOWN`, que não + * responde a nenhum. + */ + #[DataProvider('helperTruthTableProvider')] + public function testHelpersPartitionTheKnownStates( + SubscriptionStatus $status, + bool $active, + bool $recoverable, + bool $ended + ): void { + $expected = $status === SubscriptionStatus::UNKNOWN ? 0 : 1; + + $this->assertSame($expected, (int) $active + (int) $recoverable + (int) $ended); + } + + public function testTheOldConstantsKeepTheEnumValues(): void + { + $this->assertSame(SubscriptionStatus::TRIALING->value, Subscription::STATUS_TRIALING); + $this->assertSame(SubscriptionStatus::ACTIVE->value, Subscription::STATUS_ACTIVE); + $this->assertSame(SubscriptionStatus::SUSPENDED->value, Subscription::STATUS_SUSPENDED); + $this->assertSame(SubscriptionStatus::PENDING->value, Subscription::STATUS_PENDING); + $this->assertSame(SubscriptionStatus::PAST_DUE->value, Subscription::STATUS_PAST_DUE); + $this->assertSame(SubscriptionStatus::EXPIRED->value, Subscription::STATUS_EXPIRED); + $this->assertSame(SubscriptionStatus::CANCELED->value, Subscription::STATUS_CANCELED); + } + + public function testFromValueReturnsTheMatchingCaseWithoutLogging(): void + { + $logger = $this->bindLogger(); + + $this->assertSame(SubscriptionStatus::ACTIVE, SubscriptionStatus::fromValue('active', 'iugu')); + $this->assertSame(SubscriptionStatus::PAST_DUE, SubscriptionStatus::fromValue('past_due')); + $this->assertSame([], $logger->records); + } + + public function testFromValueTurnsAnUnknownStringIntoUnknownWithAWarning(): void + { + $logger = $this->bindLogger(); + + $status = SubscriptionStatus::fromValue('status_novo', 'stripe'); + + $this->assertSame(SubscriptionStatus::UNKNOWN, $status); + $this->assertCount(1, $logger->records); + $this->assertSame('warning', $logger->records[0]['level']); + $this->assertStringContainsString('assinatura', $logger->records[0]['message']); + $this->assertStringContainsString('status_novo', $logger->records[0]['message']); + $this->assertStringContainsString('stripe', $logger->records[0]['message']); + $this->assertSame(['status' => 'status_novo', 'gateway' => 'stripe'], $logger->records[0]['context']); + } + + public function testUnknownLogsAGatewaylessValue(): void + { + $logger = $this->bindLogger(); + + $this->assertSame(SubscriptionStatus::UNKNOWN, SubscriptionStatus::unknown('x')); + $this->assertSame(['status' => 'x', 'gateway' => null], $logger->records[0]['context']); + $this->assertStringContainsString('desconhecido', $logger->records[0]['message']); + } + + private function bindLogger(): RecordingLogger + { + $app = new Container(); + $app->instance('log', $logger = new RecordingLogger()); + Facade::setFacadeApplication($app); + + return $logger; + } +} diff --git a/tests/Unit/Exceptions/CardDeclinedExceptionTest.php b/tests/Unit/Exceptions/CardDeclinedExceptionTest.php new file mode 100644 index 0000000..01ed96b --- /dev/null +++ b/tests/Unit/Exceptions/CardDeclinedExceptionTest.php @@ -0,0 +1,104 @@ +assertInstanceOf(MultiPaymentException::class, $exception); + $this->assertNotInstanceOf(GatewayException::class, $exception); + } + + public function testDeclinedFillsCodeGatewayCodeRetryableAndReason(): void + { + $previous = new \RuntimeException('sdk'); + + $exception = CardDeclinedException::declined('stripe', DeclineCode::INSUFFICIENT_FUNDS, 'insufficient_funds', 'Your card has insufficient funds.', $previous, 402); + + $this->assertSame(DeclineCode::INSUFFICIENT_FUNDS, $exception->declineCode); + $this->assertSame('insufficient_funds', $exception->gatewayCode); + $this->assertTrue($exception->retryable); + $this->assertSame('insufficient_funds', $exception->reason); + $this->assertSame($previous, $exception->getPrevious()); + $this->assertSame(402, $exception->httpStatus); + $this->assertNull($exception->chargeResponse); + $this->assertSame( + 'Cartão recusado pelo gateway stripe (insufficient_funds, código insufficient_funds): Your card has insufficient funds.', + $exception->getMessage() + ); + } + + public function testRetryableCanBeOverriddenByTheDriver(): void + { + $forced = CardDeclinedException::declined('stripe', DeclineCode::GENERIC, 'generic_decline', '', null, null, true); + $denied = CardDeclinedException::declined('stripe', DeclineCode::TRY_AGAIN, 'processing_error', '', null, null, false); + + $this->assertTrue($forced->retryable); + $this->assertFalse($denied->retryable); + } + + public function testMessageWithoutCodeAndWithoutDetail(): void + { + $exception = CardDeclinedException::declined('iugu', DeclineCode::UNKNOWN, '', ''); + + $this->assertNull($exception->gatewayCode); + $this->assertSame('Cartão recusado pelo gateway iugu (unknown, sem código)', $exception->getMessage()); + } + + public function testDefaultsBeforeDeclinedIsCalled(): void + { + $exception = new CardDeclinedException('recusado'); + + $this->assertSame(DeclineCode::UNKNOWN, $exception->declineCode); + $this->assertNull($exception->gatewayCode); + $this->assertFalse($exception->retryable); + $this->assertNull($exception->reason); + } + + public function testChargingExceptionIsACardDeclinedExceptionAndIsCaughtByBothNames(): void + { + $exception = ChargingException::declined('iugu', DeclineCode::EXPIRED_CARD, '54', 'Cartão vencido'); + + $this->assertInstanceOf(ChargingException::class, $exception); + $this->assertInstanceOf(CardDeclinedException::class, $exception); + $this->assertSame(DeclineCode::EXPIRED_CARD, $exception->declineCode); + + $caught = []; + try { + throw $exception; + } catch (ChargingException $e) { + $caught[] = 'charging'; + } + try { + throw $exception; + } catch (CardDeclinedException $e) { + $caught[] = 'card_declined'; + } + + $this->assertSame(['charging', 'card_declined'], $caught); + } + + public function testChargingExceptionKeepsThePreviousConstructorSignature(): void + { + $previous = new \RuntimeException('sdk'); + + $exception = new ChargingException('recusado', $previous, 402); + $exception->chargeResponse = ['LR' => '51']; + $exception->reason = 'card_declined'; + + $this->assertSame($previous, $exception->getPrevious()); + $this->assertSame(402, $exception->httpStatus); + $this->assertSame(['LR' => '51'], $exception->chargeResponse); + $this->assertSame('card_declined', $exception->reason); + } +} diff --git a/tests/Unit/Exceptions/ExceptionHierarchyTest.php b/tests/Unit/Exceptions/ExceptionHierarchyTest.php new file mode 100644 index 0000000..eaa2743 --- /dev/null +++ b/tests/Unit/Exceptions/ExceptionHierarchyTest.php @@ -0,0 +1,121 @@ +assertNotEmpty($files); + + foreach ($files as $file) { + $class = 'Potelo\\MultiPayment\\Exceptions\\' . basename($file, '.php'); + $this->assertTrue(class_exists($class), "{$class} não existe"); + $this->assertTrue( + $class === MultiPaymentException::class || is_subclass_of($class, MultiPaymentException::class), + "{$class} não herda de MultiPaymentException" + ); + } + } + + #[DataProvider('gatewayResponseProvider')] + public function testGatewayResponseExceptionsExtendGatewayException(string $class): void + { + $this->assertTrue(is_subclass_of($class, GatewayException::class), "{$class} não herda de GatewayException"); + } + + public static function gatewayResponseProvider(): array + { + return [ + 'ValidationException' => [ValidationException::class], + 'NotFoundException' => [NotFoundException::class], + 'RateLimitException' => [RateLimitException::class], + 'IdempotencyConflictException' => [IdempotencyConflictException::class], + ]; + } + + #[DataProvider('outsideGatewayExceptionProvider')] + public function testTheOtherExceptionsStayOutsideGatewayException(string $class): void + { + $this->assertFalse(is_subclass_of($class, GatewayException::class), "{$class} não deveria herdar de GatewayException"); + } + + public static function outsideGatewayExceptionProvider(): array + { + return [ + 'CardDeclinedException' => [CardDeclinedException::class], + 'ChargingException' => [ChargingException::class], + 'AuthenticationException' => [AuthenticationException::class], + 'GatewayNotAvailableException' => [GatewayNotAvailableException::class], + 'UnsupportedOperationException' => [UnsupportedOperationException::class], + 'RefundNotSupportedException' => [RefundNotSupportedException::class], + 'ModelAttributeValidationException' => [ModelAttributeValidationException::class], + 'ConfigurationException' => [ConfigurationException::class], + ]; + } + + /** + * `RefundNotSupportedException` herda direto de `MultiPaymentException` e fica fora da árvore + * de `UnsupportedOperationException`. + */ + public function testRefundNotSupportedExceptionStaysOutsideUnsupportedOperationException(): void + { + $this->assertFalse(is_subclass_of(RefundNotSupportedException::class, UnsupportedOperationException::class)); + $this->assertSame(MultiPaymentException::class, get_parent_class(RefundNotSupportedException::class)); + } + + public function testChargingExceptionIsTheDeprecatedNameOfCardDeclinedException(): void + { + $this->assertSame(CardDeclinedException::class, get_parent_class(ChargingException::class)); + } + + public function testRateLimitExceptionCarriesRetryAfter(): void + { + $previous = new \RuntimeException('sdk'); + + $exception = RateLimitException::withRetryAfter('Too many requests', ['type' => 'rate_limit_error'], $previous, 429, 7); + + $this->assertSame(7, $exception->retryAfter); + $this->assertSame(['type' => 'rate_limit_error'], $exception->getErrors()); + $this->assertSame($previous, $exception->getPrevious()); + $this->assertSame(429, $exception->httpStatus); + $this->assertNull((new RateLimitException('erro'))->retryAfter); + } + + public function testNotFoundAndIdempotencyConflictKeepTheGatewayExceptionConstructor(): void + { + $previous = new \RuntimeException('sdk'); + + $notFound = new NotFoundException('Error getting invoice', 'Not Found', $previous, 404); + $conflict = new IdempotencyConflictException('Error creating invoice', ['base' => ['conflito']], $previous, 409); + + $this->assertSame(['Not Found'], $notFound->getErrors()); + $this->assertSame(404, $notFound->httpStatus); + $this->assertSame($previous, $notFound->getPrevious()); + $this->assertSame(['base' => ['conflito']], $conflict->getErrors()); + $this->assertSame(409, $conflict->httpStatus); + } +} diff --git a/tests/Unit/Exceptions/GatewayExceptionTest.php b/tests/Unit/Exceptions/GatewayExceptionTest.php new file mode 100644 index 0000000..0c8abe4 --- /dev/null +++ b/tests/Unit/Exceptions/GatewayExceptionTest.php @@ -0,0 +1,23 @@ +assertSame([], (new GatewayException('erro'))->getErrors()); + $this->assertSame(['mensagem'], (new GatewayException('erro', 'mensagem'))->getErrors()); + $this->assertSame( + ['code' => 'invalid'], + (new GatewayException('erro', (object) ['code' => 'invalid']))->getErrors() + ); + $this->assertSame( + ['code' => 'invalid'], + (new GatewayException('erro', ['code' => 'invalid']))->getErrors() + ); + } +} diff --git a/tests/Unit/Exceptions/MultiPaymentExceptionTest.php b/tests/Unit/Exceptions/MultiPaymentExceptionTest.php new file mode 100644 index 0000000..f68b50a --- /dev/null +++ b/tests/Unit/Exceptions/MultiPaymentExceptionTest.php @@ -0,0 +1,97 @@ +assertSame('erro', $exception->getMessage()); + $this->assertSame($original, $exception->getPrevious()); + $this->assertSame(503, $exception->httpStatus); + } + + public function testBaseExceptionDefaultsToNoPreviousAndNoHttpStatus(): void + { + $exception = new MultiPaymentException('erro'); + + $this->assertNull($exception->getPrevious()); + $this->assertNull($exception->httpStatus); + } + + public function testGatewayExceptionKeepsErrorsWhileAcceptingPreviousAndHttpStatus(): void + { + $original = new \RuntimeException('sdk'); + + $exception = new GatewayException('erro', ['code' => 'invalid'], $original, 422); + + $this->assertSame('erro - code: invalid', $exception->getMessage()); + $this->assertSame(['code' => 'invalid'], $exception->getErrors()); + $this->assertSame($original, $exception->getPrevious()); + $this->assertSame(422, $exception->httpStatus); + } + + public function testSubclassesWithoutOwnConstructorInheritPreviousAndHttpStatus(): void + { + $original = new \RuntimeException('sdk'); + + $charging = new ChargingException('recusado', $original, 402); + $unavailable = new GatewayNotAvailableException('fora do ar', $original, 502); + + $this->assertSame($original, $charging->getPrevious()); + $this->assertSame(402, $charging->httpStatus); + $this->assertSame($original, $unavailable->getPrevious()); + $this->assertSame(502, $unavailable->httpStatus); + } + + public function testAuthenticationExceptionNamesTheGatewayAndKeepsTheDetail(): void + { + $original = new \RuntimeException('Unauthorized'); + + $exception = AuthenticationException::invalidCredentials('iugu', 'Unauthorized', $original, 401); + + $this->assertInstanceOf(MultiPaymentException::class, $exception); + $this->assertNotInstanceOf(GatewayNotAvailableException::class, $exception); + $this->assertStringContainsString('iugu', $exception->getMessage()); + $this->assertStringContainsString('Unauthorized', $exception->getMessage()); + $this->assertSame($original, $exception->getPrevious()); + $this->assertSame(401, $exception->httpStatus); + } + + public function testAuthenticationExceptionWithoutDetailHasNoDanglingSuffix(): void + { + $exception = AuthenticationException::invalidCredentials('stripe', ''); + + $this->assertStringEndsWith('configurada.', $exception->getMessage()); + $this->assertNull($exception->httpStatus); + } + + public function testRefundNotSupportedExceptionAcceptsPrevious(): void + { + $original = new \RuntimeException('sdk'); + + $exception = new RefundNotSupportedException( + 'recusado', + 'pix', + RefundNotSupportedException::REASON_PIX_PARTIAL_NOT_SUPPORTED, + false, + $original + ); + + $this->assertSame($original, $exception->getPrevious()); + $this->assertSame('pix', $exception->paymentMethod); + $this->assertFalse($exception->manualRefundRequired); + } +} diff --git a/tests/Unit/Exceptions/NoLocalGatewayExceptionTest.php b/tests/Unit/Exceptions/NoLocalGatewayExceptionTest.php new file mode 100644 index 0000000..01a0737 --- /dev/null +++ b/tests/Unit/Exceptions/NoLocalGatewayExceptionTest.php @@ -0,0 +1,300 @@ +sourceFiles() as $file) { + foreach (self::gatewayExceptionCreations($file) as $creation) { + $context = $creation['class'] . '::' . $creation['function']; + if ($creation['insideCatch'] || in_array($context, self::CLASSIFIERS, true)) { + continue; + } + $offenders[] = "{$creation['file']}:{$creation['line']} ({$context})"; + } + } + + $this->assertSame( + [], + $offenders, + "GatewayException criada fora de um classificador ou de um catch (use ModelAttributeValidationException, " + . "UnsupportedOperationException::restricted() ou ConfigurationException):\n" . implode("\n", $offenders) + ); + } + + /** + * A varredura reconhece os pontos legítimos, senão o teste passaria por não olhar nada. + */ + public function testTheScanFindsTheClassifiers(): void + { + $found = []; + foreach ($this->sourceFiles() as $file) { + foreach (self::gatewayExceptionCreations($file) as $creation) { + $found[] = $creation['class'] . '::' . $creation['function']; + } + } + + foreach (self::CLASSIFIERS as $classifier) { + $this->assertContains($classifier, $found, "a varredura não encontrou {$classifier}"); + } + } + + /** + * Controle negativo sobre uma fixture: criação direta, construtor estático, nome qualificado + * e `throw` depois de um `catch` já fechado são reportados fora de `catch`; só a criação + * dentro do `catch` (inclusive numa closure) é marcada como tal, e `::class` é ignorado. + */ + public function testTheScanReportsCreationsOutsideACatchAndTracksTheCatchDepth(): void + { + $code = <<<'PHP' + nome} {$this->outro}"; + throw new GatewayException('local'); + } + + public function estatica(): void + { + $classe = GatewayException::class; + throw GatewayException::methodNotFound('a', 'b'); + } + + public function qualificada(): void + { + throw new \Potelo\MultiPayment\Exceptions\GatewayException('local'); + } + + public function depoisDoCatch(): void + { + try { + $this->x(); + } catch (\Exception $e) { + $this->log($e); + } + throw new GatewayException('fora do catch'); + } + + public function dentroDoCatch(): void + { + try { + $this->x(); + } catch (\Exception $e) { + $f = function () use ($e) { + throw new GatewayException('closure', null, $e); + }; + throw new GatewayException('direto', null, $e); + } + } + + public function arm(): int + { + return match (true) { + default => throw new GatewayException('arm'), + }; + } + } + PHP; + $path = tempnam(sys_get_temp_dir(), 'scan') . '.php'; + file_put_contents($path, $code); + + try { + $method = new \ReflectionMethod(self::class, 'gatewayExceptionCreations'); + $creations = $method->invoke(null, $path); + } finally { + unlink($path); + } + + $this->assertSame([ + ['direta', false], + ['estatica', false], + ['qualificada', false], + ['depoisDoCatch', false], + ['dentroDoCatch', true], + ['dentroDoCatch', true], + ['arm', false], + ], array_map(static fn (array $c) => [$c['function'], $c['insideCatch']], $creations)); + } + + /** + * @return string[] + */ + private function sourceFiles(): array + { + $root = realpath(__DIR__ . '/../../../src'); + $files = []; + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root)); + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'php' && basename($file->getPathname()) !== 'GatewayException.php') { + $files[] = $file->getPathname(); + } + } + sort($files); + $this->assertNotEmpty($files); + + return $files; + } + + /** + * Ocorrências de `new GatewayException(` e de `GatewayException::metodo(` no arquivo, cada + * uma com o método que a contém e se está dentro de um bloco `catch`. + * + * @param string $path + * @return array + */ + private static function gatewayExceptionCreations(string $path): array + { + $tokens = token_get_all(file_get_contents($path)); + $class = basename($path, '.php'); + $creations = []; + + $braces = []; // pilha de chaves abertas: 'code' ou 'interp' (interpolação em string) + $function = null; + $functionDepth = null; + $catchDepths = []; + $pendingFunction = false; + $pendingCatch = false; + $codeDepth = 0; + + $count = count($tokens); + for ($i = 0; $i < $count; $i++) { + $token = $tokens[$i]; + + if (is_array($token)) { + [$id, $text, $line] = $token; + + if ($id === T_FUNCTION) { + $pendingFunction = true; + } elseif ($pendingFunction && $id === T_STRING) { + $function = $text; + $functionDepth = null; + $pendingFunction = false; + } elseif ($id === T_CATCH) { + $pendingCatch = true; + } elseif ($id === T_CURLY_OPEN || $id === T_DOLLAR_OPEN_CURLY_BRACES) { + $braces[] = 'interp'; + } elseif ($id === T_NEW) { + $next = self::nextSignificant($tokens, $i); + if (self::isGatewayExceptionName($next)) { + $creations[] = self::creation($path, $line, $class, $function, $catchDepths); + } + } elseif ($id === T_DOUBLE_COLON && self::isGatewayExceptionName(self::previousSignificant($tokens, $i))) { + $next = self::nextSignificant($tokens, $i); + if (is_array($next) && $next[0] === T_STRING) { + $creations[] = self::creation($path, $line, $class, $function, $catchDepths); + } + } + + continue; + } + + if ($token === '(' && $pendingFunction) { + // closure: não tem nome e não troca o método corrente + $pendingFunction = false; + } elseif ($token === '{') { + $braces[] = 'code'; + $codeDepth++; + if ($pendingCatch) { + $catchDepths[] = $codeDepth; + $pendingCatch = false; + } + if (!is_null($function) && is_null($functionDepth)) { + $functionDepth = $codeDepth; + } + } elseif ($token === '}') { + $kind = array_pop($braces); + if ($kind !== 'code') { + continue; + } + if (!empty($catchDepths) && end($catchDepths) === $codeDepth) { + array_pop($catchDepths); + } + if ($functionDepth === $codeDepth) { + $function = null; + $functionDepth = null; + } + $codeDepth--; + } + } + + return $creations; + } + + /** + * @param array $tokens + * @return array|string|null + */ + private static function nextSignificant(array $tokens, int $from): array|string|null + { + for ($j = $from + 1, $count = count($tokens); $j < $count; $j++) { + if (!is_array($tokens[$j]) || !in_array($tokens[$j][0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + return $tokens[$j]; + } + } + + return null; + } + + /** + * @param array $tokens + * @return array|string|null + */ + private static function previousSignificant(array $tokens, int $from): array|string|null + { + for ($j = $from - 1; $j >= 0; $j--) { + if (!is_array($tokens[$j]) || !in_array($tokens[$j][0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + return $tokens[$j]; + } + } + + return null; + } + + private static function isGatewayExceptionName(array|string|null $token): bool + { + if (!is_array($token) || !in_array($token[0], [T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED], true)) { + return false; + } + + $name = ltrim($token[1], '\\'); + $basename = substr(strrchr('\\' . $name, '\\'), 1); + + return $basename === 'GatewayException'; + } + + /** + * @return array{file: string, line: int, class: string, function: string, insideCatch: bool} + */ + private static function creation(string $path, int $line, string $class, ?string $function, array $catchDepths): array + { + return [ + 'file' => substr($path, strpos($path, '/src/') + 1), + 'line' => $line, + 'class' => $class, + 'function' => $function ?? '(fora de método)', + 'insideCatch' => !empty($catchDepths), + ]; + } +} diff --git a/tests/Unit/Exceptions/RefundNotSupportedExceptionTest.php b/tests/Unit/Exceptions/RefundNotSupportedExceptionTest.php new file mode 100644 index 0000000..b276c40 --- /dev/null +++ b/tests/Unit/Exceptions/RefundNotSupportedExceptionTest.php @@ -0,0 +1,165 @@ +assertInstanceOf(MultiPaymentException::class, RefundNotSupportedException::boletoNoRefund('iugu')); + } + + /** + * A classe fica fora da árvore de `UnsupportedOperationException`: `instanceof` e + * `is_subclass_of` respondem falso. + */ + public function testIsNotAnUnsupportedOperation(): void + { + $this->assertNotInstanceOf(UnsupportedOperationException::class, RefundNotSupportedException::boletoNoRefund('iugu')); + $this->assertFalse(is_subclass_of(RefundNotSupportedException::class, UnsupportedOperationException::class)); + } + + public function testIsCaughtByItsOwnNameAndNotByUnsupportedOperation(): void + { + $caught = []; + + try { + throw RefundNotSupportedException::boletoNoRefund('stripe'); + } catch (RefundNotSupportedException $e) { + $caught[] = 'refund'; + } + + try { + throw RefundNotSupportedException::alreadyRefunded('stripe', 'pix'); + } catch (UnsupportedOperationException $e) { + $caught[] = 'unsupported'; + } catch (MultiPaymentException $e) { + $caught[] = 'base'; + } + + $this->assertSame(['refund', 'base'], $caught); + } + + public function testCapabilityLimitationsCarryTheCapabilityAndTheGateway(): void + { + $boleto = RefundNotSupportedException::boletoNoRefund('iugu'); + $pix = RefundNotSupportedException::pixPartialNotSupported('iugu', 500, 1000); + + $this->assertTrue($boleto->isCapabilityLimitation()); + $this->assertSame(Capability::REFUND_BANK_SLIP, $boleto->capability); + $this->assertSame('iugu', $boleto->gateway); + $this->assertTrue($pix->isCapabilityLimitation()); + $this->assertSame(Capability::PARTIAL_REFUND_PIX, $pix->capability); + } + + public function testStateRefusalsHaveNoCapability(): void + { + $refusals = [ + RefundNotSupportedException::alreadyRefunded('stripe', 'pix'), + RefundNotSupportedException::amountExceedsRefundable('stripe', 'credit_card', 11000, 10000), + RefundNotSupportedException::refundWindowExpired('iugu', 'pix', Carbon::parse('2026-05-01'), 90), + ]; + + foreach ($refusals as $refusal) { + $this->assertFalse($refusal->isCapabilityLimitation(), $refusal->reason); + $this->assertNull($refusal->capability, $refusal->reason); + } + $this->assertSame('stripe', $refusals[0]->gateway); + } + + public function testHttpStatusIsAlwaysNull(): void + { + $this->assertNull(RefundNotSupportedException::boletoNoRefund('iugu')->httpStatus); + $this->assertNull(RefundNotSupportedException::alreadyRefunded('stripe', 'pix')->httpStatus); + } + + /** + * O construtor de cinco argumentos continua aceito; gateway e capability ficam vazios. + */ + public function testKeepsThePreviousConstructorSignature(): void + { + $previous = new \RuntimeException('sdk'); + $exception = new RefundNotSupportedException('msg', 'pix', RefundNotSupportedException::REASON_ALREADY_REFUNDED, false, $previous); + + $this->assertSame($previous, $exception->getPrevious()); + $this->assertSame('', $exception->gateway); + $this->assertNull($exception->capability); + $this->assertFalse($exception->isCapabilityLimitation()); + $this->assertSame('already_refunded', $exception->reason); + } + + public function testIsNotImplementedIsDeprecatedAndAlwaysFalse(): void + { + $this->expectUserDeprecationMessage('RefundNotSupportedException::isNotImplemented() está obsoleto desde 2026-09-02 e responde sempre falso'); + + $this->assertFalse(RefundNotSupportedException::boletoNoRefund('iugu')->isNotImplemented()); + } + + public function testBoletoNoRefundRequiresManualRefund(): void + { + $exception = RefundNotSupportedException::boletoNoRefund('stripe'); + + $this->assertSame('boleto_no_refund', $exception->reason); + $this->assertSame('bank_slip', $exception->paymentMethod); + $this->assertTrue($exception->manualRefundRequired); + $this->assertStringContainsString('stripe', $exception->getMessage()); + } + + public function testPixPartialNotSupportedIsFixableByTheCaller(): void + { + $exception = RefundNotSupportedException::pixPartialNotSupported('iugu', 500, 1000); + + $this->assertSame('pix_partial_not_supported', $exception->reason); + $this->assertSame('pix', $exception->paymentMethod); + $this->assertFalse($exception->manualRefundRequired); + $this->assertStringContainsString('500', $exception->getMessage()); + $this->assertStringContainsString('1000', $exception->getMessage()); + } + + public function testPixPartialNotSupportedWithUnknownPaidAmount(): void + { + $exception = RefundNotSupportedException::pixPartialNotSupported('iugu', 500, null); + + $this->assertStringContainsString('desconhecido', $exception->getMessage()); + } + + public function testAlreadyRefundedKeepsThePaymentMethod(): void + { + $exception = RefundNotSupportedException::alreadyRefunded('iugu', 'credit_card'); + + $this->assertSame('already_refunded', $exception->reason); + $this->assertSame('credit_card', $exception->paymentMethod); + $this->assertFalse($exception->manualRefundRequired); + } + + public function testAmountExceedsRefundableIsFixableByTheCaller(): void + { + $exception = RefundNotSupportedException::amountExceedsRefundable('stripe', 'credit_card', 11000, 10000); + + $this->assertSame('amount_exceeds_refundable', $exception->reason); + $this->assertSame('credit_card', $exception->paymentMethod); + $this->assertFalse($exception->manualRefundRequired); + $this->assertNull($exception->capability); + $this->assertSame('stripe', $exception->gateway); + $this->assertStringContainsString('11000', $exception->getMessage()); + $this->assertStringContainsString('10000', $exception->getMessage()); + } + + public function testRefundWindowExpiredRequiresManualRefund(): void + { + $exception = RefundNotSupportedException::refundWindowExpired('iugu', 'pix', Carbon::parse('2026-05-01'), 90); + + $this->assertSame('refund_window_expired', $exception->reason); + $this->assertSame('pix', $exception->paymentMethod); + $this->assertTrue($exception->manualRefundRequired); + $this->assertStringContainsString('90 dias', $exception->getMessage()); + $this->assertStringContainsString('2026-05-01', $exception->getMessage()); + } +} diff --git a/tests/Unit/Exceptions/UnsupportedOperationExceptionTest.php b/tests/Unit/Exceptions/UnsupportedOperationExceptionTest.php new file mode 100644 index 0000000..e2926d7 --- /dev/null +++ b/tests/Unit/Exceptions/UnsupportedOperationExceptionTest.php @@ -0,0 +1,82 @@ +assertInstanceOf( + MultiPaymentException::class, + UnsupportedOperationException::notImplemented('stripe', Capability::BANK_SLIP) + ); + } + + public function testNotImplementedAttributesTheGapToTheLibrary(): void + { + $exception = UnsupportedOperationException::notImplemented('stripe', Capability::BANK_SLIP); + + $this->assertSame(Capability::BANK_SLIP, $exception->capability); + $this->assertSame('stripe', $exception->gateway); + $this->assertSame('not_implemented', $exception->reason); + $this->assertTrue($exception->isNotImplemented()); + $this->assertSame( + 'A capability [bank_slip] ainda não está implementada nesta lib para o gateway stripe; o gateway oferece o recurso.', + $exception->getMessage() + ); + $this->assertNull($exception->httpStatus); + } + + public function testGatewayLimitationAttributesTheGapToTheGateway(): void + { + $exception = UnsupportedOperationException::gatewayLimitation('iugu', Capability::PARTIAL_REFUND_PIX, 'Repita sem valor parcial.'); + + $this->assertSame(Capability::PARTIAL_REFUND_PIX, $exception->capability); + $this->assertSame('iugu', $exception->gateway); + $this->assertSame('gateway_limitation', $exception->reason); + $this->assertFalse($exception->isNotImplemented()); + $this->assertSame( + 'O gateway iugu não oferece a capability [partial_refund_pix]. Repita sem valor parcial.', + $exception->getMessage() + ); + } + + public function testRestrictedKeepsTheCapabilityAndUsesTheGivenMessage(): void + { + $exception = UnsupportedOperationException::restricted('stripe', Capability::INVOICE_DUPLICATION, 'Só Pix pendente.'); + + $this->assertSame('Só Pix pendente.', $exception->getMessage()); + $this->assertSame(Capability::INVOICE_DUPLICATION, $exception->capability); + $this->assertSame('gateway_limitation', $exception->reason); + } + + public function testForGatewayReadsTheReasonFromTheDeclaration(): void + { + $gateway = Mockery::mock(GatewayContract::class); + $gateway->shouldReceive('notYetImplemented')->andReturn([Capability::BANK_SLIP]); + $gateway->shouldReceive('__toString')->andReturn('falso'); + + $notImplemented = UnsupportedOperationException::forGateway($gateway, Capability::BANK_SLIP); + $limitation = UnsupportedOperationException::forGateway($gateway, Capability::INSTALLMENTS, 'Detalhe.'); + + $this->assertSame('not_implemented', $notImplemented->reason); + $this->assertSame('falso', $notImplemented->gateway); + $this->assertSame('gateway_limitation', $limitation->reason); + $this->assertStringEndsWith(' Detalhe.', $limitation->getMessage()); + } + +} diff --git a/tests/Unit/Exceptions/ValidationExceptionTest.php b/tests/Unit/Exceptions/ValidationExceptionTest.php new file mode 100644 index 0000000..4753258 --- /dev/null +++ b/tests/Unit/Exceptions/ValidationExceptionTest.php @@ -0,0 +1,73 @@ + ['inválido']], + ['email' => ['inválido']], + $previous, + 422 + ); + + $this->assertInstanceOf(GatewayException::class, $exception); + $this->assertSame(['email' => ['inválido']], $exception->fieldErrors); + $this->assertSame(['email' => ['inválido']], $exception->getErrors()); + $this->assertSame('Error creating customer - email.0: inválido', $exception->getMessage()); + $this->assertSame($previous, $exception->getPrevious()); + $this->assertSame(422, $exception->httpStatus); + } + + public function testFieldErrorsAreEmptyByDefault(): void + { + $this->assertSame([], (new ValidationException('erro'))->fieldErrors); + } + + #[DataProvider('normalizeProvider')] + public function testNormalizeFieldErrors($errors, array $expected): void + { + $this->assertSame($expected, ValidationException::normalizeFieldErrors($errors)); + } + + public static function normalizeProvider(): array + { + return [ + 'nulo' => [null, []], + 'string vazia' => ['', []], + 'array vazio' => [[], []], + 'string' => ['Unauthorized', ['base' => ['Unauthorized']]], + 'objeto por campo (formato do SDK da Iugu)' => [ + ['email' => ['não é válido', 'já está em uso'], 'cpf_cnpj' => 'inválido'], + ['email' => ['não é válido', 'já está em uso'], 'cpf_cnpj' => ['inválido']], + ], + 'stdClass por campo' => [ + (object) ['email' => ['não é válido']], + ['email' => ['não é válido']], + ], + 'lista sem campo' => [ + ['erro um', 'erro dois'], + ['base' => ['erro um', 'erro dois']], + ], + 'lista de objetos com message (Pix Automático)' => [ + [(object) ['message' => 'Pagamento não pode ser cancelado'], ['message' => 'Outro']], + ['base' => ['Pagamento não pode ser cancelado', 'Outro']], + ], + 'valor que não é texto vira JSON' => [ + ['amount' => [['min' => 100]]], + ['amount' => ['{"min":100}']], + ], + 'escalar que não é string' => [42, ['base' => ['42']]], + ]; + } +} diff --git a/tests/Unit/Gateways/GatewayCapabilitiesTest.php b/tests/Unit/Gateways/GatewayCapabilitiesTest.php new file mode 100644 index 0000000..a5e5e80 --- /dev/null +++ b/tests/Unit/Gateways/GatewayCapabilitiesTest.php @@ -0,0 +1,311 @@ +instance('config', new Repository([ + 'multi-payment.gateways.iugu.api_key' => 'iugu-key', + 'multi-payment.gateways.stripe.api_key' => 'sk_test_fake', + ])); + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + #[DataProvider('matrixProvider')] + public function testDriverDeclaresTheExpectedSupport(string $gateway, Capability $capability, string $expected): void + { + $driver = self::driver($gateway); + + $this->assertSame($expected, CapabilitiesTable::cell($driver, $capability)); + $this->assertSame( + in_array($expected, [self::SUPPORTED, self::EMULATED], true), + $driver->supports($capability) + ); + $this->assertSame($expected === self::EMULATED, $driver->isEmulated($capability)); + } + + /** + * Uma célula por driver e capability. Linha nova em `Capability` sem entrada aqui falha + * em `testMatrixCoversEveryCapabilityForEveryDriver`. + */ + public static function matrixProvider(): array + { + $matrix = [ + // iugu stripe + Capability::CREDIT_CARD->name => [self::SUPPORTED, self::SUPPORTED], + Capability::PIX->name => [self::SUPPORTED, self::SUPPORTED], + Capability::BANK_SLIP->name => [self::SUPPORTED, self::SUPPORTED], + Capability::AUTOMATIC_PIX->name => [self::SUPPORTED, self::SUPPORTED], + Capability::MULTIPLE_PAYMENT_METHODS->name => [self::SUPPORTED, self::NOT_IMPLEMENTED], + Capability::RAW_CARD_DATA->name => [self::SUPPORTED, self::LIMITATION], + Capability::CARD_SETUP_AUTHENTICATION->name => [self::LIMITATION, self::SUPPORTED], + Capability::INSTALLMENTS->name => [self::SUPPORTED, self::LIMITATION], + Capability::DELAYED_CAPTURE->name => [self::NOT_IMPLEMENTED, self::NOT_IMPLEMENTED], + Capability::PARTIAL_REFUND_CARD->name => [self::SUPPORTED, self::SUPPORTED], + Capability::PARTIAL_REFUND_PIX->name => [self::LIMITATION, self::SUPPORTED], + Capability::REFUND_BANK_SLIP->name => [self::LIMITATION, self::LIMITATION], + Capability::INVOICE_DUPLICATION->name => [self::SUPPORTED, self::SUPPORTED], + Capability::INVOICE_CANCELLATION->name => [self::SUPPORTED, self::SUPPORTED], + Capability::IDEMPOTENCY->name => [self::SUPPORTED, self::SUPPORTED], + Capability::IDEMPOTENCY_ALL_ENDPOINTS->name => [self::LIMITATION, self::SUPPORTED], + Capability::SUBSCRIPTIONS->name => [self::SUPPORTED, self::SUPPORTED], + Capability::PLANS->name => [self::SUPPORTED, self::SUPPORTED], + Capability::PLAN_DEACTIVATION->name => [self::LIMITATION, self::SUPPORTED], + Capability::CANCEL_AT_PERIOD_END->name => [self::EMULATED, self::SUPPORTED], + Capability::COUPONS->name => [self::EMULATED, self::SUPPORTED], + Capability::PERCENT_DISCOUNT->name => [self::LIMITATION, self::SUPPORTED], + Capability::PLAN_CHANGE_PRORATION->name => [self::LIMITATION, self::SUPPORTED], + Capability::SUBSCRIPTION_CREDITS->name => [self::NOT_IMPLEMENTED, self::LIMITATION], + Capability::MANAGES_RECURRENCE->name => [self::LIMITATION, self::SUPPORTED], + ]; + + $cases = []; + foreach ($matrix as $name => [$iugu, $stripe]) { + $capability = constant(Capability::class . '::' . $name); + $cases["iugu {$name}"] = ['iugu', $capability, $iugu]; + $cases["stripe {$name}"] = ['stripe', $capability, $stripe]; + } + + return $cases; + } + + public function testMatrixCoversEveryCapabilityForEveryDriver(): void + { + $this->assertCount(count(Capability::cases()) * 2, self::matrixProvider()); + } + + #[DataProvider('driverProvider')] + public function testCapabilitiesNotYetImplementedAndEmulatedDoNotOverlap(string $gateway): void + { + $driver = self::driver($gateway); + + $overlap = array_filter( + $driver->capabilities(), + static fn (Capability $capability) => in_array($capability, $driver->notYetImplemented(), true) + || in_array($capability, $driver->emulated(), true) + ); + $emulatedOverlap = array_filter( + $driver->emulated(), + static fn (Capability $capability) => in_array($capability, $driver->notYetImplemented(), true) + ); + + $this->assertSame([], $overlap); + $this->assertSame([], $emulatedOverlap); + $this->assertSame($driver->capabilities(), array_values(array_unique($driver->capabilities(), SORT_REGULAR))); + } + + /** + * A capability de assinaturas (ou planos) só é declarada por driver que implementa o + * contract correspondente, e todo driver que implementa o contract a declara. + */ + #[DataProvider('driverProvider')] + public function testSubscriptionAndPlanCapabilitiesMatchTheContracts(string $gateway): void + { + $driver = self::driver($gateway); + + $this->assertSame($driver instanceof SubscriptionContract, $driver->supports(Capability::SUBSCRIPTIONS)); + $this->assertSame($driver instanceof PlanContract, $driver->supports(Capability::PLANS)); + } + + public static function driverProvider(): array + { + return ['iugu' => ['iugu'], 'stripe' => ['stripe']]; + } + + /** + * O README publica a saída de `composer capabilities:table`; declaração nova sem regerar + * a tabela falha aqui. + */ + public function testReadmeContainsTheGeneratedTable(): void + { + $table = CapabilitiesTable::markdown(['iugu' => self::driver('iugu'), 'stripe' => self::driver('stripe')]); + $readme = file_get_contents(__DIR__ . '/../../../README.md'); + + $this->assertStringContainsString($table, $readme, 'README desatualizado: rode `composer capabilities:table` e cole a saída na seção Capabilities'); + } + + public function testTableHasOneRowPerCapabilityOneColumnPerGatewayAndARestrictionsColumn(): void + { + $table = CapabilitiesTable::markdown(['iugu' => self::driver('iugu'), 'stripe' => self::driver('stripe')]); + $lines = explode("\n", trim($table)); + + $this->assertSame('| Capability | Significado | Iugu | Stripe | Restrições |', $lines[0]); + $this->assertSame('|---|---|---|---|---|', $lines[1]); + $this->assertCount(count(Capability::cases()) + 2, $lines); + $this->assertStringStartsWith('| `CREDIT_CARD` | Fatura paga com cartão de crédito. | sim | sim | Stripe: ', $lines[2]); + $this->assertStringEndsWith('| sim | sim | |', $lines[3], 'PIX não tem restrição em nenhum gateway'); + } + + /** + * Cada restrição declarada aparece na coluna com o nome do gateway; capability sem restrição + * deixa a célula vazia. + */ + public function testRestrictionsCellListsEveryGatewayThatRestrictsTheCapability(): void + { + $gateways = ['iugu' => self::driver('iugu'), 'stripe' => self::driver('stripe')]; + + $this->assertSame('', CapabilitiesTable::restrictionsCell($gateways, Capability::PIX)); + $this->assertStringStartsWith('Iugu: ', CapabilitiesTable::restrictionsCell($gateways, Capability::INSTALLMENTS)); + $this->assertStringStartsWith('Stripe: ', CapabilitiesTable::restrictionsCell($gateways, Capability::INVOICE_DUPLICATION)); + } + + #[DataProvider('restrictionProvider')] + public function testDriverDeclaresTheExpectedRestriction(string $gateway, Capability $capability, ?array $expected): void + { + $restriction = self::driver($gateway)->restriction($capability); + + if (is_null($expected)) { + $this->assertNull($restriction); + + return; + } + + $this->assertNotNull($restriction); + $this->assertNotSame('', $restriction->description); + $this->assertSame($expected['payment_methods'] ?? null, $restriction->allowedPaymentMethods); + $this->assertSame($expected['brands'] ?? null, $restriction->allowedBrands); + $this->assertSame($expected['max_installments'] ?? null, $restriction->maxInstallments); + } + + public static function restrictionProvider(): array + { + return [ + 'iugu parcelamento' => ['iugu', Capability::INSTALLMENTS, ['max_installments' => 12]], + 'iugu cartão sem restrição' => ['iugu', Capability::CREDIT_CARD, null], + 'iugu duplicação sem restrição' => ['iugu', Capability::INVOICE_DUPLICATION, null], + 'stripe bandeiras' => ['stripe', Capability::CREDIT_CARD, ['brands' => ['visa', 'mastercard']]], + 'stripe duplicação só pix' => ['stripe', Capability::INVOICE_DUPLICATION, ['payment_methods' => [PaymentMethod::PIX]]], + 'stripe cancelamento de rascunho' => ['stripe', Capability::INVOICE_CANCELLATION, []], + 'stripe nextBillingAt só na criação' => ['stripe', Capability::SUBSCRIPTIONS, []], + 'stripe cupom dura meses inteiros' => ['stripe', Capability::COUPONS, []], + 'iugu cupom sem restrição' => ['iugu', Capability::COUPONS, null], + 'stripe pix sem restrição' => ['stripe', Capability::PIX, null], + ]; + } + + /** + * Uma restrição só faz sentido sobre uma capability suportada: célula "não implementado" ou + * "limitação do gateway" não pode ter restrição. + */ + #[DataProvider('driverProvider')] + public function testRestrictionsOnlyCoverSupportedCapabilities(string $gateway): void + { + $driver = self::driver($gateway); + + foreach ($driver->restrictions() as $value => $restriction) { + $capability = Capability::from($value); + $this->assertTrue($driver->supports($capability), "{$gateway} restringe {$capability->name} sem suportá-la"); + $this->assertEquals($restriction, $driver->restriction($capability)); + } + } + + /** + * O máximo de parcelas da Iugu vem da configuração da conta, com 12 como padrão. + */ + public function testIuguMaxInstallmentsComesFromTheConfiguration(): void + { + Facade::getFacadeApplication()['config']->set('multi-payment.gateways.iugu.max_installments', 6); + + $restriction = self::driver('iugu')->restriction(Capability::INSTALLMENTS); + + $this->assertSame(6, $restriction->maxInstallments); + $this->assertStringContainsString('até 6', $restriction->description); + } + + #[DataProvider('driverProvider')] + public function testSupportsAllRequiresEveryCapability(string $gateway): void + { + $driver = self::driver($gateway); + + $this->assertTrue($driver->supportsAll()); + $this->assertTrue($driver->supportsAll(Capability::CREDIT_CARD, Capability::PIX)); + $this->assertFalse($driver->supportsAll(Capability::CREDIT_CARD, Capability::REFUND_BANK_SLIP)); + } + + /** + * A fachada expõe `supportsAll()`, `restriction()` e `restrictions()` do gateway. + */ + public function testTheFacadeExposesSupportsAllAndTheRestrictions(): void + { + Facade::getFacadeApplication()['config']->set('multi-payment.gateways.iugu.class', IuguGateway::class); + Facade::getFacadeApplication()['config']->set('multi-payment.gateways.stripe.class', StripeGateway::class); + + $payment = new MultiPayment('stripe'); + + $this->assertTrue($payment->supportsAll(Capability::CREDIT_CARD, Capability::PIX)); + $this->assertFalse($payment->supportsAll(Capability::CREDIT_CARD, Capability::DELAYED_CAPTURE)); + $this->assertSame(['visa', 'mastercard'], $payment->restriction(Capability::CREDIT_CARD)->allowedBrands); + $this->assertNull($payment->restriction(Capability::PIX)); + $this->assertSame(12, $payment->restriction(Capability::INSTALLMENTS, 'iugu')->maxInstallments); + $this->assertArrayHasKey(Capability::INVOICE_DUPLICATION->value, $payment->restrictions()); + $this->assertArrayHasKey(Capability::INSTALLMENTS->value, $payment->restrictions('iugu')); + } + + /** + * A fachada expõe `emulated()` e `isEmulated()` do gateway, e o nome antigo + * `Capability::NATIVE_COUPONS` resolve para o mesmo caso `COUPONS`. + */ + public function testTheFacadeExposesTheEmulatedListAndTheLegacyCouponsName(): void + { + Facade::getFacadeApplication()['config']->set('multi-payment.gateways.iugu.class', IuguGateway::class); + Facade::getFacadeApplication()['config']->set('multi-payment.gateways.stripe.class', StripeGateway::class); + + $payment = new MultiPayment('stripe'); + + $this->assertSame([], $payment->emulated()); + $this->assertFalse($payment->isEmulated(Capability::COUPONS)); + $this->assertSame( + [Capability::COUPONS, Capability::CANCEL_AT_PERIOD_END], + $payment->emulated('iugu') + ); + $this->assertTrue($payment->isEmulated(Capability::CANCEL_AT_PERIOD_END, 'iugu')); + $this->assertTrue($payment->supports(Capability::COUPONS, 'iugu')); + $this->assertSame(Capability::COUPONS, Capability::NATIVE_COUPONS); + } + + private static function driver(string $gateway): GatewayContract&DeclaresCapabilities + { + return match ($gateway) { + 'iugu' => new IuguGateway(new QueuedIuguApiRequest([])), + 'stripe' => new StripeGateway(), + }; + } +} diff --git a/tests/Unit/Gateways/Iugu/DeclineCodesTest.php b/tests/Unit/Gateways/Iugu/DeclineCodesTest.php new file mode 100644 index 0000000..1cb1811 --- /dev/null +++ b/tests/Unit/Gateways/Iugu/DeclineCodesTest.php @@ -0,0 +1,138 @@ +assertSame($expected, DeclineCodes::toDeclineCode($lr)); + } + + public static function lrProvider(): array + { + return [ + '51' => ['51', DeclineCode::INSUFFICIENT_FUNDS], + '61' => ['61', DeclineCode::INSUFFICIENT_FUNDS], + '70' => ['70', DeclineCode::INSUFFICIENT_FUNDS], + 'DM' => ['DM', DeclineCode::INSUFFICIENT_FUNDS], + '54' => ['54', DeclineCode::EXPIRED_CARD], + '14' => ['14', DeclineCode::INCORRECT_NUMBER], + '25' => ['25', DeclineCode::INCORRECT_NUMBER], + '12' => ['12', DeclineCode::INVALID_CARD], + '56' => ['56', DeclineCode::INVALID_CARD], + 'BM' => ['BM', DeclineCode::INVALID_CARD], + 'G4' => ['G4', DeclineCode::INVALID_CARD], + '4' => ['4', DeclineCode::LOST_OR_STOLEN], + '41' => ['41', DeclineCode::LOST_OR_STOLEN], + '43' => ['43', DeclineCode::LOST_OR_STOLEN], + '62' => ['62', DeclineCode::LOST_OR_STOLEN], + '7' => ['7', DeclineCode::FRAUD_SUSPECTED], + '59' => ['59', DeclineCode::FRAUD_SUSPECTED], + 'AF01' => ['AF01', DeclineCode::FRAUD_SUSPECTED], + 'BP171' => ['BP171', DeclineCode::FRAUD_SUSPECTED], + 'AI' => ['AI', DeclineCode::AUTHENTICATION_REQUIRED], + '57' => ['57', DeclineCode::BRAND_NOT_SUPPORTED], + '39' => ['39', DeclineCode::BRAND_NOT_SUPPORTED], + 'C1' => ['C1', DeclineCode::BRAND_NOT_SUPPORTED], + '5' => ['5', DeclineCode::DO_NOT_HONOR], + '63' => ['63', DeclineCode::DO_NOT_HONOR], + '100' => ['100', DeclineCode::DO_NOT_HONOR], + 'FC' => ['FC', DeclineCode::DO_NOT_HONOR], + 'R0' => ['R0', DeclineCode::DO_NOT_HONOR], + '91' => ['91', DeclineCode::TRY_AGAIN], + '96' => ['96', DeclineCode::TRY_AGAIN], + '99A' => ['99A', DeclineCode::TRY_AGAIN], + '911' => ['911', DeclineCode::TRY_AGAIN], + 'BP902' => ['BP902', DeclineCode::TRY_AGAIN], + '13' => ['13', DeclineCode::GENERIC], + '94' => ['94', DeclineCode::GENERIC], + ]; + } + + public function testLookupIgnoresCase(): void + { + $this->assertSame(DeclineCode::FRAUD_SUSPECTED, DeclineCodes::toDeclineCode('af02')); + } + + #[DataProvider('zeroPaddedProvider')] + public function testLookupIgnoresLeadingZerosOfNumericCodes(string $lr, DeclineCode $expected): void + { + $this->assertSame($expected, DeclineCodes::toDeclineCode($lr)); + } + + public static function zeroPaddedProvider(): array + { + return [ + '01' => ['01', DeclineCode::INVALID_CARD], + '04' => ['04', DeclineCode::LOST_OR_STOLEN], + '05' => ['05', DeclineCode::DO_NOT_HONOR], + '06' => ['06', DeclineCode::DO_NOT_HONOR], + '07' => ['07', DeclineCode::FRAUD_SUSPECTED], + '051 (três dígitos, não é 51)' => ['051', DeclineCode::INSUFFICIENT_FUNDS], + ]; + } + + public function testAllZerosStaysUnmapped(): void + { + // 0 e 00 são "transação autorizada" na tabela e nunca chegam numa recusa + $this->assertNull(DeclineCodes::toDeclineCode('00')); + } + + #[DataProvider('unmappedProvider')] + public function testUnmappedOrEmptyLrIsNull(?string $lr): void + { + $this->assertNull(DeclineCodes::toDeclineCode($lr)); + } + + public static function unmappedProvider(): array + { + return [ + 'nulo' => [null], + 'vazio' => [''], + 'senha (cartão presente)' => ['75'], + 'comerciante inválido' => ['3'], + 'inexistente' => ['ZZ9'], + ]; + } + + public function testExtractLrPrefersTheField(): void + { + $charge = (object) ['LR' => '51', 'info_message' => 'Fulano, Master, XXXXXXXXXXXX1234, LR: 05']; + + $this->assertSame('51', DeclineCodes::extractLr($charge)); + } + + public function testExtractLrNormalizesTheFieldToUpperCaseAndAcceptsInteger(): void + { + $this->assertSame('AF02', DeclineCodes::extractLr((object) ['LR' => ' af02 '])); + $this->assertSame('51', DeclineCodes::extractLr((object) ['LR' => 51])); + } + + #[DataProvider('messageProvider')] + public function testExtractLrReadsTheMessageWhenTheFieldIsAbsent(object $charge, ?string $expected): void + { + $this->assertSame($expected, DeclineCodes::extractLr($charge)); + } + + public static function messageProvider(): array + { + return [ + 'info_message com dois pontos' => [(object) ['info_message' => 'Fulano, Master, XXXXXXXXXXXX1234, LR: 05'], '05'], + 'info_message sem dois pontos' => [(object) ['info_message' => 'Transação não autorizada LR 51'], '51'], + 'message alfanumérico' => [(object) ['message' => 'Recusado (LR: af02)'], 'AF02'], + 'campo LR vazio e mensagem com código' => [(object) ['LR' => '', 'info_message' => 'LR: 54'], '54'], + 'sem código' => [(object) ['info_message' => 'Transação não autorizada'], null], + 'resposta vazia' => [(object) [], null], + ]; + } +} diff --git a/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php b/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php index 6ac3a2c..88b25f1 100644 --- a/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php +++ b/tests/Unit/Gateways/IuguGatewayAutomaticPixTest.php @@ -15,6 +15,7 @@ use Potelo\MultiPayment\Gateways\IuguGateway; use Potelo\MultiPayment\Exceptions\GatewayException; use Potelo\MultiPayment\Exceptions\ModelAttributeValidationException; +use Potelo\MultiPayment\Enums\InvoiceStatus; class IuguGatewayAutomaticPixTest extends TestCase { @@ -236,7 +237,7 @@ public function testCancelsInvoiceAndReturnsParsedInvoice(): void $this->assertSame('PUT', $apiRequest->method); $this->assertSame('/v1/invoices/invoice-id/cancel', parse_url($apiRequest->url, PHP_URL_PATH)); - $this->assertSame(Invoice::STATUS_CANCELED, $result->status); + $this->assertSame(InvoiceStatus::CANCELED, $result->status); $this->assertSame('invoice-id', $result->id); } @@ -308,7 +309,7 @@ public function __construct(private object|array $response) { } - public function request($method, $url, $data = []) + public function request($method, $url, $data = [], $headers = []) { $this->method = $method; $this->url = $url; diff --git a/tests/Unit/Gateways/IuguGatewayExceptionTranslationTest.php b/tests/Unit/Gateways/IuguGatewayExceptionTranslationTest.php new file mode 100644 index 0000000..9901774 --- /dev/null +++ b/tests/Unit/Gateways/IuguGatewayExceptionTranslationTest.php @@ -0,0 +1,960 @@ +logger = new RecordingLogger(); + $app = new Container(); + $app->instance('config', new Repository([ + 'multi-payment.gateways.iugu.api_key' => 'test-api-key', + 'multi-payment.gateways.iugu.id' => 'account-id', + 'multi-payment.environment' => 'testing', + ])); + $app->instance('log', $this->logger); + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + QueuedIuguApiRequest::restoreSdkRequester(); + unset($GLOBALS['iugu_last_api_response_code']); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testUnauthorizedJsonBodyBecomesAuthenticationException(): void + { + // a Iugu responde 401 com corpo JSON, então o SDK devolve a resposta em vez de lançar + $api = new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => 'Unauthorized'], 401), + ]); + + try { + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + $this->fail('Esperava AuthenticationException'); + } catch (AuthenticationException $e) { + $this->assertNotInstanceOf(GatewayNotAvailableException::class, $e); + $this->assertSame(401, $e->httpStatus); + $this->assertStringContainsString('Unauthorized', $e->getMessage()); + $this->assertNull($e->getPrevious()); + } + } + + public function testForbiddenJsonBodyBecomesAuthenticationException(): void + { + $api = new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => 'Forbidden'], 403), + ]); + + try { + (new IuguGateway($api))->cancelInvoice($this->invoiceWithId()); + $this->fail('Esperava AuthenticationException'); + } catch (AuthenticationException $e) { + $this->assertSame(403, $e->httpStatus); + $this->assertNull($e->getPrevious()); + $this->assertStringContainsString('Forbidden', $e->getMessage()); + } + } + + public function testUnauthorizedNonJsonResponseBecomesAuthenticationExceptionWithPrevious(): void + { + $original = new \IuguRequestException('401 Unauthorized', 401); + $api = new QueuedIuguApiRequest([$original]); + + try { + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + $this->fail('Esperava AuthenticationException'); + } catch (AuthenticationException $e) { + $this->assertSame($original, $e->getPrevious()); + $this->assertSame(401, $e->httpStatus); + } + } + + public function testMissingApiKeyBecomesAuthenticationExceptionNotGatewayNotAvailable(): void + { + $original = new \IuguAuthenticationException('Chave de API não configurada.'); + $api = new QueuedIuguApiRequest([$original]); + + try { + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + $this->fail('Esperava AuthenticationException'); + } catch (AuthenticationException $e) { + $this->assertNotInstanceOf(GatewayNotAvailableException::class, $e); + $this->assertSame($original, $e->getPrevious()); + $this->assertNull($e->httpStatus); + } + } + + #[DataProvider('serverErrorProvider')] + public function testServerErrorsAndTimeoutBecomeGatewayNotAvailableException(\Throwable $original, ?int $expectedStatus): void + { + $api = new QueuedIuguApiRequest([$original]); + + try { + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + $this->fail('Esperava GatewayNotAvailableException'); + } catch (GatewayNotAvailableException $e) { + $this->assertSame($original, $e->getPrevious()); + $this->assertSame($expectedStatus, $e->httpStatus); + } + } + + public static function serverErrorProvider(): array + { + return [ + '502 com página html do proxy' => [new \IuguRequestException('502 Bad Gateway', 502), 502], + '503 sem corpo json' => [new \IuguRequestException('Service Unavailable', 503), 503], + '500 sem corpo json' => [new \IuguRequestException('Internal Server Error', 500), 500], + // cURL sem resposta: corpo vazio e código 0 + 'timeout de rede' => [new \IuguRequestException('', 0), null], + ]; + } + + public function testRequestExceptionWithoutCodeButWithMessageIsNotReadAsNetworkFailure(): void + { + // fetchAPI() do SDK lança IuguRequestException('Iugu: ...') sem código para resposta com `error` + $original = new \IuguRequestException('Iugu: invoice unavailable'); + $api = new QueuedIuguApiRequest([$original]); + + try { + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + $this->fail('Esperava GatewayException'); + } catch (GatewayException $e) { + $this->assertNotInstanceOf(GatewayNotAvailableException::class, $e); + $this->assertSame($original, $e->getPrevious()); + $this->assertNull($e->httpStatus); + $this->assertStringContainsString('invoice unavailable', $e->getMessage()); + } + } + + public function testServerErrorWithJsonBodyBecomesGatewayNotAvailableException(): void + { + $api = new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => 'Service Unavailable'], 503), + ]); + + try { + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + $this->fail('Esperava GatewayNotAvailableException'); + } catch (GatewayNotAvailableException $e) { + $this->assertSame(503, $e->httpStatus); + $this->assertNull($e->getPrevious()); + } + } + + public function testNotFoundBecomesNotFoundExceptionWithStatusAndPrevious(): void + { + // fetchAPI() do SDK relança IuguObjectNotFound sem o código HTTP + $original = new \IuguObjectNotFound('invoice: not found'); + $api = new QueuedIuguApiRequest([$original]); + + try { + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + $this->fail('Esperava NotFoundException'); + } catch (NotFoundException $e) { + $this->assertInstanceOf(GatewayException::class, $e); + $this->assertNotInstanceOf(GatewayNotAvailableException::class, $e); + $this->assertSame($original, $e->getPrevious()); + $this->assertSame(404, $e->httpStatus); + } + } + + public function testNotFoundWithJsonBodyBecomesNotFoundException(): void + { + $api = new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => 'Not Found'], 404), + ]); + + try { + (new IuguGateway($api))->cancelInvoice($this->invoiceWithId()); + $this->fail('Esperava NotFoundException'); + } catch (NotFoundException $e) { + $this->assertSame(404, $e->httpStatus); + $this->assertSame(['Not Found'], $e->getErrors()); + } + } + + #[DataProvider('clientErrorProvider')] + public function testClientErrorsGetTheExceptionOfTheirStatus(int $status, string $expectedClass): void + { + $api = new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => ['base' => ['erro']]], $status), + ]); + + try { + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + $this->fail("Esperava {$expectedClass}"); + } catch (GatewayException $e) { + $this->assertInstanceOf($expectedClass, $e); + $this->assertNotInstanceOf(GatewayNotAvailableException::class, $e); + $this->assertNotInstanceOf(AuthenticationException::class, $e); + $this->assertSame($status, $e->httpStatus); + $this->assertSame(['base' => ['erro']], $e->getErrors()); + $this->assertNull($e->getPrevious()); + } + } + + public static function clientErrorProvider(): array + { + return [ + 'validação' => [422, ValidationException::class], + 'requisição inválida' => [400, ValidationException::class], + 'conflito de idempotência' => [409, IdempotencyConflictException::class], + 'rate limit' => [429, RateLimitException::class], + 'status sem classe própria' => [418, GatewayException::class], + ]; + } + + public function testRateLimitWithHtmlBodyBecomesRateLimitExceptionWithPrevious(): void + { + // 429 com página HTML: o SDK lança IuguRequestException com o status em getCode() + $original = new \IuguRequestException('429 Too Many Requests', 429); + $api = new QueuedIuguApiRequest([$original]); + + try { + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + $this->fail('Esperava RateLimitException'); + } catch (RateLimitException $e) { + $this->assertSame($original, $e->getPrevious()); + $this->assertSame(429, $e->httpStatus); + $this->assertNull($e->retryAfter); + } + } + + public function testValidationErrorsByFieldAreExposedInTheIuguObjectFormat(): void + { + $api = new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => [ + 'email' => ['não é válido', 'já está em uso'], + 'cpf_cnpj' => 'inválido', + ]], 422), + ]); + + try { + (new IuguGateway($api))->updateCustomer(self::customerWithId()); + $this->fail('Esperava ValidationException'); + } catch (ValidationException $e) { + $this->assertSame(422, $e->httpStatus); + $this->assertSame([ + 'email' => ['não é válido', 'já está em uso'], + 'cpf_cnpj' => ['inválido'], + ], $e->fieldErrors); + $this->assertSame(['email' => ['não é válido', 'já está em uso'], 'cpf_cnpj' => 'inválido'], $e->getErrors()); + } + } + + public function testValidationErrorWithNonJsonBodyUsesTheBodyAsBaseField(): void + { + // 422 sem JSON: o SDK lança IuguRequestException com o corpo cru e o status em getCode() + $original = new \IuguRequestException('422 Unprocessable Entity', 422); + $api = new QueuedIuguApiRequest([$original]); + + try { + (new IuguGateway($api))->cancelInvoice($this->invoiceWithId()); + $this->fail('Esperava ValidationException'); + } catch (ValidationException $e) { + $this->assertSame($original, $e->getPrevious()); + $this->assertSame(422, $e->httpStatus); + $this->assertSame(['base' => ['422 Unprocessable Entity']], $e->fieldErrors); + } + } + + public function testValidationErrorAsStringGoesToTheBaseField(): void + { + $api = new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => 'Fatura não pode ser cancelada'], 400), + ]); + + try { + (new IuguGateway($api))->cancelInvoice($this->invoiceWithId()); + $this->fail('Esperava ValidationException'); + } catch (ValidationException $e) { + $this->assertSame(400, $e->httpStatus); + $this->assertSame(['base' => ['Fatura não pode ser cancelada']], $e->fieldErrors); + } + } + + public function testJsonErrorBodyWithoutKnownStatusIsStillGatewayException(): void + { + // ramo defensivo: o SDK real grava o status em toda resposta decodificada, mas se ele + // faltar o gateway ainda respondeu, então a falha não pode ser lida como indisponibilidade + $api = new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => 'Not Found'], 0), + ]); + + try { + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + $this->fail('Esperava GatewayException'); + } catch (GatewayException $e) { + $this->assertNotInstanceOf(GatewayNotAvailableException::class, $e); + $this->assertNull($e->httpStatus); + } + } + + public function testUnexpectedExceptionBecomesGatewayExceptionWithPrevious(): void + { + $original = new \RuntimeException('json inesperado'); + $api = new QueuedIuguApiRequest([$original]); + + try { + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + $this->fail('Esperava GatewayException'); + } catch (GatewayException $e) { + $this->assertSame($original, $e->getPrevious()); + $this->assertNull($e->httpStatus); + $this->assertStringContainsString('json inesperado', $e->getMessage()); + } + } + + #[DataProvider('injectedRequesterFlowProvider')] + public function testEveryInjectedRequesterFlowAttachesThePreviousException(\Closure $operation): void + { + $original = new \IuguRequestException('502 Bad Gateway', 502); + $api = new QueuedIuguApiRequest([$original]); + + try { + $operation(new IuguGateway($api)); + $this->fail('Esperava GatewayNotAvailableException'); + } catch (GatewayNotAvailableException $e) { + $this->assertSame($original, $e->getPrevious()); + $this->assertSame(502, $e->httpStatus); + } + + $this->assertCount(1, $api->calls); + } + + public static function injectedRequesterFlowProvider(): array + { + $invoice = static function (): Invoice { + $invoice = new Invoice(); + $invoice->id = 'inv_1'; + + return $invoice; + }; + $subscription = static function (): Subscription { + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + return $subscription; + }; + + return [ + 'getInvoice' => [fn (IuguGateway $g) => $g->getInvoice($invoice())], + 'cancelInvoice' => [fn (IuguGateway $g) => $g->cancelInvoice($invoice())], + 'refundInvoice' => [function (IuguGateway $g) use ($invoice) { + $paid = $invoice(); + $paid->paymentMethod = PaymentMethod::CREDIT_CARD; + $paid->status = InvoiceStatus::PAID; + $paid->paidAt = Carbon::now(); + + return $g->refundInvoice($paid); + }], + 'rescheduleAutomaticPixPayment' => [fn (IuguGateway $g) => $g->rescheduleAutomaticPixPayment($invoice())], + 'getSubscription' => [fn (IuguGateway $g) => $g->getSubscription($subscription())], + 'suspendSubscription' => [fn (IuguGateway $g) => $g->suspendSubscription($subscription())], + 'getPlan' => [function (IuguGateway $g) { + $plan = new Plan(); + $plan->identifier = 'plano_mensal'; + + return $g->getPlan($plan); + }], + 'duplicateInvoice' => [fn (IuguGateway $g) => $g->duplicateInvoice($invoice(), Carbon::parse('2026-10-01'))], + 'updateCustomer' => [function (IuguGateway $g) { + $customer = self::customerWithId(); + + return $g->updateCustomer($customer); + }], + 'deleteCreditCard' => [fn (IuguGateway $g) => $g->deleteCreditCard(self::savedCreditCard())], + ]; + } + + #[DataProvider('staticSdkFlowProvider')] + public function testEveryStaticSdkFlowAttachesThePreviousException(\Closure $operation, array $responsesBefore = []): void + { + $original = new \IuguRequestException('502 Bad Gateway', 502); + $api = (new QueuedIuguApiRequest([...$responsesBefore, $original]))->installAsSdkRequester(); + + try { + $operation(new IuguGateway($api)); + $this->fail('Esperava GatewayNotAvailableException'); + } catch (GatewayNotAvailableException $e) { + $this->assertSame($original, $e->getPrevious()); + $this->assertSame(502, $e->httpStatus); + } + + $this->assertCount(count($responsesBefore) + 1, $api->calls); + } + + public static function staticSdkFlowProvider(): array + { + return [ + 'createInvoice (Iugu_Invoice::create)' => [function (IuguGateway $g) { + $invoice = new Invoice(); + $invoice->customer = self::customerWithId(); + $invoice->availablePaymentMethods = [PaymentMethod::PIX]; + $item = new InvoiceItem(); + $item->description = 'Item'; + $item->price = 1000; + $item->quantity = 1; + $invoice->items = [$item]; + + return $g->createInvoice($invoice); + }], + 'chargeInvoiceWithCreditCard (Iugu_Charge::create)' => [function (IuguGateway $g) { + $invoice = new Invoice(); + $invoice->id = 'inv_1'; + $invoice->creditCard = self::savedCreditCard(); + + return $g->chargeInvoiceWithCreditCard($invoice); + }], + 'createCustomer (Iugu_Customer::create)' => [function (IuguGateway $g) { + $customer = self::customerWithId(); + $customer->id = null; + + return $g->createCustomer($customer); + }], + 'getCustomer (Iugu_Customer::fetch)' => [fn (IuguGateway $g) => $g->getCustomer(self::customerWithId())], + 'createCreditCard (token ok, Iugu_PaymentMethod::create falha)' => [ + function (IuguGateway $g) { + $creditCard = self::savedCreditCard(); + $creditCard->id = null; + $creditCard->token = 'tok_1'; + + return $g->createCreditCard($creditCard); + }, + ], + 'createCreditCard (Iugu_PaymentToken::create falha)' => [ + function (IuguGateway $g) { + $creditCard = self::savedCreditCard(); + $creditCard->id = null; + $creditCard->number = '4111111111111111'; + $creditCard->cvv = '123'; + $creditCard->firstName = 'Cliente'; + $creditCard->lastName = 'Teste'; + $creditCard->month = '12'; + $creditCard->year = '2030'; + + return $g->createCreditCard($creditCard); + }, + ], + 'getCreditCard (payment_methods()->fetch)' => [fn (IuguGateway $g) => $g->getCreditCard(self::savedCreditCard())], + ]; + } + + private static function customerWithId(): Customer + { + $customer = new Customer(); + $customer->id = 'cus_1'; + $customer->name = 'Cliente'; + $customer->email = 'cliente@example.com'; + $customer->taxDocument = '20176996915'; + + return $customer; + } + + private static function savedCreditCard(): CreditCard + { + $creditCard = new CreditCard(); + $creditCard->id = 'pm_1'; + $creditCard->customer = self::customerWithId(); + + return $creditCard; + } + + public function testStaticSdkResourceFailureIsTranslatedWithPrevious(): void + { + $original = new \IuguRequestException('502 Bad Gateway', 502); + $api = (new QueuedIuguApiRequest([$original]))->installAsSdkRequester(); + + try { + (new IuguGateway($api))->createCustomer($this->customerModel()); + $this->fail('Esperava GatewayNotAvailableException'); + } catch (GatewayNotAvailableException $e) { + $this->assertSame($original, $e->getPrevious()); + } + + $this->assertCount(1, $api->calls); + $this->assertSame('POST', $api->calls[0]['method']); + } + + public function testStaticSdkResourceUnauthorizedBodyBecomesAuthenticationException(): void + { + $api = (new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => 'Unauthorized'], 401), + ]))->installAsSdkRequester(); + + try { + (new IuguGateway($api))->createCustomer($this->customerModel()); + $this->fail('Esperava AuthenticationException'); + } catch (AuthenticationException $e) { + $this->assertSame(401, $e->httpStatus); + } + + $this->assertCount(1, $api->calls); + } + + public function testInvalidRawCardOnTokenizationBecomesValidationExceptionWithoutSecondRequest(): void + { + $api = (new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => ['number' => ['não é válido']]], 422), + ]))->installAsSdkRequester(); + + try { + (new IuguGateway($api))->createCreditCard($this->rawCreditCardModel()); + $this->fail('Esperava ValidationException'); + } catch (ValidationException $e) { + $this->assertSame(422, $e->httpStatus); + $this->assertSame(['number' => ['não é válido']], $e->getErrors()); + $this->assertSame(['number' => ['não é válido']], $e->fieldErrors); + $this->assertStringContainsString('payment token', $e->getMessage()); + } + + // só a tokenização foi tentada; o cartão não chegou a ser salvo no cliente + $this->assertCount(1, $api->calls); + $this->assertStringEndsWith('/payment_token', $api->calls[0]['url']); + } + + public function testSdkExceptionDuringTokenizationDoesNotEscapeThePackage(): void + { + $original = new \IuguRequestException('502 Bad Gateway', 502); + $api = (new QueuedIuguApiRequest([$original]))->installAsSdkRequester(); + + try { + (new IuguGateway($api))->createCreditCard($this->rawCreditCardModel()); + $this->fail('Esperava exceção do pacote'); + } catch (MultiPaymentException $e) { + $this->assertInstanceOf(GatewayNotAvailableException::class, $e); + $this->assertSame($original, $e->getPrevious()); + } + } + + public function testTokenizationResponseWithoutIdBecomesGatewayException(): void + { + $api = (new QueuedIuguApiRequest([(object) ['method' => 'credit_card']]))->installAsSdkRequester(); + + try { + (new IuguGateway($api))->createCreditCard($this->rawCreditCardModel()); + $this->fail('Esperava GatewayException'); + } catch (GatewayException $e) { + // resposta 200 sem token: o status vem da global gravada pelo requester + $this->assertSame(200, $e->httpStatus); + } + + // sem token não há tentativa de salvar o cartão no cliente + $this->assertCount(1, $api->calls); + } + + public function testSuccessfulTokenizationUsesTheReturnedIdAsToken(): void + { + $api = (new QueuedIuguApiRequest([ + (object) ['id' => 'tok_1', 'method' => 'credit_card'], + (object) [ + 'id' => 'pm_1', + 'description' => 'CREDIT CARD', + 'data' => (object) ['brand' => 'VISA', 'display_number' => 'XXXX-XXXX-XXXX-4242', 'month' => 12, 'year' => 2030], + ], + ]))->installAsSdkRequester(); + + $creditCard = (new IuguGateway($api))->createCreditCard($this->rawCreditCardModel()); + + $this->assertSame('tok_1', $creditCard->token); + $this->assertSame('pm_1', $creditCard->id); + $this->assertCount(2, $api->calls); + $this->assertSame('tok_1', $api->calls[1]['data']['token']); + } + + public function testFailureReadingTheInvoiceAfterAChargeDoesNotEscapeThePackage(): void + { + // Iugu_Charge::invoice() faz um GET separado; fetchAPI() relança IuguObjectNotFound + $api = (new QueuedIuguApiRequest([ + (object) ['success' => true, 'invoice_id' => 'inv_1'], + new \IuguObjectNotFound('{"errors":"Not Found"}', 404), + ]))->installAsSdkRequester(); + + $invoice = $this->invoiceWithId(); + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_1'; + + try { + (new IuguGateway($api))->chargeInvoiceWithCreditCard($invoice); + $this->fail('Esperava NotFoundException'); + } catch (NotFoundException $e) { + $this->assertInstanceOf(\IuguObjectNotFound::class, $e->getPrevious()); + $this->assertSame(404, $e->httpStatus); + } + + $this->assertCount(2, $api->calls); + } + + public function testDeclinedChargeBecomesChargingExceptionWithTheLrTranslated(): void + { + $api = (new QueuedIuguApiRequest([ + (object) ['success' => false, 'LR' => '51', 'info_message' => 'Saldo insuficiente'], + ]))->installAsSdkRequester(); + + try { + (new IuguGateway($api))->chargeInvoiceWithCreditCard($this->invoiceWithSavedCard()); + $this->fail('Esperava ChargingException'); + } catch (ChargingException $e) { + $this->assertInstanceOf(CardDeclinedException::class, $e); + $this->assertNotInstanceOf(GatewayException::class, $e); + $this->assertSame(200, $e->httpStatus); + $this->assertNull($e->getPrevious()); + $this->assertSame(DeclineCode::INSUFFICIENT_FUNDS, $e->declineCode); + $this->assertSame('51', $e->gatewayCode); + $this->assertTrue($e->retryable); + $this->assertSame('insufficient_funds', $e->reason); + $this->assertSame('51', $e->chargeResponse->LR); + $this->assertStringContainsString('iugu', $e->getMessage()); + $this->assertStringContainsString('Saldo insuficiente', $e->getMessage()); + } + + $this->assertSame([], $this->logger->records); + $this->assertCount(1, $api->calls); + } + + #[DataProvider('lrProvider')] + public function testLrIsTranslatedToDeclineCode(string $lr, DeclineCode $expected, bool $retryable): void + { + $api = (new QueuedIuguApiRequest([ + (object) ['success' => false, 'LR' => $lr, 'info_message' => 'Transação não autorizada'], + ]))->installAsSdkRequester(); + + try { + (new IuguGateway($api))->chargeInvoiceWithCreditCard($this->invoiceWithSavedCard()); + $this->fail('Esperava ChargingException'); + } catch (ChargingException $e) { + $this->assertSame($expected, $e->declineCode); + $this->assertSame($lr, $e->gatewayCode); + $this->assertSame($retryable, $e->retryable); + } + + $this->assertSame([], $this->logger->records); + } + + public static function lrProvider(): array + { + return [ + '51 saldo insuficiente' => ['51', DeclineCode::INSUFFICIENT_FUNDS, true], + '61 valor excedido' => ['61', DeclineCode::INSUFFICIENT_FUNDS, true], + '54 cartão vencido' => ['54', DeclineCode::EXPIRED_CARD, false], + '14 número inválido' => ['14', DeclineCode::INCORRECT_NUMBER, false], + '12 verifique os dados' => ['12', DeclineCode::INVALID_CARD, false], + '78 cartão não desbloqueado' => ['78', DeclineCode::INVALID_CARD, false], + '41 cartão perdido' => ['41', DeclineCode::LOST_OR_STOLEN, false], + '43 cartão roubado' => ['43', DeclineCode::LOST_OR_STOLEN, false], + '59 suspeita de fraude' => ['59', DeclineCode::FRAUD_SUSPECTED, false], + 'AF02 antifraude' => ['AF02', DeclineCode::FRAUD_SUSPECTED, false], + 'AI autenticação não realizada' => ['AI', DeclineCode::AUTHENTICATION_REQUIRED, false], + '57 não permitida para o cartão' => ['57', DeclineCode::BRAND_NOT_SUPPORTED, false], + 'AB função incorreta' => ['AB', DeclineCode::BRAND_NOT_SUPPORTED, false], + '5 contate a central' => ['5', DeclineCode::DO_NOT_HONOR, false], + '05 com zero à esquerda, como no exemplo oficial' => ['05', DeclineCode::DO_NOT_HONOR, false], + '93 não tente novamente' => ['93', DeclineCode::DO_NOT_HONOR, false], + 'R1 suspensão de recorrência' => ['R1', DeclineCode::DO_NOT_HONOR, false], + '91 emissor fora do ar' => ['91', DeclineCode::TRY_AGAIN, true], + '96 falha de sistema' => ['96', DeclineCode::TRY_AGAIN, true], + 'AA tempo excedido' => ['AA', DeclineCode::TRY_AGAIN, true], + '94 transação duplicada' => ['94', DeclineCode::GENERIC, false], + ]; + } + + public function testLrIsReadFromTheMessageWhenTheFieldIsAbsent(): void + { + $api = (new QueuedIuguApiRequest([ + (object) ['success' => false, 'info_message' => 'JOAO DA SILVA, Master, XXXXXXXXXXXX1234, LR: 54'], + ]))->installAsSdkRequester(); + + try { + (new IuguGateway($api))->chargeInvoiceWithCreditCard($this->invoiceWithSavedCard()); + $this->fail('Esperava ChargingException'); + } catch (ChargingException $e) { + $this->assertSame(DeclineCode::EXPIRED_CARD, $e->declineCode); + $this->assertSame('54', $e->gatewayCode); + } + } + + public function testUnmappedLrBecomesUnknownAndIsLoggedWithTheCode(): void + { + $api = (new QueuedIuguApiRequest([ + (object) ['success' => false, 'LR' => '75', 'info_message' => 'Excedidas tentativas de senha'], + ]))->installAsSdkRequester(); + + try { + (new IuguGateway($api))->chargeInvoiceWithCreditCard($this->invoiceWithSavedCard()); + $this->fail('Esperava ChargingException'); + } catch (ChargingException $e) { + $this->assertSame(DeclineCode::UNKNOWN, $e->declineCode); + $this->assertSame('75', $e->gatewayCode); + $this->assertFalse($e->retryable); + $this->assertSame('unknown', $e->reason); + } + + $this->assertCount(1, $this->logger->records); + $this->assertSame('info', $this->logger->records[0]['level']); + $this->assertSame(['gateway' => 'iugu', 'lr' => '75'], $this->logger->records[0]['context']); + } + + public function testDeclineWithoutLrBecomesUnknownWithoutCodeAndWithoutLog(): void + { + $api = (new QueuedIuguApiRequest([ + (object) ['success' => false, 'message' => 'Transação negada'], + ]))->installAsSdkRequester(); + + try { + (new IuguGateway($api))->chargeInvoiceWithCreditCard($this->invoiceWithSavedCard()); + $this->fail('Esperava ChargingException'); + } catch (ChargingException $e) { + $this->assertSame(DeclineCode::UNKNOWN, $e->declineCode); + $this->assertNull($e->gatewayCode); + $this->assertStringContainsString('Transação negada', $e->getMessage()); + } + + $this->assertSame([], $this->logger->records); + } + + private function invoiceWithSavedCard(): Invoice + { + $invoice = $this->invoiceWithId(); + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_1'; + + return $invoice; + } + + public function testDuplicateInvoiceGoesThroughTheInjectedRequesterAndTranslatesUnauthorized(): void + { + // Iugu_Invoice::duplicate() do SDK engole a exceção e devolve false; o driver faz o POST direto + $api = new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => 'Unauthorized'], 401), + ]); + + try { + (new IuguGateway($api))->duplicateInvoice($this->invoiceWithId(), Carbon::parse('2026-10-01')); + $this->fail('Esperava AuthenticationException'); + } catch (AuthenticationException $e) { + $this->assertSame(401, $e->httpStatus); + } + + $this->assertCount(1, $api->calls); + $this->assertSame('POST', $api->calls[0]['method']); + $this->assertStringEndsWith('/invoices/inv_1/duplicate', $api->calls[0]['url']); + $this->assertSame(['due_date' => '2026-10-01'], $api->calls[0]['data']); + } + + public function testDuplicateInvoiceParsesTheDuplicatedInvoice(): void + { + $api = new QueuedIuguApiRequest([$this->pendingInvoiceResponse('inv_2')]); + + $duplicated = (new IuguGateway($api))->duplicateInvoice( + $this->invoiceWithId(), + Carbon::parse('2026-10-01'), + ['ignore_due_email' => true] + ); + + $this->assertSame('inv_2', $duplicated->id); + $this->assertSame(InvoiceStatus::PENDING, $duplicated->status); + $this->assertSame(['ignore_due_email' => true, 'due_date' => '2026-10-01'], $api->calls[0]['data']); + } + + public function testUpdateCustomerGoesThroughTheInjectedRequesterAndTranslatesServerError(): void + { + // Iugu_Customer::save() do SDK engole a exceção e devolve false; o driver faz o PUT direto + $original = new \IuguRequestException('502 Bad Gateway', 502); + $api = new QueuedIuguApiRequest([$original]); + + $customer = $this->customerModel(); + $customer->id = 'cus_1'; + + try { + (new IuguGateway($api))->updateCustomer($customer); + $this->fail('Esperava GatewayNotAvailableException'); + } catch (GatewayNotAvailableException $e) { + $this->assertSame($original, $e->getPrevious()); + } + + $this->assertCount(1, $api->calls); + $this->assertSame('PUT', $api->calls[0]['method']); + $this->assertStringEndsWith('/customers/cus_1', $api->calls[0]['url']); + $this->assertSame('Cliente', $api->calls[0]['data']['name']); + $this->assertSame('20176996915', $api->calls[0]['data']['cpf_cnpj']); + } + + public function testUpdateCustomerParsesTheUpdatedCustomer(): void + { + $api = new QueuedIuguApiRequest([$this->iuguCustomerResponse(['name' => 'Cliente Novo'])]); + + $customer = $this->customerModel(); + $customer->id = 'cus_1'; + $customer->name = 'Cliente Novo'; + + $updated = (new IuguGateway($api))->updateCustomer($customer); + + $this->assertSame('cus_1', $updated->id); + $this->assertSame('Cliente Novo', $updated->name); + $this->assertSame('iugu', $updated->gateway); + $this->assertSame('2026-09-02T09:00:00-03:00', $updated->createdAt->toIso8601String()); + } + + public function testDeleteCreditCardGoesThroughTheInjectedRequesterAndTranslatesUnauthorized(): void + { + // Iugu_PaymentMethod::delete() do SDK engole a exceção e devolve false; o driver faz o DELETE direto + $api = new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => 'Unauthorized'], 401), + ]); + + $creditCard = new CreditCard(); + $creditCard->id = 'pm_1'; + $creditCard->customer = new Customer(); + $creditCard->customer->id = 'cus_1'; + + $this->expectException(AuthenticationException::class); + + try { + (new IuguGateway($api))->deleteCreditCard($creditCard); + } finally { + $this->assertCount(1, $api->calls); + $this->assertSame('DELETE', $api->calls[0]['method']); + $this->assertStringEndsWith('/customers/cus_1/payment_methods/pm_1', $api->calls[0]['url']); + } + } + + public function testDeleteCreditCardSucceedsSilentlyOnAValidResponse(): void + { + $api = new QueuedIuguApiRequest([(object) ['id' => 'pm_1', 'description' => 'CREDIT CARD']]); + + $creditCard = new CreditCard(); + $creditCard->id = 'pm_1'; + $creditCard->customer = new Customer(); + $creditCard->customer->id = 'cus_1'; + + (new IuguGateway($api))->deleteCreditCard($creditCard); + + $this->assertCount(1, $api->calls); + } + + private function pendingInvoiceResponse(string $id): object + { + return (object) [ + 'id' => $id, + 'status' => 'pending', + 'total_cents' => 10000, + 'paid_at' => null, + 'secure_url' => "https://faturas.iugu.com/{$id}", + 'taxes_paid_cents' => null, + 'created_at_iso' => '2026-09-02T09:00:00-03:00', + 'paid_cents' => 0, + 'refunded_cents' => 0, + 'due_date' => '2026-10-01', + 'payment_method' => null, + 'payable_with' => 'pix', + 'customer_id' => 'cus_1', + 'customer_name' => 'Cliente', + 'email' => 'cliente@example.com', + 'payer_phone' => null, + 'payer_phone_prefix' => null, + 'items' => [ + (object) ['description' => 'Item', 'price_cents' => 10000, 'quantity' => 1], + ], + 'payer_address_zip_code' => null, + 'bank_slip' => null, + 'pix' => null, + 'automatic_pix' => null, + 'credit_card_transaction' => null, + 'variables' => [], + ]; + } + + private function iuguCustomerResponse(array $overrides = []): object + { + return (object) array_merge([ + 'id' => 'cus_1', + 'name' => 'Cliente', + 'email' => 'cliente@example.com', + 'cpf_cnpj' => '20176996915', + 'phone' => null, + 'phone_prefix' => null, + 'created_at' => '2026-09-02T09:00:00-03:00', + 'custom_variables' => [], + 'default_payment_method_id' => null, + ], $overrides); + } + + private function invoiceWithId(): Invoice + { + $invoice = new Invoice(); + $invoice->id = 'inv_1'; + + return $invoice; + } + + private function customerModel(): Customer + { + $customer = new Customer(); + $customer->name = 'Cliente'; + $customer->email = 'cliente@example.com'; + $customer->taxDocument = '20176996915'; + + return $customer; + } + + private function rawCreditCardModel(): CreditCard + { + $creditCard = new CreditCard(); + $creditCard->customer = new Customer(); + $creditCard->customer->id = 'cus_1'; + $creditCard->number = '4111111111111111'; + $creditCard->cvv = '123'; + $creditCard->firstName = 'Cliente'; + $creditCard->lastName = 'Teste'; + $creditCard->month = '12'; + $creditCard->year = '2030'; + + return $creditCard; + } +} diff --git a/tests/Unit/Gateways/IuguGatewayIdempotencyTest.php b/tests/Unit/Gateways/IuguGatewayIdempotencyTest.php new file mode 100644 index 0000000..6806877 --- /dev/null +++ b/tests/Unit/Gateways/IuguGatewayIdempotencyTest.php @@ -0,0 +1,1022 @@ +app = new Container(); + $this->app->instance('config', new Repository([ + 'multi-payment.gateways.iugu.api_key' => 'test-api-key', + 'multi-payment.gateways.iugu.id' => 'account-id', + 'multi-payment.environment' => 'testing', + 'multi-payment.idempotency.ttl' => 3600, + ])); + Facade::setFacadeApplication($this->app); + Carbon::setTestNow('2026-09-02 12:00:00'); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + QueuedIuguApiRequest::restoreSdkRequester(); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + #[DataProvider('nativeEndpointProvider')] + public function testTheKeyGoesInTheHeaderOfTheEndpointsTheIuguSupports(\Closure $operation, array $responses, string $expectedPath): void + { + $api = new QueuedIuguApiRequest($responses); + $store = new InMemoryIdempotencyStore(); + + $operation(new IuguGateway($api, $store), 'chave-1'); + + $this->assertSame('POST', $api->calls[0]['method']); + $this->assertStringEndsWith($expectedPath, $api->calls[0]['url']); + $this->assertSame(['Idempotency-Key: chave-1'], $api->calls[0]['headers']); + $this->assertArrayNotHasKey('idempotency_key', $api->calls[0]['data']); + // o gateway deduplica sozinho: a store não é usada + $this->assertFalse($store->has('iugu:chave-1')); + + // nenhuma outra requisição da operação (a leitura da fatura cobrada) leva a chave + foreach (array_slice($api->calls, 1) as $call) { + $this->assertSame([], $call['headers']); + } + } + + public static function nativeEndpointProvider(): array + { + return [ + 'createInvoice pix (POST /invoices)' => [ + fn (IuguGateway $g, string $key) => $g->createInvoice(self::pixInvoiceModel(), $key), + [self::pendingInvoiceResponse()], + '/invoices', + ], + 'createInvoice com cartão salvo (POST /charge)' => [ + fn (IuguGateway $g, string $key) => $g->createInvoice(self::cardInvoiceModel(), $key), + [(object) ['success' => true, 'invoice_id' => 'inv_1'], self::pendingInvoiceResponse()], + '/charge', + ], + 'chargeInvoiceWithCreditCard (POST /charge)' => [ + function (IuguGateway $g, string $key) { + $invoice = self::invoiceWithId(); + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_1'; + + return $g->chargeInvoiceWithCreditCard($invoice, $key); + }, + [(object) ['success' => true, 'invoice_id' => 'inv_1'], self::pendingInvoiceResponse()], + '/charge', + ], + 'createCustomer (POST /customers)' => [ + fn (IuguGateway $g, string $key) => $g->createCustomer(self::customerModel(), $key), + [self::customerResponse()], + '/customers', + ], + 'createSubscription (POST /subscriptions)' => [ + function (IuguGateway $g, string $key) { + $subscription = new Subscription(); + $subscription->planId = 'plano_mensal'; + $subscription->customer = self::customerWithId(); + + return $g->createSubscription($subscription, $key); + }, + [self::subscriptionResponse()], + '/subscriptions', + ], + ]; + } + + /** + * A segunda chamada com a mesma chave devolve a resposta guardada: a requisição de escrita + * não se repete, só as leituras que a operação faz em volta dela. + */ + #[DataProvider('storeEndpointProvider')] + public function testTheKeyGoesThroughTheStoreOnTheEndpointsTheIuguDoesNotSupport( + \Closure $operation, + array $responses, + string $expectedMethod, + string $expectedPath, + array $responsesForTheRetry = [] + ): void { + $api = new QueuedIuguApiRequest(array_merge($responses, $responsesForTheRetry)); + $store = new InMemoryIdempotencyStore(); + $gateway = new IuguGateway($api, $store); + + $operation($gateway, 'chave-1'); + + $writes = array_filter( + $api->calls, + fn (array $call) => $call['method'] === $expectedMethod && str_ends_with(parse_url($call['url'], PHP_URL_PATH), $expectedPath) + ); + $this->assertCount(1, $writes, "esperava uma requisição {$expectedMethod} {$expectedPath}"); + $this->assertSame([], reset($writes)['headers']); + $this->assertArrayNotHasKey('idempotency_key', reset($writes)['data']); + $this->assertTrue($store->has('iugu:chave-1')); + + $operation($gateway, 'chave-1'); + + $writesAfterRetry = array_filter( + $api->calls, + fn (array $call) => $call['method'] === $expectedMethod && str_ends_with(parse_url($call['url'], PHP_URL_PATH), $expectedPath) + ); + $this->assertCount(1, $writesAfterRetry, 'a segunda chamada com a mesma chave não pode repetir a escrita'); + } + + public static function storeEndpointProvider(): array + { + $paidCardInvoice = self::pendingInvoiceResponse([ + 'status' => 'paid', + 'paid_at' => '2026-08-20T10:00:00-03:00', + 'paid_cents' => 10000, + 'payment_method' => 'iugu_credit_card', + 'payable_with' => 'credit_card', + ]); + $refundedInvoice = self::pendingInvoiceResponse([ + 'status' => 'refunded', + 'paid_at' => '2026-08-20T10:00:00-03:00', + 'paid_cents' => 0, + 'refunded_cents' => 10000, + 'payment_method' => 'iugu_credit_card', + ]); + + return [ + 'cancelInvoice (PUT /cancel)' => [ + fn (IuguGateway $g, string $key) => $g->cancelInvoice(self::invoiceWithId(), $key), + [self::pendingInvoiceResponse(['status' => 'canceled'])], + 'PUT', '/invoices/inv_1/cancel', + ], + 'refundInvoice (operação inteira guardada: nem a leitura prévia se repete)' => [ + fn (IuguGateway $g, string $key) => $g->refundInvoice(self::invoiceWithId(), null, $key), + [$paidCardInvoice, $refundedInvoice], + 'POST', '/invoices/inv_1/refund', + ], + 'duplicateInvoice (POST /duplicate)' => [ + fn (IuguGateway $g, string $key) => $g->duplicateInvoice(self::invoiceWithId(), Carbon::parse('2026-10-01'), [], $key), + [self::pendingInvoiceResponse(['id' => 'inv_2'])], + 'POST', '/invoices/inv_1/duplicate', + ], + 'rescheduleAutomaticPixPayment' => [ + fn (IuguGateway $g, string $key) => $g->rescheduleAutomaticPixPayment(self::invoiceWithId(), $key), + [self::pendingInvoiceResponse()], + 'POST', '/invoices/inv_1/reschedule_automatic_pix_payment', + ], + 'cancelAutomaticPixRecurrence' => [ + function (IuguGateway $g, string $key) { + $automaticPix = new AutomaticPix(); + $automaticPix->id = 'rec_1'; + + return $g->cancelAutomaticPixRecurrence($automaticPix, $key); + }, + [(object) ['cancellation_id' => 'can_1', 'status' => 'requested']], + 'PUT', '/automatic_pix/receiver_recurrences/rec_1/cancel', + ], + 'cancelAutomaticPixScheduledPayment' => [ + function (IuguGateway $g, string $key) { + $charge = new AutomaticPixCharge(); + $charge->id = 'pay_1'; + $charge->endToEndId = 'E123'; + + return $g->cancelAutomaticPixScheduledPayment($charge, $key); + }, + [(object) ['cancellation_id' => 'can_1', 'status' => 'requested']], + 'POST', '/automatic_pix/receiver_recurrence_payments/cancel', + ], + 'updateCustomer (PUT /customers/{id})' => [ + fn (IuguGateway $g, string $key) => $g->updateCustomer(self::customerWithId(), $key), + [self::customerResponse()], + 'PUT', '/customers/cus_1', + ], + 'setCustomerDefaultCard (PUT /customers/{id})' => [ + fn (IuguGateway $g, string $key) => $g->setCustomerDefaultCard(self::customerWithId(), 'pm_1', $key), + [self::customerResponse(['default_payment_method_id' => 'pm_1'])], + 'PUT', '/customers/cus_1', + ], + 'createCreditCard com token (POST /payment_methods)' => [ + fn (IuguGateway $g, string $key) => $g->createCreditCard(self::tokenizedCardModel(), $key), + [self::paymentMethodResponse()], + 'POST', '/customers/cus_1/payment_methods', + ], + 'deleteCreditCard (DELETE /payment_methods/{id})' => [ + fn (IuguGateway $g, string $key) => $g->deleteCreditCard(self::savedCardModel(), $key), + [(object) ['id' => 'pm_1']], + 'DELETE', '/customers/cus_1/payment_methods/pm_1', + ], + 'updateSubscription sem itens (PUT /subscriptions/{id})' => [ + function (IuguGateway $g, string $key) { + $subscription = self::subscriptionWithId(); + $subscription->metadata = ['origem' => 'teste']; + + return $g->updateSubscription($subscription, $key); + }, + [self::subscriptionResponse()], + 'PUT', '/subscriptions/sub_1', + ], + 'suspendSubscription (POST /suspend)' => [ + fn (IuguGateway $g, string $key) => $g->suspendSubscription(self::subscriptionWithId(), $key), + [self::subscriptionResponse(['suspended' => true])], + 'POST', '/subscriptions/sub_1/suspend', + ], + 'resumeSubscription (POST /activate)' => [ + fn (IuguGateway $g, string $key) => $g->resumeSubscription(self::subscriptionWithId(), $key), + [self::subscriptionResponse()], + 'POST', '/subscriptions/sub_1/activate', + ], + 'cancelSubscription (POST /suspend, mais o PUT da marca de cancelamento)' => [ + fn (IuguGateway $g, string $key) => $g->cancelSubscription(self::subscriptionWithId(), false, $key), + [ + self::subscriptionResponse(['suspended' => true]), + self::subscriptionResponse([ + 'suspended' => true, + 'custom_variables' => [(object) ['name' => 'mp_canceled_at', 'value' => '2026-09-02T10:00:00-03:00']], + ]), + ], + 'POST', '/subscriptions/sub_1/suspend', + ], + 'cancelSubscription (PUT da marca de cancelamento, chave derivada)' => [ + fn (IuguGateway $g, string $key) => $g->cancelSubscription(self::subscriptionWithId(), false, $key), + [ + self::subscriptionResponse(['suspended' => true]), + self::subscriptionResponse([ + 'suspended' => true, + 'custom_variables' => [(object) ['name' => 'mp_canceled_at', 'value' => '2026-09-02T10:00:00-03:00']], + ]), + ], + 'PUT', '/subscriptions/sub_1', + ], + 'cancelSubscription agendado (PUT das variáveis de agendamento)' => [ + function (IuguGateway $g, string $key) { + $subscription = self::subscriptionWithId(); + $subscription->nextBillingAt = Carbon::parse('2026-12-01'); + + return $g->cancelSubscription($subscription, true, $key); + }, + [self::subscriptionResponse(['custom_variables' => [ + (object) ['name' => 'mp_cancel_at_period_end', 'value' => '1'], + (object) ['name' => 'mp_cancel_scheduled_for', 'value' => '2026-12-01'], + ]])], + 'PUT', '/subscriptions/sub_1', + ], + 'changeSubscriptionPlan com cobrança (POST /change_plan, com a releitura repetida)' => [ + fn (IuguGateway $g, string $key) => $g->changeSubscriptionPlan(self::subscriptionWithId(), 'plano_anual', ProrationBehavior::CHARGE_DIFFERENCE, $key), + [(object) ['success' => true], self::subscriptionResponse(['plan_identifier' => 'plano_anual'])], + 'POST', '/subscriptions/sub_1/change_plan/plano_anual', + [self::subscriptionResponse(['plan_identifier' => 'plano_anual'])], + ], + 'changeSubscriptionPlan sem cobrança (PUT /subscriptions/{id})' => [ + fn (IuguGateway $g, string $key) => $g->changeSubscriptionPlan(self::subscriptionWithId(), 'plano_anual', ProrationBehavior::NONE, $key), + [self::subscriptionResponse(['plan_identifier' => 'plano_anual'])], + 'PUT', '/subscriptions/sub_1', + ], + 'createPlan (POST /plans)' => [ + function (IuguGateway $g, string $key) { + $plan = new Plan(); + $plan->name = 'Mensal'; + $plan->identifier = 'plano_mensal'; + $plan->amount = 10000; + $plan->interval = PlanInterval::MONTH; + + return $g->createPlan($plan, $key); + }, + [self::planResponse()], + 'POST', '/plans', + ], + ]; + } + + /** + * O retry do estorno com a mesma chave devolve o `Refund` da primeira execução sem nenhuma + * requisição: a leitura prévia encontraria a fatura já estornada e a guarda recusaria. + */ + public function testRefundRetryReturnsTheStoredRefundWithoutReadingTheInvoiceAgain(): void + { + $paid = self::pendingInvoiceResponse([ + 'status' => 'paid', + 'paid_at' => '2026-08-20T10:00:00-03:00', + 'paid_cents' => 10000, + 'payment_method' => 'iugu_credit_card', + ]); + $refunded = self::pendingInvoiceResponse([ + 'status' => 'refunded', + 'paid_at' => '2026-08-20T10:00:00-03:00', + 'paid_cents' => 0, + 'refunded_cents' => 10000, + 'payment_method' => 'iugu_credit_card', + ]); + $api = new QueuedIuguApiRequest([$paid, $refunded]); + $gateway = new IuguGateway($api, new InMemoryIdempotencyStore()); + + $first = $gateway->refundInvoice(self::invoiceWithId(), null, 'chave-1'); + $second = $gateway->refundInvoice(self::invoiceWithId(), null, 'chave-1'); + + $this->assertCount(2, $api->calls); + $this->assertSame(10000, $first->amount); + $this->assertSame($first, $second); + } + + /** + * O caminho antigo (valor escrito em `refundedAmount`) também vale com chave: o valor é + * resolvido antes de a operação entrar na store, e o retry devolve o mesmo `Refund`. + */ + #[IgnoreDeprecations] + public function testRefundWithAKeyStillHonoursTheLegacyRefundedAmount(): void + { + $paid = self::pendingInvoiceResponse([ + 'status' => 'paid', + 'paid_at' => '2026-08-20T10:00:00-03:00', + 'paid_cents' => 10000, + 'payment_method' => 'iugu_credit_card', + ]); + $partiallyRefunded = self::pendingInvoiceResponse([ + 'status' => 'partially_refunded', + 'paid_at' => '2026-08-20T10:00:00-03:00', + 'paid_cents' => 7500, + 'refunded_cents' => 2500, + 'payment_method' => 'iugu_credit_card', + ]); + $api = new QueuedIuguApiRequest([$paid, $partiallyRefunded]); + $gateway = new IuguGateway($api, new InMemoryIdempotencyStore()); + $invoice = self::invoiceWithId(); + $invoice->refundedAmount = 2500; + + $first = $gateway->refundInvoice($invoice, null, 'chave-legada'); + $second = $gateway->refundInvoice(self::invoiceWithId(), null, 'chave-legada'); + + $this->assertCount(2, $api->calls); + $this->assertSame(['partial_value_refund_cents' => 2500], $api->calls[1]['data']); + $this->assertSame(2500, $first->amount); + $this->assertSame($first, $second); + } + + public function testTheSameKeyOnAnotherOperationIsAConflict(): void + { + $api = new QueuedIuguApiRequest([ + self::pendingInvoiceResponse(['status' => 'canceled']), + self::pendingInvoiceResponse(['id' => 'inv_2']), + ]); + $gateway = new IuguGateway($api, new InMemoryIdempotencyStore()); + $gateway->cancelInvoice(self::invoiceWithId(), 'chave-1'); + + try { + $gateway->duplicateInvoice(self::invoiceWithId(), Carbon::parse('2026-10-01'), [], 'chave-1'); + $this->fail('Esperava IdempotencyConflictException'); + } catch (IdempotencyConflictException $e) { + $this->assertStringContainsString('[chave-1]', $e->getMessage()); + $this->assertStringContainsString('outra operação', $e->getMessage()); + } + + $this->assertCount(1, $api->calls); + } + + #[DataProvider('conflictWithoutResourceIdProvider')] + public function testAReusedKeyWithoutResourceIdOnAnInvoiceWriteSurfacesTheConflict(\Closure $operation): void + { + $api = new QueuedIuguApiRequest([self::iuguConflictResponse('processing')]); + + try { + $operation(new IuguGateway($api), 'chave-1'); + $this->fail('Esperava IdempotencyConflictException'); + } catch (IdempotencyConflictException $e) { + $this->assertNull($e->resourceId); + } + + $this->assertCount(1, $api->calls); + } + + public static function conflictWithoutResourceIdProvider(): array + { + return [ + 'POST /invoices' => [fn (IuguGateway $g, string $key) => $g->createInvoice(self::pixInvoiceModel(), $key)], + 'POST /charge' => [fn (IuguGateway $g, string $key) => $g->createInvoice(self::cardInvoiceModel(), $key)], + ]; + } + + /** + * A chave antiga em `gatewayOptions` fica fora do corpo em toda operação, e as demais + * opções continuam entrando. + */ + #[DataProvider('legacyKeyProvider')] + #[IgnoreDeprecations] + public function testTheLegacyKeyStaysOutOfTheBodyOnEveryOperation(\Closure $operation, array $responses, int $writeCall, ?string $expectedStoreKey): void + { + $api = new QueuedIuguApiRequest($responses); + $store = new InMemoryIdempotencyStore(); + + $operation(new IuguGateway($api, $store)); + + $data = $api->calls[$writeCall]['data']; + $this->assertArrayNotHasKey('idempotency_key', $data); + $this->assertSame(1, $data['outra']); + if (!is_null($expectedStoreKey)) { + $this->assertTrue($store->has($expectedStoreKey)); + } else { + $this->assertSame(['Idempotency-Key: chave-antiga'], $api->calls[$writeCall]['headers']); + } + } + + public static function legacyKeyProvider(): array + { + $legacy = ['idempotency_key' => 'chave-antiga', 'outra' => 1]; + + return [ + 'createCustomer' => [ + function (IuguGateway $g) use ($legacy) { + $customer = self::customerModel(); + $customer->gatewayOptions = $legacy; + + return $g->createCustomer($customer); + }, + [self::customerResponse()], 0, null, + ], + 'updateCustomer' => [ + function (IuguGateway $g) use ($legacy) { + $customer = self::customerWithId(); + $customer->gatewayOptions = $legacy; + + return $g->updateCustomer($customer); + }, + [self::customerResponse()], 0, 'iugu:chave-antiga', + ], + 'createSubscription' => [ + function (IuguGateway $g) use ($legacy) { + $subscription = new Subscription(); + $subscription->planId = 'plano_mensal'; + $subscription->customer = self::customerWithId(); + $subscription->gatewayOptions = $legacy; + + return $g->createSubscription($subscription); + }, + [self::subscriptionResponse()], 0, null, + ], + 'updateSubscription' => [ + function (IuguGateway $g) use ($legacy) { + $subscription = self::subscriptionWithId(); + $subscription->gatewayOptions = $legacy; + + return $g->updateSubscription($subscription); + }, + [self::subscriptionResponse()], 0, 'iugu:chave-antiga', + ], + 'createPlan' => [ + function (IuguGateway $g) use ($legacy) { + $plan = new Plan(); + $plan->name = 'Mensal'; + $plan->identifier = 'plano_mensal'; + $plan->amount = 10000; + $plan->interval = PlanInterval::MONTH; + $plan->gatewayOptions = $legacy; + + return $g->createPlan($plan); + }, + [self::planResponse()], 0, 'iugu:chave-antiga', + ], + 'duplicateInvoice (chave nas opções do argumento)' => [ + fn (IuguGateway $g) => $g->duplicateInvoice(self::invoiceWithId(), Carbon::parse('2026-10-01'), $legacy), + [self::pendingInvoiceResponse(['id' => 'inv_2'])], 0, 'iugu:chave-antiga', + ], + ]; + } + + public function testTheCardSavedBeforeAChargeUsesADerivedKeyInTheStore(): void + { + $api = new QueuedIuguApiRequest([ + self::paymentMethodResponse(), + (object) ['success' => true, 'invoice_id' => 'inv_1'], + self::pendingInvoiceResponse(), + ]); + $store = new InMemoryIdempotencyStore(); + + $invoice = self::cardInvoiceModel(); + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->token = 'tok_1'; + (new IuguGateway($api, $store))->createInvoice($invoice, 'chave-1'); + + $this->assertStringEndsWith('/customers/cus_1/payment_methods', $api->calls[0]['url']); + $this->assertSame([], $api->calls[0]['headers']); + $this->assertTrue($store->has('iugu:chave-1:card')); + $this->assertStringEndsWith('/charge', $api->calls[1]['url']); + $this->assertSame(['Idempotency-Key: chave-1'], $api->calls[1]['headers']); + $this->assertFalse($store->has('iugu:chave-1')); + } + + public function testTokenizationOfRawCardDataUsesADerivedKeyInTheStore(): void + { + $api = new QueuedIuguApiRequest([ + (object) ['id' => 'tok_1', 'method' => 'credit_card'], + self::paymentMethodResponse(), + ]); + $store = new InMemoryIdempotencyStore(); + + $creditCard = self::savedCardModel(); + $creditCard->id = null; + $creditCard->number = '4111111111111111'; + $creditCard->cvv = '123'; + $creditCard->firstName = 'Cliente'; + $creditCard->lastName = 'Teste'; + $creditCard->month = '12'; + $creditCard->year = '2030'; + (new IuguGateway($api, $store))->createCreditCard($creditCard, 'chave-1'); + + $this->assertStringEndsWith('/payment_token', $api->calls[0]['url']); + $this->assertStringEndsWith('/customers/cus_1/payment_methods', $api->calls[1]['url']); + $this->assertTrue($store->has('iugu:chave-1:token')); + $this->assertTrue($store->has('iugu:chave-1')); + } + + public function testRemovingSubscriptionItemsBeforeTheUpdateUsesADerivedKey(): void + { + $api = new QueuedIuguApiRequest([ + self::subscriptionResponse(['subitems' => [ + (object) ['id' => 'sub_item_velho', 'description' => 'Antigo', 'price_cents' => 1000, 'quantity' => 1, 'recurrent' => true], + ]]), + self::subscriptionResponse(), + self::subscriptionResponse(), + ]); + $store = new InMemoryIdempotencyStore(); + + $subscription = self::subscriptionWithId(); + $item = new SubscriptionItem(); + $item->description = 'Novo'; + $item->amount = 2500; + $subscription->items = [$item]; + (new IuguGateway($api, $store))->updateSubscription($subscription, 'chave-1'); + + $this->assertSame(['GET', 'PUT', 'PUT'], array_column($api->calls, 'method')); + $this->assertTrue($store->has('iugu:chave-1:remove')); + $this->assertTrue($store->has('iugu:chave-1')); + } + + public function testWithoutAKeyNoHeaderIsSentAndTheStoreIsNotTouched(): void + { + $api = new QueuedIuguApiRequest([self::pendingInvoiceResponse(), self::pendingInvoiceResponse(['status' => 'canceled'])]); + $store = new class implements IdempotencyStore { + public function remember(string $key, callable $operation, int $ttlSeconds): mixed + { + throw new \LogicException('a store não pode ser usada sem chave'); + } + + public function has(string $key): bool + { + return false; + } + }; + $gateway = new IuguGateway($api, $store); + + $gateway->createInvoice(self::pixInvoiceModel()); + $gateway->cancelInvoice(self::invoiceWithId()); + + $this->assertSame([[], []], array_column($api->calls, 'headers')); + } + + public function testAStoreEndpointWithAKeyButNoStoreConfiguredIsAConfigurationError(): void + { + $api = new QueuedIuguApiRequest([self::pendingInvoiceResponse()]); + + try { + (new IuguGateway($api))->cancelInvoice(self::invoiceWithId(), 'chave-1'); + $this->fail('Esperava ConfigurationException'); + } catch (ConfigurationException $e) { + $this->assertStringContainsString('IdempotencyStore', $e->getMessage()); + } + + $this->assertSame([], $api->calls); + } + + public function testANativeEndpointWithAKeyWorksWithoutAStore(): void + { + $api = new QueuedIuguApiRequest([self::pendingInvoiceResponse()]); + + $invoice = (new IuguGateway($api))->createInvoice(self::pixInvoiceModel(), 'chave-1'); + + $this->assertSame('inv_1', $invoice->id); + $this->assertSame(['Idempotency-Key: chave-1'], $api->calls[0]['headers']); + } + + public function testTheStoreIsResolvedFromTheContainerWhenNotInjected(): void + { + $store = new InMemoryIdempotencyStore(); + $this->app->instance(IdempotencyStore::class, $store); + $api = new QueuedIuguApiRequest([self::pendingInvoiceResponse(['status' => 'canceled'])]); + + (new IuguGateway($api))->cancelInvoice(self::invoiceWithId(), 'chave-1'); + + $this->assertTrue($store->has('iugu:chave-1')); + } + + public function testAConflictInTheStoreIsAnIdempotencyConflictExceptionWithoutARequest(): void + { + $api = new QueuedIuguApiRequest([self::pendingInvoiceResponse()]); + $store = new class implements IdempotencyStore { + public function remember(string $key, callable $operation, int $ttlSeconds): mixed + { + throw IdempotencyConflictException::concurrent($key); + } + + public function has(string $key): bool + { + return false; + } + }; + + try { + (new IuguGateway($api, $store))->cancelInvoice(self::invoiceWithId(), 'chave-1'); + $this->fail('Esperava IdempotencyConflictException'); + } catch (IdempotencyConflictException $e) { + $this->assertStringContainsString('[iugu:chave-1]', $e->getMessage()); + } + + $this->assertSame([], $api->calls); + } + + public function testTheStoreTtlComesFromTheConfiguration(): void + { + $api = new QueuedIuguApiRequest([self::pendingInvoiceResponse(['status' => 'canceled'])]); + $store = new InMemoryIdempotencyStore(); + + (new IuguGateway($api, $store))->cancelInvoice(self::invoiceWithId(), 'chave-1'); + + Carbon::setTestNow('2026-09-02 12:59:59'); + $this->assertTrue($store->has('iugu:chave-1')); + Carbon::setTestNow('2026-09-02 13:00:00'); + $this->assertFalse($store->has('iugu:chave-1')); + } + + #[IgnoreDeprecations] + public function testTheLegacyGatewayOptionStillWorksWithADeprecationAndStaysOutOfTheBody(): void + { + $api = new QueuedIuguApiRequest([self::pendingInvoiceResponse()]); + + $invoice = self::pixInvoiceModel(); + $invoice->gatewayOptions = ['idempotency_key' => 'chave-antiga', 'expires_in' => 3]; + + $this->expectUserDeprecationMessage("gateway_options['idempotency_key'] está obsoleto desde 2026-09-02; passe idempotencyKey como argumento da operação"); + + (new IuguGateway($api))->createInvoice($invoice); + + $this->assertSame(['Idempotency-Key: chave-antiga'], $api->calls[0]['headers']); + $this->assertArrayNotHasKey('idempotency_key', $api->calls[0]['data']); + $this->assertSame(3, $api->calls[0]['data']['expires_in']); + } + + #[IgnoreDeprecations] + public function testTheLegacyGatewayOptionAlsoReachesTheStoreEndpoints(): void + { + $api = new QueuedIuguApiRequest([self::pendingInvoiceResponse(['status' => 'canceled'])]); + $store = new InMemoryIdempotencyStore(); + + $invoice = self::invoiceWithId(); + $invoice->gatewayOptions = ['idempotency_key' => 'chave-antiga']; + + $this->expectUserDeprecationMessage("gateway_options['idempotency_key'] está obsoleto desde 2026-09-02; passe idempotencyKey como argumento da operação"); + + (new IuguGateway($api, $store))->cancelInvoice($invoice); + + $this->assertTrue($store->has('iugu:chave-antiga')); + } + + #[IgnoreDeprecations] + public function testTheArgumentWinsOverTheLegacyGatewayOption(): void + { + $api = new QueuedIuguApiRequest([self::pendingInvoiceResponse()]); + + $invoice = self::pixInvoiceModel(); + $invoice->gatewayOptions = ['idempotency_key' => 'chave-antiga']; + + (new IuguGateway($api))->createInvoice($invoice, 'chave-nova'); + + $this->assertSame(['Idempotency-Key: chave-nova'], $api->calls[0]['headers']); + } + + /** + * Na reutilização da chave a Iugu responde 409 com o id da fatura original; o driver a lê e + * devolve, sem cabeçalho na leitura. + */ + public function testAReusedKeyOnInvoiceCreationReturnsTheOriginalInvoice(): void + { + $api = new QueuedIuguApiRequest([ + self::iuguConflictResponse('F53DE68D632E48618DB238F2C1E5D531'), + self::pendingInvoiceResponse(['id' => 'F53DE68D632E48618DB238F2C1E5D531']), + ]); + + $invoice = (new IuguGateway($api))->createInvoice(self::pixInvoiceModel(), 'chave-1'); + + $this->assertSame('F53DE68D632E48618DB238F2C1E5D531', $invoice->id); + $this->assertSame(InvoiceStatus::PENDING, $invoice->status); + $this->assertSame('GET', $api->calls[1]['method']); + $this->assertStringEndsWith('/invoices/F53DE68D632E48618DB238F2C1E5D531', $api->calls[1]['url']); + $this->assertSame([], $api->calls[1]['headers']); + } + + public function testAReusedKeyOnACardChargeReturnsTheOriginalInvoice(): void + { + $api = new QueuedIuguApiRequest([ + self::iuguConflictResponse('495CD9FE89EB496FB3230F97AB857AA9'), + self::pendingInvoiceResponse(['id' => '495CD9FE89EB496FB3230F97AB857AA9', 'status' => 'paid']), + ]); + + $invoice = (new IuguGateway($api))->createInvoice(self::cardInvoiceModel(), 'chave-1'); + + $this->assertSame('495CD9FE89EB496FB3230F97AB857AA9', $invoice->id); + $this->assertStringEndsWith('/charge', $api->calls[0]['url']); + $this->assertStringEndsWith('/invoices/495CD9FE89EB496FB3230F97AB857AA9', $api->calls[1]['url']); + } + + /** + * Para cliente e assinatura a Iugu responde `resource_id: processing`, sem o id do recurso, + * então não há o que ler e a exceção sobe com `resourceId` nulo. + */ + public function testAReusedKeyOnCustomerCreationIsAConflictWithoutResourceId(): void + { + $api = new QueuedIuguApiRequest([self::iuguConflictResponse('processing')]); + + try { + (new IuguGateway($api))->createCustomer(self::customerModel(), 'chave-1'); + $this->fail('Esperava IdempotencyConflictException'); + } catch (IdempotencyConflictException $e) { + $this->assertSame(409, $e->httpStatus); + $this->assertNull($e->resourceId); + $this->assertStringContainsString('já esta em uso', $e->getMessage()); + } + + $this->assertCount(1, $api->calls); + } + + public function testTheConflictExceptionCarriesTheResourceIdWhenTheIuguInformsIt(): void + { + $api = new QueuedIuguApiRequest([self::iuguConflictResponse('D51E2917CDFE409FAE08EDCA52D8E8FC')]); + + $subscription = new Subscription(); + $subscription->planId = 'plano_mensal'; + $subscription->customer = self::customerWithId(); + + try { + (new IuguGateway($api))->createSubscription($subscription, 'chave-1'); + $this->fail('Esperava IdempotencyConflictException'); + } catch (IdempotencyConflictException $e) { + $this->assertSame('D51E2917CDFE409FAE08EDCA52D8E8FC', $e->resourceId); + } + } + + public function testRetryAfterHeaderOfA429FillsTheRateLimitException(): void + { + $api = new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => 'Too many requests'], 429, ['retry-after' => '7']), + ]); + + try { + (new IuguGateway($api))->getInvoice(self::invoiceWithId()); + $this->fail('Esperava RateLimitException'); + } catch (RateLimitException $e) { + $this->assertSame(429, $e->httpStatus); + $this->assertSame(7, $e->retryAfter); + } + } + + #[DataProvider('retryAfterProvider')] + public function testRetryAfterReadsTheFirstNumericValueOnly(mixed $header, ?int $expected): void + { + $api = new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => 'Too many requests'], 429, ['retry-after' => $header]), + ]); + + try { + (new IuguGateway($api))->getInvoice(self::invoiceWithId()); + $this->fail('Esperava RateLimitException'); + } catch (RateLimitException $e) { + $this->assertSame($expected, $e->retryAfter); + } + } + + public static function retryAfterProvider(): array + { + return [ + 'cabeçalho repetido vira lista: vale o primeiro' => [['7', '9'], 7], + 'data HTTP não é interpretada' => ['Wed, 02 Sep 2026 12:00:00 GMT', null], + ]; + } + + public function testRetryAfterIsNullWhenTheIuguDoesNotSendIt(): void + { + $api = new QueuedIuguApiRequest([ + new QueuedIuguResponse((object) ['errors' => 'Too many requests'], 429), + ]); + + try { + (new IuguGateway($api))->getInvoice(self::invoiceWithId()); + $this->fail('Esperava RateLimitException'); + } catch (RateLimitException $e) { + $this->assertNull($e->retryAfter); + } + } + + /** + * Resposta 409 da Iugu para chave reutilizada, como observada na sandbox. + */ + private static function iuguConflictResponse(string $resourceId): QueuedIuguResponse + { + return new QueuedIuguResponse((object) ['errors' => [ + "Essa chave de idempotência já esta em uso: idempotency_key: chave-1, resource_id: {$resourceId}", + ]], 409); + } + + private static function invoiceWithId(): Invoice + { + $invoice = new Invoice(); + $invoice->id = 'inv_1'; + + return $invoice; + } + + private static function customerWithId(): Customer + { + $customer = self::customerModel(); + $customer->id = 'cus_1'; + + return $customer; + } + + private static function customerModel(): Customer + { + $customer = new Customer(); + $customer->name = 'Cliente'; + $customer->email = 'cliente@example.com'; + $customer->taxDocument = '20176996915'; + + return $customer; + } + + private static function pixInvoiceModel(): Invoice + { + $invoice = new Invoice(); + $invoice->customer = self::customerWithId(); + $invoice->availablePaymentMethods = [PaymentMethod::PIX]; + $invoice->dueDate = Carbon::parse('2026-10-01'); + $item = new InvoiceItem(); + $item->description = 'Item'; + $item->price = 10000; + $item->quantity = 1; + $invoice->items = [$item]; + + return $invoice; + } + + private static function cardInvoiceModel(): Invoice + { + $invoice = self::pixInvoiceModel(); + $invoice->availablePaymentMethods = [PaymentMethod::CREDIT_CARD]; + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_1'; + + return $invoice; + } + + private static function savedCardModel(): CreditCard + { + $creditCard = new CreditCard(); + $creditCard->id = 'pm_1'; + $creditCard->customer = self::customerWithId(); + + return $creditCard; + } + + private static function tokenizedCardModel(): CreditCard + { + $creditCard = self::savedCardModel(); + $creditCard->id = null; + $creditCard->token = 'tok_1'; + + return $creditCard; + } + + private static function subscriptionWithId(): Subscription + { + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + return $subscription; + } + + private static function pendingInvoiceResponse(array $overrides = []): object + { + return (object) array_merge([ + 'id' => 'inv_1', + 'status' => 'pending', + 'total_cents' => 10000, + 'paid_at' => null, + 'secure_url' => 'https://faturas.iugu.com/inv_1', + 'taxes_paid_cents' => null, + 'created_at_iso' => '2026-09-02T09:00:00-03:00', + 'paid_cents' => 0, + 'refunded_cents' => 0, + 'due_date' => '2026-10-01', + 'payment_method' => null, + 'payable_with' => 'pix', + 'customer_id' => 'cus_1', + 'customer_name' => 'Cliente', + 'email' => 'cliente@example.com', + 'payer_phone' => null, + 'payer_phone_prefix' => null, + 'items' => [ + (object) ['description' => 'Item', 'price_cents' => 10000, 'quantity' => 1], + ], + 'payer_address_zip_code' => null, + 'bank_slip' => null, + 'pix' => null, + 'automatic_pix' => null, + 'credit_card_transaction' => null, + 'variables' => [], + ], $overrides); + } + + private static function customerResponse(array $overrides = []): object + { + return (object) array_merge([ + 'id' => 'cus_1', + 'name' => 'Cliente', + 'email' => 'cliente@example.com', + 'cpf_cnpj' => '20176996915', + 'phone' => null, + 'phone_prefix' => null, + 'created_at' => '2026-09-02T09:00:00-03:00', + 'custom_variables' => [], + 'default_payment_method_id' => null, + ], $overrides); + } + + private static function paymentMethodResponse(): object + { + return (object) [ + 'id' => 'pm_1', + 'description' => 'CREDIT CARD', + 'created_at_iso' => '2026-09-02T09:00:00-03:00', + 'data' => (object) ['brand' => 'VISA', 'display_number' => 'XXXX-XXXX-XXXX-4242', 'month' => 12, 'year' => 2030, 'holder_name' => 'Cliente Teste'], + ]; + } + + private static function subscriptionResponse(array $overrides = []): object + { + return (object) array_merge([ + 'id' => 'sub_1', + 'customer_id' => 'cus_1', + 'plan_identifier' => 'plano_mensal', + 'price_cents' => 10000, + 'expires_at' => '2026-10-01', + 'created_at' => '2026-09-01T10:00:00-03:00', + 'active' => true, + 'suspended' => false, + 'in_trial' => false, + ], $overrides); + } + + private static function planResponse(): object + { + return (object) [ + 'id' => 'plan_1', + 'identifier' => 'plano_mensal', + 'name' => 'Mensal', + 'interval' => 1, + 'interval_type' => 'months', + 'prices' => [(object) ['value_cents' => 10000, 'currency' => 'BRL']], + ]; + } +} diff --git a/tests/Unit/Gateways/IuguGatewayInvoiceStatusTest.php b/tests/Unit/Gateways/IuguGatewayInvoiceStatusTest.php new file mode 100644 index 0000000..391edda --- /dev/null +++ b/tests/Unit/Gateways/IuguGatewayInvoiceStatusTest.php @@ -0,0 +1,312 @@ +instance('config', new Repository([ + 'multi-payment.gateways.iugu.api_key' => 'test-api-key', + ])); + $app->instance('log', $this->logger = new RecordingLogger()); + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + /** + * Mapa completo dos onze status oficiais da fatura Iugu, mais `partially_refunded` e + * `authorized`, que o gateway já conhecia, para o status genérico. Cada status lê como o + * caso homônimo, exceto `draft` (lê como `PENDING`, junto com `pending`) e `in_analysis` + * (lê como `AUTHORIZED`, junto com `authorized`). + */ + public static function statusProvider(): array + { + return [ + 'pending' => ['pending', InvoiceStatus::PENDING], + 'draft' => ['draft', InvoiceStatus::PENDING], + 'in_analysis' => ['in_analysis', InvoiceStatus::AUTHORIZED], + 'authorized' => ['authorized', InvoiceStatus::AUTHORIZED], + 'paid' => ['paid', InvoiceStatus::PAID], + 'partially_paid' => ['partially_paid', InvoiceStatus::PARTIALLY_PAID], + 'externally_paid' => ['externally_paid', InvoiceStatus::EXTERNALLY_PAID], + 'partially_refunded' => ['partially_refunded', InvoiceStatus::PARTIALLY_REFUNDED], + 'refunded' => ['refunded', InvoiceStatus::REFUNDED], + 'in_protest' => ['in_protest', InvoiceStatus::DISPUTED], + 'chargeback' => ['chargeback', InvoiceStatus::CHARGEBACK], + 'canceled' => ['canceled', InvoiceStatus::CANCELED], + 'expired' => ['expired', InvoiceStatus::EXPIRED], + ]; + } + + #[DataProvider('statusProvider')] + public function testMapsEveryIuguInvoiceStatusToTheGenericOne(string $iuguStatus, InvoiceStatus $expected): void + { + $this->assertSame($expected, $this->mapStatus($iuguStatus)); + $this->assertSame([], $this->logger->records); + } + + /** + * Caminho público: cada status da Iugu passa por `getInvoice()` e chega no model como o + * caso do enum, com o valor cru preservado em `original`. + */ + #[DataProvider('statusProvider')] + public function testGetInvoiceParsesEveryIuguStatusFromTheGatewayResponse(string $iuguStatus, InvoiceStatus $expected): void + { + $api = new QueuedIuguApiRequest([$this->invoiceResponse(['status' => $iuguStatus])]); + + $invoice = (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + + $this->assertSame($expected, $invoice->status); + $this->assertSame($iuguStatus, $invoice->original->status); + $this->assertSame([], $this->logger->records); + } + + /** + * Toda fatura da Iugu é lida do objeto de fatura do gateway: `originType` é `INVOICE`. + */ + public function testGetInvoiceMarksTheOriginAsInvoice(): void + { + $api = new QueuedIuguApiRequest([$this->invoiceResponse()]); + + $invoice = (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + + $this->assertSame(InvoiceOriginType::INVOICE, $invoice->originType); + $this->assertSame('invoice', $invoice->toArray()['origin_type']); + } + + public function testNoIuguStatusIsFlattenedAnymore(): void + { + $this->assertSame(InvoiceStatus::PARTIALLY_PAID, $this->mapStatus('partially_paid')); + $this->assertSame(InvoiceStatus::EXTERNALLY_PAID, $this->mapStatus('externally_paid')); + $this->assertSame(InvoiceStatus::AUTHORIZED, $this->mapStatus('in_analysis')); + $this->assertSame(InvoiceStatus::EXPIRED, $this->mapStatus('expired')); + $this->assertNotSame(InvoiceStatus::PENDING, $this->mapStatus('partially_paid')); + $this->assertNotSame(InvoiceStatus::CANCELED, $this->mapStatus('expired')); + } + + public function testInProtestNoLongerReadsAsPaid(): void + { + $status = $this->mapStatus('in_protest'); + + $this->assertNotSame(InvoiceStatus::PAID, $status); + $this->assertSame(InvoiceStatus::DISPUTED, $status); + $this->assertFalse($status->isSettled()); + $this->assertTrue($status->isContested()); + } + + public function testChargebackNoLongerReadsAsRefunded(): void + { + $status = $this->mapStatus('chargeback'); + + $this->assertNotSame(InvoiceStatus::REFUNDED, $status); + $this->assertSame(InvoiceStatus::CHARGEBACK, $status); + $this->assertFalse($status->isSettled()); + $this->assertTrue($status->isContested()); + } + + public function testUnknownStatusBecomesUnknownWithAWarningInsteadOfThrowing(): void + { + $api = new QueuedIuguApiRequest([$this->invoiceResponse(['status' => 'status_novo'])]); + + $invoice = (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + + $this->assertSame(InvoiceStatus::UNKNOWN, $invoice->status); + $this->assertSame('status_novo', $invoice->original->status); + $this->assertCount(1, $this->logger->records); + $this->assertSame('warning', $this->logger->records[0]['level']); + $this->assertSame(['status' => 'status_novo', 'gateway' => 'iugu'], $this->logger->records[0]['context']); + $this->assertFalse($invoice->status->isSettled()); + $this->assertFalse($invoice->status->isOpen()); + $this->assertFalse($invoice->status->isTerminal()); + } + + /** + * Cada `payment_method` da Iugu chega como o caso do enum; valor desconhecido fica nulo. + */ + public static function paymentMethodProvider(): array + { + return [ + 'cartão' => ['iugu_credit_card', PaymentMethod::CREDIT_CARD], + 'boleto' => ['iugu_bank_slip', PaymentMethod::BANK_SLIP], + 'pix' => ['iugu_pix', PaymentMethod::PIX], + 'desconhecido' => ['iugu_novidade', null], + 'ausente' => [null, null], + ]; + } + + #[DataProvider('paymentMethodProvider')] + public function testGetInvoiceParsesTheIuguPaymentMethod(?string $iuguPaymentMethod, ?PaymentMethod $expected): void + { + $api = new QueuedIuguApiRequest([$this->invoiceResponse(['payment_method' => $iuguPaymentMethod])]); + + $invoice = (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + + $this->assertSame($expected, $invoice->paymentMethod); + } + + /** + * `payable_with` explícito preenche `availablePaymentMethods` com os métodos pedidos; + * `all` expande para os três selecionáveis. + */ + public static function payableWithProvider(): array + { + return [ + 'lista explícita' => [['bank_slip', 'pix'], [PaymentMethod::BANK_SLIP, PaymentMethod::PIX]], + 'string única' => ['credit_card', [PaymentMethod::CREDIT_CARD]], + 'all' => ['all', [PaymentMethod::CREDIT_CARD, PaymentMethod::BANK_SLIP, PaymentMethod::PIX]], + 'valor desconhecido é ignorado' => [['pix', 'cripto'], [PaymentMethod::PIX]], + ]; + } + + #[DataProvider('payableWithProvider')] + public function testGetInvoiceParsesPayableWithIntoAvailablePaymentMethods(array|string $payableWith, array $expected): void + { + $api = new QueuedIuguApiRequest([$this->invoiceResponse(['payable_with' => $payableWith])]); + + $invoice = (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + + $this->assertSame($expected, $invoice->availablePaymentMethods); + } + + /** + * Caminho público: a fatura resumida em `recent_invoices` da assinatura passa pelo mesmo + * mapa, então uma resposta real da Iugu com `in_protest` chega como `disputed`. + */ + public function testDisputedInvoiceFromTheGatewayResponseIsParsedAsDisputed(): void + { + $subscription = $this->readSubscriptionWithLatestInvoiceStatus('in_protest'); + + $this->assertSame(InvoiceStatus::DISPUTED, $subscription->latestInvoice->status); + $this->assertSame('in_protest', $subscription->latestInvoice->original->status); + } + + public function testChargebackInvoiceFromTheGatewayResponseIsParsedAsChargeback(): void + { + $subscription = $this->readSubscriptionWithLatestInvoiceStatus('chargeback'); + + $this->assertSame(InvoiceStatus::CHARGEBACK, $subscription->latestInvoice->status); + $this->assertSame('chargeback', $subscription->latestInvoice->original->status); + } + + public static function contestedStatusProvider(): array + { + return [ + 'in_protest' => ['in_protest'], + 'chargeback' => ['chargeback'], + ]; + } + + /** + * Disputa e chargeback não são dívida em aberto: a assinatura não vira `past_due` por + * causa deles, mesmo com a data de cobrança no passado. + */ + #[DataProvider('contestedStatusProvider')] + public function testContestedInvoicesDoNotMakeTheSubscriptionPastDue(string $iuguStatus): void + { + $subscription = $this->readSubscriptionWithLatestInvoiceStatus($iuguStatus, '2026-08-01'); + + $this->assertSame(SubscriptionStatus::ACTIVE, $subscription->status); + } + + private function mapStatus(string $iuguStatus): InvoiceStatus + { + $method = new \ReflectionMethod(IuguGateway::class, 'iuguStatusToMultiPayment'); + + return $method->invoke(null, $iuguStatus); + } + + private function invoiceWithId(): Invoice + { + $invoice = new Invoice(); + $invoice->id = 'inv_1'; + + return $invoice; + } + + /** + * Fatura no formato de `GET /v1/invoices/{id}`, com os campos que `parseInvoice()` lê. + */ + private function invoiceResponse(array $overrides = []): object + { + return (object) array_merge([ + 'id' => 'inv_1', + 'status' => 'paid', + 'total_cents' => 10000, + 'paid_at' => '2026-08-20T10:00:00-03:00', + 'secure_url' => 'https://faturas.iugu.com/inv_1', + 'taxes_paid_cents' => 150, + 'created_at_iso' => '2026-08-20T09:00:00-03:00', + 'paid_cents' => 10000, + 'refunded_cents' => 0, + 'due_date' => '2026-08-25', + 'payment_method' => 'iugu_credit_card', + 'payable_with' => null, + 'customer_id' => 'cus_1', + 'customer_name' => 'Cliente', + 'email' => 'cliente@example.com', + 'payer_phone' => null, + 'payer_phone_prefix' => null, + 'items' => [ + (object) ['description' => 'Item', 'price_cents' => 10000, 'quantity' => 1], + ], + 'payer_address_zip_code' => null, + 'bank_slip' => null, + 'pix' => null, + 'automatic_pix' => null, + 'credit_card_transaction' => null, + 'variables' => [], + ], $overrides); + } + + private function readSubscriptionWithLatestInvoiceStatus(string $iuguStatus, string $expiresAt = '2026-10-01'): Subscription + { + $api = new QueuedIuguApiRequest([ + (object) [ + 'id' => 'sub_1', + 'customer_id' => 'cus_1', + 'plan_identifier' => 'plano_mensal', + 'price_cents' => 10000, + 'expires_at' => $expiresAt, + 'created_at' => '2026-09-01T10:00:00-03:00', + 'active' => true, + 'suspended' => false, + 'in_trial' => false, + 'recent_invoices' => [ + (object) ['id' => 'inv_1', 'status' => $iuguStatus, 'due_date' => '2026-09-01'], + ], + ], + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + return (new IuguGateway($api))->getSubscription($subscription); + } +} diff --git a/tests/Unit/Gateways/IuguGatewayInvoiceTest.php b/tests/Unit/Gateways/IuguGatewayInvoiceTest.php new file mode 100644 index 0000000..e9f45f0 --- /dev/null +++ b/tests/Unit/Gateways/IuguGatewayInvoiceTest.php @@ -0,0 +1,351 @@ +instance('config', new Repository([ + 'multi-payment.gateways.iugu.api_key' => 'test-api-key', + ])); + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + QueuedIuguApiRequest::restoreSdkRequester(); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + /** + * As `gatewayOptions` entram no payload de `POST /invoices` como campos de primeiro nível e + * sobrescrevem o que o driver preenche por padrão, como o `expires_in` zerado. + */ + public function testCreateInvoiceMergesGatewayOptionsIntoIuguPayload(): void + { + $api = (new QueuedIuguApiRequest([$this->pendingInvoiceResponse()]))->installAsSdkRequester(); + + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'due_date' => '2026-10-01', + 'gateway_options' => ['expires_in' => 5, 'payable_with' => ['bank_slip', 'pix']], + ]); + + (new IuguGateway($api))->createInvoice($invoice); + + $this->assertCount(1, $api->calls); + $this->assertSame('POST', $api->calls[0]['method']); + $this->assertStringEndsWith('/invoices', $api->calls[0]['url']); + + $payload = $api->calls[0]['data']; + $this->assertSame(5, $payload['expires_in']); + $this->assertSame(['bank_slip', 'pix'], $payload['payable_with']); + $this->assertSame('2026-10-01', $payload['due_date']); + $this->assertSame(['expires_in' => 5, 'payable_with' => ['bank_slip', 'pix']], $invoice->gatewayOptions); + } + + /** + * `availablePaymentMethods` vai para a Iugu como `payable_with` de strings; uma string + * apensada por `[]=` (que entra no array sem conversão) é normalizada antes do envio. + */ + public function testCreateInvoiceSendsAvailablePaymentMethodsAsIuguStrings(): void + { + $api = (new QueuedIuguApiRequest([$this->pendingInvoiceResponse()]))->installAsSdkRequester(); + + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'due_date' => '2026-10-01', + ]); + $invoice->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; + $invoice->availablePaymentMethods[] = 'pix'; + + (new IuguGateway($api))->createInvoice($invoice); + + $this->assertStringEndsWith('/invoices', $api->calls[0]['url']); + $this->assertSame(['bank_slip', 'pix'], $api->calls[0]['data']['payable_with']); + } + + /** + * Cartão em `availablePaymentMethods` com um cartão salvo vai por `POST /charge`, com o + * id do cartão em `customer_payment_method_id`; a fatura cobrada é lida em seguida. + */ + public function testCreateInvoiceWithCreditCardChargesTheSavedCard(): void + { + $api = (new QueuedIuguApiRequest([ + (object) ['success' => true, 'invoice_id' => 'inv_1'], + $this->pendingInvoiceResponse(), + ]))->installAsSdkRequester(); + + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'available_payment_methods' => ['credit_card'], + 'credit_card' => ['id' => 'pm_1'], + ]); + + (new IuguGateway($api))->createInvoice($invoice); + + $this->assertCount(2, $api->calls); + $this->assertStringEndsWith('/charge', $api->calls[0]['url']); + $this->assertSame('pm_1', $api->calls[0]['data']['customer_payment_method_id']); + $this->assertSame(['credit_card'], $api->calls[0]['data']['payable_with']); + $this->assertStringEndsWith('/invoices/inv_1', $api->calls[1]['url']); + } + + public function testCreateInvoiceWithoutGatewayOptionsKeepsTheDefaultExpiresIn(): void + { + $api = (new QueuedIuguApiRequest([$this->pendingInvoiceResponse()]))->installAsSdkRequester(); + + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'due_date' => '2026-10-01', + ]); + + (new IuguGateway($api))->createInvoice($invoice); + + $payload = $api->calls[0]['data']; + $this->assertSame(0, $payload['expires_in']); + $this->assertArrayNotHasKey('payable_with', $payload); + } + + private function pendingInvoiceResponse(): object + { + return (object) [ + 'id' => 'inv_1', + 'status' => 'pending', + 'total_cents' => 10000, + 'paid_at' => null, + 'secure_url' => 'https://faturas.iugu.com/inv_1', + 'taxes_paid_cents' => null, + 'created_at_iso' => '2026-09-02T09:00:00-03:00', + 'paid_cents' => 0, + 'refunded_cents' => 0, + 'due_date' => '2026-10-01', + 'payment_method' => null, + 'payable_with' => ['bank_slip', 'pix'], + 'customer_id' => 'cus_1', + 'customer_name' => 'Cliente', + 'email' => 'cliente@example.com', + 'payer_phone' => null, + 'payer_phone_prefix' => null, + 'items' => [ + (object) ['description' => 'Item', 'price_cents' => 10000, 'quantity' => 1], + ], + 'payer_address_zip_code' => null, + 'bank_slip' => null, + 'pix' => null, + 'automatic_pix' => null, + 'credit_card_transaction' => null, + 'variables' => [], + ]; + } + + /** + * `paymentMethod` de cartão com `availablePaymentMethods` vazia cobra o cartão por + * `POST /charge`, com `payable_with` só de cartão. + */ + public function testPaymentMethodAloneWithASavedCardChargesTheCard(): void + { + $api = (new QueuedIuguApiRequest([ + (object) ['success' => true, 'invoice_id' => 'inv_1'], + $this->pendingInvoiceResponse(), + ]))->installAsSdkRequester(); + + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'payment_method' => 'credit_card', + 'credit_card' => ['id' => 'pm_1'], + ]); + + (new IuguGateway($api))->createInvoice($invoice); + + $this->assertStringEndsWith('/charge', $api->calls[0]['url']); + $this->assertSame('pm_1', $api->calls[0]['data']['customer_payment_method_id']); + $this->assertSame(['credit_card'], $api->calls[0]['data']['payable_with']); + } + + public function testACardAloneChargesTheCard(): void + { + $api = (new QueuedIuguApiRequest([ + (object) ['success' => true, 'invoice_id' => 'inv_1'], + $this->pendingInvoiceResponse(), + ]))->installAsSdkRequester(); + + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'credit_card' => ['id' => 'pm_1'], + ]); + + (new IuguGateway($api))->createInvoice($invoice); + + $this->assertStringEndsWith('/charge', $api->calls[0]['url']); + $this->assertSame(['credit_card'], $api->calls[0]['data']['payable_with']); + } + + /** + * Cartão como método sem `creditCard` abre a fatura só a cartão por `POST /invoices`, sem + * cobrança. + */ + public function testPaymentMethodCardWithoutACardOpensTheInvoiceWithoutCharging(): void + { + $api = (new QueuedIuguApiRequest([$this->pendingInvoiceResponse()]))->installAsSdkRequester(); + + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'payment_method' => 'credit_card', + ]); + + (new IuguGateway($api))->createInvoice($invoice); + + $this->assertCount(1, $api->calls); + $this->assertStringEndsWith('/invoices', $api->calls[0]['url']); + $this->assertSame(['credit_card'], $api->calls[0]['data']['payable_with']); + } + + public function testPaymentMethodAloneWithPixOpensThePixInvoice(): void + { + $api = (new QueuedIuguApiRequest([$this->pendingInvoiceResponse()]))->installAsSdkRequester(); + + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'payment_method' => PaymentMethod::PIX, + ]); + + (new IuguGateway($api))->createInvoice($invoice); + + $this->assertStringEndsWith('/invoices', $api->calls[0]['url']); + $this->assertSame(['pix'], $api->calls[0]['data']['payable_with']); + } + + /** + * `dueDate` vai em `due_date` (só o dia) e `pixExpiresAt` em `pix_qr_code_expires_at` + * (ISO 8601 com hora); sem `dueDate`, o vencimento é o dia em que o QR Code expira. + */ + #[DataProvider('datesProvider')] + public function testDueDateAndPixExpiresAtGoToTheirOwnIuguFields(array $data, string $dueDate, ?string $pixExpiresAt): void + { + Carbon::setTestNow('2026-09-02 10:00:00'); + $api = (new QueuedIuguApiRequest([$this->pendingInvoiceResponse()]))->installAsSdkRequester(); + + $invoice = new Invoice(); + $invoice->fill(array_merge([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + ], $data)); + + (new IuguGateway($api))->createInvoice($invoice); + + $payload = $api->calls[0]['data']; + $this->assertSame($dueDate, $payload['due_date']); + $this->assertSame($pixExpiresAt, $payload['pix_qr_code_expires_at'] ?? null); + } + + public static function datesProvider(): array + { + return [ + 'so vencimento' => [['due_date' => '2026-10-01'], '2026-10-01', null], + 'vencimento e expiracao do QR' => [ + ['due_date' => '2026-10-01', 'pix_expires_at' => '2026-09-30T18:00:00-03:00'], + '2026-10-01', + '2026-09-30T18:00:00-03:00', + ], + 'so expiracao do QR' => [ + ['pix_expires_at' => '2026-09-30T18:00:00-03:00'], + '2026-09-30', + '2026-09-30T18:00:00-03:00', + ], + 'nenhuma' => [[], '2026-09-02', null], + ]; + } + + /** + * O método pedido na escrita fica no model só enquanto a Iugu não informa o método com que + * a fatura foi paga. + */ + public function testParseOverwritesTheRequestedPaymentMethodWithTheOneTheInvoiceWasPaidWith(): void + { + $paid = $this->pendingInvoiceResponse(); + $paid->status = 'paid'; + $paid->payment_method = 'iugu_credit_card'; + $api = (new QueuedIuguApiRequest([$this->pendingInvoiceResponse(), $paid]))->installAsSdkRequester(); + $gateway = new IuguGateway($api); + + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'payment_method' => 'pix', + 'available_payment_methods' => ['pix', 'credit_card'], + ]); + + $gateway->createInvoice($invoice); + $this->assertSame(PaymentMethod::PIX, $invoice->paymentMethod); + + $gateway->getInvoice($invoice); + $this->assertSame(PaymentMethod::CREDIT_CARD, $invoice->paymentMethod); + } + + public function testParseReadsDueDateAndKeepsThePixExpiryTheModelHad(): void + { + $api = (new QueuedIuguApiRequest([$this->pendingInvoiceResponse()]))->installAsSdkRequester(); + + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'pix_expires_at' => '2026-09-30T18:00:00-03:00', + ]); + + $result = (new IuguGateway($api))->createInvoice($invoice); + + $this->assertSame('2026-10-01', $result->dueDate->format('Y-m-d')); + $this->assertSame('2026-09-30T18:00:00-03:00', $result->pixExpiresAt->toIso8601String()); + } + + public function testParseReadsThePixExpiryWhenTheInvoiceBringsIt(): void + { + $response = $this->pendingInvoiceResponse(); + $response->pix_qr_code_expires_at = '2026-10-01T12:00:00-03:00'; + $api = (new QueuedIuguApiRequest([$response]))->installAsSdkRequester(); + + $invoice = new Invoice(); + $invoice->id = 'inv_1'; + $result = (new IuguGateway($api))->getInvoice($invoice); + + $this->assertSame('2026-10-01T12:00:00-03:00', $result->pixExpiresAt->toIso8601String()); + $this->assertSame('2026-10-01', $result->dueDate->format('Y-m-d')); + } +} diff --git a/tests/Unit/Gateways/IuguGatewayRefundTest.php b/tests/Unit/Gateways/IuguGatewayRefundTest.php new file mode 100644 index 0000000..9a6b6c1 --- /dev/null +++ b/tests/Unit/Gateways/IuguGatewayRefundTest.php @@ -0,0 +1,781 @@ +instance('config', new Repository([ + 'multi-payment.gateways.iugu.api_key' => 'test-api-key', + ])); + Facade::setFacadeApplication($app); + + Carbon::setTestNow('2026-09-02 12:00:00'); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testRefundInvoiceRequiresId(): void + { + $this->expectException(ModelAttributeValidationException::class); + + (new IuguGateway(new QueuedIuguApiRequest([])))->refundInvoice(new Invoice()); + } + + public function testBoletoRefundThrowsBeforeTheNetworkAfterReadingTheInvoice(): void + { + $api = new QueuedIuguApiRequest([$this->paidInvoiceResponse([ + 'payment_method' => 'iugu_bank_slip', + 'payable_with' => 'bank_slip', + ])]); + + $exception = $this->refundExpectingRefusal($api, $this->invoiceWithId()); + + $this->assertSame(RefundNotSupportedException::REASON_BOLETO_NO_REFUND, $exception->reason); + $this->assertSame(PaymentMethod::BANK_SLIP->value, $exception->paymentMethod); + $this->assertTrue($exception->manualRefundRequired); + $this->assertOnlyTheInvoiceWasRead($api); + } + + public function testBoletoRefundWithThePaymentMethodInHandMakesNoRequest(): void + { + $api = new QueuedIuguApiRequest([]); + $invoice = $this->invoiceWithId(); + $invoice->paymentMethod = PaymentMethod::BANK_SLIP; + $invoice->status = InvoiceStatus::PAID; + $invoice->paidAt = Carbon::parse('2026-08-20'); + + $exception = $this->refundExpectingRefusal($api, $invoice); + + $this->assertSame(RefundNotSupportedException::REASON_BOLETO_NO_REFUND, $exception->reason); + $this->assertSame([], $api->calls); + } + + public function testPartialPixRefundThrowsBeforeTheNetwork(): void + { + $api = new QueuedIuguApiRequest([$this->paidPixInvoiceResponse()]); + $invoice = $this->invoiceWithId(); + $exception = $this->refundExpectingRefusal($api, $invoice, 5000); + + $this->assertSame(RefundNotSupportedException::REASON_PIX_PARTIAL_NOT_SUPPORTED, $exception->reason); + $this->assertSame(PaymentMethod::PIX->value, $exception->paymentMethod); + $this->assertFalse($exception->manualRefundRequired); + $this->assertStringContainsString('5000', $exception->getMessage()); + $this->assertStringContainsString('10000', $exception->getMessage()); + $this->assertOnlyTheInvoiceWasRead($api); + } + + public function testFullPixRefundWithoutAmountGoesToTheGateway(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidPixInvoiceResponse(), + $this->paidPixInvoiceResponse(['status' => 'refunded', 'refunded_cents' => 10000, 'paid_cents' => 0]), + ]); + + $invoice = $this->invoiceWithId(); + $result = (new IuguGateway($api))->refundInvoice($invoice); + + $this->assertCount(2, $api->calls); + $this->assertSame('POST', $api->calls[1]['method']); + $this->assertStringEndsWith('/invoices/inv_1/refund', $api->calls[1]['url']); + $this->assertSame([], $api->calls[1]['data']); + + $this->assertInstanceOf(Refund::class, $result); + $this->assertNull($result->id, 'a Iugu não identifica o estorno'); + $this->assertSame('inv_1', $result->invoiceId); + $this->assertSame(10000, $result->amount); + $this->assertSame(RefundStatus::SUCCEEDED, $result->status); + $this->assertSame('2026-09-02 12:00:00', $result->createdAt->toDateTimeString()); + $this->assertSame('iugu', $result->gateway); + $this->assertNull($result->original); + + $this->assertSame($invoice, $result->invoice()); + $this->assertSame(InvoiceStatus::REFUNDED, $invoice->status); + $this->assertSame(10000, $invoice->refundedAmount); + $this->assertCount(2, $api->calls, 'invoice() não faz requisição'); + } + + /** + * Pix só aceita estorno integral: pedir exatamente o valor pago não pode virar um + * `partial_value_refund_cents` que a Iugu recusaria. + */ + public function testPixRefundOfTheFullPaidAmountIsSentAsIntegral(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidPixInvoiceResponse(), + $this->paidPixInvoiceResponse(['status' => 'refunded', 'refunded_cents' => 10000, 'paid_cents' => 0]), + ]); + $invoice = $this->invoiceWithId(); + $result = (new IuguGateway($api))->refundInvoice($invoice, 10000); + + $this->assertSame([], $api->calls[1]['data']); + $this->assertSame(10000, $result->amount); + $this->assertSame(InvoiceStatus::REFUNDED, $result->invoice()->status); + } + + public function testPartialCardRefundSendsThePartialValue(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(), + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 2500, 'paid_cents' => 7500]), + ]); + $invoice = $this->invoiceWithId(); + $result = (new IuguGateway($api))->refundInvoice($invoice, 2500); + + $this->assertSame('POST', $api->calls[1]['method']); + $this->assertSame(['partial_value_refund_cents' => 2500], $api->calls[1]['data']); + $this->assertSame(2500, $result->amount); + $this->assertSame(RefundStatus::SUCCEEDED, $result->status); + $this->assertSame(InvoiceStatus::PARTIALLY_REFUNDED, $result->invoice()->status); + $this->assertSame(2500, $result->invoice()->refundedAmount); + $this->assertSame(7500, $result->invoice()->paidAmount); + $this->assertCount(1, $result->invoice()->refunds); + $this->assertSame(2500, $result->invoice()->refunds[0]->amount); + } + + /** + * Fatura parcialmente estornada aceita novo estorno até o restante (`paid_cents`, que a + * Iugu devolve líquido do já estornado). Pedir exatamente o que resta vai como integral. + */ + public function testRefundingTheExactRemainderOfAPartiallyRefundedInvoiceGoesAsIntegral(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 2500, 'paid_cents' => 7500]), + $this->paidInvoiceResponse(['status' => 'refunded', 'refunded_cents' => 10000, 'paid_cents' => 0]), + ]); + $invoice = $this->invoiceWithId(); + $result = (new IuguGateway($api))->refundInvoice($invoice, 7500); + + $this->assertCount(2, $api->calls); + $this->assertSame([], $api->calls[1]['data']); + $this->assertSame(7500, $result->amount); + $this->assertSame(InvoiceStatus::REFUNDED, $result->invoice()->status); + } + + /** + * O `Refund` devolvido traz o valor deste estorno; `Invoice::$refunds` traz o acumulado + * num único registro, porque a Iugu só informa `refunded_cents`. + */ + public function testSecondPartialRefundWithinTheRemainderGoesToTheGateway(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 2500, 'paid_cents' => 7500]), + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 4500, 'paid_cents' => 5500]), + ]); + $invoice = $this->invoiceWithId(); + $result = (new IuguGateway($api))->refundInvoice($invoice, 2000); + + $this->assertSame(['partial_value_refund_cents' => 2000], $api->calls[1]['data']); + $this->assertSame(2000, $result->amount); + $this->assertSame(InvoiceStatus::PARTIALLY_REFUNDED, $result->invoice()->status); + $this->assertSame(4500, $result->invoice()->refundedAmount); + $this->assertCount(1, $result->invoice()->refunds); + $this->assertNull($result->invoice()->refunds[0]->id); + $this->assertSame(4500, $result->invoice()->refunds[0]->amount); + } + + public function testSecondPartialRefundAboveTheRemainderThrowsBeforeTheNetwork(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 2500, 'paid_cents' => 7500]), + ]); + $invoice = $this->invoiceWithId(); + $exception = $this->refundExpectingRefusal($api, $invoice, 8000); + + $this->assertSame(RefundNotSupportedException::REASON_AMOUNT_EXCEEDS_REFUNDABLE, $exception->reason); + $this->assertSame(PaymentMethod::CREDIT_CARD->value, $exception->paymentMethod); + $this->assertFalse($exception->manualRefundRequired); + $this->assertNull($exception->capability); + $this->assertStringContainsString('8000', $exception->getMessage()); + $this->assertStringContainsString('7500', $exception->getMessage()); + $this->assertOnlyTheInvoiceWasRead($api); + $this->assertNull($invoice->refundedAmount, 'a leitura prévia não altera o model do chamador'); + } + + /** + * Regressão: a leitura prévia parseia uma cópia, e a cópia precisa ser profunda, senão o + * `customer` do model do chamador recebe os dados da resposta mesmo quando a guarda dispara. + */ + public function testRefusedRefundLeavesTheCallerNestedObjectsUntouched(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 2500, 'paid_cents' => 7500]), + ]); + $invoice = $this->invoiceWithId(); + $invoice->customer = new Customer(); + $invoice->customer->name = 'Nome do chamador'; + $this->refundExpectingRefusal($api, $invoice, 8000); + + $this->assertSame('Nome do chamador', $invoice->customer->name); + $this->assertNull($invoice->customer->id); + } + + /** + * Caminho antigo: escrever `refundedAmount` antes de estornar continua pedindo o estorno + * parcial desse valor, com aviso de deprecação, mesmo num model lido do gateway em que a + * propriedade trazia o acumulado. + */ + public function testWritingRefundedAmountOnAModelReadFromTheGatewayStillRequestsThatPartialRefund(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 2500, 'paid_cents' => 7500]), + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 4500, 'paid_cents' => 5500]), + ]); + $gateway = new IuguGateway($api); + $invoice = $gateway->getInvoice($this->invoiceWithId()); + + $this->expectUserDeprecationMessage('Invoice::$refundedAmount é só de leitura desde 2026-09-02; passe o valor do estorno em refund(amount:) ou refundInvoice($id, $amount)'); + $invoice->refundedAmount = 2000; + $result = $gateway->refundInvoice($invoice); + + $this->assertSame(['partial_value_refund_cents' => 2000], $api->calls[1]['data']); + $this->assertSame(2000, $result->amount); + $this->assertSame(4500, $invoice->refundedAmount); + $this->assertNull($invoice->requestedRefundAmount(), 'o estorno feito apaga o valor pedido pelo caminho antigo'); + } + + /** + * Caminho antigo num model montado só com o id: o valor escrito em `refundedAmount` vai + * como estorno parcial, com aviso de deprecação; zero continua sendo estorno do restante. + */ + public function testWritingRefundedAmountOnABareModelStillRequestsThatPartialRefund(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(), + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 2500, 'paid_cents' => 7500]), + ]); + $invoice = $this->invoiceWithId(); + + $this->expectUserDeprecationMessage('Invoice::$refundedAmount é só de leitura desde 2026-09-02; passe o valor do estorno em refund(amount:) ou refundInvoice($id, $amount)'); + $invoice->refundedAmount = 2500; + $this->assertSame(2500, $invoice->requestedRefundAmount()); + + $result = (new IuguGateway($api))->refundInvoice($invoice); + + $this->assertSame(['partial_value_refund_cents' => 2500], $api->calls[1]['data']); + $this->assertSame(2500, $result->amount); + } + + /** + * O argumento prevalece sobre o caminho antigo quando os dois são informados. + */ + public function testTheAmountArgumentPrevailsOverTheLegacyRefundedAmount(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(), + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 1000, 'paid_cents' => 9000]), + ]); + $invoice = $this->invoiceWithId(); + + $this->expectUserDeprecationMessage('Invoice::$refundedAmount é só de leitura desde 2026-09-02; passe o valor do estorno em refund(amount:) ou refundInvoice($id, $amount)'); + $invoice->refundedAmount = 2500; + $result = (new IuguGateway($api))->refundInvoice($invoice, 1000); + + $this->assertSame(['partial_value_refund_cents' => 1000], $api->calls[1]['data']); + $this->assertSame(1000, $result->amount); + } + + public function testZeroOrNegativeAmountIsRejectedBeforeAnyRequest(): void + { + $api = new QueuedIuguApiRequest([]); + + foreach ([0, -100] as $amount) { + try { + (new IuguGateway($api))->refundInvoice($this->invoiceWithId(), $amount); + $this->fail("Esperava ModelAttributeValidationException para {$amount}"); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('amount', $e->getMessage()); + } + } + + $this->assertSame([], $api->calls); + } + + /** + * `refundableAmount()` é `paid_cents`, que a Iugu devolve líquido do já estornado; um model + * que já traz `paidAmount` não paga requisição, um model só com o id lê a fatura. + */ + public function testRefundableAmountIsThePaidCentsAndReadsTheInvoiceOnlyWhenNeeded(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 2500, 'paid_cents' => 7500]), + $this->paidInvoiceResponse(['status' => 'refunded', 'refunded_cents' => 10000, 'paid_cents' => 0]), + $this->paidInvoiceResponse(['status' => 'pending', 'paid_at' => null, 'paid_cents' => 0, 'payment_method' => null]), + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 2500, 'paid_cents' => 7500]), + ]); + $gateway = new IuguGateway($api); + + $this->assertSame(7500, $gateway->refundableAmount($this->invoiceWithId())); + $this->assertSame(0, $gateway->refundableAmount($this->invoiceWithId())); + $this->assertSame(0, $gateway->refundableAmount($this->invoiceWithId()), 'fatura não paga'); + $this->assertCount(3, $api->calls); + + $read = $gateway->getInvoice($this->invoiceWithId()); + $this->assertSame(7500, $gateway->refundableAmount($read)); + $preloaded = $this->invoiceWithId(); + $preloaded->paidAmount = 5000; + $this->assertSame(5000, $gateway->refundableAmount($preloaded)); + $this->assertCount(4, $api->calls, 'o model com paidAmount não custa requisição'); + } + + /** + * `refundableAmount()` é o restante aritmético: boleto pago devolve `paid_cents` mesmo sem + * estorno pela API; a recusa de boleto continua em `refundInvoice()`. + */ + public function testRefundableAmountOfAPaidBankSlipIsTheArithmeticRemainder(): void + { + $api = new QueuedIuguApiRequest([$this->paidInvoiceResponse([ + 'payment_method' => 'iugu_bank_slip', + 'payable_with' => 'bank_slip', + ])]); + $gateway = new IuguGateway($api); + + $this->assertSame(10000, $gateway->refundableAmount($this->invoiceWithId())); + $this->assertOnlyTheInvoiceWasRead($api); + } + + public function testRefundableAmountRequiresTheInvoiceId(): void + { + $this->expectException(ModelAttributeValidationException::class); + + (new IuguGateway(new QueuedIuguApiRequest([])))->refundableAmount(new Invoice()); + } + + public function testFirstRefundAboveThePaidAmountThrowsBeforeTheNetwork(): void + { + $api = new QueuedIuguApiRequest([$this->paidInvoiceResponse()]); + $invoice = $this->invoiceWithId(); + $exception = $this->refundExpectingRefusal($api, $invoice, 10001); + + $this->assertSame(RefundNotSupportedException::REASON_AMOUNT_EXCEEDS_REFUNDABLE, $exception->reason); + $this->assertOnlyTheInvoiceWasRead($api); + } + + /** + * Num Pix, valor acima do pago é recusado com `amount_exceeds_refundable`, antes da guarda de + * Pix parcial, porque a orientação de repetir sem valor não resolveria esse pedido. + */ + public function testPixRefundAboveThePaidAmountIsRefusedAsExceeding(): void + { + $api = new QueuedIuguApiRequest([$this->paidPixInvoiceResponse()]); + $invoice = $this->invoiceWithId(); + $exception = $this->refundExpectingRefusal($api, $invoice, 15000); + + $this->assertSame(RefundNotSupportedException::REASON_AMOUNT_EXCEEDS_REFUNDABLE, $exception->reason); + $this->assertSame(PaymentMethod::PIX->value, $exception->paymentMethod); + } + + /** + * Regressão: a leitura prévia não pode vazar para o model do chamador. Se o estorno falha, + * o model continua como o chamador o montou. + */ + public function testFailedRefundLeavesTheCallerModelUntouched(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(), + (object) ['errors' => 'Fatura não pode ser reembolsada'], + ]); + $invoice = $this->invoiceWithId(); + + try { + (new IuguGateway($api))->refundInvoice($invoice, 2500); + $this->fail('Esperava GatewayException'); + } catch (GatewayException $e) { + } + + $this->assertNull($invoice->refundedAmount); + $this->assertNull($invoice->status); + $this->assertNull($invoice->paymentMethod); + } + + public function testRefusedRefundLeavesTheCallerModelUntouched(): void + { + $api = new QueuedIuguApiRequest([$this->paidPixInvoiceResponse()]); + $invoice = $this->invoiceWithId(); + $this->refundExpectingRefusal($api, $invoice, 5000); + + $this->assertNull($invoice->refundedAmount); + $this->assertNull($invoice->status); + } + + /** + * Regressão: model montado pela aplicação com método, status e data, mas sem o valor pago. + * Estorno por valor precisa do `paidAmount` para decidir entre integral e parcial, então o + * driver lê a fatura mesmo assim, e o valor cheio de um Pix segue como integral. + */ + public function testPixRefundByAmountWithoutPaidAmountReadsTheInvoiceFirst(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidPixInvoiceResponse(), + $this->paidPixInvoiceResponse(['status' => 'refunded', 'refunded_cents' => 10000, 'paid_cents' => 0]), + ]); + $invoice = $this->invoiceWithId(); + $invoice->paymentMethod = PaymentMethod::PIX; + $invoice->status = InvoiceStatus::PAID; + $invoice->paidAt = Carbon::parse('2026-08-20'); + $result = (new IuguGateway($api))->refundInvoice($invoice, 10000); + + $this->assertCount(2, $api->calls); + $this->assertSame('GET', $api->calls[0]['method']); + $this->assertSame([], $api->calls[1]['data']); + $this->assertSame(InvoiceStatus::REFUNDED, $result->invoice()->status); + } + + /** + * Boleto pendente chega da Iugu sem `payment_method` (só preenchido após o pagamento), então + * a guarda de boleto não dispara e é a API que recusa. Documenta o comportamento atual. + */ + public function testPendingInvoiceWithoutPaymentMethodGoesToTheGateway(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse([ + 'status' => 'pending', + 'payment_method' => null, + 'payable_with' => 'bank_slip', + 'paid_at' => null, + 'paid_cents' => 0, + ]), + (object) ['errors' => 'Fatura não pode ser reembolsada'], + ]); + + try { + (new IuguGateway($api))->refundInvoice($this->invoiceWithId()); + $this->fail('Esperava GatewayException'); + } catch (GatewayException $e) { + } + + $this->assertCount(2, $api->calls); + $this->assertSame('POST', $api->calls[1]['method']); + } + + public function testAlreadyRefundedInvoiceThrowsBeforeTheNetwork(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['status' => 'refunded', 'refunded_cents' => 10000, 'paid_cents' => 0]), + ]); + + $exception = $this->refundExpectingRefusal($api, $this->invoiceWithId()); + + $this->assertSame(RefundNotSupportedException::REASON_ALREADY_REFUNDED, $exception->reason); + $this->assertSame(PaymentMethod::CREDIT_CARD->value, $exception->paymentMethod); + $this->assertFalse($exception->manualRefundRequired); + $this->assertOnlyTheInvoiceWasRead($api); + } + + public function testRefundAfterTheNinetyDayWindowThrowsBeforeTheNetwork(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['paid_at' => '2026-06-03T12:00:00-03:00']), + ]); + + $exception = $this->refundExpectingRefusal($api, $this->invoiceWithId()); + + $this->assertSame(RefundNotSupportedException::REASON_REFUND_WINDOW_EXPIRED, $exception->reason); + $this->assertSame(PaymentMethod::CREDIT_CARD->value, $exception->paymentMethod); + $this->assertTrue($exception->manualRefundRequired); + $this->assertStringContainsString('2026-06-03', $exception->getMessage()); + $this->assertOnlyTheInvoiceWasRead($api); + } + + public function testRefundInsideTheNinetyDayWindowGoesToTheGateway(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['paid_at' => '2026-06-05T12:00:00-03:00']), + $this->paidInvoiceResponse(['status' => 'refunded', 'refunded_cents' => 10000, 'paid_cents' => 0]), + ]); + + $result = (new IuguGateway($api))->refundInvoice($this->invoiceWithId()); + + $this->assertCount(2, $api->calls); + $this->assertSame(InvoiceStatus::REFUNDED, $result->invoice()->status); + } + + /** + * O prazo conta em dias: no 90º dia após o pagamento a chamada ainda segue, mesmo que a + * hora do pagamento já tenha passado, e é a API que decide no limite. + */ + public function testRefundOnTheLastDayOfTheWindowGoesToTheGateway(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['paid_at' => '2026-06-04T08:00:00-03:00']), + $this->paidInvoiceResponse(['status' => 'refunded', 'refunded_cents' => 10000, 'paid_cents' => 0]), + ]); + + $result = (new IuguGateway($api))->refundInvoice($this->invoiceWithId()); + + $this->assertCount(2, $api->calls); + $this->assertSame(InvoiceStatus::REFUNDED, $result->invoice()->status); + } + + public function testRefundOnTheDayAfterTheWindowThrowsBeforeTheNetwork(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['paid_at' => '2026-06-03T23:30:00-03:00']), + ]); + + $exception = $this->refundExpectingRefusal($api, $this->invoiceWithId()); + + $this->assertSame(RefundNotSupportedException::REASON_REFUND_WINDOW_EXPIRED, $exception->reason); + $this->assertOnlyTheInvoiceWasRead($api); + } + + /** + * Um model já lido do gateway (método, status e data de pagamento em mãos) não paga o GET + * extra antes do estorno. + */ + public function testInvoiceReadFromTheGatewayDoesNotPayTheExtraGet(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(), + $this->paidInvoiceResponse(['status' => 'refunded', 'refunded_cents' => 10000, 'paid_cents' => 0]), + ]); + $gateway = new IuguGateway($api); + + $invoice = $gateway->getInvoice($this->invoiceWithId()); + $result = $gateway->refundInvoice($invoice); + + $this->assertCount(2, $api->calls); + $this->assertSame('GET', $api->calls[0]['method']); + $this->assertSame('POST', $api->calls[1]['method']); + $this->assertSame(10000, $result->amount, 'sem valor pedido, o valor vem do que a Iugu acrescentou a refunded_cents'); + $this->assertSame(InvoiceStatus::REFUNDED, $result->invoice()->status); + } + + /** + * Model lido do gateway, já parcialmente estornado: `refundInvoice()` sem valor estorna o + * restante (o acumulado em `refundedAmount` é só leitura e não vira pedido). Sem leitura + * prévia, o valor do `Refund` vem do `paid_cents` que o model trazia. + */ + public function testIntegralRefundOfAPartiallyRefundedInvoiceReadFromTheGatewayReportsTheRemainder(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 2500, 'paid_cents' => 7500]), + $this->paidInvoiceResponse(['status' => 'refunded', 'refunded_cents' => 10000, 'paid_cents' => 0]), + ]); + $gateway = new IuguGateway($api); + + $invoice = $gateway->getInvoice($this->invoiceWithId()); + $result = $gateway->refundInvoice($invoice); + + $this->assertCount(2, $api->calls); + $this->assertSame([], $api->calls[1]['data']); + $this->assertSame(7500, $result->amount); + $this->assertSame(10000, $invoice->refundedAmount); + $this->assertSame(InvoiceStatus::REFUNDED, $invoice->status); + } + + /** + * Regressão: estorno integral do restante numa fatura já parcialmente estornada, lida do + * gateway. O valor do `Refund` é só o que este estorno devolveu. + */ + public function testIntegralRefundOfTheRemainderReportsOnlyWhatThisRefundReturned(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 2500, 'paid_cents' => 7500]), + $this->paidInvoiceResponse(['status' => 'refunded', 'refunded_cents' => 10000, 'paid_cents' => 0]), + ]); + + $result = (new IuguGateway($api))->refundInvoice($this->invoiceWithId()); + + $this->assertSame([], $api->calls[1]['data']); + $this->assertSame(7500, $result->amount); + $this->assertSame(10000, $result->invoice()->refundedAmount); + } + + public function testGatewayErrorOnRefundBecomesGatewayException(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(), + (object) ['errors' => 'Fatura não pode ser reembolsada'], + ]); + + $this->expectException(GatewayException::class); + $this->expectExceptionMessageMatches('/^Error refunding invoice - .*Fatura não pode ser reembolsada/'); + + (new IuguGateway($api))->refundInvoice($this->invoiceWithId()); + } + + public function testGetInvoiceReadsTheInvoiceById(): void + { + $api = new QueuedIuguApiRequest([$this->paidInvoiceResponse()]); + + $result = (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + + $this->assertCount(1, $api->calls); + $this->assertSame('GET', $api->calls[0]['method']); + $this->assertStringEndsWith('/invoices/inv_1', $api->calls[0]['url']); + $this->assertSame('inv_1', $result->id); + $this->assertSame(InvoiceStatus::PAID, $result->status); + $this->assertSame(PaymentMethod::CREDIT_CARD, $result->paymentMethod); + $this->assertSame(10000, $result->paidAmount); + $this->assertSame('2026-08-20', $result->paidAt->toDateString()); + $this->assertSame([], $result->refunds); + } + + /** + * A Iugu só informa `refunded_cents`, então a lista de estornos tem um único `Refund`, sem + * id e sem data, com o acumulado. + */ + public function testGetInvoiceListsASingleSyntheticRefundWithTheAccumulatedAmount(): void + { + $api = new QueuedIuguApiRequest([ + $this->paidInvoiceResponse(['status' => 'partially_refunded', 'refunded_cents' => 3000, 'paid_cents' => 7000]), + ]); + + $result = (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + + $this->assertCount(1, $result->refunds); + $refund = $result->refunds[0]; + $this->assertInstanceOf(Refund::class, $refund); + $this->assertNull($refund->id); + $this->assertSame('inv_1', $refund->invoiceId); + $this->assertSame(3000, $refund->amount); + $this->assertSame(RefundStatus::SUCCEEDED, $refund->status); + $this->assertNull($refund->createdAt); + $this->assertSame('iugu', $refund->gateway); + $this->assertNull($refund->invoice); + } + + public function testGetInvoiceErrorBecomesGatewayException(): void + { + $api = new QueuedIuguApiRequest([(object) ['errors' => 'Not Found']]); + + $this->expectException(GatewayException::class); + $this->expectExceptionMessageMatches('/^Error getting invoice - .*Not Found/'); + + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + } + + public function testGetInvoiceNotFoundBecomesNotFoundException(): void + { + $api = new QueuedIuguApiRequest([new \IuguObjectNotFound('{"errors":"Not Found"}', 404)]); + + try { + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + $this->fail('Esperava NotFoundException'); + } catch (NotFoundException $e) { + $this->assertNotInstanceOf(GatewayNotAvailableException::class, $e); + } + + $this->assertCount(1, $api->calls); + } + + public function testGetInvoiceOn502BecomesGatewayNotAvailableException(): void + { + $api = new QueuedIuguApiRequest([new \IuguRequestException('502 Bad Gateway', 502)]); + + $this->expectException(GatewayNotAvailableException::class); + + (new IuguGateway($api))->getInvoice($this->invoiceWithId()); + } + + public function testRefundOfUnknownInvoiceBecomesNotFoundExceptionWithoutPosting(): void + { + $api = new QueuedIuguApiRequest([new \IuguObjectNotFound('{"errors":"Not Found"}', 404)]); + + try { + (new IuguGateway($api))->refundInvoice($this->invoiceWithId()); + $this->fail('Esperava NotFoundException'); + } catch (NotFoundException $e) { + $this->assertSame(404, $e->httpStatus); + } + + $this->assertOnlyTheInvoiceWasRead($api); + } + + private function invoiceWithId(): Invoice + { + $invoice = new Invoice(); + $invoice->id = 'inv_1'; + + return $invoice; + } + + private function refundExpectingRefusal(QueuedIuguApiRequest $api, Invoice $invoice, ?int $amount = null): RefundNotSupportedException + { + try { + (new IuguGateway($api))->refundInvoice($invoice, $amount); + } catch (RefundNotSupportedException $e) { + return $e; + } + + $this->fail('Esperava RefundNotSupportedException'); + } + + private function assertOnlyTheInvoiceWasRead(QueuedIuguApiRequest $api): void + { + $this->assertCount(1, $api->calls); + $this->assertSame('GET', $api->calls[0]['method']); + $this->assertStringEndsWith('/invoices/inv_1', $api->calls[0]['url']); + } + + private function paidPixInvoiceResponse(array $overrides = []): object + { + return $this->paidInvoiceResponse(array_merge([ + 'payment_method' => 'iugu_pix', + 'payable_with' => 'pix', + 'pix' => (object) ['qrcode' => 'https://example.com/qr.png', 'qrcode_text' => '000201'], + ], $overrides)); + } + + private function paidInvoiceResponse(array $overrides = []): object + { + return (object) array_merge([ + 'id' => 'inv_1', + 'status' => 'paid', + 'total_cents' => 10000, + 'paid_at' => '2026-08-20T10:00:00-03:00', + 'secure_url' => 'https://faturas.iugu.com/inv_1', + 'taxes_paid_cents' => 250, + 'created_at_iso' => '2026-08-20T09:00:00-03:00', + 'paid_cents' => 10000, + 'refunded_cents' => 0, + 'due_date' => '2026-08-25', + 'payment_method' => 'iugu_credit_card', + 'payable_with' => 'credit_card', + 'customer_id' => 'cus_1', + 'customer_name' => 'Cliente', + 'email' => 'cliente@example.com', + 'payer_phone' => null, + 'payer_phone_prefix' => null, + 'items' => [ + (object) ['description' => 'Item', 'price_cents' => 10000, 'quantity' => 1], + ], + 'payer_address_zip_code' => null, + 'bank_slip' => null, + 'pix' => null, + 'automatic_pix' => null, + 'credit_card_transaction' => null, + 'variables' => [], + ], $overrides); + } +} diff --git a/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php new file mode 100644 index 0000000..1f1c88a --- /dev/null +++ b/tests/Unit/Gateways/IuguGatewaySubscriptionTest.php @@ -0,0 +1,3396 @@ +instance('config', new Repository([ + 'multi-payment' => [ + 'default' => 'iugu', + 'gateways' => ['iugu' => ['api_key' => 'test-api-key', 'class' => IuguGateway::class]], + ], + ])); + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + QueuedIuguApiRequest::restoreSdkRequester(); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + private function subscriptionResponse(array $overrides = []): object + { + return (object) array_merge([ + 'id' => 'sub_1', + 'customer_id' => 'cus_1', + 'plan_identifier' => 'plano_mensal', + 'price_cents' => 10000, + 'expires_at' => '2026-10-01', + 'created_at' => '2026-09-01T10:00:00-03:00', + 'active' => true, + 'suspended' => false, + 'in_trial' => false, + ], $overrides); + } + + public function testCreateSubscriptionMapsGenericFieldsToIuguPayload(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse()]); + $gateway = new IuguGateway($api); + + $subscription = new Subscription(); + $subscription->fill([ + 'plan_id' => 'plano_mensal', + 'customer' => ['id' => 'cus_1'], + 'next_billing_at' => '2026-10-01', + 'available_payment_methods' => [PaymentMethod::PIX], + 'metadata' => ['origem' => 'teste'], + 'items' => [['description' => 'Consultas', 'amount' => 2500, 'quantity' => 2]], + 'discounts' => [['description' => 'Promo', 'amount_off' => 500]], + ]); + + $gateway->createSubscription($subscription); + + $this->assertCount(1, $api->calls); + $call = $api->calls[0]; + $this->assertSame('POST', $call['method']); + $this->assertStringEndsWith('/subscriptions', $call['url']); + $this->assertSame([ + 'customer_id' => 'cus_1', + 'plan_identifier' => 'plano_mensal', + 'expires_at' => '2026-10-01', + 'payable_with' => ['pix'], + 'custom_variables' => [['name' => 'origem', 'value' => 'teste']], + 'subitems' => [ + ['description' => 'Consultas', 'price_cents' => 2500, 'quantity' => 2, 'recurrent' => 1], + ['description' => 'Promo', 'price_cents' => -500, 'quantity' => 1, 'recurrent' => 1], + ], + ], $call['data']); + } + + /** + * Na Iugu a recorrência de Pix Automático nasce na fatura: a assinatura com o método é + * recusada pela falta de `MANAGES_RECURRENCE`, a mesma capability do guard do model, sem + * nenhuma requisição. + */ + public function testCreateSubscriptionRejectsAutomaticPixAsThePaymentMethod(): void + { + $api = new QueuedIuguApiRequest([]); + $gateway = new IuguGateway($api); + + $subscription = new Subscription(); + $subscription->fill(['plan_id' => 'plano_mensal', 'customer' => ['id' => 'cus_1']]); + $subscription->paymentMethod = PaymentMethod::AUTOMATIC_PIX; + + try { + $gateway->createSubscription($subscription); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::MANAGES_RECURRENCE, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertStringContainsString('nasce na fatura', $e->getMessage()); + } + + $this->assertCount(0, $api->calls); + } + + public function testGatewayOptionsOverrideTheGeneratedPayload(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse()]); + + $subscription = new Subscription(); + $subscription->fill(['plan_id' => 'plano_mensal', 'customer' => ['id' => 'cus_1']]); + $subscription->gatewayOptions = [ + 'only_on_charge_success' => true, + 'plan_identifier' => 'outro_plano', + ]; + + (new IuguGateway($api))->createSubscription($subscription); + + $this->assertSame([ + 'customer_id' => 'cus_1', + 'plan_identifier' => 'outro_plano', + 'only_on_charge_success' => true, + ], $api->calls[0]['data']); + } + + public function testParseSplitsNegativeSubitemsIntoDiscounts(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'subitems' => [ + (object) ['id' => 'si_1', 'description' => 'Consultas', 'price_cents' => 2500, 'quantity' => 2, 'recurrent' => true], + (object) ['id' => 'si_2', 'description' => 'Promo', 'price_cents' => -500, 'quantity' => 1, 'recurrent' => true], + (object) ['id' => 'si_3', 'description' => 'Bonus', 'price_cents' => -300, 'quantity' => 1, 'recurrent' => false], + ], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertCount(1, $subscription->items); + $this->assertInstanceOf(SubscriptionItem::class, $subscription->items[0]); + $this->assertSame('si_1', $subscription->items[0]->id); + $this->assertSame(2500, $subscription->items[0]->amount); + + $this->assertCount(2, $subscription->discounts); + $this->assertInstanceOf(SubscriptionDiscount::class, $subscription->discounts[0]); + $this->assertSame(500, $subscription->discounts[0]->amountOff); + $this->assertNull($subscription->discounts[0]->cycles); + $this->assertSame(300, $subscription->discounts[1]->amountOff); + $this->assertSame(1, $subscription->discounts[1]->cycles); + $this->assertTrue($subscription->items[0]->recurring); + } + + public function testParseReadsRecurrentFalseAsANonRecurringItem(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'subitems' => [ + (object) ['id' => 'si_1', 'description' => 'Setup', 'price_cents' => 500, 'quantity' => 1, 'recurrent' => false], + ], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->assertFalse( + (new IuguGateway($api))->getSubscription($subscription)->items[0]->recurring + ); + } + + public function testParseReadsNextBillingFromExpiresAt(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse()]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame('2026-10-01', $subscription->nextBillingAt->format('Y-m-d')); + $this->assertNull($subscription->trialEndsAt); + $this->assertSame(10000, $subscription->amount); + $this->assertSame('cus_1', $subscription->customer->id); + $this->assertSame('iugu', $subscription->gateway); + } + + /** + * Derivação do status a partir das flags da Iugu, um caso por estado genérico que o driver + * produz, com a data de hoje fixada em 2026-09-15 (a resposta padrão vence em 2026-10-01). + */ + #[DataProvider('statusProvider')] + public function testParseMapsIuguFlagsToGenericStatus(array $flags, ?SubscriptionStatus $expected): void + { + Carbon::setTestNow('2026-09-15 12:00:00'); + $api = new QueuedIuguApiRequest([$this->subscriptionResponse($flags)]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame($expected, $subscription->status); + } + + public static function statusProvider(): array + { + $canceledMark = [(object) ['name' => 'mp_canceled_at', 'value' => '2026-09-10T10:00:00-03:00']]; + $openInvoice = [(object) ['id' => 'inv_1', 'status' => 'pending', 'due_date' => '2026-09-01']]; + + return [ + 'suspensa' => [['suspended' => true, 'active' => false], SubscriptionStatus::SUSPENDED], + 'suspensa tem precedencia sobre trial' => [ + ['suspended' => true, 'in_trial' => true], + SubscriptionStatus::SUSPENDED, + ], + 'suspensa com a marca de cancelamento' => [ + ['suspended' => true, 'active' => false, 'custom_variables' => $canceledMark], + SubscriptionStatus::CANCELED, + ], + 'ativa com a marca de cancelamento continua ativa' => [ + ['suspended' => false, 'active' => true, 'custom_variables' => $canceledMark], + SubscriptionStatus::ACTIVE, + ], + 'suspensa com a marca vazia' => [ + ['suspended' => true, 'custom_variables' => [(object) ['name' => 'mp_canceled_at', 'value' => '']]], + SubscriptionStatus::SUSPENDED, + ], + 'em trial' => [['in_trial' => true], SubscriptionStatus::TRIALING], + 'ativa' => [['active' => true], SubscriptionStatus::ACTIVE], + 'ativa com cobranca vencida e fatura em aberto' => [ + ['active' => true, 'expires_at' => '2026-09-01', 'recent_invoices' => $openInvoice], + SubscriptionStatus::PAST_DUE, + ], + 'ativa com cobranca vencida e sem fatura em aberto' => [ + ['active' => true, 'expires_at' => '2026-09-01', 'recent_invoices' => []], + SubscriptionStatus::ACTIVE, + ], + 'inativa com cobranca futura' => [['active' => false], SubscriptionStatus::PENDING], + 'inativa sem data de cobranca' => [['active' => false, 'expires_at' => null], SubscriptionStatus::PENDING], + 'inativa com cobranca vencida no dia' => [ + ['active' => false, 'expires_at' => '2026-09-15'], + SubscriptionStatus::PENDING, + ], + 'inativa com cobranca vencida e sem fatura em aberto' => [ + ['active' => false, 'expires_at' => '2026-09-01', 'recent_invoices' => []], + SubscriptionStatus::EXPIRED, + ], + 'inativa com cobranca vencida e fatura em aberto' => [ + ['active' => false, 'expires_at' => '2026-09-01', 'recent_invoices' => $openInvoice], + SubscriptionStatus::PAST_DUE, + ], + 'sem flag nenhuma' => [['active' => null, 'suspended' => null, 'in_trial' => null], null], + ]; + } + + public function testParseReadsTheCancellationMarkIntoCanceledAtAndKeepsItOutOfMetadata(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse([ + 'suspended' => true, + 'custom_variables' => [ + (object) ['name' => 'origem', 'value' => 'teste'], + (object) ['name' => 'mp_canceled_at', 'value' => '2026-09-10T10:00:00-03:00'], + ], + ])]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame(SubscriptionStatus::CANCELED, $subscription->status); + $this->assertSame('2026-09-10T10:00:00-03:00', $subscription->canceledAt->toIso8601String()); + $this->assertSame(['origem' => 'teste'], $subscription->metadata); + $this->assertFalse($subscription->cancelAtPeriodEnd); + } + + /** + * `custom_variables` vazia na resposta zera `canceledAt` e `metadata`: é o que a Iugu + * devolve depois de remover a última variável. + */ + public function testParseClearsCanceledAtAndMetadataWhenTheResponseHasNoVariablesLeft(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse(['custom_variables' => []])]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->canceledAt = Carbon::parse('2026-09-10T10:00:00-03:00'); + $subscription->metadata = ['mp_canceled_at' => '2026-09-10T10:00:00-03:00']; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertNull($subscription->canceledAt); + $this->assertSame([], $subscription->metadata); + $this->assertSame(SubscriptionStatus::ACTIVE, $subscription->status); + } + + public function testParseKeepsCanceledAtAndMetadataWhenTheResponseOmitsCustomVariables(): void + { + $response = $this->subscriptionResponse(['suspended' => true]); + unset($response->custom_variables); + $api = new QueuedIuguApiRequest([$response]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->canceledAt = Carbon::parse('2026-09-10T10:00:00-03:00'); + $subscription->metadata = ['origem' => 'teste']; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame('2026-09-10T10:00:00-03:00', $subscription->canceledAt->toIso8601String()); + $this->assertSame(['origem' => 'teste'], $subscription->metadata); + } + + public function testParseFillsTrialEndsAtWhileInTrial(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse(['in_trial' => true])]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame('2026-10-01', $subscription->trialEndsAt->format('Y-m-d')); + } + + /** + * A Iugu recusa remover e adicionar subitens na mesma requisição. + */ + public function testUpdateRemovesItemsInAnEarlierRequest(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'subitems' => [ + (object) ['id' => 'si_antigo', 'description' => 'Antigo', 'price_cents' => 100, 'quantity' => 1, 'recurrent' => true], + (object) ['id' => 'si_mantido', 'description' => 'Mantido', 'price_cents' => 200, 'quantity' => 1, 'recurrent' => true], + ], + ]), + $this->subscriptionResponse(), + $this->subscriptionResponse(), + ]); + + $subscription = new Subscription(); + $subscription->fill([ + 'id' => 'sub_1', + 'items' => [ + ['id' => 'si_mantido', 'description' => 'Mantido', 'amount' => 200, 'quantity' => 1], + ['description' => 'Novo', 'amount' => 300, 'quantity' => 1], + ], + ]); + + (new IuguGateway($api))->updateSubscription($subscription); + + $this->assertCount(3, $api->calls); + + $this->assertSame('GET', $api->calls[0]['method']); + + $this->assertSame('PUT', $api->calls[1]['method']); + $this->assertSame( + [['id' => 'si_antigo', '_destroy' => true]], + $api->calls[1]['data']['subitems'] + ); + + $this->assertSame('PUT', $api->calls[2]['method']); + $this->assertSame([ + ['description' => 'Mantido', 'price_cents' => 200, 'quantity' => 1, 'recurrent' => 1, 'id' => 'si_mantido'], + ['description' => 'Novo', 'price_cents' => 300, 'quantity' => 1, 'recurrent' => 1], + ], $api->calls[2]['data']['subitems']); + } + + public function testUpdateWithoutItemsToRemoveSkipsTheRemovalRequest(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['subitems' => []]), + $this->subscriptionResponse(), + ]); + + $subscription = new Subscription(); + $subscription->fill([ + 'id' => 'sub_1', + 'items' => [['description' => 'Novo', 'amount' => 300, 'quantity' => 1]], + ]); + + (new IuguGateway($api))->updateSubscription($subscription); + + $this->assertCount(2, $api->calls); + $this->assertSame('GET', $api->calls[0]['method']); + $this->assertSame('PUT', $api->calls[1]['method']); + } + + public function testUpdateWithoutItemsAtAllDoesNotReadTheCurrentState(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse()]); + + $subscription = new Subscription(); + $subscription->fill(['id' => 'sub_1', 'next_billing_at' => '2026-11-01']); + + (new IuguGateway($api))->updateSubscription($subscription); + + $this->assertCount(1, $api->calls); + $this->assertSame('PUT', $api->calls[0]['method']); + $this->assertSame('2026-11-01', $api->calls[0]['data']['expires_at']); + } + + public function testSuspendAndResumeHitTheirOwnEndpoints(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['suspended' => true]), + $this->subscriptionResponse(), + ]); + $gateway = new IuguGateway($api); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $suspended = $gateway->suspendSubscription($subscription); + $this->assertStringEndsWith('/subscriptions/sub_1/suspend', $api->calls[0]['url']); + $this->assertSame(SubscriptionStatus::SUSPENDED, $suspended->status); + + $resumed = $gateway->resumeSubscription($subscription); + $this->assertStringEndsWith('/subscriptions/sub_1/activate', $api->calls[1]['url']); + $this->assertSame(SubscriptionStatus::ACTIVE, $resumed->status); + } + + /** + * Cancelar na Iugu é suspender e gravar `mp_canceled_at` em `custom_variables`, nesta + * ordem; a leitura da resposta do `PUT` devolve `CANCELED` com `canceledAt` preenchido. + */ + public function testCancelWithoutPeriodEndSuspendsAndMarksTheSubscriptionAsCanceled(): void + { + Carbon::setTestNow(Carbon::parse('2026-09-02T10:00:00-03:00')); + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['suspended' => true]), + $this->subscriptionResponse([ + 'suspended' => true, + 'custom_variables' => [(object) ['name' => 'mp_canceled_at', 'value' => '2026-09-02T10:00:00-03:00']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $canceled = (new IuguGateway($api))->cancelSubscription($subscription); + + $this->assertCount(2, $api->calls); + $this->assertSame('POST', $api->calls[0]['method']); + $this->assertStringEndsWith('/subscriptions/sub_1/suspend', $api->calls[0]['url']); + $this->assertSame('PUT', $api->calls[1]['method']); + $this->assertStringEndsWith('/subscriptions/sub_1', $api->calls[1]['url']); + $this->assertSame( + ['custom_variables' => [['name' => 'mp_canceled_at', 'value' => Carbon::now()->toIso8601String()]]], + $api->calls[1]['data'], + 'a marca de cancelamento leva o instante da chamada em ISO 8601' + ); + + $this->assertSame($subscription, $canceled); + $this->assertSame(SubscriptionStatus::CANCELED, $canceled->status); + $this->assertSame('2026-09-02T10:00:00-03:00', $canceled->canceledAt->toIso8601String()); + } + + /** + * Segundo `cancel()` numa assinatura já marcada só repete a suspensão: a data original da + * marca fica, e o `PUT` não sai. + */ + public function testCancelOfAnAlreadyCanceledSubscriptionKeepsTheOriginalDate(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'suspended' => true, + 'custom_variables' => [(object) ['name' => 'mp_canceled_at', 'value' => '2026-09-01T10:00:00-03:00']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $canceled = (new IuguGateway($api))->cancelSubscription($subscription); + + $this->assertCount(1, $api->calls); + $this->assertStringEndsWith('/subscriptions/sub_1/suspend', $api->calls[0]['url']); + $this->assertSame(SubscriptionStatus::CANCELED, $canceled->status); + $this->assertSame('2026-09-01T10:00:00-03:00', $canceled->canceledAt->toIso8601String()); + } + + /** + * Falha no `PUT` da marca deixa o model com a resposta da suspensão: `SUSPENDED`, sem + * `canceledAt`, e a exceção sobe. + */ + public function testCancelLeavesTheSubscriptionSuspendedWhenTheMarkFails(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['suspended' => true]), + new \IuguObjectNotFound('not found'), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + try { + (new IuguGateway($api))->cancelSubscription($subscription); + $this->fail('esperava NotFoundException'); + } catch (\Potelo\MultiPayment\Exceptions\NotFoundException) { + } + + $this->assertCount(2, $api->calls); + $this->assertSame(SubscriptionStatus::SUSPENDED, $subscription->status); + $this->assertNull($subscription->canceledAt); + } + + /** + * Marca `mp_canceled_at` que não é uma data lê como ausente: a assinatura suspensa fica + * `SUSPENDED`, `canceledAt` nulo, e um aviso vai para o log. + */ + public function testAnUnreadableCancellationMarkIsIgnoredWithAWarning(): void + { + $app = \Illuminate\Support\Facades\Facade::getFacadeApplication(); + $app->instance('log', $logger = new RecordingLogger()); + + $api = new QueuedIuguApiRequest([$this->subscriptionResponse([ + 'suspended' => true, + 'custom_variables' => [(object) ['name' => 'mp_canceled_at', 'value' => 'sim']], + ])]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame(SubscriptionStatus::SUSPENDED, $subscription->status); + $this->assertNull($subscription->canceledAt); + $this->assertSame([], $subscription->metadata); + $this->assertCount(2, $logger->records, 'um aviso por leitura da marca (status e canceledAt)'); + $this->assertSame('warning', $logger->records[0]['level']); + $this->assertStringContainsString('mp_canceled_at', $logger->records[0]['message']); + $this->assertSame(['subscription' => 'sub_1', 'value' => 'sim', 'gateway' => 'iugu'], $logger->records[0]['context']); + } + + public function testCancelDoesNotMarkTheSubscriptionWhenTheSuspensionFails(): void + { + $api = new QueuedIuguApiRequest([new \IuguObjectNotFound('not found')]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + try { + (new IuguGateway($api))->cancelSubscription($subscription); + $this->fail('esperava NotFoundException'); + } catch (\Potelo\MultiPayment\Exceptions\NotFoundException) { + } + + $this->assertCount(1, $api->calls); + $this->assertNull($subscription->canceledAt); + } + + /** + * Reativar uma assinatura cancelada remove a marca `mp_canceled_at` (`PUT` com `_destroy`) + * depois do `activate`, e o status volta a `ACTIVE`. + */ + public function testResumeClearsTheCancellationMark(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'custom_variables' => [(object) ['name' => 'mp_canceled_at', 'value' => '2026-09-02T10:00:00-03:00']], + ]), + $this->subscriptionResponse(['custom_variables' => []]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $resumed = (new IuguGateway($api))->resumeSubscription($subscription); + + $this->assertCount(2, $api->calls); + $this->assertStringEndsWith('/subscriptions/sub_1/activate', $api->calls[0]['url']); + $this->assertSame('PUT', $api->calls[1]['method']); + $this->assertStringEndsWith('/subscriptions/sub_1', $api->calls[1]['url']); + $this->assertSame( + ['custom_variables' => [ + ['name' => 'mp_canceled_at', '_destroy' => true], + ['name' => 'mp_cancel_at_period_end', '_destroy' => true], + ['name' => 'mp_cancel_scheduled_for', '_destroy' => true], + ]], + $api->calls[1]['data'] + ); + $this->assertSame(SubscriptionStatus::ACTIVE, $resumed->status); + $this->assertNull($resumed->canceledAt); + } + + /** + * Com chave, o `PUT` que remove a marca recebe `{chave}:uncancel`, e o retry inteiro sai + * da store sem nova requisição. + */ + public function testResumeStoresTheActivationAndTheUnmarkUnderTheirOwnKeys(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'custom_variables' => [(object) ['name' => 'mp_canceled_at', 'value' => '2026-09-02T10:00:00-03:00']], + ]), + $this->subscriptionResponse(['custom_variables' => []]), + ]); + $store = new \Potelo\MultiPayment\Idempotency\InMemoryIdempotencyStore(); + $gateway = new IuguGateway($api, $store); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $gateway->resumeSubscription($subscription, 'chave-1'); + $this->assertTrue($store->has('iugu:chave-1')); + $this->assertTrue($store->has('iugu:chave-1:uncancel')); + $this->assertSame([], $api->calls[1]['headers']); + + $again = new Subscription(); + $again->id = 'sub_1'; + $retried = $gateway->resumeSubscription($again, 'chave-1'); + + $this->assertCount(2, $api->calls); + $this->assertSame(SubscriptionStatus::ACTIVE, $retried->status); + $this->assertNull($retried->canceledAt); + } + + /** + * Quando a resposta do `activate` não traz `custom_variables`, a marca conhecida pelo + * model decide o `PUT` de remoção. + */ + public function testResumeUsesTheMarkKnownByTheModelWhenTheResponseOmitsCustomVariables(): void + { + $activate = $this->subscriptionResponse(); + unset($activate->custom_variables); + $api = new QueuedIuguApiRequest([$activate, $this->subscriptionResponse(['custom_variables' => []])]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->canceledAt = Carbon::parse('2026-09-02T10:00:00-03:00'); + + $resumed = (new IuguGateway($api))->resumeSubscription($subscription); + + $this->assertCount(2, $api->calls); + $this->assertSame('PUT', $api->calls[1]['method']); + $this->assertNull($resumed->canceledAt); + } + + public function testResumeOfASuspendedSubscriptionDoesNotTouchCustomVariables(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse(['custom_variables' => [ + (object) ['name' => 'origem', 'value' => 'teste'], + ]])]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $resumed = (new IuguGateway($api))->resumeSubscription($subscription); + + $this->assertCount(1, $api->calls); + $this->assertSame(SubscriptionStatus::ACTIVE, $resumed->status); + $this->assertSame(['origem' => 'teste'], $resumed->metadata); + } + + public function testCancelAtPeriodEndSchedulesTheCancellationWithoutSuspending(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse([ + 'custom_variables' => [ + (object) ['name' => 'mp_cancel_at_period_end', 'value' => '1'], + (object) ['name' => 'mp_cancel_scheduled_for', 'value' => '2026-12-01'], + ], + ])]); + + $subscription = new Subscription(); + $subscription->fill(['id' => 'sub_1', 'next_billing_at' => '2026-12-01']); + + $canceled = (new IuguGateway($api))->cancelSubscription($subscription, true); + + $this->assertCount(1, $api->calls); + $this->assertSame('PUT', $api->calls[0]['method']); + $this->assertStringEndsWith('/subscriptions/sub_1', $api->calls[0]['url']); + $this->assertSame( + ['custom_variables' => [ + ['name' => 'mp_cancel_at_period_end', 'value' => '1'], + ['name' => 'mp_cancel_scheduled_for', 'value' => '2026-12-01'], + ]], + $api->calls[0]['data'] + ); + $this->assertSame(SubscriptionStatus::ACTIVE, $canceled->status); + $this->assertTrue($canceled->cancelAtPeriodEnd); + $this->assertNull($canceled->canceledAt); + } + + /** + * Sem `nextBillingAt` no model, a data programada vem de uma leitura da assinatura; sem + * data de cobrança na resposta, não há fim de período e a operação é recusada antes de + * qualquer escrita. + */ + public function testCancelAtPeriodEndReadsTheBillingDateWhenTheModelDoesNotHaveIt(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['expires_at' => '2026-11-15']), + $this->subscriptionResponse(), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + (new IuguGateway($api))->cancelSubscription($subscription, true); + + $this->assertCount(2, $api->calls); + $this->assertSame('GET', $api->calls[0]['method']); + $this->assertSame( + ['name' => 'mp_cancel_scheduled_for', 'value' => '2026-11-15'], + $api->calls[1]['data']['custom_variables'][1] + ); + } + + public function testCancelAtPeriodEndWithoutABillingDateIsRejected(): void + { + $response = $this->subscriptionResponse(); + unset($response->expires_at); + $api = new QueuedIuguApiRequest([$response]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + try { + (new IuguGateway($api))->cancelSubscription($subscription, true); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('no billing date', $e->getMessage()); + } + $this->assertCount(1, $api->calls); + $this->assertSame('GET', $api->calls[0]['method']); + } + + public function testChangePlanWithoutChargeSendsSkipChargeAndTheNewBillingDate(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse()]); + + $subscription = new Subscription(); + $subscription->fill(['id' => 'sub_1', 'next_billing_at' => '2026-12-01']); + + (new IuguGateway($api))->changeSubscriptionPlan($subscription, 'plano_anual', ProrationBehavior::NONE); + + $this->assertCount(1, $api->calls); + $this->assertSame('PUT', $api->calls[0]['method']); + $this->assertSame('plano_anual', $api->calls[0]['data']['plan_identifier']); + $this->assertTrue($api->calls[0]['data']['skip_charge']); + $this->assertSame('2026-12-01', $api->calls[0]['data']['expires_at']); + } + + public function testChangePlanWithChargeUsesTheChangePlanEndpointThenReloads(): void + { + $api = new QueuedIuguApiRequest([ + (object) ['success' => true], + $this->subscriptionResponse(['plan_identifier' => 'plano_anual']), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $changed = (new IuguGateway($api)) + ->changeSubscriptionPlan($subscription, 'plano_anual', ProrationBehavior::CHARGE_DIFFERENCE); + + $this->assertCount(2, $api->calls); + $this->assertSame('POST', $api->calls[0]['method']); + $this->assertStringEndsWith('/subscriptions/sub_1/change_plan/plano_anual', $api->calls[0]['url']); + $this->assertSame('GET', $api->calls[1]['method']); + $this->assertSame('plano_anual', $changed->planId); + } + + /** + * `CREDIT` é limitação da Iugu: a recusa aponta `PLAN_CHANGE_PRORATION` e nenhuma + * requisição sai. + */ + public function testChangePlanWithCreditIsRefusedBeforeTheNetwork(): void + { + $api = new QueuedIuguApiRequest([]); + $store = new InMemoryIdempotencyStore(); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + try { + (new IuguGateway($api, $store)) + ->changeSubscriptionPlan($subscription, 'plano_anual', ProrationBehavior::CREDIT, 'chave-1'); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::PLAN_CHANGE_PRORATION, $e->capability); + $this->assertSame('iugu', $e->gateway); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertStringContainsString('ProrationBehavior::CHARGE_DIFFERENCE', $e->getMessage()); + } + $this->assertCount(0, $api->calls); + // a recusa vem antes da chave entrar na store + $this->assertFalse($store->has('iugu:chave-1')); + } + + /** + * O booleano antigo continua aceito pelo driver: `true` segue para `change_plan` e `false` + * para o `PUT` com `skip_charge`, com aviso de obsolescência. + */ + #[DataProvider('deprecatedChargeProvider')] + #[IgnoreDeprecations] + public function testChangePlanTranslatesTheDeprecatedBoolean(bool $charge, string $method, array $responses): void + { + $api = new QueuedIuguApiRequest($responses); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->expectUserDeprecationMessage( + 'O booleano $charge de changePlan() está obsoleto desde 2026-09-02; passe' + . ' ProrationBehavior::CHARGE_DIFFERENCE ou ProrationBehavior::NONE' + ); + + (new IuguGateway($api))->changeSubscriptionPlan($subscription, 'plano_anual', $charge); + + $this->assertSame($method, $api->calls[0]['method']); + if ($charge) { + $this->assertStringEndsWith('/subscriptions/sub_1/change_plan/plano_anual', $api->calls[0]['url']); + } else { + $this->assertStringEndsWith('/subscriptions/sub_1', $api->calls[0]['url']); + $this->assertTrue($api->calls[0]['data']['skip_charge']); + } + } + + public static function deprecatedChargeProvider(): array + { + $reloaded = (object) ['id' => 'sub_1', 'active' => true, 'plan_identifier' => 'plano_anual']; + + return [ + 'true cobra pelo change_plan' => [true, 'POST', [(object) ['success' => true], $reloaded]], + 'false troca pelo PUT com skip_charge' => [false, 'PUT', [$reloaded]], + ]; + } + + /** + * Do model ao driver, o booleano antigo dispara um único aviso: o model o traduz e o driver + * recebe o enum. + */ + public function testTheDeprecatedBooleanTriggersASingleNoticeFromTheModelToTheDriver(): void + { + $api = new QueuedIuguApiRequest([ + (object) ['id' => 'sub_1', 'active' => true, 'plan_identifier' => 'plano_anual'], + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $notices = []; + set_error_handler(function (int $level, string $message) use (&$notices): bool { + $notices[] = $message; + + return true; + }, E_USER_DEPRECATED); + + try { + $subscription->changePlan('plano_anual', false, new IuguGateway($api)); + } finally { + restore_error_handler(); + } + + $this->assertCount(1, $notices); + $this->assertSame('PUT', $api->calls[0]['method']); + } + + /** + * O argumento nomeado `charge` antigo continua aceito pelo driver e prevalece sobre a + * política, com aviso de obsolescência. + */ + #[IgnoreDeprecations] + public function testChangePlanAcceptsTheDeprecatedNamedChargeArgument(): void + { + $api = new QueuedIuguApiRequest([ + (object) ['id' => 'sub_1', 'active' => true, 'plan_identifier' => 'plano_anual'], + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->expectUserDeprecationMessage( + 'O booleano $charge de changePlan() está obsoleto desde 2026-09-02; passe' + . ' ProrationBehavior::CHARGE_DIFFERENCE ou ProrationBehavior::NONE' + ); + + (new IuguGateway($api))->changeSubscriptionPlan($subscription, 'plano_anual', charge: false); + + $this->assertCount(1, $api->calls); + $this->assertSame('PUT', $api->calls[0]['method']); + $this->assertTrue($api->calls[0]['data']['skip_charge']); + } + + /** + * O plano da assinatura devolvida vem do gateway; o plano pedido só é mantido quando a + * resposta não traz `plan_identifier`. + */ + public function testChangePlanKeepsTheRequestedPlanWhenTheReloadOmitsIt(): void + { + $api = new QueuedIuguApiRequest([ + (object) ['success' => true], + (object) ['id' => 'sub_1', 'active' => true], + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $changed = (new IuguGateway($api))->changeSubscriptionPlan($subscription, 'plano_anual'); + + $this->assertSame('plano_anual', $changed->planId); + } + + /** + * Resposta real de `change_plan_simulation` gravada na sandbox, de uma assinatura com + * subitem e desconto ativos: só `cost`, `discount`, `cycles`, `expires_at`, `new_plan` e + * `old_plan`, com `discount` em 0 e sem linhas. O parse lê `cost` e monta uma única linha, + * a de cobrança do plano novo; o model só com o id faz o driver ler a assinatura antes da + * simulação, e a assinatura paga por Pix não aplica o plano na hora. + */ + public function testPreviewPlanChangeReadsTheSimulationResponse(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['payable_with' => 'pix']), + json_decode( + file_get_contents(__DIR__ . '/../../fixtures/iugu/change_plan_simulation.json'), + flags: JSON_THROW_ON_ERROR + ), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $planChange = (new IuguGateway($api)) + ->previewSubscriptionPlanChange($subscription, 'multipayment-teste-destino'); + + $this->assertCount(2, $api->calls); + $this->assertSame('GET', $api->calls[0]['method']); + $this->assertStringEndsWith('/subscriptions/sub_1', $api->calls[0]['url']); + $this->assertStringEndsWith( + '/subscriptions/sub_1/change_plan_simulation/multipayment-teste-destino', + $api->calls[1]['url'] + ); + $this->assertSame(30000, $planChange->amount); + $this->assertSame('2026-10-02', $planChange->effectiveAt->format('Y-m-d')); + $this->assertFalse($planChange->appliesImmediately); + $this->assertCount(1, $planChange->items); + $this->assertSame('Plano multipayment-teste-destino', $planChange->items[0]->description); + $this->assertSame(30000, $planChange->items[0]->price); + $this->assertSame(1, $planChange->items[0]->quantity); + $this->assertSame(0, $planChange->original->discount); + $this->assertSame(1, $planChange->original->cycles); + $this->assertSame('multipayment-teste-destino', $planChange->original->new_plan); + $this->assertSame('multipayment-teste-origem', $planChange->original->old_plan); + // a leitura prévia não altera o model do chamador + $this->assertNull($subscription->availablePaymentMethods); + } + + /** + * A leitura prévia usa um model à parte: o cliente do model do chamador não é sobrescrito + * com o que veio do gateway. + */ + public function testPreviewReadsTheSubscriptionWithoutTouchingTheCallersCustomer(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'payable_with' => 'pix', + 'customer_name' => 'Nome do gateway', + 'customer_email' => 'gateway@exemplo.com', + ]), + (object) ['cost' => 30000, 'discount' => 0, 'expires_at' => '2026-10-02', 'new_plan' => 'p', 'old_plan' => 'o'], + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->customer = new Customer(); + $subscription->customer->id = 'cus_1'; + $subscription->customer->name = 'Nome local'; + + (new IuguGateway($api))->previewSubscriptionPlanChange($subscription, 'p'); + + $this->assertCount(2, $api->calls); + $this->assertSame('Nome local', $subscription->customer->name); + $this->assertNull($subscription->customer->email); + } + + /** + * `creditCard` é atributo de escrita e não diz como a assinatura é paga no gateway: com + * ele sozinho o driver ainda lê a assinatura, e `payable_with: all` responde falso. + */ + public function testPreviewReadsTheSubscriptionWhenTheModelOnlyHasACreditCard(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['payable_with' => 'all']), + (object) ['cost' => 30000, 'discount' => 0, 'expires_at' => '2026-10-02', 'new_plan' => 'p', 'old_plan' => 'o'], + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->creditCard = new CreditCard(); + $subscription->creditCard->id = 'pm_1'; + + $planChange = (new IuguGateway($api))->previewSubscriptionPlanChange($subscription, 'p'); + + $this->assertCount(2, $api->calls); + $this->assertFalse($planChange->appliesImmediately); + } + + /** + * Com `discount` maior que zero, a lib monta a linha de crédito do plano antigo com valor + * negativo, e a soma das linhas continua igual a `cost`. + */ + public function testPreviewSynthesizesACreditLineWhenTheSimulationHasADiscount(): void + { + $api = new QueuedIuguApiRequest([ + (object) [ + 'cost' => 25000, + 'discount' => 5000, + 'cycles' => 1, + 'expires_at' => '2026-10-02', + 'new_plan' => 'plano_anual', + 'old_plan' => 'plano_mensal', + ], + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->paymentMethod = PaymentMethod::PIX; + + $planChange = (new IuguGateway($api))->previewSubscriptionPlanChange($subscription, 'plano_anual'); + + $this->assertSame(25000, $planChange->amount); + $this->assertCount(2, $planChange->items); + $this->assertSame('Plano plano_anual', $planChange->items[0]->description); + $this->assertSame(30000, $planChange->items[0]->price); + $this->assertSame('Crédito do plano plano_mensal', $planChange->items[1]->description); + $this->assertSame(-5000, $planChange->items[1]->price); + $this->assertSame(1, $planChange->items[1]->quantity); + $this->assertSame( + $planChange->amount, + array_sum(array_map(fn (InvoiceItem $item) => $item->price * $item->quantity, $planChange->items)) + ); + } + + /** + * Assinatura paga só com cartão aplica o plano novo na hora; o model que já traz o método + * dispensa a leitura prévia. + */ + public function testPreviewAppliesImmediatelyWhenTheSubscriptionIsPaidOnlyByCard(): void + { + $api = new QueuedIuguApiRequest([ + (object) ['cost' => 30000, 'discount' => 0, 'expires_at' => '2026-10-02', 'new_plan' => 'p', 'old_plan' => 'o'], + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->paymentMethod = PaymentMethod::CREDIT_CARD; + + $planChange = (new IuguGateway($api))->previewSubscriptionPlanChange($subscription, 'p'); + + $this->assertCount(1, $api->calls); + $this->assertTrue($planChange->appliesImmediately); + } + + /** + * Model lido do gateway com `payable_with: credit_card` já traz a lista: sem leitura extra, + * e o plano novo vale na hora. + */ + public function testPreviewAppliesImmediatelyForASubscriptionReadWithCardOnly(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['payable_with' => 'credit_card']), + (object) ['cost' => 30000, 'discount' => 0, 'expires_at' => '2026-10-02', 'new_plan' => 'p', 'old_plan' => 'o'], + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $planChange = (new IuguGateway($api))->previewSubscriptionPlanChange($subscription, 'p'); + + $this->assertCount(2, $api->calls); + $this->assertTrue($planChange->appliesImmediately); + } + + /** + * As linhas sintetizadas toleram totais fora do esperado: `discount` não numérico ou + * negativo vale zero, e plano sem identificador ganha descrição genérica. + */ + #[DataProvider('unusualSimulationTotalsProvider')] + public function testPreviewSynthesizedLinesTolerateUnusualTotals(object $simulation, array $expectedItems): void + { + $api = new QueuedIuguApiRequest([$simulation]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->paymentMethod = PaymentMethod::PIX; + + $planChange = (new IuguGateway($api))->previewSubscriptionPlanChange($subscription, 'p'); + + $this->assertSame(30000, $planChange->amount); + $this->assertSame( + $expectedItems, + array_map(fn (InvoiceItem $item) => $item->toArray(), $planChange->items) + ); + } + + public static function unusualSimulationTotalsProvider(): array + { + return [ + 'discount formatado' => [ + (object) ['cost' => 30000, 'discount' => 'R$ 50,00', 'new_plan' => 'p', 'old_plan' => 'o'], + [['description' => 'Plano p', 'price' => 30000, 'quantity' => 1]], + ], + 'discount negativo' => [ + (object) ['cost' => 30000, 'discount' => -5000, 'new_plan' => 'p', 'old_plan' => 'o'], + [['description' => 'Plano p', 'price' => 30000, 'quantity' => 1]], + ], + 'planos sem identificador' => [ + (object) ['cost' => 30000, 'discount' => 5000], + [ + ['description' => 'Plano novo', 'price' => 35000, 'quantity' => 1], + ['description' => 'Crédito do plano anterior', 'price' => -5000, 'quantity' => 1], + ], + ], + ]; + } + + /** + * Assinatura aberta a mais de um método (`payable_with: all`) não aplica na hora: o driver + * não sabe se o cartão padrão será cobrado. + */ + public function testPreviewDoesNotApplyImmediatelyWhenTheSubscriptionAcceptsSeveralMethods(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['payable_with' => 'all']), + (object) ['cost' => 30000, 'discount' => 0, 'expires_at' => '2026-10-02', 'new_plan' => 'p', 'old_plan' => 'o'], + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $planChange = (new IuguGateway($api))->previewSubscriptionPlanChange($subscription, 'p'); + + $this->assertCount(2, $api->calls); + $this->assertFalse($planChange->appliesImmediately); + } + + public function testPreviewPlanChangeFallsBackToPriceCentsAndSubitems(): void + { + $api = new QueuedIuguApiRequest([ + (object) [ + 'price_cents' => 30000, + 'expires_at' => '2026-12-01', + 'subitems' => [ + (object) ['description' => 'Plano anual', 'price_cents' => 30000, 'quantity' => 1], + ], + ], + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->paymentMethod = PaymentMethod::PIX; + + $planChange = (new IuguGateway($api))->previewSubscriptionPlanChange($subscription, 'plano_anual'); + + $this->assertSame(30000, $planChange->amount); + $this->assertSame('2026-12-01', $planChange->effectiveAt->format('Y-m-d')); + $this->assertCount(1, $planChange->items); + $this->assertSame('Plano anual', $planChange->items[0]->description); + $this->assertSame(30000, $planChange->items[0]->price); + } + + public function testListSubscriptionsPaginatesByCustomer(): void + { + $api = new QueuedIuguApiRequest([ + (object) ['items' => [$this->subscriptionResponse(), $this->subscriptionResponse(['id' => 'sub_2'])]], + ]); + + $customer = new Customer(); + $customer->id = 'cus_1'; + + $subscriptions = (new IuguGateway($api))->listSubscriptions($customer, 2, 50); + + $this->assertStringContainsString('customer_id=cus_1', $api->calls[0]['url']); + $this->assertStringContainsString('limit=50', $api->calls[0]['url']); + $this->assertStringContainsString('start=50', $api->calls[0]['url']); + $this->assertCount(2, $subscriptions); + $this->assertSame('sub_2', $subscriptions[1]->id); + } + + public function testPercentageDiscountIsRejected(): void + { + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->percentOff = 10; + + $subscription = new Subscription(); + $subscription->fill(['plan_id' => 'plano', 'customer' => ['id' => 'cus_1']]); + $subscription->discounts = [$discount]; + + $api = new QueuedIuguApiRequest([]); + + try { + (new IuguGateway($api))->createSubscription($subscription); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::PERCENT_DISCOUNT, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertStringContainsString('use amountOff', $e->getMessage()); + } + $this->assertCount(0, $api->calls); + } + + public function testDiscountWithoutAmountOffIsRejectedByTheMapper(): void + { + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->discounts = [$discount]; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/`amountOff` attribute is required/'); + + (new IuguGateway(new QueuedIuguApiRequest([])))->updateSubscription($subscription); + } + + public function testItemWithoutAmountIsRejectedByTheMapper(): void + { + $item = new SubscriptionItem(); + $item->description = 'X'; + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->items = [$item]; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/`amount` attribute is required/'); + + (new IuguGateway(new QueuedIuguApiRequest([])))->updateSubscription($subscription); + } + + public function testCreatePlanMapsIntervalToIuguIntervalType(): void + { + $api = new QueuedIuguApiRequest([ + (object) [ + 'id' => 'plan_1', + 'identifier' => 'mensal', + 'name' => 'Mensal', + 'interval' => 1, + 'interval_type' => 'months', + 'prices' => [(object) ['value_cents' => 10000, 'currency' => 'BRL']], + ], + ]); + + $plan = new Plan(); + $plan->name = 'Mensal'; + $plan->identifier = 'mensal'; + $plan->amount = 10000; + $plan->interval = PlanInterval::MONTH; + $plan->intervalCount = 1; + + $created = (new IuguGateway($api))->createPlan($plan); + + $this->assertSame('months', $api->calls[0]['data']['interval_type']); + $this->assertSame(1, $api->calls[0]['data']['interval']); + $this->assertSame(10000, $api->calls[0]['data']['value_cents']); + $this->assertSame(PlanInterval::MONTH, $created->interval); + $this->assertSame(10000, $created->amount); + $this->assertSame('BRL', $created->currency); + } + + /** + * A Iugu não tem intervalo anual: `year` vai como múltiplo de 12 meses e volta como `year`. + */ + #[DataProvider('intervalRoundTripProvider')] + public function testCreatePlanTranslatesTheIntervalBothWays( + PlanInterval $interval, + int $intervalCount, + int $iuguInterval, + string $iuguIntervalType + ): void { + $api = new QueuedIuguApiRequest([ + (object) [ + 'id' => 'plan_1', + 'identifier' => 'plano', + 'name' => 'Plano', + 'interval' => $iuguInterval, + 'interval_type' => $iuguIntervalType, + 'prices' => [(object) ['value_cents' => 100000, 'currency' => 'BRL']], + ], + ]); + + $plan = new Plan(); + $plan->name = 'Plano'; + $plan->identifier = 'plano'; + $plan->amount = 100000; + $plan->interval = $interval; + $plan->intervalCount = $intervalCount; + + $created = (new IuguGateway($api))->createPlan($plan); + + $this->assertSame([ + 'name' => 'Plano', + 'identifier' => 'plano', + 'interval' => $iuguInterval, + 'interval_type' => $iuguIntervalType, + 'value_cents' => 100000, + ], $api->calls[0]['data']); + $this->assertSame($interval, $created->interval); + $this->assertSame($intervalCount, $created->intervalCount); + } + + public static function intervalRoundTripProvider(): array + { + return [ + 'anual' => [PlanInterval::YEAR, 1, 12, 'months'], + 'bianual' => [PlanInterval::YEAR, 2, 24, 'months'], + 'mensal' => [PlanInterval::MONTH, 1, 1, 'months'], + 'semestral' => [PlanInterval::MONTH, 6, 6, 'months'], + 'quinzenal' => [PlanInterval::WEEK, 2, 2, 'weeks'], + ]; + } + + public function testYearlyPlanWithoutIntervalCountIsSentAsTwelveMonths(): void + { + $api = new QueuedIuguApiRequest([ + (object) ['id' => 'plan_1', 'identifier' => 'anual', 'name' => 'Anual', 'interval' => 12, 'interval_type' => 'months', 'value_cents' => 100000], + ]); + + $plan = new Plan(); + $plan->name = 'Anual'; + $plan->identifier = 'anual'; + $plan->amount = 100000; + $plan->interval = PlanInterval::YEAR; + + $created = (new IuguGateway($api))->createPlan($plan); + + $this->assertSame(12, $api->calls[0]['data']['interval']); + $this->assertSame('months', $api->calls[0]['data']['interval_type']); + $this->assertSame(PlanInterval::YEAR, $created->interval); + $this->assertSame(1, $created->intervalCount); + } + + /** + * A heurística de leitura vale também para o model que o chamador mandou: um plano criado + * como 12 meses sai do createPlan como 1 ano. + */ + public function testTwelveMonthPlanIsReadBackAsYearly(): void + { + $api = new QueuedIuguApiRequest([ + (object) ['id' => 'plan_1', 'identifier' => 'doze', 'name' => 'Doze', 'interval' => 12, 'interval_type' => 'months', 'value_cents' => 100000], + ]); + + $plan = new Plan(); + $plan->name = 'Doze'; + $plan->identifier = 'doze'; + $plan->amount = 100000; + $plan->interval = PlanInterval::MONTH; + $plan->intervalCount = 12; + + $created = (new IuguGateway($api))->createPlan($plan); + + $this->assertSame(12, $api->calls[0]['data']['interval']); + $this->assertSame(PlanInterval::YEAR, $created->interval); + $this->assertSame(1, $created->intervalCount); + $this->assertSame(12, $created->original->interval); + } + + #[DataProvider('invalidIntervalProvider')] + public function testInvalidIntervalIsRejectedBeforeTheRequest(PlanInterval|string|null $interval, int $intervalCount, string $message): void + { + $api = new QueuedIuguApiRequest([]); + + $plan = new Plan(); + $plan->name = 'Plano'; + $plan->amount = 100000; + $plan->interval = $interval; + $plan->intervalCount = $intervalCount; + + try { + (new IuguGateway($api))->createPlan($plan); + $this->fail('Expected ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertMatchesRegularExpression($message, $e->getMessage()); + } + + $this->assertCount(0, $api->calls); + } + + public static function invalidIntervalProvider(): array + { + return [ + 'diário como string' => ['day', 1, '/does not support the `day` plan interval/'], + 'diário como enum' => [PlanInterval::DAY, 1, '/does not support the `day` plan interval/'], + 'intervalo ausente' => [null, 1, '/does not support the `null` plan interval/'], + 'anual acima do teto da Iugu' => [PlanInterval::YEAR, 50, '/from 1 to 599 months, 600 given/'], + 'mensal acima do teto da Iugu' => [PlanInterval::MONTH, 600, '/from 1 to 599 months, 600 given/'], + ]; + } + + public function testGetPlanKeepsTheLocalIntervalWhenTheResponseOmitsIt(): void + { + $api = new QueuedIuguApiRequest([ + (object) ['id' => 'plan_1', 'name' => 'Plano', 'interval_type' => 'days', 'value_cents' => 10000], + ]); + + $plan = new Plan(); + $plan->id = 'plan_1'; + $plan->interval = PlanInterval::YEAR; + $plan->intervalCount = 1; + + $found = (new IuguGateway($api))->getPlan($plan); + + $this->assertSame(PlanInterval::YEAR, $found->interval); + $this->assertSame(1, $found->intervalCount); + } + + /** + * Na leitura, múltiplo de 12 meses vira `year`; o resto mantém o tipo da Iugu. + */ + #[DataProvider('iuguIntervalParseProvider')] + public function testGetPlanParsesTheIuguInterval( + int|string $iuguInterval, + string $iuguIntervalType, + PlanInterval $interval, + int $intervalCount + ): void { + $api = new QueuedIuguApiRequest([ + (object) ['id' => 'plan_1', 'identifier' => 'plano', 'name' => 'Plano', 'interval' => $iuguInterval, 'interval_type' => $iuguIntervalType, 'value_cents' => 10000], + ]); + + $plan = new Plan(); + $plan->id = 'plan_1'; + + $found = (new IuguGateway($api))->getPlan($plan); + + $this->assertSame($interval, $found->interval); + $this->assertSame($intervalCount, $found->intervalCount); + } + + public static function iuguIntervalParseProvider(): array + { + return [ + '12 meses vira 1 ano' => [12, 'months', PlanInterval::YEAR, 1], + '24 meses vira 2 anos' => [24, 'months', PlanInterval::YEAR, 2], + '6 meses continua mensal' => [6, 'months', PlanInterval::MONTH, 6], + '1 mês continua mensal' => [1, 'months', PlanInterval::MONTH, 1], + '12 semanas continua semanal' => [12, 'weeks', PlanInterval::WEEK, 12], + '12 como string vira 1 ano' => ['12', 'months', PlanInterval::YEAR, 1], + ]; + } + + public function testDeactivatePlanIsRejected(): void + { + $plan = new Plan(); + $plan->id = 'plan_1'; + + $api = new QueuedIuguApiRequest([]); + + try { + (new IuguGateway($api))->deactivatePlan($plan); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::PLAN_DEACTIVATION, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertStringContainsString('flag de ativo', $e->getMessage()); + } + $this->assertCount(0, $api->calls); + } + + public function testGatewayErrorsBecomeGatewayException(): void + { + $api = new QueuedIuguApiRequest([(object) ['errors' => ['plan_identifier' => 'não encontrado']]]); + + $subscription = new Subscription(); + $subscription->fill(['plan_id' => 'inexistente', 'customer' => ['id' => 'cus_1']]); + + $this->expectException(GatewayException::class); + $this->expectExceptionMessageMatches('/creating subscription/'); + + (new IuguGateway($api))->createSubscription($subscription); + } + + /** + * A Iugu não tem estado de inadimplência: fatura vencida em aberto deixa a assinatura + * `active` com `expires_at` no passado. + */ + public function testDerivesPastDueFromOverdueDateAndUnpaidInvoice(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2026-08-01', + 'recent_invoices' => [ + (object) ['id' => 'inv_1', 'status' => 'pending', 'due_date' => '2026-08-01', 'secure_url' => 'https://iugu/inv_1'], + ], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame(SubscriptionStatus::PAST_DUE, $subscription->status); + $this->assertInstanceOf(Invoice::class, $subscription->latestInvoice); + $this->assertSame('inv_1', $subscription->latestInvoice->id); + $this->assertSame('https://iugu/inv_1', $subscription->latestInvoice->url); + } + + /** + * A fatura resumida de `recent_invoices` também vem do objeto de fatura da Iugu: + * `originType` é `INVOICE`. + */ + public function testLatestInvoiceMarksTheOriginAsInvoice(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'recent_invoices' => [(object) ['id' => 'inv_1', 'status' => 'paid']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame(InvoiceOriginType::INVOICE, $subscription->latestInvoice->originType); + } + + /** + * `expired` conta como dívida, mas o status genérico da fatura é canceled. + */ + public function testExpiredInvoiceAlsoCountsAsPastDue(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2026-08-01', + 'recent_invoices' => [(object) ['id' => 'inv_1', 'status' => 'expired']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame(SubscriptionStatus::PAST_DUE, $subscription->status); + $this->assertSame(InvoiceStatus::EXPIRED, $subscription->latestInvoice->status); + } + + public function testPaidInvoiceWithOverdueDateIsNotPastDue(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2026-08-01', + 'recent_invoices' => [(object) ['id' => 'inv_1', 'status' => 'paid']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->assertSame( + SubscriptionStatus::ACTIVE, + (new IuguGateway($api))->getSubscription($subscription)->status + ); + } + + public function testUnpaidInvoiceWithFutureBillingDateIsNotPastDue(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2099-01-01', + 'recent_invoices' => [(object) ['id' => 'inv_1', 'status' => 'pending']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->assertSame( + SubscriptionStatus::ACTIVE, + (new IuguGateway($api))->getSubscription($subscription)->status + ); + } + + public function testSuspendedTakesPrecedenceOverPastDue(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'suspended' => true, + 'expires_at' => '2026-08-01', + 'recent_invoices' => [(object) ['id' => 'inv_1', 'status' => 'pending']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->assertSame( + SubscriptionStatus::SUSPENDED, + (new IuguGateway($api))->getSubscription($subscription)->status + ); + } + + /** + * `recent_invoices` é um resumo: status fora do mapa vira `UNKNOWN` com aviso no log, e + * a leitura da assinatura segue. + */ + public function testUnknownInvoiceStatusDoesNotBreakTheSubscriptionRead(): void + { + Facade::getFacadeApplication()->instance('log', $logger = new RecordingLogger()); + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'recent_invoices' => [(object) ['id' => 'inv_1', 'status' => 'status_novo_da_iugu']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame(SubscriptionStatus::ACTIVE, $subscription->status); + $this->assertSame('inv_1', $subscription->latestInvoice->id); + $this->assertSame(InvoiceStatus::UNKNOWN, $subscription->latestInvoice->status); + $this->assertSame('status_novo_da_iugu', $subscription->latestInvoice->original->status); + $this->assertCount(1, $logger->records); + $this->assertSame(['status' => 'status_novo_da_iugu', 'gateway' => 'iugu'], $logger->records[0]['context']); + } + + public function testSubscriptionWithoutRecentInvoicesHasNoLatestInvoice(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse()]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->assertNull((new IuguGateway($api))->getSubscription($subscription)->latestInvoice); + } + + /** + * Lista não informada mantém os subitens daquele tipo. + */ + public function testUpdatingOnlyItemsDoesNotDestroyDiscounts(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'subitems' => [ + (object) ['id' => 'si_item_antigo', 'description' => 'Antigo', 'price_cents' => 100, 'quantity' => 1, 'recurrent' => true], + (object) ['id' => 'si_desconto', 'description' => 'Promo', 'price_cents' => -500, 'quantity' => 1, 'recurrent' => true], + ], + ]), + $this->subscriptionResponse(), + $this->subscriptionResponse(), + ]); + + $subscription = new Subscription(); + $subscription->fill([ + 'id' => 'sub_1', + 'items' => [['description' => 'Novo', 'amount' => 300, 'quantity' => 1]], + ]); + + (new IuguGateway($api))->updateSubscription($subscription); + + $this->assertCount(3, $api->calls); + $this->assertSame( + [['id' => 'si_item_antigo', '_destroy' => true]], + $api->calls[1]['data']['subitems'] + ); + $this->assertSame( + [['description' => 'Novo', 'price_cents' => 300, 'quantity' => 1, 'recurrent' => 1]], + $api->calls[2]['data']['subitems'] + ); + } + + public function testUpdatingOnlyDiscountsDoesNotDestroyItems(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'subitems' => [ + (object) ['id' => 'si_item', 'description' => 'Consultas', 'price_cents' => 100, 'quantity' => 1, 'recurrent' => true], + (object) ['id' => 'si_desconto_antigo', 'description' => 'Promo', 'price_cents' => -500, 'quantity' => 1, 'recurrent' => true], + ], + ]), + $this->subscriptionResponse(), + $this->subscriptionResponse(), + ]); + + $subscription = new Subscription(); + $subscription->fill([ + 'id' => 'sub_1', + 'discounts' => [['description' => 'Nova promo', 'amount_off' => 300]], + ]); + + (new IuguGateway($api))->updateSubscription($subscription); + + $this->assertCount(3, $api->calls); + $this->assertSame( + [['id' => 'si_desconto_antigo', '_destroy' => true]], + $api->calls[1]['data']['subitems'] + ); + $this->assertSame( + [['description' => 'Nova promo', 'price_cents' => -300, 'quantity' => 1, 'recurrent' => 1]], + $api->calls[2]['data']['subitems'] + ); + } + + public function testEmptyItemListRemovesEveryItemAndKeepsDiscounts(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'subitems' => [ + (object) ['id' => 'si_a', 'price_cents' => 100], + (object) ['id' => 'si_b', 'price_cents' => 200], + (object) ['id' => 'si_desconto', 'price_cents' => -50], + ], + ]), + $this->subscriptionResponse(), + $this->subscriptionResponse(), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->items = []; + + (new IuguGateway($api))->updateSubscription($subscription); + + $this->assertSame([ + ['id' => 'si_a', '_destroy' => true], + ['id' => 'si_b', '_destroy' => true], + ], $api->calls[1]['data']['subitems']); + $this->assertArrayNotHasKey('subitems', $api->calls[2]['data']); + } + + public function testSubitemsFromTheGatewayAreAcceptedAsAssociativeArrays(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['subitems' => [['id' => 'si_a', 'price_cents' => 100]]]), + $this->subscriptionResponse(), + $this->subscriptionResponse(), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->items = []; + + (new IuguGateway($api))->updateSubscription($subscription); + + $this->assertSame([['id' => 'si_a', '_destroy' => true]], $api->calls[1]['data']['subitems']); + } + + public function testGatewayOptionsAlsoOverrideTheUpdatePayload(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse()]); + + $subscription = new Subscription(); + $subscription->fill(['id' => 'sub_1', 'next_billing_at' => '2026-11-01']); + $subscription->gatewayOptions = ['expires_at' => '2026-12-25', 'ignore_due_email' => true]; + + (new IuguGateway($api))->updateSubscription($subscription); + + $this->assertSame( + ['expires_at' => '2026-12-25', 'ignore_due_email' => true], + $api->calls[0]['data'] + ); + } + + /** + * Na Iugu o fim do trial e a próxima cobrança são o mesmo campo. + */ + public function testTrialEndsAtIsSentAsExpiresAt(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse()]); + + $subscription = new Subscription(); + $subscription->fill([ + 'plan_id' => 'plano_mensal', + 'customer' => ['id' => 'cus_1'], + 'trial_ends_at' => '2026-09-15', + ]); + + (new IuguGateway($api))->createSubscription($subscription); + + $this->assertSame('2026-09-15', $api->calls[0]['data']['expires_at']); + } + + public function testNextBillingAtAndTrialEndsAtTogetherAreRejected(): void + { + $subscription = new Subscription(); + $subscription->fill([ + 'plan_id' => 'plano_mensal', + 'customer' => ['id' => 'cus_1'], + 'next_billing_at' => '2026-10-01', + 'trial_ends_at' => '2026-09-15', + ]); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/same field/'); + + (new IuguGateway(new QueuedIuguApiRequest([])))->createSubscription($subscription); + } + + #[DataProvider('payableWithProvider')] + public function testParseMapsPayableWithBackToGenericMethods($payableWith, array $expected): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse(['payable_with' => $payableWith])]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->assertSame( + $expected, + (new IuguGateway($api))->getSubscription($subscription)->availablePaymentMethods + ); + } + + public static function payableWithProvider(): array + { + return [ + 'lista' => [ + ['credit_card', 'pix'], + [PaymentMethod::CREDIT_CARD, PaymentMethod::PIX], + ], + 'metodo desconhecido e ignorado' => [ + ['pix', 'crypto'], + [PaymentMethod::PIX], + ], + 'all expande nos tres' => [ + 'all', + [ + PaymentMethod::CREDIT_CARD, + PaymentMethod::BANK_SLIP, + PaymentMethod::PIX, + ], + ], + ]; + } + + public function testParseReadsMetadataAndCreatedAt(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'custom_variables' => [(object) ['name' => 'origem', 'value' => 'teste']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame(['origem' => 'teste'], $subscription->metadata); + $this->assertSame('2026-09-01', $subscription->createdAt->format('Y-m-d')); + } + + public function testGetPlanUsesTheIdentifierEndpointWhenThereIsNoId(): void + { + $api = new QueuedIuguApiRequest([ + (object) ['id' => 'plan_1', 'identifier' => 'mensal', 'name' => 'Mensal', 'interval' => 1, 'interval_type' => 'months', 'value_cents' => 10000], + ]); + + $plan = new Plan(); + $plan->identifier = 'mensal'; + + $found = (new IuguGateway($api))->getPlan($plan); + + $this->assertStringEndsWith('/plans/identifier/mensal', $api->calls[0]['url']); + $this->assertSame(10000, $found->amount); + $this->assertSame(PlanInterval::MONTH, $found->interval); + } + + public function testGetPlanRequiresIdOrIdentifier(): void + { + $this->expectException(ModelAttributeValidationException::class); + + (new IuguGateway(new QueuedIuguApiRequest([])))->getPlan(new Plan()); + } + + public function testListPlansPaginates(): void + { + $api = new QueuedIuguApiRequest([ + (object) ['items' => [(object) ['id' => 'plan_1', 'name' => 'Mensal', 'interval_type' => 'months']]], + ]); + + $plans = (new IuguGateway($api))->listPlans(3, 10); + + $this->assertStringContainsString('limit=10', $api->calls[0]['url']); + $this->assertStringContainsString('start=20', $api->calls[0]['url']); + $this->assertCount(1, $plans); + $this->assertSame(PlanInterval::MONTH, $plans[0]->interval); + $this->assertNull($plans[0]->intervalCount); + } + + #[DataProvider('methodsThatRequireSubscriptionIdProvider')] + public function testMethodsRequireTheSubscriptionId(callable $call): void + { + $this->expectException(ModelAttributeValidationException::class); + + $call(new IuguGateway(new QueuedIuguApiRequest([])), new Subscription()); + } + + public static function methodsThatRequireSubscriptionIdProvider(): array + { + return [ + 'get' => [fn(IuguGateway $g, Subscription $s) => $g->getSubscription($s)], + 'update' => [fn(IuguGateway $g, Subscription $s) => $g->updateSubscription($s)], + 'suspend' => [fn(IuguGateway $g, Subscription $s) => $g->suspendSubscription($s)], + 'resume' => [fn(IuguGateway $g, Subscription $s) => $g->resumeSubscription($s)], + 'cancel' => [fn(IuguGateway $g, Subscription $s) => $g->cancelSubscription($s)], + 'changePlan' => [fn(IuguGateway $g, Subscription $s) => $g->changeSubscriptionPlan($s, 'p')], + 'preview' => [fn(IuguGateway $g, Subscription $s) => $g->previewSubscriptionPlanChange($s, 'p')], + ]; + } + + public function testCreateRequiresACustomerWithId(): void + { + $subscription = new Subscription(); + $subscription->planId = 'plano'; + + $this->expectException(ModelAttributeValidationException::class); + + (new IuguGateway(new QueuedIuguApiRequest([])))->createSubscription($subscription); + } + + public function testListSubscriptionsRequiresTheCustomerId(): void + { + $this->expectException(ModelAttributeValidationException::class); + + (new IuguGateway(new QueuedIuguApiRequest([])))->listSubscriptions(new Customer()); + } + + #[DataProvider('invalidPaginationProvider')] + public function testPaginationBoundsAreRejected(int $page, int $limit, string $mensagem): void + { + $customer = new Customer(); + $customer->id = 'cus_1'; + $gateway = new IuguGateway(new QueuedIuguApiRequest([])); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches($mensagem); + + $gateway->listSubscriptions($customer, $page, $limit); + } + + #[DataProvider('invalidPaginationProvider')] + public function testPlanPaginationBoundsAreRejected(int $page, int $limit, string $mensagem): void + { + $gateway = new IuguGateway(new QueuedIuguApiRequest([])); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches(str_replace('Subscription', 'Plan', $mensagem)); + + $gateway->listPlans($page, $limit); + } + + public static function invalidPaginationProvider(): array + { + return [ + 'pagina zero' => [0, 100, '/Subscription page must be at least 1/'], + 'limite zero' => [1, 0, '/Subscription limit must be between 1 and 100/'], + 'limite acima de 100' => [1, 101, '/Subscription limit must be between 1 and 100/'], + ]; + } + + /** + * Com chave de idempotência, o `PUT` da marca de cancelamento recebe a chave derivada + * `{chave}:cancel`, e o retry inteiro sai da store sem nova requisição. + */ + public function testCancelStoresTheSuspensionAndTheMarkUnderTheirOwnKeys(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['suspended' => true]), + $this->subscriptionResponse([ + 'suspended' => true, + 'custom_variables' => [(object) ['name' => 'mp_canceled_at', 'value' => '2026-09-02T10:00:00-03:00']], + ]), + ]); + $store = new \Potelo\MultiPayment\Idempotency\InMemoryIdempotencyStore(); + $gateway = new IuguGateway($api, $store); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $gateway->cancelSubscription($subscription, false, 'chave-1'); + $this->assertTrue($store->has('iugu:chave-1')); + $this->assertTrue($store->has('iugu:chave-1:cancel')); + + $again = new Subscription(); + $again->id = 'sub_1'; + $retried = $gateway->cancelSubscription($again, false, 'chave-1'); + + $this->assertCount(2, $api->calls); + $this->assertSame(SubscriptionStatus::CANCELED, $retried->status); + } + + public function testListSubscriptionsAcceptsAPlainArrayResponse(): void + { + $api = new QueuedIuguApiRequest([[$this->subscriptionResponse()]]); + + $customer = new Customer(); + $customer->id = 'cus_1'; + + $this->assertCount(1, (new IuguGateway($api))->listSubscriptions($customer)); + } + + /** + * O parse preenche nextBillingAt e trialEndsAt do mesmo `expires_at`, então ler e regravar + * uma assinatura em trial não pode ser recusado por conflito. + */ + public function testSubscriptionReadWhileInTrialCanBeUpdatedBack(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['in_trial' => true]), + $this->subscriptionResponse(['in_trial' => true]), + ]); + $gateway = new IuguGateway($api); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = $gateway->getSubscription($subscription); + + $this->assertEquals($subscription->nextBillingAt, $subscription->trialEndsAt); + $this->assertNotSame($subscription->nextBillingAt, $subscription->trialEndsAt); + + $subscription->metadata = ['origem' => 'teste']; + $gateway->updateSubscription($subscription); + + $this->assertArrayNotHasKey('expires_at', $api->calls[1]['data']); + } + + /** + * Update reafirmando o que veio da leitura rebobinaria a data de cobrança quando o model + * está velho. + */ + public function testUpdateDoesNotResendTheBillingDateItJustRead(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(), + $this->subscriptionResponse(), + ]); + $gateway = new IuguGateway($api); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = $gateway->getSubscription($subscription); + + $subscription->metadata = ['origem' => 'teste']; + $gateway->updateSubscription($subscription); + + $this->assertArrayNotHasKey('expires_at', $api->calls[1]['data']); + $this->assertSame( + [['name' => 'origem', 'value' => 'teste']], + $api->calls[1]['data']['custom_variables'] + ); + } + + public function testUpdateSendsTheBillingDateWhenItActuallyChanged(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(), + $this->subscriptionResponse(), + ]); + $gateway = new IuguGateway($api); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = $gateway->getSubscription($subscription); + + $subscription->nextBillingAt = \Carbon\Carbon::parse('2026-12-01'); + $subscription->trialEndsAt = null; + $gateway->updateSubscription($subscription); + + $this->assertSame('2026-12-01', $api->calls[1]['data']['expires_at']); + } + + /** + * `all` na Iugu significa os métodos habilitados na conta; reenviá-lo como lista fixa + * trocaria a configuração da assinatura sem que ninguém pedisse. + */ + public function testUpdateDoesNotTurnPayableWithAllIntoAFixedList(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['payable_with' => 'all']), + $this->subscriptionResponse(), + ]); + $gateway = new IuguGateway($api); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = $gateway->getSubscription($subscription); + + $this->assertCount(3, $subscription->availablePaymentMethods); + + $subscription->metadata = ['origem' => 'teste']; + $gateway->updateSubscription($subscription); + + $this->assertArrayNotHasKey('payable_with', $api->calls[1]['data']); + } + + public function testUpdateSendsPaymentMethodsWhenTheyActuallyChanged(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['payable_with' => 'all']), + $this->subscriptionResponse(), + ]); + $gateway = new IuguGateway($api); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = $gateway->getSubscription($subscription); + + $subscription->availablePaymentMethods = [PaymentMethod::PIX]; + $gateway->updateSubscription($subscription); + + $this->assertSame(['pix'], $api->calls[1]['data']['payable_with']); + } + + public function testDivergingNextBillingAndTrialEndAreStillRejected(): void + { + $subscription = new Subscription(); + $subscription->fill([ + 'plan_id' => 'p', + 'customer' => ['id' => 'cus_1'], + 'next_billing_at' => '2026-10-01', + 'trial_ends_at' => '2026-09-15', + ]); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/different dates/'); + + (new IuguGateway(new QueuedIuguApiRequest([])))->createSubscription($subscription); + } + + /** + * `price_cents` do subitem é unitário, então o desconto abatido é o valor vezes a + * quantidade. + */ + public function testDiscountAmountAccountsForTheSubitemQuantity(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'subitems' => [ + (object) ['id' => 'si_d', 'description' => 'Promo', 'price_cents' => -500, 'quantity' => 3, 'recurrent' => true], + ], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame(1500, $subscription->discounts[0]->amountOff); + } + + /** + * `false` vira string vazia no encoder do SDK. + */ + public function testRecurrentGoesAsIntegerInThePayload(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse()]); + + $subscription = new Subscription(); + $subscription->fill([ + 'plan_id' => 'p', + 'customer' => ['id' => 'cus_1'], + 'items' => [['description' => 'Setup', 'amount' => 500, 'quantity' => 1, 'recurring' => false]], + 'discounts' => [['description' => 'Promo', 'amount_off' => 300, 'cycles' => 1]], + ]); + + (new IuguGateway($api))->createSubscription($subscription); + + $this->assertSame(0, $api->calls[0]['data']['subitems'][0]['recurrent']); + $this->assertSame(0, $api->calls[0]['data']['subitems'][1]['recurrent']); + } + + public function testSubitemIdComparisonDoesNotDependOnTheJsonType(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['subitems' => [(object) ['id' => 123, 'price_cents' => 100]]]), + $this->subscriptionResponse(), + ]); + + $subscription = new Subscription(); + $subscription->fill([ + 'id' => 'sub_1', + 'items' => [['id' => '123', 'description' => 'Mantido', 'amount' => 100, 'quantity' => 1]], + ]); + + (new IuguGateway($api))->updateSubscription($subscription); + + // nada a destruir: o id 123 do gateway é o mesmo '123' da lista desejada + $this->assertCount(2, $api->calls); + $this->assertSame('PUT', $api->calls[1]['method']); + } + + /** + * O `total_cents` da Iugu pode vir formatado, e valor não numérico não vira amount. + */ + public function testPlanChangeIgnoresNonNumericAmountFields(): void + { + $api = new QueuedIuguApiRequest([(object) ['total_cents' => 'R$ 300,00']]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->paymentMethod = PaymentMethod::PIX; + + $planChange = (new IuguGateway($api))->previewSubscriptionPlanChange($subscription, 'p'); + + $this->assertNull($planChange->amount); + // sem total não há linha a sintetizar + $this->assertSame([], $planChange->items); + } + + public function testStatusKeepsThePreviousValueWhenTheResponseHasNoFlags(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'recent_invoices' => [(object) ['id' => 'inv_1', 'status' => 'paid']], + ]), + (object) ['id' => 'sub_1'], + ]); + $gateway = new IuguGateway($api); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = $gateway->getSubscription($subscription); + $this->assertSame(SubscriptionStatus::ACTIVE, $subscription->status); + + $subscription = $gateway->updateSubscription($subscription); + + $this->assertSame(SubscriptionStatus::ACTIVE, $subscription->status); + $this->assertSame('inv_1', $subscription->latestInvoice->id); + } + + public function testPlanChangeFallsBackWhenCostIsNotNumeric(): void + { + $api = new QueuedIuguApiRequest([(object) ['cost' => 'R$ 300,00', 'price_cents' => 30000]]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->paymentMethod = PaymentMethod::PIX; + + $this->assertSame( + 30000, + (new IuguGateway($api))->previewSubscriptionPlanChange($subscription, 'p')->amount + ); + } + + /** + * Entre faturas do mesmo estado, vence a de maior vencimento, em qualquer ordem de resposta. + */ + #[DataProvider('ordemProvider')] + public function testLatestInvoiceDoesNotDependOnTheOrderIuguReturns(array $recentInvoices): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2026-08-01', + 'recent_invoices' => $recentInvoices, + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame('inv_recente', $subscription->latestInvoice->id); + $this->assertSame(SubscriptionStatus::PAST_DUE, $subscription->status); + } + + public static function ordemProvider(): array + { + $antiga = (object) ['id' => 'inv_antiga', 'status' => 'pending', 'due_date' => '2026-06-01']; + $recente = (object) ['id' => 'inv_recente', 'status' => 'pending', 'due_date' => '2026-08-01']; + + return [ + 'antiga primeiro' => [[$antiga, $recente]], + 'recente primeiro' => [[$recente, $antiga]], + ]; + } + + /** + * Vencimento igual é desempatado pelo menor id, qualquer que seja o estado das faturas. + */ + #[DataProvider('tieProvider')] + public function testSameDueDateIsBrokenByTheSmallestId(array $recentInvoices): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2026-08-01', + 'recent_invoices' => $recentInvoices, + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame('inv_a_paga', $subscription->latestInvoice->id); + $this->assertSame(SubscriptionStatus::PAST_DUE, $subscription->status); + } + + public static function tieProvider(): array + { + return [ + 'paga primeiro' => [[ + (object) ['id' => 'inv_a_paga', 'status' => 'paid', 'due_date' => '2026-08-01'], + (object) ['id' => 'inv_z_aberta', 'status' => 'pending', 'due_date' => '2026-08-01'], + ]], + 'aberta primeiro' => [[ + (object) ['id' => 'inv_z_aberta', 'status' => 'pending', 'due_date' => '2026-08-01'], + (object) ['id' => 'inv_a_paga', 'status' => 'paid', 'due_date' => '2026-08-01'], + ]], + 'paga como externally_paid' => [[ + (object) ['id' => 'inv_a_paga', 'status' => 'externally_paid', 'due_date' => '2026-08-01'], + (object) ['id' => 'inv_z_aberta', 'status' => 'expired', 'due_date' => '2026-08-01'], + ]], + ]; + } + + /** + * Cliente de outro id não é o mesmo cliente: o parse troca o objeto em vez de misturar os + * atributos. + */ + public function testParseReplacesTheCustomerWhenTheGatewayReturnsAnotherId(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['customer_id' => 'cus_outro', 'customer_name' => 'Beltrano']), + ]); + + $subscription = new Subscription(); + $subscription->fill([ + 'id' => 'sub_1', + 'customer' => ['id' => 'cus_1', 'tax_document' => '20176996915'], + ]); + + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame('cus_outro', $subscription->customer->id); + $this->assertSame('Beltrano', $subscription->customer->name); + $this->assertNull($subscription->customer->taxDocument); + } + + /** + * O parse preenche o cliente existente em vez de trocá-lo por um só com id. + */ + public function testParseKeepsTheCustomerItAlreadyHad(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'customer_name' => 'Fulano da Silva', + 'customer_email' => 'fulano@exemplo.com', + ]), + ]); + + $subscription = new Subscription(); + $subscription->fill(['id' => 'sub_1', 'customer' => ['id' => 'cus_1', 'tax_document' => '20176996915']]); + $original = $subscription->customer; + + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame($original, $subscription->customer); + $this->assertSame('20176996915', $subscription->customer->taxDocument); + $this->assertSame('Fulano da Silva', $subscription->customer->name); + $this->assertSame('fulano@exemplo.com', $subscription->customer->email); + } + + /** + * Empate de vencimento e de estado é resolvido pelo menor id, em qualquer ordem. + */ + #[DataProvider('mesmaSituacaoProvider')] + public function testLatestInvoiceIsStableWhenDueDateAndStateTie(array $recentInvoices): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['recent_invoices' => $recentInvoices]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->assertSame( + 'inv_a', + (new IuguGateway($api))->getSubscription($subscription)->latestInvoice->id + ); + } + + public static function mesmaSituacaoProvider(): array + { + return [ + 'ordem crescente' => [[ + (object) ['id' => 'inv_a', 'status' => 'pending', 'due_date' => '2026-08-01'], + (object) ['id' => 'inv_b', 'status' => 'pending', 'due_date' => '2026-08-01'], + ]], + 'ordem inversa' => [[ + (object) ['id' => 'inv_b', 'status' => 'pending', 'due_date' => '2026-08-01'], + (object) ['id' => 'inv_a', 'status' => 'pending', 'due_date' => '2026-08-01'], + ]], + ]; + } + + public function testParseKeepsTheLocalNameWhenTheResponseOmitsIt(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse()]); + + $subscription = new Subscription(); + $subscription->fill([ + 'id' => 'sub_1', + 'customer' => ['id' => 'cus_1', 'name' => 'Fulano', 'email' => 'fulano@exemplo.com'], + ]); + + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame('Fulano', $subscription->customer->name); + $this->assertSame('fulano@exemplo.com', $subscription->customer->email); + } + + /** + * Entrada sem id não serve como latestInvoice, porque não dá para buscá-la, mas continua + * valendo como fatura em aberto para a inadimplência. + */ + public function testRecentInvoiceWithoutIdIsNotSelectableButStillCountsAsDebt(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2026-08-01', + 'recent_invoices' => [(object) ['status' => 'pending', 'due_date' => '2026-08-01']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertNull($subscription->latestInvoice); + $this->assertSame(SubscriptionStatus::PAST_DUE, $subscription->status); + } + + /** + * Vencimento hoje ainda não é inadimplência: a carência vai até o fim do dia. + */ + public function testSubscriptionDueTodayWithAnOpenInvoiceIsNotPastDueYet(): void + { + $hoje = Carbon::today()->toDateString(); + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => $hoje, + 'recent_invoices' => [(object) ['id' => 'inv_1', 'status' => 'pending', 'due_date' => $hoje]], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->assertSame( + SubscriptionStatus::ACTIVE, + (new IuguGateway($api))->getSubscription($subscription)->status + ); + } + + /** + * Fatura cancelada não é dívida. + */ + public function testCanceledInvoiceDoesNotMakeTheSubscriptionPastDue(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2026-08-01', + 'recent_invoices' => [(object) ['id' => 'inv_1', 'status' => 'canceled', 'due_date' => '2026-08-01']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->assertSame( + SubscriptionStatus::ACTIVE, + (new IuguGateway($api))->getSubscription($subscription)->status + ); + } + + /** + * Cliente local sem id não é o mesmo cliente: o parse devolve a visão do gateway em vez de + * misturar os atributos. + */ + public function testParseReplacesTheCustomerWhenTheLocalOneHasNoId(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse(['customer_name' => 'Beltrano'])]); + + $subscription = new Subscription(); + $subscription->fill(['id' => 'sub_1', 'customer' => ['tax_document' => '20176996915']]); + + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame('cus_1', $subscription->customer->id); + $this->assertSame('Beltrano', $subscription->customer->name); + $this->assertNull($subscription->customer->taxDocument); + } + + /** + * Fatura cancelada de vencimento posterior não esconde a pendente anterior, nem na + * inadimplência nem na escolha da fatura que representa a assinatura. + */ + public function testPastDueLooksAtEveryInvoiceNotOnlyTheChosenOne(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2026-08-01', + 'recent_invoices' => [ + (object) ['id' => 'inv_pendente', 'status' => 'pending', 'due_date' => '2026-08-01'], + (object) ['id' => 'inv_cancelada', 'status' => 'canceled', 'due_date' => '2026-08-05'], + ], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame('inv_cancelada', $subscription->latestInvoice->id); + $this->assertSame(SubscriptionStatus::PAST_DUE, $subscription->status); + } + + /** + * `latestInvoice` é a mais recente, não a que gerou a inadimplência: em `past_due` ela pode + * estar quitada, e quem precisa da fatura a pagar tem de buscá-la pelo id. + */ + public function testPastDueCanPointAtAnInvoiceThatIsAlreadyPaid(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2026-08-01', + 'recent_invoices' => [ + (object) ['id' => 'inv_aberta', 'status' => 'pending', 'due_date' => '2026-07-01'], + (object) ['id' => 'inv_paga', 'status' => 'paid', 'due_date' => '2026-08-01'], + ], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame(SubscriptionStatus::PAST_DUE, $subscription->status); + $this->assertSame('inv_paga', $subscription->latestInvoice->id); + $this->assertSame(InvoiceStatus::PAID, $subscription->latestInvoice->status); + } + + /** + * Sem fatura em aberto, a escolhida é a de maior vencimento. + */ + public function testWithoutAnyOpenInvoiceTheLatestOneIsChosen(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'recent_invoices' => [ + (object) ['id' => 'inv_antiga', 'status' => 'paid', 'due_date' => '2026-07-01'], + (object) ['id' => 'inv_recente', 'status' => 'paid', 'due_date' => '2026-08-01'], + ], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->assertSame( + 'inv_recente', + (new IuguGateway($api))->getSubscription($subscription)->latestInvoice->id + ); + } + + /** + * Fatura paga pela metade continua com valor a receber. + */ + public function testPartiallyPaidInvoiceCountsAsOpen(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2026-08-01', + 'recent_invoices' => [ + (object) ['id' => 'inv_parcial', 'status' => 'partially_paid', 'due_date' => '2026-08-01'], + ], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame(InvoiceStatus::PARTIALLY_PAID, $subscription->latestInvoice->status); + $this->assertSame(SubscriptionStatus::PAST_DUE, $subscription->status); + } + + /** + * Entrada sem id não vence a escolha, mesmo com vencimento maior. + */ + public function testRecentInvoiceWithoutIdDoesNotHijackTheChoice(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2026-08-01', + 'recent_invoices' => [ + (object) ['id' => 'inv_1', 'status' => 'pending', 'due_date' => '2026-08-01'], + (object) ['status' => 'pending', 'due_date' => '2026-08-02'], + ], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame('inv_1', $subscription->latestInvoice->id); + $this->assertSame(SubscriptionStatus::PAST_DUE, $subscription->status); + } + /** + * Entrada sem vencimento perde para qualquer uma com data, em qualquer ordem. + */ + #[DataProvider('semDataProvider')] + public function testEntryWithoutDueDateLosesToOneWithIt(array $recentInvoices): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['recent_invoices' => $recentInvoices]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->assertSame( + 'inv_com_data', + (new IuguGateway($api))->getSubscription($subscription)->latestInvoice->id + ); + } + + public static function semDataProvider(): array + { + $semData = (object) ['id' => 'inv_a_sem_data', 'status' => 'pending']; + $comData = (object) ['id' => 'inv_com_data', 'status' => 'pending', 'due_date' => '2026-08-01']; + + return [ + 'sem data primeiro' => [[$semData, $comData]], + 'com data primeiro' => [[$comData, $semData]], + ]; + } + + public function testEntryWithoutStatusIsNotOpen(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2026-08-01', + 'recent_invoices' => [(object) ['id' => 'inv_1', 'due_date' => '2026-08-01']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame(SubscriptionStatus::ACTIVE, $subscription->status); + $this->assertNull($subscription->latestInvoice->status); + } + + public function testRecentInvoicesFromTheGatewayAreAcceptedAsAssociativeArrays(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'expires_at' => '2026-08-01', + 'recent_invoices' => [ + ['id' => 'inv_1', 'status' => 'pending', 'due_date' => '2026-08-01'], + ], + ]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame('inv_1', $subscription->latestInvoice->id); + $this->assertSame('2026-08-01', $subscription->latestInvoice->dueDate->format('Y-m-d')); + $this->assertSame(SubscriptionStatus::PAST_DUE, $subscription->status); + } + + /** + * Resposta que traz `recent_invoices` sem entrada utilizável zera a fatura, em vez de manter + * a da leitura anterior, que pode nem estar mais na resposta. + */ + public function testAResponseListingNoUsableInvoiceClearsTheStoredOne(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse([ + 'recent_invoices' => [(object) ['id' => 'inv_1', 'status' => 'paid', 'due_date' => '2026-08-01']], + ]), + $this->subscriptionResponse([ + 'recent_invoices' => [(object) ['status' => 'pending', 'due_date' => '2026-09-01']], + ]), + ]); + $gateway = new IuguGateway($api); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = $gateway->getSubscription($subscription); + $this->assertSame('inv_1', $subscription->latestInvoice->id); + + $this->assertNull($gateway->getSubscription($subscription)->latestInvoice); + } + + private function cardResponse(string $id = 'pm_1'): object + { + return (object) [ + 'id' => $id, + 'description' => 'CREDIT CARD', + 'created_at_iso' => '2026-09-02T09:00:00-03:00', + 'data' => (object) ['brand' => 'VISA', 'display_number' => 'XXXX-XXXX-XXXX-4242', 'month' => 12, 'year' => 2030, 'holder_name' => 'Cliente'], + ]; + } + + /** + * A assinatura da Iugu cobra o cartão padrão do cliente: o cartão salvo informado vira o + * padrão por um `PUT` no cliente antes de `POST /subscriptions`, e `payable_with` sai só + * com cartão. + */ + public function testCreateSubscriptionWithASavedCardMakesItTheCustomerDefaultAndPaysWithCard(): void + { + $api = new QueuedIuguApiRequest([(object) ['id' => 'cus_1'], $this->subscriptionResponse(['payable_with' => 'credit_card'])]); + + $subscription = (new SubscriptionBuilder(new IuguGateway($api))) + ->setPlanId('plano_mensal') + ->setCustomerId('cus_1') + ->setCreditCard('pm_1') + ->create(); + + $this->assertCount(2, $api->calls); + $this->assertSame('PUT', $api->calls[0]['method']); + $this->assertStringEndsWith('/customers/cus_1', $api->calls[0]['url']); + $this->assertSame(['default_payment_method_id' => 'pm_1'], $api->calls[0]['data']); + $this->assertSame('POST', $api->calls[1]['method']); + $this->assertStringEndsWith('/subscriptions', $api->calls[1]['url']); + $this->assertSame(['credit_card'], $api->calls[1]['data']['payable_with']); + $this->assertSame(PaymentMethod::CREDIT_CARD, $subscription->paymentMethod); + $this->assertSame('pm_1', $subscription->creditCard->id); + $this->assertTrue($subscription->creditCard->default); + } + + /** + * Cartão sem id é salvo no cliente já como padrão (`set_as_default`) antes da assinatura. + */ + public function testCreateSubscriptionWithATokenizedCardSavesItAsTheDefaultFirst(): void + { + $api = new QueuedIuguApiRequest([$this->cardResponse('pm_novo'), $this->subscriptionResponse()]); + + $card = new CreditCard(); + $card->token = 'tok_1'; + $subscription = (new SubscriptionBuilder(new IuguGateway($api))) + ->setPlanId('plano_mensal') + ->setCustomerId('cus_1') + ->setCreditCard($card) + ->create(); + + $this->assertCount(2, $api->calls); + $this->assertSame('POST', $api->calls[0]['method']); + $this->assertStringEndsWith('/customers/cus_1/payment_methods', $api->calls[0]['url']); + $this->assertSame('tok_1', $api->calls[0]['data']['token']); + $this->assertTrue($api->calls[0]['data']['set_as_default']); + $this->assertSame(['credit_card'], $api->calls[1]['data']['payable_with']); + $this->assertSame('pm_novo', $subscription->creditCard->id); + } + + public function testCreateSubscriptionWithATokenizedCardUsesTheDerivedCardKey(): void + { + $api = new QueuedIuguApiRequest([$this->cardResponse('pm_novo'), $this->subscriptionResponse()]); + $store = new InMemoryIdempotencyStore(); + + $card = new CreditCard(); + $card->token = 'tok_1'; + (new SubscriptionBuilder(new IuguGateway($api, $store))) + ->setPlanId('plano_mensal') + ->setCustomerId('cus_1') + ->setCreditCard($card) + ->withIdempotencyKey('sub-1') + ->create(); + + $this->assertTrue($store->has('iugu:sub-1:card')); + $this->assertSame([], $api->calls[0]['headers']); + $this->assertSame(['Idempotency-Key: sub-1'], $api->calls[1]['headers']); + } + + public function testCreateSubscriptionWithOnlyAPaymentMethodDerivesPayableWithWithoutTouchingTheCustomer(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse(['payable_with' => 'pix'])]); + + $subscription = (new SubscriptionBuilder(new IuguGateway($api))) + ->setPlanId('plano_mensal') + ->setCustomerId('cus_1') + ->setPaymentMethod(PaymentMethod::PIX) + ->create(); + + $this->assertCount(1, $api->calls); + $this->assertSame(['pix'], $api->calls[0]['data']['payable_with']); + $this->assertSame(PaymentMethod::PIX, $subscription->paymentMethod); + } + + public function testAvailablePaymentMethodsTakePrecedenceOverPaymentMethod(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse()]); + + $subscription = new Subscription(); + $subscription->fill(['plan_id' => 'plano_mensal', 'customer' => ['id' => 'cus_1'], 'payment_method' => 'pix']); + $subscription->availablePaymentMethods = [PaymentMethod::BANK_SLIP, PaymentMethod::PIX]; + + (new IuguGateway($api))->createSubscription($subscription); + + $this->assertSame(['bank_slip', 'pix'], $api->calls[0]['data']['payable_with']); + } + + /** + * `trialDays` vira `expires_at` contado do momento da requisição, com + * `only_charge_on_due_date` para a Iugu não cobrar o cartão na criação, e a chave de + * idempotência vai intacta no cabeçalho de `POST /subscriptions`; o cartão padrão usa a + * chave derivada `{chave}:default` pela store. + */ + public function testTrialDaysBecomesExpiresAtCountedFromNowWithoutChangingTheIdempotencyKey(): void + { + Carbon::setTestNow('2026-09-15 12:00:00'); + $api = new QueuedIuguApiRequest([(object) ['id' => 'cus_1'], $this->subscriptionResponse(['in_trial' => true, 'expires_at' => '2026-09-22'])]); + $store = new InMemoryIdempotencyStore(); + + $subscription = (new SubscriptionBuilder(new IuguGateway($api, $store))) + ->setPlanId('plano_mensal') + ->setCustomerId('cus_1') + ->setCreditCard('pm_1') + ->setTrialDays(7) + ->withIdempotencyKey('sub-1') + ->create(); + + $this->assertSame('2026-09-22', $api->calls[1]['data']['expires_at']); + $this->assertTrue($api->calls[1]['data']['only_charge_on_due_date']); + $this->assertSame(['Idempotency-Key: sub-1'], $api->calls[1]['headers']); + $this->assertSame([], $api->calls[0]['headers']); + $this->assertTrue($store->has('iugu:sub-1:default')); + $this->assertSame('2026-09-22', $subscription->trialEndsAt->format('Y-m-d')); + $this->assertNull($subscription->trialDays); + } + + /** + * Um model criado com `trialDays` pode ser salvo de novo: os dias viraram a data. + */ + public function testAModelCreatedWithTrialDaysCanBeSavedAgain(): void + { + Carbon::setTestNow('2026-09-15 12:00:00'); + // o save() seguinte resolve o gateway pelo nome gravado no model, então o fake vai no + // requester compartilhado do SDK + $api = (new QueuedIuguApiRequest([ + $this->subscriptionResponse(['in_trial' => true, 'expires_at' => '2026-09-22']), + $this->subscriptionResponse(['in_trial' => true, 'expires_at' => '2026-09-22']), + ]))->installAsSdkRequester(); + + $subscription = (new SubscriptionBuilder(new IuguGateway($api))) + ->setPlanId('plano_mensal') + ->setCustomerId('cus_1') + ->setTrialDays(7) + ->create(); + $subscription->metadata = ['origem' => 'teste']; + $subscription->save(); + + $this->assertCount(2, $api->calls); + $this->assertSame('PUT', $api->calls[1]['method']); + $this->assertSame(['custom_variables' => [['name' => 'origem', 'value' => 'teste']]], $api->calls[1]['data']); + } + + /** + * Num model lido do gateway a lista de métodos vem preenchida, então trocar só + * `paymentMethod` ou informar um cartão fora dela é recusado antes de qualquer requisição. + */ + public function testChangingThePaymentMethodOfAReadModelRequiresChangingTheList(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse(['payable_with' => 'pix'])]); + $gateway = new IuguGateway($api); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = $gateway->getSubscription($subscription); + $this->assertSame([PaymentMethod::PIX], $subscription->availablePaymentMethods); + + $subscription->paymentMethod = PaymentMethod::CREDIT_CARD; + try { + $subscription->save($gateway); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('paymentMethod [credit_card] must be one of availablePaymentMethods', $e->getMessage()); + } + + $subscription->paymentMethod = null; + $subscription->creditCard = new CreditCard(); + $subscription->creditCard->id = 'pm_9'; + try { + $subscription->save($gateway); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('creditCard was given but credit_card is not among the payment methods', $e->getMessage()); + } + $this->assertCount(1, $api->calls); + } + + public function testTrialEndsAtAlsoPostponesTheFirstChargeWhileNextBillingAtAloneDoesNot(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse(), $this->subscriptionResponse(), $this->subscriptionResponse()]); + $gateway = new IuguGateway($api); + + $trial = new Subscription(); + $trial->fill(['plan_id' => 'plano_mensal', 'customer' => ['id' => 'cus_1'], 'trial_ends_at' => '2026-10-01']); + $gateway->createSubscription($trial); + $this->assertSame('2026-10-01', $api->calls[0]['data']['expires_at']); + $this->assertTrue($api->calls[0]['data']['only_charge_on_due_date']); + + $billing = new Subscription(); + $billing->fill(['plan_id' => 'plano_mensal', 'customer' => ['id' => 'cus_1'], 'next_billing_at' => '2026-10-01']); + $gateway->createSubscription($billing); + $this->assertSame('2026-10-01', $api->calls[1]['data']['expires_at']); + $this->assertArrayNotHasKey('only_charge_on_due_date', $api->calls[1]['data']); + + $overridden = new Subscription(); + $overridden->fill(['plan_id' => 'plano_mensal', 'customer' => ['id' => 'cus_1'], 'trial_days' => 7]); + $overridden->gatewayOptions = ['only_charge_on_due_date' => false]; + $gateway->createSubscription($overridden); + $this->assertFalse($api->calls[2]['data']['only_charge_on_due_date']); + } + + public function testTrialDaysConflictingWithNextBillingAtIsRejectedBeforeTheNetwork(): void + { + Carbon::setTestNow('2026-09-15 12:00:00'); + $api = new QueuedIuguApiRequest([]); + + $subscription = new Subscription(); + $subscription->fill(['plan_id' => 'plano_mensal', 'customer' => ['id' => 'cus_1'], 'trial_days' => 7, 'next_billing_at' => '2026-10-01']); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/trialEndsAt \(or trialDays\)/'); + + (new IuguGateway($api))->createSubscription($subscription); + } + + public function testUpdateSubscriptionWithACardMakesItTheCustomerDefaultBeforeTheUpdate(): void + { + $api = new QueuedIuguApiRequest([(object) ['id' => 'cus_1'], $this->subscriptionResponse()]); + + $subscription = new Subscription(); + $subscription->fill(['id' => 'sub_1', 'customer' => ['id' => 'cus_1'], 'credit_card' => ['id' => 'pm_2']]); + + (new IuguGateway($api))->updateSubscription($subscription); + + $this->assertCount(2, $api->calls); + $this->assertStringEndsWith('/customers/cus_1', $api->calls[0]['url']); + $this->assertSame(['default_payment_method_id' => 'pm_2'], $api->calls[0]['data']); + $this->assertStringEndsWith('/subscriptions/sub_1', $api->calls[1]['url']); + $this->assertSame(['payable_with' => ['credit_card']], $api->calls[1]['data']); + } + + public function testUpdateSubscriptionWithATokenizedCardSavesItAsTheDefaultBeforeTheUpdate(): void + { + $api = new QueuedIuguApiRequest([$this->cardResponse('pm_novo'), $this->subscriptionResponse()]); + + $subscription = new Subscription(); + $subscription->fill(['id' => 'sub_1', 'customer' => ['id' => 'cus_1'], 'credit_card' => ['token' => 'tok_1']]); + + $subscription = (new IuguGateway($api))->updateSubscription($subscription); + + $this->assertCount(2, $api->calls); + $this->assertSame('POST', $api->calls[0]['method']); + $this->assertStringEndsWith('/customers/cus_1/payment_methods', $api->calls[0]['url']); + $this->assertTrue($api->calls[0]['data']['set_as_default']); + $this->assertStringEndsWith('/subscriptions/sub_1', $api->calls[1]['url']); + $this->assertSame(['payable_with' => ['credit_card']], $api->calls[1]['data']); + $this->assertSame('pm_novo', $subscription->creditCard->id); + } + + public function testCardWithoutACustomerIdIsRejectedBeforeTheNetwork(): void + { + $api = new QueuedIuguApiRequest([]); + + $subscription = new Subscription(); + $subscription->fill(['id' => 'sub_1', 'credit_card' => ['id' => 'pm_2']]); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/`customer` attribute is required/'); + + (new IuguGateway($api))->updateSubscription($subscription); + } + + /** + * A Iugu não informa com qual método a assinatura é cobrada; o driver só preenche + * `paymentMethod` quando ela aceita um único método. + */ + #[DataProvider('parsedPaymentMethodProvider')] + public function testParseFillsPaymentMethodOnlyWhenTheSubscriptionAcceptsASingleMethod(mixed $payableWith, ?PaymentMethod $expected): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse(['payable_with' => $payableWith])]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->paymentMethod = PaymentMethod::CREDIT_CARD; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame($expected, $subscription->paymentMethod); + } + + public static function parsedPaymentMethodProvider(): array + { + return [ + 'string unica' => ['pix', PaymentMethod::PIX], + 'lista de um' => [['credit_card'], PaymentMethod::CREDIT_CARD], + 'lista de dois' => [['pix', 'bank_slip'], null], + 'all' => ['all', null], + ]; + } + + private function discountSubitem(string $id = 'si_d1', int $priceCents = -500, bool $recurrent = true): object + { + return (object) [ + 'id' => $id, + 'description' => 'Promo', + 'price_cents' => $priceCents, + 'quantity' => 1, + 'recurrent' => $recurrent, + ]; + } + + public function testDiscountWithValidUntilWritesTheVariableAfterTheCreation(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['subitems' => [$this->discountSubitem()]]), + $this->subscriptionResponse([ + 'subitems' => [$this->discountSubitem()], + 'custom_variables' => [(object) ['name' => 'mp_discount_si_d1_until', 'value' => '2026-12-31']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->fill([ + 'plan_id' => 'plano_mensal', + 'customer' => ['id' => 'cus_1'], + 'discounts' => [['description' => 'Promo', 'amount_off' => 500, 'valid_until' => '2026-12-31']], + ]); + + $created = (new IuguGateway($api))->createSubscription($subscription); + + $this->assertCount(2, $api->calls); + $this->assertSame( + [['description' => 'Promo', 'price_cents' => -500, 'quantity' => 1, 'recurrent' => 1]], + $api->calls[0]['data']['subitems'] + ); + $this->assertSame('PUT', $api->calls[1]['method']); + $this->assertStringEndsWith('/subscriptions/sub_1', $api->calls[1]['url']); + $this->assertSame( + ['custom_variables' => [['name' => 'mp_discount_si_d1_until', 'value' => '2026-12-31']]], + $api->calls[1]['data'] + ); + $this->assertSame('2026-12-31', $created->discounts[0]->validUntil->format('Y-m-d')); + } + + /** + * `cycles` acima de 1 vira a data da fatura de número `cycles`. Na criação sem trial a + * primeira fatura é cobrada na hora e a segunda sai na próxima cobrança da resposta, então + * três ciclos terminam um intervalo do plano (lido por `getPlan()`) depois dela. + */ + public function testDiscountCyclesBecomeAValidUntilComputedFromThePlanInterval(): void + { + Carbon::setTestNow('2026-09-04 12:00:00'); + + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['subitems' => [$this->discountSubitem()]]), + (object) ['identifier' => 'plano_mensal', 'interval_type' => 'months', 'interval' => 1, 'value_cents' => 10000], + $this->subscriptionResponse([ + 'subitems' => [$this->discountSubitem()], + 'custom_variables' => [(object) ['name' => 'mp_discount_si_d1_until', 'value' => '2026-11-01']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->fill([ + 'plan_id' => 'plano_mensal', + 'customer' => ['id' => 'cus_1'], + 'discounts' => [['description' => 'Promo', 'amount_off' => 500, 'cycles' => 3]], + ]); + + $created = (new IuguGateway($api))->createSubscription($subscription); + + $this->assertCount(3, $api->calls); + $this->assertStringEndsWith('/plans/identifier/plano_mensal', $api->calls[1]['url']); + $this->assertSame( + ['custom_variables' => [['name' => 'mp_discount_si_d1_until', 'value' => '2026-11-01']]], + $api->calls[2]['data'] + ); + $this->assertSame('2026-11-01', $created->discounts[0]->validUntil->format('Y-m-d')); + } + + /** + * Substituir a lista de descontos por uma vazia remove o subitem e a variável de validade + * que ficou sem desconto correspondente. + */ + public function testUpdateWithAnEmptyDiscountListRemovesTheStaleValidityVariable(): void + { + $current = $this->subscriptionResponse([ + 'subitems' => [$this->discountSubitem()], + 'custom_variables' => [(object) ['name' => 'mp_discount_si_d1_until', 'value' => '2026-12-31']], + ]); + $api = new QueuedIuguApiRequest([ + $current, + $this->subscriptionResponse(['custom_variables' => [ + (object) ['name' => 'mp_discount_si_d1_until', 'value' => '2026-12-31'], + ]]), + $this->subscriptionResponse(['custom_variables' => [ + (object) ['name' => 'mp_discount_si_d1_until', 'value' => '2026-12-31'], + ]]), + $this->subscriptionResponse(['custom_variables' => []]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->discounts = []; + + $updated = (new IuguGateway($api))->updateSubscription($subscription); + + $this->assertCount(4, $api->calls); + $this->assertSame('GET', $api->calls[0]['method']); + $this->assertSame( + ['subitems' => [['id' => 'si_d1', '_destroy' => true]]], + $api->calls[1]['data'] + ); + $this->assertSame( + ['custom_variables' => [['name' => 'mp_discount_si_d1_until', '_destroy' => true]]], + $api->calls[3]['data'] + ); + $this->assertNull($updated->canceledAt); + $this->assertSame([], $updated->metadata); + } + + public function testParseReadsTheDiscountValidityAndTheScheduledCancellation(): void + { + $api = new QueuedIuguApiRequest([$this->subscriptionResponse([ + 'subitems' => [$this->discountSubitem()], + 'custom_variables' => [ + (object) ['name' => 'origem', 'value' => 'teste'], + (object) ['name' => 'mp_discount_si_d1_until', 'value' => '2026-12-31'], + (object) ['name' => 'mp_cancel_at_period_end', 'value' => '1'], + (object) ['name' => 'mp_cancel_scheduled_for', 'value' => '2026-10-01'], + ], + ])]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertSame(SubscriptionStatus::ACTIVE, $subscription->status); + $this->assertTrue($subscription->cancelAtPeriodEnd); + $this->assertNull($subscription->canceledAt); + $this->assertSame('2026-12-31', $subscription->discounts[0]->validUntil->format('Y-m-d')); + $this->assertNull($subscription->discounts[0]->cycles); + $this->assertSame(['origem' => 'teste'], $subscription->metadata); + } + + /** + * Com chave, o `PUT` da validade recebe `{chave}:discounts` pela store, e o retry só + * repete o `POST` (endpoint nativo, deduplicado pela própria Iugu). + */ + public function testCreateWithDiscountStoresTheValidityWriteUnderItsOwnKey(): void + { + $created = $this->subscriptionResponse(['subitems' => [$this->discountSubitem()]]); + $withVariable = $this->subscriptionResponse([ + 'subitems' => [$this->discountSubitem()], + 'custom_variables' => [(object) ['name' => 'mp_discount_si_d1_until', 'value' => '2026-12-31']], + ]); + $api = new QueuedIuguApiRequest([$created, $withVariable, $created]); + $store = new \Potelo\MultiPayment\Idempotency\InMemoryIdempotencyStore(); + $gateway = new IuguGateway($api, $store); + + $subscription = new Subscription(); + $subscription->fill([ + 'plan_id' => 'plano_mensal', + 'customer' => ['id' => 'cus_1'], + 'discounts' => [['description' => 'Promo', 'amount_off' => 500, 'valid_until' => '2026-12-31']], + ]); + + $gateway->createSubscription($subscription, 'chave-1'); + + $this->assertCount(2, $api->calls); + $this->assertSame(['Idempotency-Key: chave-1'], $api->calls[0]['headers']); + $this->assertSame([], $api->calls[1]['headers']); + $this->assertTrue($store->has('iugu:chave-1:discounts')); + + $again = new Subscription(); + $again->fill([ + 'plan_id' => 'plano_mensal', + 'customer' => ['id' => 'cus_1'], + 'discounts' => [['description' => 'Promo', 'amount_off' => 500, 'valid_until' => '2026-12-31']], + ]); + $gateway->createSubscription($again, 'chave-1'); + + $this->assertCount(3, $api->calls, 'o retry repete só o POST; o PUT da validade sai da store'); + $this->assertSame('POST', $api->calls[2]['method']); + } + + /** + * No update, o desconto novo casa com o subitem da resposta e ganha a variável de + * validade; a variável do desconto que saiu da lista é removida no mesmo `PUT`. + */ + public function testUpdateReplacesTheDiscountAndRewritesItsValidityVariable(): void + { + $old = $this->discountSubitem('si_old'); + $oldVariable = (object) ['name' => 'mp_discount_si_old_until', 'value' => '2026-10-01']; + $new = $this->discountSubitem('si_new'); + + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['subitems' => [$old], 'custom_variables' => [$oldVariable]]), + $this->subscriptionResponse(['subitems' => [], 'custom_variables' => [$oldVariable]]), + $this->subscriptionResponse(['subitems' => [$new], 'custom_variables' => [$oldVariable]]), + $this->subscriptionResponse([ + 'subitems' => [$new], + 'custom_variables' => [(object) ['name' => 'mp_discount_si_new_until', 'value' => '2026-12-31']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->fill([ + 'id' => 'sub_1', + 'discounts' => [['description' => 'Promo', 'amount_off' => 500, 'valid_until' => '2026-12-31']], + ]); + + $updated = (new IuguGateway($api))->updateSubscription($subscription); + + $this->assertCount(4, $api->calls); + $this->assertSame( + ['subitems' => [['id' => 'si_old', '_destroy' => true]]], + $api->calls[1]['data'] + ); + $this->assertSame( + ['custom_variables' => [ + ['name' => 'mp_discount_si_new_until', 'value' => '2026-12-31'], + ['name' => 'mp_discount_si_old_until', '_destroy' => true], + ]], + $api->calls[3]['data'] + ); + $this->assertSame('2026-12-31', $updated->discounts[0]->validUntil->format('Y-m-d')); + } + + /** + * Update com o mesmo desconto e a mesma validade não escreve a variável de novo: a + * operação termina no `PUT` da atualização. + */ + public function testUpdateWithTheSameDiscountValidityDoesNotWriteTheVariableAgain(): void + { + $withVariable = $this->subscriptionResponse([ + 'subitems' => [$this->discountSubitem()], + 'custom_variables' => [(object) ['name' => 'mp_discount_si_d1_until', 'value' => '2026-12-31']], + ]); + $api = new QueuedIuguApiRequest([$withVariable, $withVariable]); + + $subscription = new Subscription(); + $subscription->fill([ + 'id' => 'sub_1', + 'discounts' => [[ + 'id' => 'si_d1', + 'description' => 'Promo', + 'amount_off' => 500, + 'valid_until' => '2026-12-31', + ]], + ]); + + (new IuguGateway($api))->updateSubscription($subscription); + + $this->assertCount(2, $api->calls); + $this->assertSame('GET', $api->calls[0]['method']); + $this->assertSame('PUT', $api->calls[1]['method']); + } + + /** + * Na criação com trial, a contagem de `cycles` parte do fim do teste, que é quando a + * primeira fatura é cobrada. + */ + public function testDiscountCyclesCountFromTheTrialEndWhenCreatingWithATrial(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['subitems' => [$this->discountSubitem()], 'expires_at' => '2026-09-11']), + (object) ['identifier' => 'plano_mensal', 'interval_type' => 'months', 'interval' => 1, 'value_cents' => 10000], + $this->subscriptionResponse([ + 'subitems' => [$this->discountSubitem()], + 'custom_variables' => [(object) ['name' => 'mp_discount_si_d1_until', 'value' => '2026-10-11']], + ]), + ]); + + $subscription = new Subscription(); + $subscription->fill([ + 'plan_id' => 'plano_mensal', + 'customer' => ['id' => 'cus_1'], + 'trial_ends_at' => '2026-09-11', + 'discounts' => [['description' => 'Promo', 'amount_off' => 500, 'cycles' => 2]], + ]); + + (new IuguGateway($api))->createSubscription($subscription); + + $this->assertSame( + ['name' => 'mp_discount_si_d1_until', 'value' => '2026-10-11'], + $api->calls[2]['data']['custom_variables'][0] + ); + } + + public function testAnUnreadableDiscountValidityIsIgnoredWithAWarning(): void + { + $app = \Illuminate\Support\Facades\Facade::getFacadeApplication(); + $app->instance('log', $logger = new RecordingLogger()); + + $api = new QueuedIuguApiRequest([$this->subscriptionResponse([ + 'subitems' => [$this->discountSubitem()], + 'custom_variables' => [(object) ['name' => 'mp_discount_si_d1_until', 'value' => 'sim']], + ])]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription = (new IuguGateway($api))->getSubscription($subscription); + + $this->assertNull($subscription->discounts[0]->validUntil); + $this->assertCount(1, $logger->records); + $this->assertSame('warning', $logger->records[0]['level']); + $this->assertStringContainsString('mp_discount_si_d1_until', $logger->records[0]['message']); + } + + /** + * `resume()` de uma assinatura ativa com cancelamento agendado remove as variáveis do + * agendamento, mesmo sem a marca `mp_canceled_at`. + */ + public function testResumeClearsAScheduledCancellation(): void + { + $api = new QueuedIuguApiRequest([ + $this->subscriptionResponse(['custom_variables' => [ + (object) ['name' => 'mp_cancel_at_period_end', 'value' => '1'], + (object) ['name' => 'mp_cancel_scheduled_for', 'value' => '2026-10-01'], + ]]), + $this->subscriptionResponse(['custom_variables' => []]), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $resumed = (new IuguGateway($api))->resumeSubscription($subscription); + + $this->assertCount(2, $api->calls); + $this->assertSame('PUT', $api->calls[1]['method']); + $this->assertSame( + [ + ['name' => 'mp_canceled_at', '_destroy' => true], + ['name' => 'mp_cancel_at_period_end', '_destroy' => true], + ['name' => 'mp_cancel_scheduled_for', '_destroy' => true], + ], + $api->calls[1]['data']['custom_variables'] + ); + $this->assertFalse($resumed->cancelAtPeriodEnd); + } + + /** + * O prefixo `mp_` de `custom_variables` guarda o estado da emulação, então `metadata` com + * uma chave assim é recusado antes de qualquer requisição. + */ + public function testMetadataWithTheReservedPrefixIsRejected(): void + { + $api = new QueuedIuguApiRequest([]); + + $subscription = new Subscription(); + $subscription->fill(['plan_id' => 'plano_mensal', 'customer' => ['id' => 'cus_1']]); + $subscription->metadata = ['mp_canceled_at' => '2026-09-04']; + + try { + (new IuguGateway($api))->createSubscription($subscription); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('mp_ prefix', $e->getMessage()); + } + $this->assertCount(0, $api->calls); + } +} diff --git a/tests/Unit/Gateways/IuguGatewaySyncSubscriptionsTest.php b/tests/Unit/Gateways/IuguGatewaySyncSubscriptionsTest.php new file mode 100644 index 0000000..5c3cbb9 --- /dev/null +++ b/tests/Unit/Gateways/IuguGatewaySyncSubscriptionsTest.php @@ -0,0 +1,278 @@ +instance('config', new Repository([ + 'multi-payment' => [ + 'default' => 'iugu', + 'gateways' => ['iugu' => ['api_key' => 'test-api-key', 'class' => IuguGateway::class]], + ], + ])); + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + private static function subscription(string $id, array $overrides = []): object + { + return (object) array_merge([ + 'id' => $id, + 'customer_id' => 'cus_1', + 'plan_identifier' => 'plano_mensal', + 'expires_at' => '2026-10-01', + 'active' => true, + 'suspended' => false, + 'in_trial' => false, + ], $overrides); + } + + private static function listResponse(array $items): object + { + return (object) ['totalItems' => count($items), 'items' => $items]; + } + + private static function expiredDiscountSubscription(): object + { + return self::subscription('sub_expirado', [ + 'subitems' => [(object) ['id' => 'si_d1', 'description' => 'Promo', 'price_cents' => -500, 'quantity' => 1, 'recurrent' => true]], + 'custom_variables' => [(object) ['name' => 'mp_discount_si_d1_until', 'value' => '2026-09-03']], + ]); + } + + private static function dueCancellationSubscription(): object + { + return self::subscription('sub_agendado', [ + 'custom_variables' => [ + (object) ['name' => 'mp_cancel_at_period_end', 'value' => '1'], + (object) ['name' => 'mp_cancel_scheduled_for', 'value' => '2026-09-04'], + ], + ]); + } + + public function testSyncAppliesEachPendingEmulationAndSkipsWhatIsNotDue(): void + { + $api = new QueuedIuguApiRequest([ + self::listResponse([ + self::expiredDiscountSubscription(), + self::subscription('sub_vigente', [ + 'subitems' => [(object) ['id' => 'si_d2', 'description' => 'Promo', 'price_cents' => -500, 'quantity' => 1, 'recurrent' => true]], + 'custom_variables' => [(object) ['name' => 'mp_discount_si_d2_until', 'value' => '2026-12-31']], + ]), + // validade inclusiva: o desconto que vale até hoje ainda não venceu + self::subscription('sub_no_limite', [ + 'subitems' => [(object) ['id' => 'si_d3', 'description' => 'Promo', 'price_cents' => -500, 'quantity' => 1, 'recurrent' => true]], + 'custom_variables' => [(object) ['name' => 'mp_discount_si_d3_until', 'value' => '2026-09-04']], + ]), + self::subscription('sub_suspensa', [ + 'suspended' => true, + 'custom_variables' => [ + (object) ['name' => 'mp_cancel_at_period_end', 'value' => '1'], + (object) ['name' => 'mp_cancel_scheduled_for', 'value' => '2026-09-01'], + ], + ]), + self::dueCancellationSubscription(), + self::subscription('sub_orfa', [ + 'custom_variables' => [(object) ['name' => 'mp_discount_si_x_until', 'value' => '2026-09-01']], + ]), + self::subscription('sub_futura', [ + 'custom_variables' => [ + (object) ['name' => 'mp_cancel_at_period_end', 'value' => '1'], + (object) ['name' => 'mp_cancel_scheduled_for', 'value' => '2026-09-05'], + ], + ]), + ]), + self::subscription('sub_expirado'), + self::subscription('sub_agendado', ['suspended' => true]), + self::subscription('sub_agendado', ['suspended' => true]), + self::subscription('sub_orfa'), + ]); + + $actions = (new IuguGateway($api))->syncSubscriptions(); + + $this->assertSame( + [ + ['sub_expirado', 'remove_discount'], + ['sub_agendado', 'cancel'], + ['sub_orfa', 'remove_orphan_discount_variable'], + ], + array_map(static fn (array $action) => [$action['subscription'], $action['action']], $actions) + ); + + $this->assertCount(5, $api->calls); + $this->assertStringContainsString('limit=100', $api->calls[0]['url']); + $this->assertStringContainsString('start=0', $api->calls[0]['url']); + + $this->assertSame('PUT', $api->calls[1]['method']); + $this->assertStringEndsWith('/subscriptions/sub_expirado', $api->calls[1]['url']); + $this->assertSame([ + 'custom_variables' => [['name' => 'mp_discount_si_d1_until', '_destroy' => true]], + 'subitems' => [['id' => 'si_d1', '_destroy' => true]], + ], $api->calls[1]['data']); + + // a marca vai antes da suspensão, para uma falha no meio ser reprocessada na rodada + // seguinte (assinatura suspensa sem a marca seria pulada) + $this->assertSame('PUT', $api->calls[2]['method']); + $this->assertStringEndsWith('/subscriptions/sub_agendado', $api->calls[2]['url']); + $this->assertSame('mp_canceled_at', $api->calls[2]['data']['custom_variables'][0]['name']); + $this->assertSame('POST', $api->calls[3]['method']); + $this->assertStringEndsWith('/subscriptions/sub_agendado/suspend', $api->calls[3]['url']); + + $this->assertSame('PUT', $api->calls[4]['method']); + $this->assertStringEndsWith('/subscriptions/sub_orfa', $api->calls[4]['url']); + $this->assertSame( + ['custom_variables' => [['name' => 'mp_discount_si_x_until', '_destroy' => true]]], + $api->calls[4]['data'] + ); + } + + /** + * Sobre o estado que a primeira passada deixou (subitem e variável removidos, assinatura + * agendada suspensa com `mp_canceled_at`), a segunda passada não escreve nada. + */ + public function testSyncIsIdempotentOverTheResultingState(): void + { + $api = new QueuedIuguApiRequest([ + self::listResponse([ + self::subscription('sub_expirado'), + self::subscription('sub_agendado', [ + 'suspended' => true, + 'custom_variables' => [ + (object) ['name' => 'mp_cancel_at_period_end', 'value' => '1'], + (object) ['name' => 'mp_cancel_scheduled_for', 'value' => '2026-09-04'], + (object) ['name' => 'mp_canceled_at', 'value' => '2026-09-04T12:00:00-03:00'], + ], + ]), + ]), + ]); + + $actions = (new IuguGateway($api))->syncSubscriptions(); + + $this->assertSame([], $actions); + $this->assertCount(1, $api->calls); + } + + public function testDryRunReportsTheActionsWithoutWriting(): void + { + $api = new QueuedIuguApiRequest([ + self::listResponse([self::expiredDiscountSubscription(), self::dueCancellationSubscription()]), + ]); + + $actions = (new IuguGateway($api))->syncSubscriptions(true); + + $this->assertCount(2, $actions); + $this->assertCount(1, $api->calls); + $this->assertSame('GET', $api->calls[0]['method']); + } + + /** + * Uma página cheia dispara a leitura da página seguinte, e as assinaturas das duas são + * processadas. + */ + public function testSyncPagesThroughTheSubscriptions(): void + { + $fullPage = array_map( + static fn (int $i) => self::subscription("sub_{$i}"), + range(1, 100) + ); + + $api = new QueuedIuguApiRequest([ + self::listResponse($fullPage), + self::listResponse([self::expiredDiscountSubscription()]), + self::subscription('sub_expirado'), + ]); + + $actions = (new IuguGateway($api))->syncSubscriptions(); + + $this->assertCount(1, $actions); + $this->assertCount(3, $api->calls, 'a ação da segunda página precisa ser aplicada'); + $this->assertStringContainsString('start=0', $api->calls[0]['url']); + $this->assertStringContainsString('start=100', $api->calls[1]['url']); + $this->assertSame('PUT', $api->calls[2]['method']); + } + + /** + * Uma assinatura cuja escrita falha não derruba a varredura: o erro vai para o log e as + * assinaturas seguintes são sincronizadas. + */ + public function testAFailingSubscriptionDoesNotAbortTheSweep(): void + { + $app = Facade::getFacadeApplication(); + $app->instance('log', $logger = new \Potelo\MultiPayment\Tests\Unit\RecordingLogger()); + + $api = new QueuedIuguApiRequest([ + self::listResponse([ + self::expiredDiscountSubscription(), + self::dueCancellationSubscription(), + ]), + new \IuguObjectNotFound('not found'), + self::subscription('sub_agendado'), + self::subscription('sub_agendado', ['suspended' => true]), + ]); + + $actions = (new IuguGateway($api))->syncSubscriptions(); + + $this->assertSame(['sub_agendado'], array_column($actions, 'subscription')); + $this->assertCount(4, $api->calls, 'a segunda assinatura precisa ser sincronizada depois da falha'); + $failures = array_filter( + $logger->records, + static fn (array $record) => str_contains($record['message'], 'sub_expirado') + ); + $this->assertCount(1, $failures); + $this->assertSame('warning', array_values($failures)[0]['level']); + } + + /** + * Data de agendamento que não é uma data lê como sem agendamento, com aviso no log, e + * nada é aplicado. + */ + public function testAnUnreadableScheduledDateIsIgnoredWithAWarning(): void + { + $app = Facade::getFacadeApplication(); + $app->instance('log', $logger = new \Potelo\MultiPayment\Tests\Unit\RecordingLogger()); + + $api = new QueuedIuguApiRequest([ + self::listResponse([self::subscription('sub_ilegivel', [ + 'custom_variables' => [ + (object) ['name' => 'mp_cancel_at_period_end', 'value' => '1'], + (object) ['name' => 'mp_cancel_scheduled_for', 'value' => 'amanha'], + ], + ])]), + ]); + + $actions = (new IuguGateway($api))->syncSubscriptions(); + + $this->assertSame([], $actions); + $this->assertCount(1, $api->calls); + $this->assertCount(1, $logger->records); + $this->assertSame('warning', $logger->records[0]['level']); + $this->assertStringContainsString('mp_cancel_scheduled_for', $logger->records[0]['message']); + } +} diff --git a/tests/Unit/Gateways/QueuedIuguApiRequest.php b/tests/Unit/Gateways/QueuedIuguApiRequest.php new file mode 100644 index 0000000..038e111 --- /dev/null +++ b/tests/Unit/Gateways/QueuedIuguApiRequest.php @@ -0,0 +1,86 @@ + */ + public array $calls = []; + + /** + * @param array $responses + */ + public function __construct(private array $responses) + { + parent::__construct(); + } + + public function request($method, $url, $data = [], $headers = []) + { + $this->calls[] = ['method' => $method, 'url' => $url, 'data' => $data, 'headers' => $headers]; + $this->lastResponseCode = null; + $this->lastResponseHeaders = []; + + if (empty($this->responses)) { + throw new \RuntimeException("Sem resposta enfileirada para {$method} {$url}"); + } + + $response = array_shift($this->responses); + if ($response instanceof \Throwable) { + throw $response; + } + + if ($response instanceof QueuedIuguResponse) { + $this->lastResponseCode = $response->status > 0 ? $response->status : null; + $this->lastResponseHeaders = $response->headers; + + return $response->body; + } + + $this->lastResponseCode = 200; + + return $response; + } + + /** + * Instala este fake como requester compartilhado do SDK (`APIResource::API()`), que é o + * que `IuguGateway` usa quando é construído sem requester (por exemplo, via + * `ConfigurationHelper::resolveGateway()` e `new MultiPayment('iugu')`). Chame + * `restoreSdkRequester()` no tearDown. + * + * @return $this + */ + public function installAsSdkRequester(): static + { + self::sdkRequesterProperty()->setValue(null, $this); + + return $this; + } + + /** + * Devolve o SDK ao requester real, para o fake não vazar para outros testes. + * + * @return void + */ + public static function restoreSdkRequester(): void + { + self::sdkRequesterProperty()->setValue(null, null); + } + + private static function sdkRequesterProperty(): \ReflectionProperty + { + return new \ReflectionProperty(\APIResource::class, '_apiRequester'); + } +} diff --git a/tests/Unit/Gateways/QueuedIuguResponse.php b/tests/Unit/Gateways/QueuedIuguResponse.php new file mode 100644 index 0000000..239a348 --- /dev/null +++ b/tests/Unit/Gateways/QueuedIuguResponse.php @@ -0,0 +1,17 @@ + método, url, parâmetros e cabeçalhos (`Nome: valor`) */ + public array $calls = []; + + /** @var array */ + private array $responses; + + private function __construct(array $responses) + { + $this->responses = array_map(static function ($response) { + if ($response instanceof \Throwable) { + return $response; + } + + return isset($response[1]) && is_int($response[1]) + ? $response + : [$response, 200]; + }, $responses); + } + + /** + * Cria o fake e o instala como client HTTP global do stripe-php. + * + * @param array $responses + * @return static + */ + public static function withResponses(array $responses): self + { + $httpClient = new self($responses); + ApiRequestor::setHttpClient($httpClient); + + return $httpClient; + } + + /** + * Valor de um cabeçalho enviado na chamada de índice `$call`, ou nulo quando ausente. O + * nome é comparado sem diferenciar maiúsculas. + * + * @param int $call + * @param string $name + * @return string|null + */ + public function header(int $call, string $name): ?string + { + foreach ($this->calls[$call][3] ?? [] as $rawHeader) { + [$headerName, $value] = array_pad(explode(':', $rawHeader, 2), 2, ''); + if (strcasecmp(trim($headerName), $name) === 0) { + return trim($value); + } + } + + return null; + } + + /** + * @inheritDoc + */ + public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1', $maxNetworkRetries = null) + { + $this->calls[] = [$method, $absUrl, $params, $headers]; + + if (empty($this->responses)) { + throw new \RuntimeException("Unexpected Stripe request: {$method} {$absUrl}"); + } + $response = array_shift($this->responses); + if ($response instanceof \Throwable) { + throw $response; + } + [$body, $code] = $response; + $headers = $response[2] ?? []; + + return [is_string($body) ? $body : json_encode($body), $code, $headers]; + } +} diff --git a/tests/Unit/Gateways/RecordingStripeLogger.php b/tests/Unit/Gateways/RecordingStripeLogger.php new file mode 100644 index 0000000..1158df9 --- /dev/null +++ b/tests/Unit/Gateways/RecordingStripeLogger.php @@ -0,0 +1,20 @@ +messages[] = (string) $message; + } +} diff --git a/tests/Unit/Gateways/Stripe/DeclineCodesTest.php b/tests/Unit/Gateways/Stripe/DeclineCodesTest.php new file mode 100644 index 0000000..6ec3c70 --- /dev/null +++ b/tests/Unit/Gateways/Stripe/DeclineCodesTest.php @@ -0,0 +1,92 @@ +assertSame($expected, DeclineCodes::toDeclineCode($stripeCode)); + } + + public static function codeProvider(): array + { + return [ + 'insufficient_funds' => ['insufficient_funds', DeclineCode::INSUFFICIENT_FUNDS], + 'card_velocity_exceeded' => ['card_velocity_exceeded', DeclineCode::INSUFFICIENT_FUNDS], + 'withdrawal_count_limit_exceeded' => ['withdrawal_count_limit_exceeded', DeclineCode::INSUFFICIENT_FUNDS], + 'expired_card' => ['expired_card', DeclineCode::EXPIRED_CARD], + 'incorrect_cvc' => ['incorrect_cvc', DeclineCode::INCORRECT_CVC], + 'invalid_cvc' => ['invalid_cvc', DeclineCode::INCORRECT_CVC], + 'incorrect_number' => ['incorrect_number', DeclineCode::INCORRECT_NUMBER], + 'invalid_number' => ['invalid_number', DeclineCode::INCORRECT_NUMBER], + 'invalid_expiry_month' => ['invalid_expiry_month', DeclineCode::INVALID_CARD], + 'invalid_account' => ['invalid_account', DeclineCode::INVALID_CARD], + 'incorrect_zip' => ['incorrect_zip', DeclineCode::INVALID_CARD], + 'lost_card' => ['lost_card', DeclineCode::LOST_OR_STOLEN], + 'stolen_card' => ['stolen_card', DeclineCode::LOST_OR_STOLEN], + 'pickup_card' => ['pickup_card', DeclineCode::LOST_OR_STOLEN], + 'restricted_card' => ['restricted_card', DeclineCode::LOST_OR_STOLEN], + 'fraudulent' => ['fraudulent', DeclineCode::FRAUD_SUSPECTED], + 'merchant_blacklist' => ['merchant_blacklist', DeclineCode::FRAUD_SUSPECTED], + 'authentication_required' => ['authentication_required', DeclineCode::AUTHENTICATION_REQUIRED], + 'authentication_not_handled' => ['authentication_not_handled', DeclineCode::AUTHENTICATION_REQUIRED], + 'setup_intent_authentication_failure' => ['setup_intent_authentication_failure', DeclineCode::AUTHENTICATION_REQUIRED], + 'payment_intent_authentication_failure' => ['payment_intent_authentication_failure', DeclineCode::AUTHENTICATION_REQUIRED], + 'card_not_supported' => ['card_not_supported', DeclineCode::BRAND_NOT_SUPPORTED], + 'currency_not_supported' => ['currency_not_supported', DeclineCode::BRAND_NOT_SUPPORTED], + 'do_not_honor' => ['do_not_honor', DeclineCode::DO_NOT_HONOR], + 'call_issuer' => ['call_issuer', DeclineCode::DO_NOT_HONOR], + 'transaction_not_allowed' => ['transaction_not_allowed', DeclineCode::DO_NOT_HONOR], + 'security_violation' => ['security_violation', DeclineCode::DO_NOT_HONOR], + 'processing_error' => ['processing_error', DeclineCode::TRY_AGAIN], + 'issuer_not_available' => ['issuer_not_available', DeclineCode::TRY_AGAIN], + 'try_again_later' => ['try_again_later', DeclineCode::TRY_AGAIN], + 'generic_decline' => ['generic_decline', DeclineCode::GENERIC], + 'card_declined (code sem decline_code)' => ['card_declined', DeclineCode::GENERIC], + 'duplicate_transaction' => ['duplicate_transaction', DeclineCode::GENERIC], + ]; + } + + #[DataProvider('unmappedProvider')] + public function testUnmappedOrEmptyCodeIsNull(?string $stripeCode): void + { + $this->assertNull(DeclineCodes::toDeclineCode($stripeCode)); + } + + public static function unmappedProvider(): array + { + return [ + 'nulo' => [null], + 'vazio' => [''], + 'PIN (cartão presente)' => ['offline_pin_required'], + 'inexistente' => ['made_up_code'], + ]; + } + + #[DataProvider('adviceProvider')] + public function testAdviceCodeDecidesRetryableOnlyWhenItTalksAboutRetrying(?string $advice, ?bool $expected): void + { + $this->assertSame($expected, DeclineCodes::retryableFromAdvice($advice)); + } + + public static function adviceProvider(): array + { + return [ + 'try_again_later' => ['try_again_later', true], + 'do_not_try_again' => ['do_not_try_again', false], + 'confirm_card_data' => ['confirm_card_data', null], + 'ausente' => [null, null], + ]; + } +} diff --git a/tests/Unit/Gateways/Stripe/ProrationBehaviorsTest.php b/tests/Unit/Gateways/Stripe/ProrationBehaviorsTest.php new file mode 100644 index 0000000..1a9b6e9 --- /dev/null +++ b/tests/Unit/Gateways/Stripe/ProrationBehaviorsTest.php @@ -0,0 +1,36 @@ +assertSame($expected, ProrationBehaviors::toStripe($behavior)); + } + + public static function behaviorProvider(): array + { + return [ + 'charge_difference fatura na hora' => [ProrationBehavior::CHARGE_DIFFERENCE, 'always_invoice'], + 'none não cria pró-rata' => [ProrationBehavior::NONE, 'none'], + 'credit deixa o crédito para a próxima fatura' => [ProrationBehavior::CREDIT, 'create_prorations'], + ]; + } + + public function testEveryPolicyIsMapped(): void + { + foreach (ProrationBehavior::cases() as $behavior) { + $this->assertNotSame('', ProrationBehaviors::toStripe($behavior)); + } + } +} diff --git a/tests/Unit/Gateways/Stripe/SubscriptionStatusesTest.php b/tests/Unit/Gateways/Stripe/SubscriptionStatusesTest.php new file mode 100644 index 0000000..2c90df8 --- /dev/null +++ b/tests/Unit/Gateways/Stripe/SubscriptionStatusesTest.php @@ -0,0 +1,122 @@ +instance('log', $this->logger = new RecordingLogger()); + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + #[DataProvider('fixtureProvider')] + public function testEachStripeStatusIsTranslatedFromItsFixture(string $fixture, SubscriptionStatus $expected): void + { + $subscription = \Stripe\Subscription::constructFrom(self::fixture($fixture)); + + $this->assertSame($expected, SubscriptionStatuses::toSubscriptionStatus($subscription)); + $this->assertSame([], $this->logger->records); + } + + public static function fixtureProvider(): array + { + return [ + 'incomplete' => ['incomplete', SubscriptionStatus::PENDING], + 'incomplete_expired' => ['incomplete_expired', SubscriptionStatus::EXPIRED], + 'trialing' => ['trialing', SubscriptionStatus::TRIALING], + 'active' => ['active', SubscriptionStatus::ACTIVE], + 'past_due' => ['past_due', SubscriptionStatus::PAST_DUE], + 'unpaid' => ['unpaid', SubscriptionStatus::PAST_DUE], + 'canceled' => ['canceled', SubscriptionStatus::CANCELED], + 'paused' => ['paused', SubscriptionStatus::PAUSED], + 'active com pause_collection' => ['active_pause_collection', SubscriptionStatus::PAUSED], + ]; + } + + /** + * Toda string da máquina de estados documentada tem uma linha no mapa. + */ + public function testEveryDocumentedStripeStatusIsMapped(): void + { + foreach (['incomplete', 'incomplete_expired', 'trialing', 'active', 'past_due', 'canceled', 'unpaid', 'paused'] as $status) { + $this->assertNotSame( + SubscriptionStatus::UNKNOWN, + SubscriptionStatuses::toSubscriptionStatus((object) ['status' => $status]), + $status + ); + } + + $this->assertSame([], $this->logger->records); + } + + public function testPauseCollectionDoesNotReviveAnEndedSubscription(): void + { + $paused = ['behavior' => 'void', 'resumes_at' => null]; + + $this->assertSame( + SubscriptionStatus::CANCELED, + SubscriptionStatuses::toSubscriptionStatus((object) ['status' => 'canceled', 'pause_collection' => $paused]) + ); + $this->assertSame( + SubscriptionStatus::EXPIRED, + SubscriptionStatuses::toSubscriptionStatus((object) ['status' => 'incomplete_expired', 'pause_collection' => $paused]) + ); + $this->assertSame( + SubscriptionStatus::PAUSED, + SubscriptionStatuses::toSubscriptionStatus((object) ['status' => 'past_due', 'pause_collection' => $paused]) + ); + } + + public function testAnUnknownStatusReadsAsUnknownWithAWarningNamingStripe(): void + { + $status = SubscriptionStatuses::toSubscriptionStatus((object) [ + 'status' => 'status_novo', + 'pause_collection' => ['behavior' => 'void'], + ]); + + $this->assertSame(SubscriptionStatus::UNKNOWN, $status); + $this->assertCount(1, $this->logger->records); + $this->assertSame('warning', $this->logger->records[0]['level']); + $this->assertSame(['status' => 'status_novo', 'gateway' => 'stripe'], $this->logger->records[0]['context']); + } + + public function testAMissingStatusReadsAsUnknownWithAWarning(): void + { + $this->assertSame(SubscriptionStatus::UNKNOWN, SubscriptionStatuses::toSubscriptionStatus((object) [])); + $this->assertSame(['status' => '', 'gateway' => 'stripe'], $this->logger->records[0]['context']); + } + + private static function fixture(string $name): array + { + return json_decode( + file_get_contents(__DIR__ . '/../../../fixtures/stripe/subscriptions/' . $name . '.json'), + true + ); + } +} diff --git a/tests/Unit/Gateways/StripeGatewayAutomaticPixTest.php b/tests/Unit/Gateways/StripeGatewayAutomaticPixTest.php new file mode 100644 index 0000000..22d722a --- /dev/null +++ b/tests/Unit/Gateways/StripeGatewayAutomaticPixTest.php @@ -0,0 +1,483 @@ +instance('config', new Repository([ + 'multi-payment.gateways.stripe.api_key' => 'sk_test_fake', + 'multi-payment.gateways.stripe.pix_mandate_reference' => 'Empresa Exemplo', + ])); + Facade::setFacadeApplication($app); + + RecordingStripeHttpClient::withResponses([]); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + ApiRequestor::setHttpClient(null); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testCreateSubscriptionWithAutomaticPixRegistersTheMandate(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::priceResponse(), + self::fixture('subscriptions/incomplete_automatic_pix'), + self::fixture('invoices/open_requires_payment_method'), + ]); + + $subscription = self::subscriptionModel(); + $subscription->paymentMethod = PaymentMethod::AUTOMATIC_PIX; + + $result = (new StripeGateway())->createSubscription($subscription); + + $this->assertSame([ + 'get /v1/prices/price_fake1', + 'post /v1/subscriptions', + 'get /v1/invoices/in_1UBJmkPjx0CusuMrN6Yc2Ha1', + ], self::calledPaths($httpClient)); + + $params = $httpClient->calls[1][2]; + $this->assertSame('charge_automatically', $params['collection_method']); + $this->assertSame('default_incomplete', $params['payment_behavior']); + $this->assertSame(['pix'], $params['payment_settings']['payment_method_types']); + + $this->assertSame([ + 'amount' => 10000, + 'amount_type' => 'fixed', + 'payment_schedule' => 'monthly', + 'start_date' => Carbon::parse('2026-09-08 00:00:00')->getTimestamp(), + 'reference' => 'Empresa Exemplo', + ], $params['payment_settings']['payment_method_options']['pix']['mandate_options']); + + $this->assertSame(SubscriptionStatus::PENDING, $result->status); + $this->assertSame(PaymentMethod::AUTOMATIC_PIX, $result->paymentMethod); + $this->assertNull($result->availablePaymentMethods); + $this->assertInstanceOf(AutomaticPix::class, $result->automaticPix); + $this->assertSame(AutomaticPix::FREQUENCY_MONTHLY, $result->automaticPix->frequency); + $this->assertSame(1788825600, $result->automaticPix->startsAt->getTimestamp()); + $this->assertSame(1791157981, $result->automaticPix->preDebitNotificationAt->getTimestamp()); + $this->assertSame( + Carbon::createFromTimestamp(1791157981)->addDays(3)->getTimestamp(), + $result->automaticPix->nextDebitAt->getTimestamp() + ); + $this->assertSame('stripe', $result->automaticPix->gateway); + } + + /** + * A frequência e as datas informadas em `automaticPix` prevalecem sobre as derivadas do + * plano, e um desconto na assinatura faz o valor do mandato virar um teto (`maximum`). + */ + public function testCreateSubscriptionWithAutomaticPixHonorsTheModelAndTheDiscounts(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::priceResponse(), + self::fixture('subscriptions/incomplete_automatic_pix'), + self::fixture('invoices/open_requires_payment_method'), + ]); + + $startsAt = Carbon::now()->addDays(10)->startOfDay(); + $endsAt = Carbon::now()->addYear()->startOfDay(); + + $subscription = self::subscriptionModel(); + $subscription->paymentMethod = PaymentMethod::AUTOMATIC_PIX; + $subscription->automaticPix = new AutomaticPix(); + $subscription->automaticPix->frequency = AutomaticPix::FREQUENCY_WEEKLY; + $subscription->automaticPix->startsAt = $startsAt; + $subscription->automaticPix->endsAt = $endsAt; + $discount = new SubscriptionDiscount(); + $discount->id = 'coupon_fake1'; + $subscription->discounts = [$discount]; + + (new StripeGateway())->createSubscription($subscription); + + $mandateOptions = $httpClient->calls[1][2]['payment_settings']['payment_method_options']['pix']['mandate_options']; + $this->assertSame('weekly', $mandateOptions['payment_schedule']); + $this->assertSame('maximum', $mandateOptions['amount_type']); + $this->assertSame($startsAt->getTimestamp(), $mandateOptions['start_date']); + $this->assertSame($endsAt->getTimestamp(), $mandateOptions['end_date']); + } + + /** + * O valor do mandato soma o plano com os itens recorrentes, a data derivada anterior ao + * mínimo de três dias é elevada a ele, e sem `pix_mandate_reference` na configuração o + * `reference` fica de fora. + */ + public function testCreateSubscriptionWithAutomaticPixSumsTheItemsAndFloorsTheDerivedStart(): void + { + $app = new Container(); + $app->instance('config', new Repository([ + 'multi-payment.gateways.stripe.api_key' => 'sk_test_fake', + ])); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication($app); + + $httpClient = RecordingStripeHttpClient::withResponses([ + self::priceResponse(), + ['id' => 'prod_item0', 'object' => 'product', 'name' => 'Consultas', 'active' => true, 'created' => 1786700000, 'metadata' => []], + self::priceResponse(), + self::fixture('subscriptions/incomplete_automatic_pix'), + self::fixture('invoices/open_requires_payment_method'), + ]); + + $subscription = self::subscriptionModel(); + $subscription->paymentMethod = PaymentMethod::AUTOMATIC_PIX; + $subscription->trialEndsAt = Carbon::now()->addDay(); + $item = new SubscriptionItem(); + $item->description = 'Consultas'; + $item->amount = 2500; + $item->quantity = 2; + $subscription->items = [$item]; + + (new StripeGateway())->createSubscription($subscription); + + $mandateOptions = $httpClient->calls[3][2]['payment_settings']['payment_method_options']['pix']['mandate_options']; + $this->assertSame(15000, $mandateOptions['amount']); + $this->assertSame(Carbon::parse('2026-09-08 00:00:00')->getTimestamp(), $mandateOptions['start_date']); + $this->assertArrayNotHasKey('reference', $mandateOptions); + } + + public function testCreateSubscriptionWithAutomaticPixRejectsAStartDateEarlierThanThreeDays(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::priceResponse(), + ]); + + $subscription = self::subscriptionModel(); + $subscription->paymentMethod = PaymentMethod::AUTOMATIC_PIX; + $subscription->automaticPix = new AutomaticPix(); + $subscription->automaticPix->startsAt = Carbon::now()->addDay(); + + try { + (new StripeGateway())->createSubscription($subscription); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('startsAt must be at least 3 days', $e->getMessage()); + } + + $this->assertSame(['get /v1/prices/price_fake1'], self::calledPaths($httpClient)); + } + + /** + * O mandato precisa do valor por ciclo: um Price sem `unit_amount` fixo (por camadas ou + * por uso) é recusado antes da criação da assinatura. + */ + public function testCreateSubscriptionWithAutomaticPixRejectsAPlanWithoutAFixedUnitAmount(): void + { + $price = self::priceResponse(); + $price['unit_amount'] = null; + $httpClient = RecordingStripeHttpClient::withResponses([$price]); + + $subscription = self::subscriptionModel(); + $subscription->paymentMethod = PaymentMethod::AUTOMATIC_PIX; + + try { + (new StripeGateway())->createSubscription($subscription); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::AUTOMATIC_PIX, $e->capability); + $this->assertStringContainsString('unit_amount', $e->getMessage()); + } + + $this->assertSame(['get /v1/prices/price_fake1'], self::calledPaths($httpClient)); + } + + /** + * Trocar o método de uma assinatura que já tem mandato é recusado antes da rede: sem a + * recusa, a troca seria engolida em silêncio e o mandato continuaria valendo. + */ + public function testUpdateSubscriptionRefusesLeavingTheMandateForAnotherMethod(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $subscription->paymentMethod = PaymentMethod::PIX; + $subscription->original = json_decode(json_encode(self::fixture('subscriptions/active_automatic_pix'))); + + try { + (new StripeGateway())->updateSubscription($subscription); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::AUTOMATIC_PIX, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_NOT_IMPLEMENTED, $e->reason); + } + + $this->assertSame([], $httpClient->calls); + } + + public function testCreateSubscriptionWithAutomaticPixRejectsAPlanIntervalWithoutASchedule(): void + { + $price = self::priceResponse(); + $price['recurring'] = ['interval' => 'week', 'interval_count' => 2, 'usage_type' => 'licensed']; + $httpClient = RecordingStripeHttpClient::withResponses([$price]); + + $subscription = self::subscriptionModel(); + $subscription->paymentMethod = PaymentMethod::AUTOMATIC_PIX; + + try { + (new StripeGateway())->createSubscription($subscription); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::AUTOMATIC_PIX, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertStringContainsString('[week:2]', $e->getMessage()); + } + + $this->assertSame(['get /v1/prices/price_fake1'], self::calledPaths($httpClient)); + } + + public function testGetSubscriptionReadsTheMandateIntoAutomaticPix(): void + { + RecordingStripeHttpClient::withResponses([ + self::fixture('subscriptions/active_automatic_pix'), + self::fixture('invoices/open_requires_payment_method'), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + + $result = (new StripeGateway())->getSubscription($subscription); + + $this->assertSame(SubscriptionStatus::ACTIVE, $result->status); + $this->assertSame(PaymentMethod::AUTOMATIC_PIX, $result->paymentMethod); + $this->assertNull($result->availablePaymentMethods); + $this->assertSame(AutomaticPix::FREQUENCY_MONTHLY, $result->automaticPix->frequency); + $this->assertSame(1791157981, $result->automaticPix->preDebitNotificationAt->getTimestamp()); + $this->assertNull($result->automaticPix->mandateId); + $this->assertNull($result->automaticPix->mandateStatus); + } + + /** + * Sem `current_period_end` na leitura, as datas derivadas do ciclo ficam nulas; o restante + * do mandato é lido normalmente. + */ + public function testGetSubscriptionWithoutAPeriodEndLeavesTheDerivedDatesNull(): void + { + $fixture = self::fixture('subscriptions/active_automatic_pix'); + unset($fixture['items']['data'][0]['current_period_end']); + RecordingStripeHttpClient::withResponses([ + $fixture, + self::fixture('invoices/open_requires_payment_method'), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + + $result = (new StripeGateway())->getSubscription($subscription); + + $this->assertSame(AutomaticPix::FREQUENCY_MONTHLY, $result->automaticPix->frequency); + $this->assertNull($result->automaticPix->nextDebitAt); + $this->assertNull($result->automaticPix->preDebitNotificationAt); + } + + /** + * As três operações de agendamento respondem `managed_by_gateway` sem nenhuma requisição; + * o encerramento da recorrência orienta o cancelamento da assinatura. + */ + public function testSchedulingOperationsAreManagedByTheGateway(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $gateway = new StripeGateway(); + + $charge = new AutomaticPixCharge(); + $charge->id = 'pay_1'; + $charge->endToEndId = 'E123'; + $automaticPix = new AutomaticPix(); + $automaticPix->id = 'mandate_1UBJmkPjx0CusuMr7PxMnd41'; + + foreach ( + [ + fn () => $gateway->rescheduleAutomaticPixPayment(new \Potelo\MultiPayment\Models\Invoice()), + fn () => $gateway->cancelAutomaticPixScheduledPayment($charge), + fn () => $gateway->cancelAutomaticPixRecurrence($automaticPix), + ] as $operation + ) { + try { + $operation(); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::AUTOMATIC_PIX, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_MANAGED_BY_GATEWAY, $e->reason); + } + } + + $this->assertSame([], $httpClient->calls); + } + + public function testListAutomaticPixCancellationsReadsTheMandate(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('mandates/active'), + ]); + + $automaticPix = new AutomaticPix(); + $automaticPix->id = 'mandate_1UBJmkPjx0CusuMr7PxMnd41'; + + $cancellations = (new StripeGateway())->listAutomaticPixCancellations($automaticPix); + + $this->assertSame( + ['get /v1/mandates/mandate_1UBJmkPjx0CusuMr7PxMnd41'], + self::calledPaths($httpClient) + ); + $this->assertSame([], $cancellations); + $this->assertSame('mandate_1UBJmkPjx0CusuMr7PxMnd41', $automaticPix->mandateId); + $this->assertSame('active', $automaticPix->mandateStatus); + } + + public function testListAutomaticPixCancellationsReturnsTheCompletedOneForAnInactiveMandate(): void + { + RecordingStripeHttpClient::withResponses([ + self::fixture('mandates/inactive'), + ]); + + $automaticPix = new AutomaticPix(); + $automaticPix->id = 'mandate_1UBJmkPjx0CusuMr7PxMnd41'; + + $cancellations = (new StripeGateway())->listAutomaticPixCancellations($automaticPix); + + $this->assertCount(1, $cancellations); + $this->assertSame(AutomaticPixCancellation::STATUS_COMPLETED, $cancellations[0]->status); + $this->assertSame('mandate_1UBJmkPjx0CusuMr7PxMnd41', $cancellations[0]->recurrenceId); + $this->assertSame('inactive', $automaticPix->mandateStatus); + } + + public function testGetAutomaticPixCancellationReadsTheMandate(): void + { + RecordingStripeHttpClient::withResponses([ + self::fixture('mandates/inactive'), + ]); + + $cancellation = new AutomaticPixCancellation(); + $cancellation->recurrenceId = 'mandate_1UBJmkPjx0CusuMr7PxMnd41'; + + $result = (new StripeGateway())->getAutomaticPixCancellation($cancellation); + + $this->assertSame(AutomaticPixCancellation::STATUS_COMPLETED, $result->status); + $this->assertSame('mandate_1UBJmkPjx0CusuMr7PxMnd41', $result->id); + $this->assertSame('stripe', $result->gateway); + } + + public function testGetAutomaticPixCancellationOnAnActiveMandateIsNotFound(): void + { + RecordingStripeHttpClient::withResponses([ + self::fixture('mandates/active'), + ]); + + $cancellation = new AutomaticPixCancellation(); + $cancellation->recurrenceId = 'mandate_1UBJmkPjx0CusuMr7PxMnd41'; + + $this->expectException(NotFoundException::class); + + (new StripeGateway())->getAutomaticPixCancellation($cancellation); + } + + /** + * Num model lido do gateway o mandato já existe e o método não é uma troca: o update passa + * sem reescrever `payment_settings`. + */ + public function testUpdateSubscriptionKeepsAnExistingMandateUntouched(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('subscriptions/active_automatic_pix'), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $subscription->paymentMethod = PaymentMethod::AUTOMATIC_PIX; + $subscription->metadata = ['origem' => 'teste']; + $subscription->original = json_decode(json_encode(self::fixture('subscriptions/active_automatic_pix'))); + + (new StripeGateway())->updateSubscription($subscription); + + $params = $httpClient->calls[0][2]; + $this->assertArrayNotHasKey('payment_settings', $params); + $this->assertSame(['origem' => 'teste'], $params['metadata']); + } + + private static function subscriptionModel(): Subscription + { + $subscription = new Subscription(); + $subscription->customer = new Customer(); + $subscription->customer->id = 'cus_fake123'; + $subscription->planId = 'price_fake1'; + + return $subscription; + } + + /** + * @return string[] `método caminho` de cada chamada gravada + */ + private static function calledPaths(RecordingStripeHttpClient $httpClient): array + { + return array_map( + static fn (array $call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), + $httpClient->calls + ); + } + + private static function fixture(string $path): array + { + return json_decode(file_get_contents(__DIR__ . "/../../fixtures/stripe/{$path}.json"), true); + } + + private static function priceResponse(): array + { + return [ + 'id' => 'price_fake1', + 'object' => 'price', + 'active' => true, + 'currency' => 'brl', + 'lookup_key' => 'plano_mensal', + 'nickname' => null, + 'created' => 1786700000, + 'product' => 'prod_fake1', + 'recurring' => ['interval' => 'month', 'interval_count' => 1, 'usage_type' => 'licensed'], + 'type' => 'recurring', + 'unit_amount' => 10000, + 'unit_amount_decimal' => '10000', + ]; + } +} diff --git a/tests/Unit/Gateways/StripeGatewayCreditCardTest.php b/tests/Unit/Gateways/StripeGatewayCreditCardTest.php new file mode 100644 index 0000000..07ab9f1 --- /dev/null +++ b/tests/Unit/Gateways/StripeGatewayCreditCardTest.php @@ -0,0 +1,612 @@ +instance('config', new Repository([ + 'multi-payment.gateways.stripe.api_key' => 'sk_test_fake', + ])); + Facade::setFacadeApplication($app); + + // fake vazio por padrão: teste que esquecer withResponses() estoura em vez de ir à rede + RecordingStripeHttpClient::withResponses([]); + } + + protected function tearDown(): void + { + ApiRequestor::setHttpClient(null); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testCreateCreditCardRejectsRawCardData(): void + { + $creditCard = $this->creditCardModel(); + $creditCard->token = null; + $creditCard->number = '4111111111111111'; + $creditCard->month = '12'; + $creditCard->year = '2030'; + $creditCard->cvv = '123'; + + $httpClient = RecordingStripeHttpClient::withResponses([]); + + try { + (new StripeGateway())->createCreditCard($creditCard); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::RAW_CARD_DATA, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertStringContainsString('Stripe.js', $e->getMessage()); + } + $this->assertSame([], $httpClient->calls); + } + + public function testCreateCreditCardRequiresCustomer(): void + { + $creditCard = new CreditCard(); + $creditCard->token = 'pm_fake123'; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('customer'); + + (new StripeGateway())->createCreditCard($creditCard); + } + + /** + * O SetupIntent confirmado com cliente anexa o PaymentMethod; nenhum attach é enviado. + */ + public function testCreateCreditCardSavesTheCardThroughAConfirmedSetupIntent(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->setupIntentResponse()]); + + $result = (new StripeGateway())->createCreditCard($this->creditCardModel()); + + $this->assertCount(1, $httpClient->calls); + [$method, $url, $params] = $httpClient->calls[0]; + $this->assertSame('post', $method); + $this->assertSame('/v1/setup_intents', parse_url($url, PHP_URL_PATH)); + $this->assertSame([ + 'customer' => 'cus_fake123', + 'payment_method' => 'pm_fake123', + 'payment_method_types' => ['card'], + 'usage' => 'off_session', + 'confirm' => 'true', + 'expand' => ['payment_method'], + ], $params); + + $this->assertSame('pm_fake123', $result->id); + $this->assertSame('seti_fake123', $result->setupId); + $this->assertFalse($result->requiresAction); + $this->assertNull($result->clientSecret); + $this->assertNull($result->actionUrl); + $this->assertSame('visa', $result->brand); + $this->assertSame('4242', $result->lastDigits); + $this->assertSame('08', $result->month); + $this->assertSame('2027', $result->year); + $this->assertSame('Faker', $result->firstName); + $this->assertSame('Teste', $result->lastName); + $this->assertSame('stripe', $result->gateway); + $this->assertInstanceOf(\Stripe\PaymentMethod::class, $result->original); + $this->assertInstanceOf(Carbon::class, $result->createdAt); + } + + /** + * Resposta em que a Stripe devolve o PaymentMethod sem cliente: o driver anexa por conta + * própria antes de devolver o cartão. + */ + public function testCreateCreditCardAttachesWhenStripeReturnsTheCardWithoutCustomer(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->setupIntentResponse(paymentMethodCustomer: null), + $this->paymentMethodResponse(customer: 'cus_fake123'), + ]); + + $result = (new StripeGateway())->createCreditCard($this->creditCardModel()); + + $this->assertSame([ + 'post /v1/setup_intents', + 'post /v1/payment_methods/pm_fake123/attach', + ], $this->paths($httpClient)); + $this->assertSame(['customer' => 'cus_fake123'], $httpClient->calls[1][2]); + $this->assertSame('pm_fake123', $result->id); + $this->assertFalse($result->requiresAction); + } + + public function testCreateDefaultCreditCardWithDescriptionIssuesExtraUpdates(): void + { + $metadata = ['description' => 'cartão principal', 'set_as_default' => '1']; + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->setupIntentResponse(metadata: $metadata), + $this->paymentMethodResponse(customer: 'cus_fake123', metadata: ['description' => 'cartão principal']), + $this->stripeCustomerResponse(), + ]); + + $creditCard = $this->creditCardModel(); + $creditCard->description = 'cartão principal'; + $creditCard->default = true; + $result = (new StripeGateway())->createCreditCard($creditCard); + + $this->assertSame([ + 'post /v1/setup_intents', + 'post /v1/payment_methods/pm_fake123', + 'post /v1/customers/cus_fake123', + ], $this->paths($httpClient)); + $this->assertSame($metadata, $httpClient->calls[0][2]['metadata']); + $this->assertSame(['metadata' => ['description' => 'cartão principal']], $httpClient->calls[1][2]); + $this->assertSame( + ['invoice_settings' => ['default_payment_method' => 'pm_fake123']], + $httpClient->calls[2][2] + ); + $this->assertSame('cartão principal', $result->description); + } + + /** + * Resposta gravada na sandbox com `pm_card_visa`: o PaymentMethod expandido já vem com o + * cliente, e a descrição e a marcação de padrão em `metadata` do setup viram as duas + * escritas seguintes. + */ + public function testCreateCreditCardParsesTheRecordedSucceededSetupIntent(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('succeeded'), + $this->paymentMethodResponse(customer: 'cus_VBjzroZKS8d5LY', metadata: ['description' => 'cartão de teste']), + $this->stripeCustomerResponse(), + ]); + + $creditCard = $this->creditCardModel(); + $creditCard->customer->id = 'cus_VBjzroZKS8d5LY'; + $creditCard->description = 'cartão de teste'; + $creditCard->default = true; + $result = (new StripeGateway())->createCreditCard($creditCard); + + $this->assertSame([ + 'post /v1/setup_intents', + 'post /v1/payment_methods/pm_1UBMVgPjx0CusuMrtGi8Aruq', + 'post /v1/customers/cus_VBjzroZKS8d5LY', + ], $this->paths($httpClient)); + $this->assertSame( + ['invoice_settings' => ['default_payment_method' => 'pm_1UBMVgPjx0CusuMrtGi8Aruq']], + $httpClient->calls[2][2] + ); + $this->assertSame('seti_1UBMVgPjx0CusuMrtgTZPbBG', $result->setupId); + $this->assertFalse($result->requiresAction); + $this->assertSame('cartão de teste', $result->description); + } + + /** + * `metadata` de `gatewayOptions` convive com as chaves que o setup usa para a descrição + * e o padrão. + */ + public function testCreateCreditCardMergesTheConsumerMetadataWithTheSetupMetadata(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->setupIntentResponse(metadata: ['user_id' => '7', 'description' => 'cartão principal']), + $this->paymentMethodResponse(customer: 'cus_fake123', metadata: ['description' => 'cartão principal']), + ]); + + $creditCard = $this->creditCardModel(); + $creditCard->description = 'cartão principal'; + $creditCard->gatewayOptions = ['metadata' => ['user_id' => '7']]; + $result = (new StripeGateway())->createCreditCard($creditCard); + + $this->assertSame(['user_id' => '7', 'description' => 'cartão principal'], $httpClient->calls[0][2]['metadata']); + $this->assertSame('post /v1/payment_methods/pm_fake123', $this->paths($httpClient)[1]); + $this->assertSame('cartão principal', $result->description); + } + + public function testConfirmCreditCardSetupRefusesASetupWithoutCustomerBeforeAnyWrite(): void + { + $setupIntent = $this->setupIntentResponse(paymentMethodCustomer: null); + $setupIntent['customer'] = null; + $httpClient = RecordingStripeHttpClient::withResponses([$setupIntent]); + + try { + (new StripeGateway())->confirmCreditCardSetup('seti_fake123'); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('no customer', $e->getMessage()); + } + $this->assertSame(['get /v1/setup_intents/seti_fake123'], $this->paths($httpClient)); + } + + public function testCreateCreditCardConvertsLegacyTokenIntoPaymentMethod(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->paymentMethodResponse(), + $this->setupIntentResponse(), + ]); + + $creditCard = $this->creditCardModel(); + $creditCard->token = 'tok_fake123'; + (new StripeGateway())->createCreditCard($creditCard); + + $this->assertSame([ + 'post /v1/payment_methods', + 'post /v1/setup_intents', + ], $this->paths($httpClient)); + $this->assertSame(['type' => 'card', 'card' => ['token' => 'tok_fake123']], $httpClient->calls[0][2]); + $this->assertSame('pm_fake123', $httpClient->calls[1][2]['payment_method']); + } + + /** + * Resposta gravada na sandbox com `pm_card_authenticationRequired`: o cartão volta sem id, + * com o que o navegador precisa para autenticar, e nada é anexado ao cliente. + */ + public function testCreateCreditCardReturnsRequiresActionWhenTheIssuerAsksForAuthentication(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('requires_action')]); + + $creditCard = $this->creditCardModel(); + $creditCard->description = 'cartão 3ds'; + $result = (new StripeGateway())->createCreditCard($creditCard); + + $this->assertSame(['post /v1/setup_intents'], $this->paths($httpClient)); + $this->assertTrue($result->requiresAction); + $this->assertNull($result->id); + $this->assertSame('seti_1UBMViPjx0CusuMrBNDQEzqv', $result->setupId); + $this->assertSame('seti_1UBMViPjx0CusuMrBNDQEzqv_secret_PLACEHOLDER', $result->clientSecret); + $this->assertNull($result->actionUrl, 'sem return_url a autenticação é pelo Stripe.js (use_stripe_sdk)'); + $this->assertSame('visa', $result->brand); + $this->assertSame('3184', $result->lastDigits); + $this->assertSame('09', $result->month); + $this->assertSame('2027', $result->year); + $this->assertSame('cartão 3ds', $result->description); + $this->assertSame('cus_fake123', $result->customer->id); + $this->assertSame('stripe', $result->gateway); + $this->assertInstanceOf(\Stripe\SetupIntent::class, $result->original); + $this->assertInstanceOf(Carbon::class, $result->createdAt); + } + + /** + * Com `return_url` em `gatewayOptions`, a Stripe devolve a página hospedada de 3DS em + * `next_action.redirect_to_url`, que vira `actionUrl`. + */ + public function testCreateCreditCardExposesTheHostedAuthenticationPageWhenReturnUrlIsGiven(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('requires_action_redirect')]); + + $creditCard = $this->creditCardModel(); + $creditCard->gatewayOptions = ['return_url' => 'https://exemplo.com/retorno']; + $result = (new StripeGateway())->createCreditCard($creditCard); + + $this->assertSame('https://exemplo.com/retorno', $httpClient->calls[0][2]['return_url']); + $this->assertTrue($result->requiresAction); + $this->assertStringStartsWith('https://hooks.stripe.com/3d_secure_2/hosted?', $result->actionUrl); + $this->assertSame('seti_1UBMVjPjx0CusuMr53vgIbuw', $result->setupId); + } + + /** + * Recusa no setup (resposta 402 gravada com `pm_card_chargeDeclined`) é `ChargingException` + * com o código traduzido e o `advice_code` decidindo `retryable`. + */ + public function testCreateCreditCardDeclinedAtSetupBecomesCardDeclinedException(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([[self::fixture('card_declined'), 402]]); + + try { + (new StripeGateway())->createCreditCard($this->creditCardModel()); + $this->fail('Esperava CardDeclinedException'); + } catch (CardDeclinedException $e) { + $this->assertInstanceOf(ChargingException::class, $e); + $this->assertSame(DeclineCode::GENERIC, $e->declineCode); + $this->assertSame('generic_decline', $e->gatewayCode); + $this->assertTrue($e->retryable, 'advice_code try_again_later'); + $this->assertSame(402, $e->httpStatus); + $this->assertSame('card_declined', $e->reason); + $this->assertInstanceOf(\Stripe\Exception\CardException::class, $e->getPrevious()); + $this->assertIsArray($e->chargeResponse); + $this->assertSame('generic_decline', $e->chargeResponse['decline_code']); + $this->assertSame('requires_payment_method', $e->chargeResponse['setup_intent']['status']); + } + $this->assertSame(['post /v1/setup_intents'], $this->paths($httpClient)); + } + + /** + * Depois da autenticação: o setup é lido, o cartão já está anexado, e a descrição e a + * marcação de padrão guardadas em `metadata` do setup são aplicadas. + */ + public function testConfirmCreditCardSetupAppliesTheSetupMetadataAndReturnsTheCard(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->setupIntentResponse(metadata: ['description' => 'cartão principal', 'set_as_default' => '1']), + $this->paymentMethodResponse(customer: 'cus_fake123', metadata: ['description' => 'cartão principal']), + $this->stripeCustomerResponse(), + ]); + + $result = (new StripeGateway())->confirmCreditCardSetup('seti_fake123'); + + $this->assertSame([ + 'get /v1/setup_intents/seti_fake123', + 'post /v1/payment_methods/pm_fake123', + 'post /v1/customers/cus_fake123', + ], $this->paths($httpClient)); + $this->assertSame(['expand' => ['payment_method']], $httpClient->calls[0][2]); + $this->assertSame('pm_fake123', $result->id); + $this->assertSame('seti_fake123', $result->setupId); + $this->assertFalse($result->requiresAction); + $this->assertSame('cartão principal', $result->description); + $this->assertTrue($result->default); + $this->assertSame('cus_fake123', $result->customer->id); + $this->assertSame('4242', $result->lastDigits); + } + + public function testConfirmCreditCardSetupKeepsRequiresActionWhileThePayerHasNotAuthenticated(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('requires_action')]); + + $result = (new StripeGateway())->confirmCreditCardSetup('seti_1UBMViPjx0CusuMrBNDQEzqv'); + + $this->assertSame(['get /v1/setup_intents/seti_1UBMViPjx0CusuMrBNDQEzqv'], $this->paths($httpClient)); + $this->assertTrue($result->requiresAction); + $this->assertNull($result->id); + $this->assertSame('seti_1UBMViPjx0CusuMrBNDQEzqv', $result->setupId); + $this->assertSame('cus_VBjzroZKS8d5LY', $result->customer->id); + $this->assertNotEmpty($result->clientSecret); + $this->assertSame('cartão 3ds', $result->description, 'a descrição volta de metadata do setup'); + $this->assertSame('3184', $result->lastDigits); + } + + /** + * Autenticação que falhou: o setup volta a `requires_payment_method` com + * `last_setup_error`, e a recusa pede ação do pagador. + */ + public function testConfirmCreditCardSetupOnAFailedAuthenticationThrowsCardDeclined(): void + { + RecordingStripeHttpClient::withResponses([ + $this->setupIntentResponse(status: 'requires_payment_method', paymentMethodCustomer: null, lastSetupError: [ + 'type' => 'invalid_request_error', + 'code' => 'setup_intent_authentication_failure', + 'message' => 'The latest attempt to set up the payment method has failed because authentication failed.', + ]), + ]); + + try { + (new StripeGateway())->confirmCreditCardSetup('seti_fake123'); + $this->fail('Esperava CardDeclinedException'); + } catch (CardDeclinedException $e) { + $this->assertSame(DeclineCode::AUTHENTICATION_REQUIRED, $e->declineCode); + $this->assertSame('setup_intent_authentication_failure', $e->gatewayCode); + $this->assertTrue($e->declineCode->requiresPayerAction()); + $this->assertFalse($e->retryable); + $this->assertNull($e->httpStatus); + $this->assertSame('seti_fake123', $e->chargeResponse['id']); + } + } + + public function testConfirmCreditCardSetupOnACanceledSetupThrowsCardDeclined(): void + { + RecordingStripeHttpClient::withResponses([ + $this->setupIntentResponse(status: 'canceled', paymentMethodCustomer: null, cancellationReason: 'abandoned'), + ]); + + try { + (new StripeGateway())->confirmCreditCardSetup('seti_fake123'); + $this->fail('Esperava CardDeclinedException'); + } catch (CardDeclinedException $e) { + $this->assertSame(DeclineCode::UNKNOWN, $e->declineCode); + $this->assertSame('abandoned', $e->gatewayCode); + $this->assertFalse($e->retryable); + $this->assertStringContainsString('canceled', $e->getMessage()); + $this->assertSame('seti_fake123', $e->chargeResponse['id']); + $this->assertSame('canceled', $e->chargeResponse['status']); + } + } + + /** + * Venda avulsa com token de cartão que exige autenticação: a cobrança fora de sessão não + * tem como atendê-la, e a fatura não chega a ser criada. + */ + public function testCreateInvoiceWithATokenThatRequiresAuthenticationFailsBeforeThePaymentIntent(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('requires_action')]); + + $invoice = new Invoice(); + $invoice->customer = new Customer(); + $invoice->customer->id = 'cus_fake123'; + $invoice->availablePaymentMethods = [PaymentMethod::CREDIT_CARD]; + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->token = 'pm_fake123'; + $item = new InvoiceItem(); + $item->description = 'Curso'; + $item->price = 19900; + $item->quantity = 1; + $invoice->items = [$item]; + + try { + (new StripeGateway())->createInvoice($invoice); + $this->fail('Esperava CardDeclinedException'); + } catch (CardDeclinedException $e) { + $this->assertSame(DeclineCode::AUTHENTICATION_REQUIRED, $e->declineCode); + $this->assertSame('authentication_required', $e->gatewayCode); + $this->assertTrue($e->declineCode->requiresPayerAction()); + $this->assertFalse($e->retryable); + $this->assertStringContainsString('confirmCreditCardSetup', $e->getMessage()); + $this->assertSame('seti_1UBMViPjx0CusuMrBNDQEzqv', $e->chargeResponse['id']); + } + $this->assertSame(['post /v1/setup_intents'], $this->paths($httpClient), 'nenhum PaymentIntent é criado'); + } + + public function testCreditCardConfirmSetupDelegatesToTheGatewayWithItsSetupId(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->setupIntentResponse()]); + + $creditCard = new CreditCard(); + $creditCard->setupId = 'seti_fake123'; + $creditCard->requiresAction = true; + $creditCard->clientSecret = 'seti_fake123_secret_fake'; + $creditCard->customer = new Customer(); + $creditCard->customer->id = 'cus_fake123'; + $creditCard->customer->name = 'Faker Teste'; + $result = $creditCard->confirmSetup(new StripeGateway()); + + $this->assertSame(['get /v1/setup_intents/seti_fake123'], $this->paths($httpClient)); + $this->assertSame($creditCard, $result, 'o próprio model é atualizado e devolvido'); + $this->assertSame('pm_fake123', $creditCard->id); + $this->assertFalse($creditCard->requiresAction); + $this->assertNull($creditCard->clientSecret); + $this->assertSame('Faker Teste', $creditCard->customer->name, 'o customer já preenchido é mantido'); + } + + public function testCreditCardConfirmSetupRequiresTheSetupId(): void + { + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('setupId'); + + (new CreditCard())->confirmSetup(new StripeGateway()); + } + + public function testGetCreditCardValidatesOwnershipWhenCustomerIsInformed(): void + { + RecordingStripeHttpClient::withResponses([ + $this->paymentMethodResponse(customer: 'cus_other'), + ]); + + $creditCard = $this->creditCardModel(); + $creditCard->id = 'pm_fake123'; + + try { + (new StripeGateway())->getCreditCard($creditCard); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertStringContainsString('does not belong to customer', $e->getMessage()); + $this->assertSame(Capability::CREDIT_CARD, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + } + } + + public function testGetCreditCardSkipsOwnershipCheckWhenCustomerOmitted(): void + { + RecordingStripeHttpClient::withResponses([ + $this->paymentMethodResponse(customer: 'cus_other'), + ]); + + $creditCard = new CreditCard(); + $creditCard->id = 'pm_fake123'; + $result = (new StripeGateway())->getCreditCard($creditCard); + + $this->assertSame('pm_fake123', $result->id); + $this->assertSame('4242', $result->lastDigits); + $this->assertFalse($result->requiresAction); + } + + public function testDeleteCreditCardDetachesThePaymentMethod(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->paymentMethodResponse(customer: 'cus_fake123'), + $this->paymentMethodResponse(), + ]); + + $creditCard = $this->creditCardModel(); + $creditCard->id = 'pm_fake123'; + (new StripeGateway())->deleteCreditCard($creditCard); + + $this->assertSame([ + 'get /v1/payment_methods/pm_fake123', + 'post /v1/payment_methods/pm_fake123/detach', + ], $this->paths($httpClient)); + } + + private function paths(RecordingStripeHttpClient $httpClient): array + { + return array_map(static fn ($call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), $httpClient->calls); + } + + private static function fixture(string $name): array + { + return json_decode(file_get_contents(self::FIXTURES . $name . '.json'), true); + } + + private function creditCardModel(): CreditCard + { + $creditCard = new CreditCard(); + $creditCard->token = 'pm_fake123'; + $creditCard->customer = new Customer(); + $creditCard->customer->id = 'cus_fake123'; + + return $creditCard; + } + + /** + * SetupIntent com o PaymentMethod expandido, no formato da resposta da Stripe. + */ + private function setupIntentResponse( + string $status = 'succeeded', + ?string $paymentMethodCustomer = 'cus_fake123', + array $metadata = [], + ?array $lastSetupError = null, + ?string $cancellationReason = null + ): array { + return [ + 'id' => 'seti_fake123', + 'object' => 'setup_intent', + 'status' => $status, + 'customer' => 'cus_fake123', + 'usage' => 'off_session', + 'client_secret' => 'seti_fake123_secret_fake', + 'created' => 1786700000, + 'payment_method_types' => ['card'], + 'metadata' => $metadata, + 'next_action' => null, + 'last_setup_error' => $lastSetupError, + 'cancellation_reason' => $cancellationReason, + 'payment_method' => $this->paymentMethodResponse(customer: $paymentMethodCustomer), + ]; + } + + private function paymentMethodResponse(?string $customer = null, array $metadata = []): array + { + return [ + 'id' => 'pm_fake123', + 'object' => 'payment_method', + 'type' => 'card', + 'customer' => $customer, + 'created' => 1786700000, + 'billing_details' => ['name' => 'Faker Teste'], + 'metadata' => $metadata, + 'card' => ['brand' => 'visa', 'last4' => '4242', 'exp_month' => 8, 'exp_year' => 2027], + ]; + } + + private function stripeCustomerResponse(): array + { + return [ + 'id' => 'cus_fake123', + 'object' => 'customer', + 'invoice_settings' => ['default_payment_method' => 'pm_fake123'], + ]; + } +} diff --git a/tests/Unit/Gateways/StripeGatewayCustomerTest.php b/tests/Unit/Gateways/StripeGatewayCustomerTest.php new file mode 100644 index 0000000..2f0b0d3 --- /dev/null +++ b/tests/Unit/Gateways/StripeGatewayCustomerTest.php @@ -0,0 +1,409 @@ +instance('config', new Repository([ + 'multi-payment.gateways.stripe.api_key' => 'sk_test_fake', + ])); + Facade::setFacadeApplication($app); + + // fake vazio por padrão: teste que esquecer withResponses() estoura em vez de ir à rede + RecordingStripeHttpClient::withResponses([]); + } + + protected function tearDown(): void + { + // o hook de HTTP do stripe-php é estático — sem o reset, o fake vazaria para outros testes + ApiRequestor::setHttpClient(null); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testCreatesCustomerSendingGenericFieldsAsStripeData(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->stripeCustomerResponse()]); + + $customer = $this->customerModel(); + $result = (new StripeGateway())->createCustomer($customer); + + $this->assertCount(1, $httpClient->calls); + [$method, $url, $params] = $httpClient->calls[0]; + $this->assertSame('post', $method); + $this->assertSame('/v1/customers', parse_url($url, PHP_URL_PATH)); + + // payload completo: uma chave extra vazando para o request também deve falhar + $this->assertSame([ + 'name' => 'Fake Customer', + 'email' => 'email@exemplo.com', + 'phone' => '+5571982345678', + 'address' => [ + 'line1' => 'Rua Deputado Mário Lima, 123', + 'line2' => 'Apto. 123', + 'city' => 'Salvador', + 'state' => 'BA', + 'postal_code' => '41820330', + ], + 'metadata' => [ + 'birth_date' => '1980-01-01', + 'district' => 'Caminho das Árvores', + 'country' => 'Brasil', + ], + 'tax_id_data' => [['type' => 'br_cpf', 'value' => '20176996915']], + 'expand' => ['tax_ids'], + ], $params); + + $this->assertSame('cus_fake123', $result->id); + $this->assertSame('stripe', $result->gateway); + $this->assertInstanceOf(Carbon::class, $result->createdAt); + } + + public function testCreatesCustomerWithCnpjUsingBrCnpjTaxIdType(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->stripeCustomerResponse()]); + + $customer = new Customer(); + $customer->name = 'Fake Company'; + $customer->email = 'email@exemplo.com'; + $customer->taxDocument = '28585583000189'; + (new StripeGateway())->createCustomer($customer); + + [, , $params] = $httpClient->calls[0]; + $this->assertSame([['type' => 'br_cnpj', 'value' => '28585583000189']], $params['tax_id_data']); + } + + public function testParsesStripeCustomerIntoGenericModel(): void + { + RecordingStripeHttpClient::withResponses([$this->stripeCustomerResponse()]); + + $customer = new Customer(); + $customer->id = 'cus_fake123'; + $result = (new StripeGateway())->getCustomer($customer); + + $this->assertSame('cus_fake123', $result->id); + $this->assertSame('Fake Customer', $result->name); + $this->assertSame('email@exemplo.com', $result->email); + $this->assertSame('20176996915', $result->taxDocument); + $this->assertSame('55', $result->phoneCountryCode); + $this->assertSame('71', $result->phoneArea); + $this->assertSame('982345678', $result->phoneNumber); + $this->assertTrue($result->birthDate->isSameDay(Carbon::createFromFormat('Y-m-d', '1980-01-01'))); + $this->assertSame('Rua Deputado Mário Lima', $result->address->street); + $this->assertSame('123', $result->address->number); + $this->assertSame('Apto. 123', $result->address->complement); + $this->assertSame('Caminho das Árvores', $result->address->district); + $this->assertSame('Salvador', $result->address->city); + $this->assertSame('BA', $result->address->state); + $this->assertSame('41820330', $result->address->zipCode); + $this->assertSame('Brasil', $result->address->country); + $this->assertSame('stripe', $result->gateway); + $this->assertNotNull($result->original); + } + + public function testParsesCustomerWithoutAddressKeepingAddressNull(): void + { + $response = $this->stripeCustomerResponse(); + $response['address'] = null; + $response['metadata'] = ['birth_date' => '1980-01-01']; + RecordingStripeHttpClient::withResponses([$response]); + + $customer = new Customer(); + $customer->id = 'cus_fake123'; + $result = (new StripeGateway())->getCustomer($customer); + + $this->assertNull($result->address); + $this->assertTrue($result->birthDate->isSameDay(Carbon::createFromFormat('Y-m-d', '1980-01-01'))); + } + + public function testUpdateCustomerRequiresId(): void + { + RecordingStripeHttpClient::withResponses([]); + + $this->expectException(ModelAttributeValidationException::class); + + (new StripeGateway())->updateCustomer(new Customer()); + } + + public function testUpdateCustomerReplacesChangedTaxDocument(): void + { + $staleCustomer = $this->stripeCustomerResponse(); + $freshCustomer = $this->stripeCustomerResponse(); + $freshCustomer['tax_ids']['data'][0]['value'] = '68419761001'; + $httpClient = RecordingStripeHttpClient::withResponses([ + $staleCustomer, + ['id' => 'txi_fake2', 'object' => 'tax_id', 'type' => 'br_cpf', 'value' => '68419761001'], + ['id' => 'txi_fake1', 'object' => 'tax_id', 'deleted' => true], + $freshCustomer, + ]); + + $customer = new Customer(); + $customer->id = 'cus_fake123'; + $customer->taxDocument = '68419761001'; + $result = (new StripeGateway())->updateCustomer($customer); + + $paths = array_map(static fn ($call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), $httpClient->calls); + $this->assertSame([ + 'post /v1/customers/cus_fake123', + 'post /v1/customers/cus_fake123/tax_ids', + 'delete /v1/customers/cus_fake123/tax_ids/txi_fake1', + 'get /v1/customers/cus_fake123', + ], $paths); + $this->assertSame(['type' => 'br_cpf', 'value' => '68419761001'], $httpClient->calls[1][2]); + $this->assertSame('68419761001', $result->taxDocument); + } + + public function testUpdateCustomerKeepsUnchangedTaxDocumentWithoutExtraRequests(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->stripeCustomerResponse()]); + + $customer = new Customer(); + $customer->id = 'cus_fake123'; + $customer->taxDocument = '20176996915'; + (new StripeGateway())->updateCustomer($customer); + + $this->assertCount(1, $httpClient->calls); + } + + public function testSetCustomerDefaultCardSendsInvoiceSettingsAndParsesDefaultCard(): void + { + $response = $this->stripeCustomerResponse(); + $response['invoice_settings'] = ['default_payment_method' => 'pm_fake123']; + $httpClient = RecordingStripeHttpClient::withResponses([$response]); + + $customer = new Customer(); + $customer->id = 'cus_fake123'; + $result = (new StripeGateway())->setCustomerDefaultCard($customer, 'pm_fake123'); + + [, , $params] = $httpClient->calls[0]; + $this->assertSame('pm_fake123', $params['invoice_settings']['default_payment_method']); + $this->assertSame('pm_fake123', $result->defaultCard->id); + } + + public function testGatewayOptionsReachThePayloadAndExpandIsMerged(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->stripeCustomerResponse()]); + + $customer = new Customer(); + $customer->name = 'Fake Customer'; + $customer->taxDocument = '20176996915'; + $customer->gatewayOptions = [ + 'preferred_locales' => ['pt-BR'], + 'expand' => ['subscriptions'], + ]; + (new StripeGateway())->createCustomer($customer); + + [, , $params] = $httpClient->calls[0]; + $this->assertSame(['pt-BR'], $params['preferred_locales']); + // o expand do usuário não pode descartar o tax_ids exigido pelo parse/sync + $this->assertSame(['subscriptions', 'tax_ids'], $params['expand']); + } + + public function testCustomerWithExplicitPhoneCountryCodeIsConcatenated(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->stripeCustomerResponse()]); + + $customer = new Customer(); + $customer->phoneCountryCode = '01'; + $customer->phoneArea = '71'; + $customer->phoneNumber = '982345678'; + (new StripeGateway())->createCustomer($customer); + + [, , $params] = $httpClient->calls[0]; + $this->assertSame('+0171982345678', $params['phone']); + } + + public function testCustomerWithoutTaxDocumentOmitsTaxIdData(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->stripeCustomerResponse()]); + + $customer = new Customer(); + $customer->name = 'Fake Customer'; + (new StripeGateway())->createCustomer($customer); + + [, , $params] = $httpClient->calls[0]; + $this->assertArrayNotHasKey('tax_id_data', $params); + } + + public function testAddressWithoutNumberUsesSNPlaceholderAndClearsAbsentMetadata(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->stripeCustomerResponse()]); + + $customer = new Customer(); + $customer->fill(['address' => ['street' => 'Rua Deputado Mário Lima', 'zip_code' => '41820330']]); + (new StripeGateway())->createCustomer($customer); + + [, , $params] = $httpClient->calls[0]; + $this->assertSame('Rua Deputado Mário Lima, S/N', $params['address']['line1']); + // bairro/país ausentes limpam as chaves no metadata (que faz merge por chave na Stripe) + $this->assertSame(['district' => '', 'country' => ''], $params['metadata']); + } + + public function testParsesNumberOnlyLine1IntoAddressNumber(): void + { + $response = $this->stripeCustomerResponse(); + $response['address']['line1'] = '123'; + RecordingStripeHttpClient::withResponses([$response]); + + $customer = new Customer(); + $customer->id = 'cus_fake123'; + $result = (new StripeGateway())->getCustomer($customer); + + $this->assertNull($result->address->street); + $this->assertSame('123', $result->address->number); + } + + public function testManagedByGatewayOperationThrowsUnsupportedOperationExceptionWithoutHittingTheApi(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + + try { + (new StripeGateway())->rescheduleAutomaticPixPayment(new Invoice()); + $this->fail('Reagendamento de Pix Automático no Stripe deveria lançar UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::AUTOMATIC_PIX, $e->capability); + $this->assertSame('stripe', $e->gateway); + $this->assertSame(UnsupportedOperationException::REASON_MANAGED_BY_GATEWAY, $e->reason); + $this->assertFalse($e->isNotImplemented()); + $this->assertSame( + 'No gateway stripe a operação de [automatic_pix] é conduzida pelo próprio gateway' + . ' e não se aplica pela lib.' + . ' A Stripe agenda e retenta as cobranças do mandato; não há reagendamento pela lib.', + $e->getMessage() + ); + } + $this->assertSame([], $httpClient->calls); + } + + public function testAuthenticationErrorBecomesAuthenticationExceptionNotGatewayNotAvailable(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => ['type' => 'invalid_request_error', 'message' => 'Invalid API Key provided']], 401], + ]); + + $customer = new Customer(); + $customer->id = 'cus_fake123'; + + try { + (new StripeGateway())->getCustomer($customer); + $this->fail('Esperava AuthenticationException'); + } catch (AuthenticationException $e) { + $this->assertNotInstanceOf(GatewayNotAvailableException::class, $e); + $this->assertSame(401, $e->httpStatus); + } + } + + public function testApiErrorBecomesGatewayExceptionWithNormalizedErrors(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'invalid_request_error', + 'code' => 'parameter_unknown', + 'param' => 'foo', + 'message' => 'Received unknown parameter: foo', + ]], 400], + ]); + + $customer = new Customer(); + $customer->id = 'cus_fake123'; + + try { + (new StripeGateway())->getCustomer($customer); + $this->fail('Expected ValidationException was not thrown'); + } catch (ValidationException $exception) { + $this->assertInstanceOf(GatewayException::class, $exception); + $this->assertSame(['foo' => ['Received unknown parameter: foo']], $exception->fieldErrors); + $this->assertSame([ + 'type' => 'invalid_request_error', + 'code' => 'parameter_unknown', + 'param' => 'foo', + ], $exception->getErrors()); + } + } + + public function testToStringReturnsGatewayName(): void + { + $this->assertSame('stripe', (string) new StripeGateway()); + } + + private function customerModel(): Customer + { + $customer = new Customer(); + $customer->fill([ + 'name' => 'Fake Customer', + 'email' => 'email@exemplo.com', + 'tax_document' => '20176996915', + 'phone_area' => '71', + 'phone_number' => '982345678', + 'address' => [ + 'zip_code' => '41820330', + 'street' => 'Rua Deputado Mário Lima', + 'number' => '123', + 'district' => 'Caminho das Árvores', + 'complement' => 'Apto. 123', + 'city' => 'Salvador', + 'state' => 'BA', + 'country' => 'Brasil', + ], + ]); + $customer->birthDate = Carbon::createFromFormat('Y-m-d', '1980-01-01'); + + return $customer; + } + + private function stripeCustomerResponse(): array + { + return [ + 'id' => 'cus_fake123', + 'object' => 'customer', + 'name' => 'Fake Customer', + 'email' => 'email@exemplo.com', + 'phone' => '+5571982345678', + 'created' => 1786700000, + 'metadata' => [ + 'birth_date' => '1980-01-01', + 'district' => 'Caminho das Árvores', + 'country' => 'Brasil', + ], + 'address' => [ + 'line1' => 'Rua Deputado Mário Lima, 123', + 'line2' => 'Apto. 123', + 'city' => 'Salvador', + 'state' => 'BA', + 'postal_code' => '41820330', + 'country' => null, + ], + 'invoice_settings' => ['default_payment_method' => null], + 'tax_ids' => [ + 'object' => 'list', + 'data' => [ + ['id' => 'txi_fake1', 'object' => 'tax_id', 'type' => 'br_cpf', 'value' => '20176996915'], + ], + ], + ]; + } +} diff --git a/tests/Unit/Gateways/StripeGatewayExceptionTranslationTest.php b/tests/Unit/Gateways/StripeGatewayExceptionTranslationTest.php new file mode 100644 index 0000000..22b30f4 --- /dev/null +++ b/tests/Unit/Gateways/StripeGatewayExceptionTranslationTest.php @@ -0,0 +1,620 @@ +logger = new RecordingLogger(); + $app = new Container(); + $app->instance('config', new Repository([ + 'multi-payment.gateways.stripe.api_key' => 'sk_test_fake', + ])); + $app->instance('log', $this->logger); + Facade::setFacadeApplication($app); + + RecordingStripeHttpClient::withResponses([]); + } + + protected function tearDown(): void + { + ApiRequestor::setHttpClient(null); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testInvalidApiKeyBecomesAuthenticationExceptionWithPrevious(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => ['type' => 'invalid_request_error', 'message' => 'Invalid API Key provided']], 401], + ]); + + try { + (new StripeGateway())->getCustomer($this->customerWithId()); + $this->fail('Esperava AuthenticationException'); + } catch (AuthenticationException $e) { + $this->assertNotInstanceOf(GatewayNotAvailableException::class, $e); + $this->assertInstanceOf(StripeAuthenticationException::class, $e->getPrevious()); + $this->assertSame(401, $e->httpStatus); + $this->assertStringContainsString('stripe', $e->getMessage()); + $this->assertStringContainsString('Invalid API Key provided', $e->getMessage()); + } + } + + public function testKeyWithoutPermissionBecomesAuthenticationException(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => ['type' => 'invalid_request_error', 'message' => 'This API key does not have permission']], 403], + ]); + + try { + (new StripeGateway())->getCustomer($this->customerWithId()); + $this->fail('Esperava AuthenticationException'); + } catch (AuthenticationException $e) { + $this->assertInstanceOf(PermissionException::class, $e->getPrevious()); + $this->assertSame(403, $e->httpStatus); + } + } + + #[DataProvider('serverErrorStatusProvider')] + public function testServerErrorsBecomeGatewayNotAvailableExceptionWithStatus(int $status): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => ['type' => 'api_error', 'message' => 'Something went wrong']], $status], + ]); + + try { + (new StripeGateway())->getCustomer($this->customerWithId()); + $this->fail('Esperava GatewayNotAvailableException'); + } catch (GatewayNotAvailableException $e) { + $this->assertInstanceOf(UnknownApiErrorException::class, $e->getPrevious()); + $this->assertSame($status, $e->httpStatus); + } + } + + public static function serverErrorStatusProvider(): array + { + return ['500' => [500], '502' => [502], '503' => [503]]; + } + + public function testServerErrorWithHtmlBodyBecomesGatewayNotAvailableExceptionWithStatus(): void + { + // o stripe-php não consegue decodificar a página do proxy e lança + // UnexpectedValueException com o status em getCode() + RecordingStripeHttpClient::withResponses([['502 Bad Gateway', 502]]); + + try { + (new StripeGateway())->getCustomer($this->customerWithId()); + $this->fail('Esperava GatewayNotAvailableException'); + } catch (GatewayNotAvailableException $e) { + $this->assertInstanceOf(StripeUnexpectedValueException::class, $e->getPrevious()); + $this->assertSame(502, $e->httpStatus); + } + } + + public function testClientErrorWithHtmlBodyStaysGatewayExceptionWithStatus(): void + { + // página HTML de proxy: sem corpo JSON não há `code` para ler, então só o 5xx é classificado + RecordingStripeHttpClient::withResponses([['404 Not Found', 404]]); + + try { + (new StripeGateway())->getCustomer($this->customerWithId()); + $this->fail('Esperava GatewayException'); + } catch (GatewayException $e) { + $this->assertNotInstanceOf(GatewayNotAvailableException::class, $e); + $this->assertSame(404, $e->httpStatus); + } + } + + public function testConnectionFailureBecomesGatewayNotAvailableExceptionWithoutStatus(): void + { + $original = new ApiConnectionException('Could not connect to Stripe (https://api.stripe.com)'); + RecordingStripeHttpClient::withResponses([$original]); + + try { + (new StripeGateway())->getCustomer($this->customerWithId()); + $this->fail('Esperava GatewayNotAvailableException'); + } catch (GatewayNotAvailableException $e) { + $this->assertSame($original, $e->getPrevious()); + $this->assertNull($e->httpStatus); + } + } + + public function testRateLimitBecomesRateLimitExceptionWithRetryAfterFromTheHeader(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => ['type' => 'rate_limit_error', 'message' => 'Too many requests']], 429, ['Retry-After' => '3']], + ]); + + try { + (new StripeGateway())->getCustomer($this->customerWithId()); + $this->fail('Esperava RateLimitException'); + } catch (RateLimitException $e) { + $this->assertInstanceOf(GatewayException::class, $e); + $this->assertNotInstanceOf(GatewayNotAvailableException::class, $e); + $this->assertInstanceOf(StripeRateLimitException::class, $e->getPrevious()); + $this->assertSame(429, $e->httpStatus); + $this->assertSame(3, $e->retryAfter); + $this->assertSame('rate_limit_error', $e->getErrors()['type']); + } + } + + public function testRetryAfterIsReadFromTheCaseInsensitiveHeadersOfTheSdk(): void + { + // o CurlClient do stripe-php entrega os cabeçalhos neste objeto, com as chaves em minúsculas + RecordingStripeHttpClient::withResponses([ + [['error' => ['type' => 'rate_limit_error', 'message' => 'Too many requests']], 429, + new CaseInsensitiveArray(['Retry-After' => '3'])], + ]); + + try { + (new StripeGateway())->getCustomer($this->customerWithId()); + $this->fail('Esperava RateLimitException'); + } catch (RateLimitException $e) { + $this->assertSame(3, $e->retryAfter); + } + } + + #[DataProvider('retryAfterHeaderProvider')] + public function testRetryAfterHeaderVariants(array $headers, ?int $expected): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => ['type' => 'rate_limit_error', 'message' => 'Too many requests']], 429, $headers], + ]); + + try { + (new StripeGateway())->getCustomer($this->customerWithId()); + $this->fail('Esperava RateLimitException'); + } catch (RateLimitException $e) { + $this->assertSame($expected, $e->retryAfter); + } + } + + public static function retryAfterHeaderProvider(): array + { + return [ + 'minúsculas (HTTP/2)' => [['retry-after' => '10'], 10], + 'valor em lista' => [['Retry-After' => ['5']], 5], + 'data HTTP' => [['Retry-After' => 'Wed, 21 Oct 2026 07:28:00 GMT'], null], + 'vazio' => [['Retry-After' => ''], null], + ]; + } + + public function testRateLimitWithoutRetryAfterHeaderLeavesItNull(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => ['type' => 'rate_limit_error', 'message' => 'Too many requests']], 429], + ]); + + try { + (new StripeGateway())->getCustomer($this->customerWithId()); + $this->fail('Esperava RateLimitException'); + } catch (RateLimitException $e) { + $this->assertNull($e->retryAfter); + } + } + + public function testIdempotencyErrorBecomesIdempotencyConflictException(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'idempotency_error', + 'message' => 'Keys for idempotent requests can only be used with the same parameters they were first used with.', + ]], 400], + ]); + + try { + (new StripeGateway())->createCustomer($this->customerModel()); + $this->fail('Esperava IdempotencyConflictException'); + } catch (IdempotencyConflictException $e) { + $this->assertInstanceOf(GatewayException::class, $e); + $this->assertNotInstanceOf(ValidationException::class, $e); + $this->assertInstanceOf(IdempotencyException::class, $e->getPrevious()); + $this->assertSame(400, $e->httpStatus); + $this->assertSame('idempotency_error', $e->getErrors()['type']); + } + } + + public function testResourceMissingBecomesNotFoundExceptionWithNormalizedErrorsAndPrevious(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'invalid_request_error', + 'code' => 'resource_missing', + 'param' => 'customer', + 'message' => 'No such customer', + ]], 404], + ]); + + try { + (new StripeGateway())->getCustomer($this->customerWithId()); + $this->fail('Esperava NotFoundException'); + } catch (NotFoundException $e) { + $this->assertInstanceOf(GatewayException::class, $e); + $this->assertNotInstanceOf(ValidationException::class, $e); + $this->assertInstanceOf(InvalidRequestException::class, $e->getPrevious()); + $this->assertSame(404, $e->httpStatus); + $this->assertSame([ + 'type' => 'invalid_request_error', + 'code' => 'resource_missing', + 'param' => 'customer', + ], $e->getErrors()); + } + } + + public function testInvalidRequestWith404StatusIsNotFoundEvenWithoutResourceMissingCode(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => ['type' => 'invalid_request_error', 'message' => 'No such customer']], 404], + ]); + + try { + (new StripeGateway())->getCustomer($this->customerWithId()); + $this->fail('Esperava NotFoundException'); + } catch (NotFoundException $e) { + $this->assertSame(404, $e->httpStatus); + } + } + + public function testInvalidRequestBecomesValidationExceptionWithTheParamAsField(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'invalid_request_error', + 'code' => 'parameter_invalid_integer', + 'param' => 'amount', + 'message' => 'Invalid integer: abc', + ]], 400], + ]); + + try { + (new StripeGateway())->createInvoice($this->creditCardInvoiceModel()); + $this->fail('Esperava ValidationException'); + } catch (ValidationException $e) { + $this->assertInstanceOf(GatewayException::class, $e); + $this->assertInstanceOf(InvalidRequestException::class, $e->getPrevious()); + $this->assertSame(400, $e->httpStatus); + $this->assertSame(['amount' => ['Invalid integer: abc']], $e->fieldErrors); + $this->assertSame('parameter_invalid_integer', $e->getErrors()['code']); + } + } + + public function testInvalidRequestWithoutParamGoesToTheBaseField(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'invalid_request_error', + 'code' => 'payment_intent_unexpected_state', + 'message' => 'This PaymentIntent could not be canceled.', + ]], 400], + ]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + + try { + (new StripeGateway())->cancelInvoice($invoice); + $this->fail('Esperava ValidationException'); + } catch (ValidationException $e) { + $this->assertSame(['base' => ['This PaymentIntent could not be canceled.']], $e->fieldErrors); + } + } + + public function testCardDeclineAttachesTheCardExceptionAndItsStatus(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'card_error', + 'code' => 'card_declined', + 'decline_code' => 'generic_decline', + 'message' => 'Your card was declined.', + ]], 402], + ]); + + try { + (new StripeGateway())->createInvoice($this->creditCardInvoiceModel()); + $this->fail('Esperava ChargingException'); + } catch (ChargingException $e) { + $this->assertInstanceOf(CardDeclinedException::class, $e); + $this->assertNotInstanceOf(GatewayException::class, $e); + $this->assertInstanceOf(CardException::class, $e->getPrevious()); + $this->assertSame(402, $e->httpStatus); + $this->assertSame('card_declined', $e->reason); + $this->assertSame(DeclineCode::GENERIC, $e->declineCode); + $this->assertSame('generic_decline', $e->gatewayCode); + $this->assertFalse($e->retryable); + $this->assertSame('card_error', $e->chargeResponse['type']); + $this->assertStringContainsString('stripe', $e->getMessage()); + $this->assertStringContainsString('Your card was declined.', $e->getMessage()); + } + } + + public function testCardDeclineIsCaughtByTheNewName(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => ['type' => 'card_error', 'code' => 'expired_card', 'message' => 'Your card has expired.']], 402], + ]); + + try { + (new StripeGateway())->createInvoice($this->creditCardInvoiceModel()); + $this->fail('Esperava CardDeclinedException'); + } catch (CardDeclinedException $e) { + // sem decline_code o driver lê o code + $this->assertSame(DeclineCode::EXPIRED_CARD, $e->declineCode); + $this->assertSame('expired_card', $e->gatewayCode); + $this->assertSame('expired_card', $e->reason); + } + } + + #[DataProvider('declineCodeProvider')] + public function testDeclineCodeIsNormalizedAndRetryableFollowsTheCode(string $stripeDeclineCode, DeclineCode $expected, bool $retryable): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'card_error', + 'code' => 'card_declined', + 'decline_code' => $stripeDeclineCode, + 'message' => 'Your card was declined.', + ]], 402], + ]); + + try { + (new StripeGateway())->createInvoice($this->creditCardInvoiceModel()); + $this->fail('Esperava ChargingException'); + } catch (ChargingException $e) { + $this->assertSame($expected, $e->declineCode); + $this->assertSame($stripeDeclineCode, $e->gatewayCode); + $this->assertSame($retryable, $e->retryable); + } + + $this->assertSame([], $this->logger->records); + } + + public static function declineCodeProvider(): array + { + return [ + 'insufficient_funds' => ['insufficient_funds', DeclineCode::INSUFFICIENT_FUNDS, true], + 'card_velocity_exceeded' => ['card_velocity_exceeded', DeclineCode::INSUFFICIENT_FUNDS, true], + 'expired_card' => ['expired_card', DeclineCode::EXPIRED_CARD, false], + 'incorrect_cvc' => ['incorrect_cvc', DeclineCode::INCORRECT_CVC, false], + 'incorrect_number' => ['incorrect_number', DeclineCode::INCORRECT_NUMBER, false], + 'invalid_expiry_year' => ['invalid_expiry_year', DeclineCode::INVALID_CARD, false], + 'lost_card' => ['lost_card', DeclineCode::LOST_OR_STOLEN, false], + 'stolen_card' => ['stolen_card', DeclineCode::LOST_OR_STOLEN, false], + 'fraudulent' => ['fraudulent', DeclineCode::FRAUD_SUSPECTED, false], + 'authentication_required' => ['authentication_required', DeclineCode::AUTHENTICATION_REQUIRED, false], + 'card_not_supported' => ['card_not_supported', DeclineCode::BRAND_NOT_SUPPORTED, false], + 'do_not_honor' => ['do_not_honor', DeclineCode::DO_NOT_HONOR, false], + 'call_issuer' => ['call_issuer', DeclineCode::DO_NOT_HONOR, false], + 'processing_error' => ['processing_error', DeclineCode::TRY_AGAIN, true], + 'try_again_later' => ['try_again_later', DeclineCode::TRY_AGAIN, true], + 'generic_decline' => ['generic_decline', DeclineCode::GENERIC, false], + ]; + } + + public function testAdviceCodeOverridesTheRetryableDefault(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'card_error', + 'code' => 'card_declined', + 'decline_code' => 'generic_decline', + 'advice_code' => 'try_again_later', + 'message' => 'Your card was declined.', + ]], 402], + [['error' => [ + 'type' => 'card_error', + 'code' => 'card_declined', + 'decline_code' => 'insufficient_funds', + 'advice_code' => 'do_not_try_again', + 'message' => 'Your card has insufficient funds.', + ]], 402], + ]); + + try { + (new StripeGateway())->createInvoice($this->creditCardInvoiceModel()); + $this->fail('Esperava ChargingException'); + } catch (ChargingException $e) { + $this->assertSame(DeclineCode::GENERIC, $e->declineCode); + $this->assertTrue($e->retryable); + } + + try { + (new StripeGateway())->createInvoice($this->creditCardInvoiceModel()); + $this->fail('Esperava ChargingException'); + } catch (ChargingException $e) { + $this->assertSame(DeclineCode::INSUFFICIENT_FUNDS, $e->declineCode); + $this->assertFalse($e->retryable); + } + + $this->assertSame([], $this->logger->records); + } + + public function testCardErrorWithoutAnyCodeBecomesUnknownWithoutCodeAndWithoutLog(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => ['type' => 'card_error', 'message' => 'Your card was declined.']], 402], + ]); + + try { + (new StripeGateway())->createInvoice($this->creditCardInvoiceModel()); + $this->fail('Esperava ChargingException'); + } catch (ChargingException $e) { + $this->assertSame(DeclineCode::UNKNOWN, $e->declineCode); + $this->assertNull($e->gatewayCode); + $this->assertFalse($e->retryable); + $this->assertNull($e->reason); + } + + $this->assertSame([], $this->logger->records); + } + + public function testUnmappedDeclineCodeBecomesUnknownAndIsLogged(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'card_error', + 'code' => 'card_declined', + 'decline_code' => 'offline_pin_required', + 'message' => 'Your card was declined.', + ]], 402], + ]); + + try { + (new StripeGateway())->createInvoice($this->creditCardInvoiceModel()); + $this->fail('Esperava ChargingException'); + } catch (ChargingException $e) { + $this->assertSame(DeclineCode::UNKNOWN, $e->declineCode); + $this->assertSame('offline_pin_required', $e->gatewayCode); + $this->assertFalse($e->retryable); + $this->assertSame('card_declined', $e->reason); + } + + $this->assertCount(1, $this->logger->records); + $this->assertSame('info', $this->logger->records[0]['level']); + $this->assertSame(['gateway' => 'stripe', 'code' => 'offline_pin_required'], $this->logger->records[0]['context']); + } + + public function testCardDeclineDuringAttachCarriesTheCardExceptionDirectly(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'card_error', + 'code' => 'card_declined', + 'decline_code' => 'generic_decline', + 'message' => 'Your card was declined.', + ]], 402], + ]); + + $invoice = $this->creditCardInvoiceModel(); + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->token = 'pm_fake123'; + + try { + (new StripeGateway())->createInvoice($invoice); + $this->fail('Esperava ChargingException'); + } catch (ChargingException $e) { + $this->assertSame(402, $e->httpStatus); + $this->assertInstanceOf(CardException::class, $e->getPrevious()); + $this->assertSame(DeclineCode::GENERIC, $e->declineCode); + } + } + + public function testCardDeclineWhenOnlySavingTheCardIsAlsoACardDecline(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'card_error', + 'code' => 'card_declined', + 'decline_code' => 'stolen_card', + 'message' => 'Your card was declined.', + ]], 402], + ]); + + $creditCard = new CreditCard(); + $creditCard->token = 'pm_fake123'; + $creditCard->customer = $this->customerWithId(); + + try { + (new StripeGateway())->createCreditCard($creditCard); + $this->fail('Esperava CardDeclinedException'); + } catch (CardDeclinedException $e) { + $this->assertSame(DeclineCode::LOST_OR_STOLEN, $e->declineCode); + $this->assertSame('stolen_card', $e->gatewayCode); + } + } + + public function testUnexpectedExceptionInsideTheSdkBecomesGatewayExceptionWithPrevious(): void + { + $original = new \RuntimeException('falha inesperada'); + RecordingStripeHttpClient::withResponses([$original]); + + try { + (new StripeGateway())->getCustomer($this->customerWithId()); + $this->fail('Esperava GatewayException'); + } catch (GatewayException $e) { + $this->assertSame($original, $e->getPrevious()); + $this->assertNull($e->httpStatus); + } + } + + private function customerWithId(): Customer + { + $customer = new Customer(); + $customer->id = 'cus_fake123'; + + return $customer; + } + + private function customerModel(): Customer + { + $customer = new Customer(); + $customer->name = 'Cliente'; + $customer->email = 'cliente@example.com'; + + return $customer; + } + + private function creditCardInvoiceModel(): Invoice + { + $invoice = new Invoice(); + $invoice->customer = new Customer(); + $invoice->customer->id = 'cus_fake123'; + $invoice->availablePaymentMethods = [PaymentMethod::CREDIT_CARD]; + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_fake123'; + $item = new InvoiceItem(); + $item->description = 'Assinatura mensal'; + $item->price = 12345; + $item->quantity = 1; + $invoice->items = [$item]; + + return $invoice; + } +} diff --git a/tests/Unit/Gateways/StripeGatewayIdempotencyTest.php b/tests/Unit/Gateways/StripeGatewayIdempotencyTest.php new file mode 100644 index 0000000..d13db03 --- /dev/null +++ b/tests/Unit/Gateways/StripeGatewayIdempotencyTest.php @@ -0,0 +1,972 @@ +instance('config', new Repository([ + 'multi-payment.gateways.stripe.api_key' => 'sk_test_fake', + ])); + Facade::setFacadeApplication($app); + Carbon::setTestNow('2026-09-02 12:00:00'); + + RecordingStripeHttpClient::withResponses([]); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + ApiRequestor::setHttpClient(null); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + /** + * @param array $expectedHeaders `método caminho` na ordem das chamadas, com o cabeçalho + * esperado (nulo em leitura); como mapa ou, quando um + * caminho se repete, como lista de pares + */ + #[DataProvider('operationProvider')] + public function testEveryWriteOfTheOperationCarriesTheKeyOrADerivedOne(\Closure $operation, array $responses, array $expectedHeaders): void + { + $httpClient = RecordingStripeHttpClient::withResponses($responses); + + $operation(new StripeGateway(), 'chave-1'); + + $actual = []; + foreach ($httpClient->calls as $index => [$method, $url, $params]) { + $actual[] = [$method . ' ' . parse_url($url, PHP_URL_PATH), $httpClient->header($index, 'Idempotency-Key')]; + $this->assertArrayNotHasKey('idempotency_key', $params); + } + $expected = array_is_list($expectedHeaders) + ? $expectedHeaders + : array_map(null, array_keys($expectedHeaders), array_values($expectedHeaders)); + $this->assertSame($expected, $actual); + + $keysSent = array_filter(array_column($actual, 1)); + $this->assertSame($keysSent, array_unique($keysSent), 'duas escritas da mesma operação não podem repetir a chave'); + } + + /** + * Fatura já estornada com a mesma chave: a guarda cede e a Stripe repete o refund original. + */ + public function testRefundRetryOnARefundedInvoiceReplaysTheOriginalRefund(): void + { + $refunded = self::paidCardPaymentIntentResponse(); + $refunded['latest_charge']['amount_refunded'] = 12345; + $refunded['latest_charge']['refunded'] = true; + $httpClient = RecordingStripeHttpClient::withResponses([ + $refunded, + ['id' => 're_original', 'object' => 'refund', 'amount' => 12345, 'status' => 'succeeded', 'created' => 1786700100, 'reason' => null], + $refunded, + ]); + + $refund = (new StripeGateway())->refundInvoice(self::invoiceWithId(), null, 'chave-1'); + + $this->assertSame('re_original', $refund->id); + $this->assertSame('chave-1', $httpClient->header(1, 'Idempotency-Key')); + } + + /** + * Sem chave a guarda continua recusando antes da rede. + */ + public function testRefundOnARefundedInvoiceWithoutAKeyIsRefusedBeforeTheNetwork(): void + { + $refunded = self::paidCardPaymentIntentResponse(); + $refunded['latest_charge']['amount_refunded'] = 12345; + $refunded['latest_charge']['refunded'] = true; + $httpClient = RecordingStripeHttpClient::withResponses([$refunded]); + + try { + (new StripeGateway())->refundInvoice(self::invoiceWithId()); + $this->fail('Esperava RefundNotSupportedException'); + } catch (RefundNotSupportedException $e) { + $this->assertSame(RefundNotSupportedException::REASON_ALREADY_REFUNDED, $e->reason); + } + + $this->assertCount(1, $httpClient->calls); + } + + /** + * Com chave e a Stripe recusando o refund (a chave não é a de um estorno anterior), a + * recusa da guarda é a que sobe. + */ + public function testRefundRetryRefusedByStripeSurfacesTheGuardRefusal(): void + { + $refunded = self::paidCardPaymentIntentResponse(); + $refunded['latest_charge']['amount_refunded'] = 12345; + $refunded['latest_charge']['refunded'] = true; + $httpClient = RecordingStripeHttpClient::withResponses([ + $refunded, + [['error' => ['type' => 'invalid_request_error', 'code' => 'charge_already_refunded', 'message' => 'Charge has already been refunded.']], 400], + ]); + + try { + (new StripeGateway())->refundInvoice(self::invoiceWithId(), null, 'outra-chave'); + $this->fail('Esperava RefundNotSupportedException'); + } catch (RefundNotSupportedException $e) { + $this->assertSame(RefundNotSupportedException::REASON_ALREADY_REFUNDED, $e->reason); + } + + $this->assertCount(2, $httpClient->calls); + } + + public function testDuplicateRetryAcceptsTheAlreadyCanceledOriginalWhenAKeyIsGiven(): void + { + $pendingPix = self::pendingPixPaymentIntentResponse(); + $canceled = array_merge($pendingPix, ['status' => 'canceled', 'next_action' => null]); + $httpClient = RecordingStripeHttpClient::withResponses([ + $canceled, + self::stripeCustomerResponse(), + array_merge($pendingPix, ['id' => 'pi_fake456']), + $canceled, + ]); + + $duplicated = (new StripeGateway())->duplicateInvoice(self::invoiceWithId(), Carbon::now()->addDay(), [], 'chave-1'); + + $this->assertSame('pi_fake456', $duplicated->id); + $this->assertSame('chave-1', $httpClient->header(2, 'Idempotency-Key')); + $this->assertSame('chave-1:cancel_original', $httpClient->header(3, 'Idempotency-Key')); + } + + public function testDuplicateOfACanceledInvoiceWithoutAKeyIsStillRefused(): void + { + $pendingPix = self::pendingPixPaymentIntentResponse(); + RecordingStripeHttpClient::withResponses([array_merge($pendingPix, ['status' => 'canceled', 'next_action' => null])]); + + $this->expectException(UnsupportedOperationException::class); + + (new StripeGateway())->duplicateInvoice(self::invoiceWithId(), Carbon::now()->addDay()); + } + + public function testDeleteRetryOnADetachedCardSkipsTheOwnershipCheckWhenAKeyIsGiven(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::paymentMethodResponse(null), + self::paymentMethodResponse(null), + ]); + $creditCard = self::creditCardModel(); + $creditCard->id = 'pm_fake123'; + + (new StripeGateway())->deleteCreditCard($creditCard, 'chave-1'); + + $this->assertSame('post', $httpClient->calls[1][0]); + $this->assertSame('chave-1', $httpClient->header(1, 'Idempotency-Key')); + } + + public function testDeleteOfADetachedCardWithoutAKeyIsRefused(): void + { + RecordingStripeHttpClient::withResponses([self::paymentMethodResponse(null)]); + $creditCard = self::creditCardModel(); + $creditCard->id = 'pm_fake123'; + + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessageMatches('/does not belong/'); + + (new StripeGateway())->deleteCreditCard($creditCard); + } + + /** + * No retry do update de cliente a Stripe repete a resposta antiga (com o tax id já + * trocado), e o `deleteTaxId` do id já excluído responde 404, que é ignorado. + */ + public function testUpdateCustomerRetryIgnoresTheTaxIdAlreadyDeleted(): void + { + $freshCustomer = self::stripeCustomerResponse(); + $freshCustomer['tax_ids']['data'][0]['value'] = '68419761001'; + $httpClient = RecordingStripeHttpClient::withResponses([ + self::stripeCustomerResponse(), + ['id' => 'txi_fake2', 'object' => 'tax_id', 'type' => 'br_cpf', 'value' => '68419761001'], + [['error' => ['type' => 'invalid_request_error', 'code' => 'resource_missing', 'param' => 'id', 'message' => 'No such tax id']], 404], + $freshCustomer, + ]); + $customer = new Customer(); + $customer->id = 'cus_fake123'; + $customer->taxDocument = '68419761001'; + + $result = (new StripeGateway())->updateCustomer($customer, 'chave-1'); + + $this->assertSame('68419761001', $result->taxDocument); + $this->assertCount(4, $httpClient->calls); + } + + public function testChargeUpdateAlwaysSendsTheCardCustomerSoTheRetryPayloadIsTheSame(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::paymentMethodResponse('cus_fake123'), + self::paidCardPaymentIntentResponse('requires_payment_method'), + self::paidCardPaymentIntentResponse('requires_payment_method'), + self::paidCardPaymentIntentResponse(), + ]); + $invoice = self::invoiceWithId(); + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_fake123'; + + (new StripeGateway())->chargeInvoiceWithCreditCard($invoice, 'chave-1'); + + $this->assertSame(['payment_method_types' => ['card'], 'customer' => 'cus_fake123'], $httpClient->calls[2][2]); + } + + #[DataProvider('operationProvider')] + public function testWithoutAKeyNoHeaderIsSent(\Closure $operation, array $responses, array $expectedHeaders): void + { + $httpClient = RecordingStripeHttpClient::withResponses($responses); + + $operation(new StripeGateway(), null); + + foreach (array_keys($httpClient->calls) as $index) { + $this->assertNull($httpClient->header($index, 'Idempotency-Key')); + } + } + + public static function operationProvider(): array + { + $freshCustomer = self::stripeCustomerResponse(); + $freshCustomer['tax_ids']['data'][0]['value'] = '68419761001'; + + $pendingPix = self::pendingPixPaymentIntentResponse(); + $refunded = self::paidCardPaymentIntentResponse(); + $refunded['latest_charge']['amount_refunded'] = 12345; + $refunded['latest_charge']['refunded'] = true; + + return [ + 'createCustomer' => [ + fn (StripeGateway $g, ?string $key) => $g->createCustomer(self::customerModel(), $key), + [self::stripeCustomerResponse()], + ['post /v1/customers' => 'chave-1'], + ], + 'updateCustomer trocando o documento' => [ + function (StripeGateway $g, ?string $key) { + $customer = new Customer(); + $customer->id = 'cus_fake123'; + $customer->taxDocument = '68419761001'; + + return $g->updateCustomer($customer, $key); + }, + [ + self::stripeCustomerResponse(), + ['id' => 'txi_fake2', 'object' => 'tax_id', 'type' => 'br_cpf', 'value' => '68419761001'], + ['id' => 'txi_fake1', 'object' => 'tax_id', 'deleted' => true], + $freshCustomer, + ], + [ + 'post /v1/customers/cus_fake123' => 'chave-1', + 'post /v1/customers/cus_fake123/tax_ids' => 'chave-1:tax_id', + 'delete /v1/customers/cus_fake123/tax_ids/txi_fake1' => null, + 'get /v1/customers/cus_fake123' => null, + ], + ], + 'setCustomerDefaultCard' => [ + function (StripeGateway $g, ?string $key) { + $customer = new Customer(); + $customer->id = 'cus_fake123'; + + return $g->setCustomerDefaultCard($customer, 'pm_fake123', $key); + }, + [self::stripeCustomerResponse()], + ['post /v1/customers/cus_fake123' => 'chave-1'], + ], + 'createInvoice com cartão salvo' => [ + fn (StripeGateway $g, ?string $key) => $g->createInvoice(self::creditCardInvoiceModel(), $key), + [self::paidCardPaymentIntentResponse()], + ['post /v1/payment_intents' => 'chave-1'], + ], + 'createInvoice salvando o cartão antes' => [ + function (StripeGateway $g, ?string $key) { + $invoice = self::creditCardInvoiceModel(); + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->token = 'pm_fake123'; + + return $g->createInvoice($invoice, $key); + }, + [self::setupIntentResponse(), self::paidCardPaymentIntentResponse()], + [ + 'post /v1/setup_intents' => 'chave-1:card', + 'post /v1/payment_intents' => 'chave-1', + ], + ], + 'createInvoice pix' => [ + fn (StripeGateway $g, ?string $key) => $g->createInvoice(self::pixInvoiceModel(), $key), + [$pendingPix], + ['post /v1/payment_intents' => 'chave-1'], + ], + 'createInvoice boleto' => [ + fn (StripeGateway $g, ?string $key) => $g->createInvoice(self::bankSlipInvoiceModel(), $key), + [self::fixture('payment_intents/boleto_requires_action')], + ['post /v1/payment_intents' => 'chave-1'], + ], + 'refundInvoice' => [ + fn (StripeGateway $g, ?string $key) => $g->refundInvoice(self::invoiceWithId(), null, $key), + [ + self::paidCardPaymentIntentResponse(), + ['id' => 're_fake123', 'object' => 'refund', 'amount' => 12345, 'status' => 'pending', 'created' => 1786700100, 'reason' => null], + $refunded, + ], + [ + ['get /v1/payment_intents/pi_fake123', null], + ['post /v1/refunds', 'chave-1'], + ['get /v1/payment_intents/pi_fake123', null], + ], + ], + 'chargeInvoiceWithCreditCard com cartão salvo' => [ + function (StripeGateway $g, ?string $key) { + $invoice = self::invoiceWithId(); + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_fake123'; + + return $g->chargeInvoiceWithCreditCard($invoice, $key); + }, + [ + self::paymentMethodResponse('cus_fake123'), + self::paidCardPaymentIntentResponse('requires_payment_method'), + self::paidCardPaymentIntentResponse('requires_payment_method'), + self::paidCardPaymentIntentResponse(), + ], + [ + 'get /v1/payment_methods/pm_fake123' => null, + 'get /v1/payment_intents/pi_fake123' => null, + 'post /v1/payment_intents/pi_fake123' => 'chave-1:update', + 'post /v1/payment_intents/pi_fake123/confirm' => 'chave-1', + ], + ], + 'chargeInvoiceWithCreditCard com token legado' => [ + function (StripeGateway $g, ?string $key) { + $invoice = self::invoiceWithId(); + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->token = 'tok_fake123'; + + return $g->chargeInvoiceWithCreditCard($invoice, $key); + }, + [ + self::paymentMethodResponse(), + self::paymentMethodResponse(), + self::paidCardPaymentIntentResponse('requires_payment_method'), + self::paidCardPaymentIntentResponse('requires_payment_method'), + self::paidCardPaymentIntentResponse(), + ], + [ + 'post /v1/payment_methods' => 'chave-1:payment_method', + 'get /v1/payment_methods/pm_fake123' => null, + 'get /v1/payment_intents/pi_fake123' => null, + 'post /v1/payment_intents/pi_fake123' => 'chave-1:update', + 'post /v1/payment_intents/pi_fake123/confirm' => 'chave-1', + ], + ], + 'cancelInvoice' => [ + fn (StripeGateway $g, ?string $key) => $g->cancelInvoice(self::invoiceWithId(), $key), + [array_merge(self::paidCardPaymentIntentResponse('canceled'), ['latest_charge' => null])], + ['post /v1/payment_intents/pi_fake123/cancel' => 'chave-1'], + ], + 'duplicateInvoice' => [ + fn (StripeGateway $g, ?string $key) => $g->duplicateInvoice(self::invoiceWithId(), Carbon::now()->addDay(), [], $key), + [ + $pendingPix, + self::stripeCustomerResponse(), + array_merge($pendingPix, ['id' => 'pi_fake456']), + array_merge($pendingPix, ['status' => 'canceled', 'next_action' => null]), + ], + [ + 'get /v1/payment_intents/pi_fake123' => null, + 'get /v1/customers/cus_fake123' => null, + 'post /v1/payment_intents' => 'chave-1', + 'post /v1/payment_intents/pi_fake123/cancel' => 'chave-1:cancel_original', + ], + ], + 'createCreditCard padrão com descrição' => [ + function (StripeGateway $g, ?string $key) { + $creditCard = self::creditCardModel(); + $creditCard->description = 'principal'; + $creditCard->default = true; + + return $g->createCreditCard($creditCard, $key); + }, + [ + self::setupIntentResponse(metadata: ['description' => 'principal', 'set_as_default' => '1']), + self::paymentMethodResponse('cus_fake123'), + self::stripeCustomerResponse(), + ], + [ + 'post /v1/setup_intents' => 'chave-1', + 'post /v1/payment_methods/pm_fake123' => 'chave-1:metadata', + 'post /v1/customers/cus_fake123' => 'chave-1:default', + ], + ], + 'createCreditCard com token legado' => [ + function (StripeGateway $g, ?string $key) { + $creditCard = self::creditCardModel(); + $creditCard->token = 'tok_fake123'; + + return $g->createCreditCard($creditCard, $key); + }, + [self::paymentMethodResponse(), self::setupIntentResponse()], + [ + 'post /v1/payment_methods' => 'chave-1:payment_method', + 'post /v1/setup_intents' => 'chave-1', + ], + ], + 'createCreditCard anexando o cartão que a Stripe devolveu sem cliente' => [ + fn (StripeGateway $g, ?string $key) => $g->createCreditCard(self::creditCardModel(), $key), + [self::setupIntentResponse(paymentMethodCustomer: null), self::paymentMethodResponse('cus_fake123')], + [ + 'post /v1/setup_intents' => 'chave-1', + 'post /v1/payment_methods/pm_fake123/attach' => 'chave-1:attach', + ], + ], + 'confirmCreditCardSetup' => [ + fn (StripeGateway $g, ?string $key) => $g->confirmCreditCardSetup('seti_fake123', $key), + [ + self::setupIntentResponse(metadata: ['description' => 'principal', 'set_as_default' => '1']), + self::paymentMethodResponse('cus_fake123'), + self::stripeCustomerResponse(), + ], + [ + 'get /v1/setup_intents/seti_fake123' => null, + 'post /v1/payment_methods/pm_fake123' => 'chave-1:metadata', + 'post /v1/customers/cus_fake123' => 'chave-1:default', + ], + ], + 'deleteCreditCard' => [ + function (StripeGateway $g, ?string $key) { + $creditCard = self::creditCardModel(); + $creditCard->id = 'pm_fake123'; + + return $g->deleteCreditCard($creditCard, $key); + }, + [self::paymentMethodResponse('cus_fake123'), self::paymentMethodResponse()], + [ + 'get /v1/payment_methods/pm_fake123' => null, + 'post /v1/payment_methods/pm_fake123/detach' => 'chave-1', + ], + ], + 'createPlan' => [ + fn (StripeGateway $g, ?string $key) => $g->createPlan(self::planModel(), $key), + [self::productResponse(), self::priceResponse()], + [ + 'post /v1/products' => 'chave-1:product', + 'post /v1/prices' => 'chave-1', + ], + ], + 'deactivatePlan' => [ + function (StripeGateway $g, ?string $key) { + $plan = new Plan(); + $plan->id = 'price_fake1'; + + return $g->deactivatePlan($plan, $key); + }, + [self::priceResponse()], + ['post /v1/prices/price_fake1' => 'chave-1'], + ], + 'createSubscription com cartão salvo' => [ + fn (StripeGateway $g, ?string $key) => $g->createSubscription(self::subscriptionModel('pm_fake123'), $key), + [ + self::subscriptionFixture(), + self::stripeInvoiceFixture(), + self::fixture('payment_intents/paid'), + ], + [ + 'post /v1/subscriptions' => 'chave-1', + 'get /v1/invoices/in_1UBJmkPjx0CusuMrN6Yc2Ha1' => null, + 'get /v1/payment_intents/pi_3UBHTpPjx0CusuMr1JTEiHGi' => null, + ], + ], + 'createSubscription salvando o cartão antes' => [ + function (StripeGateway $g, ?string $key) { + $subscription = self::subscriptionModel(); + $subscription->creditCard = new CreditCard(); + $subscription->creditCard->token = 'pm_fake123'; + + return $g->createSubscription($subscription, $key); + }, + [ + self::setupIntentResponse(), + self::subscriptionFixture(), + self::stripeInvoiceFixture(), + self::fixture('payment_intents/paid'), + ], + [ + 'post /v1/setup_intents' => 'chave-1:card', + 'post /v1/subscriptions' => 'chave-1', + 'get /v1/invoices/in_1UBJmkPjx0CusuMrN6Yc2Ha1' => null, + 'get /v1/payment_intents/pi_3UBHTpPjx0CusuMr1JTEiHGi' => null, + ], + ], + 'createSubscription com item extra' => [ + function (StripeGateway $g, ?string $key) { + $subscription = self::subscriptionModel('pm_fake123'); + $item = new SubscriptionItem(); + $item->description = 'Consultas extras'; + $item->amount = 2500; + $subscription->items = [$item]; + + return $g->createSubscription($subscription, $key); + }, + [ + self::productResponse(), + self::priceResponse(), + self::subscriptionFixture(), + self::stripeInvoiceFixture(), + self::fixture('payment_intents/paid'), + ], + [ + 'post /v1/products' => 'chave-1:item0_product', + 'get /v1/prices/price_fake1' => null, + 'post /v1/subscriptions' => 'chave-1', + 'get /v1/invoices/in_1UBJmkPjx0CusuMrN6Yc2Ha1' => null, + 'get /v1/payment_intents/pi_3UBHTpPjx0CusuMr1JTEiHGi' => null, + ], + ], + 'createSubscription com desconto' => [ + function (StripeGateway $g, ?string $key) { + $subscription = self::subscriptionModel('pm_fake123'); + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->amountOff = 500; + $subscription->discounts = [$discount]; + + return $g->createSubscription($subscription, $key); + }, + [ + self::couponResponse(), + self::subscriptionFixture(), + self::stripeInvoiceFixture(), + self::fixture('payment_intents/paid'), + ], + [ + 'post /v1/coupons' => 'chave-1:discount0_coupon', + 'post /v1/subscriptions' => 'chave-1', + 'get /v1/invoices/in_1UBJmkPjx0CusuMrN6Yc2Ha1' => null, + 'get /v1/payment_intents/pi_3UBHTpPjx0CusuMr1JTEiHGi' => null, + ], + ], + 'createSubscription boleto' => [ + function (StripeGateway $g, ?string $key) { + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_1UC8LwPjx0CusuMrr3Vq7Hpk'; + $subscription->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; + + return $g->createSubscription($subscription, $key); + }, + [ + self::fixture('subscriptions/active_send_invoice_boleto'), + self::fixture('invoices/open_boleto_send_invoice'), + self::fixture('invoices/open_boleto_send_invoice'), + ], + [ + 'post /v1/subscriptions' => 'chave-1', + 'post /v1/invoices/in_1UC8LxPjx0CusuMr8L1JgWdN/finalize' => 'chave-1:finalize', + 'get /v1/invoices/in_1UC8LxPjx0CusuMr8L1JgWdN' => null, + ], + ], + 'updateSubscription' => [ + function (StripeGateway $g, ?string $key) { + $subscription = new Subscription(); + $subscription->id = 'sub_fake1'; + $subscription->metadata = ['origem' => 'teste']; + + return $g->updateSubscription($subscription, $key); + }, + [self::subscriptionFixture()], + ['post /v1/subscriptions/sub_fake1' => 'chave-1'], + ], + 'updateSubscription com desconto novo' => [ + function (StripeGateway $g, ?string $key) { + $subscription = new Subscription(); + $subscription->id = 'sub_fake1'; + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->amountOff = 500; + $subscription->discounts = [$discount]; + + return $g->updateSubscription($subscription, $key); + }, + [self::couponResponse(), self::subscriptionFixture()], + [ + 'post /v1/coupons' => 'chave-1:discount0_coupon', + 'post /v1/subscriptions/sub_fake1' => 'chave-1', + ], + ], + 'suspendSubscription' => [ + fn (StripeGateway $g, ?string $key) => $g->suspendSubscription(self::subscriptionWithId(), $key), + [self::subscriptionFixture()], + ['post /v1/subscriptions/sub_fake1' => 'chave-1'], + ], + 'resumeSubscription' => [ + fn (StripeGateway $g, ?string $key) => $g->resumeSubscription(self::subscriptionWithId(), $key), + [self::subscriptionFixture()], + ['post /v1/subscriptions/sub_fake1' => 'chave-1'], + ], + 'cancelSubscription imediato' => [ + fn (StripeGateway $g, ?string $key) => $g->cancelSubscription(self::subscriptionWithId(), false, $key), + [self::subscriptionFixture()], + ['delete /v1/subscriptions/sub_fake1' => 'chave-1'], + ], + 'cancelSubscription ao fim do período' => [ + fn (StripeGateway $g, ?string $key) => $g->cancelSubscription(self::subscriptionWithId(), true, $key), + [self::subscriptionFixture()], + ['post /v1/subscriptions/sub_fake1' => 'chave-1'], + ], + 'changeSubscriptionPlan sem cobrança' => [ + fn (StripeGateway $g, ?string $key) => $g->changeSubscriptionPlan( + self::subscriptionWithId(), + 'price_fake2', + \Potelo\MultiPayment\Enums\ProrationBehavior::NONE, + $key + ), + [self::subscriptionFixture(), self::subscriptionFixture()], + [ + 'get /v1/subscriptions/sub_fake1' => null, + 'post /v1/subscriptions/sub_fake1' => 'chave-1', + ], + ], + ]; + } + + #[IgnoreDeprecations] + public function testTheLegacyKeyInTheDuplicateOptionsIsUsedAndKeptOutOfThePayload(): void + { + $pendingPix = self::pendingPixPaymentIntentResponse(); + $httpClient = RecordingStripeHttpClient::withResponses([ + $pendingPix, + self::stripeCustomerResponse(), + array_merge($pendingPix, ['id' => 'pi_fake456']), + array_merge($pendingPix, ['status' => 'canceled', 'next_action' => null]), + ]); + + $this->expectUserDeprecationMessage("gateway_options['idempotency_key'] está obsoleto desde 2026-09-02; passe idempotencyKey como argumento da operação"); + + (new StripeGateway())->duplicateInvoice(self::invoiceWithId(), Carbon::now()->addDay(), ['idempotency_key' => 'chave-antiga', 'statement_descriptor' => 'DUP']); + + $this->assertSame('chave-antiga', $httpClient->header(2, 'Idempotency-Key')); + $this->assertSame('DUP', $httpClient->calls[2][2]['statement_descriptor']); + $this->assertArrayNotHasKey('idempotency_key', $httpClient->calls[2][2]); + } + + #[IgnoreDeprecations] + public function testTheLegacyKeyOnTheCustomerStaysOutOfThePayload(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::stripeCustomerResponse()]); + + $customer = self::customerModel(); + $customer->gatewayOptions = ['idempotency_key' => 'chave-antiga', 'description' => 'VIP']; + + $this->expectUserDeprecationMessage("gateway_options['idempotency_key'] está obsoleto desde 2026-09-02; passe idempotencyKey como argumento da operação"); + + (new StripeGateway())->createCustomer($customer); + + $this->assertSame('chave-antiga', $httpClient->header(0, 'Idempotency-Key')); + $this->assertSame('VIP', $httpClient->calls[0][2]['description']); + $this->assertArrayNotHasKey('idempotency_key', $httpClient->calls[0][2]); + } + + private static function invoiceWithId(): Invoice + { + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + + return $invoice; + } + + private static function planModel(): Plan + { + $plan = new Plan(); + $plan->name = 'Mensal'; + $plan->identifier = 'plano_mensal'; + $plan->amount = 10000; + $plan->interval = PlanInterval::MONTH; + + return $plan; + } + + /** + * Assinatura pronta para criação, com o plano pelo id de Price (a busca por `lookup_key` + * é leitura e ficaria fora das asserções de cabeçalho). + */ + private static function subscriptionModel(?string $cardId = null): Subscription + { + $subscription = new Subscription(); + $subscription->customer = new Customer(); + $subscription->customer->id = 'cus_fake123'; + $subscription->planId = 'price_fake1'; + if (!is_null($cardId)) { + $subscription->creditCard = new CreditCard(); + $subscription->creditCard->id = $cardId; + } + + return $subscription; + } + + private static function subscriptionWithId(): Subscription + { + $subscription = new Subscription(); + $subscription->id = 'sub_fake1'; + + return $subscription; + } + + private static function productResponse(): array + { + return ['id' => 'prod_fake1', 'object' => 'product', 'name' => 'Mensal', 'active' => true, 'created' => 1786700000, 'metadata' => []]; + } + + private static function priceResponse(): array + { + return [ + 'id' => 'price_fake1', + 'object' => 'price', + 'active' => true, + 'currency' => 'brl', + 'lookup_key' => 'plano_mensal', + 'nickname' => 'Mensal', + 'created' => 1786700000, + 'product' => self::productResponse(), + 'recurring' => ['interval' => 'month', 'interval_count' => 1, 'usage_type' => 'licensed'], + 'type' => 'recurring', + 'unit_amount' => 10000, + 'unit_amount_decimal' => '10000', + ]; + } + + private static function couponResponse(): array + { + return [ + 'id' => 'co_fake1', + 'object' => 'coupon', + 'amount_off' => 500, + 'currency' => 'brl', + 'duration' => 'forever', + 'name' => 'Promo', + 'valid' => true, + 'created' => 1786700000, + 'metadata' => [], + ]; + } + + private static function subscriptionFixture(): array + { + return self::fixture('subscriptions/active'); + } + + private static function stripeInvoiceFixture(): array + { + return self::fixture('invoices/paid'); + } + + private static function fixture(string $path): array + { + return json_decode(file_get_contents(__DIR__ . "/../../fixtures/stripe/{$path}.json"), true); + } + + private static function customerModel(): Customer + { + $customer = new Customer(); + $customer->name = 'Fake Customer'; + $customer->email = 'email@exemplo.com'; + $customer->taxDocument = '20176996915'; + + return $customer; + } + + private static function creditCardModel(): CreditCard + { + $creditCard = new CreditCard(); + $creditCard->token = 'pm_fake123'; + $creditCard->customer = new Customer(); + $creditCard->customer->id = 'cus_fake123'; + + return $creditCard; + } + + private static function creditCardInvoiceModel(): Invoice + { + $invoice = new Invoice(); + $invoice->customer = new Customer(); + $invoice->customer->id = 'cus_fake123'; + $invoice->availablePaymentMethods = [PaymentMethod::CREDIT_CARD]; + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_fake123'; + $item = new InvoiceItem(); + $item->description = 'Assinatura mensal'; + $item->price = 12345; + $item->quantity = 1; + $invoice->items = [$item]; + + return $invoice; + } + + private static function pixInvoiceModel(): Invoice + { + $invoice = self::creditCardInvoiceModel(); + $invoice->creditCard = null; + $invoice->availablePaymentMethods = [PaymentMethod::PIX]; + $invoice->customer->name = 'Fake Customer'; + $invoice->customer->email = 'email@exemplo.com'; + $invoice->customer->taxDocument = '20176996915'; + + return $invoice; + } + + private static function bankSlipInvoiceModel(): Invoice + { + $invoice = self::pixInvoiceModel(); + $invoice->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; + $invoice->customer->address = new \Potelo\MultiPayment\Models\Address(); + $invoice->customer->address->street = 'Av Paulista'; + $invoice->customer->address->number = '1234'; + $invoice->customer->address->city = 'Sao Paulo'; + $invoice->customer->address->state = 'SP'; + $invoice->customer->address->zipCode = '01310000'; + + return $invoice; + } + + private static function stripeCustomerResponse(): array + { + return [ + 'id' => 'cus_fake123', + 'object' => 'customer', + 'name' => 'Fake Customer', + 'email' => 'email@exemplo.com', + 'phone' => null, + 'created' => 1786700000, + 'metadata' => [], + 'address' => null, + 'invoice_settings' => ['default_payment_method' => null], + 'tax_ids' => [ + 'object' => 'list', + 'data' => [ + ['id' => 'txi_fake1', 'object' => 'tax_id', 'type' => 'br_cpf', 'value' => '20176996915'], + ], + ], + ]; + } + + private static function paymentMethodResponse(?string $customer = null): array + { + return [ + 'id' => 'pm_fake123', + 'object' => 'payment_method', + 'type' => 'card', + 'customer' => $customer, + 'created' => 1786700000, + 'billing_details' => ['name' => 'Faker Teste'], + 'metadata' => [], + 'card' => ['brand' => 'visa', 'last4' => '4242', 'exp_month' => 8, 'exp_year' => 2027], + ]; + } + + /** + * SetupIntent confirmado, com o PaymentMethod expandido (anexado ao cliente por padrão). + */ + private static function setupIntentResponse(?string $paymentMethodCustomer = 'cus_fake123', array $metadata = []): array + { + return [ + 'id' => 'seti_fake123', + 'object' => 'setup_intent', + 'status' => 'succeeded', + 'customer' => 'cus_fake123', + 'usage' => 'off_session', + 'client_secret' => 'seti_fake123_secret_fake', + 'created' => 1786700000, + 'payment_method_types' => ['card'], + 'metadata' => $metadata, + 'next_action' => null, + 'last_setup_error' => null, + 'payment_method' => self::paymentMethodResponse($paymentMethodCustomer), + ]; + } + + private static function paidCardPaymentIntentResponse(string $status = 'succeeded'): array + { + return [ + 'id' => 'pi_fake123', + 'object' => 'payment_intent', + 'status' => $status, + 'amount' => 12345, + 'currency' => 'brl', + 'customer' => 'cus_fake123', + 'created' => 1786700000, + 'payment_method_types' => ['card'], + 'next_action' => null, + 'metadata' => [ + 'item_0_description' => 'Assinatura mensal', + 'item_0_price' => '12345', + 'item_0_quantity' => '1', + ], + 'latest_charge' => $status === 'succeeded' ? [ + 'id' => 'ch_fake123', + 'object' => 'charge', + 'status' => 'succeeded', + 'paid' => true, + 'amount' => 12345, + 'amount_captured' => 12345, + 'amount_refunded' => 0, + 'refunded' => false, + 'disputed' => false, + 'created' => 1786700010, + 'payment_method_details' => [ + 'type' => 'card', + 'card' => ['brand' => 'visa', 'last4' => '4242'], + ], + 'balance_transaction' => [ + 'id' => 'txn_fake123', + 'object' => 'balance_transaction', + 'fee' => 425, + 'currency' => 'brl', + ], + ] : null, + ]; + } + + private static function pendingPixPaymentIntentResponse(): array + { + $response = self::paidCardPaymentIntentResponse('requires_action'); + $response['payment_method_types'] = ['pix']; + $response['next_action'] = [ + 'type' => 'pix_display_qr_code', + 'pix_display_qr_code' => [ + 'data' => '00020126pixcopiaecola', + 'image_url_png' => 'https://qr.stripe.com/test.png', + 'image_url_svg' => 'https://qr.stripe.com/test.svg', + 'expires_at' => 1786800000, + 'hosted_instructions_url' => 'https://payments.stripe.com/qr/instructions/test', + ], + ]; + + return $response; + } +} diff --git a/tests/Unit/Gateways/StripeGatewayInvoiceTest.php b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php new file mode 100644 index 0000000..f386023 --- /dev/null +++ b/tests/Unit/Gateways/StripeGatewayInvoiceTest.php @@ -0,0 +1,2429 @@ +instance('config', new Repository([ + 'multi-payment.gateways.stripe.api_key' => 'sk_test_fake', + ])); + Facade::setFacadeApplication($app); + + // fake vazio por padrão: teste que esquecer withResponses() estoura em vez de ir à rede + RecordingStripeHttpClient::withResponses([]); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + ApiRequestor::setHttpClient(null); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testCreatesCreditCardInvoiceChargingSavedCard(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->paidCardPaymentIntentResponse()]); + + $result = (new StripeGateway())->createInvoice($this->creditCardInvoiceModel()); + + $this->assertCount(1, $httpClient->calls); + [$method, $url, $params] = $httpClient->calls[0]; + $this->assertSame('post', $method); + $this->assertSame('/v1/payment_intents', parse_url($url, PHP_URL_PATH)); + $this->assertSame([ + 'amount' => 12345, + 'currency' => 'brl', + 'customer' => 'cus_fake123', + 'metadata' => [ + 'item_0_description' => 'Assinatura mensal', + 'item_0_price' => 12345, + 'item_0_quantity' => 1, + ], + 'payment_method_types' => ['card'], + 'payment_method' => 'pm_fake123', + // o encoder do stripe-php serializa booleanos como string antes da camada HTTP + 'confirm' => 'true', + 'off_session' => 'true', + 'expand' => ['latest_charge.balance_transaction', 'latest_charge.refunds'], + ], $params); + + $this->assertSame('pi_fake123', $result->id); + $this->assertSame(InvoiceStatus::PAID, $result->status); + $this->assertSame(12345, $result->amount); + $this->assertSame(12345, $result->paidAmount); + $this->assertSame(0, $result->refundedAmount); + $this->assertSame(425, $result->fee); + $this->assertInstanceOf(Carbon::class, $result->paidAt); + $this->assertSame(PaymentMethod::CREDIT_CARD, $result->paymentMethod); + $this->assertSame('visa', $result->creditCard->brand); + $this->assertSame('4242', $result->creditCard->lastDigits); + $this->assertCount(1, $result->items); + $this->assertSame('Assinatura mensal', $result->items[0]->description); + $this->assertSame(12345, $result->items[0]->price); + $this->assertSame('stripe', $result->gateway); + } + + /** + * O token vira cartão salvo por um SetupIntent confirmado (que anexa o PaymentMethod ao + * cliente) antes do PaymentIntent da cobrança. + */ + public function testCreatesCreditCardInvoiceSavingTokenizedCardFirst(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + [ + 'id' => 'seti_fake123', + 'object' => 'setup_intent', + 'status' => 'succeeded', + 'customer' => 'cus_fake123', + 'usage' => 'off_session', + 'client_secret' => 'seti_fake123_secret_fake', + 'created' => 1786700000, + 'metadata' => [], + 'next_action' => null, + 'last_setup_error' => null, + 'payment_method' => $this->paymentMethodResponse('cus_fake123'), + ], + $this->paidCardPaymentIntentResponse(), + ]); + + $invoice = $this->creditCardInvoiceModel(); + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->token = 'pm_fake123'; + $result = (new StripeGateway())->createInvoice($invoice); + + $paths = array_map(static fn ($call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), $httpClient->calls); + $this->assertSame([ + 'post /v1/setup_intents', + 'post /v1/payment_intents', + ], $paths); + $this->assertSame('cus_fake123', $httpClient->calls[0][2]['customer']); + $this->assertSame('pm_fake123', $httpClient->calls[0][2]['payment_method']); + $this->assertSame('pm_fake123', $httpClient->calls[1][2]['payment_method']); + $this->assertSame('pm_fake123', $result->creditCard->id); + $this->assertFalse($result->creditCard->requiresAction); + } + + public function testRejectsInvoiceWithMultiplePaymentMethods(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $invoice = $this->creditCardInvoiceModel(); + $invoice->availablePaymentMethods = [PaymentMethod::CREDIT_CARD, PaymentMethod::PIX]; + + try { + (new StripeGateway())->createInvoice($invoice); + $this->fail('Fatura multi-método no Stripe deveria lançar UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::MULTIPLE_PAYMENT_METHODS, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_NOT_IMPLEMENTED, $e->reason); + } + $this->assertSame([], $httpClient->calls); + } + + public function testCreatesBankSlipInvoiceFullyServerSideAndParsesTheVoucher(): void + { + Carbon::setTestNow('2026-09-04 10:00:00'); + $httpClient = RecordingStripeHttpClient::withResponses([$this->boletoPaymentIntentResponse()]); + + $invoice = $this->bankSlipInvoiceModel(); + $invoice->dueDate = Carbon::parse('2026-09-07'); + $result = (new StripeGateway())->createInvoice($invoice); + + $this->assertCount(1, $httpClient->calls); + [$method, $url, $params] = $httpClient->calls[0]; + $this->assertSame('post', $method); + $this->assertSame('/v1/payment_intents', parse_url($url, PHP_URL_PATH)); + $this->assertSame(['boleto'], $params['payment_method_types']); + $this->assertSame([ + 'type' => 'boleto', + 'boleto' => ['tax_id' => '20176996915'], + 'billing_details' => [ + 'name' => 'Fake Customer', + 'email' => 'email@exemplo.com', + 'address' => [ + 'line1' => 'Av Paulista, 1234', + 'city' => 'Sao Paulo', + 'state' => 'SP', + 'postal_code' => '01310000', + 'country' => 'BR', + ], + ], + ], $params['payment_method_data']); + $this->assertSame('true', $params['confirm']); + $this->assertSame(['boleto' => ['expires_after_days' => 3]], $params['payment_method_options']); + + $this->assertSame(InvoiceStatus::PENDING, $result->status); + $this->assertSame(PaymentMethod::BANK_SLIP, $result->paymentMethod); + $this->assertNull($result->paidAmount); + $this->assertStringContainsString('payments.stripe.com/boleto/voucher', $result->url); + $this->assertSame('01010101010101010101010101010101010101010101010', $result->bankSlip->number); + $this->assertStringEndsWith('/pdf', $result->bankSlip->url); + $this->assertNull($result->pix); + } + + /** + * Sem `dueDate` no model, o vencimento do voucher (fim do dia na Stripe) preenche a data. + */ + public function testGetBankSlipInvoiceFillsTheDueDateFromTheVoucherExpiry(): void + { + RecordingStripeHttpClient::withResponses([$this->boletoPaymentIntentResponse()]); + + $result = $this->getInvoice(); + + $this->assertSame(1788836340, $result->dueDate->getTimestamp()); + $this->assertSame(PaymentMethod::BANK_SLIP, $result->paymentMethod); + } + + public function testBankSlipInvoiceRequiresCustomerTaxDocument(): void + { + $invoice = $this->bankSlipInvoiceModel(); + $invoice->customer->taxDocument = null; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('taxDocument'); + + (new StripeGateway())->createInvoice($invoice); + } + + public function testBankSlipInvoiceRequiresCustomerAddress(): void + { + $invoice = $this->bankSlipInvoiceModel(); + $invoice->customer->address = null; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('address'); + + (new StripeGateway())->createInvoice($invoice); + } + + public function testBankSlipInvoiceRequiresCustomerEmail(): void + { + $invoice = $this->bankSlipInvoiceModel(); + $invoice->customer->email = null; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('email'); + + (new StripeGateway())->createInvoice($invoice); + } + + /** + * Endereço presente mas incompleto também é recusado: a Stripe exige rua, cidade, estado + * e CEP nos billing details do boleto. + */ + public function testBankSlipInvoiceRequiresACompleteAddress(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $invoice = $this->bankSlipInvoiceModel(); + $invoice->customer->address->zipCode = null; + + try { + (new StripeGateway())->createInvoice($invoice); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('address (street, city, state and zipCode)', $e->getMessage()); + } + $this->assertSame([], $httpClient->calls); + } + + /** + * Limites de valor do boleto na Stripe: R$ 5,00 a R$ 49.999,99. + */ + #[DataProvider('bankSlipAmountOutsideLimitsProvider')] + public function testBankSlipInvoiceRejectsAmountOutsideStripeLimits(int $amount): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $invoice = $this->bankSlipInvoiceModel(); + $invoice->items = null; + $invoice->amount = $amount; + + try { + (new StripeGateway())->createInvoice($invoice); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('amount must be between 500 and 4999999', $e->getMessage()); + } + $this->assertSame([], $httpClient->calls); + } + + public static function bankSlipAmountOutsideLimitsProvider(): array + { + return [ + 'abaixo do minimo' => [499], + 'acima do maximo' => [5000000], + ]; + } + + #[DataProvider('bankSlipDueDateOutsideWindowProvider')] + public function testBankSlipInvoiceRejectsDueDateOutsideStripeWindow(string $dueDate): void + { + Carbon::setTestNow('2026-09-04 10:00:00'); + $httpClient = RecordingStripeHttpClient::withResponses([]); + $invoice = $this->bankSlipInvoiceModel(); + $invoice->dueDate = Carbon::parse($dueDate); + + try { + (new StripeGateway())->createInvoice($invoice); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('dueDate must be between today and 60 days', $e->getMessage()); + } + $this->assertSame([], $httpClient->calls); + } + + public static function bankSlipDueDateOutsideWindowProvider(): array + { + return [ + 'vencida ontem' => ['2026-09-03'], + 'alem de 60 dias' => ['2026-11-04'], + ]; + } + + /** + * Vencimento hoje é aceito: `expires_after_days` zero vence às 23h59 de hoje na Stripe. + */ + public function testBankSlipInvoiceDueTodaySendsZeroExpiresAfterDays(): void + { + Carbon::setTestNow('2026-09-04 10:00:00'); + $httpClient = RecordingStripeHttpClient::withResponses([$this->boletoPaymentIntentResponse()]); + + $invoice = $this->bankSlipInvoiceModel(); + $invoice->dueDate = Carbon::parse('2026-09-04'); + (new StripeGateway())->createInvoice($invoice); + + $this->assertSame(0, $httpClient->calls[0][2]['payment_method_options']['boleto']['expires_after_days']); + } + + /** + * O teto de 60 dias é aceito, e a contagem usa a data corrente no fuso de São Paulo, onde + * a Stripe vira o dia do boleto. + */ + public function testBankSlipInvoiceDueInExactlySixtyDaysSendsSixtyExpiresAfterDays(): void + { + // 01:00 UTC do dia 5 ainda é dia 4 em São Paulo: a contagem parte do dia 4 + Carbon::setTestNow('2026-09-05 01:00:00'); + $httpClient = RecordingStripeHttpClient::withResponses([$this->boletoPaymentIntentResponse()]); + + $invoice = $this->bankSlipInvoiceModel(); + $invoice->dueDate = Carbon::parse('2026-11-03'); + (new StripeGateway())->createInvoice($invoice); + + $this->assertSame(60, $httpClient->calls[0][2]['payment_method_options']['boleto']['expires_after_days']); + } + + public function testBankSlipInvoiceWithoutDueDateOmitsExpiresAfterDays(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->boletoPaymentIntentResponse()]); + + (new StripeGateway())->createInvoice($this->bankSlipInvoiceModel()); + + $this->assertArrayNotHasKey('payment_method_options', $httpClient->calls[0][2]); + } + + /** + * A Stripe não cancela um boleto com voucher em aberto; com o voucher no model, a recusa + * acontece antes da requisição. + */ + public function testCancelPendingBankSlipInvoiceWithTheVoucherInHandIsRefusedBeforeTheNetwork(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->boletoPaymentIntentResponse()]); + $result = $this->getInvoice(); + + try { + (new StripeGateway())->cancelInvoice($result); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::INVOICE_CANCELLATION, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertStringContainsString('voucher', $e->getMessage()); + } + $this->assertCount(1, $httpClient->calls, 'o cancelamento não pode chegar à rede'); + } + + /** + * Boleto pendente sem o voucher no model é cancelável: o cancel chega ao gateway e a + * fatura volta cancelada com `bankSlip` limpo. + */ + public function testCancelExpiredBankSlipInvoiceGoesToTheGateway(): void + { + $canceled = $this->boletoPaymentIntentResponse(); + $canceled['status'] = 'canceled'; + $canceled['next_action'] = null; + $httpClient = RecordingStripeHttpClient::withResponses([$canceled]); + + $expired = new Invoice(); + $expired->id = 'pi_fake123'; + $expired->paymentMethod = PaymentMethod::BANK_SLIP; + $expired->status = InvoiceStatus::PENDING; + $result = (new StripeGateway())->cancelInvoice($expired); + + $this->assertSame('/v1/payment_intents/pi_fake123/cancel', parse_url($httpClient->calls[0][1], PHP_URL_PATH)); + $this->assertSame(InvoiceStatus::CANCELED, $result->status); + $this->assertNull($result->bankSlip); + } + + public function testRejectsUnknownPaymentMethodStringOnWriteWithoutHittingTheApi(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $invoice = $this->creditCardInvoiceModel(); + + try { + $invoice->availablePaymentMethods = ['foo']; + $this->fail('Método de pagamento desconhecido deveria lançar ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('availablePaymentMethods must be one of', $e->getMessage()); + } + $this->assertSame([], $httpClient->calls); + } + + public function testRejectsNonSelectablePaymentMethodWithoutHittingTheApi(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $invoice = $this->creditCardInvoiceModel(); + $invoice->availablePaymentMethods = [PaymentMethod::AUTOMATIC_PIX]; + + try { + (new StripeGateway())->createInvoice($invoice); + $this->fail('Método de pagamento não selecionável deveria lançar ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('availablePaymentMethods must be one of: credit_card, bank_slip, pix', $e->getMessage()); + } + $this->assertSame([], $httpClient->calls); + } + + /** + * `availablePaymentMethods[] = 'pix'` entra no array sem conversão; o driver normaliza + * antes de escolher o método. + */ + public function testStringAppendedToAvailablePaymentMethodsIsNormalizedBeforeUse(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->pendingPixPaymentIntentResponse()]); + $invoice = $this->pixInvoiceModel(); + $invoice->availablePaymentMethods = []; + $invoice->availablePaymentMethods[] = 'pix'; + + $result = (new StripeGateway())->createInvoice($invoice); + + $this->assertSame(['pix'], $httpClient->calls[0][2]['payment_method_types']); + $this->assertSame(PaymentMethod::PIX, $result->paymentMethod); + } + + public function testCreatesPixInvoiceFullyServerSideAndParsesQrCode(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->pendingPixPaymentIntentResponse()]); + + $invoice = $this->pixInvoiceModel(); + // o parse sobrescreve pixExpiresAt com o valor devolvido pela Stripe: captura antes + $requestedExpiresAt = $invoice->pixExpiresAt->getTimestamp(); + $result = (new StripeGateway())->createInvoice($invoice); + + $this->assertCount(1, $httpClient->calls); + [$method, $url, $params] = $httpClient->calls[0]; + $this->assertSame('post', $method); + $this->assertSame('/v1/payment_intents', parse_url($url, PHP_URL_PATH)); + $this->assertSame([ + 'amount' => 12345, + 'currency' => 'brl', + 'customer' => 'cus_fake123', + 'metadata' => [ + 'item_0_description' => 'Assinatura mensal', + 'item_0_price' => 12345, + 'item_0_quantity' => 1, + ], + 'payment_method_types' => ['pix'], + 'payment_method_data' => [ + 'type' => 'pix', + 'billing_details' => [ + 'name' => 'Fake Customer', + 'email' => 'email@exemplo.com', + 'tax_id' => '20176996915', + ], + ], + 'confirm' => 'true', + 'payment_method_options' => ['pix' => ['expires_at' => $requestedExpiresAt]], + 'expand' => ['latest_charge.balance_transaction', 'latest_charge.refunds'], + ], $params); + + $this->assertSame(InvoiceStatus::PENDING, $result->status); + $this->assertSame(PaymentMethod::PIX, $result->paymentMethod); + $this->assertSame('00020126pixcopiaecola', $result->pix->qrCodeText); + $this->assertSame('https://qr.stripe.com/test.png', $result->pix->qrCodeImageUrl); + $this->assertSame('https://payments.stripe.com/qr/instructions/test', $result->url); + $this->assertSame(1786800000, $result->pixExpiresAt->getTimestamp()); + $this->assertNull($result->dueDate); + $this->assertNull($result->paidAmount); + } + + public function testRejectsInvoiceWithAutomaticPixUntilSupported(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $invoice = $this->pixInvoiceModel(); + $invoice->automaticPix = new \Potelo\MultiPayment\Models\AutomaticPix(); + + try { + (new StripeGateway())->createInvoice($invoice); + $this->fail('Pix Automático no Stripe deveria lançar UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::AUTOMATIC_PIX, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_NOT_IMPLEMENTED, $e->reason); + } + $this->assertSame([], $httpClient->calls); + } + + public function testPixInvoiceRequiresCustomerTaxDocument(): void + { + $invoice = $this->pixInvoiceModel(); + $invoice->customer->taxDocument = null; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('taxDocument'); + + (new StripeGateway())->createInvoice($invoice); + } + + public function testPixInvoiceRequiresCustomer(): void + { + $invoice = $this->pixInvoiceModel(); + $invoice->customer = null; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('taxDocument'); + + (new StripeGateway())->createInvoice($invoice); + } + + public function testPixInvoiceWithoutPixExpiresAtOrDueDateOmitsPaymentMethodOptions(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->pendingPixPaymentIntentResponse()]); + + $invoice = $this->pixInvoiceModel(); + $invoice->pixExpiresAt = null; + (new StripeGateway())->createInvoice($invoice); + + $this->assertArrayNotHasKey('payment_method_options', $httpClient->calls[0][2]); + } + + public function testPixInvoiceRejectsPixExpiresAtOutsideStripeWindow(): void + { + $invoice = $this->pixInvoiceModel(); + $invoice->pixExpiresAt = Carbon::now()->subMinute(); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('pixExpiresAt must be more than 10 seconds and less than 14 days'); + + (new StripeGateway())->createInvoice($invoice); + } + + /** + * Sem `pixExpiresAt`, o QR Code expira no fim do dia de `dueDate`: um vencimento de hoje + * cabe na janela e `dueDate` permanece no model. + */ + public function testPixInvoiceDerivesTheQrCodeExpiryFromTheEndOfTheDueDate(): void + { + Carbon::setTestNow('2026-09-02 10:00:00'); + $httpClient = RecordingStripeHttpClient::withResponses([$this->pendingPixPaymentIntentResponse()]); + + $invoice = $this->pixInvoiceModel(); + $invoice->pixExpiresAt = null; + $invoice->dueDate = Carbon::parse('2026-09-02'); + (new StripeGateway())->createInvoice($invoice); + + $this->assertSame( + Carbon::parse('2026-09-02 23:59:59')->getTimestamp(), + $httpClient->calls[0][2]['payment_method_options']['pix']['expires_at'] + ); + $this->assertSame('2026-09-02', $invoice->dueDate->format('Y-m-d')); + } + + public function testPixInvoiceGivesPixExpiresAtPrecedenceOverDueDate(): void + { + Carbon::setTestNow('2026-09-02 10:00:00'); + $httpClient = RecordingStripeHttpClient::withResponses([$this->pendingPixPaymentIntentResponse()]); + + $invoice = $this->pixInvoiceModel(); + $invoice->dueDate = Carbon::parse('2026-09-05'); + $invoice->pixExpiresAt = Carbon::parse('2026-09-02 14:00:00'); + (new StripeGateway())->createInvoice($invoice); + + $this->assertSame( + Carbon::parse('2026-09-02 14:00:00')->getTimestamp(), + $httpClient->calls[0][2]['payment_method_options']['pix']['expires_at'] + ); + } + + #[DataProvider('dueDateOutsideWindowProvider')] + public function testPixInvoiceRejectsDueDateWhoseEndOfDayIsOutsideStripeWindow(string $dueDate): void + { + Carbon::setTestNow('2026-09-02 10:00:00'); + $invoice = $this->pixInvoiceModel(); + $invoice->pixExpiresAt = null; + $invoice->dueDate = Carbon::parse($dueDate); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('dueDate must be more than 10 seconds and less than 14 days'); + + (new StripeGateway())->createInvoice($invoice); + } + + public static function dueDateOutsideWindowProvider(): array + { + return [ + 'vencida ontem' => ['2026-09-01'], + 'fim do dia alem de 14 dias' => ['2026-09-16'], + ]; + } + + /** + * `paymentMethod` com a lista vazia escolhe o método da fatura, como a lista faria. + */ + #[DataProvider('paymentMethodOnlyProvider')] + public function testPaymentMethodAloneSelectsTheStripePaymentMethodType(PaymentMethod $method, string $stripeType, bool $withCard): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + $withCard ? $this->paidCardPaymentIntentResponse() : $this->pendingPixPaymentIntentResponse(), + ]); + $invoice = $withCard ? $this->creditCardInvoiceModel() : $this->pixInvoiceModel(); + $invoice->availablePaymentMethods = null; + $invoice->paymentMethod = $method; + + $result = (new StripeGateway())->createInvoice($invoice); + + $this->assertSame([$stripeType], $httpClient->calls[0][2]['payment_method_types']); + $this->assertSame($method, $result->paymentMethod); + } + + public static function paymentMethodOnlyProvider(): array + { + return [ + 'cartao' => [PaymentMethod::CREDIT_CARD, 'card', true], + 'pix' => [PaymentMethod::PIX, 'pix', false], + ]; + } + + public function testInvoiceWithoutAnyPaymentMethodIsRejectedBeforeTheNetwork(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $invoice = $this->pixInvoiceModel(); + $invoice->availablePaymentMethods = null; + + try { + (new StripeGateway())->createInvoice($invoice); + $this->fail('Fatura sem método deveria lançar ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('paymentMethod or availablePaymentMethods', $e->getMessage()); + } + $this->assertSame([], $httpClient->calls); + } + + public function testPixInvoiceBillingDetailsOmitsMissingNameAndEmail(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->pendingPixPaymentIntentResponse()]); + + $invoice = $this->pixInvoiceModel(); + $invoice->customer->name = null; + $invoice->customer->email = null; + (new StripeGateway())->createInvoice($invoice); + + $this->assertSame( + ['tax_id' => '20176996915'], + $httpClient->calls[0][2]['payment_method_data']['billing_details'] + ); + } + + public function testIdempotencyKeyArgumentBecomesRequestHeader(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->pendingPixPaymentIntentResponse()]); + + (new StripeGateway())->createInvoice($this->pixInvoiceModel(), 'chave-unica-123'); + + $this->assertSame('chave-unica-123', $httpClient->header(0, 'Idempotency-Key')); + $this->assertArrayNotHasKey('idempotency_key', $httpClient->calls[0][2]); + } + + public function testWithoutIdempotencyKeyNoHeaderIsSent(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->pendingPixPaymentIntentResponse()]); + + (new StripeGateway())->createInvoice($this->pixInvoiceModel()); + + $this->assertNull($httpClient->header(0, 'Idempotency-Key')); + } + + /** + * A chave antiga em `gatewayOptions` ainda vira o cabeçalho, com aviso de deprecação, e + * não vaza como parâmetro do payload (a API a rejeitaria). + */ + #[IgnoreDeprecations] + public function testLegacyIdempotencyKeyInGatewayOptionsStillBecomesTheHeaderWithADeprecation(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->pendingPixPaymentIntentResponse()]); + + $invoice = $this->pixInvoiceModel(); + $invoice->gatewayOptions = ['idempotency_key' => 'chave-antiga']; + + $this->expectUserDeprecationMessage("gateway_options['idempotency_key'] está obsoleto desde 2026-09-02; passe idempotencyKey como argumento da operação"); + + (new StripeGateway())->createInvoice($invoice); + + $this->assertSame('chave-antiga', $httpClient->header(0, 'Idempotency-Key')); + $this->assertArrayNotHasKey('idempotency_key', $httpClient->calls[0][2]); + } + + /** + * O argumento tem precedência sobre a chave antiga em `gatewayOptions`. + */ + #[IgnoreDeprecations] + public function testIdempotencyKeyArgumentWinsOverTheLegacyGatewayOption(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->pendingPixPaymentIntentResponse()]); + + $invoice = $this->pixInvoiceModel(); + $invoice->gatewayOptions = ['idempotency_key' => 'chave-antiga']; + + (new StripeGateway())->createInvoice($invoice, 'chave-nova'); + + $this->assertSame('chave-nova', $httpClient->header(0, 'Idempotency-Key')); + } + + public function testCancelsPendingInvoice(): void + { + $response = $this->paidCardPaymentIntentResponse(status: 'canceled'); + $response['latest_charge'] = null; + $httpClient = RecordingStripeHttpClient::withResponses([$response]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $result = (new StripeGateway())->cancelInvoice($invoice); + + [$method, $url, $params] = $httpClient->calls[0]; + $this->assertSame('post', $method); + $this->assertSame('/v1/payment_intents/pi_fake123/cancel', parse_url($url, PHP_URL_PATH)); + $this->assertSame(['expand' => ['latest_charge.balance_transaction', 'latest_charge.refunds']], $params); + $this->assertSame(InvoiceStatus::CANCELED, $result->status); + } + + public function testCancelPaidInvoiceBecomesValidationException(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'invalid_request_error', + 'code' => 'payment_intent_unexpected_state', + 'message' => 'This PaymentIntent could not be canceled because it has a status of succeeded.', + ]], 400], + ]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + + try { + (new StripeGateway())->cancelInvoice($invoice); + $this->fail('Expected ValidationException was not thrown'); + } catch (ValidationException $exception) { + $this->assertSame('payment_intent_unexpected_state', $exception->getErrors()['code']); + $this->assertSame( + ['base' => ['This PaymentIntent could not be canceled because it has a status of succeeded.']], + $exception->fieldErrors + ); + } + } + + public function testCancelInvoiceRequiresId(): void + { + $this->expectException(ModelAttributeValidationException::class); + + (new StripeGateway())->cancelInvoice(new Invoice()); + } + + public function testCardDeclineBecomesChargingExceptionWithNormalizedReason(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'card_error', + 'code' => 'card_declined', + 'decline_code' => 'generic_decline', + 'message' => 'Your card was declined.', + 'payment_intent' => ['id' => 'pi_fake123', 'object' => 'payment_intent', 'status' => 'requires_payment_method'], + ]], 402], + ]); + + try { + (new StripeGateway())->createInvoice($this->creditCardInvoiceModel()); + $this->fail('Expected ChargingException was not thrown'); + } catch (ChargingException $exception) { + $this->assertSame('card_declined', $exception->reason); + $this->assertNotEmpty($exception->chargeResponse); + $this->assertSame('pi_fake123', $exception->chargeResponse['payment_intent']['id']); + } + } + + public function testDeclineDuringCardAttachAlsoBecomesChargingException(): void + { + // a Stripe valida o cartão já no attach — recusa nesse ponto precisa manter a + // semântica de falha de cobrança + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'card_error', + 'code' => 'card_declined', + 'decline_code' => 'generic_decline', + 'message' => 'Your card was declined.', + ]], 402], + ]); + + $invoice = $this->creditCardInvoiceModel(); + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->token = 'pm_fake123'; + + try { + (new StripeGateway())->createInvoice($invoice); + $this->fail('Expected ChargingException was not thrown'); + } catch (ChargingException $exception) { + $this->assertSame('card_declined', $exception->reason); + $this->assertSame('card_error', $exception->chargeResponse['type']); + } + } + + public function testAuthenticationRequiredDeclineIsNormalized(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'card_error', + 'code' => 'authentication_required', + 'decline_code' => 'authentication_required', + 'message' => 'This transaction requires authentication.', + ]], 402], + ]); + + try { + (new StripeGateway())->createInvoice($this->creditCardInvoiceModel()); + $this->fail('Expected ChargingException was not thrown'); + } catch (ChargingException $exception) { + $this->assertSame('authentication_required', $exception->reason); + } + } + + public function testBrandNotSupportedDeclineIsNormalized(): void + { + RecordingStripeHttpClient::withResponses([ + [['error' => [ + 'type' => 'card_error', + 'code' => 'card_declined', + 'decline_code' => 'card_not_supported', + 'message' => 'Your card is not supported.', + ]], 402], + ]); + + try { + (new StripeGateway())->createInvoice($this->creditCardInvoiceModel()); + $this->fail('Expected ChargingException was not thrown'); + } catch (ChargingException $exception) { + $this->assertSame('brand_not_supported', $exception->reason); + } + } + + public function testGetInvoiceParsesFullRefund(): void + { + $response = $this->paidCardPaymentIntentResponse(); + $response['latest_charge']['amount_refunded'] = 12345; + $response['latest_charge']['refunded'] = true; + RecordingStripeHttpClient::withResponses([$response]); + + $result = $this->getInvoice(); + + $this->assertSame(InvoiceStatus::REFUNDED, $result->status); + $this->assertSame(12345, $result->refundedAmount); + } + + public function testGetInvoiceParsesPartialRefund(): void + { + $response = $this->paidCardPaymentIntentResponse(); + $response['latest_charge']['amount_refunded'] = 2345; + RecordingStripeHttpClient::withResponses([$response]); + + $result = $this->getInvoice(); + + $this->assertSame(InvoiceStatus::PARTIALLY_REFUNDED, $result->status); + $this->assertSame(2345, $result->refundedAmount); + } + + public function testGetInvoiceReportsExpiredPixAsPendingIgnoringFailedCharge(): void + { + // pix expirado: o PI volta a requires_payment_method com o charge em failed + $response = $this->paidCardPaymentIntentResponse(); + $response['status'] = 'requires_payment_method'; + $response['payment_method_types'] = ['pix']; + $response['latest_charge']['status'] = 'failed'; + $response['latest_charge']['paid'] = false; + $response['latest_charge']['amount_captured'] = 0; + $response['latest_charge']['payment_method_details'] = ['type' => 'pix', 'pix' => []]; + $response['latest_charge']['balance_transaction'] = null; + RecordingStripeHttpClient::withResponses([$response]); + + $result = $this->getInvoice(); + + $this->assertSame(InvoiceStatus::PENDING, $result->status); + $this->assertNull($result->paidAmount); + $this->assertNull($result->paidAt); + $this->assertSame(PaymentMethod::PIX, $result->paymentMethod); + } + + public function testGetInvoiceReadsAnUnexpectedStatusAsUnknownWithAWarning(): void + { + Facade::getFacadeApplication()->instance('log', $logger = new RecordingLogger()); + $response = $this->paidCardPaymentIntentResponse(); + $response['status'] = 'partially_funded'; + $response['latest_charge'] = null; + RecordingStripeHttpClient::withResponses([$response]); + + $result = $this->getInvoice(); + + $this->assertSame(InvoiceStatus::UNKNOWN, $result->status); + $this->assertSame('partially_funded', $result->original->status); + $this->assertCount(1, $logger->records); + $this->assertSame('warning', $logger->records[0]['level']); + $this->assertSame(['status' => 'partially_funded', 'gateway' => 'stripe'], $logger->records[0]['context']); + } + + /** + * Cada tipo de PaymentMethod da Stripe em `payment_method_details.type` chega como o caso + * do enum; tipo fora do mapa fica nulo. + */ + public static function stripePaymentMethodTypeProvider(): array + { + return [ + 'card' => ['card', PaymentMethod::CREDIT_CARD], + 'pix' => ['pix', PaymentMethod::PIX], + 'boleto' => ['boleto', PaymentMethod::BANK_SLIP], + 'tipo fora do mapa' => ['card_present', null], + ]; + } + + #[DataProvider('stripePaymentMethodTypeProvider')] + public function testGetInvoiceParsesTheStripePaymentMethodType(string $type, ?PaymentMethod $expected): void + { + $response = $this->paidCardPaymentIntentResponse(); + $response['payment_method_types'] = [$type]; + $response['latest_charge']['payment_method_details'] = ['type' => $type]; + RecordingStripeHttpClient::withResponses([$response]); + + $result = $this->getInvoice(); + + $this->assertSame($expected, $result->paymentMethod); + $this->assertSame($expected ? [$expected] : null, $result->availablePaymentMethods); + } + + public function testRequiresCaptureReadsAsAuthorizedAndProcessingAsProcessing(): void + { + RecordingStripeHttpClient::withResponses([$this->paidCardPaymentIntentResponse(status: 'requires_capture')]); + $authorized = $this->getInvoice()->status; + + RecordingStripeHttpClient::withResponses([$this->paidCardPaymentIntentResponse(status: 'processing')]); + $processing = $this->getInvoice()->status; + + $this->assertSame(InvoiceStatus::AUTHORIZED, $authorized); + $this->assertTrue($authorized->isOpen()); + $this->assertFalse($authorized->isSettled()); + $this->assertSame(InvoiceStatus::PROCESSING, $processing); + $this->assertTrue($processing->isOpen()); + $this->assertFalse($processing->isSettled()); + } + + /** + * Status de dispute em aberto, conforme docs.stripe.com/api/disputes/object. + */ + public static function openDisputeStatusProvider(): array + { + return [ + ['warning_needs_response'], + ['warning_under_review'], + ['needs_response'], + ['under_review'], + ]; + } + + #[DataProvider('openDisputeStatusProvider')] + public function testGetInvoiceReportsOpenDisputeAsDisputed(string $disputeStatus): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->disputedCardPaymentIntentResponse(), + $this->disputeListResponse([$disputeStatus]), + ]); + + $result = $this->getInvoice(); + + $this->assertSame(InvoiceStatus::DISPUTED, $result->status); + $this->assertNotSame(InvoiceStatus::PAID, $result->status); + // o dinheiro continua contabilizado no charge até a resolução + $this->assertSame(12345, $result->paidAmount); + + $this->assertCount(2, $httpClient->calls); + [$method, $url, $params] = $httpClient->calls[1]; + $this->assertSame('get', $method); + $this->assertSame('/v1/disputes', parse_url($url, PHP_URL_PATH)); + $this->assertSame(['charge' => 'ch_fake123', 'limit' => 100], $params); + } + + public function testGetInvoiceReportsLostDisputeAsChargeback(): void + { + RecordingStripeHttpClient::withResponses([ + $this->disputedCardPaymentIntentResponse(), + $this->disputeListResponse(['lost']), + ]); + + $result = $this->getInvoice(); + + $this->assertSame(InvoiceStatus::CHARGEBACK, $result->status); + $this->assertNotSame(InvoiceStatus::REFUNDED, $result->status); + $this->assertTrue($result->status->isContested()); + $this->assertFalse($result->status->isSettled()); + } + + /** + * Status de dispute encerrada sem devolução ao cliente: a fatura volta ao status normal. + */ + public static function closedDisputeStatusProvider(): array + { + return [ + ['won'], + ['warning_closed'], + ['prevented'], + ]; + } + + #[DataProvider('closedDisputeStatusProvider')] + public function testGetInvoiceKeepsDerivedStatusWhenDisputeWasWonOrClosed(string $disputeStatus): void + { + RecordingStripeHttpClient::withResponses([ + $this->disputedCardPaymentIntentResponse(), + $this->disputeListResponse([$disputeStatus]), + ]); + + $this->assertSame(InvoiceStatus::PAID, $this->getInvoice()->status); + } + + public static function disputeOverRefundProvider(): array + { + return [ + 'aberta sobre estorno parcial' => ['needs_response', 2345, false, InvoiceStatus::DISPUTED], + 'perdida sobre estorno total' => ['lost', 12345, true, InvoiceStatus::CHARGEBACK], + ]; + } + + #[DataProvider('disputeOverRefundProvider')] + public function testGetInvoiceDisputeTakesPrecedenceOverRefund(string $disputeStatus, int $refunded, bool $fully, InvoiceStatus $expected): void + { + $response = $this->disputedCardPaymentIntentResponse(); + $response['latest_charge']['amount_refunded'] = $refunded; + $response['latest_charge']['refunded'] = $fully; + RecordingStripeHttpClient::withResponses([ + $response, + $this->disputeListResponse([$disputeStatus]), + ]); + + $result = $this->getInvoice(); + + $this->assertSame($expected, $result->status); + $this->assertSame($refunded, $result->refundedAmount); + } + + public function testGetInvoiceFallsBackToDerivedStatusWhenDisputedChargeHasNoDisputes(): void + { + // a flag pode chegar antes da dispute aparecer na listagem; sem dispute a fatura + // segue a derivação normal + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->disputedCardPaymentIntentResponse(), + $this->disputeListResponse([]), + ]); + + $this->assertSame(InvoiceStatus::PAID, $this->getInvoice()->status); + $this->assertCount(2, $httpClient->calls); + } + + public function testGetInvoiceOpenDisputeWinsOverAnEarlierWonOne(): void + { + RecordingStripeHttpClient::withResponses([ + $this->disputedCardPaymentIntentResponse(), + $this->disputeListResponse(['won', 'needs_response']), + ]); + + $this->assertSame(InvoiceStatus::DISPUTED, $this->getInvoice()->status); + } + + public function testGetInvoiceDoesNotListDisputesWhenChargeIsNotDisputed(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->paidCardPaymentIntentResponse()]); + + $this->assertSame(InvoiceStatus::PAID, $this->getInvoice()->status); + $this->assertCount(1, $httpClient->calls); + } + + public function testGetInvoiceDoesNotListDisputesForFailedCharge(): void + { + // charge failed marcado como disputed não existe na prática, mas o parse só consulta + // disputes de charge pago + $response = $this->disputedCardPaymentIntentResponse(); + $response['status'] = 'requires_payment_method'; + $response['latest_charge']['status'] = 'failed'; + $httpClient = RecordingStripeHttpClient::withResponses([$response]); + + $this->assertSame(InvoiceStatus::PENDING, $this->getInvoice()->status); + $this->assertCount(1, $httpClient->calls); + } + + public function testChargeInvoiceWithCreditCardUpdatesIntentBeforeConfirming(): void + { + // PI sem customer + PaymentMethod salvo: o customer do dono do cartão é vinculado + $pendingIntent = $this->paidCardPaymentIntentResponse(status: 'requires_payment_method'); + $pendingIntent['customer'] = null; + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->paymentMethodResponse(customer: 'cus_fake123'), + $pendingIntent, + $this->paidCardPaymentIntentResponse(), + $this->paidCardPaymentIntentResponse(), + ]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_fake123'; + $result = (new StripeGateway())->chargeInvoiceWithCreditCard($invoice); + + $paths = array_map(static fn ($call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), $httpClient->calls); + $this->assertSame([ + 'get /v1/payment_methods/pm_fake123', + 'get /v1/payment_intents/pi_fake123', + 'post /v1/payment_intents/pi_fake123', + 'post /v1/payment_intents/pi_fake123/confirm', + ], $paths); + $this->assertSame( + ['payment_method_types' => ['card'], 'customer' => 'cus_fake123'], + $httpClient->calls[2][2] + ); + $this->assertSame('pm_fake123', $httpClient->calls[3][2]['payment_method']); + $this->assertSame('true', $httpClient->calls[3][2]['off_session']); + $this->assertSame(InvoiceStatus::PAID, $result->status); + } + + public function testChargeInvoiceWithMatchingCustomerResendsItInTheUpdate(): void + { + // PI e PaymentMethod do mesmo customer: o update repete o customer, sem efeito na Stripe, + // para o payload ser o mesmo num retry com a mesma chave de idempotência + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->paymentMethodResponse(customer: 'cus_fake123'), + $this->paidCardPaymentIntentResponse(status: 'requires_payment_method'), + $this->paidCardPaymentIntentResponse(), + $this->paidCardPaymentIntentResponse(), + ]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_fake123'; + (new StripeGateway())->chargeInvoiceWithCreditCard($invoice); + + $this->assertSame(['payment_method_types' => ['card'], 'customer' => 'cus_fake123'], $httpClient->calls[2][2]); + } + + public function testChargeInvoiceRejectsCardFromAnotherCustomer(): void + { + RecordingStripeHttpClient::withResponses([ + $this->paymentMethodResponse(customer: 'cus_other'), + $this->paidCardPaymentIntentResponse(status: 'requires_payment_method'), + ]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_fake123'; + + try { + (new StripeGateway())->chargeInvoiceWithCreditCard($invoice); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertStringContainsString('does not belong to customer', $e->getMessage()); + $this->assertSame(Capability::CREDIT_CARD, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertNull($e->httpStatus); + } + } + + public function testChargeInvoiceWithLegacyTokenConvertsItIntoPaymentMethod(): void + { + $pendingIntent = $this->paidCardPaymentIntentResponse(status: 'requires_payment_method'); + $pendingIntent['customer'] = null; + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->paymentMethodResponse(), + $this->paymentMethodResponse(), + $pendingIntent, + $this->paidCardPaymentIntentResponse(), + $this->paidCardPaymentIntentResponse(), + ]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->token = 'tok_fake123'; + (new StripeGateway())->chargeInvoiceWithCreditCard($invoice); + + $paths = array_map(static fn ($call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), $httpClient->calls); + $this->assertSame([ + 'post /v1/payment_methods', + 'get /v1/payment_methods/pm_fake123', + 'get /v1/payment_intents/pi_fake123', + 'post /v1/payment_intents/pi_fake123', + 'post /v1/payment_intents/pi_fake123/confirm', + ], $paths); + $this->assertSame('pm_fake123', $httpClient->calls[4][2]['payment_method']); + } + + public function testGatewayOptionsOverrideAndExpandIsMerged(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->paidCardPaymentIntentResponse()]); + + $invoice = $this->creditCardInvoiceModel(); + $invoice->gatewayOptions = [ + 'statement_descriptor_suffix' => 'POTELO', + 'off_session' => false, + 'expand' => ['customer'], + ]; + (new StripeGateway())->createInvoice($invoice); + + $params = $httpClient->calls[0][2]; + $this->assertSame('POTELO', $params['statement_descriptor_suffix']); + // a opção do consumidor vence a chave montada pelo gateway + $this->assertSame('false', $params['off_session']); + // o expand do consumidor é mesclado, não descartado + $this->assertSame(['customer', 'latest_charge.balance_transaction', 'latest_charge.refunds'], $params['expand']); + } + + /** + * Status do PaymentIntent sem estorno mapeado para o status genérico. + * + * @return array[] + */ + public static function paymentIntentStatusDataProvider(): array + { + return [ + ['succeeded', InvoiceStatus::PAID], + ['canceled', InvoiceStatus::CANCELED], + ['processing', InvoiceStatus::PROCESSING], + ['requires_action', InvoiceStatus::PENDING], + ['requires_confirmation', InvoiceStatus::PENDING], + ['requires_capture', InvoiceStatus::AUTHORIZED], + ['requires_payment_method', InvoiceStatus::PENDING], + ]; + } + + #[DataProvider('paymentIntentStatusDataProvider')] + public function testStatusMapping(string $stripeStatus, InvoiceStatus $expected): void + { + $response = $this->paidCardPaymentIntentResponse(status: $stripeStatus); + RecordingStripeHttpClient::withResponses([$response]); + + $this->assertSame($expected, $this->getInvoice()->status); + } + + public function testExplicitAmountTakesPrecedenceOverItemsSum(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->paidCardPaymentIntentResponse()]); + + $invoice = $this->creditCardInvoiceModel(); + $invoice->amount = 999; + (new StripeGateway())->createInvoice($invoice); + + $this->assertSame(999, $httpClient->calls[0][2]['amount']); + } + + public function testAmountFallsBackToItemsSumMultiplyingQuantity(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->paidCardPaymentIntentResponse()]); + + $invoice = $this->creditCardInvoiceModel(); + $invoice->items[0]->quantity = 3; + (new StripeGateway())->createInvoice($invoice); + + $this->assertSame(37035, $httpClient->calls[0][2]['amount']); + } + + public function testChargeInvoiceWithCreditCardRequiresTokenOrId(): void + { + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $invoice->creditCard = new CreditCard(); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('Credit card token or id is required'); + + (new StripeGateway())->chargeInvoiceWithCreditCard($invoice); + } + + /** + * Só com o id, o driver lê a fatura antes das guardas (um GET), cria o refund e relê o + * PaymentIntent. O `Refund` devolvido vem do objeto da Stripe e carrega a fatura relida. + */ + public function testRefundsInvoiceTotally(): void + { + $refunded = $this->paidCardPaymentIntentResponse(); + $refunded['latest_charge']['amount_refunded'] = 12345; + $refunded['latest_charge']['refunded'] = true; + $refunded['latest_charge']['refunds'] = $this->refundListResponse([ + $this->refundResponse('re_fake123', 12345, 'succeeded'), + ]); + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->paidCardPaymentIntentResponse(), + $this->refundResponse('re_fake123', 12345, 'pending'), + $refunded, + ]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $result = (new StripeGateway())->refundInvoice($invoice); + + $this->assertSame([ + 'get /v1/payment_intents/pi_fake123', + 'post /v1/refunds', + 'get /v1/payment_intents/pi_fake123', + ], $this->calledPaths($httpClient)); + // sem amount: estorno total + $this->assertSame(['payment_intent' => 'pi_fake123'], $httpClient->calls[1][2]); + + $this->assertInstanceOf(Refund::class, $result); + $this->assertSame('re_fake123', $result->id); + $this->assertSame('pi_fake123', $result->invoiceId); + $this->assertSame(12345, $result->amount); + $this->assertSame(RefundStatus::PENDING, $result->status); + $this->assertSame(1786700100, $result->createdAt->getTimestamp()); + $this->assertSame('requested_by_customer', $result->reason); + $this->assertSame('stripe', $result->gateway); + $this->assertSame('re_fake123', $result->original->id); + + $this->assertSame($invoice, $result->invoice()); + $this->assertSame(InvoiceStatus::REFUNDED, $invoice->status); + $this->assertSame(12345, $invoice->refundedAmount); + $this->assertCount(1, $invoice->refunds); + $this->assertSame('re_fake123', $invoice->refunds[0]->id); + $this->assertSame(RefundStatus::SUCCEEDED, $invoice->refunds[0]->status); + $this->assertCount(3, $httpClient->calls, 'invoice() não faz requisição'); + } + + public function testRefundsInvoicePartially(): void + { + $refunded = $this->paidCardPaymentIntentResponse(); + $refunded['latest_charge']['amount_refunded'] = 2345; + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->paidCardPaymentIntentResponse(), + $this->refundResponse('re_fake123', 2345, 'pending'), + $refunded, + ]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $result = (new StripeGateway())->refundInvoice($invoice, 2345); + + $this->assertSame( + ['payment_intent' => 'pi_fake123', 'amount' => 2345], + $httpClient->calls[1][2] + ); + $this->assertSame(2345, $result->amount); + $this->assertSame(InvoiceStatus::PARTIALLY_REFUNDED, $result->invoice()->status); + $this->assertSame(2345, $result->invoice()->refundedAmount); + } + + public function testRefundInvoiceRequiresId(): void + { + $this->expectException(ModelAttributeValidationException::class); + + (new StripeGateway())->refundInvoice(new Invoice()); + } + + public function testBoletoRefundWithThePaymentMethodInHandMakesNoRequest(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $invoice->paymentMethod = PaymentMethod::BANK_SLIP; + $invoice->status = InvoiceStatus::PAID; + + $exception = $this->refundExpectingRefusal($invoice); + + $this->assertSame(RefundNotSupportedException::REASON_BOLETO_NO_REFUND, $exception->reason); + $this->assertSame(PaymentMethod::BANK_SLIP->value, $exception->paymentMethod); + $this->assertTrue($exception->manualRefundRequired); + $this->assertSame([], $httpClient->calls); + } + + public function testBoletoRefundThrowsBeforePostingAfterReadingTheInvoice(): void + { + $response = $this->paidCardPaymentIntentResponse(); + $response['payment_method_types'] = ['boleto']; + $response['latest_charge']['payment_method_details'] = ['type' => 'boleto', 'boleto' => []]; + $httpClient = RecordingStripeHttpClient::withResponses([$response]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $exception = $this->refundExpectingRefusal($invoice); + + $this->assertSame(RefundNotSupportedException::REASON_BOLETO_NO_REFUND, $exception->reason); + $this->assertSame(['get /v1/payment_intents/pi_fake123'], $this->calledPaths($httpClient)); + } + + public function testAlreadyRefundedInvoiceWithTheStatusInHandMakesNoRequest(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $invoice->paymentMethod = PaymentMethod::CREDIT_CARD; + $invoice->status = InvoiceStatus::REFUNDED; + + $exception = $this->refundExpectingRefusal($invoice); + + $this->assertSame(RefundNotSupportedException::REASON_ALREADY_REFUNDED, $exception->reason); + $this->assertSame(PaymentMethod::CREDIT_CARD->value, $exception->paymentMethod); + $this->assertFalse($exception->manualRefundRequired); + $this->assertSame([], $httpClient->calls); + } + + /** + * Só com o id, a leitura prévia é o que faz a guarda de fatura já estornada disparar sem + * um POST que a Stripe recusaria com `charge_already_refunded`. + */ + public function testAlreadyRefundedInvoiceWithOnlyTheIdIsRefusedAfterReadingIt(): void + { + $response = $this->paidCardPaymentIntentResponse(); + $response['latest_charge']['amount_refunded'] = 12345; + $response['latest_charge']['refunded'] = true; + $httpClient = RecordingStripeHttpClient::withResponses([$response]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $exception = $this->refundExpectingRefusal($invoice); + + $this->assertSame(RefundNotSupportedException::REASON_ALREADY_REFUNDED, $exception->reason); + $this->assertSame(['get /v1/payment_intents/pi_fake123'], $this->calledPaths($httpClient)); + $this->assertNull($invoice->status, 'a leitura prévia não altera o model do chamador'); + } + + /** + * Pix parcial é permitido na Stripe: a guarda da Iugu não pode vazar para cá. + */ + public function testPartialPixRefundGoesToTheGateway(): void + { + $refunded = $this->paidPixPaymentIntentResponse(); + $refunded['latest_charge']['amount_refunded'] = 2345; + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->paidPixPaymentIntentResponse(), + $this->refundResponse('re_fake123', 2345, 'succeeded'), + $refunded, + ]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $invoice->paymentMethod = PaymentMethod::PIX; + $result = (new StripeGateway())->refundInvoice($invoice, 2345); + + $this->assertSame([ + 'get /v1/payment_intents/pi_fake123', + 'post /v1/refunds', + 'get /v1/payment_intents/pi_fake123', + ], $this->calledPaths($httpClient)); + $this->assertSame(['payment_intent' => 'pi_fake123', 'amount' => 2345], $httpClient->calls[1][2]); + $this->assertSame('re_fake123', $result->id); + $this->assertSame(RefundStatus::SUCCEEDED, $result->status); + $this->assertSame(InvoiceStatus::PARTIALLY_REFUNDED, $result->invoice()->status); + $this->assertSame(2345, $result->invoice()->refundedAmount); + } + + /** + * Um model lido do gateway, pago e sem estorno anterior não paga o GET extra no estorno + * por valor: o restante estornável é o valor pago. + */ + public function testPaidInvoiceReadFromTheGatewayDoesNotPayTheExtraGet(): void + { + $refunded = $this->paidCardPaymentIntentResponse(); + $refunded['latest_charge']['amount_refunded'] = 2345; + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->paidCardPaymentIntentResponse(), + $this->refundResponse('re_fake123', 2345, 'succeeded'), + $refunded, + ]); + $gateway = new StripeGateway(); + + $invoice = $gateway->getInvoice($this->invoiceWithId()); + $result = $gateway->refundInvoice($invoice, 2345); + + $this->assertSame([ + 'get /v1/payment_intents/pi_fake123', + 'post /v1/refunds', + 'get /v1/payment_intents/pi_fake123', + ], $this->calledPaths($httpClient)); + $this->assertSame(2345, $result->amount); + } + + /** + * Fatura parcialmente estornada aceita novo estorno até o restante. Com o status fora de + * `PAID` e sem o acumulado em `refundedAmount`, o driver relê a fatura para conhecer o + * restante. + */ + public function testSecondPartialRefundWithinTheRemainderGoesToTheGateway(): void + { + $partiallyRefunded = $this->paidCardPaymentIntentResponse(); + $partiallyRefunded['latest_charge']['amount_refunded'] = 2345; + $refunded = $this->paidCardPaymentIntentResponse(); + $refunded['latest_charge']['amount_refunded'] = 7345; + $httpClient = RecordingStripeHttpClient::withResponses([ + $partiallyRefunded, + $this->refundResponse('re_fake456', 5000, 'succeeded'), + $refunded, + ]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $invoice->paymentMethod = PaymentMethod::CREDIT_CARD; + $invoice->status = InvoiceStatus::PARTIALLY_REFUNDED; + $invoice->paidAmount = 12345; + $result = (new StripeGateway())->refundInvoice($invoice, 5000); + + $this->assertSame([ + 'get /v1/payment_intents/pi_fake123', + 'post /v1/refunds', + 'get /v1/payment_intents/pi_fake123', + ], $this->calledPaths($httpClient)); + $this->assertSame(['payment_intent' => 'pi_fake123', 'amount' => 5000], $httpClient->calls[1][2]); + $this->assertSame('re_fake456', $result->id); + $this->assertSame(5000, $result->amount); + $this->assertSame(InvoiceStatus::PARTIALLY_REFUNDED, $result->invoice()->status); + $this->assertSame(7345, $result->invoice()->refundedAmount); + } + + /** + * Sem leitura prévia (model em `PAID` com `paidAmount`), o restante é o valor pago e a + * recusa acontece sem nenhuma requisição além da leitura inicial. + */ + public function testRefundAboveThePaidAmountOnAModelReadFromTheGatewayThrowsWithoutAnotherRequest(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([$this->paidCardPaymentIntentResponse()]); + $gateway = new StripeGateway(); + + $invoice = $gateway->getInvoice($this->invoiceWithId()); + + try { + $gateway->refundInvoice($invoice, 12346); + $this->fail('Esperava RefundNotSupportedException'); + } catch (RefundNotSupportedException $e) { + $this->assertSame(RefundNotSupportedException::REASON_AMOUNT_EXCEEDS_REFUNDABLE, $e->reason); + $this->assertStringContainsString('12345', $e->getMessage()); + } + $this->assertSame(['get /v1/payment_intents/pi_fake123'], $this->calledPaths($httpClient)); + } + + /** + * Regressão: a leitura prévia parseia uma cópia, e a cópia precisa ser profunda, senão o + * `customer` do model do chamador recebe os dados da resposta mesmo quando a guarda dispara. + */ + public function testRefusedRefundLeavesTheCallerNestedObjectsUntouched(): void + { + $partiallyRefunded = $this->paidCardPaymentIntentResponse(); + $partiallyRefunded['latest_charge']['amount_refunded'] = 2345; + RecordingStripeHttpClient::withResponses([$partiallyRefunded]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $invoice->customer = new Customer(); + $invoice->customer->name = 'Nome do chamador'; + $invoice->creditCard = new CreditCard(); + $this->refundExpectingRefusal($invoice, 11000); + + $this->assertSame('Nome do chamador', $invoice->customer->name); + $this->assertNull($invoice->customer->id); + $this->assertNull($invoice->creditCard->brand); + } + + /** + * Model lido do gateway em `partially_refunded`: `refundInvoice()` sem valor estorna o + * restante, sem enviar `amount` (o acumulado em `refundedAmount` é só leitura). + */ + public function testRefundWithoutAmountOnAPartiallyRefundedModelReadFromTheGatewayRefundsTheRemainder(): void + { + $partiallyRefunded = $this->paidCardPaymentIntentResponse(); + $partiallyRefunded['latest_charge']['amount_refunded'] = 2345; + $refunded = $this->paidCardPaymentIntentResponse(); + $refunded['latest_charge']['amount_refunded'] = 12345; + $refunded['latest_charge']['refunded'] = true; + $httpClient = RecordingStripeHttpClient::withResponses([ + $partiallyRefunded, + $this->refundResponse('re_fake456', 10000, 'succeeded'), + $refunded, + ]); + $gateway = new StripeGateway(); + + $invoice = $gateway->getInvoice($this->invoiceWithId()); + $result = $gateway->refundInvoice($invoice); + + $this->assertSame([ + 'get /v1/payment_intents/pi_fake123', + 'post /v1/refunds', + 'get /v1/payment_intents/pi_fake123', + ], $this->calledPaths($httpClient)); + $this->assertSame(['payment_intent' => 'pi_fake123'], $httpClient->calls[1][2]); + $this->assertSame(10000, $result->amount); + $this->assertSame(InvoiceStatus::REFUNDED, $invoice->status); + $this->assertSame(12345, $invoice->refundedAmount); + } + + /** + * Model lido do gateway em `partially_refunded` traz o acumulado confiável, então o estorno + * por valor não paga o GET extra: o restante é `paidAmount` menos `refundedAmount`. + */ + public function testPartialRefundOnAPartiallyRefundedModelReadFromTheGatewayDoesNotPayTheExtraGet(): void + { + $partiallyRefunded = $this->paidCardPaymentIntentResponse(); + $partiallyRefunded['latest_charge']['amount_refunded'] = 2345; + $refundedTwice = $this->paidCardPaymentIntentResponse(); + $refundedTwice['latest_charge']['amount_refunded'] = 7345; + $httpClient = RecordingStripeHttpClient::withResponses([ + $partiallyRefunded, + $this->refundResponse('re_fake456', 5000, 'succeeded'), + $refundedTwice, + ]); + $gateway = new StripeGateway(); + + $invoice = $gateway->getInvoice($this->invoiceWithId()); + $result = $gateway->refundInvoice($invoice, 5000); + + $this->assertSame([ + 'get /v1/payment_intents/pi_fake123', + 'post /v1/refunds', + 'get /v1/payment_intents/pi_fake123', + ], $this->calledPaths($httpClient)); + $this->assertSame(['payment_intent' => 'pi_fake123', 'amount' => 5000], $httpClient->calls[1][2]); + $this->assertSame(5000, $result->amount); + $this->assertSame(7345, $invoice->refundedAmount); + } + + /** + * Caminho antigo: escrever `refundedAmount` num model lido do gateway continua pedindo o + * estorno parcial desse valor, com aviso de deprecação; como o acumulado deixou de ser + * confiável, o driver relê a fatura antes. + */ + public function testWritingRefundedAmountOnAModelReadFromTheGatewayStillRequestsThatPartialRefund(): void + { + $partiallyRefunded = $this->paidCardPaymentIntentResponse(); + $partiallyRefunded['latest_charge']['amount_refunded'] = 2345; + $refundedTwice = $this->paidCardPaymentIntentResponse(); + $refundedTwice['latest_charge']['amount_refunded'] = 4690; + $httpClient = RecordingStripeHttpClient::withResponses([ + $partiallyRefunded, + $partiallyRefunded, + $this->refundResponse('re_fake456', 2345, 'succeeded'), + $refundedTwice, + ]); + $gateway = new StripeGateway(); + $invoice = $gateway->getInvoice($this->invoiceWithId()); + + $this->expectUserDeprecationMessage('Invoice::$refundedAmount é só de leitura desde 2026-09-02; passe o valor do estorno em refund(amount:) ou refundInvoice($id, $amount)'); + $invoice->refundedAmount = 2345; + $result = $gateway->refundInvoice($invoice); + + $this->assertSame('post /v1/refunds', $this->calledPaths($httpClient)[2]); + $this->assertSame(['payment_intent' => 'pi_fake123', 'amount' => 2345], $httpClient->calls[2][2]); + $this->assertSame(2345, $result->amount); + $this->assertSame(4690, $invoice->refundedAmount); + $this->assertNull($invoice->requestedRefundAmount()); + } + + /** + * Model em `PAID` com `paidAmount` e sem acumulado estornado é confiável: o restante é o + * valor pago, e o estorno por valor não relê a fatura. + */ + public function testPartialRefundOnAPaidModelWithThePaidAmountInHandDoesNotReadTheInvoiceFirst(): void + { + $partiallyRefunded = $this->paidCardPaymentIntentResponse(); + $partiallyRefunded['latest_charge']['amount_refunded'] = 2345; + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->refundResponse('re_fake456', 2345, 'succeeded'), + $partiallyRefunded, + ]); + $invoice = $this->invoiceWithId(); + $invoice->paymentMethod = PaymentMethod::CREDIT_CARD; + $invoice->status = InvoiceStatus::PAID; + $invoice->paidAmount = 12345; + + $result = (new StripeGateway())->refundInvoice($invoice, 2345); + + $this->assertSame(['post /v1/refunds', 'get /v1/payment_intents/pi_fake123'], $this->calledPaths($httpClient)); + $this->assertSame(['payment_intent' => 'pi_fake123', 'amount' => 2345], $httpClient->calls[0][2]); + $this->assertSame(2345, $result->amount); + } + + public function testRefundAboveThePaidAmountOnAPaidModelWithThePaidAmountInHandIsRefusedWithoutAnyRequest(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $invoice = $this->invoiceWithId(); + $invoice->paymentMethod = PaymentMethod::CREDIT_CARD; + $invoice->status = InvoiceStatus::PAID; + $invoice->paidAmount = 12345; + + $exception = $this->refundExpectingRefusal($invoice, 12346); + + $this->assertSame(RefundNotSupportedException::REASON_AMOUNT_EXCEEDS_REFUNDABLE, $exception->reason); + $this->assertSame([], $httpClient->calls); + } + + public function testZeroOrNegativeAmountIsRejectedBeforeAnyRequest(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + + foreach ([0, -1] as $amount) { + try { + (new StripeGateway())->refundInvoice($this->invoiceWithId(), $amount); + $this->fail("Esperava ModelAttributeValidationException para {$amount}"); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('amount', $e->getMessage()); + } + } + + $this->assertSame([], $httpClient->calls); + } + + /** + * `refundableAmount()` é `paidAmount` menos `refundedAmount` (o valor pago da Stripe vem + * bruto); um model lido do gateway não paga requisição, um model só com o id lê a fatura, e + * um model com o acumulado escrito pelo caminho antigo relê a fatura. + */ + public function testRefundableAmountIsThePaidMinusTheRefundedAndReadsTheInvoiceOnlyWhenNeeded(): void + { + $partiallyRefunded = $this->paidCardPaymentIntentResponse(); + $partiallyRefunded['latest_charge']['amount_refunded'] = 2345; + $refunded = $this->paidCardPaymentIntentResponse(); + $refunded['latest_charge']['amount_refunded'] = 12345; + $refunded['latest_charge']['refunded'] = true; + $httpClient = RecordingStripeHttpClient::withResponses([ + $partiallyRefunded, + $partiallyRefunded, + $refunded, + $this->paidCardPaymentIntentResponse(status: 'requires_payment_method'), + ]); + $gateway = new StripeGateway(); + + $read = $gateway->getInvoice($this->invoiceWithId()); + $this->assertSame(10000, $gateway->refundableAmount($read)); + $this->assertCount(1, $httpClient->calls, 'o model lido do gateway não custa requisição'); + + $this->assertSame(10000, $gateway->refundableAmount($this->invoiceWithId())); + $this->assertSame(0, $gateway->refundableAmount($this->invoiceWithId())); + $this->assertSame(0, $gateway->refundableAmount($this->invoiceWithId()), 'fatura não paga'); + $this->assertCount(4, $httpClient->calls); + } + + public function testRefundableAmountReReadsTheInvoiceWhenTheLegacyPathWroteTheRefundedAmount(): void + { + $partiallyRefunded = $this->paidCardPaymentIntentResponse(); + $partiallyRefunded['latest_charge']['amount_refunded'] = 2345; + $httpClient = RecordingStripeHttpClient::withResponses([$partiallyRefunded, $partiallyRefunded]); + $gateway = new StripeGateway(); + $invoice = $gateway->getInvoice($this->invoiceWithId()); + + $this->expectUserDeprecationMessage('Invoice::$refundedAmount é só de leitura desde 2026-09-02; passe o valor do estorno em refund(amount:) ou refundInvoice($id, $amount)'); + $invoice->refundedAmount = 5000; + + $this->assertSame(10000, $gateway->refundableAmount($invoice)); + $this->assertCount(2, $httpClient->calls); + } + + public function testSecondPartialRefundAboveTheRemainderThrowsBeforePosting(): void + { + $partiallyRefunded = $this->paidCardPaymentIntentResponse(); + $partiallyRefunded['latest_charge']['amount_refunded'] = 2345; + $httpClient = RecordingStripeHttpClient::withResponses([$partiallyRefunded]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $exception = $this->refundExpectingRefusal($invoice, 11000); + + $this->assertSame(RefundNotSupportedException::REASON_AMOUNT_EXCEEDS_REFUNDABLE, $exception->reason); + $this->assertSame(PaymentMethod::CREDIT_CARD->value, $exception->paymentMethod); + $this->assertFalse($exception->manualRefundRequired); + $this->assertStringContainsString('11000', $exception->getMessage()); + $this->assertStringContainsString('10000', $exception->getMessage()); + $this->assertSame(['get /v1/payment_intents/pi_fake123'], $this->calledPaths($httpClient)); + $this->assertNull($invoice->refundedAmount, 'a leitura prévia não altera o model do chamador'); + } + + public function testRefundingTheExactRemainderOfAPartiallyRefundedInvoiceGoesToTheGateway(): void + { + $partiallyRefunded = $this->paidCardPaymentIntentResponse(); + $partiallyRefunded['latest_charge']['amount_refunded'] = 2345; + $refunded = $this->paidCardPaymentIntentResponse(); + $refunded['latest_charge']['amount_refunded'] = 12345; + $refunded['latest_charge']['refunded'] = true; + $httpClient = RecordingStripeHttpClient::withResponses([ + $partiallyRefunded, + $this->refundResponse('re_fake456', 10000, 'succeeded'), + $refunded, + ]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $invoice->status = InvoiceStatus::PARTIALLY_REFUNDED; + $result = (new StripeGateway())->refundInvoice($invoice, 10000); + + $this->assertSame('post', $httpClient->calls[1][0]); + $this->assertSame(['payment_intent' => 'pi_fake123', 'amount' => 10000], $httpClient->calls[1][2]); + $this->assertSame(InvoiceStatus::REFUNDED, $result->invoice()->status); + $this->assertSame('re_fake456', $result->id); + } + + public function testGetInvoiceListsTheRefundsOfTheCharge(): void + { + $response = $this->paidCardPaymentIntentResponse(); + $response['latest_charge']['amount_refunded'] = 5345; + $response['latest_charge']['refunds'] = $this->refundListResponse([ + $this->refundResponse('re_fake123', 2345, 'succeeded'), + $this->refundResponse('re_fake456', 3000, 'pending', 1786700200, null), + ]); + RecordingStripeHttpClient::withResponses([$response]); + + $result = $this->getInvoice(); + + $this->assertSame(InvoiceStatus::PARTIALLY_REFUNDED, $result->status); + $this->assertCount(2, $result->refunds); + $this->assertContainsOnlyInstancesOf(Refund::class, $result->refunds); + $this->assertSame('re_fake123', $result->refunds[0]->id); + $this->assertSame(2345, $result->refunds[0]->amount); + $this->assertSame(RefundStatus::SUCCEEDED, $result->refunds[0]->status); + $this->assertSame('requested_by_customer', $result->refunds[0]->reason); + $this->assertSame(1786700100, $result->refunds[0]->createdAt->getTimestamp()); + $this->assertSame('pi_fake123', $result->refunds[0]->invoiceId); + $this->assertSame('stripe', $result->refunds[0]->gateway); + $this->assertNull($result->refunds[0]->invoice, 'um Refund lido da fatura não carrega a fatura'); + $this->assertSame('re_fake456', $result->refunds[1]->id); + $this->assertSame(RefundStatus::PENDING, $result->refunds[1]->status); + $this->assertNull($result->refunds[1]->reason); + } + + public function testGetInvoiceWithoutRefundHasAnEmptyRefundsList(): void + { + RecordingStripeHttpClient::withResponses([$this->paidCardPaymentIntentResponse()]); + + $this->assertSame([], $this->getInvoice()->refunds); + } + + /** + * Resposta com estorno mas sem a lista `refunds` expandida: a lista não pode contradizer + * `refundedAmount`, então volta um único `Refund` sem id com o acumulado. + */ + public function testGetInvoiceWithoutTheRefundsListFallsBackToASingleRefundWithoutId(): void + { + $response = $this->paidCardPaymentIntentResponse(); + $response['latest_charge']['amount_refunded'] = 2345; + RecordingStripeHttpClient::withResponses([$response]); + + $loggerAnterior = \Stripe\Stripe::getLogger(); + $logger = new RecordingStripeLogger(); + \Stripe\Stripe::setLogger($logger); + + try { + $result = $this->getInvoice(); + } finally { + \Stripe\Stripe::setLogger($loggerAnterior); + } + + $this->assertSame([], $logger->messages, 'ler `refunds` ausente não pode logar Undefined property'); + $this->assertCount(1, $result->refunds); + $this->assertNull($result->refunds[0]->id); + $this->assertSame(2345, $result->refunds[0]->amount); + $this->assertSame(RefundStatus::SUCCEEDED, $result->refunds[0]->status); + } + + #[DataProvider('refundStatusProvider')] + public function testRefundStatusIsMappedToTheGenericEnum(string $stripeStatus, RefundStatus $expected): void + { + $response = $this->paidCardPaymentIntentResponse(); + $response['latest_charge']['amount_refunded'] = 2345; + $response['latest_charge']['refunds'] = $this->refundListResponse([ + $this->refundResponse('re_fake123', 2345, $stripeStatus), + ]); + RecordingStripeHttpClient::withResponses([$response]); + + $this->assertSame($expected, $this->getInvoice()->refunds[0]->status); + } + + public static function refundStatusProvider(): array + { + return [ + 'pending' => ['pending', RefundStatus::PENDING], + 'requires_action' => ['requires_action', RefundStatus::PENDING], + 'succeeded' => ['succeeded', RefundStatus::SUCCEEDED], + 'failed' => ['failed', RefundStatus::FAILED], + 'canceled' => ['canceled', RefundStatus::CANCELED], + ]; + } + + public function testRefundStatusOutsideTheMapIsReadAsUnknownWithAWarning(): void + { + Facade::getFacadeApplication()->instance('log', $logger = new RecordingLogger()); + $response = $this->paidCardPaymentIntentResponse(); + $response['latest_charge']['amount_refunded'] = 2345; + $response['latest_charge']['refunds'] = $this->refundListResponse([ + $this->refundResponse('re_fake123', 2345, 'status_novo'), + ]); + RecordingStripeHttpClient::withResponses([$response]); + + $this->assertSame(RefundStatus::UNKNOWN, $this->getInvoice()->refunds[0]->status); + $this->assertCount(1, $logger->records); + $this->assertSame('warning', $logger->records[0]['level']); + $this->assertSame(['status' => 'status_novo', 'gateway' => 'stripe'], $logger->records[0]['context']); + } + + public function testDuplicatesPendingPixInvoiceCancelingTheOriginal(): void + { + $newIntent = $this->pendingPixPaymentIntentResponse(); + $newIntent['id'] = 'pi_fake456'; + $canceled = $this->pendingPixPaymentIntentResponse(); + $canceled['status'] = 'canceled'; + $canceled['next_action'] = null; + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->pendingPixPaymentIntentResponse(), + $this->duplicableCustomerResponse(), + $newIntent, + $canceled, + ]); + + $expiresAt = Carbon::now()->addDay(); + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $result = (new StripeGateway())->duplicateInvoice($invoice, $expiresAt); + + $paths = array_map(static fn ($call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), $httpClient->calls); + $this->assertSame([ + 'get /v1/payment_intents/pi_fake123', + 'get /v1/customers/cus_fake123', + 'post /v1/payment_intents', + 'post /v1/payment_intents/pi_fake123/cancel', + ], $paths); + + // payload completo; o metadata é o da fatura original preservado (valores string, + // como a Stripe devolve), não a reserialização dos items + $this->assertSame([ + 'amount' => 12345, + 'currency' => 'brl', + 'customer' => 'cus_fake123', + 'metadata' => [ + 'item_0_description' => 'Assinatura mensal', + 'item_0_price' => '12345', + 'item_0_quantity' => '1', + ], + 'payment_method_types' => ['pix'], + 'payment_method_data' => [ + 'type' => 'pix', + 'billing_details' => [ + 'name' => 'Fake Customer', + 'email' => 'email@exemplo.com', + 'tax_id' => '20176996915', + ], + ], + 'confirm' => 'true', + 'payment_method_options' => ['pix' => ['expires_at' => $expiresAt->getTimestamp()]], + 'expand' => ['latest_charge.balance_transaction', 'latest_charge.refunds'], + ], $httpClient->calls[2][2]); + + $this->assertSame('pi_fake456', $result->id); + $this->assertSame(InvoiceStatus::PENDING, $result->status); + } + + public function testDuplicateFallsBackToOriginalBillingTaxIdWhenCustomerHasNone(): void + { + $original = $this->pendingPixPaymentIntentResponse(); + $original['payment_method'] = [ + 'id' => 'pm_pix_fake', + 'object' => 'payment_method', + 'type' => 'pix', + 'billing_details' => ['name' => 'Fake Customer', 'email' => 'email@exemplo.com', 'tax_id' => '201.769.969-15'], + ]; + $customer = $this->duplicableCustomerResponse(); + $customer['tax_ids']['data'] = []; + $newIntent = $this->pendingPixPaymentIntentResponse(); + $newIntent['id'] = 'pi_fake456'; + $canceled = $this->pendingPixPaymentIntentResponse(); + $canceled['status'] = 'canceled'; + $canceled['next_action'] = null; + $httpClient = RecordingStripeHttpClient::withResponses([$original, $customer, $newIntent, $canceled]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + (new StripeGateway())->duplicateInvoice($invoice, Carbon::now()->addDay()); + + $this->assertSame( + '201.769.969-15', + $httpClient->calls[2][2]['payment_method_data']['billing_details']['tax_id'] + ); + } + + public function testDuplicateWithoutAnyTaxDocumentFailsWithoutCancelingTheOriginal(): void + { + $customer = $this->duplicableCustomerResponse(); + $customer['tax_ids']['data'] = []; + $original = $this->pendingPixPaymentIntentResponse(); + $original['payment_method'] = null; + $original['last_payment_error'] = null; + $httpClient = RecordingStripeHttpClient::withResponses([$original, $customer]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + + try { + (new StripeGateway())->duplicateInvoice($invoice, Carbon::now()->addDay()); + $this->fail('Expected ModelAttributeValidationException was not thrown'); + } catch (ModelAttributeValidationException $exception) { + // a original não pode ter sido cancelada — só os dois GETs aconteceram + $this->assertCount(2, $httpClient->calls); + } + } + + public function testDuplicatePassesGatewayOptionsToTheNewIntent(): void + { + $newIntent = $this->pendingPixPaymentIntentResponse(); + $newIntent['id'] = 'pi_fake456'; + $canceled = $this->pendingPixPaymentIntentResponse(); + $canceled['status'] = 'canceled'; + $canceled['next_action'] = null; + $httpClient = RecordingStripeHttpClient::withResponses([ + $this->pendingPixPaymentIntentResponse(), + $this->duplicableCustomerResponse(), + $newIntent, + $canceled, + ]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + (new StripeGateway())->duplicateInvoice($invoice, Carbon::now()->addDay(), ['statement_descriptor' => 'DUP']); + + $this->assertSame('DUP', $httpClient->calls[2][2]['statement_descriptor']); + } + + public function testDuplicateReportsTheNewInvoiceWhenCancelingTheOriginalFails(): void + { + $newIntent = $this->pendingPixPaymentIntentResponse(); + $newIntent['id'] = 'pi_fake456'; + RecordingStripeHttpClient::withResponses([ + $this->pendingPixPaymentIntentResponse(), + $this->duplicableCustomerResponse(), + $newIntent, + [['error' => [ + 'type' => 'invalid_request_error', + 'code' => 'payment_intent_unexpected_state', + 'message' => 'This PaymentIntent could not be canceled.', + ]], 400], + ]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + + try { + (new StripeGateway())->duplicateInvoice($invoice, Carbon::now()->addDay()); + $this->fail('Esperava GatewayException'); + } catch (GatewayException $e) { + $this->assertStringContainsString('Invoice duplicated as [pi_fake456]', $e->getMessage()); + // a falha do cancelamento continua acessível, com a exceção do SDK abaixo dela + $this->assertInstanceOf(ValidationException::class, $e->getPrevious()); + $this->assertInstanceOf(InvalidRequestException::class, $e->getPrevious()->getPrevious()); + $this->assertSame(400, $e->httpStatus); + } + } + + public function testDuplicateRejectsPaidInvoice(): void + { + RecordingStripeHttpClient::withResponses([$this->paidCardPaymentIntentResponse()]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + + try { + (new StripeGateway())->duplicateInvoice($invoice, Carbon::now()->addDay()); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::INVOICE_DUPLICATION, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertSame('No Stripe só uma fatura Pix pendente pode ser duplicada; a fatura [pi_fake123] está [paid].', $e->getMessage()); + } + } + + /** + * Fatura sem cliente no PaymentIntent é restrição de `INVOICE_DUPLICATION`: a recusa vem + * depois da leitura da original e antes de criar ou cancelar qualquer coisa. + */ + public function testDuplicateRejectsAnInvoiceWithoutCustomerAsARestriction(): void + { + $pendingPix = $this->pendingPixPaymentIntentResponse(); + $pendingPix['customer'] = null; + $httpClient = RecordingStripeHttpClient::withResponses([$pendingPix]); + + try { + (new StripeGateway())->duplicateInvoice($this->invoiceWithId(), Carbon::parse('2026-10-01')); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::INVOICE_DUPLICATION, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertNull($e->httpStatus); + $this->assertStringContainsString('has no customer', $e->getMessage()); + } + $this->assertSame(['get /v1/payment_intents/pi_fake123'], $this->calledPaths($httpClient), 'a original não é cancelada'); + } + + public function testDuplicateRejectsNonPixInvoice(): void + { + $response = $this->paidCardPaymentIntentResponse(status: 'requires_payment_method'); + $response['latest_charge'] = null; + RecordingStripeHttpClient::withResponses([$response]); + + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + + try { + (new StripeGateway())->duplicateInvoice($invoice, Carbon::now()->addDay()); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::INVOICE_DUPLICATION, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertStringContainsString('a fatura [pi_fake123] não é Pix', $e->getMessage()); + } + } + + public function testDuplicateInvoiceRequiresId(): void + { + $this->expectException(ModelAttributeValidationException::class); + + (new StripeGateway())->duplicateInvoice(new Invoice(), Carbon::now()->addDay()); + } + + private function duplicableCustomerResponse(): array + { + return [ + 'id' => 'cus_fake123', + 'object' => 'customer', + 'name' => 'Fake Customer', + 'email' => 'email@exemplo.com', + 'phone' => null, + 'address' => null, + 'metadata' => [], + 'created' => 1786700000, + 'invoice_settings' => ['default_payment_method' => null], + 'tax_ids' => [ + 'object' => 'list', + 'data' => [ + ['id' => 'txi_fake1', 'object' => 'tax_id', 'type' => 'br_cpf', 'value' => '20176996915'], + ], + ], + ]; + } + + private function invoiceWithId(): Invoice + { + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + + return $invoice; + } + + private function refundExpectingRefusal(Invoice $invoice, ?int $amount = null): RefundNotSupportedException + { + try { + (new StripeGateway())->refundInvoice($invoice, $amount); + } catch (RefundNotSupportedException $e) { + return $e; + } + + $this->fail('Esperava RefundNotSupportedException'); + } + + /** + * @return string[] método e caminho de cada chamada, na ordem + */ + private function calledPaths(RecordingStripeHttpClient $httpClient): array + { + return array_map( + static fn ($call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), + $httpClient->calls + ); + } + + private function refundResponse(string $id, int $amount, string $status, int $created = 1786700100, ?string $reason = 'requested_by_customer'): array + { + return [ + 'id' => $id, + 'object' => 'refund', + 'amount' => $amount, + 'currency' => 'brl', + 'status' => $status, + 'created' => $created, + 'reason' => $reason, + 'payment_intent' => 'pi_fake123', + 'charge' => 'ch_fake123', + ]; + } + + private function refundListResponse(array $refunds): array + { + return [ + 'object' => 'list', + 'data' => $refunds, + 'has_more' => false, + 'url' => '/v1/charges/ch_fake123/refunds', + ]; + } + + private function getInvoice(): Invoice + { + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + + return (new StripeGateway())->getInvoice($invoice); + } + + private function creditCardInvoiceModel(): Invoice + { + $invoice = new Invoice(); + $invoice->customer = new Customer(); + $invoice->customer->id = 'cus_fake123'; + $invoice->availablePaymentMethods = [PaymentMethod::CREDIT_CARD]; + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_fake123'; + $item = new InvoiceItem(); + $item->description = 'Assinatura mensal'; + $item->price = 12345; + $item->quantity = 1; + $invoice->items = [$item]; + + return $invoice; + } + + private function pixInvoiceModel(): Invoice + { + $invoice = $this->creditCardInvoiceModel(); + $invoice->creditCard = null; + $invoice->availablePaymentMethods = [PaymentMethod::PIX]; + $invoice->customer->name = 'Fake Customer'; + $invoice->customer->email = 'email@exemplo.com'; + $invoice->customer->taxDocument = '20176996915'; + $invoice->pixExpiresAt = Carbon::now()->addHour(); + + return $invoice; + } + + private function bankSlipInvoiceModel(): Invoice + { + $invoice = $this->pixInvoiceModel(); + $invoice->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; + $invoice->pixExpiresAt = null; + $invoice->customer->address = new \Potelo\MultiPayment\Models\Address(); + $invoice->customer->address->street = 'Av Paulista'; + $invoice->customer->address->number = '1234'; + $invoice->customer->address->city = 'Sao Paulo'; + $invoice->customer->address->state = 'SP'; + $invoice->customer->address->zipCode = '01310000'; + + return $invoice; + } + + /** + * Resposta gravada na sandbox em 2026-09-04 (ver o README das fixtures): PaymentIntent de + * boleto confirmado, em `requires_action` com o voucher em `boleto_display_details`. + */ + private function boletoPaymentIntentResponse(): array + { + $response = json_decode( + file_get_contents(__DIR__ . '/../../fixtures/stripe/payment_intents/boleto_requires_action.json'), + true + ); + $response['id'] = 'pi_fake123'; + + return $response; + } + + /** + * O StripeObject registra "Undefined property" no logger da Stripe ao ler uma chave + * ausente, e `?->` não protege contra isso porque o objeto pai existe. Em fatura pix + * paga, payment_method_details vem sem `card`, então o parse não pode acessar a chave + * às cegas sob pena de sujar o log de produção a cada fatura. + */ + public function testParsingPaidPixInvoiceDoesNotLogUndefinedProperty(): void + { + RecordingStripeHttpClient::withResponses([$this->paidPixPaymentIntentResponse()]); + + $loggerAnterior = \Stripe\Stripe::getLogger(); + $logger = new RecordingStripeLogger(); + \Stripe\Stripe::setLogger($logger); + + try { + $invoice = new Invoice(); + $invoice->id = 'pi_fake123'; + $result = (new StripeGateway())->getInvoice($invoice); + } finally { + \Stripe\Stripe::setLogger($loggerAnterior); + } + + $this->assertSame(InvoiceStatus::PAID, $result->status); + $this->assertSame(PaymentMethod::PIX, $result->paymentMethod); + $this->assertNull($result->creditCard); + $this->assertSame([], $logger->messages); + } + + private function paidPixPaymentIntentResponse(): array + { + $response = $this->paidCardPaymentIntentResponse(); + $response['payment_method_types'] = ['pix']; + $response['latest_charge']['payment_method_details'] = [ + 'type' => 'pix', + 'pix' => ['bank_transaction_id' => 'E00000000202601011200abcdef123456'], + ]; + + return $response; + } + + private function pendingPixPaymentIntentResponse(): array + { + $response = $this->paidCardPaymentIntentResponse(status: 'requires_action'); + $response['payment_method_types'] = ['pix']; + $response['latest_charge'] = null; + $response['next_action'] = [ + 'type' => 'pix_display_qr_code', + 'pix_display_qr_code' => [ + 'data' => '00020126pixcopiaecola', + 'image_url_png' => 'https://qr.stripe.com/test.png', + 'image_url_svg' => 'https://qr.stripe.com/test.svg', + 'expires_at' => 1786800000, + 'hosted_instructions_url' => 'https://payments.stripe.com/qr/instructions/test', + ], + ]; + + return $response; + } + + private function paidCardPaymentIntentResponse(string $status = 'succeeded'): array + { + return [ + 'id' => 'pi_fake123', + 'object' => 'payment_intent', + 'status' => $status, + 'amount' => 12345, + 'currency' => 'brl', + 'customer' => 'cus_fake123', + 'created' => 1786700000, + 'payment_method_types' => ['card'], + 'next_action' => null, + 'metadata' => [ + 'item_0_description' => 'Assinatura mensal', + 'item_0_price' => '12345', + 'item_0_quantity' => '1', + ], + 'latest_charge' => [ + 'id' => 'ch_fake123', + 'object' => 'charge', + 'status' => $status === 'succeeded' ? 'succeeded' : 'failed', + 'paid' => $status === 'succeeded', + 'amount' => 12345, + 'amount_captured' => 12345, + 'amount_refunded' => 0, + 'refunded' => false, + 'disputed' => false, + 'created' => 1786700010, + 'payment_method_details' => [ + 'type' => 'card', + 'card' => ['brand' => 'visa', 'last4' => '4242'], + ], + 'balance_transaction' => [ + 'id' => 'txn_fake123', + 'object' => 'balance_transaction', + 'fee' => 425, + 'currency' => 'brl', + ], + ], + ]; + } + + private function disputedCardPaymentIntentResponse(): array + { + $response = $this->paidCardPaymentIntentResponse(); + $response['latest_charge']['disputed'] = true; + + return $response; + } + + /** + * Resposta de GET /v1/disputes?charge=..., uma dispute por status informado. + * + * @param string[] $statuses + * @return array + */ + private function disputeListResponse(array $statuses): array + { + $data = []; + foreach ($statuses as $index => $status) { + $data[] = [ + 'id' => "du_fake{$index}", + 'object' => 'dispute', + 'amount' => 12345, + 'charge' => 'ch_fake123', + 'payment_intent' => 'pi_fake123', + 'currency' => 'brl', + 'reason' => 'fraudulent', + 'status' => $status, + 'created' => 1786700020, + ]; + } + + return [ + 'object' => 'list', + 'url' => '/v1/disputes', + 'has_more' => false, + 'data' => $data, + ]; + } + + private function paymentMethodResponse(?string $customer = null): array + { + return [ + 'id' => 'pm_fake123', + 'object' => 'payment_method', + 'type' => 'card', + 'customer' => $customer, + 'created' => 1786700000, + 'billing_details' => ['name' => 'Faker Teste'], + 'metadata' => [], + 'card' => ['brand' => 'visa', 'last4' => '4242', 'exp_month' => 8, 'exp_year' => 2027], + ]; + } +} diff --git a/tests/Unit/Gateways/StripeGatewayPlanTest.php b/tests/Unit/Gateways/StripeGatewayPlanTest.php new file mode 100644 index 0000000..fbe8360 --- /dev/null +++ b/tests/Unit/Gateways/StripeGatewayPlanTest.php @@ -0,0 +1,348 @@ +instance('config', new Repository([ + 'multi-payment.gateways.stripe.api_key' => 'sk_test_fake', + ])); + Facade::setFacadeApplication($app); + + RecordingStripeHttpClient::withResponses([]); + } + + protected function tearDown(): void + { + ApiRequestor::setHttpClient(null); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testCreatePlanCreatesAProductAndARecurringPriceAndParsesThePrice(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::productResponse(), + self::priceResponse(), + ]); + + $plan = new Plan(); + $plan->name = 'Mensal'; + $plan->identifier = 'plano_mensal'; + $plan->amount = 10000; + $plan->interval = PlanInterval::MONTH; + + $result = (new StripeGateway())->createPlan($plan); + + $this->assertSame(['post /v1/products', 'post /v1/prices'], self::calledPaths($httpClient)); + $this->assertSame(['name' => 'Mensal', 'metadata' => ['identifier' => 'plano_mensal']], $httpClient->calls[0][2]); + $priceParams = $httpClient->calls[1][2]; + $this->assertSame('prod_fake1', $priceParams['product']); + $this->assertSame(10000, $priceParams['unit_amount']); + $this->assertSame('brl', $priceParams['currency']); + $this->assertSame(['interval' => 'month', 'interval_count' => 1], $priceParams['recurring']); + $this->assertSame('plano_mensal', $priceParams['lookup_key']); + $this->assertSame('Mensal', $priceParams['nickname']); + $this->assertContains('product', $priceParams['expand']); + + $this->assertSame('price_fake1', $result->id); + $this->assertSame('plano_mensal', $result->identifier); + $this->assertSame('Mensal', $result->name); + $this->assertSame(10000, $result->amount); + $this->assertSame(PlanInterval::MONTH, $result->interval); + $this->assertSame(1, $result->intervalCount); + $this->assertSame('BRL', $result->currency); + $this->assertTrue($result->active); + $this->assertSame('stripe', $result->gateway); + } + + public function testCreatePlanUsesTheNameAsIdentifierWhenNoneIsGiven(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::productResponse(), + self::priceResponse(), + ]); + + $plan = new Plan(); + $plan->name = 'Mensal'; + $plan->amount = 10000; + $plan->interval = PlanInterval::MONTH; + + (new StripeGateway())->createPlan($plan); + + $this->assertSame(['identifier' => 'Mensal'], $httpClient->calls[0][2]['metadata']); + $this->assertSame('Mensal', $httpClient->calls[1][2]['lookup_key']); + } + + public function testCreatePlanTranslatesYearAndDayIntervals(): void + { + foreach ([[PlanInterval::YEAR, 2, 'year'], [PlanInterval::DAY, 15, 'day']] as [$interval, $count, $expected]) { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::productResponse(), + self::priceResponse(), + ]); + + $plan = new Plan(); + $plan->name = 'Plano'; + $plan->amount = 10000; + $plan->interval = $interval; + $plan->intervalCount = $count; + + (new StripeGateway())->createPlan($plan); + + $this->assertSame(['interval' => $expected, 'interval_count' => $count], $httpClient->calls[1][2]['recurring']); + } + } + + public function testCreatePlanWithoutAnIntervalFailsBeforeTheNetwork(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $plan = new Plan(); + $plan->name = 'Mensal'; + $plan->amount = 10000; + + $this->expectException(ModelAttributeValidationException::class); + + try { + (new StripeGateway())->createPlan($plan); + } finally { + $this->assertSame([], $httpClient->calls); + } + } + + public function testGetPlanByIdRetrievesThePriceWithTheProductExpanded(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::priceResponse()]); + + $plan = new Plan(); + $plan->id = 'price_fake1'; + + $result = (new StripeGateway())->getPlan($plan); + + $this->assertSame(['get /v1/prices/price_fake1'], self::calledPaths($httpClient)); + $this->assertSame(['expand' => ['product']], $httpClient->calls[0][2]); + $this->assertSame('plano_mensal', $result->identifier); + } + + public function testGetPlanByIdentifierSearchesTheLookupKey(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::priceListResponse([self::priceResponse()])]); + + $plan = new Plan(); + $plan->identifier = 'plano_mensal'; + + $result = (new StripeGateway())->getPlan($plan); + + $this->assertSame(['get /v1/prices'], self::calledPaths($httpClient)); + $this->assertSame(['plano_mensal'], $httpClient->calls[0][2]['lookup_keys']); + $this->assertContains('data.product', $httpClient->calls[0][2]['expand']); + $this->assertSame('price_fake1', $result->id); + } + + public function testGetPlanByAnIdentifierWithThePricePrefixReadsItAsAnId(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::priceResponse()]); + + $plan = new Plan(); + $plan->identifier = 'price_fake1'; + + (new StripeGateway())->getPlan($plan); + + $this->assertSame(['get /v1/prices/price_fake1'], self::calledPaths($httpClient)); + } + + public function testGetPlanByAnUnknownIdentifierRaisesNotFound(): void + { + RecordingStripeHttpClient::withResponses([self::priceListResponse([])]); + + $plan = new Plan(); + $plan->identifier = 'inexistente'; + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessageMatches('/inexistente/'); + + (new StripeGateway())->getPlan($plan); + } + + public function testGetPlanWithoutIdOrIdentifierIsRefusedBeforeTheNetwork(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + + $this->expectException(ModelAttributeValidationException::class); + + try { + (new StripeGateway())->getPlan(new Plan()); + } finally { + $this->assertSame([], $httpClient->calls); + } + } + + public function testListPlansListsRecurringPrices(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::priceListResponse([self::priceResponse(), self::priceResponse(['id' => 'price_fake2', 'lookup_key' => 'plano_anual'])]), + ]); + + $plans = (new StripeGateway())->listPlans(1, 50); + + $this->assertSame(['get /v1/prices'], self::calledPaths($httpClient)); + $this->assertSame('recurring', $httpClient->calls[0][2]['type']); + $this->assertSame(50, $httpClient->calls[0][2]['limit']); + $this->assertCount(2, $plans); + $this->assertSame('plano_anual', $plans[1]->identifier); + } + + public function testListPlansWalksTheCursorToReachALaterPage(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::priceListResponse([self::priceResponse()], true), + self::priceListResponse([self::priceResponse(['id' => 'price_fake2'])]), + ]); + + $plans = (new StripeGateway())->listPlans(2, 1); + + $this->assertCount(2, $httpClient->calls); + $this->assertSame('price_fake1', $httpClient->calls[1][2]['starting_after']); + $this->assertCount(1, $plans); + $this->assertSame('price_fake2', $plans[0]->id); + } + + public function testListPlansBeyondTheEndReturnsAnEmptyList(): void + { + RecordingStripeHttpClient::withResponses([self::priceListResponse([self::priceResponse()])]); + + $this->assertSame([], (new StripeGateway())->listPlans(2, 1)); + } + + public function testListPlansValidatesPageAndLimitBeforeTheNetwork(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $gateway = new StripeGateway(); + + foreach ([[0, 10], [1, 0], [1, 101]] as [$page, $limit]) { + try { + $gateway->listPlans($page, $limit); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException) { + } + } + $this->assertSame([], $httpClient->calls); + } + + public function testDeactivatePlanArchivesThePrice(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::priceResponse(['active' => false])]); + + $plan = new Plan(); + $plan->id = 'price_fake1'; + + $result = (new StripeGateway())->deactivatePlan($plan); + + $this->assertSame(['post /v1/prices/price_fake1'], self::calledPaths($httpClient)); + // o encoder do stripe-php envia booleano como a string 'false' + $this->assertSame('false', $httpClient->calls[0][2]['active']); + $this->assertFalse($result->active); + } + + public function testDeactivatePlanWithoutIdOrIdentifierIsRefusedBeforeTheNetwork(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + + $this->expectException(ModelAttributeValidationException::class); + + try { + (new StripeGateway())->deactivatePlan(new Plan()); + } finally { + $this->assertSame([], $httpClient->calls); + } + } + + public function testDeactivatePlanByIdentifierResolvesThePriceFirst(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::priceListResponse([self::priceResponse()]), + self::priceResponse(['active' => false]), + ]); + + $plan = new Plan(); + $plan->identifier = 'plano_mensal'; + + $result = (new StripeGateway())->deactivatePlan($plan); + + $this->assertSame(['get /v1/prices', 'post /v1/prices/price_fake1'], self::calledPaths($httpClient)); + $this->assertFalse($result->active); + } + + /** + * @return string[] `método caminho` de cada chamada gravada + */ + private static function calledPaths(RecordingStripeHttpClient $httpClient): array + { + return array_map( + static fn (array $call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), + $httpClient->calls + ); + } + + private static function productResponse(): array + { + return [ + 'id' => 'prod_fake1', + 'object' => 'product', + 'name' => 'Mensal', + 'active' => true, + 'created' => 1786700000, + 'metadata' => ['identifier' => 'plano_mensal'], + ]; + } + + private static function priceResponse(array $overrides = []): array + { + return array_merge([ + 'id' => 'price_fake1', + 'object' => 'price', + 'active' => true, + 'currency' => 'brl', + 'lookup_key' => 'plano_mensal', + 'nickname' => 'Mensal', + 'created' => 1786700000, + 'product' => self::productResponse(), + 'recurring' => ['interval' => 'month', 'interval_count' => 1, 'usage_type' => 'licensed'], + 'type' => 'recurring', + 'unit_amount' => 10000, + 'unit_amount_decimal' => '10000', + ], $overrides); + } + + private static function priceListResponse(array $prices, bool $hasMore = false): array + { + return [ + 'object' => 'list', + 'url' => '/v1/prices', + 'has_more' => $hasMore, + 'data' => $prices, + ]; + } +} diff --git a/tests/Unit/Gateways/StripeGatewayStripeInvoiceTest.php b/tests/Unit/Gateways/StripeGatewayStripeInvoiceTest.php new file mode 100644 index 0000000..c90e37c --- /dev/null +++ b/tests/Unit/Gateways/StripeGatewayStripeInvoiceTest.php @@ -0,0 +1,802 @@ +instance('config', new Repository([ + 'multi-payment.gateways.stripe.api_key' => 'sk_test_fake', + ])); + $app->instance('log', $this->logger = new RecordingLogger()); + Facade::setFacadeApplication($app); + + RecordingStripeHttpClient::withResponses([]); + } + + protected function tearDown(): void + { + ApiRequestor::setHttpClient(null); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testGetInvoiceWithAnInvoiceIdReadsTheStripeInvoiceWithItsPaymentsExpanded(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('invoices/open_requires_payment_method')]); + + $result = $this->getInvoice('in_1UBHTnPjx0CusuMrjxjg8WhK'); + + $this->assertSame(['get /v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK'], self::calledPaths($httpClient)); + $this->assertSame(['expand' => self::INVOICE_EXPAND], $httpClient->calls[0][2]); + + $this->assertSame('in_1UBHTnPjx0CusuMrjxjg8WhK', $result->id); + $this->assertSame(InvoiceOriginType::INVOICE, $result->originType); + $this->assertSame(InvoiceStatus::PENDING, $result->status); + $this->assertSame('stripe', $result->gateway); + $this->assertSame(12345, $result->amount); + $this->assertNull($result->paidAmount); + $this->assertNull($result->refundedAmount); + $this->assertSame([], $result->refunds); + $this->assertNull($result->paidAt); + $this->assertNull($result->fee); + $this->assertSame(1788368263, $result->createdAt->getTimestamp()); + $this->assertNull($result->dueDate); + $this->assertNull($result->pixExpiresAt); + $this->assertStringStartsWith('https://invoice.stripe.com/i/', $result->url); + $this->assertSame('cus_VBen1v8T4Qa6XX', $result->customer->id); + $this->assertSame(PaymentMethod::CREDIT_CARD, $result->paymentMethod); + $this->assertSame([PaymentMethod::CREDIT_CARD], $result->availablePaymentMethods); + $this->assertNull($result->creditCard); + $this->assertNull($result->pix); + $this->assertInstanceOf(\Stripe\Invoice::class, $result->original); + $this->assertSame([], $this->logger->records); + } + + public function testLineItemsComeFromTheInvoiceLines(): void + { + RecordingStripeHttpClient::withResponses([self::fixture('invoices/open_requires_payment_method')]); + + $result = $this->getInvoice('in_1UBHTnPjx0CusuMrjxjg8WhK'); + + $this->assertCount(1, $result->items); + $this->assertSame('Assinatura mensal', $result->items[0]->description); + $this->assertSame(12345, $result->items[0]->price); + $this->assertSame(1, $result->items[0]->quantity); + } + + /** + * Linha com quantidade maior que um usa o valor unitário de `pricing`; sem `pricing`, o + * `amount` da linha dividido pela quantidade. + */ + public function testLineItemPriceIsTheUnitAmount(): void + { + $response = self::fixture('invoices/open_requires_payment_method'); + $line = $response['lines']['data'][0]; + $withPricing = array_merge($line, ['id' => 'il_1', 'amount' => 20000, 'quantity' => 2, 'description' => 'Com pricing']); + $withPricing['pricing']['unit_amount_decimal'] = '10000'; + $withoutPricing = array_merge($line, ['id' => 'il_2', 'amount' => 3000, 'quantity' => 3, 'description' => 'Sem pricing', 'pricing' => null]); + $response['lines']['data'] = [$withPricing, $withoutPricing]; + RecordingStripeHttpClient::withResponses([$response]); + + $items = $this->getInvoice('in_1UBHTnPjx0CusuMrjxjg8WhK')->items; + + $this->assertSame([10000, 2], [$items[0]->price, $items[0]->quantity]); + $this->assertSame([1000, 3], [$items[1]->price, $items[1]->quantity]); + } + + public function testDueDateBecomesDueDate(): void + { + $response = self::fixture('invoices/open_requires_payment_method'); + $response['due_date'] = 1789000000; + RecordingStripeHttpClient::withResponses([$response]); + + $result = $this->getInvoice('in_1UBHTnPjx0CusuMrjxjg8WhK'); + + $this->assertInstanceOf(Carbon::class, $result->dueDate); + $this->assertSame(1789000000, $result->dueDate->getTimestamp()); + $this->assertNull($result->pixExpiresAt); + } + + public function testGetInvoiceWithAPaymentIntentIdKeepsThePaymentIntentOrigin(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('payment_intents/paid')]); + + $result = $this->getInvoice('pi_3UBHTpPjx0CusuMr1JTEiHGi'); + + $this->assertSame(['get /v1/payment_intents/pi_3UBHTpPjx0CusuMr1JTEiHGi'], self::calledPaths($httpClient)); + $this->assertSame(InvoiceOriginType::PAYMENT_INTENT, $result->originType); + $this->assertSame(InvoiceStatus::PAID, $result->status); + $this->assertInstanceOf(\Stripe\PaymentIntent::class, $result->original); + } + + /** + * Uma linha da tabela de precedência por caso: fixture do Invoice, respostas seguintes + * (PaymentIntent relido e lista de disputes, quando há), status esperado e requisições. + * + * @return array + */ + public static function precedenceTableProvider(): array + { + $invoice = 'get /v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK'; + $paymentIntent = 'get /v1/payment_intents/pi_3UBHTpPjx0CusuMr1JTEiHGi'; + + return [ + 'draft' => ['invoices/draft', [], InvoiceStatus::PENDING, [$invoice]], + 'open sem PaymentIntent' => ['invoices/open_without_payment_intent', [], InvoiceStatus::PENDING, [$invoice]], + 'open + requires_payment_method' => ['invoices/open_requires_payment_method', [], InvoiceStatus::PENDING, [$invoice]], + 'open + requires_payment_method após recusa' => [ + 'invoices/open_after_declined_attempt', + ['payment_intents/after_declined_attempt'], + InvoiceStatus::PENDING, + [$invoice, $paymentIntent], + ], + 'open + requires_action' => [ + 'invoices/open_requires_action', + ['payment_intents/requires_action'], + InvoiceStatus::PENDING, + [$invoice, $paymentIntent], + ], + 'open + requires_confirmation' => ['invoices/open_requires_confirmation', [], InvoiceStatus::PENDING, [$invoice]], + 'open + requires_capture' => ['invoices/open_requires_capture', [], InvoiceStatus::AUTHORIZED, [$invoice]], + 'open + processing' => ['invoices/open_processing', [], InvoiceStatus::PROCESSING, [$invoice]], + 'open parcialmente paga' => ['invoices/open_partially_paid', [], InvoiceStatus::PARTIALLY_PAID, [$invoice]], + 'paid + succeeded sem estorno' => ['invoices/paid', ['payment_intents/paid'], InvoiceStatus::PAID, [$invoice, $paymentIntent]], + 'paid + estorno parcial' => [ + 'invoices/paid', + ['payment_intents/partially_refunded'], + InvoiceStatus::PARTIALLY_REFUNDED, + [$invoice, $paymentIntent], + ], + 'paid + estorno total' => ['invoices/paid', ['payment_intents/refunded'], InvoiceStatus::REFUNDED, [$invoice, $paymentIntent]], + 'paid + dispute aberta' => [ + 'invoices/paid_disputed', + ['payment_intents/disputed', 'disputes/needs_response'], + InvoiceStatus::DISPUTED, + ['get /v1/invoices/in_1UBHU4Pjx0CusuMrOTB0POaR', 'get /v1/payment_intents/pi_3UBHU5Pjx0CusuMr1cNe2BIo', 'get /v1/disputes'], + ], + 'paid + dispute perdida' => [ + 'invoices/paid_disputed', + ['payment_intents/disputed', 'disputes/lost'], + InvoiceStatus::CHARGEBACK, + ['get /v1/invoices/in_1UBHU4Pjx0CusuMrOTB0POaR', 'get /v1/payment_intents/pi_3UBHU5Pjx0CusuMr1cNe2BIo', 'get /v1/disputes'], + ], + 'paid fora da Stripe' => ['invoices/paid_out_of_band', [], InvoiceStatus::EXTERNALLY_PAID, ['get /v1/invoices/in_1UBHUHPjx0CusuMrD81KgsNd']], + 'paid sem cobrança (amount_due zero)' => ['invoices/paid_zero_amount_due', [], InvoiceStatus::PAID, ['get /v1/invoices/in_1UBHUNPjx0CusuMrEG6etDb8']], + 'void' => ['invoices/void', [], InvoiceStatus::CANCELED, ['get /v1/invoices/in_1UBHUBPjx0CusuMrdZ8CWaQW']], + 'uncollectible' => ['invoices/uncollectible', [], InvoiceStatus::EXPIRED, ['get /v1/invoices/in_1UBHUEPjx0CusuMrnkQe23fT']], + ]; + } + + #[DataProvider('precedenceTableProvider')] + public function testDerivesTheStatusFromTheInvoiceThenThePaymentIntentThenTheCharge( + string $invoiceFixture, + array $followingFixtures, + InvoiceStatus $expected, + array $expectedPaths + ): void { + $responses = [self::fixture($invoiceFixture)]; + foreach ($followingFixtures as $fixture) { + $responses[] = self::fixture($fixture); + } + $httpClient = RecordingStripeHttpClient::withResponses($responses); + + $result = $this->getInvoice($responses[0]['id']); + + $this->assertSame($expected, $result->status); + $this->assertSame(InvoiceOriginType::INVOICE, $result->originType); + $this->assertSame($expectedPaths, self::calledPaths($httpClient)); + $this->assertSame([], $this->logger->records); + } + + /** + * O PaymentIntent da fatura só é relido quando já tem charge, e aí com o mesmo expand da + * cobrança avulsa; a Stripe limita o expand a quatro níveis e o charge fica no quinto. + */ + public function testReadsThePaymentIntentSeparatelyOnlyWhenItHasACharge(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $this->getInvoice('in_1UBHTnPjx0CusuMrjxjg8WhK'); + + $this->assertSame(['expand' => self::PAYMENT_INTENT_EXPAND], $httpClient->calls[1][2]); + } + + public function testPaidInvoiceCarriesTheChargeAmountsFeeAndCard(): void + { + RecordingStripeHttpClient::withResponses([ + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $result = $this->getInvoice('in_1UBHTnPjx0CusuMrjxjg8WhK'); + + $this->assertSame(12345, $result->paidAmount); + $this->assertSame(0, $result->refundedAmount); + $this->assertSame([], $result->refunds); + $this->assertSame(520, $result->fee); + // paidAt vem de status_transitions.paid_at do Invoice + $this->assertSame(1788368273, $result->paidAt->getTimestamp()); + $this->assertSame(PaymentMethod::CREDIT_CARD, $result->paymentMethod); + $this->assertSame('visa', $result->creditCard->brand); + $this->assertSame('4242', $result->creditCard->lastDigits); + } + + public function testRefundedInvoiceListsTheRefundsOfTheCharge(): void + { + RecordingStripeHttpClient::withResponses([ + self::fixture('invoices/paid'), + self::fixture('payment_intents/refunded'), + ]); + + $result = $this->getInvoice('in_1UBHTnPjx0CusuMrjxjg8WhK'); + + $this->assertSame(12345, $result->refundedAmount); + $this->assertCount(2, $result->refunds); + // a Stripe lista os estornos do mais recente para o mais antigo + $this->assertSame([10000, 2345], array_map(static fn ($refund) => $refund->amount, $result->refunds)); + $this->assertSame('in_1UBHTnPjx0CusuMrjxjg8WhK', $result->refunds[0]->invoiceId); + $this->assertStringStartsWith('re_', $result->refunds[0]->id); + } + + public function testDisputedInvoiceListsTheDisputesOfTheCharge(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('invoices/paid_disputed'), + self::fixture('payment_intents/disputed'), + self::fixture('disputes/needs_response'), + ]); + + $result = $this->getInvoice('in_1UBHU4Pjx0CusuMrOTB0POaR'); + + $this->assertSame(InvoiceStatus::DISPUTED, $result->status); + $this->assertSame(12345, $result->paidAmount); + $this->assertSame(['charge' => 'ch_3UBHU5Pjx0CusuMr1q2mLg5W', 'limit' => 100], $httpClient->calls[2][2]); + } + + /** + * Paga fora da Stripe, a fatura tem o PaymentIntent padrão cancelado e um InvoicePayment + * do tipo `payment_record`; o valor recebido vem do Invoice e o método fica em aberto. + */ + public function testExternallyPaidInvoiceReadsTheAmountFromTheInvoice(): void + { + RecordingStripeHttpClient::withResponses([self::fixture('invoices/paid_out_of_band')]); + + $result = $this->getInvoice('in_1UBHUHPjx0CusuMrD81KgsNd'); + + $this->assertSame(InvoiceStatus::EXTERNALLY_PAID, $result->status); + $this->assertSame(12345, $result->paidAmount); + $this->assertNull($result->refundedAmount); + $this->assertNull($result->paymentMethod); + $this->assertSame(1788368296, $result->paidAt->getTimestamp()); + } + + /** + * `amount_paid_off_stripe`, quando a versão da API o devolve, também sinaliza pagamento + * externo. + */ + public function testAmountPaidOffStripeAlsoReadsAsExternallyPaid(): void + { + $response = self::fixture('invoices/paid_out_of_band'); + $response['payments']['data'] = []; + $response['amount_paid_off_stripe'] = 12345; + RecordingStripeHttpClient::withResponses([$response]); + + $this->assertSame(InvoiceStatus::EXTERNALLY_PAID, $this->getInvoice($response['id'])->status); + } + + public function testInvoicePaidWithoutAChargeHasZeroPaidAmount(): void + { + RecordingStripeHttpClient::withResponses([self::fixture('invoices/paid_zero_amount_due')]); + + $result = $this->getInvoice('in_1UBHUNPjx0CusuMrEG6etDb8'); + + $this->assertSame(InvoiceStatus::PAID, $result->status); + $this->assertSame(0, $result->amount); + $this->assertSame(0, $result->paidAmount); + $this->assertNull($result->paymentMethod); + $this->assertSame('Período de teste', $result->items[0]->description); + } + + public function testPartiallyPaidInvoiceReadsTheAmountPaidSoFar(): void + { + RecordingStripeHttpClient::withResponses([self::fixture('invoices/open_partially_paid')]); + + $result = $this->getInvoice('in_1UBHTnPjx0CusuMrjxjg8WhK'); + + $this->assertSame(InvoiceStatus::PARTIALLY_PAID, $result->status); + $this->assertSame(5000, $result->paidAmount); + $this->assertTrue($result->status->isSettled()); + $this->assertTrue($result->status->isOpen()); + } + + /** + * Combinações fora da tabela: cada uma vira `UNKNOWN` com um aviso no log que traz os três + * status e o id da fatura. + * + * @return array + */ + public static function unknownCombinationProvider(): array + { + return [ + 'open + succeeded (transição)' => ['open', 'succeeded'], + 'open + canceled' => ['open', 'canceled'], + 'paid + requires_payment_method sem pagamento externo' => ['paid', 'requires_payment_method'], + 'status de Invoice desconhecido' => ['partially_funded', 'requires_payment_method'], + ]; + } + + #[DataProvider('unknownCombinationProvider')] + public function testCombinationOutsideTheTableReadsAsUnknownWithAWarning(string $invoiceStatus, string $paymentIntentStatus): void + { + $response = self::fixture('invoices/open_requires_payment_method'); + $response['status'] = $invoiceStatus; + $response['payments']['data'][0]['payment']['payment_intent']['status'] = $paymentIntentStatus; + RecordingStripeHttpClient::withResponses([$response]); + + $result = $this->getInvoice('in_1UBHTnPjx0CusuMrjxjg8WhK'); + + $this->assertSame(InvoiceStatus::UNKNOWN, $result->status); + $this->assertSame($invoiceStatus, $result->original->status); + $this->assertCount(1, $this->logger->records); + $this->assertSame('warning', $this->logger->records[0]['level']); + $this->assertSame([ + 'gateway' => 'stripe', + 'invoice_id' => 'in_1UBHTnPjx0CusuMrjxjg8WhK', + 'invoice_status' => $invoiceStatus, + 'payment_intent_status' => $paymentIntentStatus, + 'charge_status' => null, + ], $this->logger->records[0]['context']); + $this->assertStringContainsString('in_1UBHTnPjx0CusuMrjxjg8WhK', $this->logger->records[0]['message']); + } + + /** + * O aviso traz o status do charge quando o PaymentIntent relido tem um. + */ + public function testUnknownCombinationLogsTheChargeStatusWhenThereIsACharge(): void + { + $paymentIntent = self::fixture('payment_intents/after_declined_attempt'); + $paymentIntent['status'] = 'succeeded'; + RecordingStripeHttpClient::withResponses([self::fixture('invoices/open_after_declined_attempt'), $paymentIntent]); + + $result = $this->getInvoice('in_1UBHTnPjx0CusuMrjxjg8WhK'); + + $this->assertSame(InvoiceStatus::UNKNOWN, $result->status); + $this->assertSame('succeeded', $this->logger->records[0]['context']['payment_intent_status']); + $this->assertSame('failed', $this->logger->records[0]['context']['charge_status']); + } + + /** + * `paid` sem PaymentIntent e com `amount_due` acima de zero não está na tabela. + */ + public function testPaidInvoiceWithoutAnyPaymentAndAnAmountDueReadsAsUnknown(): void + { + $response = self::fixture('invoices/paid_zero_amount_due'); + $response['amount_due'] = 12345; + RecordingStripeHttpClient::withResponses([$response]); + + $this->assertSame(InvoiceStatus::UNKNOWN, $this->getInvoice($response['id'])->status); + $this->assertCount(1, $this->logger->records); + $this->assertNull($this->logger->records[0]['context']['payment_intent_status']); + } + + /** + * Entre vários pagamentos do Invoice sem nenhum pago, o padrão (`is_default`) é o + * PaymentIntent da fatura. + */ + public function testTheDefaultPaymentIsTheInvoicePaymentIntent(): void + { + $response = self::fixture('invoices/open_requires_payment_method'); + $default = $response['payments']['data'][0]; + $other = $default; + $other['id'] = 'inpay_outro'; + $other['is_default'] = false; + $other['payment']['payment_intent']['id'] = 'pi_outro'; + $other['payment']['payment_intent']['status'] = 'processing'; + $response['payments']['data'] = [$other, $default]; + RecordingStripeHttpClient::withResponses([$response]); + + $this->assertSame(InvoiceStatus::PENDING, $this->getInvoice($response['id'])->status); + } + + /** + * O pagamento pago é o PaymentIntent da fatura, mesmo quando outro pagamento é o padrão. + */ + public function testThePaidPaymentIsTheInvoicePaymentIntentEvenWhenAnotherOneIsTheDefault(): void + { + $response = self::fixture('invoices/paid'); + $paid = $response['payments']['data'][0]; + $paid['is_default'] = false; + $open = $paid; + $open['id'] = 'inpay_aberto'; + $open['is_default'] = true; + $open['status'] = 'open'; + $open['payment']['payment_intent']['id'] = 'pi_aberto'; + $open['payment']['payment_intent']['status'] = 'requires_payment_method'; + $open['payment']['payment_intent']['latest_charge'] = null; + $response['payments']['data'] = [$open, $paid]; + $httpClient = RecordingStripeHttpClient::withResponses([$response, self::fixture('payment_intents/paid')]); + + $result = $this->getInvoice($response['id']); + + $this->assertSame(InvoiceStatus::PAID, $result->status); + $this->assertSame('/v1/payment_intents/pi_3UBHTpPjx0CusuMr1JTEiHGi', parse_url($httpClient->calls[1][1], PHP_URL_PATH)); + } + + /** + * Sem pagamento padrão nem pago, vale o primeiro do tipo PaymentIntent. + */ + public function testWithoutADefaultOrPaidPaymentTheFirstOneIsUsed(): void + { + $response = self::fixture('invoices/open_requires_payment_method'); + $first = $response['payments']['data'][0]; + $first['is_default'] = false; + $first['payment']['payment_intent']['status'] = 'processing'; + $second = $first; + $second['id'] = 'inpay_segundo'; + $second['payment']['payment_intent']['id'] = 'pi_segundo'; + $second['payment']['payment_intent']['status'] = 'requires_capture'; + $record = ['id' => 'inpay_registro', 'object' => 'invoice_payment', 'status' => 'open', 'is_default' => false, 'payment' => ['type' => 'payment_record', 'payment_record' => 'pr_x']]; + $response['payments']['data'] = [$record, $first, $second]; + RecordingStripeHttpClient::withResponses([$response]); + + $this->assertSame(InvoiceStatus::PROCESSING, $this->getInvoice($response['id'])->status); + } + + /** + * InvoicePayment de PaymentIntent sem objeto nem id deixa a fatura sem PaymentIntent. + */ + public function testPaymentIntentPaymentWithoutAnIdIsIgnored(): void + { + $response = self::fixture('invoices/open_requires_payment_method'); + $response['payments']['data'][0]['payment'] = ['type' => 'payment_intent']; + $httpClient = RecordingStripeHttpClient::withResponses([$response]); + + $result = $this->getInvoice($response['id']); + + $this->assertSame(InvoiceStatus::PENDING, $result->status); + $this->assertNull($result->paymentMethod); + $this->assertCount(1, $httpClient->calls); + } + + /** + * Pagamento do tipo `charge` anexado à fatura lê como paga, sem refinamento de estorno ou + * contestação. + */ + public function testPaidInvoiceWhosePaymentIsAChargeReadsAsPaid(): void + { + $response = self::fixture('invoices/paid_out_of_band'); + $response['payments']['data'][0]['payment'] = ['type' => 'charge', 'charge' => 'ch_anexado']; + $httpClient = RecordingStripeHttpClient::withResponses([$response]); + + $result = $this->getInvoice($response['id']); + + $this->assertSame(InvoiceStatus::PAID, $result->status); + $this->assertSame(12345, $result->paidAmount); + $this->assertCount(1, $httpClient->calls); + $this->assertSame([], $this->logger->records); + } + + public function testPaidAtFallsBackToTheChargeCreationWhenTheInvoiceHasNoPaidAt(): void + { + $response = self::fixture('invoices/paid'); + $response['status_transitions']['paid_at'] = null; + $paymentIntent = self::fixture('payment_intents/paid'); + RecordingStripeHttpClient::withResponses([$response, $paymentIntent]); + + $result = $this->getInvoice($response['id']); + + $this->assertSame($paymentIntent['latest_charge']['created'], $result->paidAt->getTimestamp()); + } + + /** + * Um `next_action` de 3DS não tem `pix_display_qr_code`; o parse não pode ler a chave às + * cegas, senão o StripeObject registra "Undefined property" no logger da Stripe. Vale nas + * duas origens. + * + * @return array + */ + public static function threeDSecureNextActionProvider(): array + { + return [ + 'PaymentIntent' => ['pi_3UBHTpPjx0CusuMr1JTEiHGi', ['payment_intents/requires_action']], + 'Invoice' => ['in_1UBHTnPjx0CusuMrjxjg8WhK', ['invoices/open_requires_action', 'payment_intents/requires_action']], + ]; + } + + #[DataProvider('threeDSecureNextActionProvider')] + public function testThreeDSecureNextActionDoesNotLogAnUndefinedProperty(string $id, array $fixtures): void + { + RecordingStripeHttpClient::withResponses(array_map([self::class, 'fixture'], $fixtures)); + $previousLogger = \Stripe\Stripe::getLogger(); + \Stripe\Stripe::setLogger($stripeLogger = new RecordingStripeLogger()); + + try { + $result = $this->getInvoice($id); + } finally { + \Stripe\Stripe::setLogger($previousLogger); + } + + $this->assertSame(InvoiceStatus::PENDING, $result->status); + $this->assertNull($result->pix); + $this->assertSame([], $stripeLogger->messages); + } + + /** + * PaymentIntent que veio só como id (sem expand) é lido num GET. + */ + public function testPaymentIntentGivenAsIdIsRead(): void + { + $response = self::fixture('invoices/open_requires_payment_method'); + $response['payments']['data'][0]['payment']['payment_intent'] = 'pi_3UBHTpPjx0CusuMr1JTEiHGi'; + $paymentIntent = self::fixture('payment_intents/after_declined_attempt'); + $paymentIntent['status'] = 'processing'; + $httpClient = RecordingStripeHttpClient::withResponses([$response, $paymentIntent]); + + $result = $this->getInvoice($response['id']); + + $this->assertSame(InvoiceStatus::PROCESSING, $result->status); + $this->assertCount(2, $httpClient->calls); + $this->assertSame('/v1/payment_intents/pi_3UBHTpPjx0CusuMr1JTEiHGi', parse_url($httpClient->calls[1][1], PHP_URL_PATH)); + } + + /** + * Fatura de assinatura paga por Pix: o QR Code e a expiração dele vêm do PaymentIntent, o + * vencimento vem da fatura e `url` continua sendo a página hospedada da fatura. + */ + public function testPixQrCodeComesFromThePaymentIntentAndUrlStaysTheHostedInvoicePage(): void + { + $response = self::fixture('invoices/open_requires_payment_method'); + $response['due_date'] = 1789000000; + $paymentIntent = &$response['payments']['data'][0]['payment']['payment_intent']; + $paymentIntent['status'] = 'requires_action'; + $paymentIntent['payment_method_types'] = ['pix']; + $paymentIntent['next_action'] = [ + 'type' => 'pix_display_qr_code', + 'pix_display_qr_code' => [ + 'data' => '00020126pixcopiaecola', + 'image_url_png' => 'https://qr.stripe.com/test.png', + 'expires_at' => 1788400000, + 'hosted_instructions_url' => 'https://payments.stripe.com/qr/instructions/test', + ], + ]; + unset($paymentIntent); + RecordingStripeHttpClient::withResponses([$response]); + + $result = $this->getInvoice($response['id']); + + $this->assertSame(InvoiceStatus::PENDING, $result->status); + $this->assertSame(PaymentMethod::PIX, $result->paymentMethod); + $this->assertSame('00020126pixcopiaecola', $result->pix->qrCodeText); + $this->assertSame('https://qr.stripe.com/test.png', $result->pix->qrCodeImageUrl); + $this->assertSame(1788400000, $result->pixExpiresAt->getTimestamp()); + $this->assertSame(1789000000, $result->dueDate->getTimestamp()); + $this->assertStringStartsWith('https://invoice.stripe.com/i/', $result->url); + } + + public function testCancelInvoiceVoidsTheStripeInvoiceAfterReadingIt(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('invoices/open_requires_payment_method'), + self::fixture('invoices/void'), + ]); + + $invoice = new Invoice(); + $invoice->id = 'in_1UBHTnPjx0CusuMrjxjg8WhK'; + $result = (new StripeGateway())->cancelInvoice($invoice, 'chave-void'); + + $this->assertSame([ + 'get /v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK', + 'post /v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK/void', + ], self::calledPaths($httpClient)); + $this->assertSame(['expand' => self::INVOICE_EXPAND], $httpClient->calls[1][2]); + $this->assertSame('chave-void', $httpClient->header(1, 'Idempotency-Key')); + $this->assertNull($httpClient->header(0, 'Idempotency-Key')); + + $this->assertSame($invoice, $result); + $this->assertSame(InvoiceStatus::CANCELED, $result->status); + $this->assertSame(InvoiceOriginType::INVOICE, $result->originType); + } + + public function testCancelInvoiceRefusesADraftWithoutVoiding(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('invoices/draft')]); + + $invoice = new Invoice(); + $invoice->id = 'in_1UBHTnPjx0CusuMrjxjg8WhK'; + + try { + (new StripeGateway())->cancelInvoice($invoice); + $this->fail('Rascunho deveria lançar UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertStringContainsString('rascunho', $e->getMessage()); + $this->assertStringContainsString('in_1UBHTnPjx0CusuMrjxjg8WhK', $e->getMessage()); + $this->assertSame(Capability::INVOICE_CANCELLATION, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertNull($e->httpStatus); + } + $this->assertSame(['get /v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK'], self::calledPaths($httpClient)); + } + + public function testCancelPaidInvoiceBecomesValidationExceptionFromTheStripeRefusal(): void + { + RecordingStripeHttpClient::withResponses([ + self::fixture('invoices/paid'), + [['error' => [ + 'type' => 'invalid_request_error', + 'message' => 'Invoices with `paid` payments cannot be voided.', + ]], 400], + ]); + + $invoice = new Invoice(); + $invoice->id = 'in_1UBHTnPjx0CusuMrjxjg8WhK'; + + try { + (new StripeGateway())->cancelInvoice($invoice); + $this->fail('Fatura paga deveria lançar ValidationException'); + } catch (ValidationException $e) { + $this->assertSame(400, $e->httpStatus); + $this->assertSame(['base' => ['Invoices with `paid` payments cannot be voided.']], $e->fieldErrors); + } + } + + public function testCancelInvoiceWithAPaymentIntentIdStillCancelsThePaymentIntent(): void + { + $response = self::fixture('payment_intents/after_declined_attempt'); + $response['status'] = 'canceled'; + $httpClient = RecordingStripeHttpClient::withResponses([$response]); + + $invoice = new Invoice(); + $invoice->id = 'pi_3UBHTpPjx0CusuMr1JTEiHGi'; + $result = (new StripeGateway())->cancelInvoice($invoice); + + $this->assertSame(['post /v1/payment_intents/pi_3UBHTpPjx0CusuMr1JTEiHGi/cancel'], self::calledPaths($httpClient)); + $this->assertSame(InvoiceStatus::CANCELED, $result->status); + $this->assertSame(InvoiceOriginType::PAYMENT_INTENT, $result->originType); + } + + public function testDuplicateInvoiceRefusesAStripeInvoiceBeforeAnyRequest(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + + $invoice = new Invoice(); + $invoice->id = 'in_1UBHTnPjx0CusuMrjxjg8WhK'; + + try { + (new StripeGateway())->duplicateInvoice($invoice, Carbon::now()->addDay()); + $this->fail('Fatura de assinatura deveria lançar UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::INVOICE_DUPLICATION, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertSame('stripe', $e->gateway); + $this->assertStringContainsString('in_1UBHTnPjx0CusuMrjxjg8WhK', $e->getMessage()); + } + $this->assertSame([], $httpClient->calls); + } + + public function testRefundInvoiceOnAStripeInvoiceIsNotImplementedYet(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + + $invoice = new Invoice(); + $invoice->id = 'in_1UBHTnPjx0CusuMrjxjg8WhK'; + + try { + (new StripeGateway())->refundInvoice($invoice); + $this->fail('Estorno de fatura de assinatura deveria lançar UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::SUBSCRIPTIONS, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_NOT_IMPLEMENTED, $e->reason); + $this->assertStringContainsString('refundInvoice', $e->getMessage()); + } + $this->assertSame([], $httpClient->calls); + } + + public function testChargeInvoiceWithCreditCardOnAStripeInvoiceIsNotImplementedYet(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + + $invoice = new Invoice(); + $invoice->id = 'in_1UBHTnPjx0CusuMrjxjg8WhK'; + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_fake123'; + + try { + (new StripeGateway())->chargeInvoiceWithCreditCard($invoice); + $this->fail('Cobrança de fatura de assinatura deveria lançar UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::SUBSCRIPTIONS, $e->capability); + $this->assertTrue($e->isNotImplemented()); + $this->assertStringContainsString('chargeInvoiceWithCreditCard', $e->getMessage()); + } + $this->assertSame([], $httpClient->calls); + } + + /** + * `refundableAmount()` segue `refundInvoice()`: a fatura de assinatura é recusada antes de + * qualquer leitura, para o restante nunca prometer um estorno que o driver recusa. + */ + public function testRefundableAmountRefusesAStripeInvoiceBeforeAnyRequest(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $invoice = new Invoice(); + $invoice->id = 'in_1UBHTnPjx0CusuMrjxjg8WhK'; + + try { + (new StripeGateway())->refundableAmount($invoice); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::SUBSCRIPTIONS, $e->capability); + $this->assertTrue($e->isNotImplemented()); + } + $this->assertSame([], $httpClient->calls); + } + + private function getInvoice(string $id): Invoice + { + $invoice = new Invoice(); + $invoice->id = $id; + + return (new StripeGateway())->getInvoice($invoice); + } + + /** + * @return string[] `método caminho` de cada chamada gravada + */ + private static function calledPaths(RecordingStripeHttpClient $httpClient): array + { + return array_map( + static fn (array $call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), + $httpClient->calls + ); + } + + /** + * Resposta gravada em `tests/fixtures/stripe/.json`, como array. + */ + private static function fixture(string $path): array + { + return json_decode(file_get_contents(__DIR__ . "/../../fixtures/stripe/{$path}.json"), true); + } +} diff --git a/tests/Unit/Gateways/StripeGatewaySubscriptionTest.php b/tests/Unit/Gateways/StripeGatewaySubscriptionTest.php new file mode 100644 index 0000000..0f1c6e3 --- /dev/null +++ b/tests/Unit/Gateways/StripeGatewaySubscriptionTest.php @@ -0,0 +1,1386 @@ +instance('config', new Repository([ + 'multi-payment.gateways.stripe.api_key' => 'sk_test_fake', + ])); + Facade::setFacadeApplication($app); + + RecordingStripeHttpClient::withResponses([]); + } + + protected function tearDown(): void + { + ApiRequestor::setHttpClient(null); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testCreateSubscriptionWithASavedCardChargesTheFirstInvoiceAndReadsIt(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::priceListResponse(), + self::fixture('subscriptions/active'), + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $subscription = self::subscriptionModel(); + $subscription->creditCard = new CreditCard(); + $subscription->creditCard->id = 'pm_fake123'; + + $result = (new StripeGateway())->createSubscription($subscription); + + $this->assertSame([ + 'get /v1/prices', + 'post /v1/subscriptions', + 'get /v1/invoices/in_1UBJmkPjx0CusuMrN6Yc2Ha1', + 'get /v1/payment_intents/pi_3UBHTpPjx0CusuMr1JTEiHGi', + ], self::calledPaths($httpClient)); + + $params = $httpClient->calls[1][2]; + $this->assertSame('cus_fake123', $params['customer']); + $this->assertSame([['price' => 'price_fake1']], $params['items']); + $this->assertSame('charge_automatically', $params['collection_method']); + $this->assertSame('error_if_incomplete', $params['payment_behavior']); + $this->assertSame(['payment_method_types' => ['card']], $params['payment_settings']); + $this->assertSame('pm_fake123', $params['default_payment_method']); + $this->assertSame(self::SUBSCRIPTION_EXPAND, $params['expand']); + + $this->assertSame('sub_1UBJmkPjx0CusuMr3KQ2wXyZ', $result->id); + $this->assertSame(SubscriptionStatus::ACTIVE, $result->status); + $this->assertSame('plano_mensal', $result->planId); + $this->assertSame(10000, $result->amount); + $this->assertSame([], $result->items); + $this->assertSame('stripe', $result->gateway); + $this->assertSame(InvoiceStatus::PAID, $result->latestInvoice->status); + } + + public function testCreateSubscriptionWithTrialDaysSendsThePeriodAndReturnsTheDate(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('subscriptions/trialing'), + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_fake1'; + $subscription->trialDays = 7; + + $result = (new StripeGateway())->createSubscription($subscription); + + $params = $httpClient->calls[0][2]; + $this->assertSame(7, $params['trial_period_days']); + $this->assertArrayNotHasKey('trial_end', $params); + + $this->assertNull($result->trialDays); + $this->assertSame(1789170793, $result->trialEndsAt->getTimestamp()); + $this->assertSame(SubscriptionStatus::TRIALING, $result->status); + } + + public function testCreateSubscriptionWithPixLeavesTheFirstInvoiceOpen(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('subscriptions/incomplete'), + self::fixture('invoices/open_requires_payment_method'), + ]); + + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_fake1'; + $subscription->availablePaymentMethods = [PaymentMethod::PIX]; + + $result = (new StripeGateway())->createSubscription($subscription); + + $params = $httpClient->calls[0][2]; + $this->assertSame('default_incomplete', $params['payment_behavior']); + $this->assertSame(['payment_method_types' => ['pix']], $params['payment_settings']); + + $this->assertSame(SubscriptionStatus::PENDING, $result->status); + $this->assertSame(InvoiceStatus::PENDING, $result->latestInvoice->status); + } + + /** + * Com boleto a assinatura nasce ativa em modo de fatura enviada, e a primeira fatura é + * finalizada na hora para já ter a página hospedada onde o pagador gera o voucher. + */ + public function testCreateSubscriptionWithBoletoSendsTheInvoiceAndFinalizesTheFirstOne(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('subscriptions/active_send_invoice_boleto'), + self::fixture('invoices/open_boleto_send_invoice'), + self::fixture('invoices/open_boleto_send_invoice'), + ]); + + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_1UC8LwPjx0CusuMrr3Vq7Hpk'; + $subscription->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; + + $result = (new StripeGateway())->createSubscription($subscription); + + $this->assertSame([ + 'post /v1/subscriptions', + 'post /v1/invoices/in_1UC8LxPjx0CusuMr8L1JgWdN/finalize', + 'get /v1/invoices/in_1UC8LxPjx0CusuMr8L1JgWdN', + ], self::calledPaths($httpClient)); + + $params = $httpClient->calls[0][2]; + $this->assertSame('send_invoice', $params['collection_method']); + $this->assertSame(3, $params['days_until_due']); + $this->assertSame(['payment_method_types' => ['boleto']], $params['payment_settings']); + $this->assertArrayNotHasKey('payment_behavior', $params); + + $this->assertSame(SubscriptionStatus::ACTIVE, $result->status); + $this->assertSame(PaymentMethod::BANK_SLIP, $result->paymentMethod); + $this->assertSame(InvoiceStatus::PENDING, $result->latestInvoice->status); + $this->assertStringContainsString('invoice.stripe.com', $result->latestInvoice->url); + $this->assertSame(1788830709, $result->latestInvoice->dueDate->getTimestamp()); + } + + /** + * A chave de idempotência da criação deriva a da finalização da primeira fatura. + */ + public function testCreateSubscriptionWithBoletoDerivesTheFinalizeIdempotencyKey(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('subscriptions/active_send_invoice_boleto'), + self::fixture('invoices/open_boleto_send_invoice'), + self::fixture('invoices/open_boleto_send_invoice'), + ]); + + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_1UC8LwPjx0CusuMrr3Vq7Hpk'; + $subscription->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; + + (new StripeGateway())->createSubscription($subscription, 'chave-sub-1'); + + $this->assertSame('chave-sub-1', $httpClient->header(0, 'Idempotency-Key')); + $this->assertSame('chave-sub-1:finalize', $httpClient->header(1, 'Idempotency-Key')); + } + + /** + * Assinatura de boleto sem primeira fatura (trial) não tem o que finalizar. + */ + public function testCreateSubscriptionWithBoletoAndNoInvoiceSkipsTheFinalization(): void + { + $created = self::fixture('subscriptions/active_send_invoice_boleto'); + $created['latest_invoice'] = null; + $httpClient = RecordingStripeHttpClient::withResponses([$created]); + + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_1UC8LwPjx0CusuMrr3Vq7Hpk'; + $subscription->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; + + $result = (new StripeGateway())->createSubscription($subscription); + + $this->assertSame(['post /v1/subscriptions'], self::calledPaths($httpClient)); + $this->assertNull($result->latestInvoice); + } + + public function testUpdateSubscriptionSwitchingToBoletoChangesTheCollectionMethod(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('subscriptions/active_send_invoice_boleto')]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UC8LxPjx0CusuMrKfvHqdWA'; + $subscription->paymentMethod = PaymentMethod::BANK_SLIP; + + (new StripeGateway())->updateSubscription($subscription); + + $params = $httpClient->calls[0][2]; + $this->assertSame(['payment_method_types' => ['boleto']], $params['payment_settings']); + $this->assertSame('send_invoice', $params['collection_method']); + $this->assertSame(3, $params['days_until_due']); + } + + /** + * A volta à cobrança automática não depende do estado lido do gateway: num model fresco, + * só com o id, a troca de boleto para outro método também escreve `collection_method`. + */ + public function testUpdateSubscriptionSwitchingFromBoletoRestoresAutomaticCollection(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('subscriptions/active')]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UC8LxPjx0CusuMrKfvHqdWA'; + $subscription->paymentMethod = PaymentMethod::PIX; + + (new StripeGateway())->updateSubscription($subscription); + + $params = $httpClient->calls[0][2]; + $this->assertSame(['payment_method_types' => ['pix']], $params['payment_settings']); + $this->assertSame('charge_automatically', $params['collection_method']); + $this->assertArrayNotHasKey('days_until_due', $params); + } + + /** + * O método lido do gateway não reescreve o modo de cobrança: um update de outra coisa numa + * assinatura de boleto não mexe em `collection_method`. + */ + public function testUpdateSubscriptionKeepsTheCollectionMethodWhenTheBoletoCameFromTheGateway(): void + { + $original = json_decode(json_encode(self::fixture('subscriptions/active_send_invoice_boleto'))); + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('subscriptions/active_send_invoice_boleto')]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UC8LxPjx0CusuMrKfvHqdWA'; + $subscription->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; + $subscription->metadata = ['origem' => 'teste']; + $subscription->original = $original; + + (new StripeGateway())->updateSubscription($subscription); + + $params = $httpClient->calls[0][2]; + $this->assertArrayNotHasKey('payment_settings', $params); + $this->assertArrayNotHasKey('collection_method', $params); + } + + public function testCreateSubscriptionWithExtraItemsCreatesPricesOnDemand(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::productResponse('prod_item0'), + self::priceResponse(), + self::productResponse('prod_item1'), + self::fixture('subscriptions/active'), + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $recurring = new SubscriptionItem(); + $recurring->description = 'Consultas extras'; + $recurring->amount = 2500; + $recurring->quantity = 2; + + $oneTime = new SubscriptionItem(); + $oneTime->description = 'Taxa de adesão'; + $oneTime->amount = 900; + $oneTime->recurring = false; + + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_fake1'; + $subscription->items = [$recurring, $oneTime]; + + (new StripeGateway())->createSubscription($subscription); + + $this->assertSame([ + 'post /v1/products', + 'get /v1/prices/price_fake1', + 'post /v1/products', + 'post /v1/subscriptions', + 'get /v1/invoices/in_1UBJmkPjx0CusuMrN6Yc2Ha1', + 'get /v1/payment_intents/pi_3UBHTpPjx0CusuMr1JTEiHGi', + ], self::calledPaths($httpClient)); + $this->assertSame(['name' => 'Consultas extras'], $httpClient->calls[0][2]); + $this->assertSame(['name' => 'Taxa de adesão'], $httpClient->calls[2][2]); + + $params = $httpClient->calls[3][2]; + $this->assertSame([ + ['price' => 'price_fake1'], + [ + 'price_data' => [ + 'currency' => 'brl', + 'product' => 'prod_item0', + 'unit_amount' => 2500, + 'recurring' => ['interval' => 'month', 'interval_count' => 1], + ], + 'quantity' => 2, + ], + ], $params['items']); + $this->assertSame([ + [ + 'price_data' => ['currency' => 'brl', 'product' => 'prod_item1', 'unit_amount' => 900], + 'quantity' => 1, + ], + ], $params['add_invoice_items']); + } + + public function testCreateSubscriptionWithACardThatRequiresAuthenticationIsInterrupted(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('setup_intents/requires_action')]); + + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_fake1'; + $subscription->creditCard = new CreditCard(); + $subscription->creditCard->token = 'pm_fake123'; + + try { + (new StripeGateway())->createSubscription($subscription); + $this->fail('Esperava ChargingException'); + } catch (ChargingException $e) { + $this->assertSame(DeclineCode::AUTHENTICATION_REQUIRED, $e->declineCode); + $this->assertNotNull($e->chargeResponse); + } + + $this->assertSame(['post /v1/setup_intents'], self::calledPaths($httpClient)); + } + + public function testCreateSubscriptionSendsTrialEndAndBillingCycleAnchor(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('subscriptions/trialing'), + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_fake1'; + $subscription->trialEndsAt = Carbon::createFromTimestamp(1789170793); + $subscription->nextBillingAt = Carbon::createFromTimestamp(1791157981); + + (new StripeGateway())->createSubscription($subscription); + + $params = $httpClient->calls[0][2]; + $this->assertSame(1789170793, $params['trial_end']); + $this->assertSame(1791157981, $params['billing_cycle_anchor']); + $this->assertArrayNotHasKey('trial_period_days', $params); + } + + public function testCreateSubscriptionRequiresCustomerAndPlanBeforeTheNetwork(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $gateway = new StripeGateway(); + + $withoutCustomer = new Subscription(); + $withoutCustomer->planId = 'plano_mensal'; + try { + $gateway->createSubscription($withoutCustomer); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException) { + } + + $withoutPlan = new Subscription(); + $withoutPlan->customer = new Customer(); + $withoutPlan->customer->id = 'cus_fake123'; + try { + $gateway->createSubscription($withoutPlan); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException) { + } + + $this->assertSame([], $httpClient->calls); + } + + #[DataProvider('statusFixtureProvider')] + public function testGetSubscriptionMapsEachStatus(string $fixture, SubscriptionStatus $expected): void + { + $response = self::fixture("subscriptions/{$fixture}"); + $responses = [$response]; + if (!empty($response['latest_invoice'])) { + $responses[] = self::fixture('invoices/paid'); + $responses[] = self::fixture('payment_intents/paid'); + } + RecordingStripeHttpClient::withResponses($responses); + + $result = $this->getSubscription($response['id']); + + $this->assertSame($expected, $result->status); + } + + public static function statusFixtureProvider(): array + { + return [ + 'incomplete' => ['incomplete', SubscriptionStatus::PENDING], + 'incomplete_expired' => ['incomplete_expired', SubscriptionStatus::EXPIRED], + 'trialing' => ['trialing', SubscriptionStatus::TRIALING], + 'active' => ['active', SubscriptionStatus::ACTIVE], + 'past_due' => ['past_due', SubscriptionStatus::PAST_DUE], + 'unpaid' => ['unpaid', SubscriptionStatus::PAST_DUE], + 'canceled' => ['canceled', SubscriptionStatus::CANCELED], + 'paused' => ['paused', SubscriptionStatus::PAUSED], + 'pause_collection' => ['active_pause_collection', SubscriptionStatus::PAUSED], + ]; + } + + public function testGetSubscriptionParsesTheModelAndReadsTheLatestInvoice(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('subscriptions/active'), + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $result = $this->getSubscription('sub_1UBJmkPjx0CusuMr3KQ2wXyZ'); + + $this->assertSame(['expand' => self::SUBSCRIPTION_EXPAND], $httpClient->calls[0][2]); + $this->assertSame('sub_1UBJmkPjx0CusuMr3KQ2wXyZ', $result->id); + $this->assertSame('plano_mensal', $result->planId); + $this->assertSame(10000, $result->amount); + $this->assertSame('cus_VBen1v8T4Qa6XX', $result->customer->id); + $this->assertSame(1791157981, $result->nextBillingAt->getTimestamp()); + $this->assertFalse($result->cancelAtPeriodEnd); + $this->assertNull($result->canceledAt); + $this->assertSame(1788565981, $result->createdAt->getTimestamp()); + $this->assertSame([], $result->metadata); + $this->assertSame(PaymentMethod::CREDIT_CARD, $result->paymentMethod); + $this->assertSame([PaymentMethod::CREDIT_CARD], $result->availablePaymentMethods); + $this->assertSame(InvoiceStatus::PAID, $result->latestInvoice->status); + $this->assertSame('stripe', $result->latestInvoice->gateway); + } + + public function testGetSubscriptionOnACanceledSubscriptionFillsCanceledAt(): void + { + $response = self::fixture('subscriptions/canceled'); + RecordingStripeHttpClient::withResponses([ + $response, + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $result = $this->getSubscription($response['id']); + + $this->assertSame(SubscriptionStatus::CANCELED, $result->status); + $this->assertSame(1788565992, $result->canceledAt->getTimestamp()); + } + + public function testSuspendPausesTheCollection(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('subscriptions/active_pause_collection')]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + + $result = (new StripeGateway())->suspendSubscription($subscription); + + $this->assertSame(['post /v1/subscriptions/sub_1UBJmkPjx0CusuMr3KQ2wXyZ'], self::calledPaths($httpClient)); + $this->assertSame(['behavior' => 'void'], $httpClient->calls[0][2]['pause_collection']); + $this->assertSame(SubscriptionStatus::PAUSED, $result->status); + } + + public function testResumeClearsThePauseAndTheScheduledCancellation(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('subscriptions/active')]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + + $result = (new StripeGateway())->resumeSubscription($subscription); + + $params = $httpClient->calls[0][2]; + $this->assertSame('', $params['pause_collection']); + // o encoder do stripe-php envia booleano como a string 'false' + $this->assertSame('false', $params['cancel_at_period_end']); + $this->assertSame(SubscriptionStatus::ACTIVE, $result->status); + } + + public function testCancelImmediatelyDeletesTheSubscription(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('subscriptions/canceled')]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + + $result = (new StripeGateway())->cancelSubscription($subscription); + + $this->assertSame(['delete /v1/subscriptions/sub_1UBJmkPjx0CusuMr3KQ2wXyZ'], self::calledPaths($httpClient)); + $this->assertSame(SubscriptionStatus::CANCELED, $result->status); + $this->assertSame(1788565992, $result->canceledAt->getTimestamp()); + } + + public function testCancelAtPeriodEndKeepsTheSubscriptionActiveAndFillsTheModel(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('subscriptions/active_cancel_at_period_end')]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + + $result = (new StripeGateway())->cancelSubscription($subscription, true); + + $this->assertSame(['post /v1/subscriptions/sub_1UBJmkPjx0CusuMr3KQ2wXyZ'], self::calledPaths($httpClient)); + $this->assertSame('true', $httpClient->calls[0][2]['cancel_at_period_end']); + $this->assertSame(SubscriptionStatus::ACTIVE, $result->status); + $this->assertTrue($result->cancelAtPeriodEnd); + $this->assertSame(1788565991, $result->canceledAt->getTimestamp()); + } + + #[DataProvider('prorationProvider')] + public function testChangePlanTranslatesTheProrationPolicy(ProrationBehavior $proration, string $expected, bool $readsInvoice): void + { + $changed = self::fixture('subscriptions/active'); + $changed['items']['data'][0]['price']['id'] = 'price_fake2'; + $changed['items']['data'][0]['price']['lookup_key'] = 'plano_anual'; + $responses = [ + self::priceListResponse('price_fake2', 'plano_anual'), + self::fixture('subscriptions/active'), + $changed, + ]; + if ($readsInvoice) { + $responses[] = self::fixture('invoices/paid'); + $responses[] = self::fixture('payment_intents/paid'); + } + $httpClient = RecordingStripeHttpClient::withResponses($responses); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + + $result = (new StripeGateway())->changeSubscriptionPlan($subscription, 'plano_anual', $proration); + + $params = $httpClient->calls[2][2]; + $this->assertSame([['id' => 'si_UBJmkPjx0CusuMrLq0v9Yb2', 'price' => 'price_fake2']], $params['items']); + $this->assertSame($expected, $params['proration_behavior']); + $this->assertSame('plano_anual', $result->planId); + $this->assertSame($readsInvoice, !is_null($result->latestInvoice)); + } + + public static function prorationProvider(): array + { + return [ + 'CHARGE_DIFFERENCE fatura na hora' => [ProrationBehavior::CHARGE_DIFFERENCE, 'always_invoice', true], + 'NONE não gera pró-rata' => [ProrationBehavior::NONE, 'none', false], + 'CREDIT deixa a pró-rata para a próxima fatura' => [ProrationBehavior::CREDIT, 'create_prorations', false], + ]; + } + + public function testChangePlanWithADifferentNextBillingAtIsRefusedBeforeTheNetwork(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $subscription->nextBillingAt = Carbon::parse('2030-01-01'); + + try { + (new StripeGateway())->changeSubscriptionPlan($subscription, 'plano_anual', ProrationBehavior::NONE); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::SUBSCRIPTIONS, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + } + + // outra hora do mesmo dia também é mudança: a comparação com o lido é exata + $sameDay = new Subscription(); + $sameDay->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $sameDay->nextBillingAt = Carbon::createFromTimestamp(1791157981 + 3600); + $sameDay->original = json_decode(json_encode(self::fixture('subscriptions/active'))); + + try { + (new StripeGateway())->changeSubscriptionPlan($sameDay, 'plano_anual', ProrationBehavior::NONE); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::SUBSCRIPTIONS, $e->capability); + } + $this->assertSame([], $httpClient->calls); + } + + public function testChangePlanWithTheNextBillingAtReadFromTheGatewayIsAccepted(): void + { + $changed = self::fixture('subscriptions/active'); + RecordingStripeHttpClient::withResponses([ + self::priceListResponse('price_fake2', 'plano_anual'), + self::fixture('subscriptions/active'), + $changed, + ]); + + // um model lido do gateway traz nextBillingAt preenchido com o current_period_end + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $subscription->nextBillingAt = Carbon::createFromTimestamp(1791157981); + $subscription->original = json_decode(json_encode(self::fixture('subscriptions/active'))); + + $result = (new StripeGateway())->changeSubscriptionPlan($subscription, 'plano_anual', ProrationBehavior::NONE); + + $this->assertSame(SubscriptionStatus::ACTIVE, $result->status); + } + + public function testPreviewPlanChangeReturnsTheRealProrationLines(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::priceListResponse('price_fake2', 'plano_anual'), + self::fixture('subscriptions/active'), + self::previewInvoiceResponse(), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + + $preview = (new StripeGateway())->previewSubscriptionPlanChange($subscription, 'plano_anual'); + + $this->assertSame([ + 'get /v1/prices', + 'get /v1/subscriptions/sub_1UBJmkPjx0CusuMr3KQ2wXyZ', + 'post /v1/invoices/create_preview', + ], self::calledPaths($httpClient)); + $params = $httpClient->calls[2][2]; + $this->assertSame('sub_1UBJmkPjx0CusuMr3KQ2wXyZ', $params['subscription']); + $this->assertSame( + [ + 'items' => [['id' => 'si_UBJmkPjx0CusuMrLq0v9Yb2', 'price' => 'price_fake2']], + 'proration_behavior' => 'always_invoice', + ], + $params['subscription_details'] + ); + + $this->assertSame(25005, $preview->amount); + $this->assertCount(3, $preview->items); + $this->assertSame('Unused time on Mensal', $preview->items[0]->description); + $this->assertSame(-5000, $preview->items[0]->price); + $this->assertSame('Anual', $preview->items[1]->description); + // linha com quantidade 2: price é o valor unitário (30000 dividido por 2) + $this->assertSame(2, $preview->items[1]->quantity); + $this->assertSame(15000, $preview->items[1]->price); + // valor que não divide pela quantidade vira linha de valor total, e a soma dos itens + // continua igual a amount + $this->assertSame(1, $preview->items[2]->quantity); + $this->assertSame(5, $preview->items[2]->price); + $this->assertSame( + $preview->amount, + array_sum(array_map(static fn ($item) => $item->price * $item->quantity, $preview->items)) + ); + $this->assertSame(1819904400, $preview->effectiveAt->getTimestamp()); + $this->assertTrue($preview->appliesImmediately); + $this->assertSame('stripe', $preview->gateway); + } + + public function testUpdateSubscriptionReplacesTheItemsDeclaratively(): void + { + $current = self::fixture('subscriptions/active'); + $current['items']['data'][] = self::extraItemResponse(); + $httpClient = RecordingStripeHttpClient::withResponses([ + $current, + self::fixture('subscriptions/active'), + ]); + + // lista desejada vazia: o item extra sai, o item do plano fica + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $subscription->items = []; + + (new StripeGateway())->updateSubscription($subscription); + + $this->assertSame([ + 'get /v1/subscriptions/sub_1UBJmkPjx0CusuMr3KQ2wXyZ', + 'post /v1/subscriptions/sub_1UBJmkPjx0CusuMr3KQ2wXyZ', + ], self::calledPaths($httpClient)); + $params = $httpClient->calls[1][2]; + $this->assertSame([['id' => 'si_extra1', 'deleted' => 'true']], $params['items']); + $this->assertSame('none', $params['proration_behavior']); + } + + public function testUpdateSubscriptionWritesCardTrialAndMetadata(): void + { + Carbon::setTestNow('2026-09-04 12:00:00'); + try { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('subscriptions/active')]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $subscription->creditCard = new CreditCard(); + $subscription->creditCard->id = 'pm_fake456'; + $subscription->trialDays = 7; + $subscription->metadata = ['origem' => 'teste']; + + (new StripeGateway())->updateSubscription($subscription); + + $params = $httpClient->calls[0][2]; + $this->assertSame('pm_fake456', $params['default_payment_method']); + $this->assertSame(Carbon::now()->addDays(7)->getTimestamp(), $params['trial_end']); + $this->assertSame(['origem' => 'teste'], $params['metadata']); + $this->assertNull($subscription->trialDays); + } finally { + Carbon::setTestNow(); + } + } + + public function testUpdateSubscriptionWritesThePaymentSettingsWhenTheMethodChanged(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('subscriptions/active')]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $subscription->paymentMethod = PaymentMethod::PIX; + + (new StripeGateway())->updateSubscription($subscription); + + $this->assertSame( + ['payment_method_types' => ['pix']], + $httpClient->calls[0][2]['payment_settings'] + ); + } + + /** + * Método e trial iguais aos lidos do gateway ficam fora do payload: um update que mexeu em + * outra coisa não reescreve o que já está na assinatura. + */ + public function testUpdateSubscriptionSkipsThePaymentMethodAndTrialReadFromTheGateway(): void + { + $original = json_decode(json_encode(self::fixture('subscriptions/trialing'))); + $httpClient = RecordingStripeHttpClient::withResponses([self::fixture('subscriptions/trialing')]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $subscription->availablePaymentMethods = [PaymentMethod::CREDIT_CARD]; + $subscription->trialEndsAt = Carbon::createFromTimestamp($original->trial_end); + $subscription->metadata = ['origem' => 'teste']; + $subscription->original = $original; + + (new StripeGateway())->updateSubscription($subscription); + + $params = $httpClient->calls[0][2]; + $this->assertArrayNotHasKey('payment_settings', $params); + $this->assertArrayNotHasKey('trial_end', $params); + $this->assertSame(['origem' => 'teste'], $params['metadata']); + } + + public function testUpdateSubscriptionUpdatesKeptItemsAndCreatesNewOnes(): void + { + $current = self::fixture('subscriptions/active'); + $current['items']['data'][] = self::extraItemResponse(); + $httpClient = RecordingStripeHttpClient::withResponses([ + $current, + self::productResponse('prod_item0'), + self::priceResponse(), + self::fixture('subscriptions/active'), + ]); + + $kept = new SubscriptionItem(); + $kept->id = 'si_extra1'; + $kept->quantity = 3; + + $new = new SubscriptionItem(); + $new->description = 'Consultas extras'; + $new->amount = 2500; + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $subscription->items = [$kept, $new]; + + (new StripeGateway())->updateSubscription($subscription); + + $params = $httpClient->calls[3][2]; + $this->assertSame([ + ['id' => 'si_extra1', 'quantity' => 3], + [ + 'price_data' => [ + 'currency' => 'brl', + 'product' => 'prod_item0', + 'unit_amount' => 2500, + 'recurring' => ['interval' => 'month', 'interval_count' => 1], + ], + 'quantity' => 1, + ], + ], $params['items']); + $this->assertSame('none', $params['proration_behavior']); + } + + /** + * A recusa do método vem antes de o cartão ser salvo: um cartão por token com uma lista + * sem cartão não pode ficar anexado ao cliente na Stripe. + */ + public function testUpdateSubscriptionValidatesTheMethodBeforeSavingTheCard(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $subscription->availablePaymentMethods = [PaymentMethod::PIX]; + $subscription->creditCard = new CreditCard(); + $subscription->creditCard->token = 'pm_fake123'; + + try { + (new StripeGateway())->updateSubscription($subscription); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException) { + } + $this->assertSame([], $httpClient->calls); + } + + /** + * Sem `planId` no model, o item do plano é o único cujo Price tem `lookup_key`: os Prices + * de itens extras criados pela lib não têm um, e a ordem da lista da Stripe não decide. + */ + public function testParseElectsThePlanItemByLookupKeyWhenThePlanIdIsUnknown(): void + { + $response = self::fixture('subscriptions/active'); + $extra = self::extraItemResponse(); + $extra['created'] = $response['items']['data'][0]['created']; + $extra['price']['lookup_key'] = null; + array_unshift($response['items']['data'], $extra); + RecordingStripeHttpClient::withResponses([ + $response, + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $result = $this->getSubscription('sub_1UBJmkPjx0CusuMr3KQ2wXyZ'); + + $this->assertSame('plano_mensal', $result->planId); + $this->assertCount(1, $result->items); + $this->assertSame('si_extra1', $result->items[0]->id); + } + + public function testUpdateSubscriptionRefusesMethodsTheDriverDoesNotCoverBeforeTheNetwork(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $gateway = new StripeGateway(); + + $multi = new Subscription(); + $multi->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $multi->availablePaymentMethods = [PaymentMethod::PIX, PaymentMethod::CREDIT_CARD]; + try { + $gateway->updateSubscription($multi); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::MULTIPLE_PAYMENT_METHODS, $e->capability); + } + + // o mandato de Pix Automático nasce com a assinatura: a troca para o método num + // model sem o mandato lido do gateway é recusada antes da rede + $automaticPix = new Subscription(); + $automaticPix->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $automaticPix->paymentMethod = PaymentMethod::AUTOMATIC_PIX; + try { + $gateway->updateSubscription($automaticPix); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::AUTOMATIC_PIX, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_NOT_IMPLEMENTED, $e->reason); + } + + $this->assertSame([], $httpClient->calls); + } + + public function testUpdateSubscriptionWithADifferentNextBillingAtIsRefusedBeforeTheNetwork(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $subscription->nextBillingAt = Carbon::parse('2030-01-01'); + + try { + (new StripeGateway())->updateSubscription($subscription); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::SUBSCRIPTIONS, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + } + $this->assertSame([], $httpClient->calls); + } + + public function testListSubscriptionsValidatesInputBeforeTheNetwork(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([]); + $gateway = new StripeGateway(); + $customer = new Customer(); + $customer->id = 'cus_fake123'; + + foreach ([ + [new Customer(), 1, 10], + [$customer, 0, 10], + [$customer, 1, 0], + [$customer, 1, 101], + ] as [$who, $page, $limit]) { + try { + $gateway->listSubscriptions($who, $page, $limit); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException) { + } + } + $this->assertSame([], $httpClient->calls); + } + + public function testListSubscriptionsListsEveryStatusWithoutTheLatestInvoice(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + [ + 'object' => 'list', + 'url' => '/v1/subscriptions', + 'has_more' => false, + 'data' => [self::fixture('subscriptions/active'), self::fixture('subscriptions/canceled')], + ], + ]); + + $customer = new Customer(); + $customer->id = 'cus_fake123'; + + $subscriptions = (new StripeGateway())->listSubscriptions($customer); + + $params = $httpClient->calls[0][2]; + $this->assertSame('cus_fake123', $params['customer']); + $this->assertSame('all', $params['status']); + $this->assertSame(100, $params['limit']); + $this->assertCount(2, $subscriptions); + $this->assertSame(SubscriptionStatus::ACTIVE, $subscriptions[0]->status); + $this->assertSame(SubscriptionStatus::CANCELED, $subscriptions[1]->status); + $this->assertNull($subscriptions[0]->latestInvoice); + } + + private function getSubscription(string $id): Subscription + { + $subscription = new Subscription(); + $subscription->id = $id; + + return (new StripeGateway())->getSubscription($subscription); + } + + public function testCreateSubscriptionWithADiscountCreatesTheCouponAndAppliesIt(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::couponResponse(), + self::fixture('subscriptions/active'), + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_fake1'; + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->amountOff = 500; + $subscription->discounts = [$discount]; + + (new StripeGateway())->createSubscription($subscription); + + $this->assertSame('post /v1/coupons', self::calledPaths($httpClient)[0]); + $this->assertSame( + ['name' => 'Promo', 'amount_off' => 500, 'currency' => 'brl', 'duration' => 'forever'], + $httpClient->calls[0][2] + ); + $this->assertSame([['coupon' => 'co_fake1']], $httpClient->calls[1][2]['discounts']); + } + + public function testDiscountWithOneCycleCreatesAOnceCouponWithPercentOff(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::couponResponse(['amount_off' => null, 'percent_off' => 10.0, 'duration' => 'once']), + self::fixture('subscriptions/active'), + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_fake1'; + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->percentOff = 10.0; + $discount->cycles = 1; + $subscription->discounts = [$discount]; + + (new StripeGateway())->createSubscription($subscription); + + $this->assertSame( + ['name' => 'Promo', 'percent_off' => 10.0, 'duration' => 'once'], + $httpClient->calls[0][2] + ); + } + + /** + * `cycles` acima de 1 vira `repeating` com os meses dos ciclos do plano, lido do Price + * quando o intervalo ainda não é conhecido. + */ + public function testDiscountCyclesBecomeTheMonthsOfTheMonthlyPlan(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::priceResponse(), + self::couponResponse(['duration' => 'repeating', 'duration_in_months' => 3]), + self::fixture('subscriptions/active'), + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_fake1'; + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->amountOff = 500; + $discount->cycles = 3; + $subscription->discounts = [$discount]; + + (new StripeGateway())->createSubscription($subscription); + + $this->assertSame('get /v1/prices/price_fake1', self::calledPaths($httpClient)[0]); + $this->assertSame('repeating', $httpClient->calls[1][2]['duration']); + $this->assertSame(3, $httpClient->calls[1][2]['duration_in_months']); + } + + public function testDiscountValidUntilBecomesWholeMonthsRoundedUp(): void + { + Carbon::setTestNow('2026-09-04 12:00:00'); + try { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::couponResponse(['duration' => 'repeating', 'duration_in_months' => 3]), + self::fixture('subscriptions/active'), + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_fake1'; + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->amountOff = 500; + $discount->validUntil = Carbon::parse('2026-11-10'); + $subscription->discounts = [$discount]; + + (new StripeGateway())->createSubscription($subscription); + + $this->assertSame('repeating', $httpClient->calls[0][2]['duration']); + $this->assertSame(3, $httpClient->calls[0][2]['duration_in_months']); + } finally { + Carbon::setTestNow(); + } + } + + /** + * O cupom da Stripe dura meses inteiros, então `cycles` acima de 1 num plano semanal não + * tem duração equivalente e é recusado antes de criar o cupom. + */ + public function testDiscountCyclesOnAWeeklyPlanAreRefused(): void + { + $weeklyPrice = self::priceResponse(); + $weeklyPrice['recurring']['interval'] = 'week'; + $httpClient = RecordingStripeHttpClient::withResponses([$weeklyPrice]); + + $subscription = self::subscriptionModel(); + $subscription->planId = 'price_fake1'; + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->amountOff = 500; + $discount->cycles = 3; + $subscription->discounts = [$discount]; + + try { + (new StripeGateway())->createSubscription($subscription); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::COUPONS, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + $this->assertStringContainsString('meses inteiros', $e->getMessage()); + } + $this->assertSame(['get /v1/prices/price_fake1'], self::calledPaths($httpClient)); + } + + public function testGetSubscriptionParsesTheDiscounts(): void + { + $fixture = self::fixture('subscriptions/active'); + $once = self::discountResponse(['id' => 'co_fake2', 'duration' => 'once']); + $fixture['discounts'] = [ + self::discountResponse(['duration' => 'repeating', 'duration_in_months' => 3], 1796500000), + $once, + ]; + RecordingStripeHttpClient::withResponses([ + $fixture, + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + ]); + + $result = $this->getSubscription('sub_1UBJmkPjx0CusuMr3KQ2wXyZ'); + + $this->assertCount(2, $result->discounts); + $discount = $result->discounts[0]; + $this->assertSame('co_fake1', $discount->id); + $this->assertSame('Promo', $discount->description); + $this->assertSame(500, $discount->amountOff); + $this->assertNull($discount->percentOff); + $this->assertNull($discount->cycles); + $this->assertSame(1796500000, $discount->validUntil->getTimestamp()); + + $this->assertSame('co_fake2', $result->discounts[1]->id); + $this->assertSame(1, $result->discounts[1]->cycles); + $this->assertNull($result->discounts[1]->validUntil); + } + + /** + * Um discount que veio como id (sem expand) não tem o Coupon para ler: a lista fica nula, + * e um `save()` posterior não toca os descontos da assinatura. + */ + public function testUnexpandedDiscountsDoNotOverwriteTheList(): void + { + $fixture = self::fixture('subscriptions/active'); + $fixture['discounts'] = ['di_fake1']; + $httpClient = RecordingStripeHttpClient::withResponses([ + $fixture, + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + $fixture, + ]); + + $subscription = $this->getSubscription('sub_1UBJmkPjx0CusuMr3KQ2wXyZ'); + $this->assertNull($subscription->discounts); + + $subscription->items = null; + (new StripeGateway())->updateSubscription($subscription); + + $this->assertCount(4, $httpClient->calls); + $this->assertArrayNotHasKey('discounts', $httpClient->calls[3][2]); + } + + /** + * No update, os meses de `cycles` vêm do intervalo do plano lido em `original`, sem + * requisição extra ao Price. + */ + public function testUpdateDiscountCyclesReadThePlanIntervalFromTheOriginal(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('subscriptions/active'), + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + self::couponResponse(['duration' => 'repeating', 'duration_in_months' => 2]), + self::fixture('subscriptions/active'), + ]); + + $subscription = $this->getSubscription('sub_1UBJmkPjx0CusuMr3KQ2wXyZ'); + $subscription->items = null; + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->amountOff = 500; + $discount->cycles = 2; + $subscription->discounts = [$discount]; + + (new StripeGateway())->updateSubscription($subscription); + + $this->assertSame('post /v1/coupons', self::calledPaths($httpClient)[3]); + $this->assertSame(2, $httpClient->calls[3][2]['duration_in_months']); + $this->assertNotContains('get /v1/prices/price_fake1', self::calledPaths($httpClient)); + } + + public function testUpdateReplacesTheDiscounts(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::couponResponse(), + self::fixture('subscriptions/active'), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->amountOff = 500; + $subscription->discounts = [$discount]; + + (new StripeGateway())->updateSubscription($subscription); + + $this->assertSame([ + 'post /v1/coupons', + 'post /v1/subscriptions/sub_1UBJmkPjx0CusuMr3KQ2wXyZ', + ], self::calledPaths($httpClient)); + $this->assertSame([['coupon' => 'co_fake1']], $httpClient->calls[1][2]['discounts']); + } + + public function testUpdateWithAnEmptyDiscountListClearsThem(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + self::fixture('subscriptions/active'), + ]); + + $subscription = new Subscription(); + $subscription->id = 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ'; + $subscription->discounts = []; + + (new StripeGateway())->updateSubscription($subscription); + + $this->assertSame('', $httpClient->calls[0][2]['discounts']); + } + + /** + * Um model lido do gateway traz os descontos com o id do Coupon; salvar sem mexer neles + * não recria cupom nem reenvia `discounts`. + */ + public function testUpdateKeepsTheDiscountsThatCameFromTheGateway(): void + { + $fixture = self::fixture('subscriptions/active'); + $fixture['discounts'] = [self::discountResponse()]; + $httpClient = RecordingStripeHttpClient::withResponses([ + $fixture, + self::fixture('invoices/paid'), + self::fixture('payment_intents/paid'), + $fixture, + ]); + + $subscription = $this->getSubscription('sub_1UBJmkPjx0CusuMr3KQ2wXyZ'); + $subscription->items = null; + + (new StripeGateway())->updateSubscription($subscription); + + $this->assertCount(4, $httpClient->calls); + $this->assertSame('post /v1/subscriptions/sub_1UBJmkPjx0CusuMr3KQ2wXyZ', self::calledPaths($httpClient)[3]); + $this->assertArrayNotHasKey('discounts', $httpClient->calls[3][2]); + } + + private static function couponResponse(array $overrides = []): array + { + return array_filter(array_merge([ + 'id' => 'co_fake1', + 'object' => 'coupon', + 'amount_off' => 500, + 'currency' => 'brl', + 'percent_off' => null, + 'duration' => 'forever', + 'duration_in_months' => null, + 'name' => 'Promo', + 'valid' => true, + 'created' => 1786700000, + 'metadata' => [], + ], $overrides), static fn ($value) => !is_null($value)); + } + + private static function discountResponse(array $couponOverrides = [], ?int $end = null): array + { + return [ + 'id' => 'di_fake1', + 'object' => 'discount', + 'source' => ['coupon' => self::couponResponse($couponOverrides), 'type' => 'coupon'], + 'customer' => 'cus_VBen1v8T4Qa6XX', + 'subscription' => 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ', + 'start' => 1788565981, + 'end' => $end, + ]; + } + + private static function subscriptionModel(): Subscription + { + $subscription = new Subscription(); + $subscription->customer = new Customer(); + $subscription->customer->id = 'cus_fake123'; + $subscription->planId = 'plano_mensal'; + + return $subscription; + } + + /** + * @return string[] `método caminho` de cada chamada gravada + */ + private static function calledPaths(RecordingStripeHttpClient $httpClient): array + { + return array_map( + static fn (array $call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), + $httpClient->calls + ); + } + + private static function fixture(string $path): array + { + return json_decode(file_get_contents(__DIR__ . "/../../fixtures/stripe/{$path}.json"), true); + } + + private static function productResponse(string $id): array + { + return ['id' => $id, 'object' => 'product', 'name' => 'Produto', 'active' => true, 'created' => 1786700000, 'metadata' => []]; + } + + private static function priceResponse(string $id = 'price_fake1', string $lookupKey = 'plano_mensal'): array + { + return [ + 'id' => $id, + 'object' => 'price', + 'active' => true, + 'currency' => 'brl', + 'lookup_key' => $lookupKey, + 'nickname' => null, + 'created' => 1786700000, + 'product' => 'prod_fake1', + 'recurring' => ['interval' => 'month', 'interval_count' => 1, 'usage_type' => 'licensed'], + 'type' => 'recurring', + 'unit_amount' => 10000, + 'unit_amount_decimal' => '10000', + ]; + } + + private static function priceListResponse(string $id = 'price_fake1', string $lookupKey = 'plano_mensal'): array + { + return [ + 'object' => 'list', + 'url' => '/v1/prices', + 'has_more' => false, + 'data' => [self::priceResponse($id, $lookupKey)], + ]; + } + + private static function extraItemResponse(): array + { + return [ + 'id' => 'si_extra1', + 'object' => 'subscription_item', + // criado depois do item do plano: o mais antigo é lido como o do plano + 'created' => 1788566500, + 'quantity' => 1, + 'subscription' => 'sub_1UBJmkPjx0CusuMr3KQ2wXyZ', + 'price' => self::priceResponse('price_extra1', ''), + ]; + } + + /** + * Prévia de fatura da troca de plano: crédito do período não usado e cobrança do plano + * novo, no formato de `invoices.create_preview`. + */ + private static function previewInvoiceResponse(): array + { + return [ + 'id' => 'upcoming_in_fake1', + 'object' => 'invoice', + 'total' => 25005, + 'currency' => 'brl', + 'lines' => [ + 'object' => 'list', + 'has_more' => false, + 'data' => [ + [ + 'id' => 'il_fake1', + 'object' => 'line_item', + 'description' => 'Unused time on Mensal', + 'amount' => -5000, + 'quantity' => 1, + 'period' => ['start' => 1788368400, 'end' => 1791157981], + ], + [ + 'id' => 'il_fake2', + 'object' => 'line_item', + 'description' => 'Anual', + 'amount' => 30000, + 'quantity' => 2, + 'period' => ['start' => 1788368400, 'end' => 1819904400], + ], + [ + 'id' => 'il_fake3', + 'object' => 'line_item', + 'description' => 'Ajuste', + 'amount' => 5, + 'quantity' => 3, + 'period' => ['start' => 1788368400, 'end' => 1790960400], + ], + ], + ], + ]; + } +} diff --git a/tests/Unit/Idempotency/CacheIdempotencyStoreTest.php b/tests/Unit/Idempotency/CacheIdempotencyStoreTest.php new file mode 100644 index 0000000..9102b62 --- /dev/null +++ b/tests/Unit/Idempotency/CacheIdempotencyStoreTest.php @@ -0,0 +1,205 @@ +cache = new Repository(new ArrayStore()); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + + parent::tearDown(); + } + + public function testExecutesTheOperationOnceAndStoresTheResultUnderThePrefixedKey(): void + { + $store = new CacheIdempotencyStore($this->cache); + $executions = 0; + $operation = function () use (&$executions) { + $executions++; + + return (object) ['id' => 'inv_1']; + }; + + $first = $store->remember('chave', $operation, 60); + $second = $store->remember('chave', $operation, 60); + + $this->assertSame(1, $executions); + $this->assertEquals($first, $second); + $this->assertTrue($store->has('chave')); + $this->assertTrue($this->cache->has(CacheIdempotencyStore::DEFAULT_PREFIX . 'chave')); + // o lock é liberado ao fim da execução + $this->assertTrue($this->cache->getStore()->lock(CacheIdempotencyStore::DEFAULT_PREFIX . 'chave:lock')->get()); + } + + public function testACustomPrefixIsApplied(): void + { + $store = new CacheIdempotencyStore($this->cache, 'app:idem:'); + + $store->remember('chave', fn () => 'ok', 60); + + $this->assertTrue($this->cache->has('app:idem:chave')); + $this->assertFalse($this->cache->has(CacheIdempotencyStore::DEFAULT_PREFIX . 'chave')); + } + + public function testANullResultCountsAsStored(): void + { + $store = new CacheIdempotencyStore($this->cache); + $executions = 0; + $operation = function () use (&$executions) { + $executions++; + + return null; + }; + + $this->assertNull($store->remember('chave', $operation, 60)); + $this->assertNull($store->remember('chave', $operation, 60)); + $this->assertSame(1, $executions); + $this->assertTrue($store->has('chave')); + } + + public function testTheResultExpiresAfterTheTtl(): void + { + $store = new CacheIdempotencyStore($this->cache); + $executions = 0; + $operation = function () use (&$executions) { + return ++$executions; + }; + + $store->remember('chave', $operation, 60); + Carbon::setTestNow('2026-09-02 12:01:01'); + + $this->assertFalse($store->has('chave')); + $this->assertSame(2, $store->remember('chave', $operation, 60)); + } + + public function testAnOperationThatThrowsIsNotStoredAndReleasesTheLock(): void + { + $store = new CacheIdempotencyStore($this->cache); + + try { + $store->remember('chave', function () { + throw new \RuntimeException('falha transitória'); + }, 60); + $this->fail('Esperava RuntimeException'); + } catch (\RuntimeException $e) { + $this->assertFalse($store->has('chave')); + } + + $this->assertSame('ok', $store->remember('chave', fn () => 'ok', 60)); + } + + public function testAKeyWhoseLockIsHeldElsewhereIsAConflict(): void + { + $store = new CacheIdempotencyStore($this->cache); + $held = $this->cache->getStore()->lock(CacheIdempotencyStore::DEFAULT_PREFIX . 'chave:lock', 60, 'outro-processo'); + $this->assertTrue($held->get()); + + try { + $store->remember('chave', fn () => 'nunca executa', 60); + $this->fail('Esperava IdempotencyConflictException'); + } catch (IdempotencyConflictException $e) { + $this->assertStringContainsString('[chave]', $e->getMessage()); + $this->assertNull($e->httpStatus); + $this->assertFalse($store->has('chave')); + } finally { + $held->release(); + } + } + + public function testACacheStoreWithoutLockSupportIsAConfigurationError(): void + { + $storeWithoutLock = new class implements Store { + private array $items = []; + + public function get($key) + { + return $this->items[$key] ?? null; + } + + public function many(array $keys) + { + return array_map(fn ($key) => $this->get($key), array_combine($keys, $keys)); + } + + public function put($key, $value, $seconds) + { + $this->items[$key] = $value; + + return true; + } + + public function putMany(array $values, $seconds) + { + foreach ($values as $key => $value) { + $this->put($key, $value, $seconds); + } + + return true; + } + + public function increment($key, $value = 1) + { + return $this->items[$key] = ($this->items[$key] ?? 0) + $value; + } + + public function decrement($key, $value = 1) + { + return $this->increment($key, -$value); + } + + public function forever($key, $value) + { + return $this->put($key, $value, 0); + } + + public function forget($key) + { + unset($this->items[$key]); + + return true; + } + + public function flush() + { + $this->items = []; + + return true; + } + + public function getPrefix() + { + return ''; + } + }; + $store = new CacheIdempotencyStore(new Repository($storeWithoutLock)); + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessageMatches('/não suporta lock/'); + + $store->remember('chave', fn () => 'nunca executa', 60); + } +} diff --git a/tests/Unit/Idempotency/InMemoryIdempotencyStoreTest.php b/tests/Unit/Idempotency/InMemoryIdempotencyStoreTest.php new file mode 100644 index 0000000..03a6e03 --- /dev/null +++ b/tests/Unit/Idempotency/InMemoryIdempotencyStoreTest.php @@ -0,0 +1,134 @@ + 'inv_1', 'execution' => $executions]; + }; + + $first = $store->remember('chave', $operation, 60); + $second = $store->remember('chave', $operation, 60); + + $this->assertSame(1, $executions); + $this->assertSame($first, $second); + $this->assertTrue($store->has('chave')); + $this->assertFalse($store->has('outra')); + } + + public function testDistinctKeysExecuteSeparately(): void + { + $store = new InMemoryIdempotencyStore(); + $executions = 0; + $operation = function () use (&$executions) { + return ++$executions; + }; + + $this->assertSame(1, $store->remember('a', $operation, 60)); + $this->assertSame(2, $store->remember('b', $operation, 60)); + } + + public function testANullResultIsStoredToo(): void + { + $store = new InMemoryIdempotencyStore(); + $executions = 0; + $operation = function () use (&$executions) { + $executions++; + + return null; + }; + + $this->assertNull($store->remember('chave', $operation, 60)); + $this->assertNull($store->remember('chave', $operation, 60)); + $this->assertSame(1, $executions); + $this->assertTrue($store->has('chave')); + } + + public function testTheResultExpiresAfterTheTtl(): void + { + $store = new InMemoryIdempotencyStore(); + $executions = 0; + $operation = function () use (&$executions) { + return ++$executions; + }; + + $store->remember('chave', $operation, 60); + Carbon::setTestNow('2026-09-02 12:01:00'); + + $this->assertFalse($store->has('chave')); + $this->assertSame(2, $store->remember('chave', $operation, 60)); + } + + public function testAnOperationThatThrowsIsNotStored(): void + { + $store = new InMemoryIdempotencyStore(); + $executions = 0; + $operation = function () use (&$executions) { + $executions++; + if ($executions === 1) { + throw new \RuntimeException('falha transitória'); + } + + return 'ok'; + }; + + try { + $store->remember('chave', $operation, 60); + $this->fail('Esperava RuntimeException'); + } catch (\RuntimeException $e) { + $this->assertFalse($store->has('chave')); + } + + $this->assertSame('ok', $store->remember('chave', $operation, 60)); + $this->assertSame(2, $executions); + } + + public function testAConcurrentExecutionWithTheSameKeyIsAConflict(): void + { + $store = new InMemoryIdempotencyStore(); + + $this->expectException(IdempotencyConflictException::class); + $this->expectExceptionMessageMatches('/\[chave\]/'); + + $store->remember('chave', function () use ($store) { + // reentrância com a mesma chave é a única concorrência possível num só processo + return $store->remember('chave', fn () => 'aninhada', 60); + }, 60); + } + + public function testForgetDropsTheStoredResult(): void + { + $store = new InMemoryIdempotencyStore(); + $store->remember('chave', fn () => 'ok', 60); + + $store->forget('chave'); + + $this->assertFalse($store->has('chave')); + } +} diff --git a/tests/Unit/IdempotencyKeyPropagationTest.php b/tests/Unit/IdempotencyKeyPropagationTest.php new file mode 100644 index 0000000..e99c421 --- /dev/null +++ b/tests/Unit/IdempotencyKeyPropagationTest.php @@ -0,0 +1,362 @@ + método do driver e argumentos recebidos */ + private array $calls = []; + + protected function setUp(): void + { + parent::setUp(); + + $this->calls = []; + $app = new Container(); + $app->instance('config', new Repository(['multi-payment' => [ + 'default' => 'falso', + 'gateways' => ['falso' => ['class' => self::OVERLOADED_GATEWAY]], + ]])); + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + Mockery::close(); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testFacadeInvoiceOperationsPassTheKey(): void + { + $gateway = $this->gateway([ + 'refundInvoice' => fn () => new Refund(), + 'cancelInvoice' => fn (Invoice $i) => $i, + 'chargeInvoiceWithCreditCard' => fn (Invoice $i) => $i, + 'duplicateInvoice' => fn () => new Invoice(), + 'rescheduleAutomaticPixPayment' => fn (Invoice $i) => $i, + ]); + $expiresAt = Carbon::parse('2026-10-01'); + + $payment = new MultiPayment($gateway); + $payment->refundInvoice('inv_1', 500, 'k-refund'); + $payment->cancelInvoice('inv_1', 'k-cancel'); + $payment->chargeInvoiceWithCreditCard('inv_1', 'tok_1', null, 'k-charge'); + $payment->duplicateInvoice('inv_1', $expiresAt, ['x' => 1], 'k-dup'); + $payment->rescheduleAutomaticPixPayment('inv_1', 'k-resched'); + + $this->assertSame([ + 'refundInvoice', 'cancelInvoice', 'chargeInvoiceWithCreditCard', 'duplicateInvoice', 'rescheduleAutomaticPixPayment', + ], array_column($this->calls, 0)); + [$refund, $cancel, $charge, $duplicate, $reschedule] = array_column($this->calls, 1); + $this->assertSame('inv_1', $refund[0]->id); + $this->assertSame(500, $refund[1]); + $this->assertSame('k-refund', $refund[2]); + $this->assertNull($refund[0]->refundedAmount, 'o valor vai como argumento; refundedAmount é só de leitura'); + $this->assertSame(['inv_1', 'k-cancel'], [$cancel[0]->id, $cancel[1]]); + $this->assertSame(['tok_1', 'k-charge'], [$charge[0]->creditCard->token, $charge[1]]); + $this->assertSame($expiresAt, $duplicate[1]); + $this->assertSame([['x' => 1], 'k-dup'], [$duplicate[2], $duplicate[3]]); + $this->assertSame(['inv_1', 'k-resched'], [$reschedule[0]->id, $reschedule[1]]); + } + + public function testFacadeCustomerCardAndAutomaticPixOperationsPassTheKey(): void + { + $gateway = $this->gateway([ + 'deleteCreditCard' => fn () => null, + 'setCustomerDefaultCard' => fn (Customer $c) => $c, + 'cancelAutomaticPixRecurrence' => fn () => new AutomaticPixCancellation(), + 'cancelAutomaticPixScheduledPayment' => fn () => new AutomaticPixCancellation(), + 'confirmCreditCardSetup' => fn () => new CreditCard(), + ]); + + $payment = new MultiPayment($gateway); + $payment->deleteCard('cus_1', 'pm_1', 'k-delete'); + $payment->setDefaultCard('cus_1', 'pm_1', 'k-default'); + $payment->cancelAutomaticPixRecurrence('rec_1', 'k-rec'); + $payment->cancelAutomaticPixScheduledPayment('pay_1', 'E1', 'k-pay'); + $payment->confirmCreditCardSetup('seti_1', 'k-confirm'); + + [$delete, $default, $recurrence, $payment, $confirm] = array_column($this->calls, 1); + $this->assertSame(['seti_1', 'k-confirm'], $confirm); + $this->assertSame(['pm_1', 'cus_1', 'k-delete'], [$delete[0]->id, $delete[0]->customer->id, $delete[1]]); + $this->assertSame(['cus_1', 'pm_1', 'k-default'], [$default[0]->id, $default[1], $default[2]]); + $this->assertInstanceOf(AutomaticPix::class, $recurrence[0]); + $this->assertSame('k-rec', $recurrence[1]); + $this->assertInstanceOf(AutomaticPixCharge::class, $payment[0]); + $this->assertSame('k-pay', $payment[1]); + } + + /** + * `charge()` cria o cliente antes da fatura, com a chave derivada, e a fatura com a chave. + */ + public function testChargeCreatesTheCustomerWithADerivedKeyAndTheInvoiceWithTheKey(): void + { + $gateway = $this->gateway([ + 'createCustomer' => function (Customer $c) { + $c->id = 'cus_novo'; + + return $c; + }, + 'createInvoice' => fn (Invoice $i) => $i, + ]); + + (new MultiPayment($gateway))->charge([ + 'amount' => 10000, + 'available_payment_methods' => ['pix'], + 'customer' => ['name' => 'Cliente', 'email' => 'cliente@example.com', 'tax_document' => '20176996915'], + ], 'k-charge'); + + $this->assertSame(['createCustomer', 'createInvoice'], array_column($this->calls, 0)); + $this->assertSame('k-charge:customer', $this->calls[0][1][1]); + $this->assertSame('cus_novo', $this->calls[1][1][0]->customer->id); + $this->assertSame('k-charge', $this->calls[1][1][1]); + } + + public function testChargeWithoutAKeyCreatesTheCustomerWithoutAKey(): void + { + $gateway = $this->gateway([ + 'createCustomer' => function (Customer $c) { + $c->id = 'cus_novo'; + + return $c; + }, + 'createInvoice' => fn (Invoice $i) => $i, + ]); + + (new MultiPayment($gateway))->charge([ + 'amount' => 10000, + 'available_payment_methods' => ['pix'], + 'customer' => ['name' => 'Cliente', 'email' => 'cliente@example.com'], + ]); + + $this->assertSame([null, null], array_map(fn (array $call) => $call[1][1], $this->calls)); + } + + public function testBuildersPassTheKeyGivenToWithIdempotencyKey(): void + { + $gateway = $this->gateway([ + 'createInvoice' => fn (Invoice $i) => $i, + 'createCustomer' => fn (Customer $c) => $c, + 'createCreditCard' => fn (CreditCard $c) => $c, + ]); + $customer = new Customer(); + $customer->id = 'cus_1'; + $customer->name = 'Cliente'; + $customer->email = 'cliente@example.com'; + + $payment = new MultiPayment($gateway); + $payment->newInvoice() + ->setCustomer($customer) + ->addItem('Item', 10000, 1) + ->setAvailablePaymentMethods(['pix']) + ->withIdempotencyKey('k-invoice') + ->create(); + $payment->newCustomer() + ->setName('Cliente') + ->setEmail('cliente@example.com') + ->withIdempotencyKey('k-customer') + ->create(); + $payment->newCreditCard() + ->setCustomer($customer) + ->setToken('tok_1') + ->withIdempotencyKey('k-card') + ->create(); + $payment->newCustomer() + ->setName('Sem chave') + ->setEmail('semchave@example.com') + ->create(); + + $this->assertSame([ + ['createInvoice', 'k-invoice'], + ['createCustomer', 'k-customer'], + ['createCreditCard', 'k-card'], + ['createCustomer', null], + ], array_map(fn (array $call) => [$call[0], $call[1][1]], $this->calls)); + $this->assertSame('tok_1', $this->calls[2][1][0]->token); + } + + public function testModelSaveAndDeletePassTheKey(): void + { + $gateway = $this->gateway([ + 'createCustomer' => fn (Customer $c) => $c, + 'updateCustomer' => fn (Customer $c) => $c, + 'deleteCreditCard' => fn () => null, + 'confirmCreditCardSetup' => fn () => new CreditCard(), + ]); + + $customer = new Customer(); + $customer->name = 'Cliente'; + $customer->email = 'cliente@example.com'; + $customer->save($gateway, true, 'k-create'); + $customer->id = 'cus_1'; + $customer->save($gateway, true, 'k-update'); + + $creditCard = new CreditCard(); + $creditCard->id = 'pm_1'; + $creditCard->delete($gateway, 'k-delete'); + + $pendingCard = new CreditCard(); + $pendingCard->setupId = 'seti_1'; + $pendingCard->confirmSetup($gateway, 'k-confirm'); + + $this->assertSame([ + ['createCustomer', 'k-create'], + ['updateCustomer', 'k-update'], + ['deleteCreditCard', 'k-delete'], + ['confirmCreditCardSetup', 'k-confirm'], + ], array_map(fn (array $call) => [$call[0], $call[1][1]], $this->calls)); + $this->assertSame('seti_1', $this->calls[3][1][0]); + } + + public function testInvoiceSaveCreatesTheCustomerWithADerivedKey(): void + { + $gateway = $this->gateway([ + 'createCustomer' => function (Customer $c) { + $c->id = 'cus_novo'; + + return $c; + }, + 'createInvoice' => fn (Invoice $i) => $i, + ]); + + $invoice = new Invoice(); + $invoice->fill([ + 'amount' => 10000, + 'available_payment_methods' => ['pix'], + 'customer' => ['name' => 'Cliente', 'email' => 'cliente@example.com'], + ]); + $invoice->save($gateway, true, 'k-invoice'); + + $this->assertSame([ + ['createCustomer', 'k-invoice:customer'], + ['createInvoice', 'k-invoice'], + ], array_map(fn (array $call) => [$call[0], $call[1][1]], $this->calls)); + } + + public function testModelCreateAndCustomerDeleteCreditCardPassTheKey(): void + { + $gateway = $this->gateway([ + 'createCustomer' => fn (Customer $c) => $c, + 'deleteCreditCard' => fn () => null, + ]); + + $customer = new Customer(); + $customer->create(['name' => 'Cliente', 'email' => 'cliente@example.com'], $gateway, 'k-create'); + $customer->id = 'cus_1'; + $customer->deleteCreditCard('pm_1', $gateway, 'k-delete'); + + $this->assertSame([ + ['createCustomer', 'k-create'], + ['deleteCreditCard', 'k-delete'], + ], array_map(fn (array $call) => [$call[0], $call[1][1]], $this->calls)); + $this->assertSame('cus_1', $this->calls[1][1][0]->customer->id); + } + + public function testSubscriptionSaveCreatesTheCustomerWithADerivedKeyAndPlanSavePassesTheKey(): void + { + $gateway = Mockery::mock(GatewayContract::class, SubscriptionContract::class, \Potelo\MultiPayment\Contracts\PlanContract::class); + $gateway->shouldReceive('supports')->andReturn(true); + $gateway->shouldReceive('__toString')->andReturn('falso'); + $gateway->shouldReceive('createCustomer')->once() + ->with(Mockery::type(Customer::class), 'k-sub:customer') + ->andReturnUsing(function (Customer $c) { + $c->id = 'cus_novo'; + + return $c; + }); + $gateway->shouldReceive('createSubscription')->once() + ->with(Mockery::type(Subscription::class), 'k-sub') + ->andReturnUsing(fn (Subscription $s) => $s); + $gateway->shouldReceive('createPlan')->once() + ->with(Mockery::type(\Potelo\MultiPayment\Models\Plan::class), 'k-plan') + ->andReturnUsing(fn ($p) => $p); + + $subscription = new Subscription(); + $subscription->fill(['plan_id' => 'plano_mensal', 'customer' => ['name' => 'Cliente', 'email' => 'cliente@example.com']]); + $subscription->save($gateway, true, 'k-sub'); + $this->assertSame('cus_novo', $subscription->customer->id); + + $plan = new \Potelo\MultiPayment\Models\Plan(); + $plan->name = 'Mensal'; + $plan->amount = 10000; + $plan->interval = \Potelo\MultiPayment\Enums\PlanInterval::MONTH; + $plan->save($gateway, true, 'k-plan'); + } + + public function testSubscriptionDomainMethodsPassTheKey(): void + { + $gateway = Mockery::mock(GatewayContract::class, SubscriptionContract::class); + $gateway->shouldReceive('supports')->andReturn(true); + $gateway->shouldReceive('__toString')->andReturn('falso'); + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $gateway->shouldReceive('suspendSubscription')->once()->with($subscription, 'k-suspend')->andReturn($subscription); + $gateway->shouldReceive('resumeSubscription')->once()->with($subscription, 'k-resume')->andReturn($subscription); + $gateway->shouldReceive('cancelSubscription')->once()->with($subscription, true, 'k-cancel')->andReturn($subscription); + $gateway->shouldReceive('changeSubscriptionPlan')->once()->with($subscription, 'plano_anual', \Potelo\MultiPayment\Enums\ProrationBehavior::NONE, 'k-change')->andReturn($subscription); + $gateway->shouldReceive('updateSubscription')->once()->with($subscription, 'k-update')->andReturn($subscription); + + $this->assertSame($subscription, $subscription->suspend($gateway, 'k-suspend')); + $this->assertSame($subscription, $subscription->resume($gateway, 'k-resume')); + $this->assertSame($subscription, $subscription->cancel(true, $gateway, 'k-cancel')); + $this->assertSame($subscription, $subscription->changePlan('plano_anual', \Potelo\MultiPayment\Enums\ProrationBehavior::NONE, $gateway, 'k-change')); + $subscription->save($gateway, true, 'k-update'); + $this->assertSame('sub_1', $subscription->id); + } + + /** + * Gateway falso que grava cada chamada em `$this->calls` e responde com o retorno informado + * por método. + * + * @param array $returns método do driver e o que ele devolve + * @return GatewayContract + */ + private function gateway(array $returns): GatewayContract + { + $gateway = Mockery::mock('overload:' . self::OVERLOADED_GATEWAY, GatewayContract::class); + $gateway->shouldReceive('supports')->andReturn(true); + $gateway->shouldReceive('__toString')->andReturn('falso'); + + foreach ($returns as $method => $return) { + $gateway->shouldReceive($method)->andReturnUsing(function (...$args) use ($method, $return) { + $this->calls[] = [$method, $args]; + + return $return(...$args); + }); + } + + return $gateway; + } +} diff --git a/tests/Unit/InvoiceTest.php b/tests/Unit/InvoiceTest.php new file mode 100644 index 0000000..c780713 --- /dev/null +++ b/tests/Unit/InvoiceTest.php @@ -0,0 +1,442 @@ + [InvoiceStatus::PAID, true], + 'paga (string antiga)' => [Invoice::STATUS_PAID, true], + 'parcialmente estornada' => [Invoice::STATUS_PARTIALLY_REFUNDED, true], + 'parcialmente paga' => ['partially_paid', true], + 'paga por fora' => ['externally_paid', true], + 'pendente' => [Invoice::STATUS_PENDING, false], + 'cancelada' => [Invoice::STATUS_CANCELED, false], + 'estornada' => [Invoice::STATUS_REFUNDED, false], + 'em disputa' => [Invoice::STATUS_DISPUTED, false], + 'chargeback' => [Invoice::STATUS_CHARGEBACK, false], + 'status desconhecido' => ['qualquer_coisa', false], + ]; + } + + #[DataProvider('settledProvider')] + #[IgnoreDeprecations] + public function testIsSettledDelegatesToTheEnumAndAcceptsTheOldString(InvoiceStatus|string $status, bool $expected): void + { + $this->assertSame($expected, Invoice::isSettled($status)); + } + + /** + * `amount` sem `items` vira um único item com o valor; a lista precisa sobreviver ao + * restante do `fill()`. + */ + public function testFillWithAmountAndNoItemsCreatesASingleItem(): void + { + $invoice = new Invoice(); + $invoice->fill(['amount' => 10000, 'available_payment_methods' => ['pix']]); + + $this->assertCount(1, $invoice->items); + $this->assertSame(10000, $invoice->items[0]->price); + $this->assertSame(1, $invoice->items[0]->quantity); + $this->assertNull($invoice->amount); + } + + public static function contestedProvider(): array + { + return [ + 'em disputa (enum)' => [InvoiceStatus::DISPUTED, true], + 'em disputa (string antiga)' => [Invoice::STATUS_DISPUTED, true], + 'chargeback' => [Invoice::STATUS_CHARGEBACK, true], + 'paga' => [Invoice::STATUS_PAID, false], + 'estornada' => [Invoice::STATUS_REFUNDED, false], + 'parcialmente estornada' => [Invoice::STATUS_PARTIALLY_REFUNDED, false], + 'pendente' => [Invoice::STATUS_PENDING, false], + 'cancelada' => [Invoice::STATUS_CANCELED, false], + 'status desconhecido' => ['qualquer_coisa', false], + ]; + } + + #[DataProvider('contestedProvider')] + #[IgnoreDeprecations] + public function testIsContestedDelegatesToTheEnumAndAcceptsTheOldString(InvoiceStatus|string $status, bool $expected): void + { + $this->assertSame($expected, Invoice::isContested($status)); + } + + #[IgnoreDeprecations] + public function testIsSettledTriggersADeprecationNotice(): void + { + $this->expectUserDeprecationMessage('Invoice::isSettled() está obsoleto desde 2026-09-02; use $invoice->status->isSettled()'); + + Invoice::isSettled(InvoiceStatus::PAID); + } + + #[IgnoreDeprecations] + public function testIsContestedTriggersADeprecationNotice(): void + { + $this->expectUserDeprecationMessage('Invoice::isContested() está obsoleto desde 2026-09-02; use $invoice->status->isContested()'); + + Invoice::isContested(InvoiceStatus::DISPUTED); + } + + /** + * As constantes antigas continuam existindo com o mesmo valor do enum, então quem compara + * `$invoice->status->value` com `Invoice::STATUS_PAID` continua obtendo verdadeiro. + */ + public static function oldConstantProvider(): array + { + return [ + [Invoice::STATUS_PENDING, InvoiceStatus::PENDING], + [Invoice::STATUS_PAID, InvoiceStatus::PAID], + [Invoice::STATUS_CANCELED, InvoiceStatus::CANCELED], + [Invoice::STATUS_REFUNDED, InvoiceStatus::REFUNDED], + [Invoice::STATUS_PARTIALLY_REFUNDED, InvoiceStatus::PARTIALLY_REFUNDED], + [Invoice::STATUS_DISPUTED, InvoiceStatus::DISPUTED], + [Invoice::STATUS_CHARGEBACK, InvoiceStatus::CHARGEBACK], + ]; + } + + #[DataProvider('oldConstantProvider')] + public function testOldStatusConstantsKeepTheEnumValue(string $constant, InvoiceStatus $status): void + { + $invoice = new Invoice(); + $invoice->status = $constant; + + $this->assertSame($status, $invoice->status); + $this->assertSame($constant, $invoice->status->value); + $this->assertTrue($invoice->status->value === $constant); + } + + #[IgnoreDeprecations] + public function testExpiresAtIsADeprecatedAliasOfDueDate(): void + { + $this->expectUserDeprecationMessage('Invoice::$expiresAt está obsoleto desde 2026-09-02; use $dueDate (vencimento) ou $pixExpiresAt (expiração do QR Code)'); + + $invoice = new Invoice(); + $invoice->expiresAt = Carbon::parse('2026-10-01'); + + $this->assertSame('2026-10-01', $invoice->dueDate->format('Y-m-d')); + $this->assertSame($invoice->dueDate, $invoice->expiresAt); + $this->assertTrue(isset($invoice->expiresAt)); + $this->assertArrayHasKey('due_date', $invoice->toArray()); + $this->assertArrayNotHasKey('expires_at', $invoice->toArray()); + } + + public function testIssetOnExpiresAtDoesNotWarnAndFollowsDueDate(): void + { + $invoice = new Invoice(); + + $this->assertFalse(isset($invoice->expiresAt)); + $this->assertTrue(empty($invoice->expiresAt)); + } + + #[IgnoreDeprecations] + public function testFillAcceptsExpiresAtAsAnAliasWithDueDateTakingPrecedence(): void + { + $this->expectUserDeprecationMessage('Invoice::$expiresAt está obsoleto desde 2026-09-02; use $dueDate (vencimento) ou $pixExpiresAt (expiração do QR Code)'); + + $invoice = new Invoice(); + $invoice->fill(['expires_at' => '2026-10-01']); + $this->assertSame('2026-10-01', $invoice->dueDate->format('Y-m-d')); + + $both = new Invoice(); + $both->fill(['expires_at' => '2026-10-01', 'due_date' => '2026-10-05']); + $this->assertSame('2026-10-05', $both->dueDate->format('Y-m-d')); + } + + #[DataProvider('dateInputProvider')] + public function testFillAcceptsDateOnlyIso8601AndCarbonInBothDates(mixed $value, string $expected): void + { + $invoice = new Invoice(); + $invoice->fill(['due_date' => $value, 'pix_expires_at' => $value]); + + $this->assertSame($expected, $invoice->dueDate->toIso8601String()); + $this->assertSame($expected, $invoice->pixExpiresAt->toIso8601String()); + $this->assertSame(['due_date' => $invoice->dueDate, 'pix_expires_at' => $invoice->pixExpiresAt], array_intersect_key($invoice->toArray(), ['due_date' => 1, 'pix_expires_at' => 1])); + } + + public static function dateInputProvider(): array + { + return [ + 'so data' => ['2026-10-01', Carbon::parse('2026-10-01')->toIso8601String()], + 'ISO 8601 com hora' => ['2026-10-01T18:30:00-03:00', '2026-10-01T18:30:00-03:00'], + 'Carbon' => [Carbon::parse('2026-10-01 18:30:00', 'America/Bahia'), '2026-10-01T18:30:00-03:00'], + ]; + } + + public function testResolvedPaymentMethodsFollowThePrecedenceListMethodCard(): void + { + $invoice = new Invoice(); + $this->assertSame([], $invoice->resolvedPaymentMethods()); + + $invoice->creditCard = new CreditCard(); + $invoice->creditCard->id = 'pm_1'; + $this->assertSame([PaymentMethod::CREDIT_CARD], $invoice->resolvedPaymentMethods()); + + $invoice->paymentMethod = 'credit_card'; + $this->assertSame([PaymentMethod::CREDIT_CARD], $invoice->resolvedPaymentMethods()); + + $invoice->creditCard = null; + $invoice->paymentMethod = 'pix'; + $this->assertSame([PaymentMethod::PIX], $invoice->resolvedPaymentMethods()); + + $invoice->paymentMethod = null; + $invoice->availablePaymentMethods = [PaymentMethod::BANK_SLIP]; + $invoice->availablePaymentMethods[] = 'bank_slip'; + $this->assertSame([PaymentMethod::BANK_SLIP], $invoice->resolvedPaymentMethods()); + } + + #[DataProvider('conflictingPaymentProvider')] + public function testValidationRejectsAPaymentMethodOutsideTheListAndACardWithoutCardAmongTheMethods(callable $mutate, string $message): void + { + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['name' => 'Ana', 'email' => 'ana@example.com'], + 'amount' => 10000, + ]); + $mutate($invoice); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches($message); + + $invoice->validate(); + } + + public static function conflictingPaymentProvider(): array + { + $card = function (Invoice $i) { + $i->creditCard = new CreditCard(); + $i->creditCard->id = 'pm_1'; + }; + + return [ + 'metodo fora da lista' => [ + function (Invoice $i) { + $i->paymentMethod = PaymentMethod::CREDIT_CARD; + $i->availablePaymentMethods = [PaymentMethod::PIX]; + }, + '/paymentMethod \[credit_card\] must be one of availablePaymentMethods/', + ], + 'cartao com metodo pix' => [ + function (Invoice $i) use ($card) { + $card($i); + $i->paymentMethod = PaymentMethod::PIX; + }, + '/creditCard was given but credit_card is not among the payment methods/', + ], + 'cartao com lista sem cartao' => [ + function (Invoice $i) use ($card) { + $card($i); + $i->availablePaymentMethods = [PaymentMethod::PIX, PaymentMethod::BANK_SLIP]; + }, + '/creditCard was given but credit_card is not among the payment methods/', + ], + ]; + } + + public function testValidationRejectsANonSelectablePaymentMethod(): void + { + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['name' => 'Ana', 'email' => 'ana@example.com'], + 'amount' => 10000, + ]); + $invoice->paymentMethod = PaymentMethod::AUTOMATIC_PIX; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/paymentMethod must be one of: credit_card, bank_slip, pix/'); + + $invoice->validate(); + } + + /** + * `amount` junto de `items` só é aceito quando é a soma deles: `itemsTotal()` devolve a soma + * e `validate()` lança `ModelAttributeValidationException` citando os dois valores. + */ + public function testValidationRejectsAnAmountDifferentFromTheItemsTotal(): void + { + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['name' => 'Ana', 'email' => 'ana@example.com'], + 'amount' => 10000, + 'items' => [ + ['description' => 'Produto 1', 'price' => 10000, 'quantity' => 1], + ['description' => 'Produto 2', 'price' => 5000, 'quantity' => 2], + ], + ]); + $this->assertSame(20000, $invoice->itemsTotal()); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/amount \[10000\] must equal the sum of the items \[20000\]/'); + + $invoice->validate(); + } + + public function testValidationAcceptsAnAmountEqualToTheItemsTotal(): void + { + $invoice = new Invoice(); + $invoice->fill([ + 'customer' => ['name' => 'Ana', 'email' => 'ana@example.com'], + 'amount' => 20000, + 'items' => [ + ['description' => 'Produto 1', 'price' => 10000, 'quantity' => 1], + ['description' => 'Produto 2', 'price' => 5000, 'quantity' => 2], + ], + ]); + + $invoice->validate(); + + $this->assertSame(20000, $invoice->amount); + } + + public function testBuilderSetsPaymentMethodAndBothDates(): void + { + $gateway = Mockery::mock(GatewayContract::class); + + $invoice = (new InvoiceBuilder($gateway)) + ->setPaymentMethod('pix') + ->setDueDate(CarbonImmutable::parse('2026-10-01')) + ->setPixExpiresAt('2026-09-30T18:00:00-03:00') + ->get(); + + $this->assertSame(PaymentMethod::PIX, $invoice->paymentMethod); + $this->assertInstanceOf(Carbon::class, $invoice->dueDate); + $this->assertSame('2026-10-01', $invoice->dueDate->format('Y-m-d')); + $this->assertSame('2026-09-30T18:00:00-03:00', $invoice->pixExpiresAt->toIso8601String()); + } + + #[IgnoreDeprecations] + public function testBuilderSetExpiresAtIsADeprecatedAliasOfSetDueDate(): void + { + $this->expectUserDeprecationMessage('InvoiceBuilder::setExpiresAt() está obsoleto desde 2026-09-02; use setDueDate() ou setPixExpiresAt()'); + + $invoice = (new InvoiceBuilder(Mockery::mock(GatewayContract::class))) + ->setExpiresAt('2026-10-01') + ->get(); + + $this->assertSame('2026-10-01', $invoice->dueDate->format('Y-m-d')); + $this->assertNull($invoice->pixExpiresAt); + } + + /** + * `refundedAmount` é só de leitura: os drivers a preenchem por `setRefundedAmountFromGateway()`, + * que não marca o valor como pedido de estorno. + */ + public function testRefundedAmountWrittenByTheDriverIsNotARefundRequest(): void + { + $invoice = new Invoice(); + $invoice->setRefundedAmountFromGateway(3000); + + $this->assertSame(3000, $invoice->refundedAmount); + $this->assertTrue(isset($invoice->refundedAmount)); + $this->assertNull($invoice->requestedRefundAmount()); + $this->assertNull($invoice->resolveRefundAmount(null)); + $this->assertSame(500, $invoice->resolveRefundAmount(500)); + $this->assertSame(['refunded_amount' => 3000], $invoice->toArray()); + $this->assertSame(3000, json_decode(json_encode($invoice), true)['refundedAmount']); + } + + /** + * Escrever em `refundedAmount` é o caminho antigo de pedir estorno parcial: o valor fica + * como pedido, com aviso de deprecação, e o argumento de `refund()` prevalece sobre ele. + */ + public function testWritingRefundedAmountIsTheDeprecatedWayOfRequestingAPartialRefund(): void + { + $invoice = new Invoice(); + + $this->expectUserDeprecationMessage('Invoice::$refundedAmount é só de leitura desde 2026-09-02; passe o valor do estorno em refund(amount:) ou refundInvoice($id, $amount)'); + $invoice->refundedAmount = 2500; + + $this->assertSame(2500, $invoice->refundedAmount); + $this->assertSame(2500, $invoice->requestedRefundAmount()); + $this->assertSame(2500, $invoice->resolveRefundAmount(null)); + $this->assertSame(1000, $invoice->resolveRefundAmount(1000)); + + $invoice->setRefundedAmountFromGateway(2500); + $this->assertNull($invoice->requestedRefundAmount(), 'a leitura do gateway apaga o pedido'); + } + + #[DataProvider('refundedAmountKeyProvider')] + public function testFillWithRefundedAmountFollowsTheDeprecatedPathInBothSpellings(string $key): void + { + $invoice = new Invoice(); + + $this->expectUserDeprecationMessage('Invoice::$refundedAmount é só de leitura desde 2026-09-02; passe o valor do estorno em refund(amount:) ou refundInvoice($id, $amount)'); + $invoice->fill(['id' => 'inv_1', $key => 700]); + + $this->assertSame('inv_1', $invoice->id); + $this->assertSame(700, $invoice->refundedAmount); + $this->assertSame(700, $invoice->requestedRefundAmount()); + } + + public static function refundedAmountKeyProvider(): array + { + return ['snake_case' => ['refunded_amount'], 'camelCase' => ['refundedAmount']]; + } + + /** + * A propriedade privada que guarda o pedido do caminho antigo é estado interno: a chave é + * desconhecida para `fill()`, como qualquer outra fora de `fillableKeys()`. + */ + public function testFillRejectsTheKeyOfThePrivateRequestedRefundAmount(): void + { + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/`requested_refund_amount` key is unknown/'); + + (new Invoice())->fill(['requested_refund_amount' => 123]); + } + + #[IgnoreDeprecations] + public function testZeroWrittenInRefundedAmountMeansNoPartialRequest(): void + { + $invoice = new Invoice(); + $invoice->refundedAmount = 0; + + $this->assertSame(0, $invoice->refundedAmount); + $this->assertNull($invoice->requestedRefundAmount()); + } + + public function testResolveRefundAmountRejectsZeroAndNegative(): void + { + $invoice = new Invoice(); + + foreach ([0, -1] as $amount) { + try { + $invoice->resolveRefundAmount($amount); + $this->fail("Esperava ModelAttributeValidationException para {$amount}"); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('positive', $e->getMessage()); + } + } + } +} diff --git a/tests/Unit/ModelEnumCastTest.php b/tests/Unit/ModelEnumCastTest.php new file mode 100644 index 0000000..8ee6607 --- /dev/null +++ b/tests/Unit/ModelEnumCastTest.php @@ -0,0 +1,321 @@ +instance('config', new \Illuminate\Config\Repository([ + 'multi-payment.gateways.stripe.api_key' => 'sk_test_fake', + ])); + $app->instance('log', $this->logger = new RecordingLogger()); + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testStatusAcceptsTheOldStringAndReadsAsTheEnum(): void + { + $invoice = new Invoice(); + $invoice->status = Invoice::STATUS_PAID; + + $this->assertSame(InvoiceStatus::PAID, $invoice->status); + $this->assertTrue($invoice->status === InvoiceStatus::PAID); + $this->assertSame(Invoice::STATUS_PAID, $invoice->status->value); + } + + public function testStatusAcceptsTheEnumCaseAndNull(): void + { + $invoice = new Invoice(); + $invoice->status = InvoiceStatus::DISPUTED; + $this->assertSame(InvoiceStatus::DISPUTED, $invoice->status); + + $invoice->status = null; + $this->assertNull($invoice->status); + } + + public function testUnknownStatusStringBecomesUnknownWithAWarningNamingTheGateway(): void + { + $invoice = new Invoice(); + $invoice->gateway = 'iugu'; + $invoice->status = 'status_inventado'; + + $this->assertSame(InvoiceStatus::UNKNOWN, $invoice->status); + $this->assertCount(1, $this->logger->records); + $this->assertSame('warning', $this->logger->records[0]['level']); + $this->assertSame(['status' => 'status_inventado', 'gateway' => 'iugu'], $this->logger->records[0]['context']); + } + + public function testSubscriptionStatusAcceptsTheOldStringAndReadsAsTheEnum(): void + { + $subscription = new Subscription(); + $subscription->status = Subscription::STATUS_PAST_DUE; + + $this->assertSame(SubscriptionStatus::PAST_DUE, $subscription->status); + $this->assertTrue($subscription->status->isRecoverable()); + $this->assertSame(Subscription::STATUS_PAST_DUE, $subscription->status->value); + $this->assertSame('past_due', $subscription->toArray()['status']); + $this->assertSame('past_due', json_decode(json_encode($subscription), true)['status']); + $this->assertTrue(isset($subscription->status)); + $this->assertContains('status', Subscription::fillableKeys()); + + $subscription->fill(['status' => 'trialing']); + $this->assertSame(SubscriptionStatus::TRIALING, $subscription->status); + + $subscription->status = SubscriptionStatus::CANCELED; + $this->assertSame(SubscriptionStatus::CANCELED, $subscription->status); + + $subscription->status = null; + $this->assertNull($subscription->status); + $this->assertFalse(isset($subscription->status)); + } + + public function testUnknownSubscriptionStatusStringBecomesUnknownWithAWarningNamingTheGateway(): void + { + $subscription = new Subscription(); + $subscription->gateway = 'stripe'; + $subscription->fill(['status' => 'status_inventado']); + + $this->assertSame(SubscriptionStatus::UNKNOWN, $subscription->status); + $this->assertCount(1, $this->logger->records); + $this->assertSame('warning', $this->logger->records[0]['level']); + $this->assertStringContainsString('assinatura', $this->logger->records[0]['message']); + $this->assertSame(['status' => 'status_inventado', 'gateway' => 'stripe'], $this->logger->records[0]['context']); + } + + public function testFillConvertsStatusPaymentMethodAndAvailablePaymentMethods(): void + { + $invoice = new Invoice(); + $invoice->fill([ + 'status' => 'partially_paid', + 'payment_method' => 'pix', + 'available_payment_methods' => ['pix', PaymentMethod::BANK_SLIP], + ]); + + $this->assertSame(InvoiceStatus::PARTIALLY_PAID, $invoice->status); + $this->assertSame(PaymentMethod::PIX, $invoice->paymentMethod); + $this->assertSame([PaymentMethod::PIX, PaymentMethod::BANK_SLIP], $invoice->availablePaymentMethods); + } + + public function testUnknownPaymentMethodStringIsRejectedOnWrite(): void + { + $invoice = new Invoice(); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('paymentMethod must be one of: credit_card, bank_slip, pix, automatic_pix'); + + $invoice->paymentMethod = 'dinheiro'; + } + + public function testUnknownPaymentMethodInTheListIsRejectedOnWrite(): void + { + $invoice = new Invoice(); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('availablePaymentMethods must be one of'); + + $invoice->availablePaymentMethods = ['pix', 'cripto']; + } + + public function testNonArrayAvailablePaymentMethodsIsRejectedOnWrite(): void + { + $invoice = new Invoice(); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('availablePaymentMethods must be an array'); + + $invoice->availablePaymentMethods = 'pix'; + } + + public function testNonStringValueIsRejectedOnWrite(): void + { + $plan = new Plan(); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('interval must be one of: day, week, month, year'); + + $plan->interval = 12; + } + + /** + * `__get` devolve o array por referência, então um `[]=` entra sem conversão; a validação + * normaliza e recusa o que não é selecionável. + */ + public function testValidationNormalizesStringsAppendedToAvailablePaymentMethods(): void + { + $invoice = new Invoice(); + $invoice->availablePaymentMethods = [PaymentMethod::PIX]; + $invoice->availablePaymentMethods[] = 'credit_card'; + + $invoice->validateAvailablePaymentMethodsAttribute(); + + $this->assertSame([PaymentMethod::PIX, PaymentMethod::CREDIT_CARD], $invoice->availablePaymentMethods); + } + + public function testValidationRejectsAutomaticPixInAvailablePaymentMethods(): void + { + $invoice = new Invoice(); + $invoice->availablePaymentMethods = [PaymentMethod::AUTOMATIC_PIX]; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('availablePaymentMethods must be one of: credit_card, bank_slip, pix'); + + $invoice->validateAvailablePaymentMethodsAttribute(); + } + + public function testPlanIntervalAcceptsTheOldConstantAndReadsAsTheEnum(): void + { + $plan = new Plan(); + $plan->interval = Plan::INTERVAL_YEAR; + + $this->assertSame(PlanInterval::YEAR, $plan->interval); + + $plan->fill(['interval' => 'week']); + $this->assertSame(PlanInterval::WEEK, $plan->interval); + } + + public function testUnknownPlanIntervalIsRejectedOnWrite(): void + { + $plan = new Plan(); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('interval must be one of: day, week, month, year'); + + $plan->interval = 'quinzena'; + } + + public function testSubscriptionPaymentMethodsAreConvertedToo(): void + { + $subscription = new Subscription(); + $subscription->fill(['payment_method' => 'credit_card', 'available_payment_methods' => ['pix']]); + + $this->assertSame(PaymentMethod::CREDIT_CARD, $subscription->paymentMethod); + $this->assertSame([PaymentMethod::PIX], $subscription->availablePaymentMethods); + } + + public function testToArrayEmitsTheStringValues(): void + { + $invoice = new Invoice(); + $invoice->id = 'inv_1'; + $invoice->status = InvoiceStatus::PAID; + $invoice->paymentMethod = PaymentMethod::CREDIT_CARD; + $invoice->availablePaymentMethods = [PaymentMethod::CREDIT_CARD, PaymentMethod::PIX]; + + $this->assertSame([ + 'id' => 'inv_1', + 'status' => 'paid', + 'payment_method' => 'credit_card', + 'available_payment_methods' => ['credit_card', 'pix'], + ], $invoice->toArray()); + + $plan = new Plan(); + $plan->interval = PlanInterval::MONTH; + $this->assertSame('month', $plan->toArray()['interval']); + } + + public function testToArrayRoundTripsThroughFill(): void + { + $invoice = new Invoice(); + $invoice->status = 'paid'; + $invoice->availablePaymentMethods = ['pix']; + + $copy = new Invoice(); + $copy->fill($invoice->toArray()); + + $this->assertSame(InvoiceStatus::PAID, $copy->status); + $this->assertSame([PaymentMethod::PIX], $copy->availablePaymentMethods); + } + + public function testJsonEncodeKeepsTheCamelCaseKeysWithTheStringValues(): void + { + $invoice = new Invoice(); + $invoice->id = 'inv_1'; + $invoice->status = InvoiceStatus::EXPIRED; + $invoice->paymentMethod = PaymentMethod::PIX; + $invoice->availablePaymentMethods = [PaymentMethod::PIX, PaymentMethod::BANK_SLIP]; + + $json = json_decode(json_encode($invoice), true); + + $this->assertSame('inv_1', $json['id']); + $this->assertSame('expired', $json['status']); + $this->assertSame('pix', $json['paymentMethod']); + $this->assertSame(['pix', 'bank_slip'], $json['availablePaymentMethods']); + $this->assertArrayHasKey('gatewayOptions', $json); + + $plan = new Plan(); + $plan->interval = PlanInterval::YEAR; + $this->assertSame('year', json_decode(json_encode($plan), true)['interval']); + } + + public function testIssetAndEmptyWorkOnEnumProperties(): void + { + $invoice = new Invoice(); + + $this->assertFalse(isset($invoice->status)); + $this->assertTrue(empty($invoice->status)); + $this->assertFalse(isset($invoice->availablePaymentMethods)); + $this->assertTrue(empty($invoice->availablePaymentMethods)); + + $invoice->status = 'paid'; + $invoice->availablePaymentMethods = ['pix']; + + $this->assertTrue(isset($invoice->status)); + $this->assertFalse(empty($invoice->status)); + $this->assertTrue(isset($invoice->availablePaymentMethods)); + $this->assertFalse(empty($invoice->availablePaymentMethods)); + } + + public function testBuilderAcceptsStringsAndEnumCasesForAvailablePaymentMethods(): void + { + $builder = new InvoiceBuilder(new StripeGateway()); + $builder->addAvailablePaymentMethod('pix')->addAvailablePaymentMethod(PaymentMethod::CREDIT_CARD); + + $this->assertSame([PaymentMethod::PIX, PaymentMethod::CREDIT_CARD], $builder->get()->availablePaymentMethods); + + $builder->setAvailablePaymentMethods(['bank_slip']); + $this->assertSame([PaymentMethod::BANK_SLIP], $builder->get()->availablePaymentMethods); + } + + public function testValidateStillSeesEnumPropertiesAsAttributes(): void + { + $plan = new Plan(); + $plan->name = 'Plano'; + $plan->amount = 1000; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/interval/'); + + $plan->validate(); + } +} diff --git a/tests/Unit/ModelFillTest.php b/tests/Unit/ModelFillTest.php new file mode 100644 index 0000000..2a4cd7a --- /dev/null +++ b/tests/Unit/ModelFillTest.php @@ -0,0 +1,232 @@ +configure(['multi-payment.strict_fill' => true]); + $customer = new Customer(); + + try { + $customer->fill(['name' => 'Ana', 'nome_fantasia' => 'Loja']); + $this->fail('Chave desconhecida deveria lançar ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('`nome_fantasia` key is unknown for the `Customer` model', $e->getMessage()); + $this->assertStringContainsString('name, email, tax_document', $e->getMessage()); + $this->assertStringContainsString('gateway_options', $e->getMessage()); + } + // as chaves anteriores à desconhecida já foram aplicadas + $this->assertSame('Ana', $customer->name); + } + + public function testStrictIsTheDefaultWithoutTheConfigurationKey(): void + { + $this->configure([]); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('`trial_days` key is unknown for the `Invoice` model'); + + (new Invoice())->fill(['amount' => 10000, 'trial_days' => 7]); + } + + public function testStrictIsTheDefaultWithoutALaravelContainer(): void + { + Facade::setFacadeApplication(null); + + $this->expectException(ModelAttributeValidationException::class); + + (new Customer())->fill(['nome' => 'Ana']); + } + + public function testPermissiveModeIgnoresUnknownKeysSilently(): void + { + $this->configure(['multi-payment.strict_fill' => false]); + $customer = new Customer(); + + $customer->fill(['name' => 'Ana', 'nome_fantasia' => 'Loja']); + + $this->assertSame('Ana', $customer->name); + $this->assertFalse(property_exists($customer, 'nomeFantasia')); + } + + public function testKeysWithTheGatewayPrefixStayFree(): void + { + $this->configure(['multi-payment.strict_fill' => true]); + $customer = new Customer(); + + $customer->fill(['name' => 'Ana', 'gateway_options' => ['x' => 1], 'gateway_customer_ref' => 'abc', 'gatewayOtherRef' => 'def']); + + $this->assertSame(['x' => 1], $customer->gatewayOptions); + $this->assertFalse(property_exists($customer, 'gatewayCustomerRef')); + $this->assertFalse(property_exists($customer, 'gatewayOtherRef')); + } + + public function testStrictIsTheDefaultWithAContainerWithoutConfig(): void + { + Facade::setFacadeApplication(new Container()); + + $this->expectException(ModelAttributeValidationException::class); + + (new Customer())->fill(['nome' => 'Ana']); + } + + public function testGatewayOptionsContentIsFree(): void + { + $this->configure(['multi-payment.strict_fill' => true]); + $invoice = new Invoice(); + + $invoice->fill(['amount' => 10000, 'gateway_options' => ['qualquer_chave' => 'vale', 'expires_in' => 3]]); + + $this->assertSame(['qualquer_chave' => 'vale', 'expires_in' => 3], $invoice->gatewayOptions); + } + + public function testCamelCaseKeysAreAccepted(): void + { + $this->configure(['multi-payment.strict_fill' => true]); + $customer = new Customer(); + + $customer->fill(['taxDocument' => '20176996915', 'phoneArea' => '71']); + + $this->assertSame('20176996915', $customer->taxDocument); + $this->assertSame('71', $customer->phoneArea); + } + + public function testNestedModelsAreStrictToo(): void + { + $this->configure(['multi-payment.strict_fill' => true]); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessage('`nome` key is unknown for the `Customer` model'); + + (new Invoice())->fill(['amount' => 10000, 'customer' => ['nome' => 'Ana']]); + } + + /** + * As chaves que os `fill()` especializados consomem antes do `Model` (`items`, `customer`, + * `due_date`, `pix_expires_at`, `credit_card`, datas da assinatura) continuam aceitas. + */ + public function testKeysConsumedBySpecializedFillsAreStillAccepted(): void + { + $this->configure(['multi-payment.strict_fill' => true]); + + $invoice = new Invoice(); + $invoice->fill([ + 'items' => [['description' => 'Item', 'price' => 1000, 'quantity' => 1]], + 'customer' => ['name' => 'Ana', 'email' => 'ana@example.com', 'address' => ['zip_code' => '41820330']], + 'due_date' => '2026-10-01', + 'pix_expires_at' => '2026-10-01T18:00:00-03:00', + 'credit_card' => ['token' => 'pm_x', 'first_name' => 'Ana'], + 'available_payment_methods' => ['credit_card'], + 'origin_type' => 'invoice', + ]); + $this->assertSame('41820330', $invoice->customer->address->zipCode); + $this->assertSame('pm_x', $invoice->creditCard->token); + $this->assertSame('2026-10-01', $invoice->dueDate->format('Y-m-d')); + $this->assertSame('2026-10-01T18:00:00-03:00', $invoice->pixExpiresAt->toIso8601String()); + + $subscription = new Subscription(); + $subscription->fill([ + 'plan_id' => 'plano', + 'trial_ends_at' => '2026-10-01', + 'next_billing_at' => '2026-11-01', + 'trial_days' => null, + 'credit_card' => ['id' => 'pm_1'], + 'customer' => ['id' => 'cus_1'], + 'latest_invoice' => ['id' => 'inv_1', 'status' => 'paid'], + 'items' => [['description' => 'Extra', 'amount' => 500]], + ]); + $this->assertSame('inv_1', $subscription->latestInvoice->id); + $this->assertSame('pm_1', $subscription->creditCard->id); + + $card = new CreditCard(); + $card->fill(['token' => 'tok_x', 'customer' => ['id' => 'cus_1'], 'default' => true]); + $this->assertTrue($card->default); + } + + /** + * O cartão que aguarda autenticação serializa `requires_action`, `setup_id`, + * `client_secret` e `action_url`, e volta igual por `fill()`; sem autenticação pendente, + * `requires_action` (falso) fica fora do array. + */ + public function testCreditCardPendingAuthenticationRoundTripsThroughToArrayAndFill(): void + { + $card = new CreditCard(); + $card->requiresAction = true; + $card->setupId = 'seti_1'; + $card->clientSecret = 'seti_1_secret'; + $card->actionUrl = 'https://exemplo.com/3ds'; + + $array = $card->toArray(); + $this->assertSame( + ['requires_action' => true, 'action_url' => 'https://exemplo.com/3ds', 'client_secret' => 'seti_1_secret', 'setup_id' => 'seti_1'], + array_intersect_key($array, array_flip(['requires_action', 'action_url', 'client_secret', 'setup_id'])) + ); + + $copy = new CreditCard(); + $copy->fill($array); + $this->assertTrue($copy->requiresAction); + $this->assertSame('seti_1', $copy->setupId); + $this->assertSame('seti_1_secret', $copy->clientSecret); + $this->assertSame('https://exemplo.com/3ds', $copy->actionUrl); + + $saved = new CreditCard(); + $saved->id = 'pm_1'; + $this->assertArrayNotHasKey('requires_action', $saved->toArray()); + $this->assertFalse($saved->requiresAction); + } + + public function testFillableKeysAreTheSnakeCasePropertiesIncludingEnums(): void + { + $this->assertSame(['description', 'price', 'quantity', 'gateway_options'], InvoiceItem::fillableKeys()); + + $keys = Invoice::fillableKeys(); + foreach (['id', 'status', 'amount', 'refunded_amount', 'payment_method', 'available_payment_methods', 'origin_type', 'credit_card', 'due_date', 'pix_expires_at', 'gateway_options'] as $key) { + $this->assertContains($key, $keys); + } + // o nome antigo é alias, fora da lista, como `gateway_adicional_options` + $this->assertNotContains('expires_at', $keys); + foreach (['requires_action', 'action_url', 'client_secret', 'setup_id'] as $key) { + $this->assertContains($key, CreditCard::fillableKeys()); + } + foreach (['payment_method', 'credit_card', 'trial_days', 'trial_ends_at'] as $key) { + $this->assertContains($key, Subscription::fillableKeys()); + } + $this->assertContains('interval', Plan::fillableKeys()); + $this->assertContains('status', Refund::fillableKeys()); + } + + private function configure(array $config): void + { + $app = new Container(); + $app->instance('config', new Repository($config)); + Facade::setFacadeApplication($app); + } +} diff --git a/tests/Unit/ModelTest.php b/tests/Unit/ModelTest.php new file mode 100644 index 0000000..52013a1 --- /dev/null +++ b/tests/Unit/ModelTest.php @@ -0,0 +1,152 @@ +instance('config', new Repository([ + 'multi-payment.gateways.stripe.api_key' => 'sk_test_fake', + ])); + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testGatewayOptionsStartsEmptyAndIsAPublicArray(): void + { + $invoice = new Invoice(); + + $this->assertSame([], $invoice->gatewayOptions); + $this->assertTrue(property_exists($invoice, 'gatewayOptions')); + $this->assertFalse(property_exists($invoice, 'gatewayAdicionalOptions')); + } + + #[IgnoreDeprecations] + public function testWritingTheDeprecatedNameFillsGatewayOptionsWithADeprecationNotice(): void + { + $invoice = new Invoice(); + + $this->expectUserDeprecationMessage('Model::$gatewayAdicionalOptions está obsoleto desde 2026-09-02; use $gatewayOptions'); + + $invoice->gatewayAdicionalOptions = ['expires_in' => 3]; + + $this->assertSame(['expires_in' => 3], $invoice->gatewayOptions); + } + + #[IgnoreDeprecations] + public function testReadingTheDeprecatedNameReturnsGatewayOptionsWithADeprecationNotice(): void + { + $invoice = new Invoice(); + $invoice->gatewayOptions = ['idempotency_key' => 'abc']; + + $this->expectUserDeprecationMessage('Model::$gatewayAdicionalOptions está obsoleto desde 2026-09-02; use $gatewayOptions'); + + $this->assertSame(['idempotency_key' => 'abc'], $invoice->gatewayAdicionalOptions); + } + + #[IgnoreDeprecations] + public function testIndirectModificationThroughTheDeprecatedNameStillAltersTheArray(): void + { + $invoice = new Invoice(); + $invoice->gatewayOptions = ['a' => 1]; + + $this->expectUserDeprecationMessage('Model::$gatewayAdicionalOptions está obsoleto desde 2026-09-02; use $gatewayOptions'); + + $invoice->gatewayAdicionalOptions['b'] = 2; + + $this->assertSame(['a' => 1, 'b' => 2], $invoice->gatewayOptions); + } + + #[IgnoreDeprecations] + public function testIssetAndEmptyWorkOnTheDeprecatedName(): void + { + $invoice = new Invoice(); + + // isset() só passa pelo __isset, sem aviso; empty() também lê o valor pelo __get e avisa + $this->assertTrue(isset($invoice->gatewayAdicionalOptions)); + $this->assertFalse(isset($invoice->propriedadeInexistente)); + + $this->expectUserDeprecationMessage('Model::$gatewayAdicionalOptions está obsoleto desde 2026-09-02; use $gatewayOptions'); + + $this->assertTrue(empty($invoice->gatewayAdicionalOptions)); + + $invoice->gatewayOptions = ['a' => 1]; + + $this->assertFalse(empty($invoice->gatewayAdicionalOptions)); + } + + public function testFillAcceptsBothSnakeCaseKeys(): void + { + $novo = new Customer(); + $novo->fill(['name' => 'Ana', 'gateway_options' => ['x' => 1]]); + $this->assertSame(['x' => 1], $novo->gatewayOptions); + + $antigo = new Customer(); + @$antigo->fill(['name' => 'Ana', 'gateway_adicional_options' => ['y' => 2]]); + $this->assertSame(['y' => 2], $antigo->gatewayOptions); + } + + #[IgnoreDeprecations] + public function testFillWithTheDeprecatedKeyTriggersADeprecationNotice(): void + { + $this->expectUserDeprecationMessage('Model::$gatewayAdicionalOptions está obsoleto desde 2026-09-02; use $gatewayOptions'); + + (new Customer())->fill(['gateway_adicional_options' => ['y' => 2]]); + } + + public function testToArrayExposesGatewayOptionsUnderTheNewKeyOnly(): void + { + $customer = new Customer(); + $customer->name = 'Ana'; + $customer->gatewayOptions = ['x' => 1]; + + $array = $customer->toArray(); + + $this->assertSame(['x' => 1], $array['gateway_options']); + $this->assertArrayNotHasKey('gateway_adicional_options', $array); + } + + public function testBuilderSetGatewayOptionsFillsTheModel(): void + { + $builder = new InvoiceBuilder(new StripeGateway()); + + $this->assertSame($builder, $builder->setGatewayOptions(['expand' => ['customer']])); + $this->assertSame(['expand' => ['customer']], $builder->get()->gatewayOptions); + } + + #[IgnoreDeprecations] + public function testBuilderDeprecatedSetterStillWorksWithADeprecationNotice(): void + { + $builder = new InvoiceBuilder(new StripeGateway()); + + $this->expectUserDeprecationMessage('Builder::setGatewayAdicionalOptions() está obsoleto desde 2026-09-02; use setGatewayOptions()'); + + $builder->setGatewayAdicionalOptions(['expand' => ['customer']]); + + $this->assertSame(['expand' => ['customer']], $builder->get()->gatewayOptions); + } +} diff --git a/tests/Unit/MultiPaymentChargeTest.php b/tests/Unit/MultiPaymentChargeTest.php new file mode 100644 index 0000000..dfd42c0 --- /dev/null +++ b/tests/Unit/MultiPaymentChargeTest.php @@ -0,0 +1,178 @@ +instance('config', new Repository([ + 'multi-payment' => [ + 'default' => 'iugu', + 'gateways' => ['iugu' => ['api_key' => 'iugu-key', 'class' => IuguGateway::class]], + ], + ])); + Facade::setFacadeApplication($app); + + $this->api = (new QueuedIuguApiRequest([]))->installAsSdkRequester(); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + QueuedIuguApiRequest::restoreSdkRequester(); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testChargeWithoutCustomerFailsBeforeAnyRequest(): void + { + try { + (new MultiPayment('iugu'))->charge([ + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'payment_method' => 'pix', + ]); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('`customer` attribute is required', $e->getMessage()); + } + $this->assertSame([], $this->api->calls); + } + + /** + * `payment_method` `credit_card` com `credit_card` preenchido e sem + * `available_payment_methods` cobra o cartão na Iugu: `POST /charge` com o id do cartão e + * `payable_with` só de cartão. + */ + public function testChargeHonorsPaymentMethodOnWrite(): void + { + $this->queue([(object) ['success' => true, 'invoice_id' => 'inv_1'], $this->pendingInvoiceResponse()]); + + $invoice = (new MultiPayment('iugu'))->charge([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'payment_method' => 'credit_card', + 'credit_card' => ['id' => 'pm_1'], + ]); + + $this->assertStringEndsWith('/charge', $this->api->calls[0]['url']); + $this->assertSame('pm_1', $this->api->calls[0]['data']['customer_payment_method_id']); + $this->assertSame(['credit_card'], $this->api->calls[0]['data']['payable_with']); + $this->assertSame('inv_1', $invoice->id); + } + + public function testChargeAcceptsACustomerInstance(): void + { + $this->queue([$this->pendingInvoiceResponse()]); + $customer = new Customer(); + $customer->id = 'cus_1'; + $customer->name = 'Cliente'; + $customer->email = 'cliente@example.com'; + + (new MultiPayment('iugu'))->charge([ + 'customer' => $customer, + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'payment_method' => 'pix', + ]); + + $this->assertCount(1, $this->api->calls); + $this->assertStringEndsWith('/invoices', $this->api->calls[0]['url']); + $this->assertSame('cus_1', $this->api->calls[0]['data']['customer_id']); + } + + public function testChargeSendsDueDateAndPixExpiresAtSeparately(): void + { + $this->queue([$this->pendingInvoiceResponse()]); + + (new MultiPayment('iugu'))->charge([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'payment_method' => 'pix', + 'due_date' => '2026-10-01', + 'pix_expires_at' => '2026-09-30T18:00:00-03:00', + ]); + + $payload = $this->api->calls[0]['data']; + $this->assertSame('2026-10-01', $payload['due_date']); + $this->assertSame('2026-09-30T18:00:00-03:00', $payload['pix_qr_code_expires_at']); + } + + #[IgnoreDeprecations] + public function testChargeStillAcceptsExpiresAtAsADeprecatedAliasOfDueDate(): void + { + $this->expectUserDeprecationMessage('Invoice::$expiresAt está obsoleto desde 2026-09-02; use $dueDate (vencimento) ou $pixExpiresAt (expiração do QR Code)'); + $this->queue([$this->pendingInvoiceResponse()]); + + (new MultiPayment('iugu'))->charge([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'items' => [['description' => 'Item', 'price' => 10000, 'quantity' => 1]], + 'expires_at' => '2026-10-01', + ]); + + $this->assertSame('2026-10-01', $this->api->calls[0]['data']['due_date']); + } + + public function testChargeRejectsAnAmountThatDiffersFromTheItemsBeforeAnyRequest(): void + { + try { + (new MultiPayment('iugu'))->charge([ + 'customer' => ['id' => 'cus_1', 'name' => 'Cliente', 'email' => 'cliente@example.com'], + 'amount' => 10000, + 'items' => [ + ['description' => 'Produto 1', 'price' => 10000, 'quantity' => 1], + ['description' => 'Produto 2', 'price' => 5000, 'quantity' => 2], + ], + ]); + $this->fail('Esperava ModelAttributeValidationException'); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('amount [10000] must equal the sum of the items [20000]', $e->getMessage()); + } + $this->assertSame([], $this->api->calls); + } + + private function queue(array $responses): void + { + $this->api = (new QueuedIuguApiRequest($responses))->installAsSdkRequester(); + } + + private function pendingInvoiceResponse(): object + { + return (object) [ + 'id' => 'inv_1', + 'status' => 'pending', + 'total_cents' => 10000, + 'paid_cents' => 0, + 'refunded_cents' => 0, + 'due_date' => '2026-10-01', + 'secure_url' => 'https://faturas.iugu.com/inv_1', + 'customer_id' => 'cus_1', + 'customer_name' => 'Cliente', + 'email' => 'cliente@example.com', + 'items' => [(object) ['description' => 'Item', 'price_cents' => 10000, 'quantity' => 1]], + ]; + } +} diff --git a/tests/Unit/MultiPaymentGatewayRoutingTest.php b/tests/Unit/MultiPaymentGatewayRoutingTest.php new file mode 100644 index 0000000..1ac7c28 --- /dev/null +++ b/tests/Unit/MultiPaymentGatewayRoutingTest.php @@ -0,0 +1,135 @@ +instance('config', new Repository([ + 'multi-payment' => [ + // default proposital em iugu: o teste falharia se o gateway selecionado fosse ignorado + 'default' => 'iugu', + 'gateways' => [ + 'iugu' => ['api_key' => 'iugu-key', 'class' => \Potelo\MultiPayment\Gateways\IuguGateway::class], + 'stripe' => ['api_key' => 'sk_test_fake', 'class' => StripeGateway::class], + ], + ], + ])); + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + ApiRequestor::setHttpClient(null); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + /** + * O cartão devolvido com `requiresAction` traz `gateway` preenchido, então + * `confirmSetup()` sem argumento resolve o mesmo gateway pela configuração. + */ + public function testConfirmSetupOnTheReturnedCardResolvesTheGatewayFromTheModel(): void + { + $requiresAction = json_decode(file_get_contents(__DIR__ . '/../fixtures/stripe/setup_intents/requires_action.json'), true); + $httpClient = RecordingStripeHttpClient::withResponses([$requiresAction, $requiresAction]); + + $card = (new MultiPayment('stripe'))->newCreditCard() + ->setCustomerId('cus_VBjzroZKS8d5LY') + ->setToken('pm_fake123') + ->create(); + $this->assertTrue($card->requiresAction); + $this->assertSame('stripe', $card->gateway); + + $confirmed = $card->confirmSetup(); + + $paths = array_map(static fn ($call) => $call[0] . ' ' . parse_url($call[1], PHP_URL_PATH), $httpClient->calls); + $this->assertSame([ + 'post /v1/setup_intents', + 'get /v1/setup_intents/seti_1UBMViPjx0CusuMrBNDQEzqv', + ], $paths); + $this->assertSame($card, $confirmed); + $this->assertTrue($card->requiresAction); + $this->assertSame('stripe', $card->gateway); + } + + public function testDuplicateInvoiceUsesTheSelectedGatewayInsteadOfTheDefault(): void + { + $pendingPix = [ + 'id' => 'pi_fake123', + 'object' => 'payment_intent', + 'status' => 'requires_action', + 'amount' => 5000, + 'currency' => 'brl', + 'customer' => 'cus_fake123', + 'created' => 1786700000, + 'payment_method_types' => ['pix'], + 'next_action' => null, + 'metadata' => [], + 'latest_charge' => null, + ]; + $customer = [ + 'id' => 'cus_fake123', + 'object' => 'customer', + 'name' => 'Fake Customer', + 'email' => 'email@exemplo.com', + 'phone' => null, + 'address' => null, + 'metadata' => [], + 'created' => 1786700000, + 'invoice_settings' => ['default_payment_method' => null], + 'tax_ids' => ['object' => 'list', 'data' => [ + ['id' => 'txi_fake1', 'object' => 'tax_id', 'type' => 'br_cpf', 'value' => '20176996915'], + ]], + ]; + $newIntent = array_merge($pendingPix, ['id' => 'pi_fake456']); + $canceled = array_merge($pendingPix, ['status' => 'canceled']); + $httpClient = RecordingStripeHttpClient::withResponses([$pendingPix, $customer, $newIntent, $canceled]); + + $invoice = (new MultiPayment('stripe')) + ->duplicateInvoice('pi_fake123', \Carbon\Carbon::now()->addDay()); + + $this->assertStringContainsString('api.stripe.com/v1/payment_intents/pi_fake123', $httpClient->calls[0][1]); + $this->assertSame('pi_fake456', $invoice->id); + $this->assertSame('stripe', $invoice->gateway); + } + + public function testSetDefaultCardUsesTheSelectedGatewayInsteadOfTheDefault(): void + { + $httpClient = RecordingStripeHttpClient::withResponses([ + [ + 'id' => 'cus_fake123', + 'object' => 'customer', + // campos lidos pelo parseCustomer: ausentes, o StripeObject emite aviso "Undefined property" + 'name' => null, + 'email' => null, + 'phone' => null, + 'address' => null, + 'metadata' => [], + 'created' => 1786700000, + 'invoice_settings' => ['default_payment_method' => 'pm_fake123'], + ], + ]); + + $customer = (new MultiPayment('stripe'))->setDefaultCard('cus_fake123', 'pm_fake123'); + + $this->assertCount(1, $httpClient->calls); + $this->assertStringContainsString('api.stripe.com/v1/customers/cus_fake123', $httpClient->calls[0][1]); + $this->assertSame('pm_fake123', $customer->defaultCard->id); + } +} diff --git a/tests/Unit/MultiPaymentReadTest.php b/tests/Unit/MultiPaymentReadTest.php new file mode 100644 index 0000000..ad4212a --- /dev/null +++ b/tests/Unit/MultiPaymentReadTest.php @@ -0,0 +1,184 @@ +instance('config', new Repository([ + 'multi-payment' => [ + 'default' => 'iugu', + 'gateways' => ['iugu' => ['api_key' => 'iugu-key', 'class' => IuguGateway::class]], + ], + ])); + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + QueuedIuguApiRequest::restoreSdkRequester(); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testGetSubscriptionReadsTheSubscriptionById(): void + { + $api = (new QueuedIuguApiRequest([ + (object) [ + 'id' => 'sub_1', + 'customer_id' => 'cus_1', + 'plan_identifier' => 'plano_mensal', + 'price_cents' => 10000, + 'expires_at' => '2026-10-01', + 'active' => true, + 'suspended' => false, + 'in_trial' => false, + ], + ]))->installAsSdkRequester(); + + $subscription = (new MultiPayment('iugu'))->getSubscription('sub_1'); + + $this->assertInstanceOf(Subscription::class, $subscription); + $this->assertCount(1, $api->calls); + $this->assertSame('GET', $api->calls[0]['method']); + $this->assertStringEndsWith('/subscriptions/sub_1', $api->calls[0]['url']); + $this->assertSame('sub_1', $subscription->id); + $this->assertSame('plano_mensal', $subscription->planId); + $this->assertSame(SubscriptionStatus::ACTIVE, $subscription->status); + $this->assertSame('iugu', $subscription->gateway); + } + + /** + * `refundableAmount()` pela fachada lê a fatura e devolve o restante que o driver calcula. + */ + public function testRefundableAmountReadsTheInvoiceAndReturnsTheRemainder(): void + { + $api = (new QueuedIuguApiRequest([ + (object) [ + 'id' => 'inv_1', + 'status' => 'partially_refunded', + 'total_cents' => 10000, + 'paid_cents' => 7500, + 'refunded_cents' => 2500, + 'paid_at' => '2026-08-20T10:00:00-03:00', + 'payment_method' => 'iugu_credit_card', + 'items' => [], + 'variables' => [], + ], + ]))->installAsSdkRequester(); + + $this->assertSame(7500, (new MultiPayment('iugu'))->refundableAmount('inv_1')); + $this->assertCount(1, $api->calls); + $this->assertStringEndsWith('/invoices/inv_1', $api->calls[0]['url']); + } + + /** + * O valor informado é buscado primeiro como identificador do plano. + */ + public function testGetPlanReadsThePlanByItsIdentifier(): void + { + $api = (new QueuedIuguApiRequest([self::planResponse()]))->installAsSdkRequester(); + + $plan = (new MultiPayment('iugu'))->getPlan('plano_mensal'); + + $this->assertInstanceOf(Plan::class, $plan); + $this->assertCount(1, $api->calls); + $this->assertStringEndsWith('/plans/identifier/plano_mensal', $api->calls[0]['url']); + $this->assertSame('plan_1', $plan->id); + $this->assertSame('plano_mensal', $plan->identifier); + $this->assertSame(10000, $plan->amount); + $this->assertSame(PlanInterval::MONTH, $plan->interval); + } + + /** + * Quando o identificador não existe, o mesmo valor é buscado como id do gateway. + */ + public function testGetPlanFallsBackToTheIdWhenTheIdentifierDoesNotExist(): void + { + $api = (new QueuedIuguApiRequest([ + new \IuguObjectNotFound('plan: not found'), + self::planResponse(), + ]))->installAsSdkRequester(); + + $plan = (new MultiPayment('iugu'))->getPlan('plan_1'); + + $this->assertCount(2, $api->calls); + $this->assertStringEndsWith('/plans/identifier/plan_1', $api->calls[0]['url']); + $this->assertStringEndsWith('/plans/plan_1', $api->calls[1]['url']); + $this->assertSame('plan_1', $plan->id); + } + + /** + * Plano inexistente pelos dois caminhos lança a `NotFoundException` da busca pelo id. + */ + public function testGetPlanThrowsNotFoundWhenNeitherLookupFinds(): void + { + $api = (new QueuedIuguApiRequest([ + new \IuguObjectNotFound('identifier: not found'), + new \IuguObjectNotFound('id: not found'), + ]))->installAsSdkRequester(); + + try { + (new MultiPayment('iugu'))->getPlan('inexistente'); + $this->fail('Esperava NotFoundException'); + } catch (NotFoundException $e) { + $this->assertStringContainsString('id: not found', $e->getMessage()); + } + $this->assertCount(2, $api->calls); + $this->assertStringEndsWith('/plans/inexistente', $api->calls[1]['url']); + } + + /** + * Só o 404 da busca pelo identificador leva à busca pelo id; outro erro sobe sem a segunda + * requisição. + */ + public function testGetPlanDoesNotFallBackToTheIdWhenTheIdentifierLookupFailsForAnotherReason(): void + { + $api = (new QueuedIuguApiRequest([ + new \IuguRequestException('Bad Gateway', 502), + ]))->installAsSdkRequester(); + + try { + (new MultiPayment('iugu'))->getPlan('plano_mensal'); + $this->fail('Esperava GatewayNotAvailableException'); + } catch (GatewayNotAvailableException) { + $this->assertCount(1, $api->calls); + } + } + + private static function planResponse(): object + { + return (object) [ + 'id' => 'plan_1', + 'identifier' => 'plano_mensal', + 'name' => 'Mensal', + 'interval' => 1, + 'interval_type' => 'months', + 'prices' => [(object) ['value_cents' => 10000, 'currency' => 'BRL']], + ]; + } +} diff --git a/tests/Unit/Providers/MultiPaymentServiceProviderTest.php b/tests/Unit/Providers/MultiPaymentServiceProviderTest.php new file mode 100644 index 0000000..610f94d --- /dev/null +++ b/tests/Unit/Providers/MultiPaymentServiceProviderTest.php @@ -0,0 +1,56 @@ +set('cache.default', 'array'); + $app['config']->set('multi-payment.idempotency.prefix', 'teste:idem:'); + } + + public function testTheProviderBindsACacheIdempotencyStoreWithTheConfiguredPrefix(): void + { + $store = $this->app->make(IdempotencyStore::class); + + $this->assertInstanceOf(CacheIdempotencyStore::class, $store); + $this->assertSame('ok', $store->remember('chave', fn () => 'ok', 60)); + $this->assertTrue($this->app['cache']->has('teste:idem:chave')); + $this->assertSame($store::class, ConfigurationHelper::resolveIdempotencyStore()::class); + } + + public function testTheApplicationCanReplaceTheStoreWithItsOwnBinding(): void + { + $own = new InMemoryIdempotencyStore(); + $this->app->instance(IdempotencyStore::class, $own); + + $this->assertSame($own, ConfigurationHelper::resolveIdempotencyStore()); + } + + public function testTheTtlComesFromTheConfigurationWithADefaultOfOneDay(): void + { + $this->assertSame(86400, ConfigurationHelper::idempotencyTtl()); + + $this->app['config']->set('multi-payment.idempotency.ttl', 120); + + $this->assertSame(120, ConfigurationHelper::idempotencyTtl()); + } +} diff --git a/tests/Unit/RecordingLogger.php b/tests/Unit/RecordingLogger.php new file mode 100644 index 0000000..c46550a --- /dev/null +++ b/tests/Unit/RecordingLogger.php @@ -0,0 +1,20 @@ + */ + public array $records = []; + + public function log($level, string|\Stringable $message, array $context = []): void + { + $this->records[] = ['level' => (string) $level, 'message' => (string) $message, 'context' => $context]; + } +} diff --git a/tests/Unit/RefundTest.php b/tests/Unit/RefundTest.php new file mode 100644 index 0000000..0ed0d2d --- /dev/null +++ b/tests/Unit/RefundTest.php @@ -0,0 +1,217 @@ +instance('config', new Repository([ + 'multi-payment' => [ + 'default' => 'iugu', + 'gateways' => [ + 'iugu' => ['api_key' => 'test-api-key', 'class' => RefundTestIuguGateway::class], + ], + ], + ])); + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + RefundTestIuguGateway::$api = null; + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + + parent::tearDown(); + } + + public function testStatusAcceptsTheStringAndReadsAsTheEnum(): void + { + $refund = new Refund(); + $refund->status = 'succeeded'; + + $this->assertSame(RefundStatus::SUCCEEDED, $refund->status); + $this->assertTrue(isset($refund->status)); + } + + public function testFillConvertsSnakeCaseKeysAndTheStatus(): void + { + $refund = new Refund(); + $refund->fill(['invoice_id' => 'inv_1', 'amount' => 500, 'status' => 'pending', 'reason' => 'duplicado']); + + $this->assertSame('inv_1', $refund->invoiceId); + $this->assertSame(500, $refund->amount); + $this->assertSame(RefundStatus::PENDING, $refund->status); + $this->assertSame('duplicado', $refund->reason); + } + + public function testToArrayEmitsSnakeCaseKeysAndTheStatusValue(): void + { + $refund = new Refund(); + $refund->id = 're_1'; + $refund->invoiceId = 'inv_1'; + $refund->amount = 500; + $refund->status = RefundStatus::SUCCEEDED; + $refund->gateway = 'stripe'; + + $this->assertSame([ + 'id' => 're_1', + 'invoice_id' => 'inv_1', + 'amount' => 500, + 'status' => 'succeeded', + 'gateway' => 'stripe', + ], $refund->toArray()); + } + + public function testInvoiceReturnsTheLoadedInvoiceWithoutARequest(): void + { + RefundTestIuguGateway::$api = new QueuedIuguApiRequest([]); + $invoice = new Invoice(); + $invoice->id = 'inv_1'; + $refund = new Refund(); + $refund->invoiceId = 'inv_1'; + $refund->gateway = 'iugu'; + $refund->invoice = $invoice; + + $this->assertSame($invoice, $refund->invoice()); + $this->assertSame([], RefundTestIuguGateway::$api->calls); + } + + public function testInvoiceReadsTheInvoiceFromTheGatewayOnceWhenNotLoaded(): void + { + RefundTestIuguGateway::$api = new QueuedIuguApiRequest([$this->partiallyRefundedInvoiceResponse()]); + $refund = new Refund(); + $refund->invoiceId = 'inv_1'; + $refund->gateway = 'iugu'; + + $invoice = $refund->invoice(); + + $this->assertSame('inv_1', $invoice->id); + $this->assertSame(InvoiceStatus::PARTIALLY_REFUNDED, $invoice->status); + $this->assertSame(3000, $invoice->refundedAmount); + $this->assertCount(1, RefundTestIuguGateway::$api->calls); + $this->assertSame('GET', RefundTestIuguGateway::$api->calls[0]['method']); + $this->assertStringEndsWith('/invoices/inv_1', RefundTestIuguGateway::$api->calls[0]['url']); + + $this->assertSame($invoice, $refund->invoice(), 'a segunda chamada devolve a fatura guardada'); + $this->assertCount(1, RefundTestIuguGateway::$api->calls); + $this->assertSame($invoice, $refund->invoice); + } + + public function testInvoiceRequiresTheInvoiceId(): void + { + $refund = new Refund(); + $refund->gateway = 'iugu'; + + $this->expectException(ModelAttributeValidationException::class); + + $refund->invoice(); + } + + /** + * Um `Refund` de `Invoice::$refunds` não aponta de volta para a fatura, senão a + * serialização entraria em ciclo. + */ + public function testInvoiceWithRefundsSerializesWithoutACycle(): void + { + RefundTestIuguGateway::$api = new QueuedIuguApiRequest([$this->partiallyRefundedInvoiceResponse()]); + + $invoice = (new RefundTestIuguGateway())->getInvoice($this->invoiceWithId()); + $json = json_decode(json_encode($invoice), true); + + $this->assertIsArray($json); + $this->assertCount(1, $json['refunds']); + $this->assertSame(3000, $json['refunds'][0]['amount']); + $this->assertSame('succeeded', $json['refunds'][0]['status']); + $this->assertNull($json['refunds'][0]['invoice']); + $this->assertSame('inv_1', $invoice->toArray()['refunds'][0]->invoiceId); + } + + public function testMultiPaymentRejectsAZeroOrNegativePartialValueBeforeAnyRequest(): void + { + RefundTestIuguGateway::$api = new QueuedIuguApiRequest([]); + + foreach ([0, -100] as $value) { + try { + (new MultiPayment('iugu'))->refundInvoice('inv_1', $value); + $this->fail("Esperava ModelAttributeValidationException para {$value}"); + } catch (ModelAttributeValidationException $e) { + $this->assertStringContainsString('amount', $e->getMessage()); + } + } + + $this->assertSame([], RefundTestIuguGateway::$api->calls); + } + + private function invoiceWithId(): Invoice + { + $invoice = new Invoice(); + $invoice->id = 'inv_1'; + + return $invoice; + } + + private function partiallyRefundedInvoiceResponse(): object + { + return (object) [ + 'id' => 'inv_1', + 'status' => 'partially_refunded', + 'total_cents' => 10000, + 'paid_at' => '2026-08-20T10:00:00-03:00', + 'secure_url' => 'https://faturas.iugu.com/inv_1', + 'taxes_paid_cents' => 250, + 'created_at_iso' => '2026-08-20T09:00:00-03:00', + 'paid_cents' => 7000, + 'refunded_cents' => 3000, + 'due_date' => '2026-08-25', + 'payment_method' => 'iugu_credit_card', + 'payable_with' => 'credit_card', + 'customer_id' => 'cus_1', + 'customer_name' => 'Cliente', + 'email' => 'cliente@example.com', + 'payer_phone' => null, + 'payer_phone_prefix' => null, + 'items' => [ + (object) ['description' => 'Item', 'price_cents' => 10000, 'quantity' => 1], + ], + 'payer_address_zip_code' => null, + 'bank_slip' => null, + 'pix' => null, + 'automatic_pix' => null, + 'credit_card_transaction' => null, + 'variables' => [], + ]; + } +} diff --git a/tests/Unit/SubscriptionTest.php b/tests/Unit/SubscriptionTest.php new file mode 100644 index 0000000..873876a --- /dev/null +++ b/tests/Unit/SubscriptionTest.php @@ -0,0 +1,1019 @@ +shouldReceive('supports')->with(Capability::SUBSCRIPTIONS)->andReturn(true); + + return $gateway; + } + + /** + * Gateway falso que não declara capability alguma. + */ + private static function gatewayWithoutCapabilities(): GatewayContract + { + $gateway = Mockery::mock(GatewayContract::class); + $gateway->shouldReceive('supports')->andReturn(false); + $gateway->shouldReceive('notYetImplemented')->andReturn([]); + $gateway->shouldReceive('__toString')->andReturn('falso'); + + return $gateway; + } + + public function testFillBuildsNestedModelsAndParsesDates(): void + { + $subscription = new Subscription(); + $subscription->fill([ + 'id' => 'sub_1', + 'plan_id' => 'plano_mensal', + 'customer' => ['name' => 'Fulano', 'email' => 'fulano@exemplo.com'], + 'items' => [['description' => 'Consultas', 'amount' => 1000, 'quantity' => 2]], + 'discounts' => [['description' => 'Promo', 'amount_off' => 500, 'cycles' => 1]], + 'next_billing_at' => '2026-10-01', + 'trial_ends_at' => '2026-09-15', + 'metadata' => ['origem' => 'teste'], + ]); + + $this->assertSame('plano_mensal', $subscription->planId); + $this->assertInstanceOf(Customer::class, $subscription->customer); + $this->assertSame('fulano@exemplo.com', $subscription->customer->email); + $this->assertInstanceOf(SubscriptionItem::class, $subscription->items[0]); + $this->assertSame(1000, $subscription->items[0]->amount); + $this->assertTrue($subscription->items[0]->recurring); + $this->assertInstanceOf(SubscriptionDiscount::class, $subscription->discounts[0]); + $this->assertSame(500, $subscription->discounts[0]->amountOff); + $this->assertInstanceOf(Carbon::class, $subscription->nextBillingAt); + $this->assertSame('2026-10-01', $subscription->nextBillingAt->format('Y-m-d')); + $this->assertSame('2026-09-15', $subscription->trialEndsAt->format('Y-m-d')); + $this->assertSame(['origem' => 'teste'], $subscription->metadata); + } + + public function testFillKeepsInstancesAlreadyBuilt(): void + { + $item = new SubscriptionItem(); + $item->description = 'Consultas'; + $item->amount = 100; + + $subscription = new Subscription(); + $subscription->fill(['items' => [$item]]); + + $this->assertSame($item, $subscription->items[0]); + } + + public function testFillWithoutItemsDoesNotClearTheExistingOnes(): void + { + $subscription = new Subscription(); + $subscription->items = [new SubscriptionItem()]; + + $subscription->fill(['status' => SubscriptionStatus::ACTIVE]); + + $this->assertCount(1, $subscription->items); + $this->assertSame(SubscriptionStatus::ACTIVE, $subscription->status); + } + + public function testToArrayFlattensItemsDiscountsAndCustomer(): void + { + $subscription = new Subscription(); + $subscription->fill([ + 'customer' => ['name' => 'Fulano'], + 'items' => [['description' => 'Consultas', 'amount' => 1000]], + 'discounts' => [['description' => 'Promo', 'percent_off' => 10.5]], + ]); + + $array = $subscription->toArray(); + + $this->assertSame('Consultas', $array['items'][0]['description']); + $this->assertSame(10.5, $array['discounts'][0]['percent_off']); + $this->assertSame('Fulano', $array['customer']['name']); + } + + public function testSubscriptionRequiresCustomerAndPlan(): void + { + $subscription = new Subscription(); + $subscription->planId = 'plano'; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/`customer` attribute is required/'); + + $subscription->validate(); + } + + public function testSubscriptionRequiresPlanId(): void + { + $subscription = new Subscription(); + $subscription->fill(['customer' => ['name' => 'Fulano']]); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/`planId` attribute is required/'); + + $subscription->validate(); + } + + public function testSubscriptionRejectsUnknownPaymentMethodOnWrite(): void + { + $subscription = new Subscription(); + $subscription->fill(['customer' => ['name' => 'Fulano'], 'plan_id' => 'plano']); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/availablePaymentMethods must be one of/'); + + $subscription->availablePaymentMethods = ['bitcoin']; + } + + public function testSubscriptionValidationRejectsNonSelectablePaymentMethod(): void + { + $subscription = new Subscription(); + $subscription->fill(['customer' => ['name' => 'Fulano'], 'plan_id' => 'plano']); + $subscription->availablePaymentMethods = [PaymentMethod::AUTOMATIC_PIX]; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/availablePaymentMethods must be one of/'); + + $subscription->validate(); + } + + /** + * `fill()` com `automatic_pix` cria o model aninhado (com as datas do mandato) e + * `toArray()` o devolve como array. + */ + public function testFillAndToArrayCarryAutomaticPix(): void + { + $subscription = new Subscription(); + $subscription->fill([ + 'plan_id' => 'plano_mensal', + 'automatic_pix' => [ + 'frequency' => AutomaticPix::FREQUENCY_MONTHLY, + 'starts_at' => '2026-10-01', + 'next_debit_at' => '2026-10-04', + 'pre_debit_notification_at' => '2026-10-01', + ], + ]); + + $this->assertInstanceOf(AutomaticPix::class, $subscription->automaticPix); + $this->assertSame(AutomaticPix::FREQUENCY_MONTHLY, $subscription->automaticPix->frequency); + $this->assertSame('2026-10-04', $subscription->automaticPix->nextDebitAt->format('Y-m-d')); + $this->assertSame('2026-10-01', $subscription->automaticPix->preDebitNotificationAt->format('Y-m-d')); + + $array = $subscription->toArray(); + $this->assertIsArray($array['automatic_pix']); + $this->assertSame(AutomaticPix::FREQUENCY_MONTHLY, $array['automatic_pix']['frequency']); + } + + public function testSubscriptionPropagatesItemValidation(): void + { + $subscription = new Subscription(); + $subscription->fill([ + 'customer' => ['name' => 'Fulano'], + 'plan_id' => 'plano', + 'items' => [['description' => 'Desconto', 'amount' => -100]], + ]); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/must not be negative/'); + + $subscription->validate(); + } + + public function testSubscriptionRejectsItemsOfTheWrongType(): void + { + $subscription = new Subscription(); + $subscription->fill(['customer' => ['name' => 'Fulano'], 'plan_id' => 'plano']); + $subscription->items = ['não é um item']; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/array of SubscriptionItem/'); + + $subscription->validate(); + } + + public function testValidSubscriptionPassesValidation(): void + { + $subscription = new Subscription(); + $subscription->fill([ + 'customer' => ['name' => 'Fulano'], + 'plan_id' => 'plano', + 'items' => [['description' => 'Consultas', 'amount' => 1000, 'quantity' => 1]], + 'discounts' => [['description' => 'Promo', 'amount_off' => 500]], + 'available_payment_methods' => [PaymentMethod::CREDIT_CARD, PaymentMethod::PIX], + ]); + + $subscription->validate(); + + $this->assertSame('plano', $subscription->planId); + } + + public function testItemRejectsNegativeAmount(): void + { + $item = new SubscriptionItem(); + $item->description = 'Desconto'; + $item->amount = -100; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/Use SubscriptionDiscount/'); + + $item->validate(); + } + + public function testItemRequiresDescriptionAndAmount(): void + { + $item = new SubscriptionItem(); + $item->amount = 100; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/`description` attribute is required/'); + + $item->validate(); + } + + /** + * Zero é vazio para o validate() do Model, então a regra de quantity mínima não pode + * depender de validateQuantityAttribute(). + */ + public function testItemRejectsZeroQuantity(): void + { + $item = new SubscriptionItem(); + $item->description = 'Consultas'; + $item->amount = 100; + $item->quantity = 0; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/quantity must be at least 1/'); + + $item->validate(); + } + + public function testItemAcceptsZeroAmount(): void + { + $item = new SubscriptionItem(); + $item->description = 'Cortesia'; + $item->amount = 0; + $item->quantity = 1; + + $item->validate(); + + $this->assertSame(0, $item->amount); + } + + public function testDiscountRequiresOneOfAmountOffOrPercentOff(): void + { + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/`amountOff or percentOff` attribute is required/'); + + $discount->validate(); + } + + public function testDiscountRejectsAmountOffAndPercentOffTogether(): void + { + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->amountOff = 500; + $discount->percentOff = 10; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/mutually exclusive/'); + + $discount->validate(); + } + + public function testDiscountRejectsNegativeAmountOff(): void + { + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->amountOff = -500; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/positive amount in cents/'); + + $discount->validate(); + } + + public function testDiscountRejectsPercentOffAboveOneHundred(): void + { + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->percentOff = 101; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/at most 100/'); + + $discount->validate(); + } + + public function testDiscountRejectsZeroCycles(): void + { + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->amountOff = 500; + $discount->cycles = 0; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/cycles must be null or at least 1/'); + + $discount->validate(); + } + + public function testDiscountRejectsCyclesAndValidUntilTogether(): void + { + $discount = new SubscriptionDiscount(); + $discount->description = 'Promo'; + $discount->amountOff = 500; + $discount->cycles = 3; + $discount->validUntil = Carbon::parse('2026-12-31'); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/cycles and validUntil are mutually exclusive/'); + + $discount->validate(); + } + + public function testBuilderDiscountsAcceptAValidUntilDate(): void + { + $gateway = Mockery::mock(GatewayContract::class); + + $subscription = (new SubscriptionBuilder($gateway)) + ->addAmountDiscount('Promo', 300, null, Carbon::parse('2026-12-31')) + ->get(); + + $this->assertSame('2026-12-31', $subscription->discounts[0]->validUntil->format('Y-m-d')); + $this->assertNull($subscription->discounts[0]->cycles); + } + + public function testDiscountFillParsesValidUntil(): void + { + $discount = new SubscriptionDiscount(); + $discount->fill(['description' => 'Promo', 'amount_off' => 500, 'valid_until' => '2026-12-31']); + + $this->assertSame('2026-12-31', $discount->validUntil->format('Y-m-d')); + + $carbon = new SubscriptionDiscount(); + $carbon->fill(['valid_until' => Carbon::parse('2026-12-31 10:00:00')]); + + $this->assertSame('2026-12-31 10:00:00', $carbon->validUntil->format('Y-m-d H:i:s')); + + $empty = new SubscriptionDiscount(); + $empty->fill(['description' => 'Promo', 'valid_until' => '']); + + $this->assertNull($empty->validUntil); + } + + public function testPlanRejectsUnknownIntervalOnWrite(): void + { + $plan = new Plan(); + $plan->name = 'Mensal'; + $plan->amount = 10000; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/interval must be one of: day, week, month, year/'); + + $plan->interval = 'quinzena'; + } + + public function testPlanRequiresNameAmountAndInterval(): void + { + $plan = new Plan(); + $plan->amount = 10000; + $plan->interval = PlanInterval::MONTH; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/`name` attribute is required/'); + + $plan->validate(); + } + + public function testValidPlanPassesValidation(): void + { + $plan = new Plan(); + $plan->name = 'Mensal'; + $plan->amount = 10000; + $plan->interval = PlanInterval::MONTH; + $plan->intervalCount = 1; + + $plan->validate(); + + $this->assertSame(PlanInterval::MONTH, $plan->interval); + } + + /** + * As linhas de uma simulação são linhas de fatura: crédito de período não usado vem com + * valor negativo. + */ + public function testPlanChangeParsesInvoiceLinesAndDate(): void + { + $planChange = new SubscriptionPlanChange(); + $planChange->fill([ + 'amount' => 50000, + 'items' => [['description' => 'Tempo não utilizado', 'price' => -10000, 'quantity' => 1]], + 'effective_at' => '2026-10-01', + 'applies_immediately' => true, + ]); + + $this->assertInstanceOf(InvoiceItem::class, $planChange->items[0]); + $this->assertSame(-10000, $planChange->items[0]->price); + $this->assertSame('2026-10-01', $planChange->effectiveAt->format('Y-m-d')); + $this->assertTrue($planChange->appliesImmediately); + } + + /** + * Uma prévia recém-criada já tem lista de linhas (vazia) e não assume que o plano vale na + * hora. + */ + public function testPlanChangeStartsWithAnEmptyListOfLines(): void + { + $planChange = new SubscriptionPlanChange(); + + $this->assertSame([], $planChange->items); + $this->assertFalse($planChange->appliesImmediately); + } + + /** + * `items` e `applies_immediately` nulos no `fill()` mantêm o valor atual em vez de lançar + * `TypeError`. + */ + public function testPlanChangeFillIgnoresNullLinesAndFlag(): void + { + $planChange = new SubscriptionPlanChange(); + $planChange->fill(['amount' => 100, 'items' => null, 'applies_immediately' => null]); + + $this->assertSame(100, $planChange->amount); + $this->assertSame([], $planChange->items); + $this->assertFalse($planChange->appliesImmediately); + } + + public function testBuilderAssemblesTheSubscription(): void + { + $gateway = Mockery::mock(GatewayContract::class); + + $subscription = (new SubscriptionBuilder($gateway)) + ->setPlanId('plano_mensal') + ->setCustomerId('cus_1') + ->addItem('Consultas', 1000, 2) + ->addItem('Setup', 500, 1, false) + ->addAmountDiscount('Promo', 300, 1) + ->addPercentDiscount('Anual', 10.0) + ->setNextBillingAt('2026-10-01') + ->setMetadata(['origem' => 'teste']) + ->get(); + + $this->assertSame('plano_mensal', $subscription->planId); + $this->assertSame('cus_1', $subscription->customer->id); + $this->assertCount(2, $subscription->items); + $this->assertTrue($subscription->items[0]->recurring); + $this->assertFalse($subscription->items[1]->recurring); + $this->assertCount(2, $subscription->discounts); + $this->assertSame(300, $subscription->discounts[0]->amountOff); + $this->assertSame(1, $subscription->discounts[0]->cycles); + $this->assertSame(10.0, $subscription->discounts[1]->percentOff); + $this->assertNull($subscription->discounts[1]->cycles); + $this->assertSame('2026-10-01', $subscription->nextBillingAt->format('Y-m-d')); + $this->assertSame(['origem' => 'teste'], $subscription->metadata); + } + + public function testBuilderCreateDelegatesToTheGateway(): void + { + $gateway = self::subscriptionGateway(); + $gateway->shouldReceive('createSubscription') + ->once() + ->andReturnUsing(function (Subscription $subscription) { + $subscription->id = 'sub_criada'; + + return $subscription; + }); + + $subscription = (new SubscriptionBuilder($gateway)) + ->setPlanId('plano_mensal') + ->setCustomerId('cus_1') + ->create(); + + $this->assertSame('sub_criada', $subscription->id); + } + + public function testFillAndToArrayHandleTheLatestInvoice(): void + { + $subscription = new Subscription(); + $subscription->fill([ + 'id' => 'sub_1', + 'latest_invoice' => ['id' => 'inv_1', 'status' => InvoiceStatus::PENDING], + ]); + + $this->assertInstanceOf(Invoice::class, $subscription->latestInvoice); + $this->assertSame('inv_1', $subscription->latestInvoice->id); + $this->assertSame('inv_1', $subscription->toArray()['latest_invoice']['id']); + } + + #[DataProvider('lifecycleProvider')] + public function testModelDelegatesLifecycleToTheGateway( + string $gatewayMethod, + array $gatewayArgs, + string $modelMethod, + array $modelArgs + ): void { + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $gateway = self::subscriptionGateway(); + $gateway->shouldReceive($gatewayMethod) + ->once() + // o último argumento do contract é a chave de idempotência, nula por padrão + ->with($subscription, ...array_merge($gatewayArgs, [null])) + ->andReturn($subscription); + + $this->assertSame( + $subscription, + $subscription->{$modelMethod}(...array_merge($modelArgs, [$gateway])) + ); + } + + public static function lifecycleProvider(): array + { + return [ + 'suspend' => ['suspendSubscription', [], 'suspend', []], + 'resume' => ['resumeSubscription', [], 'resume', []], + 'cancel imediato' => ['cancelSubscription', [false], 'cancel', [false]], + 'cancel ao fim do periodo' => ['cancelSubscription', [true], 'cancel', [true]], + 'changePlan cobrando' => ['changeSubscriptionPlan', ['plano_anual', ProrationBehavior::CHARGE_DIFFERENCE], 'changePlan', ['plano_anual', ProrationBehavior::CHARGE_DIFFERENCE]], + 'changePlan sem cobrar' => ['changeSubscriptionPlan', ['plano_anual', ProrationBehavior::NONE], 'changePlan', ['plano_anual', ProrationBehavior::NONE]], + 'changePlan com crédito' => ['changeSubscriptionPlan', ['plano_anual', ProrationBehavior::CREDIT], 'changePlan', ['plano_anual', ProrationBehavior::CREDIT]], + ]; + } + + /** + * O booleano antigo de `changePlan()`, posicional ou pelo nome `charge`, chega ao gateway + * traduzido para o enum, com aviso de obsolescência. + */ + #[DataProvider('deprecatedChargeProvider')] + #[IgnoreDeprecations] + public function testChangePlanTranslatesTheDeprecatedBoolean(callable $call, ProrationBehavior $expected): void + { + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $gateway = self::subscriptionGateway(); + $gateway->shouldReceive('changeSubscriptionPlan') + ->once() + ->with($subscription, 'plano_anual', $expected, null) + ->andReturn($subscription); + + $this->expectUserDeprecationMessage( + 'O booleano $charge de changePlan() está obsoleto desde 2026-09-02; passe' + . ' ProrationBehavior::CHARGE_DIFFERENCE ou ProrationBehavior::NONE' + ); + + $this->assertSame($subscription, $call($subscription, $gateway)); + } + + public static function deprecatedChargeProvider(): array + { + return [ + 'true posicional' => [ + fn (Subscription $s, $g) => $s->changePlan('plano_anual', true, $g), + ProrationBehavior::CHARGE_DIFFERENCE, + ], + 'false posicional' => [ + fn (Subscription $s, $g) => $s->changePlan('plano_anual', false, $g), + ProrationBehavior::NONE, + ], + 'charge nomeado' => [ + fn (Subscription $s, $g) => $s->changePlan('plano_anual', charge: false, gateway: $g), + ProrationBehavior::NONE, + ], + 'charge nomeado prevalece sobre o enum' => [ + fn (Subscription $s, $g) => $s->changePlan('plano_anual', ProrationBehavior::NONE, $g, charge: true), + ProrationBehavior::CHARGE_DIFFERENCE, + ], + ]; + } + + public function testPreviewPlanChangeDelegatesToTheGateway(): void + { + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $planChange = new SubscriptionPlanChange(); + + $gateway = self::subscriptionGateway(); + $gateway->shouldReceive('previewSubscriptionPlanChange') + ->once() + ->with($subscription, 'plano_anual') + ->andReturn($planChange); + + $this->assertSame($planChange, $subscription->previewPlanChange('plano_anual', $gateway)); + } + + public function testCreateSavesTheCustomerBeforeTheSubscription(): void + { + $ordem = []; + + $gateway = self::subscriptionGateway(); + $gateway->shouldReceive('createCustomer') + ->once() + ->andReturnUsing(function (Customer $customer) use (&$ordem) { + $ordem[] = 'customer'; + $customer->id = 'cus_novo'; + + return $customer; + }); + $gateway->shouldReceive('createSubscription') + ->once() + ->andReturnUsing(function (Subscription $subscription) use (&$ordem) { + $ordem[] = 'subscription'; + $subscription->id = 'sub_1'; + + return $subscription; + }); + + $customer = new Customer(); + $customer->name = 'Fulano'; + $customer->email = 'fulano@exemplo.com'; + + $subscription = (new SubscriptionBuilder($gateway)) + ->setPlanId('plano_mensal') + ->setCustomer($customer) + ->create(); + + $this->assertSame(['customer', 'subscription'], $ordem); + $this->assertSame('cus_novo', $subscription->customer->id); + } + + public function testSetItemsAndSetDiscountsReplaceInsteadOfAppending(): void + { + $gateway = Mockery::mock(GatewayContract::class); + + $item = new SubscriptionItem(); + $item->description = 'Único'; + $item->amount = 100; + + $subscription = (new SubscriptionBuilder($gateway)) + ->addItem('Descartado', 999, 1) + ->setItems([$item]) + ->addAmountDiscount('Descartado', 999) + ->setDiscounts([]) + ->setAvailablePaymentMethods([PaymentMethod::PIX]) + ->setTrialEndsAt('2026-09-15') + ->get(); + + $this->assertSame([$item], $subscription->items); + $this->assertSame([], $subscription->discounts); + $this->assertSame([PaymentMethod::PIX], $subscription->availablePaymentMethods); + $this->assertSame('2026-09-15', $subscription->trialEndsAt->format('Y-m-d')); + } + + /** + * Com id preenchido o save() faz update, que aceita atributo parcial. + */ + public function testUpdateDoesNotRequireCustomerOrPlanId(): void + { + $gateway = self::subscriptionGateway(); + $gateway->shouldReceive('updateSubscription')->once()->andReturnUsing(fn($s) => $s); + $gateway->shouldNotReceive('createCustomer'); + + $subscription = new Subscription(); + $subscription->fill([ + 'id' => 'sub_1', + 'items' => [['description' => 'Consultas', 'amount' => 100, 'quantity' => 1]], + ]); + + $subscription->save($gateway); + + $this->assertSame('sub_1', $subscription->id); + } + + /** + * Método de domínio recusa gateway sem a capability de assinaturas com + * `UnsupportedOperationException`, sem chegar ao contract. + */ + public function testDomainMethodsRejectAGatewayWithoutTheSubscriptionsCapability(): void + { + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + try { + $subscription->suspend(self::gatewayWithoutCapabilities()); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame(Capability::SUBSCRIPTIONS, $e->capability); + $this->assertSame('falso', $e->gateway); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + } + } + + /** + * Gateway que declara a capability sem implementar o contract é erro de driver e chega como + * `ConfigurationException`, sem `httpStatus`. + */ + public function testGatewayDeclaringTheCapabilityWithoutTheContractIsADriverError(): void + { + $gateway = Mockery::mock(GatewayContract::class); + $gateway->shouldReceive('supports')->with(Capability::SUBSCRIPTIONS)->andReturn(true); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessageMatches('/declares the subscriptions capability but does not implement SubscriptionContract/'); + + $subscription->suspend($gateway); + } + + /** + * O update dispensa cliente e plano, mas não as demais validações: item de valor negativo + * seria relido como desconto. + */ + public function testUpdateStillValidatesItemsAndPaymentMethods(): void + { + $gateway = self::subscriptionGateway(); + $gateway->shouldNotReceive('updateSubscription'); + + $subscription = new Subscription(); + $subscription->fill([ + 'id' => 'sub_1', + 'items' => [['description' => 'X', 'amount' => -500, 'quantity' => 1]], + ]); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/must not be negative/'); + + $subscription->save($gateway); + } + + public function testUpdateStillValidatesAvailablePaymentMethods(): void + { + $gateway = self::subscriptionGateway(); + $gateway->shouldNotReceive('updateSubscription'); + + $subscription = new Subscription(); + $subscription->id = 'sub_1'; + $subscription->availablePaymentMethods = [PaymentMethod::AUTOMATIC_PIX]; + + $this->expectException(ModelAttributeValidationException::class); + + $subscription->save($gateway); + } + + /** + * Gateway que declara a capability mas não tem o método do despacho por convenção é erro + * de driver e chega como `ConfigurationException`, sem requisição. + */ + public function testGatewayWithoutTheDispatchMethodIsAConfigurationError(): void + { + $gateway = Mockery::mock(GatewayContract::class); + $gateway->shouldReceive('supports')->with(Capability::PLANS)->andReturn(true); + + $plan = new Plan(); + $plan->name = 'Mensal'; + $plan->amount = 10000; + $plan->interval = PlanInterval::MONTH; + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessageMatches('/does not have method \[createPlan\]/'); + + $plan->save($gateway); + } + + public function testTheFacadeRejectsAGatewayDeclaringTheCapabilityWithoutTheContractAsAConfigurationError(): void + { + $gateway = Mockery::mock(GatewayContract::class); + $gateway->shouldReceive('supports')->with(Capability::PLANS)->andReturn(true); + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessageMatches('/declares the plans capability but does not implement PlanContract/'); + + (new MultiPayment($gateway))->listPlans(); + } + + public function testPlanWithIdCannotBeSavedAgain(): void + { + $plan = new Plan(); + $plan->id = 'plan_1'; + $plan->name = 'Mensal'; + $plan->amount = 10000; + $plan->interval = PlanInterval::MONTH; + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches('/cannot be updated/'); + + $plan->save(Mockery::mock(GatewayContract::class)); + } + + #[DataProvider('listOperationsProvider')] + public function testListOperationsRejectAGatewayWithoutTheCapability(string $metodo, array $args, Capability $capability): void + { + $multiPayment = new \Potelo\MultiPayment\MultiPayment(self::gatewayWithoutCapabilities()); + + try { + $multiPayment->{$metodo}(...$args); + $this->fail('Esperava UnsupportedOperationException'); + } catch (UnsupportedOperationException $e) { + $this->assertSame($capability, $e->capability); + $this->assertSame(UnsupportedOperationException::REASON_GATEWAY_LIMITATION, $e->reason); + } + } + + public static function listOperationsProvider(): array + { + return [ + 'assinaturas' => ['listSubscriptions', ['cus_1'], Capability::SUBSCRIPTIONS], + 'planos' => ['listPlans', [], Capability::PLANS], + ]; + } + + public function testBuilderSetsTheCardByIdAndImpliesTheCardPaymentMethod(): void + { + $subscription = (new SubscriptionBuilder(Mockery::mock(GatewayContract::class))) + ->setPlanId('plano_mensal') + ->setCustomerId('cus_1') + ->setCreditCard('pm_1') + ->setTrialDays(7) + ->get(); + + $this->assertInstanceOf(CreditCard::class, $subscription->creditCard); + $this->assertSame('pm_1', $subscription->creditCard->id); + $this->assertSame(PaymentMethod::CREDIT_CARD, $subscription->paymentMethod); + $this->assertSame(7, $subscription->trialDays); + $this->assertNull($subscription->trialEndsAt); + } + + public function testBuilderSetsTheCardByModelAndThePaymentMethodByString(): void + { + $card = new CreditCard(); + $card->token = 'tok_1'; + + $subscription = (new SubscriptionBuilder(Mockery::mock(GatewayContract::class))) + ->setCreditCard($card) + ->get(); + $this->assertSame($card, $subscription->creditCard); + + $pix = (new SubscriptionBuilder(Mockery::mock(GatewayContract::class))) + ->setPaymentMethod('pix') + ->get(); + $this->assertSame(PaymentMethod::PIX, $pix->paymentMethod); + $this->assertNull($pix->creditCard); + } + + public function testResolvedPaymentMethodFallsBackToTheCard(): void + { + $subscription = new Subscription(); + $this->assertNull($subscription->resolvedPaymentMethod()); + + $subscription->creditCard = new CreditCard(); + $this->assertSame(PaymentMethod::CREDIT_CARD, $subscription->resolvedPaymentMethod()); + + $subscription->paymentMethod = PaymentMethod::CREDIT_CARD; + $this->assertSame(PaymentMethod::CREDIT_CARD, $subscription->resolvedPaymentMethod()); + } + + public function testFillAndToArrayHandleTheCreditCard(): void + { + $subscription = new Subscription(); + $subscription->fill(['credit_card' => ['id' => 'pm_1'], 'trial_days' => 3]); + + $this->assertSame('pm_1', $subscription->creditCard->id); + $this->assertSame(3, $subscription->trialDays); + $this->assertSame('pm_1', $subscription->toArray()['credit_card']['id']); + $this->assertSame(3, $subscription->toArray()['trial_days']); + } + + #[DataProvider('invalidSubscriptionProvider')] + public function testValidationRejectsConflictingPaymentAndTrialAttributes(callable $mutate, string $message): void + { + $subscription = new Subscription(); + $subscription->fill(['customer' => ['name' => 'Fulano'], 'plan_id' => 'plano']); + $mutate($subscription); + + $this->expectException(ModelAttributeValidationException::class); + $this->expectExceptionMessageMatches($message); + + $subscription->validate(); + } + + public static function invalidSubscriptionProvider(): array + { + return [ + 'trialDays zero' => [fn (Subscription $s) => $s->trialDays = 0, '/trialDays must be at least 1/'], + 'trialDays negativo' => [fn (Subscription $s) => $s->trialDays = -1, '/trialDays must be at least 1/'], + 'trialDays com trialEndsAt' => [ + function (Subscription $s) { + $s->trialDays = 7; + $s->trialEndsAt = Carbon::parse('2026-10-01'); + }, + '/trialDays and trialEndsAt are mutually exclusive/', + ], + 'cartao com metodo pix' => [ + function (Subscription $s) { + $s->creditCard = new CreditCard(); + $s->creditCard->id = 'pm_1'; + $s->paymentMethod = PaymentMethod::PIX; + }, + '/creditCard was given but credit_card is not among the payment methods/', + ], + 'cartao com lista sem cartao' => [ + function (Subscription $s) { + $s->creditCard = new CreditCard(); + $s->creditCard->id = 'pm_1'; + $s->availablePaymentMethods = [PaymentMethod::PIX]; + }, + '/creditCard was given but credit_card is not among the payment methods/', + ], + 'metodo fora da lista' => [ + function (Subscription $s) { + $s->paymentMethod = PaymentMethod::CREDIT_CARD; + $s->availablePaymentMethods = [PaymentMethod::PIX]; + }, + '/paymentMethod \[credit_card\] must be one of availablePaymentMethods/', + ], + 'automatic pix na lista de metodos' => [ + fn (Subscription $s) => $s->availablePaymentMethods = [PaymentMethod::AUTOMATIC_PIX], + '/availablePaymentMethods must be one of: credit_card, bank_slip, pix/', + ], + 'automatic pix sem o metodo' => [ + fn (Subscription $s) => $s->automaticPix = new AutomaticPix(), + '/automaticPix was given but automatic_pix is not the payment method/', + ], + 'cartao invalido' => [ + function (Subscription $s) { + $s->creditCard = new CreditCard(); + $s->creditCard->number = '123'; + }, + '/CreditCard number/', + ], + ]; + } + + public function testRequiredCapabilitiesDeriveFromThePaymentMethodAndTheCard(): void + { + $subscription = new Subscription(); + $this->assertSame([Capability::SUBSCRIPTIONS], $subscription->requiredCapabilities()); + + $subscription->paymentMethod = PaymentMethod::PIX; + $this->assertSame([Capability::SUBSCRIPTIONS, Capability::PIX], $subscription->requiredCapabilities()); + + $rawCard = new Subscription(); + $rawCard->creditCard = new CreditCard(); + $rawCard->creditCard->number = '4111111111111111'; + $this->assertSame( + [Capability::SUBSCRIPTIONS, Capability::CREDIT_CARD, Capability::RAW_CARD_DATA], + $rawCard->requiredCapabilities() + ); + + $tokenCard = new Subscription(); + $tokenCard->creditCard = new CreditCard(); + $tokenCard->creditCard->token = 'tok_1'; + $this->assertSame([Capability::SUBSCRIPTIONS, Capability::CREDIT_CARD], $tokenCard->requiredCapabilities()); + + // a lista tem precedência sobre o método, e mais de um método exige MULTIPLE_PAYMENT_METHODS + $list = new Subscription(); + $list->paymentMethod = PaymentMethod::PIX; + $list->availablePaymentMethods = [PaymentMethod::PIX, PaymentMethod::BANK_SLIP]; + $this->assertSame( + [Capability::SUBSCRIPTIONS, Capability::PIX, Capability::BANK_SLIP, Capability::MULTIPLE_PAYMENT_METHODS], + $list->requiredCapabilities() + ); + $this->assertSame([PaymentMethod::PIX, PaymentMethod::BANK_SLIP], $list->resolvedPaymentMethods()); + } +} diff --git a/tests/fixtures/iugu/change_plan_simulation.json b/tests/fixtures/iugu/change_plan_simulation.json new file mode 100644 index 0000000..7f35f6e --- /dev/null +++ b/tests/fixtures/iugu/change_plan_simulation.json @@ -0,0 +1,8 @@ +{ + "cost": 30000, + "discount": 0, + "cycles": 1, + "expires_at": "2026-10-02", + "new_plan": "multipayment-teste-destino", + "old_plan": "multipayment-teste-origem" +} diff --git a/tests/fixtures/stripe/README.md b/tests/fixtures/stripe/README.md new file mode 100644 index 0000000..9053a04 --- /dev/null +++ b/tests/fixtures/stripe/README.md @@ -0,0 +1,115 @@ +# Fixtures da Stripe + +Respostas da sandbox da Stripe, API `2026-07-29.dahlia`, gravadas em 2026-09-02 (as de +boleto em 2026-09-04) com o `expand` que o driver usa (`payments.data.payment.payment_intent` +no Invoice; `latest_charge.balance_transaction` e `latest_charge.refunds` no PaymentIntent). +Só o `client_secret` dos PaymentIntents foi substituído por um placeholder. + +## `invoices/` + +Gravadas na sandbox: + +| Arquivo | Como foi produzida | +|---|---| +| `draft.json` | Invoice criado com um invoice item, antes de finalizar | +| `open_requires_payment_method.json` | finalizado, sem tentativa de pagamento | +| `open_after_declined_attempt.json` | `pay` com `pm_card_chargeCustomerFail` recusado (o PaymentIntent tem `latest_charge`) | +| `open_requires_action.json` | `pay` com `pm_card_authenticationRequired` | +| `paid.json` | `pay` com `pm_card_visa`; o mesmo Invoice serve para os estornos, que só mudam o PaymentIntent | +| `paid_disputed.json` | `pay` com `pm_card_createDispute` | +| `paid_out_of_band.json` | `pay` com `paid_out_of_band` (InvoicePayment do tipo `payment_record`; o PaymentIntent padrão é cancelado) | +| `paid_zero_amount_due.json` | invoice item de valor zero, finalizado (sem PaymentIntent) | +| `void.json` | `void` de um Invoice `open` | +| `uncollectible.json` | `mark_uncollectible` de um Invoice `open` | + +Montadas sobre `open_requires_payment_method.json`, porque a sandbox não produz o estado: + +| Arquivo | Diferença | +|---|---| +| `open_requires_confirmation.json` | status do PaymentIntent | +| `open_requires_capture.json` | status do PaymentIntent | +| `open_processing.json` | status do PaymentIntent | +| `open_partially_paid.json` | `amount_paid` 5000 e `amount_remaining` 7345 | +| `open_without_payment_intent.json` | `payments.data` vazio | + +Gravada na sandbox em 2026-09-04: + +| Arquivo | Como foi produzida | +|---|---| +| `open_boleto_send_invoice.json` | primeira fatura de uma assinatura `send_invoice` com `payment_settings.payment_method_types: ['boleto']`, finalizada na hora (GET com o expand do driver) | + +## `payment_intents/` + +`after_declined_attempt.json`, `paid.json`, `partially_refunded.json`, `refunded.json` e +`disputed.json` são o GET do PaymentIntent dos Invoices acima. `requires_action.json` é +`after_declined_attempt.json` com o status trocado e um `next_action` de 3DS. +`boleto_requires_action.json` (2026-09-04) é a resposta da criação de um PaymentIntent de +boleto confirmado server-side, com o voucher em `next_action.boleto_display_details`. + +## `disputes/` + +`needs_response.json` é o GET de `/v1/disputes?charge=` do charge disputado; +`lost.json` é o mesmo com o status trocado. + +## `setup_intents/` + +Gravadas na sandbox em 2026-09-02, com `expand[]=payment_method` e o SetupIntent criado e +confirmado na mesma requisição (`usage: off_session`, `payment_method_types: ['card']`), como +o driver faz em `createCreditCard()`: + +| Arquivo | Como foi produzida | +|---|---| +| `succeeded.json` | `pm_card_visa`, com `metadata` de descrição e de padrão; o PaymentMethod expandido já vem com `customer` (a Stripe anexa ao confirmar) | +| `requires_action.json` | `pm_card_authenticationRequired`, sem `return_url`: `next_action` do tipo `use_stripe_sdk` (o objeto interno foi reduzido a alguns campos; o certificado do servidor de diretório saiu) e PaymentMethod sem `customer` | +| `requires_action_redirect.json` | idem, com `return_url`: `next_action` do tipo `redirect_to_url`, com a chave publicável trocada por placeholder na URL | +| `card_declined.json` | corpo da resposta 402 de `pm_card_chargeDeclined` (`card_declined`, `generic_decline`, `advice_code` `try_again_later`), com o SetupIntent em `requires_payment_method` dentro do erro | + +O `client_secret` de todas foi substituído por um placeholder. + +## `subscriptions/` + +Gravadas na sandbox em 2026-09-04, pelo próprio driver e com o `expand` que ele usa +(`default_payment_method` e `items.data.price.product`); ids, `lookup_key`, nomes de plano e +e-mail foram renomeados para os valores estáveis das fixtures (`sub_1UBJmk...`, +`plano_mensal`...), sem tocar no restante do payload: + +| Arquivo | Como foi produzida | +|---|---| +| `active.json` | assinatura criada com cartão salvo (`pm_card_visa`) e plano mensal | +| `active_pause_collection.json` | a mesma assinatura depois de `suspendSubscription()` | +| `active_cancel_at_period_end.json` | a mesma depois de `cancelSubscription(atPeriodEnd: true)` | +| `canceled.json` | a mesma depois do cancelamento imediato (já no plano anual, pela troca) | +| `trialing.json` | assinatura criada com `trialDays` 7 no cartão salvo | +| `active_send_invoice_boleto.json` | assinatura criada com `collection_method` `send_invoice`, `days_until_due` 3 e `payment_settings.payment_method_types: ['boleto']` (GET com o expand do driver; ids reais da sessão de 2026-09-04) | + +Montadas sobre `active.json` (ou `trialing.json`), porque a sandbox não produz o estado: + +| Arquivo | Diferença | +|---|---| +| `incomplete.json` | status | +| `incomplete_expired.json` | status e `ended_at` | +| `past_due.json` | status | +| `unpaid.json` | status | +| `paused.json` | status, sobre `trialing.json` (trial que terminou sem método de pagamento) | + +Montadas a partir da documentação do Pix Automático (a conta ainda não tem o recurso +liberado; regravar na sandbox quando a Stripe o liberar): + +| Arquivo | Diferença | +|---|---| +| `incomplete_automatic_pix.json` | sobre `incomplete.json`: `payment_settings` com `payment_method_types: ['pix']` e `payment_method_options.pix.mandate_options`, sem método padrão | +| `active_automatic_pix.json` | sobre `active.json`: o mesmo `payment_settings` e um PaymentMethod `pix` como método padrão | + +## `mandates/` + +Montadas a partir da documentação do objeto Mandate (a conta ainda não tem Pix Automático +liberado): `active.json` e `inactive.json` diferem só no `status`. Regravar na sandbox quando +o recurso for liberado. + +## `webhooks/` + +Eventos entregues por `stripe listen` (CLI 1.50.10) numa sessão de sandbox em 2026-09-04, um +arquivo por tipo, com o corpo cru byte a byte como recebido (o `Stripe-Signature` de cada +entrega e o signing secret do listener ficam em arquivo fora do git, para o teste de +verificação de assinatura de uma versão futura validar o corpo exato). `mandate.updated` não +foi gravado: exige Pix Automático, que a conta ainda não tem liberado. diff --git a/tests/fixtures/stripe/disputes/lost.json b/tests/fixtures/stripe/disputes/lost.json new file mode 100644 index 0000000..cc9f4fc --- /dev/null +++ b/tests/fixtures/stripe/disputes/lost.json @@ -0,0 +1,98 @@ +{ + "object": "list", + "count": 1, + "data": [ + { + "id": "du_1UBHU8Pjx0CusuMr3hVB1hzt", + "object": "dispute", + "amount": 12345, + "balance_transaction": "txn_1UBHU9Pjx0CusuMrYjmqrfZA", + "balance_transactions": [ + { + "id": "txn_1UBHU9Pjx0CusuMrYjmqrfZA", + "object": "balance_transaction", + "amount": -12345, + "available_on": 1788912000, + "balance_type": "payments", + "created": 1788368284, + "currency": "brl", + "description": "Chargeback withdrawal for ch_3UBHU5Pjx0CusuMr1q2mLg5W", + "exchange_rate": null, + "fee": 3700, + "fee_details": [ + { + "amount": 3700, + "application": null, + "currency": "brl", + "description": "Dispute fee", + "type": "stripe_fee" + } + ], + "net": -16045, + "reporting_category": "dispute", + "source": "du_1UBHU8Pjx0CusuMr3hVB1hzt", + "status": "pending", + "type": "adjustment" + } + ], + "charge": "ch_3UBHU5Pjx0CusuMr1q2mLg5W", + "created": 1788368284, + "currency": "brl", + "enhanced_eligibility_types": [], + "evidence": { + "access_activity_log": null, + "billing_address": null, + "cancellation_policy": null, + "cancellation_policy_disclosure": null, + "cancellation_rebuttal": null, + "customer_communication": null, + "customer_email_address": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_purchase_ip": null, + "customer_signature": null, + "duplicate_charge_documentation": null, + "duplicate_charge_explanation": null, + "duplicate_charge_id": null, + "enhanced_evidence": [], + "product_description": "Payment for Invoice", + "receipt": null, + "refund_policy": null, + "refund_policy_disclosure": null, + "refund_refusal_explanation": null, + "service_date": null, + "service_documentation": null, + "shipping_address": null, + "shipping_carrier": null, + "shipping_date": null, + "shipping_documentation": null, + "shipping_tracking_number": null, + "uncategorized_file": null, + "uncategorized_text": null + }, + "evidence_details": { + "due_by": 1789084799, + "enhanced_eligibility": [], + "has_evidence": false, + "past_due": false, + "submission_count": 0 + }, + "is_charge_refundable": false, + "livemode": false, + "metadata": [], + "payment_intent": "pi_3UBHU5Pjx0CusuMr1cNe2BIo", + "payment_method_details": { + "card": { + "brand": "visa", + "case_type": "chargeback", + "network": "visa", + "network_reason_code": "10.4" + }, + "type": "card" + }, + "reason": "fraudulent", + "status": "lost" + } + ], + "has_more": false, + "url": "/v1/disputes" +} diff --git a/tests/fixtures/stripe/disputes/needs_response.json b/tests/fixtures/stripe/disputes/needs_response.json new file mode 100644 index 0000000..fab9fde --- /dev/null +++ b/tests/fixtures/stripe/disputes/needs_response.json @@ -0,0 +1,98 @@ +{ + "object": "list", + "count": 1, + "data": [ + { + "id": "du_1UBHU8Pjx0CusuMr3hVB1hzt", + "object": "dispute", + "amount": 12345, + "balance_transaction": "txn_1UBHU9Pjx0CusuMrYjmqrfZA", + "balance_transactions": [ + { + "id": "txn_1UBHU9Pjx0CusuMrYjmqrfZA", + "object": "balance_transaction", + "amount": -12345, + "available_on": 1788912000, + "balance_type": "payments", + "created": 1788368284, + "currency": "brl", + "description": "Chargeback withdrawal for ch_3UBHU5Pjx0CusuMr1q2mLg5W", + "exchange_rate": null, + "fee": 3700, + "fee_details": [ + { + "amount": 3700, + "application": null, + "currency": "brl", + "description": "Dispute fee", + "type": "stripe_fee" + } + ], + "net": -16045, + "reporting_category": "dispute", + "source": "du_1UBHU8Pjx0CusuMr3hVB1hzt", + "status": "pending", + "type": "adjustment" + } + ], + "charge": "ch_3UBHU5Pjx0CusuMr1q2mLg5W", + "created": 1788368284, + "currency": "brl", + "enhanced_eligibility_types": [], + "evidence": { + "access_activity_log": null, + "billing_address": null, + "cancellation_policy": null, + "cancellation_policy_disclosure": null, + "cancellation_rebuttal": null, + "customer_communication": null, + "customer_email_address": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_purchase_ip": null, + "customer_signature": null, + "duplicate_charge_documentation": null, + "duplicate_charge_explanation": null, + "duplicate_charge_id": null, + "enhanced_evidence": [], + "product_description": "Payment for Invoice", + "receipt": null, + "refund_policy": null, + "refund_policy_disclosure": null, + "refund_refusal_explanation": null, + "service_date": null, + "service_documentation": null, + "shipping_address": null, + "shipping_carrier": null, + "shipping_date": null, + "shipping_documentation": null, + "shipping_tracking_number": null, + "uncategorized_file": null, + "uncategorized_text": null + }, + "evidence_details": { + "due_by": 1789084799, + "enhanced_eligibility": [], + "has_evidence": false, + "past_due": false, + "submission_count": 0 + }, + "is_charge_refundable": false, + "livemode": false, + "metadata": [], + "payment_intent": "pi_3UBHU5Pjx0CusuMr1cNe2BIo", + "payment_method_details": { + "card": { + "brand": "visa", + "case_type": "chargeback", + "network": "visa", + "network_reason_code": "10.4" + }, + "type": "card" + }, + "reason": "fraudulent", + "status": "needs_response" + } + ], + "has_more": false, + "url": "/v1/disputes" +} diff --git a/tests/fixtures/stripe/invoices/draft.json b/tests/fixtures/stripe/invoices/draft.json new file mode 100644 index 0000000..857262b --- /dev/null +++ b/tests/fixtures/stripe/invoices/draft.json @@ -0,0 +1,160 @@ +{ + "id": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 0, + "amount_remaining": 12345, + "amount_shipping": 0, + "application": null, + "attempt_count": 0, + "attempted": false, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368263, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": null, + "ending_balance": null, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": null, + "invoice_pdf": null, + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHToPjx0CusuMrDMrHiN5q", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHToPjx0CusuMrV6jEQhm5", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368264, + "start": 1788368264 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": null, + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368263, + "period_start": 1788368263, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "auto" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "draft", + "status_transitions": { + "finalized_at": null, + "marked_uncollectible_at": null, + "paid_at": null, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368263 +} diff --git a/tests/fixtures/stripe/invoices/open_after_declined_attempt.json b/tests/fixtures/stripe/invoices/open_after_declined_attempt.json new file mode 100644 index 0000000..86e2626 --- /dev/null +++ b/tests/fixtures/stripe/invoices/open_after_declined_attempt.json @@ -0,0 +1,305 @@ +{ + "id": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 0, + "amount_remaining": 12345, + "amount_shipping": 0, + "application": null, + "attempt_count": 1, + "attempted": true, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368263, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368265, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2OQ0200FbEr9ZkZ?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2OQ0200FbEr9ZkZ/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHToPjx0CusuMrDMrHiN5q", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHToPjx0CusuMrV6jEQhm5", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368264, + "start": 1788368264 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0001", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [ + { + "id": "inpay_1UBHTpPjx0CusuMr5eW50ghI", + "object": "invoice_payment", + "amount_paid": null, + "amount_requested": 12345, + "created": 1788368265, + "currency": "brl", + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "is_default": true, + "livemode": false, + "payment": { + "payment_intent": { + "id": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHTpPjx0CusuMr1JTEiHGi_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368265, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": { + "advice_code": "try_again_later", + "charge": "ch_3UBHTpPjx0CusuMr1KQeiDo0", + "code": "card_declined", + "decline_code": "generic_decline", + "doc_url": "https://stripe.com/docs/error-codes/card-declined", + "message": "Your card was declined.", + "network_decline_code": "01", + "payment_method": { + "id": "pm_1UBHTrPjx0CusuMrw9Pshrt0", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "sryrMZCxUxxxIzqq", + "funding": "credit", + "generated_from": null, + "last4": "0341", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788368267, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": [], + "shared_payment_granted_token": null, + "type": "card" + }, + "type": "card_error" + }, + "latest_charge": "ch_3UBHTpPjx0CusuMr1KQeiDo0", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHTnPjx0CusuMrjxjg8WhK" + }, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null + }, + "type": "payment_intent" + }, + "status": "open", + "status_transitions": { + "canceled_at": null, + "paid_at": null + } + } + ], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368263, + "period_start": 1788368263, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "open", + "status_transitions": { + "finalized_at": 1788368265, + "marked_uncollectible_at": null, + "paid_at": null, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368263 +} diff --git a/tests/fixtures/stripe/invoices/open_boleto_send_invoice.json b/tests/fixtures/stripe/invoices/open_boleto_send_invoice.json new file mode 100644 index 0000000..a2afb88 --- /dev/null +++ b/tests/fixtures/stripe/invoices/open_boleto_send_invoice.json @@ -0,0 +1,239 @@ +{ + "id": "in_1UC8LxPjx0CusuMr8L1JgWdN", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 0, + "amount_remaining": 12345, + "amount_shipping": 0, + "application": null, + "attempt_count": 0, + "attempted": false, + "auto_advance": true, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "subscription_create", + "collection_method": "send_invoice", + "created": 1788571509, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VCXQBStDgeEid0", + "customer_account": null, + "customer_address": null, + "customer_email": "boleto-lote3@exemplo.com.br", + "customer_name": "Boleto Teste Lote3", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": 1788830709, + "effective_at": 1788571524, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQ1hRMGZKUlBrdFUzazI2S01mYUR3b0Y3UU9qb2RULDE3OTExMjQyOQ02006Ati0Ydk?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQ1hRMGZKUlBrdFUzazI2S01mYUR3b0Y3UU9qb2RULDE3OTExMjQyOQ02006Ati0Ydk/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UC8LxPjx0CusuMrB5oVCtqi", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "1 × Plano boleto lote3 (a R$ 123.45 / month)", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UC8LxPjx0CusuMr8L1JgWdN", + "livemode": false, + "metadata": {}, + "parent": { + "invoice_item_details": null, + "subscription_item_details": { + "invoice_item": null, + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": "sub_1UC8LxPjx0CusuMrKfvHqdWA", + "subscription_item": "si_VCXQ1RlvYb1XiI" + }, + "type": "subscription_item_details" + }, + "period": { + "end": 1791163509, + "start": 1788571509 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UC8LwPjx0CusuMrr3Vq7Hpk", + "product": "prod_VCXQ5X2E8yDpUz" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UC8LxPjx0CusuMr8L1JgWdN/lines" + }, + "livemode": false, + "metadata": {}, + "next_payment_attempt": null, + "number": "6B7ZYNWI-0001", + "on_behalf_of": null, + "parent": { + "quote_details": null, + "subscription_details": { + "metadata": {}, + "subscription": "sub_1UC8LxPjx0CusuMrKfvHqdWA" + }, + "type": "subscription_details" + }, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": [ + "boleto" + ] + }, + "payments": { + "object": "list", + "data": [ + { + "id": "inpay_1UC8MDPjx0CusuMrFJLLrEUj", + "object": "invoice_payment", + "amount_paid": null, + "amount_requested": 12345, + "created": 1788571525, + "currency": "brl", + "invoice": "in_1UC8LxPjx0CusuMr8L1JgWdN", + "is_default": true, + "livemode": false, + "payment": { + "payment_intent": { + "id": "pi_3UC8MDPjx0CusuMr05S6MVu1", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_fake_secret_placeholder", + "confirmation_method": "automatic", + "created": 1788571525, + "currency": "brl", + "customer": "cus_VCXQBStDgeEid0", + "customer_account": null, + "description": "Subscription creation", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": null, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UC8LxPjx0CusuMr8L1JgWdN" + }, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "boleto": { + "expires_after_days": 3 + } + }, + "payment_method_types": [ + "boleto" + ], + "payment_record": null, + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null + }, + "type": "payment_intent" + }, + "status": "open", + "status_transitions": { + "canceled_at": null, + "paid_at": null + } + } + ], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788571509, + "period_start": 1788571509, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": null, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "open", + "status_transitions": { + "finalized_at": 1788571524, + "marked_uncollectible_at": null, + "paid_at": null, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788571510 +} diff --git a/tests/fixtures/stripe/invoices/open_partially_paid.json b/tests/fixtures/stripe/invoices/open_partially_paid.json new file mode 100644 index 0000000..a0cfd8c --- /dev/null +++ b/tests/fixtures/stripe/invoices/open_partially_paid.json @@ -0,0 +1,243 @@ +{ + "id": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 5000, + "amount_remaining": 7345, + "amount_shipping": 0, + "application": null, + "attempt_count": 0, + "attempted": false, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368263, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368265, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2Ng0200LllC1QSU?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2Ng0200LllC1QSU/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHToPjx0CusuMrDMrHiN5q", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHToPjx0CusuMrV6jEQhm5", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368264, + "start": 1788368264 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0001", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [ + { + "id": "inpay_1UBHTpPjx0CusuMr5eW50ghI", + "object": "invoice_payment", + "amount_paid": 5000, + "amount_requested": 12345, + "created": 1788368265, + "currency": "brl", + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "is_default": true, + "livemode": false, + "payment": { + "payment_intent": { + "id": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHTpPjx0CusuMr1JTEiHGi_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368265, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": null, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHTnPjx0CusuMrjxjg8WhK" + }, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null + }, + "type": "payment_intent" + }, + "status": "open", + "status_transitions": { + "canceled_at": null, + "paid_at": null + } + } + ], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368263, + "period_start": 1788368263, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "open", + "status_transitions": { + "finalized_at": 1788368265, + "marked_uncollectible_at": null, + "paid_at": null, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368263 +} diff --git a/tests/fixtures/stripe/invoices/open_processing.json b/tests/fixtures/stripe/invoices/open_processing.json new file mode 100644 index 0000000..1b29e3f --- /dev/null +++ b/tests/fixtures/stripe/invoices/open_processing.json @@ -0,0 +1,243 @@ +{ + "id": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 0, + "amount_remaining": 12345, + "amount_shipping": 0, + "application": null, + "attempt_count": 0, + "attempted": false, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368263, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368265, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2Ng0200LllC1QSU?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2Ng0200LllC1QSU/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHToPjx0CusuMrDMrHiN5q", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHToPjx0CusuMrV6jEQhm5", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368264, + "start": 1788368264 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0001", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [ + { + "id": "inpay_1UBHTpPjx0CusuMr5eW50ghI", + "object": "invoice_payment", + "amount_paid": null, + "amount_requested": 12345, + "created": 1788368265, + "currency": "brl", + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "is_default": true, + "livemode": false, + "payment": { + "payment_intent": { + "id": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHTpPjx0CusuMr1JTEiHGi_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368265, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": null, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHTnPjx0CusuMrjxjg8WhK" + }, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "processing", + "transfer_data": null, + "transfer_group": null + }, + "type": "payment_intent" + }, + "status": "open", + "status_transitions": { + "canceled_at": null, + "paid_at": null + } + } + ], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368263, + "period_start": 1788368263, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "open", + "status_transitions": { + "finalized_at": 1788368265, + "marked_uncollectible_at": null, + "paid_at": null, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368263 +} diff --git a/tests/fixtures/stripe/invoices/open_requires_action.json b/tests/fixtures/stripe/invoices/open_requires_action.json new file mode 100644 index 0000000..f19a605 --- /dev/null +++ b/tests/fixtures/stripe/invoices/open_requires_action.json @@ -0,0 +1,263 @@ +{ + "id": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 0, + "amount_remaining": 12345, + "amount_shipping": 0, + "application": null, + "attempt_count": 1, + "attempted": true, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368263, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368265, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA3Mg02002PvIsoNX?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA3Mg02002PvIsoNX/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHToPjx0CusuMrDMrHiN5q", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHToPjx0CusuMrV6jEQhm5", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368264, + "start": 1788368264 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0001", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [ + { + "id": "inpay_1UBHTpPjx0CusuMr5eW50ghI", + "object": "invoice_payment", + "amount_paid": null, + "amount_requested": 12345, + "created": 1788368265, + "currency": "brl", + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "is_default": true, + "livemode": false, + "payment": { + "payment_intent": { + "id": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHTpPjx0CusuMr1JTEiHGi_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368265, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": "ch_3UBHTpPjx0CusuMr1KQeiDo0", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": { + "type": "use_stripe_sdk", + "use_stripe_sdk": { + "directory_server_encryption": { + "algorithm": "RSA", + "certificate": "-----BEGIN CERTIFICATE-----\nMIIGAzCCA+ugAwIBAgIQNyg9v/wemJMz4m18kdvt6zANBgkqhkiG9w0BAQsFADB2\nMQswCQYDVQQGEwJVUzENMAsGA1UECgwEVklTQTEvMC0GA1UECwwmVmlzYSBJbnRl\ncm5hdGlvbmFsIFNlcnZpY2UgQXNzb2NpYXRpb24xJzAlBgNVBAMMHlZpc2EgZUNv\nbW1lcmNlIElzc3VpbmcgQ0EgLSBHMjAeFw0yNDAyMjcyMjQ0MDNaFw0yNzAyMjYy\nMjQ0MDJaMIGhMRgwFgYDVQQHDA9IaWdobGFuZHMgUmFuY2gxETAPBgNVBAgMCENv\nbG9yYWRvMQswCQYDVQQGEwJVUzENMAsGA1UECgwEVklTQTEvMC0GA1UECwwmVmlz\nYSBJbnRlcm5hdGlvbmFsIFNlcnZpY2UgQXNzb2NpYXRpb24xJTAjBgNVBAMMHDNk\nczIucnNhLmVuY3J5cHRpb24udmlzYS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IB\nDwAwggEKAoIBAQCSABLI5aAnf8Cypn/sKETE+U3e3YruYzUkqNpEMH0sPMpCAW1V\n33xiklm5R0S3ZFOEzzlmL22tRyjXaB6/WJUa66ajRz2DiY8+7x5CcMJNVlwqS6OG\nTlmvXGOxPXIVz6hxCsAb7mKS7cnpGpkjVD/Oe4u8ZmeUrcPWKdeH+e5BfeWp2Iel\nh89pU4tpJ84PlPTlLjZ3TLa2OutFLsMBExcr4ipWnEcbpkzvAPBRABBVZbkWTA9i\nVM9v5MXIJGzXDsQVJEOgxm/kyQXeO3JtNytYOaQ6sQi+z9gF32plP3hsrUQ2jJ3H\nCnZmOyrHl8tQWxhAIMZ/WHogpvEGufi14eyBAgMBAAGjggFfMIIBWzAMBgNVHRMB\nAf8EAjAAMB8GA1UdIwQYMBaAFL0nYyikrlS3yCO3wTVCF+nGeF+FMGcGCCsGAQUF\nBwEBBFswWTAwBggrBgEFBQcwAoYkaHR0cDovL2Vucm9sbC52aXNhY2EuY29tL2VD\nb21tRzIuY3J0MCUGCCsGAQUFBzABhhlodHRwOi8vb2NzcC52aXNhLmNvbS9vY3Nw\nMEYGA1UdIAQ/MD0wMQYIKwYBBQUHAgEwJTAjBggrBgEFBQcCARYXaHR0cDovL3d3\ndy52aXNhLmNvbS9wa2kwCAYGZ4EDAQEBMBMGA1UdJQQMMAoGCCsGAQUFBwMCMDUG\nA1UdHwQuMCwwKqAooCaGJGh0dHA6Ly9lbnJvbGwudmlzYWNhLmNvbS9lQ29tbUcy\nLmNybDAdBgNVHQ4EFgQU6gD/Z5m2gw4n676qt4jXOWr8ynowDgYDVR0PAQH/BAQD\nAgSwMA0GCSqGSIb3DQEBCwUAA4ICAQBumWXGS/uaaajte2gI3xCpsuewmihQKvKq\nOhaOc+wgdfwIkEAXdJIc33n2j/iaT5Lusi09ezZpBB80C6KQVr5Fs10SX0hlBCvT\n9MGYFwFtYUgi5OenqYsXc8BdmiE0bFiSXZ7XfxWuvYjis4LOubMr48yVyyEynWWb\nSGr7rTJztraOFjsykK16x946YXi/LswEP9RtDiOBNJ9cBVcOP+u8Q+dEzA5A63Ay\nA2Z/5m/pyCIWMSYGMtM0RANUp9gp7cEXaMpwKWfrW6ZanF60IoGlmRHilGU4rvm7\ntd4yJTfwxtkmh4w/IPiqI/OaXj5iG8hCo8a9FTnGahIcij8c1UWzQ3/A7o99AUwy\n1Xi9JnNiXYVM1bfQalK0DKsLqRiS6rzPfwMUxj2+ALnhcrnSdPnZgVxiQB7kvfi3\nHFXHoGhmMHHj8tlURqUQzpJcCz1QOy5c9SCc/M8Xu+eRsCnJrtvvDxriLWfDDM9M\n8/8JsiBP+DF6omCb6J4ryEulcVPnVAAZNlNtaUadPYnCJfB0p67jWkHa5NvQdZGW\naT1tvo9DqjI7zLf9Km0qqkK51gwYDnsy14pYvfeEwL9O9aepFa7x08bM3272xqNK\neGHXFK/zUVxYY5uxukcqYAdhAvx7f6d0J6Nr8jPxo6UNXKiFH9leGPPzx9oNugts\nz6qck4YdAQ==\n-----END CERTIFICATE-----\n", + "directory_server_id": "A000000003", + "root_certificate_authorities": [ + "-----BEGIN CERTIFICATE-----\nMIIFqTCCA5GgAwIBAgIPUT6WAAAA20Qn7qzgvuFIMA0GCSqGSIb3DQEBCwUAMG8x\nCzAJBgNVBAYTAlVTMQ0wCwYDVQQKDARWSVNBMS8wLQYDVQQLDCZWaXNhIEludGVy\nbmF0aW9uYWwgU2VydmljZSBBc3NvY2lhdGlvbjEgMB4GA1UEAwwXVmlzYSBQdWJs\naWMgUlNBIFJvb3QgQ0EwHhcNMjEwMzE2MDAwMDAwWhcNNDEwMzE1MDAwMDAwWjBv\nMQswCQYDVQQGEwJVUzENMAsGA1UECgwEVklTQTEvMC0GA1UECwwmVmlzYSBJbnRl\ncm5hdGlvbmFsIFNlcnZpY2UgQXNzb2NpYXRpb24xIDAeBgNVBAMMF1Zpc2EgUHVi\nbGljIFJTQSBSb290IENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA\n2WEbXLS3gI6LOY93bP7Kz6EO9L1QXlr8l+fTkJWZldJ6QuwZ1cv4369tfjeJ8O5w\nSJiDcVw7eNdOP73LfAtwHlTnUnb0e9ILTTipc5bkNnAevocrJACsrpiQ8jBI9ttp\ncqKUeJgzW4Ie25ypirKroVD42b4E0iICK2cZ5QfD4BSzUnftp4Bqh8AfpGvG1lre\nCaD53qrsy5SUadY/NaeUGOkqdPvDSNoDIdrbExwnZaSFUmjQT1svKwMqGo2GFrgJ\n4cULEp4NNj5rga8YTTZ7Xo5MblHrLpSPOmJev30KWi/BcbvtCNYNWBTg7UMzP3cK\nMQ1pGLvG2PgvFTZSRvH3QzngJRgrDYYOJ6kj9ave+6yOOFqj80ZCuH0Nugt2mMS3\nc3+Nksaw+6H3cQPsE/Gv5zjfsKleRhEFtE1gyrdUg1DMgu8o/YhKM7FAqkXUn74z\nwoRFgx3Mi5OaGTQbg+NlwJgR4sVHXCV4s9b8PjneLhzWMn353SFARF9dnO7LDBqq\ntT6WltJu1z9x2Ze0UVNZvxKGcyCkLody29O8j9/MGZ8SOSUu4U6NHrebKuuf9Fht\nn6PqQ4ppkhy6sReXeV5NVGfVpDYY5ZAKEWqTYgMULWpQ2Py4BGpFzBe07jXkyulR\npoKvz14iXeA0oq16c94DrFYX0jmrWLeU4a/TCZQLFIsCAwEAAaNCMEAwHQYDVR0O\nBBYEFEtNpg77oBHorQvi8PMKAC+sixb7MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0P\nAQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUAA4ICAQC5BU9qQSZYPcgCp2x0Juq59kMm\nXuBly094DaEnPqvtCgwwAirkv8x8/QSOxiWWiu+nveyuR+j6Gz/fJaV4u+J5QEDy\ncfk605Mw3HIcJOeZvDgk1eyOmQwUP6Z/BdQTNJmZ92Z8dcG5yWCxLBrqPH7ro3Ss\njhYq9duIJU7jfizCJCN4W8tp0D2pWBe1/CYNswP4GMs5jQ5+ZQKN/L5JFdwVTu7X\nPt8b5zfgbmmQpVmUn0oFwm3OI++Z6gEpNmW5bd/2oUIZoG96Qff2fauVMAYiWQvN\nnL3y1gkRguTOSMVUCCiGfdvwu5ygowillvV2nHb7+YibQ9N5Z2spP0o9Zlfzoat2\n7WFpyK47TiUdu/4toarLKGZP+hbA/F4xlnM/8EfZkE1DeTTI0lhN3O8yEsHrtRl1\nOuQZ/IexHO8UGU6jvn4TWo10HYeXzrGckL7oIXfGTrjPzfY62T5HDW/BAEZS+9Tk\nijz25YM0fPPz7IdlEG+k4q4YwZ82j73Y9kDEM5423mrWorq/Bq7I5Y8v0LTY9GWH\nYrpElYf0WdOXAbsfwQiT6qnRio+p82VyqlY8Jt6VVA6CDy/iHKwcj1ELEnDQfVv9\nhedoxmnQ6xe/nK8czclu9hQJRv5Lh9gk9Q8DKK2nmgzZ8SSQ+lr3mSSeY8JOMRlE\n+RKdOQIChWthTJKh7w==\n-----END CERTIFICATE-----\n" + ] + }, + "directory_server_name": "visa", + "merchant": "acct_1TzKkTPjx0CusuMr", + "one_click_authn": null, + "server_transaction_id": "e7752c64-4c90-465e-8118-cfd955765525", + "three_d_secure_2_source": "payatt_3UBHTpPjx0CusuMr1aSm67j9", + "three_ds_frontend_optimizations": "eyJtZXRob2RfdGltZW91dCI6MTAsInNraXBfbWV0aG9kIjpmYWxzZSwic2FuZGJveF9tZXRob2QiOnRydWUsInNhbmRib3hfY2hhbGxlbmdlIjpmYWxzZSwicmVjb3JkX2ZpbmFsX2NyZXMiOnRydWUsImZ1bGxzY3JlZW5faG9zdGVkX2NoYWxsZW5nZSI6ZmFsc2UsInNob3dfbG9hZGluZ19iYXIiOnRydWUsInNob3dfY2hhbGxlbmdlX2ZyYW1lX2R1cmluZ19maW5nZXJwcmludGluZyI6dHJ1ZSwid2Fybl9vbl9hYmFuZG9uIjp0cnVlfQ==", + "three_ds_method_url": "", + "type": "stripe_3ds2_fingerprint" + } + }, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHTnPjx0CusuMrjxjg8WhK" + }, + "payment_method": "pm_1UBHTuPjx0CusuMr2Gfp5NBs", + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_action", + "transfer_data": null, + "transfer_group": null + }, + "type": "payment_intent" + }, + "status": "open", + "status_transitions": { + "canceled_at": null, + "paid_at": null + } + } + ], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368263, + "period_start": 1788368263, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "open", + "status_transitions": { + "finalized_at": 1788368265, + "marked_uncollectible_at": null, + "paid_at": null, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368263 +} diff --git a/tests/fixtures/stripe/invoices/open_requires_capture.json b/tests/fixtures/stripe/invoices/open_requires_capture.json new file mode 100644 index 0000000..1b85fa8 --- /dev/null +++ b/tests/fixtures/stripe/invoices/open_requires_capture.json @@ -0,0 +1,243 @@ +{ + "id": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 0, + "amount_remaining": 12345, + "amount_shipping": 0, + "application": null, + "attempt_count": 0, + "attempted": false, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368263, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368265, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2Ng0200LllC1QSU?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2Ng0200LllC1QSU/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHToPjx0CusuMrDMrHiN5q", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHToPjx0CusuMrV6jEQhm5", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368264, + "start": 1788368264 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0001", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [ + { + "id": "inpay_1UBHTpPjx0CusuMr5eW50ghI", + "object": "invoice_payment", + "amount_paid": null, + "amount_requested": 12345, + "created": 1788368265, + "currency": "brl", + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "is_default": true, + "livemode": false, + "payment": { + "payment_intent": { + "id": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHTpPjx0CusuMr1JTEiHGi_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368265, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": null, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHTnPjx0CusuMrjxjg8WhK" + }, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_capture", + "transfer_data": null, + "transfer_group": null + }, + "type": "payment_intent" + }, + "status": "open", + "status_transitions": { + "canceled_at": null, + "paid_at": null + } + } + ], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368263, + "period_start": 1788368263, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "open", + "status_transitions": { + "finalized_at": 1788368265, + "marked_uncollectible_at": null, + "paid_at": null, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368263 +} diff --git a/tests/fixtures/stripe/invoices/open_requires_confirmation.json b/tests/fixtures/stripe/invoices/open_requires_confirmation.json new file mode 100644 index 0000000..72052a5 --- /dev/null +++ b/tests/fixtures/stripe/invoices/open_requires_confirmation.json @@ -0,0 +1,243 @@ +{ + "id": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 0, + "amount_remaining": 12345, + "amount_shipping": 0, + "application": null, + "attempt_count": 0, + "attempted": false, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368263, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368265, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2Ng0200LllC1QSU?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2Ng0200LllC1QSU/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHToPjx0CusuMrDMrHiN5q", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHToPjx0CusuMrV6jEQhm5", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368264, + "start": 1788368264 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0001", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [ + { + "id": "inpay_1UBHTpPjx0CusuMr5eW50ghI", + "object": "invoice_payment", + "amount_paid": null, + "amount_requested": 12345, + "created": 1788368265, + "currency": "brl", + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "is_default": true, + "livemode": false, + "payment": { + "payment_intent": { + "id": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHTpPjx0CusuMr1JTEiHGi_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368265, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": null, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHTnPjx0CusuMrjxjg8WhK" + }, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_confirmation", + "transfer_data": null, + "transfer_group": null + }, + "type": "payment_intent" + }, + "status": "open", + "status_transitions": { + "canceled_at": null, + "paid_at": null + } + } + ], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368263, + "period_start": 1788368263, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "open", + "status_transitions": { + "finalized_at": 1788368265, + "marked_uncollectible_at": null, + "paid_at": null, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368263 +} diff --git a/tests/fixtures/stripe/invoices/open_requires_payment_method.json b/tests/fixtures/stripe/invoices/open_requires_payment_method.json new file mode 100644 index 0000000..a3dca14 --- /dev/null +++ b/tests/fixtures/stripe/invoices/open_requires_payment_method.json @@ -0,0 +1,243 @@ +{ + "id": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 0, + "amount_remaining": 12345, + "amount_shipping": 0, + "application": null, + "attempt_count": 0, + "attempted": false, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368263, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368265, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2Ng0200LllC1QSU?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2Ng0200LllC1QSU/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHToPjx0CusuMrDMrHiN5q", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHToPjx0CusuMrV6jEQhm5", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368264, + "start": 1788368264 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0001", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [ + { + "id": "inpay_1UBHTpPjx0CusuMr5eW50ghI", + "object": "invoice_payment", + "amount_paid": null, + "amount_requested": 12345, + "created": 1788368265, + "currency": "brl", + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "is_default": true, + "livemode": false, + "payment": { + "payment_intent": { + "id": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHTpPjx0CusuMr1JTEiHGi_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368265, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": null, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHTnPjx0CusuMrjxjg8WhK" + }, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null + }, + "type": "payment_intent" + }, + "status": "open", + "status_transitions": { + "canceled_at": null, + "paid_at": null + } + } + ], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368263, + "period_start": 1788368263, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "open", + "status_transitions": { + "finalized_at": 1788368265, + "marked_uncollectible_at": null, + "paid_at": null, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368263 +} diff --git a/tests/fixtures/stripe/invoices/open_without_payment_intent.json b/tests/fixtures/stripe/invoices/open_without_payment_intent.json new file mode 100644 index 0000000..e23d5c3 --- /dev/null +++ b/tests/fixtures/stripe/invoices/open_without_payment_intent.json @@ -0,0 +1,160 @@ +{ + "id": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 0, + "amount_remaining": 12345, + "amount_shipping": 0, + "application": null, + "attempt_count": 0, + "attempted": false, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368263, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368265, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2Ng0200LllC1QSU?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA2Ng0200LllC1QSU/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHToPjx0CusuMrDMrHiN5q", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHToPjx0CusuMrV6jEQhm5", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368264, + "start": 1788368264 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0001", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368263, + "period_start": 1788368263, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "open", + "status_transitions": { + "finalized_at": 1788368265, + "marked_uncollectible_at": null, + "paid_at": null, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368263 +} diff --git a/tests/fixtures/stripe/invoices/paid.json b/tests/fixtures/stripe/invoices/paid.json new file mode 100644 index 0000000..06ca9c6 --- /dev/null +++ b/tests/fixtures/stripe/invoices/paid.json @@ -0,0 +1,243 @@ +{ + "id": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 12345, + "amount_remaining": 0, + "amount_shipping": 0, + "application": null, + "attempt_count": 1, + "attempted": true, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368263, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368265, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA3NQ0200GZP1D7F9?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuQWVBcUNuMnFYUVNuYUpKWkdmbVlRUmNpcDkzLDE3ODkwOTA3NQ0200GZP1D7F9/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHToPjx0CusuMrDMrHiN5q", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHToPjx0CusuMrV6jEQhm5", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368264, + "start": 1788368264 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHTnPjx0CusuMrjxjg8WhK/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0001", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [ + { + "id": "inpay_1UBHTpPjx0CusuMr5eW50ghI", + "object": "invoice_payment", + "amount_paid": 12345, + "amount_requested": 12345, + "created": 1788368265, + "currency": "brl", + "invoice": "in_1UBHTnPjx0CusuMrjxjg8WhK", + "is_default": true, + "livemode": false, + "payment": { + "payment_intent": { + "id": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 12345, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHTpPjx0CusuMr1JTEiHGi_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368265, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": "ch_3UBHTpPjx0CusuMr1Grz2MK4", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHTnPjx0CusuMrjxjg8WhK" + }, + "payment_method": "pm_1UBHTwPjx0CusuMrTo9mvfUx", + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + }, + "type": "payment_intent" + }, + "status": "paid", + "status_transitions": { + "canceled_at": null, + "paid_at": 1788368273 + } + } + ], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368263, + "period_start": 1788368263, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "paid", + "status_transitions": { + "finalized_at": 1788368265, + "marked_uncollectible_at": null, + "paid_at": 1788368273, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368263 +} diff --git a/tests/fixtures/stripe/invoices/paid_disputed.json b/tests/fixtures/stripe/invoices/paid_disputed.json new file mode 100644 index 0000000..a0eda0b --- /dev/null +++ b/tests/fixtures/stripe/invoices/paid_disputed.json @@ -0,0 +1,243 @@ +{ + "id": "in_1UBHU4Pjx0CusuMrOTB0POaR", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 12345, + "amount_remaining": 0, + "amount_shipping": 0, + "application": null, + "attempt_count": 1, + "attempted": true, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368280, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368281, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVub0s5Yjl5dURDd1FrMUQwMUVYRVMxQWhodmd3LDE3ODkwOTA4Ng0200C4tYbPDy?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVub0s5Yjl5dURDd1FrMUQwMUVYRVMxQWhodmd3LDE3ODkwOTA4Ng0200C4tYbPDy/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHU4Pjx0CusuMros25pT4P", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHU4Pjx0CusuMrOTB0POaR", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHU4Pjx0CusuMr5PrOUkIb", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368280, + "start": 1788368280 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHU4Pjx0CusuMrOTB0POaR/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0002", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [ + { + "id": "inpay_1UBHU5Pjx0CusuMrA42qQGru", + "object": "invoice_payment", + "amount_paid": 12345, + "amount_requested": 12345, + "created": 1788368281, + "currency": "brl", + "invoice": "in_1UBHU4Pjx0CusuMrOTB0POaR", + "is_default": true, + "livemode": false, + "payment": { + "payment_intent": { + "id": "pi_3UBHU5Pjx0CusuMr1cNe2BIo", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 12345, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHU5Pjx0CusuMr1cNe2BIo_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368281, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": "ch_3UBHU5Pjx0CusuMr1q2mLg5W", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHU4Pjx0CusuMrOTB0POaR" + }, + "payment_method": "pm_1UBHU6Pjx0CusuMrlJ3aMuAK", + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + }, + "type": "payment_intent" + }, + "status": "paid", + "status_transitions": { + "canceled_at": null, + "paid_at": 1788368283 + } + } + ], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368280, + "period_start": 1788368280, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "paid", + "status_transitions": { + "finalized_at": 1788368281, + "marked_uncollectible_at": null, + "paid_at": 1788368283, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368280 +} diff --git a/tests/fixtures/stripe/invoices/paid_out_of_band.json b/tests/fixtures/stripe/invoices/paid_out_of_band.json new file mode 100644 index 0000000..073247a --- /dev/null +++ b/tests/fixtures/stripe/invoices/paid_out_of_band.json @@ -0,0 +1,263 @@ +{ + "id": "in_1UBHUHPjx0CusuMrD81KgsNd", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 12345, + "amount_remaining": 0, + "amount_shipping": 0, + "application": null, + "attempt_count": 0, + "attempted": false, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368293, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368294, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuSEFJSDNTVzVsU3I2VFYxOHB2Zm5PYVFCbjM0LDE3ODkwOTA5OA0200FNURwqxW?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuSEFJSDNTVzVsU3I2VFYxOHB2Zm5PYVFCbjM0LDE3ODkwOTA5OA0200FNURwqxW/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHUIPjx0CusuMruYYLM4mN", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHUHPjx0CusuMrD81KgsNd", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHUIPjx0CusuMrRqKbhPtU", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368294, + "start": 1788368294 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHUHPjx0CusuMrD81KgsNd/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0005", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [ + { + "id": "inpay_1UBHUKPjx0CusuMr88FVgCVO", + "object": "invoice_payment", + "amount_paid": 12345, + "amount_requested": 12345, + "created": 1788368296, + "currency": "brl", + "invoice": "in_1UBHUHPjx0CusuMrD81KgsNd", + "is_default": false, + "livemode": false, + "payment": { + "payment_record": "pr_test_65VKfRg6G9qn6oUaOkJ41Pjx0CusuMrUdM", + "type": "payment_record" + }, + "status": "paid", + "status_transitions": { + "canceled_at": null, + "paid_at": 1788368296 + } + }, + { + "id": "inpay_1UBHUJPjx0CusuMrjY5vdZwl", + "object": "invoice_payment", + "amount_paid": null, + "amount_requested": 12345, + "created": 1788368295, + "currency": "brl", + "invoice": "in_1UBHUHPjx0CusuMrD81KgsNd", + "is_default": false, + "livemode": false, + "payment": { + "payment_intent": { + "id": "pi_3UBHUJPjx0CusuMr1g8brq49", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": 1788368296, + "cancellation_reason": "duplicate", + "capture_method": "automatic", + "client_secret": "pi_3UBHUJPjx0CusuMr1g8brq49_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368295, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": null, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHUHPjx0CusuMrD81KgsNd" + }, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "canceled", + "transfer_data": null, + "transfer_group": null + }, + "type": "payment_intent" + }, + "status": "canceled", + "status_transitions": { + "canceled_at": 1788368297, + "paid_at": null + } + } + ], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368293, + "period_start": 1788368293, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "paid", + "status_transitions": { + "finalized_at": 1788368294, + "marked_uncollectible_at": null, + "paid_at": 1788368296, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368293 +} diff --git a/tests/fixtures/stripe/invoices/paid_zero_amount_due.json b/tests/fixtures/stripe/invoices/paid_zero_amount_due.json new file mode 100644 index 0000000..a66a2ab --- /dev/null +++ b/tests/fixtures/stripe/invoices/paid_zero_amount_due.json @@ -0,0 +1,160 @@ +{ + "id": "in_1UBHUNPjx0CusuMrEG6etDb8", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 0, + "amount_overpaid": 0, + "amount_paid": 0, + "amount_remaining": 0, + "amount_shipping": 0, + "application": null, + "attempt_count": 0, + "attempted": true, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368299, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368300, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuM3VLbkNZS2tVc0ppOWZmSEoxcDltdEtNSFlNLDE3ODkwOTEwMA0200daC6w6MF?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVuM3VLbkNZS2tVc0ppOWZmSEoxcDltdEtNSFlNLDE3ODkwOTEwMA0200daC6w6MF/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHUNPjx0CusuMr4FkbSzmo", + "object": "line_item", + "amount": 0, + "currency": "brl", + "description": "Período de teste", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHUNPjx0CusuMrEG6etDb8", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHUNPjx0CusuMrbRF91HQA", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368299, + "start": 1788368299 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHUNPjx0CusuMrbGQ5kEqP", + "product": "prod_VBenoD0uH86i0j" + }, + "type": "price_details", + "unit_amount_decimal": "0" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 0, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHUNPjx0CusuMrEG6etDb8/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0006", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368299, + "period_start": 1788368299, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "paid", + "status_transitions": { + "finalized_at": 1788368300, + "marked_uncollectible_at": null, + "paid_at": 1788368300, + "voided_at": null + }, + "subtotal": 0, + "subtotal_excluding_tax": 0, + "test_clock": null, + "total": 0, + "total_discount_amounts": [], + "total_excluding_tax": 0, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368299 +} diff --git a/tests/fixtures/stripe/invoices/uncollectible.json b/tests/fixtures/stripe/invoices/uncollectible.json new file mode 100644 index 0000000..227290f --- /dev/null +++ b/tests/fixtures/stripe/invoices/uncollectible.json @@ -0,0 +1,243 @@ +{ + "id": "in_1UBHUEPjx0CusuMrnkQe23fT", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 0, + "amount_remaining": 12345, + "amount_shipping": 0, + "application": null, + "attempt_count": 0, + "attempted": false, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368290, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368291, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVudlk1SUJjWmRMTjFGdkZiakQ3MzM5NEJJTVV3LDE3ODkwOTA5Mw0200zEaqVcJo?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVudlk1SUJjWmRMTjFGdkZiakQ3MzM5NEJJTVV3LDE3ODkwOTA5Mw0200zEaqVcJo/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHUFPjx0CusuMrfR4xvtyu", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHUEPjx0CusuMrnkQe23fT", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHUFPjx0CusuMrWuNACPFV", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368291, + "start": 1788368291 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHUEPjx0CusuMrnkQe23fT/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0004", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [ + { + "id": "inpay_1UBHUGPjx0CusuMreZ1I63mR", + "object": "invoice_payment", + "amount_paid": null, + "amount_requested": 12345, + "created": 1788368292, + "currency": "brl", + "invoice": "in_1UBHUEPjx0CusuMrnkQe23fT", + "is_default": true, + "livemode": false, + "payment": { + "payment_intent": { + "id": "pi_3UBHUGPjx0CusuMr1qD1a6qP", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHUGPjx0CusuMr1qD1a6qP_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368292, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": null, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHUEPjx0CusuMrnkQe23fT" + }, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null + }, + "type": "payment_intent" + }, + "status": "open", + "status_transitions": { + "canceled_at": null, + "paid_at": null + } + } + ], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368290, + "period_start": 1788368290, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "uncollectible", + "status_transitions": { + "finalized_at": 1788368291, + "marked_uncollectible_at": 1788368293, + "paid_at": null, + "voided_at": null + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368290 +} diff --git a/tests/fixtures/stripe/invoices/void.json b/tests/fixtures/stripe/invoices/void.json new file mode 100644 index 0000000..e39112a --- /dev/null +++ b/tests/fixtures/stripe/invoices/void.json @@ -0,0 +1,243 @@ +{ + "id": "in_1UBHUBPjx0CusuMrdZ8CWaQW", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 12345, + "amount_overpaid": 0, + "amount_paid": 0, + "amount_remaining": 12345, + "amount_shipping": 0, + "application": null, + "attempt_count": 0, + "attempted": false, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "manual", + "collection_method": "charge_automatically", + "created": 1788368287, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "customer_address": null, + "customer_email": "fixture@example.com", + "customer_name": "Fixture Customer", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788368288, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVucWRHYXpJMFZVMHFHNUZJdWhCYWp4ZFRZSzQyLDE3ODkwOTA5MA0200LXmagdLN?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQmVucWRHYXpJMFZVMHFHNUZJdWhCYWp4ZFRZSzQyLDE3ODkwOTA5MA0200LXmagdLN/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UBHUCPjx0CusuMr3G5FJ0KK", + "object": "line_item", + "amount": 12345, + "currency": "brl", + "description": "Assinatura mensal", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UBHUBPjx0CusuMrdZ8CWaQW", + "livemode": false, + "metadata": [], + "parent": { + "invoice_item_details": { + "invoice_item": "ii_1UBHUCPjx0CusuMr9hFzgHt0", + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": null + }, + "subscription_item_details": null, + "type": "invoice_item_details" + }, + "period": { + "end": 1788368288, + "start": 1788368288 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UBHTYPjx0CusuMrLR1pKSFN", + "product": "prod_VBeni0Nk8Fandr" + }, + "type": "price_details", + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 12345, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UBHUBPjx0CusuMrdZ8CWaQW/lines" + }, + "livemode": false, + "metadata": [], + "next_payment_attempt": null, + "number": "HJRZH2D7-0003", + "on_behalf_of": null, + "parent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "payments": { + "object": "list", + "data": [ + { + "id": "inpay_1UBHUDPjx0CusuMre4zedBKF", + "object": "invoice_payment", + "amount_paid": null, + "amount_requested": 12345, + "created": 1788368289, + "currency": "brl", + "invoice": "in_1UBHUBPjx0CusuMrdZ8CWaQW", + "is_default": true, + "livemode": false, + "payment": { + "payment_intent": { + "id": "pi_3UBHUDPjx0CusuMr1qYLdJ5A", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": 1788368290, + "cancellation_reason": "void_invoice", + "capture_method": "automatic", + "client_secret": "pi_3UBHUDPjx0CusuMr1qYLdJ5A_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368289, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": null, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHUBPjx0CusuMrdZ8CWaQW" + }, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "canceled", + "transfer_data": null, + "transfer_group": null + }, + "type": "payment_intent" + }, + "status": "open", + "status_transitions": { + "canceled_at": null, + "paid_at": null + } + } + ], + "has_more": false, + "url": "/v1/invoices/payments" + }, + "period_end": 1788368287, + "period_start": 1788368287, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": { + "amount_tax_display": null, + "pdf": { + "page_size": "letter" + }, + "template": null, + "template_version": null + }, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "void", + "status_transitions": { + "finalized_at": 1788368288, + "marked_uncollectible_at": null, + "paid_at": null, + "voided_at": 1788368290 + }, + "subtotal": 12345, + "subtotal_excluding_tax": 12345, + "test_clock": null, + "total": 12345, + "total_discount_amounts": [], + "total_excluding_tax": 12345, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": 1788368287 +} diff --git a/tests/fixtures/stripe/mandates/active.json b/tests/fixtures/stripe/mandates/active.json new file mode 100644 index 0000000..a9ebcb6 --- /dev/null +++ b/tests/fixtures/stripe/mandates/active.json @@ -0,0 +1,29 @@ +{ + "id": "mandate_1UBJmkPjx0CusuMr7PxMnd41", + "object": "mandate", + "customer_acceptance": { + "accepted_at": 1788565981, + "online": { + "ip_address": "127.0.0.1", + "user_agent": "Mozilla/5.0" + }, + "type": "online" + }, + "livemode": false, + "multi_use": {}, + "payment_method": "pm_1UBJmjPjx0CusuMrPixMand1", + "payment_method_details": { + "pix": { + "amount": 10000, + "amount_includes_iof": "never", + "amount_type": "fixed", + "end_date": null, + "payment_schedule": "monthly", + "reference": "Empresa Exemplo", + "start_date": 1788825600 + }, + "type": "pix" + }, + "status": "active", + "type": "multi_use" +} diff --git a/tests/fixtures/stripe/mandates/inactive.json b/tests/fixtures/stripe/mandates/inactive.json new file mode 100644 index 0000000..14edd57 --- /dev/null +++ b/tests/fixtures/stripe/mandates/inactive.json @@ -0,0 +1,29 @@ +{ + "id": "mandate_1UBJmkPjx0CusuMr7PxMnd41", + "object": "mandate", + "customer_acceptance": { + "accepted_at": 1788565981, + "online": { + "ip_address": "127.0.0.1", + "user_agent": "Mozilla/5.0" + }, + "type": "online" + }, + "livemode": false, + "multi_use": {}, + "payment_method": "pm_1UBJmjPjx0CusuMrPixMand1", + "payment_method_details": { + "pix": { + "amount": 10000, + "amount_includes_iof": "never", + "amount_type": "fixed", + "end_date": null, + "payment_schedule": "monthly", + "reference": "Empresa Exemplo", + "start_date": 1788825600 + }, + "type": "pix" + }, + "status": "inactive", + "type": "multi_use" +} diff --git a/tests/fixtures/stripe/payment_intents/after_declined_attempt.json b/tests/fixtures/stripe/payment_intents/after_declined_attempt.json new file mode 100644 index 0000000..60c73f3 --- /dev/null +++ b/tests/fixtures/stripe/payment_intents/after_declined_attempt.json @@ -0,0 +1,244 @@ +{ + "id": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHTpPjx0CusuMr1JTEiHGi_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368265, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": { + "advice_code": "try_again_later", + "charge": "ch_3UBHTpPjx0CusuMr1KQeiDo0", + "code": "card_declined", + "decline_code": "generic_decline", + "doc_url": "https://stripe.com/docs/error-codes/card-declined", + "message": "Your card was declined.", + "network_decline_code": "01", + "payment_method": { + "id": "pm_1UBHTrPjx0CusuMrw9Pshrt0", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "sryrMZCxUxxxIzqq", + "funding": "credit", + "generated_from": null, + "last4": "0341", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788368267, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": [], + "shared_payment_granted_token": null, + "type": "card" + }, + "type": "card_error" + }, + "latest_charge": { + "id": "ch_3UBHTpPjx0CusuMr1KQeiDo0", + "object": "charge", + "amount": 12345, + "amount_captured": 0, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": null, + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "calculated_statement_descriptor": "ESCAVADOR", + "captured": false, + "created": 1788368268, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "description": "Payment for Invoice", + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": "card_declined", + "failure_message": "Your card was declined.", + "fraud_details": [], + "livemode": false, + "metadata": [], + "on_behalf_of": null, + "order": null, + "outcome": { + "advice_code": "try_again_later", + "network_advice_code": null, + "network_decline_code": "01", + "network_status": "declined_by_network", + "reason": "generic_decline", + "risk_level": "normal", + "risk_score": 36, + "seller_message": "The bank did not return any further details with this decline.", + "type": "issuer_declined" + }, + "paid": false, + "payment_intent": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "payment_method": "pm_1UBHTrPjx0CusuMrw9Pshrt0", + "payment_method_details": { + "card": { + "amount_authorized": null, + "authorization_code": "319799", + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 9, + "exp_year": 2027, + "extended_authorization": { + "status": "disabled" + }, + "fingerprint": "sryrMZCxUxxxIzqq", + "funding": "credit", + "incremental_authorization": { + "status": "unavailable" + }, + "installments": null, + "last4": "0341", + "mandate": null, + "multicapture": { + "status": "unavailable" + }, + "network": "visa", + "network_token": { + "used": false + }, + "network_transaction_id": "115114121114779", + "overcapture": { + "maximum_amount_capturable": 12345, + "status": "unavailable" + }, + "regulated_status": "unregulated", + "three_d_secure": null, + "transaction_link_id": null, + "wallet": null + }, + "type": "card" + }, + "radar_options": [], + "receipt_email": null, + "receipt_number": null, + "receipt_url": null, + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_3UBHTpPjx0CusuMr1KQeiDo0/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "failed", + "transfer_data": null, + "transfer_group": null + }, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHTnPjx0CusuMrjxjg8WhK" + }, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null +} diff --git a/tests/fixtures/stripe/payment_intents/boleto_requires_action.json b/tests/fixtures/stripe/payment_intents/boleto_requires_action.json new file mode 100644 index 0000000..c345ca5 --- /dev/null +++ b/tests/fixtures/stripe/payment_intents/boleto_requires_action.json @@ -0,0 +1,69 @@ +{ + "id": "pi_3UC8LGPjx0CusuMr1jDGqScY", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic_async", + "client_secret": "pi_fake_secret_placeholder", + "confirmation_method": "automatic", + "created": 1788571466, + "currency": "brl", + "customer": null, + "customer_account": null, + "description": null, + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": null, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": { + "item_0_description": "Assinatura mensal", + "item_0_price": "12345", + "item_0_quantity": "1" + }, + "next_action": { + "boleto_display_details": { + "expires_at": 1788836340, + "hosted_voucher_url": "https://payments.stripe.com/boleto/voucher/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQ1hRQ0F2NDBxZGZ0WFhjT3Q4M2Q2d1BvSVhkbTk50100QLU2LKPD", + "number": "01010101010101010101010101010101010101010101010", + "pdf": "https://payments.stripe.com/boleto/voucher/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQ1hRQ0F2NDBxZGZ0WFhjT3Q4M2Q2d1BvSVhkbTk50100QLU2LKPD/pdf" + }, + "type": "boleto_display_details" + }, + "on_behalf_of": null, + "payment_method": "pm_1UC8LGPjx0CusuMrOMQIs9ua", + "payment_method_configuration_details": null, + "payment_method_options": { + "boleto": { + "expires_after_days": 3 + } + }, + "payment_method_types": [ + "boleto" + ], + "payment_record": null, + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_action", + "transfer_data": null, + "transfer_group": null +} diff --git a/tests/fixtures/stripe/payment_intents/disputed.json b/tests/fixtures/stripe/payment_intents/disputed.json new file mode 100644 index 0000000..92079d9 --- /dev/null +++ b/tests/fixtures/stripe/payment_intents/disputed.json @@ -0,0 +1,207 @@ +{ + "id": "pi_3UBHU5Pjx0CusuMr1cNe2BIo", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 12345, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHU5Pjx0CusuMr1cNe2BIo_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368281, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": { + "id": "ch_3UBHU5Pjx0CusuMr1q2mLg5W", + "object": "charge", + "amount": 12345, + "amount_captured": 12345, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": { + "id": "txn_3UBHU5Pjx0CusuMr1yiBKYnC", + "object": "balance_transaction", + "amount": 12345, + "available_on": 1788912000, + "balance_type": "payments", + "created": 1788368283, + "currency": "brl", + "description": "Payment for Invoice", + "exchange_rate": null, + "fee": 520, + "fee_details": [ + { + "amount": 520, + "application": null, + "currency": "brl", + "description": "Stripe processing fees", + "type": "stripe_fee" + } + ], + "net": 11825, + "reporting_category": "charge", + "source": "ch_3UBHU5Pjx0CusuMr1q2mLg5W", + "status": "pending", + "type": "charge" + }, + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "calculated_statement_descriptor": "ESCAVADOR", + "captured": true, + "created": 1788368283, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "description": "Payment for Invoice", + "destination": null, + "dispute": "du_1UBHU8Pjx0CusuMr3hVB1hzt", + "disputed": true, + "failure_balance_transaction": null, + "failure_code": null, + "failure_message": null, + "fraud_details": [], + "livemode": false, + "metadata": [], + "on_behalf_of": null, + "order": null, + "outcome": { + "advice_code": null, + "network_advice_code": null, + "network_decline_code": null, + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 39, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": "pi_3UBHU5Pjx0CusuMr1cNe2BIo", + "payment_method": "pm_1UBHU6Pjx0CusuMrlJ3aMuAK", + "payment_method_details": { + "card": { + "amount_authorized": 12345, + "authorization_code": "074695", + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 9, + "exp_year": 2027, + "extended_authorization": { + "status": "disabled" + }, + "fingerprint": "Da65OXs5lv5nviqQ", + "funding": "credit", + "incremental_authorization": { + "status": "unavailable" + }, + "installments": null, + "last4": "0259", + "mandate": null, + "multicapture": { + "status": "unavailable" + }, + "network": "visa", + "network_token": { + "used": false + }, + "network_transaction_id": "689754537988115", + "overcapture": { + "maximum_amount_capturable": 12345, + "status": "unavailable" + }, + "regulated_status": "unregulated", + "three_d_secure": null, + "transaction_link_id": null, + "wallet": null + }, + "type": "card" + }, + "radar_options": [], + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/invoices/CAcaFwoVYWNjdF8xVHpLa1RQangwQ3VzdU1yKJ6r4dQGMga9wj0Esb86LBYxivT4OZHNfyO80HaHx5vdfQq19mo0-CYfIav6s49SDxNTn0r8JkNZ-gfh?s=ap", + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_3UBHU5Pjx0CusuMr1q2mLg5W/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + }, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHU4Pjx0CusuMrOTB0POaR" + }, + "payment_method": "pm_1UBHU6Pjx0CusuMrlJ3aMuAK", + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null +} diff --git a/tests/fixtures/stripe/payment_intents/paid.json b/tests/fixtures/stripe/payment_intents/paid.json new file mode 100644 index 0000000..8eaf024 --- /dev/null +++ b/tests/fixtures/stripe/payment_intents/paid.json @@ -0,0 +1,207 @@ +{ + "id": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 12345, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHTpPjx0CusuMr1JTEiHGi_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368265, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": { + "id": "ch_3UBHTpPjx0CusuMr1Grz2MK4", + "object": "charge", + "amount": 12345, + "amount_captured": 12345, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": { + "id": "txn_3UBHTpPjx0CusuMr1GJ9M7S6", + "object": "balance_transaction", + "amount": 12345, + "available_on": 1788912000, + "balance_type": "payments", + "created": 1788368273, + "currency": "brl", + "description": "Payment for Invoice", + "exchange_rate": null, + "fee": 520, + "fee_details": [ + { + "amount": 520, + "application": null, + "currency": "brl", + "description": "Stripe processing fees", + "type": "stripe_fee" + } + ], + "net": 11825, + "reporting_category": "charge", + "source": "ch_3UBHTpPjx0CusuMr1Grz2MK4", + "status": "pending", + "type": "charge" + }, + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "calculated_statement_descriptor": "ESCAVADOR", + "captured": true, + "created": 1788368273, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "description": "Payment for Invoice", + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": null, + "failure_message": null, + "fraud_details": [], + "livemode": false, + "metadata": [], + "on_behalf_of": null, + "order": null, + "outcome": { + "advice_code": null, + "network_advice_code": null, + "network_decline_code": null, + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 37, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "payment_method": "pm_1UBHTwPjx0CusuMrTo9mvfUx", + "payment_method_details": { + "card": { + "amount_authorized": 12345, + "authorization_code": "154318", + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 9, + "exp_year": 2027, + "extended_authorization": { + "status": "disabled" + }, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "incremental_authorization": { + "status": "unavailable" + }, + "installments": null, + "last4": "4242", + "mandate": null, + "multicapture": { + "status": "unavailable" + }, + "network": "visa", + "network_token": { + "used": false + }, + "network_transaction_id": "666611471566978", + "overcapture": { + "maximum_amount_capturable": 12345, + "status": "unavailable" + }, + "regulated_status": "unregulated", + "three_d_secure": null, + "transaction_link_id": null, + "wallet": null + }, + "type": "card" + }, + "radar_options": [], + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/invoices/CAcaFwoVYWNjdF8xVHpLa1RQangwQ3VzdU1yKJOr4dQGMgb970rw0jA6LBY8dgTEI9dke67Ta1MOOepBietwOe6MF35SjmzCBteD7Dt8a7-NvpVej--m?s=ap", + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_3UBHTpPjx0CusuMr1Grz2MK4/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + }, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHTnPjx0CusuMrjxjg8WhK" + }, + "payment_method": "pm_1UBHTwPjx0CusuMrTo9mvfUx", + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null +} diff --git a/tests/fixtures/stripe/payment_intents/partially_refunded.json b/tests/fixtures/stripe/payment_intents/partially_refunded.json new file mode 100644 index 0000000..f3a5092 --- /dev/null +++ b/tests/fixtures/stripe/payment_intents/partially_refunded.json @@ -0,0 +1,236 @@ +{ + "id": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 12345, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHTpPjx0CusuMr1JTEiHGi_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368265, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": { + "id": "ch_3UBHTpPjx0CusuMr1Grz2MK4", + "object": "charge", + "amount": 12345, + "amount_captured": 12345, + "amount_refunded": 2345, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": { + "id": "txn_3UBHTpPjx0CusuMr1GJ9M7S6", + "object": "balance_transaction", + "amount": 12345, + "available_on": 1788912000, + "balance_type": "payments", + "created": 1788368273, + "currency": "brl", + "description": "Payment for Invoice", + "exchange_rate": null, + "fee": 520, + "fee_details": [ + { + "amount": 520, + "application": null, + "currency": "brl", + "description": "Stripe processing fees", + "type": "stripe_fee" + } + ], + "net": 11825, + "reporting_category": "charge", + "source": "ch_3UBHTpPjx0CusuMr1Grz2MK4", + "status": "pending", + "type": "charge" + }, + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "calculated_statement_descriptor": "ESCAVADOR", + "captured": true, + "created": 1788368273, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "description": "Payment for Invoice", + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": null, + "failure_message": null, + "fraud_details": [], + "livemode": false, + "metadata": [], + "on_behalf_of": null, + "order": null, + "outcome": { + "advice_code": null, + "network_advice_code": null, + "network_decline_code": null, + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 37, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "payment_method": "pm_1UBHTwPjx0CusuMrTo9mvfUx", + "payment_method_details": { + "card": { + "amount_authorized": 12345, + "authorization_code": "154318", + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 9, + "exp_year": 2027, + "extended_authorization": { + "status": "disabled" + }, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "incremental_authorization": { + "status": "unavailable" + }, + "installments": null, + "last4": "4242", + "mandate": null, + "multicapture": { + "status": "unavailable" + }, + "network": "visa", + "network_token": { + "used": false + }, + "network_transaction_id": "666611471566978", + "overcapture": { + "maximum_amount_capturable": 12345, + "status": "unavailable" + }, + "regulated_status": "unregulated", + "three_d_secure": null, + "transaction_link_id": null, + "wallet": null + }, + "type": "card" + }, + "radar_options": [], + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/invoices/CAcaFwoVYWNjdF8xVHpLa1RQangwQ3VzdU1yKJar4dQGMgYjxOjKU1s6LBYM7nuexXpIYp3K07uo_IvpOwEXkO-kwFO9YQ7Z9xEFrgu8z_vqzperYXQC?s=ap", + "refunded": false, + "refunds": { + "object": "list", + "data": [ + { + "id": "re_3UBHTpPjx0CusuMr17mvyNgb", + "object": "refund", + "amount": 2345, + "balance_transaction": "txn_3UBHTpPjx0CusuMr1Z5mxfYY", + "charge": "ch_3UBHTpPjx0CusuMr1Grz2MK4", + "created": 1788368276, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "destination_details": { + "card": { + "reference": "1007725422320003", + "reference_status": "available", + "reference_type": "acquirer_reference_number", + "type": "refund" + }, + "type": "card" + }, + "metadata": [], + "payment_intent": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "payment_method": "pm_1UBHTwPjx0CusuMrTo9mvfUx", + "reason": null, + "receipt_number": null, + "source_transfer_reversal": null, + "status": "succeeded", + "transfer_reversal": null + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/charges/ch_3UBHTpPjx0CusuMr1Grz2MK4/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + }, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHTnPjx0CusuMrjxjg8WhK" + }, + "payment_method": "pm_1UBHTwPjx0CusuMrTo9mvfUx", + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null +} diff --git a/tests/fixtures/stripe/payment_intents/refunded.json b/tests/fixtures/stripe/payment_intents/refunded.json new file mode 100644 index 0000000..22ea889 --- /dev/null +++ b/tests/fixtures/stripe/payment_intents/refunded.json @@ -0,0 +1,264 @@ +{ + "id": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 12345, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHTpPjx0CusuMr1JTEiHGi_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368265, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": { + "id": "ch_3UBHTpPjx0CusuMr1Grz2MK4", + "object": "charge", + "amount": 12345, + "amount_captured": 12345, + "amount_refunded": 12345, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": { + "id": "txn_3UBHTpPjx0CusuMr1GJ9M7S6", + "object": "balance_transaction", + "amount": 12345, + "available_on": 1788912000, + "balance_type": "payments", + "created": 1788368273, + "currency": "brl", + "description": "Payment for Invoice", + "exchange_rate": null, + "fee": 520, + "fee_details": [ + { + "amount": 520, + "application": null, + "currency": "brl", + "description": "Stripe processing fees", + "type": "stripe_fee" + } + ], + "net": 11825, + "reporting_category": "charge", + "source": "ch_3UBHTpPjx0CusuMr1Grz2MK4", + "status": "pending", + "type": "charge" + }, + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "calculated_statement_descriptor": "ESCAVADOR", + "captured": true, + "created": 1788368273, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "description": "Payment for Invoice", + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": null, + "failure_message": null, + "fraud_details": [], + "livemode": false, + "metadata": [], + "on_behalf_of": null, + "order": null, + "outcome": { + "advice_code": null, + "network_advice_code": null, + "network_decline_code": null, + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 37, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "payment_method": "pm_1UBHTwPjx0CusuMrTo9mvfUx", + "payment_method_details": { + "card": { + "amount_authorized": 12345, + "authorization_code": "154318", + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 9, + "exp_year": 2027, + "extended_authorization": { + "status": "disabled" + }, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "incremental_authorization": { + "status": "unavailable" + }, + "installments": null, + "last4": "4242", + "mandate": null, + "multicapture": { + "status": "unavailable" + }, + "network": "visa", + "network_token": { + "used": false + }, + "network_transaction_id": "666611471566978", + "overcapture": { + "maximum_amount_capturable": 12345, + "status": "unavailable" + }, + "regulated_status": "unregulated", + "three_d_secure": null, + "transaction_link_id": null, + "wallet": null + }, + "type": "card" + }, + "radar_options": [], + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/invoices/CAcaFwoVYWNjdF8xVHpLa1RQangwQ3VzdU1yKJer4dQGMgZFicsMwqw6LBbR8m3PpRYxpnupZ9qHkSmG83n4LuAhmnEIO9EF0IwUxQQfkTBNSjyQfULV?s=ap", + "refunded": true, + "refunds": { + "object": "list", + "data": [ + { + "id": "re_3UBHTpPjx0CusuMr1X9KHYad", + "object": "refund", + "amount": 10000, + "balance_transaction": "txn_3UBHTpPjx0CusuMr1D0cp94k", + "charge": "ch_3UBHTpPjx0CusuMr1Grz2MK4", + "created": 1788368278, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "destination_details": { + "card": { + "reference": "3456132009380231", + "reference_status": "available", + "reference_type": "acquirer_reference_number", + "type": "refund" + }, + "type": "card" + }, + "metadata": [], + "payment_intent": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "payment_method": "pm_1UBHTwPjx0CusuMrTo9mvfUx", + "reason": null, + "receipt_number": null, + "source_transfer_reversal": null, + "status": "succeeded", + "transfer_reversal": null + }, + { + "id": "re_3UBHTpPjx0CusuMr17mvyNgb", + "object": "refund", + "amount": 2345, + "balance_transaction": "txn_3UBHTpPjx0CusuMr1Z5mxfYY", + "charge": "ch_3UBHTpPjx0CusuMr1Grz2MK4", + "created": 1788368276, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "destination_details": { + "card": { + "reference": "1007725422320003", + "reference_status": "available", + "reference_type": "acquirer_reference_number", + "type": "refund" + }, + "type": "card" + }, + "metadata": [], + "payment_intent": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "payment_method": "pm_1UBHTwPjx0CusuMrTo9mvfUx", + "reason": null, + "receipt_number": null, + "source_transfer_reversal": null, + "status": "succeeded", + "transfer_reversal": null + } + ], + "has_more": false, + "total_count": 2, + "url": "/v1/charges/ch_3UBHTpPjx0CusuMr1Grz2MK4/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + }, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHTnPjx0CusuMrjxjg8WhK" + }, + "payment_method": "pm_1UBHTwPjx0CusuMrTo9mvfUx", + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null +} diff --git a/tests/fixtures/stripe/payment_intents/requires_action.json b/tests/fixtures/stripe/payment_intents/requires_action.json new file mode 100644 index 0000000..ea8a833 --- /dev/null +++ b/tests/fixtures/stripe/payment_intents/requires_action.json @@ -0,0 +1,250 @@ +{ + "id": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 12345, + "amount_capturable": 0, + "amount_details": { + "tip": [] + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UBHTpPjx0CusuMr1JTEiHGi_secret_REDACTED", + "confirmation_method": "automatic", + "created": 1788368265, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "description": "Payment for Invoice", + "excluded_payment_method_types": null, + "last_payment_error": { + "advice_code": "try_again_later", + "charge": "ch_3UBHTpPjx0CusuMr1KQeiDo0", + "code": "card_declined", + "decline_code": "generic_decline", + "doc_url": "https://stripe.com/docs/error-codes/card-declined", + "message": "Your card was declined.", + "network_decline_code": "01", + "payment_method": { + "id": "pm_1UBHTrPjx0CusuMrw9Pshrt0", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "sryrMZCxUxxxIzqq", + "funding": "credit", + "generated_from": null, + "last4": "0341", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788368267, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": [], + "shared_payment_granted_token": null, + "type": "card" + }, + "type": "card_error" + }, + "latest_charge": { + "id": "ch_3UBHTpPjx0CusuMr1KQeiDo0", + "object": "charge", + "amount": 12345, + "amount_captured": 0, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": null, + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "calculated_statement_descriptor": "ESCAVADOR", + "captured": false, + "created": 1788368268, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "description": "Payment for Invoice", + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": "card_declined", + "failure_message": "Your card was declined.", + "fraud_details": [], + "livemode": false, + "metadata": [], + "on_behalf_of": null, + "order": null, + "outcome": { + "advice_code": "try_again_later", + "network_advice_code": null, + "network_decline_code": "01", + "network_status": "declined_by_network", + "reason": "generic_decline", + "risk_level": "normal", + "risk_score": 36, + "seller_message": "The bank did not return any further details with this decline.", + "type": "issuer_declined" + }, + "paid": false, + "payment_intent": "pi_3UBHTpPjx0CusuMr1JTEiHGi", + "payment_method": "pm_1UBHTrPjx0CusuMrw9Pshrt0", + "payment_method_details": { + "card": { + "amount_authorized": null, + "authorization_code": "319799", + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 9, + "exp_year": 2027, + "extended_authorization": { + "status": "disabled" + }, + "fingerprint": "sryrMZCxUxxxIzqq", + "funding": "credit", + "incremental_authorization": { + "status": "unavailable" + }, + "installments": null, + "last4": "0341", + "mandate": null, + "multicapture": { + "status": "unavailable" + }, + "network": "visa", + "network_token": { + "used": false + }, + "network_transaction_id": "115114121114779", + "overcapture": { + "maximum_amount_capturable": 12345, + "status": "unavailable" + }, + "regulated_status": "unregulated", + "three_d_secure": null, + "transaction_link_id": null, + "wallet": null + }, + "type": "card" + }, + "radar_options": [], + "receipt_email": null, + "receipt_number": null, + "receipt_url": null, + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_3UBHTpPjx0CusuMr1KQeiDo0/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "failed", + "transfer_data": null, + "transfer_group": null + }, + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": [], + "next_action": { + "type": "use_stripe_sdk", + "use_stripe_sdk": { + "type": "three_d_secure_redirect", + "stripe_js": "https://hooks.stripe.com/redirect/authenticate/src_REDACTED" + } + }, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UBHTnPjx0CusuMrjxjg8WhK" + }, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_action", + "transfer_data": null, + "transfer_group": null +} diff --git a/tests/fixtures/stripe/setup_intents/card_declined.json b/tests/fixtures/stripe/setup_intents/card_declined.json new file mode 100644 index 0000000..bcb529c --- /dev/null +++ b/tests/fixtures/stripe/setup_intents/card_declined.json @@ -0,0 +1,165 @@ +{ + "error": { + "advice_code": "try_again_later", + "code": "card_declined", + "decline_code": "generic_decline", + "doc_url": "https://stripe.com/docs/error-codes/card-declined", + "message": "Your card was declined.", + "network_decline_code": "01", + "payment_method": { + "id": "pm_1UBMVkPjx0CusuMrbXKNAWgd", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "mO7AbKfepAraPY63", + "funding": "credit", + "generated_from": null, + "last4": "0002", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788387604, + "customer": null, + "customer_account": null, + "livemode": false, + "metadata": [], + "shared_payment_granted_token": null, + "type": "card" + }, + "setup_intent": { + "id": "seti_1UBMVkPjx0CusuMrFSlrDDSN", + "object": "setup_intent", + "allowed_payment_method_types": null, + "application": null, + "automatic_payment_methods": null, + "cancellation_reason": null, + "client_secret": "seti_1UBMVkPjx0CusuMrFSlrDDSN_secret_PLACEHOLDER", + "created": 1788387604, + "customer": "cus_VBjzroZKS8d5LY", + "customer_account": null, + "description": null, + "excluded_payment_method_types": null, + "flow_directions": null, + "last_setup_error": { + "advice_code": "try_again_later", + "code": "card_declined", + "decline_code": "generic_decline", + "doc_url": "https://stripe.com/docs/error-codes/card-declined", + "message": "Your card was declined.", + "network_decline_code": "01", + "payment_method": { + "id": "pm_1UBMVkPjx0CusuMrbXKNAWgd", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "mO7AbKfepAraPY63", + "funding": "credit", + "generated_from": null, + "last4": "0002", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788387604, + "customer": null, + "customer_account": null, + "livemode": false, + "metadata": [], + "shared_payment_granted_token": null, + "type": "card" + }, + "type": "card_error" + }, + "latest_attempt": "setatt_1UBMVkPjx0CusuMrpt6Apm5Y", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "mandate": null, + "metadata": [], + "next_action": null, + "on_behalf_of": null, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "single_use_mandate": null, + "status": "requires_payment_method", + "usage": "off_session" + }, + "type": "card_error" + } +} diff --git a/tests/fixtures/stripe/setup_intents/requires_action.json b/tests/fixtures/stripe/setup_intents/requires_action.json new file mode 100644 index 0000000..ab5c538 --- /dev/null +++ b/tests/fixtures/stripe/setup_intents/requires_action.json @@ -0,0 +1,104 @@ +{ + "id": "seti_1UBMViPjx0CusuMrBNDQEzqv", + "object": "setup_intent", + "allowed_payment_method_types": null, + "application": null, + "automatic_payment_methods": null, + "cancellation_reason": null, + "client_secret": "seti_1UBMViPjx0CusuMrBNDQEzqv_secret_PLACEHOLDER", + "created": 1788387602, + "customer": "cus_VBjzroZKS8d5LY", + "customer_account": null, + "description": null, + "excluded_payment_method_types": null, + "flow_directions": null, + "last_setup_error": null, + "latest_attempt": "setatt_1UBMViPjx0CusuMrZ5hah14v", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "mandate": null, + "metadata": { + "description": "cartão 3ds" + }, + "next_action": { + "type": "use_stripe_sdk", + "use_stripe_sdk": { + "type": "stripe_3ds2_fingerprint", + "directory_server_name": "visa", + "merchant": "acct_1TzKkTPjx0CusuMr", + "server_transaction_id": "356f0f99-bc7f-4969-b013-6c23de73a65d", + "three_d_secure_2_source": "setatt_1UBMViPjx0CusuMrZ5hah14v", + "three_ds_method_url": "" + } + }, + "on_behalf_of": null, + "payment_method": { + "id": "pm_1UBMViPjx0CusuMrMQHrkUVI", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "unchecked" + }, + "country": "DE", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "qePYzmiPHESInjMG", + "funding": "credit", + "generated_from": null, + "last4": "3184", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788387602, + "customer": null, + "customer_account": null, + "livemode": false, + "metadata": [], + "shared_payment_granted_token": null, + "type": "card" + }, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "single_use_mandate": null, + "status": "requires_action", + "usage": "off_session" +} diff --git a/tests/fixtures/stripe/setup_intents/requires_action_redirect.json b/tests/fixtures/stripe/setup_intents/requires_action_redirect.json new file mode 100644 index 0000000..b6ee21f --- /dev/null +++ b/tests/fixtures/stripe/setup_intents/requires_action_redirect.json @@ -0,0 +1,98 @@ +{ + "id": "seti_1UBMVjPjx0CusuMr53vgIbuw", + "object": "setup_intent", + "allowed_payment_method_types": null, + "application": null, + "automatic_payment_methods": null, + "cancellation_reason": null, + "client_secret": "seti_1UBMVjPjx0CusuMr53vgIbuw_secret_PLACEHOLDER", + "created": 1788387603, + "customer": "cus_VBjzroZKS8d5LY", + "customer_account": null, + "description": null, + "excluded_payment_method_types": null, + "flow_directions": null, + "last_setup_error": null, + "latest_attempt": "setatt_1UBMVjPjx0CusuMrI3AVs7Hx", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "mandate": null, + "metadata": [], + "next_action": { + "redirect_to_url": { + "return_url": "https://exemplo.com/retorno", + "url": "https://hooks.stripe.com/3d_secure_2/hosted?merchant=acct_1TzKkTPjx0CusuMr&publishable_key=pk_test_PLACEHOLDER&setup_intent=seti_1UBMVjPjx0CusuMr53vgIbuw&setup_intent_client_secret=seti_1UBMVjPjx0CusuMr53vgIbuw_secret_PLACEHOLDER&source=setatt_1UBMVjPjx0CusuMrI3AVs7Hx" + }, + "type": "redirect_to_url" + }, + "on_behalf_of": null, + "payment_method": { + "id": "pm_1UBMVjPjx0CusuMr6jGTU0hn", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "unchecked" + }, + "country": "DE", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "qePYzmiPHESInjMG", + "funding": "credit", + "generated_from": null, + "last4": "3184", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788387603, + "customer": null, + "customer_account": null, + "livemode": false, + "metadata": [], + "shared_payment_granted_token": null, + "type": "card" + }, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "single_use_mandate": null, + "status": "requires_action", + "usage": "off_session" +} diff --git a/tests/fixtures/stripe/setup_intents/succeeded.json b/tests/fixtures/stripe/setup_intents/succeeded.json new file mode 100644 index 0000000..3f8fb0c --- /dev/null +++ b/tests/fixtures/stripe/setup_intents/succeeded.json @@ -0,0 +1,95 @@ +{ + "id": "seti_1UBMVgPjx0CusuMrtgTZPbBG", + "object": "setup_intent", + "allowed_payment_method_types": null, + "application": null, + "automatic_payment_methods": null, + "cancellation_reason": null, + "client_secret": "seti_1UBMVgPjx0CusuMrtgTZPbBG_secret_PLACEHOLDER", + "created": 1788387600, + "customer": "cus_VBjzroZKS8d5LY", + "customer_account": null, + "description": null, + "excluded_payment_method_types": null, + "flow_directions": null, + "last_setup_error": null, + "latest_attempt": "setatt_1UBMVgPjx0CusuMry4kdz5w8", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "mandate": null, + "metadata": { + "description": "cartão de teste", + "set_as_default": "1" + }, + "next_action": null, + "on_behalf_of": null, + "payment_method": { + "id": "pm_1UBMVgPjx0CusuMrtGi8Aruq", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788387600, + "customer": "cus_VBjzroZKS8d5LY", + "customer_account": null, + "livemode": false, + "metadata": [], + "shared_payment_granted_token": null, + "type": "card" + }, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "single_use_mandate": null, + "status": "succeeded", + "usage": "off_session" +} diff --git a/tests/fixtures/stripe/subscriptions/active.json b/tests/fixtures/stripe/subscriptions/active.json new file mode 100644 index 0000000..400a3d8 --- /dev/null +++ b/tests/fixtures/stripe/subscriptions/active.json @@ -0,0 +1,252 @@ +{ + "id": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788565981, + "billing_cycle_anchor_config": null, + "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, + "type": "flexible", + "updated_at": 1788565981 + }, + "billing_schedules": [], + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "feedback_option": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1788565981, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "days_until_due": null, + "default_payment_method": { + "id": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788565979, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": {}, + "shared_payment_granted_token": null, + "type": "card" + }, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788565981, + "current_period_end": 1791157981, + "current_period_start": 1788565981, + "current_trial": null, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "plano_mensal", + "metadata": {}, + "nickname": "Plano Mensal", + "product": { + "id": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "object": "product", + "active": true, + "attributes": [], + "created": 1788565977, + "default_price": null, + "description": null, + "images": [], + "livemode": false, + "marketing_features": [], + "metadata": { + "identifier": "plano_mensal" + }, + "name": "Plano Mensal", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "tax_code": null, + "tax_details": null, + "type": "service", + "unit_label": null, + "updated": 1788565977, + "url": null + }, + "recurring": { + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 10000, + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "subscription": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UBJmkPjx0CusuMr3KQ2wXyZ" + }, + "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": [ + "card" + ], + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1788565981, + "status": "active", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "billing_cycle_anchor": null, + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null +} diff --git a/tests/fixtures/stripe/subscriptions/active_automatic_pix.json b/tests/fixtures/stripe/subscriptions/active_automatic_pix.json new file mode 100644 index 0000000..5adb988 --- /dev/null +++ b/tests/fixtures/stripe/subscriptions/active_automatic_pix.json @@ -0,0 +1,238 @@ +{ + "id": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788565981, + "billing_cycle_anchor_config": null, + "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, + "type": "flexible", + "updated_at": 1788565981 + }, + "billing_schedules": [], + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "feedback_option": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1788565981, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "days_until_due": null, + "default_payment_method": { + "id": "pm_1UBJmjPjx0CusuMrPixMand1", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "created": 1788565979, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": {}, + "pix": {}, + "shared_payment_granted_token": null, + "type": "pix" + }, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788565981, + "current_period_end": 1791157981, + "current_period_start": 1788565981, + "current_trial": null, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "plano_mensal", + "metadata": {}, + "nickname": "Plano Mensal", + "product": { + "id": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "object": "product", + "active": true, + "attributes": [], + "created": 1788565977, + "default_price": null, + "description": null, + "images": [], + "livemode": false, + "marketing_features": [], + "metadata": { + "identifier": "plano_mensal" + }, + "name": "Plano Mensal", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "tax_code": null, + "tax_details": null, + "type": "service", + "unit_label": null, + "updated": 1788565977, + "url": null + }, + "recurring": { + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 10000, + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "subscription": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UBJmkPjx0CusuMr3KQ2wXyZ" + }, + "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": { + "pix": { + "mandate_options": { + "amount": 10000, + "amount_includes_iof": "never", + "amount_type": "fixed", + "end_date": null, + "payment_schedule": "monthly", + "reference": "Empresa Exemplo", + "start_date": 1788825600 + } + } + }, + "payment_method_types": [ + "pix" + ], + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1788565981, + "status": "active", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "billing_cycle_anchor": null, + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null +} diff --git a/tests/fixtures/stripe/subscriptions/active_cancel_at_period_end.json b/tests/fixtures/stripe/subscriptions/active_cancel_at_period_end.json new file mode 100644 index 0000000..16a8b2a --- /dev/null +++ b/tests/fixtures/stripe/subscriptions/active_cancel_at_period_end.json @@ -0,0 +1,252 @@ +{ + "id": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788565981, + "billing_cycle_anchor_config": null, + "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, + "type": "flexible", + "updated_at": 1788565981 + }, + "billing_schedules": [], + "billing_thresholds": null, + "cancel_at": 1820101981, + "cancel_at_period_end": true, + "canceled_at": 1788565991, + "cancellation_details": { + "comment": null, + "feedback": null, + "feedback_option": null, + "reason": "cancellation_requested" + }, + "collection_method": "charge_automatically", + "created": 1788565981, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "days_until_due": null, + "default_payment_method": { + "id": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788565979, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": {}, + "shared_payment_granted_token": null, + "type": "card" + }, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788565981, + "current_period_end": 1820101981, + "current_period_start": 1788565981, + "current_trial": null, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk2An", + "object": "plan", + "active": true, + "amount": 90000, + "amount_decimal": "90000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "year", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Anual", + "product": "prod_UBJmiPjx0CusuMr7Wq3An2", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk2An", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "plano_anual", + "metadata": {}, + "nickname": "Plano Anual", + "product": { + "id": "prod_UBJmiPjx0CusuMr7Wq3An2", + "object": "product", + "active": true, + "attributes": [], + "created": 1788565978, + "default_price": null, + "description": null, + "images": [], + "livemode": false, + "marketing_features": [], + "metadata": { + "identifier": "plano_anual" + }, + "name": "Plano Anual", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "tax_code": null, + "tax_details": null, + "type": "service", + "unit_label": null, + "updated": 1788565978, + "url": null + }, + "recurring": { + "interval": "year", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 90000, + "unit_amount_decimal": "90000" + }, + "quantity": 1, + "subscription": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UBJmkPjx0CusuMr3KQ2wXyZ" + }, + "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": [ + "card" + ], + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk2An", + "object": "plan", + "active": true, + "amount": 90000, + "amount_decimal": "90000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "year", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Anual", + "product": "prod_UBJmiPjx0CusuMr7Wq3An2", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1788565981, + "status": "active", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "billing_cycle_anchor": null, + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null +} diff --git a/tests/fixtures/stripe/subscriptions/active_pause_collection.json b/tests/fixtures/stripe/subscriptions/active_pause_collection.json new file mode 100644 index 0000000..65cbfed --- /dev/null +++ b/tests/fixtures/stripe/subscriptions/active_pause_collection.json @@ -0,0 +1,255 @@ +{ + "id": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788565981, + "billing_cycle_anchor_config": null, + "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, + "type": "flexible", + "updated_at": 1788565981 + }, + "billing_schedules": [], + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "feedback_option": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1788565981, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "days_until_due": null, + "default_payment_method": { + "id": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788565979, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": {}, + "shared_payment_granted_token": null, + "type": "card" + }, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788565981, + "current_period_end": 1820101981, + "current_period_start": 1788565981, + "current_trial": null, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk2An", + "object": "plan", + "active": true, + "amount": 90000, + "amount_decimal": "90000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "year", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Anual", + "product": "prod_UBJmiPjx0CusuMr7Wq3An2", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk2An", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "plano_anual", + "metadata": {}, + "nickname": "Plano Anual", + "product": { + "id": "prod_UBJmiPjx0CusuMr7Wq3An2", + "object": "product", + "active": true, + "attributes": [], + "created": 1788565978, + "default_price": null, + "description": null, + "images": [], + "livemode": false, + "marketing_features": [], + "metadata": { + "identifier": "plano_anual" + }, + "name": "Plano Anual", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "tax_code": null, + "tax_details": null, + "type": "service", + "unit_label": null, + "updated": 1788565978, + "url": null + }, + "recurring": { + "interval": "year", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 90000, + "unit_amount_decimal": "90000" + }, + "quantity": 1, + "subscription": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UBJmkPjx0CusuMr3KQ2wXyZ" + }, + "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": { + "behavior": "void", + "resumes_at": null + }, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": [ + "card" + ], + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk2An", + "object": "plan", + "active": true, + "amount": 90000, + "amount_decimal": "90000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "year", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Anual", + "product": "prod_UBJmiPjx0CusuMr7Wq3An2", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1788565981, + "status": "active", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "billing_cycle_anchor": null, + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null +} diff --git a/tests/fixtures/stripe/subscriptions/active_send_invoice_boleto.json b/tests/fixtures/stripe/subscriptions/active_send_invoice_boleto.json new file mode 100644 index 0000000..6f2f2f9 --- /dev/null +++ b/tests/fixtures/stripe/subscriptions/active_send_invoice_boleto.json @@ -0,0 +1,198 @@ +{ + "id": "sub_1UC8LxPjx0CusuMrKfvHqdWA", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788571509, + "billing_cycle_anchor_config": null, + "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, + "type": "flexible", + "updated_at": 1788571509 + }, + "billing_schedules": [], + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "feedback_option": null, + "reason": null + }, + "collection_method": "send_invoice", + "created": 1788571509, + "currency": "brl", + "customer": "cus_VCXQBStDgeEid0", + "customer_account": null, + "days_until_due": 3, + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_VCXQ1RlvYb1XiI", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788571510, + "current_period_end": 1791163509, + "current_period_start": 1788571509, + "current_trial": null, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UC8LwPjx0CusuMrr3Vq7Hpk", + "object": "plan", + "active": true, + "amount": 12345, + "amount_decimal": "12345", + "billing_scheme": "per_unit", + "created": 1788571508, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": null, + "product": "prod_VCXQ5X2E8yDpUz", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UC8LwPjx0CusuMrr3Vq7Hpk", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788571508, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": null, + "metadata": {}, + "nickname": null, + "product": { + "id": "prod_VCXQ5X2E8yDpUz", + "object": "product", + "active": true, + "attributes": [], + "created": 1788571508, + "default_price": "price_1UC8LwPjx0CusuMrr3Vq7Hpk", + "description": null, + "images": [], + "livemode": false, + "marketing_features": [], + "metadata": {}, + "name": "Plano boleto lote3", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "tax_code": null, + "tax_details": null, + "type": "service", + "unit_label": null, + "updated": 1788571508, + "url": null + }, + "recurring": { + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 12345, + "unit_amount_decimal": "12345" + }, + "quantity": 1, + "subscription": "sub_1UC8LxPjx0CusuMrKfvHqdWA", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UC8LxPjx0CusuMrKfvHqdWA" + }, + "latest_invoice": "in_1UC8LxPjx0CusuMr8L1JgWdN", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": [ + "boleto" + ], + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1UC8LwPjx0CusuMrr3Vq7Hpk", + "object": "plan", + "active": true, + "amount": 12345, + "amount_decimal": "12345", + "billing_scheme": "per_unit", + "created": 1788571508, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": null, + "product": "prod_VCXQ5X2E8yDpUz", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1788571509, + "status": "active", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "billing_cycle_anchor": null, + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null +} diff --git a/tests/fixtures/stripe/subscriptions/canceled.json b/tests/fixtures/stripe/subscriptions/canceled.json new file mode 100644 index 0000000..c66658a --- /dev/null +++ b/tests/fixtures/stripe/subscriptions/canceled.json @@ -0,0 +1,252 @@ +{ + "id": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788565981, + "billing_cycle_anchor_config": null, + "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, + "type": "flexible", + "updated_at": 1788565981 + }, + "billing_schedules": [], + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": 1788565992, + "cancellation_details": { + "comment": null, + "feedback": null, + "feedback_option": null, + "reason": "cancellation_requested" + }, + "collection_method": "charge_automatically", + "created": 1788565981, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "days_until_due": null, + "default_payment_method": { + "id": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788565979, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": {}, + "shared_payment_granted_token": null, + "type": "card" + }, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": 1788565992, + "invoice_settings": { + "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788565981, + "current_period_end": 1820101981, + "current_period_start": 1788565981, + "current_trial": null, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk2An", + "object": "plan", + "active": true, + "amount": 90000, + "amount_decimal": "90000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "year", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Anual", + "product": "prod_UBJmiPjx0CusuMr7Wq3An2", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk2An", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "plano_anual", + "metadata": {}, + "nickname": "Plano Anual", + "product": { + "id": "prod_UBJmiPjx0CusuMr7Wq3An2", + "object": "product", + "active": true, + "attributes": [], + "created": 1788565978, + "default_price": null, + "description": null, + "images": [], + "livemode": false, + "marketing_features": [], + "metadata": { + "identifier": "plano_anual" + }, + "name": "Plano Anual", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "tax_code": null, + "tax_details": null, + "type": "service", + "unit_label": null, + "updated": 1788565978, + "url": null + }, + "recurring": { + "interval": "year", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 90000, + "unit_amount_decimal": "90000" + }, + "quantity": 1, + "subscription": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UBJmkPjx0CusuMr3KQ2wXyZ" + }, + "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": [ + "card" + ], + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk2An", + "object": "plan", + "active": true, + "amount": 90000, + "amount_decimal": "90000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "year", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Anual", + "product": "prod_UBJmiPjx0CusuMr7Wq3An2", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1788565981, + "status": "canceled", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "billing_cycle_anchor": null, + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null +} diff --git a/tests/fixtures/stripe/subscriptions/incomplete.json b/tests/fixtures/stripe/subscriptions/incomplete.json new file mode 100644 index 0000000..e842c4d --- /dev/null +++ b/tests/fixtures/stripe/subscriptions/incomplete.json @@ -0,0 +1,252 @@ +{ + "id": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788565981, + "billing_cycle_anchor_config": null, + "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, + "type": "flexible", + "updated_at": 1788565981 + }, + "billing_schedules": [], + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "feedback_option": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1788565981, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "days_until_due": null, + "default_payment_method": { + "id": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788565979, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": {}, + "shared_payment_granted_token": null, + "type": "card" + }, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788565981, + "current_period_end": 1791157981, + "current_period_start": 1788565981, + "current_trial": null, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "plano_mensal", + "metadata": {}, + "nickname": "Plano Mensal", + "product": { + "id": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "object": "product", + "active": true, + "attributes": [], + "created": 1788565977, + "default_price": null, + "description": null, + "images": [], + "livemode": false, + "marketing_features": [], + "metadata": { + "identifier": "plano_mensal" + }, + "name": "Plano Mensal", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "tax_code": null, + "tax_details": null, + "type": "service", + "unit_label": null, + "updated": 1788565977, + "url": null + }, + "recurring": { + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 10000, + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "subscription": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UBJmkPjx0CusuMr3KQ2wXyZ" + }, + "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": [ + "card" + ], + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1788565981, + "status": "incomplete", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "billing_cycle_anchor": null, + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null +} diff --git a/tests/fixtures/stripe/subscriptions/incomplete_automatic_pix.json b/tests/fixtures/stripe/subscriptions/incomplete_automatic_pix.json new file mode 100644 index 0000000..9295f23 --- /dev/null +++ b/tests/fixtures/stripe/subscriptions/incomplete_automatic_pix.json @@ -0,0 +1,212 @@ +{ + "id": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788565981, + "billing_cycle_anchor_config": null, + "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, + "type": "flexible", + "updated_at": 1788565981 + }, + "billing_schedules": [], + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "feedback_option": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1788565981, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "days_until_due": null, + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788565981, + "current_period_end": 1791157981, + "current_period_start": 1788565981, + "current_trial": null, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "plano_mensal", + "metadata": {}, + "nickname": "Plano Mensal", + "product": { + "id": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "object": "product", + "active": true, + "attributes": [], + "created": 1788565977, + "default_price": null, + "description": null, + "images": [], + "livemode": false, + "marketing_features": [], + "metadata": { + "identifier": "plano_mensal" + }, + "name": "Plano Mensal", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "tax_code": null, + "tax_details": null, + "type": "service", + "unit_label": null, + "updated": 1788565977, + "url": null + }, + "recurring": { + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 10000, + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "subscription": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UBJmkPjx0CusuMr3KQ2wXyZ" + }, + "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": { + "pix": { + "mandate_options": { + "amount": 10000, + "amount_includes_iof": "never", + "amount_type": "fixed", + "end_date": null, + "payment_schedule": "monthly", + "reference": "Empresa Exemplo", + "start_date": 1788825600 + } + } + }, + "payment_method_types": [ + "pix" + ], + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1788565981, + "status": "incomplete", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "billing_cycle_anchor": null, + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null +} diff --git a/tests/fixtures/stripe/subscriptions/incomplete_expired.json b/tests/fixtures/stripe/subscriptions/incomplete_expired.json new file mode 100644 index 0000000..b20c93e --- /dev/null +++ b/tests/fixtures/stripe/subscriptions/incomplete_expired.json @@ -0,0 +1,252 @@ +{ + "id": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788565981, + "billing_cycle_anchor_config": null, + "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, + "type": "flexible", + "updated_at": 1788565981 + }, + "billing_schedules": [], + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "feedback_option": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1788565981, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "days_until_due": null, + "default_payment_method": { + "id": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788565979, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": {}, + "shared_payment_granted_token": null, + "type": "card" + }, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": 1788648781, + "invoice_settings": { + "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788565981, + "current_period_end": 1791157981, + "current_period_start": 1788565981, + "current_trial": null, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "plano_mensal", + "metadata": {}, + "nickname": "Plano Mensal", + "product": { + "id": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "object": "product", + "active": true, + "attributes": [], + "created": 1788565977, + "default_price": null, + "description": null, + "images": [], + "livemode": false, + "marketing_features": [], + "metadata": { + "identifier": "plano_mensal" + }, + "name": "Plano Mensal", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "tax_code": null, + "tax_details": null, + "type": "service", + "unit_label": null, + "updated": 1788565977, + "url": null + }, + "recurring": { + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 10000, + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "subscription": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UBJmkPjx0CusuMr3KQ2wXyZ" + }, + "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": [ + "card" + ], + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1788565981, + "status": "incomplete_expired", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "billing_cycle_anchor": null, + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null +} diff --git a/tests/fixtures/stripe/subscriptions/past_due.json b/tests/fixtures/stripe/subscriptions/past_due.json new file mode 100644 index 0000000..46b172d --- /dev/null +++ b/tests/fixtures/stripe/subscriptions/past_due.json @@ -0,0 +1,252 @@ +{ + "id": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788565981, + "billing_cycle_anchor_config": null, + "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, + "type": "flexible", + "updated_at": 1788565981 + }, + "billing_schedules": [], + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "feedback_option": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1788565981, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "days_until_due": null, + "default_payment_method": { + "id": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788565979, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": {}, + "shared_payment_granted_token": null, + "type": "card" + }, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788565981, + "current_period_end": 1791157981, + "current_period_start": 1788565981, + "current_trial": null, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "plano_mensal", + "metadata": {}, + "nickname": "Plano Mensal", + "product": { + "id": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "object": "product", + "active": true, + "attributes": [], + "created": 1788565977, + "default_price": null, + "description": null, + "images": [], + "livemode": false, + "marketing_features": [], + "metadata": { + "identifier": "plano_mensal" + }, + "name": "Plano Mensal", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "tax_code": null, + "tax_details": null, + "type": "service", + "unit_label": null, + "updated": 1788565977, + "url": null + }, + "recurring": { + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 10000, + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "subscription": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UBJmkPjx0CusuMr3KQ2wXyZ" + }, + "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": [ + "card" + ], + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1788565981, + "status": "past_due", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "billing_cycle_anchor": null, + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null +} diff --git a/tests/fixtures/stripe/subscriptions/paused.json b/tests/fixtures/stripe/subscriptions/paused.json new file mode 100644 index 0000000..f056aef --- /dev/null +++ b/tests/fixtures/stripe/subscriptions/paused.json @@ -0,0 +1,252 @@ +{ + "id": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1789170793, + "billing_cycle_anchor_config": null, + "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, + "type": "flexible", + "updated_at": 1788565993 + }, + "billing_schedules": [], + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "feedback_option": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1788565993, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "days_until_due": null, + "default_payment_method": { + "id": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788565979, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": {}, + "shared_payment_granted_token": null, + "type": "card" + }, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788565993, + "current_period_end": 1789170793, + "current_period_start": 1788565993, + "current_trial": null, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "plano_mensal", + "metadata": {}, + "nickname": "Plano Mensal", + "product": { + "id": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "object": "product", + "active": true, + "attributes": [], + "created": 1788565977, + "default_price": null, + "description": null, + "images": [], + "livemode": false, + "marketing_features": [], + "metadata": { + "identifier": "plano_mensal" + }, + "name": "Plano Mensal", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "tax_code": null, + "tax_details": null, + "type": "service", + "unit_label": null, + "updated": 1788565977, + "url": null + }, + "recurring": { + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 10000, + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "subscription": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UBJmkPjx0CusuMr3KQ2wXyZ" + }, + "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": [ + "card" + ], + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1788565993, + "status": "paused", + "test_clock": null, + "transfer_data": null, + "trial_end": 1789170793, + "trial_settings": { + "end_behavior": { + "billing_cycle_anchor": null, + "missing_payment_method": "create_invoice" + } + }, + "trial_start": 1788565993 +} diff --git a/tests/fixtures/stripe/subscriptions/trialing.json b/tests/fixtures/stripe/subscriptions/trialing.json new file mode 100644 index 0000000..e2ed81b --- /dev/null +++ b/tests/fixtures/stripe/subscriptions/trialing.json @@ -0,0 +1,252 @@ +{ + "id": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1789170793, + "billing_cycle_anchor_config": null, + "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, + "type": "flexible", + "updated_at": 1788565993 + }, + "billing_schedules": [], + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "feedback_option": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1788565993, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "days_until_due": null, + "default_payment_method": { + "id": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788565979, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": {}, + "shared_payment_granted_token": null, + "type": "card" + }, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788565993, + "current_period_end": 1789170793, + "current_period_start": 1788565993, + "current_trial": null, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "plano_mensal", + "metadata": {}, + "nickname": "Plano Mensal", + "product": { + "id": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "object": "product", + "active": true, + "attributes": [], + "created": 1788565977, + "default_price": null, + "description": null, + "images": [], + "livemode": false, + "marketing_features": [], + "metadata": { + "identifier": "plano_mensal" + }, + "name": "Plano Mensal", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "tax_code": null, + "tax_details": null, + "type": "service", + "unit_label": null, + "updated": 1788565977, + "url": null + }, + "recurring": { + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 10000, + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "subscription": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UBJmkPjx0CusuMr3KQ2wXyZ" + }, + "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": [ + "card" + ], + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1788565993, + "status": "trialing", + "test_clock": null, + "transfer_data": null, + "trial_end": 1789170793, + "trial_settings": { + "end_behavior": { + "billing_cycle_anchor": null, + "missing_payment_method": "create_invoice" + } + }, + "trial_start": 1788565993 +} diff --git a/tests/fixtures/stripe/subscriptions/unpaid.json b/tests/fixtures/stripe/subscriptions/unpaid.json new file mode 100644 index 0000000..d410f39 --- /dev/null +++ b/tests/fixtures/stripe/subscriptions/unpaid.json @@ -0,0 +1,252 @@ +{ + "id": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788565981, + "billing_cycle_anchor_config": null, + "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, + "type": "flexible", + "updated_at": 1788565981 + }, + "billing_schedules": [], + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "feedback_option": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1788565981, + "currency": "brl", + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "days_until_due": null, + "default_payment_method": { + "id": "pm_1UBJmjPjx0CusuMrQ4hM2Kp9", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788565979, + "customer": "cus_VBen1v8T4Qa6XX", + "customer_account": null, + "livemode": false, + "metadata": {}, + "shared_payment_granted_token": null, + "type": "card" + }, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_UBJmkPjx0CusuMrLq0v9Yb2", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788565981, + "current_period_end": 1791157981, + "current_period_start": 1788565981, + "current_trial": null, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "plano_mensal", + "metadata": {}, + "nickname": "Plano Mensal", + "product": { + "id": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "object": "product", + "active": true, + "attributes": [], + "created": 1788565977, + "default_price": null, + "description": null, + "images": [], + "livemode": false, + "marketing_features": [], + "metadata": { + "identifier": "plano_mensal" + }, + "name": "Plano Mensal", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "tax_code": null, + "tax_details": null, + "type": "service", + "unit_label": null, + "updated": 1788565977, + "url": null + }, + "recurring": { + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 10000, + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "subscription": "sub_1UBJmkPjx0CusuMr3KQ2wXyZ", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UBJmkPjx0CusuMr3KQ2wXyZ" + }, + "latest_invoice": "in_1UBJmkPjx0CusuMrN6Yc2Ha1", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": [ + "card" + ], + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1UBJmiPjx0CusuMrTz8Rk1Lm", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "Plano Mensal", + "product": "prod_UBJmiPjx0CusuMr7Wq3Nc0", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1788565981, + "status": "unpaid", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "billing_cycle_anchor": null, + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null +} diff --git a/tests/fixtures/stripe/webhooks/charge.dispute.created.json b/tests/fixtures/stripe/webhooks/charge.dispute.created.json new file mode 100644 index 0000000..98c5b8a --- /dev/null +++ b/tests/fixtures/stripe/webhooks/charge.dispute.created.json @@ -0,0 +1,105 @@ +{ + "id": "evt_1UC6vwPjx0CusuMr3RQZkHeD", + "object": "event", + "api_version": "2026-06-24.dahlia", + "created": 1788566052, + "data": { + "object": { + "id": "du_1UC6vvPjx0CusuMrFCR9SiV0", + "object": "dispute", + "amount": 7000, + "balance_transaction": "txn_1UC6vwPjx0CusuMrDKJHpKUT", + "balance_transactions": [ + { + "available_on": 1788566051, + "created": 1788566051, + "net": -12500, + "currency": "brl", + "source": "du_1UC6vvPjx0CusuMrFCR9SiV0", + "reporting_category": "dispute", + "fee_details": [ + { + "application": null, + "amount": 5500, + "type": "stripe_fee", + "description": "Dispute fee", + "currency": "brl" + } + ], + "amount": -7000, + "status": "available", + "balance_type": "payments", + "object": "balance_transaction", + "id": "txn_1UC6vwPjx0CusuMrDKJHpKUT", + "exchange_rate": null, + "type": "adjustment", + "description": "Chargeback withdrawal for ch_3UC6vvPjx0CusuMr1eTM0Zbf", + "fee": 5500 + } + ], + "charge": "ch_3UC6vvPjx0CusuMr1eTM0Zbf", + "created": 1788566051, + "currency": "brl", + "enhanced_eligibility_types": [], + "evidence": { + "access_activity_log": null, + "billing_address": null, + "cancellation_policy": null, + "cancellation_policy_disclosure": null, + "cancellation_rebuttal": null, + "customer_communication": null, + "customer_email_address": null, + "customer_name": null, + "customer_purchase_ip": null, + "customer_signature": null, + "duplicate_charge_documentation": null, + "duplicate_charge_explanation": null, + "duplicate_charge_id": null, + "enhanced_evidence": {}, + "product_description": null, + "receipt": null, + "refund_policy": null, + "refund_policy_disclosure": null, + "refund_refusal_explanation": null, + "service_date": null, + "service_documentation": null, + "shipping_address": null, + "shipping_carrier": null, + "shipping_date": null, + "shipping_documentation": null, + "shipping_tracking_number": null, + "uncategorized_file": null, + "uncategorized_text": null + }, + "evidence_details": { + "due_by": 1789257599, + "enhanced_eligibility": {}, + "has_evidence": false, + "past_due": false, + "submission_count": 0 + }, + "is_charge_refundable": false, + "livemode": false, + "metadata": {}, + "payment_intent": "pi_3UC6vvPjx0CusuMr1QDzJkyL", + "payment_method_details": { + "card": { + "brand": "visa", + "case_type": "chargeback", + "network": "visa", + "network_reason_code": "10.4" + }, + "type": "card" + }, + "reason": "fraudulent", + "status": "needs_response" + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_Ux6mKfmUFyegOW", + "idempotency_key": "173f01d9-c5e5-4b28-bdb4-01d4b66eac6b" + }, + "type": "charge.dispute.created" +} \ No newline at end of file diff --git a/tests/fixtures/stripe/webhooks/charge.refunded.json b/tests/fixtures/stripe/webhooks/charge.refunded.json new file mode 100644 index 0000000..08c2b83 --- /dev/null +++ b/tests/fixtures/stripe/webhooks/charge.refunded.json @@ -0,0 +1,137 @@ +{ + "id": "evt_3UC6vrPjx0CusuMr1Dl0qeMF", + "object": "event", + "api_version": "2026-06-24.dahlia", + "created": 1788566048, + "data": { + "object": { + "id": "ch_3UC6vrPjx0CusuMr1kgZJyoO", + "object": "charge", + "amount": 5000, + "amount_captured": 5000, + "amount_refunded": 5000, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": "txn_3UC6vrPjx0CusuMr1NBdNIDz", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "calculated_statement_descriptor": "ESCAVADOR", + "captured": true, + "created": 1788566047, + "currency": "brl", + "customer": "cus_VCVxAZQMCHXOt8", + "description": null, + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": null, + "failure_message": null, + "fraud_details": {}, + "livemode": false, + "metadata": { + "item_0_description": "Cobranca para estorno", + "item_0_price": "5000", + "item_0_quantity": "1" + }, + "on_behalf_of": null, + "order": null, + "outcome": { + "advice_code": null, + "network_advice_code": null, + "network_decline_code": null, + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 25, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": "pi_3UC6vrPjx0CusuMr1p8wB8OF", + "payment_method": "pm_1UC6vpPjx0CusuMr1aZb0Cwt", + "payment_method_details": { + "card": { + "amount_authorized": 5000, + "authorization_code": "951116", + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 9, + "exp_year": 2027, + "extended_authorization": { + "status": "disabled" + }, + "fingerprint": "BBrG8ENEZUZi7U6k", + "funding": "credit", + "incremental_authorization": { + "status": "unavailable" + }, + "installments": null, + "last4": "4242", + "mandate": null, + "multicapture": { + "status": "unavailable" + }, + "network": "visa", + "network_token": { + "used": false + }, + "network_transaction_id": "666611471566978", + "overcapture": { + "maximum_amount_capturable": 5000, + "status": "unavailable" + }, + "regulated_status": "unregulated", + "three_d_secure": null, + "transaction_link_id": null, + "wallet": null + }, + "type": "card" + }, + "radar_options": {}, + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xVHpLa1RQangwQ3VzdU1yKKG07dQGMgbSlvgGNGY6LBbYyqpucoFdj3ANQs4FnFI50cTWK_HL1akSX2iD4p7LzjlGrK7Ltqtsf66e", + "refunded": true, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + }, + "previous_attributes": { + "amount_refunded": 0, + "receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xVHpLa1RQangwQ3VzdU1yKKC07dQGMgYW4HfsvuQ6LBad9Q3eqcxuoRv3D8BRcW43HwPhLRwQbZQ3DmMX_OdkvrrSUsROV5yD362l", + "refunded": false + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_F2cFBYuedq08e7", + "idempotency_key": "cd0bd68c-622a-409d-9906-1966de360394" + }, + "type": "charge.refunded" +} \ No newline at end of file diff --git a/tests/fixtures/stripe/webhooks/customer.subscription.created.json b/tests/fixtures/stripe/webhooks/customer.subscription.created.json new file mode 100644 index 0000000..2672158 --- /dev/null +++ b/tests/fixtures/stripe/webhooks/customer.subscription.created.json @@ -0,0 +1,191 @@ +{ + "id": "evt_1UC6upPjx0CusuMrLVt80Lm2", + "object": "event", + "api_version": "2026-06-24.dahlia", + "created": 1788565983, + "data": { + "object": { + "id": "sub_1UC6unPjx0CusuMrH0YMAWK6", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788565981, + "billing_cycle_anchor_config": null, + "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, + "type": "flexible", + "updated_at": 1788565981 + }, + "billing_schedules": [], + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "feedback_option": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1788565981, + "currency": "brl", + "customer": "cus_VCVwy3d2PYIJXM", + "customer_account": null, + "days_until_due": null, + "default_payment_method": "pm_1UC6ulPjx0CusuMrE28WKacN", + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_VCVwoG3IX26J0b", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788565981, + "current_period_end": 1791157981, + "current_period_start": 1788565981, + "current_trial": null, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UC6ukPjx0CusuMrh0XqmZZd", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "MP P06 Mensal 607a71f6", + "product": "prod_VCVwrMO5ms21iL", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UC6ukPjx0CusuMrh0XqmZZd", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "mp-p06-mensal-607a71f6", + "metadata": {}, + "nickname": "MP P06 Mensal 607a71f6", + "product": "prod_VCVwrMO5ms21iL", + "recurring": { + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 10000, + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "subscription": "sub_1UC6unPjx0CusuMrH0YMAWK6", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UC6unPjx0CusuMrH0YMAWK6" + }, + "latest_invoice": "in_1UC6unPjx0CusuMr2Jkqq5Fb", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": [ + "card" + ], + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1UC6ukPjx0CusuMrh0XqmZZd", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "MP P06 Mensal 607a71f6", + "product": "prod_VCVwrMO5ms21iL", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1788565981, + "status": "active", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "billing_cycle_anchor": null, + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_3E7Ws12zN5HMSB", + "idempotency_key": "p06-607a71f6-card" + }, + "type": "customer.subscription.created" +} \ No newline at end of file diff --git a/tests/fixtures/stripe/webhooks/customer.subscription.deleted.json b/tests/fixtures/stripe/webhooks/customer.subscription.deleted.json new file mode 100644 index 0000000..06886a3 --- /dev/null +++ b/tests/fixtures/stripe/webhooks/customer.subscription.deleted.json @@ -0,0 +1,191 @@ +{ + "id": "evt_1UC6uyPjx0CusuMrQ3S6u2dQ", + "object": "event", + "api_version": "2026-06-24.dahlia", + "created": 1788565992, + "data": { + "object": { + "id": "sub_1UC6unPjx0CusuMrH0YMAWK6", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788565981, + "billing_cycle_anchor_config": null, + "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, + "type": "flexible", + "updated_at": 1788565981 + }, + "billing_schedules": [], + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": 1788565992, + "cancellation_details": { + "comment": null, + "feedback": null, + "feedback_option": null, + "reason": "cancellation_requested" + }, + "collection_method": "charge_automatically", + "created": 1788565981, + "currency": "brl", + "customer": "cus_VCVwy3d2PYIJXM", + "customer_account": null, + "days_until_due": null, + "default_payment_method": "pm_1UC6ulPjx0CusuMrE28WKacN", + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": 1788565992, + "invoice_settings": { + "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_VCVwoG3IX26J0b", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788565981, + "current_period_end": 1820101981, + "current_period_start": 1788565981, + "current_trial": null, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UC6ukPjx0CusuMrfiJshCLw", + "object": "plan", + "active": true, + "amount": 90000, + "amount_decimal": "90000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "year", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "MP P06 Anual 607a71f6", + "product": "prod_VCVw3GaYGFkGe4", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UC6ukPjx0CusuMrfiJshCLw", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "mp-p06-anual-607a71f6", + "metadata": {}, + "nickname": "MP P06 Anual 607a71f6", + "product": "prod_VCVw3GaYGFkGe4", + "recurring": { + "interval": "year", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 90000, + "unit_amount_decimal": "90000" + }, + "quantity": 1, + "subscription": "sub_1UC6unPjx0CusuMrH0YMAWK6", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UC6unPjx0CusuMrH0YMAWK6" + }, + "latest_invoice": "in_1UC6usPjx0CusuMrgAijCrPZ", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": [ + "card" + ], + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1UC6ukPjx0CusuMrfiJshCLw", + "object": "plan", + "active": true, + "amount": 90000, + "amount_decimal": "90000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "year", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "MP P06 Anual 607a71f6", + "product": "prod_VCVw3GaYGFkGe4", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1788565981, + "status": "canceled", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "billing_cycle_anchor": null, + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_RRIkVKQEkJ4AjN", + "idempotency_key": "p06-607a71f6-cancel" + }, + "type": "customer.subscription.deleted" +} \ No newline at end of file diff --git a/tests/fixtures/stripe/webhooks/customer.subscription.updated.json b/tests/fixtures/stripe/webhooks/customer.subscription.updated.json new file mode 100644 index 0000000..e46eddc --- /dev/null +++ b/tests/fixtures/stripe/webhooks/customer.subscription.updated.json @@ -0,0 +1,268 @@ +{ + "id": "evt_1UC6uuPjx0CusuMrsiKlht8v", + "object": "event", + "api_version": "2026-06-24.dahlia", + "created": 1788565988, + "data": { + "object": { + "id": "sub_1UC6unPjx0CusuMrH0YMAWK6", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null + }, + "billing_cycle_anchor": 1788565981, + "billing_cycle_anchor_config": null, + "billing_mode": { + "flexible": { + "proration_discounts": "included" + }, + "type": "flexible", + "updated_at": 1788565981 + }, + "billing_schedules": [], + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "feedback_option": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1788565981, + "currency": "brl", + "customer": "cus_VCVwy3d2PYIJXM", + "customer_account": null, + "days_until_due": null, + "default_payment_method": "pm_1UC6ulPjx0CusuMrE28WKacN", + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "custom_fields": null, + "description": null, + "footer": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_VCVwoG3IX26J0b", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788565981, + "current_period_end": 1820101981, + "current_period_start": 1788565981, + "current_trial": null, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UC6ukPjx0CusuMrfiJshCLw", + "object": "plan", + "active": true, + "amount": 90000, + "amount_decimal": "90000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "year", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "MP P06 Anual 607a71f6", + "product": "prod_VCVw3GaYGFkGe4", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UC6ukPjx0CusuMrfiJshCLw", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "mp-p06-anual-607a71f6", + "metadata": {}, + "nickname": "MP P06 Anual 607a71f6", + "product": "prod_VCVw3GaYGFkGe4", + "recurring": { + "interval": "year", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 90000, + "unit_amount_decimal": "90000" + }, + "quantity": 1, + "subscription": "sub_1UC6unPjx0CusuMrH0YMAWK6", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1UC6unPjx0CusuMrH0YMAWK6" + }, + "latest_invoice": "in_1UC6usPjx0CusuMrgAijCrPZ", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": [ + "card" + ], + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1UC6ukPjx0CusuMrfiJshCLw", + "object": "plan", + "active": true, + "amount": 90000, + "amount_decimal": "90000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "year", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "MP P06 Anual 607a71f6", + "product": "prod_VCVw3GaYGFkGe4", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1788565981, + "status": "active", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "billing_cycle_anchor": null, + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null + }, + "previous_attributes": { + "items": { + "data": [ + { + "id": "si_VCVwoG3IX26J0b", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1788565981, + "current_period_end": 1791157981, + "current_period_start": 1788565981, + "current_trial": null, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1UC6ukPjx0CusuMrh0XqmZZd", + "object": "plan", + "active": true, + "amount": 10000, + "amount_decimal": "10000", + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": "MP P06 Mensal 607a71f6", + "product": "prod_VCVwrMO5ms21iL", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1UC6ukPjx0CusuMrh0XqmZZd", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1788565978, + "currency": "brl", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": "mp-p06-mensal-607a71f6", + "metadata": {}, + "nickname": "MP P06 Mensal 607a71f6", + "product": "prod_VCVwrMO5ms21iL", + "recurring": { + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 10000, + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "subscription": "sub_1UC6unPjx0CusuMrH0YMAWK6", + "tax_rates": [] + } + ] + }, + "latest_invoice": "in_1UC6unPjx0CusuMr2Jkqq5Fb", + "plan": { + "id": "price_1UC6ukPjx0CusuMrh0XqmZZd", + "amount": 10000, + "amount_decimal": "10000", + "interval": "month", + "nickname": "MP P06 Mensal 607a71f6", + "product": "prod_VCVwrMO5ms21iL" + } + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_ooVw7ohCghBXbH", + "idempotency_key": "p06-607a71f6-change" + }, + "type": "customer.subscription.updated" +} \ No newline at end of file diff --git a/tests/fixtures/stripe/webhooks/invoice.created.json b/tests/fixtures/stripe/webhooks/invoice.created.json new file mode 100644 index 0000000..c6ee6b4 --- /dev/null +++ b/tests/fixtures/stripe/webhooks/invoice.created.json @@ -0,0 +1,172 @@ +{ + "id": "evt_1UC6upPjx0CusuMrbh1kV6t8", + "object": "event", + "api_version": "2026-06-24.dahlia", + "created": 1788565983, + "data": { + "object": { + "id": "in_1UC6unPjx0CusuMr2Jkqq5Fb", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 10000, + "amount_overpaid": 0, + "amount_paid": 10000, + "amount_remaining": 0, + "amount_shipping": 0, + "application": null, + "attempt_count": 1, + "attempted": true, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "subscription_create", + "collection_method": "charge_automatically", + "created": 1788565981, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VCVwy3d2PYIJXM", + "customer_account": null, + "customer_address": null, + "customer_email": "mp-p06-607a71f6@exemplo.com", + "customer_name": "MP Teste Prompt06", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788565981, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQ1Z3aVdHUTY5N2REVXcySFU0WnNMeld4dU55aGRsLDE3OTEwNjc4Mw0200EdI3V4M5?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQ1Z3aVdHUTY5N2REVXcySFU0WnNMeld4dU55aGRsLDE3OTEwNjc4Mw0200EdI3V4M5/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UC6unPjx0CusuMrxYHjaaU6", + "object": "line_item", + "amount": 10000, + "currency": "brl", + "description": "1 × MP P06 Mensal 607a71f6 (a R$ 100.00 / month)", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UC6unPjx0CusuMr2Jkqq5Fb", + "livemode": false, + "metadata": {}, + "parent": { + "invoice_item_details": null, + "subscription_item_details": { + "invoice_item": null, + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": "sub_1UC6unPjx0CusuMrH0YMAWK6", + "subscription_item": "si_VCVwoG3IX26J0b" + }, + "type": "subscription_item_details" + }, + "period": { + "end": 1791157981, + "start": 1788565981 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UC6ukPjx0CusuMrh0XqmZZd", + "product": "prod_VCVwrMO5ms21iL" + }, + "type": "price_details", + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 10000, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UC6unPjx0CusuMr2Jkqq5Fb/lines" + }, + "livemode": false, + "metadata": {}, + "next_payment_attempt": null, + "number": "QO3ETNSL-0001", + "on_behalf_of": null, + "parent": { + "quote_details": null, + "subscription_details": { + "metadata": {}, + "subscription": "sub_1UC6unPjx0CusuMrH0YMAWK6" + }, + "type": "subscription_details" + }, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": [ + "card" + ] + }, + "period_end": 1788565981, + "period_start": 1788565981, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": null, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "paid", + "status_transitions": { + "finalized_at": 1788565981, + "marked_uncollectible_at": null, + "paid_at": 1788565981, + "voided_at": null + }, + "subtotal": 10000, + "subtotal_excluding_tax": 10000, + "test_clock": null, + "total": 10000, + "total_discount_amounts": [], + "total_excluding_tax": 10000, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": null + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_3E7Ws12zN5HMSB", + "idempotency_key": "p06-607a71f6-card" + }, + "type": "invoice.created" +} \ No newline at end of file diff --git a/tests/fixtures/stripe/webhooks/invoice.finalized.json b/tests/fixtures/stripe/webhooks/invoice.finalized.json new file mode 100644 index 0000000..5435d18 --- /dev/null +++ b/tests/fixtures/stripe/webhooks/invoice.finalized.json @@ -0,0 +1,172 @@ +{ + "id": "evt_1UC6upPjx0CusuMrsM7ryUqb", + "object": "event", + "api_version": "2026-06-24.dahlia", + "created": 1788565983, + "data": { + "object": { + "id": "in_1UC6unPjx0CusuMr2Jkqq5Fb", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 10000, + "amount_overpaid": 0, + "amount_paid": 10000, + "amount_remaining": 0, + "amount_shipping": 0, + "application": null, + "attempt_count": 1, + "attempted": true, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "subscription_create", + "collection_method": "charge_automatically", + "created": 1788565981, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VCVwy3d2PYIJXM", + "customer_account": null, + "customer_address": null, + "customer_email": "mp-p06-607a71f6@exemplo.com", + "customer_name": "MP Teste Prompt06", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788565981, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQ1Z3aVdHUTY5N2REVXcySFU0WnNMeld4dU55aGRsLDE3OTEwNjc4Mw0200EdI3V4M5?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQ1Z3aVdHUTY5N2REVXcySFU0WnNMeld4dU55aGRsLDE3OTEwNjc4Mw0200EdI3V4M5/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UC6unPjx0CusuMrxYHjaaU6", + "object": "line_item", + "amount": 10000, + "currency": "brl", + "description": "1 × MP P06 Mensal 607a71f6 (a R$ 100.00 / month)", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UC6unPjx0CusuMr2Jkqq5Fb", + "livemode": false, + "metadata": {}, + "parent": { + "invoice_item_details": null, + "subscription_item_details": { + "invoice_item": null, + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": "sub_1UC6unPjx0CusuMrH0YMAWK6", + "subscription_item": "si_VCVwoG3IX26J0b" + }, + "type": "subscription_item_details" + }, + "period": { + "end": 1791157981, + "start": 1788565981 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UC6ukPjx0CusuMrh0XqmZZd", + "product": "prod_VCVwrMO5ms21iL" + }, + "type": "price_details", + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 10000, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UC6unPjx0CusuMr2Jkqq5Fb/lines" + }, + "livemode": false, + "metadata": {}, + "next_payment_attempt": null, + "number": "QO3ETNSL-0001", + "on_behalf_of": null, + "parent": { + "quote_details": null, + "subscription_details": { + "metadata": {}, + "subscription": "sub_1UC6unPjx0CusuMrH0YMAWK6" + }, + "type": "subscription_details" + }, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": [ + "card" + ] + }, + "period_end": 1788565981, + "period_start": 1788565981, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": null, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "paid", + "status_transitions": { + "finalized_at": 1788565981, + "marked_uncollectible_at": null, + "paid_at": 1788565981, + "voided_at": null + }, + "subtotal": 10000, + "subtotal_excluding_tax": 10000, + "test_clock": null, + "total": 10000, + "total_discount_amounts": [], + "total_excluding_tax": 10000, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": null + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_3E7Ws12zN5HMSB", + "idempotency_key": "p06-607a71f6-card" + }, + "type": "invoice.finalized" +} \ No newline at end of file diff --git a/tests/fixtures/stripe/webhooks/invoice.paid.json b/tests/fixtures/stripe/webhooks/invoice.paid.json new file mode 100644 index 0000000..b093eec --- /dev/null +++ b/tests/fixtures/stripe/webhooks/invoice.paid.json @@ -0,0 +1,172 @@ +{ + "id": "evt_1UC6upPjx0CusuMr7t76P2p5", + "object": "event", + "api_version": "2026-06-24.dahlia", + "created": 1788565983, + "data": { + "object": { + "id": "in_1UC6unPjx0CusuMr2Jkqq5Fb", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 10000, + "amount_overpaid": 0, + "amount_paid": 10000, + "amount_remaining": 0, + "amount_shipping": 0, + "application": null, + "attempt_count": 1, + "attempted": true, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "subscription_create", + "collection_method": "charge_automatically", + "created": 1788565981, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VCVwy3d2PYIJXM", + "customer_account": null, + "customer_address": null, + "customer_email": "mp-p06-607a71f6@exemplo.com", + "customer_name": "MP Teste Prompt06", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788565981, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQ1Z3aVdHUTY5N2REVXcySFU0WnNMeld4dU55aGRsLDE3OTEwNjc4Mw0200EdI3V4M5?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQ1Z3aVdHUTY5N2REVXcySFU0WnNMeld4dU55aGRsLDE3OTEwNjc4Mw0200EdI3V4M5/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UC6unPjx0CusuMrxYHjaaU6", + "object": "line_item", + "amount": 10000, + "currency": "brl", + "description": "1 × MP P06 Mensal 607a71f6 (a R$ 100.00 / month)", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UC6unPjx0CusuMr2Jkqq5Fb", + "livemode": false, + "metadata": {}, + "parent": { + "invoice_item_details": null, + "subscription_item_details": { + "invoice_item": null, + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": "sub_1UC6unPjx0CusuMrH0YMAWK6", + "subscription_item": "si_VCVwoG3IX26J0b" + }, + "type": "subscription_item_details" + }, + "period": { + "end": 1791157981, + "start": 1788565981 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UC6ukPjx0CusuMrh0XqmZZd", + "product": "prod_VCVwrMO5ms21iL" + }, + "type": "price_details", + "unit_amount_decimal": "10000" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 10000, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UC6unPjx0CusuMr2Jkqq5Fb/lines" + }, + "livemode": false, + "metadata": {}, + "next_payment_attempt": null, + "number": "QO3ETNSL-0001", + "on_behalf_of": null, + "parent": { + "quote_details": null, + "subscription_details": { + "metadata": {}, + "subscription": "sub_1UC6unPjx0CusuMrH0YMAWK6" + }, + "type": "subscription_details" + }, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": [ + "card" + ] + }, + "period_end": 1788565981, + "period_start": 1788565981, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": null, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "paid", + "status_transitions": { + "finalized_at": 1788565981, + "marked_uncollectible_at": null, + "paid_at": 1788565981, + "voided_at": null + }, + "subtotal": 10000, + "subtotal_excluding_tax": 10000, + "test_clock": null, + "total": 10000, + "total_discount_amounts": [], + "total_excluding_tax": 10000, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": null + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_3E7Ws12zN5HMSB", + "idempotency_key": "p06-607a71f6-card" + }, + "type": "invoice.paid" +} \ No newline at end of file diff --git a/tests/fixtures/stripe/webhooks/invoice.payment_failed.json b/tests/fixtures/stripe/webhooks/invoice.payment_failed.json new file mode 100644 index 0000000..e57e81f --- /dev/null +++ b/tests/fixtures/stripe/webhooks/invoice.payment_failed.json @@ -0,0 +1,170 @@ +{ + "id": "evt_1UC6wTPjx0CusuMrGqqAySXf", + "object": "event", + "api_version": "2026-06-24.dahlia", + "created": 1788566084, + "data": { + "object": { + "id": "in_1UC6wQPjx0CusuMrikar2pXa", + "object": "invoice", + "account_country": "BR", + "account_name": "Escavador", + "account_tax_ids": null, + "amount_due": 4000, + "amount_overpaid": 0, + "amount_paid": 0, + "amount_remaining": 4000, + "amount_shipping": 0, + "application": null, + "attempt_count": 1, + "attempted": true, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "provider": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "subscription_create", + "collection_method": "charge_automatically", + "created": 1788566082, + "currency": "brl", + "custom_fields": null, + "customer": "cus_VCVyPodH6sGSXc", + "customer_account": null, + "customer_address": null, + "customer_email": "mp-p06wh2-f7c5dae9@exemplo.com", + "customer_name": "MP Teste Webhooks 2", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + { + "type": "br_cpf", + "value": "201.769.969-15" + } + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discounts": [], + "due_date": null, + "effective_at": 1788566082, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQ1Z5YUJXVEc3UlhZT0liQzZJMEFvQjV1QVdjNHVHLDE3OTEwNjg4NQ02008pZrz1Rx?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1TzKkTPjx0CusuMr/test_YWNjdF8xVHpLa1RQangwQ3VzdU1yLF9WQ1Z5YUJXVEc3UlhZT0liQzZJMEFvQjV1QVdjNHVHLDE3OTEwNjg4NQ02008pZrz1Rx/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1UC6wQPjx0CusuMrGC9rtmRb", + "object": "line_item", + "amount": 4000, + "currency": "brl", + "description": "1 × MP P06 WH2 f7c5dae9 (a R$ 40.00 / month)", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "invoice": "in_1UC6wQPjx0CusuMrikar2pXa", + "livemode": false, + "metadata": {}, + "parent": { + "invoice_item_details": null, + "subscription_item_details": { + "invoice_item": null, + "proration": false, + "proration_details": { + "credited_items": null + }, + "subscription": "sub_1UC6wQPjx0CusuMrsmhkXBow", + "subscription_item": "si_VCVyEb0uEFOyhM" + }, + "type": "subscription_item_details" + }, + "period": { + "end": 1791158082, + "start": 1788566082 + }, + "pretax_credit_amounts": [], + "pricing": { + "price_details": { + "price": "price_1UC6wQPjx0CusuMr1OSsR2oV", + "product": "prod_VCVyrNe2mwrugX" + }, + "type": "price_details", + "unit_amount_decimal": "4000" + }, + "quantity": 1, + "quantity_decimal": "1", + "subtotal": 4000, + "taxes": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1UC6wQPjx0CusuMrikar2pXa/lines" + }, + "livemode": false, + "metadata": {}, + "next_payment_attempt": null, + "number": "MNHMQAKN-0001", + "on_behalf_of": null, + "parent": { + "quote_details": null, + "subscription_details": { + "metadata": {}, + "subscription": "sub_1UC6wQPjx0CusuMrsmhkXBow" + }, + "type": "subscription_details" + }, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "period_end": 1788566082, + "period_start": 1788566082, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "rendering": null, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "open", + "status_transitions": { + "finalized_at": 1788566082, + "marked_uncollectible_at": null, + "paid_at": null, + "voided_at": null + }, + "subtotal": 4000, + "subtotal_excluding_tax": 4000, + "test_clock": null, + "total": 4000, + "total_discount_amounts": [], + "total_excluding_tax": 4000, + "total_pretax_credit_amounts": [], + "total_taxes": [], + "webhooks_delivered_at": null + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_VFpL6enekjx7eH", + "idempotency_key": "8cd25e88-b3c8-4657-8ad1-7178e86f5b81" + }, + "type": "invoice.payment_failed" +} \ No newline at end of file diff --git a/tests/fixtures/stripe/webhooks/payment_intent.payment_failed.json b/tests/fixtures/stripe/webhooks/payment_intent.payment_failed.json new file mode 100644 index 0000000..aa31b2f --- /dev/null +++ b/tests/fixtures/stripe/webhooks/payment_intent.payment_failed.json @@ -0,0 +1,141 @@ +{ + "id": "evt_3UC6wQPjx0CusuMr0ZdMD3mh", + "object": "event", + "api_version": "2026-06-24.dahlia", + "created": 1788566084, + "data": { + "object": { + "id": "pi_3UC6wQPjx0CusuMr0U8FJLrn", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 4000, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UC6wQPjx0CusuMr0U8FJLrn_secret_g00fFKr2LgN29mA0LkZ11EI5j", + "confirmation_method": "automatic", + "created": 1788566082, + "currency": "brl", + "customer": "cus_VCVyPodH6sGSXc", + "customer_account": null, + "description": "Subscription creation", + "excluded_payment_method_types": null, + "last_payment_error": { + "advice_code": "try_again_later", + "charge": "ch_3UC6wQPjx0CusuMr0ukM1IF1", + "code": "card_declined", + "decline_code": "generic_decline", + "doc_url": "https://stripe.com/docs/error-codes/card-declined", + "message": "Your card was declined.", + "network_decline_code": "01", + "payment_method": { + "id": "pm_1UC6wOPjx0CusuMrnMR6nM9I", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 9, + "exp_year": 2027, + "fingerprint": "sryrMZCxUxxxIzqq", + "funding": "credit", + "generated_from": null, + "last4": "0341", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1788566080, + "customer": "cus_VCVyPodH6sGSXc", + "customer_account": null, + "livemode": false, + "metadata": {}, + "shared_payment_granted_token": null, + "type": "card" + }, + "type": "card_error" + }, + "latest_charge": "ch_3UC6wQPjx0CusuMr0ukM1IF1", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UC6wQPjx0CusuMrikar2pXa" + }, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "payment_record": null, + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_VFpL6enekjx7eH", + "idempotency_key": "8cd25e88-b3c8-4657-8ad1-7178e86f5b81" + }, + "type": "payment_intent.payment_failed" +} \ No newline at end of file diff --git a/tests/fixtures/stripe/webhooks/payment_intent.succeeded.json b/tests/fixtures/stripe/webhooks/payment_intent.succeeded.json new file mode 100644 index 0000000..ebd1d47 --- /dev/null +++ b/tests/fixtures/stripe/webhooks/payment_intent.succeeded.json @@ -0,0 +1,79 @@ +{ + "id": "evt_3UC6unPjx0CusuMr1YVY85MW", + "object": "event", + "api_version": "2026-06-24.dahlia", + "created": 1788565982, + "data": { + "object": { + "id": "pi_3UC6unPjx0CusuMr1L8mQPNj", + "object": "payment_intent", + "allowed_payment_method_types": null, + "amount": 10000, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 10000, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3UC6unPjx0CusuMr1L8mQPNj_secret_YmueIkpVbYHfn98l1A5mlOGTX", + "confirmation_method": "automatic", + "created": 1788565981, + "currency": "brl", + "customer": "cus_VCVwy3d2PYIJXM", + "customer_account": null, + "description": "Subscription creation", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": "ch_3UC6unPjx0CusuMr12E30a16", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_details": { + "customer_reference": null, + "order_reference": "in_1UC6unPjx0CusuMr2Jkqq5Fb" + }, + "payment_method": "pm_1UC6ulPjx0CusuMrE28WKacN", + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "payment_record": null, + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": "off_session", + "shared_payment_granted_token": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_3E7Ws12zN5HMSB", + "idempotency_key": "p06-607a71f6-card" + }, + "type": "payment_intent.succeeded" +} \ No newline at end of file diff --git a/tests/fixtures/stripe/webhooks/setup_intent.succeeded.json b/tests/fixtures/stripe/webhooks/setup_intent.succeeded.json new file mode 100644 index 0000000..c11d71c --- /dev/null +++ b/tests/fixtures/stripe/webhooks/setup_intent.succeeded.json @@ -0,0 +1,55 @@ +{ + "id": "evt_1UC6umPjx0CusuMrGf4gkinv", + "object": "event", + "api_version": "2026-06-24.dahlia", + "created": 1788565980, + "data": { + "object": { + "id": "seti_1UC6ulPjx0CusuMrIz23i4LU", + "object": "setup_intent", + "allowed_payment_method_types": null, + "application": null, + "automatic_payment_methods": null, + "cancellation_reason": null, + "client_secret": "seti_1UC6ulPjx0CusuMrIz23i4LU_secret_VCVw5uZoL7ik5vbkWno6DUaVZSQ6PyC", + "created": 1788565979, + "customer": "cus_VCVwy3d2PYIJXM", + "customer_account": null, + "description": null, + "excluded_payment_method_types": null, + "flow_directions": null, + "last_setup_error": null, + "latest_attempt": "setatt_1UC6umPjx0CusuMrr3p7NAqf", + "livemode": false, + "managed_payments": { + "enabled": false + }, + "mandate": null, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_method": "pm_1UC6ulPjx0CusuMrE28WKacN", + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "single_use_mandate": null, + "status": "succeeded", + "usage": "off_session" + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_hZLvxAEjK0tthv", + "idempotency_key": "3916aebd-c1f6-4804-b691-d04b0db2d6cf" + }, + "type": "setup_intent.succeeded" +} \ No newline at end of file