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

166 lines
8.4 KiB
Markdown

# 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 `<feature>/<feature>.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<MoneyErrorCode>` 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.<CODE>, ...)` 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).