Designing an Account Suspension State Machine
When dunning finally gives up, something has to happen to the account, and “delete it” is never the answer. The right behaviour is a graded degradation that reduces what the customer can do while preserving what they need to recover — chiefly their data and their ability to pay. This guide sits under Grace Period & Retry Logic and covers modelling that degradation as an explicit state machine rather than a set of scattered conditionals.
The reason to make it explicit is that the alternative accumulates. A check here that hides a button, a check there that returns a 403, a background job that skips unpaid accounts — each reasonable, and collectively an account state that nobody can describe. When a customer pays and something remains blocked, the only way to find out why is to grep.
The second reason is recovery. An account that degraded through three states must be able to return through them in order, and transitions that were written as one-way operations — a disabled flag set but never cleared — leave customers who paid still locked out.
Trade-offs
| Model | Explainable | Reversible | Enforcement | Fits |
|---|---|---|---|---|
Boolean is_suspended |
Barely | Yes, trivially | Scattered checks | Very simple products |
| Payment status as the gate | No — conflates concepts | Partly | Scattered | Common and regrettable |
| Explicit capability states | Yes | By design | One resolver | Most products |
| Per-feature timers | Precise, unexplainable | Complex | Everywhere | Rarely worth it |
Recovering directly to active rather than stepping back through each state matters. A customer who pays has resolved the underlying condition entirely, and a stepped recovery leaves them partially restricted for reasons that no longer apply.
Step-by-Step Implementation
1. Define states by capability
from enum import Enum
class AccountState(str, Enum):
ACTIVE = "active" # everything
LIMITED = "limited" # no new resources, existing work continues
READ_ONLY = "read_only" # view, export, and pay; no writes
SUSPENDED = "suspended" # billing surfaces only
CLOSED = "closed" # retention window elapsed
CAPABILITIES = {
AccountState.ACTIVE: {"read", "write", "create", "invite", "api", "export", "billing"},
AccountState.LIMITED: {"read", "write", "api", "export", "billing"},
AccountState.READ_ONLY: {"read", "export", "billing"},
AccountState.SUSPENDED: {"billing"},
AccountState.CLOSED: {"billing"},
}
Naming states after capabilities rather than after billing conditions is what keeps the model usable. read_only tells every engineer what to expect; dunning_stage_3 does not.
2. Drive transitions from billing events, in one place
def next_state(current: AccountState, event: str, days_past_due: int) -> AccountState:
if event == "payment_succeeded":
return AccountState.ACTIVE # full recovery, always
if event == "dunning_exhausted":
return AccountState.READ_ONLY
if event == "payment_failed" and current is AccountState.ACTIVE:
return AccountState.LIMITED
if event == "retention_elapsed" and current is AccountState.SUSPENDED:
return AccountState.CLOSED
if event == "grace_elapsed" and current is AccountState.READ_ONLY and days_past_due > 30:
return AccountState.SUSPENDED
return current
A single transition function is testable exhaustively and is the only place a state changes. Anything else that wants to restrict behaviour reads the state instead of setting it.
3. Keep the payment path open everywhere
4. Attach a retention clock
ALTER TABLE accounts
ADD COLUMN state TEXT NOT NULL DEFAULT 'active',
ADD COLUMN state_changed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ADD COLUMN retention_expires_at TIMESTAMPTZ; -- set when entering suspended
The retention window is a product and legal decision — commonly 30 to 90 days — and it must be communicated before it starts, not when it ends. Deleting data on a clock nobody was told about is the single most damaging thing this subsystem can do.
5. Emit the change as an event
Entitlements, notifications, and analytics all need to react to a state change. Publish it through the outbox pattern so consumers cannot miss it and so a replay reconstructs the same state.
Choosing How Aggressive to Be
The degradation schedule is a revenue decision disguised as a technical one, and the extremes are both wrong.
Degrading too fast damages recovery. An account suspended the day after a card expires loses the customer’s goodwill precisely when you need them to act, and involuntary churn from expired cards is overwhelmingly recoverable given a few weeks. The evidence across subscription businesses is consistent: most recovered payments arrive well after the first failure, which argues for a long, gentle ramp.
Degrading too slowly costs money and invites abuse. An account that keeps full access for months after payment stopped is being served for free, and in products where usage has a real marginal cost that is a direct loss. It also teaches customers that payment is optional, which is a slow but real effect in self-serve products.
The shape most products converge on is: full access through the retry window, a limited state that prevents new commitments but does not disrupt existing work, read-only once dunning exhausts, and suspension only after a further extended period. The first two steps are almost invisible to a customer who is going to pay, and the last two are unmistakable to one who is not.
Segment the schedule where the economics differ. A high-usage account costs more to serve while unpaid than a dormant one, and it is entirely defensible to degrade the expensive account faster. Similarly, an annual customer who has paid for eleven months and failed on renewal deserves more patience than a first-month monthly subscriber whose initial payment never cleared.
Whatever the schedule, publish it in the dunning emails. “Your account will move to read-only on 14 March if payment is not received” converts better than a vague warning, because it gives the customer a deadline they can act against — and it removes the surprise that turns a degradation into a complaint.
Making the State Visible
A degradation the customer cannot see or understand generates support load out of proportion to its revenue effect, and most of that load is avoidable with three surfaces.
An in-product banner naming the state and the reason, present in every degraded state, with a direct link to update payment. It should say what has changed and what will change next — “your account is read-only because a payment failed; it will be suspended on 12 April” — because a customer who knows the next step can act on it.
A billing page that shows the outstanding invoice, the amount, and a pay-now action. A surprising number of customers in degraded states want to pay and cannot find how, particularly when the failure was on a card belonging to someone who has left the company.
A support view showing the current state, the transition history with timestamps, and the retention date. Agents answering “why can’t I create anything?” need the state and its cause in one place; reconstructing it from payment history is slow and error-prone.
Instrument the states as a funnel, too. Counting accounts in each state over time, and the rate at which each state recovers to active, tells you whether the schedule is working. A state that almost never recovers is either too late in the sequence to matter or too aggressive to be survivable, and both are worth knowing before the next schedule change.
Verification & Testing
Test the transition function exhaustively over the cross-product of states and events. It is a pure function with a small domain, so the full table can be asserted, and the assertion documents the intended behaviour better than any comment.
Test recovery from every degraded state: assert that a successful payment returns the account to active in one transition, and that every capability is restored. The specific regression this catches is a flag set during degradation that no recovery path clears.
Test the always-available paths in every state, including suspended: sign-in, payment update, invoice view, and export must all succeed. Write these as integration tests against real endpoints rather than unit tests over the capability map, because the failure usually lives in a middleware that never consulted the map.
Gotchas & Production Pitfalls
- Blocking sign-in on suspension. Removes the only path to recovery and converts a recoverable account into churn.
- One-way degradation flags. A boolean set during degradation and never cleared leaves paying customers restricted.
- Deleting data on suspension. Retention and suspension are different clocks; conflating them destroys data the customer could still have paid for.
- Capability checks outside the resolver. Scattered conditions drift from the state machine and produce restrictions nobody can locate.
- Silent transitions. A state change with no notification reads to the customer as the product breaking rather than as a billing consequence.
Frequently Asked Questions
Should suspension be automatic or reviewed? Automatic for self-serve, reviewed for accounts above a value threshold or under contract. An enterprise contract frequently specifies a notice period, and suspending inside it is a breach.
What about accounts with active integrations? API access is a capability like any other, and cutting it abruptly can break the customer’s production systems. Degrade API access with its own notice, and consider returning a specific error code so their monitoring identifies the cause.
Does read-only include the customer portal? It must. Viewing invoices, updating the payment method, and exporting data are the recovery paths, and restricting them to paying customers only guarantees non-recovery.
How does this interact with entitlements? The account state is an input to the entitlement resolver, which folds it together with plan grants. Keeping enforcement in one resolver is what stops the two systems from disagreeing.