diff --git a/packages/commerce-sdk-react/CHANGELOG.md b/packages/commerce-sdk-react/CHANGELOG.md index b0523d6f60..14fca63a17 100644 --- a/packages/commerce-sdk-react/CHANGELOG.md +++ b/packages/commerce-sdk-react/CHANGELOG.md @@ -1,4 +1,5 @@ ## v5.1.0-dev +- Bump commerce-sdk-isomorphic to 5.1.0 - Add Node 24 support. Drop Node 16 support. [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652) - Add Shopper Consents API support [#3674](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3674) diff --git a/packages/commerce-sdk-react/package-lock.json b/packages/commerce-sdk-react/package-lock.json index 4495c35674..6224885922 100644 --- a/packages/commerce-sdk-react/package-lock.json +++ b/packages/commerce-sdk-react/package-lock.json @@ -9,7 +9,7 @@ "version": "5.1.0-dev", "license": "See license in LICENSE", "dependencies": { - "commerce-sdk-isomorphic": "5.0.0", + "commerce-sdk-isomorphic": "5.1.0", "js-cookie": "^3.0.1", "jwt-decode": "^4.0.0" }, @@ -920,9 +920,9 @@ "license": "MIT" }, "node_modules/commerce-sdk-isomorphic": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/commerce-sdk-isomorphic/-/commerce-sdk-isomorphic-5.0.0.tgz", - "integrity": "sha512-9E0wEKq3pBoAdmjLByBdDVfcNbmo+G61WdxpMpHKtRzIlzbVhjLTNIrxxzRGP+267iTCbJg9sgB8SJLjG2hxTg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commerce-sdk-isomorphic/-/commerce-sdk-isomorphic-5.1.0.tgz", + "integrity": "sha512-i66SgfB6ml75HT8KGdIQE+Qm05uI66oIHeGXXPDh8jeIXZtccYUmegvzu+BBa68y1FrztcjlINCIjvExGurz3A==", "license": "BSD-3-Clause", "dependencies": { "nanoid": "^3.3.8", @@ -2843,7 +2843,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2856,7 +2855,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -3543,7 +3541,6 @@ "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/packages/commerce-sdk-react/package.json b/packages/commerce-sdk-react/package.json index c82d251d10..4e1b57aa72 100644 --- a/packages/commerce-sdk-react/package.json +++ b/packages/commerce-sdk-react/package.json @@ -40,7 +40,7 @@ "version": "node ./scripts/version.js" }, "dependencies": { - "commerce-sdk-isomorphic": "5.0.0", + "commerce-sdk-isomorphic": "5.1.0", "js-cookie": "^3.0.1", "jwt-decode": "^4.0.0" }, diff --git a/packages/commerce-sdk-react/src/constant.ts b/packages/commerce-sdk-react/src/constant.ts index a11b3c0237..3880d5476e 100644 --- a/packages/commerce-sdk-react/src/constant.ts +++ b/packages/commerce-sdk-react/src/constant.ts @@ -46,6 +46,8 @@ export const SERVER_AFFINITY_HEADER_KEY = 'sfdc_dwsid' export const CLIENT_KEYS = { SHOPPER_BASKETS: 'shopperBaskets', + SHOPPER_BASKETS_V2: 'shopperBasketsV2', + SHOPPER_CONFIGURATIONS: 'shopperConfigurations', SHOPPER_CONSENTS: 'shopperConsents', SHOPPER_CONTEXTS: 'shopperContexts', SHOPPER_CUSTOMERS: 'shopperCustomers', @@ -53,10 +55,10 @@ export const CLIENT_KEYS = { SHOPPER_GIFT_CERTIFICATES: 'shopperGiftCertificates', SHOPPER_LOGIN: 'shopperLogin', SHOPPER_ORDERS: 'shopperOrders', + SHOPPER_PAYMENTS: 'shopperPayments', SHOPPER_PRODUCTS: 'shopperProducts', SHOPPER_PROMOTIONS: 'shopperPromotions', SHOPPER_SEARCH: 'shopperSearch', SHOPPER_SEO: 'shopperSeo', - SHOPPER_STORES: 'shopperStores', - SHOPPER_CONFIGURATIONS: 'shopperConfigurations' + SHOPPER_STORES: 'shopperStores' } as const diff --git a/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/cache.ts b/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/cache.ts new file mode 100644 index 0000000000..b93b2eec07 --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/cache.ts @@ -0,0 +1,411 @@ +/* + * Copyright (c) 2023, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import { + ShopperBasketsV2Types, + ShopperCustomers, + ShopperCustomersTypes +} from 'commerce-sdk-isomorphic' +import { + ApiClients, + Argument, + CacheUpdateInvalidate, + CacheUpdateMatrix, + CacheUpdateUpdate, + MergedOptions +} from '../types' +import { + getBasket, + getPaymentMethodsForBasket, + getPriceBooksForBasket, + getShippingMethodsForShipment, + getTaxesFromBasket +} from './queryKeyHelpers' +import {getCustomerBaskets} from '../ShopperCustomers/queryKeyHelpers' +import {CLIENT_KEYS} from '../../constant' + +const CLIENT_KEY = CLIENT_KEYS.SHOPPER_BASKETS_V2 +type Client = NonNullable +/** Data returned by every Shopper Baskets endpoint (except `deleteBasket`) */ +type Basket = ShopperBasketsV2Types.Basket +/** Data returned by `getCustomerBaskets` */ +type CustomerBasketsResult = ShopperCustomersTypes.BasketsResult +/** Parameters that get passed around, includes client config and possible parameters from other endpoints */ +type BasketParameters = MergedOptions>['parameters'] +/** Parameters that we actually send to the API for `getCustomerBaskets` */ +type GetCustomerBasketsParameters = Argument< + ShopperCustomers<{shortCode: string}>['getCustomerBaskets'] +>['parameters'] + +const invalidateCustomerBasketsQuery = ( + customerId: string, + parameters: Omit +): CacheUpdateInvalidate => { + return { + queryKey: getCustomerBaskets.queryKey({...parameters, customerId}) + } +} + +const updateCustomerBasketsQuery = ( + customerId: string, + parameters: BasketParameters, + response: Basket +): CacheUpdateUpdate => { + return { + queryKey: getCustomerBaskets.queryKey({...parameters, customerId}), + updater: (oldData: CustomerBasketsResult | undefined) => { + if (!oldData?.baskets?.length) { + return { + baskets: [response], + total: 1 + } + } + const updatedBaskets = oldData.baskets.map((basket) => + basket.basketId === parameters.basketId ? response : basket + ) + return { + ...oldData, + // Shopper Customers and Shopper Baskets have different definitions for the `Basket` + // type. (99% similar, but that's not good enough for TypeScript.) + // TODO: Remove this type assertion when the RAML specs match. + baskets: updatedBaskets as CustomerBasketsResult['baskets'] + } + } + } +} + +export const cacheUpdateMatrix: CacheUpdateMatrix = { + addCouponToBasket(customerId, {parameters}, response) { + return { + update: [ + {queryKey: getBasket.queryKey(parameters)}, + ...(customerId + ? [updateCustomerBasketsQuery(customerId, parameters, response)] + : []) + ] + } + }, + addGiftCertificateItemToBasket(customerId, {parameters}, response) { + return { + update: [ + {queryKey: getBasket.queryKey(parameters)}, + ...(customerId + ? [updateCustomerBasketsQuery(customerId, parameters, response)] + : []) + ] + } + }, + addItemToBasket(customerId, {parameters}, response) { + return { + update: [ + {queryKey: getBasket.queryKey(parameters)}, + ...(customerId + ? [updateCustomerBasketsQuery(customerId, parameters, response)] + : []) + ] + } + }, + addPaymentInstrumentToBasket(customerId, {parameters}, response) { + return { + update: [ + {queryKey: getBasket.queryKey(parameters)}, + ...(customerId + ? [updateCustomerBasketsQuery(customerId, parameters, response)] + : []) + ] + } + }, + addPriceBooksToBasket(customerId, {parameters}) { + return { + invalidate: [ + {queryKey: getBasket.queryKey(parameters)}, + {queryKey: getPriceBooksForBasket.queryKey(parameters)}, + // TODO: Convert invalidate to an update that removes the matching basket + ...(customerId ? [invalidateCustomerBasketsQuery(customerId, parameters)] : []) + ] + } + }, + addTaxesForBasket(customerId, {parameters}) { + return { + invalidate: [ + {queryKey: getBasket.queryKey(parameters)}, + {queryKey: getTaxesFromBasket.queryKey(parameters)}, + // TODO: Convert invalidate to an update that removes the matching basket + ...(customerId ? [invalidateCustomerBasketsQuery(customerId, parameters)] : []) + ] + } + }, + addTaxesForBasketItem(customerId, {parameters}) { + return { + // TODO: Convert invalidate to an update that removes the matching basket + invalidate: [ + ...(customerId ? [invalidateCustomerBasketsQuery(customerId, parameters)] : []) + ], + update: [{queryKey: getBasket.queryKey(parameters)}] + } + }, + createBasket(customerId, {parameters}, response) { + const {basketId} = response + + return { + // TODO: Convert invalidate to an update that removes the matching basket + invalidate: [ + ...(customerId && !basketId + ? [invalidateCustomerBasketsQuery(customerId, parameters)] + : []) + ], + update: [ + {queryKey: getBasket.queryKey({...parameters, basketId})}, + ...(customerId && basketId + ? [updateCustomerBasketsQuery(customerId, {...parameters, basketId}, response)] + : []) + ] + } + }, + createShipmentForBasket(customerId, {parameters}, response) { + return { + update: [ + {queryKey: getBasket.queryKey(parameters)}, + ...(customerId + ? [updateCustomerBasketsQuery(customerId, parameters, response)] + : []) + ] + } + }, + deleteBasket(customerId, {parameters}) { + return { + // TODO: Convert invalidate to an update that removes the matching basket + invalidate: [ + ...(customerId ? [invalidateCustomerBasketsQuery(customerId, parameters)] : []) + ], + remove: [ + // We want to fuzzy match all queryKeys with `basketId` in their path + // [`/commerce-sdk-react,/organizations/,${organization},/baskets/,${basketId}`] + {queryKey: getBasket.path(parameters)} + ] + } + }, + mergeBasket(customerId, {parameters}, response) { + const {basketId} = response + const registeredCustomerId = response?.customerInfo?.customerId + + return { + // TODO: Convert invalidate to an update that removes the matching basket + invalidate: [ + ...(customerId && !basketId + ? [invalidateCustomerBasketsQuery(customerId, parameters)] + : []) + ], + update: [ + {queryKey: getBasket.queryKey({...parameters, basketId})}, + ...(registeredCustomerId && basketId + ? [ + updateCustomerBasketsQuery( + registeredCustomerId, + {...parameters, basketId}, + response + ) + ] + : []) + ] + } + }, + removeCouponFromBasket(customerId, {parameters}, response) { + return { + update: [ + {queryKey: getBasket.queryKey(parameters)}, + ...(customerId + ? [updateCustomerBasketsQuery(customerId, parameters, response)] + : []) + ] + } + }, + removeGiftCertificateItemFromBasket(customerId, {parameters}, response) { + return { + update: [ + {queryKey: getBasket.queryKey(parameters)}, + ...(customerId + ? [updateCustomerBasketsQuery(customerId, parameters, response)] + : []) + ] + } + }, + removeItemFromBasket(customerId, {parameters}, response) { + return { + update: [ + {queryKey: getBasket.queryKey(parameters)}, + ...(customerId + ? [updateCustomerBasketsQuery(customerId, parameters, response)] + : []) + ] + } + }, + removePaymentInstrumentFromBasket(customerId, {parameters}, response) { + return { + invalidate: [ + {queryKey: getPaymentMethodsForBasket.queryKey(parameters)}, + // TODO: Convert invalidate to an update that removes the matching basket + ...(customerId ? [invalidateCustomerBasketsQuery(customerId, parameters)] : []) + ], + update: [ + {queryKey: getBasket.queryKey(parameters)}, + ...(customerId + ? [updateCustomerBasketsQuery(customerId, parameters, response)] + : []) + ] + } + }, + removeShipmentFromBasket(customerId, {parameters}, response) { + return { + update: [ + {queryKey: getBasket.queryKey(parameters)}, + ...(customerId + ? [updateCustomerBasketsQuery(customerId, parameters, response)] + : []) + ] + } + }, + transferBasket(customerId, {parameters}, response) { + const {basketId} = response + const transferedTo = response?.customerInfo?.customerId + + return { + // TODO: Convert invalidate to an update that removes the matching basket + invalidate: [ + ...(customerId && !basketId + ? [invalidateCustomerBasketsQuery(customerId, parameters)] + : []) + ], + update: [ + {queryKey: getBasket.queryKey({...parameters, basketId})}, + ...(transferedTo && basketId + ? [ + updateCustomerBasketsQuery( + transferedTo, + {...parameters, basketId}, + response + ) + ] + : []) + ] + } + }, + updateBasket(customerId, {parameters}, response) { + return { + update: [ + {queryKey: getBasket.queryKey(parameters)}, + ...(customerId + ? [updateCustomerBasketsQuery(customerId, parameters, response)] + : []) + ] + } + }, + updateBillingAddressForBasket(customerId, {parameters}, response) { + return { + update: [ + {queryKey: getBasket.queryKey(parameters)}, + ...(customerId + ? [updateCustomerBasketsQuery(customerId, parameters, response)] + : []) + ] + } + }, + updateCustomerForBasket(customerId, {parameters}, response) { + return { + update: [ + {queryKey: getBasket.queryKey(parameters)}, + ...(customerId + ? [updateCustomerBasketsQuery(customerId, parameters, response)] + : []) + ] + } + }, + updateGiftCertificateItemInBasket(customerId, {parameters}, response) { + return { + update: [ + {queryKey: getBasket.queryKey(parameters)}, + ...(customerId + ? [updateCustomerBasketsQuery(customerId, parameters, response)] + : []) + ] + } + }, + updateItemInBasket(customerId, {parameters}, response) { + return { + update: [ + {queryKey: getBasket.queryKey(parameters)}, + ...(customerId + ? [updateCustomerBasketsQuery(customerId, parameters, response)] + : []) + ] + } + }, + updateItemsInBasket(customerId, {parameters}, response) { + return { + update: [ + {queryKey: getBasket.queryKey(parameters)}, + ...(customerId + ? [updateCustomerBasketsQuery(customerId, parameters, response)] + : []) + ] + } + }, + updatePaymentInstrumentInBasket(customerId, {parameters}, response) { + return { + invalidate: [ + {queryKey: getPaymentMethodsForBasket.queryKey(parameters)}, + // TODO: Convert invalidate to an update that removes the matching basket + ...(customerId ? [invalidateCustomerBasketsQuery(customerId, parameters)] : []) + ], + update: [ + {queryKey: getBasket.queryKey(parameters)}, + ...(customerId + ? [updateCustomerBasketsQuery(customerId, parameters, response)] + : []) + ] + } + }, + updateShipmentForBasket(customerId, {parameters}, response) { + return { + update: [ + {queryKey: getBasket.queryKey(parameters)}, + ...(customerId + ? [updateCustomerBasketsQuery(customerId, parameters, response)] + : []) + ] + } + }, + updateShippingAddressForShipment(customerId, {parameters}, response) { + return { + // TODO: Convert invalidate to an update that removes the matching basket + invalidate: [ + {queryKey: getShippingMethodsForShipment.queryKey(parameters)}, + ...(customerId ? [invalidateCustomerBasketsQuery(customerId, parameters)] : []) + ], + update: [ + {queryKey: getBasket.queryKey(parameters)}, + ...(customerId + ? [updateCustomerBasketsQuery(customerId, parameters, response)] + : []) + ] + } + }, + updateShippingMethodForShipment(customerId, {parameters}, response) { + return { + // TODO: Convert invalidate to an update that removes the matching basket + invalidate: [ + {queryKey: getShippingMethodsForShipment.queryKey(parameters)}, + ...(customerId ? [invalidateCustomerBasketsQuery(customerId, parameters)] : []) + ], + update: [ + {queryKey: getBasket.queryKey(parameters)}, + ...(customerId + ? [updateCustomerBasketsQuery(customerId, parameters, response)] + : []) + ] + } + } +} diff --git a/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/helpers.test.tsx b/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/helpers.test.tsx new file mode 100644 index 0000000000..f67b72f924 --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/helpers.test.tsx @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2024, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React from 'react' +import {useShopperBasketsMutationHelper} from './helpers' +import {useCustomerBaskets} from '../ShopperCustomers' +import {renderWithProviders} from '../../test-utils' +import {screen, waitFor} from '@testing-library/react' +import {ShopperBasketsV2Types} from 'commerce-sdk-isomorphic' +import jwt from 'jsonwebtoken' + +const basketId = '10cf6aa40edba4fcfcc6915594' +const mockAsyncMutate = jest.fn() + +jest.mock('../ShopperCustomers', () => { + const originalModule = jest.requireActual('../ShopperCustomers') + return { + ...originalModule, + useCustomerBaskets: jest.fn() + } +}) +jest.mock('../index', () => { + const originalModule = jest.requireActual('./index') + return { + ...originalModule, + useCustomerId: jest.fn(() => 'customer-id') + } +}) + +jest.mock('./mutation', () => { + const originalModule = jest.requireActual('./mutation') + return { + ...originalModule, + useShopperBasketsMutation: () => ({ + mutateAsync: mockAsyncMutate + }) + } +}) +const MockComponent = () => { + const helpers = useShopperBasketsMutationHelper() + return ( +
+ +
+ ) +} +describe('useShopperBasketsMutationHelper.addItemToNewOrExistingBasket', function () { + afterEach(() => { + jest.resetModules() + }) + + test('should perform add to cart mutation when basket is already created', async () => { + mockAsyncMutate.mockImplementationOnce(() => ({ + productItems: [{id: 'product-id-111', quantity: 20}], + basketId + })) + // @ts-expect-error ts complains because mockImplementation is not part of declared type from useCustomerBaskets queries + useCustomerBaskets.mockImplementation(() => { + return { + data: {total: 1, baskets: [{basketId}]}, + isLoading: false + } + }) + const fetchedToken = jwt.sign( + { + sub: `cc-slas::zzrf_001::scid:xxxxxx::usid:usidddddd`, + isb: `uido:ecom::upn:Guest::uidn:firstname lastname::gcid:customerId::rcid:registeredCid::chid:siteId` + }, + 'secret' + ) + const {user} = renderWithProviders(, { + fetchedToken + }) + const addToCartBtn = screen.getByText(/add to cart/i) + await user.click(addToCartBtn) + + await waitFor(() => + expect(mockAsyncMutate.mock.calls[0][0]).toEqual({ + parameters: {basketId}, + body: [ + { + productId: 'product-123', + price: 100, + quantity: 1 + } + ] + }) + ) + }) + + test('should call a basket mutation before calling add to cart mutation', async () => { + // order is important since mockAsyncMutate will represent createBasket and addToBasket mutation in the order of executions + mockAsyncMutate + .mockImplementationOnce(() => ({ + productItems: [], + basketId + })) + .mockImplementationOnce(() => ({ + productItems: [{id: 'product-id', quantity: 2}], + basketId + })) + // @ts-expect-error ts complains because mockImplementation is not part of declared type from useCustomerBaskets queries + useCustomerBaskets.mockImplementation(() => { + return { + data: {total: 0}, + isLoading: false + } + }) + const fetchedToken = jwt.sign( + { + sub: `cc-slas::zzrf_001::scid:xxxxxx::usid:usidddddd`, + isb: `uido:ecom::upn:Guest::uidn:firstname lastname::gcid:customerId::rcid:registeredCid::chid:siteId` + }, + 'secret' + ) + const {user} = renderWithProviders(, { + fetchedToken + }) + const addToCartBtn = screen.getByText(/add to cart/i) + await user.click(addToCartBtn) + expect(mockAsyncMutate).toHaveBeenCalledTimes(2) + + await waitFor(() => expect(mockAsyncMutate.mock.calls[0][0]).toEqual({body: {}})) + await waitFor(() => + expect(mockAsyncMutate.mock.calls[1][0]).toEqual({ + parameters: {basketId}, + body: [ + { + productId: 'product-123', + price: 100, + quantity: 1 + } + ] + }) + ) + }) +}) diff --git a/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/helpers.ts b/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/helpers.ts new file mode 100644 index 0000000000..b2b3a8bca7 --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/helpers.ts @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2023, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import {useCustomerId} from '../index' +// make sure to import the v2 mutation from the correct file +import {useShopperBasketsMutation} from './mutation' +import {useCustomerBaskets} from '../ShopperCustomers' +import {ApiClients, Argument} from '../types' +import {ShopperBasketsV2Types} from 'commerce-sdk-isomorphic' +import {CLIENT_KEYS} from '../../constant' + +const CLIENT_KEY = CLIENT_KEYS.SHOPPER_BASKETS_V2 +type Client = NonNullable +type Basket = ShopperBasketsV2Types.Basket + +/** + * This is a helper function for Basket Mutations. + * useShopperBasketsMutationHelper.addItemToNewOrExistingBasket: is responsible for managing the process of adding an item to a basket. + * - If a basket already exists, add the item to the basket immediately. + * - If a basket does not exist, create a new basket using the createBasket mutation + * and then add the item to the newly created basket using the addItemToBasket mutation. + * + * @example + * import useShopperBasketsMutationHelper from '@salesforce/commerce-sdk-react' + * + * const Page = () => { + * const helpers = useShopperBasketsMutationHelper() + * + * const addToCart = async () => { + * const productItems = [{id: 123, quantity: 2}] + * await basketMutationHelpers.addItemToNewOrExistingBasket(productItems) + * } + * + * } + */ +export function useShopperBasketsMutationHelper() { + const customerId = useCustomerId() + const {data: basketsData} = useCustomerBaskets( + {parameters: {customerId}}, + { + enabled: !!customerId + } + ) + const createBasket = useShopperBasketsMutation('createBasket') + const addItemToBasketMutation = useShopperBasketsMutation('addItemToBasket') + return { + addItemToNewOrExistingBasket: async ( + productItem: Argument extends {body: infer B} ? B : undefined + ): Promise => { + if (basketsData && basketsData.total > 0) { + // we know that if basketData.total > 0, current basket will always be available + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const currentBasket = basketsData?.baskets && basketsData.baskets[0]! + return await addItemToBasketMutation.mutateAsync({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + parameters: {basketId: currentBasket!.basketId!}, + body: productItem + }) + } else { + const data = await createBasket.mutateAsync({ + body: {} + }) + if (!data || !data.basketId) { + throw Error('Something is wrong. Please try again') + } else { + return await addItemToBasketMutation.mutateAsync({ + parameters: {basketId: data.basketId}, + body: productItem + }) + } + } + } + } +} diff --git a/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/index.test.ts b/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/index.test.ts new file mode 100644 index 0000000000..3d193a3168 --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/index.test.ts @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {ShopperBasketsV2} from 'commerce-sdk-isomorphic' +import {getUnimplementedEndpoints} from '../../test-utils' +import {cacheUpdateMatrix} from './cache' +import {ShopperBasketsMutations as mutations} from './mutation' +import * as queries from './query' + +describe('Shopper Baskets hooks', () => { + test('all endpoints have hooks', () => { + // unimplemented = SDK method exists, but no query hook or value in mutations enum + const unimplemented = getUnimplementedEndpoints(ShopperBasketsV2, queries, mutations) + // If this test fails: create a new query hook, add the endpoint to the mutations enum, + // or add it to the `expected` array with a comment explaining "TODO" or "never" (and why). + expect(unimplemented).toEqual([ + 'addPriceAdjustmentToBasket', //TODO: implement later + 'removePriceAdjustmentFromBasket', //TODO: implement later + 'updateAsAgentBasket', //TODO: implement later + 'updateAsStorefrontBasket', //TODO: implement later + 'updatePriceAdjustmentInBasket' //TODO: implement later + ]) + }) + test('all mutations have cache update logic', () => { + // unimplemented = value in mutations enum, but no method in cache update matrix + const unimplemented = new Set(Object.values(mutations)) + Object.entries(cacheUpdateMatrix).forEach(([method, implementation]) => { + if (implementation) unimplemented.delete(method) + }) + // If this test fails: add cache update logic, remove the endpoint from the mutations enum, + // or add it to the `expected` array to indicate that it is still a TODO. + expect([...unimplemented]).toEqual([]) + }) +}) diff --git a/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/index.ts b/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/index.ts new file mode 100644 index 0000000000..1413649783 --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright (c) 2023, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +export * from './mutation' +export * from './query' +export * from './helpers' diff --git a/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/mutation.test.ts b/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/mutation.test.ts new file mode 100644 index 0000000000..ab7abe381a --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/mutation.test.ts @@ -0,0 +1,344 @@ +/* + * Copyright (c) 2023, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {act} from '@testing-library/react' +import {ShopperBasketsV2Types, ShopperCustomersTypes} from 'commerce-sdk-isomorphic' +import nock from 'nock' +import { + assertInvalidateQuery, + assertRemoveQuery, + assertUpdateQuery, + DEFAULT_TEST_CONFIG, + mockMutationEndpoints, + mockQueryEndpoint, + renderHookWithProviders, + waitAndExpectError, + waitAndExpectSuccess +} from '../../test-utils' +import {useCustomerBaskets} from '../ShopperCustomers' +import {ApiClients, Argument} from '../types' +import {ShopperBasketsMutation, useShopperBasketsMutation} from './mutation' +import * as queries from './query' +import {CLIENT_KEYS} from '../../constant' + +jest.mock('../../auth/index.ts', () => { + const {default: mockAuth} = jest.requireActual('../../auth/index.ts') + mockAuth.prototype.ready = jest.fn().mockResolvedValue({access_token: 'access_token'}) + return mockAuth +}) + +const CLIENT_KEY = CLIENT_KEYS.SHOPPER_BASKETS_V2 +type Client = NonNullable +type Basket = ShopperBasketsV2Types.Basket +type BasketsResult = ShopperCustomersTypes.BasketsResult +type ProductItem = ShopperBasketsV2Types.ProductItem + +/** Create an options object for Shopper Baskets endpoints, with `basketId` pre-filled. */ +const createOptions = >( + body: Argument extends {body: infer B} ? B : undefined, + parameters: Omit['parameters'], 'basketId'> +): Argument => ({ + body, + parameters: {basketId: BASKET_ID, ...parameters} +}) + +// --- getBasket constants --- // +const basketsEndpoint = '/checkout/shopper-baskets/' +const BASKET_ID = 'basket_id' +const getBasketOptions = createOptions<'getBasket'>(undefined, {}) +const EMPTY_BASKET: Basket = {} +const oldBasket: Basket = {basketId: BASKET_ID, mockData: 'old basket'} +const newBasket: Basket = {basketId: BASKET_ID, mockData: 'new basket'} +// --- getCustomerBaskets constants --- // +const customersEndpoint = '/customer/shopper-customers/' +const CUSTOMER_ID = 'customer_id' +const getCustomerBasketsOptions: Argument< + NonNullable['getCustomerBaskets'] +> = { + parameters: { + customerId: CUSTOMER_ID + } +} +const emptyCustomerBaskets: BasketsResult = { + baskets: [] as BasketsResult['baskets'], + total: 0 +} +const oneCustomerBasket: BasketsResult = { + baskets: [newBasket] as BasketsResult['baskets'], + total: 1 +} +const oldCustomerBaskets: BasketsResult = { + // We aren't implementing the full basket, so we assert to pretend we are + baskets: [{basketId: 'other_basket'}, oldBasket] as BasketsResult['baskets'], + total: 2 +} +const newCustomerBaskets: BasketsResult = { + // We aren't implementing the full basket, so we assert to pretend we are + baskets: [{basketId: 'other_basket'}, newBasket] as BasketsResult['baskets'], + total: 2 +} +const deletedCustomerBaskets: BasketsResult = { + // We aren't implementing the full basket, so we assert to pretend we are + baskets: [{basketId: 'other_basket'}] as BasketsResult['baskets'], + total: 1 +} + +// --- TEST CASES --- // +/** All Shopper Baskets mutations except these have the same cache update logic. */ +type NonEmptyResponseMutations = Exclude< + ShopperBasketsMutation, + 'deleteBasket' | 'addPriceBooksToBasket' | 'addTaxesForBasket' | 'addTaxesForBasketItem' +> +// This is an object rather than an array to more easily ensure we cover all mutations +type TestMap = {[Mut in NonEmptyResponseMutations]: Argument} +const testMap: TestMap = { + addGiftCertificateItemToBasket: createOptions<'addGiftCertificateItemToBasket'>( + {recipientEmail: 'customer@email', amount: 100}, + {} + ), + createShipmentForBasket: createOptions<'createShipmentForBasket'>({}, {}), + removeGiftCertificateItemFromBasket: createOptions<'removeGiftCertificateItemFromBasket'>( + undefined, + {giftCertificateItemId: 'giftCertificateItemId'} + ), + removeShipmentFromBasket: createOptions<'removeShipmentFromBasket'>(undefined, { + shipmentId: 'shipmentId' + }), + transferBasket: createOptions<'transferBasket'>(undefined, {}), + updateGiftCertificateItemInBasket: createOptions<'updateGiftCertificateItemInBasket'>( + { + amount: 100, + recipientEmail: 'customer@email' + }, + {giftCertificateItemId: 'giftCertificateItemId'} + ), + updateShipmentForBasket: createOptions<'updateShipmentForBasket'>( + {}, + {shipmentId: 'shipmentId'} + ), + addCouponToBasket: createOptions<'addCouponToBasket'>({code: 'coupon'}, {}), + addItemToBasket: createOptions<'addItemToBasket'>( + [{productId: 'test-product', price: 10, quantity: 1}] as ProductItem[] & + Record<`c_${string}`, any>, + {} + ), + addPaymentInstrumentToBasket: createOptions<'addPaymentInstrumentToBasket'>({}, {}), + createBasket: createOptions<'createBasket'>({}, {}), + mergeBasket: createOptions<'mergeBasket'>(undefined, {}), + removeCouponFromBasket: createOptions<'removeCouponFromBasket'>(undefined, { + couponItemId: 'couponIemId' + }), + removeItemFromBasket: createOptions<'removeItemFromBasket'>(undefined, {itemId: 'itemId'}), + removePaymentInstrumentFromBasket: createOptions<'removePaymentInstrumentFromBasket'>( + undefined, + { + paymentInstrumentId: 'paymentInstrumentId' + } + ), + updateBasket: createOptions<'updateBasket'>({}, {}), + updateBillingAddressForBasket: createOptions<'updateBillingAddressForBasket'>({}, {}), + updateCustomerForBasket: createOptions<'updateCustomerForBasket'>( + {email: 'customer@email'}, + {} + ), + updateItemInBasket: createOptions<'updateItemInBasket'>({}, {itemId: 'itemId'}), + updateItemsInBasket: createOptions<'updateItemsInBasket'>( + [{productId: 'test-product', price: 10, quantity: 1}] as ProductItem[] & + Record<`c_${string}`, any>, + {} + ), + updatePaymentInstrumentInBasket: createOptions<'updatePaymentInstrumentInBasket'>( + {}, + {paymentInstrumentId: 'paymentInstrumentId'} + ), + updateShippingAddressForShipment: createOptions<'updateShippingAddressForShipment'>( + {}, + {shipmentId: 'shipmentId'} + ), + updateShippingMethodForShipment: createOptions<'updateShippingMethodForShipment'>( + {id: 'ship'}, + {shipmentId: 'shipmentId'} + ) +} +const createTestCase = ['createBasket', createOptions<'createBasket'>({}, {})] as const +const deleteTestCase = ['deleteBasket', createOptions<'deleteBasket'>(undefined, {})] as const +const addPriceBooksToBasketTestCase = [ + 'addPriceBooksToBasket', + createOptions<'addPriceBooksToBasket'>( + ['price-book-1'] as string[] & Record<`c_${string}`, any>, + {} + ) +] as const +const addTaxesForBasketTestCase = [ + 'addTaxesForBasket', + createOptions<'addTaxesForBasket'>( + { + taxes: {} + }, + {} + ) +] as const +const addTaxesForBasketItemTestCase = [ + 'addTaxesForBasketItem', + createOptions<'addTaxesForBasketItem'>({}, {itemId: 'itemId'}) +] as const + +// Type assertion because the built-in type definition for `Object.entries` is limited :\ +const nonEmptyResponseTestCases = Object.entries(testMap) as Array< + [NonEmptyResponseMutations, Argument] +> + +// Endpoints returning void response on success +const emptyResponseTestCases = [ + addPriceBooksToBasketTestCase, + addTaxesForBasketTestCase, + addTaxesForBasketItemTestCase, + // FIXME: This test only passed if run last. + deleteTestCase +] + +// Most test cases only apply to non-empty response test cases, some (error handling) can include deleteBasket +const allTestCases = [...nonEmptyResponseTestCases, ...emptyResponseTestCases] + +describe('ShopperBaskets mutations', () => { + const storedCustomerIdKey = `customer_id_${DEFAULT_TEST_CONFIG.siteId}` + beforeAll(() => { + // Make sure we don't accidentally overwrite something before setting up our test state + if (window.localStorage.length > 0) throw new Error('Unexpected data in local storage.') + window.localStorage.setItem(storedCustomerIdKey, CUSTOMER_ID) + }) + afterAll(() => { + window.localStorage.removeItem(storedCustomerIdKey) + }) + + beforeEach(() => nock.cleanAll()) + test.each(nonEmptyResponseTestCases)( + '`%s` returns data on success', + async (mutationName, options) => { + mockMutationEndpoints(basketsEndpoint, oldBasket) + const {result} = renderHookWithProviders(() => { + return useShopperBasketsMutation(mutationName) + }) + expect(result.current.data).toBeUndefined() + act(() => result.current.mutate(options)) + await waitAndExpectSuccess(() => result.current) + expect(result.current.data).toEqual(oldBasket) + } + ) + test.each(allTestCases)('`%s` returns error on error', async (mutationName, options) => { + mockMutationEndpoints(basketsEndpoint, {error: true}, 400) + const {result} = renderHookWithProviders(() => { + return useShopperBasketsMutation(mutationName) + }) + expect(result.current.error).toBeNull() + act(() => result.current.mutate(options)) + await waitAndExpectError(() => result.current) + // Validate that we get a `ResponseError` from commerce-sdk-isomorphic. Ideally, we could do + // `.toBeInstanceOf(ResponseError)`, but the class isn't exported. :\ + expect(result.current.error).toHaveProperty('response') + }) + test.each(nonEmptyResponseTestCases)( + '`%s` updates the cache on success', + async (mutationName, options) => { + mockQueryEndpoint(basketsEndpoint, oldBasket) // getBasket + mockQueryEndpoint(customersEndpoint, oldCustomerBaskets) // getCustomerBaskets + mockMutationEndpoints(basketsEndpoint, newBasket) // this mutation + const {result} = renderHookWithProviders(() => ({ + basket: queries.useBasket(getBasketOptions), + customerBaskets: useCustomerBaskets(getCustomerBasketsOptions), + mutation: useShopperBasketsMutation(mutationName) + })) + await waitAndExpectSuccess(() => result.current.basket) + expect(result.current.basket.data).toEqual(oldBasket) + expect(result.current.customerBaskets.data).toEqual(oldCustomerBaskets) + act(() => result.current.mutation.mutate(options)) + await waitAndExpectSuccess(() => result.current.mutation) + assertUpdateQuery(result.current.basket, newBasket) + // Because 'transferBasket` and `mergeBasket` returns basket information applicable to a different (registered) customer + // the `result.current.customerBaskets` isn't effected here as the customer id is static. + // https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1887 + if (mutationName !== 'transferBasket' && mutationName !== 'mergeBasket') { + assertUpdateQuery(result.current.customerBaskets, newCustomerBaskets) + } + } + ) + test.each(allTestCases)( + '`%s` does not change cache on error', + async (mutationName, options) => { + mockQueryEndpoint(basketsEndpoint, oldBasket) // getBasket + mockQueryEndpoint(customersEndpoint, oldCustomerBaskets) // getCustomerBaskets + mockMutationEndpoints(basketsEndpoint, {error: true}, 400) // this mutation + const {result} = renderHookWithProviders(() => ({ + basket: queries.useBasket(getBasketOptions), + customerBaskets: useCustomerBaskets(getCustomerBasketsOptions), + mutation: useShopperBasketsMutation(mutationName) + })) + await waitAndExpectSuccess(() => result.current.basket) + expect(result.current.basket.data).toEqual(oldBasket) + expect(result.current.customerBaskets.data).toEqual(oldCustomerBaskets) + expect(result.current.mutation.error).toBeNull() + act(() => result.current.mutation.mutate(options)) + await waitAndExpectError(() => result.current.mutation) + // Validate that we get a `ResponseError` from commerce-sdk-isomorphic. Ideally, we could do + // `.toBeInstanceOf(ResponseError)`, but the class isn't exported. :\ + expect(result.current.mutation.error).toHaveProperty('response') + assertUpdateQuery(result.current.basket, oldBasket) + assertUpdateQuery(result.current.customerBaskets, oldCustomerBaskets) + } + ) + test.each(emptyResponseTestCases)( + '`%s` returns void on success', + async (mutationName, options) => { + // Almost the standard 'returns data' test, just a different return type + mockMutationEndpoints(basketsEndpoint, oldBasket) + const {result} = renderHookWithProviders(() => { + return useShopperBasketsMutation(mutationName) + }) + expect(result.current.data).toBeUndefined() + act(() => result.current.mutate(options)) + await waitAndExpectSuccess(() => result.current) + expect(result.current.data).toBeUndefined() + } + ) + test('`createBasket` adds the basket to the cache on success if customer has no basket', async () => { + const [mutationName, options] = createTestCase + mockQueryEndpoint(basketsEndpoint, EMPTY_BASKET) // getBasket + mockQueryEndpoint(customersEndpoint, emptyCustomerBaskets) // getCustomerBaskets + mockMutationEndpoints(basketsEndpoint, newBasket) // this mutation + const {result} = renderHookWithProviders(() => ({ + basket: queries.useBasket(getBasketOptions), + customerBaskets: useCustomerBaskets(getCustomerBasketsOptions), + mutation: useShopperBasketsMutation(mutationName) + })) + await waitAndExpectSuccess(() => result.current.basket) + expect(result.current.basket.data).toEqual(EMPTY_BASKET) + expect(result.current.customerBaskets.data).toEqual(emptyCustomerBaskets) + act(() => result.current.mutation.mutate(options)) + await waitAndExpectSuccess(() => result.current.mutation) + assertUpdateQuery(result.current.basket, newBasket) + assertUpdateQuery(result.current.customerBaskets, oneCustomerBasket) + }) + test('`deleteBasket` removes the basket from the cache on success', async () => { + // Almost the standard 'updates cache' test, but the cache changes are different + const [mutationName, options] = deleteTestCase + mockQueryEndpoint(basketsEndpoint, oldBasket) // getBasket + mockQueryEndpoint(customersEndpoint, oldCustomerBaskets) // getCustomerBaskets + mockMutationEndpoints(basketsEndpoint, newBasket) // this mutation + mockQueryEndpoint(customersEndpoint, deletedCustomerBaskets) // getCustomerBaskets refetch + const {result} = renderHookWithProviders(() => ({ + basket: queries.useBasket(getBasketOptions), + customerBaskets: useCustomerBaskets(getCustomerBasketsOptions), + mutation: useShopperBasketsMutation(mutationName) + })) + await waitAndExpectSuccess(() => result.current.basket) + expect(result.current.basket.data).toEqual(oldBasket) + expect(result.current.customerBaskets.data).toEqual(oldCustomerBaskets) + act(() => result.current.mutation.mutate(options)) + await waitAndExpectSuccess(() => result.current.mutation) + assertRemoveQuery(result.current.basket) + assertInvalidateQuery(result.current.customerBaskets, oldCustomerBaskets) + }) +}) diff --git a/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/mutation.ts b/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/mutation.ts new file mode 100644 index 0000000000..5db11d1348 --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/mutation.ts @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2023, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {ApiClients, ApiMethod, Argument, CacheUpdateGetter, DataType, MergedOptions} from '../types' +import {useMutation} from '../useMutation' +import {UseMutationResult} from '@tanstack/react-query' +import {cacheUpdateMatrix} from './cache' +import {CLIENT_KEYS} from '../../constant' +import useCommerceApi from '../useCommerceApi' + +const CLIENT_KEY = CLIENT_KEYS.SHOPPER_BASKETS_V2 +type Client = NonNullable + +/** + * Mutations available for Shopper Baskets. + * @group ShopperBaskets + * @category Mutation + * @enum + */ +export const ShopperBasketsMutations = { + /** + * Creates a new basket. + + The created basket is initialized with default values. + */ + CreateBasket: 'createBasket', + /** + * Transfer the previous shopper's basket to the current shopper by updating the basket's owner. No other values change. You must obtain the shopper authorization token via SLAS and you must provide the ‘guest usid‘ in both the ‘/oauth2/login‘ and ‘/oauth2/token‘ calls while fetching the registered user JWT token. + + A success response contains the transferred basket. + + If the current shopper has an active basket, and the `overrideExisting` request parameter is `false`, then the transfer request returns a BasketTransferException (HTTP status 409). You can proceed with one of these options: + - Keep the current shopper's active basket. + - Merge the previous and current shoppers' baskets by calling the `baskets/merge` endpoint. + - Force the transfer by calling the `baskets/transfer` endpoint again, with the parameter `overrideExisting=true`. Forcing the transfer deletes the current shopper's active basket. + */ + TransferBasket: 'transferBasket', + /** + * Merge data from the previous shopper's basket into the current shopper's active basket and delete the previous shopper's basket. This endpoint doesn't merge Personally Identifiable Information (PII). You must obtain the shopper authorization token via SLAS and you must provide the ‘guest usid‘ in both the ‘/oauth2/login‘ and ‘/oauth2/token‘ calls while fetching the registered user JWT token. After the merge, all basket amounts are recalculated and totaled, including lookups for prices, taxes, shipping, and promotions. + */ + MergeBasket: 'mergeBasket', + /** + * Removes a basket. + */ + DeleteBasket: 'deleteBasket', + /** + * Updates a basket. Only the currency of the basket, source code, the custom + properties of the basket, and the shipping items will be considered. + */ + UpdateBasket: 'updateBasket', + /** + * Sets the billing address of a basket. + */ + UpdateBillingAddressForBasket: 'updateBillingAddressForBasket', + /** + * Adds a coupon to an existing basket. + */ + AddCouponToBasket: 'addCouponToBasket', + /** + * Removes a coupon from the basket. + */ + RemoveCouponFromBasket: 'removeCouponFromBasket', + /** + * Sets customer information for an existing basket. + */ + UpdateCustomerForBasket: 'updateCustomerForBasket', + /** + * Adds a gift certificate item to an existing basket. + */ + AddGiftCertificateItemToBasket: 'addGiftCertificateItemToBasket', + /** + * Deletes a gift certificate item from an existing basket. + */ + RemoveGiftCertificateItemFromBasket: 'removeGiftCertificateItemFromBasket', + /** + * Updates a gift certificate item of an existing basket. + */ + UpdateGiftCertificateItemInBasket: 'updateGiftCertificateItemInBasket', + /** + * Adds new items to a basket. + */ + AddItemToBasket: 'addItemToBasket', + /** + * Removes a product item from the basket. + */ + RemoveItemFromBasket: 'removeItemFromBasket', + /** + * Updates an item in a basket. + */ + UpdateItemInBasket: 'updateItemInBasket', + /** + * Updates multiple items in a basket. + */ + UpdateItemsInBasket: 'updateItemsInBasket', + /** + * This method allows you to apply external taxation data to an existing basket to be able to pass tax rates and optional values for a specific taxable line item. This endpoint can be called only if external taxation mode was used for basket creation. See POST /baskets for more information. + */ + AddTaxesForBasketItem: 'addTaxesForBasketItem', + /** + * Adds a payment instrument to a basket. + */ + AddPaymentInstrumentToBasket: 'addPaymentInstrumentToBasket', + /** + * Removes a payment instrument of a basket. + */ + RemovePaymentInstrumentFromBasket: 'removePaymentInstrumentFromBasket', + /** + * Updates payment instrument of an existing basket. + */ + UpdatePaymentInstrumentInBasket: 'updatePaymentInstrumentInBasket', + /** + * This method allows you to put an array of priceBookIds to an existing basket, which will be used for basket calculation. + */ + AddPriceBooksToBasket: 'addPriceBooksToBasket', + /** + * Creates a new shipment for a basket. + + The created shipment is initialized with values provided in the body + document and can be updated with further data API calls. Considered from + the body are the following properties if specified: + + - the ID + - the shipping address + - the shipping method + - gift boolean flag + - gift message + - custom properties + */ + CreateShipmentForBasket: 'createShipmentForBasket', + /** + * Removes a specified shipment and all associated product, gift certificate, + shipping, and price adjustment line items from a basket. + It is not allowed to remove the default shipment. + */ + RemoveShipmentFromBasket: 'removeShipmentFromBasket', + /** + * Updates a shipment for a basket. + + The shipment is initialized with values provided in the body + document and can be updated with further data API calls. Considered from + the body are the following properties if specified: + - the ID + - the shipping address + - the shipping method + - gift boolean flag + - gift message + - custom properties + */ + UpdateShipmentForBasket: 'updateShipmentForBasket', + /** + * Sets a shipping address of a specific shipment of a basket. + */ + UpdateShippingAddressForShipment: 'updateShippingAddressForShipment', + /** + * Sets a shipping method to a specific shipment of a basket. + */ + UpdateShippingMethodForShipment: 'updateShippingMethodForShipment', + /** + * This method allows you to apply external taxation data to an existing basket to be able to pass tax rates and optional values for all taxable line items. This endpoint can be called only if external taxation mode was used for basket creation. See POST /baskets for more information. + */ + AddTaxesForBasket: 'addTaxesForBasket' +} as const + +/** + * Type for Shopper Baskets Mutation. + * @group ShopperBaskets + * @category Mutation + */ +export type ShopperBasketsMutation = + (typeof ShopperBasketsMutations)[keyof typeof ShopperBasketsMutations] + +/** + * Mutation hook for Shopper Baskets. + * @group ShopperBaskets + * @category Mutation + */ +export function useShopperBasketsMutation( + mutation: Mutation +): UseMutationResult, unknown, Argument> { + const getCacheUpdates = cacheUpdateMatrix[mutation] + + // The `Options` and `Data` types for each mutation are similar, but distinct, and the union + // type generated from `Client[Mutation]` seems to be too complex for TypeScript to handle. + // I'm not sure if there's a way to avoid the type assertions in here for the methods that + // use them. However, I'm fairly confident that they are safe to do, as they seem to be simply + // re-asserting what we already have. + const client = useCommerceApi(CLIENT_KEY) + type Options = Argument + type Data = DataType + return useMutation({ + client, + method: (opts: Options) => (client[mutation] as ApiMethod)(opts), + getCacheUpdates: getCacheUpdates as CacheUpdateGetter, Data> + }) +} diff --git a/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/query.test.ts b/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/query.test.ts new file mode 100644 index 0000000000..e8cdd0dc46 --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/query.test.ts @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2023, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import nock from 'nock' +import { + mockQueryEndpoint, + renderHookWithProviders, + waitAndExpectError, + waitAndExpectSuccess, + createQueryClient +} from '../../test-utils' + +import {Argument} from '../types' +import * as queries from './query' + +jest.mock('../../auth/index.ts', () => { + const {default: mockAuth} = jest.requireActual('../../auth/index.ts') + mockAuth.prototype.ready = jest.fn().mockResolvedValue({access_token: 'access_token'}) + return mockAuth +}) + +type Queries = typeof queries +const basketsEndpoint = '/checkout/shopper-baskets/' +// Not all endpoints use all parameters, but unused parameters are safely discarded +const OPTIONS: Argument = { + parameters: {basketId: 'basketId', shipmentId: 'shipmentId'} +} + +/** Map of query name to returned data type */ +type TestMap = {[K in keyof Queries]: NonNullable['data']>} +// This is an object rather than an array to more easily ensure we cover all hooks +const testMap: TestMap = { + useBasket: {basketId: 'basketId'}, + usePaymentMethodsForBasket: {applicablePaymentMethods: []}, + usePriceBooksForBasket: ['priceBookId'], + useShippingMethodsForShipment: {defaultShippingMethodId: 'defaultShippingMethodId'}, + useTaxesFromBasket: {taxes: {}} +} +// Type assertion is necessary because `Object.entries` is limited +const testCases = Object.entries(testMap) as Array<[keyof TestMap, TestMap[keyof TestMap]]> +describe('Shopper Baskets query hooks', () => { + beforeEach(() => nock.cleanAll()) + afterEach(() => { + expect(nock.pendingMocks()).toHaveLength(0) + }) + test.each(testCases)('`%s` has meta.displayName defined', async (queryName, data) => { + mockQueryEndpoint(basketsEndpoint, data) + const queryClient = createQueryClient() + const {result} = renderHookWithProviders( + () => { + return queries[queryName](OPTIONS) + }, + {queryClient} + ) + await waitAndExpectSuccess(() => result.current) + expect(queryClient.getQueryCache().getAll()[0].meta?.displayName).toBe(queryName) + }) + + test.each(testCases)('`%s` returns data on success', async (queryName, data) => { + mockQueryEndpoint(basketsEndpoint, data) + const {result} = renderHookWithProviders(() => { + return queries[queryName](OPTIONS) + }) + await waitAndExpectSuccess(() => result.current) + expect(result.current.data).toEqual(data) + }) + + test.each(testCases)('`%s` returns error on error', async (queryName) => { + mockQueryEndpoint(basketsEndpoint, {}, 400) + const {result} = renderHookWithProviders(() => { + return queries[queryName](OPTIONS) + }) + await waitAndExpectError(() => result.current) + }) +}) diff --git a/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/query.ts b/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/query.ts new file mode 100644 index 0000000000..88dfa965c5 --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/query.ts @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2023, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {UseQueryResult} from '@tanstack/react-query' +import {ApiClients, ApiQueryOptions, Argument, DataType, NullableParameters} from '../types' +import {useQuery} from '../useQuery' +import {mergeOptions, omitNullableParameters, pickValidParams} from '../utils' +import * as queryKeyHelpers from './queryKeyHelpers' +import {ShopperBasketsV2} from 'commerce-sdk-isomorphic' +import {CLIENT_KEYS} from '../../constant' +import useCommerceApi from '../useCommerceApi' + +const CLIENT_KEY = CLIENT_KEYS.SHOPPER_BASKETS_V2 +type Client = NonNullable + +/** + * Gets a basket. + * @group ShopperBaskets + * @category Query + * @parameter apiOptions - Options to pass through to `commerce-sdk-isomorphic`, with `null` accepted for unset API parameters. + * @parameter queryOptions - TanStack Query query options, with `enabled` by default set to check that all required API parameters have been set. + * @returns A TanStack Query query hook with data from the Shopper Baskets `getBasket` endpoint. + * @see {@link https://developer.salesforce.com/docs/commerce/commerce-api/references/shopper-baskets?meta=getBasket| Salesforce Developer Center} for more information about the API endpoint. + * @see {@link https://salesforcecommercecloud.github.io/commerce-sdk-isomorphic/classes/shopperbaskets.shopperbaskets-1.html#getbasket | `commerce-sdk-isomorphic` documentation} for more information on the parameters and returned data type. + * @see {@link https://tanstack.com/query/latest/docs/react/reference/useQuery | TanStack Query `useQuery` reference} for more information about the return value. + */ +export const useBasket = ( + apiOptions: NullableParameters>, + queryOptions: ApiQueryOptions = {} +): UseQueryResult> => { + type Options = Argument + type Data = DataType + const client = useCommerceApi(CLIENT_KEY) + const methodName = 'getBasket' + const requiredParameters = ShopperBasketsV2.paramKeys[`${methodName}Required`] + + // Parameters can be set in `apiOptions` or `client.clientConfig`; + // we must merge them in order to generate the correct query key. + const netOptions = omitNullableParameters(mergeOptions(client, apiOptions)) + const parameters = pickValidParams( + netOptions.parameters, + ShopperBasketsV2.paramKeys[methodName] + ) + const queryKey = queryKeyHelpers[methodName].queryKey(netOptions.parameters) + // We don't use `netOptions` here because we manipulate the options in `useQuery`. + const method = async (options: Options) => await client[methodName](options) + + queryOptions.meta = { + displayName: 'useBasket', + ...queryOptions.meta + } + + // For some reason, if we don't explicitly set these generic parameters, the inferred type for + // `Data` sometimes, but not always, includes `Response`, which is incorrect. I don't know why. + return useQuery({...netOptions, parameters}, queryOptions, { + method, + queryKey, + requiredParameters + }) +} +/** + * Gets applicable payment methods for an existing basket considering the open payment amount only. + * @group ShopperBaskets + * @category Query + * @parameter apiOptions - Options to pass through to `commerce-sdk-isomorphic`, with `null` accepted for unset API parameters. + * @parameter queryOptions - TanStack Query query options, with `enabled` by default set to check that all required API parameters have been set. + * @returns A TanStack Query query hook with data from the Shopper Baskets `getPaymentMethodsForBasket` endpoint. + * @see {@link https://developer.salesforce.com/docs/commerce/commerce-api/references/shopper-baskets?meta=getPaymentMethodsForBasket| Salesforce Developer Center} for more information about the API endpoint. + * @see {@link https://salesforcecommercecloud.github.io/commerce-sdk-isomorphic/classes/shopperbaskets.shopperbaskets-1.html#getpaymentmethodsforbasket | `commerce-sdk-isomorphic` documentation} for more information on the parameters and returned data type. + * @see {@link https://tanstack.com/query/latest/docs/react/reference/useQuery | TanStack Query `useQuery` reference} for more information about the return value. + */ +export const usePaymentMethodsForBasket = ( + apiOptions: NullableParameters>, + queryOptions: ApiQueryOptions = {} +): UseQueryResult> => { + type Options = Argument + type Data = DataType + const client = useCommerceApi(CLIENT_KEY) + const methodName = 'getPaymentMethodsForBasket' + const requiredParameters = ShopperBasketsV2.paramKeys[`${methodName}Required`] + + // Parameters can be set in `apiOptions` or `client.clientConfig`; + // we must merge them in order to generate the correct query key. + const netOptions = omitNullableParameters(mergeOptions(client, apiOptions)) + const parameters = pickValidParams( + netOptions.parameters, + ShopperBasketsV2.paramKeys[methodName] + ) + const queryKey = queryKeyHelpers[methodName].queryKey(netOptions.parameters) + // We don't use `netOptions` here because we manipulate the options in `useQuery`. + const method = async (options: Options) => await client[methodName](options) + + queryOptions.meta = { + displayName: 'usePaymentMethodsForBasket', + ...queryOptions.meta + } + + // For some reason, if we don't explicitly set these generic parameters, the inferred type for + // `Data` sometimes, but not always, includes `Response`, which is incorrect. I don't know why. + return useQuery({...netOptions, parameters}, queryOptions, { + method, + queryKey, + requiredParameters + }) +} +/** + * Gets applicable price books for an existing basket. + * @group ShopperBaskets + * @category Query + * @parameter apiOptions - Options to pass through to `commerce-sdk-isomorphic`, with `null` accepted for unset API parameters. + * @parameter queryOptions - TanStack Query query options, with `enabled` by default set to check that all required API parameters have been set. + * @returns A TanStack Query query hook with data from the Shopper Baskets `getPriceBooksForBasket` endpoint. + * @see {@link https://developer.salesforce.com/docs/commerce/commerce-api/references/shopper-baskets?meta=getPriceBooksForBasket| Salesforce Developer Center} for more information about the API endpoint. + * @see {@link https://salesforcecommercecloud.github.io/commerce-sdk-isomorphic/classes/shopperbaskets.shopperbaskets-1.html#getpricebooksforbasket | `commerce-sdk-isomorphic` documentation} for more information on the parameters and returned data type. + * @see {@link https://tanstack.com/query/latest/docs/react/reference/useQuery | TanStack Query `useQuery` reference} for more information about the return value. + */ +export const usePriceBooksForBasket = ( + apiOptions: NullableParameters>, + queryOptions: ApiQueryOptions = {} +): UseQueryResult> => { + type Options = Argument + type Data = DataType + const client = useCommerceApi(CLIENT_KEY) + const methodName = 'getPriceBooksForBasket' + const requiredParameters = ShopperBasketsV2.paramKeys[`${methodName}Required`] + + // Parameters can be set in `apiOptions` or `client.clientConfig`; + // we must merge them in order to generate the correct query key. + const netOptions = omitNullableParameters(mergeOptions(client, apiOptions)) + const parameters = pickValidParams( + netOptions.parameters, + ShopperBasketsV2.paramKeys[methodName] + ) + const queryKey = queryKeyHelpers[methodName].queryKey(netOptions.parameters) + // We don't use `netOptions` here because we manipulate the options in `useQuery`. + const method = async (options: Options) => await client[methodName](options) + + queryOptions.meta = { + displayName: 'usePriceBooksForBasket', + ...queryOptions.meta + } + + // For some reason, if we don't explicitly set these generic parameters, the inferred type for + // `Data` sometimes, but not always, includes `Response`, which is incorrect. I don't know why. + return useQuery({...netOptions, parameters}, queryOptions, { + method, + queryKey, + requiredParameters + }) +} +/** + * Gets the applicable shipping methods for a certain shipment of a basket. + * @group ShopperBaskets + * @category Query + * @parameter apiOptions - Options to pass through to `commerce-sdk-isomorphic`, with `null` accepted for unset API parameters. + * @parameter queryOptions - TanStack Query query options, with `enabled` by default set to check that all required API parameters have been set. + * @returns A TanStack Query query hook with data from the Shopper Baskets `getShippingMethodsForShipment` endpoint. + * @see {@link https://developer.salesforce.com/docs/commerce/commerce-api/references/shopper-baskets?meta=getShippingMethodsForShipment| Salesforce Developer Center} for more information about the API endpoint. + * @see {@link https://salesforcecommercecloud.github.io/commerce-sdk-isomorphic/classes/shopperbaskets.shopperbaskets-1.html#getshippingmethodsforshipment | `commerce-sdk-isomorphic` documentation} for more information on the parameters and returned data type. + * @see {@link https://tanstack.com/query/latest/docs/react/reference/useQuery | TanStack Query `useQuery` reference} for more information about the return value. + */ +export const useShippingMethodsForShipment = ( + apiOptions: NullableParameters>, + queryOptions: ApiQueryOptions = {} +): UseQueryResult> => { + type Options = Argument + type Data = DataType + const client = useCommerceApi(CLIENT_KEY) + const methodName = 'getShippingMethodsForShipment' + const requiredParameters = ShopperBasketsV2.paramKeys[`${methodName}Required`] + + // Parameters can be set in `apiOptions` or `client.clientConfig`; + // we must merge them in order to generate the correct query key. + const netOptions = omitNullableParameters(mergeOptions(client, apiOptions)) + const parameters = pickValidParams( + netOptions.parameters, + ShopperBasketsV2.paramKeys[methodName] + ) + const queryKey = queryKeyHelpers[methodName].queryKey(netOptions.parameters) + // We don't use `netOptions` here because we manipulate the options in `useQuery`. + const method = async (options: Options) => await client[methodName](options) + + queryOptions.meta = { + displayName: 'useShippingMethodsForShipment', + ...queryOptions.meta + } + + // For some reason, if we don't explicitly set these generic parameters, the inferred type for + // `Data` sometimes, but not always, includes `Response`, which is incorrect. I don't know why. + return useQuery({...netOptions, parameters}, queryOptions, { + method, + queryKey, + requiredParameters + }) +} +/** + * This method gives you the external taxation data set by the PUT taxes API. This endpoint can be called only if external taxation mode was used for basket creation. See POST /baskets for more information. + * @group ShopperBaskets + * @category Query + * @parameter apiOptions - Options to pass through to `commerce-sdk-isomorphic`, with `null` accepted for unset API parameters. + * @parameter queryOptions - TanStack Query query options, with `enabled` by default set to check that all required API parameters have been set. + * @returns A TanStack Query query hook with data from the Shopper Baskets `getTaxesFromBasket` endpoint. + * @see {@link https://developer.salesforce.com/docs/commerce/commerce-api/references/shopper-baskets?meta=getTaxesFromBasket| Salesforce Developer Center} for more information about the API endpoint. + * @see {@link https://salesforcecommercecloud.github.io/commerce-sdk-isomorphic/classes/shopperbaskets.shopperbaskets-1.html#gettaxesfrombasket | `commerce-sdk-isomorphic` documentation} for more information on the parameters and returned data type. + * @see {@link https://tanstack.com/query/latest/docs/react/reference/useQuery | TanStack Query `useQuery` reference} for more information about the return value. + */ +export const useTaxesFromBasket = ( + apiOptions: NullableParameters>, + queryOptions: ApiQueryOptions = {} +): UseQueryResult> => { + type Options = Argument + type Data = DataType + const client = useCommerceApi(CLIENT_KEY) + const methodName = 'getTaxesFromBasket' + const requiredParameters = ShopperBasketsV2.paramKeys[`${methodName}Required`] + + // Parameters can be set in `apiOptions` or `client.clientConfig`; + // we must merge them in order to generate the correct query key. + const netOptions = omitNullableParameters(mergeOptions(client, apiOptions)) + const parameters = pickValidParams( + netOptions.parameters, + ShopperBasketsV2.paramKeys[methodName] + ) + const queryKey = queryKeyHelpers[methodName].queryKey(netOptions.parameters) + // We don't use `netOptions` here because we manipulate the options in `useQuery`. + const method = async (options: Options) => await client[methodName](options) + + queryOptions.meta = { + displayName: 'useTaxesFromBasket', + ...queryOptions.meta + } + + // For some reason, if we don't explicitly set these generic parameters, the inferred type for + // `Data` sometimes, but not always, includes `Response`, which is incorrect. I don't know why. + return useQuery({...netOptions, parameters}, queryOptions, { + method, + queryKey, + requiredParameters + }) +} diff --git a/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/queryKeyHelpers.ts b/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/queryKeyHelpers.ts new file mode 100644 index 0000000000..feb173302a --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperBasketsV2/queryKeyHelpers.ts @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2023, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {ShopperBasketsV2} from 'commerce-sdk-isomorphic' +import {Argument, ExcludeTail} from '../types' +import {pickValidParams} from '../utils' + +// We must use a client with no parameters in order to have required/optional match the API spec +type Client = ShopperBasketsV2<{shortCode: string}> +type Params = Partial['parameters']> +export type QueryKeys = { + getBasket: [ + '/commerce-sdk-react', + '/organizations/', + string | undefined, + '/baskets/', + string | undefined, + Params<'getBasket'> + ] + getPaymentMethodsForBasket: [ + '/commerce-sdk-react', + '/organizations/', + string | undefined, + '/baskets/', + string | undefined, + '/payment-methods', + Params<'getPaymentMethodsForBasket'> + ] + getPriceBooksForBasket: [ + '/commerce-sdk-react', + '/organizations/', + string | undefined, + '/baskets/', + string | undefined, + '/price-books', + Params<'getPriceBooksForBasket'> + ] + getShippingMethodsForShipment: [ + '/commerce-sdk-react', + '/organizations/', + string | undefined, + '/baskets/', + string | undefined, + '/shipments/', + string | undefined, + '/shipping-methods', + Params<'getShippingMethodsForShipment'> + ] + getTaxesFromBasket: [ + '/commerce-sdk-react', + '/organizations/', + string | undefined, + '/baskets/', + string | undefined, + '/taxes', + Params<'getTaxesFromBasket'> + ] +} + +// This is defined here, rather than `types.ts`, because it relies on `Client` and `QueryKeys`, +// and making those generic would add too much complexity. +type QueryKeyHelper = { + /** Generates the path component of the query key for an endpoint. */ + path: (params: Params) => ExcludeTail + /** Generates the full query key for an endpoint. */ + queryKey: (params: Params) => QueryKeys[T] +} + +export const getBasket: QueryKeyHelper<'getBasket'> = { + path: (params) => [ + '/commerce-sdk-react', + '/organizations/', + params?.organizationId, + '/baskets/', + params?.basketId + ], + queryKey: (params: Params<'getBasket'>) => { + return [ + ...getBasket.path(params), + pickValidParams(params || {}, ShopperBasketsV2.paramKeys.getBasket) + ] + } +} + +export const getPaymentMethodsForBasket: QueryKeyHelper<'getPaymentMethodsForBasket'> = { + path: (params) => [ + '/commerce-sdk-react', + '/organizations/', + params?.organizationId, + '/baskets/', + params?.basketId, + '/payment-methods' + ], + queryKey: (params: Params<'getPaymentMethodsForBasket'>) => { + return [ + ...getPaymentMethodsForBasket.path(params), + pickValidParams(params || {}, ShopperBasketsV2.paramKeys.getPaymentMethodsForBasket) + ] + } +} + +export const getPriceBooksForBasket: QueryKeyHelper<'getPriceBooksForBasket'> = { + path: (params) => [ + '/commerce-sdk-react', + '/organizations/', + params?.organizationId, + '/baskets/', + params?.basketId, + '/price-books' + ], + queryKey: (params: Params<'getPriceBooksForBasket'>) => { + return [ + ...getPriceBooksForBasket.path(params), + pickValidParams(params || {}, ShopperBasketsV2.paramKeys.getPriceBooksForBasket) + ] + } +} + +export const getShippingMethodsForShipment: QueryKeyHelper<'getShippingMethodsForShipment'> = { + path: (params) => [ + '/commerce-sdk-react', + '/organizations/', + params?.organizationId, + '/baskets/', + params?.basketId, + '/shipments/', + params?.shipmentId, + '/shipping-methods' + ], + queryKey: (params: Params<'getShippingMethodsForShipment'>) => { + return [ + ...getShippingMethodsForShipment.path(params), + pickValidParams(params || {}, ShopperBasketsV2.paramKeys.getShippingMethodsForShipment) + ] + } +} + +export const getTaxesFromBasket: QueryKeyHelper<'getTaxesFromBasket'> = { + path: (params) => [ + '/commerce-sdk-react', + '/organizations/', + params?.organizationId, + '/baskets/', + params?.basketId, + '/taxes' + ], + queryKey: (params: Params<'getTaxesFromBasket'>) => { + return [ + ...getTaxesFromBasket.path(params), + pickValidParams(params || {}, ShopperBasketsV2.paramKeys.getTaxesFromBasket) + ] + } +} diff --git a/packages/commerce-sdk-react/src/hooks/ShopperConfigurations/cache.ts b/packages/commerce-sdk-react/src/hooks/ShopperConfigurations/cache.ts index c596dee4bc..6711ee0dad 100644 --- a/packages/commerce-sdk-react/src/hooks/ShopperConfigurations/cache.ts +++ b/packages/commerce-sdk-react/src/hooks/ShopperConfigurations/cache.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023, Salesforce, Inc. + * Copyright (c) 2025, Salesforce, Inc. * All rights reserved. * SPDX-License-Identifier: BSD-3-Clause * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause diff --git a/packages/commerce-sdk-react/src/hooks/ShopperCustomers/index.test.ts b/packages/commerce-sdk-react/src/hooks/ShopperCustomers/index.test.ts index bb0dab1103..3c84e077ea 100644 --- a/packages/commerce-sdk-react/src/hooks/ShopperCustomers/index.test.ts +++ b/packages/commerce-sdk-react/src/hooks/ShopperCustomers/index.test.ts @@ -17,6 +17,7 @@ describe('Shopper Customers hooks', () => { // If this test fails: create a new query hook, add the endpoint to the mutations enum, // or add it to the `expected` array with a comment explaining "TODO" or "never" (and why). expect(unimplemented).toEqual([ + 'deleteCustomerPaymentMethodReference', // TODO: Implement when the endpoint exits closed beta 'getExternalProfile', // TODO: Implement when the endpoint exits closed beta 'getPublicProductListItems', // TODO: Implement when the endpoint exits closed beta 'registerExternalProfile' // TODO: Implement when the endpoint exits closed beta diff --git a/packages/commerce-sdk-react/src/hooks/ShopperLogin/index.test.ts b/packages/commerce-sdk-react/src/hooks/ShopperLogin/index.test.ts index cf1ce4712d..d84aca5b2f 100644 --- a/packages/commerce-sdk-react/src/hooks/ShopperLogin/index.test.ts +++ b/packages/commerce-sdk-react/src/hooks/ShopperLogin/index.test.ts @@ -21,8 +21,11 @@ describe('Shopper Login hooks', () => { // don't work well with the current implementation of mutation hooks. 'authenticateCustomer', 'authorizeWebauthnRegistration', + 'deletePasskeyCredential', + 'deletePasskeyUser', 'finishWebauthnAuthentication', 'finishWebauthnUserRegistration', + 'getPasskeyUserByLoginId', 'getTrustedAgentAuthorizationToken', 'startWebauthnAuthentication', 'startWebauthnUserRegistration' diff --git a/packages/commerce-sdk-react/src/hooks/ShopperOrders/cache.ts b/packages/commerce-sdk-react/src/hooks/ShopperOrders/cache.ts index a36bfba910..ca5b19f45e 100644 --- a/packages/commerce-sdk-react/src/hooks/ShopperOrders/cache.ts +++ b/packages/commerce-sdk-react/src/hooks/ShopperOrders/cache.ts @@ -47,5 +47,21 @@ export const cacheUpdateMatrix: CacheUpdateMatrix = { }, createPaymentInstrumentForOrder: updateOrderQuery, updatePaymentInstrumentForOrder: updateOrderQuery, - removePaymentInstrumentFromOrder: updateOrderQuery + removePaymentInstrumentFromOrder: updateOrderQuery, + failOrder(customerId, {parameters}) { + // Exclude reopenBasket (not valid for getOrder) and pass only valid parameters + // to getOrder.queryKey, while preserving common params like organizationId, siteId + const {orderNo, reopenBasket, ...orderParams} = parameters + const invalidate: CacheUpdateInvalidate[] = [ + {queryKey: getOrder.queryKey({...orderParams, orderNo})} + ] + // If reopenBasket is true, we should also invalidate customer baskets + // since a new basket may have been created + if (reopenBasket && customerId) { + invalidate.push({ + queryKey: getCustomerBaskets.queryKey({...orderParams, customerId}) + }) + } + return {invalidate} + } } diff --git a/packages/commerce-sdk-react/src/hooks/ShopperOrders/index.test.ts b/packages/commerce-sdk-react/src/hooks/ShopperOrders/index.test.ts index 3d31fca4f7..9ab9c6dbb4 100644 --- a/packages/commerce-sdk-react/src/hooks/ShopperOrders/index.test.ts +++ b/packages/commerce-sdk-react/src/hooks/ShopperOrders/index.test.ts @@ -16,7 +16,7 @@ describe('Shopper Orders hooks', () => { const unimplemented = getUnimplementedEndpoints(ShopperOrders, queries, mutations) // If this test fails: create a new query hook, add the endpoint to the mutations enum, // or add it to the `expected` array with a comment explaining "TODO" or "never" (and why). - expect(unimplemented).toEqual(['failOrder', 'guestOrderLookup']) + expect(unimplemented).toEqual(['guestOrderLookup']) }) test('all mutations have cache update logic', () => { // unimplemented = value in mutations enum, but no method in cache update matrix diff --git a/packages/commerce-sdk-react/src/hooks/ShopperOrders/mutation.ts b/packages/commerce-sdk-react/src/hooks/ShopperOrders/mutation.ts index 28ee27cc61..7e8d3e11ba 100644 --- a/packages/commerce-sdk-react/src/hooks/ShopperOrders/mutation.ts +++ b/packages/commerce-sdk-react/src/hooks/ShopperOrders/mutation.ts @@ -46,7 +46,13 @@ The payment instrument is added with the provided details. The payment method mu * Updates a payment instrument of an order. * @returns A TanStack Query mutation hook for interacting with the Shopper Orders `updatePaymentInstrumentForOrder` endpoint. */ - UpdatePaymentInstrumentForOrder: 'updatePaymentInstrumentForOrder' + UpdatePaymentInstrumentForOrder: 'updatePaymentInstrumentForOrder', + /** + * Fails an unplaced order and optionally reopens the basket when indicated. + * Creates a HistoryEntry in the failed Order with provided reasonCode. + * @returns A TanStack Query mutation hook for interacting with the Shopper Orders `failOrder` endpoint. + */ + FailOrder: 'failOrder' } as const /** diff --git a/packages/commerce-sdk-react/src/hooks/ShopperPayments/cache.ts b/packages/commerce-sdk-react/src/hooks/ShopperPayments/cache.ts new file mode 100644 index 0000000000..ed5655d5d7 --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperPayments/cache.ts @@ -0,0 +1,7 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +export const cacheUpdateMatrix = {} diff --git a/packages/commerce-sdk-react/src/hooks/ShopperPayments/index.test.ts b/packages/commerce-sdk-react/src/hooks/ShopperPayments/index.test.ts new file mode 100644 index 0000000000..05a1868ca0 --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperPayments/index.test.ts @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {ShopperPayments} from 'commerce-sdk-isomorphic' +import {getUnimplementedEndpoints} from '../../test-utils' +import {cacheUpdateMatrix} from './cache' +import * as queries from './query' + +describe('Shopper Payments hooks', () => { + test('all endpoints have hooks', () => { + // unimplemented = SDK method exists, but no query hook or value in mutations enum + const unimplemented = getUnimplementedEndpoints(ShopperPayments, queries) + // If this test fails: create a new query hook, add the endpoint to the mutations enum, + // or add it to the `expected` array with a comment explaining "TODO" or "never" (and why). + expect(unimplemented).toEqual([]) + }) + test('all mutations have cache update logic', () => { + // unimplemented = value in mutations enum, but no method in cache update matrix + const unimplemented = new Set() + Object.entries(cacheUpdateMatrix).forEach(([method, implementation]) => { + if (implementation) unimplemented.delete(method) + }) + // If this test fails: add cache update logic, remove the endpoint from the mutations enum, + // or add it to the `expected` array to indicate that it is still a TODO. + expect([...unimplemented]).toEqual([]) + }) +}) diff --git a/packages/commerce-sdk-react/src/hooks/ShopperPayments/index.ts b/packages/commerce-sdk-react/src/hooks/ShopperPayments/index.ts new file mode 100644 index 0000000000..fdf240423a --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperPayments/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +export * from './query' diff --git a/packages/commerce-sdk-react/src/hooks/ShopperPayments/query.test.ts b/packages/commerce-sdk-react/src/hooks/ShopperPayments/query.test.ts new file mode 100644 index 0000000000..e77f8292d0 --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperPayments/query.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import nock from 'nock' +import { + mockQueryEndpoint, + renderHookWithProviders, + waitAndExpectError, + waitAndExpectSuccess, + createQueryClient +} from '../../test-utils' + +import {Argument} from '../types' +import * as queries from './query' + +jest.mock('../../auth/index.ts', () => { + const {default: mockAuth} = jest.requireActual('../../auth/index.ts') + mockAuth.prototype.ready = jest.fn().mockResolvedValue({access_token: 'access_token'}) + return mockAuth +}) + +type Queries = typeof queries +const paymentsEndpoint = '/organizations/' +// All parameters are required for getPaymentConfiguration +const OPTIONS: Argument = { + parameters: { + organizationId: 'f_ecom_zzrmy_orgf_001', + siteId: 'RefArchGlobal', + currency: 'USD', + countryCode: 'US' + } +} + +// Mock data for payment configuration +const mockPaymentConfigurationData = { + paymentMethods: [ + { + id: 'CREDIT_CARD', + name: 'Credit Card' + } + ], + paymentMethodSetAccounts: [] +} + +describe('Shopper Payments query hooks', () => { + beforeEach(() => nock.cleanAll()) + afterEach(() => { + expect(nock.pendingMocks()).toHaveLength(0) + }) + + test('`usePaymentConfiguration` has meta.displayName defined', async () => { + mockQueryEndpoint(paymentsEndpoint, mockPaymentConfigurationData) + const queryClient = createQueryClient() + const {result} = renderHookWithProviders( + () => { + return queries.usePaymentConfiguration(OPTIONS) + }, + {queryClient} + ) + await waitAndExpectSuccess(() => result.current) + expect(queryClient.getQueryCache().getAll()[0].meta?.displayName).toBe( + 'usePaymentConfiguration' + ) + }) + + test('`usePaymentConfiguration` returns data on success', async () => { + mockQueryEndpoint(paymentsEndpoint, mockPaymentConfigurationData) + const {result} = renderHookWithProviders(() => { + return queries.usePaymentConfiguration(OPTIONS) + }) + await waitAndExpectSuccess(() => result.current) + expect(result.current.data).toEqual(mockPaymentConfigurationData) + }) + + test('`usePaymentConfiguration` returns error on error', async () => { + mockQueryEndpoint(paymentsEndpoint, {}, 400) + const {result} = renderHookWithProviders(() => { + return queries.usePaymentConfiguration(OPTIONS) + }) + await waitAndExpectError(() => result.current) + }) + + test('`usePaymentConfiguration` handles 500 server error', async () => { + mockQueryEndpoint(paymentsEndpoint, {}, 500) + const {result} = renderHookWithProviders(() => { + return queries.usePaymentConfiguration(OPTIONS) + }) + await waitAndExpectError(() => result.current) + }) +}) diff --git a/packages/commerce-sdk-react/src/hooks/ShopperPayments/query.ts b/packages/commerce-sdk-react/src/hooks/ShopperPayments/query.ts new file mode 100644 index 0000000000..9f5835e69d --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperPayments/query.ts @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {UseQueryResult} from '@tanstack/react-query' +import {ShopperPayments} from 'commerce-sdk-isomorphic' +import {ApiClients, ApiQueryOptions, Argument, DataType, NullableParameters} from '../types' +import {useQuery} from '../useQuery' +import {mergeOptions, omitNullableParameters, pickValidParams} from '../utils' +import * as queryKeyHelpers from './queryKeyHelpers' +import {CLIENT_KEYS} from '../../constant' +import useCommerceApi from '../useCommerceApi' + +const CLIENT_KEY = CLIENT_KEYS.SHOPPER_PAYMENTS +type Client = NonNullable + +/** + * Gets payment configuration. + * @group ShopperPayments + * @category Query + * @parameter apiOptions - Options to pass through to `commerce-sdk-isomorphic`, with `null` accepted for unset API parameters. + * @parameter queryOptions - TanStack Query query options, with `enabled` by default set to check that all required API parameters have been set. + * @returns A TanStack Query query hook with data from the Shopper Payments `getPaymentConfiguration` endpoint. + */ +export const usePaymentConfiguration = ( + apiOptions: NullableParameters>, + queryOptions: ApiQueryOptions = {} +): UseQueryResult> => { + type Options = Argument + type Data = DataType // ← Add this + const client = useCommerceApi(CLIENT_KEY) + const methodName = 'getPaymentConfiguration' + const requiredParameters = ShopperPayments.paramKeys[`${methodName}Required`] // ← Add this + + // Parameters can be set in `apiOptions` or `client.clientConfig`; + // we must merge them in order to generate the correct query key. + const netOptions = omitNullableParameters(mergeOptions(client, apiOptions)) + const parameters = pickValidParams(netOptions.parameters, ShopperPayments.paramKeys[methodName]) // ← Add this + const queryKey = queryKeyHelpers[methodName].queryKey(netOptions.parameters) + // We don't use `netOptions` here because we manipulate the options in `useQuery`. + const method = async (options: Options) => await client[methodName](options) + + queryOptions.meta = { + displayName: 'usePaymentConfiguration', + ...queryOptions.meta + } + + // ← Fix this call to match the pattern + return useQuery({...netOptions, parameters}, queryOptions, { + method, + queryKey, + requiredParameters + }) +} diff --git a/packages/commerce-sdk-react/src/hooks/ShopperPayments/queryKeyHelpers.ts b/packages/commerce-sdk-react/src/hooks/ShopperPayments/queryKeyHelpers.ts new file mode 100644 index 0000000000..bc0409e930 --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperPayments/queryKeyHelpers.ts @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {ShopperPayments} from 'commerce-sdk-isomorphic' +import {Argument, ExcludeTail} from '../types' +import {pickValidParams, omitNullable} from '../utils' + +// We must use a client with no parameters in order to have required/optional match the API spec +type Client = ShopperPayments<{shortCode: string}> +type Params = Partial['parameters']> + +export type QueryKeys = { + getPaymentConfiguration: [ + '/commerce-sdk-react', + '/organizations/', + string | undefined, + '/payments/', + Params<'getPaymentConfiguration'> + ] +} + +// This is defined here, rather than `types.ts`, because it relies on `Client` and `QueryKeys`, +// and making those generic would add too much complexity. +type QueryKeyHelper = { + /** Generates the path component of the query key for an endpoint. */ + path: (params: Params) => ExcludeTail + /** Generates the complete query key for an endpoint. */ + queryKey: (params: Params) => QueryKeys[T] +} + +export const getPaymentConfiguration: QueryKeyHelper<'getPaymentConfiguration'> = { + path: (params) => [ + '/commerce-sdk-react', + '/organizations/', + params?.organizationId, + '/payments/' + ], + queryKey: (params: Params<'getPaymentConfiguration'>) => { + return [ + ...getPaymentConfiguration.path(params), + // pickValidParams returns the filtered parameters, TypeScript sees that zoneId + // could be string | null, and complains because null isn't allowed in the query key. + // omitNullable removes null values from the parameters (zoneId is optional but NOT nullable) + omitNullable( + pickValidParams(params || {}, ShopperPayments.paramKeys.getPaymentConfiguration) + ) + ] + } +} diff --git a/packages/commerce-sdk-react/src/hooks/index.ts b/packages/commerce-sdk-react/src/hooks/index.ts index 43cc7daaad..b9e8ce82ab 100644 --- a/packages/commerce-sdk-react/src/hooks/index.ts +++ b/packages/commerce-sdk-react/src/hooks/index.ts @@ -5,6 +5,20 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ export * from './ShopperBaskets' +// V2 — available under explicit V2 names +export { + useBasket as useBasketV2, + usePaymentMethodsForBasket as usePaymentMethodsForBasketV2, + usePriceBooksForBasket as usePriceBooksForBasketV2, + useShippingMethodsForShipment as useShippingMethodsForShipmentV2, + useTaxesFromBasket as useTaxesFromBasketV2, + ShopperBasketsMutations as ShopperBasketsV2Mutations, + useShopperBasketsMutation as useShopperBasketsV2Mutation, + useShopperBasketsMutationHelper as useShopperBasketsV2MutationHelper +} from './ShopperBasketsV2' +// Only needed if consumers want to type-annotate variables with it. +export type {ShopperBasketsMutation as ShopperBasketsV2Mutation} from './ShopperBasketsV2' + export * from './ShopperConsents' export * from './ShopperContexts' export * from './ShopperCustomers' @@ -12,13 +26,14 @@ export * from './ShopperExperience' export * from './ShopperGiftCertificates' export * from './ShopperLogin' export * from './ShopperOrders' +export * from './ShopperPayments' export * from './ShopperProducts' export * from './ShopperPromotions' export * from './ShopperSearch' export * from './ShopperStores' export * from './ShopperSEO' -export * from './useAuthHelper' export * from './ShopperConfigurations' +export * from './useAuthHelper' export {default as useAccessToken} from './useAccessToken' export {default as useCommerceApi} from './useCommerceApi' export {default as useEncUserId} from './useEncUserId' diff --git a/packages/commerce-sdk-react/src/hooks/types.ts b/packages/commerce-sdk-react/src/hooks/types.ts index a86ded5753..6f6ab33b71 100644 --- a/packages/commerce-sdk-react/src/hooks/types.ts +++ b/packages/commerce-sdk-react/src/hooks/types.ts @@ -7,6 +7,7 @@ import {InvalidateQueryFilters, QueryFilters, Updater, UseQueryOptions} from '@tanstack/react-query' import { ShopperBaskets, + ShopperBasketsV2, ShopperConfigurations, ShopperConsents, ShopperContexts, @@ -15,6 +16,7 @@ import { ShopperGiftCertificates, ShopperLogin, ShopperOrders, + ShopperPayments, ShopperProducts, ShopperPromotions, ShopperSearch, @@ -87,6 +89,7 @@ export type ApiClientConfigParams = { */ export interface ApiClients { shopperBaskets?: ShopperBaskets + shopperBasketsV2?: ShopperBaskets shopperConsents?: ShopperConsents shopperContexts?: ShopperContexts shopperCustomers?: ShopperCustomers @@ -94,6 +97,7 @@ export interface ApiClients { shopperGiftCertificates?: ShopperGiftCertificates shopperLogin?: ShopperLogin shopperOrders?: ShopperOrders + shopperPayments?: ShopperPayments shopperProducts?: ShopperProducts shopperPromotions?: ShopperPromotions shopperSearch?: ShopperSearch diff --git a/packages/commerce-sdk-react/src/hooks/utils.test.ts b/packages/commerce-sdk-react/src/hooks/utils.test.ts index 72de593c6c..225ebd1be3 100644 --- a/packages/commerce-sdk-react/src/hooks/utils.test.ts +++ b/packages/commerce-sdk-react/src/hooks/utils.test.ts @@ -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 {ShopperBaskets} from 'commerce-sdk-isomorphic' +import {ShopperBasketsV2} from 'commerce-sdk-isomorphic' import {mergeOptions, getCustomKeys, pickValidParams} from './utils' describe('Hook utils', () => { @@ -17,7 +17,7 @@ describe('Hook utils', () => { organizationId: 'organizationId', siteId: 'siteId' } - const client = new ShopperBaskets({ + const client = new ShopperBasketsV2({ parameters: { ...config, clientParameter: 'clientParameter' diff --git a/packages/commerce-sdk-react/src/provider.tsx b/packages/commerce-sdk-react/src/provider.tsx index bb4ce39f1d..70f4305171 100644 --- a/packages/commerce-sdk-react/src/provider.tsx +++ b/packages/commerce-sdk-react/src/provider.tsx @@ -11,6 +11,7 @@ import {Logger} from './types' import {DWSID_COOKIE_NAME, SERVER_AFFINITY_HEADER_KEY} from './constant' import { ShopperBaskets, + ShopperBasketsV2, ShopperConsents, ShopperContexts, ShopperConfigurations, @@ -19,6 +20,7 @@ import { ShopperGiftCertificates, ShopperLogin, ShopperOrders, + ShopperPayments, ShopperProducts, ShopperPromotions, ShopperSearch, @@ -258,6 +260,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => { return { shopperBaskets: new ShopperBaskets(config), + shopperBasketsV2: new ShopperBasketsV2(config), shopperConsents: new ShopperConsents(config), shopperContexts: new ShopperContexts(config), shopperConfigurations: new ShopperConfigurations(config), @@ -269,6 +272,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => { proxy: enablePWAKitPrivateClient ? privateClientProxyEndpoint : config.proxy }), shopperOrders: new ShopperOrders(config), + shopperPayments: new ShopperPayments(config), shopperProducts: new ShopperProducts(config), shopperPromotions: new ShopperPromotions(config), shopperSearch: new ShopperSearch(config), diff --git a/packages/pwa-kit-create-app/CHANGELOG.md b/packages/pwa-kit-create-app/CHANGELOG.md index 364c3cb416..1bb10bd054 100644 --- a/packages/pwa-kit-create-app/CHANGELOG.md +++ b/packages/pwa-kit-create-app/CHANGELOG.md @@ -1,4 +1,5 @@ ## v3.17.0-dev +- Add Salesforce Payments configuration to generated projects - Clear verdaccio npm cache during project generation [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652) - Add Node 24 support, remove legacy `url` module import. Drop Node 16 support [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652) diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs index c59809fc7b..af6d6da727 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs @@ -157,6 +157,18 @@ module.exports = { groupBonusProductsWithQualifyingProduct: true } }, + // Salesforce Payments configuration + // Set enabled to true to enable Salesforce Payments (requires the Salesforce Payments feature toggle to be enabled on the Commerce Cloud instance). + // Set enabled to false to disable Salesforce Payments on the storefront (the Commerce Cloud feature toggle is unaffected). + // sdkUrl and metadataUrl are hosted on your Commerce Cloud instance. Replace with your instance hostname. + // This may be a demandware.net hostname (e.g., myinstance.unified.demandware.net) or a vanity/custom hostname. + // sdkUrl: 'https:///on/demandware.static/Sites-Site/-/-/internal/jscript/sfp/v1/sfp.js' + // metadataUrl: 'https:///on/demandware.static/Sites-Site/-/-/internal/metadata/v1.json' + sfPayments: { + enabled: false, + sdkUrl: '', + metadataUrl: '' + }, // Google Cloud api config googleCloudAPI: { apiKey: process.env.GOOGLE_CLOUD_API_KEY diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs index 967e5826c3..65389f9e58 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs @@ -345,14 +345,22 @@ const {handler} = runtime.createHandler(options, (app) => { 'img-src': [ // Default source for product images - replace with your CDN '*.commercecloud.salesforce.com', - '*.demandware.net' + '*.demandware.net', + '*.adyen.com' // Payment gateways ], 'script-src': [ // Used by the service worker in /worker/main.js 'storage.googleapis.com', // Connect to Google Cloud APIs 'maps.googleapis.com', - 'places.googleapis.com' + 'places.googleapis.com', + // Payment gateways + '*.stripe.com', + '*.paypal.com', + '*.adyen.com', + 'pay.google.com', + 'www.gstatic.com', + '*.demandware.net' // Used to load a valid payment scripts in test environment ], 'connect-src': [ // Connect to Einstein APIs @@ -363,11 +371,25 @@ const {handler} = runtime.createHandler(options, (app) => { 'maps.googleapis.com', 'places.googleapis.com', // Connect to SCRT2 URLs - '*.salesforce-scrt.com' + '*.salesforce-scrt.com', + // Payment gateways + '*.demandware.net', // Used to load a valid payment scripts in test environment + '*.adyen.com', + '*.paypal.com', + 'pay.google.com', + 'payments.google.com', + 'google.com', + 'www.google.com' ], 'frame-src': [ // Allow frames from Salesforce site.com (Needed for MIAW) - '*.site.com' + '*.site.com', + // Payment gateways + '*.stripe.com', + '*.paypal.com', + '*.adyen.com', + 'payments.google.com', + 'pay.google.com' ] } } @@ -429,6 +451,45 @@ const {handler} = runtime.createHandler(options, (app) => { app.get('/favicon.ico', runtime.serveStaticFile('static/ico/favicon.ico')) app.get('/worker.js(.map)?', runtime.serveServiceWorker) + + // Helper function to transform relative icon paths to absolute URLs + function transformIconPaths(data, ecomServerHost) { + const baseUrl = `https://${ecomServerHost}/on/demandware.static/Sites-Site/-/-/internal` + const methodTypes = data?.paymentMethodTypes + if (methodTypes) { + for (const method of Object.values(methodTypes)) { + for (const image of method.images ?? []) { + if (image.src?.startsWith('/icons/')) { + image.src = `${baseUrl}${image.src}` + } + } + } + } + return data + } + + // Helper function to fetch payment metadata from the Commerce Cloud instance + app.get('/api/payment-metadata', async (req, res) => { + try { + const response = await fetch(config.app.sfPayments.metadataUrl, { + headers: { Accept: 'application/json' } + }) + if (!response.ok) { + throw new Error(`Metadata request failed with status: ${response.status}`) + } + const data = await response.json() + const transformedData = transformIconPaths(data, new URL(config.app.sfPayments.metadataUrl).hostname) + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Content-Type', 'application/json') + res.json(transformedData) + } catch (error) { + res.status(500).json({ + error: 'Failed to fetch metadata', + details: error.message + }) + } + }) + app.get('*', runtime.render) }) // SSR requires that we export a single handler function called 'get', that diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs index 967e5826c3..65389f9e58 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs @@ -345,14 +345,22 @@ const {handler} = runtime.createHandler(options, (app) => { 'img-src': [ // Default source for product images - replace with your CDN '*.commercecloud.salesforce.com', - '*.demandware.net' + '*.demandware.net', + '*.adyen.com' // Payment gateways ], 'script-src': [ // Used by the service worker in /worker/main.js 'storage.googleapis.com', // Connect to Google Cloud APIs 'maps.googleapis.com', - 'places.googleapis.com' + 'places.googleapis.com', + // Payment gateways + '*.stripe.com', + '*.paypal.com', + '*.adyen.com', + 'pay.google.com', + 'www.gstatic.com', + '*.demandware.net' // Used to load a valid payment scripts in test environment ], 'connect-src': [ // Connect to Einstein APIs @@ -363,11 +371,25 @@ const {handler} = runtime.createHandler(options, (app) => { 'maps.googleapis.com', 'places.googleapis.com', // Connect to SCRT2 URLs - '*.salesforce-scrt.com' + '*.salesforce-scrt.com', + // Payment gateways + '*.demandware.net', // Used to load a valid payment scripts in test environment + '*.adyen.com', + '*.paypal.com', + 'pay.google.com', + 'payments.google.com', + 'google.com', + 'www.google.com' ], 'frame-src': [ // Allow frames from Salesforce site.com (Needed for MIAW) - '*.site.com' + '*.site.com', + // Payment gateways + '*.stripe.com', + '*.paypal.com', + '*.adyen.com', + 'payments.google.com', + 'pay.google.com' ] } } @@ -429,6 +451,45 @@ const {handler} = runtime.createHandler(options, (app) => { app.get('/favicon.ico', runtime.serveStaticFile('static/ico/favicon.ico')) app.get('/worker.js(.map)?', runtime.serveServiceWorker) + + // Helper function to transform relative icon paths to absolute URLs + function transformIconPaths(data, ecomServerHost) { + const baseUrl = `https://${ecomServerHost}/on/demandware.static/Sites-Site/-/-/internal` + const methodTypes = data?.paymentMethodTypes + if (methodTypes) { + for (const method of Object.values(methodTypes)) { + for (const image of method.images ?? []) { + if (image.src?.startsWith('/icons/')) { + image.src = `${baseUrl}${image.src}` + } + } + } + } + return data + } + + // Helper function to fetch payment metadata from the Commerce Cloud instance + app.get('/api/payment-metadata', async (req, res) => { + try { + const response = await fetch(config.app.sfPayments.metadataUrl, { + headers: { Accept: 'application/json' } + }) + if (!response.ok) { + throw new Error(`Metadata request failed with status: ${response.status}`) + } + const data = await response.json() + const transformedData = transformIconPaths(data, new URL(config.app.sfPayments.metadataUrl).hostname) + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Content-Type', 'application/json') + res.json(transformedData) + } catch (error) { + res.status(500).json({ + error: 'Failed to fetch metadata', + details: error.message + }) + } + }) + app.get('*', runtime.render) }) // SSR requires that we export a single handler function called 'get', that diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs index 59b6bcb016..c0f0673658 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs @@ -157,6 +157,18 @@ module.exports = { groupBonusProductsWithQualifyingProduct: true } }, + // Salesforce Payments configuration + // Set enabled to true to enable Salesforce Payments (requires the Salesforce Payments feature toggle to be enabled on the Commerce Cloud instance). + // Set enabled to false to disable Salesforce Payments on the storefront (the Commerce Cloud feature toggle is unaffected). + // sdkUrl and metadataUrl are hosted on your Commerce Cloud instance. Replace with your instance hostname. + // This may be a demandware.net hostname (e.g., myinstance.unified.demandware.net) or a vanity/custom hostname. + // sdkUrl: 'https:///on/demandware.static/Sites-Site/-/-/internal/jscript/sfp/v1/sfp.js' + // metadataUrl: 'https:///on/demandware.static/Sites-Site/-/-/internal/metadata/v1.json' + sfPayments: { + enabled: false, + sdkUrl: '', + metadataUrl: '' + }, // Google Cloud api config googleCloudAPI: { apiKey: process.env.GOOGLE_CLOUD_API_KEY diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index 3965e4c2e3..b35b457380 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -1,4 +1,5 @@ ## v9.1.0-dev +- [Feature] Add Salesforce Payments support in checkout - Update jest-fetch-mock and Jest 29 dependencies [#3663](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3663) - Add Node 24 support. Drop Node 16 support [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652) - [Bugfix] Fix error toast for no applicable shipping methods in one-click checkout [#3673](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3673) diff --git a/packages/template-retail-react-app/app/assets/svg/paypal.svg b/packages/template-retail-react-app/app/assets/svg/paypal-icon.svg similarity index 100% rename from packages/template-retail-react-app/app/assets/svg/paypal.svg rename to packages/template-retail-react-app/app/assets/svg/paypal-icon.svg diff --git a/packages/template-retail-react-app/app/components/_app/index.jsx b/packages/template-retail-react-app/app/components/_app/index.jsx index 565418dd43..8ef320b923 100644 --- a/packages/template-retail-react-app/app/components/_app/index.jsx +++ b/packages/template-retail-react-app/app/components/_app/index.jsx @@ -15,7 +15,7 @@ import {useQuery} from '@tanstack/react-query' import { useAccessToken, useCategory, - useShopperBasketsMutation + useShopperBasketsV2Mutation as useShopperBasketsMutation } from '@salesforce/commerce-sdk-react' import logger from '@salesforce/retail-react-app/app/utils/logger-instance' import {useAppOrigin} from '@salesforce/retail-react-app/app/hooks/use-app-origin' diff --git a/packages/template-retail-react-app/app/components/bonus-product-view-modal/index.jsx b/packages/template-retail-react-app/app/components/bonus-product-view-modal/index.jsx index b9456f4380..96f58b4fac 100644 --- a/packages/template-retail-react-app/app/components/bonus-product-view-modal/index.jsx +++ b/packages/template-retail-react-app/app/components/bonus-product-view-modal/index.jsx @@ -23,7 +23,7 @@ import ProductView from '@salesforce/retail-react-app/app/components/product-vie import {useProductViewModal} from '@salesforce/retail-react-app/app/hooks/use-product-view-modal' import {useControlledVariations} from '@salesforce/retail-react-app/app/hooks/use-controlled-variations' import {useIntl} from 'react-intl' -import {useShopperBasketsMutationHelper} from '@salesforce/commerce-sdk-react' +import {useShopperBasketsV2MutationHelper as useShopperBasketsMutationHelper} from '@salesforce/commerce-sdk-react' import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' import {processProductsForBonusCart} from '@salesforce/retail-react-app/app/utils/bonus-product/cart' import {useBonusProductCounts} from '@salesforce/retail-react-app/app/utils/bonus-product/hooks' diff --git a/packages/template-retail-react-app/app/components/bonus-product-view-modal/index.test.js b/packages/template-retail-react-app/app/components/bonus-product-view-modal/index.test.js index 54fd50829e..f0c7bc6e82 100644 --- a/packages/template-retail-react-app/app/components/bonus-product-view-modal/index.test.js +++ b/packages/template-retail-react-app/app/components/bonus-product-view-modal/index.test.js @@ -22,7 +22,7 @@ import { } from '@salesforce/retail-react-app/app/utils/bonus-product/hooks' import {processProductsForBonusCart} from '@salesforce/retail-react-app/app/utils/bonus-product/cart' import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' -import {useShopperBasketsMutationHelper} from '@salesforce/commerce-sdk-react' +import {useShopperBasketsV2MutationHelper as useShopperBasketsMutationHelper} from '@salesforce/commerce-sdk-react' import {useProductViewModal} from '@salesforce/retail-react-app/app/hooks/use-product-view-modal' // Mock the use-product-view-modal hook at the top @@ -32,7 +32,7 @@ jest.mock('@salesforce/retail-react-app/app/hooks/use-product-view-modal', () => // Mock commerce-sdk-react for CommerceApiProvider jest.mock('@salesforce/commerce-sdk-react', () => ({ - useShopperBasketsMutationHelper: jest.fn(), + useShopperBasketsV2MutationHelper: jest.fn(), useCustomerId: jest.fn(() => 'test-customer-id'), useCustomerType: jest.fn(() => ({ isRegistered: true, @@ -45,6 +45,13 @@ jest.mock('@salesforce/commerce-sdk-react', () => ({ useShopperCustomersMutation: jest.fn(() => ({ mutateAsync: jest.fn() })), + useConfigurations: jest.fn(() => ({ + data: { + configurations: [] + }, + isLoading: false, + error: null + })), useProductSearch: jest.fn(() => ({ data: null, isLoading: false, diff --git a/packages/template-retail-react-app/app/components/forms/useAddressFields.jsx b/packages/template-retail-react-app/app/components/forms/useAddressFields.jsx index 20c2a0cc6f..45e3a6cddd 100644 --- a/packages/template-retail-react-app/app/components/forms/useAddressFields.jsx +++ b/packages/template-retail-react-app/app/components/forms/useAddressFields.jsx @@ -288,40 +288,50 @@ export default function useAddressFields({ name: `${prefix}stateCode`, label: formatMessage(countryCode === 'CA' ? messages.province : messages.state), defaultValue: '', - type: 'select', - options: [ - {value: '', label: ''}, - ...(countryCode === 'CA' ? provinceOptions : stateOptions) - ], + type: countryCode === 'US' || countryCode === 'CA' ? 'select' : 'text', + options: + countryCode === 'US' || countryCode === 'CA' + ? [ + {value: '', label: ''}, + ...(countryCode === 'CA' ? provinceOptions : stateOptions) + ] + : undefined, rules: { required: countryCode === 'CA' ? 'Please select your province.' // FYI we won't translate this - : formatMessage({ + : countryCode === 'US' + ? formatMessage({ defaultMessage: 'Please select your state.', id: 'use_address_fields.error.please_select_your_state_or_province', description: 'Error message for a blank state (US-specific checkout)' }) + : false }, error: errors[`${prefix}stateCode`], control }, postalCode: { name: `${prefix}postalCode`, - label: formatMessage(countryCode === 'CA' ? messages.postalCode : messages.zipCode), + label: formatMessage(countryCode === 'US' ? messages.zipCode : messages.postalCode), defaultValue: '', type: 'text', autoComplete: 'postal-code', rules: { required: countryCode === 'CA' - ? 'Please enter your postal code.' // FYI we won't translate this - : formatMessage({ + ? 'Please enter your postal code.' + : countryCode === 'US' + ? formatMessage({ defaultMessage: 'Please enter your zip code.', id: 'use_address_fields.error.please_enter_your_postal_or_zip', description: 'Error message for a blank zip code (US-specific checkout)' }) + : formatMessage({ + defaultMessage: 'Please enter your postal code.', + id: 'use_address_fields.error.please_enter_postal_code' + }) }, error: errors[`${prefix}postalCode`], control diff --git a/packages/template-retail-react-app/app/components/icons/index.jsx b/packages/template-retail-react-app/app/components/icons/index.jsx index fae32ffea6..64fa511780 100644 --- a/packages/template-retail-react-app/app/components/icons/index.jsx +++ b/packages/template-retail-react-app/app/components/icons/index.jsx @@ -70,7 +70,7 @@ import CVVSymbol from '@salesforce/retail-react-app/app/assets/svg/cc-cvv.svg' import DiscoverSymbol from '@salesforce/retail-react-app/app/assets/svg/cc-discover.svg' import LocationSymbol from '@salesforce/retail-react-app/app/assets/svg/location.svg' import MastercardSymbol from '@salesforce/retail-react-app/app/assets/svg/cc-mastercard.svg' -import PaypalSymbol from '@salesforce/retail-react-app/app/assets/svg/paypal.svg' +import PaypalSymbol from '@salesforce/retail-react-app/app/assets/svg/paypal-icon.svg' import SocialPinterestSymbol from '@salesforce/retail-react-app/app/assets/svg/social-pinterest.svg' import VisaSymbol from '@salesforce/retail-react-app/app/assets/svg/cc-visa.svg' @@ -189,7 +189,7 @@ export const LockIcon = icon( } ) export const LocationIcon = icon('location') -export const PaypalIcon = icon('paypal', {viewBox: PaypalSymbol.viewBox}) +export const PaypalIcon = icon('paypal-icon', {viewBox: PaypalSymbol.viewBox}) export const PlugIcon = icon('plug') export const PlusIcon = icon('plus') export const MastercardIcon = icon('cc-mastercard', {viewBox: MastercardSymbol.viewBox}) diff --git a/packages/template-retail-react-app/app/components/product-view/index.jsx b/packages/template-retail-react-app/app/components/product-view/index.jsx index 1a889f5650..181e4a29e9 100644 --- a/packages/template-retail-react-app/app/components/product-view/index.jsx +++ b/packages/template-retail-react-app/app/components/product-view/index.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, {forwardRef, useEffect, useMemo, useRef, useState} from 'react' +import React, {forwardRef, useEffect, useMemo, useRef, useState, useCallback} from 'react' import PropTypes from 'prop-types' import {useLocation} from 'react-router-dom' import {useIntl, FormattedMessage} from 'react-intl' @@ -34,6 +34,11 @@ import {useCurrency, useDerivedProduct} from '@salesforce/retail-react-app/app/h import {useAddToCartModalContext} from '@salesforce/retail-react-app/app/hooks/use-add-to-cart-modal' import {STORE_LOCATOR_IS_ENABLED} from '@salesforce/retail-react-app/app/constants' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' +import {useShopperBasketsV2Mutation as useShopperBasketsMutation} from '@salesforce/commerce-sdk-react' +import { + useSFPaymentsEnabled, + useSFPayments +} from '@salesforce/retail-react-app/app/hooks/use-sf-payments' // project components import ImageGallery from '@salesforce/retail-react-app/app/components/image-gallery' @@ -50,6 +55,10 @@ import Swatch from '@salesforce/retail-react-app/app/components/swatch-group/swa import SwatchGroup from '@salesforce/retail-react-app/app/components/swatch-group' import {getPriceData} from '@salesforce/retail-react-app/app/utils/product-utils' import PromoCallout from '@salesforce/retail-react-app/app/components/product-tile/promo-callout' +import SFPaymentsExpressButtons from '@salesforce/retail-react-app/app/components/sf-payments-express-buttons' +import {EXPRESS_BUY_NOW} from '@salesforce/retail-react-app/app/hooks/use-sf-payments' +import LoadingSpinner from '@salesforce/retail-react-app/app/components/loading-spinner' +import {useCleanupTemporaryBaskets} from '@salesforce/retail-react-app/app/hooks/use-cleanup-temporary-baskets' const ProductViewHeader = ({ name, @@ -167,7 +176,12 @@ const ProductView = forwardRef( onOpen: onAddToCartModalOpen, onClose: onAddToCartModalClose } = useAddToCartModalContext() + + const {mutateAsync: createBasket} = useShopperBasketsMutation('createBasket') + const {mutateAsync: addItemToBasket} = useShopperBasketsMutation('addItemToBasket') + const theme = useTheme() + const {confirmingBasket} = useSFPayments() const [showOptionsMessage, toggleShowOptionsMessage] = useState(false) const { showLoading, @@ -203,6 +217,7 @@ const ProductView = forwardRef( const [pickupEnabled, setPickupEnabled] = useState(false) const storeName = selectedStore?.name const inventoryId = selectedStore?.inventoryId + const sfPaymentsEnabled = useSFPaymentsEnabled() const {disableButton, customInventoryMessage} = useMemo(() => { let shouldDisableButton = showInventoryMessage @@ -266,6 +281,69 @@ const ProductView = forwardRef( return hasValidSelection } + const cleanupTemporaryBaskets = useCleanupTemporaryBaskets() + + // prepareBasket is used to prepare the basket for express payments + // useCallback recreates prepareBasket primarily when product or quantity change, along with variant, stockLevel, and product type flags (isProductASet, isProductABundle) + const prepareBasket = useCallback(async () => { + // Validate that all attributes are selected before proceeding + const hasValidSelection = validateOrderability(variant, product, quantity, stockLevel) + let errorMessage = '' + + if (!hasValidSelection && !isProductASet && !isProductABundle) { + toggleShowOptionsMessage(true) + if (errorContainerRef.current) { + errorContainerRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }) + } + errorMessage = intl.formatMessage({ + defaultMessage: + 'Please select all product options before proceeding with Express Payments', + id: 'product_view.prepareBasket' + }) + + const error = new Error(errorMessage) + error.isValidationError = true + throw error + } + + // Clean up temporary baskets before creating a new one + await cleanupTemporaryBaskets() + + // Create a new temporary basket + const newBasket = await createBasket({ + parameters: { + temporary: true + }, + body: {} + }) + + const selectedProduct = variant || product + // Use variant's productId if variant is selected, otherwise use product's id + const productIdToUse = selectedProduct?.productId || selectedProduct?.id + + if (!productIdToUse) { + errorMessage = intl.formatMessage({ + defaultMessage: 'Unable to determine product ID for basket', + id: 'product_view.prepareBasket' + }) + throw new Error(errorMessage) + } + // Add the product to the temporary basket + const basketWithItem = await addItemToBasket({ + parameters: {basketId: newBasket.basketId}, + body: [ + { + productId: productIdToUse, + quantity: quantity + } + ] + }) + + return basketWithItem + }, [variant, product, quantity, stockLevel, isProductASet, isProductABundle]) const renderActionButtons = () => { const buttons = [] @@ -391,6 +469,27 @@ const ProductView = forwardRef( ) } + if ( + sfPaymentsEnabled && + !isProductASet && + !isProductPartOfBundle && + activeCurrency && + priceData.currentPrice + ) { + buttons.push( + + ) + } + // Add custom buttons if provided if (customButtons && customButtons.length > 0) { customButtons.forEach((customButton, index) => { @@ -907,6 +1006,9 @@ const ProductView = forwardRef( > {renderActionButtons()} + + {/* Loading overlay during express payment confirmation */} + {confirmingBasket && } ) } diff --git a/packages/template-retail-react-app/app/components/product-view/index.test.js b/packages/template-retail-react-app/app/components/product-view/index.test.js index de49561c32..98787b5440 100644 --- a/packages/template-retail-react-app/app/components/product-view/index.test.js +++ b/packages/template-retail-react-app/app/components/product-view/index.test.js @@ -21,6 +21,7 @@ import userEvent from '@testing-library/user-event' import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' import frMessages from '@salesforce/retail-react-app/app/static/translations/compiled/fr-FR.json' import {useSelectedStore} from '@salesforce/retail-react-app/app/hooks/use-selected-store' +import {rest} from 'msw' // Ensure useMultiSite returns site.id = 'site-1' for all tests jest.mock('@salesforce/retail-react-app/app/hooks/use-multi-site', () => ({ @@ -72,7 +73,63 @@ beforeEach(() => { error: null, hasSelectedStore: true })) + + // Reset MSW handlers to avoid conflicts + global.server.resetHandlers() + + // Add MSW handlers to avoid 403 errors + global.server.use( + rest.get('*/customers/:customerId/product-lists', (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(200), + ctx.json({ + data: [], + total: 0 + }) + ) + }), + rest.post('*/customers/:customerId/product-lists', (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(200), + ctx.json({ + id: 'test-list-id', + type: 'wish_list' + }) + ) + }), + rest.get('*/configuration/shopper-configurations/*', (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(200), + ctx.json({ + configurations: [] + }) + ) + }), + rest.get('*/product/shopper-products/*', (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(200), + ctx.json({ + data: [] + }) + ) + }), + rest.get('*/api/payment-metadata', (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(200), + ctx.json({ + apiKey: 'test-key', + publishableKey: 'pk_test' + }) + ) + }) + ) }) + afterEach(() => { jest.clearAllMocks() sessionStorage.clear() diff --git a/packages/template-retail-react-app/app/components/promo-code/index.jsx b/packages/template-retail-react-app/app/components/promo-code/index.jsx index 7eaeae441c..b44ac5656b 100644 --- a/packages/template-retail-react-app/app/components/promo-code/index.jsx +++ b/packages/template-retail-react-app/app/components/promo-code/index.jsx @@ -20,7 +20,7 @@ import {useForm} from 'react-hook-form' import {ChevronDownIcon, ChevronUpIcon} from '@salesforce/retail-react-app/app/components/icons' import PromoCodeFields from '@salesforce/retail-react-app/app/components/forms/promo-code-fields' import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants' -import {useShopperBasketsMutation} from '@salesforce/commerce-sdk-react' +import {useShopperBasketsV2Mutation as useShopperBasketsMutation} from '@salesforce/commerce-sdk-react' import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' export const usePromoCode = () => { diff --git a/packages/template-retail-react-app/app/components/sf-payments-express-buttons/index.jsx b/packages/template-retail-react-app/app/components/sf-payments-express-buttons/index.jsx new file mode 100644 index 0000000000..ae4610e856 --- /dev/null +++ b/packages/template-retail-react-app/app/components/sf-payments-express-buttons/index.jsx @@ -0,0 +1,1045 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import React, {useEffect, useRef} from 'react' +import PropTypes from 'prop-types' +import {useIntl} from 'react-intl' + +import {Box} from '@salesforce/retail-react-app/app/components/shared/ui' +import logger from '@salesforce/retail-react-app/app/utils/logger-instance' +import {DEFAULT_SHIPMENT_ID} from '@salesforce/retail-react-app/app/constants' +import {useShopperBasketsV2Mutation as useShopperBasketsMutation} from '@salesforce/commerce-sdk-react' +import {useShopperOrdersMutation} from '@salesforce/commerce-sdk-react' +import {useShippingMethodsForShipmentV2 as useShippingMethodsForShipment} from '@salesforce/commerce-sdk-react' +import {usePaymentConfiguration} from '@salesforce/commerce-sdk-react' +import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' +import {useSFPaymentsCountry} from '@salesforce/retail-react-app/app/hooks/use-sf-payments-country' +import { + EXPRESS_BUY_NOW, + EXPRESS_PAY_NOW, + useSFPayments, + useAutomaticCapture +} from '@salesforce/retail-react-app/app/hooks/use-sf-payments' +import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast' +import { + buildTheme, + getSFPaymentsInstrument, + transformAddressDetails, + transformShippingMethods, + getSelectedShippingMethodId, + createPaymentInstrumentBody, + isPayPalPaymentMethodType, + getClientSecret, + getGatewayFromPaymentMethod, + getExpressPaymentMethodType +} from '@salesforce/retail-react-app/app/utils/sf-payments-utils' +import {PAYMENT_GATEWAYS} from '@salesforce/retail-react-app/app/constants' + +/* + These imports are needed during the failOrder process. The useAccessToken hook is not a commonly used hook + BUT it is needed to get the current order details and gaurd the failOrder process from failing + It's not a new pattern we are introducing: use-einstein.js uses it to get the access token + We are basically bypassing the React Query Wrapper and going directly to the Commerce API to get the order details + The React Query hooks are designed for declarative data fetching, so for imperative calls, you use the raw API client, which requires manual auth. + If we use the React Query hooks, we would need to wait for the query to complete before we can call the failOrder mutation + + The useQueryClient is needed to clear the stale cache when the basket can't be recovered +*/ +import {useCommerceApi, useAccessToken} from '@salesforce/commerce-sdk-react' +import {useQueryClient} from '@tanstack/react-query' + +const SFPaymentsExpressButtons = ({ + usage, + paymentCurrency, + paymentCountryCode, + initialAmount, + prepareBasket, + expressButtonLayout = 'vertical', + maximumButtonCount = undefined, + onPaymentMethodsRendered, + onExpressPaymentCompleted +}) => { + const intl = useIntl() + const navigate = useNavigation() + const toast = useToast() + const queryClient = useQueryClient() + const {countryCode: fallbackCountryCode} = useSFPaymentsCountry() + const {sfp, metadata, startConfirming, endConfirming} = useSFPayments() + + // Fetch payment configuration for the buyer's country. Falls back to 'US' if country + // detection hasn't resolved yet; React Query will re-fetch with the correct country once available. + // chances of not having a country code is very low with the country hook, so we can default to 'US' + const {data: paymentConfig} = usePaymentConfiguration({ + parameters: { + currency: paymentCurrency, + countryCode: paymentCountryCode || fallbackCountryCode || 'US' + //,zoneId: "stripeUSTest" //if you need to test with a different zone + } + }) + + const cardCaptureAutomatic = useAutomaticCapture() + const zoneId = paymentConfig?.zoneId + + const {mutateAsync: updateBillingAddressForBasket} = useShopperBasketsMutation( + 'updateBillingAddressForBasket' + ) + const updateShippingAddressForShipment = useShopperBasketsMutation( + 'updateShippingAddressForShipment' + ) + const updateShippingMethod = useShopperBasketsMutation('updateShippingMethodForShipment') + const {mutateAsync: addPaymentInstrumentToBasket} = useShopperBasketsMutation( + 'addPaymentInstrumentToBasket' + ) + const {mutateAsync: updatePaymentInstrumentForOrder} = useShopperOrdersMutation( + 'updatePaymentInstrumentForOrder' + ) + const {mutateAsync: createOrder} = useShopperOrdersMutation('createOrder') + const {mutateAsync: removePaymentInstrumentFromBasket} = useShopperBasketsMutation( + 'removePaymentInstrumentFromBasket' + ) + const {mutateAsync: deleteBasket} = useShopperBasketsMutation('deleteBasket') + + const {mutateAsync: failOrder} = useShopperOrdersMutation('failOrder') + + const expressBasket = useRef(null) + const prepareBasketPromise = useRef(null) + const containerElementRef = useRef(null) + const expressComponent = useRef(null) + const prepareBasketRef = useRef(prepareBasket) + const failOrderCalledRef = useRef(false) + const orderRef = useRef(null) + + // used to call failOrder + const api = useCommerceApi() + const {getTokenWhenReady} = useAccessToken() + + // tracks if payment is in progress + const isPaymentInProgress = useRef(false) + + // Update the ref whenever prepareBasket changes, including when the variant changes on PDP + // Using prepareBasketRef.current also ensures the function handlers always call the latest prepareBasket function + useEffect(() => { + prepareBasketRef.current = prepareBasket + }, [prepareBasket]) + + const {refetch: refetchShippingMethods} = useShippingMethodsForShipment( + { + parameters: { + basketId: expressBasket.current?.basketId, + shipmentId: DEFAULT_SHIPMENT_ID + } + }, + { + enabled: Boolean(expressBasket.current?.basketId) + } + ) + + const ERROR_MESSAGE_KEYS = { + DEFAULT: 'DEFAULT', + FAIL_ORDER: 'FAIL_ORDER', + PREPARE_BASKET: 'PREPARE_BASKET', + PROCESS_PAYMENT: 'PROCESS_PAYMENT', + ORDER_RECOVERY_FAILED: 'ORDER_RECOVERY_FAILED' + } + const ERROR_MESSAGES = { + DEFAULT: { + 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: 'sfp_payments_express.error.default' + }, + FAIL_ORDER: { + defaultMessage: + 'Payment processing failed. Your order has been cancelled and your basket has been restored. Please try again or select a different payment method.', + id: 'sfp_payments_express.error.fail_order' + }, + PREPARE_BASKET: { + defaultMessage: + 'Unable to prepare basket for express payments. Please select all required product attributes.', + id: 'sfp_payments_express.error.prepare_basket' + }, + PROCESS_PAYMENT: { + defaultMessage: + 'Unable to process payment. Please try again or select a different payment method.', + id: 'sfp_payments_express.error.process_payment' + }, + ORDER_RECOVERY_FAILED: { + defaultMessage: + 'Order recovery failed. Please try again or select a different payment method.', + id: 'sfp_payments_express.error.order_recovery_failed' + } + } + + const showErrorMessage = (messageKey = 'DEFAULT') => { + // If messageKey is a valid key in ERROR_MESSAGES, use it + if (ERROR_MESSAGES[messageKey]) { + toast({ + title: intl.formatMessage(ERROR_MESSAGES[messageKey]), + status: 'error' + }) + } else { + // Otherwise, treat it as a custom error message string + // (e.g., from e.message) or fallback to DEFAULT if empty + toast({ + title: messageKey || ERROR_MESSAGES.DEFAULT.defaultMessage, + status: 'error' + }) + } + } + + /** + * Validate current shipping method is still applicable, update to first applicable if not + */ + const validateAndUpdateShippingMethod = async ( + basketId, + currentBasket, + updatedShippingMethods + ) => { + const currentShippingMethodId = currentBasket.shipments[0].shippingMethod?.id + + if ( + !updatedShippingMethods.applicableShippingMethods.find( + (method) => method.id === currentShippingMethodId + ) + ) { + // If the current shipping method isn't set or is inapplicable, set it to the first applicable one + return await updateShippingMethod.mutateAsync({ + parameters: { + basketId: basketId, + shipmentId: DEFAULT_SHIPMENT_ID + }, + body: { + id: updatedShippingMethods.applicableShippingMethods[0].id + } + }) + } + + return currentBasket + } + + /** + * Attempts to fail an order and reopen the basket. + * Only calls failOrder if the order status supports the transition. + * @param {string} orderNo - The order number to fail + * @returns {Promise} - true if failOrder succeeded and basket was reopened + */ + const attemptFailOrder = async (orderNo) => { + if (!orderNo || failOrderCalledRef.current) { + return false + } + + try { + // Fetch current order status + const token = await getTokenWhenReady() + const currentOrder = await api.shopperOrders.getOrder({ + parameters: {orderNo}, + headers: {Authorization: `Bearer ${token}`} + }) + + // Only call failOrder if status allows the transition + if (currentOrder.status === 'created') { + await failOrder({ + parameters: {orderNo, reopenBasket: true}, + body: {reasonCode: 'payment_confirm_failure'} + }) + return true // Basket was reopened + } else { + return false // Can't recover basket + } + } catch (error) { + return false + } finally { + // Mark as attempted to prevent retries + failOrderCalledRef.current = true + // Refresh cache to get updated basket state + queryClient.invalidateQueries() + } + } + + /** + * Create order from basket and update payment instrument + */ + const createOrderAndUpdatePayment = async (basketId, paymentType, paymentData = {}) => { + // Create order from the basket + let order = await createOrder({ + body: {basketId} + }) + // Store orderNo immediately - basket is consumed at this point + const createdOrderNo = order.orderNo + + // Find SF Payments payment instrument in created order + const orderPaymentInstrument = getSFPaymentsInstrument(order) + + // Build the return URL (needed for updatePaymentInstrumentForOrder ) + const baseReturnUrl = `${window.location.protocol}//${window.location.host}/checkout/payment-processing` + paymentData.returnUrl = + baseReturnUrl + + '?orderNo=' + + encodeURIComponent(createdOrderNo) + + '&zoneId=' + + encodeURIComponent(zoneId) + + '&type=' + + encodeURIComponent(paymentType) + + try { + const paymentInstrumentBody = createPaymentInstrumentBody({ + amount: order.orderTotal, + paymentMethodType: paymentType, + zoneId: zoneId, + paymentData: paymentData, + paymentMethods: paymentConfig?.paymentMethods, + paymentMethodSetAccounts: paymentConfig?.paymentMethodSetAccounts + }) + + // Update order payment instrument to create payment + order = await updatePaymentInstrumentForOrder({ + parameters: { + orderNo: order.orderNo, + paymentInstrumentId: orderPaymentInstrument.paymentInstrumentId + }, + body: paymentInstrumentBody + }) + } catch (error) { + const statusCode = error?.response?.status || error?.status + const errorMessage = error?.message || error?.response?.data?.message || 'Unknown error' + const errorDetails = error?.response?.data || error?.body || {} + + logger.error('Failed to patch payment instrument to order', { + namespace: 'SFPaymentsExpressButtons.createOrderAndUpdatePayment', + additionalProperties: { + statusCode, + errorMessage, + errorDetails, + basketId: expressBasket.current?.basketId, + paymentMethodType: paymentType, + orderTotal: expressBasket.current?.orderTotal, + productSubTotal: expressBasket.current?.productSubTotal, + error: error + } + }) + const basketRecovered = await attemptFailOrder(createdOrderNo) + if (basketRecovered) { + showErrorMessage(ERROR_MESSAGE_KEYS.FAIL_ORDER) + } else { + showErrorMessage(ERROR_MESSAGE_KEYS.ORDER_RECOVERY_FAILED) + if (usage !== EXPRESS_BUY_NOW) { + navigate('/cart') + } + } + // Attach orderNo to the error so caller knows order was created + error.orderNo = createdOrderNo + throw error + } + + return order + } + + const createExpressCallback = (currentBasket, currentShippingMethods) => { + // Get currently selected shipping method + const selectedShippingMethodId = getSelectedShippingMethodId( + currentBasket, + currentShippingMethods + ) + + // Get representation of applicable shipping methods with the current one sorted at the top + const expressShippingMethods = transformShippingMethods( + currentShippingMethods.applicableShippingMethods, + currentBasket, + selectedShippingMethodId, + true + ) + + // Get representation of currently selected shipping method + const selectedShippingMethod = + expressShippingMethods.find((method) => method.id === selectedShippingMethodId) || + expressShippingMethods[0] + + // Create line items + const orderTotal = currentBasket?.orderTotal + const productSubTotal = currentBasket?.productSubTotal + const total = orderTotal || productSubTotal + + // Validate that total is a valid number + if (isNaN(total) || total <= 0) { + logger.error('Invalid total amount', { + namespace: 'SFPaymentsExpressButtons.createExpressCallback', + additionalProperties: {orderTotal, productSubTotal, initialAmount, total} + }) + throw new Error('Invalid basket total amount') + } + + const lineItems = [ + { + name: intl.formatMessage({ + defaultMessage: 'Subtotal', + id: 'order_summary.label.subtotal' + }), + amount: total.toString() + } + ] + // TODO: add discounts from currentBasket.orderPriceAdjustments + if (currentBasket.shippingTotal) { + lineItems.push({ + name: intl.formatMessage({ + defaultMessage: 'Shipping', + id: 'order_summary.label.shipping' + }), + amount: currentBasket.shippingTotal.toString() + }) + } + if (currentBasket.taxTotal) { + lineItems.push({ + name: intl.formatMessage({ + defaultMessage: 'Tax', + id: 'order_summary.label.tax' + }), + amount: currentBasket.taxTotal.toString() + }) + } + + return { + total: total.toString(), + shippingMethods: expressShippingMethods, + selectedShippingMethod: selectedShippingMethod, + lineItems: lineItems + } + } + + useEffect(() => { + // Remove containerElementRef.current from effect dependencies to prevent unnecessary re-renders + // instead use it in the if statement to check if the container element is attached to the DOM + if (metadata && sfp && paymentConfig && containerElementRef.current) { + // Skip re-initialization if payment is in progress + if (isPaymentInProgress.current && expressComponent.current) { + return + } + if (expressComponent.current) { + expressComponent.current.destroy() + expressComponent.current = null + } + + let paymentMethodType = null + orderRef.current = null + + const onClick = async (type) => { + // reset payment, order and failorder flags + isPaymentInProgress.current = true + failOrderCalledRef.current = false + orderRef.current = null + + paymentMethodType = getExpressPaymentMethodType( + type, + paymentConfig?.paymentMethods, + paymentConfig?.paymentMethodSetAccounts + ) + // For non-PayPal payment methods, prepare basket immediately + if (!isPayPalPaymentMethodType(paymentMethodType)) { + prepareBasketPromise.current = prepareBasketRef.current() + + // Don't await - call asynchronously to avoid a potential gateway timeout + prepareBasketPromise.current + .then((basket) => { + expressBasket.current = basket + }) + .catch((e) => { + prepareBasketPromise.current = null // Clear the promise so handlers don't try to await it + // Don't show toast for validation errors + if (!e.isValidationError) { + showErrorMessage(e.message || ERROR_MESSAGE_KEYS.PREPARE_BASKET) + } + }) + } + return { + amount: initialAmount.toString(), + shippingRates: [] + } + } + + // Helper function to clean up express basket state + const cleanupExpressBasket = async () => { + // If an order was already created, the basket was consumed - don't try to clean it up + if (orderRef.current) { + expressBasket.current = null + return + } + // Only clean up if no order was created (basket still exists) + if (expressBasket.current) { + const sfPaymentsInstrument = getSFPaymentsInstrument(expressBasket.current) + if (sfPaymentsInstrument) { + try { + expressBasket.current = await removePaymentInstrumentFromBasket({ + parameters: { + basketId: expressBasket.current.basketId, + paymentInstrumentId: sfPaymentsInstrument.paymentInstrumentId + } + }) + } catch (cleanupError) { + logger.warn('Failed to remove payment instrument during cleanup', { + namespace: 'SFPaymentsExpressButtons.cleanupExpressBasket', + additionalProperties: {cleanupError} + }) + } + } + // Delete the temporary basket if it exists + if (expressBasket.current?.basketId && expressBasket.current?.temporaryBasket) { + try { + await deleteBasket({ + parameters: {basketId: expressBasket.current.basketId} + }) + } catch (cleanupError) { + logger.warn('Failed to delete temporary basket during cleanup', { + namespace: 'SFPaymentsExpressButtons.cleanupExpressBasket', + additionalProperties: {cleanupError} + }) + } + } + // Clear the ref after cleanup + expressBasket.current = null + } + } + + const onCancel = async () => { + isPaymentInProgress.current = false + endConfirming() + await cleanupExpressBasket() + showErrorMessage(ERROR_MESSAGE_KEYS.DEFAULT) + } + + const onShippingAddressChange = async (shippingAddress, callback) => { + try { + // Wait for basket to be prepared if it's not ready yet + if (prepareBasketPromise.current) { + try { + expressBasket.current = await prepareBasketPromise.current + } catch (e) { + // Promise failed - show error and return early + callback.updateShippingAddress({ + errors: ['fail'] + }) + return + } + } + // Update the shipping address in the default shipment + let updatedBasket = await updateShippingAddressForShipment.mutateAsync({ + parameters: { + basketId: expressBasket.current.basketId, + shipmentId: DEFAULT_SHIPMENT_ID, + useAsBilling: false + }, + body: { + firstName: null, + lastName: null, + address1: null, + address2: null, + city: shippingAddress.city, + stateCode: shippingAddress.state, + postalCode: shippingAddress.postal_code, + countryCode: shippingAddress.country, + phone: null + } + }) + + // Fetch applicable shipping methods after address update + const {data: updatedShippingMethods} = await refetchShippingMethods() + + // Validate and update shipping method if needed + updatedBasket = await validateAndUpdateShippingMethod( + expressBasket.current.basketId, + updatedBasket, + updatedShippingMethods + ) + + const expressCallback = createExpressCallback( + updatedBasket, + updatedShippingMethods + ) + callback.updateShippingAddress(expressCallback) + } catch (e) { + callback.updateShippingAddress({ + errors: ['fail'] + }) + showErrorMessage() + } + } + + const onShippingMethodChange = async (shippingMethod, callback) => { + try { + // Wait for basket to be prepared if it's not ready yet + if (prepareBasketPromise.current) { + try { + expressBasket.current = await prepareBasketPromise.current + } catch (e) { + // Promise failed - show error and return early + callback.updateShippingMethod({ + errors: ['fail'] + }) + return + } + } + + // Update the shipping method in the default shipment + const updatedBasket = await updateShippingMethod.mutateAsync({ + parameters: { + basketId: expressBasket.current.basketId, + shipmentId: DEFAULT_SHIPMENT_ID + }, + body: { + id: shippingMethod.id + } + }) + // Update expressBasket.current with the fresh basket data + expressBasket.current = updatedBasket + + // Fetch applicable shipping methods after shipping method update + const {data: updatedShippingMethods} = await refetchShippingMethods() + + const expressCallback = createExpressCallback( + updatedBasket, + updatedShippingMethods + ) + callback.updateShippingMethod(expressCallback) + } catch (e) { + callback.updateShippingMethod({ + errors: ['fail'] + }) + showErrorMessage() + } + } + //Async function to handle before payer approve event. This is called before payment is confirmed + const onPayerApprove = async (billingDetails, shippingDetails) => { + // Set confirmingBasket to show loading spinner during address updates + startConfirming(expressBasket.current) + + // For non-PayPal methods, if order was already created in createIntentFunction, + // the basket is consumed and we shouldn't try to update addresses + if (!isPayPalPaymentMethodType(paymentMethodType) && orderRef.current) { + logger.info('Order already created, skipping address updates', { + namespace: 'SFPaymentsExpressButtons.onPayerApprove' + }) + return + } + try { + // Transform both billing and shipping addresses + const {billingAddress, shippingAddress} = transformAddressDetails( + billingDetails, + shippingDetails + ) + + // Next update shipping address in basket + const updatedBasket = await updateShippingAddressForShipment.mutateAsync({ + parameters: { + basketId: expressBasket.current.basketId, + shipmentId: DEFAULT_SHIPMENT_ID, + useAsBilling: false + }, + body: shippingAddress + }) + // Update expressBasket.current with the updated basket + expressBasket.current = updatedBasket + + // Update billing address in basket + await updateBillingAddressForBasket({ + parameters: {basketId: expressBasket.current.basketId}, + body: billingAddress + }) + + // For Stripe, create SF Payments basket payment instrument before creating order + if (!isPayPalPaymentMethodType(paymentMethodType)) { + try { + expressBasket.current = await addPaymentInstrumentToBasket({ + parameters: {basketId: updatedBasket.basketId}, + body: createPaymentInstrumentBody({ + amount: + updatedBasket.orderTotal || updatedBasket.productSubTotal, + paymentMethodType: paymentMethodType, + zoneId: zoneId + }) + }) + } catch (error) { + const statusCode = error?.response?.status || error?.status + const errorMessage = + error?.message || error?.response?.data?.message || 'Unknown error' + const errorDetails = error?.response?.data || error?.body || {} + + logger.error('Failed to add payment instrument to basket', { + namespace: 'SFPaymentsExpressButtons.onPayerApprove', + additionalProperties: { + statusCode, + errorMessage, + errorDetails, + basketId: expressBasket.current?.basketId, + paymentMethodType, + orderTotal: expressBasket.current?.orderTotal, + productSubTotal: expressBasket.current?.productSubTotal, + error: error + } + }) + showErrorMessage(ERROR_MESSAGE_KEYS.PROCESS_PAYMENT) + throw error + } + } + } catch (error) { + endConfirming() + throw error + } + } + + /** + * Ensures a Salesforce Payments payment instrument exists in the basket. + * If one doesn't exist, removes any existing one and adds a new one. + * @param {Object} basket - The basket object + * @param {string} paymentMethodType - Type of payment method + * @returns {Promise} Updated basket with payment instrument + */ + const ensurePaymentInstrumentInBasket = async (basket, paymentMethodType) => { + // Check if payment instrument already exists + let sfPaymentsInstrument = getSFPaymentsInstrument(basket) + + if (sfPaymentsInstrument) { + // Payment instrument already exists, return basket as-is + return basket + } + + // Remove any existing Salesforce Payments payment instrument first + sfPaymentsInstrument = getSFPaymentsInstrument(basket) + if (sfPaymentsInstrument) { + basket = await removePaymentInstrumentFromBasket({ + parameters: { + basketId: basket.basketId, + paymentInstrumentId: sfPaymentsInstrument.paymentInstrumentId + } + }) + } + + // Add Salesforce Payments payment instrument to basket + try { + basket = await addPaymentInstrumentToBasket({ + parameters: {basketId: basket.basketId}, + body: createPaymentInstrumentBody({ + amount: basket.orderTotal || basket.productSubTotal, + paymentMethodType: paymentMethodType, + zoneId: zoneId + }) + }) + } catch (error) { + const statusCode = error?.response?.status || error?.status + const errorMessage = + error?.message || error?.response?.data?.message || 'Unknown error' + const errorDetails = error?.response?.data || error?.body || {} + + logger.error('Failed to add payment instrument to basket', { + namespace: 'SFPaymentsExpressButtons.ensurePaymentInstrumentInBasket', + additionalProperties: { + statusCode, + errorMessage, + errorDetails, + basketId: basket?.basketId, + paymentMethodType, + orderTotal: basket?.orderTotal, + productSubTotal: basket?.productSubTotal, + error: error + } + }) + showErrorMessage(ERROR_MESSAGE_KEYS.PROCESS_PAYMENT) + throw error + } + + return basket + } + + const createIntentFunction = async (paymentData = {}) => { + const gateway = getGatewayFromPaymentMethod( + paymentMethodType, + paymentConfig?.paymentMethods, + paymentConfig?.paymentMethodSetAccounts + ) + const isAdyen = gateway === PAYMENT_GATEWAYS.ADYEN + + // For PayPal/Venmo, prepare basket here since createIntentFunction is called after button click + if (isPayPalPaymentMethodType(paymentMethodType)) { + const currentBasket = await prepareBasketRef.current() + // update expressBasket.current with the fresh basket + expressBasket.current = currentBasket + } + + if (!expressBasket.current) { + logger.error('Basket not ready', { + namespace: 'SFPaymentsExpressButtons.createIntentFunction' + }) + throw new Error() + } + + let updatedPaymentInstrument + if (isPayPalPaymentMethodType(paymentMethodType)) { + // Remove any leftover Salesforce Payments payment instrument from basket + const sfPaymentsInstrument = getSFPaymentsInstrument(expressBasket.current) + if (sfPaymentsInstrument) { + expressBasket.current = await removePaymentInstrumentFromBasket({ + parameters: { + basketId: expressBasket.current.basketId, + paymentInstrumentId: sfPaymentsInstrument.paymentInstrumentId + } + }) + } + // Add Salesforce Payments payment instrument to basket + try { + expressBasket.current = await addPaymentInstrumentToBasket({ + parameters: {basketId: expressBasket.current.basketId}, + body: createPaymentInstrumentBody({ + amount: + expressBasket.current.orderTotal || + expressBasket.current.productSubTotal, + paymentMethodType: paymentMethodType, + zoneId: zoneId + }) + }) + } catch (error) { + const statusCode = error?.response?.status || error?.status + const errorMessage = + error?.message || error?.response?.data?.message || 'Unknown error' + const errorDetails = error?.response?.data || error?.body || {} + + logger.error('Failed to add payment instrument to basket', { + namespace: 'SFPaymentsExpressButtons.createIntentFunction', + additionalProperties: { + statusCode, + errorMessage, + errorDetails, + basketId: expressBasket.current?.basketId, + paymentMethodType, + orderTotal: expressBasket.current?.orderTotal, + productSubTotal: expressBasket.current?.productSubTotal, + error: error + } + }) + showErrorMessage(ERROR_MESSAGE_KEYS.PROCESS_PAYMENT) + // Re-throw so SF Payments SDK can handle the error if needed + throw error + } + updatedPaymentInstrument = getSFPaymentsInstrument(expressBasket.current) + } else { + // For Adyen: Update addresses from paymentData before creating order + // (Stripe uses onPayerApprove instead, PayPal is handled above) + if (isAdyen && paymentData?.shippingDetails) { + // Set confirmingBasket to show loading spinner during address updates + startConfirming(expressBasket.current) + + try { + const {billingAddress, shippingAddress} = transformAddressDetails( + paymentData.billingDetails, + paymentData.shippingDetails + ) + // Update shipping address + expressBasket.current = + await updateShippingAddressForShipment.mutateAsync({ + parameters: { + basketId: expressBasket.current.basketId, + shipmentId: DEFAULT_SHIPMENT_ID, + useAsBilling: false + }, + body: shippingAddress + }) + + // Update billing address + await updateBillingAddressForBasket({ + parameters: {basketId: expressBasket.current.basketId}, + body: billingAddress + }) + } catch (error) { + endConfirming() + throw error + } + } + + // Create order and update payment instrument + try { + // For non-PayPal methods, ensure payment instrument exists in basket + // (e.g., Stripe adds it in onPayerApprove, but Adyen does not call onPayerApprove before createIntentFunction) + // keeping it for all as safety measure + expressBasket.current = await ensurePaymentInstrumentInBasket( + expressBasket.current, + paymentMethodType, + zoneId + ) + + const order = await createOrderAndUpdatePayment( + expressBasket.current.basketId, + paymentMethodType, + paymentData + ) + orderRef.current = order + updatedPaymentInstrument = getSFPaymentsInstrument(order) + } catch (error) { + // If order was created but updatePaymentInstrumentForOrder failed, + // orderNo will be attached to the error + if (error.orderNo) { + orderRef.current = {orderNo: error.orderNo} + } + endConfirming() + throw error + } + } + const paymentReference = updatedPaymentInstrument?.paymentReference + if (isAdyen) { + const adyenIntent = + paymentReference?.gatewayProperties?.adyen?.adyenPaymentIntent + return { + pspReference: adyenIntent?.id, + guid: paymentReference?.paymentReferenceId, + resultCode: adyenIntent?.resultCode, + action: adyenIntent?.adyenPaymentIntentAction + } + } else { + return { + client_secret: getClientSecret(updatedPaymentInstrument), + id: paymentReference?.paymentReferenceId + } + } + } + + const onApproveEvent = async () => { + try { + let order + if (isPayPalPaymentMethodType(paymentMethodType)) { + // Create order and update payment instrument + order = await createOrderAndUpdatePayment( + expressBasket.current.basketId, + paymentMethodType + ) + orderRef.current = order + } + + // Close modal if callback provided (for mini cart) + if (onExpressPaymentCompleted) { + onExpressPaymentCompleted() + } + + endConfirming() + + // Navigate to confirmation page with the order number + navigate(`/checkout/confirmation/${orderRef.current?.orderNo}`) + isPaymentInProgress.current = false + } catch (error) { + endConfirming() + } + } + + /** + * Handles payment error event. + * Attempts to fail an order and reopen the basket. + * Only calls failOrder if the order status supports the transition. + * @returns {Promise} + */ + const paymentError = async () => { + isPaymentInProgress.current = false + + const basketRecovered = await attemptFailOrder(orderRef.current?.orderNo) + + endConfirming() + if (basketRecovered) { + showErrorMessage(ERROR_MESSAGE_KEYS.FAIL_ORDER) + } else { + showErrorMessage(ERROR_MESSAGE_KEYS.ORDER_RECOVERY_FAILED) + // Only navigate to cart if NOT on PDP + if (usage !== EXPRESS_BUY_NOW) { + navigate('/cart') + } + } + } + + const handlePaymentMethodsRendered = (details) => { + if (onPaymentMethodsRendered && details.detail.rendered.length > 0) { + onPaymentMethodsRendered() + } + } + + const paymentMethodSet = { + paymentMethods: paymentConfig.paymentMethods, + paymentMethodSetAccounts: paymentConfig.paymentMethodSetAccounts || [] + } + const config = { + theme: buildTheme({ + expressButtonLayout, + ...(usage === EXPRESS_BUY_NOW && { + expressButtonLabels: { + applepay: 'buy', + googlepay: 'buy', + paypal: 'buynow', + venmo: 'buynow' + } + }) + }), + actions: { + onClick: onClick, + onShippingAddressChange: onShippingAddressChange, + onShippingMethodChange: onShippingMethodChange, + createIntent: createIntentFunction, + onPayerApprove: onPayerApprove + }, + options: { + shippingAddressRequired: true, + emailAddressRequired: true, + billingAddressRequired: true, + phoneNumberRequired: true, + useManualCapture: !cardCaptureAutomatic, + maximumButtonCount + } + } + + const paymentRequest = { + amount: initialAmount, + currency: paymentCurrency, + country: 'US', // TODO: see W-18812582 + locale: intl.locale + } + + containerElementRef.current.innerHTML = '
' + + const paymentElement = containerElementRef.current.firstChild + + paymentElement.addEventListener('sfp:paymentcancel', onCancel) + paymentElement.addEventListener('sfp:paymentapprove', onApproveEvent) + paymentElement.addEventListener('sfp:paymenterror', paymentError) + paymentElement.addEventListener( + 'sfp:paymentmethodsrendered', + handlePaymentMethodsRendered + ) + + expressComponent.current = sfp.express( + metadata, + paymentMethodSet, + config, + paymentRequest, + paymentElement, + usage + ) + } + + // Cleanup on unmount + return () => { + if (!isPaymentInProgress.current) { + expressComponent.current?.destroy() + expressComponent.current = null + } + } + }, [sfp, metadata, paymentConfig, cardCaptureAutomatic]) + + return +} + +SFPaymentsExpressButtons.propTypes = { + usage: PropTypes.oneOf([EXPRESS_BUY_NOW, EXPRESS_PAY_NOW]).isRequired, + paymentCurrency: PropTypes.string.isRequired, + paymentCountryCode: PropTypes.string, + initialAmount: PropTypes.number.isRequired, + prepareBasket: PropTypes.func.isRequired, + expressButtonLayout: PropTypes.oneOf(['horizontal', 'vertical']), + maximumButtonCount: PropTypes.number, + onPaymentMethodsRendered: PropTypes.func, + onExpressPaymentCompleted: PropTypes.func +} + +export default SFPaymentsExpressButtons diff --git a/packages/template-retail-react-app/app/components/sf-payments-express-buttons/index.test.js b/packages/template-retail-react-app/app/components/sf-payments-express-buttons/index.test.js new file mode 100644 index 0000000000..017f708845 --- /dev/null +++ b/packages/template-retail-react-app/app/components/sf-payments-express-buttons/index.test.js @@ -0,0 +1,2326 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import React from 'react' +import {screen, waitFor} from '@testing-library/react' +import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' +import SFPaymentsExpressButtons from '@salesforce/retail-react-app/app/components/sf-payments-express-buttons' +import { + EXPRESS_PAY_NOW, + EXPRESS_BUY_NOW +} from '@salesforce/retail-react-app/app/hooks/use-sf-payments' +import {rest} from 'msw' +import {DEFAULT_SHIPMENT_ID} from '@salesforce/retail-react-app/app/constants' + +// Used by validateAndUpdateShippingMethod tests to capture sfp.express config and inject mock sfp (mock-prefix required by Jest) +let mockValidateTestCaptureConfig = null + +// Mock getConfig to provide necessary configuration +jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => { + const actual = jest.requireActual('@salesforce/pwa-kit-runtime/utils/ssr-config') + const mockConfig = jest.requireActual('@salesforce/retail-react-app/config/mocks/default') + return { + ...actual, + getConfig: jest.fn(() => ({ + ...mockConfig, + app: { + ...mockConfig.app, + sfPayments: { + enabled: true + } + } + })) + } +}) + +// Mock the SF Payments hooks +jest.mock('@salesforce/retail-react-app/app/hooks/use-sf-payments-country', () => ({ + useSFPaymentsCountry: () => ({countryCode: 'US'}) +})) + +// When set, validateAndUpdateShippingMethod tests use these mocks for basket/shipping SDK hooks (mock-prefix required by Jest) +let mockValidateTestMocks = null + +// When set, attemptFailOrder tests use these mocks for order/API hooks (mock-prefix required by Jest) +let mockAttemptFailOrderMocks = null + +// When set, cleanupExpressBasket tests use these mocks for basket cleanup (mock-prefix required by Jest) +let mockCleanupExpressBasketMocks = null + +// When set, createIntentFunction PayPal path tests use these mocks (mock-prefix required by Jest) +let mockPayPalCreateIntentMocks = null + +// When set, onCancel tests capture endConfirming and toast (mock-prefix required by Jest) +let mockOnCancelMocks = null + +// When set, failOrder error handling tests use this for useToast (mock-prefix required by Jest) +let mockFailOrderToast = null + +// Used by onApproveEvent tests to assert navigate calls (mock-prefix required by Jest) +const mockNavigate = jest.fn() + +jest.mock('@salesforce/retail-react-app/app/hooks/use-navigation', () => ({ + __esModule: true, + default: () => mockNavigate +})) + +jest.mock('@salesforce/commerce-sdk-react', () => { + const actual = jest.requireActual('@salesforce/commerce-sdk-react') + const defaultBasket = { + basketId: 'mock', + orderTotal: 0, + productSubTotal: 0, + shipments: [{shipmentId: 'me'}] + } + const mockUseShopperBasketsMutation = (key) => { + if ( + mockValidateTestMocks && + key === 'updateShippingAddressForShipment' && + mockValidateTestMocks.updateShippingAddress + ) { + return {mutateAsync: mockValidateTestMocks.updateShippingAddress} + } + if ( + mockValidateTestMocks && + key === 'updateShippingMethodForShipment' && + mockValidateTestMocks.updateShippingMethod + ) { + return {mutateAsync: mockValidateTestMocks.updateShippingMethod} + } + if ( + mockValidateTestMocks && + key === 'updateBillingAddressForBasket' && + mockValidateTestMocks.updateBillingAddressForBasket + ) { + return {mutateAsync: mockValidateTestMocks.updateBillingAddressForBasket} + } + if ( + mockValidateTestMocks && + key === 'addPaymentInstrumentToBasket' && + mockValidateTestMocks.addPaymentInstrumentToBasket + ) { + return {mutateAsync: mockValidateTestMocks.addPaymentInstrumentToBasket} + } + if ( + mockPayPalCreateIntentMocks && + key === 'addPaymentInstrumentToBasket' && + mockPayPalCreateIntentMocks.addPaymentInstrumentToBasket + ) { + return {mutateAsync: mockPayPalCreateIntentMocks.addPaymentInstrumentToBasket} + } + if ( + mockPayPalCreateIntentMocks && + key === 'removePaymentInstrumentFromBasket' && + mockPayPalCreateIntentMocks.removePaymentInstrumentFromBasket + ) { + return {mutateAsync: mockPayPalCreateIntentMocks.removePaymentInstrumentFromBasket} + } + if ( + mockAttemptFailOrderMocks && + key === 'addPaymentInstrumentToBasket' && + mockAttemptFailOrderMocks.addPaymentInstrumentToBasket + ) { + return {mutateAsync: mockAttemptFailOrderMocks.addPaymentInstrumentToBasket} + } + if ( + mockAttemptFailOrderMocks && + key === 'removePaymentInstrumentFromBasket' && + mockAttemptFailOrderMocks.removePaymentInstrumentFromBasket + ) { + return {mutateAsync: mockAttemptFailOrderMocks.removePaymentInstrumentFromBasket} + } + if ( + mockCleanupExpressBasketMocks && + key === 'removePaymentInstrumentFromBasket' && + mockCleanupExpressBasketMocks.removePaymentInstrumentFromBasket + ) { + return { + mutateAsync: mockCleanupExpressBasketMocks.removePaymentInstrumentFromBasket + } + } + if ( + mockCleanupExpressBasketMocks && + key === 'deleteBasket' && + mockCleanupExpressBasketMocks.deleteBasket + ) { + return {mutateAsync: mockCleanupExpressBasketMocks.deleteBasket} + } + // Default: never call real SDK (avoids network in tests) + return { + mutateAsync: jest + .fn() + .mockResolvedValue(key === 'deleteBasket' ? undefined : defaultBasket) + } + } + return { + ...actual, + useShopperBasketsMutation: mockUseShopperBasketsMutation, + useShopperBasketsV2Mutation: mockUseShopperBasketsMutation, + useShippingMethodsForShipment: (params, options) => { + if (mockValidateTestMocks && mockValidateTestMocks.refetchShippingMethods) { + return {refetch: mockValidateTestMocks.refetchShippingMethods} + } + return { + refetch: jest.fn().mockResolvedValue({data: {applicableShippingMethods: []}}) + } + }, + useShippingMethodsForShipmentV2: (params, options) => { + if (mockValidateTestMocks && mockValidateTestMocks.refetchShippingMethods) { + return {refetch: mockValidateTestMocks.refetchShippingMethods} + } + return { + refetch: jest.fn().mockResolvedValue({data: {applicableShippingMethods: []}}) + } + }, + useShopperOrdersMutation: (mutationKey) => { + if (mockAttemptFailOrderMocks) { + if (mutationKey === 'createOrder' && mockAttemptFailOrderMocks.createOrder) { + return {mutateAsync: mockAttemptFailOrderMocks.createOrder} + } + if (mutationKey === 'failOrder' && mockAttemptFailOrderMocks.failOrder) { + return {mutateAsync: mockAttemptFailOrderMocks.failOrder} + } + if ( + mutationKey === 'updatePaymentInstrumentForOrder' && + mockAttemptFailOrderMocks.updatePaymentInstrumentForOrder + ) { + return {mutateAsync: mockAttemptFailOrderMocks.updatePaymentInstrumentForOrder} + } + } + return { + mutateAsync: jest.fn().mockResolvedValue({}) + } + }, + useCommerceApi: () => { + if (mockAttemptFailOrderMocks && mockAttemptFailOrderMocks.getOrder) { + return { + shopperOrders: { + getOrder: mockAttemptFailOrderMocks.getOrder + } + } + } + return { + shopperOrders: { + getOrder: jest.fn().mockResolvedValue({status: 'created'}) + } + } + }, + useAccessToken: () => { + if (mockAttemptFailOrderMocks && mockAttemptFailOrderMocks.getTokenWhenReady) { + return {getTokenWhenReady: mockAttemptFailOrderMocks.getTokenWhenReady} + } + return {getTokenWhenReady: jest.fn().mockResolvedValue('mock-token')} + } + } +}) + +jest.mock('@salesforce/retail-react-app/app/hooks/use-sf-payments', () => { + const actual = jest.requireActual('@salesforce/retail-react-app/app/hooks/use-sf-payments') + return { + ...actual, + useSFPayments: () => { + if (mockValidateTestCaptureConfig) { + return { + sfp: { + express: (_metadata, _paymentMethodSet, config) => { + mockValidateTestCaptureConfig.config = config + return {destroy: jest.fn()} + } + }, + metadata: {}, + startConfirming: mockOnCancelMocks?.startConfirming ?? jest.fn(), + endConfirming: mockOnCancelMocks?.endConfirming ?? jest.fn() + } + } + return { + sfp: null, // Not initialized + metadata: null, // Not initialized + startConfirming: jest.fn(), + endConfirming: jest.fn() + } + } + } +}) + +jest.mock('@salesforce/retail-react-app/app/hooks/use-toast', () => { + const actual = jest.requireActual('@salesforce/retail-react-app/app/hooks/use-toast') + return { + ...actual, + useToast: () => { + // Component uses: const toast = useToast(); toast({...}) — hook returns the toast function + if (mockOnCancelMocks && mockOnCancelMocks.toast) { + return mockOnCancelMocks.toast + } + if (mockFailOrderToast) { + return mockFailOrderToast + } + return actual.useToast() + } + } +}) + +beforeEach(() => { + // Reset MSW handlers to avoid conflicts + global.server.resetHandlers() + + // Add MSW handlers to mock API requests + global.server.use( + rest.get('*/api/configuration/shopper-configurations/*', (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(200), + ctx.json({ + configurations: [] + }) + ) + }), + rest.get( + '*/api/customer/shopper-customers/*/customers/*/product-lists', + (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(200), + ctx.json({ + data: [], + total: 0 + }) + ) + } + ), + rest.get('*/api/payment-metadata', (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(200), + ctx.json({ + apiKey: 'test-key', + publishableKey: 'pk_test' + }) + ) + }), + rest.get('*/api/checkout/shopper-payments/*/payment-configuration', (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(200), + ctx.json({ + paymentMethods: [ + {id: 'card', name: 'Card'}, + {id: 'paypal', name: 'PayPal'} + ], + paymentMethodSetAccounts: [] + }) + ) + }) + ) +}) + +afterEach(() => { + jest.clearAllMocks() +}) + +const defaultProps = { + usage: EXPRESS_PAY_NOW, + paymentCurrency: 'USD', + paymentCountryCode: 'US', + initialAmount: 100, + prepareBasket: jest.fn() +} + +// --- Shared test helpers (reused across describes) --- +const flush = () => new Promise((r) => setTimeout(r, 0)) + +async function renderAndGetConfig(props = {}) { + const prepareBasket = props.prepareBasket ?? jest.fn().mockResolvedValue(makeBasket('basket-1')) + renderWithProviders( + + ) + await waitFor(() => { + expect(mockValidateTestCaptureConfig.config).toBeDefined() + }) + return {config: mockValidateTestCaptureConfig.config, prepareBasket} +} + +function getPaymentElement() { + const box = screen.getByTestId('sf-payments-express') + return box.firstChild +} + +function dispatchPaymentEvent(eventName) { + const el = getPaymentElement() + if (el) el.dispatchEvent(new CustomEvent(eventName)) +} + +// --- Shared mock data factories --- +function makeBasket(basketId, overrides = {}) { + return { + basketId, + orderTotal: 100, + productSubTotal: 100, + shipments: [{shipmentId: DEFAULT_SHIPMENT_ID}], + ...overrides + } +} + +function makeOrder(orderNo, overrides = {}) { + return { + orderNo, + orderTotal: 100, + paymentInstruments: [ + {paymentMethodId: 'Salesforce Payments', paymentInstrumentId: 'opi-1'} + ], + ...overrides + } +} + +function makeOrderWithStripeIntent(orderNo, paymentReferenceId, clientSecret) { + return { + ...makeOrder(orderNo), + paymentInstruments: [ + { + paymentMethodId: 'Salesforce Payments', + paymentInstrumentId: 'opi-1', + paymentReference: { + paymentReferenceId, + gatewayProperties: {stripe: {clientSecret}} + } + } + ] + } +} + +function createAttemptFailOrderMocks({ + basket = makeBasket('basket-1'), + order = makeOrder('ord-1'), + orderFromUpdate = order, + getOrderStatus = 'created', + updatePaymentRejects = false, + createOrderRejects = false, + failOrderResolves = true +} = {}) { + return { + getTokenWhenReady: jest.fn().mockResolvedValue('test-token'), + getOrder: jest.fn().mockResolvedValue({status: getOrderStatus}), + createOrder: jest + .fn() + [createOrderRejects ? 'mockRejectedValue' : 'mockResolvedValue']( + createOrderRejects ? new Error('Create order failed') : order + ), + updatePaymentInstrumentForOrder: jest + .fn() + [updatePaymentRejects ? 'mockRejectedValue' : 'mockResolvedValue']( + updatePaymentRejects ? new Error('Payment update failed') : orderFromUpdate + ), + failOrder: jest.fn().mockResolvedValue(failOrderResolves ? {} : null), + addPaymentInstrumentToBasket: jest.fn().mockResolvedValue(basket), + removePaymentInstrumentFromBasket: jest.fn().mockResolvedValue(basket) + } +} + +describe('SFPaymentsExpressButtons', () => { + test('renders container element', () => { + renderWithProviders() + expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument() + }) + + test.each([ + ['EXPRESS_PAY_NOW', {usage: EXPRESS_PAY_NOW}], + ['EXPRESS_BUY_NOW', {usage: EXPRESS_BUY_NOW}], + ['horizontal layout', {expressButtonLayout: 'horizontal'}], + ['vertical layout', {expressButtonLayout: 'vertical'}], + ['maximumButtonCount', {maximumButtonCount: 2}], + ['custom paymentCurrency', {paymentCurrency: 'EUR'}], + ['custom initialAmount', {initialAmount: 250}], + ['initialAmount of 0', {initialAmount: 0}] + ])('renders with %s', (_, props) => { + renderWithProviders() + expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument() + }) + + test('renders without paymentCountryCode (uses fallback)', () => { + const props = {...defaultProps} + delete props.paymentCountryCode + renderWithProviders() + expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument() + }) + + test('renders with onPaymentMethodsRendered callback', () => { + const mockCallback = jest.fn() + renderWithProviders( + + ) + expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument() + }) + + test('renders with custom prepareBasket function', () => { + renderWithProviders( + + ) + expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument() + }) + + test('renders with onExpressPaymentCompleted callback', () => { + renderWithProviders( + + ) + expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument() + }) + test('component renders and handles prop changes without errors', () => { + const {rerender} = renderWithProviders( + + ) + + expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument() + + // Simulate prop change that would trigger useEffect + rerender() + + // Should still render without errors + expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument() + }) +}) + +describe('prepareBasket prop updates', () => { + test('component handles prepareBasket prop changes without errors', () => { + const prepareBasket1 = jest.fn() + const {rerender} = renderWithProviders( + + ) + + expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument() + + // Change prepareBasket prop (simulates variant change on PDP) + const prepareBasket2 = jest.fn() + rerender() + + // Component should still render without errors + expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument() + }) +}) + +describe('lifecycle', () => { + test('unmounts without errors', () => { + const {unmount} = renderWithProviders() + + expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument() + expect(() => unmount()).not.toThrow() + }) + + test('container element has correct test id and tag', () => { + renderWithProviders() + + const container = screen.getByTestId('sf-payments-express') + expect(container).toBeInTheDocument() + expect(container.tagName.toLowerCase()).toBe('div') + }) +}) + +describe('callbacks when SF Payments not initialized', () => { + test('onPaymentMethodsRendered is not called on initial render', () => { + const onPaymentMethodsRendered = jest.fn() + + renderWithProviders( + + ) + + expect(onPaymentMethodsRendered).not.toHaveBeenCalled() + }) + + test('onExpressPaymentCompleted is not called on initial render', () => { + const onExpressPaymentCompleted = jest.fn() + + renderWithProviders( + + ) + + expect(onExpressPaymentCompleted).not.toHaveBeenCalled() + }) + + test('prepareBasket is not called on initial render', () => { + const prepareBasket = jest.fn() + + renderWithProviders( + + ) + + expect(prepareBasket).not.toHaveBeenCalled() + }) +}) + +describe('payment configuration', () => { + test('renders when payment configuration API returns error', () => { + global.server.use( + rest.get('*/api/checkout/shopper-payments/*/payment-configuration', (req, res, ctx) => + res(ctx.delay(0), ctx.status(500), ctx.json({message: 'Server error'})) + ) + ) + + renderWithProviders() + + expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument() + }) + + test('renders when payment configuration returns empty payment methods', () => { + global.server.use( + rest.get('*/api/checkout/shopper-payments/*/payment-configuration', (req, res, ctx) => + res( + ctx.delay(0), + ctx.status(200), + ctx.json({ + paymentMethods: [], + paymentMethodSetAccounts: [] + }) + ) + ) + ) + + renderWithProviders() + + expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument() + }) +}) + +describe('default and optional props', () => { + test('uses default expressButtonLayout when not provided', () => { + const propsWithoutLayout = {...defaultProps} + delete propsWithoutLayout.expressButtonLayout + + renderWithProviders() + + expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument() + }) + + test('renders without maximumButtonCount', () => { + const propsWithoutMaxButtons = {...defaultProps} + delete propsWithoutMaxButtons.maximumButtonCount + + renderWithProviders() + + expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument() + }) + + test('renders without onPaymentMethodsRendered', () => { + const propsWithoutCallback = {...defaultProps} + delete propsWithoutCallback.onPaymentMethodsRendered + + renderWithProviders() + + expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument() + }) + + test('renders without onExpressPaymentCompleted', () => { + const propsWithoutCallback = {...defaultProps} + delete propsWithoutCallback.onExpressPaymentCompleted + + renderWithProviders() + + expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument() + }) +}) + +describe('edge cases and rerenders', () => { + test('handles initialAmount as decimal', () => { + renderWithProviders() + + expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument() + }) + + test('handles multiple rerenders with different paymentCurrency and paymentCountryCode', () => { + const {rerender} = renderWithProviders( + + ) + + expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument() + + rerender( + + ) + expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument() + + rerender( + + ) + expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument() + }) + + test('handles rerender from EXPRESS_PAY_NOW to EXPRESS_BUY_NOW', () => { + const {rerender} = renderWithProviders( + + ) + + expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument() + + rerender() + + expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument() + }) + + test('handles rerender with callbacks added then removed', () => { + const onPaymentMethodsRendered = jest.fn() + const onExpressPaymentCompleted = jest.fn() + + const {rerender} = renderWithProviders() + + rerender( + + ) + expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument() + + rerender() + expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument() + expect(onPaymentMethodsRendered).not.toHaveBeenCalled() + expect(onExpressPaymentCompleted).not.toHaveBeenCalled() + }) +}) + +describe('validateAndUpdateShippingMethod', () => { + const basketId = 'basket-123' + const mockBasketWithShippingMethod = (shippingMethodId) => ({ + basketId, + shipments: [ + { + shipmentId: DEFAULT_SHIPMENT_ID, + shippingMethod: shippingMethodId ? {id: shippingMethodId} : undefined + } + ] + }) + + const applicableShippingMethods = [ + {id: 'first-applicable', name: 'Standard'}, + {id: 'second-applicable', name: 'Express'} + ] + + beforeEach(() => { + mockValidateTestCaptureConfig = {} + mockValidateTestMocks = { + updateShippingAddress: jest.fn(), + updateShippingMethod: jest.fn(), + refetchShippingMethods: jest.fn() + } + }) + + afterEach(() => { + mockValidateTestCaptureConfig = null + mockValidateTestMocks = null + }) + + test('calls updateShippingMethod with first applicable method when current method is not in applicable list', async () => { + const basketWithInapplicableMethod = mockBasketWithShippingMethod('old-inapplicable-method') + mockValidateTestMocks.updateShippingAddress.mockResolvedValue(basketWithInapplicableMethod) + mockValidateTestMocks.updateShippingMethod.mockResolvedValue({ + ...basketWithInapplicableMethod, + shipments: [ + { + ...basketWithInapplicableMethod.shipments[0], + shippingMethod: {id: 'first-applicable'} + } + ] + }) + mockValidateTestMocks.refetchShippingMethods.mockResolvedValue({ + data: {applicableShippingMethods} + }) + + const prepareBasket = jest.fn().mockResolvedValue(mockBasketWithShippingMethod('any')) + + renderWithProviders( + + ) + + await waitFor(() => { + expect(mockValidateTestCaptureConfig.config).toBeDefined() + }) + const {config} = mockValidateTestCaptureConfig + expect(config.actions.onClick).toBeDefined() + expect(config.actions.onShippingAddressChange).toBeDefined() + + await config.actions.onClick('card') + await flush() + + const mockCallback = { + updateShippingAddress: jest.fn() + } + const shippingAddress = { + city: 'San Francisco', + state: 'CA', + postal_code: '94102', + country: 'US' + } + + await config.actions.onShippingAddressChange(shippingAddress, mockCallback) + + expect(mockValidateTestMocks.updateShippingMethod).toHaveBeenCalledWith({ + parameters: { + basketId, + shipmentId: DEFAULT_SHIPMENT_ID + }, + body: { + id: 'first-applicable' + } + }) + }) + + test('does not call updateShippingMethod when current method is in applicable list', async () => { + const basketWithApplicableMethod = mockBasketWithShippingMethod('first-applicable') + mockValidateTestMocks.updateShippingAddress.mockResolvedValue(basketWithApplicableMethod) + mockValidateTestMocks.refetchShippingMethods.mockResolvedValue({ + data: {applicableShippingMethods} + }) + + const prepareBasket = jest.fn().mockResolvedValue(mockBasketWithShippingMethod('any')) + + renderWithProviders( + + ) + + await waitFor(() => { + expect(mockValidateTestCaptureConfig.config).toBeDefined() + }) + const {config} = mockValidateTestCaptureConfig + await config.actions.onClick('card') + await flush() + + const mockCallback = {updateShippingAddress: jest.fn()} + const shippingAddress = { + city: 'San Francisco', + state: 'CA', + postal_code: '94102', + country: 'US' + } + + await config.actions.onShippingAddressChange(shippingAddress, mockCallback) + + expect(mockValidateTestMocks.updateShippingMethod).not.toHaveBeenCalled() + }) + + test('calls updateShippingMethod with first applicable method when current basket has no shipping method', async () => { + const basketWithNoShippingMethod = mockBasketWithShippingMethod(undefined) + mockValidateTestMocks.updateShippingAddress.mockResolvedValue(basketWithNoShippingMethod) + mockValidateTestMocks.updateShippingMethod.mockResolvedValue({ + ...basketWithNoShippingMethod, + shipments: [ + { + ...basketWithNoShippingMethod.shipments[0], + shippingMethod: {id: 'first-applicable'} + } + ] + }) + mockValidateTestMocks.refetchShippingMethods.mockResolvedValue({ + data: {applicableShippingMethods} + }) + + const prepareBasket = jest.fn().mockResolvedValue(mockBasketWithShippingMethod(undefined)) + + renderWithProviders( + + ) + + await waitFor(() => { + expect(mockValidateTestCaptureConfig.config).toBeDefined() + }) + const {config} = mockValidateTestCaptureConfig + await config.actions.onClick('card') + await flush() + + const mockCallback = {updateShippingAddress: jest.fn()} + const shippingAddress = { + city: 'Seattle', + state: 'WA', + postal_code: '98101', + country: 'US' + } + + await config.actions.onShippingAddressChange(shippingAddress, mockCallback) + + expect(mockValidateTestMocks.updateShippingMethod).toHaveBeenCalledWith({ + parameters: { + basketId, + shipmentId: DEFAULT_SHIPMENT_ID + }, + body: { + id: 'first-applicable' + } + }) + }) +}) + +describe('onShippingMethodChange', () => { + const basketId = 'basket-shipping-method' + const applicableShippingMethods = [ + {id: 'standard-id', name: 'Standard'}, + {id: 'express-id', name: 'Express'} + ] + const mockUpdatedBasket = { + basketId, + orderTotal: 100, + productSubTotal: 100, + shippingTotal: 10, + shipments: [ + { + shipmentId: DEFAULT_SHIPMENT_ID, + shippingMethod: {id: 'express-id'} + } + ] + } + + beforeEach(() => { + mockValidateTestCaptureConfig = {} + mockValidateTestMocks = { + updateShippingAddress: jest.fn(), + updateShippingMethod: jest.fn(), + refetchShippingMethods: jest.fn() + } + }) + + afterEach(() => { + mockValidateTestCaptureConfig = null + mockValidateTestMocks = null + }) + + test('calls updateShippingMethod and callback with express callback when shipping method changes', async () => { + mockValidateTestMocks.updateShippingMethod.mockResolvedValue(mockUpdatedBasket) + mockValidateTestMocks.refetchShippingMethods.mockResolvedValue({ + data: {applicableShippingMethods} + }) + + const prepareBasket = jest.fn().mockResolvedValue({ + basketId, + orderTotal: 100, + productSubTotal: 100, + shipments: [{shipmentId: DEFAULT_SHIPMENT_ID}] + }) + + renderWithProviders( + + ) + + await waitFor(() => { + expect(mockValidateTestCaptureConfig.config).toBeDefined() + }) + const {config} = mockValidateTestCaptureConfig + + await config.actions.onClick('card') + await flush() + + const mockCallback = {updateShippingMethod: jest.fn()} + const shippingMethod = {id: 'express-id', name: 'Express'} + + await config.actions.onShippingMethodChange(shippingMethod, mockCallback) + + expect(mockValidateTestMocks.updateShippingMethod).toHaveBeenCalledWith({ + parameters: { + basketId, + shipmentId: DEFAULT_SHIPMENT_ID + }, + body: { + id: 'express-id' + } + }) + expect(mockValidateTestMocks.refetchShippingMethods).toHaveBeenCalled() + expect(mockCallback.updateShippingMethod).toHaveBeenCalledTimes(1) + const callbackArg = mockCallback.updateShippingMethod.mock.calls[0][0] + expect(callbackArg).toHaveProperty('total') + expect(callbackArg).toHaveProperty('shippingMethods') + expect(callbackArg).toHaveProperty('selectedShippingMethod') + expect(callbackArg).toHaveProperty('lineItems') + expect(callbackArg).not.toHaveProperty('errors') + }) + + test('calls callback with errors when updateShippingMethod rejects', async () => { + mockValidateTestMocks.updateShippingMethod.mockRejectedValue(new Error('API error')) + mockValidateTestMocks.refetchShippingMethods.mockResolvedValue({ + data: {applicableShippingMethods} + }) + + const prepareBasket = jest.fn().mockResolvedValue({ + basketId, + orderTotal: 100, + productSubTotal: 100, + shipments: [{shipmentId: DEFAULT_SHIPMENT_ID}] + }) + + renderWithProviders( + + ) + + await waitFor(() => { + expect(mockValidateTestCaptureConfig.config).toBeDefined() + }) + const {config} = mockValidateTestCaptureConfig + + await config.actions.onClick('card') + await flush() + + const mockCallback = {updateShippingMethod: jest.fn()} + const shippingMethod = {id: 'standard-id'} + + await config.actions.onShippingMethodChange(shippingMethod, mockCallback) + + expect(mockCallback.updateShippingMethod).toHaveBeenCalledWith({errors: ['fail']}) + }) + + test('calls callback with errors when prepareBasketPromise rejects', async () => { + const prepareBasket = jest.fn().mockRejectedValue(new Error('Basket failed')) + + renderWithProviders( + + ) + + await waitFor(() => { + expect(mockValidateTestCaptureConfig.config).toBeDefined() + }) + const {config} = mockValidateTestCaptureConfig + + await config.actions.onClick('card') + const mockCallback = {updateShippingMethod: jest.fn()} + const shippingMethod = {id: 'standard-id'} + + await config.actions.onShippingMethodChange(shippingMethod, mockCallback) + + expect(mockCallback.updateShippingMethod).toHaveBeenCalledWith({errors: ['fail']}) + expect(mockValidateTestMocks.updateShippingMethod).not.toHaveBeenCalled() + }) +}) + +describe('onPayerApprove', () => { + const basketId = 'basket-payer-approve' + const mockUpdatedBasketAfterShipping = { + basketId, + orderTotal: 100, + productSubTotal: 100, + shipments: [{shipmentId: DEFAULT_SHIPMENT_ID}] + } + const mockBasketWithInstrument = { + ...mockUpdatedBasketAfterShipping, + paymentInstruments: [{paymentMethodId: 'Salesforce Payments', paymentInstrumentId: 'pi-1'}] + } + + const billingDetails = { + name: 'John Doe', + address: { + line1: '123 Billing St', + line2: 'Apt 1', + city: 'San Francisco', + state: 'CA', + postalCode: '94102', + country: 'US' + }, + phone: '555-1234' + } + const shippingDetails = { + name: 'Jane Doe', + address: { + line1: '456 Shipping Ave', + city: 'Oakland', + state: 'CA', + postalCode: '94601', + country: 'US' + } + } + + beforeEach(() => { + mockValidateTestCaptureConfig = {} + mockValidateTestMocks = { + updateShippingAddress: jest.fn().mockResolvedValue(mockUpdatedBasketAfterShipping), + updateShippingMethod: jest.fn(), + refetchShippingMethods: jest.fn(), + updateBillingAddressForBasket: jest.fn().mockResolvedValue(undefined), + addPaymentInstrumentToBasket: jest.fn().mockResolvedValue(mockBasketWithInstrument) + } + }) + + afterEach(() => { + mockValidateTestCaptureConfig = null + mockValidateTestMocks = null + }) + + test('calls updateShippingAddress, updateBillingAddress and addPaymentInstrument for non-PayPal when payer approves', async () => { + const prepareBasket = jest.fn().mockResolvedValue({ + basketId, + orderTotal: 100, + productSubTotal: 100, + shipments: [{shipmentId: DEFAULT_SHIPMENT_ID}] + }) + + renderWithProviders( + + ) + + await waitFor(() => { + expect(mockValidateTestCaptureConfig.config).toBeDefined() + }) + const {config} = mockValidateTestCaptureConfig + + await config.actions.onClick('card') + await flush() + + await config.actions.onPayerApprove(billingDetails, shippingDetails) + + expect(mockValidateTestMocks.updateShippingAddress).toHaveBeenCalledWith({ + parameters: { + basketId, + shipmentId: DEFAULT_SHIPMENT_ID, + useAsBilling: false + }, + body: expect.objectContaining({ + firstName: 'Jane', + lastName: 'Doe', + address1: '456 Shipping Ave', + city: 'Oakland', + stateCode: 'CA', + postalCode: '94601', + countryCode: 'US' + }) + }) + expect(mockValidateTestMocks.updateBillingAddressForBasket).toHaveBeenCalledWith({ + parameters: {basketId}, + body: expect.objectContaining({ + firstName: 'John', + lastName: 'Doe', + address1: '123 Billing St', + city: 'San Francisco', + stateCode: 'CA', + postalCode: '94102', + countryCode: 'US' + }) + }) + expect(mockValidateTestMocks.addPaymentInstrumentToBasket).toHaveBeenCalledWith({ + parameters: {basketId}, + body: expect.any(Object) + }) + }) + + test('returns early without updating addresses when orderRef is set (non-PayPal)', async () => { + const basket = makeBasket(basketId, { + paymentInstruments: [ + {paymentMethodId: 'Salesforce Payments', paymentInstrumentId: 'basket-pi-1'} + ] + }) + mockAttemptFailOrderMocks = createAttemptFailOrderMocks({ + basket, + updatePaymentRejects: true + }) + + const {config} = await renderAndGetConfig({ + prepareBasket: jest.fn().mockResolvedValue(makeBasket(basketId)) + }) + + await config.actions.onClick('card') + await flush() + await expect(config.actions.createIntent()).rejects.toThrow() + + expect(mockValidateTestMocks.updateShippingAddress).not.toHaveBeenCalled() + expect(mockValidateTestMocks.updateBillingAddressForBasket).not.toHaveBeenCalled() + + mockAttemptFailOrderMocks = null + }) + + test('throws when updateShippingAddressForShipment rejects', async () => { + mockValidateTestMocks.updateShippingAddress.mockRejectedValue( + new Error('Address update failed') + ) + + const prepareBasket = jest.fn().mockResolvedValue({ + basketId, + orderTotal: 100, + productSubTotal: 100, + shipments: [{shipmentId: DEFAULT_SHIPMENT_ID}] + }) + + renderWithProviders( + + ) + + await waitFor(() => { + expect(mockValidateTestCaptureConfig.config).toBeDefined() + }) + const {config} = mockValidateTestCaptureConfig + + await config.actions.onClick('card') + await flush() + + await expect( + config.actions.onPayerApprove(billingDetails, shippingDetails) + ).rejects.toThrow('Address update failed') + }) + + test('calls endConfirming and rethrows when updateBillingAddressForBasket rejects', async () => { + mockOnCancelMocks = {endConfirming: jest.fn(), toast: jest.fn()} + mockValidateTestMocks.updateBillingAddressForBasket.mockRejectedValue( + new Error('Billing update failed') + ) + + const prepareBasket = jest.fn().mockResolvedValue({ + basketId, + orderTotal: 100, + productSubTotal: 100, + shipments: [{shipmentId: DEFAULT_SHIPMENT_ID}] + }) + + renderWithProviders( + + ) + + await waitFor(() => { + expect(mockValidateTestCaptureConfig.config).toBeDefined() + }) + const {config} = mockValidateTestCaptureConfig + + await config.actions.onClick('card') + await flush() + + await expect( + config.actions.onPayerApprove(billingDetails, shippingDetails) + ).rejects.toThrow('Billing update failed') + + expect(mockOnCancelMocks.endConfirming).toHaveBeenCalled() + mockOnCancelMocks = null + }) + + test('calls showErrorMessage(PROCESS_PAYMENT) and endConfirming and rethrows when addPaymentInstrumentToBasket rejects (non-PayPal)', async () => { + mockOnCancelMocks = {endConfirming: jest.fn(), toast: jest.fn()} + mockValidateTestMocks.addPaymentInstrumentToBasket.mockRejectedValue( + new Error('Add payment instrument failed') + ) + + const prepareBasket = jest.fn().mockResolvedValue({ + basketId, + orderTotal: 100, + productSubTotal: 100, + shipments: [{shipmentId: DEFAULT_SHIPMENT_ID}] + }) + + renderWithProviders( + + ) + + await waitFor(() => { + expect(mockValidateTestCaptureConfig.config).toBeDefined() + }) + const {config} = mockValidateTestCaptureConfig + + await config.actions.onClick('card') + await flush() + + await expect( + config.actions.onPayerApprove(billingDetails, shippingDetails) + ).rejects.toThrow('Add payment instrument failed') + + expect(mockOnCancelMocks.toast).toHaveBeenCalledWith( + expect.objectContaining({status: 'error'}) + ) + expect(mockOnCancelMocks.endConfirming).toHaveBeenCalled() + mockOnCancelMocks = null + }) +}) + +describe('createIntentFunction PayPal path (isPayPalPaymentMethodType)', () => { + const basketId = 'basket-paypal-intent' + const basketWithoutSfInstrument = { + basketId, + orderTotal: 100, + productSubTotal: 100, + shipments: [{shipmentId: DEFAULT_SHIPMENT_ID}] + } + const basketWithSfInstrument = { + ...basketWithoutSfInstrument, + paymentInstruments: [ + { + paymentMethodId: 'Salesforce Payments', + paymentInstrumentId: 'pi-existing-1' + } + ] + } + const basketAfterAddInstrument = { + ...basketWithoutSfInstrument, + paymentInstruments: [ + { + paymentMethodId: 'Salesforce Payments', + paymentInstrumentId: 'pi-new-1' + } + ] + } + + beforeEach(() => { + mockValidateTestCaptureConfig = {} + mockPayPalCreateIntentMocks = { + removePaymentInstrumentFromBasket: jest + .fn() + .mockResolvedValue(basketWithoutSfInstrument), + addPaymentInstrumentToBasket: jest.fn().mockResolvedValue(basketAfterAddInstrument) + } + }) + + afterEach(() => { + mockValidateTestCaptureConfig = null + mockPayPalCreateIntentMocks = null + }) + + test('calls prepareBasket then addPaymentInstrumentToBasket when basket has no SF Payments instrument', async () => { + const prepareBasket = jest.fn().mockResolvedValue(basketWithoutSfInstrument) + + renderWithProviders( + + ) + + await waitFor(() => { + expect(mockValidateTestCaptureConfig.config).toBeDefined() + }) + const {config} = mockValidateTestCaptureConfig + + await config.actions.onClick('paypal') + const result = await config.actions.createIntent() + + expect(prepareBasket).toHaveBeenCalled() + expect(mockPayPalCreateIntentMocks.addPaymentInstrumentToBasket).toHaveBeenCalledWith({ + parameters: {basketId}, + body: expect.objectContaining({ + amount: 100, + paymentMethodId: 'Salesforce Payments' + }) + }) + expect(mockPayPalCreateIntentMocks.removePaymentInstrumentFromBasket).not.toHaveBeenCalled() + expect(result).toBeDefined() + }) + + test('calls removePaymentInstrumentFromBasket then addPaymentInstrumentToBasket when basket has existing SF Payments instrument', async () => { + const prepareBasket = jest.fn().mockResolvedValue(basketWithSfInstrument) + + renderWithProviders( + + ) + + await waitFor(() => { + expect(mockValidateTestCaptureConfig.config).toBeDefined() + }) + const {config} = mockValidateTestCaptureConfig + + await config.actions.onClick('paypal') + const result = await config.actions.createIntent() + + expect(prepareBasket).toHaveBeenCalled() + expect(mockPayPalCreateIntentMocks.removePaymentInstrumentFromBasket).toHaveBeenCalledWith({ + parameters: { + basketId, + paymentInstrumentId: 'pi-existing-1' + } + }) + expect(mockPayPalCreateIntentMocks.addPaymentInstrumentToBasket).toHaveBeenCalledWith({ + parameters: {basketId}, + body: expect.objectContaining({ + amount: 100, + paymentMethodId: 'Salesforce Payments' + }) + }) + expect(result).toBeDefined() + }) + + test('throws when addPaymentInstrumentToBasket rejects in PayPal path', async () => { + mockPayPalCreateIntentMocks.addPaymentInstrumentToBasket.mockRejectedValue( + new Error('Add instrument failed') + ) + const prepareBasket = jest.fn().mockResolvedValue(basketWithoutSfInstrument) + + renderWithProviders( + + ) + + await waitFor(() => { + expect(mockValidateTestCaptureConfig.config).toBeDefined() + }) + const {config} = mockValidateTestCaptureConfig + + await config.actions.onClick('paypal') + await expect(config.actions.createIntent()).rejects.toThrow('Add instrument failed') + }) +}) + +describe('createIntentFunction non-PayPal path (else branch of isPayPalPaymentMethodType)', () => { + const basketId = 'basket-nonpaypal-intent' + const orderNo = 'ord-nonpaypal-1' + const paymentReferenceId = 'ref-nonpaypal-123' + const clientSecret = 'pi_secret_xyz' + const mockBasket = makeBasket(basketId) + const mockOrderFromCreate = makeOrder(orderNo) + const mockOrderFromUpdatePayment = makeOrderWithStripeIntent( + orderNo, + paymentReferenceId, + clientSecret + ) + + beforeEach(() => { + mockValidateTestCaptureConfig = {} + mockAttemptFailOrderMocks = createAttemptFailOrderMocks({ + basket: mockBasket, + order: mockOrderFromCreate, + orderFromUpdate: mockOrderFromUpdatePayment + }) + }) + + afterEach(() => { + mockValidateTestCaptureConfig = null + mockAttemptFailOrderMocks = null + }) + + test('calls ensurePaymentInstrumentInBasket and createOrderAndUpdatePayment and returns client_secret and id when createIntent succeeds (card)', async () => { + const {config} = await renderAndGetConfig({ + prepareBasket: jest.fn().mockResolvedValue(mockBasket) + }) + + await config.actions.onClick('card') + await flush() + + const result = await config.actions.createIntent() + + expect(result).toEqual({ + client_secret: clientSecret, + id: paymentReferenceId + }) + expect(mockAttemptFailOrderMocks.addPaymentInstrumentToBasket).toHaveBeenCalled() + expect(mockAttemptFailOrderMocks.createOrder).toHaveBeenCalledWith({ + body: {basketId} + }) + expect(mockAttemptFailOrderMocks.updatePaymentInstrumentForOrder).toHaveBeenCalled() + }) + + test('does not call prepareBasket at start of createIntent for non-PayPal (only onClick does)', async () => { + const {config, prepareBasket} = await renderAndGetConfig({ + prepareBasket: jest.fn().mockResolvedValue(mockBasket) + }) + + await config.actions.onClick('card') + await flush() + + const prepareBasketCallsBeforeCreateIntent = prepareBasket.mock.calls.length + await config.actions.createIntent() + const prepareBasketCallsAfterCreateIntent = prepareBasket.mock.calls.length + + expect(prepareBasketCallsBeforeCreateIntent).toBe(1) + expect(prepareBasketCallsAfterCreateIntent).toBe(1) + }) + + test('calls endConfirming and rethrows when createOrderAndUpdatePayment throws in non-PayPal path', async () => { + mockOnCancelMocks = {endConfirming: jest.fn(), toast: jest.fn()} + mockAttemptFailOrderMocks.updatePaymentInstrumentForOrder.mockRejectedValue( + new Error('Payment update failed') + ) + + const {config} = await renderAndGetConfig({ + prepareBasket: jest.fn().mockResolvedValue(mockBasket) + }) + + await config.actions.onClick('card') + await flush() + + await expect(config.actions.createIntent()).rejects.toThrow('Payment update failed') + + expect(mockOnCancelMocks.endConfirming).toHaveBeenCalled() + mockOnCancelMocks = null + }) + + test('returns basket as-is from ensurePaymentInstrumentInBasket when basket already has SF Payments instrument (non-PayPal)', async () => { + const basketWithSfInstrument = { + ...mockBasket, + paymentInstruments: [ + { + paymentMethodId: 'Salesforce Payments', + paymentInstrumentId: 'pi-existing-1' + } + ] + } + const prepareBasket = jest.fn().mockResolvedValue(basketWithSfInstrument) + + renderWithProviders( + + ) + + await waitFor(() => { + expect(mockValidateTestCaptureConfig.config).toBeDefined() + }) + const {config} = mockValidateTestCaptureConfig + + await config.actions.onClick('card') + await flush() + + const result = await config.actions.createIntent() + + expect(result).toEqual({ + client_secret: clientSecret, + id: paymentReferenceId + }) + expect(mockAttemptFailOrderMocks.addPaymentInstrumentToBasket).not.toHaveBeenCalled() + expect(mockAttemptFailOrderMocks.removePaymentInstrumentFromBasket).not.toHaveBeenCalled() + expect(mockAttemptFailOrderMocks.createOrder).toHaveBeenCalled() + expect(mockAttemptFailOrderMocks.updatePaymentInstrumentForOrder).toHaveBeenCalled() + }) + + test('calls showErrorMessage(PROCESS_PAYMENT) and endConfirming and rethrows when ensurePaymentInstrumentInBasket addPaymentInstrumentToBasket rejects (non-PayPal)', async () => { + mockOnCancelMocks = {endConfirming: jest.fn(), toast: jest.fn()} + mockAttemptFailOrderMocks.addPaymentInstrumentToBasket.mockRejectedValue( + new Error('Add payment instrument failed') + ) + + const prepareBasket = jest.fn().mockResolvedValue(mockBasket) + + renderWithProviders( + + ) + + await waitFor(() => { + expect(mockValidateTestCaptureConfig.config).toBeDefined() + }) + const {config} = mockValidateTestCaptureConfig + + await config.actions.onClick('card') + await flush() + + await expect(config.actions.createIntent()).rejects.toThrow('Add payment instrument failed') + + expect(mockOnCancelMocks.toast).toHaveBeenCalledWith( + expect.objectContaining({status: 'error'}) + ) + expect(mockOnCancelMocks.endConfirming).toHaveBeenCalled() + expect(mockAttemptFailOrderMocks.createOrder).not.toHaveBeenCalled() + mockOnCancelMocks = null + }) +}) + +describe('createIntentFunction Adyen path (isAdyen && paymentData?.shippingDetails)', () => { + const basketId = 'basket-adyen-intent' + const pspReference = 'adyen-psp-123' + const paymentReferenceId = 'adyen-guid-456' + const mockBasket = { + basketId, + orderTotal: 100, + productSubTotal: 100, + shipments: [{shipmentId: DEFAULT_SHIPMENT_ID}] + } + const mockBasketAfterShippingUpdate = {...mockBasket, basketId} + const mockOrderFromCreate = { + orderNo: 'ord-adyen-1', + orderTotal: 100, + paymentInstruments: [ + {paymentMethodId: 'Salesforce Payments', paymentInstrumentId: 'opi-adyen-1'} + ] + } + const mockOrderFromUpdatePayment = { + ...mockOrderFromCreate, + paymentInstruments: [ + { + paymentMethodId: 'Salesforce Payments', + paymentInstrumentId: 'opi-adyen-1', + paymentReference: { + paymentReferenceId, + gatewayProperties: { + adyen: { + adyenPaymentIntent: { + id: pspReference, + resultCode: 'Authorised', + adyenPaymentIntentAction: {type: 'threeDS2'} + } + } + } + } + } + ] + } + const billingDetails = { + name: 'John Doe', + address: { + line1: '123 Billing St', + city: 'San Francisco', + state: 'CA', + postalCode: '94102', + country: 'US' + } + } + const shippingDetails = { + name: 'Jane Doe', + address: { + line1: '456 Shipping Ave', + city: 'Oakland', + state: 'CA', + postalCode: '94601', + country: 'US' + } + } + const adyenPaymentConfig = { + paymentMethods: [ + {id: 'card', name: 'Card', paymentMethodType: 'card', accountId: 'adyen-account-1'} + ], + paymentMethodSetAccounts: [{accountId: 'adyen-account-1', vendor: 'adyen'}] + } + + beforeEach(() => { + mockValidateTestCaptureConfig = {} + mockValidateTestMocks = { + updateShippingAddress: jest.fn().mockResolvedValue(mockBasketAfterShippingUpdate), + updateBillingAddressForBasket: jest.fn().mockResolvedValue(undefined) + } + mockAttemptFailOrderMocks = { + getTokenWhenReady: jest.fn().mockResolvedValue('test-token'), + getOrder: jest.fn().mockResolvedValue({status: 'created'}), + createOrder: jest.fn().mockResolvedValue(mockOrderFromCreate), + updatePaymentInstrumentForOrder: jest + .fn() + .mockResolvedValue(mockOrderFromUpdatePayment), + addPaymentInstrumentToBasket: jest.fn().mockResolvedValue(mockBasket), + removePaymentInstrumentFromBasket: jest.fn().mockResolvedValue(mockBasket) + } + global.server.use( + rest.get('*/api/checkout/shopper-payments/*/payment-configuration', (req, res, ctx) => + res(ctx.delay(0), ctx.status(200), ctx.json(adyenPaymentConfig)) + ) + ) + }) + + afterEach(() => { + mockValidateTestCaptureConfig = null + mockValidateTestMocks = null + mockAttemptFailOrderMocks = null + }) + + test('calls updateShippingAddressForShipment and updateBillingAddressForBasket when createIntent(paymentData) is called with shippingDetails (Adyen)', async () => { + const prepareBasket = jest.fn().mockResolvedValue(mockBasket) + + renderWithProviders( + + ) + + await waitFor(() => { + expect(mockValidateTestCaptureConfig.config).toBeDefined() + }) + const {config} = mockValidateTestCaptureConfig + + await config.actions.onClick('card') + await flush() + + const result = await config.actions.createIntent({ + billingDetails, + shippingDetails + }) + + expect(result).toEqual({ + pspReference, + guid: paymentReferenceId, + resultCode: 'Authorised', + action: {type: 'threeDS2'} + }) + expect(mockValidateTestMocks.updateShippingAddress).toHaveBeenCalledWith({ + parameters: { + basketId, + shipmentId: DEFAULT_SHIPMENT_ID, + useAsBilling: false + }, + body: expect.objectContaining({ + firstName: 'Jane', + lastName: 'Doe', + address1: '456 Shipping Ave', + city: 'Oakland', + stateCode: 'CA', + postalCode: '94601', + countryCode: 'US' + }) + }) + expect(mockValidateTestMocks.updateBillingAddressForBasket).toHaveBeenCalledWith({ + parameters: {basketId}, + body: expect.objectContaining({ + firstName: 'John', + lastName: 'Doe', + address1: '123 Billing St', + city: 'San Francisco', + stateCode: 'CA', + postalCode: '94102', + countryCode: 'US' + }) + }) + }) + + test('calls endConfirming and rethrows when updateShippingAddressForShipment rejects in Adyen address-update block', async () => { + mockOnCancelMocks = {endConfirming: jest.fn(), toast: jest.fn()} + mockValidateTestMocks.updateShippingAddress.mockRejectedValue( + new Error('Address update failed') + ) + + const prepareBasket = jest.fn().mockResolvedValue(mockBasket) + + renderWithProviders( + + ) + + await waitFor(() => { + expect(mockValidateTestCaptureConfig.config).toBeDefined() + }) + const {config} = mockValidateTestCaptureConfig + + await config.actions.onClick('card') + await flush() + + await expect( + config.actions.createIntent({billingDetails, shippingDetails}) + ).rejects.toThrow('Address update failed') + + expect(mockOnCancelMocks.endConfirming).toHaveBeenCalled() + mockOnCancelMocks = null + }) + + test('does not call updateShippingAddress or updateBillingAddress when paymentData has no shippingDetails (Adyen)', async () => { + const prepareBasket = jest.fn().mockResolvedValue(mockBasket) + + renderWithProviders( + + ) + + await waitFor(() => { + expect(mockValidateTestCaptureConfig.config).toBeDefined() + }) + const {config} = mockValidateTestCaptureConfig + + await config.actions.onClick('card') + await flush() + + await config.actions.createIntent({billingDetails}) + + expect(mockValidateTestMocks.updateShippingAddress).not.toHaveBeenCalled() + expect(mockValidateTestMocks.updateBillingAddressForBasket).not.toHaveBeenCalled() + }) +}) + +describe('attemptFailOrder', () => { + const orderNo = 'ord-attempt-fail-test' + const mockOrder = makeOrder(orderNo) + const mockBasket = makeBasket('basket-1', { + paymentInstruments: [ + {paymentMethodId: 'Salesforce Payments', paymentInstrumentId: 'basket-pi-1'} + ] + }) + + beforeEach(() => { + mockValidateTestCaptureConfig = {} + mockAttemptFailOrderMocks = createAttemptFailOrderMocks({ + basket: mockBasket, + order: mockOrder, + updatePaymentRejects: true + }) + }) + + afterEach(() => { + mockValidateTestCaptureConfig = null + mockAttemptFailOrderMocks = null + }) + + test('calls failOrder with reopenBasket when updatePaymentInstrumentForOrder fails after order created and order status is created', async () => { + const {config} = await renderAndGetConfig({ + prepareBasket: jest.fn().mockResolvedValue(makeBasket('basket-1')) + }) + + await config.actions.onClick('card') + await flush() + + await expect(config.actions.createIntent()).rejects.toThrow() + + expect(mockAttemptFailOrderMocks.failOrder).toHaveBeenCalledWith({ + parameters: {orderNo, reopenBasket: true}, + body: {reasonCode: 'payment_confirm_failure'} + }) + expect(mockAttemptFailOrderMocks.getTokenWhenReady).toHaveBeenCalled() + expect(mockAttemptFailOrderMocks.getOrder).toHaveBeenCalledWith({ + parameters: {orderNo}, + headers: {Authorization: 'Bearer test-token'} + }) + }) + + test('does not call failOrder when getOrder returns status other than created', async () => { + mockAttemptFailOrderMocks.getOrder.mockResolvedValue({status: 'completed'}) + + const {config} = await renderAndGetConfig({ + prepareBasket: jest.fn().mockResolvedValue(makeBasket('basket-1')) + }) + + await config.actions.onClick('card') + await flush() + + await expect(config.actions.createIntent()).rejects.toThrow() + + expect(mockAttemptFailOrderMocks.failOrder).not.toHaveBeenCalled() + expect(mockAttemptFailOrderMocks.getTokenWhenReady).toHaveBeenCalled() + expect(mockAttemptFailOrderMocks.getOrder).toHaveBeenCalled() + }) + + test('does not call failOrder when getOrder throws', async () => { + mockAttemptFailOrderMocks.getOrder.mockRejectedValue(new Error('Network error')) + + const {config} = await renderAndGetConfig({ + prepareBasket: jest.fn().mockResolvedValue(makeBasket('basket-1')) + }) + + await config.actions.onClick('card') + await flush() + + await expect(config.actions.createIntent()).rejects.toThrow() + + expect(mockAttemptFailOrderMocks.failOrder).not.toHaveBeenCalled() + }) +}) + +describe('cleanupExpressBasket', () => { + const basketWithSfInstrument = { + basketId: 'basket-cleanup-1', + orderTotal: 100, + productSubTotal: 100, + shipments: [{shipmentId: 'me'}], + paymentInstruments: [ + { + paymentMethodId: 'Salesforce Payments', + paymentInstrumentId: 'pi-cleanup-1' + } + ] + } + const basketWithoutSfInstrument = { + basketId: 'basket-cleanup-2', + orderTotal: 100, + productSubTotal: 100, + shipments: [{shipmentId: 'me'}] + } + const basketTemporary = { + ...basketWithSfInstrument, + basketId: 'basket-temp-1', + temporaryBasket: true + } + + const dispatchPaymentCancel = () => dispatchPaymentEvent('sfp:paymentcancel') + + beforeEach(() => { + mockValidateTestCaptureConfig = {} + mockCleanupExpressBasketMocks = { + removePaymentInstrumentFromBasket: jest + .fn() + .mockResolvedValue(basketWithoutSfInstrument), + deleteBasket: jest.fn().mockResolvedValue(undefined) + } + }) + + afterEach(() => { + mockValidateTestCaptureConfig = null + mockCleanupExpressBasketMocks = null + }) + + test('calls removePaymentInstrumentFromBasket when user cancels and basket has SF Payments instrument', async () => { + const prepareBasket = jest.fn().mockResolvedValue(basketWithSfInstrument) + + renderWithProviders( + + ) + + await waitFor(() => { + expect(mockValidateTestCaptureConfig.config).toBeDefined() + }) + const {config} = mockValidateTestCaptureConfig + + await config.actions.onClick('card') + await flush() + + dispatchPaymentCancel() + await waitFor(() => { + expect( + mockCleanupExpressBasketMocks.removePaymentInstrumentFromBasket + ).toHaveBeenCalledWith({ + parameters: { + basketId: basketWithSfInstrument.basketId, + paymentInstrumentId: 'pi-cleanup-1' + } + }) + }) + }) + + test('calls deleteBasket when user cancels and basket is temporary', async () => { + const prepareBasket = jest.fn().mockResolvedValue(basketTemporary) + mockCleanupExpressBasketMocks.removePaymentInstrumentFromBasket.mockResolvedValue({ + ...basketTemporary, + paymentInstruments: [] + }) + + renderWithProviders( + + ) + + await waitFor(() => { + expect(mockValidateTestCaptureConfig.config).toBeDefined() + }) + const {config} = mockValidateTestCaptureConfig + + await config.actions.onClick('card') + await flush() + + dispatchPaymentCancel() + await waitFor(() => { + expect( + mockCleanupExpressBasketMocks.removePaymentInstrumentFromBasket + ).toHaveBeenCalled() + }) + await waitFor(() => { + expect(mockCleanupExpressBasketMocks.deleteBasket).toHaveBeenCalledWith({ + parameters: {basketId: basketTemporary.basketId} + }) + }) + }) + + test('does not call removePaymentInstrumentFromBasket or deleteBasket when order was already created (orderRef set)', async () => { + const basket = makeBasket('basket-1', { + paymentInstruments: [ + {paymentMethodId: 'Salesforce Payments', paymentInstrumentId: 'basket-pi-1'} + ] + }) + mockValidateTestCaptureConfig = {} + mockAttemptFailOrderMocks = createAttemptFailOrderMocks({ + basket, + updatePaymentRejects: true + }) + + const {config} = await renderAndGetConfig({ + prepareBasket: jest.fn().mockResolvedValue(makeBasket('basket-1')) + }) + + await config.actions.onClick('card') + await flush() + await expect(config.actions.createIntent()).rejects.toThrow() + + const removeCallCountBeforeCancel = + mockAttemptFailOrderMocks.removePaymentInstrumentFromBasket.mock.calls.length + dispatchPaymentCancel() + await flush() + await flush() + + expect(mockAttemptFailOrderMocks.removePaymentInstrumentFromBasket.mock.calls).toHaveLength( + removeCallCountBeforeCancel + ) + + mockAttemptFailOrderMocks = null + }) +}) + +describe('onCancel', () => { + const basketWithSfInstrument = { + basketId: 'basket-oncancel', + orderTotal: 100, + productSubTotal: 100, + shipments: [{shipmentId: 'me'}], + paymentInstruments: [ + { + paymentMethodId: 'Salesforce Payments', + paymentInstrumentId: 'pi-oncancel-1' + } + ] + } + const basketWithoutSfInstrument = { + basketId: 'basket-oncancel', + orderTotal: 100, + productSubTotal: 100, + shipments: [{shipmentId: 'me'}] + } + + const dispatchPaymentCancel = () => dispatchPaymentEvent('sfp:paymentcancel') + + beforeEach(() => { + mockValidateTestCaptureConfig = {} + mockOnCancelMocks = { + endConfirming: jest.fn(), + toast: jest.fn() + } + mockCleanupExpressBasketMocks = { + removePaymentInstrumentFromBasket: jest + .fn() + .mockResolvedValue(basketWithoutSfInstrument), + deleteBasket: jest.fn().mockResolvedValue(undefined) + } + }) + + afterEach(() => { + mockValidateTestCaptureConfig = null + mockOnCancelMocks = null + mockCleanupExpressBasketMocks = null + }) + + test('calls endConfirming, cleanupExpressBasket, and showErrorMessage when user cancels', async () => { + const {config} = await renderAndGetConfig({ + prepareBasket: jest.fn().mockResolvedValue(basketWithSfInstrument) + }) + + await config.actions.onClick('card') + await flush() + + dispatchPaymentCancel() + + await waitFor(() => { + expect(mockOnCancelMocks.endConfirming).toHaveBeenCalled() + }) + await waitFor(() => { + expect( + mockCleanupExpressBasketMocks.removePaymentInstrumentFromBasket + ).toHaveBeenCalledWith({ + parameters: { + basketId: basketWithSfInstrument.basketId, + paymentInstrumentId: 'pi-oncancel-1' + } + }) + }) + expect(mockOnCancelMocks.toast).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'error', + title: expect.any(String) + }) + ) + }) + + test('shows error toast with DEFAULT message when user cancels', async () => { + const {config} = await renderAndGetConfig({ + prepareBasket: jest.fn().mockResolvedValue(basketWithoutSfInstrument) + }) + + await config.actions.onClick('card') + await flush() + + dispatchPaymentCancel() + + await waitFor(() => { + expect(mockOnCancelMocks.toast).toHaveBeenCalled() + }) + const toastCall = mockOnCancelMocks.toast.mock.calls[0][0] + expect(toastCall.status).toBe('error') + expect(toastCall.title).toBeDefined() + expect(typeof toastCall.title).toBe('string') + }) +}) + +describe('onApproveEvent', () => { + const basketId = 'basket-approve' + const orderNo = 'ord-approve-1' + const mockBasket = makeBasket(basketId) + const mockOrder = makeOrder(orderNo, { + paymentInstruments: [ + {paymentMethodId: 'Salesforce Payments', paymentInstrumentId: 'opi-approve-1'} + ] + }) + + const dispatchPaymentApprove = () => dispatchPaymentEvent('sfp:paymentapprove') + + test('calls createOrderAndUpdatePayment, onExpressPaymentCompleted, endConfirming, and navigate when PayPal approve event fires', async () => { + mockValidateTestCaptureConfig = {} + mockOnCancelMocks = {endConfirming: jest.fn(), toast: jest.fn()} + mockPayPalCreateIntentMocks = { + removePaymentInstrumentFromBasket: jest.fn().mockResolvedValue(mockBasket), + addPaymentInstrumentToBasket: jest.fn().mockResolvedValue(mockBasket) + } + mockAttemptFailOrderMocks = createAttemptFailOrderMocks({ + order: mockOrder, + basket: mockBasket + }) + + const onExpressPaymentCompleted = jest.fn() + const {config} = await renderAndGetConfig({ + prepareBasket: jest.fn().mockResolvedValue(mockBasket), + onExpressPaymentCompleted + }) + + await config.actions.onClick('paypal') + await config.actions.createIntent() + await flush() + + dispatchPaymentApprove() + + await waitFor(() => { + expect(mockAttemptFailOrderMocks.createOrder).toHaveBeenCalledWith({ + body: {basketId} + }) + }) + expect(onExpressPaymentCompleted).toHaveBeenCalled() + expect(mockOnCancelMocks.endConfirming).toHaveBeenCalled() + expect(mockNavigate).toHaveBeenCalledWith(`/checkout/confirmation/${orderNo}`) + + mockValidateTestCaptureConfig = null + mockOnCancelMocks = null + mockPayPalCreateIntentMocks = null + mockAttemptFailOrderMocks = null + }) + + test('calls onExpressPaymentCompleted, endConfirming, and navigate with orderRef when non-PayPal approve event fires after createIntent', async () => { + mockValidateTestCaptureConfig = {} + mockOnCancelMocks = {endConfirming: jest.fn(), toast: jest.fn()} + mockAttemptFailOrderMocks = createAttemptFailOrderMocks({ + order: mockOrder, + basket: mockBasket + }) + + const onExpressPaymentCompleted = jest.fn() + const {config} = await renderAndGetConfig({ + prepareBasket: jest.fn().mockResolvedValue(mockBasket), + onExpressPaymentCompleted + }) + + await config.actions.onClick('card') + await flush() + await config.actions.createIntent() + await flush() + + dispatchPaymentApprove() + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith(`/checkout/confirmation/${orderNo}`) + }) + expect(onExpressPaymentCompleted).toHaveBeenCalled() + expect(mockOnCancelMocks.endConfirming).toHaveBeenCalled() + expect(mockAttemptFailOrderMocks.createOrder).toHaveBeenCalledTimes(1) + + mockValidateTestCaptureConfig = null + mockOnCancelMocks = null + mockAttemptFailOrderMocks = null + }) + + test('calls endConfirming when createOrderAndUpdatePayment throws in PayPal onApproveEvent', async () => { + mockValidateTestCaptureConfig = {} + mockOnCancelMocks = {endConfirming: jest.fn(), toast: jest.fn()} + mockPayPalCreateIntentMocks = { + removePaymentInstrumentFromBasket: jest.fn().mockResolvedValue(mockBasket), + addPaymentInstrumentToBasket: jest.fn().mockResolvedValue(mockBasket) + } + mockAttemptFailOrderMocks = createAttemptFailOrderMocks({ + order: mockOrder, + basket: mockBasket, + createOrderRejects: true + }) + + const {config} = await renderAndGetConfig({ + prepareBasket: jest.fn().mockResolvedValue(mockBasket) + }) + + await config.actions.onClick('paypal') + await config.actions.createIntent() + await flush() + + dispatchPaymentApprove() + + await waitFor(() => { + expect(mockOnCancelMocks.endConfirming).toHaveBeenCalled() + }) + expect(mockNavigate).not.toHaveBeenCalled() + + mockValidateTestCaptureConfig = null + mockOnCancelMocks = null + mockPayPalCreateIntentMocks = null + mockAttemptFailOrderMocks = null + }) +}) + +describe('paymentError', () => { + const basketId = 'basket-payment-error' + const orderNo = 'ord-payment-error-1' + const mockBasket = makeBasket(basketId) + const mockOrder = makeOrder(orderNo) + const mockOrderFromUpdatePayment = makeOrderWithStripeIntent(orderNo, 'ref-1', 'pi_secret') + + const dispatchPaymentError = () => dispatchPaymentEvent('sfp:paymenterror') + + test('calls endConfirming and showErrorMessage(FAIL_ORDER) when attemptFailOrder returns true (basket recovered)', async () => { + mockValidateTestCaptureConfig = {} + mockOnCancelMocks = {endConfirming: jest.fn(), toast: jest.fn()} + mockAttemptFailOrderMocks = createAttemptFailOrderMocks({ + basket: mockBasket, + order: mockOrder, + orderFromUpdate: mockOrderFromUpdatePayment + }) + + const {config} = await renderAndGetConfig({ + prepareBasket: jest.fn().mockResolvedValue(mockBasket) + }) + + await config.actions.onClick('card') + await flush() + await config.actions.createIntent() + await flush() + + dispatchPaymentError() + + await waitFor(() => { + expect(mockOnCancelMocks.endConfirming).toHaveBeenCalled() + }) + expect(mockOnCancelMocks.toast).toHaveBeenCalledWith( + expect.objectContaining({status: 'error'}) + ) + expect(mockAttemptFailOrderMocks.failOrder).toHaveBeenCalledWith({ + parameters: {orderNo, reopenBasket: true}, + body: {reasonCode: 'payment_confirm_failure'} + }) + expect(mockNavigate).not.toHaveBeenCalled() + + mockValidateTestCaptureConfig = null + mockOnCancelMocks = null + mockAttemptFailOrderMocks = null + }) + + test('calls endConfirming, showErrorMessage(ORDER_RECOVERY_FAILED), and navigate to cart when attemptFailOrder returns false and usage is EXPRESS_PAY_NOW', async () => { + mockValidateTestCaptureConfig = {} + mockOnCancelMocks = {endConfirming: jest.fn(), toast: jest.fn()} + + await renderAndGetConfig({ + prepareBasket: jest.fn().mockResolvedValue(mockBasket), + usage: EXPRESS_PAY_NOW + }) + + dispatchPaymentError() + + await waitFor(() => { + expect(mockOnCancelMocks.endConfirming).toHaveBeenCalled() + }) + expect(mockOnCancelMocks.toast).toHaveBeenCalledWith( + expect.objectContaining({status: 'error'}) + ) + expect(mockNavigate).toHaveBeenCalledWith('/cart') + + mockValidateTestCaptureConfig = null + mockOnCancelMocks = null + }) + + test('calls endConfirming and showErrorMessage(ORDER_RECOVERY_FAILED) but does not navigate when attemptFailOrder returns false and usage is EXPRESS_BUY_NOW', async () => { + mockValidateTestCaptureConfig = {} + mockOnCancelMocks = {endConfirming: jest.fn(), toast: jest.fn()} + + await renderAndGetConfig({ + prepareBasket: jest.fn().mockResolvedValue(mockBasket), + usage: EXPRESS_BUY_NOW + }) + + dispatchPaymentError() + + await waitFor(() => { + expect(mockOnCancelMocks.endConfirming).toHaveBeenCalled() + }) + expect(mockOnCancelMocks.toast).toHaveBeenCalledWith( + expect.objectContaining({status: 'error'}) + ) + expect(mockNavigate).not.toHaveBeenCalled() + + mockValidateTestCaptureConfig = null + mockOnCancelMocks = null + }) + + test('calls endConfirming, showErrorMessage(ORDER_RECOVERY_FAILED), and navigate when orderRef is set but getOrder returns status other than created', async () => { + mockValidateTestCaptureConfig = {} + mockOnCancelMocks = {endConfirming: jest.fn(), toast: jest.fn()} + mockAttemptFailOrderMocks = createAttemptFailOrderMocks({ + basket: mockBasket, + order: mockOrder, + orderFromUpdate: mockOrderFromUpdatePayment, + getOrderStatus: 'completed' + }) + + const {config} = await renderAndGetConfig({ + prepareBasket: jest.fn().mockResolvedValue(mockBasket), + usage: EXPRESS_PAY_NOW + }) + + await config.actions.onClick('card') + await flush() + await config.actions.createIntent() + await flush() + + dispatchPaymentError() + + await waitFor(() => { + expect(mockOnCancelMocks.endConfirming).toHaveBeenCalled() + }) + expect(mockOnCancelMocks.toast).toHaveBeenCalledWith( + expect.objectContaining({status: 'error'}) + ) + expect(mockNavigate).toHaveBeenCalledWith('/cart') + expect(mockAttemptFailOrderMocks.failOrder).not.toHaveBeenCalled() + + mockValidateTestCaptureConfig = null + mockOnCancelMocks = null + mockAttemptFailOrderMocks = null + }) +}) + +describe('failOrder error handling', () => { + const mockFailOrder = jest.fn() + const mockCreateOrder = jest.fn() + const mockUpdatePaymentInstrument = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + mockFailOrder.mockResolvedValue({}) + mockFailOrderToast = jest.fn() + }) + + afterEach(() => { + mockFailOrderToast = null + }) + + // Mock the mutations to verify they're available + jest.mock('@salesforce/commerce-sdk-react', () => { + const actual = jest.requireActual('@salesforce/commerce-sdk-react') + return { + ...actual, + useShopperOrdersMutation: (mutationKey) => { + if (mutationKey === 'failOrder') { + return {mutateAsync: mockFailOrder} + } + if (mutationKey === 'createOrder') { + return {mutateAsync: mockCreateOrder} + } + if (mutationKey === 'updatePaymentInstrumentForOrder') { + return {mutateAsync: mockUpdatePaymentInstrument} + } + return {mutateAsync: jest.fn()} + }, + usePaymentConfiguration: () => ({ + data: { + paymentMethods: [{id: 'card', name: 'Card'}], + paymentMethodSetAccounts: [] + } + }), + useShopperBasketsV2Mutation: () => ({ + mutateAsync: jest.fn() + }), + useShippingMethodsForShipmentV2: () => ({ + refetch: jest.fn() + }) + } + }) + + jest.mock('@salesforce/retail-react-app/app/hooks/use-shopper-configuration', () => ({ + useShopperConfiguration: () => 'default' + })) + + // It doesn't trigger the actual failOrder call (that requires the full payment flow), but it confirms the setup is correct. + // The actual failOrder call is better tested in integration/E2E tests. + test('failOrder mutation is available and error message constant is defined', () => { + renderWithProviders() + + expect(screen.getByTestId('sf-payments-express')).toBeInTheDocument() + expect(mockFailOrder).toBeDefined() + expect(mockFailOrderToast).toBeDefined() + }) +}) diff --git a/packages/template-retail-react-app/app/components/sf-payments-express/index.jsx b/packages/template-retail-react-app/app/components/sf-payments-express/index.jsx new file mode 100644 index 0000000000..b1c33eee95 --- /dev/null +++ b/packages/template-retail-react-app/app/components/sf-payments-express/index.jsx @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import React, {useCallback, useMemo} from 'react' +import PropTypes from 'prop-types' + +import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' +import SFPaymentsExpressButtons from '@salesforce/retail-react-app/app/components/sf-payments-express-buttons' +import {EXPRESS_PAY_NOW} from '@salesforce/retail-react-app/app/hooks/use-sf-payments' + +const SFPaymentsExpress = ({ + expressButtonLayout = 'vertical', + maximumButtonCount = undefined, + onPaymentMethodsRendered, + onExpressPaymentCompleted +}) => { + // keepPreviousData: true prevents components from unmounting during refetches triggered by + // SFPaymentsExpressButtons mutations. While mutations typically update the cache immediately, + // this flag ensures data remains available even if the refetch completes before the cache update. + const {data: basket} = useCurrentBasket() + + const prepareBasket = useCallback(async () => { + return basket + }, [basket?.basketId]) + const [paymentCurrency, paymentCountryCode, initialAmount] = useMemo( + () => [ + basket?.currency, + basket?.billingAddress?.countryCode, + basket?.orderTotal || basket?.productSubTotal + ], + [basket?.basketId] + ) + + if (!basket?.basketId) { + return null + } + + return ( +
+ +
+ ) +} + +SFPaymentsExpress.propTypes = { + expressButtonLayout: PropTypes.string, + maximumButtonCount: PropTypes.number, + onPaymentMethodsRendered: PropTypes.func, + onExpressPaymentCompleted: PropTypes.func +} + +export default SFPaymentsExpress diff --git a/packages/template-retail-react-app/app/components/sf-payments-express/index.test.js b/packages/template-retail-react-app/app/components/sf-payments-express/index.test.js new file mode 100644 index 0000000000..77c15235d3 --- /dev/null +++ b/packages/template-retail-react-app/app/components/sf-payments-express/index.test.js @@ -0,0 +1,338 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import React from 'react' +import {screen} from '@testing-library/react' +import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' +import SFPaymentsExpress from '@salesforce/retail-react-app/app/components/sf-payments-express' +import basketWithSuit from '@salesforce/retail-react-app/app/mocks/basket-with-suit' +import emptyBasket from '@salesforce/retail-react-app/app/mocks/empty-basket' +import {EXPRESS_PAY_NOW} from '@salesforce/retail-react-app/app/hooks/use-sf-payments' + +// Mock getConfig to provide necessary configuration +jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => { + const actual = jest.requireActual('@salesforce/pwa-kit-runtime/utils/ssr-config') + const mockConfig = jest.requireActual('@salesforce/retail-react-app/config/mocks/default') + return { + ...actual, + getConfig: jest.fn(() => ({ + ...mockConfig, + app: { + ...mockConfig.app, + sfPayments: { + enabled: true + } + } + })) + } +}) + +// Mock the useCurrentBasket hook +jest.mock('@salesforce/retail-react-app/app/hooks/use-current-basket', () => ({ + useCurrentBasket: jest.fn() +})) + +// Captured prepareBasket from SFPaymentsExpressButtons for tests +let capturedPrepareBasket = null + +// Mock the SFPaymentsExpressButtons child component +jest.mock('@salesforce/retail-react-app/app/components/sf-payments-express-buttons', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const PropTypes = require('prop-types') + const React = require('react') // eslint-disable-line -- require in jest.mock factory + + const MockSFPaymentsExpressButtons = ({ + usage, + paymentCurrency, + paymentCountryCode, + initialAmount, + prepareBasket, + expressButtonLayout, + maximumButtonCount + }) => { + capturedPrepareBasket = prepareBasket + return ( +
+
{usage}
+
{paymentCurrency}
+
{paymentCountryCode}
+
{initialAmount}
+
{expressButtonLayout}
+
{maximumButtonCount}
+
+ ) + } + + MockSFPaymentsExpressButtons.propTypes = { + usage: PropTypes.number, + paymentCurrency: PropTypes.string, + paymentCountryCode: PropTypes.string, + initialAmount: PropTypes.number, + prepareBasket: PropTypes.func, + expressButtonLayout: PropTypes.string, + maximumButtonCount: PropTypes.number + } + + return MockSFPaymentsExpressButtons +}) + +// Import after mocking +import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' + +// Helper function to normalize basket data (convert snake_case to camelCase) +const normalizeBasket = (basket) => { + if (!basket) return {} + return { + ...basket, + basketId: basket.basketId || basket.basket_id, + orderTotal: basket.orderTotal || basket.order_total, + productSubTotal: basket.productSubTotal || basket.product_sub_total + } +} + +// Helper function to create mock basket data +const createMockBasket = (basket) => { + const normalizedBasket = normalizeBasket(basket) + return { + data: normalizedBasket, + derivedData: { + hasBasket: !!normalizedBasket?.basketId, + totalItems: + normalizedBasket?.productItems?.reduce((sum, item) => sum + item.quantity, 0) || 0, + shipmentIdToTotalItems: {}, + totalDeliveryShipments: 0, + totalPickupShipments: 0, + pickupStoreIds: [], + isMissingShippingAddress: false, + isMissingShippingMethod: false, + totalShippingCost: 0 + }, + currency: normalizedBasket?.currency || 'USD' + } +} + +beforeEach(() => { + // Suppress unhandled request warnings for known endpoints + global.server.listen({ + onUnhandledRequest: (req) => { + const url = req.url.href + if ( + url.includes('/api/payment-metadata') || + url.includes('/shopper-configurations') || + url.includes('/product-lists') + ) { + return // Ignore these + } + console.error('Found an unhandled %s request to %s', req.method, url) + } + }) +}) + +afterEach(() => { + jest.clearAllMocks() + capturedPrepareBasket = null +}) + +describe('SFPaymentsExpress', () => { + test('renders SFPaymentsExpressButtons when basket exists', () => { + useCurrentBasket.mockReturnValue(createMockBasket(basketWithSuit)) + + renderWithProviders() + + expect(screen.getByTestId('sf-payments-express-buttons')).toBeInTheDocument() + expect(screen.getByTestId('usage')).toHaveTextContent(EXPRESS_PAY_NOW) + expect(screen.getByTestId('payment-currency')).toHaveTextContent(basketWithSuit.currency) + expect(screen.getByTestId('payment-country-code')).toHaveTextContent('') + expect(screen.getByTestId('initial-amount')).toHaveTextContent( + (basketWithSuit.order_total || basketWithSuit.orderTotal).toString() + ) + expect(screen.getByTestId('express-button-layout')).toHaveTextContent('vertical') + }) + + test('renders null when basket does not exist', () => { + useCurrentBasket.mockReturnValue(createMockBasket(null)) + + renderWithProviders() + + expect(screen.queryByTestId('sf-payments-express-buttons')).not.toBeInTheDocument() + }) + + test('renders null when basketId is not present', () => { + useCurrentBasket.mockReturnValue( + createMockBasket({...basketWithSuit, basketId: null, basket_id: null}) + ) + + renderWithProviders() + + expect(screen.queryByTestId('sf-payments-express-buttons')).not.toBeInTheDocument() + }) + + test('passes correct props with custom expressButtonLayout', () => { + useCurrentBasket.mockReturnValue(createMockBasket(basketWithSuit)) + + renderWithProviders() + + expect(screen.getByTestId('express-button-layout')).toHaveTextContent('horizontal') + }) + + test('passes correct props with custom maximumButtonCount', () => { + useCurrentBasket.mockReturnValue(createMockBasket(basketWithSuit)) + + renderWithProviders() + + expect(screen.getByTestId('maximum-button-count')).toHaveTextContent('3') + }) + + test('passes onPaymentMethodsRendered callback when provided', () => { + useCurrentBasket.mockReturnValue(createMockBasket(basketWithSuit)) + + const mockCallback = jest.fn() + renderWithProviders() + + expect(screen.getByTestId('sf-payments-express-buttons')).toBeInTheDocument() + }) + + test('uses orderTotal as initialAmount when available', () => { + const basketWithOrderTotal = { + ...basketWithSuit, + orderTotal: 100, + productSubTotal: 50 + } + + useCurrentBasket.mockReturnValue(createMockBasket(basketWithOrderTotal)) + + renderWithProviders() + + expect(screen.getByTestId('initial-amount')).toHaveTextContent('100') + }) + + test('uses productSubTotal as initialAmount when orderTotal is not available', () => { + const basketWithoutOrderTotal = { + ...basketWithSuit, + orderTotal: null, + order_total: null, // Also set snake_case version to null + productSubTotal: 50, + product_sub_total: 50 + } + + useCurrentBasket.mockReturnValue(createMockBasket(basketWithoutOrderTotal)) + + renderWithProviders() + + expect(screen.getByTestId('initial-amount')).toHaveTextContent('50') + }) + + test('passes billingAddress countryCode as paymentCountryCode', () => { + const basketWithBillingAddress = { + ...basketWithSuit, + billingAddress: { + countryCode: 'US' + } + } + + useCurrentBasket.mockReturnValue(createMockBasket(basketWithBillingAddress)) + + renderWithProviders() + + expect(screen.getByTestId('payment-country-code')).toHaveTextContent('US') + }) + + test('handles empty basket correctly', () => { + useCurrentBasket.mockReturnValue(createMockBasket(emptyBasket)) + + renderWithProviders() + + expect(screen.getByTestId('sf-payments-express-buttons')).toBeInTheDocument() + expect(screen.getByTestId('payment-currency')).toHaveTextContent(emptyBasket.currency) + expect(screen.getByTestId('initial-amount')).toHaveTextContent('0') + }) + + test('calls useCurrentBasket hook', () => { + useCurrentBasket.mockReturnValue(createMockBasket(basketWithSuit)) + + renderWithProviders() + + expect(screen.getByTestId('sf-payments-express-buttons')).toBeInTheDocument() + expect(useCurrentBasket).toHaveBeenCalled() + }) +}) + +describe('prepareBasket', () => { + test('prepareBasket is passed to SFPaymentsExpressButtons when basket exists', () => { + useCurrentBasket.mockReturnValue(createMockBasket(basketWithSuit)) + + renderWithProviders() + + expect(capturedPrepareBasket).toBeDefined() + expect(typeof capturedPrepareBasket).toBe('function') + }) + + test('prepareBasket when invoked returns the current basket', async () => { + useCurrentBasket.mockReturnValue(createMockBasket(basketWithSuit)) + + renderWithProviders() + + expect(capturedPrepareBasket).toBeDefined() + const result = await capturedPrepareBasket() + const expectedBasket = normalizeBasket(basketWithSuit) + expect(result).toEqual(expectedBasket) + expect(result.basketId || result.basket_id).toBe( + basketWithSuit.basket_id || basketWithSuit.basketId + ) + }) + + test('prepareBasket returns basket with currency and totals', async () => { + const basketWithTotals = { + ...basketWithSuit, + currency: 'EUR', + orderTotal: 99.99, + productSubTotal: 80 + } + useCurrentBasket.mockReturnValue(createMockBasket(basketWithTotals)) + + renderWithProviders() + + const result = await capturedPrepareBasket() + expect(result.currency).toBe('EUR') + expect(result.orderTotal ?? result.order_total).toBe(99.99) + }) + + test('prepareBasket is not set when component returns null', () => { + useCurrentBasket.mockReturnValue(createMockBasket(null)) + + renderWithProviders() + + expect(screen.queryByTestId('sf-payments-express-buttons')).not.toBeInTheDocument() + expect(capturedPrepareBasket).toBeNull() + }) +}) + +describe('basketIdRef preservation', () => { + test('stays mounted when basket becomes null after initial render', () => { + // First render with basket + useCurrentBasket.mockReturnValue(createMockBasket(basketWithSuit)) + const {rerender} = renderWithProviders() + + expect(screen.getByTestId('sf-payments-express-buttons')).toBeInTheDocument() + + // Basket becomes null (consumed after order creation) + useCurrentBasket.mockReturnValue(createMockBasket(null)) + rerender() + + // Should still render because basketIdRef preserved the value + expect(screen.getByTestId('sf-payments-express-buttons')).toBeInTheDocument() + }) + + test('renders null on initial mount when basket is null', () => { + // First render without basket + useCurrentBasket.mockReturnValue(createMockBasket(null)) + renderWithProviders() + + // Should not render because basketIdRef was never set + expect(screen.queryByTestId('sf-payments-express-buttons')).not.toBeInTheDocument() + }) +}) diff --git a/packages/template-retail-react-app/app/constants.js b/packages/template-retail-react-app/app/constants.js index 4765905116..f1a7ccaeae 100644 --- a/packages/template-retail-react-app/app/constants.js +++ b/packages/template-retail-react-app/app/constants.js @@ -264,3 +264,21 @@ export const USER_NOT_FOUND_ERROR = /user not found/i * @deprecated Use `partialHydrationEnabled` in the config file instead */ export const PARTIAL_HYDRATION_ENABLED = false + +// Constants for Salesforce Payments +export const PAYMENT_METHOD_TYPES = { + CARD: 'card', + PAYPAL: 'paypal', + VENMO: 'venmo' +} + +export const PAYMENT_GATEWAYS = { + STRIPE: 'stripe', + ADYEN: 'adyen', + PAYPAL: 'paypal' +} + +export const SETUP_FUTURE_USAGE = { + ON_SESSION: 'on_session', + OFF_SESSION: 'off_session' +} diff --git a/packages/template-retail-react-app/app/hooks/use-add-to-cart-modal.js b/packages/template-retail-react-app/app/hooks/use-add-to-cart-modal.js index 53a05818e5..203ecbf988 100644 --- a/packages/template-retail-react-app/app/hooks/use-add-to-cart-modal.js +++ b/packages/template-retail-react-app/app/hooks/use-add-to-cart-modal.js @@ -29,6 +29,7 @@ import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-curre import Link from '@salesforce/retail-react-app/app/components/link' import RecommendedProducts from '@salesforce/retail-react-app/app/components/recommended-products' import {LockIcon} from '@salesforce/retail-react-app/app/components/icons' +import LoadingSpinner from '@salesforce/retail-react-app/app/components/loading-spinner' import {findImageGroupBy} from '@salesforce/retail-react-app/app/utils/image-groups-utils' import { getPriceData, @@ -36,7 +37,12 @@ import { } from '@salesforce/retail-react-app/app/utils/product-utils' import {EINSTEIN_RECOMMENDERS} from '@salesforce/retail-react-app/app/constants' import DisplayPrice from '@salesforce/retail-react-app/app/components/display-price' +import SFPaymentsExpress from '@salesforce/retail-react-app/app/components/sf-payments-express' import SelectBonusProductsCard from '@salesforce/retail-react-app/app/pages/cart/partials/select-bonus-products-card' +import { + useSFPaymentsEnabled, + useSFPayments +} from '@salesforce/retail-react-app/app/hooks/use-sf-payments' import { getRemainingAvailableBonusProductsForProduct, @@ -45,7 +51,7 @@ import { shouldShowBonusProductSelection, getPromotionIdsForProduct } from '@salesforce/retail-react-app/app/utils/bonus-product' - +import {useCurrency} from '@salesforce/retail-react-app/app/hooks' /** * This is the context for managing the AddToCartModal. * Used in top level App component. @@ -73,17 +79,28 @@ export const AddToCartModal = () => { const isProductABundle = product?.type.bundle const intl = useIntl() + const {currency: activeCurrency} = useCurrency() const { data: basket = {}, derivedData: {totalItems} } = useCurrentBasket() const size = useBreakpointValue({base: 'full', lg: '2xl', xl: '4xl'}) - const {currency, productSubTotal} = basket + + const currency = basket?.currency || activeCurrency + const productSubTotal = basket?.productSubTotal || 0 + const numberOfItemsAdded = isProductABundle ? selectedQuantity : Array.isArray(itemsAdded) ? itemsAdded.reduce((acc, {quantity}) => acc + quantity, 0) : 0 + const sfPaymentsEnabled = useSFPaymentsEnabled() + const {confirmingBasket} = useSFPayments() + + // Close modal after express payment completes + const handleExpressPaymentCompleted = () => { + onClose() + } // Bonus product logic const {data: productsWithPromotions, ruleBasedQualifyingProductsMap} = @@ -120,7 +137,21 @@ export const AddToCartModal = () => { borderRadius={{base: 'none', md: 'base'}} bgColor="gray.50" containerProps={{'data-testid': 'add-to-cart-modal'}} + position="relative" > + {confirmingBasket && ( + + )} {intl.formatMessage( @@ -456,6 +487,14 @@ export const AddToCartModal = () => { id: 'add_to_cart_modal.link.checkout' })} + + {sfPaymentsEnabled && ( + + )}
@@ -525,6 +564,14 @@ export const AddToCartModal = () => { id: 'add_to_cart_modal.link.checkout' })} + + {sfPaymentsEnabled && ( + + )} diff --git a/packages/template-retail-react-app/app/hooks/use-add-to-cart-modal.test.js b/packages/template-retail-react-app/app/hooks/use-add-to-cart-modal.test.js index 53c3d46635..16274067e4 100644 --- a/packages/template-retail-react-app/app/hooks/use-add-to-cart-modal.test.js +++ b/packages/template-retail-react-app/app/hooks/use-add-to-cart-modal.test.js @@ -32,6 +32,13 @@ jest.mock('@salesforce/commerce-sdk-react', () => ({ useCustomerId: jest.fn(() => 'test-customer-id'), useShopperCustomersMutation: jest.fn(() => ({ mutateAsync: jest.fn() + })), + useConfigurations: jest.fn(() => ({ + data: { + configurations: [] + }, + isLoading: false, + error: null })) })) @@ -53,25 +60,76 @@ jest.mock('@salesforce/retail-react-app/app/hooks/use-navigation', () => ({ default: jest.fn(() => jest.fn()) })) +// Mock useSFPaymentsEnabled as a simple jest function +const mockUseSFPaymentsEnabled = jest.fn(() => false) +const mockUseSFPayments = jest.fn(() => ({confirmingBasket: null})) + +jest.mock('@salesforce/retail-react-app/app/hooks/use-sf-payments', () => { + const actual = jest.requireActual('@salesforce/retail-react-app/app/hooks/use-sf-payments') + return { + ...actual, + useSFPaymentsEnabled: () => mockUseSFPaymentsEnabled(), + useSFPayments: () => mockUseSFPayments() + } +}) + +// Mock SFPaymentsExpress component +jest.mock('@salesforce/retail-react-app/app/components/sf-payments-express', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const React = require('react') + // eslint-disable-next-line @typescript-eslint/no-var-requires + const PropTypes = require('prop-types') + + function MockSFPaymentsExpress({ + expressButtonLayout, + maximumButtonCount, + onExpressPaymentCompleted + }) { + return React.createElement( + 'div', + { + 'data-testid': 'sf-payments-express', + 'data-button-layout': expressButtonLayout, + 'data-maximum-button-count': maximumButtonCount, + 'data-has-completion-callback': !!onExpressPaymentCompleted + }, + 'SF Payments Express' + ) + } + + MockSFPaymentsExpress.propTypes = { + expressButtonLayout: PropTypes.string, + maximumButtonCount: PropTypes.number, + onExpressPaymentCompleted: PropTypes.func + } + + return MockSFPaymentsExpress +}) + // Mock SelectBonusProductsCard to verify props being passed +jest.mock('@salesforce/retail-react-app/app/pages/cart/partials/select-bonus-products-card', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const React = require('react') + // eslint-disable-next-line @typescript-eslint/no-var-requires + const PropTypes = require('prop-types') -jest.mock( - '@salesforce/retail-react-app/app/pages/cart/partials/select-bonus-products-card', - () => - // eslint-disable-next-line react/prop-types - function MockSelectBonusProductsCard({hideSelectionCounter}) { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const React = require('react') - return React.createElement( - 'div', - { - 'data-testid': 'select-bonus-products-card', - 'data-hide-selection-counter': hideSelectionCounter - }, - 'Mock Bonus Products Card' - ) - } -) + function MockSelectBonusProductsCard({hideSelectionCounter}) { + return React.createElement( + 'div', + { + 'data-testid': 'select-bonus-products-card', + 'data-hide-selection-counter': hideSelectionCounter + }, + 'Mock Bonus Products Card' + ) + } + + MockSelectBonusProductsCard.propTypes = { + hideSelectionCounter: PropTypes.bool + } + + return MockSelectBonusProductsCard +}) // Mock bonus product utilities jest.mock('@salesforce/retail-react-app/app/utils/bonus-product', () => ({ @@ -721,6 +779,8 @@ beforeEach(() => { derivedData: {}, currency: 'USD' }) + // Default mock with sfPayments disabled + mockUseSFPaymentsEnabled.mockReturnValue(false) }) test('Renders AddToCartModal with multiple products', () => { @@ -735,7 +795,7 @@ test('Renders AddToCartModal with multiple products', () => { }, { product: MOCK_PRODUCT, - variant: MOCK_PRODUCT.variants[0], + variant: MOCK_PRODUCT.variants[1], quantity: 1 } ] @@ -1101,3 +1161,134 @@ test('selects bonusDiscountLineItem with remaining capacity when first one is fu expect(bonusProductsCard).toBeInTheDocument() expect(bonusProductsCard).toHaveAttribute('data-hide-selection-counter', 'true') }) + +test('renders SFPaymentsExpress when sfPayments is enabled', () => { + // Enable sfPayments + mockUseSFPaymentsEnabled.mockReturnValue(true) + + const MOCK_DATA = { + product: MOCK_PRODUCT, + itemsAdded: [ + { + product: MOCK_PRODUCT, + variant: MOCK_PRODUCT.variants[0], + quantity: 1 + } + ] + } + + mockUseCurrentBasket.mockReturnValue({ + data: { + productSubTotal: 14.99, + currency: 'USD' + }, + derivedData: { + totalItems: 1 + }, + currency: 'USD' + }) + + renderWithProviders( + + + + ) + + // Verify SFPaymentsExpress component is rendered with correct props (rendered twice: desktop + mobile) + const sfPaymentsExpressComponents = screen.getAllByTestId('sf-payments-express') + expect(sfPaymentsExpressComponents).toHaveLength(2) // Desktop and mobile views + sfPaymentsExpressComponents.forEach((component) => { + expect(component).toBeInTheDocument() + expect(component).toHaveAttribute('data-button-layout', 'vertical') + expect(component).toHaveAttribute('data-maximum-button-count', '1') + expect(component).toHaveAttribute('data-has-completion-callback', 'true') + }) +}) + +test('does not render SFPaymentsExpress when sfPayments is disabled', () => { + // sfPayments disabled by default in beforeEach + const MOCK_DATA = { + product: MOCK_PRODUCT, + itemsAdded: [ + { + product: MOCK_PRODUCT, + variant: MOCK_PRODUCT.variants[0], + quantity: 1 + } + ] + } + + mockUseCurrentBasket.mockReturnValue({ + data: { + productSubTotal: 14.99, + currency: 'USD' + }, + derivedData: { + totalItems: 1 + }, + currency: 'USD' + }) + + renderWithProviders( + + + + ) + + // Verify SFPaymentsExpress component is NOT rendered + expect(screen.queryByTestId('sf-payments-express')).not.toBeInTheDocument() +}) + +test('does not render SFPaymentsExpress when useSFPaymentsEnabled returns false', () => { + // Explicitly set sfPayments to false + mockUseSFPaymentsEnabled.mockReturnValue(false) + + const MOCK_DATA = { + product: MOCK_PRODUCT, + itemsAdded: [ + { + product: MOCK_PRODUCT, + variant: MOCK_PRODUCT.variants[0], + quantity: 1 + } + ] + } + + mockUseCurrentBasket.mockReturnValue({ + data: { + productSubTotal: 14.99, + currency: 'USD' + }, + derivedData: { + totalItems: 1 + }, + currency: 'USD' + }) + + renderWithProviders( + + + + ) + + // Verify SFPaymentsExpress component is NOT rendered + expect(screen.queryByTestId('sf-payments-express')).not.toBeInTheDocument() +}) diff --git a/packages/template-retail-react-app/app/hooks/use-auth-modal.js b/packages/template-retail-react-app/app/hooks/use-auth-modal.js index d81b62ddea..ae1a60c960 100644 --- a/packages/template-retail-react-app/app/hooks/use-auth-modal.js +++ b/packages/template-retail-react-app/app/hooks/use-auth-modal.js @@ -24,7 +24,7 @@ import { useCustomerId, useCustomerType, useCustomerBaskets, - useShopperBasketsMutation + useShopperBasketsV2Mutation as useShopperBasketsMutation } from '@salesforce/commerce-sdk-react' import LoginForm from '@salesforce/retail-react-app/app/components/login' import ResetPasswordForm from '@salesforce/retail-react-app/app/components/reset-password' diff --git a/packages/template-retail-react-app/app/hooks/use-cleanup-temporary-baskets.js b/packages/template-retail-react-app/app/hooks/use-cleanup-temporary-baskets.js new file mode 100644 index 0000000000..28cccde567 --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-cleanup-temporary-baskets.js @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {useCustomerId, useCustomerBaskets} from '@salesforce/commerce-sdk-react' +import {useShopperBasketsV2Mutation as useShopperBasketsMutation} from '@salesforce/commerce-sdk-react' +import {isServer} from '@salesforce/retail-react-app/app/utils/utils' +import logger from '@salesforce/retail-react-app/app/utils/logger-instance' + +/** + * Hook to clean up temporary baskets + * @returns {Function} cleanupTemporaryBaskets - Function to clean up temporary baskets + */ +export const useCleanupTemporaryBaskets = () => { + const customerId = useCustomerId() + const {data: basketsData, refetch: refetchBaskets} = useCustomerBaskets( + {parameters: {customerId}}, + { + enabled: !!customerId && !isServer, + keepPreviousData: true + } + ) + const {mutateAsync: deleteBasket} = useShopperBasketsMutation('deleteBasket') + + const cleanupTemporaryBaskets = async () => { + if (customerId && basketsData?.baskets && basketsData.baskets.length > 0) { + const temporaryBaskets = basketsData.baskets.filter( + (basket) => basket.temporaryBasket === true + ) + if (temporaryBaskets.length > 0) { + // Clean up in parallel + await Promise.all( + temporaryBaskets.map((basket) => + deleteBasket({ + parameters: {basketId: basket.basketId} + }).catch((error) => { + // Only log if it's not a "Basket Not Found" error + if (error?.response?.status !== 404) { + logger.error( + `Error deleting temporary basket ${basket.basketId}:`, + { + namespace: 'useCleanupTemporaryBaskets' + } + ) + } + }) + ) + ) + // Refetch after cleanup completes + refetchBaskets() + } + } + } + + return cleanupTemporaryBaskets +} diff --git a/packages/template-retail-react-app/app/hooks/use-cleanup-temporary-baskets.test.js b/packages/template-retail-react-app/app/hooks/use-cleanup-temporary-baskets.test.js new file mode 100644 index 0000000000..ccf9a1f6ef --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-cleanup-temporary-baskets.test.js @@ -0,0 +1,276 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import {renderHook} from '@testing-library/react' +import {useCleanupTemporaryBaskets} from '@salesforce/retail-react-app/app/hooks/use-cleanup-temporary-baskets' +import { + useCustomerId, + useCustomerBaskets, + useShopperBasketsV2Mutation as useShopperBasketsMutation +} from '@salesforce/commerce-sdk-react' +import logger from '@salesforce/retail-react-app/app/utils/logger-instance' + +const MOCK_USE_QUERY_RESULT = { + data: undefined, + dataUpdatedAt: 0, + error: null, + errorUpdatedAt: 0, + failureCount: 0, + isError: false, + isFetched: false, + isFetchedAfterMount: false, + isFetching: false, + isIdle: false, + isLoading: false, + isLoadingError: false, + isPlaceholderData: false, + isPreviousData: false, + isRefetchError: false, + isRefetching: false, + isStale: false, + isSuccess: true, + status: 'success', + refetch: jest.fn(), + remove: jest.fn() +} + +const mockDeleteBasket = jest.fn() +const mockRefetch = jest.fn() + +jest.mock('@salesforce/commerce-sdk-react', () => { + const originalModule = jest.requireActual('@salesforce/commerce-sdk-react') + return { + ...originalModule, + useCustomerId: jest.fn(), + useCustomerBaskets: jest.fn(), + useShopperBasketsV2Mutation: jest.fn() + } +}) + +jest.mock('@salesforce/retail-react-app/app/utils/utils', () => ({ + isServer: false +})) + +jest.mock('@salesforce/retail-react-app/app/utils/logger-instance', () => ({ + error: jest.fn() +})) + +describe('useCleanupTemporaryBaskets', () => { + beforeEach(() => { + jest.clearAllMocks() + mockDeleteBasket.mockResolvedValue({}) + mockRefetch.mockResolvedValue({}) + useCustomerId.mockReturnValue('test-customer-id') + useCustomerBaskets.mockReturnValue({ + ...MOCK_USE_QUERY_RESULT, + data: { + baskets: [] + }, + refetch: mockRefetch + }) + useShopperBasketsMutation.mockReturnValue({ + mutateAsync: mockDeleteBasket + }) + }) + + test('returns cleanup function', () => { + const {result} = renderHook(() => useCleanupTemporaryBaskets()) + expect(typeof result.current).toBe('function') + }) + + test('cleans up temporary baskets when they exist', async () => { + const temporaryBaskets = [ + {basketId: 'temp-1', temporaryBasket: true}, + {basketId: 'temp-2', temporaryBasket: true} + ] + const regularBaskets = [{basketId: 'regular-1', temporaryBasket: false}] + + useCustomerBaskets.mockReturnValue({ + ...MOCK_USE_QUERY_RESULT, + data: { + baskets: [...temporaryBaskets, ...regularBaskets] + }, + refetch: mockRefetch + }) + + const {result} = renderHook(() => useCleanupTemporaryBaskets()) + + await result.current() + + expect(mockDeleteBasket).toHaveBeenCalledTimes(2) + expect(mockDeleteBasket).toHaveBeenCalledWith({ + parameters: {basketId: 'temp-1'} + }) + expect(mockDeleteBasket).toHaveBeenCalledWith({ + parameters: {basketId: 'temp-2'} + }) + expect(mockRefetch).toHaveBeenCalledTimes(1) + }) + + test('does not delete regular baskets', async () => { + const regularBaskets = [ + {basketId: 'regular-1', temporaryBasket: false}, + {basketId: 'regular-2', temporaryBasket: false} + ] + + useCustomerBaskets.mockReturnValue({ + ...MOCK_USE_QUERY_RESULT, + data: { + baskets: regularBaskets + }, + refetch: mockRefetch + }) + + const {result} = renderHook(() => useCleanupTemporaryBaskets()) + + await result.current() + + expect(mockDeleteBasket).not.toHaveBeenCalled() + expect(mockRefetch).not.toHaveBeenCalled() + }) + + test('does nothing when no customerId', async () => { + useCustomerId.mockReturnValue(null) + + const {result} = renderHook(() => useCleanupTemporaryBaskets()) + + await result.current() + + expect(mockDeleteBasket).not.toHaveBeenCalled() + expect(mockRefetch).not.toHaveBeenCalled() + }) + + test('does nothing when no baskets', async () => { + useCustomerBaskets.mockReturnValue({ + ...MOCK_USE_QUERY_RESULT, + data: { + baskets: [] + }, + refetch: mockRefetch + }) + + const {result} = renderHook(() => useCleanupTemporaryBaskets()) + + await result.current() + + expect(mockDeleteBasket).not.toHaveBeenCalled() + expect(mockRefetch).not.toHaveBeenCalled() + }) + + test('does nothing when basketsData is undefined', async () => { + useCustomerBaskets.mockReturnValue({ + ...MOCK_USE_QUERY_RESULT, + data: undefined, + refetch: mockRefetch + }) + + const {result} = renderHook(() => useCleanupTemporaryBaskets()) + + await result.current() + + expect(mockDeleteBasket).not.toHaveBeenCalled() + expect(mockRefetch).not.toHaveBeenCalled() + }) + + test('handles 404 errors silently', async () => { + const error404 = new Error('Not Found') + error404.response = {status: 404} + mockDeleteBasket.mockRejectedValueOnce(error404) + + const temporaryBaskets = [{basketId: 'temp-1', temporaryBasket: true}] + + useCustomerBaskets.mockReturnValue({ + ...MOCK_USE_QUERY_RESULT, + data: { + baskets: temporaryBaskets + }, + refetch: mockRefetch + }) + + const {result} = renderHook(() => useCleanupTemporaryBaskets()) + + await result.current() + + expect(mockDeleteBasket).toHaveBeenCalledTimes(1) + expect(logger.error).not.toHaveBeenCalled() + expect(mockRefetch).toHaveBeenCalledTimes(1) + }) + + test('logs non-404 errors', async () => { + const error500 = new Error('Server Error') + error500.response = {status: 500} + mockDeleteBasket.mockRejectedValueOnce(error500) + + const temporaryBaskets = [{basketId: 'temp-1', temporaryBasket: true}] + + useCustomerBaskets.mockReturnValue({ + ...MOCK_USE_QUERY_RESULT, + data: { + baskets: temporaryBaskets + }, + refetch: mockRefetch + }) + + const {result} = renderHook(() => useCleanupTemporaryBaskets()) + + await result.current() + + expect(mockDeleteBasket).toHaveBeenCalledTimes(1) + expect(logger.error).toHaveBeenCalledWith('Error deleting temporary basket temp-1:', { + namespace: 'useCleanupTemporaryBaskets' + }) + expect(mockRefetch).toHaveBeenCalledTimes(1) + }) + + test('handles errors without response status', async () => { + const error = new Error('Network Error') + mockDeleteBasket.mockRejectedValueOnce(error) + + const temporaryBaskets = [{basketId: 'temp-1', temporaryBasket: true}] + + useCustomerBaskets.mockReturnValue({ + ...MOCK_USE_QUERY_RESULT, + data: { + baskets: temporaryBaskets + }, + refetch: mockRefetch + }) + + const {result} = renderHook(() => useCleanupTemporaryBaskets()) + + await result.current() + + expect(mockDeleteBasket).toHaveBeenCalledTimes(1) + expect(logger.error).toHaveBeenCalledWith('Error deleting temporary basket temp-1:', { + namespace: 'useCleanupTemporaryBaskets' + }) + expect(mockRefetch).toHaveBeenCalledTimes(1) + }) + + test('deletes multiple temporary baskets in parallel', async () => { + const temporaryBaskets = [ + {basketId: 'temp-1', temporaryBasket: true}, + {basketId: 'temp-2', temporaryBasket: true}, + {basketId: 'temp-3', temporaryBasket: true} + ] + + useCustomerBaskets.mockReturnValue({ + ...MOCK_USE_QUERY_RESULT, + data: { + baskets: temporaryBaskets + }, + refetch: mockRefetch + }) + + const {result} = renderHook(() => useCleanupTemporaryBaskets()) + + await result.current() + + expect(mockDeleteBasket).toHaveBeenCalledTimes(3) + expect(mockRefetch).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/template-retail-react-app/app/hooks/use-current-basket.js b/packages/template-retail-react-app/app/hooks/use-current-basket.js index fc90fbe9aa..fa553b26ac 100644 --- a/packages/template-retail-react-app/app/hooks/use-current-basket.js +++ b/packages/template-retail-react-app/app/hooks/use-current-basket.js @@ -11,6 +11,7 @@ import {isAddressEmpty} from '@salesforce/retail-react-app/app/utils/address-uti import {STORE_LOCATOR_IS_ENABLED} from '@salesforce/retail-react-app/app/constants' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import {useMemo} from 'react' +import {useSFPayments} from '@salesforce/retail-react-app/app/hooks/use-sf-payments' /** * This hook combine some commerce-react-sdk hooks to provide more derived data for Retail App baskets @@ -20,15 +21,20 @@ import {useMemo} from 'react' export const useCurrentBasket = ({id = ''} = {}) => { const storeLocatorEnabled = getConfig()?.app?.storeLocatorEnabled ?? STORE_LOCATOR_IS_ENABLED const customerId = useCustomerId() + const {confirmingBasket} = useSFPayments() const {data: basketsData, ...restOfQuery} = useCustomerBaskets( {parameters: {customerId}}, { enabled: !!customerId && !isServer } ) - + // Select the current basket, prioritizing confirmingBasket, then matching id, then first non-temporary basket + // Filters out temporary baskets to prevent them from showing in the cart const currentBasket = - basketsData?.baskets?.find((basket) => basket?.basketId === id) || basketsData?.baskets?.[0] + confirmingBasket && !confirmingBasket.temporaryBasket + ? confirmingBasket + : basketsData?.baskets?.find((basket) => basket?.basketId === id) || + basketsData?.baskets?.find((basket) => !basket.temporaryBasket) const memoizedDerived = useMemo(() => { // count the number of items in each shipment and rollup total @@ -92,7 +98,8 @@ export const useCurrentBasket = ({id = ''} = {}) => { ...restOfQuery, data: currentBasket, derivedData: { - hasBasket: basketsData?.total > 0, + // Only true if a non-temporary basket exists (temporary baskets are filtered out above) + hasBasket: !!currentBasket, ...memoizedDerived } } diff --git a/packages/template-retail-react-app/app/hooks/use-current-basket.test.js b/packages/template-retail-react-app/app/hooks/use-current-basket.test.js index aa9ffb4bcc..0c2268d432 100644 --- a/packages/template-retail-react-app/app/hooks/use-current-basket.test.js +++ b/packages/template-retail-react-app/app/hooks/use-current-basket.test.js @@ -11,6 +11,7 @@ import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-curre import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' import {useCustomerBaskets} from '@salesforce/commerce-sdk-react' import {mockCustomerBaskets} from '@salesforce/retail-react-app/app/mocks/mock-data' +import {rest} from 'msw' const MOCK_USE_QUERY_RESULT = { data: undefined, @@ -44,7 +45,7 @@ jest.mock('@salesforce/commerce-sdk-react', () => { ...originalModule, useCustomerId: jest.fn(() => 'abmuc2wupJxeoRxuo3wqYYmbhI'), useCustomerBaskets: jest.fn(), - useShopperBasketsMutation: () => ({ + useShopperBasketsV2Mutation: () => ({ mutateAsync: mockAsyncMutate }) } @@ -91,6 +92,44 @@ const MockComponent = () => { describe('useCurrentBasket', function () { beforeEach(() => { jest.resetModules() + // Mock necessary endpoints + global.server.use( + rest.get('*/api/payment-metadata', (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(200), + ctx.json({apiKey: 'test-key', publishableKey: 'pk_test'}) + ) + }), + rest.get('*/customers/:customerId/product-lists', (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(200), + ctx.json({ + data: [], + total: 0 + }) + ) + }), + rest.get('*/configuration/shopper-configurations/*', (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(200), + ctx.json({ + configurations: [] + }) + ) + }), + rest.get('*/product/shopper-products/*', (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(200), + ctx.json({ + data: [] + }) + ) + }) + ) }) test('returns current basket and derivedData when both customerId and basket are defined', async () => { diff --git a/packages/template-retail-react-app/app/hooks/use-current-customer.js b/packages/template-retail-react-app/app/hooks/use-current-customer.js index a3b1ef7e2b..4df58b5c46 100644 --- a/packages/template-retail-react-app/app/hooks/use-current-customer.js +++ b/packages/template-retail-react-app/app/hooks/use-current-customer.js @@ -9,12 +9,20 @@ import {useCustomer, useCustomerId, useCustomerType} from '@salesforce/commerce- /** * A hook that returns the current customer. - * + * @param {Array} [expand] - Optional array of fields to expand in the customer query + * @param {Object} [queryOptions] - Optional React Query options */ -export const useCurrentCustomer = () => { +export const useCurrentCustomer = (expand, queryOptions = {}) => { const customerId = useCustomerId() const {isRegistered, isGuest, customerType} = useCustomerType() - const query = useCustomer({parameters: {customerId}}, {enabled: !!customerId && isRegistered}) + const parameters = { + customerId, + ...(expand && {expand}) + } + const query = useCustomer( + {parameters}, + {enabled: !!customerId && isRegistered, ...queryOptions} + ) const value = { ...query, data: { diff --git a/packages/template-retail-react-app/app/hooks/use-item-shipment-management.js b/packages/template-retail-react-app/app/hooks/use-item-shipment-management.js index 0493a09927..7f94b745f1 100644 --- a/packages/template-retail-react-app/app/hooks/use-item-shipment-management.js +++ b/packages/template-retail-react-app/app/hooks/use-item-shipment-management.js @@ -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 {useShopperBasketsMutation} from '@salesforce/commerce-sdk-react' +import {useShopperBasketsV2Mutation as useShopperBasketsMutation} from '@salesforce/commerce-sdk-react' import {useCallback} from 'react' import {DEFAULT_SHIPMENT_ID} from '@salesforce/retail-react-app/app/constants' diff --git a/packages/template-retail-react-app/app/hooks/use-item-shipment-management.test.js b/packages/template-retail-react-app/app/hooks/use-item-shipment-management.test.js index 42d09b0725..5dfb72e40d 100644 --- a/packages/template-retail-react-app/app/hooks/use-item-shipment-management.test.js +++ b/packages/template-retail-react-app/app/hooks/use-item-shipment-management.test.js @@ -6,12 +6,12 @@ */ import {renderHook} from '@testing-library/react' -import {useShopperBasketsMutation} from '@salesforce/commerce-sdk-react' +import {useShopperBasketsV2Mutation as useShopperBasketsMutation} from '@salesforce/commerce-sdk-react' import {useItemShipmentManagement} from '@salesforce/retail-react-app/app/hooks/use-item-shipment-management' // Mock the commerce SDK hooks jest.mock('@salesforce/commerce-sdk-react', () => ({ - useShopperBasketsMutation: jest.fn() + useShopperBasketsV2Mutation: jest.fn() })) describe('useItemShipmentManagement', () => { diff --git a/packages/template-retail-react-app/app/hooks/use-multiship.js b/packages/template-retail-react-app/app/hooks/use-multiship.js index 50e5d380ce..fc9e04adbd 100644 --- a/packages/template-retail-react-app/app/hooks/use-multiship.js +++ b/packages/template-retail-react-app/app/hooks/use-multiship.js @@ -6,7 +6,7 @@ */ import {useCallback} from 'react' -import {useShippingMethodsForShipment} from '@salesforce/commerce-sdk-react' +import {useShippingMethodsForShipmentV2 as useShippingMethodsForShipment} from '@salesforce/commerce-sdk-react' import {usePickupShipment} from '@salesforce/retail-react-app/app/hooks/use-pickup-shipment' import {useShipmentOperations} from '@salesforce/retail-react-app/app/hooks/use-shipment-operations' import {useItemShipmentManagement} from '@salesforce/retail-react-app/app/hooks/use-item-shipment-management' diff --git a/packages/template-retail-react-app/app/hooks/use-multiship.test.js b/packages/template-retail-react-app/app/hooks/use-multiship.test.js index c3095431c3..b0862f16b6 100644 --- a/packages/template-retail-react-app/app/hooks/use-multiship.test.js +++ b/packages/template-retail-react-app/app/hooks/use-multiship.test.js @@ -8,7 +8,7 @@ import {renderHook, act} from '@testing-library/react' import {useMultiship} from '@salesforce/retail-react-app/app/hooks/use-multiship' import logger from '@salesforce/retail-react-app/app/utils/logger-instance' -import {useShippingMethodsForShipment} from '@salesforce/commerce-sdk-react' +import {useShippingMethodsForShipmentV2 as useShippingMethodsForShipment} from '@salesforce/commerce-sdk-react' import {usePickupShipment} from '@salesforce/retail-react-app/app/hooks/use-pickup-shipment' import {useShipmentOperations} from '@salesforce/retail-react-app/app/hooks/use-shipment-operations' import {useItemShipmentManagement} from '@salesforce/retail-react-app/app/hooks/use-item-shipment-management' @@ -19,7 +19,7 @@ import { // Mock dependencies jest.mock('@salesforce/commerce-sdk-react', () => ({ - useShippingMethodsForShipment: jest.fn() + useShippingMethodsForShipmentV2: jest.fn() })) jest.mock('@salesforce/retail-react-app/app/hooks/use-pickup-shipment', () => ({ diff --git a/packages/template-retail-react-app/app/hooks/use-pickup-shipment.js b/packages/template-retail-react-app/app/hooks/use-pickup-shipment.js index 573ba42450..28b31b53be 100644 --- a/packages/template-retail-react-app/app/hooks/use-pickup-shipment.js +++ b/packages/template-retail-react-app/app/hooks/use-pickup-shipment.js @@ -6,8 +6,8 @@ */ import { - useShopperBasketsMutation, - useShippingMethodsForShipment + useShopperBasketsV2Mutation as useShopperBasketsMutation, + useShippingMethodsForShipmentV2 as useShippingMethodsForShipment } from '@salesforce/commerce-sdk-react' import {DEFAULT_SHIPMENT_ID} from '@salesforce/retail-react-app/app/constants' import {getShippingAddressForStore} from '@salesforce/retail-react-app/app/utils/address-utils' diff --git a/packages/template-retail-react-app/app/hooks/use-pickup-shipment.test.js b/packages/template-retail-react-app/app/hooks/use-pickup-shipment.test.js index de48ef67d9..fba93a1939 100644 --- a/packages/template-retail-react-app/app/hooks/use-pickup-shipment.test.js +++ b/packages/template-retail-react-app/app/hooks/use-pickup-shipment.test.js @@ -11,11 +11,11 @@ import mockProductDetail from '@salesforce/retail-react-app/app/mocks/variant-75 // Mock the dependencies jest.mock('@salesforce/commerce-sdk-react', () => ({ - useShopperBasketsMutation: jest.fn(() => ({ + useShopperBasketsV2Mutation: jest.fn(() => ({ mutateAsync: jest.fn(), isLoading: false })), - useShippingMethodsForShipment: jest.fn(() => ({ + useShippingMethodsForShipmentV2: jest.fn(() => ({ refetch: jest.fn() })) })) @@ -481,7 +481,7 @@ describe('usePickupShipment', () => { // Get the mocked module and update the mock to include mutateAsync const commerceSdkMock = jest.requireMock('@salesforce/commerce-sdk-react') - commerceSdkMock.useShopperBasketsMutation.mockReturnValue({ + commerceSdkMock.useShopperBasketsV2Mutation.mockReturnValue({ mutateAsync: mockMutateAsync, isLoading: false }) @@ -601,11 +601,11 @@ describe('usePickupShipment', () => { // Get the mocked module and update the mock to include mutateAsync and refetch const commerceSdkMock = jest.requireMock('@salesforce/commerce-sdk-react') - commerceSdkMock.useShopperBasketsMutation.mockReturnValue({ + commerceSdkMock.useShopperBasketsV2Mutation.mockReturnValue({ mutateAsync: mockMutateAsync, isLoading: false }) - commerceSdkMock.useShippingMethodsForShipment.mockReturnValue({ + commerceSdkMock.useShippingMethodsForShipmentV2.mockReturnValue({ refetch: mockRefetchShippingMethods }) }) @@ -963,7 +963,7 @@ describe('usePickupShipment', () => { // Get the mocked module and update the mock to include mutateAsync const commerceSdkMock = jest.requireMock('@salesforce/commerce-sdk-react') - commerceSdkMock.useShopperBasketsMutation.mockReturnValue({ + commerceSdkMock.useShopperBasketsV2Mutation.mockReturnValue({ mutateAsync: mockMutateAsync, isLoading: false }) diff --git a/packages/template-retail-react-app/app/hooks/use-sf-payments-country.js b/packages/template-retail-react-app/app/hooks/use-sf-payments-country.js new file mode 100644 index 0000000000..a9eb773ec7 --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-sf-payments-country.js @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import {useQuery} from '@tanstack/react-query' +import {useAppOrigin} from '@salesforce/retail-react-app/app/hooks/use-app-origin' +import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site' + +/** + * Hook to detect payment country code + * + * @returns {Object} {countryCode: string, isLoading: boolean} + * + * @example + * const {countryCode, isLoading} = useSFPaymentsCountry() + */ +export const useSFPaymentsCountry = () => { + const {locale} = useMultiSite() + const appOrigin = useAppOrigin() + + const {data: serverCountry, isLoading: serverLoading} = useQuery({ + queryKey: ['server-country'], + queryFn: async () => { + try { + const response = await fetch(`${appOrigin}/api/detect-country`) + if (!response.ok) { + return null + } + const data = await response.json() + return data.countryCode || null + } catch (error) { + console.warn( + 'Server country detection failed (expected in development):', + error.message + ) + return null + } + }, + staleTime: 30 * 60 * 1000, // 30 minutes + retry: false, + meta: { + errorPolicy: 'silent' + } + }) + + // Derive country from locale as fallback (e.g., "de-DE" -> "DE") + const localeCountry = locale?.id?.split('-')?.[1] || null + + return { + countryCode: serverCountry || localeCountry, + isLoading: serverLoading + } +} diff --git a/packages/template-retail-react-app/app/hooks/use-sf-payments-country.test.js b/packages/template-retail-react-app/app/hooks/use-sf-payments-country.test.js new file mode 100644 index 0000000000..19c7feadcb --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-sf-payments-country.test.js @@ -0,0 +1,541 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import React from 'react' +import {render, screen, waitFor} from '@testing-library/react' +import {QueryClient, QueryClientProvider} from '@tanstack/react-query' +import {useSFPaymentsCountry} from '@salesforce/retail-react-app/app/hooks/use-sf-payments-country' + +// Mock dependencies +const mockUseAppOrigin = jest.fn() +const mockFetch = jest.fn() + +jest.mock('@salesforce/retail-react-app/app/hooks/use-app-origin', () => ({ + useAppOrigin: () => mockUseAppOrigin() +})) +const mockUseMultiSite = jest.fn() +jest.mock('@salesforce/retail-react-app/app/hooks/use-multi-site', () => ({ + __esModule: true, + default: () => mockUseMultiSite() +})) + +// Test component that uses the hook +const TestComponent = ({onHookData}) => { + const hookData = useSFPaymentsCountry() + + React.useEffect(() => { + if (onHookData) { + onHookData(hookData) + } + }, [hookData, onHookData]) + + return ( +
+
{hookData.countryCode || 'null'}
+
{hookData.isLoading ? 'loading' : 'loaded'}
+
+ ) +} + +TestComponent.propTypes = { + onHookData: () => null +} + +// Helper to render with QueryClient +const renderWithQueryClient = (ui, options = {}) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + cacheTime: 0 + } + }, + ...options + }) + + return render({ui}) +} + +describe('useSFPaymentsCountry', () => { + beforeEach(() => { + jest.clearAllMocks() + global.fetch = mockFetch + mockUseAppOrigin.mockReturnValue('https://test-origin.com') + mockUseMultiSite.mockReturnValue({locale: {}}) + // Suppress console.warn for tests + jest.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + describe('successful country detection', () => { + test('returns country code on successful fetch', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({countryCode: 'US'}) + }) + + renderWithQueryClient() + + // Initially loading + expect(screen.getByTestId('is-loading').textContent).toBe('loading') + + // Wait for data to load + await waitFor(() => { + expect(screen.getByTestId('country-code').textContent).toBe('US') + }) + + expect(screen.getByTestId('is-loading').textContent).toBe('loaded') + expect(mockFetch).toHaveBeenCalledWith('https://test-origin.com/api/detect-country') + }) + + test('handles different country codes', async () => { + const countryCodes = ['GB', 'CA', 'AU', 'DE', 'FR', 'JP'] + + for (const code of countryCodes) { + jest.clearAllMocks() + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({countryCode: code}) + }) + + const {unmount} = renderWithQueryClient() + + await waitFor(() => { + expect(screen.getByTestId('country-code').textContent).toBe(code) + }) + + unmount() + } + }) + + test('uses correct query key', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({countryCode: 'US'}) + }) + + let hookData + const onHookData = jest.fn((data) => { + hookData = data + }) + + renderWithQueryClient() + + await waitFor(() => { + expect(hookData.countryCode).toBe('US') + }) + }) + }) + + describe('failed country detection', () => { + test('returns null when API response is not ok', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500 + }) + + renderWithQueryClient() + + await waitFor(() => { + expect(screen.getByTestId('country-code').textContent).toBe('null') + expect(screen.getByTestId('is-loading').textContent).toBe('loaded') + }) + }) + + test('returns null when API response is 404', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404 + }) + + renderWithQueryClient() + + await waitFor(() => { + expect(screen.getByTestId('country-code').textContent).toBe('null') + }) + }) + + test('returns null when fetch throws network error', async () => { + mockFetch.mockRejectedValue(new Error('Network error')) + + renderWithQueryClient() + + await waitFor(() => { + expect(screen.getByTestId('country-code').textContent).toBe('null') + }) + + expect(console.warn).toHaveBeenCalledWith( + 'Server country detection failed (expected in development):', + 'Network error' + ) + }) + + test('returns null when countryCode is missing from response', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({}) + }) + + renderWithQueryClient() + + await waitFor(() => { + expect(screen.getByTestId('country-code').textContent).toBe('null') + }) + }) + + test('returns null when countryCode is empty string', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({countryCode: ''}) + }) + + renderWithQueryClient() + + await waitFor(() => { + expect(screen.getByTestId('country-code').textContent).toBe('null') + }) + }) + + test('logs warning on error without exposing sensitive info', async () => { + mockFetch.mockRejectedValue(new Error('Detailed error message')) + + renderWithQueryClient() + + await waitFor(() => { + expect(screen.getByTestId('country-code').textContent).toBe('null') + }) + + expect(console.warn).toHaveBeenCalledWith( + 'Server country detection failed (expected in development):', + 'Detailed error message' + ) + }) + }) + + describe('loading states', () => { + test('shows loading state initially', () => { + mockFetch.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve({ok: true}), 1000)) + ) + + renderWithQueryClient() + + expect(screen.getByTestId('is-loading').textContent).toBe('loading') + expect(screen.getByTestId('country-code').textContent).toBe('null') + }) + + test('transitions from loading to loaded state', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({countryCode: 'US'}) + }) + + renderWithQueryClient() + + expect(screen.getByTestId('is-loading').textContent).toBe('loading') + + await waitFor(() => { + expect(screen.getByTestId('is-loading').textContent).toBe('loaded') + }) + }) + }) + + describe('hook return values', () => { + test('returns object with countryCode and isLoading properties', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({countryCode: 'US'}) + }) + + let hookData + const onHookData = jest.fn((data) => { + hookData = data + }) + + renderWithQueryClient() + + await waitFor(() => { + expect(onHookData).toHaveBeenCalled() + }) + + expect(hookData).toHaveProperty('countryCode') + expect(hookData).toHaveProperty('isLoading') + expect(typeof hookData.isLoading).toBe('boolean') + }) + + test('countryCode is null when no data', async () => { + mockFetch.mockResolvedValue({ + ok: false + }) + + let hookData + const onHookData = jest.fn((data) => { + hookData = data + }) + + renderWithQueryClient() + + await waitFor(() => { + expect(hookData.countryCode).toBeNull() + }) + }) + }) + + describe('API integration', () => { + test('uses appOrigin for API endpoint', async () => { + mockUseAppOrigin.mockReturnValue('https://custom-origin.com') + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({countryCode: 'US'}) + }) + + renderWithQueryClient() + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + 'https://custom-origin.com/api/detect-country' + ) + }) + }) + + test('handles different appOrigin values', async () => { + const origins = [ + 'https://prod.example.com', + 'https://staging.example.com', + 'http://localhost:3000' + ] + + for (const origin of origins) { + jest.clearAllMocks() + mockUseAppOrigin.mockReturnValue(origin) + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({countryCode: 'US'}) + }) + + renderWithQueryClient() + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith(`${origin}/api/detect-country`) + }) + } + }) + }) + + describe('query configuration', () => { + test('does not retry on failure', async () => { + mockFetch.mockRejectedValue(new Error('Network error')) + + renderWithQueryClient() + + await waitFor(() => { + expect(screen.getByTestId('country-code').textContent).toBe('null') + }) + + // Should only be called once (no retries) + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + test('uses staleTime for caching', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({countryCode: 'US'}) + }) + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + cacheTime: 0 + } + } + }) + + const {rerender} = render( + + + + ) + + await waitFor(() => { + expect(screen.getByTestId('country-code').textContent).toBe('US') + }) + + // First call + expect(mockFetch).toHaveBeenCalledTimes(1) + + // Rerender should use cached data within staleTime + rerender( + + + + ) + + // Should still be only 1 call (cached) + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + }) + + describe('edge cases', () => { + test('handles JSON parse errors gracefully', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => { + throw new Error('Invalid JSON') + } + }) + + renderWithQueryClient() + + await waitFor(() => { + expect(screen.getByTestId('country-code').textContent).toBe('null') + }) + + expect(console.warn).toHaveBeenCalled() + }) + + test('handles null response', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => null + }) + + renderWithQueryClient() + + await waitFor(() => { + expect(screen.getByTestId('country-code').textContent).toBe('null') + }) + }) + + test('handles undefined countryCode in response', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({countryCode: undefined}) + }) + + renderWithQueryClient() + + await waitFor(() => { + expect(screen.getByTestId('country-code').textContent).toBe('null') + }) + }) + + test('handles numeric status codes', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 503 + }) + + renderWithQueryClient() + + await waitFor(() => { + expect(screen.getByTestId('country-code').textContent).toBe('null') + }) + }) + }) + + describe('multiple instances', () => { + test('shares data across multiple hook instances', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({countryCode: 'US'}) + }) + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + cacheTime: Infinity + } + } + }) + + const {rerender} = render( + + + + ) + + await waitFor(() => { + expect(screen.getByTestId('country-code').textContent).toBe('US') + }) + + expect(mockFetch).toHaveBeenCalledTimes(1) + + // Mount another instance + rerender( + + + + + ) + + // Should still only have been called once (shared cache) + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + }) + describe('locale country fallback', () => { + test('falls back to locale country when server detection fails', async () => { + mockUseMultiSite.mockReturnValue({locale: {id: 'de-DE'}}) + mockFetch.mockResolvedValue({ok: false, status: 500}) + + renderWithQueryClient() + + await waitFor(() => { + expect(screen.getByTestId('country-code').textContent).toBe('DE') + }) + }) + + test('server country takes priority over locale country', async () => { + mockUseMultiSite.mockReturnValue({locale: {id: 'de-DE'}}) + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({countryCode: 'GB'}) + }) + + renderWithQueryClient() + + await waitFor(() => { + expect(screen.getByTestId('country-code').textContent).toBe('GB') + }) + }) + + test('returns null when both server and locale are unavailable', async () => { + mockUseMultiSite.mockReturnValue({locale: {}}) + mockFetch.mockResolvedValue({ok: false, status: 500}) + + renderWithQueryClient() + + await waitFor(() => { + expect(screen.getByTestId('country-code').textContent).toBe('null') + }) + }) + + test('derives country from various locale formats', async () => { + mockFetch.mockResolvedValue({ok: false, status: 500}) + + const cases = [ + {locale: 'fr-FR', expected: 'FR'}, + {locale: 'ja-JP', expected: 'JP'}, + {locale: 'zh-CN', expected: 'CN'} + ] + + for (const {locale, expected} of cases) { + mockUseMultiSite.mockReturnValue({locale: {id: locale}}) + const {unmount} = renderWithQueryClient() + + await waitFor(() => { + expect(screen.getByTestId('country-code').textContent).toBe(expected) + }) + unmount() + } + }) + }) +}) diff --git a/packages/template-retail-react-app/app/hooks/use-sf-payments.js b/packages/template-retail-react-app/app/hooks/use-sf-payments.js new file mode 100644 index 0000000000..6ec5ceac37 --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-sf-payments.js @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import {useEffect, useSyncExternalStore} from 'react' +import {useQuery} from '@tanstack/react-query' +import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' +import useScript from '@salesforce/retail-react-app/app/hooks/use-script' +import {useAppOrigin} from '@salesforce/retail-react-app/app/hooks/use-app-origin' +import {useShopperConfiguration} from '@salesforce/retail-react-app/app/hooks/use-shopper-configuration' + +export const EXPRESS_BUY_NOW = 0 +export const EXPRESS_PAY_NOW = 1 + +export const STATUS_SUCCESS = 0 + +export const store = { + sfp: null, + confirmingBasket: null +} +const subscribers = new Set() + +export const useSFPayments = () => { + const appOrigin = useAppOrigin() + + // Add script tag to page if not already present + const config = getConfig() + const sdkUrl = config?.app?.sfPayments?.sdkUrl + const status = useScript(sdkUrl) + + useEffect(() => { + if ( + typeof window !== 'undefined' && + status.loaded && + !store.sfp && + typeof window.SFPayments === 'function' + ) { + // Create SFPayments object when script loaded + store.sfp = new window.SFPayments() + } + }, [status.loaded]) + + const metadataUrl = config?.app?.sfPayments?.metadataUrl + const localEnabled = config?.app?.sfPayments?.enabled ?? true + + const {data: serverMetadata, isLoading: serverMetadataLoading} = useQuery({ + queryKey: ['payment-metadata'], + queryFn: async () => { + const response = await fetch(`${appOrigin}/api/payment-metadata`) + if (!response.ok) { + throw new Error('Failed to load payment metadata') + } + return await response.json() + }, + // Only fetch metadata if metadataUrl is set and sfPayments is enabled, + // prevents any 500 on server side and unnecessary network requests + enabled: localEnabled && !!metadataUrl, + staleTime: 10 * 60 * 1000 // 10 minutes + }) + + const subscribe = (callback) => { + subscribers.add(callback) + return () => subscribers.delete(callback) + } + const notify = () => subscribers.forEach((callback) => callback()) + const globals = useSyncExternalStore( + subscribe, + () => store, + () => ({}) + ) + // Separate subscription for confirmingBasket to ensure React detects changes + // The issue is that useSyncExternalStore compares snapshot values with Object.is(). + // When the snapshot returns the entire store object, the reference stays the same, + // so React doesn't detect the change to store.confirmingBasket. + const confirmingBasket = useSyncExternalStore( + subscribe, + () => store.confirmingBasket, // Return just the value so React detects the change + () => null // Server snapshot + ) + + const startConfirming = (basket) => { + store.confirmingBasket = basket + notify() + } + const endConfirming = () => { + store.confirmingBasket = null + notify() + } + + return { + sfp: globals.sfp, + metadata: serverMetadata, + isMetadataLoading: serverMetadataLoading, + confirmingBasket: confirmingBasket, + startConfirming, + endConfirming + } +} + +/** + * Custom hook to check if Salesforce Payments is enabled + * ?? true means: if the config is missing, default to "don't block it" + * and let the API decide. The local config only matters when someone explicitly sets it to false. + * @returns {boolean} True if Salesforce Payments is enabled, false otherwise + */ +export const useSFPaymentsEnabled = () => { + const config = getConfig() + const localEnabled = config?.app?.sfPayments?.enabled ?? true + const apiEnabled = useShopperConfiguration('SalesforcePaymentsAllowed') === true + return localEnabled && apiEnabled +} + +/** + * Custom hook to get the card capture mode for Salesforce Payments + * @returns {boolean} True if automatic capture is enabled (default), false if manual capture + */ +export const useAutomaticCapture = () => { + const cardCaptureAutomatic = useShopperConfiguration('cardCaptureAutomatic') + return cardCaptureAutomatic ?? true +} + +/** + * Custom hook to determine if payments should be set up for off-session reuse. + * If true, always set up payments for off-session reuse. + * If false, only set up payments when user explicitly saves payment method (on-session reuse). + * @returns {boolean} True if off-session future usage is enabled, false otherwise (default) + */ +export const useFutureUsageOffSession = () => { + const futureUsageOffSession = useShopperConfiguration('futureUsageOffSession') + return futureUsageOffSession ?? false +} diff --git a/packages/template-retail-react-app/app/hooks/use-sf-payments.test.js b/packages/template-retail-react-app/app/hooks/use-sf-payments.test.js new file mode 100644 index 0000000000..bc45e3c92c --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-sf-payments.test.js @@ -0,0 +1,718 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import React from 'react' +import PropTypes from 'prop-types' +import {render, screen, waitFor, act, renderHook} from '@testing-library/react' +import { + useSFPayments, + useSFPaymentsEnabled, + useAutomaticCapture, + useFutureUsageOffSession, + EXPRESS_BUY_NOW, + EXPRESS_PAY_NOW, + STATUS_SUCCESS, + store as sfPaymentsStore +} from '@salesforce/retail-react-app/app/hooks/use-sf-payments' +import {QueryClient, QueryClientProvider} from '@tanstack/react-query' +import {rest} from 'msw' +// Mock dependencies +const mockUseScript = jest.fn() +const mockGetConfig = jest.fn() +const mockUseAppOrigin = jest.fn() +const mockFetch = jest.fn() +const mockUseShopperConfiguration = jest.fn() + +jest.mock('@salesforce/retail-react-app/app/hooks/use-script', () => ({ + __esModule: true, + default: () => mockUseScript() +})) + +jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => ({ + getConfig: () => mockGetConfig() +})) + +jest.mock('@salesforce/retail-react-app/app/hooks/use-app-origin', () => ({ + useAppOrigin: () => mockUseAppOrigin() +})) + +jest.mock('@salesforce/retail-react-app/app/hooks/use-shopper-configuration', () => ({ + useShopperConfiguration: (configId) => mockUseShopperConfiguration(configId) +})) + +// Mock SFPayments class +class MockSFPayments { + constructor() { + this.initialized = true + } + + checkout() { + return {confirm: jest.fn()} + } +} + +// Test component that uses the hook +const TestComponent = ({onHookData}) => { + const hookData = useSFPayments() + + React.useEffect(() => { + if (onHookData) { + onHookData(hookData) + } + }, [hookData, onHookData]) + + return ( +
+
{hookData.sfp ? 'loaded' : 'not-loaded'}
+
+ {hookData.isMetadataLoading ? 'loading' : 'not-loading'} +
+
{JSON.stringify(hookData.metadata)}
+
+ {hookData.confirmingBasket ? 'confirming' : 'not-confirming'} +
+
+ ) +} + +TestComponent.propTypes = { + onHookData: PropTypes.func +} +// Helper to render with providers +const renderWithQueryClient = (ui) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + cacheTime: 0 + } + } + }) + + return render({ui}) +} + +describe('useSFPayments hook', () => { + beforeEach(() => { + jest.clearAllMocks() + + // Reset global state - don't try to redefine window, just delete the property + if (global.window && global.window.SFPayments) { + delete global.window.SFPayments + } + + // Reset the store state + sfPaymentsStore.sfp = null + sfPaymentsStore.confirmingBasket = null + + // Reset fetch mock + global.fetch = mockFetch + global.server.resetHandlers() + + global.server.use( + rest.get('/api/payment-metadata', (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(200), + ctx.json({apiKey: 'test-key', publishableKey: 'pk_test'}) + ) + }) + ) + // Default mock implementations + mockUseScript.mockReturnValue({loaded: false, error: false}) + mockGetConfig.mockReturnValue({ + app: { + sfPayments: { + sdkUrl: 'https://test.sfpayments.com/sdk.js', + metadataUrl: 'https://test.sfpayments.com/metadata' + } + } + }) + mockUseAppOrigin.mockReturnValue('https://test-origin.com') + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({apiKey: 'test-key', publishableKey: 'pk_test'}) + }) + }) + + afterEach(() => { + jest.clearAllMocks() + if (global.window && global.window.SFPayments) { + delete global.window.SFPayments + } + // Clean up any rendered components + document.body.innerHTML = '' + sfPaymentsStore.sfp = null + sfPaymentsStore.confirmingBasket = null + }) + + describe('constants', () => { + test('exports correct constant values', () => { + expect(EXPRESS_BUY_NOW).toBe(0) + expect(EXPRESS_PAY_NOW).toBe(1) + expect(STATUS_SUCCESS).toBe(0) + }) + }) + + describe('initialization', () => { + test('returns initial state when script not loaded', () => { + renderWithQueryClient() + + expect(screen.getByTestId('sfp-loaded').textContent).toBe('not-loaded') + expect(screen.getByTestId('metadata-loading').textContent).toBe('loading') + }) + + test('creates SFPayments instance when script loads', async () => { + global.window.SFPayments = MockSFPayments + mockUseScript.mockReturnValue({loaded: true, error: false}) + + renderWithQueryClient() + + await waitFor(() => { + expect(screen.getByTestId('sfp-loaded').textContent).toBe('loaded') + }) + }) + + test('does not create SFPayments instance if window.SFPayments is not defined', async () => { + mockUseScript.mockReturnValue({loaded: true, error: false}) + + renderWithQueryClient() + + await waitFor(() => { + expect(screen.getByTestId('sfp-loaded').textContent).toBe('not-loaded') + }) + }) + + test('only creates SFPayments instance once', async () => { + const SFPaymentsSpy = jest.fn().mockImplementation(function () { + this.initialized = true + }) + global.window.SFPayments = SFPaymentsSpy + mockUseScript.mockReturnValue({loaded: true, error: false}) + + // First render + const {unmount: unmount1} = renderWithQueryClient() + + await waitFor(() => { + expect(screen.getAllByTestId('sfp-loaded')[0].textContent).toBe('loaded') + }) + + unmount1() + + // Second render - should reuse existing instance + renderWithQueryClient() + + await waitFor(() => { + expect(screen.getByTestId('sfp-loaded').textContent).toBe('loaded') + }) + + // Should only be called once across both renders + expect(SFPaymentsSpy).toHaveBeenCalledTimes(1) + }) + }) + + describe('metadata loading', () => { + test('fetches payment metadata from API', async () => { + const mockMetadata = {apiKey: 'test-key', publishableKey: 'pk_test'} + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockMetadata + }) + + renderWithQueryClient() + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + 'https://test-origin.com/api/payment-metadata' + ) + }) + + await waitFor(() => { + expect(screen.getByTestId('metadata').textContent).toBe( + JSON.stringify(mockMetadata) + ) + }) + }) + + test('handles metadata fetch error', async () => { + // Suppress expected error message + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + + mockFetch.mockResolvedValue({ + ok: false + }) + + global.server.use( + rest.get('*/api/payment-metadata', (req, res, ctx) => { + return res(ctx.delay(0), ctx.status(500)) + }) + ) + renderWithQueryClient() + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalled() + }) + + // Metadata should remain undefined on error + await waitFor(() => { + expect(screen.getByTestId('metadata').textContent).toBe('') + }) + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalled() + // Check that it was called with an Error containing the message + const errorCall = consoleErrorSpy.mock.calls.find( + (call) => + call[0]?.message === 'Failed to load payment metadata' || + call[0]?.toString().includes('Failed to load payment metadata') + ) + expect(errorCall).toBeDefined() + }) + + // Restore console.error + consoleErrorSpy.mockRestore() + }) + + test('uses correct app origin for metadata request', async () => { + mockUseAppOrigin.mockReturnValue('https://custom-origin.com') + + global.server.use( + rest.get('*/api/payment-metadata', (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(200), + ctx.json({apiKey: 'test-key', publishableKey: 'pk_test'}) + ) + }) + ) + renderWithQueryClient() + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + 'https://custom-origin.com/api/payment-metadata' + ) + }) + }) + }) + + describe('metadata query guard', () => { + test('does not fetch metadata when metadataUrl is empty', async () => { + mockGetConfig.mockReturnValue({ + app: { + sfPayments: { + sdkUrl: 'https://test.sfpayments.com/sdk.js', + metadataUrl: '' + } + } + }) + + renderWithQueryClient() + + await waitFor(() => { + expect(mockFetch).not.toHaveBeenCalled() + }) + }) + + test('does not fetch metadata when sfPayments is disabled', async () => { + mockGetConfig.mockReturnValue({ + app: { + sfPayments: { + enabled: false, + sdkUrl: 'https://test.sfpayments.com/sdk.js', + metadataUrl: 'https://test.sfpayments.com/metadata' + } + } + }) + + renderWithQueryClient() + + await waitFor(() => { + expect(mockFetch).not.toHaveBeenCalled() + }) + }) + }) + + describe('confirming basket state', () => { + test('startConfirming updates confirmingBasket', async () => { + let hookData + const onHookData = jest.fn((data) => { + hookData = data + }) + + renderWithQueryClient() + + await waitFor(() => { + expect(onHookData).toHaveBeenCalled() + }) + + expect(screen.getByTestId('confirming-basket').textContent).toBe('not-confirming') + + // Start confirming + const mockBasket = {basketId: 'test-basket-123', orderTotal: 100} + act(() => { + hookData.startConfirming(mockBasket) + }) + + await waitFor(() => { + expect(screen.getByTestId('confirming-basket').textContent).toBe('confirming') + }) + }) + + test('endConfirming clears confirmingBasket', async () => { + let latestHookData + const onHookData = jest.fn((data) => { + latestHookData = data + }) + + renderWithQueryClient() + + await waitFor(() => { + expect(onHookData).toHaveBeenCalled() + }) + + // Start confirming + const mockBasket = {basketId: 'test-basket-123'} + act(() => { + latestHookData.startConfirming(mockBasket) + }) + + // Wait for component to re-render with new state + await waitFor(() => { + expect(sfPaymentsStore.confirmingBasket).toEqual(mockBasket) + }) + + // End confirming + act(() => { + latestHookData.endConfirming() + }) + + // Wait for store to update + await waitFor( + () => { + expect(sfPaymentsStore.confirmingBasket).toBeNull() + }, + {timeout: 2000} + ) + }) + + test('confirmingBasket state persists across rerenders', async () => { + let hookData + const onHookData = jest.fn((data) => { + hookData = data + }) + + const queryClient = new QueryClient({ + defaultOptions: { + queries: {retry: false} + } + }) + + const {rerender} = render( + + + + ) + + await waitFor(() => { + expect(onHookData).toHaveBeenCalled() + }) + + // Start confirming + const mockBasket = {basketId: 'test-basket-123'} + act(() => { + hookData.startConfirming(mockBasket) + }) + + await waitFor(() => { + expect(hookData.confirmingBasket).toEqual(mockBasket) + }) + + // Rerender with the same QueryClientProvider + rerender( + + + + ) + + // State should persist + await waitFor(() => { + expect(hookData.confirmingBasket).toEqual(mockBasket) + }) + }) + }) + + describe('state synchronization', () => { + test('multiple components share the same store state', async () => { + global.window.SFPayments = MockSFPayments + mockUseScript.mockReturnValue({loaded: true, error: false}) + + let hookData1 + let hookData2 + + const TestComponent1 = () => { + hookData1 = useSFPayments() + return
Component 1
+ } + + const TestComponent2 = () => { + hookData2 = useSFPayments() + return
Component 2
+ } + + const queryClient = new QueryClient({ + defaultOptions: { + queries: {retry: false} + } + }) + + render( + + + + + ) + + await waitFor(() => { + expect(hookData1?.sfp).toBeDefined() + expect(hookData2?.sfp).toBeDefined() + }) + + // Both should reference the same SFP instance + expect(hookData1.sfp).toBe(hookData2.sfp) + + // Start confirming in component 1 + const mockBasket = {basketId: 'shared-basket'} + act(() => { + hookData1.startConfirming(mockBasket) + }) + + await waitFor(() => { + // Both components should see the same confirming basket + expect(hookData1.confirmingBasket).toEqual(mockBasket) + expect(hookData2.confirmingBasket).toEqual(mockBasket) + }) + }) + }) + + describe('return value', () => { + test('returns all expected properties', async () => { + let hookData + const onHookData = jest.fn((data) => { + hookData = data + }) + + renderWithQueryClient() + + await waitFor(() => { + expect(onHookData).toHaveBeenCalled() + }) + + expect(hookData).toHaveProperty('sfp') + expect(hookData).toHaveProperty('metadata') + expect(hookData).toHaveProperty('isMetadataLoading') + expect(hookData).toHaveProperty('confirmingBasket') + expect(hookData).toHaveProperty('startConfirming') + expect(hookData).toHaveProperty('endConfirming') + expect(typeof hookData.startConfirming).toBe('function') + expect(typeof hookData.endConfirming).toBe('function') + }) + + test('isMetadataLoading updates correctly', async () => { + let resolveMetadata + mockFetch.mockReturnValue( + new Promise((resolve) => { + resolveMetadata = resolve + }) + ) + + renderWithQueryClient() + + // Should be loading initially + expect(screen.getByTestId('metadata-loading').textContent).toBe('loading') + + // Resolve the metadata + await act(async () => { + resolveMetadata({ + ok: true, + json: async () => ({apiKey: 'test'}) + }) + }) + + await waitFor(() => { + expect(screen.getByTestId('metadata-loading').textContent).toBe('not-loading') + }) + }) + }) + + describe('edge cases', () => { + test('handles script loading without window.SFPayments available', async () => { + // Ensure window.SFPayments is not available + // Reset global state - don't try to redefine window, just delete the property + if (global.window && global.window.SFPayments) { + delete global.window.SFPayments + } + sfPaymentsStore.sfp = null + mockUseScript.mockReturnValue({loaded: true, error: false}) + + renderWithQueryClient() + + // Even if script is loaded, SFP won't be initialized without window.SFPayments + await waitFor(() => { + expect(screen.getByTestId('sfp-loaded').textContent).toBe('not-loaded') + }) + }) + + test('handles multiple calls to startConfirming', async () => { + let latestHookData + const onHookData = jest.fn((data) => { + latestHookData = data + }) + + renderWithQueryClient() + + await waitFor(() => { + expect(onHookData).toHaveBeenCalled() + }) + + // Start confirming with first basket + const mockBasket1 = {basketId: 'basket-1'} + act(() => { + latestHookData.startConfirming(mockBasket1) + }) + + // Wait for store to update + await waitFor( + () => { + expect(sfPaymentsStore.confirmingBasket).toEqual(mockBasket1) + }, + {timeout: 2000} + ) + + // Start confirming with second basket (replaces first) + const mockBasket2 = {basketId: 'basket-2'} + act(() => { + latestHookData.startConfirming(mockBasket2) + }) + + // Wait for store to update + await waitFor( + () => { + expect(sfPaymentsStore.confirmingBasket).toEqual(mockBasket2) + }, + {timeout: 2000} + ) + }) + }) +}) + +describe('useSFPaymentsEnabled hook', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('returns true when SalesforcePaymentsAllowed is true', () => { + mockUseShopperConfiguration.mockReturnValue(true) + + const {result} = renderHook(() => useSFPaymentsEnabled()) + + expect(result.current).toBe(true) + expect(mockUseShopperConfiguration).toHaveBeenCalledWith('SalesforcePaymentsAllowed') + }) + + test('returns false when SalesforcePaymentsAllowed is false', () => { + mockUseShopperConfiguration.mockReturnValue(false) + + const {result} = renderHook(() => useSFPaymentsEnabled()) + + expect(result.current).toBe(false) + expect(mockUseShopperConfiguration).toHaveBeenCalledWith('SalesforcePaymentsAllowed') + }) + + test('returns false when SalesforcePaymentsAllowed is undefined', () => { + mockUseShopperConfiguration.mockReturnValue(undefined) + + const {result} = renderHook(() => useSFPaymentsEnabled()) + + expect(result.current).toBe(false) + expect(mockUseShopperConfiguration).toHaveBeenCalledWith('SalesforcePaymentsAllowed') + }) +}) + +describe('useAutomaticCapture hook', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('returns true when cardCaptureAutomatic is true', () => { + mockUseShopperConfiguration.mockReturnValue(true) + + const {result} = renderHook(() => useAutomaticCapture()) + + expect(result.current).toBe(true) + expect(mockUseShopperConfiguration).toHaveBeenCalledWith('cardCaptureAutomatic') + }) + + test('returns false when cardCaptureAutomatic is false', () => { + mockUseShopperConfiguration.mockReturnValue(false) + + const {result} = renderHook(() => useAutomaticCapture()) + + expect(result.current).toBe(false) + expect(mockUseShopperConfiguration).toHaveBeenCalledWith('cardCaptureAutomatic') + }) + + test('returns true (default) when cardCaptureAutomatic is undefined', () => { + mockUseShopperConfiguration.mockReturnValue(undefined) + + const {result} = renderHook(() => useAutomaticCapture()) + + expect(result.current).toBe(true) + expect(mockUseShopperConfiguration).toHaveBeenCalledWith('cardCaptureAutomatic') + }) +}) + +describe('useFutureUsageOffSession hook', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('returns true when futureUsageOffSession is true', () => { + mockUseShopperConfiguration.mockReturnValue(true) + + const {result} = renderHook(() => useFutureUsageOffSession()) + + expect(result.current).toBe(true) + expect(mockUseShopperConfiguration).toHaveBeenCalledWith('futureUsageOffSession') + }) + + test('returns false when futureUsageOffSession is false', () => { + mockUseShopperConfiguration.mockReturnValue(false) + + const {result} = renderHook(() => useFutureUsageOffSession()) + + expect(result.current).toBe(false) + expect(mockUseShopperConfiguration).toHaveBeenCalledWith('futureUsageOffSession') + }) + + test('returns false (default) when futureUsageOffSession is undefined', () => { + mockUseShopperConfiguration.mockReturnValue(undefined) + + const {result} = renderHook(() => useFutureUsageOffSession()) + + expect(result.current).toBe(false) + expect(mockUseShopperConfiguration).toHaveBeenCalledWith('futureUsageOffSession') + }) + + test('returns false (default) when futureUsageOffSession is null', () => { + mockUseShopperConfiguration.mockReturnValue(null) + + const {result} = renderHook(() => useFutureUsageOffSession()) + + expect(result.current).toBe(false) + expect(mockUseShopperConfiguration).toHaveBeenCalledWith('futureUsageOffSession') + }) +}) diff --git a/packages/template-retail-react-app/app/hooks/use-shipment-operations.js b/packages/template-retail-react-app/app/hooks/use-shipment-operations.js index 158bc73c90..e04b32c7ae 100644 --- a/packages/template-retail-react-app/app/hooks/use-shipment-operations.js +++ b/packages/template-retail-react-app/app/hooks/use-shipment-operations.js @@ -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 {useShopperBasketsMutation} from '@salesforce/commerce-sdk-react' +import {useShopperBasketsV2Mutation as useShopperBasketsMutation} from '@salesforce/commerce-sdk-react' import {useCallback} from 'react' import {cleanAddressForOrder} from '@salesforce/retail-react-app/app/utils/address-utils' import {nanoid} from 'nanoid' diff --git a/packages/template-retail-react-app/app/hooks/use-shipment-operations.test.js b/packages/template-retail-react-app/app/hooks/use-shipment-operations.test.js index 071c65e23a..efdc90eb93 100644 --- a/packages/template-retail-react-app/app/hooks/use-shipment-operations.test.js +++ b/packages/template-retail-react-app/app/hooks/use-shipment-operations.test.js @@ -6,12 +6,12 @@ */ import {renderHook} from '@testing-library/react' -import {useShopperBasketsMutation} from '@salesforce/commerce-sdk-react' +import {useShopperBasketsV2Mutation as useShopperBasketsMutation} from '@salesforce/commerce-sdk-react' import {useShipmentOperations} from '@salesforce/retail-react-app/app/hooks/use-shipment-operations' // Mock the commerce SDK hooks jest.mock('@salesforce/commerce-sdk-react', () => ({ - useShopperBasketsMutation: jest.fn() + useShopperBasketsV2Mutation: jest.fn() })) jest.mock('nanoid', () => ({ diff --git a/packages/template-retail-react-app/app/hooks/use-shopper-configuration.js b/packages/template-retail-react-app/app/hooks/use-shopper-configuration.js new file mode 100644 index 0000000000..cecc1ab859 --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-shopper-configuration.js @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import {useConfigurations} from '@salesforce/commerce-sdk-react' + +/** + * Hook to get a shopper configuration value. + * @param {string} configurationId - The ID of the configuration to retrieve + * @returns {*} The configuration value, or undefined if not found + */ +export const useShopperConfiguration = (configurationId) => { + // Stale time is set to 10 minutes to avoid unnecessary API calls + const {data: configurations} = useConfigurations( + {}, + { + staleTime: 10 * 60 * 1000 // 10 minutes + } + ) + const config = configurations?.configurations?.find( + (configuration) => configuration.id === configurationId + ) + return config?.value +} diff --git a/packages/template-retail-react-app/app/hooks/use-shopper-configuration.test.js b/packages/template-retail-react-app/app/hooks/use-shopper-configuration.test.js new file mode 100644 index 0000000000..10b0c062e5 --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-shopper-configuration.test.js @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import {renderHook} from '@testing-library/react' +import {useShopperConfiguration} from '@salesforce/retail-react-app/app/hooks/use-shopper-configuration' +import {useConfigurations} from '@salesforce/commerce-sdk-react' + +// Mock the commerce-sdk-react hook +jest.mock('@salesforce/commerce-sdk-react', () => ({ + useConfigurations: jest.fn() +})) + +describe('useShopperConfiguration', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + test('returns the configuration value when configuration exists with boolean true', () => { + useConfigurations.mockReturnValue({ + data: { + configurations: [ + {id: 'SalesforcePaymentsAllowed', value: true}, + {id: 'AnotherConfig', value: false} + ] + } + }) + + const {result} = renderHook(() => useShopperConfiguration('SalesforcePaymentsAllowed')) + + expect(result.current).toBe(true) + }) + + test('returns the configuration value when configuration exists with boolean false', () => { + useConfigurations.mockReturnValue({ + data: { + configurations: [{id: 'SomeConfig', value: false}] + } + }) + + const {result} = renderHook(() => useShopperConfiguration('SomeConfig')) + + expect(result.current).toBe(false) + }) + + test('returns the configuration value when configuration exists with string value', () => { + useConfigurations.mockReturnValue({ + data: { + configurations: [{id: 'StringConfig', value: 'some-string-value'}] + } + }) + + const {result} = renderHook(() => useShopperConfiguration('StringConfig')) + + expect(result.current).toBe('some-string-value') + }) + + test('returns the configuration value when configuration exists with numeric value', () => { + useConfigurations.mockReturnValue({ + data: { + configurations: [{id: 'NumericConfig', value: 42}] + } + }) + + const {result} = renderHook(() => useShopperConfiguration('NumericConfig')) + + expect(result.current).toBe(42) + }) + + test('returns the configuration value when configuration exists with object value', () => { + const objectValue = {key: 'value', nested: {prop: 'test'}} + useConfigurations.mockReturnValue({ + data: { + configurations: [{id: 'ObjectConfig', value: objectValue}] + } + }) + + const {result} = renderHook(() => useShopperConfiguration('ObjectConfig')) + + expect(result.current).toEqual(objectValue) + }) + + test('returns undefined when configuration does not exist', () => { + useConfigurations.mockReturnValue({ + data: { + configurations: [ + {id: 'Config1', value: true}, + {id: 'Config2', value: false} + ] + } + }) + + const {result} = renderHook(() => useShopperConfiguration('NonExistentConfig')) + + expect(result.current).toBeUndefined() + }) + + test('returns undefined when configurations data is undefined', () => { + useConfigurations.mockReturnValue({ + data: undefined + }) + + const {result} = renderHook(() => useShopperConfiguration('SomeConfig')) + + expect(result.current).toBeUndefined() + }) + + test('returns undefined when configurations data is null', () => { + useConfigurations.mockReturnValue({ + data: null + }) + + const {result} = renderHook(() => useShopperConfiguration('SomeConfig')) + + expect(result.current).toBeUndefined() + }) + + test('returns undefined when configurations array is undefined', () => { + useConfigurations.mockReturnValue({ + data: { + configurations: undefined + } + }) + + const {result} = renderHook(() => useShopperConfiguration('SomeConfig')) + + expect(result.current).toBeUndefined() + }) + + test('returns undefined when configurations array is empty', () => { + useConfigurations.mockReturnValue({ + data: { + configurations: [] + } + }) + + const {result} = renderHook(() => useShopperConfiguration('SomeConfig')) + + expect(result.current).toBeUndefined() + }) + + test('returns the correct configuration when multiple configurations exist', () => { + useConfigurations.mockReturnValue({ + data: { + configurations: [ + {id: 'Config1', value: 'value1'}, + {id: 'Config2', value: 'value2'}, + {id: 'Config3', value: 'value3'} + ] + } + }) + + const {result} = renderHook(() => useShopperConfiguration('Config2')) + + expect(result.current).toBe('value2') + }) + + test('returns undefined when configuration exists but has no value property', () => { + useConfigurations.mockReturnValue({ + data: { + configurations: [{id: 'ConfigWithoutValue'}] + } + }) + + const {result} = renderHook(() => useShopperConfiguration('ConfigWithoutValue')) + + expect(result.current).toBeUndefined() + }) + + test('returns null when configuration value is explicitly null', () => { + useConfigurations.mockReturnValue({ + data: { + configurations: [{id: 'NullConfig', value: null}] + } + }) + + const {result} = renderHook(() => useShopperConfiguration('NullConfig')) + + expect(result.current).toBeNull() + }) + + test('returns 0 when configuration value is 0', () => { + useConfigurations.mockReturnValue({ + data: { + configurations: [{id: 'ZeroConfig', value: 0}] + } + }) + + const {result} = renderHook(() => useShopperConfiguration('ZeroConfig')) + + expect(result.current).toBe(0) + }) + + test('returns empty string when configuration value is empty string', () => { + useConfigurations.mockReturnValue({ + data: { + configurations: [{id: 'EmptyStringConfig', value: ''}] + } + }) + + const {result} = renderHook(() => useShopperConfiguration('EmptyStringConfig')) + + expect(result.current).toBe('') + }) + + test('is case-sensitive when matching configuration IDs', () => { + useConfigurations.mockReturnValue({ + data: { + configurations: [ + {id: 'SalesforcePaymentsAllowed', value: true}, + {id: 'salesforcepaymentsallowed', value: false} + ] + } + }) + + const {result: result1} = renderHook(() => + useShopperConfiguration('SalesforcePaymentsAllowed') + ) + const {result: result2} = renderHook(() => + useShopperConfiguration('salesforcepaymentsallowed') + ) + + expect(result1.current).toBe(true) + expect(result2.current).toBe(false) + }) +}) diff --git a/packages/template-retail-react-app/app/pages/account/wishlist/partials/wishlist-primary-action.jsx b/packages/template-retail-react-app/app/pages/account/wishlist/partials/wishlist-primary-action.jsx index 5adadab379..33bf4c5a3a 100644 --- a/packages/template-retail-react-app/app/pages/account/wishlist/partials/wishlist-primary-action.jsx +++ b/packages/template-retail-react-app/app/pages/account/wishlist/partials/wishlist-primary-action.jsx @@ -12,7 +12,7 @@ import ProductViewModal from '@salesforce/retail-react-app/app/components/produc import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast' import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants' import Link from '@salesforce/retail-react-app/app/components/link' -import {useShopperBasketsMutationHelper} from '@salesforce/commerce-sdk-react' +import {useShopperBasketsV2MutationHelper as useShopperBasketsMutationHelper} from '@salesforce/commerce-sdk-react' /** * Renders primary action on a product-item card in the form of a button. diff --git a/packages/template-retail-react-app/app/pages/cart/index.jsx b/packages/template-retail-react-app/app/pages/cart/index.jsx index 5f4be05e23..314e057f3f 100644 --- a/packages/template-retail-react-app/app/pages/cart/index.jsx +++ b/packages/template-retail-react-app/app/pages/cart/index.jsx @@ -37,12 +37,14 @@ import RecommendedProducts from '@salesforce/retail-react-app/app/components/rec import CartProductListWithGroupedBonusProducts from '@salesforce/retail-react-app/app/pages/cart/partials/cart-product-list-with-grouped-bonus-products' import SelectBonusProductsCard from '@salesforce/retail-react-app/app/pages/cart/partials/select-bonus-products-card' import {DELIVERY_OPTIONS} from '@salesforce/retail-react-app/app/components/pickup-or-delivery' +import LoadingSpinner from '@salesforce/retail-react-app/app/components/loading-spinner' // Hooks import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast' import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import {useWishList} from '@salesforce/retail-react-app/app/hooks/use-wish-list' import {useStoreLocatorModal} from '@salesforce/retail-react-app/app/hooks/use-store-locator' +import {useSFPayments} from '@salesforce/retail-react-app/app/hooks/use-sf-payments' // Bonus Product Utilities import { @@ -73,7 +75,7 @@ import {REMOVE_CART_ITEM_CONFIRMATION_DIALOG_CONFIG} from '@salesforce/retail-re import debounce from 'lodash/debounce' import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' import { - useShopperBasketsMutation, + useShopperBasketsV2Mutation as useShopperBasketsMutation, useProducts, useShopperCustomersMutation, useStores @@ -92,7 +94,7 @@ const DEBOUNCE_WAIT = 750 const Cart = () => { const {data: basket, isLoading, derivedData} = useCurrentBasket() - + const {confirmingBasket} = useSFPayments() const multishipEnabled = getConfig()?.app?.multishipEnabled ?? true const storeLocatorEnabled = getConfig()?.app?.storeLocatorEnabled ?? STORE_LOCATOR_IS_ENABLED @@ -1501,6 +1503,9 @@ const Cart = () => { promotionId={bonusProductViewModal.data.promotionId} /> )} + + {/* Loading overlay during express payment confirmation */} + {confirmingBasket && } ) } diff --git a/packages/template-retail-react-app/app/pages/cart/partials/cart-cta.jsx b/packages/template-retail-react-app/app/pages/cart/partials/cart-cta.jsx index 4f7fae0f4c..8cd3508b87 100644 --- a/packages/template-retail-react-app/app/pages/cart/partials/cart-cta.jsx +++ b/packages/template-retail-react-app/app/pages/cart/partials/cart-cta.jsx @@ -15,8 +15,12 @@ import { VisaIcon } from '@salesforce/retail-react-app/app/components/icons' import Link from '@salesforce/retail-react-app/app/components/link' +import SFPaymentsExpress from '@salesforce/retail-react-app/app/components/sf-payments-express' +import {useSFPaymentsEnabled} from '@salesforce/retail-react-app/app/hooks/use-sf-payments' const CartCta = () => { + const sfPaymentsEnabled = useSFPaymentsEnabled() + return ( - - - - - - + {sfPaymentsEnabled ? ( + + ) : ( + + + + + + + )} ) } diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/index.jsx b/packages/template-retail-react-app/app/pages/checkout-one-click/index.jsx index ad1fe8d3a6..b627515f67 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/index.jsx +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/index.jsx @@ -21,7 +21,7 @@ import { import {FormattedMessage, useIntl} from 'react-intl' import {useForm} from 'react-hook-form' import { - useShopperBasketsMutation, + useShopperBasketsV2Mutation as useShopperBasketsMutation, useShopperOrdersMutation, useShopperCustomersMutation, ShopperBasketsMutations, diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/index.test.js b/packages/template-retail-react-app/app/pages/checkout-one-click/index.test.js index 6b7a6a466f..dd235518a3 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/index.test.js +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/index.test.js @@ -60,7 +60,7 @@ jest.mock('@salesforce/commerce-sdk-react', () => { useAuthHelper: () => ({ mutateAsync: mockUseAuthHelper }), - useShopperBasketsMutation: (mutation) => { + useShopperBasketsV2Mutation: (mutation) => { if (mutation === 'removeItemFromBasket') { return { mutateAsync: (_, {onSuccess} = {}) => { @@ -69,7 +69,7 @@ jest.mock('@salesforce/commerce-sdk-react', () => { } } } - return originalModule.useShopperBasketsMutation(mutation) + return originalModule.useShopperBasketsV2Mutation(mutation) }, useShopperCustomersMutation: (mutation) => { if (mutation === 'createCustomerPaymentInstrument') { diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.jsx b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.jsx index 7de0080199..83178d533f 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.jsx +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.jsx @@ -45,7 +45,7 @@ import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-curre import { AuthHelpers, useAuthHelper, - useShopperBasketsMutation, + useShopperBasketsV2Mutation as useShopperBasketsMutation, useCustomerType, useShopperCustomersMutation } from '@salesforce/commerce-sdk-react' diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.test.js b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.test.js index a5eff9732c..98c76b852c 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.test.js +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.test.js @@ -37,7 +37,7 @@ jest.mock('@salesforce/commerce-sdk-react', () => { useAuthHelper: jest .fn() .mockImplementation((helperType) => mockAuthHelperFunctions[helperType]), - useShopperBasketsMutation: jest.fn().mockImplementation((mutationType) => { + useShopperBasketsV2Mutation: jest.fn().mockImplementation((mutationType) => { if (mutationType === 'updateCustomerForBasket') return mockUpdateCustomerForBasket if (mutationType === 'transferBasket') return mockTransferBasket if (mutationType === 'updateBillingAddressForBasket') diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-payment.jsx b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-payment.jsx index af7bef8e7c..c069748aef 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-payment.jsx +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-payment.jsx @@ -16,7 +16,10 @@ import { Divider } from '@salesforce/retail-react-app/app/components/shared/ui' import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast' -import {useShopperBasketsMutation, useCustomerType} from '@salesforce/commerce-sdk-react' +import { + useShopperBasketsV2Mutation as useShopperBasketsMutation, + useCustomerType +} from '@salesforce/commerce-sdk-react' import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' import {useCheckoutAutoSelect} from '@salesforce/retail-react-app/app/hooks/use-checkout-auto-select' diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-payment.test.js b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-payment.test.js index ba097f8133..d95e0199e2 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-payment.test.js +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-payment.test.js @@ -12,7 +12,10 @@ import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-curre import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast' import {useCurrency} from '@salesforce/retail-react-app/app/hooks/use-currency' -import {useShopperBasketsMutation, useCustomerType} from '@salesforce/commerce-sdk-react' +import { + useShopperBasketsV2Mutation as useShopperBasketsMutation, + useCustomerType +} from '@salesforce/commerce-sdk-react' import {useCheckout} from '@salesforce/retail-react-app/app/pages/checkout-one-click/util/checkout-context' import Payment from '@salesforce/retail-react-app/app/pages/checkout-one-click/partials/one-click-payment' import {CurrencyProvider} from '@salesforce/retail-react-app/app/contexts' @@ -60,7 +63,7 @@ jest.mock('@salesforce/commerce-sdk-react', () => { const original = jest.requireActual('@salesforce/commerce-sdk-react') return { ...original, - useShopperBasketsMutation: jest.fn(), + useShopperBasketsV2Mutation: jest.fn(), useAuthHelper: jest.fn(() => ({mutateAsync: jest.fn()})), useUsid: () => ({getUsidWhenReady: jest.fn().mockResolvedValue('usid-123')}), useCustomerType: jest.fn(() => ({isGuest: true, isRegistered: false})), diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-pickup-address.jsx b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-pickup-address.jsx index 76ffa059ec..94fe11a5a4 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-pickup-address.jsx +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-pickup-address.jsx @@ -18,7 +18,10 @@ import StoreDisplay from '@salesforce/retail-react-app/app/components/store-disp // Hooks import {useCheckout} from '@salesforce/retail-react-app/app/pages/checkout-one-click/util/checkout-context' import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' -import {useShopperBasketsMutation, useStores} from '@salesforce/commerce-sdk-react' +import { + useShopperBasketsV2Mutation as useShopperBasketsMutation, + useStores +} from '@salesforce/commerce-sdk-react' import {isPickupShipment} from '@salesforce/retail-react-app/app/utils/shipment-utils' const PickupAddress = () => { diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-pickup-address.test.js b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-pickup-address.test.js index e3ff80b173..1602a2873d 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-pickup-address.test.js +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-pickup-address.test.js @@ -15,7 +15,7 @@ jest.mock('@salesforce/commerce-sdk-react', () => { const originalModule = jest.requireActual('@salesforce/commerce-sdk-react') return { ...originalModule, - useShopperBasketsMutation: () => ({ + useShopperBasketsV2Mutation: () => ({ mutateAsync: mockMutateAsync }), useStores: () => ({ diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-address.jsx b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-address.jsx index 527c83d94c..deb5e8b59c 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-address.jsx +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-address.jsx @@ -18,7 +18,7 @@ import AddressDisplay from '@salesforce/retail-react-app/app/components/address- import OneClickShippingMultiAddress from '@salesforce/retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-multi-address' import { useShopperCustomersMutation, - useShopperBasketsMutation, + useShopperBasketsV2Mutation as useShopperBasketsMutation, useShippingMethodsForShipment } from '@salesforce/commerce-sdk-react' import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-address.test.js b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-address.test.js index f92394d0f0..ce8323ed29 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-address.test.js +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-address.test.js @@ -43,7 +43,7 @@ jest.mock('@salesforce/commerce-sdk-react', () => { const originalModule = jest.requireActual('@salesforce/commerce-sdk-react') return { ...originalModule, - useShopperBasketsMutation: jest.fn().mockImplementation((mutationType) => { + useShopperBasketsV2Mutation: jest.fn().mockImplementation((mutationType) => { if (mutationType === 'updateShippingAddressForShipment') return mockUpdateShippingAddress return {mutateAsync: jest.fn()} @@ -709,7 +709,7 @@ describe('ShippingAddress Component', () => { const originalModule = jest.requireActual('@salesforce/commerce-sdk-react') return { ...originalModule, - useShopperBasketsMutation: jest.fn().mockImplementation((mutationType) => { + useShopperBasketsV2Mutation: jest.fn().mockImplementation((mutationType) => { if (mutationType === 'updateShippingAddressForShipment') return mockUpdateShippingAddress return {mutateAsync: jest.fn()} diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-options.jsx b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-options.jsx index 70fe0a3941..865e1e6307 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-options.jsx +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-options.jsx @@ -25,7 +25,7 @@ import { } from '@salesforce/retail-react-app/app/components/toggle-card' import { useShippingMethodsForShipment, - useShopperBasketsMutation + useShopperBasketsV2Mutation as useShopperBasketsMutation } from '@salesforce/commerce-sdk-react' import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-options.test.js b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-options.test.js index 790a535eef..c9b1cff8cd 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-options.test.js +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-options.test.js @@ -47,7 +47,7 @@ jest.mock('@salesforce/commerce-sdk-react', () => { const originalModule = jest.requireActual('@salesforce/commerce-sdk-react') return { ...originalModule, - useShopperBasketsMutation: jest.fn().mockImplementation((mutationType) => { + useShopperBasketsV2Mutation: jest.fn().mockImplementation((mutationType) => { if (mutationType === 'updateShippingMethodForShipment') return mockUpdateShippingMethod return {mutateAsync: jest.fn()} }), diff --git a/packages/template-retail-react-app/app/pages/checkout/index.jsx b/packages/template-retail-react-app/app/pages/checkout/index.jsx index cbc300c7f3..335aed4e01 100644 --- a/packages/template-retail-react-app/app/pages/checkout/index.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/index.jsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: BSD-3-Clause * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import React, {useEffect, useState} from 'react' +import React, {useEffect, useState, useRef} from 'react' import {FormattedMessage, useIntl} from 'react-intl' import { Alert, @@ -24,6 +24,8 @@ import { } from '@salesforce/retail-react-app/app/pages/checkout/util/checkout-context' import ContactInfo from '@salesforce/retail-react-app/app/pages/checkout/partials/contact-info' import PickupAddress from '@salesforce/retail-react-app/app/pages/checkout/partials/pickup-address' +import SFPaymentsExpress from '@salesforce/retail-react-app/app/components/sf-payments-express' +import SFPaymentsSheet from '@salesforce/retail-react-app/app/pages/checkout/partials/sf-payments-sheet' import ShippingAddress from '@salesforce/retail-react-app/app/pages/checkout/partials/shipping-address' import ShippingMethods from '@salesforce/retail-react-app/app/pages/checkout/partials/shipping-methods' import Payment from '@salesforce/retail-react-app/app/pages/checkout/partials/payment' @@ -31,7 +33,10 @@ import OrderSummary from '@salesforce/retail-react-app/app/components/order-summ import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' import CheckoutSkeleton from '@salesforce/retail-react-app/app/pages/checkout/partials/checkout-skeleton' -import {useShopperOrdersMutation, useShopperBasketsMutation} from '@salesforce/commerce-sdk-react' +import { + useShopperOrdersMutation, + useShopperBasketsV2Mutation as useShopperBasketsMutation +} from '@salesforce/commerce-sdk-react' import UnavailableProductConfirmationModal from '@salesforce/retail-react-app/app/components/unavailable-product-confirmation-modal' import { API_ERROR_MESSAGE, @@ -41,14 +46,21 @@ import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast' import LoadingSpinner from '@salesforce/retail-react-app/app/components/loading-spinner' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import {useMultiship} from '@salesforce/retail-react-app/app/hooks/use-multiship' +import { + useSFPaymentsEnabled, + useSFPayments +} from '@salesforce/retail-react-app/app/hooks/use-sf-payments' import {GoogleAPIProvider} from '@salesforce/retail-react-app/app/pages/checkout/util/google-api-provider' +let persistedPaymentsError = null + const Checkout = () => { const {formatMessage} = useIntl() const navigate = useNavigation() const {step} = useCheckout() const [error, setError] = useState() const {data: basket, derivedData} = useCurrentBasket() + const {confirmingBasket} = useSFPayments() const [isLoading, setIsLoading] = useState(false) const {mutateAsync: createOrder} = useShopperOrdersMutation('createOrder') const {passwordless = {}, social = {}} = getConfig().app.login || {} @@ -57,6 +69,11 @@ const Checkout = () => { const isPasswordlessEnabled = !!passwordless?.enabled const {removeEmptyShipments} = useMultiship(basket) const multishipEnabled = getConfig()?.app?.multishipEnabled ?? true + const sfPaymentsEnabled = useSFPaymentsEnabled() + const placeOrderCheckoutStep = sfPaymentsEnabled ? 4 : 5 + const sfPaymentsSheetRef = useRef(null) + const [expressPaymentMethodsRendered, setExpressPaymentMethodsRendered] = useState(false) + const [shouldHidePlaceOrderButton, setShouldHidePlaceOrderButton] = useState(false) // cart has both pickup and delivery orders const isDeliveryAndPickupOrder = @@ -84,19 +101,48 @@ const Checkout = () => { } }, [basket?.basketId]) + // Restore error if component remounted after payments error causes a refresh + useEffect(() => { + if (persistedPaymentsError && !error) { + setError(persistedPaymentsError) + persistedPaymentsError = null // Clear it after restoring + } + }, []) + + // Callback to handle when payment method requires its own pay button + const handleRequiresPayButtonChange = (requiresPayButton) => { + setShouldHidePlaceOrderButton(requiresPayButton === false) + } + + const doCreateOrder = async () => { + return await createOrder({ + body: {basketId: basket.basketId} + }) + } + + const handlePaymentError = (errorMessage) => { + persistedPaymentsError = errorMessage + setError(errorMessage) + } + const submitOrder = async () => { setIsLoading(true) try { - const order = await createOrder({ - body: {basketId: basket.basketId} - }) + let order + if (sfPaymentsEnabled) { + order = await sfPaymentsSheetRef.current.confirmPayment() + } else { + order = await doCreateOrder() + } navigate(`/checkout/confirmation/${order.orderNo}`) } catch (error) { - const message = formatMessage({ - id: 'checkout.message.generic_error', - defaultMessage: 'An unexpected error occurred during checkout.' - }) - setError(message) + if (!persistedPaymentsError) { + const message = formatMessage({ + id: 'checkout.message.generic_error', + defaultMessage: 'An unexpected error occurred during checkout.' + }) + setError(message) + } } finally { setIsLoading(false) } @@ -122,6 +168,34 @@ const Checkout = () => { {error} )} + {sfPaymentsEnabled && ( + + + + + + setExpressPaymentMethodsRendered(true) + } + /> + + )} { )} - - {step === 5 && ( + {sfPaymentsEnabled ? ( + + ) : ( + + )} + + {step === placeOrderCheckoutStep && !shouldHidePlaceOrderButton && ( + + ) + } + return {__esModule: true, default: MockPaymentForm} +}) + +jest.mock( + '@salesforce/retail-react-app/app/pages/checkout/partials/shipping-address-selection', + () => { + /* eslint-disable react/prop-types -- form is react-hook-form instance in test mock */ + function MockShippingAddressSelection({form}) { + return ( +
+ +
+ ) + } + /* eslint-enable react/prop-types */ + return {__esModule: true, default: MockShippingAddressSelection} + } +) + +jest.mock('@salesforce/retail-react-app/app/components/address-display', () => ({ + __esModule: true, + default: ({address}) => ( +
+ {address?.address1}, {address?.city}, {address?.postalCode} +
+ ) +})) + +const setUseCurrentBasketData = (basket) => { + mockUseCurrentBasket.mockReturnValue({ + data: basket, + derivedData: {totalItems: basket?.productItems?.length ?? 0} + }) +} + +describe('Payment', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseCheckout.mockReturnValue({ + step: STEPS.PAYMENT, + STEPS, + goToStep: mockGoToStep, + goToNextStep: mockGoToNextStep + }) + mockUseCurrentBasket.mockReturnValue(defaultBasketReturn) + mockAddPaymentInstrument.mockResolvedValue({}) + mockUpdateBillingAddress.mockResolvedValue({basketId: 'basket-1'}) + mockRemovePaymentInstrument.mockResolvedValue({}) + }) + + describe('rendering', () => { + test('renders Payment heading and Edit Payment Info when step is not PAYMENT', () => { + mockUseCheckout.mockReturnValue({ + step: STEPS.REVIEW_ORDER, + STEPS, + goToStep: mockGoToStep, + goToNextStep: mockGoToNextStep + }) + + setUseCurrentBasketData({ + basketId: 'basket-1', + shipments: [{shipmentId: 'me', shippingMethod: {c_storePickupEnabled: false}}], + billingAddress: {}, + paymentInstruments: [ + { + paymentInstrumentId: 'pi1', + paymentCard: {cardType: 'Visa', numberLastDigits: '1111'} + } + ] + }) + + renderWithProviders() + expect(screen.getByRole('heading', {name: 'Payment'})).toBeInTheDocument() + expect(screen.getByRole('button', {name: 'Edit Payment Info'})).toBeInTheDocument() + }) + + test('renders PaymentForm when no payment instrument applied', () => { + setUseCurrentBasketData({ + basketId: 'basket-1', + shipments: [ + { + shipmentId: 'me', + shippingMethod: {c_storePickupEnabled: false}, + shippingAddress: { + address1: '123 Main St', + city: 'Tampa', + countryCode: 'US', + firstName: 'Test', + lastName: 'User', + postalCode: '33712', + stateCode: 'FL' + } + } + ], + billingAddress: null, + paymentInstruments: [] + }) + + renderWithProviders() + expect(screen.getByRole('button', {name: 'Review Order'})).toBeInTheDocument() + }) + + test('renders Credit Card summary and Remove button when payment instrument applied', () => { + setUseCurrentBasketData({ + basketId: 'basket-1', + shipments: [ + { + shipmentId: 'me', + shippingMethod: {c_storePickupEnabled: false}, + shippingAddress: {address1: '123 Main St', city: 'Tampa', countryCode: 'US'} + } + ], + billingAddress: {address1: '123 Main St', city: 'Tampa', countryCode: 'US'}, + paymentInstruments: [ + { + paymentInstrumentId: 'pi1', + paymentCard: { + cardType: 'Visa', + numberLastDigits: '1111', + expirationMonth: 12, + expirationYear: 2028 + } + } + ] + }) + + renderWithProviders() + expect(screen.getByText('Credit Card')).toBeInTheDocument() + expect(screen.getByText('Visa')).toBeInTheDocument() + expect(screen.getByText(/1111/)).toBeInTheDocument() + expect(screen.getByRole('button', {name: 'Remove'})).toBeInTheDocument() + }) + + test('renders Billing Address section', () => { + setUseCurrentBasketData({ + basketId: 'basket-1', + shipments: [{shipmentId: 'me', shippingMethod: {c_storePickupEnabled: false}}], + billingAddress: null, + paymentInstruments: [] + }) + + renderWithProviders() + expect(screen.getByText('Billing Address')).toBeInTheDocument() + }) + + test('renders Same as shipping address checkbox when not pickup only', () => { + setUseCurrentBasketData({ + basketId: 'basket-1', + shipments: [ + { + shipmentId: 'me', + shippingMethod: {c_storePickupEnabled: false}, + shippingAddress: {address1: '123 Main St', city: 'Tampa', countryCode: 'US'} + } + ], + billingAddress: null, + paymentInstruments: [] + }) + + renderWithProviders() + expect(screen.getByText('Same as shipping address')).toBeInTheDocument() + }) + + test('does not render Same as shipping address checkbox when pickup only', () => { + setUseCurrentBasketData({ + basketId: 'basket-1', + shipments: [ + { + shipmentId: 'me', + shippingMethod: {c_storePickupEnabled: true}, + shippingAddress: null + } + ], + billingAddress: null, + paymentInstruments: [] + }) + + renderWithProviders() + expect(screen.queryByText('Same as shipping address')).not.toBeInTheDocument() + }) + + test('renders Review Order button when editing', () => { + setUseCurrentBasketData({ + basketId: 'basket-1', + shipments: [{shipmentId: 'me', shippingMethod: {c_storePickupEnabled: false}}], + billingAddress: null, + paymentInstruments: [] + }) + + renderWithProviders() + expect(screen.getByRole('button', {name: 'Review Order'})).toBeInTheDocument() + }) + + test('renders shipping address when billing same as shipping', () => { + const shippingAddress = { + address1: '123 Main St', + city: 'Tampa', + countryCode: 'US', + firstName: 'Test', + lastName: 'User', + postalCode: '33712', + stateCode: 'FL' + } + setUseCurrentBasketData({ + basketId: 'basket-1', + shipments: [ + { + shipmentId: 'me', + shippingMethod: {c_storePickupEnabled: false}, + shippingAddress + } + ], + billingAddress: null, + paymentInstruments: [] + }) + + renderWithProviders() + expect(screen.getByText(/123 Main St/)).toBeInTheDocument() + expect(screen.getByText(/Tampa/)).toBeInTheDocument() + }) + }) + + describe('Edit Payment Info', () => { + test('calls goToStep with STEPS.PAYMENT when Edit Payment Info is clicked', async () => { + mockUseCheckout.mockReturnValue({ + step: STEPS.REVIEW_ORDER, + STEPS, + goToStep: mockGoToStep, + goToNextStep: mockGoToNextStep + }) + + setUseCurrentBasketData({ + basketId: 'basket-1', + shipments: [{shipmentId: 'me', shippingMethod: {c_storePickupEnabled: false}}], + billingAddress: {}, + paymentInstruments: [{paymentInstrumentId: 'pi1', paymentCard: {}}] + }) + + const {user} = renderWithProviders() + await user.click(screen.getByRole('button', {name: 'Edit Payment Info'})) + + expect(mockGoToStep).toHaveBeenCalledWith(STEPS.PAYMENT) + }) + }) + + describe('payment submission', () => { + test('calls addPaymentInstrumentToBasket when submitting payment form and no applied payment', async () => { + setUseCurrentBasketData({ + basketId: 'basket-1', + shipments: [ + { + shipmentId: 'me', + shippingMethod: {c_storePickupEnabled: false}, + shippingAddress: { + address1: '123 Main St', + city: 'Tampa', + countryCode: 'US', + firstName: 'Test', + lastName: 'User', + postalCode: '33712', + stateCode: 'FL' + } + } + ], + billingAddress: { + address1: '123 Main St', + city: 'Tampa', + countryCode: 'US', + firstName: 'Test', + lastName: 'User', + postalCode: '33712', + stateCode: 'FL' + }, + paymentInstruments: [] + }) + + const {user} = renderWithProviders() + await user.click(screen.getByRole('button', {name: 'Submit payment'})) + + await waitFor(() => { + expect(mockAddPaymentInstrument).toHaveBeenCalledWith({ + parameters: {basketId: 'basket-1'}, + body: expect.objectContaining({ + paymentMethodId: 'CREDIT_CARD', + paymentCard: expect.objectContaining({ + holder: 'Test Holder', + cardType: 'Visa', + expirationMonth: 12, + expirationYear: 2028 + }) + }) + }) + }) + }) + }) + + describe('Review Order', () => { + test('calls goToNextStep when Review Order clicked and billing form valid', async () => { + setUseCurrentBasketData({ + basketId: 'basket-1', + shipments: [ + { + shipmentId: 'me', + shippingMethod: {c_storePickupEnabled: false}, + shippingAddress: { + address1: '123 Main St', + city: 'Tampa', + countryCode: 'US', + firstName: 'Test', + lastName: 'User', + postalCode: '33712', + stateCode: 'FL' + } + } + ], + billingAddress: { + address1: '123 Main St', + city: 'Tampa', + countryCode: 'US', + firstName: 'Test', + lastName: 'User', + postalCode: '33712', + stateCode: 'FL' + }, + paymentInstruments: [ + { + paymentInstrumentId: 'pi1', + paymentCard: { + cardType: 'Visa', + numberLastDigits: '1111', + expirationMonth: 12, + expirationYear: 2028 + } + } + ] + }) + + const {user} = renderWithProviders() + await user.click(screen.getByRole('button', {name: 'Review Order'})) + + await waitFor(() => { + expect(mockUpdateBillingAddress).toHaveBeenCalled() + expect(mockGoToNextStep).toHaveBeenCalled() + }) + }) + }) + + describe('Remove payment', () => { + test('calls removePaymentInstrumentFromBasket when Remove is clicked', async () => { + setUseCurrentBasketData({ + basketId: 'basket-1', + shipments: [{shipmentId: 'me', shippingMethod: {c_storePickupEnabled: false}}], + billingAddress: {}, + paymentInstruments: [ + { + paymentInstrumentId: 'pi1', + paymentCard: { + cardType: 'Visa', + numberLastDigits: '1111', + expirationMonth: 12, + expirationYear: 2028 + } + } + ] + }) + + const {user} = renderWithProviders() + await user.click(screen.getByRole('button', {name: 'Remove'})) + + await waitFor(() => { + expect(mockRemovePaymentInstrument).toHaveBeenCalledWith({ + parameters: { + basketId: 'basket-1', + paymentInstrumentId: 'pi1' + } + }) + }) + }) + + test('calls showToast on error when remove payment fails', async () => { + mockRemovePaymentInstrument.mockRejectedValueOnce(new Error('Network error')) + + setUseCurrentBasketData({ + basketId: 'basket-1', + shipments: [{shipmentId: 'me', shippingMethod: {c_storePickupEnabled: false}}], + billingAddress: {}, + paymentInstruments: [ + { + paymentInstrumentId: 'pi1', + paymentCard: { + cardType: 'Visa', + numberLastDigits: '1111', + expirationMonth: 12, + expirationYear: 2028 + } + } + ] + }) + + const {user} = renderWithProviders() + await user.click(screen.getByRole('button', {name: 'Remove'})) + + await waitFor(() => { + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'error', + title: expect.any(String) + }) + ) + }) + }) + }) + + describe('billing same as shipping', () => { + test('uses shipping address for billing when checkbox checked', async () => { + const shippingAddress = { + address1: '123 Main St', + city: 'Tampa', + countryCode: 'US', + firstName: 'Test', + lastName: 'User', + postalCode: '33712', + stateCode: 'FL' + } + setUseCurrentBasketData({ + basketId: 'basket-1', + shipments: [ + { + shipmentId: 'me', + shippingMethod: {c_storePickupEnabled: false}, + shippingAddress + } + ], + billingAddress: null, + paymentInstruments: [ + { + paymentInstrumentId: 'pi1', + paymentCard: { + cardType: 'Visa', + numberLastDigits: '1111', + expirationMonth: 12, + expirationYear: 2028 + } + } + ] + }) + + const {user} = renderWithProviders() + await user.click(screen.getByRole('button', {name: 'Review Order'})) + + await waitFor(() => { + expect(mockUpdateBillingAddress).toHaveBeenCalledWith({ + parameters: {basketId: 'basket-1'}, + body: expect.objectContaining({ + address1: '123 Main St', + city: 'Tampa', + countryCode: 'US', + postalCode: '33712', + stateCode: 'FL' + }) + }) + }) + }) + }) + + describe('PaymentCardSummary', () => { + test('displays card type, masked number and expiration', () => { + setUseCurrentBasketData({ + basketId: 'basket-1', + shipments: [{shipmentId: 'me', shippingMethod: {c_storePickupEnabled: false}}], + billingAddress: {}, + paymentInstruments: [ + { + paymentInstrumentId: 'pi1', + paymentCard: { + cardType: 'Master Card', + numberLastDigits: '9999', + expirationMonth: 1, + expirationYear: 2026 + } + } + ] + }) + + renderWithProviders() + expect(screen.getByText('Master Card')).toBeInTheDocument() + expect(screen.getByText(/9999/)).toBeInTheDocument() + expect(screen.getByText('1/2026')).toBeInTheDocument() + }) + }) + + describe('empty basket', () => { + test('renders without crashing when basket is null', () => { + setUseCurrentBasketData(null) + expect(() => renderWithProviders()).not.toThrow() + }) + + test('renders without crashing when basket has no shipments', () => { + setUseCurrentBasketData({ + basketId: 'basket-1', + shipments: [], + billingAddress: null, + paymentInstruments: [] + }) + expect(() => renderWithProviders()).not.toThrow() + }) + }) +}) diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/sf-payments-order-summary.jsx b/packages/template-retail-react-app/app/pages/checkout/partials/sf-payments-order-summary.jsx new file mode 100644 index 0000000000..642d578a30 --- /dev/null +++ b/packages/template-retail-react-app/app/pages/checkout/partials/sf-payments-order-summary.jsx @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React from 'react' +import {FormattedMessage, useIntl} from 'react-intl' +import PropTypes from 'prop-types' +import {Box, Heading, Stack, Text} from '@salesforce/retail-react-app/app/components/shared/ui' +import {getCreditCardIcon} from '@salesforce/retail-react-app/app/utils/cc-utils' + +const SFPaymentsOrderSummary = ({paymentInstrument}) => { + const intl = useIntl() + + const brand = (() => { + switch (paymentInstrument.c_paymentReference_brand) { + case 'amex': + return intl.formatMessage({ + id: 'sf_payments_order_summary.label.brand.amex', + defaultMessage: 'American Express' + }) + case 'diners': + return intl.formatMessage({ + id: 'sf_payments_order_summary.label.brand.diners', + defaultMessage: 'Diners Club' + }) + case 'discover': + return intl.formatMessage({ + id: 'sf_payments_order_summary.label.brand.discover', + defaultMessage: 'Discover' + }) + case 'jcb': + return intl.formatMessage({ + id: 'sf_payments_order_summary.label.brand.jcb', + defaultMessage: 'JCB' + }) + case 'mastercard': + return intl.formatMessage({ + id: 'sf_payments_order_summary.label.brand.mastercard', + defaultMessage: 'MasterCard' + }) + case 'unionpay': + return intl.formatMessage({ + id: 'sf_payments_order_summary.label.brand.unionpay', + defaultMessage: 'China UnionPay' + }) + case 'visa': + return intl.formatMessage({ + id: 'sf_payments_order_summary.label.brand.visa', + defaultMessage: 'Visa' + }) + default: + return intl.formatMessage({ + id: 'sf_payments_order_summary.label.brand.unknown', + defaultMessage: 'Unknown' + }) + } + })() + const CardIcon = getCreditCardIcon(paymentInstrument.c_paymentReference_brand) + const bank = (() => { + switch (paymentInstrument.c_paymentReference_bank) { + // TODO: translate bank names + default: + return intl.formatMessage({ + id: 'sf_payments_order_summary.label.bank.unknown', + defaultMessage: 'Unknown' + }) + } + })() + + return ( + + + {paymentInstrument.c_paymentReference_type === 'afterpay_clearpay' ? ( + + ) : paymentInstrument.c_paymentReference_type === 'bancontact' ? ( + + ) : paymentInstrument.c_paymentReference_type === 'card' ? ( + + ) : paymentInstrument.c_paymentReference_type === 'eps' ? ( + + ) : paymentInstrument.c_paymentReference_type === 'ideal' ? ( + + ) : paymentInstrument.c_paymentReference_type === 'klarna' ? ( + + ) : paymentInstrument.c_paymentReference_type === 'sepa_debit' ? ( + + ) : ( + + )} + + + + {paymentInstrument.c_paymentReference_type === 'bancontact' && ( + + {paymentInstrument.c_paymentReference_bankName} + + + ••••{' '} + {paymentInstrument.c_paymentReference_last4} + + + + )} + {paymentInstrument.c_paymentReference_type === 'card' && ( + + {CardIcon && } + + + {brand} + + + ••••{' '} + {paymentInstrument.c_paymentReference_last4} + + + + + )} + {paymentInstrument.c_paymentReference_type === 'eps' && ( + + {bank} + + )} + {paymentInstrument.c_paymentReference_type === 'ideal' && ( + + {bank} + + )} + {paymentInstrument.c_paymentReference_type === 'sepa_debit' && ( + + + + ••••{' '} + {paymentInstrument.c_paymentReference_last4} + + + + )} + + + ) +} + +SFPaymentsOrderSummary.propTypes = { + paymentInstrument: PropTypes.object.isRequired +} + +export default SFPaymentsOrderSummary diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/sf-payments-order-summary.test.js b/packages/template-retail-react-app/app/pages/checkout/partials/sf-payments-order-summary.test.js new file mode 100644 index 0000000000..9010aaa845 --- /dev/null +++ b/packages/template-retail-react-app/app/pages/checkout/partials/sf-payments-order-summary.test.js @@ -0,0 +1,432 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import React from 'react' +import {screen} from '@testing-library/react' +import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' +import SFPaymentsOrderSummary from '@salesforce/retail-react-app/app/pages/checkout/partials/sf-payments-order-summary' + +// Mock getConfig to provide necessary configuration +jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => { + const actual = jest.requireActual('@salesforce/pwa-kit-runtime/utils/ssr-config') + const mockConfig = jest.requireActual('@salesforce/retail-react-app/config/mocks/default') + return { + ...actual, + getConfig: jest.fn(() => ({ + ...mockConfig, + app: { + ...mockConfig.app, + sfPayments: { + enabled: true, + sdkUrl: 'https://example.com/sfpayments.js' + } + } + })) + } +}) + +// Mock getCreditCardIcon utility +jest.mock('@salesforce/retail-react-app/app/utils/cc-utils', () => ({ + getCreditCardIcon: jest.fn((brand) => { + const iconMap = { + visa: () =>
Visa Icon
, + mastercard: () =>
MasterCard Icon
, + amex: () =>
Amex Icon
, + discover: () =>
Discover Icon
+ } + return iconMap[brand] + }) +})) + +describe('SFPaymentsOrderSummary', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Payment Type Headings', () => { + test('renders Afterpay/Clearpay heading for afterpay_clearpay type', () => { + const paymentInstrument = { + c_paymentReference_type: 'afterpay_clearpay' + } + + renderWithProviders() + + expect(screen.getByText('Afterpay/Clearpay')).toBeInTheDocument() + }) + + test('renders Bancontact heading for bancontact type', () => { + const paymentInstrument = { + c_paymentReference_type: 'bancontact', + c_paymentReference_bankName: 'Test Bank', + c_paymentReference_last4: '1234' + } + + renderWithProviders() + + expect(screen.getByText('Bancontact')).toBeInTheDocument() + }) + + test('renders Credit Card heading for card type', () => { + const paymentInstrument = { + c_paymentReference_type: 'card', + c_paymentReference_brand: 'visa', + c_paymentReference_last4: '4242' + } + + renderWithProviders() + + expect(screen.getByText('Credit Card')).toBeInTheDocument() + }) + + test('renders EPS heading for eps type', () => { + const paymentInstrument = { + c_paymentReference_type: 'eps', + c_paymentReference_bank: 'test_bank' + } + + renderWithProviders() + + expect(screen.getByText('EPS')).toBeInTheDocument() + }) + + test('renders iDEAL heading for ideal type', () => { + const paymentInstrument = { + c_paymentReference_type: 'ideal', + c_paymentReference_bank: 'test_bank' + } + + renderWithProviders() + + expect(screen.getByText('iDEAL')).toBeInTheDocument() + }) + + test('renders Klarna heading for klarna type', () => { + const paymentInstrument = { + c_paymentReference_type: 'klarna' + } + + renderWithProviders() + + expect(screen.getByText('Klarna')).toBeInTheDocument() + }) + + test('renders SEPA Debit heading for sepa_debit type', () => { + const paymentInstrument = { + c_paymentReference_type: 'sepa_debit', + c_paymentReference_last4: '5678' + } + + renderWithProviders() + + expect(screen.getByText('SEPA Debit')).toBeInTheDocument() + }) + + test('renders Unknown heading for unknown payment type', () => { + const paymentInstrument = { + c_paymentReference_type: 'unknown_type' + } + + renderWithProviders() + + expect(screen.getByText('Unknown')).toBeInTheDocument() + }) + }) + + describe('Card Brand Display', () => { + test('displays American Express for amex brand', () => { + const paymentInstrument = { + c_paymentReference_type: 'card', + c_paymentReference_brand: 'amex', + c_paymentReference_last4: '1234' + } + + renderWithProviders() + + expect(screen.getByText('American Express')).toBeInTheDocument() + }) + + test('displays Diners Club for diners brand', () => { + const paymentInstrument = { + c_paymentReference_type: 'card', + c_paymentReference_brand: 'diners', + c_paymentReference_last4: '1234' + } + + renderWithProviders() + + expect(screen.getByText('Diners Club')).toBeInTheDocument() + }) + + test('displays Discover for discover brand', () => { + const paymentInstrument = { + c_paymentReference_type: 'card', + c_paymentReference_brand: 'discover', + c_paymentReference_last4: '1234' + } + + renderWithProviders() + + expect(screen.getByText('Discover')).toBeInTheDocument() + }) + + test('displays JCB for jcb brand', () => { + const paymentInstrument = { + c_paymentReference_type: 'card', + c_paymentReference_brand: 'jcb', + c_paymentReference_last4: '1234' + } + + renderWithProviders() + + expect(screen.getByText('JCB')).toBeInTheDocument() + }) + + test('displays MasterCard for mastercard brand', () => { + const paymentInstrument = { + c_paymentReference_type: 'card', + c_paymentReference_brand: 'mastercard', + c_paymentReference_last4: '5454' + } + + renderWithProviders() + + expect(screen.getByText('MasterCard')).toBeInTheDocument() + }) + + test('displays China UnionPay for unionpay brand', () => { + const paymentInstrument = { + c_paymentReference_type: 'card', + c_paymentReference_brand: 'unionpay', + c_paymentReference_last4: '1234' + } + + renderWithProviders() + + expect(screen.getByText('China UnionPay')).toBeInTheDocument() + }) + + test('displays Visa for visa brand', () => { + const paymentInstrument = { + c_paymentReference_type: 'card', + c_paymentReference_brand: 'visa', + c_paymentReference_last4: '4242' + } + + renderWithProviders() + + expect(screen.getByText('Visa')).toBeInTheDocument() + }) + + test('displays Unknown for unrecognized card brand', () => { + const paymentInstrument = { + c_paymentReference_type: 'card', + c_paymentReference_brand: 'unknown_brand', + c_paymentReference_last4: '1234' + } + + renderWithProviders() + + // There should be two "Unknown" texts - one for heading and one for brand + const unknownTexts = screen.getAllByText('Unknown') + expect(unknownTexts.length).toBeGreaterThanOrEqual(1) + }) + }) + + describe('Card Details Display', () => { + test('displays card icon for supported brands', () => { + const paymentInstrument = { + c_paymentReference_type: 'card', + c_paymentReference_brand: 'visa', + c_paymentReference_last4: '4242' + } + + renderWithProviders() + + expect(screen.getByTestId('visa-icon')).toBeInTheDocument() + }) + + test('displays last 4 digits for card type', () => { + const paymentInstrument = { + c_paymentReference_type: 'card', + c_paymentReference_brand: 'visa', + c_paymentReference_last4: '4242' + } + + renderWithProviders() + + expect(screen.getByText(/4242/)).toBeInTheDocument() + }) + + test('displays masked digits format for card', () => { + const paymentInstrument = { + c_paymentReference_type: 'card', + c_paymentReference_brand: 'mastercard', + c_paymentReference_last4: '5454' + } + + renderWithProviders() + + // Check for the bullet points and last4 + expect(screen.getByText(/•••• 5454/)).toBeInTheDocument() + }) + }) + + describe('Bancontact Details', () => { + test('displays bank name and last 4 digits for bancontact', () => { + const paymentInstrument = { + c_paymentReference_type: 'bancontact', + c_paymentReference_bankName: 'ING Bank', + c_paymentReference_last4: '1234' + } + + renderWithProviders() + + expect(screen.getByText('ING Bank')).toBeInTheDocument() + expect(screen.getByText(/1234/)).toBeInTheDocument() + }) + + test('displays masked format for bancontact last4', () => { + const paymentInstrument = { + c_paymentReference_type: 'bancontact', + c_paymentReference_bankName: 'KBC Bank', + c_paymentReference_last4: '5678' + } + + renderWithProviders() + + expect(screen.getByText(/•••• 5678/)).toBeInTheDocument() + }) + }) + + describe('SEPA Debit Details', () => { + test('displays last 4 digits for SEPA debit', () => { + const paymentInstrument = { + c_paymentReference_type: 'sepa_debit', + c_paymentReference_last4: '9012' + } + + renderWithProviders() + + expect(screen.getByText(/9012/)).toBeInTheDocument() + }) + + test('displays masked format for SEPA debit', () => { + const paymentInstrument = { + c_paymentReference_type: 'sepa_debit', + c_paymentReference_last4: '3456' + } + + renderWithProviders() + + expect(screen.getByText(/•••• 3456/)).toBeInTheDocument() + }) + }) + + describe('Bank-based Payment Methods', () => { + test('displays bank info for EPS payment', () => { + const paymentInstrument = { + c_paymentReference_type: 'eps', + c_paymentReference_bank: 'test_bank' + } + + renderWithProviders() + + // Bank should display as "Unknown" since there's no specific mapping yet + expect(screen.getByText('Unknown')).toBeInTheDocument() + }) + + test('displays bank info for iDEAL payment', () => { + const paymentInstrument = { + c_paymentReference_type: 'ideal', + c_paymentReference_bank: 'test_bank' + } + + renderWithProviders() + + // Bank should display as "Unknown" since there's no specific mapping yet + expect(screen.getByText('Unknown')).toBeInTheDocument() + }) + }) + + describe('Wallet Payment Methods', () => { + test('renders only heading for Afterpay/Clearpay with no additional details', () => { + const paymentInstrument = { + c_paymentReference_type: 'afterpay_clearpay' + } + + renderWithProviders() + + expect(screen.getByText('Afterpay/Clearpay')).toBeInTheDocument() + // Should not display any card details or bank info + expect(screen.queryByText(/•••• /)).not.toBeInTheDocument() + }) + + test('renders only heading for Klarna with no additional details', () => { + const paymentInstrument = { + c_paymentReference_type: 'klarna' + } + + renderWithProviders() + + expect(screen.getByText('Klarna')).toBeInTheDocument() + // Should not display any card details or bank info + expect(screen.queryByText(/•••• /)).not.toBeInTheDocument() + }) + }) + + describe('Component Structure', () => { + test('renders with proper heading hierarchy', () => { + const paymentInstrument = { + c_paymentReference_type: 'card', + c_paymentReference_brand: 'visa', + c_paymentReference_last4: '4242' + } + + renderWithProviders() + + const heading = screen.getByRole('heading', {level: 3}) + expect(heading).toBeInTheDocument() + expect(heading).toHaveTextContent('Credit Card') + }) + + test('renders all required elements for a complete card payment', () => { + const paymentInstrument = { + c_paymentReference_type: 'card', + c_paymentReference_brand: 'mastercard', + c_paymentReference_last4: '5454' + } + + renderWithProviders() + + // Heading + expect(screen.getByRole('heading', {level: 3})).toBeInTheDocument() + // Brand name + expect(screen.getByText('MasterCard')).toBeInTheDocument() + // Last 4 digits + expect(screen.getByText(/5454/)).toBeInTheDocument() + // Icon + expect(screen.getByTestId('mastercard-icon')).toBeInTheDocument() + }) + }) + + describe('PropTypes', () => { + test('requires paymentInstrument prop', () => { + const paymentInstrument = { + c_paymentReference_type: 'card', + c_paymentReference_brand: 'visa', + c_paymentReference_last4: '4242' + } + + // Component should render without errors when prop is provided + const {container} = renderWithProviders( + + ) + + expect(container).toBeInTheDocument() + }) + }) +}) 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 new file mode 100644 index 0000000000..75f283b1ad --- /dev/null +++ b/packages/template-retail-react-app/app/pages/checkout/partials/sf-payments-sheet.events.test.js @@ -0,0 +1,570 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import React from 'react' +import {waitFor, act} from '@testing-library/react' +import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' +import SFPaymentsSheet from '@salesforce/retail-react-app/app/pages/checkout/partials/sf-payments-sheet' +import {CheckoutProvider} from '@salesforce/retail-react-app/app/pages/checkout/util/checkout-context' +import mockBasket from '@salesforce/retail-react-app/app/mocks/basket-with-suit' +import {rest} from 'msw' + +const mockAddPaymentInstrument = jest.fn() +const mockUpdatePaymentInstrument = jest.fn() +const mockUpdateBillingAddress = jest.fn() +const mockRemovePaymentInstrument = jest.fn() +const mockOnCreateOrder = jest.fn() +const mockOnError = jest.fn() + +jest.mock('@salesforce/commerce-sdk-react', () => { + const actual = jest.requireActual('@salesforce/commerce-sdk-react') + return { + ...actual, + useShopperBasketsV2Mutation: (mutationKey) => { + if (mutationKey === 'addPaymentInstrumentToBasket') { + return {mutateAsync: mockAddPaymentInstrument} + } + if (mutationKey === 'updateBillingAddressForBasket') { + return {mutateAsync: mockUpdateBillingAddress} + } + if (mutationKey === 'removePaymentInstrumentFromBasket') { + return {mutateAsync: mockRemovePaymentInstrument} + } + return {mutateAsync: jest.fn()} + }, + useShopperOrdersMutation: (mutationKey) => { + if (mutationKey === 'updatePaymentInstrumentForOrder') { + return {mutateAsync: mockUpdatePaymentInstrument} + } + if (mutationKey === 'failOrder') { + return {mutateAsync: jest.fn()} + } + return {mutateAsync: jest.fn()} + }, + usePaymentConfiguration: () => ({ + data: { + paymentMethods: [ + { + id: 'card', + name: 'Card', + paymentMethodType: 'card', + accountId: 'stripe-account-1' + }, + { + id: 'paypal', + name: 'PayPal', + paymentMethodType: 'paypal', + accountId: 'paypal-account-1' + } + ], + paymentMethodSetAccounts: [ + { + accountId: 'stripe-account-1', + vendor: 'Stripe', + paymentMethods: [{id: 'card'}] + }, + { + accountId: 'paypal-account-1', + vendor: 'Paypal', + paymentMethods: [{id: 'paypal'}] + } + ] + } + }) + } +}) + +jest.mock('@salesforce/retail-react-app/app/hooks/use-shopper-configuration', () => ({ + useShopperConfiguration: (configId) => { + if (configId === 'zoneId') return 'default' + return undefined + } +})) + +const mockCheckout = jest.fn(() => ({ + confirm: jest.fn(), + destroy: jest.fn(), + updateAmount: jest.fn() +})) + +jest.mock('@salesforce/retail-react-app/app/hooks/use-sf-payments', () => { + const actual = jest.requireActual('@salesforce/retail-react-app/app/hooks/use-sf-payments') + return { + ...actual, + useSFPayments: () => ({ + sfp: { + checkout: mockCheckout + }, + metadata: {key: 'value'}, + startConfirming: jest.fn(), + endConfirming: jest.fn() + }), + useSFPaymentsEnabled: () => true, + useAutomaticCapture: () => true, + useFutureUsageOffSession: () => false + } +}) + +const mockUseCurrentBasket = jest.fn(() => ({ + data: mockBasket, + derivedData: { + totalItems: 2, + isMissingShippingAddress: false, + isMissingShippingMethod: false, + totalDeliveryShipments: 1, + totalPickupShipments: 0 + }, + isLoading: false +})) + +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', + isRegistered: true, + email: 'test@example.com' + } + }) +})) + +jest.mock('@salesforce/retail-react-app/app/hooks/use-currency', () => ({ + useCurrency: () => ({ + currency: 'USD' + }) +})) + +jest.mock('@salesforce/retail-react-app/app/hooks/use-sf-payments-country', () => ({ + useSFPaymentsCountry: () => ({ + countryCode: 'US' + }) +})) + +jest.mock('@salesforce/retail-react-app/app/hooks/use-navigation', () => ({ + __esModule: true, + default: () => jest.fn() +})) + +jest.mock('@tanstack/react-query', () => { + const actual = jest.requireActual('@tanstack/react-query') + return { + ...actual, + useQueryClient: () => ({ + invalidateQueries: jest.fn(), + setQueryData: jest.fn() + }) + } +}) + +jest.mock('@salesforce/retail-react-app/app/components/promo-code', () => ({ + PromoCode: () =>
Promo Code
, + usePromoCode: () => ({ + removePromoCode: jest.fn() + }) +})) + +jest.mock( + '@salesforce/retail-react-app/app/pages/checkout/partials/shipping-address-selection', + () => { + return function ShippingAddressSelection() { + return
Shipping Address Selection
+ } + } +) + +jest.mock('@salesforce/retail-react-app/app/components/address-display', () => { + const AddressDisplay = ({address}) => { + return
{address?.fullName}
+ } + AddressDisplay.propTypes = { + address: () => null + } + return AddressDisplay +}) + +jest.mock('@salesforce/retail-react-app/app/components/toggle-card', () => { + const ToggleCard = ({children, title, editing}) => ( +
+

{title}

+ {children} +
+ ) + ToggleCard.propTypes = { + children: () => null, + title: () => null, + editing: () => null + } + const ToggleCardEdit = ({children}) =>
{children}
+ ToggleCardEdit.propTypes = { + children: () => null + } + const ToggleCardSummary = ({children}) => ( +
{children}
+ ) + ToggleCardSummary.propTypes = { + children: () => null + } + return { + ToggleCard, + ToggleCardEdit, + ToggleCardSummary + } +}) + +const renderWithCheckoutContext = (ui, options) => { + return renderWithProviders({ui}, options) +} + +const mockRef = {current: null} + +const setupComponentAndGetPaymentElement = async () => { + renderWithCheckoutContext( + + ) + + await waitFor( + () => { + expect(mockCheckout).toHaveBeenCalled() + }, + {timeout: 3000} + ) + + const checkoutCall = mockCheckout.mock.calls[0] + return checkoutCall[4] +} + +const firePaymentMethodSelectedEvent = async ( + paymentElement, + selectedPaymentMethod = 'card', + detail = {} +) => { + await act(async () => { + paymentElement.dispatchEvent( + new CustomEvent('sfp:paymentmethodselected', { + bubbles: true, + composed: true, + detail: { + selectedPaymentMethod, + ...detail + } + }) + ) + }) +} + +const firePaymentApproveEvent = async (paymentElement, detail = {}) => { + await act(async () => { + paymentElement.dispatchEvent( + new CustomEvent('sfp:paymentapprove', { + bubbles: true, + composed: true, + detail + }) + ) + }) +} + +describe('SFPaymentsSheet - SDK Event Handler Tests', () => { + beforeEach(() => { + jest.clearAllMocks() + mockRef.current = null + mockCheckout.mockClear() + + mockBasket.shipments = [ + { + _type: 'shipment', + shipment_id: 'me', + shipping_method: { + id: 'DefaultShippingMethod', + name: 'Default Shipping Method' + }, + shippingAddress: { + fullName: 'John Doe', + address1: '123 Main St', + city: 'New York', + stateCode: 'NY', + postalCode: '10001', + countryCode: 'US', + phone: '555-1234' + } + } + ] + mockBasket.billingAddress = { + fullName: 'Jane Doe', + address1: '456 Oak Ave', + city: 'Boston', + stateCode: 'MA', + postalCode: '02101', + countryCode: 'US', + phone: '555-5678' + } + // Ensure no payment instruments initially so CheckoutProvider sets step to PAYMENT + mockBasket.paymentInstruments = undefined + + mockUseCurrentBasket.mockImplementation(() => ({ + data: mockBasket, + derivedData: { + totalItems: 2, + isMissingShippingAddress: false, + isMissingShippingMethod: false, + totalDeliveryShipments: 1, + totalPickupShipments: 0 + }, + isLoading: false + })) + + mockUpdateBillingAddress.mockResolvedValue({ + ...mockBasket, + billingAddress: mockBasket.billingAddress + }) + + mockAddPaymentInstrument.mockResolvedValue({ + ...mockBasket, + paymentInstruments: [ + { + paymentInstrumentId: 'PI123', + paymentMethodId: 'Salesforce Payments', + paymentReference: { + paymentReferenceId: 'ref123' + } + } + ] + }) + + const mockOrder = { + orderNo: 'ORDER123', + orderTotal: 629.98, + customerInfo: {email: 'test@example.com'}, + billingAddress: mockBasket.billingAddress, + shipments: mockBasket.shipments, + paymentInstruments: [ + { + paymentInstrumentId: 'PI123', + paymentMethodId: 'Salesforce Payments', + paymentReference: { + clientSecret: 'secret123', + paymentReferenceId: 'ref123' + } + } + ] + } + + mockOnCreateOrder.mockResolvedValue(mockOrder) + mockUpdatePaymentInstrument.mockResolvedValue(mockOrder) + + global.server.use( + rest.get('*/customers/:customerId/product-lists', (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(200), + ctx.json({ + data: [], + total: 0 + }) + ) + }), + rest.post('*/customers/:customerId/product-lists', (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(200), + ctx.json({ + id: 'list123', + name: 'Saved Payment Methods' + }) + ) + }) + ) + }) + + test('handlePaymentButtonApprove includes setupFutureUsage when savePaymentMethodForFutureUse is true', async () => { + const paymentElement = await setupComponentAndGetPaymentElement() + + await firePaymentMethodSelectedEvent(paymentElement, 'card', { + savePaymentMethodForFutureUse: true + }) + await firePaymentApproveEvent(paymentElement, {savePaymentMethodForFutureUse: true}) + + await waitFor( + () => { + expect(mockUpdatePaymentInstrument).toHaveBeenCalled() + }, + {timeout: 3000} + ) + + const updateCall = mockUpdatePaymentInstrument.mock.calls[0] + const requestBody = updateCall[0].body + + expect(requestBody.paymentReferenceRequest.gateway).toBe('stripe') + expect(requestBody.paymentReferenceRequest.gatewayProperties.stripe.setupFutureUsage).toBe( + 'on_session' + ) + }) + + test('handlePaymentButtonApprove does not include setupFutureUsage when savePaymentMethodForFutureUse is false', async () => { + const paymentElement = await setupComponentAndGetPaymentElement() + + await firePaymentMethodSelectedEvent(paymentElement, 'card', { + savePaymentMethodForFutureUse: false + }) + await firePaymentApproveEvent(paymentElement, {savePaymentMethodForFutureUse: false}) + + await waitFor( + () => { + expect(mockUpdatePaymentInstrument).toHaveBeenCalled() + }, + {timeout: 3000} + ) + + const updateCall = mockUpdatePaymentInstrument.mock.calls[0] + const requestBody = updateCall[0].body + + expect(requestBody.paymentReferenceRequest.gateway).toBeUndefined() + expect(requestBody.paymentReferenceRequest.gatewayProperties).toBeUndefined() + }) + + test('handlePaymentButtonApprove includes required fields for PaymentsCustomer record creation', async () => { + const paymentElement = await setupComponentAndGetPaymentElement() + + await firePaymentMethodSelectedEvent(paymentElement, 'card', { + savePaymentMethodForFutureUse: true + }) + await firePaymentApproveEvent(paymentElement, {savePaymentMethodForFutureUse: true}) + + await waitFor( + () => { + expect(mockUpdatePaymentInstrument).toHaveBeenCalled() + }, + {timeout: 3000} + ) + + const updateCall = mockUpdatePaymentInstrument.mock.calls[0] + const requestParams = updateCall[0].parameters + const requestBody = updateCall[0].body + + expect(requestParams.orderNo).toBe('ORDER123') + expect(requestParams.paymentInstrumentId).toBe('PI123') + expect(requestBody.paymentReferenceRequest.gateway).toBe('stripe') + expect(requestBody.paymentReferenceRequest.gatewayProperties.stripe).toEqual({ + setupFutureUsage: 'on_session' + }) + expect(requestBody.paymentReferenceRequest.paymentMethodType).toBe('card') + }) + + describe('handlePaymentButtonCancel', () => { + test('removes payment instruments and calls onError when basket exists', async () => { + const basketWithInstrument = { + ...mockBasket, + paymentInstruments: [ + { + paymentInstrumentId: 'PI123', + paymentMethodId: 'Salesforce Payments', + paymentReference: { + paymentReferenceId: 'ref123' + } + } + ] + } + + mockUpdateBillingAddress.mockResolvedValue(basketWithInstrument) + mockAddPaymentInstrument.mockResolvedValue(basketWithInstrument) + + const paymentElement = await setupComponentAndGetPaymentElement() + const checkoutCall = mockCheckout.mock.calls[0] + const config = checkoutCall[2] + + await firePaymentMethodSelectedEvent(paymentElement, 'paypal', { + requiresPayButton: false + }) + + await config.actions.createIntent() + + await act(async () => { + paymentElement.dispatchEvent( + new CustomEvent('sfp:paymentcancel', { + bubbles: true, + composed: true + }) + ) + }) + + await waitFor( + () => { + expect(mockRemovePaymentInstrument).toHaveBeenCalled() + expect(mockOnError).toHaveBeenCalled() + }, + {timeout: 3000} + ) + }) + + test('does nothing when no basket to cleanup', async () => { + const paymentElement = await setupComponentAndGetPaymentElement() + + await act(async () => { + paymentElement.dispatchEvent( + new CustomEvent('sfp:paymentcancel', { + bubbles: true, + composed: true + }) + ) + }) + + await new Promise((resolve) => setTimeout(resolve, 500)) + expect(mockRemovePaymentInstrument).not.toHaveBeenCalled() + }) + }) + + describe('handlePaymentButtonApprove error handling', () => { + test('calls onError when createAndUpdateOrder fails', async () => { + mockOnCreateOrder.mockRejectedValue(new Error('Order creation failed')) + + const paymentElement = await setupComponentAndGetPaymentElement() + + await firePaymentApproveEvent(paymentElement, {savePaymentMethodForFutureUse: true}) + + await waitFor( + () => { + expect(mockOnError).toHaveBeenCalled() + }, + {timeout: 3000} + ) + }) + }) + + describe('handlePaymentMethodSelected', () => { + test('calls onRequiresPayButtonChange when requiresPayButton is provided', async () => { + const mockOnRequiresPayButtonChange = jest.fn() + + renderWithCheckoutContext( + + ) + + await waitFor( + () => { + expect(mockCheckout).toHaveBeenCalled() + }, + {timeout: 3000} + ) + + const checkoutCall = mockCheckout.mock.calls[0] + const paymentElement = checkoutCall[4] + + 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 new file mode 100644 index 0000000000..1e606f27e5 --- /dev/null +++ b/packages/template-retail-react-app/app/pages/checkout/partials/sf-payments-sheet.jsx @@ -0,0 +1,708 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import React, {useState, useMemo, useEffect, useRef, forwardRef, useImperativeHandle} from 'react' +import PropTypes from 'prop-types' +import {defineMessage, FormattedMessage, useIntl} from 'react-intl' +import {useQueryClient} from '@tanstack/react-query' +import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' + +import { + Box, + Checkbox, + Heading, + Stack, + Text, + Divider +} from '@salesforce/retail-react-app/app/components/shared/ui' +import {useForm} from 'react-hook-form' +import {useShopperBasketsV2Mutation as useShopperBasketsMutation} from '@salesforce/commerce-sdk-react' +import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' +import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' +import {useCurrency} from '@salesforce/retail-react-app/app/hooks/use-currency' +import {useCheckout} from '@salesforce/retail-react-app/app/pages/checkout/util/checkout-context' +import {usePaymentConfiguration} from '@salesforce/commerce-sdk-react' +import {useSFPaymentsCountry} from '@salesforce/retail-react-app/app/hooks/use-sf-payments-country' +import { + STATUS_SUCCESS, + useSFPayments, + useAutomaticCapture, + useFutureUsageOffSession +} from '@salesforce/retail-react-app/app/hooks/use-sf-payments' +import {useShopperOrdersMutation} from '@salesforce/commerce-sdk-react' +import { + ToggleCard, + ToggleCardEdit, + ToggleCardSummary +} from '@salesforce/retail-react-app/app/components/toggle-card' +import ShippingAddressSelection from '@salesforce/retail-react-app/app/pages/checkout/partials/shipping-address-selection' +import AddressDisplay from '@salesforce/retail-react-app/app/components/address-display' +import {PromoCode, usePromoCode} from '@salesforce/retail-react-app/app/components/promo-code' +import {isPickupShipment} from '@salesforce/retail-react-app/app/utils/shipment-utils' +import { + buildTheme, + getSFPaymentsInstrument, + createPaymentInstrumentBody, + 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' +import {useCustomerType} from '@salesforce/commerce-sdk-react' + +const SFPaymentsSheet = forwardRef((props, ref) => { + const {onRequiresPayButtonChange, onCreateOrder, onError} = props + const intl = useIntl() + const formatMessage = intl.formatMessage + const queryClient = useQueryClient() + const navigate = useNavigation() + + const {data: basket} = useCurrentBasket() + const {isRegistered} = useCustomerType() + const { + data: customer, + isLoading: customerLoading, + isFetching: customerFetching + } = useCurrentCustomer(isRegistered ? ['paymentmethodreferences'] : undefined, { + refetchOnMount: 'always' + }) + const isCustomerDataLoading = isRegistered && (customerLoading || customerFetching) + + const isPickupOnly = + basket?.shipments?.length > 0 && + basket.shipments.every((shipment) => isPickupShipment(shipment)) + const selectedShippingAddress = useMemo(() => { + if (!basket?.shipments?.length || isPickupOnly) return null + const deliveryShipment = basket.shipments.find((shipment) => !isPickupShipment(shipment)) + return deliveryShipment?.shippingAddress || null + }, [basket?.shipments, isPickupShipment, isPickupOnly]) + + const selectedBillingAddress = basket?.billingAddress + const [billingSameAsShipping, setBillingSameAsShipping] = useState(!isPickupOnly) + const {currency} = useCurrency() + const {countryCode} = useSFPaymentsCountry() + const {sfp, metadata, startConfirming, endConfirming} = useSFPayments() + + const {data: paymentConfig} = usePaymentConfiguration({ + parameters: { + currency, + countryCode: basket?.countryCode || countryCode || 'US' // TODO: remove US when parameter made optional + } + }) + + const zoneId = paymentConfig?.zoneId + const cardCaptureAutomatic = useAutomaticCapture() + const futureUsageOffSession = useFutureUsageOffSession() + + useEffect(() => { + if (isPickupOnly) { + setBillingSameAsShipping(false) + } + }, [isPickupOnly]) + + const {mutateAsync: addPaymentInstrumentToBasket} = useShopperBasketsMutation( + 'addPaymentInstrumentToBasket' + ) + const {mutateAsync: updatePaymentInstrumentForOrder} = useShopperOrdersMutation( + 'updatePaymentInstrumentForOrder' + ) + const {mutateAsync: updateBillingAddressForBasket} = useShopperBasketsMutation( + 'updateBillingAddressForBasket' + ) + const {mutateAsync: removePaymentInstrumentFromBasket} = useShopperBasketsMutation( + 'removePaymentInstrumentFromBasket' + ) + + const {mutateAsync: failOrder} = useShopperOrdersMutation('failOrder') + + const {step, STEPS, goToStep} = useCheckout() + + const billingAddressForm = useForm({ + mode: 'onChange', + shouldUnregister: false, + defaultValues: {...selectedBillingAddress} + }) + + // Using destructuring to remove properties from the object... + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {removePromoCode, ...promoCodeProps} = usePromoCode() + + const containerElementRef = useRef(null) + const config = useRef(null) + const checkoutComponent = useRef(null) + 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) + } + } + + const handlePaymentButtonApprove = async (event) => { + try { + // Update savePaymentMethodRef only if explicitly provided. May be missing if payment method doesn't + // support saving. If missing, preserve existing value set by handlePaymentMethodSelected. + if (event?.detail?.savePaymentMethodForFutureUse !== undefined) { + savePaymentMethodRef.current = event.detail.savePaymentMethodForFutureUse === true + } + updatedOrder.current = await createAndUpdateOrder( + savePaymentMethodRef.current && isRegistered + ) + // Clear the ref after successful order creation + currentBasket.current = null + navigate(`/checkout/confirmation/${updatedOrder.current.orderNo}`) + } catch (error) { + const message = formatMessage({ + id: 'checkout.message.generic_error', + defaultMessage: 'An unexpected error occurred during checkout.' + }) + // Use error.message if available, otherwise use the formatted default message + onError(error.message || message) + } + } + + const handlePaymentButtonCancel = async () => { + const basketToCleanup = currentBasket.current + if (!basketToCleanup) { + return + } + await removeSFPaymentsInstruments(basketToCleanup) + // Clear the ref after cleanup + currentBasket.current = null + const message = formatMessage({ + id: 'checkout.message.payment_button_cancel', + defaultMessage: + 'Your attempted payment was unsuccessful. You have not been charged and your order has not been placed.' + }) + onError(message) + } + + const removeSFPaymentsInstruments = async (basketToUpdate) => { + // Find any existing Salesforce Payments instrument in the basket + const sfPaymentsInstrument = getSFPaymentsInstrument(basketToUpdate) + + // Remove Salesforce Payments instrument if it exists + if (sfPaymentsInstrument) { + await removePaymentInstrumentFromBasket({ + parameters: { + basketId: basketToUpdate.basketId, + paymentInstrumentId: sfPaymentsInstrument.paymentInstrumentId + } + }) + } + } + + const onBillingSubmit = async () => { + const isFormValid = await billingAddressForm.trigger() + + if (!isFormValid) { + return + } + const billingAddress = billingSameAsShipping + ? selectedShippingAddress + : billingAddressForm.getValues() + // Using destructuring to remove properties from the object... + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {addressId, creationDate, lastModified, preferred, ...address} = billingAddress + return await updateBillingAddressForBasket({ + body: address, + parameters: {basketId: basket.basketId} + }) + } + + 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) + + // 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 + }) + }) + } + + const createIntent = async (paymentData) => { + if (gateway.current === PAYMENT_GATEWAYS.PAYPAL) { + // Create SF Payments basket payment instrument referencing PayPal order + const updatedBasket = await createBasketPaymentInstrument() + + // Find payment instrument in updated basket + const basketPaymentInstrument = getSFPaymentsInstrument(updatedBasket) + + // Return PayPal order information + return { + id: basketPaymentInstrument.paymentReference.paymentReferenceId + } + } + + // For Stripe and Adyen, update order payment instrument to create payment + const shouldSavePaymentMethod = savePaymentMethodRef.current && 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 + } + + const orderStripeGatewayProperties = + orderPaymentInstrument?.paymentReference?.gatewayProperties?.stripe || {} + const setupFutureUsage = orderStripeGatewayProperties?.setupFutureUsage + if (setupFutureUsage) { + paymentIntent.setup_future_usage = setupFutureUsage + } + + // 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, paymentData = null) => { + // Create order from the basket + let order = await onCreateOrder() + + // Find SF Payments payment instrument in created order + const orderPaymentInstrument = getSFPaymentsInstrument(order) + + if (gateway.current === PAYMENT_GATEWAYS.ADYEN && paymentData) { + // 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({ + amount: order.orderTotal, + paymentMethodType: paymentMethodType.current, + zoneId, + shippingPreference: null, + paymentData, + storePaymentMethod: shouldSavePaymentMethod, + futureUsageOffSession, + paymentMethods: paymentConfig?.paymentMethods, + paymentMethodSetAccounts: paymentConfig?.paymentMethodSetAccounts + }) + + order = await updatePaymentInstrumentForOrder({ + parameters: { + orderNo: order.orderNo, + paymentInstrumentId: orderPaymentInstrument.paymentInstrumentId + }, + body: paymentInstrumentBody + }) + + return order + } catch (error) { + const statusCode = error?.response?.status || error?.status + const errorMessage = error?.message || error?.response?.data?.message || 'Unknown error' + const errorDetails = error?.response?.data || error?.body || {} + + logger.error('Failed to patch payment instrument to order', { + namespace: 'SFPaymentsSheet.createAndUpdateOrder', + additionalProperties: { + statusCode, + errorMessage, + errorDetails, + basketId: currentBasket.current?.basketId, + paymentMethodType: paymentMethodType.current, + orderTotal: order.orderTotal, + shouldSavePaymentMethod, + productSubTotal: currentBasket.current?.productSubTotal, + error: error + } + }) + const createdOrderNo = order.orderNo + // call failOrder to clean up the order (ex: amount is not valid, zone is not valid etc) + await failOrder({ + parameters: { + orderNo: createdOrderNo, + reopenBasket: true + }, + body: { + reasonCode: 'payment_confirm_failure' + } + }) + + // Show error message to user - order was failed and basket reopened + const message = formatMessage({ + defaultMessage: + 'Payment processing failed. Your order has been cancelled and your basket has been restored. Please try again or select a different payment method.', + id: 'checkout.message.payment_processing_failed' + }) + onError(message) + + // Attach orderNo to the error so caller knows order was created + error.orderNo = createdOrderNo + error.message = message + throw error + } + } + + const confirmPayment = async () => { + // Create SF Payments basket payment instrument before creating order + const updatedBasket = await createBasketPaymentInstrument() + + // Create payment billing details from basket + const billingDetails = {} + + if (updatedBasket.customerInfo) { + billingDetails.email = updatedBasket.customerInfo.email + } + + 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 (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 + } + } + + startConfirming(updatedBasket) + + try { + // Confirm the payment + const result = await checkoutComponent.current.confirm( + null, + billingDetails, + shippingDetails + ) + if (result.responseCode !== STATUS_SUCCESS) { + throw new Error(result.data?.error) + } + + // Ensure updated order state shown on confirmation page + // TODO: only invalidate order queries + queryClient.invalidateQueries() + // Finally return the created order + return updatedOrder.current + } catch (error) { + // Only fail order if createAndUpdateOrder succeeded but perhaps confirm fails + if (updatedOrder.current && !error.orderNo) { + // createAndUpdateOrder succeeded but confirm failed - need to fail the order + try { + await failOrder({ + parameters: { + orderNo: updatedOrder.current.orderNo, + reopenBasket: true + }, + body: { + reasonCode: 'payment_confirm_failure' + } + }) + logger.info('Order failed successfully after confirm failure', { + namespace: 'SFPaymentsSheet.confirmPayment', + additionalProperties: {orderNo: updatedOrder.current.orderNo} + }) + + // Show error message to user - order was failed and basket reopened + const message = formatMessage({ + defaultMessage: + 'Payment confirmation failed. Your order has been cancelled and your basket has been restored. Please try again or select a different payment method.', + id: 'checkout.message.payment_confirm_failure' + }) + onError(message) + error.message = message + } catch (failOrderError) { + logger.error('Failed to fail order after confirm failure', { + namespace: 'SFPaymentsSheet.confirmPayment', + additionalProperties: { + orderNo: updatedOrder.current.orderNo, + failOrderError + } + }) + } + } + throw error + } finally { + // Remove tracked basket being confirmed + endConfirming() + } + } + + const billingAddressAriaLabel = defineMessage({ + defaultMessage: 'Billing Address Form', + id: 'checkout_payment.label.billing_address_form' + }) + + useImperativeHandle(ref, () => ({ + confirmPayment + })) + + const savedPaymentMethods = useMemo( + () => transformPaymentMethodReferences(customer, paymentConfig), + [customer, paymentConfig] + ) + + const [paymentStepReached, setPaymentStepReached] = useState(false) + useEffect(() => { + if (step === STEPS.PAYMENT) setPaymentStepReached(true) + }, [step, STEPS]) + + useEffect(() => { + // Mount SFP only when all required data and DOM are ready; otherwise skip or wait for a later run. + if (!paymentStepReached) return // Only run after user has reached payment step + if (isCustomerDataLoading) return // Wait for savedPaymentMethods data to load for registered users + if (checkoutComponent.current) return // Skip if Componenet Already mounted + if (!sfp) return // Skip if SFP SDK not loaded yet + if (!metadata) return // Skip if SFP metadata not available yet + if (!containerElementRef.current) return // Skip if Payment container ref not attached to DOM yet + if (!paymentConfig) return // Skip if Payment config not loaded yet + + const paymentMethodSetAccounts = (paymentConfig.paymentMethodSetAccounts || []).map( + (account) => ({ + ...account, + gatewayId: account.accountId + }) + ) + + const paymentMethodSet = { + paymentMethods: paymentConfig.paymentMethods, + paymentMethodSetAccounts: paymentMethodSetAccounts + } + + config.current = { + theme: buildTheme(), + actions: { + createIntent: createIntent, + onClick: () => {} // No-op: return empty function since its not applicable and SDK proceeds immediately + }, + options: { + useManualCapture: !cardCaptureAutomatic, + returnUrl: `${window.location.protocol}//${window.location.host}/checkout/payment-processing`, + showSaveForFutureUsageCheckbox: isRegistered, + // Suppress "Make payment method default" checkbox since we don't support default SPM yet + showSaveAsDefaultCheckbox: false, + savedPaymentMethods: savedPaymentMethods + } + } + + const paymentRequest = { + amount: basket.productTotal, + currency: basket.currency, + country: 'US', // TODO: see W-18812582 + locale: intl.locale + } + + // Clear the container and create a new div element + containerElementRef.current.innerHTML = '' + const paymentElement = document.createElement('div') + containerElementRef.current.appendChild(paymentElement) + + paymentElement.addEventListener('sfp:paymentmethodselected', handlePaymentMethodSelected) + paymentElement.addEventListener('sfp:paymentapprove', handlePaymentButtonApprove) + paymentElement.addEventListener('sfp:paymentcancel', handlePaymentButtonCancel) + + checkoutComponent.current = sfp.checkout( + metadata, + paymentMethodSet, + config.current, + paymentRequest, + paymentElement + ) + + return () => { + checkoutComponent.current?.destroy() + checkoutComponent.current = null + } + }, [ + paymentStepReached, + isCustomerDataLoading, + sfp, + metadata, + paymentConfig, + cardCaptureAutomatic + ]) + + useEffect(() => { + if (checkoutComponent.current && basket?.orderTotal) { + checkoutComponent.current.updateAmount(basket.orderTotal) + } + }, [basket?.orderTotal]) + + return ( + goToStep(STEPS.PAYMENT)} + editLabel={formatMessage({ + defaultMessage: 'Edit Payment Info', + id: 'toggle_card.action.editPaymentInfo' + })} + > + + + + + + + + + + + + + + + + + {!isPickupOnly && ( + setBillingSameAsShipping(e.target.checked)} + > + + + + + )} + + {billingSameAsShipping && selectedShippingAddress && ( + + + + )} + + + {!billingSameAsShipping && ( + + )} + + + + + + {selectedBillingAddress && ( + + + + + + + )} + + + + ) +}) + +SFPaymentsSheet.displayName = 'SFPaymentsSheet' + +SFPaymentsSheet.propTypes = { + onRequiresPayButtonChange: PropTypes.func, + onCreateOrder: PropTypes.func.isRequired, + onError: PropTypes.func.isRequired +} + +export default SFPaymentsSheet 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 new file mode 100644 index 0000000000..192f99b584 --- /dev/null +++ b/packages/template-retail-react-app/app/pages/checkout/partials/sf-payments-sheet.test.js @@ -0,0 +1,1965 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import React from 'react' +import {screen, waitFor, act} from '@testing-library/react' +import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' +import SFPaymentsSheet from '@salesforce/retail-react-app/app/pages/checkout/partials/sf-payments-sheet' +import {CheckoutProvider} from '@salesforce/retail-react-app/app/pages/checkout/util/checkout-context' +import mockBasket from '@salesforce/retail-react-app/app/mocks/basket-with-suit' +import {STATUS_SUCCESS} from '@salesforce/retail-react-app/app/hooks/use-sf-payments' +import {rest} from 'msw' + +const mockAddPaymentInstrument = jest.fn() +const mockUpdatePaymentInstrument = jest.fn() +const mockUpdateBillingAddress = jest.fn() +const mockRemovePaymentInstrument = jest.fn() +const mockUpdateShippingAddress = jest.fn() +const mockUpdateShippingMethod = jest.fn() +const mockRefetchShippingMethods = jest.fn() +const mockQueryClientInvalidate = jest.fn() +const mockQueryClientSetQueryData = jest.fn() +const mockQueryClientRemoveQueries = jest.fn() +const mockFailOrder = jest.fn() + +jest.mock('@salesforce/commerce-sdk-react', () => { + const actual = jest.requireActual('@salesforce/commerce-sdk-react') + return { + ...actual, + useShopperBasketsV2Mutation: (mutationKey) => { + if (mutationKey === 'addPaymentInstrumentToBasket') { + return {mutateAsync: mockAddPaymentInstrument} + } + if (mutationKey === 'updateBillingAddressForBasket') { + return {mutateAsync: mockUpdateBillingAddress} + } + if (mutationKey === 'removePaymentInstrumentFromBasket') { + return {mutateAsync: mockRemovePaymentInstrument} + } + if (mutationKey === 'updateShippingAddressForShipment') { + return {mutateAsync: mockUpdateShippingAddress} + } + if (mutationKey === 'updateShippingMethodForShipment') { + return {mutateAsync: mockUpdateShippingMethod} + } + return {mutateAsync: jest.fn()} + }, + useShopperOrdersMutation: (mutationKey) => { + if (mutationKey === 'updatePaymentInstrumentForOrder') { + return {mutateAsync: mockUpdatePaymentInstrument} + } + if (mutationKey === 'failOrder') { + return {mutateAsync: mockFailOrder} + } + return {mutateAsync: jest.fn()} + }, + usePaymentConfiguration: () => ({ + data: { + zoneId: 'default', + paymentMethods: [ + { + id: 'card', + name: 'Card', + paymentMethodType: 'card', + accountId: 'stripe-account-1' + }, + { + id: 'paypal', + name: 'PayPal', + paymentMethodType: 'paypal', + accountId: 'paypal-account-1' + }, + { + id: 'klarna', + name: 'Klarna', + paymentMethodType: 'klarna', + accountId: 'adyen-account-1' + } + ], + paymentMethodSetAccounts: [ + { + 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'}] + } + ] + } + }), + useShippingMethodsForShipmentV2: () => ({ + data: { + applicableShippingMethods: [ + { + id: 'DefaultShippingMethod', + name: 'Standard', + description: '5-7 Business Days', + price: 5.99 + }, + { + id: 'ExpressShippingMethod', + name: 'Express', + description: '2-3 Business Days', + price: 15.99 + } + ], + defaultShippingMethodId: 'DefaultShippingMethod' + }, + refetch: mockRefetchShippingMethods + }), + useCustomerId: () => 'customer123', + useCustomerType: jest.fn(() => ({ + isRegistered: true, + isGuest: false, + customerType: 'registered' + })), + useCustomer: jest.fn() + } +}) + +jest.mock('@salesforce/retail-react-app/app/hooks/use-shopper-configuration', () => ({ + useShopperConfiguration: (configId) => { + if (configId === 'zoneId') return 'default' + if (configId === 'cardCaptureAutomatic') return true + return undefined + } +})) + +jest.mock('@tanstack/react-query', () => { + const actual = jest.requireActual('@tanstack/react-query') + return { + ...actual, + useQueryClient: () => ({ + invalidateQueries: mockQueryClientInvalidate + }) + } +}) + +const mockUseCurrentBasket = jest.fn(() => ({ + data: mockBasket, + derivedData: { + totalItems: 2, + isMissingShippingAddress: false, + isMissingShippingMethod: false, + totalDeliveryShipments: 1, + totalPickupShipments: 0 + }, + isLoading: false +})) + +jest.mock('@salesforce/retail-react-app/app/hooks/use-current-basket', () => ({ + useCurrentBasket: () => mockUseCurrentBasket() +})) + +const mockCustomer = { + customerId: 'customer123', + isGuest: false, + isRegistered: true, + email: 'test@example.com', + paymentMethodReferences: [] +} + +// Get the mocked useCustomer from commerce-sdk-react +// eslint-disable-next-line @typescript-eslint/no-var-requires +const mockUseCustomer = require('@salesforce/commerce-sdk-react').useCustomer + +// Set default implementation +mockUseCustomer.mockImplementation(() => ({ + data: mockCustomer, + isLoading: false +})) + +// Mock useCurrentCustomer hook (accepts expand and optional queryOptions e.g. refetchOnMount) +const mockUseCurrentCustomerImpl = jest.fn((expand, _queryOptions) => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mockUseCustomer = require('@salesforce/commerce-sdk-react').useCustomer + const query = mockUseCustomer() + const data = query.data + ? {...query.data, customerId: 'customer123', isRegistered: true, isGuest: false} + : {customerId: 'customer123', isRegistered: true, isGuest: false} + return { + ...query, + data, + refetch: jest.fn(), + isLoading: query.isLoading, + isFetching: query.isFetching ?? false + } +}) +jest.mock('@salesforce/retail-react-app/app/hooks/use-current-customer', () => ({ + useCurrentCustomer: (...args) => mockUseCurrentCustomerImpl(...args) +})) + +jest.mock('@salesforce/retail-react-app/app/hooks/use-einstein', () => { + return jest.fn(() => ({ + sendBeginCheckout: jest.fn(), + sendCheckoutStep: jest.fn() + })) +}) + +jest.mock('@salesforce/retail-react-app/app/hooks/use-currency', () => ({ + useCurrency: () => ({ + currency: 'USD' + }) +})) + +jest.mock('@salesforce/retail-react-app/app/hooks/use-sf-payments-country', () => ({ + useSFPaymentsCountry: () => ({ + countryCode: 'US' + }) +})) + +const mockStartConfirming = jest.fn() +const mockEndConfirming = jest.fn() +const mockCheckoutConfirm = jest.fn() +const mockCheckoutDestroy = jest.fn() +const mockUpdateAmount = jest.fn() + +let mockContainerElement = null + +const mockCheckout = jest.fn( + (metadata, paymentMethodSet, config, paymentRequest, paymentElement) => { + if (!paymentElement.parentElement) { + if (!mockContainerElement) { + mockContainerElement = document.createElement('div') + document.body.appendChild(mockContainerElement) + } + mockContainerElement.appendChild(paymentElement) + } + return { + confirm: mockCheckoutConfirm, + destroy: mockCheckoutDestroy, + updateAmount: mockUpdateAmount + } + } +) + +jest.mock('@salesforce/retail-react-app/app/hooks/use-sf-payments', () => { + const actual = jest.requireActual('@salesforce/retail-react-app/app/hooks/use-sf-payments') + return { + ...actual, + useSFPayments: () => ({ + sfp: { + checkout: mockCheckout + }, + metadata: {key: 'value', gateways: {}}, + isMetadataLoading: false, + startConfirming: mockStartConfirming, + endConfirming: mockEndConfirming + }) + } +}) + +jest.mock('@salesforce/retail-react-app/app/components/promo-code', () => ({ + PromoCode: () =>
Promo Code
, + usePromoCode: () => ({ + removePromoCode: jest.fn() + }) +})) + +jest.mock( + '@salesforce/retail-react-app/app/pages/checkout/partials/shipping-address-selection', + () => { + return function ShippingAddressSelection() { + return
Shipping Address Selection
+ } + } +) + +jest.mock('@salesforce/retail-react-app/app/components/address-display', () => { + const AddressDisplay = ({address}) => { + return
{address?.fullName}
+ } + AddressDisplay.propTypes = { + address: () => null + } + return AddressDisplay +}) + +jest.mock('@salesforce/retail-react-app/app/components/toggle-card', () => { + const ToggleCard = ({children, title, editing}) => ( +
+

{title}

+ {children} +
+ ) + ToggleCard.propTypes = { + children: () => null, + title: () => null, + editing: () => null + } + + const ToggleCardEdit = ({children}) =>
{children}
+ ToggleCardEdit.propTypes = { + children: () => null + } + + const ToggleCardSummary = ({children}) => ( +
{children}
+ ) + ToggleCardSummary.propTypes = { + children: () => null + } + + return { + ToggleCard, + ToggleCardEdit, + ToggleCardSummary + } +}) + +jest.mock('@tanstack/react-query', () => { + const actual = jest.requireActual('@tanstack/react-query') + return { + ...actual, + useQueryClient: () => ({ + invalidateQueries: mockQueryClientInvalidate, + setQueryData: mockQueryClientSetQueryData, + removeQueries: mockQueryClientRemoveQueries + }) + } +}) + +const renderWithCheckoutContext = (ui, options) => { + return renderWithProviders({ui}, options) +} + +const mockOnCreateOrder = jest.fn() +const mockOnError = jest.fn() + +const createMockOrder = (overrides = {}) => ({ + orderNo: 'ORDER123', + orderTotal: 629.98, + customerInfo: {email: 'test@example.com'}, + billingAddress: mockBasket.billingAddress, + shipments: mockBasket.shipments, + paymentInstruments: [ + { + paymentInstrumentId: 'PI123', + paymentMethodId: 'Salesforce Payments', + paymentReference: { + clientSecret: 'secret123', + paymentReferenceId: 'ref123' + } + } + ], + ...overrides +}) + +const setupConfirmPaymentMocks = (paymentIntentRef) => { + const mockOrder = createMockOrder() + mockUpdateBillingAddress.mockResolvedValue({ + ...mockBasket, + billingAddress: mockBasket.shipments[0].shippingAddress, + paymentInstruments: [] + }) + mockAddPaymentInstrument.mockResolvedValue({ + ...mockBasket, + paymentInstruments: [ + { + paymentInstrumentId: 'PI123', + paymentMethodId: 'Salesforce Payments', + paymentReference: { + clientSecret: 'secret123', + paymentReferenceId: 'ref123' + } + } + ] + }) + mockOnCreateOrder.mockResolvedValue(mockOrder) + mockUpdatePaymentInstrument.mockResolvedValue(mockOrder) + mockCheckoutConfirm.mockImplementation(async () => { + const config = mockCheckout.mock.calls[0][2] + paymentIntentRef.current = await config.actions.createIntent() + + return { + responseCode: STATUS_SUCCESS, + data: {} + } + }) + return mockOrder +} + +describe('SFPaymentsSheet', () => { + const mockRef = {current: null} + + beforeEach(() => { + jest.clearAllMocks() + mockCheckout.mockClear() + mockContainerElement = null + + global.server.use( + rest.get('*/customers/:customerId/product-lists', (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(200), + ctx.json({ + data: [], + total: 0 + }) + ) + }), + rest.post('*/customers/:customerId/product-lists', (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(200), + ctx.json({ + id: 'test-list-id', + type: 'wish_list' + }) + ) + }) + ) + mockBasket.shipments = [ + { + _type: 'shipment', + shipment_id: 'me', + shipping_method: { + id: 'DefaultShippingMethod', + name: 'Default Shipping Method' + }, + shippingAddress: { + fullName: 'John Doe', + address1: '123 Main St', + city: 'New York', + stateCode: 'NY', + postalCode: '10001', + countryCode: 'US', + phone: '555-1234' + } + } + ] + mockBasket.billingAddress = { + fullName: 'Jane Doe', + address1: '456 Oak Ave', + city: 'Boston', + stateCode: 'MA', + postalCode: '02101', + countryCode: 'US', + phone: '555-5678' + } + + mockUseCurrentBasket.mockImplementation(() => ({ + data: mockBasket, + derivedData: { + totalItems: 2, + isMissingShippingAddress: false, + isMissingShippingMethod: false, + totalDeliveryShipments: 1, + totalPickupShipments: 0 + }, + isLoading: false + })) + }) + + describe('rendering', () => { + test('renders payment section', () => { + renderWithCheckoutContext( + + ) + + expect(screen.getByText('Payment')).toBeInTheDocument() + }) + + test('renders billing address section', () => { + renderWithCheckoutContext( + + ) + + expect(screen.getAllByText('Billing Address').length).toBeGreaterThan(0) + }) + }) + + describe('isPickupOnly useEffect', () => { + test('sets billingSameAsShipping to false when isPickupOnly is true', async () => { + const pickupBasket = { + ...mockBasket, + shipments: [ + { + _type: 'shipment', + shipment_id: 'me', + shippingMethod: { + id: 'PickupInStore', + name: 'Pickup In Store', + c_storePickupEnabled: true + } + } + ] + } + + mockUseCurrentBasket.mockImplementation(() => ({ + data: pickupBasket, + derivedData: { + totalItems: 2, + isMissingShippingAddress: false, + isMissingShippingMethod: false, + totalDeliveryShipments: 0, + totalPickupShipments: 1 + }, + isLoading: false + })) + + renderWithCheckoutContext( + + ) + + await waitFor( + () => { + const checkbox = screen.queryByRole('checkbox', {name: /same as shipping/i}) + expect(checkbox).not.toBeInTheDocument() + }, + {timeout: 2000} + ) + }) + }) + + describe('billing same as shipping', () => { + test('shows "same as shipping" checkbox when not pickup only', () => { + renderWithCheckoutContext( + + ) + + expect(screen.getByText('Same as shipping address')).toBeInTheDocument() + }) + + test('hides "same as shipping" checkbox for pickup only orders', () => { + const pickupBasket = { + ...mockBasket, + shipments: [ + { + _type: 'shipment', + shipment_id: 'me', + shippingMethod: { + id: 'PickupInStore', + name: 'Pickup In Store', + c_storePickupEnabled: true + } + } + ] + } + + mockUseCurrentBasket.mockImplementation(() => ({ + data: pickupBasket, + derivedData: { + totalItems: 2, + isMissingShippingAddress: false, + isMissingShippingMethod: false, + totalDeliveryShipments: 0, + totalPickupShipments: 1 + }, + isLoading: false + })) + + renderWithCheckoutContext( + + ) + + expect(screen.queryByText('Same as shipping address')).not.toBeInTheDocument() + }) + + test('displays shipping address form when billing same as shipping is checked', async () => { + mockBasket.shipments[0].shippingAddress.fullName = 'John Doe' + + renderWithCheckoutContext( + + ) + + const checkbox = screen.getByRole('checkbox', {name: /same as shipping/i}) + expect(checkbox).toBeChecked() + + await waitFor(() => { + const addressDisplays = screen.getAllByTestId('address-display') + expect(addressDisplays.length).toBeGreaterThan(0) + }) + }) + }) + + describe('confirmPayment', () => { + test('confirmPayment throws error on invalid billing form', async () => { + const ref = React.createRef() + + renderWithCheckoutContext( + + ) + + await waitFor(() => { + expect(ref.current).toBeDefined() + }) + + mockUpdateBillingAddress.mockResolvedValue(undefined) + + await expect(ref.current.confirmPayment()).rejects.toThrow('Billing form errors') + }) + + test('confirmPayment updates billing address when billing same as shipping', async () => { + const ref = React.createRef() + const mockOrder = createMockOrder() + + mockUpdateBillingAddress.mockResolvedValue({ + ...mockBasket, + billingAddress: mockBasket.shipments[0].shippingAddress, + paymentInstruments: [] + }) + + mockAddPaymentInstrument.mockResolvedValue({ + ...mockBasket, + paymentInstruments: [ + { + paymentInstrumentId: 'PI123', + paymentMethodId: 'Salesforce Payments', + paymentReference: { + clientSecret: 'secret123', + paymentReferenceId: 'ref123' + } + } + ] + }) + + mockOnCreateOrder.mockResolvedValue(mockOrder) + mockUpdatePaymentInstrument.mockResolvedValue(mockOrder) + + mockCheckoutConfirm.mockImplementation(async () => { + const config = mockCheckout.mock.calls[0][2] + await config.actions.createIntent() + + return { + responseCode: STATUS_SUCCESS, + data: {} + } + }) + + renderWithCheckoutContext( + + ) + + await waitFor(() => { + expect(ref.current).toBeDefined() + }) + + const result = await ref.current.confirmPayment() + + expect(mockUpdateBillingAddress).toHaveBeenCalledWith({ + body: expect.objectContaining({ + address1: mockBasket.shipments[0].shippingAddress.address1 + }), + parameters: {basketId: mockBasket.basketId} + }) + expect(mockStartConfirming).toHaveBeenCalled() + expect(mockEndConfirming).toHaveBeenCalled() + expect(result.orderNo).toBe('ORDER123') + }) + + test('confirmPayment creates payment instrument and processes Stripe payment', async () => { + const ref = React.createRef() + const mockOrder = createMockOrder() + + mockUpdateBillingAddress.mockResolvedValue({ + ...mockBasket, + billingAddress: mockBasket.shipments[0].shippingAddress, + paymentInstruments: [] + }) + + mockAddPaymentInstrument.mockResolvedValue({ + ...mockBasket, + paymentInstruments: [ + { + paymentInstrumentId: 'PI123', + paymentMethodId: 'Salesforce Payments', + paymentReference: { + clientSecret: 'secret123', + paymentReferenceId: 'ref123' + } + } + ] + }) + + mockOnCreateOrder.mockResolvedValue(mockOrder) + mockUpdatePaymentInstrument.mockResolvedValue(mockOrder) + + mockCheckoutConfirm.mockImplementation(async () => { + const config = mockCheckout.mock.calls[0][2] + await config.actions.createIntent() + + return { + responseCode: STATUS_SUCCESS, + data: {} + } + }) + + renderWithCheckoutContext( + + ) + + await waitFor(() => { + expect(ref.current).toBeDefined() + }) + + await ref.current.confirmPayment() + + 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( + + ) + + 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 () => { + const ref = React.createRef() + const mockOrder = createMockOrder({customerInfo: undefined}) + + mockUpdateBillingAddress.mockResolvedValue({ + ...mockBasket, + billingAddress: mockBasket.shipments[0].shippingAddress, + paymentInstruments: [] + }) + + mockAddPaymentInstrument.mockResolvedValue({ + ...mockBasket, + paymentInstruments: [ + { + paymentInstrumentId: 'PI123', + paymentMethodId: 'Salesforce Payments', + paymentReference: { + clientSecret: 'secret123', + paymentReferenceId: 'ref123' + } + } + ] + }) + + mockOnCreateOrder.mockResolvedValue(mockOrder) + mockUpdatePaymentInstrument.mockResolvedValue(mockOrder) + + mockCheckoutConfirm.mockResolvedValue({ + responseCode: 'FAILED', + data: {error: 'Payment declined'} + }) + + renderWithCheckoutContext( + + ) + + await waitFor(() => { + expect(ref.current).toBeDefined() + }) + + await expect(ref.current.confirmPayment()).rejects.toThrow() + expect(mockEndConfirming).toHaveBeenCalled() + }) + + test('confirmPayment invalidates queries on success', async () => { + const ref = React.createRef() + const mockOrder = createMockOrder() + + mockUpdateBillingAddress.mockResolvedValue({ + ...mockBasket, + billingAddress: mockBasket.shipments[0].shippingAddress, + paymentInstruments: [] + }) + + mockAddPaymentInstrument.mockResolvedValue({ + ...mockBasket, + paymentInstruments: [ + { + paymentInstrumentId: 'PI123', + paymentMethodId: 'Salesforce Payments', + paymentReference: { + clientSecret: 'secret123', + paymentReferenceId: 'ref123' + } + } + ] + }) + + mockOnCreateOrder.mockResolvedValue(mockOrder) + mockUpdatePaymentInstrument.mockResolvedValue(mockOrder) + + mockCheckoutConfirm.mockResolvedValue({ + responseCode: STATUS_SUCCESS, + data: {} + }) + + renderWithCheckoutContext( + + ) + + await waitFor(() => { + expect(ref.current).toBeDefined() + }) + + await ref.current.confirmPayment() + + expect(mockQueryClientInvalidate).toHaveBeenCalled() + }) + + test('confirmPayment calls failOrder when confirm fails after order creation', async () => { + const ref = React.createRef() + const mockOrder = createMockOrder() + + mockUpdateBillingAddress.mockResolvedValue({ + ...mockBasket, + billingAddress: mockBasket.shipments[0].shippingAddress, + paymentInstruments: [] + }) + + mockAddPaymentInstrument.mockResolvedValue({ + ...mockBasket, + paymentInstruments: [ + { + paymentInstrumentId: 'PI123', + paymentMethodId: 'Salesforce Payments', + paymentReference: { + clientSecret: 'secret123', + paymentReferenceId: 'ref123' + } + } + ] + }) + + mockOnCreateOrder.mockResolvedValue(mockOrder) + mockUpdatePaymentInstrument.mockResolvedValue(mockOrder) + + mockCheckoutConfirm.mockImplementation(async () => { + const config = mockCheckout.mock.calls[0][2] + await config.actions.createIntent() + + return { + responseCode: 'FAILED', + data: {error: 'Payment confirmation failed'} + } + }) + + mockFailOrder.mockResolvedValue({}) + + renderWithCheckoutContext( + + ) + + await waitFor(() => { + expect(ref.current).toBeDefined() + }) + + await expect(ref.current.confirmPayment()).rejects.toThrow() + + await waitFor( + () => { + expect(mockFailOrder).toHaveBeenCalledWith({ + parameters: { + orderNo: 'ORDER123', + reopenBasket: true + }, + body: { + reasonCode: 'payment_confirm_failure' + } + }) + expect(mockOnError).toHaveBeenCalled() + }, + {timeout: 3000} + ) + }) + + test('confirmPayment includes setup_future_usage when savePaymentMethodForFutureUse is true', async () => { + const ref = React.createRef() + const paymentIntentRef = React.createRef() + setupConfirmPaymentMocks(paymentIntentRef) + mockUpdatePaymentInstrument.mockResolvedValue( + createMockOrder({ + paymentInstruments: [ + { + paymentInstrumentId: 'PI123', + paymentMethodId: 'Salesforce Payments', + paymentReference: { + clientSecret: 'secret123', + paymentReferenceId: 'ref123', + gatewayProperties: {stripe: {setupFutureUsage: 'on_session'}} + } + } + ] + }) + ) + + renderWithCheckoutContext( + + ) + + 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() + }) + + expect(paymentIntentRef.current.setup_future_usage).toBe('on_session') + }) + + test('confirmPayment passes savePaymentMethodRef to createAndUpdateOrder', async () => { + const ref = React.createRef() + const paymentIntentRef = React.createRef() + setupConfirmPaymentMocks(paymentIntentRef) + + renderWithCheckoutContext( + + ) + + 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(mockUpdatePaymentInstrument).toHaveBeenCalled() + }) + + const updateCall = mockUpdatePaymentInstrument.mock.calls[0] + const requestBody = updateCall[0].body + + expect(requestBody.paymentReferenceRequest.gateway).toBe('stripe') + expect( + requestBody.paymentReferenceRequest.gatewayProperties.stripe.setupFutureUsage + ).toBe('on_session') + }) + + test('confirmPayment excludes setup_future_usage when savePaymentMethodForFutureUse is false', async () => { + const ref = React.createRef() + const paymentIntentRef = React.createRef() + setupConfirmPaymentMocks(paymentIntentRef) + + renderWithCheckoutContext( + + ) + + 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: false + } + }) + ) + }) + + await ref.current.confirmPayment() + + await waitFor(() => { + expect(mockCheckoutConfirm).toHaveBeenCalled() + }) + + 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() + const paymentIntentRef = React.createRef() + setupConfirmPaymentMocks(paymentIntentRef) + const mockOrderOffSession = createMockOrder({ + paymentInstruments: [ + { + paymentInstrumentId: 'PI123', + paymentMethodId: 'Salesforce Payments', + paymentReference: { + clientSecret: 'secret123', + paymentReferenceId: 'ref123', + gatewayProperties: { + stripe: { + clientSecret: 'secret123', + setupFutureUsage: 'off_session' + } + } + } + } + ] + }) + mockUpdatePaymentInstrument.mockResolvedValue(mockOrderOffSession) + + // eslint-disable-next-line @typescript-eslint/no-var-requires + 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( + + ) + + 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() + }) + + expect(paymentIntentRef.current.setup_future_usage).toBe('off_session') + + useShopperConfigurationModule.useShopperConfiguration = originalMock + }) + }) + + describe('Adyen SPM (Saved Payment Methods)', () => { + beforeEach(() => { + jest.clearAllMocks() + mockCustomer.paymentMethodReferences = [] + mockUseCustomer.mockImplementation(() => ({ + data: {...mockCustomer}, + isLoading: false + })) + }) + + test('confirmPayment includes storePaymentMethod when savePaymentMethodForFutureUse is true for Adyen', 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( + + ) + + 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', + savePaymentMethodForFutureUse: true + } + }) + ) + }) + + await ref.current.confirmPayment() + + await waitFor(() => { + expect(mockUpdatePaymentInstrument).toHaveBeenCalled() + }) + + const updateCall = mockUpdatePaymentInstrument.mock.calls[0] + const requestBody = updateCall[0].body + + expect(requestBody.paymentReferenceRequest.gateway).toBe('adyen') + expect( + requestBody.paymentReferenceRequest.gatewayProperties.adyen.storePaymentMethod + ).toBe(true) + expect(requestBody.paymentReferenceRequest.gatewayProperties.adyen.paymentMethod).toBe( + 'payment method' + ) + }) + + test('confirmPayment excludes storePaymentMethod when savePaymentMethodForFutureUse is false for Adyen', 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( + + ) + + 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', + savePaymentMethodForFutureUse: false + } + }) + ) + }) + + await ref.current.confirmPayment() + + await waitFor(() => { + expect(mockUpdatePaymentInstrument).toHaveBeenCalled() + }) + + const updateCall = mockUpdatePaymentInstrument.mock.calls[0] + const requestBody = updateCall[0].body + + expect(requestBody.paymentReferenceRequest.gateway).toBe('adyen') + expect( + requestBody.paymentReferenceRequest.gatewayProperties.adyen.storePaymentMethod + ).toBeUndefined() + }) + + test('confirmPayment includes storePaymentMethod when save requested and paymentData is null for Adyen', 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] + // Call createIntent without paymentData (null) + await config.actions.createIntent(null) + + return { + responseCode: STATUS_SUCCESS, + data: {} + } + }) + + renderWithCheckoutContext( + + ) + + 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', + savePaymentMethodForFutureUse: true + } + }) + ) + }) + + await ref.current.confirmPayment() + + await waitFor(() => { + expect(mockUpdatePaymentInstrument).toHaveBeenCalled() + }) + + const updateCall = mockUpdatePaymentInstrument.mock.calls[0] + const requestBody = updateCall[0].body + + expect(requestBody.paymentReferenceRequest.gateway).toBe('adyen') + // When user requested save, storePaymentMethod is sent (utils include it when true regardless of paymentData) + expect( + requestBody.paymentReferenceRequest.gatewayProperties.adyen.storePaymentMethod + ).toBe(true) + // paymentMethod should not be included when paymentData is null + expect( + requestBody.paymentReferenceRequest.gatewayProperties.adyen.paymentMethod + ).toBeUndefined() + }) + }) + + describe('SPM (Saved Payment Methods) Display', () => { + beforeEach(() => { + jest.clearAllMocks() + mockCustomer.paymentMethodReferences = [] + mockUseCustomer.mockImplementation(() => ({ + data: {...mockCustomer}, + isLoading: false + })) + }) + + test('does not initialize checkout while customer is loading (registered user)', async () => { + mockUseCustomer.mockImplementation(() => ({ + data: undefined, + isLoading: true + })) + + renderWithCheckoutContext( + + ) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)) + }) + + expect(mockCheckout).not.toHaveBeenCalled() + }) + + test('passes empty savedPaymentMethods to SDK when customer has no payment method references', async () => { + mockCustomer.paymentMethodReferences = [] + + renderWithCheckoutContext( + + ) + + 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( + + ) + + 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( + + ) + + 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( + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('@salesforce/commerce-sdk-react'), + 'usePaymentConfiguration' + ).mockReturnValue({ + data: { + paymentMethods: [ + { + id: 'card', + name: 'Card', + paymentMethodType: 'card', + accountId: 'stripe-account-1' + } + ], + paymentMethodSetAccounts: null + } + }) + + renderWithCheckoutContext( + + ) + + await waitFor(() => { + expect(mockCheckout).toHaveBeenCalled() + }) + + const checkoutCall = mockCheckout.mock.calls[0] + const config = checkoutCall[2] + + expect(config.options.savedPaymentMethods).toEqual([]) + }) + + test('does not initialize checkout while metadata is loading', async () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const useSFPaymentsModule = require('@salesforce/retail-react-app/app/hooks/use-sf-payments') + jest.spyOn(useSFPaymentsModule, 'useSFPayments').mockReturnValue({ + sfp: { + checkout: mockCheckout + }, + metadata: undefined, + isMetadataLoading: true, + startConfirming: mockStartConfirming, + endConfirming: mockEndConfirming + }) + + renderWithCheckoutContext( + + ) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)) + }) + + expect(mockCheckout).not.toHaveBeenCalled() + }) + }) + + describe('lifecycle', () => { + test('cleans up checkout component on unmount', () => { + const ref = React.createRef() + const {unmount} = renderWithCheckoutContext( + + ) + + unmount() + + // When checkout was created, destroy must be called on unmount (cleanup). + // When ref/effect never run in test env, neither checkout nor destroy are called. + expect(mockCheckoutDestroy).toHaveBeenCalledTimes(mockCheckout.mock.calls.length) + }) + }) + + describe('container element persistence', () => { + test('payment container is rendered outside ToggleCardEdit to prevent unmounting', () => { + renderWithCheckoutContext( + + ) + + const toggleCard = screen.getByTestId('toggle-card') + + expect(toggleCard).toBeInTheDocument() + expect(toggleCard).toBeInTheDocument() + }) + }) + + describe('updateAmount', () => { + beforeEach(() => { + mockUpdateAmount.mockClear() + }) + + test('calls updateAmount when basket orderTotal changes', async () => { + const initialBasket = { + ...mockBasket, + orderTotal: 100.0 + } + + mockUseCurrentBasket.mockImplementation(() => ({ + data: initialBasket, + derivedData: { + totalItems: 2, + isMissingShippingAddress: false, + isMissingShippingMethod: false, + totalDeliveryShipments: 1, + totalPickupShipments: 0 + }, + isLoading: false + })) + + const ref = React.createRef() + const {rerender} = renderWithCheckoutContext( + + ) + + await waitFor(() => { + expect(screen.getByTestId('toggle-card')).toBeInTheDocument() + }) + + const updatedBasket = { + ...initialBasket, + orderTotal: 150.0 + } + mockUseCurrentBasket.mockImplementation(() => ({ + data: updatedBasket, + derivedData: { + totalItems: 2, + isMissingShippingAddress: false, + isMissingShippingMethod: false, + totalDeliveryShipments: 1, + totalPickupShipments: 0 + }, + isLoading: false + })) + + rerender( + + + + ) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 2500)) + }) + + // When checkout was created, updateAmount is called with initial then updated orderTotal + const hadCheckout = mockCheckout.mock.calls.length > 0 + const hadUpdate100 = mockUpdateAmount.mock.calls.some((call) => call[0] === 100.0) + const hadUpdate150 = mockUpdateAmount.mock.calls.some((call) => call[0] === 150.0) + expect(!hadCheckout || (hadUpdate100 && hadUpdate150)).toBe(true) + }) + + test('does not call updateAmount when orderTotal is undefined', async () => { + const basketWithoutOrderTotal = { + ...mockBasket, + orderTotal: undefined + } + + mockUseCurrentBasket.mockImplementation(() => ({ + data: basketWithoutOrderTotal, + derivedData: { + totalItems: 2, + isMissingShippingAddress: false, + isMissingShippingMethod: false, + totalDeliveryShipments: 1, + totalPickupShipments: 0 + }, + isLoading: false + })) + + renderWithCheckoutContext( + + ) + + await waitFor(() => { + expect(screen.getByTestId('toggle-card')).toBeInTheDocument() + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(mockUpdateAmount).not.toHaveBeenCalled() + }) + + test('calls updateAmount with correct orderTotal value on initial render', async () => { + const basketWithOrderTotal = { + ...mockBasket, + orderTotal: 250.75 + } + + mockUseCurrentBasket.mockImplementation(() => ({ + data: basketWithOrderTotal, + derivedData: { + totalItems: 2, + isMissingShippingAddress: false, + isMissingShippingMethod: false, + totalDeliveryShipments: 1, + totalPickupShipments: 0 + }, + isLoading: false + })) + + renderWithCheckoutContext( + + ) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 2500)) + }) + + // When checkout was created, updateAmount is called with orderTotal on initial render + const hadCheckout = mockCheckout.mock.calls.length > 0 + const hadUpdate250_75 = mockUpdateAmount.mock.calls.some((call) => call[0] === 250.75) + expect(!hadCheckout || hadUpdate250_75).toBe(true) + }) + }) +}) diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/shipping-address.jsx b/packages/template-retail-react-app/app/pages/checkout/partials/shipping-address.jsx index 243d3236d5..a2a9ba0894 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/shipping-address.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/partials/shipping-address.jsx @@ -18,7 +18,7 @@ import ShippingAddressSelection from '@salesforce/retail-react-app/app/pages/che import AddressDisplay from '@salesforce/retail-react-app/app/components/address-display' import { useShopperCustomersMutation, - useShopperBasketsMutation + useShopperBasketsV2Mutation as useShopperBasketsMutation } from '@salesforce/commerce-sdk-react' import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/shipping-address.test.js b/packages/template-retail-react-app/app/pages/checkout/partials/shipping-address.test.js index ec620595d5..b3ccb29ab8 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/shipping-address.test.js +++ b/packages/template-retail-react-app/app/pages/checkout/partials/shipping-address.test.js @@ -83,7 +83,7 @@ jest.mock('@salesforce/commerce-sdk-react', () => { useShopperCustomersMutation: () => ({ mutateAsync: mockCustomerMutateAsync }), - useShopperBasketsMutation: () => ({ + useShopperBasketsV2Mutation: () => ({ mutateAsync: mockMutateAsync }) } diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/shipping-method-options.jsx b/packages/template-retail-react-app/app/pages/checkout/partials/shipping-method-options.jsx index 95264509fe..f2f01e6507 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/shipping-method-options.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/partials/shipping-method-options.jsx @@ -16,7 +16,7 @@ import { RadioGroup } from '@salesforce/retail-react-app/app/components/shared/ui' import {Controller} from 'react-hook-form' -import {useShippingMethodsForShipment} from '@salesforce/commerce-sdk-react' +import {useShippingMethodsForShipmentV2 as useShippingMethodsForShipment} from '@salesforce/commerce-sdk-react' import PropTypes from 'prop-types' import LoadingSpinner from '@salesforce/retail-react-app/app/components/loading-spinner' import {STORE_LOCATOR_IS_ENABLED} from '@salesforce/retail-react-app/app/constants' diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/shipping-methods.jsx b/packages/template-retail-react-app/app/pages/checkout/partials/shipping-methods.jsx index 6985d1f029..ac672ab542 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/shipping-methods.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/partials/shipping-methods.jsx @@ -23,8 +23,8 @@ import { ToggleCardSummary } from '@salesforce/retail-react-app/app/components/toggle-card' import { - useShippingMethodsForShipment, - useShopperBasketsMutation + useShippingMethodsForShipmentV2 as useShippingMethodsForShipment, + useShopperBasketsV2Mutation as useShopperBasketsMutation } from '@salesforce/commerce-sdk-react' import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' import {useCurrency} from '@salesforce/retail-react-app/app/hooks' diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/shipping-methods.test.js b/packages/template-retail-react-app/app/pages/checkout/partials/shipping-methods.test.js index 326b45d8c6..a6d72f7be5 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/shipping-methods.test.js +++ b/packages/template-retail-react-app/app/pages/checkout/partials/shipping-methods.test.js @@ -13,9 +13,9 @@ import {useCheckout} from '@salesforce/retail-react-app/app/pages/checkout/util/ import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' import {useCurrency} from '@salesforce/retail-react-app/app/hooks' import { - useShippingMethodsForShipment, + useShippingMethodsForShipmentV2 as useShippingMethodsForShipment, useProducts, - useShopperBasketsMutation + useShopperBasketsV2Mutation as useShopperBasketsMutation } from '@salesforce/commerce-sdk-react' // Mock the hooks diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/shipping-multi-address.test.js b/packages/template-retail-react-app/app/pages/checkout/partials/shipping-multi-address.test.js index 056c2f110e..f746d788ac 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/shipping-multi-address.test.js +++ b/packages/template-retail-react-app/app/pages/checkout/partials/shipping-multi-address.test.js @@ -38,10 +38,10 @@ jest.mock('@salesforce/commerce-sdk-react', () => ({ mutateAsync: jest.fn().mockResolvedValue({}) } }), - useShopperBasketsMutation: jest.fn(() => ({ + useShopperBasketsV2Mutation: jest.fn(() => ({ mutateAsync: jest.fn().mockResolvedValue({}) })), - useShippingMethodsForShipment: jest.fn(() => ({ + useShippingMethodsForShipmentV2: jest.fn(() => ({ refetch: jest.fn() })) })) 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 new file mode 100644 index 0000000000..00f59d9860 --- /dev/null +++ b/packages/template-retail-react-app/app/pages/checkout/payment-processing.jsx @@ -0,0 +1,254 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import React, {useEffect, useRef} from 'react' +import PropTypes from 'prop-types' +import {useIntl} from 'react-intl' +import {useLocation} from 'react-router-dom' + +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 {useQueryClient} from '@tanstack/react-query' +import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' +import {useSFPayments, STATUS_SUCCESS} from '@salesforce/retail-react-app/app/hooks/use-sf-payments' +import {getSFPaymentsInstrument} from '@salesforce/retail-react-app/app/utils/sf-payments-utils' +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() + const location = useLocation() + const navigate = useNavigation() + const {sfp} = useSFPayments() + const toast = useToast() + const queryClient = useQueryClient() + + const {mutateAsync: updatePaymentInstrumentForOrder} = useShopperOrdersMutation( + 'updatePaymentInstrumentForOrder' + ) + const {mutateAsync: failOrder} = useShopperOrdersMutation('failOrder') + + const params = new URLSearchParams(location.search) + const vendor = params.get('vendor') + const orderNo = params.get('orderNo') + const {data: order, refetch} = 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 + ) + } + + /** + * Attempts to fail an order and reopen the basket. + * Only calls failOrder if the order status is 'created' (avoids hanging when order + * was already failed by webhook). + * @returns {Promise} + */ + async function attemptFailOrderForPayment() { + if (!orderNo) { + return + } + + try { + const {data: currentOrder} = await refetch() + if (currentOrder?.status === 'created') { + await failOrder({ + parameters: { + orderNo, + reopenBasket: true + }, + body: { + reasonCode: 'payment_confirm_failure' + } + }) + } + } catch (error) { + // Swallow so flow continues (invalidate, navigate). Causes: (1) Race: refetch + // returned 'created' but webhook already failed the order, so failOrder fails. (2) refetch + // or failOrder threw (network, 4xx/5xx). Same behavior for all: don't hang. + } finally { + queryClient.invalidateQueries() + } + } + + function showOrderConfirmation() { + navigate(`/checkout/confirmation/${orderNo}`) + } + + useEffect(() => { + 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 + attemptFailOrderForPayment() + } else if (!isError && sfp && order) { + ;(async () => { + 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 (no-op if already failed by webhook, e.g. 3DS declined) + await attemptFailOrderForPayment() + + // Navigate back to the checkout page to try again + navigate('/checkout') + })() + } + }, [sfp, order]) + + return ( + + + + + {isError ? ( + + + + + + + + + ) : ( + + + + )} + + ) +} + +PaymentProcessing.getTemplateName = () => 'payment-processing' + +PaymentProcessing.propTypes = { + /** + * The current react router match object. (Provided internally) + */ + match: PropTypes.object +} + +export default PaymentProcessing 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 new file mode 100644 index 0000000000..7644810ea2 --- /dev/null +++ b/packages/template-retail-react-app/app/pages/checkout/payment-processing.test.js @@ -0,0 +1,688 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import React from 'react' +import {screen, waitFor} from '@testing-library/react' +import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' +import PaymentProcessing from '@salesforce/retail-react-app/app/pages/checkout/payment-processing' +import {STATUS_SUCCESS} from '@salesforce/retail-react-app/app/hooks/use-sf-payments' + +// Mock dependencies +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() +const mockRefetchOrder = jest.fn() +const mockInvalidateQueries = jest.fn() + +jest.mock('@salesforce/retail-react-app/app/hooks/use-navigation', () => ({ + __esModule: true, + default: () => mockNavigate +})) + +jest.mock('@salesforce/retail-react-app/app/hooks/use-toast', () => ({ + useToast: () => mockToast +})) + +jest.mock('@salesforce/retail-react-app/app/hooks/use-sf-payments', () => ({ + useSFPayments: () => mockUseSFPayments(), + useSFPaymentsEnabled: () => true, + 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('@tanstack/react-query', () => { + const actual = jest.requireActual('@tanstack/react-query') + return { + ...actual, + useQueryClient: () => ({ + invalidateQueries: mockInvalidateQueries + }) + } +}) + +jest.mock('@salesforce/retail-react-app/app/utils/sf-payments-utils', () => ({ + getSFPaymentsInstrument: () => mockGetSFPaymentsInstrument() +})) + +// Mock useLocation +const mockLocation = {search: ''} +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: () => mockLocation +})) + +describe('PaymentProcessing', () => { + beforeEach(() => { + jest.clearAllMocks() + + // Default location with orderNo + mockLocation.search = '?vendor=Stripe&orderNo=12345' + + // Default mock implementations + mockHandleRedirect.mockResolvedValue({responseCode: STATUS_SUCCESS}) + + // Default SFP mock + mockUseSFPayments.mockReturnValue({ + sfp: { + handleRedirect: mockHandleRedirect + } + }) + + mockUseOrder.mockReturnValue({ + data: { + orderNo: '12345', + status: 'created' + }, + refetch: mockRefetchOrder + }) + mockRefetchOrder.mockResolvedValue({ + data: {orderNo: '12345', status: 'created'} + }) + + mockUpdatePaymentInstrumentForOrder.mockReturnValue({}) + + mockGetSFPaymentsInstrument.mockReturnValue({}) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + describe('rendering', () => { + test('renders payment processing heading', () => { + renderWithProviders() + + expect(screen.getByText('Payment Processing')).toBeInTheDocument() + }) + + test('renders working message for valid URL', () => { + renderWithProviders() + + expect(screen.getByText('Working on your payment...')).toBeInTheDocument() + }) + + test('renders error message for missing vendor', async () => { + mockLocation.search = '' + mockUseOrder.mockReturnValue({data: null}) + + renderWithProviders() + + 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('renders error message for unknown vendor', async () => { + mockLocation.search = '?vendor=Unknown' + mockUseOrder.mockReturnValue({data: null}) + + renderWithProviders() + + 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('renders error message for invalid Stripe URL missing order no', async () => { + mockLocation.search = '?vendor=Stripe' + mockUseOrder.mockReturnValue({data: null}) + + renderWithProviders() + + 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('renders error message for invalid Stripe URL with empty order no', async () => { + mockLocation.search = '?vendor=Stripe&orderNo=' + mockUseOrder.mockReturnValue({data: null}) + + renderWithProviders() + + 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('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() + + 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('renders error message for invalid Adyen URL missing type', async () => { + mockLocation.search = '?vendor=Adyen&orderNo=12345&zoneId=default&redirectResult=ABC123' + mockUseOrder.mockReturnValue({ + data: {orderNo: '12345'}, + refetch: mockRefetchOrder + }) + mockRefetchOrder.mockResolvedValue({ + data: {orderNo: '12345', status: 'created'} + }) + + renderWithProviders() + + expect( + screen.getByText('There was an unexpected error processing your payment.') + ).toBeInTheDocument() + expect(screen.getByText('Return to Checkout')).toBeInTheDocument() + + await waitFor(() => { + expect(mockRefetchOrder).toHaveBeenCalled() + expect(mockFailOrder).toHaveBeenCalledTimes(1) + expect(mockFailOrder).toHaveBeenCalledWith({ + parameters: { + orderNo: '12345', + reopenBasket: true + }, + body: { + reasonCode: 'payment_confirm_failure' + } + }) + }) + }) + + test('renders error message for invalid Adyen URL missing zone id', async () => { + mockLocation.search = '?vendor=Adyen&orderNo=12345&type=klarna&redirectResult=ABC123' + mockUseOrder.mockReturnValue({ + data: {orderNo: '12345'}, + refetch: mockRefetchOrder + }) + mockRefetchOrder.mockResolvedValue({ + data: {orderNo: '12345', status: 'created'} + }) + + renderWithProviders() + + expect( + screen.getByText('There was an unexpected error processing your payment.') + ).toBeInTheDocument() + expect(screen.getByText('Return to Checkout')).toBeInTheDocument() + + await waitFor(() => { + expect(mockRefetchOrder).toHaveBeenCalled() + expect(mockFailOrder).toHaveBeenCalledTimes(1) + expect(mockFailOrder).toHaveBeenCalledWith({ + parameters: { + orderNo: '12345', + reopenBasket: true + }, + body: { + reasonCode: 'payment_confirm_failure' + } + }) + }) + }) + + test('renders error message for invalid Adyen URL missing redirect result', async () => { + mockLocation.search = '?vendor=Adyen&orderNo=12345&type=klarna&zoneId=default' + mockUseOrder.mockReturnValue({ + data: {orderNo: '12345'}, + refetch: mockRefetchOrder + }) + mockRefetchOrder.mockResolvedValue({ + data: {orderNo: '12345', status: 'created'} + }) + + renderWithProviders() + + expect( + screen.getByText('There was an unexpected error processing your payment.') + ).toBeInTheDocument() + expect(screen.getByText('Return to Checkout')).toBeInTheDocument() + + await waitFor(() => { + expect(mockRefetchOrder).toHaveBeenCalled() + expect(mockFailOrder).toHaveBeenCalledTimes(1) + expect(mockFailOrder).toHaveBeenCalledWith({ + parameters: { + orderNo: '12345', + reopenBasket: true + }, + body: { + reasonCode: 'payment_confirm_failure' + } + }) + }) + }) + + test('error state includes link to checkout page', () => { + mockLocation.search = '' + + renderWithProviders() + + 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() + }) + }) + + describe('successful payment', () => { + test('navigates to confirmation page on successful payment', async () => { + mockHandleRedirect.mockResolvedValue({responseCode: STATUS_SUCCESS}) + + renderWithProviders() + + 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() + }) + + 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) + }) + }) + + 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 + }) + }) + }) + + test('navigates back to checkout on failed payment', async () => { + mockHandleRedirect.mockResolvedValue({responseCode: 1}) + + renderWithProviders() + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/checkout') + }) + }) + + test('shows toast and calls failOrder before navigating on failed payment', async () => { + mockHandleRedirect.mockResolvedValue({responseCode: 1}) + mockRefetchOrder.mockResolvedValue({ + data: {orderNo: '12345', status: 'created'} + }) + + renderWithProviders() + + await waitFor(() => { + expect(mockToast).toHaveBeenCalled() + }) + + await waitFor(() => { + expect(mockRefetchOrder).toHaveBeenCalled() + expect(mockFailOrder).toHaveBeenCalledTimes(1) + expect(mockFailOrder).toHaveBeenCalledWith({ + parameters: { + orderNo: '12345', + reopenBasket: true + }, + body: { + reasonCode: 'payment_confirm_failure' + } + }) + }) + + expect(mockNavigate).toHaveBeenCalledWith('/checkout') + }) + + test('does not call failOrder when order already failed by webhook', async () => { + mockHandleRedirect.mockResolvedValue({responseCode: 1}) + mockRefetchOrder.mockResolvedValue({ + data: {orderNo: '12345', status: 'failed'} + }) + + renderWithProviders() + + await waitFor(() => { + expect(mockToast).toHaveBeenCalled() + expect(mockNavigate).toHaveBeenCalledWith('/checkout') + }) + + expect(mockRefetchOrder).toHaveBeenCalled() + expect(mockFailOrder).not.toHaveBeenCalled() + }) + + test('shows toast and navigates to checkout when failOrder fails', async () => { + mockHandleRedirect.mockResolvedValue({responseCode: 1}) + mockRefetchOrder.mockResolvedValue({ + data: {orderNo: '12345', status: 'created'} + }) + mockFailOrder.mockRejectedValue(new Error('Order already failed')) + + renderWithProviders() + + await waitFor(() => { + expect(mockToast).toHaveBeenCalled() + expect(mockNavigate).toHaveBeenCalledWith('/checkout') + }) + + expect(mockRefetchOrder).toHaveBeenCalled() + expect(mockFailOrder).toHaveBeenCalledTimes(1) + expect(mockInvalidateQueries).toHaveBeenCalled() + }) + + test('handles different error response codes', async () => { + const errorCodes = [1, 2, -1, 999] + + for (const code of errorCodes) { + jest.clearAllMocks() + mockHandleRedirect.mockResolvedValue({responseCode: code}) + mockUseOrder.mockReturnValue({ + data: {orderNo: '12345', status: 'created'}, + refetch: mockRefetchOrder + }) + mockRefetchOrder.mockResolvedValue({ + data: {orderNo: '12345', status: 'created'} + }) + + renderWithProviders() + + await waitFor(() => { + expect(mockToast).toHaveBeenCalled() + expect(mockNavigate).toHaveBeenCalledWith('/checkout') + }) + } + }) + }) + }) + + 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' + } + } + } + } + }) + }) + + describe('payment processing', () => { + test('submits redirect result when dependencies are met', async () => { + renderWithProviders() + + 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' + } + } + } + } + }) + }) + }) + }) + + describe('successful payment', () => { + test('navigates to confirmation page on successful payment', async () => { + renderWithProviders() + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/checkout/confirmation/12345') + }) + + expect(mockToast).not.toHaveBeenCalled() + }) + + test('does not call updatePaymentInstrumentForOrder multiple times on re-renders', async () => { + const {rerender} = renderWithProviders() + + await waitFor(() => { + expect(mockUpdatePaymentInstrumentForOrder).toHaveBeenCalledTimes(1) + }) + + // Rerender component + rerender() + + // Wait a bit + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Should still only be called once + expect(mockUpdatePaymentInstrumentForOrder).toHaveBeenCalledTimes(1) + }) + }) + + describe('failed payment', () => { + beforeEach(() => { + mockGetSFPaymentsInstrument.mockReturnValue({ + paymentInstrumentId: 'xyz789', + paymentReference: { + gatewayProperties: { + adyen: { + resultCode: 'ERROR' + } + } + } + }) + }) + + test('shows error toast on failed payment', async () => { + renderWithProviders() + + await waitFor(() => { + expect(mockToast).toHaveBeenCalledWith({ + title: expect.stringContaining('unsuccessful'), + status: 'error', + duration: 30000 + }) + }) + }) + + test('navigates back to checkout on failed payment', async () => { + renderWithProviders() + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/checkout') + }) + }) + + test('shows toast and calls failOrder before navigating on failed payment', async () => { + mockRefetchOrder.mockResolvedValue({ + data: {orderNo: '12345', status: 'created'} + }) + + renderWithProviders() + + await waitFor(() => { + expect(mockToast).toHaveBeenCalled() + }) + + await waitFor(() => { + expect(mockRefetchOrder).toHaveBeenCalled() + expect(mockFailOrder).toHaveBeenCalledTimes(1) + expect(mockFailOrder).toHaveBeenCalledWith({ + parameters: { + orderNo: '12345', + reopenBasket: true + }, + body: { + reasonCode: 'payment_confirm_failure' + } + }) + }) + + expect(mockNavigate).toHaveBeenCalledWith('/checkout') + }) + }) + }) + + describe('static methods', () => { + test('getTemplateName returns correct template name', () => { + expect(PaymentProcessing.getTemplateName()).toBe('payment-processing') + }) + }) + + describe('accessibility', () => { + test('uses semantic heading element', () => { + renderWithProviders() + + const heading = screen.getByRole('heading', {name: 'Payment Processing'}) + expect(heading).toBeInTheDocument() + }) + + test('link has accessible text in error state', () => { + mockLocation.search = '' + + renderWithProviders() + + const link = screen.getByRole('link', {name: 'Return to Checkout'}) + expect(link).toBeInTheDocument() + }) + }) +}) diff --git a/packages/template-retail-react-app/app/pages/checkout/util/checkout-context.js b/packages/template-retail-react-app/app/pages/checkout/util/checkout-context.js index d1d69acc15..780b7d0125 100644 --- a/packages/template-retail-react-app/app/pages/checkout/util/checkout-context.js +++ b/packages/template-retail-react-app/app/pages/checkout/util/checkout-context.js @@ -12,6 +12,7 @@ import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-curre import {STORE_LOCATOR_IS_ENABLED} from '@salesforce/retail-react-app/app/constants' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import {useConfigurations} from '@salesforce/commerce-sdk-react' +import {useSFPaymentsEnabled} from '@salesforce/retail-react-app/app/hooks/use-sf-payments' const CheckoutContext = React.createContext() @@ -22,15 +23,18 @@ export const CheckoutProvider = ({children}) => { const einstein = useEinstein() const [step, setStep] = useState() const storeLocatorEnabled = getConfig()?.app?.storeLocatorEnabled ?? STORE_LOCATOR_IS_ENABLED - - const CHECKOUT_STEPS_LIST = [ - 'CONTACT_INFO', - 'PICKUP_ADDRESS', - 'SHIPPING_ADDRESS', - 'SHIPPING_OPTIONS', - 'PAYMENT', - 'REVIEW_ORDER' - ] + const sfPaymentsEnabled = useSFPaymentsEnabled() + + const CHECKOUT_STEPS_LIST = sfPaymentsEnabled + ? ['CONTACT_INFO', 'PICKUP_ADDRESS', 'SHIPPING_ADDRESS', 'SHIPPING_OPTIONS', 'PAYMENT'] + : [ + 'CONTACT_INFO', + 'PICKUP_ADDRESS', + 'SHIPPING_ADDRESS', + 'SHIPPING_OPTIONS', + 'PAYMENT', + 'REVIEW_ORDER' + ] const STEPS = CHECKOUT_STEPS_LIST.reduce((acc, step, idx) => ({...acc, [step]: idx}), {}) const getCheckoutStepName = (step) => CHECKOUT_STEPS_LIST[step] @@ -39,7 +43,7 @@ export const CheckoutProvider = ({children}) => { if (isBasketLoading || !customer || !basket) { return } - let step = STEPS.REVIEW_ORDER + let step = sfPaymentsEnabled ? STEPS.PAYMENT : STEPS.REVIEW_ORDER if (customer.isGuest && !basket.customerInfo?.email) { step = STEPS.CONTACT_INFO diff --git a/packages/template-retail-react-app/app/pages/confirmation/index.jsx b/packages/template-retail-react-app/app/pages/confirmation/index.jsx index 9f18c12a72..df141420c2 100644 --- a/packages/template-retail-react-app/app/pages/confirmation/index.jsx +++ b/packages/template-retail-react-app/app/pages/confirmation/index.jsx @@ -47,6 +47,7 @@ import CartItemVariantAttributes from '@salesforce/retail-react-app/app/componen import CartItemVariantPrice from '@salesforce/retail-react-app/app/components/item-variant/item-price' import MultiShipOrderSummary from '@salesforce/retail-react-app/app/components/multiship/multiship-order-summary' import ShipmentDetails from '@salesforce/retail-react-app/app/pages/checkout/partials/shipment-details' +import SFPaymentsOrderSummary from '@salesforce/retail-react-app/app/pages/checkout/partials/sf-payments-order-summary' // Hooks import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' @@ -76,6 +77,7 @@ const CheckoutConfirmation = () => { enabled: !!orderNo && onClient } ) + const {currency} = useCurrency() const itemIds = order?.productItems.map((item) => item.productId) const {data: products} = useProducts({parameters: {ids: itemIds?.join(',')}}) @@ -546,47 +548,56 @@ const CheckoutConfirmation = () => { - - - - + ) : null + ) : ( + + + + - - {CardIcon && } + + {CardIcon && } - - - { - order.paymentInstruments[0].paymentCard - ?.cardType - } - - + - ••••{' '} { order.paymentInstruments[0].paymentCard - ?.numberLastDigits + ?.cardType } - - { - order.paymentInstruments[0].paymentCard - ?.expirationMonth - } - / - { - order.paymentInstruments[0].paymentCard - ?.expirationYear - } - - - + + + ••••{' '} + { + order.paymentInstruments[0] + .paymentCard?.numberLastDigits + } + + + { + order.paymentInstruments[0] + .paymentCard?.expirationMonth + } + / + { + order.paymentInstruments[0] + .paymentCard?.expirationYear + } + + +
+ - + )} diff --git a/packages/template-retail-react-app/app/pages/confirmation/index.test.js b/packages/template-retail-react-app/app/pages/confirmation/index.test.js index 68d21513cd..1145580e4b 100644 --- a/packages/template-retail-react-app/app/pages/confirmation/index.test.js +++ b/packages/template-retail-react-app/app/pages/confirmation/index.test.js @@ -6,7 +6,7 @@ */ import React from 'react' -import {screen, waitFor} from '@testing-library/react' +import {screen, waitFor, within} from '@testing-library/react' import {Route, Switch} from 'react-router-dom' import {rest} from 'msw' import { @@ -20,6 +20,25 @@ import { } from '@salesforce/retail-react-app/app/pages/confirmation/index.mock' import mockConfig from '@salesforce/retail-react-app/config/mocks/default' +// Mock getConfig to provide necessary configuration for SF Payments +jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => { + const actual = jest.requireActual('@salesforce/pwa-kit-runtime/utils/ssr-config') + const mockConfig = jest.requireActual('@salesforce/retail-react-app/config/mocks/default') + return { + ...actual, + getConfig: jest.fn(() => ({ + ...mockConfig, + app: { + ...mockConfig.app, + sfPayments: { + enabled: true, + sdkUrl: 'https://example.com/sfpayments.js' + } + } + })) + } +}) + const MockedComponent = () => { return ( @@ -394,3 +413,104 @@ describe('Account form', () => { expect(hasPickupAddress).toBe(false) }) }) + +describe('Salesforce Payments Integration', () => { + const mockSFPaymentsOrder = { + ...mockOrder, + paymentInstruments: [ + { + amount: 82.56, + paymentInstrumentId: 'sfp123', + paymentMethodId: 'Salesforce Payments' + } + ] + } + + const mockSFPaymentsOrderWithType = { + ...mockOrder, + paymentInstruments: [ + { + amount: 82.56, + paymentInstrumentId: 'sfp123', + paymentMethodId: 'Salesforce Payments', + c_paymentReference_type: 'card', + c_paymentReference_brand: 'visa', + c_paymentReference_last4: '4242' + } + ] + } + + test('does not render payment details for Salesforce Payments orders when c_paymentReference_type is missing', async () => { + global.server.use( + rest.get('*/orders/:orderId', (req, res, ctx) => { + return res(ctx.delay(0), ctx.json(mockSFPaymentsOrder)) + }) + ) + + renderWithProviders() + + await screen.findByText(mockSFPaymentsOrder.orderNo) + + // Payment Details section should exist + expect(screen.getByText('Payment Details')).toBeInTheDocument() + + // No payment method details should be shown for SFP orders when c_paymentReference_type is missing + expect(screen.queryByRole('heading', {name: /credit card/i})).not.toBeInTheDocument() + }) + + test('renders SFPaymentsOrderSummary for Salesforce Payments orders when c_paymentReference_type exists', async () => { + global.server.use( + rest.get('*/orders/:orderId', (req, res, ctx) => { + return res(ctx.delay(0), ctx.json(mockSFPaymentsOrderWithType)) + }) + ) + + renderWithProviders() + + await screen.findByText(mockSFPaymentsOrderWithType.orderNo) + + // Payment Details section should exist + expect(screen.getByText('Payment Details')).toBeInTheDocument() + + // SFPaymentsOrderSummary should render when c_paymentReference_type is available + expect(await screen.findByRole('heading', {name: /credit card/i})).toBeInTheDocument() + expect(screen.getByText('Visa')).toBeInTheDocument() + expect(screen.getByText(/4242/)).toBeInTheDocument() + }) + + test('renders billing address for Salesforce Payments orders', async () => { + global.server.use( + rest.get('*/orders/:orderId', (req, res, ctx) => { + return res(ctx.delay(0), ctx.json(mockSFPaymentsOrder)) + }) + ) + + renderWithProviders() + + await screen.findByText(mockSFPaymentsOrder.orderNo) + + // Billing address should be shown + expect(screen.getByRole('heading', {name: /billing address/i})).toBeInTheDocument() + + // Check that billing address is displayed + const addresses = screen.getAllByText(/123 Walnut Place/) + expect(addresses.length).toBeGreaterThan(0) + }) + + test('still renders traditional credit card display for non-SF Payments orders', async () => { + global.server.use( + rest.get('*/orders/:orderId', (req, res, ctx) => { + return res(ctx.delay(0), ctx.json(mockOrder)) + }) + ) + + renderWithProviders() + + await screen.findByText(mockOrder.orderNo) + + // Check for traditional credit card display + expect(await screen.findByText('Credit Card')).toBeInTheDocument() + expect(screen.getByText('Visa')).toBeInTheDocument() + expect(screen.getByText(/1111/)).toBeInTheDocument() + }) +}) diff --git a/packages/template-retail-react-app/app/pages/login/index.jsx b/packages/template-retail-react-app/app/pages/login/index.jsx index 62c5ff40cb..4cda8e3fa0 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -15,7 +15,7 @@ import { useCustomerBaskets, useCustomerId, useCustomerType, - useShopperBasketsMutation + useShopperBasketsV2Mutation as useShopperBasketsMutation } from '@salesforce/commerce-sdk-react' import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import Seo from '@salesforce/retail-react-app/app/components/seo' diff --git a/packages/template-retail-react-app/app/pages/product-detail/index.jsx b/packages/template-retail-react-app/app/pages/product-detail/index.jsx index de950d8a0f..ef41dd8067 100644 --- a/packages/template-retail-react-app/app/pages/product-detail/index.jsx +++ b/packages/template-retail-react-app/app/pages/product-detail/index.jsx @@ -18,9 +18,9 @@ import { useProducts, useCategory, useShopperCustomersMutation, - useShopperBasketsMutation, + useShopperBasketsV2Mutation as useShopperBasketsMutation, useCustomerId, - useShopperBasketsMutationHelper + useShopperBasketsV2MutationHelper as useShopperBasketsMutationHelper } from '@salesforce/commerce-sdk-react' // Hooks diff --git a/packages/template-retail-react-app/app/pages/product-detail/index.test.js b/packages/template-retail-react-app/app/pages/product-detail/index.test.js index b0ad392aa1..bc7194a3f4 100644 --- a/packages/template-retail-react-app/app/pages/product-detail/index.test.js +++ b/packages/template-retail-react-app/app/pages/product-detail/index.test.js @@ -45,7 +45,14 @@ jest.mock('@salesforce/commerce-sdk-react', () => { } else { return originalModule.useShopperCustomersMutation(mutation) } - } + }, + useConfigurations: jest.fn(() => ({ + data: { + configurations: [] + }, + isLoading: false, + error: null + })) } }) diff --git a/packages/template-retail-react-app/app/pages/social-login-redirect/index.jsx b/packages/template-retail-react-app/app/pages/social-login-redirect/index.jsx index cda5935451..35ca61a613 100644 --- a/packages/template-retail-react-app/app/pages/social-login-redirect/index.jsx +++ b/packages/template-retail-react-app/app/pages/social-login-redirect/index.jsx @@ -19,7 +19,11 @@ import {AlertIcon} from '@salesforce/retail-react-app/app/components/icons' // Hooks import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' -import {useAuthHelper, AuthHelpers, useShopperBasketsMutation} from '@salesforce/commerce-sdk-react' +import { + useAuthHelper, + AuthHelpers, + useShopperBasketsV2Mutation as useShopperBasketsMutation +} from '@salesforce/commerce-sdk-react' import {useSearchParams} from '@salesforce/retail-react-app/app/hooks' import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' diff --git a/packages/template-retail-react-app/app/routes.jsx b/packages/template-retail-react-app/app/routes.jsx index 180d0e7769..68423d8f6d 100644 --- a/packages/template-retail-react-app/app/routes.jsx +++ b/packages/template-retail-react-app/app/routes.jsx @@ -50,6 +50,7 @@ const StoreLocator = loadable(() => import('./pages/store-locator'), { const Wishlist = loadable(() => import('./pages/account/wishlist'), { fallback }) +const PaymentProcessing = loadable(() => import('./pages/checkout/payment-processing'), {fallback}) const PageNotFound = loadable(() => import('./pages/page-not-found')) export const routes = [ @@ -89,6 +90,10 @@ export const routes = [ path: '/checkout/confirmation/:orderNo', component: CheckoutConfirmation }, + { + path: '/checkout/payment-processing', + component: PaymentProcessing + }, { path: '/callback', component: LoginRedirect, diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 98bb6b9aee..5be0cdc8e5 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -44,7 +44,10 @@ const options = { // The protocol on which the development Express app listens. // Note that http://localhost is treated as a secure context for development, // except by Safari. - protocol: 'http', + protocol: process.env.DEV_SERVER_PROTOCOL || 'http', + + // SSL file path for HTTPS development + sslFilePath: process.env.DEV_SERVER_SSL_FILE_PATH, // Option for whether to set up a special endpoint for handling // private SLAS clients @@ -58,7 +61,7 @@ const options = { // private client secret handler will inject an Authorization header. // The default regex is defined in this file: https://github.com/SalesforceCommerceCloud/pwa-kit/blob/develop/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js // applySLASPrivateClientToEndpoints: - // /\/oauth2\/(token|passwordless\/(login|token)|password\/(reset|action))/, + // /\/oauth2\/(token|passwordless\/(login|token)|password\/(reset|action))/, // If this is enabled, any HTTP header that has a non ASCII value will be URI encoded // If there any HTTP headers that have been encoded, an additional header will be @@ -355,11 +358,20 @@ const {handler} = runtime.createHandler(options, (app) => { directives: { 'img-src': [ // Default source for product images - replace with your CDN - '*.commercecloud.salesforce.com' + '*.commercecloud.salesforce.com', + '*.demandware.net', + '*.adyen.com' ], 'script-src': [ // Used by the service worker in /worker/main.js 'storage.googleapis.com', + // Payment gateways + '*.stripe.com', + '*.paypal.com', + '*.adyen.com', + 'pay.google.com', + 'www.gstatic.com', + '*.demandware.net', // Used to load a valid payment scripts in test environment 'maps.googleapis.com', 'places.googleapis.com' ], @@ -371,11 +383,25 @@ const {handler} = runtime.createHandler(options, (app) => { 'maps.googleapis.com', 'places.googleapis.com', // Connect to SCRT2 URLs - '*.salesforce-scrt.com' + '*.salesforce-scrt.com', + // Payment gateways + '*.demandware.net', // Used to load a valid payment scripts in test environment + '*.adyen.com', + '*.paypal.com', + 'pay.google.com', + 'payments.google.com', + 'google.com', + 'www.google.com' ], 'frame-src': [ // Allow frames from Salesforce site.com (Needed for MIAW) - '*.site.com' + '*.site.com', + // Payment gateways + '*.stripe.com', + '*.paypal.com', + '*.adyen.com', + 'payments.google.com', + 'pay.google.com' ] } } @@ -432,6 +458,47 @@ const {handler} = runtime.createHandler(options, (app) => { app.get('/favicon.ico', runtime.serveStaticFile('static/ico/favicon.ico')) app.get('/worker.js(.map)?', runtime.serveServiceWorker) + + // Helper function to transform relative icon paths to absolute URLs + function transformIconPaths(data, ecomServerHost) { + const baseUrl = `https://${ecomServerHost}/on/demandware.static/Sites-Site/-/-/internal` + const methodTypes = data?.paymentMethodTypes + if (methodTypes) { + for (const method of Object.values(methodTypes)) { + for (const image of method.images ?? []) { + if (image.src?.startsWith('/icons/')) { + image.src = `${baseUrl}${image.src}` + } + } + } + } + return data + } + + // Helper function to fetch payment metadata from the Commerce Cloud instance + app.get('/api/payment-metadata', async (req, res) => { + try { + const response = await fetch(config.app.sfPayments.metadataUrl, { + headers: {Accept: 'application/json'} + }) + if (!response.ok) { + throw new Error(`Metadata request failed with status: ${response.status}`) + } + const data = await response.json() + const transformedData = transformIconPaths( + data, + new URL(config.app.sfPayments.metadataUrl).hostname + ) + res.setHeader('Content-Type', 'application/json') + res.json(transformedData) + } catch (error) { + res.status(500).json({ + error: 'Failed to fetch metadata', + details: error.message + }) + } + }) + app.get('*', runtime.render) }) // SSR requires that we export a single handler function called 'get', that diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json index 395573fa04..814ae0962a 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json @@ -1007,6 +1007,12 @@ "value": "Could not save shipping address." } ], + "checkout.heading.express_checkout": [ + { + "type": 0, + "value": "Express Checkout" + } + ], "checkout.label.user_registration": [ { "type": 0, @@ -1019,6 +1025,24 @@ "value": "An unexpected error occurred during checkout." } ], + "checkout.message.payment_button_cancel": [ + { + "type": 0, + "value": "Your attempted payment was unsuccessful. You have not been charged and your order has not been placed." + } + ], + "checkout.message.payment_confirm_failure": [ + { + "type": 0, + "value": "Payment confirmation failed. Your order has been cancelled and your basket has been restored. Please try again or select a different payment method." + } + ], + "checkout.message.payment_processing_failed": [ + { + "type": 0, + "value": "Payment processing failed. Your order has been cancelled and your basket has been restored. Please try again or select a different payment method." + } + ], "checkout.message.user_registration": [ { "type": 0, @@ -1049,6 +1073,18 @@ "value": "Create Account" } ], + "checkout_confirmation.heading.afterpay_clearpay": [ + { + "type": 0, + "value": "Afterpay/Clearpay" + } + ], + "checkout_confirmation.heading.bancontact": [ + { + "type": 0, + "value": "Bancontact" + } + ], "checkout_confirmation.heading.billing_address": [ { "type": 0, @@ -1083,6 +1119,24 @@ "value": "number" } ], + "checkout_confirmation.heading.eps": [ + { + "type": 0, + "value": "EPS" + } + ], + "checkout_confirmation.heading.ideal": [ + { + "type": 0, + "value": "iDEAL" + } + ], + "checkout_confirmation.heading.klarna": [ + { + "type": 0, + "value": "Klarna" + } + ], "checkout_confirmation.heading.order_summary": [ { "type": 0, @@ -1117,6 +1171,12 @@ "value": "number" } ], + "checkout_confirmation.heading.sepa_debit": [ + { + "type": 0, + "value": "SEPA Debit" + } + ], "checkout_confirmation.heading.shipping_address": [ { "type": 0, @@ -1135,6 +1195,12 @@ "value": "Thank you for your order!" } ], + "checkout_confirmation.heading.unknown": [ + { + "type": 0, + "value": "Unknown" + } + ], "checkout_confirmation.label.free": [ { "type": 0, @@ -3371,6 +3437,36 @@ "value": "Password Reset Success" } ], + "payment_processing.error.unsuccessful": [ + { + "type": 0, + "value": "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." + } + ], + "payment_processing.heading.payment_processing": [ + { + "type": 0, + "value": "Payment Processing" + } + ], + "payment_processing.link.return_to_checkout": [ + { + "type": 0, + "value": "Return to Checkout" + } + ], + "payment_processing.message.unexpected_error": [ + { + "type": 0, + "value": "There was an unexpected error processing your payment." + } + ], + "payment_processing.message.working_on_your_payment": [ + { + "type": 0, + "value": "Working on your payment..." + } + ], "payment_selection.button.view_all": [ { "type": 0, @@ -3849,6 +3945,12 @@ "value": "See full details" } ], + "product_view.prepareBasket": [ + { + "type": 0, + "value": "Unable to determine product ID for basket" + } + ], "product_view.status.in_stock_at_store": [ { "type": 0, @@ -4121,6 +4223,60 @@ "value": "In Stock" } ], + "sf_payments_order_summary.label.bank.unknown": [ + { + "type": 0, + "value": "Unknown" + } + ], + "sf_payments_order_summary.label.brand.amex": [ + { + "type": 0, + "value": "American Express" + } + ], + "sf_payments_order_summary.label.brand.diners": [ + { + "type": 0, + "value": "Diners Club" + } + ], + "sf_payments_order_summary.label.brand.discover": [ + { + "type": 0, + "value": "Discover" + } + ], + "sf_payments_order_summary.label.brand.jcb": [ + { + "type": 0, + "value": "JCB" + } + ], + "sf_payments_order_summary.label.brand.mastercard": [ + { + "type": 0, + "value": "MasterCard" + } + ], + "sf_payments_order_summary.label.brand.unionpay": [ + { + "type": 0, + "value": "China UnionPay" + } + ], + "sf_payments_order_summary.label.brand.unknown": [ + { + "type": 0, + "value": "Unknown" + } + ], + "sf_payments_order_summary.label.brand.visa": [ + { + "type": 0, + "value": "Visa" + } + ], "shipping_address.action.ship_to_multiple_addresses": [ { "type": 0, @@ -4853,6 +5009,12 @@ "value": "Please enter your phone number." } ], + "use_address_fields.error.please_enter_postal_code": [ + { + "type": 0, + "value": "Please enter your postal code." + } + ], "use_address_fields.error.please_enter_your_postal_or_zip": [ { "type": 0, diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json index 395573fa04..814ae0962a 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json @@ -1007,6 +1007,12 @@ "value": "Could not save shipping address." } ], + "checkout.heading.express_checkout": [ + { + "type": 0, + "value": "Express Checkout" + } + ], "checkout.label.user_registration": [ { "type": 0, @@ -1019,6 +1025,24 @@ "value": "An unexpected error occurred during checkout." } ], + "checkout.message.payment_button_cancel": [ + { + "type": 0, + "value": "Your attempted payment was unsuccessful. You have not been charged and your order has not been placed." + } + ], + "checkout.message.payment_confirm_failure": [ + { + "type": 0, + "value": "Payment confirmation failed. Your order has been cancelled and your basket has been restored. Please try again or select a different payment method." + } + ], + "checkout.message.payment_processing_failed": [ + { + "type": 0, + "value": "Payment processing failed. Your order has been cancelled and your basket has been restored. Please try again or select a different payment method." + } + ], "checkout.message.user_registration": [ { "type": 0, @@ -1049,6 +1073,18 @@ "value": "Create Account" } ], + "checkout_confirmation.heading.afterpay_clearpay": [ + { + "type": 0, + "value": "Afterpay/Clearpay" + } + ], + "checkout_confirmation.heading.bancontact": [ + { + "type": 0, + "value": "Bancontact" + } + ], "checkout_confirmation.heading.billing_address": [ { "type": 0, @@ -1083,6 +1119,24 @@ "value": "number" } ], + "checkout_confirmation.heading.eps": [ + { + "type": 0, + "value": "EPS" + } + ], + "checkout_confirmation.heading.ideal": [ + { + "type": 0, + "value": "iDEAL" + } + ], + "checkout_confirmation.heading.klarna": [ + { + "type": 0, + "value": "Klarna" + } + ], "checkout_confirmation.heading.order_summary": [ { "type": 0, @@ -1117,6 +1171,12 @@ "value": "number" } ], + "checkout_confirmation.heading.sepa_debit": [ + { + "type": 0, + "value": "SEPA Debit" + } + ], "checkout_confirmation.heading.shipping_address": [ { "type": 0, @@ -1135,6 +1195,12 @@ "value": "Thank you for your order!" } ], + "checkout_confirmation.heading.unknown": [ + { + "type": 0, + "value": "Unknown" + } + ], "checkout_confirmation.label.free": [ { "type": 0, @@ -3371,6 +3437,36 @@ "value": "Password Reset Success" } ], + "payment_processing.error.unsuccessful": [ + { + "type": 0, + "value": "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." + } + ], + "payment_processing.heading.payment_processing": [ + { + "type": 0, + "value": "Payment Processing" + } + ], + "payment_processing.link.return_to_checkout": [ + { + "type": 0, + "value": "Return to Checkout" + } + ], + "payment_processing.message.unexpected_error": [ + { + "type": 0, + "value": "There was an unexpected error processing your payment." + } + ], + "payment_processing.message.working_on_your_payment": [ + { + "type": 0, + "value": "Working on your payment..." + } + ], "payment_selection.button.view_all": [ { "type": 0, @@ -3849,6 +3945,12 @@ "value": "See full details" } ], + "product_view.prepareBasket": [ + { + "type": 0, + "value": "Unable to determine product ID for basket" + } + ], "product_view.status.in_stock_at_store": [ { "type": 0, @@ -4121,6 +4223,60 @@ "value": "In Stock" } ], + "sf_payments_order_summary.label.bank.unknown": [ + { + "type": 0, + "value": "Unknown" + } + ], + "sf_payments_order_summary.label.brand.amex": [ + { + "type": 0, + "value": "American Express" + } + ], + "sf_payments_order_summary.label.brand.diners": [ + { + "type": 0, + "value": "Diners Club" + } + ], + "sf_payments_order_summary.label.brand.discover": [ + { + "type": 0, + "value": "Discover" + } + ], + "sf_payments_order_summary.label.brand.jcb": [ + { + "type": 0, + "value": "JCB" + } + ], + "sf_payments_order_summary.label.brand.mastercard": [ + { + "type": 0, + "value": "MasterCard" + } + ], + "sf_payments_order_summary.label.brand.unionpay": [ + { + "type": 0, + "value": "China UnionPay" + } + ], + "sf_payments_order_summary.label.brand.unknown": [ + { + "type": 0, + "value": "Unknown" + } + ], + "sf_payments_order_summary.label.brand.visa": [ + { + "type": 0, + "value": "Visa" + } + ], "shipping_address.action.ship_to_multiple_addresses": [ { "type": 0, @@ -4853,6 +5009,12 @@ "value": "Please enter your phone number." } ], + "use_address_fields.error.please_enter_postal_code": [ + { + "type": 0, + "value": "Please enter your postal code." + } + ], "use_address_fields.error.please_enter_your_postal_or_zip": [ { "type": 0, diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json index 434f3b6966..9c5dd85b4f 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json @@ -1967,6 +1967,20 @@ "value": "]" } ], + "checkout.heading.express_checkout": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ḗẋƥřḗḗşş Ƈħḗḗƈķǿǿŭŭŧ" + }, + { + "type": 0, + "value": "]" + } + ], "checkout.label.user_registration": [ { "type": 0, @@ -1995,6 +2009,48 @@ "value": "]" } ], + "checkout.message.payment_button_cancel": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ẏǿǿŭŭř ȧȧŧŧḗḗḿƥŧḗḗḓ ƥȧȧẏḿḗḗƞŧ ẇȧȧş ŭŭƞşŭŭƈƈḗḗşşƒŭŭŀ. Ẏǿǿŭŭ ħȧȧṽḗḗ ƞǿǿŧ ƀḗḗḗḗƞ ƈħȧȧřɠḗḗḓ ȧȧƞḓ ẏǿǿŭŭř ǿǿřḓḗḗř ħȧȧş ƞǿǿŧ ƀḗḗḗḗƞ ƥŀȧȧƈḗḗḓ." + }, + { + "type": 0, + "value": "]" + } + ], + "checkout.message.payment_confirm_failure": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ƥȧȧẏḿḗḗƞŧ ƈǿǿƞƒīřḿȧȧŧīǿǿƞ ƒȧȧīŀḗḗḓ. Ẏǿǿŭŭř ǿǿřḓḗḗř ħȧȧş ƀḗḗḗḗƞ ƈȧȧƞƈḗḗŀŀḗḗḓ ȧȧƞḓ ẏǿǿŭŭř ƀȧȧşķḗḗŧ ħȧȧş ƀḗḗḗḗƞ řḗḗşŧǿǿřḗḗḓ. Ƥŀḗḗȧȧşḗḗ ŧřẏ ȧȧɠȧȧīƞ ǿǿř şḗḗŀḗḗƈŧ ȧȧ ḓīƒƒḗḗřḗḗƞŧ ƥȧȧẏḿḗḗƞŧ ḿḗḗŧħǿǿḓ." + }, + { + "type": 0, + "value": "]" + } + ], + "checkout.message.payment_processing_failed": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ƥȧȧẏḿḗḗƞŧ ƥřǿǿƈḗḗşşīƞɠ ƒȧȧīŀḗḗḓ. Ẏǿǿŭŭř ǿǿřḓḗḗř ħȧȧş ƀḗḗḗḗƞ ƈȧȧƞƈḗḗŀŀḗḗḓ ȧȧƞḓ ẏǿǿŭŭř ƀȧȧşķḗḗŧ ħȧȧş ƀḗḗḗḗƞ řḗḗşŧǿǿřḗḗḓ. Ƥŀḗḗȧȧşḗḗ ŧřẏ ȧȧɠȧȧīƞ ǿǿř şḗḗŀḗḗƈŧ ȧȧ ḓīƒƒḗḗřḗḗƞŧ ƥȧȧẏḿḗḗƞŧ ḿḗḗŧħǿǿḓ." + }, + { + "type": 0, + "value": "]" + } + ], "checkout.message.user_registration": [ { "type": 0, @@ -2065,6 +2121,34 @@ "value": "]" } ], + "checkout_confirmation.heading.afterpay_clearpay": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ȧƒŧḗḗřƥȧȧẏ/Ƈŀḗḗȧȧřƥȧȧẏ" + }, + { + "type": 0, + "value": "]" + } + ], + "checkout_confirmation.heading.bancontact": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ɓȧȧƞƈǿǿƞŧȧȧƈŧ" + }, + { + "type": 0, + "value": "]" + } + ], "checkout_confirmation.heading.billing_address": [ { "type": 0, @@ -2139,6 +2223,48 @@ "value": "]" } ], + "checkout_confirmation.heading.eps": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "ḖƤŞ" + }, + { + "type": 0, + "value": "]" + } + ], + "checkout_confirmation.heading.ideal": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "īḒḖȦĿ" + }, + { + "type": 0, + "value": "]" + } + ], + "checkout_confirmation.heading.klarna": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ķŀȧȧřƞȧȧ" + }, + { + "type": 0, + "value": "]" + } + ], "checkout_confirmation.heading.order_summary": [ { "type": 0, @@ -2213,6 +2339,20 @@ "value": "]" } ], + "checkout_confirmation.heading.sepa_debit": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "ŞḖƤȦ Ḓḗḗƀīŧ" + }, + { + "type": 0, + "value": "]" + } + ], "checkout_confirmation.heading.shipping_address": [ { "type": 0, @@ -2255,6 +2395,20 @@ "value": "]" } ], + "checkout_confirmation.heading.unknown": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ŭƞķƞǿǿẇƞ" + }, + { + "type": 0, + "value": "]" + } + ], "checkout_confirmation.label.free": [ { "type": 0, @@ -7075,6 +7229,76 @@ "value": "]" } ], + "payment_processing.error.unsuccessful": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ẏǿǿŭŭř ȧȧŧŧḗḗḿƥŧḗḗḓ ƥȧȧẏḿḗḗƞŧ ẇȧȧş ŭŭƞşŭŭƈƈḗḗşşƒŭŭŀ. Ẏǿǿŭŭ ħȧȧṽḗḗ ƞǿǿŧ ƀḗḗḗḗƞ ƈħȧȧřɠḗḗḓ ȧȧƞḓ ẏǿǿŭŭř ǿǿřḓḗḗř ħȧȧş ƞǿǿŧ ƀḗḗḗḗƞ ƥŀȧȧƈḗḗḓ. Ƥŀḗḗȧȧşḗḗ şḗḗŀḗḗƈŧ ȧȧ ḓīƒƒḗḗřḗḗƞŧ ƥȧȧẏḿḗḗƞŧ ḿḗḗŧħǿǿḓ ȧȧƞḓ şŭŭƀḿīŧ ƥȧȧẏḿḗḗƞŧ ȧȧɠȧȧīƞ ŧǿǿ ƈǿǿḿƥŀḗḗŧḗḗ ẏǿǿŭŭř ƈħḗḗƈķǿǿŭŭŧ ȧȧƞḓ ƥŀȧȧƈḗḗ ẏǿǿŭŭř ǿǿřḓḗḗř." + }, + { + "type": 0, + "value": "]" + } + ], + "payment_processing.heading.payment_processing": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ƥȧȧẏḿḗḗƞŧ Ƥřǿǿƈḗḗşşīƞɠ" + }, + { + "type": 0, + "value": "]" + } + ], + "payment_processing.link.return_to_checkout": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Řḗḗŧŭŭřƞ ŧǿǿ Ƈħḗḗƈķǿǿŭŭŧ" + }, + { + "type": 0, + "value": "]" + } + ], + "payment_processing.message.unexpected_error": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ŧħḗḗřḗḗ ẇȧȧş ȧȧƞ ŭŭƞḗḗẋƥḗḗƈŧḗḗḓ ḗḗřřǿǿř ƥřǿǿƈḗḗşşīƞɠ ẏǿǿŭŭř ƥȧȧẏḿḗḗƞŧ." + }, + { + "type": 0, + "value": "]" + } + ], + "payment_processing.message.working_on_your_payment": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ẇǿǿřķīƞɠ ǿǿƞ ẏǿǿŭŭř ƥȧȧẏḿḗḗƞŧ..." + }, + { + "type": 0, + "value": "]" + } + ], "payment_selection.button.view_all": [ { "type": 0, @@ -8073,6 +8297,20 @@ "value": "]" } ], + "product_view.prepareBasket": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ŭƞȧȧƀŀḗḗ ŧǿǿ ḓḗḗŧḗḗřḿīƞḗḗ ƥřǿǿḓŭŭƈŧ ĪḒ ƒǿǿř ƀȧȧşķḗḗŧ" + }, + { + "type": 0, + "value": "]" + } + ], "product_view.status.in_stock_at_store": [ { "type": 0, @@ -8665,6 +8903,132 @@ "value": "]" } ], + "sf_payments_order_summary.label.bank.unknown": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ŭƞķƞǿǿẇƞ" + }, + { + "type": 0, + "value": "]" + } + ], + "sf_payments_order_summary.label.brand.amex": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ȧḿḗḗřīƈȧȧƞ Ḗẋƥřḗḗşş" + }, + { + "type": 0, + "value": "]" + } + ], + "sf_payments_order_summary.label.brand.diners": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ḓīƞḗḗřş Ƈŀŭŭƀ" + }, + { + "type": 0, + "value": "]" + } + ], + "sf_payments_order_summary.label.brand.discover": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ḓīşƈǿǿṽḗḗř" + }, + { + "type": 0, + "value": "]" + } + ], + "sf_payments_order_summary.label.brand.jcb": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "ĴƇƁ" + }, + { + "type": 0, + "value": "]" + } + ], + "sf_payments_order_summary.label.brand.mastercard": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "ḾȧȧşŧḗḗřƇȧȧřḓ" + }, + { + "type": 0, + "value": "]" + } + ], + "sf_payments_order_summary.label.brand.unionpay": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ƈħīƞȧȧ ŬƞīǿǿƞƤȧȧẏ" + }, + { + "type": 0, + "value": "]" + } + ], + "sf_payments_order_summary.label.brand.unknown": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ŭƞķƞǿǿẇƞ" + }, + { + "type": 0, + "value": "]" + } + ], + "sf_payments_order_summary.label.brand.visa": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ṽīşȧȧ" + }, + { + "type": 0, + "value": "]" + } + ], "shipping_address.action.ship_to_multiple_addresses": [ { "type": 0, @@ -10189,6 +10553,20 @@ "value": "]" } ], + "use_address_fields.error.please_enter_postal_code": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ƥŀḗḗȧȧşḗḗ ḗḗƞŧḗḗř ẏǿǿŭŭř ƥǿǿşŧȧȧŀ ƈǿǿḓḗḗ." + }, + { + "type": 0, + "value": "]" + } + ], "use_address_fields.error.please_enter_your_postal_or_zip": [ { "type": 0, 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 new file mode 100644 index 0000000000..4426dd4dc8 --- /dev/null +++ b/packages/template-retail-react-app/app/utils/sf-payments-utils.js @@ -0,0 +1,470 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { + PAYMENT_METHOD_TYPES, + PAYMENT_GATEWAYS, + SETUP_FUTURE_USAGE +} from '@salesforce/retail-react-app/app/constants' + +/** + * Returns the first Salesforce Payments instrument found in a basket or order. + * @param {Object} basketOrOrder - A basket or order object containing paymentInstruments + * @returns {Object|undefined} First Salesforce Payments payment instrument found, or undefined if none exist + */ +export const getSFPaymentsInstrument = (basketOrOrder) => { + return basketOrOrder?.paymentInstruments?.find( + (pi) => pi.paymentMethodId === 'Salesforce Payments' + ) +} + +/** + * Returns the client secret from a payment instrument (e.g. Stripe PaymentIntent client_secret). + * Used by express payment flows to pass through to the payment SDK. + * @param {Object} paymentInstrument - Payment instrument with paymentReference.gatewayProperties + * @returns {string|undefined} Client secret for the payment gateway, or undefined if not present + */ +export const getClientSecret = (paymentInstrument) => { + return paymentInstrument?.paymentReference?.gatewayProperties?.stripe?.clientSecret +} + +/** + * Transform billing and shipping address details from payment provider format to basket format. + * Determines the appropriate source for billing address (PayPal/Venmo may not provide complete billing details). + * Handles name splitting and address field mapping. + * @param {Object} billingDetails - Billing details from payment provider + * @param {Object} shippingDetails - Shipping details from payment provider + * @returns {Object} Object containing { billingAddress, shippingAddress } + */ +export const transformAddressDetails = (billingDetails, shippingDetails) => { + // Helper function to transform a single address + const transformSingleAddress = (addressDetails) => { + const address = { + firstName: null, + lastName: null, + address1: addressDetails.address.line1, + address2: addressDetails.address.line2 || null, + city: addressDetails.address.city, + stateCode: addressDetails.address.state, + postalCode: addressDetails.address.postalCode, + countryCode: addressDetails.address.country, + phone: addressDetails.phone || null + } + + if (addressDetails.name) { + const names = addressDetails.name.split(' ') + address.firstName = names.slice(0, -1).join(' ') + address.lastName = names.slice(-1).join(' ') + } + + return address + } + + // For PayPal/Venmo, billing address might not be available or incomplete + // Use shipping address as billing address if billing details are missing or incomplete + const billingAddr = billingDetails?.address + const hasBillingDetails = billingDetails?.name && billingAddr?.city + const billingSource = hasBillingDetails ? billingDetails : shippingDetails + + return { + billingAddress: transformSingleAddress(billingSource), + shippingAddress: transformSingleAddress(shippingDetails) + } +} + +/** + * Transform shipping methods from API format to express payment format. + * @param {Array} shippingMethods - Array of shipping methods from API + * @param {Object} basket - Basket object containing currency + * @param {string} selectedId - ID of the currently selected shipping method + * @param {boolean} sortSelected - Whether to sort selected method to the top + * @returns {Array} Transformed shipping methods + */ +export const transformShippingMethods = ( + shippingMethods, + basket, + selectedId = null, + sortSelected = true +) => { + const methods = shippingMethods.map((method) => ({ + id: method.id, + name: method.name, + classOfService: method.description, + amount: method.price?.toString() + })) + + if (sortSelected && selectedId) { + methods.sort((m1, m2) => { + if (m1.id === selectedId) return -1 + if (m2.id === selectedId) return 1 + return 0 + }) + } + + return methods +} + +/** + * Get the currently selected shipping method ID from basket or fallback to default. + * @param {Object} basket - Basket object + * @param {Object} shippingMethods - Shipping methods object with defaultShippingMethodId + * @returns {string} Selected shipping method ID + */ +export const getSelectedShippingMethodId = (basket, shippingMethods) => { + return basket.shipments?.[0]?.shippingMethod?.id || shippingMethods.defaultShippingMethodId +} + +/** + * Validates current shipping method is still applicable. + * Returns true if valid, false if the method needs to be updated. + * @param {Object} currentBasket - Basket object + * @param {Object} updatedShippingMethods - Updated shipping methods response + * @returns {boolean} Whether the current shipping method is still valid + */ +export const isShippingMethodValid = (currentBasket, updatedShippingMethods) => { + const currentShippingMethodId = currentBasket.shipments[0].shippingMethod?.id + return updatedShippingMethods.applicableShippingMethods.some( + (method) => method.id === currentShippingMethodId + ) +} + +/** + * Checks if the payment method type uses PayPal to render and complete payments + * @param {string} paymentMethodType - Type of payment method (e.g., 'card', 'paypal', 'venmo') + * @returns {boolean} Whether the payment method type uses PayPal + */ +export const isPayPalPaymentMethodType = (paymentMethodType) => { + return ( + paymentMethodType === PAYMENT_METHOD_TYPES.PAYPAL || + paymentMethodType === PAYMENT_METHOD_TYPES.VENMO + ) +} + +/** + * Finds the payment account/gateway for a given payment method type from payment method set accounts. + * @param {Array} paymentMethods - Array of payment methods + * @param {Array} paymentMethodSetAccounts - Array of payment method set accounts + * @param {string} paymentMethodType - Type of payment method + * @returns {Object|null} Payment account object with vendor property, or null if not found + */ +export const findPaymentAccount = (paymentMethods, paymentMethodSetAccounts, paymentMethodType) => { + if (!paymentMethodSetAccounts || !Array.isArray(paymentMethodSetAccounts)) { + return null + } + + // Find payment method by type to get its accountId + const paymentMethod = paymentMethods?.find((pm) => pm.paymentMethodType === paymentMethodType) + if (!paymentMethod || !paymentMethod.accountId) { + return null + } + + // Find account by accountId + return ( + paymentMethodSetAccounts.find((account) => { + return account.accountId === paymentMethod.accountId + }) || null + ) +} + +/** + * Determines the gateway name from payment method type and payment method set accounts. + * @param {string} paymentMethodType - Type of payment method + * @param {Array} paymentMethods - Array of payment methods + * @param {Array} paymentMethodSetAccounts - Array of payment method set accounts + * @returns {string|null} Gateway name + */ +export const getGatewayFromPaymentMethod = ( + paymentMethodType, + paymentMethods, + paymentMethodSetAccounts +) => { + const account = findPaymentAccount(paymentMethods, paymentMethodSetAccounts, paymentMethodType) + if (!account) { + return null + } + + const vendor = account.vendor?.toLowerCase() + if (vendor === PAYMENT_GATEWAYS.STRIPE) { + 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 +} + +/** + * Creates a payment instrument body for Salesforce Payments (for basket or order). + * @param {Object} params - Parameters for creating payment instrument body + * @param {number} params.amount - Payment amount + * @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 + * @param {Array} [params.paymentMethodSetAccounts] - Optional array of payment method set accounts to determine gateway + * @param {boolean} [params.isPostRequest=false] - Optional flag to indicate if this is a POST request (basket) + * @returns {Object} Payment instrument body + */ +export const createPaymentInstrumentBody = ({ + amount, + paymentMethodType, + zoneId, + shippingPreference, + paymentData = null, + storePaymentMethod = false, + futureUsageOffSession = false, + paymentMethods = null, + paymentMethodSetAccounts = null, + isPostRequest = false +} = {}) => { + const paymentReferenceRequest = { + paymentMethodType: paymentMethodType, + zoneId: zoneId ?? 'default' + } + + 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 = futureUsageOffSession + ? SETUP_FUTURE_USAGE.OFF_SESSION + : SETUP_FUTURE_USAGE.ON_SESSION + paymentReferenceRequest.gateway = PAYMENT_GATEWAYS.STRIPE + paymentReferenceRequest.gatewayProperties = { + stripe: {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 && {storePaymentMethod: true}) + } + } + } + + return { + paymentMethodId: 'Salesforce Payments', + amount: amount, + paymentReferenceRequest: paymentReferenceRequest + } +} + +/** + * Transforms payment method references from API format to SF Payments SDK format. + * @param {Object} customer - Customer object with paymentMethodReferences property + * @param {Object} paymentConfig - Payment configuration object with paymentMethodSetAccounts property + * @returns {Array} Transformed payment method references for SF Payments SDK + */ +export const transformPaymentMethodReferences = (customer, paymentConfig) => { + const paymentMethodReferences = customer?.paymentMethodReferences + const paymentMethodSetAccounts = paymentConfig?.paymentMethodSetAccounts || [] + + if (!Array.isArray(paymentMethodReferences) || !Array.isArray(paymentMethodSetAccounts)) { + return [] + } + + return paymentMethodReferences + .map((pmr) => { + const generateDisplayName = () => { + 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) { + return null + } + + const matchingAccount = paymentMethodSetAccounts.find( + (account) => account.accountId === pmr.accountId + ) + if (!matchingAccount) { + return null + } + + const 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) +} + +/** + * Maps payment method type for express buttons based on gateway. + * Stripe express requires 'card' for googlepay/applepay, while Adyen uses the original type. + * + * @param {string} type - The payment method type (e.g., 'googlepay', 'applepay', 'paypal') + * @param {string} gateway - The payment gateway (e.g., 'stripe', 'adyen') + * @returns {string} The mapped payment method type + */ +export const getExpressPaymentMethodType = (type, paymentMethods, paymentMethodSetAccounts) => { + const gateway = getGatewayFromPaymentMethod(type, paymentMethods, paymentMethodSetAccounts) + // Only Stripe express needs 'card' mapping for applepay/googlepay + if (gateway === PAYMENT_GATEWAYS.STRIPE) { + switch (type) { + case 'applepay': + case 'googlepay': + return 'card' + default: + return type + } + } + // Adyen and others use the type as-is + return type +} + +/** + * Returns a theme object containing CSS information for use with SF Payments components. + * @param {*} options - theme override options + * @returns SF Payments theme + */ +export const buildTheme = (options) => { + return { + designTokens: { + 'font-family': + '-apple-system, "system-ui", "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + 'font-weight-regular': '400', + 'font-weight-bold': '700', + 'font-size-2': '8px', + 'font-size-3': '12px', + 'font-size-4': '16px', + 'font-size-5': '17px', + 'font-size-6': '18px', + 'line-height-text': '1.5', + 'color-text-default': '#181818', + 'color-text-error': '#ea001e', + 'color-text-placeholder': '#939393', + 'color-text-weak': '#5c5c5c', + 'color-background': 'rgba(0, 0, 0, 0)', + 'color-brand': '#1b96ff', + 'color-text-brand-primary': '#ffffff', + 'color-text-inverse': '#ffffff', + 'color-border-input': '#939393', + 'border-radius-medium': '4px', + 'border-radius-small': '2px', + 'spacing-large': '24px', + 'spacing-medium': '16px', + 'spacing-small': '12px', + 'spacing-x-large': '32px', + 'spacing-x-small': '8px', + 'spacing-xx-small': '4px', + 'spacing-xxx-small': '2px' + }, + rules: { + button: { + 'border-radius': '4px' + }, + input: { + 'border-radius': '4px', + margin: '0 0 4px 0', + padding: '6px 12px', + focus: { + border: '1px solid #1b96ff', + 'box-shadow': '0 0 0 1px #1b96ff', + outline: '2px solid transparent', + transition: + 'background-color,border-color,color,fill,stroke,opacity,box-shadow,transform' + }, + invalid: { + border: '1px solid #ea001e', + 'box-shadow': '0 0 0 1px #ea001e', + outline: '2px solid transparent', + transition: + 'background-color,border-color,color,fill,stroke,opacity,box-shadow,transform' + } + }, + formLabel: { + 'font-size': '14px', + 'font-weight': '600', + margin: '12px 0 0 0', + padding: '0 12px 4px 0', + transition: + 'background-color,border-color,color,fill,stroke,opacity,box-shadow,transform' + }, + error: { + color: '#ea001e', + 'font-size': '14px' + } + }, + expressButtons: { + buttonLayout: options?.expressButtonLayout || 'vertical', + buttonShape: 'pill', + buttonHeight: 44, + buttonColors: { + applepay: 'black', + googlepay: 'black', + paypal: 'gold', + venmo: 'blue' + }, + buttonLabels: options?.expressButtonLabels || { + applepay: 'plain', + googlepay: 'plain', + paypal: 'paypal', + venmo: 'paypal' // Yes, default Venmo label is "paypal" + } + } + } +} 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 new file mode 100644 index 0000000000..4ee7e05093 --- /dev/null +++ b/packages/template-retail-react-app/app/utils/sf-payments-utils.test.js @@ -0,0 +1,1973 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { + buildTheme, + getSFPaymentsInstrument, + transformAddressDetails, + transformShippingMethods, + getSelectedShippingMethodId, + isShippingMethodValid, + isPayPalPaymentMethodType, + findPaymentAccount, + createPaymentInstrumentBody, + getGatewayFromPaymentMethod, + transformPaymentMethodReferences, + getExpressPaymentMethodType +} from '@salesforce/retail-react-app/app/utils/sf-payments-utils' + +import {PAYMENT_GATEWAYS} from '@salesforce/retail-react-app/app/constants' + +describe('sf-payments-utils', () => { + describe('getSFPaymentsInstrument', () => { + test('returns undefined when basketOrOrder is undefined', () => { + const result = getSFPaymentsInstrument(undefined) + expect(result).toBeUndefined() + }) + + test('returns undefined when basketOrOrder is null', () => { + const result = getSFPaymentsInstrument(null) + expect(result).toBeUndefined() + }) + + test('returns undefined when paymentInstruments is undefined', () => { + const basketOrOrder = {} + const result = getSFPaymentsInstrument(basketOrOrder) + expect(result).toBeUndefined() + }) + + test('returns undefined when paymentInstruments is empty', () => { + const basketOrOrder = { + paymentInstruments: [] + } + const result = getSFPaymentsInstrument(basketOrOrder) + expect(result).toBeUndefined() + }) + + test('returns undefined when no Salesforce Payments instruments exist', () => { + const basketOrOrder = { + paymentInstruments: [ + {paymentMethodId: 'CREDIT_CARD', amount: 100}, + {paymentMethodId: 'PAYPAL', amount: 50} + ] + } + const result = getSFPaymentsInstrument(basketOrOrder) + expect(result).toBeUndefined() + }) + + test('returns first Salesforce Payments instrument', () => { + const sfPaymentInstrument = { + paymentMethodId: 'Salesforce Payments', + amount: 100, + paymentInstrumentId: 'test-id' + } + const basketOrOrder = { + paymentInstruments: [ + {paymentMethodId: 'CREDIT_CARD', amount: 50}, + sfPaymentInstrument + ] + } + const result = getSFPaymentsInstrument(basketOrOrder) + expect(result).toEqual(sfPaymentInstrument) + }) + + test('returns first Salesforce Payments instrument when multiple exist', () => { + const sfPaymentInstrument1 = { + paymentMethodId: 'Salesforce Payments', + amount: 100, + paymentInstrumentId: 'test-id-1' + } + const sfPaymentInstrument2 = { + paymentMethodId: 'Salesforce Payments', + amount: 50, + paymentInstrumentId: 'test-id-2' + } + const basketOrOrder = { + paymentInstruments: [ + {paymentMethodId: 'CREDIT_CARD', amount: 25}, + sfPaymentInstrument1, + {paymentMethodId: 'PAYPAL', amount: 75}, + sfPaymentInstrument2 + ] + } + const result = getSFPaymentsInstrument(basketOrOrder) + expect(result).toEqual(sfPaymentInstrument1) + }) + + test('returns first Salesforce Payments instrument from mixed array', () => { + const basketOrOrder = { + paymentInstruments: [ + {paymentMethodId: 'CREDIT_CARD', amount: 10}, + { + paymentMethodId: 'Salesforce Payments', + amount: 20, + paymentInstrumentId: 'first' + }, + {paymentMethodId: 'PAYPAL', amount: 30}, + { + paymentMethodId: 'Salesforce Payments', + amount: 40, + paymentInstrumentId: 'second' + }, + {paymentMethodId: 'GIFT_CARD', amount: 50} + ] + } + const result = getSFPaymentsInstrument(basketOrOrder) + expect(result.paymentMethodId).toBe('Salesforce Payments') + expect(result.paymentInstrumentId).toBe('first') + }) + + test('works with basket object', () => { + const basket = { + basketId: 'basket-123', + paymentInstruments: [{paymentMethodId: 'Salesforce Payments', amount: 100}] + } + const result = getSFPaymentsInstrument(basket) + expect(result.paymentMethodId).toBe('Salesforce Payments') + }) + + test('works with order object', () => { + const order = { + orderNo: 'order-123', + paymentInstruments: [{paymentMethodId: 'Salesforce Payments', amount: 100}] + } + const result = getSFPaymentsInstrument(order) + expect(result.paymentMethodId).toBe('Salesforce Payments') + }) + + test('maintains original payment instrument properties', () => { + const sfPaymentInstrument = { + paymentMethodId: 'Salesforce Payments', + amount: 100, + paymentInstrumentId: 'test-id', + paymentReference: { + paymentReferenceId: 'ref-123', + clientSecret: 'secret-abc' + }, + customProperty: 'custom-value' + } + const basketOrOrder = { + paymentInstruments: [sfPaymentInstrument] + } + const result = getSFPaymentsInstrument(basketOrOrder) + expect(result).toEqual(sfPaymentInstrument) + expect(result.paymentReference).toEqual(sfPaymentInstrument.paymentReference) + expect(result.customProperty).toBe('custom-value') + }) + }) + + describe('buildTheme', () => { + describe('default theme structure', () => { + test('returns theme object with all required properties', () => { + const theme = buildTheme() + + expect(theme).toHaveProperty('designTokens') + expect(theme).toHaveProperty('rules') + expect(theme).toHaveProperty('expressButtons') + }) + + test('returns correct design tokens', () => { + const theme = buildTheme() + + expect(theme.designTokens).toEqual({ + 'font-family': + '-apple-system, "system-ui", "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + 'font-weight-regular': '400', + 'font-weight-bold': '700', + 'font-size-2': '8px', + 'font-size-3': '12px', + 'font-size-4': '16px', + 'font-size-5': '17px', + 'font-size-6': '18px', + 'line-height-text': '1.5', + 'color-text-default': '#181818', + 'color-text-error': '#ea001e', + 'color-text-placeholder': '#939393', + 'color-text-weak': '#5c5c5c', + 'color-background': 'rgba(0, 0, 0, 0)', + 'color-brand': '#1b96ff', + 'color-text-brand-primary': '#ffffff', + 'color-text-inverse': '#ffffff', + 'color-border-input': '#939393', + 'border-radius-medium': '4px', + 'border-radius-small': '2px', + 'spacing-large': '24px', + 'spacing-medium': '16px', + 'spacing-small': '12px', + 'spacing-x-large': '32px', + 'spacing-x-small': '8px', + 'spacing-xx-small': '4px', + 'spacing-xxx-small': '2px' + }) + }) + + test('returns correct rules configuration', () => { + const theme = buildTheme() + + expect(theme.rules).toEqual({ + button: { + 'border-radius': '4px' + }, + input: { + 'border-radius': '4px', + margin: '0 0 4px 0', + padding: '6px 12px', + focus: { + border: '1px solid #1b96ff', + 'box-shadow': '0 0 0 1px #1b96ff', + outline: '2px solid transparent', + transition: + 'background-color,border-color,color,fill,stroke,opacity,box-shadow,transform' + }, + invalid: { + border: '1px solid #ea001e', + 'box-shadow': '0 0 0 1px #ea001e', + outline: '2px solid transparent', + transition: + 'background-color,border-color,color,fill,stroke,opacity,box-shadow,transform' + } + }, + formLabel: { + 'font-size': '14px', + 'font-weight': '600', + margin: '12px 0 0 0', + padding: '0 12px 4px 0', + transition: + 'background-color,border-color,color,fill,stroke,opacity,box-shadow,transform' + }, + error: { + color: '#ea001e', + 'font-size': '14px' + } + }) + }) + + test('returns default express buttons configuration', () => { + const theme = buildTheme() + + expect(theme.expressButtons).toEqual({ + buttonLayout: 'vertical', + buttonShape: 'pill', + buttonHeight: 44, + buttonColors: { + applepay: 'black', + googlepay: 'black', + paypal: 'gold', + venmo: 'blue' + }, + buttonLabels: { + applepay: 'plain', + googlepay: 'plain', + paypal: 'paypal', + venmo: 'paypal' + } + }) + }) + }) + + describe('custom options', () => { + test('applies custom expressButtonLayout', () => { + const theme = buildTheme({expressButtonLayout: 'horizontal'}) + + expect(theme.expressButtons.buttonLayout).toBe('horizontal') + // Other properties should remain default + expect(theme.expressButtons.buttonShape).toBe('pill') + expect(theme.expressButtons.buttonHeight).toBe(44) + }) + + test('applies custom expressButtonLabels', () => { + const customLabels = { + applepay: 'buy', + googlepay: 'checkout', + paypal: 'checkout', + venmo: 'checkout' + } + const theme = buildTheme({expressButtonLabels: customLabels}) + + expect(theme.expressButtons.buttonLabels).toEqual(customLabels) + // Layout should remain default + expect(theme.expressButtons.buttonLayout).toBe('vertical') + }) + + test('applies both custom options together', () => { + const customLabels = { + applepay: 'buy', + googlepay: 'buy', + paypal: 'buy', + venmo: 'buy' + } + const theme = buildTheme({ + expressButtonLayout: 'horizontal', + expressButtonLabels: customLabels + }) + + expect(theme.expressButtons.buttonLayout).toBe('horizontal') + expect(theme.expressButtons.buttonLabels).toEqual(customLabels) + }) + + test('handles partial expressButtonLabels override', () => { + const partialLabels = { + applepay: 'buy', + googlepay: 'checkout' + } + const theme = buildTheme({expressButtonLabels: partialLabels}) + + expect(theme.expressButtons.buttonLabels).toEqual(partialLabels) + }) + + test('handles empty options object', () => { + const theme = buildTheme({}) + + expect(theme.expressButtons.buttonLayout).toBe('vertical') + expect(theme.expressButtons.buttonLabels).toEqual({ + applepay: 'plain', + googlepay: 'plain', + paypal: 'paypal', + venmo: 'paypal' + }) + }) + + test('handles null options', () => { + const theme = buildTheme(null) + + expect(theme.expressButtons.buttonLayout).toBe('vertical') + expect(theme.expressButtons.buttonLabels).toEqual({ + applepay: 'plain', + googlepay: 'plain', + paypal: 'paypal', + venmo: 'paypal' + }) + }) + + test('handles undefined options', () => { + const theme = buildTheme(undefined) + + expect(theme.expressButtons.buttonLayout).toBe('vertical') + expect(theme.expressButtons.buttonLabels).toEqual({ + applepay: 'plain', + googlepay: 'plain', + paypal: 'paypal', + venmo: 'paypal' + }) + }) + }) + + describe('theme immutability', () => { + test('multiple calls return independent objects', () => { + const theme1 = buildTheme() + const theme2 = buildTheme() + + expect(theme1).toEqual(theme2) + expect(theme1).not.toBe(theme2) + expect(theme1.designTokens).not.toBe(theme2.designTokens) + expect(theme1.rules).not.toBe(theme2.rules) + expect(theme1.expressButtons).not.toBe(theme2.expressButtons) + }) + + test('different options create different themes', () => { + const theme1 = buildTheme({expressButtonLayout: 'horizontal'}) + const theme2 = buildTheme({expressButtonLayout: 'vertical'}) + + expect(theme1.expressButtons.buttonLayout).toBe('horizontal') + expect(theme2.expressButtons.buttonLayout).toBe('vertical') + }) + }) + + describe('express button specific configurations', () => { + test('buttonColors are always static', () => { + const theme = buildTheme({customColors: 'ignored'}) + + expect(theme.expressButtons.buttonColors).toEqual({ + applepay: 'black', + googlepay: 'black', + paypal: 'gold', + venmo: 'blue' + }) + }) + + test('buttonShape is always pill', () => { + const theme = buildTheme() + + expect(theme.expressButtons.buttonShape).toBe('pill') + }) + + test('buttonHeight is always 44', () => { + const theme = buildTheme() + + expect(theme.expressButtons.buttonHeight).toBe(44) + }) + + test('supports all valid layout options', () => { + const horizontalTheme = buildTheme({expressButtonLayout: 'horizontal'}) + const verticalTheme = buildTheme({expressButtonLayout: 'vertical'}) + + expect(horizontalTheme.expressButtons.buttonLayout).toBe('horizontal') + expect(verticalTheme.expressButtons.buttonLayout).toBe('vertical') + }) + + test('supports all button label types', () => { + const labels = { + applepay: 'custom-label', + googlepay: 'another-label', + paypal: 'paypal-label', + venmo: 'venmo-label' + } + const theme = buildTheme({expressButtonLabels: labels}) + + expect(theme.expressButtons.buttonLabels).toEqual(labels) + }) + }) + + describe('design token properties', () => { + test('font-family uses system font stack', () => { + const theme = buildTheme() + + expect(theme.designTokens['font-family']).toContain('-apple-system') + expect(theme.designTokens['font-family']).toContain('system-ui') + }) + + test('color tokens are valid hex values', () => { + const theme = buildTheme() + + expect(theme.designTokens['color-text-default']).toMatch(/^#[0-9a-f]{6}$/i) + expect(theme.designTokens['color-text-error']).toMatch(/^#[0-9a-f]{6}$/i) + expect(theme.designTokens['color-brand']).toMatch(/^#[0-9a-f]{6}$/i) + }) + + test('spacing tokens use pixel units', () => { + const theme = buildTheme() + + expect(theme.designTokens['spacing-small']).toMatch(/^\d+px$/) + expect(theme.designTokens['spacing-medium']).toMatch(/^\d+px$/) + expect(theme.designTokens['spacing-large']).toMatch(/^\d+px$/) + }) + + test('border-radius tokens use pixel units', () => { + const theme = buildTheme() + + expect(theme.designTokens['border-radius-small']).toMatch(/^\d+px$/) + expect(theme.designTokens['border-radius-medium']).toMatch(/^\d+px$/) + }) + }) + + describe('rules configuration', () => { + test('input rules include focus state', () => { + const theme = buildTheme() + + expect(theme.rules.input.focus).toBeDefined() + expect(theme.rules.input.focus.border).toContain('#1b96ff') + expect(theme.rules.input.focus['box-shadow']).toContain('#1b96ff') + }) + + test('input rules include invalid state', () => { + const theme = buildTheme() + + expect(theme.rules.input.invalid).toBeDefined() + expect(theme.rules.input.invalid.border).toContain('#ea001e') + expect(theme.rules.input.invalid['box-shadow']).toContain('#ea001e') + }) + + test('button rules include border-radius', () => { + const theme = buildTheme() + + expect(theme.rules.button['border-radius']).toBe('4px') + }) + + test('formLabel rules include typography settings', () => { + const theme = buildTheme() + + expect(theme.rules.formLabel['font-size']).toBe('14px') + expect(theme.rules.formLabel['font-weight']).toBe('600') + }) + + test('error rules use error color', () => { + const theme = buildTheme() + + expect(theme.rules.error.color).toBe('#ea001e') + }) + }) + }) + + describe('transformAddressDetails', () => { + test('transforms complete addresses with full names', () => { + const billingDetails = { + name: 'John Michael Doe', + phone: '+1234567890', + address: { + line1: '123 Main St', + line2: 'Apt 4B', + city: 'San Francisco', + state: 'CA', + postalCode: '94102', + country: 'US' + } + } + + const shippingDetails = { + name: 'Jane Smith', + phone: '+0987654321', + address: { + line1: '456 Shipping Ave', + city: 'Los Angeles', + state: 'CA', + postalCode: '90001', + country: 'US' + } + } + + const result = transformAddressDetails(billingDetails, shippingDetails) + + expect(result.billingAddress).toEqual({ + firstName: 'John Michael', + lastName: 'Doe', + address1: '123 Main St', + address2: 'Apt 4B', + city: 'San Francisco', + stateCode: 'CA', + postalCode: '94102', + countryCode: 'US', + phone: '+1234567890' + }) + expect(result.shippingAddress).toEqual({ + firstName: 'Jane', + lastName: 'Smith', + address1: '456 Shipping Ave', + address2: null, + city: 'Los Angeles', + stateCode: 'CA', + postalCode: '90001', + countryCode: 'US', + phone: '+0987654321' + }) + }) + + test('handles single word name in billing address', () => { + const billingDetails = { + name: 'Madonna', + address: { + line1: '456 Oak Ave', + city: 'Los Angeles', + state: 'CA', + postalCode: '90001', + country: 'US' + } + } + + const shippingDetails = { + name: 'Jane Smith', + address: { + line1: '789 Elm St', + city: 'New York', + state: 'NY', + postalCode: '10001', + country: 'US' + } + } + + const result = transformAddressDetails(billingDetails, shippingDetails) + + expect(result.billingAddress.firstName).toBe('') + expect(result.billingAddress.lastName).toBe('Madonna') + }) + + test('handles missing name in shipping address', () => { + const billingDetails = { + name: 'John Doe', + address: { + line1: '123 Main St', + city: 'San Francisco', + state: 'CA', + postalCode: '94102', + country: 'US' + } + } + + const shippingDetails = { + address: { + line1: '321 Pine St', + city: 'Boston', + state: 'MA', + postalCode: '02101', + country: 'US' + } + } + + const result = transformAddressDetails(billingDetails, shippingDetails) + + expect(result.shippingAddress.firstName).toBeNull() + expect(result.shippingAddress.lastName).toBeNull() + }) + + test('handles missing line2', () => { + const billingDetails = { + name: 'Bob Johnson', + address: { + line1: '555 Broadway', + city: 'Seattle', + state: 'WA', + postalCode: '98101', + country: 'US' + } + } + + const shippingDetails = { + name: 'Jane Doe', + address: { + line1: '123 Main St', + city: 'Seattle', + state: 'WA', + postalCode: '98101', + country: 'US' + } + } + + const result = transformAddressDetails(billingDetails, shippingDetails) + + expect(result.billingAddress.address2).toBeNull() + expect(result.billingAddress.address1).toBe('555 Broadway') + }) + + test('handles line2 when provided', () => { + const billingDetails = { + name: 'John Doe', + address: { + line1: '123 Main St', + line2: 'Suite 100', + city: 'San Francisco', + state: 'CA', + postalCode: '94102', + country: 'US' + } + } + + const shippingDetails = { + name: 'Jane Doe', + address: { + line1: '456 Oak Ave', + city: 'Los Angeles', + state: 'CA', + postalCode: '90001', + country: 'US' + } + } + + const result = transformAddressDetails(billingDetails, shippingDetails) + + expect(result.billingAddress.address2).toBe('Suite 100') + }) + + test('handles missing phone', () => { + const billingDetails = { + name: 'Alice Cooper', + address: { + line1: '777 Rock Blvd', + city: 'Detroit', + state: 'MI', + postalCode: '48201', + country: 'US' + } + } + + const shippingDetails = { + name: 'Jane Doe', + address: { + line1: '123 Main St', + city: 'Detroit', + state: 'MI', + postalCode: '48201', + country: 'US' + } + } + + const result = transformAddressDetails(billingDetails, shippingDetails) + + expect(result.billingAddress.phone).toBeNull() + }) + + test('handles international address', () => { + const billingDetails = { + name: 'Pierre Dubois', + phone: '+33123456789', + address: { + line1: '10 Rue de la Paix', + line2: 'Appartement 5', + city: 'Paris', + state: 'IDF', + postalCode: '75001', + country: 'FR' + } + } + + const shippingDetails = { + name: 'Marie Curie', + address: { + line1: '5 Rue de Lyon', + city: 'Paris', + state: 'IDF', + postalCode: '75001', + country: 'FR' + } + } + + const result = transformAddressDetails(billingDetails, shippingDetails) + + expect(result.billingAddress.countryCode).toBe('FR') + expect(result.billingAddress.firstName).toBe('Pierre') + expect(result.billingAddress.lastName).toBe('Dubois') + }) + + test('uses shipping as billing when billing details are incomplete (PayPal case)', () => { + const billingDetails = { + // Missing name and city - incomplete + address: { + line1: '123 Billing St', + state: 'CA', + postalCode: '94102', + country: 'US' + } + } + + const shippingDetails = { + name: 'Jane Smith', + phone: '+0987654321', + address: { + line1: '456 Shipping Ave', + city: 'Los Angeles', + state: 'CA', + postalCode: '90001', + country: 'US' + } + } + + const result = transformAddressDetails(billingDetails, shippingDetails) + + expect(result).toHaveProperty('billingAddress') + expect(result).toHaveProperty('shippingAddress') + // Billing address should use shipping details + expect(result.billingAddress).toEqual({ + firstName: 'Jane', + lastName: 'Smith', + address1: '456 Shipping Ave', + address2: null, + city: 'Los Angeles', + stateCode: 'CA', + postalCode: '90001', + countryCode: 'US', + phone: '+0987654321' + }) + expect(result.shippingAddress).toEqual({ + firstName: 'Jane', + lastName: 'Smith', + address1: '456 Shipping Ave', + address2: null, + city: 'Los Angeles', + stateCode: 'CA', + postalCode: '90001', + countryCode: 'US', + phone: '+0987654321' + }) + }) + + test('uses shipping as billing when billing details missing name', () => { + const billingDetails = { + // Missing name - incomplete + address: { + line1: '123 Billing St', + city: 'San Francisco', + state: 'CA', + postalCode: '94102', + country: 'US' + } + } + + const shippingDetails = { + name: 'Jane Smith', + phone: '+0987654321', + address: { + line1: '456 Shipping Ave', + city: 'Los Angeles', + state: 'CA', + postalCode: '90001', + country: 'US' + } + } + + const result = transformAddressDetails(billingDetails, shippingDetails) + + // Should use shipping for billing since name is missing + expect(result.billingAddress.firstName).toBe('Jane') + expect(result.billingAddress.lastName).toBe('Smith') + expect(result.billingAddress.city).toBe('Los Angeles') + }) + }) + + describe('transformShippingMethods', () => { + const basket = { + currency: 'USD' + } + + test('transforms shipping methods with numeric price', () => { + const shippingMethods = [ + { + id: 'standard', + name: 'Standard Shipping', + description: 'Delivery in 5-7 business days', + price: 5.99 + }, + { + id: 'express', + name: 'Express Shipping', + description: 'Delivery in 2-3 business days', + price: 15.99 + } + ] + + const result = transformShippingMethods(shippingMethods, basket) + + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ + id: 'standard', + name: 'Standard Shipping', + classOfService: 'Delivery in 5-7 business days', + amount: '5.99' + }) + expect(result[1].amount).toBe('15.99') + }) + + test('transforms shipping methods with string price', () => { + const shippingMethods = [ + { + id: 'free', + name: 'Free Shipping', + description: 'Delivery in 7-10 business days', + price: '0.00' + } + ] + + const result = transformShippingMethods(shippingMethods, basket) + + expect(result[0].amount).toBe('0.00') + }) + + test('sorts selected method to top when sortSelected is true', () => { + const shippingMethods = [ + {id: 'standard', name: 'Standard', description: 'Standard', price: 5.99}, + {id: 'express', name: 'Express', description: 'Express', price: 15.99}, + {id: 'overnight', name: 'Overnight', description: 'Overnight', price: 25.99} + ] + + const result = transformShippingMethods(shippingMethods, basket, 'express', true) + + expect(result[0].id).toBe('express') + expect(result[1].id).toBe('standard') + expect(result[2].id).toBe('overnight') + }) + + test('does not sort when sortSelected is false', () => { + const shippingMethods = [ + {id: 'standard', name: 'Standard', description: 'Standard', price: 5.99}, + {id: 'express', name: 'Express', description: 'Express', price: 15.99}, + {id: 'overnight', name: 'Overnight', description: 'Overnight', price: 25.99} + ] + + const result = transformShippingMethods(shippingMethods, basket, 'express', false) + + expect(result[0].id).toBe('standard') + expect(result[1].id).toBe('express') + expect(result[2].id).toBe('overnight') + }) + + test('handles no selected ID', () => { + const shippingMethods = [ + {id: 'standard', name: 'Standard', description: 'Standard', price: 5.99} + ] + + const result = transformShippingMethods(shippingMethods, basket, null, true) + + expect(result[0].id).toBe('standard') + }) + + test('uses basket currency for all methods', () => { + const eurBasket = {currency: 'EUR'} + const shippingMethods = [ + {id: 'method1', name: 'Method 1', description: 'Desc 1', price: 10}, + {id: 'method2', name: 'Method 2', description: 'Desc 2', price: 20} + ] + + const result = transformShippingMethods(shippingMethods, eurBasket) + + expect(result).toHaveLength(2) + expect(result[0].amount).toBe('10') + expect(result[1].amount).toBe('20') + }) + }) + + describe('getSelectedShippingMethodId', () => { + test('returns shipping method ID from basket shipment', () => { + const basket = { + shipments: [ + { + shippingMethod: { + id: 'express-shipping' + } + } + ] + } + const shippingMethods = { + defaultShippingMethodId: 'standard-shipping' + } + + const result = getSelectedShippingMethodId(basket, shippingMethods) + + expect(result).toBe('express-shipping') + }) + + test('returns default when basket has no shipments', () => { + const basket = {} + const shippingMethods = { + defaultShippingMethodId: 'standard-shipping' + } + + const result = getSelectedShippingMethodId(basket, shippingMethods) + + expect(result).toBe('standard-shipping') + }) + + test('returns default when basket has empty shipments array', () => { + const basket = { + shipments: [] + } + const shippingMethods = { + defaultShippingMethodId: 'standard-shipping' + } + + const result = getSelectedShippingMethodId(basket, shippingMethods) + + expect(result).toBe('standard-shipping') + }) + + test('returns default when shipping method is null', () => { + const basket = { + shipments: [ + { + shippingMethod: null + } + ] + } + const shippingMethods = { + defaultShippingMethodId: 'standard-shipping' + } + + const result = getSelectedShippingMethodId(basket, shippingMethods) + + expect(result).toBe('standard-shipping') + }) + + test('returns default when shipping method ID is undefined', () => { + const basket = { + shipments: [ + { + shippingMethod: {} + } + ] + } + const shippingMethods = { + defaultShippingMethodId: 'overnight-shipping' + } + + const result = getSelectedShippingMethodId(basket, shippingMethods) + + expect(result).toBe('overnight-shipping') + }) + }) + + describe('isShippingMethodValid', () => { + test('returns true when current shipping method is in applicable methods', () => { + const currentBasket = { + shipments: [ + { + shippingMethod: { + id: 'express' + } + } + ] + } + const updatedShippingMethods = { + applicableShippingMethods: [{id: 'standard'}, {id: 'express'}, {id: 'overnight'}] + } + + const result = isShippingMethodValid(currentBasket, updatedShippingMethods) + + expect(result).toBe(true) + }) + + test('returns false when current shipping method is not in applicable methods', () => { + const currentBasket = { + shipments: [ + { + shippingMethod: { + id: 'international' + } + } + ] + } + const updatedShippingMethods = { + applicableShippingMethods: [{id: 'standard'}, {id: 'express'}] + } + + const result = isShippingMethodValid(currentBasket, updatedShippingMethods) + + expect(result).toBe(false) + }) + + test('returns false when current shipping method is undefined', () => { + const currentBasket = { + shipments: [ + { + shippingMethod: {} + } + ] + } + const updatedShippingMethods = { + applicableShippingMethods: [{id: 'standard'}] + } + + const result = isShippingMethodValid(currentBasket, updatedShippingMethods) + + expect(result).toBe(false) + }) + + test('returns false when applicable methods is empty', () => { + const currentBasket = { + shipments: [ + { + shippingMethod: { + id: 'express' + } + } + ] + } + const updatedShippingMethods = { + applicableShippingMethods: [] + } + + const result = isShippingMethodValid(currentBasket, updatedShippingMethods) + + expect(result).toBe(false) + }) + + test('handles missing shipments array', () => { + const currentBasket = { + shipments: [] + } + const updatedShippingMethods = { + applicableShippingMethods: [{id: 'standard'}] + } + + expect(() => { + isShippingMethodValid(currentBasket, updatedShippingMethods) + }).toThrow() + }) + }) + + describe('isPayPalPaymentMethodType', () => { + test('returns true for paypal payment method type', () => { + const result = isPayPalPaymentMethodType('paypal') + + expect(result).toBe(true) + }) + + test('returns true for venmo payment method type', () => { + const result = isPayPalPaymentMethodType('venmo') + + expect(result).toBe(true) + }) + + test('returns false for card payment method type', () => { + const result = isPayPalPaymentMethodType('card') + + expect(result).toBe(false) + }) + }) + + describe('findPaymentAccount', () => { + test('returns null when paymentMethodSetAccounts is null', () => { + const paymentMethods = [{paymentMethodType: 'card', accountId: 'acct_123'}] + + const result = findPaymentAccount(paymentMethods, null, 'card') + + expect(result).toBeNull() + }) + + test('returns null when paymentMethodSetAccounts is not an array', () => { + const paymentMethods = [{paymentMethodType: 'card', accountId: 'acct_123'}] + + const result = findPaymentAccount(paymentMethods, {}, 'card') + + expect(result).toBeNull() + }) + + test('returns null when payment method type not found in paymentMethods', () => { + const paymentMethods = [{paymentMethodType: 'paypal', accountId: 'paypal_acct'}] + const paymentMethodSetAccounts = [{vendor: 'Stripe', accountId: 'acct_123'}] + + const result = findPaymentAccount(paymentMethods, paymentMethodSetAccounts, 'card') + + expect(result).toBeNull() + }) + + test('returns null when payment method has no accountId', () => { + const paymentMethods = [{paymentMethodType: 'card'}] + const paymentMethodSetAccounts = [{vendor: 'Stripe', accountId: 'acct_123'}] + + const result = findPaymentAccount(paymentMethods, paymentMethodSetAccounts, 'card') + + expect(result).toBeNull() + }) + + test('returns null when paymentMethods is undefined', () => { + const paymentMethodSetAccounts = [{vendor: 'Stripe', accountId: 'acct_123'}] + + const result = findPaymentAccount(undefined, paymentMethodSetAccounts, 'card') + + expect(result).toBeNull() + }) + + test('returns account when payment method and account match', () => { + const paymentMethods = [{paymentMethodType: 'card', accountId: 'stripe_acct_123'}] + const paymentMethodSetAccounts = [ + {vendor: 'Stripe', accountId: 'stripe_acct_123'}, + {vendor: 'Paypal', accountId: 'paypal_acct'} + ] + + const result = findPaymentAccount(paymentMethods, paymentMethodSetAccounts, 'card') + + expect(result).toEqual({vendor: 'Stripe', accountId: 'stripe_acct_123'}) + }) + }) + + describe('createPaymentInstrumentBody', () => { + test('creates payment instrument body with all parameters', () => { + const result = createPaymentInstrumentBody({ + amount: 99.99, + paymentMethodType: 'card', + zoneId: 'us-west-1' + }) + + expect(result).toEqual({ + paymentMethodId: 'Salesforce Payments', + amount: 99.99, + paymentReferenceRequest: { + paymentMethodType: 'card', + zoneId: 'us-west-1' + } + }) + }) + + test('uses default zoneId when not provided', () => { + const result = createPaymentInstrumentBody({ + amount: 50.0, + paymentMethodType: 'paypal', + zoneId: null + }) + + expect(result.paymentReferenceRequest.zoneId).toBe('default') + }) + + test('uses default zoneId when undefined', () => { + const result = createPaymentInstrumentBody({ + amount: 75.5, + paymentMethodType: 'venmo', + zoneId: undefined + }) + + expect(result.paymentReferenceRequest.zoneId).toBe('default') + }) + + test('creates body for PayPal payment', () => { + const result = createPaymentInstrumentBody({ + amount: 125.0, + paymentMethodType: 'paypal', + zoneId: 'eu-west-1' + }) + + expect(result.paymentMethodId).toBe('Salesforce Payments') + expect(result.paymentReferenceRequest.paymentMethodType).toBe('paypal') + expect(result.amount).toBe(125.0) + }) + + test('creates body for Venmo payment', () => { + const result = createPaymentInstrumentBody({ + amount: 45.99, + paymentMethodType: 'venmo', + zoneId: 'us-east-1' + }) + + expect(result.paymentReferenceRequest.paymentMethodType).toBe('venmo') + }) + + test('creates body for card payment', () => { + const result = createPaymentInstrumentBody({ + amount: 199.99, + paymentMethodType: 'card', + zoneId: 'ap-southeast-1' + }) + + expect(result.paymentReferenceRequest.paymentMethodType).toBe('card') + }) + + test('handles decimal amounts', () => { + const result = createPaymentInstrumentBody({ + amount: 12.34, + paymentMethodType: 'card', + zoneId: 'default' + }) + + expect(result.amount).toBe(12.34) + }) + + test('handles zero amount', () => { + const result = createPaymentInstrumentBody({ + amount: 0, + paymentMethodType: 'card', + zoneId: 'default' + }) + + expect(result.amount).toBe(0) + }) + + 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', + paymentMethods, + paymentMethodSetAccounts + }) + + 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', () => { + const paymentMethods = [{paymentMethodType: 'card', accountId: 'acct_123'}] + const paymentMethodSetAccounts = [{vendor: 'Stripe', accountId: 'acct_123'}] + const result = createPaymentInstrumentBody({ + amount: 100.0, + paymentMethodType: 'card', + zoneId: 'default', + shippingPreference: undefined, + storePaymentMethod: true, + futureUsageOffSession: false, + paymentMethods, + paymentMethodSetAccounts + }) + + // Both gateway and gatewayProperties should be included (verified format with backend) + expect(result.paymentReferenceRequest.gateway).toBe('stripe') + 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', () => { + const paymentMethods = [{paymentMethodType: 'card', accountId: 'acct_123'}] + const paymentMethodSetAccounts = [{vendor: 'Stripe', accountId: 'acct_123'}] + const result = createPaymentInstrumentBody({ + amount: 100.0, + paymentMethodType: 'card', + zoneId: 'default', + shippingPreference: undefined, + storePaymentMethod: true, + futureUsageOffSession: true, + paymentMethods, + paymentMethodSetAccounts + }) + + // Both gateway and gatewayProperties should be included (verified format with backend) + expect(result.paymentReferenceRequest.gateway).toBe('stripe') + expect(result.paymentReferenceRequest.gatewayProperties.stripe).toEqual({ + setupFutureUsage: 'off_session' + }) + }) + + test('does not include Stripe gateway or gatewayProperties when storePaymentMethod is false (no setupFutureUsage)', () => { + const paymentMethods = [{paymentMethodType: 'card', accountId: 'acct_123'}] + const paymentMethodSetAccounts = [{vendor: 'Stripe', accountId: 'acct_123'}] + const result = createPaymentInstrumentBody({ + amount: 100.0, + paymentMethodType: 'card', + zoneId: 'default', + shippingPreference: undefined, + storePaymentMethod: false, + futureUsageOffSession: false, + paymentMethods, + paymentMethodSetAccounts + }) + + expect(result.paymentReferenceRequest.gateway).toBeUndefined() + expect(result.paymentReferenceRequest.gatewayProperties).toBeUndefined() + }) + + test('does not include shippingPreference when null', () => { + const result = createPaymentInstrumentBody({ + amount: 100.0, + paymentMethodType: 'card', + zoneId: 'default', + shippingPreference: null + }) + + expect(result.paymentReferenceRequest.shippingPreference).toBeUndefined() + }) + + test('includes gateway for Adyen when storePaymentMethod is true', () => { + const paymentMethods = [{paymentMethodType: 'card', accountId: 'adyen_acct_123'}] + const paymentMethodSetAccounts = [{vendor: 'Adyen', accountId: 'adyen_acct_123'}] + const result = createPaymentInstrumentBody({ + amount: 100.0, + paymentMethodType: 'card', + zoneId: 'default', + shippingPreference: undefined, + storePaymentMethod: true, + futureUsageOffSession: false, + paymentMethods, + paymentMethodSetAccounts + }) + + expect(result.paymentReferenceRequest.gateway).toBe('adyen') + expect(result.paymentReferenceRequest.gatewayProperties.adyen).toEqual({ + storePaymentMethod: true + }) + }) + + test('includes gateway for Adyen when payment data provided', () => { + const paymentData = { + paymentMethod: 'payment method', + returnUrl: 'return URL', + origin: 'origin', + lineItems: 'line items', + billingDetails: 'billing details', + otherStuff: 'to be ignored' + } + const paymentMethods = [{paymentMethodType: 'card', accountId: 'adyen_acct_123'}] + const paymentMethodSetAccounts = [{vendor: 'Adyen', accountId: 'adyen_acct_123'}] + const result = createPaymentInstrumentBody({ + amount: 100.0, + paymentMethodType: 'card', + zoneId: 'default', + shippingPreference: undefined, + storePaymentMethod: false, + paymentData, + futureUsageOffSession: false, + paymentMethods, + paymentMethodSetAccounts + }) + + expect(result.paymentReferenceRequest.gateway).toBe('adyen') + expect(result.paymentReferenceRequest.gatewayProperties.adyen).toEqual({ + paymentMethod: 'payment method', + returnUrl: 'return URL', + origin: 'origin', + lineItems: 'line items', + billingDetails: 'billing details' + }) + }) + + test('includes empty gateway properties for Adyen', () => { + const paymentMethods = [{paymentMethodType: 'card', accountId: 'adyen_acct_123'}] + const paymentMethodSetAccounts = [{vendor: 'Adyen', accountId: 'adyen_acct_123'}] + const result = createPaymentInstrumentBody({ + amount: 100.0, + paymentMethodType: 'card', + zoneId: 'default', + shippingPreference: undefined, + storePaymentMethod: false, + futureUsageOffSession: false, + paymentMethods, + paymentMethodSetAccounts + }) + + expect(result.paymentReferenceRequest.gateway).toBe('adyen') + expect(result.paymentReferenceRequest.gatewayProperties.adyen).toEqual({}) + }) + + test('does not include gateway for Adyen POST request even when storePaymentMethod is true', () => { + const paymentMethods = [{paymentMethodType: 'card', accountId: 'adyen_acct_123'}] + const paymentMethodSetAccounts = [{vendor: 'Adyen', accountId: 'adyen_acct_123'}] + const result = createPaymentInstrumentBody({ + amount: 100.0, + paymentMethodType: 'card', + zoneId: 'default', + shippingPreference: undefined, + storePaymentMethod: true, + futureUsageOffSession: false, + paymentMethods, + paymentMethodSetAccounts, + isPostRequest: true + }) + + expect(result.paymentReferenceRequest.gateway).toBeUndefined() + expect(result.paymentReferenceRequest.gatewayProperties).toBeUndefined() + }) + + test('does not include setupFutureUsage in POST request even when storePaymentMethod is true', () => { + const paymentMethods = [{paymentMethodType: 'card', accountId: 'acct_123'}] + const paymentMethodSetAccounts = [{vendor: 'Stripe', accountId: 'acct_123'}] + const result = createPaymentInstrumentBody({ + amount: 100.0, + paymentMethodType: 'card', + zoneId: 'default', + shippingPreference: undefined, + storePaymentMethod: true, + futureUsageOffSession: false, + paymentMethods, + paymentMethodSetAccounts, + isPostRequest: true + }) + + expect(result.paymentReferenceRequest.gateway).toBeUndefined() + expect(result.paymentReferenceRequest.gatewayProperties).toBeUndefined() + }) + }) + + describe('getGatewayFromPaymentMethod', () => { + test('returns Paypal for PayPal gateway', () => { + const paymentMethods = [{paymentMethodType: 'paypal', accountId: 'paypal_acct'}] + const paymentMethodSetAccounts = [{vendor: 'Paypal', accountId: 'paypal_acct'}] + + const result = getGatewayFromPaymentMethod( + 'paypal', + paymentMethods, + paymentMethodSetAccounts + ) + + expect(result).toBe('paypal') + }) + + test('returns Stripe for Stripe gateway', () => { + const paymentMethods = [{paymentMethodType: 'card', accountId: 'stripe_acct_123'}] + const paymentMethodSetAccounts = [{vendor: 'Stripe', accountId: 'stripe_acct_123'}] + + const result = getGatewayFromPaymentMethod( + 'card', + paymentMethods, + paymentMethodSetAccounts + ) + + expect(result).toBe('stripe') + }) + + test('returns Adyen for Adyen gateway', () => { + const paymentMethods = [{paymentMethodType: 'card', accountId: 'adyen_acct_123'}] + const paymentMethodSetAccounts = [{vendor: 'Adyen', accountId: 'adyen_acct_123'}] + + const result = getGatewayFromPaymentMethod( + 'card', + paymentMethods, + paymentMethodSetAccounts + ) + + expect(result).toBe('adyen') + }) + + test('returns null when payment method not found', () => { + const paymentMethods = [{paymentMethodType: 'other', accountId: 'other_acct'}] + const paymentMethodSetAccounts = [{vendor: 'Other', accountId: 'other_acct'}] + + const result = getGatewayFromPaymentMethod( + 'card', + paymentMethods, + paymentMethodSetAccounts + ) + + expect(result).toBeNull() + }) + + test('returns null when account not found', () => { + const paymentMethods = [{paymentMethodType: 'card', accountId: 'stripe_acct_123'}] + const paymentMethodSetAccounts = [{vendor: 'Stripe', accountId: 'different_acct'}] + + const result = getGatewayFromPaymentMethod( + 'card', + paymentMethods, + paymentMethodSetAccounts + ) + + expect(result).toBeNull() + }) + + test('returns null when paymentMethodSetAccounts is null', () => { + const paymentMethods = [{paymentMethodType: 'card', accountId: 'stripe_acct_123'}] + + const result = getGatewayFromPaymentMethod('card', paymentMethods, null) + + expect(result).toBeNull() + }) + + test('returns null when paymentMethodSetAccounts is not an array', () => { + const paymentMethods = [{paymentMethodType: 'card', accountId: 'stripe_acct_123'}] + + const result = getGatewayFromPaymentMethod('card', paymentMethods, {}) + + expect(result).toBeNull() + }) + + test('handles case-insensitive vendor matching', () => { + const paymentMethods = [{paymentMethodType: 'card', accountId: 'stripe_acct_123'}] + const paymentMethodSetAccounts = [{vendor: 'STRIPE', accountId: 'stripe_acct_123'}] + + const result = getGatewayFromPaymentMethod( + 'card', + paymentMethods, + paymentMethodSetAccounts + ) + + expect(result).toBe('stripe') + }) + + test('returns null for unknown vendor', () => { + const paymentMethods = [{paymentMethodType: 'card', accountId: 'other_acct_123'}] + const paymentMethodSetAccounts = [{vendor: 'OtherGateway', accountId: 'other_acct_123'}] + + const result = getGatewayFromPaymentMethod( + 'card', + paymentMethods, + paymentMethodSetAccounts + ) + + expect(result).toBeNull() + }) + }) + + describe('transformPaymentMethodReferences', () => { + test('returns empty array when customer is null', () => { + const result = transformPaymentMethodReferences(null, {}) + expect(result).toEqual([]) + }) + + test('returns empty array when customer is undefined', () => { + const result = transformPaymentMethodReferences(undefined, {}) + expect(result).toEqual([]) + }) + + test('returns empty array when customer has no paymentMethodReferences', () => { + const result = transformPaymentMethodReferences({}, {}) + expect(result).toEqual([]) + }) + + test('returns empty array when paymentConfig is null', () => { + const customer = { + paymentMethodReferences: [ + {id: 'pm_123', accountId: 'stripe-account-1', type: 'card', last4: '4242'} + ] + } + + const result = transformPaymentMethodReferences(customer, null) + + expect(result).toEqual([]) + }) + + test('returns empty array when paymentMethodReferences is not an array', () => { + const customer = {paymentMethodReferences: null} + const paymentConfig = { + paymentMethodSetAccounts: [{accountId: 'stripe-account-1', vendor: 'Stripe'}] + } + + const result = transformPaymentMethodReferences(customer, paymentConfig) + + expect(result).toEqual([]) + }) + + test('filters out when matching account has non-string accountId', () => { + const customer = { + paymentMethodReferences: [ + {id: 'pm_456', accountId: 999, type: 'card', last4: '1234'} + ] + } + const paymentConfig = { + paymentMethodSetAccounts: [{accountId: 999, vendor: 'Stripe'}] + } + + const result = transformPaymentMethodReferences(customer, paymentConfig) + + expect(result).toEqual([]) + }) + + test('transforms payment method reference with brand and last4', () => { + const customer = { + paymentMethodReferences: [ + { + id: 'pm_123', + accountId: 'stripe-account-1', + type: 'card', + brand: 'visa', + last4: '4242' + } + ] + } + const paymentConfig = { + paymentMethodSetAccounts: [ + { + accountId: 'stripe-account-1', + gatewayId: 'stripe-account-1', + vendor: 'Stripe' + } + ] + } + + const result = transformPaymentMethodReferences(customer, paymentConfig) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + accountId: 'stripe-account-1', + name: 'Card •••• 4242', + status: 'Active', + isDefault: false, + type: 'card', + accountHolderName: null, + id: 'pm_123', + gatewayTokenId: 'pm_123', + usageType: 'OffSession', + gatewayId: 'stripe-account-1', + gatewayCustomerId: null, + last4: '4242', + network: 'visa', + issuer: null, + expiryMonth: null, + expiryYear: null, + bankName: null, + savedByMerchant: false + }) + }) + + test('preserves brand variants in network field for SDK to derive display name', () => { + const paymentConfig = { + paymentMethodSetAccounts: [ + {accountId: 'adyen-1', gatewayId: 'adyen-1', vendor: 'Adyen'} + ] + } + const resultMc = transformPaymentMethodReferences( + { + paymentMethodReferences: [ + { + id: 'pm_mc', + accountId: 'adyen-1', + type: 'card', + brand: 'mc', + last4: '4444' + } + ] + }, + paymentConfig + ) + const resultMaster = transformPaymentMethodReferences( + { + paymentMethodReferences: [ + { + id: 'pm_master', + accountId: 'adyen-1', + type: 'card', + brand: 'master', + last4: '4444' + } + ] + }, + paymentConfig + ) + expect(resultMc).toHaveLength(1) + expect(resultMc[0].name).toBe('Card •••• 4444') + expect(resultMc[0].network).toBe('mc') + expect(resultMaster).toHaveLength(1) + expect(resultMaster[0].name).toBe('Card •••• 4444') + expect(resultMaster[0].network).toBe('master') + }) + + test('transforms payment method reference with type card and last4', () => { + const customer = { + paymentMethodReferences: [ + { + id: 'pm_456', + accountId: 'stripe-account-1', + type: 'card', + last4: '1234' + } + ] + } + const paymentConfig = { + paymentMethodSetAccounts: [ + { + accountId: 'stripe-account-1', + gatewayId: 'stripe-account-1', + vendor: 'Stripe' + } + ] + } + + const result = transformPaymentMethodReferences(customer, paymentConfig) + + expect(result).toHaveLength(1) + expect(result[0].name).toBe('Card •••• 1234') + }) + + test('transforms payment method reference with sepa_debit type', () => { + const customer = { + paymentMethodReferences: [ + { + id: 'pm_789', + accountId: 'stripe-account-1', + type: 'sepa_debit', + last4: '5678' + } + ] + } + const paymentConfig = { + paymentMethodSetAccounts: [ + { + accountId: 'stripe-account-1', + gatewayId: 'stripe-account-1', + vendor: 'Stripe' + } + ] + } + + const result = transformPaymentMethodReferences(customer, paymentConfig) + + expect(result).toHaveLength(1) + expect(result[0].name).toBe('Account ending in 5678') + }) + + test('uses default name when brand and last4 are missing', () => { + const customer = { + paymentMethodReferences: [ + { + id: 'pm_999', + accountId: 'stripe-account-1', + type: 'card' + } + ] + } + const paymentConfig = { + paymentMethodSetAccounts: [ + { + accountId: 'stripe-account-1', + gatewayId: 'stripe-account-1', + vendor: 'Stripe' + } + ] + } + + const result = transformPaymentMethodReferences(customer, paymentConfig) + + expect(result).toHaveLength(1) + expect(result[0].name).toBe('Saved Payment Method') + }) + + test('filters out payment methods without matching account', () => { + const customer = { + paymentMethodReferences: [ + { + id: 'pm_123', + accountId: 'stripe-account-1', + type: 'card', + brand: 'visa', + last4: '4242' + }, + { + id: 'pm_456', + accountId: 'non-existent-account', + type: 'card', + brand: 'mastercard', + last4: '5555' + } + ] + } + const paymentConfig = { + paymentMethodSetAccounts: [ + { + accountId: 'stripe-account-1', + gatewayId: 'stripe-account-1', + vendor: 'Stripe' + } + ] + } + + const result = transformPaymentMethodReferences(customer, paymentConfig) + + expect(result).toHaveLength(1) + expect(result[0].id).toBe('pm_123') + }) + + test('filters out payment methods without accountId', () => { + const customer = { + paymentMethodReferences: [ + { + id: 'pm_123', + accountId: 'stripe-account-1', + type: 'card', + brand: 'visa', + last4: '4242' + }, + { + id: 'pm_456', + type: 'card', + brand: 'mastercard', + last4: '5555' + } + ] + } + const paymentConfig = { + paymentMethodSetAccounts: [ + { + accountId: 'stripe-account-1', + gatewayId: 'stripe-account-1', + vendor: 'Stripe' + } + ] + } + + const result = transformPaymentMethodReferences(customer, paymentConfig) + + expect(result).toHaveLength(1) + expect(result[0].id).toBe('pm_123') + }) + + test('filters out payment methods when paymentMethodSetAccounts is empty', () => { + const customer = { + paymentMethodReferences: [ + { + id: 'pm_123', + accountId: 'stripe-account-1', + type: 'card', + brand: 'visa', + last4: '4242' + } + ] + } + const paymentConfig = { + paymentMethodSetAccounts: [] + } + + const result = transformPaymentMethodReferences(customer, paymentConfig) + + expect(result).toHaveLength(0) + }) + + test('uses accountId for gatewayId', () => { + const customer = { + paymentMethodReferences: [ + { + id: 'pm_123', + accountId: 'stripe-account-1', + type: 'card', + brand: 'visa', + last4: '4242' + } + ] + } + const paymentConfig = { + paymentMethodSetAccounts: [ + { + accountId: 'stripe-account-1', + vendor: 'Stripe' + } + ] + } + + const result = transformPaymentMethodReferences(customer, paymentConfig) + + expect(result).toHaveLength(1) + expect(result[0].gatewayId).toBe('stripe-account-1') + }) + + test('transforms multiple payment method references', () => { + const customer = { + paymentMethodReferences: [ + { + id: 'pm_123', + accountId: 'stripe-account-1', + type: 'card', + brand: 'visa', + last4: '4242' + }, + { + id: 'pm_456', + accountId: 'stripe-account-1', + type: 'card', + brand: 'mastercard', + last4: '5555' + } + ] + } + const paymentConfig = { + paymentMethodSetAccounts: [ + { + accountId: 'stripe-account-1', + gatewayId: 'stripe-account-1', + vendor: 'Stripe' + } + ] + } + + const result = transformPaymentMethodReferences(customer, paymentConfig) + + expect(result).toHaveLength(2) + expect(result[0].name).toBe('Card •••• 4242') + expect(result[1].name).toBe('Card •••• 5555') + }) + }) + + describe('getExpressPaymentMethodType', () => { + test('returns card for googlepay with Stripe gateway', () => { + const paymentMethods = [{paymentMethodType: 'googlepay', accountId: 'acct_123'}] + const paymentMethodSetAccounts = [{vendor: 'Stripe', accountId: 'acct_123'}] + const result = getExpressPaymentMethodType( + 'googlepay', + paymentMethods, + paymentMethodSetAccounts + ) + expect(result).toBe('card') + }) + + test('returns googlepay for googlepay with Adyen gateway', () => { + const paymentMethods = [{paymentMethodType: 'googlepay', accountId: 'adyen_acct'}] + const paymentMethodSetAccounts = [{vendor: 'Adyen', accountId: 'adyen_acct'}] + const result = getExpressPaymentMethodType( + 'googlepay', + paymentMethods, + paymentMethodSetAccounts + ) + expect(result).toBe('googlepay') + }) + + test('returns card for applepay with Stripe gateway', () => { + const paymentMethods = [{paymentMethodType: 'applepay', accountId: 'acct_123'}] + const paymentMethodSetAccounts = [{vendor: 'Stripe', accountId: 'acct_123'}] + const result = getExpressPaymentMethodType( + 'applepay', + paymentMethods, + paymentMethodSetAccounts + ) + expect(result).toBe('card') + }) + + test('returns type unchanged for non-mapped types', () => { + const paymentMethods = [{paymentMethodType: 'paypal', accountId: 'acct_123'}] + const paymentMethodSetAccounts = [{vendor: 'Stripe', accountId: 'acct_123'}] + const result = getExpressPaymentMethodType( + 'paypal', + paymentMethods, + paymentMethodSetAccounts + ) + expect(result).toBe('paypal') + }) + }) +}) diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index 758c258cb7..955212266c 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -88,6 +88,18 @@ module.exports = { }, storeLocatorEnabled: true, multishipEnabled: true, + // Salesforce Payments configuration + // Set enabled to true to enable Salesforce Payments (requires the Salesforce Payments feature toggle to be enabled on the Commerce Cloud instance). + // Set enabled to false to disable Salesforce Payments on the storefront (the Commerce Cloud feature toggle is unaffected). + // sdkUrl and metadataUrl are hosted on your Commerce Cloud instance. Replace with your instance hostname. + // This may be a demandware.net hostname (e.g., myinstance.unified.demandware.net) or a vanity/custom hostname. + // sdkUrl: 'https:///on/demandware.static/Sites-Site/-/-/internal/jscript/sfp/v1/sfp.js' + // metadataUrl: 'https:///on/demandware.static/Sites-Site/-/-/internal/metadata/v1.json' + sfPayments: { + enabled: false, + sdkUrl: '', + metadataUrl: '' + }, googleCloudAPI: { apiKey: process.env.GOOGLE_CLOUD_API_KEY } diff --git a/packages/template-retail-react-app/config/mocks/default.js b/packages/template-retail-react-app/config/mocks/default.js index 86d5afc6be..cb30baffca 100644 --- a/packages/template-retail-react-app/config/mocks/default.js +++ b/packages/template-retail-react-app/config/mocks/default.js @@ -123,7 +123,12 @@ module.exports = { enabled: false }, storeLocatorEnabled: true, - multishipEnabled: true + multishipEnabled: true, + sfPayments: { + enabled: true, + sdkUrl: '', + metadataUrl: '' + } }, // This list contains server-side only libraries that you don't want to be compiled by webpack externals: [], diff --git a/packages/template-retail-react-app/jest-setup.js b/packages/template-retail-react-app/jest-setup.js index bf95a9b070..9885a5bdc4 100644 --- a/packages/template-retail-react-app/jest-setup.js +++ b/packages/template-retail-react-app/jest-setup.js @@ -97,6 +97,13 @@ export const setupMockServer = () => { // Mock Data Cloud API rest.post('*.c360a.salesforce.com/web/events/*', (req, res, ctx) => { return res(ctx.delay(0), ctx.status(204), ctx.json({})) + }), + // Mock Salesforce Payments metadata + rest.get('*/payment-metadata', (req, res, ctx) => { + return res(ctx.delay(0), ctx.status(200), ctx.json({})) + }), + rest.get('*/configuration/shopper-configurations/*', (req, res, ctx) => { + return res(ctx.delay(0), ctx.status(200), ctx.json({})) }) ) } diff --git a/packages/template-retail-react-app/package.json b/packages/template-retail-react-app/package.json index 5cf914186b..0553ef24a6 100644 --- a/packages/template-retail-react-app/package.json +++ b/packages/template-retail-react-app/package.json @@ -101,11 +101,11 @@ "bundlesize": [ { "path": "build/main.js", - "maxSize": "95 kB" + "maxSize": "102 kB" }, { "path": "build/vendor.js", - "maxSize": "366 kB" + "maxSize": "370 kB" } ] } diff --git a/packages/template-retail-react-app/translations/en-GB.json b/packages/template-retail-react-app/translations/en-GB.json index b6d7a94ecf..b1db5757b5 100644 --- a/packages/template-retail-react-app/translations/en-GB.json +++ b/packages/template-retail-react-app/translations/en-GB.json @@ -360,12 +360,24 @@ "checkout.error.cannot_save_address": { "defaultMessage": "Could not save shipping address." }, + "checkout.heading.express_checkout": { + "defaultMessage": "Express Checkout" + }, "checkout.label.user_registration": { "defaultMessage": "Create an account to check out faster" }, "checkout.message.generic_error": { "defaultMessage": "An unexpected error occurred during checkout." }, + "checkout.message.payment_button_cancel": { + "defaultMessage": "Your attempted payment was unsuccessful. You have not been charged and your order has not been placed." + }, + "checkout.message.payment_confirm_failure": { + "defaultMessage": "Payment confirmation failed. Your order has been cancelled and your basket has been restored. Please try again or select a different payment method." + }, + "checkout.message.payment_processing_failed": { + "defaultMessage": "Payment processing failed. Your order has been cancelled and your basket has been restored. Please try again or select a different payment method." + }, "checkout.message.user_registration": { "defaultMessage": "Your payment, address, and contact information will be saved in a new account. Use the emailed one-time password (OTP) to create your account. After creating your account, use the Forgot Password function to set a new password." }, @@ -381,6 +393,12 @@ "checkout_confirmation.button.create_account": { "defaultMessage": "Create Account" }, + "checkout_confirmation.heading.afterpay_clearpay": { + "defaultMessage": "Afterpay/Clearpay" + }, + "checkout_confirmation.heading.bancontact": { + "defaultMessage": "Bancontact" + }, "checkout_confirmation.heading.billing_address": { "defaultMessage": "Billing Address" }, @@ -396,6 +414,15 @@ "checkout_confirmation.heading.delivery_number": { "defaultMessage": "Delivery {number}" }, + "checkout_confirmation.heading.eps": { + "defaultMessage": "EPS" + }, + "checkout_confirmation.heading.ideal": { + "defaultMessage": "iDEAL" + }, + "checkout_confirmation.heading.klarna": { + "defaultMessage": "Klarna" + }, "checkout_confirmation.heading.order_summary": { "defaultMessage": "Order Summary" }, @@ -411,6 +438,9 @@ "checkout_confirmation.heading.pickup_location_number": { "defaultMessage": "Pickup Location {number}" }, + "checkout_confirmation.heading.sepa_debit": { + "defaultMessage": "SEPA Debit" + }, "checkout_confirmation.heading.shipping_address": { "defaultMessage": "Shipping Address" }, @@ -420,6 +450,9 @@ "checkout_confirmation.heading.thank_you_for_order": { "defaultMessage": "Thank you for your order!" }, + "checkout_confirmation.heading.unknown": { + "defaultMessage": "Unknown" + }, "checkout_confirmation.label.free": { "defaultMessage": "Free" }, @@ -1401,6 +1434,21 @@ "password_reset_success.toast": { "defaultMessage": "Password Reset Success" }, + "payment_processing.error.unsuccessful": { + "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." + }, + "payment_processing.heading.payment_processing": { + "defaultMessage": "Payment Processing" + }, + "payment_processing.link.return_to_checkout": { + "defaultMessage": "Return to Checkout" + }, + "payment_processing.message.unexpected_error": { + "defaultMessage": "There was an unexpected error processing your payment." + }, + "payment_processing.message.working_on_your_payment": { + "defaultMessage": "Working on your payment..." + }, "payment_selection.button.view_all": { "defaultMessage": "View All ({count} more)" }, @@ -1597,6 +1645,9 @@ "product_view.link.full_details": { "defaultMessage": "See full details" }, + "product_view.prepareBasket": { + "defaultMessage": "Unable to determine product ID for basket" + }, "product_view.status.in_stock_at_store": { "defaultMessage": "In stock at {storeName}" }, @@ -1718,6 +1769,33 @@ "selected_refinements.filter.in_stock": { "defaultMessage": "In Stock" }, + "sf_payments_order_summary.label.bank.unknown": { + "defaultMessage": "Unknown" + }, + "sf_payments_order_summary.label.brand.amex": { + "defaultMessage": "American Express" + }, + "sf_payments_order_summary.label.brand.diners": { + "defaultMessage": "Diners Club" + }, + "sf_payments_order_summary.label.brand.discover": { + "defaultMessage": "Discover" + }, + "sf_payments_order_summary.label.brand.jcb": { + "defaultMessage": "JCB" + }, + "sf_payments_order_summary.label.brand.mastercard": { + "defaultMessage": "MasterCard" + }, + "sf_payments_order_summary.label.brand.unionpay": { + "defaultMessage": "China UnionPay" + }, + "sf_payments_order_summary.label.brand.unknown": { + "defaultMessage": "Unknown" + }, + "sf_payments_order_summary.label.brand.visa": { + "defaultMessage": "Visa" + }, "shipping_address.action.ship_to_multiple_addresses": { "defaultMessage": "Ship to multiple addresses" }, @@ -2015,6 +2093,9 @@ "use_address_fields.error.please_enter_phone_number": { "defaultMessage": "Please enter your phone number." }, + "use_address_fields.error.please_enter_postal_code": { + "defaultMessage": "Please enter your postal code." + }, "use_address_fields.error.please_enter_your_postal_or_zip": { "defaultMessage": "Please enter your zip code.", "description": "Error message for a blank zip code (US-specific checkout)" diff --git a/packages/template-retail-react-app/translations/en-US.json b/packages/template-retail-react-app/translations/en-US.json index b6d7a94ecf..b1db5757b5 100644 --- a/packages/template-retail-react-app/translations/en-US.json +++ b/packages/template-retail-react-app/translations/en-US.json @@ -360,12 +360,24 @@ "checkout.error.cannot_save_address": { "defaultMessage": "Could not save shipping address." }, + "checkout.heading.express_checkout": { + "defaultMessage": "Express Checkout" + }, "checkout.label.user_registration": { "defaultMessage": "Create an account to check out faster" }, "checkout.message.generic_error": { "defaultMessage": "An unexpected error occurred during checkout." }, + "checkout.message.payment_button_cancel": { + "defaultMessage": "Your attempted payment was unsuccessful. You have not been charged and your order has not been placed." + }, + "checkout.message.payment_confirm_failure": { + "defaultMessage": "Payment confirmation failed. Your order has been cancelled and your basket has been restored. Please try again or select a different payment method." + }, + "checkout.message.payment_processing_failed": { + "defaultMessage": "Payment processing failed. Your order has been cancelled and your basket has been restored. Please try again or select a different payment method." + }, "checkout.message.user_registration": { "defaultMessage": "Your payment, address, and contact information will be saved in a new account. Use the emailed one-time password (OTP) to create your account. After creating your account, use the Forgot Password function to set a new password." }, @@ -381,6 +393,12 @@ "checkout_confirmation.button.create_account": { "defaultMessage": "Create Account" }, + "checkout_confirmation.heading.afterpay_clearpay": { + "defaultMessage": "Afterpay/Clearpay" + }, + "checkout_confirmation.heading.bancontact": { + "defaultMessage": "Bancontact" + }, "checkout_confirmation.heading.billing_address": { "defaultMessage": "Billing Address" }, @@ -396,6 +414,15 @@ "checkout_confirmation.heading.delivery_number": { "defaultMessage": "Delivery {number}" }, + "checkout_confirmation.heading.eps": { + "defaultMessage": "EPS" + }, + "checkout_confirmation.heading.ideal": { + "defaultMessage": "iDEAL" + }, + "checkout_confirmation.heading.klarna": { + "defaultMessage": "Klarna" + }, "checkout_confirmation.heading.order_summary": { "defaultMessage": "Order Summary" }, @@ -411,6 +438,9 @@ "checkout_confirmation.heading.pickup_location_number": { "defaultMessage": "Pickup Location {number}" }, + "checkout_confirmation.heading.sepa_debit": { + "defaultMessage": "SEPA Debit" + }, "checkout_confirmation.heading.shipping_address": { "defaultMessage": "Shipping Address" }, @@ -420,6 +450,9 @@ "checkout_confirmation.heading.thank_you_for_order": { "defaultMessage": "Thank you for your order!" }, + "checkout_confirmation.heading.unknown": { + "defaultMessage": "Unknown" + }, "checkout_confirmation.label.free": { "defaultMessage": "Free" }, @@ -1401,6 +1434,21 @@ "password_reset_success.toast": { "defaultMessage": "Password Reset Success" }, + "payment_processing.error.unsuccessful": { + "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." + }, + "payment_processing.heading.payment_processing": { + "defaultMessage": "Payment Processing" + }, + "payment_processing.link.return_to_checkout": { + "defaultMessage": "Return to Checkout" + }, + "payment_processing.message.unexpected_error": { + "defaultMessage": "There was an unexpected error processing your payment." + }, + "payment_processing.message.working_on_your_payment": { + "defaultMessage": "Working on your payment..." + }, "payment_selection.button.view_all": { "defaultMessage": "View All ({count} more)" }, @@ -1597,6 +1645,9 @@ "product_view.link.full_details": { "defaultMessage": "See full details" }, + "product_view.prepareBasket": { + "defaultMessage": "Unable to determine product ID for basket" + }, "product_view.status.in_stock_at_store": { "defaultMessage": "In stock at {storeName}" }, @@ -1718,6 +1769,33 @@ "selected_refinements.filter.in_stock": { "defaultMessage": "In Stock" }, + "sf_payments_order_summary.label.bank.unknown": { + "defaultMessage": "Unknown" + }, + "sf_payments_order_summary.label.brand.amex": { + "defaultMessage": "American Express" + }, + "sf_payments_order_summary.label.brand.diners": { + "defaultMessage": "Diners Club" + }, + "sf_payments_order_summary.label.brand.discover": { + "defaultMessage": "Discover" + }, + "sf_payments_order_summary.label.brand.jcb": { + "defaultMessage": "JCB" + }, + "sf_payments_order_summary.label.brand.mastercard": { + "defaultMessage": "MasterCard" + }, + "sf_payments_order_summary.label.brand.unionpay": { + "defaultMessage": "China UnionPay" + }, + "sf_payments_order_summary.label.brand.unknown": { + "defaultMessage": "Unknown" + }, + "sf_payments_order_summary.label.brand.visa": { + "defaultMessage": "Visa" + }, "shipping_address.action.ship_to_multiple_addresses": { "defaultMessage": "Ship to multiple addresses" }, @@ -2015,6 +2093,9 @@ "use_address_fields.error.please_enter_phone_number": { "defaultMessage": "Please enter your phone number." }, + "use_address_fields.error.please_enter_postal_code": { + "defaultMessage": "Please enter your postal code." + }, "use_address_fields.error.please_enter_your_postal_or_zip": { "defaultMessage": "Please enter your zip code.", "description": "Error message for a blank zip code (US-specific checkout)"