Skip to main content
A subscription is a small state machine. Getting the state machine right is the difference between a billing system you can reason about and a pile of boolean flags that drift out of sync. Duro encodes it explicitly in 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 a from 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.
FromActionToEmits
incompletestart_trialtrialingsubscription_created
trialing / incompleteactivateactivesubscription_activated
active / trialingrenewal_failedpast_duesubscription_past_due
past_duerecoveractivesubscription_recovered
past_dueexhaust_dunningunpaidsubscription_unpaid
activereach_limitexpiredsubscription_expired
activepausepausedsubscription_paused
pausedresumeactivesubscription_resumed
(any cancelable)cancelcanceledsubscription_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.ts sees the entire lifecycle in one screen, with no Prisma noise.
Next: the renewal engine — the loop that drives subscriptions through this machine on a timer.