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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 593600084e06843e9fd2f9a1de5e5f503efa118d471fa43d23186029a33c39b0
4
- data.tar.gz: 39d6ebce7fde438a61149611af88f3afcf6d913549973a3619428026d5dea7a4
3
+ metadata.gz: d09d026a692438a7e89cd1eb47700bc34fb8ef077da6475c94dd61471e70dfed
4
+ data.tar.gz: fca83a3ef6cfb0bb6e27851d65c0ae0daa35b414506acc25218246bbe0a0c19a
5
5
  SHA512:
6
- metadata.gz: 2039d4bf6952b6b950a3c0d861f05102330d38b815ed82d3257b61caced05d69b0dd7d4b1b438579890d173b5b79c5b482fe4ba4d7ee5ab87bd02471709607ac
7
- data.tar.gz: b145c1842d66afaa2b1fac49237d7dfe1054b043cdb20de68a3f8b2416496545ee87bcd38b42e0f3a827141ad34aefe0ecaa8a62be33573b0f4387d963d47396
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.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 |
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
- # @return [String, NilClass]
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.url_or_custom_domain}/api/v3/webhooks/payments/#{prefixed_id}"
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>
@@ -4,6 +4,6 @@ module Spree
4
4
  module PaypalMerchant
5
5
  # Major version tracks Spree's major version: 6.x supports Spree 6.
6
6
  # 5.2.x supports Spree 5.6 and lives on the `5-2-stable` branch.
7
- VERSION = '6.0.0'
7
+ VERSION = '6.0.1'
8
8
  end
9
9
  end
@@ -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.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