DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Premium does not unlock the games, and the access check never looks at your tier

Most paywalls are a ladder. Free, Pro, Team, Enterprise, each rung a superset of the one below, one column on the user row, one comparison everywhere. That model is so common that it gets reached for before anyone checks whether the products actually nest.

CogniPrep's do not nest. Four things can be bought, and none of them contains another:

Purchase Column What it grants
Provider Access unlocked_providers (array) Unlimited play of one assessment provider's practice tests
Scores and Reports plan_tier Scores, percentiles and feedback reports
Assessment Centre assessment_centre_unlocked (boolean) The exercise library
Interview Access a credit count A number of recorded mock interview sessions

Someone can buy scores without unlocking a single provider beyond the free tier. Someone else can unlock three providers and never buy scores. Neither is "higher" than the other. The moment you express that as a tier ladder you are writing a lie down in a column, and every gate downstream inherits it.

The access check does not consult the tier

Here is the whole game access rule:

export function hasGameAccess(
  gameId: string,
  tier: PlanTier,
  unlockedProviders: string[] = []
): boolean {
  const game = getGameById(gameId);
  if (!game) return false;

  // Free-tier games are always accessible on any plan
  if (game.freeTier) return true;

  // Any tier can play a paid game if the provider has been unlocked
  return unlockedProviders.includes(game.provider);
}
Enter fullscreen mode Exit fullscreen mode

tier is in the signature and is never read. That is not an oversight, it is the invariant: what you can play is decided entirely by which providers you own. A Premium account with no unlocks has exactly the same library as a free account with no unlocks.

Its neighbour goes one step further and says so in the parameter name:

export function getPlayLimitForGame(
  gameId: string,
  _tier: PlanTier,
  unlockedProviders: string[] = []
): number | null {
  const game = getGameById(gameId);
  if (!game) return null;
  if (unlockedProviders.includes(game.provider)) return null;
  return FREE_PLAY_BUDGET_PER_PROVIDER;
}
Enter fullscreen mode Exit fullscreen mode

Premium used to lift the free play budget. It no longer does, because "buy scores, get more plays" is exactly the sort of quiet coupling that makes a pricing page impossible to write honestly. The parameter stayed for call site compatibility with an underscore in front of it, which is a small piece of honesty in a signature: every caller can see that it is accepted and ignored.

One design note worth stealing: the free budget is scoped per provider, not per game and not per account. A visitor can sample a provider's entire suite and still hit one wall, and that wall sells one specific thing. Per game budgets produce the opposite: a user who spread their plays thin hits ten small walls and buys nothing.

The failure direction is chosen, not accidental

plan_tier is a plain string column, so reads used to cast it with as PlanTier. That is a compile time assertion over a runtime value: a null, a legacy tier name or a typo in a manual SQL update flows through typed as a tier and then silently fails a premium check somewhere three layers down.

export function toPlanTier(value: unknown): PlanTier {
  return isPlanTier(value) ? value : DEFAULT_PLAN_TIER;
}
Enter fullscreen mode Exit fullscreen mode

Coercion has a direction, and the direction is a product decision rather than a style preference. Unrecognised means free, so the worst case is that a paying user is shown an upgrade prompt and emails us, which is loud and fixable. The other direction hands entitlements to someone who did not pay, which is silent and is discovered in a revenue report.

The tier union is also exported as a runtime array rather than only a type, so the guard is derived from the same list the type is. Two hand maintained copies of "valid tier names" is a bug waiting for a deploy.

The code was right and the copy was wrong

Here is what this architecture actually costs, and it is not in the access layer. Three separate commits in this repo exist only to fix sentences:

  • clarify that the scores upgrade does not unlock provider games
  • clarify that the exercises unlock scope excludes games
  • hide the free tier badge when the provider is already unlocked

Not one of them changed a gate. Orthogonal entitlements are easy to enforce and hard to describe, because English is full of words like "unlock", "access", "premium" and "full" that imply a ladder. Every surface that says "upgrade" has to answer "to what, exactly", and if one card gets it wrong, the user who bought scores and then found the games still locked is entirely right to be annoyed.

The pricing page ended up with the scope written into the card itself, immediately under the price, rather than in a footnote: Scores and feedback only. The games themselves come with Provider Access. That sentence exists because of a support email.

The same discipline applies to the numbers. The free game count is derived with a filter over the library, and the free play budget is one exported constant, and both the pricing page and the in-app help FAQ read those, so the two places that quote the limits cannot drift from the place that enforces them.

Where two entitlements do touch

Exactly one rule spans the boundary, and it is the one a free account notices first: you see the raw score of your first completed session with each provider, and nothing after that until you buy scores.

Three things make that rule survive contact with reality:

  1. It is decided on the server at save time from the sessions already in the database, never from anything the client claims about whether it is the first.
  2. It is scoped per provider, not per account. A candidate invited to tests from two different providers gets a fair taste of both.
  3. Only games that produce a rankable score count. Games that produce a profile rather than a score have no number to reveal, so playing one neither shows a free score nor spends it. Without that carve out, a user whose first session happened to be a profile style game would spend their one free score on something that never displayed one.

Point 3 is the kind of rule that is obvious in hindsight and invisible in a spec, and it only turned up by walking the actual first session of a real provider.

See it

  • cogniprep.app/pricing: read the second card. The scope sentence sits under the price, not in the small print. Note that no card claims to include another card.
  • Same page, expand the FAQ item Do I need the scores upgrade to play games?, then Can I try before I buy?. The second one currently answers "36 free practice games across every provider ... 15 free plays per provider, shared across that provider's games". Both numbers are read from the constants the access checks use, so the page cannot quote a limit the server does not enforce.
  • Sign up free and look at the games dashboard: a provider you have not unlocked shows its free games playable and the rest locked, with your tier making no difference to which is which.

Top comments (0)