spree-paypal_merchant 5.2.0

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 (35) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +67 -0
  3. data/LICENSE +10 -0
  4. data/README.md +84 -0
  5. data/Rakefile +27 -0
  6. data/app/models/spree/payment_sessions/paypal_merchant.rb +94 -0
  7. data/app/models/spree/paypal_merchant/base.rb +14 -0
  8. data/app/models/spree/paypal_merchant/gateway/payment_sessions.rb +308 -0
  9. data/app/models/spree/paypal_merchant/gateway.rb +324 -0
  10. data/app/models/spree/paypal_merchant/order.rb +89 -0
  11. data/app/models/spree/paypal_merchant/order_decorator.rb +24 -0
  12. data/app/models/spree/paypal_merchant/payment_method_decorator.rb +29 -0
  13. data/app/models/spree/paypal_merchant/payment_sources/apple_pay.rb +49 -0
  14. data/app/models/spree/paypal_merchant/payment_sources/card.rb +42 -0
  15. data/app/models/spree/paypal_merchant/payment_sources/paypal.rb +37 -0
  16. data/app/models/spree/paypal_merchant/store_decorator.rb +19 -0
  17. data/app/presenters/spree/paypal_merchant/order_presenter.rb +148 -0
  18. data/app/serializers/spree/api/v3/payment_method_serializer_decorator.rb +20 -0
  19. data/app/services/spree/paypal_merchant/capture_order.rb +44 -0
  20. data/app/services/spree/paypal_merchant/create_payment.rb +49 -0
  21. data/app/services/spree/paypal_merchant/create_source.rb +110 -0
  22. data/app/views/spree/admin/payment_methods/configuration_guides/_spree_paypal_merchant.html.erb +17 -0
  23. data/app/views/spree/admin/payment_methods/descriptions/_spree_paypal_merchant.html.erb +10 -0
  24. data/app/views/spree/admin/payments/source_forms/_spree_paypal_merchant.html.erb +7 -0
  25. data/app/views/spree/payment_sources/_paypal_merchant.html.erb +17 -0
  26. data/config/initializers/spree.rb +8 -0
  27. data/db/migrate/20250528095719_create_spree_paypal_merchant_orders.rb +31 -0
  28. data/db/migrate/20261003120000_rename_spree_paypal_platform_to_paypal_merchant.rb +46 -0
  29. data/lib/generators/spree/paypal_merchant/install/install_generator.rb +39 -0
  30. data/lib/spree/paypal_merchant/engine.rb +41 -0
  31. data/lib/spree/paypal_merchant/factories.rb +60 -0
  32. data/lib/spree/paypal_merchant/version.rb +8 -0
  33. data/lib/spree/paypal_merchant.rb +32 -0
  34. data/lib/spree-paypal_merchant.rb +6 -0
  35. metadata +176 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ab60916880d1d5bea9ba2921266c7780011afe124111ced6ec568ea290749f3c
4
+ data.tar.gz: 05320b99c8cc4f7f077d9e99761618a05a61ef4a730f412094b4fd705f055469
5
+ SHA512:
6
+ metadata.gz: dd1331f42e0599a7ab58d7271a9f7d85b3d37375c3e5e8b7e5004a72953cff2b6f34ff4c7373c9479d06b442659acc2857c80858479fa21ad6491ad5ed4da2eb
7
+ data.tar.gz: 38b28e6801bdb7e982cd286a547739a8b44605f6a1a1e524f8680df647a7cf2f955c7b34d247cb11e81b0ccc78d03fcac2cf89091cd5024e51defb660e51f18c
data/CHANGELOG.md ADDED
@@ -0,0 +1,67 @@
1
+ # Changelog
2
+
3
+ ## 5.2.0
4
+
5
+ Renamed from `spree-paypal_platform`. Same code, new name.
6
+
7
+ | | Before | After |
8
+ |---|---|---|
9
+ | Gem | `spree-paypal_platform` | `spree-paypal_merchant` |
10
+ | Require | `spree/paypal_platform` | `spree/paypal_merchant` |
11
+ | Constant | `Spree::PaypalPlatform` | `Spree::PaypalMerchant` |
12
+ | Table | `spree_paypal_platform_orders` | `spree_paypal_merchant_orders` |
13
+ | Store API type | `paypal_platform` | `paypal_merchant` |
14
+ | Generator | `spree:paypal_platform:install` | `spree:paypal_merchant:install` |
15
+
16
+ To upgrade, swap the gem in the Gemfile and run
17
+ `bin/rails g spree:paypal_merchant:install`. It copies
18
+ `RenameSpreePaypalPlatformToPaypalMerchant`, which renames the table and
19
+ rewrites stored STI class names (payment methods, payment sources, payment
20
+ sessions). Map the `paypal_merchant` Store API type in the storefront
21
+ *before* deploying the backend.
22
+
23
+ Entries below use the old name.
24
+
25
+ ## 5.1.5
26
+
27
+ - Gift cards and store credit: the PayPal order is created for the amount
28
+ due (`total_minus_store_credits`), not the full order total. Before, PayPal
29
+ charged the full total while Spree recorded the gift card as paying part
30
+ of it, so the customer paid twice for that part. A partial charge is sent
31
+ as a plain amount, without the breakdown and items that sum to the full
32
+ total.
33
+ - Completing a session refuses to capture when the PayPal order amount no
34
+ longer matches the amount due (e.g. a gift card was applied, or the cart
35
+ changed, after the PayPal order was created). The session fails and the
36
+ storefront creates a new one.
37
+
38
+ ## 5.1.4
39
+
40
+ - `enable_card_fields` preference (checkbox, default on).
41
+ - Storefront sees `enable_apple_pay` and `enable_card_fields` on the payment
42
+ method (`public_preferences`) and on the payment session `external_data`.
43
+ - Client token is not requested when Card Fields are disabled.
44
+
45
+ ## 5.1.3
46
+
47
+ Remove the unused `apple_pay_domain` preference. Domain registration is
48
+ in the PayPal dashboard, not this field.
49
+
50
+ ## 5.1.2
51
+
52
+ - Completing a session GETs the PayPal order first and skips capture when
53
+ it is already `COMPLETED` (Apple Pay `confirmOrder`).
54
+ - `CreateSource` accepts an empty `payment_source.paypal` hash from the
55
+ create-order echo so checkout can finish after PayPal has taken payment.
56
+
57
+ ## 5.1.1
58
+
59
+ First release.
60
+
61
+ - `Spree::PaypalPlatform::Gateway` for Spree 5.6+
62
+ - PayPal wallet, Apple Pay, and Card Fields on one payment method
63
+ - Store API v3 payment sessions
64
+ - `SET_PROVIDED_ADDRESS` so the storefront owns shipping
65
+ - VAT-safe amount breakdown (`additional_tax_total` only)
66
+ - Official `paypal-server-sdk` `~> 2.3`
67
+ - One migration: create `spree_paypal_platform_orders`
data/LICENSE ADDED
@@ -0,0 +1,10 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Vendo Connect Inc.
4
+ Copyright (c) 2026 Aypex
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
7
+
8
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
9
+
10
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # spree-paypal_merchant
2
+
3
+ PayPal Checkout for Spree 5.6+, as a single payment method covering the PayPal
4
+ wallet, Apple Pay, and Card Fields.
5
+
6
+ Published gem: **`spree-paypal_merchant`**
7
+ Require: **`spree/paypal_merchant`**
8
+ Constant: **`Spree::PaypalMerchant`**
9
+
10
+ This is not the official `spree_paypal_checkout` gem. It started from that
11
+ extension (VAT `AMOUNT_MISMATCH` guard, storefront-owned address) and adds
12
+ Apple Pay as a funding source of the same gateway.
13
+
14
+ ## What it is
15
+
16
+ - One `Spree::PaypalMerchant::Gateway` payment method.
17
+ - Checkout goes through Spree's **Store API v3 payment sessions**
18
+ (`POST /api/v3/store/carts/:id/payment_sessions` → complete).
19
+ - The storefront owns the address. The gem pins it with
20
+ `SET_PROVIDED_ADDRESS` so PayPal does not re-collect it.
21
+ - Amount breakdown sends **only `additional_tax_total`**. Sending full
22
+ `tax_total` double-counts included VAT and trips PayPal `AMOUNT_MISMATCH`.
23
+ - Talks to PayPal via the official
24
+ [`paypal-server-sdk`](https://github.com/paypal/PayPal-Ruby-Server-SDK) gem
25
+ (`~> 2.3`).
26
+
27
+ Apple Pay is not a second payment method and not a Spree Integration. It is
28
+ a `payment_source.apple_pay` on the same Checkout order.
29
+
30
+ ## Installation
31
+
32
+ ```ruby
33
+ gem 'spree-paypal_merchant'
34
+ ```
35
+
36
+ ```bash
37
+ bundle install
38
+ bin/rails g spree:paypal_merchant:install
39
+ ```
40
+
41
+ The generator copies the migrations. Pass `--auto-run-migrations` to skip the
42
+ prompt.
43
+
44
+ Then add a **PayPal** payment method in admin and set:
45
+
46
+ | Preference | Purpose |
47
+ |---|---|
48
+ | `client_id` / `client_secret` | REST app credentials from the PayPal Developer Dashboard |
49
+ | `webhook_secret` | PayPal webhook ID (used as the verify-webhook `webhook_id`) |
50
+ | `test_mode` | Sandbox vs live |
51
+ | `enable_apple_pay` | Offer Apple Pay on the storefront (default on) |
52
+ | `enable_card_fields` | Offer Card Fields on the storefront (default on) |
53
+
54
+ Webhook URL is the standard Spree v3 payments webhook:
55
+
56
+ ```text
57
+ https://<store>/api/v3/webhooks/payments/<prefixed-payment-method-id>
58
+ ```
59
+
60
+ ## Apple Pay
61
+
62
+ 1. Enable Apple Pay on the PayPal business account.
63
+ 2. Register every storefront domain (and subdomain) that will show the button
64
+ in the [PayPal Apple Pay domain
65
+ registration](https://developer.paypal.com/docs/checkout/apm/apple-pay/)
66
+ dashboard.
67
+ 3. Headless storefronts load the PayPal JS SDK with `components=applepay`
68
+ and render Apple's `ApplePaySession`. The gem records
69
+ `payment_source.apple_pay` as
70
+ `Spree::PaypalMerchant::PaymentSources::ApplePay`.
71
+ 4. Safari + HTTPS only.
72
+
73
+ ## Developing
74
+
75
+ ```bash
76
+ bundle install
77
+ bundle exec rake test_app
78
+ bundle exec rspec
79
+ ```
80
+
81
+ ## License
82
+
83
+ MIT. Derived from [spree/spree_paypal_checkout](https://github.com/spree/spree_paypal_checkout)
84
+ (Vendo Connect Inc.), re-namespaced and extended by Aypex.
data/Rakefile ADDED
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler'
4
+ Bundler::GemHelper.install_tasks
5
+
6
+ require 'rspec/core/rake_task'
7
+ require 'spree/testing_support/extension_rake'
8
+
9
+ RSpec::Core::RakeTask.new
10
+
11
+ task :default do
12
+ if Dir['spec/dummy'].empty?
13
+ Rake::Task[:test_app].invoke
14
+ Dir.chdir('../../')
15
+ end
16
+ Rake::Task[:spec].invoke
17
+ end
18
+
19
+ desc 'Generates a dummy app for testing'
20
+ task :test_app do
21
+ # Must be the require path, not the gem name: spree_core's common:test_app
22
+ # does a literal `require ENV['LIB_NAME']` and constantizes
23
+ # "#{LIB_NAME.camelize}::Generators::InstallGenerator".
24
+ ENV['LIB_NAME'] = 'spree/paypal_merchant'
25
+ ENV['DB'] ||= 'postgres'
26
+ Rake::Task['extension:test_app'].execute(install_storefront: true, install_admin: true)
27
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ class PaymentSessions::PaypalMerchant < PaymentSession
5
+ ##
6
+ # @return [String]
7
+ #
8
+ def paypal_order_id
9
+ external_id
10
+ end
11
+
12
+ ##
13
+ # @return [String, NilClass]
14
+ #
15
+ def paypal_capture_id
16
+ external_data&.dig('purchase_units', 0, 'payments', 'captures', 0, 'id')
17
+ end
18
+
19
+ ##
20
+ # @return [Hash, NilClass]
21
+ #
22
+ def paypal_payer
23
+ external_data&.dig('payer')
24
+ end
25
+
26
+ ##
27
+ # @return [Hash, NilClass]
28
+ #
29
+ def paypal_payment_source
30
+ external_data&.dig('payment_source')
31
+ end
32
+
33
+ ##
34
+ # @return [TrueClass, FalseClass]
35
+ #
36
+ def accepted?
37
+ external_data&.dig('status') == 'COMPLETED'
38
+ end
39
+
40
+ ##
41
+ # @return [TrueClass, FalseClass]
42
+ #
43
+ def successful?
44
+ accepted?
45
+ end
46
+
47
+ ##
48
+ # Creates or finds the Spree::Payment for this session.
49
+ #
50
+ # Defers creation until paypal_capture_id is present so response_code
51
+ # is always the capture ID (required for refunds via Gateway#credit).
52
+ #
53
+ # @param metadata [Hash]
54
+ # @return [Spree::Payment, NilClass]
55
+ #
56
+ def find_or_create_payment!(metadata = {})
57
+ return unless persisted?
58
+ return payment if payment.present?
59
+ return unless paypal_capture_id
60
+
61
+ order.with_lock do
62
+ existing_payment = order.payments.where(
63
+ payment_method: payment_method,
64
+ response_code: paypal_capture_id
65
+ ).first
66
+
67
+ return existing_payment if existing_payment.present?
68
+
69
+ source = create_payment_source!
70
+
71
+ order.payments.create!(
72
+ payment_method: payment_method,
73
+ amount: amount,
74
+ response_code: paypal_capture_id,
75
+ source: source,
76
+ skip_source_requirement: true,
77
+ private_metadata: metadata
78
+ )
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def create_payment_source!
85
+ return nil if paypal_payment_source.blank?
86
+
87
+ Spree::PaypalMerchant::CreateSource.new(
88
+ paypal_payment_source: paypal_payment_source,
89
+ gateway: payment_method,
90
+ order: order
91
+ ).call
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module PaypalMerchant
5
+ ##
6
+ # Abstract ActiveRecord base for this gem's persisted models.
7
+ #
8
+ # Table names resolve through {Spree::PaypalMerchant.table_name_prefix}.
9
+ #
10
+ class Base < ::Spree.base_class
11
+ self.abstract_class = true
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+
5
+ module Spree
6
+ module PaypalMerchant
7
+ class Gateway < ::Spree::Gateway
8
+ ##
9
+ # Payment-session lifecycle used by Spree 5 storefronts.
10
+ #
11
+ module PaymentSessions
12
+ extend ActiveSupport::Concern
13
+
14
+ ##
15
+ # @return [TrueClass, FalseClass]
16
+ #
17
+ def session_required?
18
+ true
19
+ end
20
+
21
+ ##
22
+ # @return [Class]
23
+ #
24
+ def payment_session_class
25
+ Spree::PaymentSessions::PaypalMerchant
26
+ end
27
+
28
+ ##
29
+ # Creates a PayPal order and persists a payment session.
30
+ #
31
+ # Also requests a Card Fields client token. Failure is non-fatal —
32
+ # the wallet / Apple Pay buttons still work without it.
33
+ #
34
+ # @param order [Spree::Order]
35
+ # @param amount [Numeric, NilClass]
36
+ # @param external_data [Hash]
37
+ # @return [Spree::PaymentSessions::PaypalMerchant, NilClass]
38
+ #
39
+ def create_payment_session(order:, amount: nil, **_unused)
40
+ total = amount.presence || order.total_minus_store_credits
41
+
42
+ return nil if total.zero?
43
+
44
+ protect_from_error do
45
+ order_presenter = OrderPresenter.new(order, amount: total)
46
+ paypal_response = client.orders.create_order(order_presenter.to_json)
47
+
48
+ session_data = paypal_response.data.as_json
49
+
50
+ session_data['enable_apple_pay'] = apple_pay_enabled?
51
+ session_data['enable_card_fields'] = card_fields_enabled?
52
+
53
+ if card_fields_enabled?
54
+ client_token = generate_client_token
55
+ session_data['client_token'] = client_token if client_token.present?
56
+ end
57
+
58
+ payment_session_class.create!(
59
+ order: order,
60
+ payment_method: self,
61
+ amount: total,
62
+ currency: order.currency,
63
+ status: 'pending',
64
+ external_id: paypal_response.data.id,
65
+ customer: order.user,
66
+ external_data: session_data
67
+ )
68
+ end
69
+ end
70
+
71
+ ##
72
+ # @param payment_session [Spree::PaymentSessions::PaypalMerchant]
73
+ # @param amount [Numeric, NilClass]
74
+ # @param external_data [Hash]
75
+ # @return [TrueClass, FalseClass, NilClass]
76
+ #
77
+ def update_payment_session(payment_session:, amount: nil, external_data: {})
78
+ attrs = {}
79
+ attrs[:amount] = amount if amount.present?
80
+
81
+ attrs[:external_data] = (payment_session.external_data || {}).merge(external_data.stringify_keys) if external_data.present?
82
+
83
+ payment_session.update!(attrs) if attrs.any?
84
+ end
85
+
86
+ ##
87
+ # Captures the PayPal order and creates the Spree payment.
88
+ #
89
+ # Does not complete the order — that is handled by Carts::Complete.
90
+ #
91
+ # @param payment_session [Spree::PaymentSessions::PaypalMerchant]
92
+ # @param params [Hash]
93
+ # @return [void]
94
+ #
95
+ def complete_payment_session(payment_session:, **_unused)
96
+ paypal_order_id = payment_session.external_id
97
+ lookup = { 'id' => paypal_order_id, 'prefer' => 'return=representation' }
98
+
99
+ fetched = client.orders.get_order(lookup)
100
+ response =
101
+ if fetched.data.status == 'COMPLETED'
102
+ fetched
103
+ else
104
+ ensure_amount_matches!(payment_session, fetched)
105
+ client.orders.capture_order(lookup)
106
+ end
107
+
108
+ payment_session.update!(external_data: response.data.as_json)
109
+
110
+ payment_session.order.with_lock do
111
+ if response.data.status == 'COMPLETED'
112
+ payment_session.process if payment_session.can_process?
113
+
114
+ payment = payment_session.find_or_create_payment!
115
+
116
+ if payment.present? && !payment.completed?
117
+ payment.started_processing! if payment.checkout?
118
+ payment.complete! if payment.can_complete?
119
+ end
120
+
121
+ create_profile(payment) if payment&.source.present?
122
+
123
+ payment_session.complete unless payment_session.completed?
124
+ elsif payment_session.can_fail?
125
+ payment_session.fail
126
+ end
127
+ end
128
+ rescue PaypalServerSdk::APIException => e
129
+ payment_session.fail if payment_session.can_fail?
130
+ raise Spree::Core::GatewayError, "PayPal API error: #{e.message}"
131
+ end
132
+
133
+ ##
134
+ # Parses an incoming PayPal webhook into a payment-session action.
135
+ #
136
+ # @param raw_body [String]
137
+ # @param headers [Hash]
138
+ # @return [Hash, NilClass]
139
+ #
140
+ def parse_webhook_event(raw_body, headers)
141
+ verify_webhook_signature!(raw_body, headers)
142
+
143
+ event = JSON.parse(raw_body).with_indifferent_access
144
+ event_type = event[:event_type]
145
+ resource = event[:resource] || {}
146
+
147
+ paypal_order_id = extract_order_id_from_webhook(event_type, resource)
148
+ return nil unless paypal_order_id
149
+
150
+ payment_session = Spree::PaymentSessions::PaypalMerchant.find_by(
151
+ payment_method: self,
152
+ external_id: paypal_order_id
153
+ )
154
+ return nil unless payment_session
155
+
156
+ case event_type
157
+ when 'CHECKOUT.ORDER.APPROVED'
158
+ { action: :authorized, payment_session: payment_session, metadata: { paypal_event: event } }
159
+ when 'PAYMENT.CAPTURE.COMPLETED'
160
+ { action: :captured, payment_session: payment_session, metadata: { paypal_event: event } }
161
+ when 'PAYMENT.CAPTURE.DENIED', 'PAYMENT.CAPTURE.DECLINED'
162
+ { action: :failed, payment_session: payment_session, metadata: { paypal_event: event } }
163
+ when 'PAYMENT.CAPTURE.REVERSED', 'PAYMENT.CAPTURE.REFUNDED'
164
+ { action: :canceled, payment_session: payment_session, metadata: { paypal_event: event } }
165
+ end
166
+ end
167
+
168
+ private
169
+
170
+ ##
171
+ # Refuses to capture a PayPal order whose amount no longer matches what
172
+ # is due — e.g. a gift card was applied, or the cart changed, after the
173
+ # PayPal order was created. Capturing would take the stale amount; the
174
+ # storefront recreates the session instead.
175
+ #
176
+ # @raise [Spree::Core::GatewayError]
177
+ #
178
+ def ensure_amount_matches!(payment_session, fetched)
179
+ value = fetched.data.as_json.dig('purchase_units', 0, 'amount', 'value')
180
+ return if value.blank?
181
+
182
+ due = payment_session.order.total_minus_store_credits
183
+ return if value.to_d == due.to_d
184
+
185
+ payment_session.fail if payment_session.can_fail?
186
+ raise Spree::Core::GatewayError,
187
+ "PayPal order amount #{value} does not match the amount due #{due}"
188
+ end
189
+
190
+ def extract_order_id_from_webhook(event_type, resource)
191
+ case event_type
192
+ when /\ACHECKOUT\.ORDER\./
193
+ resource['id']
194
+ when /\APAYMENT\.CAPTURE\./
195
+ resource.dig('supplementary_data', 'related_ids', 'order_id')
196
+ end
197
+ end
198
+
199
+ def verify_webhook_signature!(raw_body, headers)
200
+ if preferred_webhook_secret.blank?
201
+ return if Rails.env.local?
202
+
203
+ raise Spree::PaymentMethod::WebhookSignatureError,
204
+ 'PayPal webhook_secret is not configured'
205
+ end
206
+
207
+ transmission_id = headers['HTTP_PAYPAL_TRANSMISSION_ID'] || headers['PAYPAL-TRANSMISSION-ID']
208
+ transmission_time = headers['HTTP_PAYPAL_TRANSMISSION_TIME'] || headers['PAYPAL-TRANSMISSION-TIME']
209
+ cert_url = headers['HTTP_PAYPAL_CERT_URL'] || headers['PAYPAL-CERT-URL']
210
+ auth_algo = headers['HTTP_PAYPAL_AUTH_ALGO'] || headers['PAYPAL-AUTH-ALGO']
211
+ transmission_sig = headers['HTTP_PAYPAL_TRANSMISSION_SIG'] || headers['PAYPAL-TRANSMISSION-SIG']
212
+
213
+ raise Spree::PaymentMethod::WebhookSignatureError, 'Missing PayPal webhook headers' unless transmission_id && transmission_sig
214
+
215
+ token = obtain_access_token
216
+
217
+ uri = URI("#{api_base}/v1/notifications/verify-webhook-signature")
218
+
219
+ payload = {
220
+ auth_algo: auth_algo,
221
+ cert_url: cert_url,
222
+ transmission_id: transmission_id,
223
+ transmission_sig: transmission_sig,
224
+ transmission_time: transmission_time,
225
+ webhook_id: preferred_webhook_secret,
226
+ webhook_event: JSON.parse(raw_body)
227
+ }
228
+
229
+ http = Net::HTTP.new(uri.host, uri.port)
230
+ http.use_ssl = true
231
+ http.open_timeout = 5
232
+ http.read_timeout = 10
233
+ request = Net::HTTP::Post.new(uri.path, {
234
+ 'Content-Type' => 'application/json',
235
+ 'Authorization' => "Bearer #{token}"
236
+ })
237
+ request.body = payload.to_json
238
+
239
+ response = http.request(request)
240
+ raise Spree::PaymentMethod::WebhookSignatureError, "Webhook verification failed: HTTP #{response.code}" unless http_success?(response)
241
+
242
+ result = JSON.parse(response.body)
243
+
244
+ raise Spree::PaymentMethod::WebhookSignatureError, 'Invalid webhook signature' unless result['verification_status'] == 'SUCCESS'
245
+ rescue Spree::PaymentMethod::WebhookSignatureError
246
+ raise
247
+ rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, JSON::ParserError, Errno::ECONNREFUSED => e
248
+ raise Spree::PaymentMethod::WebhookSignatureError, "Webhook verification failed: #{e.message}"
249
+ end
250
+
251
+ def obtain_access_token
252
+ uri = URI("#{api_base}/v1/oauth2/token")
253
+
254
+ http = Net::HTTP.new(uri.host, uri.port)
255
+ http.use_ssl = true
256
+ http.open_timeout = 5
257
+ http.read_timeout = 10
258
+ request = Net::HTTP::Post.new(uri.path, { 'Content-Type' => 'application/x-www-form-urlencoded' })
259
+ request.basic_auth(preferred_client_id, preferred_client_secret)
260
+ request.body = 'grant_type=client_credentials'
261
+
262
+ response = http.request(request)
263
+ return nil unless http_success?(response)
264
+
265
+ JSON.parse(response.body)['access_token']
266
+ end
267
+
268
+ ##
269
+ # Short-lived client token for Card Fields. Returns nil on failure.
270
+ #
271
+ # @return [String, NilClass]
272
+ #
273
+ def generate_client_token
274
+ token = obtain_access_token
275
+ return nil if token.blank?
276
+
277
+ uri = URI("#{api_base}/v1/identity/generate-token")
278
+
279
+ http = Net::HTTP.new(uri.host, uri.port)
280
+ http.use_ssl = true
281
+ http.open_timeout = 5
282
+ http.read_timeout = 10
283
+ request = Net::HTTP::Post.new(uri.path, {
284
+ 'Content-Type' => 'application/json',
285
+ 'Accept-Language' => 'en_US',
286
+ 'Authorization' => "Bearer #{token}"
287
+ })
288
+
289
+ response = http.request(request)
290
+ return nil unless http_success?(response)
291
+
292
+ JSON.parse(response.body)['client_token']
293
+ rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, JSON::ParserError, Errno::ECONNREFUSED => e
294
+ Rails.logger.warn("[spree-paypal_merchant] client token generation failed: #{e.message}")
295
+ nil
296
+ end
297
+
298
+ def api_base
299
+ preferred_test_mode ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'
300
+ end
301
+
302
+ def http_success?(response)
303
+ response.code.to_i.between?(200, 299)
304
+ end
305
+ end
306
+ end
307
+ end
308
+ end