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.
- checksums.yaml +4 -4
- data/LICENSE +21 -0
- data/README.md +41 -104
- data/Rakefile +7 -16
- data/app/jobs/spree_stripe/base_job.rb +1 -1
- data/app/jobs/spree_stripe/create_webhook_endpoint_job.rb +17 -2
- data/app/models/spree/payment_sessions/stripe.rb +45 -11
- data/app/models/spree_stripe/gateway/connect.rb +327 -0
- data/app/models/spree_stripe/gateway/payment_sessions.rb +225 -112
- data/app/models/spree_stripe/gateway/payment_setup_sessions.rb +15 -9
- data/app/models/spree_stripe/gateway/webhooks.rb +107 -0
- data/app/models/spree_stripe/gateway.rb +197 -244
- data/app/models/spree_stripe/payment_sources/ideal.rb +1 -1
- data/app/models/spree_stripe/payment_sources/przelewy24.rb +1 -1
- data/app/models/spree_stripe/payout_provider.rb +348 -0
- data/app/presenters/spree_stripe/customer_presenter.rb +7 -6
- data/app/presenters/spree_stripe/statement_descriptor_suffix_presenter.rb +11 -10
- data/app/services/spree_stripe/create_gateway_webhooks.rb +57 -33
- data/app/services/spree_stripe/create_payment.rb +46 -31
- data/app/services/spree_stripe/create_source.rb +22 -22
- data/app/services/spree_stripe/update_customer.rb +5 -4
- data/app/subscribers/spree_stripe/customer_updated_subscriber.rb +18 -0
- data/config/initializers/stripe.rb +1 -13
- data/config/locales/en.yml +4 -1
- data/lib/generators/spree_stripe/install/install_generator.rb +12 -12
- data/lib/spree_stripe/engine.rb +7 -38
- data/lib/spree_stripe/factories.rb +1 -3
- data/lib/spree_stripe/testing_support/factories/gateway_factory.rb +10 -9
- data/lib/spree_stripe/testing_support/factories/payment_session_factory.rb +50 -0
- data/lib/spree_stripe.rb +7 -8
- data/lib/tasks/migrate_webhook_keys.rake +128 -0
- metadata +29 -168
- data/app/assets/config/spree_stripe_manifest.js +0 -4
- data/app/assets/images/payment_icons/banktransfer.svg +0 -3
- data/app/assets/images/payment_icons/link.svg +0 -1
- data/app/assets/images/payment_icons/sepadebit.svg +0 -22
- data/app/controllers/spree_stripe/apple_pay_domain_verification_controller.rb +0 -11
- data/app/controllers/spree_stripe/confirm_payments_controller.rb +0 -52
- data/app/controllers/spree_stripe/store_controller_decorator.rb +0 -11
- data/app/controllers/stripe_event/webhook_controller_decorator.rb +0 -21
- data/app/helpers/spree_stripe/base_helper.rb +0 -42
- data/app/helpers/spree_stripe/checkout_helper_decorator.rb +0 -17
- data/app/javascript/spree_stripe/application.js +0 -18
- data/app/javascript/spree_stripe/controllers/stripe_button_controller.js +0 -431
- data/app/javascript/spree_stripe/controllers/stripe_controller.js +0 -234
- data/app/jobs/spree_stripe/attach_customer_to_credit_card_job.rb +0 -18
- data/app/jobs/spree_stripe/complete_order_from_session_job.rb +0 -12
- data/app/jobs/spree_stripe/create_tax_transaction_job.rb +0 -10
- data/app/jobs/spree_stripe/register_domain_job.rb +0 -20
- data/app/jobs/spree_stripe/update_customer_job.rb +0 -10
- data/app/models/spree_stripe/base.rb +0 -6
- data/app/models/spree_stripe/calculators/stripe_tax.rb +0 -96
- data/app/models/spree_stripe/credit_card_decorator.rb +0 -49
- data/app/models/spree_stripe/custom_domain_decorator.rb +0 -20
- data/app/models/spree_stripe/gateway/payment_intents.rb +0 -149
- data/app/models/spree_stripe/gateway_customer_decorator.rb +0 -9
- data/app/models/spree_stripe/order_decorator.rb +0 -9
- data/app/models/spree_stripe/payment_decorator.rb +0 -46
- data/app/models/spree_stripe/payment_method_decorator.rb +0 -18
- data/app/models/spree_stripe/payment_methods_webhook_key.rb +0 -8
- data/app/models/spree_stripe/payment_source_decorator.rb +0 -9
- data/app/models/spree_stripe/shipment_decorator.rb +0 -21
- data/app/models/spree_stripe/store_decorator.rb +0 -26
- data/app/models/spree_stripe/user_decorator.rb +0 -21
- data/app/models/spree_stripe/webhook_key.rb +0 -14
- data/app/presenters/spree_stripe/payment_intent_presenter.rb +0 -101
- data/app/presenters/spree_stripe/tax_presenter.rb +0 -63
- data/app/services/spree_stripe/complete_order.rb +0 -105
- data/app/services/spree_stripe/create_payment_session.rb +0 -18
- data/app/services/spree_stripe/register_domain.rb +0 -22
- data/app/services/spree_stripe/webhook_handlers/base.rb +0 -19
- data/app/services/spree_stripe/webhook_handlers/payment_intent_amount_capturable_updated.rb +0 -13
- data/app/services/spree_stripe/webhook_handlers/payment_intent_payment_failed.rb +0 -16
- data/app/services/spree_stripe/webhook_handlers/payment_intent_succeeded.rb +0 -11
- data/app/services/spree_stripe/webhook_handlers/setup_intent_succeeded.rb +0 -34
- data/app/subscribers/spree_stripe/order_completed_subscriber.rb +0 -14
- data/app/views/spree/admin/payment_methods/configuration_guides/_spree_stripe.html.erb +0 -0
- data/app/views/spree/admin/payment_methods/custom_form_fields/_spree_stripe.html.erb +0 -47
- data/app/views/spree/admin/payment_methods/descriptions/_spree_stripe.html.erb +0 -15
- data/app/views/spree/checkout/payment/_spree_stripe.html.erb +0 -66
- data/app/views/spree_stripe/_head.html.erb +0 -2
- data/app/views/spree_stripe/_quick_checkout.html.erb +0 -34
- data/config/i18n-tasks.yml +0 -173
- data/config/importmap.rb +0 -8
- data/config/initializers/spree.rb +0 -9
- data/config/routes.rb +0 -11
- data/db/migrate/20250310152812_setup_spree_stripe_models.rb +0 -24
- data/db/migrate/20260610120000_add_fingerprint_to_spree_credit_cards.rb +0 -52
- data/lib/spree_stripe/configuration.rb +0 -11
- data/lib/spree_stripe/testing_support/factories/after_pay_payment_source_factory.rb +0 -6
- data/lib/spree_stripe/testing_support/factories/alipay_payment_source_factory.rb +0 -6
- data/lib/spree_stripe/testing_support/factories/ideal_payment_source_factory.rb +0 -6
- data/lib/spree_stripe/testing_support/factories/klarna_payment_source_factory.rb +0 -6
- data/lib/spree_stripe/testing_support/factories/link_payment_source_factory.rb +0 -6
- data/lib/spree_stripe/testing_support/factories/przelewy24_payment_source_factory.rb +0 -6
- data/lib/spree_stripe/testing_support/factories/sepa_debit_payment_source_factory.rb +0 -6
- data/lib/spree_stripe/testing_support/factories/stripe_payment_session_factory.rb +0 -33
- data/lib/spree_stripe/testing_support/factories/webhook_key_factory.rb +0 -23
- data/lib/spree_stripe/version.rb +0 -7
- 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
|
-
#
|
|
7
|
-
|
|
8
|
-
#
|
|
9
|
-
|
|
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
|
-
#
|
|
20
|
-
#
|
|
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,
|
|
32
|
+
raise Spree::Core::GatewayError, Spree.t('stripe.payment_session_errors.zero_amount') if amount_in_cents.zero?
|
|
29
33
|
|
|
30
|
-
|
|
31
|
-
|
|
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:
|
|
39
|
-
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(
|
|
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
|
-
|
|
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.
|
|
53
|
-
customer_external_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' =>
|
|
57
|
-
'stripe_payment_method_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
|
-
|
|
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.
|
|
77
|
-
|
|
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
|
-
#
|
|
88
|
-
#
|
|
85
|
+
# Verifies the intent with Stripe, creates the Payment record and patches
|
|
86
|
+
# wallet address data.
|
|
89
87
|
#
|
|
90
|
-
# Does NOT complete the order —
|
|
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
|
-
|
|
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
|
|
96
|
-
payment_session.process if payment_session.can_process?
|
|
97
|
+
payment_session.process if payment_session.can_process?
|
|
97
98
|
|
|
98
|
-
|
|
99
|
+
charge = payment_session.stripe_charge
|
|
100
|
+
patch_wallet_address(payment_session.owner, charge) if charge.present?
|
|
99
101
|
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
|
|
109
|
+
payment_session.complete unless payment_session.completed?
|
|
110
|
+
payment_session
|
|
111
|
+
end
|
|
105
112
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
247
|
+
def statement_descriptor_suffix_for(order)
|
|
248
|
+
SpreeStripe::StatementDescriptorSuffixPresenter.new(order_description: order.number).call
|
|
143
249
|
end
|
|
144
250
|
|
|
145
|
-
def
|
|
146
|
-
|
|
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
|
-
#
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
|
|
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
|
-
|
|
159
|
-
|
|
268
|
+
owner.email ||= billing_details.email
|
|
269
|
+
owner.save! if owner.email_changed?
|
|
160
270
|
|
|
161
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
302
|
+
owner.bill_address.state_name ||= state_name
|
|
190
303
|
|
|
191
|
-
if
|
|
192
|
-
return if
|
|
304
|
+
if owner.bill_address.invalid?
|
|
305
|
+
return if owner.ship_address.blank?
|
|
193
306
|
|
|
194
|
-
|
|
307
|
+
owner.bill_address = owner.ship_address
|
|
195
308
|
end
|
|
196
309
|
|
|
197
|
-
|
|
198
|
-
|
|
310
|
+
owner.bill_address.save! if owner.bill_address&.changed?
|
|
311
|
+
owner.save!
|
|
199
312
|
|
|
200
|
-
|
|
313
|
+
copy_bill_info_to_customer(owner) if owner.customer.present?
|
|
201
314
|
end
|
|
202
315
|
|
|
203
|
-
def
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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(
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|