ADR-074: A subscription's billing timezone is snapshotted at creation, not read live
Date: 2026-07-04 Status: Superseded by ADR-077 (2026-07-04). The per-subscription snapshot below is no longer the model: the billing timezone is an ORG-level setting (default UTC), resolved live at compute time and denormalized onto the invoice at issue. The deferred adversarial panel (see “Process”) is what surfaced the flaw — no peer bills each subscription in its own frozen zone, and the snapshot’s display divergence was the sole source of a ~8-bug render class. Kept for history; do not build on it.
Summary
Section titled “Summary”This record froze a billing timezone on each subscription at creation, so that changing the org timezone would not re-time live subscriptions. It was reversed the same day: ADR-077 made the billing timezone one org-level setting (default UTC) and dropped the subscriptions.billing_timezone column. Its invoice change survives: each invoice stores the timezone it was issued in (invoices.billing_timezone), so its printed dates do not move later. Status: superseded by ADR-077.
Context
Section titled “Context”Billing calendar date-math is anchored in a timezone (ADR-058): period boundaries snap to civil midnight in that zone, and proration denominators count days in it. Two halves define a subscription’s calendar:
- The anchor DAY —
billing_anchor_day, persisted per-subscription (ADR-055) so a Jan-31 sub stably bills Jan 31 / Feb 28 / Mar 31 / …. - The anchor TIMEZONE — which, before this ADR, was read live from
tenant_settings.timezoneat every computation (tenantLocation(ctx, tenantID)), at ~23 call sites.
That split is a bug: the anchor is half-frozen. A subscription bills on “the 1st,” but “the 1st in whichever timezone the tenant happens to have set right now.” So changing the tenant timezone — a control that reads as presentational — silently re-times the future billing boundaries of subscriptions that are already live, and re-introduces exactly the mixed-anchor drift ADR-058 exists to eliminate (a period anchored under UTC rolling into an IST-anchored next period shifts by 5.5h, occasionally onto a different calendar day, perturbing proration and revenue timing).
Industry parity. The mature model separates a display timezone (a lens;
changes freely, applies everywhere) from a billing anchor (immutable per
contract). Stripe anchors each subscription to a billing_cycle_anchor — a
stored Unix instant, never re-interpreted by a timezone setting. Chargebee
/ Zuora treat the site timezone as a setup-time decision that is heavyweight
(or restricted) to change once live billing exists. Hardcount already froze the
anchor day; freezing the anchor timezone completes the model.
Decision
Section titled “Decision”- Snapshot
billing_timezoneper subscription at creation — a new column, the peer ofbilling_anchor_day.Createstamps it once from the live tenant timezone (loc.String()— the canonical IANA name, or"UTC"for an unset/invalid tenant TZ: a concrete, immutable anchor). The pair(billing_anchor_day, billing_timezone)now fully and immutably defines the sub’s calendar. - All billing-math reads the snapshot, not the live tenant setting. New
subscriptionLocation(ctx, sub)helpers (onEngine,subscription.Service, and the subscriptionHandlerassubLoc) return the sub’sBillingTimezone, falling back to the live tenant timezone only for legacy/unset rows (so pre-migration subs are byte-for-byte unchanged). Every period-boundary and proration site was swapped fromtenantLocation(ctx, sub.TenantID)→subscriptionLocation(ctx, sub): 16 inbilling/engine.go, 1 inpreview.go, 2 inthreshold_scan.go, 5 insubscription/service.go(Create is the ONE exception — it readstenantLocationto snapshot), and 1 insubscription/handler.go— the proration-denominator (fullBillingCycleDays) call that resolves the zone via the injectedTenantLocator. That handler site is the subtle one: it uses a different mechanism than the directtenantLocationcalls, so only the full site-set audit caught it; missing it would have anchored the proration denominator to the live tenant TZ while the period boundaries used the snapshot — the exact mismatch this ADR prevents. - Display stays live — but see the 2026-07-04 amendment. As first shipped, the render sites kept reading the live tenant timezone (“a display lens over the immutable billing math”). That was correct for event instants but wrong for the period RANGE string: the inclusive-day conversion (“last day covered”) is a civil-calendar step that is only meaningful in the zone the boundary is civil-midnight in — the snapshot. Rendering the range in a different (live) TZ shifts it by a day for a tenant that changed TZ. The amendment below narrows this decision.
- Migration 0133 backfills every existing subscription with its tenant’s current timezone, so in-flight subs get zero behavior change: their effective timezone at migration becomes their frozen anchor. Rows whose tenant has no timezone stay NULL and fall back (at read time) to the live tenant TZ, exactly as before.
- Re-anchor paths keep the original timezone. A cross-interval plan swap or
threshold reset recomputes the anchor day to “now” (ADR-055) but reads the
sub’s stored
BillingTimezone— the sub’s billing-calendar timezone is fixed at signup and never changes. (An explicit “re-adopt the current tenant TZ on re-anchor” is a possible future refinement; the immutable default is the safe one and is flagged for the fast-follow panel.)
Net contract: changing the tenant timezone is now display-only for running subscriptions and a default for new ones — never a silent re-timing of live contracts.
Process
Section titled “Process”This shipped as an Opus build with the adversarial panel as a fast-follow,
a deliberate, noted deviation from the standing “panel before any money-path
build” rule. Justification: this is pattern-replication, not novel design —
it copies the already-shipped, proven billing_anchor_day snapshot into a
parallel billing_timezone column. The design risk (the part a panel de-risks)
is low; the real risk is completeness, which was controlled by the full
site-set audit + mutation-verified tests + edge-case coverage. A fresh-context
spec self-review ran before merge. The Fable panel remains a fast-follow to
adversarially sweep for a missed edge (backfill on unusual rows, DST
interactions) when credits return.
Amendment (2026-07-04): period-range display follows the snapshot, not the live TZ
Section titled “Amendment (2026-07-04): period-range display follows the snapshot, not the live TZ”The original decision 3 (“display stays live”) was too broad. A period range
label (“Jun 1 – Jun 30”) is derived by snapping the half-open boundaries to their
civil dates and stepping the exclusive end back one calendar day — the ADR-058
inclusive-display step. That step is only correct in the timezone the boundary is
civil-midnight in, i.e. the sub’s snapshot (billing_timezone). Rendering it
in the live tenant TZ reintroduces exactly the mixed-anchor off-by-one this ADR
exists to kill: an IST-anchored period [May 2 00:00 IST, Jun 1 00:00 IST) reads
"May 2 – May 31" in Kolkata but "May 1 – May 30" in New_York — a full day off
— the moment the operator changes the tenant TZ. (Empirically confirmed;
mutation-locked in period_display_test.go.)
Corrected contract:
- Period-range strings are authored by the backend in the anchor TZ. Invoices
already surface
Invoice.BillingPeriodDisplay; subscriptions now surface the newSubscription.CurrentBillingPeriodDisplay— computed on read viaFormatInclusivePeriod(start, end, subLoc(sub)), stamped at every subscription response site by the handler’srespondSub/stampPeriodDisplay. The wire stays half-open; this is a derived display field. - The frontend renders those strings verbatim and no longer recomputes a
sub’s period range in the browser (which defaulted to the live tenant TZ). The
TS
formatCivil*mirror survives only for auxiliary labels the backend doesn’t author (trial range, single period-end date, line-item “Covers …”), and those now receive the explicitbilling_timezonerather than defaulting to live. - Event instants still render live. A wall-clock timestamp (“Renews”, “Next
billing”, “created at”) is not a civil-day-derived range and legitimately shows
in the operator’s current TZ; those keep
formatDate.
Shipped in two focused PRs (money-path diffs stay small):
- PR1 — subscription API + frontend.
Subscription.CurrentBillingPeriodDisplaystamped at every response site; the dashboard renders it verbatim. - PR2 — invoice. A denormalized
invoices.billing_timezonecolumn (migration 0134), copied from the sub’s snapshot at every invoice-creation site (billing/engine.go×3,threshold_scan.go×1). The read-path decorator (invoice/service.goinvoiceDisplayLoc) and the PDF/hosted fallback (pdf_context.go) renderbilling_period_displayin that zone; the frontend line-item “Covers …” label passesinvoice.billing_timezone. No backfill — an existing invoice’s period was computed under whatever TZ was live when it was generated (not recoverable from stored data), so legacy rows stay NULL and fall back to the live tenant TZ, exactly what they display today. Only invoices created after 0134 render in the anchor zone. This is a display fix; the invoice wire (billing_period_start/end) is unchanged.
Accepted residuals
Section titled “Accepted residuals”- Re-anchor (swap/reset) keeps the original TZ rather than re-adopting the current tenant TZ (see decision 5) — deliberate; revisit if a DP needs it.
- An unset-tenant-TZ sub snapshots the concrete
"UTC"(immutable), whereas the migration backfill leaves an unset-tenant sub NULL (falls back live). Both are correct-under-their-moment; the divergence only matters if a tenant sets a TZ after having created subs with no TZ, where the pre-existing subs correctly keep following live until they next re-anchor.
Test locks (mutation-verified 2026-07-04)
Section titled “Test locks (mutation-verified 2026-07-04)”Unit: snapshot-at-create (Kolkata; unset → “UTC”); subscriptionLocation returns
the snapshot despite a changed tenant setting AND the boundaries genuinely
differ (Kolkata vs New_York), so the assertion can’t pass vacuously; empty-TZ
legacy row falls back to the live tenant TZ. Real-Postgres: the INSERT persists
billing_timezone (the memStore echoes fields — only a real round-trip catches
a dropped column, the Finding-1 activated_at trap) AND the snapshot survives a
real tenant_settings.timezone change. Mutations killed: revert
subscriptionLocation to the live read → immutability test fails; drop
billing_timezone from the INSERT → persistence test fails.