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.