Skip to main content

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). An approval_requests.week_start is always a Sunday; invoices.period_start / period_end are 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

  1. 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.

  2. All crossing between the two anchors goes through one bridge: overageApprovalWeekToPayWeek(weekStartIso) in src/lib/dates, which returns weekStart + 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.

  3. 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 (see catchupPay / settleApprovedOverage).

  4. 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 overageApprovalWeekToPayWeek rather than recomputing the offset; a raw week_start used 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.