spree-paypal_merchant 5.2.0 → 6.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 +4 -4
- data/CHANGELOG.md +216 -0
- data/README.md +163 -3
- data/Rakefile +2 -1
- data/app/jobs/spree/paypal_merchant/sync_refunds_job.rb +55 -0
- data/app/models/spree/payment_sessions/paypal_merchant.rb +137 -39
- data/app/models/spree/paypal_merchant/gateway/payment_sessions.rb +373 -78
- data/app/models/spree/paypal_merchant/gateway.rb +395 -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/app/services/spree/paypal_merchant/sync_refunds.rb +274 -0
- data/lib/spree/paypal_merchant/factories.rb +8 -17
- data/lib/spree/paypal_merchant/paypal-mark.svg +1 -0
- data/lib/spree/paypal_merchant/version.rb +3 -2
- data/lib/spree/paypal_merchant.rb +26 -3
- data/lib/tasks/spree_paypal_merchant.rake +30 -0
- metadata +35 -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: d09d026a692438a7e89cd1eb47700bc34fb8ef077da6475c94dd61471e70dfed
|
|
4
|
+
data.tar.gz: fca83a3ef6cfb0bb6e27851d65c0ae0daa35b414506acc25218246bbe0a0c19a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 765ffdaf6c95115be5c8f51cf31d42b1c3e171ff0cef1435abff604112735d45eae3051dde53ce5da75d6bb52f29108d6b7c811e02b834f997cef954f2c115c8
|
|
7
|
+
data.tar.gz: 3ddd64eabd1c28835cd4c4760f1c375ede3c45d9dc7251ab69be6f478703633f83f193d4f3498ac13aa7b92c8ea827d12e51e8ceb7f09e9b6a6a65f271c809c2
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,221 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 6.0.1
|
|
4
|
+
|
|
5
|
+
To record refunds made in PayPal, subscribe the webhook to
|
|
6
|
+
`PAYMENT.CAPTURE.REFUNDED` (see "Refunds made in PayPal" in the README).
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Refunds made in PayPal's dashboard are recorded in Spree, so the order no
|
|
11
|
+
longer shows as paid. `PAYMENT.CAPTURE.REFUNDED` queues
|
|
12
|
+
`Spree::PaypalMerchant::SyncRefundsJob` for the payment it is about. The job
|
|
13
|
+
reads the payment's refunds from PayPal, not from the event, and records each
|
|
14
|
+
`COMPLETED` one Spree doesn't have: PayPal's amount, the PayPal refund id as
|
|
15
|
+
the transaction id, and the store's refund reason. The order's payment state
|
|
16
|
+
and totals update as for a refund made in Spree, and PayPal is never asked to
|
|
17
|
+
refund it again.
|
|
18
|
+
- Refunds made from Spree carry their PayPal refund id and are not recorded
|
|
19
|
+
twice. While one is still waiting for PayPal's answer, the job tries again
|
|
20
|
+
later.
|
|
21
|
+
- The reason is the store's "Return processing" reason, or else its first by
|
|
22
|
+
name. None is created: with no reason, nothing is recorded and a warning
|
|
23
|
+
is logged.
|
|
24
|
+
- A refund in another currency, or for more than is left to refund, is
|
|
25
|
+
logged and not recorded.
|
|
26
|
+
- Payments taken on 5.x are synced too.
|
|
27
|
+
- `spree:paypal_merchant:sync_refunds[<order number>]` records the refunds made
|
|
28
|
+
in PayPal for one order, such as those made before the webhook was set up,
|
|
29
|
+
and prints what it recorded or skipped.
|
|
30
|
+
- The payment method's card under Settings -> Integrations shows PayPal's mark
|
|
31
|
+
and links to the README. The mark ships with the gem
|
|
32
|
+
(`lib/spree/paypal_merchant/paypal-mark.svg`) and `Gateway.logo_url` serves
|
|
33
|
+
it as a data URI, so the dashboard needs no asset pipeline and fetches it
|
|
34
|
+
from no other host. The mark is PayPal's trademark, not covered by the gem's
|
|
35
|
+
license.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- `Gateway#webhook_url` is an absolute URL, `https://` in production. It had no
|
|
40
|
+
scheme, so PayPal would not take it. When the Spree API is on another host
|
|
41
|
+
than the store's URL, give PayPal the API host (see the README).
|
|
42
|
+
- `PAYMENT.CAPTURE.REFUNDED` no longer maps to canceling the payment session.
|
|
43
|
+
Its resource is the refund, which names no PayPal order, so the event was
|
|
44
|
+
dropped; at most it could cancel a session still open, and a refund never
|
|
45
|
+
cancels one.
|
|
46
|
+
|
|
47
|
+
## 6.0.0
|
|
48
|
+
|
|
49
|
+
Ports the gem to Spree 6. The major version tracks Spree's. Spree 5.6 stays
|
|
50
|
+
on 5.2.x, on the `5-2-stable` branch. To upgrade, see "Upgrading from 5.2" in
|
|
51
|
+
the README.
|
|
52
|
+
|
|
53
|
+
The storefront contract is unchanged. The Store API type is still
|
|
54
|
+
`paypal_merchant`, and the payment session's `external_data` and the payment
|
|
55
|
+
method's `public_preferences` keep their keys.
|
|
56
|
+
|
|
57
|
+
### Requirements
|
|
58
|
+
|
|
59
|
+
- Spree `~> 6.0` and Ruby 3.3 or newer.
|
|
60
|
+
- The `spree_admin` dependency is gone. Spree 6 has no Rails admin; its
|
|
61
|
+
dashboard builds the settings form from the gateway's preferences.
|
|
62
|
+
- `faraday` (`~> 2.0`) and `net-http-persistent` (`~> 4.0`) are declared. The
|
|
63
|
+
gem uses both directly; before, they came only through `paypal-server-sdk`.
|
|
64
|
+
|
|
65
|
+
### Checkout
|
|
66
|
+
|
|
67
|
+
- Sessions belong to the cart. The amount is the cart total less store
|
|
68
|
+
credit and gift cards. A zero amount raises `Spree::Core::GatewayError`.
|
|
69
|
+
- A session's amount can't change once its PayPal order exists. Updating a
|
|
70
|
+
session with a different amount raises `Spree::Core::GatewayError` (a 422
|
|
71
|
+
from the Store API) telling the storefront to create a new session, and
|
|
72
|
+
`external_data` sent by the client is not stored. The payment records the
|
|
73
|
+
amount PayPal captured, not the session's. Before, a client could change the
|
|
74
|
+
amount, and a cart with store credit could be placed as paid in full while
|
|
75
|
+
PayPal took less and the store credit went unspent.
|
|
76
|
+
- A payment source Spree refuses no longer blocks settlement. Spree keeps a
|
|
77
|
+
source's profile id unique per source type, not per payment method, so a
|
|
78
|
+
PayPal account that already had a source under another PayPal method (a
|
|
79
|
+
second store, a recreated method) made settlement fail after PayPal took
|
|
80
|
+
the money, and every retry failed the same way. The payment now settles
|
|
81
|
+
without a source, and the refusal is logged.
|
|
82
|
+
- The storefront's confirm call and PayPal's webhook settle through Spree 6's
|
|
83
|
+
shared path, in either order, and produce one payment. A webhook that arrives
|
|
84
|
+
first reads PayPal's order, creates the payment and completes the cart.
|
|
85
|
+
- Settlement reads the capture, not the order. PayPal can report an order as
|
|
86
|
+
`COMPLETED` whose capture was declined, as can happen with Card Fields.
|
|
87
|
+
|
|
88
|
+
| Capture status | Result |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `COMPLETED` | A captured payment |
|
|
91
|
+
| `PENDING` | A pending payment; the `COMPLETED` event completes it later |
|
|
92
|
+
| `DECLINED`, `FAILED`, none | The session fails; no payment is created |
|
|
93
|
+
|
|
94
|
+
5.2.0 treated a pending capture as paid.
|
|
95
|
+
- `PAYMENT.CAPTURE.PENDING` (an eCheck, say) leaves the Spree payment pending.
|
|
96
|
+
`PAYMENT.CAPTURE.COMPLETED` completes it.
|
|
97
|
+
- A webhook settles only when PayPal's order shows the capture `COMPLETED`,
|
|
98
|
+
`PENDING` or `PARTIALLY_REFUNDED`. If PayPal's order can't be read, or shows
|
|
99
|
+
none of these, the event is dropped. Spree does not retry it and PayPal does not resend it. See the
|
|
100
|
+
README.
|
|
101
|
+
- `CHECKOUT.ORDER.APPROVED` is ignored. An approved order holds no capture.
|
|
102
|
+
- A webhook for a checkout settled under 5.x is ignored, so it can't create a
|
|
103
|
+
second payment.
|
|
104
|
+
- A session that can no longer be used is never captured. Confirming one that
|
|
105
|
+
was canceled or failed, or whose cart is already a placed order, is refused.
|
|
106
|
+
This stops a stale browser tab from paying twice.
|
|
107
|
+
- Creating a session cancels the cart's earlier open PayPal sessions. It
|
|
108
|
+
raises if one of them is already completed.
|
|
109
|
+
- If a capture gets no clear answer from PayPal, the session stays open. A
|
|
110
|
+
retry reads the order first, so a capture that went through is settled, not
|
|
111
|
+
repeated.
|
|
112
|
+
- PayPal captures at checkout, whatever the store's default capture method is.
|
|
113
|
+
A payment method whose own capture method is `on_dispatch` or `manual` is
|
|
114
|
+
refused. The gateway's capture method is always `checkout`, so a store that
|
|
115
|
+
defaults to `on_dispatch` or `manual` never captures a PayPal payment on
|
|
116
|
+
dispatch, and never ships an unpaid PayPal order as one left for staff.
|
|
117
|
+
- Canceling a payment session is refused while PayPal's capture is `PENDING`,
|
|
118
|
+
`COMPLETED` or `PARTIALLY_REFUNDED`, because money has moved or is on its
|
|
119
|
+
way. Otherwise it cancels.
|
|
120
|
+
- The gateway customer profile is looked up by the owner's customer. An owner
|
|
121
|
+
with no customer (a guest) gets none.
|
|
122
|
+
|
|
123
|
+
### Refunds and cancels
|
|
124
|
+
|
|
125
|
+
- A refund reads the capture id from the payment. A payment taken on 5.x
|
|
126
|
+
refunds as before, through its response code.
|
|
127
|
+
- Refund amounts follow the currency's decimals, so a yen refund has none.
|
|
128
|
+
- When a refund's outcome is unknown (a timeout or connection error), the
|
|
129
|
+
refund fails with a message telling the admin to check PayPal before
|
|
130
|
+
retrying. PayPal may have issued it.
|
|
131
|
+
- A refund that PayPal reports as `FAILED` or `CANCELLED` fails. A `PENDING`
|
|
132
|
+
refund is recorded.
|
|
133
|
+
- Canceling a completed payment refunds it through Spree's refund workflow,
|
|
134
|
+
unless told to keep the money.
|
|
135
|
+
- Void and cancel refuse a payment PayPal may still complete (checkout,
|
|
136
|
+
pending or processing state), with a "still processing" message. Capture
|
|
137
|
+
settles it as PayPal did, or refund it once PayPal completes it.
|
|
138
|
+
- Capture in the dashboard reads PayPal and never captures again. A
|
|
139
|
+
`COMPLETED` capture completes the payment, and a denied or declined one fails
|
|
140
|
+
it. While PayPal is still processing the capture, or can't be read, Capture
|
|
141
|
+
says so and leaves the payment pending, so it can be pressed again. It takes
|
|
142
|
+
the whole payment only, since PayPal captured it all at checkout.
|
|
143
|
+
- Spree's checkout processing can't authorize or purchase with this gateway.
|
|
144
|
+
It fails, saying PayPal payments are taken through a payment session.
|
|
145
|
+
Before, it raised `ActiveModel::UnknownAttributeError`.
|
|
146
|
+
- Canceling an order with Spree's default (`refund_payments: false`) marks a
|
|
147
|
+
completed PayPal payment void in Spree while PayPal keeps the money. Refund
|
|
148
|
+
it before canceling, or refund it in PayPal.
|
|
149
|
+
- A failed, void or invalid payment cancels in Spree with no PayPal call.
|
|
150
|
+
- Void always fails. PayPal payments are captured at checkout, so there is
|
|
151
|
+
nothing to void: refund the payment instead.
|
|
152
|
+
|
|
153
|
+
### Order payload
|
|
154
|
+
|
|
155
|
+
- Every discount counts, promotion and manual.
|
|
156
|
+
- A free-shipping discount goes in PayPal's `shipping_discount`.
|
|
157
|
+
- Fees go in `handling`.
|
|
158
|
+
- Amounts are rounded with Spree's currency rules, so a unit price with four
|
|
159
|
+
decimals is sent with the currency's own.
|
|
160
|
+
- Line items are left out when they don't add up to the item total. The buyer
|
|
161
|
+
sees no itemised list in PayPal for that order only. With two-decimal prices
|
|
162
|
+
it never happens.
|
|
163
|
+
- When the breakdown can't balance, or has a negative figure, only the amount
|
|
164
|
+
is sent.
|
|
165
|
+
- The breakdown still sends only `additional_tax_total`.
|
|
166
|
+
|
|
167
|
+
### Timeouts and retries
|
|
168
|
+
|
|
169
|
+
- Every Orders and Payments API request has a 15-second timeout. The
|
|
170
|
+
gem's own webhook-verification and token requests time out after 5 seconds
|
|
171
|
+
to connect and 10 to read.
|
|
172
|
+
- PayPal reads (GET) are retried up to 3 times. Only GET and PUT are
|
|
173
|
+
retried, and the client says so explicitly.
|
|
174
|
+
- Creating, capturing and refunding are never re-sent automatically.
|
|
175
|
+
- A PayPal server error whose body isn't JSON (an HTML 502 page, say) is
|
|
176
|
+
treated like a timeout: PayPal may have acted. A refund fails with the
|
|
177
|
+
message to check PayPal before retrying, a confirm leaves the session open,
|
|
178
|
+
and Capture leaves the payment pending. Before, the SDK's `TypeError`
|
|
179
|
+
escaped, and the refund failed with a generic error.
|
|
180
|
+
- The webhook signature check treats more transient failures as a failed
|
|
181
|
+
check: a reset connection, a TLS error, a broken HTTP exchange. Spree answers
|
|
182
|
+
401 and PayPal sends the event again. The Card Fields token request handles
|
|
183
|
+
the same failures by creating the session without a token.
|
|
184
|
+
- A PayPal error with no message of its own is reported with PayPal's issue
|
|
185
|
+
descriptions.
|
|
186
|
+
|
|
187
|
+
### Payment sources
|
|
188
|
+
|
|
189
|
+
- The PayPal, Apple Pay and card sources store their fields in `metadata`
|
|
190
|
+
(Spree 6 merged `private_metadata` into it). The accessors keep their names.
|
|
191
|
+
- A PayPal wallet with no PayPal account id is keyed by the cart's (or
|
|
192
|
+
order's) prefixed id instead of the order number, so a 5.x source of that
|
|
193
|
+
kind isn't reused.
|
|
194
|
+
|
|
195
|
+
### Removed
|
|
196
|
+
|
|
197
|
+
- The 5.x checkout flow: `Spree::PaypalMerchant::Order`, `CreatePayment`,
|
|
198
|
+
`CaptureOrder`, the order's `paypal_id`, and the gateway's `find_order`. It
|
|
199
|
+
had no routes. The gateway's `authorize`, `purchase` and `capture` are
|
|
200
|
+
rewritten for Spree 6 (see "Refunds and cancels").
|
|
201
|
+
- The ERB admin views and the `*_partial_name` hooks.
|
|
202
|
+
- `Spree::PaypalMerchant::Base`, an abstract model class nothing inherits from
|
|
203
|
+
any more.
|
|
204
|
+
- The `spree_paypal_merchant_orders` table stays, and no migration drops it in
|
|
205
|
+
this release.
|
|
206
|
+
|
|
207
|
+
### Known limitations
|
|
208
|
+
|
|
209
|
+
- PayPal treats HUF and TWD as zero-decimal currencies, but Spree formats them
|
|
210
|
+
with two decimals, so those currencies aren't supported.
|
|
211
|
+
- Multi-seller checkouts, where one payment covers several orders, aren't
|
|
212
|
+
tested.
|
|
213
|
+
- A payment the storefront confirmed on a cart it never completed isn't
|
|
214
|
+
placed by PayPal's later webhook. It has to be placed by hand.
|
|
215
|
+
- If the webhook settles first and completing the cart then fails, Spree rolls
|
|
216
|
+
the payment back and the event is dropped. The storefront's confirm call
|
|
217
|
+
settles it instead.
|
|
218
|
+
|
|
3
219
|
## 5.2.0
|
|
4
220
|
|
|
5
221
|
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
|
|---|---|
|
|
@@ -51,12 +66,91 @@ Then add a **PayPal** payment method in admin and set:
|
|
|
51
66
|
| `enable_apple_pay` | Offer Apple Pay on the storefront (default on) |
|
|
52
67
|
| `enable_card_fields` | Offer Card Fields on the storefront (default on) |
|
|
53
68
|
|
|
54
|
-
Webhook URL is the standard Spree v3 payments webhook
|
|
69
|
+
Webhook URL is the standard Spree v3 payments webhook. PayPal only accepts an
|
|
70
|
+
absolute `https://` URL:
|
|
55
71
|
|
|
56
72
|
```text
|
|
57
73
|
https://<store>/api/v3/webhooks/payments/<prefixed-payment-method-id>
|
|
58
74
|
```
|
|
59
75
|
|
|
76
|
+
The gateway's `webhook_url` builds it from the store's URL. When the Spree API
|
|
77
|
+
is served from another host than the store's URL (a headless storefront on its
|
|
78
|
+
own domain, say), give PayPal the **API host** instead:
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
https://<api host>/api/v3/webhooks/payments/<prefixed-payment-method-id>
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Subscribe it to these events. Others, such as `CHECKOUT.ORDER.APPROVED`, are
|
|
85
|
+
ignored.
|
|
86
|
+
|
|
87
|
+
| Event | What the gem does |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `PAYMENT.CAPTURE.COMPLETED` | Settles the payment as captured |
|
|
90
|
+
| `PAYMENT.CAPTURE.PENDING` | Settles it as pending (an eCheck, say); `COMPLETED` completes it later |
|
|
91
|
+
| `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) |
|
|
92
|
+
| `PAYMENT.CAPTURE.REFUNDED` | Records the refund on the Spree payment (see [Refunds made in PayPal](#refunds-made-in-paypal)). PayPal sends it for refunds made from Spree too; those are not recorded twice |
|
|
93
|
+
| `PAYMENT.CAPTURE.REVERSED` | Cancels the payment session if it is still open and the event names the PayPal order. The event leaves a completed session and its payment as they are. But the refund sync records whatever PayPal lists as a `COMPLETED` refund of the capture, so if PayPal lists a reversal (after a chargeback, say) among the order's refunds, a later sync records it as a refund |
|
|
94
|
+
|
|
95
|
+
## In the dashboard
|
|
96
|
+
|
|
97
|
+
- **Capture** on a pending PayPal payment reads PayPal and settles the payment
|
|
98
|
+
the way PayPal did. It completes the payment if PayPal completed the capture,
|
|
99
|
+
and fails it if PayPal denied or declined it. It never captures again. While
|
|
100
|
+
PayPal is still processing the capture, or can't be reached, Capture says so
|
|
101
|
+
and the payment stays pending, so Capture can be pressed again. Capture takes
|
|
102
|
+
the whole payment: PayPal captured it all at checkout.
|
|
103
|
+
- **Canceling an order** with Spree's default (`refund_payments: false`) marks
|
|
104
|
+
a completed PayPal payment void in Spree while PayPal keeps the money. Refund
|
|
105
|
+
the payment in the dashboard before canceling the order, or refund it in
|
|
106
|
+
PayPal.
|
|
107
|
+
- **Void** always fails. A PayPal payment holds captured money, so refund it
|
|
108
|
+
instead. One that PayPal is still processing can't be voided or canceled;
|
|
109
|
+
refund it once PayPal completes it.
|
|
110
|
+
|
|
111
|
+
## Refunds made in PayPal
|
|
112
|
+
|
|
113
|
+
A refund made in PayPal's own dashboard is recorded in Spree, so the order
|
|
114
|
+
stops showing as paid. When PayPal sends `PAYMENT.CAPTURE.REFUNDED`, the gem
|
|
115
|
+
queues `Spree::PaypalMerchant::SyncRefundsJob` (on Spree's payment webhook
|
|
116
|
+
queue, 30 seconds later) for the payment the refund is about. The job reads the
|
|
117
|
+
payment's refunds from PayPal, not from the event, and records each `COMPLETED`
|
|
118
|
+
one Spree doesn't have yet:
|
|
119
|
+
|
|
120
|
+
- as a Spree refund for PayPal's amount, with the PayPal refund id as its
|
|
121
|
+
transaction id, so Spree never asks PayPal to refund it again. Its metadata
|
|
122
|
+
says it was recorded from PayPal. The order's payment state and totals update
|
|
123
|
+
as for a refund made in Spree.
|
|
124
|
+
- with the store's **Return processing** refund reason, the one Spree's refund
|
|
125
|
+
workflow defaults to, or else the store's first reason by name, as the admin
|
|
126
|
+
API defaults. The gem never creates a reason: with none, nothing is recorded
|
|
127
|
+
and a warning is logged.
|
|
128
|
+
|
|
129
|
+
Refunds made from Spree already carry their PayPal refund id, so they are never
|
|
130
|
+
recorded twice, and the job runs again later while one is still waiting for
|
|
131
|
+
PayPal's answer. Not recorded, and logged: a refund PayPal hasn't completed, one
|
|
132
|
+
in another currency than the payment's, one for more than is left to refund,
|
|
133
|
+
and any refund of a payment that isn't completed (one Spree voided, say).
|
|
134
|
+
Payments taken on 5.x are synced too.
|
|
135
|
+
|
|
136
|
+
The sync records whatever PayPal lists as a `COMPLETED` refund of the
|
|
137
|
+
payment's capture. If PayPal lists a reversal there (after a chargeback, say),
|
|
138
|
+
the sync records it as a refund, though `PAYMENT.CAPTURE.REVERSED` itself
|
|
139
|
+
changes nothing.
|
|
140
|
+
|
|
141
|
+
Refunds recorded from PayPal don't run Spree's refund workflow, so its
|
|
142
|
+
callbacks (`:validate`, `:before_refund`, `:after_refund`) don't run for them.
|
|
143
|
+
App hooks registered there, such as a customer refund email, won't fire. The
|
|
144
|
+
`refund.created` and `payment.refunded` events do.
|
|
145
|
+
|
|
146
|
+
For refunds made before the webhook was set up, or ones the job gave up on,
|
|
147
|
+
run the same sync for one order's PayPal payments. It prints what it recorded
|
|
148
|
+
or skipped, and running it again records nothing new:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
bin/rails 'spree:paypal_merchant:sync_refunds[R123456789]'
|
|
152
|
+
```
|
|
153
|
+
|
|
60
154
|
## Apple Pay
|
|
61
155
|
|
|
62
156
|
1. Enable Apple Pay on the PayPal business account.
|
|
@@ -70,6 +164,64 @@ https://<store>/api/v3/webhooks/payments/<prefixed-payment-method-id>
|
|
|
70
164
|
`Spree::PaypalMerchant::PaymentSources::ApplePay`.
|
|
71
165
|
4. Safari + HTTPS only.
|
|
72
166
|
|
|
167
|
+
## Upgrading from 5.2
|
|
168
|
+
|
|
169
|
+
Bump the gem to `~> 6.0` as part of your Spree 6 upgrade.
|
|
170
|
+
|
|
171
|
+
- **No data migration of the gem's own.** Payment methods, sources and
|
|
172
|
+
sessions keep their types. Spree's migrations move the sources' metadata.
|
|
173
|
+
- **The webhook URL is unchanged.** `CHECKOUT.ORDER.APPROVED` is no longer
|
|
174
|
+
needed; the gem ignores it.
|
|
175
|
+
- **PayPal captures at checkout, whatever the store default is.** A store whose
|
|
176
|
+
default capture method is `on_dispatch` still captures PayPal at checkout. Only
|
|
177
|
+
a PayPal payment method whose own capture method is `on_dispatch` or `manual`
|
|
178
|
+
is refused.
|
|
179
|
+
- **Old payments refund as before.** A payment taken on 5.x refunds through the
|
|
180
|
+
capture id stored on it.
|
|
181
|
+
- **The storefront contract is the same.** The Store API type,
|
|
182
|
+
`external_data` keys and `public_preferences` are unchanged. One thing is
|
|
183
|
+
refused: a session's amount can't be changed after it is created, so a
|
|
184
|
+
storefront that updated it must create a new payment session instead.
|
|
185
|
+
- **The 5.x checkout flow is gone.** `Spree::PaypalMerchant::Order`,
|
|
186
|
+
`CreatePayment`, `CaptureOrder`, the order's `paypal_id` and the gateway's
|
|
187
|
+
`find_order` are removed, along with the ERB admin views. Spree 6 has no Rails
|
|
188
|
+
admin; its dashboard builds the settings form from the gateway's preferences.
|
|
189
|
+
The `spree_paypal_merchant_orders` table stays. The gateway's `authorize` and
|
|
190
|
+
`purchase` now fail, since PayPal payments are taken through a payment
|
|
191
|
+
session, and `capture` reads PayPal (see "In the dashboard").
|
|
192
|
+
|
|
193
|
+
Three things to know before you cut over:
|
|
194
|
+
|
|
195
|
+
1. **Webhooks are one-shot.** Spree 6 settles a payment webhook in a
|
|
196
|
+
background job and does not retry a failed one. PayPal does not send it
|
|
197
|
+
again, because Spree already answered 200. If the gem cannot read PayPal's
|
|
198
|
+
order, or the order shows no completed, pending or partly refunded capture,
|
|
199
|
+
that event is dropped. The storefront's confirm step, or a later PayPal event, settles the
|
|
200
|
+
payment instead.
|
|
201
|
+
2. **Check PayPal for captures still pending** (an eCheck, say). Under 5.x a
|
|
202
|
+
pending capture was recorded as a completed payment. After the upgrade the
|
|
203
|
+
gem ignores later PayPal events for checkouts settled under 5.x, so if
|
|
204
|
+
PayPal denies or reverses one of those captures, Spree won't change. Handle
|
|
205
|
+
any that fail by hand.
|
|
206
|
+
3. **A source's profile id can change.** A PayPal wallet with no PayPal account
|
|
207
|
+
id is now keyed by the cart's (or order's) prefixed id instead of the order
|
|
208
|
+
number. Such a source isn't reused, and a new one is created.
|
|
209
|
+
|
|
210
|
+
## Known limitations
|
|
211
|
+
|
|
212
|
+
- PayPal treats HUF and TWD as zero-decimal currencies, but Spree formats them
|
|
213
|
+
with two decimals, so those currencies aren't supported.
|
|
214
|
+
- Multi-seller checkouts, where one payment covers several orders, aren't
|
|
215
|
+
tested.
|
|
216
|
+
- If the storefront confirms the payment but never completes the cart (the
|
|
217
|
+
buyer closes the tab between the two calls), PayPal's later `COMPLETED`
|
|
218
|
+
event doesn't place the cart. That is Spree's design: a webhook places the
|
|
219
|
+
cart only when it is what settles the payment. The payment sits on an
|
|
220
|
+
unplaced cart, and the order has to be placed by hand.
|
|
221
|
+
- If the webhook settles first and completing the cart then fails (stock, a
|
|
222
|
+
validation, tax), Spree rolls the payment back with it, and the one-shot event
|
|
223
|
+
is dropped. The storefront's confirm call settles the payment instead.
|
|
224
|
+
|
|
73
225
|
## Developing
|
|
74
226
|
|
|
75
227
|
```bash
|
|
@@ -78,7 +230,15 @@ bundle exec rake test_app
|
|
|
78
230
|
bundle exec rspec
|
|
79
231
|
```
|
|
80
232
|
|
|
233
|
+
Specs run against PostgreSQL (set `DATABASE_URL`). To test one Spree version,
|
|
234
|
+
set `SPREE_VERSION`, for example `SPREE_VERSION=6.0.0 bundle update spree`. CI
|
|
235
|
+
runs Spree 6.0.0 and the newest release, each on Ruby 3.3 and 4.0.
|
|
236
|
+
|
|
81
237
|
## License
|
|
82
238
|
|
|
83
239
|
MIT. Derived from [spree/spree_paypal_checkout](https://github.com/spree/spree_paypal_checkout)
|
|
84
240
|
(Vendo Connect Inc.), re-namespaced and extended by Aypex.
|
|
241
|
+
|
|
242
|
+
The PayPal mark (`lib/spree/paypal_merchant/paypal-mark.svg`) is PayPal's
|
|
243
|
+
trademark, included only to identify the payment method in the Spree
|
|
244
|
+
dashboard, and is not covered by this gem's license.
|
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
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module PaypalMerchant
|
|
5
|
+
##
|
|
6
|
+
# Records in Spree the refunds made in PayPal for one payment
|
|
7
|
+
# ({SyncRefunds}). Enqueued when PayPal reports a refund.
|
|
8
|
+
#
|
|
9
|
+
# Runs on the queue Spree's own payment webhook job uses, after the same
|
|
10
|
+
# wait. Tries again a few times while PayPal cannot be read, and while a
|
|
11
|
+
# refund made in Spree waits for PayPal's answer. A payment that is gone
|
|
12
|
+
# is dropped. Anything it gives up on is logged with the rake task that
|
|
13
|
+
# records it by hand.
|
|
14
|
+
#
|
|
15
|
+
class SyncRefundsJob < Spree::BaseJob
|
|
16
|
+
##
|
|
17
|
+
# How long after PayPal's event the sync runs, as for Spree's own
|
|
18
|
+
# payment webhooks.
|
|
19
|
+
#
|
|
20
|
+
WAIT = 30.seconds
|
|
21
|
+
|
|
22
|
+
##
|
|
23
|
+
# How to record a payment's PayPal refunds by hand.
|
|
24
|
+
#
|
|
25
|
+
BY_HAND = "record them with bin/rails 'spree:paypal_merchant:sync_refunds[<order number>]'"
|
|
26
|
+
|
|
27
|
+
queue_as Spree.queues.payment_webhooks
|
|
28
|
+
|
|
29
|
+
retry_on SyncRefunds::PaypalUnavailable, wait: :polynomially_longer, attempts: 5
|
|
30
|
+
|
|
31
|
+
retry_on SyncRefunds::RefundInProgress, wait: 30.seconds, attempts: 5 do |_job, error|
|
|
32
|
+
Rails.logger.warn("[spree-paypal_merchant] PayPal refunds not synced: #{error.message}. Once it settles, #{BY_HAND}.")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# PayPal refused to show the payment's order or capture. Asking again
|
|
36
|
+
# changes nothing.
|
|
37
|
+
discard_on Spree::Core::GatewayError do |_job, error|
|
|
38
|
+
Rails.logger.warn("[spree-paypal_merchant] PayPal refunds not synced: #{error.message}. Once resolved, #{BY_HAND}.")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
discard_on ActiveRecord::RecordNotFound
|
|
42
|
+
|
|
43
|
+
##
|
|
44
|
+
# @param payment_id [Integer] a payment of a {Gateway}
|
|
45
|
+
# @return [SyncRefunds::Result]
|
|
46
|
+
#
|
|
47
|
+
def perform(payment_id:)
|
|
48
|
+
payment = Spree::Payment.find(payment_id)
|
|
49
|
+
Spree::Current.store = payment.payment_method.store
|
|
50
|
+
|
|
51
|
+
SyncRefunds.new(payment: payment).call
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|