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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +16 -4
  3. data/README.md +34 -52
  4. data/Rakefile +7 -17
  5. data/app/jobs/spree_paypal_checkout/base_job.rb +5 -0
  6. data/app/jobs/spree_paypal_checkout/register_webhook_job.rb +20 -0
  7. data/app/maintenance_tasks/spree_paypal_checkout/maintenance_tasks/migrate_payments.rb +100 -0
  8. data/app/models/spree/payment_sessions/paypal_checkout.rb +39 -57
  9. data/app/models/spree_paypal_checkout/amounts.rb +55 -0
  10. data/app/models/spree_paypal_checkout/gateway/payment_sessions.rb +232 -149
  11. data/app/models/spree_paypal_checkout/gateway/webhooks.rb +112 -0
  12. data/app/models/spree_paypal_checkout/gateway.rb +418 -128
  13. data/app/models/spree_paypal_checkout/notifications_client.rb +82 -0
  14. data/app/models/spree_paypal_checkout/payment_sources/paypal.rb +43 -3
  15. data/app/presenters/spree_paypal_checkout/order_presenter.rb +121 -49
  16. data/app/services/spree_paypal_checkout/register_webhook.rb +30 -0
  17. data/config/locales/en.yml +23 -4
  18. data/lib/generators/spree_paypal_checkout/install/install_generator.rb +12 -12
  19. data/lib/spree_paypal_checkout/engine.rb +6 -37
  20. data/lib/spree_paypal_checkout/factories.rb +1 -48
  21. data/lib/spree_paypal_checkout/testing_support/factories/gateway_factory.rb +18 -0
  22. data/lib/spree_paypal_checkout/testing_support/factories/payment_session_factory.rb +16 -0
  23. data/lib/spree_paypal_checkout.rb +11 -3
  24. data/lib/tasks/register_paypal_webhooks.rake +37 -0
  25. metadata +33 -101
  26. data/app/assets/config/spree_paypal_checkout_manifest.js +0 -2
  27. data/app/controllers/spree_paypal_checkout/store_controller_decorator.rb +0 -9
  28. data/app/helpers/spree_paypal_checkout/base_helper.rb +0 -11
  29. data/app/javascript/spree_paypal_checkout/application.js +0 -16
  30. data/app/javascript/spree_paypal_checkout/controllers/checkout_paypal_controller.js +0 -117
  31. data/app/models/spree_paypal_checkout/base.rb +0 -6
  32. data/app/models/spree_paypal_checkout/order.rb +0 -71
  33. data/app/models/spree_paypal_checkout/order_decorator.rb +0 -11
  34. data/app/models/spree_paypal_checkout/payment_method_decorator.rb +0 -15
  35. data/app/models/spree_paypal_checkout/store_decorator.rb +0 -9
  36. data/app/services/spree_paypal_checkout/capture_order.rb +0 -104
  37. data/app/services/spree_paypal_checkout/create_payment.rb +0 -34
  38. data/app/services/spree_paypal_checkout/create_source.rb +0 -52
  39. data/app/views/spree/admin/payment_methods/configuration_guides/_spree_paypal_checkout.html.erb +0 -6
  40. data/app/views/spree/admin/payment_methods/descriptions/_spree_paypal_checkout.html.erb +0 -10
  41. data/app/views/spree/admin/payments/source_forms/_spree_paypal_checkout.html.erb +0 -7
  42. data/app/views/spree/checkout/payment/_spree_paypal_checkout.html.erb +0 -13
  43. data/app/views/spree/payment_sources/_paypal_checkout.html.erb +0 -16
  44. data/app/views/spree_paypal_checkout/_head.html.erb +0 -11
  45. data/config/importmap.rb +0 -6
  46. data/config/initializers/spree.rb +0 -7
  47. data/config/routes.rb +0 -16
  48. data/db/migrate/20250528095719_create_spree_paypal_checkout_orders.rb +0 -20
  49. data/lib/spree_api_v2/spree/api/v2/storefront/paypal_order_serializer.rb +0 -11
  50. data/lib/spree_api_v2/spree/api/v2/storefront/paypal_orders_controller.rb +0 -55
  51. data/lib/spree_paypal_checkout/configuration.rb +0 -5
  52. 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
- def session_required?
9
- !SpreePaypalCheckout::Config[:use_legacy_api]
10
- end
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
- def payment_session_class
13
- Spree::PaymentSessions::PaypalCheckout
14
- end
30
+ SpreePaypalCheckout::Amounts.to_paypal(total, order.currency)
31
+ replace_open_sessions(order)
15
32
 
16
- # Creates a PayPal order via the PayPal Orders API and persists
17
- # a Spree::PaymentSessions::PaypalCheckout record.
18
- def create_payment_session(order:, amount: nil, external_data: {})
19
- total = amount.presence || order.total_minus_store_credits
20
-
21
- return nil if total.zero?
22
-
23
- protect_from_error do
24
- order_presenter = SpreePaypalCheckout::OrderPresenter.new(order)
25
- paypal_response = client.orders.create_order(order_presenter.to_json)
26
-
27
- payment_session_class.create!(
28
- order: order,
29
- payment_method: self,
30
- amount: total,
31
- currency: order.currency,
32
- status: 'pending',
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
- end
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 external_data.present?
45
- attrs[:external_data] = (payment_session.external_data || {}).merge(external_data.stringify_keys)
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
- # Completes a payment session by capturing the PayPal order,
52
- # creating the Payment record, and transitioning the session.
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
- # Does NOT complete the order -- that is handled by Carts::Complete
55
- # (called by the storefront or by the webhook handler).
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
- paypal_order_id = payment_session.external_id
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
- response = client.orders.capture_order({
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
- # Persist capture data outside the lock so it survives if post-capture bookkeeping fails
65
- payment_session.update!(external_data: response.data.as_json)
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
- payment_session.order.with_lock do
68
- if response.data.status == 'COMPLETED'
69
- payment_session.process if payment_session.can_process?
96
+ paypal_order = move_money(payment_session)
97
+ end
70
98
 
71
- payment = payment_session.find_or_create_payment!
99
+ settle(payment_session, paypal_order)
100
+ end
72
101
 
73
- if payment.present? && !payment.completed?
74
- payment.started_processing! if payment.checkout?
75
- payment.complete! if payment.can_complete?
76
- end
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
- create_profile(payment) if payment&.source.present?
112
+ paypal_order = paypal_order(payment_session.external_id)
79
113
 
80
- payment_session.complete unless payment_session.completed?
81
- else
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
- # Parses incoming PayPal webhook events.
91
- def parse_webhook_event(raw_body, headers)
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
- event = JSON.parse(raw_body).with_indifferent_access
95
- event_type = event[:event_type]
96
- resource = event[:resource] || {}
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
- paypal_order_id = extract_order_id_from_webhook(event_type, resource)
99
- return nil unless paypal_order_id
128
+ raise Spree::Core::GatewayError, paypal_error_message(e)
129
+ end
100
130
 
101
- payment_session = Spree::PaymentSessions::PaypalCheckout.find_by(
102
- payment_method: self,
103
- external_id: paypal_order_id
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
- return nil unless payment_session
106
-
107
- case event_type
108
- when 'CHECKOUT.ORDER.APPROVED'
109
- { action: :authorized, payment_session: payment_session, metadata: { paypal_event: event } }
110
- when 'PAYMENT.CAPTURE.COMPLETED'
111
- { action: :captured, payment_session: payment_session, metadata: { paypal_event: event } }
112
- when 'PAYMENT.CAPTURE.DENIED', 'PAYMENT.CAPTURE.DECLINED'
113
- { action: :failed, payment_session: payment_session, metadata: { paypal_event: event } }
114
- when 'PAYMENT.CAPTURE.REVERSED', 'PAYMENT.CAPTURE.REFUNDED'
115
- { action: :canceled, payment_session: payment_session, metadata: { paypal_event: event } }
116
- else
117
- nil
118
- end
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
- def extract_order_id_from_webhook(event_type, resource)
124
- case event_type
125
- when /\ACHECKOUT\.ORDER\./
126
- resource['id']
127
- when /\APAYMENT\.CAPTURE\./
128
- resource.dig('supplementary_data', 'related_ids', 'order_id')
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
- def verify_webhook_signature!(raw_body, headers)
133
- if preferred_webhook_secret.blank?
134
- return if Rails.env.development? || Rails.env.test?
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
- raise Spree::PaymentMethod::WebhookSignatureError,
137
- 'PayPal webhook_secret is not configured'
138
- end
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
- transmission_id = headers['HTTP_PAYPAL_TRANSMISSION_ID'] || headers['PAYPAL-TRANSMISSION-ID']
141
- transmission_time = headers['HTTP_PAYPAL_TRANSMISSION_TIME'] || headers['PAYPAL-TRANSMISSION-TIME']
142
- cert_url = headers['HTTP_PAYPAL_CERT_URL'] || headers['PAYPAL-CERT-URL']
143
- auth_algo = headers['HTTP_PAYPAL_AUTH_ALGO'] || headers['PAYPAL-AUTH-ALGO']
144
- transmission_sig = headers['HTTP_PAYPAL_TRANSMISSION_SIG'] || headers['PAYPAL-TRANSMISSION-SIG']
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
- unless transmission_id && transmission_sig
147
- raise Spree::PaymentMethod::WebhookSignatureError, 'Missing PayPal webhook headers'
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
- token = obtain_access_token
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::PaymentMethod::WebhookSignatureError
264
+ rescue Spree::Core::GatewayError
182
265
  raise
183
- rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, JSON::ParserError, Errno::ECONNREFUSED => e
184
- raise Spree::PaymentMethod::WebhookSignatureError, "Webhook verification failed: #{e.message}"
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 obtain_access_token
188
- api_base = preferred_test_mode ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'
189
- uri = URI("#{api_base}/v1/oauth2/token")
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
- http = Net::HTTP.new(uri.host, uri.port)
192
- http.use_ssl = true
193
- http.open_timeout = 5
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
- response = http.request(request)
200
- JSON.parse(response.body)['access_token']
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