Subscription Expires One Hour Early on DST Spring-Forward Day
A 24-hour trial subscription purchased at midnight on 2024-03-10 in America/New_York — the US spring-forward day — should grant exactly 24 hours of access. The server computes the expiry using local calendar arithmetic: midnight March 10 EST plus one local day equals midnight March 11 EDT, which is 2024-03-11T04:00:00Z. Because the spring-forward transition makes March 10 only 23 UTC hours long, the stored expiry is 23 hours from the purchase timestamp rather than the expected 24. The subscription expires one hour earlier than the user's stated trial duration.
MediumIntermediateManual testingAPI testingBoundary value testing
// UNDERSTAND
// Symptoms
- GET /api/subscriptions/me returns expiry_at: '2024-03-11T04:00:00Z' for a subscription purchased at '2024-03-10T05:00:00Z' — only 23 hours apart instead of the expected 24
- A user whose 24-hour trial started at midnight on 2024-03-10 Eastern loses access at midnight March 11 EDT (23 UTC hours later), not at 1:00 AM EDT (24 UTC hours later)
- The bug occurs only on the spring-forward date (2024-03-10 in America/New_York) and only for subscriptions purchased on that day — non-DST dates are unaffected
- On a non-DST purchase date (e.g. 2024-03-08), the same 24-hour trial shows expiry_at exactly 86400 seconds from purchase — confirming the discrepancy is DST-specific
- The fall-back transition (2024-11-03 in America/New_York) produces the opposite error: the local day is 25 hours, so the subscription expires 1 hour LATER than expected
// Root Cause
- The expiry is computed using local calendar arithmetic — addDays(localDate(purchase_at), trial_duration_days) — rather than by adding the equivalent seconds to the UTC purchase timestamp. On the spring-forward day, localDate(midnight March 10 EST) + 1 day = midnight March 11 EDT = 2024-03-11T04:00:00Z, which is only 23 UTC hours from the purchase timestamp 2024-03-10T05:00:00Z.
- The subscription service assumes one local calendar day always equals 24 hours. This holds on non-transition days but breaks on the spring-forward day (23 UTC hours) and fall-back day (25 UTC hours). The correct approach is purchase_utc + N × 86400 s, which gives the right UTC expiry regardless of DST.
// Where It Appears
- SaaS trial and subscription billing systems that compute expiry by advancing the local date by N days
- Mobile applications that compute trial end by calling addDays() on the local device date rather than adding seconds to the UTC timestamp
- Any subscription system where the expiry date is computed in server local time and the server timezone observes DST
// REPRODUCE & TEST
// How to Reproduce
- 01In the test environment, create a subscription with purchase_at = '2024-03-10T05:00:00Z' (midnight Eastern Standard Time, the start of the spring-forward day)
- 02Send GET /api/subscriptions/me and read the expiry_at field
- 03Compute the difference: expiry_at_epoch - purchase_at_epoch in seconds
- 04Assert the difference is exactly 86400 seconds (24 hours) — if it is 82800 seconds (23 hours), the DST-shift bug is confirmed
- 05Advance the test clock to 2024-03-11T04:30:00Z (30 minutes after the stored expiry) and send GET /api/subscriptions/me; confirm the status shows 'expired' — the subscription expired at the 23-hour mark
// Test Data Needed
- A test user account with a 24-hour trial subscription whose purchase_at can be set to 2024-03-10T05:00:00Z
- Ability to inject or override the server clock in the test environment (or the ability to manually set the subscription record's purchase_at via admin API)
- A browser or API client to read the expiry_at field and compute the UTC interval
// Manual Testing Ideas
- Create a subscription with purchase_at = 2024-03-10T05:00:00Z; read expiry_at and confirm it is 2024-03-11T05:00:00Z (86400 s from purchase), not 2024-03-11T04:00:00Z (82800 s)
- Repeat with purchase_at = 2024-03-08T05:00:00Z (a non-DST day) and confirm the expiry is exactly 86400 s later — this isolates the bug to the spring-forward date
- Test the fall-back transition: purchase on 2024-11-03T05:00:00Z (UTC) — which is midnight EDT, the fall-back day in Eastern — and confirm the expiry is 2024-11-04T06:00:00Z (86400 s) rather than 2024-11-04T05:00:00Z (90000 s = 25 hours from midnight EST)
- Inspect the subscription management code to confirm whether expiry uses purchase_utc + N × 86400 s or addDays(localDate(purchase), N) — the latter is the vulnerable form
// API Testing Ideas
- Create a test subscription with purchase_at = '2024-03-10T05:00:00Z' via the admin API or clock injection
- Send GET /api/subscriptions/me and read the expiry_at field
- Assert expiry_at is '2024-03-11T05:00:00Z' (exactly 86400 seconds from purchase)
- If expiry_at is '2024-03-11T04:00:00Z' (only 82800 seconds from purchase), the DST-shift bug is confirmed
- Repeat with purchase_at = '2024-03-08T05:00:00Z' (non-DST day) and assert expiry is also exactly 86400 s later — confirming the issue is specific to the spring-forward date
// Automation Idea
Using clock injection in a test environment, create a subscription with purchase_at = 2024-03-10T05:00:00Z. Read expiry_at via GET /api/subscriptions/me. Assert (expiry_at_epoch - purchase_at_epoch) === 86400. If the difference is 82800, the DST-shift bug is confirmed. Repeat with purchase_at = 2024-03-08T05:00:00Z and assert the difference is also 86400 — the non-DST control confirms the bug is date-specific.
// Expected Result
A 24-hour trial subscription purchased at 2024-03-10T05:00:00Z has an expiry_at of 2024-03-11T05:00:00Z — exactly 86400 seconds from purchase — regardless of DST transitions occurring within the trial period.
// Actual Result (Example)
GET /api/subscriptions/me returns expiry_at: '2024-03-11T04:00:00Z' for a subscription purchased at '2024-03-10T05:00:00Z'. The UTC interval between purchase and expiry is 82800 seconds (23 hours) instead of the expected 86400 (24 hours). The spring-forward transition on 2024-03-10 makes the local calendar day 23 hours long, and the expiry is computed using local calendar arithmetic rather than UTC seconds.
// REPORT IT
Example Bug Report
- Title
- 24-hour trial purchased at 2024-03-10T05:00:00Z (midnight EST) expires after 23 UTC hours due to DST spring-forward
- Severity
- Medium
- Environment
- Staging environment Postman Test user account Purchase timestamp: 2024-03-10T05:00:00Z (midnight EST on spring-forward day) Timezone: America/New_York
- Steps to Reproduce
- 01Create a test subscription with purchase_at = '2024-03-10T05:00:00Z' via the admin API
- 02Send GET /api/subscriptions/me and read the expiry_at field
- 03Compute the difference: expiry_at_epoch − purchase_at_epoch in seconds
- 04Compare the result against the expected 86400 seconds (24 hours)
- Expected Result
- expiry_at is '2024-03-11T05:00:00Z' — exactly 86400 seconds from purchase.
- Actual Result
- expiry_at is '2024-03-11T04:00:00Z' — only 82800 seconds (23 hours) from purchase. The spring-forward on 2024-03-10 causes the local calendar arithmetic to produce a 23-hour day, and the expiry is computed as midnight March 11 EDT rather than purchase_utc + 86400 s.
- Impact
- Users who purchase subscriptions on the DST spring-forward day receive one hour less access than the stated trial duration. The bug occurs once per year and only affects purchases made on the transition day. Affected users contacting support may receive credit or a free extension, increasing support overhead and billing complexity.