From 4f31e45ebc4dfb0c87f8b0105cb644a922ea36d8 Mon Sep 17 00:00:00 2001 From: bc-nick Date: Wed, 27 Nov 2024 21:56:00 +0100 Subject: [PATCH] feat(payment): PAYPAL-4867 POC of headless wallet buttons --- packages/core/auto-export.config.json | 5 + .../src/bundles/checkout-headless-button.ts | 3 + packages/core/src/cart/cart-action-creator.ts | 53 ++++ packages/core/src/cart/cart-actions.ts | 26 ++ packages/core/src/cart/cart-reducer.ts | 7 +- packages/core/src/cart/cart-request-sender.ts | 160 ++++++++++++ packages/core/src/cart/index.ts | 1 + .../checkout-button-initializer-options.ts | 2 + .../checkout-headless-button-initializer.ts | 152 ++++++++++++ ...headless-button-strategy-action-creator.ts | 110 +++++++++ ...te-checkout-headless-button-initializer.ts | 41 ++++ ...te-checkout-headless-button-registry-v2.ts | 45 ++++ packages/core/src/checkout-buttons/index.ts | 1 + packages/core/src/config/config-selector.ts | 14 ++ packages/core/src/config/config-state.ts | 2 + packages/core/src/loader-cdn.ts | 6 +- packages/core/src/loader.ts | 6 +- .../create-payment-integration-selectors.ts | 4 + .../create-payment-integration-service.ts | 5 +- ...efault-payment-integration-service.spec.ts | 4 +- .../default-payment-integration-service.ts | 14 +- .../payment/payment-method-action-creator.ts | 51 ++++ .../payment/payment-method-request-sender.ts | 104 ++++++++ .../src/payment-integration-selectors.ts | 4 + .../src/payment-integration-service.ts | 2 + .../payment-integration-service.mock.ts | 4 + ...ate-paypal-commerce-integration-service.ts | 4 +- .../paypal-commerce-integration/src/index.ts | 6 + ...ommerce-credit-headless-button-strategy.ts | 20 ++ ...edit-headless-button-initialize-options.ts | 20 ++ ...ommerce-credit-headless-button-strategy.ts | 228 ++++++++++++++++++ .../paypal-commerce-integration-service.ts | 34 +++ .../src/paypal-commerce-request-sender.ts | 79 ++++++ .../src/paypal-commerce-types.ts | 33 ++- ...aypal-commerce-headless-button-strategy.ts | 18 ++ ...erce-headless-button-initialize-options.ts | 7 + ...aypal-commerce-headless-button-strategy.ts | 203 ++++++++++++++++ webpack-common.config.js | 1 + 38 files changed, 1470 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/bundles/checkout-headless-button.ts create mode 100644 packages/core/src/cart/cart-action-creator.ts create mode 100644 packages/core/src/cart/cart-actions.ts create mode 100644 packages/core/src/checkout-buttons/checkout-headless-button-initializer.ts create mode 100644 packages/core/src/checkout-buttons/checkout-headless-button-strategy-action-creator.ts create mode 100644 packages/core/src/checkout-buttons/create-checkout-headless-button-initializer.ts create mode 100644 packages/core/src/checkout-buttons/create-checkout-headless-button-registry-v2.ts create mode 100644 packages/paypal-commerce-integration/src/paypal-commerce-credit/create-paypal-commerce-credit-headless-button-strategy.ts create mode 100644 packages/paypal-commerce-integration/src/paypal-commerce-credit/paypal-commerce-credit-headless-button-initialize-options.ts create mode 100644 packages/paypal-commerce-integration/src/paypal-commerce-credit/paypal-commerce-credit-headless-button-strategy.ts create mode 100644 packages/paypal-commerce-integration/src/paypal-commerce/create-paypal-commerce-headless-button-strategy.ts create mode 100644 packages/paypal-commerce-integration/src/paypal-commerce/paypal-commerce-headless-button-initialize-options.ts create mode 100644 packages/paypal-commerce-integration/src/paypal-commerce/paypal-commerce-headless-button-strategy.ts diff --git a/packages/core/auto-export.config.json b/packages/core/auto-export.config.json index 6ef0ba9e72..0c795f6eb8 100644 --- a/packages/core/auto-export.config.json +++ b/packages/core/auto-export.config.json @@ -14,6 +14,11 @@ "inputPath": "packages/*/src/index.ts", "outputPath": "packages/core/src/generated/checkout-button-strategies.ts", "memberPattern": "^create.+ButtonStrategy$" + }, + { + "inputPath": "packages/*/src/index.ts", + "outputPath": "packages/core/src/generated/checkout-headless-button-strategies.ts", + "memberPattern": "^create.+HeadlessButtonStrategy$" } ] } diff --git a/packages/core/src/bundles/checkout-headless-button.ts b/packages/core/src/bundles/checkout-headless-button.ts new file mode 100644 index 0000000000..0c4b1d7236 --- /dev/null +++ b/packages/core/src/bundles/checkout-headless-button.ts @@ -0,0 +1,3 @@ +export { createTimeout } from '@bigcommerce/request-sender'; + +export { createCheckoutHeadlessButtonInitializer } from '../checkout-buttons'; diff --git a/packages/core/src/cart/cart-action-creator.ts b/packages/core/src/cart/cart-action-creator.ts new file mode 100644 index 0000000000..a8d81aab18 --- /dev/null +++ b/packages/core/src/cart/cart-action-creator.ts @@ -0,0 +1,53 @@ +import { createAction, createErrorAction, ThunkAction } from '@bigcommerce/data-store'; +import { Observable, Observer } from 'rxjs'; + +import { RequestOptions } from '@bigcommerce/checkout-sdk/payment-integration-api'; + +import { InternalCheckoutSelectors } from '../checkout'; +import { cachableAction } from '../common/data-store'; +import ActionOptions from '../common/data-store/action-options'; +import { MissingDataError, MissingDataErrorType } from '../common/error/errors'; + +import { CartActionType, LoadCartAction } from './cart-actions'; +import CartRequestSender from './cart-request-sender'; + +export default class CartActionCreator { + constructor(private _cartRequestSender: CartRequestSender) {} + + @cachableAction + loadCardEntity( + cartId: string, + options?: RequestOptions & ActionOptions, + ): ThunkAction { + return (store) => { + return Observable.create((observer: Observer) => { + const state = store.getState(); + const jwtToken = state.config.getStorefrontJwtToken(); + + if (!jwtToken) { + throw new MissingDataError(MissingDataErrorType.MissingPaymentToken); + } + + observer.next(createAction(CartActionType.LoadCartRequested, undefined)); + + this._cartRequestSender + .loadCardEntity(cartId, { + headers: { + Authorization: `Bearer ${jwtToken}`, + 'Content-Type': 'application/json', + }, + ...options, + }) + .then((response) => { + observer.next( + createAction(CartActionType.LoadCartSucceeded, response.body), + ); + observer.complete(); + }) + .catch((response) => { + observer.error(createErrorAction(CartActionType.LoadCartFailed, response)); + }); + }); + }; + } +} diff --git a/packages/core/src/cart/cart-actions.ts b/packages/core/src/cart/cart-actions.ts new file mode 100644 index 0000000000..c7e95a9b42 --- /dev/null +++ b/packages/core/src/cart/cart-actions.ts @@ -0,0 +1,26 @@ +import { Action } from '@bigcommerce/data-store'; + +import Cart from './cart'; + +export enum CartActionType { + LoadCartRequested = 'LOAD_CART_REQUESTED', + LoadCartSucceeded = 'LOAD_CART_SUCCEEDED', + LoadCartFailed = 'LOAD_CART_FAILED', +} + +export type LoadCartAction = + | LoadCartRequestedAction + | LoadCartSucceededAction + | LoadCartFailedAction; + +export interface LoadCartRequestedAction extends Action { + type: CartActionType.LoadCartRequested; +} + +export interface LoadCartSucceededAction extends Action { + type: CartActionType.LoadCartSucceeded; +} + +export interface LoadCartFailedAction extends Action { + type: CartActionType.LoadCartFailed; +} diff --git a/packages/core/src/cart/cart-reducer.ts b/packages/core/src/cart/cart-reducer.ts index e86545ebab..68bfeddf05 100644 --- a/packages/core/src/cart/cart-reducer.ts +++ b/packages/core/src/cart/cart-reducer.ts @@ -13,6 +13,7 @@ import { import { ConsignmentAction, ConsignmentActionType } from '../shipping'; import Cart from './cart'; +import { CartActionType, LoadCartAction } from './cart-actions'; import CartState, { CartErrorsState, CartStatusesState, DEFAULT_STATE } from './cart-state'; export default function cartReducer(state: CartState = DEFAULT_STATE, action: Action): CartState { @@ -32,7 +33,8 @@ function dataReducer( | CheckoutAction | ConsignmentAction | CouponAction - | GiftCertificateAction, + | GiftCertificateAction + | LoadCartAction, ): Cart | undefined { switch (action.type) { case BillingAddressActionType.UpdateBillingAddressSucceeded: @@ -48,6 +50,9 @@ function dataReducer( case GiftCertificateActionType.RemoveGiftCertificateSucceeded: return objectMerge(data, action.payload && action.payload.cart); + case CartActionType.LoadCartSucceeded: + return objectMerge(data, action.payload && action.payload); + default: return data; } diff --git a/packages/core/src/cart/cart-request-sender.ts b/packages/core/src/cart/cart-request-sender.ts index ad4e54de45..9fb1d68f06 100644 --- a/packages/core/src/cart/cart-request-sender.ts +++ b/packages/core/src/cart/cart-request-sender.ts @@ -4,6 +4,34 @@ import { BuyNowCartRequestBody, Cart } from '@bigcommerce/checkout-sdk/payment-i import { ContentType, RequestOptions, SDK_VERSION_HEADERS } from '../common/http-request'; +import { LineItemMap } from './index'; + +interface LoadCartRequestOptions extends RequestOptions { + body?: { query: string }; + headers: { Authorization: string; [key: string]: string }; +} + +interface LoadCartResponse { + data: { + site: { + cart: { + amount: { + currencyCode: string; + }; + entityId: string; + lineItems: { + physicalItems: Array<{ + name: string; + entityId: string; + quantity: string; + productEntityId: string; + }>; + }; + }; + }; + }; +} + export default class CartRequestSender { constructor(private _requestSender: RequestSender) {} @@ -19,4 +47,136 @@ export default class CartRequestSender { return this._requestSender.post(url, { body, headers, timeout }); } + + loadCardEntity(cartId: string, options: LoadCartRequestOptions): Promise> { + const url = `/graphql`; + + const graphQLQuery = ` + query { + site { + cart(entityId: "${cartId}") { + currencyCode + entityId + id + isTaxIncluded + discounts { + entityId + discountedAmount { + currencyCode + value + } + } + discountedAmount { + currencyCode + value + } + baseAmount { + currencyCode + value + } + amount { + currencyCode + value + } + lineItems { + physicalItems { + brand + couponAmount { + value + } + discountedAmount { + value + } + discounts { + discountedAmount { + value + } + entityId + } + extendedListPrice { + value + } + extendedSalePrice { + value + } + giftWrapping { + amount { + value + } + message + name + } + isShippingRequired + isTaxable + listPrice { + value + } + name + originalPrice { + value + } + entityId + quantity + salePrice { + value + } + sku + url + } + } + } + } + }`; + + const requestOptions: LoadCartRequestOptions = { + ...options, + headers: { + ...options.headers, + 'Content-Type': 'application/json', + }, + body: { + query: graphQLQuery, + }, + }; + + return this._requestSender + .post(url, { + ...requestOptions, + }) + .then(this.transformToCartResponse); + } + + private transformToCartResponse(response: Response): Response { + const { + body: { + data: { + site: { + cart: { amount, entityId, lineItems }, + }, + }, + }, + } = response; + + const mappedLineItems: LineItemMap = { + // @ts-ignore + physicalItems: lineItems.physicalItems.map((item) => ({ + id: item.entityId, + name: item.name, + quantity: item.quantity, + productId: item.productEntityId, + })), + }; + + return { + ...response, + body: { + id: entityId, + // @ts-ignore + currency: { + code: amount.currencyCode, + }, + lineItems: mappedLineItems, + }, + }; + } } diff --git a/packages/core/src/cart/index.ts b/packages/core/src/cart/index.ts index 02e2f02cbf..f11cfc5c50 100644 --- a/packages/core/src/cart/index.ts +++ b/packages/core/src/cart/index.ts @@ -14,6 +14,7 @@ export { default as LineItemMap } from './line-item-map'; export { default as CartComparator } from './cart-comparator'; export { default as CartRequestSender } from './cart-request-sender'; export { default as cartReducer } from './cart-reducer'; +export { default as CartActionCreator } from './cart-action-creator'; export { default as CartSelector, CartSelectorFactory, diff --git a/packages/core/src/checkout-buttons/checkout-button-initializer-options.ts b/packages/core/src/checkout-buttons/checkout-button-initializer-options.ts index 4bca8b55be..69ecc33a69 100644 --- a/packages/core/src/checkout-buttons/checkout-button-initializer-options.ts +++ b/packages/core/src/checkout-buttons/checkout-button-initializer-options.ts @@ -1,4 +1,6 @@ export default interface CheckoutButtonInitializerOptions { host?: string; locale?: string; + storefrontJwtToken?: string; + siteLink?: string; } diff --git a/packages/core/src/checkout-buttons/checkout-headless-button-initializer.ts b/packages/core/src/checkout-buttons/checkout-headless-button-initializer.ts new file mode 100644 index 0000000000..f1dc6bf750 --- /dev/null +++ b/packages/core/src/checkout-buttons/checkout-headless-button-initializer.ts @@ -0,0 +1,152 @@ +import { bindDecorator as bind } from '@bigcommerce/checkout-sdk/utility'; + +import { CheckoutStore, InternalCheckoutSelectors } from '../checkout'; +import { isElementId, setUniqueElementId } from '../common/dom'; + +import { CheckoutButtonInitializeOptions, CheckoutButtonOptions } from './checkout-button-options'; +import CheckoutButtonSelectors from './checkout-button-selectors'; +import CheckoutHeadlessButtonStrategyActionCreator from './checkout-headless-button-strategy-action-creator'; +import createCheckoutButtonSelectors from './create-checkout-button-selectors'; + +@bind +export default class CheckoutHeadlessButtonInitializer { + private _state: CheckoutButtonSelectors; + + /** + * @internal + */ + constructor( + private _store: CheckoutStore, + private _checkoutHeadlessButtonStrategyActionCreator: CheckoutHeadlessButtonStrategyActionCreator, + ) { + this._state = createCheckoutButtonSelectors(this._store.getState()); + + this._store.subscribe((state) => { + this._state = createCheckoutButtonSelectors(state); + }); + } + + /** + * Returns a snapshot of the current state. + * + * The method returns a new instance every time there is a change in the + * state. You can query the state by calling any of its getter methods. + * + * ```js + * const state = service.getState(); + * + * console.log(state.errors.getInitializeButtonError()); + * console.log(state.statuses.isInitializingButton()); + * ``` + * + * @returns The current customer's checkout state + */ + getState(): CheckoutButtonSelectors { + return this._state; + } + + /** + * Subscribes to any changes to the current state. + * + * The method registers a callback function and executes it every time there + * is a change in the current state. + * + * ```js + * service.subscribe(state => { + * console.log(state.statuses.isInitializingButton()); + * }); + * ``` + * + * The method can be configured to notify subscribers only regarding + * relevant changes, by providing a filter function. + * + * ```js + * const filter = state => state.errors.getInitializeButtonError(); + * + * // Only trigger the subscriber when the cart changes. + * service.subscribe(state => { + * console.log(state.errors.getInitializeButtonError()) + * }, filter); + * ``` + * + * @param subscriber - The function to subscribe to state changes. + * @param filters - One or more functions to filter out irrelevant state + * changes. If more than one function is provided, the subscriber will only + * be triggered if all conditions are met. + * @returns A function, if called, will unsubscribe the subscriber. + */ + subscribe( + subscriber: (state: CheckoutButtonSelectors) => void, + ...filters: Array<(state: CheckoutButtonSelectors) => any> + ): () => void { + return this._store.subscribe( + () => subscriber(this.getState()), + (state) => state.checkoutButton.getState(), + ...filters.map( + (filter) => (state: InternalCheckoutSelectors) => + filter(createCheckoutButtonSelectors(state)), + ), + ); + } + + /** + * Initializes the headless wallet button of a payment method. + * + * When the headless wallet button is initialized, it will be inserted into the DOM, + * ready to be interacted with by the customer. + * + * ```js + * initializer.initializeHeadlessButton({ + * methodId: 'braintreepaypal', + * containerId: 'headlessWalletButton', + * braintreepaypal: { + * }, + * }); + * ``` + * + * @param options - Options for initializing the checkout button. + * @returns A promise that resolves to the current state. + */ + initializeHeadlessButton( + options: CheckoutButtonInitializeOptions, + ): Promise { + const containerIds = this.getContainerIds(options); + + return Promise.all( + containerIds.map((containerId) => { + const action = this._checkoutHeadlessButtonStrategyActionCreator.initialize({ + ...options, + containerId, + }); + const queueId = `checkoutHeadlessButtonStrategy:${options.methodId}:${containerId}`; + + return this._store.dispatch(action, { queueId }); + }), + ).then(() => this.getState()); + } + + /** + * De-initializes the checkout button by performing any necessary clean-ups. + * + * ```js + * await service.deinitializeHeadlessButton({ + * methodId: 'braintreepaypal', + * }); + * ``` + * + * @param options - Options for deinitializing the checkout button. + * @returns A promise that resolves to the current state. + */ + deinitializeHeadlessButton(options: CheckoutButtonOptions): Promise { + const action = this._checkoutHeadlessButtonStrategyActionCreator.deinitialize(options); + const queueId = `checkoutHeadlessButtonStrategy:${options.methodId}`; + + return this._store.dispatch(action, { queueId }).then(() => this.getState()); + } + + private getContainerIds(options: CheckoutButtonInitializeOptions) { + return isElementId(options.containerId) + ? [options.containerId] + : setUniqueElementId(options.containerId, `${options.methodId}-container`); + } +} diff --git a/packages/core/src/checkout-buttons/checkout-headless-button-strategy-action-creator.ts b/packages/core/src/checkout-buttons/checkout-headless-button-strategy-action-creator.ts new file mode 100644 index 0000000000..efb371a3e8 --- /dev/null +++ b/packages/core/src/checkout-buttons/checkout-headless-button-strategy-action-creator.ts @@ -0,0 +1,110 @@ +import { createAction, ThunkAction } from '@bigcommerce/data-store'; +import { concat, defer, empty, of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { InternalCheckoutSelectors } from '../checkout'; +import { throwErrorAction } from '../common/error'; +import { PaymentMethodActionCreator } from '../payment'; + +import { + CheckoutButtonActionType, + DeinitializeButtonAction, + InitializeButtonAction, +} from './checkout-button-actions'; +import { CheckoutButtonInitializeOptions, CheckoutButtonOptions } from './checkout-button-options'; +import CheckoutButtonRegistryV2 from './checkout-button-strategy-registry-v2'; + +export default class CheckoutHeadlessButtonStrategyActionCreator { + constructor( + private _registryV2: CheckoutButtonRegistryV2, + private _paymentMethodActionCreator: PaymentMethodActionCreator, + ) {} + + initialize( + options: CheckoutButtonInitializeOptions, + ): ThunkAction { + return (store) => { + const meta = { + methodId: options.methodId, + containerId: options.containerId, + }; + + if ( + store.getState().checkoutButton.isInitialized(options.methodId, options.containerId) + ) { + return empty(); + } + + return concat( + of( + createAction( + CheckoutButtonActionType.InitializeButtonRequested, + undefined, + meta, + ), + ), + this._paymentMethodActionCreator.loadPaymentWalletWithInitializationData( + options.methodId, + )(store), + defer(() => + this._registryV2 + .get({ id: options.methodId }) + .initialize(options) + .then(() => + createAction( + CheckoutButtonActionType.InitializeButtonSucceeded, + undefined, + meta, + ), + ), + ), + ).pipe( + catchError((error) => + throwErrorAction(CheckoutButtonActionType.InitializeButtonFailed, error, meta), + ), + ); + }; + } + + deinitialize( + options: CheckoutButtonOptions, + ): ThunkAction { + return (store) => { + const meta = { methodId: options.methodId }; + + if (!store.getState().checkoutButton.isInitialized(options.methodId)) { + return empty(); + } + + return concat( + of( + createAction( + CheckoutButtonActionType.DeinitializeButtonRequested, + undefined, + meta, + ), + ), + defer(() => + this._registryV2 + .get({ id: options.methodId }) + .deinitialize() + .then(() => + createAction( + CheckoutButtonActionType.DeinitializeButtonSucceeded, + undefined, + meta, + ), + ), + ), + ).pipe( + catchError((error) => + throwErrorAction( + CheckoutButtonActionType.DeinitializeButtonFailed, + error, + meta, + ), + ), + ); + }; + } +} diff --git a/packages/core/src/checkout-buttons/create-checkout-headless-button-initializer.ts b/packages/core/src/checkout-buttons/create-checkout-headless-button-initializer.ts new file mode 100644 index 0000000000..52f9af4344 --- /dev/null +++ b/packages/core/src/checkout-buttons/create-checkout-headless-button-initializer.ts @@ -0,0 +1,41 @@ +import { createRequestSender } from '@bigcommerce/request-sender'; + +import { createCheckoutStore } from '../checkout'; +import { ConfigState } from '../config'; +import { PaymentMethodActionCreator, PaymentMethodRequestSender } from '../payment'; +import { createPaymentIntegrationService } from '../payment-integration'; + +import CheckoutButtonInitializerOptions from './checkout-button-initializer-options'; +import CheckoutHeadlessButtonInitializer from './checkout-headless-button-initializer'; +import CheckoutHeadlessButtonStrategyActionCreator from './checkout-headless-button-strategy-action-creator'; +import createCheckoutHeadlessButtonRegistryV2 from './create-checkout-headless-button-registry-v2'; + +export default function createCheckoutHeadlessButtonInitializer( + options?: CheckoutButtonInitializerOptions, +): CheckoutHeadlessButtonInitializer { + const { host, locale = 'en', storefrontJwtToken, siteLink } = options ?? {}; + + const config: ConfigState = { + meta: { + host, + locale, + storefrontJwtToken, + siteLink, + }, + errors: {}, + statuses: {}, + }; + + const store = createCheckoutStore({ config }); + const requestSender = createRequestSender({ host }); + const paymentIntegrationService = createPaymentIntegrationService(store); + const registryV2 = createCheckoutHeadlessButtonRegistryV2(paymentIntegrationService); + + return new CheckoutHeadlessButtonInitializer( + store, + new CheckoutHeadlessButtonStrategyActionCreator( + registryV2, + new PaymentMethodActionCreator(new PaymentMethodRequestSender(requestSender)), + ), + ); +} diff --git a/packages/core/src/checkout-buttons/create-checkout-headless-button-registry-v2.ts b/packages/core/src/checkout-buttons/create-checkout-headless-button-registry-v2.ts new file mode 100644 index 0000000000..087484b7af --- /dev/null +++ b/packages/core/src/checkout-buttons/create-checkout-headless-button-registry-v2.ts @@ -0,0 +1,45 @@ +import { + CheckoutButtonStrategy, + CheckoutButtonStrategyFactory, + CheckoutButtonStrategyResolveId, + isResolvableModule, + PaymentIntegrationService, +} from '@bigcommerce/checkout-sdk/payment-integration-api'; + +import { ResolveIdRegistry } from '../common/registry'; +import * as defaultCheckoutHeadlessButtonStrategyFactories from '../generated/checkout-headless-button-strategies'; + +export interface CheckoutButtonStrategyFactories { + [key: string]: CheckoutButtonStrategyFactory; +} + +export default function createCheckoutHeadlessButtonStrategyRegistry( + paymentIntegrationService: PaymentIntegrationService, + checkoutButtonHeadlessStrategyFactories: CheckoutButtonStrategyFactories = defaultCheckoutHeadlessButtonStrategyFactories, +): ResolveIdRegistry { + const registry = new ResolveIdRegistry< + CheckoutButtonStrategy, + CheckoutButtonStrategyResolveId + >(); + + for (const [, createCheckoutButtonStrategy] of Object.entries( + checkoutButtonHeadlessStrategyFactories, + )) { + if ( + !isResolvableModule< + CheckoutButtonStrategyFactory, + CheckoutButtonStrategyResolveId + >(createCheckoutButtonStrategy) + ) { + continue; + } + + for (const resolverId of createCheckoutButtonStrategy.resolveIds) { + registry.register(resolverId, () => + createCheckoutButtonStrategy(paymentIntegrationService), + ); + } + } + + return registry; +} diff --git a/packages/core/src/checkout-buttons/index.ts b/packages/core/src/checkout-buttons/index.ts index f5c7d2bf56..dbfe2ace4d 100644 --- a/packages/core/src/checkout-buttons/index.ts +++ b/packages/core/src/checkout-buttons/index.ts @@ -1,4 +1,5 @@ export { default as createCheckoutButtonInitializer } from './create-checkout-button-initializer'; +export { default as createCheckoutHeadlessButtonInitializer } from './create-checkout-headless-button-initializer'; export { default as checkoutButtonReducer } from './checkout-button-reducer'; export { default as CheckoutButtonSelector, diff --git a/packages/core/src/config/config-selector.ts b/packages/core/src/config/config-selector.ts index b1f94f4244..0bc617b72b 100644 --- a/packages/core/src/config/config-selector.ts +++ b/packages/core/src/config/config-selector.ts @@ -18,6 +18,8 @@ export default interface ConfigSelector { getHost(): string | undefined; getLocale(): string | undefined; getVariantIdentificationToken(): string | undefined; + getStorefrontJwtToken(): string | undefined; + getSiteLink(): string | undefined; getLoadError(): Error | undefined; isLoading(): boolean; } @@ -100,6 +102,16 @@ export function createConfigSelectorFactory(): ConfigSelectorFactory { (data) => () => data, ); + const getStorefrontJwtToken = createSelector( + (state: ConfigState) => state.meta && state.meta.storefrontJwtToken, + (data) => () => data, + ); + + const getSiteLink = createSelector( + (state: ConfigState) => state.meta && state.meta.siteLink, + (data) => () => data, + ); + const getLoadError = createSelector( (state: ConfigState) => state.errors.loadError, (error) => () => error, @@ -122,6 +134,8 @@ export function createConfigSelectorFactory(): ConfigSelectorFactory { getHost: getHost(state), getLocale: getLocale(state), getVariantIdentificationToken: getVariantIdentificationToken(state), + getStorefrontJwtToken: getStorefrontJwtToken(state), + getSiteLink: getSiteLink(state), getLoadError: getLoadError(state), isLoading: isLoading(state), }; diff --git a/packages/core/src/config/config-state.ts b/packages/core/src/config/config-state.ts index 15ded51e82..04daac07a9 100644 --- a/packages/core/src/config/config-state.ts +++ b/packages/core/src/config/config-state.ts @@ -12,6 +12,8 @@ export interface ConfigMetaState { variantIdentificationToken?: string; host?: string; locale?: string; + storefrontJwtToken?: string; + siteLink?: string; } export interface ConfigErrorsState { diff --git a/packages/core/src/loader-cdn.ts b/packages/core/src/loader-cdn.ts index 2c0c1f8249..2e5beff74a 100644 --- a/packages/core/src/loader-cdn.ts +++ b/packages/core/src/loader-cdn.ts @@ -2,18 +2,21 @@ import { getScriptLoader } from '@bigcommerce/script-loader'; import 'current-script-polyfill'; import * as checkoutButtonBundle from './bundles/checkout-button'; +import * as checkoutHeadlessButtonBundle from './bundles/checkout-headless-button'; import * as mainBundle from './bundles/checkout-sdk'; import * as embeddedCheckoutBundle from './bundles/embedded-checkout'; import * as hostedFormBundle from './bundles/hosted-form'; import { parseUrl } from './common/url'; export type CheckoutButtonBundle = typeof checkoutButtonBundle & { version: string }; +export type CheckoutHeadlessButtonBundle = typeof checkoutHeadlessButtonBundle & { version: string }; export type EmbeddedCheckoutBundle = typeof embeddedCheckoutBundle & { version: string }; export type HostedFormBundle = typeof hostedFormBundle & { version: string }; export type MainBundle = typeof mainBundle & { version: string }; export enum BundleType { CheckoutButton = 'checkout-button', + CheckoutHeadlessButton = 'checkout-headless-button', EmbeddedCheckout = 'embedded-checkout', HostedForm = 'hosted-form', Main = 'checkout-sdk', @@ -25,12 +28,13 @@ const scriptOrigin = isScriptElement(document.currentScript) export function load(moduleName?: BundleType.Main): Promise; export function load(moduleName: BundleType.CheckoutButton): Promise; +export function load(moduleName: BundleType.CheckoutHeadlessButton): Promise; export function load(moduleName: BundleType.EmbeddedCheckout): Promise; export function load(moduleName: BundleType.HostedForm): Promise; export async function load( moduleName: string = BundleType.Main, -): Promise { +): Promise { const { version, js } = MANIFEST_JSON; const manifestPath = js.find((path) => path.indexOf(moduleName) !== -1); diff --git a/packages/core/src/loader.ts b/packages/core/src/loader.ts index 81140d9ee9..05ca031e60 100644 --- a/packages/core/src/loader.ts +++ b/packages/core/src/loader.ts @@ -1,17 +1,20 @@ import { getScriptLoader } from '@bigcommerce/script-loader'; import * as checkoutButtonBundle from './bundles/checkout-button'; +import * as checkoutHeadlessButtonBundle from './bundles/checkout-headless-button'; import * as mainBundle from './bundles/checkout-sdk'; import * as embeddedCheckoutBundle from './bundles/embedded-checkout'; import * as hostedFormBundle from './bundles/hosted-form'; export type CheckoutButtonBundle = typeof checkoutButtonBundle & { version: string }; +export type CheckoutHeadlessButtonBundle = typeof checkoutHeadlessButtonBundle & { version: string }; export type EmbeddedCheckoutBundle = typeof embeddedCheckoutBundle & { version: string }; export type HostedFormBundle = typeof hostedFormBundle & { version: string }; export type MainBundle = typeof mainBundle & { version: string }; export enum BundleType { CheckoutButton = 'checkout-button', + CheckoutHeadlessButton = 'checkout-headless-button', EmbeddedCheckout = 'embedded-checkout', HostedForm = 'hosted-form', Main = 'checkout-sdk', @@ -19,12 +22,13 @@ export enum BundleType { export function load(moduleName?: BundleType.Main): Promise; export function load(moduleName: BundleType.CheckoutButton): Promise; +export function load(moduleName: BundleType.CheckoutHeadlessButton): Promise; export function load(moduleName: BundleType.EmbeddedCheckout): Promise; export function load(moduleName: BundleType.HostedForm): Promise; export async function load( moduleName: string = BundleType.Main, -): Promise { +): Promise { const { version, js } = MANIFEST_JSON; const manifestPath = js.find((path) => path.indexOf(moduleName) !== -1); diff --git a/packages/core/src/payment-integration/create-payment-integration-selectors.ts b/packages/core/src/payment-integration/create-payment-integration-selectors.ts index 5a1711fcb1..f4a5934578 100644 --- a/packages/core/src/payment-integration/create-payment-integration-selectors.ts +++ b/packages/core/src/payment-integration/create-payment-integration-selectors.ts @@ -14,6 +14,8 @@ export default function createPaymentIntegrationSelectors({ getStoreConfig, getStoreConfigOrThrow, getConfig, + getStorefrontJwtToken, + getSiteLink, }, consignments: { getConsignments, getConsignmentsOrThrow }, countries: { getCountries }, @@ -80,6 +82,8 @@ export default function createPaymentIntegrationSelectors({ getPaymentStatusOrThrow, getPaymentRedirectUrl, getPaymentRedirectUrlOrThrow, + getStorefrontJwtToken, + getSiteLink, getPaymentMethod: clone(getPaymentMethod), getPaymentMethodOrThrow: clone(getPaymentMethodOrThrow), getPaymentMethodsMeta: clone(getPaymentMethodsMeta), diff --git a/packages/core/src/payment-integration/create-payment-integration-service.ts b/packages/core/src/payment-integration/create-payment-integration-service.ts index 607cc8ce69..94d0f2ec27 100644 --- a/packages/core/src/payment-integration/create-payment-integration-service.ts +++ b/packages/core/src/payment-integration/create-payment-integration-service.ts @@ -4,7 +4,7 @@ import { createScriptLoader } from '@bigcommerce/script-loader'; import { PaymentIntegrationService } from '@bigcommerce/checkout-sdk/payment-integration-api'; import { BillingAddressActionCreator, BillingAddressRequestSender } from '../billing'; -import { CartRequestSender } from '../cart'; +import { CartActionCreator, CartRequestSender } from '../cart'; import { CheckoutActionCreator, CheckoutRequestSender, @@ -121,6 +121,8 @@ export default function createPaymentIntegrationService( const cartRequestSender = new CartRequestSender(requestSender); + const cartActionCreator = new CartActionCreator(cartRequestSender); + const paymentProviderCustomerActionCreator = new PaymentProviderCustomerActionCreator(); const shippingCountryActionCreator = new ShippingCountryActionCreator( @@ -142,6 +144,7 @@ export default function createPaymentIntegrationService( checkoutValidator, hostedFormFactory, orderActionCreator, + cartActionCreator, billingAddressActionCreator, consignmentActionCreator, paymentMethodActionCreator, diff --git a/packages/core/src/payment-integration/default-payment-integration-service.spec.ts b/packages/core/src/payment-integration/default-payment-integration-service.spec.ts index 9ff4763a57..ac3190f938 100644 --- a/packages/core/src/payment-integration/default-payment-integration-service.spec.ts +++ b/packages/core/src/payment-integration/default-payment-integration-service.spec.ts @@ -15,7 +15,7 @@ import { import { BillingAddressActionCreator } from '../billing'; import { getBillingAddress } from '../billing/billing-addresses.mock'; -import { CartRequestSender } from '../cart'; +import {CartActionCreator, CartRequestSender} from '../cart'; import { CheckoutActionCreator, CheckoutStore, @@ -64,6 +64,7 @@ describe('DefaultPaymentIntegrationService', () => { 'submitOrder' | 'finalizeOrder' | 'loadCurrentOrder' >; let billingAddressActionCreator: Pick; + let cartActionCreator: Pick; let consignmentActionCreator: Pick< ConsignmentActionCreator, 'updateAddress' | 'selectShippingOption' | 'deleteConsignment' @@ -287,6 +288,7 @@ describe('DefaultPaymentIntegrationService', () => { checkoutValidator as CheckoutValidator, hostedFormFactory, orderActionCreator as OrderActionCreator, + cartActionCreator as CartActionCreator, billingAddressActionCreator as BillingAddressActionCreator, consignmentActionCreator as ConsignmentActionCreator, paymentMethodActionCreator as PaymentMethodActionCreator, diff --git a/packages/core/src/payment-integration/default-payment-integration-service.ts b/packages/core/src/payment-integration/default-payment-integration-service.ts index a7f218a201..6bf2c12bab 100644 --- a/packages/core/src/payment-integration/default-payment-integration-service.ts +++ b/packages/core/src/payment-integration/default-payment-integration-service.ts @@ -14,7 +14,7 @@ import { } from '@bigcommerce/checkout-sdk/payment-integration-api'; import { BillingAddressActionCreator } from '../billing'; -import { CartRequestSender } from '../cart'; +import { CartActionCreator, CartRequestSender } from '../cart'; import { Checkout, CheckoutActionCreator, CheckoutStore, CheckoutValidator } from '../checkout'; import { DataStoreProjection } from '../common/data-store'; import { CustomerActionCreator, CustomerCredentials } from '../customer'; @@ -46,6 +46,7 @@ export default class DefaultPaymentIntegrationService implements PaymentIntegrat private _checkoutValidator: CheckoutValidator, private _hostedFormFactory: HostedFormFactory, private _orderActionCreator: OrderActionCreator, + private _cartActionCreator: CartActionCreator, private _billingAddressActionCreator: BillingAddressActionCreator, private _consignmentActionCreator: ConsignmentActionCreator, private _paymentMethodActionCreator: PaymentMethodActionCreator, @@ -204,6 +205,17 @@ export default class DefaultPaymentIntegrationService implements PaymentIntegrat return buyNowCart; } + async loadCardEntity( + cartId: string, + options?: RequestOptions, + ): Promise { + await this._store.dispatch( + this._cartActionCreator.loadCardEntity(cartId, { ...options, useCache: true }), + ); + + return this._storeProjection.getState(); + } + async applyStoreCredit( useStoreCredit: boolean, options?: RequestOptions, diff --git a/packages/core/src/payment/payment-method-action-creator.ts b/packages/core/src/payment/payment-method-action-creator.ts index 68997b3fa6..b28f84f352 100644 --- a/packages/core/src/payment/payment-method-action-creator.ts +++ b/packages/core/src/payment/payment-method-action-creator.ts @@ -15,6 +15,7 @@ import PaymentMethodRequestSender from './payment-method-request-sender'; import { isApplePayWindow } from './strategies/apple-pay'; import { PaymentMethod } from '.'; +import {MissingDataError, MissingDataErrorType} from "../common/error/errors"; const APPLEPAYID = 'applepay'; @@ -160,6 +161,56 @@ export default class PaymentMethodActionCreator { }); } + @cachableAction + loadPaymentWalletWithInitializationData( + methodId: string, + options?: RequestOptions & ActionOptions, + ): ThunkAction { + return (store) => + Observable.create((observer: Observer) => { + const state = store.getState(); + const jwtToken = state.config.getStorefrontJwtToken(); + + if (!jwtToken) { + throw new MissingDataError(MissingDataErrorType.MissingPaymentToken); + } + + observer.next( + createAction(PaymentMethodActionType.LoadPaymentMethodRequested, undefined, { + methodId, + }), + ); + + this._requestSender + .loadPaymentWalletWithInitializationData(methodId, { + headers: { + Authorization: `Bearer ${jwtToken}`, + 'Content-Type': 'application/json', + }, + ...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.ts b/packages/core/src/payment/payment-method-request-sender.ts index b0fea224e5..2db3446919 100644 --- a/packages/core/src/payment/payment-method-request-sender.ts +++ b/packages/core/src/payment/payment-method-request-sender.ts @@ -9,6 +9,33 @@ import { import PaymentMethod from './payment-method'; +// TODO:: move types to separate files +enum HeadlessPaymentMethodType { + PAYPALCOMMERCE = 'paypalcommerce.paypal', + PAYPALCOMMERCECREDIT = 'paypalcommerce.paypalcredit', +} + +const paymentMethodConfig: Record = { + paypalcommerce: HeadlessPaymentMethodType.PAYPALCOMMERCE, + paypalcommercecredit: HeadlessPaymentMethodType.PAYPALCOMMERCECREDIT, +}; + +interface LoadPaymentWalletWithInitializationDataRequestOptions extends RequestOptions { + body?: { query: string }; + headers: { Authorization: string; [key: string]: string }; +} + +interface LoadPaymentMethodResponse { + data: { + site: { + paymentWalletWithInitializationData: { + clientToken?: string; + initializationData?: T; + }; + }; + }; +} + export default class PaymentMethodRequestSender { constructor(private _requestSender: RequestSender) {} @@ -44,4 +71,81 @@ export default class PaymentMethodRequestSender { params, }); } + + /** + * GraphQL payment requests + */ + + loadPaymentWalletWithInitializationData( + methodId: string, + options: LoadPaymentWalletWithInitializationDataRequestOptions, + ): Promise> { + const url = `/graphql`; + + const entityId = this.getPaymentEntityId(methodId); + + const graphQLQuery = ` + query { + site { + paymentWalletWithInitializationData(filter: {paymentWalletEntityId: "${entityId}"}) { + clientToken + initializationData + } + } + } + `; + + const requestOptions: LoadPaymentWalletWithInitializationDataRequestOptions = { + headers: { + ...options.headers, + 'Content-Type': 'application/json', + }, + body: { + query: graphQLQuery, + }, + }; + + return this._requestSender + .post(url, requestOptions) + .then((response) => this.transformToPaymentMethodResponse(response, methodId)); + } + + private transformToPaymentMethodResponse( + response: Response, + methodId: string, + ): Response { + const { + body: { + data: { + site: { paymentWalletWithInitializationData }, + }, + }, + } = response; + + return { + ...response, + body: { + initializationData: JSON.parse( + atob(paymentWalletWithInitializationData.initializationData), + ), + clientToken: paymentWalletWithInitializationData.clientToken, + id: methodId, + config: {}, + // TODO:: define method type by methodId + method: 'paypal', + supportedCards: [], + type: 'PAYMENT_TYPE_API', + }, + }; + } + + private getPaymentEntityId(methodId: string): HeadlessPaymentMethodType { + const entityId = paymentMethodConfig[methodId]; + + if (!entityId) { + throw new Error('Unable to get payment entity id.'); + } + + return entityId; + } } diff --git a/packages/payment-integration-api/src/payment-integration-selectors.ts b/packages/payment-integration-api/src/payment-integration-selectors.ts index a75d417cdc..d5862d4b03 100644 --- a/packages/payment-integration-api/src/payment-integration-selectors.ts +++ b/packages/payment-integration-api/src/payment-integration-selectors.ts @@ -83,6 +83,10 @@ export default interface PaymentIntegrationSelectors { getConfig(): Config | undefined; + getStorefrontJwtToken(): string | undefined; + + getSiteLink(): string | undefined; + getInstrumentsMeta(): InstrumentMeta | undefined; getOrderMeta(): OrderMetaState | undefined; diff --git a/packages/payment-integration-api/src/payment-integration-service.ts b/packages/payment-integration-api/src/payment-integration-service.ts index c28ad8fcfd..15447ee71c 100644 --- a/packages/payment-integration-api/src/payment-integration-service.ts +++ b/packages/payment-integration-api/src/payment-integration-service.ts @@ -79,6 +79,8 @@ export default interface PaymentIntegrationService { createBuyNowCart(body: BuyNowCartRequestBody, options?: RequestOptions): Promise; + loadCardEntity(cartId: string, options?: RequestOptions): Promise; + updatePaymentProviderCustomer( paymentProviderCustomer: PaymentProviderCustomer, ): Promise; diff --git a/packages/payment-integrations-test-utils/src/test-utils/payment-integration-service.mock.ts b/packages/payment-integrations-test-utils/src/test-utils/payment-integration-service.mock.ts index 5bd6cad0fe..2ac6aa8ffe 100644 --- a/packages/payment-integrations-test-utils/src/test-utils/payment-integration-service.mock.ts +++ b/packages/payment-integrations-test-utils/src/test-utils/payment-integration-service.mock.ts @@ -59,6 +59,8 @@ const state = { getPaymentRedirectUrl: jest.fn(), getPaymentRedirectUrlOrThrow: jest.fn(), isPaymentDataRequired: jest.fn(), + getStorefrontJwtToken: jest.fn(), + getSiteLink: jest.fn(), }; const createBuyNowCart = jest.fn(() => Promise.resolve(getCart())); @@ -80,6 +82,7 @@ const loadCurrentOrder = jest.fn(); const submitOrder = jest.fn(); const submitPayment = jest.fn(); const finalizeOrder = jest.fn(); +const loadCardEntity = jest.fn(); const updateBillingAddress = jest.fn(); const updateShippingAddress = jest.fn(); const signInCustomer = jest.fn(); @@ -104,6 +107,7 @@ const PaymentIntegrationServiceMock = jest forgetCheckout, remoteCheckoutSignOut, getConsignments, + loadCardEntity, getPaymentProviderCustomerOrThrow, getState, handlePaymentHumanVerification, diff --git a/packages/paypal-commerce-integration/src/create-paypal-commerce-integration-service.ts b/packages/paypal-commerce-integration/src/create-paypal-commerce-integration-service.ts index a8084fe0b3..0a857ebe8e 100644 --- a/packages/paypal-commerce-integration/src/create-paypal-commerce-integration-service.ts +++ b/packages/paypal-commerce-integration/src/create-paypal-commerce-integration-service.ts @@ -13,10 +13,10 @@ import { const createPayPalCommerceIntegrationService = ( paymentIntegrationService: PaymentIntegrationService, ) => { - const { getHost } = paymentIntegrationService.getState(); + const { getHost, getSiteLink } = paymentIntegrationService.getState(); return new PayPalCommerceIntegrationService( - createFormPoster(), + createFormPoster({ host: getSiteLink() }), paymentIntegrationService, new PayPalCommerceRequestSender(createRequestSender({ host: getHost() })), new PayPalCommerceScriptLoader(getScriptLoader()), diff --git a/packages/paypal-commerce-integration/src/index.ts b/packages/paypal-commerce-integration/src/index.ts index 7a9318155f..d97e0f7dd7 100644 --- a/packages/paypal-commerce-integration/src/index.ts +++ b/packages/paypal-commerce-integration/src/index.ts @@ -16,6 +16,9 @@ export { WithPayPalCommerceCustomerInitializeOptions } from './paypal-commerce/p export { default as createPayPalCommercePaymentStrategy } from './paypal-commerce/create-paypal-commerce-payment-strategy'; export { WithPayPalCommercePaymentInitializeOptions } from './paypal-commerce/paypal-commerce-payment-initialize-options'; +export { default as createPayPalCommerceHeadlessButtonStrategy } from './paypal-commerce/create-paypal-commerce-headless-button-strategy'; +export { WithPayPalCommerceHeadlessButtonInitializeOptions } from './paypal-commerce/paypal-commerce-headless-button-initialize-options'; + /** * * PayPalCommerce Credit (PayLater) strategies @@ -24,6 +27,9 @@ export { WithPayPalCommercePaymentInitializeOptions } from './paypal-commerce/pa export { default as createPayPalCommerceCreditButtonStrategy } from './paypal-commerce-credit/create-paypal-commerce-credit-button-strategy'; export { WithPayPalCommerceCreditButtonInitializeOptions } from './paypal-commerce-credit/paypal-commerce-credit-button-initialize-options'; +export { default as createPayPalCommerceCreditHeadlessButtonStrategy } from './paypal-commerce-credit/create-paypal-commerce-credit-headless-button-strategy'; +export { WithPayPalCommerceCreditHeadlessButtonInitializeOptions } from './paypal-commerce-credit/paypal-commerce-credit-headless-button-initialize-options'; + export { default as createPayPalCommerceCreditCustomerStrategy } from './paypal-commerce-credit/create-paypal-commerce-credit-customer-strategy'; export { WithPayPalCommerceCreditCustomerInitializeOptions } from './paypal-commerce-credit/paypal-commerce-credit-customer-initialize-options'; diff --git a/packages/paypal-commerce-integration/src/paypal-commerce-credit/create-paypal-commerce-credit-headless-button-strategy.ts b/packages/paypal-commerce-integration/src/paypal-commerce-credit/create-paypal-commerce-credit-headless-button-strategy.ts new file mode 100644 index 0000000000..2008563830 --- /dev/null +++ b/packages/paypal-commerce-integration/src/paypal-commerce-credit/create-paypal-commerce-credit-headless-button-strategy.ts @@ -0,0 +1,20 @@ +import { + CheckoutButtonStrategyFactory, + toResolvableModule, +} from '@bigcommerce/checkout-sdk/payment-integration-api'; + +import createPayPalCommerceIntegrationService from '../create-paypal-commerce-integration-service'; + +import PayPalCommerceCreditHeadlessButtonStrategy from './paypal-commerce-credit-headless-button-strategy'; + +const createPayPalCommerceHeadlessButtonStrategy: CheckoutButtonStrategyFactory< + PayPalCommerceCreditHeadlessButtonStrategy +> = (paymentIntegrationService) => + new PayPalCommerceCreditHeadlessButtonStrategy( + paymentIntegrationService, + createPayPalCommerceIntegrationService(paymentIntegrationService), + ); + +export default toResolvableModule(createPayPalCommerceHeadlessButtonStrategy, [ + { id: 'paypalcommercecredit' }, +]); diff --git a/packages/paypal-commerce-integration/src/paypal-commerce-credit/paypal-commerce-credit-headless-button-initialize-options.ts b/packages/paypal-commerce-integration/src/paypal-commerce-credit/paypal-commerce-credit-headless-button-initialize-options.ts new file mode 100644 index 0000000000..23f6e3324c --- /dev/null +++ b/packages/paypal-commerce-integration/src/paypal-commerce-credit/paypal-commerce-credit-headless-button-initialize-options.ts @@ -0,0 +1,20 @@ +import { PayPalButtonStyleOptions } from '../paypal-commerce-types'; + +export default interface PayPalCommerceCreditHeadlessButtonInitializeOptions { + cartId: string; + /** + * A set of styling options for the checkout button. + */ + style?: PayPalButtonStyleOptions; + + /** + * + * A callback that gets called when PayPal SDK restricts to render PayPal component. + * + */ + onEligibilityFailure?(): void; +} + +export interface WithPayPalCommerceCreditHeadlessButtonInitializeOptions { + paypalcommercecredit?: PayPalCommerceCreditHeadlessButtonInitializeOptions; +} diff --git a/packages/paypal-commerce-integration/src/paypal-commerce-credit/paypal-commerce-credit-headless-button-strategy.ts b/packages/paypal-commerce-integration/src/paypal-commerce-credit/paypal-commerce-credit-headless-button-strategy.ts new file mode 100644 index 0000000000..1355566a32 --- /dev/null +++ b/packages/paypal-commerce-integration/src/paypal-commerce-credit/paypal-commerce-credit-headless-button-strategy.ts @@ -0,0 +1,228 @@ +import { + CheckoutButtonInitializeOptions, + CheckoutButtonStrategy, + InvalidArgumentError, + MissingDataError, + MissingDataErrorType, + PaymentIntegrationService, +} from '@bigcommerce/checkout-sdk/payment-integration-api'; + +import PayPalCommerceIntegrationService from '../paypal-commerce-integration-service'; +import { + ApproveCallbackActions, + ApproveCallbackPayload, + PayPalCommerceButtonsOptions, + PayPalCommerceInitializationData, + ShippingAddressChangeCallbackPayload, + ShippingOptionChangeCallbackPayload, +} from '../paypal-commerce-types'; + +import PayPalCommerceCreditHeadlessButtonInitializeOptions, { + WithPayPalCommerceCreditHeadlessButtonInitializeOptions, +} from './paypal-commerce-credit-headless-button-initialize-options'; + +export default class PayPalCommerceCreditHeadlessButtonStrategy implements CheckoutButtonStrategy { + constructor( + private paymentIntegrationService: PaymentIntegrationService, + private paypalCommerceIntegrationService: PayPalCommerceIntegrationService, + ) {} + + async initialize( + options: CheckoutButtonInitializeOptions & + WithPayPalCommerceCreditHeadlessButtonInitializeOptions, + ): Promise { + const { paypalcommercecredit, containerId, methodId } = options; + + if (!methodId) { + throw new InvalidArgumentError( + 'Unable to initialize payment because "options.methodId" argument is not provided.', + ); + } + + if (!containerId) { + throw new InvalidArgumentError( + `Unable to initialize payment because "options.containerId" argument is not provided.`, + ); + } + + if (!paypalcommercecredit) { + throw new InvalidArgumentError( + `Unable to initialize payment because "options.paypalcommercecredit" argument is not provided.`, + ); + } + + const state = await this.paymentIntegrationService.loadCardEntity( + paypalcommercecredit.cartId, + ); + + const currencyCode = state.getCartOrThrow().currency.code; + + await this.paypalCommerceIntegrationService.loadPayPalSdk(methodId, currencyCode, false); + + this.renderButton(containerId, methodId, paypalcommercecredit); + } + + deinitialize(): Promise { + return Promise.resolve(); + } + + private renderButton( + containerId: string, + methodId: string, + paypalcommercecredit: PayPalCommerceCreditHeadlessButtonInitializeOptions, + ): void { + const { style, onEligibilityFailure } = paypalcommercecredit; + + const paypalSdk = this.paypalCommerceIntegrationService.getPayPalSdkOrThrow(); + const state = this.paymentIntegrationService.getState(); + const paymentMethod = + state.getPaymentMethodOrThrow(methodId); + const { isHostedCheckoutEnabled } = paymentMethod.initializationData || {}; + + const defaultCallbacks = { + createOrder: () => + this.paypalCommerceIntegrationService.createPaymentOrderIntent( + 'paypalcommerce.paypalcredit', + ), + onApprove: ({ orderID }: ApproveCallbackPayload) => + this.paypalCommerceIntegrationService.tokenizePayment(methodId, orderID), + }; + + const onShippingChangeCallbacks = { + onShippingAddressChange: (data: ShippingAddressChangeCallbackPayload) => + this.onShippingAddressChange(data), + onShippingOptionsChange: (data: ShippingOptionChangeCallbackPayload) => + this.onShippingOptionsChange(data), + }; + + const hostedCheckoutCallbacks = { + ...onShippingChangeCallbacks, + onApprove: (data: ApproveCallbackPayload, actions: ApproveCallbackActions) => + this.onHostedCheckoutApprove(data, actions, methodId), + }; + + const fundingSources = [paypalSdk.FUNDING.PAYLATER, paypalSdk.FUNDING.CREDIT]; + let hasRenderedSmartButton = false; + + fundingSources.forEach((fundingSource) => { + if (!hasRenderedSmartButton) { + const buttonRenderOptions: PayPalCommerceButtonsOptions = { + fundingSource, + style: this.paypalCommerceIntegrationService.getValidButtonStyle(style), + ...defaultCallbacks, + ...(isHostedCheckoutEnabled && hostedCheckoutCallbacks), + }; + + const paypalButton = paypalSdk.Buttons(buttonRenderOptions); + + if (paypalButton.isEligible()) { + paypalButton.render(`#${containerId}`); + hasRenderedSmartButton = true; + } else if (onEligibilityFailure && typeof onEligibilityFailure === 'function') { + onEligibilityFailure(); + } + } + }); + + if (!hasRenderedSmartButton) { + this.paypalCommerceIntegrationService.removeElement(containerId); + } + } + + private async onHostedCheckoutApprove( + data: ApproveCallbackPayload, + actions: ApproveCallbackActions, + methodId: string, + onComplete?: () => void, + ): Promise { + if (!data.orderID) { + throw new MissingDataError(MissingDataErrorType.MissingOrderId); + } + + const state = this.paymentIntegrationService.getState(); + const cart = state.getCartOrThrow(); + const orderDetails = await actions.order.get(); + + try { + const billingAddress = + this.paypalCommerceIntegrationService.getBillingAddressFromOrderDetails( + orderDetails, + ); + + await this.paymentIntegrationService.updateBillingAddress(billingAddress); + + if (cart.lineItems.physicalItems.length > 0) { + const shippingAddress = + this.paypalCommerceIntegrationService.getShippingAddressFromOrderDetails( + orderDetails, + ); + + await this.paymentIntegrationService.updateShippingAddress(shippingAddress); + await this.paypalCommerceIntegrationService.updateOrder(); + } + + await this.paymentIntegrationService.submitOrder({}, { params: { methodId } }); + await this.paypalCommerceIntegrationService.submitPayment(methodId, data.orderID); + + if (onComplete && typeof onComplete === 'function') { + onComplete(); + } + + return true; // FIXME: Do we really need to return true here? + } catch (error) { + if (typeof error === 'string') { + throw new Error(error); + } + + throw error; + } + } + + private async onShippingAddressChange( + data: ShippingAddressChangeCallbackPayload, + ): Promise { + const address = this.paypalCommerceIntegrationService.getAddress({ + city: data.shippingAddress.city, + countryCode: data.shippingAddress.countryCode, + postalCode: data.shippingAddress.postalCode, + stateOrProvinceCode: data.shippingAddress.state, + }); + + try { + // Info: we use the same address to fill billing and shipping addresses to have valid quota on BE for order updating process + // on this stage we don't have access to valid customer's address accept shipping data + await this.paymentIntegrationService.updateBillingAddress(address); + await this.paymentIntegrationService.updateShippingAddress(address); + + const shippingOption = this.paypalCommerceIntegrationService.getShippingOptionOrThrow(); + + await this.paymentIntegrationService.selectShippingOption(shippingOption.id); + await this.paypalCommerceIntegrationService.updateOrder(); + } catch (error) { + if (typeof error === 'string') { + throw new Error(error); + } + + throw error; + } + } + + private async onShippingOptionsChange( + data: ShippingOptionChangeCallbackPayload, + ): Promise { + const shippingOption = this.paypalCommerceIntegrationService.getShippingOptionOrThrow( + data.selectedShippingOption.id, + ); + + try { + await this.paymentIntegrationService.selectShippingOption(shippingOption.id); + await this.paypalCommerceIntegrationService.updateOrder(); + } catch (error) { + if (typeof error === 'string') { + throw new Error(error); + } + + throw error; + } + } +} diff --git a/packages/paypal-commerce-integration/src/paypal-commerce-integration-service.ts b/packages/paypal-commerce-integration/src/paypal-commerce-integration-service.ts index 7da75cbcde..d1781b75de 100644 --- a/packages/paypal-commerce-integration/src/paypal-commerce-integration-service.ts +++ b/packages/paypal-commerce-integration/src/paypal-commerce-integration-service.ts @@ -18,6 +18,7 @@ import { import PayPalCommerceRequestSender from './paypal-commerce-request-sender'; import PayPalCommerceScriptLoader from './paypal-commerce-script-loader'; import { + CreatePaymentOrderIntentOptions, PayPalButtonStyleOptions, PayPalBuyNowInitializeOptions, PayPalCommerceInitializationData, @@ -325,6 +326,39 @@ export default class PayPalCommerceIntegrationService { return height; } + /** + * + * GraphQL methods + * + */ + async createPaymentOrderIntent( + providerId: string, + options?: Partial, + ): Promise { + const cartId = this.paymentIntegrationService.getState().getCartOrThrow().id; + const state = this.paymentIntegrationService.getState(); + + const jwtToken = state.getStorefrontJwtToken(); + + if (!jwtToken) { + throw new MissingDataError(MissingDataErrorType.MissingPaymentToken); + } + + const { orderId } = await this.paypalCommerceRequestSender.createPaymentOrderIntent( + providerId, + cartId, + { + headers: { + Authorization: `Bearer ${jwtToken}`, + 'Content-Type': 'application/json', + }, + ...options, + }, + ); + + return orderId; + } + /** * * Utils methods diff --git a/packages/paypal-commerce-integration/src/paypal-commerce-request-sender.ts b/packages/paypal-commerce-integration/src/paypal-commerce-request-sender.ts index 0d12815b62..eeddddc32a 100644 --- a/packages/paypal-commerce-integration/src/paypal-commerce-request-sender.ts +++ b/packages/paypal-commerce-integration/src/paypal-commerce-request-sender.ts @@ -8,6 +8,8 @@ import { } from '@bigcommerce/checkout-sdk/payment-integration-api'; import { + CreatePaymentOrderIntentOptions, + CreatePaymentOrderIntentResponse, PayPalCreateOrderRequestBody, PayPalOrderData, PayPalOrderStatusData, @@ -69,4 +71,81 @@ export default class PayPalCommerceRequestSender { return res.body; } + + /** + * + * GraphQL methods + * + */ + async createPaymentOrderIntent( + walletEntityId: string, + cartId: string, + options: CreatePaymentOrderIntentOptions, + ): Promise { + const url = '/graphql'; + + const graphQLQuery = ` + mutation { + payment { + paymentWallet { + createPaymentWalletIntent( + input: {cartEntityId: "${cartId}", paymentWalletEntityId: "${walletEntityId}"} + ) { + errors { + ... on CreatePaymentWalletIntentGenericError { + __typename + message + } + } + paymentWalletIntentData { + ... on PayPalCommercePaymentWalletIntentData { + __typename + approvalUrl + orderId + } + } + } + } + } + } + `; + + const requestOptions: CreatePaymentOrderIntentOptions = { + headers: { + ...options?.headers, + 'Content-Type': 'application/json', + }, + body: { + ...options.body, + query: graphQLQuery, + }, + }; + + const res = await this.requestSender.post( + url, + requestOptions, + ); + + const { + data: { + payment: { + paymentWallet: { + createPaymentWalletIntent: { paymentWalletIntentData, errors }, + }, + }, + }, + } = res.body; + + const errorMessage = errors[0]?.message; + + if (errorMessage) { + // TODO:: add error handling + throw new Error(errorMessage); + } + + return { + orderId: paymentWalletIntentData.orderId, + approveUrl: paymentWalletIntentData.approvalUrl, + }; + } } diff --git a/packages/paypal-commerce-integration/src/paypal-commerce-types.ts b/packages/paypal-commerce-integration/src/paypal-commerce-types.ts index 8b1412da5e..c828aec78b 100644 --- a/packages/paypal-commerce-integration/src/paypal-commerce-types.ts +++ b/packages/paypal-commerce-integration/src/paypal-commerce-types.ts @@ -1,6 +1,6 @@ import { BuyNowCartRequestBody, - HostedInstrument, + HostedInstrument, RequestOptions, ShippingOption, VaultedInstrument, } from '@bigcommerce/checkout-sdk/payment-integration-api'; @@ -612,3 +612,34 @@ export interface PayPalCreateOrderCardFieldsResponse { orderId: string; setupToken?: string; } + +/** + * + * GraphQL Request Types + * + */ + +export interface CreatePaymentOrderIntentOptions extends RequestOptions { + body?: { query: string }; + headers: { Authorization: string; [key: string]: string }; +} + +export interface CreatePaymentOrderIntentResponse { + data: { + payment: { + paymentWallet: { + createPaymentWalletIntent: { + errors: Array<{ + location: Array<{ line: string; column: string }>; + message: string + }>; + paymentWalletIntentData: { + __typename: string; + approvalUrl: string; + orderId: string; + }; + }; + }; + }; + }; +} diff --git a/packages/paypal-commerce-integration/src/paypal-commerce/create-paypal-commerce-headless-button-strategy.ts b/packages/paypal-commerce-integration/src/paypal-commerce/create-paypal-commerce-headless-button-strategy.ts new file mode 100644 index 0000000000..36f2c90d9d --- /dev/null +++ b/packages/paypal-commerce-integration/src/paypal-commerce/create-paypal-commerce-headless-button-strategy.ts @@ -0,0 +1,18 @@ +import { + CheckoutButtonStrategyFactory, + toResolvableModule, +} from '@bigcommerce/checkout-sdk/payment-integration-api'; + +import createPayPalCommerceIntegrationService from '../create-paypal-commerce-integration-service'; + +import PaypalCommerceHeadlessButtonStrategy from "./paypal-commerce-headless-button-strategy"; + +const createPayPalCommerceHeadlessButtonStrategy: CheckoutButtonStrategyFactory< + PaypalCommerceHeadlessButtonStrategy +> = (paymentIntegrationService) => + new PaypalCommerceHeadlessButtonStrategy( + paymentIntegrationService, + createPayPalCommerceIntegrationService(paymentIntegrationService), + ); + +export default toResolvableModule(createPayPalCommerceHeadlessButtonStrategy, [{ id: 'paypalcommerce' }]); diff --git a/packages/paypal-commerce-integration/src/paypal-commerce/paypal-commerce-headless-button-initialize-options.ts b/packages/paypal-commerce-integration/src/paypal-commerce/paypal-commerce-headless-button-initialize-options.ts new file mode 100644 index 0000000000..5f7b45cf57 --- /dev/null +++ b/packages/paypal-commerce-integration/src/paypal-commerce/paypal-commerce-headless-button-initialize-options.ts @@ -0,0 +1,7 @@ +export default interface PayPalCommerceHeadlessButtonInitializeOptions { + cartId: string; +} + +export interface WithPayPalCommerceHeadlessButtonInitializeOptions { + paypalcommerce?: PayPalCommerceHeadlessButtonInitializeOptions; +} diff --git a/packages/paypal-commerce-integration/src/paypal-commerce/paypal-commerce-headless-button-strategy.ts b/packages/paypal-commerce-integration/src/paypal-commerce/paypal-commerce-headless-button-strategy.ts new file mode 100644 index 0000000000..29bf99c876 --- /dev/null +++ b/packages/paypal-commerce-integration/src/paypal-commerce/paypal-commerce-headless-button-strategy.ts @@ -0,0 +1,203 @@ +import { + CheckoutButtonInitializeOptions, + CheckoutButtonStrategy, + InvalidArgumentError, + MissingDataError, + MissingDataErrorType, + PaymentIntegrationService, +} from '@bigcommerce/checkout-sdk/payment-integration-api'; + +import PayPalCommerceIntegrationService from '../paypal-commerce-integration-service'; +import { + ApproveCallbackActions, + ApproveCallbackPayload, + PayPalCommerceButtonsOptions, + PayPalCommerceInitializationData, + ShippingAddressChangeCallbackPayload, + ShippingOptionChangeCallbackPayload, +} from '../paypal-commerce-types'; + +import { WithPayPalCommerceHeadlessButtonInitializeOptions } from './paypal-commerce-headless-button-initialize-options'; + +export default class PaypalCommerceHeadlessButtonStrategy implements CheckoutButtonStrategy { + constructor( + private paymentIntegrationService: PaymentIntegrationService, + private paypalCommerceIntegrationService: PayPalCommerceIntegrationService, + ) {} + + async initialize( + options: CheckoutButtonInitializeOptions & + WithPayPalCommerceHeadlessButtonInitializeOptions, + ): Promise { + const { paypalcommerce, containerId, methodId } = options; + + if (!methodId) { + throw new InvalidArgumentError( + 'Unable to initialize payment because "options.methodId" argument is not provided.', + ); + } + + if (!containerId) { + throw new InvalidArgumentError( + `Unable to initialize payment because "options.containerId" argument is not provided.`, + ); + } + + if (!paypalcommerce) { + throw new InvalidArgumentError( + `Unable to initialize payment because "options.paypalcommerce" argument is not provided.`, + ); + } + + const state = await this.paymentIntegrationService.loadCardEntity(paypalcommerce.cartId); + + await this.paypalCommerceIntegrationService.loadPayPalSdk( + methodId, + state.getCart()?.currency.code, + false, + ); + + this.renderButton(containerId, methodId); + } + + deinitialize(): Promise { + return Promise.resolve(); + } + + private renderButton(containerId: string, methodId: string): void { + const paypalSdk = this.paypalCommerceIntegrationService.getPayPalSdkOrThrow(); + const state = this.paymentIntegrationService.getState(); + const paymentMethod = + state.getPaymentMethodOrThrow(methodId); + const { isHostedCheckoutEnabled } = paymentMethod.initializationData || {}; + + const defaultCallbacks = { + createOrder: () => + this.paypalCommerceIntegrationService.createPaymentOrderIntent( + 'paypalcommerce.paypal', + ), + onApprove: ({ orderID }: ApproveCallbackPayload) => + this.paypalCommerceIntegrationService.tokenizePayment(methodId, orderID), + }; + + const onShippingChangeCallbacks = { + onShippingAddressChange: (data: ShippingAddressChangeCallbackPayload) => + this.onShippingAddressChange(data), + onShippingOptionsChange: (data: ShippingOptionChangeCallbackPayload) => + this.onShippingOptionsChange(data), + }; + + const hostedCheckoutCallbacks = { + ...onShippingChangeCallbacks, + onApprove: (data: ApproveCallbackPayload, actions: ApproveCallbackActions) => + this.onHostedCheckoutApprove(data, actions, methodId), + }; + + const buttonRenderOptions: PayPalCommerceButtonsOptions = { + fundingSource: paypalSdk.FUNDING.PAYPAL, + style: this.paypalCommerceIntegrationService.getValidButtonStyle(), + ...defaultCallbacks, + ...(isHostedCheckoutEnabled && hostedCheckoutCallbacks), + }; + + const paypalButton = paypalSdk.Buttons(buttonRenderOptions); + + if (paypalButton.isEligible()) { + paypalButton.render(`#${containerId}`); + } else { + this.paypalCommerceIntegrationService.removeElement(containerId); + } + } + + private async onHostedCheckoutApprove( + data: ApproveCallbackPayload, + actions: ApproveCallbackActions, + methodId: string, + ): Promise { + if (!data.orderID) { + throw new MissingDataError(MissingDataErrorType.MissingOrderId); + } + + const state = this.paymentIntegrationService.getState(); + const cart = state.getCartOrThrow(); + const orderDetails = await actions.order.get(); + + try { + const billingAddress = + this.paypalCommerceIntegrationService.getBillingAddressFromOrderDetails( + orderDetails, + ); + + await this.paymentIntegrationService.updateBillingAddress(billingAddress); + + if (cart.lineItems.physicalItems.length > 0) { + const shippingAddress = + this.paypalCommerceIntegrationService.getShippingAddressFromOrderDetails( + orderDetails, + ); + + await this.paymentIntegrationService.updateShippingAddress(shippingAddress); + await this.paypalCommerceIntegrationService.updateOrder(); + } + + await this.paymentIntegrationService.submitOrder({}, { params: { methodId } }); + await this.paypalCommerceIntegrationService.submitPayment(methodId, data.orderID); + + return true; // FIXME: Do we really need to return true here? + } catch (error) { + if (typeof error === 'string') { + throw new Error(error); + } + + throw error; + } + } + + private async onShippingAddressChange( + data: ShippingAddressChangeCallbackPayload, + ): Promise { + const address = this.paypalCommerceIntegrationService.getAddress({ + city: data.shippingAddress.city, + countryCode: data.shippingAddress.countryCode, + postalCode: data.shippingAddress.postalCode, + stateOrProvinceCode: data.shippingAddress.state, + }); + + try { + // Info: we use the same address to fill billing and shipping addresses to have valid quota on BE for order updating process + // on this stage we don't have access to valid customer's address accept shipping data + await this.paymentIntegrationService.updateBillingAddress(address); + await this.paymentIntegrationService.updateShippingAddress(address); + + const shippingOption = this.paypalCommerceIntegrationService.getShippingOptionOrThrow(); + + await this.paymentIntegrationService.selectShippingOption(shippingOption.id); + await this.paypalCommerceIntegrationService.updateOrder(); + } catch (error) { + if (typeof error === 'string') { + throw new Error(error); + } + + throw error; + } + } + + private async onShippingOptionsChange( + data: ShippingOptionChangeCallbackPayload, + ): Promise { + const shippingOption = this.paypalCommerceIntegrationService.getShippingOptionOrThrow( + data.selectedShippingOption.id, + ); + + try { + await this.paymentIntegrationService.selectShippingOption(shippingOption.id); + await this.paypalCommerceIntegrationService.updateOrder(); + } catch (error) { + if (typeof error === 'string') { + throw new Error(error); + } + + throw error; + } + } +} diff --git a/webpack-common.config.js b/webpack-common.config.js index 84115367da..bd9f23c63e 100644 --- a/webpack-common.config.js +++ b/webpack-common.config.js @@ -14,6 +14,7 @@ const hostedFormV2SrcPath = path.join(__dirname, 'packages/hosted-form-v2/src'); const libraryEntries = { 'checkout-sdk': path.join(coreSrcPath, 'bundles', 'checkout-sdk.ts'), 'checkout-button': path.join(coreSrcPath, 'bundles', 'checkout-button.ts'), + 'checkout-headless-button': path.join(coreSrcPath, 'bundles', 'checkout-headless-button.ts'), 'embedded-checkout': path.join(coreSrcPath, 'bundles', 'embedded-checkout.ts'), extension: path.join(coreSrcPath, 'bundles', 'extension.ts'), 'hosted-form': path.join(coreSrcPath, 'bundles', 'hosted-form.ts'),