HR domain — overview, entity map & cross-module flows

The whole HR domain reduces to one idea: everything hangs off the Employee record, and every "request" document (leave, claim, loan, advance) is approved through one shared HR approval engine before it can affect payroll. The Employee (employee) wraps a login User and 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-employeeId rows.

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): employees and departments use @ApSchema (no prefix); leaves, claims, loans, advances, payroll, attendances, calendars, hr_timesheets, hr_approval_policies, the training/payroll groups, etc. use @HrSchema (mostly hr_*, but several like leaves/claims/payroll/attendances/calendars keep 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 (hodIdUser); 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.userIdusers (1:1). The User holds identity + login; the Employee holds employment context. groupId (RBAC access group) is mirrored onto both. See employee §2.3.
  • Employee.reportingTo and Employee.hrId are Employee._id references (not user ids). The approval orchestrator re-resolves them to userId for routing (§4). reportingTo is also the sole source of the org-chart tree.
  • Department.hodIdusers (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 plain ObjectIds 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 reads basicSalary, the tax fields, and dateOfBirth off the employee.
  • Recruitment is the one HR-flavoured area not under Employee — it manages candidates before they become employees, and is wired into app.module.ts separately, so it is not behind the HR_MODULE feature gate via HrModule. (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/hrId are Employee._id but hodId is User._id, the orchestrator looks up the first two via EmployeeService.findById(...).userId and uses hodId directly. 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); HrApprovalService maps between the two via HR_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 (default PENDING_APPROVAL). §4.4.
  • Payroll lifecycle: DRAFT | PENDING_APPROVAL | APPROVED | PAID | CANCELLED (default DRAFT). §5.2.
  • Timesheet lifecycle: TimesheetStatus (default DRAFT); hr_timesheets indexed 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 *Id fields coerce 24-char hex → ObjectId; all dates are unix-ms numbers (BaseSchema.toUnixTimestamp).
  • Tenancy: every HR collection is scoped by companyId (+ branchId from 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)

  1. Recruitment is not under HrModule. It is a top-level module wired in app.module.ts; treat its feature-gating/permissions separately. recruitment.
  2. reportingTo is 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.
  3. Mixed manager-link target types. reportingTo/hrId = Employee._id; Department.hodId = User._id. The orchestrator handles both; replicators must not conflate them.
  4. 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).
  5. 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.
  6. 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.