56. Stranded late hours: surface + owner disposition for contractor-entered hours in closed periods
Date: 2026-08-22
Status
Accepted
Context
ADR-0038 gave owner-entered back-dated hours a disposition flow (already paid / queue catch-up on
the next run). Contractor-entered hours had no equivalent: a contractor can log a missed day in a
period whose pay run already closed (upsertTimeEntry only refuses ended/locked/approved/invoiced
days), and the approval grid approves by date range with no closed-batch check. Since runs pull
entries strictly by entry_date within their own period, such hours end up submitted/approved in
a period no future run reads — silently payable-but-never-paid, with no alert anywhere.
Why submitted/approved is exactly the stranded set: close_and_lock_payroll_batch (0041) locks
draft/submitted/approved entries on every paid assignment at close, and its snapshot guard
refuses drift between Prepare and Close. So any entry still submitted/approved inside a closed
period arrived after the close (or sat on an assignment the run couldn't price — no rate).
pending_approval is deliberately not part of it: pre-close ones were priced and paid at the
contracted cap (the overage flow owns the premium, ADR-0043), and post-close ones must clear the
overage gate first — approval flips them to approved, which lands them in this flow.
Decision
- Detection (
findStrandedLateHours): entries with status in {submitted,approved} whose PAY period has apayroll_batchesrow with statusprepared/funded/closed. Grouped per (contractor, assignment, pay period).submittedis included becausePAYABLE_ENTRY_STATUSESincludes it: a run would have paid it. - Pay-period membership is
payPeriodForDate, NOT the calendar period. Outsourced pay settles by whole ISO Mon–Sun week in the period holding the week's SUNDAY (ADR-0044), so ~1–6 tail days of every calendar period are paid by the NEXT period's run, and a period's first settling week can start up to 6 days before its calendar start. Mapping byperiodForDate(entry_date)would (a) flag tail-of-period dates as stranded while the run that pays them is still open — "Already paid" there would let the next run double-pay them — and (b) price leading-edge dates against a period whose settling weeks don't contain them (₱0 → silent underpay).payPeriodForDate(lib/pay) mirrorscalculateContractorPay's dispatch exactly: calendar period forin_house_adminand for assignments ending on/before the calendar period's end (terminating runs day-prorate the calendar window); the Sunday-holding period otherwise. The resolve action re-maps every entry server-side with the same function, so a date settling in a still-open period can never be resolved here. - Surfacing: an "Unpaid late hours" panel on
/admin/payroll/calculate(amounts priced per group) and a high-tone needs-attention alert on the admin dashboard (hours only — pricing every group on each dashboard load is not worth it). - Owner disposition per group, mirroring ADR-0038:
- Add to next run → entries lock; the marginal pay is queued in
pending_pay_itemsand drained into apayment_adjustmentsrow by the nextprepareDraft(existing machinery, no new tables). - Already paid → entries lock, nothing queued (settled outside the system). Locking is the point: a locked day can never be paid by date again, and the status-filtered lock update doubles as the concurrency guard (a second resolve matches zero rows and stops).
- Add to next run → entries lock; the marginal pay is queued in
- Pricing:
computeCatchupCentavosgains anexcludeFromBaselinedate-set. Unlike ADR-0038's call site, stranded entries are already persisted, so the baseline is rebuilt WITHOUT them and they are overlaid back — the marginal is identical to the not-yet-persisted case: proration and the 100% weekly cap respected (a week already at cap prices to ₱0; the days still lock). An assignment with no bi-monthly rate cannot be priced — resolution is refused with a "set the rate first" error instead of silently locking hours for ₱0. - No atomicity theater: PostgREST has no cross-statement transaction. Order is compute → lock (status-filtered, verified count) → queue; a failed queue insert restores the entries' prior statuses best-effort so the hours stay visibly unpaid rather than locked with no pay item. The restore touches ONLY the rows the failed request's own update returned — a resolve that lost the concurrency race restores nothing, so it can never unlock the winner's rows and reopen the group for a duplicate pay item. Restore failures are logged (entry id + assignment id only).
- Refuse, don't zero: the engine's error branches (no rate, contracted hours ≤ 0) surface as
₱0 from
computeCatchupCentavos; both are pre-checked and refused with a "set it first" error so unpriceable hours are never locked as "nothing owed".
Consequences
- The contractor-entry inlet is now covered end-to-end; ADR-0038 (owner backfill) and the overage catch-up (ADR-0043) already covered theirs. Any future inlet (e.g. an importer writing approved hours into a closed period) is caught by the same backstop — detection is by state, not by path.
- The catch-up is a fixed amount computed at resolve time, carried as a labeled adjustment with a note, visible on the draft table and the contractor's payslip — never mis-dated into a wrong period.
- Known overlap (rare): a period with BOTH a late overage approval and late entries in the same week can queue overlapping premiums (each marginal is computed against the then-current state). Both items are visible with notes on the calculate page; the owner cancels one. Not worth a ledger.
- A queued item for a contractor with no payment on the next draft (e.g. offboarded) stays queued
until a draft includes them — pre-existing
pending_pay_itemsbehavior, unchanged. - The billing side is covered by the existing drift backstop (verified 2026-08-22): the same
late entry is also unbilled on an already-invoiced week, and the
invoice_driftview (0114), the "Bill late hours" supplemental preview, and the invoice generator all count entries by DATE with billing'sPAYABLEstatuses includinglocked— so resolving the pay side here (which locks the entries) never clears the facility-undercharge badge, and the supplemental still bills them. A static guard (tests/server/stranded-late-hours.test.ts) pins'locked'in both. Pre-existing limit, unchanged: an assignment with NO line on that week's invoice has no drift row (ADR-0055). - Detection runs in JS over normally-empty sets;
needs_attention_countsis untouched. Migration 0118 adds the partial index(company_id, entry_date) where status in ('submitted','approved')backing the detection read.