Fix shipping rate and address handling in Stripe payment request payment method. (https://github.com/woocommerce/woocommerce-blocks/pull/2484)

* fix dependencies

* refactor stripe payment-request to extract things into smaller units

- adds/fixes typedefs
- fixes dependencies
- improves logic.

* implement memoizing for functions.

* if same shipping address is selected, just call updateWith immediately

* add separate handler for failed shipping rate retrieval

* improve logic around shipping rate fail/success status

* add notice suppression logic to store notices.

- this is implemented in checkout processor to suppress notices when express payment methods are active.

* add error detection for shipping address errors and update the shipping status accordingly

* update type-def

* set billingData before shippingData

This is needed because of the shipping data and billing data sync logic in use-checkout-address.

* have to tighten dependencies to prevent unnecessary firing

With us now adding error status setters for shippping, the potential for the shipping status changes to trigger the effect went up. So tightening the dependencies to only the stati we care about prevent unnecessary effect calls.

* refactor event handlers to be named and remove all listeners.

This is an undocumented api on the stripe `paymentRequest.on` return value, but I’m trusting it will be relatively stable for this api.

The need for this is caused by the fact that without it, the listeners are re-registered on the paymentRequest event everytime the paymentRequest modal is closed and reopened.

* fix typo in doc block
This commit is contained in:
Darren Ethier 2020-05-14 19:55:22 -04:00 committed by GitHub
parent 89f843b2b7
commit e2d6e4a038
17 changed files with 755 additions and 373 deletions

View File

@ -8,6 +8,7 @@ import {
useRef,
useMemo,
useEffect,
useCallback,
} from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { useStoreNotices, useEmitResponse } from '@woocommerce/base-hooks';
@ -304,9 +305,9 @@ export const CheckoutStateProvider = ( {
isSuccessResponse,
] );
const onSubmit = () => {
const onSubmit = useCallback( () => {
dispatch( actions.setBeforeProcessing() );
};
}, [] );
/**
* @type {CheckoutDataContext}

View File

@ -75,7 +75,7 @@ const CheckoutProcessor = () => {
paymentMethods,
shouldSavePayment,
} = usePaymentMethodDataContext();
const { addErrorNotice, removeNotice } = useStoreNotices();
const { addErrorNotice, removeNotice, setIsSuppressed } = useStoreNotices();
const currentBillingData = useRef( billingData );
const currentShippingAddress = useRef( shippingAddress );
const currentRedirectUrl = useRef( redirectUrl );
@ -94,6 +94,11 @@ const CheckoutProcessor = () => {
currentPaymentStatus.hasError ||
shippingErrorStatus.hasError;
// If express payment method is active, let's suppress notices
useEffect( () => {
setIsSuppressed( expressPaymentMethodActive );
}, [ expressPaymentMethodActive, setIsSuppressed ] );
useEffect( () => {
if (
checkoutWillHaveError !== checkoutHasError &&

View File

@ -150,19 +150,22 @@ export const PaymentMethodDataProvider = ( { children } ) => {
[ dispatch ]
);
const setExpressPaymentError = ( message ) => {
if ( message ) {
addErrorNotice( message, {
context: 'wc/express-payment-area',
id: 'wc-express-payment-error',
} );
} else {
removeNotice(
'wc-express-payment-error',
'wc/express-payment-area'
);
}
};
const setExpressPaymentError = useCallback(
( message ) => {
if ( message ) {
addErrorNotice( message, {
context: 'wc/express-payment-area',
id: 'wc-express-payment-error',
} );
} else {
removeNotice(
'wc-express-payment-error',
'wc/express-payment-area'
);
}
},
[ addErrorNotice, removeNotice ]
);
// ensure observers are always current.
useEffect( () => {
currentObservers.current = observers;
@ -230,12 +233,12 @@ export const PaymentMethodDataProvider = ( { children } ) => {
billingData = null,
shippingData = null
) => {
if ( shippingData !== null && shippingData?.address ) {
setShippingAddress( shippingData.address );
}
if ( billingData ) {
setBillingData( billingData );
}
if ( shippingData !== null && shippingData?.address ) {
setShippingAddress( shippingData.address );
}
dispatch(
success( {
paymentMethodData,

View File

@ -13,6 +13,12 @@ export const ERROR_TYPES = {
UNKNOWN: 'unknown_error',
};
export const shippingErrorCodes = {
INVALID_COUNTRY: 'woocommerce_rest_cart_shipping_rates_invalid_country',
MISSING_COUNTRY: 'woocommerce_rest_cart_shipping_rates_missing_country',
INVALID_STATE: 'woocommerce_rest_cart_shipping_rates_invalid_state',
};
/**
* @type {CartShippingAddress}
*/

View File

@ -19,7 +19,11 @@ import { useCheckoutContext } from '@woocommerce/base-context';
/**
* Internal dependencies
*/
import { ERROR_TYPES, DEFAULT_SHIPPING_CONTEXT_DATA } from './constants';
import {
ERROR_TYPES,
DEFAULT_SHIPPING_CONTEXT_DATA,
shippingErrorCodes,
} from './constants';
import {
EMIT_TYPES,
emitterSubscribers,
@ -56,6 +60,18 @@ export const useShippingDataContext = () => {
return useContext( ShippingDataContext );
};
const hasInvalidShippingAddress = ( errors ) => {
return errors.some( ( error ) => {
if (
error.code &&
Object.values( shippingErrorCodes ).includes( error.code )
) {
return true;
}
return false;
} );
};
/**
* The shipping data provider exposes the interface for shipping in the
* checkout/cart.
@ -68,6 +84,7 @@ export const ShippingDataProvider = ( { children } ) => {
cartNeedsShipping: needsShipping,
shippingRates,
shippingRatesLoading,
cartErrors,
} = useStoreCart();
const [ shippingErrorStatus, dispatchErrorStatus ] = useReducer(
errorStatusReducer,
@ -105,7 +122,7 @@ export const ShippingDataProvider = ( { children } ) => {
} else {
dispatchActions.decrementCalculating();
}
}, [ shippingRatesLoading ] );
}, [ shippingRatesLoading, dispatchActions ] );
// increment/decrement checkout calculating counts when shipping rates are
// being selected.
@ -115,7 +132,19 @@ export const ShippingDataProvider = ( { children } ) => {
} else {
dispatchActions.decrementCalculating();
}
}, [ isSelectingRate ] );
}, [ isSelectingRate, dispatchActions ] );
// set shipping error status if there are shipping error codes
useEffect( () => {
if (
cartErrors.length > 0 &&
hasInvalidShippingAddress( cartErrors )
) {
dispatchErrorStatus( { type: INVALID_ADDRESS } );
} else {
dispatchErrorStatus( { type: NONE } );
}
}, [ cartErrors ] );
const currentErrorStatus = useMemo(
() => ( {
@ -131,19 +160,30 @@ export const ShippingDataProvider = ( { children } ) => {
// emit events.
useEffect( () => {
if ( ! shippingRatesLoading && currentErrorStatus.hasError ) {
if (
! shippingRatesLoading &&
( shippingRates.length === 0 || currentErrorStatus.hasError )
) {
emitEvent(
currentObservers.current,
EMIT_TYPES.SHIPPING_RATES_FAIL,
currentErrorStatus
{
hasInvalidAddress: currentErrorStatus.hasInvalidAddress,
hasError: currentErrorStatus.hasError,
}
);
}
}, [ shippingRates, shippingRatesLoading, currentErrorStatus.hasError ] );
}, [
shippingRates,
shippingRatesLoading,
currentErrorStatus.hasError,
currentErrorStatus.hasInvalidAddress,
] );
useEffect( () => {
if (
! shippingRatesLoading &&
shippingRates &&
shippingRates.length > 0 &&
! currentErrorStatus.hasError
) {
emitEvent(
@ -160,10 +200,18 @@ export const ShippingDataProvider = ( { children } ) => {
emitEvent(
currentObservers.current,
EMIT_TYPES.SHIPPING_RATE_SELECT_FAIL,
currentErrorStatus
{
hasError: currentErrorStatus.hasError,
hasInvalidAddress: currentErrorStatus.hasInvalidAddress,
}
);
}
}, [ selectedRates, isSelectingRate, currentErrorStatus.hasError ] );
}, [
selectedRates,
isSelectingRate,
currentErrorStatus.hasError,
currentErrorStatus.hasInvalidAddress,
] );
useEffect( () => {
if (

View File

@ -2,7 +2,12 @@
* External dependencies
*/
import PropTypes from 'prop-types';
import { createContext, useContext, useCallback } from '@wordpress/element';
import {
createContext,
useContext,
useCallback,
useState,
} from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';
import {
StoreNoticesContainer,
@ -47,6 +52,7 @@ export const StoreNoticesProvider = ( {
context = 'wc/core',
} ) => {
const { createNotice, removeNotice } = useDispatch( 'core/notices' );
const [ isSuppressed, setIsSuppressed ] = useState( false );
const createNoticeWithContext = useCallback(
( status = 'default', content = '', options = {} ) => {
@ -90,18 +96,25 @@ export const StoreNoticesProvider = ( {
createSnackbarNotice,
removeNotice: removeNoticeWithContext,
context,
setIsSuppressed,
};
const noticeOutput = isSuppressed ? null : (
<StoreNoticesContainer
className={ className }
notices={ contextValue.notices }
/>
);
const snackbarNoticeOutput = isSuppressed ? null : (
<SnackbarNoticesContainer />
);
return (
<StoreNoticesContext.Provider value={ contextValue }>
{ createNoticeContainer && (
<StoreNoticesContainer
className={ className }
notices={ contextValue.notices }
/>
) }
{ createNoticeContainer && noticeOutput }
{ children }
<SnackbarNoticesContainer />
{ snackbarNoticeOutput }
</StoreNoticesContext.Provider>
);
};

View File

@ -1,5 +0,0 @@
export const shippingErrorCodes = {
INVALID_COUNTRY: 'woocommerce_rest_cart_shipping_rates_invalid_country',
MISSING_COUNTRY: 'woocommerce_rest_cart_shipping_rates_missing_country',
INVALID_STATE: 'woocommerce_rest_cart_shipping_rates_invalid_state',
};

View File

@ -2,7 +2,7 @@
* External dependencies
*/
import { useDispatch } from '@wordpress/data';
import { useEffect, useState } from '@wordpress/element';
import { useEffect, useState, useRef } from '@wordpress/element';
import isShallowEqual from '@wordpress/is-shallow-equal';
import { useDebounce } from 'use-debounce';
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
@ -24,23 +24,29 @@ export const useShippingAddress = () => {
const [ debouncedShippingAddress ] = useDebounce( shippingAddress, 400 );
const { updateShippingAddress } = useDispatch( storeKey );
const { addErrorNotice } = useStoreNotices();
const previousAddress = useRef( initialAddress );
// Note, we're intentionally not using initialAddress as a dependency here
// so that the stale (previous) value is being used for comparison.
useEffect( () => {
if (
debouncedShippingAddress.country &&
shouldUpdateStore( initialAddress, debouncedShippingAddress )
shouldUpdateStore(
previousAddress.current,
debouncedShippingAddress
)
) {
updateShippingAddress( debouncedShippingAddress ).catch(
( error ) => {
updateShippingAddress( debouncedShippingAddress )
.then( () => {
previousAddress.current = debouncedShippingAddress;
} )
.catch( ( error ) => {
addErrorNotice( error.message, {
id: 'shipping-form',
} );
}
);
} );
}
}, [ debouncedShippingAddress ] );
}, [ debouncedShippingAddress, updateShippingAddress, addErrorNotice ] );
const decodedShippingAddress = {};
Object.keys( shippingAddress ).forEach( ( key ) => {

View File

@ -10,6 +10,7 @@ export const useStoreNotices = () => {
createNotice,
removeNotice,
createSnackbarNotice,
setIsSuppressed,
} = useStoreNoticesContext();
// Added to a ref so the surface for notices doesn't change frequently
// and thus can be used as dependencies on effects.
@ -73,5 +74,6 @@ export const useStoreNotices = () => {
notices,
...noticesApi,
...noticeCreators,
setIsSuppressed,
};
};

View File

@ -1,33 +1,14 @@
/**
* Internal dependencies
*/
import { DEFAULT_STRIPE_EVENT_HANDLERS } from './constants';
import {
getStripeServerData,
getPaymentRequest,
updatePaymentRequest,
canDoPaymentRequest,
getTotalPaymentItem,
getBillingData,
getPaymentMethodData,
getShippingData,
normalizeShippingAddressForCheckout,
normalizeShippingOptions,
normalizeLineItems,
normalizeShippingOptionSelectionsForCheckout,
} from '../stripe-utils';
import { getStripeServerData } from '../stripe-utils';
import { useInitialization } from './use-initialization';
import { useCheckoutSubscriptions } from './use-checkout-subscriptions';
/**
* External dependencies
*/
import { useRef, useState, useEffect } from '@wordpress/element';
import {
Elements,
PaymentRequestButtonElement,
useStripe,
} from '@stripe/react-stripe-js';
import { __ } from '@wordpress/i18n';
import { getSetting } from '@woocommerce/settings';
import { Elements, PaymentRequestButtonElement } from '@stripe/react-stripe-js';
/**
* @typedef {import('../stripe-utils/type-defs').Stripe} Stripe
@ -60,295 +41,37 @@ const PaymentRequestExpressComponent = ( {
onClick,
onClose,
} ) => {
/**
* @type {[ StripePaymentRequest|null, function( StripePaymentRequest ):StripePaymentRequest|null]}
*/
// @ts-ignore
const [ paymentRequest, setPaymentRequest ] = useState( null );
const stripe = useStripe();
const [ canMakePayment, setCanMakePayment ] = useState( false );
const [ paymentRequestType, setPaymentRequestType ] = useState( '' );
const [ isProcessing, setIsProcessing ] = useState( false );
const [ isFinished, setIsFinished ] = useState( false );
const eventHandlers = useRef( DEFAULT_STRIPE_EVENT_HANDLERS );
const currentBilling = useRef( billing );
const currentShipping = useRef( shippingData );
const currentPaymentRequest = useRef( paymentRequest );
// update refs when any change.
useEffect( () => {
currentBilling.current = billing;
currentShipping.current = shippingData;
currentPaymentRequest.current = paymentRequest;
}, [ billing, shippingData, paymentRequest ] );
// set paymentRequest.
useEffect( () => {
// can't do anything if stripe isn't available yet or we have zero total.
if ( ! stripe || ! billing.cartTotal.value ) {
return;
}
// if payment request hasn't been set yet then set it.
if ( ! currentPaymentRequest.current && ! isFinished ) {
setPaymentRequest(
getPaymentRequest( {
total: billing.cartTotal,
currencyCode: billing.currency.code.toLowerCase(),
countryCode: getSetting( 'baseLocation', {} )?.country,
shippingRequired: shippingData.needsShipping,
cartTotalItems: billing.cartTotalItems,
stripe,
} )
);
}
// otherwise we just update it (but only if payment processing hasn't
// already started).
if ( ! isProcessing && currentPaymentRequest.current && ! isFinished ) {
updatePaymentRequest( {
// @ts-ignore
paymentRequest: currentPaymentRequest.current,
total: billing.cartTotal,
currencyCode: billing.currency.code.toLowerCase(),
cartTotalItems: billing.cartTotalItems,
} );
}
}, [
billing.cartTotal,
billing.currency.code,
shippingData.needsShipping,
billing.cartTotalItems,
stripe,
const {
paymentRequest,
paymentRequestEventHandlers,
clearPaymentRequestEventHandler,
isProcessing,
isFinished,
] );
// whenever paymentRequest changes, then we need to update whether
// payment can be made.
useEffect( () => {
if ( paymentRequest ) {
canDoPaymentRequest( paymentRequest ).then( ( result ) => {
if ( result.requestType ) {
setPaymentRequestType( result.requestType );
}
setCanMakePayment( result.canPay );
} );
}
}, [ paymentRequest ] );
// kick off payment processing.
const onButtonClick = () => {
setIsProcessing( true );
setIsFinished( false );
setExpressPaymentError( '' );
onClick();
};
const abortPayment = ( paymentMethod, message ) => {
const response = {
fail: {
message,
billingData: getBillingData( paymentMethod ),
paymentMethodData: getPaymentMethodData(
paymentMethod,
paymentRequestType
),
},
};
paymentMethod.complete( 'fail' );
setIsProcessing( false );
setIsFinished( true );
return response;
};
const completePayment = ( paymentMethod ) => {
paymentMethod.complete( 'success' );
setIsFinished( true );
setIsProcessing( false );
};
// event callbacks.
const onShippingRatesEvent = ( forSuccess = true ) => ( shippingRates ) => {
const handlers = eventHandlers.current;
const billingData = currentBilling.current;
if ( handlers.shippingAddressChange && isProcessing ) {
handlers.shippingAddressChange.updateWith( {
status: forSuccess ? 'success' : 'fail',
shippingOptions: normalizeShippingOptions( shippingRates ),
total: getTotalPaymentItem( billingData.cartTotal ),
displayItems: normalizeLineItems( billingData.cartTotalItems ),
} );
handlers.shippingAddressChange = null;
}
};
const onShippingSelectedRate = ( forSuccess = true ) => () => {
const handlers = eventHandlers.current;
const shipping = currentShipping.current;
const billingData = currentBilling.current;
if (
handlers.shippingOptionChange &&
! shipping.isSelectingRate &&
isProcessing
) {
const updateObject = forSuccess
? {
status: 'success',
total: getTotalPaymentItem( billingData.cartTotal ),
displayItems: normalizeLineItems(
billingData.cartTotalItems
),
}
: {
status: 'fail',
};
handlers.shippingOptionChange.updateWith( updateObject );
handlers.shippingOptionChange = null;
}
};
const onPaymentProcessing = () => {
const handlers = eventHandlers.current;
if ( handlers.sourceEvent && isProcessing ) {
const response = {
type: emitResponse.responseTypes.SUCCESS,
meta: {
billingData: getBillingData( handlers.sourceEvent ),
paymentMethodData: getPaymentMethodData(
handlers.sourceEvent,
paymentRequestType
),
shippingData: getShippingData( handlers.sourceEvent ),
},
};
return response;
}
return { type: emitResponse.responseTypes.SUCCESS };
};
const onCheckoutComplete = ( checkoutResponse ) => {
const handlers = eventHandlers.current;
let response = { type: emitResponse.responseTypes.SUCCESS };
if ( handlers.sourceEvent && isProcessing ) {
const { paymentStatus, paymentDetails } = checkoutResponse;
if ( paymentStatus === emitResponse.responseTypes.SUCCESS ) {
completePayment( 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 response;
};
// when canMakePayment is true, then we set listeners on payment request for
// handling updates.
useEffect( () => {
if ( paymentRequest && canMakePayment && isProcessing ) {
paymentRequest.on( 'shippingaddresschange', ( event ) => {
// @todo check if there is an address change, and if not, then
// just call updateWith and don't call setShippingAddress here
// because the state won't change upstream.
currentShipping.current.setShippingAddress(
normalizeShippingAddressForCheckout( event.shippingAddress )
);
eventHandlers.current.shippingAddressChange = event;
} );
paymentRequest.on( 'shippingoptionchange', ( event ) => {
currentShipping.current.setSelectedRates(
normalizeShippingOptionSelectionsForCheckout(
event.shippingOption
)
);
eventHandlers.current.shippingOptionChange = event;
} );
paymentRequest.on( 'source', ( paymentMethod ) => {
if (
// eslint-disable-next-line no-undef
! getStripeServerData().allowPrepaidCard &&
paymentMethod.source.card.funding
) {
setExpressPaymentError(
__(
"Sorry, we're not accepting prepaid cards at this time.",
'woocommerce-gateway-stripe'
)
);
return;
}
eventHandlers.current.sourceEvent = paymentMethod;
// kick off checkout processing step.
onSubmit();
} );
paymentRequest.on( 'cancel', () => {
setIsFinished( true );
setIsProcessing( false );
onClose();
} );
}
}, [ paymentRequest, canMakePayment, isProcessing, onClose ] );
// subscribe to events.
useEffect( () => {
if ( canMakePayment && isProcessing ) {
const subscriber = eventRegistration;
const unsubscribeShippingRateSuccess = subscriber.onShippingRateSuccess(
onShippingRatesEvent()
);
const unsubscribeShippingRateFail = subscriber.onShippingRateFail(
onShippingRatesEvent( false )
);
const unsubscribeShippingRateSelectSuccess = subscriber.onShippingRateSelectSuccess(
onShippingSelectedRate()
);
const unsubscribeShippingRateSelectFail = subscriber.onShippingRateSelectFail(
onShippingRatesEvent( false )
);
const unsubscribePaymentProcessing = subscriber.onPaymentProcessing(
onPaymentProcessing
);
const unsubscribeCheckoutCompleteSuccess = subscriber.onCheckoutAfterProcessingWithSuccess(
onCheckoutComplete
);
const unsubscribeCheckoutCompleteFail = subscriber.onCheckoutAfterProcessingWithError(
onCheckoutComplete
);
return () => {
unsubscribeCheckoutCompleteFail();
unsubscribeCheckoutCompleteSuccess();
unsubscribePaymentProcessing();
unsubscribeShippingRateFail();
unsubscribeShippingRateSuccess();
unsubscribeShippingRateSelectSuccess();
unsubscribeShippingRateSelectFail();
};
}
return undefined;
}, [
canMakePayment,
onButtonClick,
abortPayment,
completePayment,
paymentRequestType,
} = useInitialization( {
billing,
shippingData,
setExpressPaymentError,
onClick,
onClose,
onSubmit,
} );
useCheckoutSubscriptions( {
canMakePayment,
isProcessing,
eventRegistration.onShippingRateSuccess,
eventRegistration.onShippingRateFail,
eventRegistration.onShippingRateSelectSuccess,
eventRegistration.onShippingRateSelectFail,
eventRegistration.onPaymentProcessing,
eventRegistration.onCheckoutAfterProcessingWithSuccess,
eventRegistration.onCheckoutAfterProcessingWithError,
] );
eventRegistration,
paymentRequestEventHandlers,
clearPaymentRequestEventHandler,
billing,
shippingData,
emitResponse,
paymentRequestType,
completePayment,
abortPayment,
} );
// locale is not a valid value for the paymentRequestButton style.
const { theme } = getStripeServerData().button;
@ -365,7 +88,9 @@ const PaymentRequestExpressComponent = ( {
<PaymentRequestButtonElement
onClick={ onButtonClick }
options={ {
// @ts-ignore
style: paymentRequestButtonStyle,
// @ts-ignore
paymentRequest,
} }
/>

View File

@ -0,0 +1,241 @@
/**
* External dependencies
*/
import { useEffect, useRef } from '@wordpress/element';
/**
* Internal dependencies
*/
import {
normalizeShippingOptions,
getTotalPaymentItem,
normalizeLineItems,
getBillingData,
getPaymentMethodData,
getShippingData,
} from '../stripe-utils';
/**
* @typedef {import('@woocommerce/type-defs/registered-payment-method-props').EventRegistrationProps} EventRegistrationProps
* @typedef {import('@woocommerce/type-defs/registered-payment-method-props').BillingDataProps} BillingDataProps
* @typedef {import('@woocommerce/type-defs/registered-payment-method-props').ShippingDataProps} ShippingDataProps
* @typedef {import('@woocommerce/type-defs/registered-payment-method-props').EmitResponseProps} EmitResponseProps
*/
/**
* @param {Object} props
*
* @param {boolean} props.canMakePayment Whether the payment request
* can make payment or not.
* @param {boolean} props.isProcessing Whether the express payment
* method is processing or not.
* @param {EventRegistrationProps} props.eventRegistration Various functions for
* registering observers to
* events.
* @param {Object} props.paymentRequestEventHandlers Cached handlers registered
* for paymentRequest events.
* @param {function(string):void} props.clearPaymentRequestEventHandler Clears the cached payment
* request event handler.
* @param {BillingDataProps} props.billing
* @param {ShippingDataProps} props.shippingData
* @param {EmitResponseProps} props.emitResponse
* @param {string} props.paymentRequestType The derived payment request
* type for the express
* payment being processed.
* @param {function(any):void} props.completePayment This is a callback
* receiving the source event
* and setting it to
* successful payment.
* @param {function(any,string):any} props.abortPayment This is a callback
* receiving the source
* event and setting it to
* failed payment.
*/
export const useCheckoutSubscriptions = ( {
canMakePayment,
isProcessing,
eventRegistration,
paymentRequestEventHandlers,
clearPaymentRequestEventHandler,
billing,
shippingData,
emitResponse,
paymentRequestType,
completePayment,
abortPayment,
} ) => {
const {
onShippingRateSuccess,
onShippingRateFail,
onShippingRateSelectSuccess,
onShippingRateSelectFail,
onPaymentProcessing,
onCheckoutAfterProcessingWithSuccess,
onCheckoutAfterProcessingWithError,
} = eventRegistration;
const { noticeContexts, responseTypes } = emitResponse;
const eventHandlers = useRef( paymentRequestEventHandlers );
const currentBilling = useRef( billing );
const currentShipping = useRef( shippingData );
const currentPaymentRequestType = useRef( paymentRequestType );
useEffect( () => {
eventHandlers.current = paymentRequestEventHandlers;
currentBilling.current = billing;
currentShipping.current = shippingData;
currentPaymentRequestType.current = paymentRequestType;
}, [
paymentRequestEventHandlers,
billing,
shippingData,
paymentRequestType,
] );
// subscribe to events.
useEffect( () => {
const onShippingRatesEvent = ( shippingRates ) => {
const handlers = eventHandlers.current;
const billingData = currentBilling.current;
if ( handlers.shippingAddressChange && isProcessing ) {
handlers.shippingAddressChange.updateWith( {
status: 'success',
shippingOptions: normalizeShippingOptions( shippingRates ),
total: getTotalPaymentItem( billingData.cartTotal ),
displayItems: normalizeLineItems(
billingData.cartTotalItems
),
} );
clearPaymentRequestEventHandler( 'shippingAddressChange' );
}
};
const onShippingRatesEventFail = ( currentErrorStatus ) => {
const handlers = eventHandlers.current;
if ( handlers.shippingAddressChange && isProcessing ) {
handlers.shippingAddressChange.updateWith( {
status: currentErrorStatus.hasInvalidAddress
? 'invalid_shipping_address'
: 'fail',
shippingOptions: [],
} );
}
clearPaymentRequestEventHandler( 'shippingAddressChange' );
};
const onShippingSelectedRate = ( forSuccess = true ) => () => {
const handlers = eventHandlers.current;
const shipping = currentShipping.current;
const billingData = currentBilling.current;
if (
handlers.shippingOptionChange &&
! shipping.isSelectingRate &&
isProcessing
) {
const updateObject = forSuccess
? {
status: 'success',
total: getTotalPaymentItem( billingData.cartTotal ),
displayItems: normalizeLineItems(
billingData.cartTotalItems
),
}
: {
status: 'fail',
};
handlers.shippingOptionChange.updateWith( updateObject );
clearPaymentRequestEventHandler( 'shippingOptionChange' );
}
};
const onProcessingPayment = () => {
const handlers = eventHandlers.current;
if ( handlers.sourceEvent && isProcessing ) {
const response = {
type: responseTypes.SUCCESS,
meta: {
billingData: getBillingData( handlers.sourceEvent ),
paymentMethodData: getPaymentMethodData(
handlers.sourceEvent,
currentPaymentRequestType.current
),
shippingData: getShippingData( handlers.sourceEvent ),
},
};
return response;
}
return { type: responseTypes.SUCCESS };
};
const onCheckoutComplete = ( checkoutResponse ) => {
const handlers = eventHandlers.current;
let response = { type: responseTypes.SUCCESS };
if ( handlers.sourceEvent && isProcessing ) {
const { paymentStatus, paymentDetails } = checkoutResponse;
if ( paymentStatus === responseTypes.SUCCESS ) {
completePayment( handlers.sourceEvent );
}
if (
paymentStatus === responseTypes.ERROR ||
paymentStatus === responseTypes.FAIL
) {
const paymentResponse = abortPayment(
handlers.sourceEvent,
paymentDetails?.errorMessage
);
response = {
type: responseTypes.ERROR,
message: paymentResponse.message,
messageContext: noticeContexts.EXPRESS_PAYMENTS,
retry: true,
};
}
clearPaymentRequestEventHandler( 'sourceEvent' );
}
return response;
};
if ( canMakePayment && isProcessing ) {
const unsubscribeShippingRateSuccess = onShippingRateSuccess(
onShippingRatesEvent
);
const unsubscribeShippingRateFail = onShippingRateFail(
onShippingRatesEventFail
);
const unsubscribeShippingRateSelectSuccess = onShippingRateSelectSuccess(
onShippingSelectedRate()
);
const unsubscribeShippingRateSelectFail = onShippingRateSelectFail(
onShippingRatesEventFail
);
const unsubscribePaymentProcessing = onPaymentProcessing(
onProcessingPayment
);
const unsubscribeCheckoutCompleteSuccess = onCheckoutAfterProcessingWithSuccess(
onCheckoutComplete
);
const unsubscribeCheckoutCompleteFail = onCheckoutAfterProcessingWithError(
onCheckoutComplete
);
return () => {
unsubscribeCheckoutCompleteFail();
unsubscribeCheckoutCompleteSuccess();
unsubscribePaymentProcessing();
unsubscribeShippingRateFail();
unsubscribeShippingRateSuccess();
unsubscribeShippingRateSelectSuccess();
unsubscribeShippingRateSelectFail();
};
}
return undefined;
}, [
canMakePayment,
isProcessing,
onShippingRateSuccess,
onShippingRateFail,
onShippingRateSelectSuccess,
onShippingRateSelectFail,
onPaymentProcessing,
onCheckoutAfterProcessingWithSuccess,
onCheckoutAfterProcessingWithError,
responseTypes,
noticeContexts,
completePayment,
abortPayment,
clearPaymentRequestEventHandler,
] );
};

View File

@ -0,0 +1,48 @@
/**
* External dependencies
*/
import { useState, useCallback } from '@wordpress/element';
/**
* Internal dependencies
*/
import { DEFAULT_STRIPE_EVENT_HANDLERS } from './constants';
/**
* A utility hook for maintaining an event handler cache.
*/
export const useEventHandlers = () => {
const [ paymentRequestEventHandlers, setEventHandlers ] = useState(
DEFAULT_STRIPE_EVENT_HANDLERS
);
const setPaymentRequestEventHandler = useCallback(
( eventName, handler ) => {
setEventHandlers( ( prevEventHandlers ) => {
return {
...prevEventHandlers,
[ eventName ]: handler,
};
} );
},
[ setEventHandlers ]
);
const clearPaymentRequestEventHandler = useCallback(
( eventName ) => {
// @ts-ignore
setEventHandlers( ( prevEventHandlers ) => {
// @ts-ignore
// eslint-disable-next-line no-unused-vars
const { [ eventName ]: __, ...newHandlers } = prevEventHandlers;
return newHandlers;
} );
},
[ setEventHandlers ]
);
return {
paymentRequestEventHandlers,
setPaymentRequestEventHandler,
clearPaymentRequestEventHandler,
};
};

View File

@ -0,0 +1,260 @@
/**
* External dependencies
*/
import { useEffect, useState, useRef, useCallback } from '@wordpress/element';
import { useStripe } from '@stripe/react-stripe-js';
import { getSetting } from '@woocommerce/settings';
import { __ } from '@wordpress/i18n';
import isShallowEqual from '@wordpress/is-shallow-equal';
/**
* Internal dependencies
*/
import {
getPaymentRequest,
updatePaymentRequest,
canDoPaymentRequest,
getBillingData,
getPaymentMethodData,
normalizeShippingAddressForCheckout,
normalizeShippingOptionSelectionsForCheckout,
getStripeServerData,
pluckAddress,
normalizeShippingOptions,
} from '../stripe-utils';
import { useEventHandlers } from './use-event-handlers';
/**
* @typedef {import('../stripe-utils/type-defs').StripePaymentRequest} StripePaymentRequest
*/
export const useInitialization = ( {
billing,
shippingData,
setExpressPaymentError,
onClick,
onClose,
onSubmit,
} ) => {
const stripe = useStripe();
/**
* @type {[ StripePaymentRequest|null, function( StripePaymentRequest ):void]}
*/
// @ts-ignore
const [ paymentRequest, setPaymentRequest ] = useState( null );
const [ isFinished, setIsFinished ] = useState( false );
const [ isProcessing, setIsProcessing ] = useState( false );
const [ canMakePayment, setCanMakePayment ] = useState( false );
const currentPaymentRequest = useRef( paymentRequest );
const currentPaymentRequestType = useRef( '' );
const currentShipping = useRef( shippingData );
const {
paymentRequestEventHandlers,
clearPaymentRequestEventHandler,
setPaymentRequestEventHandler,
} = useEventHandlers();
// Update refs when any change.
useEffect( () => {
currentPaymentRequest.current = paymentRequest;
currentShipping.current = shippingData;
}, [ paymentRequest, shippingData ] );
// set paymentRequest.
useEffect( () => {
// can't do anything if stripe isn't available yet or we have zero total.
if ( ! stripe || ! billing.cartTotal.value ) {
return;
}
// if payment request hasn't been set yet then set it.
if ( ! currentPaymentRequest.current && ! isFinished ) {
setPaymentRequest(
getPaymentRequest( {
total: billing.cartTotal,
currencyCode: billing.currency.code.toLowerCase(),
countryCode: getSetting( 'baseLocation', {} )?.country,
shippingRequired: shippingData.needsShipping,
cartTotalItems: billing.cartTotalItems,
stripe,
} )
);
}
// otherwise we just update it (but only if payment processing hasn't
// already started).
if ( ! isProcessing && currentPaymentRequest.current && ! isFinished ) {
updatePaymentRequest( {
// @ts-ignore
paymentRequest: currentPaymentRequest.current,
total: billing.cartTotal,
currencyCode: billing.currency.code.toLowerCase(),
cartTotalItems: billing.cartTotalItems,
} );
}
}, [
billing.cartTotal,
billing.currency.code,
shippingData.needsShipping,
billing.cartTotalItems,
stripe,
isProcessing,
isFinished,
] );
// whenever paymentRequest changes, then we need to update whether
// payment can be made.
useEffect( () => {
if ( paymentRequest ) {
canDoPaymentRequest( paymentRequest ).then( ( result ) => {
if ( result.requestType ) {
currentPaymentRequestType.current = result.requestType;
}
setCanMakePayment( result.canPay );
} );
}
}, [ paymentRequest ] );
// kick off payment processing.
const onButtonClick = () => {
setIsProcessing( true );
setIsFinished( false );
setExpressPaymentError( '' );
onClick();
};
const abortPayment = useCallback( ( paymentMethod, message ) => {
const response = {
fail: {
message,
billingData: getBillingData( paymentMethod ),
paymentMethodData: getPaymentMethodData(
paymentMethod,
currentPaymentRequestType.current
),
},
};
paymentMethod.complete( 'fail' );
setIsProcessing( false );
setIsFinished( true );
return response;
}, [] );
const completePayment = useCallback( ( paymentMethod ) => {
paymentMethod.complete( 'success' );
setIsFinished( true );
setIsProcessing( false );
}, [] );
// when canMakePayment is true, then we set listeners on payment request for
// handling updates.
useEffect( () => {
const shippingAddressChangeHandler = ( event ) => {
const newShippingAddress = normalizeShippingAddressForCheckout(
event.shippingAddress
);
if (
isShallowEqual(
pluckAddress( newShippingAddress ),
pluckAddress( currentShipping.current.shippingAddress )
)
) {
// the address is the same so no change needed.
event.updateWith( {
status: 'success',
shippingOptions: normalizeShippingOptions(
currentShipping.current.shippingRates
),
} );
} else {
// the address is different so let's set the new address and
// register the handler to be picked up by the shipping rate
// change event.
currentShipping.current.setShippingAddress(
normalizeShippingAddressForCheckout( event.shippingAddress )
);
setPaymentRequestEventHandler( 'shippingAddressChange', event );
}
};
const shippingOptionChangeHandler = ( event ) => {
currentShipping.current.setSelectedRates(
normalizeShippingOptionSelectionsForCheckout(
event.shippingOption
)
);
setPaymentRequestEventHandler( 'shippingOptionChange', event );
};
const sourceHandler = ( paymentMethod ) => {
if (
// eslint-disable-next-line no-undef
! getStripeServerData().allowPrepaidCard &&
paymentMethod.source.card.funding
) {
setExpressPaymentError(
__(
"Sorry, we're not accepting prepaid cards at this time.",
'woocommerce-gateway-stripe'
)
);
return;
}
setPaymentRequestEventHandler( 'sourceEvent', paymentMethod );
// kick off checkout processing step.
onSubmit();
};
const cancelHandler = () => {
setIsFinished( true );
setIsProcessing( false );
onClose();
};
const noop = { removeAllListeners: () => void null };
let shippingAddressChangeEvent = noop,
shippingOptionChangeEvent = noop,
sourceChangeEvent = noop,
cancelChangeEvent = noop;
if ( paymentRequest && canMakePayment && isProcessing ) {
// @ts-ignore
shippingAddressChangeEvent = paymentRequest.on(
'shippingaddresschange',
shippingAddressChangeHandler
);
// @ts-ignore
shippingOptionChangeEvent = paymentRequest.on(
'shippingoptionchange',
shippingOptionChangeHandler
);
// @ts-ignore
sourceChangeEvent = paymentRequest.on( 'source', sourceHandler );
// @ts-ignore
cancelChangeEvent = paymentRequest.on( 'cancel', cancelHandler );
}
return () => {
if ( paymentRequest ) {
shippingAddressChangeEvent.removeAllListeners();
shippingOptionChangeEvent.removeAllListeners();
sourceChangeEvent.removeAllListeners();
cancelChangeEvent.removeAllListeners();
}
};
}, [
paymentRequest,
canMakePayment,
isProcessing,
onClose,
setPaymentRequestEventHandler,
setExpressPaymentError,
onSubmit,
] );
return {
paymentRequest,
paymentRequestEventHandlers,
clearPaymentRequestEventHandler,
isProcessing,
canMakePayment,
onButtonClick,
abortPayment,
completePayment,
paymentRequestType: currentPaymentRequestType.current,
};
};

View File

@ -249,18 +249,18 @@
/**
* @typedef {Object} StripePaymentRequest Stripe payment request object.
*
* @property {Function<Promise>} canMakePayment Returns a promise that resolves
* with an object detailing if a
* browser payment API is
* available.
* @property {Function} show Shows the browser's payment
* interface (called automatically
* if payment request button in
* use)
* @property {Function} update Used to update a PaymentRequest
* object.
* @property {Function} on For registering callbacks on
* payment request events.
* @property {function():Promise} canMakePayment Returns a promise that resolves
* with an object detailing if a
* browser payment API is
* available.
* @property {function()} show Shows the browser's payment
* interface (called automatically
* if payment request button in
* use)
* @property {function()} update Used to update a PaymentRequest
* object.
* @property {function()} on For registering callbacks on
* payment request events.
*/
/**

View File

@ -253,6 +253,21 @@ const getErrorMessageForTypeAndCode = ( type, code = '' ) => {
return null;
};
/**
* pluckAddress takes a full address object and returns relevant fields for calculating
* shipping, so we can track when one of them change to update rates.
*
* @param {Object} address An object containing all address information
*
* @return {Object} pluckedAddress An object containing shipping address that are needed to fetch an address.
*/
const pluckAddress = ( { country, state, city, postcode } ) => ( {
country,
state,
city,
postcode: postcode.replace( ' ', '' ).toUpperCase(),
} );
export {
getStripeServerData,
getApiKey,
@ -261,4 +276,5 @@ export {
updatePaymentRequest,
canDoPaymentRequest,
getErrorMessageForTypeAndCode,
pluckAddress,
};

View File

@ -304,6 +304,8 @@
* @property {string} context The current context
* identifier for the notice
* provider
* @property {function(boolean):void} setSuppressed Consumers can use this
* setter to suppress
*/
/**

View File

@ -146,17 +146,17 @@
* will fire when checkout begins
* processing (as a part of the
* processing process).
* @property {function()} onShippingRateSuccess Used to subscribe callbacks that
* @property {function(function())} onShippingRateSuccess Used to subscribe callbacks that
* will fire when shipping rates for a
* given address have been received
* successfully.
* @property {function()} onShippingRateFail Used to subscribe callbacks that
* @property {function(function())} onShippingRateFail Used to subscribe callbacks that
* will fire when retrieving shipping
* rates failed.
* @property {function()} onShippingRateSelectSuccess Used to subscribe callbacks that
* @property {function(function())} onShippingRateSelectSuccess Used to subscribe callbacks that
* will fire after selecting a
* shipping rate successfully.
* @property {function()} onShippingRateSelectFail Used to subscribe callbacks that
* @property {function(function())} onShippingRateSelectFail Used to subscribe callbacks that
* will fire after selecting a shipping
* rate unsuccessfully.
* @property {function(function())} onPaymentProcessing Event registration callback for
@ -211,6 +211,17 @@
* message string) for express
* payment methods. Does not change
* payment status.
* @property {function()} [onClick] Provided to express payment
* methods that should be triggered
* when the payment method button
* is clicked (which will signal to
* checkout the payment method has
* taken over payment processing)
* @property {function()} [onClose] Provided to express payment
* methods that should be triggered
* when the express payment method
* modal closes and control is
* returned to checkout.
*/
export {};