Master Data — domain overview

The whole master-data domain reduces to one idea: every lookup, reference, and reporting dimension in zerp is a single row in one masters collection, self-referencing via parentId. A "type" (e.g. currency, uom, tax_type) is a parent Master with a stable key; each concrete option (NGN, KG, VAT) is a child Master pointing at that parent. There is one schema, one service, one resolver, and one admin module for all of it.

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


1. What master/reference data is

Master data in zerp is the seedable, cross-cutting reference data that the rest of the ERP looks up by name or id — units of measure, currencies, tax types, reporting dimensions (cost center, department, class, analysis code, location), industries, leave types, gender, employment type, marital status, nationality, price levels, item types/categories, and statutory types. It is not transactional data (orders, journals, payslips) and it is not company-scoped operational config — it is the shared vocabulary those documents reference.

The implementation is deliberately minimal: instead of one collection per lookup (a uoms table, a currencies table, …), zerp stores all of them in a single polymorphic masters collection and distinguishes them with a two-level parent/child tree:

Master(key="uom", name="UOM")                 ← TYPE (parent, has key, no parentId)
 ├─ Master(name="PCS", parentId=<uom._id>)     ← OPTION (child, has parentId, no key)
 └─ Master(name="KG",  parentId=<uom._id>)

Master(key="currency", name="Currency")
 ├─ Master(name="NGN", parentId=<currency._id>)
 ├─ Master(name="MYR", parentId=<currency._id>)
 └─ …

Rule of thumb that holds everywhere in the codebase:

Has key Has parentId Role
Type / parent — the category itself (currency, uom, cost_center). key is the stable handle other code queries by.
Option / child — a concrete value under a type (NGN, KG, a specific cost center).

The two-level tree is the only nesting in practice. parentId is a generic self-ref, so deeper trees are possible in the schema, but the seed and all consumers assume exactly type → option.

See master-reference-data.md for the full per-entity breakdown (schema, seeded values, where each is used).


2. How it is seeded at boot

The MasterModule implements OnModuleInit and calls MasterService.seed() on every application start — there is no separate migration or CLI step.

// master/master.module.ts
export class MasterModule implements OnModuleInit {
  constructor(private readonly masterService: MasterService) {}
  async onModuleInit() {
    await this.masterService.seed();
  }
}

seed() iterates the static mst array (master/constants.ts) plus the country engine's statutory seeds, calling createMaster() for each type. Seeding is idempotent:

// master/master.service.ts → createMaster()
const exist = await this.findOne({ key: master.key });
if (exist) {
  // parent already seeded → only add any NEW children that don't exist yet
  for (const c of master.children ?? []) {
    const childExist = await this.findOne({ key: c.key, parentId: exist._id });
    if (!childExist) await this.create({ ...c, parentId: exist._id });
  }
  return;
}
// first time → create parent, then all children under it
const m = await this.create(master);
for (const c of master.children ?? []) await this.create({ ...c, parentId: m._id });

So adding a new option to the seed array and restarting the app back-fills it without touching existing rows or duplicating. After the main loop, seed() runs backfillReportingDimensionCodes() to assign generated codes (CC001, DEP001, …) to any reporting dimension children that lack one (see §4 below and the costing/coding rules in master-reference-data.md).

Note: master data is global, not tenant-scoped. Every read/write in MasterService first calls this.contextSvc.setCompany(undefined), deliberately clearing the company filter so the masters collection lives in the shared/master DB rather than per-tenant. See platform/multi-tenancy.


3. Registry of all master entities (seeded types)

Every parent type seeded from master/constants.ts (mst) and the Malaysia statutory engine. The key column is the stable handle other modules query by; "Seeds children" lists the options created at boot (empty = parent only, children added by users via the admin UI).

key Name Seeded children Primary consumers
industry Industry (none — user-populated) Company profile / setup
cost_center Cost Center (none)reporting dimension Finance (journals, accounts, reports)
department Department (none)reporting dimension Finance, HR
location Location (none)reporting dimension Finance, inventory
class Class (none)reporting dimension Finance
analysis_code Analysis Code (none)reporting dimension Finance
currency Currency NGN, MYR, USD, EUR, GOLD Finance, exchange rates, orders, trades
uom UOM PCS, KG Inventory items, order lines, UOM conversion
tax_type Tax Type VAT, WHT Finance taxation, item tax setup
price-level Price Level Retail, Wholesale, VIP Inventory pricing
item-category Item Category (none) Inventory items
item-type Item Type Inventory, Service Inventory items (drives isInventoryItem)
leave_type Leave Type 30 types (Annual, Sick, Casual, … — full list in master-reference-data.md) HR leave
gender Gender MALE, FEMALE, OTHER HR employee
employment_type Employment Type FULL_TIME, PART_TIME, CONTRACT, INTERN, DOMESTIC_SERVANT HR employee
marital_status Marital Status SINGLE, MARRIED, DIVORCED, WIDOWED HR employee
nationality Nationality NIGERIAN, MALAYSIAN, SINGAPOREAN HR employee, payroll
statutory_type Statutory Type EPF, SOCSO, EIS, PCB HR payroll (Malaysia statutory)

statutory_type is contributed by the payroll country engine (hr/payroll/payroll-country.ts → MALAYSIA_STATUTORY_SEEDS), not the static mst array — it is spread into the seed via ...payrollCountryEngine.getCountrySpecificMasterSeeds(). See hr/payroll.

The exact seed arrays, field-by-field schema, and per-entity usage notes live in master-reference-data.md.


4. Reporting dimensions — the special case

Five keys are flagged as reporting dimensions and get extra validation + auto-coding:

// master/master.service.ts
public static readonly REPORTING_DIMENSION_KEYS =
  ["cost_center", "department", "class", "analysis_code", "location"];

For children whose parent key is in this list:

  • code is required (validateMaster() throws "<Type> code is required" if missing).
  • (parentId, code) must be unique — enforced both in validateMaster() (throws 409 on duplicate) and by a partial unique index in the schema.
  • At boot, backfillReportingDimensionCodes() assigns codes to any uncoded children using prefixes CC/DEP/CLS/AC/LOC + zero-padded sequence (CC001, DEP001, …).

Reporting dimensions are also the only master type exposed to bulk import (the XLSX import/confirm flow lives entirely in the master module but is surfaced in the admin under Finance → Reporting Dimensions, not the master page). See master-reference-data.md for the import pipeline.


5. Who consumes master data

Master data is referenced by almost every other domain. Consumers either (a) call MasterService.findByParentKey(key) / findOne({ name }) on the backend, or (b) read getMasterByKey(key) / masterNode from the global admin MasterContext.

Backend consumers (inject MasterService, found via forwardRef): inventory item (inventory/item/item.service.ts resolves UOM by name during import), order & order-item, price-level, uom-conversion, finance (account, journal, taxation, transaction, payment, note, cashbook, report), assets, customer, company, exchange, HR payroll (settings, contribution group, employee/company statutory). See module-map.md.

Admin consumers (global MasterContext mounted in _app.tsx, prefetched at session start): finance account/journal/note/payment/transaction pages, inventory order/stock pages, HR employee/leave/claim forms, assets, company setup, and the entire reporting suite (report/*). The canonical read pattern:

// e.g. hr/employee-form/employee-form.tsx
const items = getMasterByKey('gender')?.children ?? [];   // dropdown options

Stored master references are persisted as the child's _id (with legacy free-text name fallback handled by findMasterItem() in employee-form.tsx).


This domain has a single backend/admin module pair (master), documented in:

  • master-reference-data.md — the one-and-only sub-module doc: full schema, every seeded entity, the GraphQL/REST surface, the create/import/confirm flows, and the admin UI. Read this for everything concrete.

Master data is intentionally narrow. Several adjacent reference/organizational concerns are documented as sibling docs in this same domain (and a few in other domains), cross-linked here so the registry is discoverable:

  • Company / branch / store (the tenant + warehouse records that master rows are not scoped to; also owns fiscal periods) — see company-branch.
  • Exchange rates (the (from,to) → rate lookup table that uses the currency master as its currency dictionary) — see exchange-rate.
  • Fiscal periods / accounting calendar — owned by the company record; see company-branch and finance/_overview.
  • Runtime config / subscription (operator-editable key/value settings, distinct from seeded reference data) — see subscription-config/_overview.
  • Product categories & UOM in inventory context (how items consume the uom/item-category masters) — see inventory/categories-uom.

Cross-cutting platform behavior referenced above: multi-tenant DB routing (platform/multi-tenancy), auth/permissions on the master resolver (platform/permissions-access), audit snapshots on create/update/delete (platform/audit-trail).


7. Shared enums / constants (domain-level)

There are no GraphQL enums in this domain — every "type" is data (a parent Master row), not a TypeScript/GraphQL enum. The closest things to enums are two service-level constant arrays:

// master/master.service.ts
REPORTING_DIMENSION_KEYS = ["cost_center", "department", "class", "analysis_code", "location"];

// master/constants.ts → mst[].key values are the canonical key vocabulary:
//   industry, cost_center, department, location, class, analysis_code,
//   currency, uom, tax_type, price-level, item-category, item-type,
//   leave_type, gender, employment_type, marital_status, nationality
//   (+ statutory_type from the payroll country engine)

These string keys are the contract between the master module and every consumer — they are the only stable identifiers; _ids differ per environment because they are generated at first boot.