Designing a membership state machine: freezes, grace periods and pro-rata billing
Most gym software models membership status as a handful of boolean columns. Here is why that fails, and what an explicit transition table buys you at the front desk.
Every subscription business eventually writes this bug. It starts as three columns on a
membership row — is_active, is_frozen, expiry_date — and status is derived at read
time by evaluating them together. It works for about two months.
The bug arrives the first time someone freezes a membership that was already in its grace
period. Now is_active is false, is_frozen is true, and expiry_date is in the past.
What should the turnstile do? Three different screens in the application will answer that
question three different ways, because each one re-implemented the derivation.
The columns are not the problem. The derivation is.
The instinct is to add a fourth column, then a fifth. is_in_grace. frozen_until.
suspended_reason. Each one is locally reasonable and each one multiplies the number of
combinations the code has to consider — most of which are unreachable in theory and
reachable in practice, because some endpoint updated one column without the others.
The real problem is that status is being computed, in several places, from state that can
be internally inconsistent. Nothing in the schema prevents is_active and is_frozen
from both being true.
An explicit state machine
Six states cover every gym we have looked at:
Pending → Active → Expiring → Grace → Expired → Cancelled
↕
Frozen
Pending is a membership sold but not started. Expiring is fully active but inside the
renewal window, which is what triggers renewal messaging. Grace is past the end date but
still admitted, under rules the gym configures. The distinction between the last two matters
because gyms treat them differently — some allow full access during grace, others allow entry
but block classes.
The important part is not the diagram. It is that transitions are rows:
create table membership_transition (
id bigserial primary key,
membership_id bigint not null references membership(id),
from_state text not null,
to_state text not null,
reason text not null,
actor_id bigint references app_user(id),
effective_at timestamptz not null,
days_delta integer not null default 0,
created_at timestamptz not null default now()
);
The membership row still carries a current_state column, because you need to index it. But
that column is a cache of the transition history, and a nightly job recomputes it from the
transitions and alerts on any mismatch. If the two ever disagree, that is a bug you can
detect rather than one that silently admits the wrong member.
What this buys you
Illegal transitions become impossible rather than discouraged. There is no edge from
Cancelled to Frozen, so the attempt fails at the one place transitions are applied
instead of being prevented by a check that three of four call sites remembered to write.
The end date stops being mutated in place. This is the single biggest practical win. A
freeze does not overwrite expiry_date — it records a transition with days_delta = 14.
The current expiry is the plan’s original end date plus the sum of deltas. Six months later
you can still explain, line by line, why a membership ends on the date it does.
Compare the two conversations at the front desk:
Without: “The system says your membership ends on the 14th.” “That’s wrong, I froze it for two weeks in March.” “I can change it if you’re sure.”
With: “It ends on the 14th — your original end date was the 30th of April, you froze for 14 days in March which pushed it to the 14th of May, and there was a 3-day goodwill extension in June. Here’s the list.”
The second conversation ends. The first one becomes policy.
Retroactive corrections stay auditable. A manager backdating a freeze creates a new
transition with effective_at in the past and their identity attached. The original
transition stays. Nothing is edited, so nothing is lost.
Pro-rata: store the working, not just the answer
The second half of this problem is billing, and the rule we have converged on is: store the inputs to every pro-rata calculation on the invoice line, not just its result.
{
"basis": "monthly",
"period_start": "2026-03-22",
"period_end": "2026-03-31",
"days_in_period": 31,
"days_charged": 10,
"daily_rate": 129.03,
"amount": 1290.30
}
It costs a few hundred bytes per line. It settles almost every billing dispute at the desk in under thirty seconds, because the receptionist can show the member the arithmetic instead of asserting it.
The cases that break naive implementations are all ordinary, and they make a good acceptance suite:
- A member joins on the 22nd of a 31-day month on a monthly plan.
- A member upgrades from monthly to annual on day 12, having already paid the month.
- A member freezes for 10 days, then cancels during the freeze — is the freeze refunded?
- The plan price increases on the 1st; their renewal falls on the 15th.
- A member pays three months in advance and then downgrades.
- A payment bounces after the membership was already extended.
Every one of these is expressible as an ordered sequence of transitions plus a stored calculation. None of them requires a special case in the code, which is the actual test of whether the model is right.
The one place we deliberately allow denormalisation
Reading a membership’s current state by folding its transition history is correct and too
slow for a turnstile that has to answer in under 200ms. So current_state and expiry_date
are both materialised on the membership row, written inside the same transaction as the
transition that changed them.
That is a denormalisation, and denormalisations drift. The mitigation is not discipline — it is a reconciliation job that recomputes both from the ledger nightly and raises an alert on mismatch. In two years of running this pattern, the alert has fired exactly when there was a genuine bug, which is precisely what you want from it.
When not to do this
If your business sells one plan, monthly, with no freezes and no grace period, this is
over-engineering and a status enum will serve you fine. The state machine earns its keep at
the point where the policy has more than about four rules — which for most gyms is the day
they introduce freezing.
The tell that you have crossed that line: someone asks why a member’s end date is what it is, and the honest answer requires opening the payments table.