We run BaseAcross, a site for founders outside the US forming US companies. A price on a site like ours shows up in more places than you'd guess: the landing page, the pricing table, the checkout total, guides, FAQ answers, glossary entries, emails, JSON-LD offers and the llms.txt file. On 29 October 2026 our founder said our pricing didn't look consistent across pages.
So we audited it. We fetched all 516 URLs in the sitemap and checked 16,540 dollar amounts against the catalogue. No page printed a wrong number for a product we were selling. The inconsistency he saw was real, and it came from three other places:
- Plans we no longer sold still had prices on the site, including JSON-LD offers on three pages. A reader who clicked through would find a different plan at a different price on the order form.
- An add-on that was switched off was still priced in one guide.
- One state charges an LLC and a corporation different yearly amounts. Both totals were correct, and different pages showed different ones without saying which was which.
The audit also counted state fees typed by hand into prose: 69 Wyoming and Delaware fee figures across 16 files, each one right on the day it was typed. Each of them would stay put the next time the state changed its fee.
What follows is the setup we built after it: one home for every figure, tokens for prose, and a test that walks every string in src/ looking for a price somebody typed.
The catalogue is the only place a price may live
Plans, add-ons and the state fees we pass on live in src/lib/data/catalog-picks.ts and a short list of product modules beside it. Public pages read plansOnSale, a filtered view of the catalogue, and a page that names one plan goes through a helper that throws for a retired one:
export const plansOnSale: PlanPick[] = planPicks.filter((plan) => plan.onSale); /** A plan by key, for a page that names one: throws for a retired plan. */ export function planOnSale(key: PlanKey): PlanPick { const plan = plansOnSale.find((p) => p.key === key); if (!plan) throw new Error(`Plan ${key} is not on sale.`); return plan; } The arithmetic gets the same treatment. Whether a plan includes the state's filing fee is decided in one function, and everything that adds a state fee to a plan price calls it:
/** * Everything that adds a state fee to a plan price goes through here, so the * figure on the pricing table, the one in the wizard's running total, the one * on the review and the one Stripe charges cannot disagree again. */ export function stateFeeFor(plan: Pick<PlanPick, "coversStateFee">, state: { stateFee: number }) { return plan.coversStateFee ? 0 : state.stateFee; } Formatting has one home too. money() prints whole amounts without cents and keeps cents only when an amount has them, because a page showing a rounded figure next to a card charge with cents looks like a mistake even when it's a rounding choice:
export function money(value: number, options: Intl.NumberFormatOptions = {}) { const places = Number.isInteger(value) ? 0 : 2; return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", minimumFractionDigits: places, maximumFractionDigits: places, ...options, }).format(value); } Prose gets tokens instead of template literals
Code was already reading the catalogue. The 69 typed fees were all in prose, and they were typed for a plain reason: writing ${money(DE.annualCost)} in the middle of a long guide paragraph is clumsy, so people typed the number. The fix had to keep plain strings for the people writing them.
So prose now writes a token, and prices() swaps it for the catalogue's figure when the module that holds the text loads:
const TOKEN = /\{((?:price|fee|addon|total)\.[A-Za-z0-9.]+)\}/g; export function prices(text: string): string { return text.replace(TOKEN, (_, name: string) => { const amount = PRICE_TOKENS[name]; if (amount === undefined) throw new Error(`Unknown price token {${name}}`); return money(amount); }); } A sentence in a guide reads like this (an illustration in the shape our data files use):
a: prices("Launch is {price.launch} a year, and Delaware then charges {fee.DE.annual.llc} for an LLC."), PRICE_TOKENS is built from the catalogue: plan prices, add-ons, every state fee field, and a few totals such as a plan plus the state's filing fee. Three properties fall out of that.
- A typo like
{fee.DE.anual.llc}throws when the module loads, so it fails the build and never reaches a reader. - A retired plan has no token, so its price can't be written into a page.
prices("{price.build}")is a test case that must throw. - The regex only matches the four token families, so the add-on blurbs' own
{state}and{turnaround}placeholders pass through for their own filler.
The test reads every string in src/
Tokens only help if people use them. src/lib/price-literals.test.ts checks that they do. It builds the set of every amount the catalogue charges or passes on, formatted by money(), then parses every .ts and .tsx file with the TypeScript compiler API and looks at string literals, template literals and JSX text:
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { words = node.text; context = words; } else if (ts.isJsxText(node)) { words = node.text; context = node.parent.getText(); } // … template expressions are read with their ${} holes blanked out for (const m of words.matchAll(AMOUNT)) { if (!guarded.has(m[0])) continue; // not one of our figures if (!OURS.test(context)) continue; // not next to our words // … allow-list check, then report file:line and the words around it } We parse instead of grepping so that comments are never read. A comment can explain where a figure came from without failing the build. Code that computes a figure from the catalogue isn't text, so it passes too.
A failure prints file:line $X | the 80 characters either side, so it shows the sentence as well as the line.
Keeping false positives down
A site about company formation is full of dollar figures that aren't ours: other providers' prices, other states' fees, worked examples of a payment. The test filters in layers.
-
AMOUNTmatches a whole figure, so a larger amount that starts with the same digits isn't read as one of ours. - Only figures equal to one we charge or pass on count.
- Only figures in a string that also contains one of our words count: a state we form in, a plan name, "state fee", or "we" and "our".
- The catalogue modules themselves are exempt, along with the token resolver.
- Anything left goes on an allow-list, and the allow-list is held to its own rules:
const ALLOW: { file?: RegExp; near?: RegExp; amounts?: string[]; reason: string }[] = [ { file: /^lib\/site\/data\/competitors\.ts$/, reason: "Rivals' prices, as each publishes them." }, { file: /^lib\/site\/sources\.ts$/, reason: "The source's own words, which the source check looks for." }, { near: /authori[sz]ed shares|…/, amounts: ["$…"], reason: "Delaware's authorised-shares franchise tax, not a certificate fee." }, // … ]; it("keeps the allow-list small, each entry with its reason", () => { expect(ALLOW.length).toBeLessThanOrEqual(12); for (const a of ALLOW) { expect(a.reason.length).toBeGreaterThan(10); if (a.near) expect(a.amounts?.length, a.reason).toBeGreaterThan(0); } }); The last rule matters most. An entry matched by nearby words has to name the exact amounts it lets through, so a second figure typed into the same sentence still fails.
What it doesn't catch
The guarded set is today's catalogue. If a price changes and an old figure is still typed somewhere, that old figure no longer matches anything and the test passes. The test catches a literal at the moment it's written, while it still equals a real price. The literals that were already there had to be found by the audit and converted by hand.
Retired plans needed their own tests for the same reason. One asserts that only the two plans on sale are on sale. One checks that structured data offers only those plans. One serialises the add-on pages, guides, glossary and FAQ and fails on a retired plan's name or price. That last matcher needed care, because one retired plan was called "Build", which is also a verb, so it only matches the name in plan-shaped phrases like "on Build" or "Build plan".
Beyond that, the test only knows dollar-sign figures, and "we" and "our" are a blunt way to decide whose number it is. We'd rather take the occasional allow-list entry than miss a real one. The audit found 69 typed fees, each right on the day it was typed; the test's own header says it exists so the 70th is caught when it's written.
Drafted with AI assistance and checked against our code and the sources it cites.
Cover photo: Anh Phan on Unsplash
