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

@ultimat3/money

v18.0.0

Published

Integer minor units with an attached currency: arithmetic, allocation, rounding, Intl formatting

Readme

💶 @ultimat3/money

Golden rule: integer minor units, currency always attached, Intl at the edge. 0.1 + 0.2 !== 0.3, so no amount is ever a float. Money carries minor and currency together — plus an optional scale — and arithmetic across two currencies throws instead of guessing.

Money is @ultimat3/schema's MoneyValue, and so is @ultimat3/entity's: one declaration at tier 0, aliased twice, never restated. A row a money() column decodes is therefore a Money already — add(row.price, shipping) and formatMoney(row.price, locale) take it with no cast. minor is a number because money is projected onto every wire the framework generates and JSON.stringify refuses a bigint; the bigint column that backs it refuses a value past ±2^53 on read rather than rounding it. → Money

| Concern | Store | Format | |---|---|---| | Amount | integer minor units (1299) | Intl.NumberFormat, style: 'currency' | | Currency | a 3-letter code ('EUR') — shipped ISO-4217 or registerCurrency'd | fraction digits derived from its exponent | | Scale | whenever it differs from the currency's, finer or coarser (scale: 6) | 10 ** moneyScale(amount) — never a literal / 100 | | FX rate | explicit argument + timestamp | recorded on the converted value |

Use

import { add, allocate, formatMoney, fromDecimal, money } from '@ultimat3/money';

const price = fromDecimal('12.99', 'EUR');   // { minor: 1299, currency: 'EUR' }
const total = add(price, money(500, 'EUR')); // 1799
formatMoney(total, 'de-DE');                 // "17,99 €"
formatMoney(money(1200, 'JPY'), 'en-US');    // "¥1,200"  — 0 decimals
formatMoney(money(1234, 'KWD'), 'en-US');    // "KWD 1.234" — 3 decimals
add(price, money(500, 'USD'));               // throws X_CURRENCY_MISMATCH

Minor units are not always cents

exponentOf() is the single source of truth: USD/EUR 2, JPY/KRW/VND/ISK 0, KWD/BHD/OMR 3. fromDecimal scales by it ('1.234' KWD → 1234), toDecimalString reverses it, and formatMoney sets the fraction digits from it. Hardcoding / 100 is a JPY bug and a KWD bug.

A currency the shipped rows do not carry

As of 2026-08, 53 ISO-4217 rows ship. They are a convention — one useful subset — so an app adds its own with a call rather than a fork: a local currency, a scrip, a loyalty point, a token.

import { fromDecimal, registerCurrency } from '@ultimat3/money';

registerCurrency({ code: 'XBT', exponent: 8, name: 'Bitcoin' });
fromDecimal('1.23456789', 'XBT');            // { minor: 123456789, currency: 'XBT' }

Once, at boot, before the first amount in that currency is built. The rules, each a refusal:

| Rule | Refusal | |---|---| | three A–Z letters — Intl throws a RangeError on anything else | X_CURRENCY_INVALID | | a whole exponent from 0 to MAX_MONEY_SCALE — there is no safe default, and a silent 2 is the corrupted maths this package exists to prevent | X_CURRENCY_INVALID | | a non-empty name | X_CURRENCY_INVALID | | one code, one declaration — a second exponent reinterprets every stored amount by a power of ten, and a second name makes currencyInfo().name depend on import order. An identical re-registration is a no-op, so a module imported twice is not a crash | X_CURRENCY_REDEFINED | | a shipped ISO row is not the app's to redefine | X_CURRENCY_REDEFINED |

CURRENCIES stays the shipped constant; currencyCodes() answers for this process, registrations included. That is why one is a value and the other is a call.

Sub-cent amounts carry a scale

money(2, 'USD', 6) is $0.000002 — minor counting 10⁻⁶ instead of the currency's own 10⁻². A value that names no scale means the currency's, which is every amount that already exists, so nothing about { minor, currency } changes: same shape, same JSON, same columns. Only a scale equal to the currency's is dropped, so a deliberately coarser one is kept too: money(5, 'USD', 0) is $5 counted in whole dollars, and rescale() produces such values legitimately.

import { add, fromDecimal, money, moneyScale, rescale } from '@ultimat3/money';

moneyScale(money(1299, 'EUR'));              // 2 — the currency's own
moneyScale(money(2, 'USD', 6));              // 6
rescale(money(80, 'USD'), 8);                // $0.80 as 80,000,000 hundred-millionths
rescale(money(1_234_567, 'USD', 6), 2);      // throws X_MONEY_NOT_INTEGER — digits would go
rescale(money(1_234_567, 'USD', 6), 2, 'half-up');  // 123¢, the loss named at the call
fromDecimal('0.000002', 'USD', { scale: 6 });
add(money(1, 'USD'), money(2, 'USD', 6));    // meets at scale 6: 10002, nothing lost

Arithmetic normalises to the finer of two scales, never the coarser — adding a sub-cent fee to a cent cannot round the fee away. compare and equals read the value rather than the encoding, so 1299 EUR and 12,990,000 EUR at scale 6 are one amount. multiply, divide, negate, allocate and convert keep the scale they were handed — a micro-priced amount is still micro-priced in the target currency. Widening is exact and free; a lossy narrowing needs a RoundingMode at the call site, exactly as excess precision does in fromDecimal — a narrowing that drops only zeros is exact and needs no mode.

It exists because whole cents could not name the cost of a model call: 200 tokens at $0.80 per million is $0.00016, and rounding that up to 1¢ bills 62x — a budget built on that number is fiction. The alternative was a second money type.

Allocation

allocate(money(100, 'USD'), 3)34, 33, 33. Largest-remainder split: floor every part, then hand out the leftover units one at a time, biggest fractional remainder first. round(100 / 3) either loses a cent or invents one, and an invoice that does that fails reconciliation forever. allocateByRatios does the same for revenue shares and line splits.

Rounding is never implicit

multiply(price, 0.19, 'half-up') — the mode is an argument because tax and interest rules name one in law. half-up, half-even (banker's), down, up. The default is half-up and it is stated, not inherited from Math.round.

Conversion

No default rate provider ships. convert(amount, to, rate) takes the rate explicitly and returns the source amount, the rate, and its timestamp alongside the result — a finance audit has to be able to reproduce the number. Implement RateProvider for a live feed; fixedRateProvider() covers tests, seeds and manually agreed invoice rates.

A rate may also carry ratio — the exact Fraction its rate approximates — and convert scales by that when it is there. It is how a derived direction stays exact: a table naming USD/EUR: 0.92 names 23/25, so fixedRateProvider answers EUR→USD with 25/23 rather than the double 1 / 0.92, whose own decimal spelling rounds a large amount one minor unit low. rate stays the readable number the audit trail records.

convert preserves the amount's own scale. convert(money(2, 'USD', 6), 'EUR', parity) is €0.000002, not €0.02 — the target currency's minor unit decides nothing about a value that already carries its own precision. convertWith on a same-currency pair stamps at from an injected Clock ({ clock }, default systemClock) or from an explicit { at }, never from the epoch: ExchangeRate.at is the audit trail.

Errors

| Code | When | |---|---| | X_MONEY_NOT_INTEGER | fractional minor units, a decimal string more precise than the scale, or a rescale that would drop a digit with no mode named | | X_MONEY_SCALE_INVALID | a scale that is not a whole number of decimal places in 0…15, or a widening whose result no longer fits a safe integer | | X_CURRENCY_UNKNOWN | a code neither shipped nor registered by this process — currencyCodes() is the list | | X_CURRENCY_MISMATCH | arithmetic across two currencies | | X_ALLOCATION_INVALID | bad part count, empty/negative/all-zero ratios, percentages ≠ 100 | | X_RATE_MISSING | no rate for the pair — never assumes parity |

Why it exists

Every money bug in production is one of three things: a float, a missing currency, or a lost cent in a split. This package makes all three unrepresentable rather than discouraged.