From 93ced1c1ca337644de756201d6f847218f6645b8 Mon Sep 17 00:00:00 2001 From: Kenta Ishizaki Date: Sat, 11 Apr 2026 11:57:39 +0900 Subject: [PATCH] Treat Stripe charge_already_captured as successful capture 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) --- app/models/spree/gateway/stripe_gateway.rb | 14 +++- spec/models/gateway/stripe_gateway_spec.rb | 75 +++++++++++++++++++++- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/app/models/spree/gateway/stripe_gateway.rb b/app/models/spree/gateway/stripe_gateway.rb index 45bc845e..46863f66 100644 --- a/app/models/spree/gateway/stripe_gateway.rb +++ b/app/models/spree/gateway/stripe_gateway.rb @@ -37,7 +37,19 @@ def authorize(money, creditcard, gateway_options) end def capture(money, response_code, gateway_options) - provider.capture(money, response_code, gateway_options) + response = provider.capture(money, response_code, gateway_options) + return response if response.success? + + if response.params.is_a?(Hash) && response.params.dig('error', 'code') == 'charge_already_captured' + return ActiveMerchant::Billing::Response.new( + true, + 'Charge was already captured on Stripe', + response.params, + authorization: response_code + ) + end + + response end def credit(money, creditcard, response_code, gateway_options) diff --git a/spec/models/gateway/stripe_gateway_spec.rb b/spec/models/gateway/stripe_gateway_spec.rb index 81b3f667..167bc145 100644 --- a/spec/models/gateway/stripe_gateway_spec.rb +++ b/spec/models/gateway/stripe_gateway_spec.rb @@ -143,11 +143,11 @@ end it 'convert the amount to cents' do - provider.should_receive(:capture).with(1234,anything,anything) + provider.should_receive(:capture).with(1234,anything,anything).and_return(double(success?: true)) end it 'use the response code as the authorization' do - provider.should_receive(:capture).with(anything,'response_code',anything) + provider.should_receive(:capture).with(anything,'response_code',anything).and_return(double(success?: true)) end end @@ -198,3 +198,74 @@ end end end + +# Regression for spree/spree#11974: when a charge is captured directly on the +# Stripe dashboard and then captured again from Spree, ActiveMerchant returns +# an error with code 'charge_already_captured'. We treat this as a successful +# capture so the Spree payment transitions to completed instead of failed. +# +# This describe intentionally sits outside the block above so it does not +# inherit the legacy `.stub` syntax used in the older specs. +describe Spree::Gateway::StripeGateway, '#capture when Stripe already captured the charge' do + let(:provider) { double('provider') } + + let(:gateway) do + described_class.new.tap do |g| + g.preferences = { secret_key: 'key' } + allow(g).to receive(:provider).and_return(provider) + end + end + + let(:already_captured_response) do + ActiveMerchant::Billing::Response.new( + false, + 'Charge ch_123 has already been captured.', + { + 'error' => { + 'code' => 'charge_already_captured', + 'message' => 'Charge ch_123 has already been captured.', + 'type' => 'invalid_request_error' + } + }, + {} + ) + end + + before do + allow(provider).to receive(:capture).and_return(already_captured_response) + end + + subject(:capture_result) { gateway.capture(1234, 'ch_123', {}) } + + it 'returns a successful response' do + expect(capture_result.success?).to be true + end + + it 'uses the original response_code as the authorization' do + expect(capture_result.authorization).to eq('ch_123') + end + + it 'preserves the Stripe error params on the response' do + expect(capture_result.params.dig('error', 'code')).to eq('charge_already_captured') + end + + context 'when Stripe returns a different error' do + let(:other_error_response) do + ActiveMerchant::Billing::Response.new( + false, + 'Your card was declined.', + { 'error' => { 'code' => 'card_declined' } }, + {} + ) + end + + before do + allow(provider).to receive(:capture).and_return(other_error_response) + end + + it 'returns the original failed response untouched' do + expect(capture_result.success?).to be false + expect(capture_result.params.dig('error', 'code')).to eq('card_declined') + end + end +end