Mesrai
Back to blog
// essayTechnical Deep Dive

DST Transition Bug in Billing Cycle Math

Real PR catch: addMonths in local time drifts at DST — renewals hours off. Temporal ZonedDateTime fix.

Mesrai TeamAugust 25, 20268 min read

A monthly billing cycle calculator. Uses date-fns `addMonths` on a local Date. DST transitions cause some renewals to be at 23:00 instead of 00:00, and a small fraction of edge-case scheduling to skip a day entirely.

The vulnerable diff

tsbilling/cycle.ts
// billing/cycle.ts
import { addMonths } from "date-fns";

function nextRenewalDate(start: Date): Date {
  // BUG: addMonths in local time, no DST awareness
  return addMonths(start, 1);
}

// start = 2026-03-08 12:00 (US/Eastern) — day DST kicks in
// nextRenewal = 2026-04-08 11:00 (US/Eastern) — lost an hour
// User invoiced one hour earlier than expected

What is wrong

Date math libraries operate on absolute moments (Instant) or on calendar time (PlainDateTime). For billing, you usually want calendar-time — 'one month later, same local clock time'. date-fns `addMonths` operates on the Date object's underlying Instant; if the timezone shifts (DST), the local clock time drifts. The Temporal API (now standard in modern Node) handles this correctly with `ZonedDateTime.add({ months: 1 })` which preserves local clock time across DST.

The attack

Edge case behaviors:

textexploit
1. DST-on:  2026-03-08 12:00 -> 2026-04-08 11:00 (1 hour off)
2. DST-off: 2026-11-01 12:00 -> 2026-12-01 13:00 (1 hour off, other way)
3. Feb 29:  2024-02-29 -> 2024-03-29 (correct), but 2024-03-31 + 1 month
            = 2024-04-30 (date-fns clamps; some libs return 2024-05-01)

User-visible: invoice timestamps drift; some users get charged hours
earlier or later than expected.

Subscription math is a deep iceberg — DST is one of the easier parts.

Mesrai's review comment

textreview-comment.txt
mesraipilot · Bot · reviewed 1 min ago

[mesrai] [code-review] [Logic] [DST-Math] [medium]

addMonths on a local Date can drift across DST. Use Temporal (or
luxon) with explicit timezone to preserve local clock time:

  import { Temporal } from "@js-temporal/polyfill";

  function nextRenewalDate(start: string /* ISO */, tz: string): string {
    return Temporal.ZonedDateTime
      .from(`${start}[${tz}]`)
      .add({ months: 1 })
      .toString();  // "2026-04-08T12:00:00-04:00[America/New_York]"
  }

For invariant billing time-of-day across DST, store the user's
timezone alongside the start date and always compute in that zone.

For edge cases at end-of-month (Jan 31 + 1 month), pick a policy
explicitly — 'last day of month' or 'overflow to next month' — and
document it.

The fix

tsbilling/cycle.ts (fixed)
// billing/cycle.ts — fixed
import { Temporal } from "@js-temporal/polyfill";

export function nextRenewalDate(startIso: string, tz: string): string {
  return Temporal.ZonedDateTime
    .from(`${startIso}[${tz}]`)
    .add({ months: 1 })
    .toString();
}

Temporal ZonedDateTime preserves local clock time across DST. Renewal at 12:00 local stays 12:00 local even if the offset changes. Edge-case end-of-month policy is explicit (Temporal defaults to clamp; document it).

Why human review missed it

Subscription DST bugs surface only twice a year and affect only DST-observing regions. Production teams find them via support tickets a year after launch. Mesrai catches every date-math call in subscription / billing code without timezone awareness.

Related rules + further reading

Mesrai rule pack: logic/billing-cycle-tz-aware — flags addMonths/addDays in subscription code without timezone.

Temporal proposal: TC39 § 7 — ZonedDateTime.add.

Common in international SaaS billing.

Takeaway

Subscription dates need timezone + DST handling. Temporal or luxon. Local clock time preserved. Mesrai catches naive addMonths.

// try

See it on your next PR.

Free for individuals. Install in two minutes. Mesrai reviews every commit.