spree_stripe 1.7.1 → 1.8.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/spree_stripe/confirm_payments_controller.rb +52 -0
  3. data/app/helpers/spree_stripe/base_helper.rb +24 -2
  4. data/app/javascript/spree_stripe/controllers/stripe_button_controller.js +256 -338
  5. data/app/javascript/spree_stripe/controllers/stripe_controller.js +129 -206
  6. data/app/models/spree/payment_sessions/stripe.rb +1 -2
  7. data/app/models/spree_stripe/gateway/payment_intents.rb +0 -4
  8. data/app/models/spree_stripe/gateway/payment_sessions.rb +1 -1
  9. data/app/models/spree_stripe/order_decorator.rb +0 -27
  10. data/app/models/spree_stripe/shipment_decorator.rb +3 -3
  11. data/app/services/spree_stripe/create_payment_session.rb +18 -0
  12. data/app/services/spree_stripe/webhook_handlers/payment_intent_payment_failed.rb +4 -15
  13. data/app/services/spree_stripe/webhook_handlers/payment_intent_succeeded.rb +1 -7
  14. data/app/views/spree/checkout/payment/_spree_stripe.html.erb +58 -58
  15. data/app/views/spree_stripe/_quick_checkout.html.erb +9 -15
  16. data/config/locales/en.yml +1 -8
  17. data/config/routes.rb +3 -22
  18. data/db/migrate/20250310152812_setup_spree_stripe_models.rb +0 -17
  19. data/lib/spree_stripe/configuration.rb +0 -1
  20. data/lib/spree_stripe/engine.rb +0 -7
  21. data/lib/spree_stripe/version.rb +1 -1
  22. metadata +5 -44
  23. data/app/controllers/spree_stripe/payment_intents_controller.rb +0 -61
  24. data/app/jobs/spree_stripe/complete_order_job.rb +0 -8
  25. data/app/models/spree_stripe/payment_intent.rb +0 -81
  26. data/app/services/spree_stripe/create_payment_intent.rb +0 -40
  27. data/app/services/spree_stripe/create_setup_intent.rb +0 -20
  28. data/lib/spree_api_v2/spree/api/v2/platform/affirm_serializer.rb +0 -9
  29. data/lib/spree_api_v2/spree/api/v2/platform/after_pay_serializer.rb +0 -9
  30. data/lib/spree_api_v2/spree/api/v2/platform/alipay_serializer.rb +0 -9
  31. data/lib/spree_api_v2/spree/api/v2/platform/ideal_serializer.rb +0 -9
  32. data/lib/spree_api_v2/spree/api/v2/platform/klarna_serializer.rb +0 -9
  33. data/lib/spree_api_v2/spree/api/v2/platform/link_serializer.rb +0 -9
  34. data/lib/spree_api_v2/spree/api/v2/platform/przelewy24_serializer.rb +0 -9
  35. data/lib/spree_api_v2/spree/api/v2/platform/sepa_debit_serializer.rb +0 -9
  36. data/lib/spree_api_v2/spree/api/v2/storefront/stripe/base_controller.rb +0 -21
  37. data/lib/spree_api_v2/spree/api/v2/storefront/stripe/payment_intents_controller.rb +0 -89
  38. data/lib/spree_api_v2/spree/api/v2/storefront/stripe/setup_intents_controller.rb +0 -19
  39. data/lib/spree_api_v2/spree/v2/storefront/affirm_serializer.rb +0 -7
  40. data/lib/spree_api_v2/spree/v2/storefront/after_pay_serializer.rb +0 -7
  41. data/lib/spree_api_v2/spree/v2/storefront/alipay_serializer.rb +0 -7
  42. data/lib/spree_api_v2/spree/v2/storefront/ideal_serializer.rb +0 -7
  43. data/lib/spree_api_v2/spree/v2/storefront/klarna_serializer.rb +0 -7
  44. data/lib/spree_api_v2/spree/v2/storefront/link_serializer.rb +0 -7
  45. data/lib/spree_api_v2/spree/v2/storefront/payment_intent_serializer.rb +0 -15
  46. data/lib/spree_api_v2/spree/v2/storefront/przelewy24_serializer.rb +0 -7
  47. data/lib/spree_api_v2/spree/v2/storefront/sepa_debit_serializer.rb +0 -7
  48. data/lib/spree_api_v2/spree_stripe/v2/storefront/payment_method_serializer_decorator.rb +0 -17
  49. data/lib/spree_stripe/testing_support/factories/payment_intent_factory.rb +0 -24
@@ -2,39 +2,43 @@ import { Controller } from '@hotwired/stimulus'
2
2
  import { loadStripe } from '@stripe/stripe-js/pure'
3
3
  import showFlashMessage from 'spree/storefront/helpers/show_flash_message'
4
4
 
5
+ // Stripe zero-decimal currencies — amount is already in the smallest unit.
6
+ // https://docs.stripe.com/currencies#zero-decimal
7
+ const ZERO_DECIMAL_CURRENCIES = new Set([
8
+ 'bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg',
9
+ 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf'
10
+ ])
11
+
12
+ // Apple Pay / Google Pay express checkout on the v3 Store API. The wallet sheet
13
+ // drives address + shipping selection against `/api/v3/store/carts`, then a
14
+ // payment session is created and confirmed; completion is handled server-side by
15
+ // ConfirmPaymentsController via the `return_url` (same as the regular flow).
5
16
  export default class extends Controller {
6
17
  static values = {
7
18
  apiKey: String,
8
- orderToken: String,
9
- clientSecret: String,
19
+ spreeApiKey: String,
20
+ paymentMethodId: String,
21
+ cartId: String,
22
+ cartToken: String,
23
+ confirmPaymentUrl: String,
10
24
  currency: String,
11
25
  amount: Number,
12
- availableCountries: Array,
13
- giftCardCode: String,
14
- giftCardAmount: Number,
15
26
  borderRadius: Number,
16
27
  height: Number,
17
28
  theme: String,
18
29
  maxRows: Number,
19
30
  maxColumns: Number,
20
31
  buttonWidth: Number,
21
- checkoutPath: String,
22
- checkoutAdvancePath: String,
23
- checkoutSelectShippingMethodPath: String,
24
- checkoutValidateGiftCardDataPath: String,
25
- checkoutValidateOrderForPaymentPath: String,
26
32
  shippingRequired: { type: Boolean, default: true },
27
- returnUrl: String,
28
- phoneRequired: { type: Boolean, default: false },
33
+ phoneRequired: { type: Boolean, default: false }
29
34
  }
30
35
 
31
36
  static targets = ['container']
32
37
 
33
38
  connect() {
34
- this.paymentMethod = null
39
+ this.isGooglePay = false
40
+ this.shippingRateMap = new Map()
35
41
  this.initStripe()
36
- this.shippingRates = []
37
- this.currentShippingOptionId = null
38
42
  }
39
43
 
40
44
  initStripe() {
@@ -63,8 +67,7 @@ export default class extends Controller {
63
67
  paymentMethodCreation: 'manual'
64
68
  })
65
69
 
66
- const prButton = this.elements.create('expressCheckout', {
67
- wallets: { applePay: 'always', googlePay: 'always' },
70
+ const expressCheckout = this.elements.create('expressCheckout', {
68
71
  buttonHeight: this.heightValue > 0 ? this.heightValue : undefined,
69
72
  buttonTheme: {
70
73
  applePay: this.themeValue.length ? this.themeValue : undefined,
@@ -81,400 +84,315 @@ export default class extends Controller {
81
84
  },
82
85
  paymentMethodOrder: ['applePay', 'googlePay', 'link']
83
86
  })
84
- prButton.mount('#payment-request-button')
87
+ expressCheckout.mount('#payment-request-button')
85
88
 
86
- prButton.on('ready', ({ availablePaymentMethods }) => {
87
- if (!availablePaymentMethods) {
88
- this.containerTarget.style.display = 'hidden'
89
- return
90
- }
91
- const availableMethodsCount = Object.keys(availablePaymentMethods).filter(
92
- (key) => availablePaymentMethods[key]
93
- ).length
94
- if (this.buttonWidthValue > 0) {
95
- this.containerTarget.style.setProperty(
96
- '--desktop-max-width',
97
- this.buttonWidthValue * availableMethodsCount + 'px'
98
- )
99
- this.containerTarget.classList.add('desktop-max-width')
100
- }
101
- window.parent?.postMessage({ enabledPaymentMethodsCount: availableMethodsCount }, '*')
102
- })
103
- prButton.on('click', (event) => {
104
- this.paymentMethod = event.expressPaymentType
105
-
106
- event.resolve({
107
- emailRequired: true,
108
- phoneNumberRequired: this.phoneRequiredValue,
109
- shippingAddressRequired: this.shippingRequiredValue,
110
- allowedShippingCountries: this.availableCountriesValue,
111
- // If we want to collect shipping address then we need to provide at least one shipping option, it will be updated to the real ones in the `shippingaddresschange` event
112
- shippingRates: this.shippingRequiredValue ? [{ id: 'loading', displayName: 'Loading...', amount: 0 }] : [],
113
- lineItems: [
114
- { name: 'Subtotal', amount: 0 },
115
- { name: 'Shipping', amount: 0 },
116
- { name: 'Store credit', amount: 0 },
117
- { name: 'Discount', amount: 0 },
118
- { name: 'Tax', amount: 0 }
119
- ]
120
- })
121
- })
89
+ expressCheckout.on('ready', this.handleReady.bind(this))
90
+ expressCheckout.on('click', this.handleClick.bind(this))
122
91
  if (this.shippingRequiredValue) {
123
- prButton.on('shippingaddresschange', this.handleAddressChange.bind(this))
124
- prButton.on('shippingratechange', this.handleShippingOptionChange.bind(this))
92
+ expressCheckout.on('shippingaddresschange', this.handleAddressChange.bind(this))
93
+ expressCheckout.on('shippingratechange', this.handleShippingOptionChange.bind(this))
125
94
  }
126
- prButton.on('confirm', this.handleFinalizePayment.bind(this))
127
- prButton.on('cancel', this.handleCancelPayment.bind(this))
95
+ expressCheckout.on('confirm', this.handleFinalizePayment.bind(this))
128
96
  }
129
97
 
130
- async handleAddressChange(ev) {
131
- // Perform server-side request to fetch shipping options
132
- // https://stripe.com/docs/js/payment_request/events/on_shipping_address_change#payment_request_on_shipping_address_change-handler-shippingAddress
133
- const orderUpdatePayload = {
134
- order: {
135
- ship_address_attributes: {
136
- // we need to use quick checkout option to skip first/last/street address validation
137
- // as at this point we don't receive this information as browsers do not share it with us
138
- quick_checkout: true,
139
- firstname: ev.name.split(' ')[0],
140
- lastname: ev.name.split(' ')[1],
141
- city: ev.address.city,
142
- zipcode: ev.address.postal_code,
143
- country_iso: ev.address.country,
144
- state_name: ev.address.state
145
- },
146
- bill_address_id: 'CLEAR' // we need to clear out the bill address to avoid order being pushed to confirm/complete state
147
- }
98
+ handleReady({ availablePaymentMethods }) {
99
+ if (!availablePaymentMethods) {
100
+ this.containerTarget.style.display = 'none'
101
+ return
102
+ }
103
+ const availableMethodsCount = Object.keys(availablePaymentMethods).filter(
104
+ (key) => availablePaymentMethods[key]
105
+ ).length
106
+ if (this.buttonWidthValue > 0) {
107
+ this.containerTarget.style.setProperty('--desktop-max-width', this.buttonWidthValue * availableMethodsCount + 'px')
108
+ this.containerTarget.classList.add('desktop-max-width')
148
109
  }
110
+ window.parent?.postMessage({ enabledPaymentMethodsCount: availableMethodsCount }, '*')
111
+ }
149
112
 
150
- // 1st we need to persist the address to the order
151
- const saveAddressResponse = await fetch(this.checkoutPathValue, {
152
- method: 'PATCH',
153
- headers: {
154
- 'X-Spree-Order-Token': this.orderTokenValue,
155
- 'Content-Type': 'application/json'
156
- },
157
- body: JSON.stringify(orderUpdatePayload)
113
+ handleClick(event) {
114
+ this.isGooglePay = event.expressPaymentType === 'google_pay'
115
+ event.resolve({
116
+ emailRequired: true,
117
+ phoneNumberRequired: this.phoneRequiredValue,
118
+ shippingAddressRequired: this.shippingRequiredValue,
119
+ lineItems: [{ name: 'Subtotal', amount: parseInt(this.amountValue) }]
158
120
  })
159
-
160
- // 2nd we need to push the order to delivery state to generate shipping rates
161
- if (saveAddressResponse.status === 200) {
162
- // In case of any error here we have to allow user try again
163
- try {
164
- const response = await fetch(
165
- this.checkoutAdvancePathValue,
166
- {
167
- method: 'PATCH',
168
- headers: {
169
- 'X-Spree-Order-Token': this.orderTokenValue,
170
- 'Content-Type': 'application/json'
171
- },
172
- body: JSON.stringify({
173
- state: 'delivery',
174
- include: 'shipments.shipping_rates',
175
- quick_checkout: true,
176
- shipping_method_id: this.currentShippingOptionId
177
- })
178
- }
179
- )
180
- const newOrderResponse = await response.json()
181
- this.shippingRates = newOrderResponse.included.filter((item) => item.type === 'shipping_rate')
182
-
183
- if (this.shippingRates.length > 0) {
184
- this.elements.update({ amount: newOrderResponse.data.attributes.total_minus_store_credits_cents })
185
- const shippingRates = this.shippingOptions(this.shippingRates)
186
- // We need to select first shipping rate as default, because Apple Pay sometimes doesn't trigger `shippingratechange` event when the modal is opened
187
- this.currentShippingOptionId = String(shippingRates[0].id)
188
-
189
- ev.resolve({
190
- shippingRates: shippingRates,
191
- lineItems: this.buildLineItems(newOrderResponse)
192
- })
193
- return
194
- }
195
- } catch (error) {
196
- ev.reject()
197
- return
198
- }
199
- }
200
- ev.reject()
201
121
  }
202
122
 
203
- async handleShippingOptionChange(ev) {
204
- const { resolve, shippingRate, reject } = ev
123
+ async handleAddressChange(event) {
124
+ try {
125
+ const { address } = event
126
+ // Stripe only shares city/zip/country/state at this stage; pad the rest
127
+ // with placeholders and quick_checkout to skip street-level validation.
128
+ const cart = await this.patchCart({
129
+ shipping_address: {
130
+ first_name: 'Express',
131
+ last_name: 'Checkout',
132
+ address1: 'TBD',
133
+ city: address.city,
134
+ postal_code: address.postal_code,
135
+ country_iso: address.country,
136
+ state_name: address.state || undefined,
137
+ quick_checkout: true
138
+ }
139
+ })
140
+ if (!cart) return event.reject()
205
141
 
206
- if (shippingRate) {
207
- const shippingRateId = String(shippingRate.id).replace(/_google_pay_\d+/, '')
142
+ const { shippingRates, selectionMap } = this.buildShippingRates(cart.fulfillments || [])
143
+ this.shippingRateMap = selectionMap
208
144
 
209
- if (shippingRateId === 'standard') return resolve()
210
- if (shippingRateId === 'loading') return reject()
145
+ if (shippingRates.length === 0) return event.reject()
211
146
 
212
- this.currentShippingOptionId = shippingRateId
147
+ const lineItems = this.buildLineItems(cart)
148
+ lineItems.push({ name: 'Shipping', amount: shippingRates[0].amount })
213
149
 
214
- const response = await fetch(this.checkoutSelectShippingMethodPathValue, {
215
- method: 'PATCH',
216
- headers: {
217
- 'X-Spree-Order-Token': this.orderTokenValue,
218
- 'Content-Type': 'application/json'
219
- },
220
- body: JSON.stringify({ shipping_method_id: shippingRateId })
221
- })
150
+ // Amount must be updated before resolve — Stripe requires amount >= sum(lineItems).
151
+ this.elements.update({ amount: this.sumLineItems(lineItems) })
152
+ event.resolve({ shippingRates, lineItems })
153
+ } catch (_e) {
154
+ try { event.reject() } catch (_) { /* already resolved */ }
155
+ }
156
+ }
222
157
 
223
- if (response.status === 200) {
224
- const newOrderResponse = await response.json()
158
+ async handleShippingOptionChange(event) {
159
+ try {
160
+ const selections = this.shippingRateMap.get(event.shippingRate.id)
161
+ if (!selections || selections.length === 0) return event.reject()
225
162
 
226
- this.elements.update({ amount: newOrderResponse.data.attributes.total_minus_store_credits_cents })
227
- resolve({ lineItems: this.buildLineItems(newOrderResponse) })
228
- } else {
229
- reject()
163
+ let cart = null
164
+ for (const { fulfillmentId, rateId } of selections) {
165
+ cart = await this.patchFulfillment(fulfillmentId, rateId)
166
+ if (!cart) return event.reject()
230
167
  }
231
- } else {
232
- reject()
168
+
169
+ const lineItems = this.buildLineItems(cart)
170
+ lineItems.push({ name: 'Shipping', amount: event.shippingRate.amount })
171
+
172
+ this.elements.update({ amount: this.sumLineItems(lineItems) })
173
+ event.resolve({ lineItems })
174
+ } catch (_e) {
175
+ try { event.reject() } catch (_) { /* already resolved */ }
233
176
  }
234
177
  }
235
178
 
236
- async handleFinalizePayment(ev) {
237
- let shippingRateId = (ev.shippingRate?.id || this.currentShippingOptionId)
238
- if (shippingRateId) {
239
- shippingRateId = String(shippingRateId).replace(/_google_pay_\d+/, '')
179
+ async handleFinalizePayment(event) {
180
+ if (!this.stripe || !this.elements) {
181
+ event.paymentFailed({ reason: 'fail' })
182
+ return
240
183
  }
241
184
 
242
- if (this.shippingRequiredValue && (!shippingRateId || shippingRateId === 'loading')) {
243
- ev.paymentFailed({ reason: 'invalid_shipping_address' })
185
+ const billing = event.billingDetails
186
+ const shipping = event.shippingAddress
187
+ const shipAddress = shipping?.address || billing?.address
188
+ const billAddress = billing?.address || shipping?.address
189
+
190
+ if (!shipAddress || !billAddress) {
191
+ event.paymentFailed({ reason: 'invalid_shipping_address' })
244
192
  return
245
193
  }
246
194
 
247
- if (this.giftCardCodeValue && this.giftCardAmountValue && this.checkoutValidateGiftCardDataPathValue) {
248
- const giftCardValidationResponse = await fetch(
249
- this.checkoutValidateGiftCardDataPathValue,
250
- {
251
- method: 'POST',
252
- headers: {
253
- 'X-Spree-Order-Token': this.orderTokenValue,
254
- 'Content-Type': 'application/json'
255
- },
256
- body: JSON.stringify({
257
- gift_card_code: this.giftCardCodeValue,
258
- gift_card_amount: this.giftCardAmountValue
259
- })
260
- }
261
- )
262
- if (giftCardValidationResponse.status === 422) {
263
- ev.paymentFailed()
264
- return
265
- }
266
- }
195
+ const phone = billing?.phone
196
+ const shipName = this.parseName(shipping?.name || billing?.name || '')
197
+ const billName = this.parseName(billing?.name || shipping?.name || '')
267
198
 
268
- const validationResponse = await fetch(
269
- this.checkoutValidateOrderForPaymentPathValue,
270
- {
271
- method: 'POST',
272
- headers: {
273
- 'X-Spree-Order-Token': this.orderTokenValue,
274
- 'Content-Type': 'application/json'
275
- },
276
- body: JSON.stringify({ skip_state: true })
277
- }
278
- )
199
+ // Persist the final email + addresses before creating the payment session.
200
+ const cart = await this.patchCart({
201
+ email: billing?.email,
202
+ shipping_address: this.buildAddress(shipName, shipAddress, phone),
203
+ billing_address: this.buildAddress(billName, billAddress, phone)
204
+ })
205
+ if (!cart) {
206
+ event.paymentFailed({ reason: 'invalid_shipping_address' })
207
+ return
208
+ }
279
209
 
280
- if (validationResponse.status === 422) {
281
- ev.paymentFailed()
210
+ // The wallet can keep stale rates and an enabled Pay button after a rejected
211
+ // address change, so re-check the confirmed address is serviceable before
212
+ // charging — otherwise the payment confirms but the order can't complete.
213
+ if (this.shippingRequiredValue && !this.hasAvailableShipping(cart)) {
214
+ event.paymentFailed({ reason: 'address_unserviceable' })
282
215
  return
283
216
  }
284
217
 
285
- // Confirm the PaymentIntent without handling potential next actions (yet).
286
- const { error: confirmError } = await this.elements.submit()
218
+ const { error: submitError } = await this.elements.submit()
219
+ if (submitError) {
220
+ event.paymentFailed({ reason: 'fail' })
221
+ this.handleError(submitError)
222
+ return
223
+ }
287
224
 
288
- if (confirmError) {
289
- if (confirmError.length > 0) {
290
- showFlashMessage(confirmError, 'error')
291
- }
225
+ const { error: pmError, paymentMethod } = await this.stripe.createPaymentMethod({ elements: this.elements })
226
+ if (pmError || !paymentMethod) {
227
+ event.paymentFailed({ reason: 'invalid_payment_data' })
228
+ this.handleError(pmError)
292
229
  return
293
230
  }
294
231
 
295
- // we need to persist some information about the customer to move the order to the next state
296
- const orderUpdatePayload = {
297
- order: {
298
- email: ev.billingDetails.email,
299
- ship_address_attributes: this.ShipAddressAttributes(ev)
300
- },
301
- do_not_change_state: true
232
+ const session = await this.createPaymentSession(paymentMethod.id)
233
+ const clientSecret = session?.external_data?.client_secret
234
+ if (!clientSecret) {
235
+ event.paymentFailed({ reason: 'fail' })
236
+ return
302
237
  }
303
238
 
304
- const updateResponse = await fetch(this.checkoutPathValue, {
305
- method: 'PATCH',
306
- headers: {
307
- 'X-Spree-Order-Token': this.orderTokenValue,
308
- 'Content-Type': 'application/json'
239
+ const returnUrl = `${this.confirmPaymentUrlValue}?session=${session.id}`
240
+ const { error: confirmError } = await this.stripe.confirmPayment({
241
+ clientSecret,
242
+ confirmParams: {
243
+ payment_method: paymentMethod.id,
244
+ return_url: returnUrl
309
245
  },
310
- body: JSON.stringify(orderUpdatePayload)
246
+ redirect: 'if_required'
311
247
  })
312
248
 
313
- if (updateResponse.status !== 200) {
314
- ev.paymentFailed()
249
+ if (confirmError) {
250
+ event.paymentFailed({ reason: 'fail' })
251
+ this.handleError(confirmError)
315
252
  return
316
253
  }
317
254
 
318
- const advanceResponse = await fetch(this.checkoutAdvancePathValue, {
255
+ // Payment confirmed let the server finalize the session + cart.
256
+ window.location.href = returnUrl
257
+ }
258
+
259
+ // --- v3 Store API calls -------------------------------------------------
260
+
261
+ async patchCart(body) {
262
+ const response = await fetch(this.cartApiBase, {
319
263
  method: 'PATCH',
320
- headers: {
321
- 'X-Spree-Order-Token': this.orderTokenValue,
322
- 'Content-Type': 'application/json'
323
- },
264
+ headers: this.spreeApiHeaders,
265
+ body: JSON.stringify(body)
266
+ })
267
+ return response.ok ? response.json() : null
268
+ }
269
+
270
+ async patchFulfillment(fulfillmentId, deliveryRateId) {
271
+ const response = await fetch(`${this.cartApiBase}/fulfillments/${fulfillmentId}`, {
272
+ method: 'PATCH',
273
+ headers: this.spreeApiHeaders,
274
+ body: JSON.stringify({ selected_delivery_rate_id: deliveryRateId })
275
+ })
276
+ return response.ok ? response.json() : null
277
+ }
278
+
279
+ async createPaymentSession(stripePaymentMethodId) {
280
+ const response = await fetch(`${this.cartApiBase}/payment_sessions`, {
281
+ method: 'POST',
282
+ headers: this.spreeApiHeaders,
324
283
  body: JSON.stringify({
325
- state: 'payment',
326
- shipping_method_id: shippingRateId
284
+ payment_method_id: this.paymentMethodIdValue,
285
+ external_data: { stripe_payment_method_id: stripePaymentMethodId }
327
286
  })
328
287
  })
288
+ return response.ok ? response.json() : null
289
+ }
329
290
 
330
- if (advanceResponse.status !== 200) {
331
- ev.paymentFailed()
332
- return
333
- }
291
+ // --- helpers ------------------------------------------------------------
334
292
 
335
- try {
336
- const { error: paymentMethodError, paymentMethod } = await this.stripe.createPaymentMethod({
337
- elements: this.elements
338
- })
293
+ // Line items for the wallet sheet. Shipping is added separately by the
294
+ // address/rate handlers (Stripe owns shipping via shippingRates).
295
+ buildLineItems(cart) {
296
+ const items = [{ name: 'Subtotal', amount: this.toCents(cart.item_total) }]
339
297
 
340
- if (paymentMethodError) {
341
- showFlashMessage(paymentMethodError, 'error')
342
- return
343
- }
298
+ const discount = this.toCents(cart.discount_total)
299
+ if (discount < 0) items.push({ name: 'Discount', amount: discount })
344
300
 
345
- const { error } = await this.stripe.confirmPayment({
346
- clientSecret: this.clientSecretValue,
347
- confirmParams: {
348
- payment_method: paymentMethod.id,
349
- // Stripe will automatically add `payment_intent` and `payment_intent_client_secret` params
350
- return_url: this.returnUrlValue
351
- }
352
- })
353
- if (error?.length > 0) {
354
- showFlashMessage(error, 'error')
355
- }
356
- } catch (e) {
357
- console.log(e)
358
- }
359
- }
301
+ const tax = this.toCents(cart.additional_tax_total)
302
+ if (tax > 0) items.push({ name: 'Tax', amount: tax })
360
303
 
361
- async handleCancelPayment(ev) {
362
- const orderUpdatePayload = {
363
- order: {
364
- ship_address_id: 'CLEAR',
365
- bill_address_id: 'CLEAR'
366
- }
367
- }
304
+ return items
305
+ }
368
306
 
369
- // reset shipping method for some cases when user select paid shipping method then reload page
370
- // do not reset shipping method if we have only one or is multivendor order
371
- let defaultShippingMethodId = this.shippingRates[0]?.attributes?.shipping_method_id
372
- if (defaultShippingMethodId) {
373
- defaultShippingMethodId = String(defaultShippingMethodId)
374
- }
307
+ // Builds deduped Stripe shipping rates (by delivery_method_id) and a map from
308
+ // each Stripe rate id to the per-fulfillment delivery rate ids to select.
309
+ buildShippingRates(fulfillments) {
310
+ const rateMap = new Map()
311
+ const selectionMap = new Map()
312
+
313
+ for (const fulfillment of fulfillments) {
314
+ for (const rate of fulfillment.delivery_rates || []) {
315
+ const methodId = rate.delivery_method_id
316
+
317
+ if (!rateMap.has(methodId)) {
318
+ // Google Pay rejects duplicate rate ids across address changes, so
319
+ // give it a unique suffix.
320
+ const id = this.isGooglePay ? `${methodId}-${this.randomSuffix()}` : String(methodId)
321
+ rateMap.set(methodId, { id, displayName: rate.name, amount: this.toCents(rate.cost) })
322
+ selectionMap.set(id, [])
323
+ } else {
324
+ rateMap.get(methodId).amount += this.toCents(rate.cost)
325
+ }
375
326
 
376
- if (this.shippingRates?.length > 1 && this.currentShippingOptionId !== defaultShippingMethodId) {
377
- // reset shipping choice
378
- await fetch(this.checkoutSelectShippingMethodPathValue, {
379
- method: 'PATCH',
380
- headers: {
381
- 'X-Spree-Order-Token': this.orderTokenValue,
382
- 'Content-Type': 'application/json'
383
- },
384
- body: JSON.stringify({ shipping_method_id: defaultShippingMethodId })
385
- })
327
+ const stripeId = rateMap.get(methodId).id
328
+ selectionMap.get(stripeId).push({ fulfillmentId: fulfillment.id, rateId: rate.id })
329
+ }
386
330
  }
387
331
 
388
- // reset addresses
389
- await fetch(this.checkoutPathValue, {
390
- method: 'PATCH',
391
- headers: {
392
- 'X-Spree-Order-Token': this.orderTokenValue,
393
- 'Content-Type': 'application/json'
394
- },
395
- body: JSON.stringify(orderUpdatePayload)
396
- })
397
-
398
- this.currentShippingOptionId = null
332
+ return { shippingRates: Array.from(rateMap.values()), selectionMap }
399
333
  }
400
334
 
401
- shippingOptions(shippingRates) {
402
- return shippingRates.map((rate) => {
403
- let id = String(rate.attributes.shipping_method_id)
404
- if (this.paymentMethod === 'google_pay') {
405
- // We need to add some random data to the shipping rate to avoid weird issue with Google Pay, in which it clears the shipping rates when a new address is added
406
- id += `_google_pay_${Math.floor(Math.random() * 100)}`
407
- }
408
- return {
409
- id: id, // shipping rates can be refreshed and removed, shipping methods are more reliable
410
- displayName: rate.attributes.name,
411
- deliveryEstimate: rate.attributes.display_delivery_range || '',
412
- amount: parseInt(rate.attributes.final_price_cents)
413
- }
414
- })
335
+ // True when every shippable fulfillment has at least one delivery rate — i.e.
336
+ // the confirmed address is serviceable. Digital fulfillments need no shipping.
337
+ hasAvailableShipping(cart) {
338
+ const shippable = (cart.fulfillments || []).filter((f) => f.fulfillment_type !== 'digital')
339
+ return shippable.length > 0 && shippable.every((f) => (f.delivery_rates || []).length > 0)
415
340
  }
416
341
 
417
- multiVendorOrder(newOrderResponse) {
418
- const vendorIds = newOrderResponse.included.filter((item) => item.type === 'vendor').map((vendor) => vendor.id)
342
+ buildAddress(name, address, phone) {
343
+ return {
344
+ first_name: name.firstName,
345
+ last_name: name.lastName,
346
+ address1: address.line1,
347
+ address2: address.line2 || undefined,
348
+ city: address.city,
349
+ postal_code: address.postal_code,
350
+ country_iso: address.country,
351
+ state_name: address.state || undefined,
352
+ phone: phone || undefined,
353
+ quick_checkout: true
354
+ }
355
+ }
419
356
 
420
- return [...new Set(vendorIds)].length > 1
357
+ parseName(name) {
358
+ const parts = name.trim().split(/\s+/)
359
+ if (parts.length <= 1) return { firstName: parts[0] || '', lastName: '' }
360
+ return { firstName: parts.slice(0, -1).join(' '), lastName: parts[parts.length - 1] }
421
361
  }
422
362
 
423
- buildLineItems(newOrderResponse) {
424
- return [
425
- { name: 'Subtotal', amount: newOrderResponse.data.attributes.subtotal_cents },
426
- { name: 'Shipping', amount: newOrderResponse.data.attributes.ship_total_cents },
427
- this.buildStoreCreditLine(newOrderResponse),
428
- this.buildDiscountLine(newOrderResponse),
429
- this.buildTaxLine(newOrderResponse)
430
- ].filter((i) => i)
363
+ toCents(amount) {
364
+ const n = Number(amount)
365
+ if (!Number.isFinite(n)) return 0
366
+ return ZERO_DECIMAL_CURRENCIES.has(this.currencyValue.toLowerCase()) ? Math.round(n) : Math.round(n * 100)
431
367
  }
432
368
 
433
- buildStoreCreditLine(newOrderResponse) {
434
- const amount = newOrderResponse.data.attributes.store_credit_total_cents
369
+ sumLineItems(lineItems) {
370
+ return lineItems.reduce((sum, item) => sum + item.amount, 0)
371
+ }
435
372
 
436
- if (amount > 0) {
437
- return { name: 'Store credit', amount: -amount }
438
- }
373
+ randomSuffix() {
374
+ return Math.random().toString(36).slice(2, 6)
439
375
  }
440
376
 
441
- buildDiscountLine(newOrderResponse) {
442
- const amount = newOrderResponse.data.attributes.promo_total_cents
377
+ handleError(error) {
378
+ if (!error) return
443
379
 
444
- if (amount > 0) {
445
- return { name: 'Discount', amount: -amount }
380
+ if (error.type === 'card_error' || error.type === 'validation_error') {
381
+ showFlashMessage(error.message, 'error')
382
+ } else {
383
+ showFlashMessage('An unexpected error occurred. Please refresh the page and try again.', 'error')
446
384
  }
447
385
  }
448
386
 
449
- buildTaxLine(newOrderResponse) {
450
- const attributes = newOrderResponse.data.attributes
451
- const isTaxIncluded = parseInt(attributes.included_tax_total) > 0
452
- if (isTaxIncluded) return null
453
-
454
- const amount = attributes.tax_total_cents
455
-
456
- return { name: 'Tax', amount: amount }
387
+ get cartApiBase() {
388
+ return `/api/v3/store/carts/${this.cartIdValue}`
457
389
  }
458
390
 
459
- ShipAddressAttributes(ev) {
460
- if (ev.shippingAddress) {
461
- return {
462
- quick_checkout: true,
463
- firstname: ev.shippingAddress.name.split(' ')[0],
464
- lastname: ev.shippingAddress.name.split(' ')[1],
465
- address1: ev.shippingAddress.address.line1,
466
- address2: ev.shippingAddress.address.line2,
467
- city: ev.shippingAddress.address.city,
468
- zipcode: ev.shippingAddress.address.postal_code,
469
- country_iso: ev.shippingAddress.address.country,
470
- state_name: ev.shippingAddress.address.state,
471
- phone: ev.billingDetails.phone
472
- }
473
- }
474
- else {
475
- return {
476
- quick_checkout: true,
477
- }
391
+ get spreeApiHeaders() {
392
+ return {
393
+ 'X-Spree-API-Key': this.spreeApiKeyValue,
394
+ 'X-Spree-Token': this.cartTokenValue,
395
+ 'Content-Type': 'application/json'
478
396
  }
479
397
  }
480
398
  }