The first version of billing code usually has one boolean: is this account paid. It works for the demo. Then a customer starts a trial and forgets to add a card, another downgrades to a plan that allows fewer seats than they currently have, and a third has a card expire on the first of the month. None of those fit in a boolean, so each becomes a manual fix by whoever is on support that day.
The pattern is consistent. The happy path (sign up, pay, use the product) gets built carefully. The states around it get handled by a cron job someone wrote in a hurry and a column nobody trusts. The bill for this shows up as support time, wrongly cut-off customers and revenue that quietly leaks.
This post lays out the states worth modeling on day one, the one design decision that makes the rest manageable, and where a full lifecycle engine is more than you need.
Separate what the customer owes from what the customer can use
The decision that pays off most is to keep two things apart. Billing state describes the commercial relationship: trialing, active, past due, canceled. Entitlement describes what the product lets the account do right now: which features, how many seats, whether it is read-only. The billing state is an input to the entitlement calculation, never the thing the product code checks directly.
When product code asks if the subscription is active, every new commercial idea becomes a code change in a hundred places. When it asks whether the account is entitled to a feature, a new pricing rule is a change to one function. We covered the second half of this in feature gating that survives your next pricing change, and the lifecycle below is what feeds it.
The states most codebases never model
- Trialing. Has an end date and a plan. Needs a decision at expiry: convert if a payment method exists, otherwise move to a limited state rather than deleting anything.
- Active. Paid through the end of the current period.
- Past due. The latest invoice failed. The customer still has access, and the system is retrying. This is the dunning window.
- Restricted. Retries are exhausted or the grace period ended. Data is intact and readable, but writes and new usage are blocked. This state is the difference between a polite reminder and a destroyed relationship.
- Canceling. The customer canceled but has paid through the period end. Access continues until that date, and the account should show the date plainly.
- Canceled. Period ended. Retain data for a defined window, then follow your deletion policy.
- Paused. Some products need it for seasonal customers. If you do not, do not build it.
Transitions matter as much as the states. Write them down as a table of allowed moves, and reject anything else. An account should not jump from trialing to canceled without a recorded event, because that is how a data problem hides for a quarter.
Downgrades: the question is when, and what about the overage
A downgrade has two decisions hiding inside it. The first is timing. Applying a downgrade at the end of the paid period is the simplest rule and the one customers tend to expect, since they already paid for the current one. Immediate downgrades need a credit calculation, and those are their own source of dispute; our piece on mid-cycle plan changes goes through that math.
The second decision is what happens when the new plan allows less than the account currently has. Say the lower plan permits five seats and the account has nine, or permits 10 GB and they store 14. The wrong answers are deleting data and silently ignoring the limit. The workable answer is to let existing data stand, block growth past the limit, and show a banner explaining what to remove or when to upgrade. Mark the account as over limit, which is a flag the entitlement function reads, rather than a billing state.
Dunning is a schedule, so write it as one
A failed payment is the most common involuntary churn event, and most of it is recoverable because cards expire and banks decline for mundane reasons. Treat the recovery as an explicit schedule that anyone can read, not as scattered logic. For example:
// day offsets from first failure
const DUNNING = [
{ day: 0, action: 'retry', email: 'payment_failed' },
{ day: 3, action: 'retry', email: 'update_card' },
{ day: 7, action: 'retry', email: 'access_at_risk' },
{ day: 10, action: 'restrict', email: 'account_restricted' },
{ day: 30, action: 'cancel', email: 'subscription_ended' },
]
// payment succeeds at any point: state -> active, clear the schedule
The offsets above are an illustration, not a recommendation. Your own are a business choice that depends on customer type: a consumer app and a B2B contract with a purchase order behave very differently. What is not optional is that the schedule lives in data you can change, that every step is logged, and that a successful payment at any point cleanly resets the account.
Payment providers send you much of this as webhook events, and they arrive late, twice and out of order. The lifecycle code has to tolerate that, which we described in how to build webhooks that survive retries and replay. Derive state from the latest authoritative event with an idempotency check instead of trusting arrival order.
Make every state change an event you can read back
When a customer writes in to say they were locked out while paid up, you need to answer in a minute: what state was the account in, what event moved it, and what did the provider say at that moment. Store each transition with a timestamp, the previous state, the new state and the triggering event id. That is a small table, and it converts most billing support tickets from detective work into a lookup.
It also lets you test the lifecycle properly. With the transitions as pure functions of state and event, you can write a table-driven test covering every allowed move and every rejected one, plus the awkward cases: a payment that succeeds on the same day the account was restricted, or a downgrade scheduled for a period that then gets canceled.
We have built subscription billing, usage metering and entitlement gating across our own products and client platforms, and the pattern that holds up each time is the same: few states, explicit transitions, and an audit trail that support can read without asking an engineer.
When you do not need all of this
If you sell annual contracts to a few dozen businesses and invoice them by hand, a dunning schedule and a restricted state are machinery you will not use. A shared sheet, a calendar reminder and a human making a phone call is a legitimate system at that scale, and it is cheaper than building one. Likewise, if you pass all billing to a hosted provider with its own retry logic and customer portal, lean on its settings and model only the entitlement side yourself.
The point at which this earns its keep is when volume exceeds what one person can remember, when different plans have different limits, or when the first angry customer was cut off in error. If you are about to design a subscription product and want the billing model reviewed before it hardens into the schema, that is the kind of work described under multi-tenant SaaS platforms, and you can start the conversation through our contact page.
Frequently asked
What is dunning in subscription billing?
Dunning is the process of recovering a failed payment: retrying the charge on a schedule and emailing the customer to update their card. Done well, it recovers many failures caused by expired cards or temporary declines, and it moves the account through defined states instead of cutting access abruptly.
Should a failed payment cut off access immediately?
Usually not. Keep access during a grace window while retries run, then move to a restricted state where data stays readable but new usage is blocked. Immediate cut-off punishes customers for mundane card problems and creates support load that a short grace period avoids.
What happens to data when someone downgrades below their usage?
Leave existing data in place and block further growth past the new limit, with a clear banner saying what to remove or when to upgrade. Deleting data on downgrade is hard to reverse and a common source of angry tickets. Track over-limit as an entitlement flag, not a billing state.
Do I need a billing state machine if I use a payment provider?
You still need your own entitlement logic, because the provider does not know what your product allows. Its subscription statuses are inputs. Map them to your own states, and keep a transition log so you can explain any account state without reading provider dashboards.