diff --git a/packages/template-retail-react-app/app/constants.js b/packages/template-retail-react-app/app/constants.js index c9b013313c..fcb3fb480a 100644 --- a/packages/template-retail-react-app/app/constants.js +++ b/packages/template-retail-react-app/app/constants.js @@ -283,7 +283,8 @@ export const PAYMENT_METHOD_TYPES = { export const PAYMENT_GATEWAYS = { STRIPE: 'stripe', - ADYEN: 'adyen' + ADYEN: 'adyen', + PAYPAL: 'paypal' } export const SETUP_FUTURE_USAGE = { diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/sf-payments-sheet.events.test.js b/packages/template-retail-react-app/app/pages/checkout/partials/sf-payments-sheet.events.test.js index d174c895f3..6040577a35 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/sf-payments-sheet.events.test.js +++ b/packages/template-retail-react-app/app/pages/checkout/partials/sf-payments-sheet.events.test.js @@ -66,6 +66,11 @@ jest.mock('@salesforce/commerce-sdk-react', () => { accountId: 'stripe-account-1', vendor: 'Stripe', paymentMethods: [{id: 'card'}] + }, + { + accountId: 'paypal-account-1', + vendor: 'Paypal', + paymentMethods: [{id: 'paypal'}] } ] } @@ -235,14 +240,18 @@ const setupComponentAndGetPaymentElement = async () => { return checkoutCall[4] } -const firePaymentMethodSelectedEvent = async (paymentElement, detail = {}) => { +const firePaymentMethodSelectedEvent = async ( + paymentElement, + selectedPaymentMethod = 'card', + detail = {} +) => { await act(async () => { paymentElement.dispatchEvent( new CustomEvent('sfp:paymentmethodselected', { bubbles: true, composed: true, detail: { - selectedPaymentMethod: 'card', + selectedPaymentMethod, ...detail } }) @@ -377,7 +386,9 @@ describe('SFPaymentsSheet - SDK Event Handler Tests', () => { test('handlePaymentButtonApprove includes setupFutureUsage when savePaymentMethodForFutureUse is true', async () => { const paymentElement = await setupComponentAndGetPaymentElement() - await firePaymentMethodSelectedEvent(paymentElement, {savePaymentMethodForFutureUse: true}) + await firePaymentMethodSelectedEvent(paymentElement, 'card', { + savePaymentMethodForFutureUse: true + }) await firePaymentApproveEvent(paymentElement, {savePaymentMethodForFutureUse: true}) await waitFor( @@ -399,7 +410,9 @@ describe('SFPaymentsSheet - SDK Event Handler Tests', () => { test('handlePaymentButtonApprove does not include setupFutureUsage when savePaymentMethodForFutureUse is false', async () => { const paymentElement = await setupComponentAndGetPaymentElement() - await firePaymentMethodSelectedEvent(paymentElement, {savePaymentMethodForFutureUse: false}) + await firePaymentMethodSelectedEvent(paymentElement, 'card', { + savePaymentMethodForFutureUse: false + }) await firePaymentApproveEvent(paymentElement, {savePaymentMethodForFutureUse: false}) await waitFor( @@ -418,7 +431,9 @@ describe('SFPaymentsSheet - SDK Event Handler Tests', () => { test('handlePaymentButtonApprove includes required fields for PaymentsCustomer record creation', async () => { const paymentElement = await setupComponentAndGetPaymentElement() - await firePaymentMethodSelectedEvent(paymentElement, {savePaymentMethodForFutureUse: true}) + await firePaymentMethodSelectedEvent(paymentElement, 'card', { + savePaymentMethodForFutureUse: true + }) await firePaymentApproveEvent(paymentElement, {savePaymentMethodForFutureUse: true}) await waitFor( @@ -463,6 +478,10 @@ describe('SFPaymentsSheet - SDK Event Handler Tests', () => { const checkoutCall = mockCheckout.mock.calls[0] const config = checkoutCall[2] + await firePaymentMethodSelectedEvent(paymentElement, 'paypal', { + requiresPayButton: false + }) + await config.actions.createIntent() await act(async () => { @@ -540,7 +559,7 @@ describe('SFPaymentsSheet - SDK Event Handler Tests', () => { const checkoutCall = mockCheckout.mock.calls[0] const paymentElement = checkoutCall[4] - await firePaymentMethodSelectedEvent(paymentElement, {requiresPayButton: true}) + await firePaymentMethodSelectedEvent(paymentElement, 'card', {requiresPayButton: true}) await waitFor(() => { expect(mockOnRequiresPayButtonChange).toHaveBeenCalledWith(true) diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/sf-payments-sheet.jsx b/packages/template-retail-react-app/app/pages/checkout/partials/sf-payments-sheet.jsx index 7de1e2d2f1..2331b5e80f 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/sf-payments-sheet.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/partials/sf-payments-sheet.jsx @@ -48,9 +48,11 @@ import { buildTheme, getSFPaymentsInstrument, createPaymentInstrumentBody, - transformPaymentMethodReferences + transformPaymentMethodReferences, + getGatewayFromPaymentMethod } from '@salesforce/retail-react-app/app/utils/sf-payments-utils' import logger from '@salesforce/retail-react-app/app/utils/logger-instance' +import {PAYMENT_GATEWAYS} from '@salesforce/retail-react-app/app/constants' const SFPaymentsSheet = forwardRef((props, ref) => { const {onRequiresPayButtonChange, onCreateOrder, onError} = props @@ -127,13 +129,27 @@ const SFPaymentsSheet = forwardRef((props, ref) => { const paymentMethodType = useRef(null) const currentBasket = useRef(null) const savePaymentMethodRef = useRef(false) + const updatedOrder = useRef(null) + const gateway = useRef(null) const handlePaymentMethodSelected = (evt) => { + // Track selected payment method paymentMethodType.current = evt.detail.selectedPaymentMethod + + // Determine gateway for selected payment method + gateway.current = getGatewayFromPaymentMethod( + paymentMethodType.current, + paymentConfig?.paymentMethods, + paymentConfig?.paymentMethodSetAccounts + ) + if (evt.detail.savePaymentMethodForFutureUse !== undefined) { + // Track if payment method should be saved for future use savePaymentMethodRef.current = evt.detail.savePaymentMethodForFutureUse === true } + if (evt.detail.requiresPayButton !== undefined && onRequiresPayButtonChange) { + // Notify listener whether pay button is required onRequiresPayButtonChange(evt.detail.requiresPayButton) } } @@ -145,12 +161,12 @@ const SFPaymentsSheet = forwardRef((props, ref) => { if (event?.detail?.savePaymentMethodForFutureUse !== undefined) { savePaymentMethodRef.current = event.detail.savePaymentMethodForFutureUse === true } - const updatedOrder = await createAndUpdateOrder( + updatedOrder.current = await createAndUpdateOrder( savePaymentMethodRef.current && customer?.isRegistered ) // Clear the ref after successful order creation currentBasket.current = null - navigate(`/checkout/confirmation/${updatedOrder.orderNo}`) + navigate(`/checkout/confirmation/${updatedOrder.current.orderNo}`) } catch (error) { const message = formatMessage({ id: 'checkout.message.generic_error', @@ -210,45 +226,121 @@ const SFPaymentsSheet = forwardRef((props, ref) => { }) } - const createPaymentInstrument = async () => { - let updatedBasket = await onBillingSubmit() + const createBasketPaymentInstrument = async () => { + // If successful `onBillingSubmit` returns the updated basket. If the form was invalid on + // submit, `undefined` is returned. + const updatedBasket = await onBillingSubmit() + + if (!updatedBasket) { + throw new Error('Billing form errors') + } + + // Store the updated basket for potential cleanup on cancel + currentBasket.current = updatedBasket // Remove any existing Salesforce Payments instruments first await removeSFPaymentsInstruments(updatedBasket) - updatedBasket = await addPaymentInstrumentToBasket({ + // Create SF Payments basket payment instrument + return await addPaymentInstrumentToBasket({ parameters: {basketId: updatedBasket.basketId}, body: createPaymentInstrumentBody({ amount: updatedBasket.orderTotal, paymentMethodType: paymentMethodType.current, zoneId, shippingPreference: 'SET_PROVIDED_ADDRESS', + paymentData: null, storePaymentMethod: false, futureUsageOffSession, paymentMethods: paymentConfig?.paymentMethods, paymentMethodSetAccounts: paymentConfig?.paymentMethodSetAccounts, - isPostRequest: true // never include setupFutureUsage in POST + isPostRequest: true }) }) + } - // Store the updated basket for potential cleanup on cancel - currentBasket.current = updatedBasket + const createIntent = async (paymentData) => { + if (gateway.current === PAYMENT_GATEWAYS.PAYPAL) { + // Create SF Payments basket payment instrument referencing PayPal order + const updatedBasket = await createBasketPaymentInstrument() - // Find SF Payments payment instrument - const updatedBasketPaymentInstrument = getSFPaymentsInstrument(updatedBasket) + // Find payment instrument in updated basket + const basketPaymentInstrument = getSFPaymentsInstrument(updatedBasket) - return { - id: updatedBasketPaymentInstrument.paymentReference?.paymentReferenceId + // Return PayPal order information + return { + id: basketPaymentInstrument.paymentReference.paymentReferenceId + } } + + // For Stripe and Adyen, update order payment instrument to create payment + const shouldSavePaymentMethod = savePaymentMethodRef.current && customer?.isRegistered + updatedOrder.current = await createAndUpdateOrder(shouldSavePaymentMethod, paymentData) + + // Find updated SF Payments payment instrument in updated order + const orderPaymentInstrument = getSFPaymentsInstrument(updatedOrder.current) + + let paymentIntent + if (gateway.current === PAYMENT_GATEWAYS.STRIPE) { + // Track created payment intent + paymentIntent = { + id: orderPaymentInstrument.paymentReference.paymentReferenceId, + client_secret: + orderPaymentInstrument?.paymentReference?.gatewayProperties?.stripe + ?.clientSecret + } + + // Read setup_future_usage from backend response, fallback to manual calculation if not available + // TODO: The fallback is temporary that's to be removed in next iteration. + const setupFutureUsage = + orderPaymentInstrument?.paymentReference?.gatewayProperties?.stripe + ?.setup_future_usage + if (setupFutureUsage) { + paymentIntent.setup_future_usage = setupFutureUsage + } else if (futureUsageOffSession) { + paymentIntent.setup_future_usage = 'off_session' + } else if (shouldSavePaymentMethod) { + paymentIntent.setup_future_usage = 'on_session' + } + + // Update the redirect return URL to include the related order no + config.current.options.returnUrl += + '?orderNo=' + encodeURIComponent(updatedOrder.current.orderNo) + } else if (gateway.current === PAYMENT_GATEWAYS.ADYEN) { + // Track created Adyen payment + paymentIntent = { + pspReference: + orderPaymentInstrument.paymentReference.gatewayProperties.adyen + .adyenPaymentIntent.id, + resultCode: + orderPaymentInstrument.paymentReference.gatewayProperties.adyen + .adyenPaymentIntent.resultCode, + action: orderPaymentInstrument.paymentReference.gatewayProperties.adyen + .adyenPaymentIntent.adyenPaymentIntentAction + } + } + + return paymentIntent } - const createAndUpdateOrder = async (shouldSavePaymentMethod = false) => { + const createAndUpdateOrder = async (shouldSavePaymentMethod = false, paymentData = null) => { // Create order from the basket - const order = await onCreateOrder() + let order = await onCreateOrder() // Find SF Payments payment instrument in created order const orderPaymentInstrument = getSFPaymentsInstrument(order) + if (gateway.current === PAYMENT_GATEWAYS.ADYEN) { + // Append necessary data to Adyen redirect return URL + paymentData.returnUrl += + '&orderNo=' + + encodeURIComponent(order.orderNo) + + '&zoneId=' + + encodeURIComponent(paymentConfig?.zoneId) + + '&type=' + + encodeURIComponent(paymentMethodType.current) + } + try { // Update order payment instrument to create payment const paymentInstrumentBody = createPaymentInstrumentBody({ @@ -256,13 +348,14 @@ const SFPaymentsSheet = forwardRef((props, ref) => { paymentMethodType: paymentMethodType.current, zoneId, shippingPreference: null, + paymentData, storePaymentMethod: shouldSavePaymentMethod, futureUsageOffSession, paymentMethods: paymentConfig?.paymentMethods, paymentMethodSetAccounts: paymentConfig?.paymentMethodSetAccounts }) - const updatedOrder = await updatePaymentInstrumentForOrder({ + order = await updatePaymentInstrumentForOrder({ parameters: { orderNo: order.orderNo, paymentInstrumentId: orderPaymentInstrument.paymentInstrumentId @@ -270,7 +363,7 @@ const SFPaymentsSheet = forwardRef((props, ref) => { body: paymentInstrumentBody }) - return updatedOrder + return order } catch (error) { const statusCode = error?.response?.status || error?.status const errorMessage = error?.message || error?.response?.data?.message || 'Unknown error' @@ -318,105 +411,49 @@ const SFPaymentsSheet = forwardRef((props, ref) => { } const confirmPayment = async () => { - // If successful `onBillingSubmit` returns the updated basket. If the form was invalid on - // submit, `undefined` is returned. - const updatedBasket = await onBillingSubmit() - - if (!updatedBasket) { - throw new Error('Billing form errors') - } - - startConfirming(updatedBasket) - - // Remove any existing Salesforce Payments instruments first - await removeSFPaymentsInstruments(updatedBasket) - // Create SF Payments basket payment instrument before creating order - await addPaymentInstrumentToBasket({ - parameters: {basketId: updatedBasket.basketId}, - body: createPaymentInstrumentBody({ - amount: updatedBasket.orderTotal, - paymentMethodType: paymentMethodType.current, - zoneId, - shippingPreference: null, - storePaymentMethod: false, - futureUsageOffSession, - paymentMethods: paymentConfig?.paymentMethods, - paymentMethodSetAccounts: paymentConfig?.paymentMethodSetAccounts, - isPostRequest: true - }) - }) + const updatedBasket = await createBasketPaymentInstrument() - let updatedOrder = null - try { - // Update order payment instrument to create payment - const shouldSavePaymentMethod = savePaymentMethodRef.current && customer?.isRegistered - updatedOrder = await createAndUpdateOrder(shouldSavePaymentMethod) - - // Find updated SF Payments payment instrument in updated order - const orderPaymentInstrument = getSFPaymentsInstrument(updatedOrder) + // Create payment billing details from basket + const billingDetails = {} - // Track created payment intent - const paymentIntent = { - client_secret: - orderPaymentInstrument?.paymentReference?.gatewayProperties?.stripe - ?.clientSecret, - id: orderPaymentInstrument.paymentReference.paymentReferenceId - } - - // Read setup_future_usage from backend response, fallback to manual calculation if not available - // TODO: The fallback is temporary that's to be removed in next iteration. - const setupFutureUsage = - orderPaymentInstrument?.paymentReference?.gatewayProperties?.stripe - ?.setup_future_usage - if (setupFutureUsage) { - paymentIntent.setup_future_usage = setupFutureUsage - } else if (futureUsageOffSession) { - paymentIntent.setup_future_usage = 'off_session' - } else if (shouldSavePaymentMethod) { - paymentIntent.setup_future_usage = 'on_session' - } - - // Create payment billing details from basket - const billingDetails = {} - - if (updatedOrder.customerInfo) { - billingDetails.email = updatedOrder.customerInfo.email - } + if (updatedBasket.customerInfo) { + billingDetails.email = updatedBasket.customerInfo.email + } - if (updatedOrder.billingAddress) { - billingDetails.phone = updatedOrder.billingAddress.phone - billingDetails.name = updatedOrder.billingAddress.fullName - billingDetails.address = { - line1: updatedOrder.billingAddress.address1, - line2: updatedOrder.billingAddress.address2, - city: updatedOrder.billingAddress.city, - state: updatedOrder.billingAddress.stateCode, - postalCode: updatedOrder.billingAddress.postalCode, - country: updatedOrder.billingAddress.countryCode - } + if (updatedBasket.billingAddress) { + billingDetails.phone = updatedBasket.billingAddress.phone + billingDetails.name = updatedBasket.billingAddress.fullName + billingDetails.address = { + line1: updatedBasket.billingAddress.address1, + line2: updatedBasket.billingAddress.address2, + city: updatedBasket.billingAddress.city, + state: updatedBasket.billingAddress.stateCode, + postalCode: updatedBasket.billingAddress.postalCode, + country: updatedBasket.billingAddress.countryCode } + } - // Create payment shipping details from basket - const shippingDetails = {} - if (updatedOrder.shipments?.[0].shippingAddress) { - shippingDetails.name = updatedOrder.shipments[0].shippingAddress.fullName - shippingDetails.address = { - line1: updatedOrder.shipments[0].shippingAddress.address1, - line2: updatedOrder.shipments[0].shippingAddress.address2, - city: updatedOrder.shipments[0].shippingAddress.city, - state: updatedOrder.shipments[0].shippingAddress.stateCode, - postalCode: updatedOrder.shipments[0].shippingAddress.postalCode, - country: updatedOrder.shipments[0].shippingAddress.countryCode - } + // Create payment shipping details from basket + const shippingDetails = {} + if (updatedBasket.shipments?.[0].shippingAddress) { + shippingDetails.name = updatedBasket.shipments[0].shippingAddress.fullName + shippingDetails.address = { + line1: updatedBasket.shipments[0].shippingAddress.address1, + line2: updatedBasket.shipments[0].shippingAddress.address2, + city: updatedBasket.shipments[0].shippingAddress.city, + state: updatedBasket.shipments[0].shippingAddress.stateCode, + postalCode: updatedBasket.shipments[0].shippingAddress.postalCode, + country: updatedBasket.shipments[0].shippingAddress.countryCode } + } - // Update the redirect return URL to include the related order no - config.current.options.returnUrl += '?orderNo=' + updatedOrder.orderNo + startConfirming(updatedBasket) + try { // Confirm the payment const result = await checkoutComponent.current.confirm( - async () => paymentIntent, + null, billingDetails, shippingDetails ) @@ -428,15 +465,15 @@ const SFPaymentsSheet = forwardRef((props, ref) => { // TODO: only invalidate order queries queryClient.invalidateQueries() // Finally return the created order - return updatedOrder + return updatedOrder.current } catch (error) { // Only fail order if createAndUpdateOrder succeeded but perhaps confirm fails - if (updatedOrder && !error.orderNo) { + if (updatedOrder.current && !error.orderNo) { // createAndUpdateOrder succeeded but confirm failed - need to fail the order try { await failOrder({ parameters: { - orderNo: updatedOrder.orderNo, + orderNo: updatedOrder.current.orderNo, reopenBasket: true }, body: { @@ -445,7 +482,7 @@ const SFPaymentsSheet = forwardRef((props, ref) => { }) logger.info('Order failed successfully after confirm failure', { namespace: 'SFPaymentsSheet.confirmPayment', - additionalProperties: {orderNo: updatedOrder.orderNo} + additionalProperties: {orderNo: updatedOrder.current.orderNo} }) // Show error message to user - order was failed and basket reopened @@ -460,7 +497,7 @@ const SFPaymentsSheet = forwardRef((props, ref) => { logger.error('Failed to fail order after confirm failure', { namespace: 'SFPaymentsSheet.confirmPayment', additionalProperties: { - orderNo: updatedOrder.orderNo, + orderNo: updatedOrder.current.orderNo, failOrderError } }) @@ -504,7 +541,7 @@ const SFPaymentsSheet = forwardRef((props, ref) => { config.current = { theme: buildTheme(), actions: { - createIntent: createPaymentInstrument, + createIntent: createIntent, onClick: () => {} // No-op: return empty function since its not applicable and SDK proceeds immediately }, options: { diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/sf-payments-sheet.test.js b/packages/template-retail-react-app/app/pages/checkout/partials/sf-payments-sheet.test.js index d275229326..36a2929de8 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/sf-payments-sheet.test.js +++ b/packages/template-retail-react-app/app/pages/checkout/partials/sf-payments-sheet.test.js @@ -59,6 +59,7 @@ jest.mock('@salesforce/commerce-sdk-react', () => { }, usePaymentConfiguration: () => ({ data: { + zoneId: 'default', paymentMethods: [ { id: 'card', @@ -71,6 +72,12 @@ jest.mock('@salesforce/commerce-sdk-react', () => { name: 'PayPal', paymentMethodType: 'paypal', accountId: 'paypal-account-1' + }, + { + id: 'klarna', + name: 'Klarna', + paymentMethodType: 'klarna', + accountId: 'adyen-account-1' } ], paymentMethodSetAccounts: [ @@ -78,6 +85,16 @@ jest.mock('@salesforce/commerce-sdk-react', () => { accountId: 'stripe-account-1', vendor: 'Stripe', paymentMethods: [{id: 'card'}] + }, + { + accountId: 'paypal-account-1', + vendor: 'Paypal', + paymentMethods: [{id: 'paypal'}] + }, + { + accountId: 'adyen-account-1', + vendor: 'Adyen', + paymentMethods: [{id: 'klarna'}] } ] } @@ -318,7 +335,7 @@ const createMockOrder = (overrides = {}) => ({ ...overrides }) -const setupConfirmPaymentMocks = () => { +const setupConfirmPaymentMocks = (paymentIntentRef) => { const mockOrder = createMockOrder() mockUpdateBillingAddress.mockResolvedValue({ ...mockBasket, @@ -340,9 +357,14 @@ const setupConfirmPaymentMocks = () => { }) mockOnCreateOrder.mockResolvedValue(mockOrder) mockUpdatePaymentInstrument.mockResolvedValue(mockOrder) - mockCheckoutConfirm.mockResolvedValue({ - responseCode: STATUS_SUCCESS, - data: {} + mockCheckoutConfirm.mockImplementation(async () => { + const config = mockCheckout.mock.calls[0][2] + paymentIntentRef.current = await config.actions.createIntent() + + return { + responseCode: STATUS_SUCCESS, + data: {} + } }) return mockOrder } @@ -613,9 +635,14 @@ describe('SFPaymentsSheet', () => { mockOnCreateOrder.mockResolvedValue(mockOrder) mockUpdatePaymentInstrument.mockResolvedValue(mockOrder) - mockCheckoutConfirm.mockResolvedValue({ - responseCode: STATUS_SUCCESS, - data: {} + mockCheckoutConfirm.mockImplementation(async () => { + const config = mockCheckout.mock.calls[0][2] + await config.actions.createIntent() + + return { + responseCode: STATUS_SUCCESS, + data: {} + } }) renderWithCheckoutContext( @@ -643,7 +670,7 @@ describe('SFPaymentsSheet', () => { expect(result.orderNo).toBe('ORDER123') }) - test('confirmPayment creates payment instrument and processes payment', async () => { + test('confirmPayment creates payment instrument and processes Stripe payment', async () => { const ref = React.createRef() const mockOrder = createMockOrder() @@ -670,9 +697,14 @@ describe('SFPaymentsSheet', () => { mockOnCreateOrder.mockResolvedValue(mockOrder) mockUpdatePaymentInstrument.mockResolvedValue(mockOrder) - mockCheckoutConfirm.mockResolvedValue({ - responseCode: STATUS_SUCCESS, - data: {} + mockCheckoutConfirm.mockImplementation(async () => { + const config = mockCheckout.mock.calls[0][2] + await config.actions.createIntent() + + return { + responseCode: STATUS_SUCCESS, + data: {} + } }) renderWithCheckoutContext( @@ -689,16 +721,146 @@ describe('SFPaymentsSheet', () => { await ref.current.confirmPayment() - expect(mockAddPaymentInstrument).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.objectContaining({ - paymentMethodId: 'Salesforce Payments' + await waitFor(() => { + expect(mockAddPaymentInstrument).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + paymentMethodId: 'Salesforce Payments' + }) }) + ) + + expect(mockUpdatePaymentInstrument).toHaveBeenCalled() + expect(mockCheckoutConfirm).toHaveBeenCalled() + }) + }) + + test('confirmPayment creates payment instrument and processes Adyen payment', async () => { + const ref = React.createRef() + const mockOrder = createMockOrder({ + paymentInstruments: [ + { + paymentInstrumentId: 'PI123', + paymentMethodId: 'Salesforce Payments', + paymentReference: { + paymentReferenceId: 'ref123', + gateway: 'adyen', + gatewayProperties: { + adyen: { + adyenPaymentIntent: { + id: 'PI123', + resultCode: 'AUTHORISED', + adyenPaymentAction: 'action' + } + } + } + } + } + ] + }) + + mockUpdateBillingAddress.mockResolvedValue({ + ...mockBasket, + billingAddress: mockBasket.shipments[0].shippingAddress, + paymentInstruments: [] + }) + + mockAddPaymentInstrument.mockResolvedValue({ + ...mockBasket, + paymentInstruments: [ + { + paymentInstrumentId: 'PI123', + paymentMethodId: 'Salesforce Payments', + paymentReference: { + paymentReferenceId: 'ref123' + } + } + ] + }) + + mockOnCreateOrder.mockResolvedValue(mockOrder) + mockUpdatePaymentInstrument.mockResolvedValue(mockOrder) + + mockCheckoutConfirm.mockImplementation(async () => { + const config = mockCheckout.mock.calls[0][2] + await config.actions.createIntent({ + paymentMethod: 'payment method', + returnUrl: 'http://test.com?name=value', + origin: 'http://mystore.com', + lineItems: [], + billingDetails: {} }) + + return { + responseCode: STATUS_SUCCESS, + data: {} + } + }) + + renderWithCheckoutContext( + ) - expect(mockUpdatePaymentInstrument).toHaveBeenCalled() - expect(mockCheckoutConfirm).toHaveBeenCalled() + await waitFor(() => { + expect(ref.current).toBeDefined() + }) + + const paymentElement = mockCheckout.mock.calls[0][4] + + await act(async () => { + paymentElement.dispatchEvent( + new CustomEvent('sfp:paymentmethodselected', { + bubbles: true, + composed: true, + detail: { + selectedPaymentMethod: 'klarna' + } + }) + ) + }) + + await ref.current.confirmPayment() + + await waitFor(() => { + expect(mockAddPaymentInstrument).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + paymentMethodId: 'Salesforce Payments', + paymentReferenceRequest: { + paymentMethodType: 'klarna', + zoneId: 'default' + } + }) + }) + ) + + expect(mockUpdatePaymentInstrument).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + paymentReferenceRequest: expect.objectContaining({ + paymentMethodType: 'klarna', + zoneId: 'default', + gateway: 'adyen', + gatewayProperties: { + adyen: { + paymentMethod: 'payment method', + returnUrl: + 'http://test.com?name=value&orderNo=ORDER123&zoneId=default&type=klarna', + origin: 'http://mystore.com', + lineItems: [], + billingDetails: {} + } + } + }) + }) + }) + ) + expect(mockCheckoutConfirm).toHaveBeenCalled() + }) }) test('confirmPayment handles payment failure', async () => { @@ -825,9 +987,14 @@ describe('SFPaymentsSheet', () => { mockOnCreateOrder.mockResolvedValue(mockOrder) mockUpdatePaymentInstrument.mockResolvedValue(mockOrder) - mockCheckoutConfirm.mockResolvedValue({ - responseCode: 'FAILED', - data: {error: 'Payment confirmation failed'} + mockCheckoutConfirm.mockImplementation(async () => { + const config = mockCheckout.mock.calls[0][2] + await config.actions.createIntent() + + return { + responseCode: 'FAILED', + data: {error: 'Payment confirmation failed'} + } }) mockFailOrder.mockResolvedValue({}) @@ -865,7 +1032,8 @@ describe('SFPaymentsSheet', () => { test('confirmPayment includes setup_future_usage when savePaymentMethodForFutureUse is true', async () => { const ref = React.createRef() - setupConfirmPaymentMocks() + const paymentIntentRef = React.createRef() + setupConfirmPaymentMocks(paymentIntentRef) renderWithCheckoutContext( { expect(mockCheckoutConfirm).toHaveBeenCalled() }) - const confirmCall = mockCheckoutConfirm.mock.calls[0] - const paymentIntentFunction = confirmCall[0] - const paymentIntent = await paymentIntentFunction() - - expect(paymentIntent.setup_future_usage).toBe('on_session') + expect(paymentIntentRef.current.setup_future_usage).toBe('on_session') }) test('confirmPayment passes savePaymentMethodRef to createAndUpdateOrder', async () => { const ref = React.createRef() - setupConfirmPaymentMocks() + const paymentIntentRef = React.createRef() + setupConfirmPaymentMocks(paymentIntentRef) renderWithCheckoutContext( { test('confirmPayment excludes setup_future_usage when savePaymentMethodForFutureUse is false', async () => { const ref = React.createRef() - setupConfirmPaymentMocks() + const paymentIntentRef = React.createRef() + setupConfirmPaymentMocks(paymentIntentRef) renderWithCheckoutContext( { expect(mockCheckoutConfirm).toHaveBeenCalled() }) - const confirmCall = mockCheckoutConfirm.mock.calls[0] - const paymentIntentFunction = confirmCall[0] - const paymentIntent = await paymentIntentFunction() - - expect(paymentIntent.setup_future_usage).toBeUndefined() + expect(paymentIntentRef.current.setup_future_usage).toBeUndefined() }) test('confirmPayment sets setup_future_usage to off_session when futureUsageOffSession is true', async () => { const ref = React.createRef() - setupConfirmPaymentMocks() + const paymentIntentRef = React.createRef() + setupConfirmPaymentMocks(paymentIntentRef) // eslint-disable-next-line @typescript-eslint/no-var-requires const useShopperConfigurationModule = require('@salesforce/retail-react-app/app/hooks/use-shopper-configuration') @@ -1061,11 +1224,7 @@ describe('SFPaymentsSheet', () => { expect(mockCheckoutConfirm).toHaveBeenCalled() }) - const confirmCall = mockCheckoutConfirm.mock.calls[0] - const paymentIntentFunction = confirmCall[0] - const paymentIntent = await paymentIntentFunction() - - expect(paymentIntent.setup_future_usage).toBe('off_session') + expect(paymentIntentRef.current.setup_future_usage).toBe('off_session') useShopperConfigurationModule.useShopperConfiguration = originalMock }) 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 2aeaeaf64c..a75032e7e1 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 @@ -5,7 +5,7 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import React, {useEffect} from 'react' +import React, {useEffect, useRef} from 'react' import PropTypes from 'prop-types' import {useIntl} from 'react-intl' import {useLocation} from 'react-router-dom' @@ -14,9 +14,27 @@ import {FormattedMessage} from 'react-intl' import {Heading, Stack, Text} from '@salesforce/retail-react-app/app/components/shared/ui' import Link from '@salesforce/retail-react-app/app/components/link' +import {useOrder, useShopperOrdersMutation} from '@salesforce/commerce-sdk-react' 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' import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast' +import {PAYMENT_GATEWAYS} from '@salesforce/retail-react-app/app/constants' + +// const ADYEN_SUCCESS_RESULT_CODES = [ +// 'Authorised', +// 'PartiallyAuthorised', +// 'Received', +// 'Pending', +// 'PresentToShopper' +// ] +const ADYEN_SUCCESS_RESULT_CODES = [ + 'AUTHORISED', + 'PARTIALLYAUTHORISED', + 'RECEIVED', + 'PENDING', + 'PRESENTTOSHOPPER' +] const PaymentProcessing = () => { const intl = useIntl() @@ -25,38 +43,146 @@ const PaymentProcessing = () => { const {sfp} = useSFPayments() const toast = useToast() + const {mutateAsync: updatePaymentInstrumentForOrder} = useShopperOrdersMutation( + 'updatePaymentInstrumentForOrder' + ) + const {mutateAsync: failOrder} = useShopperOrdersMutation('failOrder') + const params = new URLSearchParams(location.search) - const isError = !params.has('orderNo') + const vendor = params.get('vendor') const orderNo = params.get('orderNo') + const {data: order} = useOrder( + { + parameters: {orderNo} + }, + { + enabled: !!orderNo + } + ) + + function isValidReturnUrl() { + switch (vendor) { + case 'Stripe': + // Stripe requires orderNo + return !!orderNo + case 'Adyen': + // Adyen requires orderNo, type, redirectResult, and zoneId + return ( + !!orderNo && + params.has('type') && + params.has('zoneId') && + params.has('redirectResult') + ) + default: + // Unsupported payment gateway + return false + } + } + + const isError = !isValidReturnUrl() + const isHandled = useRef(false) + + async function handleAdyenRedirect() { + // Find SF Payments payment instrument in order + const orderPaymentInstrument = getSFPaymentsInstrument(order) + + // Submit redirect result + const updatedOrder = await updatePaymentInstrumentForOrder({ + parameters: { + orderNo: order.orderNo, + paymentInstrumentId: orderPaymentInstrument.paymentInstrumentId + }, + body: { + paymentMethodId: 'Salesforce Payments', + paymentReferenceRequest: { + paymentMethodType: params.get('type'), + zoneId: params.get('zoneId'), + gateway: PAYMENT_GATEWAYS.ADYEN, + gatewayProperties: { + adyen: { + redirectResult: params.get('redirectResult') + } + } + } + } + }) + + // Find updated SF Payments payment instrument in updated order + const updatedOrderPaymentInstrument = getSFPaymentsInstrument(updatedOrder) + + // Check if Adyen result code indicates redirect payment was successful + return ADYEN_SUCCESS_RESULT_CODES.includes( + updatedOrderPaymentInstrument?.paymentReference?.gatewayProperties?.adyen + ?.adyenPaymentIntent?.resultCode + ) + } + + async function failOrderForPayment() { + await failOrder({ + parameters: { + orderNo, + reopenBasket: true + }, + body: { + reasonCode: 'payment_confirm_failure' + } + }) + } + + function showOrderConfirmation() { + navigate(`/checkout/confirmation/${orderNo}`) + } useEffect(() => { - if (!isError && sfp) { + if (isError && order && !isHandled.current) { + // Ensure we don't handle the redirect twice + isHandled.current = true + + // Order exists but payment can't be processed for return URL + failOrderForPayment() + } else if (!isError && sfp && order) { ;(async () => { - // If the URL has the necessary parameters, attempt to handle the redirect - const result = await sfp.handleRedirect() - if (result.responseCode === STATUS_SUCCESS) { - // Payment was successful so navigate to order confirmation - navigate(`/checkout/confirmation/${orderNo}`) - } else { - // Show an error message that the payment was unsuccessful - toast({ - title: intl.formatMessage({ - defaultMessage: - 'Your attempted payment was unsuccessful. You have not been charged and your order has not been placed. Please select a different payment method and submit payment again to complete your checkout and place your order.', - id: 'payment_processing.error.unsuccessful' - }), - status: 'error', - duration: 30000 - }) - - // TODO: need to fail the order if not failed automatically by webhook - - // Navigate back to the checkout page to try again - navigate('/checkout') + if (isHandled.current) { + // Redirect already handled + return } + + // Ensure we don't handle the redirect twice + isHandled.current = true + + if (vendor === 'Stripe') { + // Use sfp.js to attempt to handle the redirect + const stripeResult = await sfp.handleRedirect() + if (stripeResult.responseCode === STATUS_SUCCESS) { + return showOrderConfirmation() + } + } else if (vendor === 'Adyen') { + const adyenResult = await handleAdyenRedirect() + if (adyenResult) { + // Redirect result submitted successfully, and we can proceed to the order confirmation + return showOrderConfirmation() + } + } + + // Show an error message that the payment was unsuccessful + toast({ + title: intl.formatMessage({ + defaultMessage: + 'Your attempted payment was unsuccessful. You have not been charged and your order has not been placed. Please select a different payment method and submit payment again to complete your checkout and place your order.', + id: 'payment_processing.error.unsuccessful' + }), + status: 'error', + duration: 30000 + }) + + // Attempt to fail the order + await failOrderForPayment() + + // Navigate back to the checkout page to try again + navigate('/checkout') })() } - }, [sfp]) + }, [sfp, order]) return ( 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 3aa6aafd68..5bd1c502bb 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 @@ -16,6 +16,10 @@ const mockNavigate = jest.fn() const mockToast = jest.fn() const mockHandleRedirect = jest.fn() const mockUseSFPayments = jest.fn() +const mockUseOrder = jest.fn() +const mockUpdatePaymentInstrumentForOrder = jest.fn() +const mockFailOrder = jest.fn() +const mockGetSFPaymentsInstrument = jest.fn() jest.mock('@salesforce/retail-react-app/app/hooks/use-navigation', () => ({ __esModule: true, @@ -32,6 +36,27 @@ jest.mock('@salesforce/retail-react-app/app/hooks/use-sf-payments', () => ({ STATUS_SUCCESS: 0 })) +jest.mock('@salesforce/commerce-sdk-react', () => { + const actual = jest.requireActual('@salesforce/commerce-sdk-react') + return { + ...actual, + useShopperOrdersMutation: (mutationKey) => { + if (mutationKey === 'updatePaymentInstrumentForOrder') { + return {mutateAsync: mockUpdatePaymentInstrumentForOrder} + } + if (mutationKey === 'failOrder') { + return {mutateAsync: mockFailOrder} + } + return {mutateAsync: jest.fn()} + }, + useOrder: () => mockUseOrder() + } +}) + +jest.mock('@salesforce/retail-react-app/app/utils/sf-payments-utils', () => ({ + getSFPaymentsInstrument: () => mockGetSFPaymentsInstrument() +})) + // Mock useLocation const mockLocation = {search: ''} jest.mock('react-router-dom', () => ({ @@ -44,7 +69,7 @@ describe('PaymentProcessing', () => { jest.clearAllMocks() // Default location with orderNo - mockLocation.search = '?orderNo=12345' + mockLocation.search = '?vendor=Stripe&orderNo=12345' // Default mock implementations mockHandleRedirect.mockResolvedValue({responseCode: STATUS_SUCCESS}) @@ -55,6 +80,16 @@ describe('PaymentProcessing', () => { handleRedirect: mockHandleRedirect } }) + + mockUseOrder.mockReturnValue({ + data: { + orderNo: '12345' + } + }) + + mockUpdatePaymentInstrumentForOrder.mockReturnValue({}) + + mockGetSFPaymentsInstrument.mockReturnValue({}) }) afterEach(() => { @@ -68,14 +103,15 @@ describe('PaymentProcessing', () => { expect(screen.getByText('Payment Processing')).toBeInTheDocument() }) - test('renders working message when orderNo is present', () => { + test('renders working message for valid URL', () => { renderWithProviders() expect(screen.getByText('Working on your payment...')).toBeInTheDocument() }) - test('renders error message when orderNo is missing', () => { + test('renders error message for missing vendor', async () => { mockLocation.search = '' + mockUseOrder.mockReturnValue({data: null}) renderWithProviders() @@ -83,200 +119,453 @@ describe('PaymentProcessing', () => { screen.getByText('There was an unexpected error processing your payment.') ).toBeInTheDocument() expect(screen.getByText('Return to Checkout')).toBeInTheDocument() - }) - test('error state includes link to checkout page', () => { - mockLocation.search = '' - - renderWithProviders() + // Wait a bit to ensure failOrder is not called + await new Promise((resolve) => setTimeout(resolve, 100)) - const link = screen.getByText('Return to Checkout') - // Check that href contains /checkout (may include locale prefix) - expect(link.closest('a')).toHaveAttribute('href', expect.stringContaining('/checkout')) + expect(mockFailOrder).not.toHaveBeenCalled() }) - }) - describe('payment processing', () => { - test('calls handleRedirect when sfp is available and orderNo exists', async () => { - mockHandleRedirect.mockResolvedValue({responseCode: STATUS_SUCCESS}) - mockUseSFPayments.mockReturnValue({ - sfp: { - handleRedirect: mockHandleRedirect - } - }) + test('renders error message for unknown vendor', async () => { + mockLocation.search = '?vendor=Unknown' + mockUseOrder.mockReturnValue({data: null}) renderWithProviders() - await waitFor(() => { - expect(mockHandleRedirect).toHaveBeenCalledTimes(1) - }) + expect( + screen.getByText('There was an unexpected error processing your payment.') + ).toBeInTheDocument() + expect(screen.getByText('Return to Checkout')).toBeInTheDocument() + + // Wait a bit to ensure failOrder is not called + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(mockFailOrder).not.toHaveBeenCalled() }) - test('does not call handleRedirect when orderNo is missing', async () => { - mockLocation.search = '' + test('renders error message for invalid Stripe URL missing order no', async () => { + mockLocation.search = '?vendor=Stripe' + mockUseOrder.mockReturnValue({data: null}) renderWithProviders() - // Wait a bit to ensure handleRedirect is not called + expect( + screen.getByText('There was an unexpected error processing your payment.') + ).toBeInTheDocument() + expect(screen.getByText('Return to Checkout')).toBeInTheDocument() + + // Wait a bit to ensure failOrder is not called await new Promise((resolve) => setTimeout(resolve, 100)) - expect(mockHandleRedirect).not.toHaveBeenCalled() + expect(mockFailOrder).not.toHaveBeenCalled() }) - test('does not call handleRedirect when sfp is not available', async () => { - mockUseSFPayments.mockReturnValue({sfp: null}) + test('renders error message for invalid Stripe URL with empty order no', async () => { + mockLocation.search = '?vendor=Stripe&orderNo=' + mockUseOrder.mockReturnValue({data: null}) renderWithProviders() - // Wait a bit to ensure handleRedirect is not called + expect( + screen.getByText('There was an unexpected error processing your payment.') + ).toBeInTheDocument() + expect(screen.getByText('Return to Checkout')).toBeInTheDocument() + + // Wait a bit to ensure failOrder is not called await new Promise((resolve) => setTimeout(resolve, 100)) - expect(mockHandleRedirect).not.toHaveBeenCalled() + expect(mockFailOrder).not.toHaveBeenCalled() }) - test('does not call handleRedirect when sfp initially unavailable', async () => { - // Start with no sfp - mockUseSFPayments.mockReturnValue({sfp: null}) + test('renders error message for invalid Adyen URL missing order no', async () => { + mockLocation.search = '?vendor=Adyen&type=klarna&zoneId=default&redirectResult=ABC123' + mockUseOrder.mockReturnValue({data: null}) renderWithProviders() - // Wait a bit to ensure handleRedirect is not called + expect( + screen.getByText('There was an unexpected error processing your payment.') + ).toBeInTheDocument() + expect(screen.getByText('Return to Checkout')).toBeInTheDocument() + + // Wait a bit to ensure failOrder is not called await new Promise((resolve) => setTimeout(resolve, 100)) - expect(mockHandleRedirect).not.toHaveBeenCalled() + expect(mockFailOrder).not.toHaveBeenCalled() }) - }) - describe('successful payment', () => { - test('navigates to confirmation page on successful payment', async () => { - mockLocation.search = '?orderNo=12345' - mockHandleRedirect.mockResolvedValue({responseCode: STATUS_SUCCESS}) + test('renders error message for invalid Adyen URL missing type', async () => { + mockLocation.search = '?vendor=Adyen&orderNo=12345&zoneId=default&redirectResult=ABC123' renderWithProviders() + expect( + screen.getByText('There was an unexpected error processing your payment.') + ).toBeInTheDocument() + expect(screen.getByText('Return to Checkout')).toBeInTheDocument() + await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('/checkout/confirmation/12345') + expect(mockFailOrder).toHaveBeenCalledTimes(1) + expect(mockFailOrder).toHaveBeenCalledWith({ + parameters: { + orderNo: '12345', + reopenBasket: true + }, + body: { + reasonCode: 'payment_confirm_failure' + } + }) }) - - expect(mockToast).not.toHaveBeenCalled() }) - test('navigates with correct orderNo from URL', async () => { - mockLocation.search = '?orderNo=67890' - mockHandleRedirect.mockResolvedValue({responseCode: STATUS_SUCCESS}) + test('renders error message for invalid Adyen URL missing zone id', async () => { + mockLocation.search = '?vendor=Adyen&orderNo=12345&type=klarna&redirectResult=ABC123' renderWithProviders() + expect( + screen.getByText('There was an unexpected error processing your payment.') + ).toBeInTheDocument() + expect(screen.getByText('Return to Checkout')).toBeInTheDocument() + await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('/checkout/confirmation/67890') + expect(mockFailOrder).toHaveBeenCalledTimes(1) + expect(mockFailOrder).toHaveBeenCalledWith({ + parameters: { + orderNo: '12345', + reopenBasket: true + }, + body: { + reasonCode: 'payment_confirm_failure' + } + }) }) }) - }) - describe('failed payment', () => { - test('shows error toast on failed payment', async () => { - mockHandleRedirect.mockResolvedValue({responseCode: 1}) + test('renders error message for invalid Adyen URL missing redirect result', async () => { + mockLocation.search = '?vendor=Adyen&orderNo=12345&type=klarna&zoneId=default' renderWithProviders() + expect( + screen.getByText('There was an unexpected error processing your payment.') + ).toBeInTheDocument() + expect(screen.getByText('Return to Checkout')).toBeInTheDocument() + await waitFor(() => { - expect(mockToast).toHaveBeenCalledWith({ - title: expect.stringContaining('unsuccessful'), - status: 'error', - duration: 30000 + expect(mockFailOrder).toHaveBeenCalledTimes(1) + expect(mockFailOrder).toHaveBeenCalledWith({ + parameters: { + orderNo: '12345', + reopenBasket: true + }, + body: { + reasonCode: 'payment_confirm_failure' + } }) }) }) - test('navigates back to checkout on failed payment', async () => { - mockHandleRedirect.mockResolvedValue({responseCode: 1}) + test('error state includes link to checkout page', () => { + mockLocation.search = '' renderWithProviders() - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('/checkout') + const link = screen.getByText('Return to Checkout') + // Check that href contains /checkout (may include locale prefix) + expect(link.closest('a')).toHaveAttribute('href', expect.stringContaining('/checkout')) + }) + }) + + describe('Stripe', () => { + describe('payment processing', () => { + test('calls handleRedirect when sfp is available and orderNo exists', async () => { + mockHandleRedirect.mockResolvedValue({responseCode: STATUS_SUCCESS}) + mockUseSFPayments.mockReturnValue({ + sfp: { + handleRedirect: mockHandleRedirect + } + }) + + renderWithProviders() + + await waitFor(() => { + expect(mockHandleRedirect).toHaveBeenCalledTimes(1) + }) + }) + + test('does not call handleRedirect when orderNo is missing', async () => { + mockLocation.search = '?vendor=Stripe' + + renderWithProviders() + + // Wait a bit to ensure handleRedirect is not called + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(mockHandleRedirect).not.toHaveBeenCalled() + }) + + test('does not call handleRedirect when sfp is not available', async () => { + mockUseSFPayments.mockReturnValue({sfp: null}) + + renderWithProviders() + + // Wait a bit to ensure handleRedirect is not called + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(mockHandleRedirect).not.toHaveBeenCalled() }) }) - test('shows toast before navigating on failed payment', async () => { - mockHandleRedirect.mockResolvedValue({responseCode: 1}) + describe('successful payment', () => { + test('navigates to confirmation page on successful payment', async () => { + mockHandleRedirect.mockResolvedValue({responseCode: STATUS_SUCCESS}) - renderWithProviders() + renderWithProviders() - await waitFor(() => { - expect(mockToast).toHaveBeenCalled() + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/checkout/confirmation/12345') + }) + + expect(mockToast).not.toHaveBeenCalled() + }) + + test('handles orderNo with special characters', async () => { + mockLocation.search = '?vendor=Stripe&orderNo=ORDER-123-ABC' + mockHandleRedirect.mockResolvedValue({responseCode: STATUS_SUCCESS}) + + renderWithProviders() + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith( + '/checkout/confirmation/ORDER-123-ABC' + ) + }) + + expect(mockToast).not.toHaveBeenCalled() }) - expect(mockNavigate).toHaveBeenCalledWith('/checkout') + test('does not call handleRedirect multiple times on re-renders', async () => { + mockHandleRedirect.mockResolvedValue({responseCode: STATUS_SUCCESS}) + + const {rerender} = renderWithProviders() + + await waitFor(() => { + expect(mockHandleRedirect).toHaveBeenCalledTimes(1) + }) + + // Rerender component + rerender() + + // Wait a bit + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Should still only be called once + expect(mockHandleRedirect).toHaveBeenCalledTimes(1) + }) }) - test('handles different error response codes', async () => { - const errorCodes = [1, 2, -1, 999] + describe('failed payment', () => { + test('shows error toast on failed payment', async () => { + mockHandleRedirect.mockResolvedValue({responseCode: 1}) + + renderWithProviders() + + await waitFor(() => { + expect(mockToast).toHaveBeenCalledWith({ + title: expect.stringContaining('unsuccessful'), + status: 'error', + duration: 30000 + }) + }) + }) - for (const code of errorCodes) { - jest.clearAllMocks() - mockHandleRedirect.mockResolvedValue({responseCode: code}) + test('navigates back to checkout on failed payment', async () => { + mockHandleRedirect.mockResolvedValue({responseCode: 1}) renderWithProviders() await waitFor(() => { - expect(mockToast).toHaveBeenCalled() expect(mockNavigate).toHaveBeenCalledWith('/checkout') }) - } + }) + + test('shows toast and calls failOrder before navigating on failed payment', async () => { + mockHandleRedirect.mockResolvedValue({responseCode: 1}) + + renderWithProviders() + + await waitFor(() => { + expect(mockToast).toHaveBeenCalled() + }) + + await waitFor(() => { + expect(mockFailOrder).toHaveBeenCalledTimes(1) + expect(mockFailOrder).toHaveBeenCalledWith({ + parameters: { + orderNo: '12345', + reopenBasket: true + }, + body: { + reasonCode: 'payment_confirm_failure' + } + }) + }) + + expect(mockNavigate).toHaveBeenCalledWith('/checkout') + }) + + test('handles different error response codes', async () => { + const errorCodes = [1, 2, -1, 999] + + for (const code of errorCodes) { + jest.clearAllMocks() + mockHandleRedirect.mockResolvedValue({responseCode: code}) + + renderWithProviders() + + await waitFor(() => { + expect(mockToast).toHaveBeenCalled() + expect(mockNavigate).toHaveBeenCalledWith('/checkout') + }) + } + }) }) }) - describe('edge cases', () => { - test('handles orderNo with special characters', async () => { - mockLocation.search = '?orderNo=ORDER-123-ABC' - mockHandleRedirect.mockResolvedValue({responseCode: STATUS_SUCCESS}) + describe('Adyen', () => { + beforeEach(() => { + mockLocation.search = + '?vendor=Adyen&orderNo=12345&type=klarna&zoneId=default&redirectResult=ABC123' + mockGetSFPaymentsInstrument.mockReturnValue({ + paymentInstrumentId: 'xyz789', + paymentReference: { + gatewayProperties: { + adyen: { + adyenPaymentIntent: { + resultCode: 'AUTHORISED' + } + } + } + } + }) + }) - renderWithProviders() + describe('payment processing', () => { + test('submits redirect result when dependencies are met', async () => { + renderWithProviders() - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('/checkout/confirmation/ORDER-123-ABC') + await waitFor(() => { + expect(mockGetSFPaymentsInstrument).toHaveBeenCalledTimes(2) + expect(mockUpdatePaymentInstrumentForOrder).toHaveBeenCalledTimes(1) + expect(mockUpdatePaymentInstrumentForOrder).toHaveBeenCalledWith({ + parameters: { + orderNo: '12345', + paymentInstrumentId: 'xyz789' + }, + body: { + paymentMethodId: 'Salesforce Payments', + paymentReferenceRequest: { + paymentMethodType: 'klarna', + zoneId: 'default', + gateway: 'adyen', + gatewayProperties: { + adyen: { + redirectResult: 'ABC123' + } + } + } + } + }) + }) }) }) - test('handles multiple query parameters', async () => { - mockLocation.search = '?orderNo=12345&other=value&foo=bar' - mockHandleRedirect.mockResolvedValue({responseCode: STATUS_SUCCESS}) + describe('successful payment', () => { + test('navigates to confirmation page on successful payment', async () => { + renderWithProviders() - renderWithProviders() + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/checkout/confirmation/12345') + }) - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('/checkout/confirmation/12345') + expect(mockToast).not.toHaveBeenCalled() }) - }) - test('handles empty orderNo parameter', async () => { - mockLocation.search = '?orderNo=' + test('does not call updatePaymentInstrumentForOrder multiple times on re-renders', async () => { + const {rerender} = renderWithProviders() - renderWithProviders() + await waitFor(() => { + expect(mockUpdatePaymentInstrumentForOrder).toHaveBeenCalledTimes(1) + }) - // Empty string is falsy, so it's treated as missing orderNo - // But the param exists, so isError should be false and we see the working message - expect(screen.getByText('Working on your payment...')).toBeInTheDocument() + // Rerender component + rerender() + + // Wait a bit + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Should still only be called once + expect(mockUpdatePaymentInstrumentForOrder).toHaveBeenCalledTimes(1) + }) }) - test('does not call handleRedirect multiple times on re-renders', async () => { - mockHandleRedirect.mockResolvedValue({responseCode: STATUS_SUCCESS}) + describe('failed payment', () => { + beforeEach(() => { + mockGetSFPaymentsInstrument.mockReturnValue({ + paymentInstrumentId: 'xyz789', + paymentReference: { + gatewayProperties: { + adyen: { + resultCode: 'ERROR' + } + } + } + }) + }) - const {rerender} = renderWithProviders() + test('shows error toast on failed payment', async () => { + renderWithProviders() - await waitFor(() => { - expect(mockHandleRedirect).toHaveBeenCalledTimes(1) + await waitFor(() => { + expect(mockToast).toHaveBeenCalledWith({ + title: expect.stringContaining('unsuccessful'), + status: 'error', + duration: 30000 + }) + }) }) - // Rerender component - rerender() + test('navigates back to checkout on failed payment', async () => { + renderWithProviders() - // Wait a bit - await new Promise((resolve) => setTimeout(resolve, 100)) + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/checkout') + }) + }) + + test('shows toast and calls failOrder before navigating on failed payment', async () => { + renderWithProviders() + + await waitFor(() => { + expect(mockToast).toHaveBeenCalled() + }) + + await waitFor(() => { + expect(mockFailOrder).toHaveBeenCalledTimes(1) + expect(mockFailOrder).toHaveBeenCalledWith({ + parameters: { + orderNo: '12345', + reopenBasket: true + }, + body: { + reasonCode: 'payment_confirm_failure' + } + }) + }) - // Should still only be called once - expect(mockHandleRedirect).toHaveBeenCalledTimes(1) + expect(mockNavigate).toHaveBeenCalledWith('/checkout') + }) }) }) diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index b33fe10def..7c17c12d86 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -327,6 +327,7 @@ const {handler} = runtime.createHandler(options, (app) => { // Payment gateways '*.stripe.com', '*.paypal.com', + '*.adyen.com', // TODO: Used to load a valid sfp.js '*.demandware.net' ], @@ -337,6 +338,8 @@ const {handler} = runtime.createHandler(options, (app) => { '*.c360a.salesforce.com', // Connect to SCRT2 URLs '*.salesforce-scrt.com', + // Payment gateways + '*.adyen.com', // TODO: Used to load metadata '*.demandware.net' ], @@ -345,7 +348,8 @@ const {handler} = runtime.createHandler(options, (app) => { '*.site.com', // Payment gateways '*.stripe.com', - '*.paypal.com' + '*.paypal.com', + '*.adyen.com' ] } } diff --git a/packages/template-retail-react-app/app/utils/sf-payments-utils.js b/packages/template-retail-react-app/app/utils/sf-payments-utils.js index 6b0eb21e2d..40991c1f55 100644 --- a/packages/template-retail-react-app/app/utils/sf-payments-utils.js +++ b/packages/template-retail-react-app/app/utils/sf-payments-utils.js @@ -181,10 +181,6 @@ export const getGatewayFromPaymentMethod = ( paymentMethods, paymentMethodSetAccounts ) => { - if (isPayPalPaymentMethodType(paymentMethodType)) { - return null - } - const account = findPaymentAccount(paymentMethods, paymentMethodSetAccounts, paymentMethodType) if (!account) { return null @@ -195,6 +191,8 @@ export const getGatewayFromPaymentMethod = ( return PAYMENT_GATEWAYS.STRIPE } else if (vendor === PAYMENT_GATEWAYS.ADYEN) { return PAYMENT_GATEWAYS.ADYEN + } else if (vendor === PAYMENT_GATEWAYS.PAYPAL) { + return PAYMENT_GATEWAYS.PAYPAL } return null @@ -222,6 +220,7 @@ export const getSetupFutureUsage = (storePaymentMethod, futureUsageOffSession) = * @param {string} params.paymentMethodType - Type of payment method (e.g., 'card', 'paypal', 'venmo') * @param {string} params.zoneId - Zone ID for payment processing * @param {string} [params.shippingPreference] - Optional shipping preference for PayPal payment processing + * @param {string} [params.paymentData] - Optional Adyen client payment data object * @param {boolean} [params.storePaymentMethod=false] - Optional flag to save payment method for future use * @param {boolean} [params.futureUsageOffSession=false] - Optional flag indicating if off-session future usage is enabled (from payment config) * @param {Array} [params.paymentMethods] - Optional array of payment methods to determine gateway @@ -234,6 +233,7 @@ export const createPaymentInstrumentBody = ({ paymentMethodType, zoneId, shippingPreference, + paymentData = null, storePaymentMethod = false, futureUsageOffSession = false, paymentMethods = null, @@ -245,28 +245,56 @@ export const createPaymentInstrumentBody = ({ zoneId: zoneId ?? 'default' } - if (shippingPreference !== undefined && shippingPreference !== null) { - paymentReferenceRequest.shippingPreference = shippingPreference - } - const gateway = getGatewayFromPaymentMethod( paymentMethodType, paymentMethods, paymentMethodSetAccounts ) + if ( + gateway === PAYMENT_GATEWAYS.PAYPAL && + shippingPreference !== undefined && + shippingPreference !== null + ) { + paymentReferenceRequest.gateway = PAYMENT_GATEWAYS.PAYPAL + paymentReferenceRequest.gatewayProperties = { + paypal: { + shippingPreference + } + } + } + if (!isPostRequest && gateway === PAYMENT_GATEWAYS.STRIPE && storePaymentMethod) { const setupFutureUsage = getSetupFutureUsage(storePaymentMethod, futureUsageOffSession) if (setupFutureUsage) { paymentReferenceRequest.gateway = PAYMENT_GATEWAYS.STRIPE paymentReferenceRequest.gatewayProperties = { stripe: { - setupFutureUsage: setupFutureUsage + setupFutureUsage } } } } + if (!isPostRequest && gateway === PAYMENT_GATEWAYS.ADYEN) { + // Create Adyen payment reference request + paymentReferenceRequest.gateway = PAYMENT_GATEWAYS.ADYEN + paymentReferenceRequest.gatewayProperties = { + adyen: { + ...(paymentData && { + paymentMethod: paymentData.paymentMethod, + returnUrl: paymentData.returnUrl, + origin: paymentData.origin, + lineItems: paymentData.lineItems, + billingDetails: paymentData.billingDetails + }), + ...(storePaymentMethod === true && { + storePaymentMethod: true + }) + } + } + } + return { paymentMethodId: 'Salesforce Payments', amount: amount, diff --git a/packages/template-retail-react-app/app/utils/sf-payments-utils.test.js b/packages/template-retail-react-app/app/utils/sf-payments-utils.test.js index 3135305319..915f7b44ba 100644 --- a/packages/template-retail-react-app/app/utils/sf-payments-utils.test.js +++ b/packages/template-retail-react-app/app/utils/sf-payments-utils.test.js @@ -1182,15 +1182,22 @@ describe('sf-payments-utils', () => { expect(result.amount).toBe(0) }) - test('includes shippingPreference when provided', () => { + test('includes shippingPreference when provided for PayPal', () => { + const paymentMethods = [{paymentMethodType: 'paypal', accountId: 'paypal_acct_123'}] + const paymentMethodSetAccounts = [{vendor: 'Paypal', accountId: 'paypal_acct_123'}] const result = createPaymentInstrumentBody({ amount: 100.0, paymentMethodType: 'paypal', zoneId: 'us-west-1', - shippingPreference: 'GET_FROM_FILE' + shippingPreference: 'GET_FROM_FILE', + paymentMethods, + paymentMethodSetAccounts }) - expect(result.paymentReferenceRequest.shippingPreference).toBe('GET_FROM_FILE') + expect(result.paymentReferenceRequest.gateway).toBe('paypal') + expect(result.paymentReferenceRequest.gatewayProperties.paypal).toEqual({ + shippingPreference: 'GET_FROM_FILE' + }) }) test('includes gateway and gatewayProperties.stripe.setup_future_usage when storePaymentMethod is true', () => { @@ -1209,9 +1216,9 @@ describe('sf-payments-utils', () => { // Both gateway and gatewayProperties should be included (verified format with backend) expect(result.paymentReferenceRequest.gateway).toBe('stripe') - expect(result.paymentReferenceRequest.gatewayProperties.stripe.setupFutureUsage).toBe( - 'on_session' - ) + expect(result.paymentReferenceRequest.gatewayProperties.stripe).toEqual({ + setupFutureUsage: 'on_session' + }) }) test('includes gateway and gatewayProperties.stripe.setup_future_usage as off_session when futureUsageOffSession is true', () => { @@ -1230,9 +1237,9 @@ describe('sf-payments-utils', () => { // Both gateway and gatewayProperties should be included (verified format with backend) expect(result.paymentReferenceRequest.gateway).toBe('stripe') - expect(result.paymentReferenceRequest.gatewayProperties.stripe.setupFutureUsage).toBe( - 'off_session' - ) + expect(result.paymentReferenceRequest.gatewayProperties.stripe).toEqual({ + setupFutureUsage: 'off_session' + }) }) test('does not include gatewayProperties when storePaymentMethod is false and futureUsageOffSession is false', () => { @@ -1285,9 +1292,9 @@ describe('sf-payments-utils', () => { }) describe('getGatewayFromPaymentMethod', () => { - test('returns null for PayPal payment method type', () => { + test('returns Paypal for PayPal gateway', () => { const paymentMethods = [{paymentMethodType: 'paypal', accountId: 'paypal_acct'}] - const paymentMethodSetAccounts = [{vendor: 'PayPal', accountId: 'paypal_acct'}] + const paymentMethodSetAccounts = [{vendor: 'Paypal', accountId: 'paypal_acct'}] const result = getGatewayFromPaymentMethod( 'paypal', @@ -1295,20 +1302,7 @@ describe('sf-payments-utils', () => { paymentMethodSetAccounts ) - expect(result).toBeNull() - }) - - test('returns null for Venmo payment method type', () => { - const paymentMethods = [{paymentMethodType: 'venmo', accountId: 'venmo_acct'}] - const paymentMethodSetAccounts = [{vendor: 'PayPal', accountId: 'venmo_acct'}] - - const result = getGatewayFromPaymentMethod( - 'venmo', - paymentMethods, - paymentMethodSetAccounts - ) - - expect(result).toBeNull() + expect(result).toBe('paypal') }) test('returns Stripe for Stripe gateway', () => {