Subscription & Config domain — plans, features, and end-to-end feature gating

The whole domain reduces to one chain:

A Feature catalog is bundled into Plans; a company holds one Subscription to a plan (+ optional add-ons); the FeatureResolverService collapses plan features + add-ons intersected with the tenant whitelist into a flat resolved set; everything downstream (@RequireFeature guards on the BE, the admin sidebar / FeatureGate on the FE) asks that set hasFeature(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 by SCREAMING_SNAKE strings (VIEW_DASHBOARD, CREATE_SALES_INVOICE, MULTI_STORE, HR_MODULE, …), each carrying a module group (accounting, sales, inventory, hr, pos, modules, …) and a FeatureCategory of CORE | 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 (see subscription.seed.ts comment).
  • 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 via addOns[] 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:

  1. Dev overrideenable_all_features === "true" → every catalog feature, source: "override", maxUsers/maxStores: 9999. Bypasses plan, DB, and tenant whitelist entirely.
  2. 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's maxUsers/maxStores.
  3. 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 whose key is in it. Final set = (catalog | plan∪addons) ∩ whitelist.
  4. Result cached per company for 5 minutes (LRU, max 1000). Any subscription mutation calls invalidateCache(companyId); a platform whitelist change calls invalidateAllCache() 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)

  1. Admin (super-admin) opens the subscriptions page → createSubscription({ companyId, planId, billingCycle, startTrial }).
  2. SubscriptionService.createSubscription rejects if the company already has an ACTIVE/TRIALING subscription; validates the plan exists and isActive.
  3. Sets status = TRIALING (if startTrial) else ACTIVE; computes currentPeriodStart/End from billingCycle (MONTHLY = +1 month, YEARLY = +1 year); sets trialEnd = now + plan.trialDays when trialing.
  4. Persists, then featureResolverSvc.invalidateCache(companyId) so the next feature check reflects the new plan.

4.2 Resolve features on a guarded request (the gate)

  1. A resolver method is annotated @UseGuards(GqlFeatureGuard) @RequireFeature("HR_MODULE") (or @RequireFeatures(a, b) = ALL required).
  2. GqlFeatureGuard.canActivate reads the metadata. SuperAdmin (isInAdminGroup) bypasses all feature checks. No metadata → allow.
  3. Resolves companyId from context; if absent, falls back to looking up the employees collection by employeeId/userId.
  4. For each required key → featureResolverSvc.hasFeature(companyId, key)resolveCompanyFeatures (override → mode → whitelist → cache). Any missing key throws The feature "<key>" is not enabled for your account. Contact your administrator.

4.3 Toggle an add-on / change plan

  1. addSubscriptionAddOn({ subscriptionId, featureId, price }) pushes {featureId, price, activatedAt} onto addOns[] (rejecting duplicates); removeSubscriptionAddOn filters it out; changePlan(subscriptionId, planId) swaps planId.
  2. Each mutation calls invalidateCache(companyId) → next resolution picks up the new feature set.

4.4 Author the catalog (platform admin)

  1. /features page lists the resolved/catalog features grouped by module with category badges (read-only view of what's enabled).
  2. Plan editing pages call createPlan/updatePlan/setPlanFeatures/addFeatureToPlan/removeFeatureFromPlan; feature CRUD via createFeature/updateFeature/deleteFeature.
  3. On boot, SubscriptionSeedService.seed() idempotently upserts DEFAULT_FEATURES then DEFAULT_PLANS (resolving plan featureKeys → feature _ids), skipping anything already present by key.

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";