Skip to content
5 min read Muhammad Ali architecturebillingstate machines

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.

Tell us what your operation actually does.

A 30-minute call, no slide deck. We will tell you whether this is worth building, whether an off-the-shelf product would serve you better, and roughly what it would take.

30 minutes · We reply within one business day.

WhatsApp Book a call