spree_stripe 1.8.2 → 6.0.0.beta1

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.
Files changed (100) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +41 -104
  4. data/Rakefile +7 -16
  5. data/app/jobs/spree_stripe/base_job.rb +1 -1
  6. data/app/jobs/spree_stripe/create_webhook_endpoint_job.rb +17 -2
  7. data/app/models/spree/payment_sessions/stripe.rb +45 -11
  8. data/app/models/spree_stripe/gateway/connect.rb +327 -0
  9. data/app/models/spree_stripe/gateway/payment_sessions.rb +225 -112
  10. data/app/models/spree_stripe/gateway/payment_setup_sessions.rb +15 -9
  11. data/app/models/spree_stripe/gateway/webhooks.rb +107 -0
  12. data/app/models/spree_stripe/gateway.rb +197 -244
  13. data/app/models/spree_stripe/payment_sources/ideal.rb +1 -1
  14. data/app/models/spree_stripe/payment_sources/przelewy24.rb +1 -1
  15. data/app/models/spree_stripe/payout_provider.rb +348 -0
  16. data/app/presenters/spree_stripe/customer_presenter.rb +7 -6
  17. data/app/presenters/spree_stripe/statement_descriptor_suffix_presenter.rb +11 -10
  18. data/app/services/spree_stripe/create_gateway_webhooks.rb +57 -33
  19. data/app/services/spree_stripe/create_payment.rb +46 -31
  20. data/app/services/spree_stripe/create_source.rb +22 -22
  21. data/app/services/spree_stripe/update_customer.rb +5 -4
  22. data/app/subscribers/spree_stripe/customer_updated_subscriber.rb +18 -0
  23. data/config/initializers/stripe.rb +1 -13
  24. data/config/locales/en.yml +4 -1
  25. data/lib/generators/spree_stripe/install/install_generator.rb +12 -12
  26. data/lib/spree_stripe/engine.rb +7 -38
  27. data/lib/spree_stripe/factories.rb +1 -3
  28. data/lib/spree_stripe/testing_support/factories/gateway_factory.rb +10 -9
  29. data/lib/spree_stripe/testing_support/factories/payment_session_factory.rb +50 -0
  30. data/lib/spree_stripe.rb +7 -8
  31. data/lib/tasks/migrate_webhook_keys.rake +128 -0
  32. metadata +29 -168
  33. data/app/assets/config/spree_stripe_manifest.js +0 -4
  34. data/app/assets/images/payment_icons/banktransfer.svg +0 -3
  35. data/app/assets/images/payment_icons/link.svg +0 -1
  36. data/app/assets/images/payment_icons/sepadebit.svg +0 -22
  37. data/app/controllers/spree_stripe/apple_pay_domain_verification_controller.rb +0 -11
  38. data/app/controllers/spree_stripe/confirm_payments_controller.rb +0 -52
  39. data/app/controllers/spree_stripe/store_controller_decorator.rb +0 -11
  40. data/app/controllers/stripe_event/webhook_controller_decorator.rb +0 -21
  41. data/app/helpers/spree_stripe/base_helper.rb +0 -42
  42. data/app/helpers/spree_stripe/checkout_helper_decorator.rb +0 -17
  43. data/app/javascript/spree_stripe/application.js +0 -18
  44. data/app/javascript/spree_stripe/controllers/stripe_button_controller.js +0 -431
  45. data/app/javascript/spree_stripe/controllers/stripe_controller.js +0 -234
  46. data/app/jobs/spree_stripe/attach_customer_to_credit_card_job.rb +0 -18
  47. data/app/jobs/spree_stripe/complete_order_from_session_job.rb +0 -12
  48. data/app/jobs/spree_stripe/create_tax_transaction_job.rb +0 -10
  49. data/app/jobs/spree_stripe/register_domain_job.rb +0 -20
  50. data/app/jobs/spree_stripe/update_customer_job.rb +0 -10
  51. data/app/models/spree_stripe/base.rb +0 -6
  52. data/app/models/spree_stripe/calculators/stripe_tax.rb +0 -96
  53. data/app/models/spree_stripe/credit_card_decorator.rb +0 -49
  54. data/app/models/spree_stripe/custom_domain_decorator.rb +0 -20
  55. data/app/models/spree_stripe/gateway/payment_intents.rb +0 -149
  56. data/app/models/spree_stripe/gateway_customer_decorator.rb +0 -9
  57. data/app/models/spree_stripe/order_decorator.rb +0 -9
  58. data/app/models/spree_stripe/payment_decorator.rb +0 -46
  59. data/app/models/spree_stripe/payment_method_decorator.rb +0 -18
  60. data/app/models/spree_stripe/payment_methods_webhook_key.rb +0 -8
  61. data/app/models/spree_stripe/payment_source_decorator.rb +0 -9
  62. data/app/models/spree_stripe/shipment_decorator.rb +0 -21
  63. data/app/models/spree_stripe/store_decorator.rb +0 -26
  64. data/app/models/spree_stripe/user_decorator.rb +0 -21
  65. data/app/models/spree_stripe/webhook_key.rb +0 -14
  66. data/app/presenters/spree_stripe/payment_intent_presenter.rb +0 -101
  67. data/app/presenters/spree_stripe/tax_presenter.rb +0 -63
  68. data/app/services/spree_stripe/complete_order.rb +0 -105
  69. data/app/services/spree_stripe/create_payment_session.rb +0 -18
  70. data/app/services/spree_stripe/register_domain.rb +0 -22
  71. data/app/services/spree_stripe/webhook_handlers/base.rb +0 -19
  72. data/app/services/spree_stripe/webhook_handlers/payment_intent_amount_capturable_updated.rb +0 -13
  73. data/app/services/spree_stripe/webhook_handlers/payment_intent_payment_failed.rb +0 -16
  74. data/app/services/spree_stripe/webhook_handlers/payment_intent_succeeded.rb +0 -11
  75. data/app/services/spree_stripe/webhook_handlers/setup_intent_succeeded.rb +0 -34
  76. data/app/subscribers/spree_stripe/order_completed_subscriber.rb +0 -14
  77. data/app/views/spree/admin/payment_methods/configuration_guides/_spree_stripe.html.erb +0 -0
  78. data/app/views/spree/admin/payment_methods/custom_form_fields/_spree_stripe.html.erb +0 -47
  79. data/app/views/spree/admin/payment_methods/descriptions/_spree_stripe.html.erb +0 -15
  80. data/app/views/spree/checkout/payment/_spree_stripe.html.erb +0 -66
  81. data/app/views/spree_stripe/_head.html.erb +0 -2
  82. data/app/views/spree_stripe/_quick_checkout.html.erb +0 -34
  83. data/config/i18n-tasks.yml +0 -173
  84. data/config/importmap.rb +0 -8
  85. data/config/initializers/spree.rb +0 -9
  86. data/config/routes.rb +0 -11
  87. data/db/migrate/20250310152812_setup_spree_stripe_models.rb +0 -24
  88. data/db/migrate/20260610120000_add_fingerprint_to_spree_credit_cards.rb +0 -52
  89. data/lib/spree_stripe/configuration.rb +0 -11
  90. data/lib/spree_stripe/testing_support/factories/after_pay_payment_source_factory.rb +0 -6
  91. data/lib/spree_stripe/testing_support/factories/alipay_payment_source_factory.rb +0 -6
  92. data/lib/spree_stripe/testing_support/factories/ideal_payment_source_factory.rb +0 -6
  93. data/lib/spree_stripe/testing_support/factories/klarna_payment_source_factory.rb +0 -6
  94. data/lib/spree_stripe/testing_support/factories/link_payment_source_factory.rb +0 -6
  95. data/lib/spree_stripe/testing_support/factories/przelewy24_payment_source_factory.rb +0 -6
  96. data/lib/spree_stripe/testing_support/factories/sepa_debit_payment_source_factory.rb +0 -6
  97. data/lib/spree_stripe/testing_support/factories/stripe_payment_session_factory.rb +0 -33
  98. data/lib/spree_stripe/testing_support/factories/webhook_key_factory.rb +0 -23
  99. data/lib/spree_stripe/version.rb +0 -7
  100. data/vendor/javascript/@stripe--stripe-js--dist--pure.esm.js.js +0 -4
@@ -1,12 +1,19 @@
1
1
  module SpreeStripe
2
2
  class Gateway < ::Spree::Gateway
3
+ # A Stripe payment session is a Stripe payment intent: creating the session
4
+ # creates the intent, and the session stores its id as the external id.
5
+ # Intents are Stripe-side API objects — there is no intent record in Spree.
3
6
  module PaymentSessions
4
7
  extend ActiveSupport::Concern
5
8
 
6
- # Intent states where nothing has been confirmed yet, so the intent can
7
- # safely be handed to another checkout window. Anything further along
8
- # means money is already moving and a second intent would double-charge.
9
- REUSABLE_INTENT_STATUSES = %w[requires_payment_method requires_confirmation].freeze
9
+ # Banks confirm these asynchronously, so `processing` is as good as accepted.
10
+ DELAYED_NOTIFICATION_PAYMENT_METHOD_TYPES = %w[sepa_debit us_bank_account].freeze
11
+ # Funds arrive out of band, leaving the intent in `requires_action`.
12
+ BANK_PAYMENT_METHOD_TYPES = %w[customer_balance us_bank_account].freeze
13
+ # next_action.type Stripe sets while an ACH debit awaits verification.
14
+ MICRODEPOSIT_VERIFICATION_ACTION = 'verify_with_microdeposits'.freeze
15
+ MANUAL_CAPTURE_METHOD = 'manual'.freeze
16
+ SETUP_FUTURE_USAGE = 'off_session'.freeze
10
17
 
11
18
  def session_required?
12
19
  true
@@ -16,45 +23,38 @@ module SpreeStripe
16
23
  Spree::PaymentSessions::Stripe
17
24
  end
18
25
 
19
- # Returns the order's reusable pending session (its amount and payment
20
- # method brought up to date) or creates a new one. An order gets at most
21
- # one unconfirmed Stripe intent at a time: a second checkout window
22
- # confirms the same intent — which Stripe rejects once the first one
23
- # succeeds — instead of charging a second one.
26
+ # @param order [Spree::Cart, Spree::Order]
27
+ # @return [Spree::PaymentSessions::Stripe]
24
28
  def create_payment_session(order:, amount: nil, external_data: {})
25
29
  total = amount.presence || order.total_minus_store_credits
26
30
  amount_in_cents = Spree::Money.new(total, currency: order.currency).cents
27
31
 
28
- raise Spree::Core::GatewayError, I18n.t('spree.stripe.payment_session_errors.zero_amount') if amount_in_cents.zero?
32
+ raise Spree::Core::GatewayError, Spree.t('stripe.payment_session_errors.zero_amount') if amount_in_cents.zero?
29
33
 
30
- reused_session = reuse_payment_session(order, total, external_data)
31
- return reused_session if reused_session.present?
32
-
33
- customer = fetch_or_create_customer(order: order)
34
- stripe_pm_id = external_data[:stripe_payment_method_id] || external_data['stripe_payment_method_id']
34
+ gateway_customer = fetch_or_create_customer(order: order)
35
+ stripe_payment_method_id = external_data[:stripe_payment_method_id] || external_data['stripe_payment_method_id']
35
36
 
36
37
  response = create_payment_intent(
37
38
  amount_in_cents, order,
38
- payment_method_id: stripe_pm_id,
39
- customer_profile_id: customer&.profile_id
39
+ payment_method_id: stripe_payment_method_id,
40
+ customer_profile_id: gateway_customer&.profile_id
40
41
  )
41
42
 
42
- ephemeral_key_response = create_ephemeral_key(customer.profile_id) if customer.present?
43
- ephemeral_key_secret = ephemeral_key_response&.params&.dig('secret')
43
+ ephemeral_key_response = create_ephemeral_key(gateway_customer.profile_id) if gateway_customer.present?
44
44
 
45
45
  payment_session_class.create!(
46
- order: order,
46
+ owner: order,
47
47
  payment_method: self,
48
48
  amount: total,
49
49
  currency: order.currency,
50
50
  status: 'pending',
51
51
  external_id: response.authorization,
52
- customer: order.user,
53
- customer_external_id: customer&.profile_id,
52
+ customer: order.customer,
53
+ customer_external_id: gateway_customer&.profile_id,
54
54
  external_data: {
55
55
  'client_secret' => response.params['client_secret'],
56
- 'ephemeral_key_secret' => ephemeral_key_secret,
57
- 'stripe_payment_method_id' => stripe_pm_id
56
+ 'ephemeral_key_secret' => ephemeral_key_response&.params&.dig('secret'),
57
+ 'stripe_payment_method_id' => stripe_payment_method_id
58
58
  }.compact
59
59
  )
60
60
  end
@@ -68,145 +68,258 @@ module SpreeStripe
68
68
  amount_in_cents = Spree::Money.new(amount, currency: payment_session.currency).cents
69
69
  end
70
70
 
71
- stripe_pm_id = external_data[:stripe_payment_method_id] || external_data['stripe_payment_method_id']
71
+ stripe_payment_method_id = external_data[:stripe_payment_method_id] || external_data['stripe_payment_method_id']
72
72
 
73
73
  update_payment_intent(
74
74
  payment_session.external_id,
75
75
  amount_in_cents || payment_session.amount_in_cents,
76
- payment_session.order,
77
- stripe_pm_id
76
+ payment_session.owner,
77
+ stripe_payment_method_id
78
78
  )
79
79
 
80
- if external_data.present?
81
- attrs[:external_data] = (payment_session.external_data || {}).merge(external_data.stringify_keys)
82
- end
80
+ attrs[:external_data] = (payment_session.external_data || {}).merge(external_data.stringify_keys) if external_data.present?
83
81
 
84
82
  payment_session.update!(attrs) if attrs.any?
85
83
  end
86
84
 
87
- # Completes a payment session by verifying with Stripe, creating the
88
- # Payment record, and patching wallet address data.
85
+ # Verifies the intent with Stripe, creates the Payment record and patches
86
+ # wallet address data.
89
87
  #
90
- # Does NOT complete the order — that is handled by Carts::Complete
91
- # (called by the storefront or by the webhook handler).
88
+ # Does NOT complete the order — Carts::Complete owns that.
92
89
  def complete_payment_session(payment_session:, params: {})
93
- stripe_pi = retrieve_payment_intent(payment_session.external_id)
90
+ stripe_payment_intent = retrieve_payment_intent(payment_session.external_id)
91
+
92
+ unless payment_intent_accepted?(stripe_payment_intent)
93
+ payment_session.fail if payment_session.can_fail?
94
+ return payment_session
95
+ end
94
96
 
95
- if payment_intent_accepted?(stripe_pi)
96
- payment_session.process if payment_session.can_process?
97
+ payment_session.process if payment_session.can_process?
97
98
 
98
- charge = payment_session.stripe_charge
99
+ charge = payment_session.stripe_charge
100
+ patch_wallet_address(payment_session.owner, charge) if charge.present?
99
101
 
100
- # Patch wallet billing address (Apple Pay, Google Pay)
101
- patch_wallet_address(payment_session.order, charge) if charge.present?
102
+ # `succeeded` is the only status where funds have moved. The other
103
+ # accepted statuses — requires_capture (manual capture), processing
104
+ # (delayed-notification banks), requires_action (bank transfer awaiting
105
+ # funds) — settle as authorization-only, completed later by webhook or
106
+ # explicit capture.
107
+ payment_session.settle_payment!(captured: payment_intent_successful?(stripe_payment_intent))
102
108
 
103
- # Create the Payment record
104
- payment_session.find_or_create_payment!
109
+ payment_session.complete unless payment_session.completed?
110
+ payment_session
111
+ end
105
112
 
106
- # `else` covers requires_capture (manual capture), processing (delayed-notification
107
- # banks), and requires_action (bank transfer awaiting funds) — all auth-only states.
108
- payment = payment_session.payment
109
- if payment.present? && !payment.completed?
110
- if payment_intent_successful?(stripe_pi)
111
- payment.process!
112
- else
113
- payment.authorize!
114
- end
115
- end
113
+ def retrieve_payment_intent(payment_intent_id)
114
+ send_request { |opts| Stripe::PaymentIntent.retrieve({ id: payment_intent_id, expand: ['payment_method'] }, opts) }
115
+ end
116
116
 
117
- payment_session.complete unless payment_session.completed?
118
- else
119
- payment_session.fail if payment_session.can_fail?
120
- end
117
+ def confirm_payment_intent(payment_intent_id)
118
+ send_request { |opts| Stripe::PaymentIntent.confirm(payment_intent_id, {}, opts) }
121
119
  end
122
120
 
123
- private
121
+ def capture_payment_intent(payment_intent_id, amount_in_cents)
122
+ send_request { |opts| Stripe::PaymentIntent.capture(payment_intent_id, { amount_to_capture: amount_in_cents }, opts) }
123
+ end
124
+
125
+ def cancel_payment_intent(payment_intent_id)
126
+ send_request { |opts| Stripe::PaymentIntent.cancel(payment_intent_id, {}, opts) }
127
+ end
128
+
129
+ # Whether the intent has progressed far enough to record a payment. The
130
+ # acceptable statuses depend on how the money moves: manual capture stops
131
+ # at `requires_capture`, delayed-notification banks sit in `processing`,
132
+ # and bank transfers wait for funds in `requires_action`.
133
+ def payment_intent_accepted?(payment_intent)
134
+ statuses = %w[succeeded]
135
+ statuses << 'requires_capture' if payment_intent_manual_capture?(payment_intent)
136
+ statuses << 'processing' if payment_intent_type_in?(payment_intent, DELAYED_NOTIFICATION_PAYMENT_METHOD_TYPES)
137
+ statuses << 'requires_action' if payment_intent_charge_not_required?(payment_intent) &&
138
+ !payment_intent_awaiting_microdeposits?(payment_intent)
139
+
140
+ payment_intent.status.in?(statuses)
141
+ end
124
142
 
125
- def reuse_payment_session(order, amount, external_data)
126
- session = order.payment_sessions.where(payment_method: self, status: 'pending').not_expired.order(:created_at).last
127
- return if session.blank?
143
+ def payment_intent_successful?(payment_intent)
144
+ payment_intent.status == 'succeeded'
145
+ end
146
+
147
+ def payment_intent_requires_capture?(payment_intent)
148
+ payment_intent.status == 'requires_capture'
149
+ end
150
+
151
+ # Bank transfers settle without a charge object, so the payment source has
152
+ # to be built from the intent instead.
153
+ def payment_intent_charge_not_required?(payment_intent)
154
+ payment_intent_type_in?(payment_intent, BANK_PAYMENT_METHOD_TYPES)
155
+ end
128
156
 
129
- intent = retrieve_payment_intent(session.external_id)
157
+ # ACH debits sit in requires_action until the customer confirms the two
158
+ # microdeposit amounts. Funds are not on their way yet, so the session
159
+ # must not settle — Stripe reports the eventual outcome by webhook.
160
+ def payment_intent_awaiting_microdeposits?(payment_intent)
161
+ next_action = payment_intent.try(:next_action)
162
+ return false unless next_action.respond_to?(:type)
130
163
 
131
- if intent.status == 'canceled'
132
- session.cancel if session.can_cancel?
133
- return
164
+ next_action.type.to_s == MICRODEPOSIT_VERIFICATION_ACTION
165
+ end
166
+
167
+ def payment_intent_manual_capture?(payment_intent)
168
+ payment_intent.respond_to?(:capture_method) && payment_intent.capture_method == MANUAL_CAPTURE_METHOD
169
+ end
170
+
171
+ private
172
+
173
+ # @param order [Spree::Cart, Spree::Order]
174
+ # @return [Spree::PaymentResponse]
175
+ def create_payment_intent(amount_in_cents, order, payment_method_id: nil, customer_profile_id: nil)
176
+ payload = {
177
+ amount: amount_in_cents,
178
+ currency: order.currency,
179
+ customer: customer_profile_id,
180
+ payment_method: payment_method_id,
181
+ # Stripe knows only "charge now" or "authorize now, capture later",
182
+ # so both on_dispatch and manual map to its manual capture.
183
+ capture_method: (MANUAL_CAPTURE_METHOD unless capture_at_checkout?),
184
+ statement_descriptor_suffix: statement_descriptor_suffix_for(order),
185
+ automatic_payment_methods: { enabled: true },
186
+ transfer_group: order.number,
187
+ metadata: { spree_order_id: order.id },
188
+ shipping: shipping_payload(order.ship_address)
189
+ }.compact
190
+
191
+ # A saved payment method is already tokenized; a new one is stored so it
192
+ # can be charged off-session later.
193
+ payload[:payment_method_options] = {
194
+ card: { setup_future_usage: SETUP_FUTURE_USAGE },
195
+ sepa_debit: { setup_future_usage: SETUP_FUTURE_USAGE }
196
+ } if payment_method_id.blank?
197
+
198
+ protect_from_error do
199
+ response = send_request { |opts| Stripe::PaymentIntent.create(payload, opts) }
200
+
201
+ success(response.id, response)
134
202
  end
203
+ end
204
+
205
+ # Only the fields that can legitimately change while a session is pending.
206
+ def update_payment_intent(payment_intent_id, amount_in_cents, order, payment_method_id = nil)
207
+ protect_from_error do
208
+ payload = {
209
+ amount: amount_in_cents,
210
+ currency: order.currency,
211
+ customer: fetch_or_create_customer(order: order)&.profile_id,
212
+ payment_method: payment_method_id,
213
+ shipping: shipping_payload(order.ship_address)
214
+ }.compact
135
215
 
136
- unless intent.status.in?(REUSABLE_INTENT_STATUSES)
137
- raise Spree::Core::GatewayError, I18n.t('spree.stripe.payment_session_errors.payment_in_progress')
216
+ response = send_request { |opts| Stripe::PaymentIntent.update(payment_intent_id, payload, opts) }
217
+
218
+ success(response.id, response)
138
219
  end
220
+ end
139
221
 
140
- update_payment_session(payment_session: session, amount: amount, external_data: external_data)
222
+ # Stripe requires address1 on a shipping address; Spree does not always
223
+ # demand one, so an incomplete address is omitted rather than rejected.
224
+ #
225
+ # @return [Hash, nil]
226
+ def shipping_payload(ship_address)
227
+ return if ship_address.blank?
228
+
229
+ # Validated on a duplicate: `invalid?` populates errors on the record,
230
+ # and clearing them afterwards would still stomp whatever the caller
231
+ # had collected on the live address.
232
+ return if ship_address.address1.blank? || ship_address.dup.invalid?
233
+
234
+ {
235
+ address: {
236
+ city: ship_address.city,
237
+ country: ship_address.country_code,
238
+ line1: ship_address.address1,
239
+ line2: ship_address.address2,
240
+ postal_code: ship_address.postal_code,
241
+ state: ship_address.state_code
242
+ },
243
+ name: ship_address.full_name
244
+ }
245
+ end
141
246
 
142
- session
247
+ def statement_descriptor_suffix_for(order)
248
+ SpreeStripe::StatementDescriptorSuffixPresenter.new(order_description: order.number).call
143
249
  end
144
250
 
145
- def payment_intent_successful?(stripe_pi)
146
- stripe_pi.status == 'succeeded'
251
+ def payment_intent_type_in?(payment_intent, types)
252
+ payment_method = payment_intent.payment_method
253
+ return false unless payment_method.respond_to?(:type)
254
+
255
+ payment_method.type.in?(types)
147
256
  end
148
257
 
149
- # Patches the order's billing address with data from the Stripe charge.
150
- # Needed for quick checkout (Apple Pay/Google Pay) where the storefront
151
- # doesn't have the billing address before payment confirmation.
152
- def patch_wallet_address(order, charge)
258
+ # Quick checkout (Apple Pay / Google Pay) confirms payment before the
259
+ # storefront has a billing address, so it arrives on the Stripe charge.
260
+ #
261
+ # @param owner [Spree::Cart, Spree::Order]
262
+ def patch_wallet_address(owner, charge)
153
263
  return if charge.blank?
154
264
 
155
265
  billing_details = charge.billing_details
156
266
  address = billing_details.address
157
267
 
158
- order.email ||= billing_details.email
159
- order.save! if order.email_changed?
268
+ owner.email ||= billing_details.email
269
+ owner.save! if owner.email_changed?
160
270
 
161
- # Skip if billing address is already valid
162
- return if order.bill_address.present? && order.bill_address.valid?
271
+ return if owner.bill_address.present? && owner.bill_address.valid?
163
272
 
164
- country_iso = address.country
165
- country = (country_iso.present? && Spree::Country.by_iso(country_iso)) ||
166
- order.store.default_market&.default_country ||
273
+ country = (address.country.presence && Spree::Country.by_iso(address.country)) ||
274
+ owner.store.default_market&.default_country ||
167
275
  Spree::Country.by_iso('US')
168
276
 
169
- order.bill_address ||= Spree::Address.new(country: country, user: order.user)
170
- order.bill_address.quick_checkout = true
171
-
172
- first_name = billing_details.name&.split(' ')&.first || order.ship_address&.first_name || order.user&.first_name
173
- last_name = billing_details.name&.split(' ')&.last || order.ship_address&.last_name || order.user&.last_name
174
-
175
- order.bill_address.first_name ||= first_name
176
- order.bill_address.last_name ||= last_name
177
- order.bill_address.phone ||= billing_details.phone
178
- order.bill_address.address1 ||= address.line1
179
- order.bill_address.address2 ||= address.line2
180
- order.bill_address.city ||= address.city
181
- order.bill_address.zipcode ||= address.postal_code
182
-
277
+ # Countries are reference data now, not records — addresses name one by
278
+ # ISO code rather than holding a foreign key.
279
+ owner.bill_address ||= Spree::Address.new(country_code: country.iso, owner: owner.customer)
280
+ owner.bill_address.quick_checkout = true
281
+
282
+ # Google Pay sometimes omits the name entirely.
283
+ first_name = billing_details.name&.split(' ')&.first || owner.ship_address&.first_name || owner.customer&.first_name
284
+ last_name = billing_details.name&.split(' ')&.last || owner.ship_address&.last_name || owner.customer&.last_name
285
+
286
+ owner.bill_address.first_name ||= first_name
287
+ owner.bill_address.last_name ||= last_name
288
+ owner.bill_address.phone ||= billing_details.phone
289
+ owner.bill_address.address1 ||= address.line1
290
+ owner.bill_address.address2 ||= address.line2
291
+ owner.bill_address.city ||= address.city
292
+ owner.bill_address.postal_code ||= address.postal_code
293
+
294
+ # Stripe sends whatever the wallet had — a code or a full name — so it
295
+ # is resolved to a current subdivision code. The free-text state_name
296
+ # is kept as the fallback for countries without subdivisions, and as a
297
+ # record of what the wallet actually said.
183
298
  state_name = address.state
184
299
  if country.states_required?
185
- order.bill_address.state = country.states.find_all_by_name_or_abbr(state_name)&.first
186
- else
187
- order.bill_address.state_name = state_name
300
+ owner.bill_address.state_code ||= Spree::IsoData.subdivision_code(country.iso, state_name)
188
301
  end
189
- order.bill_address.state_name ||= state_name
302
+ owner.bill_address.state_name ||= state_name
190
303
 
191
- if order.bill_address.invalid?
192
- return if order.ship_address.blank?
304
+ if owner.bill_address.invalid?
305
+ return if owner.ship_address.blank?
193
306
 
194
- order.bill_address = order.ship_address
307
+ owner.bill_address = owner.ship_address
195
308
  end
196
309
 
197
- order.bill_address.save! if order.bill_address&.changed?
198
- order.save!
310
+ owner.bill_address.save! if owner.bill_address&.changed?
311
+ owner.save!
199
312
 
200
- copy_bill_info_to_user(order) if order.user.present?
313
+ copy_bill_info_to_customer(owner) if owner.customer.present?
201
314
  end
202
315
 
203
- def copy_bill_info_to_user(order)
204
- user = order.user
205
- user.first_name ||= order.bill_address.first_name
206
- user.last_name ||= order.bill_address.last_name
207
- user.phone ||= order.bill_address.phone
208
- user.bill_address_id ||= order.bill_address.id
209
- user.save! if user.changed?
316
+ def copy_bill_info_to_customer(owner)
317
+ customer = owner.customer
318
+ customer.first_name ||= owner.bill_address.first_name
319
+ customer.last_name ||= owner.bill_address.last_name
320
+ customer.phone ||= owner.bill_address.phone
321
+ customer.bill_address_id ||= owner.bill_address.id
322
+ customer.save! if customer.changed?
210
323
  end
211
324
  end
212
325
  end
@@ -1,5 +1,6 @@
1
1
  module SpreeStripe
2
2
  class Gateway < ::Spree::Gateway
3
+ # Saving a payment method for later use, without taking a payment.
3
4
  module PaymentSetupSessions
4
5
  extend ActiveSupport::Concern
5
6
 
@@ -11,8 +12,10 @@ module SpreeStripe
11
12
  Spree::PaymentSetupSessions::Stripe
12
13
  end
13
14
 
15
+ # @param customer [Spree::Customer]
16
+ # @return [Spree::PaymentSetupSessions::Stripe]
14
17
  def create_payment_setup_session(customer:, external_data: {})
15
- gateway_customer = fetch_or_create_customer(user: customer)
18
+ gateway_customer = fetch_or_create_customer(customer: customer)
16
19
 
17
20
  setup_intent_response = create_setup_intent(gateway_customer.profile_id)
18
21
  ephemeral_key_response = create_ephemeral_key(gateway_customer.profile_id)
@@ -38,15 +41,18 @@ module SpreeStripe
38
41
 
39
42
  stripe_payment_method = stripe_setup_intent.payment_method
40
43
 
41
- source = SpreeStripe::CreateSource.new(
42
- stripe_payment_method_details: stripe_payment_method,
43
- stripe_payment_method_id: stripe_payment_method.id,
44
- stripe_billing_details: stripe_payment_method.billing_details,
45
- gateway: self,
46
- user: setup_session.customer
47
- ).call
44
+ # Persisted explicitly — a status transition writes status alone,
45
+ # never a ride-along for other dirty attributes.
46
+ setup_session.update!(
47
+ payment_source: SpreeStripe::CreateSource.new(
48
+ stripe_payment_method_details: stripe_payment_method,
49
+ stripe_payment_method_id: stripe_payment_method.id,
50
+ stripe_billing_details: stripe_payment_method.billing_details,
51
+ gateway: self,
52
+ customer: setup_session.customer
53
+ ).call
54
+ )
48
55
 
49
- setup_session.payment_source = source
50
56
  setup_session.complete unless setup_session.completed?
51
57
  else
52
58
  setup_session.fail if setup_session.can_fail?
@@ -0,0 +1,107 @@
1
+ module SpreeStripe
2
+ class Gateway < ::Spree::Gateway
3
+ # Webhook signature verification and endpoint registration.
4
+ #
5
+ # Signing secrets live in the gateway's own preferences. Before 6.0 they were
6
+ # rows in spree_stripe_webhook_keys joined to payment methods many-to-many;
7
+ # with a payment method now owned by exactly one store there is a single
8
+ # endpoint per gateway, so a preference carries the same data. Existing
9
+ # secrets are carried over by `spree:upgrade:migrate_stripe_webhook_keys`.
10
+ module Webhooks
11
+ extend ActiveSupport::Concern
12
+
13
+ # Only events with a handler — subscribing to more would silently drop
14
+ # deliveries and bake phantom events into the endpoint's identity.
15
+ # setup_intent.succeeded is deliberately absent: healing an abandoned
16
+ # setup session by webhook needs core's webhook contract to learn about
17
+ # setup sessions first (it only routes payment sessions today).
18
+ SUPPORTED_EVENTS = %w[
19
+ payment_intent.amount_capturable_updated
20
+ payment_intent.payment_failed
21
+ payment_intent.succeeded
22
+ ].freeze
23
+
24
+ WEBHOOK_EVENT_ACTIONS = {
25
+ 'payment_intent.succeeded' => :captured,
26
+ 'payment_intent.amount_capturable_updated' => :authorized,
27
+ 'payment_intent.payment_failed' => :failed
28
+ }.freeze
29
+
30
+ included do
31
+ # Both written by CreateGatewayWebhooks once Stripe has registered the
32
+ # endpoint — Stripe issues the secret and only reveals it at creation.
33
+ # An operator has nothing to type here, and a value typed over one
34
+ # would break the signature check it exists to satisfy.
35
+ preference :webhook_signing_secret, :password, internal: true
36
+ preference :webhook_endpoint_id, :string, internal: true
37
+
38
+ after_commit :create_webhook_endpoint_async, on: %i[create update]
39
+ end
40
+
41
+ # Translates a Stripe event into the normalized shape core's webhook
42
+ # controller and `Spree::Payments::HandleWebhook` consume. Everything
43
+ # after this — idempotency, locking, payment creation, order completion —
44
+ # belongs to core.
45
+ #
46
+ # @param raw_body [String]
47
+ # @param headers [Hash]
48
+ # @return [Hash, nil] nil for events this gateway does not act on
49
+ # @raise [Spree::PaymentMethod::WebhookSignatureError]
50
+ def parse_webhook_event(raw_body, headers)
51
+ event = verify_webhook_signature(raw_body, headers)
52
+
53
+ action = WEBHOOK_EVENT_ACTIONS[event.type]
54
+ return nil unless action
55
+
56
+ payment_session = Spree::PaymentSessions::Stripe.find_by(
57
+ payment_method: self,
58
+ external_id: event.data.object[:id]
59
+ )
60
+ return nil unless payment_session
61
+
62
+ { action: action, payment_session: payment_session }
63
+ end
64
+
65
+ def create_webhook_endpoint
66
+ SpreeStripe::CreateGatewayWebhooks.new.call(payment_method: self)
67
+ end
68
+
69
+ private
70
+
71
+ def verify_webhook_signature(raw_body, headers)
72
+ signature = headers['HTTP_STRIPE_SIGNATURE']
73
+
74
+ webhook_signing_secrets.each do |secret|
75
+ return Stripe::Webhook.construct_event(raw_body, signature, secret)
76
+ rescue Stripe::SignatureVerificationError
77
+ next
78
+ rescue JSON::ParserError
79
+ # construct_event parses before it verifies, so a malformed body
80
+ # never reaches the signature check. Unauthenticated garbage is a
81
+ # rejected webhook, not a server error to retry.
82
+ raise Spree::PaymentMethod::WebhookSignatureError, 'Malformed webhook payload'
83
+ end
84
+
85
+ raise Spree::PaymentMethod::WebhookSignatureError, 'Invalid webhook signature'
86
+ end
87
+
88
+ # Development only: the Stripe CLI signs forwarded events with its own
89
+ # secret rather than the endpoint's. Accepting it anywhere else would
90
+ # make one leaked value a forgery key for every gateway in the install.
91
+ def webhook_signing_secrets
92
+ secrets = [preferred_webhook_signing_secret]
93
+ secrets << ENV['STRIPE_SIGNING_SECRET'] if Rails.env.development?
94
+ secrets.select(&:present?)
95
+ end
96
+
97
+ # A stored secret means the endpoint is already registered — unless the
98
+ # key now points at a different Stripe account.
99
+ def create_webhook_endpoint_async
100
+ return if only_webhook_registration_changed?
101
+ return if preferred_webhook_signing_secret.present? && !stripe_secret_key_changed?
102
+
103
+ SpreeStripe::CreateWebhookEndpointJob.perform_later(id, connect: false)
104
+ end
105
+ end
106
+ end
107
+ end