Payroll — Malaysia monthly pay run, statutory engine & GL posting

The whole payroll model reduces to one idea: a Payroll is a monthly run — a container of PayrollEmployee rows — that moves through a five-state lifecycle (DRAFT → PENDING_APPROVAL → APPROVED → PAID, or CANCELLED). 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-period fromDate/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 the payrollContributionGroupId assignment.
  • 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 isCalculated is false, the GraphQL resolvers compute live estimates via PayrollService.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 is isAdditionalRemuneration (then the full lump sum, never smoothed).
  • ONE_OFF → descriptive only; the date range (startDate/endDate confined 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 registration number per statutoryTypeId. Unique (companyId, statutoryTypeId).
  • payroll_employee_statutories (EmployeeStatutory): employee registration number per 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 is PAID or CANCELLED; requires ≥1 employee.
  • approvePayroll/rejectPayroll: only from PENDING_APPROVAL.
  • markAsPaid: only from APPROVED and journalEntryId must be set.
  • postPayrollJournal/previewPayrollJournal: post requires APPROVED; preview has no status gate.
  • cancelPayroll: any status except PAID.
  • 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:

  1. Resolve the periodfromDate/toDate from the run, else the calendar month of payDate (resolvePeriod).
  2. Working-day context — the set of working dates = the employee's AttendanceGroup → Shift → ShiftDay weekdays (fallback Mon–Fri) minus company holidays in the period (getWorkingContext). totalWorkingDays = |set|.
  3. Unpaid leave (addUnpaidLeaves): approved, isPaid=false leaves 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.
  4. Overtime (addOvertime): approved timesheets in the period. hourlyRate = salary / workingDaysPerMonth / workingHoursPerDay (from the attendance group, defaults 30 days / 8 h). totalOvertimeAmount = Σ overtimeHours × hourlyRate.
  5. 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).
  6. 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.

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.ceil to 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 by isForeignWorkerForEpf (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:

  1. Load run + its employees (must be ≥1). Derive payDate, payDateYear.
  2. Load the year's TaxBracket (taxYear === payDateYear).
  3. Build statutoryKeyById (master statutory_type._id → key) so per-type relief caps apply.
  4. Build YTD accumulation (buildYtdAccumulation) — per employee, sum grossSalary, employee EPF, and taxDeduction from this year's prior finalized runs (status APPROVED/PAID, payDate in [year-start, this payDate)). Drives the MTD true-up.
  5. Resolve statutory relief caps (config tax-bracket statutoryReliefs, else legal defaults).
  6. Load HRDF config (hrdfEnabled, hrdfRate).

Per employee (the calculation order):

  1. Load enriched active items + the employee record. Split into normal vs additional-remuneration (isAdditionalRemuneration).
  2. additionalGross = Σ additional items (full lump sum).
  3. fullSalary = Σ isBasicSalary normal items (or payrollEmployee.salary). baseSalary = proratedSalary(fullSalary).
  4. totalAllowances = proratedSalary(Σ non-basic ALLOWANCE). totalItemDeductions = proratedSalary(Σ |DEDUCTION|).
  5. grossSalary = round( baseSalary + totalAllowances + overtime − unpaidLeave − absence ) (normal, pre-bonus).
  6. Load contributions; sum employee statutory deductions and employer contributions by paidBy.
  7. Annualise employee statutory reliefs (fullMonthEmployeePaymentAmount × 12), tag by key, and cap them: annualStatutoryReliefs (EPF capped RM4,000 + SOCSO/EIS capped RM350 + other) and annualNonEpfStatutoryReliefs (SOCSO/EIS+other only — MTD derives EPF itself).
  8. Resolve annualPersonalRelief (§4.7) and residency.
  9. currentEpf = this month's employee EPF; pull ytd figures (gross/epf/mtd). additionalEpf = EPF on the bonus (only if the employee normally contributes EPF). currentMonthZakat = proratedSalary(Σ D_ZAKAT deduction items).
  10. Compute PCB/MTD (countryEngine.calculateTaxDeduction) → taxDeduction, taxRelief, taxableIncome (§4.6).
  11. paidGross = round(grossSalary + additionalGross).
  12. netSalary = max(0, round( paidGross − totalItemDeductions − employeeStatutoryDeductions − taxDeduction )). totalDeductions = paidGross − netSalary.
  13. 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%).
  14. employerCost = round( paidGross + employerContributions + hrdfLevy ).
  15. 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: payMonth always 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 where P ≤ 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 / deletePayroll run inside retry transactions and cascade to PayrollContribution.
  • runPayroll writes all employee updates + the run roll-up in one transaction (run_payroll).
  • postPayrollJournal creates the journal entry and stamps journalEntryId in 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 DEDUCTION item or statutory contribution has no GL account, its name is collected; previewPayrollJournal returns hasUnmappedItems/unmappedItemNames, and postPayrollJournal throws 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, markPayrollAsPaid is unblocked (it requires journalEntryId).

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/canDelete on Payroll and PayrollEmployee are both status === 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

  • runPayroll on a PAID/CANCELLED run, or with no employees → throws.
  • markPayrollAsPaid before the journal is posted → "Post the payroll journal before marking as paid".
  • postPayrollJournal with unmapped deduction/statutory accounts → throws listing the offending items.
  • postPayrollJournal with no liability account (arg or config) → throws.
  • Cancelling a PAID run → 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_type keys (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

  1. Malaysia-only, behind a one-member country engine. PayrollInstanceCountry exists but resolvePayrollCountry always returns MY; 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.)
  2. EPF/SOCSO/EIS ignore the group's valueType. Scheduled keys always use the legal schedule (MALAYSIA_SCHEDULED_KEYS); PERCENTAGE/FIXED only apply to non-scheduled custom contributions.
  3. 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.
  4. 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).
  5. PCB true-up depends on prior finalized runs. buildYtdAccumulation only 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.
  6. MTD vs annualized estimate. Production runPayroll always passes payMonth, 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.
  7. Net salary floors at 0. If statutory + tax + deductions exceed gross, netSalary = 0 (no negative pay); totalDeductions absorbs the difference.
  8. 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.
  9. ANNUALLY items are ÷12-smoothed except additional-remuneration. A bonus tagged both ANNUALLY and isAdditionalRemuneration is paid in full, not smoothed.
  10. Editability is status-only. No RBAC/workflow on run progression — DRAFT is the only editable state; everything past calculate is locked except via reject.
  11. Two amounts per contribution. employeePaymentAmount (prorated, used for net + journal) vs fullMonthEmployeePaymentAmount (×12 for PCB EPF relief) — don't conflate them.
  12. Zakat double role. A D_ZAKAT deduction item is both a normal payslip deduction and a ringgit-for-ringgit PCB offset (current-month) — it reduces tax, not just net.