Only the Numbers Rot
This is the story behind VKT MarginMate, an Amazon FBA profit calculator that runs as a panel on the product page. Most of it is arithmetic. The interesting engineering is not the arithmetic — it is keeping the arithmetic's inputs honest when the inputs are somebody else's pricing sheet, published on a website that changes without telling you.
Break the product into parts and the split is immediate. The subtraction order — net revenue, referral, fulfilment, storage, ads, returns, landed cost — is stable; nobody re-derives that twice a year. The rates are not stable: Amazon revises the fee schedule several times a year, and the revision dates are not aligned with your release train. Meanwhile the extension ships as a zip that has to pass a store review before a byte of it changes.
So the design question was not "how do we compute a margin" but "how do we let one file change without shipping a new version of the extension" — while keeping the offline case working, the malformed-payload case harmless, and the user's trust intact. The answer is five pieces: an authored data module, an export tool, a daily alarm, two validation guards, and a calculator that is not allowed to know any of it exists.
One Authored File, Two Artifacts
The source of truth is a JavaScript file, not JSON, because that is what a service worker and a content script can both load without an async dance:
/**
* VKT MarginMate fee tables.
*
* SINGLE SOURCE OF TRUTH for every number the calculator uses.
* Bump `version` whenever a rate changes — the panel shows it to the user.
*
* ⚠️ These are *estimates* transcribed from Amazon's public fee schedules.
* Amazon changes them several times a year and applies category / size /
* program exceptions we cannot see from a product page. The UI always
* renders them as estimates and every fee can be overridden by hand.
*/
self.MM_FEES = {
schema: 1,
version: "2026.11",
updatedAt: "2026-11-01",
source: "https://sellercentral.amazon.com/help/hub/reference/GTG4BAWSY39N4Z8F",
marketplaces: { US: { ... }, UK: { ... }, DE: { ... } }
};
importScripts("data/fees.js", "license.js") at the top of background.js makes the bundled table the floor under every calculation. Note the header comment is part of the product: it says out loud that these are transcribed estimates, that they go stale, and that every one of them can be overridden. A fee table that does not admit it is an estimate is a lie with decimals.
The JSON copy is produced by a script rather than maintained by hand, so the two artifacts cannot drift apart in the way that only one of them gets edited:
// tools/export-fees.js
const sandbox = { self: {} };
vm.createContext(sandbox);
vm.runInContext(fs.readFileSync(src, "utf8"), sandbox, { filename: src });
const fees = sandbox.self.MM_FEES;
if (!fees || !fees.marketplaces) {
console.error("❌ data/fees.js does not define self.MM_FEES");
process.exit(1);
}
fs.writeFileSync(out, JSON.stringify(fees, null, 2) + "\n", "utf8");
A vm context instead of require(), because the file assigns to self — it is written for a browser world, and the exporter impersonates one instead of asking Node to swallow a construct it would reject. Then it refuses to write a file it cannot recognise, which is the cheapest possible test.
A Daily Alarm, and Two Guards
The update loop is about fifteen lines, and most of it is refusals:
// background.js
const REMOTE_FEES_URL = "https://annmax1983.com/static/marginmate/fees.json";
const FEES_CACHE_KEY = "mm_fees_remote";
async function refreshFees() {
try {
const resp = await fetch(REMOTE_FEES_URL, { cache: "no-store" });
if (!resp.ok) return;
const data = await resp.json();
if (!data || data.schema !== self.MM_FEES.schema || !data.marketplaces) return;
await set({ [FEES_CACHE_KEY]: { data, fetchedAt: Date.now() } });
} catch (e) {
// offline / not published yet — the bundled table stays in charge
}
}
chrome.runtime.onInstalled.addListener(async () => {
chrome.alarms.create(REFRESH_ALARM, { periodInMinutes: 1440, delayInMinutes: 1 });
refreshFees();
});
chrome.runtime.onStartup.addListener(() => {
chrome.alarms.create(REFRESH_ALARM, { periodInMinutes: 1440, delayInMinutes: 1 });
});
chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === REFRESH_ALARM) refreshFees(); });
periodInMinutes: 1440 because fee schedules are not intraday data, and a daily fetch is also the honest answer to "what does this extension do on my network". The alarm is recreated on onInstalled and onStartup: MV3 alarms survive a service-worker restart but not every browser profile event, and re-creating an existing alarm is idempotent, so there is no reason to be clever about it.
Three separate checks before anything is written, and each catches a different failure. resp.ok — a 404 or a captive-portal HTML page must not be parsed as data. data.schema !== self.MM_FEES.schema — the shape has to match, because a table with renamed fields would flow straight into the calculator and produce confident wrong numbers. !data.marketplaces — a JSON document that is valid but empty is not an update.
The two-field split is the part I'd defend hardest in review. schema is the compatibility contract; version is content. Bumping schema means old clients ignore the new file forever, which is exactly what you want when fields move — the failure mode is "stale but correct", never "fresh and misread". And the empty catch is a feature with a comment attached: a user on a plane, or a server with a bad deploy, keeps the last good table.
Version Strings Are Not Numbers
Adopting the fetched table is a comparison, and the comparison is a trap I want written down forever:
// background.js — feeTable()
/** Bundled table is the floor; a newer remote copy wins. */
async function feeTable() {
const r = await get(FEES_CACHE_KEY);
const remote = r[FEES_CACHE_KEY];
if (remote && remote.data && remote.data.schema === self.MM_FEES.schema) {
const newer = String(remote.data.version) > String(self.MM_FEES.version);
if (newer) return remote.data;
}
return self.MM_FEES;
}
Run the comparison the way the code does it. As numbers, 2026.11 is greater than 2026.9. As strings — which is what this line compares — "2026.9" > "2026.11" is true, because the comparison stops at the character where '9' meets '1'. So a remote table labelled September would beat a bundled table labelled November: the extension would "update" backwards, and every user would silently get the older rates.
The code is correct only under an undocumented convention: fixed-width segments. 2026.11, 2026.12, 2027.01 compare fine; the day someone exports 2027.1, the whole refresh mechanism inverts, quietly, for everyone. Three ways out — zero-pad and put it in a comment (what happened), compare parsed segments (correct, more code), or compare updatedAt as dates (also correct, and it couples adoption to a field that is easier to typo than a version). I chose the convention because the exporter prints the current version on success, so the string you publish is the string you saw. If you build something similar, write the convention in the file that defines the field, not in the file that consumes it.
The Calculator Never Finds Out
The table moves; the math does not, and the boundary is enforced by a rule at the top of the engine file:
/**
* VKT MarginMate fee engine.
*
* Pure functions: (marketplace fee table, inputs) → numbers.
* Used by the injected panel today and by the batch/compare views later, so it
* must never touch the DOM or chrome.* APIs.
*/
The refresh lives in the worker, which owns chrome.storage and the alarm. The panel is a content script, and it pulls the current table once per product page:
// content.js — start()
const ctx = await chrome.runtime.sendMessage({ type: "mm:context", marketplace: product.marketplace });
if (!ctx || !ctx.fees) return 0;
const fees = ctx.fees;
const market = MMCalc.marketFor(fees, product.marketplace) || fees.marketplaces.US;
// background.js
case "mm:context": {
const [fees, st, saved, pro] = await Promise.all([
feeTable(), settings(), savedList(), LicenseManager.hasCachedLicense()
]);
sendResponse({ fees, settings: st, saved, pro });
break;
}
One worker holds one cached copy, so sixteen open product pages do not mean sixteen fetches or sixteen storage reads; and the response carries the licence flag in the same round trip, because the panel needs it to decide what to render before the user types anything. The engine then does its arithmetic with a table it did not load, validate, or even know could be replaced:
// calc.js — compute(m, input)
const vat = m.taxInclusive ? n(m.vatRate) : 0;
const revenue = price / (1 + vat);
const rr = referralRate(m, input.category, price);
const rate = input.referralOverride != null ? n(input.referralOverride) : rr.rate;
const referral = Math.max(n(m.referral.min), price * rate);
...
const fixed = cost + freight + duty + fbaFee + st.fee + inbound;
const variable = rate + adPct + returnPct;
const profit = revenue - referral - ad - returns - fbaFee - st.fee - inbound - cost - freight - duty;
const denom = 1 / (1 + vat) - variable;
const breakeven = denom > 0 ? fixed / denom : null;
Two asymmetries in there are worth seeing, because both are places where a naive implementation is wrong rather than approximate. The VAT strip comes first: a German list price already contains 19%, so net revenue is price / 1.19 — but the referral is still charged on the gross price, which is why price * rate sits next to revenue in the same function and is not a bug. And the reverse solver divides fixed by 1/(1+vat) - variable because every percentage in the model is a share of the list price while the thing you are solving for changes that list price; if the variable share exceeds the net-of-VAT share, no price makes money, and the honest answer is null, not a negative number rendered in red.
Fallbacks That Refuse to Guess
Sixteen storefronts are matched in the manifest; three have fee tables. The other thirteen get this:
// calc.js — marketFor(fees, code)
if (tables[code]) return tables[code];
const base = tables.US;
const vatRate = VAT_BY_MARKET[code] || 0; // FR .2, IT .22, ES .21, NL .21, SE .25, PL .23, JP .1
return Object.assign({}, base, {
code,
currency: CURRENCY_BY_MARKET[code] || base.currency,
taxInclusive: vatRate > 0,
vatRate: vatRate,
fba: { mode: "manual" },
fallback: true
});
The comment in the caller states the intent: "so nothing is silently presented as US". Three deliberate parts. The consumption-tax strip is applied per storefront, because a Tokyo price is gross of 10% and a London price is gross of 20% whether or not we have an FBA number for that country. fba: { mode: "manual" } makes fba() return null, and a null fee renders as an input field rather than as a number — the panel asks you for the Japanese fulfilment fee instead of inventing one from the US tier table. That is the single most important line in the file, because it is where the product refuses to be a guess machine.
Two honest warts, recorded because they are in the shipped code. The US referral minimum of $0.30 travels to inherited markets with the rest of the object, so a Japanese listing gets a ¥-denominated comparison against a number that was never converted — a floor that is wrong in currency but roughly right in magnitude, and one I would rather document than quietly tune. And the UK and DE tables carry storage: { standard: { low: 0.0, peak: 0.0 } }, so the storage line displays zero rather than an invented rate. A zero that means "we don't have this" is a lie only if you present the total as precise; the panel presents every fee as an estimate and every one of them is overridable.
Assumptions Have to Be Visible
The search-results view computes a margin for a whole page of listings with almost no information, so it assumes — and says so, in the tooltip it renders:
// batch.js
const FREE_CARDS = 3;
const ASSUMED_DIMS = [10, 8, 4];
const costPct = s.batchCostPct == null ? 0.35 : s.batchCostPct;
// batch.js — the badge tooltip, one line in the source
el.title = `${t("batchBadgeTitle", "Estimated profit at current price")}\n${t("batchAssumption", "cost {c}% of price · 1 lb standard size", [...])}`;
A one-pound standard-size box at 35% landed cost is not a prediction about your product; it is a common axis so two listings can be compared. The badge is a hint about which products deserve a real calculation on the product page, and the tooltip is what stops it being read as a quote. Same discipline as the free tier: FREE_MAX_PRODUCTS = 3 and FREE_CARDS = 3 are checked where the write happens (saveProduct returns { ok: false, limited: true, count }) rather than in the UI that calls it, so the limit cannot be bypassed by whichever entry point forgot to ask.
The version string earns its keep here too — the popup prints "v" + fees.version next to the numbers. If my table is from November and your invoice disagrees, the UI has already told you which of us is stale. And the CSV export escapes titles by hand (replace(/"/g, '""'), quote if the field contains [",\n]) because Amazon product names are full of commas, quotes, and inch marks — a spreadsheet that silently mis-parses one row is worse than no export at all.
The Day the Endpoint Just 404s
Here is the part that this design hides from you until you look. Every failure mode in refreshFees() is silent by intent, so a refresh that never succeeds looks exactly like a refresh that never needed to happen. If the JSON was never uploaded, if the path moved, if a deploy dropped a directory: the extension works, the panel renders, the numbers are the bundled ones, and "updates without a store release" is a feature nobody can observe failing.
I checked, and the endpoint this post describes is currently one of those states — https://annmax1983.com/static/marginmate/fees.json answers 404 today, so every installed copy is running on the table baked into the package. Nothing is broken; the hot-reload is simply not wired up to a published file yet. Which is precisely the failure I want this section to prevent: an offline-first fallback makes every remote-data feature untestable by observation. The fix is not a stricter client, it is an external check — a scheduled request to the URL, asserting 200, valid JSON, schema === 1, and version matching the last export. Three lines in a CI job, and it converts an invisible degradation into an alert. Add it before you ship the second rate change, not after.
Checklist
- Split the product into the part that rots (rates) and the part that does not (the subtraction order), and give them different update paths.
- Author the data once and generate the second artifact; never hand-maintain two copies of the same numbers.
- Ship the authored table as the floor, so offline and remote-failure are the same code path: correct, just older.
- Validate with two fields —
schemafor shape compatibility,versionfor content. Old clients must ignore an incompatible table, not misread it. - Refuse before you store:
resp.ok, schema match, required key present. The catch block stays empty because the fallback is the point. - String version comparison only works with fixed-width segments; put the convention in the file that defines the field.
- Recreate the daily alarm on both install and startup;
chrome.alarms.createis idempotent, so don't out-think it. - Keep the calculator pure so it cannot learn where its inputs came from, and deliver the table through one worker so N tabs mean one fetch.
- When a market has no real number, render an empty field instead of a foreign estimate — and strip local consumption tax anyway, because that part you do know.
- Label estimates as estimates, surface the table version in the UI, and print batch assumptions in the tooltip.
- Monitor the remote endpoint from outside the client. A silent fallback is indistinguishable from a silent failure.
References
- chrome.alarms API Reference — Chrome Docs
- Service Worker Lifecycle — Chrome Docs
- chrome.storage API Reference — Chrome Docs
- vm (Virtual Machines) — Node.js Documentation
- Amazon FBA fulfilment fee schedule — Seller Central