45. Billing weeks (Sunday) vs pay weeks (Monday ISO) — the week-anchor split
Date: 2026-06-23
Status
Accepted
Documents an existing, load-bearing invariant surfaced by the invoicing adversarial review. No behaviour change — this ADR makes the rule explicit so it is not reintroduced as a boundary bug.
Context
The system anchors weeks on two different days, on purpose:
- Invoicing / facility billing keys on Sunday–Saturday weeks (
sundayWeekStart). Anapproval_requests.week_startis always a Sunday;invoices.period_start/period_endare a Sunday and the following Saturday. - The pay engine keys on Monday-anchored ISO weeks (Mon–Sun). Overage approval, catch-up settlement, and weekly outsourced pay (ADR-0044) all bucket by the ISO Monday.
These two anchors are offset by exactly one day: a Sunday billing week S overlaps the ISO pay
week that starts on the Monday S + 1. The same real overage approval is therefore consumed by
billing under the key S and by pay under the key S + 1.
If either side reads the other's key verbatim, an approved overage is billed but not paid (or vice-versa), and a post-close approval near a month boundary can settle in the wrong half-month. This was the root of the P0 overage week-key regression: the billing change keyed Sunday while the pay loaders still keyed Monday, so approvals billed but stopped paying.
Decision
-
The split stays. Invoicing is Sunday-anchored; pay is Monday-ISO-anchored. We do not unify them — each anchor matches its domain's external reality (Wise invoice weeks vs. ISO payroll weeks), and a migration to a single anchor would touch every billing and pay path at once.
-
All crossing between the two anchors goes through one bridge:
overageApprovalWeekToPayWeek(weekStartIso)insrc/lib/dates, which returnsweekStart + 1 day(the billing-Sunday → pay-Monday map). No path may add/subtract the offset inline; use the helper so the relationship has a single definition and a single test. -
A boundary overage's settlement period is derived from its pay-week Sunday (
payMonday + 6), not the raw Sun–Sat billing days — so an approval whose pay week crosses a month boundary settles in the correct half-month (seecatchupPay/settleApprovedOverage). -
The invariant is locked by
tests/lib/dates/dates.test.ts(overageApprovalWeekToPayWeek): every billing Sunday maps to the next-day Monday, and the boundary case settles in the next period.
Consequences
- New code that bridges billing↔pay weeks MUST call
overageApprovalWeekToPayWeekrather than recomputing the offset; a rawweek_startused on the wrong side is the canonical failure mode. - Anyone tempted to "fix" the one-day offset by changing an anchor should read this ADR first: the offset is correct, not a bug.