spree-paypal_merchant 5.2.0 → 6.0.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 +4 -4
- data/CHANGELOG.md +172 -0
- data/README.md +105 -2
- data/Rakefile +2 -1
- data/app/models/spree/payment_sessions/paypal_merchant.rb +137 -39
- data/app/models/spree/paypal_merchant/gateway/payment_sessions.rb +289 -79
- data/app/models/spree/paypal_merchant/gateway.rb +358 -131
- data/app/models/spree/paypal_merchant/payment_sources/apple_pay.rb +1 -1
- data/app/models/spree/paypal_merchant/payment_sources/card.rb +1 -1
- data/app/models/spree/paypal_merchant/payment_sources/paypal.rb +1 -1
- data/app/presenters/spree/paypal_merchant/order_presenter.rb +127 -36
- data/app/services/spree/paypal_merchant/create_source.rb +10 -10
- data/lib/spree/paypal_merchant/factories.rb +8 -17
- data/lib/spree/paypal_merchant/version.rb +3 -2
- data/lib/spree/paypal_merchant.rb +3 -3
- metadata +31 -26
- data/app/models/spree/paypal_merchant/base.rb +0 -14
- data/app/models/spree/paypal_merchant/order.rb +0 -89
- data/app/models/spree/paypal_merchant/order_decorator.rb +0 -24
- data/app/services/spree/paypal_merchant/capture_order.rb +0 -44
- data/app/services/spree/paypal_merchant/create_payment.rb +0 -49
- data/app/views/spree/admin/payment_methods/configuration_guides/_spree_paypal_merchant.html.erb +0 -17
- data/app/views/spree/admin/payment_methods/descriptions/_spree_paypal_merchant.html.erb +0 -10
- data/app/views/spree/admin/payments/source_forms/_spree_paypal_merchant.html.erb +0 -7
- data/app/views/spree/payment_sources/_paypal_merchant.html.erb +0 -17
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 593600084e06843e9fd2f9a1de5e5f503efa118d471fa43d23186029a33c39b0
|
|
4
|
+
data.tar.gz: 39d6ebce7fde438a61149611af88f3afcf6d913549973a3619428026d5dea7a4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2039d4bf6952b6b950a3c0d861f05102330d38b815ed82d3257b61caced05d69b0dd7d4b1b438579890d173b5b79c5b482fe4ba4d7ee5ab87bd02471709607ac
|
|
7
|
+
data.tar.gz: b145c1842d66afaa2b1fac49237d7dfe1054b043cdb20de68a3f8b2416496545ee87bcd38b42e0f3a827141ad34aefe0ecaa8a62be33573b0f4387d963d47396
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,177 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 6.0.0
|
|
4
|
+
|
|
5
|
+
Ports the gem to Spree 6. The major version tracks Spree's. Spree 5.6 stays
|
|
6
|
+
on 5.2.x, on the `5-2-stable` branch. To upgrade, see "Upgrading from 5.2" in
|
|
7
|
+
the README.
|
|
8
|
+
|
|
9
|
+
The storefront contract is unchanged. The Store API type is still
|
|
10
|
+
`paypal_merchant`, and the payment session's `external_data` and the payment
|
|
11
|
+
method's `public_preferences` keep their keys.
|
|
12
|
+
|
|
13
|
+
### Requirements
|
|
14
|
+
|
|
15
|
+
- Spree `~> 6.0` and Ruby 3.3 or newer.
|
|
16
|
+
- The `spree_admin` dependency is gone. Spree 6 has no Rails admin; its
|
|
17
|
+
dashboard builds the settings form from the gateway's preferences.
|
|
18
|
+
- `faraday` (`~> 2.0`) and `net-http-persistent` (`~> 4.0`) are declared. The
|
|
19
|
+
gem uses both directly; before, they came only through `paypal-server-sdk`.
|
|
20
|
+
|
|
21
|
+
### Checkout
|
|
22
|
+
|
|
23
|
+
- Sessions belong to the cart. The amount is the cart total less store
|
|
24
|
+
credit and gift cards. A zero amount raises `Spree::Core::GatewayError`.
|
|
25
|
+
- A session's amount can't change once its PayPal order exists. Updating a
|
|
26
|
+
session with a different amount raises `Spree::Core::GatewayError` (a 422
|
|
27
|
+
from the Store API) telling the storefront to create a new session, and
|
|
28
|
+
`external_data` sent by the client is not stored. The payment records the
|
|
29
|
+
amount PayPal captured, not the session's. Before, a client could change the
|
|
30
|
+
amount, and a cart with store credit could be placed as paid in full while
|
|
31
|
+
PayPal took less and the store credit went unspent.
|
|
32
|
+
- A payment source Spree refuses no longer blocks settlement. Spree keeps a
|
|
33
|
+
source's profile id unique per source type, not per payment method, so a
|
|
34
|
+
PayPal account that already had a source under another PayPal method (a
|
|
35
|
+
second store, a recreated method) made settlement fail after PayPal took
|
|
36
|
+
the money, and every retry failed the same way. The payment now settles
|
|
37
|
+
without a source, and the refusal is logged.
|
|
38
|
+
- The storefront's confirm call and PayPal's webhook settle through Spree 6's
|
|
39
|
+
shared path, in either order, and produce one payment. A webhook that arrives
|
|
40
|
+
first reads PayPal's order, creates the payment and completes the cart.
|
|
41
|
+
- Settlement reads the capture, not the order. PayPal can report an order as
|
|
42
|
+
`COMPLETED` whose capture was declined, as can happen with Card Fields.
|
|
43
|
+
|
|
44
|
+
| Capture status | Result |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `COMPLETED` | A captured payment |
|
|
47
|
+
| `PENDING` | A pending payment; the `COMPLETED` event completes it later |
|
|
48
|
+
| `DECLINED`, `FAILED`, none | The session fails; no payment is created |
|
|
49
|
+
|
|
50
|
+
5.2.0 treated a pending capture as paid.
|
|
51
|
+
- `PAYMENT.CAPTURE.PENDING` (an eCheck, say) leaves the Spree payment pending.
|
|
52
|
+
`PAYMENT.CAPTURE.COMPLETED` completes it.
|
|
53
|
+
- A webhook settles only when PayPal's order shows the capture `COMPLETED`,
|
|
54
|
+
`PENDING` or `PARTIALLY_REFUNDED`. If PayPal's order can't be read, or shows
|
|
55
|
+
none of these, the event is dropped. Spree does not retry it and PayPal does not resend it. See the
|
|
56
|
+
README.
|
|
57
|
+
- `CHECKOUT.ORDER.APPROVED` is ignored. An approved order holds no capture.
|
|
58
|
+
- A webhook for a checkout settled under 5.x is ignored, so it can't create a
|
|
59
|
+
second payment.
|
|
60
|
+
- A session that can no longer be used is never captured. Confirming one that
|
|
61
|
+
was canceled or failed, or whose cart is already a placed order, is refused.
|
|
62
|
+
This stops a stale browser tab from paying twice.
|
|
63
|
+
- Creating a session cancels the cart's earlier open PayPal sessions. It
|
|
64
|
+
raises if one of them is already completed.
|
|
65
|
+
- If a capture gets no clear answer from PayPal, the session stays open. A
|
|
66
|
+
retry reads the order first, so a capture that went through is settled, not
|
|
67
|
+
repeated.
|
|
68
|
+
- PayPal captures at checkout, whatever the store's default capture method is.
|
|
69
|
+
A payment method whose own capture method is `on_dispatch` or `manual` is
|
|
70
|
+
refused. The gateway's capture method is always `checkout`, so a store that
|
|
71
|
+
defaults to `on_dispatch` or `manual` never captures a PayPal payment on
|
|
72
|
+
dispatch, and never ships an unpaid PayPal order as one left for staff.
|
|
73
|
+
- Canceling a payment session is refused while PayPal's capture is `PENDING`,
|
|
74
|
+
`COMPLETED` or `PARTIALLY_REFUNDED`, because money has moved or is on its
|
|
75
|
+
way. Otherwise it cancels.
|
|
76
|
+
- The gateway customer profile is looked up by the owner's customer. An owner
|
|
77
|
+
with no customer (a guest) gets none.
|
|
78
|
+
|
|
79
|
+
### Refunds and cancels
|
|
80
|
+
|
|
81
|
+
- A refund reads the capture id from the payment. A payment taken on 5.x
|
|
82
|
+
refunds as before, through its response code.
|
|
83
|
+
- Refund amounts follow the currency's decimals, so a yen refund has none.
|
|
84
|
+
- When a refund's outcome is unknown (a timeout or connection error), the
|
|
85
|
+
refund fails with a message telling the admin to check PayPal before
|
|
86
|
+
retrying. PayPal may have issued it.
|
|
87
|
+
- A refund that PayPal reports as `FAILED` or `CANCELLED` fails. A `PENDING`
|
|
88
|
+
refund is recorded.
|
|
89
|
+
- Canceling a completed payment refunds it through Spree's refund workflow,
|
|
90
|
+
unless told to keep the money.
|
|
91
|
+
- Void and cancel refuse a payment PayPal may still complete (checkout,
|
|
92
|
+
pending or processing state), with a "still processing" message. Capture
|
|
93
|
+
settles it as PayPal did, or refund it once PayPal completes it.
|
|
94
|
+
- Capture in the dashboard reads PayPal and never captures again. A
|
|
95
|
+
`COMPLETED` capture completes the payment, and a denied or declined one fails
|
|
96
|
+
it. While PayPal is still processing the capture, or can't be read, Capture
|
|
97
|
+
says so and leaves the payment pending, so it can be pressed again. It takes
|
|
98
|
+
the whole payment only, since PayPal captured it all at checkout.
|
|
99
|
+
- Spree's checkout processing can't authorize or purchase with this gateway.
|
|
100
|
+
It fails, saying PayPal payments are taken through a payment session.
|
|
101
|
+
Before, it raised `ActiveModel::UnknownAttributeError`.
|
|
102
|
+
- Canceling an order with Spree's default (`refund_payments: false`) marks a
|
|
103
|
+
completed PayPal payment void in Spree while PayPal keeps the money. Refund
|
|
104
|
+
it before canceling, or refund it in PayPal.
|
|
105
|
+
- A failed, void or invalid payment cancels in Spree with no PayPal call.
|
|
106
|
+
- Void always fails. PayPal payments are captured at checkout, so there is
|
|
107
|
+
nothing to void: refund the payment instead.
|
|
108
|
+
|
|
109
|
+
### Order payload
|
|
110
|
+
|
|
111
|
+
- Every discount counts, promotion and manual.
|
|
112
|
+
- A free-shipping discount goes in PayPal's `shipping_discount`.
|
|
113
|
+
- Fees go in `handling`.
|
|
114
|
+
- Amounts are rounded with Spree's currency rules, so a unit price with four
|
|
115
|
+
decimals is sent with the currency's own.
|
|
116
|
+
- Line items are left out when they don't add up to the item total. The buyer
|
|
117
|
+
sees no itemised list in PayPal for that order only. With two-decimal prices
|
|
118
|
+
it never happens.
|
|
119
|
+
- When the breakdown can't balance, or has a negative figure, only the amount
|
|
120
|
+
is sent.
|
|
121
|
+
- The breakdown still sends only `additional_tax_total`.
|
|
122
|
+
|
|
123
|
+
### Timeouts and retries
|
|
124
|
+
|
|
125
|
+
- Every Orders and Payments API request has a 15-second timeout. The
|
|
126
|
+
gem's own webhook-verification and token requests time out after 5 seconds
|
|
127
|
+
to connect and 10 to read.
|
|
128
|
+
- PayPal reads (GET) are retried up to 3 times. Only GET and PUT are
|
|
129
|
+
retried, and the client says so explicitly.
|
|
130
|
+
- Creating, capturing and refunding are never re-sent automatically.
|
|
131
|
+
- A PayPal server error whose body isn't JSON (an HTML 502 page, say) is
|
|
132
|
+
treated like a timeout: PayPal may have acted. A refund fails with the
|
|
133
|
+
message to check PayPal before retrying, a confirm leaves the session open,
|
|
134
|
+
and Capture leaves the payment pending. Before, the SDK's `TypeError`
|
|
135
|
+
escaped, and the refund failed with a generic error.
|
|
136
|
+
- The webhook signature check treats more transient failures as a failed
|
|
137
|
+
check: a reset connection, a TLS error, a broken HTTP exchange. Spree answers
|
|
138
|
+
401 and PayPal sends the event again. The Card Fields token request handles
|
|
139
|
+
the same failures by creating the session without a token.
|
|
140
|
+
- A PayPal error with no message of its own is reported with PayPal's issue
|
|
141
|
+
descriptions.
|
|
142
|
+
|
|
143
|
+
### Payment sources
|
|
144
|
+
|
|
145
|
+
- The PayPal, Apple Pay and card sources store their fields in `metadata`
|
|
146
|
+
(Spree 6 merged `private_metadata` into it). The accessors keep their names.
|
|
147
|
+
- A PayPal wallet with no PayPal account id is keyed by the cart's (or
|
|
148
|
+
order's) prefixed id instead of the order number, so a 5.x source of that
|
|
149
|
+
kind isn't reused.
|
|
150
|
+
|
|
151
|
+
### Removed
|
|
152
|
+
|
|
153
|
+
- The 5.x checkout flow: `Spree::PaypalMerchant::Order`, `CreatePayment`,
|
|
154
|
+
`CaptureOrder`, the order's `paypal_id`, and the gateway's `find_order`. It
|
|
155
|
+
had no routes. The gateway's `authorize`, `purchase` and `capture` are
|
|
156
|
+
rewritten for Spree 6 (see "Refunds and cancels").
|
|
157
|
+
- The ERB admin views and the `*_partial_name` hooks.
|
|
158
|
+
- `Spree::PaypalMerchant::Base`, an abstract model class nothing inherits from
|
|
159
|
+
any more.
|
|
160
|
+
- The `spree_paypal_merchant_orders` table stays, and no migration drops it in
|
|
161
|
+
this release.
|
|
162
|
+
|
|
163
|
+
### Known limitations
|
|
164
|
+
|
|
165
|
+
- PayPal treats HUF and TWD as zero-decimal currencies, but Spree formats them
|
|
166
|
+
with two decimals, so those currencies aren't supported.
|
|
167
|
+
- Multi-seller checkouts, where one payment covers several orders, aren't
|
|
168
|
+
tested.
|
|
169
|
+
- A payment the storefront confirmed on a cart it never completed isn't
|
|
170
|
+
placed by PayPal's later webhook. It has to be placed by hand.
|
|
171
|
+
- If the webhook settles first and completing the cart then fails, Spree rolls
|
|
172
|
+
the payment back and the event is dropped. The storefront's confirm call
|
|
173
|
+
settles it instead.
|
|
174
|
+
|
|
3
175
|
## 5.2.0
|
|
4
176
|
|
|
5
177
|
Renamed from `spree-paypal_platform`. Same code, new name.
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# spree-paypal_merchant
|
|
2
2
|
|
|
3
|
-
PayPal Checkout for Spree
|
|
3
|
+
PayPal Checkout for Spree 6, as a single payment method covering the PayPal
|
|
4
4
|
wallet, Apple Pay, and Card Fields.
|
|
5
5
|
|
|
6
6
|
Published gem: **`spree-paypal_merchant`**
|
|
@@ -11,6 +11,11 @@ This is not the official `spree_paypal_checkout` gem. It started from that
|
|
|
11
11
|
extension (VAT `AMOUNT_MISMATCH` guard, storefront-owned address) and adds
|
|
12
12
|
Apple Pay as a funding source of the same gateway.
|
|
13
13
|
|
|
14
|
+
## Requirements
|
|
15
|
+
|
|
16
|
+
Spree 6 (`spree ~> 6.0`) and Ruby 3.3 or newer. Spree 5.6 uses the 5.2.x
|
|
17
|
+
releases, on the `5-2-stable` branch.
|
|
18
|
+
|
|
14
19
|
## What it is
|
|
15
20
|
|
|
16
21
|
- One `Spree::PaypalMerchant::Gateway` payment method.
|
|
@@ -18,6 +23,16 @@ Apple Pay as a funding source of the same gateway.
|
|
|
18
23
|
(`POST /api/v3/store/carts/:id/payment_sessions` → complete).
|
|
19
24
|
- The storefront owns the address. The gem pins it with
|
|
20
25
|
`SET_PROVIDED_ADDRESS` so PayPal does not re-collect it.
|
|
26
|
+
- PayPal **captures at checkout**, whatever capture method the store defaults
|
|
27
|
+
to. Authorize now, capture later is not supported: a payment method whose own
|
|
28
|
+
capture method is `on_dispatch` or `manual` is refused. Spree never captures a
|
|
29
|
+
PayPal payment on dispatch, and never treats one as left for staff to collect.
|
|
30
|
+
- A payment session's amount is fixed once its PayPal order exists. Changing
|
|
31
|
+
it is refused: the storefront creates a new session instead. The payment
|
|
32
|
+
records the amount PayPal captured.
|
|
33
|
+
- The storefront's confirm call and PayPal's webhook settle through the same
|
|
34
|
+
Spree path, in either order. Whichever arrives first creates the payment; the
|
|
35
|
+
other changes nothing.
|
|
21
36
|
- Amount breakdown sends **only `additional_tax_total`**. Sending full
|
|
22
37
|
`tax_total` double-counts included VAT and trips PayPal `AMOUNT_MISMATCH`.
|
|
23
38
|
- Talks to PayPal via the official
|
|
@@ -41,7 +56,7 @@ bin/rails g spree:paypal_merchant:install
|
|
|
41
56
|
The generator copies the migrations. Pass `--auto-run-migrations` to skip the
|
|
42
57
|
prompt.
|
|
43
58
|
|
|
44
|
-
Then add a **PayPal** payment method in admin and set:
|
|
59
|
+
Then add a **PayPal** payment method in the admin dashboard and set:
|
|
45
60
|
|
|
46
61
|
| Preference | Purpose |
|
|
47
62
|
|---|---|
|
|
@@ -57,6 +72,32 @@ Webhook URL is the standard Spree v3 payments webhook:
|
|
|
57
72
|
https://<store>/api/v3/webhooks/payments/<prefixed-payment-method-id>
|
|
58
73
|
```
|
|
59
74
|
|
|
75
|
+
Subscribe it to these events. Others, such as `CHECKOUT.ORDER.APPROVED`, are
|
|
76
|
+
ignored.
|
|
77
|
+
|
|
78
|
+
| Event | What the gem does |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `PAYMENT.CAPTURE.COMPLETED` | Settles the payment as captured |
|
|
81
|
+
| `PAYMENT.CAPTURE.PENDING` | Settles it as pending (an eCheck, say); `COMPLETED` completes it later |
|
|
82
|
+
| `PAYMENT.CAPTURE.DENIED`, `PAYMENT.CAPTURE.DECLINED` | Fails the payment session if it is still open. A payment already left pending by `PAYMENT.CAPTURE.PENDING` stays pending (no money was taken); use Capture in the dashboard: it reads PayPal and fails the payment if PayPal denied the capture (or completes it if PayPal completed it) |
|
|
83
|
+
| `PAYMENT.CAPTURE.REVERSED`, `PAYMENT.CAPTURE.REFUNDED` | Cancels the payment session if it is still open and the event names the PayPal order. A completed session and its payment are left as they are, so a refund or chargeback made in PayPal does not change the Spree payment |
|
|
84
|
+
|
|
85
|
+
## In the dashboard
|
|
86
|
+
|
|
87
|
+
- **Capture** on a pending PayPal payment reads PayPal and settles the payment
|
|
88
|
+
the way PayPal did. It completes the payment if PayPal completed the capture,
|
|
89
|
+
and fails it if PayPal denied or declined it. It never captures again. While
|
|
90
|
+
PayPal is still processing the capture, or can't be reached, Capture says so
|
|
91
|
+
and the payment stays pending, so Capture can be pressed again. Capture takes
|
|
92
|
+
the whole payment: PayPal captured it all at checkout.
|
|
93
|
+
- **Canceling an order** with Spree's default (`refund_payments: false`) marks
|
|
94
|
+
a completed PayPal payment void in Spree while PayPal keeps the money. Refund
|
|
95
|
+
the payment in the dashboard before canceling the order, or refund it in
|
|
96
|
+
PayPal.
|
|
97
|
+
- **Void** always fails. A PayPal payment holds captured money, so refund it
|
|
98
|
+
instead. One that PayPal is still processing can't be voided or canceled;
|
|
99
|
+
refund it once PayPal completes it.
|
|
100
|
+
|
|
60
101
|
## Apple Pay
|
|
61
102
|
|
|
62
103
|
1. Enable Apple Pay on the PayPal business account.
|
|
@@ -70,6 +111,64 @@ https://<store>/api/v3/webhooks/payments/<prefixed-payment-method-id>
|
|
|
70
111
|
`Spree::PaypalMerchant::PaymentSources::ApplePay`.
|
|
71
112
|
4. Safari + HTTPS only.
|
|
72
113
|
|
|
114
|
+
## Upgrading from 5.2
|
|
115
|
+
|
|
116
|
+
Bump the gem to `~> 6.0` as part of your Spree 6 upgrade.
|
|
117
|
+
|
|
118
|
+
- **No data migration of the gem's own.** Payment methods, sources and
|
|
119
|
+
sessions keep their types. Spree's migrations move the sources' metadata.
|
|
120
|
+
- **The webhook URL is unchanged.** `CHECKOUT.ORDER.APPROVED` is no longer
|
|
121
|
+
needed; the gem ignores it.
|
|
122
|
+
- **PayPal captures at checkout, whatever the store default is.** A store whose
|
|
123
|
+
default capture method is `on_dispatch` still captures PayPal at checkout. Only
|
|
124
|
+
a PayPal payment method whose own capture method is `on_dispatch` or `manual`
|
|
125
|
+
is refused.
|
|
126
|
+
- **Old payments refund as before.** A payment taken on 5.x refunds through the
|
|
127
|
+
capture id stored on it.
|
|
128
|
+
- **The storefront contract is the same.** The Store API type,
|
|
129
|
+
`external_data` keys and `public_preferences` are unchanged. One thing is
|
|
130
|
+
refused: a session's amount can't be changed after it is created, so a
|
|
131
|
+
storefront that updated it must create a new payment session instead.
|
|
132
|
+
- **The 5.x checkout flow is gone.** `Spree::PaypalMerchant::Order`,
|
|
133
|
+
`CreatePayment`, `CaptureOrder`, the order's `paypal_id` and the gateway's
|
|
134
|
+
`find_order` are removed, along with the ERB admin views. Spree 6 has no Rails
|
|
135
|
+
admin; its dashboard builds the settings form from the gateway's preferences.
|
|
136
|
+
The `spree_paypal_merchant_orders` table stays. The gateway's `authorize` and
|
|
137
|
+
`purchase` now fail, since PayPal payments are taken through a payment
|
|
138
|
+
session, and `capture` reads PayPal (see "In the dashboard").
|
|
139
|
+
|
|
140
|
+
Three things to know before you cut over:
|
|
141
|
+
|
|
142
|
+
1. **Webhooks are one-shot.** Spree 6 settles a payment webhook in a
|
|
143
|
+
background job and does not retry a failed one. PayPal does not send it
|
|
144
|
+
again, because Spree already answered 200. If the gem cannot read PayPal's
|
|
145
|
+
order, or the order shows no completed, pending or partly refunded capture,
|
|
146
|
+
that event is dropped. The storefront's confirm step, or a later PayPal event, settles the
|
|
147
|
+
payment instead.
|
|
148
|
+
2. **Check PayPal for captures still pending** (an eCheck, say). Under 5.x a
|
|
149
|
+
pending capture was recorded as a completed payment. After the upgrade the
|
|
150
|
+
gem ignores later PayPal events for checkouts settled under 5.x, so if
|
|
151
|
+
PayPal denies or reverses one of those captures, Spree won't change. Handle
|
|
152
|
+
any that fail by hand.
|
|
153
|
+
3. **A source's profile id can change.** A PayPal wallet with no PayPal account
|
|
154
|
+
id is now keyed by the cart's (or order's) prefixed id instead of the order
|
|
155
|
+
number. Such a source isn't reused, and a new one is created.
|
|
156
|
+
|
|
157
|
+
## Known limitations
|
|
158
|
+
|
|
159
|
+
- PayPal treats HUF and TWD as zero-decimal currencies, but Spree formats them
|
|
160
|
+
with two decimals, so those currencies aren't supported.
|
|
161
|
+
- Multi-seller checkouts, where one payment covers several orders, aren't
|
|
162
|
+
tested.
|
|
163
|
+
- If the storefront confirms the payment but never completes the cart (the
|
|
164
|
+
buyer closes the tab between the two calls), PayPal's later `COMPLETED`
|
|
165
|
+
event doesn't place the cart. That is Spree's design: a webhook places the
|
|
166
|
+
cart only when it is what settles the payment. The payment sits on an
|
|
167
|
+
unplaced cart, and the order has to be placed by hand.
|
|
168
|
+
- If the webhook settles first and completing the cart then fails (stock, a
|
|
169
|
+
validation, tax), Spree rolls the payment back with it, and the one-shot event
|
|
170
|
+
is dropped. The storefront's confirm call settles the payment instead.
|
|
171
|
+
|
|
73
172
|
## Developing
|
|
74
173
|
|
|
75
174
|
```bash
|
|
@@ -78,6 +177,10 @@ bundle exec rake test_app
|
|
|
78
177
|
bundle exec rspec
|
|
79
178
|
```
|
|
80
179
|
|
|
180
|
+
Specs run against PostgreSQL (set `DATABASE_URL`). To test one Spree version,
|
|
181
|
+
set `SPREE_VERSION`, for example `SPREE_VERSION=6.0.0 bundle update spree`. CI
|
|
182
|
+
runs Spree 6.0.0 and the newest release, each on Ruby 3.3 and 4.0.
|
|
183
|
+
|
|
81
184
|
## License
|
|
82
185
|
|
|
83
186
|
MIT. Derived from [spree/spree_paypal_checkout](https://github.com/spree/spree_paypal_checkout)
|
data/Rakefile
CHANGED
|
@@ -23,5 +23,6 @@ task :test_app do
|
|
|
23
23
|
# "#{LIB_NAME.camelize}::Generators::InstallGenerator".
|
|
24
24
|
ENV['LIB_NAME'] = 'spree/paypal_merchant'
|
|
25
25
|
ENV['DB'] ||= 'postgres'
|
|
26
|
-
|
|
26
|
+
# Spree 6's extension:test_app takes no install_storefront/install_admin options.
|
|
27
|
+
Rake::Task['extension:test_app'].execute
|
|
27
28
|
end
|
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
module Spree
|
|
4
4
|
class PaymentSessions::PaypalMerchant < PaymentSession
|
|
5
|
+
##
|
|
6
|
+
# Capture statuses that will not change again: the funds moved, or they never will.
|
|
7
|
+
# Anything else (PENDING, or no capture yet) still needs to be re-read from PayPal.
|
|
8
|
+
#
|
|
9
|
+
SETTLED_CAPTURE_STATUSES = %w[COMPLETED DECLINED FAILED].freeze
|
|
10
|
+
|
|
11
|
+
##
|
|
12
|
+
# Capture statuses where the buyer has paid: the funds moved, or are on
|
|
13
|
+
# their way. A partly refunded capture was paid first.
|
|
14
|
+
#
|
|
15
|
+
PAID_CAPTURE_STATUSES = %w[COMPLETED PENDING PARTIALLY_REFUNDED].freeze
|
|
16
|
+
|
|
17
|
+
##
|
|
18
|
+
# The first capture of the first purchase unit of a PayPal order.
|
|
19
|
+
#
|
|
20
|
+
# @param paypal_order [Hash, NilClass] a PayPal order as JSON
|
|
21
|
+
# @return [Hash, NilClass]
|
|
22
|
+
#
|
|
23
|
+
def self.paypal_capture_in(paypal_order)
|
|
24
|
+
paypal_order&.dig('purchase_units', 0, 'payments', 'captures', 0)
|
|
25
|
+
end
|
|
26
|
+
|
|
5
27
|
##
|
|
6
28
|
# @return [String]
|
|
7
29
|
#
|
|
@@ -9,11 +31,34 @@ module Spree
|
|
|
9
31
|
external_id
|
|
10
32
|
end
|
|
11
33
|
|
|
34
|
+
##
|
|
35
|
+
# The first capture of the first purchase unit.
|
|
36
|
+
#
|
|
37
|
+
# @return [Hash, NilClass]
|
|
38
|
+
#
|
|
39
|
+
def paypal_capture
|
|
40
|
+
self.class.paypal_capture_in(external_data)
|
|
41
|
+
end
|
|
42
|
+
|
|
12
43
|
##
|
|
13
44
|
# @return [String, NilClass]
|
|
14
45
|
#
|
|
15
46
|
def paypal_capture_id
|
|
16
|
-
|
|
47
|
+
paypal_capture&.dig('id')
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
##
|
|
51
|
+
# @return [String, NilClass]
|
|
52
|
+
#
|
|
53
|
+
def paypal_capture_status
|
|
54
|
+
paypal_capture&.dig('status')
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
##
|
|
58
|
+
# @return [TrueClass, FalseClass]
|
|
59
|
+
#
|
|
60
|
+
def capture_settled?
|
|
61
|
+
SETTLED_CAPTURE_STATUSES.include?(paypal_capture_status)
|
|
17
62
|
end
|
|
18
63
|
|
|
19
64
|
##
|
|
@@ -31,10 +76,13 @@ module Spree
|
|
|
31
76
|
end
|
|
32
77
|
|
|
33
78
|
##
|
|
79
|
+
# Whether the funds were captured. Reads the capture, not the order:
|
|
80
|
+
# PayPal can report an order as COMPLETED whose capture was declined.
|
|
81
|
+
#
|
|
34
82
|
# @return [TrueClass, FalseClass]
|
|
35
83
|
#
|
|
36
84
|
def accepted?
|
|
37
|
-
|
|
85
|
+
paypal_capture_status == 'COMPLETED'
|
|
38
86
|
end
|
|
39
87
|
|
|
40
88
|
##
|
|
@@ -45,50 +93,100 @@ module Spree
|
|
|
45
93
|
end
|
|
46
94
|
|
|
47
95
|
##
|
|
48
|
-
#
|
|
96
|
+
# Stores a PayPal order read from PayPal on the session. The order's own
|
|
97
|
+
# fields replace the stored ones; the keys the storefront put there
|
|
98
|
+
# (+client_token+, +enable_apple_pay+, +enable_card_fields+) stay, because
|
|
99
|
+
# the session can still be open when it is stored.
|
|
49
100
|
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
101
|
+
# @param paypal_order [Hash] a PayPal order as JSON
|
|
102
|
+
# @return [void]
|
|
52
103
|
#
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
104
|
+
def store_paypal_order!(paypal_order)
|
|
105
|
+
update!(external_data: (external_data || {}).merge(paypal_order))
|
|
80
106
|
end
|
|
81
107
|
|
|
82
|
-
|
|
108
|
+
##
|
|
109
|
+
# Reads the PayPal order before settlement, so the settlement lock holds
|
|
110
|
+
# across no PayPal I/O. Skipped once the capture has reached a final status.
|
|
111
|
+
#
|
|
112
|
+
# Only core's webhook workflow calls this. It settles a captured event as
|
|
113
|
+
# paid whatever the order shows, so a capture that holds no funds must stop
|
|
114
|
+
# here: DECLINED, FAILED, or none at all. Nothing is created, and the
|
|
115
|
+
# session stays as it was.
|
|
116
|
+
#
|
|
117
|
+
# @return [Spree::PaymentSessions::PaypalMerchant] self
|
|
118
|
+
# @raise [Spree::Core::GatewayError] unless the capture is in {PAID_CAPTURE_STATUSES}
|
|
119
|
+
#
|
|
120
|
+
def prepare_for_settlement!
|
|
121
|
+
store_paypal_order!(payment_method.fetch_paypal_order(external_id).data.as_json) unless capture_settled?
|
|
83
122
|
|
|
84
|
-
|
|
123
|
+
return self if PAID_CAPTURE_STATUSES.include?(paypal_capture_status)
|
|
124
|
+
|
|
125
|
+
raise Spree::Core::GatewayError,
|
|
126
|
+
"PayPal order #{external_id} has #{paypal_capture_status ? "a #{paypal_capture_status} capture" : 'no capture'}; nothing to settle"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
##
|
|
130
|
+
# Builds the wallet, Apple Pay, or card source from the PayPal order.
|
|
131
|
+
# Runs inside the settlement lock, so it reads only what
|
|
132
|
+
# {#prepare_for_settlement!} already stored.
|
|
133
|
+
#
|
|
134
|
+
# The funds are already captured, so a source that cannot be recorded is
|
|
135
|
+
# logged and skipped, and the payment settles without one:
|
|
136
|
+
# - a source type the gem does not know;
|
|
137
|
+
# - a source Spree refuses. Spree keeps a profile id unique per source
|
|
138
|
+
# type, not per payment method, so the buyer's PayPal account can
|
|
139
|
+
# already have a source under another PayPal method (a second store, a
|
|
140
|
+
# recreated method).
|
|
141
|
+
#
|
|
142
|
+
# The source is written in a savepoint, so a refusal from the database
|
|
143
|
+
# leaves the settlement's own transaction usable.
|
|
144
|
+
#
|
|
145
|
+
# @return [Spree::PaymentSource, NilClass]
|
|
146
|
+
#
|
|
147
|
+
def payment_source_for_settlement
|
|
85
148
|
return nil if paypal_payment_source.blank?
|
|
86
149
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
150
|
+
self.class.transaction(requires_new: true) do
|
|
151
|
+
Spree::PaypalMerchant::CreateSource.new(
|
|
152
|
+
paypal_payment_source: paypal_payment_source,
|
|
153
|
+
gateway: payment_method,
|
|
154
|
+
owner: owner
|
|
155
|
+
).call
|
|
156
|
+
end
|
|
157
|
+
rescue ArgumentError
|
|
158
|
+
settle_without_source("unsupported PayPal payment source (#{paypal_payment_source.keys.join(', ')})")
|
|
159
|
+
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e
|
|
160
|
+
# The class only: the error's message can carry the buyer's PayPal account id.
|
|
161
|
+
settle_without_source("PayPal payment source not recorded (#{e.class})")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
##
|
|
165
|
+
# Keeps the PayPal capture id on the payment: refunds are issued against
|
|
166
|
+
# the capture, while the payment itself is keyed on the PayPal order id.
|
|
167
|
+
#
|
|
168
|
+
# The payment's amount is the amount PayPal captured, not the session's,
|
|
169
|
+
# so Spree records what PayPal took. Reads the capture already stored on
|
|
170
|
+
# the session; no PayPal call.
|
|
171
|
+
#
|
|
172
|
+
# @param payment [Spree::Payment] unsaved
|
|
173
|
+
# @param metadata [Hash]
|
|
174
|
+
# @return [void]
|
|
175
|
+
#
|
|
176
|
+
def apply_settlement_metadata(payment, metadata)
|
|
177
|
+
super
|
|
178
|
+
capture_id = paypal_capture_id
|
|
179
|
+
payment.metadata['paypal_capture_id'] = capture_id if capture_id.present?
|
|
180
|
+
|
|
181
|
+
captured_value = paypal_capture&.dig('amount', 'value')
|
|
182
|
+
payment.amount = captured_value.to_d if captured_value.present?
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
def settle_without_source(reason)
|
|
188
|
+
Rails.logger.warn("[spree-paypal_merchant] #{reason}; settling payment session #{id} without a source")
|
|
189
|
+
nil
|
|
92
190
|
end
|
|
93
191
|
end
|
|
94
192
|
end
|