22 KiB
Capability Spec: money
Package: @suaveplan/money
Status: ACCEPTED (2026-05-17, change add-money)
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions.
ADDED Requirements
Requirement: Universal-tier purity
The package SHALL ship as a universal-tier package — no imports from node:*, bun:*, or the bare specifiers fs/path/stream/net/http/crypto in any file under src/. The package SHALL run unchanged under Node ≥ 18, Bun, modern browsers, and Web Workers. The package.json SHALL NOT declare a "browser" field, SHALL NOT pin engines.node or engines.bun, and SHALL declare "sideEffects": false. The single permitted platform-aware call is Intl.NumberFormat (available in every supported runtime).
Scenario: no platform imports in src/
- WHEN
grep -rnE "from ['\"](node:|bun:|fs|path|stream|net|http|crypto)['\"]" packages/finance/money/src/is run - THEN the command produces zero matches
Verified by: src/money/money.test.ts::"Money module has no node/bun imports (tier purity)" AND src/formatter/formatter.test.ts::"formatter module has no node/bun imports (tier purity)"
Requirement: Stable MONEY_CODES error registry and MoneyError class
The package SHALL declare an error-code registry via defineCodes("MONEY", [...]) containing exactly the codes CURRENCY_MISMATCH, UNKNOWN_CURRENCY, DIVISION_BY_ZERO, INVALID_ALLOCATION, EMPTY_INPUT, PARSE_ERROR. Every public error path SHALL throw a MoneyError extends SuaveplanError<MoneyErrorCode> carrying one of those codes. MoneyError SHALL expose optional currency and otherCurrency discriminants for diagnostics. The package SHALL NOT throw raw Error instances from any public method.
Scenario: every code is reachable from a real call site
- WHEN the test suite exercises cross-currency arithmetic, unknown-currency lookup, division by zero, invalid allocation parameters, empty aggregator input, and malformed decimal-string parsing
- THEN each path raises a
MoneyErrorwhosecodematches the expectedMONEY.*constant
Scenario: SuaveplanError contract preserved
- WHEN a
MoneyErroris thrown with acause - THEN the resulting error is
instanceof SuaveplanError, exposes the original onerror.cause, and the wrapped code remains stable
Verified by: src/error-codes/error-codes.test.ts::"every MONEY.* code is reachable from a call site" AND src/error-codes/error-codes.test.ts::"MoneyError preserves cause and SuaveplanError contract" AND src/error-codes/error-codes.test.ts::"MoneyError carries currency and otherCurrency discriminants"
Requirement: Immutable Money value type with bigint storage
The package SHALL provide an immutable Money class with readonly amount: bigint and readonly currency: CurrencyCode properties. Every arithmetic and conversion method SHALL return a new Money instance — methods MUST NOT mutate this. The class SHALL implements MoneyLike so structural-typing consumers ({ amount, currency }) interoperate freely. Static constructors fromMajor(value: number | string, currency), fromMinor(amount: bigint | number, currency), zero(currency), and fromJSON({ amount, currency }) SHALL be provided.
Scenario: fromMajor handles numeric and decimal-string input
- WHEN
Money.fromMajor(10.50, "USD")andMoney.fromMajor("10.50", "USD")are called - THEN both return a
Moneywithamount === 1050nandcurrency === "USD"
Scenario: zero produces a zero-amount Money
- WHEN
Money.zero("EUR")is called - THEN the result has
amount === 0nandcurrency === "EUR"
Scenario: fromJSON inverts toJSON
- WHEN
Money.fromJSON(m.toJSON())is called for anyMoneym - THEN the result equals
m(sameamountandcurrency)
Scenario: arithmetic does not mutate this
- WHEN
const m = Money.fromMinor(100n, "USD"); m.add(Money.fromMinor(50n, "USD"))is called - THEN
m.amount === 100nis still true after the call
Verified by: src/money/money.test.ts::"fromMajor handles number and decimal string" AND src/money/money.test.ts::"zero returns a zero Money" AND src/money/money.test.ts::"toJSON / fromJSON round-trips" AND src/money/money.test.ts::"arithmetic does not mutate the receiver"
Requirement: Money arithmetic, comparison, and conversion surface
The Money class SHALL expose add(other), subtract(other), multiply(factor, rounding?), divide(divisor, rounding?), abs(), negate() arithmetic methods; compareTo(other), equals(other), greaterThan(other), lessThan(other), greaterThanOrEqual(other), lessThanOrEqual(other), isZero(), isPositive(), isNegative() comparison methods; and toMajor(), toDecimalString(), toJSON(), toString() conversion methods. Cross-currency operations SHALL raise MoneyError({ code: MONEY.CURRENCY_MISMATCH }) with both currencies on the discriminants. divide(0) and divide(0n) SHALL raise MoneyError({ code: MONEY.DIVISION_BY_ZERO }).
Scenario: cross-currency add raises CURRENCY_MISMATCH
- WHEN
Money.fromMinor(100n, "USD").add(Money.fromMinor(100n, "EUR"))is called - THEN a
MoneyErroris thrown withcode === "MONEY.CURRENCY_MISMATCH",currency === "USD",otherCurrency === "EUR"
Scenario: divide by zero raises DIVISION_BY_ZERO
- WHEN
Money.fromMinor(100n, "USD").divide(0)is called - THEN a
MoneyErroris thrown withcode === "MONEY.DIVISION_BY_ZERO"
Scenario: toDecimalString preserves precision
- WHEN
Money.fromMinor(1050n, "USD").toDecimalString()is called - THEN the result is
"10.50"
Scenario: compareTo returns -1/0/1
- WHEN comparing
Money.fromMinor(100n, "USD")against200n/100n/50n - THEN the results are
-1,0,1
Verified by: src/money/money.test.ts::"add raises CURRENCY_MISMATCH on cross-currency" AND src/money/money.test.ts::"divide raises DIVISION_BY_ZERO" AND src/money/money.test.ts::"toDecimalString preserves precision" AND src/money/money.test.ts::"compareTo returns -1, 0, 1" AND src/money/money.test.ts::"every comparison helper aligns with compareTo"
Requirement: applyRounding supports five modes
The package SHALL export an applyRounding(value: number, mode: RoundingMode): bigint function supporting modes "half-up", "half-down", "half-even", "floor", "ceil". multiply and divide on Money SHALL accept an optional rounding parameter defaulting to "half-up" and delegate to applyRounding.
Scenario: each mode rounds 2.5 correctly
- WHEN
applyRounding(2.5, mode)is called for each of the five modes - THEN the results are
3n(half-up),2n(half-down),2n(half-even / banker's),2n(floor),3n(ceil)
Scenario: each mode rounds 3.5 correctly under banker's
- WHEN
applyRounding(3.5, "half-even")is called - THEN the result is
4n(round to even)
Verified by: src/money/money.test.ts::"applyRounding handles every mode at 2.5 boundary" AND src/money/money.test.ts::"applyRounding half-even rounds to even at 3.5"
Requirement: Built-in currency registry with lookup, registration, and virtual-currency helpers
The package SHALL ship a built-in registry containing exactly 30 ISO 4217 fiat currencies (USD, EUR, GBP, JPY, CHF, CAD, AUD, CNY, INR, BRL, KRW, MXN, SGD, HKD, NOK, SEK, DKK, NZD, ZAR, PLN, THB, TRY, TWD, AED, SAR, ILS, RUB, CZK, CLP, PHP) plus 5 cryptocurrencies (BTC, ETH, SOL, USDT, USDC). The package SHALL export getCurrency(code): CurrencyInfo | undefined, getCurrencyOrThrow(code): CurrencyInfo, registerCurrency(info): void, getAllCurrencyCodes(): CurrencyCode[], getVirtualCurrencies(): CurrencyInfo[], isVirtualCurrency(code): boolean, minorUnitMultiplier(exponent): bigint. getCurrencyOrThrow SHALL raise MoneyError({ code: MONEY.UNKNOWN_CURRENCY, currency }) when the code is neither built-in nor custom-registered. registerCurrency SHALL accept an optional virtual: boolean flag and issuerId: string for virtual currencies.
Scenario: built-in lookup succeeds
- WHEN
getCurrency("USD")is called - THEN the result is
{ code: "USD", exponent: 2, symbol: "$", name: "US Dollar" }
Scenario: unknown currency raises UNKNOWN_CURRENCY
- WHEN
getCurrencyOrThrow("XYZ")is called against a currency neither built-in nor registered - THEN a
MoneyErroris thrown withcode === "MONEY.UNKNOWN_CURRENCY"andcurrency === "XYZ"
Scenario: custom registration overrides built-ins
- WHEN
registerCurrency({ code: "USD", exponent: 3, symbol: "$$$", name: "Custom" })is called - THEN subsequent
getCurrency("USD")returns the custom record (exponent 3)
Scenario: virtual currency helpers
- WHEN
registerCurrency({ code: "GEM", exponent: 0, symbol: "💎", name: "Game Gem", virtual: true })is called - THEN
isVirtualCurrency("GEM")istrueandgetVirtualCurrencies()includes the registered entry
Scenario: minorUnitMultiplier computes 10^exponent as bigint
- WHEN
minorUnitMultiplier(8)is called - THEN the result is
100_000_000n
Verified by: src/currencies/currencies.test.ts::"built-in lookup returns USD" AND src/currencies/currencies.test.ts::"getCurrencyOrThrow raises UNKNOWN_CURRENCY for unknown code" AND src/currencies/currencies.test.ts::"registerCurrency overrides built-in" AND src/currencies/currencies.test.ts::"virtual currency helpers report registered virtual entries" AND src/currencies/currencies.test.ts::"minorUnitMultiplier computes 10^exponent as bigint" AND src/currencies/currencies.test.ts::"ships exactly 30 ISO 4217 fiat + 5 crypto built-ins"
Requirement: Locale-aware formatMoney with options and overrides
The formatMoney(money, options?) function SHALL render a MoneyLike to a human-readable string using Intl.NumberFormat(locale)-derived decimal and thousands separators by default. Options SHALL include locale (default "en-US"), showSymbol (default true), showCode (default false — when true, suffix the ISO code and suppress the symbol), decimalSeparator (override), thousandsSeparator (override), and trailingZeros (default true — when false, trim trailing zeros from the fractional part). Currency precision SHALL be preserved by avoiding Number casts on the integer portion.
Scenario: default formatting uses symbol
- WHEN
formatMoney({ amount: 1050n, currency: "USD" })is called - THEN the result is
"$10.50"
Scenario: showCode mode suppresses symbol
- WHEN
formatMoney({ amount: 1050n, currency: "USD" }, { showCode: true })is called - THEN the result is
"10.50 USD"
Scenario: locale derives separator
- WHEN
formatMoney({ amount: 105050n, currency: "EUR" }, { locale: "de-DE" })is called - THEN the result uses
,as the decimal separator and.as the thousands separator (e.g."€1.050,50")
Scenario: trailingZeros false trims trailing zeros
- WHEN
formatMoney({ amount: 1000n, currency: "USD" }, { trailingZeros: false })is called - THEN the fractional portion is empty in the output
Scenario: negative amount renders minus before the numeric portion
- WHEN
formatMoney({ amount: -1050n, currency: "USD" })is called - THEN the result is
"$-10.50"(symbol prefix retained, minus precedes the numeric portion)
Scenario: zero-exponent currency omits decimal
- WHEN
formatMoney({ amount: 1000n, currency: "JPY" })is called - THEN the result is
"¥1,000"(no decimal point)
Verified by: src/formatter/formatter.test.ts::"formats USD with symbol by default" AND src/formatter/formatter.test.ts::"showCode mode suffixes ISO code" AND src/formatter/formatter.test.ts::"de-DE locale derives comma decimal separator" AND src/formatter/formatter.test.ts::"trailingZeros false trims trailing zeros" AND src/formatter/formatter.test.ts::"negative amount prepends minus" AND src/formatter/formatter.test.ts::"zero-exponent currency omits decimal point"
Requirement: Compact money formatting
The formatMoneyCompact(money) function SHALL render large amounts with K / M / B suffixes. Values ≥ 1e9 SHALL use B, ≥ 1e6 SHALL use M, ≥ 1e3 SHALL use K, and smaller values SHALL render with one decimal (or no decimal for zero-exponent currencies). The currency symbol SHALL prefix the value; negative amounts SHALL prepend - before the symbol.
Scenario: thousands → K
- WHEN
formatMoneyCompact(Money.fromMajor(1500, "USD"))is called - THEN the result is
"$1.5K"
Scenario: millions → M
- WHEN
formatMoneyCompact(Money.fromMajor(2_500_000, "USD"))is called - THEN the result is
"$2.5M"
Scenario: billions → B
- WHEN
formatMoneyCompact(Money.fromMajor(7_000_000_000, "USD"))is called - THEN the result is
"$7.0B"
Scenario: small values render with currency precision
- WHEN
formatMoneyCompact(Money.fromMajor(99, "USD"))is called - THEN the result is
"$99.00"
Scenario: negative amount
- WHEN
formatMoneyCompact(Money.fromMajor(-1500, "USD"))is called - THEN the result is
"-$1.5K"
Verified by: src/formatter/formatter.test.ts::"compact: thousands rendered with K" AND src/formatter/formatter.test.ts::"compact: millions rendered with M" AND src/formatter/formatter.test.ts::"compact: billions rendered with B" AND src/formatter/formatter.test.ts::"compact: small values use currency precision" AND src/formatter/formatter.test.ts::"compact: negative prepends minus before symbol"
Requirement: Allocators preserve input total
The allocator functions allocateEvenly(money, parts), allocateByRatios(money, ratios), and allocateByPercentages(money, percentages) SHALL return an AllocationResult { shares, remainder } such that sumMoney(shares).amount === money.amount for every input. The remainder field SHALL always equal 0n. Remainders from integer division SHALL be distributed round-robin to the leading shares, with sign preserved for negative inputs. Invalid inputs SHALL raise MoneyError({ code: MONEY.INVALID_ALLOCATION }): allocateEvenly(parts ≤ 0), allocateByRatios(empty), allocateByRatios(negative ratio), allocateByRatios(totalRatio ≤ 0), allocateByPercentages(sum != 100 ± 0.001).
Scenario: allocateEvenly preserves total
- WHEN
allocateEvenly({ amount: 1000n, currency: "USD" }, 3)is called - THEN the result is
{ shares: [334n, 333n, 333n], remainder: 0n }and the sum is1000n
Scenario: allocateByRatios preserves total
- WHEN
allocateByRatios({ amount: 10000n, currency: "USD" }, [50, 30, 20])is called - THEN the result shares sum to
10000nand approximate[5000n, 3000n, 2000n]
Scenario: allocateByPercentages enforces sum = 100
- WHEN
allocateByPercentages({ amount: 1000n, currency: "USD" }, [50, 30])is called (sum 80) - THEN a
MoneyErroris thrown withcode === "MONEY.INVALID_ALLOCATION"
Scenario: negative input preserves sign distribution
- WHEN
allocateEvenly({ amount: -1000n, currency: "USD" }, 3)is called - THEN the shares sum to
-1000n
Scenario: zero parts raises INVALID_ALLOCATION
- WHEN
allocateEvenly({ amount: 1000n, currency: "USD" }, 0)is called - THEN a
MoneyErroris thrown withcode === "MONEY.INVALID_ALLOCATION"
Scenario: empty ratios raises INVALID_ALLOCATION
- WHEN
allocateByRatios({ amount: 1000n, currency: "USD" }, [])is called - THEN a
MoneyErroris thrown withcode === "MONEY.INVALID_ALLOCATION"
Scenario: negative ratio raises INVALID_ALLOCATION
- WHEN
allocateByRatios({ amount: 1000n, currency: "USD" }, [-1, 2])is called - THEN a
MoneyErroris thrown withcode === "MONEY.INVALID_ALLOCATION"
Scenario: zero total ratio raises INVALID_ALLOCATION
- WHEN
allocateByRatios({ amount: 1000n, currency: "USD" }, [0, 0])is called - THEN a
MoneyErroris thrown withcode === "MONEY.INVALID_ALLOCATION"
Verified by: src/allocator/allocator.test.ts::"allocateEvenly preserves total" AND src/allocator/allocator.test.ts::"allocateByRatios preserves total" AND src/allocator/allocator.test.ts::"allocateByPercentages raises INVALID_ALLOCATION when sum != 100" AND src/allocator/allocator.test.ts::"allocateEvenly preserves negative sign distribution" AND src/allocator/allocator.test.ts::"allocateEvenly raises INVALID_ALLOCATION for zero parts" AND src/allocator/allocator.test.ts::"allocateByRatios raises INVALID_ALLOCATION for empty ratios" AND src/allocator/allocator.test.ts::"allocateByRatios raises INVALID_ALLOCATION for negative ratio" AND src/allocator/allocator.test.ts::"allocateByRatios raises INVALID_ALLOCATION for zero total ratio"
Requirement: Aggregators handle empty input and cross-currency mismatch
sumMoney(values), minMoney(...values), and maxMoney(...values) SHALL raise MoneyError({ code: MONEY.EMPTY_INPUT }) when called with no values. Heterogeneous currency input SHALL raise MoneyError({ code: MONEY.CURRENCY_MISMATCH }).
Scenario: sumMoney with empty array raises EMPTY_INPUT
- WHEN
sumMoney([])is called - THEN a
MoneyErroris thrown withcode === "MONEY.EMPTY_INPUT"
Scenario: minMoney with no args raises EMPTY_INPUT
- WHEN
minMoney()is called - THEN a
MoneyErroris thrown withcode === "MONEY.EMPTY_INPUT"
Scenario: maxMoney with no args raises EMPTY_INPUT
- WHEN
maxMoney()is called - THEN a
MoneyErroris thrown withcode === "MONEY.EMPTY_INPUT"
Scenario: heterogeneous currencies raise CURRENCY_MISMATCH
- WHEN
sumMoney([{ amount: 1n, currency: "USD" }, { amount: 1n, currency: "EUR" }])is called - THEN a
MoneyErroris thrown withcode === "MONEY.CURRENCY_MISMATCH"
Scenario: aggregators return correct extremum
- WHEN
minMoney(Money.fromMinor(100n, "USD"), Money.fromMinor(50n, "USD"))andmaxMoney(...)are called - THEN
minMoneyreturns the 50n value,maxMoneyreturns the 100n value, andsumMoney([...])returns 150n
Verified by: src/allocator/allocator.test.ts::"sumMoney raises EMPTY_INPUT for empty array" AND src/allocator/allocator.test.ts::"minMoney raises EMPTY_INPUT with no args" AND src/allocator/allocator.test.ts::"maxMoney raises EMPTY_INPUT with no args" AND src/allocator/allocator.test.ts::"sumMoney raises CURRENCY_MISMATCH on heterogeneous currencies" AND src/allocator/allocator.test.ts::"minMoney raises CURRENCY_MISMATCH on heterogeneous currencies" AND src/allocator/allocator.test.ts::"maxMoney raises CURRENCY_MISMATCH on heterogeneous currencies" AND src/allocator/allocator.test.ts::"aggregators return correct min, max, sum"
Requirement: Decimal-string parsing rejects malformed input
Money.fromMajor(value: string, currency) SHALL parse signed decimal strings with optional whitespace, a single optional . separator, and digit-only whole and fractional parts. The parser SHALL pad or truncate the fractional part to the currency exponent. Malformed input — non-digit characters in the whole or fractional part, multiple . separators, or an empty input — SHALL raise MoneyError({ code: MONEY.PARSE_ERROR, currency }). Valid edge cases include leading sign, missing fractional part ("10"), missing whole part (".50"), and zero ("0" / "0.00").
Scenario: valid decimal string parses
- WHEN
Money.fromMajor("10.50", "USD")is called - THEN the result has
amount === 1050n
Scenario: negative decimal string parses
- WHEN
Money.fromMajor("-10.50", "USD")is called - THEN the result has
amount === -1050n
Scenario: empty string raises PARSE_ERROR
- WHEN
Money.fromMajor("", "USD")is called - THEN a
MoneyErroris thrown withcode === "MONEY.PARSE_ERROR"
Scenario: non-numeric input raises PARSE_ERROR
- WHEN
Money.fromMajor("abc", "USD")is called - THEN a
MoneyErroris thrown withcode === "MONEY.PARSE_ERROR"
Scenario: multiple decimal points raise PARSE_ERROR
- WHEN
Money.fromMajor("1.2.3", "USD")is called - THEN a
MoneyErroris thrown withcode === "MONEY.PARSE_ERROR"
Scenario: fractional part longer than exponent truncates
- WHEN
Money.fromMajor("10.555", "USD")is called - THEN the result has
amount === 1055n(truncated to two minor digits)
Verified by: src/money/money.test.ts::"fromMajor parses valid decimal string" AND src/money/money.test.ts::"fromMajor parses negative decimal string" AND src/money/money.test.ts::"fromMajor raises PARSE_ERROR on empty input" AND src/money/money.test.ts::"fromMajor raises PARSE_ERROR on non-numeric input" AND src/money/money.test.ts::"fromMajor raises PARSE_ERROR on multiple decimal points" AND src/money/money.test.ts::"fromMajor truncates over-long fractional part"
Requirement: Subpath exports per public module
The package.json SHALL expose six subpath exports in addition to the root "." export: ./money/money, ./currencies/currencies, ./formatter/formatter, ./allocator/allocator, ./error-codes/error-codes, ./types/common. Every subpath SHALL resolve to a .d.ts and a .js file under dist/ for both import and require conditions. Every feature-module index.ts barrel SHALL use explicit named re-exports (no export *).
Scenario: subpath exports declared
- WHEN a consumer runs
node -e "console.log(Object.keys(require('@suaveplan/money/package.json').exports))" - THEN the output includes
./money/money,./currencies/currencies,./formatter/formatter,./allocator/allocator,./error-codes/error-codes,./types/common
Scenario: no export * in feature barrels
- WHEN
grep -rn "^\\s*export\\s\\+\\(type\\s\\+\\)\\?\\*\\s\\+from" packages/finance/money/src/ --include="index.ts" | grep -v "/src/index\\.ts:"is run - THEN the command produces zero matches
Verified by: src/money/money.test.ts::"subpath exports are declared in package.json" AND src/money/money.test.ts::"feature barrels use explicit named re-exports"