Config & Tenant-Config — runtime settings, setup wizard, and feature gates
The whole "configuration" surface reduces to three layers stacked by scope:
Config— one per-company document of default accounts, currency, and app settings, edited in-app (theconfigscollection).TenantConfig— a read-only snapshot of this deployment's limits and feature whitelist, loaded once at boot from the platform/master DB (no local collection).- Subscription feature gates — plan/add-on features, resolved per-company and then intersected with the tenant whitelist.
Layer 1 is what a finance admin tunes; layers 2–3 decide what the deployment is even allowed to do. The admin setup wizard is a fourth, client-only step that just points the admin app at a server host.
Source: BE src/modules/config, src/modules/tenant-config, src/modules/subscription/feature-resolver.service.ts · Admin src/modules/config, src/modules/setup, pages config.tsx, setup/, features.tsx, company-features.tsx
1. Purpose & scope
| Layer | Owns | Scope | Mutable in-app? | Source |
|---|---|---|---|---|
Config |
default accounts (sales/finance/HR), base currency, app/mobile version, support contacts, HRDF levy | per company | yes (updateConfig) |
config.schema.ts |
TenantConfig |
enabledFeatureKeys, maxCompanies, maxUsers, maxEmployee, status |
per deployment (tenant) | no (read-only mirror) | tenant-config.service.ts |
| Feature gates | resolved feature set + limits per company | per company × deployment | no (derived) | feature-resolver.service.ts |
| Setup wizard | which server host the admin app talks to | per browser | yes (localStorage/cookie) | setup/service.ts |
It does NOT:
- Store arbitrary string key/value pairs.
Configis a typed, fixed-field document, not a generic{key,value}table. (Contrast with the zync-nextjs-standalone "configs collection" pattern — zerp'sConfigis a single fat schema.) - Hold subscription plan/feature definitions — those live in the
subscriptiondomain. This module only consumes the resolved feature set. - Manage timezone storage —
timezoneis resolved fromprocess.env.TZ, never persisted.
2. Data model
2.1 Collection: configs (config.schema.ts)
One document per company holding system defaults. There is no companyId uniqueness enforced — reads use findLast (latest wins), and updateConfig creates a new row each time (see §4.2).
| field | type | description |
|---|---|---|
contactWhatsappNumber |
string |
support WhatsApp |
androidVersion / iosVersion |
string |
mobile app version gate |
androidForceUpdate / iosForceUpdate |
boolean |
force mobile update |
testUsers |
string |
comma list of test user identifiers |
supportTeamContacts |
string |
support contacts |
baseCurrencyId |
ObjectId → masters |
company base currency (note: the baseCurrency resolve-field actually reads company.currencyId, not this field — see §9) |
| sales | ||
defaultSalesCashAcountId |
ObjectId → accounts |
default cash account for sales |
defaultSalesTransferAcountId |
ObjectId → accounts |
default bank/transfer account |
defaultSalesValueAcountId |
ObjectId → accounts |
sales revenue account |
defaultSalesReturnValueAcountId |
ObjectId → accounts |
sales-return account |
| finance | ||
pettyCashAccountId |
ObjectId → accounts |
petty cash |
debtorsAccountTypeId |
ObjectId → account_categories |
debtors (AR) category |
creditorsAccountTypeId |
ObjectId → account_categories |
creditors (AP) category |
bankChargesAccountId |
ObjectId → accounts |
bank charges |
taxPaidAccountId |
ObjectId → accounts |
input/tax-paid |
stockPayableAccountTypeId |
ObjectId → account_categories |
stock payable category |
stockReceivableAccountTypeId |
ObjectId → account_categories |
stock receivable category |
| hr | ||
payrollLiabilityAccountId |
ObjectId → accounts |
payroll liability |
payrollExpenseAccountId |
ObjectId → accounts |
payroll expense |
payrollTaxAccountId |
ObjectId → accounts |
payroll tax (PAYE/PCB) |
payrollEmployerExpenseAccountId |
ObjectId → accounts |
employer cost (EPF/SOCSO employer) |
loanAccountId / loanPaymentAccountId |
ObjectId → accounts |
staff loan + repayment |
advanceAccountId / advancePaymentAccountId |
ObjectId → accounts |
staff advance + repayment |
hrdfEnabled |
boolean (default false) |
Malaysia HRD Corp training levy toggle |
hrdfRate |
number (default 0) |
HRDF percent, e.g. 1 or 0.5 |
Plus BaseSchema envelope + soft-delete (deletedAt). timestamps: true.
All account/category id fields use the
BaseSchema.toObjectIdsetter so string ids from GraphQL are coerced toObjectId. Each has a matching@ResolveFieldon the resolver that lazily populates the fullAccount/AccountCategoryobject (config.resolver.ts).
Resolved-only fields (in the Config DTO, not stored): timezone (from process.env.TZ), welcomeMessage, supportPaymentMessage, branchId, and every *Account / *AccountType / baseCurrency object (resolve-fields).
// config.schema.ts (trimmed)
@ApSchema({ collection: `configs`, timestamps: true })
export class Config extends BaseSchema {
@Prop() androidVersion: string;
@Prop() androidForceUpdate: boolean;
@Prop({ set: v => BaseSchema.toObjectId(v) }) baseCurrencyId: Types.ObjectId;
@Prop({ set: v => BaseSchema.toObjectId(v) }) defaultSalesCashAcountId: Types.ObjectId;
// …all the *AccountId / *AccountTypeId fields…
@Prop({ default: false }) hrdfEnabled: boolean; // Malaysia HRD Corp levy
@Prop({ default: 0 }) hrdfRate: number; // percent
}2.2 tenants (platform/master DB) — read by TenantConfigService
There is no local collection for tenant config. TenantConfigService connects once to the platform DB (process.env.mongodb_master_url), looks up the tenants document by process.env.tenant_key, caches it in memory, and closes the connection. The shape it reads (defined inline, strict: false):
| field | type | meaning |
|---|---|---|
key |
string |
tenant identifier (matches tenant_key) |
enabledFeatureKeys |
string[] |
feature whitelist; []/absent = unrestricted |
maxCompanies |
number |
hard cap on companies (0 = no cap) |
maxUsers |
number |
hard cap on staff users (0 = no cap) |
maxEmployee |
number |
hard cap on employees (0 = no cap) |
status |
string |
"active" / "suspended" |
See ../../platform/multi-tenancy.md for the master-DB / tenant-DB split and tenant_key boot wiring.
Enums: none formally declared. status is a free string compared against "suspended".
3. API surface
Config (GraphQL, config.resolver.ts, class-level @ApGqlAuthorize)
| Operation | Type | Input | Returns | Auth |
|---|---|---|---|---|
updateConfig |
Mutation | UpdateConfigInput (all fields optional) |
Config |
@ApGqlAuthorize + audit UPDATE. Internally calls configSvc.create (inserts a new row). |
fetchCurrentConfig |
Query | — | Config (nullable) |
@ApGqlAuthorize({ ignoreCompanyQuery: true }) → configSvc.findLast() |
UpdateConfigInput(CommonConfigInput) exposes only a subset of schema fields: support contacts, mobile version flags,testUsers, the HR/payroll/loan/advance account ids, andhrdfEnabled/hrdfRate. The sales/finance default-account ids are NOT in this input — they are written through other paths (e.g. theDefaultTransactionConfigadmin component, see §7) orcreateConfig. There is a separatecreateConfigmutation used by the admin (config/gql/query.ts).
fetchCurrentConfig uses ignoreCompanyQuery: true and the service forces ignoreBranchId = true while reading, then scopes by companyId from the auth context (config.service.ts findLast). So config is company-scoped but branch-agnostic.
Tenant config — no GraphQL/REST surface
TenantConfigService is a @Global() in-memory service consumed by other services directly. The only external trigger is an internal cache-clear endpoint:
| Operation | Type | Route | Auth | Effect |
|---|---|---|---|---|
| version | REST GET | /api/version |
none (authNotRequired) |
returns {version, env, instanceCountry} — used by the admin setup wizard to probe a host |
| clear feature cache | REST POST | /api/cache/clear-feature-cache |
x-master-secret header must equal process.env.internal_master_secret |
tenantConfigSvc.reloadConfig() + featureResolverSvc.invalidateAllCache() |
Feature resolution (feature-resolver.service.ts)
Service-level API (consumed by guards/resolvers in the subscription domain): resolveCompanyFeatures(companyId), hasFeature(companyId, key), getFeatureLimit(companyId, key), invalidateCache(companyId), invalidateAllCache().
4. Business rules & calculations
4.1 Tenant config load & cache (tenant-config.service.ts)
- On
onModuleInit,loadConfig()runs. - Standalone fallback: if
mongodb_master_urlortenant_keyis missing butenabled_feature_keysenv is set, it builds a{key:"standalone", enabledFeatureKeys: <split csv>, max*:0, status:"active"}config. If neither is present → no config, no restrictions. - Otherwise it queries the master DB
tenantscollection bykeyand caches the row. - Cache TTL:
0whenapp_env==="local"(always fresh), else 5 min.checkAndRefresh()serves stale cache and refreshes in the background.reloadConfig()forces an immediate reload (bypasses TTL). - Accessor semantics for
getEnabledFeatureKeys():null= no config loaded → unrestricted;[]= configured but unrestricted;[...]= only those keys allowed. (max*accessors return0when no config.)
4.2 The limits — hard enforcement
The numeric caps are enforced at write time, throwing if exceeded:
// company.service.ts create()
const maxCompanies = this.tenantConfigSvc.getMaxCompanies();
if (maxCompanies > 0 && (await this.count({})) >= maxCompanies)
throw new Error(`Company limit of ${maxCompanies} reached for this tenant. …`);
// user.service.ts create()
const maxUsers = this.tenantConfigSvc.getMaxUsers();
if (maxUsers > 0) {
const staffKinds = [Admin, SuperAdmin, Company, StoreAdmin, Staff];
if ((await this.count({ kind: { $in: staffKinds } })) >= maxUsers)
throw new HttpException(`User limit of ${maxUsers} reached …`, NOT_ACCEPTABLE);
}maxCompanies/maxUsersof0⇒ unlimited.maxUserscounts only staff kinds (Admin, SuperAdmin, Company, StoreAdmin, Staff) — customers don't count.isSuspended()(status==="suspended") is available for callers to block access (used in the tenancy/auth path — see../../platform/multi-tenancy.md).
4.3 Feature resolution — the intersection rule
resolveCompanyFeatures(companyId) (feature-resolver.service.ts), cached per company for 5 min (max 1000 entries, LRU-evicts expired):
- Dev override: if
enable_all_features==="true"→ return every catalog feature,source:"override",maxUsers/maxStores:9999. Bypasses plan, DB, and tenant whitelist. - Else branch on
development_modeenv (default"dedicated"):dedicated→ all catalog features, filtered by tenant whitelist;source:"tenant". Empty/null whitelist = all features.shared(SaaS) → build the feature map from the active subscription's plan features (source:"plan") plus add-on features (source:"addon"), carryingmaxUsers/maxStoresfrom the plan, then filter by the tenant whitelist.
- Tenant whitelist filter (both modes):
unrestricted = keys===null || keys.length===0. When restricted, keep only features whosekeyis in the whitelistSet. So the final feature set is plan/catalog ∩ tenant whitelist. hasFeature/getFeatureLimitread from this resolved set (limitdefaults tofeature.metadata.defaultLimit ?? null).
See
../subscription-config/_overview.mdfor plans, features catalog, add-ons, and how the resolved set gates UI/guards. This doc covers only the tenant-whitelist intersection that the config layer contributes.
4.4 Config update semantics
updateConfig resolver calls configSvc.create(...) — i.e. each "update" inserts a new configs document, and fetchCurrentConfig returns findLast(). There is no in-place update; history accumulates and the newest row wins. (Known wart — see §9.)
Side effects: config mutations write the audit trail (@AuditMeta module:'config'). No GL/stock effects. Tenant config load/limits write nothing.
Transactionality: none special — ApConfigService.setSession is a no-op; config writes are single-document.
5. Permissions
- Config: resolver
@ApGqlAuthorize. Admin routeconfig.tsxSSR-guardsUSER_ACCESS.SETTINGS.MODULE/.ACTIONS.VIEW. Audit on update. See../../platform/permissions-access.md. - Feature cache clear: shared-secret header (
internal_master_secret), no user auth — platform-internal. - Setup wizard: unauthenticated (it runs before login); it only probes
/api/versionand stores the host. - Feature gates:
hasFeature(companyId, key)is the building block guards use to allow/deny features; the actual guard decorators live in the subscription domain.
6. Flows
6.1 Server setup wizard (admin, pre-login)
- Admin opens
/setup→SetupPagereadsApSetupService.setupInfo(server host fromApSsrGlobal). - If already registered, shows host + Disconnect Server (
clearSetup→ clears localStorage/cookie →/setup). - Else admin enters a domain →
registerHost(host)→GET {host}/api/version.- OK → store host in
localStorage.server+servercookie → redirect/login. - Not OK / throws → toast error; nothing stored.
- OK → store host in
This wizard is purely a client-side "which backend do I talk to" selector. It is not the subscription
setup/*pages (setup/features.tsx,setup/plans.tsx, etc.), which configure plans/features and belong to the subscription domain.
6.2 Edit company config (admin)
/config(guarded bySETTINGS.VIEW) →ConfigPage→fetchCurrentConfig()→fetchCurrentConfigquery →configSvc.findLast().- Page renders
<DefaultTransactionConfig />(default sales/finance accounts) +<ConfigDetail />(the rest). - On save →
updateConfig/createConfigmutation → inserts a newconfigsrow →toastSvc.success('Settings Updated'), context replacesconfig. - On load, the context also applies
data.timezoneviasetTenantTimezoneso all instant displays render in the tenant zone.
6.3 Tenant boot + feature resolution (runtime)
- App boots →
TenantConfigService.onModuleInit→ load whitelist + limits (or standalone/unrestricted fallback). - A company/user create → limit check (§4.2) → throws if cap reached.
- A feature check anywhere →
featureResolverSvc.hasFeature(companyId, key)→resolveCompanyFeatures(override → mode branch → tenant-whitelist intersection) → cached 5 min. - Platform changes the tenant row → calls
POST /api/cache/clear-feature-cachewith the master secret →reloadConfig()+invalidateAllCache()→ next resolution reflects new limits/whitelist.
6.4 View enabled features (admin)
/features→FeaturesPage→useFeatures()(subscription module) → shows enabled features grouped by module with category badges (core/advanced/premium)./company-featuresis a permanent redirect to/features.
7. Admin UI
- Routes:
config.tsx(→config/page.tsx),setup/(server wizard + subscription setup pages),features.tsx,company-features.tsx(redirect →/features). - Config module (
modules/config):page.tsx→ApPageHeader "Config"+DefaultTransactionConfig+ConfigDetail.context.tsx— the onlygqlconsumer. Methods:fetchCurrentConfig,updateConfig. State:config,loading,updateLoading,configLoaded. On fetch it callssetTenantTimezone(data.timezone). Skips fetch when there's nocompanyIdin the session.gql/query.ts—CREATE_CONFIG,UPDATE_CONFIG,CONFIG(fetchCurrentConfig,no-cache) + SSR helperfindConfigAsync.components/DefaultTransactionConfig.tsx— the default sales/finance account pickers.hr-page.tsx— HR-specific config (payroll/loan/advance accounts, HRDF).
- Setup module (
modules/setup):page.tsx(server connect/disconnect form, Yup-validated domain) +service.ts(registerHost,setupInfo,clearSetup). - Features page: read-only grid grouped by module, category color badges, check-circle per enabled feature.
- UX: config "update" is really an insert-new-row (latest wins); timezone auto-applied on load.
8. Dependencies & integrations
- Config depends on
AccountModule,AccountCategoryModule,MasterModule,CompanyModule,BranchModule,AuditLogModule,AuthModule(resolve-fields + audit). baseCurrencyresolve-field readscompany.currencyId(CompanyModule), not the storedbaseCurrencyId— see §9.TenantConfigModuleis@Global; consumed byCompanyService,UserService,FeatureResolverService,AppController. Connects to the platform/master DB viamongodb_master_url.FeatureResolverServicedepends onSubscriptionRepository,PlanService,FeatureService(subscription domain) +TenantConfigService+ConfigService(env).- Config consumers: finance/order/HR modules read the default account ids when posting GL legs and payroll (e.g. payroll uses
payroll*AccountId, sales usesdefaultSales*). - External: master MongoDB (tenant config),
process.env.TZ(timezone), env flagsenable_all_features,development_mode,enabled_feature_keys,tenant_key,internal_master_secret.
9. Gotchas & project-specific rules
Configis NOT a generic key/value store. It is one fat typed document. Adding a setting means adding a schema field + DTO field + resolve-field, not inserting a{key,value}row.- "Update" inserts a new row.
updateConfig→configSvc.create; reads usefindLast. Theconfigscollection grows on every save and there is no per-company uniqueness. Treat it as append-only with latest-wins. baseCurrencyignoresbaseCurrencyId. The resolve-field returns the company'scurrencyIdmaster, not the config's storedbaseCurrencyId. The persisted field exists but the API reads the company currency.- Config is company-scoped, branch-agnostic. Reads force
ignoreBranchId = trueand filter bycompanyId;fetchCurrentConfigusesignoreCompanyQuery: truethen re-applies companyId in the service. - Two different "setup".
modules/setup= client-only server-host picker (pre-login). The subscriptionsetup/*pages = plan/feature admin. Don't conflate them. - Feature whitelist tri-state.
null(no tenant config) and[](configured, empty) both mean unrestricted; only a non-empty array restricts. Easy to misread[]as "no features". - Final features = plan/catalog ∩ tenant whitelist, unless
enable_all_features=true(dev) short-circuits everything.development_modedefaults to"dedicated"(all catalog features), not SaaS. maxUserscounts staff kinds only (Admin, SuperAdmin, Company, StoreAdmin, Staff); customers are exempt.0= unlimited for all caps.- Tenant config is a boot snapshot. Changes in the master
tenantsrow aren't seen until the 5-min TTL refresh or an explicitPOST /api/cache/clear-feature-cachewith the master secret (local env = TTL 0, always fresh). ExchangeRepository-style console noise:TenantConfigService.onModuleInitlogs the full loaded config toconsole.log.- Currency base config and FX: see
./exchange-rate.md.