npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

shopify-money-rounding

v1.1.0

Published

Round money the way Shopify rounds it, with per-currency decimals and Markets rounding steps.

Downloads

68

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.68

Install

pnpm add shopify-money-rounding

The 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 code

Formatting 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.68

Use 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 decimals

Use 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.17

toFixed 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.01

BigInt 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 test

tsc 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.68

Cài đặt

pnpm add shopify-money-rounding

Package 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.68

Dù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ân

Dù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.17

toFixed 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.01

BigInt 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.