One metering path assigns an event to a billing month after converting its occurrence time into the account’s timezone. The same path derives its daily usage key directly from the occurrence timestamp without that conversion.
Near midnight, those two fields can disagree:
event instant: May 31, 21:30 UTC
local business time: June 1, 00:30
billing period: June
stored usage date: May 31Each expression looks like an ordinary date calculation. They model different things, and the defect stays invisible until the difference crosses a day or month boundary.
One timestamp can answer several questions
An event usually has one occurrence instant: a point on the global timeline. From that instant, the system may need several derived values:
occurred_at:
which instant did the event happen?
local_display_time:
what did a person in this timezone see on the clock?
usage_date:
which local business day owns the event?
billing_period_start:
which local billing month owns the event?These values are related. They are not interchangeable names for the same timestamp.
Store the instant with an offset or normalized to UTC. Store a civil date when the date itself is the business fact. Store the timezone identifier used to derive business periods when the derivation must remain explainable.
Calling every value timestamp or date makes accidental conversion almost
inevitable. The name should reveal which question the field answers, not merely
which database type happens to store it.
An offset is not a timezone
+02:00 tells us the difference from UTC at one instant. It knows nothing
about next month, last year, or daylight-saving transitions.
Europe/Helsinki is a timezone identifier. Given a timezone database and an
instant, it can produce the applicable offset and local clock reading.
instant + timezone rules -> local civil time
instant + fixed offset -> one shifted clock readingA recurring rule such as “run at 09:00 local time on the first day of each month” needs a timezone, not the offset observed when the rule was created. If the offset changes seasonally, the UTC instant of the next occurrence changes while the local rule remains 09:00.
For historic decisions, preserve the timezone identifier and the original instant. Timezone databases can be corrected, so high-stakes systems may also need the resolved offset or rule-version provenance used at the time.
Business dates should come from occurrence, not processing
The queued metering path captures the occurrence timestamp before dispatch and carries it in the job. That prevents queue delay from moving an event into another day or billing month.
The wrong derivation is tempting because the worker already has a clock:
usage_date = worker_now()A request at 23:59 may wait until 00:02. A retry may run hours later. A replay may run next week. If those three executions produce three different business dates, the code is classifying queue latency rather than the event. Processing time describes when the system handled the fact, not when it occurred.
Persist both when both matter:
occurred_at:
source or ingress instant
recorded_at:
persistence instant
processed_at:
worker completion instantBilling, deduplication, and event ordering usually follow occurred_at.
Operational latency follows the difference between occurrence, recording, and
processing.
If the source does not supply a trustworthy time, the ingress boundary can assign one and record that provenance. It should not silently pretend that worker time came from the source.
Derive related periods through one calendar
The code correctly converts the occurrence instant into a configured timezone before taking the start of the month:
billing_period_start =
occurred_at
-> account timezone
-> local month start
-> civil dateIts daily aggregate uses the occurrence timestamp’s existing timezone directly:
usage_date =
occurred_at
-> dateWhen the timestamp is normalized to UTC, the day and month can diverge from the account’s business calendar. A query which sums local June by daily date may then omit an event whose billing ledger calls it June.
The repair principle is broader than one line of code:
calendar = timezone policy for this owner
local_occurred_at = instant converted through calendar
usage_date = local_occurred_at date
billing_period_start = local_occurred_at month startDerive all related civil values from the same localized value or a shared calendar service. Letting each projection rediscover its own timezone is how one event ends up in May and June at the same time.
The repository does not contain this complete repair. It proves the mismatch exists in the derivation, not that production data was affected or that somebody already fixed it. Those are materially different claims.
A date is not midnight UTC
A civil date such as 2026-06-01 has no time or offset. Turning it into an
instant requires a timezone and a choice of local time.
June 1 in Helsinki begins at one UTC instant
June 1 in New York begins at anotherStoring a billing-period start as a date is appropriate when the business fact is “June in this account’s calendar.” Converting it to UTC midnight does not standardize that fact. It changes it.
When a query needs instant bounds, derive them deliberately:
inclusive start:
local period start -> timezone -> UTC instant
exclusive end:
next local period start -> timezone -> UTC instantUsing an exclusive end avoids guessing the last representable instant of a day and remains clear across timestamp precision.
The interval length may not be a whole multiple of 24 hours when it crosses a daylight-saving change. That is normal for a local calendar interval.
Some local clock readings do not identify an instant
During a spring transition, a local clock can skip an hour. During an autumn transition, it can repeat one.
In Helsinki on a spring transition, the clock advances from 02:59 to 04:00. The local time 03:30 does not occur. During an autumn transition, 03:30 occurs twice with different offsets.
A bare local value such as:
2026-10-25 03:30cannot identify which repeated instant was intended. No amount of nicer date formatting can recover the missing choice. The model needs an offset, an explicit earlier/later policy, or source provenance which already resolves the instant.
For schedules, define behavior rather than relying on a date library’s default:
- skip a nonexistent local time;
- move it forward to the first valid instant;
- run once at the earlier repeated time;
- run once at the later repeated time; or
- run at both repeated instants.
There is no universal correct choice. Billing cutoffs, reminders, transport windows, and human appointments can require different policies. The bad choice is leaving it to whatever the date library happens to do this year.
The fixture crosses boundaries on purpose
The DATA-06 fixture uses the JavaScript runtime’s timezone database to inspect fixed UTC instants in named zones. It verifies a month boundary where UTC May belongs to local June, a skipped spring hour, and two distinct autumn instants which render as the same local clock time.
It also compares the two derivation shapes:
direct UTC date:
May 31
localized business date:
June 1
localized billing period:
June 1The fixture does not run the private metering code, prove a production incident, or validate every timezone database version. Its timestamps and owner are synthetic. It makes the type distinctions and boundary behavior reproducible; it does not turn a synthetic calendar example into production evidence.
Time policy belongs beside the business rule
UTC remains the right default for instants, ordering, durations, and system exchange. “Store everything in UTC” goes wrong when the slogan is stretched to cover civil facts which were never instants in the first place.
Store a local date when the domain owns a date. Store a named timezone with a recurring local rule. Store an instant for something which happened. Derive display time at the reader’s boundary. Preserve occurrence separately from processing.
Reverse the owner-calendar rule if the contract defines one global UTC billing period for everyone. Then derive both day and month in UTC and say so. The problem is not UTC; it is letting two implicit calendars classify the same event.
A timezone bug often gets reported as formatting because that is where a human finally notices it. The underlying mistake is usually in the model. Once the system names instant, civil time, date, recurrence, and calendar separately, the boundary calculations become ordinary and testable.