HR domain — overview, entity map & cross-module flows
The whole HR domain reduces to one idea: everything hangs off the
Employeerecord, and every "request" document (leave, claim, loan, advance) is approved through one shared HR approval engine before it can affect payroll. TheEmployee(employee) wraps a loginUserand carries the group assignments (leaveGroupId,attendanceGroupId,claimGroupId,payrollContributionGroupId, …) that wire it into each downstream engine. Those engines (leave, attendance, timesheet, claim, loan, advance, payroll, training) only ever read the employee + its groups and write their own keyed-by-employeeIdrows.
Source: BE src/modules/hr/* (mounted via hr/hr.module.ts) · Admin src/modules/hr/*, src/modules/employees, src/modules/department · Recruitment BE src/modules/recruitment/* (mounted directly in app.module.ts, not under HrModule — see §3).
1. What lives in the HR domain
hr/hr.module.ts imports & re-exports the following module set (verbatim from the imports array):
EmployeeModule, DepartmentModule, CalendarModule, LeaveGroupModule, LeaveModule, ClaimModule, ClaimGroupModule, AdvanceModule, LoanModule, AttendanceModule, TimesheetModule, PayrollModule, PayrollContributionGroupModule, PayrollItemSettingModule, TaxBracketModule, HrDashboardModule, EssModule, TrainingModule, OrgChartModule, HrApprovalPolicyModule, HrApprovalOrchestratorModule, HrApprovalModule, EaFormModule, CompanyStatutoryModule, BorangEModule.
Every HR resolver is gated by @RequireFeature('HR_MODULE') + GqlFeatureGuard (subscription feature gate) on top of @ApGqlAuthorize() (JWT). See permissions-access and subscription-config.
Collection-prefix gotcha (domain-wide):
employeesanddepartmentsuse@ApSchema(no prefix);leaves,claims,loans,advances,payroll,attendances,calendars,hr_timesheets,hr_approval_policies, the training/payroll groups, etc. use@HrSchema(mostlyhr_*, but several likeleaves/claims/payroll/attendances/calendarskeep their plain names). Don't assume a uniform prefix.
2. HR sub-modules (the full list)
Each links to its own rebuild-grade doc (those not yet written are still listed for completeness).
| Sub-module | One-line role | Doc |
|---|---|---|
| Employee | Person master record wrapping a User; the hub every other HR doc references by employeeId. Holds group assignments, statutory/tax fields, reportingTo/hrId. |
employee |
| Department | Flat named grouping with one optional head (hodId → User); employees point at it via departmentId. No hierarchy, no cost-centre. |
department |
| Org-chart | Read-only derived view: builds the reporting tree from Employee.reportingTo and groups people by department; never stored. |
org-chart |
| Calendar | Company working calendar / public holidays (calendars); the basis for leave-day and attendance-day calculations. |
calendar |
| Attendance | Clock-in/out + biometric-device records (attendances); employees mapped to attendance/shift groups (attendanceGroupId); back-links device swipes on employee.created. |
attendance |
| Timesheet | Daily worked-hours records (hr_timesheets, employeeId+date indexed); DRAFT→approval; feeds payroll completion metrics. |
timesheet |
| Leave | Leave requests (leaves); leaveTypeId + leaveGroupId policy; 5-state lifecycle; paid/unpaid flows into payroll. |
leave |
| Payroll | Monthly pay runs (payroll); proration, Malaysia PCB/MTD tax, statutory contributions (EPF/SOCSO/EIS), HRDF levy, GL journal posting. |
payroll |
| Claim | Expense/benefit claims (claims) under claimGroupId policy; reimbursable; 5-state lifecycle; approved claims payable. |
claim |
| Loan | Staff loans (loans) with a transaction ledger (LoanTransactionService); repayment schedule deducted via payroll. |
loan |
| Advance | Salary advances (advances); 5-state lifecycle; recovered through payroll deduction. |
advance |
| Training | Training programs + assignments, groups, progress & certificates (training* collections); assignable by employee/department. |
training |
| ESS | Employee Self-Service: separate auth (PIN/QR), profile, and QR clock-in for staff (EssAuthResult, AttendanceQrResult). |
ess |
| Approvals | The HR approval inbox/action surface (HrApprovalService); read pending tasks, approve/reject — a façade over the workflow engine (see §4). |
approvals |
| Dashboard | Aggregated HR metrics (HrDashboardSummary): headcount, attendance, timesheet completion by dept, approvals, payroll, training, leave, loan/advance exposure. |
dashboard |
| Recruitment | Job postings, applicants, interview schedules, job offers (src/modules/recruitment). Mounted in app.module.ts, not in HrModule, and gated by its own @RequireFeature('RECRUITMENT_MODULE') (not HR_MODULE) — see §3. |
recruitment |
Supporting / nested modules (documented within the parents above): LeaveGroupModule & ClaimGroupModule (policy groups), PayrollContributionGroupModule, PayrollItemSettingModule, TaxBracketModule, EaFormModule, CompanyStatutoryModule, BorangEModule (all under payroll), HrApprovalPolicyModule & HrApprovalOrchestratorModule (the engine under approvals).
3. Domain entity map — how everything ties to Employee
User (users) Department (departments)
identity ref, name, hodId → User
▲ ▲
userId │ (1:1) departmentId │ (optional)
│ │
permission_groups ◀─groupId─┐ │
(RBAC, mirrored to User) │ │
┌────┴─────────────── Employee ──────────┘
│ (employees — the hub)
│ reportingTo → Employee._id (line manager)
│ hrId → Employee._id (HR officer)
│ leaveGroupId / attendanceGroupId / claimGroupId
│ payrollContributionGroupId / payrollItemGroupId
│ basicSalary, taxResidencyStatus, taxCategory, dateOfBirth, …
│
every downstream HR document references employeeId = Employee._id:
┌──────────┬──────────┬──────────┬──────────┬──────────┬──────────┬──────────┐
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
Leave Claim Loan Advance Attendance Timesheet Training Payroll
(leaves) (claims) (loans) (advances)(attendances)(hr_time…) (training*)(payroll +
employee-payroll,
└────────┴──────────┴──────────┘ statutory,
approvable docs → HR approval engine (§4) payroll-item)
Key relationship facts (all confirmed in code):
Employee.userId→users(1:1). TheUserholds identity + login; theEmployeeholds employment context.groupId(RBAC access group) is mirrored onto both. See employee §2.3.Employee.reportingToandEmployee.hrIdareEmployee._idreferences (not user ids). The approval orchestrator re-resolves them touserIdfor routing (§4).reportingTois also the sole source of the org-chart tree.Department.hodId→users(a User, not an Employee) — the one manager-link that is a User. department §9.- Group assignments on the employee (
leaveGroupId,attendanceGroupId,claimGroupId,payrollContributionGroupId, …) are plainObjectIds read directly by each engine; they decide the policy applied to that employee. - Payroll-specific employee data lives in separate collections keyed by
employeeId:EmployeePayroll,EmployeePayrollItem,EmployeeStatutory(under payroll). Payroll readsbasicSalary, the tax fields, anddateOfBirthoff the employee. - Recruitment is the one HR-flavoured area not under
Employee— it manages candidates before they become employees, and is wired intoapp.module.tsseparately, so it is not behind theHR_MODULEfeature gate viaHrModule. (Verify its own feature gating in recruitment.)
4. HR approval / workflow integration
All four "request" documents — Leave, Claim, Loan, Advance — share an identical approval spine. They do not implement their own approval logic; they delegate to a thin HR layer on top of the generic platform workflow-approval-engine.
4.1 The three approval modules
| Module | Role |
|---|---|
HrApprovalPolicyModule |
Stores the per-kind approval policy (hr_approval_policies). One policy per HrApprovalKind (LEAVE / CLAIM / LOAN / ADVANCE), each with an ordered list of levels ({ role, order, approveBy }). Roles: MANAGER, HEAD_OF_DEPARTMENT, HR. Defaults seeded via approval-policy.defaults.ts. |
HrApprovalOrchestratorModule |
HrApprovalOrchestratorService — resolves a policy's abstract roles into concrete approver user ids for a given employee, builds dynamic workflow stages, and submits to the WorkflowEngine. Also cancelWorkflow(refId) to archive stale tasks on document cancellation. |
HrApprovalModule |
HrApprovalService — the inbox / action façade. Lists an approver's pending HR tasks and exposes approve/reject/cancel, translating between HR statuses and workflow task/approval statuses. Owns no schema (pure orchestration over WorkflowTaskService + WorkflowApprovalService + the four domain services). See approvals. |
4.2 Role → approver resolution (the crucial mapping)
HrApprovalOrchestratorService.validateAndResolveStages(employeeId, kind):
WorkflowTaskKind → HrApprovalKind (Leave→LEAVE, Claim→CLAIM, Loan→LOAN, Advance→ADVANCE)
policy = findByKind(approvalKind, companyId) (must exist and have ≥1 level)
role → approver User._id:
MANAGER = employee.reportingTo → look up that Employee → its userId
HEAD_OF_DEPARTMENT = department(employee.departmentId).hodId (already a User._id — no lookup)
HR = employee.hrId → look up that Employee → its userId
stages = policy.levels (sorted by order)
.filter(level → an approver could be resolved for level.role)
.map(level → { name, order, approveBy, approverIds:[resolvedUserId] })
if no stages resolve → CustomError "configure the policy / assign an approver"
Because
reportingTo/hrIdareEmployee._idbuthodIdisUser._id, the orchestrator looks up the first two viaEmployeeService.findById(...).userIdand useshodIddirectly. A level whose role has no assignee is silently dropped — so an employee with no manager simply skips the manager stage.
4.3 Submit / approve / reject
document submitted (e.g. createLeave)
→ status set to PENDING_APPROVAL (domain service)
→ orchestrator.resolveAndSubmit({ refId, kind, employeeId, submitterId })
→ validateAndResolveStages → dynamicStages
→ WorkflowEngine.submit({ refId, kind, submitterId, dynamicStages })
→ creates WorkflowTask (AWAITING_APPROVAL) + per-stage WorkflowApproval rows
approver opens inbox (HrApprovalService.page) → approve / reject
→ WorkflowEngine.approve/reject → advances/closes stages
→ on final approval/rejection the domain doc status → APPROVED / REJECTED
→ notifications to next approver / submitter (see platform doc)
document cancelled → orchestrator.cancelWorkflow(refId) → WorkflowTaskService.archiveByRefId
→ pending tasks removed from approver inboxes; doc status → CANCELLED
4.4 Shared request-document status enum
Leave, Claim, Loan, and Advance all use the same 5-state lifecycle (verbatim from each *.schema.ts):
enum {Leave|Claim|Loan|Advance}Status {
PENDING = "PENDING",
PENDING_APPROVAL = "PENDING_APPROVAL", // default on submit; in the approval engine
APPROVED = "APPROVED",
REJECTED = "REJECTED",
CANCELLED = "CANCELLED",
} submit approve (payroll/payout)
[draft] ─────────▶ PENDING_APPROVAL ─────────▶ APPROVED ─────────▶ (consumed)
│ │
reject │ │ cancel
▼ ▼
REJECTED CANCELLED
The platform workflow side uses its own
WorkflowTaskStatus(PENDING/AWAITING_APPROVAL/APPROVED/REJECTED/ARCHIVED);HrApprovalServicemaps between the two viaHR_STATUS_TO_TASK_STATUS/TASK_STATUS_TO_HR_STATUS.
5. Main cross-submodule flows
5.1 Onboarding (new hire → active employee)
1. Admin /hr/employees → Add Employee (or bulk import XLSX, or invite existing user)
→ createEmployee: tx { userSvc.create(kind=Staff, roles=[Staff]) → employeeRepo.create(userId) }
→ emit `employee.created`
2. emit `employee.created` → AttendanceService.linkDeviceRecords(ref)
→ back-links any biometric device swipes already logged under that staff ref [attendance]
3. Admin assigns the employee to:
- a Department (departmentId) → org-chart + dashboard grouping [department]/[org-chart]
- line manager (reportingTo) + HR officer (hrId) → approval routing (§4) [employee §9: reportingTo not in standard form]
- policy groups: leaveGroupId / attendanceGroupId / claimGroupId / payrollContributionGroupId
4. Set compensation + tax: basicSalary, taxResidencyStatus, taxCategory, numberOfChildren, annualPersonalRelief
5. (optional) adminSetEmployeePin → enables ESS / QR clock-in [ess]
→ Employee is now "active" (implicit; no status field — active until resignDate set or soft-deleted)
Caveat: reportingTo is read by org-chart/approvals but is not settable through the standard create/update form — see employee §9. Without it, the MANAGER approval stage is skipped (§4.2).
5.2 Monthly payroll cycle
Throughout the month
• Attendance recorded (clock-in/out + device) [attendance]
• Timesheets filled (DRAFT → approved) [timesheet] → drives PayrollCompletionByDepartment
• Leave / Claim / Loan / Advance requests submitted & approved (§4)
Run payroll (Payroll status machine)
DRAFT ─▶ PENDING_APPROVAL ─▶ APPROVED ─▶ PAID
│ ▲
cancel │ │ (CANCELLED at any point)
▼
CANCELLED
Calculation (payroll.service.ts), per employee in the run period [fromDate..toDate]:
1. Prorate basic salary + fixed allowances + deductions for mid-month join/resign (line 264)
2. Apply unpaid-leave / absence effects (reduce paid days; absences excluded from HRDF base)
3. Allowance + deduction payroll items (EmployeePayrollItem) — see [payroll]
4. Statutory contributions (EPF/SOCSO/EIS) via payrollContributionGroupId + company statutory
5. PCB/MTD income tax — month-to-date true-up from earlier APPROVED/PAID runs (line 85, 237);
current-month zakat (D_ZAKAT) offsets PCB ringgit-for-ringgit (line 316)
6. HRDF levy — employer-only, Malaysian employees, on basic+fixed allowances (line 358)
→ roll up: totalGrossSalary, totalNetSalary, totalEmployee/EmployerContributions, totalTaxDeduction, totalHrdfLevy
→ calculatedAt stamped
Finalise
→ on APPROVE/PAY: post a GL journal entry (journalEntryId, journalLiabilityAccountId,
paymentAccountId) — only deductions get their own CR line (line 615). → [finance/accounts]
→ year-end statutory artefacts: EA Form (per employee), Borang E (employer) [payroll/ea-form, payroll/borang-e]
5.3 Leave-to-payroll (representative request→payroll path; Claim/Loan/Advance follow the same spine)
1. Employee/admin createLeave → Leave.status = PENDING_APPROVAL [leave]
fields: employeeId, leaveTypeId, leaveGroupId, fromDate, toDate, duration, isHalfDay, isPaid, reasonForLeave
2. orchestrator.resolveAndSubmit({ kind: Leave, employeeId, … }) (§4.2/4.3)
→ WorkflowEngine.submit → WorkflowTask(AWAITING_APPROVAL) + stage approvals (Manager → HOD → HR per policy)
3. Approvers act via HR inbox (HrApprovalService) [approvals]
→ all stages approved → Leave.status = APPROVED (or REJECTED / CANCELLED → cancelWorkflow archives tasks)
4. Monthly payroll run accounts for APPROVED leave in [fromDate..toDate]:
→ isPaid=false (unpaid leave) reduces paid days → lower gross/net + excluded from HRDF base [payroll §5.2]
→ paid leave: no deduction
5. Claim/Loan/Advance follow the same submit→approve spine (§4). How their APPROVED amounts feed pay
(claim payable, loan repayment, advance recovery) is handled via payroll items — confirm the exact
wiring in [payroll]/[loan]/[advance]/[claim]; `payroll.service.ts` does not import those services directly.
6. Shared enums & cross-cutting rules (domain quick-reference)
- Request lifecycle (Leave/Claim/Loan/Advance):
PENDING | PENDING_APPROVAL | APPROVED | REJECTED | CANCELLED(defaultPENDING_APPROVAL). §4.4. - Payroll lifecycle:
DRAFT | PENDING_APPROVAL | APPROVED | PAID | CANCELLED(defaultDRAFT). §5.2. - Timesheet lifecycle:
TimesheetStatus(defaultDRAFT);hr_timesheetsindexed on{employeeId, date}and{status}. - Approval roles:
MANAGER | HEAD_OF_DEPARTMENT | HR; approval kinds:LEAVE | CLAIM | LOAN | ADVANCE. §4. - Employee tax/employment enums:
TaxResidencyStatus,TaxCategory(GraphQL-registered);EmploymentType,EmployeeGender(constants only, passed as strings). See employee §2.2. - IDs & dates: all
*Idfields coerce 24-char hex →ObjectId; all dates are unix-ms numbers (BaseSchema.toUnixTimestamp). - Tenancy: every HR collection is scoped by
companyId(+branchIdfrom context). Soft-delete (mongoose-delete) excludes rows from all aggregations. - Feature gate:
@RequireFeature('HR_MODULE')across HR resolvers (recruitment excepted — verify in recruitment).
7. Module dependency cheat-sheet
| Consumer | Reads from | Why |
|---|---|---|
| org-chart | employee (reportingTo, departmentId), department (hodId) |
build reporting tree + dept grouping |
| approval-orchestrator | employee (reportingTo, hrId, userId), department (hodId), approval-policy |
resolve approvers, build stages |
| approvals (HrApprovalService) | leave / claim / loan / advance services, workflow task & approval, user, employee | inbox + approve/reject |
| payroll | employee (basicSalary, tax, dateOfBirth), contribution/item groups (EmployeePayrollItem/EmployeeStatutory), finance accounts |
calculate & post pay runs (loan/advance/claim not imported directly by payroll.service.ts) |
| dashboard | employee, department, attendance, timesheet, leave, loan, advance, training, payroll | aggregate metrics |
| attendance | employee (ref, attendanceGroupId); listens to employee.created |
shift policy + device back-link |
| ess | employee (pin), attendance (QR clock-in) |
self-service auth & actions |
8. Gotchas (domain-level)
- Recruitment is not under
HrModule. It is a top-level module wired inapp.module.ts; treat its feature-gating/permissions separately. recruitment. reportingTois read everywhere but writable nowhere standard. Org-chart and the MANAGER approval stage depend on it, yet it is absent from the employee create/update/import DTOs (set only via migration). See employee §9. Without it, hierarchy and manager-approval silently degrade.- Mixed manager-link target types.
reportingTo/hrId=Employee._id;Department.hodId=User._id. The orchestrator handles both; replicators must not conflate them. - Approval levels are dropped when unassigned — a missing manager/HOD/HR simply removes that stage rather than blocking submission (unless no stage resolves, which errors).
- Status fields differ by document — request docs use the 5-state request enum; payroll uses its own 5-state; timesheet has its own. They are not interchangeable.
- The HR approval modules own no document of their own beyond
hr_approval_policies; the actual approval state lives in the platform workflow collections. workflow-approval-engine.