spree_paypal_checkout 0.7.0 → 6.0.3
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 +16 -4
- data/README.md +34 -52
- data/Rakefile +7 -17
- data/app/jobs/spree_paypal_checkout/base_job.rb +5 -0
- data/app/jobs/spree_paypal_checkout/register_webhook_job.rb +20 -0
- data/app/maintenance_tasks/spree_paypal_checkout/maintenance_tasks/migrate_payments.rb +100 -0
- data/app/models/spree/payment_sessions/paypal_checkout.rb +39 -57
- data/app/models/spree_paypal_checkout/amounts.rb +55 -0
- data/app/models/spree_paypal_checkout/gateway/payment_sessions.rb +232 -149
- data/app/models/spree_paypal_checkout/gateway/webhooks.rb +112 -0
- data/app/models/spree_paypal_checkout/gateway.rb +418 -128
- data/app/models/spree_paypal_checkout/notifications_client.rb +82 -0
- data/app/models/spree_paypal_checkout/payment_sources/paypal.rb +43 -3
- data/app/presenters/spree_paypal_checkout/order_presenter.rb +121 -49
- data/app/services/spree_paypal_checkout/register_webhook.rb +30 -0
- data/config/locales/en.yml +23 -4
- data/lib/generators/spree_paypal_checkout/install/install_generator.rb +12 -12
- data/lib/spree_paypal_checkout/engine.rb +6 -37
- data/lib/spree_paypal_checkout/factories.rb +1 -48
- data/lib/spree_paypal_checkout/testing_support/factories/gateway_factory.rb +18 -0
- data/lib/spree_paypal_checkout/testing_support/factories/payment_session_factory.rb +16 -0
- data/lib/spree_paypal_checkout.rb +11 -3
- data/lib/tasks/register_paypal_webhooks.rake +37 -0
- metadata +33 -101
- data/app/assets/config/spree_paypal_checkout_manifest.js +0 -2
- data/app/controllers/spree_paypal_checkout/store_controller_decorator.rb +0 -9
- data/app/helpers/spree_paypal_checkout/base_helper.rb +0 -11
- data/app/javascript/spree_paypal_checkout/application.js +0 -16
- data/app/javascript/spree_paypal_checkout/controllers/checkout_paypal_controller.js +0 -117
- data/app/models/spree_paypal_checkout/base.rb +0 -6
- data/app/models/spree_paypal_checkout/order.rb +0 -71
- data/app/models/spree_paypal_checkout/order_decorator.rb +0 -11
- data/app/models/spree_paypal_checkout/payment_method_decorator.rb +0 -15
- data/app/models/spree_paypal_checkout/store_decorator.rb +0 -9
- data/app/services/spree_paypal_checkout/capture_order.rb +0 -104
- data/app/services/spree_paypal_checkout/create_payment.rb +0 -34
- data/app/services/spree_paypal_checkout/create_source.rb +0 -52
- data/app/views/spree/admin/payment_methods/configuration_guides/_spree_paypal_checkout.html.erb +0 -6
- data/app/views/spree/admin/payment_methods/descriptions/_spree_paypal_checkout.html.erb +0 -10
- data/app/views/spree/admin/payments/source_forms/_spree_paypal_checkout.html.erb +0 -7
- data/app/views/spree/checkout/payment/_spree_paypal_checkout.html.erb +0 -13
- data/app/views/spree/payment_sources/_paypal_checkout.html.erb +0 -16
- data/app/views/spree_paypal_checkout/_head.html.erb +0 -11
- data/config/importmap.rb +0 -6
- data/config/initializers/spree.rb +0 -7
- data/config/routes.rb +0 -16
- data/db/migrate/20250528095719_create_spree_paypal_checkout_orders.rb +0 -20
- data/lib/spree_api_v2/spree/api/v2/storefront/paypal_order_serializer.rb +0 -11
- data/lib/spree_api_v2/spree/api/v2/storefront/paypal_orders_controller.rb +0 -55
- data/lib/spree_paypal_checkout/configuration.rb +0 -5
- data/lib/spree_paypal_checkout/version.rb +0 -7
|
@@ -1,203 +1,286 @@
|
|
|
1
|
-
require 'net/http'
|
|
2
|
-
|
|
3
1
|
module SpreePaypalCheckout
|
|
4
2
|
class Gateway < ::Spree::Gateway
|
|
3
|
+
# A PayPal payment session is a PayPal order. Creating the session creates
|
|
4
|
+
# the order, the buyer approves it in PayPal's window, and completing the
|
|
5
|
+
# session captures or authorizes it. Until then the order is inert: nothing
|
|
6
|
+
# but this server can take money against it.
|
|
5
7
|
module PaymentSessions
|
|
6
8
|
extend ActiveSupport::Concern
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
# Written by the gem; a client's values for them are ignored.
|
|
11
|
+
OWNED_EXTERNAL_DATA_KEYS = %w[client_id intent attempt paypal_result].freeze
|
|
12
|
+
# The buyer's card or account was refused and PayPal lets them pick
|
|
13
|
+
# another in the same window, so the session stays open for them.
|
|
14
|
+
RECOVERABLE_DECLINE = 'INSTRUMENT_DECLINED'.freeze
|
|
15
|
+
# Answers that say nothing about the order: PayPal turned the request away
|
|
16
|
+
# before looking, or could not be reached at all.
|
|
17
|
+
RETRYABLE_STATUS_CODES = [401, 408, 429].freeze
|
|
18
|
+
|
|
19
|
+
# Opens a new session on every call, canceling the cart's open PayPal
|
|
20
|
+
# sessions first so only one order can ever be paid.
|
|
21
|
+
#
|
|
22
|
+
# @param order [Spree::Cart, Spree::Order]
|
|
23
|
+
# @return [Spree::PaymentSessions::PaypalCheckout]
|
|
24
|
+
# @raise [Spree::Core::GatewayError] for a zero amount, an amount PayPal
|
|
25
|
+
# cannot take, or a cart this method has already been paid for
|
|
26
|
+
def create_payment_session(order:, amount: nil, external_data: {})
|
|
27
|
+
total = amount.present? ? BigDecimal(amount.to_s) : order.total_minus_store_credits
|
|
28
|
+
raise Spree::Core::GatewayError, I18n.t('spree.paypal_checkout.payment_session_errors.zero_amount') unless total.positive?
|
|
11
29
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
end
|
|
30
|
+
SpreePaypalCheckout::Amounts.to_paypal(total, order.currency)
|
|
31
|
+
replace_open_sessions(order)
|
|
15
32
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
external_id: paypal_response.data.id,
|
|
34
|
-
customer: order.user,
|
|
35
|
-
external_data: paypal_response.data.as_json
|
|
33
|
+
intent = paypal_intent
|
|
34
|
+
presenter = SpreePaypalCheckout::OrderPresenter.new(order: order, amount: total, intent: intent)
|
|
35
|
+
paypal_order = protect_from_error { create_paypal_order(presenter.order_request) }
|
|
36
|
+
|
|
37
|
+
payment_session_class.create!(
|
|
38
|
+
owner: order,
|
|
39
|
+
payment_method: self,
|
|
40
|
+
amount: total,
|
|
41
|
+
currency: order.currency,
|
|
42
|
+
status: 'pending',
|
|
43
|
+
external_id: paypal_order['id'],
|
|
44
|
+
customer: order.customer,
|
|
45
|
+
# The JS SDK must load with the client id that created the order and
|
|
46
|
+
# the order's own intent, or PayPal's buttons refuse it.
|
|
47
|
+
external_data: client_external_data(external_data).merge(
|
|
48
|
+
'client_id' => preferred_client_id,
|
|
49
|
+
'intent' => intent
|
|
36
50
|
)
|
|
37
|
-
|
|
51
|
+
)
|
|
38
52
|
end
|
|
39
53
|
|
|
54
|
+
# A new amount is sent to PayPal before the session takes it, so PayPal
|
|
55
|
+
# never captures a total Spree no longer charges. The whole purchase unit
|
|
56
|
+
# is replaced: items and breakdown come and go with whether they add up.
|
|
40
57
|
def update_payment_session(payment_session:, amount: nil, external_data: {})
|
|
41
58
|
attrs = {}
|
|
42
|
-
attrs[:amount] = amount if amount.present?
|
|
43
59
|
|
|
44
|
-
if
|
|
45
|
-
|
|
60
|
+
new_amount = BigDecimal(amount.to_s) if amount.present?
|
|
61
|
+
if new_amount && new_amount != payment_session.amount
|
|
62
|
+
presenter = SpreePaypalCheckout::OrderPresenter.new(
|
|
63
|
+
order: payment_session.owner, amount: new_amount, intent: payment_session.intent || paypal_intent
|
|
64
|
+
)
|
|
65
|
+
protect_from_error { patch_paypal_order(payment_session.external_id, presenter.purchase_unit) }
|
|
66
|
+
attrs[:amount] = new_amount
|
|
46
67
|
end
|
|
47
68
|
|
|
69
|
+
data = client_external_data(external_data)
|
|
70
|
+
attrs[:external_data] = (payment_session.external_data || {}).merge(data) if data.any?
|
|
71
|
+
|
|
48
72
|
payment_session.update!(attrs) if attrs.any?
|
|
49
73
|
end
|
|
50
74
|
|
|
51
|
-
#
|
|
52
|
-
#
|
|
75
|
+
# Captures or authorizes the order the buyer approved, then settles the
|
|
76
|
+
# payment. Does NOT complete the cart: the storefront's cart-complete
|
|
77
|
+
# call or the webhook handler does that.
|
|
53
78
|
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
79
|
+
# @raise [Spree::Core::GatewayError] when the session is not open, or
|
|
80
|
+
# PayPal declines; a recoverable decline leaves the session open
|
|
56
81
|
def complete_payment_session(payment_session:, params: {})
|
|
57
|
-
|
|
82
|
+
unless payment_session.pending? || payment_session.processing?
|
|
83
|
+
raise Spree::Core::GatewayError, I18n.t('spree.paypal_checkout.payment_session_errors.not_open')
|
|
84
|
+
end
|
|
58
85
|
|
|
59
|
-
|
|
60
|
-
'id' => paypal_order_id,
|
|
61
|
-
'prefer' => 'return=representation'
|
|
62
|
-
})
|
|
86
|
+
paypal_order = protect_from_error { paypal_order(payment_session.external_id) }
|
|
63
87
|
|
|
64
|
-
#
|
|
65
|
-
|
|
88
|
+
# A capture that timed out may have gone through; it is settled, not
|
|
89
|
+
# taken again.
|
|
90
|
+
unless money_moved?(paypal_order)
|
|
91
|
+
unless order_matches_session?(paypal_order, payment_session)
|
|
92
|
+
payment_session.fail if payment_session.can_fail?
|
|
93
|
+
return payment_session
|
|
94
|
+
end
|
|
66
95
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
payment_session.process if payment_session.can_process?
|
|
96
|
+
paypal_order = move_money(payment_session)
|
|
97
|
+
end
|
|
70
98
|
|
|
71
|
-
|
|
99
|
+
settle(payment_session, paypal_order)
|
|
100
|
+
end
|
|
72
101
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
102
|
+
# A session that never reached Complete is canceled here alone: its order
|
|
103
|
+
# can only ever be paid by this server. Otherwise the order is read,
|
|
104
|
+
# since a capture that timed out may have gone through.
|
|
105
|
+
#
|
|
106
|
+
# @return [Boolean] whether the session was canceled
|
|
107
|
+
# @raise [Spree::Core::GatewayError] when PayPal has taken the money
|
|
108
|
+
# @raise [StandardError] PayPal's own error when it could not be asked
|
|
109
|
+
def cancel_payment_session(payment_session:)
|
|
110
|
+
return payment_session.cancel unless payment_session.complete_attempted?
|
|
77
111
|
|
|
78
|
-
|
|
112
|
+
paypal_order = paypal_order(payment_session.external_id)
|
|
79
113
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
payment_session.fail if payment_session.can_fail?
|
|
83
|
-
end
|
|
114
|
+
if capture_taken?(paypal_order)
|
|
115
|
+
raise Spree::Core::GatewayError, I18n.t('spree.paypal_checkout.payment_session_errors.payment_captured')
|
|
84
116
|
end
|
|
85
|
-
rescue PaypalServerSdk::APIException => e
|
|
86
|
-
payment_session.fail if payment_session.can_fail?
|
|
87
|
-
raise Spree::Core::GatewayError, "PayPal API error: #{e.message}"
|
|
88
|
-
end
|
|
89
117
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
verify_webhook_signature!(raw_body, headers)
|
|
118
|
+
authorization = open_authorization(paypal_order)
|
|
119
|
+
void_authorization(authorization['id'], request_id: "#{payment_session.prefixed_id}-void") if authorization
|
|
93
120
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
121
|
+
payment_session.cancel
|
|
122
|
+
rescue PaypalServerSdk::ErrorException => e
|
|
123
|
+
raise if retryable_paypal_error?(e)
|
|
124
|
+
# An order the account the credentials now reach does not know can no
|
|
125
|
+
# longer be paid.
|
|
126
|
+
return payment_session.cancel if e.response_code == 404
|
|
97
127
|
|
|
98
|
-
|
|
99
|
-
|
|
128
|
+
raise Spree::Core::GatewayError, paypal_error_message(e)
|
|
129
|
+
end
|
|
100
130
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
131
|
+
# @return [Hash] the created order
|
|
132
|
+
def create_paypal_order(order_request)
|
|
133
|
+
client.orders.create_order('body' => order_request, 'prefer' => 'return=minimal').data.to_hash
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def patch_paypal_order(paypal_order_id, purchase_unit)
|
|
137
|
+
client.orders.patch_order(
|
|
138
|
+
'id' => paypal_order_id,
|
|
139
|
+
'body' => [
|
|
140
|
+
PaypalServerSdk::Patch.new(
|
|
141
|
+
op: PaypalServerSdk::PatchOp::REPLACE,
|
|
142
|
+
path: "/purchase_units/@reference_id=='default'",
|
|
143
|
+
value: purchase_unit.to_hash
|
|
144
|
+
)
|
|
145
|
+
]
|
|
104
146
|
)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @return [Hash] the order, with its capture
|
|
150
|
+
def capture_paypal_order(paypal_order_id, request_id:)
|
|
151
|
+
client.orders.capture_order(
|
|
152
|
+
'id' => paypal_order_id,
|
|
153
|
+
'paypal_request_id' => request_id,
|
|
154
|
+
'prefer' => 'return=representation'
|
|
155
|
+
).data.to_hash
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# @return [Hash] the order, with its authorization
|
|
159
|
+
def authorize_paypal_order(paypal_order_id, request_id:)
|
|
160
|
+
client.orders.authorize_order(
|
|
161
|
+
'id' => paypal_order_id,
|
|
162
|
+
'paypal_request_id' => request_id,
|
|
163
|
+
'prefer' => 'return=representation'
|
|
164
|
+
).data.to_hash
|
|
119
165
|
end
|
|
120
166
|
|
|
121
167
|
private
|
|
122
168
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
169
|
+
# The attempt is stored before PayPal is asked, so a cancel after a
|
|
170
|
+
# timeout knows to look at the order, and a retry of the same attempt
|
|
171
|
+
# reaches PayPal as the same request.
|
|
172
|
+
def move_money(payment_session)
|
|
173
|
+
attempt = payment_session.attempt
|
|
174
|
+
payment_session.update!(external_data: payment_session.external_data.merge('attempt' => attempt))
|
|
175
|
+
action = payment_session.intent == PaypalServerSdk::CheckoutPaymentIntent::AUTHORIZE ? 'authorize' : 'capture'
|
|
176
|
+
request_id = "#{payment_session.prefixed_id}-#{action}-#{attempt}"
|
|
177
|
+
|
|
178
|
+
paypal_order = protect_from_error(money_moving: true) do
|
|
179
|
+
action == 'authorize' ? authorize_paypal_order(payment_session.external_id, request_id: request_id) : capture_paypal_order(payment_session.external_id, request_id: request_id)
|
|
129
180
|
end
|
|
181
|
+
return paypal_order if money_moved?(paypal_order)
|
|
182
|
+
|
|
183
|
+
payment_session.fail if payment_session.can_fail?
|
|
184
|
+
status = payment_status(paypal_order)
|
|
185
|
+
raise PaypalError.new(I18n.t('spree.paypal_checkout.payment_session_errors.declined', status: status), issue: status)
|
|
186
|
+
rescue PaypalError => e
|
|
187
|
+
if e.issue == RECOVERABLE_DECLINE
|
|
188
|
+
payment_session.update!(external_data: payment_session.external_data.merge('attempt' => attempt + 1))
|
|
189
|
+
elsif !e.status.to_i.in?(RETRYABLE_STATUS_CODES) && payment_session.reload.can_fail?
|
|
190
|
+
# A rate limit or a refused token is not a decline: the buyer can
|
|
191
|
+
# complete the same session again.
|
|
192
|
+
payment_session.fail
|
|
193
|
+
end
|
|
194
|
+
raise
|
|
130
195
|
end
|
|
131
196
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
197
|
+
# Once money has moved the session is never failed: a failed session
|
|
198
|
+
# lets the storefront open another and charge again.
|
|
199
|
+
def settle(payment_session, paypal_order)
|
|
200
|
+
payment_session.paypal_order = paypal_order
|
|
201
|
+
payment_session.update!(
|
|
202
|
+
external_data: payment_session.external_data.merge('paypal_result' => paypal_result(paypal_order))
|
|
203
|
+
)
|
|
204
|
+
report_amount_mismatch(payment_session, paypal_order)
|
|
135
205
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
206
|
+
payment_session.process if payment_session.can_process?
|
|
207
|
+
capture = captures(paypal_order).first
|
|
208
|
+
payment_session.settle_payment!(captured: capture.present? && capture['status'] == 'COMPLETED')
|
|
209
|
+
payment_session.complete unless payment_session.completed?
|
|
210
|
+
payment_session
|
|
211
|
+
end
|
|
139
212
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
213
|
+
def order_matches_session?(paypal_order, payment_session)
|
|
214
|
+
amount_matches?(Array(paypal_order['purchase_units']).first&.dig('amount'), payment_session.amount, payment_session.currency)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def report_amount_mismatch(payment_session, paypal_order)
|
|
218
|
+
moved = first_payment(paypal_order)
|
|
219
|
+
return if moved.nil? || amount_matches?(moved['amount'], payment_session.amount, payment_session.currency)
|
|
220
|
+
|
|
221
|
+
expected = SpreePaypalCheckout::Amounts.to_paypal(payment_session.amount, payment_session.currency)
|
|
222
|
+
|
|
223
|
+
Rails.error.report(
|
|
224
|
+
Spree::Core::GatewayError.new(
|
|
225
|
+
"PayPal took #{moved.dig('amount', 'value')} #{moved.dig('amount', 'currency_code')} " \
|
|
226
|
+
"for a session of #{expected} #{payment_session.currency.upcase}"
|
|
227
|
+
),
|
|
228
|
+
context: { payment_session_id: payment_session.id, paypal_order_id: payment_session.external_id },
|
|
229
|
+
source: 'spree_paypal_checkout.complete_payment_session'
|
|
230
|
+
)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def paypal_result(paypal_order)
|
|
234
|
+
capture = captures(paypal_order).first
|
|
235
|
+
authorization = authorizations(paypal_order).first
|
|
236
|
+
{
|
|
237
|
+
'status' => paypal_order['status'],
|
|
238
|
+
'capture_id' => capture&.dig('id'),
|
|
239
|
+
'capture_status' => capture&.dig('status'),
|
|
240
|
+
'authorization_id' => authorization&.dig('id'),
|
|
241
|
+
'authorization_status' => authorization&.dig('status')
|
|
242
|
+
}.compact
|
|
243
|
+
end
|
|
145
244
|
|
|
146
|
-
|
|
147
|
-
|
|
245
|
+
def payment_status(paypal_order)
|
|
246
|
+
first_payment(paypal_order)&.dig('status') || paypal_order['status']
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def first_payment(paypal_order)
|
|
250
|
+
captures(paypal_order).first || authorizations(paypal_order).first
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Only the payable ones are canceled; a failed session's order can no
|
|
254
|
+
# longer be completed by anyone.
|
|
255
|
+
def replace_open_sessions(order)
|
|
256
|
+
sessions = order.payment_sessions.where(payment_method: self)
|
|
257
|
+
if sessions.exists?(status: 'completed')
|
|
258
|
+
raise Spree::Core::GatewayError, I18n.t('spree.paypal_checkout.payment_session_errors.payment_in_progress')
|
|
148
259
|
end
|
|
149
260
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
api_base = preferred_test_mode ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'
|
|
153
|
-
uri = URI("#{api_base}/v1/notifications/verify-webhook-signature")
|
|
154
|
-
|
|
155
|
-
payload = {
|
|
156
|
-
auth_algo: auth_algo,
|
|
157
|
-
cert_url: cert_url,
|
|
158
|
-
transmission_id: transmission_id,
|
|
159
|
-
transmission_sig: transmission_sig,
|
|
160
|
-
transmission_time: transmission_time,
|
|
161
|
-
webhook_id: preferred_webhook_secret,
|
|
162
|
-
webhook_event: JSON.parse(raw_body)
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
166
|
-
http.use_ssl = true
|
|
167
|
-
http.open_timeout = 5
|
|
168
|
-
http.read_timeout = 10
|
|
169
|
-
request = Net::HTTP::Post.new(uri.path, {
|
|
170
|
-
'Content-Type' => 'application/json',
|
|
171
|
-
'Authorization' => "Bearer #{token}"
|
|
172
|
-
})
|
|
173
|
-
request.body = payload.to_json
|
|
174
|
-
|
|
175
|
-
response = http.request(request)
|
|
176
|
-
result = JSON.parse(response.body)
|
|
177
|
-
|
|
178
|
-
unless result['verification_status'] == 'SUCCESS'
|
|
179
|
-
raise Spree::PaymentMethod::WebhookSignatureError, 'Invalid webhook signature'
|
|
261
|
+
sessions.active.order(:id).each do |payment_session|
|
|
262
|
+
cancel_payment_session(payment_session: payment_session)
|
|
180
263
|
end
|
|
181
|
-
rescue Spree::
|
|
264
|
+
rescue Spree::Core::GatewayError
|
|
182
265
|
raise
|
|
183
|
-
rescue
|
|
184
|
-
raise
|
|
266
|
+
rescue StandardError => e
|
|
267
|
+
raise unless retryable_error?(e)
|
|
268
|
+
|
|
269
|
+
# Checkout speaks GatewayError. The unused-session job does not: it
|
|
270
|
+
# retries these, and has to see them unwrapped.
|
|
271
|
+
raise Spree::Core::GatewayError, e.message
|
|
185
272
|
end
|
|
186
273
|
|
|
187
|
-
def
|
|
188
|
-
|
|
189
|
-
|
|
274
|
+
def retryable_error?(error)
|
|
275
|
+
error.is_a?(PaypalServerSdk::ErrorException) ? retryable_paypal_error?(error) : error.is_a?(Faraday::Error) || error.is_a?(CoreLibrary::AuthValidationException)
|
|
276
|
+
end
|
|
190
277
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
http.read_timeout = 10
|
|
195
|
-
request = Net::HTTP::Post.new(uri.path, { 'Content-Type' => 'application/x-www-form-urlencoded' })
|
|
196
|
-
request.basic_auth(preferred_client_id, preferred_client_secret)
|
|
197
|
-
request.body = 'grant_type=client_credentials'
|
|
278
|
+
def retryable_paypal_error?(error)
|
|
279
|
+
error.response_code.in?(RETRYABLE_STATUS_CODES) || error.response_code.to_i >= 500
|
|
280
|
+
end
|
|
198
281
|
|
|
199
|
-
|
|
200
|
-
|
|
282
|
+
def client_external_data(external_data)
|
|
283
|
+
external_data.to_h.stringify_keys.except(*OWNED_EXTERNAL_DATA_KEYS)
|
|
201
284
|
end
|
|
202
285
|
end
|
|
203
286
|
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
module SpreePaypalCheckout
|
|
2
|
+
class Gateway < ::Spree::Gateway
|
|
3
|
+
# Webhook signature verification and registration. Webhooks only report
|
|
4
|
+
# money that has moved: the storefront completes the session as soon as the
|
|
5
|
+
# buyer approves, so the approval itself is not subscribed to.
|
|
6
|
+
module Webhooks
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
WEBHOOK_EVENT_ACTIONS = {
|
|
10
|
+
'PAYMENT.CAPTURE.COMPLETED' => :captured,
|
|
11
|
+
'PAYMENT.CAPTURE.PENDING' => :authorized,
|
|
12
|
+
'PAYMENT.AUTHORIZATION.CREATED' => :authorized,
|
|
13
|
+
'PAYMENT.CAPTURE.DENIED' => :failed,
|
|
14
|
+
'PAYMENT.CAPTURE.DECLINED' => :failed
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
# Registration subscribes to exactly the events with a handler.
|
|
18
|
+
SUPPORTED_EVENTS = WEBHOOK_EVENT_ACTIONS.keys.freeze
|
|
19
|
+
|
|
20
|
+
# Where PayPal puts each value the verification call needs.
|
|
21
|
+
SIGNATURE_HEADERS = {
|
|
22
|
+
auth_algo: 'HTTP_PAYPAL_AUTH_ALGO',
|
|
23
|
+
cert_url: 'HTTP_PAYPAL_CERT_URL',
|
|
24
|
+
transmission_id: 'HTTP_PAYPAL_TRANSMISSION_ID',
|
|
25
|
+
transmission_sig: 'HTTP_PAYPAL_TRANSMISSION_SIG',
|
|
26
|
+
transmission_time: 'HTTP_PAYPAL_TRANSMISSION_TIME'
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
included do
|
|
30
|
+
after_commit :register_webhook_async, on: %i[create update]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Translates a PayPal event into the shape core's webhook handler takes.
|
|
34
|
+
# Everything after this (idempotency, locking, payment creation, order
|
|
35
|
+
# completion) belongs to core.
|
|
36
|
+
#
|
|
37
|
+
# @param raw_body [String]
|
|
38
|
+
# @param headers [Hash, ActionDispatch::Http::Headers]
|
|
39
|
+
# @return [Hash, nil] nil for events this gateway does not act on
|
|
40
|
+
# @raise [Spree::PaymentMethod::WebhookSignatureError]
|
|
41
|
+
def parse_webhook_event(raw_body, headers)
|
|
42
|
+
event = verify_webhook_signature(raw_body, headers)
|
|
43
|
+
|
|
44
|
+
action = WEBHOOK_EVENT_ACTIONS[event['event_type']]
|
|
45
|
+
return nil unless action
|
|
46
|
+
|
|
47
|
+
paypal_order_id = event.dig('resource', 'supplementary_data', 'related_ids', 'order_id')
|
|
48
|
+
return nil if paypal_order_id.blank?
|
|
49
|
+
|
|
50
|
+
payment_session = Spree::PaymentSessions::PaypalCheckout.find_by(payment_method: self, external_id: paypal_order_id)
|
|
51
|
+
return nil unless payment_session
|
|
52
|
+
|
|
53
|
+
{ action: action, payment_session: payment_session }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def register_webhook
|
|
57
|
+
SpreePaypalCheckout::RegisterWebhook.new.call(payment_method: self)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [SpreePaypalCheckout::NotificationsClient]
|
|
61
|
+
def notifications_client
|
|
62
|
+
SpreePaypalCheckout::NotificationsClient.new(self)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Every delivery is checked with PayPal, in every environment. Anything
|
|
68
|
+
# short of PayPal's SUCCESS is a rejected webhook, which PayPal delivers
|
|
69
|
+
# again later.
|
|
70
|
+
def verify_webhook_signature(raw_body, headers)
|
|
71
|
+
if preferred_webhook_id.blank?
|
|
72
|
+
raise Spree::PaymentMethod::WebhookSignatureError, 'PayPal webhook is not registered for this payment method'
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
event = parse_event(raw_body)
|
|
76
|
+
signature = SIGNATURE_HEADERS.transform_values { |key| headers[key] }
|
|
77
|
+
raise Spree::PaymentMethod::WebhookSignatureError, 'Missing PayPal signature headers' if signature.values.any?(&:blank?)
|
|
78
|
+
|
|
79
|
+
verdict = notifications_client.verify_webhook_signature(
|
|
80
|
+
raw_body: raw_body, headers: signature, webhook_id: preferred_webhook_id
|
|
81
|
+
)
|
|
82
|
+
raise Spree::PaymentMethod::WebhookSignatureError, 'Invalid webhook signature' unless verdict == 'SUCCESS'
|
|
83
|
+
|
|
84
|
+
event
|
|
85
|
+
rescue SpreePaypalCheckout::NotificationsClient::Error, *SpreePaypalCheckout::NotificationsClient::NETWORK_ERRORS,
|
|
86
|
+
PaypalServerSdk::APIException, CoreLibrary::AuthValidationException, Faraday::Error, JSON::ParserError => e
|
|
87
|
+
raise Spree::PaymentMethod::WebhookSignatureError, "PayPal could not verify the webhook: #{e.message}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def parse_event(raw_body)
|
|
91
|
+
event = JSON.parse(raw_body.to_s)
|
|
92
|
+
raise Spree::PaymentMethod::WebhookSignatureError, 'Malformed webhook payload' unless event.is_a?(Hash)
|
|
93
|
+
|
|
94
|
+
event
|
|
95
|
+
rescue JSON::ParserError
|
|
96
|
+
raise Spree::PaymentMethod::WebhookSignatureError, 'Malformed webhook payload'
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Registered when the method is created and whenever the credentials
|
|
100
|
+
# change, since a webhook belongs to the REST app they name. Not when
|
|
101
|
+
# registration itself stores the webhook id.
|
|
102
|
+
def register_webhook_async
|
|
103
|
+
return unless previously_new_record? ||
|
|
104
|
+
saved_change_to_preferred_client_id? ||
|
|
105
|
+
saved_change_to_preferred_client_secret? ||
|
|
106
|
+
saved_change_to_preferred_test_mode?
|
|
107
|
+
|
|
108
|
+
SpreePaypalCheckout::RegisterWebhookJob.perform_later(id)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|