New:Microsoft Teams Notifications Are Now Available in Socket.Learn more
Get Started

@ultimat3/money

Package Overview
Dependencies
Maintainers
1
Versions
37
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@ultimat3/money

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

Source
npmnpm
Version
19.2.0
Version published
Weekly downloads
1.5K
226.29%
Maintainers
1
Weekly downloads
 
Created
Source

💶 @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

ConcernStoreFormat
Amountinteger minor units (1299)Intl.NumberFormat, style: 'currency'
Currencya 3-letter code ('EUR') — shipped ISO-4217 or registerCurrency'dfraction digits derived from its exponent
Scalewhenever it differs from the currency's, finer or coarser (scale: 6)10 ** moneyScale(amount) — never a literal / 100
FX rateexplicit argument + timestamprecorded 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:

RuleRefusal
three A–Z letters — Intl throws a RangeError on anything elseX_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 preventX_CURRENCY_INVALID
a non-empty nameX_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 crashX_CURRENCY_REDEFINED
a shipped ISO row is not the app's to redefineX_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

CodeWhen
X_MONEY_NOT_INTEGERfractional 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_INVALIDa 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_UNKNOWNa code neither shipped nor registered by this process — currencyCodes() is the list
X_CURRENCY_MISMATCHarithmetic across two currencies
X_ALLOCATION_INVALIDbad part count, empty/negative/all-zero ratios, percentages ≠ 100
X_RATE_MISSINGno 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.

FAQs

Package last updated on 06 Sep 2026

Related posts