diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index f271c82e..088cdc4d 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -154,6 +154,7 @@ export type { CreateCheckoutResult, CompleteCheckoutResult, EmbeddedCheckout, + PayPalCheckoutOnClick, PayPalEmbeddedCheckout, StripeEmbeddedCheckout, MountCheckoutOptions, diff --git a/packages/sdk/src/modules/payments-checkout.ts b/packages/sdk/src/modules/payments-checkout.ts index 236a3e4e..31f1f3c0 100644 --- a/packages/sdk/src/modules/payments-checkout.ts +++ b/packages/sdk/src/modules/payments-checkout.ts @@ -4,6 +4,7 @@ import type { CompleteCheckoutResult, MountableCheckout, MountCheckoutOptions, + PayPalCheckoutOnClick, PayPalEmbeddedCheckout, StripeEmbeddedCheckout, } from "./payments.types.js"; @@ -80,10 +81,16 @@ export type LoadScript = (src: string) => Promise; * @internal */ export interface PaymentsCheckoutConfig { - paypal?: { script_url?: string }; + paypal?: { + script_url?: string; + // Set once the app's PayPal can take payments, so the button mounts before the order exists. + button?: PayPalButtonConfig | null; + }; stripe?: { script_url?: string }; } +type PayPalButtonConfig = Pick; + /** @internal */ export interface MountCheckoutDeps { completeCheckout: (checkoutId: string) => Promise; @@ -211,6 +218,8 @@ function mountStripe( if (activeStripeCheckout === handle) activeStripeCheckout = null; checkout.destroy(); }, + // Stripe's own form has no single button to disable. + setDisabled: () => undefined, }; activeStripeCheckout = handle; return handle; @@ -222,9 +231,10 @@ function mountStripe( async function mountPayPal( element: CheckoutElement, scriptUrl: string, - checkoutId: string, currency: string, embedded: PayPalEmbeddedCheckout, + // A created checkout's id, or what an on-click checkout calls inside the click to create one. + checkout: string | (() => Promise), options: MountCheckoutOptions, deps: MountCheckoutDeps ): Promise { @@ -269,6 +279,10 @@ async function mountPayPal( let destroyed = false; let completing = false; + // Set once a payment goes through: another click would create and charge a second order. + let paid = false; + let starting = false; + let checkoutId = typeof checkout === "string" ? checkout : undefined; // App callbacks run only while mounted, and their own errors never reach PayPal or onError. const notify = (callback: () => void) => { if (destroyed) return; @@ -289,6 +303,7 @@ async function mountPayPal( button.disabled = true; let result: CompleteCheckoutResult; try { + if (!checkoutId) throw new Error("PayPal approved a checkout that wasn't created"); // Runs even after destroy(), so an approved payment is still captured. result = await deps.completeCheckout(checkoutId); } catch (error) { @@ -299,6 +314,7 @@ async function mountPayPal( return; } completing = false; + paid = true; notify(() => options.onComplete?.(result)); }, onCancel() { @@ -311,15 +327,24 @@ async function mountPayPal( button.setAttribute("type", subscription ? "subscribe" : "pay"); const onClick = () => { - if (completing) return; + if (paid || completing || starting || button.disabled) return; + starting = true; // Not awaited before start(), which must run inside the click to open PayPal's popup. - const id = Promise.resolve( - subscription ? { subscriptionId: checkoutId } : { orderId: checkoutId } - ); + const id = Promise.resolve() + .then(typeof checkout === "string" ? () => checkout : checkout) + .then((resolved) => { + checkoutId = resolved; + return subscription ? { subscriptionId: resolved } : { orderId: resolved }; + }); const report = (error: unknown) => notify(() => options.onError?.(error)); try { - Promise.resolve(session.start({ presentationMode: "auto" }, id)).catch(report); + Promise.resolve(session.start({ presentationMode: "auto" }, id)) + .catch(report) + .finally(() => { + starting = false; + }); } catch (error) { + starting = false; report(error); } }; @@ -334,6 +359,32 @@ async function mountPayPal( button.remove(); session.destroy?.(); }, + setDisabled(disabled) { + // A payment being completed, or already made, keeps the button off whatever the app asks. + if (!completing && !paid) button.disabled = disabled; + }, + }; +} + +/** The new checkout's id, refused unless it is the checkout the button was drawn for. */ +function onClickCheckoutId( + spec: PayPalCheckoutOnClick, + button: PayPalButtonConfig +): () => Promise { + return async () => { + const checkout = await spec.createCheckout(); + const embedded = checkout?.embedded; + if ( + embedded?.provider !== "paypal" || + embedded.intent !== spec.intent || + embedded.merchant_id !== button.merchant_id || + checkout.currency?.toLowerCase() !== spec.currency.toLowerCase() + ) { + throw new Error( + "createCheckout() returned a checkout that doesn't match the PayPal button. Check its provider, intent and currency." + ); + } + return checkout.checkout_id; }; } @@ -362,7 +413,7 @@ export async function preloadCheckout( */ export async function mountCheckout( element: CheckoutElement, - checkout: MountableCheckout, + checkout: MountableCheckout | PayPalCheckoutOnClick, options: MountCheckoutOptions, deps: MountCheckoutDeps ): Promise { @@ -372,6 +423,9 @@ export async function mountCheckout( if (!element) { throw new Error("mountCheckout() requires an element to render in"); } + if ("createCheckout" in checkout) { + return mountPayPalOnClick(element, checkout, options, deps); + } const embedded = checkout?.embedded; if (!embedded) { throw new Error( @@ -385,9 +439,9 @@ export async function mountCheckout( return mountPayPal( element, await scriptUrlFor("paypal", deps), - checkout.checkout_id, checkout.currency, embedded, + checkout.checkout_id, options, deps ); @@ -397,3 +451,23 @@ export async function mountCheckout( ); } } + +async function mountPayPalOnClick( + element: CheckoutElement, + spec: PayPalCheckoutOnClick, + options: MountCheckoutOptions, + deps: MountCheckoutDeps +): Promise { + if (spec.provider !== "paypal" || typeof spec.createCheckout !== "function") { + throw new Error("Only PayPal can create its checkout on click"); + } + const scriptUrl = await scriptUrlFor("paypal", deps); + const button = (await deps.getConfig())?.paypal?.button; + if (!button?.client_id || !button.merchant_id) { + throw new Error("PayPal isn't ready to take payments in this app yet"); + } + const embedded: PayPalEmbeddedCheckout = { provider: "paypal", ...button, intent: spec.intent }; + return mountPayPal( + element, scriptUrl, spec.currency, embedded, onClickCheckoutId(spec, button), options, deps + ); +} diff --git a/packages/sdk/src/modules/payments.types.ts b/packages/sdk/src/modules/payments.types.ts index 21296d63..06e5de79 100644 --- a/packages/sdk/src/modules/payments.types.ts +++ b/packages/sdk/src/modules/payments.types.ts @@ -419,12 +419,30 @@ export interface MountableCheckout { embedded: EmbeddedCheckout | null; } +/** + * A PayPal checkout that [`mountCheckout()`](#mountcheckout) creates when the buyer clicks PayPal's button. The button shows as soon as the page loads, the buyer clicks once, and no order is created until they do. + */ +export interface PayPalCheckoutOnClick { + /** Payment provider. Only PayPal can create its checkout on click. */ + provider: "paypal"; + /** Three-letter ISO currency code the checkout uses, such as `'usd'`. */ + currency: string; + /** Whether the checkout is a one-time payment or a subscription. */ + intent: "capture" | "subscription"; + /** + * Creates the checkout when the buyer clicks, usually by calling the backend function that calls {@linkcode ServiceRolePaymentsModule.createCheckout | createCheckout()}. Resolve with its result. Reject to stop, for example when a required form field is empty: PayPal's window closes and `onError` receives the error. + */ + createCheckout: () => Promise; +} + /** * A mounted embedded checkout. */ export interface CheckoutMount { /** Removes the checkout from the page. Call it when the component that holds it unmounts. For Stripe, mounting another checkout destroys this one for you. */ destroy: () => void; + /** PayPal: greys out the button and ignores clicks, for example until a required form is filled in. After a payment completes the button stays off, so `setDisabled(false)` can't start a second payment. Stripe ignores it. */ + setDisabled: (disabled: boolean) => void; } /** @@ -487,12 +505,14 @@ export interface PaymentsModule { * * To make the checkout appear faster, load the library ahead of time with [`preloadCheckout()`](#preloadcheckout) when the page opens. * - * Requires a browser environment and can't be used in the backend. Rejects when the checkout isn't embedded, when the payments config can't be fetched or has no valid script URL for the provider, when the provider's library fails to load, or when PayPal isn't available for the checkout's currency. + * For PayPal, pass a {@linkcode PayPalCheckoutOnClick} instead of a created checkout to show PayPal's button right away and create the checkout only when the buyer clicks it. The buyer clicks once, and page views create no orders. + * + * Requires a browser environment and can't be used in the backend. Rejects when the checkout isn't embedded, when an on-click PayPal checkout is mounted before the app's PayPal can take payments, when the payments config can't be fetched or has no valid script URL for the provider, when the provider's library fails to load, or when PayPal isn't available for the checkout's currency. * * The checkout's callbacks run in the buyer's browser, where they can be faked. Grant access to what the buyer paid for only after your backend confirms the purchase, for example with [`listPurchases()`](#service-role-methods) in a backend function. * * @param element - Element to render the checkout in. It should be empty. - * @param checkout - The result of {@linkcode ServiceRolePaymentsModule.createCheckout | createCheckout()}. + * @param checkout - The result of {@linkcode ServiceRolePaymentsModule.createCheckout | createCheckout()}, or for PayPal a {@linkcode PayPalCheckoutOnClick} that creates it when the buyer clicks. * @param options - Callbacks for the checkout's outcome. * @returns Promise resolving to the mounted checkout once it renders. * @@ -516,6 +536,25 @@ export interface PaymentsModule { * ``` * * @example + * ```typescript + * // PayPal: show the button on page load and create the checkout on click + * const mount = await base44.payments.mountCheckout( + * document.getElementById('paypal')!, + * { + * provider: 'paypal', + * currency: 'usd', + * intent: 'capture', + * createCheckout: async () => + * (await base44.functions.invoke('createCheckout', { productId })).data, + * }, + * { onComplete: ({ checkout_id }) => confirmPurchase(checkout_id) } + * ); + * + * // Grey out the button until the shipping form is valid + * mount.setDisabled(!formIsValid); + * ``` + * + * @example * ```tsx * // React component * function Checkout({ checkout }: { checkout: CreateCheckoutResult }) { @@ -540,7 +579,7 @@ export interface PaymentsModule { */ mountCheckout( element: CheckoutElement, - checkout: MountableCheckout, + checkout: MountableCheckout | PayPalCheckoutOnClick, options?: MountCheckoutOptions ): Promise; diff --git a/packages/sdk/tests/types/payments.types.ts b/packages/sdk/tests/types/payments.types.ts index bc09edfd..3f57ecef 100644 --- a/packages/sdk/tests/types/payments.types.ts +++ b/packages/sdk/tests/types/payments.types.ts @@ -27,6 +27,14 @@ base44.payments.refund; // @ts-expect-error The service role runs in backend code, where there is nothing to mount. base44.asServiceRole.payments.mountCheckout; +// PayPal can create its checkout on click; the mount can grey the button out until a form is valid. +base44.payments + .mountCheckout(div, { provider: "paypal", currency: "usd", intent: "capture", createCheckout: async () => checkout }) + .then((mount) => mount.setDisabled(true)); + +// @ts-expect-error Only PayPal creates its checkout on click: Stripe needs its session to render. +base44.payments.mountCheckout(div, { provider: "stripe", currency: "usd", intent: "capture", createCheckout: async () => checkout }); + base44.payments.preloadCheckout("paypal"); // @ts-expect-error Only connected checkout providers have a script to preload. diff --git a/packages/sdk/tests/unit/payments.test.ts b/packages/sdk/tests/unit/payments.test.ts index a2eec6fc..4dbd68ca 100644 --- a/packages/sdk/tests/unit/payments.test.ts +++ b/packages/sdk/tests/unit/payments.test.ts @@ -315,6 +315,104 @@ describe("mountCheckout", () => { expect(session.destroy).toHaveBeenCalledTimes(1); }); + describe("PayPal checkout created on click", () => { + const onClickConfig = { + ...config, + paypal: { + ...config.paypal, + button: { client_id: "client-1", merchant_id: "MERCHANT1", partner_attribution_id: null }, + }, + }; + const spec = (createCheckout: () => Promise) => ({ + provider: "paypal" as const, + currency: "usd", + intent: "capture" as const, + createCheckout, + }); + + test("shows the button before any order exists and creates it on the click", async () => { + const { paypal, session, callbacks } = installPayPal(); + getConfig.mockResolvedValue(onClickConfig); + const createCheckout = vi.fn().mockResolvedValue({ + checkout_id: "ORDER1", currency: "usd", embedded: paypalEmbedded(), + }); + completeCheckout.mockResolvedValue({ checkout_id: "ORDER1", status: "paid" }); + const onComplete = vi.fn(); + + await mountCheckout(container as any, spec(createCheckout), { onComplete }, deps()); + + expect(paypal.createInstance).toHaveBeenCalledWith( + expect.objectContaining({ clientId: "client-1", merchantId: "MERCHANT1" }) + ); + expect(createCheckout).not.toHaveBeenCalled(); + + container.children[0].click(); + await expect(session.start.mock.calls[0][1]).resolves.toEqual({ orderId: "ORDER1" }); + expect(createCheckout).toHaveBeenCalledTimes(1); + + await callbacks().onApprove({}); + expect(completeCheckout).toHaveBeenCalledWith("ORDER1"); + expect(onComplete).toHaveBeenCalledWith({ checkout_id: "ORDER1", status: "paid" }); + }); + + test("refuses a created checkout that isn't the one the button was drawn for", async () => { + const { session } = installPayPal(); + getConfig.mockResolvedValue(onClickConfig); + const onError = vi.fn(); + const createCheckout = vi.fn().mockResolvedValue({ + checkout_id: "SUB1", currency: "usd", embedded: paypalEmbedded({ intent: "subscription" }), + }); + session.start.mockImplementation((_options: unknown, id: Promise) => id); + + await mountCheckout(container as any, spec(createCheckout), { onError }, deps()); + container.children[0].click(); + + await vi.waitFor(() => expect(onError).toHaveBeenCalled()); + expect(onError.mock.calls[0][0].message).toContain("doesn't match the PayPal button"); + }); + + test("rejects until the app's PayPal can take payments", async () => { + installPayPal(); + + await expect( + mountCheckout(container as any, spec(vi.fn()), {}, deps()) + ).rejects.toThrow("PayPal isn't ready"); + }); + + test("after a payment, re-enabling the button can't create a second order", async () => { + const { session, callbacks } = installPayPal(); + getConfig.mockResolvedValue(onClickConfig); + const createCheckout = vi.fn().mockResolvedValue({ + checkout_id: "ORDER1", currency: "usd", embedded: paypalEmbedded(), + }); + completeCheckout.mockResolvedValue({ checkout_id: "ORDER1", status: "paid" }); + + const mount = await mountCheckout(container as any, spec(createCheckout), {}, deps()); + container.children[0].click(); + await session.start.mock.calls[0][1]; + await callbacks().onApprove({}); + + mount.setDisabled(false); + container.children[0].click(); + + expect(createCheckout).toHaveBeenCalledTimes(1); + expect(session.start).toHaveBeenCalledTimes(1); + }); + + test("a disabled button ignores clicks", async () => { + const { session } = installPayPal(); + getConfig.mockResolvedValue(onClickConfig); + const createCheckout = vi.fn(); + + const mount = await mountCheckout(container as any, spec(createCheckout), {}, deps()); + mount.setDisabled(true); + container.children[0].click(); + + expect(session.start).not.toHaveBeenCalled(); + expect(createCheckout).not.toHaveBeenCalled(); + }); + }); + test("uses the subscriptions component and passes the partner attribution id", async () => { const { paypal, sdk, session } = installPayPal(); const checkout = {