Master Reference Data — the single polymorphic lookup collection

One-paragraph framing: The whole master module reduces to one self-referencing masters collection. A type is a parent row identified by a stable key (currency, uom, tax_type, cost_center, …); each option is a child row pointing at that parent via parentId. One schema, one service, one resolver, and one admin module serve every lookup in the ERP. Quantity of distinct lookups grows by adding rows, never new collections.

Source: BE src/modules/master · Admin src/modules/master

See the domain framing and consumer registry in _overview.md.

1. Purpose & scope

Responsible for: storing and serving all seedable reference/lookup data — UOM, currency, tax type, the five reporting dimensions (cost center, department, class, analysis code, location), industry, leave type, gender, employment type, marital status, nationality, price level, item type/category, and Malaysia statutory types. Seeds them idempotently at boot. Provides CRUD + a reporting-dimension XLSX import pipeline + an XLSX download report.

Does NOT: enforce business semantics of the values (it doesn't know what VAT means — finance does); scope data per tenant (master data is global — every call clears the company context); provide exchange rates (the exchange module does, using currency masters as its dictionary); or model fiscal periods / runtime config (separate modules).

2. Data model

Collection: masters

One collection holds every type (parent) and every option (child). BaseSchema contributes ref, companyId, branchId, createdBy, updatedBy, createdAt, updatedAt and the soft-delete fields.

field type required? description
_id ObjectId auto Mongo id. Generated per environment — not a stable handle; use key for types.
key string parent only Stable lowercase handle of a type (currency, uom, …). Lowercased on write (normalizeMaster). Children have no key. Effectively unique per type via findOne({ key }) checks (no DB unique index on key alone).
code string reporting-dim children Indexed. Uppercased on write. Required + unique-per-parent for reporting dimensions; optional elsewhere.
number string Indexed. Mirror of code (set equal during normalize). Used as alt display for analysis codes.
name string ✅ (create) Display name. Uppercased on write (normalizeMaster). The human label other modules show.
description string Free text.
isActive boolean Default true (indexed). Children can be deactivated; admin shows ACTIVE/INACTIVE.
parentId ObjectId child only Self-ref to the parent type row. Cast to ObjectId on set. Presence ⇒ this row is an option.
categories ObjectId[] Refs into product_categories (used when a child is tagged to item categories; $lookupCategory joins them as categoryList).
image MasterImage Embedded { uri, type }. Only used for gender options in the admin (avatar).
ref string From BaseSchema; for reporting dimensions it is set equal to code during normalize.
children Partial<Master>[] Not persisted — transient field used only by the seed (createMaster reads it to create child rows). Not a stored array.
// master/master.schema.ts (trimmed)
@ApSchema({ timestamps: true })
export class Master extends BaseSchema {
  _id: Types.ObjectId;
  @Prop() key: string;
  @Prop({ index: true }) code: string;
  @Prop({ index: true }) number: string;
  @Prop() name: string;
  @Prop() description: string;
  @Prop({ default: true, index: true }) isActive: boolean;
  @Prop({ set: (v) => BaseSchema.toObjectId(v) }) parentId: Types.ObjectId;
  @Prop({ set: (vals) => vals.map((v) => BaseSchema.toObjectId(v)) }) categories: Types.ObjectId[];
  @Prop(() => MasterImage) image: MasterImage;   // { uri, type }
  children: Partial<Master>[];                    // transient (seed only)
}

Indexes (the integrity rules live here)

// master/master.schema.ts
MasterSchema.plugin(SoftDelete, { deletedAt: true, deletedBy: true });
MasterSchema.index({ name: "text", ref: "text", code: "text", number: "text" }); // keyword search

// reporting-dimension uniqueness: code/number unique WITHIN a parent (partial — only when set)
MasterSchema.index({ parentId: 1, code: 1 },
  { unique: true, partialFilterExpression: { code:   { $type: "string", $gt: "" } } });
MasterSchema.index({ parentId: 1, number: 1 },
  { unique: true, partialFilterExpression: { number: { $type: "string", $gt: "" } } });

The partial filter means rows with no code (the common case for non-dimension options) are exempt from the unique constraint — many children can have empty code, but two children under the same parent cannot share a non-empty code.

Soft delete

mongoose-delete plugin with deletedAt/deletedBy. deleteMaster soft-deletes; deleted rows are excluded from all queries automatically.

Tenant scoping

None. Although BaseSchema carries companyId/branchId, MasterService calls contextSvc.setCompany(undefined) before every operation, so masters live in the shared DB and are not filtered by tenant. See platform/multi-tenancy.

Enums

This module defines no enums. Types are data (parent rows). The canonical key vocabulary is the set of mst[].key values listed in §4.

Embedded: MasterImage

field type description
_id ObjectId? sub-doc id
uri string image URL
type string mime/type tag

(The GraphQL MasterImage type additionally exposes name, but the schema stores only uri/type.)

Aggregation helpers (exported from the schema file)

$lookupCategory  // joins masters.categories → product_categories  as categoryList
$lookUpMaster    // joins <doc>.masterId → masters                as master (used by consumers)
$lookupCurrency  // joins <doc>.currencyId → masters              as currency (used by consumers)

$lookUpMaster / $lookupCurrency are how other modules embed a master row into their reads (e.g. an order showing its currency name).

3. API surface

GraphQL

All operations are guarded by @ApGqlAuthorize() at the resolver class level (see §5). The schema shapes (src/schema.gql):

Operation Type Input Returns Auth
masterDetail Query MasterQueryInput { _id?, key? } Master! authorized
masterPage Query MasterPageInput { skip!, take!, keyword?, sortBy?, sortOrder? } MasterPageResult { totalRecords, data[] } ignoreCompanyQuery: true, authNotRequired: true (public read — needed at app boot / login screen)
createMaster Mutation CreateMasterInput Master! authorized + audit CREATE
updateMaster Mutation id: String!, UpdateMasterInput Master! authorized + audit UPDATE
deleteMaster Mutation id: String! Boolean! authorized + audit DELETE
importMasters Mutation MasterImportInput { file: Upload } [MasterImport!]! (preview, nothing persisted) authorized + audit CREATE
confirmMasterImport Mutation ConfirmMasterImportInput { masters: [ImportMasterInput!]! } Boolean! authorized + audit CREATE

Input DTO shapes (master/master.dto.ts):

input CreateMasterInput {                  input UpdateMasterInput {          // PartialType(MasterCommonInput)
  name: String!                              name code number description
  code number description: String            isActive key categories[] file
  isActive: Boolean                        }
  key: String
  categories: [String!]                    input MasterImageInput { base64Str, filename, filetype }
  file: MasterImageInput
  parentId: ID                             input MasterImportInput { file: Upload }
}
                                           input ConfirmMasterImportInput { masters: [ImportMasterInput!]! }
input ImportMasterInput { parentId, name, code, description }

MasterImport (preview row returned by importMasters): { dimensionType, key, parentId, parentName, name, code, description, error }.

masterPage being authNotRequired is deliberate: the admin prefetches all masters at session bootstrap (and the login/setup screens need them) before a full auth context exists.

REST controller

GET /api/maintainance/master/download   (@ApiAuthorize)

Streams an XLSX report of master rows (filtered by keyword, key, parentId). Columns: Code (code|number|ref|-), Name (uppercased), Description, Status (Active/Inactive), Key (parent key uppercased, - for children), Created At. PDF type is accepted by the admin button but the controller only implements XLSX. Source: master/master.controller.ts.

4. Seeded entities (every type & its options)

Seed source: master/constants.ts (mst) + hr/payroll/payroll-country.ts (MALAYSIA_STATUTORY_SEEDS, spread in via getCountrySpecificMasterSeeds()). Run at boot by MasterModule.onModuleInit → seed().

Currency · key currency

NGN · MYR · USD · EUR · GOLD

Used by: finance (amounts, multi-currency), the exchange module (rate pairs), orders/trades. The $lookupCurrency helper joins a document's currencyId to its currency master. Exchange rates are not stored here — see finance/_overview.

UOM (Unit of Measure) · key uom

PCS · KG

Used by: inventory items (item.uomId), order lines (multi-UOM entry → base conversion), and the UOM-conversion module. On item XLSX import, the BE resolves a UOM string to its master row by name:

// inventory/item/item.service.ts
const uom = UOM ? await this.masterSvc.findOne({ name: this.exactNameQuery(UOM), ignoreCompanyId: true }) : null;

See inventory/categories-uom.

Tax Type · key tax_type

vat → "Value Added Tax (VAT)"
wht → "Withholding Tax (WHT)"

Used by: finance taxation, item tax setup. See finance/taxation.

Price Level · key price-level

Retail · Wholesale · VIP

Used by: inventory pricing (per-level item prices). See inventory/pricing.

Item Type · key item-type

Inventory · Service

Drives whether an item moves stock (isInventoryItem). See inventory/item and the stock-flow guard in zerp-be/docs/inventory-stock-flow.md.

Item Category · key item-category

Parent only — user-populated. (Distinct from the product_categories collection that categories refs join to.)

Industry · key industry

Parent only — user-populated. Used by company profile/setup.

Reporting dimensions

Five parent-only types, all flagged in REPORTING_DIMENSION_KEYS:

key name auto-code prefix
cost_center Cost Center CC
department Department DEP
class Class CLS
analysis_code Analysis Code AC
location Location LOC

Children (added by users or imported) require a unique code per parent. At boot, backfillReportingDimensionCodes() assigns codes to any uncoded children:

// master/master.service.ts
generateDimensionCode(key, seq) {  // → "CC001", "DEP001", ...
  const prefixes = { cost_center:"CC", department:"DEP", class:"CLS", analysis_code:"AC", location:"LOC" };
  return `${prefixes[key] || "DIM"}${String(seq).padStart(3, "0")}`;
}

Consumed by finance journals/accounts/reports as GL analysis dimensions.

Leave Type · key leave_type

30 seeded options (HR leave). Full list: Annual, Sick, Casual, Maternity, Paternity, Parental, Adoption, Unpaid, Paid, Leave Without Pay, Compassionate, Bereavement, Emergency, Study, Examination, Training, Marriage, Religious, Public Holiday, Medical, Hospitalization, Family Care, Caregiver, Compensatory, Time Off in Lieu, Half-Day, Short, Work From Home, Special, Administrative, Suspension. See hr/leave.

Gender · key gender

MALE · FEMALE · OTHER

The only type whose admin create form exposes an image upload (avatar). Used by HR employee.

Employment Type · key employment_type

FULL_TIME · PART_TIME · CONTRACT · INTERN · DOMESTIC_SERVANT

Marital Status · key marital_status

SINGLE · MARRIED · DIVORCED · WIDOWED

Nationality · key nationality

NIGERIAN · MALAYSIAN · SINGAPOREAN

Used by HR employee + payroll (isMalaysianNationality).

Statutory Type · key statutory_type (from payroll country engine)

EPF · SOCSO · EIS · PCB

Contributed by MALAYSIA_STATUTORY_SEEDS, not the static mst array. See hr/payroll.

5. Business rules & calculations

Normalization (every create/update) — normalizeMaster()

if (name) name = name.toUpperCase();
if (key)  key  = key.toLowerCase();
const dimensionCode = (number || code || ref || "").trim();
if (dimensionCode) { code = number = ref = dimensionCode.toUpperCase(); }  // keep code/number/ref aligned
if (defaultActive && isActive == null) isActive = true;                    // create only

Validation — validateMaster()

Only enforces rules for reporting-dimension children (parent key ∈ REPORTING_DIMENSION_KEYS):

  • code required → 400 "<Type> code is required".
  • (parentId, code) unique → 409 "<Type> code (<code>) already exists" (excludes self on update).

Non-dimension masters have no service-level validation beyond name being GraphQL-required.

Create-time key uniqueness

create() rejects a duplicate key: "Master with key (<key>) already exist" (only checked when a key is supplied, i.e. for parent types).

Seeding idempotency — createMaster()

Parent exists → only missing children are added. Parent absent → create parent then all children. Re-running seed() never duplicates and back-fills newly-added seed entries. (Full code in _overview.md §2.)

Import pipeline (reporting dimensions only)

importMasters(file)                     confirmMasterImport(rows)
  parse XLSX (XlsxUtils)                  for each row with parentId+name+code:
  preload 5 dimension parents              upsert by (parentId, code.toUpperCase()):
  per row: resolve type by key/name          existing → update(id, model)
  validate (type/parent/name/code)            else     → create(model)
  return [MasterImport] w/ error col       (skip rows missing parentId|name|code)
  ── nothing persisted ──                  ── persists, idempotent by (parentId,code) ──

import() resolves a row's Dimension Type column to a parent either by normalized key ("Cost Center"cost_center) or by uppercased name, and stamps a per-row error string for the preview. Column aliases accepted: Dimension Type/Type/Dimension/Key, Name/Dimension Name, Code/Dimension Code, Description.

Side effects

  • Audit: create/update/delete and both import mutations carry @AuditMeta({ module:'master', collection:'masters', snapshots:[...] }) → audit-trail snapshots. See platform/audit-trail.
  • No GL legs, no stock, no notifications — master data is pure reference.

Transactionality

CRUD is single-document; the seed loop is sequential (not one transaction). No withRetryTransaction wrapping in this module.

6. Permissions

  • Resolver class is decorated @ApGqlAuthorize() (login required) for all ops except masterPage, which overrides with @ApGqlAuthorize({ ignoreCompanyQuery: true, authNotRequired: true }) so the public/boot read works without a tenant or full auth.
  • REST download uses @ApiAuthorize().
  • Admin write access to the master page itself is gated at the route level: pages/master.tsx redirects unless session.user.kind === UserKindTypes.SuperAdmin.
  • No CASL ability checks specific to master data. See platform/permissions-access.

7. Admin UI

Routes / pages (zerp-admin/src/pages)

Route Renders Notes
/master MasterContextProviderMasterPage inside SetupLayout SuperAdmin only (getServerSideProps redirect otherwise).
/finance/reporting-dimensions reporting-dimension view of the same MasterPage (reportingDimensionsOnly) filters to cost_center, department, class, analysis_code.
/finance/reporting-dimensions/import MasterImport XLSX upload → preview. Template at /templates/reporting-dimensions-template.xlsx.
/finance/reporting-dimensions/confirm-import ConfirmMasterImport editable preview table → confirm selected rows.

Module files (zerp-admin/src/modules/master)

  • page.tsxApTable tree (parent rows expand to children via NodeService.mapParentAndChildren). Columns: Name, (Code — reporting only), Status, Key, Created At, Action (edit / add-child / delete). Search by Name/Key/Code; ApDownloadButton hits maintainance/master/download.
  • create/index.tsx — Formik create/update modal. FormSchema = { name required, code, key }. Conditional fields: reporting dimensions show Code+Description+Status; type==='parent' shows Key; type==='child' shows Category multiselect; gender shows image upload. Auto-creates the parent type if a known key is referenced but no parent row exists yet.
  • components/import.tsx, components/confirmImport.tsx, components/rowContent.tsx.

context.tsx — the single state owner (useMasterState)

Exposed methods/state: master, masterNode (tree), totalRecords, loading, updateLoading, imported, fetchMaster(), saveMaster(id|null, data) (create-or-update), deleteMaster(item), importMaster({file}), confirmMasterImport(rows), and the key read hook:

getMasterByKey(key) => { ...parent, label, value, children: children.map(c => ({...c, label:c.name, value:c._id})) }

MasterContext is mounted globally in _app.tsx and fetchMaster() is called at session bootstrap, so getMasterByKey('uom' | 'currency' | 'gender' | …)?.children is the universal dropdown source for every other admin module. (Consumer registry in _overview.md §5.)

8. Dependencies & integrations

  • Imports: AuthModule, UserModule, ConfigModule, ApUploadModule (XLSX upload), MongooseModule.forFeature(Master).
  • Depended on by (BE, via forwardRef(MasterService)): inventory item / order / price-level / uom-conversion; finance account / journal / taxation / transaction / payment / note / cashbook / report; assets; customer; company; HR payroll (settings, contribution group, employee & company statutory). See module-map.md.
  • Boot coupling: imports createPayrollCountryEngine from hr/payroll/payroll-country for statutory_type seeds — master seeding therefore depends on the payroll country engine.
  • External: S3/upload for the gender image; XLSX (zync-nest-library XlsxUtils) for import/download. No cron, no events emitted.

9. Gotchas & project-specific rules

  1. Global, not tenant-scoped. Every service method calls contextSvc.setCompany(undefined) first. Don't expect companyId filtering — masters are shared across all tenants.
  2. name is force-uppercased; key force-lowercased on every write. Stored display names won't match user casing.
  3. code, number, ref are kept in lockstep for any row with a code — they are all set to the same uppercased value. Reporting dimensions rely on this.
  4. key ⇒ parent, parentId ⇒ child. This is the entire type system; there are no enums. Querying a "type" means findOne({ key }) then reading its children.
  5. children on the schema is transient — it exists only so the seed array can declare nested options; it is never stored as an array on a document.
  6. masterPage is publicly readable (authNotRequired). Intentional for boot prefetch; keep in mind for any data-sensitivity review.
  7. Only reporting dimensions support bulk import and require codes; all other types are point-and-click in the admin (and most are seeded, not user-created).
  8. Master page is SuperAdmin-gated in the admin even though the backend read is public.
  9. Adding a new lookup type = add to mst and restart — seeding back-fills it idempotently. No migration needed. New options under an existing type are likewise back-filled.
  10. Stored references are child _ids, but legacy free-text names exist; consumers fall back to matching by name (findMasterItem() in employee-form.tsx).