Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,15 @@ import {useCustomer, useCustomerId, useCustomerType} from '@salesforce/commerce-
export const useCurrentCustomer = () => {
const customerId = useCustomerId()
const {isRegistered, isGuest, customerType} = useCustomerType()
const query = useCustomer({parameters: {customerId}}, {enabled: !!customerId && isRegistered})
const query = useCustomer(
{
parameters: {
customerId,
expand: ['paymentmethodreferences']
}
},
{enabled: !!customerId && isRegistered}
)
const value = {
...query,
data: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
buildTheme,
getSFPaymentsInstrument,
createPaymentInstrumentBody,
transformPaymentMethodReferences,
getClientSecret
} from '@salesforce/retail-react-app/app/utils/sf-payments-utils'
import logger from '@salesforce/retail-react-app/app/utils/logger-instance'
Expand Down Expand Up @@ -486,27 +487,44 @@ const SFPaymentsSheet = forwardRef((props, ref) => {
confirmPayment
}))

const savedPaymentMethods = useMemo(() => {
if (!customer?.paymentMethodReferences || !paymentConfig?.paymentMethodSetAccounts) {
return []
}
return transformPaymentMethodReferences(
customer.paymentMethodReferences,
paymentConfig.paymentMethodSetAccounts
)
}, [customer?.paymentMethodReferences, paymentConfig?.paymentMethodSetAccounts])

useEffect(() => {
if (sfp && metadata && containerElementRef.current && paymentConfig) {
const paymentMethodSetAccounts = (paymentConfig.paymentMethodSetAccounts || []).map(
(account) => ({
...account,
gatewayId: account.gatewayId || account.accountId
})
)

const paymentMethodSet = {
paymentMethods: paymentConfig.paymentMethods,
paymentMethodSetAccounts: paymentConfig.paymentMethodSetAccounts
paymentMethodSetAccounts: paymentMethodSetAccounts
}

config.current = {
theme: buildTheme(),
actions: {
createIntent: createPaymentInstrument,
onClick: () => {} // No-op: return empty function since its not applicable and SDK proceeds immediately
onClick: () => {}
},
options: {
useManualCapture: !cardCaptureAutomatic,
returnUrl: `${window.location.protocol}//${window.location.host}/checkout/payment-processing`,
showSaveForFutureUsageCheckbox: !!(
customer?.isRegistered && customer?.customerId
),
// Suppress "Make payment method default" checkbox since we don't support default SPM yet
showSaveAsDefaultCheckbox: false
showSaveAsDefaultCheckbox: false,
savedPaymentMethods: savedPaymentMethods
}
}

Expand Down Expand Up @@ -549,7 +567,9 @@ const SFPaymentsSheet = forwardRef((props, ref) => {
containerElementRef.current,
paymentConfig,
cardCaptureAutomatic,
customer?.isRegistered
customer?.isRegistered,
customer?.customerId,
savedPaymentMethods
])

useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,16 +139,24 @@ jest.mock('@salesforce/retail-react-app/app/hooks/use-current-basket', () => ({
useCurrentBasket: () => mockUseCurrentBasket()
}))

jest.mock('@salesforce/retail-react-app/app/hooks/use-current-customer', () => ({
useCurrentCustomer: () => ({
data: {
customerId: 'customer123',
isGuest: false,
isRegistered: true,
email: 'test@example.com'
}
})
}))
const mockCustomer = {
customerId: 'customer123',
isGuest: false,
isRegistered: true,
email: 'test@example.com',
paymentMethodReferences: []
}

let mockUseCurrentCustomer

jest.mock('@salesforce/retail-react-app/app/hooks/use-current-customer', () => {
mockUseCurrentCustomer = jest.fn(() => ({
data: mockCustomer
}))
return {
useCurrentCustomer: mockUseCurrentCustomer
}
})

jest.mock('@salesforce/retail-react-app/app/hooks/use-einstein', () => {
return jest.fn(() => ({
Expand Down Expand Up @@ -995,6 +1003,186 @@ describe('SFPaymentsSheet', () => {

expect(paymentIntent.setup_future_usage).toBeUndefined()
})

test('confirmPayment sets setup_future_usage to off_session when futureUsageOffSession is true', async () => {
const ref = React.createRef()
setupConfirmPaymentMocks()

const useShopperConfigurationModule = require('@salesforce/retail-react-app/app/hooks/use-shopper-configuration')
const originalMock = useShopperConfigurationModule.useShopperConfiguration

useShopperConfigurationModule.useShopperConfiguration = jest.fn((configId) => {
if (configId === 'futureUsageOffSession') return true
if (configId === 'cardCaptureAutomatic') return true
if (configId === 'zoneId') return 'default'
return undefined
})

renderWithCheckoutContext(
<SFPaymentsSheet
ref={ref}
onCreateOrder={mockOnCreateOrder}
onError={mockOnError}
/>
)

await waitFor(() => {
expect(ref.current).toBeDefined()
})

await waitFor(() => {
expect(mockCheckout).toHaveBeenCalled()
})

const paymentElement = mockCheckout.mock.calls[0][4]

await act(async () => {
paymentElement.dispatchEvent(
new CustomEvent('sfp:paymentmethodselected', {
bubbles: true,
composed: true,
detail: {
selectedPaymentMethod: 'card',
savePaymentMethodForFutureUse: true
}
})
)
})

await ref.current.confirmPayment()

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

useShopperConfigurationModule.useShopperConfiguration = originalMock
})
})

describe('SPM (Saved Payment Methods) Display', () => {
beforeEach(() => {
jest.clearAllMocks()
mockCustomer.paymentMethodReferences = []
mockUseCurrentCustomer.mockImplementation(() => ({
data: {...mockCustomer}
}))
})

test('passes empty savedPaymentMethods to SDK when customer has no payment method references', async () => {
mockCustomer.paymentMethodReferences = []

renderWithCheckoutContext(
<SFPaymentsSheet
ref={React.createRef()}
onCreateOrder={mockOnCreateOrder}
onError={mockOnError}
/>
)

await waitFor(() => {
expect(mockCheckout).toHaveBeenCalled()
})

const checkoutCall = mockCheckout.mock.calls[0]
const config = checkoutCall[2]

expect(config.options.savedPaymentMethods).toEqual([])
})

test('passes empty savedPaymentMethods to SDK when paymentMethodReferences is null', async () => {
mockCustomer.paymentMethodReferences = null

renderWithCheckoutContext(
<SFPaymentsSheet
ref={React.createRef()}
onCreateOrder={mockOnCreateOrder}
onError={mockOnError}
/>
)

await waitFor(() => {
expect(mockCheckout).toHaveBeenCalled()
})

const checkoutCall = mockCheckout.mock.calls[0]
const config = checkoutCall[2]

expect(config.options.savedPaymentMethods).toEqual([])
})

test('passes empty savedPaymentMethods to SDK when paymentMethodReferences is undefined', async () => {
mockCustomer.paymentMethodReferences = undefined

renderWithCheckoutContext(
<SFPaymentsSheet
ref={React.createRef()}
onCreateOrder={mockOnCreateOrder}
onError={mockOnError}
/>
)

await waitFor(() => {
expect(mockCheckout).toHaveBeenCalled()
})

const checkoutCall = mockCheckout.mock.calls[0]
const config = checkoutCall[2]

expect(config.options.savedPaymentMethods).toEqual([])
})

test('passes empty savedPaymentMethods to SDK when paymentMethodSetAccounts is missing', async () => {
mockCustomer.paymentMethodReferences = [
{
id: 'pm_123',
accountId: 'stripe-account-1',
type: 'card',
brand: 'visa',
last4: '4242'
}
]

jest.spyOn(
require('@salesforce/commerce-sdk-react'),
'usePaymentConfiguration'
).mockReturnValue({
data: {
paymentMethods: [
{
id: 'card',
name: 'Card',
paymentMethodType: 'card',
accountId: 'stripe-account-1'
}
],
paymentMethodSetAccounts: null
}
})

renderWithCheckoutContext(
<SFPaymentsSheet
ref={React.createRef()}
onCreateOrder={mockOnCreateOrder}
onError={mockOnError}
/>
)

await waitFor(() => {
expect(mockCheckout).toHaveBeenCalled()
})

const checkoutCall = mockCheckout.mock.calls[0]
const config = checkoutCall[2]

expect(config.options.savedPaymentMethods).toEqual([])
})

})

describe('lifecycle', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,82 @@ export const createPaymentInstrumentBody = ({
}
}

/**
* Transforms payment method references from API format to SF Payments SDK format.
* @param {Array} paymentMethodReferences - Array of payment method references
* @param {Array} paymentMethodSetAccounts - Array of payment method set accounts
* @returns {Array} Transformed payment method references for SF Payments SDK
*/
export const transformPaymentMethodReferences = (
paymentMethodReferences,
paymentMethodSetAccounts = []
) => {
if (!paymentMethodReferences || !Array.isArray(paymentMethodReferences)) {
return []
}

return paymentMethodReferences
.map((pmr) => {
const generateDisplayName = () => {
if (pmr.brand && pmr.last4) {
const brandName = pmr.brand.charAt(0).toUpperCase() + pmr.brand.slice(1)
return `${brandName} •••• ${pmr.last4}`
}
if (pmr.type === 'card' && pmr.last4) {
return `Card •••• ${pmr.last4}`
}
if (pmr.type === 'sepa_debit' && pmr.last4) {
return `Account ending in ${pmr.last4}`
}
return 'Saved Payment Method'
}

// Determine gatewayId for SDK matching
if (
!pmr.accountId ||
!paymentMethodSetAccounts ||
!Array.isArray(paymentMethodSetAccounts)
) {
return null
}

const matchingAccount = paymentMethodSetAccounts.find(
(account) => account.accountId === pmr.accountId
)
if (!matchingAccount) {
return null
}

const gatewayId = matchingAccount.gatewayId || matchingAccount.accountId

if (!gatewayId || typeof gatewayId !== 'string') {
return null
}

return {
accountId: pmr.accountId || null,
name: generateDisplayName(),
status: 'Active',
isDefault: false,
type: pmr.type || null,
accountHolderName: null,
id: pmr.id || null,
gatewayTokenId: pmr.id || null,
usageType: 'OffSession',
gatewayId: gatewayId,
gatewayCustomerId: null,
last4: pmr.last4 || null,
network: pmr.brand || null,
issuer: null,
expiryMonth: null,
expiryYear: null,
bankName: null,
savedByMerchant: false
}
})
.filter((spm) => spm !== null)
}

/**
* Returns a theme object containing CSS information for use with SF Payments components.
* @param {*} options - theme override options
Expand Down
Loading