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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +67 -0
- data/LICENSE +10 -0
- data/README.md +84 -0
- data/Rakefile +27 -0
- data/app/models/spree/payment_sessions/paypal_merchant.rb +94 -0
- data/app/models/spree/paypal_merchant/base.rb +14 -0
- data/app/models/spree/paypal_merchant/gateway/payment_sessions.rb +308 -0
- data/app/models/spree/paypal_merchant/gateway.rb +324 -0
- data/app/models/spree/paypal_merchant/order.rb +89 -0
- data/app/models/spree/paypal_merchant/order_decorator.rb +24 -0
- data/app/models/spree/paypal_merchant/payment_method_decorator.rb +29 -0
- data/app/models/spree/paypal_merchant/payment_sources/apple_pay.rb +49 -0
- data/app/models/spree/paypal_merchant/payment_sources/card.rb +42 -0
- data/app/models/spree/paypal_merchant/payment_sources/paypal.rb +37 -0
- data/app/models/spree/paypal_merchant/store_decorator.rb +19 -0
- data/app/presenters/spree/paypal_merchant/order_presenter.rb +148 -0
- data/app/serializers/spree/api/v3/payment_method_serializer_decorator.rb +20 -0
- data/app/services/spree/paypal_merchant/capture_order.rb +44 -0
- data/app/services/spree/paypal_merchant/create_payment.rb +49 -0
- data/app/services/spree/paypal_merchant/create_source.rb +110 -0
- data/app/views/spree/admin/payment_methods/configuration_guides/_spree_paypal_merchant.html.erb +17 -0
- data/app/views/spree/admin/payment_methods/descriptions/_spree_paypal_merchant.html.erb +10 -0
- data/app/views/spree/admin/payments/source_forms/_spree_paypal_merchant.html.erb +7 -0
- data/app/views/spree/payment_sources/_paypal_merchant.html.erb +17 -0
- data/config/initializers/spree.rb +8 -0
- data/db/migrate/20250528095719_create_spree_paypal_merchant_orders.rb +31 -0
- data/db/migrate/20261003120000_rename_spree_paypal_platform_to_paypal_merchant.rb +46 -0
- data/lib/generators/spree/paypal_merchant/install/install_generator.rb +39 -0
- data/lib/spree/paypal_merchant/engine.rb +41 -0
- data/lib/spree/paypal_merchant/factories.rb +60 -0
- data/lib/spree/paypal_merchant/version.rb +8 -0
- data/lib/spree/paypal_merchant.rb +32 -0
- data/lib/spree-paypal_merchant.rb +6 -0
- 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
|