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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +34 -0
- data/LICENSE +10 -0
- data/README.md +117 -0
- data/Rakefile +27 -0
- data/app/models/spree/payment_sessions/paypal_checkout.rb +94 -0
- data/app/models/spree/paypal_checkout/base.rb +14 -0
- data/app/models/spree/paypal_checkout/gateway/payment_sessions.rb +278 -0
- data/app/models/spree/paypal_checkout/gateway.rb +309 -0
- data/app/models/spree/paypal_checkout/order.rb +89 -0
- data/app/models/spree/paypal_checkout/order_decorator.rb +24 -0
- data/app/models/spree/paypal_checkout/payment_method_decorator.rb +29 -0
- data/app/models/spree/paypal_checkout/payment_sources/apple_pay.rb +49 -0
- data/app/models/spree/paypal_checkout/payment_sources/card.rb +42 -0
- data/app/models/spree/paypal_checkout/payment_sources/paypal.rb +37 -0
- data/app/models/spree/paypal_checkout/store_decorator.rb +19 -0
- data/app/presenters/spree/paypal_checkout/order_presenter.rb +130 -0
- data/app/services/spree/paypal_checkout/capture_order.rb +44 -0
- data/app/services/spree/paypal_checkout/create_payment.rb +49 -0
- data/app/services/spree/paypal_checkout/create_source.rb +109 -0
- data/app/views/spree/admin/payment_methods/configuration_guides/_spree_paypal_checkout.html.erb +16 -0
- data/app/views/spree/admin/payment_methods/descriptions/_spree_paypal_checkout.html.erb +10 -0
- data/app/views/spree/admin/payments/source_forms/_spree_paypal_checkout.html.erb +7 -0
- data/app/views/spree/payment_sources/_paypal_checkout.html.erb +17 -0
- data/config/initializers/spree.rb +8 -0
- data/db/migrate/20250528095719_create_spree_paypal_checkout_orders.rb +22 -0
- data/db/migrate/20260817120000_rewrite_spree_paypal_checkout_sti_types.rb +33 -0
- data/db/migrate/20260817130000_add_unique_index_on_paypal_checkout_order_paypal_id.rb +7 -0
- data/lib/generators/spree/paypal_checkout/install/install_generator.rb +39 -0
- data/lib/spree/paypal_checkout/engine.rb +41 -0
- data/lib/spree/paypal_checkout/factories.rb +59 -0
- data/lib/spree/paypal_checkout/version.rb +8 -0
- data/lib/spree/paypal_checkout.rb +45 -0
- data/lib/spree-paypal_checkout.rb +7 -0
- data/lib/spree-paypal_platform.rb +7 -0
- metadata +178 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'paypal_server_sdk'
|
|
4
|
+
|
|
5
|
+
module Spree
|
|
6
|
+
module PaypalCheckout
|
|
7
|
+
##
|
|
8
|
+
# Builds a PayPal Orders API create-order payload from a Spree order.
|
|
9
|
+
#
|
|
10
|
+
# The storefront owns the address: we send `purchase_units[].shipping`
|
|
11
|
+
# from `order.ship_address` and pin it with `SET_PROVIDED_ADDRESS` so
|
|
12
|
+
# PayPal does not re-collect it. The amount breakdown sends only
|
|
13
|
+
# `additional_tax_total` — sending full `tax_total` double-counts
|
|
14
|
+
# included VAT and trips PayPal `AMOUNT_MISMATCH`.
|
|
15
|
+
#
|
|
16
|
+
class OrderPresenter
|
|
17
|
+
PAYPAL_ITEM_NAME_MAX_LENGTH = 127
|
|
18
|
+
|
|
19
|
+
##
|
|
20
|
+
# @param order [Spree::Order]
|
|
21
|
+
#
|
|
22
|
+
def initialize(order)
|
|
23
|
+
@order = order
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
attr_reader :order
|
|
27
|
+
|
|
28
|
+
##
|
|
29
|
+
# @return [Hash] body ready for `client.orders.create_order`
|
|
30
|
+
#
|
|
31
|
+
def to_json(*_args)
|
|
32
|
+
{
|
|
33
|
+
'body' => PaypalServerSdk::OrderRequest.new(
|
|
34
|
+
intent: PaypalServerSdk::CheckoutPaymentIntent::CAPTURE,
|
|
35
|
+
purchase_units: [purchase_unit],
|
|
36
|
+
payment_source: payment_source
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def purchase_unit
|
|
44
|
+
args = {
|
|
45
|
+
amount: amount_with_breakdown,
|
|
46
|
+
items: items
|
|
47
|
+
}
|
|
48
|
+
args[:shipping] = shipping_details if shipping_details
|
|
49
|
+
|
|
50
|
+
PaypalServerSdk::PurchaseUnitRequest.new(**args)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# NOTE: the breakdown only sends additional (non-included) tax — sending
|
|
54
|
+
# the full tax_total double-counts VAT on tax-inclusive markets and
|
|
55
|
+
# trips AMOUNT_MISMATCH. Do not change to order.tax_total.
|
|
56
|
+
def amount_with_breakdown
|
|
57
|
+
PaypalServerSdk::AmountWithBreakdown.new(
|
|
58
|
+
currency_code: order.currency.upcase,
|
|
59
|
+
value: order.total.to_s,
|
|
60
|
+
breakdown: PaypalServerSdk::AmountBreakdown.new(
|
|
61
|
+
item_total: paypal_money(order.item_total),
|
|
62
|
+
shipping: paypal_money(order.ship_total),
|
|
63
|
+
tax_total: paypal_money(order.additional_tax_total),
|
|
64
|
+
discount: paypal_money((order.promo_total || 0).abs)
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def paypal_money(amount)
|
|
70
|
+
PaypalServerSdk::Money.new(
|
|
71
|
+
currency_code: order.currency.upcase,
|
|
72
|
+
value: amount.to_s
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def items
|
|
77
|
+
order.line_items.map do |line_item|
|
|
78
|
+
PaypalServerSdk::Item.new(
|
|
79
|
+
name: line_item.name.to_s[0...PAYPAL_ITEM_NAME_MAX_LENGTH],
|
|
80
|
+
unit_amount: paypal_money(line_item.price),
|
|
81
|
+
quantity: line_item.quantity.to_s,
|
|
82
|
+
sku: line_item.sku,
|
|
83
|
+
category: line_item.variant.digital? ? PaypalServerSdk::ItemCategory::DIGITAL_GOODS : PaypalServerSdk::ItemCategory::PHYSICAL_GOODS
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def shipping_details
|
|
89
|
+
address = order.ship_address
|
|
90
|
+
return nil if address.blank?
|
|
91
|
+
|
|
92
|
+
PaypalServerSdk::ShippingDetails.new(
|
|
93
|
+
name: PaypalServerSdk::ShippingName.new(full_name: address.full_name),
|
|
94
|
+
address: PaypalServerSdk::Address.new(
|
|
95
|
+
country_code: address.country&.iso,
|
|
96
|
+
address_line_1: address.address1,
|
|
97
|
+
address_line_2: address.address2.presence,
|
|
98
|
+
admin_area_2: address.city,
|
|
99
|
+
admin_area_1: region_code(address),
|
|
100
|
+
postal_code: address.zipcode
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def region_code(address)
|
|
106
|
+
address.state&.abbr.presence || address.state_name.presence
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def payment_source
|
|
110
|
+
PaypalServerSdk::PaymentSource.new(
|
|
111
|
+
paypal: PaypalServerSdk::PaypalWallet.new(
|
|
112
|
+
experience_context: PaypalServerSdk::PaypalWalletExperienceContext.new(
|
|
113
|
+
brand_name: order.store&.name,
|
|
114
|
+
shipping_preference: shipping_preference,
|
|
115
|
+
user_action: PaypalServerSdk::PaypalExperienceUserAction::PAY_NOW
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def shipping_preference
|
|
122
|
+
if order.ship_address.present?
|
|
123
|
+
PaypalServerSdk::PaypalWalletContextShippingPreference::SET_PROVIDED_ADDRESS
|
|
124
|
+
else
|
|
125
|
+
PaypalServerSdk::PaypalWalletContextShippingPreference::NO_SHIPPING
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module PaypalCheckout
|
|
5
|
+
##
|
|
6
|
+
# Captures a legacy {Order} via the PayPal Orders API and completes
|
|
7
|
+
# the Spree checkout.
|
|
8
|
+
#
|
|
9
|
+
class CaptureOrder
|
|
10
|
+
##
|
|
11
|
+
# @param paypal_order [Spree::PaypalCheckout::Order]
|
|
12
|
+
#
|
|
13
|
+
def initialize(paypal_order:)
|
|
14
|
+
@paypal_order = paypal_order
|
|
15
|
+
@order = paypal_order.order
|
|
16
|
+
@gateway = paypal_order.gateway
|
|
17
|
+
@amount = paypal_order.amount
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
attr_reader :paypal_order, :order, :gateway, :amount
|
|
21
|
+
|
|
22
|
+
##
|
|
23
|
+
# @return [Spree::PaypalCheckout::Order]
|
|
24
|
+
#
|
|
25
|
+
def call
|
|
26
|
+
return paypal_order if order.completed? || order.canceled?
|
|
27
|
+
|
|
28
|
+
gateway_response = gateway.capture(
|
|
29
|
+
(amount.to_d * 100).to_i,
|
|
30
|
+
paypal_order.paypal_id,
|
|
31
|
+
{ order_id: order.number }
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
order.with_lock do
|
|
35
|
+
paypal_order.update!(data: gateway_response.params)
|
|
36
|
+
paypal_order.create_payment!
|
|
37
|
+
Spree::Dependencies.checkout_complete_service.constantize.call(order: order)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
paypal_order
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module PaypalCheckout
|
|
5
|
+
##
|
|
6
|
+
# Creates the Spree::Payment for a captured legacy PayPal order.
|
|
7
|
+
#
|
|
8
|
+
class CreatePayment
|
|
9
|
+
##
|
|
10
|
+
# @param paypal_order [Spree::PaypalCheckout::Order]
|
|
11
|
+
# @param order [Spree::Order, NilClass]
|
|
12
|
+
# @param gateway [Spree::PaypalCheckout::Gateway, NilClass]
|
|
13
|
+
# @param amount [Numeric, NilClass]
|
|
14
|
+
#
|
|
15
|
+
def initialize(paypal_order:, order: nil, gateway: nil, amount: nil)
|
|
16
|
+
@paypal_order = paypal_order
|
|
17
|
+
@order = order || paypal_order.order
|
|
18
|
+
@gateway = gateway || paypal_order.gateway
|
|
19
|
+
@amount = amount || paypal_order.amount
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
##
|
|
23
|
+
# @return [Spree::Payment]
|
|
24
|
+
#
|
|
25
|
+
def call
|
|
26
|
+
source = CreateSource.new(
|
|
27
|
+
paypal_payment_source: paypal_order.payment_source,
|
|
28
|
+
gateway: gateway,
|
|
29
|
+
order: order
|
|
30
|
+
).call
|
|
31
|
+
|
|
32
|
+
payment = order.payments.find_or_initialize_by(
|
|
33
|
+
payment_method_id: gateway.id,
|
|
34
|
+
response_code: paypal_order.paypal_payment_id,
|
|
35
|
+
amount: amount
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
payment.source = source if source.present?
|
|
39
|
+
payment.state = 'completed'
|
|
40
|
+
payment.save!
|
|
41
|
+
payment
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
attr_reader :order, :gateway, :paypal_order, :amount
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module PaypalCheckout
|
|
5
|
+
##
|
|
6
|
+
# Builds a {PaymentSources} record from a PayPal Orders `payment_source`.
|
|
7
|
+
#
|
|
8
|
+
# PayPal returns one of `paypal`, `apple_pay`, or `card` (Card Fields).
|
|
9
|
+
# Unknown keys raise so a new wallet is not silently dropped.
|
|
10
|
+
#
|
|
11
|
+
class CreateSource
|
|
12
|
+
##
|
|
13
|
+
# @param paypal_payment_source [Hash]
|
|
14
|
+
# @param gateway [Spree::PaypalCheckout::Gateway]
|
|
15
|
+
# @param order [Spree::Order, NilClass]
|
|
16
|
+
# @param user [Spree::User, NilClass]
|
|
17
|
+
#
|
|
18
|
+
def initialize(paypal_payment_source:, gateway:, order: nil, user: nil)
|
|
19
|
+
@paypal_payment_source = paypal_payment_source.with_indifferent_access
|
|
20
|
+
@gateway = gateway
|
|
21
|
+
@user = user || order&.user
|
|
22
|
+
@order = order
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
##
|
|
26
|
+
# @return [Spree::PaymentSource]
|
|
27
|
+
# @raise [ArgumentError] when the payload has no known wallet key
|
|
28
|
+
#
|
|
29
|
+
def call
|
|
30
|
+
if paypal_payment_source[:paypal].present?
|
|
31
|
+
create_paypal_source
|
|
32
|
+
elsif paypal_payment_source[:apple_pay].present?
|
|
33
|
+
create_apple_pay_source
|
|
34
|
+
elsif paypal_payment_source[:card].present?
|
|
35
|
+
create_card_source
|
|
36
|
+
else
|
|
37
|
+
raise ArgumentError, "Unsupported PayPal payment source: #{paypal_payment_source.keys.join(', ')}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
attr_reader :gateway, :user, :paypal_payment_source, :order
|
|
44
|
+
|
|
45
|
+
def create_paypal_source
|
|
46
|
+
wallet = paypal_payment_source[:paypal]
|
|
47
|
+
source = PaymentSources::Paypal.find_or_initialize_by(
|
|
48
|
+
payment_method: gateway,
|
|
49
|
+
gateway_payment_profile_id: wallet[:account_id]
|
|
50
|
+
)
|
|
51
|
+
source.update!(
|
|
52
|
+
user: user,
|
|
53
|
+
email: wallet[:email_address],
|
|
54
|
+
name: full_name(wallet[:name]),
|
|
55
|
+
account_id: wallet[:account_id],
|
|
56
|
+
account_status: wallet[:account_status]
|
|
57
|
+
)
|
|
58
|
+
source
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def create_apple_pay_source
|
|
62
|
+
wallet = paypal_payment_source[:apple_pay]
|
|
63
|
+
card = wallet[:card] || {}
|
|
64
|
+
profile_id = wallet[:id].presence ||
|
|
65
|
+
wallet[:token].presence ||
|
|
66
|
+
['applepay', card[:brand], card[:last_digits]].compact.join('-')
|
|
67
|
+
|
|
68
|
+
source = PaymentSources::ApplePay.find_or_initialize_by(
|
|
69
|
+
payment_method: gateway,
|
|
70
|
+
gateway_payment_profile_id: profile_id
|
|
71
|
+
)
|
|
72
|
+
source.update!(
|
|
73
|
+
user: user,
|
|
74
|
+
name: full_name(wallet[:name]).presence || wallet[:name],
|
|
75
|
+
email: wallet[:email_address],
|
|
76
|
+
card_brand: card[:brand],
|
|
77
|
+
last_digits: card[:last_digits],
|
|
78
|
+
card_type: card[:type]
|
|
79
|
+
)
|
|
80
|
+
source
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def create_card_source
|
|
84
|
+
card = paypal_payment_source[:card]
|
|
85
|
+
profile_id = card[:id].presence || ['card', card[:brand], card[:last_digits]].compact.join('-')
|
|
86
|
+
|
|
87
|
+
source = PaymentSources::Card.find_or_initialize_by(
|
|
88
|
+
payment_method: gateway,
|
|
89
|
+
gateway_payment_profile_id: profile_id
|
|
90
|
+
)
|
|
91
|
+
source.update!(
|
|
92
|
+
user: user,
|
|
93
|
+
name: card[:name],
|
|
94
|
+
card_brand: card[:brand],
|
|
95
|
+
last_digits: card[:last_digits],
|
|
96
|
+
card_type: card[:type]
|
|
97
|
+
)
|
|
98
|
+
source
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def full_name(name)
|
|
102
|
+
return name if name.is_a?(String)
|
|
103
|
+
return '' if name.blank?
|
|
104
|
+
|
|
105
|
+
"#{name[:given_name]} #{name[:surname]}".strip
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
data/app/views/spree/admin/payment_methods/configuration_guides/_spree_paypal_checkout.html.erb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<div class="alert alert-info">
|
|
2
|
+
<p class="mb-0">
|
|
3
|
+
To find your <strong>Client ID</strong> and <strong>Client Secret</strong>, go to the
|
|
4
|
+
<%= external_link_to 'PayPal Developer Dashboard', 'https://developer.paypal.com/dashboard/', class: 'alert-link' %>.
|
|
5
|
+
For more information, see the <%= external_link_to 'PayPal REST API documentation', 'https://developer.paypal.com/api/rest/', class: 'alert-link' %>.
|
|
6
|
+
</p>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<div class="alert alert-info mt-3">
|
|
10
|
+
<p class="mb-0">
|
|
11
|
+
<strong>Apple Pay</strong> is a funding source of this same PayPal method, not a second payment method.
|
|
12
|
+
Register every storefront domain that will show the Apple Pay button in the
|
|
13
|
+
<%= external_link_to 'PayPal Apple Pay domain registration', 'https://developer.paypal.com/docs/checkout/apm/apple-pay/', class: 'alert-link' %>
|
|
14
|
+
dashboard, then set <em>Apple pay domain</em> below. The button only renders in Safari over HTTPS.
|
|
15
|
+
</p>
|
|
16
|
+
</div>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<p class="mb-1">
|
|
2
|
+
PayPal Checkout: wallet, Pay Later, Apple Pay, and Card Fields on one payment method.
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<div class="d-flex align-items-center">
|
|
6
|
+
<%= payment_method_icon_tag 'paypal', class: 'm-1' %>
|
|
7
|
+
<%= payment_method_icon_tag 'visa', class: 'm-1' %>
|
|
8
|
+
<%= payment_method_icon_tag 'master', class: 'm-1' %>
|
|
9
|
+
<%= payment_method_icon_tag 'apple_pay', class: 'm-1' %>
|
|
10
|
+
</div>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<% if previous_cards.any? %>
|
|
2
|
+
<%= render 'spree/admin/payments/source_forms/previous_cards', previous_cards: previous_cards, f: f %>
|
|
3
|
+
<% else %>
|
|
4
|
+
<span class="text-gray-600">
|
|
5
|
+
<%= Spree.t(:no_payment_sources_available, default: 'No saved PayPal accounts available.') %>
|
|
6
|
+
</span>
|
|
7
|
+
<% end %>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<p class="mb-0">
|
|
2
|
+
<%= source.try(:display_payment_info) || source.class.try(:display_name) %>
|
|
3
|
+
</p>
|
|
4
|
+
<% if source.respond_to?(:email) && source.email.present? %>
|
|
5
|
+
<p class="mb-0">
|
|
6
|
+
<%= source.email %>
|
|
7
|
+
</p>
|
|
8
|
+
<% end %>
|
|
9
|
+
<% if source.respond_to?(:account_status) && source.account_status.present? %>
|
|
10
|
+
<p class="mb-0">
|
|
11
|
+
<% if source.account_status == 'VERIFIED' %>
|
|
12
|
+
<code class="text-success"><%= source.account_status %></code>
|
|
13
|
+
<% else %>
|
|
14
|
+
<code class="text-danger"><%= source.account_status %></code>
|
|
15
|
+
<% end %>
|
|
16
|
+
</p>
|
|
17
|
+
<% end %>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Must be after_initialize: spree_core assigns (does not append to)
|
|
4
|
+
# config.spree.payment_methods in its own after_initialize, and a
|
|
5
|
+
# file-scope registration here would be silently clobbered.
|
|
6
|
+
Rails.application.config.after_initialize do
|
|
7
|
+
Rails.application.config.spree.payment_methods << Spree::PaypalCheckout::Gateway
|
|
8
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateSpreePaypalCheckoutOrders < ActiveRecord::Migration[7.2]
|
|
4
|
+
def change
|
|
5
|
+
create_table :spree_paypal_checkout_orders do |t|
|
|
6
|
+
t.references :order, null: false
|
|
7
|
+
t.references :payment_method, null: false
|
|
8
|
+
t.string :paypal_id, null: false
|
|
9
|
+
t.decimal :amount, null: false, precision: 10, scale: 2
|
|
10
|
+
|
|
11
|
+
if t.respond_to? :jsonb
|
|
12
|
+
t.jsonb :data
|
|
13
|
+
else
|
|
14
|
+
t.json :data
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
t.timestamps
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
add_index :spree_paypal_checkout_orders, [:order_id, :paypal_id], unique: true
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class RewriteSpreePaypalCheckoutStiTypes < ActiveRecord::Migration[7.2]
|
|
4
|
+
# Hosts coming from the official / forked `spree_paypal_checkout` gem store
|
|
5
|
+
# `SpreePaypalCheckout::Gateway` (and `…::PaymentSources::Paypal`) in STI
|
|
6
|
+
# type columns. The new gem aliases those constants, so checkout keeps
|
|
7
|
+
# working before this migration runs; rewriting the strings makes the
|
|
8
|
+
# alias optional for the next deploy.
|
|
9
|
+
LEGACY_TO_CURRENT = {
|
|
10
|
+
'SpreePaypalCheckout::Gateway' => 'Spree::PaypalCheckout::Gateway',
|
|
11
|
+
'SpreePaypalCheckout::PaymentSources::Paypal' => 'Spree::PaypalCheckout::PaymentSources::Paypal',
|
|
12
|
+
'SpreePaypalCheckout::Order' => 'Spree::PaypalCheckout::Order'
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def up
|
|
16
|
+
rewrite_column(:spree_payment_methods, :type)
|
|
17
|
+
rewrite_column(:spree_payment_sources, :type) if column_exists?(:spree_payment_sources, :type)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def down
|
|
21
|
+
rewrite_column(:spree_payment_methods, :type, LEGACY_TO_CURRENT.invert)
|
|
22
|
+
rewrite_column(:spree_payment_sources, :type, LEGACY_TO_CURRENT.invert) if column_exists?(:spree_payment_sources, :type)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def rewrite_column(table, column, mapping = LEGACY_TO_CURRENT)
|
|
28
|
+
mapping.each do |from, to|
|
|
29
|
+
reversible_update = "UPDATE #{table} SET #{column} = #{quote(to)} WHERE #{column} = #{quote(from)}"
|
|
30
|
+
execute reversible_update
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module PaypalCheckout
|
|
5
|
+
module Generators
|
|
6
|
+
##
|
|
7
|
+
# Copies this gem's migrations into the host app and optionally runs them.
|
|
8
|
+
#
|
|
9
|
+
class InstallGenerator < Rails::Generators::Base
|
|
10
|
+
desc 'Copies Spree::PaypalCheckout migrations into the host application and runs them.'
|
|
11
|
+
|
|
12
|
+
class_option :auto_run_migrations,
|
|
13
|
+
type: :boolean,
|
|
14
|
+
default: false,
|
|
15
|
+
desc: 'Run the copied migrations immediately instead of asking'
|
|
16
|
+
|
|
17
|
+
def copy_migrations
|
|
18
|
+
rake 'spree_paypal_checkout:install:migrations'
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run_migrations
|
|
22
|
+
if run_migrations?
|
|
23
|
+
rake 'db:migrate'
|
|
24
|
+
else
|
|
25
|
+
say_status :skip, 'db:migrate — remember to run it before using the gem', :yellow
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def run_migrations?
|
|
32
|
+
return true if options[:auto_run_migrations]
|
|
33
|
+
|
|
34
|
+
ask('Run the migrations now? [Yn]').to_s.strip.casecmp('n') != 0
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module PaypalCheckout
|
|
5
|
+
##
|
|
6
|
+
# Rails engine for the PayPal Checkout payment method.
|
|
7
|
+
#
|
|
8
|
+
class Engine < ::Rails::Engine
|
|
9
|
+
require 'spree/core'
|
|
10
|
+
isolate_namespace Spree
|
|
11
|
+
|
|
12
|
+
# Decorators reopen existing constants, so Zeitwerk must not autoload them.
|
|
13
|
+
initializer 'spree_paypal_checkout.ignore_decorators_from_zeitwerk', before: :setup_main_autoloader do
|
|
14
|
+
Rails.autoloaders.main.ignore(
|
|
15
|
+
File.join(File.dirname(__FILE__), '../../../app/**/*_decorator*.rb')
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# engine_name generates route-helper prefixes and must be a valid Ruby
|
|
20
|
+
# identifier, so it cannot contain a dash.
|
|
21
|
+
engine_name 'spree_paypal_checkout'
|
|
22
|
+
|
|
23
|
+
config.generators do |g|
|
|
24
|
+
g.test_framework :rspec
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
##
|
|
28
|
+
# Loads the gem's decorators. Called on every reload in development.
|
|
29
|
+
#
|
|
30
|
+
# @return [void]
|
|
31
|
+
#
|
|
32
|
+
def self.activate
|
|
33
|
+
Dir.glob(File.join(File.dirname(__FILE__), '../../../app/**/*_decorator*.rb')).each do |c|
|
|
34
|
+
Rails.configuration.cache_classes ? require(c) : load(c)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
config.to_prepare(&method(:activate).to_proc)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
FactoryBot.define do
|
|
4
|
+
factory :paypal_checkout_gateway, class: 'Spree::PaypalCheckout::Gateway' do
|
|
5
|
+
name { 'PayPal Checkout' }
|
|
6
|
+
association :store, factory: :store
|
|
7
|
+
preferences do
|
|
8
|
+
{
|
|
9
|
+
client_id: ENV.fetch('PAYPAL_CLIENT_ID', 'client_id_test'),
|
|
10
|
+
client_secret: ENV.fetch('PAYPAL_CLIENT_SECRET', 'client_secret_test'),
|
|
11
|
+
test_mode: true,
|
|
12
|
+
enable_apple_pay: true
|
|
13
|
+
}
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
factory :paypal_checkout_payment_source, class: 'Spree::PaypalCheckout::PaymentSources::Paypal' do
|
|
18
|
+
association :payment_method, factory: :paypal_checkout_gateway
|
|
19
|
+
gateway_payment_profile_id { 'PAY-CUSTOMER-ID' }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
factory :paypal_checkout_apple_pay_source, class: 'Spree::PaypalCheckout::PaymentSources::ApplePay' do
|
|
23
|
+
association :payment_method, factory: :paypal_checkout_gateway
|
|
24
|
+
gateway_payment_profile_id { 'APPLEPAY-TOKEN-1' }
|
|
25
|
+
card_brand { 'VISA' }
|
|
26
|
+
last_digits { '4242' }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
factory :paypal_checkout_payment_session, class: 'Spree::PaymentSessions::PaypalCheckout' do
|
|
30
|
+
association :order, factory: :order
|
|
31
|
+
association :payment_method, factory: :paypal_checkout_gateway
|
|
32
|
+
amount { order.total }
|
|
33
|
+
currency { order.currency }
|
|
34
|
+
status { 'pending' }
|
|
35
|
+
external_id { "PAYPAL-ORDER-#{SecureRandom.hex(8).upcase}" }
|
|
36
|
+
external_data do
|
|
37
|
+
JSON.parse(File.read(Spree::PaypalCheckout::Engine.root.join('spec/fixtures/paypal_order.json')))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
factory :completed_paypal_checkout_payment_session do
|
|
41
|
+
status { 'completed' }
|
|
42
|
+
external_data do
|
|
43
|
+
JSON.parse(File.read(Spree::PaypalCheckout::Engine.root.join('spec/fixtures/captured_paypal_order.json')))
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
factory :paypal_checkout_order, class: 'Spree::PaypalCheckout::Order' do
|
|
49
|
+
paypal_id { 'PAY-ORDER-ID' }
|
|
50
|
+
order { create(:order) }
|
|
51
|
+
payment_method { create(:paypal_checkout_gateway) }
|
|
52
|
+
amount { order.total }
|
|
53
|
+
data { JSON.parse(File.read(Spree::PaypalCheckout::Engine.root.join('spec/fixtures/paypal_order.json'))) }
|
|
54
|
+
|
|
55
|
+
factory :captured_paypal_checkout_order do
|
|
56
|
+
data { JSON.parse(File.read(Spree::PaypalCheckout::Engine.root.join('spec/fixtures/captured_paypal_order.json'))) }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spree_core'
|
|
4
|
+
require 'paypal_server_sdk'
|
|
5
|
+
require 'spree/paypal_checkout/version'
|
|
6
|
+
require 'spree/paypal_checkout/engine'
|
|
7
|
+
|
|
8
|
+
module Spree
|
|
9
|
+
module PaypalCheckout
|
|
10
|
+
##
|
|
11
|
+
# Table prefix for models nested under this module.
|
|
12
|
+
#
|
|
13
|
+
# Must live on the module, not on {Base}. ActiveRecord walks
|
|
14
|
+
# `module_parents` for `table_name_prefix` and finds `Spree` first
|
|
15
|
+
# (`"spree_"`) unless this closer parent defines it.
|
|
16
|
+
#
|
|
17
|
+
# @return [String]
|
|
18
|
+
#
|
|
19
|
+
def self.table_name_prefix
|
|
20
|
+
'spree_paypal_checkout_'
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
##
|
|
24
|
+
# STI type names that count as this gem's gateway.
|
|
25
|
+
#
|
|
26
|
+
# Includes the historical `SpreePaypalCheckout::Gateway` string from the
|
|
27
|
+
# official / forked gem so existing payment-method rows keep matching
|
|
28
|
+
# after a host switches gems, even before the type-rewrite migration
|
|
29
|
+
# runs.
|
|
30
|
+
#
|
|
31
|
+
# @return [Array<String>]
|
|
32
|
+
#
|
|
33
|
+
def self.gateway_type_names
|
|
34
|
+
(
|
|
35
|
+
[Gateway.name, 'SpreePaypalCheckout::Gateway'] + Gateway.descendants.map(&:name)
|
|
36
|
+
).uniq
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Historical top-level namespace from `spree_paypal_checkout`. Existing
|
|
42
|
+
# `spree_payment_methods.type` / `spree_payment_sources.type` rows store
|
|
43
|
+
# `SpreePaypalCheckout::…`; this alias lets those strings constantize
|
|
44
|
+
# onto the new classes.
|
|
45
|
+
SpreePaypalCheckout = Spree::PaypalCheckout
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Bundler auto-requires a gem by its *name*, so `gem "spree-paypal_checkout"`
|
|
4
|
+
# in a host Gemfile issues `require "spree-paypal_checkout"`. The real entry
|
|
5
|
+
# point is `spree/paypal_checkout` (matching the Spree::PaypalCheckout
|
|
6
|
+
# namespace), so this shim keeps the default `Bundler.require` working.
|
|
7
|
+
require 'spree/paypal_checkout'
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Bundler auto-requires a gem by its *name*, so `gem "spree-paypal_platform"`
|
|
4
|
+
# in a host Gemfile issues `require "spree-paypal_platform"`. The real entry
|
|
5
|
+
# point is `spree/paypal_checkout` (matching the Spree::PaypalCheckout
|
|
6
|
+
# namespace), so this shim keeps the default `Bundler.require` working.
|
|
7
|
+
require 'spree/paypal_checkout'
|