KPI Media · Internal · For sign-off

People Platform: how we build it

Second edition, rewritten after your answers. All twenty-four items you sent back have been worked through — seven of them changed the design, one of them changed it a great deal. Everything below reflects that.

32 people 3 legal entities 4 modules 4 access levels 40 decisions taken 0 decisions outstanding 2 answers still needed

What changed, and what we need now

Your twenty-four answers are all in. Nothing is waiting on a decision any more — the design is settled and the build can proceed. What we need from you now is much smaller than last time.

  1. Two answers, in section 09. One is a correction we think you meant; one is a policy nobody has set.
  2. Two things from HR and two from Finance — also section 09. These are data, not decisions, and the build stalls without them.
  3. A sanity check on section 08 — what your answers changed, and the three places where following them exactly produced a result you may not have pictured.

Sections 03 to 07 are the same document you approved in shape, updated where your answers moved it. The largest single change is in section 03: statutory contributions turn out not to follow the country someone works in, and roughly twenty people are currently set up wrongly because of it.

01What the workbook changed

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.

3

Blocking flags closed

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.

32/32

Promotion timing already derivable

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.

13

People affected by the leave change

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.

02One system, four parts

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
Figure 1  An arrow means “needs”. Everything needs Core. Nothing needs a sibling, with one deliberate exception: unpaid leave has to reach payroll as a salary deduction.

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.

03The data model

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.

How to read the next five diagrams

││
exactly one
│○
none, or one
│⋖
one, or many
○⋖
none, one, or many

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.

The person

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"
  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
  }
  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
    text department
    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
  }
Figure 2  Core, the person.
One supervisor, many reports  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.
Read it as  That single field is the entire org chart, and the database refuses any value that would create a loop. work_email is the @kpimedia.sg address and doubles as the sign-in identity. The three fields marked new are additions to the workbook.
Who can read it  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.

Contributions follow the entity, not the country — and about twenty people are set up wrongly today

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 roughly twenty people who should have nothing deducted, every month, from the first live payroll.

So legal_entity becomes a field on the employee, the scheme is worked out from it rather than typed, and HR needs to tell us which company employs each of the thirty-two. That is the single most important thing outstanding on this document.

Access

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
  }
Figure 3  Core, access.
Read it as  A role grants nothing by itself — it is a named bundle of permissions, and the permission is what the system actually checks. 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.
Note what is missing  Being someone's manager is not in this diagram at all. It is read from 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.

Why the override table should stay almost empty

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.

Claims

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 state
  }
  CLAIM_APPROVAL {
    uuid id PK
    uuid claim_id FK
    text stage
    uuid approver_id FK
    timestamptz decided_at
  }
Figure 4  HRMS, claims.
The change you asked for  The category still carries the usual answer, so the common case is automatic and consistent. Finance can override it on one claim when they approve — and an override has to say why. That way an auditor can be shown a rule, and the exceptions justify themselves rather than looking like inconsistency.
Two amounts, in two currencies  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.
Why the asymmetry  A benefit cap is an SGD entitlement, so it has to be spent in SGD exactly or the balance drifts. The receipt is evidence, so it has to stay in the currency it was issued in. Those pull in different directions, and the record holds both rather than picking one. stage is either finance or final, and the database refuses an approver_id that equals the claim's own employee_id.
Who can read it  Your own claims, always. Everyone else's: Finance and the Super Admins only — managers included.

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.

Payroll

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
  }
  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 directors_fee_sgd "new"
    numeric claims_sgd
    numeric leave_encashment_sgd "new"
    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
  }
Figure 5  HRMS, payroll.
The two rates  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.
Year-to-date, and the year we did not run  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.
Read it as  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.
Who can read it  Finance and the Super Admins. Each person sees their own payslip, which is one payroll_line.

Directors' fees go through payroll. Dividends do not.

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.

Anwar can approve a run he prepared — but not quietly

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.

Leave

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
  }
Figure 6  Leave.
Read it as  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.
Your answers are the policy row  Everything you decided about leave — the five-day carry-forward, the ten-day encashment cap, the 19 December cutoff, 14 and 60 for sickness — lives in leave_policy as data, not buried in code. Changing a figure later is an edit, not a rebuild.
Note what is missing  There is no balance column anywhere. A balance is entitlement plus the award ledger minus approved requests, worked out at the moment it is shown. 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.

What your year-end leave rule does, in your own numbers

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.

Balances are calculated, never stored

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.

The tables, and who can read them

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

04How the main flows work

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.

Claims

%%{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 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"]
Figure 7  Claims.
Finance decides two things  how much, and how it is taxed. The category supplies the usual tax answer, so most claims need no thought; an override records why. A claim over the benefit cap is no longer bounced — see the note below.
Read it as  A claim never goes to the claimant's manager: spending is a finance control here, not a line-management one. Nobody approves their own claim at either stage, enforced in the database rather than hidden in the screen. The second approver signs the exact figure Finance set, or sends it back — they never re-price it, so both signatures are always on the same number.

Claiming $130 against $70 of cap left

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.

Payroll

%%{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
Figure 8  Two exchange rates doing two different jobs. The rate Finance declares ahead of the run computes every contribution and every filing, so reprinting a past month reproduces the original figures exactly. The rate the payment rail charges is recorded afterwards and computes nothing. The gap between them is real money the company absorbs, and the system will show it rather than hide it.

Leave

%%{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"]
Figure 9  Leave is consumed in a strict order: carry-forward first, then off-in-lieu, then the annual entitlement. After 31 March any unused carry-forward is gone. This is the existing tracker's behaviour, reproduced against all 31 rows with no variance — we are encoding it, not changing it.

The year-end close

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"]
Figure 10  The year-end close.
Checked against your own examples  Three off-in-lieu and five annual leave: carry three plus two, encash the remaining three. Ten and twelve: carry five off-in-lieu, burn five, encash ten, burn two. Both reproduce exactly.
Why off-in-lieu goes first  Because only annual leave can be encashed, filling the bucket with off-in-lieu leaves more annual leave to pay out. In your second example that ordering is worth three extra days of cash to the person.
After the cutoff  A cancelled booking, or off-in-lieu earned over Christmas, lands in January as a fresh day in the new year. The closed year never reopens.

05What each person can do

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.

These stack. They are not tiers you move between

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.

Employee

all 32 people · the level everyone starts at
%%{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"]
Figure 11  The employee's path. Everything here is either their own data or data the whole company already shares.
Cannot

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.

Manager

13 people · not granted — read from the reporting line

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"]
Figure 12  The manager's path. Their authority covers time, not money.
Cannot

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 & HR

2 people · Arul and Agus · one role, not two

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"]
Figure 13  The Finance and HR path. Note where it stops: this role prepares payroll and cannot release it.
Cannot

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.

Super Admin

2 people · Yashwin and Anwar

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"]
Figure 14  The Super Admin path. Short by design — this role signs, configures and oversees; it does not do the daily work, though nothing is hidden from it.
Cannot

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.

The same thing, as a table

Can they… Employee Manager Finance & HR Super Admin
See the directory and org chart
See personal details — address, date of birthownown
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 leaveown teamown teamown team
See leave balancesown+ team
Grant off-in-lieuown team
Generate a payroll run
Finalise a payroll run
Finalise a run you generated yourselfwith a reason
Issue a client org-chart link
Change the framework, policy or caps
Grant or revoke someone's access
Read the audit logown actions

Super Admin sees everything — so the controls are on acting, not on seeing

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.

06What each feature reads and writes

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

07The build, in order

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.

Phase one · underway

Foundation

Everything shared. Built once and never rebuilt, which is why the full employee record is designed now rather than grown as each module arrives.

  • DatabaseThe employee record as drawn in Figure 2: separate "how they are engaged" from "how many hours", add career scale and track, add phone number, add Sri Lanka to the country list, and refuse any reporting line that forms a loop.
  • DatabaseLegal entity table, and the entity on each employee — the change described in Figure 2. The contribution scheme is worked out from it rather than stored, so it can never be typed wrongly again.
  • DatabasePersonal details on their own table, including a personal email address so we can still reach someone after their @kpimedia.sg account is closed. Identity documents and bank details encrypted.
  • DatabaseCareer framework table, holding all nine scales across five tracks with their job titles and promotion intervals.
  • DatabaseAccounts, roles and permissions as drawn in Figure 3, plus approver overrides, company cards and the audit log.
  • AccessGrant Arul and Agus the same Finance and HR access. Add a distinct permission for final claim approval so it is a real second stage rather than a side effect of being an admin.
  • DatabaseThe whole leave structure from Figure 6 — policy, leave types, holiday calendars, award ledger, entitlements, requests. Tables only; the screens come in phase four.
  • ImportAll 32 people: identity, reporting lines, career placement, personal details, leave opening balances. Salary is not part of this import.
  • ImportCorrect at the point of import: Neesha's career track, Andre's job title, Stella's leave entitlement, Arul's carry-forward, and the reporting line at the top of the company — which turns out to be a two-person loop, not a self-reference. Yashwin reports to Anwar and Anwar reports to Yashwin, so all 32 people have a manager and the tree has no top at all. A check for "nobody is their own manager" sails straight past this; the check has to look for loops of any length.
  • DevelopmentInvented salary figures for local development, covering every country and every contribution scheme, so the payroll engine can be built and tested without real pay data ever leaving Finance.
Phase two

Payroll and claims

needs the salary figures

The hardest module and the reason for the rebuild. Everything except the final salary import can be built before those figures arrive.

  • RatesMonthly exchange rate register per country, plus CPF, EPF/SOCSO and BPJS rates, tax brackets and Indonesian relief bands, each dated. A missing rate stops the run and names what is missing.
  • ClaimsSubmit in the receipt's currency, checked against the benefit cap, approved by Finance and then a Super Admin, never by the claimant. Receipt upload ships here, not later with the OCR — Finance needs the evidence at the moment they approve, and the stored file is what makes OCR possible in phase two.
  • ClaimsCategories carry the usual tax treatment; Finance sets or overrides it on the individual claim when they approve, with a reason recorded on any override. Plus the two new uncapped categories, office and equipment.
  • PayrollContribution engines driven by the legal entity: CPF, EPF/SOCSO, BPJS, and no-filing-obligation — which is a configured answer, not a gap, and now covers far more people than we thought.
  • PayrollRun states: drafted and generated by Finance, finalised by a Super Admin. The submitter cannot approve their own run without a written reason that notifies the other Super Admin. Dated salary history so any past month can be re-run at the figure that applied then.
  • PayrollDirectors' fees as their own line, taxed but not CPF-able. Dividends recorded outside the engine entirely.
  • PayrollSettlements: the local amount and the rate the rail actually charged, recorded after each transfer.
  • ReportingBudget against actual staff cost on one screen. Payslips — in-app with a download button; email says one is ready and carries no figures. Year-to-date on every payslip. Year-end tax forms per entity: IR8A, Form EA, 1721-A1.
  • ImportReal salaries, loaded directly into the live system by Finance or a Super Admin. Earlier than first planned — the shadow month below needs them.
  • Import2026 opening balances per person per entity, broken into the lines the year-end form asks for, signed off by a Super Admin and locked once the first form is generated.
Before go-live

One shadow payroll month

new, from your answers

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.

  • VerifyThe month before go-live, Finance runs payroll their normal way and the platform computes the same month. Nobody is paid from the platform. Every line is compared, every difference explained, then the switch happens. One duplicated month, once — not the ongoing parallel running you ruled out.
  • VerifyThe highest-value thing it checks is Indonesia. Roughly twenty people are about to stop having BPJS deducted. That is a large, visible change to take-home pay for two thirds of the company, produced by a brand-new engine, in its first live month. This is where we find out if it is right.
Phase three

Org chart

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.

  • ScreensThe reporting tree, with team sizes counted live rather than typed, and search across name, department, office and country.
  • ScreensA profile page per person: contact card, reporting line, and their place on the career framework. Job titles and levels read from the framework, so a promotion updates the chart with no re-typing.
  • ScreensTeam view for managers — who reports to me, who is out this week, whose probation ends soon.
  • ScreensHR editing of the reporting line, with the loop check enforced and every change written to the audit log. This is the one thing in this phase that writes.
  • ReportingHeadcount by department, office, country and engagement type — the figures currently assembled by hand.
  • AlertsPassport expiry, probation end dates, and work anniversaries.
Phase four

Leave

The database is already in place from phase one. This phase is the screens and the scheduled jobs.

  • ScreensRequest and approve leave, with balances calculated live through the carry-forward, off-in-lieu and entitlement order.
  • ScreensAward off-in-lieu for working a holiday, a weekend or a birthday, and the annual bulk holiday-gap award.
  • Jobs1 January: post service awards, post the holiday-gap awards, and open the new leave year. 31 March: expire unused carry-forward.
  • JobsThe year-end company closure booked automatically, so it stops being confused with personal leave.
  • PayrollUnpaid leave feeding through to payroll as a salary deduction — the thing the spreadsheet has never been able to do.

08What your answers changed

All twenty-four items came back answered. Seven of them changed the design; the rest confirmed it or corrected data. This section is the audit trail — what moved, and the three places where following your instruction exactly produced a result worth seeing before it happens.

The seven that changed the design

  1. Finance decides taxable-or-reimbursement at approval

    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.

  2. Contributions follow the entity, not the country

    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.

  3. Annual leave and off-in-lieu close as one pool

    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.

  4. Anwar can approve a run he prepared — visibly

    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.

  5. Payslips by email, payslips in the app

    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.

  6. A client-facing org chart

    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.

  7. Records kept, identity data destroyed

    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.

Three results you may not have pictured

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.

  1. About 57 days of off-in-lieu burn at the first year-end

    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"
  2. The card limit bands raise 13 people and give 3 a card who have none

    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"
  3. Roughly 20 people stop having BPJS deducted

    Their take-home pay goes up, visibly, in the first month the new system runs. That is the correct outcome if they are 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"

One place we did the opposite of what you wrote

Benefit caps stay flat at $150 medical and $100 flexi

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.

That may be exactly what you meant. But it is the one place we have not followed your words as written, so please confirm rather than let it pass by silence. If you did want benefits on a scale, we need three numbers for medical and three for flexi.

Answered, needing nothing further

  • LeaveParental leave renamed to PML. Sick leave 14 days and hospitalisation 60 — the 60 inclusive of the 14, per Singapore's rule and your contract — granted whole from day one, applied to all 32 wherever they sit. New accrual rule adopted. Arul's sixth carry-forward day burned.
  • CutoverBackdated to January 2026. No spreadsheet kept. Old tracker retired. Past payslips stay in email; the platform stores only what it generates.
  • ApprovalsClaims stay two stages, Finance then Super Admin, pool of four. Confirmed rather than changed.
  • AccessGoogle sign-in, @kpimedia.sg only, no exceptions. Shehan already holds an account, so the one contractor is covered.
  • OwnershipRafli builds and owns it; Andre is the second engineer. Andre joined in July and is already in the data.
  • TimingPay on the 25th, Finance starts the 20th — which is what sets the claim cut-off and the leave cutoff. No hard deadline for go-live.
  • MobileClaims and leave built phone-first. Org chart and payroll desktop-first.
  • AuditSuper Admins read the log when something needs investigating.
  • ScopeRecruitment, performance reviews, timesheets, attendance, asset tracking and document signing all out. Performance reviews noted as a possible fifth module later.
  • Phase twoAppreciation app and receipt OCR deferred by decision. Receipt upload moved into phase one — Finance needs the evidence when they approve.

09What we still need

Nothing here is a design decision — those are all made. Two are answers only you can give; the rest is data that other people hold. The build proceeds without them right up to the point where it cannot.

From you — two things

Both are short. Neither blocks work starting this week.

  1. Confirm the benefit caps stay flat

    As set out at the end of section 08. We think you meant the scale bands for cards only. A one-line "yes, cards only" closes it; anything else means we need six numbers.

    Needed before  the import
  2. How long are database backups kept?

    Nobody has set this, and it decides whether the five-year destruction rule is real. Wiping a bank account number from the live system does nothing about the copy in a backup taken last month. If backups outlive five years, the deletion policy is decoration. Our recommendation is backups retained well under that window, which is normal practice anyway.

    Needed before  go-live, not before the build

From HR — three things, one with a deadline

These are facts nobody has recorded yet. The first one blocks the payroll engine.

  1. Which company employs each of the 32?

    Singapore, Indonesia or Malaysia, per person. Every row in the workbook currently says KPI Media Pte Ltd, which we now know is wrong. Without this the contribution engine cannot be built correctly for anyone — it is the single most important item on this page.

    Blocks  phase two
  2. Rename PM to PML in the tracker — before the export

    Parental leave and afternoon half-day annual leave share the code PM today. One draws down the allowance and one does not. If we export before the rename, some parental leave silently becomes annual leave and quietly eats people's balances — and nobody notices until someone runs out of days they thought they had. This is the only item in the whole document with a hard deadline, and the only one that corrupts data if it slips.

    Hard deadline  before the tracker export
  3. Identity document type for 31 of 32

    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 anything

From Finance — two things

Both were implied by your answers rather than stated, and one of them arrives earlier than the original plan assumed.

  1. 2026 opening balances, per person, per entity

    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.

    Blocks  year-end forms and year-to-date figures
  2. Real salaries, earlier than planned

    The 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.

    Blocks  the shadow month

One thing we would raise even though you did not ask

Deferring 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.

10Already decided

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.

#DecisionWhy it matters
1Salaries are contracted in SGDRemoves the need to collect 27 local salary figures that do not exist
2Two exchange rates per payroll monthFilings stay reproducible; the real cost of currency movement becomes visible
3Service leave accrues 1 January; 2026 grandfatheredNobody loses a day mid-year
4Claims go to Finance, then a Super AdminManagers do not see their team's spending; spending stays a finance control
5Nobody approves their own claim, at either stageEnforced by the database, not by the screen
6One reporting line, with rare recorded exceptionsThe org chart and the approval queue can never disagree
7The CEO reports to nobodyThe chart has a top; his own leave routes to the COO by exception
8Being a manager is read from the reporting lineNot something granted and later forgotten about
9Arul and Agus hold the same Finance and HR accessMatches how the two of them actually work
10Career framework is a table; job titles are read from itAndre's and Neesha's mis-typed titles become impossible
11Everyone sits on the framework, including engineersCloses the flag saying four people had no promotion path
12Identity documents encrypted, masked, and audited on openingA database backup no longer exposes 32 bank accounts
13Login status stored; employment status read from the datesSomeone who has left can still hand over
14Benefit caps do not pro-rate, and are editableSimple to explain; not buried in the code
15Claims submitted in the receipt's currencyThe claim matches the receipt under audit
16Card limits derived from career scale, never typedLimits stop drifting after a promotion
17"No filing obligation" is a real settingMiguel's and Claire's payroll does not stall every month
18Payroll built against invented salary dataReal pay data never needs to leave Finance
19Build order unchanged: foundation, payroll, org chart, leavePayroll remains the priority it was always meant to be
20The whole company works the Singapore holiday calendarOne calendar to maintain; off-in-lieu covers the gap elsewhere
21Finance or a Super Admin loads salaries straight into productionThe figures never pass through development at any point
From your answers
22Contribution scheme derived from the legal entity, never storedStops ~20 people having BPJS deducted who should have nothing
23Category sets the tax treatment; Finance may override one claim, with a reasonThe rule stays showable to an auditor; the exceptions justify themselves
24Sick 14, hospitalisation 60 inclusive, whole from day one, all 32Matches the contract and the Singapore rule; no proration engine needed
25Leave year closes 19 December as one AL and off-in-lieu poolThe balance is final when payroll computes it, so encashment can be paid in December
26Anything moving after the cutoff rolls into JanuaryThe closed year never reopens, and no special case is needed for cancellations
27Part-time entitlements prorate; days already earned do notCorrects Stella's 14 days to 7 without clawing back what she worked for
28Yashwin announces the leave changes in OctoberPeople can still spend off-in-lieu before it burns
29Payroll submitter cannot approve their own run without a recorded reasonThe two-person check becomes a fact rather than a courtesy
30Leave module blocks overlapping absence across the four approversMakes the rule you described something the system guarantees
31Client org chart is a revocable expiring link over four fieldsNo public door into a database holding salaries and bank accounts
32Payroll history arrives as attested opening balances, not monthly runsYear-end forms work for 2026 without inventing seven months of detail
33Directors' fees run through payroll; dividends never doNo engine has to be taught to ignore money that is not pay
34Identity data destroyed five years after the last day; record keptMeets the obligation without breaking historical charts and payroll
35Office and equipment are two uncapped categories on the ordinary pathAsset data exists from day one if it is ever wanted
36Email notifies a payslip; it never carries onePay figures stop accumulating in inboxes nobody controls
37One shadow payroll month before the switchThe only place the engine can be checked against a known-good answer
38A claim carries a claimed amount and an approved amountAn over-cap claim is part-paid instead of bounced, and the receipt still matches the claim
39Only Finance sets the approved amount; the second stage cannot re-priceBoth signatures are always on the same figure
40Approved amounts are SGD; the claimed amount keeps the receipt's currencyCaps are spent exactly, and the claim still matches the document