One membership, one expiry date
The obvious model gives every plan its own start and end date. A member buys a
twelve-month Workout plan in January and adds three months of Personal Training
in March, and the two run on independent clocks.
It is more flexible, and it is wrong for this business. Every downstream feature
immediately has to answer “expiring when?” for a member with two answers. The
renewal list shows the member twice. The reminder rules fire twice. The
receptionist looks at the screen and cannot tell you, in the two seconds she has
while the member is standing there, whether this person is current.
So a membership is a dated cycle that holds plans, and the cycle owns the
expiry. Each plan is charged at its own list price, and the cycle runs to the
furthest of their end dates — a member on a twelve-month Workout plan who adds
three months of Personal Training has one expiry, not two. The member has one
date, the desk has one answer, and the reminder rules have one thing to count
against.
The cost is real and worth naming: a three-month add-on inside a twelve-month
cycle gets no renewal prompt of its own three months in. Gyms at this size
generally treat that as the right trade — the renewal conversation happens once
a year rather than four times — but if you sell short add-ons you intend to
re-sell on their own clock, that is the first thing to change.
A price change is a new plan, not an edit
Editing the price on a plan is one UPDATE. It is also the quietest way to
corrupt a year of reporting.
Once the price has moved, every historical row pointing at that plan now implies
the wrong figure. Last March’s revenue report disagrees with last March’s
receipts. Nobody notices until a member produces a receipt that no longer matches
what the system says the plan costs, and by then there is no way to reconstruct
which price was in force when.
So a price change retires the existing plan and creates a new one. Existing
memberships keep pointing at the plan they were sold on. New sales get the new
plan. The plan list shows both, one marked current and one retired, with the
number of members still on each.
The same reasoning runs through the rest of the system, and it is the single idea
the product is organised around:
- Members are deactivated, never deleted — a cancelled member keeps their
history and stays searchable.
- Payments are reversed, never deleted, with the reversal linked to what it
reverses and both visible.
- Expenses are voided, never deleted, so voucher numbering stays unbroken.
- Services and plans are retired, never deleted.
Receipt numbers run in an unbroken sequence per financial year, and the format
cannot be changed part-way through a year once receipts exist. That constraint is
not fussiness. A gym owner reading their own receipt book treats a gap as theft,
and they are usually right to.
Permissions are enforced; roles are only a label
The system ships with Owner, Manager, Receptionist and Trainer. Nothing in the
code checks any of those names.
Every endpoint checks a single permission key — payments.reverse,
collection.view.all, memberships.dates.edit — and a role is just a named
bundle of them. Fifty-six keys, with a fifty-seventh that scopes a trainer to
their own assigned members.
This matters because of how the request always arrives. A gym does not ask for a
new role. It asks whether one specific receptionist can start applying discounts,
because she has been there four years and the manager is tired of being called
over. With role-name checks, that is either a code change or a third role called
“Receptionist Plus” — and a year later there are five of those and nobody knows
what distinguishes them. With permission checks it is a checkbox, and the audit
trail records who ticked it.
Two boundaries in the shipped defaults are worth pointing at:
- The front desk can take money all day and never see what the gym makes.
collection.view.own covers reconciling a shift. collection.view.all,
revenue and profit are separate permissions the Receptionist does not hold.
- Nobody can reverse their own mistake.
payments.create is a front-desk
permission; payments.reverse is not. The correction is always made by
somebody other than the person who made the error.
The door policy is configuration, and the override is evidence
Every gym already runs a grace period. It is just undocumented, inconsistent
between staff, and impossible to audit.
So it is a setting, in days, during which an expired member checks in completely
normally. Past it, two separate rules decide what happens — one for members whose
membership has run out, one for members who owe money — each set to warn or to
block outright.
The part that does the real work is what a warning costs. It has to be
acknowledged with a reason, that reason is stored on the visit record, and the
check-in screen counts the result as admitted with an override. A policy that
can be waved through at no cost stops being a policy within a fortnight. One that
leaves a countable trail stays one — and the count itself becomes the useful
number. If three hundred people were admitted on an override last month, the
grace period is set wrong, and that is worth knowing.
One hourly job, safe to run twice
Membership state is date arithmetic — active until an end date, expired after it,
lapsed some days later. The tempting implementation computes it at read time, and
it works until two screens disagree because they rounded a timezone differently.
Instead a single process runs every hour and settles it: Active and Expired are
recalculated from the dates, member status follows their memberships, and visits
left open are closed and flagged as automatic rather than given an invented
check-out time.
Two properties make it safe to leave running unattended.
It is idempotent — running it twice changes nothing the second time, so a
retry after a failure is free, and the button in the settings screen that runs it
on demand cannot hurt anything.
And it never overwrites a human decision. Frozen and Cancelled are states a
person chose, and the job does not touch them. This sounds obvious and is the bug
most often found in systems like this one: a nightly job that helpfully
reactivates a membership somebody cancelled that afternoon.