shopify-money-rounding
v1.1.0
Published
Round money the way Shopify rounds it, with per-currency decimals and Markets rounding steps.
Downloads
68
Maintainers
Readme
shopify-money-rounding
Round money the way Shopify rounds it. No runtime dependencies, usable from an embedded admin app, a theme app extension, a Shopify Function, or a Node backend.
import { roundMoney } from "shopify-money-rounding";
roundMoney(250_010, "VND"); // 251000
roundMoney(45.678, "USD", false); // 45.68Install
pnpm add shopify-money-roundingThe package ships ESM and CJS builds plus type declarations.
API
function roundMoney(amount: number, presentmentCurrency: string | undefined, roundingEnabled?: boolean): number;| Parameter | Required | Meaning |
| --------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| amount | yes | The amount already converted to the presentment currency. |
| presentmentCurrency | yes | ISO code of the currency the buyer is looking at. Case-insensitive. undefined or a code missing from the table falls back to 2 decimals and no step. |
| roundingEnabled | no | Defaults to true. Comes from MarketCurrencySettings.roundingEnabled on the market being served. |
The library does not convert currency
That is deliberate. Where the rate comes from — window.Shopify.currency.rate on the storefront, Market.currencySettings through the Admin API, a rate table you maintain yourself — differs per project and per page. Multiply on your side, then pass the result in:
const presented = baseAmount * rate;
const final = roundMoney(presented, presentmentCurrency, market.roundingEnabled);roundingRuleForCurrency(currency)
Returns the rules roundMoney applies, so you can read them without rebuilding the table yourself.
import { roundingRuleForCurrency } from "shopify-money-rounding";
roundingRuleForCurrency("VND"); // { decimals: 0, roundUpToNearest: 1000 }
roundingRuleForCurrency("EUR"); // { decimals: 2, roundUpToNearest: 0.95 }
roundingRuleForCurrency("KWD"); // { decimals: 3 } — Markets ships no step for it
roundingRuleForCurrency("ZZZ"); // { decimals: 2 } — unknown codeFormatting an amount needs the decimal count:
amount.toFixed(roundingRuleForCurrency(currency).decimals);Telling the buyer what will happen to the price needs the step:
const { roundUpToNearest } = roundingRuleForCurrency(currency);
if (roundUpToNearest) {
// "Prices are rounded up to the nearest 1000"
}The returned object is typed readonly, but it is the table entry itself rather than a copy, so do not mutate it at runtime.
Two modes, and how to pick
roundingEnabled is the only thing that selects a mode. It maps straight onto MarketCurrencySettings.roundingEnabled — "whether or not rounding is enabled on multi-currency prices". The library never infers it from the exchange rate or from whether the two currencies differ.
false — half-up to the currency's decimals
Reduces the amount to the decimal count the currency allows, rounding halves up. Negative halves go away from zero.
roundMoney(45.678, "USD", false); // 45.68
roundMoney(45678.6, "VND", false); // 45679 — VND has 0 decimals
roundMoney(45.6789, "KWD", false); // 45.679 — KWD has 3 decimals
roundMoney(-2.675, "USD", false); // -2.68Use this for every amount that is not the final price the buyer pays: discount values, minimum requirements, intermediate subtotals. Use it for final prices too, in markets where rounding is turned off.
true (default) — round up to the Markets step
Rounds the extra decimals upward, then raises the result to the step Shopify Markets assigns that currency.
roundMoney(250_010, "VND"); // 251000 — step 1,000₫
roundMoney(12.301, "JPY"); // 100 — step ¥100
roundMoney(12.301, "USD"); // 13 — step $1
roundMoney(10.2, "EUR"); // 10.95 — step €0.95
roundMoney(45.6781, "KWD"); // 45.679 — no step, only ceils to the decimalsUse this for the final price the buyer pays, in a market with rounding enabled.
Choosing in practice
Markets rounding applies only to converted prices — that is what the field means by multi-currency prices. Prices in the store's own currency are set by the merchant and never get bumped to a multiple of ¥100 or 1,000₫. The library does not know the store currency, so that condition belongs at the call site:
const isConverted = presentmentCurrency !== storeCurrency;
roundMoney(amount, presentmentCurrency, isConverted && market.roundingEnabled);Algorithm
The currency table
161 codes, two properties each.
decimals comes from Shopify/money, config/currency_iso.yml, with the non-transactional codes removed: metals, SDR, CLF.
roundUpToNearest is Shopify's "Round up to the nearest {value}". This value can only be read by eye from Shopify admin → Markets: Shopify exposes it through neither an API nor Liquid. It is also not the money gem's smallest_denomination — that is cash rounding and gives different numbers (100₫ instead of 1,000₫, ¥1 instead of ¥100). 33 codes carry no roundUpToNearest: either Markets cannot select them (every 3-decimal currency — BHD, IQD, JOD, KWD, LYD, OMR, TND) or it ships them without a step, LBP among them.
These are Shopify defaults, not guarantees. A merchant can disable rounding per market or pick a different price ending, which is why roundingEnabled is a parameter and not a constant.
Where roundingEnabled comes from
The field lives on MarketCurrencySettings in the Admin GraphQL API, reached through Market.currencySettings. It needs the read_markets scope.
query MarketRounding($id: ID!) {
market(id: $id) {
currencySettings {
roundingEnabled
localCurrencies
baseCurrency {
currencyCode
}
}
}
}A storefront cannot query the Admin API. In a theme app extension, the admin side of your app has to read the value and hand it down to the storefront — through a metafield, an app proxy, or a config variable baked into the theme.
roundUpToNearest has two meanings
Same number, two readings depending on its magnitude.
| Step | Meaning | Example |
| --------- | -------------------- | ------------------------------------------------ |
| >= 1 unit | Multiple of the step | ¥100: 250 → 300; 1,000₫: 250010 → 251000 |
| < 1 unit | Price ending | €0.95: 10.20 → 10.95, 10.96 → 11.95 |
With €0.95 the result is not a multiple of 0.95. It is the nearest whole unit ending in .95. The arithmetic runs on integer subunits so it introduces no new error of its own.
Truncate decimals first, raise to the step second
roundMoney rounds up to decimals, and only then raises to the step. Reversed, 12.000001 — floating-point residue accumulated by the rate multiplication upstream — is enough to jump a whole step to 13 instead of standing still at 12.
Rounding runs on the digit string, not on a multiplied number
The familiar Math.round(amount * 10 ** decimals) / 10 ** decimals is wrong on amounts that actually occur:
1.005 * 100 → 100.49999999999999 → Math.round → 100 → 1.00 wrong, should be 1.01
8.165 * 100 → 816.4999999999999 → Math.round → 816 → 8.16 wrong, should be 8.17toFixed has the same problem: (1.005).toFixed(2) is "1.00".
The library takes amount.toPrecision(15), splits the integer and fractional parts as strings, decides up or down from the first dropped digit, then adds subunits with BigInt. Fifteen significant digits is also enough to absorb error accumulated upstream without misreading the value:
100.005 - 80 → 20.004999999999995 → toPrecision(15) → "20.0050000000000" → 20.01BigInt is why the minimum build target is ES2020.
At 1e21 and above, numbers stringify in exponent notation and BigInt() breaks, so amounts at or past that ceiling are returned untouched.
Negatives
Half-up rounds away from zero: -2.675 → -2.68. Step-raising ceils toward +∞, which means toward zero: -12.301 → -11, not -13. Negatives only show up as discount deltas, which belong in the roundingEnabled: false group anyway.
Integration examples
Storefront (theme app extension)
import { roundMoney } from "shopify-money-rounding";
const storeCurrency = "USD";
const { active, rate } = window.Shopify.currency;
export function buyerPrice(baseAmount: number, roundingEnabled: boolean): number {
const presented = baseAmount * Number.parseFloat(rate);
return roundMoney(presented, active, active !== storeCurrency && roundingEnabled);
}
export function discountValue(amount: number): number {
return roundMoney(amount, active, false);
}window.Shopify.currency.rate always corresponds to window.Shopify.currency.active. If you are displaying some other currency, that rate is unusable.
Admin app
import { roundMoney } from "shopify-money-rounding";
const previewPrice = roundMoney(
basePrice * market.exchangeRate,
market.currencyCode,
market.currencySettings.roundingEnabled,
);Development
pnpm build # tsup (ESM + CJS), then tsc for the .d.ts
pnpm typecheck
pnpm testtsc emits the .d.ts instead of tsup: tsup's dts step uses rollup-plugin-dts, which calls the TypeScript 5 API, and this project runs TypeScript 7 native, so it crashes on load.
Làm tròn số tiền đúng cách Shopify làm tròn. Một function, không dependency runtime, dùng được trong embedded admin app, theme app extension, Shopify Function hay backend Node.
import { roundMoney } from "shopify-money-rounding";
roundMoney(250_010, "VND"); // 251000
roundMoney(45.678, "USD", false); // 45.68Cài đặt
pnpm add shopify-money-roundingPackage xuất ESM, CJS và file khai báo type.
API
function roundMoney(amount: number, presentmentCurrency: string | undefined, roundingEnabled?: boolean): number;| Tham số | Bắt buộc | Ý nghĩa |
| --------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| amount | có | Số tiền ĐÃ quy đổi sang presentment currency. |
| presentmentCurrency | có | Mã ISO của đơn vị tiền buyer đang nhìn thấy. Không phân biệt hoa thường. undefined hoặc mã không có trong bảng rơi về 2 chữ số thập phân và không có bậc. |
| roundingEnabled | không | Mặc định true. Lấy từ MarketCurrencySettings.roundingEnabled của market đang phục vụ. |
Thư viện không quy đổi tiền tệ
Cố tình như vậy. Tỉ giá lấy từ đâu — window.Shopify.currency.rate ở storefront, Market.currencySettings qua Admin API, hay bảng tỉ giá bạn tự duy trì — khác nhau theo từng dự án và từng trang. Nhân tỉ giá ở phía bạn, rồi đưa kết quả vào:
const presented = baseAmount * rate;
const final = roundMoney(presented, presentmentCurrency, market.roundingEnabled);roundingRuleForCurrency(currency)
Trả về đúng bộ quy tắc mà roundMoney áp dụng, để bạn đọc được mà không phải tự dựng lại bảng.
import { roundingRuleForCurrency } from "shopify-money-rounding";
roundingRuleForCurrency("VND"); // { decimals: 0, roundUpToNearest: 1000 }
roundingRuleForCurrency("EUR"); // { decimals: 2, roundUpToNearest: 0.95 }
roundingRuleForCurrency("KWD"); // { decimals: 3 } — Markets không có bậc cho đơn vị này
roundingRuleForCurrency("ZZZ"); // { decimals: 2 } — mã lạĐịnh dạng số tiền cần số chữ số thập phân:
amount.toFixed(roundingRuleForCurrency(currency).decimals);Báo cho buyer biết giá sẽ bị làm gì thì cần bậc:
const { roundUpToNearest } = roundingRuleForCurrency(currency);
if (roundUpToNearest) {
// "Giá được làm tròn lên bội số 1.000"
}Object trả về có kiểu readonly, nhưng chính là entry trong bảng chứ không phải bản sao, nên đừng sửa nó lúc chạy.
Hai chế độ và cách chọn
roundingEnabled là thứ duy nhất quyết định chế độ. Nó ánh xạ thẳng vào MarketCurrencySettings.roundingEnabled — "whether or not rounding is enabled on multi-currency prices". Thư viện không tự suy ra nó từ tỉ giá, cũng không suy ra từ việc hai đơn vị tiền có khác nhau hay không.
false — half-up tới số chữ số thập phân của đơn vị tiền
Cắt số tiền về đúng số chữ số thập phân mà đơn vị tiền cho phép, nửa thì làm tròn lên. Số âm nửa thì làm tròn ra xa số 0.
roundMoney(45.678, "USD", false); // 45.68
roundMoney(45678.6, "VND", false); // 45679 — VND 0 chữ số thập phân
roundMoney(45.6789, "KWD", false); // 45.679 — KWD 3 chữ số thập phân
roundMoney(-2.675, "USD", false); // -2.68Dùng cho mọi số tiền không phải giá cuối cùng buyer trả: discount value, minimum requirement, subtotal trung gian. Dùng cả cho giá cuối, ở market đã tắt rounding.
true (mặc định) — làm tròn lên tới bậc của Markets
Làm tròn lên phần thập phân thừa, rồi nâng tiếp kết quả lên bậc mà Shopify Markets gán cho đơn vị tiền đó.
roundMoney(250_010, "VND"); // 251000 — bậc 1.000₫
roundMoney(12.301, "JPY"); // 100 — bậc ¥100
roundMoney(250, "JPY"); // 300 — bội số của bậc
roundMoney(12.301, "USD"); // 13 — bậc $1
roundMoney(10.2, "EUR"); // 10.95 — bậc €0.95
roundMoney(45.6781, "KWD"); // 45.679 — không có bậc, chỉ ceil tới số thập phânDùng cho giá cuối cùng buyer trả, ở market có bật rounding.
Chọn trong thực tế
Rounding của Markets chỉ áp cho giá đã quy đổi — đó chính là nghĩa của multi-currency prices. Giá ở đơn vị tiền gốc do merchant tự đặt, không bao giờ bị nâng lên bội số của ¥100 hay 1.000₫. Thư viện không biết store currency, nên điều kiện đó thuộc về phía caller:
const isConverted = presentmentCurrency !== storeCurrency;
roundMoney(amount, presentmentCurrency, isConverted && market.roundingEnabled);Thuật toán
Bảng đơn vị tiền
161 mã, mỗi mã hai thuộc tính.
decimals lấy từ Shopify/money, config/currency_iso.yml, đã bỏ các mã phi giao dịch: kim loại, SDR, CLF.
roundUpToNearest là "Round up to the nearest {value}" của Shopify. Giá trị này chỉ đọc được bằng mắt từ Shopify admin → Markets: Shopify không expose nó qua API lẫn Liquid. Nó cũng KHÔNG phải smallest_denomination của money gem — cái đó là cash rounding và cho ra số khác hẳn (100₫ thay vì 1.000₫, ¥1 thay vì ¥100). Có 33 mã thiếu roundUpToNearest: hoặc là mã Markets không chọn được (toàn bộ đơn vị 3 chữ số thập phân — BHD, IQD, JOD, KWD, LYD, OMR, TND), hoặc là mã phát hành không kèm bậc, LBP là một ví dụ.
Đây là mặc định của Shopify, không phải cam kết. Merchant tắt được rounding theo từng market hoặc chọn price ending khác, nên roundingEnabled là tham số chứ không phải hằng số.
roundingEnabled lấy ở đâu
Field nằm trên MarketCurrencySettings trong Admin GraphQL API, truy cập qua Market.currencySettings. Cần scope read_markets.
query MarketRounding($id: ID!) {
market(id: $id) {
currencySettings {
roundingEnabled
localCurrencies
baseCurrency {
currencyCode
}
}
}
}Storefront không query được Admin API. Trong theme app extension, phía admin của app phải đọc giá trị này rồi truyền xuống storefront — qua metafield, app proxy, hoặc biến config nhúng sẵn vào theme.
roundUpToNearest có hai ngữ nghĩa
Cùng một con số, đọc theo hai cách tùy độ lớn.
| Bậc | Ngữ nghĩa | Ví dụ |
| ----------- | -------------- | ------------------------------------------------ |
| >= 1 đơn vị | Bội số của bậc | ¥100: 250 → 300; 1.000₫: 250010 → 251000 |
| < 1 đơn vị | Price ending | €0.95: 10.20 → 10.95, 10.96 → 11.95 |
Với €0.95, kết quả không phải bội số của 0.95. Nó là số nguyên đơn vị gần nhất kết thúc bằng .95. Phép toán chạy trên subunit nguyên nên không tự đẻ thêm sai số.
Cắt thập phân trước, nâng bậc sau
roundMoney làm tròn lên tới decimals trước, rồi mới nâng bậc. Đảo lại thì 12.000001 — sai số dấu phẩy động tích lại từ phép nhân tỉ giá phía trên — đã đủ để nhảy nguyên một bậc lên 13 thay vì đứng yên ở 12.
Làm tròn chạy trên chuỗi chữ số, không chạy trên số đã nhân
Công thức quen thuộc Math.round(amount * 10 ** decimals) / 10 ** decimals sai với những số tiền có thật:
1.005 * 100 → 100.49999999999999 → Math.round → 100 → 1.00 sai, phải là 1.01
8.165 * 100 → 816.4999999999999 → Math.round → 816 → 8.16 sai, phải là 8.17toFixed dính đúng lỗi đó: (1.005).toFixed(2) là "1.00".
Thư viện lấy amount.toPrecision(15), tách phần nguyên và phần thập phân dưới dạng chuỗi, quyết định lên hay xuống bằng chữ số bị cắt đầu tiên, rồi cộng subunit bằng BigInt. 15 chữ số có nghĩa cũng đủ để hấp thụ sai số tích từ phía trên mà không đọc sai giá trị:
100.005 - 80 → 20.004999999999995 → toPrecision(15) → "20.0050000000000" → 20.01BigInt là lý do build target tối thiểu là ES2020.
Từ 1e21 trở lên, số sẽ stringify thành dạng lũy thừa và BigInt() vỡ, nên số tiền chạm ngưỡng đó được trả về nguyên vẹn.
Số âm
Half-up làm tròn ra xa số 0: -2.675 → -2.68. Nâng bậc thì ceil theo hướng +∞, tức là về phía 0: -12.301 → -11, không xuống -13. Số âm chỉ xuất hiện dưới dạng discount delta, vốn là nhóm phải dùng roundingEnabled: false.
Ví dụ tích hợp
Storefront (theme app extension)
import { roundMoney } from "shopify-money-rounding";
const storeCurrency = "USD";
const { active, rate } = window.Shopify.currency;
export function buyerPrice(baseAmount: number, roundingEnabled: boolean): number {
const presented = baseAmount * Number.parseFloat(rate);
return roundMoney(presented, active, active !== storeCurrency && roundingEnabled);
}
export function discountValue(amount: number): number {
return roundMoney(amount, active, false);
}window.Shopify.currency.rate luôn ứng với window.Shopify.currency.active. Nếu bạn đang hiển thị một đơn vị tiền khác thì tỉ giá đó không dùng được.
Admin app
import { roundMoney } from "shopify-money-rounding";
const previewPrice = roundMoney(
basePrice * market.exchangeRate,
market.currencyCode,
market.currencySettings.roundingEnabled,
);Phát triển
pnpm build # tsup (ESM + CJS), rồi tsc sinh .d.ts
pnpm typecheck
pnpm test.d.ts do tsc sinh chứ không phải tsup: bước dts của tsup dùng rollup-plugin-dts vốn gọi API TypeScript 5, còn dự án này chạy TypeScript 7 native nên nó vỡ ngay lúc load.
