57. Settlement module — one home for "which run pays this date"; terminating window opens at the first settling Monday
Date: 2026-08-27 Status: accepted
Context
Four spellings of the closed-batch status set (['prepared','funded','closed']) lived in six files,
and the "which pay run pays this date" question was answered ad hoc at each call site. That produced
the tail-day double-pay bug (#164: manual-entry classification used the bare calendar period) and
left a divergence between payPeriodForDate and the pay engine (#163): payPeriodForDate decided
the ended-assignment rule from the date's calendar period, while the engine's terminating
dispatch decides from the run's period. They disagreed exactly on tail days under a future-dated
end_date — payPeriodForDate said the terminating run pays the day, but that run's window
[max(period.start, effective_date), end_date] (ADR-0044 §5 as-built) excluded it, and the prior
run's weekly branch excluded it too. The day was priced by neither run (underpay/strand), and
the "add to next run" catch-up quoted ₱0 for it.
Decision
-
src/lib/pay/settlement.tsis the one home for date→run settlement:CLOSED_BATCH_STATUSES(single definition),payPeriodForDate,periodKeyOf(the"start|end"batch-key shape), andsettlementFor(dates, ctx, closedBatchKeys)→ per-date{payingPeriod, periodKey, isSettled}. Adopted by manual-entry classification, overage settlement, stranded-hours detection, and the calculate/process pages. Anything asking "which run pays / paid this date" routes through it. -
The terminating window opens at the first settling Monday (amends ADR-0044 §5):
active window = [max(first settling Monday, effective_date), end_date], where the first settling Monday issettlingWeekRange(period).start— the day after the last Sunday the previous period's run settled. The prior period's calendar tail days were priced by no earlier run, so the terminating run picks them up. The ratio is now capped at 1 (min(1, activeDays/periodDays × min(1, worked/FTE-for-active-days))): the widened window can exceedperiodDaysby up to 6 days, and without the cap a fully worked final period would pay up to 146% of the rate. A fully worked final period pays exactly the bi-monthly rate — the same invariant as weekly settling. Loaders already fetch from this Monday, so no fetch change. This closes #163:payPeriodForDate's existing mapping and the engine agree on every date ≤end_date.Pay impact (owner must sign off — this is policy, not just a bug fix). Monotonically upward, below the rate cap, from two effects: (a) hours worked on the prior period's tail days now count (the stranded hours #163 is about); and (b) the wider window raises the FTE denominator AND the day-fraction numerator together, which relaxes the fulfillment cap for a leaver who worked above the old in-period window's full-time equivalent even with ZERO tail-day hours — e.g.
end_dateJul 25 2025, 8h Mon–Fri Jul 16–25 only: ₱12,500 → ₱14,000 per ₱20,000 rate (+12%). The window is now "contract days since the last settled Sunday", not "calendar days in the period".
Encoded as-built constraints (deliberate; documented, not fixed)
close_and_lock_payroll_batchlocks the calendar period range while pricing spans the settling weeks (ADR-0044 as-built note: no RPC migration). SoisSettledmeans "the paying run went out", not "the entry is locked" — a paid tail day can sitapprovedforever. #162 tracks the real fix (settling-week-span lock migration);pricedByRun(ADR-0056/#165) is the interim guard.- Billing weeks are Sunday-anchored and a separate domain (ADR-0045). The settlement module is pay-only; never use it to place a date in a billing week.
Consequences
- One constant, one mapping, one key shape; the A-class misclassification can no longer be reintroduced per-call-site.
- A leaver with hours on prior-period tail days is now paid for them by their terminating run; catch-up quotes for such days become positive instead of ₱0.
- Callers must never run the engine for a period entirely after
end_date's own period (loaders already exclude ended assignments) — the widened window would price the tail days a second time. - A catch-up (
pending_pay_items) queued for an OFFBOARDED contractor never drains on its own:drainPendingPayItemsonly applies items to contractors with a payment row in the next draft, and an ended assignment has none. #163 makes this reachable (tail-day catch-ups on terminating assignments now quote positive amounts). The item stays visible in Queued Catch-ups but needs manual handling — tracked as a follow-up issue.