initial commit

This commit is contained in:
2021-12-10 12:03:04 +00:00
commit c46c7ddbf0
3643 changed files with 582794 additions and 0 deletions

View File

@ -0,0 +1,85 @@
/**
* External dependencies
*/
import {
PaymentMethods,
ExpressPaymentMethods,
} from '@woocommerce/type-defs/payments';
/**
* Internal dependencies
*/
import { ACTION, STATUS } from './constants';
export interface ActionType {
type: ACTION | STATUS;
errorMessage?: string;
paymentMethodData?: Record< string, unknown >;
paymentMethods?: PaymentMethods | ExpressPaymentMethods;
shouldSavePaymentMethod?: boolean;
}
/**
* All the actions that can be dispatched for payment methods.
*/
export const actions = {
statusOnly: ( type: STATUS ): { type: STATUS } => ( { type } as const ),
error: ( errorMessage: string ): ActionType =>
( {
type: STATUS.ERROR,
errorMessage,
} as const ),
failed: ( {
errorMessage,
paymentMethodData,
}: {
errorMessage: string;
paymentMethodData: Record< string, unknown >;
} ): ActionType =>
( {
type: STATUS.FAILED,
errorMessage,
paymentMethodData,
} as const ),
success: ( {
paymentMethodData,
}: {
paymentMethodData?: Record< string, unknown >;
} ): ActionType =>
( {
type: STATUS.SUCCESS,
paymentMethodData,
} as const ),
started: ( {
paymentMethodData,
}: {
paymentMethodData?: Record< string, unknown >;
} ): ActionType =>
( {
type: STATUS.STARTED,
paymentMethodData,
} as const ),
setRegisteredPaymentMethods: (
paymentMethods: PaymentMethods
): ActionType =>
( {
type: ACTION.SET_REGISTERED_PAYMENT_METHODS,
paymentMethods,
} as const ),
setRegisteredExpressPaymentMethods: (
paymentMethods: ExpressPaymentMethods
): ActionType =>
( {
type: ACTION.SET_REGISTERED_EXPRESS_PAYMENT_METHODS,
paymentMethods,
} as const ),
setShouldSavePaymentMethod: (
shouldSavePaymentMethod: boolean
): ActionType =>
( {
type: ACTION.SET_SHOULD_SAVE_PAYMENT_METHOD,
shouldSavePaymentMethod,
} as const ),
};
export default actions;

View File

@ -0,0 +1,77 @@
/**
* Internal dependencies
*/
import type {
PaymentMethodDataContextType,
PaymentMethodDataContextState,
} from './types';
export enum STATUS {
PRISTINE = 'pristine',
STARTED = 'started',
PROCESSING = 'processing',
ERROR = 'has_error',
FAILED = 'failed',
SUCCESS = 'success',
COMPLETE = 'complete',
}
export enum ACTION {
SET_REGISTERED_PAYMENT_METHODS = 'set_registered_payment_methods',
SET_REGISTERED_EXPRESS_PAYMENT_METHODS = 'set_registered_express_payment_methods',
SET_SHOULD_SAVE_PAYMENT_METHOD = 'set_should_save_payment_method',
}
// Note - if fields are added/shape is changed, you may want to update PRISTINE reducer clause to preserve your new field.
export const DEFAULT_PAYMENT_DATA_CONTEXT_STATE: PaymentMethodDataContextState = {
currentStatus: STATUS.PRISTINE,
shouldSavePaymentMethod: false,
paymentMethodData: {
payment_method: '',
},
hasSavedToken: false,
errorMessage: '',
paymentMethods: {},
expressPaymentMethods: {},
};
export const DEFAULT_PAYMENT_METHOD_DATA: PaymentMethodDataContextType = {
setPaymentStatus: () => ( {
pristine: () => void null,
started: () => void null,
processing: () => void null,
completed: () => void null,
error: ( errorMessage: string ) => void errorMessage,
failed: ( errorMessage, paymentMethodData ) =>
void [ errorMessage, paymentMethodData ],
success: ( paymentMethodData, billingData ) =>
void [ paymentMethodData, billingData ],
} ),
currentStatus: {
isPristine: true,
isStarted: false,
isProcessing: false,
isFinished: false,
hasError: false,
hasFailed: false,
isSuccessful: false,
isDoingExpressPayment: false,
},
paymentStatuses: STATUS,
paymentMethodData: {},
errorMessage: '',
activePaymentMethod: '',
setActivePaymentMethod: () => void null,
activeSavedToken: '',
setActiveSavedToken: () => void null,
customerPaymentMethods: {},
paymentMethods: {},
expressPaymentMethods: {},
paymentMethodsInitialized: false,
expressPaymentMethodsInitialized: false,
onPaymentProcessing: () => () => () => void null,
setExpressPaymentError: () => void null,
isExpressPaymentMethodActive: false,
setShouldSavePayment: () => void null,
shouldSavePayment: false,
};

View File

@ -0,0 +1,49 @@
/**
* External dependencies
*/
import { useMemo } from '@wordpress/element';
/**
* Internal dependencies
*/
import {
reducer,
emitEvent,
emitEventWithAbort,
emitterCallback,
ActionType,
} from '../../../event-emit';
const EMIT_TYPES = {
PAYMENT_PROCESSING: 'payment_processing',
};
type EventEmittersType = Record< string, ReturnType< typeof emitterCallback > >;
/**
* Receives a reducer dispatcher and returns an object with the
* various event emitters for the payment processing events.
*
* Calling the event registration function with the callback will register it
* for the event emitter and will return a dispatcher for removing the
* registered callback (useful for implementation in `useEffect`).
*
* @param {Function} observerDispatch The emitter reducer dispatcher.
* @return {Object} An object with the various payment event emitter registration functions
*/
const useEventEmitters = (
observerDispatch: React.Dispatch< ActionType >
): EventEmittersType => {
const eventEmitters = useMemo(
() => ( {
onPaymentProcessing: emitterCallback(
EMIT_TYPES.PAYMENT_PROCESSING,
observerDispatch
),
} ),
[ observerDispatch ]
);
return eventEmitters;
};
export { EMIT_TYPES, useEventEmitters, reducer, emitEvent, emitEventWithAbort };

View File

@ -0,0 +1,4 @@
export {
PaymentMethodDataProvider,
usePaymentMethodDataContext,
} from './payment-method-data-context';

View File

@ -0,0 +1,355 @@
/**
* External dependencies
*/
import {
createContext,
useContext,
useReducer,
useCallback,
useRef,
useEffect,
useMemo,
} from '@wordpress/element';
/**
* Internal dependencies
*/
import type {
CustomerPaymentMethods,
PaymentMethodDataContextType,
} from './types';
import {
STATUS,
DEFAULT_PAYMENT_DATA_CONTEXT_STATE,
DEFAULT_PAYMENT_METHOD_DATA,
} from './constants';
import reducer from './reducer';
import {
usePaymentMethods,
useExpressPaymentMethods,
} from './use-payment-method-registration';
import { usePaymentMethodDataDispatchers } from './use-payment-method-dispatchers';
import { useActivePaymentMethod } from './use-active-payment-method';
import { useCheckoutContext } from '../checkout-state';
import { useEditorContext } from '../../editor-context';
import {
EMIT_TYPES,
useEventEmitters,
emitEventWithAbort,
reducer as emitReducer,
} from './event-emit';
import { useValidationContext } from '../../validation';
import { useStoreNotices } from '../../../hooks/use-store-notices';
import { useEmitResponse } from '../../../hooks/use-emit-response';
import { getCustomerPaymentMethods } from './utils';
const PaymentMethodDataContext = createContext( DEFAULT_PAYMENT_METHOD_DATA );
export const usePaymentMethodDataContext = (): PaymentMethodDataContextType => {
return useContext( PaymentMethodDataContext );
};
/**
* PaymentMethodDataProvider is automatically included in the CheckoutDataProvider.
*
* This provides the api interface (via the context hook) for payment method status and data.
*
* @param {Object} props Incoming props for provider
* @param {Object} props.children The wrapped components in this provider.
*/
export const PaymentMethodDataProvider = ( {
children,
}: {
children: React.ReactChildren;
} ): JSX.Element => {
const {
isProcessing: checkoutIsProcessing,
isIdle: checkoutIsIdle,
isCalculating: checkoutIsCalculating,
hasError: checkoutHasError,
} = useCheckoutContext();
const { isEditor, getPreviewData } = useEditorContext();
const { setValidationErrors } = useValidationContext();
const { addErrorNotice, removeNotice } = useStoreNotices();
const {
isSuccessResponse,
isErrorResponse,
isFailResponse,
noticeContexts,
} = useEmitResponse();
const [ observers, observerDispatch ] = useReducer( emitReducer, {} );
const { onPaymentProcessing } = useEventEmitters( observerDispatch );
const currentObservers = useRef( observers );
// ensure observers are always current.
useEffect( () => {
currentObservers.current = observers;
}, [ observers ] );
const [ paymentData, dispatch ] = useReducer(
reducer,
DEFAULT_PAYMENT_DATA_CONTEXT_STATE
);
const {
dispatchActions,
setPaymentStatus,
} = usePaymentMethodDataDispatchers( dispatch );
const paymentMethodsInitialized = usePaymentMethods(
dispatchActions.setRegisteredPaymentMethods
);
const expressPaymentMethodsInitialized = useExpressPaymentMethods(
dispatchActions.setRegisteredExpressPaymentMethods
);
const {
activePaymentMethod,
activeSavedToken,
setActivePaymentMethod,
setActiveSavedToken,
} = useActivePaymentMethod();
const customerPaymentMethods = useMemo( (): CustomerPaymentMethods => {
if ( isEditor ) {
return getPreviewData(
'previewSavedPaymentMethods'
) as CustomerPaymentMethods;
}
return paymentMethodsInitialized
? getCustomerPaymentMethods( paymentData.paymentMethods )
: {};
}, [
isEditor,
getPreviewData,
paymentMethodsInitialized,
paymentData.paymentMethods,
] );
const setExpressPaymentError = useCallback(
( message ) => {
if ( message ) {
addErrorNotice( message, {
id: 'wc-express-payment-error',
context: noticeContexts.EXPRESS_PAYMENTS,
} );
} else {
removeNotice(
'wc-express-payment-error',
noticeContexts.EXPRESS_PAYMENTS
);
}
},
[ addErrorNotice, noticeContexts.EXPRESS_PAYMENTS, removeNotice ]
);
const isExpressPaymentMethodActive = Object.keys(
paymentData.expressPaymentMethods
).includes( activePaymentMethod );
const currentStatus = useMemo(
() => ( {
isPristine: paymentData.currentStatus === STATUS.PRISTINE,
isStarted: paymentData.currentStatus === STATUS.STARTED,
isProcessing: paymentData.currentStatus === STATUS.PROCESSING,
isFinished: [
STATUS.ERROR,
STATUS.FAILED,
STATUS.SUCCESS,
].includes( paymentData.currentStatus ),
hasError: paymentData.currentStatus === STATUS.ERROR,
hasFailed: paymentData.currentStatus === STATUS.FAILED,
isSuccessful: paymentData.currentStatus === STATUS.SUCCESS,
isDoingExpressPayment:
paymentData.currentStatus !== STATUS.PRISTINE &&
isExpressPaymentMethodActive,
} ),
[ paymentData.currentStatus, isExpressPaymentMethodActive ]
);
// Update the active (selected) payment method when it is empty, or invalid.
useEffect( () => {
const paymentMethodKeys = Object.keys( paymentData.paymentMethods );
const allPaymentMethodKeys = [
...paymentMethodKeys,
...Object.keys( paymentData.expressPaymentMethods ),
];
if ( ! paymentMethodsInitialized || ! paymentMethodKeys.length ) {
return;
}
setActivePaymentMethod( ( currentActivePaymentMethod ) => {
// If there's no active payment method, or the active payment method has
// been removed (e.g. COD vs shipping methods), set one as active.
// Note: It's possible that the active payment method might be an
// express payment method. So registered express payment methods are
// included in the check here.
if (
! currentActivePaymentMethod ||
! allPaymentMethodKeys.includes( currentActivePaymentMethod )
) {
setPaymentStatus().pristine();
return Object.keys( paymentData.paymentMethods )[ 0 ];
}
return currentActivePaymentMethod;
} );
}, [
paymentMethodsInitialized,
paymentData.paymentMethods,
paymentData.expressPaymentMethods,
setActivePaymentMethod,
setPaymentStatus,
] );
// flip payment to processing if checkout processing is complete, there are no errors, and payment status is started.
useEffect( () => {
if (
checkoutIsProcessing &&
! checkoutHasError &&
! checkoutIsCalculating &&
! currentStatus.isFinished
) {
setPaymentStatus().processing();
}
}, [
checkoutIsProcessing,
checkoutHasError,
checkoutIsCalculating,
currentStatus.isFinished,
setPaymentStatus,
] );
// When checkout is returned to idle, set payment status to pristine but only if payment status is already not finished.
useEffect( () => {
if ( checkoutIsIdle && ! currentStatus.isSuccessful ) {
setPaymentStatus().pristine();
}
}, [ checkoutIsIdle, currentStatus.isSuccessful, setPaymentStatus ] );
// if checkout has an error and payment is not being made with a saved token and payment status is success, then let's sync payment status back to pristine.
useEffect( () => {
if (
checkoutHasError &&
currentStatus.isSuccessful &&
! paymentData.hasSavedToken
) {
setPaymentStatus().pristine();
}
}, [
checkoutHasError,
currentStatus.isSuccessful,
paymentData.hasSavedToken,
setPaymentStatus,
] );
useEffect( () => {
// Note: the nature of this event emitter is that it will bail on any
// observer that returns a response that !== true. However, this still
// allows for other observers that return true for continuing through
// to the next observer (or bailing if there's a problem).
if ( currentStatus.isProcessing ) {
removeNotice( 'wc-payment-error', noticeContexts.PAYMENTS );
emitEventWithAbort(
currentObservers.current,
EMIT_TYPES.PAYMENT_PROCESSING,
{}
).then( ( observerResponses ) => {
let successResponse, errorResponse;
observerResponses.forEach( ( response ) => {
if ( isSuccessResponse( response ) ) {
// the last observer response always "wins" for success.
successResponse = response;
}
if (
isErrorResponse( response ) ||
isFailResponse( response )
) {
errorResponse = response;
}
} );
if ( successResponse && ! errorResponse ) {
setPaymentStatus().success(
successResponse?.meta?.paymentMethodData,
successResponse?.meta?.billingData,
successResponse?.meta?.shippingData
);
} else if ( errorResponse && isFailResponse( errorResponse ) ) {
if (
errorResponse.message &&
errorResponse.message.length
) {
addErrorNotice( errorResponse.message, {
id: 'wc-payment-error',
isDismissible: false,
context:
errorResponse?.messageContext ||
noticeContexts.PAYMENTS,
} );
}
setPaymentStatus().failed(
errorResponse?.message,
errorResponse?.meta?.paymentMethodData,
errorResponse?.meta?.billingData
);
} else if ( errorResponse ) {
if (
errorResponse.message &&
errorResponse.message.length
) {
addErrorNotice( errorResponse.message, {
id: 'wc-payment-error',
isDismissible: false,
context:
errorResponse?.messageContext ||
noticeContexts.PAYMENTS,
} );
}
setPaymentStatus().error( errorResponse.message );
setValidationErrors( errorResponse?.validationErrors );
} else {
// otherwise there are no payment methods doing anything so
// just consider success
setPaymentStatus().success();
}
} );
}
}, [
currentStatus.isProcessing,
setValidationErrors,
setPaymentStatus,
removeNotice,
noticeContexts.PAYMENTS,
isSuccessResponse,
isFailResponse,
isErrorResponse,
addErrorNotice,
] );
const paymentContextData: PaymentMethodDataContextType = {
setPaymentStatus,
currentStatus,
paymentStatuses: STATUS,
paymentMethodData: paymentData.paymentMethodData,
errorMessage: paymentData.errorMessage,
activePaymentMethod,
setActivePaymentMethod,
activeSavedToken,
setActiveSavedToken,
onPaymentProcessing,
customerPaymentMethods,
paymentMethods: paymentData.paymentMethods,
expressPaymentMethods: paymentData.expressPaymentMethods,
paymentMethodsInitialized,
expressPaymentMethodsInitialized,
setExpressPaymentError,
isExpressPaymentMethodActive,
shouldSavePayment: paymentData.shouldSavePaymentMethod,
setShouldSavePayment: dispatchActions.setShouldSavePayment,
};
return (
<PaymentMethodDataContext.Provider value={ paymentContextData }>
{ children }
</PaymentMethodDataContext.Provider>
);
};

View File

@ -0,0 +1,120 @@
/**
* Internal dependencies
*/
import {
ACTION,
STATUS,
DEFAULT_PAYMENT_DATA_CONTEXT_STATE,
} from './constants';
import type { PaymentMethodDataContextState } from './types';
import type { ActionType } from './actions';
const hasSavedPaymentToken = (
paymentMethodData: Record< string, unknown > | undefined
): boolean => {
return !! (
typeof paymentMethodData === 'object' && paymentMethodData.isSavedToken
);
};
/**
* Reducer for payment data state
*/
const reducer = (
state = DEFAULT_PAYMENT_DATA_CONTEXT_STATE,
{
type,
paymentMethodData,
shouldSavePaymentMethod = false,
errorMessage = '',
paymentMethods = {},
}: ActionType
): PaymentMethodDataContextState => {
switch ( type ) {
case STATUS.STARTED:
return {
...state,
currentStatus: STATUS.STARTED,
paymentMethodData: paymentMethodData || state.paymentMethodData,
hasSavedToken: hasSavedPaymentToken(
paymentMethodData || state.paymentMethodData
),
};
case STATUS.ERROR:
return state.currentStatus !== STATUS.ERROR
? {
...state,
currentStatus: STATUS.ERROR,
errorMessage: errorMessage || state.errorMessage,
}
: state;
case STATUS.FAILED:
return state.currentStatus !== STATUS.FAILED
? {
...state,
currentStatus: STATUS.FAILED,
paymentMethodData:
paymentMethodData || state.paymentMethodData,
errorMessage: errorMessage || state.errorMessage,
}
: state;
case STATUS.SUCCESS:
return state.currentStatus !== STATUS.SUCCESS
? {
...state,
currentStatus: STATUS.SUCCESS,
paymentMethodData:
paymentMethodData || state.paymentMethodData,
hasSavedToken: hasSavedPaymentToken(
paymentMethodData || state.paymentMethodData
),
}
: state;
case STATUS.PROCESSING:
return state.currentStatus !== STATUS.PROCESSING
? {
...state,
currentStatus: STATUS.PROCESSING,
errorMessage: '',
}
: state;
case STATUS.COMPLETE:
return state.currentStatus !== STATUS.COMPLETE
? {
...state,
currentStatus: STATUS.COMPLETE,
}
: state;
case STATUS.PRISTINE:
return {
...DEFAULT_PAYMENT_DATA_CONTEXT_STATE,
currentStatus: STATUS.PRISTINE,
// keep payment method registration state
paymentMethods: {
...state.paymentMethods,
},
expressPaymentMethods: {
...state.expressPaymentMethods,
},
shouldSavePaymentMethod: state.shouldSavePaymentMethod,
};
case ACTION.SET_REGISTERED_PAYMENT_METHODS:
return {
...state,
paymentMethods,
};
case ACTION.SET_REGISTERED_EXPRESS_PAYMENT_METHODS:
return {
...state,
expressPaymentMethods: paymentMethods,
};
case ACTION.SET_SHOULD_SAVE_PAYMENT_METHOD:
return {
...state,
shouldSavePaymentMethod,
};
}
};
export default reducer;

View File

@ -0,0 +1,251 @@
/**
* External dependencies
*/
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { previewCart } from '@woocommerce/resource-previews';
import { dispatch } from '@wordpress/data';
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
import {
registerPaymentMethod,
registerExpressPaymentMethod,
__experimentalDeRegisterPaymentMethod,
__experimentalDeRegisterExpressPaymentMethod,
} from '@woocommerce/blocks-registry';
import { default as fetchMock } from 'jest-fetch-mock';
/**
* Internal dependencies
*/
import {
usePaymentMethodDataContext,
PaymentMethodDataProvider,
} from '../payment-method-data-context';
import {
CheckoutExpressPayment,
SavedPaymentMethodOptions,
} from '../../../../../../blocks/cart-checkout/payment-methods';
import { defaultCartState } from '../../../../../../data/default-states';
jest.mock( '@woocommerce/settings', () => {
const originalModule = jest.requireActual( '@woocommerce/settings' );
return {
// @ts-ignore We know @woocommerce/settings is an object.
...originalModule,
getSetting: ( setting, ...rest ) => {
if ( setting === 'customerPaymentMethods' ) {
return {
cc: [
{
method: {
gateway: 'stripe',
last4: '4242',
brand: 'Visa',
},
expires: '12/22',
is_default: true,
tokenId: 1,
},
],
};
}
return originalModule.getSetting( setting, ...rest );
},
};
} );
const registerMockPaymentMethods = () => {
[ 'cheque', 'bacs' ].forEach( ( name ) => {
registerPaymentMethod( {
name,
label: name,
content: <div>A payment method</div>,
edit: <div>A payment method</div>,
icons: null,
canMakePayment: () => true,
supports: {
features: [ 'products' ],
},
ariaLabel: name,
} );
} );
[ 'stripe' ].forEach( ( name ) => {
registerPaymentMethod( {
name,
label: name,
content: <div>A payment method</div>,
edit: <div>A payment method</div>,
icons: null,
canMakePayment: () => true,
supports: {
showSavedCards: true,
showSaveOption: true,
features: [ 'products' ],
},
ariaLabel: name,
} );
} );
[ 'express-payment' ].forEach( ( name ) => {
const Content = ( {
onClose = () => void null,
onClick = () => void null,
} ) => {
return (
<>
<button onClick={ onClick }>
{ name + ' express payment method' }
</button>
<button onClick={ onClose }>
{ name + ' express payment method close' }
</button>
</>
);
};
registerExpressPaymentMethod( {
name,
content: <Content />,
edit: <div>An express payment method</div>,
canMakePayment: () => true,
paymentMethodId: name,
supports: {
features: [ 'products' ],
},
} );
} );
};
const resetMockPaymentMethods = () => {
[ 'cheque', 'bacs', 'stripe' ].forEach( ( name ) => {
__experimentalDeRegisterPaymentMethod( name );
} );
[ 'express-payment' ].forEach( ( name ) => {
__experimentalDeRegisterExpressPaymentMethod( name );
} );
};
describe( 'Testing Payment Method Data Context Provider', () => {
beforeEach( async () => {
registerMockPaymentMethods();
fetchMock.mockResponse( ( req ) => {
if ( req.url.match( /wc\/store\/cart/ ) ) {
return Promise.resolve( JSON.stringify( previewCart ) );
}
return Promise.resolve( '' );
} );
// need to clear the store resolution state between tests.
await dispatch( storeKey ).invalidateResolutionForStore();
await dispatch( storeKey ).receiveCart( defaultCartState.cartData );
} );
afterEach( async () => {
resetMockPaymentMethods();
fetchMock.resetMocks();
} );
it( 'toggles active payment method correctly for express payment activation and close', async () => {
const TriggerActiveExpressPaymentMethod = () => {
const { activePaymentMethod } = usePaymentMethodDataContext();
return (
<>
<CheckoutExpressPayment />
{ 'Active Payment Method: ' + activePaymentMethod }
</>
);
};
const TestComponent = () => {
return (
<PaymentMethodDataProvider>
<TriggerActiveExpressPaymentMethod />
</PaymentMethodDataProvider>
);
};
render( <TestComponent /> );
// should initialize by default the first payment method.
await waitFor( () => {
const activePaymentMethod = screen.queryByText(
/Active Payment Method: cheque/
);
expect( activePaymentMethod ).not.toBeNull();
} );
// Express payment method clicked.
fireEvent.click(
screen.getByText( 'express-payment express payment method' )
);
await waitFor( () => {
const activePaymentMethod = screen.queryByText(
/Active Payment Method: express-payment/
);
expect( activePaymentMethod ).not.toBeNull();
} );
// Express payment method closed.
fireEvent.click(
screen.getByText( 'express-payment express payment method close' )
);
await waitFor( () => {
const activePaymentMethod = screen.queryByText(
/Active Payment Method: cheque/
);
expect( activePaymentMethod ).not.toBeNull();
} );
// ["`select` control in `@wordpress/data-controls` is deprecated. Please use built-in `resolveSelect` control in `@wordpress/data` instead."]
expect( console ).toHaveWarned();
} );
it( 'resets saved payment method data after starting and closing an express payment method', async () => {
const TriggerActiveExpressPaymentMethod = () => {
const {
activePaymentMethod,
paymentMethodData,
} = usePaymentMethodDataContext();
return (
<>
<CheckoutExpressPayment />
<SavedPaymentMethodOptions onChange={ () => void null } />
{ 'Active Payment Method: ' + activePaymentMethod }
{ paymentMethodData[ 'wc-stripe-payment-token' ] && (
<span>Stripe token</span>
) }
</>
);
};
const TestComponent = () => {
return (
<PaymentMethodDataProvider>
<TriggerActiveExpressPaymentMethod />
</PaymentMethodDataProvider>
);
};
render( <TestComponent /> );
// Should initialize by default the default saved payment method.
await waitFor( () => {
const activePaymentMethod = screen.queryByText(
/Active Payment Method: stripe/
);
const stripeToken = screen.queryByText( /Stripe token/ );
expect( activePaymentMethod ).not.toBeNull();
expect( stripeToken ).not.toBeNull();
} );
// Express payment method clicked.
fireEvent.click(
screen.getByText( 'express-payment express payment method' )
);
await waitFor( () => {
const activePaymentMethod = screen.queryByText(
/Active Payment Method: express-payment/
);
const stripeToken = screen.queryByText( /Stripe token/ );
expect( activePaymentMethod ).not.toBeNull();
expect( stripeToken ).toBeNull();
} );
// Express payment method closed.
fireEvent.click(
screen.getByText( 'express-payment express payment method close' )
);
await waitFor( () => {
const activePaymentMethod = screen.queryByText(
/Active Payment Method: stripe/
);
const stripeToken = screen.queryByText( /Stripe token/ );
expect( activePaymentMethod ).not.toBeNull();
expect( stripeToken ).not.toBeNull();
} );
} );
} );

View File

@ -0,0 +1,130 @@
/**
* External dependencies
*/
import {
PaymentMethodConfiguration,
PaymentMethods,
ExpressPaymentMethods,
} from '@woocommerce/type-defs/payments';
import type {
EmptyObjectType,
ObjectType,
} from '@woocommerce/type-defs/objects';
/**
* Internal dependencies
*/
import type { emitterCallback } from '../../../event-emit';
import { STATUS } from './constants';
export interface CustomerPaymentMethod {
method: PaymentMethodConfiguration;
expires: string;
is_default: boolean;
tokenId: number;
actions: ObjectType;
}
export type CustomerPaymentMethods =
| Record< string, CustomerPaymentMethod >
| EmptyObjectType;
export type PaymentMethodDispatchers = {
setRegisteredPaymentMethods: ( paymentMethods: PaymentMethods ) => void;
setRegisteredExpressPaymentMethods: (
paymentMethods: ExpressPaymentMethods
) => void;
setShouldSavePayment: ( shouldSave: boolean ) => void;
};
export interface PaymentStatusDispatchers {
pristine: () => void;
started: ( paymentMethodData?: ObjectType | EmptyObjectType ) => void;
processing: () => void;
completed: () => void;
error: ( error: string ) => void;
failed: (
error?: string,
paymentMethodData?: ObjectType | EmptyObjectType,
billingData?: ObjectType | EmptyObjectType
) => void;
success: (
paymentMethodData?: ObjectType | EmptyObjectType,
billingData?: ObjectType | EmptyObjectType,
shippingData?: ObjectType | EmptyObjectType
) => void;
}
export interface PaymentMethodDataContextState {
currentStatus: STATUS;
shouldSavePaymentMethod: boolean;
paymentMethodData: ObjectType | EmptyObjectType;
hasSavedToken: boolean;
errorMessage: string;
paymentMethods: PaymentMethods;
expressPaymentMethods: ExpressPaymentMethods;
}
export type PaymentMethodCurrentStatusType = {
// If true then the payment method state in checkout is pristine.
isPristine: boolean;
// If true then the payment method has been initialized and has started.
isStarted: boolean;
// If true then the payment method is processing payment.
isProcessing: boolean;
// If true then the payment method is in a finished state (which may mean it's status is either error, failed, or success).
isFinished: boolean;
// If true then the payment method is in an error state.
hasError: boolean;
// If true then the payment method has failed (usually indicates a problem with the payment method used, not logic error).
hasFailed: boolean;
// If true then the payment method has completed it's processing successfully.
isSuccessful: boolean;
// If true, an express payment is in progress.
isDoingExpressPayment: boolean;
};
export type PaymentMethodDataContextType = {
// Sets the payment status for the payment method.
setPaymentStatus: () => PaymentStatusDispatchers;
// The current payment status.
currentStatus: PaymentMethodCurrentStatusType;
// An object of payment status constants.
paymentStatuses: ObjectType;
// Arbitrary data to be passed along for processing by the payment method on the server.
paymentMethodData: ObjectType | EmptyObjectType;
// An error message provided by the payment method if there is an error.
errorMessage: string;
// The active payment method slug.
activePaymentMethod: string;
// A function for setting the active payment method.
setActivePaymentMethod: ( paymentMethod: string ) => void;
// Current active token.
activeSavedToken: string;
// A function for setting the active payment method token.
setActiveSavedToken: ( activeSavedToken: string ) => void;
// Returns the customer payment for the customer if it exists.
customerPaymentMethods:
| Record< string, CustomerPaymentMethod >
| EmptyObjectType;
// Registered payment methods.
paymentMethods: PaymentMethods;
// Registered express payment methods.
expressPaymentMethods: ExpressPaymentMethods;
// True when all registered payment methods have been initialized.
paymentMethodsInitialized: boolean;
// True when all registered express payment methods have been initialized.
expressPaymentMethodsInitialized: boolean;
// Event registration callback for registering observers for the payment processing event.
onPaymentProcessing: ReturnType< typeof emitterCallback >;
// A function used by express payment methods to indicate an error for checkout to handle. It receives an error message string. Does not change payment status.
setExpressPaymentError: ( error: string ) => void;
// True if an express payment method is active.
isExpressPaymentMethodActive: boolean;
// A function used to set the shouldSavePayment value.
setShouldSavePayment: ( shouldSavePayment: boolean ) => void;
// True means that the configured payment method option is saved for the customer.
shouldSavePayment: boolean;
};
export type PaymentMethodsDispatcherType = (
paymentMethods: PaymentMethods
) => undefined;

View File

@ -0,0 +1,38 @@
/**
* External dependencies
*/
import { useState, useEffect } from '@wordpress/element';
/**
* Internal dependencies
*/
import { useStoreEvents } from '../../../hooks/use-store-events';
export const useActivePaymentMethod = (): {
activePaymentMethod: string;
activeSavedToken: string;
setActivePaymentMethod: React.Dispatch< React.SetStateAction< string > >;
setActiveSavedToken: ( token: string ) => void;
} => {
const { dispatchCheckoutEvent } = useStoreEvents();
// The active payment method - e.g. Stripe CC or BACS.
const [ activePaymentMethod, setActivePaymentMethod ] = useState( '' );
// If a previously saved payment method is active, the token for that method. For example, a for a Stripe CC card saved to user account.
const [ activeSavedToken, setActiveSavedToken ] = useState( '' );
// Trigger event on change.
useEffect( () => {
dispatchCheckoutEvent( 'set-active-payment-method', {
activePaymentMethod,
} );
}, [ dispatchCheckoutEvent, activePaymentMethod ] );
return {
activePaymentMethod,
activeSavedToken,
setActivePaymentMethod,
setActiveSavedToken,
};
};

View File

@ -0,0 +1,105 @@
/**
* External dependencies
*/
import { useCallback, useMemo } from '@wordpress/element';
/**
* Internal dependencies
*/
import { actions, ActionType } from './actions';
import { STATUS } from './constants';
import { useCustomerDataContext } from '../customer';
import { useShippingDataContext } from '../shipping';
import type {
PaymentStatusDispatchers,
PaymentMethodDispatchers,
} from './types';
export const usePaymentMethodDataDispatchers = (
dispatch: React.Dispatch< ActionType >
): {
dispatchActions: PaymentMethodDispatchers;
setPaymentStatus: () => PaymentStatusDispatchers;
} => {
const { setBillingData } = useCustomerDataContext();
const { setShippingAddress } = useShippingDataContext();
const dispatchActions = useMemo(
(): PaymentMethodDispatchers => ( {
setRegisteredPaymentMethods: ( paymentMethods ) =>
void dispatch(
actions.setRegisteredPaymentMethods( paymentMethods )
),
setRegisteredExpressPaymentMethods: ( paymentMethods ) =>
void dispatch(
actions.setRegisteredExpressPaymentMethods( paymentMethods )
),
setShouldSavePayment: ( shouldSave ) =>
void dispatch(
actions.setShouldSavePaymentMethod( shouldSave )
),
} ),
[ dispatch ]
);
const setPaymentStatus = useCallback(
(): PaymentStatusDispatchers => ( {
pristine: () => dispatch( actions.statusOnly( STATUS.PRISTINE ) ),
started: ( paymentMethodData ) => {
dispatch(
actions.started( {
paymentMethodData,
} )
);
},
processing: () =>
dispatch( actions.statusOnly( STATUS.PROCESSING ) ),
completed: () => dispatch( actions.statusOnly( STATUS.COMPLETE ) ),
error: ( errorMessage ) =>
dispatch( actions.error( errorMessage ) ),
failed: (
errorMessage,
paymentMethodData,
billingData = undefined
) => {
if ( billingData ) {
setBillingData( billingData );
}
dispatch(
actions.failed( {
errorMessage: errorMessage || '',
paymentMethodData: paymentMethodData || {},
} )
);
},
success: (
paymentMethodData,
billingData = undefined,
shippingData = undefined
) => {
if ( billingData ) {
setBillingData( billingData );
}
if (
typeof shippingData !== undefined &&
shippingData?.address
) {
setShippingAddress(
shippingData.address as Record< string, unknown >
);
}
dispatch(
actions.success( {
paymentMethodData,
} )
);
},
} ),
[ dispatch, setBillingData, setShippingAddress ]
);
return {
dispatchActions,
setPaymentStatus,
};
};

View File

@ -0,0 +1,229 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import {
getPaymentMethods,
getExpressPaymentMethods,
} from '@woocommerce/blocks-registry';
import { useState, useEffect, useRef, useCallback } from '@wordpress/element';
import { useShallowEqual } from '@woocommerce/base-hooks';
import { CURRENT_USER_IS_ADMIN, getSetting } from '@woocommerce/settings';
import type {
PaymentMethods,
ExpressPaymentMethods,
PaymentMethodConfigInstance,
ExpressPaymentMethodConfigInstance,
} from '@woocommerce/type-defs/payments';
import { useDebouncedCallback } from 'use-debounce';
/**
* Internal dependencies
*/
import { useEditorContext } from '../../editor-context';
import { useShippingDataContext } from '../shipping';
import { useCustomerDataContext } from '../customer';
import { useStoreCart } from '../../../hooks/cart/use-store-cart';
import { useStoreNotices } from '../../../hooks/use-store-notices';
import { useEmitResponse } from '../../../hooks/use-emit-response';
import type { PaymentMethodsDispatcherType } from './types';
/**
* This hook handles initializing registered payment methods and exposing all
* registered payment methods that can be used in the current environment (via
* the payment method's `canMakePayment` property).
*
* @param {function(Object):undefined} dispatcher A dispatcher for setting registered payment methods to an external state.
* @param {Object} registeredPaymentMethods Registered payment methods to process.
* @param {Array} paymentMethodsSortOrder Array of payment method names to sort by. This should match keys of registeredPaymentMethods.
* @param {string} noticeContext Id of the context to append notices to.
*
* @return {boolean} Whether the payment methods have been initialized or not. True when all payment methods have been initialized.
*/
const usePaymentMethodRegistration = (
dispatcher: PaymentMethodsDispatcherType,
registeredPaymentMethods: PaymentMethods | ExpressPaymentMethods,
paymentMethodsSortOrder: string[],
noticeContext: string
) => {
const [ isInitialized, setIsInitialized ] = useState( false );
const { isEditor } = useEditorContext();
const { selectedRates } = useShippingDataContext();
const { billingData, shippingAddress } = useCustomerDataContext();
const selectedShippingMethods = useShallowEqual( selectedRates );
const paymentMethodsOrder = useShallowEqual( paymentMethodsSortOrder );
const cart = useStoreCart();
const { cartTotals, cartNeedsShipping, paymentRequirements } = cart;
const canPayArgument = useRef( {
cart,
cartTotals,
cartNeedsShipping,
billingData,
shippingAddress,
selectedShippingMethods,
paymentRequirements,
} );
const { addErrorNotice } = useStoreNotices();
useEffect( () => {
canPayArgument.current = {
cart,
cartTotals,
cartNeedsShipping,
billingData,
shippingAddress,
selectedShippingMethods,
paymentRequirements,
};
}, [
cart,
cartTotals,
cartNeedsShipping,
billingData,
shippingAddress,
selectedShippingMethods,
paymentRequirements,
] );
const refreshCanMakePayments = useCallback( async () => {
let availablePaymentMethods = {};
const addAvailablePaymentMethod = (
paymentMethod:
| PaymentMethodConfigInstance
| ExpressPaymentMethodConfigInstance
) => {
availablePaymentMethods = {
...availablePaymentMethods,
[ paymentMethod.name ]: paymentMethod,
};
};
for ( let i = 0; i < paymentMethodsOrder.length; i++ ) {
const paymentMethodName = paymentMethodsOrder[ i ];
const paymentMethod = registeredPaymentMethods[ paymentMethodName ];
if ( ! paymentMethod ) {
continue;
}
// See if payment method should be available. This always evaluates to true in the editor context.
try {
const canPay = isEditor
? true
: await Promise.resolve(
paymentMethod.canMakePayment(
canPayArgument.current
)
);
if ( canPay ) {
if (
typeof canPay === 'object' &&
canPay !== null &&
canPay.error
) {
throw new Error( canPay.error.message );
}
addAvailablePaymentMethod( paymentMethod );
}
} catch ( e ) {
if ( CURRENT_USER_IS_ADMIN || isEditor ) {
const errorText = sprintf(
/* translators: %s the id of the payment method being registered (bank transfer, Stripe...) */
__(
`There was an error registering the payment method with id '%s': `,
'woo-gutenberg-products-block'
),
paymentMethod.paymentMethodId
);
addErrorNotice( `${ errorText } ${ e }`, {
context: noticeContext,
id: `wc-${ paymentMethod.paymentMethodId }-registration-error`,
} );
}
}
}
// Re-dispatch available payment methods to store.
dispatcher( availablePaymentMethods );
// Note: some payment methods use the `canMakePayment` callback to initialize / setup.
// Example: Stripe CC, Stripe Payment Request.
// That's why we track "is initialized" state here.
setIsInitialized( true );
}, [
addErrorNotice,
dispatcher,
isEditor,
noticeContext,
paymentMethodsOrder,
registeredPaymentMethods,
] );
const [ debouncedRefreshCanMakePayments ] = useDebouncedCallback(
refreshCanMakePayments,
500
);
// Determine which payment methods are available initially and whenever
// shipping methods, cart or the billing data change.
// Some payment methods (e.g. COD) can be disabled for specific shipping methods.
useEffect( () => {
debouncedRefreshCanMakePayments();
}, [
debouncedRefreshCanMakePayments,
cart,
selectedShippingMethods,
billingData,
] );
return isInitialized;
};
/**
* Custom hook for setting up payment methods (standard, non-express).
*
* @param {function(Object):undefined} dispatcher
*
* @return {boolean} True when standard payment methods have been initialized.
*/
export const usePaymentMethods = (
dispatcher: PaymentMethodsDispatcherType
): boolean => {
const standardMethods: PaymentMethods = getPaymentMethods() as PaymentMethods;
const { noticeContexts } = useEmitResponse();
// Ensure all methods are present in order.
// Some payment methods may not be present in paymentGatewaySortOrder if they
// depend on state, e.g. COD can depend on shipping method.
const displayOrder = new Set( [
...( getSetting( 'paymentGatewaySortOrder', [] ) as [ ] ),
...Object.keys( standardMethods ),
] );
return usePaymentMethodRegistration(
dispatcher,
standardMethods,
Array.from( displayOrder ),
noticeContexts.PAYMENTS
);
};
/**
* Custom hook for setting up express payment methods.
*
* @param {function(Object):undefined} dispatcher
*
* @return {boolean} True when express payment methods have been initialized.
*/
export const useExpressPaymentMethods = (
dispatcher: PaymentMethodsDispatcherType
): boolean => {
const expressMethods: ExpressPaymentMethods = getExpressPaymentMethods() as ExpressPaymentMethods;
const { noticeContexts } = useEmitResponse();
return usePaymentMethodRegistration(
dispatcher,
expressMethods,
Object.keys( expressMethods ),
noticeContexts.EXPRESS_PAYMENTS
);
};

View File

@ -0,0 +1,43 @@
/**
* External dependencies
*/
import { getSetting } from '@woocommerce/settings';
/**
* Internal dependencies
*/
import type { PaymentMethods, CustomerPaymentMethod } from './types';
/**
* Gets the payment methods saved for the current user after filtering out disabled ones.
*/
export const getCustomerPaymentMethods = (
availablePaymentMethods: PaymentMethods = {}
): Record< string, CustomerPaymentMethod > => {
if ( Object.keys( availablePaymentMethods ).length === 0 ) {
return {};
}
const customerPaymentMethods = getSetting( 'customerPaymentMethods', {} );
const paymentMethodKeys = Object.keys( customerPaymentMethods );
const enabledCustomerPaymentMethods = {} as Record<
string,
CustomerPaymentMethod
>;
paymentMethodKeys.forEach( ( type ) => {
const methods = customerPaymentMethods[ type ].filter(
( {
method: { gateway },
}: {
method: {
gateway: string;
};
} ) =>
gateway in availablePaymentMethods &&
availablePaymentMethods[ gateway ].supports?.showSavedCards
);
if ( methods.length ) {
enabledCustomerPaymentMethods[ type ] = methods;
}
} );
return enabledCustomerPaymentMethods;
};