01/Systems & Platforms

PeopleOS

HR, attendance and statutory payroll for ~300 people

Year
2026
Status
Production
Category
Systems & Platforms
Stack
TypeScript · Next.js 16 · React 19

01/Context

The client 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, the 78-hour monthly overtime threshold, THR — 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.

02/At a glance

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.
Built
A full HR platform: 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
Sole engineer and de-facto product owner. Domain model, rule interpretation with Accounting and HR, release process, and production ownership.
Changed
Replaced spreadsheets and paper with one auditable system of record, live on managed cloud infrastructure since the August 2026 cutover.

03/Architecture

A Next.js 16 App Router monolith where every mutation is a server action rather than a REST endpoint — 105 page routes and exactly one HTTP route. Authorization resolves before any module runs; pure rule engines sit beneath the modules and never touch I/O.

PeopleOS architecture diagramData flows through 12 components: PWA client (Interface), Next.js 16 App Router (Service), CLI: import/reconcile (Ingest), Legacy workbooks (Source), Permission resolver (Decision logic), Cloud Run + Cloud SQL (External), 17 domain modules (Decision logic), Pure rule engines (Decision logic), Field encryption (Decision logic), Document store (Store), Payslips · bank file (Output), PostgreSQL (Store). Connections: PWA client to Next.js 16 App Router; Next.js 16 App Router to Permission resolver; Permission resolver to 17 domain modules; 17 domain modules to Pure rule engines; 17 domain modules to Field encryption; 17 domain modules to PostgreSQL; Field encryption to PostgreSQL; 17 domain modules to Document store; 17 domain modules to Payslips · bank file; Legacy workbooks to CLI: import/reconcile; CLI: import/reconcile to PostgreSQL; Next.js 16 App Router to Cloud Run + Cloud SQL; Cloud Run + Cloud SQL to PostgreSQL.PWA clientAdmin / Manager / Employee …Next.js 16 App RouterRSC + 35 server-action modu…CLI: import/reconcileIntegrity checks, environme…Legacy workbooks291-column master data, tax…Permission resolver158 codes · level + grants …Cloud Run + Cloud SQLNon-destructive, code-only …17 domain modulesAuthorize, scope and audit …Pure rule enginesAttendance day · overtime ·…Field encryptionAES-256-GCM, versioned ciph…Document storeMagic-byte validated, versi…Payslips · bank fileReact-PDF · exceljs · MCM2 …PostgreSQL102 models · 28 migrations …

04/The system

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.

A finalized run is immutable. It passes an eight-stage approval workflow, then produces payslips as HTML and React-PDF — and a checksummed Mandiri MCM2 bank transfer file, generated by byte-carefully patching the bank's macro-preserving XLSM template so the re-keying step between payroll and the bank disappears.

Access control that can be demonstrated

A 158-code permission catalog 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.

05/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 the bank accepts

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. Getting this right removed a hand-keying step that sat 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

105 page routes and one HTTP route. Every mutation goes through a server action that authorizes, scopes, validates and audits before it touches a module. There is no second, weaker path into the data — which is the entire point when the data is 300 people's salaries and national IDs.

06/AI

No AI runs in the product, and that is deliberate — nothing probabilistic belongs between an attendance event and a payslip. AI was the delivery method, not a feature: 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.

07/Automation

Attendance punches become priced minute buckets automatically; overtime derives from actual attendance with no request step; the 78-hour threshold reallocation, BPJS, PPh 21 and THR 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.

08/Direction & delivery

32 numbered releases, each with a git tag matching the container image tag and the in-app version constant, recorded in the changelog, in a deployment history document, and in a version table surfaced inside the app. Each release entry states the management request it 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.

09/Scale

17
Domain modules
102
Prisma models
105
Page routes
158
Permission codes
23
Migrations
32
Numbered releases

Objective size signals taken from the repository. No impact metrics are claimed that the source does not prove.

10/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.

11/Technologies

  • 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
  • GCP Cloud Run
  • Cloud SQL

Integrations

  • Mandiri MCM2 bulk-transfer bank file
  • 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.