spree-paypal_merchant 6.0.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 +44 -0
- data/README.md +59 -2
- data/app/jobs/spree/paypal_merchant/sync_refunds_job.rb +55 -0
- data/app/models/spree/paypal_merchant/gateway/payment_sessions.rb +102 -17
- data/app/models/spree/paypal_merchant/gateway.rb +39 -2
- data/app/services/spree/paypal_merchant/sync_refunds.rb +274 -0
- data/lib/spree/paypal_merchant/paypal-mark.svg +1 -0
- data/lib/spree/paypal_merchant/version.rb +1 -1
- data/lib/spree/paypal_merchant.rb +23 -0
- data/lib/tasks/spree_paypal_merchant.rake +30 -0
- metadata +5 -1
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,49 @@
|
|
|
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
|
+
|
|
3
47
|
## 6.0.0
|
|
4
48
|
|
|
5
49
|
Ports the gem to Spree 6. The major version tracks Spree's. Spree 5.6 stays
|
data/README.md
CHANGED
|
@@ -66,12 +66,21 @@ Then add a **PayPal** payment method in the admin dashboard and set:
|
|
|
66
66
|
| `enable_apple_pay` | Offer Apple Pay on the storefront (default on) |
|
|
67
67
|
| `enable_card_fields` | Offer Card Fields on the storefront (default on) |
|
|
68
68
|
|
|
69
|
-
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:
|
|
70
71
|
|
|
71
72
|
```text
|
|
72
73
|
https://<store>/api/v3/webhooks/payments/<prefixed-payment-method-id>
|
|
73
74
|
```
|
|
74
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
|
+
|
|
75
84
|
Subscribe it to these events. Others, such as `CHECKOUT.ORDER.APPROVED`, are
|
|
76
85
|
ignored.
|
|
77
86
|
|
|
@@ -80,7 +89,8 @@ ignored.
|
|
|
80
89
|
| `PAYMENT.CAPTURE.COMPLETED` | Settles the payment as captured |
|
|
81
90
|
| `PAYMENT.CAPTURE.PENDING` | Settles it as pending (an eCheck, say); `COMPLETED` completes it later |
|
|
82
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) |
|
|
83
|
-
| `PAYMENT.CAPTURE.
|
|
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 |
|
|
84
94
|
|
|
85
95
|
## In the dashboard
|
|
86
96
|
|
|
@@ -98,6 +108,49 @@ ignored.
|
|
|
98
108
|
instead. One that PayPal is still processing can't be voided or canceled;
|
|
99
109
|
refund it once PayPal completes it.
|
|
100
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
|
+
|
|
101
154
|
## Apple Pay
|
|
102
155
|
|
|
103
156
|
1. Enable Apple Pay on the PayPal business account.
|
|
@@ -185,3 +238,7 @@ runs Spree 6.0.0 and the newest release, each on Ruby 3.3 and 4.0.
|
|
|
185
238
|
|
|
186
239
|
MIT. Derived from [spree/spree_paypal_checkout](https://github.com/spree/spree_paypal_checkout)
|
|
187
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.
|
|
@@ -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
|
|
@@ -41,15 +41,24 @@ module Spree
|
|
|
41
41
|
# A pending capture settles as a payment that is not completed yet; the
|
|
42
42
|
# COMPLETED event then completes that same payment.
|
|
43
43
|
#
|
|
44
|
+
# A refund is not among them ({REFUND_EVENT}): it never cancels a
|
|
45
|
+
# session.
|
|
46
|
+
#
|
|
44
47
|
WEBHOOK_ACTIONS = {
|
|
45
48
|
'PAYMENT.CAPTURE.COMPLETED' => :captured,
|
|
46
49
|
'PAYMENT.CAPTURE.PENDING' => :authorized,
|
|
47
50
|
'PAYMENT.CAPTURE.DENIED' => :failed,
|
|
48
51
|
'PAYMENT.CAPTURE.DECLINED' => :failed,
|
|
49
|
-
'PAYMENT.CAPTURE.REVERSED' => :canceled
|
|
50
|
-
'PAYMENT.CAPTURE.REFUNDED' => :canceled
|
|
52
|
+
'PAYMENT.CAPTURE.REVERSED' => :canceled
|
|
51
53
|
}.freeze
|
|
52
54
|
|
|
55
|
+
##
|
|
56
|
+
# The event PayPal sends for a refund of a capture, however it was
|
|
57
|
+
# made. Its resource is the refund, which links up to its capture.
|
|
58
|
+
# The gem records the refund itself ({SyncRefundsJob}).
|
|
59
|
+
#
|
|
60
|
+
REFUND_EVENT = 'PAYMENT.CAPTURE.REFUNDED'
|
|
61
|
+
|
|
53
62
|
##
|
|
54
63
|
# @return [TrueClass, FalseClass]
|
|
55
64
|
#
|
|
@@ -227,7 +236,7 @@ module Spree
|
|
|
227
236
|
end
|
|
228
237
|
|
|
229
238
|
##
|
|
230
|
-
# Reads a PayPal order, including its captures.
|
|
239
|
+
# Reads a PayPal order, including its captures and refunds.
|
|
231
240
|
#
|
|
232
241
|
# @param paypal_order_id [String]
|
|
233
242
|
# @return [PaypalServerSdk::ApiResponse]
|
|
@@ -236,6 +245,29 @@ module Spree
|
|
|
236
245
|
client.orders.get_order('id' => paypal_order_id, 'prefer' => 'return=representation')
|
|
237
246
|
end
|
|
238
247
|
|
|
248
|
+
##
|
|
249
|
+
# Reads a PayPal capture, which names its order.
|
|
250
|
+
#
|
|
251
|
+
# @param capture_id [String]
|
|
252
|
+
# @return [PaypalServerSdk::ApiResponse]
|
|
253
|
+
#
|
|
254
|
+
def fetch_paypal_capture(capture_id)
|
|
255
|
+
client.payments.get_captured_payment('capture_id' => capture_id)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
##
|
|
259
|
+
# Whether PayPal definitely refused the request. A server error, or a
|
|
260
|
+
# client error in {AMBIGUOUS_CLIENT_ERROR_STATUSES}, leaves the outcome
|
|
261
|
+
# unknown, and asking again later may succeed.
|
|
262
|
+
#
|
|
263
|
+
# @param error [PaypalServerSdk::APIException]
|
|
264
|
+
# @return [TrueClass, FalseClass]
|
|
265
|
+
#
|
|
266
|
+
def paypal_refusal?(error)
|
|
267
|
+
status = error.response_code.to_i
|
|
268
|
+
status.between?(400, 499) && AMBIGUOUS_CLIENT_ERROR_STATUSES.exclude?(status)
|
|
269
|
+
end
|
|
270
|
+
|
|
239
271
|
##
|
|
240
272
|
# Parses an incoming PayPal webhook into a payment-session action.
|
|
241
273
|
#
|
|
@@ -244,6 +276,10 @@ module Spree
|
|
|
244
276
|
# with this method, and for a session settled before Spree 6 (see
|
|
245
277
|
# {#settled_before_spree_6?}).
|
|
246
278
|
#
|
|
279
|
+
# A refund ({REFUND_EVENT}) also returns nil, once the signature has
|
|
280
|
+
# been checked: the gem enqueues {SyncRefundsJob} for the payment of
|
|
281
|
+
# this method the refund is about, if it knows one.
|
|
282
|
+
#
|
|
247
283
|
# @param raw_body [String]
|
|
248
284
|
# @param headers [Hash]
|
|
249
285
|
# @return [Hash, NilClass]
|
|
@@ -253,10 +289,12 @@ module Spree
|
|
|
253
289
|
verify_webhook_signature!(raw_body, headers)
|
|
254
290
|
|
|
255
291
|
event = JSON.parse(raw_body).with_indifferent_access
|
|
292
|
+
resource = event[:resource] || {}
|
|
293
|
+
return sync_refunds_later(resource) if event[:event_type] == REFUND_EVENT
|
|
294
|
+
|
|
256
295
|
action = WEBHOOK_ACTIONS[event[:event_type]]
|
|
257
296
|
return nil unless action
|
|
258
297
|
|
|
259
|
-
resource = event[:resource] || {}
|
|
260
298
|
paypal_order_id = resource.dig('supplementary_data', 'related_ids', 'order_id')
|
|
261
299
|
return nil unless paypal_order_id
|
|
262
300
|
|
|
@@ -271,6 +309,66 @@ module Spree
|
|
|
271
309
|
|
|
272
310
|
private
|
|
273
311
|
|
|
312
|
+
##
|
|
313
|
+
# Enqueues {SyncRefundsJob} for the payment a PayPal refund is about.
|
|
314
|
+
# The refund only names the payment: the job reads its refunds from
|
|
315
|
+
# PayPal.
|
|
316
|
+
#
|
|
317
|
+
# @param refund [Hash] the event's resource, a PayPal refund
|
|
318
|
+
# @return [NilClass] so Spree enqueues no payment-session action
|
|
319
|
+
#
|
|
320
|
+
def sync_refunds_later(refund)
|
|
321
|
+
payment = payment_for_paypal_refund(refund)
|
|
322
|
+
SyncRefundsJob.set(wait: SyncRefundsJob::WAIT).perform_later(payment_id: payment.id) if payment
|
|
323
|
+
|
|
324
|
+
nil
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
##
|
|
328
|
+
# The payment of this method a PayPal refund is about. The refund links
|
|
329
|
+
# up to its capture, and may name its order. A payment settled on Spree
|
|
330
|
+
# 6 holds the PayPal order id as its response code and the capture id
|
|
331
|
+
# on its metadata; one taken on 5.x holds the capture id as its
|
|
332
|
+
# response code.
|
|
333
|
+
#
|
|
334
|
+
# Response codes are searched first. The metadata is searched only
|
|
335
|
+
# when none matches: its expression has no index, so it reads every
|
|
336
|
+
# payment of the method.
|
|
337
|
+
#
|
|
338
|
+
# @param refund [Hash] a PayPal refund, as JSON
|
|
339
|
+
# @return [Spree::Payment, NilClass]
|
|
340
|
+
#
|
|
341
|
+
def payment_for_paypal_refund(refund)
|
|
342
|
+
related_ids = refund.dig('supplementary_data', 'related_ids') || {}
|
|
343
|
+
capture_id = PaypalMerchant.linked_id(refund['links'], rel: 'up', path: 'v2/payments/captures').presence ||
|
|
344
|
+
related_ids['capture_id'].presence
|
|
345
|
+
response_codes = [related_ids['order_id'].presence, capture_id].compact
|
|
346
|
+
return if response_codes.empty?
|
|
347
|
+
|
|
348
|
+
payments = Spree::Payment.where(payment_method_id: id).order(:id)
|
|
349
|
+
payment = payments.find_by(response_code: response_codes)
|
|
350
|
+
return payment if payment || capture_id.nil?
|
|
351
|
+
|
|
352
|
+
payments.find_by(recorded_capture_id_sql, capture_id)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
##
|
|
356
|
+
# Matches the capture id Spree 6 settlement records on a payment's
|
|
357
|
+
# metadata, which is jsonb on PostgreSQL and json elsewhere. The
|
|
358
|
+
# expressions are the ones Spree's own metadata specs use.
|
|
359
|
+
#
|
|
360
|
+
# @return [String] SQL with one bind for the capture id
|
|
361
|
+
#
|
|
362
|
+
def recorded_capture_id_sql
|
|
363
|
+
column = "#{Spree::Payment.quoted_table_name}.#{Spree::Payment.connection.quote_column_name('metadata')}"
|
|
364
|
+
|
|
365
|
+
if Spree::Payment.connection.adapter_name.match?(/postg/i)
|
|
366
|
+
"#{column}->>'paypal_capture_id' = ?"
|
|
367
|
+
else
|
|
368
|
+
"JSON_EXTRACT(#{column}, '$.paypal_capture_id') = ?"
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
274
372
|
##
|
|
275
373
|
# PayPal captures at checkout. Only this method's own capture method
|
|
276
374
|
# can refuse it: a store-wide default never switches PayPal off.
|
|
@@ -333,19 +431,6 @@ module Spree
|
|
|
333
431
|
fetch_paypal_order(paypal_order_id)
|
|
334
432
|
end
|
|
335
433
|
|
|
336
|
-
##
|
|
337
|
-
# Whether PayPal definitely refused the request. A server error, or a
|
|
338
|
-
# client error in {AMBIGUOUS_CLIENT_ERROR_STATUSES}, leaves the outcome
|
|
339
|
-
# unknown.
|
|
340
|
-
#
|
|
341
|
-
# @param error [PaypalServerSdk::APIException]
|
|
342
|
-
# @return [TrueClass, FalseClass]
|
|
343
|
-
#
|
|
344
|
-
def paypal_refusal?(error)
|
|
345
|
-
status = error.response_code.to_i
|
|
346
|
-
status.between?(400, 499) && AMBIGUOUS_CLIENT_ERROR_STATUSES.exclude?(status)
|
|
347
|
-
end
|
|
348
|
-
|
|
349
434
|
##
|
|
350
435
|
# @param error [PaypalServerSdk::ErrorException]
|
|
351
436
|
# @param issue [String] a PayPal error issue code
|
|
@@ -113,6 +113,36 @@ module Spree
|
|
|
113
113
|
|
|
114
114
|
validates :preferred_client_id, :preferred_client_secret, presence: true
|
|
115
115
|
|
|
116
|
+
##
|
|
117
|
+
# PayPal's mark (a square SVG, as PayPal publishes it), shipped with the
|
|
118
|
+
# gem and read once into a data URI. The dashboard then needs no asset
|
|
119
|
+
# pipeline, and fetches the logo from no other host.
|
|
120
|
+
#
|
|
121
|
+
LOGO_PATH = File.expand_path('../../../../lib/spree/paypal_merchant/paypal-mark.svg', __dir__)
|
|
122
|
+
|
|
123
|
+
##
|
|
124
|
+
# @see LOGO_PATH
|
|
125
|
+
#
|
|
126
|
+
LOGO_DATA_URI = "data:image/svg+xml;base64,#{[File.binread(LOGO_PATH)].pack('m0')}".freeze
|
|
127
|
+
|
|
128
|
+
##
|
|
129
|
+
# The logo on this payment method's card under Settings -> Integrations.
|
|
130
|
+
#
|
|
131
|
+
# @return [String] {LOGO_DATA_URI}
|
|
132
|
+
#
|
|
133
|
+
def self.logo_url
|
|
134
|
+
LOGO_DATA_URI
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
##
|
|
138
|
+
# The setup guide the card links to.
|
|
139
|
+
#
|
|
140
|
+
# @return [String]
|
|
141
|
+
#
|
|
142
|
+
def self.docs_url
|
|
143
|
+
'https://github.com/aypex-io/spree-paypal_merchant#readme'
|
|
144
|
+
end
|
|
145
|
+
|
|
116
146
|
##
|
|
117
147
|
# @return [Class]
|
|
118
148
|
#
|
|
@@ -194,12 +224,19 @@ module Spree
|
|
|
194
224
|
end
|
|
195
225
|
|
|
196
226
|
##
|
|
197
|
-
#
|
|
227
|
+
# The URL to give PayPal for this method's webhooks, on the store's own
|
|
228
|
+
# host. It is absolute, and https in production, as PayPal requires.
|
|
229
|
+
#
|
|
230
|
+
# When the Spree API is served from another host than the store's URL
|
|
231
|
+
# (a headless storefront on its own domain, say), PayPal must be given
|
|
232
|
+
# the API host instead, with the same path.
|
|
233
|
+
#
|
|
234
|
+
# @return [String, NilClass] nil without a store
|
|
198
235
|
#
|
|
199
236
|
def webhook_url
|
|
200
237
|
return nil unless store
|
|
201
238
|
|
|
202
|
-
"#{store.
|
|
239
|
+
"#{store.formatted_url}/api/v3/webhooks/payments/#{prefixed_id}"
|
|
203
240
|
end
|
|
204
241
|
|
|
205
242
|
##
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module PaypalMerchant
|
|
5
|
+
##
|
|
6
|
+
# Records in Spree the refunds PayPal holds for one of the gateway's
|
|
7
|
+
# payments, such as a refund made in PayPal's dashboard, which never passes
|
|
8
|
+
# through Spree.
|
|
9
|
+
#
|
|
10
|
+
# The refunds are read from PayPal, never from the event that prompted the
|
|
11
|
+
# sync: PayPal's order for the payment lists the refunds of its capture.
|
|
12
|
+
# Each COMPLETED one that no Spree::Refund on the payment holds (by its
|
|
13
|
+
# PayPal refund id, as the refund's transaction id) is recorded the way
|
|
14
|
+
# Spree's refund workflow records a refund, with the PayPal refund id
|
|
15
|
+
# already set, so Spree never asks PayPal to refund it again. Running it
|
|
16
|
+
# again records nothing new.
|
|
17
|
+
#
|
|
18
|
+
# Not recorded, and reported (logged as a warning where it needs a look):
|
|
19
|
+
# - a refund already recorded, including one made in Spree;
|
|
20
|
+
# - a refund PayPal has not completed (PENDING, FAILED, CANCELLED);
|
|
21
|
+
# - a refund in another currency than the payment's;
|
|
22
|
+
# - a refund for more than is left to refund on the payment;
|
|
23
|
+
# - every refund, when the store has no refund reason;
|
|
24
|
+
# - every refund, when the payment is not completed, or covers several
|
|
25
|
+
# orders.
|
|
26
|
+
#
|
|
27
|
+
class SyncRefunds
|
|
28
|
+
##
|
|
29
|
+
# A refund made in Spree on the payment has no PayPal refund id yet:
|
|
30
|
+
# Spree has asked PayPal for it and not stored PayPal's answer. The
|
|
31
|
+
# refund PayPal reports may be that one, so nothing is recorded until it
|
|
32
|
+
# settles.
|
|
33
|
+
#
|
|
34
|
+
class RefundInProgress < StandardError; end
|
|
35
|
+
|
|
36
|
+
##
|
|
37
|
+
# PayPal could not be reached, did not answer in time, or answered with
|
|
38
|
+
# an error that can pass. Reading it again later may succeed.
|
|
39
|
+
#
|
|
40
|
+
class PaypalUnavailable < StandardError; end
|
|
41
|
+
|
|
42
|
+
##
|
|
43
|
+
# What a sync did.
|
|
44
|
+
#
|
|
45
|
+
# @!attribute [r] recorded
|
|
46
|
+
# @return [Array<Spree::Refund>] the refunds recorded, oldest first
|
|
47
|
+
# @!attribute [r] skipped
|
|
48
|
+
# @return [Array<String>] why each PayPal refund, or the whole
|
|
49
|
+
# payment, was not recorded
|
|
50
|
+
#
|
|
51
|
+
Result = Data.define(:recorded, :skipped)
|
|
52
|
+
|
|
53
|
+
##
|
|
54
|
+
# The relation a PayPal refund's link to its capture has, and the one a
|
|
55
|
+
# capture's link to its order has.
|
|
56
|
+
#
|
|
57
|
+
UP = 'up'
|
|
58
|
+
|
|
59
|
+
##
|
|
60
|
+
# @param payment [Spree::Payment] a payment of a {Gateway}
|
|
61
|
+
# @raise [ArgumentError] when the payment is not one of a {Gateway}'s
|
|
62
|
+
#
|
|
63
|
+
def initialize(payment:)
|
|
64
|
+
@payment = payment
|
|
65
|
+
@gateway = payment.payment_method
|
|
66
|
+
@skipped = []
|
|
67
|
+
|
|
68
|
+
raise ArgumentError, "payment #{payment.number} is not a PayPal payment" unless gateway.is_a?(Gateway)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
##
|
|
72
|
+
# Reads PayPal, then records what Spree is missing. PayPal is read
|
|
73
|
+
# before the payment is locked, so the lock holds across no PayPal I/O.
|
|
74
|
+
#
|
|
75
|
+
# @return [Result]
|
|
76
|
+
# @raise [RefundInProgress] when a refund made in Spree on the payment
|
|
77
|
+
# has no PayPal refund id yet
|
|
78
|
+
# @raise [PaypalUnavailable] when PayPal cannot be read for now
|
|
79
|
+
# @raise [Spree::Core::GatewayError] when PayPal refuses to show the
|
|
80
|
+
# payment's order or capture
|
|
81
|
+
#
|
|
82
|
+
def call
|
|
83
|
+
return result([]) if payment_not_synced?
|
|
84
|
+
|
|
85
|
+
paypal_refunds = read_paypal_refunds
|
|
86
|
+
recorded = payment.with_lock { record(paypal_refunds) }
|
|
87
|
+
announce(recorded)
|
|
88
|
+
|
|
89
|
+
result(recorded)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
attr_reader :payment, :gateway
|
|
95
|
+
|
|
96
|
+
# Whether the payment is not one to sync, noting why. Asked before
|
|
97
|
+
# PayPal is read, and again once the payment is locked and read afresh.
|
|
98
|
+
def payment_not_synced?
|
|
99
|
+
why_not = why_payment_not_synced
|
|
100
|
+
note(:warn, "PayPal refunds not synced for payment #{payment.number}: #{why_not}") if why_not
|
|
101
|
+
|
|
102
|
+
why_not.present?
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# As Spree's refund workflow: only a completed payment is refunded, and
|
|
106
|
+
# a payment shared by a split checkout needs to be told which order a
|
|
107
|
+
# refund puts right.
|
|
108
|
+
def why_payment_not_synced
|
|
109
|
+
return "it is #{payment.status}, and only a completed payment can be refunded" unless payment.completed?
|
|
110
|
+
|
|
111
|
+
'it covers several orders, so the order each refund is for has to be chosen by hand' if payment.grouped?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Runs inside the payment's lock, which Spree's refund workflow also
|
|
115
|
+
# takes to create a refund. Two syncs for one payment (two partial
|
|
116
|
+
# refunds reported together) take turns, and the second finds what the
|
|
117
|
+
# first recorded. Everything that decides what is recorded is checked
|
|
118
|
+
# here, on the payment as the lock read it: PayPal was read before the
|
|
119
|
+
# lock, and the payment may have been voided, or refunded, meanwhile.
|
|
120
|
+
def record(paypal_refunds)
|
|
121
|
+
return [] if payment_not_synced?
|
|
122
|
+
|
|
123
|
+
ensure_no_refund_in_progress!
|
|
124
|
+
|
|
125
|
+
unrecorded = paypal_refunds.select { |paypal_refund| unrecorded_and_completed?(paypal_refund) }
|
|
126
|
+
return [] if unrecorded.empty?
|
|
127
|
+
|
|
128
|
+
reason = refund_reason
|
|
129
|
+
if reason.nil?
|
|
130
|
+
unrecorded.each { |paypal_refund| skip(paypal_refund, 'the store has no refund reason; add one, then sync again') }
|
|
131
|
+
return []
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
unrecorded.filter_map { |paypal_refund| record_refund(paypal_refund, reason) }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def ensure_no_refund_in_progress!
|
|
138
|
+
return unless payment.refunds.exists?(transaction_id: [nil, ''])
|
|
139
|
+
|
|
140
|
+
raise RefundInProgress, "Payment #{payment.number} has a refund made in Spree that PayPal has not confirmed yet"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# A refund with no PayPal id is never recorded: the id is what keeps it
|
|
144
|
+
# from being recorded twice, and a blank transaction id would read as a
|
|
145
|
+
# refund still in progress.
|
|
146
|
+
def unrecorded_and_completed?(paypal_refund)
|
|
147
|
+
if paypal_refund['id'].blank?
|
|
148
|
+
skip(paypal_refund, 'PayPal gives it no id')
|
|
149
|
+
false
|
|
150
|
+
elsif payment.refunds.exists?(transaction_id: paypal_refund['id'])
|
|
151
|
+
note(:info, "#{describe(paypal_refund)} already recorded on payment #{payment.number}")
|
|
152
|
+
false
|
|
153
|
+
elsif paypal_refund['status'] != 'COMPLETED'
|
|
154
|
+
skip(paypal_refund, "it is #{paypal_refund['status'] || 'without a status'} at PayPal", level: :info)
|
|
155
|
+
false
|
|
156
|
+
else
|
|
157
|
+
true
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Created as Spree's refund workflow creates a refund (its own order,
|
|
162
|
+
# no refunder, no originator), with the PayPal refund id as the
|
|
163
|
+
# transaction id that the workflow would otherwise get from PayPal.
|
|
164
|
+
def record_refund(paypal_refund, reason)
|
|
165
|
+
amount = paypal_refund.dig('amount', 'value').to_d
|
|
166
|
+
currency = paypal_refund.dig('amount', 'currency_code')
|
|
167
|
+
left = payment.credit_allowed
|
|
168
|
+
|
|
169
|
+
return skip(paypal_refund, "the payment is in #{payment.currency}") if currency != payment.currency
|
|
170
|
+
return skip(paypal_refund, 'PayPal shows no amount') unless amount.positive?
|
|
171
|
+
return skip(paypal_refund, "only #{money(left)} is left to refund on the payment") if amount > left
|
|
172
|
+
|
|
173
|
+
payment.refunds.create!(
|
|
174
|
+
amount: amount,
|
|
175
|
+
order: payment.order,
|
|
176
|
+
reason: reason,
|
|
177
|
+
transaction_id: paypal_refund['id'],
|
|
178
|
+
metadata: provenance(paypal_refund)
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# What Spree::Refund#perform! and the refund workflow do once PayPal
|
|
183
|
+
# has made a refund: the order's totals are summed again, and the
|
|
184
|
+
# payment announces the refund. The refund's own created event has
|
|
185
|
+
# already updated the order's payment state.
|
|
186
|
+
def announce(recorded)
|
|
187
|
+
return if recorded.empty?
|
|
188
|
+
|
|
189
|
+
payment.order&.recalculate_totals!
|
|
190
|
+
recorded.each { payment.publish_event('payment.refunded') }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The reason Spree's refund workflow gives a refund named without one
|
|
194
|
+
# ("Return processing"), or else the store's first by name, as Spree's
|
|
195
|
+
# admin API does. Found, never created.
|
|
196
|
+
def refund_reason
|
|
197
|
+
reasons = Spree::RefundReason.where(store: payment.owner&.store || gateway.store)
|
|
198
|
+
|
|
199
|
+
reasons.find_by(name: Spree::RefundReason::RETURN_PROCESSING_REASON) || reasons.first
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def provenance(paypal_refund)
|
|
203
|
+
{
|
|
204
|
+
'recorded_from' => 'paypal',
|
|
205
|
+
'paypal_refund_id' => paypal_refund['id'],
|
|
206
|
+
'paypal_capture_id' => capture_id,
|
|
207
|
+
'paypal_create_time' => paypal_refund['create_time']
|
|
208
|
+
}.compact
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# The refunds of the payment's capture, oldest first. PayPal's order
|
|
212
|
+
# lists them by purchase unit; a refund that names no capture is the
|
|
213
|
+
# capture's, as the gem makes one capture per PayPal order.
|
|
214
|
+
def read_paypal_refunds
|
|
215
|
+
paypal_order = gateway.fetch_paypal_order(paypal_order_id).data.as_json
|
|
216
|
+
|
|
217
|
+
Array(paypal_order['purchase_units'])
|
|
218
|
+
.flat_map { |purchase_unit| Array(purchase_unit.dig('payments', 'refunds')) }
|
|
219
|
+
.select { |paypal_refund| [nil, capture_id].include?(PaypalMerchant.linked_id(paypal_refund['links'], rel: UP, path: 'v2/payments/captures')) }
|
|
220
|
+
.sort_by { |paypal_refund| paypal_refund['create_time'].to_s }
|
|
221
|
+
rescue PaypalServerSdk::APIException => e
|
|
222
|
+
raise PaypalUnavailable, "PayPal answered #{e.response_code}" unless gateway.paypal_refusal?(e)
|
|
223
|
+
|
|
224
|
+
raise Spree::Core::GatewayError, "PayPal refused to show payment #{payment.number} (HTTP #{e.response_code}): #{e.message}"
|
|
225
|
+
rescue *Gateway::UNKNOWN_OUTCOME_ERRORS => e
|
|
226
|
+
raise PaypalUnavailable, "PayPal could not be read: #{e.message}"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# The capture, as {Gateway#credit} finds it: on the metadata of a
|
|
230
|
+
# payment settled on Spree 6, or the response code of one taken on 5.x.
|
|
231
|
+
def capture_id
|
|
232
|
+
@capture_id ||= payment.metadata['paypal_capture_id'].presence || payment.response_code
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# A payment settled on Spree 6 holds the PayPal order id as its response
|
|
236
|
+
# code. One taken on 5.x holds the capture id there instead, and
|
|
237
|
+
# PayPal's capture names its order.
|
|
238
|
+
def paypal_order_id
|
|
239
|
+
return payment.response_code if payment.metadata['paypal_capture_id'].present?
|
|
240
|
+
|
|
241
|
+
paypal_capture = gateway.fetch_paypal_capture(capture_id).data.as_json
|
|
242
|
+
order_id = paypal_capture.dig('supplementary_data', 'related_ids', 'order_id').presence ||
|
|
243
|
+
PaypalMerchant.linked_id(paypal_capture['links'], rel: UP, path: 'v2/checkout/orders')
|
|
244
|
+
return order_id if order_id.present?
|
|
245
|
+
|
|
246
|
+
raise Spree::Core::GatewayError, "PayPal capture #{capture_id} names no PayPal order"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def skip(paypal_refund, why, level: :warn)
|
|
250
|
+
note(level, "#{describe(paypal_refund)} not recorded on payment #{payment.number}: #{why}")
|
|
251
|
+
nil
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def note(level, message)
|
|
255
|
+
@skipped << message
|
|
256
|
+
Rails.logger.public_send(level, "[spree-paypal_merchant] #{message}")
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def describe(paypal_refund)
|
|
260
|
+
amount = "of #{paypal_refund.dig('amount', 'value')} #{paypal_refund.dig('amount', 'currency_code')}"
|
|
261
|
+
|
|
262
|
+
['PayPal refund', paypal_refund['id'].presence, amount].compact.join(' ')
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def money(amount)
|
|
266
|
+
"#{Spree::Money::Rounding.format(amount, payment.currency)} #{payment.currency}"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def result(recorded)
|
|
270
|
+
Result.new(recorded: recorded, skipped: @skipped)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 48 48"><g clip-path="url(#a)"><path fill="#002991" d="M38.914 13.35c0 5.574-5.144 12.15-12.927 12.15H18.49l-.368 2.322L16.373 39H7.056l5.605-36h15.095c5.083 0 9.082 2.833 10.555 6.77a9.687 9.687 0 0 1 .603 3.58z"/><path fill="#60CDFF" d="M44.284 23.7A12.894 12.894 0 0 1 31.53 34.5h-5.206L24.157 48H14.89l1.483-9 1.75-11.178.367-2.322h7.497c7.773 0 12.927-6.576 12.927-12.15 3.825 1.974 6.055 5.963 5.37 10.35z"/><path fill="#008CFF" d="M38.914 13.35C37.31 12.511 35.365 12 33.248 12h-12.64L18.49 25.5h7.497c7.773 0 12.927-6.576 12.927-12.15z"/></g><defs><clipPath id="a"><path fill="#fff" d="M7.056 3h37.35v45H7.056z"/></clipPath></defs></svg>
|
|
@@ -28,5 +28,28 @@ module Spree
|
|
|
28
28
|
def self.gateway_type_names
|
|
29
29
|
([Gateway.name] + Gateway.descendants.map(&:name)).uniq
|
|
30
30
|
end
|
|
31
|
+
|
|
32
|
+
##
|
|
33
|
+
# The id a PayPal resource's link names. PayPal links a refund to its
|
|
34
|
+
# capture, and a capture to its order, with an +up+ link such as
|
|
35
|
+
# +https://api.paypal.com/v2/payments/captures/<id>+.
|
|
36
|
+
#
|
|
37
|
+
# @param links [Array<Hash>, NilClass] the resource's +links+, as JSON
|
|
38
|
+
# @param rel [String] the link's relation, such as +up+
|
|
39
|
+
# @param path [String] what precedes the id, such as +v2/payments/captures+
|
|
40
|
+
# @return [String, NilClass] nil when no such link names an id
|
|
41
|
+
#
|
|
42
|
+
def self.linked_id(links, rel:, path:)
|
|
43
|
+
pattern = %r{/#{Regexp.escape(path)}/([^/?#]+)\z}
|
|
44
|
+
|
|
45
|
+
Array(links).each do |link|
|
|
46
|
+
next unless link.respond_to?(:to_h) && link.to_h.with_indifferent_access[:rel] == rel
|
|
47
|
+
|
|
48
|
+
id = link.to_h.with_indifferent_access[:href].to_s[pattern, 1]
|
|
49
|
+
return id if id.present?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
31
54
|
end
|
|
32
55
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :spree do
|
|
4
|
+
namespace :paypal_merchant do
|
|
5
|
+
desc "Record in Spree the refunds made in PayPal for an order's PayPal payments, e.g. before the webhook was set up"
|
|
6
|
+
task :sync_refunds, [:order_number] => :environment do |_task, args|
|
|
7
|
+
order_number = args[:order_number].to_s.strip
|
|
8
|
+
abort "Usage: bin/rails 'spree:paypal_merchant:sync_refunds[<order number>]'" if order_number.empty?
|
|
9
|
+
|
|
10
|
+
order = Spree::Order.find_by(number: order_number)
|
|
11
|
+
abort "No order #{order_number}" if order.nil?
|
|
12
|
+
|
|
13
|
+
Spree::Current.store = order.store
|
|
14
|
+
payments = order.payments.order(:id).select { |payment| payment.payment_method.is_a?(Spree::PaypalMerchant::Gateway) }
|
|
15
|
+
puts "Order #{order_number} has no PayPal payments." if payments.empty?
|
|
16
|
+
|
|
17
|
+
payments.each do |payment|
|
|
18
|
+
result = Spree::PaypalMerchant::SyncRefunds.new(payment: payment).call
|
|
19
|
+
|
|
20
|
+
puts "Payment #{payment.number}:"
|
|
21
|
+
result.recorded.each { |refund| puts " recorded PayPal refund #{refund.transaction_id} (#{refund.display_amount})" }
|
|
22
|
+
result.skipped.each { |message| puts " #{message}" }
|
|
23
|
+
puts ' no PayPal refunds' if result.recorded.empty? && result.skipped.empty?
|
|
24
|
+
rescue Spree::PaypalMerchant::SyncRefunds::RefundInProgress, Spree::PaypalMerchant::SyncRefunds::PaypalUnavailable,
|
|
25
|
+
Spree::Core::GatewayError => e
|
|
26
|
+
puts "Payment #{payment.number}: not synced: #{e.message}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: spree-paypal_merchant
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 6.0.
|
|
4
|
+
version: 6.0.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Aypex
|
|
@@ -133,6 +133,7 @@ files:
|
|
|
133
133
|
- LICENSE
|
|
134
134
|
- README.md
|
|
135
135
|
- Rakefile
|
|
136
|
+
- app/jobs/spree/paypal_merchant/sync_refunds_job.rb
|
|
136
137
|
- app/models/spree/payment_sessions/paypal_merchant.rb
|
|
137
138
|
- app/models/spree/paypal_merchant/gateway.rb
|
|
138
139
|
- app/models/spree/paypal_merchant/gateway/payment_sessions.rb
|
|
@@ -144,6 +145,7 @@ files:
|
|
|
144
145
|
- app/presenters/spree/paypal_merchant/order_presenter.rb
|
|
145
146
|
- app/serializers/spree/api/v3/payment_method_serializer_decorator.rb
|
|
146
147
|
- app/services/spree/paypal_merchant/create_source.rb
|
|
148
|
+
- app/services/spree/paypal_merchant/sync_refunds.rb
|
|
147
149
|
- config/initializers/spree.rb
|
|
148
150
|
- db/migrate/20250528095719_create_spree_paypal_merchant_orders.rb
|
|
149
151
|
- db/migrate/20261003120000_rename_spree_paypal_platform_to_paypal_merchant.rb
|
|
@@ -152,7 +154,9 @@ files:
|
|
|
152
154
|
- lib/spree/paypal_merchant.rb
|
|
153
155
|
- lib/spree/paypal_merchant/engine.rb
|
|
154
156
|
- lib/spree/paypal_merchant/factories.rb
|
|
157
|
+
- lib/spree/paypal_merchant/paypal-mark.svg
|
|
155
158
|
- lib/spree/paypal_merchant/version.rb
|
|
159
|
+
- lib/tasks/spree_paypal_merchant.rake
|
|
156
160
|
homepage: https://github.com/aypex-io/spree-paypal_merchant
|
|
157
161
|
licenses:
|
|
158
162
|
- MIT
|