Skip to content

Gateway Stripe completo: assinatura, plano, cupom, boleto e Pix Automático - #48

Closed
ramonsenadev wants to merge 32 commits into
mainfrom
stripe-gateway
Closed

Gateway Stripe completo: assinatura, plano, cupom, boleto e Pix Automático#48
ramonsenadev wants to merge 32 commits into
mainfrom
stripe-gateway

Conversation

@ramonsenadev

Copy link
Copy Markdown
Member

Completa o driver Stripe conforme a ADR 0001 (cobertura completa nos dois gateways) e fecha o lote 3 da revisão de DX.

O que entra

  • Vocabulário de assinatura: enum SubscriptionStatus com helpers (isActive(), isRecoverable(), isEnded()), cartão, método de pagamento e trial em dias no builder, política de pró-rata nomeada (ProrationBehavior), getSubscription() e getPlan() na fachada.
  • Fatura: paymentMethod honrado na escrita nos dois drivers, dueDate separado de pixExpiresAt (com expiresAt como alias deprecado), refundableAmount() e valor do estorno como argumento de refund().
  • Exceções: regra de valor local é ModelAttributeValidationException, restrição de gateway é UnsupportedOperationException::restricted(); GatewayException fica só para resposta do gateway (com teste de varredura). RefundNotSupportedException sai da árvore de UnsupportedOperationException.
  • Cartão: salvamento por SetupIntent no Stripe com requiresAction/confirmCreditCardSetup() para 3DS.
  • Stripe Billing: plano sobre Product e Price, assinatura com trial, troca de plano com prévia de linhas reais, cupom nativo, cancelamento ao fim do ciclo, boleto em venda avulsa e assinatura, e Pix Automático via mandato (integração pulada até a liberação na conta, grupo pix-automatico-stripe).
  • Emulações na Iugu (ADR 0007): cupom com validade e cancelamento ao fim do ciclo, com estado em custom_variables de prefixo mp_ e aplicação pelo comando multipayment:sync-subscriptions.
  • Capabilities com três níveis (implementada, não implementada, emulada) e restrições consultáveis, com a tabela do README gerada por composer capabilities:table.
  • Idempotência de primeira classe em toda escrita, com cabeçalho na Stripe e nos endpoints da Iugu que o aceitam e IdempotencyStore nos demais.

O que fica para uma versão futura

  • Escrita sobre a fatura de assinatura do Stripe (in_): estorno e cobrança manual.
  • Webhooks (as fixtures já foram gravadas), captura tardia, model de disputa e coexistência de contas por gateway.

Diretório de estudos e planos de implementação locais (ex.: estudo do
gateway Stripe), que não fazem parte do pacote publicado.
Fundação do segundo gateway do pacote (fase 1 do plano):

- stripe/stripe-php ^21.2 com prefer-stable (sem ele o lock resolvia
  para v21.3.0-alpha.1, um SDK alpha da API preview)
- config do gateway stripe (STRIPE_APIKEY, customer_column stripe_id)
- StripeGateway com os 17 métodos do contrato: Customer completo
  (create/update/get/setCustomerDefaultCard) e os demais lançando
  GatewayException clara de não implementado; versão da API fixada em
  2026-07-29.dahlia no client
- mapeamento customerToStripeData/parseCustomer no padrão da Iugu:
  tax id via tax_id_data/tax_ids (com sync create-antes-de-delete no
  update), bairro/país/nascimento em metadata, telefone concatenado
  +{país}{DDD}{número} com decomposição no parse
- testes unitários com fake da camada HTTP do stripe-php
  (ApiRequestor::setHttpClient, resetado no tearDown) e caso stripe no
  dataProvider do CustomerBuilderTest (sandbox real)
- sleep(12) do TestCase condicionado aos testes que usam a Iugu
- STRIPE_APIKEY no phpunit.xml.dist e nos secrets da CI
Fase 2 do gateway Stripe:

- createInvoice para credit_card: PaymentIntent confirm+off_session
  síncrono; um método por fatura (multi-método não tem equivalente
  server-side no Stripe); boleto lança exceção clara (fora do escopo)
- createCreditCard token-only (dados crus lançam exceção orientando a
  tokenização client-side; tok_ legado vira PaymentMethod antes do
  attach; description em metadata; default via invoice_settings),
  getCreditCard/deleteCreditCard com validação de posse
- getInvoice/parseInvoice com expand de latest_charge.balance_transaction
  (charge failed não alimenta paidAmount; fee de cartão é assíncrono) e
  status derivado do par PaymentIntent+charge (estorno não muda o status
  do PaymentIntent; pix expirado reporta pending e segue re-cobrável)
- chargeInvoiceWithCreditCard atualiza o PaymentIntent (types + customer)
  antes do confirm, recusando cartão de outro customer
- ChargingException ganha $reason normalizada (card_declined,
  brand_not_supported, authentication_required...) para a aplicação
  decidir fallback de gateway; recusa no attach (a Stripe valida o
  cartão nesse ponto) também vira ChargingException
- GatewayException::getErrors() normaliza errors nulo/string/objeto
  (antes fatalava com TypeError quando nulo)
- MultiPayment::setDefaultCard passa a propagar o gateway selecionado
  (antes caía no gateway default, ignorando setGateway)
Fase 3 do gateway Stripe:

- createInvoice para pix: PaymentIntent criado e confirmado 100%
  server-side (payment_method_data inline com billing_details
  name/email/tax_id); QR code, copia-e-cola, url hospedada e expiração
  parseados de next_action
- validação antecipada de Customer::taxDocument (produção exige CPF/CNPJ
  no billing_details; a sandbox não valida) e da janela de expires_at
  aceita pela Stripe (mais de 10s e menos de 14 dias no futuro —
  verificado na sandbox; na Iugu expires_at é due_date date-only)
- cancelInvoice (estados não-terminais; fatura paga vira GatewayException
  com payment_intent_unexpected_state)
- idempotency key aceita via gatewayAdicionalOptions['idempotency_key'] e
  enviada como cabeçalho da requisição nos creates de PaymentIntent
- fatura pix expirada segue pendente e re-cobrável com cartão (fluxo
  coberto por teste de integração na sandbox, com pagamento e expiração
  simulados pelos e-mails mágicos)
Fase 4 do gateway Stripe:

- refundInvoice total e parcial via /v1/refunds (refundedAmount
  preenchido = parcial, mesma semântica da Iugu), com refetch do
  PaymentIntent para reparse e idempotency key opcional via
  gatewayAdicionalOptions
- duplicateInvoice emulado (o PaymentIntent não tem duplicate nativo):
  restrito a faturas pix pendentes; recria com os dados da original
  (customer, valor, metadata preservado) e a nova expiração, e só então
  cancela a original — se a criação falhar, o consumidor não fica sem
  fatura; se o cancel falhar, a exceção informa o id da duplicata
- CPF/CNPJ da duplicata cai para os billing_details do PaymentMethod
  original quando o customer da Stripe não tem tax id (fatura criada com
  o documento apenas no model)
- MultiPayment::duplicateInvoice passa a propagar o gateway selecionado
  ao model (mesmo bug de gateway default corrigido em setDefaultCard)
- README: seção Gateways com a matriz de suporte por operação
  (Iugu × Stripe) e as particularidades do Stripe (cartão token-only e
  bandeiras aceitas, razões normalizadas de recusa para fallback de
  gateway, pix com tax_document obrigatório e janela de expiração,
  fatura expirada re-cobrável, url/fee, idempotency key); exemplos de
  refund/cancel/duplicate/chargeInvoiceWithCreditCard; STRIPE_APIKEY na
  configuração; tabela do charge anotada com as diferenças por gateway
- Facade: anotações @method que faltavam (getInvoice, getCustomer,
  refundInvoice, duplicateInvoice) e tipo de retorno do charge corrigido
…stir

Sem a guarda, uma fatura pix criada no Stripe com automatic_pix
preenchido era criada como pix comum, descartando a recorrência
silenciosamente. Agora lança GatewayException clara, como as demais
operações de Pix Automático (integração pendente da habilitação do
recurso na conta). README registra a pendência na seção Pix Automático.
Adiciona o domínio de assinatura recorrente ao pacote: SubscriptionContract e
PlanContract, os models genéricos (Subscription, SubscriptionItem,
SubscriptionDiscount, SubscriptionPlanChange, Plan), o SubscriptionBuilder e a
implementação completa no IuguGateway.

Os dois contracts ficam fora da composição de GatewayContract enquanto só a Iugu
os implementa; o StripeGateway entra numa fase seguinte.

Decisões de vocabulário:

- Desconto é conceito próprio, não item de preço negativo. Na Iugu vira subitem
  de price_cents negativo, e a volta separa item de desconto pelo sinal.
- Itens e descontos são declarativos no update: a lista informada vira o estado
  da assinatura, e a que ficar em null é preservada. A Iugu recusa remover e
  adicionar subitens na mesma chamada, então a remoção sai numa requisição
  própria e anterior.
- nextBillingAt mapeia para expires_at, que na Iugu é a data da próxima
  cobrança. O update só reenvia data e métodos de pagamento quando mudaram em
  relação à leitura.
- past_due não existe na Iugu e é derivado de expires_at no passado somado a
  qualquer fatura de recent_invoices ainda em aberto, olhando todas e não só a
  escolhida como latestInvoice. A escolha do latestInvoice é regra separada:
  fatura em aberto tem preferência, e entre as do mesmo estado vence a de maior
  vencimento.
- O que a Iugu não faz lança GatewayException: cancelar ao fim do período,
  desconto percentual, desconto limitado a mais de um ciclo, plano anual e
  desativar plano.

As chamadas de assinatura usam iuguRequest() (antes automaticPixRequest), porque
o SDK engole exceção em suspend, activate, change_plan e search, e não tem
change_plan_simulation.

Claude-Session: https://claude.ai/code/session_0144j5PoRvSrwbuv97gesV8q
- composer.json: php ^8.3, illuminate ^10|^11|^12, stripe-php ^21.3,
  phpunit ^12.0, orchestra/testbench ^10 (lock resolvido em PHP 8.3)
- phpunit.xml.dist migrado para o schema 12.5, com failOnWarning e
  failOnNotice preservando o comportamento do PHPUnit 9
- testes: @dataProvider e @group viram atributos, data providers estáticos,
  TestCase sem construtor (final no PHPUnit 12) e sem o construtor legado
  que bootava a aplicação na carga da suíte
- models: parâmetro $gateway com nullable explícito (deprecação do PHP 8.4)
- Dockerfile com ARG PHP_VERSION e workflow em matriz 8.3 e 8.4
- README: requisitos PHP 8.3+ e Laravel 10+
… de planos

A Iugu só aceita interval_type weeks e months, mas suporta plano anual como
interval = 12 meses. O driver recusava Plan::INTERVAL_YEAR por não traduzir.

- na ida, year vira 12 * intervalCount com interval_type months
- na volta, months com interval múltiplo de 12 lê como year com
  intervalCount / 12 (heurística documentada: 24 meses lê como 2 anos)
- guarda de faixa 1 a 599 do interval da Iugu antes da requisição
- intervalo desconhecido continua lançando antes da rede
- testes unitários de ida e volta e teste de integração na sandbox

Claude-Session: https://claude.ai/code/session_018kJFQbFYkJanVw2x5Ei1yk
…s de chamar o gateway

Cria RefundNotSupportedException (paymentMethod, reason, manualRefundRequired) e
lança antes de qualquer requisição: boleto nos dois drivers, Pix parcial, fatura
já estornada e prazo de 90 dias na Iugu. O driver Iugu lê a fatura antes (numa
cópia) quando o model não traz método, status, data de pagamento ou valor pago,
e passa a usar iuguRequest() para GET e POST de fatura, preservando o corpo de
erro que o refund() do SDK engolia. O Stripe guarda o id do refund em
Invoice::$lastRefundId.
… erros por status HTTP e anexa previous

Credencial inválida (401, 403 ou chave ausente) passa a lançar a nova
AuthenticationException nos dois drivers, em vez de GatewayNotAvailableException
ou GatewayException genérica. A base MultiPaymentException ganha `httpStatus` e
recebe a exceção do SDK como `previous`; todo throw dentro de catch a repassa.

Iugu: classificação centralizada em translateIuguException() e
iuguResponseException(), no lugar dos onze str_contains('502 Bad Gateway'). O
status vem de getCode() da IuguRequestException (resposta não JSON) ou da global
$iugu_last_api_response_code (resposta JSON de erro, que o SDK devolve sem
lançar). 5xx e cURL sem resposta viram GatewayNotAvailableException; 404, 409,
422 e 429 viram GatewayException com o status exposto. Iugu_PaymentToken::create()
e Iugu_Charge::invoice() entram no mesmo tratamento; o token passa a ser o `id`
da resposta, com erro de tokenização detectado antes de salvar o cartão.
duplicateInvoice(), updateCustomer() e deleteCreditCard() migram para request
cru, porque duplicate(), save() e delete() do SDK engolem exceção e devolviam
sucesso silencioso em 401 e 502. parseCustomer() lê `created_at` (o recurso de
cliente não tem `created_at_iso`; antes createdAt vinha como "agora").

Stripe: translateStripeException() mapeia AuthenticationException e
PermissionException do SDK para AuthenticationException, ApiConnectionException
e 5xx (inclusive página HTML, que o SDK lança como UnexpectedValueException)
para GatewayNotAvailableException, e preserva type/code/decline_code/param no
restante.

Testes: fakes ganham status HTTP (QueuedIuguResponse), instalação como requester
estático do SDK da Iugu e Throwable na fila do fake HTTP da Stripe;
ignoreIndirectDeprecations no phpunit.xml.dist filtra as deprecações de
ArrayAccess do SDK da Iugu ao carregar Iugu_Object.
…ns e documenta responsabilidades do Pix Automático
…inertes do InvoiceBuilderTest

Ativa as quatro asserções de gatewayOptions que usavam in_array no lugar de
array_key_exists e nunca rodavam; os dois casos afetados passam na sandbox.
Grava a resposta real de change_plan_simulation da Iugu como fixture e aponta o
teste unitário de preview para ela. Cobre em teste unitário o merge de
gatewayOptions no payload de criação de fatura da Iugu. Documenta no README que
cartão salvo no Stripe sem SetupIntent pode ser recusado na primeira cobrança
com authentication_required.
…tervalo por enums com o conjunto completo de estados
…o não suportada em UnsupportedOperationException

Cada driver implementa DeclaresCapabilities (capabilities(), notYetImplemented(), supports());
o que fica fora das duas listas é limitação do gateway. Toda operação fora das capabilities
lança UnsupportedOperationException (capability, gateway, reason) antes de qualquer
requisição, inclusive antes de criar o cliente que acompanha fatura e assinatura. Substitui
operationNotImplemented() do Stripe, o methodNotFound de despacho e as checagens de
instanceof de contract; RefundNotSupportedException passa a herdar da nova exceção.

MultiPayment e Facade expõem gateway(), supports(), capabilities() e notYetImplemented().
O README ganha a seção Capabilities com a matriz gerada por composer capabilities:table, com
teste unitário que falha se o README ficar defasado.
…IdempotencyConflict e Validation e normaliza códigos de recusa

CardDeclinedException passa a ser a recusa de cartão, com declineCode
(enum DeclineCode), gatewayCode e retryable; ChargingException vira o
nome antigo, subclasse dela, ainda instanciada nos drivers para o catch
antigo continuar capturando. ValidationException (400/422, fieldErrors),
NotFoundException (404), RateLimitException (429, retryAfter) e
IdempotencyConflictException (409) herdam de GatewayException.

Os mapas de código de recusa ficam em src/Gateways/Iugu/DeclineCodes.php
(Tabela de LRs, lida do campo LR ou do texto "LR: xx", sem zeros à
esquerda) e src/Gateways/Stripe/DeclineCodes.php (decline_code e code,
com advice_code decidindo retryable). Código fora da tabela vira UNKNOWN
com o original preservado e registro info no log.

Na Iugu, classifyIuguFailure() escolhe a classe pelo status HTTP. Na
Stripe, translateStripeException() escolhe pela classe do SDK, e a
CardException vira recusa em qualquer operação, inclusive no attach do
cartão; o Retry-After é lido do CaseInsensitiveArray que o SDK entrega.

Claude-Session: https://claude.ai/code/session_018kJFQbFYkJanVw2x5Ei1yk
…a e guardas de segundo estorno

refundInvoice() passa a devolver Refund (id, invoiceId, amount, status em RefundStatus,
reason, createdAt, original) nos dois drivers, com a fatura relida em $refund->invoice()
e o model do chamador atualizado no lugar. Invoice::$refunds é preenchida na leitura:
no Stripe a partir dos refunds do charge (expand latest_charge.refunds), na Iugu um
único registro sintético com o acumulado. Guarda amount_exceeds_refundable nos dois
drivers, leitura prévia da fatura no Stripe antes das guardas, Invoice::__clone com
cópia dos objetos aninhados e lastRefundId removido.

Claude-Session: https://claude.ai/code/session_018kJFQbFYkJanVw2x5Ei1yk
…como cabeçalho e cobre endpoints da Iugu com store própria

- toda operação de escrita recebe `?string $idempotencyKey = null` como último
  argumento (contracts, drivers, models, MultiPayment, Facade, Trait) e os
  builders ganham `withIdempotencyKey()`; o cliente criado junto com fatura ou
  assinatura recebe `{chave}:customer`
- Stripe: a chave vai em `Idempotency-Key` em todo POST, com chave derivada nas
  requisições secundárias; com chave, as guardas de estado (estorno, duplicação,
  exclusão de cartão, tax id) cedem ao replay da Stripe
- Iugu: cabeçalho nos quatro endpoints que o aceitam (fatura, cobrança, cliente,
  assinatura) e `IdempotencyStore` nos demais métodos de escrita
  (`CacheIdempotencyStore` sobre o cache do Laravel com lock, registrada pelo
  provider; `InMemoryIdempotencyStore` para testes); na reutilização da chave a
  Iugu responde 409 com `resource_id`, e o driver relê a fatura original
- fork Potelo/iugu-php 1.1.0 (cabeçalhos por requisição, status e cabeçalhos da
  resposta por instância): o driver deixa de usar os recursos estáticos do SDK,
  `lastIuguHttpStatus()` lê da instância e `RateLimitException::$retryAfter` é
  preenchido na Iugu
- `gateway_options['idempotency_key']` continua aceito com E_USER_DEPRECATED e
  fica fora do corpo da requisição
- corrige `Invoice::fill(['amount' => ...])`, que zerava o item recém-criado

Claude-Session: https://claude.ai/code/session_018kJFQbFYkJanVw2x5Ei1yk
…nforme ADR 0005 e fill() estrito nos models

- Invoice::$originType (enum InvoiceOriginType: PAYMENT_INTENT ou INVOICE), preenchido nos dois drivers; na Iugu é sempre INVOICE.
- StripeGateway: parseInvoice() despacha para parseFromPaymentIntent() (comportamento anterior intacto) ou parseFromStripeInvoice() (fatura de assinatura, só leitura); deriveStatus() concentra a derivação de status com a tabela de precedência (o Invoice manda no ciclo de vida, PaymentIntent e charge refinam; combinação fora da tabela é UNKNOWN com aviso no log).
- getInvoice() aceita pi_ e in_ e decide pelo prefixo; o Invoice é lido com expand de payments e o PaymentIntent relido só quando já tem charge (o expand da Stripe para em quatro níveis).
- cancelInvoice() anula o Invoice (void) depois de ler a fatura; rascunho lança GatewayException. duplicateInvoice() recusa in_ (INVOICE_DUPLICATION); refundInvoice() e chargeInvoiceWithCreditCard() recusam in_ (SUBSCRIPTIONS, not_implemented).
- Pagamento fora da Stripe é lido pelo InvoicePayment do tipo payment_record (amount_paid_off_stripe não vem na API 2026-07-29.dahlia); parsePixDisplay() lê next_action com isset() para um next_action de 3DS não sujar o log.
- Model::fill() estrito: chave sem propriedade lança ModelAttributeValidationException::unknownAttribute() com Model::fillableKeys(); prefixo gateway_ e o conteúdo de gateway_options ficam livres; multi-payment.strict_fill desliga na migração.
- Fixtures reais da sandbox em tests/fixtures/stripe/ (README separa o gravado do montado); testes unitários por linha da tabela e de fill() estrito; testes de integração que criam o Invoice no SDK e o leem e anulam pela lib.
- README: seções "Fatura no Stripe: duas origens" e "fill() estrito", tabela de status com as duas origens, correção de customer.birth_date na tabela de charge().
… isTerminal() para fatura expirada

Subscription::$status passa a ser o enum SubscriptionStatus (nove estados, com
isActive(), isRecoverable() e isEnded()), pelo mesmo mecanismo de ENUM_CASTS e
AcceptsUnknownValue da fatura; as constantes STATUS_* continuam com o mesmo valor,
deprecadas.

Na Iugu, cancelSubscription() suspende e grava a marca mp_canceled_at em
custom_variables numa segunda requisição; suspended com a marca lê como CANCELED,
resumeSubscription() remove a marca, e active falso com expires_at no passado sem
fatura em aberto lê como EXPIRED. O mapa de status da Stripe fica em
Gateways\Stripe\SubscriptionStatuses, com uma fixture por status.

InvoiceStatus::isTerminal() deixa de incluir EXPIRED, que continua pagável nos dois
gateways, e isPayable() passa a responder essa pergunta.

Claude-Session: https://claude.ai/code/session_018kJFQbFYkJanVw2x5Ei1yk
…eDate de pixExpiresAt e adiciona cartão e trial em dias na assinatura

Claude-Session: https://claude.ai/code/session_018kJFQbFYkJanVw2x5Ei1yk
…prévia e expõe getSubscription e getPlan na fachada
…tSupported, adiciona refundableAmount e restrições consultáveis

Nenhuma GatewayException nasce mais de regra local: regra de valor (page e limit,
plano com id, nextBillingAt diferente de trialEndsAt, PlanInterval::DAY e teto de
599 na Iugu) vira ModelAttributeValidationException; restrição de gateway (cartão de
outro cliente, rascunho não anulável e duplicação sem cliente no Stripe) vira
UnsupportedOperationException::restricted(); driver que declara capability sem o
contract ou sem o método de despacho vira ConfigurationException. Um teste percorre
src/ com o tokenizer e falha se GatewayException for criada fora dos
classificadores ou de um catch.

RefundNotSupportedException passa a herdar direto de MultiPaymentException, com
isCapabilityLimitation() separando boleto e Pix parcial (capability preenchida) de
fatura já estornada, valor acima do restante e prazo vencido.

O valor do estorno vira argumento: Invoice::refund(?int $amount, ?string $key) e
refundInvoice(Invoice, ?int $amount, ?string $key) no contract; refundableAmount()
na fachada, no model e nos drivers (paid_cents na Iugu, pago menos estornado na
Stripe). Invoice::$refundedAmount fica só de leitura, com o caminho antigo de
escrever nela aceito com E_USER_DEPRECATED; num model lido do gateway, refund() sem
valor estorna o restante. A Stripe deixa de reler a fatura parcialmente estornada
no estorno por valor.

Capabilities ganham restrições consultáveis: CapabilityRestriction, restrictions(),
restriction() e supportsAll() na interface, no trait, nos drivers e na fachada;
capability nova INVOICE_CANCELLATION; coluna "Restrições" na tabela gerada;
configuração multi-payment.gateways.iugu.max_installments.
…a autenticação do pagador

- CreditCardContract ganha confirmCreditCardSetup(); MultiPayment, Facade e CreditCard::confirmSetup() a expõem
- StripeGateway::createCreditCard() cria e confirma um SetupIntent (usage off_session): succeeded devolve o cartão cobrável, requires_action devolve requiresAction, setupId, clientSecret e actionUrl sem anexar, recusa é ChargingException
- Capability CARD_SETUP_AUTHENTICATION: Stripe sim, Iugu limitação do gateway (Zero Auth não autentica o portador)
- Venda avulsa com token que exige autenticação lança AUTHENTICATION_REQUIRED antes do PaymentIntent
- Fixtures de SetupIntent gravadas na sandbox, testes unitários e de integração, README com o fluxo de 3DS
…, nativos no Stripe e emulados na Iugu via custom_variables
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant