DEV Community

Daniel Pertu
Daniel Pertu

Posted on

null meant unlimited, so our free plan had more of the paid feature than the paid plan

Every plan limit in our app is six numbers in one table, and the whole file is about sixty lines. It is still where the most embarrassing bug of the project lived for a few weeks.

export interface PlanLimits {
  /** Max simultaneously active quiz sessions for this subscription */
  maxConcurrentSessions: number
  /** Max quizzes that can be started per calendar day (UTC). null = unlimited */
  maxSessionsPerDay: number | null
  /** Max active tables in the venue. null = unlimited */
  maxTables: number | null
  /** Max custom question packs. null = unlimited */
  maxCustomPacks: number | null
  /** Max accounts (owner plus invited hosts) per venue */
  maxAccounts: number
}
Enter fullscreen mode Exit fullscreen mode

null = unlimited is a reasonable convention and it reads fine in the type. The problem is what a call site does with it:

const limits = getPlanLimits(planType)

if (limits.maxCustomPacks !== null) {
  // count what exists, compare, refuse
}
Enter fullscreen mode Exit fullscreen mode

null is not merely "no limit". It is the value that skips the check entirely. Which makes it the most dangerous thing you can leave in a cell by accident, and in the free trial row, that is exactly what was in the cell for custom question packs, underneath a comment that said the trial has none.

So for a while: the trial, which costs nothing, could create an unlimited number of custom packs, while Pro, which costs £30 a month and advertises up to five, was correctly capped at five. The comment and the value disagreed and the comment was the one everybody read.

// 0, not null. The contract above is "null = unlimited", and both call sites
// skip the check entirely when the value is null, so the comment said "no
// custom packs" while the value said "unlimited", and a trial account could
// create more packs than a paying Pro one.
maxCustomPacks: 0,
Enter fullscreen mode Exit fullscreen mode

The lesson is not "write better comments". It is that a sentinel for infinity and a zero for none sit one keystroke apart in the type and mean opposite things, and that the infinity value is the one that disables your guard. If I were designing the table again the unlimited case would be a string, or the check would be a function the row has to supply, so that "no limit" has to be said out loud rather than left blank.

One table, read by both the page that advertises the limit and the code that enforces it

The pricing page says fifty tables on Pro. The action that adds tables reads the same module. There is no second list of numbers anywhere, which is the only reason the page and the behaviour cannot drift into disagreement, and the only reason the plan comparison is safe to write as prose.

That matters more than it sounds. The expensive version of this bug is not a crash, it is a support email that says "your site told me fifty".

The enforcement is at the mutation, and it counts rather than trusts

The dashboard disables the button at the limit. That is cosmetic. The real check happens inside the server action, against a fresh count from the database, because the UI state is a suggestion and the user has a second tab.

The part that took a revision is bulk input. Adding ten tables when seven slots remain is not the same failure as adding one table when zero remain, and one message cannot serve both:

  • At the limit: "You've reached the 50-table limit on your Pro plan. Upgrade to Ultimate for unlimited tables."
  • Below it, but not far enough: "You can only add 3 more tables on your current plan (limit: 50)."

The second message is the one that stops the support email, because it tells the user the number they should have typed.

Downgrades are the direction that hurts

Going up a plan is arithmetic. Going down is a decision about somebody's access.

Ultimate carries five host accounts on one subscription. When that subscription ends, the venue drops to one, and four people who could log in this morning cannot log in this afternoon. Something has to choose which four.

const excess = members.length - (newLimit - 1) // -1 to account for the owner seat
if (excess <= 0) return

const toRevoke = members.slice(0, excess) // oldest invites first
Enter fullscreen mode Exit fullscreen mode

Three decisions are compressed into those lines:

  • The owner is never a candidate. They are the one holding the card, and the query that builds members excludes them before any counting happens.
  • That is what the - 1 is doing, and it is the off-by-one that matters. Forget it and the code revokes one host too many every time, which looks exactly like a glitch rather than a policy.
  • Oldest invites go first. The alternative, newest first, protects the person who was just added over the person who has been hosting the quiz since March. Neither is obviously correct, but one of them has to be written down, and a rule nobody wrote down is just whatever order the database felt like returning.

Revoked is also not deleted. The row stays, with its invite token cleared, so re-subscribing is a reinstatement rather than a re-invitation, and the history of who hosted what survives the gap.

The login cap is eventually consistent on purpose

There is one limit that is not read from the database on every request: a small cap on how many browsers one account can be signed into at once, which exists as a backstop against a login being passed around a group of venues.

That number is written to Redis by the Stripe webhook when a subscription activates, changes or cancels, and read by the auth layer on dashboard requests, with a default applied when the key is absent. So it is eventually consistent with the subscription state, with a TTL set comfortably beyond any billing cycle.

That is a deliberate choice rather than a shortcut. The alternative is a database round trip on every authenticated navigation to enforce a rule that almost never fires, and the cost of being a few seconds late to tighten it is nothing, while the cost of adding a query to every request is paid forever. Worth noticing which way the default points, though: when the key is missing the user gets the ordinary allowance, not zero, because a Redis blip must not lock paying customers out of their own dashboard mid-quiz.

What the table does not contain

No feature flags, no per-customer overrides, no "enterprise" row with everything set to null. Three plans, six numbers each, every one of them visible on the public pricing page.

That is a constraint I would defend for a while. The moment one customer has a bespoke limit, the numbers stop living in a table and start living in a database column, and then every page that quotes a limit needs to know which customer is reading it.

See it yourself

Top comments (0)