diff --git a/packages/bigcommerce-payments-integration/src/bigcommerce-payments-venmo/bigcommerce-payments-venmo-wallet-initialize-options.ts b/packages/bigcommerce-payments-integration/src/bigcommerce-payments-venmo/bigcommerce-payments-venmo-wallet-initialize-options.ts new file mode 100644 index 0000000000..1b838a282f --- /dev/null +++ b/packages/bigcommerce-payments-integration/src/bigcommerce-payments-venmo/bigcommerce-payments-venmo-wallet-initialize-options.ts @@ -0,0 +1,12 @@ +export default interface BigCommercePaymentsVenmoWalletInitializeOptions { + cartId: string; + currency: { + code: string; + }; + initializationData: string; + clientToken: string; +} + +export interface WithBigCommercePaymentsVenmoWalletInitializeOptions { + bigcommerce_paymentsvenmo?: BigCommercePaymentsVenmoWalletInitializeOptions; +} diff --git a/packages/bigcommerce-payments-integration/src/bigcommerce-payments-venmo/bigcommerce-payments-venmo-wallet-strategy.spec.ts b/packages/bigcommerce-payments-integration/src/bigcommerce-payments-venmo/bigcommerce-payments-venmo-wallet-strategy.spec.ts new file mode 100644 index 0000000000..b6ec050f93 --- /dev/null +++ b/packages/bigcommerce-payments-integration/src/bigcommerce-payments-venmo/bigcommerce-payments-venmo-wallet-strategy.spec.ts @@ -0,0 +1,262 @@ +import { + CheckoutButtonInitializeOptions, + InvalidArgumentError, +} from '@bigcommerce/checkout-sdk/payment-integration-api'; +import { PaypalCommerceWalletService } from '@bigcommerce/checkout-sdk/paypal-utils'; + +import { getPayPalSDKMock } from '../mocks'; + +import { WithBigCommercePaymentsVenmoWalletInitializeOptions } from './bigcommerce-payments-venmo-wallet-initialize-options'; +import BigCommercePaymentsVenmoWalletStrategy from './bigcommerce-payments-venmo-wallet-strategy'; + +describe('BigCommercePaymentsVenmoWalletStrategy', () => { + let strategy: BigCommercePaymentsVenmoWalletStrategy; + let bigCommercePaymentsVenmoWalletService: jest.Mocked; + + const defaultContainerId = 'bigcommerce-payments-venmo-wallet-button'; + const defaultMethodId = 'bigcommerce_payments_venmo'; + const defaultProviderId = 'bigcommerce_payments.venmo'; + const defaultCartId = 'abc123'; + const defaultOrderId = 'ORDER_ID'; + const defaultButtonStyle = { color: 'blue', label: 'checkout' }; + + const initializationOptions: CheckoutButtonInitializeOptions & + WithBigCommercePaymentsVenmoWalletInitializeOptions = { + methodId: defaultMethodId, + containerId: defaultContainerId, + bigcommerce_paymentsvenmo: { + cartId: defaultCartId, + currency: { + code: 'USD', + }, + initializationData: btoa( + JSON.stringify({ + initializationData: { + clientId: 'test-client-id', + paymentButtonStyles: { + cartButtonStyles: defaultButtonStyle, + }, + }, + }), + ), + clientToken: 'test-client-token', + }, + }; + + beforeEach(() => { + const paypalSdk = getPayPalSDKMock(); + + bigCommercePaymentsVenmoWalletService = { + addBillingAddress: jest.fn(), + createPaymentOrderIntent: jest.fn().mockResolvedValue(defaultOrderId), + getPayPalSdkOrThrow: jest.fn().mockReturnValue(paypalSdk), + getValidButtonStyle: jest.fn().mockReturnValue({ height: 45 }), + loadPayPalSdk: jest.fn(), + mapOrderDetailsToBillingAddress: jest.fn(), + proxyTokenizationPayment: jest.fn(), + removeElement: jest.fn(), + } as unknown as jest.Mocked; + + strategy = new BigCommercePaymentsVenmoWalletStrategy( + bigCommercePaymentsVenmoWalletService, + ); + }); + + it('throws when methodId is not provided', async () => { + const optionsWithoutMethodId = { + ...initializationOptions, + methodId: undefined, + } as unknown as CheckoutButtonInitializeOptions & + WithBigCommercePaymentsVenmoWalletInitializeOptions; + + await expect(strategy.initialize(optionsWithoutMethodId)).rejects.toEqual( + new InvalidArgumentError( + 'Unable to initialize payment because "options.methodId" argument is not provided.', + ), + ); + }); + + it('throws when containerId is not provided', async () => { + const optionsWithoutContainerId = { + ...initializationOptions, + containerId: undefined, + } as unknown as CheckoutButtonInitializeOptions & + WithBigCommercePaymentsVenmoWalletInitializeOptions; + + await expect(strategy.initialize(optionsWithoutContainerId)).rejects.toEqual( + new InvalidArgumentError( + `Unable to initialize payment because "options.containerId" argument is not provided.`, + ), + ); + }); + + it('throws when bigcommerce_paymentsvenmo options are not provided', async () => { + const optionsWithoutVenmoOptions = { + ...initializationOptions, + bigcommerce_paymentsvenmo: undefined, + } as unknown as CheckoutButtonInitializeOptions & + WithBigCommercePaymentsVenmoWalletInitializeOptions; + + await expect(strategy.initialize(optionsWithoutVenmoOptions)).rejects.toEqual( + new InvalidArgumentError( + `Unable to initialize payment because "options.bigcommerce_paymentsvenmo" argument is not provided.`, + ), + ); + }); + + it('throws when payment method initializationData cannot be parsed', async () => { + const invalidOptions = { + ...initializationOptions, + bigcommerce_paymentsvenmo: { + ...initializationOptions.bigcommerce_paymentsvenmo!, + initializationData: '%%%invalid-base64%%%', + }, + }; + + await expect(strategy.initialize(invalidOptions)).rejects.toEqual( + new InvalidArgumentError("Failed to parse payment method 'initializationData'."), + ); + }); + + it('loads PayPal SDK with parsed initialization data and currency', async () => { + await strategy.initialize(initializationOptions); + + expect(bigCommercePaymentsVenmoWalletService.loadPayPalSdk).toHaveBeenCalledWith( + expect.objectContaining({ + initializationData: expect.objectContaining({ clientId: 'test-client-id' }), + }), + 'USD', + false, + ); + }); + + it('renders venmo button with VENMO funding source', async () => { + const paypalButtons = { + close: jest.fn(), + isEligible: jest.fn().mockReturnValue(true), + render: jest.fn(), + }; + const paypalSdk = bigCommercePaymentsVenmoWalletService.getPayPalSdkOrThrow(); + const buttonsSpy = jest.spyOn(paypalSdk, 'Buttons').mockReturnValue(paypalButtons); + + await strategy.initialize(initializationOptions); + + const buttonOptions = buttonsSpy.mock.calls[0][0]; + + expect(buttonOptions.fundingSource).toEqual(paypalSdk.FUNDING.VENMO); + expect(paypalButtons.render).toHaveBeenCalledWith(`#${defaultContainerId}`); + }); + + it('applies cart button styles from parsed initializationData', async () => { + const paypalButtons = { + close: jest.fn(), + isEligible: jest.fn().mockReturnValue(true), + render: jest.fn(), + }; + const paypalSdk = bigCommercePaymentsVenmoWalletService.getPayPalSdkOrThrow(); + + jest.spyOn(paypalSdk, 'Buttons').mockReturnValue(paypalButtons); + + await strategy.initialize(initializationOptions); + + expect(bigCommercePaymentsVenmoWalletService.getValidButtonStyle).toHaveBeenCalledWith( + defaultButtonStyle, + ); + }); + + it('creates payment order intent with correct provider ID', async () => { + const paypalButtons = { + close: jest.fn(), + isEligible: jest.fn().mockReturnValue(true), + render: jest.fn(), + }; + const paypalSdk = bigCommercePaymentsVenmoWalletService.getPayPalSdkOrThrow(); + const buttonsSpy = jest.spyOn(paypalSdk, 'Buttons').mockReturnValue(paypalButtons); + + await strategy.initialize(initializationOptions); + + const buttonOptions = buttonsSpy.mock.calls[0][0]; + + await buttonOptions.createOrder(); + + expect(bigCommercePaymentsVenmoWalletService.createPaymentOrderIntent).toHaveBeenCalledWith( + defaultProviderId, + defaultCartId, + ); + }); + + it('proxies tokenization payment on approve', async () => { + const paypalButtons = { + close: jest.fn(), + isEligible: jest.fn().mockReturnValue(true), + render: jest.fn(), + }; + const paypalSdk = bigCommercePaymentsVenmoWalletService.getPayPalSdkOrThrow(); + const buttonsSpy = jest.spyOn(paypalSdk, 'Buttons').mockReturnValue(paypalButtons); + + await strategy.initialize(initializationOptions); + + const buttonOptions = buttonsSpy.mock.calls[0][0]; + + await buttonOptions.onApprove?.({ orderID: defaultOrderId }, { order: { get: jest.fn() } }); + + expect(bigCommercePaymentsVenmoWalletService.proxyTokenizationPayment).toHaveBeenCalledWith( + defaultCartId, + defaultProviderId, + 'bigcommerce_payments_venmo', + defaultOrderId, + ); + }); + + it('removes element when button is not eligible', async () => { + const paypalButtons = { + close: jest.fn(), + isEligible: jest.fn().mockReturnValue(false), + render: jest.fn(), + }; + const paypalSdk = bigCommercePaymentsVenmoWalletService.getPayPalSdkOrThrow(); + + jest.spyOn(paypalSdk, 'Buttons').mockReturnValue(paypalButtons); + + await strategy.initialize(initializationOptions); + + expect(bigCommercePaymentsVenmoWalletService.removeElement).toHaveBeenCalledWith( + defaultContainerId, + ); + }); + + it('uses fallback style when cartButtonStyles is not provided', async () => { + const optionsWithoutButtonStyle: CheckoutButtonInitializeOptions & + WithBigCommercePaymentsVenmoWalletInitializeOptions = { + methodId: defaultMethodId, + containerId: defaultContainerId, + bigcommerce_paymentsvenmo: { + cartId: defaultCartId, + currency: { code: 'USD' }, + initializationData: btoa( + JSON.stringify({ initializationData: { clientId: 'test-client-id' } }), + ), + clientToken: 'test-client-token', + }, + }; + + const paypalButtons = { + close: jest.fn(), + isEligible: jest.fn().mockReturnValue(true), + render: jest.fn(), + }; + const paypalSdk = bigCommercePaymentsVenmoWalletService.getPayPalSdkOrThrow(); + + jest.spyOn(paypalSdk, 'Buttons').mockReturnValue(paypalButtons); + + await strategy.initialize(optionsWithoutButtonStyle); + + expect(bigCommercePaymentsVenmoWalletService.getValidButtonStyle).toHaveBeenCalledWith( + undefined, + ); + }); + + it('resolves on deinitialize', async () => { + await expect(strategy.deinitialize()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/bigcommerce-payments-integration/src/bigcommerce-payments-venmo/bigcommerce-payments-venmo-wallet-strategy.ts b/packages/bigcommerce-payments-integration/src/bigcommerce-payments-venmo/bigcommerce-payments-venmo-wallet-strategy.ts new file mode 100644 index 0000000000..d0f132d9cd --- /dev/null +++ b/packages/bigcommerce-payments-integration/src/bigcommerce-payments-venmo/bigcommerce-payments-venmo-wallet-strategy.ts @@ -0,0 +1,129 @@ +import { + CheckoutButtonInitializeOptions, + CheckoutButtonStrategy, + InvalidArgumentError, + PaymentMethod, +} from '@bigcommerce/checkout-sdk/payment-integration-api'; +import { PaypalCommerceWalletService } from '@bigcommerce/checkout-sdk/paypal-utils'; + +import { + ApproveCallbackPayload, + BigCommercePaymentsButtonsOptions, + BigCommercePaymentsInitializationData, + PayPalButtonStyleOptions, + StyleButtonColor, +} from '../bigcommerce-payments-types'; + +import { WithBigCommercePaymentsVenmoWalletInitializeOptions } from './bigcommerce-payments-venmo-wallet-initialize-options'; + +export default class BigCommercePaymentsVenmoWalletStrategy implements CheckoutButtonStrategy { + constructor(private bigCommercePaymentsVenmoWalletService: PaypalCommerceWalletService) {} + + async initialize( + options: CheckoutButtonInitializeOptions & + WithBigCommercePaymentsVenmoWalletInitializeOptions, + ): Promise { + const { bigcommerce_paymentsvenmo, 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 (!bigcommerce_paymentsvenmo) { + throw new InvalidArgumentError( + `Unable to initialize payment because "options.bigcommerce_paymentsvenmo" argument is not provided.`, + ); + } + + let parsedInitializationData: PaymentMethod; + + try { + parsedInitializationData = JSON.parse( + atob(bigcommerce_paymentsvenmo.initializationData), + ); + } catch { + throw new InvalidArgumentError("Failed to parse payment method 'initializationData'."); + } + + const buttonStyle = + parsedInitializationData.initializationData?.paymentButtonStyles?.cartButtonStyles; + + await this.bigCommercePaymentsVenmoWalletService.loadPayPalSdk( + parsedInitializationData, + bigcommerce_paymentsvenmo.currency.code, + false, + ); + + this.renderButton( + containerId, + 'bigcommerce_payments.venmo', + bigcommerce_paymentsvenmo.cartId, + buttonStyle, + ); + } + + deinitialize(): Promise { + return Promise.resolve(); + } + + private renderButton( + containerId: string, + providerId: string, + cartId: string, + buttonStyle?: PayPalButtonStyleOptions, + ): void { + const paypalSdk = this.bigCommercePaymentsVenmoWalletService.getPayPalSdkOrThrow(); + + const defaultCallbacks = { + createOrder: () => + this.bigCommercePaymentsVenmoWalletService.createPaymentOrderIntent( + providerId, + cartId, + ), + onApprove: async ({ orderID }: ApproveCallbackPayload) => { + await this.bigCommercePaymentsVenmoWalletService.proxyTokenizationPayment( + cartId, + providerId, + 'bigcommerce_payments_venmo', + orderID, + ); + }, + }; + + const buttonRenderOptions: BigCommercePaymentsButtonsOptions = { + fundingSource: paypalSdk.FUNDING.VENMO, + style: this.getValidVenmoButtonStyles(buttonStyle), + ...defaultCallbacks, + }; + + const paypalButton = paypalSdk.Buttons(buttonRenderOptions); + + if (paypalButton.isEligible()) { + paypalButton.render(`#${containerId}`); + } else { + this.bigCommercePaymentsVenmoWalletService.removeElement(containerId); + } + } + + private getValidVenmoButtonStyles(style: PayPalButtonStyleOptions | undefined) { + const validButtonStyle = + this.bigCommercePaymentsVenmoWalletService.getValidButtonStyle(style); + + if (validButtonStyle.color === StyleButtonColor.gold) { + return { + ...validButtonStyle, + color: undefined, + }; + } + + return validButtonStyle; + } +} diff --git a/packages/bigcommerce-payments-integration/src/bigcommerce-payments-venmo/create-bigcommerce-payments-venmo-wallet-strategy.spec.ts b/packages/bigcommerce-payments-integration/src/bigcommerce-payments-venmo/create-bigcommerce-payments-venmo-wallet-strategy.spec.ts new file mode 100644 index 0000000000..6c49e7ca19 --- /dev/null +++ b/packages/bigcommerce-payments-integration/src/bigcommerce-payments-venmo/create-bigcommerce-payments-venmo-wallet-strategy.spec.ts @@ -0,0 +1,23 @@ +import { + createWalletButtonIntegrationService, + WalletButtonIntegrationService, +} from '@bigcommerce/checkout-sdk/wallet-button-integration'; + +import BigCommercePaymentsVenmoWalletStrategy from './bigcommerce-payments-venmo-wallet-strategy'; +import createBigCommercePaymentsVenmoWalletStrategy from './create-bigcommerce-payments-venmo-wallet-strategy'; + +describe('createBigCommercePaymentsVenmoWalletStrategy', () => { + let walletButtonIntegrationService: WalletButtonIntegrationService; + + beforeEach(() => { + walletButtonIntegrationService = createWalletButtonIntegrationService('/graphql'); + }); + + it('instantiates BigCommercePaymentsVenmoWalletStrategy', () => { + const strategy = createBigCommercePaymentsVenmoWalletStrategy( + walletButtonIntegrationService, + ); + + expect(strategy).toBeInstanceOf(BigCommercePaymentsVenmoWalletStrategy); + }); +}); diff --git a/packages/bigcommerce-payments-integration/src/bigcommerce-payments-venmo/create-bigcommerce-payments-venmo-wallet-strategy.ts b/packages/bigcommerce-payments-integration/src/bigcommerce-payments-venmo/create-bigcommerce-payments-venmo-wallet-strategy.ts new file mode 100644 index 0000000000..eb66fcb721 --- /dev/null +++ b/packages/bigcommerce-payments-integration/src/bigcommerce-payments-venmo/create-bigcommerce-payments-venmo-wallet-strategy.ts @@ -0,0 +1,23 @@ +import { toResolvableModule } from '@bigcommerce/checkout-sdk/payment-integration-api'; +import { + createPayPalSdkScriptLoader, + PaypalCommerceWalletService, +} from '@bigcommerce/checkout-sdk/paypal-utils'; +import { WalletPaymentButtonStrategyFactory } from '@bigcommerce/checkout-sdk/wallet-button-integration'; + +import BigCommercePaymentsVenmoWalletStrategy from './bigcommerce-payments-venmo-wallet-strategy'; + +const createBigCommercePaymentsVenmoWalletStrategy: WalletPaymentButtonStrategyFactory< + BigCommercePaymentsVenmoWalletStrategy +> = (walletButtonIntegrationService) => + new BigCommercePaymentsVenmoWalletStrategy( + new PaypalCommerceWalletService( + walletButtonIntegrationService, + createPayPalSdkScriptLoader(), + 'BigcommercePaymentWalletIntentData', + ), + ); + +export default toResolvableModule(createBigCommercePaymentsVenmoWalletStrategy, [ + { id: 'bigcommerce_paymentsvenmo' }, +]); diff --git a/packages/bigcommerce-payments-integration/src/index.ts b/packages/bigcommerce-payments-integration/src/index.ts index 3934b2d8aa..1b6a0b92bb 100644 --- a/packages/bigcommerce-payments-integration/src/index.ts +++ b/packages/bigcommerce-payments-integration/src/index.ts @@ -100,3 +100,6 @@ export { WithBigCommercePaymentsVenmoCustomerInitializeOptions } from './bigcomm export { default as createBigCommercePaymentsVenmoPaymentStrategy } from './bigcommerce-payments-venmo/create-bigcommerce-payments-venmo-payment-strategy'; export { WithBigCommercePaymentsVenmoPaymentInitializeOptions } from './bigcommerce-payments-venmo/bigcommerce-payments-venmo-payment-initialize-options'; + +export { default as createBigCommercePaymentsVenmoWalletStrategy } from './bigcommerce-payments-venmo/create-bigcommerce-payments-venmo-wallet-strategy'; +export { WithBigCommercePaymentsVenmoWalletInitializeOptions } from './bigcommerce-payments-venmo/bigcommerce-payments-venmo-wallet-initialize-options';