diff --git a/packages/core/auto-export.config.json b/packages/core/auto-export.config.json index 6ef0ba9e72..58b724f11e 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/wallet-button-strategies.ts", + "memberPattern": "^create.+WalletStrategy$" } ] } diff --git a/packages/core/extend-interface.config.json b/packages/core/extend-interface.config.json index b60ab8ac71..e79b320477 100644 --- a/packages/core/extend-interface.config.json +++ b/packages/core/extend-interface.config.json @@ -23,6 +23,14 @@ "memberPattern": "^With.+ButtonInitializeOptions$", "targetPath": "packages/core/src/checkout-buttons/index.ts", "targetMemberName": "BaseCheckoutButtonInitializeOptions" + }, + { + "inputPath": "packages/*/src/index.ts", + "outputPath": "packages/core/src/generated/wallet-button-initialize-options.ts", + "outputMemberName": "WalletButtonInitializeOptions", + "memberPattern": "^With.+WalletInitializeOptions$", + "targetPath": "packages/core/src/wallet-buttons/index.ts", + "targetMemberName": "BaseWalletButtonInitializeOptions" } ] } diff --git a/packages/core/src/bundles/wallet-button.ts b/packages/core/src/bundles/wallet-button.ts new file mode 100644 index 0000000000..c309721f96 --- /dev/null +++ b/packages/core/src/bundles/wallet-button.ts @@ -0,0 +1,3 @@ +export { createTimeout } from '@bigcommerce/request-sender'; + +export { createWalletButtonInitializer } from '../wallet-buttons'; diff --git a/packages/core/src/loader-cdn.ts b/packages/core/src/loader-cdn.ts index 2c0c1f8249..f2ad1f57a0 100644 --- a/packages/core/src/loader-cdn.ts +++ b/packages/core/src/loader-cdn.ts @@ -5,15 +5,20 @@ import * as checkoutButtonBundle from './bundles/checkout-button'; import * as mainBundle from './bundles/checkout-sdk'; import * as embeddedCheckoutBundle from './bundles/embedded-checkout'; import * as hostedFormBundle from './bundles/hosted-form'; +import * as walletButtonBundle from './bundles/wallet-button'; import { parseUrl } from './common/url'; export type CheckoutButtonBundle = typeof checkoutButtonBundle & { version: string }; +export type WalletButtonBundle = typeof walletButtonBundle & { + 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', + WalletButton = 'wallet-payment-button', EmbeddedCheckout = 'embedded-checkout', HostedForm = 'hosted-form', Main = 'checkout-sdk', @@ -25,12 +30,19 @@ const scriptOrigin = isScriptElement(document.currentScript) export function load(moduleName?: BundleType.Main): Promise; export function load(moduleName: BundleType.CheckoutButton): Promise; +export function load(moduleName: BundleType.WalletButton): 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< + | MainBundle + | CheckoutButtonBundle + | WalletButtonBundle + | EmbeddedCheckoutBundle + | HostedFormBundle +> { 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..81650950ce 100644 --- a/packages/core/src/loader.ts +++ b/packages/core/src/loader.ts @@ -4,14 +4,19 @@ import * as checkoutButtonBundle from './bundles/checkout-button'; import * as mainBundle from './bundles/checkout-sdk'; import * as embeddedCheckoutBundle from './bundles/embedded-checkout'; import * as hostedFormBundle from './bundles/hosted-form'; +import * as walletButtonBundle from './bundles/wallet-button'; export type CheckoutButtonBundle = typeof checkoutButtonBundle & { version: string }; +export type WalletPaymentButtonBundle = typeof walletButtonBundle & { + 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', + WalletButton = 'wallet-button', EmbeddedCheckout = 'embedded-checkout', HostedForm = 'hosted-form', Main = 'checkout-sdk', @@ -19,12 +24,19 @@ export enum BundleType { export function load(moduleName?: BundleType.Main): Promise; export function load(moduleName: BundleType.CheckoutButton): Promise; +export function load(moduleName: BundleType.WalletButton): 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< + | MainBundle + | CheckoutButtonBundle + | WalletPaymentButtonBundle + | EmbeddedCheckoutBundle + | HostedFormBundle +> { const { version, js } = MANIFEST_JSON; const manifestPath = js.find((path) => path.indexOf(moduleName) !== -1); diff --git a/packages/core/src/wallet-buttons/create-wallet-button-initializer.ts b/packages/core/src/wallet-buttons/create-wallet-button-initializer.ts new file mode 100644 index 0000000000..77905a0264 --- /dev/null +++ b/packages/core/src/wallet-buttons/create-wallet-button-initializer.ts @@ -0,0 +1,23 @@ +import { createWalletButtonIntegrationService } from '@bigcommerce/checkout-sdk/wallet-button-integration'; + +import * as walletButtonStrategyFactories from '../generated/wallet-button-strategies'; + +import createWalletButtonStrategyRegistry from './create-wallet-button-strategy-registry'; +import WalletButtonInitializer from './wallet-button-initializer'; +import { WalletButtonInitializerOptions } from './wallet-buttons'; + +export default function createWalletButtonInitializer( + options: WalletButtonInitializerOptions, +): WalletButtonInitializer { + const { graphQLEndpoint } = options; + + const walletPaymentButtonIntegrationService = + createWalletButtonIntegrationService(graphQLEndpoint); + + const registryV2 = createWalletButtonStrategyRegistry( + walletPaymentButtonIntegrationService, + walletButtonStrategyFactories, + ); + + return new WalletButtonInitializer(registryV2); +} diff --git a/packages/core/src/wallet-buttons/create-wallet-button-strategy-registry.ts b/packages/core/src/wallet-buttons/create-wallet-button-strategy-registry.ts new file mode 100644 index 0000000000..e9d0178c29 --- /dev/null +++ b/packages/core/src/wallet-buttons/create-wallet-button-strategy-registry.ts @@ -0,0 +1,44 @@ +import { + CheckoutButtonStrategy, + CheckoutButtonStrategyResolveId, + isResolvableModule, +} from '@bigcommerce/checkout-sdk/payment-integration-api'; +import { + WalletButtonIntegrationService, + WalletButtonStrategyFactory, +} from '@bigcommerce/checkout-sdk/wallet-button-integration'; + +import { ResolveIdRegistry } from '../common/registry'; + +export interface WalletButtonStrategyFactories { + [key: string]: WalletButtonStrategyFactory; +} + +export default function createWalletButtonStrategyRegistry( + walletButtonIntegrationService: WalletButtonIntegrationService, + walletButtonStrategyFactories: WalletButtonStrategyFactories, +): ResolveIdRegistry { + const registry = new ResolveIdRegistry< + CheckoutButtonStrategy, + CheckoutButtonStrategyResolveId + >(); + + for (const [, createCheckoutButtonStrategy] of Object.entries(walletButtonStrategyFactories)) { + if ( + !isResolvableModule< + WalletButtonStrategyFactory, + CheckoutButtonStrategyResolveId + >(createCheckoutButtonStrategy) + ) { + continue; + } + + for (const resolverId of createCheckoutButtonStrategy.resolveIds) { + registry.register(resolverId, () => + createCheckoutButtonStrategy(walletButtonIntegrationService), + ); + } + } + + return registry; +} diff --git a/packages/core/src/wallet-buttons/index.ts b/packages/core/src/wallet-buttons/index.ts new file mode 100644 index 0000000000..9fde5bdc6b --- /dev/null +++ b/packages/core/src/wallet-buttons/index.ts @@ -0,0 +1,2 @@ +export { default as createWalletButtonInitializer } from './create-wallet-button-initializer'; +export { BaseWalletButtonInitializeOptions } from './wallet-button-options'; diff --git a/packages/core/src/wallet-buttons/wallet-button-initializer.ts b/packages/core/src/wallet-buttons/wallet-button-initializer.ts new file mode 100644 index 0000000000..28e58a6524 --- /dev/null +++ b/packages/core/src/wallet-buttons/wallet-button-initializer.ts @@ -0,0 +1,33 @@ +import { bindDecorator as bind } from '@bigcommerce/checkout-sdk/utility'; + +import { isElementId, setUniqueElementId } from '../common/dom'; + +import { BaseWalletButtonInitializeOptions, WalletButtonOptions } from './wallet-button-options'; +import WalletButtonRegistryV2 from './wallet-button-strategy-registry-v2'; + +@bind +export default class WalletButtonInitializer { + constructor(private _registryV2: WalletButtonRegistryV2) {} + + initializeWalletButton(options: BaseWalletButtonInitializeOptions): Promise { + const containerIds = this.getContainerIds(options); + + return Promise.all( + containerIds.map(async (containerId) => { + const strategy = this._registryV2.get({ id: options.methodId }); + + return strategy.initialize({ ...options, containerId }); + }), + ); + } + + deinitializeWalletButton(options: WalletButtonOptions): Promise { + return this._registryV2.get({ id: options.methodId }).deinitialize(); + } + + private getContainerIds(options: BaseWalletButtonInitializeOptions) { + return isElementId(options.containerId) + ? [options.containerId] + : setUniqueElementId(options.containerId, `${options.methodId}-container`); + } +} diff --git a/packages/core/src/wallet-buttons/wallet-button-options.ts b/packages/core/src/wallet-buttons/wallet-button-options.ts new file mode 100644 index 0000000000..5f5076250b --- /dev/null +++ b/packages/core/src/wallet-buttons/wallet-button-options.ts @@ -0,0 +1,19 @@ +import { RequestOptions } from '../common/http-request'; + +export { WalletButtonInitializeOptions } from '../generated/wallet-button-initialize-options'; + +enum CheckoutButtonMethodType { + PAYPALCOMMERCE = 'paypalcommercepaypal', + PAYPLCOMMERCEVENMO = 'paypalcommercevenmo', + PAYPALCOMMERCEPAYPALCREDIT = 'paypalcommercepaypalcredit', +} + +export interface WalletButtonOptions extends RequestOptions { + methodId: CheckoutButtonMethodType; +} + +export interface BaseWalletButtonInitializeOptions extends WalletButtonOptions { + [key: string]: unknown; + + containerId: string; +} diff --git a/packages/core/src/wallet-buttons/wallet-button-strategy-registry-v2.ts b/packages/core/src/wallet-buttons/wallet-button-strategy-registry-v2.ts new file mode 100644 index 0000000000..13dd7fbbc8 --- /dev/null +++ b/packages/core/src/wallet-buttons/wallet-button-strategy-registry-v2.ts @@ -0,0 +1,13 @@ +import { + CheckoutButtonStrategy, + CheckoutButtonStrategyResolveId, +} from '@bigcommerce/checkout-sdk/payment-integration-api'; + +import { ResolveIdRegistry } from '../common/registry'; + +type WalletButtonRegistry = ResolveIdRegistry< + CheckoutButtonStrategy, + CheckoutButtonStrategyResolveId +>; + +export default WalletButtonRegistry; diff --git a/packages/core/src/wallet-buttons/wallet-buttons.ts b/packages/core/src/wallet-buttons/wallet-buttons.ts new file mode 100644 index 0000000000..bd8f7db1f3 --- /dev/null +++ b/packages/core/src/wallet-buttons/wallet-buttons.ts @@ -0,0 +1,3 @@ +export interface WalletButtonInitializerOptions { + graphQLEndpoint: string; +} diff --git a/packages/payment-integration-api/src/billing/index.ts b/packages/payment-integration-api/src/billing/index.ts index 1beda4ddd4..297aed2d7e 100644 --- a/packages/payment-integration-api/src/billing/index.ts +++ b/packages/payment-integration-api/src/billing/index.ts @@ -1,2 +1,6 @@ -export { default as BillingAddress, BillingAddressRequestBody } from './billing-address'; +export { + default as BillingAddress, + BillingAddressRequestBody, + BillingAddressUpdateRequestBody, +} from './billing-address'; export { default as isBillingAddressLike } from './is-billing-address-like'; diff --git a/packages/payment-integration-api/src/index.ts b/packages/payment-integration-api/src/index.ts index dfe88a10f6..20ca8e6fc4 100644 --- a/packages/payment-integration-api/src/index.ts +++ b/packages/payment-integration-api/src/index.ts @@ -1,5 +1,10 @@ export { Address, AddressRequestBody, LegacyAddress } from './address'; -export { BillingAddress, BillingAddressRequestBody, isBillingAddressLike } from './billing'; +export { + BillingAddress, + BillingAddressRequestBody, + BillingAddressUpdateRequestBody, + isBillingAddressLike, +} from './billing'; export { CheckoutButtonStrategy, CheckoutButtonStrategyFactory, diff --git a/packages/paypal-commerce-integration/src/index.ts b/packages/paypal-commerce-integration/src/index.ts index 7a9318155f..9ab19872d1 100644 --- a/packages/paypal-commerce-integration/src/index.ts +++ b/packages/paypal-commerce-integration/src/index.ts @@ -16,6 +16,8 @@ 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 createPayPalCommerceWalletStrategy } from './paypal-commerce/create-paypal-commerce-wallet-strategy'; +export { WithPayPalCommerceWalletInitializeOptions } from './paypal-commerce/paypal-commerce-wallet-initialize-options'; /** * * PayPalCommerce Credit (PayLater) strategies diff --git a/packages/paypal-commerce-integration/src/paypal-commerce-wallet-service.ts b/packages/paypal-commerce-integration/src/paypal-commerce-wallet-service.ts new file mode 100644 index 0000000000..e6e5dc0b55 --- /dev/null +++ b/packages/paypal-commerce-integration/src/paypal-commerce-wallet-service.ts @@ -0,0 +1,163 @@ +import { isNil, omitBy } from 'lodash'; + +import { + MissingDataError, + MissingDataErrorType, + PaymentMethod, + PaymentMethodClientUnavailableError, +} from '@bigcommerce/checkout-sdk/payment-integration-api'; +import { + GraphQLRequestOptions, + WalletButtonIntegrationService, +} from '@bigcommerce/checkout-sdk/wallet-button-integration'; + +import PayPalCommerceScriptLoader from './paypal-commerce-script-loader'; +import { + PayPalButtonStyleOptions, + PayPalCommerceInitializationData, + PayPalSDK, + StyleButtonColor, + StyleButtonLabel, + StyleButtonShape, +} from './paypal-commerce-types'; + +export default class PaypalCommerceWalletService { + private paypalSdk?: PayPalSDK; + + constructor( + private walletButtonIntegrationService: WalletButtonIntegrationService, + private paypalCommerceScriptLoader: PayPalCommerceScriptLoader, + ) {} + + /** + * + * PayPalSDK methods + * + */ + async loadPayPalSdk( + paymentMethod: PaymentMethod, + providedCurrencyCode: string, + initializesOnCheckoutPage?: boolean, + forceLoad?: boolean, + ): Promise { + this.paypalSdk = await this.paypalCommerceScriptLoader.getPayPalSDK( + paymentMethod, + providedCurrencyCode, + initializesOnCheckoutPage, + forceLoad, + ); + + return this.paypalSdk; + } + + getPayPalSdkOrThrow(): PayPalSDK { + if (!this.paypalSdk) { + throw new PaymentMethodClientUnavailableError(); + } + + return this.paypalSdk; + } + + /** + * + * Payment submitting and tokenizing methods + * + */ + async proxyTokenizationPayment(cartId: string, orderId?: string): Promise { + if (!orderId) { + throw new MissingDataError(MissingDataErrorType.MissingOrderId); + } + + const inputData = { + paymentWalletData: { + providerId: 'paypalcommerce', + providerOrderId: orderId, + }, + cartEntityId: cartId, + queryParams: [ + { key: 'payment_type', value: 'paypal' }, + { key: 'action', value: 'set_external_checkout' }, + { key: 'provider', value: 'paypalcommerce' }, + ], + }; + + const response = await this.walletButtonIntegrationService.getRedirectToCheckoutUrl( + inputData, + ); + + if (!response.body.redirectUrls?.externalCheckoutUrl) { + throw new Error('Failed to redirection to checkout page'); + } + + window.location.assign(response.body.redirectUrls.externalCheckoutUrl); + } + + async createPaymentOrderIntent( + providerId: string, + cartId: string, + options?: GraphQLRequestOptions, + ): Promise { + const inputData = { + cartEntityId: cartId, + paymentWalletEntityId: providerId, + }; + const response = await this.walletButtonIntegrationService.createPaymentOrderIntent( + inputData, + options, + ); + + return response.body.orderId; + } + + /** + * + * Buttons style methods + * + */ + getValidButtonStyle(style?: PayPalButtonStyleOptions): PayPalButtonStyleOptions { + const { color, height, label, shape } = style || {}; + + const validStyles = { + color: color && StyleButtonColor[color] ? color : undefined, + height: this.getValidHeight(height), + label: label && StyleButtonLabel[label] ? label : undefined, + shape: shape && StyleButtonShape[shape] ? shape : undefined, + }; + + return omitBy(validStyles, isNil); + } + + getValidHeight(height?: number): number { + const defaultHeight = 40; + const minHeight = 25; + const maxHeight = 55; + + if (!height || typeof height !== 'number') { + return defaultHeight; + } + + if (height > maxHeight) { + return maxHeight; + } + + if (height < minHeight) { + return minHeight; + } + + return height; + } + + /** + * + * Utils methods + * + */ + removeElement(elementId?: string): void { + const element = elementId && document.getElementById(elementId); + + if (element) { + // For now this is a temporary solution, further removeElement method will be removed + element.style.display = 'none'; + } + } +} diff --git a/packages/paypal-commerce-integration/src/paypal-commerce/create-paypal-commerce-wallet-strategy.ts b/packages/paypal-commerce-integration/src/paypal-commerce/create-paypal-commerce-wallet-strategy.ts new file mode 100644 index 0000000000..58d576c768 --- /dev/null +++ b/packages/paypal-commerce-integration/src/paypal-commerce/create-paypal-commerce-wallet-strategy.ts @@ -0,0 +1,23 @@ +import { getScriptLoader } from '@bigcommerce/script-loader'; + +import { toResolvableModule } from '@bigcommerce/checkout-sdk/payment-integration-api'; +import { WalletButtonStrategyFactory } from '@bigcommerce/checkout-sdk/wallet-button-integration'; + +import PayPalCommerceScriptLoader from '../paypal-commerce-script-loader'; +import PaypalCommerceWalletService from '../paypal-commerce-wallet-service'; + +import PaypalCommerceWalletStrategy from './paypal-commerce-wallet-strategy'; + +const createPaypalCommerceWalletStrategy: WalletButtonStrategyFactory< + PaypalCommerceWalletStrategy +> = (walletButtonIntegrationService) => + new PaypalCommerceWalletStrategy( + new PaypalCommerceWalletService( + walletButtonIntegrationService, + new PayPalCommerceScriptLoader(getScriptLoader()), + ), + ); + +export default toResolvableModule(createPaypalCommerceWalletStrategy, [ + { id: 'paypalcommercepaypal' }, +]); diff --git a/packages/paypal-commerce-integration/src/paypal-commerce/paypal-commerce-wallet-initialize-options.ts b/packages/paypal-commerce-integration/src/paypal-commerce/paypal-commerce-wallet-initialize-options.ts new file mode 100644 index 0000000000..5437d61dd2 --- /dev/null +++ b/packages/paypal-commerce-integration/src/paypal-commerce/paypal-commerce-wallet-initialize-options.ts @@ -0,0 +1,12 @@ +export default interface PaypalCommerceWalletInitializeOptions { + cartId: string; + currency: { + code: string; + }; + initializationData: string; + clientToken: string; +} + +export interface WithPayPalCommerceWalletInitializeOptions { + paypalcommercepaypal?: PaypalCommerceWalletInitializeOptions; +} diff --git a/packages/paypal-commerce-integration/src/paypal-commerce/paypal-commerce-wallet-strategy.ts b/packages/paypal-commerce-integration/src/paypal-commerce/paypal-commerce-wallet-strategy.ts new file mode 100644 index 0000000000..bc602fda8e --- /dev/null +++ b/packages/paypal-commerce-integration/src/paypal-commerce/paypal-commerce-wallet-strategy.ts @@ -0,0 +1,86 @@ +import { + CheckoutButtonInitializeOptions, + CheckoutButtonStrategy, + InvalidArgumentError, + PaymentMethod, +} from '@bigcommerce/checkout-sdk/payment-integration-api'; +import { PayPalCommerceInitializationData } from '@bigcommerce/checkout-sdk/paypal-commerce-utils'; + +import { ApproveCallbackPayload, PayPalCommerceButtonsOptions } from '../paypal-commerce-types'; +import PaypalCommerceWalletService from '../paypal-commerce-wallet-service'; + +import { WithPayPalCommerceWalletInitializeOptions } from './paypal-commerce-wallet-initialize-options'; + +export default class PaypalCommerceWalletStrategy implements CheckoutButtonStrategy { + constructor(private paypalCommerceHeadlessWalletButtonService: PaypalCommerceWalletService) {} + + async initialize( + options: CheckoutButtonInitializeOptions & WithPayPalCommerceWalletInitializeOptions, + ): Promise { + const { paypalcommercepaypal, 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 (!paypalcommercepaypal) { + throw new InvalidArgumentError( + `Unable to initialize payment because "options.paypalcommercepaypal" argument is not provided.`, + ); + } + + const parsedInitializationData: PaymentMethod = + JSON.parse(atob(paypalcommercepaypal.initializationData)); + + await this.paypalCommerceHeadlessWalletButtonService.loadPayPalSdk( + parsedInitializationData, + paypalcommercepaypal.currency.code, + false, + ); + + this.renderButton(containerId, 'paypalcommerce.paypal', paypalcommercepaypal.cartId); + } + + deinitialize(): Promise { + return Promise.resolve(); + } + + private renderButton(containerId: string, methodId: string, cartId: string): void { + const paypalSdk = this.paypalCommerceHeadlessWalletButtonService.getPayPalSdkOrThrow(); + + const defaultCallbacks = { + createOrder: () => + this.paypalCommerceHeadlessWalletButtonService.createPaymentOrderIntent( + methodId, + cartId, + ), + onApprove: ({ orderID }: ApproveCallbackPayload) => + this.paypalCommerceHeadlessWalletButtonService.proxyTokenizationPayment( + cartId, + orderID, + ), + }; + + const buttonRenderOptions: PayPalCommerceButtonsOptions = { + fundingSource: paypalSdk.FUNDING.PAYPAL, + style: this.paypalCommerceHeadlessWalletButtonService.getValidButtonStyle(), + ...defaultCallbacks, + }; + + const paypalButton = paypalSdk.Buttons(buttonRenderOptions); + + if (paypalButton.isEligible()) { + paypalButton.render(`#${containerId}`); + } else { + this.paypalCommerceHeadlessWalletButtonService.removeElement(containerId); + } + } +} diff --git a/packages/wallet-button-integration/.eslintrc.json b/packages/wallet-button-integration/.eslintrc.json new file mode 100644 index 0000000000..0d8b30c70b --- /dev/null +++ b/packages/wallet-button-integration/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["../../.eslintrc.json"] +} diff --git a/packages/wallet-button-integration/README.md b/packages/wallet-button-integration/README.md new file mode 100644 index 0000000000..bd1e63485a --- /dev/null +++ b/packages/wallet-button-integration/README.md @@ -0,0 +1,11 @@ +# wallet-button-integration + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test wallet-button-integration` to execute the unit tests via [Jest](https://jestjs.io). + +## Running lint + +Run `nx lint wallet-button-integration` to execute the lint via [ESLint](https://eslint.org/). diff --git a/packages/wallet-button-integration/jest.config.js b/packages/wallet-button-integration/jest.config.js new file mode 100644 index 0000000000..1740c6cb4d --- /dev/null +++ b/packages/wallet-button-integration/jest.config.js @@ -0,0 +1,12 @@ +module.exports = { + displayName: "wallet-button-integration", + preset: "../../jest.preset.js", + globals: { + "ts-jest": { + tsconfig: "/tsconfig.spec.json", + diagnostics: false, + }, + }, + setupFilesAfterEnv: ["../../jest-setup.js"], + coverageDirectory: "../../coverage/packages/wallet-button-integration", +}; diff --git a/packages/wallet-button-integration/project.json b/packages/wallet-button-integration/project.json new file mode 100644 index 0000000000..86d3f025a2 --- /dev/null +++ b/packages/wallet-button-integration/project.json @@ -0,0 +1,23 @@ +{ + "root": "packages/wallet-button-integration", + "sourceRoot": "packages/wallet-button-integration/src", + "projectType": "library", + "targets": { + "lint": { + "executor": "@nrwl/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["packages/wallet-button-integration/**/*.ts"] + } + }, + "test": { + "executor": "@nrwl/jest:jest", + "outputs": ["coverage/packages/wallet-button-integration"], + "options": { + "jestConfig": "packages/wallet-button-integration/jest.config.js", + "passWithNoTests": true + } + } + }, + "tags": ["scope:shared"] +} diff --git a/packages/wallet-button-integration/src/billing/billing-address-request-sender.ts b/packages/wallet-button-integration/src/billing/billing-address-request-sender.ts new file mode 100644 index 0000000000..135988aaca --- /dev/null +++ b/packages/wallet-button-integration/src/billing/billing-address-request-sender.ts @@ -0,0 +1,162 @@ +import { RequestSender, Response } from '@bigcommerce/request-sender'; + +import { GraphQLRequestOptions } from '../graphql-request-options'; + +import { + AddressRequestBody, + BillingAddressResponse, + BillingAddressResponseBody, + BillingAddressUpdateRequestBody, +} from './billing-address'; + +export default class BillingAddressRequestSender { + constructor(private readonly requestSender: RequestSender) {} + + async updateBillingAddress( + graphQLEndpoint: string, + checkoutId: string, + address: BillingAddressUpdateRequestBody, + options?: GraphQLRequestOptions, + ): Promise> { + const document = ` + mutation UpdateCheckoutBillingAddressMutation( + $input: UpdateCheckoutBillingAddressInput! + ) { + checkout { + updateCheckoutBillingAddress(input: $input) { + checkout { + billingAddress { + address1 + address2 + city + company + countryCode + email + entityId + firstName + lastName + phone + postalCode + stateOrProvince + stateOrProvinceCode + } + } + } + } + } + `; + + const { id, ...billingAddressFields } = address; + + const requestOptions: GraphQLRequestOptions = { + headers: { + ...options?.headers, + 'Content-Type': 'application/json', + }, + body: { + ...options?.body, + document, + variables: JSON.stringify({ + input: { + checkoutEntityId: checkoutId, + addressEntityId: address.id, + data: { + address: { + ...billingAddressFields, + }, + }, + }, + }), + }, + }; + + const response = await this.requestSender.post( + `${window.location.origin}/${graphQLEndpoint}`, + requestOptions, + ); + + return this.transformToBillingAddressResponse(response); + } + + async addBillingAddress( + graphQLEndpoint: string, + checkoutId: string, + address: AddressRequestBody, + options?: GraphQLRequestOptions, + ): Promise> { + const document = ` + mutation AddCheckoutBillingAddressMutation( + $input: AddCheckoutBillingAddressInput! + ) { + checkout { + addCheckoutBillingAddress(input: $input) { + checkout { + billingAddress { + address1 + address2 + city + company + countryCode + email + entityId + firstName + lastName + phone + postalCode + stateOrProvince + stateOrProvinceCode + } + } + } + } + } + `; + + const requestOptions: GraphQLRequestOptions = { + headers: { + ...options?.headers, + 'Content-Type': 'application/json', + }, + body: { + ...options?.body, + document, + variables: JSON.stringify({ + input: { + checkoutEntityId: checkoutId, + data: { + address: { + ...address, + }, + }, + }, + }), + }, + }; + + const response = await this.requestSender.post( + `${window.location.origin}/${graphQLEndpoint}`, + requestOptions, + ); + + return this.transformToBillingAddressResponse(response); + } + + private transformToBillingAddressResponse( + response: Response, + ): Response { + const { + body: { + data: { + site: { + checkout: { billingAddress }, + }, + }, + }, + } = response; + + return { + ...response, + body: billingAddress, + }; + } +} diff --git a/packages/wallet-button-integration/src/billing/billing-address.ts b/packages/wallet-button-integration/src/billing/billing-address.ts new file mode 100644 index 0000000000..2ec128088a --- /dev/null +++ b/packages/wallet-button-integration/src/billing/billing-address.ts @@ -0,0 +1,32 @@ +export interface BillingAddressResponseBody { + data: { + site: { + checkout: { + billingAddress: BillingAddressResponse; + }; + }; + }; +} + +export interface BillingAddressResponse extends AddressRequestBody { + entityId: string; +} + +export interface BillingAddressUpdateRequestBody extends AddressRequestBody { + id: string; +} + +export interface AddressRequestBody { + firstName: string; + lastName: string; + company: string; + address1: string; + address2: string; + city: string; + email: string; + stateOrProvince: string; + stateOrProvinceCode: string; + countryCode: string; + postalCode: string; + phone: string; +} diff --git a/packages/wallet-button-integration/src/create-wallet-button-integration-service.ts b/packages/wallet-button-integration/src/create-wallet-button-integration-service.ts new file mode 100644 index 0000000000..190c94691b --- /dev/null +++ b/packages/wallet-button-integration/src/create-wallet-button-integration-service.ts @@ -0,0 +1,17 @@ +import { createRequestSender } from '@bigcommerce/request-sender'; + +import BillingAddressRequestSender from './billing/billing-address-request-sender'; +import { PaymentRequestSender } from './payment/payment-request-sender'; +import WalletButtonIntegrationService from './wallet-button-integration-service'; + +const createWalletButtonIntegrationService = (graphQLEndpoint: string) => { + const requestSender = createRequestSender(); + + return new WalletButtonIntegrationService( + graphQLEndpoint, + new BillingAddressRequestSender(requestSender), + new PaymentRequestSender(requestSender), + ); +}; + +export default createWalletButtonIntegrationService; diff --git a/packages/wallet-button-integration/src/graphql-request-options.ts b/packages/wallet-button-integration/src/graphql-request-options.ts new file mode 100644 index 0000000000..f665a84630 --- /dev/null +++ b/packages/wallet-button-integration/src/graphql-request-options.ts @@ -0,0 +1,6 @@ +import { RequestOptions } from '@bigcommerce/checkout-sdk/payment-integration-api'; + +export interface GraphQLRequestOptions extends RequestOptions { + body?: { document: string; variables: string }; + headers?: { [key: string]: string }; +} diff --git a/packages/wallet-button-integration/src/index.ts b/packages/wallet-button-integration/src/index.ts new file mode 100644 index 0000000000..12d4ef8d94 --- /dev/null +++ b/packages/wallet-button-integration/src/index.ts @@ -0,0 +1,4 @@ +export { default as createWalletButtonIntegrationService } from './create-wallet-button-integration-service'; +export { default as WalletButtonIntegrationService } from './wallet-button-integration-service'; +export { default as WalletButtonStrategyFactory } from './wallet-button-factory'; +export { GraphQLRequestOptions } from './graphql-request-options'; diff --git a/packages/wallet-button-integration/src/payment/payment-order-intent-creation-error.ts b/packages/wallet-button-integration/src/payment/payment-order-intent-creation-error.ts new file mode 100644 index 0000000000..ab46128f33 --- /dev/null +++ b/packages/wallet-button-integration/src/payment/payment-order-intent-creation-error.ts @@ -0,0 +1,13 @@ +import { StandardError } from '@bigcommerce/checkout-sdk/payment-integration-api'; + +export default class PaymentOrderCreationError extends StandardError { + constructor(message?: string) { + super( + message || + 'An unexpected error has occurred during payment order creation process. Please try again later.', + ); + + this.name = 'PaymentOrderCreationError'; + this.type = 'payment_order_creation_error'; + } +} diff --git a/packages/wallet-button-integration/src/payment/payment-request-sender.ts b/packages/wallet-button-integration/src/payment/payment-request-sender.ts new file mode 100644 index 0000000000..4d3f1310f7 --- /dev/null +++ b/packages/wallet-button-integration/src/payment/payment-request-sender.ts @@ -0,0 +1,168 @@ +import { RequestSender, Response } from '@bigcommerce/request-sender'; + +import { PaymentMethodCancelledError } from '@bigcommerce/checkout-sdk/payment-integration-api'; + +import { GraphQLRequestOptions } from '../graphql-request-options'; + +import { + CreatePaymentOrderIntentInputData, + CreatePaymentOrderIntentResponse, + CreatePaymentOrderIntentResponseBody, + CreateRedirectToCheckoutResponse, + CreateRedirectToCheckoutResponseBody, + RedirectToCheckoutUrlInputData, +} from './payment'; +import PaymentOrderCreationError from './payment-order-intent-creation-error'; + +export class PaymentRequestSender { + constructor(private readonly requestSender: RequestSender) {} + + async createPaymentOrderIntent( + graphQLEndpoint: string, + inputData: CreatePaymentOrderIntentInputData, + options?: GraphQLRequestOptions, + ): Promise> { + const document = ` + mutation CreatePaymentWalletIntentMutation( + $input: CreatePaymentWalletIntentInput! + ) { + payment { + paymentWallet { + createPaymentWalletIntent(input: $input) { + paymentWalletIntentData { + __typename + ... on PayPalCommercePaymentWalletIntentData { + orderId + approvalUrl + initializationEntityId + } + } + errors { + __typename + ... on CreatePaymentWalletIntentGenericError { + message + } + ... on Error { + message + } + } + } + } + } + } + `; + + const requestOptions: GraphQLRequestOptions = { + headers: { + ...options?.headers, + 'Content-Type': 'application/json', + }, + body: { + ...options?.body, + document, + variables: JSON.stringify({ + input: inputData, + }), + }, + }; + + try { + const response = await this.requestSender.post( + `${window.location.origin}/${graphQLEndpoint}`, + requestOptions, + ); + + const { + data: { + payment: { + paymentWallet: { + createPaymentWalletIntent: { paymentWalletIntentData, errors }, + }, + }, + }, + } = response.body; + + const errorMessage = errors[0]?.message; + + if (errorMessage) { + throw new PaymentOrderCreationError(errorMessage); + } + + return { + ...response, + body: { + ...paymentWalletIntentData, + }, + }; + } catch (error) { + if (!(error instanceof PaymentOrderCreationError)) { + throw new PaymentMethodCancelledError(); + } + + throw error; + } + } + + async getRedirectToCheckoutUrl( + graphQLEndpoint: string, + inputData: RedirectToCheckoutUrlInputData, + options?: GraphQLRequestOptions, + ): Promise> { + const document = ` + mutation CheckoutRedirectMutation($input: CreateCartRedirectUrlsInput!) { + cart { + createCartRedirectUrls(input: $input) { + errors { + ... on NotFoundError { + __typename + } + } + redirectUrls { + externalCheckoutUrl + } + } + } + } + `; + + const requestOptions: GraphQLRequestOptions = { + headers: { + ...options?.headers, + 'Content-Type': 'application/json', + }, + body: { + ...options?.body, + document, + variables: JSON.stringify({ + input: inputData, + }), + }, + }; + + try { + const response = await this.requestSender.post( + `${window.location.origin}/${graphQLEndpoint}`, + requestOptions, + ); + + const { + data: { + cart: { createCartRedirectUrls }, + }, + } = response.body; + + return { + ...response, + body: { + ...createCartRedirectUrls, + }, + }; + } catch (error) { + if (error instanceof Error) { + throw error; + } + + throw new Error('Checkout redirection failed'); + } + } +} diff --git a/packages/wallet-button-integration/src/payment/payment.ts b/packages/wallet-button-integration/src/payment/payment.ts new file mode 100644 index 0000000000..c90acade9b --- /dev/null +++ b/packages/wallet-button-integration/src/payment/payment.ts @@ -0,0 +1,64 @@ +/** + * + * Create Payment Order Interfaces + * + */ + +export interface CreatePaymentOrderIntentResponse { + data: { + payment: { + paymentWallet: { + createPaymentWalletIntent: { + errors: Array<{ + location: Array<{ line: string; column: string }>; + message: string; + }>; + paymentWalletIntentData: CreatePaymentOrderIntentResponseBody; + }; + }; + }; + }; +} + +export interface CreatePaymentOrderIntentResponseBody { + approvalUrl: string; + orderId: string; + initializationEntityId: string; +} + +export interface CreatePaymentOrderIntentInputData { + cartEntityId: string; + paymentWalletEntityId: string; +} + +/** + * + * Create Redirect To Checkout Interfaces + * + */ + +export interface CreateRedirectToCheckoutResponse { + data: { + cart: { + createCartRedirectUrls: CreateRedirectToCheckoutResponseBody; + }; + }; +} + +export interface CreateRedirectToCheckoutResponseBody { + redirectUrls: { externalCheckoutUrl: string } | null; +} + +export interface QueryParams { + key: string; + value: string; +} + +export interface RedirectToCheckoutUrlInputData { + paymentWalletData: { + providerId: string; + providerOrderId: string; + }; + cartEntityId: string; + queryParams: QueryParams[]; +} diff --git a/packages/wallet-button-integration/src/wallet-button-factory.ts b/packages/wallet-button-integration/src/wallet-button-factory.ts new file mode 100644 index 0000000000..93fabebe49 --- /dev/null +++ b/packages/wallet-button-integration/src/wallet-button-factory.ts @@ -0,0 +1,9 @@ +import { CheckoutButtonStrategy } from '@bigcommerce/checkout-sdk/payment-integration-api'; + +import WalletButtonIntegrationService from './wallet-button-integration-service'; + +type WalletPaymentButtonStrategyFactory = ( + walletPaymentButtonIntegrationService: WalletButtonIntegrationService, +) => TStrategy; + +export default WalletPaymentButtonStrategyFactory; diff --git a/packages/wallet-button-integration/src/wallet-button-integration-service.ts b/packages/wallet-button-integration/src/wallet-button-integration-service.ts new file mode 100644 index 0000000000..ad251ecd3a --- /dev/null +++ b/packages/wallet-button-integration/src/wallet-button-integration-service.ts @@ -0,0 +1,55 @@ +import { Response } from '@bigcommerce/request-sender'; + +import { BillingAddressResponse, BillingAddressUpdateRequestBody } from './billing/billing-address'; +import BillingAddressRequestSender from './billing/billing-address-request-sender'; +import { GraphQLRequestOptions } from './graphql-request-options'; +import { + CreatePaymentOrderIntentInputData, + CreatePaymentOrderIntentResponseBody, + CreateRedirectToCheckoutResponseBody, + RedirectToCheckoutUrlInputData, +} from './payment/payment'; +import { PaymentRequestSender } from './payment/payment-request-sender'; + +export default class WalletButtonIntegrationService { + constructor( + private graphQLEndpoint: string, + private billingAddressRequestSender: BillingAddressRequestSender, + private paymentRequestSender: PaymentRequestSender, + ) {} + + async updateBillingAddress( + checkoutId: string, + address: BillingAddressUpdateRequestBody, + options?: GraphQLRequestOptions, + ): Promise> { + return this.billingAddressRequestSender.updateBillingAddress( + this.graphQLEndpoint, + checkoutId, + address, + options, + ); + } + + async createPaymentOrderIntent( + inputData: CreatePaymentOrderIntentInputData, + options?: GraphQLRequestOptions, + ): Promise> { + return this.paymentRequestSender.createPaymentOrderIntent( + this.graphQLEndpoint, + inputData, + options, + ); + } + + async getRedirectToCheckoutUrl( + inputData: RedirectToCheckoutUrlInputData, + options?: GraphQLRequestOptions, + ): Promise> { + return this.paymentRequestSender.getRedirectToCheckoutUrl( + this.graphQLEndpoint, + inputData, + options, + ); + } +} diff --git a/packages/wallet-button-integration/tsconfig.json b/packages/wallet-button-integration/tsconfig.json new file mode 100644 index 0000000000..02ec15ac42 --- /dev/null +++ b/packages/wallet-button-integration/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.base.json" +} diff --git a/packages/wallet-button-integration/tsconfig.spec.json b/packages/wallet-button-integration/tsconfig.spec.json new file mode 100644 index 0000000000..2c1fb69d9f --- /dev/null +++ b/packages/wallet-button-integration/tsconfig.spec.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "**/*.test.ts", + "**/*.spec.ts", + "**/*.test.tsx", + "**/*.spec.tsx", + "**/*.test.js", + "**/*.spec.js", + "**/*.test.jsx", + "**/*.spec.jsx", + "**/*.d.ts" + ] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 2066c966b7..96225482cf 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -41,10 +41,12 @@ "@bigcommerce/checkout-sdk/apple-pay-integration": [ "packages/apple-pay-integration/src/index.ts" ], - "@bigcommerce/checkout-sdk/bigcommerce-payments-utils": ["packages/bigcommerce-payments-utils/src/index.ts"], "@bigcommerce/checkout-sdk/bigcommerce-payments-integration": [ "packages/bigcommerce-payments-integration/src/index.ts" ], + "@bigcommerce/checkout-sdk/bigcommerce-payments-utils": [ + "packages/bigcommerce-payments-utils/src/index.ts" + ], "@bigcommerce/checkout-sdk/bluesnap-direct-integration": [ "packages/bluesnap-direct-integration/src/index.ts" ], @@ -137,6 +139,9 @@ ], "@bigcommerce/checkout-sdk/ui": ["packages/ui/src/index.ts"], "@bigcommerce/checkout-sdk/utility": ["packages/utility/src/index.ts"], + "@bigcommerce/checkout-sdk/wallet-button-integration": [ + "packages/wallet-button-integration/src/index.ts" + ], "@bigcommerce/checkout-sdk/workspace-tools": ["packages/workspace-tools/src/index.ts"], "@bigcommerce/checkout-sdk/worldpayaccess-integration": [ "packages/worldpayaccess-integration/src/index.ts" diff --git a/webpack-common.config.js b/webpack-common.config.js index 84115367da..9ff84f0606 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'), + 'wallet-button': path.join(coreSrcPath, 'bundles', 'wallet-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'), diff --git a/workspace.json b/workspace.json index ae4a285127..8ce73156c0 100644 --- a/workspace.json +++ b/workspace.json @@ -9,8 +9,8 @@ "amazon-pay-utils": "packages/amazon-pay-utils", "analytics": "packages/analytics", "apple-pay-integration": "packages/apple-pay-integration", - "bigcommerce-payments-utils": "packages/bigcommerce-payments-utils", "bigcommerce-payments-integration": "packages/bigcommerce-payments-integration", + "bigcommerce-payments-utils": "packages/bigcommerce-payments-utils", "bluesnap-direct-integration": "packages/bluesnap-direct-integration", "bolt-integration": "packages/bolt-integration", "braintree-integration": "packages/braintree-integration", @@ -47,6 +47,7 @@ "td-bank-integration": "packages/td-bank-integration", "ui": "packages/ui", "utility": "packages/utility", + "wallet-button-integration": "packages/wallet-button-integration", "workspace-tools": "packages/workspace-tools", "worldpayaccess-integration": "packages/worldpayaccess-integration", "zip-integration": "packages/zip-integration"