Build notes · Commerce & Edge PIM Architecture
Building a WhatsApp-Native Enterprise PIM Master for Precious Commodity Commerce
Most Product Information Management (PIM) systems make a simple assumption: a product price is a static number. For generic retail that assumption works. For precious metal commerce—jewellery, bullion, and custom manufacturing—it breaks on day one.
A gold piece does not have a price. It has a rate for its purity, a net weight, a making charge, sometimes a stone, and a tax rate — and the price is what those come to this morning. Every generic platform models the answer and not the inputs, which is why a jeweller on Shopify or WooCommerce ends up with a bolt-on app, a nightly re-index, or somebody retyping figures before the shop opens.
We built the catalogue for a single real shop, and then tried to put its actual spreadsheet through it. This note is mostly about what that found, because the failures are more useful than the architecture: each one looked like working software right up until somebody checked.
The short version
- One pricing rule, two surfaces. The price published to a storefront and the price quoted in a WhatsApp reply come from the same function. A feed tool cannot copy this without building an inbox.
- A gram was being read as a thousand grams.
14.200parsed as14200, from a file the shop had written correctly. - An import reported success and created nothing.
{"success":true,"created":0}, for any spreadsheet with an image column. - Three capabilities shipped with no caller but a test. Announced, documented, unreachable.
- It replaces a $30/month add-on and answers the customer as well.
1. The rule that has to serve two surfaces
This is the part worth copying, so it goes first.
A rate-linked price is a cached result. It was computed from a rate that was current when it was computed, and it goes stale. Every catalogue system has to decide what to do with a stale price, and the usual answer is to publish it anyway, because the number is right there in the column.
The decision lives in exactly one function, priceEligibility() in
pricing-mode.mjs, and it returns a reason rather than a boolean:
// uni-cloud/src/services/commerce/pricing-mode.mjs
export function priceEligibility(item, { now = Date.now() } = {}) {
const mode = modeOf(item);
if (mode === PRICING_MODE.BESPOKE) {
return { ok: false, code: 'bespoke', retryable: false,
reason: 'made to order, so it is never published to a storefront' };
}
const price = Number(item?.price_amount);
const hasPrice = Number.isFinite(price) && price > 0;
if (mode === PRICING_MODE.RATE_LINKED) {
if (!hasPrice || !item?.priced_at) {
return { ok: false, code: 'awaiting_rate', retryable: true,
reason: "rate-linked, and today's rate has not been applied yet" };
}
const stampedAt = Date.parse(item.priced_at);
if (!Number.isFinite(stampedAt) || now - stampedAt > RATE_PRICE_MAX_AGE_MS) {
return { ok: false, code: 'stale_rate', retryable: true,
reason: 'the rate used for this price is more than a day old' };
}
return { ok: true, code: 'ok' };
}
...
}
36 hours, because the repricing sweep runs nightly: the window tolerates exactly one missed run and never two.
The claim worth making is not that we check staleness. It is where the check lives. The Meta catalogue mapper reads this function. The product card sent into a WhatsApp thread reads this function. So a 22K chain whose rate has gone stale is withheld from the storefront and answered in chat as “at today's gold rate” rather than quoted at yesterday's figure — not because two pieces of code agree, but because there is only one.
Purity is a key, not a multiplier. A shop sets a rate per gram for each purity it sells. The market rate for 22K is its own quoted number, not 24K scaled by a fraction, and a system that derives one from the other is wrong by whatever the market says that day.
2. A gram, read as a thousand grams
We generate the blank import template from the field definitions, so it can never drift from what the parser accepts. Then we ran that template back through our own importer, which is the only way to know a template is a promise you can keep.
Its example gold row carried 14.200 grams. It came back as 14200.
The cause is a rule that is correct for money and catastrophic for weight. A lone separator
with three digits after it is a thousands mark in a great deal of the world's currency
formatting — 24.999 means 24999 — so the number parser stripped it. Indian
jewellery is weighed to the milligram and written exactly like 14.200, which
means the ambiguous case is not an edge case in this trade, it is the common one. A 14kg
chain is not a thing.
Weights and money now parse under different rules, and the fix is one flag with the reason written beside it:
// uni-cloud/src/services/channels/ingest.mjs
function normaliseDecimal(s, { decimalTail = false } = {}) {
const last = Math.max(s.lastIndexOf('.'), s.lastIndexOf(','));
if (last === -1) return s;
const tail = s.slice(last + 1);
const separators = (s.match(/[.,]/g) || []).length;
if (tail.length === 3 && separators === 1) {
// Money: 24.999 is 24999. Weight: 14.200 is 14.2, and reading it the other
// way is a factor of a thousand on the metal in a gold price.
if (!decimalTail) return s.replace(/[.,]/g, '');
return `${s.slice(0, last).replace(/[.,]/g, '')}.${tail}`;
}
...
}
Nothing about this failure was visible. The file was correct, the import reported success, the arithmetic ran, and every gold price was a thousandfold high.
3. The import that reported success and created nothing
Uploading a spreadsheet with an image column returned this, against production:
{"success": true, "created": 0, "updated": 0, "refused": [],
"failedChunks": [{"error": "D1_ERROR: table catalog_items has no column
named image_url: SQLITE_ERROR"}]}
An image_url field had been added to the importer's field table without a
matching column on the products table. The insert builds its column list from the keys of the
mapped row, so the moment a shop mapped an image column the statement named a column that did
not exist and the whole chunk failed. The aliases include image,
photo, img and src, so the mapper picks it up
automatically in most real exports.
In other words: a shop whose spreadsheet has photographs in it — the normal case — imported
nothing, and the response said success: true.
Three things were wrong and only one of them was the missing column. The failure was reported at the top level as a success. The dashboard turned the toast red but still said “0 created, 0 updated” with no reason. And the per-row warnings the dry run had been computing on every preview since the importer shipped were being discarded before the response was built — so a jeweller previewing four hundred gold pieces was told “400 will import” and never told they would all arrive unpriced.
4. Built, announced, unreachable
The most expensive class of defect here is not a crash. It is a capability that works perfectly and is called by nothing.
| Capability | Callers found | Consequence |
|---|---|---|
| Binary .xlsx / .xls decoding | One, and it was a test | Both import routes called the CSV parser, and the file picker greyed .xlsx out. A jewellery catalogue is an .xlsx. |
| Remote image fetching | Itself, on retry, and a test | Mapped image URLs were never fetched, so the catalogue looked populated and was entirely unpublishable — Meta refuses a product with no photograph. |
| Per-language product copy | Zero | The module header claimed cards went out in the customer's language. Both read sites passed no language at all. |
A unit test cannot catch any of these, because each function passes its own tests. The defect is the absence of a call, which only an assertion about the source can see. So there is now a helper that counts calls while excluding definitions, and a suite that lists what must be reached.
The helper exists because the obvious hand-written version is wrong in a specific way, and we wrote it wrong twice in one day:
// This passes with the call site deleted:
assert.match(src, /readerLanguage\(db, ctx\)/);
// because the DECLARATION contains it too:
async function readerLanguage(db, ctx) { ... }
A reach test that cannot fail is worse than no reach test, because it is counted as coverage of precisely the failure it does not detect.
5. Getting a real shop's spreadsheet in
The instinct is to write a Shopify adapter, then WooCommerce, then Magento, then an Indian retail ERP, until the list is long enough for a pricing page. Each is weeks of work, each rots when the vendor versions their API, and the list is never long enough, because the shop that matters is always using the one thing nobody built.
Column mapping covers all of them at once. Shopify exports CSV. WooCommerce exports CSV. Every Indian jewellery ERP worth naming exports CSV or Excel, and the shop whose catalogue lives in a spreadsheet is served by the same code as the shop on Shopify. Named integrations then become an ergonomic upgrade to a path that already works, rather than the only path.
Two decisions in that mapper are worth stating because they are the ones that cost money when
they are wrong. cost is deliberately not an alias for price: a
wholesale cost mapped onto a retail price and pushed to a storefront is the most damaging
mistake this importer could make, and it would look like it worked. And
grossweight is mapped to net weight, because for the many shops that
sell nothing stone-set it is the only weight column their export has — but the mapping is
flagged, because a gross figure includes the stone and pricing metal on it overcharges.
Suggesting is not deciding. The mapper guesses from headers, reports what it is unsure about, and a human confirms before anything is written.
6. What it replaces, and what it costs
Shopaccino's JewelFlex add-on does the arithmetic in this note: set the day's rate per purity, every product reprices from its own weight and purity, making charges on top. It costs US$30 per month and it needs a Shopaccino storefront.
Klaros prices the same engine at ₹1,499 per month, needs no storefront, and the rate that prices the catalogue is the rate that answers the customer in the thread. That last clause is the whole argument. A feed tool would have to build an inbox to say it; a WhatsApp inbox vendor would have to build a catalogue and a pricing engine.
| Piece | Where it lives |
|---|---|
| Rate board, per purity, with the 36-hour rule | rate-pricing.mjs, pricing-mode.mjs |
| Spreadsheet ingestion, CSV and Excel, mapped by column | channels/ingest.mjs, channels/csv.mjs |
| One canonical product, projected per channel | channels/canonical.mjs, channels/mapper.mjs |
| Meta Commerce Catalog and Google Merchant feeds | channels/adapters/, feeds-routes.mjs |
| The product card a customer is sent in WhatsApp | response/assets.mjs |
| A 20,000-piece grid that stays usable while you scroll it | product-library.js |
None of this is finished. No shop has yet run its full catalogue through it end to end, and every claim above is an argument from the code rather than from a customer — which is a weaker thing, and worth saying plainly. What the week produced is a system whose failures are now visible instead of silent, which is the precondition for the other kind of evidence.
Frequently asked questions about pricing gold in a catalogue
Why do generic e-commerce platforms fail for jewellery pricing?
They store a price as one number. A gold piece does not have one: it is the day's rate for that purity times the net weight, plus making charges, plus the stone, plus GST. On Shopify, WooCommerce or a generic PIM you get there through a bolt-on app, a nightly re-index, or somebody retyping prices every morning. Klaros computes it from the inputs, and withholds the price rather than publishing a stale one.
What stops a customer being quoted yesterday's gold rate?
One rule, in one place. priceEligibility() in pricing-mode.mjs returns stale_rate for any rate-linked price computed more than 36 hours ago, and BOTH surfaces read it: the piece is withheld from the storefront, and a customer asking about it on WhatsApp is told “at today's gold rate” instead of being quoted a number. A separate check on each surface is how the two come to disagree.
Can I import the spreadsheet I already have?
Yes, including .xlsx and .xls as they are, with no converting to CSV first. Columns are mapped by name with a suggestion you confirm, so an export from any ERP works without a named integration. cost is deliberately not treated as a price: mapping a wholesale cost onto a retail price would publish it to a storefront and look like it worked.
What happens to a piece with a weight but no price?
It is inferred as rate-linked, which is the common shape in a jewellery export, and the row says so in the preview. Leaving the price column EMPTY is the instruction. A shop that helpfully types yesterday's figure there gets a fixed-price item that never reprices, which is why the blank template ships an example row with the price deliberately left out.
How does a 14.200g weight avoid being read as 14200g?
By parsing weights and money under different rules. A lone separator with three digits after it is a thousands mark in money (24.999 means 24999) and a decimal in a weight. Indian jewellery is weighed to the milligram and written exactly like 14.200, so the ambiguous case is the common one here. Reading it the money way put a factor of a thousand on the metal in every rate-linked price.
Related: Building a WhatsApp catalogue that tells the truth
Build your commodity catalog on edge infrastructure
Deploy Klaros PIM Master with real-time metal spot rates, multi-channel syndication, and high-performance catalog management.