Skip to content

Commit

Permalink
feat(payment): PAYPAL-3520 added paypal button widget
Browse files Browse the repository at this point in the history
  • Loading branch information
bc-nick committed May 31, 2024
1 parent 07745bd commit ad8f56a
Show file tree
Hide file tree
Showing 13 changed files with 462 additions and 49 deletions.
3 changes: 2 additions & 1 deletion packages/braintree-integration/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"jest/no-conditional-expect": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-throw-literal": "off"
"@typescript-eslint/no-throw-literal": "off",
"@typescript-eslint/no-floating-promises": "off"
}
}
]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import {
BraintreeError,
BraintreeFormOptions,
BraintreeThreeDSecureOptions,
} from '@bigcommerce/checkout-sdk/braintree-utils';
import { StandardError } from '@bigcommerce/checkout-sdk/payment-integration-api';

export interface BraintreePaypalPaymentInitializeOptions {
/**
* The CSS selector of a container where the payment widget should be inserted into.
*/
containerId?: string;

threeDSecure?: BraintreeThreeDSecureOptions;

/**
Expand All @@ -19,6 +26,25 @@ export interface BraintreePaypalPaymentInitializeOptions {
*/
bannerContainerId?: string;

/**
* A callback right before render Smart Payment Button that gets called when
* Smart Payment Button is eligible. This callback can be used to hide the standard submit button.
*/
onRenderButton?(): void;

/**
* A callback for submitting payment form that gets called
* when buyer approved PayPal account.
*/
submitForm?(): void;

/**
* A callback that gets called if unable to submit payment.
*
* @param error - The error object describing the failure.
*/
onPaymentError?(error: BraintreeError | StandardError): void;

/**
* A callback for displaying error popup. This callback requires error object as parameter.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { getScriptLoader } from '@bigcommerce/script-loader';
import { EventEmitter } from 'events';
import { omit } from 'lodash';

import {
BraintreeClient,
BraintreeError,
BraintreeHostWindow,
BraintreeIntegrationService,
BraintreeModuleCreator,
Expand All @@ -16,6 +18,8 @@ import {
getPayPalCheckoutCreatorMock,
getPaypalCheckoutMock,
getPaypalMock,
getTokenizePayload,
PaypalButtonOptions,
PaypalSDK,
} from '@bigcommerce/checkout-sdk/braintree-utils';
import {
Expand All @@ -42,17 +46,18 @@ import mapToBraintreeShippingAddressOverride from '../map-to-braintree-shipping-
import BraintreePaypalPaymentStrategy from './braintree-paypal-payment-strategy';

describe('BraintreePaypalPaymentStrategy', () => {
let eventEmitter: EventEmitter;
let strategy: BraintreePaypalPaymentStrategy;
let paymentIntegrationService: PaymentIntegrationService;
let braintreeScriptLoader: BraintreeScriptLoader;
let braintreeIntegrationService: BraintreeIntegrationService;
let paymentMethodMock: PaymentMethod;
let clientCreatorMock: BraintreeModuleCreator<BraintreeClient>;
let braintreePaypalCreatorMock: BraintreeModuleCreator<BraintreePaypal>;
let paypalCheckoutMock: BraintreePaypalCheckout;
let paypalCheckoutCreatorMock: BraintreeModuleCreator<BraintreePaypalCheckout>;
let paypalSdkMock: PaypalSDK;
let paypalMessageElement: HTMLDivElement;
let braintreePaypalCheckoutMock: BraintreePaypalCheckout;

const storeConfig = getConfig().storeConfig;

Expand All @@ -67,7 +72,20 @@ describe('BraintreePaypalPaymentStrategy', () => {
},
};

const providerError = {
errors: [
{
code: 'transaction_declined',
message: 'Payment was declined. Please try again.',
provider_error: {
code: '2046',
},
},
],
};

beforeEach(() => {
eventEmitter = new EventEmitter();
paypalMessageElement = document.createElement('div');
paypalMessageElement.id = 'banner-container-id';
document.body.appendChild(paypalMessageElement);
Expand All @@ -78,8 +96,11 @@ describe('BraintreePaypalPaymentStrategy', () => {

clientCreatorMock = getModuleCreatorMock(getClientMock());
braintreePaypalCreatorMock = getModuleCreatorMock(getBraintreePaypalMock());
paypalCheckoutMock = getPaypalCheckoutMock();
paypalCheckoutCreatorMock = getPayPalCheckoutCreatorMock(paypalCheckoutMock, false);
braintreePaypalCheckoutMock = getPaypalCheckoutMock();
paypalCheckoutCreatorMock = getPayPalCheckoutCreatorMock(
braintreePaypalCheckoutMock,
false,
);

paymentIntegrationService = new PaymentIntegrationServiceMock();

Expand All @@ -106,9 +127,36 @@ describe('BraintreePaypalPaymentStrategy', () => {
window,
);

const getSDKPaypalCheckoutMock = (
braintreePaypalCheckoutPayloadMock?: BraintreePaypalCheckout,
) => {
if (!braintreePaypalCheckoutPayloadMock) {
return jest.fn(
(
_options: unknown,
_successCallback: unknown,
errorCallback: (err: BraintreeError) => void,
) => {
errorCallback({ type: 'UNKNOWN', code: '234' } as BraintreeError);
},
);
}

return jest.fn(
(
_options: unknown,
successCallback: (braintreePaypalCheckout: BraintreePaypalCheckout) => void,
) => {
successCallback(braintreePaypalCheckoutPayloadMock);
},
);
};

jest.spyOn(braintreeIntegrationService, 'initialize');
jest.spyOn(braintreeIntegrationService, 'getPaypal');
jest.spyOn(braintreeIntegrationService, 'getPaypalCheckout');
jest.spyOn(braintreeIntegrationService, 'getPaypalCheckout').mockImplementation(
getSDKPaypalCheckoutMock(braintreePaypalCheckoutMock),
);
jest.spyOn(braintreeIntegrationService, 'getSessionId').mockReturnValue('my_session_id');
jest.spyOn(braintreeIntegrationService, 'teardown');
jest.spyOn(braintreeIntegrationService, 'paypal').mockResolvedValue({
Expand All @@ -126,6 +174,26 @@ describe('BraintreePaypalPaymentStrategy', () => {
jest.spyOn(paypalSdkMock, 'Messages').mockImplementation(() => ({
render: jest.fn(),
}));

jest.spyOn(paypalSdkMock, 'Buttons').mockImplementation((options: PaypalButtonOptions) => {
eventEmitter.on('approve', () => {
if (typeof options.onApprove === 'function') {
options.onApprove();
}
});

eventEmitter.on('createOrder', () => {
if (typeof options.createOrder === 'function') {
options.createOrder();
}
});

return {
isEligible: jest.fn(() => true),
render: jest.fn(),
close: jest.fn(),
};
});
});

it('creates an instance of the braintree payment strategy', () => {
Expand All @@ -150,17 +218,6 @@ describe('BraintreePaypalPaymentStrategy', () => {
expect(braintreeIntegrationService.getPaypal).toHaveBeenCalled();
});

it('paypal checkout is not initialized', async () => {
const options = {
methodId: paymentMethodMock.id,
braintree: { bannerContainerId: 'banner-container-id' },
};

await strategy.initialize(options);

expect(braintreeIntegrationService.getPaypalCheckout).not.toHaveBeenCalled();
});

it('paypal checkout is initialized successfully', async () => {
paymentMethodMock.initializationData.enableCheckoutPaywallBanner = true;

Expand Down Expand Up @@ -432,16 +489,77 @@ describe('BraintreePaypalPaymentStrategy', () => {
});

it('throws specific error if receive INSTRUMENT_DECLINED error and experiment is on', async () => {
const providerError = {
errors: [
{
code: 'transaction_declined',
message: 'Payment was declined. Please try again.',
provider_error: {
code: '2046',
},
jest.spyOn(
paymentIntegrationService.getState(),
'getStoreConfigOrThrow',
).mockReturnValue(storeConfigWithFeaturesOn);

jest.spyOn(paymentIntegrationService, 'submitPayment').mockImplementation(() => {
throw providerError;
});

const braintreeOptions = {
...options,
braintree: {
onError: jest.fn(),
},
};

await strategy.initialize(braintreeOptions);

try {
await strategy.execute(orderRequestBody, options);
} catch (error) {
expect(braintreeOptions.braintree.onError).toHaveBeenCalledWith(
new Error('INSTRUMENT_DECLINED'),
);
}
});

it('rendering the paypal button when a specific INSTRUMENT_DECLINED error occurs', async () => {
jest.spyOn(
paymentIntegrationService.getState(),
'getStoreConfigOrThrow',
).mockReturnValue(storeConfigWithFeaturesOn);

jest.spyOn(paymentIntegrationService, 'submitPayment').mockImplementation(() => {
throw providerError;
});

const braintreeOptions = {
...options,
braintree: {
onError: jest.fn(),
containerId: '#checkout-button-container',
},
};

await strategy.initialize(braintreeOptions);

try {
await strategy.execute(orderRequestBody, options);
} catch (error) {
expect(braintreeOptions.braintree.onError).toHaveBeenCalledWith(
new Error('INSTRUMENT_DECLINED'),
);

expect(paypalSdkMock.Buttons).toHaveBeenCalled();
}
});

it('execute submitPayment with re-authorised NONCE value when a specific INSTRUMENT_DECLINED error occurs', async () => {
const token = getTokenizePayload().nonce;

const expected = {
...orderRequestBody.payment,
paymentData: {
formattedPayload: {
vault_payment_instrument: null,
set_as_default_stored_instrument: null,
device_info: null,
paypal_account: { token, email: null },
},
],
},
};

jest.spyOn(
Expand All @@ -457,6 +575,7 @@ describe('BraintreePaypalPaymentStrategy', () => {
...options,
braintree: {
onError: jest.fn(),
containerId: '#checkout-button-container',
},
};

Expand All @@ -469,6 +588,72 @@ describe('BraintreePaypalPaymentStrategy', () => {
new Error('INSTRUMENT_DECLINED'),
);
}

jest.spyOn(paymentIntegrationService, 'submitPayment').mockImplementation(jest.fn());

eventEmitter.emit('approve');

await new Promise((resolve) => process.nextTick(resolve));

await strategy.execute(orderRequestBody, options);

expect(paymentIntegrationService.submitPayment).toHaveBeenLastCalledWith(expected);
});

it('#createOrder button callback', async () => {
jest.spyOn(
paymentIntegrationService.getState(),
'getStoreConfigOrThrow',
).mockReturnValue(storeConfigWithFeaturesOn);

jest.spyOn(paymentIntegrationService, 'submitPayment').mockImplementation(() => {
throw providerError;
});

const braintreeOptions = {
...options,
braintree: {
onError: jest.fn(),
containerId: '#checkout-button-container',
},
};

await strategy.initialize(braintreeOptions);

try {
await strategy.execute(orderRequestBody, options);
} catch (error) {
expect(braintreeOptions.braintree.onError).toHaveBeenCalledWith(
new Error('INSTRUMENT_DECLINED'),
);
}

jest.spyOn(paymentIntegrationService, 'submitPayment').mockImplementation(jest.fn());

eventEmitter.emit('createOrder');

await new Promise((resolve) => process.nextTick(resolve));

await strategy.execute(orderRequestBody, options);

expect(braintreePaypalCheckoutMock.createPayment).toHaveBeenCalledWith({
amount: 190,
currency: 'USD',
enableShippingAddress: true,
flow: 'checkout',
offerCredit: false,
shippingAddressEditable: false,
shippingAddressOverride: {
city: 'Some City',
countryCode: 'US',
line1: '12345 Testing Way',
line2: '',
phone: '555-555-5555',
postalCode: '95555',
recipientName: 'Test Tester',
state: 'CA',
},
});
});

describe('when paying with a vaulted instrument', () => {
Expand Down
Loading

0 comments on commit ad8f56a

Please sign in to comment.