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.
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.
The schema workbook was read end to end — 95 columns, 10 tabs, all 32 people. It is an unusually good document, and working through it closed three of its own blocking flags and opened one question nobody had asked.
Two dissolved on inspection. The salary-currency flag rested on an assumption that turned out to be wrong; the career-framework flag had already been solved by the Engineer track without the summary tabs being updated.
Years-to-next-promotion is reproducible from career scale for every single person, with no exceptions. So it does not need to be stored, and cannot drift. Seniority label matched 30 of 32 — both misses were typing errors.
The move to 1 January accrual takes a day back from thirteen people. We are grandfathering 2026 so nobody loses a day they have already been shown, and running the new rule from 1 January 2027.
The one question the workbook does not ask: every salary figure in the company is SGD, and no local-currency figure exists in any source. All five Singapore staff are paid by bank transfer; all twenty-seven others through Wise into a local account. That is the signature of an SGD contract settled abroad, not of local-currency pay.
If that is right — and we have taken it as right — then salaries are contracted in SGD, and the flag asking us to collect twenty-seven local salary figures before payroll can start is asking for something that does not exist. It changes the shape of the compensation table and it removes a blocker.
The platform is one application and one database. Inside it, the work is split into four parts, and the split is not cosmetic — it is a rule about who is allowed to depend on whom.
%%{init: {"flowchart": {"padding": 16, "nodeSpacing": 58, "rankSpacing": 62, "useMaxWidth": true}}}%%
flowchart TB
HRMS["HRMS
payroll, claims, budget
used by 2 people daily"]
ORG["ORG CHART
reporting tree, directory
used by all 32"]
LEAVE["LEAVE
balances, requests, awards
used by all 32"]
CORE["CORE
the one record of each person — name, contact,
reporting line, pay, login, permissions
built once, never rebuilt"]
HRMS -->|"reads people from"| CORE
ORG -->|"reads people from"| CORE
LEAVE -->|"reads people from"| CORE
LEAVE -.->|"unpaid leave only"| HRMS
Three consequences follow from that picture, and they are the reason it is worth drawing:
The dotted arrow is the one place this rule bends. Unpaid leave reduces someone's pay, so Leave has to tell payroll about it. We are building that as a one-directional handover — Leave reports days, payroll decides money — rather than letting the two modules read each other freely.
The workbook describes one employee record with 95 columns. We are splitting it into tables. The main reason is access: “only Finance may see this” becomes one rule on one table, rather than a column-by-column rule that every screen has to remember and any screen can forget.
Each box is a table. Each line is a real link between them, labelled in plain words — read it in the direction of the label. The lines inside each box are the fields that table holds; PK marks the field that identifies the row, FK marks a field that points at another table.
One employee record, with the sensitive parts lifted out into their own tables. The two links to account and company_card are “none or one” on purpose: not everyone has a login, and not everyone holds a card.
%%{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
}
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.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.
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
}
account.status is one of invited, active, suspended or closed. role.code is super_admin, finance_hr or employee; permission.code is a capability such as payroll.approve.supervisor_id in Figure 2 at the moment of the check, so it can never be granted to the wrong person or left behind after a reorganisation.
Approval normally follows the reporting line exactly, with nothing recorded anywhere. approver_override exists for the genuine exceptions — chiefly that the CEO reports to nobody, so his own leave has to reach someone. If that table starts filling up, it is a signal the reporting line has stopped being true, and the fix is the org chart rather than more overrides.
Four tables. The one to look at is claim_approval — approvals are rows, not a column on the claim, which is what lets the system hold two signatures and check they came from two different people.
%%{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
}
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.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
}
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.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.payroll_line.
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
}
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.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.
The current tracker stores some balances and calculates others, and one figure the whole post-March calculation depends on is typed in by hand. That is why balances drift. In the new system every balance — days remaining, off-in-lieu held, benefit spent, direct reports, next review cycle — is worked out from the underlying records each time it is shown. It cannot fall out of step, because there is nothing to fall out of step with.
| Table | Holds | Readable by |
|---|---|---|
| Core — shared by every module | ||
| employee | Name, code, work email, phone number, country, office, department, career scale and track, who they report to, hire date, engagement type, hours. | Everyone |
| employee_personal | Personal email, date of birth, nationality, marital status, home address, emergency contact, identity document type and passport expiry. | HR and Finance |
| employee_compensation | Monthly salary in SGD, bank details, statutory scheme, CPF residency, benefit cap overrides. National ID, passport number and bank account number are encrypted and shown as last-4 unless opened deliberately, which writes an audit entry. | Finance + Super Admin |
| account | Login, and whether it is invited, active, suspended or closed. Kept separate from employment status so someone who has left can still hand over. | Admins |
| role / permission | Named capabilities and the bundles that grant them. Being someone's manager is not in here — that is read from the reporting line. | Admins |
| career_framework | Scale 1–9 across five tracks, each giving a job title and a promotion interval. Versioned, so renaming a rung is a change to the framework and moves nobody. | Everyone |
| approver_override | The rare exceptions where a request should not go to the usual person — how the CEO's own leave reaches someone. Expected to stay nearly empty. | HR |
| company_card | Who holds a card and its monthly limit, which follows their career scale. Unlimited is stated outright rather than left blank. | Finance |
| audit_log | Every change to pay, bank details and leave balances, and every time an identity document is opened. | Admins |
| HRMS — payroll and claims | ||
| salary_history | Salary with the date it took effect. Payroll can re-run any past month at the figure that applied then. | Finance + Super Admin |
| statutory_rate | The SGD-to-local rate Finance sets for each country each month, used to compute every contribution and filing. | Finance |
| statutory_config tax_bracket · ptkp |
CPF, EPF/SOCSO and BPJS rates, tax brackets and Indonesian relief bands, each with the date it takes effect. A missing rate stops the run rather than guessing. | Finance |
| claim claim_approval claim_category |
What was spent, in the currency of the receipt, with each approval stage recorded separately, and the category that decides whether it is taxed. | Claimant, Finance |
| payroll_run payroll_line |
One run per month, one line per person: base pay, claims pulled in, unpaid leave deducted, every contribution, net pay and total staff cost. | Finance + Super Admin |
| settlement | What actually left the company — the local amount, the rail, and the rate the rail charged. Written after the transfer, never before. | Finance |
| budget | Planned monthly staff cost in SGD, set ahead and compared against what payroll actually produced. | Finance |
| Leave — built now, screens later | ||
| leave_policy | Base days, how mid-year joiners are pro-rated, the carry-forward cap and its expiry, sick and hospitalisation limits. Held once, not copied onto every person. | Everyone |
| leave_type | The fourteen leave codes and whether each one draws down the allowance. | Everyone |
| holiday_calendar | Public holidays per country. Singapore's set is what everybody works to; the others exist only to calculate the annual off-in-lieu award. | Everyone |
| award_ledger | Every day granted beyond the base entitlement — service awards, off-in-lieu, the holiday-gap award — with who granted it and why. Balances are read from this, never stored. | Everyone |
| leave_entitlement | One row per person per leave year: base days, months pro-rated, carry-forward brought in and when it expires. | Everyone |
| leave_request | Leave taken and booked, with its approval. | Self, manager, HR |
These are the three places where the system decides something rather than merely records it, so they are the three worth checking most carefully.
%%{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"]
The consequence worth stating out loud: the approver pool is four people. Two at each stage, and self-approval is blocked, so a claim submitted by one of the four leaves exactly one person able to sign it. One absence stalls a stage. That is the price of the control, and it is the reason the pool should grow before the company does.
%%{init: {"flowchart": {"padding": 16, "nodeSpacing": 55, "rankSpacing": 52, "useMaxWidth": true}}}%%
flowchart TB
R["Finance sets the month's
SGD to local rate per country"] --> S["Generate the run"]
S --> T["For each person: salary in SGD
+ approved claims
- unpaid leave"]
T --> U["Convert to local at the
month's declared rate"]
U --> V["Compute contributions
CPF · EPF/SOCSO · BPJS · none"]
V --> W["Finance submits"]
W --> Y["Super Admin finalises"]
Y --> Z["Money sent"]
Z --> AA["Record what the rail
actually charged"]
M["A missing rate stops the run
and names what is missing"] -.-> S
%%{init: {"flowchart": {"padding": 16, "nodeSpacing": 55, "rankSpacing": 52, "useMaxWidth": true}}}%%
flowchart TB
P["Employee requests leave"] --> Q{"Does this type draw
down the allowance?"}
Q -- "no — sick, birthday,
parental, unpaid" --> R2["Recorded, allowance untouched"]
Q -- "yes — annual, half days" --> S2["Their manager approves"]
S2 --> T2["Balance recalculated in order"]
T2 --> U2["1 — carry forward
expires 31 March"]
U2 --> V2["2 — off-in-lieu"]
V2 --> W2["3 — base + service award"]
R2 --> X2{"Unpaid?"}
X2 -- yes --> Y2["Deduction handed to payroll"]
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.
%%{init: {"flowchart": {"padding": 16, "nodeSpacing": 40, "rankSpacing": 70, "useMaxWidth": true}}}%%
flowchart LR
L["Sign in"] --> H["Home"]
H --> D["Directory and org chart
everyone's contact card"]
H --> M["My record
view it; ask HR to change it"]
H --> C["Claims
submit, track, see the cap left"]
H --> V["My leave
balance, request, history"]
See anyone else's salary, bank details, claims, home address or date of birth. Edit their own record directly — changes go through HR, so every change has an author. Approve anything, including their own claim or leave.
This is the one level nobody is ever given. The system checks, at the moment of each request, whether you are the requester's supervisor. Someone who takes on a report gains it the same day; someone who loses their last report loses it the same day. There is no list to maintain and nothing to forget to revoke.
%%{init: {"flowchart": {"padding": 16, "nodeSpacing": 40, "rankSpacing": 70, "useMaxWidth": true}}}%%
flowchart LR
E["Everything an employee
can do"] --> A["Approvals inbox"]
A --> B{"Team member's
leave request"}
B -- approve --> C["Balance updates
team calendar updates"]
B -- reject --> D["Returned with a reason"]
E --> F["My team
who is out, balances,
probation dates"]
E --> G["Grant off-in-lieu
for a holiday or
weekend worked"]
See their team's pay. See their team's claims, or approve any of them — a manager is not a stage in the claims chain and is not shown what their team spends. Change a team member's record, their reporting line or their career scale. If cost-centre reporting is needed later, that is a report we build for Finance, not a manager permission.
Finance and HR are the same job at KPI Media, so they are one role rather than two overlapping ones. This is the level that works with pay daily — and along with the two Super Admins, the only level that can open a salary or a bank account number at all. Every one of those opens is written to the audit log with a name and a timestamp.
%%{init: {"flowchart": {"padding": 16, "nodeSpacing": 45, "rankSpacing": 55, "useMaxWidth": true}}}%%
flowchart TB
A["Sign in — Arul or Agus"] --> B["People admin
new starter, leaver, promotion,
reporting line, personal details"]
A --> C["Pay and bank details
opened daily; every open logged"]
A --> H["Claims queue
first approval"]
A --> D["Set the month's
declared exchange rates"]
D --> E["Generate the payroll run"]
E --> F["Review every line"]
F --> G["Submit for finalisation"]
G -.-> I["Waits for a Super Admin"]
H -.-> I
I --> J["Record each settlement
after the transfer clears"]
Finalise a payroll run — that is deliberately a second pair of eyes. Give final approval on a claim. Approve their own claim at the Finance stage either, which means a claim from Arul must be signed by Agus and vice versa. Change the career framework, the leave policy or anyone's access.
The final signature, and the only role that can change what the system means — what a scale-5 Manager is called, how many days the policy grants, what a benefit cap is. It carries every permission in the platform, including salary and bank details.
%%{init: {"flowchart": {"padding": 16, "nodeSpacing": 45, "rankSpacing": 55, "useMaxWidth": true}}}%%
flowchart TB
A["Sign in — Yashwin or Anwar"] --> B["Finalise the payroll run
no money moves before this"]
A --> C["Final approval on every claim"]
A --> D["Change what the system means
career framework · leave policy · benefit caps"]
A --> E["Grant and revoke access"]
A --> F["Read the audit log"]
A --> G["Pay and bank details
full access, same as Finance"]
Approve their own claim, at either stage, with no exception and no override. Sign a claim at the Finance stage and the final stage — the two signatures must always be two different people. Delete an audit entry, or a granted day from the award ledger; both are append-only, so even the highest level of access leaves a trail it cannot erase.
| Can they… | Employee | Manager | Finance & HR | Super Admin |
|---|---|---|---|---|
| See the directory and org chart | ✓ | ✓ | ✓ | ✓ |
| See personal details — address, date of birth | own | own | ✓ | ✓ |
| See salary and bank details | — | — | ✓ | ✓ |
| Change an employee record | — | — | ✓ | ✓ |
| Submit a claim | ✓ | ✓ | ✓ | ✓ |
| See someone else's claims | — | — | ✓ | ✓ |
| Approve a claim — first stage | — | — | ✓ | — |
| Approve a claim — final stage | — | — | — | ✓ |
| Request leave | ✓ | ✓ | ✓ | ✓ |
| Approve leave | — | own team | own team | own team |
| See leave balances | own | + team | ✓ | ✓ |
| Grant off-in-lieu | — | own team | ✓ | ✓ |
| Generate a payroll run | — | — | ✓ | — |
| Finalise a payroll run | — | — | — | ✓ |
| Change the framework, policy or caps | — | — | — | ✓ |
| Grant or revoke someone's access | — | — | — | ✓ |
| Read the audit log | — | — | own actions | ✓ |
A Super Admin can read every table in the platform, salary and bank details included. That is a deliberate choice: the two people holding it run the company, and a control that hides the payroll from the CEO would be theatre rather than security.
What the role is constrained on is signing. 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.
The same picture from the other side — every screen we build, and exactly which tables it touches. This is the checklist we build against.
| Feature | Reads | Writes | Who can use it |
|---|---|---|---|
| Staff directory | employee, career_framework | — | Everyone |
| Employee record | employee, employee_personal | employee, employee_personal, audit_log | Finance & HR |
| Pay and bank details | employee_compensation, salary_history | salary_history, audit_log | Finance & HR, Super Admin |
| Submit a claim | claim_category, claim, employee_compensation (cap only) | claim | Everyone |
| Approve a claim | claim, claim_approval, employee | claim_approval, audit_log | Finance, then Super Admin |
| Run payroll | salary_history, statutory_rate, statutory_config, claim, leave_request | payroll_run, payroll_line | Finance & HR |
| Finalise payroll | payroll_run, payroll_line | payroll_run, audit_log | Super Admin |
| Record a payment | payroll_line | settlement | Finance & HR |
| Budget vs actual | budget, payroll_line | budget | Finance & HR |
| Payslips and year-end forms | payroll_line, statutory_rate, employee | audit_log | Finance issues; each person reads their own |
| Org chart | employee, career_framework | — | Everyone |
| Request leave | leave_entitlement, award_ledger, leave_request, holiday_calendar, leave_policy | leave_request | Everyone |
| Approve leave | employee (reporting line), approver_override, leave_request | leave_request | The requester's manager |
| Grant off-in-lieu | holiday_calendar, employee | award_ledger | Managers, Finance & HR |
| Leave balances | leave_entitlement, award_ledger, leave_request | — | Self, manager, Finance & HR |
| Year-start and year-end jobs | employee, leave_policy, leave_entitlement | award_ledger, leave_entitlement | Runs by itself; Super Admin can re-run |
Four phases, in dependency order. No dates — several items depend on decisions and data that are not ours to schedule. The ordering itself is what we are asking you to approve.
Everything shared. Built once and never rebuilt, which is why the full employee record is designed now rather than grown as each module arrives.
The hardest module and the reason for the rebuild. Everything except the final salary import can be built before those figures arrive.
Reads the foundation and writes almost nothing. The first thing all 32 people will actually use, and the phase where the platform stops being invisible to the company.
The database is already in place from phase one. This phase is the screens and the scheduled jobs.
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.
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.
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.
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.
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.
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.
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.
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.
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.
The riskiest month of the whole project is the first one where the new system is the real one.
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 FinanceOur 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 AgusStarting 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 FinanceIf 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 YashwinThe build ends. The system does not.
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 YashwinTwo 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 AnwarOne 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 YashwinThis 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 FinanceThirty-two people are about to have their leave balance and their payslip move somewhere new.
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 HRIn-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 FinanceIf 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 YashwinTwenty-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 YashwinWe would rather build the retention rules in than add them after the data has accumulated.
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 FinanceThe 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 FinanceEvery 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 AnwarWorth confirming explicitly, because each of these is a reasonable thing to assume is included.
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 YashwinWork 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 YashwinTwenty-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.
| # | Decision | Why it matters |
|---|---|---|
| 1 | Salaries are contracted in SGD | Removes the need to collect 27 local salary figures that do not exist |
| 2 | Two exchange rates per payroll month | Filings stay reproducible; the real cost of currency movement becomes visible |
| 3 | Service leave accrues 1 January; 2026 grandfathered | Nobody loses a day mid-year |
| 4 | Claims go to Finance, then a Super Admin | Managers do not see their team's spending; spending stays a finance control |
| 5 | Nobody approves their own claim, at either stage | Enforced by the database, not by the screen |
| 6 | One reporting line, with rare recorded exceptions | The org chart and the approval queue can never disagree |
| 7 | The CEO reports to nobody | The chart has a top; his own leave routes to the COO by exception |
| 8 | Being a manager is read from the reporting line | Not something granted and later forgotten about |
| 9 | Arul and Agus hold the same Finance and HR access | Matches how the two of them actually work |
| 10 | Career framework is a table; job titles are read from it | Andre's and Neesha's mis-typed titles become impossible |
| 11 | Everyone sits on the framework, including engineers | Closes the flag saying four people had no promotion path |
| 12 | Identity documents encrypted, masked, and audited on opening | A database backup no longer exposes 32 bank accounts |
| 13 | Login status stored; employment status read from the dates | Someone who has left can still hand over |
| 14 | Benefit caps do not pro-rate, and are editable | Simple to explain; not buried in the code |
| 15 | Claims submitted in the receipt's currency | The claim matches the receipt under audit |
| 16 | Card limits follow career scale; unlimited stated outright | Limits stop drifting after a promotion |
| 17 | "No filing obligation" is a real setting | Miguel's and Claire's payroll does not stall every month |
| 18 | Payroll built against invented salary data | Real pay data never needs to leave Finance |
| 19 | Build order unchanged: foundation, payroll, org chart, leave | Payroll remains the priority it was always meant to be |
| 20 | The whole company works the Singapore holiday calendar | One calendar to maintain; off-in-lieu covers the gap elsewhere |
| 21 | Finance or a Super Admin loads salaries straight into production | The figures never pass through development at any point |