Skip to content

Commit da8a8de

Browse files
authored
W-19443266: Handle Failure use-cases in the UI (#3534)
* W-19443266: Handle Failure use-cases by calling Fail Order where necessary. Also refactor the client-secret retrieval since the response structure has changed.
1 parent 6fdffd5 commit da8a8de

File tree

13 files changed

+1368
-92
lines changed

13 files changed

+1368
-92
lines changed

packages/commerce-sdk-react/src/hooks/ShopperOrders/cache.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,5 +47,21 @@ export const cacheUpdateMatrix: CacheUpdateMatrix<Client> = {
4747
},
4848
createPaymentInstrumentForOrder: updateOrderQuery,
4949
updatePaymentInstrumentForOrder: updateOrderQuery,
50-
removePaymentInstrumentFromOrder: updateOrderQuery
50+
removePaymentInstrumentFromOrder: updateOrderQuery,
51+
failOrder(customerId, {parameters}) {
52+
// Exclude reopenBasket (not valid for getOrder) and pass only valid parameters
53+
// to getOrder.queryKey, while preserving common params like organizationId, siteId
54+
const {orderNo, reopenBasket, ...orderParams} = parameters
55+
const invalidate: CacheUpdateInvalidate[] = [
56+
{queryKey: getOrder.queryKey({...orderParams, orderNo})}
57+
]
58+
// If reopenBasket is true, we should also invalidate customer baskets
59+
// since a new basket may have been created
60+
if (reopenBasket && customerId) {
61+
invalidate.push({
62+
queryKey: getCustomerBaskets.queryKey({...orderParams, customerId})
63+
})
64+
}
65+
return {invalidate}
66+
}
5167
}

packages/commerce-sdk-react/src/hooks/ShopperOrders/mutation.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,13 @@ The payment instrument is added with the provided details. The payment method mu
4646
* Updates a payment instrument of an order.
4747
* @returns A TanStack Query mutation hook for interacting with the Shopper Orders `updatePaymentInstrumentForOrder` endpoint.
4848
*/
49-
UpdatePaymentInstrumentForOrder: 'updatePaymentInstrumentForOrder'
49+
UpdatePaymentInstrumentForOrder: 'updatePaymentInstrumentForOrder',
50+
/**
51+
* Fails an unplaced order and optionally reopens the basket when indicated.
52+
* Creates a HistoryEntry in the failed Order with provided reasonCode.
53+
* @returns A TanStack Query mutation hook for interacting with the Shopper Orders `failOrder` endpoint.
54+
*/
55+
FailOrder: 'failOrder'
5056
} as const
5157

5258
/**

packages/template-retail-react-app/app/components/sf-payments-express-buttons/index.jsx

Lines changed: 200 additions & 70 deletions
Large diffs are not rendered by default.

packages/template-retail-react-app/app/components/sf-payments-express-buttons/index.test.js

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
EXPRESS_PAY_NOW,
1414
EXPRESS_BUY_NOW
1515
} from '@salesforce/retail-react-app/app/hooks/use-sf-payments'
16+
import {rest} from 'msw'
1617

1718
// Mock getConfig to provide necessary configuration
1819
jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => {
@@ -50,6 +51,60 @@ jest.mock('@salesforce/retail-react-app/app/hooks/use-sf-payments', () => {
5051
}
5152
})
5253

54+
beforeEach(() => {
55+
// Reset MSW handlers to avoid conflicts
56+
global.server.resetHandlers()
57+
58+
// Add MSW handlers to mock API requests
59+
global.server.use(
60+
rest.get('*/api/configuration/shopper-configurations/*', (req, res, ctx) => {
61+
return res(
62+
ctx.delay(0),
63+
ctx.status(200),
64+
ctx.json({
65+
configurations: []
66+
})
67+
)
68+
}),
69+
rest.get(
70+
'*/api/customer/shopper-customers/*/customers/*/product-lists',
71+
(req, res, ctx) => {
72+
return res(
73+
ctx.delay(0),
74+
ctx.status(200),
75+
ctx.json({
76+
data: [],
77+
total: 0
78+
})
79+
)
80+
}
81+
),
82+
rest.get('*/api/payment-metadata', (req, res, ctx) => {
83+
return res(
84+
ctx.delay(0),
85+
ctx.status(200),
86+
ctx.json({
87+
apiKey: 'test-key',
88+
publishableKey: 'pk_test'
89+
})
90+
)
91+
}),
92+
rest.get('*/api/checkout/shopper-payments/*/payment-configuration', (req, res, ctx) => {
93+
return res(
94+
ctx.delay(0),
95+
ctx.status(200),
96+
ctx.json({
97+
paymentMethods: [
98+
{id: 'card', name: 'Card'},
99+
{id: 'paypal', name: 'PayPal'}
100+
],
101+
paymentMethodSetAccounts: []
102+
})
103+
)
104+
})
105+
)
106+
})
107+
53108
afterEach(() => {
54109
jest.clearAllMocks()
55110
})
@@ -175,3 +230,65 @@ describe('prepareBasket prop updates', () => {
175230
expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument()
176231
})
177232
})
233+
234+
describe('failOrder error handling', () => {
235+
const mockFailOrder = jest.fn()
236+
const mockCreateOrder = jest.fn()
237+
const mockUpdatePaymentInstrument = jest.fn()
238+
const mockToast = jest.fn()
239+
240+
beforeEach(() => {
241+
jest.clearAllMocks()
242+
mockFailOrder.mockResolvedValue({})
243+
})
244+
245+
// Mock the mutations to verify they're available
246+
jest.mock('@salesforce/commerce-sdk-react', () => {
247+
const actual = jest.requireActual('@salesforce/commerce-sdk-react')
248+
return {
249+
...actual,
250+
useShopperOrdersMutation: (mutationKey) => {
251+
if (mutationKey === 'failOrder') {
252+
return {mutateAsync: mockFailOrder}
253+
}
254+
if (mutationKey === 'createOrder') {
255+
return {mutateAsync: mockCreateOrder}
256+
}
257+
if (mutationKey === 'updatePaymentInstrumentForOrder') {
258+
return {mutateAsync: mockUpdatePaymentInstrument}
259+
}
260+
return {mutateAsync: jest.fn()}
261+
},
262+
usePaymentConfiguration: () => ({
263+
data: {
264+
paymentMethods: [{id: 'card', name: 'Card'}],
265+
paymentMethodSetAccounts: []
266+
}
267+
}),
268+
useShopperBasketsMutation: () => ({
269+
mutateAsync: jest.fn()
270+
}),
271+
useShippingMethodsForShipment: () => ({
272+
refetch: jest.fn()
273+
})
274+
}
275+
})
276+
277+
jest.mock('@salesforce/retail-react-app/app/hooks/use-shopper-configuration', () => ({
278+
useShopperConfiguration: () => 'default'
279+
}))
280+
281+
jest.mock('@salesforce/retail-react-app/app/hooks/use-toast', () => ({
282+
useToast: () => mockToast
283+
}))
284+
285+
// It doesn't trigger the actual failOrder call (that requires the full payment flow), but it confirms the setup is correct.
286+
// The actual failOrder call is better tested in integration/E2E tests.
287+
test('failOrder mutation is available and error message constant is defined', () => {
288+
renderWithProviders(<SFPaymentsExpressButtons {...defaultProps} />)
289+
290+
expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument()
291+
expect(mockFailOrder).toBeDefined()
292+
expect(mockToast).toBeDefined()
293+
})
294+
})

packages/template-retail-react-app/app/pages/checkout/index.jsx

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ import {
4848
useSFPayments
4949
} from '@salesforce/retail-react-app/app/hooks/use-sf-payments'
5050

51+
let persistedPaymentsError = null
52+
5153
const Checkout = () => {
5254
const {formatMessage} = useIntl()
5355
const navigate = useNavigation()
@@ -95,6 +97,14 @@ const Checkout = () => {
9597
}
9698
}, [basket?.basketId])
9799

100+
// Restore error if component remounted after payments error causes a refresh
101+
useEffect(() => {
102+
if (persistedPaymentsError && !error) {
103+
setError(persistedPaymentsError)
104+
persistedPaymentsError = null // Clear it after restoring
105+
}
106+
}, [])
107+
98108
// Callback to handle when payment method requires its own pay button
99109
const handleRequiresPayButtonChange = (requiresPayButton) => {
100110
setShouldHidePlaceOrderButton(requiresPayButton === false)
@@ -107,6 +117,7 @@ const Checkout = () => {
107117
}
108118

109119
const handlePaymentError = (errorMessage) => {
120+
persistedPaymentsError = errorMessage
110121
setError(errorMessage)
111122
}
112123

@@ -121,11 +132,13 @@ const Checkout = () => {
121132
}
122133
navigate(`/checkout/confirmation/${order.orderNo}`)
123134
} catch (error) {
124-
const message = formatMessage({
125-
id: 'checkout.message.generic_error',
126-
defaultMessage: 'An unexpected error occurred during checkout.'
127-
})
128-
setError(message)
135+
if (!persistedPaymentsError) {
136+
const message = formatMessage({
137+
id: 'checkout.message.generic_error',
138+
defaultMessage: 'An unexpected error occurred during checkout.'
139+
})
140+
setError(message)
141+
}
129142
} finally {
130143
setIsLoading(false)
131144
}
@@ -151,7 +164,6 @@ const Checkout = () => {
151164
{error}
152165
</Alert>
153166
)}
154-
155167
{sfPaymentsEnabled && (
156168
<Box
157169
layerStyle="card"

0 commit comments

Comments
 (0)