Initial Commit
This commit is contained in:
165
openspec/archive/2026-05-17-add-money/design.md
Normal file
165
openspec/archive/2026-05-17-add-money/design.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user