Skip to content

Treat Stripe charge_already_captured as successful capture#419

Open
55728 wants to merge 1 commit into
spree-contrib:mainfrom
55728:fix/stripe-already-captured
Open

Treat Stripe charge_already_captured as successful capture#419
55728 wants to merge 1 commit into
spree-contrib:mainfrom
55728:fix/stripe-already-captured

Conversation

@55728
Copy link
Copy Markdown

@55728 55728 commented Apr 11, 2026

Summary

Fixes spree/spree#11974.

When a Stripe charge is captured directly from the Stripe dashboard (or by any other out-of-band actor) and an admin subsequently clicks Capture in Spree, ActiveMerchant's StripeGateway#capture returns a failed Response whose params['error']['code'] is charge_already_captured. Spree currently propagates that failure and transitions the payment to failed, even though Stripe already holds the funds. The order is then stuck in an inconsistent state (Stripe: captured, Spree: failed) that can only be resolved manually.

This PR teaches Spree::Gateway::StripeGateway#capture to recognize that specific Stripe error code and treat it as a successful capture, wrapping the original params in a new ActiveMerchant::Billing::Response.new(true, …) that carries the original response_code as the authorization. Any other failure (e.g. card_declined) is passed through unchanged.

Why spree_gateway and not spree/spree?

Stripe integration for Spree lives entirely in this gem — there is no Stripe-specific code left in spree/spree. The capture method that Spree's payment state machine ultimately calls is Spree::Gateway::StripeGateway#capture here, so this is the correct layer to intercept the Stripe response.

A more complete long-term fix would be a Stripe webhook handler that reconciles Spree's payment state with Stripe events asynchronously, but that is a larger, separate piece of work and is intentionally out of scope for this PR.

Changes

  • app/models/spree/gateway/stripe_gateway.rb#capture now inspects the provider response. If it failed with error.code == 'charge_already_captured', it returns a successful ActiveMerchant::Billing::Response with the original charge id as the authorization. All other responses (success or failure) are returned unchanged. A small is_a?(Hash) guard protects against providers that return a non-hash params (some ActiveMerchant response shapes).
  • spec/models/gateway/stripe_gateway_spec.rb — new top-level describe block with four regression examples:
    1. charge_already_capturedsuccess? == true
    2. authorization equals the original response_code
    3. The original Stripe error params are preserved on the response
    4. A different error (card_declined) is passed through as a failure (regression guard)
      The new block is deliberately placed at the top level (not nested inside the existing describe Spree::Gateway::StripeGateway) so it uses modern allow(...).to receive(...) syntax and is not affected by the pre-existing legacy-syntax before hook. See "Out of scope" below.
  • Two existing 'capturing' examples had .and_return(double(success?: true)) appended to their should_receive stubs. This is required because #capture now inspects response.success?; without a return value the stubbed provider.capture returns nil and the method raises NoMethodError on nil.success?.

Test plan

  • New regression spec Spree::Gateway::StripeGateway#capture when Stripe already captured the charge4 examples, 0 failures locally.
  • Manual logic verification with a standalone script that builds an ActiveMerchant::Billing::Response with the charge_already_captured payload and asserts success?, authorization, and params on the wrapped response.

Out of scope

The pre-existing examples in spec/models/gateway/stripe_gateway_spec.rb use rspec-mocks 2.x syntax (subject.stub(…), should_receive) which is deprecated in rspec-mocks 3.x and currently fails without :should syntax explicitly enabled. This is a pre-existing condition on main (unrelated to this PR) and is not addressed here — modernizing the rest of the file is left for a separate PR to keep this change minimal and focused on the bug fix.

🤖 Generated with Claude Code

When a charge is captured directly from the Stripe dashboard and then
captured again from the Spree admin, ActiveMerchant returns a failed
response with error code 'charge_already_captured'. Spree then
transitions the payment to 'failed', even though Stripe already holds
the funds, leaving the order stuck.

Intercept that specific error in Spree::Gateway::StripeGateway#capture
and wrap it in a successful ActiveMerchant::Billing::Response that
carries the original charge id as the authorization, so the Spree
payment transitions to 'completed' as expected.

Fixes spree/spree#11974

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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.

Unable to capture payment

1 participant