Blog/Developer

Membangun Kalkulator Harga Perhiasan Emas dengan JavaScript

Bangun kalkulator harga perhiasan emas dari harga per-gram karat live, dengan matematika desimal presisi, caching, margin, dan batasan valuasi yang jelas.

Developer
Bahasa:
Bahasa Indonesia

Toko emas, pegadaian, dan penjual ecommerce semuanya memerlukan alat kecil yang sama: masukkan berat dan karat, dapatkan nilai material sebagai hasilnya. Post ini membangun alat tersebut dari ujung ke ujung, dari harga per-gram live hingga UI di browser, menggunakan JavaScript murni.

Apa yang diestimasi kalkulator ini, dan apa yang tidak

Hasilnya adalah nilai material: berapa nilai kandungan emas dari sebuah perhiasan pada harga spot saat ini, sebelum ditambahkan hal lain. Ini bukan appraisal, bukan nilai jual kembali yang dijamin, dan bukan angka yang benar-benar akan dibayarkan pembeli.

Yang sepenuhnya tidak dimasukkan:

  • Batu permata, enamel, atau material non-emas apa pun pada perhiasan tersebut
  • Ongkos pembuatan dan desain (cincin yang dikerjakan tangan memerlukan ongkos pembuatan lebih tinggi dibanding yang dicetak)
  • Pajak dan bea, yang bervariasi menurut negara dan jenis transaksi
  • Spread dealer: selisih antara yang dibayarkan sebuah toko kepada Anda dan yang dikatakan harga spot
  • Kondisi: goresan, batu yang hilang, ukiran yang aus
  • Diskon buyback yang diterapkan sebuah toko untuk menjaga marginnya

Setiap angka yang dihasilkan kalkulator ini harus disertai baris yang memberi tahu pengguna hal itu. Bangun disclaimer ke dalam UI, bukan hanya di dokumentasi.

Langkah 1: Ambil delapan harga per-gram

Endpoint /v1/carat milik API mengembalikan harga per-gram untuk delapan kemurnian karat umum dalam satu panggilan, tanpa API key yang diperlukan:

GET https://api.goldprice.dev/v1/carat?currency=USD

Respons sungguhan terlihat seperti ini:

{
  "currency": "USD",
  "timestamp": "2026-07-16T18:12:06.394451Z",
  "price_gram_24k": "128.24",
  "price_gram_22k": "117.55",
  "price_gram_21k": "112.21",
  "price_gram_20k": "106.86",
  "price_gram_18k": "96.18",
  "price_gram_16k": "85.49",
  "price_gram_14k": "74.80",
  "price_gram_10k": "53.43"
}

Dua detail penting untuk kode di bawah. Setiap field harga adalah string, bukan angka, sehingga digit desimal yang tepat bertahan melewati round trip JSON. Dan currency menerima salah satu dari 31 mata uang yang didukung API, sehingga endpoint yang sama mencakup USD, EUR, INR, atau IDR hanya dengan mengubah satu query parameter.

Langkah 2: Cache di sisi server, 60 detik

Data upstream itu sendiri memperbarui pada kadensi yang serupa, jadi melakukan polling di setiap page load tidak memberi keuntungan apa pun dan menambah latensi. Server Node kecil dengan cache in-memory ditempatkan di depannya:

import { createServer } from "node:http";

const UPSTREAM = "https://api.goldprice.dev/v1/carat";
const TTL_MS = 60_000;

const cache = new Map(); // currency -> { data, fetchedAt }

async function fetchCarat(currency) {
  const res = await fetch(`${UPSTREAM}?currency=${encodeURIComponent(currency)}`);
  if (!res.ok) {
    throw new Error(`upstream ${res.status}`);
  }
  const data = await res.json();
  if (typeof data.price_gram_24k !== "string") {
    throw new Error("malformed upstream response");
  }
  return data;
}

async function getCarat(currency) {
  const cached = cache.get(currency);
  const now = Date.now();

  if (cached && now - cached.fetchedAt < TTL_MS) {
    return cached.data;
  }

  try {
    const data = await fetchCarat(currency);
    cache.set(currency, { data, fetchedAt: now });
    return data;
  } catch (err) {
    if (cached) {
      return cached.data; // stale-if-error: serve last known value
    }
    throw err;
  }
}

const server = createServer(async (req, res) => {
  const url = new URL(req.url, "http://localhost");
  if (url.pathname !== "/api/carat") {
    res.writeHead(404).end("not found");
    return;
  }
  const currency = (url.searchParams.get("currency") || "USD").toUpperCase();

  try {
    const data = await getCarat(currency);
    res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(data));
  } catch (err) {
    res.writeHead(502, { "content-type": "application/json" }).end(
      JSON.stringify({ error: "carat_unavailable" })
    );
  }
});

server.listen(3001);

Ini node:http murni, tidak perlu framework untuk satu endpoint saja. Penanganan kegagalan sama pentingnya dengan happy path: respons upstream non-200 atau body yang malformed akan throw, dan getCarat jatuh kembali ke nilai cache terakhir jika ada dan masih dalam jendela TTL. Hanya ketika sama sekali tidak ada cache barulah error tersebut sampai ke client, sebagai 502.

Jangan memanggil api.goldprice.dev langsung dari JavaScript browser. API ini secara sengaja tidak memiliki permukaan CORS browser, by design, sehingga fetch dari kode sisi client akan gagal. Arahkan setiap request melalui endpoint server Anda sendiri, yang juga membuat cache dibagikan ke semua pengunjung alih-alih satu per tab browser.

Langkah 3: Nilai material: berat dikali harga per-gram

Rumus intinya adalah satu perkalian: weight_grams × per_gram_karat_price. Persoalannya, kedua operand harus tetap dalam aritmetika desimal presisi, karena perkalian floating-point pada nilai uang bergeser (drift):

console.log(0.1 + 0.2); // 0.30000000000000004

API sudah memberi Anda string desimal presisi. Perbaikannya adalah menyimpannya sebagai integer dalam minor unit (sen) alih-alih mengonversinya ke Number lalu kembali:

function toMinorUnits(decimalString, decimals = 2) {
  const [whole, frac = ""] = decimalString.split(".");
  const paddedFrac = (frac + "0".repeat(decimals)).slice(0, decimals);
  const sign = whole.startsWith("-") ? -1n : 1n;
  const wholeAbs = whole.replace("-", "");
  return sign * (BigInt(wholeAbs) * 10n ** BigInt(decimals) + BigInt(paddedFrac || "0"));
}

function fromMinorUnits(minorUnits, decimals = 2) {
  const negative = minorUnits < 0n;
  const abs = negative ? -minorUnits : minorUnits;
  const divisor = 10n ** BigInt(decimals);
  const whole = abs / divisor;
  const frac = (abs % divisor).toString().padStart(decimals, "0");
  return `${negative ? "-" : ""}${whole}.${frac}`;
}

function materialValue(weightGrams, perGramPriceString) {
  const priceMinor = toMinorUnits(perGramPriceString, 2); // cents
  const weightMilligrams = BigInt(Math.round(weightGrams * 1000)); // 3dp precision
  const totalMinor = (priceMinor * weightMilligrams) / 1000n;
  return fromMinorUnits(totalMinor, 2);
}

Tidak ada dependency big-number di sini, hanya BigInt, yang sudah dibawa native oleh setiap runtime JS saat ini. Berat masuk sebagai float biasa (timbangan toko emas membaca hingga 0.01g atau 0.001g), jadi dikonversi ke integer miligram sebelum perkalian, dan pembagian kembali ke bawah terjadi terakhir, setelah perkalian, sehingga nilai antara tetap presisi. Perhatikan bahwa pembagian integer memotong (truncate) alih-alih membulatkan; pilih aturan pembulatan yang eksplisit untuk minor unit mata uang Anda sebelum ini masuk ke produksi, dengan cara yang sama seperti API itu sendiri membulatkan setiap harga karat ke langkah desimal mata uang tersebut.

Langkah 4: Tambahkan margin dan biaya pembuatan secara terpisah

Nilai material dari langkah 3 adalah angka turunan-spot. Itu bukan yang seharusnya dikutip sebuah toko kepada pelanggan. Dua input lagi termasuk di atasnya, dan itu harus berupa item baris terpisah, jangan pernah dilipat ke dalam angka spot itu sendiri:

  • Margin merchant: markup atau diskon sisi-beli dari toko, biasanya persentase
  • Biaya pembuatan: biaya mengubah emas mentah menjadi perhiasan jadi, biasanya biaya tetap atau persentase dari nilai material, dan sepenuhnya independen dari harga spot
function quoteBreakdown(materialValueMinor, marginPercent, makingChargeMinor) {
  const marginMinor = (materialValueMinor * BigInt(Math.round(marginPercent * 100))) / 10000n;
  return {
    material: materialValueMinor,
    margin: marginMinor,
    makingCharge: makingChargeMinor,
    total: materialValueMinor + marginMinor + makingChargeMinor,
  };
}

Menjaga ketiganya sebagai tiga field terpisah alih-alih satu total campuran adalah yang membuat kalkulator ini bisa diaudit. Seorang pelanggan, atau regulator, harus bisa melihat nilai material turunan-spot pada barisnya sendiri, terpisah dari apa yang dipilih toko untuk ditambahkan.

Langkah 5: Tampilkan timestamp, mata uang, dan batasannya

Sisi browser memanggil server Anda, tidak pernah API upstream:

async function loadCarat(currency) {
  const res = await fetch(`/api/carat?currency=${encodeURIComponent(currency)}`);
  if (!res.ok) {
    throw new Error(`carat request failed: ${res.status}`);
  }
  return res.json();
}

function estimateValue(weightGrams, karat) {
  const karatKey = `price_gram_${karat}k`;

  return loadCarat("USD").then((data) => {
    const perGram = data[karatKey];
    if (!perGram) {
      throw new Error(`unsupported karat: ${karat}`);
    }
    return {
      material: materialValue(weightGrams, perGram),
      timestamp: data.timestamp,
      currency: data.currency,
    };
  });
}

document.getElementById("calc-btn").addEventListener("click", () => {
  const grams = Number(document.getElementById("weight").value);
  const karat = Number(document.getElementById("karat").value);

  estimateValue(grams, karat)
    .then((result) => {
      document.getElementById("result").textContent =
        `${result.material} ${result.currency} as of ${result.timestamp}`;
    })
    .catch((err) => {
      document.getElementById("result").textContent =
        "Price unavailable right now. Try again shortly.";
      console.error(err);
    });
});

Setiap hasil yang ditampilkan UI perlu disertai timestamp dan mata uang di sampingnya, dan satu baris teks disclaimer tetap di bawahnya: ini adalah estimasi nilai material, bukan appraisal, dan tidak termasuk batu, ongkos pembuatan, pajak, dan kondisi. Taruh kalimat itu di template sekali saja, bukan di tooltip yang bisa terlewat seseorang.

Langkah 6: Menangani kegagalan fetch dan data yang malformed

Kegagalan memiliki tiga bentuk di batas server, dan kode di atas sudah menangani ketiganya:

  • Request upstream itu sendiri gagal (network error, timeout): ditangkap di fetchCarat, jatuh kembali ke cache
  • Respons berupa status non-200: diperiksa secara eksplisit, diperlakukan sama seperti kegagalan jaringan
  • Body respons tidak terlihat benar (field hilang, tipe salah): pemeriksaan typeof data.price_gram_24k !== "string" menangkap body yang malformed sebelum di-cache atau disajikan

Ketika ada entri cache yang segar, tidak ada satu pun dari ini yang sampai ke pengguna; mereka mendapatkan nilai baik terakhir dengan timestamp sungguhannya. Ketika tidak ada cache yang bisa dipakai, client mendapatkan state error yang jelas alih-alih kalkulasi yang rusak atau nol yang diam-diam.

Lisensi

Endpoint anonim yang digunakan sepanjang post ini cukup untuk pengembangan dan pengujian. Penggunaan komersial atas data API mentah (dalam produk yang Anda kenakan biaya, atau yang mendorong pendapatan) memerlukan lisensi paket Pro atau lebih tinggi. Jika yang Anda perlukan hanya harga live yang ditampilkan di sebuah halaman, bukan data mentah untuk dihitung, widget embeddable resmi gratis untuk penggunaan tampilan komersial dan tidak memerlukan API key.

Bacaan terkait

panduan terkait

Developer

Backtest local-currency gold without FX look-ahead bias

Build a reproducible local-currency gold backtest from settled XAU/USD bars and dated FX observations, without inserting future rates into historical rows.

Baca →
Developer

Gold price WebSocket on Reddit: the streaming questions, answered

The real-time gold streaming questions that come up on Reddit, answered: whether a gold WebSocket exists, how the goldprice.dev XAU and XAG tick stream works end to end, and which other APIs actually offer one (most metals APIs just poll).

Baca →
Developer

Gold price API on Stack Overflow: developer troubleshooting, answered

The gold-price-API errors developers hit on Stack Overflow, fixed: 401 auth, 400 invalid_symbol, 429 rate limits, CORS from the browser, reading the response, and pulling historical data.

Baca →

goldprice.dev

Harga emas real-time, OHLC historis, dan agregasi multi-sumber — tersedia via REST dan SSE.