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 {
SET_PRISTINE,
SET_IDLE,
SET_PROCESSING,
SET_PROCESSING_COMPLETE,
SET_BEFORE_PROCESSING,
SET_AFTER_PROCESSING,
SET_PROCESSING_RESPONSE,
SET_REDIRECT_URL,
SET_COMPLETE,
SET_HAS_ERROR,
@ -23,6 +26,9 @@ export const actions = {
setPristine: () => ( {
type: SET_PRISTINE,
} ),
setIdle: () => ( {
type: SET_IDLE,
} ),
setProcessing: () => ( {
type: SET_PROCESSING,
} ),
@ -30,11 +36,19 @@ export const actions = {
type: SET_REDIRECT_URL,
url,
} ),
setComplete: () => ( {
type: SET_COMPLETE,
setProcessingResponse: ( data ) => ( {
type: SET_PROCESSING_RESPONSE,
data,
} ),
setProcessingComplete: () => ( {
type: SET_PROCESSING_COMPLETE,
setComplete: ( data ) => ( {
type: SET_COMPLETE,
data,
} ),
setBeforeProcessing: () => ( {
type: SET_BEFORE_PROCESSING,
} ),
setAfterProcessing: () => ( {
type: SET_AFTER_PROCESSING,
} ),
setHasError: ( hasError = true ) => {
const type = hasError ? SET_HAS_ERROR : SET_NO_ERROR;

View File

@ -11,7 +11,8 @@ export const STATUS = {
IDLE: 'idle',
PROCESSING: 'processing',
COMPLETE: 'complete',
PROCESSING_COMPLETE: 'processing_complete',
BEFORE_PROCESSING: 'before_processing',
AFTER_PROCESSING: 'after_processing',
};
const checkoutData = getSetting( 'checkoutData', {
@ -26,13 +27,17 @@ export const DEFAULT_STATE = {
calculatingCount: 0,
orderId: checkoutData.order_id,
customerId: checkoutData.customer_id,
processingResponse: null,
};
export const TYPES = {
SET_IDLE: 'set_idle',
SET_PRISTINE: 'set_pristine',
SET_REDIRECT_URL: 'set_redirect_url',
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_HAS_ERROR: 'set_checkout_has_error',
SET_NO_ERROR: 'set_checkout_no_error',

View File

@ -9,14 +9,16 @@ import {
} from '../event-emit';
const EMIT_TYPES = {
CHECKOUT_COMPLETE_WITH_SUCCESS: 'checkout_complete',
CHECKOUT_COMPLETE_WITH_ERROR: 'checkout_complete_error',
CHECKOUT_PROCESSING: 'checkout_processing',
CHECKOUT_BEFORE_PROCESSING: 'checkout_before_processing',
CHECKOUT_AFTER_PROCESSING_WITH_SUCCESS:
'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
* onCheckoutComplete callback registration function for the checkout emit
* callback registration function for the checkout emit
* events.
*
* 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.
*
* @return {Object} An object with the `onCheckoutComplete` emmitter registration
* @return {Object} An object with the checkout emmitter registration
*/
const emitterSubscribers = ( dispatcher ) => ( {
onCheckoutCompleteSuccess: emitterCallback(
EMIT_TYPES.CHECKOUT_COMPLETE_WITH_SUCCESS,
onCheckoutAfterProcessingWithSuccess: emitterCallback(
EMIT_TYPES.CHECKOUT_AFTER_PROCESSING_WITH_SUCCESS,
dispatcher
),
onCheckoutCompleteError: emitterCallback(
EMIT_TYPES.CHECKOUT_COMPLETE_WITH_ERROR,
onCheckoutAfterProcessingWithError: emitterCallback(
EMIT_TYPES.CHECKOUT_AFTER_PROCESSING_WITH_ERROR,
dispatcher
),
onCheckoutProcessing: emitterCallback(
EMIT_TYPES.CHECKOUT_PROCESSING,
onCheckoutBeforeProcessing: emitterCallback(
EMIT_TYPES.CHECKOUT_BEFORE_PROCESSING,
dispatcher
),
} );

View File

@ -10,18 +10,19 @@ import {
useEffect,
} from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { useStoreNotices } from '@woocommerce/base-hooks';
import { useStoreNotices, useEmitResponse } from '@woocommerce/base-hooks';
/**
* Internal dependencies
*/
import { actions } from './actions';
import { reducer } from './reducer';
import { reducer, prepareResponseData } from './reducer';
import { DEFAULT_STATE, STATUS } from './constants';
import {
EMIT_TYPES,
emitterSubscribers,
emitEvent,
emitEventWithAbort,
reducer as emitReducer,
} from './event-emit';
import { useValidationContext } from '../validation';
@ -38,18 +39,20 @@ const CheckoutContext = createContext( {
isIdle: false,
isCalculating: false,
isProcessing: false,
isProcessingComplete: false,
isBeforeProcessing: false,
isAfterProcessing: false,
hasError: false,
redirectUrl: '',
orderId: 0,
onCheckoutCompleteSuccess: ( callback ) => void callback,
onCheckoutCompleteError: ( callback ) => void callback,
onCheckoutProcessing: ( callback ) => void callback,
customerId: 0,
onCheckoutAfterProcessingWithSuccess: ( callback ) => void callback,
onCheckoutAfterProcessingWithError: ( callback ) => void callback,
onCheckoutBeforeProcessing: ( callback ) => void callback,
dispatchActions: {
resetCheckout: () => void null,
setRedirectUrl: ( url ) => void url,
setHasError: ( hasError ) => void hasError,
setComplete: () => void null,
setAfterProcessing: ( response ) => void response,
incrementCalculating: () => void null,
decrementCalculating: () => void null,
setOrderId: ( id ) => void id,
@ -94,23 +97,30 @@ export const CheckoutStateProvider = ( {
const currentObservers = useRef( observers );
const { setValidationErrors } = useValidationContext();
const { addErrorNotice, removeNotices } = useStoreNotices();
const isCalculating = checkoutState.calculatingCount > 0;
const {
isSuccessResponse,
isErrorResponse,
isFailResponse,
} = useEmitResponse();
// set observers on ref so it's always current.
useEffect( () => {
currentObservers.current = observers;
}, [ observers ] );
const onCheckoutCompleteSuccess = useMemo(
() => emitterSubscribers( subscriber ).onCheckoutCompleteSuccess,
const onCheckoutAfterProcessingWithSuccess = useMemo(
() =>
emitterSubscribers( subscriber )
.onCheckoutAfterProcessingWithSuccess,
[ subscriber ]
);
const onCheckoutCompleteError = useMemo(
() => emitterSubscribers( subscriber ).onCheckoutCompleteError,
const onCheckoutAfterProcessingWithError = useMemo(
() =>
emitterSubscribers( subscriber ).onCheckoutAfterProcessingWithError,
[ subscriber ]
);
const onCheckoutProcessing = useMemo(
() => emitterSubscribers( subscriber ).onCheckoutProcessing,
const onCheckoutBeforeProcessing = useMemo(
() => emitterSubscribers( subscriber ).onCheckoutBeforeProcessing,
[ subscriber ]
);
@ -130,8 +140,25 @@ export const CheckoutStateProvider = ( {
void dispatch( actions.decrementCalculating() ),
setOrderId: ( orderId ) =>
void dispatch( actions.setOrderId( orderId ) ),
setComplete: () => {
void dispatch( actions.setComplete() );
setAfterProcessing: ( response ) => {
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.
useEffect( () => {
const { status } = checkoutState;
if ( status === STATUS.PROCESSING ) {
if ( status === STATUS.BEFORE_PROCESSING ) {
removeNotices( 'error' );
emitEvent(
currentObservers.current,
EMIT_TYPES.CHECKOUT_PROCESSING,
EMIT_TYPES.CHECKOUT_BEFORE_PROCESSING,
{}
).then( ( response ) => {
if ( response !== true ) {
@ -156,34 +183,109 @@ export const CheckoutStateProvider = ( {
}
);
}
dispatch( actions.setComplete() );
dispatch( actions.setIdle() );
} else {
dispatch( actions.setProcessingComplete() );
dispatch( actions.setProcessing() );
}
} );
}
}, [ checkoutState.status, setValidationErrors ] );
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 ) {
emitEvent(
// allow payment methods or other things to customize the error
// with a fallback if nothing customizes it.
emitEventWithAbort(
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 {
dispatch( actions.setIdle() );
}
} 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 {
emitEvent(
emitEventWithAbort(
currentObservers.current,
EMIT_TYPES.CHECKOUT_COMPLETE_WITH_SUCCESS,
{}
);
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 = () => {
dispatch( actions.setProcessing() );
dispatch( actions.setBeforeProcessing() );
};
/**
@ -196,13 +298,13 @@ export const CheckoutStateProvider = ( {
isIdle: checkoutState.status === STATUS.IDLE,
isCalculating,
isProcessing: checkoutState.status === STATUS.PROCESSING,
isProcessingComplete:
checkoutState.status === STATUS.PROCESSING_COMPLETE,
isBeforeProcessing: checkoutState.status === STATUS.BEFORE_PROCESSING,
isAfterProcessing: checkoutState.status === STATUS.AFTER_PROCESSING,
hasError: checkoutState.hasError,
redirectUrl: checkoutState.redirectUrl,
onCheckoutCompleteSuccess,
onCheckoutCompleteError,
onCheckoutProcessing,
onCheckoutAfterProcessingWithSuccess,
onCheckoutAfterProcessingWithError,
onCheckoutBeforeProcessing,
dispatchActions,
isCart,
orderId: checkoutState.orderId,

View File

@ -5,8 +5,11 @@ import { TYPES, DEFAULT_STATE, STATUS } from './constants';
const {
SET_PRISTINE,
SET_IDLE,
SET_PROCESSING,
SET_PROCESSING_COMPLETE,
SET_BEFORE_PROCESSING,
SET_AFTER_PROCESSING,
SET_PROCESSING_RESPONSE,
SET_REDIRECT_URL,
SET_COMPLETE,
SET_HAS_ERROR,
@ -16,7 +19,41 @@ const {
SET_ORDER_ID,
} = 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
@ -24,12 +61,24 @@ const { PRISTINE, IDLE, PROCESSING, PROCESSING_COMPLETE, COMPLETE } = STATUS;
* @param {Object} state Current state.
* @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;
switch ( type ) {
case SET_PRISTINE:
newState = DEFAULT_STATE;
break;
case SET_IDLE:
newState =
state.state !== IDLE
? {
...state,
status: IDLE,
}
: state;
break;
case SET_REDIRECT_URL:
newState =
url !== state.url
@ -39,12 +88,20 @@ export const reducer = ( state = DEFAULT_STATE, { url, type, orderId } ) => {
}
: state;
break;
case SET_PROCESSING_RESPONSE:
newState = {
...state,
processingResponse: data,
};
break;
case SET_COMPLETE:
newState =
state.status !== COMPLETE
? {
...state,
status: COMPLETE,
redirectUrl: data.redirectUrl || state.redirectUrl,
}
: state;
break;
@ -63,16 +120,25 @@ export const reducer = ( state = DEFAULT_STATE, { url, type, orderId } ) => {
? newState
: { ...newState, hasError: false };
break;
case SET_PROCESSING_COMPLETE:
case SET_BEFORE_PROCESSING:
newState =
state.status !== SET_PROCESSING_COMPLETE
state.status !== BEFORE_PROCESSING
? {
...state,
status: PROCESSING_COMPLETE,
status: BEFORE_PROCESSING,
hasError: false,
}
: state;
break;
case SET_AFTER_PROCESSING:
newState =
state.status !== AFTER_PROCESSING
? {
...state,
status: AFTER_PROCESSING,
}
: state;
break;
case SET_HAS_ERROR:
newState = state.hasError
? state
@ -82,7 +148,7 @@ export const reducer = ( state = DEFAULT_STATE, { url, type, orderId } ) => {
};
newState =
state.status === PROCESSING ||
state.status === PROCESSING_COMPLETE
state.status === BEFORE_PROCESSING
? {
...newState,
status: IDLE,

View File

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

View File

@ -10,9 +10,6 @@ import {
const EMIT_TYPES = {
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,
dispatcher
),
onPaymentSuccess: emitterCallback( EMIT_TYPES.PAYMENT_SUCCESS, dispatcher ),
onPaymentFail: emitterCallback( EMIT_TYPES.PAYMENT_FAIL, dispatcher ),
onPaymentError: emitterCallback( EMIT_TYPES.PAYMENT_ERROR, dispatcher ),
} );
export {

View File

@ -25,7 +25,6 @@ import { useShippingDataContext } from '../shipping';
import {
EMIT_TYPES,
emitterSubscribers,
emitEvent,
emitEventWithAbort,
reducer as emitReducer,
} from './event-emit';
@ -45,7 +44,7 @@ import {
useMemo,
} from '@wordpress/element';
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
@ -76,23 +75,6 @@ export const usePaymentMethodDataContext = () => {
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
* CheckoutDataProvider.
@ -112,11 +94,16 @@ export const PaymentMethodDataProvider = ( {
} ) => {
const { setBillingData } = useBillingDataContext();
const {
isComplete: checkoutIsComplete,
isProcessingComplete: checkoutIsProcessingComplete,
isProcessing: checkoutIsProcessing,
isIdle: checkoutIsIdle,
isCalculating: checkoutIsCalculating,
hasError: checkoutHasError,
} = useCheckoutContext();
const {
isSuccessResponse,
isErrorResponse,
isFailResponse,
} = useEmitResponse();
const [ activePaymentMethod, setActive ] = useState(
initialActivePaymentMethod
);
@ -164,18 +151,6 @@ export const PaymentMethodDataProvider = ( {
() => emitterSubscribers( subscriber ).onPaymentProcessing,
[ 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(
() => ( {
@ -196,7 +171,7 @@ export const PaymentMethodDataProvider = ( {
// no errors, and payment status is started.
useEffect( () => {
if (
checkoutIsProcessingComplete &&
checkoutIsProcessing &&
! checkoutHasError &&
! checkoutIsCalculating &&
! currentStatus.isFinished
@ -204,12 +179,17 @@ export const PaymentMethodDataProvider = ( {
setPaymentStatus().processing();
}
}, [
checkoutIsProcessingComplete,
checkoutIsProcessing,
checkoutHasError,
checkoutIsCalculating,
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.
useEffect( () => {
const paymentMethodKeys = Object.keys( paymentData.paymentMethods );
@ -289,9 +269,8 @@ export const PaymentMethodDataProvider = ( {
// emit events.
useEffect( () => {
// Note: the nature of this event emitter is that it will bail on a
// successful payment (that is an observer hooked in that returns an
// object in the shape of a successful payment). However, this still
// 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 ) {
@ -302,48 +281,29 @@ export const PaymentMethodDataProvider = ( {
).then( ( response ) => {
if ( isSuccessResponse( response ) ) {
setPaymentStatus().success(
response.paymentMethodData,
response.billingData,
response.shippingData
response?.meta?.paymentMethodData,
response?.meta?.billingData,
response?.meta?.shippingData
);
} else if ( isFailResponse( response ) ) {
addErrorNotice( response.message, {
context: 'wc/payment-area',
} );
setPaymentStatus().failed(
response.fail.errorMessage,
response.fail.paymentMethodData,
response.fail.billingData
response.message,
response?.meta?.paymentMethodData,
response?.meta?.billingData
);
} else if ( isErrorResponse( response ) ) {
setPaymentStatus().error( response.errorMessage );
setValidationErrors( response.validationErrors );
addErrorNotice( response.message, {
context: 'wc/payment-area',
} );
setPaymentStatus().error( response.message );
setValidationErrors( response?.validationErrors );
}
} );
}
if (
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,
] );
}, [ currentStatus, setValidationErrors, setPaymentStatus ] );
/**
* @type {PaymentMethodDataContext}
@ -357,9 +317,6 @@ export const PaymentMethodDataProvider = ( {
activePaymentMethod,
setActivePaymentMethod,
onPaymentProcessing,
onPaymentSuccess,
onPaymentFail,
onPaymentError,
customerPaymentMethods,
paymentMethods: paymentData.paymentMethods,
expressPaymentMethods: paymentData.expressPaymentMethods,

View File

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

View File

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

View File

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

View File

@ -12,11 +12,12 @@ import {
getStripeServerData,
getErrorMessageForTypeAndCode,
} 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').PaymentStatusProps} PaymentStatusProps
* @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('react').Dispatch<number>} SourceIdDispatch
*/
@ -24,16 +25,13 @@ import {
/**
* A custom hook for the Stripe processing and event observer logic.
*
* @param {EventRegistrationProps} eventRegistration Event registration
* functions.
* @param {BillingDataProps} billing Various billing data
* items.
* @param {number} sourceId Current set stripe
* source id.
* @param {SourceIdDispatch} setSourceId Setter for stripe
* source id.
* @param {boolean} shouldSavePayment Whether to save the
* payment or not.
* @param {EventRegistrationProps} eventRegistration Event registration functions.
* @param {EmitResponseProps} emitResponse Various helpers for usage with observer
* response objects.
* @param {BillingDataProps} billing Various billing data items.
* @param {number} sourceId Current set stripe source id.
* @param {SourceIdDispatch} setSourceId Setter for stripe source id.
* @param {boolean} shouldSavePayment Whether to save the payment or not.
* @param {Stripe} stripe The stripe.js object.
* @param {Object} elements Stripe Elements object.
*
@ -45,6 +43,7 @@ export const useCheckoutSubscriptions = (
sourceId,
setSourceId,
shouldSavePayment,
emitResponse,
stripe,
elements
) => {
@ -52,6 +51,11 @@ export const useCheckoutSubscriptions = (
const onStripeError = useRef( ( event ) => {
return event;
} );
usePaymentIntents(
stripe,
eventRegistration.onCheckoutAfterProcessingWithSuccess,
emitResponse
);
// hook into and register callbacks for events.
useEffect( () => {
onStripeError.current = ( event ) => {
@ -80,19 +84,23 @@ export const useCheckoutSubscriptions = (
// if there's an error return that.
if ( error ) {
return {
errorMessage: error,
type: emitResponse.responseTypes.ERROR,
message: error,
};
}
// use token if it's set.
if ( sourceId !== 0 ) {
return {
paymentMethodData: {
paymentMethod: PAYMENT_METHOD_NAME,
paymentRequestType: 'cc',
stripe_source: sourceId,
shouldSavePayment,
type: emitResponse.responseTypes.SUCCESS,
meta: {
paymentMethodData: {
paymentMethod: PAYMENT_METHOD_NAME,
paymentRequestType: 'cc',
stripe_source: sourceId,
shouldSavePayment,
},
billingData,
},
billingData,
};
}
const ownerInfo = {
@ -118,35 +126,54 @@ export const useCheckoutSubscriptions = (
const response = await createSource( ownerInfo );
if ( response.error ) {
return {
errorMessage: onStripeError.current( response ),
type: emitResponse.responseTypes.ERROR,
message: onStripeError.current( response ),
};
}
setSourceId( response.source.id );
return {
paymentMethodData: {
stripe_source: response.source.id,
paymentMethod: PAYMENT_METHOD_NAME,
paymentRequestType: 'cc',
shouldSavePayment,
type: emitResponse.responseTypes.SUCCESS,
meta: {
paymentMethodData: {
stripe_source: response.source.id,
paymentMethod: PAYMENT_METHOD_NAME,
paymentRequestType: 'cc',
shouldSavePayment,
},
billingData,
},
billingData,
};
} catch ( e ) {
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(
onSubmit
);
const unsubscribeAfterProcessing = eventRegistration.onCheckoutAfterProcessingWithError(
onError
);
return () => {
unsubscribeProcessing();
unsubscribeAfterProcessing();
};
}, [
eventRegistration.onCheckoutProcessing,
eventRegistration.onCheckoutCompleteSuccess,
eventRegistration.onCheckoutCompleteError,
eventRegistration.onPaymentProcessing,
eventRegistration.onCheckoutAfterProcessingWithError,
stripe,
sourceId,
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,
onSubmit,
setExpressPaymentError,
emitResponse,
onClick,
onClose,
} ) => {
@ -210,29 +211,48 @@ const PaymentRequestExpressComponent = ( {
const handlers = eventHandlers.current;
if ( handlers.sourceEvent && isProcessing ) {
const response = {
billingData: getBillingData( handlers.sourceEvent ),
paymentMethodData: getPaymentMethodData(
handlers.sourceEvent,
paymentRequestType
),
shippingData: getShippingData( handlers.sourceEvent ),
type: emitResponse.responseTypes.SUCCESS,
meta: {
billingData: getBillingData( handlers.sourceEvent ),
paymentMethodData: getPaymentMethodData(
handlers.sourceEvent,
paymentRequestType
),
shippingData: getShippingData( handlers.sourceEvent ),
},
};
return response;
}
return true;
return { type: emitResponse.responseTypes.SUCCESS };
};
const onCheckoutComplete = ( forSuccess = true ) => () => {
const onCheckoutComplete = ( checkoutResponse ) => {
const handlers = eventHandlers.current;
let response = { type: emitResponse.responseTypes.SUCCESS };
if ( handlers.sourceEvent && isProcessing ) {
if ( forSuccess ) {
const { paymentStatus, paymentDetails } = checkoutResponse;
if ( paymentStatus === emitResponse.responseTypes.SUCCESS ) {
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;
}
return true;
return response;
};
// when canMakePayment is true, then we set listeners on payment request for
@ -301,11 +321,11 @@ const PaymentRequestExpressComponent = ( {
const unsubscribePaymentProcessing = subscriber.onPaymentProcessing(
onPaymentProcessing
);
const unsubscribeCheckoutCompleteSuccess = subscriber.onCheckoutCompleteSuccess(
onCheckoutComplete()
const unsubscribeCheckoutCompleteSuccess = subscriber.onCheckoutAfterProcessingWithSuccess(
onCheckoutComplete
);
const unsubscribeCheckoutCompleteFail = subscriber.onCheckoutCompleteError(
onCheckoutComplete( false )
const unsubscribeCheckoutCompleteFail = subscriber.onCheckoutAfterProcessingWithError(
onCheckoutComplete
);
return () => {
unsubscribeCheckoutCompleteFail();
@ -326,8 +346,8 @@ const PaymentRequestExpressComponent = ( {
eventRegistration.onShippingRateSelectSuccess,
eventRegistration.onShippingRateSelectFail,
eventRegistration.onPaymentProcessing,
eventRegistration.onCheckoutCompleteSuccess,
eventRegistration.onCheckoutCompleteError,
eventRegistration.onCheckoutAfterProcessingWithSuccess,
eventRegistration.onCheckoutAfterProcessingWithError,
] );
// locale is not a valid value for the paymentRequestButton style.

View File

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

View File

@ -199,19 +199,6 @@
* observers for the
* 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.
* @property {function(string)} setExpressPaymentError A function used by
* express payment methods
* to indicate an error
@ -224,56 +211,65 @@
/**
* @typedef {Object} CheckoutDataContext
*
* @property {string} submitLabel The label to use for the
* submit checkout button.
* @property {function()} onSubmit The callback to register with
* the checkout submit button.
* @property {boolean} isComplete True when checkout is complete
* and ready for redirect.
* @property {boolean} isProcessingComplete True when checkout processing
* is complete.
* @property {boolean} isIdle True when the checkout state
* has changed and checkout has
* no activity.
* @property {boolean} isProcessing True when checkout has been
* submitted and is being
* processed by the server.
* @property {boolean} isCalculating True when something in the
* checkout is resulting in
* totals being calculated.
* @property {boolean} hasError True when the checkout is in
* an error state. Whatever
* caused the error
* (validation/payment method)
* will likely have triggered a
* notice.
* @property {string} redirectUrl This is the url that checkout
* will redirect to when it's
* ready.
* @property {function(function(),number=)} onCheckoutCompleteSuccess Used to register a callback
* that will fire when the
* checkout is marked complete
* successfully.
* @property {function(function(),number=)} onCheckoutCompleteError Used to register a callback
* that will fire when the
* checkout is marked complete
* and has an error.
* @property {function(function(),number=)} onCheckoutProcessing Used to register a callback
* that will fire when the
* checkout has been submitted
* before being sent off to the
* server.
* @property {CheckoutDispatchActions} dispatchActions Various actions that can be
* dispatched for the checkout
* context data.
* @property {number} orderId This is the ID for the draft
* order if one exists.
* @property {boolean} hasOrder True when the checkout has a
* draft order from the API.
* @property {boolean} isCart When true, means the provider
* is providing data for the cart.
* @property {number} customerId This is the ID of the customer
* the draft order belongs to.
* @property {string} submitLabel The label to use for the
* submit checkout button.
* @property {function()} onSubmit The callback to register with
* the checkout submit button.
* @property {boolean} isComplete True when checkout is complete
* and ready for redirect.
* @property {boolean} isBeforeProcessing True during any observers
* 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
* has changed and checkout has
* no activity.
* @property {boolean} isProcessing True when checkout has been
* submitted and is being
* 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
* checkout is resulting in
* totals being calculated.
* @property {boolean} hasError True when the checkout is in
* an error state. Whatever
* caused the error
* (validation/payment method)
* will likely have triggered a
* notice.
* @property {string} redirectUrl This is the url that checkout
* will redirect to when it's
* ready.
* @property {function(function(),number=)} onCheckoutAfterProcessingWithSuccess Used to register a
* callback that will fire after
* checkout has been processed
* and there are no errors.
* @property {function(function(),number=)} onCheckoutAfterProcessingWithError Used to register a
* callback that will fire when
* the checkout has been
* processed and has an error.
* @property {function(function(),number=)} onCheckoutBeforeProcessing Used to register a callback
* that will fire when the
* checkout has been submitted
* before being sent off to the
* server.
* @property {CheckoutDispatchActions} dispatchActions Various actions that can be
* dispatched for the checkout
* context data.
* @property {number} orderId This is the ID for the draft
* order if one exists.
* @property {boolean} hasOrder True when the checkout has a
* draft order from the API.
* @property {boolean} isCart When true, means the provider
* is providing data for the cart.
* @property {number} customerId This is the ID of the customer
* the draft order belongs to.
*/
/**

View File

@ -73,4 +73,82 @@
* 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 {};

View File

@ -8,6 +8,8 @@
* @typedef {import('@woocommerce/type-defs/contexts').ShippingErrorStatus} ShippingErrorStatus
* @typedef {import('@woocommerce/type-defs/contexts').ShippingErrorTypes} ShippingErrorTypes
* @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
*
* @property {function(function())} onCheckoutCompleteSuccess Used to subscribe callbacks firing
* when checkout has completed
* @property {function(function())} onCheckoutAfterProcessingWithSuccess Used to subscribe callbacks
* firing when checkout has completed
* processing successfully.
* @property {function(function())} onCheckoutCompleteError Used to subscribe callbacks firing
* when checkout has completed
* @property {function(function())} onCheckoutAfterProcessingWithError Used to subscribe callbacks
* firing when checkout has completed
* 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
* processing (as a part of the
* processing process).
@ -160,15 +162,6 @@
* @property {function(function())} onPaymentProcessing Event registration callback for
* registering observers for the
* 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
*/
/**
* @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
*
@ -194,13 +197,15 @@
* @property {EventRegistrationProps} eventRegistration Various event registration helpers
* for subscribing callbacks for
* events.
* @property {EmitResponseProps} emitResponse Utilities for usage in event
* observer response objects.
* @property {Function} [onSubmit] Used to trigger checkout
* processing.
* @property {string} [activePaymentMethod] Indicates what the active payment
* method is.
* @property {ComponentProps} components Components exposed to payment
* methods for use.
* @property {function(string)} [setExpressPaymentError] For setting an error (error
* @property {function(string)} [setExpressPaymentError] For setting an error (error
* message string) for express
* payment methods. Does not change
* payment status.

View File

@ -231,13 +231,12 @@ class Library {
$_POST = $context->payment_data;
// 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;
}
$payment_method_object = $available_gateways[ $context->payment_method ];
$payment_method_object->validate_fields();
if ( 0 !== wc_notice_count( 'error' ) ) {
@ -250,9 +249,14 @@ class Library {
// Restore $_POST data.
$_POST = $post_data;
// Clear notices so they don't show up in the block.
wc_clear_notices();
// Handle result.
$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'] );
}
}

View File

@ -52,6 +52,7 @@ final class Stripe extends AbstractPaymentMethodType {
public function __construct( 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_stripe_intents' ], 9999, 2 );
}
/**
@ -227,7 +228,7 @@ final class Stripe extends AbstractPaymentMethodType {
* @param PaymentContext $context Holds context 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;
if ( ! empty( $data['payment_request_type'] ) && 'stripe' === $context->payment_method ) {
// 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 );
$_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;
}
/**
* 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.
*

View File

@ -166,9 +166,31 @@ class CheckoutSchema extends AbstractSchema {
'payment_method' => $order->get_payment_method(),
'payment_result' => [
'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,
],
];
}
/**
* 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
);
}
}