Master Reference Data — the single polymorphic lookup collection
One-paragraph framing: The whole master module reduces to one self-referencing
masterscollection. A type is a parent row identified by a stablekey(currency,uom,tax_type,cost_center, …); each option is a child row pointing at that parent viaparentId. 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 emptycode, but two children under the same parent cannot share a non-emptycode.
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 }.
masterPagebeingauthNotRequiredis 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;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 onlyValidation — validateMaster()
Only enforces rules for reporting-dimension children (parent key ∈ REPORTING_DIMENSION_KEYS):
coderequired →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 exceptmasterPage, which overrides with@ApGqlAuthorize({ ignoreCompanyQuery: true, authNotRequired: true })so the public/boot read works without a tenant or full auth. - REST
downloaduses@ApiAuthorize(). - Admin write access to the master page itself is gated at the route level:
pages/master.tsxredirects unlesssession.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 |
MasterContextProvider → MasterPage 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.tsx—ApTabletree (parent rows expand to children viaNodeService.mapParentAndChildren). Columns: Name, (Code — reporting only), Status, Key, Created At, Action (edit / add-child / delete). Search by Name/Key/Code;ApDownloadButtonhitsmaintainance/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;gendershows image upload. Auto-creates the parent type if a knownkeyis 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
createPayrollCountryEnginefromhr/payroll/payroll-countryforstatutory_typeseeds — master seeding therefore depends on the payroll country engine. - External: S3/upload for the
genderimage; XLSX (zync-nest-libraryXlsxUtils) for import/download. No cron, no events emitted.
9. Gotchas & project-specific rules
- Global, not tenant-scoped. Every service method calls
contextSvc.setCompany(undefined)first. Don't expectcompanyIdfiltering — masters are shared across all tenants. nameis force-uppercased;keyforce-lowercased on every write. Stored display names won't match user casing.code,number,refare kept in lockstep for any row with a code — they are all set to the same uppercased value. Reporting dimensions rely on this.key⇒ parent,parentId⇒ child. This is the entire type system; there are no enums. Querying a "type" meansfindOne({ key })then reading its children.childrenon 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.masterPageis publicly readable (authNotRequired). Intentional for boot prefetch; keep in mind for any data-sensitivity review.- 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).
- Master page is SuperAdmin-gated in the admin even though the backend read is public.
- Adding a new lookup type = add to
mstand restart — seeding back-fills it idempotently. No migration needed. New options under an existing type are likewise back-filled. - Stored references are child
_ids, but legacy free-text names exist; consumers fall back to matching byname(findMasterItem()inemployee-form.tsx).