From 7498a16f3645f025d1c8775916531c28ac6b99da Mon Sep 17 00:00:00 2001 From: Gal Naor Date: Tue, 3 Nov 2026 15:14:50 +0200 Subject: [PATCH 1/2] feat(payments): PayPal checkout created on click, so the buyer clicks once mountCheckout() also accepts { provider: 'paypal', currency, intent, createCheckout }. PayPal's button renders on page load from the payments config, and createCheckout() runs inside the buyer's click, so the app no longer needs its own button that creates the order first. The created checkout must match the button's provider, intent, currency and merchant. CheckoutMount gains setDisabled() to keep the button off until a form is valid. Co-Authored-By: Claude Opus 5.5 --- packages/sdk/src/index.ts | 1 + packages/sdk/src/modules/payments-checkout.ts | 89 +++++++++++++++++-- packages/sdk/src/modules/payments.types.ts | 45 +++++++++- packages/sdk/tests/types/payments.types.ts | 8 ++ packages/sdk/tests/unit/payments.test.ts | 78 ++++++++++++++++ 5 files changed, 209 insertions(+), 12 deletions(-) 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..0b3de8f0 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,8 @@ async function mountPayPal( let destroyed = false; let completing = 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 +301,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) { @@ -311,15 +324,24 @@ async function mountPayPal( button.setAttribute("type", subscription ? "subscribe" : "pay"); const onClick = () => { - if (completing) return; + if (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 +356,32 @@ async function mountPayPal( button.remove(); session.destroy?.(); }, + setDisabled(disabled) { + // A payment being completed keeps the button off whatever the app asks. + if (!completing) 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 +410,7 @@ export async function preloadCheckout( */ export async function mountCheckout( element: CheckoutElement, - checkout: MountableCheckout, + checkout: MountableCheckout | PayPalCheckoutOnClick, options: MountCheckoutOptions, deps: MountCheckoutDeps ): Promise { @@ -372,6 +420,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 +436,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 +448,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..26c5d344 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. 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..cdb6546a 100644 --- a/packages/sdk/tests/unit/payments.test.ts +++ b/packages/sdk/tests/unit/payments.test.ts @@ -315,6 +315,84 @@ 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("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 = { From 9d16230368572c489e2ff94cf9b0759523b34b15 Mon Sep 17 00:00:00 2001 From: Gal Naor Date: Wed, 4 Nov 2026 09:14:00 +0200 Subject: [PATCH 2/2] fix(payments): a PayPal button stays off after a payment, so it can't charge twice Once onApprove completes the checkout, clicks are ignored and setDisabled(false) no longer turns the button back on. With an on-click checkout, re-enabling it would have created and charged a second order. Co-Authored-By: Claude Opus 5.5 --- packages/sdk/src/modules/payments-checkout.ts | 9 ++++++--- packages/sdk/src/modules/payments.types.ts | 2 +- packages/sdk/tests/unit/payments.test.ts | 20 +++++++++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/sdk/src/modules/payments-checkout.ts b/packages/sdk/src/modules/payments-checkout.ts index 0b3de8f0..31f1f3c0 100644 --- a/packages/sdk/src/modules/payments-checkout.ts +++ b/packages/sdk/src/modules/payments-checkout.ts @@ -279,6 +279,8 @@ 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. @@ -312,6 +314,7 @@ async function mountPayPal( return; } completing = false; + paid = true; notify(() => options.onComplete?.(result)); }, onCancel() { @@ -324,7 +327,7 @@ async function mountPayPal( button.setAttribute("type", subscription ? "subscribe" : "pay"); const onClick = () => { - if (completing || starting || button.disabled) 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() @@ -357,8 +360,8 @@ async function mountPayPal( session.destroy?.(); }, setDisabled(disabled) { - // A payment being completed keeps the button off whatever the app asks. - if (!completing) button.disabled = disabled; + // A payment being completed, or already made, keeps the button off whatever the app asks. + if (!completing && !paid) button.disabled = disabled; }, }; } diff --git a/packages/sdk/src/modules/payments.types.ts b/packages/sdk/src/modules/payments.types.ts index 26c5d344..06e5de79 100644 --- a/packages/sdk/src/modules/payments.types.ts +++ b/packages/sdk/src/modules/payments.types.ts @@ -441,7 +441,7 @@ export interface PayPalCheckoutOnClick { 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. Stripe ignores it. */ + /** 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; } diff --git a/packages/sdk/tests/unit/payments.test.ts b/packages/sdk/tests/unit/payments.test.ts index cdb6546a..4dbd68ca 100644 --- a/packages/sdk/tests/unit/payments.test.ts +++ b/packages/sdk/tests/unit/payments.test.ts @@ -379,6 +379,26 @@ describe("mountCheckout", () => { ).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);