DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our prices cannot drift and our quotas can, and I found out by reading our own terms of service

There is one number in our app that cannot be wrong in two places at once:

/** Pro: one host account, £30/month. */
export const PRO_PRICE_PENCE = 3000
Enter fullscreen mode Exit fullscreen mode

The pricing card renders it, the checkout line item is built from it, the structured data for search engines reads it, and the local price in each of 24 currencies is computed from it. Nothing types a price. A hardcoded "£30" in a component could not be localised and could not be checked against anything, so there are none.

The same file, four lines up, holds the plan quotas:

const LIMITS: Record<PlanType, PlanLimits> = {
    pro: {
        maxConcurrentSessions: 1,
        maxSessionsPerDay: 5,
        maxTables: 50,
        maxCustomPacks: 5,
        maxAccounts: 1,
        maxConcurrentLogins: 3,
    },
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Those are enforced. Every one of them is read by a server action that refuses something. And every one of them is also written out as English, by hand, somewhere a customer reads it.

Twenty four strings in one component

The pricing cards are two arrays of feature lines:

const proFeatures = [
    { label: 'Up to 5 quizzes per day', included: true },
    { label: 'Up to 50 tables per venue', included: true },
    { label: 'All built-in question packs', included: true },
    { label: 'Up to 5 custom question packs', included: true },
    // ... eight more
]
Enter fullscreen mode Exit fullscreen mode

Twelve per plan, twenty four in total, with four numbers in them that exist as integers one import away. You can read all of them on the pricing page, and today they are all correct.

I am not going to argue that these should be template literals. Feature lines are marketing copy as much as they are facts, the order matters, which ones render as a cross rather than a tick matters, and "Up to 5 custom question packs" is a better sentence than anything I would generate from maxCustomPacks. Hardcoding them is a reasonable call.

What is not reasonable is having no way to notice when one of them stops being true.

The two that stopped being true

Here is what I found while writing this post. Section 4.2 of our terms of service opens with this sentence, live on the site right now:

A paid Pro subscription gives you unlimited quiz sessions.

Pro is maxSessionsPerDay: 5. The server action that starts a quiz counts today's sessions and refuses the sixth. The pricing page, two clicks away, says "Up to 5 quizzes per day". The prose in the paragraph below the cards spells the whole thing out correctly, including which four limits Ultimate lifts.

The second one is inside the product, in the banner a new account sees once:

<p className="flex-1 text-sm">
    <span className="font-medium">Welcome!</span>{' '}
    You have <span className="font-medium">1 free quiz session</span> to try PubTrivia.
    Upgrade to Pro any time for unlimited sessions.
</p>
Enter fullscreen mode Exit fullscreen mode

Same claim, same wrongness, and this one is shown to exactly the people deciding whether to pay.

For the record, since this post is now the most detailed public statement of it: the operative numbers are the ones on the pricing page and in the code that enforces them. Pro is five quiz sessions a day and Ultimate is unlimited. Those two sentences are wrong, and the correction is to use the word "unlimited" only about the plan it is true of.

Why these two and not the other twenty four

It is worth asking why the drift landed in the terms and a welcome banner rather than in the pricing cards, because the answer generalises.

The pricing cards are the page whose entire job is to be the quota list. Anyone who changes a limit opens that file, because it is the obvious place a limit is written down. The paragraph of prose below the cards survived too, for the same reason.

The terms of service is a legal document that happens to contain a product claim. The welcome banner is a one-time nudge that happens to contain a product claim. Nobody changing maxSessionsPerDay would think to grep the terms page, and nothing in the repo would prompt them to. Both files were written when Pro was the only paid plan and unlimited was true of it.

The general shape: copy drifts in the places where a product fact is a passing remark rather than the subject. The subject gets maintained because people go there on purpose.

What the fix actually is

Not "derive the terms page from the constant". A sentence in a legal document should be legible as a sentence and should not change shape because somebody edited a record in TypeScript.

The cheap version is a test. We already have 21 tests asserting our page registry is internally consistent, and a test can read a page file as text. There is precedent for it in our own suite, because importing a page boots Supabase, so the metadata test reads the files and matches on strings instead:

// assert no page claims a quota that no plan grants
Enter fullscreen mode Exit fullscreen mode

A test that fails when the word "unlimited" appears within so many characters of "Pro" on a page we ship is crude, and it would have caught both of these the day the second plan shipped.

The second thing, which is cheaper still: when a product fact appears in a file whose subject is not that fact, leave a pointer next to it. One comment saying which constant the sentence is a restatement of turns an invisible dependency into a grep hit. Our tables action does the honest version of this, because its message is built from the number it enforces and therefore cannot lie:

message: `You've reached the ${limits.maxTables}-table limit on your ${planType === 'pro' ? 'Pro' : 'Trial'} plan. Upgrade to Ultimate for unlimited tables.`
Enter fullscreen mode Exit fullscreen mode

That sentence will be right forever, including the "unlimited" at the end of it, which is about the plan where unlimited is true.

If you want to check the numbers for yourself, the plan comparison is on the pricing page, what a quota actually buys in practice is written up feature by feature under features, and the plain-English answers about the free trial and cancelling are in the FAQ.

Top comments (0)