# Lemon Squeezy subscription statuses: who should keep access, and the two bugs I shipped

Lemon Squeezy reports a subscription as one of seven statuses: `on_trial`, `active`, `past_due`, `unpaid`, `paused`, `cancelled` and `expired`. Three of them should keep a paying customer's access (`on_trial`, `active` and `past_due`), one keeps it until a date (`cancelled`, until `ends_at`), and the rest should not. In [Freeby](https://www.mohamedgado.com/projects/freeby), my invoicing app, I got two of these wrong at the same time. Expired subscribers kept unlimited Pro forever, and a customer whose card failed lost Pro on the first retry.

## What does each Lemon Squeezy status mean for access?

| Status | What happened | Access |
| --- | --- | --- |
| `on_trial` | Free trial running | Yes |
| `active` | Paid and renewing | Yes |
| `past_due` | A renewal payment failed and Lemon Squeezy is retrying it | Yes, as a grace period |
| `unpaid` | The retries ran out | No |
| `paused` | Billing paused | No |
| `cancelled` | The customer cancelled; the paid period has not ended | Yes, until `ends_at` |
| `expired` | The paid period ended | No |

`cancelled` is the one people misread. It does not mean "gone". The customer has paid to the end of the period, and Lemon Squeezy moves the subscription to `expired` when that period ends. Cutting access on the `subscription_cancelled` webhook takes away days the customer paid for.

## How did expired subscribers keep Pro?

Freeby's webhook stored two fields per subscription: the mapped `status`, and a `plan`. The plan was derived like this:

```ts
const plan: 'pro' | 'free' = status === 'free' ? 'free' : 'pro'
```

Every paid status, `expired` included, stored `plan: "pro"`. That alone is fine, since the row records which product was bought. The bug was on the reading side. The client limit and the monthly invoice limit both looked only at the plan:

```ts
const limit = LIMITS[entitlement.plan].maxInvoicesPerMonth
```

`entitlement.plan` came straight from the row, so an expired subscriber had Infinity invoices and Infinity clients, and always would. The entitlement also had an `isActive` flag computed from the status, but only the email-sending check used it. Two checks used one field, one check used the other, and nothing forced them to agree.

## How did one failed payment cut access?

The status mapping and the access list disagreed about `past_due`:

```ts
// mapLsStatus: the webhook side
case 'past_due':
  return { status: 'past_due', isActive: true } // grace period

// getEntitlement: the read side
export const ACTIVE_STATUSES = ['active', 'on_trial']
```

The webhook mapper was written with a grace period in mind. The read side, which decides what the app actually does, left `past_due` out. A customer whose renewal failed for an ordinary reason, such as an expired card, lost Pro while Lemon Squeezy was still retrying the charge.

## What is the fix?

Make one pure function decide what a user gets right now, and make `plan` mean the *effective* plan, so the 11 places that read it are right without being touched:

```ts
export const ACTIVE_STATUSES = ['active', 'on_trial', 'past_due']

export function deriveEntitlement(row, now: Date): Entitlement {
  const inPaidPeriod =
    row.status === 'canceled' &&
    row.currentPeriodEnd !== null &&
    row.currentPeriodEnd > now
  const isActive =
    row.plan === 'pro' && (ACTIVE_STATUSES.includes(row.status) || inPaidPeriod)
  return {
    plan: isActive ? 'pro' : 'free',
    status: row.status,
    isActive,
    cancelAtPeriodEnd: row.cancelAtPeriodEnd || inPaidPeriod,
    currentPeriodEnd: row.currentPeriodEnd,
  }
}
```

The database read calls it with `new Date()`. The tests call it with a fixed date, which is why `now` is a parameter instead of a `Date` inside the function.

## How do I stop the two lists drifting apart again?

The `past_due` bug existed because the same fact lived in two places. A test now walks every Lemon Squeezy status through the webhook mapper and checks the answer against the access list:

```ts
for (const ls of ['active', 'on_trial', 'past_due', 'unpaid', 'paused',
  'cancelled', 'cancelled_immediately', 'expired']) {
  const r = mapLsStatus(ls)
  expect(ACTIVE_STATUSES.includes(r.status)).toBe(r.isActive)
}
```

Run against the old list, it fails on `past_due`. Five more tests pin the effective plan: active Pro gets Pro limits, expired Pro gets Free limits, cancelled Pro keeps access until its period ends and loses it after, and paused Pro is Free.

## What should I check in my own billing code?

- Find every place that gates a feature, and check whether it reads the stored plan or the current status. If both exist, one of them is wrong somewhere.
- Treat `cancelled` as "still paid until `ends_at`", not as "gone".
- Decide on `past_due` on purpose. A grace period is the usual choice, because most failed renewals are expired cards that the customer fixes.
- Keep the status-to-access decision in one pure function with the clock passed in, so you can test the end of a billing period without waiting a month.

