KPI Media · Internal · For sign-off

People Platform: how we build it

The database structure, what each role can do with it, and the order we build in — rewritten after working through the consolidated employee schema workbook.

32 people 6 countries 4 modules 4 access levels 21 decisions taken 7 decisions needed 17 questions open

What we are asking you to approve

  1. The database structure — sections 03 and 04. Once payroll is running on it, changing the shape of these tables is expensive.
  2. Who can see what — section 05. In particular: managers do not see their team's pay or their team's claims, and four people in the company can open a bank account number.
  3. The build order — section 07. Foundation, then payroll, then org chart, then leave. Most of the company sees nothing for several months, and that is deliberate.
  4. The seven decisions in section 08 — these unblock work that is otherwise ready to start.

Approval is not a commitment to dates. It is a commitment to this shape, so that we can start building the parts nothing else depends on changing. You can reply to section 08 as a list — "approved, except 3 and 5" is a complete answer.

Section 09 is separate and not urgent. It collects seventeen questions about cutover, running the system and what happens to the people using it. None of them block starting; all of them block launching, and most have a recommended default you can simply agree to.

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.

%%{init: {"er": {"entityPadding": 18, "minEntityWidth": 170, "diagramPadding": 22, "fontSize": 13}}}%%
erDiagram
  CAREER_FRAMEWORK ||--o{ EMPLOYEE : "gives the job title to"
  EMPLOYEE ||--o{ EMPLOYEE : "supervises"
  EMPLOYEE ||--o| EMPLOYEE_PERSONAL : "has one"
  EMPLOYEE ||--o| EMPLOYEE_COMPENSATION : "has one"
  EMPLOYEE ||--o| COMPANY_CARD : "may hold"

  EMPLOYEE {
    uuid id PK
    text employee_code UK
    text full_name
    text work_email UK
    text phone_number "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 statutory_scheme
    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.
Read it as  supervisor_id points back at the same table — that one 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 two 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, and when they are next up for promotion. All three are read from the career framework using the scale and track. That is why the framework arrow points at the employee and not away from it — and it is why Andre's and Neesha's mis-typed titles in the workbook become impossible rather than merely corrected.

Access

Being able to log in and being employed are two different facts, so they are two different tables. Someone who has left can keep read access through a handover; someone who has joined but not been set up yet exists in the directory with no account at all.

%%{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.

%%{init: {"er": {"entityPadding": 18, "minEntityWidth": 175, "diagramPadding": 22, "fontSize": 13}}}%%
erDiagram
  EMPLOYEE ||--o{ CLAIM : "submits"
  CLAIM_CATEGORY ||--o{ CLAIM : "sets the tax 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
    numeric default_cap_sgd
  }
  CLAIM {
    uuid id PK
    uuid employee_id FK
    uuid category_id FK
    numeric amount
    text currency
    text receipt_url
    text state
  }
  CLAIM_APPROVAL {
    uuid id PK
    uuid claim_id FK
    text stage
    uuid approver_id FK
    timestamptz decided_at
  }
Figure 4  HRMS, claims.
Read it as  amount and currency hold the receipt exactly as issued; the SGD value is worked out later at the settling month's rate. 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.

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.

%%{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"

  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 finalised_by FK
  }
  PAYROLL_LINE {
    uuid id PK
    uuid run_id FK
    uuid employee_id FK
    numeric base_pay_sgd
    numeric claims_sgd
    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.
Read it as  salary_history.effective_from is what lets any past month be re-run at the figure that applied then. payroll_run.state moves draft → generated → submitted → finalised, and only a Super Admin can write the last step.
Who can read it  Finance and the Super Admins. Each person sees their own payslip, which is one payroll_line.

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
    text pro_rate_rule
    int carry_forward_cap_days
    date carry_forward_expires
    int sick_days_cap
    int hospitalisation_cap
  }
  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. expires_on is the open question in section 08.2.
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.

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 three main flows work

These are the three places where the system decides something rather than merely records it, so they are the three worth checking most carefully.

Claims

%%{init: {"flowchart": {"padding": 16, "nodeSpacing": 55, "rankSpacing": 52, "useMaxWidth": true}}}%%
flowchart TB
  A["Employee submits
in the receipt currency"] --> B{"Within
benefit cap?"} B -- no --> X["Rejected on the spot"] B -- yes --> C["Finance approves
Arul or Agus"] C --> D["Super Admin approves
Yashwin or Anwar"] D --> E["Pulled into the
payroll month"] E --> F{"Category
treatment"} F -- reimbursement --> G["Paid on top of net
no tax, no contributions"] F -- "taxable allowance" --> H["Added to gross
before contributions"]
Figure 7  A claim never goes to the claimant's manager — spending is a finance control here, not a line-management one. Nobody can approve their own claim at either stage, enforced in the database rather than hidden in the screen. Whether a claim is taxed depends on its category, not on who submitted it.

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.

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.

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 10  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 11  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 12  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 13  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
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. Finance prepares a payroll run and cannot release it; a Super Admin releases it and does not prepare it. Every claim needs two signatures and they must be 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 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.

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.
  • 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, and the reporting line at the top of the company, which currently points back at itself.
  • 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.
  • ClaimsExpense categories carrying their own tax treatment, so a genuine reimbursement and an allowance that is really pay are handled differently and correctly.
  • PayrollContribution engines for Singapore, Malaysia and Indonesia, plus the countries where we have no filing obligation — which is a configured answer, not a gap.
  • PayrollRun states: drafted and generated by Finance, finalised by a Super Admin. Dated salary history so any past month can be re-run at the figure that applied then.
  • PayrollSettlements: the local amount and the rate the rail actually charged, recorded after each transfer.
  • ReportingBudget against actual staff cost on one screen. Payslips. Year-end tax forms.
  • ImportReal salaries, loaded directly into the live system by Finance or a Super Admin.
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.

08Decisions we need from you

Seven items only you can settle. They are ordered by how much they hold up: the first three change what we build, the last four are corrections to the data we are about to import.

  1. 01

    The parental leave code must be renamed before we take the final export hard deadline

    Parental leave and afternoon half-day annual leave currently share the code PM. One draws down the annual allowance and one does not. If we migrate as-is, some parental leave silently becomes annual leave and quietly eats people's balances — and it will not be obvious until someone runs out of days they thought they had.

    What we need: HR to rename parental leave to PML in the tracker before we take the export. This is the only item on this page with a deadline attached, and it is the only one that silently corrupts data if it slips.

  2. 02

    Does off-in-lieu expire? shapes the leave tables

    Today it never does. Natalie holds 11.5 days and Miguel 8, and the balance grows every January when the holiday-gap award is posted. Nothing in the tracker removes it, so it is an obligation that only ever increases — and on the day someone leaves, it is payable.

    What we need: either an expiry window — twelve months is usual — or a decision to accept it as a permanent liability, in which case Finance should be booking it. This is field award_ledger.expires_on in Figure 6; we can build either, but not neither.

  3. 03

    Sick and hospitalisation limits outside Singapore

    All 32 people are currently set to Singapore's statutory 14 and 60 days, including the 22 in Jakarta. Indonesia and Malaysia differ, and Malaysia's varies with length of service. The tracker applies no cap at all, so nobody has ever hit one.

    What we need: the correct figures for Indonesia and Malaysia, and a decision on whether sick leave is capped in practice at all. If the honest answer is "we do not cap it", that is a valid answer and simpler to build.

  4. 04

    How is the leave accrual change communicated?

    Service leave now accrues on 1 January instead of on each person's work anniversary. We are grandfathering 2026 so nobody loses a day mid-year, and the new rule takes effect on 1 January 2027. Thirteen people will have one day fewer than the old rule would have given them.

    What we need: agreement that this is communicated before January, and by whom. Taking a day back without notice is the kind of thing people notice.

  5. 05

    Identity document types need re-collecting

    Every one of the 32 records says the identity document is a passport. That is only true for Miguel, who has no Philippine equivalent to a national ID card. Payroll cannot validate an Indonesian KTP or a Singapore NRIC if all of them are labelled passport.

    What we need: HR to re-collect the document type for the other 31. The numbers themselves are already on file and do not need re-collecting.

  6. 06

    Arul's carry-forward is 6 days against a cap of 5

    Everybody else is at or below the cap.

    What we need: confirm it as an agreed exception, or correct it before we import. Whichever you choose, the system will hold the cap afterwards.

  7. 07

    Novicto's card limit is 50 at a scale where everyone else is 25

    Card limits otherwise follow career scale exactly. Lisa's is still at her old scale's limit seven weeks after her June promotion, which is what happens when a figure is typed rather than derived — we are fixing that by deriving it.

    What we need: is Novicto's 50 deliberate, or was it never corrected? A deliberate exception is fine; we will record it as one rather than let it look like a mistake.

Answered, and needing nothing further from you

09Questions we have not asked yet

Section 08 is about what we build. This section is about everything around it — cutting over, running it, and what happens to the people using it. None of these block starting. All of them block launching, and they are much cheaper to answer now than in the week before go-live.

They are grouped by who we think should answer, but that is a suggestion rather than an assignment. Several have a sensible default and we have said what it is — for those, silence is a workable answer and we will proceed on the default.

Cutover — getting from the spreadsheets to this

The riskiest month of the whole project is the first one where the new system is the real one.

  1. Which month is the first live payroll?

    Not a date we are asking you to commit to — a preference. January is the cleanest cut, because the leave year, the benefit cycle and the tax year all start together and nothing has to be carried mid-stream. Any other month means importing a part-year position for all 32 people.

    Answered by  Yashwin, with Finance
  2. Do we run the new system and the spreadsheet side by side for a month?

    Our recommendation is yes, for exactly one cycle: run both, compare every line, pay from the spreadsheet. It costs Finance one duplicated month and it is the only way to catch a contribution error before it reaches somebody's bank account. If the answer is no, say so now, because it changes how much we invest in reporting.

    Answered by  Arul and Agus
  3. Do we bring past payroll months across, or start clean?

    Starting clean is far cheaper and means the old records stay where they are. It also means the system cannot reproduce a payslip from before go-live, and cannot show year-to-date figures in the first year. That matters most for year-end tax forms in the first cycle.

    Answered by  Finance
  4. What happens to the current tracker and the workbook on the day we go live?

    If they stay editable, they will be edited, and within a month two sources will disagree. Our recommendation is that both go read-only on cutover day and are archived somewhere only Finance can reach. The workbook in particular holds every national ID, passport number and bank account in plain text, which is a good reason to retire it deliberately rather than let it drift.

    Answered by  Yashwin

Running it once it exists

The build ends. The system does not.

  1. Who covers payroll when Arul and Agus are both away?

    Today, nobody. Payroll can only be generated by that role, and it is two people. A month-end that falls across both their holidays has no path through.

    Answered by  Yashwin
  2. Should the claims approver pool grow beyond four?

    Two people per stage, with self-approval blocked, means a claim from any one of the four leaves exactly one eligible approver. One absence stalls a stage. We are not recommending a change — the control is worth the friction — but you should decide it rather than discover it.

    Answered by  Yashwin and Anwar
  3. Who owns the system after it is built?

    One engineer is building all four modules. If that person is unavailable, there is currently no second person who knows how payroll computes a CPF contribution. This is worth naming now, whether the answer is a second engineer, a documented handover, or an accepted risk.

    Answered by  Yashwin
  4. What is acceptable downtime on a payday?

    This sets how much we spend on backups, monitoring and recovery. "Payroll must never be late" and "an afternoon is survivable" lead to noticeably different amounts of work, and it is cheaper to decide before we build than to retrofit.

    Answered by  Yashwin, with Finance

The people using it

Thirty-two people are about to have their leave balance and their payslip move somewhere new.

  1. When is the company told, and by whom?

    Most of them see nothing until phase three, then get an org chart and a leave system in fairly quick succession. There is also the accrual change in section 08.4 to communicate before January. One message covering both is better than two.

    Answered by  Yashwin, with HR
  2. How do payslips reach people?

    In-app only is simplest and leaves the least trail. Emailed PDFs are what people are used to, and mean pay figures leave the system and sit in inboxes indefinitely. We would recommend in-app, with download.

    Answered by  Finance
  3. Is the org chart ever shown outside the company?

    If clients or partners might see it, it needs a public view with contact details and personal information stripped out. That is a small amount of work if we know now, and an awkward retrofit if we find out later.

    Answered by  Yashwin
  4. Does everything need to work well on a phone?

    Twenty-two of the thirty-two are in Jakarta, and requesting leave and submitting a receipt are both things people do away from a desk. We would build those two flows phone-first and leave payroll desktop-only. Worth confirming that split is right.

    Answered by  Yashwin

Records and obligations

We would rather build the retention rules in than add them after the data has accumulated.

  1. How long do we keep a leaver's records, and what do we delete?

    Payroll records generally have to be kept for years. Home addresses, emergency contacts and identity documents generally do not, once someone has gone. Without a rule, we keep everything forever by default, which is the least defensible position.

    Answered by  Yashwin, with Finance
  2. How far back must we be able to reproduce a payslip or a filing?

    The design already supports reproducing any month exactly, because the declared rate and the dated salary are both stored. The question is how many years of it we are required to keep available across Singapore, Malaysia and Indonesia.

    Answered by  Finance
  3. Who reads the audit log, and how often?

    Every opening of a bank account number is recorded. A log nobody ever reads is only useful after an incident. A quarterly glance by someone who is not Finance turns it into an actual control.

    Answered by  Yashwin and Anwar

Scope — what this project is not

Worth confirming explicitly, because each of these is a reasonable thing to assume is included.

  1. Confirm these are out of scope: recruitment, performance reviews, timesheets, attendance, asset tracking, and document signing.

    None are in the four phases. The database is built so any of them could be added later as a fifth module without disturbing what is already running — but if one of them is actually expected at launch, that changes the plan materially and we should know now rather than in phase three.

    Answered by  Yashwin
  2. Does signing in use the existing Google Workspace accounts?

    Work addresses are already @kpimedia.sg. Signing in with Google means no new password for anyone, and closing someone's Google account closes their access to this system at the same moment — which is one fewer thing to forget during an offboarding.

    Answered by  Yashwin

10Already decided

Twenty-one decisions taken so far, each recorded in full alongside the code with its reasoning and what it costs us. 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 follow career scale; unlimited stated outrightLimits 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