Payroll: what Finance needs to decide
Payroll code does not start until this document comes back answered. That is deliberate. An engineer who misreads a statutory rule writes a test asserting the misreading, and it passes forever — so the definition of a correct payslip is written by Finance first, as arithmetic, and the code’s stated job is to match it.
How to answer
Part A is twenty questions, and every one already has our assumption filled in. Most should be a plain “yes, that’s right”. The ones that are not are the ones worth a meeting. A one-line answer is enough — the reasoning matters more than the format.
Part B is thirty worked examples. We have filled in every input. You fill in what the payslip should say. That table then becomes the definition of correct, and each case turns into a check that runs automatically on every change from then on — so a rule change two years from now breaks a build instead of somebody’s pay.
If a case is wrong, or describes a person we do not have, say so and we delete it. A worked example nobody believes is worse than a missing one. And if a number depends on something we have not listed as an input, that is the most useful thing you can tell us: it means the platform is missing a field.
Nothing here needs anyone to read code.
01Where this sits
Everything to the right of the fixture table is engineering work that cannot start early. This document is the only thing on the critical path that engineering cannot do for itself.
%%{init: {"flowchart": {"padding": 16, "nodeSpacing": 44, "rankSpacing": 54, "useMaxWidth": true}}}%%
flowchart LR
A["Part A
20 questions"] --> C
B["Part B
30 worked examples"] --> C
C["The signed fixture table
the definition of a correct payslip"]
C --> D["Payroll tables,
permissions, access rules"]
D --> E["The payroll engine,
written to make the examples pass"]
E --> F["Shadow month
one full month computed in parallel
and compared, line by line"]
F --> G["Go live"]
style C fill:#E2EDEA,stroke:#14544C,stroke-width:2px
style A fill:#F3EAD6,stroke:#8A6216
style B fill:#F3EAD6,stroke:#8A6216
02Part A · Twenty questions
Grouped by where the answer lives. Each carries what we currently assume, so the fastest possible answer is “yes” twenty times — and every “no” is worth the whole document.
Singapore · CPF
Q1 Which CPF rates and ceilings apply?
needs a valueWhy it matters. The old app uses a flat 20% employee and 17% employer with no ceiling at all. Real CPF has an Ordinary Wage ceiling, a separate annual Additional Wage ceiling, and rates that step down with age. This is a filing error if it is wrong, not a display bug.
- Our assumption
- Standard under-55 rates, Ordinary Wage ceiling applied each month, Additional Wage ceiling applied across the year. We need the exact figures you want used.
Q2 Is anyone in a non-standard age band?
confirmWhy it matters. Rates step at 55, 60 and 65. If nobody is near a boundary we can leave those bands unbuilt and record that in writing, rather than shipping code nobody has ever tested against a real person.
- Our assumption
- Everyone is under 55, on standard rates.
Q3 Which pay is Ordinary Wage and which is Additional Wage?
needs a valueWhy it matters. The Additional Wage ceiling is annual and depends entirely on this split, so if it is wrong then December is wrong. The platform has no field for this today — this question is the reason it needs one.
- Our assumption
- Ordinary: monthly salary, allowances, overtime. Additional: bonus, commission, leave encashment, directors’ fees.
Q4 Confirm the SDL floor and cap.
confirmWhy it matters. Small money, but it is hardcoded in the old app and nobody has checked it since it was written.
- Our assumption
- 0.25% of monthly gross, a $2 minimum and an $11.25 maximum.
Q5 Who is CPF-exempt, and on what grounds?
confirmWhy it matters. The platform keeps an exemption flag per person. A flag whose rule nobody can state is a flag nobody will maintain correctly, and it silently removes 37% of someone’s statutory cost.
- Our assumption
- Foreigners on an Employment Pass. Citizens and PRs are never exempt.
Malaysia
Q6 Confirm EPF, SOCSO and EIS rates and wage ceilings.
needs a valueWhy it matters. SOCSO and EIS are capped, and the cap is where a naive percentage calculation goes wrong first.
- Our assumption
- Statutory defaults, with SOCSO and EIS capped at the standard wage ceiling.
Q7 PCB — do we compute it, or apply the LHDN schedule?
needs a valueWhy it matters. The two approaches give different answers, and only one of them is what LHDN expects to receive.
- Our assumption
- We compute monthly PCB from the annual brackets. This is the assumption we hold with the least confidence in the whole document.
Indonesia
Q8 Confirm BPJS rates and the Kesehatan ceiling.
confirmWhy it matters. Ketenagakerjaan and Kesehatan have different rates and only one has a wage ceiling.
- Our assumption
- BPJSTK 2% employee and 3.7% employer; BPJSK 1% employee and 4% employer, with the Kesehatan ceiling applied.
Q9 Who currently has no NPWP?
needs a valueWhy it matters. No NPWP means a 1.2× surcharge on PPh 21. It is per person, and it changes the month they register — so it has to be a field somebody owns, not a note.
- Our assumption
- None. We need the list; it is not in any data we hold.
Q10 Where does each person’s PTKP status come from?
needs a valueWhy it matters. PPh 21 depends on marital status and number of dependants. The platform stores neither today, and what somebody says in conversation is not necessarily what was filed.
- Our assumption
- Finance holds this and it is stable year to year. If so it becomes a field on the employee record, and it needs a named owner who updates it.
Q11 Does this platform run THR?
no decision covers thisWhy it matters. Tunjangan Hari Raya is a statutory obligation — a month’s wages before the religious holiday, prorated under a year of service — and nothing we have written down mentions it at all. If payroll runs it, it needs a rule, a month and a tax treatment. If it is paid outside the platform, we need that in writing, or the year-end reconciliation will quietly understate what people were paid.
- Our assumption
- None. This is a genuine gap and we found it while writing this document.
Money, rates and rounding
%%{init: {"flowchart": {"padding": 16, "nodeSpacing": 46, "rankSpacing": 50, "useMaxWidth": true}}}%%
flowchart TB
SAL["Salary
contracted in SGD"]
SAL -->|"Statutory Rate
fixed for the payroll month"| STAT["Local figure used to compute
contributions and tax"]
SAL -->|"Settlement Rate
the day the money moved"| PAY["Local figure that actually
reached the bank"]
STAT --- X(["these two are
never reconciled"])
PAY --- X
style X fill:#F6E3DE,stroke:#8F3A2C
style SAL fill:#E2EDEA,stroke:#14544C
Q12 Which rate computes, and which rate pays?
confirmWhy it matters. See Figure 2. Getting this backwards makes every statutory figure move whenever the bank’s rate moves, which would make a closed month impossible to reprint.
- Our assumption
- Contributions and tax compute at the Statutory Rate for the month. The transfer happens at the Settlement Rate and is recorded as it happened, never recomputed.
Q13 What does the payslip show — SGD, local, or both?
confirmWhy it matters. Salary is contracted in SGD, people are paid in local currency, and statutory figures are inherently local. A payslip showing one of the three cannot be checked by the person holding it.
- Our assumption
- Both, with SGD as the contractual figure and local as the paid figure, and the rate used printed on the slip.
Q14 Rounding, per currency.
confirmWhy it matters. The old app rounds everything to two decimals including rupiah, which has no cents. One-rupiah differences reconcile to nothing and look like errors forever.
- Our assumption
- SGD and MYR to two decimals, IDR to whole rupiah, rounded as each statutory figure is computed rather than at the end.
Q15 What happens when a rate is missing?
confirmWhy it matters. The old app carries a built-in fallback rate for every currency, so a missing rate produces a plausible wrong number instead of a stop.
- Our assumption
- The run refuses to compute and names the missing rate. Nobody is paid on a guessed number.
Claims, leave and one-offs
Q16 What is the claim cut-off day?
needs a valueWhy it matters. A claim approved after the cut-off settles in the following payroll month. The old app has this as a setting with no value in it.
- Our assumption
- None. We need the number, and whether it is the claim date or the approval date it applies to.
Q17 Does a taxable allowance claim attract CPF, or only income tax?
confirmWhy it matters. It decides whether a claim reaches the statutory calculation at all, or only appears on the year-end form.
- Our assumption
- It is Additional Wage and attracts both.
Q18 Directors’ fees — how often, and to whom?
needs a valueWhy it matters. A fee is a payroll line — taxable, no CPF. The old app only captures it as a year-end adjustment, so it never appears on a payslip in the month it was actually paid.
- Our assumption
- Paid irregularly rather than monthly, to a known short list, and it should show as its own line in the month it is paid.
Q19 Leave encashment at exit — which month, which wage type?
confirmWhy it matters. If encashment is Additional Wage it competes with everything else against the annual ceiling, in somebody’s final month, when there is no later month to correct it in.
- Our assumption
- Paid in the final payroll month, treated as Additional Wage.
Q20 Is there an AWS or thirteenth-month payment?
no decision covers thisWhy it matters. Same shape of gap as THR — nothing we have written down mentions one. If it exists it is Additional Wage, it lands in a specific month, and it changes the December ceiling arithmetic for everyone who gets it.
- Our assumption
- None exists.
03Part B · The thirty worked examples
Inputs are filled in. You fill in what the payslip should say. The figures are invented rather than anyone’s real salary, and they are chosen to sit near the boundaries that break things — a ceiling, a joining date, a missing rate — because an example in the comfortable middle of the range proves the least.
Itemised, not just the total
For each case we need the parts, not only the bottom line: base salary after proration, the proration factor if the month is incomplete, each employee deduction separately — CPF, EPF, SOCSO, EIS, PCB, BPJSTK, BPJSK, PPh 21 — each employer contribution separately, then net pay and total cost to the company.
A total that comes out right because two errors cancelled is exactly the failure this table exists to catch, and it is invisible unless the parts are written down.
Standing inputs
Unless a case says otherwise: the month is March 2026, a full month is worked, there are no claims, the person is under 55, holds every statutory registration, and no exemption applies. Statutory rates for the month: 1 SGD = 3.15 MYR = 11,500 IDR. Settlement rate the same unless the case varies it.
Singapore
| Case | What it tests | Inputs |
|---|---|---|
| SG-01 | Standard full month | Salary 5,000 SGD |
| SG-02 | Mid-month joiner | Salary 5,000 SGD, joined 16 March |
| SG-03 | Leaver mid-month | Salary 5,000 SGD, last day 13 March |
| SG-04 | CPF-exempt | Salary 8,000 SGD, Employment Pass |
| SG-05 | Above the Ordinary Wage ceiling | Salary 9,000 SGD |
| SG-06 | Age band 55 to 60 | Salary 5,000 SGD, born March 1970 |
| SG-07 | Bonus against the annual ceiling | Salary 5,000 SGD, bonus 15,000 SGD, December |
| SG-08 | Directors’ fee in the month | Salary 5,000 SGD plus a 20,000 SGD fee |
| SG-09 | Taxable allowance claim | Salary 5,000 SGD, claim 800 SGD settling this month |
| SG-10 | Reimbursement claim | Salary 5,000 SGD, claim 240 SGD settling this month |
| SG-11 | Encashment at exit | Salary 5,000 SGD, last day 31 March, 9 days encashed |
Malaysia
| Case | What it tests | Inputs |
|---|---|---|
| MY-01 | Standard full month | Salary 2,200 SGD |
| MY-02 | Mid-month joiner | Salary 2,200 SGD, joined 10 March |
| MY-03 | Leaver mid-month | Salary 2,200 SGD, last day 20 March |
| MY-04 | Above the SOCSO ceiling | Salary 4,500 SGD |
| MY-05 | Taxable allowance claim | Salary 2,200 SGD, claim 500 SGD settling this month |
| MY-06 | Bonus month | Salary 2,200 SGD, bonus 4,400 SGD, December |
| MY-07 | Low wage, EIS boundary | Salary 900 SGD |
Indonesia
| Case | What it tests | Inputs |
|---|---|---|
| ID-01 | Standard full month | Salary 1,800 SGD, NPWP held, PTKP TK/0 |
| ID-02 | No NPWP — the 1.2× surcharge | Salary 1,800 SGD, no NPWP, PTKP TK/0 |
| ID-03 | Dependants change PTKP | Salary 1,800 SGD, NPWP held, PTKP K/2 |
| ID-04 | Mid-month joiner | Salary 1,800 SGD, joined 9 March |
| ID-05 | Leaver with leave overdrawn | Salary 1,800 SGD, last day 31 March, 4 days taken beyond earned |
| ID-06 | Above the Kesehatan ceiling | Salary 3,500 SGD |
| ID-07 | THR — only if Q11 says we run it | Salary 1,800 SGD, THR due |
| ID-08 | Taxable allowance claim | Salary 1,800 SGD, claim 400 SGD settling this month |
| ID-09 | Rounding to whole rupiah | Salary 1,777 SGD — chosen not to divide cleanly |
Cases that cross all three
| Case | What it tests | Inputs and expected behaviour |
|---|---|---|
| X-01 | Nobody worked | Joined 2 April, run for March |
| X-02 | Missing rate | Indonesian employee, no confirmed rate for the month. Expected: the run refuses and names the rate |
| X-03 | Claim past the cut-off | Expected: settles next month, not this one |
| X-04 | Part-time | Salary 2,500 SGD at three days a week |
| X-05 | Settlement differs from statutory | Statutory 11,500, settlement 11,720. Expected: both recorded, neither reconciled |
X-02, X-03 and X-05 have no payslip to fill in. What they expect is a behaviour rather than a number, and they are in the table because those three are the ones most likely to be built as a quiet default instead of a refusal.
04What happens after this comes back
- Every case becomes an automatic check. A rule that changes eighteen months from now breaks a build, not a payslip.
- Then the payroll tables are built, shaped by what the examples turned out to need.
- Then the calculation is written, with the stated job of making all thirty pass.
- Then the shadow month — one full month computed in parallel with the real one and compared line by line, before anything goes live. That gate stays regardless of how this document comes back.
Sign-off is against the arithmetic in Part B, not against any code. That is the whole point of doing it in this order.