spree_stripe 6.0.0.beta4 → 6.0.0.rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 761c677448596680810cd6380436150bd4ed2451322f2942e8033511c777176f
4
- data.tar.gz: 7d19dd53fe7b8c742df6d141ff166870f15c093c57081c90cd0d86efd572fb4b
3
+ metadata.gz: b65629d2703ce9659457a090c9baf88ef3f7685550a64e6a8c8e3c296531ad15
4
+ data.tar.gz: a82e689658e39723dd178006b7937793f128fbff6436a48f1e4ddb2e4f74eed1
5
5
  SHA512:
6
- metadata.gz: 36dc8fdf7281243405d91bf3e466429cba10b146430c93e46f10883391b47163fb5d03159314940f29c285ec380c9bc4d6fe80f89e2b535ef5850bc6873424f3
7
- data.tar.gz: 05ce651862abfee5a54a2716674edfc958016a8ff868ca7a98d31439e11e20207e4885568c2517a3e3ce42449d97d02c9b791742fc3e2b629305f30d960f0ef1
6
+ metadata.gz: a5bb7beea57c85809530e0601431e0292943b18636cbaeecffeba60b73cd1493ec13b9b825af477462eed2022b5ae85410461c5259b7258768121c38d451f6b3
7
+ data.tar.gz: 5972312842ff35f2ca34fdfdded24cd3f95ea89146951536ae9b53b8111013f6bfba70480cc399cd18b4940153f64651046aa998eac0c61b27189165c19016a4
@@ -15,6 +15,18 @@ module SpreeStripe
15
15
  MANUAL_CAPTURE_METHOD = 'manual'.freeze
16
16
  SETUP_FUTURE_USAGE = 'off_session'.freeze
17
17
 
18
+ # Not a decision about the intent. A timeout and an outage may not have
19
+ # arrived; a rate limit was turned away before Stripe looked; a bad key
20
+ # or a missing permission never reached the intent. A refusal of the
21
+ # cancel itself is an InvalidRequestError and stays out of this list.
22
+ RETRYABLE_STRIPE_ERRORS = [
23
+ Stripe::APIConnectionError,
24
+ Stripe::RateLimitError,
25
+ Stripe::APIError,
26
+ Stripe::AuthenticationError,
27
+ Stripe::PermissionError
28
+ ].freeze
29
+
18
30
  def session_required?
19
31
  true
20
32
  end
@@ -23,17 +35,29 @@ module SpreeStripe
23
35
  Spree::PaymentSessions::Stripe
24
36
  end
25
37
 
38
+ # Opens a new session on every call. Each earlier session that can still
39
+ # be paid is canceled first: every open intent can be paid on its own,
40
+ # so a reloaded checkout would charge the buyer twice. A session Stripe
41
+ # has already accepted, a card charge still in flight, or one this cart
42
+ # has already completed is left as it is and the request fails, because
43
+ # that money is not dropped to make room for another session.
44
+ #
26
45
  # @param order [Spree::Cart, Spree::Order]
27
46
  # @return [Spree::PaymentSessions::Stripe]
47
+ # @raise [Spree::Core::GatewayError] when a session has already been
48
+ # paid, or an earlier one could not be canceled
28
49
  def create_payment_session(order:, amount: nil, external_data: {})
29
50
  total = amount.presence || order.total_minus_store_credits
30
51
  amount_in_cents = Spree::Money.new(total, currency: order.currency).cents
31
52
 
32
- raise Spree::Core::GatewayError, Spree.t('stripe.payment_session_errors.zero_amount') if amount_in_cents.zero?
53
+ raise Spree::Core::GatewayError, I18n.t('spree.stripe.payment_session_errors.zero_amount') if amount_in_cents.zero?
33
54
 
34
- gateway_customer = fetch_or_create_customer(order: order)
35
55
  stripe_payment_method_id = external_data[:stripe_payment_method_id] || external_data['stripe_payment_method_id']
36
56
 
57
+ replace_open_sessions(order)
58
+
59
+ gateway_customer = fetch_or_create_customer(order: order)
60
+
37
61
  response = create_payment_intent(
38
62
  amount_in_cents, order,
39
63
  payment_method_id: stripe_payment_method_id,
@@ -110,6 +134,29 @@ module SpreeStripe
110
134
  payment_session
111
135
  end
112
136
 
137
+ # Stripe refuses once money has moved, and that refusal is raised so the
138
+ # session is never shown as canceled while its money stands. An intent
139
+ # already canceled at Stripe, or unknown to the account the keys now
140
+ # reach, can no longer be paid, so its session is canceled all the same.
141
+ # A timeout, a rate limit, an outage, or rejected credentials is raised
142
+ # as Stripe's own error: wrapped as a refusal, the unused-session job
143
+ # would count the intent handled while the buyer can still pay it.
144
+ #
145
+ # @param payment_session [Spree::PaymentSessions::Stripe]
146
+ # @return [Boolean] whether the session was canceled
147
+ # @raise [Spree::Core::GatewayError] when Stripe refuses
148
+ # @raise [Stripe::StripeError] when Stripe could not be asked
149
+ def cancel_payment_session(payment_session:)
150
+ cancel_payment_intent(payment_session.external_id)
151
+ payment_session.cancel
152
+ rescue *RETRYABLE_STRIPE_ERRORS
153
+ raise
154
+ rescue Stripe::StripeError => error
155
+ raise Spree::Core::GatewayError, error.message unless payment_intent_unpayable?(payment_session.external_id)
156
+
157
+ payment_session.cancel
158
+ end
159
+
113
160
  def retrieve_payment_intent(payment_intent_id)
114
161
  send_request { |opts| Stripe::PaymentIntent.retrieve({ id: payment_intent_id, expand: ['payment_method'] }, opts) }
115
162
  end
@@ -170,6 +217,60 @@ module SpreeStripe
170
217
 
171
218
  private
172
219
 
220
+ # Nothing is canceled until every still-payable intent has been read. A
221
+ # later one that Stripe has already accepted must not be reached after
222
+ # an earlier one was dropped. A completed session is the same refusal
223
+ # with no provider call: the cart already has its payment.
224
+ def replace_open_sessions(order)
225
+ sessions = order.payment_sessions.where(payment_method: self)
226
+ if sessions.exists?(status: 'completed')
227
+ raise Spree::Core::GatewayError, I18n.t('spree.stripe.payment_session_errors.payment_in_progress')
228
+ end
229
+
230
+ # A failed confirm leaves the intent payable. It is canceled with the
231
+ # open ones, unless its charge is still in flight.
232
+ open_sessions = sessions.active.order(:id).to_a + sessions.where(status: 'failed').order(:id).to_a
233
+ return if open_sessions.empty?
234
+
235
+ open_sessions.each do |payment_session|
236
+ payment_intent = find_payment_intent(payment_session.external_id)
237
+ next unless payment_intent && payment_intent_in_progress?(payment_intent)
238
+
239
+ raise Spree::Core::GatewayError, I18n.t('spree.stripe.payment_session_errors.payment_in_progress')
240
+ end
241
+
242
+ open_sessions.each do |payment_session|
243
+ cancel_payment_session(payment_session: payment_session)
244
+ end
245
+ rescue *RETRYABLE_STRIPE_ERRORS => error
246
+ # Checkout speaks GatewayError. The unused-session job does not: it
247
+ # retries these, and has to see them unwrapped.
248
+ raise Spree::Core::GatewayError, error.message
249
+ end
250
+
251
+ # A card can sit in `processing` while its charge is in flight.
252
+ # `payment_intent_accepted?` does not record that yet, and canceling
253
+ # the intent can drop or refund the charge.
254
+ def payment_intent_in_progress?(payment_intent)
255
+ payment_intent.status == 'processing' || payment_intent_accepted?(payment_intent)
256
+ end
257
+
258
+ # nil when the account the keys now reach has no such intent.
259
+ def find_payment_intent(payment_intent_id)
260
+ retrieve_payment_intent(payment_intent_id)
261
+ rescue Stripe::InvalidRequestError => error
262
+ raise Spree::Core::GatewayError, error.message unless error.code == 'resource_missing'
263
+ rescue *RETRYABLE_STRIPE_ERRORS
264
+ raise
265
+ rescue Stripe::StripeError => error
266
+ raise Spree::Core::GatewayError, error.message
267
+ end
268
+
269
+ def payment_intent_unpayable?(payment_intent_id)
270
+ payment_intent = find_payment_intent(payment_intent_id)
271
+ payment_intent.nil? || payment_intent.status == 'canceled'
272
+ end
273
+
173
274
  # @param order [Spree::Cart, Spree::Order]
174
275
  # @return [Spree::PaymentResponse]
175
276
  def create_payment_intent(amount_in_cents, order, payment_method_id: nil, customer_profile_id: nil)
@@ -22,6 +22,14 @@ module SpreeStripe
22
22
  validates :preferred_secret_key, :preferred_publishable_key, presence: true
23
23
  validate :validate_secret_key, unless: -> { Rails.env.test? }, if: -> { preferred_secret_key.present? }
24
24
 
25
+ def self.logo_url
26
+ 'https://spreecommerce.org/docs/images/integrations/logos/stripe.png'
27
+ end
28
+
29
+ def self.docs_url
30
+ 'https://spreecommerce.org/docs/integrations/payments/stripe'
31
+ end
32
+
25
33
  def provider_class
26
34
  self.class
27
35
  end
@@ -50,12 +58,10 @@ module SpreeStripe
50
58
  #
51
59
  # @return [Boolean]
52
60
  def only_webhook_registration_changed?
53
- before, after = saved_change_to_preferences
54
- return false unless before.is_a?(Hash) && after.is_a?(Hash)
55
-
56
- changed = (before.keys | after.keys).reject { |key| before[key] == after[key] }
61
+ return false if previously_new_record?
57
62
 
58
- changed.any? && changed.all? { |key| WEBHOOK_REGISTRATION_PREFERENCES.include?(key.to_sym) }
63
+ changed = previously_changed_preference_names
64
+ changed.any? && changed.all? { |key| WEBHOOK_REGISTRATION_PREFERENCES.include?(key) }
59
65
  end
60
66
 
61
67
  # Endpoints are registered per Stripe account, so a rotated key leaves the
@@ -67,10 +73,7 @@ module SpreeStripe
67
73
  # read as every key having changed.
68
74
  return false if previously_new_record?
69
75
 
70
- # The preference macro's own reader rather than a hand-rolled diff: it
71
- # reads the change through indifferent access, so it answers the same
72
- # whether the serialized hash came back with symbol or string keys.
73
- preferred_secret_key_previously_changed?
76
+ saved_change_to_preferred_secret_key?
74
77
  end
75
78
 
76
79
  def gateway_dashboard_payment_url(payment)
@@ -302,14 +305,14 @@ module SpreeStripe
302
305
  def validate_secret_key
303
306
  Stripe::Refund.list({ limit: 0 }, api_options)
304
307
  rescue Stripe::AuthenticationError
305
- errors.add(:base, :secret_key_invalid, message: Spree.t('stripe.errors.secret_key_invalid'))
308
+ errors.add(:base, :secret_key_invalid, message: I18n.t('spree.stripe.errors.secret_key_invalid'))
306
309
  rescue Stripe::PermissionError => e
307
310
  return unless e.error&.code == 'secret_key_required'
308
311
 
309
312
  errors.add(:base, :publishable_key_provided,
310
- message: Spree.t('stripe.errors.publishable_key_provided'))
313
+ message: I18n.t('spree.stripe.errors.publishable_key_provided'))
311
314
  rescue Stripe::StripeError
312
- errors.add(:base, :stripe_unavailable, message: Spree.t('stripe.errors.stripe_unavailable'))
315
+ errors.add(:base, :stripe_unavailable, message: I18n.t('spree.stripe.errors.stripe_unavailable'))
313
316
  end
314
317
 
315
318
  def build_customer_payload(order: nil, customer: nil)
@@ -6,11 +6,11 @@ module SpreeStripe
6
6
  end
7
7
 
8
8
  def self.display_name
9
- Spree.t(:bank_transfer)
9
+ I18n.t('spree.bank_transfer')
10
10
  end
11
11
 
12
12
  def name
13
- Spree.t(:bank_transfer)
13
+ I18n.t('spree.bank_transfer')
14
14
  end
15
15
  end
16
16
  end
@@ -7,6 +7,6 @@ en:
7
7
  publishable_key_provided: You have provided your publishable key instead of your secret key
8
8
  secret_key_invalid: Secret key is invalid
9
9
  stripe_unavailable: Something went wrong with Stripe. Try again later.
10
- payment_could_not_be_processed: Your payment could not be processed. Please try again.
11
10
  payment_session_errors:
11
+ payment_in_progress: A payment for this checkout is already in progress.
12
12
  zero_amount: Payment session amount must be greater than zero
@@ -5,12 +5,6 @@ module SpreeStripe
5
5
  isolate_namespace Spree
6
6
  engine_name 'spree_stripe'
7
7
 
8
- config.paths.add 'app/subscribers', eager_load: true
9
-
10
- config.generators do |g|
11
- g.test_framework :rspec
12
- end
13
-
14
8
  # Core assigns the payment method registry in its own after_initialize, so
15
9
  # appending has to happen in a later one — engine callbacks run in load order.
16
10
  config.after_initialize do
@@ -40,11 +40,14 @@ module SpreeStripe
40
40
  next
41
41
  end
42
42
 
43
+ # Through the preference writers, so the signing secret lands in the
44
+ # encrypted column; saved without callbacks, which would re-register
45
+ # the endpoint the secret belongs to.
46
+ gateway.preferred_webhook_endpoint_id = key.stripe_id
47
+ gateway.preferred_webhook_signing_secret = key.signing_secret
43
48
  gateway.update_columns(
44
- preferences: gateway.preferences.merge(
45
- webhook_endpoint_id: key.stripe_id,
46
- webhook_signing_secret: key.signing_secret
47
- ),
49
+ preferences: gateway.preferences,
50
+ secret_preferences: gateway.secret_preferences,
48
51
  updated_at: Time.current
49
52
  )
50
53
  migrated += 1
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_stripe
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.0.0.beta4
4
+ version: 6.0.0.rc1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vendo Connect Inc.
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2026-09-24 00:00:00.000000000 Z
12
+ date: 2026-10-08 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: spree_core
@@ -17,14 +17,14 @@ dependencies:
17
17
  requirements:
18
18
  - - '='
19
19
  - !ruby/object:Gem::Version
20
- version: 6.0.0.beta4
20
+ version: 6.0.0.rc1
21
21
  type: :runtime
22
22
  prerelease: false
23
23
  version_requirements: !ruby/object:Gem::Requirement
24
24
  requirements:
25
25
  - - '='
26
26
  - !ruby/object:Gem::Version
27
- version: 6.0.0.beta4
27
+ version: 6.0.0.rc1
28
28
  - !ruby/object:Gem::Dependency
29
29
  name: stripe
30
30
  requirement: !ruby/object:Gem::Requirement
@@ -110,9 +110,9 @@ licenses:
110
110
  - MIT
111
111
  metadata:
112
112
  bug_tracker_uri: https://github.com/spree/spree/issues
113
- changelog_uri: https://github.com/spree/spree/releases/tag/v6.0.0.beta4
113
+ changelog_uri: https://github.com/spree/spree/releases/tag/v6.0.0.rc1
114
114
  documentation_uri: https://docs.spreecommerce.org/
115
- source_code_uri: https://github.com/spree/spree/tree/v6.0.0.beta4
115
+ source_code_uri: https://github.com/spree/spree/tree/v6.0.0.rc1
116
116
  post_install_message:
117
117
  rdoc_options: []
118
118
  require_paths: