Skip to main content

49. Unify new billing onto the Monday–Sunday pay week

Date: 2026-06-30

Status

Accepted. Supersedes ADR-0045 (the billing-Sunday / pay-Monday split) for all data created from this change forward.

Context

ADR-0045 kept two week anchors on purpose: invoicing on Sun–Sat (sundayWeekStart), pay on Mon–Sun ISO (ADR-0044), bridged by overageApprovalWeekToPayWeek. In practice the split was a recurring source of boundary bugs and operator confusion, because both sides read the same time_entries.entry_date:

  • A Sunday shift falls in the Sun–Sat invoice week it opens but the Mon–Sun pay/compliance week it closes — the same day counted in different weeks on each side.
  • The overage cap was detected on the Mon–Sun week (timeEntry.ts) but billed/approved on the Sun–Sat week.
  • Migration 0084 was a P0 from this seam (overage billed-not-paid).

We want one anchor going forward, but existing invoices are positionally bound to the Sunday anchor: weekly_billing_run and buildInvoicePreview match an approval to an invoice by exact equality (approval_requests.week_start = invoices.period_start), while the pay engine matches by date range. That asymmetry is the trap: re-keying historical approvals without re-keying their already-sent invoices would make an approved overage stop billing (exact-match miss) while it keeps paying (range hit) — 0084 all over again.

Decision

  1. New data uses the unified Mon–Sun week. Invoice/cockpit/overage week computation uses mondayWeekStart; weekKey and recentCompletedWeeks' default are Monday. New overage approvals are stored on the Monday week, matching the Monday invoices they gate.

  2. No data is migrated. Existing Sun–Sat invoices and their Sunday-keyed approvals are left exactly as they are and keep working: an existing invoice still matches its Sunday approval by exact equality, and the pay engine still finds that approval by range. There is no DB migration and therefore no deploy-ordering window — the new code is fully backward-compatible with all existing rows.

  3. The bridge is dow-aware, not identity. overageApprovalWeekToPayWeek(weekStart) snaps the stored week_start forward to its Monday: identity for a new Monday approval, +1 for a legacy Sunday one. One function absorbs both anchors, so the pay engine keys every approval (old or new) on a Monday week. Once the legacy Sunday tail has fully settled it degenerates to a pure identity and can be inlined.

  4. Invoice re-derivation reads the stored period_start raw. Recompute, send-staleness, and reissue pass fromISO(inv.period_start) directly — never re-snapping a stored date. A legacy Sunday invoice recomputes on its Sunday week (matching its Sunday approval); a new Monday invoice on its Monday week. (Re-snapping with either fixed anchor would shift one era's window — e.g. mondayWeekStart(Sunday) is the prior Monday — so raw is the only correct choice across both.)

Consequences

  • Going forward a Sunday shift belongs to one and the same week for hours, compliance, billing, and pay; the boundary confusion that prompted this is gone, and the 0084 billed-not-paid seam cannot recur.
  • Historical Sun–Sat invoices keep their dates and remain correct; the data carries both cadences by design during the tail. Reports spanning the changeover must not assume a single anchor for all history.
  • At the changeover there is a one-day seam: the first new Mon–Sun week starts the Monday after the last billed Saturday, so a single Sunday between them is unbilled and must be handled deliberately (bridge it into the first invoice or bill it as a one-off). This is an operational step, not a code path.
  • sundayWeekStart and the weekStartsOn: 'sunday' option remain only for the legacy tail / back-compat.
  • Locked by tests: overageApprovalWeekToPayWeek (legacy Sunday → Monday, new Monday → identity, output always a Monday) and a cross-component invariant (overage-page week key == invoice period_start == pay-week key for the unified anchor).