diff --git a/packages/template-retail-react-app/app/pages/checkout/payment-processing.jsx b/packages/template-retail-react-app/app/pages/checkout/payment-processing.jsx index a75032e7e1..00f59d9860 100644 --- a/packages/template-retail-react-app/app/pages/checkout/payment-processing.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/payment-processing.jsx @@ -15,6 +15,7 @@ import {Heading, Stack, Text} from '@salesforce/retail-react-app/app/components/ import Link from '@salesforce/retail-react-app/app/components/link' import {useOrder, useShopperOrdersMutation} from '@salesforce/commerce-sdk-react' +import {useQueryClient} from '@tanstack/react-query' import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import {useSFPayments, STATUS_SUCCESS} from '@salesforce/retail-react-app/app/hooks/use-sf-payments' import {getSFPaymentsInstrument} from '@salesforce/retail-react-app/app/utils/sf-payments-utils' @@ -42,6 +43,7 @@ const PaymentProcessing = () => { const navigate = useNavigation() const {sfp} = useSFPayments() const toast = useToast() + const queryClient = useQueryClient() const {mutateAsync: updatePaymentInstrumentForOrder} = useShopperOrdersMutation( 'updatePaymentInstrumentForOrder' @@ -51,7 +53,7 @@ const PaymentProcessing = () => { const params = new URLSearchParams(location.search) const vendor = params.get('vendor') const orderNo = params.get('orderNo') - const {data: order} = useOrder( + const {data: order, refetch} = useOrder( { parameters: {orderNo} }, @@ -117,16 +119,37 @@ const PaymentProcessing = () => { ) } - async function failOrderForPayment() { - await failOrder({ - parameters: { - orderNo, - reopenBasket: true - }, - body: { - reasonCode: 'payment_confirm_failure' + /** + * Attempts to fail an order and reopen the basket. + * Only calls failOrder if the order status is 'created' (avoids hanging when order + * was already failed by webhook). + * @returns {Promise} + */ + async function attemptFailOrderForPayment() { + if (!orderNo) { + return + } + + try { + const {data: currentOrder} = await refetch() + if (currentOrder?.status === 'created') { + await failOrder({ + parameters: { + orderNo, + reopenBasket: true + }, + body: { + reasonCode: 'payment_confirm_failure' + } + }) } - }) + } catch (error) { + // Swallow so flow continues (invalidate, navigate). Causes: (1) Race: refetch + // returned 'created' but webhook already failed the order, so failOrder fails. (2) refetch + // or failOrder threw (network, 4xx/5xx). Same behavior for all: don't hang. + } finally { + queryClient.invalidateQueries() + } } function showOrderConfirmation() { @@ -139,7 +162,7 @@ const PaymentProcessing = () => { isHandled.current = true // Order exists but payment can't be processed for return URL - failOrderForPayment() + attemptFailOrderForPayment() } else if (!isError && sfp && order) { ;(async () => { if (isHandled.current) { @@ -175,8 +198,8 @@ const PaymentProcessing = () => { duration: 30000 }) - // Attempt to fail the order - await failOrderForPayment() + // Attempt to fail the order (no-op if already failed by webhook, e.g. 3DS declined) + await attemptFailOrderForPayment() // Navigate back to the checkout page to try again navigate('/checkout') diff --git a/packages/template-retail-react-app/app/pages/checkout/payment-processing.test.js b/packages/template-retail-react-app/app/pages/checkout/payment-processing.test.js index 5bd1c502bb..7644810ea2 100644 --- a/packages/template-retail-react-app/app/pages/checkout/payment-processing.test.js +++ b/packages/template-retail-react-app/app/pages/checkout/payment-processing.test.js @@ -20,6 +20,8 @@ const mockUseOrder = jest.fn() const mockUpdatePaymentInstrumentForOrder = jest.fn() const mockFailOrder = jest.fn() const mockGetSFPaymentsInstrument = jest.fn() +const mockRefetchOrder = jest.fn() +const mockInvalidateQueries = jest.fn() jest.mock('@salesforce/retail-react-app/app/hooks/use-navigation', () => ({ __esModule: true, @@ -53,6 +55,16 @@ jest.mock('@salesforce/commerce-sdk-react', () => { } }) +jest.mock('@tanstack/react-query', () => { + const actual = jest.requireActual('@tanstack/react-query') + return { + ...actual, + useQueryClient: () => ({ + invalidateQueries: mockInvalidateQueries + }) + } +}) + jest.mock('@salesforce/retail-react-app/app/utils/sf-payments-utils', () => ({ getSFPaymentsInstrument: () => mockGetSFPaymentsInstrument() })) @@ -83,8 +95,13 @@ describe('PaymentProcessing', () => { mockUseOrder.mockReturnValue({ data: { - orderNo: '12345' - } + orderNo: '12345', + status: 'created' + }, + refetch: mockRefetchOrder + }) + mockRefetchOrder.mockResolvedValue({ + data: {orderNo: '12345', status: 'created'} }) mockUpdatePaymentInstrumentForOrder.mockReturnValue({}) @@ -196,6 +213,13 @@ describe('PaymentProcessing', () => { test('renders error message for invalid Adyen URL missing type', async () => { mockLocation.search = '?vendor=Adyen&orderNo=12345&zoneId=default&redirectResult=ABC123' + mockUseOrder.mockReturnValue({ + data: {orderNo: '12345'}, + refetch: mockRefetchOrder + }) + mockRefetchOrder.mockResolvedValue({ + data: {orderNo: '12345', status: 'created'} + }) renderWithProviders() @@ -205,6 +229,7 @@ describe('PaymentProcessing', () => { expect(screen.getByText('Return to Checkout')).toBeInTheDocument() await waitFor(() => { + expect(mockRefetchOrder).toHaveBeenCalled() expect(mockFailOrder).toHaveBeenCalledTimes(1) expect(mockFailOrder).toHaveBeenCalledWith({ parameters: { @@ -220,6 +245,13 @@ describe('PaymentProcessing', () => { test('renders error message for invalid Adyen URL missing zone id', async () => { mockLocation.search = '?vendor=Adyen&orderNo=12345&type=klarna&redirectResult=ABC123' + mockUseOrder.mockReturnValue({ + data: {orderNo: '12345'}, + refetch: mockRefetchOrder + }) + mockRefetchOrder.mockResolvedValue({ + data: {orderNo: '12345', status: 'created'} + }) renderWithProviders() @@ -229,6 +261,7 @@ describe('PaymentProcessing', () => { expect(screen.getByText('Return to Checkout')).toBeInTheDocument() await waitFor(() => { + expect(mockRefetchOrder).toHaveBeenCalled() expect(mockFailOrder).toHaveBeenCalledTimes(1) expect(mockFailOrder).toHaveBeenCalledWith({ parameters: { @@ -244,6 +277,13 @@ describe('PaymentProcessing', () => { test('renders error message for invalid Adyen URL missing redirect result', async () => { mockLocation.search = '?vendor=Adyen&orderNo=12345&type=klarna&zoneId=default' + mockUseOrder.mockReturnValue({ + data: {orderNo: '12345'}, + refetch: mockRefetchOrder + }) + mockRefetchOrder.mockResolvedValue({ + data: {orderNo: '12345', status: 'created'} + }) renderWithProviders() @@ -253,6 +293,7 @@ describe('PaymentProcessing', () => { expect(screen.getByText('Return to Checkout')).toBeInTheDocument() await waitFor(() => { + expect(mockRefetchOrder).toHaveBeenCalled() expect(mockFailOrder).toHaveBeenCalledTimes(1) expect(mockFailOrder).toHaveBeenCalledWith({ parameters: { @@ -392,6 +433,9 @@ describe('PaymentProcessing', () => { test('shows toast and calls failOrder before navigating on failed payment', async () => { mockHandleRedirect.mockResolvedValue({responseCode: 1}) + mockRefetchOrder.mockResolvedValue({ + data: {orderNo: '12345', status: 'created'} + }) renderWithProviders() @@ -400,6 +444,7 @@ describe('PaymentProcessing', () => { }) await waitFor(() => { + expect(mockRefetchOrder).toHaveBeenCalled() expect(mockFailOrder).toHaveBeenCalledTimes(1) expect(mockFailOrder).toHaveBeenCalledWith({ parameters: { @@ -415,12 +460,55 @@ describe('PaymentProcessing', () => { expect(mockNavigate).toHaveBeenCalledWith('/checkout') }) + test('does not call failOrder when order already failed by webhook', async () => { + mockHandleRedirect.mockResolvedValue({responseCode: 1}) + mockRefetchOrder.mockResolvedValue({ + data: {orderNo: '12345', status: 'failed'} + }) + + renderWithProviders() + + await waitFor(() => { + expect(mockToast).toHaveBeenCalled() + expect(mockNavigate).toHaveBeenCalledWith('/checkout') + }) + + expect(mockRefetchOrder).toHaveBeenCalled() + expect(mockFailOrder).not.toHaveBeenCalled() + }) + + test('shows toast and navigates to checkout when failOrder fails', async () => { + mockHandleRedirect.mockResolvedValue({responseCode: 1}) + mockRefetchOrder.mockResolvedValue({ + data: {orderNo: '12345', status: 'created'} + }) + mockFailOrder.mockRejectedValue(new Error('Order already failed')) + + renderWithProviders() + + await waitFor(() => { + expect(mockToast).toHaveBeenCalled() + expect(mockNavigate).toHaveBeenCalledWith('/checkout') + }) + + expect(mockRefetchOrder).toHaveBeenCalled() + expect(mockFailOrder).toHaveBeenCalledTimes(1) + expect(mockInvalidateQueries).toHaveBeenCalled() + }) + test('handles different error response codes', async () => { const errorCodes = [1, 2, -1, 999] for (const code of errorCodes) { jest.clearAllMocks() mockHandleRedirect.mockResolvedValue({responseCode: code}) + mockUseOrder.mockReturnValue({ + data: {orderNo: '12345', status: 'created'}, + refetch: mockRefetchOrder + }) + mockRefetchOrder.mockResolvedValue({ + data: {orderNo: '12345', status: 'created'} + }) renderWithProviders() @@ -545,6 +633,10 @@ describe('PaymentProcessing', () => { }) test('shows toast and calls failOrder before navigating on failed payment', async () => { + mockRefetchOrder.mockResolvedValue({ + data: {orderNo: '12345', status: 'created'} + }) + renderWithProviders() await waitFor(() => { @@ -552,6 +644,7 @@ describe('PaymentProcessing', () => { }) await waitFor(() => { + expect(mockRefetchOrder).toHaveBeenCalled() expect(mockFailOrder).toHaveBeenCalledTimes(1) expect(mockFailOrder).toHaveBeenCalledWith({ parameters: {