The membership state machine
Most gym software models membership status as a set of columns — is_active,
is_frozen, expiry_date — and derives the member’s status by evaluating them together
at read time. It works until two of them disagree, which happens the first time somebody
freezes a membership that was already in its grace period.
We model it as an explicit state machine with six states:
Pending → Active → Expiring → Grace → Expired → Cancelled
↕
Frozen
Every transition is a row: from-state, to-state, reason, actor, timestamp, and the effect
it had on the end date. The current state is a column, but it is derived from the
transition history and reconcilable against it — so if the two ever disagree, that is a
detectable bug rather than an invisible one.
The properties this buys are worth the extra table:
- Illegal transitions are impossible, not merely discouraged. You cannot freeze a
cancelled membership, because there is no edge from
Cancelled to Frozen. The check
lives in one place instead of being re-implemented at every call site.
- The end date is never mutated in place. A freeze does not overwrite the expiry — it
records a transition that adds days, and the current expiry is the plan’s original end
date plus the sum of the extensions. Six months later you can still explain the date.
- Retroactive corrections are auditable. A manager who backdates a freeze creates a
new transition with a reason and their identity attached. The original stays.
The Expiring and Grace states exist because gyms treat them differently and both need
distinct access rules. Expiring is still fully active but triggers renewal messaging;
Grace is past the end date but still admitted, with configurable behaviour — some gyms
allow full access for seven days, others allow entry but block classes.
Billing edge cases are the product
The list of cases that a naive implementation gets wrong is long enough that we treat it
as the acceptance-test suite rather than as an afterthought:
- 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?
- A member’s plan price increases on the 1st; their renewal falls on the 15th.
- A member pays for 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 pro-rata
calculation, and every calculation is stored with its inputs on the invoice line — day
count, daily rate, days used, days credited — not just its result.
That decision costs a little storage and settles almost every dispute at the desk in under
thirty seconds. It is the single highest-leverage thing in the billing module, and it is
the thing that off-the-shelf gym software most consistently does not do.
Offline-tolerant check-in
A gym’s front desk cannot stop working because a fibre line was cut. But an
offline-capable check-in is not simply “cache the member list” — the hard part is what
happens when the connection comes back.
The front-desk application is a PWA holding a locally-cached snapshot of active
memberships, refreshed continuously while online. When the connection drops it keeps
admitting members from that snapshot and queues each check-in locally with a
client-generated idempotency key.
On reconnect, the queue replays. The idempotency key means a replayed check-in that
already reached the server is a no-op rather than a duplicate. Conflicts — a member who
was cancelled server-side during the outage but admitted from the stale snapshot — are
surfaced as a reviewable list rather than silently resolved in either direction, because
both possible resolutions are wrong some of the time and only a human knows which.
Payments deliberately do not work this way. An offline payment is recorded as pending
and confirmed on reconnect; the receipt is not issued until the server has it. Taking
money into a queue that might fail to sync is how you end up with a member holding a
receipt for a payment the system has no record of.