Skip to main content

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​

  1. src/lib/pay/settlement.ts is the one home for date→run settlement: CLOSED_BATCH_STATUSES (single definition), payPeriodForDate, periodKeyOf (the "start|end" batch-key shape), and settlementFor(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.

  2. 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 is settlingWeekRange(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 exceed periodDays by 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_date Jul 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_batch locks the calendar period range while pricing spans the settling weeks (ADR-0044 as-built note: no RPC migration). So isSettled means "the paying run went out", not "the entry is locked" — a paid tail day can sit approved forever. #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: drainPendingPayItems only 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.