Flow
Where the room check runs
1Invite
Always checks before the new person is saved.
2Bring back
Checks only if the person had been removed.
3Move over
Checks the new team only if the person is active and the team changes.
4First sign-in
Checks after the new team is made, before the person is saved.
5Store create
Checks the store cap inside the same transaction, before any write.
Every new plan I could make from my admin screen would have come with no member limit.
Earlier that day I'd made plans editable from the admin screen of my scheduling app. You could make a tier and set how many stores and people a workspace on it gets. A few hours later I hooked those limits up to the places where people join a workspace. Part of that was fixing how a plan gets looked up.
Why an unknown plan meant no limit
The lookup only knew the built-in tiers. Any other code came back empty, and that meant every custom plan I could now make. The new member check treats an empty limit as "no limit." That's right for a tier with no cap. It's wrong for a tier the lookup just couldn't find.
So I changed the order. A saved plan with that exact code wins. If there isn't one, the built-in plan for that code is used. If there's no built-in either, the free tier is used. An unknown code now gets the free tier's limits, not none. Two tests hand in a made-up code and check that the lookups happen in that order.
Four ways onto a team
The invite form is the obvious way in. But an admin can also bring back someone who was removed, or move a person over from another workspace. And signing in for the first time makes an account right there. The middle two are updates, not new rows, so they're easy to miss.
All four now ask the same question: is there room for one more? The admin paths only ask when the change really adds someone.
Bringing a removed member back counts as a new seat, so that path checks for room first.
Each door has a test that sets the count at the limit and checks that nothing gets written. Sign-in is tested on only one of its two paths.
The sign-in door is mostly for show right now. Both sign-in paths make a brand-new workspace first, so it's empty when the check runs. If it ever did say no, that empty workspace would be left behind.
The check that asked the wrong table
To bring someone back or move them, those handlers first load the person. That same block got copied into the handler that edits a workspace, where it looked up a person by the workspace's ID. No person has that ID, so every workspace edit there would have failed, plan changes included. It compiled and read fine. A follow-up commit a few minutes later fixed the lookup and added two tests for it.
Stores get their own check
Making a store now checks the store limit first, inside the same transaction, before anything is saved. A test puts a workspace on a plan with no stores allowed and checks that nothing got written. The store check and the member check throw different kinds of errors, which I didn't sort out here.
Two invites can still take the last seat
The tests use stubs, so they show the calls happen in order, not that the real count is right. The check counts first and saves second, so two invites at the exact same moment could both grab the last seat. Any workspace whose plan code matches no saved or built-in plan now gets the free tier's limits. If one of those is already over, it keeps everyone it has but can't add a single person, not even by bringing someone back.