Recurring plans, trials, and scheduling: why time is your enemy
Plan active/expire/renew semantics, timezone edge cases, and trial-to-paid transitions. How to get scheduling right in a credits system.
Recurring plans and trials are at the heart of SaaS: a plan starts at a given time, renews on a schedule, and may transition from trial to paid or from one tier to another.
Getting that right in a credits system is deceptively hard. "Monthly renewal" sounds simple until you confront:
- Time zones
- Daylight saving time
- End-of-period semantics
- Scheduled future state changes (e.g., "at the end of this billing period, switch to the new plan")
This article walks through why time is your enemy in plan scheduling, what can go wrong, and how to design so renewals, trials, and plan changes behave correctly at scale.
What "recurring" actually means
A recurring plan has at least three components:
- Start: When the plan became active (e.g., signup, upgrade)
- End or next renewal: When the current period ends and the next one begins (or when the plan cancels)
- Period: The length of a cycle (e.g., 1 month, 1 year)
Critical questions to answer
From these components, you need to derive:
- "Is the plan active right now?"
- "When do we grant the next allowance?"
- "When does the trial end?"
If any of those are wrong—due to timezone issues, off-by-one errors, or "midnight" boundary confusion—customers get the wrong credits at the wrong time. Support gets flooded with "why didn't my plan renew?" tickets.
The first requirement is a clear, consistent model of plan windows: for each account (or subscription), you store or compute a current window (start, end) and use that for "is active?" and "when to run renewal?"
Time zones and "midnight"
"Renew at the start of each month" is ambiguous. Start of month in whose timezone?
- UTC?
- The customer's?
- The server's?
The timezone trap
If you store "renewal at 2025-02-01 00:00:00" without a timezone, you've already introduced bugs. A user in Tokyo and a user in New York have different "local midnights," and a cron job that runs at 00:00 UTC will execute at different local times for each.
The solution: pick one canonical timezone
The safest approach is to pick one canonical timezone (often UTC) for all internal scheduling and store timestamps in that timezone.
- Display "renewal date" in the user's timezone in the UI
- Don't use local midnight for business logic
Per-account timezones (advanced)
If you need "renew at the start of the customer's local month," you must:
- Store per-account timezone
- Compute the next renewal in that timezone
- Convert to UTC for job scheduling
This is doable, but easy to get wrong. Many products simplify: "renewal is at 00:00 UTC on the 1st" and show that date in the user's locale. Consistency in the backend avoids a whole class of "it worked for me" bugs.
Daylight saving and calendar edges
Even with UTC, calendar boundaries can cause problems.
Calendar arithmetic is tricky
- "Last day of month" and "first day of month" depend on the calendar (February, leap years)
- "30 days from now" ≠ "next month"
- "Jan 31 + 1 month" could be Feb 28, Feb 29, or even Mar 2 depending on your date library
Define your semantics
For trials, "14 days" should usually mean 14 × 24 hours from start, not "until midnight in 14 days."
Define your semantics explicitly and test edge cases:
- Month-end dates
- DST transitions (if you ever use local time)
- Leap years
Store absolute timestamps
A robust approach is to store next renewal as an absolute timestamp (in UTC) and have a job that:
- Runs periodically
- Finds accounts where
renewal_time <= now - Performs the renewal
- Computes and stores the next renewal timestamp
This way you're not recomputing "next month" in a vacuum; you're always working from a concrete next-run time.
Trial to paid and plan changes
Trials add another dimension: "plan is trial until date X; at X, switch to paid (or cancel)." You need to schedule that transition.
Two approaches to scheduling
- Time-based job: Run a job that wakes up at X and applies the transition
- Polling worker: Run a job frequently that processes "scheduled transitions where transition_time <= now"
Atomic transitions
The transition itself must be atomic from the user's perspective. At the moment the trial ends, they're either:
- On the paid plan (with the right allowance), or
- Cancelled
The scheduling table pattern
This usually means a small scheduling table with:
account_idaction(e.g., "trial_to_paid" or "plan_change")effective_at(timestamp)payload(optional: new_plan_id, etc.)
A worker processes due rows, applies the change, and marks them done. If the worker fails, you need idempotency and retries so you don't double-apply or skip.
The same pattern applies to:
- "At end of billing period, downgrade"
- "At end of period, cancel"
Scheduling is the engine that makes "at time T, do X" reliable.
Cancel at period end
"Cancel at period end" means: the user stays on the plan until the current period ends; then the plan (or subscription) ends.
The state distinction
You need to distinguish:
- "Active"
- "Active but cancelled at period end"
Many systems store:
cancels_atorcurrent_period_end- A flag like
cancel_at_period_end
The logic
- If
now < current_period_end, they still have access - When the period ends, a job runs and actually deactivates the plan or stops renewal
Reliability is critical
That job must run reliably and handle failures. If it doesn't run (e.g., cron drift, deployment window), the customer might:
- Keep access past the period end, or
- Lose access too early
Scheduling and cron design are critical for getting this right.
Summary and recommendations
Recurring plans and trials require:
- A clear model of plan windows (start, end, period) and renewal timestamps, preferably in UTC
- Consistent timezone handling so "midnight" and "start of month" don't vary with user location
- Explicit handling of calendar and DST edge cases so renewals and trials land on the right day
- A scheduling mechanism (scheduled jobs or a transition table) for trial-to-paid and plan changes, with idempotency and retries
- Cancel-at-period-end implemented as a scheduled transition, not as a one-off manual step
Build vs. buy
Getting this right in-house is a significant chunk of work. If you'd rather focus on your core product, a credits and plans API that handles scheduling and renewals for you can remove this entire class of complexity.
credits.dev supports plans, scheduling, and renewals, letting you offer trials and recurring plans without building and maintaining the timing logic yourself.