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.
Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +216 -0
  3. data/README.md +163 -3
  4. data/Rakefile +2 -1
  5. data/app/jobs/spree/paypal_merchant/sync_refunds_job.rb +55 -0
  6. data/app/models/spree/payment_sessions/paypal_merchant.rb +137 -39
  7. data/app/models/spree/paypal_merchant/gateway/payment_sessions.rb +373 -78
  8. data/app/models/spree/paypal_merchant/gateway.rb +395 -131
  9. data/app/models/spree/paypal_merchant/payment_sources/apple_pay.rb +1 -1
  10. data/app/models/spree/paypal_merchant/payment_sources/card.rb +1 -1
  11. data/app/models/spree/paypal_merchant/payment_sources/paypal.rb +1 -1
  12. data/app/presenters/spree/paypal_merchant/order_presenter.rb +127 -36
  13. data/app/services/spree/paypal_merchant/create_source.rb +10 -10
  14. data/app/services/spree/paypal_merchant/sync_refunds.rb +274 -0
  15. data/lib/spree/paypal_merchant/factories.rb +8 -17
  16. data/lib/spree/paypal_merchant/paypal-mark.svg +1 -0
  17. data/lib/spree/paypal_merchant/version.rb +3 -2
  18. data/lib/spree/paypal_merchant.rb +26 -3
  19. data/lib/tasks/spree_paypal_merchant.rake +30 -0
  20. metadata +35 -26
  21. data/app/models/spree/paypal_merchant/base.rb +0 -14
  22. data/app/models/spree/paypal_merchant/order.rb +0 -89
  23. data/app/models/spree/paypal_merchant/order_decorator.rb +0 -24
  24. data/app/services/spree/paypal_merchant/capture_order.rb +0 -44
  25. data/app/services/spree/paypal_merchant/create_payment.rb +0 -49
  26. data/app/views/spree/admin/payment_methods/configuration_guides/_spree_paypal_merchant.html.erb +0 -17
  27. data/app/views/spree/admin/payment_methods/descriptions/_spree_paypal_merchant.html.erb +0 -10
  28. data/app/views/spree/admin/payments/source_forms/_spree_paypal_merchant.html.erb +0 -7
  29. 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: ab60916880d1d5bea9ba2921266c7780011afe124111ced6ec568ea290749f3c
4
- data.tar.gz: 05320b99c8cc4f7f077d9e99761618a05a61ef4a730f412094b4fd705f055469
3
+ metadata.gz: d09d026a692438a7e89cd1eb47700bc34fb8ef077da6475c94dd61471e70dfed
4
+ data.tar.gz: fca83a3ef6cfb0bb6e27851d65c0ae0daa35b414506acc25218246bbe0a0c19a
5
5
  SHA512:
6
- metadata.gz: dd1331f42e0599a7ab58d7271a9f7d85b3d37375c3e5e8b7e5004a72953cff2b6f34ff4c7373c9479d06b442659acc2857c80858479fa21ad6491ad5ed4da2eb
7
- data.tar.gz: 38b28e6801bdb7e982cd286a547739a8b44605f6a1a1e524f8680df647a7cf2f955c7b34d247cb11e81b0ccc78d03fcac2cf89091cd5024e51defb660e51f18c
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 5.6+, as a single payment method covering the PayPal
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
- Rake::Task['extension:test_app'].execute(install_storefront: true, install_admin: true)
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