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
-
New data uses the unified Mon–Sun week. Invoice/cockpit/overage week computation uses
mondayWeekStart;weekKeyandrecentCompletedWeeks' default are Monday. New overage approvals are stored on the Monday week, matching the Monday invoices they gate. -
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.
-
The bridge is dow-aware, not identity.
overageApprovalWeekToPayWeek(weekStart)snaps the storedweek_startforward to its Monday: identity for a new Monday approval,+1for 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. -
Invoice re-derivation reads the stored
period_startraw. Recompute, send-staleness, and reissue passfromISO(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.
sundayWeekStartand theweekStartsOn: '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 == invoiceperiod_start== pay-week key for the unified anchor).