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
@@ -0,0 +1,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+
5
+ module Spree
6
+ module PaypalCheckout
7
+ ##
8
+ # PayPal Checkout payment method.
9
+ #
10
+ # One method covers the PayPal wallet, Apple Pay, and Card Fields. Extra
11
+ # wallets are recorded as {PaymentSources} on the same gateway rather than
12
+ # as separate payment methods.
13
+ #
14
+ class Gateway < ::Spree::Gateway
15
+ include PaymentSessions
16
+
17
+ GatewayResponse = Struct.new(:success, :message, :params, :authorization) do
18
+ alias_method :success?, :success
19
+ end
20
+
21
+ preference :client_id, :string
22
+ preference :client_secret, :password
23
+ preference :webhook_secret, :string
24
+ preference :test_mode, :boolean, default: true
25
+ preference :enable_apple_pay, :boolean, default: true
26
+ preference :apple_pay_domain, :string
27
+
28
+ validates :preferred_client_id, :preferred_client_secret, presence: true
29
+
30
+ ##
31
+ # @return [Class]
32
+ #
33
+ def provider_class
34
+ self.class
35
+ end
36
+
37
+ ##
38
+ # Default source class for admin "previous cards" lookups. Actual
39
+ # captured payments may also be {PaymentSources::ApplePay} or
40
+ # {PaymentSources::Card}.
41
+ #
42
+ # @return [Class]
43
+ #
44
+ def payment_source_class
45
+ PaymentSources::Paypal
46
+ end
47
+
48
+ ##
49
+ # @return [TrueClass]
50
+ #
51
+ def payment_profiles_supported?
52
+ true
53
+ end
54
+
55
+ ##
56
+ # @return [String]
57
+ #
58
+ def default_name
59
+ 'PayPal'
60
+ end
61
+
62
+ ##
63
+ # @return [String]
64
+ #
65
+ def method_type
66
+ 'spree_paypal_checkout'
67
+ end
68
+
69
+ ##
70
+ # @return [String]
71
+ #
72
+ def payment_icon_name
73
+ 'paypal'
74
+ end
75
+
76
+ ##
77
+ # @return [String]
78
+ #
79
+ def description_partial_name
80
+ 'spree_paypal_checkout'
81
+ end
82
+
83
+ ##
84
+ # @return [String]
85
+ #
86
+ def configuration_guide_partial_name
87
+ 'spree_paypal_checkout'
88
+ end
89
+
90
+ ##
91
+ # @return [String]
92
+ #
93
+ def source_partial_name
94
+ 'paypal_checkout'
95
+ end
96
+
97
+ ##
98
+ # Whether the storefront should offer Apple Pay for this method.
99
+ #
100
+ # Domain registration with PayPal is still required; this flag only
101
+ # advertises the capability.
102
+ #
103
+ # @return [TrueClass, FalseClass]
104
+ #
105
+ def apple_pay_enabled?
106
+ preferred_enable_apple_pay
107
+ end
108
+
109
+ ##
110
+ # @return [String, NilClass]
111
+ #
112
+ def webhook_url
113
+ return nil unless store
114
+
115
+ "#{store.url_or_custom_domain}/api/v3/webhooks/payments/#{prefixed_id}"
116
+ end
117
+
118
+ ##
119
+ # Persist a gateway-customer profile for a signed-in buyer.
120
+ #
121
+ # @param payment [Spree::Payment]
122
+ # @return [Spree::GatewayCustomer, NilClass]
123
+ #
124
+ def create_profile(payment)
125
+ user = payment.order.user
126
+ return if user.blank?
127
+ return if payment.source.blank?
128
+ return unless payment.source.is_a?(PaymentSources::Paypal)
129
+
130
+ paypal_account_id = payment.source.account_id
131
+ return if paypal_account_id.blank?
132
+
133
+ payment.payment_method.gateway_customers.find_or_create_by(user: user, profile_id: paypal_account_id)
134
+ end
135
+
136
+ ##
137
+ # @return [PaypalServerSdk::Client]
138
+ #
139
+ def client
140
+ @client ||= PaypalServerSdk::Client.new(
141
+ client_credentials_auth_credentials: PaypalServerSdk::ClientCredentialsAuthCredentials.new(
142
+ o_auth_client_id: preferred_client_id,
143
+ o_auth_client_secret: preferred_client_secret
144
+ ),
145
+ environment: preferred_test_mode ? PaypalServerSdk::Environment::SANDBOX : PaypalServerSdk::Environment::PRODUCTION,
146
+ logging_configuration: PaypalServerSdk::LoggingConfiguration.new(
147
+ log_level: Logger::WARN,
148
+ request_logging_config: PaypalServerSdk::RequestLoggingConfiguration.new(
149
+ log_body: false
150
+ ),
151
+ response_logging_config: PaypalServerSdk::ResponseLoggingConfiguration.new(
152
+ log_headers: false
153
+ )
154
+ )
155
+ )
156
+ end
157
+
158
+ ##
159
+ # @raise [NotImplementedError] always — authorize-then-capture is unused
160
+ #
161
+ def authorize(_amount_in_cents, _payment_source, _gateway_options = {})
162
+ raise NotImplementedError, 'PayPal Checkout captures in one step; use #purchase'
163
+ end
164
+
165
+ ##
166
+ # Purchase is authorize + capture in one step.
167
+ #
168
+ # @param amount_in_cents [Integer]
169
+ # @param payment_source [#paypal_id]
170
+ # @param gateway_options [Hash]
171
+ # @return [GatewayResponse]
172
+ #
173
+ def purchase(amount_in_cents, payment_source, gateway_options = {})
174
+ capture(amount_in_cents, payment_source.paypal_id, gateway_options)
175
+ end
176
+
177
+ ##
178
+ # Capture a previously created PayPal order.
179
+ #
180
+ # @param amount_in_cents [Integer]
181
+ # @param paypal_id [String] PayPal order ID
182
+ # @param gateway_options [Hash]
183
+ # @return [GatewayResponse]
184
+ #
185
+ def capture(_amount_in_cents, paypal_id, gateway_options = {})
186
+ protect_from_error do
187
+ order = find_order(gateway_options[:order_id])
188
+ return failure('Order not found') unless order
189
+
190
+ response = client.orders.capture_order({
191
+ 'id' => paypal_id,
192
+ 'prefer' => 'return=representation'
193
+ })
194
+
195
+ if response.data.status == 'COMPLETED'
196
+ success(response.data.id, response.data.as_json)
197
+ else
198
+ failure('Failed to capture PayPal payment', response.data)
199
+ end
200
+ end
201
+ end
202
+
203
+ ##
204
+ # Void a PayPal authorization.
205
+ #
206
+ # @param authorization [String]
207
+ # @param _source [Object]
208
+ # @param gateway_options [Hash]
209
+ # @return [GatewayResponse]
210
+ #
211
+ def void(authorization, _source, _gateway_options = {})
212
+ protect_from_error do
213
+ response = client.payments.void_payment({
214
+ 'authorization_id' => authorization,
215
+ 'prefer' => 'return=representation'
216
+ })
217
+
218
+ success(authorization, response.data.as_json)
219
+ end
220
+ end
221
+
222
+ ##
223
+ # Refund a captured PayPal payment.
224
+ #
225
+ # @param amount_in_cents [Integer]
226
+ # @param _payment_source [Object]
227
+ # @param paypal_payment_id [String]
228
+ # @param gateway_options [Hash]
229
+ # @return [GatewayResponse]
230
+ #
231
+ def credit(amount_in_cents, _payment_source, paypal_payment_id, gateway_options = {})
232
+ refund_originator = gateway_options[:originator]
233
+ order = refund_originator.respond_to?(:order) ? refund_originator.order : refund_originator
234
+
235
+ return failure('Order not found') unless order
236
+
237
+ protect_from_error do
238
+ payload = {
239
+ capture_id: paypal_payment_id,
240
+ amount: {
241
+ value: (amount_in_cents / 100.0).to_s,
242
+ currency_code: order.currency.upcase
243
+ }
244
+ }.deep_stringify_keys
245
+
246
+ response = client.payments.refund_captured_payment(payload)
247
+
248
+ success(response.data.id, response.data.as_json)
249
+ end
250
+ end
251
+
252
+ ##
253
+ # Cancel a payment: refund if captured, otherwise void.
254
+ #
255
+ # @param authorization [String]
256
+ # @param payment [Spree::Payment, NilClass]
257
+ # @return [GatewayResponse]
258
+ #
259
+ def cancel(authorization, payment = nil)
260
+ protect_from_error do
261
+ if payment&.completed?
262
+ amount = payment.credit_allowed
263
+ return success(authorization, {}) if amount.zero?
264
+
265
+ refund = payment.refunds.create!(
266
+ amount: amount,
267
+ reason: Spree::RefundReason.order_canceled_reason,
268
+ refunder_id: payment.order.canceler_id
269
+ )
270
+
271
+ success(payment.response_code, refund.response.params)
272
+ else
273
+ response = client.payments.void_payment({
274
+ 'authorization_id' => authorization,
275
+ 'prefer' => 'return=representation'
276
+ })
277
+
278
+ success(authorization, response.data.as_json)
279
+ end
280
+ end
281
+ end
282
+
283
+ private
284
+
285
+ def find_order(order_id)
286
+ return nil unless order_id
287
+
288
+ order_number, _payment_number = order_id.split('-')
289
+ Spree::Order.find_by(number: order_number)
290
+ end
291
+
292
+ def protect_from_error
293
+ yield
294
+ rescue PaypalServerSdk::APIException => e
295
+ raise Spree::Core::GatewayError, "PayPal API error: #{e.message}"
296
+ rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, Errno::ECONNREFUSED => e
297
+ raise Spree::Core::GatewayError, "PayPal connection error: #{e.message}"
298
+ end
299
+
300
+ def success(authorization, response)
301
+ GatewayResponse.new(true, 'Transaction successful', response, authorization)
302
+ end
303
+
304
+ def failure(message, response = {})
305
+ GatewayResponse.new(false, message, response, nil)
306
+ end
307
+ end
308
+ end
309
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module PaypalCheckout
5
+ ##
6
+ # A persisted PayPal Orders API response, used by the legacy
7
+ # `/api/v2/storefront/paypal_orders` flow.
8
+ #
9
+ # Payment-session hosts do not write this table; they keep the same
10
+ # payload on `Spree::PaymentSession#external_data`.
11
+ #
12
+ class Order < Base
13
+ class NotCapturedError < StandardError; end
14
+ class AlreadyCapturedError < StandardError; end
15
+
16
+ belongs_to :order, class_name: 'Spree::Order'
17
+ belongs_to :payment_method, class_name: 'Spree::PaymentMethod'
18
+ alias gateway payment_method
19
+
20
+ before_validation :set_amount_from_order, on: :create
21
+
22
+ validates :paypal_id, presence: true, uniqueness: true
23
+ validates :data, presence: true
24
+ validates :amount, numericality: { greater_than: 0 }, presence: true
25
+
26
+ store_accessor :data, :payer, :purchase_units, :payment_source, :status
27
+
28
+ ##
29
+ # Create a Spree::Payment for this captured PayPal order.
30
+ #
31
+ # @return [Spree::Payment]
32
+ # @raise [NotCapturedError] when the PayPal order is not COMPLETED
33
+ #
34
+ def create_payment!
35
+ raise NotCapturedError unless completed?
36
+
37
+ CreatePayment.new(
38
+ order: order,
39
+ paypal_order: self,
40
+ gateway: payment_method,
41
+ amount: amount
42
+ ).call
43
+ end
44
+
45
+ ##
46
+ # Capture the PayPal order via the Orders API.
47
+ #
48
+ # @return [Spree::PaypalCheckout::Order]
49
+ # @raise [AlreadyCapturedError] when already COMPLETED
50
+ #
51
+ def capture!
52
+ raise AlreadyCapturedError if completed?
53
+
54
+ CaptureOrder.new(paypal_order: self).call
55
+ end
56
+
57
+ ##
58
+ # Capture ID from the stored payload. Only present after capture.
59
+ #
60
+ # @return [String, NilClass]
61
+ #
62
+ def paypal_payment_id
63
+ @paypal_payment_id ||= data.dig('purchase_units', 0, 'payments', 'captures', 0, 'id')
64
+ end
65
+
66
+ ##
67
+ # Fresh PayPal order from the API.
68
+ #
69
+ # @return [PaypalServerSdk::ApiResponse]
70
+ #
71
+ def paypal_order
72
+ @paypal_order ||= gateway.client.orders.get_order({ 'id' => paypal_id })
73
+ end
74
+
75
+ ##
76
+ # @return [TrueClass, FalseClass]
77
+ #
78
+ def completed?
79
+ status == 'COMPLETED'
80
+ end
81
+
82
+ private
83
+
84
+ def set_amount_from_order
85
+ self.amount ||= order&.total
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module PaypalCheckout
5
+ ##
6
+ # Adds the legacy PayPal-order association onto {Spree::Order}.
7
+ #
8
+ module OrderDecorator
9
+ ##
10
+ # @param base [Class]
11
+ # @return [void]
12
+ #
13
+ def self.prepended(base)
14
+ base.store_accessor :private_metadata, :paypal_id
15
+ base.has_many :paypal_checkout_orders,
16
+ class_name: 'Spree::PaypalCheckout::Order',
17
+ dependent: :destroy,
18
+ foreign_key: :order_id
19
+ end
20
+ end
21
+ end
22
+ end
23
+
24
+ Spree::Order.prepend(Spree::PaypalCheckout::OrderDecorator)
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module PaypalCheckout
5
+ ##
6
+ # Adds a paypal_checkout scope and predicate onto {Spree::PaymentMethod}.
7
+ #
8
+ module PaymentMethodDecorator
9
+ ##
10
+ # @param base [Class]
11
+ # @return [void]
12
+ #
13
+ def self.prepended(base)
14
+ base.scope :paypal_checkout, lambda {
15
+ where(type: Spree::PaypalCheckout.gateway_type_names)
16
+ }
17
+ end
18
+
19
+ ##
20
+ # @return [TrueClass, FalseClass]
21
+ #
22
+ def paypal_checkout?
23
+ Spree::PaypalCheckout.gateway_type_names.include?(type)
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ Spree::PaymentMethod.prepend(Spree::PaypalCheckout::PaymentMethodDecorator)
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module PaypalCheckout
5
+ module PaymentSources
6
+ ##
7
+ # An Apple Pay wallet used through PayPal Checkout.
8
+ #
9
+ # PayPal returns `payment_source.apple_pay` on the captured order.
10
+ # The card brand / last digits (when present) are stored so admin
11
+ # can show "Apple Pay · Visa 4242" rather than a bare wallet name.
12
+ #
13
+ class ApplePay < ::Spree::PaymentSource
14
+ store_accessor :private_metadata,
15
+ :card_brand,
16
+ :last_digits,
17
+ :card_type,
18
+ :name,
19
+ :email
20
+
21
+ ##
22
+ # @return [Array<String>]
23
+ #
24
+ def actions
25
+ %w[credit void]
26
+ end
27
+
28
+ ##
29
+ # @return [String]
30
+ #
31
+ def self.display_name
32
+ 'Apple Pay'
33
+ end
34
+
35
+ ##
36
+ # Human-readable wallet line for admin and order emails.
37
+ #
38
+ # @return [String]
39
+ #
40
+ def display_payment_info
41
+ parts = ['Apple Pay']
42
+ parts << card_brand.to_s.titleize if card_brand.present?
43
+ parts << last_digits if last_digits.present?
44
+ parts.join(' · ')
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module PaypalCheckout
5
+ module PaymentSources
6
+ ##
7
+ # An Advanced Credit and Debit Card (Card Fields) payment.
8
+ #
9
+ class Card < ::Spree::PaymentSource
10
+ store_accessor :private_metadata,
11
+ :card_brand,
12
+ :last_digits,
13
+ :card_type,
14
+ :name
15
+
16
+ ##
17
+ # @return [Array<String>]
18
+ #
19
+ def actions
20
+ %w[credit void]
21
+ end
22
+
23
+ ##
24
+ # @return [String]
25
+ #
26
+ def self.display_name
27
+ 'Card'
28
+ end
29
+
30
+ ##
31
+ # Human-readable card line for admin and order emails.
32
+ #
33
+ # @return [String]
34
+ #
35
+ def display_payment_info
36
+ parts = [card_brand.to_s.titleize.presence, last_digits].compact
37
+ parts.join(' · ').presence || 'Card'
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module PaypalCheckout
5
+ module PaymentSources
6
+ ##
7
+ # A PayPal wallet account captured as a Spree payment source.
8
+ #
9
+ class Paypal < ::Spree::PaymentSource
10
+ store_accessor :private_metadata, :email, :name, :account_status, :account_id
11
+
12
+ ##
13
+ # @return [Array<String>]
14
+ #
15
+ def actions
16
+ %w[credit void]
17
+ end
18
+
19
+ ##
20
+ # @return [String]
21
+ #
22
+ def self.display_name
23
+ 'PayPal'
24
+ end
25
+
26
+ ##
27
+ # Human-readable wallet line for admin and order emails.
28
+ #
29
+ # @return [String]
30
+ #
31
+ def display_payment_info
32
+ ['PayPal', account_id].compact.join(' · ')
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module PaypalCheckout
5
+ ##
6
+ # Looks up the store's active PayPal Checkout gateway.
7
+ #
8
+ module StoreDecorator
9
+ ##
10
+ # @return [Spree::PaypalCheckout::Gateway, NilClass]
11
+ #
12
+ def paypal_checkout_gateway
13
+ @paypal_checkout_gateway ||= payment_methods.paypal_checkout.active.last
14
+ end
15
+ end
16
+ end
17
+ end
18
+
19
+ Spree::Store.prepend(Spree::PaypalCheckout::StoreDecorator)