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', () => {