Third edition, rewritten after your twenty-four answers and Anwar's five. Eleven of them changed the design; one changed it a great deal. Both questions we had for you are closed, and the project's only hard deadline has been removed. Everything below reflects that.
Your twenty-four answers are in, and so are Anwar's five. Nothing is waiting on a decision, both questions we had for you are answered, and the one hard deadline this project carried has been removed. There is nothing here that needs a reply this week.
Sections 03 to 07 are the same document you approved in shape, updated where the answers moved it. The largest single change is still in section 03: statutory contributions turn out not to follow the country someone works in, and seventeen people — an exact figure now, rather than the estimate in the last edition — are currently set up wrongly because of it.
The schema workbook was read end to end — 95 columns, 10 tabs, all 32 people. It is an unusually good document, and working through it closed three of its own blocking flags and opened one question nobody had asked.
Two dissolved on inspection. The salary-currency flag rested on an assumption that turned out to be wrong; the career-framework flag had already been solved by the Engineer track without the summary tabs being updated.
Years-to-next-promotion is reproducible from career scale for every single person, with no exceptions. So it does not need to be stored, and cannot drift. Seniority label matched 30 of 32 — both misses were typing errors.
The move to 1 January accrual takes a day back from thirteen people. We are grandfathering 2026 so nobody loses a day they have already been shown, and running the new rule from 1 January 2027.
The one question the workbook does not ask: every salary figure in the company is SGD, and no local-currency figure exists in any source. All five Singapore staff are paid by bank transfer; all twenty-seven others through Wise into a local account. That is the signature of an SGD contract settled abroad, not of local-currency pay.
If that is right — and we have taken it as right — then salaries are contracted in SGD, and the flag asking us to collect twenty-seven local salary figures before payroll can start is asking for something that does not exist. It changes the shape of the compensation table and it removes a blocker.
The platform is one application and one database. Inside it, the work is split into four parts, and the split is not cosmetic — it is a rule about who is allowed to depend on whom.
%%{init: {"flowchart": {"padding": 16, "nodeSpacing": 58, "rankSpacing": 62, "useMaxWidth": true}}}%%
flowchart TB
HRMS["HRMS
payroll, claims, budget
used by 2 people daily"]
ORG["ORG CHART
reporting tree, directory
used by all 32"]
LEAVE["LEAVE
balances, requests, awards
used by all 32"]
CORE["CORE
the one record of each person — name, contact,
reporting line, pay, login, permissions
built once, never rebuilt"]
HRMS -->|"reads people from"| CORE
ORG -->|"reads people from"| CORE
LEAVE -->|"reads people from"| CORE
LEAVE -.->|"unpaid leave only"| HRMS
Three consequences follow from that picture, and they are the reason it is worth drawing:
The dotted arrow is the one place this rule bends. Unpaid leave reduces someone's pay, so Leave has to tell payroll about it. We are building that as a one-directional handover — Leave reports days, payroll decides money — rather than letting the two modules read each other freely.
The workbook describes one employee record with 95 columns. We are splitting it into tables. The main reason is access: “only Finance may see this” becomes one rule on one table, rather than a column-by-column rule that every screen has to remember and any screen can forget.
Each box is a table. Each line is a real link between them, labelled in plain words — read it in the direction of the label. The lines inside each box are the fields that table holds; PK marks the field that identifies the row, FK marks a field that points at another table.
One employee record, with the sensitive parts lifted out into their own tables. The two links to account and company_card are “none or one” on purpose: not everyone has a login, and not everyone holds a card.
The new box here is legal_entity, and it is the most consequential addition in this edition. See the note under the diagram.
%%{init: {"er": {"entityPadding": 18, "minEntityWidth": 170, "diagramPadding": 22, "fontSize": 13}}}%%
erDiagram
CAREER_FRAMEWORK ||--o{ EMPLOYEE : "gives the job title to"
LEGAL_ENTITY ||--o{ EMPLOYEE : "employs"
DEPARTMENT ||--o{ EMPLOYEE : "groups"
SCHEME_OVERRIDE |o--o| EMPLOYEE : "rarely excepts"
EMPLOYEE ||--o{ EMPLOYEE : "supervises many"
EMPLOYEE ||--o| EMPLOYEE_PERSONAL : "has one"
EMPLOYEE ||--o| EMPLOYEE_COMPENSATION : "has one"
EMPLOYEE ||--o| COMPANY_CARD : "may hold"
LEGAL_ENTITY {
uuid id PK
text name "SG, ID or MY"
text country_code
text year_end_form
}
DEPARTMENT {
uuid id PK
text name UK
bool is_active
}
SCHEME_OVERRIDE {
uuid id PK
uuid employee_id FK
text contributions "or none"
text tax_withholding "or none"
text reason "required"
uuid set_by FK
}
EMPLOYEE {
uuid id PK
text employee_code UK
text full_name
text work_email UK
text phone_number "new"
uuid legal_entity_id FK "new"
text country_of_work
text office
uuid department_id FK
uuid scheme_override_id FK "rare"
int career_scale "1 to 9"
text career_track
uuid framework_id FK
uuid supervisor_id FK "an employee"
date hire_date
date probation_end_date
date last_working_day
text engagement_type
text working_hours
}
EMPLOYEE_PERSONAL {
uuid employee_id PK
text personal_email "new"
date date_of_birth
text nationality
text marital_status
text home_address
text emergency_name
text emergency_phone
text id_document_type
date passport_expiry
}
EMPLOYEE_COMPENSATION {
uuid employee_id PK
numeric monthly_salary_sgd
text cpf_residency
bytea national_id "encrypted"
bytea passport_number "encrypted"
bytea bank_account_no "encrypted"
text bank_name
text payment_rail
}
CAREER_FRAMEWORK {
uuid id PK
int scale "1 to 9"
text track
text seniority_label
int months_to_promotion
date version_from
}
COMPANY_CARD {
uuid id PK
uuid employee_id FK
numeric monthly_limit_sgd
bool is_unlimited
date issued_on
}
supervisor_id holds the one person you report to. The list of people who report to you is not a field — it is read back off that same link, and there is no limit on it. Yashwin has eight direct reports today and the record stores nothing about them. So “allow multiple subordinates” is already how it works; there was never a limit to lift.work_email is the @kpimedia.sg address and doubles as the sign-in identity.department is a real list you maintain yourselves: add Management, Ops, Analytics and Performance today, add more as the company grows, retire one without deleting the history of who was in it. No developer involved, and the permission to do it already sits with Finance and HR.employee_compensation and employee_personal are limited to Finance and the two Super Admins. Everything on employee itself is visible to the whole company.
Note what is not stored on the employee: job title, seniority label, when they are next up for promotion, their card limit, and — new in this edition — which statutory scheme they contribute to. All of them are read from somewhere else at the moment they are needed, which is why Andre's and Neesha's mis-typed titles in the workbook become impossible rather than merely corrected.
You told us KPI Media has three employing companies: Singapore, Indonesia and Malaysia. A few of the Jakarta staff sit under the Indonesian company and pay BPJS. The rest sit under the Singapore company and contribute nowhere.
That breaks an assumption the whole payroll engine was about to be built on. The plan you first read said a person's contribution scheme was decided by where they work. It is not. Two people at neighbouring desks in Jakarta fall under different schemes depending on which company employs them, and nothing about where they sit tells you which.
The workbook has all twenty-two Indonesians marked as BPJS, and all thirty-two marked as employed by KPI Media Pte Ltd. Both are defaults nobody filled in — the same pattern as every identity document being labelled “passport”. Taken literally, we would have deducted BPJS from seventeen people who should have nothing deducted, every month, from the first live payroll.
Anwar has since named the five who do contribute and file: Novicto, I Wayan Raka, Shahira, Putu Dewi and Kartika. Because BPJS requires an Indonesian employer, naming those five also names the Indonesian company's payroll — which answers what this box previously called the single most important thing outstanding. Five under the Indonesian entity, seventeen Indonesians under Singapore, two Malaysians under Malaysia, the rest under Singapore. HR should still confirm it in their own words rather than have it read off a contribution list, but nothing is waiting on it.
One clarification, because it is easy to talk past. KPI Media is one business — one team, one culture, one set of clients — that employs people locally through entities in Indonesia and Malaysia. Both statements are true at once, and only the second one reaches the payroll engine. “Same company” describes the organisation; the platform needs to know who signs each employment contract, because that is what decides the deductions and the year-end form. Nothing about this changes how the business thinks of itself.
Two refinements came out of the same conversation. Contributions and tax withholding are now two separate settings rather than one — someone can sit outside BPJS and still have Indonesian tax withheld, and a single field cannot say that. And any individual can be set to something other than what their company implies, which is what scheme_override is for: it requires a written reason and shows on the record as a deliberate exception. Nobody needs one today. It exists so that the next genuine exception is recorded as an exception, instead of being typed over the top of a fact — which is precisely how twenty-two rows came to be wrong in the first place.
Being able to log in and being employed are two different facts, so they are two different tables. Someone who has joined but not been set up yet exists in the directory with no account at all.
Your answer on sign-in tightened this. Google only, @kpimedia.sg only, no exceptions — which means we cannot grant a leaver access, because their sign-in is a Google account we do not control. So the rule is now the other way round: a leaver's Google account stays open for about thirty days, which is what lets them collect the final payslip that arrives after they have gone. It costs one Workspace seat for a month and needs nothing built — but it is a step on the offboarding checklist, and if someone closes the account on the last day out of habit, the platform cannot tell that it happened.
%%{init: {"er": {"entityPadding": 18, "minEntityWidth": 170, "diagramPadding": 22, "fontSize": 13}}}%%
erDiagram
EMPLOYEE ||--o| ACCOUNT : "may log in through"
ACCOUNT ||--o{ ROLE_ASSIGNMENT : "is granted"
ROLE ||--o{ ROLE_ASSIGNMENT : "appears in"
ROLE ||--o{ ROLE_PERMISSION : "bundles"
PERMISSION ||--o{ ROLE_PERMISSION : "appears in"
EMPLOYEE ||--o{ APPROVER_OVERRIDE : "may be redirected by"
ACCOUNT ||--o{ AUDIT_LOG : "leaves a trail in"
EMPLOYEE {
uuid id PK
text full_name
}
ACCOUNT {
uuid id PK
uuid employee_id FK
text status
timestamptz last_sign_in_at
}
ROLE {
text code PK
text display_name
}
PERMISSION {
text code PK
text module
}
ROLE_ASSIGNMENT {
uuid account_id FK
text role_code FK
date granted_on
}
ROLE_PERMISSION {
text role_code FK
text permission_code FK
}
APPROVER_OVERRIDE {
uuid id PK
uuid employee_id FK
text request_kind
uuid approver_id FK
text reason
}
AUDIT_LOG {
uuid id PK
uuid account_id FK
text action
text target
timestamptz occurred_at
}
account.status is one of invited, active, suspended or closed. role.code is super_admin, finance_hr or employee; permission.code is a capability such as payroll.approve.supervisor_id in Figure 2 at the moment of the check, so it can never be granted to the wrong person or left behind after a reorganisation.
Approval normally follows the reporting line exactly, with nothing recorded anywhere. approver_override exists for the genuine exceptions — chiefly that the CEO reports to nobody, so his own leave has to reach someone. If that table starts filling up, it is a signal the reporting line has stopped being true, and the fix is the org chart rather than more overrides.
Four tables. The one to look at is claim_approval — approvals are rows, not a column on the claim, which is what lets the system hold two signatures and check they came from two different people.
You asked for two changes here, and both are in the diagram: Finance decides taxable-or-reimbursement when they approve, and there is a new uncapped category for office and business spending.
%%{init: {"er": {"entityPadding": 18, "minEntityWidth": 175, "diagramPadding": 22, "fontSize": 13}}}%%
erDiagram
EMPLOYEE ||--o{ CLAIM : "submits"
CLAIM_CATEGORY ||--o{ CLAIM : "sets the default treatment of"
CLAIM ||--|{ CLAIM_APPROVAL : "collects two of"
EMPLOYEE ||--o{ CLAIM_APPROVAL : "signs"
EMPLOYEE {
uuid id PK
text full_name
}
CLAIM_CATEGORY {
uuid id PK
text name
text tax_treatment "the default"
numeric default_cap_sgd "may be none"
bool receipt_required
}
CLAIM {
uuid id PK
uuid employee_id FK
uuid category_id FK
numeric amount_claimed "receipt currency"
text currency "the receipt's"
numeric approved_sgd "new, always SGD"
text reduction_reason "new, if uncapped"
text receipt_url
text tax_treatment "new, set by Finance"
text treatment_reason "new, if overridden"
text reject_reason "new, if rejected"
text state
}
CLAIM_APPROVAL {
uuid id PK
uuid claim_id FK
text stage
uuid approver_id FK
timestamptz decided_at
}
amount_claimed holds the receipt exactly as issued — IDR 1,700,000 stays IDR 1,700,000 forever, so the claim and the document always agree. approved_sgd is what Finance agreed to pay, always in SGD, and may be less. Only Finance writes it; the second approver signs that figure or sends it back.stage is either finance or final, and the database refuses an approver_id that equals the claim's own employee_id.Two new categories, not one. You described office spending and a big equipment purchase in the same breath, and neither has a cap. We are splitting them anyway — office_expense for things that get consumed, equipment for things the company ends up owning. It costs one extra row in a reference table today. It means that if you ever want to know who is holding which laptop, the answer is already recorded rather than buried in three years of receipts. Asset tracking stays out of scope, as you confirmed.
One consequence worth naming: nothing in the system treats a $3,000 claim differently from a $40 one. That follows your instruction — spending is controlled by the two approvals, not by a value threshold — and the second approver is you or Anwar. But it also means whoever buys the laptop is out of pocket until payroll, because card limits stop at $75 and there is no company-purchase route in the plan.
The run is the spine: one payroll_run per month, holding one payroll_line per person. Everything to the left of the line feeds into it; the settlement to the right records what happened afterwards.
Three of your answers land here — directors' fees as their own line, year-to-date figures, and Anwar being able to approve a run he prepared himself.
%%{init: {"er": {"entityPadding": 18, "minEntityWidth": 175, "diagramPadding": 22, "fontSize": 13}}}%%
erDiagram
PAYROLL_RUN ||--|{ PAYROLL_LINE : "holds one per person"
EMPLOYEE ||--o{ PAYROLL_LINE : "is paid through"
SALARY_HISTORY ||--o{ PAYROLL_LINE : "sets base pay for"
STATUTORY_RATE ||--o{ PAYROLL_LINE : "converts to local for"
STATUTORY_CONFIG ||--o{ PAYROLL_LINE : "computes contributions for"
CLAIM ||--o| PAYROLL_LINE : "is settled in"
PAYROLL_LINE ||--o| SETTLEMENT : "results in"
EMPLOYEE ||--o{ SALARY_HISTORY : "has a dated series of"
EMPLOYEE ||--o| OPENING_BALANCE : "starts the year with"
OPENING_BALANCE {
uuid id PK
uuid employee_id FK
uuid legal_entity_id FK
int tax_year
numeric gross_salary_ytd
numeric directors_fee_ytd
numeric taxable_allowance_ytd
numeric contributions_ytd
uuid attested_by FK
bool locked
}
SALARY_HISTORY {
uuid id PK
uuid employee_id FK
numeric monthly_salary_sgd
date effective_from
}
STATUTORY_RATE {
uuid id PK
text country_code
text payroll_month
numeric sgd_to_local
uuid confirmed_by FK "new"
timestamp confirmed_at "new"
}
HOLIDAY {
uuid id PK
text country_code
date holiday_on
text name
}
STATUTORY_CONFIG {
uuid id PK
text country_code
text scheme
jsonb bands
date effective_from
}
PAYROLL_RUN {
uuid id PK
text payroll_month
text state
uuid submitted_by FK "new"
uuid finalised_by FK
text override_reason "new, if self-approved"
}
PAYROLL_LINE {
uuid id PK
uuid run_id FK
uuid employee_id FK
numeric base_pay_sgd
numeric days_worked "new, if partial"
numeric working_days "new, if partial"
numeric directors_fee_sgd "new"
numeric claims_sgd
numeric leave_encashment_sgd "new"
numeric leave_settlement_sgd "new, at exit"
numeric unpaid_leave_days
numeric employee_contributions
numeric employer_contributions
numeric net_pay_local
numeric total_staff_cost_sgd
}
SETTLEMENT {
uuid id PK
uuid payroll_line_id FK
numeric amount_local
numeric settlement_rate
text rail
date paid_on
}
statutory_rate.sgd_to_local is fixed before the run and computes every contribution and filing, so reprinting a closed month reproduces the original numbers exactly. settlement.settlement_rate is what Wise actually charged, written after the transfer, and it computes nothing. They never touch.confirmed_by records who. What we did not build is a live link to a Google Sheet that updates itself. These numbers are what three tax authorities are filed on, and a document anyone with the link can edit would mean a colleague tidying a column in November could silently change what was filed in October. The upload saves all the typing; the confirmation costs one press and gives the figures a name against them.days_worked and working_days appear only on a line for someone who joined or left mid-month. They are stored rather than just applied, so the payslip can show its own arithmetic and a question in six months' time can be answered without rebuilding a calendar. holiday is the table that makes them possible — the working-day count depends on each country's public holidays, and Singapore, Indonesia and Malaysia do not share them.opening_balance is how the platform shows correct year-to-date figures and produces a 2026 tax form despite only having computed part of the year. Finance supplies each person's position as at go-live, broken into the lines the form asks for, a Super Admin signs it off, and it locks the moment the first form is generated.payroll_run.state moves draft → generated → submitted → finalised, and only a Super Admin can write the last step. submitted_by is new: the platform now compares it against whoever clicks approve.payroll_line.
You asked for both as new lines. They are three different kinds of money and only two of them are ours to report.
A directors' fee is taxable and belongs on the year-end form, but carries no CPF — it is paid for holding office, not for work done. So it is a payroll line with its own rules. One thing worth knowing: a directors' fee is taxable in the year it is voted at the AGM, not the year it is paid. If a fee is approved in one year and paid the next, it belongs on the earlier year's form, so we record both dates.
A dividend is a return on shares. Under Singapore's one-tier system it is tax-exempt in your hands, touches no contribution, and does not appear on a tax form at all. Putting it through payroll would mean teaching CPF, the levies, the tax brackets and every year-end form to individually ignore it — and each one that forgot would be a filing error rather than a display bug. So it is recorded, its year-to-date figure is shown, and it never enters the payroll engine. It is also kept out of total staff cost, because a dividend is not a cost of employing anyone.
Two of your answers pulled in opposite directions. One said Anwar needs to prepare and approve payroll himself if Arul and Agus are both away. The other said only one of the four can ever be on leave at a time, so that cannot happen.
We found something while checking: Anwar can already do both steps today. Not because anyone granted it — directors hold every permission, and that quietly includes preparing payroll as well as signing it off. The two-person check has never actually been a rule.
So we are doing both things. The system will refuse to let one person do both steps, and Anwar can push through anyway with a written reason that notifies you. He is never blocked; it simply cannot happen invisibly. And the leave module will enforce the rule you described, refusing a leave request from any of the four that overlaps another's — which turns the reason four approvers is safe into something the system guarantees rather than something everyone remembers.
The honest cost: four people's holidays are genuinely constrained, yours included.
Built in phase one, used in phase four. The important thing about this diagram is what it does not contain: there is no balance column anywhere.
%%{init: {"er": {"entityPadding": 18, "minEntityWidth": 175, "diagramPadding": 22, "fontSize": 13}}}%%
erDiagram
LEAVE_POLICY ||--o{ LEAVE_ENTITLEMENT : "sets base days for"
EMPLOYEE ||--o{ LEAVE_ENTITLEMENT : "has one per leave year"
EMPLOYEE ||--o{ AWARD_LEDGER : "is granted extra days in"
EMPLOYEE ||--o{ LEAVE_REQUEST : "books"
LEAVE_TYPE ||--o{ LEAVE_REQUEST : "classifies"
LEAVE_TYPE ||--o{ AWARD_LEDGER : "classifies"
HOLIDAY_CALENDAR ||--o{ AWARD_LEDGER : "justifies the gap award in"
LEAVE_POLICY {
uuid id PK
int base_annual_days
bool prorates_by_fte "new"
int carry_forward_cap_days "5"
date carry_forward_expires "31 Mar"
date year_end_cutoff "19 Dec, new"
int encashment_cap_days "10, new"
int sick_days_cap "14"
int hospitalisation_cap "60 inclusive"
}
LEAVE_TYPE {
text code PK
text name
bool draws_down_allowance
bool is_unpaid
}
HOLIDAY_CALENDAR {
uuid id PK
text country_code
date holiday_date
text name
}
LEAVE_ENTITLEMENT {
uuid id PK
uuid employee_id FK
int leave_year
numeric base_days
numeric prorated_days
numeric carry_forward_in
date carry_forward_expiry
}
AWARD_LEDGER {
uuid id PK
uuid employee_id FK
text award_kind
numeric days
date awarded_on
date expires_on
uuid granted_by FK
text reason
}
LEAVE_REQUEST {
uuid id PK
uuid employee_id FK
text type_code FK
date start_date
date end_date
numeric days
text state
uuid approver_id FK
}
leave_type.draws_down_allowance is the field the current tracker never had — it is what separates annual leave from sick, birthday and parental leave. award_kind is service, off-in-lieu or holiday-gap.leave_policy as data, not buried in code. Changing a figure later is an edit, not a rebuild.award_ledger is append-only: a granted day is never edited or deleted, only offset by a further entry, so any balance traces back to the entries that produced it and who signed them.You gave two worked examples and they pin the rule exactly. At the close, annual leave and off-in-lieu are one pool. Five days carry forward, filled from off-in-lieu first. Leftover off-in-lieu burns. Leftover annual leave is encashed up to ten days, and the rest burns.
Filling from off-in-lieu first is the version that pays people more. In your own second example — ten days off-in-lieu and twelve days annual leave — taking off-in-lieu first carries five, burns five, and encashes ten. Doing it the other way round would encash only seven. The ordering is worth three days of cash to that person, so it is worth confirming it was deliberate.
The number nobody has seen yet: about 57 days of off-in-lieu burn at the first close, across 22 people. The company is carrying 178.5 off-in-lieu days and nothing has ever removed a single one — Natalie holds 11.5, Luck and Ferrin ten each. Those days were earned by working a public holiday or a weekend, which is not the same as an unused grant expiring, and people will not experience it as the same thing. This is the main reason the October announcement matters, and why it should come from you rather than from Finance.
The cutoff you proposed does close the gap cleanly. Applications shut on 19 December — Finance starts payroll on the 20th, so it has to be the day before, not the same day — and anything that moves afterwards, a cancelled booking or off-in-lieu earned over Christmas, rolls into January as a fresh day in the new year. The closed year never reopens, and the December payslip can carry the encashment because the balance is final when it is computed.
The current tracker stores some balances and calculates others, and one figure the whole post-March calculation depends on is typed in by hand. That is why balances drift. In the new system every balance — days remaining, off-in-lieu held, benefit spent, direct reports, next review cycle — is worked out from the underlying records each time it is shown. It cannot fall out of step, because there is nothing to fall out of step with.
| Table | Holds | Readable by |
|---|---|---|
| Core — shared by every module | ||
| employee | Name, code, work email, phone number, country, office, department, career scale and track, who they report to, hire date, engagement type, hours. | Everyone |
| employee_personal | Personal email, date of birth, nationality, marital status, home address, emergency contact, identity document type and passport expiry. | HR and Finance |
| employee_compensation | Monthly salary in SGD, bank details, statutory scheme, CPF residency, benefit cap overrides. National ID, passport number and bank account number are encrypted and shown as last-4 unless opened deliberately, which writes an audit entry. | Finance + Super Admin |
| account | Login, and whether it is invited, active, suspended or closed. Kept separate from employment status so someone who has left can still hand over. | Admins |
| role / permission | Named capabilities and the bundles that grant them. Being someone's manager is not in here — that is read from the reporting line. | Admins |
| career_framework | Scale 1–9 across five tracks, each giving a job title and a promotion interval. Versioned, so renaming a rung is a change to the framework and moves nobody. | Everyone |
| approver_override | The rare exceptions where a request should not go to the usual person — how the CEO's own leave reaches someone. Expected to stay nearly empty. | HR |
| company_card | Who holds a card and its monthly limit, which follows their career scale. Unlimited is stated outright rather than left blank. | Finance |
| audit_log | Every change to pay, bank details and leave balances, and every time an identity document is opened. | Admins |
| HRMS — payroll and claims | ||
| salary_history | Salary with the date it took effect. Payroll can re-run any past month at the figure that applied then. | Finance + Super Admin |
| statutory_rate | The SGD-to-local rate Finance sets for each country each month, used to compute every contribution and filing. | Finance |
| statutory_config tax_bracket · ptkp |
CPF, EPF/SOCSO and BPJS rates, tax brackets and Indonesian relief bands, each with the date it takes effect. A missing rate stops the run rather than guessing. | Finance |
| claim claim_approval claim_category |
What was spent, in the currency of the receipt, with each approval stage recorded separately, and the category that decides whether it is taxed. | Claimant, Finance |
| payroll_run payroll_line |
One run per month, one line per person: base pay, claims pulled in, unpaid leave deducted, every contribution, net pay and total staff cost. | Finance + Super Admin |
| settlement | What actually left the company — the local amount, the rail, and the rate the rail charged. Written after the transfer, never before. | Finance |
| budget | Planned monthly staff cost in SGD, set ahead and compared against what payroll actually produced. | Finance |
| Leave — built now, screens later | ||
| leave_policy | Base days, how mid-year joiners are pro-rated, the carry-forward cap and its expiry, sick and hospitalisation limits. Held once, not copied onto every person. | Everyone |
| leave_type | The fourteen leave codes and whether each one draws down the allowance. | Everyone |
| holiday_calendar | Public holidays per country. Singapore's set is what everybody works to; the others exist only to calculate the annual off-in-lieu award. | Everyone |
| award_ledger | Every day granted beyond the base entitlement — service awards, off-in-lieu, the holiday-gap award — with who granted it and why. Balances are read from this, never stored. | Everyone |
| leave_entitlement | One row per person per leave year: base days, months pro-rated, carry-forward brought in and when it expires. | Everyone |
| leave_request | Leave taken and booked, with its approval. | Self, manager, HR |
These are the four places where the system decides something rather than merely records it, so they are the four worth checking most carefully. The last one is new, and is the rule you set at the year end.
%%{init: {"flowchart": {"padding": 16, "nodeSpacing": 55, "rankSpacing": 52, "useMaxWidth": true}}}%%
flowchart TB
A["Employee submits
receipt attached
in the receipt currency"] --> C["Finance reviews
Arul or Agus"]
C --> T["Finance sets how much and the treatment
may agree less than was claimed"]
T --> D["Super Admin approves or rejects
Yashwin or Anwar — cannot change the amount"]
D -- "amount disputed" --> C
C -- rejected --> R["Rejected
reason required, shown to the employee"]
D -- rejected --> R
D -- approved --> E["Pulled into the
payroll month"]
E --> F{"Treatment on
this claim"}
F -- reimbursement --> G["Paid on top of net
no tax, no contributions"]
F -- "taxable allowance" --> H["Added to gross
before contributions"]
The first edition of this plan rejected that claim outright. The employee would have had to resubmit at $70 — holding a receipt that says $130. A claim whose amount disagrees with its own receipt is the thing an auditor stops on, so that was wrong.
A claim now carries two figures: what was asked for, and what was agreed. $130 claimed, $70 approved, $60 not reimbursed and visible as such. The receipt still matches the claim. The payment still matches the approval. Nothing has to be pretended.
Finance can also part-approve on the uncapped categories — "you claimed $400 for the dinner, we will cover $250". That is a judgement rather than arithmetic, so it requires a written reason. Where a cap is simply being applied, no reason is typed: the system states the arithmetic itself, and asking Finance to retype what the platform already knows produces boilerplate rather than explanation.
Two consequences worth stating. The cap is consumed by the approved amount, not the claimed one — that $130 claim uses $70 of the year's allowance, not $130. And the approved figure is always SGD, so a cap is spent exactly and cannot drift; what reaches an Indonesian account is that SGD figure converted at the month's declared rate, which moves the rupiah a little either way.
The receipt itself keeps its own currency. A receipt for IDR 1,700,000 is recorded as IDR 1,700,000 and never converted, because the whole value of attaching it is that the claim and the document agree. So a claim carries a local claimed amount and an SGD approved amount, and that asymmetry is on purpose.
The consequence worth stating out loud: the approver pool is four people. Two at each stage, and self-approval is blocked, so a claim submitted by one of the four leaves exactly one person able to sign it. One absence stalls a stage. That is the price of the control, and it is the reason the pool should grow before the company does.
%%{init: {"flowchart": {"padding": 16, "nodeSpacing": 55, "rankSpacing": 52, "useMaxWidth": true}}}%%
flowchart TB
R["Finance sets the month's
SGD to local rate per country"] --> S["Generate the run"]
S --> T["For each person: salary in SGD
+ approved claims
- unpaid leave"]
T --> U["Convert to local at the
month's declared rate"]
U --> V["Compute contributions
CPF · EPF/SOCSO · BPJS · none"]
V --> W["Finance submits"]
W --> Y["Super Admin finalises"]
Y --> Z["Money sent"]
Z --> AA["Record what the rail
actually charged"]
M["A missing rate stops the run
and names what is missing"] -.-> S
%%{init: {"flowchart": {"padding": 16, "nodeSpacing": 55, "rankSpacing": 52, "useMaxWidth": true}}}%%
flowchart TB
P["Employee requests leave"] --> Q{"Does this type draw
down the allowance?"}
Q -- "no — sick, birthday,
parental, unpaid" --> R2["Recorded, allowance untouched"]
Q -- "yes — annual, half days" --> S2["Their manager approves"]
S2 --> T2["Balance recalculated in order"]
T2 --> U2["1 — carry forward
expires 31 March"]
U2 --> V2["2 — off-in-lieu"]
V2 --> W2["3 — base + service award"]
R2 --> X2{"Unpaid?"}
X2 -- yes --> Y2["Deduction handed to payroll"]
New, and the most intricate rule in the document — drawn out because it decides who gets cash, who loses days, and in what order.
%%{init: {"flowchart": {"padding": 16, "nodeSpacing": 55, "rankSpacing": 52, "useMaxWidth": true}}}%%
flowchart TB
Y["19 December
leave applications close
Finance starts payroll on the 20th"] --> P["One pool
annual leave + off-in-lieu"]
P --> C["Fill the 5-day carry-forward
off-in-lieu first, then annual leave"]
C --> M["The 5 carried days
expire 31 March"]
C --> O["Off-in-lieu still left over
burned"]
C --> A["Annual leave still left over"]
A --> E["First 10 days
encashed on the December payslip"]
A --> B2["Anything beyond 10
burned"]
Four levels of access. Everyone has the first; thirteen people also have the second; two people have the third; two people have the fourth. This section is the one worth reading closely, because it is the part that is hardest to change later without someone noticing.
You asked whether Arul and Agus can still submit claims and request leave like anyone else, and whether the same holds for you and Anwar. Yes — and it already does.
Access is added, never swapped. All four of you hold the ordinary employee access shown in the first card below, and Finance or Super Admin access sits on top of it. Arul submits a claim the same way anyone else does; it simply gets approved by Agus rather than by himself, because the database refuses an approval from the claimant at either stage.
That was true in the first edition too — the four cards below read like a ladder, which was our mistake in drawing them, not a gap in the design.
%%{init: {"flowchart": {"padding": 16, "nodeSpacing": 40, "rankSpacing": 70, "useMaxWidth": true}}}%%
flowchart LR
L["Sign in"] --> H["Home"]
H --> D["Directory and org chart
everyone's contact card"]
H --> M["My record
view it; ask HR to change it"]
H --> C["Claims
submit, track, see the cap left"]
H --> V["My leave
balance, request, history"]
See anyone else's salary, bank details, claims, home address or date of birth. Edit their own record directly — changes go through HR, so every change has an author. Approve anything, including their own claim or leave.
This is the one level nobody is ever given. The system checks, at the moment of each request, whether you are the requester's supervisor. Someone who takes on a report gains it the same day; someone who loses their last report loses it the same day. There is no list to maintain and nothing to forget to revoke.
%%{init: {"flowchart": {"padding": 16, "nodeSpacing": 40, "rankSpacing": 70, "useMaxWidth": true}}}%%
flowchart LR
E["Everything an employee
can do"] --> A["Approvals inbox"]
A --> B{"Team member's
leave request"}
B -- approve --> C["Balance updates
team calendar updates"]
B -- reject --> D["Returned with a reason"]
E --> F["My team
who is out, balances,
probation dates"]
E --> G["Grant off-in-lieu
for a holiday or
weekend worked"]
See their team's pay. See their team's claims, or approve any of them — a manager is not a stage in the claims chain and is not shown what their team spends. Change a team member's record, their reporting line or their career scale. If cost-centre reporting is needed later, that is a report we build for Finance, not a manager permission.
Finance and HR are the same job at KPI Media, so they are one role rather than two overlapping ones. This is the level that works with pay daily — and along with the two Super Admins, the only level that can open a salary or a bank account number at all. Every one of those opens is written to the audit log with a name and a timestamp.
%%{init: {"flowchart": {"padding": 16, "nodeSpacing": 45, "rankSpacing": 55, "useMaxWidth": true}}}%%
flowchart TB
A["Sign in — Arul or Agus"] --> B["People admin
new starter, leaver, promotion,
reporting line, personal details"]
A --> C["Pay and bank details
opened daily; every open logged"]
A --> H["Claims queue
first approval"]
A --> D["Set the month's
declared exchange rates"]
D --> E["Generate the payroll run"]
E --> F["Review every line"]
F --> G["Submit for finalisation"]
G -.-> I["Waits for a Super Admin"]
H -.-> I
I --> J["Record each settlement
after the transfer clears"]
Finalise a payroll run — that is deliberately a second pair of eyes. Give final approval on a claim. Approve their own claim at the Finance stage either, which means a claim from Arul must be signed by Agus and vice versa. Change the career framework, the leave policy or anyone's access.
The final signature, and the only role that can change what the system means — what a scale-5 Manager is called, how many days the policy grants, what a benefit cap is. It carries every permission in the platform, including salary and bank details.
%%{init: {"flowchart": {"padding": 16, "nodeSpacing": 45, "rankSpacing": 55, "useMaxWidth": true}}}%%
flowchart TB
A["Sign in — Yashwin or Anwar"] --> B["Finalise the payroll run
no money moves before this"]
A --> C["Final approval on every claim"]
A --> D["Change what the system means
career framework · leave policy · benefit caps"]
A --> E["Grant and revoke access"]
A --> F["Read the audit log"]
A --> G["Pay and bank details
full access, same as Finance"]
Approve their own claim, at either stage, with no exception and no override. Sign a claim at the Finance stage and the final stage — the two signatures must always be two different people. Delete an audit entry, or a granted day from the award ledger; both are append-only, so even the highest level of access leaves a trail it cannot erase.
| Can they… | Employee | Manager | Finance & HR | Super Admin |
|---|---|---|---|---|
| See the directory and org chart | ✓ | ✓ | ✓ | ✓ |
| See personal details — address, date of birth | own | own | ✓ | ✓ |
| See salary and bank details | — | — | ✓ | ✓ |
| Change an employee record | — | — | ✓ | ✓ |
| Submit a claim | ✓ | ✓ | ✓ | ✓ |
| See someone else's claims | — | — | ✓ | ✓ |
| Approve a claim — first stage | — | — | ✓ | — |
| Approve a claim — final stage | — | — | — | ✓ |
| Request leave | ✓ | ✓ | ✓ | ✓ |
| Approve leave | — | own team | own team | own team |
| See leave balances | own | + team | ✓ | ✓ |
| Grant off-in-lieu | — | own team | ✓ | ✓ |
| Generate a payroll run | — | — | ✓ | ✓ |
| Finalise a payroll run | — | — | — | ✓ |
| Finalise a run you generated yourself | — | — | — | with a reason |
| Issue a client org-chart link | — | — | ✓ | ✓ |
| Change the framework, policy or caps | — | — | — | ✓ |
| Grant or revoke someone's access | — | — | — | ✓ |
| Read the audit log | — | — | own actions | ✓ |
A Super Admin can read every table in the platform, salary and bank details included. That is a deliberate choice: the two people holding it run the company, and a control that hides the payroll from the CEO would be theatre rather than security.
What the role is constrained on is signing. Every claim needs two signatures from two different people, so a Super Admin cannot stand in for the Finance stage on a claim they will also finalise. Nobody, at any level, approves their own claim — and there is no override on that one. And the audit log and the leave award ledger are append-only for everyone: the highest access in the system still cannot erase what it did.
Payroll is the one place with a door rather than a wall. Finance prepares a run and cannot release it. A Super Admin releases it, and can also prepare it when there is no alternative — but not without saying why, and not without the other Super Admin being told. That is the difference between the two controls: a claim's second signature protects against self-dealing, so it is absolute; a payroll run's second signature protects against an unnoticed mistake, and a control that could stop payroll happening at all would be worse than the risk it removes.
The practical consequence is that four people can open a bank account number rather than two. Every one of those opens is logged with a name and a timestamp, which is what makes broad read access acceptable rather than merely convenient.
The same picture from the other side — every screen we build, and exactly which tables it touches. This is the checklist we build against.
| Feature | Reads | Writes | Who can use it |
|---|---|---|---|
| Staff directory | employee, career_framework | — | Everyone |
| Employee record | employee, employee_personal | employee, employee_personal, audit_log | Finance & HR |
| Pay and bank details | employee_compensation, salary_history | salary_history, audit_log | Finance & HR, Super Admin |
| Submit a claim | claim_category, claim, employee_compensation (cap only) | claim | Everyone |
| Approve a claim | claim, claim_approval, employee | claim_approval, audit_log | Finance, then Super Admin |
| Run payroll | salary_history, statutory_rate, statutory_config, claim, leave_request | payroll_run, payroll_line | Finance & HR |
| Finalise payroll | payroll_run, payroll_line | payroll_run, audit_log | Super Admin |
| Record a payment | payroll_line | settlement | Finance & HR |
| Budget vs actual | budget, payroll_line | budget | Finance & HR |
| Payslips and year-end forms | payroll_line, statutory_rate, employee | audit_log | Finance issues; each person reads their own |
| Org chart | employee, career_framework | — | Everyone |
| Request leave | leave_entitlement, award_ledger, leave_request, holiday_calendar, leave_policy | leave_request | Everyone |
| Approve leave | employee (reporting line), approver_override, leave_request | leave_request | The requester's manager |
| Grant off-in-lieu | holiday_calendar, employee | award_ledger | Managers, Finance & HR |
| Leave balances | leave_entitlement, award_ledger, leave_request | — | Self, manager, Finance & HR |
| Year-start and year-end jobs | employee, leave_policy, leave_entitlement | award_ledger, leave_entitlement | Runs by itself; Super Admin can re-run |
Four phases, in dependency order. No dates — several items depend on decisions and data that are not ours to schedule. The ordering itself is what we are asking you to approve.
Everything shared. Built once and never rebuilt, which is why the full employee record is designed now rather than grown as each module arrives.
The hardest module and the reason for the rebuild. Everything except the final salary import can be built before those figures arrive.
You told us three things: no spreadsheet is kept, the old files stop being used, and payroll can never be late. Together those mean the first live run has nothing to check against — and the engineer building payroll deliberately cannot read real salaries, so he can prove the engine is consistent but never that it is right for a real person.
Reads the foundation and writes almost nothing. The first thing all 32 people will actually use, and the phase where the platform stops being invisible to the company.
The database is already in place from phase one. This phase is the screens and the scheduled jobs.
All twenty-four items came back answered, and Anwar has since read the plan and found five more. Seven of your answers changed the design; four of Anwar's did. This section is the audit trail — what moved, and the three places where following an instruction exactly produced a result worth seeing before it happens.
The category still carries the usual answer, so the ordinary case stays automatic and consistent. Finance overrides it on one claim when they approve, and an override records its reason. We did not move the decision off the category entirely, because then there is no rule to show an auditor and two identical expenses could be treated differently with nothing on file. Figure 4.
The largest change in this edition, and it came out of your answer about the Indonesian and Malaysian companies. A person's scheme is now worked out from which company employs them, and is never typed. Figure 2 and the note beneath it.
Your two worked examples pin the rule exactly, including the ordering. Five days carry, filled from off-in-lieu first; leftover off-in-lieu burns; leftover annual leave encashes up to ten days. Cutoff moved to 19 December so Finance is not computing from data still arriving. Figure 6.
Both halves built: the override with a reason and a notification to you, and the leave rule you described enforced so the situation stays rare. Section 03, payroll.
Built as a notification that carries no figures, with the payslip itself staying behind the same access rules as everything else and a download button for when someone needs the PDF for a bank or a landlord. The one thing we did not do is attach it — several hundred copies a year of everyone's pay, sitting in inboxes permanently, for a document almost nobody opens twice.
Not a public page. A link issued to a named client, expiring and revocable, showing name, job title, department and reporting line and nothing else. Optionally rooted at one team, so a client sees who they work with rather than your entire headcount. Your sign-in rule is untouched: staff sign in with Google, clients do not sign in at all.
You said five years and in perpetuity, which are opposite policies — unless the record is split. The employment record stays forever. Five years after someone's last day, the platform automatically destroys their identity documents, bank details, address, date of birth and emergency contacts. Historical org charts and old payroll runs stay intact; nothing worth stealing remains.
Anwar read the first edition against what has been built and came back with five gaps. Four were real. One was our drawing being wrong rather than the design.
Agreed, and built — as an upload that Finance confirms, rather than a live link to a Google Sheet that updates itself. The typing saved is identical. What the confirmation buys is that these are the numbers three tax authorities are filed on, and a document anyone with the link can edit could otherwise change in November what was filed in October, with nothing on file to say who or when. Figure 5.
It already is, and has been since the first line of database code. Our diagram drew it as free text, which was the error. Department is a real list you maintain: add one, rename one, retire one without losing the history of who was in it, no developer involved. The permission to do it already sits with Finance and HR. Figure 2, now drawn correctly.
A real gap, and an obvious one in hindsight. We required a reason when Finance changed an amount or a tax treatment, but not when someone was turned down outright — which is the one outcome the employee has to act on. Now required at both stages, carried in the notification, and extended to rejected leave applications, which had the identical hole. Figure 7.
This one needed a conversation, because most of what Anwar described already happens automatically — an Indonesian employed by the Singapore company comes out with no BPJS and no Indonesian tax without anyone touching a setting. He then named the five people who do contribute, which resolved the largest open item in the project as a side effect. Two changes followed: contributions and tax withholding became separate settings, since someone can be outside BPJS and still have tax withheld; and any individual can now be excepted from what their company implies, with a written reason. Nobody needs an exception today. Figure 2 and the note beneath it.
A real gap — salary was modelled as a flat monthly figure, so someone joining on the 13th would have been paid a full month. Andre joined on 13 July, so it is a live case rather than a hypothetical. Now on MOM's formula for an incomplete month, on the five-day week, for everyone. It brings one new piece of work with it: a public-holiday calendar per country, because the working-day count depends on it and Singapore, Indonesia and Malaysia do not share holidays. Figure 5.
Each of these follows directly from an answer you gave. None is a reason to change your mind — but each one is a number, and numbers are easier to look at now than in December.
Across 22 of the 31 people who hold any. The company carries 178.5 off-in-lieu days and nothing has ever removed one — Natalie holds 11.5, Luck and Ferrin ten each. These were earned by working a public holiday or a weekend, which is not the same as an unused grant lapsing. Everyone can still spend them between now and December, which is exactly why the announcement should go out in October rather than November.
Follows from "after 5 days are carry forward... the rest gets burned"We cross-checked your bands against the real career scales. 13 increases, 3 people gain a card, 16 unchanged. And Novicto — the one we flagged as an anomaly — turns out to be correct under the new rule: he is a scale 4 on $50, which is exactly what the band says. The flag was evidence the stored limits had drifted, not evidence he had been given something he should not have. This is a policy change with a cost, not a data cleanup.
Follows from "Scale 1-3 $25, 4-6 $50, 7-9 $75"Now an exact figure rather than an estimate: Anwar named the five Indonesians who contribute, and the workbook marks twenty-two. So seventeen people's take-home pay goes up, visibly, in the first month the new system runs — including the Finance holder who will be operating the payroll. That is the correct outcome for anyone employed by the Singapore company, but it is a large, visible change produced by a brand-new engine, which is the single strongest argument for the shadow month in section 07.
Follows from "a few employees under the Indo entity paying pensions, the rest are not"You wrote: "This card limit needs to be changed across all employees. Change benefits to follow Scale instead. Scale 1-3 $25, 4-6 $50, 7-9 $75."
We read that as being about card limits throughout — $25 and $50 are literally the card values in use today, and $25 a year of medical cover would be a cut from the current $250. So the bands went to the cards, and the benefit caps were left alone.
Anwar has confirmed this reading: the scale bands apply to card limits only, and benefits stay flat. The one place where we had not followed your words as written is now closed, and it turned out we had read you correctly.
Nothing here is a design decision — those are all made. This list is shorter than it was in the first edition, and for a better reason than usual: the two items we asked you for have been answered, and the two that carried the most risk turned out not to need a person at all.
Benefit caps. Confirmed flat at $150 medical and $100 flexi; the scale bands apply to card limits only. We had read you correctly.
Backup retention: 35 days. This is what makes the five-year destruction rule real rather than decorative — deleting a bank account number from the live system does nothing about the copy in a backup. Backups exist to recover from disaster, not to keep records, and the five-year obligation is met by the live system. Thirty-five days covers a full payroll cycle plus a week, so we can still recover from something discovered at the following month's close. Backups are encrypted, and access to them is logged.
The first edition carried one hard deadline: HR had to rename PM to PML in the tracker before the export was taken, or parental leave would silently turn into annual leave and quietly eat people's balances.
Looking at it properly, PM was never being renamed. It keeps its meaning — Annual Leave, Afternoon, half a day, deducts from the balance. PML is a new code for parental leave. A blanket rename would have converted every afternoon half-day anyone has ever taken into non-deductible parental leave, handing back days people had already spent. That is the original problem pointing the other way, and it is what “just rename it, it's the same” produces.
The two are mechanically distinguishable: parental leave is a full day, an afternoon is half a day, and HR confirms they have never been logged otherwise. So the split happens in code at import, and nothing has to happen in the tracker at all. The export can be taken whenever it suits. The import refuses to run and names the rows if it finds a PM row that is neither half a day nor a full one — the rule rests on a recollection, and a recollection that turns out to be wrong should stop the import rather than quietly mis-file someone's leave.
This project no longer has a hard deadline.
The item that used to head this list — which company employs each of the 32 — was answered as a side effect of Anwar naming the five who contribute to BPJS, since BPJS requires an Indonesian employer. Worth HR confirming in their own words, but nothing waits on it.
Every record says "passport". That is true only for Miguel. You confirmed HR will collect IC and passports — this is the same job. The numbers themselves are already on file.
Blocks nothing yet; needed before payroll validates anythingHR already prorates for joiners, using a rule that has never been written down anywhere — we reverse-engineered it from the three people who joined this year and it reproduces all three exactly (Batoro 10.5 days, Carina 8, Andre 6). The equivalent rule for someone leaving has no precedent, because nobody has left since the tracker began, so we have inferred it rather than read it. A sentence from HR confirming it is worth more than our arithmetic.
Blocks nothing; needed before the first leaverYou decided leavers are settled rather than keeping a whole year's entitlement, which is now built. The edge case: someone who takes three weeks in January and resigns in February can owe more than their final pay packet. We propose recovering only up to their final net and writing off the rest, rather than invoicing a former employee for the balance — it costs more to chase than it recovers. Proposed and not objected to, but never explicitly agreed, so it is here rather than assumed.
Blocks nothing; needed before the first leaverBoth were implied by your answers rather than stated, and one of them arrives earlier than the original plan assumed. With the entity question answered and the tracker deadline gone, these are the only outstanding items that actually hold anything up.
Not a monthly history — one position per person as at go-live, but broken into the lines the year-end form asks for rather than a single figure. A lump sum cannot be split back apart, and the platform has to produce IR8A for a year it only partly computed. A Super Admin signs the set off and it locks when the first form is generated.
Concretely, four figures per person per entity for tax year 2026: gross salary paid to date, directors' fees to date, taxable allowances to date, employee contributions to date. Separately — the single most common way this goes wrong is Finance sending one total, which then has to be sent back. This can wait until close to the year end; it should not reach November unstarted.
Blocks year-end forms and year-to-date figuresThe shadow month in section 07 needs real figures — invented ones are precisely what it exists to get past. So the salary import moves ahead of go-live rather than sitting at the end of phase two. Still loaded directly into the live system by Finance or a Super Admin, and still never passing through development.
Development proceeds in the meantime on invented salaries, which is deliberate rather than a workaround: it means every access rule protecting compensation is exercised for real by the person writing the code, because he genuinely cannot read the table. But invented figures must never be loaded into the live system, even temporarily. Salary is a dated series rather than a value that gets overwritten — payroll for March reads whichever figure was in effect in March. A placeholder dated January would stay in effect for January and February, and every payslip, contribution and tax line for those months would compute on it. The live system starts with no salaries at all, and Finance's entry is the first one. After that, a raise is a new dated row and old payslips still reproduce exactly.
Blocks the shadow monthDeferring the appreciation app is right — nobody has specified it yet. But two things about it are worth knowing before it is picked up, because both are easy now and awkward later.
Redeemed credits are taxable pay, not an expense claim. Nobody spent anything — it is a reward, so in Singapore it is employment income: CPF-able and on the year-end form. If a redemption arrives as an ordinary claim it will default to "reimbursement" and quietly escape both. The override in Figure 4 handles it, but somebody has to set it deliberately.
Earned credits appear never to expire. That is the same shape as off-in-lieu — a balance that only ever grows, and becomes an obligation the day someone leaves. You have just decided off-in-lieu burns. It is worth deciding the same question for credits before the balance is three years deep rather than after.
Forty decisions now, each recorded in full alongside the code with its reasoning and what it costs us. The last nineteen came out of your answers and the questions that followed. Listed here so nothing is a surprise later — and so you can reverse any of them now, while reversing them is cheap.
| # | Decision | Why it matters |
|---|---|---|
| 1 | Salaries are contracted in SGD | Removes the need to collect 27 local salary figures that do not exist |
| 2 | Two exchange rates per payroll month | Filings stay reproducible; the real cost of currency movement becomes visible |
| 3 | Service leave accrues 1 January; 2026 grandfathered | Nobody loses a day mid-year |
| 4 | Claims go to Finance, then a Super Admin | Managers do not see their team's spending; spending stays a finance control |
| 5 | Nobody approves their own claim, at either stage | Enforced by the database, not by the screen |
| 6 | One reporting line, with rare recorded exceptions | The org chart and the approval queue can never disagree |
| 7 | The CEO reports to nobody | The chart has a top; his own leave routes to the COO by exception |
| 8 | Being a manager is read from the reporting line | Not something granted and later forgotten about |
| 9 | Arul and Agus hold the same Finance and HR access | Matches how the two of them actually work |
| 10 | Career framework is a table; job titles are read from it | Andre's and Neesha's mis-typed titles become impossible |
| 11 | Everyone sits on the framework, including engineers | Closes the flag saying four people had no promotion path |
| 12 | Identity documents encrypted, masked, and audited on opening | A database backup no longer exposes 32 bank accounts |
| 13 | Login status stored; employment status read from the dates | Someone who has left can still hand over |
| 14 | Benefit caps do not pro-rate, and are editable | Simple to explain; not buried in the code |
| 15 | Claims submitted in the receipt's currency | The claim matches the receipt under audit |
| 16 | Card limits derived from career scale, never typed | Limits stop drifting after a promotion |
| 17 | "No filing obligation" is a real setting | Miguel's and Claire's payroll does not stall every month |
| 18 | Payroll built against invented salary data | Real pay data never needs to leave Finance |
| 19 | Build order unchanged: foundation, payroll, org chart, leave | Payroll remains the priority it was always meant to be |
| 20 | The whole company works the Singapore holiday calendar | One calendar to maintain; off-in-lieu covers the gap elsewhere |
| 21 | Finance or a Super Admin loads salaries straight into production | The figures never pass through development at any point |
| From your answers | ||
| 22 | Contribution scheme derived from the legal entity, never stored | Stops ~20 people having BPJS deducted who should have nothing |
| 23 | Category sets the tax treatment; Finance may override one claim, with a reason | The rule stays showable to an auditor; the exceptions justify themselves |
| 24 | Sick 14, hospitalisation 60 inclusive, whole from day one, all 32 | Matches the contract and the Singapore rule; no proration engine needed |
| 25 | Leave year closes 19 December as one AL and off-in-lieu pool | The balance is final when payroll computes it, so encashment can be paid in December |
| 26 | Anything moving after the cutoff rolls into January | The closed year never reopens, and no special case is needed for cancellations |
| 27 | Part-time entitlements prorate; days already earned do not | Corrects Stella's 14 days to 7 without clawing back what she worked for |
| 28 | Yashwin announces the leave changes in October | People can still spend off-in-lieu before it burns |
| 29 | Payroll submitter cannot approve their own run without a recorded reason | The two-person check becomes a fact rather than a courtesy |
| 30 | Leave module blocks overlapping absence across the four approvers | Makes the rule you described something the system guarantees |
| 31 | Client org chart is a revocable expiring link over four fields | No public door into a database holding salaries and bank accounts |
| 32 | Payroll history arrives as attested opening balances, not monthly runs | Year-end forms work for 2026 without inventing seven months of detail |
| 33 | Directors' fees run through payroll; dividends never do | No engine has to be taught to ignore money that is not pay |
| 34 | Identity data destroyed five years after the last day; record kept | Meets the obligation without breaking historical charts and payroll |
| 35 | Office and equipment are two uncapped categories on the ordinary path | Asset data exists from day one if it is ever wanted |
| 36 | Email notifies a payslip; it never carries one | Pay figures stop accumulating in inboxes nobody controls |
| 37 | One shadow payroll month before the switch | The only place the engine can be checked against a known-good answer |
| 38 | A claim carries a claimed amount and an approved amount | An over-cap claim is part-paid instead of bounced, and the receipt still matches the claim |
| 39 | Only Finance sets the approved amount; the second stage cannot re-price | Both signatures are always on the same figure |
| 40 | Approved amounts are SGD; the claimed amount keeps the receipt's currency | Caps are spent exactly, and the claim still matches the document |