We added a 14-day TEAM_PRO trial. The "start a trial" button was an afternoon. The part that actually needed engineering was the opposite end: making the trial end correctly. Trials are easy to begin and surprisingly easy to get wrong when they expire.
TL;DR: A daily scheduler finds teams whose trial has ended and downgrades them (TRIAL → EXPIRED_TRIAL, TEAM_PRO → FREE), emailing the billing contact. The details that matter: run at a fixed UTC time so "day 14" is unambiguous, preserve the trial timestamps as the audit trail and re-trial guard, and use a Postgres advisory lock so multiple app instances don't double-process.
The Problem
"Downgrade expired trials" sounds like a one-liner. It hides at least four decisions:
- When, exactly? "14 days" from a timestamp is fuzzy across timezones. When does the downgrade actually run?
- What survives the downgrade? If you wipe the trial dates, you lose the audit trail — and your ability to stop someone re-trialing forever.
- Who runs it? With more than one app instance, a naive
@Scheduledjob runs on every pod, double-emailing and racing. - What if the email fails? You don't want a flaky SMTP call to roll back the downgrade.
When: Pin It to UTC
We run at a fixed wall-clock time in UTC, so "day 14 + 04:00 UTC" is a single, unambiguous moment regardless of where the server or the customer is:
@Scheduled(cron = "0 0 4 * * *", zone = "UTC")
public void enforceTrialExpiry() { ... }
Every @Scheduled job in the app is UTC-pinned for the same reason — a scheduler that drifts with a server's local timezone is a scheduler that fires at a different real time when you change regions.
Who: One Pod Wins the Advisory Lock
To keep the job single-run across instances without extra infra, we wrap it in a Postgres transaction-scoped advisory lock. Whichever pod grabs the lock does the work; the others no-op and move on:
Boolean acquired = (Boolean) entityManager
.createNativeQuery("SELECT pg_try_advisory_xact_lock(:lockId)")
.setParameter("lockId", LOCK_ID) // unique per job type
.getSingleResult();
if (!Boolean.TRUE.equals(acquired)) {
log.debug("Skipped — another instance holds the lock");
return List.of();
}
pg_try_advisory_xact_lock is non-blocking and auto-releases when the transaction ends — no cleanup, no stuck locks if a pod dies mid-run. Each scheduler owns a distinct lock ID from a documented namespace, so two different jobs never collide on the same number.
What Survives: Keep the Trial Timestamps
The downgrade flips status and tier — but deliberately preserves trialStartTime and trialEndTime:
- They're the audit trail: when did this team trial, and when did it lapse?
- They're the re-trial guard: without a record that a team already used its trial, nothing stops it from starting a fresh one every two weeks.
Only the conversion path (an actual paid checkout completing) clears them. Expiry leaves them intact.
What If Email Fails: Commit First, Notify After
The transaction does the state change and returns the list of who to email. The emails go out after the transaction commits — so a flaky mail server can't roll back a downgrade that already, correctly, happened:
List<TrialExpiredNotification> notifications = transactionTemplate.execute(status -> {
// acquire lock, downgrade expired trials, collect recipients
return downgradeExpiredTrials(now);
});
if (notifications != null && !notifications.isEmpty()) {
sendTrialExpiredNotifications(notifications); // best-effort, post-commit
}
The recipient falls back sensibly too — the team's billing contact, or the owner's account email if none is set.
Why a Separate Scheduler From User Subscriptions
We already had a subscription-expiry scheduler for individual user plans. Team trials get their own scheduler on purpose: user and team subscription lifecycles are separate domains with separate rules, and conflating them into one job would tangle two sets of edge cases. Same lock-and-batch pattern, different domain — copied structure, not copied responsibility.
Make Expiry Idempotent and Re-Runnable
A scheduled job will, eventually, run twice — a retry, a manual trigger, an overlap with a deploy. So the downgrade has to be safe to repeat. The query that finds work should only select teams still in the trial state; once a team is flipped to expired, it no longer matches, so a second run is a no-op for it. That's what makes the job re-runnable without fear: if it dies halfway through the batch, you just run it again and it picks up exactly the teams it hasn't processed yet. Pair that with the advisory lock (so two instances don't process the same batch concurrently) and you've got a job that's both single-run per tick and safe across ticks.
Don't Forget the Human on the Other End
The mechanics are only half the feature — the other half is that a customer's plan just changed under them. A silent downgrade is how you get an angry "why did my team lose access?" ticket. So the notification isn't an afterthought; it's part of the contract. Email the billing contact when the trial ends, and — ideally — warn before it ends too, so the downgrade is never a surprise. We show a countdown in-app as the trial winds down for the same reason. The scheduler is the enforcement; the communication around it is what keeps the enforcement from feeling like a trap.
Lessons Learned
- The end of a trial is the hard part. Starting is a flag; ending needs timing, audit, idempotency, and failure isolation.
- Pin schedulers to UTC. "Day 14" has to mean one moment. Local-time cron drifts when your infra moves.
- Advisory locks give you single-run for free.
pg_try_advisory_xact_lockneeds no extra infra and self-releases — ideal for multi-pod@Scheduledjobs. - Commit the state change, then notify. Never let a best-effort email roll back a business-critical downgrade.
How do you handle trial expiry in your SaaS? Share your approach in the comments.
Building jo4.io - a URL shortener with team workspaces, trials, and analytics.