Billing systems · Field notes

18:30 UTC is not midnight

A WhatsApp credit runner had the start of the billing day hardcoded as 18:30 UTC. That is exactly midnight in India and exactly wrong everywhere else — and it had been copied to five call sites.

[ 01 — The constant ]

A number that was right once, in one place

India Standard Time is UTC+5:30. Midnight in Kolkata is 18:30 UTC the previous day. If every customer you have is Indian, and someone needs the start of the current billing day, then 18:30 is a correct answer to the question being asked. It ships. It works. It is tested against reality every day by the only market that exists.

Then the platform sells into markets that are not India, and the constant does not follow, because a constant has no idea what it was standing in for.

The specific failure

The credit runner treated 18:30 UTC as the day boundary for every account. For non-IST markets the runner's day and the merchant's day were offset by the difference between their local midnight and 18:30 UTC — so the window used to grant, count and reset WhatsApp credits was not the window the merchant was actually being billed for. In the worst-affected markets this cost up to 21 of 24 billable hours per day.

[ 02 — What the offset does ]

Two days that do not line up

The mechanism is not subtle once you draw it. There is a window the system believes is "today", and a window the merchant is contractually being billed for. In India they are the same window. Everywhere else they slide apart by the local UTC offset, and the two shoulders where they fail to overlap are where the accounting goes.

Runner's "day"
(hardcoded)
18:30 UTC → 18:30 UTC
18:30 UTC
IST merchant
(UTC+5:30)
00:00 → 24:00 local · aligned
Non-IST merchant
(any other offset)
counted against yesterday
overlap
falls outside
00:00
12:00 local
24:00

Only the middle band is billed correctly. The two red shoulders are usage attributed to the wrong day — and usage attributed to a day that has already been invoiced is usage nobody ever charges for.

Note what this does not look like from the outside. Nothing errors. No request fails. Credits are granted, usage is counted, invoices are generated, and every number is internally consistent with every other number. The system is confidently wrong, which is the only kind of wrong that survives in a billing system for any length of time.

[ 03 — The real bug ]

Five call sites, one assumption

The constant appearing once is a bug. The constant appearing five times is the thing worth writing about, because it tells you the concept never had a home.

The same assumption, restated five times
grant
Decides when a new day's credits are issued.
reset
Decides when the previous day's unused allocation lapses.
usage window
Decides which events count toward today's consumption.
threshold checks
Decides when a merchant is warned or cut off.
reporting
Decides what the merchant sees, which is how the mismatch stayed invisible.

Five places that each need to know when the day starts. Not one of them asked anything; each of them decided.

Duplication like this is not laziness. It is what happens when there is no function to call. The first engineer needed a day boundary and there wasn't one, so they wrote the number. The second engineer needed a day boundary, found the number in the first place, and copied it — which is the correct instinct given the options. By the fifth copy the number looks like a convention, and conventions do not get questioned during code review.

// What five call sites each contained, in one shape or another:
const dayStart = startOfDayUtc().setHours(18, 30);

// What replaced them: one function, one argument, no default.
const dayStart = billingDayStart(account.timezone, at);

The important property of the replacement is not that it handles timezones. It is that it cannot be called without stating whose day you mean. There is no default parameter, because a default would have been IST, and a default that is right for your largest market is how you get here in the first place.

[ 04 — Why nobody caught it ]

Every test was written from inside the timezone

The tests were correct. They asserted that credits reset at midnight and that usage landed in the right day, and they passed, because they ran on machines in IST against fixtures authored in IST by people in IST. The assertion and the bug shared an assumption, so the assertion could never fail on it.

What actually catches this

Run the billing suite under at least one timezone that is not your own and not UTC — and pick one with a fractional offset or a DST transition, because those break the arithmetic that a whole-hour offset lets you get away with. A test that only ever runs in one timezone is testing your machine, not your logic.

The other reason it survived: the merchants who were losing hours had no way to see it. What the reporting showed was computed by the fifth call site, using the same constant as the other four. The interface agreed with the runner because it was asking the runner. Nobody ignored a discrepancy — the system was structurally incapable of displaying one.

That is the same failure I wrote about in how to bill AI usage: a value that decides what someone is charged, and no screen anywhere that shows it.

[ 05 — What generalises ]

Constants are concepts that never got named

18:30 was never a time. It was the sentence "the billing day starts at local midnight, and our customers are in India" — with the second clause dropped, because at the time it went without saying.

Every literal in a codebase is a compressed assumption. Most of them are fine. The dangerous ones share three properties, and this one had all three:

The cheap version of this discipline: grep your codebase for timezone offsets and currency symbols, and for each one ask whose. If the answer is "well, ours", you have found the same bug at a different address.