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:
Moneyclass — immutablebigint-backed value type withamount: bigint+currency: CurrencyCode; static constructorsfromMajor(value: number | string, currency),fromMinor(amount: bigint | number, currency),zero(currency),fromJSON(json); arithmeticadd(other),subtract(other),multiply(factor, rounding?),divide(divisor, rounding?),abs(),negate(); comparisoncompareTo(other),equals(other),greaterThan(other),lessThan(other),greaterThanOrEqual(other),lessThanOrEqual(other),isZero(),isPositive(),isNegative(); conversiontoMajor(),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)(returnsCurrencyInfo | undefined),getCurrencyOrThrow(code)(raisesMoneyError({ 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/thousandsSeparatoroverrides;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 returnsAllocationResult { shares, remainder: 0n }. - ADDED: Aggregators —
minMoney(...values),maxMoney(...values),sumMoney(values). - ADDED:
MONEY_CODESregistry viadefineCodes("MONEY", [...])— 6 codes:CURRENCY_MISMATCH,UNKNOWN_CURRENCY,DIVISION_BY_ZERO,INVALID_ALLOCATION,EMPTY_INPUT,PARSE_ERROR. - ADDED:
MoneyError extends SuaveplanError<MoneyErrorCode>carrying optionalcurrency?/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 identityMoney.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-upadd-money-parsechange. MoneyRange(interval arithmetic withintersect/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
CurrencyRegistryclass with constructor injection. Listed in enterprise audit as multi-tenant requirement. The current module-level map is preserved bit-for-bit (registerCurrencystill 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-schemaswhen 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/taxpackage that consumes@suaveplan/money. - Cryptocurrency unit interop with
@suaveplan/cryptocurrency(WEI / SATOSHI / LAMPORT converters). Reason:cryptocurrencyis 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/cryptocurrencyis migrated. - Historical-currency
validFrom/validTo. Reason: not in the upstreamCurrencyInfoshape; 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-checkfor future use but does not require it for the migration's acceptance criteria. @suaveplan/errorconsumer 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.mdtouching money — none found beyondenterprise-moneyitself
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—Moneyclass arithmetic, comparison, conversion, JSON round-trip,applyRoundingmodessrc/currencies/currencies.test.ts— registry lookup, registration, virtual-currency helperssrc/formatter/formatter.test.ts—formatMoneylocale/options matrix,formatMoneyCompactK/M/Bsrc/allocator/allocator.test.ts— allocator total-preservation invariant, aggregator currency-mismatch raisessrc/error-codes/error-codes.test.ts— everyMONEY.*code reachable from a real call site;SuaveplanErrorcontract 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, theapi-plugin-bank/api-plugin-payment/api-plugin-bankingplugins) is still in thePendinglist and will adopt this package when migrated.
Migration impact
None — additive only. No existing genesis package imports @suaveplan/money.