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
// 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 expectedWhat 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:
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
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
// 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.