Nov 25, 2025·6 min read

Free trial expiry bugs: edge cases and clear rules

Free trial expiry bugs are usually caused by time math and unclear states. Learn clear rules, edge cases, and tests for grace periods and trial-ended behavior.

Free trial expiry bugs: edge cases and clear rules

Why free trials end at the wrong time

Free trials often break in ways that feel personal: a user gets blocked a day early, charged sooner than expected, or sees a “trial expired” banner while the app still lets them in. Support tickets follow fast: “Your timer is wrong,” “I signed up last night,” or “It ended during my demo.”

This happens because time is messy. People sign up in different time zones. Daylight Saving Time shifts clocks. Servers and databases store timestamps in different formats. Even “30 days” can mean different things if one part of your code counts calendar days and another counts hours. Add retries, background jobs, and caching, and you end up with multiple places calculating “is trial active?” in slightly different ways.

A common source of confusion is mixing two separate moments:

  • Trial ended: the free period is over (a billing decision point).
  • Access revoked: the user can no longer use paid features (a product decision point).

Those moments can be the same, but they don’t have to be. If you have a grace period, retries, or manual overrides, make them explicit rules, not accidental side effects.

The goal is simple: define one source of truth for trial timing (usually a stored trial_ends_at timestamp), and build repeatable tests around it. Tests should include tricky dates like month ends and DST changes, not just “now plus 7 days.”

The data you need to track (and what not to trust)

Most expiry bugs start with missing or fuzzy data. If you only store “trial = true” and a “trial days” number, every later decision becomes a guess.

Store a small set of timestamps that let you answer one question: what is the user allowed to do right now?

At minimum, keep:

  • trial_started_at: when the trial actually began (after successful signup, not after page load)
  • trial_ends_at: the exact moment trial access stops
  • canceled_at: when the user canceled (if you support canceling during trial)
  • paid_at: when the first successful payment happened (not when you created a checkout session)
  • ended_at: when you marked the trial as ended (useful for audits and replays)

Save these as full timestamps in UTC. UTC removes daylight saving surprises and avoids “midnight” bugs when a user travels or a server runs in a different region. Convert to local time only for display.

Don’t trust device time for enforcement. Phones can be wrong, users can change clocks, and browser time can drift. Use server time (or database time) as the source of “now,” and base comparisons on that single clock.

Also avoid mixing “date only” fields with real timestamps. “2026-01-20” sounds simple, but it hides an implied time zone and a cutoff you haven’t defined. Is it midnight at the user’s location, midnight UTC, or end of day? That ambiguity is a frequent cause of expiry bugs.

Simple rule: if access depends on it, store an exact UTC timestamp.

Time calculation rules that avoid off-by-one errors

Most expiry bugs happen because the product team means one thing (“two weeks”) and the code does another (“until the next midnight”). Fix it by writing one clear rule, then making every screen and job use that same rule.

First, decide what a trial is.

If you want the simplest, most predictable behavior, use a fixed duration: 14 x 24 hours from trial_started_at. This avoids debates about what “midnight” means.

If you truly need a date-based trial (“valid until a specific calendar date”), it can work, but only if you define the time zone up front (customer time zone, or a single billing time zone) and never guess.

Next, define the boundary as exclusive, not inclusive. A clean rule is: the trial is valid while now < trial_ends_at. At the instant now == trial_ends_at, the trial is over. That removes edge cases where two systems disagree by a second.

Be careful with rounding. Bugs show up when one place stores seconds, another truncates to minutes, and a third displays days. Treat timestamps as precise, and only round for display.

Finally, document what “midnight” means for your business. If you use local midnight anywhere, you must define which locale and how DST shifts affect it. Many teams avoid that by doing all storage and comparisons in UTC.

Grace periods: choose one simple policy and stick to it

A grace period is a buffer after access would normally end. It helps reduce accidental lockouts and gives billing systems time to retry payments without generating a wave of support tickets.

Where teams get into trouble is accidentally running two different grace periods at once. Pick one trigger and make it the only trigger.

Pick one start time

Choose one:

  • Start grace at trial_ends_at.
  • Start grace after the first failed payment attempt.

Then write the rule in one sentence and keep it stable, for example: “Grace starts at trial_ends_at and lasts 72 hours.” Store the duration (or computed grace end) so changing policy later doesn’t rewrite history.

Decide what access means during grace

Avoid “kind of works” access. Choose one clear mode that product and support can explain. Full access is simplest but risky. Read-only is common for dashboards. A limited mode (for example, viewing allowed but exporting blocked) is often a good compromise.

Support overrides are another place policies quietly break. If you allow extensions, treat them as explicit events: who approved it, how long, why, and what the new end time is. Log it alongside billing events so audits and tests can replay it.

Trial-ended states: make them explicit, not implied

Make trials end on time
Turn AI-generated subscription code into one tested source of truth for access decisions.

A lot of bugs happen when “trial ended” is treated like a guess: if now > trial_ends_at, the system assumes the account is no longer trialing. That works until you add grace periods, failed payments, upgrades, refunds, or manual fixes.

Instead, store an explicit subscription state that your system can reason about. Keep it small and boring:

  • trialing
  • active
  • past_due
  • canceled
  • expired

Then define allowed transitions and what triggers them. For example:

  • trialing -> active when the first payment succeeds
  • trialing -> expired when trial_ends_at is reached and there’s no conversion
  • active -> past_due when a renewal charge fails
  • past_due -> active when payment later succeeds
  • active -> canceled when the user cancels

Reactivation is where implied logic creates weird leftovers. If you allow expired -> active (upgrade after trial), treat it like a clean start: set state=active, record a new paid period end (such as current_period_ends_at), and clear or ignore trial-only fields. Otherwise you end up with “active” users still getting trial-ended locks.

Finally, make one place in code decide access based on state + timestamps. Don’t spread access checks across controllers, UI, and background jobs. Keep a single policy function that every surface calls.

Edge cases that break expiry logic

Most bugs appear when real time gets messy. A few rules cover almost all cases, as long as you apply them consistently.

  • Store all trial timestamps in UTC and treat them as the source of truth. Convert to a user time zone only for display.
  • Don’t convert back and forth during calculations. That’s how a trial “moves” by an hour depending on where code runs.

Daylight Saving Time is the classic trap. A “14 day trial” is not always the same as “336 hours” once you involve local time. Decide what you promise and encode it: either “expires after N x 24 hours in UTC” or “expires at the same local clock time after N calendar days.” Pick one, then test the DST weekends.

Month ends and leap days cause similar surprises when people mix calendar math with duration math. “Add 1 month” from Jan 31 can become Feb 28/29, then March 28/29, which feels wrong if you expected “end of month.” If your trial is measured in days, avoid months entirely.

Operational issues can also break expiry:

  • Clock skew: never use device time for enforcement.
  • Server drift: rely on one time source for all services.
  • Late jobs: queued tasks can run late, so checks should be idempotent.
  • Retries: duplicate “trial ended” events shouldn’t double-charge.

Cancellation, upgrades, and plan changes during a trial

Expiry logic usually breaks when “normal” time rules collide with user actions. If you don’t define rules for cancellation and plan changes, the app will guess, and different screens will guess differently.

If a user cancels during the trial

Pick one policy and make it explicit:

  • Cancel now, keep access until trial_ends_at.
  • Cancel now, end access immediately.

“End of day” sounds simple, but it tends to create time zone fights. If you do use it, you must define which time zone and what “end of day” means.

Whatever you choose, store it as data. For example: keep trial_ends_at unchanged, set canceled_at, set a flag like will_renew=false, and let access checks read those fields. Add a test that cancels 1 minute before trial end and confirm the same result across API and UI.

Upgrades, downgrades, and plan changes

Upgrades are where money and time meet, so decide whether an upgrade starts billing immediately or waits until the trial ends.

A clean rule set looks like this:

  • Upgrade during trial: either charge immediately and set trial_ends_at=null, or schedule the paid plan to start at trial_ends_at.
  • Downgrade during trial: don’t reset the trial clock; only change what happens after the trial.
  • Switching plans: don’t recompute trial_ends_at from “now” unless you’re intentionally granting more time.

If you charge at trial end, plan for refunds and chargebacks. The safest approach is separating “trial ended” from “payment succeeded.” A trial can end, a payment can fail, and access rules should handle that without bouncing users into random states.

Step-by-step: implement expiry with one source of truth

Align UI and backend gating
If UI and API disagree on trial status, we will trace the split logic and fix it.

Different parts of an app often “decide” trial status independently: a page checks one timestamp, a webhook checks another, and billing uses a third rule. Fix that by writing the rules once, then making everything call the same decision.

Start by writing the policy in plain English with concrete timestamps, time zones, and outcomes. Example: “A 7-day trial that starts 2026-01-20 10:15:00Z ends at 2026-01-27 10:15:00Z. Access is allowed until the exact end instant, then denied.” If you allow a grace period, specify it with the same precision.

A practical sequence:

  1. Define the policy with a few real examples (include one near midnight and one near DST change).
  2. Implement one decision function that returns can_access plus a reason string.
  3. When the trial is created, compute and store a single trial_ends_at value in UTC. Don’t recalculate it in views, webhooks, and background jobs.
  4. Enforce state changes in one place: either a scheduled job that marks trials as ended, or an on-request check that updates state when a user loads the app. Pick one approach and stick to it.
  5. Log each decision with inputs and output (user id, now, stored timestamps, decision). This makes disputes and debugging much faster.

Common mistakes that create expiry bugs

Most expiry bugs aren’t “math problems.” They come from having more than one place deciding whether a user should have access.

Client-side timers are a classic failure. If the UI uses the device clock, users can change time settings, cross time zones, or hit DST shifts and get extra hours (or lose them). Enforcement belongs on the server, using a single time source.

Split logic is just as painful: the UI says “trial active” while the API blocks requests, or the API allows access while the UI shows a paywall. This usually happens when checks were added in different places over time.

Other common gotchas:

  • Comparing dates as strings ("2026-1-2" vs "2026-01-02")
  • Mixing milliseconds and seconds between services
  • Rounding timestamps to midnight without agreeing on a time zone
  • Treating trial_ends_at as inclusive in one place and exclusive in another

Watch for accidental trial extensions too. It’s easy to reset trial_ends_at on login, refresh, or pricing page visits, especially when trial setup runs from multiple screens.

Webhooks add their own mess. Payment events can arrive late, retry, or show up out of order. Make handlers idempotent, store the latest event time you processed, and base access on your stored subscription state, not “whatever webhook arrived last.”

Quick checklist before you ship

Consolidate access checks
We will find scattered access checks and replace them with a single policy function.

Most bugs show up only after launch, when real users hit odd timing and retry patterns. Before shipping, confirm these are true in your codebase and your tests:

  • trial_ends_at is stored in UTC and doesn’t change after creation unless you explicitly extend it.
  • Every comparison uses the same rule everywhere (for example: access allowed while now < trial_ends_at).
  • Grace behavior is consistent across UI, API, and billing enforcement.
  • Late events don’t roll state backwards.
  • Logs show the exact timestamps used for each access decision (now, stored end times, resulting state).

A quick sanity scenario

Create a test user whose trial ends at 2026-01-20T00:00:00Z. Verify access 1 second before, exactly at, and 1 second after. Repeat through each path that can gate access: API request, scheduled expiration job, and any webhook handler.

Example scenario and next steps

Walk through one messy timeline and write down what the user should see at each step.

A user signs up Friday at 11:30 PM in a region where DST starts on Sunday (clocks jump forward). On signup, set trial_started_at to the exact signup timestamp and set trial_ends_at to trial_started_at + duration.

Saturday: the user is trialing and has access. The UI should show time remaining based on trial_ends_at, not calendar-day shortcuts.

Sunday: DST shifts. The user is still trialing. Nothing changes in the backend. Only display changes.

Monday 11:35 PM: the user opens the app and tries to pay.

If you offer a 24-hour grace period after the trial ends, the expected behavior should read the same everywhere:

  • Until trial_ends_at: trial access
  • From trial_ends_at to trial_ends_at + grace: grace access (whatever you documented)
  • After grace ends with no payment: expired and blocked (except billing)
  • After a successful payment at any point: active

The tests for this scenario should exist before you ship: trial end across a DST change, boundary checks (one second before/at/after), payment success and failure paths, and UI/API agreement on state.

If your subscription logic was generated quickly and now behaves inconsistently, a short audit often finds the root cause: mixed time zones, scattered access checks, or multiple competing definitions of “trial active.” FixMyMess (fixmymess.ai) focuses on turning brittle AI-built prototypes into production-ready systems by consolidating time and state logic into one tested decision point.

FAQ

Why did a user’s free trial end a day early?

This is usually a time zone or rounding mismatch. One part of the system may be counting “calendar days” (ending at a midnight boundary) while another counts “hours since signup,” so users near midnight lose almost a full day. Fix it by storing a single trial_ends_at timestamp in UTC and enforcing access with the same comparison everywhere.

What timestamps should I store to avoid trial expiry bugs?

Store full UTC timestamps that let you answer access questions without guessing: trial_started_at, trial_ends_at, paid_at, canceled_at, and an audit field like ended_at if you need a record of when you marked it ended. Avoid relying on a boolean like trial=true plus “trial_days,” because every later check will recalculate differently.

How should I compute `trial_ends_at` for a 7-day or 14-day trial?

Compute it once at the moment the trial truly starts (after successful signup, not page load). For a duration-based trial, set trial_ends_at = trial_started_at + (N * 24 hours) in UTC and persist it. Don’t recompute it in the UI, webhooks, or background jobs.

Should `trial_ends_at` be inclusive or exclusive?

Use an exclusive boundary: allow access while now < trial_ends_at. The instant now == trial_ends_at, the trial is over. This removes “same second” disagreements between services and avoids edge cases where one system treats the end moment as still valid.

Can I enforce trial expiry using the browser or phone time?

Enforce on the server using one trusted clock (server time or database time). Device clocks drift, users can change them, and browsers may be in a different time zone than your backend. You can still show a countdown in the UI, but the API should be the final authority.

How do I add a grace period without creating confusing access rules?

Pick one simple policy and write it in one sentence, then implement it in one place. A common default is: “Grace starts at trial_ends_at and lasts 72 hours,” with clearly defined access during grace (full access, read-only, or a limited mode). Store the grace rule or derived end time so later policy changes don’t rewrite old accounts.

What’s the safest cancellation policy during a trial?

Choose one rule and make it consistent across UI and API. The least surprising option is “cancel now, keep access until trial_ends_at,” while setting canceled_at and will_renew=false (or equivalent) so billing won’t start. If you end access immediately on cancel, make that explicit and test cancellations near the trial end boundary.

How do I handle late or duplicate billing webhooks without breaking access?

Make webhook handling idempotent and state-based. Payment events can arrive late, retry, or come out of order, so don’t let “last webhook wins” decide access. Instead, store subscription state (trialing, active, past_due, expired, canceled) plus timestamps, and have webhooks update that state only when the transition is valid.

What tests catch the most common trial expiry edge cases?

Test the boundaries and the weird dates, not just “now + 7 days.” Add tests for one second before/at/after trial_ends_at, month ends, leap day, and the DST weekends in relevant time zones. Also test that UI and API agree on the same account state for the same stored timestamps.

When should I bring in FixMyMess to fix trial and subscription logic?

It’s time to audit when you see contradictory behavior: the UI says “trial active” but the API blocks, different endpoints calculate expiry differently, or trials shift by an hour around DST. FixMyMess can run a free code audit to find scattered access checks, mixed time units, and time zone leaks, then consolidate everything into a single tested decision point so trials end exactly when you intend.