# Design — @suaveplan/money ## Why this design The upstream package is small, focused, and battle-tested. The right migration strategy is **preserve the public surface verbatim** and **upgrade only where genesis policy demands it**: typed errors, runner-neutral test imports, sibling docs, 100% coverage, subpath exports per feature module. The design below explains exactly which lines from upstream change and which stay the same. ## Architecture `@suaveplan/money` is a universal-tier leaf: - **Storage primitive**: a single immutable `Money` class holding `amount: bigint` (minor units) + `currency: CurrencyCode` (ISO 4217 or registered custom code). Every operation returns a new instance — never mutates. - **Currency metadata**: a module-level `Map` of `CurrencyInfo` records (built-in 30 fiats + 5 crypto + caller-registered customs). `getCurrency(code)` is `O(1)`; `registerCurrency(info)` mutates the module map (this matches upstream behaviour and is deliberately preserved for source-level compatibility). - **Formatting**: pure functions over `MoneyLike`. `formatMoney` uses `Intl.NumberFormat` to derive locale-aware decimal + thousands separators, then assembles the output by hand to preserve `bigint` precision (the formatter never loses precision via a `Number` cast). `formatMoneyCompact` is the one place where lossy `Number` conversion is acceptable (the K/M/B output is approximate by design). - **Allocation**: integer-arithmetic allocators that distribute remainders round-robin so the sum of `shares` always equals the input `amount`. The result type carries `remainder: 0n` as a contract-level invariant (the remainder field exists for future allocators that may surface it; today it is always zero). - **Aggregation**: `sumMoney`/`minMoney`/`maxMoney` that raise `MoneyError({ code: MONEY.CURRENCY_MISMATCH })` on heterogeneous currency arrays. ## File layout (genesis) Drop the upstream `core/` and `formatting/` and `allocation/` ancestor folders so every implementation lives at `/.ts` per the no-prefix and feature-folder rules: ```text packages/finance/money/ ├── package.json # 0.0.0, sideEffects:false, 6 subpath exports ├── README.md ├── tsconfig.json ├── tsconfig.lib.json ├── vite.config.ts ├── vitest.config.ts ├── tests/setup/polyfills.ts # empty — universal-tier └── src/ ├── index.ts # root barrel — explicit named re-exports ├── constants/ │ └── index.ts # currently empty (reserved) ├── error-codes/ │ ├── error-codes.ts # MONEY_CODES registry + MoneyError class │ ├── error-codes.md │ ├── error-codes.test.ts │ └── index.ts ├── types/ │ ├── common.ts # MoneyLike, CurrencyCode, etc. │ ├── common.md │ └── index.ts ├── money/ │ ├── money.ts # Money class + applyRounding │ ├── money.md │ ├── money.test.ts │ └── index.ts ├── currencies/ │ ├── currencies.ts # registry + lookup helpers + built-ins │ ├── currencies.md │ ├── currencies.test.ts │ └── index.ts ├── formatter/ │ ├── formatter.ts # formatMoney + formatMoneyCompact │ ├── formatter.md │ ├── formatter.test.ts │ └── index.ts └── allocator/ ├── allocator.ts # allocators + aggregators ├── allocator.md ├── allocator.test.ts └── index.ts ``` The `currencies.ts` file is the only one approaching the 300-line hard limit. To stay under budget while preserving the full built-in registry, the 35-entry table is inlined as a single object literal (one line per entry, ~36 lines), with the lookup/registration helpers occupying the remaining ~70 lines. The file is well under 300 lines. ## Error taxonomy `MONEY_CODES = defineCodes("MONEY", [...])` lists exactly six stable codes: | Code | Raised by | |---|---| | `CURRENCY_MISMATCH` | `Money.add`/`subtract`/`compareTo` cross-currency; `sumMoney`/`minMoney`/`maxMoney` heterogeneous input | | `UNKNOWN_CURRENCY` | `getCurrencyOrThrow(code)` when neither built-in nor custom registers `code` | | `DIVISION_BY_ZERO` | `Money.divide(0)` or `Money.divide(0n)` | | `INVALID_ALLOCATION` | `allocateEvenly(parts ≤ 0)`, `allocateByRatios(empty array)`, `allocateByRatios(negative ratio)`, `allocateByRatios(totalRatio ≤ 0)`, `allocateByPercentages(sum != 100 ± 0.001)` | | `EMPTY_INPUT` | `sumMoney([])`, `minMoney()`, `maxMoney()` | | `PARSE_ERROR` | Reserved for `parseDecimalString` failures (currently every input parses; the code is wired into `Money.fromMajor(string)` and reachable via malformed input) | `MoneyError extends SuaveplanError` carries two optional discriminants for diagnostics: `currency?: CurrencyCode` (the currency that triggered the error) and `otherCurrency?: CurrencyCode` (for mismatch errors, the second currency in the pair). These are exposed as instance properties so consumers can `if (err instanceof MoneyError && err.code === "MONEY.CURRENCY_MISMATCH")` and read `err.currency` / `err.otherCurrency` without parsing strings. ## Subpath exports Every public module gets its own subpath export so consumers can tree-shake. The package preserves a root `"."` export (everything) plus six feature-specific subpaths: - `./money/money` — `Money`, `applyRounding` - `./currencies/currencies` — every registry function - `./formatter/formatter` — `formatMoney`, `formatMoneyCompact` - `./allocator/allocator` — allocators + aggregators - `./error-codes/error-codes` — `MONEY_CODES`, `MoneyError`, type exports - `./types/common` — all type exports This is a strict superset of upstream's three subpaths (`./core/money`, `./formatting/formatter`, `./allocation/allocator`). Upstream consumers importing those paths will need to update — but upstream had no genesis consumers when this change opened. ## Test runner imports Every test file imports from `@suaveplan/testing/runner` (rule 11). Upstream imported from `vitest` directly; the migration commit rewrites every line. ## Tier purity The package is universal-tier: - No `node:*`, `bun:*`, or bare `fs`/`path`/`stream`/`net`/`http`/`crypto` imports - Only platform-aware call is `Intl.NumberFormat(locale).formatToParts(...)` in the formatter, which is available in every supported runtime (Node ≥ 18, Bun, browsers, Web Workers). - `package.json` declares `"sideEffects": false`, no `"browser"` field, no `engines` pins. ## What changes vs upstream 1. `throw new Error(...)` replaced with `throw new MoneyError(MONEY_CODES., ...)` at every raise site (8 raise sites total — counted in upstream). 2. Folder layout flattened: `core/money.ts` → `money/money.ts`, `core/currencies.ts` → `currencies/currencies.ts`, `formatting/formatter.ts` → `formatter/formatter.ts`, `allocation/allocator.ts` → `allocator/allocator.ts`, `types/money.types.ts` → `types/common.ts`. 3. Tests rewritten to import from `@suaveplan/testing/runner`, gain `{ timeout: 5000 }` on every `it(...)`, and use `expect(() => ...).toThrow(MoneyError)` + code-asserting helpers. 4. Sibling `.md` written for every implementation file (≥200 words, basic + advanced examples, full API reference, implementation notes). 5. Six subpath exports vs upstream's three. 6. Package version pinned to `0.1.0`. 7. `Money.fromMajor(string)` now parses through a hardened `parseDecimalString` that raises `MoneyError({ code: MONEY.PARSE_ERROR })` on malformed input (non-numeric whole, non-numeric fraction, multiple decimal points). Upstream silently produced `NaN`-ish bigints on bad input; the new path errors clearly. ## What stays the same - Every public symbol name, parameter order, return shape, option key, and default value. - The `Money` class is still `implements MoneyLike` so structural-typing consumers continue to accept it. - The currency-registry module-level `Map` mutation semantics — `registerCurrency(info)` is still global, still observable by every other call site. - `formatMoneyCompact` still lossy via `Number` (documented). - `AllocationResult.remainder` is still always `0n` (allocators distribute remainders).