@paypal/checkout-components 5.0.301 → 5.0.302-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paypal/checkout-components",
3
- "version": "5.0.301",
3
+ "version": "5.0.302-alpha.1",
4
4
  "description": "PayPal Checkout components, for integrating checkout products.",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/src/lib/api.js ADDED
@@ -0,0 +1,67 @@
1
+ /* @flow */
2
+
3
+ import { getPartnerAttributionID, getSessionID } from "@paypal/sdk-client/src";
4
+ import { inlineMemoize, request } from "@krakenjs/belter/src";
5
+ import { ZalgoPromise } from "@krakenjs/zalgo-promise/src";
6
+
7
+ import { HEADERS } from "../constants/api";
8
+
9
+ type RestAPIParams = {|
10
+ method?: string,
11
+ url: string,
12
+ data: Object,
13
+ accessToken: ?string,
14
+ |};
15
+
16
+ export function callRestAPI({
17
+ accessToken,
18
+ method,
19
+ url,
20
+ data,
21
+ }: RestAPIParams): ZalgoPromise<Object> {
22
+ const partnerAttributionID = getPartnerAttributionID() || "";
23
+
24
+ if (!accessToken) {
25
+ throw new Error(`No access token passed to API request ${url}`);
26
+ }
27
+
28
+ const requestHeaders = {
29
+ [HEADERS.AUTHORIZATION]: `Bearer ${accessToken}`,
30
+ [HEADERS.CONTENT_TYPE]: `application/json`,
31
+ [HEADERS.PARTNER_ATTRIBUTION_ID]: partnerAttributionID,
32
+ [HEADERS.CLIENT_METADATA_ID]: getSessionID(),
33
+ };
34
+
35
+ return request({
36
+ method,
37
+ url,
38
+ headers: requestHeaders,
39
+ json: data,
40
+ }).then(({ status, body, headers: responseHeaders }) => {
41
+ if (status >= 300) {
42
+ const error = new Error(
43
+ `${url} returned status ${status}\n\n${JSON.stringify(body)}`
44
+ );
45
+
46
+ // $FlowFixMe
47
+ error.response = { status, headers: responseHeaders, body };
48
+
49
+ throw error;
50
+ }
51
+
52
+ return body;
53
+ });
54
+ }
55
+
56
+ export function callMemoizedRestAPI({
57
+ accessToken,
58
+ method,
59
+ url,
60
+ data,
61
+ }: RestAPIParams): ZalgoPromise<Object> {
62
+ return inlineMemoize(
63
+ callMemoizedRestAPI,
64
+ () => callRestAPI({ accessToken, method, url, data }),
65
+ [accessToken, method, url, JSON.stringify(data)]
66
+ );
67
+ }
package/src/lib/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /* @flow */
2
2
 
3
+ export * from "./api";
3
4
  export * from "./errors";
4
5
  export * from "./isRTLLanguage";
5
6
  export * from "./security";
@@ -0,0 +1,64 @@
1
+ /* @flow */
2
+ import {
3
+ getUserIDToken,
4
+ getPageType,
5
+ getClientToken,
6
+ getSDKToken,
7
+ getLogger,
8
+ getPayPalAPIDomain,
9
+ getCurrency,
10
+ getBuyerCountry,
11
+ getEnv,
12
+ getSessionState,
13
+ } from "@paypal/sdk-client/src";
14
+
15
+ import type { LazyExport } from "../types";
16
+ import { callMemoizedRestAPI } from "../lib";
17
+
18
+ import {
19
+ ShopperSession,
20
+ type ShopperInsightsInterface,
21
+ } from "./shopperSession";
22
+
23
+ const sessionState = {
24
+ get: (key) => {
25
+ let value;
26
+ getSessionState((state) => {
27
+ value = state[key];
28
+ return state;
29
+ });
30
+ return value;
31
+ },
32
+ set: (key, value) => {
33
+ getSessionState((state) => ({
34
+ ...state,
35
+ [key]: value,
36
+ }));
37
+ },
38
+ };
39
+
40
+ export const ShopperInsights: LazyExport<ShopperInsightsInterface> = {
41
+ __get__: () => {
42
+ const shopperSession = new ShopperSession({
43
+ logger: getLogger(),
44
+ // $FlowIssue ZalgoPromise vs Promise
45
+ request: callMemoizedRestAPI,
46
+ sdkConfig: {
47
+ sdkToken: getSDKToken(),
48
+ pageType: getPageType(),
49
+ userIDToken: getUserIDToken(),
50
+ clientToken: getClientToken(),
51
+ paypalApiDomain: getPayPalAPIDomain(),
52
+ environment: getEnv(),
53
+ buyerCountry: getBuyerCountry() || "US",
54
+ currency: getCurrency(),
55
+ },
56
+ sessionState,
57
+ });
58
+
59
+ return {
60
+ getRecommendedPaymentMethods: (payload) =>
61
+ shopperSession.getRecommendedPaymentMethods(payload),
62
+ };
63
+ },
64
+ };
@@ -0,0 +1,342 @@
1
+ /* @flow */
2
+ /* eslint-disable eslint-comments/disable-enable-pair */
3
+ /* eslint-disable no-restricted-globals, promise/no-native */
4
+
5
+ import { type LoggerType } from "@krakenjs/beaver-logger/src";
6
+ import { stringifyError } from "@krakenjs/belter/src";
7
+ import { FPTI_KEY } from "@paypal/sdk-constants/src";
8
+
9
+ import {
10
+ ELIGIBLE_PAYMENT_METHODS,
11
+ FPTI_TRANSITION,
12
+ SHOPPER_INSIGHTS_METRIC_NAME,
13
+ } from "../constants/api";
14
+ import { ValidationError } from "../lib";
15
+
16
+ export type MerchantPayloadData = {|
17
+ email?: string,
18
+ phone?: {|
19
+ countryCode?: string,
20
+ nationalNumber?: string,
21
+ |},
22
+ |};
23
+
24
+ type RecommendedPaymentMethods = {|
25
+ isPayPalRecommended: boolean,
26
+ isVenmoRecommended: boolean,
27
+ |};
28
+
29
+ type RecommendedPaymentMethodsRequestData = {|
30
+ customer: {|
31
+ country_code?: string,
32
+ email?: string,
33
+ phone?: {|
34
+ country_code: string,
35
+ national_number: string,
36
+ |},
37
+ |},
38
+ purchase_units: $ReadOnlyArray<{|
39
+ amount: {|
40
+ currency_code: string,
41
+ |},
42
+ |}>,
43
+ preferences: {|
44
+ include_account_details: boolean,
45
+ |},
46
+ |};
47
+
48
+ type RecommendedPaymentMethodsResponse = {|
49
+ body: {|
50
+ eligible_methods: {
51
+ [paymentMethod: "paypal" | "venmo"]: {|
52
+ can_be_vaulted: boolean,
53
+ eligible_in_paypal_network?: boolean,
54
+ recommended?: boolean,
55
+ recommended_priority?: number,
56
+ |},
57
+ },
58
+ |},
59
+ |};
60
+
61
+ type SdkConfig = {|
62
+ sdkToken: ?string,
63
+ pageType: ?string,
64
+ userIDToken: ?string,
65
+ clientToken: ?string,
66
+ paypalApiDomain: string,
67
+ environment: ?string,
68
+ buyerCountry: string,
69
+ currency: string,
70
+ |};
71
+
72
+ // eslint's flow integration is very out of date
73
+ // it doesn't recognize the generics here as used
74
+ // eslint-disable-next-line no-undef
75
+ type Request = <TRequestData, TResponse>({|
76
+ method?: string,
77
+ url: string,
78
+ // eslint-disable-next-line no-undef
79
+ data: TRequestData,
80
+ accessToken: ?string,
81
+ // eslint-disable-next-line no-undef
82
+ |}) => Promise<TResponse>;
83
+
84
+ type Storage = {|
85
+ // eslint's flow integration is very out of date
86
+ // it doesn't recognize the generics here as used
87
+ // eslint-disable-next-line no-undef
88
+ get: <TValue>(key: string) => ?TValue,
89
+ // eslint-disable-next-line flowtype/no-weak-types
90
+ set: (key: string, value: any) => void,
91
+ |};
92
+
93
+ const parseEmail = (merchantPayload): ?{| email: string |} => {
94
+ if (!merchantPayload.email) {
95
+ return;
96
+ }
97
+
98
+ const email = merchantPayload.email;
99
+ const isValidEmail =
100
+ typeof email === "string" && email.length < 320 && /^.+@.+$/.test(email);
101
+
102
+ if (!isValidEmail) {
103
+ throw new ValidationError(
104
+ `Expected shopper information to include a valid email format`
105
+ );
106
+ }
107
+
108
+ return {
109
+ email,
110
+ };
111
+ };
112
+
113
+ const parsePhone = (
114
+ merchantPayload
115
+ ): ?{| phone: {| country_code: string, national_number: string |} |} => {
116
+ if (!merchantPayload.phone) {
117
+ return;
118
+ }
119
+
120
+ if (
121
+ !merchantPayload.phone.nationalNumber ||
122
+ !merchantPayload.phone.countryCode
123
+ ) {
124
+ throw new ValidationError(
125
+ `Expected phone number for shopper insights to include nationalNumber and countryCode`
126
+ );
127
+ }
128
+
129
+ const nationalNumber = merchantPayload.phone.nationalNumber;
130
+ const countryCode = merchantPayload.phone.countryCode;
131
+ const isValidPhone =
132
+ typeof nationalNumber === "string" && /\d{5,}/.test(nationalNumber);
133
+
134
+ if (!isValidPhone) {
135
+ throw new ValidationError(
136
+ `Expected shopper information to be a valid phone number format`
137
+ );
138
+ }
139
+
140
+ return {
141
+ phone: {
142
+ country_code: countryCode,
143
+ national_number: nationalNumber,
144
+ },
145
+ };
146
+ };
147
+
148
+ export const parseMerchantPayload = ({
149
+ merchantPayload,
150
+ sdkConfig,
151
+ }: {|
152
+ merchantPayload: MerchantPayloadData,
153
+ sdkConfig: SdkConfig,
154
+ |}): RecommendedPaymentMethodsRequestData => {
155
+ const email = parseEmail(merchantPayload);
156
+ const phone = parsePhone(merchantPayload);
157
+
158
+ return {
159
+ customer: {
160
+ ...(sdkConfig.environment !== "production" && {
161
+ country_code: sdkConfig.buyerCountry,
162
+ }),
163
+ // $FlowIssue too many cases?
164
+ ...email,
165
+ ...phone,
166
+ },
167
+ purchase_units: [
168
+ {
169
+ amount: {
170
+ currency_code: sdkConfig.currency,
171
+ },
172
+ },
173
+ ],
174
+ // getRecommendedPaymentMethods maps to include_account_details in the API
175
+ preferences: {
176
+ include_account_details: true,
177
+ },
178
+ };
179
+ };
180
+
181
+ const parseSdkConfig = ({
182
+ sdkConfig,
183
+ logger,
184
+ }: {|
185
+ sdkConfig: SdkConfig,
186
+ logger: LoggerType,
187
+ |}): SdkConfig => {
188
+ if (!sdkConfig.sdkToken) {
189
+ logger.metricCounter({
190
+ namespace: SHOPPER_INSIGHTS_METRIC_NAME,
191
+ event: "error",
192
+ dimensions: {
193
+ errorType: "merchant_configuration_validation_error",
194
+ validationDetails: "sdk_token_not_present",
195
+ },
196
+ });
197
+
198
+ throw new ValidationError(
199
+ `script data attribute sdk-client-token is required but was not passed`
200
+ );
201
+ }
202
+
203
+ if (!sdkConfig.pageType) {
204
+ logger.metricCounter({
205
+ namespace: SHOPPER_INSIGHTS_METRIC_NAME,
206
+ event: "error",
207
+ dimensions: {
208
+ errorType: "merchant_configuration_validation_error",
209
+ validationDetails: "page_type_not_present",
210
+ },
211
+ });
212
+
213
+ throw new ValidationError(
214
+ `script data attribute page-type is required but was not passed`
215
+ );
216
+ }
217
+
218
+ if (sdkConfig.userIDToken) {
219
+ logger.metricCounter({
220
+ namespace: SHOPPER_INSIGHTS_METRIC_NAME,
221
+ event: "error",
222
+ dimensions: {
223
+ errorType: "merchant_configuration_validation_error",
224
+ validationDetails: "sdk_token_and_id_token_present",
225
+ },
226
+ });
227
+
228
+ throw new ValidationError(
229
+ `use script data attribute sdk-client-token instead of user-id-token`
230
+ );
231
+ }
232
+
233
+ // Client token has widely adopted integrations in the SDK that we do not want
234
+ // to support anymore. For now, we will be only enforcing a warning. We should
235
+ // expand on this warning with upgrade guides when we have them.
236
+ if (sdkConfig.clientToken) {
237
+ // eslint-disable-next-line no-console
238
+ console.warn(`script data attribute client-token is not recommended`);
239
+ }
240
+
241
+ return sdkConfig;
242
+ };
243
+
244
+ export interface ShopperInsightsInterface {
245
+ getRecommendedPaymentMethods: (
246
+ payload: MerchantPayloadData
247
+ ) => Promise<RecommendedPaymentMethods>;
248
+ }
249
+
250
+ export class ShopperSession {
251
+ logger: LoggerType;
252
+ request: Request;
253
+ requestId: string = "";
254
+ sdkConfig: SdkConfig;
255
+ sessionState: Storage;
256
+
257
+ constructor({
258
+ logger,
259
+ request,
260
+ sdkConfig,
261
+ sessionState,
262
+ }: {|
263
+ logger: LoggerType,
264
+ request: Request,
265
+ sdkConfig: SdkConfig,
266
+ sessionState: Storage,
267
+ |}) {
268
+ this.logger = logger;
269
+ this.request = request;
270
+ this.sdkConfig = parseSdkConfig({ sdkConfig, logger });
271
+ this.sessionState = sessionState;
272
+ }
273
+
274
+ async getRecommendedPaymentMethods(
275
+ merchantPayload: MerchantPayloadData
276
+ ): Promise<RecommendedPaymentMethods> {
277
+ const startTime = Date.now();
278
+ const data = parseMerchantPayload({
279
+ merchantPayload,
280
+ sdkConfig: this.sdkConfig,
281
+ });
282
+ try {
283
+ const { body } = await this.request<
284
+ RecommendedPaymentMethodsRequestData,
285
+ RecommendedPaymentMethodsResponse
286
+ >({
287
+ method: "POST",
288
+ url: `${this.sdkConfig.paypalApiDomain}/${ELIGIBLE_PAYMENT_METHODS}`,
289
+ data,
290
+ accessToken: this.sdkConfig.sdkToken,
291
+ });
292
+
293
+ this.sessionState.set("shopperInsights", {
294
+ getRecommendedPaymentMethodsUsed: true,
295
+ });
296
+
297
+ const { paypal, venmo } = body?.eligible_methods;
298
+
299
+ const isPayPalRecommended =
300
+ (paypal?.eligible_in_paypal_network && paypal?.recommended) || false;
301
+ const isVenmoRecommended =
302
+ (venmo?.eligible_in_paypal_network && venmo?.recommended) || false;
303
+
304
+ this.logger.track({
305
+ [FPTI_KEY.TRANSITION]: FPTI_TRANSITION.SHOPPER_INSIGHTS_API_SUCCESS,
306
+ [FPTI_KEY.EVENT_NAME]: FPTI_TRANSITION.SHOPPER_INSIGHTS_API_SUCCESS,
307
+ [FPTI_KEY.RESPONSE_DURATION]: (Date.now() - startTime).toString(),
308
+ });
309
+
310
+ this.logger.metricCounter({
311
+ namespace: SHOPPER_INSIGHTS_METRIC_NAME,
312
+ event: "success",
313
+ dimensions: {
314
+ isPayPalRecommended: String(isPayPalRecommended),
315
+ isVenmoRecommended: String(isVenmoRecommended),
316
+ },
317
+ });
318
+
319
+ return { isPayPalRecommended, isVenmoRecommended };
320
+ } catch (error) {
321
+ this.logger.metricCounter({
322
+ namespace: SHOPPER_INSIGHTS_METRIC_NAME,
323
+ event: "error",
324
+ dimensions: {
325
+ errorType: "api_error",
326
+ },
327
+ });
328
+
329
+ this.logger.track({
330
+ [FPTI_KEY.TRANSITION]: FPTI_TRANSITION.SHOPPER_INSIGHTS_API_ERROR,
331
+ [FPTI_KEY.EVENT_NAME]: FPTI_TRANSITION.SHOPPER_INSIGHTS_API_ERROR,
332
+ [FPTI_KEY.RESPONSE_DURATION]: (Date.now() - startTime).toString(),
333
+ });
334
+
335
+ this.logger.error("shopper_insights_api_error", {
336
+ err: stringifyError(error),
337
+ });
338
+
339
+ throw error;
340
+ }
341
+ }
342
+ }