diff --git a/packages/paypal-commerce-integration/src/paypal-commerce-alternative-methods/create-paypal-commerce-alternative-methods-payment-strategy.ts b/packages/paypal-commerce-integration/src/paypal-commerce-alternative-methods/create-paypal-commerce-alternative-methods-payment-strategy.ts index e3233d2348..8ec3c52b80 100644 --- a/packages/paypal-commerce-integration/src/paypal-commerce-alternative-methods/create-paypal-commerce-alternative-methods-payment-strategy.ts +++ b/packages/paypal-commerce-integration/src/paypal-commerce-alternative-methods/create-paypal-commerce-alternative-methods-payment-strategy.ts @@ -2,6 +2,7 @@ import { PaymentStrategyFactory, toResolvableModule, } from '@bigcommerce/checkout-sdk/payment-integration-api'; +import { createPayPalCommerceSdk } from '@bigcommerce/checkout-sdk/paypal-commerce-utils'; import { LoadingIndicator } from '@bigcommerce/checkout-sdk/ui'; import createPayPalCommerceIntegrationService from '../create-paypal-commerce-integration-service'; @@ -15,6 +16,7 @@ const createPayPalCommerceAlternativeMethodsPaymentStrategy: PaymentStrategyFact new PayPalCommerceAlternativeMethodsPaymentStrategy( paymentIntegrationService, createPayPalCommerceIntegrationService(paymentIntegrationService), + createPayPalCommerceSdk(), new LoadingIndicator({ containerStyles: LOADING_INDICATOR_STYLES, }), diff --git a/packages/paypal-commerce-integration/src/paypal-commerce-alternative-methods/paypal-commerce-alternative-methods-payment-strategy.spec.ts b/packages/paypal-commerce-integration/src/paypal-commerce-alternative-methods/paypal-commerce-alternative-methods-payment-strategy.spec.ts index ea971c8a35..340c909d1a 100644 --- a/packages/paypal-commerce-integration/src/paypal-commerce-alternative-methods/paypal-commerce-alternative-methods-payment-strategy.spec.ts +++ b/packages/paypal-commerce-integration/src/paypal-commerce-alternative-methods/paypal-commerce-alternative-methods-payment-strategy.spec.ts @@ -14,6 +14,10 @@ import { getBillingAddress, PaymentIntegrationServiceMock, } from '@bigcommerce/checkout-sdk/payment-integrations-test-utils'; +import { + createPayPalCommerceSdk, + PayPalCommerceSdk, +} from '@bigcommerce/checkout-sdk/paypal-commerce-utils'; import { LoadingIndicator } from '@bigcommerce/checkout-sdk/ui'; import { @@ -43,6 +47,7 @@ describe('PayPalCommerceAlternativeMethodsPaymentStrategy', () => { let paypalCommerceIntegrationService: PayPalCommerceIntegrationService; let paypalSdk: PayPalSDK; let strategy: PayPalCommerceAlternativeMethodsPaymentStrategy; + let paypalCommerceSdk: PayPalCommerceSdk; const paypalOrderId = 'paypal123'; @@ -78,10 +83,12 @@ describe('PayPalCommerceAlternativeMethodsPaymentStrategy', () => { loadingIndicator = new LoadingIndicator(); paypalCommerceIntegrationService = getPayPalCommerceIntegrationServiceMock(); paymentIntegrationService = new PaymentIntegrationServiceMock(); + paypalCommerceSdk = createPayPalCommerceSdk(); strategy = new PayPalCommerceAlternativeMethodsPaymentStrategy( paymentIntegrationService, paypalCommerceIntegrationService, + paypalCommerceSdk, loadingIndicator, ); @@ -93,10 +100,7 @@ describe('PayPalCommerceAlternativeMethodsPaymentStrategy', () => { 'getBillingAddressOrThrow', ).mockReturnValue(billingAddress); - jest.spyOn(paypalCommerceIntegrationService, 'loadPayPalSdk').mockReturnValue(paypalSdk); - jest.spyOn(paypalCommerceIntegrationService, 'getPayPalSdkOrThrow').mockReturnValue( - paypalSdk, - ); + jest.spyOn(paypalCommerceSdk, 'getPayPalApmsSdk').mockReturnValue(paypalSdk); jest.spyOn(paypalCommerceIntegrationService, 'createOrder').mockReturnValue(paypalOrderId); jest.spyOn(paypalCommerceIntegrationService, 'submitPayment').mockReturnValue(undefined); jest.spyOn(paypalCommerceIntegrationService, 'getOrderStatus').mockReturnValue( @@ -223,15 +227,13 @@ describe('PayPalCommerceAlternativeMethodsPaymentStrategy', () => { await strategy.initialize(initializationOptions); - expect(paypalCommerceIntegrationService.loadPayPalSdk).not.toHaveBeenCalled(); + expect(paypalCommerceSdk.getPayPalApmsSdk).not.toHaveBeenCalled(); }); it('loads paypal sdk', async () => { await strategy.initialize(initializationOptions); - expect(paypalCommerceIntegrationService.loadPayPalSdk).toHaveBeenCalledWith( - defaultMethodId, - ); + expect(paypalCommerceSdk.getPayPalApmsSdk).toHaveBeenCalledWith(paymentMethod, 'USD'); }); }); diff --git a/packages/paypal-commerce-integration/src/paypal-commerce-alternative-methods/paypal-commerce-alternative-methods-payment-strategy.ts b/packages/paypal-commerce-integration/src/paypal-commerce-alternative-methods/paypal-commerce-alternative-methods-payment-strategy.ts index f730235b2c..07f3957a3f 100644 --- a/packages/paypal-commerce-integration/src/paypal-commerce-alternative-methods/paypal-commerce-alternative-methods-payment-strategy.ts +++ b/packages/paypal-commerce-integration/src/paypal-commerce-alternative-methods/paypal-commerce-alternative-methods-payment-strategy.ts @@ -7,10 +7,12 @@ import { PaymentArgumentInvalidError, PaymentInitializeOptions, PaymentIntegrationService, + PaymentMethodClientUnavailableError, PaymentMethodInvalidError, PaymentRequestOptions, PaymentStrategy, } from '@bigcommerce/checkout-sdk/payment-integration-api'; +import { PayPalApmSdk, PayPalCommerceSdk } from '@bigcommerce/checkout-sdk/paypal-commerce-utils'; import { LoadingIndicator } from '@bigcommerce/checkout-sdk/ui'; import PayPalCommerceIntegrationService from '../paypal-commerce-integration-service'; @@ -30,10 +32,12 @@ export default class PayPalCommerceAlternativeMethodsPaymentStrategy implements private loadingIndicatorContainer?: string; private orderId?: string; private paypalButton?: PayPalCommerceButtons; + private paypalApms?: PayPalApmSdk; constructor( private paymentIntegrationService: PaymentIntegrationService, private paypalCommerceIntegrationService: PayPalCommerceIntegrationService, + private paypalCommerceSdk: PayPalCommerceSdk, private loadingIndicator: LoadingIndicator, ) {} @@ -84,7 +88,10 @@ export default class PayPalCommerceAlternativeMethodsPaymentStrategy implements return; } - await this.paypalCommerceIntegrationService.loadPayPalSdk(methodId); + this.paypalApms = await this.paypalCommerceSdk.getPayPalApmsSdk( + paymentMethod, + state.getCartOrThrow().currency.code, + ); this.loadingIndicatorContainer = paypalOptions.container.split('#')[1]; @@ -141,7 +148,7 @@ export default class PayPalCommerceAlternativeMethodsPaymentStrategy implements gatewayId: string, paypalOptions: PayPalCommerceAlternativeMethodsPaymentOptions, ): void { - const paypalSdk = this.paypalCommerceIntegrationService.getPayPalSdkOrThrow(); + const paypalAmpsSdk = this.getPaypalAmpsSdkOrThrow(); const state = this.paymentIntegrationService.getState(); const paymentMethod = state.getPaymentMethodOrThrow( @@ -164,7 +171,7 @@ export default class PayPalCommerceAlternativeMethodsPaymentStrategy implements paypalOptions.onValidate(actions.resolve, actions.reject), }; - this.paypalButton = paypalSdk.Buttons(buttonOptions); + this.paypalButton = paypalAmpsSdk.Buttons(buttonOptions); if (!this.paypalButton.isEligible()) { return; @@ -241,7 +248,7 @@ export default class PayPalCommerceAlternativeMethodsPaymentStrategy implements methodId: string, paypalOptions: PayPalCommerceAlternativeMethodsPaymentOptions, ): void { - const paypalSdk = this.paypalCommerceIntegrationService.getPayPalSdkOrThrow(); + const paypalAmpsSdk = this.getPaypalAmpsSdkOrThrow(); const state = this.paymentIntegrationService.getState(); const { firstName, lastName, email } = state.getBillingAddressOrThrow(); @@ -272,7 +279,7 @@ export default class PayPalCommerceAlternativeMethodsPaymentStrategy implements }, }; - const paypalPaymentFields = paypalSdk.PaymentFields(fieldsOptions); + const paypalPaymentFields = paypalAmpsSdk.PaymentFields(fieldsOptions); paypalPaymentFields.render(apmFieldsContainer); } @@ -298,4 +305,12 @@ export default class PayPalCommerceAlternativeMethodsPaymentStrategy implements private isNonInstantPaymentMethod(methodId: string): boolean { return methodId.toUpperCase() in NonInstantAlternativePaymentMethods; } + + private getPaypalAmpsSdkOrThrow() { + if (!this.paypalApms) { + throw new PaymentMethodClientUnavailableError(); + } + + return this.paypalApms; + } } diff --git a/packages/paypal-commerce-utils/src/paypal-commerce-sdk.spec.ts b/packages/paypal-commerce-utils/src/paypal-commerce-sdk.spec.ts index fc667d301c..78098982db 100644 --- a/packages/paypal-commerce-utils/src/paypal-commerce-sdk.spec.ts +++ b/packages/paypal-commerce-utils/src/paypal-commerce-sdk.spec.ts @@ -9,6 +9,7 @@ import { import { getPayPalCommerceAcceleratedCheckoutPaymentMethod, getPayPalFastlaneSdk } from './mocks'; import PayPalCommerceSdk from './paypal-commerce-sdk'; import { + PayPalApmSdk, PayPalCommerceHostWindow, PayPalFastlaneSdk, PayPalMessagesSdk, @@ -19,23 +20,39 @@ describe('PayPalCommerceSdk', () => { let paymentMethod: PaymentMethod; let paypalFastlaneSdk: PayPalFastlaneSdk; let subject: PayPalCommerceSdk; + let mockAPMPaymentMethod: PaymentMethod; const paypalMessagesSdk: PayPalMessagesSdk = { Messages: jest.fn(), }; + const paypalApmsSdk: PayPalApmSdk = { + Buttons: jest.fn(), + PaymentFields: jest.fn(), + }; + const sessionId = '8a232bf4-d9ba-4621-a1a9-ed8f685f92d1'; const expectedSessionId = sessionId.replace(/-/g, ''); beforeEach(() => { loader = createScriptLoader(); paymentMethod = getPayPalCommerceAcceleratedCheckoutPaymentMethod(); + mockAPMPaymentMethod = { + ...paymentMethod, + id: 'oxxo', + initializationData: { + ...paymentMethod.initializationData, + enabledAlternativePaymentMethods: ['oxxo'], + availableAlternativePaymentMethods: ['oxxo'], + }, + }; paypalFastlaneSdk = getPayPalFastlaneSdk(); subject = new PayPalCommerceSdk(loader); jest.spyOn(loader, 'loadScript').mockImplementation(() => { (window as PayPalCommerceHostWindow).paypalFastlaneSdk = paypalFastlaneSdk; (window as PayPalCommerceHostWindow).paypalMessages = paypalMessagesSdk; + (window as PayPalCommerceHostWindow).paypalApms = paypalApmsSdk; return Promise.resolve(); }); @@ -44,6 +61,7 @@ describe('PayPalCommerceSdk', () => { afterEach(() => { (window as PayPalCommerceHostWindow).paypalFastlaneSdk = undefined; (window as PayPalCommerceHostWindow).paypalMessages = undefined; + (window as PayPalCommerceHostWindow).paypalApms = undefined; jest.clearAllMocks(); }); @@ -175,4 +193,54 @@ describe('PayPalCommerceSdk', () => { expect(result).toEqual(paypalMessagesSdk); }); }); + + describe('#getPayPalApmsSdk()', () => { + it('throws an error if clientId is not defined in payment method while getting configuration for PayPal Sdk', async () => { + try { + await subject.getPayPalApmsSdk( + { + ...mockAPMPaymentMethod, + initializationData: { + ...mockAPMPaymentMethod.initializationData, + clientId: undefined, + }, + }, + 'USD', + ); + } catch (error: unknown) { + expect(error).toBeInstanceOf(MissingDataError); + } + }); + + it('loads APMs sdk script', async () => { + await subject.getPayPalApmsSdk(mockAPMPaymentMethod, 'USD'); + + expect(loader.loadScript).toHaveBeenCalledWith( + 'https://www.paypal.com/sdk/js?client-id=abc&merchant-id=JTS4DY7XFSQZE&enable-funding=oxxo&commit=true&components=buttons%2Cpayment-fields¤cy=USD&intent=capture', + { + async: true, + attributes: { + 'data-namespace': 'paypalApms', + 'data-partner-attribution-id': '1123JLKJASD12', + }, + }, + ); + }); + + it('throws an error if there was an issue with loading APMs sdk', async () => { + jest.spyOn(loader, 'loadScript').mockImplementation(jest.fn()); + + try { + await subject.getPayPalApmsSdk(mockAPMPaymentMethod, 'USD'); + } catch (error: unknown) { + expect(error).toBeInstanceOf(PaymentMethodClientUnavailableError); + } + }); + + it('returns PayPal APMs Sdk', async () => { + const result = await subject.getPayPalApmsSdk(paymentMethod, 'USD'); + + expect(result).toEqual(paypalApmsSdk); + }); + }); }); diff --git a/packages/paypal-commerce-utils/src/paypal-commerce-sdk.ts b/packages/paypal-commerce-utils/src/paypal-commerce-sdk.ts index 640aaec49d..aa734242b3 100644 --- a/packages/paypal-commerce-utils/src/paypal-commerce-sdk.ts +++ b/packages/paypal-commerce-utils/src/paypal-commerce-sdk.ts @@ -44,6 +44,23 @@ export default class PayPalCommerceSdk { return this.window.paypalFastlaneSdk; } + async getPayPalApmsSdk( + paymentMethod: PaymentMethod, + currencyCode: string, + ) { + if (!this.window.paypalApms) { + const config = this.getPayPalApmSdkConfiguration(paymentMethod, currencyCode); + + await this.loadPayPalSdk(config); + + if (!this.window.paypalApms) { + throw new PaymentMethodClientUnavailableError(); + } + } + + return this.window.paypalApms; + } + async getPayPalMessages( paymentMethod: PaymentMethod, currencyCode: string, @@ -126,6 +143,51 @@ export default class PayPalCommerceSdk { }; } + private getPayPalApmSdkConfiguration( + paymentMethod: PaymentMethod, + currencyCode: string, + ): PayPalSdkConfig { + const { initializationData } = paymentMethod; + + if (!initializationData || !initializationData.clientId) { + throw new MissingDataError(MissingDataErrorType.MissingPaymentMethod); + } + + const { + intent, + clientId, + merchantId, + buyerCountry, + attributionId, + isDeveloperModeApplicable, + availableAlternativePaymentMethods = [], + enabledAlternativePaymentMethods = [], + } = initializationData; + + const enableAPMsFunding = enabledAlternativePaymentMethods; + const disableAPMsFunding = availableAlternativePaymentMethods.filter( + (apm: string) => !enabledAlternativePaymentMethods.includes(apm), + ); + + return { + options: { + 'client-id': clientId, + 'merchant-id': merchantId, + 'enable-funding': enableAPMsFunding.length > 0 ? enableAPMsFunding : undefined, + 'disable-funding': disableAPMsFunding.length > 0 ? disableAPMsFunding : undefined, + commit: true, + components: ['buttons', 'payment-fields'], + currency: currencyCode, + intent, + ...(isDeveloperModeApplicable && { 'buyer-country': buyerCountry }), + }, + attributes: { + 'data-partner-attribution-id': attributionId, + 'data-namespace': 'paypalApms', + }, + }; + } + private getPayPalSdkMessagesConfiguration( paymentMethod: PaymentMethod, currencyCode: string, diff --git a/packages/paypal-commerce-utils/src/paypal-commerce-types.ts b/packages/paypal-commerce-utils/src/paypal-commerce-types.ts index afb9b3e576..23db3448c7 100644 --- a/packages/paypal-commerce-utils/src/paypal-commerce-types.ts +++ b/packages/paypal-commerce-utils/src/paypal-commerce-types.ts @@ -6,6 +6,7 @@ import { CardInstrument, CustomerAddress } from '@bigcommerce/checkout-sdk/payme * */ export type FundingType = string[]; +export type EnableFundingType = FundingType | string; /** * @@ -48,6 +49,7 @@ export interface PayPalCommerceHostWindow extends Window { paypalFastlane?: PayPalFastlane; paypalFastlaneSdk?: PayPalFastlaneSdk; paypalMessages?: PayPalMessagesSdk; + paypalApms?: PayPalApmSdk; } /** @@ -60,6 +62,8 @@ export interface PayPalSdkConfig { 'client-id'?: string; 'merchant-id'?: string; 'buyer-country'?: string; + 'enable-funding'?: EnableFundingType; + 'disable-funding'?: FundingType; currency?: string; commit?: boolean; intent?: PayPalCommerceIntent; @@ -70,6 +74,7 @@ export interface PayPalSdkConfig { 'data-partner-attribution-id'?: string; 'data-user-id-token'?: string; 'data-namespace'?: string; + 'data-client-token'?: string; }; } @@ -78,7 +83,7 @@ export enum PayPalCommerceIntent { CAPTURE = 'capture', } -export type PayPalSdkComponents = Array<'fastlane' | 'messages'>; +export type PayPalSdkComponents = Array<'fastlane' | 'messages' | 'buttons' | 'payment-fields'>; /** * @@ -93,6 +98,167 @@ export interface PayPalMessagesSdk { Messages(options: MessagingOptions): MessagingRender; } +export interface PayPalApmSdk { + Buttons(options: PayPalCommerceButtonsOptions): PayPalCommerceButtons; + PaymentFields(options: PayPalCommercePaymentFieldsOptions): PayPalCommercePaymentFields; +} + +/** + * + * PayPal Commerce Buttons + * + */ +export interface PayPalCommerceButtons { + render(id: string): void; + close(): void; + isEligible(): boolean; +} + +export interface PayPalCommerceButtonsOptions { + style?: PayPalButtonStyleOptions; + fundingSource: string; + createOrder(): Promise; + onApprove( + data: PayPalButtonApproveCallbackPayload, + actions: PayPalButtonApproveCallbackActions, + ): Promise | void; + onInit?( + data: PayPalButtonInitCallbackPayload, + actions: PayPalButtonInitCallbackActions, + ): Promise; + onClick?( + data: PayPalButtonClickCallbackPayload, + actions: PayPalButtonClickCallbackActions, + ): Promise | void; + onError?(error: Error): void; + onCancel?(): void; +} + +export interface PayPalButtonClickCallbackPayload { + fundingSource: string; +} + +export interface PayPalButtonClickCallbackActions { + reject(): void; + resolve(): void; +} + +export interface PayPalButtonInitCallbackPayload { + correlationID: string; +} + +export interface PayPalButtonInitCallbackActions { + disable(): void; + enable(): void; +} + +export interface PayPalButtonApproveCallbackPayload { + orderID?: string; +} + +export interface PayPalButtonApproveCallbackActions { + order: { + get: () => Promise; + }; +} + +export interface PayPalOrderDetails { + payer: { + name: { + given_name: string; + surname: string; + }; + email_address: string; + address: PayPalOrderAddress; + }; + purchase_units: Array<{ + shipping: { + address: PayPalOrderAddress; + }; + }>; +} + +export interface PayPalOrderAddress { + address_line_1: string; + admin_area_2: string; + admin_area_1?: string; + postal_code: string; + country_code: string; +} + +export enum StyleButtonLabel { + paypal = 'paypal', + checkout = 'checkout', + buynow = 'buynow', + pay = 'pay', + installment = 'installment', +} + +export enum StyleButtonColor { + gold = 'gold', + blue = 'blue', + silver = 'silver', + black = 'black', + white = 'white', +} + +export enum StyleButtonShape { + pill = 'pill', + rect = 'rect', +} + +export interface PayPalButtonStyleOptions { + color?: StyleButtonColor; + shape?: StyleButtonShape; + height?: number; + label?: StyleButtonLabel; +} + +/** + * + * PayPal Commerce Payment fields + * + */ +export interface PayPalCommercePaymentFields { + render(id: string): void; +} + +export interface PayPalCommercePaymentFieldsOptions { + style?: PayPalCommerceFieldsStyleOptions; + fundingSource: string; + fields: { + name?: { + value?: string; + }; + email?: { + value?: string; + }; + }; +} + +export interface PayPalCommerceFieldsStyleOptions { + variables?: { + fontFamily?: string; + fontSizeBase?: string; + fontSizeSm?: string; + fontSizeM?: string; + fontSizeLg?: string; + textColor?: string; + colorTextPlaceholder?: string; + colorBackground?: string; + colorInfo?: string; + colorDanger?: string; + borderRadius?: string; + borderColor?: string; + borderWidth?: string; + borderFocusColor?: string; + spacingUnit?: string; + }; + rules?: { + [key: string]: any; + }; +} + /** * * PayLater Messages related types