Skip to content
This repository has been archived by the owner on Feb 23, 2024. It is now read-only.

Prevent saved payment methods showing if their main method canPay function returns false #9917

Merged
merged 11 commits into from
Jun 28, 2023
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,25 @@ import {
import { PAYMENT_STORE_KEY } from '@woocommerce/block-data';
import { useDispatch, useSelect } from '@wordpress/data';
import { getPaymentMethods } from '@woocommerce/blocks-registry';
import { isNull } from '@woocommerce/types';
import { RadioControlOption } from '@woocommerce/base-components/radio-control/types';

/**
* @typedef {import('@woocommerce/type-defs/contexts').CustomerPaymentMethod} CustomerPaymentMethod
* @typedef {import('@woocommerce/type-defs/contexts').PaymentStatusDispatch} PaymentStatusDispatch
* Internal dependencies
*/
import { getCanMakePaymentArg } from '../../../data/payment/utils/check-payment-methods';
import { CustomerPaymentMethodConfiguration } from '../../../data/payment/types';

/**
* Returns the option object for a cc or echeck saved payment method token.
*
* @param {CustomerPaymentMethod} savedPaymentMethod
* @return {string} label
*/
const getCcOrEcheckLabel = ( { method, expires } ) => {
const getCcOrEcheckLabel = ( {
method,
expires,
}: {
method: CustomerPaymentMethodConfiguration;
expires: string;
} ): string => {
return sprintf(
/* translators: %1$s is referring to the payment method brand, %2$s is referring to the last 4 digits of the payment card, %3$s is referring to the expiry date. */
__(
Expand All @@ -39,11 +45,12 @@ const getCcOrEcheckLabel = ( { method, expires } ) => {

/**
* Returns the option object for any non specific saved payment method.
*
* @param {CustomerPaymentMethod} savedPaymentMethod
* @return {string} label
*/
const getDefaultLabel = ( { method } ) => {
const getDefaultLabel = ( {
method,
}: {
method: CustomerPaymentMethodConfiguration;
} ): string => {
/* For saved payment methods with brand & last 4 */
if ( method.brand && method.last4 ) {
return sprintf(
Expand Down Expand Up @@ -74,63 +81,89 @@ const SavedPaymentMethodOptions = () => {
} );
const { __internalSetActivePaymentMethod } =
useDispatch( PAYMENT_STORE_KEY );
const canMakePaymentArg = getCanMakePaymentArg();
const paymentMethods = getPaymentMethods();
const paymentMethodInterface = usePaymentMethodInterface();
const { removeNotice } = useDispatch( 'core/notices' );
const { dispatchCheckoutEvent } = useStoreEvents();

const options = useMemo( () => {
const options = useMemo< RadioControlOption[] >( () => {
const types = Object.keys( savedPaymentMethods );
return types
.flatMap( ( type ) => {
const typeMethods = savedPaymentMethods[ type ];
return typeMethods.map( ( paymentMethod ) => {
const isCC = type === 'cc' || type === 'echeck';
const paymentMethodSlug = paymentMethod.method.gateway;
return {
name: `wc-saved-payment-method-token-${ paymentMethodSlug }`,
label: isCC
? getCcOrEcheckLabel( paymentMethod )
: getDefaultLabel( paymentMethod ),
value: paymentMethod.tokenId.toString(),
onChange: ( token ) => {
const savedTokenKey = `wc-${ paymentMethodSlug }-payment-token`;
__internalSetActivePaymentMethod(
paymentMethodSlug,
{
token,
payment_method: paymentMethodSlug,
[ savedTokenKey ]: token.toString(),
isSavedToken: true,
}
);
removeNotice(
'wc-payment-error',
noticeContexts.PAYMENTS
);
dispatchCheckoutEvent(
'set-active-payment-method',
{
paymentMethodSlug,
}
);
},
};
} );
} )
.filter( Boolean );

// Get individual payment methods from saved payment methods and put them into a unique array.
const individualPaymentGateways = new Set(
types.flatMap( ( type ) =>
savedPaymentMethods[ type ].map(
( paymentMethod ) => paymentMethod.method.gateway
)
)
);

const gatewaysThatCanMakePayment = Array.from(
individualPaymentGateways
).filter( ( method ) => {
return paymentMethods[ method ]?.canMakePayment(
canMakePaymentArg
);
} );

const mappedOptions = types.flatMap( ( type ) => {
const typeMethods = savedPaymentMethods[ type ];
return typeMethods.map( ( paymentMethod ) => {
const canMakePayment = gatewaysThatCanMakePayment.includes(
paymentMethod.method.gateway
);
if ( ! canMakePayment ) {
return void 0;
}
const isCC = type === 'cc' || type === 'echeck';
const paymentMethodSlug = paymentMethod.method.gateway;
return {
name: `wc-saved-payment-method-token-${ paymentMethodSlug }`,
label: isCC
? getCcOrEcheckLabel( paymentMethod )
: getDefaultLabel( paymentMethod ),
value: paymentMethod.tokenId.toString(),
onChange: ( token: string ) => {
const savedTokenKey = `wc-${ paymentMethodSlug }-payment-token`;
__internalSetActivePaymentMethod( paymentMethodSlug, {
token,
payment_method: paymentMethodSlug,
[ savedTokenKey ]: token.toString(),
isSavedToken: true,
} );
removeNotice(
'wc-payment-error',
noticeContexts.PAYMENTS
);
dispatchCheckoutEvent( 'set-active-payment-method', {
paymentMethodSlug,
} );
},
};
} );
} );
return mappedOptions.filter(
( option ) => typeof option !== 'undefined'
) as RadioControlOption[];
}, [
savedPaymentMethods,
paymentMethods,
__internalSetActivePaymentMethod,
removeNotice,
dispatchCheckoutEvent,
canMakePaymentArg,
] );
const savedPaymentMethodHandler =
!! activeSavedToken &&
paymentMethods[ activePaymentMethod ] &&
paymentMethods[ activePaymentMethod ]?.savedTokenComponent
typeof paymentMethods[ activePaymentMethod ]?.savedTokenComponent !==
'undefined' &&
! isNull( paymentMethods[ activePaymentMethod ].savedTokenComponent )
? cloneElement(
paymentMethods[ activePaymentMethod ]?.savedTokenComponent,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - we know for sure that the savedTokenComponent is not null or undefined at this point.
paymentMethods[ activePaymentMethod ].savedTokenComponent,
{ token: activeSavedToken, ...paymentMethodInterface }
)
: null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
import { registerPaymentMethod } from '@woocommerce/blocks-registry';
import * as wpData from '@wordpress/data';

/**
* Internal dependencies
*/
import SavedPaymentMethodOptions from '../saved-payment-method-options';

jest.mock( '@wordpress/data', () => ( {
__esModule: true,
...jest.requireActual( '@wordpress/data' ),
useSelect: jest.fn(),
} ) );

const mockedUseSelect = wpData.useSelect as jest.Mock;
// Mock use select so we can override it when wc/store/checkout is accessed, but return the original select function if any other store is accessed.
mockedUseSelect.mockImplementation(
jest.fn().mockImplementation( ( passedMapSelect ) => {
const mockedSelect = jest.fn().mockImplementation( ( storeName ) => {
if ( storeName === 'wc/store/payment' ) {
return {
...jest
.requireActual( '@wordpress/data' )
.select( storeName ),
getActiveSavedToken: () => 1,
getSavedPaymentMethods: () => {
return {
cc: [
{
tokenId: 1,
expires: '1/2099',
method: {
brand: 'Visa',
gateway:
'can-pay-true-test-payment-method',
last4: '1234',
},
},
{
tokenId: 2,
expires: '1/2099',
method: {
brand: 'Visa',
gateway:
'can-pay-true-test-payment-method',
last4: '2345',
},
},
{
tokenId: 3,
expires: '1/2099',
method: {
brand: 'Visa',
gateway:
'can-pay-true-first-false-second-test-payment-method',
last4: '3456',
},
},
],
};
},
};
}
return jest.requireActual( '@wordpress/data' ).select( storeName );
} );
return passedMapSelect( mockedSelect, {
dispatch: jest.requireActual( '@wordpress/data' ).dispatch,
} );
} )
);

describe( 'SavedPaymentMethodOptions', () => {
it( 'renders saved methods when a registered method exists', () => {
registerPaymentMethod( {
name: 'can-pay-true-test-payment-method',
label: 'Can Pay True Test Payment Method',
edit: <div>edit</div>,
ariaLabel: 'Can Pay True Test Payment Method',
canMakePayment: () => true,
content: <div>content</div>,
supports: {
showSavedCards: true,
showSaveOption: true,
features: [ 'products' ],
},
} );
render( <SavedPaymentMethodOptions /> );

// First saved token for can-pay-true-test-payment-method.
expect(
screen.getByText( 'Visa ending in 1234 (expires 1/2099)' )
).toBeInTheDocument();

// Second saved token for can-pay-true-test-payment-method.
expect(
screen.getByText( 'Visa ending in 2345 (expires 1/2099)' )
).toBeInTheDocument();

// Third saved token for can-pay-false-test-payment-method - this should not show because the method is not registered.
expect(
screen.queryByText( 'Visa ending in 3456 (expires 1/2099)' )
).not.toBeInTheDocument();
} );
it( "does not show saved methods when the method's canPay function returns false", () => {
registerPaymentMethod( {
name: 'can-pay-true-first-false-second-test-payment-method',
label: 'Can Pay True First False Second Test Payment Method',
edit: <div>edit</div>,
ariaLabel: 'Can Pay True First False Second Test Payment Method',
// This mock will return true the first time it runs, then false on subsequent calls.
canMakePayment: jest
.fn()
.mockReturnValueOnce( true )
.mockReturnValue( false ),
content: <div>content</div>,
supports: {
showSavedCards: true,
showSaveOption: true,
features: [ 'products' ],
},
} );
const { rerender } = render( <SavedPaymentMethodOptions /> );
// Saved token for can-pay-true-first-false-second-test-payment-method - this should show because canPay is true on first call.
expect(
screen.queryByText( 'Visa ending in 3456 (expires 1/2099)' )
).toBeInTheDocument();
rerender( <SavedPaymentMethodOptions /> );

// Saved token for can-pay-true-first-false-second-test-payment-method - this should not show because canPay is false on subsequent calls.
expect(
screen.queryByText( 'Visa ending in 3456 (expires 1/2099)' )
).not.toBeInTheDocument();
} );
} );
Loading
Loading