From 3e4160ea871b04084efbca9cac6c349ff402b92d Mon Sep 17 00:00:00 2001 From: Timur Karimov Date: Thu, 18 Apr 2024 12:45:07 +0200 Subject: [PATCH] Detect multiple enabled payment methods of the same type (#8513) Co-authored-by: Timur Karimov --- changelog/add-duplicates-detection | 4 + client/components/duplicate-notice/index.tsx | 71 ++++++ .../duplicate-notice/tests/index.test.tsx | 100 ++++++++ .../payment-methods-list/payment-method.tsx | 17 ++ .../test/payment-method.test.tsx | 60 +++++ client/data/settings/hooks.js | 5 + client/data/settings/selectors.js | 4 + client/data/settings/test/hooks.js | 29 +++ client/data/settings/test/selectors.js | 31 +++ client/globals.d.ts | 1 + client/payment-methods/test/index.js | 50 ++++ .../apple-google-pay-item.tsx | 21 +- .../express-checkout/test/index.test.js | 4 + .../duplicated-payment-methods-context.tsx | 12 + client/settings/settings-manager/index.js | 58 +++-- includes/admin/class-wc-payments-admin.php | 1 + ...s-wc-rest-payments-settings-controller.php | 25 +- .../class-duplicates-detection-service.php | 240 ++++++++++++++++++ includes/class-wc-payments.php | 13 +- ...s-wc-rest-payments-settings-controller.php | 34 ++- .../class-test-gateway.php | 27 ++ ...est-class-duplicates-detection-service.php | 129 ++++++++++ 22 files changed, 896 insertions(+), 40 deletions(-) create mode 100644 changelog/add-duplicates-detection create mode 100644 client/components/duplicate-notice/index.tsx create mode 100644 client/components/duplicate-notice/tests/index.test.tsx create mode 100644 client/settings/settings-manager/duplicated-payment-methods-context.tsx create mode 100644 includes/class-duplicates-detection-service.php create mode 100644 tests/unit/duplicate-detection/class-test-gateway.php create mode 100644 tests/unit/duplicate-detection/test-class-duplicates-detection-service.php diff --git a/changelog/add-duplicates-detection b/changelog/add-duplicates-detection new file mode 100644 index 00000000000..bf6de9cd06a --- /dev/null +++ b/changelog/add-duplicates-detection @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Detect payment methods enabled by multiple payment gateways. diff --git a/client/components/duplicate-notice/index.tsx b/client/components/duplicate-notice/index.tsx new file mode 100644 index 00000000000..2a509147d0e --- /dev/null +++ b/client/components/duplicate-notice/index.tsx @@ -0,0 +1,71 @@ +/** + * External dependencies + */ +import React, { useCallback } from 'react'; +import InlineNotice from '../inline-notice'; +import interpolateComponents from '@automattic/interpolate-components'; +import { __ } from '@wordpress/i18n'; +import { getAdminUrl } from 'wcpay/utils'; +import { useDispatch } from '@wordpress/data'; + +interface DuplicateNoticeProps { + paymentMethod: string; + dismissedDuplicateNotices: string[]; + setDismissedDuplicateNotices: ( notices: string[] ) => void; +} + +function DuplicateNotice( { + paymentMethod, + dismissedDuplicateNotices, + setDismissedDuplicateNotices, +}: DuplicateNoticeProps ): JSX.Element | null { + const { updateOptions } = useDispatch( 'wc/admin/options' ); + + const handleDismiss = useCallback( () => { + const updatedNotices = [ ...dismissedDuplicateNotices, paymentMethod ]; + setDismissedDuplicateNotices( updatedNotices ); + updateOptions( { + wcpay_duplicate_payment_method_notices_dismissed: updatedNotices, + } ); + wcpaySettings.dismissedDuplicateNotices = updatedNotices; + }, [ + paymentMethod, + dismissedDuplicateNotices, + setDismissedDuplicateNotices, + updateOptions, + ] ); + + if ( dismissedDuplicateNotices.includes( paymentMethod ) ) { + return null; + } + + return ( + + { interpolateComponents( { + mixedString: __( + 'This payment method is enabled by other extensions. {{reviewExtensions}}Review extensions{{/reviewExtensions}} to improve the shopper experience.', + 'woocommerce-payments' + ), + components: { + reviewExtensions: ( + + Review extensions + + ), + }, + } ) } + + ); +} + +export default DuplicateNotice; diff --git a/client/components/duplicate-notice/tests/index.test.tsx b/client/components/duplicate-notice/tests/index.test.tsx new file mode 100644 index 00000000000..aacb7c3a90c --- /dev/null +++ b/client/components/duplicate-notice/tests/index.test.tsx @@ -0,0 +1,100 @@ +/** + * External dependencies + */ +import React from 'react'; +import { render, fireEvent, screen, cleanup } from '@testing-library/react'; +import { useDispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import DuplicateNotice from '..'; + +jest.mock( '@wordpress/data', () => ( { + useDispatch: jest.fn(), +} ) ); + +const mockUseDispatch = useDispatch as jest.MockedFunction< any >; + +describe( 'DuplicateNotice', () => { + const mockDispatch = jest.fn(); + mockUseDispatch.mockReturnValue( { + updateOptions: mockDispatch, + } ); + + afterEach( () => { + cleanup(); + } ); + + test( 'does not render when the payment method is dismissed', () => { + render( + + ); + expect( + screen.queryByText( + 'This payment method is enabled by other extensions. Review extensions to improve the shopper experience.' + ) + ).not.toBeInTheDocument(); + } ); + + test( 'renders correctly when the payment method is not dismissed', () => { + render( + + ); + expect( + screen.getByText( + 'This payment method is enabled by other extensions. Review extensions to improve the shopper experience.' + ) + ).toBeInTheDocument(); + cleanup(); + } ); + + test( 'dismissal process triggers appropriate actions', () => { + const paymentMethod = 'ideal'; + const props = { + paymentMethod: paymentMethod, + dismissedDuplicateNotices: [], + setDismissedDuplicateNotices: jest.fn(), + }; + const { container } = render( ); + const dismissButton = container.querySelector( + '.components-button.components-notice__dismiss.has-icon' + ); + if ( dismissButton ) { + fireEvent.click( dismissButton ); + } else { + throw new Error( 'Dismiss button not found' ); + } + + // Check if local state update function and Redux action dispatcher are called correctly + expect( props.setDismissedDuplicateNotices ).toHaveBeenCalledWith( [ + paymentMethod, + ] ); + expect( mockDispatch ).toHaveBeenCalledWith( { + wcpay_duplicate_payment_method_notices_dismissed: [ paymentMethod ], + } ); + } ); + + test( 'clicking on the Review extensions link navigates correctly', () => { + const { getByText } = render( + + ); + expect( + getByText( 'Review extensions' ).closest( 'a' ) + ).toHaveAttribute( 'href', 'admin.php?page=wc-settings&tab=checkout' ); + } ); +} ); diff --git a/client/components/payment-methods-list/payment-method.tsx b/client/components/payment-methods-list/payment-method.tsx index 598f29e48fe..7aa2bd7f8fa 100644 --- a/client/components/payment-methods-list/payment-method.tsx +++ b/client/components/payment-methods-list/payment-method.tsx @@ -25,6 +25,8 @@ import { getDocumentationUrlForDisabledPaymentMethod } from '../payment-method-d import Pill from '../pill'; import InlineNotice from '../inline-notice'; import './payment-method.scss'; +import DuplicateNotice from '../duplicate-notice'; +import DuplicatedPaymentMethodsContext from 'wcpay/settings/settings-manager/duplicated-payment-methods-context'; interface PaymentMethodProps { id: string; @@ -144,6 +146,12 @@ const PaymentMethod = ( { isPoInProgress || upeCapabilityStatuses.REJECTED === status; const shouldDisplayNotice = id === 'sofort'; + const { + duplicates, + dismissedDuplicateNotices, + setDismissedDuplicateNotices, + } = useContext( DuplicatedPaymentMethodsContext ); + const isDuplicate = duplicates.includes( id ); const needsOverlay = ( isManualCaptureEnabled && ! isAllowingManualCapture ) || @@ -357,6 +365,15 @@ const PaymentMethod = ( { ) } + { isDuplicate && ( + + ) } ); }; diff --git a/client/components/payment-methods-list/test/payment-method.test.tsx b/client/components/payment-methods-list/test/payment-method.test.tsx index a208f24750a..601a5f44070 100644 --- a/client/components/payment-methods-list/test/payment-method.test.tsx +++ b/client/components/payment-methods-list/test/payment-method.test.tsx @@ -12,6 +12,7 @@ import { act } from 'react-dom/test-utils'; * Internal dependencies */ import PaymentMethod from '../payment-method'; +import DuplicatedPaymentMethodsContext from 'wcpay/settings/settings-manager/duplicated-payment-methods-context'; describe( 'PaymentMethod', () => { let checked = false; @@ -21,6 +22,7 @@ describe( 'PaymentMethod', () => { const handleOnUnCheckClickMock = jest.fn( () => { checked = false; } ); + const setDismissedDuplicateNoticesMock = jest.fn(); // Clear the mocks (including the mock call count) after each test. afterEach( () => { @@ -127,4 +129,62 @@ describe( 'PaymentMethod', () => { expect( handleOnUnCheckClickMock ).not.toHaveBeenCalled(); jest.useRealTimers(); } ); + + const getDuplicateComponent = ( id: string ) => ( + null } + status="" + isAllowingManualCapture={ false } + required={ false } + locked={ false } + isPoEnabled={ false } + isPoComplete={ false } + /> + ); + + test( 'does not render DuplicateNotice if payment method is not in duplicates', () => { + render( + + { getDuplicateComponent( 'card' ) } + + ); + + expect( + screen.queryByText( + 'This payment method is enabled by other extensions. Review extensions to improve the shopper experience.' + ) + ).not.toBeInTheDocument(); + } ); + + test( 'render DuplicateNotice if payment method is in duplicates', () => { + render( + + { getDuplicateComponent( 'card' ) } + + ); + + expect( + screen.queryByText( + 'This payment method is enabled by other extensions. Review extensions to improve the shopper experience.' + ) + ).toBeInTheDocument(); + } ); } ); diff --git a/client/data/settings/hooks.js b/client/data/settings/hooks.js index 349a9fe6ed4..6930939a153 100644 --- a/client/data/settings/hooks.js +++ b/client/data/settings/hooks.js @@ -322,6 +322,11 @@ export const useGetAvailablePaymentMethodIds = () => export const useGetPaymentMethodStatuses = () => useSelect( ( select ) => select( STORE_NAME ).getPaymentMethodStatuses() ); +export const useGetDuplicatedPaymentMethodIds = () => + useSelect( ( select ) => + select( STORE_NAME ).getDuplicatedPaymentMethodIds() + ); + export const useGetSettings = () => useSelect( ( select ) => select( STORE_NAME ).getSettings() ); diff --git a/client/data/settings/selectors.js b/client/data/settings/selectors.js index a93a1c51c30..a1a5191e932 100644 --- a/client/data/settings/selectors.js +++ b/client/data/settings/selectors.js @@ -24,6 +24,10 @@ const getSupportAddressState = ( state ) => { return getSettings( state ).account_business_support_address || EMPTY_OBJ; }; +export const getDuplicatedPaymentMethodIds = ( state ) => { + return getSettings( state ).duplicated_payment_method_ids || EMPTY_OBJ; +}; + export const getIsWCPayEnabled = ( state ) => { return getSettings( state ).is_wcpay_enabled || false; }; diff --git a/client/data/settings/test/hooks.js b/client/data/settings/test/hooks.js index aa2dd372baf..c9006b30064 100644 --- a/client/data/settings/test/hooks.js +++ b/client/data/settings/test/hooks.js @@ -19,6 +19,7 @@ import { useWooPayCustomMessage, useWooPayStoreLogo, useClientSecretEncryption, + useGetDuplicatedPaymentMethodIds, } from '../hooks'; import { STORE_NAME } from '../../constants'; @@ -371,4 +372,32 @@ describe( 'Settings hooks tests', () => { ); } ); } ); + + describe( 'useGetDuplicatedPaymentMethodIds', () => { + beforeEach( () => { + useSelect.mockImplementation( ( selector ) => + selector( ( name ) => { + if ( name === STORE_NAME ) { + return { + getDuplicatedPaymentMethodIds: jest.fn( () => [ + 'card', + 'bancontact', + ] ), + }; + } + return {}; + } ) + ); + } ); + + test( 'returns duplicated payment method IDs from selector', () => { + const duplicatedPaymentMethodIds = useGetDuplicatedPaymentMethodIds(); + expect( duplicatedPaymentMethodIds ).toEqual( [ + 'card', + 'bancontact', + ] ); + + expect( useSelect ).toHaveBeenCalled(); + } ); + } ); } ); diff --git a/client/data/settings/test/selectors.js b/client/data/settings/test/selectors.js index 7725b14441d..728c61c26a8 100644 --- a/client/data/settings/test/selectors.js +++ b/client/data/settings/test/selectors.js @@ -20,6 +20,7 @@ import { getWooPayCustomMessage, getWooPayStoreLogo, getIsClientSecretEncryptionEnabled, + getDuplicatedPaymentMethodIds, } from '../selectors'; describe( 'Settings selectors tests', () => { @@ -342,4 +343,34 @@ describe( 'Settings selectors tests', () => { expect( setting.getFunc( state ) ).toEqual( '' ); } ); } ); + + describe( 'getDuplicatedPaymentMethodIds()', () => { + test( 'returns the value of state.settings.data.duplicated_payment_method_ids', () => { + const state = { + settings: { + data: { + duplicated_payment_method_ids: [ 'card', 'bancontact' ], + }, + }, + }; + + expect( getDuplicatedPaymentMethodIds( state ) ).toEqual( [ + 'card', + 'bancontact', + ] ); + } ); + + test.each( [ + [ undefined ], + [ {} ], + [ { settings: {} } ], + [ { settings: { data: {} } } ], + [ { settings: { data: { duplicated_payment_method_ids: null } } } ], + ] )( + 'returns {} if missing or undefined (tested state: %j)', + ( state ) => { + expect( getDuplicatedPaymentMethodIds( state ) ).toEqual( {} ); + } + ); + } ); } ); diff --git a/client/globals.d.ts b/client/globals.d.ts index 9f2e38eb39f..9bbf2d80d27 100644 --- a/client/globals.d.ts +++ b/client/globals.d.ts @@ -90,6 +90,7 @@ declare global { isEligibilityModalDismissed: boolean; }; enabledPaymentMethods: string[]; + dismissedDuplicateNotices: string[]; accountDefaultCurrency: string; isFRTReviewFeatureActive: boolean; frtDiscoverBannerSettings: string; diff --git a/client/payment-methods/test/index.js b/client/payment-methods/test/index.js index 8305be2be74..6ab41a25c90 100644 --- a/client/payment-methods/test/index.js +++ b/client/payment-methods/test/index.js @@ -20,8 +20,10 @@ import { useManualCapture, useSelectedPaymentMethod, useUnselectedPaymentMethod, + useGetDuplicatedPaymentMethodIds, } from 'wcpay/data'; import { upeCapabilityStatuses } from 'wcpay/additional-methods-setup/constants'; +import DuplicatedPaymentMethodsContext from 'wcpay/settings/settings-manager/duplicated-payment-methods-context'; jest.mock( '@woocommerce/components', () => { return { @@ -41,6 +43,7 @@ jest.mock( '../../data', () => ( { useSelectedPaymentMethod: jest.fn(), useUnselectedPaymentMethod: jest.fn(), useAccountDomesticCurrency: jest.fn(), + useGetDuplicatedPaymentMethodIds: jest.fn(), } ) ); jest.mock( '@wordpress/data', () => ( { @@ -90,6 +93,7 @@ describe( 'PaymentMethods', () => { account_country: 'US', } ), } ) ); + useGetDuplicatedPaymentMethodIds.mockReturnValue( [] ); } ); test( 'payment methods are rendered correctly', () => { @@ -413,4 +417,50 @@ describe( 'PaymentMethods', () => { ).toBeInTheDocument(); jest.useRealTimers(); } ); + + it( 'duplicate notices should not appear when dismissed', () => { + useGetAvailablePaymentMethodIds.mockReturnValue( [ 'card' ] ); + useGetDuplicatedPaymentMethodIds.mockReturnValue( [ 'card' ] ); + + render( + + + + ); + + expect( + screen.queryByText( + 'This payment method is enabled by other extensions. Review extensions to improve the shopper experience.' + ) + ).not.toBeInTheDocument(); + } ); + + it( 'duplicate notice should appear when not dismissed', () => { + useGetAvailablePaymentMethodIds.mockReturnValue( [ 'card' ] ); + useGetDuplicatedPaymentMethodIds.mockReturnValue( [ 'card' ] ); + + render( + + + + ); + + expect( + screen.queryByText( + 'This payment method is enabled by other extensions. Review extensions to improve the shopper experience.' + ) + ).toBeInTheDocument(); + } ); } ); diff --git a/client/settings/express-checkout/apple-google-pay-item.tsx b/client/settings/express-checkout/apple-google-pay-item.tsx index 99619998f97..46ca82907a2 100644 --- a/client/settings/express-checkout/apple-google-pay-item.tsx +++ b/client/settings/express-checkout/apple-google-pay-item.tsx @@ -4,7 +4,7 @@ import { __ } from '@wordpress/i18n'; import { Button, CheckboxControl } from '@wordpress/components'; import interpolateComponents from '@automattic/interpolate-components'; -import React from 'react'; +import React, { useContext } from 'react'; /** * Internal dependencies @@ -17,14 +17,24 @@ import { import { PaymentRequestEnabledSettingsHook } from './interfaces'; import { ApplePayIcon, GooglePayIcon } from 'wcpay/payment-methods-icons'; import { ExpressCheckoutIncompatibilityNotice } from 'wcpay/settings/settings-warnings/incompatibility-notice'; +import DuplicateNotice from 'wcpay/components/duplicate-notice'; +import DuplicatedPaymentMethodsContext from '../settings-manager/duplicated-payment-methods-context'; const AppleGooglePayExpressCheckoutItem = (): React.ReactElement => { + const id = 'apple_pay_google_pay'; + const [ isPaymentRequestEnabled, updateIsPaymentRequestEnabled, ] = usePaymentRequestEnabledSettings() as PaymentRequestEnabledSettingsHook; const showIncompatibilityNotice = useExpressCheckoutShowIncompatibilityNotice(); + const { + duplicates, + dismissedDuplicateNotices, + setDismissedDuplicateNotices, + } = useContext( DuplicatedPaymentMethodsContext ); + const isDuplicate = duplicates.includes( id ); return (
  • { { showIncompatibilityNotice && ( ) } + { isDuplicate && ( + + ) }
  • ); }; diff --git a/client/settings/express-checkout/test/index.test.js b/client/settings/express-checkout/test/index.test.js index af201352197..83c40f7487f 100644 --- a/client/settings/express-checkout/test/index.test.js +++ b/client/settings/express-checkout/test/index.test.js @@ -17,6 +17,7 @@ import { usePaymentRequestEnabledSettings, useWooPayEnabledSettings, useWooPayShowIncompatibilityNotice, + useGetDuplicatedPaymentMethodIds, } from 'wcpay/data'; import WCPaySettingsContext from '../../wcpay-settings-context'; @@ -27,6 +28,7 @@ jest.mock( 'wcpay/data', () => ( { useGetAvailablePaymentMethodIds: jest.fn(), useWooPayShowIncompatibilityNotice: jest.fn(), useExpressCheckoutShowIncompatibilityNotice: jest.fn(), + useGetDuplicatedPaymentMethodIds: jest.fn(), } ) ); const getMockPaymentRequestEnabledSettings = ( @@ -49,6 +51,8 @@ describe( 'ExpressCheckout', () => { ); useWooPayShowIncompatibilityNotice.mockReturnValue( false ); + + useGetDuplicatedPaymentMethodIds.mockReturnValue( [] ); } ); it( 'should dispatch enabled status update if express checkout is being toggled', async () => { diff --git a/client/settings/settings-manager/duplicated-payment-methods-context.tsx b/client/settings/settings-manager/duplicated-payment-methods-context.tsx new file mode 100644 index 00000000000..0539483c1bb --- /dev/null +++ b/client/settings/settings-manager/duplicated-payment-methods-context.tsx @@ -0,0 +1,12 @@ +/** + * External dependencies + */ +import { createContext } from 'react'; + +const DuplicatedPaymentMethodsContext = createContext( { + duplicates: [] as string[], + dismissedDuplicateNotices: [] as string[], + setDismissedDuplicateNotices: () => null, +} ); + +export default DuplicatedPaymentMethodsContext; diff --git a/client/settings/settings-manager/index.js b/client/settings/settings-manager/index.js index 7afb5cbf55e..5b79f668b32 100644 --- a/client/settings/settings-manager/index.js +++ b/client/settings/settings-manager/index.js @@ -22,9 +22,14 @@ import Transactions from '../transactions'; import Deposits from '../deposits'; import LoadableSettingsSection from '../loadable-settings-section'; import ErrorBoundary from '../../components/error-boundary'; -import { useDepositDelayDays, useSettings } from '../../data'; +import { + useDepositDelayDays, + useGetDuplicatedPaymentMethodIds, + useSettings, +} from '../../data'; import FraudProtection from '../fraud-protection'; import { isDefaultSiteLanguage } from 'wcpay/utils'; +import DuplicatedPaymentMethodsContext from './duplicated-payment-methods-context'; const PaymentMethodsDescription = () => ( <> @@ -200,6 +205,11 @@ const SettingsManager = () => { } }, [ isLoading ] ); + const [ + dismissedDuplicateNotices, + setDismissedDuplicateNotices, + ] = useState( wcpaySettings.dismissedDuplicateNotices || [] ); + return ( { - - - - - - - - - - - - - - + + + + + + + + + + + + + + + [ 'exportModalDismissed' => get_option( 'wcpay_reporting_export_modal_dismissed', false ), ], + 'dismissedDuplicateNotices' => get_option( 'wcpay_duplicate_payment_method_notices_dismissed', [] ), 'locale' => WC_Payments_Utils::get_language_data( get_locale() ), 'trackingInfo' => $this->account->get_tracking_info(), 'lifetimeTPV' => $this->account->get_lifetime_total_payment_volume(), diff --git a/includes/admin/class-wc-rest-payments-settings-controller.php b/includes/admin/class-wc-rest-payments-settings-controller.php index 9137df7062d..ffd941feee8 100644 --- a/includes/admin/class-wc-rest-payments-settings-controller.php +++ b/includes/admin/class-wc-rest-payments-settings-controller.php @@ -8,6 +8,7 @@ use WCPay\Constants\Country_Code; use WCPay\Fraud_Prevention\Fraud_Risk_Tools; use WCPay\Constants\Track_Events; +use WCPay\Duplicates_Detection_Service; defined( 'ABSPATH' ) || exit; @@ -37,22 +38,33 @@ class WC_REST_Payments_Settings_Controller extends WC_Payments_REST_Controller { protected $account; + /** + * Duplicates detection service. + * + * @var Duplicates_Detection_Service + */ + private $duplicates_detection_service; + + /** * WC_REST_Payments_Settings_Controller constructor. * - * @param WC_Payments_API_Client $api_client WC_Payments_API_Client instance. - * @param WC_Payment_Gateway_WCPay $wcpay_gateway WC_Payment_Gateway_WCPay instance. - * @param WC_Payments_Account $account Account class instance. + * @param WC_Payments_API_Client $api_client WC_Payments_API_Client instance. + * @param WC_Payment_Gateway_WCPay $wcpay_gateway WC_Payment_Gateway_WCPay instance. + * @param WC_Payments_Account $account Account class instance. + * @param Duplicates_Detection_Service $duplicates_detection_service Duplicates detection service. */ public function __construct( WC_Payments_API_Client $api_client, WC_Payment_Gateway_WCPay $wcpay_gateway, - WC_Payments_Account $account + WC_Payments_Account $account, + Duplicates_Detection_Service $duplicates_detection_service ) { parent::__construct( $api_client ); - $this->wcpay_gateway = $wcpay_gateway; - $this->account = $account; + $this->wcpay_gateway = $wcpay_gateway; + $this->account = $account; + $this->duplicates_detection_service = $duplicates_detection_service; } /** @@ -474,6 +486,7 @@ public function get_settings(): WP_REST_Response { 'enabled_payment_method_ids' => $enabled_payment_methods, 'available_payment_method_ids' => $available_upe_payment_methods, 'payment_method_statuses' => $this->wcpay_gateway->get_upe_enabled_payment_method_statuses(), + 'duplicated_payment_method_ids' => $this->duplicates_detection_service->find_duplicates(), 'is_wcpay_enabled' => $this->wcpay_gateway->is_enabled(), 'is_manual_capture_enabled' => 'yes' === $this->wcpay_gateway->get_option( 'manual_capture' ), 'is_test_mode_enabled' => WC_Payments::mode()->is_test(), diff --git a/includes/class-duplicates-detection-service.php b/includes/class-duplicates-detection-service.php new file mode 100644 index 00000000000..3c49a0ab4fe --- /dev/null +++ b/includes/class-duplicates-detection-service.php @@ -0,0 +1,240 @@ +gateways_qualified_by_duplicates_detector = []; + + $this->search_for_cc() + ->search_for_additional_payment_methods() + ->search_for_payment_request_buttons() + ->keep_gateways_enabled_in_woopayments() + ->keep_duplicates_only(); + + // Return payment method IDs list so that front-end can successfully compare with its own list. + return array_keys( $this->gateways_qualified_by_duplicates_detector ); + } catch ( \Exception $e ) { + Logger::warning( 'Duplicates detection service failed silently with the following error: ' . $e->getMessage() ); + + // Fail silently and return an empty array in case of any exception. + return []; + } + } + + /** + * Search for credit card gateways. + * + * @return Duplicates_Detection_Service + */ + private function search_for_cc() { + $keywords = [ 'credit_card', 'creditcard', 'cc', 'card' ]; + $special_keywords = [ 'woocommerce_payments', 'stripe' ]; + + foreach ( $this->get_enabled_gateways() as $gateway ) { + if ( $this->gateway_contains_keyword( $gateway->id, $keywords ) || in_array( $gateway->id, $special_keywords, true ) ) { + $this->gateways_qualified_by_duplicates_detector[ CC_Payment_Method::PAYMENT_METHOD_STRIPE_ID ][] = $gateway->id; + } + } + + return $this; + } + + /** + * Search for additional payment methods. + * + * @return Duplicates_Detection_Service + */ + private function search_for_additional_payment_methods() { + $keywords = [ + 'bancontact' => Bancontact_Payment_Method::PAYMENT_METHOD_STRIPE_ID, + 'sepa' => Sepa_Payment_Method::PAYMENT_METHOD_STRIPE_ID, + 'giropay' => Giropay_Payment_Method::PAYMENT_METHOD_STRIPE_ID, + 'sofort' => Sofort_Payment_Method::PAYMENT_METHOD_STRIPE_ID, + 'p24' => P24_Payment_Method::PAYMENT_METHOD_STRIPE_ID, + 'przelewy24' => P24_Payment_Method::PAYMENT_METHOD_STRIPE_ID, + 'ideal' => Ideal_Payment_Method::PAYMENT_METHOD_STRIPE_ID, + 'becs' => Becs_Payment_Method::PAYMENT_METHOD_STRIPE_ID, + 'eps' => Eps_Payment_Method::PAYMENT_METHOD_STRIPE_ID, + 'affirm' => Affirm_Payment_Method::PAYMENT_METHOD_STRIPE_ID, + 'afterpay' => Afterpay_Payment_Method::PAYMENT_METHOD_STRIPE_ID, + 'clearpay' => Afterpay_Payment_Method::PAYMENT_METHOD_STRIPE_ID, + 'klarna' => Klarna_Payment_Method::PAYMENT_METHOD_STRIPE_ID, + ]; + + foreach ( $this->get_enabled_gateways() as $gateway ) { + foreach ( $keywords as $keyword => $payment_method ) { + if ( strpos( $gateway->id, $keyword ) !== false ) { + $this->gateways_qualified_by_duplicates_detector[ $payment_method ][] = $gateway->id; + break; + } + } + } + + return $this; + } + + /** + * Search for payment request buttons. + * + * @return Duplicates_Detection_Service + */ + private function search_for_payment_request_buttons() { + $prb_payment_method = 'apple_pay_google_pay'; + $keywords = [ + 'apple_pay', + 'applepay', + 'google_pay', + 'googlepay', + ]; + + foreach ( $this->get_registered_gateways() as $gateway ) { + // Stripe gateway can enable PRBs while being disabled as well, hence no need to check for enabled status. + if ( 'stripe' === $gateway->id && 'yes' === $gateway->get_option( 'payment_request' ) ) { + $this->gateways_qualified_by_duplicates_detector[ $prb_payment_method ][] = $gateway->id; + continue; + } + + if ( 'yes' === $gateway->enabled ) { + foreach ( $keywords as $keyword ) { + if ( strpos( $gateway->id, $keyword ) !== false ) { + $this->gateways_qualified_by_duplicates_detector[ $prb_payment_method ][] = $gateway->id; + break; + } elseif ( 'yes' === $gateway->get_option( 'payment_request' ) && 'woocommerce_payments' === $gateway->id ) { + $this->gateways_qualified_by_duplicates_detector[ $prb_payment_method ][] = $gateway->id; + break; + } + } + } + } + + return $this; + } + + /** + * Keep only WooCommerce Payments enabled gateways. + * + * @return Duplicates_Detection_Service + */ + private function keep_gateways_enabled_in_woopayments() { + $woopayments_gateway_ids = array_map( + function ( $gateway ) { + return $gateway->id; }, + array_values( WC_Payments::get_payment_gateway_map() ) + ); + + foreach ( $this->gateways_qualified_by_duplicates_detector as $gateway_id => $gateway_ids ) { + if ( empty( array_intersect( $gateway_ids, $woopayments_gateway_ids ) ) ) { + unset( $this->gateways_qualified_by_duplicates_detector[ $gateway_id ] ); + } + } + + return $this; + } + + /** + * Filter payment methods found to keep duplicates only. + * + * @return Duplicates_Detection_Service + */ + private function keep_duplicates_only() { + foreach ( $this->gateways_qualified_by_duplicates_detector as $gateway_id => $gateway_ids ) { + if ( count( $gateway_ids ) < 2 ) { + unset( $this->gateways_qualified_by_duplicates_detector[ $gateway_id ] ); + } + } + + return $this; + } + + /** + * Filter enabled gateways only. + * + * @return array Enabled gateways only. + */ + private function get_enabled_gateways() { + return array_filter( + $this->get_registered_gateways(), + function ( $gateway ) { + return 'yes' === $gateway->enabled; + } + ); + } + + /** + * Check if gateway ID contains any of the keywords. + * + * @param string $gateway_id Gateway ID. + * @param array $keywords Keywords to search for. + * + * @return bool True if gateway ID contains any of the keywords, false otherwise. + */ + private function gateway_contains_keyword( $gateway_id, $keywords ) { + foreach ( $keywords as $keyword ) { + if ( strpos( $gateway_id, $keyword ) !== false ) { + return true; + } + } + return false; + } + + /** + * Lazy load registered gateways. + * + * @return array Registered gateways. + */ + private function get_registered_gateways() { + if ( null === $this->registered_gateways ) { + $this->registered_gateways = WC()->payment_gateways->payment_gateways(); + } + return $this->registered_gateways; + } +} diff --git a/includes/class-wc-payments.php b/includes/class-wc-payments.php index c5823a9da3e..39081df6053 100644 --- a/includes/class-wc-payments.php +++ b/includes/class-wc-payments.php @@ -41,6 +41,7 @@ use WCPay\WooPay\WooPay_Scheduler; use WCPay\WooPay\WooPay_Session; use WCPay\Compatibility_Service; +use WCPay\Duplicates_Detection_Service; /** * Main class for the WooPayments extension. Its responsibility is to initialize the extension. @@ -284,6 +285,13 @@ class WC_Payments { */ private static $compatibility_service; + /** + * Instance of Duplicates_Detection_Service, created in init function + * + * @var Duplicates_Detection_Service + */ + private static $duplicates_detection_service; + /** * Entry point to the initialization logic. */ @@ -460,6 +468,7 @@ public static function init() { include_once __DIR__ . '/class-wc-payments-incentives-service.php'; include_once __DIR__ . '/class-compatibility-service.php'; include_once __DIR__ . '/multi-currency/wc-payments-multi-currency.php'; + include_once __DIR__ . '/class-duplicates-detection-service.php'; self::$woopay_checkout_service = new Checkout_Service(); self::$woopay_checkout_service->init(); @@ -494,6 +503,7 @@ public static function init() { self::$incentives_service = new WC_Payments_Incentives_Service( self::$database_cache ); self::$duplicate_payment_prevention_service = new Duplicate_Payment_Prevention_Service(); self::$compatibility_service = new Compatibility_Service( self::$api_client ); + self::$duplicates_detection_service = new Duplicates_Detection_Service(); ( new WooPay_Scheduler( self::$api_client ) )->init(); @@ -1010,7 +1020,7 @@ public static function init_rest_api() { $reporting_controller->register_routes(); include_once WCPAY_ABSPATH . 'includes/admin/class-wc-rest-payments-settings-controller.php'; - $settings_controller = new WC_REST_Payments_Settings_Controller( self::$api_client, self::get_gateway(), self::$account ); + $settings_controller = new WC_REST_Payments_Settings_Controller( self::$api_client, self::get_gateway(), self::$account, self::$duplicates_detection_service ); $settings_controller->register_routes(); include_once WCPAY_ABSPATH . 'includes/admin/class-wc-rest-payments-reader-controller.php'; @@ -1776,6 +1786,7 @@ public static function add_wcpay_options_to_woocommerce_permissions_list( $permi 'wcpay_capability_request_dismissed_notices', 'wcpay_onboarding_eligibility_modal_dismissed', 'wcpay_next_deposit_notice_dismissed', + 'wcpay_duplicate_payment_method_notices_dismissed', ], true ); diff --git a/tests/unit/admin/test-class-wc-rest-payments-settings-controller.php b/tests/unit/admin/test-class-wc-rest-payments-settings-controller.php index 79486949b83..7c152626ad0 100644 --- a/tests/unit/admin/test-class-wc-rest-payments-settings-controller.php +++ b/tests/unit/admin/test-class-wc-rest-payments-settings-controller.php @@ -12,6 +12,7 @@ use WCPay\Constants\Payment_Method; use WCPay\Database_Cache; use WCPay\Duplicate_Payment_Prevention_Service; +use WCPay\Duplicates_Detection_Service; use WCPay\Payment_Methods\Eps_Payment_Method; use WCPay\Payment_Methods\CC_Payment_Method; use WCPay\Payment_Methods\Bancontact_Payment_Method; @@ -60,6 +61,14 @@ class WC_REST_Payments_Settings_Controller_Test extends WCPAY_UnitTestCase { * @var WC_Payments_Account|MockObject */ private $mock_wcpay_account; + + /** + * Mock Duplicate_Payment_Prevention_Service. + * + * @var Duplicates_Detection_Service|MockObject + */ + private $mock_duplicates_detection_service; + /** * @var Database_Cache|MockObject */ @@ -111,17 +120,18 @@ public function set_up() { ->disableOriginalConstructor() ->getMock(); - $this->mock_wcpay_account = $this->createMock( WC_Payments_Account::class ); - $this->mock_db_cache = $this->createMock( Database_Cache::class ); - $this->mock_session_service = $this->createMock( WC_Payments_Session_Service::class ); - $customer_service = new WC_Payments_Customer_Service( $this->mock_api_client, $this->mock_wcpay_account, $this->mock_db_cache, $this->mock_session_service ); - $token_service = new WC_Payments_Token_Service( $this->mock_api_client, $customer_service ); - $order_service = new WC_Payments_Order_Service( $this->mock_api_client ); - $action_scheduler_service = new WC_Payments_Action_Scheduler_Service( $this->mock_api_client, $order_service ); - $mock_rate_limiter = $this->createMock( Session_Rate_Limiter::class ); - $mock_dpps = $this->createMock( Duplicate_Payment_Prevention_Service::class ); - $this->mock_localization_service = $this->createMock( WC_Payments_Localization_Service::class ); - $this->mock_fraud_service = $this->createMock( WC_Payments_Fraud_Service::class ); + $this->mock_wcpay_account = $this->createMock( WC_Payments_Account::class ); + $this->mock_db_cache = $this->createMock( Database_Cache::class ); + $this->mock_session_service = $this->createMock( WC_Payments_Session_Service::class ); + $customer_service = new WC_Payments_Customer_Service( $this->mock_api_client, $this->mock_wcpay_account, $this->mock_db_cache, $this->mock_session_service ); + $token_service = new WC_Payments_Token_Service( $this->mock_api_client, $customer_service ); + $order_service = new WC_Payments_Order_Service( $this->mock_api_client ); + $action_scheduler_service = new WC_Payments_Action_Scheduler_Service( $this->mock_api_client, $order_service ); + $mock_rate_limiter = $this->createMock( Session_Rate_Limiter::class ); + $mock_dpps = $this->createMock( Duplicate_Payment_Prevention_Service::class ); + $this->mock_localization_service = $this->createMock( WC_Payments_Localization_Service::class ); + $this->mock_fraud_service = $this->createMock( WC_Payments_Fraud_Service::class ); + $this->mock_duplicates_detection_service = $this->createMock( Duplicates_Detection_Service::class ); $mock_payment_methods = []; $payment_method_classes = [ @@ -167,7 +177,7 @@ public function set_up() { $this->mock_localization_service, $this->mock_fraud_service ); - $this->controller = new WC_REST_Payments_Settings_Controller( $this->mock_api_client, $this->gateway, $this->mock_wcpay_account ); + $this->controller = new WC_REST_Payments_Settings_Controller( $this->mock_api_client, $this->gateway, $this->mock_wcpay_account, $this->mock_duplicates_detection_service ); $this->mock_api_client ->method( 'is_server_connected' ) diff --git a/tests/unit/duplicate-detection/class-test-gateway.php b/tests/unit/duplicate-detection/class-test-gateway.php new file mode 100644 index 00000000000..99735fb8a7a --- /dev/null +++ b/tests/unit/duplicate-detection/class-test-gateway.php @@ -0,0 +1,27 @@ +form_fields = [ + 'payment_request' => [ + 'default' => 'no', + ], + ]; + } +} diff --git a/tests/unit/duplicate-detection/test-class-duplicates-detection-service.php b/tests/unit/duplicate-detection/test-class-duplicates-detection-service.php new file mode 100644 index 00000000000..2dd028d4203 --- /dev/null +++ b/tests/unit/duplicate-detection/test-class-duplicates-detection-service.php @@ -0,0 +1,129 @@ +service = new Duplicates_Detection_Service(); + + $this->woopayments_gateway = new Test_Gateway(); + $this->gateway_from_another_plugin = new Test_Gateway(); + + $this->cached_gateways = WC()->payment_gateways()->payment_gateways; + WC()->payment_gateways()->payment_gateways = [ $this->woopayments_gateway, $this->gateway_from_another_plugin ]; + } + + public function tear_down() { + WC()->payment_gateways()->payment_gateways = $this->cached_gateways; + } + + public function test_two_cc_both_enabled() { + $this->set_duplicates( 'card', 'yes', 'yes' ); + + $result = $this->service->find_duplicates(); + + $this->assertCount( 1, $result ); + $this->assertEquals( 'card', $result[0] ); + } + + public function test_two_cc_one_enabled() { + $this->set_duplicates( CC_Payment_Method::PAYMENT_METHOD_STRIPE_ID, 'yes', 'no' ); + + $result = $this->service->find_duplicates(); + + $this->assertEmpty( $result ); + } + + public function test_two_apms_enabled() { + $this->set_duplicates( Giropay_Payment_Method::PAYMENT_METHOD_STRIPE_ID, 'yes', 'yes' ); + + $result = $this->service->find_duplicates(); + + $this->assertCount( 1, $result ); + $this->assertEquals( Giropay_Payment_Method::PAYMENT_METHOD_STRIPE_ID, $result[0] ); + } + + public function test_two_bnpls_enabled() { + $this->set_duplicates( Klarna_Payment_Method::PAYMENT_METHOD_STRIPE_ID, 'yes', 'yes' ); + + $result = $this->service->find_duplicates(); + + $this->assertCount( 1, $result ); + $this->assertEquals( Klarna_Payment_Method::PAYMENT_METHOD_STRIPE_ID, $result[0] ); + } + + public function test_two_prbs_enabled() { + $this->set_duplicates( CC_Payment_Method::PAYMENT_METHOD_STRIPE_ID, 'yes', 'yes' ); + $this->woopayments_gateway->update_option( 'payment_request', 'yes' ); + $this->woopayments_gateway->enabled = 'yes'; + $this->gateway_from_another_plugin->id = 'apple_pay'; + + $result = $this->service->find_duplicates(); + + $this->assertEquals( 'apple_pay_google_pay', $result[0] ); + } + + public function test_duplicate_not_enabled_in_woopayments() { + $this->set_duplicates( CC_Payment_Method::PAYMENT_METHOD_STRIPE_ID, 'yes', 'yes' ); + $this->woopayments_gateway->id = 'not_woopayments_card'; + + $result = $this->service->find_duplicates(); + + $this->assertEmpty( $result ); + } + + private function set_duplicates( $id, $woopayments_gateway_enabled, $gateway_from_another_plugin_enabled ) { + $this->woopayments_gateway->enabled = $woopayments_gateway_enabled; + $this->gateway_from_another_plugin->enabled = $gateway_from_another_plugin_enabled; + + if ( 'card' === $id ) { + $this->woopayments_gateway->id = 'woocommerce_payments'; + } else { + $this->woopayments_gateway->id = 'woocommerce_payments_' . $id; + } + $this->gateway_from_another_plugin->id = 'another_plugin_' . $id; + } +}