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.
Owner decisions (2026-10-01)
Section titled “Owner decisions (2026-10-01)”- 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. Apayroll_runworkflow 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.
The run
Section titled “The run”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:
- 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).
- Recurring items effective in the month, plus the one-off items entered on the run.
- 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.
- 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.
Accounting and payment
Section titled “Accounting and payment”- 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
paidand clears the payroll payable; cash posts on the batch value date.
Access, screens, tests
Section titled “Access, screens, tests”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.
For the owner to confirm
Section titled “For the owner to confirm”- 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. - 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.
- 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.
- 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,salaryorpayroll_runrows:erp_appholds no DELETE on them (00900;TestOnlyOperationalTablesGrantDeleteToTheAppRole),salaryandpayslip_lineare also append-only (no UPDATE), andpayslipandpayroll_runare 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.
Out of P1
Section titled “Out of P1”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.