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
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
|