Skip to content

Phase 5 payroll, P1: salaries, pay items, the monthly run, payslips

Status: design, approved section by section by the owner 2026-10-01; not built. Branch payroll-design. Roadmap: Phase 5 — “Payroll: pay items, EPF / SOCSO / EIS / PCB, payslips, bank files, EA forms” (the CP38 line is deferred to P3). Architecture spec §19 (payroll registers a workflow definition and inherits approval, audit and posting). HR layer spec HD-5: payroll owns individual salaries; the HR layer keeps bands only and “payroll will” check a salary against one.

Payroll is too large for one spec. Three sub-projects, each its own spec and plan:

Sub-project What Needs an outside authority
P1 (this spec) Salaries, pay items, the monthly run with approval, payslips, posting, net pay through payment batches No (statutory lines are manual or zero)
P2 EPF, SOCSO, EIS, PCB as versioned rate tables and calculation Yes: the figures and PCB method (adviser, LHDN)
P3 EA forms, CP38 directions, statutory submission files (EPF, SOCSO, EIS, LHDN) Yes: file layouts

P1 leaves a statutory payslip-line kind that P2 fills in with calculated values and no schema change.

  • PD-1 Split: P1 core, P2 statutory, P3 filings.
  • PD-2 Net pay: each approved payslip becomes a payable line in an ordinary payment batch. No new cash path, no payroll bank file.
  • PD-3 Calendar: monthly only. One run per organization per calendar month.
  • PD-4 Pay items: basic salary with effective dates, recurring allowances and deductions, one-off items entered on a run, and an automatic unpaid-leave deduction from approved H3 unpaid leave. No hour-based overtime or shift formulas.
  • PD-5 Access: payroll.* permissions held by no default role except Owner. A payroll_run workflow kind carries approval (preparer is not the approver; the sole-owner exception applies). An employee sees only their own payslips.
  • PD-6 Calculation: a run is a snapshot. Preparing computes and stores every payslip line; approval freezes them. Corrections are a void (reversal) or an adjustment line in a later run, never a mutation.

Data (platform/payroll, migrations 00900–00919)

Section titled “Data (platform/payroll, migrations 00900–00919)”

Every table has org_id, ENABLE and FORCE ROW LEVEL SECURITY, and an org_isolation policy. Money is NUMERIC(19,4) in Postgres, money.Amount in Go, a string on the wire. Vocabulary values are strings. Every user-facing name is a translation row.

  • salary — employee, monthly basic amount, effective_from. Append-only: a change inserts a new row; none is updated or deleted. The amount in force for a date is the latest row on or before it.
  • pay_item_type — name (translation), kind (earning | deduction), recurrence (recurring | one_off), four liability flags (epf, socso, eis, pcb) that P2 will read, and semantic tags for accounting.
  • employee_pay_item — a recurring item for an employee: type, amount, effective-from and effective-to.
  • payroll_run — period_month (first day of the month), status, workflow instance. A unique index allows one non-voided run per organization per month.
  • payslip — one per employee per run: gross, deductions, net, the salary row and days basis used, including whether the basic was prorated by working or calendar days (proration, 2026-10-02), so the payslip explains its own basic line.
  • payslip_line — kind (basic | item | unpaid_leave | statutory | adjustment), amount, type reference, a description snapshot. Immutable once the run is submitted (trigger, in the manner of issued invoices) and watched failing in a drift test.

States (a workflow vocabulary): draft → submitted → approved → paid, plus rejected and voided. Accounting reacts to the semantic tags, never to these names.

Prepare takes every employee employed for any part of the month and computes:

  1. Basic salary, prorated for a mid-month start, last day or salary change: by working days when the divisor setting is empty, by calendar days when a fixed divisor is set (owner point 2).
  2. Recurring items effective in the month, plus the one-off items entered on the run.
  3. An unpaid-leave deduction from approved H3 unpaid leave in the month: basic ÷ working days in the month (the leave working calendar; see owner point 1), per day, half days supported.
  4. Statutory lines: manual entry or zero in P1.

A draft run can be recalculated until submitted. Amounts are rounded half-up to the sen per line.

Warnings, not blocks: a salary outside the employee’s grade band (H2 deliberately never enforced it); an employee without verified bank details, who is left out of the payment batch and listed.

Void: reverses the run’s journal; refused once any payslip is in an approved payment batch.

  • On approval the run posts through posting rules registered with accounting/, driven by the item type’s semantic tags (salary_expense, statutory_payable, net_pay_payable). Modules never write journal lines. Net pay is credited to a payroll payable. Department split uses existing cost dimensions where an organization has them.
  • Approval creates one payable line per payslip for the payment batch. The batch’s controls apply unchanged: verified bank details sealed at approval, preparer is not the approver, one file whose bank memo carries the batch reference. Marking the batch paid moves each payslip to paid and clears the payroll payable; cash posts on the batch value date.

Permissions: payroll.salary.read, payroll.salary.write, payroll.item.manage, payroll.run.prepare, payroll.run.approve. Every member reads their own payslips and only theirs. A salary read writes an audit row. Labels in en, ms and zh, with the drift test that every registered permission has one.

Screens: salary and pay items on the employee page; the monthly runs list and a run detail (the payslip table and warnings); “My payslips”. All strings en/ms/zh.

Tests: RLS and cross-organization isolation (presence before absence); the frozen-payslip trigger watched failing once; proration and unpaid-leave arithmetic (31-day month, leaver, half days); approval refused for the preparer; one end to end run from prepare through batch to paid with the journal balanced.

  1. Unpaid-leave divisor. Calendar days of the month (this spec) against 26 working days or the Employment Act formula. Malaysian practice varies and the Act’s figures are on the outside-authority list. P1 reads the divisor from one named setting so changing it is not a migration. 2026-10-02 controller ruling (final review I-3), PROVISIONAL pending adviser / Employment Act confirmation: leave is counted in working days, so the default divisor (setting empty) is the month’s working days under the same calendar the leave module counts with (its working week, less public holidays applying to the organization). A whole month of unpaid leave then deducts exactly the basic. A fixed 1–31 (26, say) stays selectable; the month’s calendar days as a moving divisor is no longer an option (a fixed 28–31 approximates it). Unpaid maternity or paternity cannot exist (leave_type_pay_by_kind), so every unpaid day is a working day.
  2. Proration basis for a mid-month starter or leaver: calendar days (this spec). 2026-10-02 controller ruling (divisor review I-1), PROVISIONAL pending adviser / Employment Act confirmation: with the divisor setting empty, each salary row’s basic is prorated by working days on the same calendar as the divisor: basic × working days employed under that row ÷ working days in the month. Deduction and proration share one basis, so worked days are paid in full and a fully unpaid span nets exactly zero (calendar proration with a working-day deduction had deducted worked days: employed Mon 3–Fri 7 Aug 2026, unpaid Mon–Thu, was paid nothing for Friday). A row whose days are only weekend or holiday days has no basic. A fixed divisor keeps calendar-day proration. Recurring items stay prorated by calendar days.
  3. Statutory lines in P1 are manual or zero. An organization must not run real payroll on P1 alone until P2 lands; the run screen says so.
  4. Retention. Payslips and the run’s frozen lines are financial records and must not be erasable (the retention drift test applies); the period an organization must keep them is a legal matter. The application never erases payslip, payslip_line, salary or payroll_run rows: erp_app holds no DELETE on them (00900; TestOnlyOperationalTablesGrantDeleteToTheAppRole), salary and payslip_line are also append-only (no UPDATE), and payslip and payroll_run are narrowed by freeze triggers. So any statutory retention period is met by the application for as long as the database itself is retained. Malaysian practice is at least 7 years (Income Tax Act 1967 s.82, Employment Act records); the period itself is for the owner and an adviser to confirm, not a legal conclusion made here. The owner must also make sure backups are kept at least as long as that period. Erasure by a database superuser or a restore from an older backup is outside what the application controls.

EPF, SOCSO, EIS, PCB calculation (P2); EA forms, CP38, statutory submission files (P3); weekly or fortnightly runs; hour-based overtime and shift formulas; leave encashment on termination; claims paid through payroll; payslip PDF styling beyond the standard print path.