SubscriptionStateMachine — a pure class in @duro/billing with a transition table and no IO.
The states
Each arrow is one row in a transition table. A transition has afrom state, an action, a to state, and the event it emits. That last column is why the state machine is also the source of truth for webhooks — the event name is not chosen at the call site, it’s a property of the transition.
| From | Action | To | Emits |
|---|---|---|---|
| incomplete | start_trial | trialing | subscription_created |
| trialing / incomplete | activate | active | subscription_activated |
| active / trialing | renewal_failed | past_due | subscription_past_due |
| past_due | recover | active | subscription_recovered |
| past_due | exhaust_dunning | unpaid | subscription_unpaid |
| active | reach_limit | expired | subscription_expired |
| active | pause | paused | subscription_paused |
| paused | resume | active | subscription_resumed |
| (any cancelable) | cancel | canceled | subscription_cancelled |
Illegal transitions are impossible, loudly
transition(from, action) looks up the table. If there’s no matching row, it throws a ConflictError — it does not silently no-op:
So “pause a canceled subscription” or “resume an active one” can’t corrupt state — the attempt is rejected at the boundary. Callers that want to check first use can(from, action), a boolean; callers that intend the transition use transition() and accept the throw. The dunning service uses can() precisely so a recovery attempt on a subscription the merchant already canceled is a quiet skip, not a crash.
Terminal states stop the world
isTerminal(status) is true for canceled, expired, and incomplete_expired. The renewal scanner only ever picks up active and trialing subscriptions, so terminal subscriptions are never billed again — they fall out of every sweep by construction.
Why this is a separate package
SubscriptionStateMachine has zero dependencies on the database, HTTP, or the queue. It’s pure functions over enums. That means:
- It’s tested exhaustively in isolation — every legal transition and a sample of illegal ones, in milliseconds.
- The same class drives transitions whether the caller is a dashboard request (
SubscriptionService), a recovery win (DunningService), or the renewal worker (BillingService). One definition of “what’s legal,” three call sites. - A judge reading
@duro/billing/state-machine.tssees the entire lifecycle in one screen, with no Prisma noise.