Skip to main content

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 a payroll_batches row with status prepared/funded/closed. Grouped per (contractor, assignment, pay period). submitted is included because PAYABLE_ENTRY_STATUSES includes 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 by periodForDate(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) mirrors calculateContractorPay's dispatch exactly: calendar period for in_house_admin and 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_items and drained into a payment_adjustments row by the next prepareDraft (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).
  • Pricing: computeCatchupCentavos gains an excludeFromBaseline date-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_items behavior, 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_drift view (0114), the "Bill late hours" supplemental preview, and the invoice generator all count entries by DATE with billing's PAYABLE statuses including locked — 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_counts is untouched. Migration 0118 adds the partial index (company_id, entry_date) where status in ('submitted','approved') backing the detection read.