# Free trials in Stripe that do not leak access

> trialing is an entitled status and trial_will_end is not a payment. Where trials are configured, which events actually matter, and how to stop one person taking ten trials.

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:

1. Access is gated on `status === "active"`, so trialing customers are locked out
   of the product they just signed up for.
2. Access is gated on "a subscription row exists", so a customer whose trial
   ended without a payment method keeps everything, forever.
3. 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`):

```ts
{ id: "pro-monthly", interval: "month", amount: 1900, trialDays: 7 },
```

and the Stripe adapter's `createCheckout` passes it on:

```ts
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`, `pause` or
  `create_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.

```ts
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.created`** with `status: 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_end`** fires 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_failed`** right after a trial: the card on file was
  declined at conversion. The subscription goes `past_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:

```ts
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

```ts
// 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_subscriptions` for any prior row with a `trial_end`,
  checked before the trial is attached (in this project, `createCheckout` in
  `src/lib/billing/provider.ts`). Cheap and effective for
  same-account retries.
- **Block disposable email domains** at signup.
- **Fingerprint the payment method.** Stripe exposes a `fingerprint` on 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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
