Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ export type {
CreateCheckoutResult,
CompleteCheckoutResult,
EmbeddedCheckout,
PayPalCheckoutOnClick,
PayPalEmbeddedCheckout,
StripeEmbeddedCheckout,
MountCheckoutOptions,
Expand Down
92 changes: 83 additions & 9 deletions packages/sdk/src/modules/payments-checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
CompleteCheckoutResult,
MountableCheckout,
MountCheckoutOptions,
PayPalCheckoutOnClick,
PayPalEmbeddedCheckout,
StripeEmbeddedCheckout,
} from "./payments.types.js";
Expand Down Expand Up @@ -80,10 +81,16 @@ export type LoadScript = (src: string) => Promise<void>;
* @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<PayPalEmbeddedCheckout, "client_id" | "merchant_id" | "partner_attribution_id">;

/** @internal */
export interface MountCheckoutDeps {
completeCheckout: (checkoutId: string) => Promise<CompleteCheckoutResult>;
Expand Down Expand Up @@ -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;
Expand All @@ -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<string>),
options: MountCheckoutOptions,
deps: MountCheckoutDeps
): Promise<CheckoutMount> {
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -299,6 +314,7 @@ async function mountPayPal(
return;
}
completing = false;
paid = true;
notify(() => options.onComplete?.(result));
},
onCancel() {
Expand All @@ -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);
}
};
Expand All @@ -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<string> {
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;
};
}

Expand Down Expand Up @@ -362,7 +413,7 @@ export async function preloadCheckout(
*/
export async function mountCheckout(
element: CheckoutElement,
checkout: MountableCheckout,
checkout: MountableCheckout | PayPalCheckoutOnClick,
options: MountCheckoutOptions,
deps: MountCheckoutDeps
): Promise<CheckoutMount> {
Expand All @@ -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(
Expand All @@ -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
);
Expand All @@ -397,3 +451,23 @@ export async function mountCheckout(
);
}
}

async function mountPayPalOnClick(
element: CheckoutElement,
spec: PayPalCheckoutOnClick,
options: MountCheckoutOptions,
deps: MountCheckoutDeps
): Promise<CheckoutMount> {
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
);
}
45 changes: 42 additions & 3 deletions packages/sdk/src/modules/payments.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MountableCheckout>;
}

/**
* 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;
}

/**
Expand Down Expand Up @@ -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.
*
Expand All @@ -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 }) {
Expand All @@ -540,7 +579,7 @@ export interface PaymentsModule {
*/
mountCheckout(
element: CheckoutElement,
checkout: MountableCheckout,
checkout: MountableCheckout | PayPalCheckoutOnClick,
options?: MountCheckoutOptions
): Promise<CheckoutMount>;

Expand Down
8 changes: 8 additions & 0 deletions packages/sdk/tests/types/payments.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading