Subscription Features — plans, feature catalog, the resolver/guard, and admin toggles

The whole module reduces to: a flat Feature catalog, bundled into nested Plan tiers, attached to a company via one Subscription (+ embedded add-ons), then collapsed per-request by FeatureResolverService into a resolved set that BE guards (@RequireFeature) and FE gates (FeatureGate, sidebar filter, SSR route guard) consult. Access is derived, never stored on the company.

Source: BE src/modules/subscription/{plan,feature,guards}/, feature-resolver.service.ts, subscription.{schema,service,repository,resolver,dto}.ts, subscription.seed.ts, migrate-enabled-modules.ts · Admin src/modules/subscription/{context.tsx,FeatureGate.tsx,module-feature-map.ts,featuresPage.tsx,plansPage.tsx,planSettingsPage.tsx,planMappingPage.tsx,subscriptionPage.tsx,gql/}, pages pages/features.tsx, pages/company-features.tsx, pages/setup/{plans,plan-settings,plan-mapping,subscriptions}.tsx, components/navbar/module-filter.ts, guard.tsx

See ./_overview.md for the entity map and end-to-end flows; this doc is the rebuild-grade detail. The tenant-whitelist + numeric-limits half of gating lives in ../master-data/config.md.

1. Purpose & scope

Owns the commercial capability model: what capabilities (Feature) exist, how they package into Plans, which Plan (+ add-ons) a company holds (Subscription), and the resolver that turns all that into a per-company boolean/limit map. It also owns the BE @RequireFeature guard and the admin authoring/assignment UIs.

It does NOT:

  • Decide tenant-wide whitelists or maxCompanies/maxUsers caps — that is TenantConfigService (../master-data/config.md). The resolver only intersects with that whitelist.
  • Do RBAC. Feature gating is orthogonal to permissions; a user needs both the permission (RBAC) and the feature enabled. See ../../platform/permissions-access.md.
  • Handle billing/payments. externalId/externalProvider exist as hooks but no provider integration is implemented (no Stripe webhook, no invoicing).

2. Data model

2.1 Collection: subscription_features (feature/feature.schema.ts) — Feature

The atomic capability catalog. Company-agnostic (seeded with ignoreCompanyId: true).

field type required description
key string yes unique SCREAMING_SNAKE capability key (VIEW_DASHBOARD, CREATE_SALES_INVOICE, MULTI_STORE, HR_MODULE). The token guards/UI check.
name string yes human label
description string no optional
module string yes grouping bucket (accounting, sales, purchases, inventory, hr, pos, reports, modules, admin, …) — used for UI grouping and the sidebar map
category FeatureCategory no CORE/ADVANCED/PREMIUM (default CORE) — badge only, no gating effect
isAddOn boolean no default false; true = can be activated à la carte on a subscription
metadata { limitType?: string; defaultLimit?: number } no numeric limit carrier; defaultLimit becomes IResolvedFeature.limit
export enum FeatureCategory { CORE = "CORE", ADVANCED = "ADVANCED", PREMIUM = "PREMIUM" }

@ApSchema({ collection: "subscription_features", timestamps: true })
export class Feature extends BaseSchema {
  @Prop({ required: true, unique: true }) key: string;
  @Prop({ required: true }) name: string;
  @Prop() description: string;
  @Prop({ required: true }) module: string;
  @Prop({ type: String, enum: FeatureCategory, default: FeatureCategory.CORE }) category: string;
  @Prop({ default: false }) isAddOn: boolean;
  @Prop({ type: Object, default: {} }) metadata: { limitType?: string; defaultLimit?: number };
}

Soft-delete via mongoose-delete (deletedAt). Only two features carry metadata in the seed: MANAGE_USERS (max_users, default 3) and MULTI_STORE (max_stores, default 1).

2.2 Collection: subscription_plans (plan/plan.schema.ts) — Plan

A priced tier referencing a set of features by ObjectId.

field type required description
key string yes unique (STARTER/GROWTH/ENTERPRISE)
name string yes display name
description string no marketing copy
tier number yes ordering rank (1/2/3)
pricing PlanPricing (embedded) no { monthly, yearly, currency='USD', perUser, perUserMonthly, perUserYearly }amounts in cents (UI divides by 100)
maxUsers number no default 0 = unlimited; surfaced as plan cap (0→"Unlimited")
maxStores number no default 0 = unlimited
trialDays number no default 14
isActive boolean no default true; inactive plans can't be newly subscribed
features ObjectId[]Feature no the bundled feature refs (populated by findWithFeatures)
@ApSchema({ collection: "subscription_plans", timestamps: true })
export class Plan extends BaseSchema {
  @Prop({ required: true, unique: true }) key: string;
  @Prop({ required: true }) name: string;
  @Prop({ required: true }) tier: number;
  @Prop({ type: PlanPricing, default: {} }) pricing: PlanPricing;  // monthly/yearly in cents
  @Prop({ default: 0 }) maxUsers: number;   // 0 = unlimited
  @Prop({ default: 0 }) maxStores: number;  // 0 = unlimited
  @Prop({ default: 14 }) trialDays: number;
  @Prop({ default: true }) isActive: boolean;
  @Prop({ type: [{ type: Types.ObjectId, ref: "Feature" }], default: [] }) features: Types.ObjectId[];
}

PlanPricing is stored in its own collection name subscription_plan_pricing with _id: false (embedded subdoc). Soft-delete enabled.

2.3 Collection: subscriptions (subscription.schema.ts) — Subscription

One per company (enforced in service logic, not a unique index). GraphQL type is CompanySubscription.

field type required description
companyId ObjectId yes indexed; the owning company
planId ObjectIdPlan yes the subscribed plan
status SubscriptionStatus default TRIALING
billingCycle BillingCycle default MONTHLY
currentPeriodStart / currentPeriodEnd number (unix) billing window
trialEnd number (unix) set when startTrial
cancelledAt number (unix) set on cancel
externalId / externalProvider string payment-provider hooks (unused)
addOns SubscriptionAddOn[] (embedded) default []; each { featureId→Feature, price=0, activatedAt }
export enum SubscriptionStatus { ACTIVE, TRIALING, PAST_DUE, CANCELLED, EXPIRED }
export enum BillingCycle { MONTHLY, YEARLY }

@ApSchema({ _id: false })
export class SubscriptionAddOn {
  @Prop({ type: Types.ObjectId, ref: "Feature", required: true }) featureId: Types.ObjectId;
  @Prop({ default: 0 }) price: number;
  @Prop() activatedAt: number;
}

@ApSchema({ collection: "subscriptions", timestamps: true })
export class Subscription extends BaseSchema {
  @Prop({ required: true, index: true }) companyId: Types.ObjectId;
  @Prop({ required: true, ref: "Plan" }) planId: Types.ObjectId;
  @Prop({ enum: SubscriptionStatus, default: SubscriptionStatus.TRIALING }) status: string;
  @Prop({ enum: BillingCycle, default: BillingCycle.MONTHLY }) billingCycle: string;
  @Prop() currentPeriodStart; currentPeriodEnd; trialEnd; cancelledAt: number;
  @Prop({ type: [SubscriptionAddOnSchema], default: [] }) addOns: SubscriptionAddOn[];
}
// Compound index — every feature-gated request queries by companyId+status:
SubscriptionSchema.index({ companyId: 1, status: 1 });

Soft-delete enabled. The compound { companyId: 1, status: 1 } index backs findActiveByCompanyId.

2.4 CompanyFeature — referenced but NOT implemented as a schema

migrate-enabled-modules.ts writes per-company override rows via getModelToken('CompanyFeature'), but no CompanyFeature Mongoose schema exists in the subscription module and the resolver never reads it. Per-company overrides are therefore effectively dead unless a CompanyFeature model is registered elsewhere. See §9.

2.5 The seed catalog (subscription.seed.ts)

DEFAULT_FEATURES (~90) defines the canonical catalog. Notable groups:

  • Module gates (module: "modules", not in any plan, tenant-config-controlled): ACCOUNT_MODULE, HR_MODULE, MANUFACTURING_MODULE, PROJECT_MODULE, POS_MODULE, RECRUITMENT_MODULE.
  • Add-ons (isAddOn: true): VIEW_INVENTORY, MANAGE_INVENTORY, MULTI_STORE (metadata max_stores/1), ADVANCED_REPORTS, VIEW_WORKFLOWS, MANAGE_WORKFLOWS.
  • Limit carriers: MANAGE_USERS (metadata max_users/3), MULTI_STORE (max_stores/1).

DEFAULT_PLANS — three nested tiers (each a superset of the previous):

Plan key tier monthly (cents) yearly maxUsers maxStores trialDays perUser
Starter STARTER 1 2900 29000 3 1 14 no
Growth GROWTH 2 5900 59000 10 5 14 no
Enterprise ENTERPRISE 3 19900 199000 0 (∞) 0 (∞) 30 yes (1500/mo, 15000/yr per user)
  • Starter = core accounting (GL/journal/cashbook/note/payment/contra/shortcuts) + basic customers/vendors + sales/purchase invoices + assets + AR/AP/asset/financial reports + full HR (employees/departments/loans/claims/advances/repayments) + admin (users/permissions/stores/taxation).
  • Growth = Starter ∪ quotations/sales-orders/purchase-requisitions/POs + inventory (+ item categories/types, stock adjustments/transfers) + MULTI_STORE + exchanges + inventory reports + products + sub-items + client sub-accounts + MULTI_UOM + MANAGE_PRICE_LEVELS.
  • Enterprise = Growth ∪ ADVANCED_REPORTS + workflows + BOM + budgets + VIEW_MANUFACTURING + projects.

SubscriptionSeedService.seed() upserts features first (by key), then plans (resolving featureKeys → feature _ids); both are idempotent (skip if key exists).

3. API surface

All resolvers are @ApGqlAuthorize({ ignoreCompanyQuery: true }) (platform/super-admin scope) and audited.

Subscription (subscription.resolver.ts)

Operation Type Input Returns Audit
createSubscription Mutation CreateSubscriptionInput {companyId, planId, billingCycle?, startTrial?} CompanySubscription CREATE
updateSubscription Mutation id, UpdateSubscriptionInput {status?, billingCycle?, planId?} CompanySubscription UPDATE
changePlan Mutation subscriptionId, planId CompanySubscription STATUS_CHANGE
cancelSubscription Mutation subscriptionId CompanySubscription STATUS_CHANGE
addSubscriptionAddOn Mutation AddSubscriptionAddOnInput {subscriptionId, featureId, price?} CompanySubscription UPDATE
removeSubscriptionAddOn Mutation RemoveSubscriptionAddOnInput {subscriptionId, featureId} CompanySubscription UPDATE
getActiveSubscription Query companyId CompanySubscription?
getCompanyFeatures Query companyId CompanyFeaturesResult
findOneSubscription Query SubscriptionQueryInput {companyId?, status?} CompanySubscription?
subscriptionPage Query SubscriptionPageInput {skip, take, keyword?, sortBy?, sortOrder?} SubscriptionPageResult

Plan (plan/plan.resolver.ts)

Operation Type Input Returns
createPlan Mutation CreatePlanInput (incl. featureIds: [String]) Plan
updatePlan Mutation id, UpdatePlanInput (Partial) Plan
deletePlan Mutation id Boolean
addFeatureToPlan Mutation planId, featureId Plan
removeFeatureFromPlan Mutation planId, featureId Plan
setPlanFeatures Mutation SetPlanFeaturesInput {planId, featureIds} Plan (with features)
findPlans Query QueryPlanInput {key?, isActive?} [Plan]
findPlansWithFeatures Query — (forces isActive: true) [Plan] populated
findOnePlan Query QueryPlanInput Plan?
planPage Query PlanPageInput PlanPageResult

createPlan/updatePlan map DTO featureIds → schema features. Mutations are audited (module: plan).

Feature (feature/feature.resolver.ts)

Operation Type Input Returns
createFeature Mutation CreateFeatureInput {key, name, description?, module, category?, isAddOn?} Feature
updateFeature Mutation id, UpdateFeatureInput (Partial) Feature
deleteFeature Mutation id Boolean
findFeatures Query QueryFeatureInput {key?, module?, category?, isAddOn?} [Feature]
findOneFeature Query QueryFeatureInput Feature?
featurePage Query FeaturePageInput FeaturePageResult
findAddOnFeatures Query — (filters isAddOn: true) [Feature]

CompanyFeaturesResult shape (the resolver output)

type ResolvedFeature { key: String! name: String! module: String category: String enabled: Boolean limit: Float source: String expiresAt: Float }
type CompanyFeaturesResult { companyId: ID! planId: ID planName: String features: [ResolvedFeature!]! maxUsers: Float maxStores: Float }

sourceplan | addon | override | tenant (feature.interface.ts).

4. Business rules & calculations

4.1 The feature resolver (feature-resolver.service.tsresolveCompanyFeatures)

The single algorithm everything depends on. Priority:

  1. Dev overrideenable_all_features === "true" (env) → returns all catalog features, source: "override", planName: "All Features (dev override)", maxUsers/maxStores: 9999. Skips cache, plan, DB, whitelist.
  2. Cache hit (per company, TTL 5 min) → return cached.
  3. Branch on development_mode (env, default "dedicated"):
    • dedicatedresolveDedicatedFeatures: all catalog features, source: "tenant", maxUsers/maxStores: 0, no subscription lookup.
    • sharedresolveSharedFeatures: find active subscription → load plan.features (source: "plan") into a Map<key, IResolvedFeature>, carrying plan.maxUsers/maxStores; then overlay subscription.addOns features (source: "addon", override plan entry on key collision).
  4. Tenant-whitelist intersection (both modes)tenantConfigSvc.getEnabledFeatureKeys():
    • unrestricted = keys === null || keys.length === 0 → keep all.
    • else keep only features whose keySet(keys).
    • Net: resolved = (catalog | plan∪addons) ∩ whitelist.
  5. limit per feature = feature.metadata?.defaultLimit ?? null. Result cached via setCacheEntry (LRU, evicts expired when size ≥ 1000).
// shared mode core (trimmed)
const featureMap = new Map<string, IResolvedFeature>();
const subscription = await this.subscriptionRepo.findActiveByCompanyId(companyId); // status ∈ ACTIVE|TRIALING
if (subscription) {
  const plan = await this.planSvc.findById(subscription.planId);
  maxUsers = plan.maxUsers || 0; maxStores = plan.maxStores || 0;
  for (const f of await this.featureSvc.find({ _id: { $in: plan.features } }))
    featureMap.set(f.key, { ...f, enabled: true, limit: f.metadata?.defaultLimit ?? null, source: "plan" });
  for (const f of addOnFeatures) featureMap.set(f.key, { ...f, source: "addon" });
}
const keys = this.tenantConfigSvc.getEnabledFeatureKeys();
const unrestricted = keys === null || keys.length === 0;
const features = unrestricted ? [...featureMap.values()] : [...featureMap.values()].filter(f => new Set(keys).has(f.key));

hasFeature(companyId, key) = resolved.features.find(f => f.key === key)?.enabled === true. getFeatureLimit(companyId, key) = that feature's limit ?? null.

4.2 Subscription lifecycle (subscription.service.ts)

  • create: reject if an ACTIVE|TRIALING subscription exists; require plan exists + isActive; status = startTrial ? TRIALING : ACTIVE; currentPeriodEnd = now + (YEARLY?1y:1m); trialEnd = now + plan.trialDays when trialing; then invalidateCache(companyId).
  • changePlan: swap planId; invalidate cache.
  • cancel: status = CANCELLED, set cancelledAt; invalidate cache.
  • addAddOn: reject duplicate featureId; push {featureId, price, activatedAt}; invalidate cache.
  • removeAddOn: filter out featureId; invalidate cache.

Every mutation calls featureResolverSvc.invalidateCache(companyId) so the next hasFeature reflects the change. No Mongo transaction wraps these (single-document updates); setSession is a no-op.

4.3 Status state machine

            createSubscription
   (startTrial?)│
      ┌─────────┴──────────┐
   TRIALING             ACTIVE ──changePlan──▶ ACTIVE (planId swapped)
      │                     │
      └────── cancel ───────┴──▶ CANCELLED (cancelledAt set)

PAST_DUE / EXPIRED exist in the enum and are settable via updateSubscription, but no automated transition (no cron expiring trials/periods) is implemented. findActiveByCompanyId treats only ACTIVE/TRIALING as active.

4.4 The BE guard (guards/feature.guard.ts + feature.decorator.ts)

@RequireFeature(key) / @RequireFeatures(...keys) set requireFeature metadata; @UseGuards(GqlFeatureGuard) enforces it. canActivate:

  1. No metadata → allow.
  2. SuperAdmin (contextSvc.isInAdminGroup) → allow (bypasses all feature checks).
  3. Resolve companyId from context; if missing, look up the employees collection by employeeId/userId (recursive arg scan for employeeId).
  4. For every required key → hasFeature(companyId, key); any miss throws The feature "<key>" is not enabled for your account. Contact your administrator.

Current @RequireFeature usages in BE (grep): HR_MODULE (HR attendance/loan/etc., ~31 resolvers), RECRUITMENT_MODULE (4 recruitment resolvers), MULTI_UOM, MANAGE_PRICE_LEVELS, plus example CREATE_INVOICE / RequireFeatures('CREATE_INVOICE','MULTI_STORE').

feature.guard.ts contains a debug console.log("GqlFeatureGuard: contextSvc =", ...) left in place — noise on every guarded request.

5. Permissions

  • Authoring/assignment resolvers: @ApGqlAuthorize({ ignoreCompanyQuery: true }) — platform scope (super-admin), audited. They are not company-scoped, so a tenant admin doesn't edit the global catalog.
  • Runtime enforcement: GqlFeatureGuard + @RequireFeature. SuperAdmin bypasses. Feature gating is additive to RBAC — a request needs the permission and the feature. See ../../platform/permissions-access.md.
  • Tenant whitelist / maxUsers-maxCompanies caps + the master-secret cache-clear endpoint: ../master-data/config.md.

6. Flows

6.1 Guarded resolver call (runtime gate)

  1. Resolver annotated @UseGuards(GqlFeatureGuard) @RequireFeature("HR_MODULE").
  2. Guard reads metadata → not super-admin → resolve companyIdhasFeature(companyId, "HR_MODULE")resolveCompanyFeatures (override → mode → whitelist → cache).
  3. Enabled → proceed; not enabled → throw the "feature not enabled" error (surfaced to the client).

6.2 Assign / change a company's plan (admin plan-mapping)

  1. Super-admin picks a company (ApSelectInputAsyncfindCompany) → getActiveSubscription + getCompanyFeatures load in parallel.
  2. Pick a plan card → confirm modal → if a subscription exists → changePlan(subscriptionId, planId), else createSubscription({companyId, planId, billingCycle:'MONTHLY', startTrial:true}).
  3. Service validates + persists + invalidateCache → page reloads subscription detail. Unhappy paths: duplicate active subscription / inactive plan / missing plan throw and surface via toastSvc.error.

6.3 Edit a plan's feature bundle (admin plan-settings)

  1. Select a plan → refreshPlansWithFeatures → pre-check its current features.
  2. Toggle individual features or whole modules (tri-state module checkbox), Select/Deselect All.
  3. Save → setPlanFeatures({planId, featureIds})setPlanFeatures mutation replaces plan.features.

6.4 View resolved features (tenant admin /features)

  1. /features (auth-guarded SSR) → FeaturesPageuseFeatures().features (already loaded by context on auth) → renders only enabled features, grouped by module, with category badges. Empty enabled set + loaded → "All features available / No restrictions". /company-features → permanent redirect to /features.

7. Admin UI

Module: zerp-admin/src/modules/subscription. Routes: pages/features.tsx, pages/company-features.tsx (redirect→/features), pages/setup/{plans,plan-settings,plan-mapping,subscriptions}.tsx.

  • context.tsx — the single gql consumer (useFeatures() / SubscriptionContextProvider). State: features, planId/planName/maxUsers/maxStores, featuresLoaded, plans, allFeatures, plansWithFeatures (+ loading flags). Methods: refreshFeatures, refreshPlans, refreshAllFeatures, refreshPlansWithFeatures, getCompanyFeatures, getActiveSubscription, createSubscription, changePlan, cancelSubscription, setPlanFeatures, plus hasFeature(key) / getFeatureLimit(key). On auth it auto-loads refreshFeatures (own company) + refreshPlans + refreshAllFeatures.
    • hasFeature fails open while loading (!featuresLoaded → return true) to avoid blocking the UI; after load, trusts the resolved set.
    • refreshFeatures always sets featuresLoaded = true in finally — fixes the "select-module loads continuously" bug where useLazyQuery's onError resolves with data: null (no throw), leaving the flag stuck.
  • gql/query.tsuseSubscriptionQuery() wraps all ops (getCompanyFeatures/getActiveSubscription network-only; findPlans/findFeatures/findAddOnFeatures/findPlansWithFeatures cache-first; mutations) with toastSvc.graphQlError onError. Also exports fetchCompanyFeatureKeys(companyId, token) — a graphql-request SSR call returning enabled keys for the route guard.
  • FeatureGate.tsx<FeatureGate feature={key|key[]} fallback inverse>: renders children only when all keys pass hasFeature (or the inverse). withFeatureGate(Component, feature, Fallback) HOC variant.
  • module-feature-map.tsMODULE_TO_FEATURE_MAP: nav module key → feature key (e.g. inventory→VIEW_INVENTORY, user-maintenance→MANAGE_USERS, all manufacturing*→VIEW_MANUFACTURING). Nav items with no mapping are always visible (RBAC-only).
  • featuresPage.tsx (super-admin setup variant, SetupLayout) — searchable/filterable ApTable of the full catalog (allFeatures) with module/category/add-on/description columns. (Distinct from the tenant-facing pages/features.tsx read-only grid above.)
  • plansPage.tsx — plan list + create/edit modal (prices entered in cents; maxUsers/maxStores 0 = Unlimited). Note: the edit submit calls changePlan(plan._id, plan._id) and never invokes createPlan/updatePlan — the modal is largely a stub (see §9).
  • planSettingsPage.tsx — per-plan feature matrix grouped by module, tri-state module checkboxes, Select/Deselect All, dirty tracking → setPlanFeatures.
  • planMappingPage.tsx — assign/change/cancel a company's subscription via plan cards + confirm modal.
  • subscriptionPage.tsx — pick a company → "View Features" modal renders the resolved CompanyFeaturesResult table (feature/module/category/status/source/limit) + plan summary cards.
  • Sidebar: components/navbar/module-filter.ts → filterNavItemsByFeatures(items, hasFeature) walks nav items; a leaf with a MODULE_TO_FEATURE_MAP entry is shown only if hasFeature(featureKey); groups hide when all children hide; unmapped items always show.
  • SSR route guard: guard.tsx → ApGuardBuilder.haveModuleAccess(pathname) calls fetchCompanyFeatureKeys(companyId, token) and hasFeatureRouteAccess(pathname, key => keys.includes(key)); on failure redirects (default /dashboard). Fail-open: if no companyId or keys can't load, access is allowed (enforcement falls back to the BE guard).

8. Dependencies & integrations

  • BE: SubscriptionModule wires Feature/Plan submodules, FeatureResolverService, GqlFeatureGuard, SubscriptionSeedService; depends on AuthModule, ConfigModule, and (via the resolver) TenantConfigService. FeatureResolverService injects SubscriptionRepository, PlanService, FeatureService, TenantConfigService, ConfigService. Exports the guard/decorator/services for consumption across modules (src/modules/subscription/index.ts).
  • Consumers: any resolver using @RequireFeature (HR, recruitment, inventory UOM/price-level, …). CompanyService/UserService consume the tenant-config caps (not this module).
  • Env flags: enable_all_features (dev override), development_mode (dedicated|shared, default dedicated). Tenant whitelist envs live with tenant-config.
  • FE: SubscriptionContextProvider mounted app-wide; consumed by sidebar filter, SSR guard, FeatureGate, and the setup pages. Uses the company module's findCompany for company pickers.
  • No external billing provider. externalId/externalProvider are unused hooks.

9. Gotchas & project-specific rules

  • Feature whitelist is tri-state. null (no tenant config) and [] (configured, empty) both mean unrestricted; only a non-empty array filters. Easy to misread [] as "deny all". (See ../master-data/config.md §9.)
  • development_mode defaults to dedicated → by default the resolver returns all catalog features (whitelist-filtered) and ignores the subscription entirely. Plans/add-ons only matter in shared (SaaS) mode.
  • enable_all_features=true short-circuits everything — all features, no plan/whitelist, limits 9999. Dev-only; never set in SaaS prod.
  • CompanyFeature overrides are not wired. The migration writes them but there is no schema and the resolver never consults them — per-company overrides are dead unless a model is added. source: "override" in the resolver/UI only ever comes from the enable_all_features path, not from a CompanyFeature row.
  • FE hasFeature fails OPEN while loading (returns true until featuresLoaded), and the SSR route guard fails OPEN if keys can't load. The authoritative gate is the BE GqlFeatureGuard — UI gating is convenience only.
  • SuperAdmin bypasses all feature checks (BE guard). Test feature gating as a non-admin company user.
  • Module gates aren't in plans. HR_MODULE, POS_MODULE, etc. (module: "modules") are controlled only via the tenant whitelist — adding them to a plan's features has no effect in dedicated mode and is intentionally omitted from seed plans.
  • Prices are in cents. Schema/seed store monthly: 2900 = $29.00; every admin view divides by 100. The plansPage modal labels say "(cents)".
  • plansPage create/edit is a stub — submit calls changePlan(id, id) (a no-op self-change) and never calls createPlan/updatePlan. Real plan authoring happens via setPlanFeatures (feature matrix) + GraphQL directly. Treat the plan modal as incomplete.
  • One active subscription per company is enforced in service code (findActiveByCompanyId check), not by a DB unique index — concurrent creates could race.
  • No trial/period expiry automation. PAST_DUE/EXPIRED are manual-only; trials don't auto-expire.
  • Stale debug logging: feature.guard.ts logs context on every guarded request; TenantConfigService logs its config at boot (../master-data/config.md §9).
  • Two "features" pages. pages/features.tsx = tenant-facing read-only grid of enabled features; modules/subscription/featuresPage.tsx (under SetupLayout) = super-admin catalog table of all features. Don't conflate.