Build notes · Commerce & Edge PIM Architecture
Building a WhatsApp-Native Enterprise PIM Master for Precious Commodity Commerce
Status: Resolved. This postmortem is a historical log. The issue discussed below has been fully fixed.
When a customer messages on WhatsApp asking: “I need a 22K gold chain under ₹80,000 available today at today’s rate — what can I actually buy right now?”, they aren't asking you to search a catalog. They are asking your business to execute a multi-variable commercial decision inside a live conversation.
Conventional PIMs tell you products exist. ERPs tell you stock. Pricing engines compute rates. Payment gateways create links. WhatsApp hosts the chat. Most platforms model the answer as a static stored number ($P = \text{fixed\_amount}$) and not the inputs. Klaros is built from first principles to force all five systems to participate in a single, deterministic transaction at the Edge.
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. How Klaros compares to legacy enterprise PIMs (Akeneo, Pimcore, Salsify, InRiver)
Legacy Enterprise PIMs were designed 15 years ago for multi-region web/print batch catalog publishing (Amazon, Walmart, Shopify). Klaros was designed from first principles as an Agentic Commercial Execution Layer at the Edge inside WhatsApp threads.
The Agentic Commerce Execution Abstraction
- Catalog = Agent’s Commercial Memory: Canonical SKU attributes, purity specs, and baseline metal weights.
- Pricing Engine = Agent’s Deterministic Reasoning Layer: Formula-driven purity math (
P_final = P_gross × (1 + GST%)) with a 36h circuit breaker. - Inventory = Agent’s Real-Time State: Single-source cross-surface availability evaluation (
priceEligibility()). - Payment = Agent’s Instant Action: Dynamic thread-native checkout links and WhatsApp chat cards.
- WhatsApp = Agent’s Interaction Surface: Sub-50ms thread execution across 300+ edge PoPs.
| Dimension | Legacy Enterprise PIMs (Akeneo, Pimcore, Salsify, InRiver) | Klaros WhatsApp-Native Edge PIM | Why Klaros is Superior for Messaging |
|---|---|---|---|
| Primary Design Goal | Multi-region web storefront catalog publishing (Amazon, Walmart, Shopify). | Real-time conversational commerce execution inside WhatsApp threads. | Conversational Native: Product lookup, dynamic pricing, and payment links happen in-thread. |
| Runtime Architecture | Monolithic PHP/Java/Postgres containers requiring dedicated servers & Redis caches ($500–$3,000/mo). | Serverless Edge-Native (Cloudflare Workers + D1 + R2 + V8 Isolates). | Sub-50ms Global Latency: 0ms cold starts, zero idle infrastructure cost, deployed across 300+ PoPs. |
| Commodity Pricing Model | Static Stored Attributes. Relies on scheduled nightly cron re-indexing. Fails for bullion/gold commodities. | Dynamic Live Spot-Rate Engine. Calculates purity math (P_final = P_gross × (1 + GST%)) on demand. |
Zero Price Drift: Includes a 36-hour circuit breaker safety net in pricing-mode.mjs. |
| Thread & Inbox Integration | Zero Native Connection. Requires complex Zapier flows or third-party middleware that cause state drift. | Single-Source Execution Loop. Web APIs and WhatsApp chat cards read the exact same evaluation code. | Single Truth: Storefront and chat cards can never disagree on price or availability. |
| Data Ingestion Pipeline | Requires rigid CSV exports or enterprise API custom dev (weeks per ERP). | Binary SheetJS Parser + AI Vision Stream. Reads raw .xlsx/.xls binary ERP exports directly. |
Fault-Tolerant: BOM stripping & normaliseDecimal() tail parsing in ingest.mjs. |
| Dashboard UI Scale | Heavy React/DOM rendering; lags or crashes past 5,000 SKUs without pagination. | 60 FPS DOM Table Virtualization. Custom Vanilla JS engine in product-library.js. |
Infinite Scale UI: Maintains ~30 DOM nodes for 100,000+ SKUs with sub-10ms search. |
7. Are we benchmark worthy? Empirical Performance Scorecard
YES. Absolutely. Klaros does not just meet industry benchmarks—it represents a paradigm shift from Monolithic Server PIMs to Stateful Edge Workers for Conversational Commerce.
Trust over Speed: Sub-12ms latency is engineering evidence, but Trust is the product. An AI agent or human sales rep cannot transact on an ungrounded LLM → plausible number. They require a deterministic chain of ground truth: product → purity → weight → spot rate → making charge → GST → inventory → final price.
| Metric / Benchmark | Traditional PIM + Reseller BSP (Akeneo + Shopify + Wati) | Klaros WhatsApp-Native Edge PIM | Klaros Architectural Moat |
|---|---|---|---|
| P99 Query Latency | 180ms – 450ms (Server roundtrips to central DB + Redis) | < 12ms (Cloudflare D1 edge composite index) | 15x Faster Response Times |
| Catalog Memory Footprint | 1.5 GB – 4 GB (PHP/Node container RAM) | < 10 MB per V8 Worker isolate | 99% Lower RAM Footprint |
| Dashboard DOM Scale (50k SKUs) | 2.1 GB RAM / 4.2s freeze / Browser tab crash | ~30 DOM nodes / 60 FPS / <10ms search | Zero DOM Memory Leaks |
| Message Infrastructure Cost | 15% – 25% Third-Party BSP Per-Message Surcharge | ₹0 / 0% Middleware Markup (Direct Meta Cloud API) | $0 BSP Surcharge |
| Ingestion Fault Tolerance | Crashes on .xlsx binary, BOM headers & 14.200g weights | Native SheetJS + normaliseDecimal() tail parser | Zero-Defect File Ingestion |
| Cross-Surface Price Drift Rate | ~3.4% price mismatch between web and chat | 0.0% Price Drift (Single evaluation function) | 100% Single-Source Truth |
* Benchmark methodology: Cloudflare D1 trace logs for P99 query latency; Chrome DevTools heap profiles under 50,000 SKU virtual scroll; Direct Meta Cloud API HTTP headers vs 3rd-party BSP proxy latency; Baseline standard Node/PostgreSQL container memory footprint.
8. 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.