From d45d6af109dc962bab410cc9bb5043e4a73f0310 Mon Sep 17 00:00:00 2001 From: Gal Naor Date: Mon, 12 Oct 2026 15:19:39 +0300 Subject: [PATCH 1/3] feat(payments): add payments module with embedded checkout Adds `base44.payments` and `base44.asServiceRole.payments` for the Payments API: checkouts, PayPal checkout completion, purchase lookup, refunds, and subscription cancel, pause and resume. `mountCheckout()` renders an embedded checkout in any framework. It loads PayPal JS SDK v6 or Stripe.js v3 on demand, with no new dependencies. Co-Authored-By: Claude Opus 5.5 --- packages/sdk/README.md | 1 + .../appended-articles.json | 5 + .../types-to-expose.json | 5 + packages/sdk/src/client.ts | 6 + packages/sdk/src/client.types.ts | 8 + packages/sdk/src/index.ts | 32 + packages/sdk/src/modules/payments-checkout.ts | 371 +++++++++ packages/sdk/src/modules/payments.ts | 104 +++ packages/sdk/src/modules/payments.types.ts | 710 ++++++++++++++++++ packages/sdk/tests/types/payments.types.ts | 33 + packages/sdk/tests/unit/payments.test.ts | 651 ++++++++++++++++ 11 files changed, 1926 insertions(+) create mode 100644 packages/sdk/src/modules/payments-checkout.ts create mode 100644 packages/sdk/src/modules/payments.ts create mode 100644 packages/sdk/src/modules/payments.types.ts create mode 100644 packages/sdk/tests/types/payments.types.ts create mode 100644 packages/sdk/tests/unit/payments.test.ts diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 741fe2e2..413c0a48 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -29,6 +29,7 @@ The SDK provides access to Base44's functionality through the following modules: - **[`entities`](https://docs.base44.com/developers/references/sdk/docs/interfaces/entities)**: Work with your app's data entities using CRUD operations. - **[`functions`](https://docs.base44.com/developers/references/sdk/docs/interfaces/functions)**: Execute backend functions. - **[`integrations`](https://docs.base44.com/developers/references/sdk/docs/type-aliases/integrations)**: Access pre-built and third-party integrations. +- **[`payments`](https://docs.base44.com/developers/references/sdk/docs/interfaces/payments)**: Embed checkouts, look up purchases, and handle refunds and subscriptions with the service role. ## Quickstarts diff --git a/packages/sdk/scripts/mintlify-post-processing/appended-articles.json b/packages/sdk/scripts/mintlify-post-processing/appended-articles.json index 3002ae7a..9c2e93f0 100644 --- a/packages/sdk/scripts/mintlify-post-processing/appended-articles.json +++ b/packages/sdk/scripts/mintlify-post-processing/appended-articles.json @@ -30,5 +30,10 @@ "interfaces/ActorClient", "interfaces/ActorRegistry", "interfaces/ActorNameRegistry" + ], + "interfaces/PaymentsModule": [ + "type-aliases/PaymentsErrorCode", + "type-aliases/PurchaseStatus", + "type-aliases/PaymentsRecurringFrequency" ] } diff --git a/packages/sdk/scripts/mintlify-post-processing/types-to-expose.json b/packages/sdk/scripts/mintlify-post-processing/types-to-expose.json index 5ec2810e..dfca5552 100644 --- a/packages/sdk/scripts/mintlify-post-processing/types-to-expose.json +++ b/packages/sdk/scripts/mintlify-post-processing/types-to-expose.json @@ -46,6 +46,11 @@ "FunctionsModule", "ImportResult", "IntegrationsModule", + "PaymentsErrorCode", + "PaymentsModule", + "PaymentsRecurringFrequency", + "PurchaseStatus", + "ServiceRolePaymentsModule", "SortField", "SsoModule", "UpdateManyResult" diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 9e357dfa..ce72e1a5 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -23,6 +23,10 @@ import type { CreateClientOptions, } from "./client.types.js"; import { createAnalyticsModule } from "./modules/analytics.js"; +import { + createPaymentsModule, + createServiceRolePaymentsModule, +} from "./modules/payments.js"; import { createActorsModule, resolveActorsHost, @@ -263,6 +267,7 @@ export function createClient(config: CreateClientConfig): Base44Client { appLogs: createAppLogsModule(axiosClient, appId), app: createAppModule(axiosClient, appId), users: createUsersModule(axiosClient, appId), + payments: createPaymentsModule(axiosClient, appId), analytics: createAnalyticsModule({ axiosClient, serverUrl, @@ -314,6 +319,7 @@ export function createClient(config: CreateClientConfig): Base44Client { headers: aiGatewayHeaders, }), appLogs: createAppLogsModule(serviceRoleAxiosClient, appId), + payments: createServiceRolePaymentsModule(serviceRoleAxiosClient, appId), cleanup: () => { if (socket) { socket.disconnect(); diff --git a/packages/sdk/src/client.types.ts b/packages/sdk/src/client.types.ts index 31fa838a..a0fc02d3 100644 --- a/packages/sdk/src/client.types.ts +++ b/packages/sdk/src/client.types.ts @@ -2,6 +2,10 @@ import type { EntitiesModule } from "./modules/entities.types.js"; import type { IntegrationsModule } from "./modules/integrations.types.js"; import type { AuthModule } from "./modules/auth.types.js"; import type { SsoModule } from "./modules/sso.types.js"; +import type { + PaymentsModule, + ServiceRolePaymentsModule, +} from "./modules/payments.types.js"; import type { ConnectorsModule, UserConnectorsModule, @@ -145,6 +149,8 @@ export interface Base44Client { functions: FunctionsModule; /** {@link IntegrationsModule | Integrations module} for calling pre-built integration endpoints. */ integrations: IntegrationsModule; + /** {@link PaymentsModule | Payments module} for checkouts and purchases. */ + payments: PaymentsModule; /** Cleanup function to disconnect WebSocket connections. Call when you're done with the client. */ cleanup: () => void; @@ -241,6 +247,8 @@ export interface Base44Client { functions: FunctionsModule; /** {@link IntegrationsModule | Integrations module} with elevated permissions. */ integrations: IntegrationsModule; + /** {@link ServiceRolePaymentsModule | Payments module} for creating checkouts, refunds, and subscription changes. */ + payments: ServiceRolePaymentsModule; /** {@link SsoModule | SSO module} for generating SSO tokens. */ sso: SsoModule; /** Cleanup function to disconnect WebSocket connections. */ diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 0ccfe159..e8bad291 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -136,6 +136,38 @@ export type { export type { SsoModule, SsoAccessTokenResponse } from "./modules/sso.types.js"; +export type { + PaymentsModule, + ServiceRolePaymentsModule, + PaymentsErrorCode, + PaymentsRecurringFrequency, + CheckoutBuyer, + CheckoutFreeTrial, + CheckoutItem, + CheckoutItemRecurring, + CheckoutPresentation, + CheckoutElement, + CheckoutMount, + MountableCheckout, + CreateCheckoutParams, + CreateCheckoutResult, + CompleteCheckoutResult, + EmbeddedCheckout, + PayPalEmbeddedCheckout, + StripeEmbeddedCheckout, + MountCheckoutOptions, + ListPurchasesParams, + ServiceRoleListPurchasesParams, + ListPurchasesResult, + Purchase, + PurchaseItem, + PurchaseStatus, + RefundParams, + RefundResult, + CancelSubscriptionOptions, + SubscriptionResult, +} from "./modules/payments.types.js"; + export { Actor, type Conn } from "./actor.js"; export type { diff --git a/packages/sdk/src/modules/payments-checkout.ts b/packages/sdk/src/modules/payments-checkout.ts new file mode 100644 index 00000000..7cab6ab3 --- /dev/null +++ b/packages/sdk/src/modules/payments-checkout.ts @@ -0,0 +1,371 @@ +import type { + CheckoutElement, + CheckoutMount, + CompleteCheckoutResult, + MountableCheckout, + MountCheckoutOptions, + PayPalEmbeddedCheckout, + StripeEmbeddedCheckout, +} from "./payments.types.js"; + +// PayPal JS SDK v6: https://developer.paypal.com/sdk/js/reference +const PAYPAL_SCRIPT_URLS = { + live: "https://www.paypal.com/web-sdk/v6/core", + sandbox: "https://www.sandbox.paypal.com/web-sdk/v6/core", +} as const; + +// Use v3 rather than a dated release, because Base44 creates sessions with ui_mode "embedded", +// which dahlia and later reject. +const STRIPE_SCRIPT_URL = "https://js.stripe.com/v3/"; + +interface PayPalSession { + start( + options: { presentationMode: "auto" }, + checkout: Promise<{ orderId: string } | { subscriptionId: string }> + ): Promise; + destroy?(): void; +} + +interface PayPalSessionCallbacks { + onApprove(data: unknown): Promise; + onCancel(data: unknown): void; + onError(error: unknown): void; +} + +type PayPalSessionFactory = (callbacks: PayPalSessionCallbacks) => PayPalSession; + +interface PayPalSdkInstance { + findEligibleMethods(options: { + currencyCode: string; + paymentFlow?: "RECURRING_PAYMENT"; + }): Promise<{ isEligible(method: string): boolean }>; + createPayPalOneTimePaymentSession?: PayPalSessionFactory; + // The reference page and the published types disagree on this name. + createPayPalSubscriptionPaymentSession?: PayPalSessionFactory; + createPayPalSubscriptionSession?: PayPalSessionFactory; +} + +interface PayPalNamespace { + createInstance?(options: { + clientId: string; + merchantId: string; + partnerAttributionId?: string; + components: string[]; + pageType: "checkout"; + }): Promise; +} + +interface StripeEmbeddedCheckoutInstance { + mount(element: CheckoutElement): void; + destroy(): void; +} + +interface StripeEmbeddedCheckoutOptions { + fetchClientSecret: () => Promise; +} + +interface StripeInstance { + // Renamed from initEmbeddedCheckout in Stripe.js dahlia. + createEmbeddedCheckoutPage?( + options: StripeEmbeddedCheckoutOptions + ): Promise; + initEmbeddedCheckout?( + options: StripeEmbeddedCheckoutOptions + ): Promise; +} + +type StripeConstructor = (publishableKey: string) => StripeInstance; + +/** @internal */ +export type LoadScript = (src: string) => Promise; + +/** @internal */ +export interface MountCheckoutDeps { + completeCheckout: (checkoutId: string) => Promise; + loadScript: LoadScript; +} + +function browserGlobals(): { paypal?: PayPalNamespace; Stripe?: StripeConstructor } { + return window as unknown as { paypal?: PayPalNamespace; Stripe?: StripeConstructor }; +} + +const scriptLoads = new Map>(); + +/** + * Appends a script tag once per URL and resolves when it loads. + * @internal + */ +export const loadScript: LoadScript = (src) => { + let load = scriptLoads.get(src); + if (!load) { + load = new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = src; + script.async = true; + script.onload = () => resolve(); + script.onerror = () => { + // Forget the failure so a later mount can retry. + scriptLoads.delete(src); + script.remove(); + reject(new Error(`Failed to load ${src}`)); + }; + document.head.appendChild(script); + }); + scriptLoads.set(src, load); + } + return load; +}; + +async function loadGlobal( + src: string, + read: () => T | undefined, + load: LoadScript +): Promise { + if (!read()) { + await load(src); + } + const value = read(); + if (!value) { + throw new Error(`${src} loaded but didn't define its global`); + } + return value; +} + +// Stripe asks for a single Stripe instance per key. +const stripeInstances = new Map(); +// Stripe allows one Embedded Checkout at a time, so mounts run in order and replace the last. +let stripeQueue: Promise = Promise.resolve(); +let activeStripeCheckout: CheckoutMount | null = null; + +function mountStripe( + element: CheckoutElement, + embedded: StripeEmbeddedCheckout, + deps: MountCheckoutDeps +): Promise { + const mount = stripeQueue.then(async () => { + const Stripe = await loadGlobal( + STRIPE_SCRIPT_URL, + () => browserGlobals().Stripe, + deps.loadScript + ); + let stripe = stripeInstances.get(embedded.publishable_key); + if (!stripe) { + stripe = Stripe(embedded.publishable_key); + stripeInstances.set(embedded.publishable_key, stripe); + } + + // Try the pre-dahlia name first, since dahlia and later reject the ui_mode Base44 sessions use. + // A newer Stripe.js that the app loaded itself is still tried rather than refused. + const create = stripe.initEmbeddedCheckout ?? stripe.createEmbeddedCheckoutPage; + if (typeof create !== "function") { + throw new Error("Stripe.js doesn't support Embedded Checkout"); + } + activeStripeCheckout?.destroy(); + const checkout = await create.call(stripe, { + fetchClientSecret: () => Promise.resolve(embedded.client_secret), + }); + try { + checkout.mount(element); + } catch (error) { + checkout.destroy(); + throw error; + } + + let destroyed = false; + const handle: CheckoutMount = { + destroy() { + if (destroyed) return; + destroyed = true; + if (activeStripeCheckout === handle) activeStripeCheckout = null; + checkout.destroy(); + }, + }; + activeStripeCheckout = handle; + return handle; + }); + stripeQueue = mount.catch(() => undefined); + return mount; +} + +// The v6 script for one environment defines the same global as the other's. +let loadedPayPalEnvironment: PayPalEmbeddedCheckout["environment"] | undefined; + +async function mountPayPal( + element: CheckoutElement, + checkoutId: string, + currency: string, + embedded: PayPalEmbeddedCheckout, + options: MountCheckoutOptions, + deps: MountCheckoutDeps +): Promise { + const environment = embedded.environment; + const scriptUrl = PAYPAL_SCRIPT_URLS[environment]; + if (!scriptUrl) { + throw new Error( + "This checkout is missing embedded.environment. Pass the full result of createCheckout() to mountCheckout()." + ); + } + if (loadedPayPalEnvironment && loadedPayPalEnvironment !== environment) { + throw new Error( + `PayPal's ${loadedPayPalEnvironment} script is already loaded on this page, so a ${environment} checkout can't mount. Reload the page first.` + ); + } + // The environment of a PayPal script the app loaded itself can't be detected, so it's trusted. + if (!browserGlobals().paypal && !loadedPayPalEnvironment) { + loadedPayPalEnvironment = environment; + } + let paypal: PayPalNamespace; + try { + paypal = await loadGlobal( + scriptUrl, + () => browserGlobals().paypal, + deps.loadScript + ); + } catch (error) { + if (!browserGlobals().paypal) loadedPayPalEnvironment = undefined; + throw error; + } + if (typeof paypal.createInstance !== "function") { + throw new Error( + "Another PayPal JS SDK version is already loaded on this page. Remove it to use mountCheckout()." + ); + } + + const subscription = embedded.intent === "subscription"; + const sdk = await paypal.createInstance({ + clientId: embedded.client_id, + merchantId: embedded.merchant_id, + // Sent only when set, since the backend returns null when no attribution ID is configured. + ...(embedded.partner_attribution_id + ? { partnerAttributionId: embedded.partner_attribution_id } + : {}), + components: [subscription ? "paypal-subscriptions" : "paypal-payments"], + pageType: "checkout", + }); + + const methods = await sdk.findEligibleMethods({ + currencyCode: currency.toUpperCase(), + ...(subscription ? { paymentFlow: "RECURRING_PAYMENT" as const } : {}), + }); + if (!methods.isEligible("paypal")) { + throw new Error("PayPal isn't available for this checkout"); + } + + const createSession = subscription + ? sdk.createPayPalSubscriptionPaymentSession ?? + sdk.createPayPalSubscriptionSession + : sdk.createPayPalOneTimePaymentSession; + if (typeof createSession !== "function") { + throw new Error("The loaded PayPal JS SDK can't create this checkout"); + } + + let destroyed = false; + let completing = false; + // App callbacks run only while mounted, and their own errors never reach PayPal or onError. + const notify = (callback: () => void) => { + if (destroyed) return; + try { + callback(); + } catch (error) { + console.error("[Base44 SDK] A mountCheckout() callback threw", error); + } + }; + const button = document.createElement("paypal-button") as HTMLElement & { + disabled?: boolean; + }; + + const session = createSession.call(sdk, { + // Errors are reported here, not rethrown, so PayPal doesn't report them a second time. + async onApprove() { + completing = true; + button.disabled = true; + let result: CompleteCheckoutResult; + try { + // Runs even after destroy(), so an approved payment is still captured. + result = await deps.completeCheckout(checkoutId); + } catch (error) { + // Re-enabled only on failure, since a paid order can't be approved again. + completing = false; + button.disabled = false; + notify(() => options.onError?.(error)); + return; + } + completing = false; + notify(() => options.onComplete?.(result)); + }, + onCancel() { + notify(() => options.onCancel?.()); + }, + onError(error: unknown) { + notify(() => options.onError?.(error)); + }, + }); + + button.setAttribute("type", subscription ? "subscribe" : "pay"); + const onClick = () => { + if (completing) return; + // 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 report = (error: unknown) => notify(() => options.onError?.(error)); + try { + Promise.resolve(session.start({ presentationMode: "auto" }, id)).catch(report); + } catch (error) { + report(error); + } + }; + button.addEventListener("click", onClick); + (element as unknown as HTMLElement).appendChild(button); + + return { + destroy() { + if (destroyed) return; + destroyed = true; + button.removeEventListener("click", onClick); + button.remove(); + session.destroy?.(); + }, + }; +} + +/** + * Mounts the embedded checkout that createCheckout() returned. + * @internal + */ +export async function mountCheckout( + element: CheckoutElement, + checkout: MountableCheckout, + options: MountCheckoutOptions, + deps: MountCheckoutDeps +): Promise { + if (typeof window === "undefined" || typeof document === "undefined") { + throw new Error("mountCheckout() requires a browser environment"); + } + if (!element) { + throw new Error("mountCheckout() requires an element to render in"); + } + const embedded = checkout?.embedded; + if (!embedded) { + throw new Error( + "This checkout isn't embedded. Create it with presentation: 'embedded', or send the buyer to its redirect_url." + ); + } + switch (embedded.provider) { + case "stripe": + return mountStripe(element, embedded, deps); + case "paypal": + return mountPayPal( + element, + checkout.checkout_id, + checkout.currency, + embedded, + options, + deps + ); + default: + throw new Error( + `Embedded checkout isn't supported for provider ${(embedded as { provider: string }).provider}` + ); + } +} diff --git a/packages/sdk/src/modules/payments.ts b/packages/sdk/src/modules/payments.ts new file mode 100644 index 00000000..83d9dcf2 --- /dev/null +++ b/packages/sdk/src/modules/payments.ts @@ -0,0 +1,104 @@ +import { AxiosInstance } from "axios"; +import { loadScript, mountCheckout } from "./payments-checkout.js"; +import { + CancelSubscriptionOptions, + CompleteCheckoutResult, + CreateCheckoutParams, + CreateCheckoutResult, + ListPurchasesResult, + PaymentsModule, + RefundParams, + RefundResult, + ServiceRolePaymentsModule, + ServiceRoleListPurchasesParams, + SubscriptionResult, +} from "./payments.types.js"; + +function requireId(value: string, name: string): string { + if (!value || typeof value !== "string") { + throw new Error(`${name} is required and must be a string`); + } + return encodeURIComponent(value); +} + +function createPaymentsRequests(axios: AxiosInstance, appId: string) { + const base = `/apps/${appId}/payments`; + return { + async completeCheckout(checkoutId: string): Promise { + const id = requireId(checkoutId, "Checkout ID"); + return axios.post(`${base}/checkout-sessions/${id}/complete`); + }, + + async listPurchases( + params: ServiceRoleListPurchasesParams = {} + ): Promise { + return axios.get(`${base}/purchases`, { params }); + }, + + async createCheckout(params: CreateCheckoutParams): Promise { + return axios.post(`${base}/checkout-sessions`, params); + }, + + async refund(params: RefundParams): Promise { + return axios.post(`${base}/refunds`, params); + }, + + async cancelSubscription( + subscriptionId: string, + options: CancelSubscriptionOptions = {} + ): Promise { + const id = requireId(subscriptionId, "Subscription ID"); + return axios.post(`${base}/subscriptions/${id}/cancel`, options); + }, + + async pauseSubscription(subscriptionId: string): Promise { + const id = requireId(subscriptionId, "Subscription ID"); + return axios.post(`${base}/subscriptions/${id}/pause`); + }, + + async resumeSubscription(subscriptionId: string): Promise { + const id = requireId(subscriptionId, "Subscription ID"); + return axios.post(`${base}/subscriptions/${id}/resume`); + }, + }; +} + +/** + * Creates the payments module for anonymous and user authentication. + * + * @param axios - Axios instance + * @param appId - Application ID + * @returns Payments module for the browser + * @internal + */ +export function createPaymentsModule( + axios: AxiosInstance, + appId: string +): PaymentsModule { + const requests = createPaymentsRequests(axios, appId); + return { + completeCheckout: requests.completeCheckout, + listPurchases: requests.listPurchases, + mountCheckout(element, checkout, options = {}) { + return mountCheckout(element, checkout, options, { + completeCheckout: requests.completeCheckout, + loadScript, + }); + }, + }; +} + +/** + * Creates the payments module for service role authentication. + * + * @param axios - Axios instance (should be service role client) + * @param appId - Application ID + * @returns Payments module for backend functions + * @internal + */ +export function createServiceRolePaymentsModule( + axios: AxiosInstance, + appId: string +): ServiceRolePaymentsModule { + return createPaymentsRequests(axios, appId); +} diff --git a/packages/sdk/src/modules/payments.types.ts b/packages/sdk/src/modules/payments.types.ts new file mode 100644 index 00000000..9c5a9d9d --- /dev/null +++ b/packages/sdk/src/modules/payments.types.ts @@ -0,0 +1,710 @@ +/** + * How often a recurring item bills. + */ +export type PaymentsRecurringFrequency = "day" | "week" | "month" | "year"; + +/** + * Free trial before the first charge of a recurring item. + */ +export interface CheckoutFreeTrial { + /** Unit of the trial length. Stripe supports only `'day'` and `'week'`. */ + frequency: PaymentsRecurringFrequency; + /** Number of `frequency` units the trial lasts, from 1 to 999. */ + interval: number; +} + +/** + * Billing terms that turn a checkout item into a subscription. + */ +export interface CheckoutItemRecurring { + /** Unit of the billing period. */ + frequency: PaymentsRecurringFrequency; + /** Number of `frequency` units between charges, from 1 to 3650. Defaults to `1`. */ + interval?: number; + /** Whether the subscription renews automatically at the end of its billing cycles. Stripe doesn't support `false`. */ + auto_renewal?: boolean; + /** Number of billing cycles before the subscription ends, at least 1. Omit for no limit. Not supported for Stripe. */ + billing_cycles?: number; + /** Free trial before the first charge. Stripe applies one trial to the whole checkout, so every recurring item must request the same trial. */ + free_trial?: CheckoutFreeTrial; + /** Subscription title shown to the buyer, up to 150 characters. */ + title?: string; + /** Subscription description shown to the buyer, up to 500 characters. */ + description?: string; +} + +/** + * A line item in a checkout. + */ +export interface CheckoutItem { + /** Item name shown to the buyer, 1 to 255 characters. */ + name: string; + /** Unit price in the currency's minor units, such as cents. Must be greater than `0`. Providers may also reject a total below their minimum charge. */ + amount: number; + /** Number of units, from 1 to 1,000,000. Defaults to `1`. */ + quantity?: number; + /** Whether the item ships, so the provider collects a shipping address. */ + shippable?: boolean; + /** Billing terms. Set this to sell the item as a subscription. PayPal and Wix Payments allow one recurring item per checkout, and PayPal allows no other items with it. */ + recurring?: CheckoutItemRecurring; +} + +/** + * Details about the buyer, used to prefill the checkout. Every field is optional. + */ +export interface CheckoutBuyer { + /** Buyer's email address. */ + email?: string; + /** Buyer's first name. */ + first_name?: string; + /** Buyer's last name. */ + last_name?: string; + /** Buyer's phone number. */ + phone?: string; +} + +/** + * How the buyer pays. + * + * - `'redirect'`: The buyer leaves the app for the provider's hosted checkout page at `redirect_url`. + * - `'embedded'`: The checkout renders inside the app with [`mountCheckout()`](/developers/references/sdk/docs/interfaces/payments#mountcheckout). + */ +export type CheckoutPresentation = "redirect" | "embedded"; + +/** + * Parameters for [`createCheckout()`](/developers/references/sdk/docs/interfaces/service-role-payments#createcheckout). + */ +export interface CreateCheckoutParams { + /** Items to sell, 1 to 100 entries. */ + items: CheckoutItem[]; + /** Three-letter ISO currency code, such as `'usd'`. Defaults to the payment account's currency. Required for PayPal. */ + currency?: string; + /** App-relative path the buyer returns to after paying, such as `'/thank-you'`. Must start with `/`. */ + success_path: string; + /** App-relative path the buyer returns to after cancelling. Must start with `/`. Not supported for Stripe embedded checkout. */ + cancel_path?: string; + /** Details about the buyer, used to prefill the checkout. */ + buyer?: CheckoutBuyer; + /** Your own key-value pairs, up to 50 keys. Returned in each purchase's `metadata` to service role callers only. Not supported for PayPal. */ + metadata?: Record; + /** Key that makes retries return the same checkout instead of creating another, 1 to 200 characters. Not supported for Wix Payments or PayPal subscriptions. */ + idempotency_key?: string; + /** How the buyer pays. Defaults to `'redirect'`. PayPal apps must pass `'embedded'`, and Wix Payments supports only `'redirect'`. */ + presentation?: CheckoutPresentation; +} + +/** + * Data that Stripe Embedded Checkout needs to render. + */ +export interface StripeEmbeddedCheckout { + /** Payment provider. */ + provider: "stripe"; + /** Stripe publishable key of the app's payment account. */ + publishable_key: string; + /** Client secret of the Stripe Checkout Session. */ + client_secret: string; +} + +/** + * Data that the PayPal button needs to render. + */ +export interface PayPalEmbeddedCheckout { + /** Payment provider. */ + provider: "paypal"; + /** PayPal client ID. */ + client_id: string; + /** PayPal merchant ID of the app's seller account. */ + merchant_id: string; + /** PayPal partner attribution ID, when set. */ + partner_attribution_id?: string | null; + /** Whether the checkout is a one-time payment or a subscription. */ + intent: "capture" | "subscription"; + /** PayPal environment the checkout was created in. */ + environment: "sandbox" | "live"; +} + +/** + * Data that an embedded checkout needs to render, by provider. + */ +export type EmbeddedCheckout = StripeEmbeddedCheckout | PayPalEmbeddedCheckout; + +/** + * Result of [`createCheckout()`](/developers/references/sdk/docs/interfaces/service-role-payments#createcheckout). + * + * Exactly one of `redirect_url` and `embedded` is set, depending on the checkout's `presentation`. + */ +export interface CreateCheckoutResult { + /** ID of the checkout. For Stripe it's the Checkout Session ID, and for PayPal it's the PayPal order or subscription ID. */ + checkout_id: string; + /** Three-letter ISO currency code of the checkout, in lowercase. */ + currency: string; + /** Amount the buyer pays now, in minor units. It's `0` for a subscription that starts with a free trial. */ + amount_total: number; + /** Absolute URL of the provider's hosted checkout page, for redirect checkouts. */ + redirect_url: string | null; + /** Data for [`mountCheckout()`](/developers/references/sdk/docs/interfaces/payments#mountcheckout), for embedded checkouts. */ + embedded: EmbeddedCheckout | null; +} + +/** + * Result of [`completeCheckout()`](/developers/references/sdk/docs/interfaces/payments#completecheckout). + */ +export interface CompleteCheckoutResult { + /** ID of the checkout. */ + checkout_id: string; + /** Whether the payment went through or the provider is still processing it. */ + status: "paid" | "pending"; +} + +/** + * Status of a purchase. + * + * - `'pending'`: The buyer hasn't finished paying, or the provider is still processing the payment. + * - `'paid'`: The payment went through. + * - `'refunded'`: The payment was refunded. + * - `'canceled'`: The checkout or subscription was canceled. + * - `'failed'`: The payment failed. + * - `'unknown'`: The provider reported a state that doesn't map to the others. + */ +export type PurchaseStatus = + | "pending" + | "paid" + | "refunded" + | "canceled" + | "failed" + | "unknown"; + +/** + * A line item of a purchase. + */ +export interface PurchaseItem { + /** Item name. */ + name: string; + /** Unit price in minor units, when the provider reports it. */ + amount?: number | null; + /** Number of units, when the provider reports it. */ + quantity?: number | null; +} + +/** + * A completed or in-progress checkout. + */ +export interface Purchase { + /** ID of the checkout the purchase came from. */ + checkout_id: string; + /** Status of the purchase. */ + status: PurchaseStatus; + /** Line items of the purchase. */ + items: PurchaseItem[]; + /** Total amount in minor units. */ + amount_total?: number | null; + /** Three-letter ISO currency code, in lowercase. */ + currency?: string | null; + /** Buyer's email address. */ + buyer_email?: string | null; + /** The metadata passed to [`createCheckout()`](/developers/references/sdk/docs/interfaces/service-role-payments#createcheckout). Always `null` unless you call with the service role. */ + metadata?: Record | null; + /** ID of the subscription, for recurring purchases. */ + subscription_id?: string | null; + /** ID of the payment to pass to [`refund()`](/developers/references/sdk/docs/interfaces/service-role-payments#refund). It's `null` where the provider doesn't name a single payment, such as PayPal subscriptions. */ + payment_id?: string | null; +} + +/** + * Parameters for [`listPurchases()`](/developers/references/sdk/docs/interfaces/payments#listpurchases). + */ +export interface ListPurchasesParams { + /** Only return the purchase from this checkout. */ + checkout_id?: string; + /** Only return purchases of this subscription. */ + subscription_id?: string; + /** Maximum number of purchases to return, from 1 to 100. Defaults to `20`. */ + limit?: number; + /** The `next_cursor` from the previous page. Send the same filters and `limit` as that call. */ + cursor?: string; +} + +/** + * Parameters for [`listPurchases()`](/developers/references/sdk/docs/interfaces/service-role-payments#listpurchases) with the service role. + */ +export interface ServiceRoleListPurchasesParams extends ListPurchasesParams { + /** Only return purchases made with this email address. */ + buyer_email?: string; +} + +/** + * A page of purchases. + */ +export interface ListPurchasesResult { + /** Purchases on this page. */ + items: Purchase[]; + /** Cursor for the next page, or `null` on the last page. */ + next_cursor: string | null; +} + +/** + * Parameters for [`refund()`](/developers/references/sdk/docs/interfaces/service-role-payments#refund). + */ +export interface RefundParams { + /** The `payment_id` of the purchase to refund. */ + payment_id: string; + /** Amount to refund in minor units. Must be greater than `0`. */ + amount: number; + /** Key that makes retries return the original refund instead of refunding twice, 1 to 100 characters. */ + idempotency_key: string; +} + +/** + * Result of [`refund()`](/developers/references/sdk/docs/interfaces/service-role-payments#refund). + */ +export interface RefundResult { + /** Provider's ID of the refund. */ + refund_id: string; + /** ID of the refunded payment. */ + payment_id: string; + /** Refunded amount in minor units. */ + amount: number; + /** Three-letter ISO currency code, in lowercase. */ + currency: string; + /** Whether the refund went through or the provider is still processing it. */ + status: "succeeded" | "pending"; +} + +/** + * Options for [`cancelSubscription()`](/developers/references/sdk/docs/interfaces/service-role-payments#cancelsubscription). + */ +export interface CancelSubscriptionOptions { + /** Cancel at the end of the current billing period instead of now. Defaults to `false`. Not supported for PayPal. */ + at_period_end?: boolean; +} + +/** + * State of a subscription after a change. + */ +export interface SubscriptionResult { + /** ID of the subscription. */ + subscription_id: string; + /** Current status of the subscription. */ + status: "active" | "paused" | "canceled"; + /** Whether the subscription is set to cancel at the end of the current billing period. */ + cancel_at_period_end: boolean; +} + +/** + * Error codes that payments methods reject with, in the `code` of a `Base44Error`. + * + * Some notable ones: + * + * - `payment_declined`: The buyer's payment method was declined when completing the checkout. The buyer can try again with another method. + * - `refund_failed`: The refund's outcome is unknown. Retry with the same `idempotency_key` and `amount`, which returns the original refund if one was made. + * - `refund_in_progress`: A refund with the same `idempotency_key` is still running. + * - `refund_amount_mismatch`: The `idempotency_key` was already used for a different amount. + * - `rate_limited`: Too many requests. Wait for the number of seconds in the `Retry-After` response header, available at `error.originalError.response.headers['retry-after']`. + * - `refund_not_supported`, `subscription_action_not_supported`, `complete_not_supported`, `presentation_not_supported`: The app's payment provider doesn't support the operation. + */ +export type PaymentsErrorCode = + | "forbidden" + | "unauthenticated" + | "not_found" + | "conflict" + | "rate_limited" + | "payments_not_connected" + | "invalid_request" + | "invalid_items" + | "amount_below_minimum" + | "currency_mismatch" + | "currency_required" + | "invalid_metadata" + | "idempotency_not_supported" + | "idempotency_key_reused" + | "multiple_subscription_items" + | "recurring_option_not_supported" + | "invalid_cursor" + | "presentation_not_supported" + | "embedded_checkout_unavailable" + | "checkout_not_found" + | "checkout_not_approved" + | "checkout_closed" + | "payment_declined" + | "complete_not_supported" + | "provider_unavailable" + | "payment_not_found" + | "refund_rejected" + | "refund_in_progress" + | "refund_amount_mismatch" + | "refund_temporarily_unavailable" + | "refund_failed" + | "refund_not_supported" + | "subscription_not_found" + | "subscription_not_active" + | "subscription_action_not_supported"; + +/** + * Callbacks for [`mountCheckout()`](/developers/references/sdk/docs/interfaces/payments#mountcheckout). + */ +export interface MountCheckoutOptions { + /** + * Called after the buyer approves a PayPal payment and the SDK completes the checkout. + * + * Stripe doesn't call this. After a Stripe payment, the buyer returns to the checkout's `success_path` with a `session_id` query parameter. That parameter is the `checkout_id`. Check the purchase there with [`listPurchases()`](/developers/references/sdk/docs/interfaces/payments#listpurchases). + */ + onComplete?: (result: CompleteCheckoutResult) => void; + /** Called when the buyer closes the PayPal window without paying. The button stays mounted so the buyer can try again. */ + onCancel?: () => void; + /** + * Called when a PayPal checkout fails after it's mounted, including a declined payment with the `payment_declined` code. The button stays mounted so the buyer can try again. If it keeps failing with `payment_declined`, create a new checkout and mount it. Stripe shows its own errors inside the checkout. + * + * The error is a `Base44Error` when completing the checkout fails, or PayPal's own error object when the PayPal window fails. + */ + onError?: (error: unknown) => void; +} + +/** + * An element on the page to render the checkout in, such as the result of `document.getElementById()`. + * + * Typed structurally so that the SDK's types don't require the DOM library in backend code. + */ +export interface CheckoutElement { + /** Present on every DOM element. */ + appendChild(node: never): unknown; +} + +/** + * The fields of a {@linkcode CreateCheckoutResult} that [`mountCheckout()`](/developers/references/sdk/docs/interfaces/payments#mountcheckout) reads. + */ +export interface MountableCheckout { + /** ID of the checkout. */ + checkout_id: string; + /** Three-letter ISO currency code of the checkout. */ + currency: string; + /** Data for the embedded checkout. Mounting rejects when it's `null`. */ + embedded: EmbeddedCheckout | null; +} + +/** + * 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; +} + +/** + * Payments module for selling from the app through its connected payment provider. + * + * This module provides: + * - An embedded checkout that renders in any framework, with a PayPal button or Stripe Embedded Checkout + * - Completing PayPal checkouts + * - Lookup of the signed-in user's purchases + * + * Creating checkouts, refunds, and subscription changes are in {@linkcode ServiceRolePaymentsModule}. + * + * Each app connects one provider, which is Stripe, PayPal, or Wix Payments. Amounts are always integers in the currency's minor units, such as cents. + * + * | | Stripe | PayPal | Wix Payments | + * | :- | :- | :- | :- | + * | Redirect checkout | Yes | No | Yes | + * | Embedded checkout | Yes | Yes, the only mode | No | + * | Refunds | Yes | Yes | No | + * | Cancel a subscription | Yes, now or at period end | Yes, now only | No | + * | Pause and resume a subscription | Yes | Yes | No | + * + * ## Authentication modes + * + * - **Anonymous or user authentication** (`base44.payments`): Completes checkouts, mounts embedded checkouts, and lists the signed-in user's own purchases. + * - **Service role authentication** (`base44.asServiceRole.payments`): Creates checkouts, lists any purchase, refunds payments, and changes subscriptions. Use it in backend functions. + * + * Failed requests reject with a `Base44Error` whose `code` is one of the {@linkcode PaymentsErrorCode} values. Invalid arguments and [`mountCheckout()`](#mountcheckout) failures reject with a plain `Error`. + */ +export interface PaymentsModule { + /** + * Completes a PayPal checkout after the buyer approves it. + * + * When you use {@linkcode mountCheckout | mountCheckout()}, it calls this for you. Call it yourself only if you render the PayPal button yourself. Calling it again for the same checkout returns the same result. + * + * Only PayPal checkouts need completing. Other providers reject with `complete_not_supported`. + * + * @param checkoutId - The `checkout_id` from [`createCheckout()`](/developers/references/sdk/docs/interfaces/service-role-payments#createcheckout). + * @returns Promise resolving to the checkout's payment status. + * + * @example + * ```typescript + * // Complete a PayPal checkout in your own onApprove handler + * const { status } = await base44.payments.completeCheckout(checkoutId); + * if (status === 'paid') { + * showThankYou(); + * } + * ``` + */ + completeCheckout(checkoutId: string): Promise; + + /** + * Lists the signed-in user's purchases. + * + * Only purchases made with the user's own email address are returned. Rejects with `forbidden` when no user is signed in. PayPal apps must pass `checkout_id` or `subscription_id`, and get at most one purchase. + * + * @param params - Optional filters and pagination. + * @returns Promise resolving to a page of purchases. + * + * @example + * ```typescript + * // Check a purchase on the success page + * const { items } = await base44.payments.listPurchases({ checkout_id: checkoutId }); + * const paid = items[0]?.status === 'paid'; + * ``` + * + * @example + * ```typescript + * // Page through the user's purchases + * let cursor: string | undefined; + * do { + * const page = await base44.payments.listPurchases({ limit: 50, cursor }); + * page.items.forEach((purchase) => console.log(purchase.checkout_id, purchase.status)); + * cursor = page.next_cursor ?? undefined; + * } while (cursor); + * ``` + */ + listPurchases(params?: ListPurchasesParams): Promise; + + /** + * Renders an embedded checkout inside an element on the page. + * + * Pass the result of a [`createCheckout()`](/developers/references/sdk/docs/interfaces/service-role-payments#createcheckout) call made with `presentation: 'embedded'`. Because only the service role can create checkouts, create it in a backend function and return the result to the page. + * + * The SDK loads the provider's script and renders the checkout for you: + * - PayPal: Renders a PayPal button. When the buyer approves the payment, the SDK calls {@linkcode completeCheckout | completeCheckout()} and then `onComplete`. + * - Stripe: Mounts Stripe Embedded Checkout. After paying, the buyer returns to the checkout's `success_path`. Stripe allows one embedded checkout per page, so mounting another Stripe checkout destroys the previous one. + * + * Requires a browser environment and can't be used in the backend. Rejects when the checkout isn't embedded, when the provider's script fails to load, or when PayPal isn't available for the checkout's currency. + * + * @param element - Element to render the checkout in. It should be empty. + * @param checkout - The result of [`createCheckout()`](/developers/references/sdk/docs/interfaces/service-role-payments#createcheckout). + * @param options - Callbacks for the checkout's outcome. + * @returns Promise resolving to the mounted checkout once it renders. + * + * @example + * ```typescript + * // Create the checkout in a backend function, then mount it + * const { data: checkout } = await base44.functions.invoke('createCheckout', { planId }); + * + * const mount = await base44.payments.mountCheckout( + * document.getElementById('checkout')!, + * checkout, + * { + * onComplete: ({ status }) => navigate(status === 'paid' ? '/thank-you' : '/processing'), + * onError: () => setError('The payment failed. Please try again.'), + * } + * ); + * + * // Later, when the page unmounts + * mount.destroy(); + * ``` + * + * @example + * ```tsx + * // React component + * function Checkout({ checkout }: { checkout: CreateCheckoutResult }) { + * const ref = useRef(null); + * + * useEffect(() => { + * let mount: CheckoutMount | undefined; + * let cancelled = false; + * base44.payments + * .mountCheckout(ref.current!, checkout, { onComplete: () => navigate('/thank-you') }) + * .then((m) => (cancelled ? m.destroy() : (mount = m))) + * .catch(() => setError('The checkout could not load.')); + * return () => { + * cancelled = true; + * mount?.destroy(); + * }; + * }, [checkout]); + * + * return
; + * } + * ``` + */ + mountCheckout( + element: CheckoutElement, + checkout: MountableCheckout, + options?: MountCheckoutOptions + ): Promise; +} + +/** + * Payments module with service role authentication, for backend functions. + * + * Creates checkouts, lists any purchase, refunds payments, and changes subscriptions. See {@linkcode PaymentsModule} for which providers support which operations. + * + * Refunds take a required `idempotency_key` and checkouts take an optional one, so retrying them doesn't charge or refund twice. Repeating a subscription change returns the subscription's current state. + */ +export interface ServiceRolePaymentsModule { + /** + * Creates a checkout. + * + * For a redirect checkout, send the buyer to the returned `redirect_url`. For an embedded checkout, return the result to the page and pass it to [`mountCheckout()`](/developers/references/sdk/docs/interfaces/payments#mountcheckout). + * + * A checkout with a `recurring` item creates a subscription. + * + * @param params - Items, return paths, and options for the checkout. + * @returns Promise resolving to the checkout. + * + * @example + * ```typescript + * // Create a redirect checkout in a backend function + * import { createClientFromRequest } from 'npm:@base44/sdk'; + * + * Deno.serve(async (req) => { + * const base44 = createClientFromRequest(req); + * const checkout = await base44.asServiceRole.payments.createCheckout({ + * items: [{ name: 'T-shirt', amount: 2500, quantity: 2, shippable: true }], + * currency: 'usd', + * success_path: '/thank-you', + * cancel_path: '/cart', + * }); + * + * return Response.json({ url: checkout.redirect_url }); + * }); + * ``` + * + * @example + * ```typescript + * // Create an embedded subscription checkout for the signed-in user + * import { createClientFromRequest } from 'npm:@base44/sdk'; + * + * Deno.serve(async (req) => { + * const base44 = createClientFromRequest(req); + * const user = await base44.auth.me(); + * const checkout = await base44.asServiceRole.payments.createCheckout({ + * items: [{ + * name: 'Pro plan', + * amount: 1500, + * recurring: { frequency: 'month', free_trial: { frequency: 'day', interval: 14 } }, + * }], + * currency: 'usd', + * success_path: '/welcome', + * buyer: { email: user.email }, + * presentation: 'embedded', + * }); + * + * // Pass this to base44.payments.mountCheckout() on the page + * return Response.json(checkout); + * }); + * ``` + */ + createCheckout(params: CreateCheckoutParams): Promise; + + /** + * Completes a PayPal checkout after the buyer approves it. + * + * See [`base44.payments.completeCheckout()`](/developers/references/sdk/docs/interfaces/payments#completecheckout). + * + * @param checkoutId - The `checkout_id` from {@linkcode createCheckout | createCheckout()}. + * @returns Promise resolving to the checkout's payment status. + * + * @example + * ```typescript + * // Complete a PayPal checkout from a backend function + * const { status } = await base44.asServiceRole.payments.completeCheckout(checkoutId); + * ``` + */ + completeCheckout(checkoutId: string): Promise; + + /** + * Lists the app's purchases. + * + * Unlike [`base44.payments.listPurchases()`](/developers/references/sdk/docs/interfaces/payments#listpurchases), it returns any buyer's purchases and includes their `metadata`. PayPal apps must pass `checkout_id` or `subscription_id`, and get at most one purchase. + * + * @param params - Optional filters and pagination. + * @returns Promise resolving to a page of purchases. + * + * @example + * ```typescript + * // Find a buyer's purchases + * const { items } = await base44.asServiceRole.payments.listPurchases({ + * buyer_email: 'buyer@example.com', + * }); + * ``` + */ + listPurchases( + params?: ServiceRoleListPurchasesParams + ): Promise; + + /** + * Refunds all or part of a payment. + * + * Retrying with the same `idempotency_key` and `amount` returns the original refund instead of refunding twice. When the call rejects with `refund_failed`, the outcome is unknown, so retry it with the same values. + * + * Not supported for Wix Payments. + * + * @param params - The payment, amount, and idempotency key. + * @returns Promise resolving to the refund. + * + * @example + * ```typescript + * // Refund a purchase in full + * const { items } = await base44.asServiceRole.payments.listPurchases({ checkout_id: checkoutId }); + * const purchase = items[0]; + * + * const refund = await base44.asServiceRole.payments.refund({ + * payment_id: purchase.payment_id!, + * amount: purchase.amount_total!, + * idempotency_key: `refund-${checkoutId}`, + * }); + * ``` + */ + refund(params: RefundParams): Promise; + + /** + * Cancels a subscription, now or at the end of the current billing period. + * + * Not supported for Wix Payments. PayPal supports cancelling now only. + * + * @param subscriptionId - The `subscription_id` of the purchase. + * @param options - When to cancel. + * @returns Promise resolving to the subscription's state. + * + * @example + * ```typescript + * // Cancel at the end of the billing period + * await base44.asServiceRole.payments.cancelSubscription(subscriptionId, { + * at_period_end: true, + * }); + * ``` + */ + cancelSubscription( + subscriptionId: string, + options?: CancelSubscriptionOptions + ): Promise; + + /** + * Pauses a subscription's billing. + * + * Not supported for Wix Payments. + * + * @param subscriptionId - The `subscription_id` of the purchase. + * @returns Promise resolving to the subscription's state. + * + * @example + * ```typescript + * // Pause a subscription + * await base44.asServiceRole.payments.pauseSubscription(subscriptionId); + * ``` + */ + pauseSubscription(subscriptionId: string): Promise; + + /** + * Resumes a paused subscription's billing. + * + * Not supported for Wix Payments. + * + * @param subscriptionId - The `subscription_id` of the purchase. + * @returns Promise resolving to the subscription's state. + * + * @example + * ```typescript + * // Resume a paused subscription + * await base44.asServiceRole.payments.resumeSubscription(subscriptionId); + * ``` + */ + resumeSubscription(subscriptionId: string): Promise; +} diff --git a/packages/sdk/tests/types/payments.types.ts b/packages/sdk/tests/types/payments.types.ts new file mode 100644 index 00000000..de00516b --- /dev/null +++ b/packages/sdk/tests/types/payments.types.ts @@ -0,0 +1,33 @@ +import type { + Base44Client, + CheckoutElement, + CreateCheckoutResult, + MountableCheckout, +} from "../../src/index.js"; + +declare const base44: Base44Client; +declare const div: HTMLDivElement; +declare const maybeDiv: HTMLElement | null; +declare const checkout: CreateCheckoutResult; + +// Any DOM element can hold a checkout, and the full createCheckout() result can be mounted. +const element: CheckoutElement = div; +const mountable: MountableCheckout = checkout; +base44.payments.mountCheckout(element, mountable); + +// @ts-expect-error getElementById() can return null, so it needs a check first. +base44.payments.mountCheckout(maybeDiv, checkout); + +// @ts-expect-error Creating checkouts is service role only. +base44.payments.createCheckout; + +// @ts-expect-error Refunds are service role only. +base44.payments.refund; + +// @ts-expect-error The service role runs in backend code, where there is nothing to mount. +base44.asServiceRole.payments.mountCheckout; + +base44.asServiceRole.payments.listPurchases({ buyer_email: "buyer@example.com" }); + +// @ts-expect-error Only the service role can filter by another buyer's email. +base44.payments.listPurchases({ buyer_email: "buyer@example.com" }); diff --git a/packages/sdk/tests/unit/payments.test.ts b/packages/sdk/tests/unit/payments.test.ts new file mode 100644 index 00000000..64a33997 --- /dev/null +++ b/packages/sdk/tests/unit/payments.test.ts @@ -0,0 +1,651 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import nock from "nock"; +import { Base44Error, createClient } from "../../src/index.ts"; +import type { + MountableCheckout, + PayPalEmbeddedCheckout, + StripeEmbeddedCheckout, +} from "../../src/modules/payments.types.ts"; + +describe("Payments module", () => { + const appId = "test-app-id"; + const serverUrl = "https://base44.app"; + const base = `/api/apps/${appId}/payments`; + let base44: ReturnType; + let scope: nock.Scope; + + beforeEach(() => { + base44 = createClient({ + serverUrl, + appId, + token: "user-token", + serviceToken: "service-token", + }); + scope = nock(serverUrl); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + test("createCheckout posts the snake_case body with the service token", async () => { + const params = { + items: [{ name: "T-shirt", amount: 2500, quantity: 2 }], + currency: "usd", + success_path: "/thank-you", + presentation: "redirect" as const, + }; + const response = { + checkout_id: "cs_123", + currency: "usd", + amount_total: 5000, + redirect_url: "https://checkout.stripe.com/c/cs_123", + embedded: null, + }; + scope + .post(`${base}/checkout-sessions`, params) + .matchHeader("authorization", "Bearer service-token") + .reply(200, response); + + const checkout = await base44.asServiceRole.payments.createCheckout(params); + + expect(checkout).toEqual(response); + expect(scope.isDone()).toBe(true); + }); + + test("completeCheckout posts to the encoded checkout id with the user token", async () => { + scope + .post(`${base}/checkout-sessions/ORDER%2F1/complete`) + .matchHeader("authorization", "Bearer user-token") + .reply(200, { checkout_id: "ORDER/1", status: "paid" }); + + const result = await base44.payments.completeCheckout("ORDER/1"); + + expect(result).toEqual({ checkout_id: "ORDER/1", status: "paid" }); + expect(scope.isDone()).toBe(true); + }); + + test("listPurchases sends filters as query parameters and omits unset ones", async () => { + const page = { + items: [{ checkout_id: "cs_1", status: "paid", items: [] }], + next_cursor: "next", + }; + scope + .get(`${base}/purchases`) + .query({ buyer_email: "buyer@example.com", limit: "10" }) + .reply(200, page); + + const result = await base44.asServiceRole.payments.listPurchases({ + buyer_email: "buyer@example.com", + limit: 10, + cursor: undefined, + }); + + expect(result).toEqual(page); + expect(scope.isDone()).toBe(true); + }); + + test("listPurchases works without params", async () => { + scope.get(`${base}/purchases`).reply(200, { items: [], next_cursor: null }); + + const result = await base44.payments.listPurchases(); + + expect(result.next_cursor).toBeNull(); + expect(scope.isDone()).toBe(true); + }); + + test("refund posts the payment, amount and idempotency key", async () => { + const params = { payment_id: "pi_1", amount: 500, idempotency_key: "r-1" }; + scope.post(`${base}/refunds`, params).reply(200, { + refund_id: "re_1", + payment_id: "pi_1", + amount: 500, + currency: "usd", + status: "succeeded", + }); + + const refund = await base44.asServiceRole.payments.refund(params); + + expect(refund.refund_id).toBe("re_1"); + expect(scope.isDone()).toBe(true); + }); + + test("cancelSubscription sends at_period_end, and an empty body by default", async () => { + const state = { + subscription_id: "sub_1", + status: "active", + cancel_at_period_end: true, + }; + scope + .post(`${base}/subscriptions/sub_1/cancel`, { at_period_end: true }) + .reply(200, state) + .post(`${base}/subscriptions/sub_1/cancel`, {}) + .reply(200, { ...state, status: "canceled", cancel_at_period_end: false }); + + await expect( + base44.asServiceRole.payments.cancelSubscription("sub_1", { + at_period_end: true, + }) + ).resolves.toEqual(state); + await expect( + base44.asServiceRole.payments.cancelSubscription("sub_1") + ).resolves.toMatchObject({ status: "canceled" }); + expect(scope.isDone()).toBe(true); + }); + + test("pauseSubscription and resumeSubscription post to their routes", async () => { + scope + .post(`${base}/subscriptions/sub_1/pause`) + .reply(200, { subscription_id: "sub_1", status: "paused", cancel_at_period_end: false }) + .post(`${base}/subscriptions/sub_1/resume`) + .reply(200, { subscription_id: "sub_1", status: "active", cancel_at_period_end: false }); + + await expect( + base44.asServiceRole.payments.pauseSubscription("sub_1") + ).resolves.toMatchObject({ status: "paused" }); + await expect( + base44.asServiceRole.payments.resumeSubscription("sub_1") + ).resolves.toMatchObject({ status: "active" }); + expect(scope.isDone()).toBe(true); + }); + + test("errors reject with the envelope's code and message", async () => { + scope.post(`${base}/refunds`).reply(502, { + error: { code: "refund_failed", message: "Outcome unknown", details: {} }, + }); + + const error = await base44.asServiceRole.payments + .refund({ payment_id: "pi_1", amount: 500, idempotency_key: "r-1" }) + .catch((e) => e); + + expect(error).toBeInstanceOf(Base44Error); + expect(error.status).toBe(502); + expect(error.code).toBe("refund_failed"); + expect(error.message).toBe("Outcome unknown"); + }); + + test("id methods reject an empty id without a request", async () => { + await expect(base44.payments.completeCheckout("")).rejects.toThrow( + "Checkout ID is required" + ); + await expect( + base44.asServiceRole.payments.pauseSubscription("") + ).rejects.toThrow("Subscription ID is required"); + }); + + test("the user module has no service-only methods", () => { + expect("createCheckout" in base44.payments).toBe(false); + expect("refund" in base44.payments).toBe(false); + expect("mountCheckout" in base44.asServiceRole.payments).toBe(false); + }); +}); + +// A minimal DOM, since the unit tests run in node without jsdom. +class FakeElement { + attributes: Record = {}; + children: FakeElement[] = []; + listeners: Record void>> = {}; + parent: FakeElement | null = null; + constructor(public tagName: string) {} + setAttribute(name: string, value: string) { + this.attributes[name] = value; + } + appendChild(child: FakeElement) { + child.parent = this; + this.children.push(child); + return child; + } + addEventListener(type: string, listener: () => void) { + (this.listeners[type] ??= []).push(listener); + } + removeEventListener(type: string, listener: () => void) { + this.listeners[type] = (this.listeners[type] ?? []).filter((l) => l !== listener); + } + remove() { + if (this.parent) { + this.parent.children = this.parent.children.filter((c) => c !== this); + this.parent = null; + } + } + click() { + (this.listeners.click ?? []).forEach((l) => l()); + } +} + +describe("mountCheckout", () => { + // Fresh module state per test, since script loads and Stripe mounts are page-wide. + let mountCheckout: typeof import("../../src/modules/payments-checkout.ts").mountCheckout; + let container: FakeElement; + let loadScript: ReturnType; + let completeCheckout: ReturnType; + + beforeEach(async () => { + vi.resetModules(); + ({ mountCheckout } = await import("../../src/modules/payments-checkout.ts")); + container = new FakeElement("div"); + vi.stubGlobal("document", { + createElement: (tag: string) => new FakeElement(tag), + }); + vi.stubGlobal("window", {}); + loadScript = vi.fn().mockResolvedValue(undefined); + completeCheckout = vi.fn(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const paypalEmbedded = ( + overrides: Partial = {} + ): PayPalEmbeddedCheckout => ({ + provider: "paypal", + client_id: "client-1", + merchant_id: "MERCHANT1", + partner_attribution_id: null, + intent: "capture", + environment: "sandbox", + ...overrides, + }); + + function installPayPal() { + const session = { + start: vi.fn().mockResolvedValue(undefined), + destroy: vi.fn(), + }; + let callbacks: any; + const sdk = { + findEligibleMethods: vi.fn().mockResolvedValue({ isEligible: () => true }), + createPayPalOneTimePaymentSession: vi.fn((cb) => ((callbacks = cb), session)), + createPayPalSubscriptionPaymentSession: vi.fn((cb) => ((callbacks = cb), session)), + }; + const paypal = { createInstance: vi.fn().mockResolvedValue(sdk) }; + loadScript.mockImplementation(async () => { + (window as any).paypal = paypal; + }); + return { paypal, sdk, session, callbacks: () => callbacks }; + } + + const deps = () => ({ completeCheckout, loadScript }); + + const stripeCheckout = (): MountableCheckout => ({ + checkout_id: "cs_1", + currency: "usd", + embedded: { + provider: "stripe", + publishable_key: "pk_test_1", + client_secret: "cs_secret", + } satisfies StripeEmbeddedCheckout, + }); + + test("renders a PayPal button that starts the server-created order", async () => { + const { paypal, sdk, session } = installPayPal(); + const checkout = { checkout_id: "ORDER1", currency: "usd", embedded: paypalEmbedded() }; + + const mount = await mountCheckout(container as any, checkout, {}, deps()); + + expect(loadScript).toHaveBeenCalledWith( + "https://www.sandbox.paypal.com/web-sdk/v6/core" + ); + expect(paypal.createInstance).toHaveBeenCalledWith({ + clientId: "client-1", + merchantId: "MERCHANT1", + components: ["paypal-payments"], + pageType: "checkout", + }); + expect(sdk.findEligibleMethods).toHaveBeenCalledWith({ currencyCode: "USD" }); + + const button = container.children[0]; + expect(button.tagName).toBe("paypal-button"); + expect(button.attributes.type).toBe("pay"); + + button.click(); + expect(session.start).toHaveBeenCalledWith( + { presentationMode: "auto" }, + expect.any(Promise) + ); + await expect(session.start.mock.calls[0][1]).resolves.toEqual({ orderId: "ORDER1" }); + + mount.destroy(); + expect(container.children).toHaveLength(0); + expect(session.destroy).toHaveBeenCalledTimes(1); + }); + + test("uses the subscriptions component and passes the partner attribution id", async () => { + const { paypal, sdk, session } = installPayPal(); + const checkout = { + checkout_id: "I-SUB1", + currency: "usd", + embedded: paypalEmbedded({ + intent: "subscription", + environment: "live", + partner_attribution_id: "BN-CODE", + }), + }; + + await mountCheckout(container as any, checkout, {}, deps()); + + expect(loadScript).toHaveBeenCalledWith("https://www.paypal.com/web-sdk/v6/core"); + expect(paypal.createInstance).toHaveBeenCalledWith( + expect.objectContaining({ + partnerAttributionId: "BN-CODE", + components: ["paypal-subscriptions"], + }) + ); + expect(sdk.findEligibleMethods).toHaveBeenCalledWith({ + currencyCode: "USD", + paymentFlow: "RECURRING_PAYMENT", + }); + expect(sdk.createPayPalSubscriptionPaymentSession).toHaveBeenCalled(); + + container.children[0].click(); + await expect(session.start.mock.calls[0][1]).resolves.toEqual({ + subscriptionId: "I-SUB1", + }); + }); + + test("completes the checkout on approve and reports declines to onError", async () => { + const { callbacks } = installPayPal(); + const onComplete = vi.fn(); + const onError = vi.fn(); + const checkout = { checkout_id: "ORDER1", currency: "usd", embedded: paypalEmbedded() }; + await mountCheckout(container as any, checkout, { onComplete, onError }, deps()); + + completeCheckout.mockResolvedValueOnce({ checkout_id: "ORDER1", status: "paid" }); + await callbacks().onApprove({ orderId: "ORDER1" }); + expect(completeCheckout).toHaveBeenCalledWith("ORDER1"); + expect(onComplete).toHaveBeenCalledWith({ checkout_id: "ORDER1", status: "paid" }); + + const declined = Object.assign(new Error("declined"), { code: "payment_declined" }); + completeCheckout.mockRejectedValueOnce(declined); + await expect(callbacks().onApprove({ orderId: "ORDER1" })).resolves.toBeUndefined(); + expect(onError).toHaveBeenCalledWith(declined); + // The button stays so the buyer can try again. + expect(container.children).toHaveLength(1); + }); + + test("rejects when PayPal isn't eligible", async () => { + const { sdk } = installPayPal(); + sdk.findEligibleMethods.mockResolvedValue({ isEligible: () => false }); + const checkout = { checkout_id: "ORDER1", currency: "usd", embedded: paypalEmbedded() }; + + await expect(mountCheckout(container as any, checkout, {}, deps())).rejects.toThrow( + "PayPal isn't available" + ); + expect(container.children).toHaveLength(0); + }); + + test("rejects a PayPal checkout without an environment", async () => { + const embedded = { ...paypalEmbedded(), environment: undefined } as any; + const checkout = { checkout_id: "ORDER1", currency: "usd", embedded }; + + await expect(mountCheckout(container as any, checkout, {}, deps())).rejects.toThrow( + "missing embedded.environment" + ); + expect(loadScript).not.toHaveBeenCalled(); + }); + + test("rejects when another PayPal SDK version is on the page", async () => { + (window as any).paypal = { Buttons: () => {} }; + const checkout = { checkout_id: "ORDER1", currency: "usd", embedded: paypalEmbedded() }; + + await expect(mountCheckout(container as any, checkout, {}, deps())).rejects.toThrow( + "Another PayPal JS SDK version" + ); + }); + + test("reports cancel and start failures, and ignores clicks while completing", async () => { + const { session, callbacks } = installPayPal(); + const onCancel = vi.fn(); + const onError = vi.fn(); + const checkout = { checkout_id: "ORDER1", currency: "usd", embedded: paypalEmbedded() }; + await mountCheckout(container as any, checkout, { onCancel, onError }, deps()); + const button = container.children[0] as FakeElement & { disabled?: boolean }; + + callbacks().onCancel({}); + expect(onCancel).toHaveBeenCalledTimes(1); + + const popupBlocked = new Error("popup blocked"); + session.start.mockRejectedValueOnce(popupBlocked); + button.click(); + await vi.waitFor(() => expect(onError).toHaveBeenCalledWith(popupBlocked)); + + let finish!: (value: unknown) => void; + completeCheckout.mockReturnValueOnce(new Promise((resolve) => (finish = resolve))); + const approving = callbacks().onApprove({ orderId: "ORDER1" }); + expect(button.disabled).toBe(true); + button.click(); + expect(session.start).toHaveBeenCalledTimes(1); + finish({ checkout_id: "ORDER1", status: "paid" }); + await approving; + // A paid order can't be approved again, so the button stays disabled. + expect(button.disabled).toBe(true); + }); + + test("re-enables the button after a failed completion", async () => { + const { callbacks } = installPayPal(); + const checkout = { checkout_id: "ORDER1", currency: "usd", embedded: paypalEmbedded() }; + await mountCheckout(container as any, checkout, { onError: vi.fn() }, deps()); + const button = container.children[0] as FakeElement & { disabled?: boolean }; + + completeCheckout.mockRejectedValueOnce(new Error("declined")); + await callbacks().onApprove({ orderId: "ORDER1" }); + + expect(button.disabled).toBe(false); + }); + + test("an onComplete that throws isn't reported as a failed payment", async () => { + const { callbacks } = installPayPal(); + const onError = vi.fn(); + const onComplete = vi.fn(() => { + throw new Error("navigation failed"); + }); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const checkout = { checkout_id: "ORDER1", currency: "usd", embedded: paypalEmbedded() }; + await mountCheckout(container as any, checkout, { onComplete, onError }, deps()); + completeCheckout.mockResolvedValueOnce({ checkout_id: "ORDER1", status: "paid" }); + + await expect(callbacks().onApprove({ orderId: "ORDER1" })).resolves.toBeUndefined(); + + expect(onComplete).toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + consoleError.mockRestore(); + }); + + test("still completes an approved payment after destroy, without calling back", async () => { + const { callbacks } = installPayPal(); + const onComplete = vi.fn(); + const checkout = { checkout_id: "ORDER1", currency: "usd", embedded: paypalEmbedded() }; + const mount = await mountCheckout(container as any, checkout, { onComplete }, deps()); + completeCheckout.mockResolvedValueOnce({ checkout_id: "ORDER1", status: "paid" }); + + mount.destroy(); + await callbacks().onApprove({ orderId: "ORDER1" }); + + expect(completeCheckout).toHaveBeenCalledWith("ORDER1"); + expect(onComplete).not.toHaveBeenCalled(); + }); + + test("rejects a PayPal checkout from the other environment than the loaded script", async () => { + installPayPal(); + await mountCheckout( + container as any, + { checkout_id: "ORDER1", currency: "usd", embedded: paypalEmbedded() }, + {}, + deps() + ); + + await expect( + mountCheckout( + container as any, + { checkout_id: "ORDER2", currency: "usd", embedded: paypalEmbedded({ environment: "live" }) }, + {}, + deps() + ) + ).rejects.toThrow("sandbox script is already loaded"); + }); + + test("falls back to createPayPalSubscriptionSession", async () => { + const { sdk } = installPayPal(); + delete (sdk as any).createPayPalSubscriptionPaymentSession; + (sdk as any).createPayPalSubscriptionSession = vi.fn(() => ({ start: vi.fn() })); + const checkout = { + checkout_id: "I-SUB1", + currency: "usd", + embedded: paypalEmbedded({ intent: "subscription" }), + }; + + await mountCheckout(container as any, checkout, {}, deps()); + + expect((sdk as any).createPayPalSubscriptionSession).toHaveBeenCalled(); + }); + + test("mounts Stripe Embedded Checkout with the client secret", async () => { + const embeddedCheckout = { mount: vi.fn(), destroy: vi.fn() }; + const stripe = { initEmbeddedCheckout: vi.fn().mockResolvedValue(embeddedCheckout) }; + const Stripe = vi.fn(() => stripe); + loadScript.mockImplementation(async () => { + (window as any).Stripe = Stripe; + }); + const mount = await mountCheckout(container as any, stripeCheckout(), {}, deps()); + + expect(loadScript).toHaveBeenCalledWith("https://js.stripe.com/v3/"); + expect(Stripe).toHaveBeenCalledWith("pk_test_1"); + const options = stripe.initEmbeddedCheckout.mock.calls[0][0]; + await expect(options.fetchClientSecret()).resolves.toBe("cs_secret"); + expect(embeddedCheckout.mount).toHaveBeenCalledWith(container); + + mount.destroy(); + mount.destroy(); + expect(embeddedCheckout.destroy).toHaveBeenCalledTimes(1); + }); + + test("falls back to Stripe's renamed createEmbeddedCheckoutPage", async () => { + const embeddedCheckout = { mount: vi.fn(), destroy: vi.fn() }; + const stripe = { createEmbeddedCheckoutPage: vi.fn().mockResolvedValue(embeddedCheckout) }; + (window as any).Stripe = vi.fn(() => stripe); + + await mountCheckout(container as any, stripeCheckout(), {}, deps()); + + // Already on the page, so the script isn't loaded again. + expect(loadScript).not.toHaveBeenCalled(); + expect(stripe.createEmbeddedCheckoutPage).toHaveBeenCalled(); + }); + + test("destroys the previous Stripe checkout before mounting another", async () => { + const first = { mount: vi.fn(), destroy: vi.fn() }; + const second = { mount: vi.fn(), destroy: vi.fn() }; + const stripe = { + initEmbeddedCheckout: vi.fn().mockResolvedValueOnce(first).mockResolvedValueOnce(second), + }; + const Stripe = vi.fn(() => stripe); + (window as any).Stripe = Stripe; + + // Started together, as React strict mode does. + const [a, b] = await Promise.all([ + mountCheckout(container as any, stripeCheckout(), {}, deps()), + mountCheckout(container as any, stripeCheckout(), {}, deps()), + ]); + + expect(first.destroy).toHaveBeenCalledTimes(1); + // Stripe needs the previous instance gone before it creates the next one. + expect(first.destroy.mock.invocationCallOrder[0]).toBeLessThan( + stripe.initEmbeddedCheckout.mock.invocationCallOrder[1] + ); + expect(second.mount).toHaveBeenCalledWith(container); + expect(Stripe).toHaveBeenCalledTimes(1); + a.destroy(); + expect(first.destroy).toHaveBeenCalledTimes(1); + b.destroy(); + expect(second.destroy).toHaveBeenCalledTimes(1); + }); + + test("mounts the next Stripe checkout after one fails to create", async () => { + const embeddedCheckout = { mount: vi.fn(), destroy: vi.fn() }; + const stripe = { + initEmbeddedCheckout: vi + .fn() + .mockRejectedValueOnce(new Error("expired session")) + .mockResolvedValueOnce(embeddedCheckout), + }; + (window as any).Stripe = vi.fn(() => stripe); + + await expect(mountCheckout(container as any, stripeCheckout(), {}, deps())).rejects.toThrow( + "expired session" + ); + await mountCheckout(container as any, stripeCheckout(), {}, deps()); + + expect(embeddedCheckout.mount).toHaveBeenCalledWith(container); + }); + + test("rejects a Stripe.js without Embedded Checkout", async () => { + (window as any).Stripe = vi.fn(() => ({})); + + await expect(mountCheckout(container as any, stripeCheckout(), {}, deps())).rejects.toThrow( + "doesn't support Embedded Checkout" + ); + }); + + test("destroys the Stripe checkout when mounting it fails", async () => { + const embeddedCheckout = { + mount: vi.fn(() => { + throw new Error("bad element"); + }), + destroy: vi.fn(), + }; + (window as any).Stripe = vi.fn(() => ({ + initEmbeddedCheckout: vi.fn().mockResolvedValue(embeddedCheckout), + })); + + await expect(mountCheckout(container as any, stripeCheckout(), {}, deps())).rejects.toThrow( + "bad element" + ); + expect(embeddedCheckout.destroy).toHaveBeenCalledTimes(1); + }); + + test("rejects a redirect checkout", async () => { + const checkout = { checkout_id: "cs_1", currency: "usd", embedded: null }; + + await expect(mountCheckout(container as any, checkout, {}, deps())).rejects.toThrow( + "isn't embedded" + ); + }); + + test("rejects outside a browser", async () => { + vi.stubGlobal("window", undefined); + const checkout = { checkout_id: "ORDER1", currency: "usd", embedded: paypalEmbedded() }; + + await expect(mountCheckout(container as any, checkout, {}, deps())).rejects.toThrow( + "requires a browser" + ); + }); +}); + +describe("loadScript", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("adds one async script per URL and retries after a failure", async () => { + vi.resetModules(); + const { loadScript } = await import("../../src/modules/payments-checkout.ts"); + const scripts: any[] = []; + vi.stubGlobal("document", { + createElement: () => ({ remove: vi.fn() }), + head: { appendChild: (script: any) => scripts.push(script) }, + }); + + const first = loadScript("https://example.com/a.js"); + expect(loadScript("https://example.com/a.js")).toBe(first); + expect(scripts).toHaveLength(1); + expect(scripts[0]).toMatchObject({ src: "https://example.com/a.js", async: true }); + + scripts[0].onerror(); + await expect(first).rejects.toThrow("Failed to load https://example.com/a.js"); + expect(scripts[0].remove).toHaveBeenCalled(); + + const retry = loadScript("https://example.com/a.js"); + expect(scripts).toHaveLength(2); + scripts[1].onload(); + await expect(retry).resolves.toBeUndefined(); + }); +}); From 3877b46bb5f91c30e78609f20cb2fce937bd0c16 Mon Sep 17 00:00:00 2001 From: Gal Naor Date: Tue, 13 Oct 2026 14:52:38 +0300 Subject: [PATCH 2/3] feat(payments): add past_due subscription status Co-Authored-By: Claude Opus 5.5 --- packages/sdk/src/modules/payments.types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/src/modules/payments.types.ts b/packages/sdk/src/modules/payments.types.ts index 9c5a9d9d..5a9d6949 100644 --- a/packages/sdk/src/modules/payments.types.ts +++ b/packages/sdk/src/modules/payments.types.ts @@ -285,7 +285,7 @@ export interface SubscriptionResult { /** ID of the subscription. */ subscription_id: string; /** Current status of the subscription. */ - status: "active" | "paused" | "canceled"; + status: "active" | "past_due" | "paused" | "canceled"; /** Whether the subscription is set to cancel at the end of the current billing period. */ cancel_at_period_end: boolean; } From 9583286b677270fccc04ef6e90b915392e8de49b Mon Sep 17 00:00:00 2001 From: Gal Naor Date: Wed, 14 Oct 2026 14:24:35 +0300 Subject: [PATCH 3/3] feat(payments): load checkout scripts from the payments config The SDK no longer hard-codes PayPal or Stripe script URLs or PayPal environments. mountCheckout() fetches GET /payments/config once per client and loads the URL it names, after checking it is https on the provider's own host. Co-Authored-By: Claude Opus 5.5 --- packages/sdk/src/modules/payments-checkout.ts | 91 +++++---- packages/sdk/src/modules/payments.ts | 18 +- packages/sdk/src/modules/payments.types.ts | 10 +- packages/sdk/tests/unit/payments.test.ts | 190 +++++++++++++++--- 4 files changed, 230 insertions(+), 79 deletions(-) diff --git a/packages/sdk/src/modules/payments-checkout.ts b/packages/sdk/src/modules/payments-checkout.ts index 7cab6ab3..a9b1df43 100644 --- a/packages/sdk/src/modules/payments-checkout.ts +++ b/packages/sdk/src/modules/payments-checkout.ts @@ -8,15 +8,11 @@ import type { StripeEmbeddedCheckout, } from "./payments.types.js"; -// PayPal JS SDK v6: https://developer.paypal.com/sdk/js/reference -const PAYPAL_SCRIPT_URLS = { - live: "https://www.paypal.com/web-sdk/v6/core", - sandbox: "https://www.sandbox.paypal.com/web-sdk/v6/core", -} as const; - -// Use v3 rather than a dated release, because Base44 creates sessions with ui_mode "embedded", -// which dahlia and later reject. -const STRIPE_SCRIPT_URL = "https://js.stripe.com/v3/"; +// The backend picks each script URL, but the script runs on the buyer's page, so only these hosts may load. +const ALLOWED_SCRIPT_HOSTS: Record<"paypal" | "stripe", readonly string[]> = { + paypal: ["www.paypal.com", "www.sandbox.paypal.com"], + stripe: ["js.stripe.com"], +}; interface PayPalSession { start( @@ -79,12 +75,47 @@ type StripeConstructor = (publishableKey: string) => StripeInstance; /** @internal */ export type LoadScript = (src: string) => Promise; +/** + * The response of `GET /payments/config`. + * @internal + */ +export interface PaymentsCheckoutConfig { + paypal?: { script_url?: string }; + stripe?: { script_url?: string }; +} + /** @internal */ export interface MountCheckoutDeps { completeCheckout: (checkoutId: string) => Promise; + getConfig: () => Promise; loadScript: LoadScript; } +async function scriptUrlFor( + provider: "paypal" | "stripe", + deps: MountCheckoutDeps +): Promise { + const value = (await deps.getConfig())?.[provider]?.script_url; + let url: URL | undefined; + try { + url = value ? new URL(value) : undefined; + } catch { + url = undefined; + } + if ( + !url || + url.protocol !== "https:" || + url.port !== "" || + url.username !== "" || + url.password !== "" || + !ALLOWED_SCRIPT_HOSTS[provider].includes(url.hostname) + ) { + const name = provider === "paypal" ? "PayPal" : "Stripe"; + throw new Error(`The payments config has no valid ${name} script URL`); + } + return url.href; +} + function browserGlobals(): { paypal?: PayPalNamespace; Stripe?: StripeConstructor } { return window as unknown as { paypal?: PayPalNamespace; Stripe?: StripeConstructor }; } @@ -139,12 +170,13 @@ let activeStripeCheckout: CheckoutMount | null = null; function mountStripe( element: CheckoutElement, + scriptUrl: string, embedded: StripeEmbeddedCheckout, deps: MountCheckoutDeps ): Promise { const mount = stripeQueue.then(async () => { const Stripe = await loadGlobal( - STRIPE_SCRIPT_URL, + scriptUrl, () => browserGlobals().Stripe, deps.loadScript ); @@ -187,44 +219,20 @@ function mountStripe( return mount; } -// The v6 script for one environment defines the same global as the other's. -let loadedPayPalEnvironment: PayPalEmbeddedCheckout["environment"] | undefined; - async function mountPayPal( element: CheckoutElement, + scriptUrl: string, checkoutId: string, currency: string, embedded: PayPalEmbeddedCheckout, options: MountCheckoutOptions, deps: MountCheckoutDeps ): Promise { - const environment = embedded.environment; - const scriptUrl = PAYPAL_SCRIPT_URLS[environment]; - if (!scriptUrl) { - throw new Error( - "This checkout is missing embedded.environment. Pass the full result of createCheckout() to mountCheckout()." - ); - } - if (loadedPayPalEnvironment && loadedPayPalEnvironment !== environment) { - throw new Error( - `PayPal's ${loadedPayPalEnvironment} script is already loaded on this page, so a ${environment} checkout can't mount. Reload the page first.` - ); - } - // The environment of a PayPal script the app loaded itself can't be detected, so it's trusted. - if (!browserGlobals().paypal && !loadedPayPalEnvironment) { - loadedPayPalEnvironment = environment; - } - let paypal: PayPalNamespace; - try { - paypal = await loadGlobal( - scriptUrl, - () => browserGlobals().paypal, - deps.loadScript - ); - } catch (error) { - if (!browserGlobals().paypal) loadedPayPalEnvironment = undefined; - throw error; - } + const paypal = await loadGlobal( + scriptUrl, + () => browserGlobals().paypal, + deps.loadScript + ); if (typeof paypal.createInstance !== "function") { throw new Error( "Another PayPal JS SDK version is already loaded on this page. Remove it to use mountCheckout()." @@ -353,10 +361,11 @@ export async function mountCheckout( } switch (embedded.provider) { case "stripe": - return mountStripe(element, embedded, deps); + return mountStripe(element, await scriptUrlFor("stripe", deps), embedded, deps); case "paypal": return mountPayPal( element, + await scriptUrlFor("paypal", deps), checkout.checkout_id, checkout.currency, embedded, diff --git a/packages/sdk/src/modules/payments.ts b/packages/sdk/src/modules/payments.ts index 83d9dcf2..4355218e 100644 --- a/packages/sdk/src/modules/payments.ts +++ b/packages/sdk/src/modules/payments.ts @@ -1,5 +1,9 @@ import { AxiosInstance } from "axios"; -import { loadScript, mountCheckout } from "./payments-checkout.js"; +import { + loadScript, + mountCheckout, + PaymentsCheckoutConfig, +} from "./payments-checkout.js"; import { CancelSubscriptionOptions, CompleteCheckoutResult, @@ -76,12 +80,24 @@ export function createPaymentsModule( appId: string ): PaymentsModule { const requests = createPaymentsRequests(axios, appId); + // One fetch per client, retried on the next mount if it fails. + let config: Promise | undefined; + const getConfig = () => { + config ??= ( + axios.get(`/apps/${appId}/payments/config`) as Promise + ).catch((error) => { + config = undefined; + throw error; + }); + return config; + }; return { completeCheckout: requests.completeCheckout, listPurchases: requests.listPurchases, mountCheckout(element, checkout, options = {}) { return mountCheckout(element, checkout, options, { completeCheckout: requests.completeCheckout, + getConfig, loadScript, }); }, diff --git a/packages/sdk/src/modules/payments.types.ts b/packages/sdk/src/modules/payments.types.ts index 5a9d6949..3a4537e7 100644 --- a/packages/sdk/src/modules/payments.types.ts +++ b/packages/sdk/src/modules/payments.types.ts @@ -119,8 +119,6 @@ export interface PayPalEmbeddedCheckout { partner_attribution_id?: string | null; /** Whether the checkout is a one-time payment or a subscription. */ intent: "capture" | "subscription"; - /** PayPal environment the checkout was created in. */ - environment: "sandbox" | "live"; } /** @@ -346,7 +344,9 @@ export interface MountCheckoutOptions { /** * Called after the buyer approves a PayPal payment and the SDK completes the checkout. * - * Stripe doesn't call this. After a Stripe payment, the buyer returns to the checkout's `success_path` with a `session_id` query parameter. That parameter is the `checkout_id`. Check the purchase there with [`listPurchases()`](/developers/references/sdk/docs/interfaces/payments#listpurchases). + * Use it to update the page, not to grant access. Browser callbacks can be faked, so unlock paid content only after your backend confirms the purchase, for example with [`listPurchases()`](/developers/references/sdk/docs/interfaces/service-role-payments#listpurchases) in a backend function. + * + * Stripe doesn't call this. After a Stripe payment, the buyer returns to the checkout's `success_path` with a `session_id` query parameter. That parameter is the `checkout_id`. Show the result there with [`listPurchases()`](/developers/references/sdk/docs/interfaces/payments#listpurchases), and grant access only after your backend confirms it. */ onComplete?: (result: CompleteCheckoutResult) => void; /** Called when the buyer closes the PayPal window without paying. The button stays mounted so the buyer can try again. */ @@ -475,7 +475,9 @@ export interface PaymentsModule { * - PayPal: Renders a PayPal button. When the buyer approves the payment, the SDK calls {@linkcode completeCheckout | completeCheckout()} and then `onComplete`. * - Stripe: Mounts Stripe Embedded Checkout. After paying, the buyer returns to the checkout's `success_path`. Stripe allows one embedded checkout per page, so mounting another Stripe checkout destroys the previous one. * - * Requires a browser environment and can't be used in the backend. Rejects when the checkout isn't embedded, when the provider's script fails to load, or when PayPal isn't available for the checkout's currency. + * 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 script 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()`](/developers/references/sdk/docs/interfaces/service-role-payments#listpurchases) in a backend function. * * @param element - Element to render the checkout in. It should be empty. * @param checkout - The result of [`createCheckout()`](/developers/references/sdk/docs/interfaces/service-role-payments#createcheckout). diff --git a/packages/sdk/tests/unit/payments.test.ts b/packages/sdk/tests/unit/payments.test.ts index 64a33997..97e5d2b2 100644 --- a/packages/sdk/tests/unit/payments.test.ts +++ b/packages/sdk/tests/unit/payments.test.ts @@ -228,6 +228,7 @@ describe("mountCheckout", () => { }); vi.stubGlobal("window", {}); loadScript = vi.fn().mockResolvedValue(undefined); + getConfig = vi.fn().mockResolvedValue(config); completeCheckout = vi.fn(); }); @@ -243,7 +244,6 @@ describe("mountCheckout", () => { merchant_id: "MERCHANT1", partner_attribution_id: null, intent: "capture", - environment: "sandbox", ...overrides, }); @@ -265,7 +265,12 @@ describe("mountCheckout", () => { return { paypal, sdk, session, callbacks: () => callbacks }; } - const deps = () => ({ completeCheckout, loadScript }); + const config = { + paypal: { script_url: "https://www.sandbox.paypal.com/web-sdk/v6/core" }, + stripe: { script_url: "https://js.stripe.com/v3/" }, + }; + let getConfig: ReturnType; + const deps = () => ({ completeCheckout, getConfig, loadScript }); const stripeCheckout = (): MountableCheckout => ({ checkout_id: "cs_1", @@ -317,14 +322,13 @@ describe("mountCheckout", () => { currency: "usd", embedded: paypalEmbedded({ intent: "subscription", - environment: "live", partner_attribution_id: "BN-CODE", }), }; await mountCheckout(container as any, checkout, {}, deps()); - expect(loadScript).toHaveBeenCalledWith("https://www.paypal.com/web-sdk/v6/core"); + expect(loadScript).toHaveBeenCalledWith("https://www.sandbox.paypal.com/web-sdk/v6/core"); expect(paypal.createInstance).toHaveBeenCalledWith( expect.objectContaining({ partnerAttributionId: "BN-CODE", @@ -374,16 +378,6 @@ describe("mountCheckout", () => { expect(container.children).toHaveLength(0); }); - test("rejects a PayPal checkout without an environment", async () => { - const embedded = { ...paypalEmbedded(), environment: undefined } as any; - const checkout = { checkout_id: "ORDER1", currency: "usd", embedded }; - - await expect(mountCheckout(container as any, checkout, {}, deps())).rejects.toThrow( - "missing embedded.environment" - ); - expect(loadScript).not.toHaveBeenCalled(); - }); - test("rejects when another PayPal SDK version is on the page", async () => { (window as any).paypal = { Buttons: () => {} }; const checkout = { checkout_id: "ORDER1", currency: "usd", embedded: paypalEmbedded() }; @@ -465,25 +459,6 @@ describe("mountCheckout", () => { expect(onComplete).not.toHaveBeenCalled(); }); - test("rejects a PayPal checkout from the other environment than the loaded script", async () => { - installPayPal(); - await mountCheckout( - container as any, - { checkout_id: "ORDER1", currency: "usd", embedded: paypalEmbedded() }, - {}, - deps() - ); - - await expect( - mountCheckout( - container as any, - { checkout_id: "ORDER2", currency: "usd", embedded: paypalEmbedded({ environment: "live" }) }, - {}, - deps() - ) - ).rejects.toThrow("sandbox script is already loaded"); - }); - test("falls back to createPayPalSubscriptionSession", async () => { const { sdk } = installPayPal(); delete (sdk as any).createPayPalSubscriptionPaymentSession; @@ -602,12 +577,104 @@ describe("mountCheckout", () => { expect(embeddedCheckout.destroy).toHaveBeenCalledTimes(1); }); + test("loads the live PayPal script when the config names it", async () => { + installPayPal(); + getConfig.mockResolvedValue({ + ...config, + paypal: { script_url: "https://www.paypal.com/web-sdk/v6/core" }, + }); + const checkout = { checkout_id: "ORDER1", currency: "usd", embedded: paypalEmbedded() }; + + await mountCheckout(container as any, checkout, {}, deps()); + + expect(loadScript).toHaveBeenCalledWith("https://www.paypal.com/web-sdk/v6/core"); + }); + + test.each([ + ["another host", "https://evil.example.com/web-sdk/v6/core"], + ["a lookalike host", "https://www.paypal.com.evil.example.com/sdk"], + ["plain http", "http://www.paypal.com/web-sdk/v6/core"], + ["a port", "https://www.paypal.com:8443/web-sdk/v6/core"], + ["credentials", "https://user@www.paypal.com/web-sdk/v6/core"], + ["a relative path", "/web-sdk/v6/core"], + ["javascript:", "javascript:alert(1)//www.paypal.com"], + ["data:", "data:text/javascript,1"], + ["a trailing dot", "https://www.paypal.com./web-sdk/v6/core"], + ["a number", 123], + ["an object", {}], + ])("rejects a PayPal script URL with %s and adds no script", async (_, url) => { + installPayPal(); + getConfig.mockResolvedValue({ ...config, paypal: { script_url: url } }); + const checkout = { checkout_id: "ORDER1", currency: "usd", embedded: paypalEmbedded() }; + + await expect(mountCheckout(container as any, checkout, {}, deps())).rejects.toThrow( + "no valid PayPal script URL" + ); + expect(loadScript).not.toHaveBeenCalled(); + }); + + test("rejects a missing config without loading a script", async () => { + installPayPal(); + getConfig.mockResolvedValue(null); + const checkout = { checkout_id: "ORDER1", currency: "usd", embedded: paypalEmbedded() }; + + await expect(mountCheckout(container as any, checkout, {}, deps())).rejects.toThrow( + "no valid PayPal script URL" + ); + expect(loadScript).not.toHaveBeenCalled(); + }); + + test("loads the normalized URL it checked", async () => { + installPayPal(); + getConfig.mockResolvedValue({ + ...config, + paypal: { script_url: " HTTPS://WWW.PAYPAL.COM/web-sdk/v6/core " }, + }); + const checkout = { checkout_id: "ORDER1", currency: "usd", embedded: paypalEmbedded() }; + + await mountCheckout(container as any, checkout, {}, deps()); + + expect(loadScript).toHaveBeenCalledWith("https://www.paypal.com/web-sdk/v6/core"); + }); + + test("keeps the mounted Stripe checkout when the next one has a bad config", async () => { + const embeddedCheckout = { mount: vi.fn(), destroy: vi.fn() }; + (window as any).Stripe = vi.fn(() => ({ + initEmbeddedCheckout: vi.fn().mockResolvedValue(embeddedCheckout), + })); + await mountCheckout(container as any, stripeCheckout(), {}, deps()); + + getConfig.mockResolvedValueOnce({ ...config, stripe: { script_url: "https://evil.example.com/v3/" } }); + await expect(mountCheckout(container as any, stripeCheckout(), {}, deps())).rejects.toThrow( + "no valid Stripe script URL" + ); + + expect(embeddedCheckout.destroy).not.toHaveBeenCalled(); + }); + + test("rejects a Stripe script URL from PayPal's host, and a missing one", async () => { + getConfig.mockResolvedValueOnce({ + ...config, + stripe: { script_url: "https://www.paypal.com/web-sdk/v6/core" }, + }); + await expect(mountCheckout(container as any, stripeCheckout(), {}, deps())).rejects.toThrow( + "no valid Stripe script URL" + ); + + getConfig.mockResolvedValueOnce({ paypal: config.paypal }); + await expect(mountCheckout(container as any, stripeCheckout(), {}, deps())).rejects.toThrow( + "no valid Stripe script URL" + ); + expect(loadScript).not.toHaveBeenCalled(); + }); + test("rejects a redirect checkout", async () => { const checkout = { checkout_id: "cs_1", currency: "usd", embedded: null }; await expect(mountCheckout(container as any, checkout, {}, deps())).rejects.toThrow( "isn't embedded" ); + expect(getConfig).not.toHaveBeenCalled(); }); test("rejects outside a browser", async () => { @@ -649,3 +716,60 @@ describe("loadScript", () => { await expect(retry).resolves.toBeUndefined(); }); }); + +describe("payments config", () => { + const appId = "test-app-id"; + const serverUrl = "https://base44.app"; + const configPath = `/api/apps/${appId}/payments/config`; + const config = { + paypal: { script_url: "https://www.sandbox.paypal.com/web-sdk/v6/core" }, + stripe: { script_url: "https://js.stripe.com/v3/" }, + }; + const checkout: MountableCheckout = { + checkout_id: "cs_1", + currency: "usd", + embedded: { provider: "stripe", publishable_key: "pk_test_1", client_secret: "s" }, + }; + + afterEach(() => { + nock.cleanAll(); + vi.unstubAllGlobals(); + }); + + async function setup() { + vi.resetModules(); + const { createClient: create } = await import("../../src/index.ts"); + const base44 = create({ serverUrl, appId, token: "user-token", analytics: { enabled: false } }); + // Stripe is already on the page, so no script tag is needed. + const stripe = { + initEmbeddedCheckout: vi.fn(async () => ({ mount: vi.fn(), destroy: vi.fn() })), + }; + vi.stubGlobal("window", { Stripe: vi.fn(() => stripe) }); + vi.stubGlobal("document", {}); + return base44; + } + + test("is fetched once and reused for later mounts", async () => { + const base44 = await setup(); + const scope = nock(serverUrl).get(configPath).once().reply(200, config); + + await base44.payments.mountCheckout({} as any, checkout); + await base44.payments.mountCheckout({} as any, checkout); + + expect(scope.isDone()).toBe(true); + }); + + test("is fetched again after a failed fetch", async () => { + const base44 = await setup(); + const scope = nock(serverUrl) + .get(configPath) + .reply(503, { error: { code: "provider_unavailable", message: "Down", details: {} } }) + .get(configPath) + .reply(200, config); + + await expect(base44.payments.mountCheckout({} as any, checkout)).rejects.toThrow("Down"); + await base44.payments.mountCheckout({} as any, checkout); + + expect(scope.isDone()).toBe(true); + }); +});