166 lines
8.4 KiB
Markdown
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).
|