Add a Free Trial That Expires to an AI App
A free trial that expires is mostly edge cases. Here is the data model, the three bugs that break trials in production, and the prompts to build it.
A free trial that expires looks like one date field and turns out to be three bugs. The happy path is trivial: store an end date, compare it to now, lock the account. What breaks in production is month-end arithmetic, what happens when the trial ends and no card is on file, and the gap between when you think the customer was warned and when they actually were. Build those three deliberately and the feature is done.
This walkthrough uses Stripe as the billing layer, because its documented behaviour makes the edge cases concrete, but the model is the same wherever you put the dates.
The data model for a free trial that expires
Four fields on the account, and resist adding more.
Field | Type | Why |
|---|---|---|
| timestamptz | Audit, and the anchor for the end date |
| timestamptz | The single source of truth for access |
| enum |
|
| boolean | Blocks a second free trial on the same account |
Two deliberate choices. Store an absolute timestamp rather than a day count, because a count has to be recomputed against a start date every time and the recomputation is where the arithmetic bugs live. And keep had_trial separate from trial_outcome, because a cancelled trial is still a used trial and you want one flag that answers "has this account ever had one" without parsing states.
Access is then one comparison. Resist the urge to spread trial logic across the codebase: a single function that returns active, trialing or locked, called everywhere, is the difference between a feature and a long afternoon.
Bug one: month-end arithmetic
"One month from today" has no single correct answer, and the answer you pick shows up as a support ticket. Stripe's trials documentation states that a trial is implicitly anchored at its start, and works two examples: a two-month trial starting on 28 February ends on 28 April, not 30 April, and a two-month trial starting on 31 January ends on 31 March.
So the rule is anchor-preserving, not end-of-month-preserving. That is almost certainly what you want, and it is almost certainly not what a naive implementation does. The trap is a 30-day trial started on 31 January: adding one month lands on a date that does not exist, and whatever your date library does next is a decision you did not make on purpose.
Two things to get right. Do the arithmetic in one place, in UTC, using a library that documents its month-overflow behaviour. And if you promise "14 days", use days rather than a half-month, because day counts have no ambiguity at all. Most of the pain here comes from marketing copy saying "a month" when the code could just as easily say 30 days.
Trials are also the first place a timezone bug becomes visible to a paying customer, since a trial that ends "on the 14th" ends at a specific instant that is the 13th for somebody. The general treatment is in how to handle time zones in an AI-built app.
Bug two: the trial ends and there is no card
This is the decision people skip, and it is a real product choice with three defensible answers. Stripe exposes it as a setting, trial_settings.end_behavior.missing_payment_method, with these options:
Setting | What happens | Use when |
|---|---|---|
| An invoice is created; if it cannot be paid the subscription moves to | You want to chase payment |
| The subscription is cancelled | Self-serve product, clean break |
| The subscription pauses until you resume it | You want the data kept and the account recoverable |
The default is the one least likely to match a no-card-required trial. Leaving it at create_invoice for a trial where you never collected a card produces accounts sitting in past_due with an unpayable invoice, which pollutes your billing reporting and sends your customer dunning email about money they never agreed to owe.
For most self-serve products pause is the better default, because it keeps the user's data and lets them come back with a card later without any support involvement. Then handle the matching events: customer.subscription.paused is your cue to revoke access, and customer.subscription.resumed to grant it back. Note also that Stripe documents prebilling as overriding this setting when it extends past the trial end, which is the kind of interaction worth knowing exists before you debug it.
If you do decide to chase payment, the failure handling is its own small project, covered in how to handle failed payments in an AI-built app.
Bug three: the reminder nobody got
Here is a mismatch worth writing on a wall. Stripe's customer.subscription.trial_will_end webhook fires according to a dashboard setting that defaults to 3 days before the trial ends, while Stripe's own hosted reminder emails are sent 7 days before. Those are different numbers, and if you build your reminder on the webhook while assuming the vendor email covers the earlier warning, your customer may get two messages four days apart, or one, depending on configuration you have not checked.
The documentation also notes that if the trial is shorter than the configured interval, the event fires immediately. On a 7-day trial with a 3-day setting that is fine; on a 2-day trial it means the "your trial is ending" email arrives essentially at signup, which reads as a bug to the recipient.
Decide the schedule yourself and own it end to end. A workable default for a 14-day trial is a message on day 7, day 12 and the morning after expiry, with the last one being the most valuable and the one teams most often omit. Drive them from your own trial_ends_at on a scheduled job rather than from vendor events, and use vendor events only to correct your state.
The bill after the trial, which surprises people
One more documented behaviour worth knowing, because it determines what the first invoice says. Stripe exposes trial_settings.end_behavior.billing_cycle_anchor with two values. The default, now, resets the billing cycle to the moment the trial completes and generates a full-amount invoice with no proration. The alternative, unchanged, keeps the original anchor from when the subscription was created and bills the gap between trial end and the next natural anchor date as a prorated amount.
The documentation works it through: a subscription created on 1 January with a 7-day trial ends the trial on 8 January, the anchor resets to 8 January, and the invoice covers 8 January to 8 February at the full price. That is almost always what a customer expects, which is why the default is the right one for a straightforward monthly product.
Pick unchanged only when you need every customer billed on the same date, for example when invoices are reconciled against a monthly finance close. The cost is a small prorated first invoice, which generates more billing questions than it saves. Either way, decide it rather than inherit it, and make sure the number your pricing page promises is the number on that first invoice. The general principles for amounts and currency are in how to handle money in an AI-built app.
The prompt sequence
Built in this order, each step is verifiable before the next one depends on it.
1. Data model.
"Add trial_started_at, trial_ends_at, trial_outcome (enum: active, converted,
expired, cancelled) and had_trial to the accounts table. Write a single
function trialStatus(account) that returns 'active' | 'trialing' | 'locked'.
Compute everything in UTC. Add a unit test for a 30-day trial starting
31 January and one starting 28 February."
2. The gate.
"Use trialStatus() in exactly one middleware. No other code may read
trial_ends_at to decide access. Show me every call site when you are done."
3. End behaviour.
"When a trial ends with no payment method, pause rather than invoice.
On customer.subscription.paused revoke access; on .resumed restore it.
Do not delete any user data."
4. Reminders.
"A scheduled job reads trial_ends_at and sends at day 7, day 12 and the
morning after expiry. Record each send so a retry cannot double-send."
5. The re-signup check.
"Block a second trial where had_trial is true, matched on verified email,
not on the raw signup string."Step five is the one agents skip unless asked. Without it, a trial is a free plan with extra steps, because the same person signs up again with a dot in their Gmail address. Match on the verified, normalised email, and accept that determined abuse needs payment-method matching rather than email matching.
Once the trial works, the conversion path is a separate build; the mechanics are in how to add a subscription paywall to an AI-built app, and the prior question of whether a trial is even the right model for your product is argued in does an AI app need a free tier.
Testing it without waiting two weeks
Make the trial length a configuration value, not a constant, so a staging account can have a 10-minute trial. Then test the four transitions you actually care about: trial to converted, trial to expired with no card, expired to converted after the fact, and the reminder job running twice in a row without sending twice. That last one catches the double-send bug, which is the one your customers notice most.
For the wider build, the step-by-step version sits in how to build an app with AI.
Frequently asked questions
Should I ask for a card up front?
Asking for a card raises trial-to-paid conversion and lowers trial signups, and which way that nets out depends on your price and how obvious your value is in a week. The engineering difference is smaller than the funnel difference: with a card on file, the no-payment-method branch simply never fires.
What status does a subscription have during a free trial?
Stripe documents that where a subscription contains only trial items priced at zero, the status is trialing. Once a non-zero price is involved the subscription follows the normal active, incomplete or past_due lifecycle, which matters if your access check branches on status.
Can I extend someone's trial?
In your own data, yes, by moving trial_ends_at, and that is the reason to treat your database rather than the billing provider as the source of truth for access. Note that Stripe documents trial length as not modifiable after the subscription is created, so an extension is your logic, not theirs.
What should the app look like after expiry?
Read-only beats a wall. Let people see their data and export it, block the actions that cost you money, and keep the upgrade path one click away. A hard lock produces support tickets and chargebacks; a read-only state produces conversions.
How did this land?
About the author

Developer Advocate
Steve builds something with Swarmz every week and writes up what worked, what broke, and what he'd do differently. Tutorials and hands-on guides are his lane.


