Files
2026-05-27 23:29:53 +01:00

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 MoneyError whose code matches the expected MONEY.* constant

Scenario: SuaveplanError contract preserved

  • WHEN a MoneyError is thrown with a cause
  • THEN the resulting error is instanceof SuaveplanError, exposes the original on error.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") and Money.fromMajor("10.50", "USD") are called
  • THEN both return a Money with amount === 1050n and currency === "USD"

Scenario: zero produces a zero-amount Money

  • WHEN Money.zero("EUR") is called
  • THEN the result has amount === 0n and currency === "EUR"

Scenario: fromJSON inverts toJSON

  • WHEN Money.fromJSON(m.toJSON()) is called for any Money m
  • THEN the result equals m (same amount and currency)

Scenario: arithmetic does not mutate this

  • WHEN const m = Money.fromMinor(100n, "USD"); m.add(Money.fromMinor(50n, "USD")) is called
  • THEN m.amount === 100n is 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 MoneyError is thrown with code === "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 MoneyError is thrown with code === "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") against 200n/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 MoneyError is thrown with code === "MONEY.UNKNOWN_CURRENCY" and currency === "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") is true and getVirtualCurrencies() 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 is 1000n

Scenario: allocateByRatios preserves total

  • WHEN allocateByRatios({ amount: 10000n, currency: "USD" }, [50, 30, 20]) is called
  • THEN the result shares sum to 10000n and approximate [5000n, 3000n, 2000n]

Scenario: allocateByPercentages enforces sum = 100

  • WHEN allocateByPercentages({ amount: 1000n, currency: "USD" }, [50, 30]) is called (sum 80)
  • THEN a MoneyError is thrown with code === "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 MoneyError is thrown with code === "MONEY.INVALID_ALLOCATION"

Scenario: empty ratios raises INVALID_ALLOCATION

  • WHEN allocateByRatios({ amount: 1000n, currency: "USD" }, []) is called
  • THEN a MoneyError is thrown with code === "MONEY.INVALID_ALLOCATION"

Scenario: negative ratio raises INVALID_ALLOCATION

  • WHEN allocateByRatios({ amount: 1000n, currency: "USD" }, [-1, 2]) is called
  • THEN a MoneyError is thrown with code === "MONEY.INVALID_ALLOCATION"

Scenario: zero total ratio raises INVALID_ALLOCATION

  • WHEN allocateByRatios({ amount: 1000n, currency: "USD" }, [0, 0]) is called
  • THEN a MoneyError is thrown with code === "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 MoneyError is thrown with code === "MONEY.EMPTY_INPUT"

Scenario: minMoney with no args raises EMPTY_INPUT

  • WHEN minMoney() is called
  • THEN a MoneyError is thrown with code === "MONEY.EMPTY_INPUT"

Scenario: maxMoney with no args raises EMPTY_INPUT

  • WHEN maxMoney() is called
  • THEN a MoneyError is thrown with code === "MONEY.EMPTY_INPUT"

Scenario: heterogeneous currencies raise CURRENCY_MISMATCH

  • WHEN sumMoney([{ amount: 1n, currency: "USD" }, { amount: 1n, currency: "EUR" }]) is called
  • THEN a MoneyError is thrown with code === "MONEY.CURRENCY_MISMATCH"

Scenario: aggregators return correct extremum

  • WHEN minMoney(Money.fromMinor(100n, "USD"), Money.fromMinor(50n, "USD")) and maxMoney(...) are called
  • THEN minMoney returns the 50n value, maxMoney returns the 100n value, and sumMoney([...]) 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 MoneyError is thrown with code === "MONEY.PARSE_ERROR"

Scenario: non-numeric input raises PARSE_ERROR

  • WHEN Money.fromMajor("abc", "USD") is called
  • THEN a MoneyError is thrown with code === "MONEY.PARSE_ERROR"

Scenario: multiple decimal points raise PARSE_ERROR

  • WHEN Money.fromMajor("1.2.3", "USD") is called
  • THEN a MoneyError is thrown with code === "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"