MetalAPI
XAU3 997,89−1,54 %XAG53,936−1,51 %XPT1 554,47−0,83 %XPD1 047,03−0,44 %XCU6,5080+2,29 %WTI103,27+3,82 %BRENT111,70−1,30 %EUR1,120400−0,01 %GBP1,337500−0,01 %JPY0,0063761+0,14 %CHF1,213445−0,25 %TRY0,020125−0,12 %BTC80 329,75−0,55 %ETH2 329,36−1,13 %
Все статьи
ТуториалE-commerceTypeScriptPython

Калькулятор цен на ювелирные изделия с актуальной ценой золота

Серверный калькулятор цен на ювелирные изделия на TypeScript и Python: цена грамма золота, проба, стоимость работы, НДС и кэширование в рамках квоты.

Автор: MetalAPI Team··10 мин чтения

Что делает калькулятор

Цена ювелирного изделия обычно складывается из четырёх составляющих: массы изделия, пробы золота, текущей цены золота и стоимости работы ювелира, а сверху добавляется налог. Калькулятору, который считает цену для каждой карточки товара или каждого запроса на расчёт, нужна цена грамма золота в валюте магазина. Причём получать её нужно быстро, в пределах квоты API и не раскрывая ключ API браузеру.

В этом туториале мы напишем небольшой серверный сервис на TypeScript (Node.js) и на Python. Браузер или бэкенд вашего магазина отправляет массу и количество карат на ваш собственный эндпоинт. Сервер объединяет их с закэшированной ценой золота из MetalAPI и возвращает полную раскладку цены. Формула:

Формула цены
стоимость металла = масса (г) × цена грамма золота (24K) × чистота
стоимость работы  = за грамм, процент от стоимости металла или фиксированная сумма
цена без НДС      = стоимость металла + стоимость работы
НДС               = цена без НДС × ставка НДС
итого             = цена без НДС + НДС

Спотовая цена золота — это оптовый ориентир. Розничные цены учитывают также дизайн, камни, технологические потери металла и маржу, а цены выкупа обычно ниже спота. Считайте этот калькулятор той частью ценообразования, которая отвечает за металл, и добавьте поверх собственные бизнес-правила.

Цена грамма золота в вашей валюте

Эндпоинт /latest возвращает цены в любой базовой валюте, а для металлов — в любой единице массы. Один запрос даёт цену одного грамма чистого золота в евро:

bash
curl "https://metalapi.com/api/v1/latest?base=EUR&currencies=XAU&unit=gram" \
  -H "X-API-Key: YOUR_API_KEY"
Ответ (значения для примера)
{
  "success": true,
  "timestamp": 1792834892,
  "date": "2026-10-24",
  "base": "EUR",
  "unit": "gram",
  "rates": {
    "XAU": 0.0090909091,
    "EURXAU": 110
  },
  "rate_limit": { "limit": 10000, "remaining": 9874, "...": "..." }
}

rates.EURXAU — цена одной единицы XAU, в данном случае одного грамма, в базовой валюте. Именно это число и нужно калькулятору. rates.XAU — обратная величина: граммы золота за один евро. Поле unit присутствует в ответе всякий раз, когда единица отличается от тройской унции по умолчанию, а timestamp — это время, к которому относятся цены. Показывайте его рядом с каждым расчётом.

Базовая валюта и единица измерения доступны на всех тарифах, включая Free, а в тот же запрос можно добавить XAG, XPT и XPD, если вы продаёте также изделия из серебра или платины. На платных тарифах доступен и эндпоинт carat: он сразу возвращает цены по каратам, например XAU22K, или все караты одновременно с carat=all. В этом туториале проба учитывается в самом коде, чтобы он работал на любом тарифе и мог использовать значения пробы по клейму, — об этом следующий раздел.

Проба, стоимость работы и НДС

Караты выражают содержание золота в 24-х долях, поэтому коэффициент чистоты равен karat / 24 — именно так считает эндпоинт /carat. Во многих странах пробу золота указывают в тысячных долях, и для некоторых каратов эти значения немного различаются:

Каратыкараты / 24Типичная проба
24K1,0000999
22K0,9167916
21K0,8750875
18K0,7500750
14K0,5833585
9K0,3750375

Для кольца 14K массой 5,2 г при условной цене 110 € за грамм коэффициент 14/24 даёт стоимость металла 333,67 €, а 0.585 — 334,62 €. Ни один из вариантов не ошибочен. Используйте то соглашение, которое применяется в ваших клеймах и счетах, — поэтому код ниже принимает коэффициент чистоты, а не количество карат. Подробнее об этом — в статье о ценах на золото по каратам.

Стоимость работы обычно задаётся одним из трёх способов, и версия на TypeScript поддерживает все три:

  • За грамм: фиксированная сумма за каждый грамм изделия, например 12 €/г.
  • Процент: доля от стоимости металла, например 8%. Она растёт и падает вместе с ценой золота.
  • Фиксированная: фиксированная плата за изделие, обычная для небольших изделий и работы на заказ.

НДС или налог с продаж добавляется в последнюю очередь. Ставка и то, к чему она применяется, зависят от страны: где-то облагается полная цена, где-то инвестиционное золото облагается иначе, чем ювелирные изделия, а где-то налогом облагается только работа. Храните ставку и налогооблагаемую базу в конфигурации, согласуйте их со своим бухгалтером и округляйте каждую строку до сотых, чтобы строки счёта в сумме давали его итог.

Кэширование в пределах квоты

На каждом тарифе цены обновляются с фиксированным интервалом, и обновления приходятся на моменты, кратные этому интервалу, если отсчитывать от 00:00 UTC: в :00, :10, :20 и так далее на тарифе Standard и раз в сутки в полночь по UTC на Free. Все запросы между двумя такими границами возвращают одни и те же цены, поэтому идеальный кэш обновляется один раз на каждой границе. Если обновлять его на каждой границе круглосуточно, за месяц из 31 дня это обойдётся в такое количество запросов:

Тариф (месячная квота)Интервал обновленияЗапросов при обновлении на каждой границеУкладывается в квоту?
Free (100)Раз в день31Да
Lite (1 000)30 мин1 488Нет: обновляйте раз в час (744)
Standard (10 000)10 мин4 464Да
Advanced (50 000)1 мин44 640Да, но остаётся меньше запаса для других вызовов

Ещё два правила делают кэш надёжным. Используйте single flight: когда срок кэша истекает под нагрузкой, первый запрос загружает новую цену, а параллельные запросы ждут его результата, а не обращаются к API каждый сам по себе. И отдавайте устаревшую цену при ошибке: если API недоступен, продолжайте несколько часов считать по последней успешно полученной цене и повторяйте попытку раз в минуту, вместо того чтобы срывать каждое оформление заказа. Запросы, завершившиеся ошибкой сервера, не тарифицируются, но и отправлять их пачками во время сбоя всё равно незачем.

Кэш ниже хранится в памяти процесса — это идеально для одного сервера. На serverless-платформах или при нескольких инстансах каждый инстанс держит собственную копию. Храните цену в общем хранилище, например в Redis, или используйте кэш данных вашего фреймворка с тем же сроком жизни.

Реализация на TypeScript

Модуль ниже работает на Node.js 18 и новее и не имеет зависимостей. Он получает цену грамма в евро, кэширует её до следующей границы интервала тарифа и превращает массу, коэффициент чистоты и стоимость работы в подробный расчёт цены:

gold-quote.ts
// gold-quote.ts — Node 18+. The API key stays on the server.
const API_KEY = process.env.METALAPI_KEY!;
const CURRENCY = "EUR";
const PLAN_INTERVAL_S = 600; // Standard plan: prices update every 10 minutes
const MIN_TTL_S = 0;         // Lite plan: set 3600 to stay under 1,000 requests a month

interface GoldPrice {
  perGram: number;   // fine (24K) gold, CURRENCY per gram
  timestamp: number; // time of the price data (Unix seconds)
  fetchedAt: number;
  expiresAt: number;
}

let cached: GoldPrice | null = null;
let inflight: Promise<GoldPrice> | null = null;

// Prices only change at multiples of the plan interval (counted from 00:00 UTC).
function nextBoundary(ms: number): number {
  const step = PLAN_INTERVAL_S * 1000;
  return Math.floor(ms / step) * step + step;
}

async function fetchGold(): Promise<GoldPrice> {
  const url = `https://metalapi.com/api/v1/latest?base=${CURRENCY}&currencies=XAU&unit=gram`;
  const res = await fetch(url, { headers: { "X-API-Key": API_KEY }, signal: AbortSignal.timeout(10_000) });
  const body = await res.json();
  if (!body.success) throw new Error(`MetalAPI ${body.error.code}: ${body.error.info}`);
  const now = Date.now();
  return {
    perGram: body.rates[`${CURRENCY}XAU`],
    timestamp: body.timestamp,
    fetchedAt: now,
    expiresAt: nextBoundary(now + MIN_TTL_S * 1000) + 5_000, // a few seconds of slack
  };
}

export function goldPerGram(): Promise<GoldPrice> {
  if (cached && Date.now() < cached.expiresAt) return Promise.resolve(cached);
  // Single flight: concurrent requests share one API call.
  inflight ??= fetchGold()
    .then((fresh) => (cached = fresh))
    .catch((err) => {
      // Keep quoting the last good price for up to 6 hours; try again in a minute.
      if (cached && Date.now() - cached.fetchedAt < 6 * 3600_000) {
        cached.expiresAt = Date.now() + 60_000;
        return cached;
      }
      throw err;
    })
    .finally(() => (inflight = null));
  return inflight;
}

export type MakingCharge =
  | { type: "per_gram"; amount: number }
  | { type: "percent"; rate: number } // 0.08 = 8% of the metal value
  | { type: "flat"; amount: number };

const money = (n: number) => Math.round(n * 100 + Number.EPSILON) / 100;

export async function quote(grams: number, purity: number, making: MakingCharge, vatRate: number) {
  const gold = await goldPerGram();
  const metal = money(grams * gold.perGram * purity);
  const labour = money(
    making.type === "per_gram" ? grams * making.amount
      : making.type === "percent" ? metal * making.rate
      : making.amount,
  );
  const net = money(metal + labour);
  const vat = money(net * vatRate);
  return {
    currency: CURRENCY,
    grams,
    purity,
    gold_per_gram_24k: gold.perGram,
    price_time: new Date(gold.timestamp * 1000).toISOString(),
    metal,
    making: labour,
    net,
    vat,
    total: money(net + vat),
  };
}

Несколько строк стоит рассмотреть подробнее. nextBoundary() округляет время вверх до ближайшего значения, кратного интервалу тарифа, поэтому на Standard цена, полученная в 10:03, истекает в 10:10:05 — через несколько секунд после следующего обновления, а не в 10:13. Если на Lite задать MIN_TTL_S равным 3600, следующее обновление дождётся первой границы не ранее чем через час, что ограничивает расход 24 запросами в сутки. Ветка .catch реализует правило «отдавать устаревшую цену при ошибке»: она возвращает последнюю успешную цену и назначает следующую попытку через минуту, так что сбой стоит не более одного неудачного запроса в минуту, а не одного запроса на каждого посетителя.

Откройте доступ к расчёту через небольшой HTTP-эндпоинт. Строго проверяйте входные данные: принимайте только те пробы, которые вы продаёте, и разумный диапазон массы. Стоимость работы и ставка НДС — это настройки на стороне сервера; никогда не принимайте их от клиента.

server.ts
// server.ts — npm install express, then: METALAPI_KEY=mk_live_... npx tsx server.ts
import express from "express";
import { quote } from "./gold-quote";

// Purity factors as stamped on your pieces (see the karat table above).
const PURITY: Record<string, number> = { "24": 0.999, "22": 0.916, "21": 0.875, "18": 0.75, "14": 0.585, "9": 0.375 };
const app = express();

app.get("/api/jewelry-quote", async (req, res) => {
  const grams = Number(req.query.grams);
  const purity = PURITY[String(req.query.karat)];
  if (!purity || !(grams > 0 && grams <= 2000)) {
    res.status(400).json({ error: "Use karat 24, 22, 21, 18, 14 or 9 and grams between 0 and 2000." });
    return;
  }
  try {
    // Making charge and VAT are server-side settings, never client input.
    res.json(await quote(grams, purity, { type: "per_gram", amount: 12 }, 0.2));
  } catch (err) {
    console.error(err);
    res.status(503).json({ error: "Gold price temporarily unavailable." });
  }
});

app.listen(3000, () => console.log("Try http://localhost:3000/api/jewelry-quote?karat=18&grams=5.2"));

Запрос к /api/jewelry-quote?karat=18&grams=5.2 возвращает примерно такой расчёт (цена золота условная):

json
{
  "currency": "EUR",
  "grams": 5.2,
  "purity": 0.75,
  "gold_per_gram_24k": 110,
  "price_time": "2026-10-24T09:41:32.000Z",
  "metal": 429,
  "making": 62.4,
  "net": 491.4,
  "vat": 98.28,
  "total": 589.68
}

Реализация на Python

Версия на Python устроена так же. Для денежных сумм она использует Decimal, чтобы в суммах не накапливались ошибки двоичной арифметики с плавающей точкой, а блокировку — чтобы параллельные запросы в многопоточном сервере использовали один общий вызов API. Для краткости она поддерживает только стоимость работы за грамм:

gold_quote.py
# gold_quote.py — Python 3.9+, pip install requests
import math
import os
import threading
import time
from decimal import ROUND_HALF_UP, Decimal

import requests

API_KEY = os.environ["METALAPI_KEY"]
CURRENCY = "EUR"
PLAN_INTERVAL_S = 600  # Standard plan: prices update every 10 minutes
MIN_TTL_S = 0          # Lite plan: set 3600 to stay under 1,000 requests a month

_lock = threading.Lock()
_cache = {"per_gram": None, "timestamp": 0, "fetched": 0.0, "expires": 0.0}


def _next_boundary(t: float) -> float:
    return (math.floor(t / PLAN_INTERVAL_S) + 1) * PLAN_INTERVAL_S


def gold_per_gram() -> tuple[Decimal, int]:
    """Fine (24K) gold in CURRENCY per gram, and the Unix time of that price."""
    with _lock:  # one API call at a time; waiting threads reuse its result
        now = time.time()
        if _cache["per_gram"] is not None and now < _cache["expires"]:
            return _cache["per_gram"], _cache["timestamp"]
        try:
            r = requests.get(
                "https://metalapi.com/api/v1/latest",
                params={"base": CURRENCY, "currencies": "XAU", "unit": "gram"},
                headers={"X-API-Key": API_KEY},
                timeout=10,
            )
            body = r.json()
            if not body.get("success"):
                raise RuntimeError(f"MetalAPI {body['error']['code']}: {body['error']['info']}")
        except Exception:
            if _cache["per_gram"] is not None and now - _cache["fetched"] < 6 * 3600:
                _cache["expires"] = now + 60  # serve the last good price, retry in a minute
                return _cache["per_gram"], _cache["timestamp"]
            raise
        _cache.update(
            per_gram=Decimal(str(body["rates"][f"{CURRENCY}XAU"])),
            timestamp=body["timestamp"],
            fetched=now,
            expires=_next_boundary(now + MIN_TTL_S) + 5,
        )
        return _cache["per_gram"], _cache["timestamp"]


def money(x: Decimal) -> Decimal:
    return x.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)


def quote(grams: Decimal, purity: Decimal, making_per_gram: Decimal, vat_rate: Decimal) -> dict:
    per_gram, ts = gold_per_gram()
    metal = money(grams * per_gram * purity)
    making = money(grams * making_per_gram)
    net = metal + making
    vat = money(net * vat_rate)
    return {
        "currency": CURRENCY,
        "gold_per_gram_24k": str(per_gram),
        "price_time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(ts)),
        "metal": str(metal),
        "making": str(making),
        "net": str(net),
        "vat": str(vat),
        "total": str(net + vat),
    }

Подойдёт любой веб-фреймворк. В FastAPI обычные эндпоинты def выполняются в пуле потоков — именно для этого и нужна блокировка:

app.py
# app.py — pip install fastapi uvicorn requests, then: METALAPI_KEY=mk_live_... uvicorn app:app
from decimal import Decimal

from fastapi import FastAPI, HTTPException, Query

from gold_quote import quote

# Purity factors as stamped on your pieces.
PURITY = {24: Decimal("0.999"), 22: Decimal("0.916"), 21: Decimal("0.875"),
          18: Decimal("0.750"), 14: Decimal("0.585"), 9: Decimal("0.375")}
app = FastAPI()


@app.get("/api/jewelry-quote")
def jewelry_quote(karat: int, grams: Decimal = Query(gt=0, le=2000)):
    if karat not in PURITY:
        raise HTTPException(400, "Use karat 24, 22, 21, 18, 14 or 9.")
    try:
        return quote(grams, PURITY[karat], making_per_gram=Decimal("12.00"), vat_rate=Decimal("0.20"))
    except Exception:
        raise HTTPException(503, "Gold price temporarily unavailable.")

Версия на Python возвращает суммы строками, а версия на TypeScript — числами. Если расчёт попадает в систему заказов, храните суммы как десятичные строки или как целое число центов, а не как числа с плавающей точкой.

Пример расчёта

Вот пошаговый расчёт цены кольца 18K при условной цене золота 110,00 € за грамм, стоимости работы 12 € за грамм и НДС 20%:

ШагРасчётСумма
Золото 24K, за граммrates.EURXAU110,00 €
Чистота18K, проба 7500,75
Стоимость металла5,2 г × 110,00 € × 0,75429,00 €
Стоимость работы5,2 г × 12,00 €62,40 €
Цена без НДС429,00 € + 62,40 €491,40 €
НДС491,40 € × 20%98,28 €
Итого589,68 €

Теперь цепочка 22K массой 10 г со стоимостью работы 8% при той же цене золота. При чистоте 22/24 стоимость металла составляет 1 008,33 €, стоимость работы — 8% от неё (80,67 €), цена без НДС — 1 089,00 €, а итог с НДС — 1 306,80 €. С коэффициентом пробы 916 стоимость металла составила бы 1 007,60 €.

От цены золота зависит только стоимость металла, поэтому итог меняется меньше, чем само золото. Если золото подорожает на 1%, до 111,10 € за грамм, стоимость металла в кольце 18K станет 433,29 €, а итог — 594,83 €: рост на 5,15 €, или 0,87%. При процентной стоимости работы итог следовал бы за ценой золота точнее.

Чек-лист для продакшена

  • Храните ключ API в переменной окружения на сервере. Эндпоинт калькулятора возвращает цены, но никогда — ключ.
  • Показывайте price_time с каждым расчётом, чтобы покупатели и сотрудники видели, насколько актуальна цена золота.
  • Фиксируйте цену в момент оформления заказа: сохраняйте вместе с заказом цену золота, её метку времени, пробу и сумму каждой строки, а не пересчитывайте их позже.
  • Учитывайте, что в выходные, когда рынок золота закрыт, цены не меняются. Это не устаревший кэш.
  • Отслеживайте расход с помощью эндпоинта usage, который не расходует квоту, и настройте предупреждение для себя, пока квота на месяц не закончилась.
  • Обрабатывайте ошибку 105 (месячная квота исчерпана): отдавайте закэшированную цену и оповещайте команду. Квота обновляется 1-го числа в 00:00 UTC, а переход на более высокий тариф действует сразу.
  • Чтобы реагировать на сильные движения цены, например переоценивать каталог, используйте ценовые оповещения с вебхуками, а не опрашивайте API чаще.

Сравните тарифы и квоты на странице тарифов или создайте бесплатный аккаунт, чтобы получить ключ и опробовать калькулятор на реальных ценах.

Попробуйте на своих данных

Создайте бесплатный аккаунт и отправьте первый запрос меньше чем за две минуты. 100 запросов в месяц, без банковской карты.

Читайте также