spree-paypal_platform 5.0.1

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 (36) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +34 -0
  3. data/LICENSE +10 -0
  4. data/README.md +117 -0
  5. data/Rakefile +27 -0
  6. data/app/models/spree/payment_sessions/paypal_checkout.rb +94 -0
  7. data/app/models/spree/paypal_checkout/base.rb +14 -0
  8. data/app/models/spree/paypal_checkout/gateway/payment_sessions.rb +278 -0
  9. data/app/models/spree/paypal_checkout/gateway.rb +309 -0
  10. data/app/models/spree/paypal_checkout/order.rb +89 -0
  11. data/app/models/spree/paypal_checkout/order_decorator.rb +24 -0
  12. data/app/models/spree/paypal_checkout/payment_method_decorator.rb +29 -0
  13. data/app/models/spree/paypal_checkout/payment_sources/apple_pay.rb +49 -0
  14. data/app/models/spree/paypal_checkout/payment_sources/card.rb +42 -0
  15. data/app/models/spree/paypal_checkout/payment_sources/paypal.rb +37 -0
  16. data/app/models/spree/paypal_checkout/store_decorator.rb +19 -0
  17. data/app/presenters/spree/paypal_checkout/order_presenter.rb +130 -0
  18. data/app/services/spree/paypal_checkout/capture_order.rb +44 -0
  19. data/app/services/spree/paypal_checkout/create_payment.rb +49 -0
  20. data/app/services/spree/paypal_checkout/create_source.rb +109 -0
  21. data/app/views/spree/admin/payment_methods/configuration_guides/_spree_paypal_checkout.html.erb +16 -0
  22. data/app/views/spree/admin/payment_methods/descriptions/_spree_paypal_checkout.html.erb +10 -0
  23. data/app/views/spree/admin/payments/source_forms/_spree_paypal_checkout.html.erb +7 -0
  24. data/app/views/spree/payment_sources/_paypal_checkout.html.erb +17 -0
  25. data/config/initializers/spree.rb +8 -0
  26. data/db/migrate/20250528095719_create_spree_paypal_checkout_orders.rb +22 -0
  27. data/db/migrate/20260817120000_rewrite_spree_paypal_checkout_sti_types.rb +33 -0
  28. data/db/migrate/20260817130000_add_unique_index_on_paypal_checkout_order_paypal_id.rb +7 -0
  29. data/lib/generators/spree/paypal_checkout/install/install_generator.rb +39 -0
  30. data/lib/spree/paypal_checkout/engine.rb +41 -0
  31. data/lib/spree/paypal_checkout/factories.rb +59 -0
  32. data/lib/spree/paypal_checkout/version.rb +8 -0
  33. data/lib/spree/paypal_checkout.rb +45 -0
  34. data/lib/spree-paypal_checkout.rb +7 -0
  35. data/lib/spree-paypal_platform.rb +7 -0
  36. metadata +178 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a661dd4e40e71d2cba0a11105489d4eecce8bbc75936fc8213ce3508903ea8b2
4
+ data.tar.gz: 3323ef46afcd1734d213c672f743ee6d1f7e4eb7580a6d5facfb034bb252001d
5
+ SHA512:
6
+ metadata.gz: f050fe6366417de127b12e0d3f1ef4cce790b79a985d1fb16ff7d27486dfb3fbdb97badb6fee18caa0ccf022dc8d54b51c5e1a6bc959fb981dc3558acf4ee933
7
+ data.tar.gz: 81552556697e34abd82667c5ca6950a147d674f47a0c3ce96b698788ebee690624a8941130a1586adda3c34cc434825f71e05e5cbff31e4e9f38667db860d067
data/CHANGELOG.md ADDED
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ ## 5.0.1
6
+
7
+ Published name is **`spree-paypal_platform`**. RubyGems rejected
8
+ `spree-paypal_checkout` as too similar to the official
9
+ `spree_paypal_checkout`. Require path and constants are unchanged
10
+ (`spree/paypal_checkout` → `Spree::PaypalCheckout`).
11
+
12
+ ## 5.0.0
13
+
14
+ First Aypex release. Intended published name `spree-paypal_checkout` was
15
+ never accepted by RubyGems; see 5.0.1.
16
+
17
+ ### Audit follow-up
18
+
19
+ - Do not log PayPal request bodies; client logging is WARN without bodies/headers.
20
+ - Webhook verify now checks HTTP status and is specced for signed and unsigned paths.
21
+ - `#authorize` raises `NotImplementedError`.
22
+ - `#void` / `#credit` / `#cancel` specs restored; `CaptureOrder` no longer uses the `money` gem.
23
+ - Unique index on `spree_paypal_checkout_orders.paypal_id`.
24
+ - Payment sources share `#display_payment_info`; OAuth/token HTTP responses must be 2xx.
25
+
26
+ - Port of the `aypex-io/spree_paypal_checkout` `tongkat-fitness` fork into
27
+ `Spree::PaypalCheckout`, published as `spree-paypal_checkout`.
28
+ - Store API v3 payment sessions only. No `/api/v2/storefront/paypal_orders`.
29
+ - Official `paypal-server-sdk` `~> 2.3`.
30
+ - Apple Pay and Card Fields recorded as payment sources on the same gateway.
31
+ - Keeps the VAT `AMOUNT_MISMATCH` guard (additional tax only) and
32
+ `SET_PROVIDED_ADDRESS` storefront-owned shipping.
33
+ - STI alias + rewrite migration for hosts coming from
34
+ `SpreePaypalCheckout::Gateway`.
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,117 @@
1
+ # spree-paypal_platform
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
+ This is Aypex's own gem, **published as `spree-paypal_platform`**. It is **not**
7
+ the official `spree_paypal_checkout` gem. RubyGems treats `-` and `_` as the
8
+ same name, so `spree-paypal_checkout` was rejected as too similar. The GitHub
9
+ repo is `aypex-io/spree-paypal_platform`; `require "spree/paypal_checkout"`
10
+ and `Spree::PaypalCheckout` are unchanged.
11
+
12
+ It started from that official extension (including the VAT `AMOUNT_MISMATCH`
13
+ fix and storefront-owned address) and adds Apple Pay as a funding source of
14
+ the same gateway.
15
+
16
+ ## What it is
17
+
18
+ - One `Spree::PaypalCheckout::Gateway` payment method.
19
+ - Checkout goes through Spree's **Store API v3 payment sessions**
20
+ (`POST /api/v3/store/carts/:id/payment_sessions` → complete). There is no
21
+ legacy `/api/v2/storefront/paypal_orders` controller.
22
+ - The storefront owns the address. The gem pins it with
23
+ `SET_PROVIDED_ADDRESS` so PayPal does not re-collect it.
24
+ - Amount breakdown sends **only `additional_tax_total`**. Sending full
25
+ `tax_total` double-counts included VAT and trips PayPal `AMOUNT_MISMATCH`.
26
+ - Talks to PayPal via the official
27
+ [`paypal-server-sdk`](https://github.com/paypal/PayPal-Ruby-Server-SDK) gem
28
+ (`~> 2.3`).
29
+
30
+ Apple Pay is **not** a second payment method and **not** a Spree Integration.
31
+ It is a `payment_source.apple_pay` on the same Checkout order. Extra PayPal
32
+ wallets (Venmo, Google Pay, …) should follow the same pattern.
33
+
34
+ ## Installation
35
+
36
+ ```ruby
37
+ gem 'spree-paypal_platform'
38
+ ```
39
+
40
+ ```bash
41
+ bundle install
42
+ bin/rails g spree:paypal_checkout:install
43
+ ```
44
+
45
+ The generator copies the migrations. Pass `--auto-run-migrations` to skip the
46
+ prompt.
47
+
48
+ Then add a **PayPal** payment method in admin and set:
49
+
50
+ | Preference | Purpose |
51
+ |---|---|
52
+ | `client_id` / `client_secret` | REST app credentials from the PayPal Developer Dashboard |
53
+ | `webhook_secret` | PayPal webhook ID (used as the verify-webhook `webhook_id`) |
54
+ | `test_mode` | Sandbox vs live |
55
+ | `enable_apple_pay` | Advertise Apple Pay to headless storefronts (default on) |
56
+ | `apple_pay_domain` | Domain registered with PayPal for Apple Pay |
57
+
58
+ Webhook URL is the standard Spree v3 payments webhook:
59
+
60
+ ```text
61
+ https://<store>/api/v3/webhooks/payments/<prefixed-payment-method-id>
62
+ ```
63
+
64
+ ## Apple Pay
65
+
66
+ 1. Enable Apple Pay on the PayPal business account.
67
+ 2. Register every storefront domain (and subdomain) that will show the button
68
+ in the [PayPal Apple Pay domain
69
+ registration](https://developer.paypal.com/docs/checkout/apm/apple-pay/)
70
+ dashboard. Unregistered domains are rejected by Apple.
71
+ 3. Headless storefronts load the PayPal JS SDK with `components=applepay`
72
+ (or `buttons,applepay,card-fields`) and render Apple's `ApplePaySession`.
73
+ The gem records the captured `payment_source.apple_pay` as
74
+ `Spree::PaypalCheckout::PaymentSources::ApplePay`.
75
+ 4. Safari + HTTPS only. `localhost` will not work; use a public HTTPS tunnel
76
+ for local testing.
77
+
78
+ This gem does not ship a Next.js / Stimulus Apple Pay button. The TKF
79
+ storefront already owns the wallet and Card Fields UI; Apple Pay belongs
80
+ there, next to `FUNDING.PAYPAL`.
81
+
82
+ ## Coming from `spree_paypal_checkout`
83
+
84
+ Existing `spree_payment_methods.type` rows store
85
+ `SpreePaypalCheckout::Gateway`. This gem aliases that constant, so checkout
86
+ keeps working before you migrate. The install generator also copies a
87
+ rewrite migration that updates those STI strings to
88
+ `Spree::PaypalCheckout::Gateway`.
89
+
90
+ Replace the GitHub pin:
91
+
92
+ ```ruby
93
+ # before
94
+ gem 'spree_paypal_checkout', github: 'aypex-io/spree_paypal_checkout', branch: 'tongkat-fitness'
95
+
96
+ # after
97
+ gem 'spree-paypal_platform'
98
+ ```
99
+
100
+ Then run the install generator and bump the storefront's
101
+ `resolveGatewayId` map if it still keys on `paypal_checkout` /
102
+ `SpreePaypalCheckout::Gateway` — both still resolve if you keep the old
103
+ keys, because the gateway's `api_type` becomes `paypal_checkout` either way
104
+ (`Spree::PaypalCheckout::Gateway` → `paypal_checkout`).
105
+
106
+ ## Developing
107
+
108
+ ```bash
109
+ bundle install
110
+ bundle exec rake test_app
111
+ bundle exec rspec
112
+ ```
113
+
114
+ ## License
115
+
116
+ MIT. Derived from [spree/spree_paypal_checkout](https://github.com/spree/spree_paypal_checkout)
117
+ (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_checkout'
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::PaypalCheckout < 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::PaypalCheckout::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 PaypalCheckout
5
+ ##
6
+ # Abstract ActiveRecord base for this gem's persisted models.
7
+ #
8
+ # Table names resolve through {Spree::PaypalCheckout.table_name_prefix}.
9
+ #
10
+ class Base < ::Spree.base_class
11
+ self.abstract_class = true
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+
5
+ module Spree
6
+ module PaypalCheckout
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::PaypalCheckout
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::PaypalCheckout, 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)
46
+ paypal_response = client.orders.create_order(order_presenter.to_json)
47
+
48
+ session_data = paypal_response.data.as_json
49
+
50
+ client_token = generate_client_token
51
+ session_data['client_token'] = client_token if client_token.present?
52
+
53
+ payment_session_class.create!(
54
+ order: order,
55
+ payment_method: self,
56
+ amount: total,
57
+ currency: order.currency,
58
+ status: 'pending',
59
+ external_id: paypal_response.data.id,
60
+ customer: order.user,
61
+ external_data: session_data
62
+ )
63
+ end
64
+ end
65
+
66
+ ##
67
+ # @param payment_session [Spree::PaymentSessions::PaypalCheckout]
68
+ # @param amount [Numeric, NilClass]
69
+ # @param external_data [Hash]
70
+ # @return [TrueClass, FalseClass, NilClass]
71
+ #
72
+ def update_payment_session(payment_session:, amount: nil, external_data: {})
73
+ attrs = {}
74
+ attrs[:amount] = amount if amount.present?
75
+
76
+ attrs[:external_data] = (payment_session.external_data || {}).merge(external_data.stringify_keys) if external_data.present?
77
+
78
+ payment_session.update!(attrs) if attrs.any?
79
+ end
80
+
81
+ ##
82
+ # Captures the PayPal order and creates the Spree payment.
83
+ #
84
+ # Does not complete the order — that is handled by Carts::Complete.
85
+ #
86
+ # @param payment_session [Spree::PaymentSessions::PaypalCheckout]
87
+ # @param params [Hash]
88
+ # @return [void]
89
+ #
90
+ def complete_payment_session(payment_session:, **_unused)
91
+ paypal_order_id = payment_session.external_id
92
+
93
+ response = client.orders.capture_order({
94
+ 'id' => paypal_order_id,
95
+ 'prefer' => 'return=representation'
96
+ })
97
+
98
+ payment_session.update!(external_data: response.data.as_json)
99
+
100
+ payment_session.order.with_lock do
101
+ if response.data.status == 'COMPLETED'
102
+ payment_session.process if payment_session.can_process?
103
+
104
+ payment = payment_session.find_or_create_payment!
105
+
106
+ if payment.present? && !payment.completed?
107
+ payment.started_processing! if payment.checkout?
108
+ payment.complete! if payment.can_complete?
109
+ end
110
+
111
+ create_profile(payment) if payment&.source.present?
112
+
113
+ payment_session.complete unless payment_session.completed?
114
+ elsif payment_session.can_fail?
115
+ payment_session.fail
116
+ end
117
+ end
118
+ rescue PaypalServerSdk::APIException => e
119
+ payment_session.fail if payment_session.can_fail?
120
+ raise Spree::Core::GatewayError, "PayPal API error: #{e.message}"
121
+ end
122
+
123
+ ##
124
+ # Parses an incoming PayPal webhook into a payment-session action.
125
+ #
126
+ # @param raw_body [String]
127
+ # @param headers [Hash]
128
+ # @return [Hash, NilClass]
129
+ #
130
+ def parse_webhook_event(raw_body, headers)
131
+ verify_webhook_signature!(raw_body, headers)
132
+
133
+ event = JSON.parse(raw_body).with_indifferent_access
134
+ event_type = event[:event_type]
135
+ resource = event[:resource] || {}
136
+
137
+ paypal_order_id = extract_order_id_from_webhook(event_type, resource)
138
+ return nil unless paypal_order_id
139
+
140
+ payment_session = Spree::PaymentSessions::PaypalCheckout.find_by(
141
+ payment_method: self,
142
+ external_id: paypal_order_id
143
+ )
144
+ return nil unless payment_session
145
+
146
+ case event_type
147
+ when 'CHECKOUT.ORDER.APPROVED'
148
+ { action: :authorized, payment_session: payment_session, metadata: { paypal_event: event } }
149
+ when 'PAYMENT.CAPTURE.COMPLETED'
150
+ { action: :captured, payment_session: payment_session, metadata: { paypal_event: event } }
151
+ when 'PAYMENT.CAPTURE.DENIED', 'PAYMENT.CAPTURE.DECLINED'
152
+ { action: :failed, payment_session: payment_session, metadata: { paypal_event: event } }
153
+ when 'PAYMENT.CAPTURE.REVERSED', 'PAYMENT.CAPTURE.REFUNDED'
154
+ { action: :canceled, payment_session: payment_session, metadata: { paypal_event: event } }
155
+ end
156
+ end
157
+
158
+ private
159
+
160
+ def extract_order_id_from_webhook(event_type, resource)
161
+ case event_type
162
+ when /\ACHECKOUT\.ORDER\./
163
+ resource['id']
164
+ when /\APAYMENT\.CAPTURE\./
165
+ resource.dig('supplementary_data', 'related_ids', 'order_id')
166
+ end
167
+ end
168
+
169
+ def verify_webhook_signature!(raw_body, headers)
170
+ if preferred_webhook_secret.blank?
171
+ return if Rails.env.local?
172
+
173
+ raise Spree::PaymentMethod::WebhookSignatureError,
174
+ 'PayPal webhook_secret is not configured'
175
+ end
176
+
177
+ transmission_id = headers['HTTP_PAYPAL_TRANSMISSION_ID'] || headers['PAYPAL-TRANSMISSION-ID']
178
+ transmission_time = headers['HTTP_PAYPAL_TRANSMISSION_TIME'] || headers['PAYPAL-TRANSMISSION-TIME']
179
+ cert_url = headers['HTTP_PAYPAL_CERT_URL'] || headers['PAYPAL-CERT-URL']
180
+ auth_algo = headers['HTTP_PAYPAL_AUTH_ALGO'] || headers['PAYPAL-AUTH-ALGO']
181
+ transmission_sig = headers['HTTP_PAYPAL_TRANSMISSION_SIG'] || headers['PAYPAL-TRANSMISSION-SIG']
182
+
183
+ raise Spree::PaymentMethod::WebhookSignatureError, 'Missing PayPal webhook headers' unless transmission_id && transmission_sig
184
+
185
+ token = obtain_access_token
186
+
187
+ uri = URI("#{api_base}/v1/notifications/verify-webhook-signature")
188
+
189
+ payload = {
190
+ auth_algo: auth_algo,
191
+ cert_url: cert_url,
192
+ transmission_id: transmission_id,
193
+ transmission_sig: transmission_sig,
194
+ transmission_time: transmission_time,
195
+ webhook_id: preferred_webhook_secret,
196
+ webhook_event: JSON.parse(raw_body)
197
+ }
198
+
199
+ http = Net::HTTP.new(uri.host, uri.port)
200
+ http.use_ssl = true
201
+ http.open_timeout = 5
202
+ http.read_timeout = 10
203
+ request = Net::HTTP::Post.new(uri.path, {
204
+ 'Content-Type' => 'application/json',
205
+ 'Authorization' => "Bearer #{token}"
206
+ })
207
+ request.body = payload.to_json
208
+
209
+ response = http.request(request)
210
+ raise Spree::PaymentMethod::WebhookSignatureError, "Webhook verification failed: HTTP #{response.code}" unless http_success?(response)
211
+
212
+ result = JSON.parse(response.body)
213
+
214
+ raise Spree::PaymentMethod::WebhookSignatureError, 'Invalid webhook signature' unless result['verification_status'] == 'SUCCESS'
215
+ rescue Spree::PaymentMethod::WebhookSignatureError
216
+ raise
217
+ rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, JSON::ParserError, Errno::ECONNREFUSED => e
218
+ raise Spree::PaymentMethod::WebhookSignatureError, "Webhook verification failed: #{e.message}"
219
+ end
220
+
221
+ def obtain_access_token
222
+ uri = URI("#{api_base}/v1/oauth2/token")
223
+
224
+ http = Net::HTTP.new(uri.host, uri.port)
225
+ http.use_ssl = true
226
+ http.open_timeout = 5
227
+ http.read_timeout = 10
228
+ request = Net::HTTP::Post.new(uri.path, { 'Content-Type' => 'application/x-www-form-urlencoded' })
229
+ request.basic_auth(preferred_client_id, preferred_client_secret)
230
+ request.body = 'grant_type=client_credentials'
231
+
232
+ response = http.request(request)
233
+ return nil unless http_success?(response)
234
+
235
+ JSON.parse(response.body)['access_token']
236
+ end
237
+
238
+ ##
239
+ # Short-lived client token for Card Fields. Returns nil on failure.
240
+ #
241
+ # @return [String, NilClass]
242
+ #
243
+ def generate_client_token
244
+ token = obtain_access_token
245
+ return nil if token.blank?
246
+
247
+ uri = URI("#{api_base}/v1/identity/generate-token")
248
+
249
+ http = Net::HTTP.new(uri.host, uri.port)
250
+ http.use_ssl = true
251
+ http.open_timeout = 5
252
+ http.read_timeout = 10
253
+ request = Net::HTTP::Post.new(uri.path, {
254
+ 'Content-Type' => 'application/json',
255
+ 'Accept-Language' => 'en_US',
256
+ 'Authorization' => "Bearer #{token}"
257
+ })
258
+
259
+ response = http.request(request)
260
+ return nil unless http_success?(response)
261
+
262
+ JSON.parse(response.body)['client_token']
263
+ rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, JSON::ParserError, Errno::ECONNREFUSED => e
264
+ Rails.logger.warn("[spree-paypal_platform] client token generation failed: #{e.message}")
265
+ nil
266
+ end
267
+
268
+ def api_base
269
+ preferred_test_mode ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'
270
+ end
271
+
272
+ def http_success?(response)
273
+ response.code.to_i.between?(200, 299)
274
+ end
275
+ end
276
+ end
277
+ end
278
+ end