Modern Web Development Tutorial Intermediate

Build an Automated Jewelry Pricing Engine in TypeScript

Reprice a jewelry catalog from metal rate, purity, stone carat and making charges with a typed TypeScript engine instead of a spreadsheet.

Tested with

Typescript
5.9.3
Node
24.4.0

Before you start

  • Node.js 24.4.0 and TypeScript 5.9.3 installed locally
  • Comfortable with TypeScript's basic type system: interfaces, union types, Record, generics
  • An existing product catalog with per-item weight, purity, stone carat/grade and making-charge data, e.g. in Postgres
  • npm plus a TypeScript runner (ts-node or tsx) to execute the examples as you go
On this page

Model gold purity and metal cost as a typed rate table

Gold purity by karat is conventionally karat divided by 24: 24k is pure, 18k is 0.75, 14k is roughly 0.583. Assay offices instead stamp a millesimal fineness — 999, 916, 750, 585 — cut directly into the metal, and that’s the number your supplier’s invoice quotes. The two don’t always match to the decimal: 22k as a fraction is 0.9167, but the fineness stamped on the piece is 916, not 917. Build the rate table from the fineness your supplier actually bills against, not a fraction you recompute yourself.

Declare the valid karats as a union type, KaratKey, and declare the rate table with the satisfies operator (opens in a new tab) against Record<KaratKey, number>, a utility type (opens in a new tab) that maps a fixed set of keys to a value type. satisfies, added in TypeScript 4.9, checks the table has exactly the keys KaratKey requires, without widening each rate to the general number type the way a plain : Record<KaratKey, number> annotation would.

A wide Record<string, number> annotation accepts any string key, so a typo like "18kt" compiles cleanly. The lookup KARAT_PURITY["18kt"] then returns undefined, and undefined * anything is NaN — a price that reaches a customer as garbage, or as a silently rounded zero. satisfies against a closed union catches the same typo at the call site, before the code ships.

metal-rate.ts
type KaratKey = "24k" | "22k" | "18k" | "14k";

const KARAT_PURITY = {
  "24k": 0.999,
  "22k": 0.916,
  "18k": 0.75,
  "14k": 0.585,
} satisfies Record<KaratKey, number>;

function metalCost(
  weightGrams: number,
  karat: KaratKey,
  spotRatePerGram: number
): number {
  return weightGrams * KARAT_PURITY[karat] * spotRatePerGram;
}

You should now see: changing "18k" to "18kt" anywhere the table or a call to metalCost references it fails tsc with a type error naming the exact line, not a silent NaN three steps into a price calculation.

Price stones by carat bracket without gaps or NaN results

Represent carat brackets as a readonly array of {min, max, pricePerCarat} objects, frozen with an as const assertion (opens in a new tab) so TypeScript keeps each bound as its literal value instead of widening every field to number. Use half-open intervals — min inclusive, max exclusive — for every bracket, so a stone sitting exactly on a boundary matches exactly one row. Mixing < and <= between adjacent brackets is how the same 0.5-carat stone ends up matching two brackets in one build and none in the next.

The last bracket needs an explicit unbounded top — max: Infinity — not a special-cased fallback bolted onto the lookup function. A hard-coded top like max: 5 silently drops any stone heavier than 5 carats: .find() returns undefined, and caratWeight * bracket.pricePerCarat throws trying to read pricePerCarat off undefined, or produces NaN if the lookup is optional-chained past the failure instead.

stoneCost is one multiplication once the bracket is found. Throw explicitly when nothing matches — with an unbounded top bracket in the data, that branch should be unreachable for any non-negative carat weight, and an unreachable throw is cheap insurance against a table someone edits carelessly later.

carat-bracket.ts
const CARAT_BRACKETS = [
  { min: 0, max: 0.3, pricePerCarat: 25_000 },
  { min: 0.3, max: 0.5, pricePerCarat: 32_000 },
  { min: 0.5, max: 1, pricePerCarat: 45_000 },
  { min: 1, max: Infinity, pricePerCarat: 68_000 },
] as const;

type CaratBracket = (typeof CARAT_BRACKETS)[number];

function stoneCost(caratWeight: number): number {
  const bracket: CaratBracket | undefined = CARAT_BRACKETS.find(
    (b) => caratWeight >= b.min && caratWeight < b.max
  );

  if (!bracket) {
    throw new Error(`no carat bracket matches weight ${caratWeight}`);
  }

  return caratWeight * bracket.pricePerCarat;
}
flowchart TD
    A[caratWeight] --> B{0 <= weight < 0.3?}
    B -->|Yes| C[25,000/carat]
    B -->|No| D{0.3 <= weight < 0.5?}
    D -->|Yes| E[32,000/carat]
    D -->|No| F{0.5 <= weight < 1?}
    F -->|Yes| G[45,000/carat]
    F -->|No| H["1 <= weight: 68,000/carat<br/>catch-all, no upper bound"]

You should now see: a stone at exactly 0.3 carats prices against the 0.3–0.5 bracket, not the 0–0.3 bracket, and a stone at 2.5 carats — above every bound except the last — still returns a defined number instead of NaN.

Add making charges and overhead as percentage layers

Making charge — jewelry-trade terminology for the labour cost of turning raw metal into a finished piece — is usually either a flat rate per gram or a percentage of the metal cost, and which one applies varies by product line. Model it as a discriminated union on a kind field rather than two optional properties on one object, so TypeScript narrows to the right shape inside each branch, and a missing ratePaisePerGram on the percentage variant is a compile error, not a runtime undefined.

Overhead — rent, marketing, staff — is conventionally a percentage applied after the making charge, against the running subtotal so far, not against the bare metal cost. Model both as an ordered array of charge specs and fold over it with a running total, so the sequence — metal, stone, making charge, overhead — is data you can read and reorder, not an order buried in how one long expression happens to be written.

charges.ts
type ChargeRule =
  | { kind: "flatPerGram"; ratePaisePerGram: number }
  | { kind: "percentOfMetal"; percent: number }
  | { kind: "percentOfRunningTotal"; percent: number };

interface ChargeSpec {
  label: string;
  rule: ChargeRule;
}

interface ChargeLine {
  label: string;
  amountPaise: number;
}

function applyCharges(
  weightGrams: number,
  metalCostPaise: number,
  startingSubtotalPaise: number,
  charges: readonly ChargeSpec[]
): { totalPaise: number; lines: ChargeLine[] } {
  let running = startingSubtotalPaise;
  const lines: ChargeLine[] = [];

  for (const { label, rule } of charges) {
    const amountPaise =
      rule.kind === "flatPerGram"
        ? rule.ratePaisePerGram * weightGrams
        : rule.kind === "percentOfMetal"
        ? metalCostPaise * rule.percent
        : running * rule.percent;

    running += amountPaise;
    lines.push({ label, amountPaise });
  }

  return { totalPaise: running, lines };
}
flowchart LR
    A["metalCost + stoneCost"] --> B["+ makingCharge<br/>percent of metal cost"]
    B --> C["+ overhead<br/>percent of running total"]
    C --> D["raw total, unrounded"]

For an 8-gram 18k piece at ₹6,200 per gram, metalCost is ₹37,200. A 12% making charge against that metal cost is ₹4,464. A 5% overhead against the running total after the making charge — ₹75,414 — is ₹3,770.70. Calling applyCharges with those inputs confirms both lines by hand:

charges-example.ts
const { totalPaise, lines } = applyCharges(
  8,
  3_720_000,
  3_720_000 + 3_375_000,
  [
    { label: "makingCharge", rule: { kind: "percentOfMetal", percent: 0.12 } },
    { label: "overhead", rule: { kind: "percentOfRunningTotal", percent: 0.05 } },
  ]
);
text
lines: [
  { label: 'makingCharge', amountPaise: 446400 },
  { label: 'overhead', amountPaise: 377070 }
]
totalPaise: 7918470

You should now see: a makingCharge line of 446,400 paise and an overhead line of 377,070 paise — ₹4,464.00 and ₹3,770.70 — matching the manual calculation above exactly.

Keep money math in integer minor units to avoid rounding drift

JavaScript numbers are IEEE-754 doubles, and 0.3 - 0.1 in a Node REPL doesn’t print 0.2 — it prints 0.19999999999999998. That’s not a display bug; the stored value really is that number, and it feeds into whatever arithmetic runs next. A single price line rarely shows the error. Five or six line items added together, each carrying its own sliver of representation error, is where a shop owner notices the storefront total doesn’t match the sum of the lines beneath it.

node REPL
> 0.3 - 0.1
0.19999999999999998

The fix is to stop doing money math in fractional rupees at all. Work in paise — the smallest real currency unit, an integer — for every intermediate value, and convert back to rupees only for display. Integer addition in JavaScript is exact below Number.MAX_SAFE_INTEGER, so summing paise never drifts the way summing fractional rupees can. decimal.js (opens in a new tab) is the other standard fix, for code that needs more precision than an integer minor unit carries, such as a per-gram rate quoted to four decimal places.

Round exactly once, at the point you produce the final sellPrice — never per line item and then again at the sum. Rounding metalCost, stoneCost, makingCharge and overhead independently to the nearest paisa, then adding the four rounded values, can land a paisa or two away from rounding the unrounded sum once. That mismatch is exactly the “why doesn’t the total match the lines” bug a shop owner files.

flowchart TD
    A["Unrounded metal, stone,<br/>making charge, overhead"] --> B{"Round each line first,<br/>then sum the rounded values?"}
    B -->|Yes| C["Displayed sum can differ<br/>from displayed total by 1-2 paise"]
    B -->|"No: sum full precision,<br/>round once at the end"| D["Sum and total agree exactly"]

Store rate tables and any monetary column in Postgres as numeric, not money or a floating-point type. The Postgres documentation (opens in a new tab) recommends numeric specifically for exact, arbitrary-precision arithmetic, where money is tied to a locale setting and float types carry the same representation error shown above. node-postgres (opens in a new tab) has no built-in type parser for the numeric OID, so a numeric column comes back from a query as a JavaScript string, not a number — convert it deliberately at the boundary instead of letting + silently concatenate strings.

numeric-to-minor-units.ts
function numericStringToMinorUnits(value: string): number {
  const [wholePart, fractionPart = ""] = value.split(".");
  const paddedFraction = (fractionPart + "00").slice(0, 2);
  const sign = wholePart.startsWith("-") ? -1 : 1;
  const digits = wholePart.replace("-", "") + paddedFraction;
  return sign * Number.parseInt(digits, 10);
}

numericStringToMinorUnits("37200.00"); // 3720000
numericStringToMinorUnits("3770.7"); // 377070

You should now see: the same three amounts summed in paise — 10 + 10 + 10 — equal exactly 30 on every run. The fractional-rupee equivalent carries the same kind of representation error as 0.3 - 0.1 above, with no guarantee it cancels out before it reaches a customer.

Return a per-line price breakdown so a shop owner can audit any price

Wrap every calculation so far in one pure function: (product, rates) => PriceBreakdown. No database call inside it, no Date.now(), no mutation of an argument — given the same product and the same rates, it returns the same breakdown every time, which is what makes it testable without a database and safe to re-run across an entire catalog.

PriceBreakdown lists every component in paise plus a roundingAdjustmentPaise field: the gap, if any, between summing the components and the final sellPricePaise, made explicit instead of silently absorbed into whichever line happens to be computed last. For a product whose charges divide evenly at the paisa level, that field comes out 0; change a percentage to something like 3.33% and it won’t, and the field is exactly where that fraction of a paisa should land.

price-breakdown.ts
interface Product {
  weightGrams: number;
  karat: KaratKey;
  caratWeight: number;
}

interface Rates {
  spotRatePerGram: number;
  makingChargePercent: number;
  overheadPercent: number;
}

interface PriceBreakdown {
  metalCostPaise: number;
  stoneCostPaise: number;
  makingChargePaise: number;
  overheadPaise: number;
  roundingAdjustmentPaise: number;
  sellPricePaise: number;
}

function priceProduct(product: Product, rates: Rates): PriceBreakdown {
  const metalCostPaise = Math.round(
    metalCost(product.weightGrams, product.karat, rates.spotRatePerGram) * 100
  );
  const stoneCostPaise = Math.round(stoneCost(product.caratWeight) * 100);

  const { totalPaise, lines } = applyCharges(
    product.weightGrams,
    metalCostPaise,
    metalCostPaise + stoneCostPaise,
    [
      {
        label: "makingCharge",
        rule: { kind: "percentOfMetal", percent: rates.makingChargePercent },
      },
      {
        label: "overhead",
        rule: { kind: "percentOfRunningTotal", percent: rates.overheadPercent },
      },
    ]
  );

  const sellPricePaise = Math.round(totalPaise);

  return {
    metalCostPaise,
    stoneCostPaise,
    makingChargePaise: Math.round(lines[0].amountPaise),
    overheadPaise: Math.round(lines[1].amountPaise),
    roundingAdjustmentPaise: sellPricePaise - totalPaise,
    sellPricePaise,
  };
}
price-breakdown-example.ts
priceProduct(
  { weightGrams: 8, karat: "18k", caratWeight: 0.75 },
  { spotRatePerGram: 6200, makingChargePercent: 0.12, overheadPercent: 0.05 }
);
json
{
  "metalCostPaise": 3720000,
  "stoneCostPaise": 3375000,
  "makingChargePaise": 446400,
  "overheadPaise": 377070,
  "roundingAdjustmentPaise": 0,
  "sellPricePaise": 7918470
}

You should now see: the first four components — 3,720,000 + 3,375,000 + 446,400 + 377,070 — sum to exactly 7,918,470, matching sellPricePaise with no leftover for roundingAdjustmentPaise to absorb in this particular example.

Apply rounding and psychological pricing as the final step

sellPricePaise from priceProduct is the real, auditable cost-plus-margin figure. What a shopper sees on a tag is usually a separate, deliberately rounded number — round up to the next hundred and knock off one rupee, so ₹79,184.70 becomes ₹79,199 rather than the exact figure. Do that in a second function, toDisplayPrice, that takes the raw paise value and returns both the rounded display value and the raw amount it came from. Never overwrite the raw figure — that’s what an audit needs later.

Apply this rounding exactly once, on the final total, following the same rule as the previous section: never round the metal cost to a psychological price, then the stone cost to a psychological price, and add those together. toDisplayPrice is a pure function of one number, so calling it twice on the same input returns the same output twice. There’s no hidden state, no clock, nothing that could make the same raw total round to two different display prices on two different days.

display-price.ts
interface DisplayPrice {
  rawPaise: number;
  displayPaise: number;
}

function toDisplayPrice(rawPaise: number): DisplayPrice {
  const rawRupees = rawPaise / 100;
  const roundedUpToHundred = Math.ceil(rawRupees / 100) * 100;
  const displayRupees = roundedUpToHundred - 1;

  return {
    rawPaise,
    displayPaise: Math.round(displayRupees * 100),
  };
}

toDisplayPrice(7_918_470);
json
{ "rawPaise": 7918470, "displayPaise": 7919900 }

You should now see: toDisplayPrice(7_918_470) returns a ₹79,184.70 raw figure against a ₹79,199.00 display price, on every call, with no drift between runs.

Recalculate the whole catalog when the metal rate changes

Repricing the catalog after a spot-rate move is just calling priceProduct again for every SKU with the new spotRatePerGram. The function already does the real work; the new problem is persisting the result at scale instead of for one product in a REPL.

The naive version — one UPDATE per SKU, awaited sequentially in a for loop — is correct and also slow enough on a few thousand SKUs to be a real problem. Firing all of them at once with no concurrency limit is worse: a Postgres connection pool has a fixed size, and a burst of thousands of simultaneous queries queues past it and starts timing out instead of just running slowly.

Two fixes both work. A fixed-size worker pool — 8 to 16 concurrent workers pulling from a shared cursor — bounds how many connections are in flight at once. A single multi-row UPDATE ... FROM (VALUES ...) or unnest() statement goes further: compute every new price in memory first, then write the whole batch in one round trip instead of one per row.

reprice-bounded-concurrency.ts
import { Pool } from "pg";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

interface CatalogRow {
  sku: string;
  weightGrams: number;
  karat: KaratKey;
  caratWeight: number;
}

async function repriceCatalog(
  rows: readonly CatalogRow[],
  rates: Rates,
  concurrency = 8
): Promise<void> {
  let cursor = 0;

  async function worker(): Promise<void> {
    while (cursor < rows.length) {
      const row = rows[cursor++];
      const breakdown = priceProduct(row, rates);
      await pool.query(
        "UPDATE products SET sell_price_paise = $1, priced_at = now() WHERE sku = $2",
        [breakdown.sellPricePaise, row.sku]
      );
    }
  }

  await Promise.all(Array.from({ length: concurrency }, worker));
}
reprice-multi-row.ts
async function repriceCatalogBatch(
  rows: readonly CatalogRow[],
  rates: Rates
): Promise<void> {
  const skus = rows.map((row) => row.sku);
  const sellPrices = rows.map((row) => priceProduct(row, rates).sellPricePaise);

  await pool.query(
    `UPDATE products AS p
     SET sell_price_paise = data.sell_price_paise, priced_at = now()
     FROM (
       SELECT * FROM unnest($1::text[], $2::bigint[]) AS t(sku, sell_price_paise)
     ) AS data
     WHERE p.sku = data.sku`,
    [skus, sellPrices]
  );
}
flowchart TD
    A["New spot rate arrives"] --> B["Load catalog rows into memory"]
    B --> C["priceProduct per row<br/>pure function, no writes yet"]
    C --> D["Persist: bounded worker pool<br/>or one multi-row UPDATE"]
    D --> E["Verify: re-query a sample,<br/>compare to a fresh priceProduct call"]

Don’t just trust the row count UPDATE reports. Re-query a sample of SKUs and compare their stored sell_price_paise against a fresh call to priceProduct with the same rates. A mismatch means either the batch wrote a stale rate or a concurrent price change raced the reprice job, and either way it needs investigating before the storefront serves the new numbers.

verify-sample.sql
SELECT sku, sell_price_paise, priced_at
FROM products
WHERE sku = ANY($1::text[])
ORDER BY sku;
verify-sample.ts
async function verifySample(
  rows: readonly CatalogRow[],
  rates: Rates
): Promise<void> {
  const sample = rows.slice(0, 20);
  const { rows: stored } = await pool.query(
    "SELECT sku, sell_price_paise FROM products WHERE sku = ANY($1::text[])",
    [sample.map((row) => row.sku)]
  );

  for (const row of sample) {
    const expected = priceProduct(row, rates).sellPricePaise;
    const actual = stored.find((r) => r.sku === row.sku)?.sell_price_paise;

    if (Number(actual) !== expected) {
      console.error(`mismatch for ${row.sku}: stored ${actual}, expected ${expected}`);
    }
  }
}

You should now see: the reprice job runs against a multi-thousand-row catalog without a connection-pool timeout in the logs, and a handful of spot-checked SKUs, priced independently, match what’s now stored for them.

Limitations

This engine prices one product at a time and reprices a catalog by re-running that same function. It doesn’t cover tax layers such as GST or VAT, promotional discounts, or currency conversion for a multi-currency storefront — those typically sit on top of sellPricePaise rather than inside it. The bracket and rate-table shapes shown assume a fixed set of karats and a fixed carat-bracket schedule; a catalog with custom alloys or negotiated per-customer rates needs a different lookup, not just more rows in the same table.

Repricing a catalog that’s actively taking checkout writes during the batch has a race that the verification step in the previous section catches after the fact but doesn’t prevent. Closing that gap needs either a maintenance window or an optimistic-concurrency check on the product row, neither of which is shown here.

None of the code in this article ran against a live Postgres instance or a real product catalog. The worked figures in the making-charge, breakdown and display-pricing sections were checked by hand against the formulas shown, not printed by an executed program. Run tsc --strict against the snippets and check the arithmetic against your own catalog before any of this reaches a live price.

Sources

  1. TypeScript documentation Microsoft Primary source
  2. TypeScript: Documentation - TypeScript 4.9 (satisfies operator) Primary source
  3. TypeScript: Documentation - TypeScript 3.4 (const assertions) Primary source
  4. TypeScript: Documentation - Utility Types (Record, Readonly) Primary source
  5. Release TypeScript 5.9.3 · microsoft/TypeScript Primary source
  6. Announcing TypeScript 5.9 Primary source
  7. Node.js — Node.js 24.4.0 (Current) Primary source
  8. PostgreSQL: Documentation: Monetary Types Primary source
  9. node-postgres — Types Primary source
  10. MikeMcl/decimal.js — arbitrary-precision Decimal type for JavaScript Primary source
9 min read