DEV Community

Daniel Pertu
Daniel Pertu

Posted on

The four line function that stops our comparison page cheating

We have a comparison page. Us in one column, three competitors in the others, a row for price. Every SaaS has one and everybody knows the genre: the table is written by the company that wins it.

Which is exactly why the engineering decisions inside one are interesting. I want to talk about a four line function that exists solely to stop our own code from quietly cheating.

The setup

We charge in GBP and display an approximate local price, because Stripe converts at checkout and a number that changes at the payment step destroys trust. The conversion applies a buffer:

const CONVERSION_FEE_BUFFER = 1.04
Enter fullscreen mode Exit fullscreen mode

Stripe's presented exchange rate embeds a card conversion fee of roughly two to four percent. If we displayed a naive mid-market conversion, the customer would see a higher number at checkout than on our page. So we quote at the top of that band and round up. The invariant is that our displayed price is never lower than what Stripe will ask for.

That is unambiguously the right behaviour for our own price. Quoting high and charging less is the only safe direction for that error to point.

Now put a competitor's price in the same table.

The bug that is not a bug

The competitor prices are constants in GBP, taken from each provider's own UK site:

const GRADUATESFIRST_BUNDLE_PRICE = 5000  // pence
const JOBTESTPREP_PACK_PRICE = 7900
Enter fullscreen mode Exit fullscreen mode

The path of least resistance is to render them with the same helper as everything else on the page. formatForDisplay(GRADUATESFIRST_BUNDLE_PRICE, currency). Done, consistent, one code path.

And it would inflate every competitor's price by 4% and round it up to the next .99, in a table where we are also listing our own price.

Nobody would have decided to do that. There would be no commit saying "make rivals look more expensive". It would be a helper reused in the obvious place, and the output would be a comparison table that is systematically wrong in our favour by a few percent. Which in a price row is a meaningful amount, because the price row is the one people actually read.

That is the thing I find worth writing about. The dishonest version is the lazy version. There is no moment of temptation, there is just a function that already exists and happens to be wrong here.

Two functions, because there are two situations

So the conversion for numbers we are reporting is separate from the conversion for numbers we are charging:

/**
 * Converts a third-party GBP figure, such as a competitor's list price on the
 * comparison page, into the visitor's currency.
 *
 * Two deliberate differences from convertForDisplay:
 *
 * 1. No CONVERSION_FEE_BUFFER. That buffer exists because Stripe adds a card
 *    conversion fee on top of what *we* charge. There is no charge behind
 *    someone else's list price, so adding it would quietly inflate a rival's
 *    number inside a table we write and grade ourselves in. Quoting our own
 *    price high is the safe direction for that error to point; quoting theirs
 *    high is not.
 * 2. Rounded to the nearest whole unit rather than up to a retail `.99`. These
 *    figures are already labelled approximate, and "about EUR 58" reads as the
 *    estimate it is, where "about EUR 58.99" implies a price someone actually
 *    published.
 */
export function convertReferencePrice(pence: number, currency: DisplayCurrency): number {
  if (currency === BASE_CURRENCY) return Math.round(pence)

  const major = (pence / 100) * CURRENCIES[currency].rate

  return Math.round(major) * Math.pow(10, minorUnitDigits(currency))
}
Enter fullscreen mode Exit fullscreen mode

The second difference is the one I did not expect to care about. Rounding a competitor's price up to .99 makes it look like a price they published. ≈ €58 announces itself as our arithmetic; ≈ €58.99 impersonates their pricing page. Same error bar, completely different claim.

The two functions then get two formatters, and the rule is one line:

// Use this for numbers we are reporting; use formatForDisplay for our own prices.
export function formatReferencePrice(pence: number, currency: DisplayCurrency): string
Enter fullscreen mode Exit fullscreen mode

Both rows sit side by side in the table:

{
  feature: 'Typical entry price',
  cogniprep: `From ${formatForDisplay(MIN_PROVIDER_PRICE, currency)}`,
  graduatesfirst: `≈ ${formatReferencePrice(GRADUATESFIRST_BUNDLE_PRICE, currency)} bundle`,
  jobtestprep: `≈ ${formatReferencePrice(JOBTESTPREP_PACK_PRICE, currency)} per pack`,
}
Enter fullscreen mode Exit fullscreen mode

Two helpers that differ by one multiplier and a rounding mode. If you saw them in review without the comments you would file a ticket to merge them, and merging them would reintroduce the problem. The comments are load bearing. They are not explaining what the code does, they are explaining why the obvious refactor is wrong.

Things we did on the same principle

Since the whole page is a self-graded exam, a few other rules fell out:

Every rival cell is allowed to say something good. The free-tier row says "Free tier", "Sample tests", "Free tests" for the other three, because all of that is true. A comparison table where the other columns are solid red crosses tells the reader more about the author than the market.

Positive claims, not absences. false is reserved for a feature a provider genuinely does not offer. Everything else is a short description of what they do instead: "Guides only", "Recording only", "Benchmarking".

The prices are marked approximate and are told to be verified. Competitor pricing is promotional and changes constantly. The page says so, and points readers at each provider's own site rather than asking them to trust a constant in our repo.

Rates are stated, not hidden. The footnote under any converted price gives the exact rate used, so anyone who checks the arithmetic finds it reconciles.

Try the demonstration

The interesting thing about this page is that you can watch both conversion policies operate on the same screen at the same time.

  1. Open cogniprep.app/comparison and find the "Typical entry price" row.
  2. In GBP, everything is a plain quoted number. No conversion anywhere.
  3. Turn on a VPN somewhere in the eurozone, or the US, or Japan, and reload.
  4. Our own price is now a rounded-up retail number with the fee buffer in it. The competitor prices are round whole units with an in front and no buffer.
  5. The footnote tells you the exact rate used to produce our figure, so you can check the difference between the two policies yourself.

Five percent is not a lot. It is also exactly the size of error that nobody catches and that adds up across every row of every comparison you publish.

The general lesson

Look for places where a helper written for your data is being applied to someone else's data. Analytics that count your events and a competitor's. Benchmarks that time your library and theirs. Any table where you are both a participant and the scorekeeper.

The rounding, the buffers, the defaults you chose for good reasons on your own numbers are almost never the right defaults for numbers you are reporting about somebody else. And because reusing the helper is easier than not reusing it, the bias arrives on its own. You do not have to choose it, you have to notice it.

Top comments (0)