---
title: Why subscription state machines beat status enums
url: https://honeybound.co/blog/subscription-state-machines-beat-status-enums
date: 2026-05-06
summary: Subscription apps outgrow simple status enums fast. Model lifecycle transitions instead to keep pause, cancel, resume, and billing logic clear.
tldr: Subscription lifecycle logic becomes clearer when apps model allowed transitions instead of piling more meanings onto one status enum.
tags: subscriptions, data-modeling, shopify
---

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](/work/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.

## Key takeaways

- 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.

## FAQ

### 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.

