@freemius/sdk
Version:
JS SDK for integrating your SaaS with Freemius
1,439 lines (1,417 loc) • 87.6 kB
JavaScript
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