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