Journal

Why subscription state machines beat status enums

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.

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.

← More from the blog Start a project