UNPKG

@freemius/sdk

Version:

JS SDK for integrating your SaaS with Freemius

1,439 lines (1,417 loc) 87.6 kB
import * as crypto$1 from "crypto"; import crypto, { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto"; import createClient from "openapi-fetch"; import { buildFreemiusQueryFromOptions, convertCheckoutOptionsToQueryParams } from "@freemius/checkout"; import * as zod from "zod"; //#region src/contracts/types.ts /** * This file holds all generic types used across the SDK, not specific to any contract. */ let BILLING_CYCLE = /* @__PURE__ */ function(BILLING_CYCLE$1) { BILLING_CYCLE$1["MONTHLY"] = "monthly"; BILLING_CYCLE$1["YEARLY"] = "yearly"; BILLING_CYCLE$1["ONEOFF"] = "oneoff"; return BILLING_CYCLE$1; }({}); let CURRENCY = /* @__PURE__ */ function(CURRENCY$1) { CURRENCY$1["USD"] = "USD"; CURRENCY$1["EUR"] = "EUR"; CURRENCY$1["GBP"] = "GBP"; return CURRENCY$1; }({}); //#endregion //#region src/api/parser.ts function idToNumber(id) { if (typeof id === "number") return id; else if (typeof id === "bigint") return Number(id); else if (typeof id === "string") { const parsed = Number.parseInt(id, 10); if (Number.isNaN(parsed)) throw new Error(`Invalid FSId: ${id}`); return parsed; } else throw new Error(`Unsupported FSId type: ${typeof id}`); } function idToString(id) { if (typeof id === "string") return id; else if (typeof id === "number" || typeof id === "bigint") return String(id); else throw new Error(`Unsupported FSId type: ${typeof id}`); } function isIdsEqual(id1, id2) { return idToString(id1) === idToString(id2); } function parseBillingCycle(cycle) { const billingCycle = Number.parseInt(cycle?.toString() ?? "", 10); if (billingCycle === 1) return BILLING_CYCLE.MONTHLY; if (billingCycle === 12) return BILLING_CYCLE.YEARLY; return BILLING_CYCLE.ONEOFF; } function parseNumber(value) { if (typeof value === "number") return value; else if (typeof value === "string") { const parsed = Number.parseFloat(value); return Number.isNaN(parsed) ? null : parsed; } else return null; } function parseDateTime(dateString) { if (!dateString) return null; const dateParts = dateString.split(" "); if (dateParts.length !== 2) return null; const date = dateParts[0].split("-"); const time = dateParts[1].split(":"); if (date.length !== 3 || time.length !== 3) return null; const year = Number.parseInt(date[0]); const month = Number.parseInt(date[1]) - 1; const day = Number.parseInt(date[2]); const hours = Number.parseInt(time[0]); const minutes = Number.parseInt(time[1]); const seconds = Number.parseInt(time[2]); if (Number.isNaN(year) || Number.isNaN(month) || Number.isNaN(day) || Number.isNaN(hours) || Number.isNaN(minutes) || Number.isNaN(seconds)) return null; const utcDate = new Date(Date.UTC(year, month, day, hours, minutes, seconds, 0)); return utcDate; } function parseDate(dateString) { if (!dateString) return null; return parseDateTime(dateString + " 00:00:00"); } function parseCurrency(currency) { switch (currency?.toLowerCase?.()) { case "usd": return CURRENCY.USD; case "eur": return CURRENCY.EUR; case "gbp": return CURRENCY.GBP; default: return null; } } function parsePaymentMethod(gateway) { return gateway?.startsWith("stripe") ? "card" : gateway?.startsWith("paypal") ? "paypal" : null; } //#endregion //#region package.json var version = "0.0.6"; //#endregion //#region src/api/client.ts function detectPlatform() { if (typeof globalThis !== "undefined" && "Bun" in globalThis) return "Bun"; if (typeof globalThis !== "undefined" && "Deno" in globalThis) return "Deno"; if (typeof globalThis !== "undefined" && "process" in globalThis && globalThis.process && typeof globalThis.process === "object" && "versions" in globalThis.process && globalThis.process.versions && "node" in globalThis.process.versions) return "Node"; if (typeof globalThis !== "undefined" && "window" in globalThis) return "Browser"; return "Unknown"; } function createApiClient(baseUrl, bearerToken) { const platform = detectPlatform(); const client = createClient({ baseUrl, headers: { Authorization: `Bearer ${bearerToken}`, "User-Agent": `Freemius-JS-SDK/${version} (${platform})` } }); return client; } //#endregion //#region src/api/ApiBase.ts const PAGING_MAX_LIMIT = 50; const PAGING_DEFAULT_LIMIT = PAGING_MAX_LIMIT; const defaultPagingOptions = { count: PAGING_DEFAULT_LIMIT, offset: 0 }; var ApiBase = class { productId; constructor(productId, client) { this.client = client; this.productId = idToNumber(productId); } /** * Async generator that yields all entities by paginating through retrieveMany. * @param filter Optional filter for entities * @param pageSize Optional page size (default: PAGING_DEFAULT_LIMIT) * * @example * // Usage example: * for await (const entity of apiInstance.iterateAll({ status: 'active' })) { * console.log(entity); * } */ async *iterateAll(filter, pageSize = PAGING_DEFAULT_LIMIT) { let offset = 0; while (true) { const page = await this.retrieveMany(filter, { count: pageSize, offset }); if (!page.length) break; for (const entity of page) yield entity; if (page.length < pageSize) break; offset += page.length; } } getPagingParams(paging = defaultPagingOptions) { return { count: paging.count ?? PAGING_DEFAULT_LIMIT, offset: paging.offset ?? 0 }; } getIdForPath(id) { return idToNumber(id); } isGoodResponse(response) { return response.status >= 200 && response.status < 300; } /** * @note - We must use this serializer when sending arrays as query parameter to our API. */ getQuerySerializerForArray() { return { array: { explode: false, style: "form" } }; } }; //#endregion //#region src/api/License.ts var License = class extends ApiBase { async retrieve(licenseId) { const licenseResponse = await this.client.GET(`/products/{product_id}/licenses/{license_id}.json`, { params: { path: { product_id: this.productId, license_id: this.getIdForPath(licenseId) } } }); if (!this.isGoodResponse(licenseResponse.response) || !licenseResponse.data || !licenseResponse.data.id) return null; return licenseResponse.data; } async retrieveMany(filter, pagination) { const response = await this.client.GET(`/products/{product_id}/licenses.json`, { params: { path: { product_id: this.productId }, query: { ...this.getPagingParams(pagination), ...filter ?? {} } } }); if (!this.isGoodResponse(response.response) || !response.data || !Array.isArray(response.data.licenses)) return []; return response.data.licenses; } async retrieveSubscription(licenseId) { const subscriptionResponse = await this.client.GET(`/products/{product_id}/licenses/{license_id}/subscription.json`, { params: { path: { product_id: this.productId, license_id: this.getIdForPath(licenseId) } } }); if (!this.isGoodResponse(subscriptionResponse.response) || !subscriptionResponse.data || !subscriptionResponse.data.id) return null; return subscriptionResponse.data; } async retrieveCheckoutUpgradeAuthorization(licenseId) { const response = await this.client.POST(`/products/{product_id}/licenses/{license_id}/checkout/link.json`, { params: { path: { product_id: this.productId, license_id: this.getIdForPath(licenseId) } }, body: { is_payment_method_update: true } }); if (!this.isGoodResponse(response.response) || !response.data || !response.data.settings) return null; return response.data.settings.authorization; } }; //#endregion //#region src/api/Product.ts var Product = class extends ApiBase { async retrieve() { const response = await this.client.GET(`/products/{product_id}.json`, { params: { path: { product_id: this.productId } } }); if (!this.isGoodResponse(response.response) || !response.data) return null; return response.data; } async retrieveMany() { throw new Error("retrieveMany is not supported for Product API"); } async retrievePricingData() { const response = await this.client.GET(`/products/{product_id}/pricing.json`, { params: { path: { product_id: this.productId } } }); if (!this.isGoodResponse(response.response) || !response.data) return null; return response.data; } async retrieveSubscriptionCancellationCoupon() { const response = await this.client.GET(`/products/{product_id}/coupons/special.json`, { params: { path: { product_id: this.productId }, query: { type: "subscription_cancellation" } } }); if (!this.isGoodResponse(response.response) || !response.data || !response.data.coupon_entities) return null; return response.data.coupon_entities; } }; //#endregion //#region src/api/Subscription.ts var Subscription = class extends ApiBase { async retrieve(subscriptionId) { const result = await this.client.GET(`/products/{product_id}/subscriptions/{subscription_id}.json`, { params: { path: { product_id: this.productId, subscription_id: this.getIdForPath(subscriptionId) } } }); if (!this.isGoodResponse(result.response) || !result.data || !result.data.id) return null; return result.data; } async retrieveMany(filter, pagination) { const result = await this.client.GET(`/products/{product_id}/subscriptions.json`, { params: { path: { product_id: this.productId }, query: { ...this.getPagingParams(pagination), ...filter ?? {} } } }); if (!this.isGoodResponse(result.response) || !result.data || !Array.isArray(result.data.subscriptions)) return []; return result.data.subscriptions; } async applyRenewalCoupon(subscriptionId, couponId, logAutoRenew) { const result = await this.client.PUT(`/products/{product_id}/subscriptions/{subscription_id}.json`, { params: { path: { product_id: this.productId, subscription_id: this.getIdForPath(subscriptionId) } }, body: { auto_renew: logAutoRenew, coupon_id: Number.parseInt(couponId, 10) } }); if (!this.isGoodResponse(result.response) || !result.data || !result.data.id) return null; return result.data; } async cancel(subscriptionId, feedback, reasonIds) { const result = await this.client.DELETE(`/products/{product_id}/subscriptions/{subscription_id}.json`, { params: { path: { product_id: this.productId, subscription_id: this.getIdForPath(subscriptionId) }, query: { reason: feedback, reason_ids: reasonIds ?? [] } }, querySerializer: this.getQuerySerializerForArray() }); if (!this.isGoodResponse(result.response) || !result.data || !result.data.id) return null; return result.data; } }; //#endregion //#region src/api/User.ts const USER_FIELDS = "email,first,last,picture,is_verified,id,created,updated,is_marketing_allowed"; var User = class extends ApiBase { async retrieve(userId) { const userResponse = await this.client.GET(`/products/{product_id}/users/{user_id}.json`, { params: { path: { product_id: this.productId, user_id: this.getIdForPath(userId) }, query: { fields: USER_FIELDS } } }); if (userResponse.response.status !== 200 || !userResponse.data || !userResponse.data.id) return null; return userResponse.data; } async retrieveMany(filter, pagination) { const response = await this.client.GET(`/products/{product_id}/users.json`, { params: { path: { product_id: this.productId }, query: { ...this.getPagingParams(pagination), ...filter ?? {}, fields: USER_FIELDS } } }); if (response.response.status !== 200 || !response.data || !Array.isArray(response.data.users)) return []; return response.data.users; } async retrieveByEmail(email) { const response = await this.client.GET(`/products/{product_id}/users.json`, { params: { path: { product_id: this.productId }, query: { email } } }); if (!this.isGoodResponse(response.response) || !Array.isArray(response.data?.users)) return null; return response.data.users?.[0] ?? null; } async retrieveBilling(userId) { const billingResponse = await this.client.GET(`/products/{product_id}/users/{user_id}/billing.json`, { params: { path: { product_id: this.productId, user_id: this.getIdForPath(userId) } } }); if (billingResponse.response.status !== 200 || !billingResponse.data || !billingResponse.data) return null; return billingResponse.data; } async retrieveSubscriptions(userId, filters, pagination) { const result = await this.client.GET(`/products/{product_id}/users/{user_id}/subscriptions.json`, { params: { path: { product_id: this.productId, user_id: this.getIdForPath(userId) }, query: { ...filters ?? {}, ...this.getPagingParams(pagination) } } }); if (!this.isGoodResponse(result.response) || !result.data || !Array.isArray(result.data.subscriptions)) return []; const discountsMap = /* @__PURE__ */ new Map(); if (result.data.discounts) Object.entries(result.data.discounts).forEach(([subscriptionId, discounts]) => { discountsMap.set(idToString(subscriptionId), discounts); }); return result.data.subscriptions.map((subscription) => ({ ...subscription, discounts: discountsMap.get(idToString(subscription.id)) || [] })); } async retrieveLicenses(userId, filters, pagination) { const response = await this.client.GET(`/products/{product_id}/users/{user_id}/licenses.json`, { params: { path: { product_id: this.productId, user_id: this.getIdForPath(userId) }, query: { ...filters ?? {}, ...this.getPagingParams(pagination) } } }); if (response.response.status !== 200 || !response.data || !Array.isArray(response.data.licenses)) return []; return response.data.licenses; } async retrievePayments(userId, filters, pagination) { const response = await this.client.GET(`/products/{product_id}/users/{user_id}/payments.json`, { params: { path: { product_id: this.productId, user_id: this.getIdForPath(userId) }, query: { ...filters ?? {}, ...this.getPagingParams(pagination) } } }); if (response.response.status !== 200 || !response.data || !Array.isArray(response.data.payments)) return []; return response.data.payments; } async updateBilling(userId, payload) { const response = await this.client.PUT(`/products/{product_id}/users/{user_id}/billing.json`, { params: { path: { product_id: this.productId, user_id: this.getIdForPath(userId) } }, body: payload }); if (!this.isGoodResponse(response.response) || !response.data || !response.data) return null; return response.data; } }; //#endregion //#region src/api/Payment.ts var Payment = class extends ApiBase { async retrieve(paymentId) { const response = await this.client.GET(`/products/{product_id}/payments/{payment_id}.json`, { params: { path: { product_id: this.productId, payment_id: this.getIdForPath(paymentId) } } }); if (!this.isGoodResponse(response.response) || !response.data || !response.data.id) return null; return response.data; } async retrieveMany(filter, pagination) { const response = await this.client.GET(`/products/{product_id}/payments.json`, { params: { path: { product_id: this.productId }, query: { ...this.getPagingParams(pagination), ...filter ?? {} } } }); if (!this.isGoodResponse(response.response) || !response.data || !Array.isArray(response.data.payments)) return []; return response.data.payments; } async retrieveInvoice(paymentId) { const response = await this.client.GET(`/products/{product_id}/payments/{payment_id}/invoice.pdf`, { params: { path: { payment_id: this.getIdForPath(paymentId), product_id: this.productId } }, parseAs: "blob" }); if (!this.isGoodResponse(response.response) || !response.data) return null; return response.data; } }; //#endregion //#region src/utils/ops.ts function splitName(name) { const parts = name.split(" "); return { firstName: parts[0] ?? "", lastName: parts.slice(1).join(" ") ?? "" }; } function isTestServer() { return process.env.FREEMIUS_INTERNAL__IS_DEVELOPMENT_MODE === "true"; } //#endregion //#region src/services/ApiService.ts const API_ENDPOINT_PRODUCTION = "https://api.freemius.com/v1/"; const API_ENDPOINT_TEST = "http://api.freemius-local.com:8080/v1/"; /** * @todo - Add a proper user-agent string with SDK version. */ var ApiService = class { client; productId; user; license; product; subscription; payment; baseUrl; constructor(productId, apiKey, secretKey, publicKey) { this.secretKey = secretKey; this.publicKey = publicKey; this.baseUrl = isTestServer() ? API_ENDPOINT_TEST : API_ENDPOINT_PRODUCTION; this.client = createApiClient(this.baseUrl, apiKey); this.productId = idToString(productId); this.user = new User(this.productId, this.client); this.license = new License(this.productId, this.client); this.product = new Product(this.productId, this.client); this.subscription = new Subscription(this.productId, this.client); this.payment = new Payment(this.productId, this.client); } /** * Low level API client for direct access to the Freemius API. * Use this for advanced use cases where you need to make custom API calls. * * For regular operations, prefer using the provided services like `User`, `Subscription`, `License` etc. */ get __unstable_ApiClient() { return this.client; } createSignedUrl(path) { return this.getSignedUrl(this.createUrl(path)); } createUrl(path) { path = path.replace(/^\/+/, ""); return `${this.baseUrl}products/${this.productId}/${path}`; } /** * Generate signed URL for the given full URL. The authentication is valid for 15 minutes only. */ getSignedUrl(fullUrl) { const url = new URL(fullUrl); const resourcePath = url.pathname; const auth = this.generateAuthorizationParams(resourcePath); url.searchParams.set("auth_date", auth.date); url.searchParams.set("authorization", auth.authorization); return url.toString(); } /** * Generate authorization parameters for signing */ generateAuthorizationParams(resourcePath, method = "GET", jsonEncodedParams = "", contentType = "") { const eol = "\n"; let contentMd5 = ""; const date = this.toDateTimeString(/* @__PURE__ */ new Date()); if (["POST", "PUT"].includes(method) && jsonEncodedParams) contentMd5 = crypto$1.createHash("md5").update(jsonEncodedParams).digest("hex"); const stringToSign = [ method, contentMd5, contentType, date, resourcePath ].join(eol); const authType = this.secretKey !== this.publicKey ? "FS" : "FSP"; const signature = crypto$1.createHmac("sha256", this.secretKey).update(stringToSign).digest("hex"); const base64 = this.base64UrlEncode(signature); return { date, authorization: `${authType} ${this.productId}:${this.publicKey}:${base64}` }; } /** * Base64 encoding that doesn't need to be urlencode()ed. * Exactly the same as base64_encode except it uses * - instead of + * _ instead of / */ base64UrlEncode(input) { return Buffer.from(input, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } toDateTimeString(date) { const year = date.getUTCFullYear(); const month = String(date.getUTCMonth() + 1).padStart(2, "0"); const day = String(date.getUTCDate()).padStart(2, "0"); const hours = String(date.getUTCHours()).padStart(2, "0"); const minutes = String(date.getUTCMinutes()).padStart(2, "0"); const seconds = String(date.getUTCSeconds()).padStart(2, "0"); return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; } }; //#endregion //#region src/checkout/Checkout.ts /** * A builder class for constructing checkout parameters. This class provides a fluent * API to create Checkout parameters for a product with various configurations. * * Every method returns the existing instance of the builder for chainability, * The final `getOptions()` method returns the constructed `CheckoutOptions` object. */ var Checkout = class Checkout { static createSandboxToken(productId, secretKey, publicKey) { const timestamp = Math.floor(Date.now() / 1e3).toString(); const token = `${timestamp}${productId}${secretKey}${publicKey}checkout`; return { ctx: timestamp, token: createHash("md5").update(token).digest("hex") }; } options; constructor(productId, publicKey, secretKey) { this.productId = productId; this.publicKey = publicKey; this.secretKey = secretKey; this.options = { product_id: productId }; } /** * Enables sandbox mode for testing purposes. * * @returns A new builder instance with sandbox configuration */ setSandbox() { this.options = { ...this.options, sandbox: Checkout.createSandboxToken(this.productId, this.secretKey, this.publicKey) }; return this; } /** * Sets user information for the checkout session. * * @param user User object with email and optional name fields. The shape matches the session from `better-auth` or next-auth packages. Also handles `null` or `undefined` gracefully. * @param readonly If true, the user information will be read-only in the checkout session. * * @returns A new builder instance with user configuration */ setUser(user, readonly = true) { if (!user) return this; let firstName = user.firstName ?? ""; let lastName = user.lastName ?? ""; if (user.name) { const { firstName: fn, lastName: ln } = splitName(user.name); firstName = fn; lastName = ln; } this.options = { ...this.options, user_email: user.email, user_firstname: firstName, user_lastname: lastName, readonly_user: readonly }; return this; } /** * Applies recommended UI settings for better user experience. * This includes fullscreen mode, upsells, refund badge, and reviews display. * * @returns A new builder instance with recommended UI settings */ setRecommendations() { this.options = { ...this.options, fullscreen: true, show_refund_badge: true, show_reviews: true, locale: "auto", currency: "auto" }; return this; } /** * Sets the plan ID for the checkout. * * @param planId The plan ID to purchase * @returns A new builder instance with plan ID set */ setPlan(planId) { this.options = { ...this.options, plan_id: planId.toString() }; return this; } /** * Sets the number of licenses to purchase. * * @param count Number of licenses * @returns A new builder instance with license count set */ setQuota(count) { this.options = { ...this.options, licenses: count }; return this; } setPricing(pricingId) { this.options = { ...this.options, pricing_id: pricingId.toString() }; return this; } setTitle(title) { this.options = { ...this.options, title }; return this; } /** * Sets a coupon code for the checkout. * * @param coupon The coupon code to apply * @param hideUI Whether to hide the coupon input field from users * @returns A new builder instance with coupon configuration */ setCoupon(options) { const { code: coupon, hideUI = false } = options; this.options = { ...this.options, coupon, hide_coupon: hideUI }; return this; } /** * Enables trial mode for the checkout. * * @param mode Trial type - true/false for plan default, or specific 'free'/'paid' mode * @returns A new builder instance with trial configuration */ setTrial(mode = true) { this.options = { ...this.options, trial: mode }; return this; } /** * Configures the visual layout and appearance of the checkout. * * @param options Appearance configuration options * @returns A new builder instance with appearance configuration */ setAppearance(options) { this.options = { ...this.options }; if (options.layout !== void 0) this.options.layout = options.layout; if (options.formPosition !== void 0) this.options.form_position = options.formPosition; if (options.fullscreen !== void 0) this.options.fullscreen = options.fullscreen; if (options.modalTitle !== void 0) this.options.modal_title = options.modalTitle; if (options.id !== void 0) this.options.id = options.id; return this; } /** * Configures discount display settings. * * @param options Discount configuration options * @returns A new builder instance with discount configuration */ setDiscounts(options) { this.options = { ...this.options }; if (options.annual !== void 0) this.options.annual_discount = options.annual; if (options.multisite !== void 0) this.options.multisite_discount = options.multisite; if (options.bundle !== void 0) this.options.bundle_discount = options.bundle; if (options.showMonthlySwitch !== void 0) this.options.show_monthly_switch = options.showMonthlySwitch; return this; } /** * Configures billing cycle selector interface. * * @param selector Type of billing cycle selector to show * @param defaultCycle Default billing cycle to select * @returns A new builder instance with billing cycle configuration */ setBillingCycle(defaultCycle, selector) { this.options = { ...this.options }; if (selector !== void 0) this.options.billing_cycle_selector = selector; if (defaultCycle !== void 0) this.options.billing_cycle = defaultCycle; return this; } /** * Sets the language/locale for the checkout. * * @param locale Language setting - 'auto', 'auto-beta', or specific locale like 'en_US' * @returns A new builder instance with locale configuration */ setLanguage(locale = "auto") { this.options = { ...this.options, language: locale }; return this; } /** * Configures review and badge display settings. * * @param options Review and badge configuration * @returns A new builder instance with reviews and badges configuration */ setSocialProofing(options) { this.options = { ...this.options }; if (options.showReviews !== void 0) this.options.show_reviews = options.showReviews; if (options.reviewId !== void 0) this.options.review_id = options.reviewId; if (options.showRefundBadge !== void 0) this.options.show_refund_badge = options.showRefundBadge; if (options.refundPolicyPosition !== void 0) this.options.refund_policy_position = options.refundPolicyPosition; return this; } /** * Enhanced currency configuration. * * @param currency Primary currency or 'auto' for automatic detection * @param defaultCurrency Default currency when using 'auto' * @param showInlineSelector Whether to show inline currency selector * @returns A new builder instance with currency configuration */ setCurrency(currency, defaultCurrency = "usd", showInlineSelector = true) { this.options = { ...this.options, show_inline_currency_selector: showInlineSelector, default_currency: defaultCurrency, currency }; return this; } /** * Configures navigation and cancel behavior. * * @param cancelUrl URL for back button when in page mode * @param cancelIcon Custom cancel icon URL * @returns A new builder instance with navigation configuration */ setCancelButton(cancelUrl, cancelIcon) { this.options = { ...this.options }; if (cancelUrl !== void 0) this.options.cancel_url = cancelUrl; if (cancelIcon !== void 0) this.options.cancel_icon = cancelIcon; return this; } /** * Associates purchases with an affiliate account. * * @param userId Affiliate user ID * @returns A new builder instance with affiliate configuration */ setAffiliate(userId) { this.options = { ...this.options, affiliate_user_id: userId }; return this; } /** * Sets a custom image/icon for the checkout. * * @param imageUrl Secure HTTPS URL to the image * @returns A new builder instance with custom image */ setImage(imageUrl) { this.options = { ...this.options, image: imageUrl }; return this; } /** * Configures the checkout for license renewal. * * @param licenseKey The license key to renew * @returns A new builder instance configured for renewal */ setLicenseRenewal(licenseKey) { this.options = { ...this.options, license_key: licenseKey }; return this; } /** * Builds and returns the final checkout options to be used with the `@freemius/checkout` package. * * @note - This is async by purpose so that we can allow for future enhancements that might require async operations. * * @returns The constructed CheckoutOptions object */ getOptions() { return { ...this.options }; } /** * Generates a checkout link based on the current builder state. * * @note - This is async by purpose so that we can allow for future enhancements that might require async operations. */ getLink() { const checkoutOptions = convertCheckoutOptionsToQueryParams(this.options); const queryParams = buildFreemiusQueryFromOptions(checkoutOptions); const url = new URL(`${this.getBaseUrl()}/product/${this.productId}/`); url.search = queryParams; return url.toString(); } serialize() { return { options: this.getOptions(), link: this.getLink(), baseUrl: this.getBaseUrl() }; } getBaseUrl() { return isTestServer() ? "http://checkout.freemius-local.com:8080" : "https://checkout.freemius.com"; } }; //#endregion //#region src/errors/ActionError.ts var ActionError = class ActionError extends Error { statusCode; validationIssues; constructor(message, statusCode = 400, validationIssues) { super(message); this.name = "ActionError"; this.statusCode = statusCode; this.validationIssues = validationIssues; } toResponse() { const errorResponse = { message: this.message }; if (this.validationIssues) errorResponse.issues = this.validationIssues; return Response.json(errorResponse, { status: this.statusCode }); } static badRequest(message) { return new ActionError(message, 400); } static unauthorized(message = "Unauthorized") { return new ActionError(message, 401); } static notFound(message = "Not found") { return new ActionError(message, 404); } static validationFailed(message, validationIssues) { return new ActionError(message, 400, validationIssues); } static internalError(message = "Internal server error") { return new ActionError(message, 500); } }; //#endregion //#region src/checkout/PricingRetriever.ts var PricingRetriever = class { constructor(pricing) { this.pricing = pricing; } canHandle(request) { const url = new URL(request.url); const action = url.searchParams.get("action"); return action === "pricing_data"; } async processAction(request) { const url = new URL(request.url); const topupPlanId = url.searchParams.get("topupPlanId") || void 0; const pricingData = await this.pricing.retrieve(topupPlanId); return Response.json(pricingData); } }; //#endregion //#region src/checkout/PurchaseProcessor.ts var PurchaseProcessor = class { constructor(purchase, callback) { this.purchase = purchase; this.callback = callback; } canHandle(request) { const url = new URL(request.url); const action = url.searchParams.get("action"); return request.method === "POST" && action === "process_purchase"; } async processAction(request) { const purchaseSchema = zod.object({ purchase: zod.object({ license_id: zod.string() }) }); const contentType = request.headers.get("content-type"); if (!contentType || !contentType.includes("application/json")) throw ActionError.badRequest("Invalid content type. Expected application/json"); let requestBody; try { requestBody = await request.json(); } catch { throw ActionError.badRequest("Request body must be valid JSON"); } const parseResult = purchaseSchema.safeParse(requestBody); if (!parseResult.success) throw ActionError.validationFailed("Invalid request body format", parseResult.error.issues); const { purchase: { license_id: licenseId } } = parseResult.data; const purchase = await this.purchase.retrievePurchase(licenseId); if (!purchase) throw ActionError.notFound("No purchase data found for the provided license ID"); if (this.callback) { const callbackResponse = await this.callback(purchase); if (callbackResponse) return callbackResponse; } return Response.json(purchase.toData()); } }; //#endregion //#region src/models/CheckoutRedirectInfo.ts var CheckoutRedirectInfo = class { user_id; plan_id; email; pricing_id; currency; license_id; expiration; quota; action; amount; tax; type; subscription_id; billing_cycle; payment_id; constructor(data) { this.user_id = idToString(data.user_id); this.plan_id = idToString(data.plan_id); this.email = data.email; this.pricing_id = idToString(data.pricing_id); this.currency = data.currency ? parseCurrency(data.currency) : CURRENCY.USD; this.license_id = idToString(data.license_id); this.expiration = data.expiration ? parseDateTime(data.expiration) : null; this.quota = data.quota ? parseNumber(data.quota) : null; this.action = data.action ? data.action : null; this.amount = parseNumber(data.amount); this.tax = parseNumber(data.tax); this.subscription_id = data.subscription_id ? idToString(data.subscription_id) : null; this.billing_cycle = data.billing_cycle ? parseBillingCycle(data.billing_cycle) : null; this.payment_id = data.payment_id ? idToString(data.payment_id) : null; this.type = this.subscription_id ? "subscription" : "one-off"; } isSubscription() { return this.type === "subscription"; } toData() { return { user_id: this.user_id, plan_id: this.plan_id, email: this.email, pricing_id: this.pricing_id, currency: this.currency, license_id: this.license_id, expiration: this.expiration, quota: this.quota, action: this.action, amount: this.amount, tax: this.tax, type: this.type, subscription_id: this.subscription_id, billing_cycle: this.billing_cycle, payment_id: this.payment_id }; } }; //#endregion //#region src/checkout/RedirectProcessor.ts var RedirectProcessor = class { constructor(secretKey, proxyUrl, callback, afterProcessUrl) { this.secretKey = secretKey; this.proxyUrl = proxyUrl; this.callback = callback; this.afterProcessUrl = afterProcessUrl; } canHandle(request) { const url = new URL(request.url); return request.method === "GET" && url.searchParams.has("signature"); } async processAction(request) { const info = await this.getRedirectInfo(request.url); if (!info) throw ActionError.badRequest("Invalid or missing redirect signature"); if (this.callback) { const callbackResponse = await this.callback(info); if (callbackResponse) return callbackResponse; } const url = new URL(this.afterProcessUrl ?? this.proxyUrl ?? request.url); url.search = ""; url.searchParams.set("plan", info.plan_id); url.searchParams.set("is_subscription", info.isSubscription() ? "1" : "0"); url.searchParams.set("quote", info.quota?.toString() ?? "0"); return Response.redirect(url.href, 302); } async getRedirectInfo(currentUrl) { const url = new URL(currentUrl.replace(/%20/g, "+")); const signature = url.searchParams.get("signature"); if (!signature) return null; if (this.proxyUrl) { const proxy = new URL(this.proxyUrl); url.protocol = proxy.protocol; url.host = proxy.host; url.port = proxy.port; } const cleanUrl = this.getCleanUrl(url.href); const calculatedSignature = createHmac("sha256", this.secretKey).update(cleanUrl).digest("hex"); const result = timingSafeEqual(Buffer.from(calculatedSignature), Buffer.from(signature)); if (!result) return null; const params = Object.fromEntries(url.searchParams.entries()); if (!params.user_id || !params.plan_id || !params.pricing_id || !params.email) return null; return new CheckoutRedirectInfo(params); } getCleanUrl(url) { const signatureParam = "&signature="; const signatureParamFirst = "?signature="; let signaturePos = url.indexOf(signatureParam); if (signaturePos === -1) signaturePos = url.indexOf(signatureParamFirst); if (signaturePos === -1) return url; return url.substring(0, signaturePos); } }; //#endregion //#region src/checkout/CheckoutRequestProcessor.ts var CheckoutRequestProcessor = class { constructor(purchase, pricing, secretKey) { this.purchase = purchase; this.pricing = pricing; this.secretKey = secretKey; } createProcessor(config) { return (request) => this.process(config, request); } async process(config, request) { const url = new URL(request.url); const action = url.searchParams.get("action"); if (!action) return Response.json({ error: "Action parameter is required" }, { status: 400 }); const actionHandlers = [ this.getPricingRetriever(), this.getRedirectProcessor({ proxyUrl: config.proxyUrl, callback: config.onRedirect, afterProcessUrl: config.afterProcessUrl }), this.getPurchaseProcessor({ callback: config.onPurchase }) ]; try { for (const actionHandler of actionHandlers) if (actionHandler.canHandle(request)) return await actionHandler.processAction(request); } catch (error) { if (error instanceof ActionError) return error.toResponse(); console.error("Error processing action:", error); return ActionError.internalError("Internal Server Error").toResponse(); } return ActionError.badRequest("Unsupported action").toResponse(); } /** * Processes the redirect from Freemius Checkout. * * This method verifies the signature in the URL and returns a CheckoutRedirectInfo object if successful. * * For nextjs like applications, make sure to replace the URL from the `Request` object with the right hostname to take care of the proxy. * * For example, if you have put the nextjs application behind nginx proxy (or ngrok during local development), then nextjs will still see the `request.url` as `https://localhost:3000/...`. * In this case, you should replace it with the actual URL of your application, like `https://xyz.ngrok-free.app/...`. * * @example * ```ts * export async function GET(request: Request) { * // Replace the URL with the actual hostname of your application * // This is important for the signature verification to work correctly. * const data = await freemius.checkout.action.getRedirectProcessor({ * proxyUrl: 'https://xyz.ngrok-free.app', * async callback(info) { * // Handle the redirect info here, like creating a license, etc. * // Return a Response object to override the default redirect behavior. * return Response.redirect('/custom-success-page', 302); * }, * }); * * return data.processAction(request); * } * ``` */ getRedirectProcessor(config) { return new RedirectProcessor(this.secretKey, config.proxyUrl, config.callback, config.afterProcessUrl); } getPurchaseProcessor(config) { return new PurchaseProcessor(this.purchase, config.callback); } getPricingRetriever() { return new PricingRetriever(this.pricing); } }; //#endregion //#region src/services/CheckoutService.ts var CheckoutService = class { request; constructor(productId, publicKey, secretKey, purchase, pricing) { this.productId = productId; this.publicKey = publicKey; this.secretKey = secretKey; this.purchase = purchase; this.pricing = pricing; this.request = new CheckoutRequestProcessor(this.purchase, this.pricing, this.secretKey); } /** * Use this to build a Checkout for your product. * You can build a Checkout link or options for the popup. * * @param withRecommendation If true, the checkout will include a recommendation for the user. * * @return A new instance of CheckoutBuilder with the product ID and public key. * * @example * Basic usage: * ```typescript * const checkout = await freemius.checkout.create({user: session?.user}) * .getOptions(); // Or .getLink() for a hosted checkout link * ``` * * @example * Advanced configuration: You can also skip the convenience options and rather use the builder directly to configure the checkout. * * ```typescript * const checkout = freemius.checkout.create() * .setUser(user, true) * .setPlan('1234') * .setCoupon({ * code: 'DISCOUNT2023', * hideUI: false * }) * .setSandbox() * .getOptions(); * ``` * * @example */ async create(options = {}) { const { user, isSandbox = false, withRecommendation = true, title, image, planId, quota, trial } = options; const builder = new Checkout(idToString(this.productId), this.publicKey, this.secretKey); if (user) builder.setUser(user, true); if (withRecommendation) builder.setRecommendations(); if (isSandbox) builder.setSandbox(); if (title) builder.setTitle(title); if (image) builder.setImage(image); if (planId) builder.setPlan(planId); if (quota) builder.setQuota(quota); if (trial) builder.setTrial(trial); return builder; } /** * Retrieves the sandbox parameters for the checkout. * * This shouldn't be used in production, but is useful for testing purposes. * * @note This is intentionally set as `async` because we would use the API in the future to generate more fine grained sandbox params (for example for a specific email address only). * * @todo - This has a duplication with the `inSandbox` method in the builder. Consider refactoring to avoid this duplication. * Also think about whether we should make the builder's `inSandbox` method async as well. */ async getSandboxParams() { return Checkout.createSandboxToken(idToString(this.productId), this.secretKey, this.publicKey); } /** * Processes a redirect URL and returns the checkout redirect information if valid. * * This is useful for handling redirects from the checkout portal back to your application. * * @param url The current URL to process. * @param proxyUrl Optional proxy URL to replace parts of the URL for signature verification. * * @returns A promise that resolves to the checkout redirect information or null if invalid. * * @example * ```typescript * const redirectInfo = await freemius.checkout.processRedirect(window.location.href); * * if (redirectInfo) { * // Handle valid redirect info * } else { * // Handle invalid or missing redirect info * } * ``` */ processRedirect(url, proxyUrl) { const processor = new RedirectProcessor(this.secretKey, proxyUrl); return processor.getRedirectInfo(url); } }; //#endregion //#region src/customer-portal/PortalDataRepository.ts var PortalDataRepository = class { constructor(api, action, checkout) { this.api = api; this.action = action; this.checkout = checkout; } async retrievePortalDataByEmail(config) { const user = await this.api.user.retrieveByEmail(config.email); if (!user) return null; return this.retrievePortalData({ user, endpoint: config.endpoint, primaryLicenseId: config.primaryLicenseId ?? null, sandbox: config.sandbox ?? false }); } async retrievePortalDataByUserId(config) { const user = await this.api.user.retrieve(config.userId); if (!user) return null; return this.retrievePortalData({ user, endpoint: config.endpoint, primaryLicenseId: config.primaryLicenseId ?? null, sandbox: config.sandbox ?? false }); } async retrievePortalData(config) { const { user, endpoint, primaryLicenseId = null, sandbox = false } = config; const userId = user.id; const data = await this.retrieveApiData(userId); if (!data) return null; const { pricingData, subscriptions, payments, billing, coupons } = data; const plans = this.getPlansById(pricingData); const pricings = this.getPricingById(pricingData); const checkoutOptions = { product_id: this.api.productId }; if (sandbox) checkoutOptions.sandbox = await this.checkout.getSandboxParams(); const portalData = { endpoint, user, checkoutOptions, billing: this.getBilling(billing, userId, endpoint), subscriptions: await this.getSubscriptions(subscriptions, plans, pricings, primaryLicenseId, endpoint), payments: this.getPayments(payments, plans, pricings, userId, endpoint), plans: pricingData.plans ?? [], sellingUnit: pricingData.plugin?.selling_unit_label ?? { singular: "Unit", plural: "Units" }, productId: this.api.productId, cancellationCoupons: coupons }; return portalData; } async retrieveApiData(userId) { const [pricingData, subscriptions, payments, billing, coupons] = await Promise.all([ this.api.product.retrievePricingData(), this.api.user.retrieveSubscriptions(userId, { extended: true, enrich_with_cancellation_discounts: true }), this.api.user.retrievePayments(userId), this.api.user.retrieveBilling(userId), this.api.product.retrieveSubscriptionCancellationCoupon() ]); if (!pricingData || !subscriptions) return null; return { pricingData, subscriptions, payments, billing, coupons }; } getPayments(payments, plans, pricings, userId, endpoint) { return payments.map((payment) => ({ ...payment, invoiceUrl: this.action.invoice.createAuthenticatedUrl(payment.id, idToString(userId), endpoint), paymentMethod: parsePaymentMethod(payment.gateway), createdAt: parseDateTime(payment.created) ?? /* @__PURE__ */ new Date(), planTitle: plans.get(payment.plan_id)?.title ?? `Plan ${payment.plan_id}`, quota: pricings.get(payment.pricing_id)?.licenses ?? null })); } getPlansById(pricingData) { const plans = /* @__PURE__ */ new Map(); pricingData.plans?.forEach((plan) => { plan.title = plan.title ?? plan.name ?? `Plan ${plan.id}`; plans.set(idToString(plan.id), plan); }); return plans; } getPricingById(pricingData) { const pricings = /* @__PURE__ */ new Map(); pricingData.plans?.forEach((plan) => { plan.pricing?.forEach((p) => { pricings.set(idToString(p.id), p); }); }); return pricings; } getBilling(billing, userId, endpoint) { return { ...billing ?? {}, updateUrl: this.action.billing.createAuthenticatedUrl(billing?.id ?? "new", idToString(userId), endpoint) }; } async getSubscriptions(subscriptions, plans, pricings, primaryLicenseId = null, endpoint) { const portalSubscriptions = { primary: null, active: [], past: [] }; subscriptions.forEach((subscription) => { const isActive = null === subscription.canceled_at; const trialEndsData = subscription.trial_ends ? parseDateTime(subscription.trial_ends) : null; const isTrial = trialEndsData ? trialEndsData > /* @__PURE__ */ new Date() : false; const isFreeTrial = isTrial && !subscription.gateway; const subscriptionData = { subscriptionId: idToString(subscription.id), planId: idToString(subscription.plan_id), pricingId: idToString(subscription.pricing_id), planTitle: plans.get(subscription.plan_id)?.title ?? `Plan ${subscription.plan_id}`, renewalAmount: parseNumber(subscription.renewal_amount), initialAmount: parseNumber(subscription.initial_amount), billingCycle: parseBillingCycle(subscription.billing_cycle), isActive, renewalDate: parseDateTime(subscription.next_payment), licenseId: idToString(subscription.license_id), currency: parseCurrency(subscription.currency) ?? CURRENCY.USD, createdAt: parseDateTime(subscription.created) ?? /* @__PURE__ */ new Date(), cancelledAt: subscription.canceled_at ? parseDateTime(subscription.canceled_at) : null, quota: pricings.get(subscription.pricing_id)?.licenses ?? null, paymentMethod: parsePaymentMethod(subscription.gateway), isTrial, trialEnds: isTrial ? trialEndsData : null, isFreeTrial, applyRenewalCancellationCouponUrl: this.isRenewalCancellationCouponApplicable(subscription) ? this.action.renewalCoupon.createAuthenticatedUrl(idToString(subscription.id), idToString(subscription.user_id), endpoint) : null, cancelRenewalUrl: this.action.cancelRenewal.createAuthenticatedUrl(idToString(subscription.id), idToString(subscription.user_id), endpoint) }; if (isActive) portalSubscriptions.active.push(subscriptionData); else portalSubscriptions.past.push(subscriptionData); if (isActive && primaryLicenseId && isIdsEqual(subscription.license_id, primaryLicenseId)) portalSubscriptions.primary = subscriptionData; }); portalSubscriptions.active.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); portalSubscriptions.past.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); if (!portalSubscriptions.primary) portalSubscriptions.primary = portalSubscriptions.active[0] ?? portalSubscriptions.past[0] ?? null; if (portalSubscriptions.primary) portalSubscriptions.primary.checkoutUpgradeAuthorization = await this.api.license.retrieveCheckoutUpgradeAuthorization(portalSubscriptions.primary.licenseId); return portalSubscriptions; } /** * Check if coupon application is impossible due to certain conditions. * This function can be used to determine if a coupon can be applied to a subscription. * Introduced initially for PayPal subscriptions with a renewal date less than 48 hours in the future. * * @author @DanieleAlessandra * @author @swashata (Ported to SDK) * * @returns boolean */ isRenewalCancellationCouponApplicable(subscription) { if (subscription.has_subscription_cancellation_discount || (subscription.total_gross ?? 0) <= 0) return false; if (subscription.gateway !== "paypal") return true; const nextPaymentTime = parseDateTime(subscription.next_payment)?.getTime() ?? 0; const currentTime = (/* @__PURE__ */ new Date()).getTime(); const fortyEightHoursInMs = 2880 * 60 * 1e3; return nextPaymentTime <= currentTime + fortyEightHoursInMs; } }; //#endregion //#region src/customer-portal/PortalDataRetriever.ts var PortalDataRetriever = class { constructor(repository, getUser, endpoint, isSandbox) { this.repository = repository; this.getUser = getUser; this.endpoint = endpoint; this.isSandbox = isSandbox; } createAuthenticatedUrl() { throw new Error("Method not implemented."); } verifyAuthentication() { return true; } canHandle(request) { const url = new URL(request.url); const action = url.searchParams.get("action"); return request.method === "GET" && action === "portal_data"; } async processAction() { const user = await this.getUser(); if (!user || !("id" in user)) return Response.json(null); return Response.json(await this.repository.retrievePortalDataByUserId({ userId: user.id, endpoint: this.endpoint, primaryLicenseId: null, sandbox: this.isSandbox ?? f