Skip to content

Commit 957d00e

Browse files
committed
W-21372336: Fail Order for redirect based payments
1 parent c48ccf4 commit 957d00e

File tree

2 files changed

+103
-22
lines changed

2 files changed

+103
-22
lines changed

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

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

17-
import {useOrder, useShopperOrdersMutation} from '@salesforce/commerce-sdk-react'
17+
import {
18+
useOrder,
19+
useShopperOrdersMutation,
20+
useCommerceApi,
21+
useAccessToken
22+
} from '@salesforce/commerce-sdk-react'
23+
import {useQueryClient} from '@tanstack/react-query'
1824
import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation'
1925
import {useSFPayments, STATUS_SUCCESS} from '@salesforce/retail-react-app/app/hooks/use-sf-payments'
2026
import {getSFPaymentsInstrument} from '@salesforce/retail-react-app/app/utils/sf-payments-utils'
@@ -42,6 +48,9 @@ const PaymentProcessing = () => {
4248
const navigate = useNavigation()
4349
const {sfp} = useSFPayments()
4450
const toast = useToast()
51+
const queryClient = useQueryClient()
52+
const api = useCommerceApi()
53+
const {getTokenWhenReady} = useAccessToken()
4554

4655
const {mutateAsync: updatePaymentInstrumentForOrder} = useShopperOrdersMutation(
4756
'updatePaymentInstrumentForOrder'
@@ -81,6 +90,7 @@ const PaymentProcessing = () => {
8190

8291
const isError = !isValidReturnUrl()
8392
const isHandled = useRef(false)
93+
const failOrderCalledRef = useRef(false)
8494

8595
async function handleAdyenRedirect() {
8696
// Find SF Payments payment instrument in order
@@ -117,16 +127,41 @@ const PaymentProcessing = () => {
117127
)
118128
}
119129

120-
async function failOrderForPayment() {
121-
await failOrder({
122-
parameters: {
123-
orderNo,
124-
reopenBasket: true
125-
},
126-
body: {
127-
reasonCode: 'payment_confirm_failure'
130+
/**
131+
* Attempts to fail an order and reopen the basket.
132+
* Only calls failOrder if the order status is 'created' (avoids hanging when order
133+
* was already failed by webhook.)
134+
* @returns {Promise<void>}
135+
*/
136+
async function attemptFailOrderForPayment() {
137+
if (!orderNo || failOrderCalledRef.current) {
138+
return
139+
}
140+
141+
try {
142+
const token = await getTokenWhenReady()
143+
const currentOrder = await api.shopperOrders.getOrder({
144+
parameters: {orderNo},
145+
headers: {Authorization: `Bearer ${token}`}
146+
})
147+
148+
if (currentOrder.status === 'created') {
149+
await failOrder({
150+
parameters: {
151+
orderNo,
152+
reopenBasket: true
153+
},
154+
body: {
155+
reasonCode: 'payment_confirm_failure'
156+
}
157+
})
128158
}
129-
})
159+
} catch {
160+
// Order may already be failed by webhook; avoid hanging
161+
} finally {
162+
failOrderCalledRef.current = true
163+
queryClient.invalidateQueries()
164+
}
130165
}
131166

132167
function showOrderConfirmation() {
@@ -139,7 +174,7 @@ const PaymentProcessing = () => {
139174
isHandled.current = true
140175

141176
// Order exists but payment can't be processed for return URL
142-
failOrderForPayment()
177+
attemptFailOrderForPayment()
143178
} else if (!isError && sfp && order) {
144179
;(async () => {
145180
if (isHandled.current) {
@@ -175,11 +210,11 @@ const PaymentProcessing = () => {
175210
duration: 30000
176211
})
177212

178-
// Attempt to fail the order
179-
await failOrderForPayment()
213+
// Attempt to fail the order (no-op if already failed by webhook
214+
await attemptFailOrderForPayment()
180215

181-
// Navigate back to the checkout page to try again
182-
navigate('/checkout')
216+
// Redirect to cart when payment fails
217+
navigate('/cart')
183218
})()
184219
}
185220
}, [sfp, order])

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

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ const mockUseOrder = jest.fn()
2020
const mockUpdatePaymentInstrumentForOrder = jest.fn()
2121
const mockFailOrder = jest.fn()
2222
const mockGetSFPaymentsInstrument = jest.fn()
23+
const mockGetOrder = jest.fn()
24+
const mockGetTokenWhenReady = jest.fn()
25+
const mockInvalidateQueries = jest.fn()
2326

2427
jest.mock('@salesforce/retail-react-app/app/hooks/use-navigation', () => ({
2528
__esModule: true,
@@ -40,6 +43,14 @@ jest.mock('@salesforce/commerce-sdk-react', () => {
4043
const actual = jest.requireActual('@salesforce/commerce-sdk-react')
4144
return {
4245
...actual,
46+
useCommerceApi: () => ({
47+
shopperOrders: {
48+
getOrder: mockGetOrder
49+
}
50+
}),
51+
useAccessToken: () => ({
52+
getTokenWhenReady: mockGetTokenWhenReady
53+
}),
4354
useShopperOrdersMutation: (mutationKey) => {
4455
if (mutationKey === 'updatePaymentInstrumentForOrder') {
4556
return {mutateAsync: mockUpdatePaymentInstrumentForOrder}
@@ -53,6 +64,16 @@ jest.mock('@salesforce/commerce-sdk-react', () => {
5364
}
5465
})
5566

67+
jest.mock('@tanstack/react-query', () => {
68+
const actual = jest.requireActual('@tanstack/react-query')
69+
return {
70+
...actual,
71+
useQueryClient: () => ({
72+
invalidateQueries: mockInvalidateQueries
73+
})
74+
}
75+
})
76+
5677
jest.mock('@salesforce/retail-react-app/app/utils/sf-payments-utils', () => ({
5778
getSFPaymentsInstrument: () => mockGetSFPaymentsInstrument()
5879
}))
@@ -90,6 +111,9 @@ describe('PaymentProcessing', () => {
90111
mockUpdatePaymentInstrumentForOrder.mockReturnValue({})
91112

92113
mockGetSFPaymentsInstrument.mockReturnValue({})
114+
115+
mockGetOrder.mockResolvedValue({status: 'created'})
116+
mockGetTokenWhenReady.mockResolvedValue('token')
93117
})
94118

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

207231
await waitFor(() => {
232+
expect(mockGetOrder).toHaveBeenCalled()
208233
expect(mockFailOrder).toHaveBeenCalledTimes(1)
209234
expect(mockFailOrder).toHaveBeenCalledWith({
210235
parameters: {
@@ -229,6 +254,7 @@ describe('PaymentProcessing', () => {
229254
expect(screen.getByText('Return to Checkout')).toBeInTheDocument()
230255

231256
await waitFor(() => {
257+
expect(mockGetOrder).toHaveBeenCalled()
232258
expect(mockFailOrder).toHaveBeenCalledTimes(1)
233259
expect(mockFailOrder).toHaveBeenCalledWith({
234260
parameters: {
@@ -253,6 +279,7 @@ describe('PaymentProcessing', () => {
253279
expect(screen.getByText('Return to Checkout')).toBeInTheDocument()
254280

255281
await waitFor(() => {
282+
expect(mockGetOrder).toHaveBeenCalled()
256283
expect(mockFailOrder).toHaveBeenCalledTimes(1)
257284
expect(mockFailOrder).toHaveBeenCalledWith({
258285
parameters: {
@@ -380,13 +407,13 @@ describe('PaymentProcessing', () => {
380407
})
381408
})
382409

383-
test('navigates back to checkout on failed payment', async () => {
410+
test('navigates to cart on failed payment', async () => {
384411
mockHandleRedirect.mockResolvedValue({responseCode: 1})
385412

386413
renderWithProviders(<PaymentProcessing />)
387414

388415
await waitFor(() => {
389-
expect(mockNavigate).toHaveBeenCalledWith('/checkout')
416+
expect(mockNavigate).toHaveBeenCalledWith('/cart')
390417
})
391418
})
392419

@@ -400,6 +427,7 @@ describe('PaymentProcessing', () => {
400427
})
401428

402429
await waitFor(() => {
430+
expect(mockGetOrder).toHaveBeenCalled()
403431
expect(mockFailOrder).toHaveBeenCalledTimes(1)
404432
expect(mockFailOrder).toHaveBeenCalledWith({
405433
parameters: {
@@ -412,7 +440,22 @@ describe('PaymentProcessing', () => {
412440
})
413441
})
414442

415-
expect(mockNavigate).toHaveBeenCalledWith('/checkout')
443+
expect(mockNavigate).toHaveBeenCalledWith('/cart')
444+
})
445+
446+
test('does not call failOrder when order already failed by webhook (e.g. 3DS declined)', async () => {
447+
mockHandleRedirect.mockResolvedValue({responseCode: 1})
448+
mockGetOrder.mockResolvedValue({status: 'failed'})
449+
450+
renderWithProviders(<PaymentProcessing />)
451+
452+
await waitFor(() => {
453+
expect(mockToast).toHaveBeenCalled()
454+
expect(mockNavigate).toHaveBeenCalledWith('/cart')
455+
})
456+
457+
expect(mockGetOrder).toHaveBeenCalled()
458+
expect(mockFailOrder).not.toHaveBeenCalled()
416459
})
417460

418461
test('handles different error response codes', async () => {
@@ -421,12 +464,14 @@ describe('PaymentProcessing', () => {
421464
for (const code of errorCodes) {
422465
jest.clearAllMocks()
423466
mockHandleRedirect.mockResolvedValue({responseCode: code})
467+
mockGetOrder.mockResolvedValue({status: 'created'})
468+
mockGetTokenWhenReady.mockResolvedValue('token')
424469

425470
renderWithProviders(<PaymentProcessing />)
426471

427472
await waitFor(() => {
428473
expect(mockToast).toHaveBeenCalled()
429-
expect(mockNavigate).toHaveBeenCalledWith('/checkout')
474+
expect(mockNavigate).toHaveBeenCalledWith('/cart')
430475
})
431476
}
432477
})
@@ -536,11 +581,11 @@ describe('PaymentProcessing', () => {
536581
})
537582
})
538583

539-
test('navigates back to checkout on failed payment', async () => {
584+
test('navigates to cart on failed payment', async () => {
540585
renderWithProviders(<PaymentProcessing />)
541586

542587
await waitFor(() => {
543-
expect(mockNavigate).toHaveBeenCalledWith('/checkout')
588+
expect(mockNavigate).toHaveBeenCalledWith('/cart')
544589
})
545590
})
546591

@@ -552,6 +597,7 @@ describe('PaymentProcessing', () => {
552597
})
553598

554599
await waitFor(() => {
600+
expect(mockGetOrder).toHaveBeenCalled()
555601
expect(mockFailOrder).toHaveBeenCalledTimes(1)
556602
expect(mockFailOrder).toHaveBeenCalledWith({
557603
parameters: {
@@ -564,7 +610,7 @@ describe('PaymentProcessing', () => {
564610
})
565611
})
566612

567-
expect(mockNavigate).toHaveBeenCalledWith('/checkout')
613+
expect(mockNavigate).toHaveBeenCalledWith('/cart')
568614
})
569615
})
570616
})

0 commit comments

Comments
 (0)