20. Payroll period close (pay-run preparation)
Date: 2026-06-06
Status
Accepted
Context
Step 10 wires the corrected pure pay engine (src/lib/pay, ADR-0012/§1 of the build plan) to real data so an admin can close a bi-monthly period and prepare contractor pay (PHP). The engine is pure and already proven by golden tests; what was missing was the orchestration: pull each contractor's rate, time entries and approved holidays for a period, run the engine, persist the result, and freeze the inputs so the snapshot can't drift.
The schema already had the right tables from migration 0002: payroll_batches (one row per company+period, with a draft → prepared → funded → closed status and a unique (company_id, period_start, period_end) guard) and payments (one row per contractor, with hours_worked / contracted_hours / ratio / amount_centavos, an idempotency_key, and Wise fields for Step 14). So this step reuses those tables rather than inventing pay_runs/payslips.
Two correctness hazards drove the design:
- Partial writes. Preparing a run touches three things (the batch row, N payment rows, and locking the period's time entries). Done as separate client calls, a mid-sequence failure leaves money records half-written.
- Status-column overloading.
time_entries.statuscarries both the contractor lifecycle (draft → submitted) and the overage/facility-approval lifecycle (pending_approval → approved) and the payroll lock (locked). Naively locking everything would strand overage entries that the facility still needs to approve for invoicing.
Decision
- Money representation unchanged: the engine takes/returns integer centavos;
payments.amount_centavos/payroll_batches.total_centavosarebigint. No floats anywhere (project rule, ADR-0006). - Atomic close via a SECURITY DEFINER RPC.
public.close_payroll_period(company, period_start, period_end, total_centavos, prepared_by, assignment_ids, expected_entry_count, expected_hours, payments jsonb). In one transaction it: guards against an existing batch for the period (also enforced by the unique constraint), locks the priced entriesFOR UPDATEand re-verifies they still match what was priced (count + rounded hours — see TOCTOU below), inserts thepreparedbatch, inserts the per-contractorpayments(statuspending_review), and locks the period's paid entries. It isrevoked frompublic/anon/authenticatedand granted only toservice_role; theclosePeriodserver action calls it after an explicit owner-only check. Pay computation happens in TypeScript (the pure engine); the RPC only persists. - Lock semantics — scoped to the paid assignments. Lock
draft/submitted/approved → locked, but only for theassignment_ids that actually produced a payment row. Entries on assignments that were not paid (a contractor's non-primary/second-facility assignment, a null-rate assignment, or a zero-pay/soft-deleted contractor) are left editable and payable rather than frozen as locked-but-unpaid.pending_approvalis also left unlocked so the Step-9 facility magic-link approval can still flip it; that overage lifecycle is independent of contractor PHP pay (capped at ratio 1.0, so unapproved overage never changes the amount owed). Unresolvedpending_approvaldays are surfaced as a warning in the preview, not a hard block. - TOCTOU safety (migration 0014). Pay is computed in TypeScript before the RPC runs, so a concurrent edit/submit/sync could otherwise let the locked set diverge from the priced set. The action passes the priced
assignment_idsplus the priced entry count and hours sum; inside the close transaction the RPC re-reads those entriesFOR UPDATEand aborts the whole close if either differs ("hours changed since preview"). The action additionally binds the owner-confirmed total (a hidden field) and refuses if a recompute moved it. Together these guarantee the persisted snapshot equals what was priced and confirmed. - Audit attribution (migration 0014). The RPCs run under the service role, where
auth.uid()is null, so the audit trigger would record a null actor on the most sensitive writes. The RPCs set a transaction-localapp.actor_idGUC andapp.audit_row()prefers it overauth.uid(), so close/discard are attributed to the acting owner. - Reversibility.
public.discard_payroll_batch(batch_id, company, actor)(owner-only, RPC) unlocks only the batch's paid assignments'lockedentries back tosubmitted, deletes the run's payments, and deletes the batch — an undo path for a mistakenprepared(neverfunded/closed) run. Once a run is funded (Step 14, Wise), it can no longer be discarded. - Payslip transparency + audit.
paymentsgainsbreakdown jsonb(the per-weekWeekPayResult[]snapshot — "32.0 / 85.7 = 37.3% × allocation") andpayment_date date(the 15-day-arrears payout date frompaymentDateForPeriod). Existing audit triggers onpayroll_batches/paymentscapture the writes. - Two-step UI.
/admin/payrollpreviews a run (read-onlycomputePayRun, human-meaningful counts and total) before an explicit confirm, matching the "show totals, hard-confirm high-stakes money actions" preferences. - One primary assignment per contractor per period. For each contractor the run uses the assignment version with the latest
effective_dateoverlapping the period that has a non-nullbimonthly_rate_centavos. Contractors with zero computed pay are skipped (no empty payment rows).
Consequences
- Closing a period is atomic and idempotent: the unique constraint + in-transaction guard make a double-close impossible; re-running returns a friendly "already prepared".
- The prepared
paymentsrows are the immutable pay snapshot and the input to Step 14 (Wise draft transfers) — funding stays owner-driven and out of code (ADR-0007). No funding call is introduced here. - Known limitations (deferred, consistent with the build plan):
- Mid-period rate change is not split; the latest overlapping assignment rate is used for the whole period. Flagged here; revisit if real rate changes land mid-period.
- Multiple concurrent assignments (one contractor at two facilities in the same period) pays only the primary; multi-assignment payroll needs a UI + model extension.
- Statutory deductions / tax (PH BIR, SSS/PhilHealth/Pag-IBIG) and USD reference conversion are not modeled — gross PHP only. Net/deductions land with the tax decision noted in the plan's prep checklist.
- Discard restores
locked → submitted(it cannot perfectly restore the priordraft/approveddistinction); acceptable for undoing a not-yet-funded run, and documented in the action.