OSPulse
← Back to the blog
Rob

The price on the page and the price on the card

The checkout showed £249 a month. Stripe was set to take £2,988 plus VAT in one hit, and that figure appeared nowhere in the flow. How two correct numbers drifted, and the build gate that stops it happening again.

Nobody had paid us yet. That's the only reason this is a blog post and not an apology.

We're selling a founding place on Article 14 Signal at £249 a month, billed annually. Card captured now, nothing taken until launch, cancel any time before. Fairly ordinary as these things go, and the page said all of it clearly enough.

Then somebody asked me to double-check the price, and I went and read the code instead of the page.

The Stripe price our checkout resolves isn't monthly. It's annual. One charge, £2,988, taken in a single hit on the first of November.

Now, £249 a month billed annually is £2,988 a year. The maths is fine. That isn't the problem.

The problem is that the number £2,988 appeared nowhere. Not on the pricing panel, not on the button, not in the fine print under the card form, not on the confirmation screen after you'd handed your details over. I grepped the whole journey for it. Nothing. The largest number anywhere in the flow a customer walks through was 249.

So the deal we were actually offering was: look at a page where the biggest number is £249, give us your card, and in three months we'll take £2,988 off it.

Two sources of truth for one number

I don't think anyone made a decision that caused this. That's rather the point.

The price lives in two places. There's the Stripe price object, which is what actually moves money, and there's the string in the site config, which is what the customer reads. Both were correct in their own terms. £249/mo billed annually is a perfectly honest description of an annual subscription, and it's how half the SaaS industry writes it. Nobody thinks they're being sly when they do.

It only becomes a problem at the moment of the charge, and the moment of the charge is the one moment the website never sees.

That's the shape of the bug, and I suspect it's a common one. Not "someone typed the wrong number", which you'd catch. Two systems each holding a version of the same fact, each internally consistent, drifting on a detail, the billing period, that only matters at the one instant neither system is looking.

Ask yourself what your own price's second source of truth is. There usually is one.

The fix, and then the actual fix

The immediate fix was copy. The annual total leads now, in the largest type on the panel, above the card form rather than below it. The monthly figure is still there because it's genuinely useful for comparison, but it's demoted and it can't appear on its own. And the fine print says what it should have said all along: your card is charged £2,988 plus VAT once, on 1 November 2026, and annually after that.

Better. But copy fixes rot. Someone edits a panel in nine months, the annual figure gets shortened to fit a layout, and we're back.

So the price is now a single constant, and everything else derives from it. The structured data Google reads doesn't have its own hardcoded number any more. It parses the displayed string and throws at build time if it can't:

ts
const PRICE_NUMERIC = (() => {
  const n = Number(SITE.pricing.foundingAnnual.replace(/[^0-9.]/g, ''))
  if (!Number.isFinite(n) || n <= 0) {
    throw new Error(`SITE.pricing.foundingAnnual is not a parseable price: ${SITE.pricing.foundingAnnual}`)
  }
  return n
})()

That's eight lines and it isn't clever. It just means the price in the machine-readable markup and the price on the page are the same fact, rather than two facts that agree today.

Then a test that fails if the disclosure disappears:

ts
await expect(page.getByText(/charged £2,988 plus VAT once/i)).toBeVisible()

Tests usually assert that a feature works. This one asserts that a sentence is still on the page, which felt odd to write until I thought about which of the two I'd rather have break the build.

The part I keep chewing on

The reason this was worth writing up isn't the bug. It's that everything about the flow was working. Checkout worked. The card captured. The confirmation sent. Every test passed, because every test was asking whether the machinery ran, and it ran beautifully.

Not one of them asked whether the person at the other end knew what they'd agreed to.

I don't think there's a neat process fix for that. The nearest thing I've got is a habit: when money is involved, go and read the thing the customer actually sees, in the order they see it, and ask what the largest number on the screen is. Then go and look at what the payment processor is configured to do. If those two disagree, it doesn't matter how green your test suite is.

We caught it with no subscribers, which makes this a cheap lesson and an easy post to write. Ask me again in a year and I'd probably rather have found it this way than any other.

See your own exposure

The most useful thing we can show you is your own dependency tree. Find out which packages would start a CRA Article 14 clock, free and with nothing to install.