Plans
Plans define the pricing tiers available on the platform. Each plan sets the limits and features available to accounts subscribed to it — API call limits, scan limits, and feature flags.
Plan structure
Each plan row in the database contains:
| Field | Description |
|---|---|
name | Display name (e.g. Free, Pro, Business) |
monthly_price | Pricing in cents |
lemonsqueezy_product_id | Lemon Squeezy product ID |
lemonsqueezy_monthly_variant_id | Monthly billing variant ID |
stripe_monthly_price_id | Stripe monthly price ID |
| Feature limit columns | API call limits, scan limits, etc. |
The monthly variant/price columns are also how an incoming webhook is matched
back to a plan when it carries no plan_id in its metadata, so fill them in
for every paid plan you sell.
API endpoints (user-facing)
| Method | Endpoint | Description |
|---|---|---|
GET | /plans | List all available plans |
POST | /upgrade | Upgrade or change the account's plan |
API endpoints (master)
| Method | Endpoint | Description |
|---|---|---|
GET | /master/plans | List all plans with account counts |
POST | /master/plans | Create a new plan |
PUT | /master/plans/:id | Update a plan's limits or pricing IDs |
DELETE | /master/plans/:id | Delete a plan |
Populating plans
After running migrations, insert your pricing tiers with the corresponding payment gateway IDs. See Lemon Squeezy Setup for how to link variant IDs.
Plan history
plan_history is a versioned snapshot of each plan's pricing and limits, not a
per-account log. Editing a plan closes the current snapshot (is_current = false,
effective_to set) and opens a new one, so accounts already billing against the
old snapshot keep the limits they signed up for.
Quota enforcement always reads the snapshot referenced by the account's billing
cycle (app_usage.plan_history_id), never the live plans row.
Exactly one row per plan should have is_current = true. The lookups that resolve
a plan's limits take the first match without ordering, so duplicates make quotas
non-deterministic. Always close the previous snapshot before opening a new one —
planHistory.markAllCurrentAsSuperseded() then planHistory.createFromPlan(), as
model/masterPlans.js does.