Blog/Developer

Construye una calculadora de precio de joyería de oro con JavaScript

Construye una calculadora de precio de joyería de oro a partir de precios en vivo por gramo y quilate, con aritmética decimal exacta, caché, márgenes y límites de valoración claros.

Developer

Los joyeros, las casas de empeño y los vendedores de ecommerce necesitan la misma herramienta pequeña: escribir un peso y un quilate, y obtener a cambio un valor del material. Este post construye esa herramienta de principio a fin, desde el precio en vivo por gramo hasta una interfaz en el navegador, usando JavaScript puro.

Qué estima esta calculadora, y qué no

El resultado es un valor del material: cuánto vale el contenido de oro de una pieza al precio spot actual, antes de agregar cualquier otra cosa. No es una tasación, no es un valor de reventa garantizado, y no es lo que un comprador realmente pagará.

Quedan totalmente fuera:

  • Gemas, esmalte o cualquier material que no sea oro en la pieza
  • La mano de obra y el diseño (un anillo terminado a mano cuesta más de fabricar que uno estampado)
  • Impuestos y aranceles, que varían según el país y el tipo de transacción
  • Los márgenes del dealer: la brecha entre lo que una tienda te paga y lo que indica el spot
  • La condición: rayones, piedras faltantes, grabado desgastado
  • Los descuentos de recompra que una tienda aplica para proteger su margen

Todo número que produzca esta calculadora debe llevar una línea que informe eso al usuario. Construye el descargo de responsabilidad en la interfaz, no solo en la documentación.

Paso 1: Obtén los ocho precios por gramo

El endpoint /v1/carat de la API devuelve precios por gramo para ocho purezas de quilate comunes en una sola llamada, sin necesidad de clave de API:

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

Una respuesta real se ve así:

{
  "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"
}

Dos detalles importan para el código de más abajo. Cada campo de precio es una cadena, no un número, para que los dígitos decimales exactos sobrevivan el ida y vuelta por JSON. Y currency acepta cualquiera de las 31 monedas que soporta la API, así que el mismo endpoint cubre USD, EUR, INR o IDR con solo cambiar un parámetro de consulta.

Paso 2: Cachéalo del lado del servidor, 60 segundos

Los datos upstream se actualizan en una cadencia similar, así que consultarlos en cada carga de página no aporta nada y agrega latencia. Un pequeño servidor Node con una caché en memoria se coloca por delante:

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);

Esto es node:http puro, sin necesidad de un framework para un solo endpoint. El manejo de fallas importa tanto como el camino feliz: una respuesta upstream que no sea 200 o un cuerpo malformado lanza un error, y getCarat recurre al último valor cacheado si existe uno y sigue dentro de la ventana del TTL. Solo cuando no hay ninguna caché el error llega al cliente, como un 502.

No llames a api.goldprice.dev directamente desde JavaScript del navegador. La API deliberadamente no tiene superficie CORS para navegador, por diseño, así que un fetch desde código del lado del cliente fallará. Enruta cada solicitud a través de tu propio endpoint de servidor, lo que además mantiene la caché compartida entre todos los visitantes en lugar de una por pestaña del navegador.

Paso 3: Valor del material: peso por precio por gramo

La fórmula central es una sola multiplicación: weight_grams × per_gram_karat_price. El truco es que ambos operandos deben mantenerse en aritmética decimal exacta, porque la multiplicación de valores monetarios en punto flotante se desvía:

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

La API ya te da cadenas decimales exactas. La solución es mantenerlas como enteros en unidades menores (centavos) en lugar de convertir a Number y de vuelta:

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);
}

Aquí no hay dependencia de números grandes, solo BigInt, que todo runtime de JS actual incluye de forma nativa. El peso llega como un float simple (una báscula de joyero lee hasta 0.01g o 0.001g), así que se convierte a miligramos enteros antes de la multiplicación, y la división de vuelta ocurre al final, después de multiplicar, para que los valores intermedios se mantengan exactos. Nota que la división entera trunca en lugar de redondear; elige una regla de redondeo explícita para la unidad menor de tu moneda antes de que esto pase a producción, de la misma forma en que la propia API redondea cada precio por quilate al paso decimal propio de la moneda.

Paso 4: Agrega el margen y los cargos de fabricación por separado

El valor del material del paso 3 es un número derivado del spot. No es lo que una tienda debería cotizarle a un cliente. Dos entradas más van encima de él, y deben ir como partidas separadas, nunca mezcladas en el propio número del spot:

  • Margen del comerciante: el markup de la tienda o el descuento del lado de compra, usualmente un porcentaje
  • Cargos de fabricación: el costo de convertir oro en bruto en una pieza terminada, usualmente una tarifa fija o un porcentaje del valor del material, y totalmente independiente del precio 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,
  };
}

Mantener estos como tres campos en lugar de un total mezclado es lo que hace que la calculadora sea auditable. Un cliente, o un regulador, debería poder ver el valor del material derivado del spot en su propia línea, separado de lo que la tienda decide agregar.

Paso 5: Muestra la marca de tiempo, la moneda y los límites

El lado del navegador llama a tu servidor, nunca a la 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);
    });
});

Cada resultado que muestre la interfaz necesita la marca de tiempo y la moneda a su lado, y una línea fija de texto de descargo de responsabilidad debajo: esto es una estimación de valor del material, no una tasación, y excluye piedras, mano de obra, impuestos y condición. Coloca esa frase en la plantilla una sola vez, no en un tooltip que alguien pueda pasar por alto.

Paso 6: Maneja fallas de fetch y datos malformados

La falla tiene tres formas en el límite del servidor, y el código de arriba ya maneja las tres:

  • La propia solicitud upstream falla (error de red, timeout): capturado en fetchCarat, recurre a la caché
  • La respuesta es un estado distinto de 200: verificado explícitamente, tratado igual que una falla de red
  • El cuerpo de la respuesta no se ve correcto (campos faltantes, tipos incorrectos): la verificación typeof data.price_gram_24k !== "string" detecta un cuerpo malformado antes de que se cachee o se sirva

Cuando existe una entrada de caché fresca, nada de esto llega al usuario; recibe el último valor bueno con su marca de tiempo real. Cuando no hay caché utilizable, el cliente obtiene un estado de error claro en lugar de un cálculo roto o un cero silencioso.

Licenciamiento

El endpoint anónimo usado a lo largo de este post está bien para desarrollo y pruebas. El uso comercial de los datos de la API sin procesar (en un producto por el que cobras, o que genera ingresos) requiere licenciamiento del plan Pro o superior. Si todo lo que necesitas es un precio en vivo mostrado en una página, no datos sin procesar para calcular sobre ellos, el widget embebible oficial es gratuito para uso de visualización comercial y no necesita clave de API.

Lectura relacionada

guías relacionadas

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.

Leer →
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).

Leer →
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.

Leer →

goldprice.dev

Precios del oro en tiempo real, OHLC histórico y agregación multi-fuente — disponible via REST y SSE.