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.
- checksums.yaml +4 -4
- data/app/controllers/spree_stripe/confirm_payments_controller.rb +52 -0
- data/app/helpers/spree_stripe/base_helper.rb +24 -2
- data/app/javascript/spree_stripe/controllers/stripe_button_controller.js +256 -338
- data/app/javascript/spree_stripe/controllers/stripe_controller.js +129 -206
- data/app/models/spree/payment_sessions/stripe.rb +1 -2
- data/app/models/spree_stripe/gateway/payment_intents.rb +0 -4
- data/app/models/spree_stripe/gateway/payment_sessions.rb +1 -1
- data/app/models/spree_stripe/order_decorator.rb +0 -27
- data/app/models/spree_stripe/shipment_decorator.rb +3 -3
- data/app/services/spree_stripe/create_payment_session.rb +18 -0
- data/app/services/spree_stripe/webhook_handlers/payment_intent_payment_failed.rb +4 -15
- data/app/services/spree_stripe/webhook_handlers/payment_intent_succeeded.rb +1 -7
- data/app/views/spree/checkout/payment/_spree_stripe.html.erb +58 -58
- data/app/views/spree_stripe/_quick_checkout.html.erb +9 -15
- data/config/locales/en.yml +1 -8
- data/config/routes.rb +3 -22
- data/db/migrate/20250310152812_setup_spree_stripe_models.rb +0 -17
- data/lib/spree_stripe/configuration.rb +0 -1
- data/lib/spree_stripe/engine.rb +0 -7
- data/lib/spree_stripe/version.rb +1 -1
- metadata +5 -44
- data/app/controllers/spree_stripe/payment_intents_controller.rb +0 -61
- data/app/jobs/spree_stripe/complete_order_job.rb +0 -8
- data/app/models/spree_stripe/payment_intent.rb +0 -81
- data/app/services/spree_stripe/create_payment_intent.rb +0 -40
- data/app/services/spree_stripe/create_setup_intent.rb +0 -20
- data/lib/spree_api_v2/spree/api/v2/platform/affirm_serializer.rb +0 -9
- data/lib/spree_api_v2/spree/api/v2/platform/after_pay_serializer.rb +0 -9
- data/lib/spree_api_v2/spree/api/v2/platform/alipay_serializer.rb +0 -9
- data/lib/spree_api_v2/spree/api/v2/platform/ideal_serializer.rb +0 -9
- data/lib/spree_api_v2/spree/api/v2/platform/klarna_serializer.rb +0 -9
- data/lib/spree_api_v2/spree/api/v2/platform/link_serializer.rb +0 -9
- data/lib/spree_api_v2/spree/api/v2/platform/przelewy24_serializer.rb +0 -9
- data/lib/spree_api_v2/spree/api/v2/platform/sepa_debit_serializer.rb +0 -9
- data/lib/spree_api_v2/spree/api/v2/storefront/stripe/base_controller.rb +0 -21
- data/lib/spree_api_v2/spree/api/v2/storefront/stripe/payment_intents_controller.rb +0 -89
- data/lib/spree_api_v2/spree/api/v2/storefront/stripe/setup_intents_controller.rb +0 -19
- data/lib/spree_api_v2/spree/v2/storefront/affirm_serializer.rb +0 -7
- data/lib/spree_api_v2/spree/v2/storefront/after_pay_serializer.rb +0 -7
- data/lib/spree_api_v2/spree/v2/storefront/alipay_serializer.rb +0 -7
- data/lib/spree_api_v2/spree/v2/storefront/ideal_serializer.rb +0 -7
- data/lib/spree_api_v2/spree/v2/storefront/klarna_serializer.rb +0 -7
- data/lib/spree_api_v2/spree/v2/storefront/link_serializer.rb +0 -7
- data/lib/spree_api_v2/spree/v2/storefront/payment_intent_serializer.rb +0 -15
- data/lib/spree_api_v2/spree/v2/storefront/przelewy24_serializer.rb +0 -7
- data/lib/spree_api_v2/spree/v2/storefront/sepa_debit_serializer.rb +0 -7
- data/lib/spree_api_v2/spree_stripe/v2/storefront/payment_method_serializer_decorator.rb +0 -17
- 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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
87
|
+
expressCheckout.mount('#payment-request-button')
|
|
85
88
|
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
124
|
-
|
|
92
|
+
expressCheckout.on('shippingaddresschange', this.handleAddressChange.bind(this))
|
|
93
|
+
expressCheckout.on('shippingratechange', this.handleShippingOptionChange.bind(this))
|
|
125
94
|
}
|
|
126
|
-
|
|
127
|
-
prButton.on('cancel', this.handleCancelPayment.bind(this))
|
|
95
|
+
expressCheckout.on('confirm', this.handleFinalizePayment.bind(this))
|
|
128
96
|
}
|
|
129
97
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
204
|
-
|
|
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
|
-
|
|
207
|
-
|
|
142
|
+
const { shippingRates, selectionMap } = this.buildShippingRates(cart.fulfillments || [])
|
|
143
|
+
this.shippingRateMap = selectionMap
|
|
208
144
|
|
|
209
|
-
if (
|
|
210
|
-
if (shippingRateId === 'loading') return reject()
|
|
145
|
+
if (shippingRates.length === 0) return event.reject()
|
|
211
146
|
|
|
212
|
-
|
|
147
|
+
const lineItems = this.buildLineItems(cart)
|
|
148
|
+
lineItems.push({ name: 'Shipping', amount: shippingRates[0].amount })
|
|
213
149
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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(
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
179
|
+
async handleFinalizePayment(event) {
|
|
180
|
+
if (!this.stripe || !this.elements) {
|
|
181
|
+
event.paymentFailed({ reason: 'fail' })
|
|
182
|
+
return
|
|
240
183
|
}
|
|
241
184
|
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
246
|
+
redirect: 'if_required'
|
|
311
247
|
})
|
|
312
248
|
|
|
313
|
-
if (
|
|
314
|
-
|
|
249
|
+
if (confirmError) {
|
|
250
|
+
event.paymentFailed({ reason: 'fail' })
|
|
251
|
+
this.handleError(confirmError)
|
|
315
252
|
return
|
|
316
253
|
}
|
|
317
254
|
|
|
318
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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
|
-
|
|
326
|
-
|
|
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
|
-
|
|
331
|
-
ev.paymentFailed()
|
|
332
|
-
return
|
|
333
|
-
}
|
|
291
|
+
// --- helpers ------------------------------------------------------------
|
|
334
292
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
return
|
|
343
|
-
}
|
|
298
|
+
const discount = this.toCents(cart.discount_total)
|
|
299
|
+
if (discount < 0) items.push({ name: 'Discount', amount: discount })
|
|
344
300
|
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
order: {
|
|
364
|
-
ship_address_id: 'CLEAR',
|
|
365
|
-
bill_address_id: 'CLEAR'
|
|
366
|
-
}
|
|
367
|
-
}
|
|
304
|
+
return items
|
|
305
|
+
}
|
|
368
306
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
418
|
-
|
|
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
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
434
|
-
|
|
369
|
+
sumLineItems(lineItems) {
|
|
370
|
+
return lineItems.reduce((sum, item) => sum + item.amount, 0)
|
|
371
|
+
}
|
|
435
372
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
}
|
|
373
|
+
randomSuffix() {
|
|
374
|
+
return Math.random().toString(36).slice(2, 6)
|
|
439
375
|
}
|
|
440
376
|
|
|
441
|
-
|
|
442
|
-
|
|
377
|
+
handleError(error) {
|
|
378
|
+
if (!error) return
|
|
443
379
|
|
|
444
|
-
if (
|
|
445
|
-
|
|
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
|
-
|
|
450
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
}
|