Payroll: a run from draft to finalized
How Tomero turns employees and versioned statutory rules into an immutable monthly pay record
On the last Friday in May, Folake opens the Payroll tab of her Surulere pharmacy on her phone. The draft run for May is ready: five employees, each with a computed pay line. She reviews the lines, taps Finalize, and confirms. The run locks, and a payslip appears for every employee in it. The run is now a permanent record of what was computed, under which rules, by whom, and when.
Employees and compensation (employees, compensation_profiles) #
An employee is a payroll record: a person the business may pay. An employee is separate from a Team Member, a user who signs in to the app; many employees have zero app access.
The employee row carries identity facts only: a full name, a job title, an optional employee code, an employment status of active or inactive, and start and end dates. Pay lives in a second table. Each employee has one or more compensation profiles, and each profile carries:
effective_from, the date the profile takes forcepay_frequencyandbase_pay_type, monthly salary only at launchbase_pay_amount_minor, the base pay in kobo (money is stored as integer minor units)allowance_payload, named allowance amounts added on top of base paystatutory_flags, four switches that enable PAYE, pension, NHF, and NHIS for the employee
Profiles are effective-dated by start date only. The profile in force on any day is the one with the greatest effective_from on or before that day. A raise is one insert: a new profile with a later effective_from. Prior profiles stay untouched, so pay history stays replayable, and a unique (employee_id, effective_from) index is the only constraint the model needs.
An owner, or a team member granted manage_employees, adds and edits employees and records pay changes; view_employees covers reading them.
Employee writes on the wire
POST /employeescreates the employee and the initial compensation profile in one transaction.PATCH /employees/{employee_id}edits identity fields only: name, job title, employee code, status, dates. Pay fields sent here fail validation.POST /employees/{employee_id}/compensation-profilesrecords a pay change by inserting one forward-dated profile. The neweffective_frommust be later than the employee's latest existing one.- Employee create and compensation-profile create each require a UUID
Idempotency-Key; a replay with the same payload returns the original response.
The rule set and the engine (payroll_rule_sets) #
Statutory rules live in data, versioned per jurisdiction. A rule set carries a jurisdiction, a version label, an effective date range, and a rule_payload: the full JSON description of rates, bands, and bases. Engineering seeds rule sets. The launch seed is NG-NTA-2025 for Nigeria, effective 2026-01-01, sourced from the Nigeria Tax Act 2025.
Only the server computes pay. The engine reads the rule payload and computes gross to net per employee. Four employee-side deductions ship at launch, each switched by the employee's statutory_flags:
- PAYE, computed on annual bands (the ladder sits in the playhead below)
- Pension, employee portion, 8% of basic pay
- NHF, 2.5% of basic pay
- NHIS, employee portion, 1.75% of basic pay
At launch every statutory base is basic pay alone: allowances raise gross pay and leave the pension, NHF, and NHIS bases unchanged until a future rule set adds allowance keys to those bases.
The seeded rates are launch approximations, calibrated against accountant-reviewed sample cases. Refining a figure is a rule-set data change; the engine code stays the same.
One planned addition: remittance reminders that track withheld statutory amounts to their due dates. The alerts model reserves a remittance_reminder type and a due_at column for it today; the reminders themselves are a later slice.
The PAYE computation as seeded
PAYE is annual. The engine annualizes gross pay, subtracts the enabled statutory deductions before tax, subtracts a relief of at least ₦200,000 per year, walks the taxable remainder down the bands, and divides the annual tax by 12.
| Band (annual taxable income) | Rate |
|---|---|
| First ₦300,000 | 7% |
| Next ₦300,000 | 11% |
| Next ₦500,000 | 15% |
| Next ₦500,000 | 19% |
| Next ₦1,600,000 | 21% |
| Remainder | 34.127049180327868852% |
Rates are stored as decimal strings and amounts as integer kobo, so money math runs on Decimal end to end.
A draft run (payroll_runs, payroll_run_items) #
A payroll run is one pay period for one business. Periods are calendar months in the Africa/Lagos timezone: period_start is the first day of the month and period_end the last. Exactly one run may exist per business and period, and the server derives the current period from Lagos time, so a run created late on the last day of the month stays in that month even after UTC midnight.
Seven days before the period ends, the app starts showing everyone who can view payroll a Pay period ending attention row until a run for the period exists; any run clears it, a draft included. The row appears only for a business with at least one active employee, and it tracks payroll cadence only; statutory due dates stay with the planned remittance reminders.
Creating a draft pins rule_set_id: the run stores the rule set effective on its period_end and keeps it for life, so later rule-set updates apply only to later runs.
Compute fills the run with one payroll_run_item per active employee. For each item the engine resolves the compensation profile in force on period_end, computes gross to net, and freezes three snapshots onto the row: employee_snapshot (identity facts at compute time), compensation_snapshot (the resolved profile), and calculation_snapshot (the exact pay lines the engine produced). Each item also carries a validation_status: valid when a profile resolved and base pay is above zero, blocking otherwise. A warning status is reserved for a later slice.
The run stays live while it is a draft. Recompute repeats the fill: it creates or updates one item per active employee, removes items whose employee has become inactive, and preserves each existing item's included flag. The mobile app prompts for a rerun when the draft's item count drifts from the active-employee count.
included is the per-run exclusion switch. Excluding an item keeps the row visible for the record and removes it from totals, from payslip generation, and from the finalize gate; the employee's employment status stays untouched. The run totals, gross_total_minor and net_total_minor, derive from included valid items only.
Every draft change bumps the run's version, an integer that starts at 1; a recompute that changes nothing leaves it alone. The version identifies exactly which draft state a reviewer saw.
An owner, or a team member granted create_payroll_drafts, creates the draft, recomputes it, and excludes or includes items.
Titus Fahad is the operations manager at Folake's pharmacy, on a ₦750,000 monthly salary with pension and PAYE enabled and the NHF and NHIS flags off. His May item computes:
| Pay line | Amount |
|---|---|
| Basic salary | ₦750,000 |
| Gross pay | ₦750,000 |
| Pension (8%) | ₦60,000 |
| PAYE | ₦185,450 |
| Total deductions | ₦245,450 |
| Net pay | ₦504,550 |
How the engine reached ₦185,450
- Annual gross: ₦750,000 × 12 = ₦9,000,000.
- Employee pension before tax: ₦9,000,000 − ₦720,000 = ₦8,280,000.
- Relief: ₦8,280,000 − ₦200,000 = ₦8,080,000 taxable.
- The first five bands cover ₦3,200,000 and yield ₦21,000 + ₦33,000 + ₦75,000 + ₦95,000 + ₦336,000 = ₦560,000.
- The remaining ₦4,880,000 × 34.127049180327868852% = ₦1,665,400.
- Annual PAYE ₦2,225,400, divided by 12: ₦185,450.
Draft writes on the wire
POST /payroll-runstakes an optionalperiodasYYYY-MMand requires a UUIDIdempotency-Key. A duplicate period returns409 payroll_run_period_exists; a period with zero active rule sets returns409 no_active_rule_set.POST /payroll-runs/{payroll_run_id}/itemscomputes or recomputes the draft. It is repeatable, so it skips idempotency keys.POST .../items/{payroll_run_item_id}/excludeand.../includeflipincluded, and bump the version only when the flag actually changes.- Draft writes each emit one audit event:
payroll_run.createdorpayroll_run.recalculated.
Review and finalize #
Review reads the draft. The run detail shows the gross, total-deductions, and net totals plus the included-valid count; the item list shows each employee's pay lines straight from the frozen calculation_snapshot. Reads return the snapshots as stored; only compute writes numbers. total_deductions_minor is derived at read time as gross minus net.
The read tiers are capability-scoped. The run list admits an owner or a member granted view_payroll or view_employees; run summaries need view_payroll; per-employee breakdowns need review_payroll.
Finalize belongs to the owner alone at launch, and it requires a connection. A team member who prepared the draft sees Finalize disabled; the tap explains that only the business owner can finalize. The request carries the reviewed version in an If-Match header. The server recomputes and revalidates against the run's pinned rule set, then locks only when two checks pass:
- The recomputed state still matches the version the reviewer saw. Any drift, such as a raise recorded mid-review, an employee deactivated, or a changed total, returns
409 draft_changedtogether with the refreshed draft, and the reviewer confirms again. - Every included item is
valid. An includedblockingitem stops the lock; only included items count toward the gate.
On success the run's status flips to finalized, recording who finalized and when, and one payslip record is created per included valid item. Finalize emits one payroll_run.finalized audit event carrying the payslip count, the net total, and the version. Repeating the finalize call returns the finalized run unchanged. A finalized run and its snapshots change only through a future corrective workflow; a voided status is reserved for it.
Read tiers on the run list
One GET /payroll-runs payload serves both tiers. A caller with view_payroll gets current_period with run totals plus the runs history list. A caller admitted by view_employees alone gets the period label and employee counts while runs arrives as null, the capability marker; the mobile app renders the run area as a locked section for that caller. History rows are prior periods only; the current period stays in current_period, so an empty history right after finalizing the month is correct.
Finalize on the wire
POST /payroll-runs/{payroll_run_id}/finalizewithIf-Match: <reviewed version>. A missing or invalid header returns400 finalize_version_required.409 draft_changedincludes the refreshedrunanditems, so the client re-renders the draft from the error response in one round trip.409 payroll_run_has_blocking_itemsnames the other gate.- Finalize against an already finalized run returns
200with the finalized run.
Payslips (payslips) #
A payslip is the per-employee document a finalized run generates. Finalized runs are the only source of payslips, and payslips exist only for included, valid items. The payslip row is metadata: a document_status, whose only launch value is ready, and a generated_at stamp. The renderable facts, the employee, the period, the pay lines, and the business name, come from the run item's frozen snapshots and the business record, joined at read time, so the document always mirrors the finalized run.
The phone renders the document. Payslip reads need view_payslips; share_payslips gates only the client's share affordance. The share and export buttons ship disabled at launch: PDF and image export is a deferred slice. storage_key and document_checksum stay empty and reserve room for server-side document storage later.
Payslip reads
GET /payroll-runs/{payroll_run_id}/payslipslists metadata rows joined with snapshot names and stored net amounts.GET /payroll-run-items/{payroll_run_item_id}/payslipreturns the full renderable payload: payslip metadata, business name, employee snapshot facts, period label, and thecalculation_snapshotmoney lines.- An excluded item, or any item from a draft run, returns
404 payslip_not_found.
Launch boundaries #
The model is built so later workflows arrive as additions. The boundaries today:
- Payroll runs monthly only, on a calendar-month period; one rate covers the whole month, resolved on the period's last day.
- The engine computes employee-side deductions only. Employer-side NSITF and employer pension arrive in a later rule-set slice.
- One jurisdiction is seeded: Nigeria, under
NG-NTA-2025. - A finalized run changes only through a future corrective workflow.
- Payslips render on the device only; server-side document storage stays reserved.
- Finalize locks the record; salary payment happens outside Tomero.
- Run, finalize, and pay-change writes need a connection; employee creation is the only payroll write that queues offline.