Fix Timezone Bugs in the production

Last month, a merchant in Jakarta complained that their settlement report showed payments from “tomorrow” — a full day ahead. The amounts were correct, but the dates were wrong. Every single transaction was timestamped in UTC, but the report expected WIB (UTC+7).

The fix was a one-line change. The journey to finding it? Three days.

How it happened

Our reconciliation pipeline aggregated transactions from five different PSPs. Each one had a different idea about what “timestamp” meant:

  • Mandiri VA: WIB, no timezone annotation
  • BRI VA: UTC, explicitly marked
  • Winpay: Local server time (whatever that meant)
  • OVO: Unix epoch, implicitly UTC
  • LinkAja: ISO 8601 with offset

We normalized everything to time.Time on ingest:

func parseTimestamp(raw string, source string) (time.Time, error) {
    switch source {
    case "mandiri":
        return parseWIB(raw)
    case "bri":
        return time.Parse(time.RFC3339, raw)
    // ...
    }
}

Seemed fine. The database stored UTC. The API returned UTC. Time arithmetic worked. Tests passed.

The silent killer: Truncate

The bug was in the settlement report generator. To group transactions by settlement date, we truncated timestamps to the start of the day:

func settlementDate(t time.Time) time.Time {
    return t.Truncate(24 * time.Hour)
}

Truncate operates in the time zone embedded in the time.Time value. Since everything was in UTC, it truncated to UTC midnight. A transaction at 11 PM WIB (4 PM UTC) was correctly assigned to the same WIB day, but the report header said “Settlement Date: June 15, 2026” while the WIB date was June 16.

The fix

func settlementDate(t time.Time, loc *time.Location) time.Time {
    local := t.In(loc)
    return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, loc)
}

Explicit. Deliberate. No magic.

What I learned

time.Time is a great type, but it carries an implicit timezone that makes operations like Truncate, AddDate, and comparisons subtly wrong for business logic. Always be explicit about the timezone when doing date-level operations.

And always, always test with a clock set to a different timezone than your server’s.