An invoice generated in the evening had tomorrow’s date on it. The app was still using UTC because the billing timezone dropdown had never been changed.
The earlier fix had addressed a real problem. Calendar filters, billing periods, and invoice grouping needed to use the business’s local day and month boundaries. Calculating those boundaries in UTC could put an entry on the wrong day or in the wrong billing period.
The conversion code used a named timezone selected in the settings page. But the database migration had defaulted that setting to UTC. Nobody had changed it, so an evening invoice received the next day’s date.
This app bills in one business timezone. I want that defined with the deployment, without requiring someone to find a settings page before the dates are correct.
Moving the timezone into deployment configuration
The billing timezone now comes from APP_TIMEZONE, resolved when the server starts. If it isn’t set, the app uses America/Chicago, the default for this deployment. Startup logs name the active zone and whether it came from the default or an explicit value.
An invalid timezone stops startup. It doesn’t fall back to UTC and continue producing dates in a zone nobody intended. The server also checks that the resolved timezone is available before it begins handling requests.
There is a test for the exact expected default. Comparing the configured value against the same constant everywhere wouldn’t catch someone changing that constant back to UTC. The test spells out America/Chicago, so changing the default also requires revisiting the test.
The server embeds Go’s time/tzdata package, which supplies timezone data when the runtime can’t find it elsewhere. That makes the deployed binary less dependent on the container image’s timezone-data installation.
Moving the value into an environment variable doesn’t make a wrong configuration impossible. The improvement is the combination of an appropriate default, startup validation, and one zone used consistently throughout the app.
Keeping existing records consistent
The dropdown is gone, and the settings API no longer accepts timezone changes. Tests cover both the missing control and the save request, so the old field can’t keep changing the configuration through the settings page.
The database column remains, but now records which timezone was last used to calculate the stored billing periods. At startup, the app compares that recorded zone with the configured one.
If they differ, it recalculates the billing periods for stopped entries that haven’t been invoiced, then records the new zone. Both changes happen in one transaction before the server accepts requests. The first startup after this change corrects the old UTC assignments using the configured billing zone.
Invoiced history stays unchanged. Updating the timezone for future billing shouldn’t silently move entries that have already been billed.
The timezone is still configurable. Changing it now means updating deployment configuration and restarting the app, with a defined process for handling existing records. For this application, that’s a better place for the decision than a routine settings edit.
The browser needs the same timezone
The frontend also needed to stop interpreting entry times in the browser’s timezone. Editing an entry from a laptop in another zone could otherwise change the stored instant and move it into a different billing period.
The editor now uses the configured billing zone. It also keeps that zone fixed while an entry is being edited, so a configuration update doesn’t make an untouched time field look changed.
Daylight saving transitions still need explicit handling. The conversion policy moves a nonexistent local time forward by the gap’s length. When a local time occurs twice, it chooses the first occurrence. These rules belong alongside the timezone decision, rather than being left to the browser’s local settings.
The timezone conversion was necessary. Asking someone to configure it in the settings page wasn’t. For this app, I want one billing timezone applied consistently, from editing an entry to generating its invoice.
Sources
- IANA Time Zone Database — the named zones used by Go and browser timezone APIs
- Go
time/tzdata— the blank-import that embeds the zoneinfo database in the binary - Go
time.LoadLocation— the sources Go checks when loading a named timezone - MDN:
Intl.DateTimeFormat.formatToParts— how you read a named zone’s offset in the browser without a library
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].