User guide
Plans and Entitlements
Looking for what it does rather than how to use it? Read the Plans and limits overview .
Last verified against the codebase: 2026-07-27. See “How this document is verified” at the end.
What it is
A plan (a ServiceTier) is a priced bundle of feature flags and numeric caps. An entitlement is what one account is allowed to do because of the plan it is on.
Every gate in Solidlio reads the same two JSON blobs on the plan the account resolves to — features and limits — so what a salesperson sells, what the platform admin edits, and what the API enforces are one value.
Concepts
| Concept | What it is |
|---|---|
| Service tier | One row of the plan catalogue: name, prices, seat rules, features JSON, limits JSON. Ten are seeded. |
| Tier group | MSP_IT_PARTNER or end customer. An account may only subscribe within its own group. |
| Feature flag | A key in features. Fifteen exist. Some are booleans, eight are graduated ladders, two are string arrays. |
| Ladder | An ordered list of rungs for a graduated flag, e.g. changeManagement: VIEW_ONLY < BASIC < CAB < ADVANCED. A gate asks for a minimum rung. |
| Limit | A numeric cap in limits. Twelve exist. -1 means unlimited, 0 means none. |
| Entitlement source | Which plan answered: the account’s own, or the managing MSP’s. |
| Upgrade envelope | The structured upsell a blocked call returns instead of a bare refusal — which tier lifts the block, its price, and whether this caller may buy it. |
| Free floor | The zero-price tier an account drops to when its subscription stops paying: tier_msp_free or tier_customer_free. |
| Entitlement caps | Three limits (maxOrganizations, maxUsersPerOrg, maxNestedDepth) that are also denormalised onto Account columns, because they are checked on hot paths. |
The shared gate helpers are in @solidlio/database — featureSatisfies, ladderLevel, buildUpgradeEnvelope, buildLimitUpgradeEnvelope, applyTierToAccount.
The two plan families
An account is either an MSP/IT partner or an end customer, and each has its own five-rung ladder. The groups are not interchangeable: subscribing across groups is refused with TIER_GROUP_MISMATCH.
| MSP / IT partner | End customer |
|---|---|
MSP_FREE | CUSTOMER_FREE |
MSP_STARTER | CUSTOMER_ESSENTIALS |
MSP_GROWTH | CUSTOMER_PROFESSIONAL |
MSP_SCALE | CUSTOMER_BUSINESS |
MSP_ENTERPRISE | CUSTOMER_ENTERPRISE |
Pricing shape. Free tiers are flat and free. Enterprise tiers are CUSTOM — no self-serve price, quote only. The three middle tiers in each group are HYBRID: a base platform fee plus a per-seat price for every seat beyond the included one.
monthly total = base + max(0, seats − includedSeats) × perSeatMonthly
Annual billing quotes a lower monthly-equivalent (for example MSP_GROWTH is $199/mo billed monthly, $165/mo billed annually).
Currency. The tier row holds the CAD price. A non-CAD price lives in a ServiceTierPrice row per currency. A tier with no row for the account’s currency is returned with priced: false and null prices rather than the CAD figure, and platform billing holds such an invoice as DRAFT instead of charging a number nobody agreed to.
Roles and permissions
| Action | Who |
|---|---|
| Read your own entitlements | Any authenticated user, no role floor |
| Read the in-app plan catalogue | organization administrator+ |
| See upgrade options and prices | organization administrator+ |
| Buy an upgrade | organization administrator+ (MSP portal: the upsell CTA appears at MSP administrator+) |
| Subscribe / change plan | organization administrator+ |
| Create, edit, price or delete a tier | platform administrator only |
| Assign a tier directly to an account | platform administrator only |
Two deliberate choices are worth calling out.
It describes what the account bought, not what the caller may do, and every role renders plan-gated UI — a CUSTOMER needs to know the change module is unavailable exactly as much as an organization administrator does. A floor here would blank the signal for non-admins and make the interface fail open on paid features. Note also that MSP technician ranks above organization administrator in the hierarchy, so the intuitive-looking floor would lock out the entire organization portal.
Self-serve is decided by the server. canSelfServe in the upgrade envelope is MSP administrator+ on the MSP portal and organization administrator+ everywhere else. The envelope is returned either way, so a user below the threshold still sees which plan unlocks the feature and is told to ask an administrator.
Managed clients buy their own plans. An MSP-managed client organization is its own tenant with its own account. Its admins use the same upgrade flow on their own account; the MSP does not have to broker it.
Walkthrough — choosing a plan at signup
- Finish signup. You land on Choose your plan (
/onboarding/plan). The plan family shown follows the portal you signed up into: MSP partners see the MSP ladder, businesses see the customer ladder. - Compare. Each card shows the base price, the per-seat price, included seats, the organization cap and the monthly AI credit allowance. Compare plans opens the full grid.
- Pick seats. For hybrid tiers, choose the seat count. The estimate updates as
base + extra seats × per-seat. - Choose monthly or annual. Annual quotes the discounted monthly equivalent and bills for twelve months.
- Confirm. Paid tiers with a trial start the trial (14 days on every paid non-Enterprise tier); paid tiers without one generate the first invoice immediately.
- Enterprise. Enterprise tiles route to Contact sales — they carry no self-serve price and cannot be subscribed to through the API.
If you are not an administrator, the page tells you plan changes need an administrator rather than showing a load error.
Walkthrough — upgrading
Upgrades are immediate and prorated. Downgrades are not self-serve.
- Open the upgrade surface. Either Manage your plan (
/onboarding/plan), or the upgrade prompt that appears in place wherever a plan gate blocked you. - Pick the target tier. Only higher-ranked tiers in your own group are offered, and Enterprise is excluded because it needs a quote.
- Check the prorated amount. You are charged the difference between the two tiers for the remaining fraction of the current billing period:
round((newAmount − oldAmount) × fractionRemaining). With no active paid period (trial or free tier) the whole difference is charged. A lateral or downward move charges nothing. - Pay. If a saved card can be charged off-session, the upgrade finalises inline. Otherwise you complete the payment and the upgrade confirms.
- Entitlements apply immediately. The new tier’s caps are pushed onto the account in the same transaction, so the feature you were blocked on works without waiting for a billing cycle.
Downgrading. Choosing a lower tier opens a support-contact panel. Downgrades are applied at the next renewal so you keep what you have paid for.
Walkthrough — editing a tier (platform admin)
Platform → Tiers (/platform/tiers).
- Pick a tier or create one. Each card shows price, seats, feature badges and limits, plus how many accounts and subscriptions are on it.
- Edit features. Booleans are toggles; graduated flags are dropdowns whose options are exactly the rungs the gates compare against.
- Edit limits. Every numeric cap. Two are labelled not enforced —
maxStorageGbandmaxContactsare stored and displayed but no service reads them. Do not sell them as caps. - Set non-CAD prices. Per-currency rows for USD and EUR. A tier with no row for a currency is shown as unpriced there and its invoices hold as DRAFT.
- Save. A
PUTmerges — keys you did not send are left alone. Every create, update, delete and price change writes aPlatformAuditLogentry with the before and after state.
Two safety rules are enforced for you:
includedSeatsandminSeatsmay not exceedmaxSeats, checked against the tier as it will be after the patch.- A tier in use by any account or subscription cannot be deleted.
⚠️ Re-running db:seed:reference overwrites features and limits on the ten seeded tiers. Tier tuning done here is not preserved across a reference re-seed. Per-currency prices are upserted, so only the currencies the seed declares are overwritten.
How a gate decides
Every plan gate follows the same four steps.
- Is this the platform account or a platform admin? If so, no gate applies. The platform account carries no tier at all and must never be plan-gated.
- Resolve the tier. The account’s own
serviceTierif it has one, otherwise the managing MSP’s tier. A managed client frequently carries no plan of its own and is entitled by its MSP’s subscription. - Compare. Booleans compare
=== true. Ladders compare rung indexes, so"CAB"satisfies a requirement of"BASIC". Arrays check membership. Numeric caps compare the current count against the cap, with-1meaning unlimited. - On a block, build the envelope and answer 403.
Gates block new work only. Reads, deletes and edits to existing records are not gated. A downgrade never makes existing data disappear or become unmanageable — it stops you creating more.
When no tier resolves. Most gates fail open: they allow the action rather than refuse an entitlement they could not confirm. The exceptions, which fail closed, are changeManagement, projectManagement, customBranding, whiteLabel, catalogControl, aiFeatures and maxAiCredits (the last drops to the free-floor credit allowance rather than to zero or to unlimited).
The upgrade envelope
A blocked call returns HTTP 403 with this body:
{
"error": {
"code": "FEATURE_NOT_ON_PLAN",
"message": "SLA management isn't included in your current plan.",
"upgrade": {
"feature": "slaManagement",
"requiredValue": "BASIC",
"currentTier": "CUSTOMER_FREE",
"requiredTier": "CUSTOMER_PROFESSIONAL",
"requiredTierDisplayName": "Professional",
"monthlyPriceCents": 10900,
"yearlyPriceCents": 8900,
"canSelfServe": true
}
}
}
The target tier is computed from the live catalogue — the cheapest tier in the caller’s own group whose features satisfy the requirement — so a platform-admin price or feature edit changes the offer immediately.
Codes.
| Code | Meaning |
|---|---|
FEATURE_NOT_ON_PLAN | The plan lacks the feature, or holds too low a rung. |
PLAN_LIMIT | A numeric cap is full (organizations, nesting, assets, contracts, active projects, technician seats, sending domains). |
SEAT_LIMIT | The per-organization user seat cap is full. |
AI_FEATURE_NOT_ON_PLAN | The plan’s aiFeatures allowlist excludes this AI capability. |
All four are HTTP 403 with the same body shape. AI credit exhaustion is different: it answers 402 AI_CREDIT_LIMIT_EXCEEDED and carries no envelope, because buying credits is not the same decision as changing plan.
Limits — every one, and what enforces it
Twelve keys. -1 is unlimited, 0 is none.
| Limit | Counts | Enforced by |
|---|---|---|
maxStorageGb | — | ❌ Nothing. No upload path reads it. |
maxContacts | — | ❌ Nothing. Contact creation is capped by maxUsersPerOrg. |
Two counting rules worth knowing: a cancelled or expired contract releases its slot, and a completed project releases its slot. The cap is on what you have, not what you have ever had.
Plan tiers
The summary below is the same data.
MSP / IT partner plans
| Free | Starter | Growth | Scale | Enterprise | |
|---|---|---|---|---|---|
| Base / month (CAD) | $0 | $109 | $199 | $339 | Custom |
| Per tech / month | — | $39 | $55 | $69 | Custom |
| Max seats | 1 | 10 | 25 | Unlimited | Unlimited |
| Client organizations | 2 | 15 | 50 | Unlimited | Unlimited |
| Users per org | 5 | 15 | 25 | 50 | Unlimited |
| Org nesting depth | 0 | 0 | 1 | 2 | 5 |
| AI credits / month | 50 | 1,000 | 5,000 | 15,000 | Unlimited |
| Assets | 50 | 1,000 | 5,000 | 25,000 | Unlimited |
| Contracts | 0 | 10 | Unlimited | Unlimited | Unlimited |
| Active projects | 0 | 5 | 15 | Unlimited | Unlimited |
| Sending domains | 1 | 2 | 5 | 10 | Unlimited |
| Custom domains | 0 | 0 | 0 | 5 | Unlimited |
| Change management | — | View | Basic | CAB | Advanced |
| SLA management | — | Basic | Advanced | Advanced | Advanced |
| Project management | — | ● | ● | ● | ● |
| Email integration | — | — | M365 | + IMAP | + IMAP |
| Branding | — | — | — | Logo only | Full |
| White-label | — | — | — | — | ● |
| SSO (Entra ID / Google) | — | — | — | — | ● |
| API access | — | — | Read | Full | Full |
| Roll-up reporting | — | — | — | Basic | Full |
| Catalog control | — | Limited | Full | Full | Full |
| Commission accrual | — | ● | ● | ● | ● |
End-customer plans
| Free | Essentials | Professional | Business | Enterprise | |
|---|---|---|---|---|---|
| Base / month (CAD) | $0 | $55 | $109 | $199 | Custom |
| Per agent / month | — | $25 | $39 | $55 | Custom |
| Max seats | 1 | 5 | 25 | Unlimited | Unlimited |
| Organizations | 1 | 1 | 3 | 10 | Unlimited |
| Users per org | 3 | 10 | 25 | 50 | Unlimited |
| Org nesting depth | 0 | 0 | 0 | 1 | 3 |
| AI credits / month | 25 | 500 | 2,500 | 10,000 | Unlimited |
| Assets | 25 | 500 | 2,500 | 10,000 | Unlimited |
| Contracts | 0 | 0 | 5 | Unlimited | Unlimited |
| Active projects | 0 | 0 | 10 | Unlimited | Unlimited |
| Sending domains | 1 | 1 | 3 | 5 | Unlimited |
| Custom domains | 0 | 0 | 0 | 1 | Unlimited |
| Change management | — | View | Basic | CAB | Advanced |
| SLA management | — | — | Basic | Advanced | Advanced |
| Project management | — | — | ● | ● | ● |
| Email integration | — | — | M365 | + IMAP | + IMAP |
| Branding | — | — | — | Logo only | Full |
| White-label | — | — | — | — | ● |
| SSO (Entra ID / Google) | — | — | — | — | ● |
| API access | — | — | Read | Full | Full |
| Roll-up reporting | — | — | — | Basic | Full |
Commission accrual is an MSP-plan concept and is off on every customer plan. Catalog control likewise does not appear on customer plans.
Trials. Every paid non-Enterprise tier carries a 14-day trial. Free and Enterprise tiers carry none.
Downgrades, cancellation and the free floor
When a subscription moves to CANCELED, PAST_DUE or SUSPENDED, the account is re-gated to its free floor tier — tier_msp_free for MSP and platform tenants, tier_customer_free for everyone else. Both serviceTierId and the cap columns move together, so features and caps can never disagree: a cancellation cannot leave an account reading paid features off a stale tier pointer.
Nothing is deleted. An account that drops from Scale to Free keeps its organizations, assets and contracts; it simply cannot create more until it is back over the cap. Gates block new work only.
Entitlement caps on the account record
Three limits are also stored as columns on Account: maxOrganizations, maxUsersPerOrg, maxNestedDepth. They are checked on hot paths (every user create, every SSO sign-in that auto-provisions) where a join to the tier row would be wasteful.
applyTierToAccount in @solidlio/database is the only place those columns are written, and it is called from every path that changes a plan: signup, subscribe, upgrade, cancel and MSP client provisioning. Rules it applies:
- Caps come from the tier’s
limits, falling back to the schema defaults (1 organization, 5 users, 0 nesting) when a key is missing or malformed — deliberately not unlimited, so malformed tier data can never grant more than it should. - On a per-seat tier, the purchased seat count overrides the tier’s
maxUsersPerOrgceiling — except on a tier whose ceiling is already unlimited, which stays unlimited. - On a re-gate status the floor tier’s caps are applied instead.
It exists for rows that drifted before applyTierToAccount existed.
Troubleshooting
“This plan requires a custom quote — contact sales” You tried to upgrade to an Enterprise tier. Those have no self-serve price and must be quoted. The API rejects them before any proration is computed.
“This plan requires sales-assisted setup; please contact sales to subscribe” The same rule on the subscribe path (CUSTOM_TIER_CONTACT_SALES).
“This account can only subscribe to end customer tiers” TIER_GROUP_MISMATCH — an end-customer account cannot buy an MSP plan or the reverse. Which group applies is derived from the account’s platform type.
“seats must be between 1 and 10” SEATS_OUT_OF_RANGE. The requested seat count is outside the tier’s minSeats/maxSeats. Move up a tier for a higher seat ceiling.
“Selected plan is not an upgrade” The target is the same rank or lower, or is in a different tier group. Downgrades go through support.
“This account has reached its organization limit for the current plan” PLAN_LIMIT on maxOrganizations. Deactivate an organization or upgrade.
“This organization has reached its user seat limit for the current plan” SEAT_LIMIT on maxUsersPerOrg. Note this is per organization, not per account. On a per-seat tier it reflects the seats you actually purchased, not the tier’s ceiling — buying more seats raises it without changing plan.
“Your plan allows a maximum of 50 asset(s). Upgrade to add more.” PLAN_LIMIT on maxAssets. A bulk import is checked against the whole batch, so a 10,000-row CSV is refused up front rather than half-applied.
“Your plan does not include contracts. Upgrade to add contracts.” maxContracts is 0 on the Free and Essentials tiers. Cancelled and expired contracts do not consume quota.
“Custom domains are not included in your current plan. Upgrade to add one.” maxCustomDomains is 0 below Scale (MSP) and Business (customer).
“Change management tier “BASIC” required. Current tier: “VIEW_ONLY”.” The plan can read changes but not create them. BASIC is the first rung that allows writes.
“Project management isn’t included in your current plan.” projectManagement is off. It is on from Starter (MSP) and Professional (customer).
“Payment not completed (status: requires_payment_method)” Upgrade confirmation ran before the payment succeeded. Complete the payment and retry; the order is reused rather than duplicated.
A platform admin edited a tier and nothing changed for existing accounts. Feature flags are read live, so those take effect at once. The three denormalised caps (maxOrganizations, maxUsersPerOrg, maxNestedDepth) are copied onto the account when its plan changes; run db:backfill:entitlement-caps to push a tier’s edited caps onto accounts that are already on it.
Limits and known behaviour
prioritySupport,maxStorageGbandmaxContactsare not enforced by any code. They are catalogue metadata. Storage enforcement needs usage accounting that does not exist yet; contacts are governed by the seat cap.- An account whose plan grants no
apiAccessat all cannot use a key. o365_calendaris never checked in the integrations allowlist. Every tier that grants it also grantso365_email, which is checked on the single Microsoft 365 connect, so the outcome is the same today.- The Sol assistant path meters credits without consulting the allowlist.
- Downgrades are not self-serve by design, so an account cannot drop below what it has already consumed without a conversation.
- The public pricing page’s comparison grid is static copy. Prices, seats and limits come from the live catalogue, but the long feature-comparison table underneath is maintained by hand and can drift from a tier edit. There is a development-mode warning; there is no production signal.
- Test gating against a real tenant.
db:seed:referenceoverwrites tier features and limits. Deliberate — it is how the catalogue ships — but it means runtime tier tuning is not durable across a reference re-seed.