357 lines
22 KiB
Markdown
357 lines
22 KiB
Markdown
<!-- 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 -->
|