Subscription & Config domain — plans, features, and end-to-end feature gating
The whole domain reduces to one chain:
A
Featurecatalog is bundled intoPlans; a company holds oneSubscriptionto a plan (+ optional add-ons); theFeatureResolverServicecollapses plan features + add-ons intersected with the tenant whitelist into a flat resolved set; everything downstream (@RequireFeatureguards on the BE, the admin sidebar /FeatureGateon the FE) asks that sethasFeature(companyId, key).Quantity of access is never a stored flag on the company — it is derived per request from
plan ∩ add-ons ∩ tenant-whitelist, cached 5 minutes per company.
Source: BE src/modules/subscription (plan/, feature/, guards/, feature-resolver.service.ts, subscription.*, subscription.seed.ts) + src/modules/tenant-config · Admin src/modules/subscription + pages pages/features.tsx, pages/company-features.tsx, pages/plans/, pages/subscriptions/
1. Entity map
┌──────────────────────────────────────────────┐
│ catalog (platform-defined, company-agnostic) │
└──────────────────────────────────────────────┘
subscription_features (Feature) subscription_plans (Plan)
┌───────────────────────┐ ┌──────────────────────────┐
│ key (unique) │ N ◀────────▶ M│ key (unique), tier │
│ module, category │ plan.features│ pricing{monthly,yearly..} │
│ isAddOn, metadata │ = [FeatureId]│ maxUsers, maxStores │
│ {limitType, │ │ trialDays, isActive │
│ defaultLimit} │ │ features: ObjectId[] ──────┐
└───────────────────────┘ └────────────────────────────┘
▲ ▲ planId
│ addOns[].featureId │
│ ┌─────────────┴──────────────┐
└──────────────────────────────│ subscriptions (Subscription)│ one active per company
│ companyId, planId, status │
│ billingCycle, period, trial │
│ addOns: [{featureId,price}] │
└─────────────────────────────┘
│
resolveCompanyFeatures(companyId) ▼
┌──────────────────────────────────────────────────────┐
tenants │ FeatureResolverService │
(master DB) ──▶│ (plan features ∪ add-on features) ∩ tenant whitelist│──▶ IResolvedFeature[]
enabledFeature │ ↑ enable_all_features dev override │ (cached 5 min)
Keys └──────────────────────────────────────────────────────┘
│
┌──────────────────────────────────┼───────────────────────────┐
▼ BE ▼ FE ▼ FE
@RequireFeature(key) + GqlFeatureGuard sidebar visibility <FeatureGate feature=…>
(throws if not enabled) (module-feature-map) (renders/locks UI)
Three collections (all company-agnostic except subscriptions):
| Collection | Schema | Scope | Role |
|---|---|---|---|
subscription_features |
feature/feature.schema.ts Feature |
global catalog | atomic capability (key), grouped by module, tagged category, optionally an add-on with a metadata limit |
subscription_plans |
plan/plan.schema.ts Plan |
global catalog | a tier (tier 1/2/3) with pricing, maxUsers/maxStores caps, and an array of Feature ObjectId refs |
subscriptions |
subscription.schema.ts Subscription |
per company | one company's active plan + status + billing period + embedded addOns[] |
A fourth shape, tenants.enabledFeatureKeys, lives in the master/platform DB (not a local collection) and is read by TenantConfigService — see ../master-data/config.md §2.2.
Detailed field tables, enums, the resolver algorithm, the guard, and the admin toggle UIs are in
./subscription-features.md. This overview is the map + the end-to-end flows.
2. The catalog at a glance
- Features (
subscription.seed.ts → DEFAULT_FEATURES, ~90 entries) are keyed bySCREAMING_SNAKEstrings (VIEW_DASHBOARD,CREATE_SALES_INVOICE,MULTI_STORE,HR_MODULE, …), each carrying amodulegroup (accounting,sales,inventory,hr,pos,modules, …) and aFeatureCategoryofCORE | ADVANCED | PREMIUM. - A handful are module gates in
module: "modules"(ACCOUNT_MODULE,HR_MODULE,MANUFACTURING_MODULE,PROJECT_MODULE,POS_MODULE,RECRUITMENT_MODULE) — controlled per tenant via the whitelist, deliberately not bundled into any plan (seesubscription.seed.tscomment). - Plans (
DEFAULT_PLANS) are exactly three:STARTER(tier 1),GROWTH(tier 2, = Starter ∪ inventory/multi-store/orders),ENTERPRISE(tier 3, = Growth ∪ workflows/BOM/budgets/manufacturing/projects/advanced-reports). Plan feature sets are strictly nested supersets. - Add-ons are features with
isAddOn: true(MULTI_STORE,VIEW/MANAGE_INVENTORY,ADVANCED_REPORTS,VIEW/MANAGE_WORKFLOWS) that a subscription can switch on individually viaaddOns[]regardless of plan.
3. The resolution rule (the one thing to get right)
FeatureResolverService.resolveCompanyFeatures(companyId) (feature-resolver.service.ts) produces the per-company IResolvedFeature[]. Priority order:
- Dev override —
enable_all_features === "true"→ every catalog feature,source: "override",maxUsers/maxStores: 9999. Bypasses plan, DB, and tenant whitelist entirely. - Else branch on
development_mode(env, default"dedicated"):dedicated(tenant-hosted) → all catalog features,source: "tenant". No subscription lookup at all.shared(SaaS) → look up the company's active subscription → union of plan features (source: "plan") and add-on features (source: "addon"), carrying the plan'smaxUsers/maxStores.
- Tenant whitelist intersection (both modes) — read
TenantConfigService.getEnabledFeatureKeys(). Tri-state:null(no tenant config) and[](configured, empty) both mean unrestricted; a non-empty array keeps only features whosekeyis in it. Final set = (catalog | plan∪addons) ∩ whitelist. - Result cached per company for 5 minutes (LRU, max 1000). Any subscription mutation calls
invalidateCache(companyId); a platform whitelist change callsinvalidateAllCache()via the master-secret cache-clear endpoint.
This is the same intersection documented from the config side in ../master-data/config.md §4.3 — that doc owns the tenant-whitelist/limits half, this domain owns the plan/feature/add-on half.
4. End-to-end flows
4.1 Provision a subscription (SaaS / shared mode)
- Admin (super-admin) opens the subscriptions page →
createSubscription({ companyId, planId, billingCycle, startTrial }). SubscriptionService.createSubscriptionrejects if the company already has anACTIVE/TRIALINGsubscription; validates the plan exists andisActive.- Sets
status = TRIALING(ifstartTrial) elseACTIVE; computescurrentPeriodStart/EndfrombillingCycle(MONTHLY= +1 month,YEARLY= +1 year); setstrialEnd = now + plan.trialDayswhen trialing. - Persists, then
featureResolverSvc.invalidateCache(companyId)so the next feature check reflects the new plan.
4.2 Resolve features on a guarded request (the gate)
- A resolver method is annotated
@UseGuards(GqlFeatureGuard) @RequireFeature("HR_MODULE")(or@RequireFeatures(a, b)= ALL required). GqlFeatureGuard.canActivatereads the metadata. SuperAdmin (isInAdminGroup) bypasses all feature checks. No metadata → allow.- Resolves
companyIdfrom context; if absent, falls back to looking up theemployeescollection byemployeeId/userId. - For each required key →
featureResolverSvc.hasFeature(companyId, key)→resolveCompanyFeatures(override → mode → whitelist → cache). Any missing key throwsThe feature "<key>" is not enabled for your account. Contact your administrator.
4.3 Toggle an add-on / change plan
addSubscriptionAddOn({ subscriptionId, featureId, price })pushes{featureId, price, activatedAt}ontoaddOns[](rejecting duplicates);removeSubscriptionAddOnfilters it out;changePlan(subscriptionId, planId)swapsplanId.- Each mutation calls
invalidateCache(companyId)→ next resolution picks up the new feature set.
4.4 Author the catalog (platform admin)
/featurespage lists the resolved/catalog features grouped by module with category badges (read-only view of what's enabled).- Plan editing pages call
createPlan/updatePlan/setPlanFeatures/addFeatureToPlan/removeFeatureFromPlan; feature CRUD viacreateFeature/updateFeature/deleteFeature. - On boot,
SubscriptionSeedService.seed()idempotently upsertsDEFAULT_FEATURESthenDEFAULT_PLANS(resolving planfeatureKeys→ feature_ids), skipping anything already present bykey.
4.5 Migrating legacy Company.enabledModules (one-off)
migrate-enabled-modules.ts maps each company's old enabledModules[] → best-fit plan (by feature-coverage score), creates a Subscription, writes CompanyFeature override rows for features the plan doesn't cover, and unsets enabledModules. Caveat: CompanyFeature is written by this script but is not a registered schema in the subscription module and is not read by FeatureResolverService — see ./subscription-features.md §9.
5. Shared enums
// subscription.schema.ts
enum SubscriptionStatus { ACTIVE, TRIALING, PAST_DUE, CANCELLED, EXPIRED }
enum BillingCycle { MONTHLY, YEARLY }
// feature/feature.schema.ts
enum FeatureCategory { CORE, ADVANCED, PREMIUM }
// feature.interface.ts IResolvedFeature.source
type FeatureSource = "plan" | "addon" | "override" | "tenant";6. Permissions & cross-links
- Subscription/plan/feature resolvers are all
@ApGqlAuthorize({ ignoreCompanyQuery: true })(platform/super-admin scope, not company-scoped) and audited (@AuditMeta module: subscription|plan|feature). See../../platform/permissions-access.mdand../../platform/audit-trail.md. - The tenant whitelist + limits half of gating (
maxCompanies/maxUserscaps,enabledFeatureKeys, the master-secret cache-clear endpoint) is documented in../master-data/config.md. - Multi-tenant DB split and
tenant_keyboot wiring:../../platform/multi-tenancy.md. - Full data model, API surface, guard internals, resolver algorithm, and admin toggle UIs:
./subscription-features.md.