Skip to content

Commit

Permalink
Checkout: extract calling payment processor and handling response to …
Browse files Browse the repository at this point in the history
…useProcessPayment hook (#48283)

* Clarify manual transaction processing in composite-checkout README

* Separate payment processor response handling into own function

* Export useCreatePaymentProcessorOnClick from composite-checkout

* Add missing useCreatePaymentProcessorOnClick

* Rethrow PaymentProcessorResponse error

* Allow payment complete callback to include isComingFromUpsell

* Add withPaymentCompleteCallback HOC

* Clean up PaymentCompleteCallback type a bit

* Remove withPaymentCompleteCallback and shouldHideUpsellNudges

* Fix useCreatePaymentProcessorOnClick import

* Rename onClick creator to useProcessPayment

* Throw if using useStripe outside StripeHookProvider
  • Loading branch information
sirbrillig authored Dec 16, 2020
1 parent a56ef2e commit 100413f
Show file tree
Hide file tree
Showing 7 changed files with 150 additions and 94 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export default function useCreatePaymentCompleteCallback( {
productAliasFromUrl,
isEligibleForSignupDestinationResult,
shouldShowOneClickTreatment,
hideNudge: !! isComingFromUpsell,
hideNudge: isComingFromUpsell,
isInEditor,
previousRoute,
};
Expand Down
18 changes: 4 additions & 14 deletions packages/calypso-stripe/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -538,20 +538,10 @@ export function StripeHookProvider( {
*/
export function useStripe(): StripeData {
const stripeData = useContext( StripeContext );
return (
stripeData || {
stripe: null,
stripeConfiguration: null,
isStripeLoading: false,
stripeLoadingError: null,
reloadStripeConfiguration: () => {
// eslint-disable-next-line no-console
console.error(
`You cannot use reloadStripeConfiguration until stripe has been initialized.`
);
},
}
);
if ( ! stripeData ) {
throw new Error( 'useStripe can only be used inside a StripeHookProvider' );
}
return stripeData;
}

/**
Expand Down
10 changes: 7 additions & 3 deletions packages/composite-checkout/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ If not using the `onClick` function, when the `submitButton` component has been

1. Call `setTransactionPending()` from [useTransactionStatus](#useTransactionStatus). This will change the [form status](#useFormStatus) to [`.SUBMITTING`](#FormStatus) and disable the form.
2. Call the payment processor function returned from [usePaymentProcessor](#usePaymentProcessor]), passing whatever data that function requires. Each payment processor will be different, so you'll need to know the API of that function explicitly.
3. Payment processor functions return a `Promise`. When the `Promise` resolves, call `setTransactionComplete()` from [useTransactionStatus](#useTransactionStatus) if the transaction was a success. Depending on the payment processor, some transactions might require additional actions before they are complete. If the transaction requires a redirect, call `setTransactionRedirecting(url: string)` instead.
3. Payment processor functions return a `Promise`. When the `Promise` resolves, check its value (it will be one of [makeManualResponse](#makeManualResponse), [makeRedirectResponse](#makeRedirectResponse), or [makeSuccessResponse](#makeSuccessResponse)). Then call `setTransactionComplete(responseData: unknown)` from [useTransactionStatus](#useTransactionStatus) if the transaction was a success. If the transaction requires a redirect, call `setTransactionRedirecting(url: string)` instead.
4. If the `Promise` rejects, call `setTransactionError(message: string)`.
5. At this point the [CheckoutProvider](#CheckoutProvider) will automatically take action if the transaction status is [`.COMPLETE`](#TransactionStatus) (call [onPaymentComplete](#CheckoutProvider)), [`.ERROR`](#TransactionStatus) (display the error and re-enable the form), or [`.REDIRECTING`](#TransactionStatus) (redirect to the url). If for some reason the transaction should be cancelled, call `resetTransaction()`.

Expand Down Expand Up @@ -506,6 +506,10 @@ A React Hook that returns a payment processor function as passed to the `payment

A React Hook that returns all the payment processor functions in a Record.

### useProcessPayment

A React Hook that will return the function passed to each [payment method's submitButton component](#payment-methods). Call it with a payment method ID and data for the payment processor and it will handle the transaction.

### useRegisterStore

A React Hook that can be used to create a @wordpress/data store. This is the same as calling `registerStore()` but is easier to use within functional components because it will only create the store once. Only works within [CheckoutProvider](#CheckoutProvider).
Expand Down Expand Up @@ -534,9 +538,9 @@ A React Hook that returns an object with the following properties to be used by
- `previousTransactionStatus: `[`TransactionStatus.`](#TransactionStatus). The last status of the transaction.
- `transactionError: string | null`. The most recent error message encountered by the transaction if its status is [`.ERROR`](#TransactionStatus).
- `transactionRedirectUrl: string | null`. The redirect url to use if the transaction status is [`.REDIRECTING`](#TransactionStatus).
- `transactionLastResponse: object | null`. The most recent full response object as returned by the transaction's endpoint and passed to `setTransactionComplete`.
- `transactionLastResponse: unknown | null`. The most recent full response object as returned by the transaction's endpoint and passed to `setTransactionComplete`.
- `resetTransaction: () => void`. Function to change the transaction status to [`.NOT_STARTED`](#TransactionStatus).
- `setTransactionComplete: ( object ) => void`. Function to change the transaction status to [`.COMPLETE`](#TransactionStatus) and save the response object in `transactionLastResponse`.
- `setTransactionComplete: ( transactionResponse: unknown ) => void`. Function to change the transaction status to [`.COMPLETE`](#TransactionStatus) and save the response object in `transactionLastResponse`.
- `setTransactionError: ( string ) => void`. Function to change the transaction status to [`.ERROR`](#TransactionStatus) and save the error in `transactionError`.
- `setTransactionPending: () => void`. Function to change the transaction status to [`.PENDING`](#TransactionStatus).
- `setTransactionRedirecting: ( string ) => void`. Function to change the transaction status to [`.REDIRECTING`](#TransactionStatus) and save the redirect URL in `transactionRedirectUrl`.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,16 @@
/**
* External dependencies
*/
import React, { useCallback } from 'react';
import debugFactory from 'debug';
import React from 'react';
import { useI18n } from '@automattic/react-i18n';

/**
* Internal dependencies
*/
import joinClasses from '../lib/join-classes';
import {
usePaymentMethod,
usePaymentProcessors,
useTransactionStatus,
useFormStatus,
FormStatus,
} from '../public-api';
import { PaymentProcessorResponse, PaymentProcessorResponseType } from '../types';
import { useFormStatus, FormStatus } from '../public-api';
import CheckoutErrorBoundary from './checkout-error-boundary';

const debug = debugFactory( 'composite-checkout:checkout-submit-button' );
import { usePaymentMethod, useProcessPayment } from '../public-api';

export default function CheckoutSubmitButton( {
className,
Expand All @@ -30,63 +21,10 @@ export default function CheckoutSubmitButton( {
disabled?: boolean;
onLoadError?: ( error: string ) => void;
} ): JSX.Element | null {
const {
setTransactionComplete,
setTransactionRedirecting,
setTransactionError,
setTransactionPending,
} = useTransactionStatus();
const paymentProcessors = usePaymentProcessors();
const { formStatus } = useFormStatus();
const { __ } = useI18n();
const redirectErrorMessage = __(
'An error occurred while redirecting to the payment partner. Please try again or contact support.'
);
const isDisabled = disabled || formStatus !== FormStatus.READY;

const onClick = useCallback(
async ( paymentProcessorId: string, submitData: unknown ) => {
debug( 'beginning payment processor onClick handler' );
if ( ! paymentProcessors[ paymentProcessorId ] ) {
throw new Error( `No payment processor found with key: ${ paymentProcessorId }` );
}
setTransactionPending();
debug( 'calling payment processor function', paymentProcessorId );
return paymentProcessors[ paymentProcessorId ]( submitData )
.then( ( processorResponse: PaymentProcessorResponse ) => {
debug( 'payment processor function response', processorResponse );
if ( processorResponse.type === PaymentProcessorResponseType.REDIRECT ) {
if ( ! processorResponse.payload ) {
throw new Error( redirectErrorMessage );
}
setTransactionRedirecting( processorResponse.payload );
return processorResponse;
}
if ( processorResponse.type === PaymentProcessorResponseType.SUCCESS ) {
setTransactionComplete( processorResponse.payload );
return processorResponse;
}
if ( processorResponse.type === PaymentProcessorResponseType.MANUAL ) {
return processorResponse;
}
throw new Error(
`Unknown payment processor response for processor "${ paymentProcessorId }"`
);
} )
.catch( ( error: Error ) => {
debug( 'payment processor function error', error );
setTransactionError( error.message );
} );
},
[
redirectErrorMessage,
paymentProcessors,
setTransactionComplete,
setTransactionPending,
setTransactionRedirecting,
setTransactionError,
]
);
const onClick = useProcessPayment();

const paymentMethod = usePaymentMethod();
if ( ! paymentMethod ) {
Expand Down
108 changes: 108 additions & 0 deletions packages/composite-checkout/src/components/use-process-payment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* External dependencies
*/
import { useCallback, useMemo } from 'react';
import debugFactory from 'debug';
import { useI18n } from '@automattic/react-i18n';

/**
* Internal dependencies
*/
import { usePaymentProcessors, useTransactionStatus } from '../public-api';
import {
PaymentProcessorResponse,
PaymentProcessorResponseType,
SetTransactionComplete,
SetTransactionRedirecting,
ProcessPayment,
} from '../types';

const debug = debugFactory( 'composite-checkout:use-create-payment-processor-on-click' );

export default function useProcessPayment(): ProcessPayment {
const paymentProcessors = usePaymentProcessors();
const { setTransactionPending } = useTransactionStatus();
const handlePaymentProcessorPromise = useHandlePaymentProcessorResponse();

return useCallback(
async ( paymentProcessorId, submitData ) => {
debug( 'beginning payment processor onClick handler' );
if ( ! paymentProcessors[ paymentProcessorId ] ) {
throw new Error( `No payment processor found with key: ${ paymentProcessorId }` );
}
setTransactionPending();
debug( 'calling payment processor function', paymentProcessorId );
return handlePaymentProcessorPromise(
paymentProcessorId,
paymentProcessors[ paymentProcessorId ]( submitData )
);
},
[ handlePaymentProcessorPromise, paymentProcessors, setTransactionPending ]
);
}

function useHandlePaymentProcessorResponse() {
const { __ } = useI18n();
const redirectErrorMessage = useMemo(
() =>
__(
'An error occurred while redirecting to the payment partner. Please try again or contact support.'
),
[ __ ]
);
const {
setTransactionComplete,
setTransactionRedirecting,
setTransactionError,
} = useTransactionStatus();

return useCallback(
async (
paymentProcessorId: string,
processorPromise: Promise< PaymentProcessorResponse >
): Promise< PaymentProcessorResponse > => {
return processorPromise
.then( ( response ) =>
handlePaymentProcessorResponse( response, paymentProcessorId, redirectErrorMessage, {
setTransactionRedirecting,
setTransactionComplete,
} )
)
.catch( ( error: Error ) => {
setTransactionError( error.message );
throw error;
} );
},
[ redirectErrorMessage, setTransactionError, setTransactionComplete, setTransactionRedirecting ]
);
}

async function handlePaymentProcessorResponse(
processorResponse: PaymentProcessorResponse,
paymentProcessorId: string,
redirectErrorMessage: string,
{
setTransactionRedirecting,
setTransactionComplete,
}: {
setTransactionRedirecting: SetTransactionRedirecting;
setTransactionComplete: SetTransactionComplete;
}
): Promise< PaymentProcessorResponse > {
debug( 'payment processor function response', processorResponse );
if ( processorResponse.type === PaymentProcessorResponseType.REDIRECT ) {
if ( ! processorResponse.payload ) {
throw new Error( redirectErrorMessage );
}
setTransactionRedirecting( processorResponse.payload );
return processorResponse;
}
if ( processorResponse.type === PaymentProcessorResponseType.SUCCESS ) {
setTransactionComplete( processorResponse.payload );
return processorResponse;
}
if ( processorResponse.type === PaymentProcessorResponseType.MANUAL ) {
return processorResponse;
}
throw new Error( `Unknown payment processor response for processor "${ paymentProcessorId }"` );
}
2 changes: 2 additions & 0 deletions packages/composite-checkout/src/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import {
makeSuccessResponse,
makeRedirectResponse,
} from './lib/payment-processors';
import useProcessPayment from './components/use-process-payment';
import RadioButton from './components/radio-button';
import checkoutTheme from './lib/theme';
export * from './types';
Expand Down Expand Up @@ -155,6 +156,7 @@ export {
usePaymentMethodId,
usePaymentProcessor,
usePaymentProcessors,
useProcessPayment,
useRegisterStore,
useRegistry,
useSelect,
Expand Down
34 changes: 24 additions & 10 deletions packages/composite-checkout/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,7 @@ export interface PaymentProcessorProp {
[ key: string ]: PaymentProcessorFunction;
}

export type PaymentCompleteCallback = ( {
paymentMethodId,
transactionLastResponse,
}: PaymentCompleteCallbackArguments ) => void;
export type PaymentCompleteCallback = ( args: PaymentCompleteCallbackArguments ) => void;

export type PaymentCompleteCallbackArguments = {
paymentMethodId: string | null;
Expand All @@ -131,8 +128,10 @@ export type PaymentProcessorResponse =
| PaymentProcessorRedirect
| PaymentProcessorManual;

export type PaymentProcessorSubmitData = unknown;

export type PaymentProcessorFunction = (
submitData: unknown
submitData: PaymentProcessorSubmitData
) => Promise< PaymentProcessorResponse >;

export enum PaymentProcessorResponseType {
Expand Down Expand Up @@ -199,13 +198,28 @@ export type TransactionStatusPayload =
export type TransactionStatusAction = ReactStandardAction< 'STATUS_SET', TransactionStatusPayload >;

export interface TransactionStatusManager extends TransactionStatusState {
resetTransaction: () => void;
setTransactionError: ( message: string ) => void;
setTransactionComplete: ( response: PaymentProcessorResponseData ) => void;
setTransactionPending: () => void;
setTransactionRedirecting: ( url: string ) => void;
resetTransaction: ResetTransaction;
setTransactionError: SetTransactionError;
setTransactionComplete: SetTransactionComplete;
setTransactionPending: SetTransactionPending;
setTransactionRedirecting: SetTransactionRedirecting;
}

export type ProcessPayment = (
paymentProcessorId: string,
processorData: PaymentProcessorSubmitData
) => Promise< PaymentProcessorResponse >;

export type SetTransactionRedirecting = ( url: string ) => void;

export type SetTransactionPending = () => void;

export type SetTransactionComplete = ( response: PaymentProcessorResponseData ) => void;

export type SetTransactionError = ( message: string ) => void;

export type ResetTransaction = () => void;

export interface LineItemsState {
items: LineItem[];
total: LineItem;
Expand Down

0 comments on commit 100413f

Please sign in to comment.