Systems & Platforms
PeopleOS
HR, attendance and statutory payroll at a ~300-employee manufacturer
2026Production
At a glance
Credit
- Built by
- Denandro YusufArchitecture · Domain and rule design · Implementation · Statutory rule interpretation · Release decision · Directing Claude Code against written specs · Review of every later change
- Further development
- Prada Dipa (LinkedIn, opens in a new tab)
Run the demo synthetic data
- Problem
- HR lived in a 291-column workbook, paper forms and a macro-driven payroll spreadsheet.
- What I did
- I built one platform: an append-only attendance ledger and versioned payroll rule engines.
- Outcome
- One auditable system of record, live since August 2026. Phased rollout toward a ~300-employee target.
Explore
The component graph, from the source repository. Follow the arrows: left to right, and down where two steps share a column. The burgundy bars are where decisions are made; the numbers on the lines are the key flows, written out under the graph.
Key flows
- PWA client to Next.js 16 App Router: server actions
- Next.js 16 App Router to Permission resolver: resolve effective permissions
- Permission resolver to Domain modules: permission + scope
- Domain modules to Pure rule engines: buckets → priced results
- Domain modules to Field encryption: encrypt / mask
- Domain modules to PostgreSQL: reads, writes, audit events
- Field encryption to PostgreSQL: ciphertext columns
- Domain modules to Payslips · bank file: generate
- Legacy workbooks to CLI: import/reconcile: workbook parsing
- CLI: import/reconcile to PostgreSQL: import / reconcile / verify
- Next.js 16 App Router to Cloud Run + Cloud SQL: container
- Cloud Run + Cloud SQL to PostgreSQL: Cloud SQL socket
Go deeper
The full account
- Problem
- HR ran on a 291-column master workbook, a legacy attendance app, paper leave forms and a macro-driven payroll spreadsheet. Nobody could answer who reports to whom, on what contract, since when — and no month's payroll could be proved to have come from a specific set of rules.
- What was built
- A full HR platform, at the August 2026 cutover: 17 domain modules over a 102-model Postgres schema, an append-only attendance ledger, and separately versioned rule engines that price statutory payroll in exact decimal arithmetic.
- Role
- Built hands-on, directing Claude Code against my own written specifications, and acted as de-facto product owner: domain model, rule interpretation with Accounting and HR, release process, and production ownership. Since September Prada Dipa, an engineer on my team, has helped maintain and develop it under my direction, and every change needs my review.
- What changed
- Replaced spreadsheets and paper with one auditable system of record, live on managed cloud infrastructure since the August 2026 cutover. The rollout is phased: ~300 is the company's headcount and the platform's design target, not the number of people on the system today.
Built hands-on, directing Claude Code against written specifications while owning the architecture, the rule interpretation, the safety regime and the release decision.
Context
The company is an Indonesian apparel manufacturer of roughly 300 staff — Bali timezone, IDR, Bahasa Indonesia primary with an English fallback. HR operations lived across a master-data workbook, chat threads, paper forms and a payroll spreadsheet whose macros nobody wanted to touch.
Two forces made the spreadsheet stack untenable. Indonesian statutory payroll — PPh 21, BPJS contributions, overtime multipliers and THR, plus the company's own 78-hour monthly overtime threshold — was computed by hand every month with no record of which rule produced which payslip. And Indonesia's personal data law (UU 27/2022) raised the bar for demonstrable access control over employee data, which a shared workbook fundamentally cannot provide.
The brief was not 'build an HR app'. It was: make each month's payroll reproducible, defensible, and impossible to quietly alter after the fact.
Architecture and the system
A Next.js 16 App Router monolith. At the August 2026 cutover every mutation was a server action rather than a REST endpoint: 108 page routes, and the only route handlers were read-only downloads. Four authenticated endpoints write today besides the server actions: one for screening results and three for HR reminders. Authorization resolves before any module runs; pure rule engines sit beneath the modules and never touch I/O.
Attendance as an event ledger, not a timesheet
Attendance is stored append-only. Punches, corrections and voids are events; nothing is ever edited in place. A correction is a void-and-replace pair with its own audit record, so a dispute six months later can be reconstructed exactly as it happened.
A pure day engine turns that ledger into priced minute buckets: events plus schedule plus leave plus rules in, buckets out. No I/O, no clock, no database — which is what makes it testable and what makes a payroll run reproducible.
Geofencing validates punches against branch coordinates using browser geolocation, with a Leaflet-based branch editor for the operations team.
Payroll as versioned rule engines
Base and overtime pay, BPJS contributions, a PPh 21 TER-style band table, THR and leave cash-out each live in their own separately versioned engine. A payslip records which rule version produced it.
Every money value moves through decimal.js fixed-point arithmetic. Floating point has no place in a system that has to agree with a bank file to the rupiah.
Each run passes a three-phase hashed sign-off — HR, then Accounting, then Director — before it is finalized and becomes immutable; then it produces payslips as HTML and React-PDF — and a checksummed bank transfer file, generated by byte-carefully patching the bank's macro-preserving XLSM template, built to remove the re-keying step between payroll and the bank.
Access control that can be demonstrated
A permission catalog (158 codes at the August 2026 cutover, 161 since v10.1.0) is resolved per request as access-level bundle plus per-user grants minus restrictions, then narrowed by scope — all, team, or self — with field-level masking on top.
Restricted fields — national ID, tax ID, bank accounts, social-security numbers — are encrypted at the application layer with AES-256-GCM and versioned ciphertext, so a database dump is not a personal-data breach.
Migration without a leap of faith
The legacy 291-column workbook was not thrown away; it was kept as the reconciliation reference. CLI tooling imports historical payroll, diffs environments, and cross-checks computed results against the workbook baseline.
A spreadsheet-style master-data workspace with staged edits, Excel-style fill and paste, atomic validation and org-chart cycle detection gives HR the ergonomics they already knew, with a full export → edit → import round trip that previews, applies transactionally, and rolls back.
What was hard
Making a payroll run provable
It is not enough to compute the right number. You have to be able to show, months later, which rule version produced it, from which attendance events, and prove nobody edited it afterwards. That requirement drove three decisions at once: the append-only ledger, the versioned engines, and immutability on finalized runs.
Generating a bank file in the bank's own template
The bank's bulk-transfer format is a macro-bearing XLSM template. Regenerating it from scratch breaks the macros; the file has to be patched byte-carefully so the workbook stays valid and checksummed. It is built to remove the hand-keying step that sits between a finalized payroll run and actual money moving.
Statutory rules that nobody will confirm in writing
Tax and contribution rates are documented in the repo as UNVERIFIED pending confirmation with Accounting, with a standing production-gaps register tracking what remains. Encoding uncertainty as a tracked, visible gap rather than a confident constant is the honest engineering choice, and it is how the rule versions earn their keep.
Server actions instead of an API surface
At the August 2026 cutover there were 108 page routes, and every route handler was a read-only GET for a file or an export. Every mutation went through a server action that authorizes, scopes, validates and audits before it touches a module, so there was no second, weaker path into the data, which is the point when the data is salaries and national IDs. The four endpoints that write today are authenticated: a token for the screening integration, the signed-in session for HR reminders.
AI and automation
AI
No AI runs in attendance or payroll, and that is deliberate: nothing probabilistic belongs between an attendance event and a payslip. The only model calls are in recruitment, where screening ranks, scores and recommends, and a person decides. AI was also the delivery method: large releases ran a documented multi-agent workflow of parallel read-only audit agents, a verifying integrator, parallel build lanes, then a fresh set of adversarial reviewers whose blockers had to be cleared before the release was recorded.
Automation
Attendance punches become priced minute buckets automatically; overtime derives from actual attendance with no request step; BPJS, PPh 21 and THR, plus the company's own 78-hour monthly overtime threshold, are computed by versioned engines; and the bank transfer file is generated directly from the finalized run instead of being re-typed into the bank's converter workbook. Operationally, CLI scripts automate legacy import, cross-environment reconciliation, production integrity checks and clean-state verification — and a boot-time hook flags a production database contaminated with demo records.
Direction and delivery
Releases are numbered in the changelog and recorded in a version table inside the app, which the release pipeline writes itself; through v10.0.5 (4 Sep 2026) each numbered release also had a git tag matching its container image. Since September 2026 a release pipeline carries every merged change as one immutable image through staging and smoke tests. Changes to payroll, leave, shared calculations, the database layer, access control or the recruitment AI wait at staging until I promote that exact image, and an integrity compare after every production release rolls it back automatically if anything protected moved. A push that carries a migration releases nothing, so schema changes go in separately, ahead of the code; the four migrations of v10.1.0 were first rehearsed on a restored copy of production before it went live on 3 Oct 2026. The cutover-era release entries state the management request each answers, what was deliberately NOT changed — 'the calculation-owning files and prisma/ are byte-identical to the previous version, verified by diff' — and the gates it passed. A written, permanent non-destructive deployment invariant governs what may never be done to production.
Size signals
- Domain modules
- 17
- Prisma models
- 102
- Page routes
- 108
- Permission codes
- 158
- Migrations
- 29
- Numbered releases
- 48
Counted from the source repository at release v9.30, 27 August 2026. No impact metric is claimed that the source does not prove.
What I would tell the next person
- Purity is a delivery strategy, not an aesthetic. The rule engines take no I/O and no clock, which is the only reason a month's payroll can be re-run and compared.
- Write down what you did not change. Release notes that name the untouched, diff-verified files buy more trust from a finance team than any test count.
- Encode unresolved questions as tracked gaps with owners. A confident constant that nobody verified is worse than a flagged one.
Technologies and access
- TypeScript
- Next.js 16
- React 19
- PostgreSQL
- Prisma 6
- decimal.js
- Zod 4
- AES-256-GCM
- argon2
- React-PDF
- exceljs
- Leaflet
- Tailwind CSS 4
- Vitest
- Playwright
- Docker
- GitHub Actions
- GCP Cloud Run
- Cloud SQL
Integrations
- Bulk-transfer file in the bank's own macro-enabled XLSM template
- Legacy payroll & master-data workbooks
- Browser Geolocation + Leaflet geofencing
- GCP Cloud Run / Cloud SQL / Artifact Registry
Internal, login-gated system holding real employee data. No production screens are published.

