spree-paypal_merchant 5.2.0 → 6.0.0

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