Skip to content

Commit

Permalink
Checkout: Decouple existingCardProcessor from checkout (#48069)
Browse files Browse the repository at this point in the history
* WIP: Move existingCardProcessor to own file

* Include transactionOptions with dataForProcessor

Eventually we can get rid of transactionOptions entirely.

* Move transaction translate functions to translate-cart file

They are not types and belong outside the types file.

* Add CardProcessorOptions type

* Add stripeConfiguration to dataForProcessor

* Make TransactionRequestWithLineItems more permissive for falsy values

* Remove stripeConfiguration from ExistingCard payment method

* Finish existingCardProcessor

* Allow stripeConfiguration to be null in CardProcessorOptions

This should never happen in production but it's really hard to avoid it
in the type.

* Apply ExistingCardProcessorData type to processor function call

* Guard against missing stripeConfiguration in existingCardProcessor

* Move getDomainDetails to own file

* Allow siteId to be a number in TransactionRequestWithLineItems

* Remove createExistingCardMethod from composite-checkout README

* Move getPostalCode to own file

* Stop passing total to existing-card payment processor

* Add mergeIfObjects helper

* Call existingCardProcessor with additional data already included

* Make existingCardProcessor require all data as arguments

* Make sure siteId is always a string in the existingCardProcessor

* Change getDomainDetails to return undefined for missing data

* Make siteId a string or undefined in existingCardProcessor

* Disallow country being undefined in TransactionRequestWithLineItems

* Ensure all required fields are checked in isValidTransactionData

* Change mergeIfObjects to support an array of objects

* Change usage of mergeIfObjects to array

* Remove paymentMethodType from transactionData type as it is added later

* Remove saveTransactionResponseToWpcomStore from existingCardProcessor

* Change mergeIfObjects to accept variadic args rather than an array

* Change isValidTransactionData to throw specific errors

* Rename CardProcessorOptions to PaymentProcessorOptions

* Organize TransactionRequestWithLineItems properties

This also makes all optional props capable of holding `undefined`.

* Clarify requried properties in ExistingCardTransactionRequest

* Pass stripe/configuration directly to assignNewCardProcessor
  • Loading branch information
sirbrillig authored Dec 16, 2020
1 parent 100413f commit ebcc760
Show file tree
Hide file tree
Showing 14 changed files with 448 additions and 280 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ function ChangePaymentMethodList( {
const currentlyAssignedPaymentMethodId = 'existingCard-' + currentPaymentMethod.stored_details_id; // TODO: make this work for paypal.

const translate = useTranslate();
const { isStripeLoading } = useStripe();
const { isStripeLoading, stripe, stripeConfiguration } = useStripe();
const paymentMethods = useAssignablePaymentMethods();

const showErrorMessage = useCallback( ( error ) => {
Expand Down Expand Up @@ -218,7 +218,10 @@ function ChangePaymentMethodList( {
paymentProcessors={ {
'existing-card': ( data ) => assignExistingCardProcessor( purchase, data ),
card: ( data ) =>
assignNewCardProcessor( { purchase, translate, siteSlug, apiParams }, data ),
assignNewCardProcessor(
{ purchase, translate, siteSlug, apiParams, stripe, stripeConfiguration },
data
),
} }
isLoading={ isStripeLoading }
initiallySelectedPaymentMethodId={ currentlyAssignedPaymentMethodId }
Expand Down Expand Up @@ -252,8 +255,8 @@ async function assignExistingCardProcessor( purchase, { storedDetailsId } ) {
}

async function assignNewCardProcessor(
{ purchase, translate, siteSlug, apiParams },
{ stripe, stripeConfiguration, name, countryCode, postalCode }
{ purchase, translate, siteSlug, apiParams, stripe, stripeConfiguration },
{ name, countryCode, postalCode }
) {
const createStripeSetupIntentAsync = async ( paymentDetails ) => {
const { country, 'postal-code': zip } = paymentDetails;
Expand Down
52 changes: 47 additions & 5 deletions client/my-sites/checkout/composite-checkout/composite-checkout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,11 @@ import {
freePurchaseProcessor,
multiPartnerCardProcessor,
fullCreditsProcessor,
existingCardProcessor,
payPalProcessor,
genericRedirectProcessor,
weChatProcessor,
} from './payment-method-processors';
import existingCardProcessor from './lib/existing-card-processor';
import useGetThankYouUrl from './hooks/use-get-thank-you-url';
import createAnalyticsEventHandler from './record-analytics';
import { useProductVariants } from './hooks/product-variants';
Expand Down Expand Up @@ -84,6 +84,9 @@ import { WPCOMCartItem } from './types/checkout-cart';
import doesValueExist from './lib/does-value-exist';
import EmptyCart from './components/empty-cart';
import getContactDetailsType from './lib/get-contact-details-type';
import getDomainDetails from './lib/get-domain-details';
import getPostalCode from './lib/get-postal-code';
import mergeIfObjects from './lib/merge-if-objects';
import type { ReactStandardAction } from './types/analytics';
import useCreatePaymentCompleteCallback from './hooks/use-create-payment-complete-callback';

Expand Down Expand Up @@ -139,7 +142,6 @@ export default function CompositeCheckout( {
const isPrivate = useSelector( ( state ) => siteId && isPrivateSite( state, siteId ) ) || false;
const { stripe, stripeConfiguration, isStripeLoading, stripeLoadingError } = useStripe();
const createUserAndSiteBeforeTransaction = Boolean( isLoggedOutCart || isNoSiteCart );
const transactionOptions = { createUserAndSiteBeforeTransaction };
const reduxDispatch = useDispatch();
// eslint-disable-next-line react-hooks/exhaustive-deps
const recordEvent: ( action: ReactStandardAction ) => void = useCallback(
Expand Down Expand Up @@ -356,6 +358,7 @@ export default function CompositeCheckout( {

const contactInfo: ManagedContactDetails | undefined = select( 'wpcom' )?.getContactInfo();
const countryCode: string = contactInfo?.countryCode?.value ?? '';
const subdivisionCode: string = contactInfo?.state?.value ?? '';

const paymentMethods = arePaymentMethodsLoading
? []
Expand Down Expand Up @@ -416,13 +419,22 @@ export default function CompositeCheckout( {
const contactDetailsType = getContactDetailsType( responseCart );
const includeDomainDetails = contactDetailsType === 'domain';
const includeGSuiteDetails = contactDetailsType === 'gsuite';
const transactionOptions = { createUserAndSiteBeforeTransaction };
const dataForProcessor = useMemo(
() => ( {
includeDomainDetails,
includeGSuiteDetails,
recordEvent,
createUserAndSiteBeforeTransaction,
stripeConfiguration,
} ),
[ includeDomainDetails, includeGSuiteDetails, recordEvent ]
[
includeDomainDetails,
includeGSuiteDetails,
recordEvent,
createUserAndSiteBeforeTransaction,
stripeConfiguration,
]
);
const dataForRedirectProcessor = useMemo(
() => ( {
Expand All @@ -433,6 +445,16 @@ export default function CompositeCheckout( {
[ dataForProcessor, getThankYouUrl, siteSlug ]
);

const domainDetails = useMemo(
() =>
getDomainDetails( {
includeDomainDetails,
includeGSuiteDetails,
} ),
[ includeGSuiteDetails, includeDomainDetails ]
);
const postalCode = getPostalCode();

const paymentProcessors = useMemo(
() => ( {
'apple-pay': ( transactionData: unknown ) =>
Expand Down Expand Up @@ -466,15 +488,35 @@ export default function CompositeCheckout( {
'full-credits': ( transactionData: unknown ) =>
fullCreditsProcessor( transactionData, dataForProcessor, transactionOptions ),
'existing-card': ( transactionData: unknown ) =>
existingCardProcessor( transactionData, dataForProcessor, transactionOptions ),
existingCardProcessor(
mergeIfObjects( transactionData, {
country: countryCode,
postalCode,
subdivisionCode,
siteId: siteId ? String( siteId ) : undefined,
domainDetails,
} ),
dataForProcessor
),
paypal: ( transactionData: unknown ) =>
payPalProcessor(
transactionData,
{ ...dataForProcessor, getThankYouUrl, couponItem },
transactionOptions
),
} ),
[ couponItem, dataForProcessor, dataForRedirectProcessor, getThankYouUrl, transactionOptions ]
[
siteId,
couponItem,
dataForProcessor,
dataForRedirectProcessor,
getThankYouUrl,
transactionOptions,
countryCode,
subdivisionCode,
postalCode,
domainDetails,
]
);

const jetpackColors = isJetpackNotAtomic
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* External dependencies
*/
import debugFactory from 'debug';
import { makeSuccessResponse, makeRedirectResponse } from '@automattic/composite-checkout';
import { confirmStripePaymentIntent } from '@automattic/calypso-stripe';
import type { PaymentProcessorResponse } from '@automattic/composite-checkout';

/**
* Internal dependencies
*/
import { createTransactionEndpointRequestPayloadFromLineItems } from './translate-cart';
import { wpcomTransaction } from '../payment-method-helpers';
import type { PaymentProcessorOptions } from '../types/payment-processors';
import type { ExistingCardTransactionRequestWithLineItems } from '../types/transaction-endpoint';

const debug = debugFactory( 'calypso:composite-checkout:payment-method-helpers' );

export default async function existingCardProcessor(
transactionData: unknown,
dataForProcessor: PaymentProcessorOptions
): Promise< PaymentProcessorResponse > {
if ( ! isValidTransactionData( transactionData ) ) {
throw new Error( 'Required purchase data is missing' );
}
const { stripeConfiguration, recordEvent } = dataForProcessor;
if ( ! stripeConfiguration ) {
throw new Error( 'Stripe configuration is required' );
}
return submitExistingCardPayment( transactionData, dataForProcessor )
.then( ( stripeResponse ) => {
if ( stripeResponse?.message?.payment_intent_client_secret ) {
// 3DS authentication required
recordEvent( { type: 'SHOW_MODAL_AUTHORIZATION' } );
return confirmStripePaymentIntent(
stripeConfiguration,
stripeResponse?.message?.payment_intent_client_secret
);
}
return stripeResponse;
} )
.then( ( stripeResponse ) => {
if ( stripeResponse?.redirect_url ) {
return makeRedirectResponse( stripeResponse.redirect_url );
}
return makeSuccessResponse( stripeResponse );
} );
}

async function submitExistingCardPayment(
transactionData: ExistingCardTransactionRequest,
transactionOptions: PaymentProcessorOptions
) {
debug( 'formatting existing card transaction', transactionData );
const formattedTransactionData = createTransactionEndpointRequestPayloadFromLineItems( {
...transactionData,
paymentMethodType: 'WPCOM_Billing_MoneyPress_Stored',
} );
debug( 'submitting existing card transaction', formattedTransactionData );

return wpcomTransaction( formattedTransactionData, transactionOptions );
}

type ExistingCardTransactionRequest = Omit<
ExistingCardTransactionRequestWithLineItems,
'paymentMethodType'
>;

function isValidTransactionData(
submitData: unknown
): submitData is ExistingCardTransactionRequest {
const data = submitData as ExistingCardTransactionRequest;
if ( ! ( data?.items?.length > 0 ) ) {
throw new Error( 'Transaction requires items and none were provided' );
}
// Validate data required for this payment method type. Some other data may
// be required by the server but not required here since the server will give
// a better localized error message than we can provide.
if ( ! data.siteId ) {
throw new Error( 'Transaction requires siteId and none was provided' );
}
if ( ! data.country ) {
throw new Error( 'Transaction requires country code and none was provided' );
}
if ( ! data.postalCode ) {
throw new Error( 'Transaction requires postal code and none was provided' );
}
if ( ! data.storedDetailsId ) {
throw new Error( 'Transaction requires saved card information and none was provided' );
}
if ( ! data.name ) {
throw new Error( 'Transaction requires cardholder name and none was provided' );
}
if ( ! data.paymentMethodToken ) {
throw new Error( 'Transaction requires a Stripe token and none was provided' );
}
if ( ! data.paymentPartnerProcessorId ) {
throw new Error( 'Transaction requires a processor id and none was provided' );
}
return true;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* External dependencies
*/
import { defaultRegistry } from '@automattic/composite-checkout';
import type { DomainContactDetails } from '@automattic/shopping-cart';

/**
* Internal dependencies
*/
import { prepareDomainContactDetailsForTransaction } from 'calypso/my-sites/checkout/composite-checkout/types/wpcom-store-state';
import type { ManagedContactDetails } from '../types/wpcom-store-state';

const { select } = defaultRegistry;

export default function getDomainDetails( {
includeDomainDetails,
includeGSuiteDetails,
}: {
includeDomainDetails: boolean;
includeGSuiteDetails: boolean;
} ): DomainContactDetails | undefined {
const managedContactDetails: ManagedContactDetails | undefined = select(
'wpcom'
)?.getContactInfo();
if ( ! managedContactDetails ) {
return undefined;
}
const domainDetails = prepareDomainContactDetailsForTransaction( managedContactDetails );
return includeDomainDetails || includeGSuiteDetails ? domainDetails : undefined;
}
24 changes: 24 additions & 0 deletions client/my-sites/checkout/composite-checkout/lib/get-postal-code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* External dependencies
*/
import { defaultRegistry } from '@automattic/composite-checkout';

/**
* Internal dependencies
*/
import { tryToGuessPostalCodeFormat } from 'calypso/lib/postal-code';
import type { ManagedContactDetails } from '../types/wpcom-store-state';

const { select } = defaultRegistry;

export default function getPostalCode(): string {
const managedContactDetails: ManagedContactDetails | undefined = select(
'wpcom'
)?.getContactInfo();
if ( ! managedContactDetails ) {
return '';
}
const countryCode = managedContactDetails.countryCode?.value ?? '';
const postalCode = managedContactDetails.postalCode?.value ?? '';
return tryToGuessPostalCodeFormat( postalCode.toUpperCase(), countryCode );
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export default function mergeIfObjects( ...objects: unknown[] ): MergedObject {
return objects.reduce( ( merged: MergedObject, obj: unknown ): MergedObject => {
if ( typeof obj !== 'object' ) {
return merged;
}
return { ...merged, ...obj };
}, {} );
}

type MergedObject = Record< string, unknown >;
Loading

0 comments on commit ebcc760

Please sign in to comment.