Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fastlane SDK Wrapper #2985

Merged
merged 8 commits into from
Dec 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions packages/lib/src/components/PayPal/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,6 @@ import { AddressData } from '../../types/global-types';
import { UIElementProps } from '../internal/UIElement/types';
import PaypalElement from './Paypal';

declare global {
interface Window {
paypal: object;
}
}

export interface PayPalConfiguration extends UIElementProps {
/**
* Configuration returned by the backend
Expand Down
229 changes: 229 additions & 0 deletions packages/lib/src/components/PayPalFastlane/FastlaneSDK.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { mockDeep, mock, mockReset } from 'jest-mock-extended';
import initializeFastlane from './initializeFastlane';
import { httpPost } from '../../core/Services/http';
import Script from '../../utils/Script';
import type { Fastlane, FastlaneProfile, FastlaneShipping } from './types';

const fastlaneMock = mockDeep<Fastlane>();
const fastlaneConstructorMock = jest.fn().mockResolvedValue(fastlaneMock);

const mockScriptLoaded = jest.fn().mockImplementation(() => {
window.paypal = {};
window.paypal.Fastlane = fastlaneConstructorMock;
return Promise.resolve();
});

jest.mock('../../core/Services/http');
jest.mock('../../utils/Script', () => {
return jest.fn().mockImplementation(() => {
return { load: mockScriptLoaded };
});
});

const httpPostMock = (httpPost as jest.Mock).mockResolvedValue({
id: 'RANDOM-ID',
clientId: 'CLIENT-ID',
merchantId: 'XXXYYYZZZ',
value: 'TOKEN-VALUE',
expiresAt: '2024-11-01T13:34:01.804+00:00'
});

describe('FastlaneSDK', () => {
beforeEach(() => {
mockReset(fastlaneMock);
});

test('should initialize the Fastlane SDK', async () => {
await initializeFastlane({
clientKey: 'test_xxx',
environment: 'test'
});

expect(fastlaneConstructorMock).toHaveBeenCalledTimes(1);
expect(fastlaneConstructorMock).toHaveBeenCalledWith({});
expect(fastlaneMock.setLocale).toHaveBeenCalledWith('en_us');
expect(httpPostMock).toHaveBeenCalledWith({
loadingContext: 'https://checkoutshopper-test.adyen.com/checkoutshopper/',
path: 'utility/v1/payPalFastlane/tokens?clientKey=test_xxx',
errorLevel: 'fatal'
});
expect(Script).toHaveBeenCalledWith(
'https://www.paypal.com/sdk/js?client-id=CLIENT-ID&components=buttons,fastlane',
'body',
{},
{ sdkClientToken: 'TOKEN-VALUE' }
);
});

test('should return not_found if email is not recognized', async () => {
fastlaneMock.identity.lookupCustomerByEmail.mockResolvedValue({
customerContextId: null
});

const fastlane = await initializeFastlane({
clientKey: 'test_xxx',
environment: 'test'
});

const authResult = await fastlane.authenticate('[email protected]');

expect(authResult.authenticationState).toBe('not_found');
expect(authResult.profileData).toBeUndefined();
});

test('should authenticate the user with email', async () => {
const customerContextId = 'customer-context-id';
const mockedFastlaneProfile = mock<FastlaneProfile>();

fastlaneMock.identity.lookupCustomerByEmail.mockResolvedValue({
customerContextId
});

fastlaneMock.identity.triggerAuthenticationFlow.mockResolvedValue({
authenticationState: 'succeeded',
profileData: mockedFastlaneProfile
});

const fastlane = await initializeFastlane({
clientKey: 'test_xxx',
environment: 'test'
});

const authResult = await fastlane.authenticate('[email protected]');

expect(fastlaneMock.identity.lookupCustomerByEmail).toHaveBeenCalledWith('[email protected]');
expect(fastlaneMock.identity.triggerAuthenticationFlow).toHaveBeenCalledWith(customerContextId);
expect(authResult.authenticationState).toBe('succeeded');
expect(authResult.profileData).toBeDefined();
});

test('should call Fastlane shipping address selector method', async () => {
const customerContextId = 'customer-context-id';
const mockedFastlaneProfile = mock<FastlaneProfile>();
const mockedFastlaneShipping = mock<FastlaneShipping>();

const fastlane = await initializeFastlane({
clientKey: 'test_xxx',
environment: 'test'
});

fastlaneMock.profile.showShippingAddressSelector.mockResolvedValue({
selectionChanged: false,
selectedAddress: mockedFastlaneShipping
});

fastlaneMock.identity.lookupCustomerByEmail.mockResolvedValue({
customerContextId
});

fastlaneMock.identity.triggerAuthenticationFlow.mockResolvedValue({
authenticationState: 'succeeded',
profileData: mockedFastlaneProfile
});

await fastlane.authenticate('[email protected]');
const addressSelectorResult = await fastlane.showShippingAddressSelector();

expect(fastlaneMock.profile.showShippingAddressSelector).toHaveBeenCalledTimes(1);
expect(addressSelectorResult.selectionChanged).toBeFalsy();
});

test('should mount Fastlane watermark', async () => {
const componentMock = {
render: jest.fn()
};
fastlaneMock.FastlaneWatermarkComponent.mockResolvedValue(componentMock);

const fastlane = await initializeFastlane({
clientKey: 'test_xxx',
environment: 'test'
});

await fastlane.mountWatermark('.my-div');

expect(fastlaneMock.FastlaneWatermarkComponent).toHaveBeenCalledTimes(1);
expect(componentMock.render).toHaveBeenCalledTimes(1);
expect(componentMock.render).toHaveBeenCalledWith('.my-div');
});

test('should return fastlane component configuration if shopper has profile', async () => {
const customerContextId = 'customer-context-id';
fastlaneMock.identity.lookupCustomerByEmail.mockResolvedValue({
customerContextId
});
fastlaneMock.identity.triggerAuthenticationFlow.mockResolvedValue({
authenticationState: 'succeeded',
profileData: mock<FastlaneProfile>({
card: {
id: 'xxxx',
paymentSource: {
card: {
brand: 'visa',
lastDigits: '1111'
}
}
}
})
});

const fastlane = await initializeFastlane({
clientKey: 'test_xxx',
environment: 'test'
});
const authResult = await fastlane.authenticate('[email protected]');
const config = fastlane.getComponentConfiguration(authResult);

expect(config).toStrictEqual({
paymentType: 'fastlane',
configuration: {
brand: 'visa',
customerId: 'customer-context-id',
email: '[email protected]',
lastFour: '1111',
sessionId: 'xxxx-yyyy',
tokenId: 'xxxx'
}
});
});

test('should return card component configuration if shopper does not have profile', async () => {
const customerContextId = 'customer-context-id';
fastlaneMock.identity.lookupCustomerByEmail.mockResolvedValue({
customerContextId
});
fastlaneMock.identity.triggerAuthenticationFlow.mockResolvedValue({
authenticationState: 'not_found',
profileData: undefined
});

const fastlane = await initializeFastlane({
clientKey: 'test_xxx',
environment: 'test'
});
const authResult = await fastlane.authenticate('[email protected]');
const config = fastlane.getComponentConfiguration(authResult);

expect(config).toStrictEqual({
paymentType: 'card',
configuration: {
fastlaneConfiguration: {
defaultToggleState: true,
privacyPolicyLink: 'https://...',
showConsent: true,
termsAndConditionsLink: 'https://...',
termsAndConditionsVersion: 'v1'
}
}
});
});

test('should thrown an error if there is no auth result to create the component configuration', async () => {
const fastlane = await initializeFastlane({
clientKey: 'test_xxx',
environment: 'test'
});

// @ts-ignore It is expected to omit the parameter here
expect(() => fastlane.getComponentConfiguration()).toThrowError();
});
});
135 changes: 135 additions & 0 deletions packages/lib/src/components/PayPalFastlane/FastlaneSDK.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { resolveEnvironments } from '../../core/Environment';
import requestFastlaneToken from './services/request-fastlane-token';
import { convertAdyenLocaleToFastlaneLocale } from './utils/convert-locale';
import Script from '../../utils/Script';
import AdyenCheckoutError from '../../core/Errors/AdyenCheckoutError';
import {
ComponentConfiguration,
Fastlane,
FastlaneAuthenticatedCustomerResult,
FastlaneShippingAddressSelectorResult,
FastlaneSDKConfiguration
} from './types';
import type { FastlaneTokenData } from './services/request-fastlane-token';

class FastlaneSDK {
private readonly clientKey: string;
private readonly checkoutShopperURL: string;
private readonly locale: string;

private fastlaneSdk: Fastlane;
private authenticatedShopper: { email: string; customerId: string };

constructor(configuration: FastlaneSDKConfiguration) {
if (!configuration.environment) throw new AdyenCheckoutError('IMPLEMENTATION_ERROR', "FastlaneSDK: 'environment' property is required");
if (!configuration.clientKey) throw new AdyenCheckoutError('IMPLEMENTATION_ERROR', "FastlaneSDK: 'clientKey' property is required");

const { apiUrl } = resolveEnvironments(configuration.environment);

this.checkoutShopperURL = apiUrl;
this.clientKey = configuration.clientKey;
this.locale = convertAdyenLocaleToFastlaneLocale(configuration.locale || 'en-US');
}

public async initialize(): Promise<FastlaneSDK> {
const tokenData = await this.requestClientToken();
await this.fetchSdk(tokenData.value, tokenData.clientId);
await this.initializeFastlane();
return this;
}

public async authenticate(email: string): Promise<FastlaneAuthenticatedCustomerResult> {
if (!this.fastlaneSdk) {
throw new AdyenCheckoutError('IMPLEMENTATION_ERROR', 'FastlaneSDK is not initialized');
}

const { customerContextId } = await this.fastlaneSdk.identity.lookupCustomerByEmail(email);

if (customerContextId) {
this.authenticatedShopper = { email, customerId: customerContextId };
return this.fastlaneSdk.identity.triggerAuthenticationFlow(customerContextId);
} else {
return {
authenticationState: 'not_found',
profileData: undefined
};
}
}

/**
* TODO: Waiting for PayPal to provide the specific methods to fetch sessionId and Consent UI details
*/
public getComponentConfiguration(authResult: FastlaneAuthenticatedCustomerResult): ComponentConfiguration {
if (!authResult) {
throw new AdyenCheckoutError(
'IMPLEMENTATION_ERROR',
'FastlaneSDK: you must pass the authentication result to get the component configuration'
);
}

if (authResult.authenticationState === 'succeeded') {
return {
paymentType: 'fastlane',
configuration: {
sessionId: 'xxxx-yyyy',
customerId: this.authenticatedShopper.customerId,
email: this.authenticatedShopper.email,
tokenId: authResult.profileData.card.id,
lastFour: authResult.profileData.card.paymentSource.card.lastDigits,
brand: authResult.profileData.card.paymentSource.card.brand
}
};
} else {
return {
paymentType: 'card',
configuration: {
fastlaneConfiguration: {
showConsent: true,
defaultToggleState: true,
termsAndConditionsLink: 'https://...',
privacyPolicyLink: 'https://...',
termsAndConditionsVersion: 'v1'
}
}
};
}
}

public showShippingAddressSelector(): Promise<FastlaneShippingAddressSelectorResult> {
if (!this.fastlaneSdk) {
throw new AdyenCheckoutError('IMPLEMENTATION_ERROR', 'FastlaneSDK is not initialized');
}
return this.fastlaneSdk.profile.showShippingAddressSelector();
}

public async mountWatermark(container: HTMLElement | string, options = { includeAdditionalInfo: false }): Promise<void> {
if (!this.fastlaneSdk) {
throw new AdyenCheckoutError('IMPLEMENTATION_ERROR', 'FastlaneSDK is not initialized');
}

const component = await this.fastlaneSdk.FastlaneWatermarkComponent(options);
component.render(container);
}

private requestClientToken(): Promise<FastlaneTokenData> {
return requestFastlaneToken(this.checkoutShopperURL, this.clientKey);
}

private async fetchSdk(clientToken: string, clientId: string): Promise<void> {
const url = `https://www.paypal.com/sdk/js?client-id=${clientId}&components=buttons,fastlane`;
const script = new Script(url, 'body', {}, { sdkClientToken: clientToken });

try {
await script.load();
} catch (error) {
console.error(error);
}
}

private async initializeFastlane(): Promise<void> {
this.fastlaneSdk = await window.paypal.Fastlane({});
this.fastlaneSdk.setLocale(this.locale);
}
}

export default FastlaneSDK;
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import FastlaneSDK from './FastlaneSDK';
import type { FastlaneSDKConfiguration } from './types';

async function initializeFastlane(configuration: FastlaneSDKConfiguration): Promise<FastlaneSDK> {
const fastlane = new FastlaneSDK(configuration);
return await fastlane.initialize();
}

export default initializeFastlane;
Loading