From 0cca8523414f335ab3a771de32503d5ed0899830 Mon Sep 17 00:00:00 2001 From: Shane Brunson Date: Tue, 23 Jan 2024 16:04:14 -0600 Subject: [PATCH] create shopper session class; add identify --- package.json | 5 +- src/api/shopper-insights/component.js | 196 ---------- src/api/shopper-insights/component.test.js | 343 ------------------ src/api/shopper-insights/fingerprint.js | 52 +++ src/api/shopper-insights/interface.js | 67 +++- src/api/shopper-insights/shopperSession.js | 304 ++++++++++++++++ .../shopper-insights/shopperSession.test.js | 320 ++++++++++++++++ src/api/shopper-insights/validation.js | 75 +--- src/api/shopper-insights/validation.test.js | 43 +-- 9 files changed, 743 insertions(+), 662 deletions(-) delete mode 100644 src/api/shopper-insights/component.js delete mode 100644 src/api/shopper-insights/component.test.js create mode 100644 src/api/shopper-insights/fingerprint.js create mode 100644 src/api/shopper-insights/shopperSession.js create mode 100644 src/api/shopper-insights/shopperSession.test.js diff --git a/package.json b/package.json index c9215b467..66832fb17 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@paypal/checkout-components", - "version": "5.0.294", + "version": "5.0.295-alpha.1", "description": "PayPal Checkout components, for integrating checkout products.", "main": "index.js", "scripts": { @@ -102,6 +102,7 @@ "vitest": "^0.25.3" }, "dependencies": { + "@fingerprintjs/fingerprintjs-pro": "^3.8.6", "@krakenjs/belter": "^2.0.0", "@krakenjs/cross-domain-utils": "^3.0.0", "@krakenjs/jsx-pragmatic": "^3", @@ -109,8 +110,8 @@ "@krakenjs/zalgo-promise": "^2.0.0", "@krakenjs/zoid": "^10.3.1", "@paypal/common-components": "^1.0.35", - "@paypal/funding-components": "^1.0.31", "@paypal/connect-loader-component": "1.1.1", + "@paypal/funding-components": "^1.0.31", "@paypal/sdk-client": "^4.0.179", "@paypal/sdk-constants": "^1.0.133", "@paypal/sdk-logos": "^2.2.6" diff --git a/src/api/shopper-insights/component.js b/src/api/shopper-insights/component.js deleted file mode 100644 index 7ced218ab..000000000 --- a/src/api/shopper-insights/component.js +++ /dev/null @@ -1,196 +0,0 @@ -/* @flow */ - -import { - getUserIDToken, - getPageType, - getClientToken, - getSDKToken, - getLogger, - getPayPalAPIDomain, - getCurrency, - getBuyerCountry, - getEnv, - getSessionState, - sendCountMetric, -} from "@paypal/sdk-client/src"; -import { FPTI_KEY } from "@paypal/sdk-constants/src"; -import { ZalgoPromise } from "@krakenjs/zalgo-promise/src"; -import { stringifyError } from "@krakenjs/belter/src"; - -import { callMemoizedRestAPI } from "../api"; -import { - ELIGIBLE_PAYMENT_METHODS, - FPTI_TRANSITION, - SHOPPER_INSIGHTS_METRIC_NAME, - type MerchantPayloadData, -} from "../../constants/api"; - -import { - validateMerchantConfig, - validateMerchantPayload, - hasEmail, - hasPhoneNumber, -} from "./validation"; - -type RecommendedPaymentMethods = {| - isPayPalRecommended: boolean, - isVenmoRecommended: boolean, -|}; - -type getRecommendedPaymentMethodsRequestPayload = {| - customer: {| - country_code?: string, - email?: string, - phone?: {| - country_code: string, - national_number: string, - |}, - |}, - purchase_units: $ReadOnlyArray<{| - amount: {| - currency_code: string, - |}, - |}>, - preferences: {| - include_account_details: boolean, - |}, -|}; - -export type ShopperInsightsComponent = {| - getRecommendedPaymentMethods: (MerchantPayloadData) => ZalgoPromise, -|}; - -function createRecommendedPaymentMethodsRequestPayload( - merchantPayload: MerchantPayloadData -): getRecommendedPaymentMethodsRequestPayload { - const isNonProdEnvironment = getEnv() !== "production"; - - return { - customer: { - ...(isNonProdEnvironment && { - country_code: getBuyerCountry() || "US", - }), - // $FlowIssue - ...(hasEmail(merchantPayload) && { - email: merchantPayload?.email, - }), - ...(hasPhoneNumber(merchantPayload) && { - phone: { - country_code: merchantPayload?.phone?.countryCode, - national_number: merchantPayload?.phone?.nationalNumber, - }, - }), - }, - purchase_units: [ - { - amount: { - currency_code: getCurrency(), - }, - }, - ], - // getRecommendedPaymentMethods maps to include_account_details in the API - preferences: { - include_account_details: true, - }, - }; -} - -function setShopperInsightsUsage() { - getSessionState((state) => { - return { - ...state, - shopperInsights: { - getRecommendedPaymentMethodsUsed: true, - }, - }; - }); -} - -export function getShopperInsightsComponent(): ShopperInsightsComponent { - const startTime = Date.now(); - - sendCountMetric({ - name: SHOPPER_INSIGHTS_METRIC_NAME, - event: "init", - dimensions: {}, - }); - - const sdkToken = getSDKToken(); - const pageType = getPageType(); - const clientToken = getClientToken(); - const userIDToken = getUserIDToken(); - - getLogger().track({ - [FPTI_KEY.TRANSITION]: FPTI_TRANSITION.SHOPPER_INSIGHTS_API_INIT, - [FPTI_KEY.EVENT_NAME]: FPTI_TRANSITION.SHOPPER_INSIGHTS_API_INIT, - }); - - const shopperInsights = { - getRecommendedPaymentMethods: (merchantPayload) => { - validateMerchantConfig({ sdkToken, pageType, userIDToken, clientToken }); - validateMerchantPayload(merchantPayload); - - const requestPayload = - createRecommendedPaymentMethodsRequestPayload(merchantPayload); - - return callMemoizedRestAPI({ - method: "POST", - url: `${getPayPalAPIDomain()}/${ELIGIBLE_PAYMENT_METHODS}`, - data: requestPayload, - accessToken: sdkToken, - }) - .then((body) => { - setShopperInsightsUsage(); - - const paypal = body?.eligible_methods?.paypal; - const venmo = body?.eligible_methods?.venmo; - - const isPayPalRecommended = - (paypal?.eligible_in_paypal_network && paypal?.recommended) || - false; - const isVenmoRecommended = - (venmo?.eligible_in_paypal_network && venmo?.recommended) || false; - - getLogger().track({ - [FPTI_KEY.TRANSITION]: FPTI_TRANSITION.SHOPPER_INSIGHTS_API_SUCCESS, - [FPTI_KEY.EVENT_NAME]: FPTI_TRANSITION.SHOPPER_INSIGHTS_API_SUCCESS, - [FPTI_KEY.RESPONSE_DURATION]: (Date.now() - startTime).toString(), - }); - - sendCountMetric({ - name: SHOPPER_INSIGHTS_METRIC_NAME, - event: "success", - dimensions: { - isPayPalRecommended: String(isPayPalRecommended), - isVenmoRecommended: String(isVenmoRecommended), - }, - }); - - return { isPayPalRecommended, isVenmoRecommended }; - }) - .catch((err) => { - sendCountMetric({ - name: SHOPPER_INSIGHTS_METRIC_NAME, - event: "error", - dimensions: { - errorType: "api_error", - }, - }); - - getLogger().track({ - [FPTI_KEY.TRANSITION]: FPTI_TRANSITION.SHOPPER_INSIGHTS_API_ERROR, - [FPTI_KEY.EVENT_NAME]: FPTI_TRANSITION.SHOPPER_INSIGHTS_API_ERROR, - [FPTI_KEY.RESPONSE_DURATION]: (Date.now() - startTime).toString(), - }); - - getLogger().error("shopper_insights_api_error", { - err: stringifyError(err), - }); - - throw err; - }); - }, - }; - - return shopperInsights; -} diff --git a/src/api/shopper-insights/component.test.js b/src/api/shopper-insights/component.test.js deleted file mode 100644 index ba6bf853d..000000000 --- a/src/api/shopper-insights/component.test.js +++ /dev/null @@ -1,343 +0,0 @@ -/* @flow */ -import { ZalgoPromise } from "@krakenjs/zalgo-promise/src"; -import { getEnv, getBuyerCountry, getSDKToken } from "@paypal/sdk-client/src"; -import { vi, describe, expect } from "vitest"; -import { request } from "@krakenjs/belter/src"; - -import { ValidationError } from "../../lib"; - -import { getShopperInsightsComponent } from "./component"; - -vi.mock("@paypal/sdk-client/src", () => { - return { - sendCountMetric: vi.fn(), - getSDKToken: vi.fn(() => "sdk-token"), - getPageType: vi.fn(() => "product-details"), - getClientToken: vi.fn(() => ""), - getUserIDToken: vi.fn(() => ""), - getEnv: vi.fn(() => "production"), - getCurrency: vi.fn(() => "USD"), - getBuyerCountry: vi.fn(() => "US"), - getPayPalAPIDomain: vi.fn(() => "https://api.paypal.com"), - getPartnerAttributionID: vi.fn(() => ""), - getSessionID: vi.fn(() => "sdk-session-ID-123"), - getSessionState: vi.fn(), - getLogger: vi.fn(() => ({ track: vi.fn(), error: vi.fn() })), - }; -}); - -vi.mock("@krakenjs/belter/src", async () => { - const actual = await vi.importActual("@krakenjs/belter/src"); - return { - ...actual, - request: vi.fn(() => - ZalgoPromise.resolve({ - status: 200, - headers: {}, - body: { - eligible_methods: { - paypal: { - can_be_vaulted: false, - eligible_in_paypal_network: true, - recommended: true, - recommended_priority: 1, - }, - }, - }, - }) - ), - }; -}); - -describe("shopper insights component - getRecommendedPaymentMethods()", () => { - afterEach(() => { - vi.clearAllMocks(); - }); - - test("should get recommended payment methods using the shopper insights API", async () => { - const shopperInsightsComponent = getShopperInsightsComponent(); - const recommendedPaymentMethods = - await shopperInsightsComponent.getRecommendedPaymentMethods({ - email: "email@test.com", - phone: { - countryCode: "1", - nationalNumber: "2345678901", - }, - }); - - expect(request).toHaveBeenCalled(); - expect(recommendedPaymentMethods).toEqual({ - isPayPalRecommended: true, - isVenmoRecommended: false, - }); - expect.assertions(2); - }); - - test("should get recommended payment methods from memoized request for the exact same payload", async () => { - const shopperInsightsComponent = getShopperInsightsComponent(); - const payload = { - email: "email-1.0@test.com", - phone: { - countryCode: "1", - nationalNumber: "2345678901", - }, - }; - const response1 = - await shopperInsightsComponent.getRecommendedPaymentMethods(payload); - expect(request).toHaveBeenCalled(); - expect(request).toHaveBeenCalledTimes(1); - const response2 = - await shopperInsightsComponent.getRecommendedPaymentMethods(payload); - - expect(request).toHaveBeenCalled(); - // This should not change as the payload is same - expect(request).toHaveBeenCalledTimes(1); - expect(response1).toEqual({ - isPayPalRecommended: true, - isVenmoRecommended: false, - }); - expect(response2).toEqual({ - isPayPalRecommended: true, - isVenmoRecommended: false, - }); - expect.assertions(6); - }); - - test("should not get recommended payment methods from memoized request for a different payload", async () => { - const shopperInsightsComponent = getShopperInsightsComponent(); - const response1 = - await shopperInsightsComponent.getRecommendedPaymentMethods({ - email: "email-1.1@test.com", - }); - expect(request).toHaveBeenCalled(); - expect(request).toHaveBeenCalledTimes(1); - const response2 = - await shopperInsightsComponent.getRecommendedPaymentMethods({ - email: "email-1.2@test.com", - }); - - expect(request).toHaveBeenCalled(); - // This must change to 2 as the payload is different - expect(request).toHaveBeenCalledTimes(2); - expect(response1).toEqual({ - isPayPalRecommended: true, - isVenmoRecommended: false, - }); - expect(response2).toEqual({ - isPayPalRecommended: true, - isVenmoRecommended: false, - }); - expect.assertions(6); - }); - - test("catch errors from the API", async () => { - // $FlowFixMe - request.mockImplementationOnce(() => - ZalgoPromise.resolve({ - status: 400, - headers: {}, - body: { - name: "ERROR", - message: "This is an API error", - }, - }) - ); - - const shopperInsightsComponent = getShopperInsightsComponent(); - - await expect(() => - shopperInsightsComponent.getRecommendedPaymentMethods({ - email: "email@test.com", - phone: { - countryCode: "1", - nationalNumber: "2345678905", - }, - }) - ).rejects.toThrow( - new Error( - `https://api.paypal.com/v2/payments/find-eligible-methods returned status 400\n\n{"name":"ERROR","message":"This is an API error"}` - ) - ); - expect(request).toHaveBeenCalled(); - expect.assertions(2); - }); - - test("create payload with email and phone number", async () => { - const shopperInsightsComponent = getShopperInsightsComponent(); - await shopperInsightsComponent.getRecommendedPaymentMethods({ - email: "email10@test.com", - phone: { - countryCode: "1", - nationalNumber: "2345678906", - }, - }); - - expect(request).toHaveBeenCalledWith( - expect.objectContaining({ - json: expect.objectContaining({ - customer: expect.objectContaining({ - email: "email10@test.com", - phone: expect.objectContaining({ - country_code: "1", - national_number: "2345678906", - }), - }), - }), - }) - ); - }); - - test("create payload with email only", async () => { - const shopperInsightsComponent = getShopperInsightsComponent(); - await shopperInsightsComponent.getRecommendedPaymentMethods({ - email: "email2@test.com", - }); - - expect(request).toHaveBeenCalledWith( - expect.objectContaining({ - json: expect.objectContaining({ - customer: expect.objectContaining({ - email: "email2@test.com", - }), - }), - }) - ); - }); - - test("create payload with phone only", async () => { - const shopperInsightsComponent = getShopperInsightsComponent(); - await shopperInsightsComponent.getRecommendedPaymentMethods({ - email: "email5@test.com", - phone: { - countryCode: "1", - nationalNumber: "2345678901", - }, - }); - - expect(request).toHaveBeenCalledWith( - expect.objectContaining({ - json: expect.objectContaining({ - customer: expect.objectContaining({ - phone: expect.objectContaining({ - country_code: "1", - national_number: "2345678901", - }), - }), - }), - }) - ); - }); - - test("should default purchase units with currency code in the payload", async () => { - const shopperInsightsComponent = getShopperInsightsComponent(); - await shopperInsightsComponent.getRecommendedPaymentMethods({ - email: "email6@test.com", - }); - - expect(request).toHaveBeenCalledWith( - expect.objectContaining({ - json: expect.objectContaining({ - purchase_units: expect.arrayContaining([ - expect.objectContaining({ - amount: expect.objectContaining({ - currency_code: "USD", - }), - }), - ]), - }), - }) - ); - }); - - test("should use the SDK buyer-country parameter if country code is not passed in a non-prod env", async () => { - // $FlowFixMe - getEnv.mockImplementationOnce(() => "stage"); - - const shopperInsightsComponent = getShopperInsightsComponent(); - await shopperInsightsComponent.getRecommendedPaymentMethods({ - email: "email7@test.com", - }); - - expect(request).toHaveBeenCalledWith( - expect.objectContaining({ - json: expect.objectContaining({ - customer: expect.objectContaining({ - country_code: "US", - }), - }), - }) - ); - }); - - test("should default US country code if SDK buyer-country parameter not passed in a non-prod env", async () => { - // $FlowFixMe - getEnv.mockImplementationOnce(() => "stage"); - // $FlowFixMe - getBuyerCountry.mockImplementationOnce(() => ""); - - const shopperInsightsComponent = getShopperInsightsComponent(); - await shopperInsightsComponent.getRecommendedPaymentMethods({ - email: "email9@test.com", - }); - - expect(request).toHaveBeenCalledWith( - expect.objectContaining({ - json: expect.objectContaining({ - customer: expect.objectContaining({ - country_code: "US", - }), - }), - }) - ); - }); - - test("should not set country code in prod env in the payload", async () => { - const shopperInsightsComponent = getShopperInsightsComponent(); - await shopperInsightsComponent.getRecommendedPaymentMethods({ - email: "email@test.com", - }); - - // $FlowIssue - expect(request.mock.calls[0][0].json.customer.country_code).toEqual( - undefined - ); - }); - - test("should request recommended payment methods by setting account details in the payload", async () => { - const shopperInsightsComponent = getShopperInsightsComponent(); - await shopperInsightsComponent.getRecommendedPaymentMethods({ - email: "email9@test.com", - }); - - expect(request).toHaveBeenCalledWith( - expect.objectContaining({ - json: expect.objectContaining({ - preferences: expect.objectContaining({ - include_account_details: true, - }), - }), - }) - ); - }); - - test("ensure sdk-token is passed when using the getRecommendedPaymentMethods", async () => { - // $FlowFixMe - getSDKToken.mockImplementationOnce(() => undefined); - // $FlowFixMe - const shopperInsightsComponent = getShopperInsightsComponent(); - const error = new ValidationError( - `script data attribute sdk-client-token is required but was not passed` - ); - await expect( - async () => - await shopperInsightsComponent.getRecommendedPaymentMethods({ - email: "email@test.com", - phone: { - countryCode: "1", - nationalNumber: "2345678905", - }, - }) - ).rejects.toThrowError(error); - expect.assertions(1); - }); -}); diff --git a/src/api/shopper-insights/fingerprint.js b/src/api/shopper-insights/fingerprint.js new file mode 100644 index 000000000..c57207fae --- /dev/null +++ b/src/api/shopper-insights/fingerprint.js @@ -0,0 +1,52 @@ +/* @flow */ +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable no-restricted-globals, promise/no-native */ +import { load as loadFingerprintJS } from "@fingerprintjs/fingerprintjs-pro"; + +const __FINGERPRINT_JS_API_KEY__ = "Eh4QKkI51U0rVUUPeQT8"; + +type FingerprintResults = {| + requestId: string, +|}; + +type Fingerprinter = {| + get: () => Promise, +|}; + +export class Fingerprint { + fingerprinter: ?Fingerprinter; + results: ?FingerprintResults; + + async load(): Promise { + if (!this.fingerprinter) { + this.fingerprinter = await loadFingerprintJS({ + apiKey: __FINGERPRINT_JS_API_KEY__, + }); + } + } + + async collect(): Promise { + if (this.results && this.results.requestId) { + return this.results; + } + + if (!this.fingerprinter) { + await this.load(); + } + + if (!this.fingerprinter) { + throw new Error("fingerprint library failed to load"); + } + + this.results = await this.fingerprinter.get(); + + return this.results; + } + + get(): Promise { + return this.collect(); + } +} + +// $FlowIssue flow is bad with classes +export const fingerprint = new Fingerprint(); diff --git a/src/api/shopper-insights/interface.js b/src/api/shopper-insights/interface.js index d40cf4f1e..78972954d 100644 --- a/src/api/shopper-insights/interface.js +++ b/src/api/shopper-insights/interface.js @@ -1,12 +1,69 @@ /* @flow */ +import { + getUserIDToken, + getPageType, + getClientToken, + getSDKToken, + getLogger, + getPayPalAPIDomain, + getCurrency, + getBuyerCountry, + getEnv, + getSessionState, +} from "@paypal/sdk-client/src"; import type { LazyExport } from "../../types"; +import { callMemoizedRestAPI } from "../api"; +import { fingerprint } from "./fingerprint"; import { - getShopperInsightsComponent, - type ShopperInsightsComponent, -} from "./component"; + ShopperSession, + type ShopperInsightsInterface, +} from "./shopperSession"; + +const sessionState = { + get: (key) => { + let value; + getSessionState((state) => { + value = state[key]; + return state; + }); + return value; + }, + set: (key, value) => { + getSessionState((state) => ({ + ...state, + [key]: value, + })); + }, +}; + +export const ShopperInsights: LazyExport = { + __get__: () => { + const shopperSession = new ShopperSession({ + fingerprint, + logger: getLogger(), + // $FlowIssue ZalgoPromise vs Promise + request: callMemoizedRestAPI, + sdkConfig: { + sdkToken: getSDKToken(), + pageType: getPageType(), + userIDToken: getUserIDToken(), + clientToken: getClientToken(), + paypalApiDomain: getPayPalAPIDomain(), + environment: getEnv(), + buyerCountry: getBuyerCountry() || "US", + currency: getCurrency(), + }, + sessionState, + }); + + shopperSession.validateSdkConfig(); -export const ShopperInsights: LazyExport = { - __get__: () => getShopperInsightsComponent(), + return { + getRecommendedPaymentMethods: (payload) => + shopperSession.getRecommendedPaymentMethods(payload), + identify: () => shopperSession.identify(), + }; + }, }; diff --git a/src/api/shopper-insights/shopperSession.js b/src/api/shopper-insights/shopperSession.js new file mode 100644 index 000000000..9da4238fd --- /dev/null +++ b/src/api/shopper-insights/shopperSession.js @@ -0,0 +1,304 @@ +/* @flow */ +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable no-restricted-globals, promise/no-native */ + +import { type LoggerType } from "@krakenjs/beaver-logger/src"; +import { stringifyError } from "@krakenjs/belter/src"; +import { sendCountMetric } from "@paypal/sdk-client/src"; +import { FPTI_KEY } from "@paypal/sdk-constants/src"; + +import { + ELIGIBLE_PAYMENT_METHODS, + FPTI_TRANSITION, + SHOPPER_INSIGHTS_METRIC_NAME, +} from "../../constants/api"; +import { ValidationError } from "../../lib"; + +import { + validateMerchantPayload, + hasEmail, + hasPhoneNumber, +} from "./validation"; +import { Fingerprint } from "./fingerprint"; + +export type MerchantPayloadData = {| + email?: string, + phone?: {| + countryCode?: string, + nationalNumber?: string, + |}, +|}; + +type RecommendedPaymentMethods = {| + isPayPalRecommended: boolean, + isVenmoRecommended: boolean, +|}; + +type RecommendedPaymentMethodsRequestData = {| + customer: {| + country_code?: string, + email?: string, + phone?: {| + country_code: string, + national_number: string, + |}, + |}, + purchase_units: $ReadOnlyArray<{| + amount: {| + currency_code: string, + |}, + |}>, + preferences: {| + include_account_details: boolean, + |}, +|}; + +type RecommendedPaymentMethodsResponse = {| + body: {| + eligible_methods: { + [paymentMethod: "paypal" | "venmo"]: {| + can_be_vaulted: boolean, + eligible_in_paypal_network?: boolean, + recommended?: boolean, + recommended_priority?: number, + |}, + }, + |}, +|}; + +type SdkConfig = {| + sdkToken: ?string, + pageType: ?string, + userIDToken: ?string, + clientToken: ?string, + paypalApiDomain: string, + environment: ?string, + buyerCountry: string, + currency: string, +|}; + +// eslint's flow integration is very out of date +// it doesn't recognize the generics here as used +// eslint-disable-next-line no-undef +type Request = ({| + method?: string, + url: string, + // eslint-disable-next-line no-undef + data: TRequestData, + accessToken: ?string, + // eslint-disable-next-line no-undef +|}) => Promise; + +type Storage = {| + // eslint's flow integration is very out of date + // it doesn't recognize the generics here as used + // eslint-disable-next-line no-undef + get: (key: string) => ?TValue, + // eslint-disable-next-line flowtype/no-weak-types + set: (key: string, value: any) => void, +|}; + +export const createRecommendedPaymentMethodsRequestPayload = ({ + merchantPayload, + sdkConfig, +}: {| + merchantPayload: MerchantPayloadData, + sdkConfig: SdkConfig, +|}): RecommendedPaymentMethodsRequestData => ({ + customer: { + ...(sdkConfig.environment !== "production" && { + country_code: sdkConfig.buyerCountry, + }), + // $FlowIssue + ...(hasEmail(merchantPayload) && { + email: merchantPayload?.email, + }), + ...(hasPhoneNumber(merchantPayload) && { + phone: { + country_code: merchantPayload?.phone?.countryCode, + national_number: merchantPayload?.phone?.nationalNumber, + }, + }), + }, + purchase_units: [ + { + amount: { + currency_code: sdkConfig.currency, + }, + }, + ], + // getRecommendedPaymentMethods maps to include_account_details in the API + preferences: { + include_account_details: true, + }, +}); + +export interface ShopperInsightsInterface { + getRecommendedPaymentMethods: ( + payload: MerchantPayloadData + ) => Promise; + identify: () => Promise<{||}>; +} + +export class ShopperSession { + fingerprint: Fingerprint; + logger: LoggerType; + request: Request; + requestId: string = ""; + sdkConfig: SdkConfig; + sessionState: Storage; + + constructor({ + fingerprint, + logger, + request, + sdkConfig, + sessionState, + }: {| + fingerprint: Fingerprint, + logger: LoggerType, + request: Request, + sdkConfig: SdkConfig, + sessionState: Storage, + |}) { + this.fingerprint = fingerprint; + this.logger = logger; + this.request = request; + this.sdkConfig = sdkConfig; + this.sessionState = sessionState; + } + + validateSdkConfig() { + if (!this.sdkConfig.sdkToken) { + sendCountMetric({ + name: SHOPPER_INSIGHTS_METRIC_NAME, + event: "error", + dimensions: { + errorType: "merchant_configuration_validation_error", + validationDetails: "sdk_token_not_present", + }, + }); + + throw new ValidationError( + `script data attribute sdk-client-token is required but was not passed` + ); + } + + if (!this.sdkConfig.pageType) { + sendCountMetric({ + name: SHOPPER_INSIGHTS_METRIC_NAME, + event: "error", + dimensions: { + errorType: "merchant_configuration_validation_error", + validationDetails: "page_type_not_present", + }, + }); + + throw new ValidationError( + `script data attribute page-type is required but was not passed` + ); + } + + if (this.sdkConfig.userIDToken) { + sendCountMetric({ + name: SHOPPER_INSIGHTS_METRIC_NAME, + event: "error", + dimensions: { + errorType: "merchant_configuration_validation_error", + validationDetails: "sdk_token_and_id_token_present", + }, + }); + + throw new ValidationError( + `use script data attribute sdk-client-token instead of user-id-token` + ); + } + + // Client token has widely adopted integrations in the SDK that we do not want + // to support anymore. For now, we will be only enforcing a warning. We should + // expand on this warning with upgrade guides when we have them. + if (this.sdkConfig.clientToken) { + // eslint-disable-next-line no-console + console.warn(`script data attribute client-token is not recommended`); + } + } + + async getRecommendedPaymentMethods( + merchantPayload: MerchantPayloadData + ): Promise { + validateMerchantPayload(merchantPayload); + + const startTime = Date.now(); + try { + const { body } = await this.request< + RecommendedPaymentMethodsRequestData, + RecommendedPaymentMethodsResponse + >({ + method: "POST", + url: `${this.sdkConfig.paypalApiDomain}/${ELIGIBLE_PAYMENT_METHODS}`, + data: createRecommendedPaymentMethodsRequestPayload({ + merchantPayload, + sdkConfig: this.sdkConfig, + }), + accessToken: this.sdkConfig.sdkToken, + }); + + this.sessionState.set("shopperInsights", { + getRecommendedPaymentMethodsUsed: true, + }); + + const { paypal, venmo } = body?.eligible_methods; + + const isPayPalRecommended = + (paypal?.eligible_in_paypal_network && paypal?.recommended) || false; + const isVenmoRecommended = + (venmo?.eligible_in_paypal_network && venmo?.recommended) || false; + + this.logger.track({ + [FPTI_KEY.TRANSITION]: FPTI_TRANSITION.SHOPPER_INSIGHTS_API_SUCCESS, + [FPTI_KEY.EVENT_NAME]: FPTI_TRANSITION.SHOPPER_INSIGHTS_API_SUCCESS, + [FPTI_KEY.RESPONSE_DURATION]: (Date.now() - startTime).toString(), + }); + + sendCountMetric({ + name: SHOPPER_INSIGHTS_METRIC_NAME, + event: "success", + dimensions: { + isPayPalRecommended: String(isPayPalRecommended), + isVenmoRecommended: String(isVenmoRecommended), + }, + }); + + return { isPayPalRecommended, isVenmoRecommended }; + } catch (error) { + sendCountMetric({ + name: SHOPPER_INSIGHTS_METRIC_NAME, + event: "error", + dimensions: { + errorType: "api_error", + }, + }); + + this.logger.track({ + [FPTI_KEY.TRANSITION]: FPTI_TRANSITION.SHOPPER_INSIGHTS_API_ERROR, + [FPTI_KEY.EVENT_NAME]: FPTI_TRANSITION.SHOPPER_INSIGHTS_API_ERROR, + [FPTI_KEY.RESPONSE_DURATION]: (Date.now() - startTime).toString(), + }); + + this.logger.error("shopper_insights_api_error", { + err: stringifyError(error), + }); + + throw error; + } + } + + async identify(): Promise<{||}> { + const { requestId } = await this.fingerprint.get(); + + this.requestId = requestId; + + // $FlowIssue + return {}; + } +} diff --git a/src/api/shopper-insights/shopperSession.test.js b/src/api/shopper-insights/shopperSession.test.js new file mode 100644 index 000000000..8dc3f2b73 --- /dev/null +++ b/src/api/shopper-insights/shopperSession.test.js @@ -0,0 +1,320 @@ +/* @flow */ +import { vi, describe, expect } from "vitest"; + +import { ShopperSession } from "./shopperSession"; + +vi.mock("@paypal/sdk-client/src", () => { + return { + sendCountMetric: vi.fn(), + }; +}); + +const mockStateObject = {}; +const mockStorage = { + get: (key) => mockStateObject[key], + set: (key, value) => { + mockStateObject[key] = value; + }, +}; + +const mockFindEligiblePaymentsRequest = ( + eligibility = { + paypal: { + can_be_vaulted: true, + eligible_in_paypal_network: true, + recommended: true, + recommended_priority: 1, + }, + } +) => + vi.fn().mockResolvedValue({ + body: { + eligible_methods: eligibility, + }, + }); + +const defaultSdkConfig = { + sdkToken: "sdk client token", + pageType: "checkout", + clientToken: "", + userIDToken: "", + paypalApiDomain: "https://api.paypal.com", + environment: "test", + buyerCountry: "US", + currency: "USD", +}; + +const createShopperSession = ({ + fingerprint = { + load: vi.fn(), + collect: vi.fn(), + get: vi.fn(), + }, + sdkConfig = defaultSdkConfig, + logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + track: vi.fn(), + }, + sessionState = mockStorage, + request = mockFindEligiblePaymentsRequest(), +} = {}) => + new ShopperSession({ + // $FlowIssue + fingerprint, + sdkConfig, + // $FlowIssue + logger, + sessionState, + request, + }); + +describe("shopper insights component - getRecommendedPaymentMethods()", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + test("should get recommended payment methods using the shopper insights API", async () => { + const shopperSession = createShopperSession(); + const recommendedPaymentMethods = + await shopperSession.getRecommendedPaymentMethods({ + email: "email@test.com", + phone: { + countryCode: "1", + nationalNumber: "2345678901", + }, + }); + + expect.assertions(1); + expect(recommendedPaymentMethods).toEqual({ + isPayPalRecommended: true, + isVenmoRecommended: false, + }); + }); + + test("catch errors from the API", async () => { + const mockRequest = vi.fn().mockRejectedValue(new Error("Error with API")); + const shopperSession = createShopperSession({ request: mockRequest }); + + expect.assertions(2); + await expect(() => + shopperSession.getRecommendedPaymentMethods({ + email: "email@test.com", + phone: { + countryCode: "1", + nationalNumber: "2345678905", + }, + }) + ).rejects.toThrow(new Error("Error with API")); + expect(mockRequest).toHaveBeenCalled(); + }); + + test("create payload with email and phone number", async () => { + const mockRequest = mockFindEligiblePaymentsRequest(); + const shopperSession = createShopperSession({ request: mockRequest }); + await shopperSession.getRecommendedPaymentMethods({ + email: "email10@test.com", + phone: { + countryCode: "1", + nationalNumber: "2345678906", + }, + }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + customer: expect.objectContaining({ + email: "email10@test.com", + phone: expect.objectContaining({ + country_code: "1", + national_number: "2345678906", + }), + }), + }), + }) + ); + }); + + test("create payload with email only", async () => { + const mockRequest = mockFindEligiblePaymentsRequest(); + const shopperSession = createShopperSession({ request: mockRequest }); + await shopperSession.getRecommendedPaymentMethods({ + email: "email2@test.com", + }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + customer: expect.objectContaining({ + email: "email2@test.com", + }), + }), + }) + ); + }); + + test("create payload with phone only", async () => { + const mockRequest = mockFindEligiblePaymentsRequest(); + const shopperSession = createShopperSession({ request: mockRequest }); + await shopperSession.getRecommendedPaymentMethods({ + email: "email5@test.com", + phone: { + countryCode: "1", + nationalNumber: "2345678901", + }, + }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + customer: expect.objectContaining({ + phone: expect.objectContaining({ + country_code: "1", + national_number: "2345678901", + }), + }), + }), + }) + ); + }); + + test("should default purchase units with currency code in the payload", async () => { + const mockRequest = mockFindEligiblePaymentsRequest(); + const shopperSession = createShopperSession({ request: mockRequest }); + await shopperSession.getRecommendedPaymentMethods({ + email: "email6@test.com", + }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + purchase_units: expect.arrayContaining([ + expect.objectContaining({ + amount: expect.objectContaining({ + currency_code: "USD", + }), + }), + ]), + }), + }) + ); + }); + + test("should use the SDK buyer-country parameter if country code is not passed in a non-prod env", async () => { + const mockRequest = mockFindEligiblePaymentsRequest(); + const shopperSession = createShopperSession({ + request: mockRequest, + sdkConfig: { + ...defaultSdkConfig, + environment: "test", + buyerCountry: "US", + }, + }); + await shopperSession.getRecommendedPaymentMethods({ + email: "email7@test.com", + }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + customer: expect.objectContaining({ + country_code: "US", + }), + }), + }) + ); + }); + + test("should not set country code in prod env in the payload", async () => { + const mockRequest = mockFindEligiblePaymentsRequest(); + const shopperSession = createShopperSession({ + request: mockRequest, + sdkConfig: { + ...defaultSdkConfig, + environment: "production", + buyerCountry: "US", + }, + }); + await shopperSession.getRecommendedPaymentMethods({ + email: "email@test.com", + }); + + // $FlowIssue + expect(mockRequest.mock.calls[0][0].data.customer.country_code).toEqual( + undefined + ); + }); + + test("should request recommended payment methods by setting account details in the payload", async () => { + const mockRequest = mockFindEligiblePaymentsRequest(); + const shopperSession = createShopperSession({ request: mockRequest }); + await shopperSession.getRecommendedPaymentMethods({ + email: "email9@test.com", + }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + preferences: expect.objectContaining({ + include_account_details: true, + }), + }), + }) + ); + }); +}); + +describe("shopper insights component - validateSdkConfig()", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + test("should throw if sdk token is not passed", () => { + const shopperSession = createShopperSession({ + sdkConfig: { + ...defaultSdkConfig, + sdkToken: "", + pageType: "", + userIDToken: "", + clientToken: "", + }, + }); + + expect(() => shopperSession.validateSdkConfig()).toThrowError( + "script data attribute sdk-client-token is required but was not passed" + ); + }); + + test("should throw if page type is not passed", () => { + const shopperSession = createShopperSession({ + sdkConfig: { + ...defaultSdkConfig, + sdkToken: "sdk-token", + pageType: "", + userIDToken: "", + clientToken: "", + }, + }); + + expect(() => shopperSession.validateSdkConfig()).toThrowError( + "script data attribute page-type is required but was not passed" + ); + }); + + test("should throw if ID token is passed", () => { + const shopperSession = createShopperSession({ + sdkConfig: { + ...defaultSdkConfig, + sdkToken: "sdk-token", + pageType: "product-listing", + userIDToken: "id-token", + clientToken: "", + }, + }); + + expect(() => shopperSession.validateSdkConfig()).toThrowError( + "use script data attribute sdk-client-token instead of user-id-token" + ); + }); +}); diff --git a/src/api/shopper-insights/validation.js b/src/api/shopper-insights/validation.js index 288591953..81eb4e42c 100644 --- a/src/api/shopper-insights/validation.js +++ b/src/api/shopper-insights/validation.js @@ -1,80 +1,7 @@ /* @flow */ - -import { sendCountMetric } from "@paypal/sdk-client/src"; - -import { - SHOPPER_INSIGHTS_METRIC_NAME, - type MerchantPayloadData, -} from "../../constants/api"; +import { type MerchantPayloadData } from "../../constants/api"; import { ValidationError } from "../../lib"; -type MerchantConfigParams = {| - sdkToken: ?string, - pageType: ?string, - userIDToken: ?string, - clientToken: ?string, -|}; - -export function validateMerchantConfig({ - sdkToken, - pageType, - userIDToken, - clientToken, -}: MerchantConfigParams) { - if (!sdkToken) { - sendCountMetric({ - name: SHOPPER_INSIGHTS_METRIC_NAME, - event: "error", - dimensions: { - errorType: "merchant_configuration_validation_error", - validationDetails: "sdk_token_not_present", - }, - }); - - throw new ValidationError( - `script data attribute sdk-client-token is required but was not passed` - ); - } - - if (!pageType) { - sendCountMetric({ - name: SHOPPER_INSIGHTS_METRIC_NAME, - event: "error", - dimensions: { - errorType: "merchant_configuration_validation_error", - validationDetails: "page_type_not_present", - }, - }); - - throw new ValidationError( - `script data attribute page-type is required but was not passed` - ); - } - - if (userIDToken) { - sendCountMetric({ - name: SHOPPER_INSIGHTS_METRIC_NAME, - event: "error", - dimensions: { - errorType: "merchant_configuration_validation_error", - validationDetails: "sdk_token_and_id_token_present", - }, - }); - - throw new ValidationError( - `use script data attribute sdk-client-token instead of user-id-token` - ); - } - - // Client token has widely adopted integrations in the SDK that we do not want - // to support anymore. For now, we will be only enforcing a warning. We should - // expand on this warning with upgrade guides when we have them. - if (clientToken) { - // eslint-disable-next-line no-console - console.warn(`script data attribute client-token is not recommended`); - } -} - export const hasEmail = (merchantPayload: MerchantPayloadData): boolean => Boolean(merchantPayload?.email); diff --git a/src/api/shopper-insights/validation.test.js b/src/api/shopper-insights/validation.test.js index 6e9d6cd9a..b28673ffa 100644 --- a/src/api/shopper-insights/validation.test.js +++ b/src/api/shopper-insights/validation.test.js @@ -1,7 +1,7 @@ /* @flow */ import { vi, describe, expect } from "vitest"; -import { validateMerchantConfig, validateMerchantPayload } from "./validation"; +import { validateMerchantPayload } from "./validation"; vi.mock("@paypal/sdk-client/src", () => { return { @@ -9,47 +9,6 @@ vi.mock("@paypal/sdk-client/src", () => { }; }); -describe("shopper insights merchant SDK config validation", () => { - test("should throw if sdk token is not passed", () => { - expect(() => - validateMerchantConfig({ - sdkToken: "", - pageType: "", - userIDToken: "", - clientToken: "", - }) - ).toThrowError( - "script data attribute sdk-client-token is required but was not passed" - ); - }); - - test("should throw if page type is not passed", () => { - expect(() => - validateMerchantConfig({ - sdkToken: "sdk-token", - pageType: "", - userIDToken: "", - clientToken: "", - }) - ).toThrowError( - "script data attribute page-type is required but was not passed" - ); - }); - - test("should throw if ID token is passed", () => { - expect(() => - validateMerchantConfig({ - sdkToken: "sdk-token", - pageType: "product-listing", - userIDToken: "id-token", - clientToken: "", - }) - ).toThrowError( - "use script data attribute sdk-client-token instead of user-id-token" - ); - }); -}); - describe("shopper insights merchant payload validation", () => { test("should have successful validation if email is only passed", () => { expect(() =>