---
title: Why subscription state machines beat status enums
url: https://honeybound.co/blog/subscription-state-machines-beat-status-enums
date: 2026-05-06
summary: Every subscription app starts with status TEXT NOT NULL and a CHECK constraint. By month six it's a 14-state mess where nobody can answer 'can a paused subscription be cancelled directly?' The fix isn't more enum values — it's modeling the transitions, not the states.
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.

