diff --git a/changelog.txt b/changelog.txt index ad2a48a1ba..5cd9c2f73c 100644 --- a/changelog.txt +++ b/changelog.txt @@ -11,6 +11,7 @@ * Tweak - Small improvements to e2e tests * Fix - Fix unnecessary Stripe API calls when rendering subscription details * Add - Adds a new action (`wc_stripe_webhook_received`) to allow additional actions to be taken for webhook notifications from Stripe +* Update - Show all available payment methods before unavailable payment methods = 9.8.1 - 2025-08-15 = * Fix - Remove connection type requirement from PMC sync migration attempt diff --git a/client/components/payment-method-missing-currency-pill/__tests__/index.test.js b/client/components/payment-method-missing-currency-pill/__tests__/index.test.js index 996f91afcf..fc837ed06e 100644 --- a/client/components/payment-method-missing-currency-pill/__tests__/index.test.js +++ b/client/components/payment-method-missing-currency-pill/__tests__/index.test.js @@ -2,6 +2,8 @@ import React from 'react'; import { screen, render } from '@testing-library/react'; import PaymentMethodMissingCurrencyPill from '..'; import { usePaymentMethodCurrencies } from 'utils/use-payment-method-currencies'; +import usePaymentMethodUnavailableReason from 'utils/use-payment-method-unavailable-reason'; +import { PAYMENT_METHOD_UNAVAILABLE_REASONS } from 'wcstripe/stripe-utils/constants'; jest.mock( '../../../payment-methods-map', () => ( { card: { currencies: [] }, @@ -12,13 +14,19 @@ jest.mock( 'utils/use-payment-method-currencies', () => ( { usePaymentMethodCurrencies: jest.fn(), } ) ); +jest.mock( 'utils/use-payment-method-unavailable-reason' ); + describe( 'PaymentMethodMissingCurrencyPill', () => { beforeEach( () => { global.wcSettings = { currency: { code: 'USD' } }; usePaymentMethodCurrencies.mockReturnValue( [ 'EUR' ] ); } ); - it( 'should render the "Requires currency" text', () => { + it( 'should render the "Requires currency" text when currency is not supported', () => { + usePaymentMethodUnavailableReason.mockReturnValue( + PAYMENT_METHOD_UNAVAILABLE_REASONS.UNSUPPORTED_CURRENCY + ); + render( ); @@ -26,20 +34,13 @@ describe( 'PaymentMethodMissingCurrencyPill', () => { expect( screen.queryByText( 'Requires currency' ) ).toBeInTheDocument(); } ); - it( 'should not render when currency matches', () => { - global.wcSettings = { currency: { code: 'EUR' } }; + it( 'should not render when currency is supported', () => { + usePaymentMethodUnavailableReason.mockReturnValue( null ); + const { container } = render( ); expect( container.firstChild ).toBeNull(); } ); - - it( 'should render when currency differs', () => { - render( - - ); - - expect( screen.queryByText( 'Requires currency' ) ).toBeInTheDocument(); - } ); } ); diff --git a/client/components/payment-method-missing-currency-pill/index.js b/client/components/payment-method-missing-currency-pill/index.js index 604f43afbb..2efdd864b8 100644 --- a/client/components/payment-method-missing-currency-pill/index.js +++ b/client/components/payment-method-missing-currency-pill/index.js @@ -5,7 +5,8 @@ import interpolateComponents from 'interpolate-components'; import { Icon, info } from '@wordpress/icons'; import Popover from 'wcstripe/components/popover'; import { usePaymentMethodCurrencies } from 'utils/use-payment-method-currencies'; -import { PAYMENT_METHOD_CARD } from 'wcstripe/stripe-utils/constants'; +import usePaymentMethodUnavailableReason from 'utils/use-payment-method-unavailable-reason'; +import { PAYMENT_METHOD_UNAVAILABLE_REASONS } from 'wcstripe/stripe-utils/constants'; const StyledPill = styled.span` display: inline-flex; @@ -47,47 +48,47 @@ const IconComponent = ( { children, ...props } ) => ( const PaymentMethodMissingCurrencyPill = ( { id, label } ) => { const paymentMethodCurrencies = usePaymentMethodCurrencies( id ); - const storeCurrency = window?.wcSettings?.currency?.code; + const unavailableReason = usePaymentMethodUnavailableReason( id ); if ( - id !== PAYMENT_METHOD_CARD && - ! paymentMethodCurrencies.includes( storeCurrency ) + unavailableReason !== + PAYMENT_METHOD_UNAVAILABLE_REASONS.UNSUPPORTED_CURRENCY ) { - return ( - - { __( 'Requires currency', 'woocommerce-gateway-stripe' ) } - { - // Stop propagation is necessary so it doesn't trigger the tooltip click event. - ev.stopPropagation(); - } } - /> - ), - }, - } ) } - /> - - ); + return null; } - return null; + return ( + + { __( 'Requires currency', 'woocommerce-gateway-stripe' ) } + { + // Stop propagation is necessary so it doesn't trigger the tooltip click event. + ev.stopPropagation(); + } } + /> + ), + }, + } ) } + /> + + ); }; export default PaymentMethodMissingCurrencyPill; diff --git a/client/components/payment-method-unavailable-due-conflict-pill/index.js b/client/components/payment-method-unavailable-due-conflict-pill/index.js index 7610b54346..9304282ee4 100644 --- a/client/components/payment-method-unavailable-due-conflict-pill/index.js +++ b/client/components/payment-method-unavailable-due-conflict-pill/index.js @@ -1,14 +1,11 @@ -/* global wc_stripe_settings_params */ import { __, sprintf } from '@wordpress/i18n'; import React from 'react'; import styled from '@emotion/styled'; import interpolateComponents from 'interpolate-components'; import { Icon, info } from '@wordpress/icons'; +import usePaymentMethodUnavailableReason from 'utils/use-payment-method-unavailable-reason'; import Popover from 'wcstripe/components/popover'; -import { - PAYMENT_METHOD_AFFIRM, - PAYMENT_METHOD_KLARNA, -} from 'wcstripe/stripe-utils/constants'; +import { PAYMENT_METHOD_UNAVAILABLE_REASONS } from 'wcstripe/stripe-utils/constants'; const StyledPill = styled.span` display: inline-flex; @@ -49,48 +46,45 @@ const IconComponent = ( { children, ...props } ) => ( ); const PaymentMethodUnavailableDueConflictPill = ( { id, label } ) => { + const unavailableReason = usePaymentMethodUnavailableReason( id ); + if ( - ( id === PAYMENT_METHOD_AFFIRM && - // eslint-disable-next-line camelcase - wc_stripe_settings_params.has_affirm_gateway_plugin ) || - ( id === PAYMENT_METHOD_KLARNA && - // eslint-disable-next-line camelcase - wc_stripe_settings_params.has_klarna_gateway_plugin ) + unavailableReason !== + PAYMENT_METHOD_UNAVAILABLE_REASONS.OFFICIAL_PLUGIN_CONFLICT ) { - return ( - - { __( 'Has plugin conflict', 'woocommerce-gateway-stripe' ) } - { - // Stop propagation is necessary so it doesn't trigger the tooltip click event. - ev.stopPropagation(); - } } - /> - ), - }, - } ) } - /> - - ); + return null; } - - return null; + return ( + + { __( 'Has plugin conflict', 'woocommerce-gateway-stripe' ) } + { + // Stop propagation is necessary so it doesn't trigger the tooltip click event. + ev.stopPropagation(); + } } + /> + ), + }, + } ) } + /> + + ); }; export default PaymentMethodUnavailableDueConflictPill; diff --git a/client/settings/general-settings-section/__tests__/general-settings-section.test.js b/client/settings/general-settings-section/__tests__/general-settings-section.test.js index 2defb652e2..56f2613c9a 100644 --- a/client/settings/general-settings-section/__tests__/general-settings-section.test.js +++ b/client/settings/general-settings-section/__tests__/general-settings-section.test.js @@ -1,3 +1,4 @@ +import { getSetting } from '@woocommerce/settings'; import React from 'react'; import { screen, render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -12,15 +13,17 @@ import { useGetOrderedPaymentMethodIds, useIsPMCEnabled, } from 'wcstripe/data'; -import { usePaymentMethodCurrencies } from 'utils/use-payment-method-currencies'; +import getPaymentMethodUnavailableReason from 'utils/get-payment-method-unavailable-reason'; import { useAccount, useGetCapabilities } from 'wcstripe/data/account'; import { + PAYMENT_METHOD_AFFIRM, PAYMENT_METHOD_ALIPAY, PAYMENT_METHOD_CARD, PAYMENT_METHOD_EPS, PAYMENT_METHOD_LINK, PAYMENT_METHOD_SEPA, PAYMENT_METHOD_SOFORT, + PAYMENT_METHOD_UNAVAILABLE_REASONS, } from 'wcstripe/stripe-utils/constants'; jest.mock( 'wcstripe/data', () => ( { @@ -34,13 +37,14 @@ jest.mock( 'wcstripe/data', () => ( { useGetOrderedPaymentMethodIds: jest.fn(), useIsPMCEnabled: jest.fn(), } ) ); -jest.mock( 'utils/use-payment-method-currencies', () => ( { - usePaymentMethodCurrencies: jest.fn().mockReturnValue( [] ), -} ) ); +jest.mock( 'utils/get-payment-method-unavailable-reason' ); jest.mock( 'wcstripe/data/account', () => ( { useAccount: jest.fn(), useGetCapabilities: jest.fn(), } ) ); +jest.mock( '@woocommerce/settings', () => ( { + getSetting: jest.fn(), +} ) ); jest.mock( '@wordpress/data', () => ( { useDispatch: jest.fn().mockReturnValue( {} ), createReduxStore: jest.fn(), @@ -58,14 +62,25 @@ jest.mock( '../../loadable-settings-section', () => ( { children } ) => describe( 'GeneralSettingsSection', () => { const globalValues = global.wcSettings; + /** + * Helper to ensure that the wcSettings global and the getSetting() helper are in sync. + * + * @param {string} currencyCode Currency code to set. + */ + const mockCurrencyCode = ( currencyCode ) => { + global.wcSettings = { currency: { code: currencyCode } }; + getSetting.mockReturnValue( { code: currencyCode } ); + }; + beforeEach( () => { - global.wcSettings = { currency: { code: 'EUR' } }; + mockCurrencyCode( 'EUR' ); global.wc_stripe_settings_params = { are_apms_deprecated: false }; useGetCapabilities.mockReturnValue( { card_payments: 'active', alipay_payments: 'active', } ); useManualCapture.mockReturnValue( [ false ] ); + getPaymentMethodUnavailableReason.mockReturnValue( null ); useGetAvailablePaymentMethodIds.mockReturnValue( [ PAYMENT_METHOD_CARD, PAYMENT_METHOD_LINK, @@ -333,8 +348,8 @@ describe( 'GeneralSettingsSection', () => { expect( updateEnabledMethodsMock ).toHaveBeenCalled(); } ); - it( 'does not display the payment method checkbox when currency is not supprted', () => { - global.wcSettings = { currency: { code: 'USD' } }; + it( 'does not display the payment method checkbox when currency is not supported', () => { + mockCurrencyCode( 'USD' ); useGetAvailablePaymentMethodIds.mockReturnValue( [ PAYMENT_METHOD_CARD, PAYMENT_METHOD_ALIPAY, @@ -515,16 +530,31 @@ describe( 'GeneralSettingsSection', () => { ).not.toBeInTheDocument(); } ); - it( 'should disable the payment method checkbox when currency is not supported', () => { + it( 'should disable the payment method checkbox and show the requires currency notice when currency is not supported', () => { + useEnabledPaymentMethodIds.mockReturnValue( [ + [ PAYMENT_METHOD_CARD ], + ] ); useGetAvailablePaymentMethodIds.mockReturnValue( [ PAYMENT_METHOD_CARD, PAYMENT_METHOD_ALIPAY, ] ); - useEnabledPaymentMethodIds.mockReturnValue( [ - [ PAYMENT_METHOD_CARD ], - ] ); - usePaymentMethodCurrencies.mockReturnValue( [ 'USD' ] ); - window.wcSettings = { currency: { code: 'EUR' } }; + useGetOrderedPaymentMethodIds.mockReturnValue( { + orderedPaymentMethodIds: [ + PAYMENT_METHOD_CARD, + PAYMENT_METHOD_ALIPAY, + ], + setOrderedPaymentMethodIds: jest.fn(), + saveOrderedPaymentMethodIds: jest.fn(), + } ); + getPaymentMethodUnavailableReason.mockImplementation( + ( { paymentMethodId } ) => { + if ( paymentMethodId === PAYMENT_METHOD_ALIPAY ) { + return PAYMENT_METHOD_UNAVAILABLE_REASONS.UNSUPPORTED_CURRENCY; + } + return null; + } + ); + mockCurrencyCode( 'EUR' ); render( @@ -535,19 +565,19 @@ describe( 'GeneralSettingsSection', () => { screen.queryByRole( 'checkbox', { name: /Credit card/, } ) + ).toBeEnabled(); + + expect( + screen.queryByRole( 'checkbox', { + name: 'Alipay', + } ) ).toBeDisabled(); + + expect( screen.queryByText( 'Requires currency' ) ).toBeVisible(); } ); - it( 'should enable the payment method checkbox when currency is supported', () => { - useGetAvailablePaymentMethodIds.mockReturnValue( [ - PAYMENT_METHOD_CARD, - PAYMENT_METHOD_ALIPAY, - ] ); - useEnabledPaymentMethodIds.mockReturnValue( [ - [ PAYMENT_METHOD_CARD ], - ] ); - usePaymentMethodCurrencies.mockReturnValue( [ 'USD' ] ); - window.wcSettings = { currency: { code: 'USD' } }; + it( 'should enable the payment method checkbox and not show the requires currency notice when currency is supported', () => { + mockCurrencyCode( 'USD' ); render( @@ -559,5 +589,86 @@ describe( 'GeneralSettingsSection', () => { name: /Credit card/, } ) ).toBeEnabled(); + + expect( + screen.queryByText( 'Requires currency' ) + ).not.toBeInTheDocument(); + } ); + + it( 'should show the payment method with supported currencies before plugin conflicts and unsupported currencies', () => { + useGetAvailablePaymentMethodIds.mockReturnValue( [ + PAYMENT_METHOD_CARD, + PAYMENT_METHOD_ALIPAY, + PAYMENT_METHOD_AFFIRM, + PAYMENT_METHOD_SEPA, + ] ); + useGetOrderedPaymentMethodIds.mockReturnValue( { + orderedPaymentMethodIds: [ + PAYMENT_METHOD_CARD, + PAYMENT_METHOD_ALIPAY, + PAYMENT_METHOD_AFFIRM, + PAYMENT_METHOD_SEPA, + ], + setOrderedPaymentMethodIds: jest.fn(), + saveOrderedPaymentMethodIds: jest.fn(), + } ); + + getPaymentMethodUnavailableReason.mockImplementation( + ( { paymentMethodId } ) => { + if ( paymentMethodId === PAYMENT_METHOD_ALIPAY ) { + return PAYMENT_METHOD_UNAVAILABLE_REASONS.UNSUPPORTED_CURRENCY; + } + if ( paymentMethodId === PAYMENT_METHOD_AFFIRM ) { + return PAYMENT_METHOD_UNAVAILABLE_REASONS.OFFICIAL_PLUGIN_CONFLICT; + } + return null; + } + ); + mockCurrencyCode( 'EUR' ); + + render( + + + + ); + + const cardElement = screen.getByRole( 'checkbox', { + name: /Credit card/, + } ); + const alipayElement = screen.getByRole( 'checkbox', { + name: 'Alipay', + } ); + const affirmElement = screen.getByRole( 'checkbox', { + name: 'Affirm', + } ); + const sepaElement = screen.getByRole( 'checkbox', { + name: 'Direct debit payment', + } ); + + expect( cardElement ).toBeEnabled(); + expect( alipayElement ).not.toBeEnabled(); + expect( affirmElement ).not.toBeEnabled(); + expect( sepaElement ).toBeEnabled(); + + // Card should be first + expect( cardElement.compareDocumentPosition( alipayElement ) ).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ); + expect( cardElement.compareDocumentPosition( sepaElement ) ).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ); + + // SEPA should be before AliPay and Affirm + expect( sepaElement.compareDocumentPosition( alipayElement ) ).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ); + expect( sepaElement.compareDocumentPosition( affirmElement ) ).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ); + + // Affirm should be before AliPay + expect( affirmElement.compareDocumentPosition( alipayElement ) ).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ); } ); } ); diff --git a/client/settings/general-settings-section/payment-method.js b/client/settings/general-settings-section/payment-method.js index e511c9ffde..9476e53fae 100644 --- a/client/settings/general-settings-section/payment-method.js +++ b/client/settings/general-settings-section/payment-method.js @@ -12,10 +12,9 @@ import { PAYMENT_METHOD_AFFIRM, PAYMENT_METHOD_AFTERPAY_CLEARPAY, PAYMENT_METHOD_CARD, - PAYMENT_METHOD_KLARNA, } from 'wcstripe/stripe-utils/constants'; import PaymentMethodFeesPill from 'wcstripe/components/payment-method-fees-pill'; -import { usePaymentMethodCurrencies } from 'utils/use-payment-method-currencies'; +import usePaymentMethodUnavailableReason from 'utils/use-payment-method-unavailable-reason'; const ListElement = styled.li` display: flex; @@ -107,7 +106,9 @@ const StyledFees = styled( PaymentMethodFeesPill )` const PaymentMethod = ( { method, data } ) => { const [ isOCEnabled ] = useIsOCEnabled(); const [ isManualCaptureEnabled ] = useManualCapture(); - const paymentMethodCurrencies = usePaymentMethodCurrencies( method ); + const paymentMethodUnavailableReason = usePaymentMethodUnavailableReason( + method + ); const { Icon, @@ -127,16 +128,7 @@ const PaymentMethod = ( { method, data } ) => { wc_stripe_settings_params.are_apms_deprecated && method !== PAYMENT_METHOD_CARD; - const storeCurrency = window?.wcSettings?.currency?.code; - const isDisabled = - ( paymentMethodCurrencies.length && - ! paymentMethodCurrencies.includes( storeCurrency ) ) || - ( PAYMENT_METHOD_AFFIRM === method && - // eslint-disable-next-line camelcase - wc_stripe_settings_params.has_affirm_gateway_plugin ) || - ( PAYMENT_METHOD_KLARNA === method && - // eslint-disable-next-line camelcase - wc_stripe_settings_params.has_klarna_gateway_plugin ); + const isDisabled = paymentMethodUnavailableReason !== null; const isDisabledButChecked = PAYMENT_METHOD_CARD === method && isOCEnabled; diff --git a/client/settings/general-settings-section/payment-methods-list.js b/client/settings/general-settings-section/payment-methods-list.js index e1ad8b6e6f..80d2813ddb 100644 --- a/client/settings/general-settings-section/payment-methods-list.js +++ b/client/settings/general-settings-section/payment-methods-list.js @@ -1,14 +1,17 @@ /* global wc_stripe_settings_params */ +import { getSetting } from '@woocommerce/settings'; import { sprintf } from '@wordpress/i18n'; -import React from 'react'; +import React, { useContext, useMemo } from 'react'; import styled from '@emotion/styled'; import classnames from 'classnames'; import { Icon as IconComponent, dragHandle } from '@wordpress/icons'; import { Reorder } from 'framer-motion'; import interpolateComponents from 'interpolate-components'; import PaymentMethodsMap from '../../payment-methods-map'; +import UpeToggleContext from '../upe-toggle/context'; import PaymentMethodDescription from './payment-method-description'; import PaymentMethod from './payment-method'; +import getPaymentMethodUnavailableReason from 'utils/get-payment-method-unavailable-reason'; import { useEnabledPaymentMethodIds, useGetOrderedPaymentMethodIds, @@ -22,6 +25,7 @@ import { PAYMENT_METHOD_CARD, PAYMENT_METHOD_GIROPAY, PAYMENT_METHOD_SOFORT, + PAYMENT_METHOD_UNAVAILABLE_REASONS, } from 'wcstripe/stripe-utils/constants'; const List = styled.ul` @@ -126,6 +130,51 @@ const StyledFees = styled( PaymentMethodFeesPill )` flex: 1 0 auto; `; +/** + * Hook to sort the payment methods based on whether the payment method is supported by the store currency. + * Unsupported payment methods are placed at the end of the list so irrelevant payment methods don't clutter the screen. + * + * @param {string[]} orderedPaymentMethodIds Ordered payment method IDs. + * @return {string[]} Sorted payment method IDs. + */ +const usePaymentMethodsSortedByAvailability = ( orderedPaymentMethodIds ) => { + const { isUpeEnabled } = useContext( UpeToggleContext ); + + const storeCurrencyCode = getSetting( 'currency' )?.code; + + const sortedPaymentMethodIds = useMemo( () => { + const availablePaymentMethodIds = []; + const pluginConflictPaymentMethodIds = []; + const unavailablePaymentMethodIds = []; + + orderedPaymentMethodIds.forEach( ( paymentMethodId ) => { + const unavailableReason = getPaymentMethodUnavailableReason( { + paymentMethodId, + isUpeEnabled, + storeCurrencyCode, + } ); + if ( unavailableReason === null ) { + availablePaymentMethodIds.push( paymentMethodId ); + } else if ( + unavailableReason === + PAYMENT_METHOD_UNAVAILABLE_REASONS.OFFICIAL_PLUGIN_CONFLICT + ) { + pluginConflictPaymentMethodIds.push( paymentMethodId ); + } else { + unavailablePaymentMethodIds.push( paymentMethodId ); + } + } ); + + return [ + ...availablePaymentMethodIds, + ...pluginConflictPaymentMethodIds, + ...unavailablePaymentMethodIds, + ]; + }, [ orderedPaymentMethodIds, storeCurrencyCode, isUpeEnabled ] ); + + return sortedPaymentMethodIds; +}; + /** * Formats the payment method description with the account default currency. * @@ -190,13 +239,17 @@ const GeneralSettingsSection = ( { isChangingDisplayOrder } ) => { setOrderedPaymentMethodIds( newOrderedPaymentMethodIds ); }; + const sortedPaymentMethodIds = usePaymentMethodsSortedByAvailability( + availablePaymentMethods + ); + return isChangingDisplayOrder ? ( - { availablePaymentMethods.map( ( method ) => { + { sortedPaymentMethodIds.map( ( method ) => { // Skip giropay as it was deprecated by Jun, 30th 2024. if ( method === PAYMENT_METHOD_GIROPAY ) { return null; @@ -258,7 +311,7 @@ const GeneralSettingsSection = ( { isChangingDisplayOrder } ) => { ) : ( - { availablePaymentMethods.map( ( method ) => { + { sortedPaymentMethodIds.map( ( method ) => { // Skip giropay as it was deprecated by Jun, 30th 2024. if ( method === PAYMENT_METHOD_GIROPAY ) { return null; diff --git a/client/stripe-utils/constants.js b/client/stripe-utils/constants.js index 796085f4b8..6eb590910f 100644 --- a/client/stripe-utils/constants.js +++ b/client/stripe-utils/constants.js @@ -158,3 +158,8 @@ export const BNPL_METHODS = [ PAYMENT_METHOD_AFTERPAY_CLEARPAY, PAYMENT_METHOD_KLARNA, ]; + +export const PAYMENT_METHOD_UNAVAILABLE_REASONS = { + UNSUPPORTED_CURRENCY: 'unsupported_currency', + OFFICIAL_PLUGIN_CONFLICT: 'official_plugin_conflict', +}; diff --git a/client/utils/__tests__/get-payment-method-unavailable-reason.test.js b/client/utils/__tests__/get-payment-method-unavailable-reason.test.js new file mode 100644 index 0000000000..b90aea3304 --- /dev/null +++ b/client/utils/__tests__/get-payment-method-unavailable-reason.test.js @@ -0,0 +1,115 @@ +import { + PAYMENT_METHOD_AFFIRM, + PAYMENT_METHOD_CARD, + PAYMENT_METHOD_KLARNA, + PAYMENT_METHOD_SEPA, + PAYMENT_METHOD_UNAVAILABLE_REASONS, +} from 'wcstripe/stripe-utils/constants'; +import getPaymentMethodUnavailableReason from 'utils/get-payment-method-unavailable-reason'; +import { getPaymentMethodCurrencies } from 'utils/use-payment-method-currencies'; + +jest.mock( 'utils/use-payment-method-currencies', () => ( { + getPaymentMethodCurrencies: jest.fn(), +} ) ); + +describe( 'getPaymentMethodUnavailableReason', () => { + beforeEach( () => { + global.wc_stripe_settings_params = { + has_klarna_gateway_plugin: false, + has_affirm_gateway_plugin: false, + }; + getPaymentMethodCurrencies.mockImplementation( ( paymentMethodId ) => { + if ( paymentMethodId === PAYMENT_METHOD_CARD ) { + return []; + } + if ( paymentMethodId === PAYMENT_METHOD_SEPA ) { + return [ 'EUR' ]; + } + return [ 'USD' ]; + } ); + } ); + + it( 'should return null if UPE is disabled', () => { + expect( + getPaymentMethodUnavailableReason( { + paymentMethodId: PAYMENT_METHOD_CARD, + isUpeEnabled: false, + storeCurrencyCode: 'USD', + } ) + ).toBeNull(); + } ); + + it( 'should return null if the store currency is not set', () => { + expect( + getPaymentMethodUnavailableReason( { + paymentMethodId: PAYMENT_METHOD_CARD, + storeCurrencyCode: null, + } ) + ).toBeNull(); + } ); + + it( 'should return null when UPE is disabled and the store currency is not set', () => { + expect( + getPaymentMethodUnavailableReason( { + paymentMethodId: PAYMENT_METHOD_CARD, + isUpeEnabled: false, + storeCurrencyCode: null, + } ) + ).toBeNull(); + } ); + + it( 'should return null for a payment method that supports all currencies when store is in USD', () => { + expect( + getPaymentMethodUnavailableReason( { + paymentMethodId: PAYMENT_METHOD_CARD, + storeCurrencyCode: 'USD', + } ) + ).toBeNull(); + } ); + it( 'should return null for a payment method that supports all currencies when store is in EUR', () => { + expect( + getPaymentMethodUnavailableReason( { + paymentMethodId: PAYMENT_METHOD_CARD, + storeCurrencyCode: 'EUR', + } ) + ).toBeNull(); + } ); + + it( 'should return OFFICIAL_PLUGIN_CONFLICT when Klarna is unavailable due to a conflict with an official plugin', () => { + global.wc_stripe_settings_params.has_klarna_gateway_plugin = true; + expect( + getPaymentMethodUnavailableReason( { + paymentMethodId: PAYMENT_METHOD_KLARNA, + storeCurrencyCode: 'USD', + } ) + ).toBe( PAYMENT_METHOD_UNAVAILABLE_REASONS.OFFICIAL_PLUGIN_CONFLICT ); + } ); + + it( 'should return OFFICIAL_PLUGIN_CONFLICT when Affirm is unavailable due to a conflict with an official plugin', () => { + global.wc_stripe_settings_params.has_affirm_gateway_plugin = true; + expect( + getPaymentMethodUnavailableReason( { + paymentMethodId: PAYMENT_METHOD_AFFIRM, + storeCurrencyCode: 'USD', + } ) + ).toBe( PAYMENT_METHOD_UNAVAILABLE_REASONS.OFFICIAL_PLUGIN_CONFLICT ); + } ); + + it( 'should return UNSUPPORTED_CURRENCY when the payment method is unavailable due to an unsupported currency - EUR needed; store in USD', () => { + expect( + getPaymentMethodUnavailableReason( { + paymentMethodId: PAYMENT_METHOD_SEPA, + storeCurrencyCode: 'USD', + } ) + ).toBe( PAYMENT_METHOD_UNAVAILABLE_REASONS.UNSUPPORTED_CURRENCY ); + } ); + + it( 'should return UNSUPPORTED_CURRENCY when the payment method is unavailable due to an unsupported currency - USD needed; store in EUR', () => { + expect( + getPaymentMethodUnavailableReason( { + paymentMethodId: PAYMENT_METHOD_AFFIRM, + storeCurrencyCode: 'EUR', + } ) + ).toBe( PAYMENT_METHOD_UNAVAILABLE_REASONS.UNSUPPORTED_CURRENCY ); + } ); +} ); diff --git a/client/utils/get-payment-method-unavailable-reason.js b/client/utils/get-payment-method-unavailable-reason.js new file mode 100644 index 0000000000..b9fb7fbda6 --- /dev/null +++ b/client/utils/get-payment-method-unavailable-reason.js @@ -0,0 +1,57 @@ +import { + PAYMENT_METHOD_AFFIRM, + PAYMENT_METHOD_KLARNA, + PAYMENT_METHOD_UNAVAILABLE_REASONS, +} from 'wcstripe/stripe-utils/constants'; +import { getPaymentMethodCurrencies } from 'utils/use-payment-method-currencies'; + +/** + * Returns the reason why a payment method is unavailable, or null if it is available. + * Intentionally outside of a React hook to support looping over payment methods. + * + * @param {Object} context + * @param {string} context.paymentMethodId The payment method ID. + * @param {boolean} context.isUpeEnabled Whether UPE is enabled. If false, the payment method is available. + * @param {string|null} context.storeCurrencyCode The store currency code. If null, the payment method is available. + * @return {string|null} The reason why the payment method is unavailable, or null if it is available. See `PAYMENT_METHOD_UNAVAILABLE_REASONS` for possible values. + */ +const getPaymentMethodUnavailableReason = ( { + paymentMethodId, + isUpeEnabled = true, + storeCurrencyCode, +} ) => { + if ( + paymentMethodId === PAYMENT_METHOD_KLARNA && + window?.wc_stripe_settings_params?.has_klarna_gateway_plugin + ) { + return PAYMENT_METHOD_UNAVAILABLE_REASONS.OFFICIAL_PLUGIN_CONFLICT; + } + + if ( + paymentMethodId === PAYMENT_METHOD_AFFIRM && + window?.wc_stripe_settings_params?.has_affirm_gateway_plugin + ) { + return PAYMENT_METHOD_UNAVAILABLE_REASONS.OFFICIAL_PLUGIN_CONFLICT; + } + + if ( ! storeCurrencyCode || ! isUpeEnabled ) { + return null; + } + + const paymentMethodCurrencies = getPaymentMethodCurrencies( + paymentMethodId, + true + ); + + // Note that getPaymentMethodCurrencies() returns [] when the payment method supports all currencies. + if ( paymentMethodCurrencies.length === 0 ) { + return null; + } + if ( paymentMethodCurrencies.includes( storeCurrencyCode ) ) { + return null; + } + + return PAYMENT_METHOD_UNAVAILABLE_REASONS.UNSUPPORTED_CURRENCY; +}; + +export default getPaymentMethodUnavailableReason; diff --git a/client/utils/use-payment-method-currencies.js b/client/utils/use-payment-method-currencies.js index afe7a29876..ec8871fa87 100644 --- a/client/utils/use-payment-method-currencies.js +++ b/client/utils/use-payment-method-currencies.js @@ -230,9 +230,15 @@ const getAmazonPayCurrencies = () => { } }; -export const usePaymentMethodCurrencies = ( paymentMethodId ) => { - const { isUpeEnabled } = useContext( UpeToggleContext ); - +/** + * Returns the currencies supported by a payment method. + * Note that [] is returned for payment methods that support all currencies. + * + * @param {string} paymentMethodId + * @param {boolean} isUpeEnabled + * @return {string[]} Array of currencies supported by that payment method. + */ +export const getPaymentMethodCurrencies = ( paymentMethodId, isUpeEnabled ) => { switch ( paymentMethodId ) { case PAYMENT_METHOD_ALIPAY: return getAliPayCurrencies( isUpeEnabled ); @@ -247,4 +253,17 @@ export const usePaymentMethodCurrencies = ( paymentMethodId ) => { } }; +/** + * Hook to return the currencies supported by a payment method. + * Note that [] is returned for payment methods that support all currencies. + * + * @param {string} paymentMethodId + * @return {string[]} Array of currencies supported by that payment method. + */ +export const usePaymentMethodCurrencies = ( paymentMethodId ) => { + const { isUpeEnabled } = useContext( UpeToggleContext ); + + return getPaymentMethodCurrencies( paymentMethodId, isUpeEnabled ); +}; + export default usePaymentMethodCurrencies; diff --git a/client/utils/use-payment-method-unavailable-reason.js b/client/utils/use-payment-method-unavailable-reason.js new file mode 100644 index 0000000000..c68338d1b4 --- /dev/null +++ b/client/utils/use-payment-method-unavailable-reason.js @@ -0,0 +1,24 @@ +import { getSetting } from '@woocommerce/settings'; +import { useContext } from 'react'; +import UpeToggleContext from 'wcstripe/settings/upe-toggle/context'; +import getPaymentMethodUnavailableReason from 'utils/get-payment-method-unavailable-reason'; + +/** + * React hook to return the reason why a payment method is unavailable, or null if it is available. + * + * @param {string} paymentMethodId The payment method ID. + * @return {string|null} The reason why the payment method is unavailable, or null if it is available. See `PAYMENT_METHOD_UNAVAILABLE_REASONS` for possible values. + */ +const usePaymentMethodUnavailableReason = ( paymentMethodId ) => { + const { isUpeEnabled } = useContext( UpeToggleContext ); + + const storeCurrencyCode = getSetting( 'currency' )?.code; + + return getPaymentMethodUnavailableReason( { + paymentMethodId, + isUpeEnabled, + storeCurrencyCode, + } ); +}; + +export default usePaymentMethodUnavailableReason; diff --git a/readme.txt b/readme.txt index c52dd85d93..43a7d09b77 100644 --- a/readme.txt +++ b/readme.txt @@ -119,7 +119,6 @@ If you get stuck, you can ask for help in the [Plugin Forum](https://wordpress.o * Fix - Ensure all Javascript strings use the correct text domain for translation * Tweak - Use more specific selector in express checkout e2e tests * Tweak - Small improvements to e2e tests -* Fix - Fix unnecessary Stripe API calls when rendering subscription details -* Add - Adds a new action (`wc_stripe_webhook_received`) to allow additional actions to be taken for webhook notifications from Stripe +* Update - Show all available payment methods before unavailable payment methods [See changelog for full details across versions](https://raw.githubusercontent.com/woocommerce/woocommerce-gateway-stripe/trunk/changelog.txt).