Payroll — Malaysia monthly pay run, statutory engine & GL posting
The whole payroll model reduces to one idea: a
Payrollis a monthly run — a container ofPayrollEmployeerows — that moves through a five-state lifecycle (DRAFT → PENDING_APPROVAL → APPROVED → PAID, orCANCELLED). Adding employees snapshots their period inputs (overtime, unpaid leave, absence, statutory contributions). Calculate (runPayroll) is the engine: it reads each employee's standing payroll items, prorates them, computes Malaysia statutory deductions (EPF/SOCSO/EIS) and PCB/MTD income tax via a pure country engine, writes net/gross/employer-cost per employee, and rolls the run totals up. Approve locks the figures; Post Journal turns the run into a balanced GL journal entry (finance/journal); Mark as Paid closes it.
Source: BE src/modules/hr/payroll · Admin src/modules/hr/payroll
This product is Malaysia-only. All tax/statutory logic lives behind a thin PayrollCountryEngine abstraction (single member, no multi-country branching) so callers compile unchanged, but every path is Malaysian statute (PCB/MTD, EPF/KWSP, SOCSO/PERKESO, EIS/SIP, HRDF/HRD Corp).
1. Purpose & scope
The payroll module owns the monthly pay run and everything that feeds it:
- The run (
Payroll) — name, pay date, optional pay-periodfromDate/toDate, status, and roll-up totals. - The per-employee run line (
PayrollEmployee) — the snapshot of a person's pay for that run: base salary, allowances, overtime, unpaid-leave/absence deductions, statutory contributions, tax, net, employer cost. - Standing payroll items (
EmployeePayrollItem) — per-employee recurring/one-off earnings, deductions, overtime and tax-relief assignments, dated by[startDate, endDate], that the run reads at calculate time. - Item definitions (
PayrollItemSetting) — the catalogue of what an item is (type, taxability, EA-form category, GL account, frequency, additional-remuneration flag). - Statutory contributions (
PayrollContribution) — per-employee-per-run EPF/SOCSO/EIS amounts, split employee/employer; driven by contribution groups (PayrollContributionGroup). - Tax brackets (
TaxBracket) — per-year PCB bands + configurable personal/spouse/child reliefs + statutory relief caps. - Statutory identity — company registration numbers (
CompanyStatutory) and per-employee numbers (EmployeeStatutory) for EPF/SOCSO/EIS/PCB. - Statutory reporting — payslip (resolved on
PayrollEmployee), EA Form (CP8A) per employee per year, and Borang E (CP8D) company annual return. - The payroll → GL journal posting and its preview.
It explicitly does not:
- Own the employee master record — see employee. Payroll only reads
basicSalary,joinDate/resignDate,dateOfBirth,nationality,employmentType, tax fields, and thepayrollContributionGroupIdassignment. - Compute attendance/leave/timesheet — it reads approved leave, attendance, and timesheet to derive unpaid-leave, absence and overtime amounts.
- Manage the chart of accounts or post double-entry itself — it builds journal lines and hands them to finance/journal.
- Drive a generic approval workflow — payroll has its own hardcoded status machine (no workflow-approval-engine policies).
2. Data model
All collections extend BaseSchema (_id, companyId, branchId multi-tenant scoping, ref/documentCode/documentDate, createdAt/By, updatedAt/By, canUpdate/View/Delete/Post) and register mongoose-delete (soft-delete deletedAt). Most use the @HrSchema(...) decorator → collection names prefixed hr_*/payroll_*. All *Id fields coerce 24-char hex strings to ObjectId via BaseSchema.toObjectId. Dates are unix-ms numbers throughout (payDate, startDate, joinDate, …).
2.1 payroll — the run header
payroll/payroll.schema.ts (@HrSchema({ collection: "payroll" })).
| field | type | required | description |
|---|---|---|---|
name |
string | yes | Run label, e.g. "June 2026". Keyword-searchable. |
payDate |
number (unix ms) | yes | The pay date. Its calendar month is the tax/period anchor for proration, MTD month, and YTD year. |
fromDate |
number | — | Pay-period start. Defaults to the calendar month of payDate if unset (used for overtime/leave/absence windows). |
toDate |
number | — | Pay-period end. |
status |
enum PayrollStatus |
— | default DRAFT. See §4 state machine. |
totalGrossSalary |
number | — | Roll-up, written by runPayroll. |
totalNetSalary |
number | — | Roll-up. |
totalEmployeeContributions |
number | — | Σ employee statutory + tax (see runPayroll). |
totalEmployerContributions |
number | — | Σ employer statutory. |
totalEmployerCost |
number | — | Σ (paid gross + employer contributions + HRDF). |
totalHrdfLevy |
number | — | Σ employer HRDF/HRD Corp levy. |
totalTaxDeduction |
number | — | Σ PCB. |
calculatedAt |
number | — | Timestamp of last successful calculate. |
paymentAccountId |
ObjectId | — | (present, unused by the journal flow). |
journalEntryId |
ObjectId → journal | — | Set when the GL journal is posted; required before markAsPaid. |
journalLiabilityAccountId |
ObjectId → account | — | The salary-payable account chosen at post time. Resolved to an Account in GraphQL. |
export enum PayrollStatus {
DRAFT = "DRAFT",
PENDING_APPROVAL = "PENDING_APPROVAL",
APPROVED = "APPROVED",
PAID = "PAID",
CANCELLED = "CANCELLED",
}2.2 payroll_employee — the per-employee run line
payroll/employee/employee.schema.ts. One row per employee per run ({ payrollId, employeeId }). Two layers of fields: period inputs (snapshotted when the employee is added/resynced) and calculated outputs (written by runPayroll, gated by isCalculated).
| field | type | description |
|---|---|---|
employeeId |
ObjectId → employees | req. The person. |
payrollId |
ObjectId → payroll | req. The run. |
salary |
number | req. Snapshot of basic salary at add time (Σ isBasicSalary items), or 0. |
totalUnpaidLeaveAmount |
number | Period input — deduction for approved unpaid leave (see §4.4). |
totalOvertimeAmount |
number | Period input — overtime pay from approved timesheets. |
totalAbsenceDeductionAmount |
number | Period input — deduction for absent working days. |
leaveIds |
ObjectId[] | The unpaid leaves folded into the deduction (traceability). |
grossSalary |
number | calc. Paid gross = normal gross + additional remuneration. |
totalAllowances |
number | calc. Prorated allowances. |
totalDeductions |
number | calc. paidGross − netSalary. |
netSalary |
number | calc. Take-home (floored at 0). |
employerCost |
number | calc. paidGross + employerContributions + hrdfLevy. |
hrdfLevy |
number | calc. Employer HRDF levy (Malaysian employees only). |
taxDeduction |
number | calc. Monthly PCB/MTD. |
taxRelief |
number | calc. Monthly relief applied (display). |
taxableIncome |
number | calc. Monthly chargeable income (display). |
isCalculated |
boolean | default false. true after a successful run; reset to false on reject/resync. |
taxYear |
number | calc. payDate's year. |
ytdTaxDeducted |
number | calc. (reset helper field; YTD true-up is computed live in §4.6). |
When
isCalculatedisfalse, the GraphQL resolvers compute live estimates viaPayrollService.estimateEmpTotals(annualized PCB, no MTD true-up) so a DRAFT run still shows projected figures. After calculate, the stored fields are returned verbatim.
2.3 employee_payroll_items — standing item assignments (recurring vs one-off)
payroll/employee-item/employee-item.schema.ts. The per-employee assignment of a PayrollItemSetting, with an amount override and an effective date range. These are the source of truth for what gets paid; the run reads the active ones at payDate.
| field | type | description |
|---|---|---|
employeeId |
ObjectId → employees | req. |
itemSettingId |
ObjectId → payroll_item_settings | The item definition. |
amount |
number | Override amount for this employee. |
status |
enum EmployeePayrollItemStatus |
ACTIVE / INACTIVE. default ACTIVE. |
startDate |
number | req. Effective from. |
endDate |
number | Effective until (open-ended if unset). |
export enum EmployeePayrollItemStatus { ACTIVE = "ACTIVE", INACTIVE = "INACTIVE" }Active-at-payDate = status=ACTIVE and startDate ≤ payDate ≤ (endDate || ∞) (employee-item.repository.ts → findActiveForPayroll). Recurring vs one-off is a property of the linked PayrollItemSetting.frequency, not the assignment:
MONTHLY→ amount applied as-is each run within the date range.ANNUALLY→ amount ÷12-smoothed across the year (findActiveWithSettings), unless the setting isisAdditionalRemuneration(then the full lump sum, never smoothed).ONE_OFF→ descriptive only; the date range (startDate/endDateconfined to one month) is what makes it pay once. It does not by itself trigger bonus tax treatment.
An employee may not hold two overlapping active assignments of the same item (hasOverlap guard, enforced on import).
2.4 payroll_item_settings — the item catalogue
payroll/settings/item/setting.schema.ts. The definition of an earning/deduction/overtime/tax-relief.
| field | type | description |
|---|---|---|
name |
string | req. Display name (e.g. "Travel Allowance"). |
type |
enum PayrollItemSettingType |
ALLOWANCE / DEDUCTION / OVERTIME / TAX_RELIEF. |
statutoryTypes |
ObjectId[] → master statutory_type |
Which statutory bases this item contributes to (e.g. an allowance that is EPF-able). Empty → uses the default base (basic + allowances). |
accountId |
ObjectId → account | GL account for journal posting (deductions/statutory). |
isBasicSalary |
boolean | This item is the basic salary component. |
isTaxable |
boolean | Taxable for PCB. isTaxable=false items are treated as tax relief (subtracted from chargeable income). |
isAdditionalRemuneration |
boolean | Bonus/commission/arrears → LHDN differential PCB method, never ÷12-smoothed. |
taxReliefType |
enum TaxReliefType |
AMOUNT / PERCENTAGE (for TAX_RELIEF items). |
taxRelief, maxTaxRelief |
number | Relief value and cap. |
frequency |
enum AmountFrequency |
MONTHLY / ANNUALLY / ONE_OFF (see §2.3). |
eaCategory |
enum EaCategory |
Which EA Form (CP8A) line the amount lands on. |
export enum PayrollItemSettingType { ALLOWANCE, DEDUCTION, OVERTIME, TAX_RELIEF } // string values
export enum TaxReliefType { AMOUNT, PERCENTAGE }
export enum AmountFrequency { MONTHLY, ANNUALLY, ONE_OFF }
export enum EaCategory {
B1_GROSS, // salary, wages, OT, bonus, commission, director fee, perquisites
B2_BIK, // benefits in kind — 13(1)(b)
B3_VOLA, // value of living accommodation — 13(1)(c)
B4_REFUND, // refund from unapproved provident fund
B5_COMPENSATION, // compensation for loss of employment
C_PENSION, // pension / annuity
D_ZAKAT, // zakat paid via salary deduction (offsets PCB)
D_CP38, // CP38 deduction
EXEMPT, // tax-exempt allowances/perquisites — EA Section F
}EnrichedPayrollItem (the in-memory shape used by the engine) is the join of an assignment + its setting, with the amount already frequency-adjusted (employee-item.service.ts → findActiveWithSettings).
2.5 payroll_contribution — per-run statutory amounts
payroll/contribution/contribution.schema.ts. One row per employee per run per statutory type (EPF/SOCSO/EIS). Written when the employee is added (addContributions), recomputed on resync, deleted on remove/reject.
| field | type | description |
|---|---|---|
payrollId, employeeId |
ObjectId | req. |
statutoryTypeId |
ObjectId → master statutory_type |
EPF / SOCSO / EIS / PCB. |
paidBy |
enum PayrollContributionPaidBy |
EMPLOYEE / EMPLOYER / BOTH. |
contributionKind |
string | Resolved master name (e.g. "EPF"). |
employeePaymentAmount |
number | Employee's deduction (prorated). |
fullMonthEmployeePaymentAmount |
number | Unprorated employee amount — used ×12 for the annual EPF relief in PCB. |
employerPaymentAmount |
number | Employer's contribution (prorated). |
export enum PayrollContributionPaidBy { EMPLOYEE, EMPLOYER, BOTH }
export enum ContributionValueType { PERCENTAGE, FIXED, TABLE } // TABLE = statutory schedule
export enum ContributionFrequency { MONTHLY, ANNUALLY }
export enum TaxCategory { SINGLE, MARRIED_SPOUSE_WORKING, MARRIED_SPOUSE_NOT_WORKING }2.6 payroll_contribution_groups — contribution policy
payroll/contribution/group/group.schema.ts. A reusable named group of statutory types assigned to employees via Employee.payrollContributionGroupId. Each ContributionGroupType carries:
| field | type | description |
|---|---|---|
statutoryTypeId |
ObjectId | The statutory type (drives the schedule by its master key: epf/socso/eis). |
accountId |
ObjectId → account | GL payable account for this contribution (used in journal). |
paidBy |
enum | EMPLOYEE / EMPLOYER / BOTH. |
frequency |
enum | MONTHLY / ANNUALLY. |
employeePayment, employerPayment |
{ mandatory, voluntary, valueType } |
Rate config. For EPF/SOCSO/EIS the configured valueType is ignored — the statutory schedule always applies (see §4.3). |
2.7 payroll_tax_brackets — PCB bands & reliefs (per year)
payroll/settings/tax-bracket/tax-bracket.schema.ts. One per taxYear. Looked up by taxYear === payDate's year at calculate time. If none configured, the engine falls back to its built-in defaults.
| field | type | description |
|---|---|---|
taxYear |
number | req. |
name |
string | req. e.g. "Malaysia PCB 2026". |
bands |
TaxBracketBand[] |
{ minAmount, maxAmount?, baseTax, ratePercentage } — cumulative-baseTax progressive bands. |
statutoryReliefs |
{ statutoryTypeId, annualLimit }[] |
Per-type relief caps (overrides legal defaults EPF RM4,000 / SOCSO+EIS RM350). |
individualRelief |
number | Annual personal (D) relief. |
spouseRelief |
number | Annual spouse (S) relief (only when spouse not working). |
perChildRelief |
number | Annual per-child relief. |
2.8 Statutory identity
payroll_company_statutories(CompanyStatutory): company registrationnumberperstatutoryTypeId. Unique(companyId, statutoryTypeId).payroll_employee_statutories(EmployeeStatutory): employee registrationnumberper type. Unique(companyId, employeeId, statutoryTypeId). Used on payslips, EA Form and Borang E.
3. API surface (GraphQL)
All operations are guarded by @ApGqlAuthorize() + @UseGuards(GqlFeatureGuard) + @RequireFeature('HR_MODULE'). Mutations carry @AuditMeta(module:'payroll') (CREATE/UPDATE/DELETE or STATUS_CHANGE snapshots). Resolvers extend ApBaseResolver.
payroll.resolver.ts
| Operation | Type | Input | Returns | Notes |
|---|---|---|---|---|
createPayroll |
Mutation | CreatePayrollInput { name, payDate, fromDate?, toDate? } |
Payroll |
Creates an empty DRAFT run. |
updatePayroll |
Mutation | _id, UpdatePayrollInput |
Payroll |
Edit header (DRAFT only — canUpdate). |
deletePayroll |
Mutation | _id |
Boolean | Cascades: deletes all PayrollEmployee + their contributions (txn). |
runPayroll |
Mutation | RunPayrollInput { payrollId } |
Boolean | Calculate. DRAFT → PENDING_APPROVAL. STATUS_CHANGE. |
approvePayroll |
Mutation | payrollId |
Boolean | PENDING_APPROVAL → APPROVED. |
rejectPayroll |
Mutation | payrollId |
Boolean | PENDING_APPROVAL → DRAFT; resets isCalculated. |
markPayrollAsPaid |
Mutation | payrollId |
Boolean | APPROVED → PAID. Requires journalEntryId set. |
cancelPayroll |
Mutation | payrollId |
Boolean | any non-PAID → CANCELLED. |
postPayrollJournal |
Mutation | payrollId, liabilityAccountId? |
Boolean | Builds + posts the GL journal (APPROVED only). Re-post deletes the prior entry. UPDATE audit. |
previewPayrollJournal |
Query | payrollId |
PayrollJournalPreview |
Dry-run of the journal lines (no write, no status gate). |
findPayroll |
Query | QueryPayrollInput { _id?, status?, … } |
Payroll |
|
payrollPage |
Query | PayrollPageInput { skip, take, keyword?, status? } |
PayrollPageResult |
Keyword matches name. |
Payroll resolve-fields: employees (the lines), employeeContributions/employeerContributions (summaries grouped by statutory type), netEmployeeContributions/netEmployerContributions, live totalGrossSalary/totalNetSalary (stored if calculated, else estimated), journalLiabilityAccount, and canUpdate/canDelete (both = status === DRAFT).
employee/employee.resolver.ts (PayrollEmployee)
| Operation | Type | Input | Returns | Notes |
|---|---|---|---|---|
addPayrollEmployees |
Mutation | AddPayrollEmployeesInput { employeeIds[], payrollId } |
Boolean | Snapshots period inputs + creates contributions per employee. |
updatePayrollEmployee |
Mutation | _id, UpdatePayrollEmployeeInput |
PayrollEmployee |
Manual override of salary/OT/leave/absence (DRAFT). |
resyncPayrollEmployeeData |
Mutation | payrollId, fromDate?, toDate? |
Boolean | Re-pull leave/OT/absence/contributions for all lines. |
deletePayrollEmployee |
Mutation | _id |
Boolean | Removes a line + its contributions (txn). |
deleteAllPayrollEmployees |
Mutation | payrollId |
Boolean | Clears the run. |
findPayrollEmployee / payrollEmployeePage |
Query | … | … |
PayrollEmployee resolve-fields build the payslip: earnings (Basic Salary + non-basic allowances + overtime), deductions (item deductions + employee statutory contributions + PCB + unpaid-leave + absence), employeeContributions/employeerContributions, and live grossSalary/netSalary/taxDeduction/taxRelief/taxableIncome/totalDeductions (stored when isCalculated, else estimated). items lists the active standing items.
Sibling sub-modules each expose standard CRUD + XLSX import (*Page, create*, update*, delete*, import*, confirm*Import): payroll item settings (item/, settings/item/), employee payroll items (employee-item/), contribution groups (contribution/group/), contributions (contribution/), tax brackets (settings/tax-bracket/), company/employee statutory (company-statutory/, employee-statutory/). EA Form (ea-form/) and Borang E (borang-e/) expose query + a REST controller streaming the PDF/XLSX statutory forms.
4. Business rules & calculations
4.1 Status / state machine
createPayroll
│
▼
┌──────┐ runPayroll (calculate) ┌──────────────────┐
│ DRAFT│ ────────────────────────▶ │ PENDING_APPROVAL │
└──────┘ ◀──────────────────────── └──────────────────┘
▲ rejectPayroll │ approvePayroll
│ (resets isCalculated) ▼
│ ┌──────────┐ postPayrollJournal ┌──────────┐
│ │ APPROVED │ ───(sets journal)───▶│ APPROVED │
│ └──────────┘ └──────────┘
│ │ markPayrollAsPaid (requires journalEntryId)
│ ▼
│ ┌──────┐
cancelPayroll │ (any non-PAID) ───────────────▶│ PAID │ (terminal)
▼ └──────┘
┌───────────┐
│ CANCELLED │ (terminal)
└───────────┘
Guards (payroll.service.ts):
runPayroll: refuses if status isPAIDorCANCELLED; requires ≥1 employee.approvePayroll/rejectPayroll: only fromPENDING_APPROVAL.markAsPaid: only fromAPPROVEDandjournalEntryIdmust be set.postPayrollJournal/previewPayrollJournal: post requiresAPPROVED; preview has no status gate.cancelPayroll: any status exceptPAID.- Header edit/delete: only in
DRAFT(canUpdate/canDelete).
4.2 Adding an employee — snapshotting period inputs (PayrollEmployeeService.addEmployees)
For each employee, within a retry transaction:
- Resolve the period —
fromDate/toDatefrom the run, else the calendar month ofpayDate(resolvePeriod). - Working-day context — the set of working dates = the employee's
AttendanceGroup → Shift → ShiftDayweekdays (fallback Mon–Fri) minus company holidays in the period (getWorkingContext).totalWorkingDays = |set|. - Unpaid leave (
addUnpaidLeaves): approved,isPaid=falseleaves overlapping the period.dailyRate = grossSalary / totalWorkingDays. Each leave is clamped to the period boundary, half-days count 0.5, holidays/non-working days skipped.totalUnpaidLeaveAmount = Σ leaveDays × dailyRate. - Overtime (
addOvertime): approved timesheets in the period.hourlyRate = salary / workingDaysPerMonth / workingHoursPerDay(from the attendance group, defaults 30 days / 8 h).totalOvertimeAmount = Σ overtimeHours × hourlyRate. - Absence (
addAbsenceDeductions): present days = attended dates ∪ all approved-leave dates (paid or unpaid count as present, so no double count with unpaid-leave deduction).absentDays = totalWorkingDays − presentDays;totalAbsenceDeductionAmount = absentDays × (salary / totalWorkingDays). - Contributions (
addContributions) — see §4.3.
resyncPayrollEmployeeData re-runs steps 1–6 for every existing line (deleting+recreating contributions). salary is snapshotted as the Σ of isBasicSalary active items.
4.3 Statutory contributions — EPF / SOCSO / EIS (the legal schedule)
addContributions resolves the employee's contribution group, and for each ContributionGroupType computes employee and employer amounts via countryEngine.calculateContributionAmount. The contribution base is the Σ of items tagged with that statutoryTypeId (or, if none tagged, the default base = basic + allowances), prorated for partial months. Two amounts are stored: prorated (employeePaymentAmount) and full-month (fullMonthEmployeePaymentAmount, used ×12 for PCB relief).
EPF/SOCSO/EIS rates are fixed by law and always computed from the statutory schedule (payroll-statutory-schedule.ts), regardless of the group's configured valueType. Age (at pay date) and foreign-worker status modulate the rate. For non-scheduled keys, FIXED (annual ÷12) or PERCENTAGE (base × rate%) apply.
EPF / KWSP — calculateEpfAmount (no wage ceiling; rounded up to next ringgit)
| Case | Employee | Employer |
|---|---|---|
| Citizen/PR, under 60, wage ≤ RM5,000 | 11% | 13% |
| Citizen/PR, under 60, wage > RM5,000 | 11% | 12% |
| Citizen/PR, age ≥ 60 | 0% | 4% |
| Non-citizen foreign worker (any age) | 2% | 2% |
- EPF has no RM6,000 ceiling (unlike SOCSO/EIS) and each share is
Math.ceilto the next whole ringgit. - Foreign-worker EPF (2%/2%) applies only on/after 2025-10-01 (
FOREIGNER_EPF_EFFECTIVE_FROM), excludes domestic servants, and is decided byisForeignWorkerForEpf(non-citizen ∧ not domestic servant ∧ payDate ≥ effective date). Empty/unknown nationality is treated as Malaysian (safe default).
SOCSO / PERKESO & EIS / SIP — calculateStatutoryScheduleAmount (RM6,000 ceiling, rounded to nearest 5 sen)
Base = min(wage, 6000). STATUTORY_WAGE_CEILING = 6000 (since 1 Oct 2024).
| Scheme | Condition | Employer | Employee |
|---|---|---|---|
| SOCSO | employee < 60 (Category 1: injury + invalidity) | 1.75% | 0.5% |
| SOCSO | employee ≥ 60 (Category 2: injury only) | 1.25% | 0% |
| EIS | ages 18–59 | 0.2% | 0.2% |
| EIS | age < 18 or ≥ 60 | 0 | 0 |
(roundTo5Sen(x) = round(x×20)/20.) Missing age is treated as < 60 and ≥ 18 (so EIS still charges).
4.4 Proration for partial months (proratedSalary)
Applies when an employee joins after the 1st of the pay month or resigns within the pay month:
totalDays = days in the pay month
startDay = joinSameMonth ? joinDate.day : 1
endDay = resignSameMonth? resignDate.day : totalDays
workedDays = max(0, endDay − startDay + 1)
prorated = round( amount × workedDays / totalDays )
Prorated: base salary, allowances, item deductions, the statutory contribution base, and the HRDF base, and current-month zakat. Overtime / unpaid-leave / absence amounts are computed per actual day and are not re-prorated. Additional remuneration (bonus) is never prorated.
4.5 Calculate — runPayroll order of operations
runPayroll (PENDING_APPROVAL on success) computes every employee in a read phase, then writes all results in one retry transaction. Per-run setup:
- Load run + its employees (must be ≥1). Derive
payDate,payDateYear. - Load the year's
TaxBracket(taxYear === payDateYear). - Build
statutoryKeyById(masterstatutory_type._id → key) so per-type relief caps apply. - Build YTD accumulation (
buildYtdAccumulation) — per employee, sumgrossSalary, employee EPF, andtaxDeductionfrom this year's prior finalized runs (status APPROVED/PAID,payDatein [year-start, this payDate)). Drives the MTD true-up. - Resolve statutory relief caps (config tax-bracket
statutoryReliefs, else legal defaults). - Load HRDF config (
hrdfEnabled,hrdfRate).
Per employee (the calculation order):
- Load enriched active items + the employee record. Split into normal vs additional-remuneration (
isAdditionalRemuneration). additionalGross = Σ additional items(full lump sum).fullSalary = Σ isBasicSalary normal items(orpayrollEmployee.salary).baseSalary = proratedSalary(fullSalary).totalAllowances = proratedSalary(Σ non-basic ALLOWANCE).totalItemDeductions = proratedSalary(Σ |DEDUCTION|).grossSalary = round( baseSalary + totalAllowances + overtime − unpaidLeave − absence )(normal, pre-bonus).- Load contributions; sum employee statutory deductions and employer contributions by
paidBy. - Annualise employee statutory reliefs (
fullMonthEmployeePaymentAmount × 12), tag by key, and cap them:annualStatutoryReliefs(EPF capped RM4,000 + SOCSO/EIS capped RM350 + other) andannualNonEpfStatutoryReliefs(SOCSO/EIS+other only — MTD derives EPF itself). - Resolve
annualPersonalRelief(§4.7) and residency. currentEpf= this month's employee EPF; pullytdfigures (gross/epf/mtd).additionalEpf= EPF on the bonus (only if the employee normally contributes EPF).currentMonthZakat = proratedSalary(Σ D_ZAKAT deduction items).- Compute PCB/MTD (
countryEngine.calculateTaxDeduction) →taxDeduction,taxRelief,taxableIncome(§4.6). paidGross = round(grossSalary + additionalGross).netSalary = max(0, round( paidGross − totalItemDeductions − employeeStatutoryDeductions − taxDeduction )).totalDeductions = paidGross − netSalary.- HRDF levy (
calculateHrdfLevy): employer cost only,base = round(baseSalary + totalAllowances)(prorated, excludes OT/bonus/unpaid/absence), Malaysian employees only, when enabled.levy = round(base × rate%). employerCost = round( paidGross + employerContributions + hrdfLevy ).- Stage the per-employee payload (
grossSalary=paidGross, allowances, deductions, net, employerCost, hrdfLevy, tax fields,taxYear,isCalculated=true).
Write phase (single transaction): update every PayrollEmployee, then update the Payroll with status=PENDING_APPROVAL, journalEntryId=null, and the rolled-up totals (totalGrossSalary, totalNetSalary, totalEmployeeContributions = Σ(employee statutory + tax), totalEmployerContributions, totalEmployerCost, totalHrdfLevy, totalTaxDeduction, calculatedAt).
4.6 PCB / MTD income tax — the actual formula
payroll-country.ts → calculateMalaysiaTax dispatches three ways:
- Non-resident → flat-rate, no reliefs/rebate/accumulation:
tax = round( (gross + additionalGross) × 30% )(MALAYSIA_NON_RESIDENT_RATE = 30). - Resident with pay-period context (production:
payMonthalways supplied) → official MTD Computerised Calculation (payroll-mtd.ts). - Resident without context (ad-hoc previews / estimates) → simplified annualized estimate (
buildAnnualizedTaxCalculator): annualize chargeable income ×12, look up annual tax, apply rebate, ÷12.
MTD normal remuneration — calculateMonthlyMtd
Let n+1 = remainingIncl = 13 − payMonth (months remaining incl. current). Then:
P (annual chargeable income)
= ytdGross + currentGross × (n+1)
− min( ytdEpf + currentEpf × (n+1), epfReliefCap ) // EPF relief K (cap RM4,000)
− annualPersonalRelief // D + S + child
− annualNonEpfStatutoryRelief // SOCSO+EIS (cap RM350) + other
− itemMonthlyTaxRelief × 12 // approved TP1-style item reliefs
P = round2( max(0, P) )
annualTax = round2( max(0, rebate( lookupAnnualTax(P) ) ) ) // s.6A rebate baked in
grossMTD = roundUpTo5sen( max(0, (annualTax − accumulatedZakat − ytdMtdPaid) / (n+1)) )
netMTD = grossMTD < RM10 ? 0 : max(0, round2( grossMTD − currentMonthZakat ))
ytdMtdPaid(Σ tax of prior finalized runs) is the true-up term X, so the running PCB converges to the correct annual liability.lookupAnnualTax(P)walks the bands:tax = baseTax + (P − minAmount) × rate%for the band whereP ≤ maxAmount(payroll-tax-bands.ts).- Section 6A rebate: resident with annual chargeable income ≤ RM35,000 gets −RM400 against tax, floored at 0 (
MALAYSIA_REBATE). - Zakat: prior-month zakat (
accumulatedZakat, Z) stays inside the/(n+1); current-month zakat is subtracted once, ringgit-for-ringgit, after rounding. - RM10 floor: a monthly MTD below RM10 (before current-month zakat) is not remitted → RM0.
- LHDN rounding: truncate beyond 2 dp, then round up to the next 5 sen (
roundUpTo5sen).
Additional remuneration (bonus) — LHDN differential method — calculateMtdOnAdditional
The bonus is taxed as the difference between the total-year tax with the bonus and the projected total-year normal MTD without it:
grossNormalMtd = calculateMonthlyMtd(..., zakat:0).taxDeduction // step 1
totalYearNormalMtd = round2( ytdMtdPaid + grossNormalMtd × (n+1) ) // projected year MTD
P_with = P including Yt (bonus) added once + Kt (bonus EPF) added once
annualTax_with = round2( max(0, rebate( lookupAnnualTax(P_with) )) ) // steps 2–3
mtdOnAdditional = roundUpTo5sen( max(0, annualTax_with − totalYearNormalMtd) ) // step 4
taxDeduction = max(0, round2( grossNormalMtd + mtdOnAdditional − currentMonthZakat )) // step 5
Yt/Kt (bonus gross / bonus EPF) are added to the annual totals once (not projected ×(n+1)).
Reference figures (the live defaults — payroll-country.ts)
Resident bands (LHDN YA2024/2025; configurable via TaxBracket.bands):
| Annual chargeable income (RM) | Rate | baseTax (cum. at lower bound) |
|---|---|---|
| 0 – 5,000 | 0% | 0 |
| 5,000 – 20,000 | 1% | 0 |
| 20,000 – 35,000 | 3% | 150 |
| 35,000 – 50,000 | 6% | 600 |
| 50,000 – 70,000 | 11% | 1,500 |
| 70,000 – 100,000 | 19% | 3,700 |
| 100,000 – 400,000 | 25% | 9,400 |
| 400,000 – 600,000 | 26% | 84,400 |
| 600,000 – 2,000,000 | 28% | 136,400 |
| 2,000,000 + | 30% | 528,400 |
Relief caps (legal defaults, overridable per year): MALAYSIA_EPF_RELIEF_CAP = 4000, MALAYSIA_SOCSO_EIS_RELIEF_CAP = 350 (combined). Non-resident flat rate: 30%. RM400 s.6A rebate boundary: chargeable income ≤ RM35,000.
4.7 Personal relief (computeAnnualPersonalRelief)
annualPersonalRelief = individualRelief
+ (category == MARRIED_SPOUSE_NOT_WORKING ? spouseRelief : 0)
+ numberOfChildren × perChildRelief
+ employee.annualPersonalRelief // ad-hoc per-employee top-up
individualRelief/spouseRelief/perChildRelief come from the year's TaxBracket; category, numberOfChildren, and the per-employee annualPersonalRelief come from the employee record.
4.8 HRDF / HRD Corp levy
Employer-only training levy, never an employee deduction. levy = round(base × hrdfRate%) where base = baseSalary + fixed allowances (prorated; excludes OT/bonus/commission/unpaid/absence). Applied only when hrdfEnabled and the employee is Malaysian.
4.9 Side effects & transactionality
addEmployees/resyncEmployeeData/removeEmployee/deleteAllByPayrollId/deletePayrollrun inside retry transactions and cascade toPayrollContribution.runPayrollwrites all employee updates + the run roll-up in one transaction (run_payroll).postPayrollJournalcreates the journal entry and stampsjournalEntryIdin one transaction (post_payroll_journal); a re-post first deletes the prior journal.- Every mutation emits an audit-trail snapshot (
@AuditMeta).
5. Payroll → GL journal posting
postPayrollJournal (and its no-write twin previewPayrollJournal) turn an APPROVED run into a single balanced General journal entry via finance/journal (JournalEntryService.addEntry, type=GENERAL). The line builder is buildJournalLines; net-salary credits are added in postPayrollJournal itself.
GL accounts come from the Config singleton (config.schema.ts): payrollExpenseAccountId (salary expense), payrollTaxAccountId (PCB payable), payrollEmployerExpenseAccountId (present, employer-side), payrollLiabilityAccountId (net-salary payable — overridable per-post via liabilityAccountId). Per-deduction-item accounts come from PayrollItemSetting.accountId; per-statutory accounts from the contribution group's ContributionGroupType.accountId.
The journal legs (per APPROVED run, calculated employees only)
| Leg | Side | Account | Amount |
|---|---|---|---|
| Salary Expense | DR | payrollExpenseAccountId |
Σ grossSalary (one consolidated line; allowances are inside gross, not separate) |
| Item deductions | CR | each PayrollItemSetting.accountId |
` |
| Employee statutory | CR | contribution-group accountId |
employeePaymentAmount per contribution (label "EPF (Employee)") |
| PCB tax | CR | payrollTaxAccountId |
taxDeduction per employee (if > 0) |
| Net salary | CR | payrollLiabilityAccountId (or chosen) |
netSalary per calculated employee with net > 0 |
Each transaction line carries accountId, payeeId = employeeId, a remark ("<name> — <item>"), and type = DEBIT if debit>0 else CREDIT.
The balancing identity (why it nets to zero):
DR Salary Expense (= Σ gross)
= CR item deductions + CR employee statutory + CR PCB + CR net salary
because net = gross − itemDeductions − employeeStatutory − tax
Explicitly excluded from the salary journal (intentional, see plan): employer-side statutory contributions (employer EPF/SOCSO/EIS lines are built then spliced out — employer pension is a separate payment) and the HRDF levy (it isn't part of the salary journal at all).
Post guards & behaviour:
- Status must be
APPROVED; a salary-liability account must resolve (arg → config → error). - If any
DEDUCTIONitem or statutory contribution has no GL account, its name is collected;previewPayrollJournalreturnshasUnmappedItems/unmappedItemNames, andpostPayrollJournalthrows rather than post an unbalanced entry. - Re-posting an already-posted run deletes the existing
journalEntryId's entry first, then creates a fresh one. - After posting,
markPayrollAsPaidis unblocked (it requiresjournalEntryId).
6. Permissions
- Feature gate:
@RequireFeature('HR_MODULE')+GqlFeatureGuard— the company subscription must include HR. See subscription-config. - Auth:
@ApGqlAuthorize()(JWT) on every resolver. - Editability is status-driven, not RBAC-driven:
canUpdate/canDeleteonPayrollandPayrollEmployeeare bothstatus === DRAFT. There is no per-action CASL ability or workflow-approval policy for payroll — the status machine in §4.1 is the entire authorization model for run progression. - Audit: all mutations snapshot via
@AuditMeta(module:'payroll'). See audit-trail.
7. Flows
7.1 Full happy path (create → pay)
Admin /hr/payroll → "New Payroll" (name, payDate, period)
→ createPayroll → Payroll(DRAFT)
Admin opens run → "Add Employees" (pick employeeIds)
→ addPayrollEmployees → per employee:
snapshot OT / unpaid-leave / absence (working-day set − holidays)
+ create EPF/SOCSO/EIS contributions (prorated + full-month)
Admin → "Run Payroll" (calculate)
→ runPayroll → per employee: prorate → statutory → PCB/MTD (with YTD true-up) → net/gross/employerCost/HRDF
→ write all lines + run totals (txn) → status PENDING_APPROVAL
Admin → "Approve" → APPROVED
Admin → "Run Payroll Journal" → previewPayrollJournal (DR Salary Expense / CR deductions+statutory+PCB+net)
→ pick net-salary liability account → postPayrollJournal → journalEntryId set
Admin → "Mark as Paid" (enabled now journal exists) → PAID (terminal)
7.2 Reject loop
PENDING_APPROVAL → rejectPayroll → DRAFT
→ resetCalculatedForPayroll: every line isCalculated=false, calc fields zeroed
→ admin edits items/employees → runPayroll again
7.3 Mid-month joiner / resigner
Employee with joinDate = 16 June in a 30-day June run → proratedSalary factor = (30 − 16 + 1)/30 = 15/30 = 0.5; base, allowances, item deductions, statutory base and HRDF base are halved. OT/leave/absence are by actual day. PCB still annualizes on the prorated monthly gross.
7.4 Bonus run
Assign an item whose setting has isAdditionalRemuneration=true. At calculate it is split out as additionalGross (full lump sum, not prorated, not ÷12), folded into paidGross for net/journal, but taxed by the differential MTD method (§4.6) separately from normal pay.
7.5 Unhappy paths
runPayrollon aPAID/CANCELLEDrun, or with no employees → throws.markPayrollAsPaidbefore the journal is posted → "Post the payroll journal before marking as paid".postPayrollJournalwith unmapped deduction/statutory accounts → throws listing the offending items.postPayrollJournalwith no liability account (arg or config) → throws.- Cancelling a
PAIDrun → throws.
8. Admin UI
zerp-admin/src/modules/hr/payroll/ — context-first (context.tsx is the only consumer of gql/query.ts).
| Area | Route | Module |
|---|---|---|
| Payroll runs list | /hr/payroll |
payroll/ (page.tsx) |
| Run detail (workflow + payslips + journal) | /hr/payroll/[id] |
payroll/detail/ |
| Item settings catalogue | /hr/payroll-item-setting |
payroll/item-setting/, payroll/item/ |
| Employee standing items | /hr/employee-payroll-item |
payroll/employee-item/ |
| Contribution groups | /hr/payroll-contribution-group |
payroll/contribution-group/ |
| Tax brackets | /hr/payroll-tax-bracket |
payroll/tax-bracket/ |
| Company statutory numbers | /hr/payroll-company-statutory |
payroll/company-statutory/ |
| Employee statutory + EA form | (employee detail tabs) | payroll/employee-statutory/, payroll/employee-ea-form/ |
| ESS payslip view | /ess/payroll, /ess/payroll/[id] |
(ESS) |
payroll/context.tsx methods: fetchPayrollPage, fetchOnePayroll, createPayroll, updatePayroll, deletePayroll, createPayrollEmployee (→ addPayrollEmployees), deletePayrollEmployee, createPayrollItem, runPayroll, approvePayroll, rejectPayroll, markPayrollAsPaid, cancelPayroll, previewPayrollJournal, postPayrollJournal, fetchPayrollEmployeeById. After each status mutation the context refetches the run (fetchOnePayroll).
The detail page renders status-conditional workflow buttons (Run / Approve / Reject / Mark as Paid / Cancel / Run Payroll Journal). The Payroll Journal modal (detail/components/payroll-journal.tsx) previews the legs grouped per employee, surfaces a re-run banner (when journalEntryId set) and an unmapped-items warning, requires selecting a net-salary liability account, and posts.
9. Dependencies & integrations
Payroll reads / calls:
- employee (
EmployeeService) — basic salary, dates, DOB (age), nationality, employment type, tax profile, contribution-group assignment. - leave, attendance, timesheet, calendar, attendance group + shift — to derive unpaid-leave/absence/overtime and the working-day set.
- master (
MasterService) —statutory_typekeys (epf/socso/eis/pcb) that drive the schedule and relief caps. - finance/journal (
JournalEntryService), finance/account (AccountService),AccountTransactionTypes— for the GL posting. ApConfigService(Config singleton) — payroll GL accounts and HRDF settings.
Pure engine modules (dependency-free, unit-tested): payroll-country.ts (the country engine + relief/rebate/contribution dispatch), payroll-mtd.ts (MTD normal + additional), payroll-statutory-schedule.ts (EPF/SOCSO/EIS), payroll-tax-bands.ts (band walk). Spec files: payroll-country.spec.ts, payroll-mtd.spec.ts, payroll-statutory-schedule.spec.ts, payroll.service.spec.ts.
No cron/events: payroll has no scheduled jobs and emits no domain events; XLSX import via zync-nest-library.
10. Gotchas & project-specific rules
- Malaysia-only, behind a one-member country engine.
PayrollInstanceCountryexists butresolvePayrollCountryalways returnsMY; the country argument is ignored. Don't treat it as multi-country. (The 2026-06-06 PCB plan migrated from an earlier NG+MY shell.) - EPF/SOCSO/EIS ignore the group's
valueType. Scheduled keys always use the legal schedule (MALAYSIA_SCHEDULED_KEYS);PERCENTAGE/FIXEDonly apply to non-scheduled custom contributions. - EPF has no RM6,000 ceiling and rounds up to the next ringgit; SOCSO/EIS cap at RM6,000 and round to 5 sen. Different rounding per scheme.
- Foreign-worker EPF (2%/2%) is date-gated to 2025-10-01 and excludes domestic servants. Unknown nationality ⇒ treated as Malaysian (so foreigner rules never auto-apply to incomplete records).
- PCB true-up depends on prior finalized runs.
buildYtdAccumulationonly counts APPROVED/PAID runs earlier in the same tax year. If runs are entered out of order or never approved, the YTD/MTD convergence is off. - MTD vs annualized estimate. Production
runPayrollalways passespayMonth, so it uses the official MTD method. The annualized estimate path is only hit by ad-hoc previews / DRAFT resolve-field estimates — its numbers won't exactly match the posted MTD. - Net salary floors at 0. If statutory + tax + deductions exceed gross,
netSalary = 0(no negative pay);totalDeductionsabsorbs the difference. - Employer contributions and HRDF are NOT in the salary journal. Employer EPF/SOCSO/EIS lines are built then removed; HRDF is never added. The salary journal balances on the employee side only.
ANNUALLYitems are ÷12-smoothed except additional-remuneration. A bonus tagged bothANNUALLYandisAdditionalRemunerationis paid in full, not smoothed.- Editability is status-only. No RBAC/workflow on run progression —
DRAFTis the only editable state; everything past calculate is locked except via reject. - Two amounts per contribution.
employeePaymentAmount(prorated, used for net + journal) vsfullMonthEmployeePaymentAmount(×12 for PCB EPF relief) — don't conflate them. - Zakat double role. A
D_ZAKATdeduction item is both a normal payslip deduction and a ringgit-for-ringgit PCB offset (current-month) — it reduces tax, not just net.