ADR-091: An org-timezone change never overbills a subscription — absorb/prorate the re-anchor "seam"
Status: Accepted (2026-07-14). Keeps ADR-077 (one org-level billing timezone) and ADR-058 (civil-midnight DST-exact anchoring). The full billing/display decouple is the deferred root fix — ADR-092, triggered by a design partner needing multi-timezone billing. Context: money-path review of the ADR-058 + ADR-077 interaction. Reproduced live on the dev server; the anniversary/yearly case found by a 15-agent adversarial review.
Summary
Section titled “Summary”Changing the org timezone never makes a subscription pay a full base fee for a short “seam” period. The next period boundary is re-read in the new zone, which can create a short period before the next anchor, and an in-advance plan used to bill a full fee for it. A period under one day now gets no in-advance base fee, as in-arrears billing already did. An anniversary or yearly period that starts off its anchor day is prorated against one normal calendar interval, so a one-day seam on a $30 monthly plan costs about $1. Status: active; the one org-level billing timezone (ADR-077) stays, and splitting billing from display timezone is deferred (ADR-092).
The defect
Section titled “The defect”Billing period boundaries are re-resolved each roll against the one org timezone
(ADR-077, resolved live; ADR-058 anchors civil-midnight in it). When an operator
changes the org timezone mid-cycle — often for a purely cosmetic reason, e.g. to
read dashboard timestamps in a country they’ve travelled to — the next roll
re-interprets the stored period-end instant in the new zone. West-ward, “the
anchor in the new zone” lands a few hours after the stored period-end, so the
engine bills a degenerate short “seam” upcoming period. For an in_advance
base fee (length-insensitive) this overbills. It manifests two ways:
-
Calendar (sub-day seam).
NextBillingPeriodEndsnaps to the 1st, ~9.5h past periodEnd.roundDays(9.5h) == 0, so the short-period proration guard no-ops and the full month is billed for ~hours. Live repro: a $30/mo sub switchedAsia/Kolkata → America/New_Yorkbilled $90 across two months instead of $60. -
Anniversary / yearly seam — the subtle one.
advanceAnchoredsnaps to the next anchor day, sobaseEndand the proration denominatorfullCycleDays(bothadvanceBillingPeriod(baseStart)) are identical by construction —advanceDays == fullCycleDays, the gate is dead code, and neither residual is sub-day, so the absorb misses it too. The magnitude differs by interval: anniversary-monthly bills a FULL month for a ~24h seam (30× overbill); yearly bills a FULL year for a ~335-day residual ($30 vs the correct ~$27.53, a ~1.09× overbill). This shipped uncovered because the original seam test only exercised calendar billing.
Decision
Section titled “Decision”Make every re-anchor seam money-correct, for every billing interval, while keeping ADR-077’s single org zone:
-
Calendar sub-day seam → absorb. At the in_advance base-fee site, skip the base line when the upcoming period rounds to zero days (
advanceDays <= 0) — the exact mirror ofemitBaseSegmentLine’s pre-existingsegDays <= 0in_arrears absorb (why in_arrears never had this bug). The sub re-aligns on the next full cycle; the sliver is free and self-healing. -
Anniversary/yearly seam → prorate against a nominal cycle. A seam leaves
baseStartoff the anchor grid. Detect it withdomain.IsPeriodStartOnAnchor(day-of-month == the month-end-clamped anchor day; calendar’s anchorDay 0 is always on-anchor and untouched). When off-anchor, measurefullCycleDaysNOMINALLY — one calendar interval frombaseStart(advanceBillingPeriod(…, anchorDay 0)=addIntervalIn, ~28–31 days / ~365) — instead of the re-anchored value that collapsed to the seam width. The gate then fires and the ~24h seam prorates to ~1 day’s worth (e.g. $1 for a $30/mo sub; the ~335-day yearly residual prorates to ~$27.53) instead of a full fee.
A nominal denominator is used only for an off-anchor seam — a normal month-end anniversary period (28-day February, day-31 anchor) is on-anchor, so it keeps the exact current behavior and never spuriously prorates.
Alternatives rejected
Section titled “Alternatives rejected”-
Merge the seam forward into the next full period. Cannot distinguish a TZ seam from a legitimate short calendar-realign / plan-change stub (a Dec-31→Jan-1 drift is a 1-day stub that overlaps the seam’s length range); a length threshold breaks stub proration (
TestRunCycle_PlanIntervalChange_InAdvance_StubProrated). Absorb/prorate by actual length sidesteps the discrimination entirely. -
Split billing timezone from display timezone (the decouple, ADR-092). The industry root fix — a display-zone change then structurally cannot re-time billing. Deferred, not rejected: it is a multi-timezone capability (bill customer A in New York, customer B in London; or an operator who must change display without touching billing), and Hardcount is pre-launch, 0 customers, single-org-zone US B2B. Building it now is enterprise-shaped work with no named pressure, and it adds real surface (two-knob UI, per-surface render classification). ADR-092 records the full design + 6-platform industry grounding + extensibility audit so it is a clean additive build the day the trigger arrives. Until then, this seam handling makes the one-zone model money-correct — which was the only actual defect.
Consequences
Section titled “Consequences”- An org-timezone change can never bill a full cycle for a re-anchor seam, for calendar, anniversary, or yearly subs; the sub self-heals to clean boundaries next cycle.
- in_advance and in_arrears now handle a sub-day upcoming period identically.
- DST-exact civil-midnight boundaries (ADR-058) and the single org zone (ADR-077) are unchanged.
- Enforced by real-Postgres tests asserting no full fee for a sub-cycle period
across all three billing intervals (mutation-verified against removing each
guard) + a
IsPeriodStartOnAnchorunit table (calendar / anniversary / month-end clamp).