diff --git a/packages/core/src/common/http-request/responses.mock.ts b/packages/core/src/common/http-request/responses.mock.ts index 38e4fb873f..6820209de6 100644 --- a/packages/core/src/common/http-request/responses.mock.ts +++ b/packages/core/src/common/http-request/responses.mock.ts @@ -2,7 +2,8 @@ import { Response } from '@bigcommerce/request-sender'; import { ErrorResponseBody } from '@bigcommerce/checkout-sdk/payment-integration-api'; -import { PaymentResponse } from '../../payment'; +import { GqlPaymentMethodResponse, PaymentResponse } from '../../payment'; +import { HeadlessPaymentMethod } from '../../payment/gql-payment'; export function getResponse( body: T, @@ -38,6 +39,27 @@ export function getPaymentResponse( }; } +export function getHeadlessPaymentResponse( + site: HeadlessPaymentMethod, + headers = {}, + status = 200, + statusText = 'OK', +): Response { + return { + body: { + data: { + site, + }, + }, + status, + statusText, + headers: { + 'content-type': 'application/json', + ...headers, + }, + }; +} + export function getErrorResponse( body = getErrorResponseBody(), headers = {}, diff --git a/packages/core/src/payment/gql-payment/gql-payment-method-config.ts b/packages/core/src/payment/gql-payment/gql-payment-method-config.ts new file mode 100644 index 0000000000..f718841b4a --- /dev/null +++ b/packages/core/src/payment/gql-payment/gql-payment-method-config.ts @@ -0,0 +1,9 @@ +import { GqlPaymentMethodType } from './gql-payment-method-type'; + +const GqlPaymentMethodConfig: Record = { + paypalcommerce: GqlPaymentMethodType.PAYPALCOMMERCE, + paypalcommercecredit: GqlPaymentMethodType.PAYPALCOMMERCECREDIT, + braintree: GqlPaymentMethodType.BRAINTREE, +}; + +export default GqlPaymentMethodConfig; diff --git a/packages/core/src/payment/gql-payment/gql-payment-method-response.ts b/packages/core/src/payment/gql-payment/gql-payment-method-response.ts new file mode 100644 index 0000000000..8f300d5f33 --- /dev/null +++ b/packages/core/src/payment/gql-payment/gql-payment-method-response.ts @@ -0,0 +1,7 @@ +import GqlPaymentMethod from './gql-payment-method'; + +export interface GqlPaymentMethodResponse { + data: { + site: GqlPaymentMethod; + }; +} diff --git a/packages/core/src/payment/gql-payment/gql-payment-method-type.ts b/packages/core/src/payment/gql-payment/gql-payment-method-type.ts new file mode 100644 index 0000000000..39f5a5628d --- /dev/null +++ b/packages/core/src/payment/gql-payment/gql-payment-method-type.ts @@ -0,0 +1,5 @@ +export enum GqlPaymentMethodType { + PAYPALCOMMERCE = 'paypalcommerce.paypal', + PAYPALCOMMERCECREDIT = 'paypalcommerce.paypalcredit', + BRAINTREE = 'braintree.paypal', +} diff --git a/packages/core/src/payment/gql-payment/gql-payment-method.ts b/packages/core/src/payment/gql-payment/gql-payment-method.ts new file mode 100644 index 0000000000..e84e2f69b4 --- /dev/null +++ b/packages/core/src/payment/gql-payment/gql-payment-method.ts @@ -0,0 +1,7 @@ +export default interface GqlPaymentMethod { + paymentWalletWithInitializationData: { + clientToken?: string; + // INFO:: initializationData given in base64 format + initializationData?: string; + }; +} diff --git a/packages/core/src/payment/gql-payment/gql-payment-request-options.ts b/packages/core/src/payment/gql-payment/gql-payment-request-options.ts new file mode 100644 index 0000000000..2cb6866323 --- /dev/null +++ b/packages/core/src/payment/gql-payment/gql-payment-request-options.ts @@ -0,0 +1,5 @@ +import { RequestOptions } from '../../common/http-request'; + +export default interface GqlPaymentRequestOptions extends RequestOptions { + body: { entityId: string }; +} diff --git a/packages/core/src/payment/gql-payment/index.ts b/packages/core/src/payment/gql-payment/index.ts new file mode 100644 index 0000000000..2b806c063d --- /dev/null +++ b/packages/core/src/payment/gql-payment/index.ts @@ -0,0 +1,6 @@ +export { default as GqlPaymentMethod } from './gql-payment-method'; +export { default as GqlPaymentMethodConfig } from './gql-payment-method-config'; +export { default as GqlPaymentRequestOptions } from './gql-payment-request-options'; + +export { GqlPaymentMethodType } from './gql-payment-method-type'; +export { GqlPaymentMethodResponse } from './gql-payment-method-response'; diff --git a/packages/core/src/payment/headless-payment-methods.mock.ts b/packages/core/src/payment/headless-payment-methods.mock.ts new file mode 100644 index 0000000000..e327ec0526 --- /dev/null +++ b/packages/core/src/payment/headless-payment-methods.mock.ts @@ -0,0 +1,19 @@ +import { GqlPaymentMethod } from './gql-payment'; + +export const initializationData = { + merchantId: '100000', + paymentButtonStyles: { + checkoutTopButtonStyles: { color: 'blue', label: 'checkout', height: '36' }, + }, +}; + +export const encodedInitializationData = btoa(JSON.stringify(initializationData)); + +export function getHeadlessPaymentMethod(): GqlPaymentMethod { + return { + paymentWalletWithInitializationData: { + clientToken: 'clientToken', + initializationData: encodedInitializationData, + }, + }; +} diff --git a/packages/core/src/payment/index.ts b/packages/core/src/payment/index.ts index 03b86f6463..2ed8364a98 100644 --- a/packages/core/src/payment/index.ts +++ b/packages/core/src/payment/index.ts @@ -18,6 +18,12 @@ export { default as isHostedInstrumentLike } from './is-hosted-intrument-like'; export { default as isNonceLike } from './is-nonce-like'; export { default as isVaultedInstrument } from './is-vaulted-instrument'; export { default as PaymentActionCreator } from './payment-action-creator'; +export { + GqlPaymentMethod, + GqlPaymentMethodConfig, + GqlPaymentRequestOptions, + GqlPaymentMethodResponse, +} from './gql-payment'; export { default as Payment, CreditCardInstrument, diff --git a/packages/core/src/payment/payment-method-action-creator.spec.ts b/packages/core/src/payment/payment-method-action-creator.spec.ts index cb1742d73f..07800c52cc 100644 --- a/packages/core/src/payment/payment-method-action-creator.spec.ts +++ b/packages/core/src/payment/payment-method-action-creator.spec.ts @@ -42,6 +42,11 @@ describe('PaymentMethodActionCreator', () => { Promise.resolve(paymentMethodsResponse), ); + jest.spyOn( + paymentMethodRequestSender, + 'loadPaymentWalletWithInitializationData', + ).mockReturnValue(Promise.resolve(paymentMethodResponse)); + jest.spyOn(store.getState().cart, 'getCartOrThrow').mockReturnValue(getCheckout().cart); }); @@ -195,6 +200,121 @@ describe('PaymentMethodActionCreator', () => { }); }); + describe('#loadPaymentWalletWithInitializationData()', () => { + it('loads payment wallet method', async () => { + const methodId = 'braintree'; + + await from( + paymentMethodActionCreator.loadPaymentWalletWithInitializationData(methodId)(store), + ).toPromise(); + + expect( + paymentMethodRequestSender.loadPaymentWalletWithInitializationData, + ).toHaveBeenCalledWith(methodId, undefined); + }); + + it('loads payment wallet method with timeout', async () => { + const methodId = 'braintree'; + const options = { + timeout: createTimeout(), + }; + + await from( + paymentMethodActionCreator.loadPaymentWalletWithInitializationData( + methodId, + options, + )(store), + ).toPromise(); + + expect( + paymentMethodRequestSender.loadPaymentWalletWithInitializationData, + ).toHaveBeenCalledWith(methodId, options); + }); + + it('emits actions if able to load payment wallet method', async () => { + const methodId = 'braintree'; + const actions = await from( + paymentMethodActionCreator.loadPaymentWalletWithInitializationData(methodId)(store), + ) + .pipe(toArray()) + .toPromise(); + + expect(actions).toEqual([ + { type: PaymentMethodActionType.LoadPaymentMethodRequested, meta: { methodId } }, + { + type: PaymentMethodActionType.LoadPaymentMethodSucceeded, + meta: { methodId }, + payload: paymentMethodResponse.body, + }, + ]); + }); + + it('emits actions with cached values if available', async () => { + const methodId = 'braintree'; + const options = { useCache: true }; + const actions = await merge( + from( + paymentMethodActionCreator.loadPaymentWalletWithInitializationData( + methodId, + options, + )(store), + ), + from( + paymentMethodActionCreator.loadPaymentWalletWithInitializationData( + methodId, + options, + )(store), + ), + ) + .pipe(toArray()) + .toPromise(); + + expect( + paymentMethodRequestSender.loadPaymentWalletWithInitializationData, + ).toHaveBeenCalledTimes(1); + expect(actions).toEqual([ + { type: PaymentMethodActionType.LoadPaymentMethodRequested, meta: { methodId } }, + { type: PaymentMethodActionType.LoadPaymentMethodRequested, meta: { methodId } }, + { + type: PaymentMethodActionType.LoadPaymentMethodSucceeded, + meta: { methodId }, + payload: paymentMethodResponse.body, + }, + { + type: PaymentMethodActionType.LoadPaymentMethodSucceeded, + meta: { methodId }, + payload: paymentMethodResponse.body, + }, + ]); + }); + + it('emits error actions if unable to load payment wallet method', async () => { + jest.spyOn( + paymentMethodRequestSender, + 'loadPaymentWalletWithInitializationData', + ).mockReturnValue(Promise.reject(errorResponse)); + + const methodId = 'braintree'; + const errorHandler = jest.fn((action) => of(action)); + const actions = await from( + paymentMethodActionCreator.loadPaymentWalletWithInitializationData(methodId)(store), + ) + .pipe(catchError(errorHandler), toArray()) + .toPromise(); + + expect(errorHandler).toHaveBeenCalled(); + expect(actions).toEqual([ + { type: PaymentMethodActionType.LoadPaymentMethodRequested, meta: { methodId } }, + { + type: PaymentMethodActionType.LoadPaymentMethodFailed, + meta: { methodId }, + payload: errorResponse, + error: true, + }, + ]); + }); + }); + describe('#loadPaymentMethodsByIds()', () => { it('loads payment methods data', async () => { const methodId = 'braintree'; diff --git a/packages/core/src/payment/payment-method-action-creator.ts b/packages/core/src/payment/payment-method-action-creator.ts index 68997b3fa6..453305d1bf 100644 --- a/packages/core/src/payment/payment-method-action-creator.ts +++ b/packages/core/src/payment/payment-method-action-creator.ts @@ -160,6 +160,43 @@ export default class PaymentMethodActionCreator { }); } + @cachableAction + loadPaymentWalletWithInitializationData( + methodId: string, + options?: RequestOptions & ActionOptions, + ): ThunkAction { + return () => + Observable.create((observer: Observer) => { + observer.next( + createAction(PaymentMethodActionType.LoadPaymentMethodRequested, undefined, { + methodId, + }), + ); + + this._requestSender + .loadPaymentWalletWithInitializationData(methodId, options) + .then((response) => { + observer.next( + createAction( + PaymentMethodActionType.LoadPaymentMethodSucceeded, + response.body, + { methodId }, + ), + ); + observer.complete(); + }) + .catch((response) => { + observer.error( + createErrorAction( + PaymentMethodActionType.LoadPaymentMethodFailed, + response, + { methodId }, + ), + ); + }); + }); + } + private _filterApplePay(methods: PaymentMethod[]): PaymentMethod[] { return filter(methods, (method) => { if (method.id === APPLEPAYID && !isApplePayWindow(window)) { diff --git a/packages/core/src/payment/payment-method-request-sender.spec.ts b/packages/core/src/payment/payment-method-request-sender.spec.ts index 430c14f5a0..1b7036de45 100644 --- a/packages/core/src/payment/payment-method-request-sender.spec.ts +++ b/packages/core/src/payment/payment-method-request-sender.spec.ts @@ -6,8 +6,10 @@ import { } from '@bigcommerce/request-sender'; import { ContentType, INTERNAL_USE_ONLY, SDK_VERSION_HEADERS } from '../common/http-request'; -import { getResponse } from '../common/http-request/responses.mock'; +import { getHeadlessPaymentResponse, getResponse } from '../common/http-request/responses.mock'; +import { GqlPaymentMethodResponse } from './gql-payment'; +import { getHeadlessPaymentMethod, initializationData } from './headless-payment-methods.mock'; import PaymentMethod from './payment-method'; import PaymentMethodRequestSender from './payment-method-request-sender'; import { getPaymentMethod, getPaymentMethods } from './payment-methods.mock'; @@ -136,4 +138,43 @@ describe('PaymentMethodRequestSender', () => { }); }); }); + + describe('#loadPaymentWalletWithInitializationData()', () => { + let response: Response; + + beforeEach(() => { + response = getHeadlessPaymentResponse(getHeadlessPaymentMethod()); + jest.spyOn(requestSender, 'post').mockReturnValue(Promise.resolve(response)); + }); + + it('loads headless payment method', async () => { + const walletInitData = + await paymentMethodRequestSender.loadPaymentWalletWithInitializationData( + 'paypalcommerce', + ); + + expect(requestSender.post).toHaveBeenCalledWith( + 'http://localhost/api/wallet-buttons/get-initialization-data', + expect.objectContaining({ + body: { + entityId: 'paypalcommerce.paypal', + }, + }), + ); + + expect(walletInitData).toEqual( + expect.objectContaining({ + body: { + initializationData, + clientToken: 'clientToken', + id: 'paypalcommerce', + config: {}, + method: '', + supportedCards: [], + type: 'PAYMENT_TYPE_API', + }, + }), + ); + }); + }); }); diff --git a/packages/core/src/payment/payment-method-request-sender.ts b/packages/core/src/payment/payment-method-request-sender.ts index b0fea224e5..ff39c00385 100644 --- a/packages/core/src/payment/payment-method-request-sender.ts +++ b/packages/core/src/payment/payment-method-request-sender.ts @@ -7,7 +7,14 @@ import { SDK_VERSION_HEADERS, } from '../common/http-request'; +import { + GqlPaymentMethodConfig, + GqlPaymentMethodResponse, + GqlPaymentMethodType, +} from './gql-payment'; +import GqlPaymentRequestOptions from './gql-payment/gql-payment-request-options'; import PaymentMethod from './payment-method'; +import paymentMethodTransformer from './payment-method-transformer'; export default class PaymentMethodRequestSender { constructor(private _requestSender: RequestSender) {} @@ -44,4 +51,35 @@ export default class PaymentMethodRequestSender { params, }); } + + /** + * Headless payment requests + */ + loadPaymentWalletWithInitializationData( + methodId: string, + { timeout }: RequestOptions = {}, + ): Promise> { + const url = `${window.location.origin}/api/wallet-buttons/get-initialization-data`; + + const requestOptions: GqlPaymentRequestOptions = { + body: { + entityId: this._getPaymentEntityId(methodId), + }, + timeout, + }; + + return this._requestSender + .post(url, requestOptions) + .then((response) => paymentMethodTransformer(response, methodId)); + } + + private _getPaymentEntityId(methodId: string): GqlPaymentMethodType { + const entityId = GqlPaymentMethodConfig[methodId]; + + if (!entityId) { + throw new Error('Unable to get payment entity id.'); + } + + return entityId; + } } diff --git a/packages/core/src/payment/payment-method-transformer.ts b/packages/core/src/payment/payment-method-transformer.ts new file mode 100644 index 0000000000..5f11f3a206 --- /dev/null +++ b/packages/core/src/payment/payment-method-transformer.ts @@ -0,0 +1,32 @@ +import { Response } from '@bigcommerce/request-sender'; + +import { GqlPaymentMethodResponse } from './gql-payment'; +import PaymentMethod from './payment-method'; + +export default function paymentMethodTransformer( + response: Response, + methodId: string, +): Response { + const { + body: { + data: { + site: { + paymentWalletWithInitializationData: { clientToken, initializationData }, + }, + }, + }, + } = response; + + return { + ...response, + body: { + initializationData: initializationData ? JSON.parse(atob(initializationData)) : null, + clientToken, + id: methodId, + config: {}, + method: '', + supportedCards: [], + type: 'PAYMENT_TYPE_API', + }, + }; +}