
Security News
Happy Birthday, Shai-Hulud
It has been one year since Shai-Hulud made its first appearance on npm.
@ultimat3/money
Advanced tools
Integer minor units with an attached currency: arithmetic, allocation, rounding, Intl formatting
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 |
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
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.
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.
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.
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.
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.
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.
| 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 |
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
Integer minor units with an attached currency: arithmetic, allocation, rounding, Intl formatting
The npm package @ultimat3/money receives a total of 902 weekly downloads. As such, @ultimat3/money popularity was classified as not popular.
We found that @ultimat3/money demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Security News
It has been one year since Shai-Hulud made its first appearance on npm.

Research
/Security News
Operators behind PolinRider used a compromised GitHub account to plant malware in four development versions of a Packagist package with 700,000+ downloads.

Security News
GitHub Actions now supports cache-mode, a least-privilege control on the Actions cache aimed at the cache poisoning technique behind recent compromises.