Config & Tenant-Config — runtime settings, setup wizard, and feature gates

The whole "configuration" surface reduces to three layers stacked by scope:

  1. Config — one per-company document of default accounts, currency, and app settings, edited in-app (the configs collection).
  2. 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).
  3. 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. Config is a typed, fixed-field document, not a generic {key,value} table. (Contrast with the zync-nextjs-standalone "configs collection" pattern — zerp's Config is a single fat schema.)
  • Hold subscription plan/feature definitions — those live in the subscription domain. This module only consumes the resolved feature set.
  • Manage timezone storage — timezone is resolved from process.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 ObjectIdmasters company base currency (note: the baseCurrency resolve-field actually reads company.currencyId, not this field — see §9)
sales
defaultSalesCashAcountId ObjectIdaccounts default cash account for sales
defaultSalesTransferAcountId ObjectIdaccounts default bank/transfer account
defaultSalesValueAcountId ObjectIdaccounts sales revenue account
defaultSalesReturnValueAcountId ObjectIdaccounts sales-return account
finance
pettyCashAccountId ObjectIdaccounts petty cash
debtorsAccountTypeId ObjectIdaccount_categories debtors (AR) category
creditorsAccountTypeId ObjectIdaccount_categories creditors (AP) category
bankChargesAccountId ObjectIdaccounts bank charges
taxPaidAccountId ObjectIdaccounts input/tax-paid
stockPayableAccountTypeId ObjectIdaccount_categories stock payable category
stockReceivableAccountTypeId ObjectIdaccount_categories stock receivable category
hr
payrollLiabilityAccountId ObjectIdaccounts payroll liability
payrollExpenseAccountId ObjectIdaccounts payroll expense
payrollTaxAccountId ObjectIdaccounts payroll tax (PAYE/PCB)
payrollEmployerExpenseAccountId ObjectIdaccounts employer cost (EPF/SOCSO employer)
loanAccountId / loanPaymentAccountId ObjectIdaccounts staff loan + repayment
advanceAccountId / advancePaymentAccountId ObjectIdaccounts 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.toObjectId setter so string ids from GraphQL are coerced to ObjectId. Each has a matching @ResolveField on the resolver that lazily populates the full Account / AccountCategory object (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, and hrdfEnabled/hrdfRate. The sales/finance default-account ids are NOT in this input — they are written through other paths (e.g. the DefaultTransactionConfig admin component, see §7) or createConfig. There is a separate createConfig mutation 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_url or tenant_key is missing but enabled_feature_keys env 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 tenants collection by key and caches the row.
  • Cache TTL: 0 when app_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 return 0 when 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/maxUsers of 0 ⇒ unlimited.
  • maxUsers counts 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):

  1. Dev override: if enable_all_features==="true" → return every catalog feature, source:"override", maxUsers/maxStores:9999. Bypasses plan, DB, and tenant whitelist.
  2. Else branch on development_mode env (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"), carrying maxUsers/maxStores from the plan, then filter by the tenant whitelist.
  3. Tenant whitelist filter (both modes): unrestricted = keys===null || keys.length===0. When restricted, keep only features whose key is in the whitelist Set. So the final feature set is plan/catalog ∩ tenant whitelist.
  4. hasFeature / getFeatureLimit read from this resolved set (limit defaults to feature.metadata.defaultLimit ?? null).

See ../subscription-config/_overview.md for 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 route config.tsx SSR-guards USER_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/version and 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)

  1. Admin opens /setupSetupPage reads ApSetupService.setupInfo (server host from ApSsrGlobal).
  2. If already registered, shows host + Disconnect Server (clearSetup → clears localStorage/cookie → /setup).
  3. Else admin enters a domain → registerHost(host)GET {host}/api/version.
    • OK → store host in localStorage.server + server cookie → redirect /login.
    • Not OK / throws → toast error; nothing stored.

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)

  1. /config (guarded by SETTINGS.VIEW) → ConfigPagefetchCurrentConfig()fetchCurrentConfig query → configSvc.findLast().
  2. Page renders <DefaultTransactionConfig /> (default sales/finance accounts) + <ConfigDetail /> (the rest).
  3. On save → updateConfig / createConfig mutation → inserts a new configs row → toastSvc.success('Settings Updated'), context replaces config.
  4. On load, the context also applies data.timezone via setTenantTimezone so all instant displays render in the tenant zone.

6.3 Tenant boot + feature resolution (runtime)

  1. App boots → TenantConfigService.onModuleInit → load whitelist + limits (or standalone/unrestricted fallback).
  2. A company/user create → limit check (§4.2) → throws if cap reached.
  3. A feature check anywhere → featureResolverSvc.hasFeature(companyId, key)resolveCompanyFeatures (override → mode branch → tenant-whitelist intersection) → cached 5 min.
  4. Platform changes the tenant row → calls POST /api/cache/clear-feature-cache with the master secret → reloadConfig() + invalidateAllCache() → next resolution reflects new limits/whitelist.

6.4 View enabled features (admin)

  • /featuresFeaturesPageuseFeatures() (subscription module) → shows enabled features grouped by module with category badges (core/advanced/premium). /company-features is 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.tsxApPageHeader "Config" + DefaultTransactionConfig + ConfigDetail.
    • context.tsx — the only gql consumer. Methods: fetchCurrentConfig, updateConfig. State: config, loading, updateLoading, configLoaded. On fetch it calls setTenantTimezone(data.timezone). Skips fetch when there's no companyId in the session.
    • gql/query.tsCREATE_CONFIG, UPDATE_CONFIG, CONFIG (fetchCurrentConfig, no-cache) + SSR helper findConfigAsync.
    • 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).
  • baseCurrency resolve-field reads company.currencyId (CompanyModule), not the stored baseCurrencyId — see §9.
  • TenantConfigModule is @Global; consumed by CompanyService, UserService, FeatureResolverService, AppController. Connects to the platform/master DB via mongodb_master_url.
  • FeatureResolverService depends on SubscriptionRepository, 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 uses defaultSales*).
  • External: master MongoDB (tenant config), process.env.TZ (timezone), env flags enable_all_features, development_mode, enabled_feature_keys, tenant_key, internal_master_secret.

9. Gotchas & project-specific rules

  • Config is 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. updateConfigconfigSvc.create; reads use findLast. The configs collection grows on every save and there is no per-company uniqueness. Treat it as append-only with latest-wins.
  • baseCurrency ignores baseCurrencyId. The resolve-field returns the company's currencyId master, not the config's stored baseCurrencyId. The persisted field exists but the API reads the company currency.
  • Config is company-scoped, branch-agnostic. Reads force ignoreBranchId = true and filter by companyId; fetchCurrentConfig uses ignoreCompanyQuery: true then re-applies companyId in the service.
  • Two different "setup". modules/setup = client-only server-host picker (pre-login). The subscription setup/* 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_mode defaults to "dedicated" (all catalog features), not SaaS.
  • maxUsers counts 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 tenants row aren't seen until the 5-min TTL refresh or an explicit POST /api/cache/clear-feature-cache with the master secret (local env = TTL 0, always fresh).
  • ExchangeRepository-style console noise: TenantConfigService.onModuleInit logs the full loaded config to console.log.
  • Currency base config and FX: see ./exchange-rate.md.