Skip to content

Commit f21da03

Browse files
committed
Merge pull request #3603 from SalesforceCommerceCloud/rvishwanathbhat/display-spms-at-checkout
W-20975340, W-21013345: SPM at checkout
2 parents dfb56cd + eee36b8 commit f21da03

File tree

5 files changed

+614
-74
lines changed

5 files changed

+614
-74
lines changed

packages/template-retail-react-app/app/hooks/use-current-customer.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,16 @@ import {useCustomer, useCustomerId, useCustomerType} from '@salesforce/commerce-
99

1010
/**
1111
* A hook that returns the current customer.
12-
*
12+
* @param {Array<string>} [expand] - Optional array of fields to expand in the customer query
1313
*/
14-
export const useCurrentCustomer = () => {
14+
export const useCurrentCustomer = (expand) => {
1515
const customerId = useCustomerId()
1616
const {isRegistered, isGuest, customerType} = useCustomerType()
17-
const query = useCustomer({parameters: {customerId}}, {enabled: !!customerId && isRegistered})
17+
const parameters = {
18+
customerId,
19+
...(expand && {expand})
20+
}
21+
const query = useCustomer({parameters}, {enabled: !!customerId && isRegistered})
1822
const value = {
1923
...query,
2024
data: {

packages/template-retail-react-app/app/pages/checkout/partials/sf-payments-sheet.jsx

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ import {
4848
buildTheme,
4949
getSFPaymentsInstrument,
5050
createPaymentInstrumentBody,
51-
getClientSecret
51+
transformPaymentMethodReferences
5252
} from '@salesforce/retail-react-app/app/utils/sf-payments-utils'
5353
import logger from '@salesforce/retail-react-app/app/utils/logger-instance'
5454

@@ -60,7 +60,7 @@ const SFPaymentsSheet = forwardRef((props, ref) => {
6060
const navigate = useNavigation()
6161

6262
const {data: basket} = useCurrentBasket()
63-
const {data: customer} = useCurrentCustomer()
63+
const {data: customer} = useCurrentCustomer(['paymentmethodreferences'])
6464

6565
const isPickupOnly =
6666
basket?.shipments?.length > 0 &&
@@ -358,11 +358,20 @@ const SFPaymentsSheet = forwardRef((props, ref) => {
358358

359359
// Track created payment intent
360360
const paymentIntent = {
361-
client_secret: getClientSecret(orderPaymentInstrument),
361+
client_secret:
362+
orderPaymentInstrument?.paymentReference?.gatewayProperties?.stripe
363+
?.clientSecret,
362364
id: orderPaymentInstrument.paymentReference.paymentReferenceId
363365
}
364366

365-
if (futureUsageOffSession) {
367+
// Read setup_future_usage from backend response, fallback to manual calculation if not available
368+
// TODO: The fallback is temporary that's to be removed in next iteration.
369+
const setupFutureUsage =
370+
orderPaymentInstrument?.paymentReference?.gatewayProperties?.stripe
371+
?.setup_future_usage
372+
if (setupFutureUsage) {
373+
paymentIntent.setup_future_usage = setupFutureUsage
374+
} else if (futureUsageOffSession) {
366375
paymentIntent.setup_future_usage = 'off_session'
367376
} else if (shouldSavePaymentMethod) {
368377
paymentIntent.setup_future_usage = 'on_session'
@@ -405,22 +414,9 @@ const SFPaymentsSheet = forwardRef((props, ref) => {
405414
// Update the redirect return URL to include the related order no
406415
config.current.options.returnUrl += '?orderNo=' + updatedOrder.orderNo
407416

408-
// Update Elements to match Payment Intent's setup_future_usage before SDK confirms
409-
const paymentIntentFunction = async () => {
410-
const selectedPaymentMethod = checkoutComponent.current?.selectedPaymentMethod
411-
if (selectedPaymentMethod?.asSavedPaymentMethodComponent) {
412-
const spmComponent = selectedPaymentMethod.asSavedPaymentMethodComponent()
413-
if (spmComponent) {
414-
spmComponent.setSavePaymentMethodFuture(futureUsageOffSession)
415-
}
416-
}
417-
418-
return paymentIntent
419-
}
420-
421417
// Confirm the payment
422418
const result = await checkoutComponent.current.confirm(
423-
paymentIntentFunction,
419+
async () => paymentIntent,
424420
billingDetails,
425421
shippingDetails
426422
)
@@ -486,11 +482,23 @@ const SFPaymentsSheet = forwardRef((props, ref) => {
486482
confirmPayment
487483
}))
488484

485+
const savedPaymentMethods = useMemo(
486+
() => transformPaymentMethodReferences(customer, paymentConfig),
487+
[customer, paymentConfig]
488+
)
489+
489490
useEffect(() => {
490491
if (sfp && metadata && containerElementRef.current && paymentConfig) {
492+
const paymentMethodSetAccounts = (paymentConfig.paymentMethodSetAccounts || []).map(
493+
(account) => ({
494+
...account,
495+
gatewayId: account.accountId
496+
})
497+
)
498+
491499
const paymentMethodSet = {
492500
paymentMethods: paymentConfig.paymentMethods,
493-
paymentMethodSetAccounts: paymentConfig.paymentMethodSetAccounts
501+
paymentMethodSetAccounts: paymentMethodSetAccounts
494502
}
495503

496504
config.current = {
@@ -506,7 +514,8 @@ const SFPaymentsSheet = forwardRef((props, ref) => {
506514
customer?.isRegistered && customer?.customerId
507515
),
508516
// Suppress "Make payment method default" checkbox since we don't support default SPM yet
509-
showSaveAsDefaultCheckbox: false
517+
showSaveAsDefaultCheckbox: false,
518+
savedPaymentMethods: savedPaymentMethods
510519
}
511520
}
512521

@@ -549,7 +558,9 @@ const SFPaymentsSheet = forwardRef((props, ref) => {
549558
containerElementRef.current,
550559
paymentConfig,
551560
cardCaptureAutomatic,
552-
customer?.isRegistered
561+
customer?.isRegistered,
562+
customer?.customerId,
563+
savedPaymentMethods
553564
])
554565

555566
useEffect(() => {

packages/template-retail-react-app/app/pages/checkout/partials/sf-payments-sheet.test.js

Lines changed: 204 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,14 @@ jest.mock('@salesforce/commerce-sdk-react', () => {
101101
defaultShippingMethodId: 'DefaultShippingMethod'
102102
},
103103
refetch: mockRefetchShippingMethods
104-
})
104+
}),
105+
useCustomerId: () => 'customer123',
106+
useCustomerType: () => ({
107+
isRegistered: true,
108+
isGuest: false,
109+
customerType: 'registered'
110+
}),
111+
useCustomer: jest.fn()
105112
}
106113
})
107114

@@ -139,15 +146,21 @@ jest.mock('@salesforce/retail-react-app/app/hooks/use-current-basket', () => ({
139146
useCurrentBasket: () => mockUseCurrentBasket()
140147
}))
141148

142-
jest.mock('@salesforce/retail-react-app/app/hooks/use-current-customer', () => ({
143-
useCurrentCustomer: () => ({
144-
data: {
145-
customerId: 'customer123',
146-
isGuest: false,
147-
isRegistered: true,
148-
email: 'test@example.com'
149-
}
150-
})
149+
const mockCustomer = {
150+
customerId: 'customer123',
151+
isGuest: false,
152+
isRegistered: true,
153+
email: 'test@example.com',
154+
paymentMethodReferences: []
155+
}
156+
157+
// Get the mocked useCustomer from commerce-sdk-react
158+
// eslint-disable-next-line @typescript-eslint/no-var-requires
159+
const mockUseCustomer = require('@salesforce/commerce-sdk-react').useCustomer
160+
161+
// Set default implementation
162+
mockUseCustomer.mockImplementation(() => ({
163+
data: mockCustomer
151164
}))
152165

153166
jest.mock('@salesforce/retail-react-app/app/hooks/use-einstein', () => {
@@ -995,6 +1008,187 @@ describe('SFPaymentsSheet', () => {
9951008

9961009
expect(paymentIntent.setup_future_usage).toBeUndefined()
9971010
})
1011+
1012+
test('confirmPayment sets setup_future_usage to off_session when futureUsageOffSession is true', async () => {
1013+
const ref = React.createRef()
1014+
setupConfirmPaymentMocks()
1015+
1016+
// eslint-disable-next-line @typescript-eslint/no-var-requires
1017+
const useShopperConfigurationModule = require('@salesforce/retail-react-app/app/hooks/use-shopper-configuration')
1018+
const originalMock = useShopperConfigurationModule.useShopperConfiguration
1019+
1020+
useShopperConfigurationModule.useShopperConfiguration = jest.fn((configId) => {
1021+
if (configId === 'futureUsageOffSession') return true
1022+
if (configId === 'cardCaptureAutomatic') return true
1023+
if (configId === 'zoneId') return 'default'
1024+
return undefined
1025+
})
1026+
1027+
renderWithCheckoutContext(
1028+
<SFPaymentsSheet
1029+
ref={ref}
1030+
onCreateOrder={mockOnCreateOrder}
1031+
onError={mockOnError}
1032+
/>
1033+
)
1034+
1035+
await waitFor(() => {
1036+
expect(ref.current).toBeDefined()
1037+
})
1038+
1039+
await waitFor(() => {
1040+
expect(mockCheckout).toHaveBeenCalled()
1041+
})
1042+
1043+
const paymentElement = mockCheckout.mock.calls[0][4]
1044+
1045+
await act(async () => {
1046+
paymentElement.dispatchEvent(
1047+
new CustomEvent('sfp:paymentmethodselected', {
1048+
bubbles: true,
1049+
composed: true,
1050+
detail: {
1051+
selectedPaymentMethod: 'card',
1052+
savePaymentMethodForFutureUse: true
1053+
}
1054+
})
1055+
)
1056+
})
1057+
1058+
await ref.current.confirmPayment()
1059+
1060+
await waitFor(() => {
1061+
expect(mockCheckoutConfirm).toHaveBeenCalled()
1062+
})
1063+
1064+
const confirmCall = mockCheckoutConfirm.mock.calls[0]
1065+
const paymentIntentFunction = confirmCall[0]
1066+
const paymentIntent = await paymentIntentFunction()
1067+
1068+
expect(paymentIntent.setup_future_usage).toBe('off_session')
1069+
1070+
useShopperConfigurationModule.useShopperConfiguration = originalMock
1071+
})
1072+
})
1073+
1074+
describe('SPM (Saved Payment Methods) Display', () => {
1075+
beforeEach(() => {
1076+
jest.clearAllMocks()
1077+
mockCustomer.paymentMethodReferences = []
1078+
mockUseCustomer.mockImplementation(() => ({
1079+
data: {...mockCustomer}
1080+
}))
1081+
})
1082+
1083+
test('passes empty savedPaymentMethods to SDK when customer has no payment method references', async () => {
1084+
mockCustomer.paymentMethodReferences = []
1085+
1086+
renderWithCheckoutContext(
1087+
<SFPaymentsSheet
1088+
ref={React.createRef()}
1089+
onCreateOrder={mockOnCreateOrder}
1090+
onError={mockOnError}
1091+
/>
1092+
)
1093+
1094+
await waitFor(() => {
1095+
expect(mockCheckout).toHaveBeenCalled()
1096+
})
1097+
1098+
const checkoutCall = mockCheckout.mock.calls[0]
1099+
const config = checkoutCall[2]
1100+
1101+
expect(config.options.savedPaymentMethods).toEqual([])
1102+
})
1103+
1104+
test('passes empty savedPaymentMethods to SDK when paymentMethodReferences is null', async () => {
1105+
mockCustomer.paymentMethodReferences = null
1106+
1107+
renderWithCheckoutContext(
1108+
<SFPaymentsSheet
1109+
ref={React.createRef()}
1110+
onCreateOrder={mockOnCreateOrder}
1111+
onError={mockOnError}
1112+
/>
1113+
)
1114+
1115+
await waitFor(() => {
1116+
expect(mockCheckout).toHaveBeenCalled()
1117+
})
1118+
1119+
const checkoutCall = mockCheckout.mock.calls[0]
1120+
const config = checkoutCall[2]
1121+
1122+
expect(config.options.savedPaymentMethods).toEqual([])
1123+
})
1124+
1125+
test('passes empty savedPaymentMethods to SDK when paymentMethodReferences is undefined', async () => {
1126+
mockCustomer.paymentMethodReferences = undefined
1127+
1128+
renderWithCheckoutContext(
1129+
<SFPaymentsSheet
1130+
ref={React.createRef()}
1131+
onCreateOrder={mockOnCreateOrder}
1132+
onError={mockOnError}
1133+
/>
1134+
)
1135+
1136+
await waitFor(() => {
1137+
expect(mockCheckout).toHaveBeenCalled()
1138+
})
1139+
1140+
const checkoutCall = mockCheckout.mock.calls[0]
1141+
const config = checkoutCall[2]
1142+
1143+
expect(config.options.savedPaymentMethods).toEqual([])
1144+
})
1145+
1146+
test('passes empty savedPaymentMethods to SDK when paymentMethodSetAccounts is missing', async () => {
1147+
mockCustomer.paymentMethodReferences = [
1148+
{
1149+
id: 'pm_123',
1150+
accountId: 'stripe-account-1',
1151+
type: 'card',
1152+
brand: 'visa',
1153+
last4: '4242'
1154+
}
1155+
]
1156+
1157+
jest.spyOn(
1158+
// eslint-disable-next-line @typescript-eslint/no-var-requires
1159+
require('@salesforce/commerce-sdk-react'),
1160+
'usePaymentConfiguration'
1161+
).mockReturnValue({
1162+
data: {
1163+
paymentMethods: [
1164+
{
1165+
id: 'card',
1166+
name: 'Card',
1167+
paymentMethodType: 'card',
1168+
accountId: 'stripe-account-1'
1169+
}
1170+
],
1171+
paymentMethodSetAccounts: null
1172+
}
1173+
})
1174+
1175+
renderWithCheckoutContext(
1176+
<SFPaymentsSheet
1177+
ref={React.createRef()}
1178+
onCreateOrder={mockOnCreateOrder}
1179+
onError={mockOnError}
1180+
/>
1181+
)
1182+
1183+
await waitFor(() => {
1184+
expect(mockCheckout).toHaveBeenCalled()
1185+
})
1186+
1187+
const checkoutCall = mockCheckout.mock.calls[0]
1188+
const config = checkoutCall[2]
1189+
1190+
expect(config.options.savedPaymentMethods).toEqual([])
1191+
})
9981192
})
9991193

10001194
describe('lifecycle', () => {

0 commit comments

Comments
 (0)