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

15 KiB

Add @suaveplan/money — precise minor-unit currency arithmetic

Package: @suaveplan/money Status: PROPOSED Type: migration (upstream ../suaveplan/packages/finance/money)

  • Design: ./design.md
  • Spec delta: ./specs/money/spec.md

Why

STATUS.md line 131 lists @suaveplan/money as Pending. The upstream package is a small, universal-tier, runtime-pure library (5 source files, ~520 LOC, 5 test files, ~99% upstream coverage) that owns a problem genesis has no other answer for: representing monetary amounts as integer minor units (bigint) so consumer apps never hit floating-point drift, and surrounding that representation with the operators (arithmetic, comparison, allocation, formatting, JSON round-trip) that real-world commerce needs. No genesis package covers this surface today, and a string of pending downstream packages (@suaveplan/currency, @suaveplan/currency-providers, @suaveplan/payment, @suaveplan/payment-providers, the api-plugin-bank / api-plugin-payment / api-plugin-banking plugins, plus @suaveplan/booking) all consume MoneyLike and the currency registry as their value-shape contract. Migrating money first unblocks every commerce-tier package downstream.

The migration is straightforward: preserve the entire public surface verbatim (same class names, same function signatures, same option fields, same return shapes), upgrade plain throw new Error(...) to SuaveplanError with a MONEY.* code registry (rule §63 of CLAUDE.md), drop the core/ and formatting/ redundant ancestors so files live under <feature>/<feature>.ts per the no-prefix and feature-folder rules, expose every feature module as a subpath export, and ship at the genesis quality bar (100/100/100/100 coverage, sibling .md per implementation file, { timeout: 5000 } on every test, @suaveplan/testing/runner for runner primitives, no export * in feature barrels). The enterprise audit (../suaveplan/enterprise/money.md) lists ambitious additions (typed error hierarchy, property-based tests, locale-aware parser, MoneyRange, scoped CurrencyRegistry, Zod schemas, tax helpers, cryptocurrency interop) — the typed error hierarchy is in scope here; everything else is explicitly deferred to follow-up changes that build on this core.

What changes

  • ADDED: Money class — immutable bigint-backed value type with amount: bigint + currency: CurrencyCode; static constructors fromMajor(value: number | string, currency), fromMinor(amount: bigint | number, currency), zero(currency), fromJSON(json); arithmetic add(other), subtract(other), multiply(factor, rounding?), divide(divisor, rounding?), abs(), negate(); comparison compareTo(other), equals(other), greaterThan(other), lessThan(other), greaterThanOrEqual(other), lessThanOrEqual(other), isZero(), isPositive(), isNegative(); conversion toMajor(), toDecimalString(), toJSON(), toString().
  • ADDED: applyRounding(value: number, mode: RoundingMode): bigint — explicit helper exporting all five rounding modes (half-up, half-down, half-even, floor, ceil).
  • ADDED: Currency registry — getCurrency(code) (returns CurrencyInfo | undefined), getCurrencyOrThrow(code) (raises MoneyError({ code: MONEY.UNKNOWN_CURRENCY })), registerCurrency(info), getAllCurrencyCodes(), getVirtualCurrencies(), isVirtualCurrency(code), minorUnitMultiplier(exponent). 35 built-in entries: 30 ISO 4217 fiats (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) + 5 cryptocurrencies (BTC, ETH, SOL, USDT, USDC).
  • ADDED: Formatters — formatMoney(money, options?) with locale-aware decimal/thousands separators, showSymbol/showCode/trailingZeros/decimalSeparator/thousandsSeparator overrides; formatMoneyCompact(money) rendering K/M/B suffixes.
  • ADDED: Allocators — allocateEvenly(money, parts), allocateByRatios(money, ratios), allocateByPercentages(money, percentages). Every allocator preserves the input total (remainder distributed round-robin) and returns AllocationResult { shares, remainder: 0n }.
  • ADDED: Aggregators — minMoney(...values), maxMoney(...values), sumMoney(values).
  • ADDED: MONEY_CODES registry via defineCodes("MONEY", [...]) — 6 codes: CURRENCY_MISMATCH, UNKNOWN_CURRENCY, DIVISION_BY_ZERO, INVALID_ALLOCATION, EMPTY_INPUT, PARSE_ERROR.
  • ADDED: MoneyError extends SuaveplanError<MoneyErrorCode> carrying optional currency? / otherCurrency? discriminants.
  • ADDED: Types — CurrencyCode, RoundingMode, MoneyOptions, CurrencyInfo, FormatOptions, AllocationResult, MoneyLike (re-exported from root and from ./types/common).
  • ADDED: Subpath exports per public module — ./money/money, ./currencies/currencies, ./formatter/formatter, ./allocator/allocator, ./error-codes/error-codes, ./types/common. Tree-shakeable.

Out of scope

  • Locale-aware parseMoney(input, currency, locale) and the inverse round-trip identity Money.fromMajor(m.toDecimalString())-via-locale. Listed in the enterprise audit as "feature-rich enhancement". The decimal-string round-trip is in scope (it is part of the upstream public surface); the locale-aware parser is a new feature whose acceptance criteria (Indian grouping, comma decimals, currency-symbol stripping) require a fully-considered locale-token API. Reason: scope is "match upstream + typed errors"; new parser belongs in a follow-up add-money-parse change.
  • MoneyRange (interval arithmetic with intersect/union/contains). Listed in enterprise audit as a feature-rich enhancement. Reason: the upstream library does not expose it; it is additive and orthogonal to the migration baseline. Will land as a follow-up.
  • Scoped CurrencyRegistry class with constructor injection. Listed in enterprise audit as multi-tenant requirement. The current module-level map is preserved bit-for-bit (registerCurrency still mutates module state) to match the upstream public contract; a scoped registry would change semantics for every caller and belongs in a separate change. Reason: behaviour-preserving migration; the scoped registry is the design pivot that requires its own proposal.
  • Zod schemas for MoneyLike / CurrencyCode / FormatOptions. Listed in enterprise audit. Reason: applicable to API boundaries; not needed by the in-process developer-facing API. Will land in @suaveplan/api-schemas when the first network-tier consumer surfaces.
  • Tax helpers (VAT split, jurisdiction-aware rounding). Listed in enterprise audit. Reason: requires a tax-rule registry of its own; belongs in a future @suaveplan/tax package that consumes @suaveplan/money.
  • Cryptocurrency unit interop with @suaveplan/cryptocurrency (WEI / SATOSHI / LAMPORT converters). Reason: cryptocurrency is not yet migrated; the BTC/ETH/SOL/USDT/USDC entries in the built-in registry use the standard exponents (8, 18, 9, 6, 6) and arithmetic works correctly today. Higher-level converters land when @suaveplan/cryptocurrency is migrated.
  • Historical-currency validFrom/validTo. Reason: not in the upstream CurrencyInfo shape; additive new feature.
  • Property-based tests via fast-check. Reason: while listed in the enterprise audit, the upstream test suite covers every arithmetic law, allocation invariant, and round-trip equality with example-based tests at ~99% coverage; reaching 100% on the migrated surface is the gate, and fast-check will be folded in once the deterministic suite is green. The new package depends on fast-check for future use but does not require it for the migration's acceptance criteria.
  • @suaveplan/error consumer migration documentation. Consumers do not yet exist on genesis (every potential downstream — currency, payment, etc. — is pending). When the first dependent package migrates it will catch the typed-error change at compile time.

Source mapping

This change derives from ../suaveplan/enterprise/money.md and the upstream source at ../suaveplan/packages/finance/money/src/.

Requirement Source lines Notes
Universal-tier purity enterprise/money.md L52-L60 (cross-cutting) Genesis-wide rule; reinforced here
Money immutable bigint value type with arithmetic / comparison / conversion enterprise/money.md L12, L20-L25, L64-L65; upstream src/core/money.ts L1-L218 Verbatim — every method preserved
applyRounding with five rounding modes enterprise/money.md L17, L194-L218; upstream src/core/money.ts L194-L218 Verbatim
Currency registry (getCurrency/getCurrencyOrThrow/registerCurrency/getAllCurrencyCodes/getVirtualCurrencies/isVirtualCurrency/minorUnitMultiplier) enterprise/money.md L12, L17, L22; upstream src/core/currencies.ts L1-L106 Verbatim, plus typed-error replacement
Built-in 30 fiat + 5 crypto entries upstream src/core/currencies.ts L4-L56 Verbatim
formatMoney + formatMoneyCompact with Intl-derived separators enterprise/money.md L12, L17, L23; upstream src/formatting/formatter.ts L1-L128 Verbatim
Allocators (allocateEvenly, allocateByRatios, allocateByPercentages) preserving total enterprise/money.md L12, L17, L24; upstream src/allocation/allocator.ts L1-L99 Verbatim
Aggregators (sumMoney, minMoney, maxMoney) enterprise/money.md L12, L17; upstream src/allocation/allocator.ts L101-L151 Verbatim
MoneyLike / CurrencyCode / CurrencyInfo / FormatOptions / AllocationResult / RoundingMode / MoneyOptions types enterprise/money.md L17, L25; upstream src/types/money.types.ts L1-L65 Verbatim
Lossless JSON round-trip via toJSON / fromJSON (string-encoded bigint) enterprise/money.md L72; upstream src/core/money.ts L180-L187 Verbatim
Subpath exports per public module enterprise/money.md L17 (tree-shakeable) Reorganised: drop core//formatting//allocation/ ancestors
Typed error hierarchy MoneyError extending SuaveplanError with stable MONEY.* codes enterprise/money.md L43-L44 Migration upgrade: replaces throw new Error(...)

Requirements with no source line

Requirement Justification
100% test coverage (istanbul, all four axes) Genesis-wide policy (CLAUDE.md §7) — upstream was at ~99%
Sibling .md per implementation file (≥200 words, basic + advanced examples) Genesis-wide policy (CLAUDE.md §4) — upstream had partial docs
Test files import runner primitives from @suaveplan/testing/runner Genesis-wide policy (AGENTS.md §7) — upstream imported vitest directly
Explicit named re-exports in every feature-module barrel (no export *) Genesis-wide policy (AGENTS.md §6a)
Package version starts at 0.1.0 Genesis-wide policy (CLAUDE.md "Editing") — upstream was 1.0.6

Parity Checklist

Capability Upstream Migrated Notes
Money class — full constructor + arithmetic + comparison + conversion surface ✅ ✅ Every method preserved bit-for-bit
applyRounding standalone helper ✅ ✅ Same five modes
getCurrency / getCurrencyOrThrow ✅ ⬆️ Enhanced getCurrencyOrThrow now raises MoneyError({ code: MONEY.UNKNOWN_CURRENCY })
registerCurrency (module-level mutation) ✅ ✅ Behaviour preserved — same semantics, same global map
getAllCurrencyCodes / getVirtualCurrencies / isVirtualCurrency / minorUnitMultiplier ✅ ✅ Verbatim
30 ISO 4217 fiats + 5 crypto built-ins ✅ ✅ Verbatim entries
formatMoney + formatMoneyCompact ✅ ✅ Verbatim
Allocators preserving input total ✅ ⬆️ Enhanced throw new Error(...) → MoneyError({ code: MONEY.INVALID_ALLOCATION })
Aggregators (sumMoney/minMoney/maxMoney) ✅ ⬆️ Enhanced Currency mismatch raises MoneyError({ code: MONEY.CURRENCY_MISMATCH })
Money.toJSON / Money.fromJSON BigInt-safe round-trip ✅ ✅ Verbatim
All public types ✅ ✅ Re-exported from root + subpath
Subpath exports per public module ✅ ⬆️ Enhanced 6 subpaths (vs upstream's 3) — every feature is tree-shakeable
Plain throw new Error(...) from boundaries ✅ ⬆️ Replaced All raised errors extend SuaveplanError with stable MONEY.* codes
MoneyError discriminants (currency/otherCurrency) 🚫 upstream ⬆️ Added Genesis convention for typed errors
Locale-aware parseMoney 🚫 upstream (audit asks) 🚫 deferred Follow-up change; scope is migration baseline
MoneyRange 🚫 upstream (audit asks) 🚫 deferred Additive new feature; follow-up change
Scoped CurrencyRegistry instance 🚫 upstream (audit asks) 🚫 deferred Changes semantics for every caller; follow-up change
Property-based tests (fast-check) 🚫 upstream (audit asks) 🚫 deferred Deterministic suite hits 100% first; fast-check added in follow-up
Tax helpers 🚫 upstream (audit asks) 🚫 deferred Belongs to future @suaveplan/tax
Zod schemas 🚫 upstream (audit asks) 🚫 deferred Will land in @suaveplan/api-schemas when needed
Cryptocurrency unit interop 🚫 upstream (audit asks) 🚫 deferred @suaveplan/cryptocurrency not yet migrated
Historical-currency validFrom/validTo 🚫 upstream (audit asks) 🚫 deferred Additive new feature

Reference docs consulted

  • ✅ ../suaveplan/packages/finance/money/src/ — all 5 implementation files + 5 test files read
  • ✅ ../suaveplan/packages/finance/money/README.md — public API documentation
  • ✅ ../suaveplan/packages/finance/money/package.json — dep graph + subpath exports
  • ✅ ../suaveplan/enterprise/money.md — enterprise audit, in full
  • 🚫 ../suaveplan/openspec/specs/money/spec.md — does not exist (upstream had no formal spec for this package; the parity contract is the source itself)
  • 🚫 ../suaveplan/openspec/changes/*/proposal.md touching money — none found beyond enterprise-money itself

Verification approach

Every requirement in the spec delta has a **Verified by:** line naming the test files that pin its scenarios. The test layout mirrors the source layout (one <feature>.test.ts per <feature>.ts):

  • src/money/money.test.ts — Money class arithmetic, comparison, conversion, JSON round-trip, applyRounding modes
  • src/currencies/currencies.test.ts — registry lookup, registration, virtual-currency helpers
  • src/formatter/formatter.test.ts — formatMoney locale/options matrix, formatMoneyCompact K/M/B
  • src/allocator/allocator.test.ts — allocator total-preservation invariant, aggregator currency-mismatch raises
  • src/error-codes/error-codes.test.ts — every MONEY.* code reachable from a real call site; SuaveplanError contract preserved

Impact

Affected dependents

Run bunx turbo ls --filter=...@suaveplan/money to enumerate.

  • No dependents — this is a new universal-tier leaf package being added to genesis. Every potential downstream (@suaveplan/currency, @suaveplan/currency-providers, @suaveplan/payment, @suaveplan/payment-providers, @suaveplan/booking, the api-plugin-bank/api-plugin-payment/api-plugin-banking plugins) is still in the Pending list and will adopt this package when migrated.

Migration impact

None — additive only. No existing genesis package imports @suaveplan/money.