diff --git a/.changeset/clever-moose-spend.md b/.changeset/clever-moose-spend.md new file mode 100644 index 00000000..5a46cc2a --- /dev/null +++ b/.changeset/clever-moose-spend.md @@ -0,0 +1,5 @@ +--- +"@paypal/react-paypal-js": minor +--- + +Adds Braintree PayPal One Time Payment Session hook. diff --git a/package-lock.json b/package-lock.json index 7aa689c2..dff03e91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12361,7 +12361,8 @@ } ], "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/async-function": { "version": "1.0.0", @@ -13287,6 +13288,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "file-uri-to-path": "1.0.0" } @@ -20021,7 +20023,8 @@ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/filesize": { "version": "10.1.0", @@ -28503,7 +28506,8 @@ "integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/nano-spawn": { "version": "2.0.0", @@ -37018,6 +37022,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=4", "yarn": "*" @@ -37929,6 +37934,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "chokidar": "^2.1.8" } @@ -37940,6 +37946,7 @@ "dev": true, "license": "ISC", "optional": true, + "peer": true, "dependencies": { "micromatch": "^3.1.4", "normalize-path": "^2.1.1" @@ -37952,6 +37959,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "remove-trailing-separator": "^1.0.1" }, @@ -37966,6 +37974,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -37977,6 +37986,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "arr-flatten": "^1.1.0", "array-unique": "^0.3.2", @@ -38000,6 +38010,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "is-extendable": "^0.1.0" }, @@ -38014,6 +38025,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "anymatch": "^2.0.0", "async-each": "^1.0.1", @@ -38038,6 +38050,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "extend-shallow": "^2.0.1", "is-number": "^3.0.0", @@ -38055,6 +38068,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "is-extendable": "^0.1.0" }, @@ -38074,6 +38088,7 @@ "os": [ "darwin" ], + "peer": true, "dependencies": { "bindings": "^1.5.0", "nan": "^2.12.1" @@ -38089,6 +38104,7 @@ "dev": true, "license": "ISC", "optional": true, + "peer": true, "dependencies": { "is-glob": "^3.1.0", "path-dirname": "^1.0.0" @@ -38101,6 +38117,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "is-extglob": "^2.1.0" }, @@ -38115,6 +38132,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "binary-extensions": "^1.0.0" }, @@ -38128,7 +38146,8 @@ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/watchpack-chokidar2/node_modules/is-extendable": { "version": "0.1.1", @@ -38137,6 +38156,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -38148,6 +38168,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "kind-of": "^3.0.2" }, @@ -38162,6 +38183,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "is-buffer": "^1.1.5" }, @@ -38175,7 +38197,8 @@ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/watchpack-chokidar2/node_modules/micromatch": { "version": "3.1.10", @@ -38184,6 +38207,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "arr-diff": "^4.0.0", "array-unique": "^0.3.2", @@ -38210,6 +38234,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -38227,6 +38252,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.1.11", "micromatch": "^3.1.10", @@ -38242,7 +38268,8 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/watchpack-chokidar2/node_modules/string_decoder": { "version": "1.1.1", @@ -38251,6 +38278,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -38262,6 +38290,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "is-number": "^3.0.0", "repeat-string": "^1.6.1" diff --git a/packages/react-paypal-js/src/v6/components/Braintree/BraintreePayPalProvider.test.tsx b/packages/react-paypal-js/src/v6/components/Braintree/BraintreePayPalProvider.test.tsx index 5d67516f..f76fd064 100644 --- a/packages/react-paypal-js/src/v6/components/Braintree/BraintreePayPalProvider.test.tsx +++ b/packages/react-paypal-js/src/v6/components/Braintree/BraintreePayPalProvider.test.tsx @@ -4,7 +4,7 @@ import { act, render, waitFor } from "@testing-library/react"; import { expectCurrentErrorValue } from "../../hooks/useErrorTestUtil"; import { BraintreePayPalProvider } from "./BraintreePayPalProvider"; -import { useBraintreePayPal } from "../../hooks/useBraintreePayPal"; +import { useBraintreePayPal } from "../../hooks/Braintree/useBraintreePayPal"; import { INSTANCE_LOADING_STATE } from "../../types/ProviderEnums"; import type { BraintreeV6Namespace } from "../../types"; diff --git a/packages/react-paypal-js/src/v6/hooks/Braintree/braintreeUtils.ts b/packages/react-paypal-js/src/v6/hooks/Braintree/braintreeUtils.ts new file mode 100644 index 00000000..26b689c2 --- /dev/null +++ b/packages/react-paypal-js/src/v6/hooks/Braintree/braintreeUtils.ts @@ -0,0 +1,34 @@ +/** + * Creates a Braintree payment session with error handling and retry prevention. + * + * @param sessionCreator - Function that creates the payment session + * @param failedInstanceRef - Ref tracking which checkout instance failed + * @param checkoutInstance - Current Braintree checkout instance + * @param setError - Error state setter + * @returns The payment session or null if creation fails + */ +export function createBraintreePaymentSession( + sessionCreator: () => T, + failedInstanceRef: { current: unknown }, + checkoutInstance: unknown, + setError: (error: Error | null) => void, +): T | null { + // Skip retry if this checkout instance already failed + if (failedInstanceRef.current === checkoutInstance) { + return null; + } + + try { + return sessionCreator(); + } catch (err) { + failedInstanceRef.current = checkoutInstance; + + const detailedError = new Error( + "Failed to create Braintree payment session. Ensure the BraintreePayPalProvider is properly initialized with a valid client token and namespace.", + { cause: err }, + ); + + setError(detailedError); + return null; + } +} diff --git a/packages/react-paypal-js/src/v6/hooks/useBraintreePayPal.test.ts b/packages/react-paypal-js/src/v6/hooks/Braintree/useBraintreePayPal.test.ts similarity index 100% rename from packages/react-paypal-js/src/v6/hooks/useBraintreePayPal.test.ts rename to packages/react-paypal-js/src/v6/hooks/Braintree/useBraintreePayPal.test.ts diff --git a/packages/react-paypal-js/src/v6/hooks/useBraintreePayPal.ts b/packages/react-paypal-js/src/v6/hooks/Braintree/useBraintreePayPal.ts similarity index 66% rename from packages/react-paypal-js/src/v6/hooks/useBraintreePayPal.ts rename to packages/react-paypal-js/src/v6/hooks/Braintree/useBraintreePayPal.ts index e326c9df..30ee67ee 100644 --- a/packages/react-paypal-js/src/v6/hooks/useBraintreePayPal.ts +++ b/packages/react-paypal-js/src/v6/hooks/Braintree/useBraintreePayPal.ts @@ -1,9 +1,9 @@ import { useContext } from "react"; -import { BraintreePayPalContext } from "../context/BraintreePayPalContext"; +import { BraintreePayPalContext } from "../../context/BraintreePayPalContext"; -import type { BraintreePayPalState } from "../context/BraintreePayPalContext"; -import type { BraintreePayPalProvider } from "../components/Braintree/BraintreePayPalProvider"; +import type { BraintreePayPalState } from "../../context/BraintreePayPalContext"; +import type { BraintreePayPalProvider } from "../../components/Braintree/BraintreePayPalProvider"; /** * Returns {@link BraintreePayPalState} provided by a parent {@link BraintreePayPalProvider}. diff --git a/packages/react-paypal-js/src/v6/hooks/Braintree/useBraintreePayPalOneTimePaymentSession.test.ts b/packages/react-paypal-js/src/v6/hooks/Braintree/useBraintreePayPalOneTimePaymentSession.test.ts new file mode 100644 index 00000000..d9f3aeb0 --- /dev/null +++ b/packages/react-paypal-js/src/v6/hooks/Braintree/useBraintreePayPalOneTimePaymentSession.test.ts @@ -0,0 +1,563 @@ +import { renderHook, act } from "@testing-library/react-hooks"; + +import { expectCurrentErrorValue } from "../useErrorTestUtil"; +import { useBraintreePayPalOneTimePaymentSession } from "./useBraintreePayPalOneTimePaymentSession"; +import { useBraintreePayPal } from "./useBraintreePayPal"; +import { useProxyProps } from "../../utils"; +import { INSTANCE_LOADING_STATE } from "../../types/ProviderEnums"; + +import type { BraintreePaymentSession } from "../../types/braintree"; +import type { BraintreePayPalState } from "../../context/BraintreePayPalContext"; +import type { UseBraintreePayPalOneTimePaymentSessionProps } from "./useBraintreePayPalOneTimePaymentSession"; + +// Must declare jest.mock at top level for hoisting +jest.mock("./useBraintreePayPal"); + +jest.mock("../../utils", () => ({ + ...jest.requireActual("../../utils"), + useProxyProps: jest.fn(), +})); + +const mockUseBraintreePayPal = useBraintreePayPal as jest.MockedFunction< + typeof useBraintreePayPal +>; + +const mockUseProxyProps = useProxyProps as jest.MockedFunction< + typeof useProxyProps +>; + +const createMockSession = (): BraintreePaymentSession => ({ + start: jest.fn(), +}); + +const createMockCheckoutInstance = (session = createMockSession()) => ({ + createOneTimePaymentSession: jest.fn().mockReturnValue(session), +}); + +const defaultBraintreeState: BraintreePayPalState = { + braintreePayPalCheckoutInstance: null, + loadingStatus: INSTANCE_LOADING_STATE.RESOLVED, + error: null, + isHydrated: true, +}; + +function mockBraintreeContext( + overrides: Partial< + Omit & { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + braintreePayPalCheckoutInstance?: any; + } + > = {}, +): void { + mockUseBraintreePayPal.mockReturnValue({ + ...defaultBraintreeState, + ...overrides, + } as BraintreePayPalState); +} + +function mockBraintreePending(): void { + mockBraintreeContext({ + loadingStatus: INSTANCE_LOADING_STATE.PENDING, + }); +} + +function mockBraintreeRejected(): void { + mockBraintreeContext({ + loadingStatus: INSTANCE_LOADING_STATE.REJECTED, + }); +} + +describe("useBraintreePayPalOneTimePaymentSession", () => { + let mockSession: BraintreePaymentSession; + let mockCheckoutInstance: ReturnType; + + const defaultProps: UseBraintreePayPalOneTimePaymentSessionProps = { + amount: "10.00", + currency: "USD", + onApprove: jest.fn(), + }; + + beforeEach(() => { + mockUseProxyProps.mockImplementation((callbacks) => callbacks); + + mockSession = createMockSession(); + mockCheckoutInstance = createMockCheckoutInstance(mockSession); + + mockBraintreeContext({ + braintreePayPalCheckoutInstance: mockCheckoutInstance, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("initialization", () => { + test("should not create session when no checkout instance is available", () => { + mockBraintreeRejected(); + + const { + result: { + current: { error }, + }, + } = renderHook(() => + useBraintreePayPalOneTimePaymentSession(defaultProps), + ); + + expectCurrentErrorValue(error); + + expect(error).toEqual( + new Error("Braintree checkout instance not available"), + ); + expect( + mockCheckoutInstance.createOneTimePaymentSession, + ).not.toHaveBeenCalled(); + }); + + test.each([ + { + description: "Error object", + thrownError: new Error("Braintree initialization failed"), + }, + { + description: "non-Error string", + thrownError: "String error message", + }, + ])( + "should handle $description thrown by createOneTimePaymentSession", + ({ thrownError }) => { + const mockCheckoutInstanceWithError = { + createOneTimePaymentSession: jest + .fn() + .mockImplementation(() => { + throw thrownError; + }), + }; + + mockBraintreeContext({ + braintreePayPalCheckoutInstance: + mockCheckoutInstanceWithError, + }); + + const { + result: { + current: { error }, + }, + } = renderHook(() => + useBraintreePayPalOneTimePaymentSession(defaultProps), + ); + + expectCurrentErrorValue(error); + + expect(error?.message).toContain( + "Failed to create Braintree payment session", + ); + expect(error?.message).toContain( + "BraintreePayPalProvider is properly initialized", + ); + expect( + (error as Error & { cause: typeof thrownError })?.cause, + ).toBe(thrownError); + }, + ); + + test("should not error if there is no checkout instance but loading is still pending", () => { + mockBraintreePending(); + + const { + result: { + current: { error }, + }, + } = renderHook(() => + useBraintreePayPalOneTimePaymentSession(defaultProps), + ); + + expect(error).toBeNull(); + }); + + test("should clear errors when checkout instance becomes available", () => { + // First render: no instance, REJECTED state + mockBraintreeRejected(); + + const { result, rerender } = renderHook(() => + useBraintreePayPalOneTimePaymentSession(defaultProps), + ); + + expectCurrentErrorValue(result.current.error); + expect(result.current.error).toEqual( + new Error("Braintree checkout instance not available"), + ); + + // Second render: instance becomes available + const newMockSession = createMockSession(); + const newMockCheckoutInstance = + createMockCheckoutInstance(newMockSession); + + mockBraintreeContext({ + braintreePayPalCheckoutInstance: newMockCheckoutInstance, + }); + + rerender(); + + expect(result.current.error).toBeNull(); + }); + + test.each([ + [INSTANCE_LOADING_STATE.PENDING, true], + [INSTANCE_LOADING_STATE.RESOLVED, false], + [INSTANCE_LOADING_STATE.REJECTED, false], + ])( + "should return isPending as %s when loadingStatus is %s", + (loadingStatus, expectedIsPending) => { + mockBraintreeContext({ loadingStatus }); + + const { result } = renderHook(() => + useBraintreePayPalOneTimePaymentSession(defaultProps), + ); + + expect(result.current.isPending).toBe(expectedIsPending); + }, + ); + + test("should create a session with the correct options", () => { + const onApprove = jest.fn(); + const onCancel = jest.fn(); + const onError = jest.fn(); + const onShippingAddressChange = jest.fn(); + const onShippingOptionsChange = jest.fn(); + + const props: UseBraintreePayPalOneTimePaymentSessionProps = { + amount: "25.00", + currency: "EUR", + intent: "authorize", + commit: false, + offerCredit: true, + userAuthenticationEmail: "test@example.com", + displayName: "Test Store", + presentationMode: "popup", + onApprove, + onCancel, + onError, + onShippingAddressChange, + onShippingOptionsChange, + }; + + renderHook(() => useBraintreePayPalOneTimePaymentSession(props)); + + expect( + mockCheckoutInstance.createOneTimePaymentSession, + ).toHaveBeenCalledWith( + expect.objectContaining({ + amount: "25.00", + currency: "EUR", + intent: "authorize", + commit: false, + offerCredit: true, + userAuthenticationEmail: "test@example.com", + displayName: "Test Store", + presentationMode: "popup", + onApprove, + onCancel, + onError, + onShippingAddressChange, + onShippingOptionsChange, + }), + ); + }); + + test("should forward callback invocations to the consumer", () => { + const onApprove = jest.fn(); + const onCancel = jest.fn(); + const onError = jest.fn(); + + const props: UseBraintreePayPalOneTimePaymentSessionProps = { + amount: "10.00", + currency: "USD", + onApprove, + onCancel, + onError, + }; + + renderHook(() => useBraintreePayPalOneTimePaymentSession(props)); + + const createSessionCall = + mockCheckoutInstance.createOneTimePaymentSession.mock + .calls[0][0]; + + const mockApprovalData = { + payerId: "PAYER123", + orderId: "ORDER456", + }; + createSessionCall.onApprove(mockApprovalData); + createSessionCall.onCancel(); + createSessionCall.onError(new Error("test error")); + + expect(onApprove).toHaveBeenCalledWith(mockApprovalData); + expect(onCancel).toHaveBeenCalled(); + expect(onError).toHaveBeenCalledWith(new Error("test error")); + }); + }); + + describe("session lifecycle", () => { + test("should nullify session on unmount", () => { + const { result, unmount } = renderHook(() => + useBraintreePayPalOneTimePaymentSession(defaultProps), + ); + + unmount(); + + // After unmount, handleClick should not call start + act(() => { + result.current.handleClick(); + }); + + expect(mockSession.start).not.toHaveBeenCalled(); + }); + + test("should recreate session when data options change", () => { + const onApprove = jest.fn(); + + const { rerender } = renderHook( + ({ amount }) => + useBraintreePayPalOneTimePaymentSession({ + amount, + currency: "USD", + onApprove, + }), + { initialProps: { amount: "10.00" } }, + ); + + expect( + mockCheckoutInstance.createOneTimePaymentSession, + ).toHaveBeenCalledTimes(1); + + jest.clearAllMocks(); + + rerender({ amount: "20.00" }); + + expect( + mockCheckoutInstance.createOneTimePaymentSession, + ).toHaveBeenCalledTimes(1); + expect( + mockCheckoutInstance.createOneTimePaymentSession, + ).toHaveBeenCalledWith( + expect.objectContaining({ amount: "20.00" }), + ); + }); + + test("should recreate session when checkout instance changes", () => { + const { rerender } = renderHook(() => + useBraintreePayPalOneTimePaymentSession(defaultProps), + ); + + jest.clearAllMocks(); + + const newMockSession = createMockSession(); + const newMockCheckoutInstance = + createMockCheckoutInstance(newMockSession); + + mockBraintreeContext({ + braintreePayPalCheckoutInstance: newMockCheckoutInstance, + }); + + rerender(); + + expect( + newMockCheckoutInstance.createOneTimePaymentSession, + ).toHaveBeenCalled(); + }); + + test("should not recreate session when only callbacks change", () => { + mockUseProxyProps.mockImplementation( + jest.requireActual("../../utils").useProxyProps, + ); + + const initialOnApprove = jest.fn(); + const newOnApprove = jest.fn(); + + const { rerender } = renderHook( + ({ onApprove }) => + useBraintreePayPalOneTimePaymentSession({ + amount: "10.00", + currency: "USD", + onApprove, + }), + { initialProps: { onApprove: initialOnApprove } }, + ); + + jest.clearAllMocks(); + + rerender({ onApprove: newOnApprove }); + + expect( + mockCheckoutInstance.createOneTimePaymentSession, + ).not.toHaveBeenCalled(); + }); + + test("should not recreate session when inline object options have the same values", () => { + mockUseProxyProps.mockImplementation( + jest.requireActual("../../utils").useProxyProps, + ); + + const { rerender } = renderHook( + ({ lineItems }) => + useBraintreePayPalOneTimePaymentSession({ + amount: "10.00", + currency: "USD", + onApprove: jest.fn(), + lineItems, + }), + { + initialProps: { + lineItems: [ + { + quantity: "1", + unitAmount: "10.00", + name: "Item", + kind: "debit" as const, + }, + ], + }, + }, + ); + + jest.clearAllMocks(); + + // Pass a new array reference with the same values + rerender({ + lineItems: [ + { + quantity: "1", + unitAmount: "10.00", + name: "Item", + kind: "debit" as const, + }, + ], + }); + + expect( + mockCheckoutInstance.createOneTimePaymentSession, + ).not.toHaveBeenCalled(); + }); + + test("should not retry session creation on a failed checkout instance", () => { + const mockCheckoutInstanceWithError = { + createOneTimePaymentSession: jest + .fn() + .mockImplementation(() => { + throw new Error("init failure"); + }), + }; + + mockBraintreeContext({ + braintreePayPalCheckoutInstance: mockCheckoutInstanceWithError, + }); + + const { rerender } = renderHook(() => + useBraintreePayPalOneTimePaymentSession(defaultProps), + ); + + expect( + mockCheckoutInstanceWithError.createOneTimePaymentSession, + ).toHaveBeenCalledTimes(1); + + jest.clearAllMocks(); + + // Rerender with same failed instance — should not retry + rerender(); + + expect( + mockCheckoutInstanceWithError.createOneTimePaymentSession, + ).not.toHaveBeenCalled(); + }); + + test("should retry session creation when a new checkout instance replaces a failed one", () => { + mockUseProxyProps.mockImplementation( + jest.requireActual("../../utils").useProxyProps, + ); + + const mockCheckoutInstanceWithError = { + createOneTimePaymentSession: jest + .fn() + .mockImplementation(() => { + throw new Error("init failure"); + }), + }; + + mockBraintreeContext({ + braintreePayPalCheckoutInstance: mockCheckoutInstanceWithError, + }); + + const { rerender } = renderHook(() => + useBraintreePayPalOneTimePaymentSession(defaultProps), + ); + + expect( + mockCheckoutInstanceWithError.createOneTimePaymentSession, + ).toHaveBeenCalledTimes(1); + + // Replace with a working instance + const newMockSession = createMockSession(); + const newMockCheckoutInstance = + createMockCheckoutInstance(newMockSession); + + mockBraintreeContext({ + braintreePayPalCheckoutInstance: newMockCheckoutInstance, + }); + + rerender(); + + expect( + newMockCheckoutInstance.createOneTimePaymentSession, + ).toHaveBeenCalledTimes(1); + }); + }); + + describe("handleClick", () => { + test("should call session.start() when session is available", () => { + const { result } = renderHook(() => + useBraintreePayPalOneTimePaymentSession(defaultProps), + ); + + act(() => { + result.current.handleClick(); + }); + + expect(mockSession.start).toHaveBeenCalled(); + }); + + test("should set error when session is not available", () => { + mockBraintreeRejected(); + + const { result } = renderHook(() => + useBraintreePayPalOneTimePaymentSession(defaultProps), + ); + + act(() => { + result.current.handleClick(); + }); + + const { error } = result.current; + + expectCurrentErrorValue(error); + + expect(error).toEqual( + new Error("Braintree payment session not available"), + ); + }); + + test("should not call start after component is unmounted", () => { + const { result, unmount } = renderHook(() => + useBraintreePayPalOneTimePaymentSession(defaultProps), + ); + + unmount(); + + act(() => { + result.current.handleClick(); + }); + + expect(mockSession.start).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/react-paypal-js/src/v6/hooks/Braintree/useBraintreePayPalOneTimePaymentSession.ts b/packages/react-paypal-js/src/v6/hooks/Braintree/useBraintreePayPalOneTimePaymentSession.ts new file mode 100644 index 00000000..c550aa38 --- /dev/null +++ b/packages/react-paypal-js/src/v6/hooks/Braintree/useBraintreePayPalOneTimePaymentSession.ts @@ -0,0 +1,190 @@ +import { useCallback, useEffect, useRef } from "react"; + +import { useBraintreePayPal } from "./useBraintreePayPal"; +import { createBraintreePaymentSession } from "./braintreeUtils"; +import { useIsMountedRef } from "../useIsMounted"; +import { useError } from "../useError"; +import { useProxyProps, useDeepCompareMemoize } from "../../utils"; +import { INSTANCE_LOADING_STATE } from "../../types/ProviderEnums"; + +import type { + BraintreeOneTimePaymentSessionOptions, + BraintreePaymentSession, +} from "../../types/braintree"; + +export type UseBraintreePayPalOneTimePaymentSessionProps = + BraintreeOneTimePaymentSessionOptions; + +export interface UseBraintreePayPalOneTimePaymentSessionReturn { + error: Error | null; + isPending: boolean; + handleClick: () => void; +} + +/** + * Hook for managing one-time payment sessions with Braintree PayPal. + * + * The hook returns an `isPending` flag that indicates whether the Braintree checkout + * instance is still being initialized. Buttons should wait to render until `isPending` + * is false. + * + * @returns Object with: `error` (any session error), `isPending` (checkout instance loading), `handleClick` (starts session) + * + * @example + * function BraintreePayPalButton() { + * const { braintreePayPalCheckoutInstance } = useBraintreePayPal(); + * const { isPending, error, handleClick } = useBraintreePayPalOneTimePaymentSession({ + * amount: "10.00", + * currency: "USD", + * onApprove: async (data) => { + * const payload = await braintreePayPalCheckoutInstance.tokenizePayment({ + * payerID: data.payerID, + * orderID: data.orderID, + * }); + * // Send payload.nonce to your server + * }, + * }); + * + * if (isPending) return null; + * if (error) return
Error: {error.message}
; + * + * return ; + * } + */ +export function useBraintreePayPalOneTimePaymentSession({ + // Callbacks + onApprove, + onCancel, + onError: onErrorCallback, + onShippingAddressChange, + onShippingOptionsChange, + // Primitive data options + amount, + currency, + intent, + commit, + offerCredit, + userAuthenticationEmail, + returnUrl, + cancelUrl, + displayName, + presentationMode, + // Object/array data options (require deep comparison) + lineItems, + shippingOptions, + amountBreakdown, +}: UseBraintreePayPalOneTimePaymentSessionProps): UseBraintreePayPalOneTimePaymentSessionReturn { + const { braintreePayPalCheckoutInstance, loadingStatus } = + useBraintreePayPal(); + const isMountedRef = useIsMountedRef(); + const sessionRef = useRef(null); + const [error, setError] = useError(); + + // Prevents retrying session creation with a failed checkout instance + const failedInstanceRef = useRef(null); + + const proxyCallbacks = useProxyProps({ + onApprove, + onCancel, + onError: onErrorCallback, + onShippingAddressChange, + onShippingOptionsChange, + }); + + // Deep-memoize only object/array options that consumers may pass inline + const memoizedLineItems = useDeepCompareMemoize(lineItems); + const memoizedShippingOptions = useDeepCompareMemoize(shippingOptions); + const memoizedAmountBreakdown = useDeepCompareMemoize(amountBreakdown); + + const isPending = loadingStatus === INSTANCE_LOADING_STATE.PENDING; + + // Handle checkout instance availability + useEffect(() => { + // Reset failed instance tracking when checkout instance changes + if (failedInstanceRef.current !== braintreePayPalCheckoutInstance) { + failedInstanceRef.current = null; + } + + if (braintreePayPalCheckoutInstance) { + setError(null); + } else if (loadingStatus !== INSTANCE_LOADING_STATE.PENDING) { + setError(new Error("Braintree checkout instance not available")); + } + }, [braintreePayPalCheckoutInstance, setError, loadingStatus]); + + // Create and manage session lifecycle + useEffect(() => { + if (!braintreePayPalCheckoutInstance) { + return; + } + + const newSession = createBraintreePaymentSession( + () => + braintreePayPalCheckoutInstance.createOneTimePaymentSession({ + amount, + currency, + intent, + commit, + offerCredit, + userAuthenticationEmail, + returnUrl, + cancelUrl, + displayName, + presentationMode, + lineItems: memoizedLineItems, + shippingOptions: memoizedShippingOptions, + amountBreakdown: memoizedAmountBreakdown, + ...proxyCallbacks, + }), + failedInstanceRef, + braintreePayPalCheckoutInstance, + setError, + ); + + if (!newSession) { + return; + } + + sessionRef.current = newSession; + + return () => { + sessionRef.current = null; + }; + }, [ + braintreePayPalCheckoutInstance, + amount, + currency, + intent, + commit, + offerCredit, + userAuthenticationEmail, + returnUrl, + cancelUrl, + displayName, + presentationMode, + memoizedLineItems, + memoizedShippingOptions, + memoizedAmountBreakdown, + proxyCallbacks, + setError, + ]); + + const handleClick = useCallback(() => { + if (!isMountedRef.current) { + return; + } + + if (!sessionRef.current) { + setError(new Error("Braintree payment session not available")); + return; + } + + sessionRef.current.start(); + }, [isMountedRef, setError]); + + return { + error, + isPending, + handleClick, + }; +} diff --git a/packages/react-paypal-js/src/v6/index.ts b/packages/react-paypal-js/src/v6/index.ts index 93ec73ee..35183560 100644 --- a/packages/react-paypal-js/src/v6/index.ts +++ b/packages/react-paypal-js/src/v6/index.ts @@ -59,7 +59,12 @@ export { PayPalCardCvvField } from "./components/PayPalCardCvvField"; // Core hooks export { usePayPal } from "./hooks/usePayPal"; -export { useBraintreePayPal } from "./hooks/useBraintreePayPal"; +export { useBraintreePayPal } from "./hooks/Braintree/useBraintreePayPal"; +export { + useBraintreePayPalOneTimePaymentSession, + type UseBraintreePayPalOneTimePaymentSessionProps, + type UseBraintreePayPalOneTimePaymentSessionReturn, +} from "./hooks/Braintree/useBraintreePayPalOneTimePaymentSession"; export * from "./hooks/useEligibleMethods"; export { usePayPalMessages } from "./hooks/usePayPalMessages"; diff --git a/packages/react-paypal-js/src/v6/types/braintree.ts b/packages/react-paypal-js/src/v6/types/braintree.ts index ea67adff..36fbd24e 100644 --- a/packages/react-paypal-js/src/v6/types/braintree.ts +++ b/packages/react-paypal-js/src/v6/types/braintree.ts @@ -62,8 +62,8 @@ export interface BraintreePlanMetadata { // ---- Callback data types ---- export interface BraintreeApprovalData { - payerID?: string; - orderID?: string; + payerId?: string; + orderId?: string; billingToken?: string; } @@ -349,6 +349,7 @@ export interface BraintreeV6Namespace { client: BraintreeClientInstance; }) => Promise; }; + [key: string]: unknown; } export function validateBraintreeNamespace(