Калькулятор цен на ювелирные изделия с актуальной ценой золота
Серверный калькулятор цен на ювелирные изделия на TypeScript и Python: цена грамма золота, проба, стоимость работы, НДС и кэширование в рамках квоты.
Что делает калькулятор
Цена ювелирного изделия обычно складывается из четырёх составляющих: массы изделия, пробы золота, текущей цены золота и стоимости работы ювелира, а сверху добавляется налог. Калькулятору, который считает цену для каждой карточки товара или каждого запроса на расчёт, нужна цена грамма золота в валюте магазина. Причём получать её нужно быстро, в пределах квоты API и не раскрывая ключ API браузеру.
В этом туториале мы напишем небольшой серверный сервис на TypeScript (Node.js) и на Python. Браузер или бэкенд вашего магазина отправляет массу и количество карат на ваш собственный эндпоинт. Сервер объединяет их с закэшированной ценой золота из MetalAPI и возвращает полную раскладку цены. Формула:
стоимость металла = масса (г) × цена грамма золота (24K) × чистота
стоимость работы = за грамм, процент от стоимости металла или фиксированная сумма
цена без НДС = стоимость металла + стоимость работы
НДС = цена без НДС × ставка НДС
итого = цена без НДС + НДССпотовая цена золота — это оптовый ориентир. Розничные цены учитывают также дизайн, камни, технологические потери металла и маржу, а цены выкупа обычно ниже спота. Считайте этот калькулятор той частью ценообразования, которая отвечает за металл, и добавьте поверх собственные бизнес-правила.
Цена грамма золота в вашей валюте
Эндпоинт /latest возвращает цены в любой базовой валюте, а для металлов — в любой единице массы. Один запрос даёт цену одного грамма чистого золота в евро:
curl "https://metalapi.com/api/v1/latest?base=EUR¤cies=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 | Типичная проба |
|---|---|---|
| 24K | 1,0000 | 999 |
| 22K | 0,9167 | 916 |
| 21K | 0,8750 | 875 |
| 18K | 0,7500 | 750 |
| 14K | 0,5833 | 585 |
| 9K | 0,3750 | 375 |
Для кольца 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 — 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}¤cies=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 — 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 возвращает примерно такой расчёт (цена золота условная):
{
"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 — 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 — 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.EURXAU | 110,00 € |
| Чистота | 18K, проба 750 | 0,75 |
| Стоимость металла | 5,2 г × 110,00 € × 0,75 | 429,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 запросов в месяц, без банковской карты.
Читайте также
API цен на золото: полное руководство для разработчиков
Как работать с API цен на золото: получить ключ, вызвать API из curl, JavaScript и Python, правильно читать курсы, работать с историей, ошибками, квотами и кэшем.
Как добавить на сайт виджет с актуальной ценой золота
Виджет с актуальной ценой золота для WordPress, Shopify или HTML: создайте его в личном кабинете MetalAPI, выберите символы, макет и тему и вставьте две строки.
Цены на золото по каратам: 24K, 22K, 18K и как их рассчитать
Что означают 24K, 22K и 18K, как с ними связаны пробы 916 и 750, как рассчитать цену грамма золота по каратам и как получить её через API цен на золото.