Skip to content

Commit 6b98416

Browse files
committed
Merge pull request #3707 from SalesforceCommerceCloud/rvishwanathbhat/fail-order-on-redirect
W-21372336: Fail Order for redirect based payments
2 parents 9a7fc21 + d8323d3 commit 6b98416

File tree

2 files changed

+131
-15
lines changed

2 files changed

+131
-15
lines changed

packages/template-retail-react-app/app/pages/checkout/payment-processing.jsx

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {Heading, Stack, Text} from '@salesforce/retail-react-app/app/components/
1515
import Link from '@salesforce/retail-react-app/app/components/link'
1616

1717
import {useOrder, useShopperOrdersMutation} from '@salesforce/commerce-sdk-react'
18+
import {useQueryClient} from '@tanstack/react-query'
1819
import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation'
1920
import {useSFPayments, STATUS_SUCCESS} from '@salesforce/retail-react-app/app/hooks/use-sf-payments'
2021
import {getSFPaymentsInstrument} from '@salesforce/retail-react-app/app/utils/sf-payments-utils'
@@ -42,6 +43,7 @@ const PaymentProcessing = () => {
4243
const navigate = useNavigation()
4344
const {sfp} = useSFPayments()
4445
const toast = useToast()
46+
const queryClient = useQueryClient()
4547

4648
const {mutateAsync: updatePaymentInstrumentForOrder} = useShopperOrdersMutation(
4749
'updatePaymentInstrumentForOrder'
@@ -51,7 +53,7 @@ const PaymentProcessing = () => {
5153
const params = new URLSearchParams(location.search)
5254
const vendor = params.get('vendor')
5355
const orderNo = params.get('orderNo')
54-
const {data: order} = useOrder(
56+
const {data: order, refetch} = useOrder(
5557
{
5658
parameters: {orderNo}
5759
},
@@ -117,16 +119,37 @@ const PaymentProcessing = () => {
117119
)
118120
}
119121

120-
async function failOrderForPayment() {
121-
await failOrder({
122-
parameters: {
123-
orderNo,
124-
reopenBasket: true
125-
},
126-
body: {
127-
reasonCode: 'payment_confirm_failure'
122+
/**
123+
* Attempts to fail an order and reopen the basket.
124+
* Only calls failOrder if the order status is 'created' (avoids hanging when order
125+
* was already failed by webhook).
126+
* @returns {Promise<void>}
127+
*/
128+
async function attemptFailOrderForPayment() {
129+
if (!orderNo) {
130+
return
131+
}
132+
133+
try {
134+
const {data: currentOrder} = await refetch()
135+
if (currentOrder?.status === 'created') {
136+
await failOrder({
137+
parameters: {
138+
orderNo,
139+
reopenBasket: true
140+
},
141+
body: {
142+
reasonCode: 'payment_confirm_failure'
143+
}
144+
})
128145
}
129-
})
146+
} catch (error) {
147+
// Swallow so flow continues (invalidate, navigate). Causes: (1) Race: refetch
148+
// returned 'created' but webhook already failed the order, so failOrder fails. (2) refetch
149+
// or failOrder threw (network, 4xx/5xx). Same behavior for all: don't hang.
150+
} finally {
151+
queryClient.invalidateQueries()
152+
}
130153
}
131154

132155
function showOrderConfirmation() {
@@ -139,7 +162,7 @@ const PaymentProcessing = () => {
139162
isHandled.current = true
140163

141164
// Order exists but payment can't be processed for return URL
142-
failOrderForPayment()
165+
attemptFailOrderForPayment()
143166
} else if (!isError && sfp && order) {
144167
;(async () => {
145168
if (isHandled.current) {
@@ -175,8 +198,8 @@ const PaymentProcessing = () => {
175198
duration: 30000
176199
})
177200

178-
// Attempt to fail the order
179-
await failOrderForPayment()
201+
// Attempt to fail the order (no-op if already failed by webhook, e.g. 3DS declined)
202+
await attemptFailOrderForPayment()
180203

181204
// Navigate back to the checkout page to try again
182205
navigate('/checkout')

packages/template-retail-react-app/app/pages/checkout/payment-processing.test.js

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ const mockUseOrder = jest.fn()
2020
const mockUpdatePaymentInstrumentForOrder = jest.fn()
2121
const mockFailOrder = jest.fn()
2222
const mockGetSFPaymentsInstrument = jest.fn()
23+
const mockRefetchOrder = jest.fn()
24+
const mockInvalidateQueries = jest.fn()
2325

2426
jest.mock('@salesforce/retail-react-app/app/hooks/use-navigation', () => ({
2527
__esModule: true,
@@ -53,6 +55,16 @@ jest.mock('@salesforce/commerce-sdk-react', () => {
5355
}
5456
})
5557

58+
jest.mock('@tanstack/react-query', () => {
59+
const actual = jest.requireActual('@tanstack/react-query')
60+
return {
61+
...actual,
62+
useQueryClient: () => ({
63+
invalidateQueries: mockInvalidateQueries
64+
})
65+
}
66+
})
67+
5668
jest.mock('@salesforce/retail-react-app/app/utils/sf-payments-utils', () => ({
5769
getSFPaymentsInstrument: () => mockGetSFPaymentsInstrument()
5870
}))
@@ -83,8 +95,13 @@ describe('PaymentProcessing', () => {
8395

8496
mockUseOrder.mockReturnValue({
8597
data: {
86-
orderNo: '12345'
87-
}
98+
orderNo: '12345',
99+
status: 'created'
100+
},
101+
refetch: mockRefetchOrder
102+
})
103+
mockRefetchOrder.mockResolvedValue({
104+
data: {orderNo: '12345', status: 'created'}
88105
})
89106

90107
mockUpdatePaymentInstrumentForOrder.mockReturnValue({})
@@ -196,6 +213,13 @@ describe('PaymentProcessing', () => {
196213

197214
test('renders error message for invalid Adyen URL missing type', async () => {
198215
mockLocation.search = '?vendor=Adyen&orderNo=12345&zoneId=default&redirectResult=ABC123'
216+
mockUseOrder.mockReturnValue({
217+
data: {orderNo: '12345'},
218+
refetch: mockRefetchOrder
219+
})
220+
mockRefetchOrder.mockResolvedValue({
221+
data: {orderNo: '12345', status: 'created'}
222+
})
199223

200224
renderWithProviders(<PaymentProcessing />)
201225

@@ -205,6 +229,7 @@ describe('PaymentProcessing', () => {
205229
expect(screen.getByText('Return to Checkout')).toBeInTheDocument()
206230

207231
await waitFor(() => {
232+
expect(mockRefetchOrder).toHaveBeenCalled()
208233
expect(mockFailOrder).toHaveBeenCalledTimes(1)
209234
expect(mockFailOrder).toHaveBeenCalledWith({
210235
parameters: {
@@ -220,6 +245,13 @@ describe('PaymentProcessing', () => {
220245

221246
test('renders error message for invalid Adyen URL missing zone id', async () => {
222247
mockLocation.search = '?vendor=Adyen&orderNo=12345&type=klarna&redirectResult=ABC123'
248+
mockUseOrder.mockReturnValue({
249+
data: {orderNo: '12345'},
250+
refetch: mockRefetchOrder
251+
})
252+
mockRefetchOrder.mockResolvedValue({
253+
data: {orderNo: '12345', status: 'created'}
254+
})
223255

224256
renderWithProviders(<PaymentProcessing />)
225257

@@ -229,6 +261,7 @@ describe('PaymentProcessing', () => {
229261
expect(screen.getByText('Return to Checkout')).toBeInTheDocument()
230262

231263
await waitFor(() => {
264+
expect(mockRefetchOrder).toHaveBeenCalled()
232265
expect(mockFailOrder).toHaveBeenCalledTimes(1)
233266
expect(mockFailOrder).toHaveBeenCalledWith({
234267
parameters: {
@@ -244,6 +277,13 @@ describe('PaymentProcessing', () => {
244277

245278
test('renders error message for invalid Adyen URL missing redirect result', async () => {
246279
mockLocation.search = '?vendor=Adyen&orderNo=12345&type=klarna&zoneId=default'
280+
mockUseOrder.mockReturnValue({
281+
data: {orderNo: '12345'},
282+
refetch: mockRefetchOrder
283+
})
284+
mockRefetchOrder.mockResolvedValue({
285+
data: {orderNo: '12345', status: 'created'}
286+
})
247287

248288
renderWithProviders(<PaymentProcessing />)
249289

@@ -253,6 +293,7 @@ describe('PaymentProcessing', () => {
253293
expect(screen.getByText('Return to Checkout')).toBeInTheDocument()
254294

255295
await waitFor(() => {
296+
expect(mockRefetchOrder).toHaveBeenCalled()
256297
expect(mockFailOrder).toHaveBeenCalledTimes(1)
257298
expect(mockFailOrder).toHaveBeenCalledWith({
258299
parameters: {
@@ -392,6 +433,9 @@ describe('PaymentProcessing', () => {
392433

393434
test('shows toast and calls failOrder before navigating on failed payment', async () => {
394435
mockHandleRedirect.mockResolvedValue({responseCode: 1})
436+
mockRefetchOrder.mockResolvedValue({
437+
data: {orderNo: '12345', status: 'created'}
438+
})
395439

396440
renderWithProviders(<PaymentProcessing />)
397441

@@ -400,6 +444,7 @@ describe('PaymentProcessing', () => {
400444
})
401445

402446
await waitFor(() => {
447+
expect(mockRefetchOrder).toHaveBeenCalled()
403448
expect(mockFailOrder).toHaveBeenCalledTimes(1)
404449
expect(mockFailOrder).toHaveBeenCalledWith({
405450
parameters: {
@@ -415,12 +460,55 @@ describe('PaymentProcessing', () => {
415460
expect(mockNavigate).toHaveBeenCalledWith('/checkout')
416461
})
417462

463+
test('does not call failOrder when order already failed by webhook', async () => {
464+
mockHandleRedirect.mockResolvedValue({responseCode: 1})
465+
mockRefetchOrder.mockResolvedValue({
466+
data: {orderNo: '12345', status: 'failed'}
467+
})
468+
469+
renderWithProviders(<PaymentProcessing />)
470+
471+
await waitFor(() => {
472+
expect(mockToast).toHaveBeenCalled()
473+
expect(mockNavigate).toHaveBeenCalledWith('/checkout')
474+
})
475+
476+
expect(mockRefetchOrder).toHaveBeenCalled()
477+
expect(mockFailOrder).not.toHaveBeenCalled()
478+
})
479+
480+
test('shows toast and navigates to checkout when failOrder fails', async () => {
481+
mockHandleRedirect.mockResolvedValue({responseCode: 1})
482+
mockRefetchOrder.mockResolvedValue({
483+
data: {orderNo: '12345', status: 'created'}
484+
})
485+
mockFailOrder.mockRejectedValue(new Error('Order already failed'))
486+
487+
renderWithProviders(<PaymentProcessing />)
488+
489+
await waitFor(() => {
490+
expect(mockToast).toHaveBeenCalled()
491+
expect(mockNavigate).toHaveBeenCalledWith('/checkout')
492+
})
493+
494+
expect(mockRefetchOrder).toHaveBeenCalled()
495+
expect(mockFailOrder).toHaveBeenCalledTimes(1)
496+
expect(mockInvalidateQueries).toHaveBeenCalled()
497+
})
498+
418499
test('handles different error response codes', async () => {
419500
const errorCodes = [1, 2, -1, 999]
420501

421502
for (const code of errorCodes) {
422503
jest.clearAllMocks()
423504
mockHandleRedirect.mockResolvedValue({responseCode: code})
505+
mockUseOrder.mockReturnValue({
506+
data: {orderNo: '12345', status: 'created'},
507+
refetch: mockRefetchOrder
508+
})
509+
mockRefetchOrder.mockResolvedValue({
510+
data: {orderNo: '12345', status: 'created'}
511+
})
424512

425513
renderWithProviders(<PaymentProcessing />)
426514

@@ -545,13 +633,18 @@ describe('PaymentProcessing', () => {
545633
})
546634

547635
test('shows toast and calls failOrder before navigating on failed payment', async () => {
636+
mockRefetchOrder.mockResolvedValue({
637+
data: {orderNo: '12345', status: 'created'}
638+
})
639+
548640
renderWithProviders(<PaymentProcessing />)
549641

550642
await waitFor(() => {
551643
expect(mockToast).toHaveBeenCalled()
552644
})
553645

554646
await waitFor(() => {
647+
expect(mockRefetchOrder).toHaveBeenCalled()
555648
expect(mockFailOrder).toHaveBeenCalledTimes(1)
556649
expect(mockFailOrder).toHaveBeenCalledWith({
557650
parameters: {

0 commit comments

Comments
 (0)