Trials look like a small feature and produce a disproportionate number of billing bugs, because they introduce a subscription state where the customer has full access and has paid nothing.
The three failures, in the order teams hit them:
- Access is gated on
status === "active", so trialing customers are locked out of the product they just signed up for. - Access is gated on "a subscription row exists", so a customer whose trial ended without a payment method keeps everything, forever.
- One person creates ten accounts and takes ten trials.
Where a trial is configured
Three places, in increasing order of preference:
On the Checkout Session, as subscription_data.trial_period_days. Checkout
takes the trial from the session, so this is the setting that decides it. In
this project the length lives next to the price it belongs to, in the catalogue
(src/lib/pricing.ts):
{ id: "pro-monthly", interval: "month", amount: 1900, trialDays: 7 },
and the Stripe adapter's createCheckout passes it on:
subscription_data: {
billing_mode: { type: "flexible" },
metadata: input.metadata,
...(input.price.trialDays ? { trial_period_days: input.price.trialDays } : {}),
},
The pricing card reads the same field ("Start 7-day free trial"), so the page and the checkout cannot disagree.
Never as a stray constant. const TRIAL_DAYS = 14 in some config file is
the same class of mistake as a hardcoded price: it drifts from what the pricing
page promises and from what Stripe actually did. One field, on the price, read
by both.
Two useful options on subscription_data:
trial_settings.end_behavior.missing_payment_method:cancel,pauseorcreate_invoice. This is what happens when the trial ends and there is no card on file. Choose deliberately.payment_method_collection: "if_required"on the Checkout Session lets someone start a trial without entering a card at all. Higher conversion into the trial, much lower conversion out of it. Know which you are optimising.
The entitlement check
trialing is an entitled status. So is active. So, usually, is past_due
(for a grace period), and that is a product decision, not a technical one. This
project grants access on all three.
export const ENTITLED_SUBSCRIPTION_STATUSES = ["trialing", "active", "past_due"] as const;
export function isEntitled(subscription: Pick<BillingSubscription, "status"> | null): boolean {
return subscription !== null && isEntitledStatus(subscription.status);
}
Write it once, in one module, and gate everything on that function. The bug this
prevents is not subtle: six components each doing
subscription?.status === "active" means six places that lock trialing customers
out, and you will find five of them.
The full status set is worth knowing, because collapsing it early loses information you need:
| Status | Access | Meaning |
|---|---|---|
trialing | yes | In trial, no payment taken yet |
active | yes | Paid and current |
past_due | your call | A payment failed; Stripe is retrying |
unpaid | no | Retries exhausted |
canceled | no | Over |
incomplete | no | First payment never completed (3DS abandoned) |
incomplete_expired | no | …and the window closed |
paused | no | Trial ended with no payment method, end_behavior: pause |
Store the status, not a boolean. Deriving hasAccess at write time throws away
the difference between "retrying a payment" and "gone", and those need different
UI. This project stores a normalized status (the same words for every payments
provider, incomplete_expired becomes expired) and Stripe's own word in
provider_status.
The events that matter
customer.subscription.createdwithstatus: trialing: the trial began.customer.subscription.updated: the workhorse. The trial→active transition, the trial→canceled transition, and every plan change all arrive as this. Re-read the subscription and upsert; do not try to infer what changed.customer.subscription.trial_will_endfires about 3 days before the trial ends. This is a marketing signal, not a billing one. Send the "your trial ends Friday" email here. Do not change entitlements on it: the trial has not ended, and acting on it early is how you cut someone off three days early.invoice.payment_failedright after a trial: the card on file was declined at conversion. The subscription goespast_due, and this is the single highest-value dunning email you will ever send.
The handler for all of these is the same shape, and that is the point:
case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted":
return subscriptionEvents(event.data.object.id); // re-read, then map
Re-fetch and upsert. Out-of-order delivery (routine around trial conversion, because several objects change within the same second) stops mattering.
trial_will_end is not in this project's handled events. Add it to
HANDLED_EVENTS in src/lib/billing/provider.ts and to the dashboard endpoint
when you want the reminder email.
Cancelling during a trial
A customer who cancels at period end in the portal mid-trial keeps access
until the trial ends and is never charged. On a flexible billing mode
subscription (every one this repo's checkout creates) Stripe records that as
cancel_at and leaves trial_end where it was, so the row still says
trialing with a scheduled end. /billing reads both through scheduledEnd() and says the trial ends
without renewing, instead of promising a conversion that will not happen.
Do not compute trial state yourself
// don't
const inTrial = subscription.trialEnd && subscription.trialEnd > new Date();
Server clocks drift, trials get extended from the dashboard, and Stripe's own
status already answers the question. Keep trial_end in the projection for
display ("7 days left") and gate access on status.
Stopping trial farming
Stripe does not do this for you. The options, roughly in order of effectiveness per unit of annoyance:
- Require a card to start the trial. Filters most casual abuse and improves conversion, at the cost of top-of-funnel.
- Record which of your own users has trialled. A boolean on the user, or a
query over
billing_subscriptionsfor any prior row with atrial_end, checked before the trial is attached (in this project,createCheckoutinsrc/lib/billing/provider.ts). Cheap and effective for same-account retries. - Block disposable email domains at signup.
- Fingerprint the payment method. Stripe exposes a
fingerprinton card payment methods that is stable across customers for the same physical card. Refusing a second trial on the same fingerprint stops the determined case. Be careful: shared corporate cards exist, so make it a flag for review rather than a hard block if your customers are businesses.
Whatever you pick, enforce it server-side where the Checkout Session is created. A check in the UI is a suggestion.
What to build first
Get isEntitled right, store the status verbatim, handle
customer.subscription.updated by re-fetching, and send an email on
trial_will_end. That is the whole trial feature for most products. Trial
farming is a problem you should solve when you have evidence of it, not before:
the mitigations all cost conversion.