Journal
Why subscription state machines beat status enums
Subscription apps outgrow simple status enums fast. Model lifecycle transitions instead to keep pause, cancel, resume, and billing logic clear.
Key takeaways
What to remember
- Status enums hide transition rules inside scattered application code.
- State machines make pause, resume, cancel, retry, and billing transitions explicit.
- Explicit transitions make subscription behavior easier to audit, test, and support.
A merchant emails: “Why does this customer have a paused subscription that’s also past_due?”
You open the database. They’re right. You wrote the code that did it. You can’t immediately tell them why.
The status-enum trap
Every subscription app starts the same way.
Day one: status TEXT NOT NULL with two values, active and cancelled. A
CHECK constraint. Clean.
Month two: add paused. Add past_due. Add pending_first_charge because
the first cycle bills on a delay. Four values. Still fine.
Month four: add cancelled_pending — cancelled, but the next cycle’s credit
still applies. Add expired — cancelled and the credit is gone. Six values.
You’re writing longer and longer case statements.
Month six: two booleans now ride alongside the enum. auto_renew for the
customer’s intent. dunning_active because the retry job needs somewhere to
record state. The “real” status of a subscription is now fragmented across
three columns and nobody on the team can tell you which one wins.
Month eight: a merchant emails about an impossible state combination. Paused-and-past-due. Cancelled-with-auto-renew. You can’t immediately reproduce it because nothing in the schema prevented it from happening.
What a state machine actually buys you
Transitions become first-class. A state change isn’t a column update, it’s a
row: (from_state, to_state, reason, actor, occurred_at).
Reasons have type. cancelled_by_merchant, cancelled_payment_failed, and
cancelled_by_customer all land in the same end state — and their
downstream behavior is completely different. One sends a goodbye email, one
sends a dunning recovery email, one sends neither. A status column can’t
hold that distinction. A transition log can.
Forbidden transitions throw at the boundary, not in production. The edge
from paused to expired doesn’t exist — the validator rejects it before
any side effect runs. You find the bug in staging the week you wrote it,
not six months later when a merchant opens a ticket.
Webhook handlers reduce to one line of intent: “apply transition X if currently in state Y, otherwise log and ignore.” Idempotency falls out for free. Shopify’s inevitable duplicate deliveries stop mattering.
The recurrabee shape
Seven states. Intentionally seven, not fifteen: pending_first_charge,
active, paused, past_due, cancelled_pending, cancelled, expired.
Some transitions are one step. Some are deliberately two. paused → cancelled
is one — a merchant or customer decision, no side effect to unwind.
past_due → cancelled is two: the contract must attempt reactivation first.
We made that a rule so the dunning logic sees a clean exit instead of firing
a final-warning email after the contract is already gone.
The transition log is permanent. Every state change writes a row with the reason, the actor (customer / merchant / system / webhook source), and a snapshot of the relevant fields before and after. Rows are never deleted.
The thing that makes it worth the cost
The transition log is the audit trail. When a merchant asks “why is this
contract paused?” you read one row. You don’t reconstruct it from
updated_at heuristics and the faint outline of deleted rows.
Same log powers the AI dunning rebuttals — the reason code feeds the prompt, so the generated copy argues against this cancellation, not a generic one. Same log satisfies the next compliance audit without a weekend of SQL archaeology. You build it once; it keeps paying.
When you don’t need this
One-time purchases. Trial-only flows. Apps where the “status” is really just “active or not.” A boolean is fine for those. The state machine earns its keep when transitions trigger side effects — charges, emails, Shopify Subscription Contract API calls — and you need to know, months later, exactly which transition fired which side effect.
Most subscription apps are in that category. Most of them still have a status column.
This is the shape behind recurrabee. It’s also why the AI rebuttal copy is useful instead of generic — it has a typed reason code to argue against, not a free-text enum nobody remembers populating correctly.
Frequently asked questions
Why are status enums risky for subscriptions?
A single status field rarely captures which actions are allowed next, so rules drift across handlers, jobs, and support tooling.
What does a state machine improve?
A state machine defines the allowed transitions directly, making lifecycle behavior easier to reason about and test.
When should a subscription app use a state machine?
Use a state machine once subscriptions can pause, resume, retry billing, cancel, reactivate, or enter multiple operational states.