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, 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:
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:
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:
// 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:
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:
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
cancelledas "still paid untilends_at", not as "gone". - Decide on
past_dueon 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.


