Gateway Stripe completo: assinatura, plano, cupom, boleto e Pix Automático - #48
Closed
ramonsenadev wants to merge 32 commits into
Closed
Gateway Stripe completo: assinatura, plano, cupom, boleto e Pix Automático#48ramonsenadev wants to merge 32 commits into
ramonsenadev wants to merge 32 commits into
Conversation
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
… only_charge_on_due_date, confirmado na sandbox 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
…bscription e grava fixtures de webhook
…, nativos no Stripe e emulados na Iugu via custom_variables
…ndicionada à liberação da conta
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
SubscriptionStatuscom helpers (isActive(),isRecoverable(),isEnded()), cartão, método de pagamento e trial em dias no builder, política de pró-rata nomeada (ProrationBehavior),getSubscription()egetPlan()na fachada.paymentMethodhonrado na escrita nos dois drivers,dueDateseparado depixExpiresAt(comexpiresAtcomo alias deprecado),refundableAmount()e valor do estorno como argumento derefund().ModelAttributeValidationException, restrição de gateway éUnsupportedOperationException::restricted();GatewayExceptionfica só para resposta do gateway (com teste de varredura).RefundNotSupportedExceptionsai da árvore deUnsupportedOperationException.requiresAction/confirmCreditCardSetup()para 3DS.pix-automatico-stripe).custom_variablesde prefixomp_e aplicação pelo comandomultipayment:sync-subscriptions.composer capabilities:table.IdempotencyStorenos demais.O que fica para uma versão futura
in_): estorno e cobrança manual.