Subscription Features — plans, feature catalog, the resolver/guard, and admin toggles
The whole module reduces to: a flat
Featurecatalog, bundled into nestedPlantiers, attached to a company via oneSubscription(+ embedded add-ons), then collapsed per-request byFeatureResolverServiceinto 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/maxUserscaps — that isTenantConfigService(../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/externalProviderexist 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 |
ObjectId → Plan |
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(metadatamax_stores/1),ADVANCED_REPORTS,VIEW_WORKFLOWS,MANAGE_WORKFLOWS. - Limit carriers:
MANAGE_USERS(metadatamax_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 }source ∈ plan | addon | override | tenant (feature.interface.ts).
4. Business rules & calculations
4.1 The feature resolver (feature-resolver.service.ts → resolveCompanyFeatures)
The single algorithm everything depends on. Priority:
- Dev override —
enable_all_features === "true"(env) → returns all catalog features,source: "override",planName: "All Features (dev override)",maxUsers/maxStores: 9999. Skips cache, plan, DB, whitelist. - Cache hit (per company, TTL 5 min) → return cached.
- Branch on
development_mode(env, default"dedicated"):dedicated→resolveDedicatedFeatures: all catalog features,source: "tenant",maxUsers/maxStores: 0, no subscription lookup.shared→resolveSharedFeatures: find active subscription → loadplan.features(source: "plan") into aMap<key, IResolvedFeature>, carryingplan.maxUsers/maxStores; then overlaysubscription.addOnsfeatures (source: "addon", override plan entry on key collision).
- Tenant-whitelist intersection (both modes) —
tenantConfigSvc.getEnabledFeatureKeys():unrestricted = keys === null || keys.length === 0→ keep all.- else keep only features whose
key∈Set(keys). - Net: resolved = (catalog | plan∪addons) ∩ whitelist.
limitper feature =feature.metadata?.defaultLimit ?? null. Result cached viasetCacheEntry(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|TRIALINGsubscription exists; require plan exists +isActive;status = startTrial ? TRIALING : ACTIVE;currentPeriodEnd = now + (YEARLY?1y:1m);trialEnd = now + plan.trialDayswhen trialing; theninvalidateCache(companyId). - changePlan: swap
planId; invalidate cache. - cancel:
status = CANCELLED, setcancelledAt; 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:
- No metadata → allow.
- SuperAdmin (
contextSvc.isInAdminGroup) → allow (bypasses all feature checks). - Resolve
companyIdfrom context; if missing, look up theemployeescollection byemployeeId/userId(recursive arg scan foremployeeId). - For every required key →
hasFeature(companyId, key); any miss throwsThe 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.tscontains a debugconsole.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-maxCompaniescaps + the master-secret cache-clear endpoint:../master-data/config.md.
6. Flows
6.1 Guarded resolver call (runtime gate)
- Resolver annotated
@UseGuards(GqlFeatureGuard) @RequireFeature("HR_MODULE"). - Guard reads metadata → not super-admin → resolve
companyId→hasFeature(companyId, "HR_MODULE")→resolveCompanyFeatures(override → mode → whitelist → cache). - 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)
- Super-admin picks a company (
ApSelectInputAsync→findCompany) →getActiveSubscription+getCompanyFeaturesload in parallel. - Pick a plan card → confirm modal → if a subscription exists →
changePlan(subscriptionId, planId), elsecreateSubscription({companyId, planId, billingCycle:'MONTHLY', startTrial:true}). - Service validates + persists +
invalidateCache→ page reloads subscription detail. Unhappy paths: duplicate active subscription / inactive plan / missing plan throw and surface viatoastSvc.error.
6.3 Edit a plan's feature bundle (admin plan-settings)
- Select a plan →
refreshPlansWithFeatures→ pre-check its current features. - Toggle individual features or whole modules (tri-state module checkbox), Select/Deselect All.
- Save →
setPlanFeatures({planId, featureIds})→setPlanFeaturesmutation replacesplan.features.
6.4 View resolved features (tenant admin /features)
/features(auth-guarded SSR) →FeaturesPage→useFeatures().features(already loaded by context on auth) → renders onlyenabledfeatures, 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 singlegqlconsumer (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, plushasFeature(key)/getFeatureLimit(key). On auth it auto-loadsrefreshFeatures(own company) +refreshPlans+refreshAllFeatures.hasFeaturefails open while loading (!featuresLoaded → return true) to avoid blocking the UI; after load, trusts the resolved set.refreshFeaturesalways setsfeaturesLoaded = trueinfinally— fixes the "select-module loads continuously" bug whereuseLazyQuery'sonErrorresolves withdata: null(no throw), leaving the flag stuck.
gql/query.ts—useSubscriptionQuery()wraps all ops (getCompanyFeatures/getActiveSubscriptionnetwork-only;findPlans/findFeatures/findAddOnFeatures/findPlansWithFeaturescache-first; mutations) withtoastSvc.graphQlErroronError. Also exportsfetchCompanyFeatureKeys(companyId, token)— agraphql-requestSSR call returning enabled keys for the route guard.FeatureGate.tsx—<FeatureGate feature={key|key[]} fallback inverse>: renders children only when all keys passhasFeature(or the inverse).withFeatureGate(Component, feature, Fallback)HOC variant.module-feature-map.ts—MODULE_TO_FEATURE_MAP: navmodulekey → feature key (e.g.inventory→VIEW_INVENTORY,user-maintenance→MANAGE_USERS, allmanufacturing*→VIEW_MANUFACTURING). Nav items with no mapping are always visible (RBAC-only).featuresPage.tsx(super-admin setup variant,SetupLayout) — searchable/filterableApTableof the full catalog (allFeatures) with module/category/add-on/description columns. (Distinct from the tenant-facingpages/features.tsxread-only grid above.)plansPage.tsx— plan list + create/edit modal (prices entered in cents;maxUsers/maxStores 0 = Unlimited). Note: the edit submit callschangePlan(plan._id, plan._id)and never invokescreatePlan/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 resolvedCompanyFeaturesResulttable (feature/module/category/status/source/limit) + plan summary cards.
Sidebar + SSR route gating (how resolved features actually hide UI)
- Sidebar:
components/navbar/module-filter.ts → filterNavItemsByFeatures(items, hasFeature)walks nav items; a leaf with aMODULE_TO_FEATURE_MAPentry is shown only ifhasFeature(featureKey); groups hide when all children hide; unmapped items always show. - SSR route guard:
guard.tsx → ApGuardBuilder.haveModuleAccess(pathname)callsfetchCompanyFeatureKeys(companyId, token)andhasFeatureRouteAccess(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:
SubscriptionModulewiresFeature/Plansubmodules,FeatureResolverService,GqlFeatureGuard,SubscriptionSeedService; depends onAuthModule,ConfigModule, and (via the resolver)TenantConfigService.FeatureResolverServiceinjectsSubscriptionRepository,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/UserServiceconsume the tenant-config caps (not this module). - Env flags:
enable_all_features(dev override),development_mode(dedicated|shared, defaultdedicated). Tenant whitelist envs live withtenant-config. - FE:
SubscriptionContextProvidermounted app-wide; consumed by sidebar filter, SSR guard,FeatureGate, and the setup pages. Uses thecompanymodule'sfindCompanyfor company pickers. - No external billing provider.
externalId/externalProviderare 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_modedefaults todedicated→ by default the resolver returns all catalog features (whitelist-filtered) and ignores the subscription entirely. Plans/add-ons only matter inshared(SaaS) mode.enable_all_features=trueshort-circuits everything — all features, no plan/whitelist, limits 9999. Dev-only; never set in SaaS prod.CompanyFeatureoverrides 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 theenable_all_featurespath, not from aCompanyFeaturerow.- FE
hasFeaturefails OPEN while loading (returnstrueuntilfeaturesLoaded), and the SSR route guard fails OPEN if keys can't load. The authoritative gate is the BEGqlFeatureGuard— 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'sfeatureshas no effect indedicatedmode and is intentionally omitted from seed plans. - Prices are in cents. Schema/seed store
monthly: 2900= $29.00; every admin view divides by 100. TheplansPagemodal labels say "(cents)". plansPagecreate/edit is a stub — submit callschangePlan(id, id)(a no-op self-change) and never callscreatePlan/updatePlan. Real plan authoring happens viasetPlanFeatures(feature matrix) + GraphQL directly. Treat the plan modal as incomplete.- One active subscription per company is enforced in service code (
findActiveByCompanyIdcheck), not by a DB unique index — concurrent creates could race. - No trial/period expiry automation.
PAST_DUE/EXPIREDare manual-only; trials don't auto-expire. - Stale debug logging:
feature.guard.tslogs context on every guarded request;TenantConfigServicelogs 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(underSetupLayout) = super-admin catalog table of all features. Don't conflate.