SaaSJune 23, 2026 · 9 min

Build-log: a multi-tenant, WPS-compliant payroll system

Payroll is software where being wrong is expensive and visible. Building Paylio — a multi-tenant, WPS-compliant payroll product — came down to three disciplines: strict tenant isolation, money handled as integers, and invoicing that's accurate per locale. Here's how those decisions played out.

The problem: correctness under regulation

Payroll has to be right to the cent, compliant with local wage-protection rules (WPS), and isolated per company — one tenant must never see another's salary data. There's no 'good enough' rounding when it's someone's wage. That framed every architectural choice.

Multi-tenancy: isolation in the data layer

Every company is a tenant. Isolation is enforced where it can't be bypassed — the database — with each row scoped to a tenant and access constrained by policy, tenant context set once per request. The application never reconstructs tenant scope by hand; that's how leaks happen in payroll systems.

Money is integers, never floats

Floating-point money is a bug waiting to happen — 0.1 + 0.2 is not 0.3 in a float. Store amounts as integer minor units (cents/fils) and only format to a decimal at display time. Every calculation — gross, deductions, net — runs on integers.

// amounts are integer minor units everywhere
const gross = 750000;            // 7,500.00
const deductions = 62500;        // 625.00
const net = gross - deductions;  // 687500 — exact, no float drift
const display = (net / 100).toLocaleString('en', { minimumFractionDigits: 2 });

Locale-accurate invoicing across markets

Operating across markets means currency symbols, decimal conventions, tax rules and document formats differ. The invoice layer is locale-aware so each market gets a correct, cent-accurate document — not an English template with a swapped symbol.

Rule that saved me repeatedly: never do money math at the presentation layer. Compute in integer minor units in the domain, format once at the very edge.

Reliability: webhooks and background work

Payment and disbursement events arrive as webhooks — verified, deduplicated, and handled idempotently — and anything slow (document generation, notifications) runs in a queue, not the request path. Payroll runs are batch-heavy; the queue keeps the app responsive during a run.

Key takeaways

  • In regulated domains, correctness drives the architecture — design for it first.
  • Enforce tenant isolation in the database; never trust the app to scope tenants.
  • Money is integer minor units end-to-end; format only at display.
  • Make invoicing locale-aware, not a translated template.

FAQ

How do you store money in a payroll system?

As integer minor units (cents/fils), never floats. All arithmetic runs on integers; you format to a decimal string only at display, per locale. This eliminates floating-point drift.

How do you keep tenants isolated in multi-tenant payroll?

Enforce isolation in the data layer — row-level security or strictly scoped queries with tenant context set once per request. The application code never reconstructs tenant scope manually.

AA
Ali Asghar

Senior software engineer & technical lead — 6+ years shipping production multi-tenant SaaS, payments and AI integration in Next.js, Node & TypeScript.

Keep reading
Multi-tenant SaaS: RLS vs schema-per-tenant vs database-per-tenantPayment webhooks the hard parts: idempotency, retries and reliability