Gate features and limits by plan
Declare plans in main.yaml, refuse paid features and over-limit actions with an upgrade hint, and change a tenant's plan from a verified payment event.
Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
You will declare a free and a pro plan, block one feature and one limit on free, and test both. It takes about 15 minutes.
You need
- A MainSDK app with
tenancyandpay(see take a payment).
1. Declare the plans
plans:
free: { features: [basic], limits: { seats: 3, exports: 1 } }
pro: { features: [basic, export], limits: { seats: 50, exports: 100 }, prices: [price_pro_monthly] }
entitlements:
default: free2. Refuse a paid feature
import { entitlements } from "mainsdk";
declare function buildExport(): Promise<unknown>; // your own code
export async function POST() {
entitlements.require("export"); // throws MAIN-PLAN-0001 (403) with an upgrade hint on free
return Response.json(await buildExport());
}To show an upgrade prompt instead of an error, branch on entitlements.has("export").
3. Enforce a limit
import { entitlements } from "mainsdk";
declare function buildExport(): Promise<unknown>; // your own code
await entitlements.within("exports", 1, () => buildExport()); // throws MAIN-PLAN-0002 at the limit
const { limit, used, remaining } = await entitlements.limit("exports");With a function, within runs it under a lock so two requests cannot both pass the limit.
4. Change a plan
A verified subscription.updated event from pay sets the plan when its price is listed under a plan's prices and metadata.tenant names the tenant. For a trial or a sales deal:
import { entitlements, tenant } from "mainsdk";
const tenantId = "tnt_acme";
await tenant.system("sales deal", async () => {
await entitlements.set(tenantId, "pro", { reason: "annual deal", trial: "14d" });
});5. Test it
import { it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { entitlements } from "mainsdk";
it("refuses a fourth seat on free", async () => {
const app = createTestApp({ main: "./main.yaml" });
await app.asTenant({ id: "tnt_free" }, async () => {
await app.entitlements.setUsage("tnt_free", "seats", 3);
await assert.rejects(entitlements.within("seats", 1), { code: "MAIN-PLAN-0002" });
});
});Check it worked
Run npx mainsdk check: it reports a feature or limit no plan declares as MAIN-PLAN-0010. The test above passes, and entitlements.has("export") is true inside app.asTenant({ id, plan: "pro" }).
Troubleshooting
MAIN-PLAN-0005in the log: a subscription event named a price under no plan, or carried no tenant. Add the price underplans.<name>.pricesand setmetadata.tenant.MAIN-PLAN-0007: a plan change for another tenant outsidetenant.system().MAIN-PLAN-0008:limitorwithinnamed something no plan declares.
See the entitlements interface.
Was this page helpful?