From fc488aa508c071ea01645de9bf9ede457d7a5a8f Mon Sep 17 00:00:00 2001 From: Jesse Shawl Date: Wed, 24 Jan 2024 10:20:48 -0600 Subject: [PATCH 1/2] Handle errors for create order and popup open failures --- src/hosted-buttons/index.js | 6 +- src/hosted-buttons/types.js | 9 ++- src/hosted-buttons/utils.js | 44 ++++++++++---- src/hosted-buttons/utils.test.js | 101 ++++++++++++++++++++++++++----- 4 files changed, 132 insertions(+), 28 deletions(-) diff --git a/src/hosted-buttons/index.js b/src/hosted-buttons/index.js index 78ce60a9dd..80d4894c51 100644 --- a/src/hosted-buttons/index.js +++ b/src/hosted-buttons/index.js @@ -5,6 +5,7 @@ import { getButtonsComponent } from "../zoid/buttons"; import { buildHostedButtonCreateOrder, buildHostedButtonOnApprove, + buildOpenPopup, getHostedButtonDetails, renderForm, getMerchantID, @@ -24,7 +25,7 @@ export const getHostedButtonsComponent = (): HostedButtonsComponent => { const merchantId = getMerchantID(); getHostedButtonDetails({ hostedButtonId }).then( - ({ html, htmlScript, style }) => { + ({ html, htmlScript, style, popupFallback }) => { const { onInit, onClick } = renderForm({ hostedButtonId, html, @@ -32,6 +33,8 @@ export const getHostedButtonsComponent = (): HostedButtonsComponent => { selector, }); + const openPopup = buildOpenPopup({ selector, popupFallback }); + // $FlowFixMe Buttons({ hostedButtonId, @@ -45,6 +48,7 @@ export const getHostedButtonsComponent = (): HostedButtonsComponent => { onApprove: buildHostedButtonOnApprove({ hostedButtonId, merchantId, + openPopup, }), }).render(selector); } diff --git a/src/hosted-buttons/types.js b/src/hosted-buttons/types.js index f3746c9740..3e965779ff 100644 --- a/src/hosted-buttons/types.js +++ b/src/hosted-buttons/types.js @@ -9,6 +9,7 @@ export type HostedButtonsComponentProps = {| export type GetCallbackProps = {| hostedButtonId: string, merchantId?: string, + openPopup?: (url: string) => void, |}; export type HostedButtonsInstance = {| @@ -25,6 +26,7 @@ export type HostedButtonDetailsParams = color: string, label: string, |}, + popupFallback: string, |}>; export type ButtonVariables = $ReadOnlyArray<{| @@ -34,7 +36,7 @@ export type ButtonVariables = $ReadOnlyArray<{| export type CreateOrder = (data: {| paymentSource: string, -|}) => ZalgoPromise; +|}) => ZalgoPromise; export type OnApprove = (data: {| orderID: string, @@ -55,3 +57,8 @@ export type RenderForm = ({| onInit: (data: mixed, actions: mixed) => void, onClick: (data: mixed, actions: mixed) => void, |}; + +export type BuildOpenPopup = ({| + popupFallback: string, + selector: string | HTMLElement, +|}) => (url: string) => void; diff --git a/src/hosted-buttons/utils.js b/src/hosted-buttons/utils.js index 66134d5919..cfa5de232d 100644 --- a/src/hosted-buttons/utils.js +++ b/src/hosted-buttons/utils.js @@ -1,6 +1,6 @@ /* @flow */ -import { request, memoize, popup, supportsPopups } from "@krakenjs/belter/src"; +import { request, memoize, popup } from "@krakenjs/belter/src"; import { getSDKHost, getClientID, @@ -11,6 +11,7 @@ import { FUNDING } from "@paypal/sdk-constants/src"; import { DEFAULT_POPUP_SIZE } from "../zoid/checkout"; import type { + BuildOpenPopup, ButtonVariables, CreateAccessToken, CreateOrder, @@ -23,6 +24,7 @@ import type { const entryPoint = "SDK"; const baseUrl = `https://${getSDKHost()}`; const apiUrl = baseUrl.replace("www", "api"); +export const popupFallbackClassName = "paypal-popup-fallback"; const getHeaders = (accessToken?: string) => ({ ...(accessToken && { Authorization: `Bearer ${accessToken}` }), @@ -75,6 +77,7 @@ export const getHostedButtonDetails: HostedButtonDetailsParams = ({ }, html: body.html, htmlScript: body.html_script, + popupFallback: body.popup_fallback, }; }); }; @@ -115,6 +118,7 @@ export const buildHostedButtonCreateOrder = ({ return (data) => { const userInputs = window[`__pp_form_fields_${hostedButtonId}`]?.getUserInputs?.() || {}; + const onError = window[`__pp_form_fields_${hostedButtonId}`]?.onError; return createAccessToken(getClientID()).then((accessToken) => { return request({ url: `${apiUrl}/v1/checkout/links/${hostedButtonId}/create-context`, @@ -126,16 +130,37 @@ export const buildHostedButtonCreateOrder = ({ merchant_id: merchantId, ...userInputs, }), - }).then(({ body }) => { - return body.context_id; - }); + }) + .then(({ body }) => body.context_id || onError(body.name)) + .catch(() => onError("REQUEST_FAILED")); }); }; }; +export const buildOpenPopup: BuildOpenPopup = ({ popupFallback, selector }) => { + return (url) => { + try { + popup(url, { + width: DEFAULT_POPUP_SIZE.WIDTH, + height: DEFAULT_POPUP_SIZE.HEIGHT, + }); + } catch (e) { + const div = document.createElement("div"); + div.classList.add(popupFallbackClassName); + div.innerHTML = popupFallback.replace("#", url); + const container = + typeof selector === "string" + ? document.querySelector(selector) + : selector; + container?.appendChild(div); + } + }; +}; + export const buildHostedButtonOnApprove = ({ hostedButtonId, merchantId, + openPopup, }: GetCallbackProps): OnApprove => { return (data) => { return createAccessToken(getClientID()).then((accessToken) => { @@ -149,19 +174,14 @@ export const buildHostedButtonOnApprove = ({ context_id: data.orderID, }), }).then((response) => { + // remove the popup fallback message, if present + document.querySelector(`.${popupFallbackClassName}`)?.remove(); // The "Debit or Credit Card" button does not open a popup // so we need to open a new popup for buyers who complete // a checkout via "Debit or Credit Card". if (data.paymentSource === FUNDING.CARD) { const url = `${baseUrl}/ncp/payment/${hostedButtonId}/${data.orderID}`; - if (supportsPopups()) { - popup(url, { - width: DEFAULT_POPUP_SIZE.WIDTH, - height: DEFAULT_POPUP_SIZE.HEIGHT, - }); - } else { - window.location = url; - } + openPopup?.(url); } return response; }); diff --git a/src/hosted-buttons/utils.test.js b/src/hosted-buttons/utils.test.js index 79bff3c42c..381129aa8b 100644 --- a/src/hosted-buttons/utils.test.js +++ b/src/hosted-buttons/utils.test.js @@ -1,13 +1,15 @@ /* @flow */ import { test, expect, vi } from "vitest"; -import { request, popup, supportsPopups } from "@krakenjs/belter/src"; +import { request, popup } from "@krakenjs/belter/src"; import { ZalgoPromise } from "@krakenjs/zalgo-promise/src"; import { buildHostedButtonCreateOrder, buildHostedButtonOnApprove, + buildOpenPopup, getHostedButtonDetails, + popupFallbackClassName, } from "./utils"; vi.mock("@krakenjs/belter/src", async () => { @@ -101,6 +103,31 @@ test("buildHostedButtonCreateOrder", async () => { expect.assertions(1); }); +test("buildHostedButtonCreateOrder error handling", async () => { + const createOrder = buildHostedButtonCreateOrder({ + hostedButtonId, + merchantId, + }); + + // $FlowIssue + request.mockImplementation(() => + ZalgoPromise.resolve({ + body: { + name: "RESOURCE_NOT_FOUND", + }, + }) + ); + + const onError = vi.fn(); + window[`__pp_form_fields_${hostedButtonId}`] = { + onError, + }; + + await createOrder({ paymentSource: "paypal" }); + expect(onError).toHaveBeenCalledWith("RESOURCE_NOT_FOUND"); + expect.assertions(1); +}); + describe("buildHostedButtonOnApprove", () => { test("makes a request to the Hosted Buttons API", async () => { const onApprove = buildHostedButtonOnApprove({ @@ -127,10 +154,20 @@ describe("buildHostedButtonOnApprove", () => { expect.assertions(1); }); - test("provides its own popup for inline guest", async () => { + describe("inline guest", () => { + const url = "https://example.com/ncp/payment/B1234567890/EC-1234567890"; + const selector = "buttons-container"; + // $FlowIssue + document.body.innerHTML = `
`; // eslint-disable-line compat/compat + const popupFallback = `See payment details`; + const openPopup = buildOpenPopup({ + popupFallback, + selector: `#${selector}`, + }); const onApprove = buildHostedButtonOnApprove({ hostedButtonId, merchantId, + openPopup, }); // $FlowIssue request.mockImplementation(() => @@ -139,19 +176,55 @@ describe("buildHostedButtonOnApprove", () => { }) ); - // $FlowIssue - supportsPopups.mockImplementation(() => true); - await onApprove({ orderID, paymentSource: "card" }); - expect(popup).toHaveBeenCalled(); + test("provides its own popup", async () => { + await onApprove({ orderID, paymentSource: "card" }); + expect(popup).toHaveBeenCalledWith(url, expect.anything()); + expect.assertions(1); + }); - // but redirects if popups are not supported - // $FlowIssue - supportsPopups.mockImplementation(() => false); - await onApprove({ orderID, paymentSource: "card" }); - expect(window.location).toMatch( - `/ncp/payment/${hostedButtonId}/${orderID}` - ); + test("appends a link if the popup is blocked", async () => { + // $FlowIssue + popup.mockImplementationOnce(() => { + throw new Error("popup_blocked"); + }); + await onApprove({ orderID, paymentSource: "card" }); + const link = document.querySelector(`.${popupFallbackClassName} a`); + expect(link?.getAttribute("href")).toBe(url); + expect.assertions(1); + }); - expect.assertions(2); + test("does not append a second link if the popup is blocked a second time", async () => { + // still present from the previous popup open failure + expect( + document.querySelectorAll(`.${popupFallbackClassName}`).length + ).toBe(1); + // $FlowIssue + popup.mockImplementationOnce(() => { + throw new Error("popup_blocked"); + }); + await onApprove({ orderID, paymentSource: "card" }); + expect( + document.querySelectorAll(`.${popupFallbackClassName}`).length + ).toBe(1); + expect.assertions(2); + }); + + test("removes the fallback message if a different payment source is used", async () => { + // still present from the previous popup open failure + expect( + document.querySelectorAll(`.${popupFallbackClassName}`).length + ).toBe(1); + // $FlowIssue + popup.mockImplementationOnce(() => { + throw new Error("popup_blocked"); + }); + + // note new payment source + await onApprove({ orderID, paymentSource: "paypal" }); + expect( + document.querySelectorAll(`.${popupFallbackClassName}`).length + ).toBe(0); + expect.assertions(2); + }); }); }); From 08aa5348d68ab78c74a8963ff786fa9d599902e9 Mon Sep 17 00:00:00 2001 From: Jesse Shawl Date: Wed, 31 Jan 2024 13:46:08 -0600 Subject: [PATCH 2/2] remove inline guest custom popup --- src/hosted-buttons/index.js | 6 +-- src/hosted-buttons/types.js | 7 --- src/hosted-buttons/utils.js | 40 +--------------- src/hosted-buttons/utils.test.js | 80 +------------------------------- 4 files changed, 3 insertions(+), 130 deletions(-) diff --git a/src/hosted-buttons/index.js b/src/hosted-buttons/index.js index 80d4894c51..78ce60a9dd 100644 --- a/src/hosted-buttons/index.js +++ b/src/hosted-buttons/index.js @@ -5,7 +5,6 @@ import { getButtonsComponent } from "../zoid/buttons"; import { buildHostedButtonCreateOrder, buildHostedButtonOnApprove, - buildOpenPopup, getHostedButtonDetails, renderForm, getMerchantID, @@ -25,7 +24,7 @@ export const getHostedButtonsComponent = (): HostedButtonsComponent => { const merchantId = getMerchantID(); getHostedButtonDetails({ hostedButtonId }).then( - ({ html, htmlScript, style, popupFallback }) => { + ({ html, htmlScript, style }) => { const { onInit, onClick } = renderForm({ hostedButtonId, html, @@ -33,8 +32,6 @@ export const getHostedButtonsComponent = (): HostedButtonsComponent => { selector, }); - const openPopup = buildOpenPopup({ selector, popupFallback }); - // $FlowFixMe Buttons({ hostedButtonId, @@ -48,7 +45,6 @@ export const getHostedButtonsComponent = (): HostedButtonsComponent => { onApprove: buildHostedButtonOnApprove({ hostedButtonId, merchantId, - openPopup, }), }).render(selector); } diff --git a/src/hosted-buttons/types.js b/src/hosted-buttons/types.js index 3e965779ff..ea5ca473be 100644 --- a/src/hosted-buttons/types.js +++ b/src/hosted-buttons/types.js @@ -9,7 +9,6 @@ export type HostedButtonsComponentProps = {| export type GetCallbackProps = {| hostedButtonId: string, merchantId?: string, - openPopup?: (url: string) => void, |}; export type HostedButtonsInstance = {| @@ -26,7 +25,6 @@ export type HostedButtonDetailsParams = color: string, label: string, |}, - popupFallback: string, |}>; export type ButtonVariables = $ReadOnlyArray<{| @@ -57,8 +55,3 @@ export type RenderForm = ({| onInit: (data: mixed, actions: mixed) => void, onClick: (data: mixed, actions: mixed) => void, |}; - -export type BuildOpenPopup = ({| - popupFallback: string, - selector: string | HTMLElement, -|}) => (url: string) => void; diff --git a/src/hosted-buttons/utils.js b/src/hosted-buttons/utils.js index cfa5de232d..f67dc44c1c 100644 --- a/src/hosted-buttons/utils.js +++ b/src/hosted-buttons/utils.js @@ -1,17 +1,13 @@ /* @flow */ -import { request, memoize, popup } from "@krakenjs/belter/src"; +import { request, memoize } from "@krakenjs/belter/src"; import { getSDKHost, getClientID, getMerchantID as getSDKMerchantID, } from "@paypal/sdk-client/src"; -import { FUNDING } from "@paypal/sdk-constants/src"; - -import { DEFAULT_POPUP_SIZE } from "../zoid/checkout"; import type { - BuildOpenPopup, ButtonVariables, CreateAccessToken, CreateOrder, @@ -24,7 +20,6 @@ import type { const entryPoint = "SDK"; const baseUrl = `https://${getSDKHost()}`; const apiUrl = baseUrl.replace("www", "api"); -export const popupFallbackClassName = "paypal-popup-fallback"; const getHeaders = (accessToken?: string) => ({ ...(accessToken && { Authorization: `Bearer ${accessToken}` }), @@ -77,7 +72,6 @@ export const getHostedButtonDetails: HostedButtonDetailsParams = ({ }, html: body.html, htmlScript: body.html_script, - popupFallback: body.popup_fallback, }; }); }; @@ -137,30 +131,9 @@ export const buildHostedButtonCreateOrder = ({ }; }; -export const buildOpenPopup: BuildOpenPopup = ({ popupFallback, selector }) => { - return (url) => { - try { - popup(url, { - width: DEFAULT_POPUP_SIZE.WIDTH, - height: DEFAULT_POPUP_SIZE.HEIGHT, - }); - } catch (e) { - const div = document.createElement("div"); - div.classList.add(popupFallbackClassName); - div.innerHTML = popupFallback.replace("#", url); - const container = - typeof selector === "string" - ? document.querySelector(selector) - : selector; - container?.appendChild(div); - } - }; -}; - export const buildHostedButtonOnApprove = ({ hostedButtonId, merchantId, - openPopup, }: GetCallbackProps): OnApprove => { return (data) => { return createAccessToken(getClientID()).then((accessToken) => { @@ -173,17 +146,6 @@ export const buildHostedButtonOnApprove = ({ merchant_id: merchantId, context_id: data.orderID, }), - }).then((response) => { - // remove the popup fallback message, if present - document.querySelector(`.${popupFallbackClassName}`)?.remove(); - // The "Debit or Credit Card" button does not open a popup - // so we need to open a new popup for buyers who complete - // a checkout via "Debit or Credit Card". - if (data.paymentSource === FUNDING.CARD) { - const url = `${baseUrl}/ncp/payment/${hostedButtonId}/${data.orderID}`; - openPopup?.(url); - } - return response; }); }); }; diff --git a/src/hosted-buttons/utils.test.js b/src/hosted-buttons/utils.test.js index 381129aa8b..a5d95af059 100644 --- a/src/hosted-buttons/utils.test.js +++ b/src/hosted-buttons/utils.test.js @@ -1,23 +1,19 @@ /* @flow */ import { test, expect, vi } from "vitest"; -import { request, popup } from "@krakenjs/belter/src"; +import { request } from "@krakenjs/belter/src"; import { ZalgoPromise } from "@krakenjs/zalgo-promise/src"; import { buildHostedButtonCreateOrder, buildHostedButtonOnApprove, - buildOpenPopup, getHostedButtonDetails, - popupFallbackClassName, } from "./utils"; vi.mock("@krakenjs/belter/src", async () => { return { ...(await vi.importActual("@krakenjs/belter/src")), request: vi.fn(), - popup: vi.fn(), - supportsPopups: vi.fn(), }; }); @@ -153,78 +149,4 @@ describe("buildHostedButtonOnApprove", () => { ); expect.assertions(1); }); - - describe("inline guest", () => { - const url = "https://example.com/ncp/payment/B1234567890/EC-1234567890"; - const selector = "buttons-container"; - // $FlowIssue - document.body.innerHTML = `
`; // eslint-disable-line compat/compat - const popupFallback = `See payment details`; - const openPopup = buildOpenPopup({ - popupFallback, - selector: `#${selector}`, - }); - const onApprove = buildHostedButtonOnApprove({ - hostedButtonId, - merchantId, - openPopup, - }); - // $FlowIssue - request.mockImplementation(() => - ZalgoPromise.resolve({ - body: {}, - }) - ); - - test("provides its own popup", async () => { - await onApprove({ orderID, paymentSource: "card" }); - expect(popup).toHaveBeenCalledWith(url, expect.anything()); - expect.assertions(1); - }); - - test("appends a link if the popup is blocked", async () => { - // $FlowIssue - popup.mockImplementationOnce(() => { - throw new Error("popup_blocked"); - }); - await onApprove({ orderID, paymentSource: "card" }); - const link = document.querySelector(`.${popupFallbackClassName} a`); - expect(link?.getAttribute("href")).toBe(url); - expect.assertions(1); - }); - - test("does not append a second link if the popup is blocked a second time", async () => { - // still present from the previous popup open failure - expect( - document.querySelectorAll(`.${popupFallbackClassName}`).length - ).toBe(1); - // $FlowIssue - popup.mockImplementationOnce(() => { - throw new Error("popup_blocked"); - }); - await onApprove({ orderID, paymentSource: "card" }); - expect( - document.querySelectorAll(`.${popupFallbackClassName}`).length - ).toBe(1); - expect.assertions(2); - }); - - test("removes the fallback message if a different payment source is used", async () => { - // still present from the previous popup open failure - expect( - document.querySelectorAll(`.${popupFallbackClassName}`).length - ).toBe(1); - // $FlowIssue - popup.mockImplementationOnce(() => { - throw new Error("popup_blocked"); - }); - - // note new payment source - await onApprove({ orderID, paymentSource: "paypal" }); - expect( - document.querySelectorAll(`.${popupFallbackClassName}`).length - ).toBe(0); - expect.assertions(2); - }); - }); });