Refactor checkout status and event emitters to support stripe intents and more complex payment methods. (https://github.com/woocommerce/woocommerce-blocks/pull/2189)

* initial mapping out of stripe payment intents

* rename checkout processing statuses to be clearer

* Add new status and refactor checkout complete behaviour.

* Make sure payment result data is included in checkout processing response

* add payment intent handling

Still testing

* make sure promise is returned

* include site url with endpoint

* modify setComplete status to optionally receive redirectUrl for changing in state at the same time as setting status

* fix typo in property retrieval

* add error handling for after checkout processing event

* add notices area for payment methods

* implement error handling for stripe intents

* hook into stripe error processing and include error in payment response

* clear notices so they don’t show in block and merge payment details

* add notice handling to payment context

* modify error processing in checkout processor

* handle errors with fallback in checkout state context

* hook into after processing for stripe cc error handling

* set checkout to idle status if before processing emitters result in error

* Add emit response type-defs and normalize expectations for observer responses

* improve doc block

* switch checkoutIsComplete check to checkoutAfterProcessing for payment complete status change

* remove unneeded event emitters and consolidate some logic

* fix idle status set logic
This commit is contained in:
Darren Ethier 2020-04-14 12:52:23 -04:00 committed by GitHub
parent 6b63f19fcb
commit 5850966894
24 changed files with 818 additions and 335 deletions

View File

@ -5,8 +5,11 @@ import { TYPES } from './constants';
const { const {
SET_PRISTINE, SET_PRISTINE,
SET_IDLE,
SET_PROCESSING, SET_PROCESSING,
SET_PROCESSING_COMPLETE, SET_BEFORE_PROCESSING,
SET_AFTER_PROCESSING,
SET_PROCESSING_RESPONSE,
SET_REDIRECT_URL, SET_REDIRECT_URL,
SET_COMPLETE, SET_COMPLETE,
SET_HAS_ERROR, SET_HAS_ERROR,
@ -23,6 +26,9 @@ export const actions = {
setPristine: () => ( { setPristine: () => ( {
type: SET_PRISTINE, type: SET_PRISTINE,
} ), } ),
setIdle: () => ( {
type: SET_IDLE,
} ),
setProcessing: () => ( { setProcessing: () => ( {
type: SET_PROCESSING, type: SET_PROCESSING,
} ), } ),
@ -30,11 +36,19 @@ export const actions = {
type: SET_REDIRECT_URL, type: SET_REDIRECT_URL,
url, url,
} ), } ),
setComplete: () => ( { setProcessingResponse: ( data ) => ( {
type: SET_COMPLETE, type: SET_PROCESSING_RESPONSE,
data,
} ), } ),
setProcessingComplete: () => ( { setComplete: ( data ) => ( {
type: SET_PROCESSING_COMPLETE, type: SET_COMPLETE,
data,
} ),
setBeforeProcessing: () => ( {
type: SET_BEFORE_PROCESSING,
} ),
setAfterProcessing: () => ( {
type: SET_AFTER_PROCESSING,
} ), } ),
setHasError: ( hasError = true ) => { setHasError: ( hasError = true ) => {
const type = hasError ? SET_HAS_ERROR : SET_NO_ERROR; const type = hasError ? SET_HAS_ERROR : SET_NO_ERROR;

View File

@ -11,7 +11,8 @@ export const STATUS = {
IDLE: 'idle', IDLE: 'idle',
PROCESSING: 'processing', PROCESSING: 'processing',
COMPLETE: 'complete', COMPLETE: 'complete',
PROCESSING_COMPLETE: 'processing_complete', BEFORE_PROCESSING: 'before_processing',
AFTER_PROCESSING: 'after_processing',
}; };
const checkoutData = getSetting( 'checkoutData', { const checkoutData = getSetting( 'checkoutData', {
@ -26,13 +27,17 @@ export const DEFAULT_STATE = {
calculatingCount: 0, calculatingCount: 0,
orderId: checkoutData.order_id, orderId: checkoutData.order_id,
customerId: checkoutData.customer_id, customerId: checkoutData.customer_id,
processingResponse: null,
}; };
export const TYPES = { export const TYPES = {
SET_IDLE: 'set_idle',
SET_PRISTINE: 'set_pristine', SET_PRISTINE: 'set_pristine',
SET_REDIRECT_URL: 'set_redirect_url', SET_REDIRECT_URL: 'set_redirect_url',
SET_COMPLETE: 'set_checkout_complete', SET_COMPLETE: 'set_checkout_complete',
SET_PROCESSING_COMPLETE: 'set_processing_complete', SET_BEFORE_PROCESSING: 'set_before_processing',
SET_AFTER_PROCESSING: 'set_after_processing',
SET_PROCESSING_RESPONSE: 'set_processing_response',
SET_PROCESSING: 'set_checkout_is_processing', SET_PROCESSING: 'set_checkout_is_processing',
SET_HAS_ERROR: 'set_checkout_has_error', SET_HAS_ERROR: 'set_checkout_has_error',
SET_NO_ERROR: 'set_checkout_no_error', SET_NO_ERROR: 'set_checkout_no_error',

View File

@ -9,14 +9,16 @@ import {
} from '../event-emit'; } from '../event-emit';
const EMIT_TYPES = { const EMIT_TYPES = {
CHECKOUT_COMPLETE_WITH_SUCCESS: 'checkout_complete', CHECKOUT_BEFORE_PROCESSING: 'checkout_before_processing',
CHECKOUT_COMPLETE_WITH_ERROR: 'checkout_complete_error', CHECKOUT_AFTER_PROCESSING_WITH_SUCCESS:
CHECKOUT_PROCESSING: 'checkout_processing', 'checkout_after_processing_with_success',
CHECKOUT_AFTER_PROCESSING_WITH_ERROR:
'checkout_after_processing_with_error',
}; };
/** /**
* Receives a reducer dispatcher and returns an object with the * Receives a reducer dispatcher and returns an object with the
* onCheckoutComplete callback registration function for the checkout emit * callback registration function for the checkout emit
* events. * events.
* *
* Calling the event registration function with the callback will register it * Calling the event registration function with the callback will register it
@ -25,19 +27,19 @@ const EMIT_TYPES = {
* *
* @param {Function} dispatcher The emitter reducer dispatcher. * @param {Function} dispatcher The emitter reducer dispatcher.
* *
* @return {Object} An object with the `onCheckoutComplete` emmitter registration * @return {Object} An object with the checkout emmitter registration
*/ */
const emitterSubscribers = ( dispatcher ) => ( { const emitterSubscribers = ( dispatcher ) => ( {
onCheckoutCompleteSuccess: emitterCallback( onCheckoutAfterProcessingWithSuccess: emitterCallback(
EMIT_TYPES.CHECKOUT_COMPLETE_WITH_SUCCESS, EMIT_TYPES.CHECKOUT_AFTER_PROCESSING_WITH_SUCCESS,
dispatcher dispatcher
), ),
onCheckoutCompleteError: emitterCallback( onCheckoutAfterProcessingWithError: emitterCallback(
EMIT_TYPES.CHECKOUT_COMPLETE_WITH_ERROR, EMIT_TYPES.CHECKOUT_AFTER_PROCESSING_WITH_ERROR,
dispatcher dispatcher
), ),
onCheckoutProcessing: emitterCallback( onCheckoutBeforeProcessing: emitterCallback(
EMIT_TYPES.CHECKOUT_PROCESSING, EMIT_TYPES.CHECKOUT_BEFORE_PROCESSING,
dispatcher dispatcher
), ),
} ); } );

View File

@ -10,18 +10,19 @@ import {
useEffect, useEffect,
} from '@wordpress/element'; } from '@wordpress/element';
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { useStoreNotices } from '@woocommerce/base-hooks'; import { useStoreNotices, useEmitResponse } from '@woocommerce/base-hooks';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { actions } from './actions'; import { actions } from './actions';
import { reducer } from './reducer'; import { reducer, prepareResponseData } from './reducer';
import { DEFAULT_STATE, STATUS } from './constants'; import { DEFAULT_STATE, STATUS } from './constants';
import { import {
EMIT_TYPES, EMIT_TYPES,
emitterSubscribers, emitterSubscribers,
emitEvent, emitEvent,
emitEventWithAbort,
reducer as emitReducer, reducer as emitReducer,
} from './event-emit'; } from './event-emit';
import { useValidationContext } from '../validation'; import { useValidationContext } from '../validation';
@ -38,18 +39,20 @@ const CheckoutContext = createContext( {
isIdle: false, isIdle: false,
isCalculating: false, isCalculating: false,
isProcessing: false, isProcessing: false,
isProcessingComplete: false, isBeforeProcessing: false,
isAfterProcessing: false,
hasError: false, hasError: false,
redirectUrl: '', redirectUrl: '',
orderId: 0, orderId: 0,
onCheckoutCompleteSuccess: ( callback ) => void callback, customerId: 0,
onCheckoutCompleteError: ( callback ) => void callback, onCheckoutAfterProcessingWithSuccess: ( callback ) => void callback,
onCheckoutProcessing: ( callback ) => void callback, onCheckoutAfterProcessingWithError: ( callback ) => void callback,
onCheckoutBeforeProcessing: ( callback ) => void callback,
dispatchActions: { dispatchActions: {
resetCheckout: () => void null, resetCheckout: () => void null,
setRedirectUrl: ( url ) => void url, setRedirectUrl: ( url ) => void url,
setHasError: ( hasError ) => void hasError, setHasError: ( hasError ) => void hasError,
setComplete: () => void null, setAfterProcessing: ( response ) => void response,
incrementCalculating: () => void null, incrementCalculating: () => void null,
decrementCalculating: () => void null, decrementCalculating: () => void null,
setOrderId: ( id ) => void id, setOrderId: ( id ) => void id,
@ -94,23 +97,30 @@ export const CheckoutStateProvider = ( {
const currentObservers = useRef( observers ); const currentObservers = useRef( observers );
const { setValidationErrors } = useValidationContext(); const { setValidationErrors } = useValidationContext();
const { addErrorNotice, removeNotices } = useStoreNotices(); const { addErrorNotice, removeNotices } = useStoreNotices();
const isCalculating = checkoutState.calculatingCount > 0; const isCalculating = checkoutState.calculatingCount > 0;
const {
isSuccessResponse,
isErrorResponse,
isFailResponse,
} = useEmitResponse();
// set observers on ref so it's always current. // set observers on ref so it's always current.
useEffect( () => { useEffect( () => {
currentObservers.current = observers; currentObservers.current = observers;
}, [ observers ] ); }, [ observers ] );
const onCheckoutCompleteSuccess = useMemo( const onCheckoutAfterProcessingWithSuccess = useMemo(
() => emitterSubscribers( subscriber ).onCheckoutCompleteSuccess, () =>
emitterSubscribers( subscriber )
.onCheckoutAfterProcessingWithSuccess,
[ subscriber ] [ subscriber ]
); );
const onCheckoutCompleteError = useMemo( const onCheckoutAfterProcessingWithError = useMemo(
() => emitterSubscribers( subscriber ).onCheckoutCompleteError, () =>
emitterSubscribers( subscriber ).onCheckoutAfterProcessingWithError,
[ subscriber ] [ subscriber ]
); );
const onCheckoutProcessing = useMemo( const onCheckoutBeforeProcessing = useMemo(
() => emitterSubscribers( subscriber ).onCheckoutProcessing, () => emitterSubscribers( subscriber ).onCheckoutBeforeProcessing,
[ subscriber ] [ subscriber ]
); );
@ -130,8 +140,25 @@ export const CheckoutStateProvider = ( {
void dispatch( actions.decrementCalculating() ), void dispatch( actions.decrementCalculating() ),
setOrderId: ( orderId ) => setOrderId: ( orderId ) =>
void dispatch( actions.setOrderId( orderId ) ), void dispatch( actions.setOrderId( orderId ) ),
setComplete: () => { setAfterProcessing: ( response ) => {
void dispatch( actions.setComplete() ); if ( response.payment_result ) {
if (
// eslint-disable-next-line camelcase
response.payment_result?.redirect_url
) {
dispatch(
actions.setRedirectUrl(
response.payment_result.redirect_url
)
);
}
dispatch(
actions.setProcessingResponse(
prepareResponseData( response.payment_result )
)
);
}
void dispatch( actions.setAfterProcessing() );
}, },
} ), } ),
[] []
@ -140,11 +167,11 @@ export const CheckoutStateProvider = ( {
// emit events. // emit events.
useEffect( () => { useEffect( () => {
const { status } = checkoutState; const { status } = checkoutState;
if ( status === STATUS.PROCESSING ) { if ( status === STATUS.BEFORE_PROCESSING ) {
removeNotices( 'error' ); removeNotices( 'error' );
emitEvent( emitEvent(
currentObservers.current, currentObservers.current,
EMIT_TYPES.CHECKOUT_PROCESSING, EMIT_TYPES.CHECKOUT_BEFORE_PROCESSING,
{} {}
).then( ( response ) => { ).then( ( response ) => {
if ( response !== true ) { if ( response !== true ) {
@ -156,34 +183,109 @@ export const CheckoutStateProvider = ( {
} }
); );
} }
dispatch( actions.setComplete() ); dispatch( actions.setIdle() );
} else { } else {
dispatch( actions.setProcessingComplete() ); dispatch( actions.setProcessing() );
} }
} ); } );
} }
}, [ checkoutState.status, setValidationErrors ] ); }, [ checkoutState.status, setValidationErrors ] );
useEffect( () => { useEffect( () => {
if ( checkoutState.status === STATUS.COMPLETE ) { if ( checkoutState.status === STATUS.AFTER_PROCESSING ) {
const data = {
redirectUrl: checkoutState.redirectUrl,
orderId: checkoutState.orderId,
customerId: checkoutState.customerId,
customerNote: checkoutState.customerNote,
processingResponse: checkoutState.processingResponse,
};
if ( checkoutState.hasError ) { if ( checkoutState.hasError ) {
emitEvent( // allow payment methods or other things to customize the error
// with a fallback if nothing customizes it.
emitEventWithAbort(
currentObservers.current, currentObservers.current,
EMIT_TYPES.CHECKOUT_COMPLETE_WITH_ERROR, EMIT_TYPES.CHECKOUT_AFTER_PROCESSING_WITH_ERROR,
{} data
); ).then( ( response ) => {
if (
isErrorResponse( response ) ||
isFailResponse( response )
) {
if ( response.message ) {
const errorOptions = response.messageContext
? { context: response.messageContext }
: undefined;
addErrorNotice( response.message, errorOptions );
}
// irrecoverable error so set complete
if (
typeof response.retry !== 'undefined' &&
response.retry !== true
) {
dispatch( actions.setComplete( response ) );
} else { } else {
emitEvent( dispatch( actions.setIdle() );
currentObservers.current, }
EMIT_TYPES.CHECKOUT_COMPLETE_WITH_SUCCESS, } else {
{} // no error handling in place by anything so let's fall
// back to default
const message =
data.processingResponse.message ||
__(
'Something went wrong. Please contact us to get assistance.',
'woo-gutenberg-products-block'
); );
addErrorNotice( message, {
id: 'checkout',
} );
dispatch( actions.setIdle() );
}
} );
} else {
emitEventWithAbort(
currentObservers.current,
EMIT_TYPES.CHECKOUT_AFTER_PROCESSING_WITH_SUCCESS,
data
).then( ( response ) => {
if ( isSuccessResponse( response ) ) {
dispatch( actions.setComplete( response ) );
}
if (
isErrorResponse( response ) ||
isFailResponse( response )
) {
if ( response.message ) {
const errorOptions = response.messageContext
? { context: response.messageContext }
: undefined;
addErrorNotice( response.message, errorOptions );
}
if ( ! response.retry ) {
dispatch( actions.setComplete( response ) );
} else {
// this will set an error which will end up
// triggering the onCheckoutAfterProcessingWithErrors emitter.
// and then setting checkout to IDLE state.
dispatch( actions.setHasError( true ) );
} }
} }
}, [ checkoutState.status, checkoutState.hasError ] ); } );
}
}
}, [
checkoutState.status,
checkoutState.hasError,
checkoutState.redirectUrl,
checkoutState.orderId,
checkoutState.customerId,
checkoutState.customerNote,
checkoutState.processingResponse,
dispatchActions,
] );
const onSubmit = () => { const onSubmit = () => {
dispatch( actions.setProcessing() ); dispatch( actions.setBeforeProcessing() );
}; };
/** /**
@ -196,13 +298,13 @@ export const CheckoutStateProvider = ( {
isIdle: checkoutState.status === STATUS.IDLE, isIdle: checkoutState.status === STATUS.IDLE,
isCalculating, isCalculating,
isProcessing: checkoutState.status === STATUS.PROCESSING, isProcessing: checkoutState.status === STATUS.PROCESSING,
isProcessingComplete: isBeforeProcessing: checkoutState.status === STATUS.BEFORE_PROCESSING,
checkoutState.status === STATUS.PROCESSING_COMPLETE, isAfterProcessing: checkoutState.status === STATUS.AFTER_PROCESSING,
hasError: checkoutState.hasError, hasError: checkoutState.hasError,
redirectUrl: checkoutState.redirectUrl, redirectUrl: checkoutState.redirectUrl,
onCheckoutCompleteSuccess, onCheckoutAfterProcessingWithSuccess,
onCheckoutCompleteError, onCheckoutAfterProcessingWithError,
onCheckoutProcessing, onCheckoutBeforeProcessing,
dispatchActions, dispatchActions,
isCart, isCart,
orderId: checkoutState.orderId, orderId: checkoutState.orderId,

View File

@ -5,8 +5,11 @@ import { TYPES, DEFAULT_STATE, STATUS } from './constants';
const { const {
SET_PRISTINE, SET_PRISTINE,
SET_IDLE,
SET_PROCESSING, SET_PROCESSING,
SET_PROCESSING_COMPLETE, SET_BEFORE_PROCESSING,
SET_AFTER_PROCESSING,
SET_PROCESSING_RESPONSE,
SET_REDIRECT_URL, SET_REDIRECT_URL,
SET_COMPLETE, SET_COMPLETE,
SET_HAS_ERROR, SET_HAS_ERROR,
@ -16,7 +19,41 @@ const {
SET_ORDER_ID, SET_ORDER_ID,
} = TYPES; } = TYPES;
const { PRISTINE, IDLE, PROCESSING, PROCESSING_COMPLETE, COMPLETE } = STATUS; const {
PRISTINE,
IDLE,
PROCESSING,
BEFORE_PROCESSING,
AFTER_PROCESSING,
COMPLETE,
} = STATUS;
/**
* Prepares the payment_result data from the server checkout endpoint response.
*
* @param {Object} data The value of `payment_result` from the checkout
* processing endpoint response.
* @param {string} data.payment_status The payment status. One of 'success', 'failure',
* 'pending', 'error'.
* @param {Array<Object>} data.payment_details An array of Objects with a 'key' property that is a
* string and value property that is a string. These are
* converted to a flat object where the key becomes the
* object property and value the property value.
*
* @return {Object} A new object with 'paymentStatus', and 'paymentDetails' as the properties.
*/
export const prepareResponseData = ( data ) => {
const responseData = {
paymentStatus: data.payment_status,
paymentDetails: {},
};
if ( Array.isArray( data.payment_details ) ) {
data.payment_details.forEach( ( { key, value } ) => {
responseData.paymentDetails[ key ] = value;
} );
}
return responseData;
};
/** /**
* Reducer for the checkout state * Reducer for the checkout state
@ -24,12 +61,24 @@ const { PRISTINE, IDLE, PROCESSING, PROCESSING_COMPLETE, COMPLETE } = STATUS;
* @param {Object} state Current state. * @param {Object} state Current state.
* @param {Object} action Incoming action object. * @param {Object} action Incoming action object.
*/ */
export const reducer = ( state = DEFAULT_STATE, { url, type, orderId } ) => { export const reducer = (
state = DEFAULT_STATE,
{ url, type, orderId, data }
) => {
let newState; let newState;
switch ( type ) { switch ( type ) {
case SET_PRISTINE: case SET_PRISTINE:
newState = DEFAULT_STATE; newState = DEFAULT_STATE;
break; break;
case SET_IDLE:
newState =
state.state !== IDLE
? {
...state,
status: IDLE,
}
: state;
break;
case SET_REDIRECT_URL: case SET_REDIRECT_URL:
newState = newState =
url !== state.url url !== state.url
@ -39,12 +88,20 @@ export const reducer = ( state = DEFAULT_STATE, { url, type, orderId } ) => {
} }
: state; : state;
break; break;
case SET_PROCESSING_RESPONSE:
newState = {
...state,
processingResponse: data,
};
break;
case SET_COMPLETE: case SET_COMPLETE:
newState = newState =
state.status !== COMPLETE state.status !== COMPLETE
? { ? {
...state, ...state,
status: COMPLETE, status: COMPLETE,
redirectUrl: data.redirectUrl || state.redirectUrl,
} }
: state; : state;
break; break;
@ -63,16 +120,25 @@ export const reducer = ( state = DEFAULT_STATE, { url, type, orderId } ) => {
? newState ? newState
: { ...newState, hasError: false }; : { ...newState, hasError: false };
break; break;
case SET_PROCESSING_COMPLETE: case SET_BEFORE_PROCESSING:
newState = newState =
state.status !== SET_PROCESSING_COMPLETE state.status !== BEFORE_PROCESSING
? { ? {
...state, ...state,
status: PROCESSING_COMPLETE, status: BEFORE_PROCESSING,
hasError: false, hasError: false,
} }
: state; : state;
break; break;
case SET_AFTER_PROCESSING:
newState =
state.status !== AFTER_PROCESSING
? {
...state,
status: AFTER_PROCESSING,
}
: state;
break;
case SET_HAS_ERROR: case SET_HAS_ERROR:
newState = state.hasError newState = state.hasError
? state ? state
@ -82,7 +148,7 @@ export const reducer = ( state = DEFAULT_STATE, { url, type, orderId } ) => {
}; };
newState = newState =
state.status === PROCESSING || state.status === PROCESSING ||
state.status === PROCESSING_COMPLETE state.status === BEFORE_PROCESSING
? { ? {
...newState, ...newState,
status: IDLE, status: IDLE,

View File

@ -47,12 +47,12 @@ const preparePaymentData = ( paymentData ) => {
const CheckoutProcessor = () => { const CheckoutProcessor = () => {
const { const {
hasError: checkoutHasError, hasError: checkoutHasError,
onCheckoutProcessing, onCheckoutBeforeProcessing,
onCheckoutCompleteSuccess,
dispatchActions, dispatchActions,
redirectUrl, redirectUrl,
isProcessing: checkoutIsProcessing, isProcessing: checkoutIsProcessing,
isProcessingComplete: checkoutIsProcessingComplete, isBeforeProcessing: checkoutIsBeforeProcessing,
isComplete: checkoutIsComplete,
} = useCheckoutContext(); } = useCheckoutContext();
const { hasValidationErrors } = useValidationContext(); const { hasValidationErrors } = useValidationContext();
const { shippingAddress, shippingErrorStatus } = useShippingDataContext(); const { shippingAddress, shippingErrorStatus } = useShippingDataContext();
@ -69,6 +69,7 @@ const CheckoutProcessor = () => {
const { addErrorNotice, removeNotice } = useStoreNotices(); const { addErrorNotice, removeNotice } = useStoreNotices();
const currentBillingData = useRef( billingData ); const currentBillingData = useRef( billingData );
const currentShippingAddress = useRef( shippingAddress ); const currentShippingAddress = useRef( shippingAddress );
const currentRedirectUrl = useRef( redirectUrl );
const [ isProcessingOrder, setIsProcessingOrder ] = useState( false ); const [ isProcessingOrder, setIsProcessingOrder ] = useState( false );
const expressPaymentMethodActive = Object.keys( const expressPaymentMethodActive = Object.keys(
expressPaymentMethods expressPaymentMethods
@ -87,7 +88,7 @@ const CheckoutProcessor = () => {
useEffect( () => { useEffect( () => {
if ( if (
checkoutWillHaveError !== checkoutHasError && checkoutWillHaveError !== checkoutHasError &&
( checkoutIsProcessing || checkoutIsProcessingComplete ) && ( checkoutIsProcessing || checkoutIsBeforeProcessing ) &&
! expressPaymentMethodActive ! expressPaymentMethodActive
) { ) {
dispatchActions.setHasError( checkoutWillHaveError ); dispatchActions.setHasError( checkoutWillHaveError );
@ -96,7 +97,7 @@ const CheckoutProcessor = () => {
checkoutWillHaveError, checkoutWillHaveError,
checkoutHasError, checkoutHasError,
checkoutIsProcessing, checkoutIsProcessing,
checkoutIsProcessingComplete, checkoutIsBeforeProcessing,
expressPaymentMethodActive, expressPaymentMethodActive,
] ); ] );
@ -104,12 +105,13 @@ const CheckoutProcessor = () => {
! checkoutHasError && ! checkoutHasError &&
! checkoutWillHaveError && ! checkoutWillHaveError &&
( currentPaymentStatus.isSuccessful || ! cartNeedsPayment ) && ( currentPaymentStatus.isSuccessful || ! cartNeedsPayment ) &&
checkoutIsProcessingComplete; checkoutIsProcessing;
useEffect( () => { useEffect( () => {
currentBillingData.current = billingData; currentBillingData.current = billingData;
currentShippingAddress.current = shippingAddress; currentShippingAddress.current = shippingAddress;
}, [ billingData, shippingAddress ] ); currentRedirectUrl.current = redirectUrl;
}, [ billingData, shippingAddress, redirectUrl ] );
useEffect( () => { useEffect( () => {
if ( errorMessage ) { if ( errorMessage ) {
@ -157,14 +159,21 @@ const CheckoutProcessor = () => {
useEffect( () => { useEffect( () => {
let unsubscribeProcessing; let unsubscribeProcessing;
if ( ! expressPaymentMethodActive ) { if ( ! expressPaymentMethodActive ) {
unsubscribeProcessing = onCheckoutProcessing( checkValidation, 0 ); unsubscribeProcessing = onCheckoutBeforeProcessing(
checkValidation,
0
);
} }
return () => { return () => {
if ( ! expressPaymentMethodActive ) { if ( ! expressPaymentMethodActive ) {
unsubscribeProcessing(); unsubscribeProcessing();
} }
}; };
}, [ onCheckoutProcessing, checkValidation, expressPaymentMethodActive ] ); }, [
onCheckoutBeforeProcessing,
checkValidation,
expressPaymentMethodActive,
] );
const processOrder = useCallback( () => { const processOrder = useCallback( () => {
setIsProcessingOrder( true ); setIsProcessingOrder( true );
@ -212,30 +221,18 @@ const CheckoutProcessor = () => {
); );
} }
dispatchActions.setHasError(); dispatchActions.setHasError();
} else {
dispatchActions.setRedirectUrl(
response.payment_result.redirect_url
);
} }
dispatchActions.setAfterProcessing( response );
dispatchActions.setComplete();
setIsProcessingOrder( false ); setIsProcessingOrder( false );
} ); } );
} ) } )
.catch( ( error ) => { .catch( ( error ) => {
const message = error.json().then( function( response ) {
error.message ||
__(
'Something went wrong. Please contact us to get assistance.',
'woo-gutenberg-products-block'
);
addErrorNotice( message, {
id: 'checkout',
} );
dispatchActions.setHasError(); dispatchActions.setHasError();
dispatchActions.setComplete(); dispatchActions.setAfterProcessing( response );
setIsProcessingOrder( false ); setIsProcessingOrder( false );
} ); } );
} );
}, [ }, [
addErrorNotice, addErrorNotice,
removeNotice, removeNotice,
@ -243,15 +240,12 @@ const CheckoutProcessor = () => {
paymentMethodData, paymentMethodData,
cartNeedsPayment, cartNeedsPayment,
] ); ] );
// setup checkout processing event observers. // redirect when checkout is complete and there is a redirect url.
useEffect( () => { useEffect( () => {
const unsubscribeRedirect = onCheckoutCompleteSuccess( () => { if ( currentRedirectUrl.current ) {
window.location.href = redirectUrl; window.location.href = currentRedirectUrl.current;
}, 999 ); }
return () => { }, [ checkoutIsComplete ] );
unsubscribeRedirect();
};
}, [ onCheckoutCompleteSuccess, redirectUrl ] );
// process order if conditions are good. // process order if conditions are good.
useEffect( () => { useEffect( () => {

View File

@ -10,9 +10,6 @@ import {
const EMIT_TYPES = { const EMIT_TYPES = {
PAYMENT_PROCESSING: 'payment_processing', PAYMENT_PROCESSING: 'payment_processing',
PAYMENT_SUCCESS: 'payment_success',
PAYMENT_FAIL: 'payment_fail',
PAYMENT_ERROR: 'payment_error',
}; };
/** /**
@ -33,9 +30,6 @@ const emitterSubscribers = ( dispatcher ) => ( {
EMIT_TYPES.PAYMENT_PROCESSING, EMIT_TYPES.PAYMENT_PROCESSING,
dispatcher dispatcher
), ),
onPaymentSuccess: emitterCallback( EMIT_TYPES.PAYMENT_SUCCESS, dispatcher ),
onPaymentFail: emitterCallback( EMIT_TYPES.PAYMENT_FAIL, dispatcher ),
onPaymentError: emitterCallback( EMIT_TYPES.PAYMENT_ERROR, dispatcher ),
} ); } );
export { export {

View File

@ -25,7 +25,6 @@ import { useShippingDataContext } from '../shipping';
import { import {
EMIT_TYPES, EMIT_TYPES,
emitterSubscribers, emitterSubscribers,
emitEvent,
emitEventWithAbort, emitEventWithAbort,
reducer as emitReducer, reducer as emitReducer,
} from './event-emit'; } from './event-emit';
@ -45,7 +44,7 @@ import {
useMemo, useMemo,
} from '@wordpress/element'; } from '@wordpress/element';
import { getSetting } from '@woocommerce/settings'; import { getSetting } from '@woocommerce/settings';
import { useStoreNotices } from '@woocommerce/base-hooks'; import { useStoreNotices, useEmitResponse } from '@woocommerce/base-hooks';
/** /**
* @typedef {import('@woocommerce/type-defs/contexts').PaymentMethodDataContext} PaymentMethodDataContext * @typedef {import('@woocommerce/type-defs/contexts').PaymentMethodDataContext} PaymentMethodDataContext
@ -76,23 +75,6 @@ export const usePaymentMethodDataContext = () => {
return useContext( PaymentMethodDataContext ); return useContext( PaymentMethodDataContext );
}; };
const isSuccessResponse = ( response ) => {
return (
( typeof response === 'object' &&
typeof response.fail === 'undefined' &&
typeof response.errorMessage === 'undefined' ) ||
response === true
);
};
const isFailResponse = ( response ) => {
return response && typeof response.fail === 'object';
};
const isErrorResponse = ( response ) => {
return response && typeof response.errorMessage !== 'undefined';
};
/** /**
* PaymentMethodDataProvider is automatically included in the * PaymentMethodDataProvider is automatically included in the
* CheckoutDataProvider. * CheckoutDataProvider.
@ -112,11 +94,16 @@ export const PaymentMethodDataProvider = ( {
} ) => { } ) => {
const { setBillingData } = useBillingDataContext(); const { setBillingData } = useBillingDataContext();
const { const {
isComplete: checkoutIsComplete, isProcessing: checkoutIsProcessing,
isProcessingComplete: checkoutIsProcessingComplete, isIdle: checkoutIsIdle,
isCalculating: checkoutIsCalculating, isCalculating: checkoutIsCalculating,
hasError: checkoutHasError, hasError: checkoutHasError,
} = useCheckoutContext(); } = useCheckoutContext();
const {
isSuccessResponse,
isErrorResponse,
isFailResponse,
} = useEmitResponse();
const [ activePaymentMethod, setActive ] = useState( const [ activePaymentMethod, setActive ] = useState(
initialActivePaymentMethod initialActivePaymentMethod
); );
@ -164,18 +151,6 @@ export const PaymentMethodDataProvider = ( {
() => emitterSubscribers( subscriber ).onPaymentProcessing, () => emitterSubscribers( subscriber ).onPaymentProcessing,
[ subscriber ] [ subscriber ]
); );
const onPaymentSuccess = useMemo(
() => emitterSubscribers( subscriber ).onPaymentSuccess,
[ subscriber ]
);
const onPaymentFail = useMemo(
() => emitterSubscribers( subscriber ).onPaymentFail,
[ subscriber ]
);
const onPaymentError = useMemo(
() => emitterSubscribers( subscriber ).onPaymentError,
[ subscriber ]
);
const currentStatus = useMemo( const currentStatus = useMemo(
() => ( { () => ( {
@ -196,7 +171,7 @@ export const PaymentMethodDataProvider = ( {
// no errors, and payment status is started. // no errors, and payment status is started.
useEffect( () => { useEffect( () => {
if ( if (
checkoutIsProcessingComplete && checkoutIsProcessing &&
! checkoutHasError && ! checkoutHasError &&
! checkoutIsCalculating && ! checkoutIsCalculating &&
! currentStatus.isFinished ! currentStatus.isFinished
@ -204,12 +179,17 @@ export const PaymentMethodDataProvider = ( {
setPaymentStatus().processing(); setPaymentStatus().processing();
} }
}, [ }, [
checkoutIsProcessingComplete, checkoutIsProcessing,
checkoutHasError, checkoutHasError,
checkoutIsCalculating, checkoutIsCalculating,
currentStatus.isFinished, currentStatus.isFinished,
] ); ] );
// when checkout is returned to idle, set payment status to pristine.
useEffect( () => {
dispatch( statusOnly( PRISTINE ) );
}, [ checkoutIsIdle ] );
// set initial active payment method if it's undefined. // set initial active payment method if it's undefined.
useEffect( () => { useEffect( () => {
const paymentMethodKeys = Object.keys( paymentData.paymentMethods ); const paymentMethodKeys = Object.keys( paymentData.paymentMethods );
@ -289,9 +269,8 @@ export const PaymentMethodDataProvider = ( {
// emit events. // emit events.
useEffect( () => { useEffect( () => {
// Note: the nature of this event emitter is that it will bail on a // Note: the nature of this event emitter is that it will bail on any
// successful payment (that is an observer hooked in that returns an // observer that returns a response that !== true. However, this still
// object in the shape of a successful payment). However, this still
// allows for other observers that return true for continuing through // allows for other observers that return true for continuing through
// to the next observer (or bailing if there's a problem). // to the next observer (or bailing if there's a problem).
if ( currentStatus.isProcessing ) { if ( currentStatus.isProcessing ) {
@ -302,48 +281,29 @@ export const PaymentMethodDataProvider = ( {
).then( ( response ) => { ).then( ( response ) => {
if ( isSuccessResponse( response ) ) { if ( isSuccessResponse( response ) ) {
setPaymentStatus().success( setPaymentStatus().success(
response.paymentMethodData, response?.meta?.paymentMethodData,
response.billingData, response?.meta?.billingData,
response.shippingData response?.meta?.shippingData
); );
} else if ( isFailResponse( response ) ) { } else if ( isFailResponse( response ) ) {
addErrorNotice( response.message, {
context: 'wc/payment-area',
} );
setPaymentStatus().failed( setPaymentStatus().failed(
response.fail.errorMessage, response.message,
response.fail.paymentMethodData, response?.meta?.paymentMethodData,
response.fail.billingData response?.meta?.billingData
); );
} else if ( isErrorResponse( response ) ) { } else if ( isErrorResponse( response ) ) {
setPaymentStatus().error( response.errorMessage ); addErrorNotice( response.message, {
setValidationErrors( response.validationErrors ); context: 'wc/payment-area',
} );
setPaymentStatus().error( response.message );
setValidationErrors( response?.validationErrors );
} }
} ); } );
} }
if ( }, [ currentStatus, setValidationErrors, setPaymentStatus ] );
currentStatus.isSuccessful &&
checkoutIsComplete &&
! checkoutHasError
) {
emitEvent(
currentObservers.current,
EMIT_TYPES.PAYMENT_SUCCESS,
{}
).then( () => {
setPaymentStatus().completed();
} );
}
if ( currentStatus.hasFailed ) {
emitEvent( currentObservers.current, EMIT_TYPES.PAYMENT_FAIL, {} );
}
if ( currentStatus.hasError ) {
emitEvent( currentObservers.current, EMIT_TYPES.PAYMENT_ERROR, {} );
}
}, [
currentStatus,
setValidationErrors,
setPaymentStatus,
checkoutIsComplete,
checkoutHasError,
] );
/** /**
* @type {PaymentMethodDataContext} * @type {PaymentMethodDataContext}
@ -357,9 +317,6 @@ export const PaymentMethodDataProvider = ( {
activePaymentMethod, activePaymentMethod,
setActivePaymentMethod, setActivePaymentMethod,
onPaymentProcessing, onPaymentProcessing,
onPaymentSuccess,
onPaymentFail,
onPaymentError,
customerPaymentMethods, customerPaymentMethods,
paymentMethods: paymentData.paymentMethods, paymentMethods: paymentData.paymentMethods,
expressPaymentMethods: paymentData.expressPaymentMethods, expressPaymentMethods: paymentData.expressPaymentMethods,

View File

@ -1,2 +1,3 @@
export * from './use-checkout-redirect-url'; export * from './use-checkout-redirect-url';
export * from './use-checkout-submit'; export * from './use-checkout-submit';
export * from './use-emit-response';

View File

@ -0,0 +1,54 @@
/**
* @typedef {import('@woocommerce/type-defs/hooks').EmitResponseTypes} EmitResponseTypes
* @typedef {import('@woocommerce/type-defs/hooks').NoticeContexts} NoticeContexts
* @typedef {import('@woocommerce/type-defs/hooks').EmitResponseApi} EmitResponseApi
*/
const isResponseOf = ( response, type ) => {
return !! response.type && response.type === type;
};
/**
* @type {EmitResponseTypes}
*/
const responseTypes = {
SUCCESS: 'success',
FAIL: 'failure',
ERROR: 'error',
};
/**
* @type {NoticeContexts}
*/
const noticeContexts = {
PAYMENTS: 'wc/payment-area',
EXPRESS_PAYMENTS: 'wc/express-payment-area',
};
const isSuccessResponse = ( response ) => {
return isResponseOf( response, responseTypes.SUCCESS );
};
const isErrorResponse = ( response ) => {
return isResponseOf( response, responseTypes.ERROR );
};
const isFailResponse = ( response ) => {
return isResponseOf( response, responseTypes.FAIL );
};
/**
* A custom hook exposing response utilities for emitters.
*
* @return {EmitResponseApi} Various interfaces for validating and implementing
* emitter response properties.
*/
export const useEmitResponse = () => {
return {
responseTypes,
noticeContexts,
isSuccessResponse,
isErrorResponse,
isFailResponse,
};
};

View File

@ -13,6 +13,7 @@ import { useEffect, useRef } from '@wordpress/element';
import { DISPLAY_CART_PRICES_INCLUDING_TAX } from '@woocommerce/block-settings'; import { DISPLAY_CART_PRICES_INCLUDING_TAX } from '@woocommerce/block-settings';
import { ValidationInputError } from '@woocommerce/base-components/validation'; import { ValidationInputError } from '@woocommerce/base-components/validation';
import CheckboxControl from '@woocommerce/base-components/checkbox-control'; import CheckboxControl from '@woocommerce/base-components/checkbox-control';
import { useEmitResponse } from '@woocommerce/base-hooks';
/** /**
* Internal dependencies * Internal dependencies
@ -87,9 +88,9 @@ export const usePaymentMethodInterface = () => {
isComplete, isComplete,
isIdle, isIdle,
isProcessing, isProcessing,
onCheckoutCompleteSuccess, onCheckoutAfterProcessingWithSuccess,
onCheckoutCompleteError, onCheckoutAfterProcessingWithError,
onCheckoutProcessing, onCheckoutBeforeProcessing,
onSubmit, onSubmit,
customerId, customerId,
} = useCheckoutContext(); } = useCheckoutContext();
@ -97,9 +98,6 @@ export const usePaymentMethodInterface = () => {
currentStatus, currentStatus,
activePaymentMethod, activePaymentMethod,
onPaymentProcessing, onPaymentProcessing,
onPaymentSuccess,
onPaymentFail,
onPaymentError,
setExpressPaymentError, setExpressPaymentError,
} = usePaymentMethodDataContext(); } = usePaymentMethodDataContext();
const { const {
@ -122,6 +120,7 @@ export const usePaymentMethodInterface = () => {
const { order, isLoading: orderLoading } = useStoreOrder(); const { order, isLoading: orderLoading } = useStoreOrder();
const { cartTotals } = useStoreCart(); const { cartTotals } = useStoreCart();
const { appliedCoupons } = useStoreCartCoupons(); const { appliedCoupons } = useStoreCartCoupons();
const { noticeContexts, responseTypes } = useEmitResponse();
const currentCartTotals = useRef( const currentCartTotals = useRef(
prepareTotalItems( cartTotals, needsShipping ) prepareTotalItems( cartTotals, needsShipping )
); );
@ -176,22 +175,23 @@ export const usePaymentMethodInterface = () => {
customerId, customerId,
}, },
eventRegistration: { eventRegistration: {
onCheckoutCompleteSuccess, onCheckoutAfterProcessingWithSuccess,
onCheckoutCompleteError, onCheckoutAfterProcessingWithError,
onCheckoutProcessing, onCheckoutBeforeProcessing,
onShippingRateSuccess, onShippingRateSuccess,
onShippingRateFail, onShippingRateFail,
onShippingRateSelectSuccess, onShippingRateSelectSuccess,
onShippingRateSelectFail, onShippingRateSelectFail,
onPaymentProcessing, onPaymentProcessing,
onPaymentSuccess,
onPaymentFail,
onPaymentError,
}, },
components: { components: {
ValidationInputError, ValidationInputError,
CheckboxControl, CheckboxControl,
}, },
emitResponse: {
noticeContexts,
responseTypes,
},
onSubmit, onSubmit,
activePaymentMethod, activePaymentMethod,
setExpressPaymentError, setExpressPaymentError,

View File

@ -28,6 +28,7 @@ import {
useShippingDataContext, useShippingDataContext,
useBillingDataContext, useBillingDataContext,
useValidationContext, useValidationContext,
StoreNoticesProvider,
} from '@woocommerce/base-context'; } from '@woocommerce/base-context';
import { useStoreCart, usePaymentMethods } from '@woocommerce/base-hooks'; import { useStoreCart, usePaymentMethods } from '@woocommerce/base-hooks';
import { import {
@ -355,7 +356,9 @@ const Checkout = ( { attributes, scrollToTop } ) => {
: '' : ''
} }
> >
<StoreNoticesProvider context="wc/payment-area">
<PaymentMethods /> <PaymentMethods />
</StoreNoticesProvider>
</FormStep> </FormStep>
) } ) }
</CheckoutForm> </CheckoutForm>

View File

@ -24,7 +24,12 @@ import { __ } from '@wordpress/i18n';
* *
* @param {RegisteredPaymentMethodProps} props Incoming props * @param {RegisteredPaymentMethodProps} props Incoming props
*/ */
const CreditCardComponent = ( { billing, eventRegistration, components } ) => { const CreditCardComponent = ( {
billing,
eventRegistration,
emitResponse,
components,
} ) => {
const { ValidationInputError, CheckboxControl } = components; const { ValidationInputError, CheckboxControl } = components;
const { customerId } = billing; const { customerId } = billing;
const [ sourceId, setSourceId ] = useState( 0 ); const [ sourceId, setSourceId ] = useState( 0 );
@ -39,6 +44,7 @@ const CreditCardComponent = ( { billing, eventRegistration, components } ) => {
sourceId, sourceId,
setSourceId, setSourceId,
shouldSavePayment, shouldSavePayment,
emitResponse,
stripe, stripe,
elements elements
); );

View File

@ -12,11 +12,12 @@ import {
getStripeServerData, getStripeServerData,
getErrorMessageForTypeAndCode, getErrorMessageForTypeAndCode,
} from '../stripe-utils'; } from '../stripe-utils';
import { usePaymentIntents } from './use-payment-intents';
/** /**
* @typedef {import('@woocommerce/type-defs/registered-payment-method-props').EventRegistrationProps} EventRegistrationProps * @typedef {import('@woocommerce/type-defs/registered-payment-method-props').EventRegistrationProps} EventRegistrationProps
* @typedef {import('@woocommerce/type-defs/registered-payment-method-props').PaymentStatusProps} PaymentStatusProps
* @typedef {import('@woocommerce/type-defs/registered-payment-method-props').BillingDataProps} BillingDataProps * @typedef {import('@woocommerce/type-defs/registered-payment-method-props').BillingDataProps} BillingDataProps
* @typedef {import('@woocommerce/type-defs/registered-payment-method-props').EmitResponseProps} EmitResponseProps
* @typedef {import('../stripe-utils/type-defs').Stripe} Stripe * @typedef {import('../stripe-utils/type-defs').Stripe} Stripe
* @typedef {import('react').Dispatch<number>} SourceIdDispatch * @typedef {import('react').Dispatch<number>} SourceIdDispatch
*/ */
@ -24,16 +25,13 @@ import {
/** /**
* A custom hook for the Stripe processing and event observer logic. * A custom hook for the Stripe processing and event observer logic.
* *
* @param {EventRegistrationProps} eventRegistration Event registration * @param {EventRegistrationProps} eventRegistration Event registration functions.
* functions. * @param {EmitResponseProps} emitResponse Various helpers for usage with observer
* @param {BillingDataProps} billing Various billing data * response objects.
* items. * @param {BillingDataProps} billing Various billing data items.
* @param {number} sourceId Current set stripe * @param {number} sourceId Current set stripe source id.
* source id. * @param {SourceIdDispatch} setSourceId Setter for stripe source id.
* @param {SourceIdDispatch} setSourceId Setter for stripe * @param {boolean} shouldSavePayment Whether to save the payment or not.
* source id.
* @param {boolean} shouldSavePayment Whether to save the
* payment or not.
* @param {Stripe} stripe The stripe.js object. * @param {Stripe} stripe The stripe.js object.
* @param {Object} elements Stripe Elements object. * @param {Object} elements Stripe Elements object.
* *
@ -45,6 +43,7 @@ export const useCheckoutSubscriptions = (
sourceId, sourceId,
setSourceId, setSourceId,
shouldSavePayment, shouldSavePayment,
emitResponse,
stripe, stripe,
elements elements
) => { ) => {
@ -52,6 +51,11 @@ export const useCheckoutSubscriptions = (
const onStripeError = useRef( ( event ) => { const onStripeError = useRef( ( event ) => {
return event; return event;
} ); } );
usePaymentIntents(
stripe,
eventRegistration.onCheckoutAfterProcessingWithSuccess,
emitResponse
);
// hook into and register callbacks for events. // hook into and register callbacks for events.
useEffect( () => { useEffect( () => {
onStripeError.current = ( event ) => { onStripeError.current = ( event ) => {
@ -80,12 +84,15 @@ export const useCheckoutSubscriptions = (
// if there's an error return that. // if there's an error return that.
if ( error ) { if ( error ) {
return { return {
errorMessage: error, type: emitResponse.responseTypes.ERROR,
message: error,
}; };
} }
// use token if it's set. // use token if it's set.
if ( sourceId !== 0 ) { if ( sourceId !== 0 ) {
return { return {
type: emitResponse.responseTypes.SUCCESS,
meta: {
paymentMethodData: { paymentMethodData: {
paymentMethod: PAYMENT_METHOD_NAME, paymentMethod: PAYMENT_METHOD_NAME,
paymentRequestType: 'cc', paymentRequestType: 'cc',
@ -93,6 +100,7 @@ export const useCheckoutSubscriptions = (
shouldSavePayment, shouldSavePayment,
}, },
billingData, billingData,
},
}; };
} }
const ownerInfo = { const ownerInfo = {
@ -118,11 +126,14 @@ export const useCheckoutSubscriptions = (
const response = await createSource( ownerInfo ); const response = await createSource( ownerInfo );
if ( response.error ) { if ( response.error ) {
return { return {
errorMessage: onStripeError.current( response ), type: emitResponse.responseTypes.ERROR,
message: onStripeError.current( response ),
}; };
} }
setSourceId( response.source.id ); setSourceId( response.source.id );
return { return {
type: emitResponse.responseTypes.SUCCESS,
meta: {
paymentMethodData: { paymentMethodData: {
stripe_source: response.source.id, stripe_source: response.source.id,
paymentMethod: PAYMENT_METHOD_NAME, paymentMethod: PAYMENT_METHOD_NAME,
@ -130,23 +141,39 @@ export const useCheckoutSubscriptions = (
shouldSavePayment, shouldSavePayment,
}, },
billingData, billingData,
},
}; };
} catch ( e ) { } catch ( e ) {
return { return {
errorMessage: e, type: emitResponse.responseTypes.ERROR,
message: e,
}; };
} }
}; };
const onError = ( { processingResponse } ) => {
if ( processingResponse?.paymentDetails?.errorMessage ) {
return {
type: emitResponse.responseTypes.ERROR,
message: processingResponse.paymentDetails.errorMessage,
messageContext: emitResponse.noticeContexts.PAYMENTS,
};
}
// leave for checkout to handle.
return null;
};
const unsubscribeProcessing = eventRegistration.onPaymentProcessing( const unsubscribeProcessing = eventRegistration.onPaymentProcessing(
onSubmit onSubmit
); );
const unsubscribeAfterProcessing = eventRegistration.onCheckoutAfterProcessingWithError(
onError
);
return () => { return () => {
unsubscribeProcessing(); unsubscribeProcessing();
unsubscribeAfterProcessing();
}; };
}, [ }, [
eventRegistration.onCheckoutProcessing, eventRegistration.onPaymentProcessing,
eventRegistration.onCheckoutCompleteSuccess, eventRegistration.onCheckoutAfterProcessingWithError,
eventRegistration.onCheckoutCompleteError,
stripe, stripe,
sourceId, sourceId,
billing.billingData, billing.billingData,

View File

@ -0,0 +1,71 @@
/**
* External dependencies
*/
import { useEffect } from '@wordpress/element';
/**
* @typedef {import('@woocommerce/type-defs/registered-payment-method-props').EmitResponseProps} EmitResponseProps
* @typedef {import('../stripe-utils/type-defs').Stripe} Stripe
*/
/**
* Opens the modal for PaymentIntent authorizations.
*
* @param {Stripe} stripe The stripe object.
* @param {Object} paymentDetails The payment details from the server after checkout
* processing.
* @param {EmitResponseProps} emitResponse Various helpers for usage with observer response
* objects.
*/
const openIntentModal = ( stripe, paymentDetails, emitResponse ) => {
const checkoutResponse = { type: emitResponse.responseTypes.SUCCESS };
if (
! paymentDetails.setup_intent &&
! paymentDetails.payment_intent_secret
) {
return checkoutResponse;
}
const isSetupIntent = !! paymentDetails.setupIntent;
const verificationUrl = paymentDetails.verification_endpoint;
const intentSecret = isSetupIntent
? paymentDetails.setup_intent
: paymentDetails.payment_intent_secret;
return stripe[ isSetupIntent ? 'confirmCardSetup' : 'confirmCardPayment' ](
intentSecret
)
.then( function( response ) {
if ( response.error ) {
throw response.error;
}
const intent =
response[ isSetupIntent ? 'setupIntent' : 'paymentIntent' ];
if (
intent.status !== 'requires_capture' &&
intent.status !== 'succeeded'
) {
return checkoutResponse;
}
checkoutResponse.redirectUrl = verificationUrl;
return checkoutResponse;
} )
.catch( function( error ) {
checkoutResponse.type = emitResponse.responseTypes.ERROR;
checkoutResponse.message = error.message;
checkoutResponse.retry = true;
checkoutResponse.messageContext =
emitResponse.noticeContexts.PAYMENTS;
// Reports back to the server.
window.fetch( verificationUrl + '&is_ajax' );
return checkoutResponse;
} );
};
export const usePaymentIntents = ( stripe, subscriber, emitResponse ) => {
useEffect( () => {
const unsubscribe = subscriber( ( { processingResponse } ) => {
const paymentDetails = processingResponse.paymentDetails || {};
return openIntentModal( stripe, paymentDetails, emitResponse );
} );
return () => unsubscribe();
}, [ subscriber, stripe ] );
};

View File

@ -55,6 +55,7 @@ const PaymentRequestExpressComponent = ( {
eventRegistration, eventRegistration,
onSubmit, onSubmit,
setExpressPaymentError, setExpressPaymentError,
emitResponse,
onClick, onClick,
onClose, onClose,
} ) => { } ) => {
@ -210,29 +211,48 @@ const PaymentRequestExpressComponent = ( {
const handlers = eventHandlers.current; const handlers = eventHandlers.current;
if ( handlers.sourceEvent && isProcessing ) { if ( handlers.sourceEvent && isProcessing ) {
const response = { const response = {
type: emitResponse.responseTypes.SUCCESS,
meta: {
billingData: getBillingData( handlers.sourceEvent ), billingData: getBillingData( handlers.sourceEvent ),
paymentMethodData: getPaymentMethodData( paymentMethodData: getPaymentMethodData(
handlers.sourceEvent, handlers.sourceEvent,
paymentRequestType paymentRequestType
), ),
shippingData: getShippingData( handlers.sourceEvent ), shippingData: getShippingData( handlers.sourceEvent ),
},
}; };
return response; return response;
} }
return true; return { type: emitResponse.responseTypes.SUCCESS };
}; };
const onCheckoutComplete = ( forSuccess = true ) => () => { const onCheckoutComplete = ( checkoutResponse ) => {
const handlers = eventHandlers.current; const handlers = eventHandlers.current;
let response = { type: emitResponse.responseTypes.SUCCESS };
if ( handlers.sourceEvent && isProcessing ) { if ( handlers.sourceEvent && isProcessing ) {
if ( forSuccess ) { const { paymentStatus, paymentDetails } = checkoutResponse;
if ( paymentStatus === emitResponse.responseTypes.SUCCESS ) {
completePayment( handlers.sourceEvent ); completePayment( handlers.sourceEvent );
} else { }
abortPayment( handlers.sourceEvent ); if (
paymentStatus === emitResponse.responseTypes.ERROR ||
paymentStatus === emitResponse.responseTypes.FAIL
) {
const paymentResponse = abortPayment(
handlers.sourceEvent,
paymentDetails?.errorMessage
);
response = {
type: emitResponse.responseTypes.ERROR,
message: paymentResponse.message,
messageContext:
emitResponse.noticeContexts.EXPRESS_PAYMENTS,
retry: true,
};
} }
handlers.sourceEvent = null; handlers.sourceEvent = null;
} }
return true; return response;
}; };
// when canMakePayment is true, then we set listeners on payment request for // when canMakePayment is true, then we set listeners on payment request for
@ -301,11 +321,11 @@ const PaymentRequestExpressComponent = ( {
const unsubscribePaymentProcessing = subscriber.onPaymentProcessing( const unsubscribePaymentProcessing = subscriber.onPaymentProcessing(
onPaymentProcessing onPaymentProcessing
); );
const unsubscribeCheckoutCompleteSuccess = subscriber.onCheckoutCompleteSuccess( const unsubscribeCheckoutCompleteSuccess = subscriber.onCheckoutAfterProcessingWithSuccess(
onCheckoutComplete() onCheckoutComplete
); );
const unsubscribeCheckoutCompleteFail = subscriber.onCheckoutCompleteError( const unsubscribeCheckoutCompleteFail = subscriber.onCheckoutAfterProcessingWithError(
onCheckoutComplete( false ) onCheckoutComplete
); );
return () => { return () => {
unsubscribeCheckoutCompleteFail(); unsubscribeCheckoutCompleteFail();
@ -326,8 +346,8 @@ const PaymentRequestExpressComponent = ( {
eventRegistration.onShippingRateSelectSuccess, eventRegistration.onShippingRateSelectSuccess,
eventRegistration.onShippingRateSelectFail, eventRegistration.onShippingRateSelectFail,
eventRegistration.onPaymentProcessing, eventRegistration.onPaymentProcessing,
eventRegistration.onCheckoutCompleteSuccess, eventRegistration.onCheckoutAfterProcessingWithSuccess,
eventRegistration.onCheckoutCompleteError, eventRegistration.onCheckoutAfterProcessingWithError,
] ); ] );
// locale is not a valid value for the paymentRequestButton style. // locale is not a valid value for the paymentRequestButton style.

View File

@ -7,8 +7,9 @@
* redirectUrl to the given value. * redirectUrl to the given value.
* @property {function(boolean=)} setHasError Dispatches an action that sets the * @property {function(boolean=)} setHasError Dispatches an action that sets the
* checkout status to having an error. * checkout status to having an error.
* @property {function()} setComplete Dispatches an action that sets the * @property {function(Object)} setAfterProcessing Dispatches an action that sets the
* checkout status to complete. * checkout status to after processing and
* also sets the response data accordingly.
* @property {function()} incrementCalculating Dispatches an action that increments * @property {function()} incrementCalculating Dispatches an action that increments
* the calculating state for checkout by one. * the calculating state for checkout by one.
* @property {function()} decrementCalculating Dispatches an action that decrements * @property {function()} decrementCalculating Dispatches an action that decrements
@ -21,17 +22,19 @@
* @typedef {Object} CheckoutStatusConstants * @typedef {Object} CheckoutStatusConstants
* *
* @property {string} PRISTINE Checkout is in it's initialized state. * @property {string} PRISTINE Checkout is in it's initialized state.
* @property {string} IDLE When checkout state has changed but * @property {string} IDLE When checkout state has changed but there is no
* there is no activity happening. * activity happening.
* @property {string} PROCESSING This is the state when the checkout * @property {string} BEFORE_PROCESSING This is the state before checkout processing
* button has been pressed and the * begins after the checkout button has been
* checkout data has been sent to the * pressed/submitted.
* server for processing. * @property {string} PROCESSING After BEFORE_PROCESSING status emitters have
* @property {string} PROCESSING_COMPLETE This is the state when the checkout * finished successfully. Payment processing is
* processing has been completed. * started on this checkout status.
* @property {string} COMPLETE This is the status when the server has * @property {string} AFTER_PROCESSING After server side checkout processing is completed
* completed processing the data * this status is set.
* successfully. * @property {string} COMPLETE After the AFTER_PROCESSING event emitters have
* completed. This status triggers the checkout
* redirect.
*/ */
export {}; export {};

View File

@ -199,19 +199,6 @@
* observers for the * observers for the
* payment processing * payment processing
* event. * event.
* @property {function(function())} onPaymentSuccess Event registration
* callback for registering
* observers for the
* successful payment
* event.
* @property {function(function())} onPaymentFail Event registration
* callback for registering
* observers for the
* failed payment event.
* @property {function(function())} onPaymentError Event registration
* callback for registering
* observers for the
* payment error event.
* @property {function(string)} setExpressPaymentError A function used by * @property {function(string)} setExpressPaymentError A function used by
* express payment methods * express payment methods
* to indicate an error * to indicate an error
@ -230,14 +217,23 @@
* the checkout submit button. * the checkout submit button.
* @property {boolean} isComplete True when checkout is complete * @property {boolean} isComplete True when checkout is complete
* and ready for redirect. * and ready for redirect.
* @property {boolean} isProcessingComplete True when checkout processing * @property {boolean} isBeforeProcessing True during any observers
* is complete. * executing logic before
* checkout processing (eg.
* validation).
* @property {boolean} isAfterProcessing True when checkout status is
* AFTER_PROCESSING.
* @property {boolean} isIdle True when the checkout state * @property {boolean} isIdle True when the checkout state
* has changed and checkout has * has changed and checkout has
* no activity. * no activity.
* @property {boolean} isProcessing True when checkout has been * @property {boolean} isProcessing True when checkout has been
* submitted and is being * submitted and is being
* processed by the server. * processed. Note, payment
* related processing happens
* during this state. When
* payemnt status is success,
* processing happens on the
* server.
* @property {boolean} isCalculating True when something in the * @property {boolean} isCalculating True when something in the
* checkout is resulting in * checkout is resulting in
* totals being calculated. * totals being calculated.
@ -250,15 +246,15 @@
* @property {string} redirectUrl This is the url that checkout * @property {string} redirectUrl This is the url that checkout
* will redirect to when it's * will redirect to when it's
* ready. * ready.
* @property {function(function(),number=)} onCheckoutCompleteSuccess Used to register a callback * @property {function(function(),number=)} onCheckoutAfterProcessingWithSuccess Used to register a
* that will fire when the * callback that will fire after
* checkout is marked complete * checkout has been processed
* successfully. * and there are no errors.
* @property {function(function(),number=)} onCheckoutCompleteError Used to register a callback * @property {function(function(),number=)} onCheckoutAfterProcessingWithError Used to register a
* that will fire when the * callback that will fire when
* checkout is marked complete * the checkout has been
* and has an error. * processed and has an error.
* @property {function(function(),number=)} onCheckoutProcessing Used to register a callback * @property {function(function(),number=)} onCheckoutBeforeProcessing Used to register a callback
* that will fire when the * that will fire when the
* checkout has been submitted * checkout has been submitted
* before being sent off to the * before being sent off to the

View File

@ -73,4 +73,82 @@
* the cart. * the cart.
*/ */
/**
* @typedef {Object} EmitResponseTypes
*
* @property {string} SUCCESS To indicate a success response.
* @property {string} FAIL To indicate a failed response.
* @property {string} ERROR To indicate an error response.
*/
/**
* @typedef {Object} NoticeContexts
*
* @property {string} PAYMENTS Notices for the payments step.
* @property {string} EXPRESS_PAYMENTS Notices for the express payments step.
*/
/**
* @typedef {NoticeContexts['PAYMENTS']|NoticeContexts['EXPRESS_PAYMENTS']} NoticeContextsEnum
*/
/**
* @typedef {Object} EmitSuccessResponse
*
* @property {EmitResponseTypes['SUCCESS']} type Should have the value of
* EmitResponseTypes.SUCCESS.
* @property {string} [redirectUrl] If the redirect url should be changed set
* this. Note, this is ignored for some
* emitters.
* @property {Object} [meta] Additional data returned for the success
* response. This varies between context
* emitters.
*/
/**
* @typedef {Object} EmitFailResponse
*
* @property {EmitResponseTypes['FAIL']} type Should have the value of
* EmitResponseTypes.FAIL
* @property {string} message A message to trigger a notice for.
* @property {NoticeContextsEnum} [messageContext] What context to display any message in.
* @property {Object} [meta] Additional data returned for the fail
* response. This varies between context
* emitters.
*/
/**
* @typedef {Object} EmitErrorResponse
*
* @property {EmitResponseTypes['ERROR']} type Should have the value of
* EmitResponseTypes.ERROR
* @property {string} message A message to trigger a notice for.
* @property {boolean} retry If false, then it means an
* irrecoverable error so don't allow for
* shopper to retry checkout (which may
* mean either a different payment or
* fixing validation errors).
* @property {Object} [validationErrors] If provided, will be set as validation
* errors in the validation context.
* @property {NoticeContextsEnum} [messageContext] What context to display any message in.
* @property {Object} [meta] Additional data returned for the fail
* response. This varies between context
* emitters.
*/
/**
* @typedef {Object} EmitResponseApi
*
* @property {EmitResponseTypes} responseTypes An object of various response types that can
* be used in returned response objects.
* @property {NoticeContexts} noticeContexts An object of various notice contexts that can
* be used for targeting where a notice appears.
* @property {function(Object):boolean} isSuccessResponse Returns whether the given response is of a
* success response type.
* @property {function(Object):boolean} isErrorResponse Returns whether the given response is of an
* error response type.
* @property {function(Object):boolean} isFailResponse Returns whether the given response is of a
* fail response type.
*/
export {}; export {};

View File

@ -8,6 +8,8 @@
* @typedef {import('@woocommerce/type-defs/contexts').ShippingErrorStatus} ShippingErrorStatus * @typedef {import('@woocommerce/type-defs/contexts').ShippingErrorStatus} ShippingErrorStatus
* @typedef {import('@woocommerce/type-defs/contexts').ShippingErrorTypes} ShippingErrorTypes * @typedef {import('@woocommerce/type-defs/contexts').ShippingErrorTypes} ShippingErrorTypes
* @typedef {import('@woocommerce/type-defs/settings').WooCommerceSiteCurrency} SiteCurrency * @typedef {import('@woocommerce/type-defs/settings').WooCommerceSiteCurrency} SiteCurrency
* @typedef {import('@woocommerce/type-defs/hooks').EmitResponseTypes} EmitResponseTypes
* @typedef {import('@woocommerce/type-defs/hooks').NoticeContexts} NoticeContexts
*/ */
/** /**
@ -134,13 +136,13 @@
/** /**
* @typedef EventRegistrationProps * @typedef EventRegistrationProps
* *
* @property {function(function())} onCheckoutCompleteSuccess Used to subscribe callbacks firing * @property {function(function())} onCheckoutAfterProcessingWithSuccess Used to subscribe callbacks
* when checkout has completed * firing when checkout has completed
* processing successfully. * processing successfully.
* @property {function(function())} onCheckoutCompleteError Used to subscribe callbacks firing * @property {function(function())} onCheckoutAfterProcessingWithError Used to subscribe callbacks
* when checkout has completed * firing when checkout has completed
* processing with an error. * processing with an error.
* @property {function(function())} onCheckoutProcessing Used to subscribe callbacks that * @property {function(function())} onCheckoutBeforeProcessing Used to subscribe callbacks that
* will fire when checkout begins * will fire when checkout begins
* processing (as a part of the * processing (as a part of the
* processing process). * processing process).
@ -160,15 +162,6 @@
* @property {function(function())} onPaymentProcessing Event registration callback for * @property {function(function())} onPaymentProcessing Event registration callback for
* registering observers for the * registering observers for the
* payment processing event. * payment processing event.
* @property {function(function())} onPaymentSuccess Event registration callback for
* registering observers for the
* successful payment event.
* @property {function(function())} onPaymentFail Event registration callback for
* registering observers for the
* failed payment event.
* @property {function(function())} onPaymentError Event registration callback for
* registering observers for the
* payment error event.
*/ */
/** /**
@ -180,6 +173,16 @@
* saved payment method functionality * saved payment method functionality
*/ */
/**
* @typedef EmitResponseProps
*
* @property {EmitResponseTypes} responseTypes Response types that can be returned from emitter
* observers.
* @property {NoticeContexts} noticeContexts Available contexts that can be returned as the value
* for the messageContext property on the object
* returned from an emitter observer.
*/
/** /**
* Registered payment method props * Registered payment method props
* *
@ -194,6 +197,8 @@
* @property {EventRegistrationProps} eventRegistration Various event registration helpers * @property {EventRegistrationProps} eventRegistration Various event registration helpers
* for subscribing callbacks for * for subscribing callbacks for
* events. * events.
* @property {EmitResponseProps} emitResponse Utilities for usage in event
* observer response objects.
* @property {Function} [onSubmit] Used to trigger checkout * @property {Function} [onSubmit] Used to trigger checkout
* processing. * processing.
* @property {string} [activePaymentMethod] Indicates what the active payment * @property {string} [activePaymentMethod] Indicates what the active payment

View File

@ -231,13 +231,12 @@ class Library {
$_POST = $context->payment_data; $_POST = $context->payment_data;
// Call the process payment method of the chosen gatway. // Call the process payment method of the chosen gatway.
$available_gateways = WC()->payment_gateways->get_available_payment_gateways(); $payment_method_object = $context->get_payment_method_instance();
if ( ! isset( $available_gateways[ $context->payment_method ] ) ) { if ( ! $payment_method_object instanceof \WC_Payment_Gateway ) {
return; return;
} }
$payment_method_object = $available_gateways[ $context->payment_method ];
$payment_method_object->validate_fields(); $payment_method_object->validate_fields();
if ( 0 !== wc_notice_count( 'error' ) ) { if ( 0 !== wc_notice_count( 'error' ) ) {
@ -250,9 +249,14 @@ class Library {
// Restore $_POST data. // Restore $_POST data.
$_POST = $post_data; $_POST = $post_data;
// Clear notices so they don't show up in the block.
wc_clear_notices();
// Handle result. // Handle result.
$result->set_status( isset( $gateway_result['result'] ) && 'success' === $gateway_result['result'] ? 'success' : 'failure' ); $result->set_status( isset( $gateway_result['result'] ) && 'success' === $gateway_result['result'] ? 'success' : 'failure' );
$result->set_payment_details( [] );
// set payment_details from result.
$result->set_payment_details( array_merge( $result->payment_details, $gateway_result ) );
$result->set_redirect_url( $gateway_result['redirect'] ); $result->set_redirect_url( $gateway_result['redirect'] );
} }
} }

View File

@ -52,6 +52,7 @@ final class Stripe extends AbstractPaymentMethodType {
public function __construct( Api $asset_api ) { public function __construct( Api $asset_api ) {
$this->asset_api = $asset_api; $this->asset_api = $asset_api;
add_action( 'woocommerce_rest_checkout_process_payment_with_context', [ $this, 'add_payment_request_order_meta' ], 8, 2 ); add_action( 'woocommerce_rest_checkout_process_payment_with_context', [ $this, 'add_payment_request_order_meta' ], 8, 2 );
add_action( 'woocommerce_rest_checkout_process_payment_with_context', [ $this, 'add_stripe_intents' ], 9999, 2 );
} }
/** /**
@ -227,7 +228,7 @@ final class Stripe extends AbstractPaymentMethodType {
* @param PaymentContext $context Holds context for the payment. * @param PaymentContext $context Holds context for the payment.
* @param PaymentResult $result Result object for the payment. * @param PaymentResult $result Result object for the payment.
*/ */
public function add_payment_request_order_meta( PaymentContext $context, PaymentResult $result ) { public function add_payment_request_order_meta( PaymentContext $context, PaymentResult &$result ) {
$data = $context->payment_data; $data = $context->payment_data;
if ( ! empty( $data['payment_request_type'] ) && 'stripe' === $context->payment_method ) { if ( ! empty( $data['payment_request_type'] ) && 'stripe' === $context->payment_method ) {
// phpcs:ignore WordPress.Security.NonceVerification // phpcs:ignore WordPress.Security.NonceVerification
@ -236,5 +237,50 @@ final class Stripe extends AbstractPaymentMethodType {
WC_Stripe_Payment_Request::add_order_meta( $context->order->id, $context->payment_data ); WC_Stripe_Payment_Request::add_order_meta( $context->order->id, $context->payment_data );
$_POST = $post_data; $_POST = $post_data;
} }
// hook into stripe error processing so that we can capture the error to
// payment details (which is added to notices and thus not helpful for
// this context).
if ( 'stripe' === $context->payment_method ) {
add_action(
'wc_gateway_stripe_process_payment_error',
function( $error ) use ( &$result ) {
$payment_details = $result->payment_details;
$payment_details['errorMessage'] = $error->getLocalizedMessage();
$result->set_payment_details( $payment_details );
}
);
}
}
/**
* Handles any potential stripe intents on the order that need handled.
*
* This is configured to execute after legacy payment processing has
* happened on the woocommerce_rest_checkout_process_payment_with_context
* action hook.
*
* @param PaymentContext $context Holds context for the payment.
* @param PaymentResult $result Result object for the payment.
*/
public function add_stripe_intents( PaymentContext $context, PaymentResult &$result ) {
if ( 'stripe' === $context->payment_method
&& (
! empty( $result->payment_details['payment_intent_secret'] )
|| ! empty( $result->payment_details['setup_intent_secret'] )
)
) {
$payment_details = $result->payment_details;
$payment_details['verification_endpoint'] = add_query_arg(
[
'order' => $context->order->get_id(),
'nonce' => wp_create_nonce( 'wc_stripe_confirm_pi' ),
'redirect_to' => rawurlencode( $result->redirect_url ),
],
home_url() . \WC_Ajax::get_endpoint( 'wc_stripe_verify_intent' )
);
$result->set_payment_details( $payment_details );
$result->set_status( 'success' );
}
} }
} }

View File

@ -55,6 +55,19 @@ class PaymentContext {
$this->payment_method = (string) $payment_method; $this->payment_method = (string) $payment_method;
} }
/**
* Retrieve the payment method instance for the current set payment method.
*
* @return {\WC_Payment_Gateway|null} An instance of the payment gateway if it exists.
*/
public function get_payment_method_instance() {
$available_gateways = WC()->payment_gateways->get_available_payment_gateways();
if ( ! isset( $available_gateways[ $this->payment_method ] ) ) {
return;
}
return $available_gateways[ $this->payment_method ];
}
/** /**
* Set the order context. * Set the order context.
* *

View File

@ -166,9 +166,31 @@ class CheckoutSchema extends AbstractSchema {
'payment_method' => $order->get_payment_method(), 'payment_method' => $order->get_payment_method(),
'payment_result' => [ 'payment_result' => [
'payment_status' => $payment_result->status, 'payment_status' => $payment_result->status,
'payment_details' => $payment_result->payment_details, 'payment_details' => $this->prepare_payment_details_for_response( $payment_result->payment_details ),
'redirect_url' => $payment_result->redirect_url, 'redirect_url' => $payment_result->redirect_url,
], ],
]; ];
} }
/**
* This prepares the payment details for the response so it's following the
* schema where it's an array of objects.
*
* @param array $payment_details An array of payment details from the processed payment.
*
* @return array An array of objects where each object has the key and value
* as distinct properties.
*/
protected function prepare_payment_details_for_response( array $payment_details ) {
return array_map(
function( $key, $value ) {
return (object) [
'key' => $key,
'value' => $value,
];
},
array_keys( $payment_details ),
$payment_details
);
}
} }