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).
|
||||
126
openspec/archive/2026-05-17-add-money/proposal.md
Normal file
126
openspec/archive/2026-05-17-add-money/proposal.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# 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: `Money` class — immutable `bigint`-backed value type with `amount: bigint` + `currency: CurrencyCode`; static constructors `fromMajor(value: number | string, currency)`, `fromMinor(amount: bigint | number, currency)`, `zero(currency)`, `fromJSON(json)`; arithmetic `add(other)`, `subtract(other)`, `multiply(factor, rounding?)`, `divide(divisor, rounding?)`, `abs()`, `negate()`; comparison `compareTo(other)`, `equals(other)`, `greaterThan(other)`, `lessThan(other)`, `greaterThanOrEqual(other)`, `lessThanOrEqual(other)`, `isZero()`, `isPositive()`, `isNegative()`; conversion `toMajor()`, `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)` (returns `CurrencyInfo | undefined`), `getCurrencyOrThrow(code)` (raises `MoneyError({ 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`/`thousandsSeparator` overrides; `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 returns `AllocationResult { shares, remainder: 0n }`.
|
||||
- ADDED: Aggregators — `minMoney(...values)`, `maxMoney(...values)`, `sumMoney(values)`.
|
||||
- ADDED: `MONEY_CODES` registry via `defineCodes("MONEY", [...])` — 6 codes: `CURRENCY_MISMATCH`, `UNKNOWN_CURRENCY`, `DIVISION_BY_ZERO`, `INVALID_ALLOCATION`, `EMPTY_INPUT`, `PARSE_ERROR`.
|
||||
- ADDED: `MoneyError extends SuaveplanError<MoneyErrorCode>` carrying optional `currency?` / `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 identity `Money.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-up `add-money-parse` change.
|
||||
- **`MoneyRange` (interval arithmetic with `intersect`/`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 `CurrencyRegistry` class with constructor injection.** Listed in enterprise audit as multi-tenant requirement. The current module-level map is preserved bit-for-bit (`registerCurrency` still 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-schemas` when 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/tax` package that consumes `@suaveplan/money`.
|
||||
- **Cryptocurrency unit interop with `@suaveplan/cryptocurrency` (WEI / SATOSHI / LAMPORT converters).** Reason: `cryptocurrency` is 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/cryptocurrency` is migrated.
|
||||
- **Historical-currency `validFrom`/`validTo`.** Reason: not in the upstream `CurrencyInfo` shape; 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-check` for future use but does not require it for the migration's acceptance criteria.
|
||||
- **`@suaveplan/error` consumer 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.md` touching money — none found beyond `enterprise-money` itself
|
||||
|
||||
## 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` — `Money` class arithmetic, comparison, conversion, JSON round-trip, `applyRounding` modes
|
||||
- `src/currencies/currencies.test.ts` — registry lookup, registration, virtual-currency helpers
|
||||
- `src/formatter/formatter.test.ts` — `formatMoney` locale/options matrix, `formatMoneyCompact` K/M/B
|
||||
- `src/allocator/allocator.test.ts` — allocator total-preservation invariant, aggregator currency-mismatch raises
|
||||
- `src/error-codes/error-codes.test.ts` — every `MONEY.*` code reachable from a real call site; `SuaveplanError` contract 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`, the `api-plugin-bank`/`api-plugin-payment`/`api-plugin-banking` plugins) is still in the `Pending` list and will adopt this package when migrated.
|
||||
|
||||
## Migration impact
|
||||
|
||||
None — additive only. No existing genesis package imports `@suaveplan/money`.
|
||||
356
openspec/archive/2026-05-17-add-money/specs/money/spec.md
Normal file
356
openspec/archive/2026-05-17-add-money/specs/money/spec.md
Normal file
@@ -0,0 +1,356 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: money (add-money)
|
||||
|
||||
**Package**: `@suaveplan/money`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Universal-tier purity
|
||||
|
||||
The package SHALL ship as a universal-tier package — no imports from `node:*`, `bun:*`, or the bare specifiers `fs`/`path`/`stream`/`net`/`http`/`crypto` in any file under `src/`. The package SHALL run unchanged under Node ≥ 18, Bun, modern browsers, and Web Workers. The `package.json` SHALL NOT declare a `"browser"` field, SHALL NOT pin `engines.node` or `engines.bun`, and SHALL declare `"sideEffects": false`. The single permitted platform-aware call is `Intl.NumberFormat` (available in every supported runtime).
|
||||
|
||||
#### Scenario: no platform imports in src/
|
||||
|
||||
- **WHEN** `grep -rnE "from ['\"](node:|bun:|fs|path|stream|net|http|crypto)['\"]" packages/finance/money/src/` is run
|
||||
- **THEN** the command produces zero matches
|
||||
|
||||
**Verified by:** `src/money/money.test.ts::"Money module has no node/bun imports (tier purity)"` AND `src/formatter/formatter.test.ts::"formatter module has no node/bun imports (tier purity)"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Stable MONEY_CODES error registry and MoneyError class
|
||||
|
||||
The package SHALL declare an error-code registry via `defineCodes("MONEY", [...])` containing exactly the codes `CURRENCY_MISMATCH`, `UNKNOWN_CURRENCY`, `DIVISION_BY_ZERO`, `INVALID_ALLOCATION`, `EMPTY_INPUT`, `PARSE_ERROR`. Every public error path SHALL throw a `MoneyError extends SuaveplanError<MoneyErrorCode>` carrying one of those codes. `MoneyError` SHALL expose optional `currency` and `otherCurrency` discriminants for diagnostics. The package SHALL NOT throw raw `Error` instances from any public method.
|
||||
|
||||
#### Scenario: every code is reachable from a real call site
|
||||
|
||||
- **WHEN** the test suite exercises cross-currency arithmetic, unknown-currency lookup, division by zero, invalid allocation parameters, empty aggregator input, and malformed decimal-string parsing
|
||||
- **THEN** each path raises a `MoneyError` whose `code` matches the expected `MONEY.*` constant
|
||||
|
||||
#### Scenario: SuaveplanError contract preserved
|
||||
|
||||
- **WHEN** a `MoneyError` is thrown with a `cause`
|
||||
- **THEN** the resulting error is `instanceof SuaveplanError`, exposes the original on `error.cause`, and the wrapped code remains stable
|
||||
|
||||
**Verified by:** `src/error-codes/error-codes.test.ts::"every MONEY.* code is reachable from a call site"` AND `src/error-codes/error-codes.test.ts::"MoneyError preserves cause and SuaveplanError contract"` AND `src/error-codes/error-codes.test.ts::"MoneyError carries currency and otherCurrency discriminants"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Immutable Money value type with bigint storage
|
||||
|
||||
The package SHALL provide an immutable `Money` class with `readonly amount: bigint` and `readonly currency: CurrencyCode` properties. Every arithmetic and conversion method SHALL return a new `Money` instance — methods MUST NOT mutate `this`. The class SHALL `implements MoneyLike` so structural-typing consumers (`{ amount, currency }`) interoperate freely. Static constructors `fromMajor(value: number | string, currency)`, `fromMinor(amount: bigint | number, currency)`, `zero(currency)`, and `fromJSON({ amount, currency })` SHALL be provided.
|
||||
|
||||
#### Scenario: fromMajor handles numeric and decimal-string input
|
||||
|
||||
- **WHEN** `Money.fromMajor(10.50, "USD")` and `Money.fromMajor("10.50", "USD")` are called
|
||||
- **THEN** both return a `Money` with `amount === 1050n` and `currency === "USD"`
|
||||
|
||||
#### Scenario: zero produces a zero-amount Money
|
||||
|
||||
- **WHEN** `Money.zero("EUR")` is called
|
||||
- **THEN** the result has `amount === 0n` and `currency === "EUR"`
|
||||
|
||||
#### Scenario: fromJSON inverts toJSON
|
||||
|
||||
- **WHEN** `Money.fromJSON(m.toJSON())` is called for any `Money` `m`
|
||||
- **THEN** the result equals `m` (same `amount` and `currency`)
|
||||
|
||||
#### Scenario: arithmetic does not mutate this
|
||||
|
||||
- **WHEN** `const m = Money.fromMinor(100n, "USD"); m.add(Money.fromMinor(50n, "USD"))` is called
|
||||
- **THEN** `m.amount === 100n` is still true after the call
|
||||
|
||||
**Verified by:** `src/money/money.test.ts::"fromMajor handles number and decimal string"` AND `src/money/money.test.ts::"zero returns a zero Money"` AND `src/money/money.test.ts::"toJSON / fromJSON round-trips"` AND `src/money/money.test.ts::"arithmetic does not mutate the receiver"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Money arithmetic, comparison, and conversion surface
|
||||
|
||||
The `Money` class SHALL expose `add(other)`, `subtract(other)`, `multiply(factor, rounding?)`, `divide(divisor, rounding?)`, `abs()`, `negate()` arithmetic methods; `compareTo(other)`, `equals(other)`, `greaterThan(other)`, `lessThan(other)`, `greaterThanOrEqual(other)`, `lessThanOrEqual(other)`, `isZero()`, `isPositive()`, `isNegative()` comparison methods; and `toMajor()`, `toDecimalString()`, `toJSON()`, `toString()` conversion methods. Cross-currency operations SHALL raise `MoneyError({ code: MONEY.CURRENCY_MISMATCH })` with both currencies on the discriminants. `divide(0)` and `divide(0n)` SHALL raise `MoneyError({ code: MONEY.DIVISION_BY_ZERO })`.
|
||||
|
||||
#### Scenario: cross-currency add raises CURRENCY_MISMATCH
|
||||
|
||||
- **WHEN** `Money.fromMinor(100n, "USD").add(Money.fromMinor(100n, "EUR"))` is called
|
||||
- **THEN** a `MoneyError` is thrown with `code === "MONEY.CURRENCY_MISMATCH"`, `currency === "USD"`, `otherCurrency === "EUR"`
|
||||
|
||||
#### Scenario: divide by zero raises DIVISION_BY_ZERO
|
||||
|
||||
- **WHEN** `Money.fromMinor(100n, "USD").divide(0)` is called
|
||||
- **THEN** a `MoneyError` is thrown with `code === "MONEY.DIVISION_BY_ZERO"`
|
||||
|
||||
#### Scenario: toDecimalString preserves precision
|
||||
|
||||
- **WHEN** `Money.fromMinor(1050n, "USD").toDecimalString()` is called
|
||||
- **THEN** the result is `"10.50"`
|
||||
|
||||
#### Scenario: compareTo returns -1/0/1
|
||||
|
||||
- **WHEN** comparing `Money.fromMinor(100n, "USD")` against `200n`/`100n`/`50n`
|
||||
- **THEN** the results are `-1`, `0`, `1`
|
||||
|
||||
**Verified by:** `src/money/money.test.ts::"add raises CURRENCY_MISMATCH on cross-currency"` AND `src/money/money.test.ts::"divide raises DIVISION_BY_ZERO"` AND `src/money/money.test.ts::"toDecimalString preserves precision"` AND `src/money/money.test.ts::"compareTo returns -1, 0, 1"` AND `src/money/money.test.ts::"every comparison helper aligns with compareTo"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: applyRounding supports five modes
|
||||
|
||||
The package SHALL export an `applyRounding(value: number, mode: RoundingMode): bigint` function supporting modes `"half-up"`, `"half-down"`, `"half-even"`, `"floor"`, `"ceil"`. `multiply` and `divide` on `Money` SHALL accept an optional `rounding` parameter defaulting to `"half-up"` and delegate to `applyRounding`.
|
||||
|
||||
#### Scenario: each mode rounds 2.5 correctly
|
||||
|
||||
- **WHEN** `applyRounding(2.5, mode)` is called for each of the five modes
|
||||
- **THEN** the results are `3n` (half-up), `2n` (half-down), `2n` (half-even / banker's), `2n` (floor), `3n` (ceil)
|
||||
|
||||
#### Scenario: each mode rounds 3.5 correctly under banker's
|
||||
|
||||
- **WHEN** `applyRounding(3.5, "half-even")` is called
|
||||
- **THEN** the result is `4n` (round to even)
|
||||
|
||||
**Verified by:** `src/money/money.test.ts::"applyRounding handles every mode at 2.5 boundary"` AND `src/money/money.test.ts::"applyRounding half-even rounds to even at 3.5"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Built-in currency registry with lookup, registration, and virtual-currency helpers
|
||||
|
||||
The package SHALL ship a built-in registry containing exactly 30 ISO 4217 fiat currencies (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) plus 5 cryptocurrencies (BTC, ETH, SOL, USDT, USDC). The package SHALL export `getCurrency(code): CurrencyInfo | undefined`, `getCurrencyOrThrow(code): CurrencyInfo`, `registerCurrency(info): void`, `getAllCurrencyCodes(): CurrencyCode[]`, `getVirtualCurrencies(): CurrencyInfo[]`, `isVirtualCurrency(code): boolean`, `minorUnitMultiplier(exponent): bigint`. `getCurrencyOrThrow` SHALL raise `MoneyError({ code: MONEY.UNKNOWN_CURRENCY, currency })` when the code is neither built-in nor custom-registered. `registerCurrency` SHALL accept an optional `virtual: boolean` flag and `issuerId: string` for virtual currencies.
|
||||
|
||||
#### Scenario: built-in lookup succeeds
|
||||
|
||||
- **WHEN** `getCurrency("USD")` is called
|
||||
- **THEN** the result is `{ code: "USD", exponent: 2, symbol: "$", name: "US Dollar" }`
|
||||
|
||||
#### Scenario: unknown currency raises UNKNOWN_CURRENCY
|
||||
|
||||
- **WHEN** `getCurrencyOrThrow("XYZ")` is called against a currency neither built-in nor registered
|
||||
- **THEN** a `MoneyError` is thrown with `code === "MONEY.UNKNOWN_CURRENCY"` and `currency === "XYZ"`
|
||||
|
||||
#### Scenario: custom registration overrides built-ins
|
||||
|
||||
- **WHEN** `registerCurrency({ code: "USD", exponent: 3, symbol: "$$$", name: "Custom" })` is called
|
||||
- **THEN** subsequent `getCurrency("USD")` returns the custom record (exponent 3)
|
||||
|
||||
#### Scenario: virtual currency helpers
|
||||
|
||||
- **WHEN** `registerCurrency({ code: "GEM", exponent: 0, symbol: "💎", name: "Game Gem", virtual: true })` is called
|
||||
- **THEN** `isVirtualCurrency("GEM")` is `true` and `getVirtualCurrencies()` includes the registered entry
|
||||
|
||||
#### Scenario: minorUnitMultiplier computes 10^exponent as bigint
|
||||
|
||||
- **WHEN** `minorUnitMultiplier(8)` is called
|
||||
- **THEN** the result is `100_000_000n`
|
||||
|
||||
**Verified by:** `src/currencies/currencies.test.ts::"built-in lookup returns USD"` AND `src/currencies/currencies.test.ts::"getCurrencyOrThrow raises UNKNOWN_CURRENCY for unknown code"` AND `src/currencies/currencies.test.ts::"registerCurrency overrides built-in"` AND `src/currencies/currencies.test.ts::"virtual currency helpers report registered virtual entries"` AND `src/currencies/currencies.test.ts::"minorUnitMultiplier computes 10^exponent as bigint"` AND `src/currencies/currencies.test.ts::"ships exactly 30 ISO 4217 fiat + 5 crypto built-ins"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Locale-aware formatMoney with options and overrides
|
||||
|
||||
The `formatMoney(money, options?)` function SHALL render a `MoneyLike` to a human-readable string using `Intl.NumberFormat(locale)`-derived decimal and thousands separators by default. Options SHALL include `locale` (default `"en-US"`), `showSymbol` (default `true`), `showCode` (default `false` — when `true`, suffix the ISO code and suppress the symbol), `decimalSeparator` (override), `thousandsSeparator` (override), and `trailingZeros` (default `true` — when `false`, trim trailing zeros from the fractional part). Currency precision SHALL be preserved by avoiding `Number` casts on the integer portion.
|
||||
|
||||
#### Scenario: default formatting uses symbol
|
||||
|
||||
- **WHEN** `formatMoney({ amount: 1050n, currency: "USD" })` is called
|
||||
- **THEN** the result is `"$10.50"`
|
||||
|
||||
#### Scenario: showCode mode suppresses symbol
|
||||
|
||||
- **WHEN** `formatMoney({ amount: 1050n, currency: "USD" }, { showCode: true })` is called
|
||||
- **THEN** the result is `"10.50 USD"`
|
||||
|
||||
#### Scenario: locale derives separator
|
||||
|
||||
- **WHEN** `formatMoney({ amount: 105050n, currency: "EUR" }, { locale: "de-DE" })` is called
|
||||
- **THEN** the result uses `,` as the decimal separator and `.` as the thousands separator (e.g. `"€1.050,50"`)
|
||||
|
||||
#### Scenario: trailingZeros false trims trailing zeros
|
||||
|
||||
- **WHEN** `formatMoney({ amount: 1000n, currency: "USD" }, { trailingZeros: false })` is called
|
||||
- **THEN** the fractional portion is empty in the output
|
||||
|
||||
#### Scenario: negative amount renders minus before the numeric portion
|
||||
|
||||
- **WHEN** `formatMoney({ amount: -1050n, currency: "USD" })` is called
|
||||
- **THEN** the result is `"$-10.50"` (symbol prefix retained, minus precedes the numeric portion)
|
||||
|
||||
#### Scenario: zero-exponent currency omits decimal
|
||||
|
||||
- **WHEN** `formatMoney({ amount: 1000n, currency: "JPY" })` is called
|
||||
- **THEN** the result is `"¥1,000"` (no decimal point)
|
||||
|
||||
**Verified by:** `src/formatter/formatter.test.ts::"formats USD with symbol by default"` AND `src/formatter/formatter.test.ts::"showCode mode suffixes ISO code"` AND `src/formatter/formatter.test.ts::"de-DE locale derives comma decimal separator"` AND `src/formatter/formatter.test.ts::"trailingZeros false trims trailing zeros"` AND `src/formatter/formatter.test.ts::"negative amount prepends minus"` AND `src/formatter/formatter.test.ts::"zero-exponent currency omits decimal point"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Compact money formatting
|
||||
|
||||
The `formatMoneyCompact(money)` function SHALL render large amounts with `K` / `M` / `B` suffixes. Values ≥ 1e9 SHALL use `B`, ≥ 1e6 SHALL use `M`, ≥ 1e3 SHALL use `K`, and smaller values SHALL render with one decimal (or no decimal for zero-exponent currencies). The currency symbol SHALL prefix the value; negative amounts SHALL prepend `-` before the symbol.
|
||||
|
||||
#### Scenario: thousands → K
|
||||
|
||||
- **WHEN** `formatMoneyCompact(Money.fromMajor(1500, "USD"))` is called
|
||||
- **THEN** the result is `"$1.5K"`
|
||||
|
||||
#### Scenario: millions → M
|
||||
|
||||
- **WHEN** `formatMoneyCompact(Money.fromMajor(2_500_000, "USD"))` is called
|
||||
- **THEN** the result is `"$2.5M"`
|
||||
|
||||
#### Scenario: billions → B
|
||||
|
||||
- **WHEN** `formatMoneyCompact(Money.fromMajor(7_000_000_000, "USD"))` is called
|
||||
- **THEN** the result is `"$7.0B"`
|
||||
|
||||
#### Scenario: small values render with currency precision
|
||||
|
||||
- **WHEN** `formatMoneyCompact(Money.fromMajor(99, "USD"))` is called
|
||||
- **THEN** the result is `"$99.00"`
|
||||
|
||||
#### Scenario: negative amount
|
||||
|
||||
- **WHEN** `formatMoneyCompact(Money.fromMajor(-1500, "USD"))` is called
|
||||
- **THEN** the result is `"-$1.5K"`
|
||||
|
||||
**Verified by:** `src/formatter/formatter.test.ts::"compact: thousands rendered with K"` AND `src/formatter/formatter.test.ts::"compact: millions rendered with M"` AND `src/formatter/formatter.test.ts::"compact: billions rendered with B"` AND `src/formatter/formatter.test.ts::"compact: small values use currency precision"` AND `src/formatter/formatter.test.ts::"compact: negative prepends minus before symbol"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Allocators preserve input total
|
||||
|
||||
The allocator functions `allocateEvenly(money, parts)`, `allocateByRatios(money, ratios)`, and `allocateByPercentages(money, percentages)` SHALL return an `AllocationResult { shares, remainder }` such that `sumMoney(shares).amount === money.amount` for every input. The `remainder` field SHALL always equal `0n`. Remainders from integer division SHALL be distributed round-robin to the leading shares, with sign preserved for negative inputs. Invalid inputs SHALL raise `MoneyError({ code: MONEY.INVALID_ALLOCATION })`: `allocateEvenly(parts ≤ 0)`, `allocateByRatios(empty)`, `allocateByRatios(negative ratio)`, `allocateByRatios(totalRatio ≤ 0)`, `allocateByPercentages(sum != 100 ± 0.001)`.
|
||||
|
||||
#### Scenario: allocateEvenly preserves total
|
||||
|
||||
- **WHEN** `allocateEvenly({ amount: 1000n, currency: "USD" }, 3)` is called
|
||||
- **THEN** the result is `{ shares: [334n, 333n, 333n], remainder: 0n }` and the sum is `1000n`
|
||||
|
||||
#### Scenario: allocateByRatios preserves total
|
||||
|
||||
- **WHEN** `allocateByRatios({ amount: 10000n, currency: "USD" }, [50, 30, 20])` is called
|
||||
- **THEN** the result shares sum to `10000n` and approximate `[5000n, 3000n, 2000n]`
|
||||
|
||||
#### Scenario: allocateByPercentages enforces sum = 100
|
||||
|
||||
- **WHEN** `allocateByPercentages({ amount: 1000n, currency: "USD" }, [50, 30])` is called (sum 80)
|
||||
- **THEN** a `MoneyError` is thrown with `code === "MONEY.INVALID_ALLOCATION"`
|
||||
|
||||
#### Scenario: negative input preserves sign distribution
|
||||
|
||||
- **WHEN** `allocateEvenly({ amount: -1000n, currency: "USD" }, 3)` is called
|
||||
- **THEN** the shares sum to `-1000n`
|
||||
|
||||
#### Scenario: zero parts raises INVALID_ALLOCATION
|
||||
|
||||
- **WHEN** `allocateEvenly({ amount: 1000n, currency: "USD" }, 0)` is called
|
||||
- **THEN** a `MoneyError` is thrown with `code === "MONEY.INVALID_ALLOCATION"`
|
||||
|
||||
#### Scenario: empty ratios raises INVALID_ALLOCATION
|
||||
|
||||
- **WHEN** `allocateByRatios({ amount: 1000n, currency: "USD" }, [])` is called
|
||||
- **THEN** a `MoneyError` is thrown with `code === "MONEY.INVALID_ALLOCATION"`
|
||||
|
||||
#### Scenario: negative ratio raises INVALID_ALLOCATION
|
||||
|
||||
- **WHEN** `allocateByRatios({ amount: 1000n, currency: "USD" }, [-1, 2])` is called
|
||||
- **THEN** a `MoneyError` is thrown with `code === "MONEY.INVALID_ALLOCATION"`
|
||||
|
||||
#### Scenario: zero total ratio raises INVALID_ALLOCATION
|
||||
|
||||
- **WHEN** `allocateByRatios({ amount: 1000n, currency: "USD" }, [0, 0])` is called
|
||||
- **THEN** a `MoneyError` is thrown with `code === "MONEY.INVALID_ALLOCATION"`
|
||||
|
||||
**Verified by:** `src/allocator/allocator.test.ts::"allocateEvenly preserves total"` AND `src/allocator/allocator.test.ts::"allocateByRatios preserves total"` AND `src/allocator/allocator.test.ts::"allocateByPercentages raises INVALID_ALLOCATION when sum != 100"` AND `src/allocator/allocator.test.ts::"allocateEvenly preserves negative sign distribution"` AND `src/allocator/allocator.test.ts::"allocateEvenly raises INVALID_ALLOCATION for zero parts"` AND `src/allocator/allocator.test.ts::"allocateByRatios raises INVALID_ALLOCATION for empty ratios"` AND `src/allocator/allocator.test.ts::"allocateByRatios raises INVALID_ALLOCATION for negative ratio"` AND `src/allocator/allocator.test.ts::"allocateByRatios raises INVALID_ALLOCATION for zero total ratio"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Aggregators handle empty input and cross-currency mismatch
|
||||
|
||||
`sumMoney(values)`, `minMoney(...values)`, and `maxMoney(...values)` SHALL raise `MoneyError({ code: MONEY.EMPTY_INPUT })` when called with no values. Heterogeneous currency input SHALL raise `MoneyError({ code: MONEY.CURRENCY_MISMATCH })`.
|
||||
|
||||
#### Scenario: sumMoney with empty array raises EMPTY_INPUT
|
||||
|
||||
- **WHEN** `sumMoney([])` is called
|
||||
- **THEN** a `MoneyError` is thrown with `code === "MONEY.EMPTY_INPUT"`
|
||||
|
||||
#### Scenario: minMoney with no args raises EMPTY_INPUT
|
||||
|
||||
- **WHEN** `minMoney()` is called
|
||||
- **THEN** a `MoneyError` is thrown with `code === "MONEY.EMPTY_INPUT"`
|
||||
|
||||
#### Scenario: maxMoney with no args raises EMPTY_INPUT
|
||||
|
||||
- **WHEN** `maxMoney()` is called
|
||||
- **THEN** a `MoneyError` is thrown with `code === "MONEY.EMPTY_INPUT"`
|
||||
|
||||
#### Scenario: heterogeneous currencies raise CURRENCY_MISMATCH
|
||||
|
||||
- **WHEN** `sumMoney([{ amount: 1n, currency: "USD" }, { amount: 1n, currency: "EUR" }])` is called
|
||||
- **THEN** a `MoneyError` is thrown with `code === "MONEY.CURRENCY_MISMATCH"`
|
||||
|
||||
#### Scenario: aggregators return correct extremum
|
||||
|
||||
- **WHEN** `minMoney(Money.fromMinor(100n, "USD"), Money.fromMinor(50n, "USD"))` and `maxMoney(...)` are called
|
||||
- **THEN** `minMoney` returns the 50n value, `maxMoney` returns the 100n value, and `sumMoney([...])` returns 150n
|
||||
|
||||
**Verified by:** `src/allocator/allocator.test.ts::"sumMoney raises EMPTY_INPUT for empty array"` AND `src/allocator/allocator.test.ts::"minMoney raises EMPTY_INPUT with no args"` AND `src/allocator/allocator.test.ts::"maxMoney raises EMPTY_INPUT with no args"` AND `src/allocator/allocator.test.ts::"sumMoney raises CURRENCY_MISMATCH on heterogeneous currencies"` AND `src/allocator/allocator.test.ts::"minMoney raises CURRENCY_MISMATCH on heterogeneous currencies"` AND `src/allocator/allocator.test.ts::"maxMoney raises CURRENCY_MISMATCH on heterogeneous currencies"` AND `src/allocator/allocator.test.ts::"aggregators return correct min, max, sum"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Decimal-string parsing rejects malformed input
|
||||
|
||||
`Money.fromMajor(value: string, currency)` SHALL parse signed decimal strings with optional whitespace, a single optional `.` separator, and digit-only whole and fractional parts. The parser SHALL pad or truncate the fractional part to the currency exponent. Malformed input — non-digit characters in the whole or fractional part, multiple `.` separators, or an empty input — SHALL raise `MoneyError({ code: MONEY.PARSE_ERROR, currency })`. Valid edge cases include leading sign, missing fractional part (`"10"`), missing whole part (`".50"`), and zero (`"0"` / `"0.00"`).
|
||||
|
||||
#### Scenario: valid decimal string parses
|
||||
|
||||
- **WHEN** `Money.fromMajor("10.50", "USD")` is called
|
||||
- **THEN** the result has `amount === 1050n`
|
||||
|
||||
#### Scenario: negative decimal string parses
|
||||
|
||||
- **WHEN** `Money.fromMajor("-10.50", "USD")` is called
|
||||
- **THEN** the result has `amount === -1050n`
|
||||
|
||||
#### Scenario: empty string raises PARSE_ERROR
|
||||
|
||||
- **WHEN** `Money.fromMajor("", "USD")` is called
|
||||
- **THEN** a `MoneyError` is thrown with `code === "MONEY.PARSE_ERROR"`
|
||||
|
||||
#### Scenario: non-numeric input raises PARSE_ERROR
|
||||
|
||||
- **WHEN** `Money.fromMajor("abc", "USD")` is called
|
||||
- **THEN** a `MoneyError` is thrown with `code === "MONEY.PARSE_ERROR"`
|
||||
|
||||
#### Scenario: multiple decimal points raise PARSE_ERROR
|
||||
|
||||
- **WHEN** `Money.fromMajor("1.2.3", "USD")` is called
|
||||
- **THEN** a `MoneyError` is thrown with `code === "MONEY.PARSE_ERROR"`
|
||||
|
||||
#### Scenario: fractional part longer than exponent truncates
|
||||
|
||||
- **WHEN** `Money.fromMajor("10.555", "USD")` is called
|
||||
- **THEN** the result has `amount === 1055n` (truncated to two minor digits)
|
||||
|
||||
**Verified by:** `src/money/money.test.ts::"fromMajor parses valid decimal string"` AND `src/money/money.test.ts::"fromMajor parses negative decimal string"` AND `src/money/money.test.ts::"fromMajor raises PARSE_ERROR on empty input"` AND `src/money/money.test.ts::"fromMajor raises PARSE_ERROR on non-numeric input"` AND `src/money/money.test.ts::"fromMajor raises PARSE_ERROR on multiple decimal points"` AND `src/money/money.test.ts::"fromMajor truncates over-long fractional part"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath exports per public module
|
||||
|
||||
The `package.json` SHALL expose six subpath exports in addition to the root `"."` export: `./money/money`, `./currencies/currencies`, `./formatter/formatter`, `./allocator/allocator`, `./error-codes/error-codes`, `./types/common`. Every subpath SHALL resolve to a `.d.ts` and a `.js` file under `dist/` for both `import` and `require` conditions. Every feature-module `index.ts` barrel SHALL use explicit named re-exports (no `export *`).
|
||||
|
||||
#### Scenario: subpath exports declared
|
||||
|
||||
- **WHEN** a consumer runs `node -e "console.log(Object.keys(require('@suaveplan/money/package.json').exports))"`
|
||||
- **THEN** the output includes `./money/money`, `./currencies/currencies`, `./formatter/formatter`, `./allocator/allocator`, `./error-codes/error-codes`, `./types/common`
|
||||
|
||||
#### Scenario: no `export *` in feature barrels
|
||||
|
||||
- **WHEN** `grep -rn "^\\s*export\\s\\+\\(type\\s\\+\\)\\?\\*\\s\\+from" packages/finance/money/src/ --include="index.ts" | grep -v "/src/index\\.ts:"` is run
|
||||
- **THEN** the command produces zero matches
|
||||
|
||||
**Verified by:** `src/money/money.test.ts::"subpath exports are declared in package.json"` AND `src/money/money.test.ts::"feature barrels use explicit named re-exports"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
66
openspec/archive/2026-05-17-add-money/tasks.md
Normal file
66
openspec/archive/2026-05-17-add-money/tasks.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Tasks — Add @suaveplan/money
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/enterprise/money.md` end-to-end
|
||||
- [x] 1.2 Read `openspec/specs/money/spec.md` (does not exist — this is a new package)
|
||||
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
|
||||
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [x] 1.5 Fill in `## Source mapping` table — every requirement cites a line range OR appears in the "no source line" table with justification
|
||||
- [x] 1.6 Draft `specs/money/spec.md` from `openspec/templates/spec-delta.md`
|
||||
- [x] 1.7 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 Scaffold `packages/finance/money/` (package.json, tsconfig.json, tsconfig.lib.json, vite.config.ts, vitest.config.ts, tests/setup/polyfills.ts)
|
||||
- [x] 2.2 Implement `src/error-codes/error-codes.ts` (`MONEY_CODES` registry + `MoneyError` class)
|
||||
- [x] 2.3 Implement `src/types/common.ts` (re-export of all public types)
|
||||
- [x] 2.4 Implement `src/currencies/currencies.ts` (registry + 30 fiat + 5 crypto built-ins + helpers)
|
||||
- [x] 2.5 Implement `src/money/money.ts` (`Money` class + `applyRounding` + hardened `parseDecimalString`)
|
||||
- [x] 2.6 Implement `src/formatter/formatter.ts` (`formatMoney` + `formatMoneyCompact`)
|
||||
- [x] 2.7 Implement `src/allocator/allocator.ts` (allocators + aggregators)
|
||||
- [x] 2.8 Write root `src/index.ts` barrel with explicit named re-exports
|
||||
- [x] 2.9 Write per-feature `index.ts` barrels with explicit named re-exports
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 `src/error-codes/error-codes.test.ts` — every `MONEY.*` code reachable from a real call site
|
||||
- [x] 3.2 `src/money/money.test.ts` — constructors, arithmetic, comparison, conversion, JSON round-trip, rounding modes, parse error
|
||||
- [x] 3.3 `src/currencies/currencies.test.ts` — lookup, registration, virtual-currency helpers, unknown-currency raise
|
||||
- [x] 3.4 `src/formatter/formatter.test.ts` — locale matrix, options matrix, compact formatter
|
||||
- [x] 3.5 `src/allocator/allocator.test.ts` — total-preservation invariant, all raise cases, aggregator cross-currency raise
|
||||
- [x] 3.6 Every `it(...)` carries `{ timeout: 5000 }`
|
||||
- [x] 3.7 Every test imports runner primitives from `@suaveplan/testing/runner`
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
|
||||
- [x] 4.2 README at package root linking every co-located `.md` under `## Modules`
|
||||
- [x] 4.3 Update root `STATUS.md` and `SUMMARY.md` rows for `@suaveplan/money` (Pending → Completed)
|
||||
|
||||
## 5. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [x] 5.1 `bun run check` — Biome clean across touched files
|
||||
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/money` — green
|
||||
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/money` — 100/100/100/100
|
||||
- [x] 5.4 Spec-conformance: every requirement in spec.md has a real test that pins it
|
||||
- [x] 5.5 `bunx turbo build --filter=@suaveplan/money` — green
|
||||
|
||||
## 6. Pre-archive 9-row checklist (SKILL.md Step 7)
|
||||
|
||||
- [x] 6.1 Generics threaded; `expectTypeOf` assertions where literal types matter (N/A — package surface is value-level, not type-level beyond standard generics)
|
||||
- [x] 6.2 Zod schemas wired at every declared boundary (N/A — package has no network boundary; explicitly deferred to `@suaveplan/api-schemas`)
|
||||
- [x] 6.3 Every error code in `MONEY_CODES` thrown by at least one production path
|
||||
- [x] 6.4 Strict-mode behaviors tested both on and off (N/A — package has no strict-mode toggle)
|
||||
- [x] 6.5 OTel instruments emitted from production paths (N/A — universal-tier package; no observability surface in this migration)
|
||||
- [x] 6.6 Lifecycle dedup tested where claimed (N/A — package is stateless aside from the module-level currency map, which has its own tests)
|
||||
- [x] 6.7 No retroactive tick — every box above reflects work done in this change
|
||||
- [x] 6.8 No `export *` in any feature-module barrel (verified by §13 step 4a)
|
||||
- [x] 6.9 No direct `vitest` / `bun:test` imports in test files (verified by §13 step 4b)
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [x] 7.1 Fold spec delta into `openspec/specs/money/spec.md`
|
||||
- [x] 7.2 Move `openspec/changes/add-money/` → `openspec/archive/2026-05-17-add-money/`
|
||||
- [x] 7.3 Refresh `MANIFEST.md` via `bun scripts/folder-reorg/generate-manifest.ts`
|
||||
- [x] 7.4 Refresh `MISSING.yml` via `bun scripts/generate-missing.ts` (if applicable)
|
||||
Reference in New Issue
Block a user