diff --git a/README.md b/README.md index bb77cd4..99f5732 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,10 @@ const btc = Money("0.5 BTC") // Arithmetic const total = usd.add(Money("$25.25")) // $125.75 +// Division with rounding +import { Round } from '@thesis/cent' +const split = total.divide(3, Round.HALF_UP) // $41.92 + // Conversion with precision preservation const price = new Price(Money("$50,000"), Money("1 BTC")) const converted = usd.convert(price) // Exact BTC amount @@ -48,11 +52,107 @@ const converted = usd.convert(price) // Exact BTC amount const [first, second, third] = usd.allocate([1, 2, 1]) // [$25.13, $50.25, $25.12] const [a, b, c] = usd.distribute(3) // [$33.50, $33.50, $33.50] +// Bounds and clamping +const tip = total.multiply("20%").atLeast(5).atMost(50) // $8.38 (min $5, max $50) +const safe = Money("-$50").clamp("$0", "$100") // $0.00 + // Formatting usd.toString({ locale: "en-US", compact: true }) // "$100.50" btc.toString({ preferredUnit: "satoshi" }) // "50,000,000 sat" ``` +## Configuration + +Configure library-wide defaults at application startup: + +```typescript +import { configure, Round } from '@thesis/cent' + +// Environment-based configuration +configure({ + numberInputMode: process.env.NODE_ENV === 'production' ? 'error' : 'warn', + strictPrecision: process.env.NODE_ENV === 'production', + defaultRoundingMode: Round.HALF_UP, + defaultCurrency: 'USD', +}) +``` + +**Configuration options:** +- `numberInputMode` - How to handle JS number inputs: `'warn'`, `'error'`, or `'silent'` +- `strictPrecision` - When `true`, throw on any operation that would lose precision +- `defaultRoundingMode` - Default rounding mode, or `'none'` to require explicit rounding +- `defaultCurrency` - Default currency code (default: `'USD'`) +- `defaultLocale` - Default locale for formatting (default: `'en-US'`) + +**Scoped configuration for testing:** + +```typescript +import { withConfig } from '@thesis/cent' + +// Temporarily override configuration +withConfig({ strictPrecision: true }, () => { + // This block uses strict precision mode + const result = Money("$100").divide(2) +}) +// Configuration is restored after the block +``` + +## Safe Parsing + +For user input or external data, use `Money.parse()` which returns a `Result` type instead of throwing exceptions. This enables clean, chainable error handling without try/catch blocks: + +```typescript +import { Money } from '@thesis/cent' + +// Parse user input safely +const result = Money.parse(userInput) + .map(money => money.add("8.25%")) // Add tax if valid + .map(money => money.roundTo(2, Round.HALF_UP)) // Round to cents + +// Handle success or failure +const total = result.match({ + ok: (money) => money.toString(), + err: (error) => `Invalid amount: ${error.suggestion}`, +}) + +// Or provide a default for invalid input +const amount = Money.parse(untrustedInput).unwrapOr(Money.zero("USD")) +``` + +## Type Guards + +When working with values from external sources or loosely-typed APIs, use `Money.isMoney()` for runtime type checking with full TypeScript type narrowing: + +```typescript +import { Money } from '@thesis/cent' + +function processPayment(amount: unknown) { + if (Money.isMoney(amount)) { + // TypeScript knows amount is Money here + return amount.multiply(2n).toString() + } + return "Invalid amount" +} + +// Filter for specific currencies +const amounts = [Money("$100"), Money("€50"), Money("$25")] +const usdOnly = amounts.filter(m => Money.isMoney(m, "USD")) +// usdOnly: [Money("$100"), Money("$25")] + +// Use assertions for validation with helpful errors +Money.assertMoney(value) // Throws if not Money +Money.assertPositive(money) // Throws if not > 0 +Money.assertNonNegative(money) // Throws if < 0 +Money.assertNonZero(money) // Throws if === 0 + +// Or use validate() for Result-based validation +const result = Money("$50").validate({ min: "$10", max: "$100", positive: true }) +result.match({ + ok: (money) => processPayment(money), + err: (error) => console.log(error.suggestion), +}) +``` + ## Core utils ### `Money()` and the `Money` class @@ -100,7 +200,7 @@ const microYen = Money('¥1000.001') // Sub-yen precision The `Money` class provides safe monetary operations with automatic precision handling: ```typescript -import { Money, EUR, USD } from '@thesis/cent' +import { Money, EUR, USD, Round } from '@thesis/cent' // Create money instances const euros = new Money({ @@ -119,7 +219,8 @@ console.log(sum.toString()) // "€750.75" // Multiplication and division const doubled = euros.multiply("2") -const half = euros.divide("2") // Only works with factors of 2 and 5 +const half = euros.divide("2") // Exact: factors of 2 and 5 only +const third = euros.divide(3, Round.HALF_UP) // Rounded: other factors need rounding mode // Comparisons console.log(euros.greaterThan(dollars)) // Error: Different currencies @@ -568,24 +669,67 @@ const sum = fp1.add("5.00") // Normalized to 2 decimals console.log(sum.toString()) // "15.00" ``` +### Rounding modes + +`cent` provides rounding modes for operations that may produce values that cannot be represented exactly: + +```typescript +import { Money, Round } from '@thesis/cent' + +const price = Money("$100.00") + +// Division with rounding +price.divide(3, Round.HALF_UP) // $33.33 +price.divide(3, Round.HALF_EVEN) // $33.33 (banker's rounding) +price.divide(3, Round.CEILING) // $33.34 +price.divide(3, Round.FLOOR) // $33.33 + +// Available rounding modes: +// - Round.UP - Round away from zero +// - Round.DOWN - Round toward zero (truncate) +// - Round.CEILING - Round toward positive infinity +// - Round.FLOOR - Round toward negative infinity +// - Round.HALF_UP - Round to nearest, ties away from zero (common commercial rounding) +// - Round.HALF_DOWN - Round to nearest, ties toward zero +// - Round.HALF_EVEN - Round to nearest, ties to even (banker's rounding) + +// Round to currency precision +const precise = Money({ asset: USD, amount: { amount: 100125n, decimals: 3n } }) +precise.round() // $100.13 (HALF_UP by default) +precise.round(Round.HALF_EVEN) // $100.12 (banker's rounding) + +// Round to specific decimal places +precise.roundTo(2) // 2 decimal places +precise.roundTo(0, Round.HALF_UP) // Round to whole dollars + +// Multiply with rounding +price.multiply("0.333", Round.HALF_UP) // $33.30 +``` + ### Safe division -Unlike floating-point arithmetic, `cent` ensures exact division results: +Unlike floating-point arithmetic, `cent` ensures exact division results when possible: ```typescript const number = FixedPoint("100") // 100 +// Exact division (factors of 2 and 5 only) console.log(number.divide("2").toString()) // "50.0" console.log(number.divide("4").toString()) // "25.00" console.log(number.divide("5").toString()) // "20.0" console.log(number.divide("10").toString()) // "10.0" -// throws an exception (3 cannot be represented exactly in decimal) +// Division by other factors requires a rounding mode try { - number.divide("3") + number.divide("3") // throws error } catch (error) { - console.log(error.message) // "divisor must be composed only of factors of 2 and 5" + console.log(error.message) // "Division by 3 requires a rounding mode..." } + +// Money.divide() with rounding mode for non-exact division +const money = Money("$100.00") +money.divide(3, Round.HALF_UP) // $33.33 +money.divide(7, Round.CEILING) // $14.29 ``` If you need division that would break out of what's possible to represent in @@ -684,15 +828,32 @@ console.log(change.toString()) // "$0.00123" (sub-unit precision) - `PriceRange(str)` - Parse range strings (e.g., `PriceRange('$50 - $100')`, `PriceRange('$50-100')`) - `PriceRange(min, max)` - Create from Money instances or strings (e.g., `PriceRange(Money('$50'), '$100')`) +### `Round` + +Constants for rounding mode selection in arithmetic operations: + +- `Round.UP` - Round away from zero +- `Round.DOWN` - Round toward zero (truncate) +- `Round.CEILING` - Round toward positive infinity +- `Round.FLOOR` - Round toward negative infinity +- `Round.HALF_UP` - Round to nearest, ties away from zero +- `Round.HALF_DOWN` - Round to nearest, ties toward zero +- `Round.HALF_EVEN` - Round to nearest, ties to even (banker's rounding) + ### `Money` **Arithmetic Operations (add/subtract accept Money objects or currency strings):** - `add(other)` - Add money amounts (same currency) - `subtract(other)` - Subtract money amounts (same currency) -- `multiply(scalar)` - Multiply by number, FixedPoint, or string +- `multiply(scalar, round?)` - Multiply by number, FixedPoint, or string; optional rounding mode +- `divide(divisor, round?)` - Divide by number, bigint, or string; rounding mode required for non-2/5 factors - `absolute()` - Get absolute value - `negate()` - Flip sign (multiply by -1) +**Rounding Operations:** +- `round(mode?)` - Round to currency precision (default: `Round.HALF_UP`) +- `roundTo(decimals, mode?)` - Round to specific decimal places + **Allocation & Distribution:** - `allocate(ratios, options?)` - Split proportionally by ratios with optional fractional unit separation - `distribute(parts, options?)` - Split evenly into N parts with optional fractional unit separation diff --git a/packages/cent/src/config.ts b/packages/cent/src/config.ts new file mode 100644 index 0000000..66ef193 --- /dev/null +++ b/packages/cent/src/config.ts @@ -0,0 +1,177 @@ +/** + * Global configuration for the cent library. + * + * @example + * import { configure, Round } from '@thesis-co/cent'; + * + * // Configure based on environment + * configure({ + * numberInputMode: process.env.NODE_ENV === 'production' ? 'error' : 'warn', + * strictPrecision: process.env.NODE_ENV === 'production', + * defaultRoundingMode: Round.HALF_UP, + * }); + */ + +import type { RoundingMode } from "./types" + +/** + * Configuration options for the cent library. + */ +export interface CentConfig { + /** + * How to handle JavaScript number inputs. + * - `'warn'`: Log a console warning for potentially imprecise numbers (default) + * - `'error'`: Throw an error for potentially imprecise numbers + * - `'silent'`: Allow all numbers without warning + * - `'never'`: Throw an error for ANY number input (use strings or bigints instead) + */ + numberInputMode: "warn" | "error" | "silent" | "never" + + /** + * Number of decimal places beyond which to warn about precision loss. + * Default is 15 (approximate limit of JS number precision). + */ + precisionWarningThreshold: number + + /** + * Default rounding mode for operations that require rounding. + * Set to `'none'` to disable default rounding (operations that would + * lose precision will throw instead). + */ + defaultRoundingMode: RoundingMode | "none" + + /** + * Default currency code when none is specified. + * Default is `'USD'`. + */ + defaultCurrency: string + + /** + * Default locale for formatting. + * Default is `'en-US'`. + */ + defaultLocale: string + + /** + * When true, throw on any operation that would lose precision. + * This is stricter than `defaultRoundingMode: 'none'` as it applies + * to all operations, not just those that would normally round. + */ + strictPrecision: boolean +} + +/** + * Default configuration values. + */ +const DEFAULT_CONFIG: CentConfig = { + numberInputMode: "warn", + precisionWarningThreshold: 15, + defaultRoundingMode: "none", + defaultCurrency: "USD", + defaultLocale: "en-US", + strictPrecision: false, +} + +/** + * Current global configuration. + */ +let currentConfig: CentConfig = { ...DEFAULT_CONFIG } + +/** + * Configure global defaults for the cent library. + * + * Call this once at application startup to set library-wide behavior. + * Partial configuration is supported - only specified options are changed. + * + * @param options - Configuration options to set + * + * @example + * // Production configuration + * configure({ + * numberInputMode: 'error', + * strictPrecision: true, + * }); + * + * @example + * // Development configuration + * configure({ + * numberInputMode: 'warn', + * strictPrecision: false, + * }); + * + * @example + * // Environment-based configuration + * configure({ + * numberInputMode: process.env.NODE_ENV === 'production' ? 'error' : 'warn', + * strictPrecision: process.env.NODE_ENV === 'production', + * }); + */ +export function configure(options: Partial): void { + currentConfig = { ...currentConfig, ...options } +} + +/** + * Get the current configuration. + * + * @returns A copy of the current configuration + * + * @example + * const config = getConfig(); + * console.log(config.defaultCurrency); // 'USD' + */ +export function getConfig(): CentConfig { + return { ...currentConfig } +} + +/** + * Reset configuration to default values. + * + * Useful for testing or when you need to restore original behavior. + * + * @example + * resetConfig(); + */ +export function resetConfig(): void { + currentConfig = { ...DEFAULT_CONFIG } +} + +/** + * Execute a function with temporary configuration overrides. + * + * The configuration is restored after the function completes, + * even if an error is thrown. + * + * @param options - Temporary configuration options + * @param fn - Function to execute with the temporary configuration + * @returns The return value of the function + * + * @example + * // Temporarily use strict mode + * const result = withConfig({ strictPrecision: true }, () => { + * return Money("$100").divide(2); + * }); + * + * @example + * // Useful for testing + * withConfig({ numberInputMode: 'error' }, () => { + * expect(() => Money(0.1, 'USD')).toThrow(); + * }); + */ +export function withConfig(options: Partial, fn: () => T): T { + const previousConfig = { ...currentConfig } + try { + currentConfig = { ...currentConfig, ...options } + return fn() + } finally { + currentConfig = previousConfig + } +} + +/** + * Get the default configuration values. + * + * @returns A copy of the default configuration + */ +export function getDefaultConfig(): CentConfig { + return { ...DEFAULT_CONFIG } +} diff --git a/packages/cent/src/errors.ts b/packages/cent/src/errors.ts new file mode 100644 index 0000000..c1c1dae --- /dev/null +++ b/packages/cent/src/errors.ts @@ -0,0 +1,342 @@ +/** + * Structured error types for cent library operations. + * + * All errors extend CentError and include: + * - `code`: Machine-readable error identifier + * - `suggestion`: Actionable guidance for fixing the error + * - `example`: Code example showing correct usage (optional) + * + * @example + * import { Money, CurrencyMismatchError } from '@thesis-co/cent'; + * + * try { + * Money("$100").add(Money("€50")); + * } catch (error) { + * if (error instanceof CurrencyMismatchError) { + * console.log(error.code); // "CURRENCY_MISMATCH" + * console.log(error.suggestion); // "Convert one amount to match..." + * } + * } + */ + +/** + * Error codes for all cent errors. + */ +export const ErrorCode = { + // Parse errors + PARSE_ERROR: "PARSE_ERROR", + INVALID_NUMBER_FORMAT: "INVALID_NUMBER_FORMAT", + INVALID_MONEY_STRING: "INVALID_MONEY_STRING", + UNKNOWN_CURRENCY: "UNKNOWN_CURRENCY", + + // Currency errors + CURRENCY_MISMATCH: "CURRENCY_MISMATCH", + INCOMPATIBLE_CURRENCIES: "INCOMPATIBLE_CURRENCIES", + + // Division errors + DIVISION_BY_ZERO: "DIVISION_BY_ZERO", + DIVISION_REQUIRES_ROUNDING: "DIVISION_REQUIRES_ROUNDING", + INVALID_DIVISOR: "INVALID_DIVISOR", + + // Precision errors + PRECISION_LOSS: "PRECISION_LOSS", + INVALID_PRECISION: "INVALID_PRECISION", + + // Input validation errors + INVALID_INPUT: "INVALID_INPUT", + INVALID_RANGE: "INVALID_RANGE", + INVALID_RATIO: "INVALID_RATIO", + INVALID_JSON: "INVALID_JSON", + EMPTY_ARRAY: "EMPTY_ARRAY", + + // Validation errors + VALIDATION_ERROR: "VALIDATION_ERROR", + + // Exchange rate errors + INVALID_EXCHANGE_RATE: "INVALID_EXCHANGE_RATE", + EXCHANGE_RATE_MISMATCH: "EXCHANGE_RATE_MISMATCH", +} as const + +export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode] + +/** + * Options for creating a CentError. + */ +export interface CentErrorOptions { + /** Machine-readable error code */ + code: ErrorCode + /** Human-readable error message */ + message: string + /** Actionable suggestion for fixing the error */ + suggestion?: string + /** Code example showing correct usage */ + example?: string + /** The original error that caused this error */ + cause?: Error +} + +/** + * Base error class for all cent library errors. + * + * Extends the native Error class with additional properties for + * better error handling and developer experience. + */ +export class CentError extends Error { + /** Machine-readable error code */ + readonly code: ErrorCode + /** Actionable suggestion for fixing the error */ + readonly suggestion?: string + /** Code example showing correct usage */ + readonly example?: string + /** The original error that caused this error */ + readonly originalCause?: Error + + constructor(options: CentErrorOptions) { + super(options.message) + this.name = "CentError" + this.code = options.code + this.suggestion = options.suggestion + this.example = options.example + this.originalCause = options.cause + + // Maintains proper stack trace for where error was thrown (V8 engines) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor) + } + } + + /** + * Returns a formatted error message including suggestion and example. + */ + toDetailedString(): string { + let result = `${this.name} [${this.code}]: ${this.message}` + if (this.suggestion) { + result += `\n\nSuggestion: ${this.suggestion}` + } + if (this.example) { + result += `\n\nExample:\n${this.example}` + } + return result + } +} + +/** + * Error thrown when parsing a string fails. + * + * This includes parsing money strings, number formats, currency codes, etc. + */ +export class ParseError extends CentError { + /** The input string that failed to parse */ + readonly input: string + + constructor( + input: string, + message: string, + options?: { + code?: ErrorCode + suggestion?: string + example?: string + cause?: Error + } + ) { + super({ + code: options?.code ?? ErrorCode.PARSE_ERROR, + message, + suggestion: options?.suggestion, + example: options?.example, + cause: options?.cause, + }) + this.name = "ParseError" + this.input = input + } +} + +/** + * Error thrown when operating on Money with different currencies. + */ +export class CurrencyMismatchError extends CentError { + /** The expected currency code */ + readonly expected: string + /** The actual currency code received */ + readonly actual: string + + constructor( + expected: string, + actual: string, + operation: string, + options?: { suggestion?: string; example?: string } + ) { + super({ + code: ErrorCode.CURRENCY_MISMATCH, + message: `Cannot ${operation} Money with different currencies: expected ${expected}, got ${actual}`, + suggestion: + options?.suggestion ?? + `Convert one amount to ${expected} or ${actual} before performing the operation.`, + example: options?.example, + }) + this.name = "CurrencyMismatchError" + this.expected = expected + this.actual = actual + } +} + +/** + * Error thrown when an operation would result in precision loss. + */ +export class PrecisionLossError extends CentError { + constructor( + message: string, + options?: { suggestion?: string; example?: string; cause?: Error } + ) { + super({ + code: ErrorCode.PRECISION_LOSS, + message, + suggestion: options?.suggestion, + example: options?.example, + cause: options?.cause, + }) + this.name = "PrecisionLossError" + } +} + +/** + * Error thrown for division-related issues. + * + * This includes division by zero and division requiring rounding. + */ +export class DivisionError extends CentError { + /** The divisor that caused the error */ + readonly divisor: string | number | bigint + + constructor( + divisor: string | number | bigint, + message: string, + options?: { + code?: ErrorCode + suggestion?: string + example?: string + } + ) { + super({ + code: options?.code ?? ErrorCode.DIVISION_BY_ZERO, + message, + suggestion: options?.suggestion, + example: options?.example, + }) + this.name = "DivisionError" + this.divisor = divisor + } +} + +/** + * Error thrown when input validation fails. + * + * This includes invalid ranges, ratios, JSON, and other inputs. + */ +export class InvalidInputError extends CentError { + constructor( + message: string, + options?: { + code?: ErrorCode + suggestion?: string + example?: string + cause?: Error + } + ) { + super({ + code: options?.code ?? ErrorCode.INVALID_INPUT, + message, + suggestion: options?.suggestion, + example: options?.example, + cause: options?.cause, + }) + this.name = "InvalidInputError" + } +} + +/** + * Error thrown when schema or format validation fails. + */ +export class ValidationError extends CentError { + /** Validation issues, if available */ + readonly issues?: Array<{ path: string; message: string }> + + constructor( + message: string, + options?: { + code?: ErrorCode + suggestion?: string + example?: string + issues?: Array<{ path: string; message: string }> + cause?: Error + } + ) { + super({ + code: options?.code ?? ErrorCode.VALIDATION_ERROR, + message, + suggestion: options?.suggestion, + example: options?.example, + cause: options?.cause, + }) + this.name = "ValidationError" + this.issues = options?.issues + } +} + +/** + * Error thrown for exchange rate operation failures. + */ +export class ExchangeRateError extends CentError { + constructor( + message: string, + options?: { + code?: ErrorCode + suggestion?: string + example?: string + cause?: Error + } + ) { + super({ + code: options?.code ?? ErrorCode.INVALID_EXCHANGE_RATE, + message, + suggestion: options?.suggestion, + example: options?.example, + cause: options?.cause, + }) + this.name = "ExchangeRateError" + } +} + +/** + * Error thrown when an array operation is performed on an empty array. + * + * @example + * import { Money, EmptyArrayError } from '@thesis-co/cent'; + * + * try { + * Money.sum([]); + * } catch (error) { + * if (error instanceof EmptyArrayError) { + * console.log(error.suggestion); // "Provide at least one Money instance..." + * } + * } + */ +export class EmptyArrayError extends CentError { + constructor( + operation: string, + options?: { + suggestion?: string + example?: string + } + ) { + super({ + code: ErrorCode.EMPTY_ARRAY, + message: `Cannot perform ${operation} on an empty array`, + suggestion: + options?.suggestion ?? + `Provide at least one Money instance, or use a default value if available.`, + example: options?.example, + }) + this.name = "EmptyArrayError" + } +} diff --git a/packages/cent/src/fixed-point.ts b/packages/cent/src/fixed-point.ts index ef95bfb..5e68dde 100644 --- a/packages/cent/src/fixed-point.ts +++ b/packages/cent/src/fixed-point.ts @@ -301,9 +301,11 @@ export class FixedPointNumber implements FixedPointType, Ratio { const finalDividend = scaledDividend * powerOf10Needed const result = finalDividend / divisor + // The result's decimals = this.decimals + neededFactors + // (otherFP.decimals cancels out in the multiplication by 10^n that we did earlier) return new FixedPointNumber( otherFP.amount < 0n ? -result : result, - this.decimals + otherFP.decimals + neededFactors, + this.decimals + neededFactors, ) } diff --git a/packages/cent/src/index.ts b/packages/cent/src/index.ts index 08304bf..b054cba 100644 --- a/packages/cent/src/index.ts +++ b/packages/cent/src/index.ts @@ -88,3 +88,31 @@ export { RoundingMode, TRUNC, } from "./types" +export { Round } from "./rounding" +export type { Round as RoundType } from "./rounding" +// Error types +export { + CentError, + CurrencyMismatchError, + DivisionError, + EmptyArrayError, + ErrorCode, + ExchangeRateError, + InvalidInputError, + ParseError, + PrecisionLossError, + ValidationError, +} from "./errors" +export type { CentErrorOptions, ErrorCode as ErrorCodeType } from "./errors" +// Configuration +export { + configure, + getConfig, + getDefaultConfig, + resetConfig, + withConfig, +} from "./config" +export type { CentConfig } from "./config" +// Result types +export { Ok, Err, ok, err } from "./result" +export type { Result } from "./result" diff --git a/packages/cent/src/money/index.ts b/packages/cent/src/money/index.ts index 3aef634..0a19016 100644 --- a/packages/cent/src/money/index.ts +++ b/packages/cent/src/money/index.ts @@ -1,8 +1,23 @@ import { assetsEqual, isAssetAmount } from "../assets" +import { getConfig } from "../config" import { getCurrencyFromCode } from "../currencies" +import { + CentError, + CurrencyMismatchError, + DivisionError, + EmptyArrayError, + ErrorCode, + InvalidInputError, + ParseError, + PrecisionLossError, + ValidationError, +} from "../errors" +import { Ok, Err, ok, err } from "../result" +import type { Result } from "../result" import { FixedPointNumber } from "../fixed-point" +import { isOnlyFactorsOf2And5 } from "../math-utils" import { RationalNumber } from "../rationals" -import type { AssetAmount, Currency, FixedPoint } from "../types" +import type { AssetAmount, Currency, FixedPoint, RoundingMode } from "../types" import { parseMoneyString } from "./parsing" import type { MoneyAmount } from "./types" import { @@ -41,6 +56,47 @@ export { safeValidateMoneyJSON, } from "./schemas" +// Import crypto currencies for sub-unit registry +import { BTC, ETH, SOL } from "../currencies/crypto" +import { USD, EUR, GBP, JPY } from "../currencies/fiat" + +/** + * Registry of known sub-units and their corresponding currencies/decimals. + * The decimals value represents the number of decimal places for the sub-unit. + */ +const SUB_UNIT_REGISTRY: Record = { + // Bitcoin sub-units + satoshi: { currency: BTC, decimals: 8 }, + sat: { currency: BTC, decimals: 8 }, + sats: { currency: BTC, decimals: 8 }, + millisatoshi: { currency: BTC, decimals: 11 }, + msat: { currency: BTC, decimals: 11 }, + msats: { currency: BTC, decimals: 11 }, + + // Ethereum sub-units + wei: { currency: ETH, decimals: 18 }, + kwei: { currency: ETH, decimals: 15 }, + babbage: { currency: ETH, decimals: 15 }, + mwei: { currency: ETH, decimals: 12 }, + lovelace: { currency: ETH, decimals: 12 }, + gwei: { currency: ETH, decimals: 9 }, + shannon: { currency: ETH, decimals: 9 }, + szabo: { currency: ETH, decimals: 6 }, + finney: { currency: ETH, decimals: 3 }, + + // Solana sub-units + lamport: { currency: SOL, decimals: 9 }, + lamports: { currency: SOL, decimals: 9 }, + + // Fiat sub-units (for convenience) + cent: { currency: USD, decimals: 2 }, + cents: { currency: USD, decimals: 2 }, + penny: { currency: GBP, decimals: 2 }, + pence: { currency: GBP, decimals: 2 }, + eurocent: { currency: EUR, decimals: 2 }, + yen: { currency: JPY, decimals: 0 }, +} + // Import formatting functions for internal use import { formatWithCustomFormatting, @@ -115,8 +171,10 @@ export class Money { // Validate that currencies match if (!assetsEqual(parsed.currency, referenceCurrency)) { - throw new Error( - `Currency mismatch: expected ${referenceCurrency.code || referenceCurrency.name}, got ${parsed.currency.code || parsed.currency.name}`, + throw new CurrencyMismatchError( + referenceCurrency.code || referenceCurrency.name, + parsed.currency.code || parsed.currency.name, + "parse", ) } @@ -148,13 +206,35 @@ export class Money { } /** - * Add money or an asset amount to this Money instance + * Add money, an asset amount, or a percentage to this Money instance. * - * @param other - The Money, AssetAmount, or string representation to add + * When a percentage string is provided (e.g., "8.25%"), the result is + * `this * (1 + percent/100)`. This is useful for adding tax or tips. + * + * @param other - The Money, AssetAmount, string representation, or percentage to add + * @param round - Optional rounding mode when adding percentages * @returns A new Money instance with the sum * @throws Error if the assets are not the same type + * + * @example + * const price = Money("$100.00"); + * price.add(Money("$10.00")); // $110.00 + * price.add("$10.00"); // $110.00 + * price.add("8.25%"); // $108.25 (add 8.25% tax) + * price.add("20%"); // $120.00 (add 20% tip) */ - add(other: Money | AssetAmount | string): Money { + add(other: Money | AssetAmount | string, round?: RoundingMode): Money { + // Check for percentage string first + if (typeof other === "string") { + const percentDecimal = parsePercentage(other) + if (percentDecimal !== null) { + // add("8.25%") means: amount * (1 + 0.0825) = amount * 1.0825 + const one = new FixedPointNumber(1n, 0n) + const multiplier = one.add(percentDecimal) + return this.multiply(multiplier, round) + } + } + let otherMoney: Money if (typeof other === "string") { otherMoney = Money.parseStringToMoney(other, this.currency) @@ -165,7 +245,11 @@ export class Money { } if (!assetsEqual(this.currency, otherMoney.currency)) { - throw new Error("Cannot add Money with different asset types") + throw new CurrencyMismatchError( + this.currency.code || this.currency.name, + otherMoney.currency.code || otherMoney.currency.name, + "add", + ) } // If both are FixedPointNumber, use fast path @@ -194,13 +278,35 @@ export class Money { } /** - * Subtract money or an asset amount from this Money instance + * Subtract money, an asset amount, or a percentage from this Money instance. * - * @param other - The Money, AssetAmount, or string representation to subtract + * When a percentage string is provided (e.g., "10%"), the result is + * `this * (1 - percent/100)`. This is useful for discounts. + * + * @param other - The Money, AssetAmount, string representation, or percentage to subtract + * @param round - Optional rounding mode when subtracting percentages * @returns A new Money instance with the difference * @throws Error if the assets are not the same type + * + * @example + * const price = Money("$100.00"); + * price.subtract(Money("$10.00")); // $90.00 + * price.subtract("$10.00"); // $90.00 + * price.subtract("10%"); // $90.00 (10% discount) + * price.subtract("25%"); // $75.00 (25% off) */ - subtract(other: Money | AssetAmount | string): Money { + subtract(other: Money | AssetAmount | string, round?: RoundingMode): Money { + // Check for percentage string first + if (typeof other === "string") { + const percentDecimal = parsePercentage(other) + if (percentDecimal !== null) { + // subtract("10%") means: amount * (1 - 0.10) = amount * 0.90 + const one = new FixedPointNumber(1n, 0n) + const multiplier = one.subtract(percentDecimal) + return this.multiply(multiplier, round) + } + } + let otherMoney: Money if (typeof other === "string") { otherMoney = Money.parseStringToMoney(other, this.currency) @@ -211,7 +317,11 @@ export class Money { } if (!assetsEqual(this.currency, otherMoney.currency)) { - throw new Error("Cannot subtract Money with different asset types") + throw new CurrencyMismatchError( + this.currency.code || this.currency.name, + otherMoney.currency.code || otherMoney.currency.name, + "subtract", + ) } // If both are FixedPointNumber, use fast path @@ -248,7 +358,12 @@ export class Money { concretize(): [Money, Money] { // Check if the asset has a decimals property (is a FungibleAsset) if (!("decimals" in this.balance.asset)) { - throw new Error("Cannot concretize Money with non-fungible asset") + throw new InvalidInputError( + "Cannot concretize Money with non-fungible asset", + { + suggestion: "Use a fungible asset (like a currency) that has decimal precision defined.", + }, + ) } const assetDecimals = this.balance.asset.decimals @@ -296,25 +411,561 @@ export class Money { } /** - * Multiply this Money instance by a scalar or fixed-point number + * Multiply this Money instance by a scalar, fixed-point number, or percentage. * - * @param other - The value to multiply by (bigint or FixedPoint) + * When a percentage string is provided (e.g., "50%"), it multiplies by + * the percentage as a decimal (50% = 0.50). + * + * @param factor - The value to multiply by (bigint, FixedPoint, decimal string, or percentage string) + * @param round - Optional rounding mode to round result to currency precision * @returns A new Money instance with the product + * + * @example + * const price = Money("$100.00"); + * price.multiply(3n); // $300.00 + * price.multiply("1.5"); // $150.00 + * price.multiply("0.333", Round.HALF_UP); // $33.30 + * price.multiply("50%"); // $50.00 (50% of $100) + * price.multiply("150%"); // $150.00 (150% of $100) */ - multiply(other: bigint | FixedPoint): Money { + multiply( + factor: bigint | FixedPoint | string, + round?: RoundingMode, + ): Money { const thisFixedPoint = new FixedPointNumber( this.balance.amount.amount, this.balance.amount.decimals, ) - const result = thisFixedPoint.multiply(other) - return new Money({ + // Check for percentage string + let factorValue: bigint | FixedPoint + if (typeof factor === "string") { + const percentDecimal = parsePercentage(factor) + if (percentDecimal !== null) { + // multiply("50%") means: amount * 0.50 + factorValue = percentDecimal + } else { + factorValue = FixedPointNumber.fromDecimalString(factor) + } + } else { + factorValue = factor + } + + const result = thisFixedPoint.multiply(factorValue) + + const money = new Money({ asset: this.balance.asset, amount: { amount: result.amount, decimals: result.decimals, }, }) + + // If rounding is requested, round to currency precision + if (round !== undefined) { + return money.roundTo(Number(this.currency.decimals), round) + } + + return money + } + + /** + * Divide this Money instance by a divisor. + * + * For divisors that are only composed of factors of 2 and 5 (like 2, 4, 5, 10, 20, 25, etc.), + * the division is exact and no rounding mode is required. + * + * For other divisors (like 3, 7, 11, etc.), a rounding mode must be provided + * to determine how to handle the non-terminating decimal result. + * + * @param divisor - The value to divide by (number, bigint, string, or FixedPoint) + * @param round - Rounding mode (required for divisors with factors other than 2 and 5) + * @returns A new Money instance with the quotient + * @throws Error if dividing by zero + * @throws Error if divisor requires rounding but no rounding mode provided + * + * @example + * const price = Money("$100.00"); + * + * // Exact division (no rounding needed) + * price.divide(2); // $50.00 + * price.divide(5); // $20.00 + * price.divide(10); // $10.00 + * + * // Division requiring rounding + * price.divide(3, Round.HALF_UP); // $33.33 + * price.divide(7, Round.HALF_EVEN); // $14.29 + * + * // Error: rounding required + * price.divide(3); // Throws: "Division by 3 requires a rounding mode" + * + * @see {@link Round} for available rounding modes + */ + divide( + divisor: number | bigint | string | FixedPoint, + round?: RoundingMode, + ): Money { + // Convert divisor to bigint for factor checking + let divisorBigInt: bigint + let divisorDecimals = 0n + + if (typeof divisor === "number") { + if (!Number.isFinite(divisor)) { + throw new DivisionError( + divisor, + "Cannot divide by Infinity or NaN", + { + code: ErrorCode.INVALID_DIVISOR, + suggestion: "Use a finite number as the divisor.", + }, + ) + } + if (divisor === 0) { + throw new DivisionError(0, "Cannot divide by zero") + } + // Convert number to string and parse as fixed-point + const str = divisor.toString() + const fp = FixedPointNumber.fromDecimalString(str) + divisorBigInt = fp.amount < 0n ? -fp.amount : fp.amount + divisorDecimals = fp.decimals + } else if (typeof divisor === "bigint") { + if (divisor === 0n) { + throw new DivisionError(0n, "Cannot divide by zero") + } + divisorBigInt = divisor < 0n ? -divisor : divisor + } else if (typeof divisor === "string") { + const fp = FixedPointNumber.fromDecimalString(divisor) + if (fp.amount === 0n) { + throw new DivisionError(divisor, "Cannot divide by zero") + } + divisorBigInt = fp.amount < 0n ? -fp.amount : fp.amount + divisorDecimals = fp.decimals + } else { + // FixedPoint object + if (divisor.amount === 0n) { + throw new DivisionError(divisor.amount, "Cannot divide by zero") + } + divisorBigInt = divisor.amount < 0n ? -divisor.amount : divisor.amount + divisorDecimals = divisor.decimals + } + + // Check if divisor is composed only of factors of 2 and 5 + const needsRounding = !isOnlyFactorsOf2And5(divisorBigInt) + + if (needsRounding && round === undefined) { + const divisorStr = typeof divisor === "object" + ? new FixedPointNumber(divisor.amount, divisor.decimals).toString() + : String(divisor) + throw new DivisionError( + divisorStr, + `Division by ${divisorStr} requires a rounding mode because ${divisorStr} contains factors other than 2 and 5.`, + { + code: ErrorCode.DIVISION_REQUIRES_ROUNDING, + suggestion: `Use: amount.divide(${divisorStr}, Round.HALF_UP) or another rounding mode.`, + example: `import { Round } from '@thesis-co/cent';\namount.divide(${divisorStr}, Round.HALF_UP);`, + }, + ) + } + + // Get the amount as FixedPointNumber + const thisFixedPoint = isFixedPointNumber(this.amount) + ? this.amount + : toFixedPointNumber(this.amount) + + // Perform division with rounding if needed + if (needsRounding && round !== undefined) { + // For non-exact division, use RationalNumber for precision then round + const thisRational = new RationalNumber({ + p: thisFixedPoint.amount, + q: 10n ** thisFixedPoint.decimals, + }) + + // Determine sign of divisor + let divisorSign = 1n + if (typeof divisor === "number" && divisor < 0) divisorSign = -1n + else if (typeof divisor === "bigint" && divisor < 0n) divisorSign = -1n + else if (typeof divisor === "string" && divisor.startsWith("-")) divisorSign = -1n + else if (typeof divisor === "object" && divisor.amount < 0n) divisorSign = -1n + + // Create divisor as rational + const divisorRational = new RationalNumber({ + p: divisorBigInt * divisorSign, + q: 10n ** divisorDecimals, + }) + + // Divide: this / divisor = this * (1/divisor) = this * (q/p) + const resultRational = thisRational.multiply( + new RationalNumber({ p: divisorRational.q, q: divisorRational.p }), + ) + + // Convert to fixed-point at currency precision with rounding + // We need to compute: (p / q) at `currencyDecimals` decimal places + // That's: round(p * 10^currencyDecimals / q) + const currencyDecimals = this.currency.decimals + const scaledNumerator = resultRational.p * 10n ** currencyDecimals + const denominator = resultRational.q + + // Apply rounding to the division + const quotient = scaledNumerator / denominator + const remainder = scaledNumerator % denominator + + let roundedAmount: bigint + if (remainder === 0n) { + roundedAmount = quotient + } else { + const isNegative = scaledNumerator < 0n !== denominator < 0n + const absRemainder = remainder < 0n ? -remainder : remainder + const absDenominator = denominator < 0n ? -denominator : denominator + const doubleRemainder = absRemainder * 2n + + switch (round) { + case "ceil": + roundedAmount = isNegative ? quotient : quotient + 1n + break + case "floor": + roundedAmount = isNegative ? quotient - 1n : quotient + break + case "expand": + roundedAmount = quotient + (isNegative ? -1n : 1n) + break + case "trunc": + roundedAmount = quotient + break + case "halfCeil": + if (doubleRemainder > absDenominator) { + roundedAmount = quotient + (isNegative ? -1n : 1n) + } else if (doubleRemainder === absDenominator) { + roundedAmount = isNegative ? quotient : quotient + 1n + } else { + roundedAmount = quotient + } + break + case "halfFloor": + if (doubleRemainder > absDenominator) { + roundedAmount = quotient + (isNegative ? -1n : 1n) + } else if (doubleRemainder === absDenominator) { + roundedAmount = isNegative ? quotient - 1n : quotient + } else { + roundedAmount = quotient + } + break + case "halfExpand": + if (doubleRemainder >= absDenominator) { + roundedAmount = quotient + (isNegative ? -1n : 1n) + } else { + roundedAmount = quotient + } + break + case "halfTrunc": + if (doubleRemainder > absDenominator) { + roundedAmount = quotient + (isNegative ? -1n : 1n) + } else { + roundedAmount = quotient + } + break + case "halfEven": + default: + if (doubleRemainder > absDenominator) { + roundedAmount = quotient + (isNegative ? -1n : 1n) + } else if (doubleRemainder === absDenominator) { + const adjustedQuotient = quotient + (isNegative ? -1n : 1n) + roundedAmount = adjustedQuotient % 2n === 0n ? adjustedQuotient : quotient + } else { + roundedAmount = quotient + } + break + } + } + + return new Money( + this.currency, + new FixedPointNumber(roundedAmount, currencyDecimals), + ) + } + + // Exact division (divisor is only factors of 2 and 5) + // Build the FixedPoint divisor + let fpDivisor: FixedPoint + if (typeof divisor === "bigint") { + fpDivisor = { amount: divisor, decimals: 0n } + } else if (typeof divisor === "number" || typeof divisor === "string") { + const fp = FixedPointNumber.fromDecimalString( + typeof divisor === "number" ? divisor.toString() : divisor, + ) + fpDivisor = { amount: fp.amount, decimals: fp.decimals } + } else { + fpDivisor = divisor + } + + const result = thisFixedPoint.divide(fpDivisor) + + return new Money({ + asset: this.currency, + amount: { + amount: result.amount, + decimals: result.decimals, + }, + }) + } + + /** + * Round this Money instance to its currency's standard precision. + * + * Most fiat currencies have 2 decimal places (e.g., USD, EUR), + * while cryptocurrencies vary (BTC has 8, ETH has 18). + * + * @param mode - The rounding mode to use (defaults to HALF_UP if not specified) + * @returns A new Money instance rounded to the currency's precision + * + * @example + * import { Money, Round } from '@thesis-co/cent'; + * + * Money("$100.125").round(); // $100.13 (default HALF_UP) + * Money("$100.125").round(Round.HALF_EVEN); // $100.12 (banker's rounding) + * Money("$100.125").round(Round.FLOOR); // $100.12 + * Money("$100.125").round(Round.CEILING); // $100.13 + * + * @see {@link roundTo} for rounding to a specific number of decimal places + */ + round(mode?: RoundingMode): Money { + return this.roundTo(Number(this.currency.decimals), mode) + } + + /** + * Round this Money instance to a specific number of decimal places. + * + * @param decimals - The number of decimal places to round to + * @param mode - The rounding mode to use (defaults to HALF_UP if not specified) + * @returns A new Money instance rounded to the specified precision + * @throws Error if decimals is negative + * + * @example + * import { Money, Round } from '@thesis-co/cent'; + * + * const precise = Money("$100.12345"); + * + * precise.roundTo(2); // $100.12 + * precise.roundTo(3); // $100.123 + * precise.roundTo(4, Round.HALF_UP); // $100.1235 + * precise.roundTo(0); // $100.00 + * + * @see {@link round} for rounding to the currency's standard precision + */ + roundTo(decimals: number, mode?: RoundingMode): Money { + if (decimals < 0) { + throw new InvalidInputError( + `Decimal places must be non-negative, got ${decimals}`, + { + code: ErrorCode.INVALID_PRECISION, + suggestion: "Use a non-negative integer for decimal places.", + }, + ) + } + + const targetDecimals = BigInt(decimals) + + // Get the amount as FixedPointNumber + const thisFixedPoint = isFixedPointNumber(this.amount) + ? this.amount + : toFixedPointNumber(this.amount) + + // If already at or below target precision, just normalize + if (thisFixedPoint.decimals <= targetDecimals) { + const normalized = thisFixedPoint.normalize({ + amount: 0n, + decimals: targetDecimals, + }) + return new Money(this.currency, normalized) + } + + // Need to round down - use applyRounding logic + const scaleDiff = thisFixedPoint.decimals - targetDecimals + const divisor = 10n ** scaleDiff + + // Default to HALF_EXPAND (HALF_UP) if no mode specified + const roundingMode = mode ?? ("halfExpand" as RoundingMode) + + // Apply rounding + const quotient = thisFixedPoint.amount / divisor + const remainder = thisFixedPoint.amount % divisor + + let roundedAmount: bigint + + if (remainder === 0n) { + roundedAmount = quotient + } else { + const isNegative = thisFixedPoint.amount < 0n + const absRemainder = remainder < 0n ? -remainder : remainder + const doubleRemainder = absRemainder * 2n + const absDivisor = divisor + + switch (roundingMode) { + case "ceil": + roundedAmount = isNegative ? quotient : quotient + 1n + break + case "floor": + roundedAmount = isNegative ? quotient - 1n : quotient + break + case "expand": + roundedAmount = quotient + (isNegative ? -1n : 1n) + break + case "trunc": + roundedAmount = quotient + break + case "halfCeil": + if (doubleRemainder > absDivisor) { + roundedAmount = quotient + (isNegative ? -1n : 1n) + } else if (doubleRemainder === absDivisor) { + roundedAmount = isNegative ? quotient : quotient + 1n + } else { + roundedAmount = quotient + } + break + case "halfFloor": + if (doubleRemainder > absDivisor) { + roundedAmount = quotient + (isNegative ? -1n : 1n) + } else if (doubleRemainder === absDivisor) { + roundedAmount = isNegative ? quotient - 1n : quotient + } else { + roundedAmount = quotient + } + break + case "halfExpand": + if (doubleRemainder >= absDivisor) { + roundedAmount = quotient + (isNegative ? -1n : 1n) + } else { + roundedAmount = quotient + } + break + case "halfTrunc": + if (doubleRemainder > absDivisor) { + roundedAmount = quotient + (isNegative ? -1n : 1n) + } else { + roundedAmount = quotient + } + break + case "halfEven": + default: + if (doubleRemainder > absDivisor) { + roundedAmount = quotient + (isNegative ? -1n : 1n) + } else if (doubleRemainder === absDivisor) { + const adjustedQuotient = quotient + (isNegative ? -1n : 1n) + roundedAmount = adjustedQuotient % 2n === 0n ? adjustedQuotient : quotient + } else { + roundedAmount = quotient + } + break + } + } + + return new Money( + this.currency, + new FixedPointNumber(roundedAmount, targetDecimals), + ) + } + + /** + * Extract the percentage portion from a total that includes the percentage. + * + * This is useful for VAT/tax calculations where you have the total amount + * (price + tax) and need to determine the tax portion. + * + * Formula: `total - (total / (1 + percent/100))` + * + * @param percent - The percentage to extract (e.g., "21%" or "21" for 21%) + * @param round - Optional rounding mode for the result + * @returns The extracted percentage amount + * + * @example + * import { Money, Round } from '@thesis-co/cent'; + * + * // Total is $121 with 21% VAT included - extract the VAT amount + * const total = Money("$121.00"); + * const vat = total.extractPercent("21%", Round.HALF_UP); // $21.00 + * + * // 8.25% sales tax on $108.25 total + * Money("$108.25").extractPercent("8.25%", Round.HALF_UP); // $8.25 + * + * @see {@link removePercent} to get the base amount (before percentage) + */ + extractPercent(percent: string | number, round?: RoundingMode): Money { + // Parse the percentage value + let percentDecimal: FixedPointNumber + if (typeof percent === "string") { + const parsed = parsePercentage(percent) + if (parsed !== null) { + percentDecimal = parsed + } else { + // Try parsing as a plain number string (e.g., "21" for 21%) + const valueFixed = FixedPointNumber.fromDecimalString(percent) + const hundred = new FixedPointNumber(100n, 0n) + percentDecimal = valueFixed.divide(hundred) + } + } else { + // Number input - treat as percentage value (21 means 21%) + const valueFixed = FixedPointNumber.fromDecimalString(percent.toString()) + const hundred = new FixedPointNumber(100n, 0n) + percentDecimal = valueFixed.divide(hundred) + } + + // Formula: total - (total / (1 + percent/100)) + // = total - baseAmount + // where baseAmount = total / (1 + percentDecimal) + const one = new FixedPointNumber(1n, 0n) + const divisor = one.add(percentDecimal) + const baseAmount = this.divide(divisor, round) + + return this.subtract(baseAmount) + } + + /** + * Remove a percentage from a total that includes the percentage. + * + * This is useful for VAT/tax calculations where you have the total amount + * (price + tax) and need to determine the original price before tax. + * + * Formula: `total / (1 + percent/100)` + * + * @param percent - The percentage to remove (e.g., "21%" or "21" for 21%) + * @param round - Optional rounding mode for the result + * @returns The base amount (before percentage was added) + * + * @example + * import { Money, Round } from '@thesis-co/cent'; + * + * // Total is $121 with 21% VAT included - get pre-VAT price + * const total = Money("$121.00"); + * const preVat = total.removePercent("21%", Round.HALF_UP); // $100.00 + * + * // Remove 8.25% sales tax from $108.25 total + * Money("$108.25").removePercent("8.25%", Round.HALF_UP); // $100.00 + * + * @see {@link extractPercent} to get just the percentage amount + */ + removePercent(percent: string | number, round?: RoundingMode): Money { + // Parse the percentage value + let percentDecimal: FixedPointNumber + if (typeof percent === "string") { + const parsed = parsePercentage(percent) + if (parsed !== null) { + percentDecimal = parsed + } else { + // Try parsing as a plain number string (e.g., "21" for 21%) + const valueFixed = FixedPointNumber.fromDecimalString(percent) + const hundred = new FixedPointNumber(100n, 0n) + percentDecimal = valueFixed.divide(hundred) + } + } else { + // Number input - treat as percentage value (21 means 21%) + const valueFixed = FixedPointNumber.fromDecimalString(percent.toString()) + const hundred = new FixedPointNumber(100n, 0n) + percentDecimal = valueFixed.divide(hundred) + } + + // Formula: total / (1 + percent/100) + const one = new FixedPointNumber(1n, 0n) + const divisor = one.add(percentDecimal) + return this.divide(divisor, round) } /** @@ -345,7 +996,11 @@ export class Money { const otherAmount = otherMoney.balance if (!assetsEqual(this.balance.asset, otherAmount.asset)) { - throw new Error("Cannot compare Money with different asset types") + throw new CurrencyMismatchError( + this.currency.code || this.currency.name, + otherMoney.currency.code || otherMoney.currency.name, + "compare", + ) } const thisFixedPoint = new FixedPointNumber( @@ -379,7 +1034,11 @@ export class Money { const otherAmount = otherMoney.balance if (!assetsEqual(this.balance.asset, otherAmount.asset)) { - throw new Error("Cannot compare Money with different asset types") + throw new CurrencyMismatchError( + this.currency.code || this.currency.name, + otherMoney.currency.code || otherMoney.currency.name, + "compare", + ) } const thisFixedPoint = new FixedPointNumber( @@ -413,7 +1072,11 @@ export class Money { const otherAmount = otherMoney.balance if (!assetsEqual(this.balance.asset, otherAmount.asset)) { - throw new Error("Cannot compare Money with different asset types") + throw new CurrencyMismatchError( + this.currency.code || this.currency.name, + otherMoney.currency.code || otherMoney.currency.name, + "compare", + ) } const thisFixedPoint = new FixedPointNumber( @@ -447,7 +1110,11 @@ export class Money { const otherAmount = otherMoney.balance if (!assetsEqual(this.balance.asset, otherAmount.asset)) { - throw new Error("Cannot compare Money with different asset types") + throw new CurrencyMismatchError( + this.currency.code || this.currency.name, + otherMoney.currency.code || otherMoney.currency.name, + "compare", + ) } const thisFixedPoint = new FixedPointNumber( @@ -555,19 +1222,38 @@ export class Money { options: { distributeFractionalUnits?: boolean } = {}, ): Money[] { if (ratios.length === 0) { - throw new Error("Cannot allocate with empty ratios array") + throw new InvalidInputError( + "Cannot allocate with empty ratios array", + { + code: ErrorCode.EMPTY_ARRAY, + suggestion: "Provide at least one ratio for allocation.", + example: "money.allocate([1, 2, 1])", + }, + ) } // Validate ratios are non-negative ratios.forEach((ratio) => { if (ratio < 0) { - throw new Error("Cannot allocate with negative ratios") + throw new InvalidInputError( + `Cannot allocate with negative ratios: got ${ratio}`, + { + code: ErrorCode.INVALID_RATIO, + suggestion: "All ratios must be non-negative integers.", + }, + ) } }) const totalRatio = ratios.reduce((sum, ratio) => sum + ratio, 0) if (totalRatio === 0) { - throw new Error("Cannot allocate with all zero ratios") + throw new InvalidInputError( + "Cannot allocate with all zero ratios", + { + code: ErrorCode.INVALID_RATIO, + suggestion: "At least one ratio must be greater than zero.", + }, + ) } const { distributeFractionalUnits = true } = options @@ -682,7 +1368,13 @@ export class Money { options: { distributeFractionalUnits?: boolean } = {}, ): Money[] { if (!Number.isInteger(parts) || parts <= 0) { - throw new Error("Parts must be a positive integer") + throw new InvalidInputError( + `Parts must be a positive integer, got ${parts}`, + { + suggestion: "Provide a positive integer for the number of parts.", + example: "money.distribute(3)", + }, + ) } // Use allocate with equal ratios @@ -708,7 +1400,11 @@ export class Money { return others.reduce((maxValue: Money, money) => { if (!assetsEqual(currentBalance.asset, money.balance.asset)) { - throw new Error("Cannot compare Money with different asset types") + throw new CurrencyMismatchError( + this.currency.code || this.currency.name, + money.currency.code || money.currency.name, + "compare", + ) } return maxValue.lessThan(money) ? money : maxValue @@ -733,7 +1429,11 @@ export class Money { return others.reduce((minValue: Money, money) => { if (!assetsEqual(currentBalance.asset, money.balance.asset)) { - throw new Error("Cannot compare Money with different asset types") + throw new CurrencyMismatchError( + this.currency.code || this.currency.name, + money.currency.code || money.currency.name, + "compare", + ) } return minValue.greaterThan(money) ? money : minValue @@ -869,7 +1569,11 @@ export class Money { } if (!assetsEqual(this.currency, otherMoney.currency)) { - throw new Error("Cannot compare Money with different asset types") + throw new CurrencyMismatchError( + this.currency.code || this.currency.name, + otherMoney.currency.code || otherMoney.currency.name, + "compare", + ) } if (this.lessThan(otherMoney)) { @@ -934,7 +1638,13 @@ export class Money { static fromJSON(json: unknown): Money { // First validate that json is an object if (typeof json !== "object" || json === null) { - throw new Error("Invalid JSON input: expected object") + throw new ValidationError( + "Invalid JSON input: expected object", + { + code: ErrorCode.INVALID_JSON, + suggestion: "Provide a valid JSON object with 'currency' and 'amount' properties.", + }, + ) } // Check if this is the old format (has 'asset' instead of 'currency') @@ -1003,82 +1713,871 @@ export class Money { amount = FixedPointNumber.fromJSON(jsonObj.amount) // legacy {amount, decimals} -> FixedPointNumber } } else { - throw new Error("Invalid amount format in JSON") + throw new ValidationError( + "Invalid amount format in JSON", + { + code: ErrorCode.INVALID_JSON, + suggestion: "Amount should be a decimal string or an object with {amount, decimals} or {p, q} properties.", + }, + ) } return new Money(currency, amount) } /** - * Convert this Money instance to a localized string representation + * Create a Money instance from a sub-unit amount. * - * @param options - Formatting options for the string representation - * @returns A formatted string representation + * This method allows creating Money from sub-units like satoshis, gwei, wei, + * lamports, etc. The sub-unit name determines both the currency and the + * decimal offset. + * + * @param amount - The amount in sub-units (as bigint) + * @param unit - The sub-unit name (e.g., "sat", "msat", "gwei", "wei", "lamport") + * @returns A new Money instance + * @throws InvalidInputError if the unit is not recognized + * + * @example + * Money.fromSubUnits(100000000n, "sat") // 1 BTC + * Money.fromSubUnits(1000n, "msat") // 0.000000001 BTC (1000 millisatoshis) + * Money.fromSubUnits(1000000000n, "gwei") // 1 ETH + * Money.fromSubUnits(1000000000n, "wei") // 0.000000001 ETH + * Money.fromSubUnits(1000000000n, "lamport") // 1 SOL */ - toString(options: MoneyToStringOptions = {}): string { - const { - locale = "en-US", - compact = false, - maxDecimals, - minDecimals, - preferredUnit, - preferSymbol = false, - preferFractionalSymbol = false, - roundingMode, - excludeCurrency = false, - } = options - - // Convert RationalNumber to FixedPointNumber for formatting - const formattingMoney = isRationalNumber(this.amount) - ? new Money(this.currency, toFixedPointNumber(this.amount)) - : this - - // Determine if we should use ISO 4217 formatting - const useIsoFormatting = shouldUseIsoFormatting(formattingMoney.currency) - - if (useIsoFormatting) { - return formatWithIntlCurrency( - formattingMoney as unknown as import("./formatting").MoneyLike, - locale, - compact, - maxDecimals, - minDecimals, - roundingMode, - excludeCurrency, + static fromSubUnits(amount: bigint, unit: string): Money { + const unitInfo = SUB_UNIT_REGISTRY[unit.toLowerCase()] + + if (!unitInfo) { + const knownUnits = Object.keys(SUB_UNIT_REGISTRY).join(", ") + throw new InvalidInputError( + `Unknown sub-unit: "${unit}"`, + { + suggestion: `Use one of the known sub-units: ${knownUnits}`, + }, ) } - return formatWithCustomFormatting( - formattingMoney as unknown as import("./formatting").MoneyLike, - locale, - compact, - maxDecimals, - minDecimals, - preferredUnit, - preferSymbol, - preferFractionalSymbol, - roundingMode, - excludeCurrency, - ) + + const { currency, decimals } = unitInfo + + return new Money({ + asset: currency, + amount: { + amount, + decimals: BigInt(decimals), + }, + }) } /** - * Convert this Money instance to another currency using a price or exchange rate - * - * Uses lossless conversion when possible (when price denominators only contain factors of 2 and 5). - * Falls back to precision-preserving RationalNumber arithmetic when exact division isn't possible. + * Create a zero Money instance for a given currency. * - * @param price - Price or ExchangeRate to use for conversion - * @param options - Conversion options (reserved for future use) - * @returns A new Money instance in the target currency - * @throws Error if conversion is not possible or currencies don't match + * @param currency - The currency code or Currency object + * @returns A Money instance with zero value * * @example - * const usd = Money("$100") - * const btcPrice = new Price(Money("$50,000"), Money("1 BTC")) - * const btc = usd.convert(btcPrice) // Returns Money with BTC amount + * import { Money } from '@thesis-co/cent'; + * + * Money.zero("USD") // $0.00 + * Money.zero("BTC") // 0 BTC + * Money.zero("EUR") // €0.00 */ - convert( - price: import("../prices").Price | import("../exchange-rates").ExchangeRate, + static zero(currency: string | Currency): Money { + const curr = + typeof currency === "string" ? getCurrencyFromCode(currency) : currency + + if (!curr) { + throw new InvalidInputError(`Unknown currency: "${currency}"`, { + code: ErrorCode.UNKNOWN_CURRENCY, + suggestion: "Use a valid currency code like 'USD', 'EUR', or 'BTC'.", + }) + } + + return new Money({ + asset: curr, + amount: { + amount: 0n, + decimals: curr.decimals, + }, + }) + } + + /** + * Sum an array of Money instances. + * + * All amounts must be in the same currency. If the array is empty, + * either provide a default value or an EmptyArrayError is thrown. + * + * @param amounts - Array of Money instances to sum + * @param defaultValue - Optional default value to return for empty arrays + * @returns The sum of all amounts + * @throws EmptyArrayError if array is empty and no default provided + * @throws CurrencyMismatchError if amounts have different currencies + * + * @example + * import { Money } from '@thesis-co/cent'; + * + * const items = [Money("$10.00"), Money("$20.00"), Money("$30.00")]; + * Money.sum(items); // $60.00 + * + * // With default for empty arrays + * Money.sum([], Money.zero("USD")); // $0.00 + * + * // Mixed currencies throw + * Money.sum([Money("$10"), Money("€20")]); // throws CurrencyMismatchError + */ + static sum(amounts: Money[], defaultValue?: Money): Money { + if (amounts.length === 0) { + if (defaultValue !== undefined) { + return defaultValue + } + throw new EmptyArrayError("sum", { + suggestion: + "Provide at least one Money instance, or use a default value: Money.sum([], Money.zero('USD'))", + example: 'Money.sum([Money("$10"), Money("$20")])', + }) + } + + let result = amounts[0] + for (let i = 1; i < amounts.length; i++) { + result = result.add(amounts[i]) + } + return result + } + + /** + * Calculate the average of an array of Money instances. + * + * All amounts must be in the same currency. + * + * @param amounts - Array of Money instances to average + * @param round - Optional rounding mode for the result + * @returns The average of all amounts + * @throws EmptyArrayError if array is empty + * @throws CurrencyMismatchError if amounts have different currencies + * + * @example + * import { Money, Round } from '@thesis-co/cent'; + * + * const prices = [Money("$10.00"), Money("$20.00"), Money("$30.00")]; + * Money.avg(prices); // $20.00 + * + * // With rounding + * const odd = [Money("$10.00"), Money("$20.00")]; + * Money.avg([...odd, Money("$5.00")], Round.HALF_UP); // $11.67 + */ + static avg(amounts: Money[], round?: RoundingMode): Money { + if (amounts.length === 0) { + throw new EmptyArrayError("avg", { + suggestion: "Provide at least one Money instance to calculate an average.", + example: 'Money.avg([Money("$10"), Money("$20"), Money("$30")])', + }) + } + + const sum = Money.sum(amounts) + return sum.divide(amounts.length, round) + } + + /** + * Find the minimum value among Money instances. + * + * Accepts either multiple arguments or a single array. + * All amounts must be in the same currency. + * + * @param amounts - Money instances to compare (variadic or array) + * @returns The minimum Money value + * @throws EmptyArrayError if no amounts provided + * @throws CurrencyMismatchError if amounts have different currencies + * + * @example + * import { Money } from '@thesis-co/cent'; + * + * // Variadic form + * Money.min(Money("$30"), Money("$10"), Money("$20")); // $10.00 + * + * // Array form + * const prices = [Money("$30"), Money("$10"), Money("$20")]; + * Money.min(prices); // $10.00 + */ + static min(...amounts: Money[] | [Money[]]): Money { + // Handle both variadic and array form + const arr = + amounts.length === 1 && Array.isArray(amounts[0]) + ? amounts[0] + : (amounts as Money[]) + + if (arr.length === 0) { + throw new EmptyArrayError("min", { + suggestion: "Provide at least one Money instance to find the minimum.", + example: 'Money.min(Money("$10"), Money("$20"), Money("$30"))', + }) + } + + let result = arr[0] + for (let i = 1; i < arr.length; i++) { + if (arr[i].lessThan(result)) { + result = arr[i] + } + } + return result + } + + /** + * Find the maximum value among Money instances. + * + * Accepts either multiple arguments or a single array. + * All amounts must be in the same currency. + * + * @param amounts - Money instances to compare (variadic or array) + * @returns The maximum Money value + * @throws EmptyArrayError if no amounts provided + * @throws CurrencyMismatchError if amounts have different currencies + * + * @example + * import { Money } from '@thesis-co/cent'; + * + * // Variadic form + * Money.max(Money("$10"), Money("$30"), Money("$20")); // $30.00 + * + * // Array form + * const prices = [Money("$10"), Money("$30"), Money("$20")]; + * Money.max(prices); // $30.00 + */ + static max(...amounts: Money[] | [Money[]]): Money { + // Handle both variadic and array form + const arr = + amounts.length === 1 && Array.isArray(amounts[0]) + ? amounts[0] + : (amounts as Money[]) + + if (arr.length === 0) { + throw new EmptyArrayError("max", { + suggestion: "Provide at least one Money instance to find the maximum.", + example: 'Money.max(Money("$10"), Money("$20"), Money("$30"))', + }) + } + + let result = arr[0] + for (let i = 1; i < arr.length; i++) { + if (arr[i].greaterThan(result)) { + result = arr[i] + } + } + return result + } + + /** + * Parse a string into a Money instance, returning a Result instead of throwing. + * + * This is useful for handling user input or external data where you want + * to handle errors programmatically without try/catch. + * + * @param input - The string to parse (e.g., "$100.00", "100 USD", "€50") + * @returns A Result containing either the Money or a ParseError + * + * @example + * import { Money } from '@thesis-co/cent'; + * + * // Success case + * const result = Money.parse("$100.00"); + * if (result.ok) { + * console.log(result.value.toString()); // "$100.00" + * } + * + * // Error case + * const invalid = Money.parse("not money"); + * if (!invalid.ok) { + * console.log(invalid.error.suggestion); // helpful message + * } + * + * // Pattern matching + * Money.parse(userInput).match({ + * ok: (money) => processPayment(money), + * err: (error) => showError(error.message), + * }); + * + * // With default value + * const amount = Money.parse(input).unwrapOr(Money.zero("USD")); + */ + static parse(input: string): Result { + try { + const money = MoneyFactory(input) + return ok(money) + } catch (e) { + if (e instanceof ParseError) { + return err(e) + } + // Convert other errors to ParseError + const message = e instanceof Error ? e.message : String(e) + return err( + new ParseError(input, message, { + code: ErrorCode.PARSE_ERROR, + suggestion: 'Use a valid money format like "$100.00", "100 USD", or "€50".', + }) + ) + } + } + + /** + * Try to create a Money instance from various input types, returning a Result. + * + * This is a more general version of `parse` that accepts any input type + * that Money() normally accepts. + * + * @param input - Any valid Money input (string, number, bigint, AssetAmount, JSON) + * @param currency - Currency code (required for number/bigint inputs) + * @returns A Result containing either the Money or a CentError + * + * @example + * import { Money } from '@thesis-co/cent'; + * + * // String input + * Money.tryFrom("$100.00") + * + * // Number input (requires currency) + * Money.tryFrom(100.50, "USD") + * + * // Bigint input (requires currency) + * Money.tryFrom(10050n, "USD") + * + * // JSON input + * Money.tryFrom({ amount: "100.00", currency: "USD" }) + * + * // Safe processing of user input + * Money.tryFrom(userInput, "USD").match({ + * ok: (money) => saveToDatabase(money), + * err: (error) => logError(error), + * }); + */ + static tryFrom( + input: string | number | bigint | AssetAmount | unknown, + currency?: string | Currency + ): Result { + try { + let money: Money + if (typeof input === "number" || typeof input === "bigint") { + if (!currency) { + return err( + new InvalidInputError( + "Currency is required for number or bigint input", + { + code: ErrorCode.INVALID_INPUT, + suggestion: + 'Provide a currency code: Money.tryFrom(100, "USD")', + } + ) + ) + } + money = MoneyFactory(input as number, currency) + } else if (typeof input === "string") { + money = MoneyFactory(input) + } else { + money = MoneyFactory(input) + } + return ok(money) + } catch (e) { + if (e instanceof CentError) { + return err(e) + } + // Convert other errors to InvalidInputError + const message = e instanceof Error ? e.message : String(e) + return err( + new InvalidInputError(message, { + code: ErrorCode.INVALID_INPUT, + suggestion: "Check the input format and try again.", + }) + ) + } + } + + /** + * Type guard to check if a value is a Money instance. + * + * Optionally checks if the Money is in a specific currency. + * + * @param value - The value to check + * @param currency - Optional currency code to match + * @returns True if value is Money (optionally matching currency) + * + * @example + * import { Money } from '@thesis-co/cent'; + * + * function processPayment(amount: unknown) { + * if (Money.isMoney(amount)) { + * // TypeScript knows amount is Money here + * console.log(amount.toString()); + * } + * } + * + * // With currency check + * if (Money.isMoney(value, "USD")) { + * // value is Money in USD + * } + */ + static isMoney(value: unknown, currency?: string): value is Money { + if (!(value instanceof Money)) { + return false + } + if (currency !== undefined) { + return value.currency.code === currency + } + return true + } + + /** + * Assert that a value is a Money instance, throwing if not. + * + * @param value - The value to check + * @param message - Optional custom error message + * @throws ValidationError if value is not Money + * + * @example + * import { Money } from '@thesis-co/cent'; + * + * function processPayment(amount: unknown) { + * Money.assertMoney(amount); + * // TypeScript knows amount is Money after this point + * return amount.multiply(2n); + * } + */ + static assertMoney( + value: unknown, + message?: string + ): asserts value is Money { + if (!(value instanceof Money)) { + throw new ValidationError( + message ?? `Expected Money instance, got ${typeof value}`, + { + suggestion: 'Use Money("$100") or Money.parse() to create Money instances.', + } + ) + } + } + + /** + * Assert that a Money instance has a positive value (greater than zero). + * + * @param money - The Money instance to check + * @param message - Optional custom error message + * @throws ValidationError if money is not positive + * + * @example + * import { Money } from '@thesis-co/cent'; + * + * Money.assertPositive(Money("$100")); // OK + * Money.assertPositive(Money("$0")); // throws + * Money.assertPositive(Money("-$50")); // throws + */ + static assertPositive(money: Money, message?: string): void { + if (!money.isPositive()) { + throw new ValidationError( + message ?? `Expected positive amount, got ${money.toString()}`, + { + code: ErrorCode.INVALID_RANGE, + suggestion: "Provide an amount greater than zero.", + } + ) + } + } + + /** + * Assert that a Money instance has a non-negative value (zero or greater). + * + * @param money - The Money instance to check + * @param message - Optional custom error message + * @throws ValidationError if money is negative + * + * @example + * import { Money } from '@thesis-co/cent'; + * + * Money.assertNonNegative(Money("$100")); // OK + * Money.assertNonNegative(Money("$0")); // OK + * Money.assertNonNegative(Money("-$50")); // throws + */ + static assertNonNegative(money: Money, message?: string): void { + if (money.isNegative()) { + throw new ValidationError( + message ?? `Expected non-negative amount, got ${money.toString()}`, + { + code: ErrorCode.INVALID_RANGE, + suggestion: "Provide an amount greater than or equal to zero.", + } + ) + } + } + + /** + * Assert that a Money instance has a non-zero value. + * + * @param money - The Money instance to check + * @param message - Optional custom error message + * @throws ValidationError if money is zero + * + * @example + * import { Money } from '@thesis-co/cent'; + * + * Money.assertNonZero(Money("$100")); // OK + * Money.assertNonZero(Money("-$50")); // OK + * Money.assertNonZero(Money("$0")); // throws + */ + static assertNonZero(money: Money, message?: string): void { + if (money.isZero()) { + throw new ValidationError( + message ?? `Expected non-zero amount, got ${money.toString()}`, + { + code: ErrorCode.INVALID_RANGE, + suggestion: "Provide a non-zero amount.", + } + ) + } + } + + /** + * Validate this Money instance against constraints, returning a Result. + * + * This is useful for validating user input or business rules without + * throwing exceptions. + * + * @param options - Validation constraints + * @returns Result containing this Money if valid, or ValidationError if not + * + * @example + * import { Money } from '@thesis-co/cent'; + * + * const amount = Money("$50"); + * + * // Single constraint + * amount.validate({ positive: true }); + * + * // Multiple constraints + * amount.validate({ + * min: Money("$10"), + * max: Money("$1000"), + * positive: true, + * }); + * + * // Use with Result methods + * Money("$50").validate({ min: "$100" }).match({ + * ok: (money) => processPayment(money), + * err: (error) => showError(error.message), + * }); + */ + validate(options: { + min?: Money | string | number + max?: Money | string | number + positive?: boolean + nonNegative?: boolean + nonZero?: boolean + }): Result { + const { min, max, positive, nonNegative, nonZero } = options + + // Check positive constraint + if (positive && !this.isPositive()) { + return err( + new ValidationError( + `Amount must be positive, got ${this.toString()}`, + { + code: ErrorCode.INVALID_RANGE, + suggestion: "Provide an amount greater than zero.", + } + ) + ) + } + + // Check nonNegative constraint + if (nonNegative && this.isNegative()) { + return err( + new ValidationError( + `Amount must be non-negative, got ${this.toString()}`, + { + code: ErrorCode.INVALID_RANGE, + suggestion: "Provide an amount greater than or equal to zero.", + } + ) + ) + } + + // Check nonZero constraint + if (nonZero && this.isZero()) { + return err( + new ValidationError( + `Amount must be non-zero, got ${this.toString()}`, + { + code: ErrorCode.INVALID_RANGE, + suggestion: "Provide a non-zero amount.", + } + ) + ) + } + + // Check min constraint + if (min !== undefined) { + const minMoney = min instanceof Money ? min : this.parseComparable(min) + if (this.lessThan(minMoney)) { + return err( + new ValidationError( + `Amount ${this.toString()} is less than minimum ${minMoney.toString()}`, + { + code: ErrorCode.INVALID_RANGE, + suggestion: `Provide an amount of at least ${minMoney.toString()}.`, + } + ) + ) + } + } + + // Check max constraint + if (max !== undefined) { + const maxMoney = max instanceof Money ? max : this.parseComparable(max) + if (this.greaterThan(maxMoney)) { + return err( + new ValidationError( + `Amount ${this.toString()} is greater than maximum ${maxMoney.toString()}`, + { + code: ErrorCode.INVALID_RANGE, + suggestion: `Provide an amount of at most ${maxMoney.toString()}.`, + } + ) + ) + } + } + + return ok(this) + } + + /** + * Parse a comparable value (string or number) to Money in this currency. + * @internal + */ + private parseComparable(value: string | number): Money { + if (typeof value === "number") { + return MoneyFactory(value, this.currency) + } + // Try parsing as Money string, fall back to assuming same currency + try { + return MoneyFactory(value) + } catch { + // If parsing fails, try as raw number string with same currency + return MoneyFactory( + parseFloat(value), + this.currency + ) + } + } + + /** + * Clamp this Money value to be within the specified bounds. + * + * Returns a new Money instance that is: + * - `min` if this value is less than `min` + * - `max` if this value is greater than `max` + * - this value if it's within bounds + * + * @param min - The minimum bound (Money, string, or number) + * @param max - The maximum bound (Money, string, or number) + * @returns A new Money instance clamped to the bounds + * @throws InvalidInputError if min > max + * @throws CurrencyMismatchError if currencies don't match + * + * @example + * import { Money } from '@thesis-co/cent'; + * + * Money("$50").clamp("$0", "$100") // $50.00 (within bounds) + * Money("-$50").clamp("$0", "$100") // $0.00 (below min) + * Money("$150").clamp("$0", "$100") // $100.00 (above max) + * + * // With Money instances + * Money("$50").clamp(Money("$0"), Money("$100")) + * + * // With numbers (interpreted in same currency) + * Money("$50").clamp(0, 100) + */ + clamp(min: Money | string | number, max: Money | string | number): Money { + const minMoney = min instanceof Money ? min : this.parseComparable(min) + const maxMoney = max instanceof Money ? max : this.parseComparable(max) + + // Validate currencies match + if (minMoney.currency.code !== this.currency.code) { + throw new CurrencyMismatchError( + "clamp", + this.currency.code, + minMoney.currency.code + ) + } + if (maxMoney.currency.code !== this.currency.code) { + throw new CurrencyMismatchError( + "clamp", + this.currency.code, + maxMoney.currency.code + ) + } + + // Validate min <= max + if (minMoney.greaterThan(maxMoney)) { + throw new InvalidInputError( + `Invalid clamp bounds: min (${minMoney.toString()}) is greater than max (${maxMoney.toString()})`, + { + suggestion: "Ensure min is less than or equal to max.", + } + ) + } + + if (this.lessThan(minMoney)) { + return minMoney + } + if (this.greaterThan(maxMoney)) { + return maxMoney + } + return this + } + + /** + * Return the larger of this value and the specified minimum. + * + * Equivalent to `clamp(min, Infinity)` - ensures the value is at least `min`. + * + * @param min - The minimum bound (Money, string, or number) + * @returns This value if >= min, otherwise min + * @throws CurrencyMismatchError if currencies don't match + * + * @example + * import { Money } from '@thesis-co/cent'; + * + * Money("$50").atLeast("$0") // $50.00 (already above min) + * Money("-$50").atLeast("$0") // $0.00 (raised to min) + * + * // Ensure non-negative amounts + * const safeAmount = amount.atLeast(0) + * + * // With Money instance + * Money("$25").atLeast(Money("$50")) // $50.00 + */ + atLeast(min: Money | string | number): Money { + const minMoney = min instanceof Money ? min : this.parseComparable(min) + + // Validate currency matches + if (minMoney.currency.code !== this.currency.code) { + throw new CurrencyMismatchError( + "atLeast", + this.currency.code, + minMoney.currency.code + ) + } + + if (this.lessThan(minMoney)) { + return minMoney + } + return this + } + + /** + * Return the smaller of this value and the specified maximum. + * + * Equivalent to `clamp(-Infinity, max)` - ensures the value is at most `max`. + * + * @param max - The maximum bound (Money, string, or number) + * @returns This value if <= max, otherwise max + * @throws CurrencyMismatchError if currencies don't match + * + * @example + * import { Money } from '@thesis-co/cent'; + * + * Money("$50").atMost("$100") // $50.00 (already below max) + * Money("$150").atMost("$100") // $100.00 (reduced to max) + * + * // Cap at maximum allowed amount + * const cappedAmount = amount.atMost("$10000") + * + * // With Money instance + * Money("$75").atMost(Money("$50")) // $50.00 + */ + atMost(max: Money | string | number): Money { + const maxMoney = max instanceof Money ? max : this.parseComparable(max) + + // Validate currency matches + if (maxMoney.currency.code !== this.currency.code) { + throw new CurrencyMismatchError( + "atMost", + this.currency.code, + maxMoney.currency.code + ) + } + + if (this.greaterThan(maxMoney)) { + return maxMoney + } + return this + } + + /** + * Convert this Money instance to a localized string representation + * + * @param options - Formatting options for the string representation + * @returns A formatted string representation + */ + toString(options: MoneyToStringOptions = {}): string { + const { + locale = "en-US", + compact = false, + maxDecimals, + minDecimals, + preferredUnit, + preferSymbol = false, + preferFractionalSymbol = false, + roundingMode, + excludeCurrency = false, + } = options + + // Convert RationalNumber to FixedPointNumber for formatting + const formattingMoney = isRationalNumber(this.amount) + ? new Money(this.currency, toFixedPointNumber(this.amount)) + : this + + // Determine if we should use ISO 4217 formatting + const useIsoFormatting = shouldUseIsoFormatting(formattingMoney.currency) + + if (useIsoFormatting) { + return formatWithIntlCurrency( + formattingMoney as unknown as import("./formatting").MoneyLike, + locale, + compact, + maxDecimals, + minDecimals, + roundingMode, + excludeCurrency, + ) + } + return formatWithCustomFormatting( + formattingMoney as unknown as import("./formatting").MoneyLike, + locale, + compact, + maxDecimals, + minDecimals, + preferredUnit, + preferSymbol, + preferFractionalSymbol, + roundingMode, + excludeCurrency, + ) + } + + /** + * Convert this Money instance to another currency using a price or exchange rate + * + * Uses lossless conversion when possible (when price denominators only contain factors of 2 and 5). + * Falls back to precision-preserving RationalNumber arithmetic when exact division isn't possible. + * + * @param price - Price or ExchangeRate to use for conversion + * @param options - Conversion options (reserved for future use) + * @returns A new Money instance in the target currency + * @throws Error if conversion is not possible or currencies don't match + * + * @example + * const usd = Money("$100") + * const btcPrice = new Price(Money("$50,000"), Money("1 BTC")) + * const btc = usd.convert(btcPrice) // Returns Money with BTC amount + */ + convert( + price: import("../prices").Price | import("../exchange-rates").ExchangeRate, ): Money { // Handle different price types let money1: Money @@ -1112,8 +2611,13 @@ export class Money { fromMoney = money2 toMoney = money1 } else { - throw new Error( - `Cannot convert ${this.currency.code || this.currency.name} using price with currencies ${money1.currency.code || money1.currency.name} and ${money2.currency.code || money2.currency.name}`, + throw new CurrencyMismatchError( + this.currency.code || this.currency.name, + `${money1.currency.code || money1.currency.name}/${money2.currency.code || money2.currency.name}`, + "convert", + { + suggestion: `The price or exchange rate must include ${this.currency.code || this.currency.name} as one of its currencies.`, + }, ) } @@ -1156,39 +2660,201 @@ export class Money { } /** - * Factory function for creating Money instances from string representations - * Also supports original constructor pattern with AssetAmount + * Parse a percentage string into a decimal multiplier. + * Supports formats: "8.25%", "8.25 %", "8.25percent", "8.25 percent" + * + * @param input - The percentage string to parse + * @returns The parsed percentage as a FixedPointNumber (e.g., "8.25%" becomes 0.0825) + * or null if the input is not a percentage string + * @internal + */ +function parsePercentage(input: string): FixedPointNumber | null { + const trimmed = input.trim() + + // Match patterns like "8.25%", "8.25 %", "8.25percent", "8.25 percent" + const percentMatch = trimmed.match( + /^(-?\d+(?:\.\d+)?)\s*(%|percent)$/i, + ) + + if (!percentMatch) { + return null + } + + const percentValue = percentMatch[1] + + // Convert percentage to decimal (divide by 100) + const valueFixed = FixedPointNumber.fromDecimalString(percentValue) + const hundred = new FixedPointNumber(100n, 0n) + + return valueFixed.divide(hundred) +} + +/** + * Check if a string is a percentage string. + * @internal + */ +function isPercentageString(input: string): boolean { + return parsePercentage(input) !== null +} + +/** + * Validate a JavaScript number input based on configuration. + * @internal + */ +function validateNumberInput(value: number, currencyCode: string): void { + const config = getConfig() + + // Check for NaN/Infinity first (always an error) + if (!Number.isFinite(value)) { + throw new InvalidInputError( + `Invalid number input: ${value}`, + { + code: ErrorCode.INVALID_INPUT, + suggestion: "Use a finite number value.", + }, + ) + } + + // If 'never' mode, reject all number inputs + if (config.numberInputMode === "never") { + throw new InvalidInputError( + `Number inputs are not allowed (numberInputMode: 'never')`, + { + code: ErrorCode.INVALID_INPUT, + suggestion: `Use a string instead: Money("${value} ${currencyCode}")`, + example: `Money("${value} ${currencyCode}")`, + }, + ) + } + + // If 'silent' mode, allow everything + if (config.numberInputMode === "silent") { + return + } + + // Check for potential precision loss + const hasPrecisionIssue = + !Number.isSafeInteger(value) || + (value.toString().includes(".") && + value.toString().split(".")[1].length > config.precisionWarningThreshold) + + if (hasPrecisionIssue) { + const message = + `Number ${value} may lose precision. ` + + `Use a string for exact values: Money("${value} ${currencyCode}")` + + if (config.numberInputMode === "error") { + throw new PrecisionLossError(message, { + suggestion: `Use a string instead: Money("${value} ${currencyCode}")`, + example: `Money("${value} ${currencyCode}")`, + }) + } + + // 'warn' mode + console.warn(`[cent] ${message}`) + } +} + +/** + * Factory function for creating Money instances from various inputs. * * Supports multiple formats: - * - Currency symbols: "$100", "€1,234.56", "£50.25" - * - Currency codes: "USD 100", "100 EUR", "JPY 1,000" - * - Crypto main units: "₿1.5", "BTC 0.001", "ETH 2.5" - * - Crypto sub-units: "1000 sat", "100000 wei", "50 gwei" - * - Number formats: US (1,234.56) and EU (1.234,56) + * - Strings: "$100", "€1,234.56", "BTC 0.001", "1000 sat" + * - Numbers: Money(100.50, "USD") - requires currency code + * - Bigints: Money(10050n, "USD") - interpreted as minor units (cents) + * - AssetAmount objects + * - JSON objects * - * Symbol disambiguation uses trading volume priority: - * $ → USD, £ → GBP, ¥ → JPY, € → EUR, etc. - * Use currency codes for non-primary currencies. + * Number inputs are validated based on the `numberInputMode` configuration: + * - `'warn'`: Log warning for imprecise numbers (default) + * - `'error'`: Throw error for imprecise numbers + * - `'silent'`: Allow all numbers + * - `'never'`: Throw error for ANY number input * - * @param input - String representation of money amount or AssetAmount - * @returns Money instance - * @throws Error for invalid format or unknown currency + * @example + * // String parsing + * Money("$100.50") // USD $100.50 + * Money("€1.234,56") // EUR €1,234.56 (EU format) + * Money("1000 sat") // BTC 0.00001000 (satoshis) + * + * @example + * // Number input (requires currency) + * Money(100.50, "USD") // USD $100.50 + * Money(99.99, "EUR") // EUR €99.99 * * @example - * Money("$100.50") // USD $100.50 - * Money("€1.234,56") // EUR €1,234.56 (EU format) - * Money("JPY 1,000") // JPY ¥1,000 (no decimals) - * Money("1000 sat") // BTC 0.00001000 (satoshis) - * Money("100 gwei") // ETH 0.0000001 (gwei) + * // Bigint input as minor units (requires currency) + * Money(10050n, "USD") // USD $100.50 (10050 cents) + * Money(100000000n, "BTC") // BTC 1.00000000 (100M satoshis) */ export function MoneyFactory(input: string): Money +export function MoneyFactory(amount: number, currency: string | Currency): Money +export function MoneyFactory(minorUnits: bigint, currency: string | Currency): Money export function MoneyFactory(balance: AssetAmount): Money export function MoneyFactory(json: unknown): Money export function MoneyFactory( - inputOrBalanceOrJson: string | AssetAmount | unknown, + inputOrBalanceOrJson: string | number | bigint | AssetAmount | unknown, + currency?: string | Currency, ): Money { + // Number input mode + if (typeof inputOrBalanceOrJson === "number") { + if (currency === undefined) { + throw new InvalidInputError( + "Currency is required when using number input", + { + suggestion: 'Provide a currency code: Money(100.50, "USD")', + example: 'Money(100.50, "USD")', + }, + ) + } + + const currencyObj = typeof currency === "string" + ? getCurrencyFromCode(currency) + : currency + + validateNumberInput(inputOrBalanceOrJson, currencyObj.code || currencyObj.name) + + // Convert number to fixed-point representation + const str = inputOrBalanceOrJson.toString() + const fp = FixedPointNumber.fromDecimalString(str) + + return new Money({ + asset: currencyObj, + amount: { + amount: fp.amount, + decimals: fp.decimals, + }, + }) + } + + // Bigint input mode (minor units) + if (typeof inputOrBalanceOrJson === "bigint") { + if (currency === undefined) { + throw new InvalidInputError( + "Currency is required when using bigint input", + { + suggestion: 'Provide a currency code: Money(10050n, "USD")', + example: 'Money(10050n, "USD") // 10050 cents = $100.50', + }, + ) + } + + const currencyObj = typeof currency === "string" + ? getCurrencyFromCode(currency) + : currency + + // Bigint is interpreted as minor units (e.g., cents for USD, satoshis for BTC) + return new Money({ + asset: currencyObj, + amount: { + amount: inputOrBalanceOrJson, + decimals: currencyObj.decimals, + }, + }) + } + + // String parsing mode if (typeof inputOrBalanceOrJson === "string") { - // String parsing mode const parseResult = parseMoneyString(inputOrBalanceOrJson) return new Money({ diff --git a/packages/cent/src/result.ts b/packages/cent/src/result.ts new file mode 100644 index 0000000..00f2801 --- /dev/null +++ b/packages/cent/src/result.ts @@ -0,0 +1,276 @@ +/** + * Result type for representing success or failure without exceptions. + * + * The Result type is a discriminated union that can be either `Ok` (success) + * or `Err` (failure). This enables programmatic error handling without + * try/catch blocks. + * + * @example + * import { Money, Ok, Err } from '@thesis-co/cent'; + * + * const result = Money.parse("$100.00"); + * + * // Pattern matching + * const message = result.match({ + * ok: (money) => `Parsed: ${money.toString()}`, + * err: (error) => `Failed: ${error.message}`, + * }); + * + * // Conditional check + * if (result.ok) { + * console.log(result.value.toString()); + * } else { + * console.log(result.error.message); + * } + * + * // With default value + * const money = Money.parse(userInput).unwrapOr(Money.zero("USD")); + */ + +/** + * Represents a successful result containing a value. + */ +export class Ok { + readonly ok = true as const + + constructor(readonly value: T) {} + + /** + * Transform the success value using a function. + * + * @param fn - Function to transform the value + * @returns A new Ok with the transformed value + * + * @example + * Ok(10).map(x => x * 2) // Ok(20) + */ + map(fn: (value: T) => U): Result { + return new Ok(fn(this.value)) + } + + /** + * Transform the success value using a function that returns a Result. + * Flattens nested Results. + * + * @param fn - Function that returns a Result + * @returns The Result from the function + * + * @example + * Ok(10).flatMap(x => x > 0 ? Ok(x) : Err("negative")) + */ + flatMap(fn: (value: T) => Result): Result { + return fn(this.value) + } + + /** + * Return the success value, or throw if this is an error. + * + * @returns The success value + * + * @example + * Ok(42).unwrap() // 42 + */ + unwrap(): T { + return this.value + } + + /** + * Return the success value, or a default if this is an error. + * + * @param _defaultValue - The default value (unused for Ok) + * @returns The success value + * + * @example + * Ok(42).unwrapOr(0) // 42 + */ + unwrapOr(_defaultValue: T): T { + return this.value + } + + /** + * Return the success value, or compute a default if this is an error. + * + * @param _fn - Function to compute default (unused for Ok) + * @returns The success value + * + * @example + * Ok(42).unwrapOrElse(() => 0) // 42 + */ + unwrapOrElse(_fn: () => T): T { + return this.value + } + + /** + * Pattern match on the result, executing the appropriate handler. + * + * @param handlers - Object with `ok` and `err` handler functions + * @returns The result of the matching handler + * + * @example + * Ok(42).match({ + * ok: (v) => `Success: ${v}`, + * err: (e) => `Error: ${e}`, + * }) // "Success: 42" + */ + match(handlers: { ok: (value: T) => U; err: (error: never) => U }): U { + return handlers.ok(this.value) + } + + /** + * Check if this is an Ok result. + * Useful for type narrowing in conditionals. + */ + isOk(): this is Ok { + return true + } + + /** + * Check if this is an Err result. + * Useful for type narrowing in conditionals. + */ + isErr(): this is Err { + return false + } +} + +/** + * Represents a failed result containing an error. + */ +export class Err { + readonly ok = false as const + + constructor(readonly error: E) {} + + /** + * Transform the success value (no-op for Err). + * + * @param _fn - Function to transform the value (unused) + * @returns This Err unchanged + * + * @example + * Err("error").map(x => x * 2) // Err("error") + */ + map(_fn: (value: never) => U): Result { + return this as unknown as Result + } + + /** + * Transform the success value (no-op for Err). + * + * @param _fn - Function that returns a Result (unused) + * @returns This Err unchanged + */ + flatMap(_fn: (value: never) => Result): Result { + return this as unknown as Result + } + + /** + * Throw the error since this is not a success. + * + * @throws The contained error + * + * @example + * Err(new Error("oops")).unwrap() // throws Error("oops") + */ + unwrap(): never { + throw this.error + } + + /** + * Return the default value since this is an error. + * + * @param defaultValue - The default value to return + * @returns The default value + * + * @example + * Err("error").unwrapOr(42) // 42 + */ + unwrapOr(defaultValue: T): T { + return defaultValue + } + + /** + * Compute and return a default value since this is an error. + * + * @param fn - Function to compute the default value + * @returns The computed default value + * + * @example + * Err("error").unwrapOrElse(() => 42) // 42 + */ + unwrapOrElse(fn: () => T): T { + return fn() + } + + /** + * Pattern match on the result, executing the error handler. + * + * @param handlers - Object with `ok` and `err` handler functions + * @returns The result of the error handler + * + * @example + * Err("oops").match({ + * ok: (v) => `Success: ${v}`, + * err: (e) => `Error: ${e}`, + * }) // "Error: oops" + */ + match(handlers: { ok: (value: never) => U; err: (error: E) => U }): U { + return handlers.err(this.error) + } + + /** + * Check if this is an Ok result. + * Useful for type narrowing in conditionals. + */ + isOk(): this is Ok { + return false + } + + /** + * Check if this is an Err result. + * Useful for type narrowing in conditionals. + */ + isErr(): this is Err { + return true + } +} + +/** + * A Result is either Ok (success) or Err (failure). + * Use this type for operations that may fail in expected ways. + */ +export type Result = Ok | Err + +/** + * Create a successful Result containing a value. + * + * @param value - The success value + * @returns An Ok Result + * + * @example + * import { Ok } from '@thesis-co/cent'; + * + * const result = Ok(42); + * result.ok // true + * result.value // 42 + */ +export function ok(value: T): Ok { + return new Ok(value) +} + +/** + * Create a failed Result containing an error. + * + * @param error - The error value + * @returns An Err Result + * + * @example + * import { Err } from '@thesis-co/cent'; + * + * const result = Err(new Error("something went wrong")); + * result.ok // false + * result.error // Error: something went wrong + */ +export function err(error: E): Err { + return new Err(error) +} diff --git a/packages/cent/src/rounding.ts b/packages/cent/src/rounding.ts new file mode 100644 index 0000000..c4b55dd --- /dev/null +++ b/packages/cent/src/rounding.ts @@ -0,0 +1,100 @@ +/** + * Rounding modes for money operations. + * + * Maps common rounding mode names (UP, DOWN, HALF_UP, etc.) to the + * underlying Intl.NumberFormat rounding modes. + * + * @example + * import { Money, Round } from '@thesis-co/cent'; + * + * const price = Money("$100.00"); + * price.divide(3, Round.HALF_UP); // $33.33 + * price.divide(3, Round.HALF_EVEN); // $33.33 (banker's rounding) + * price.divide(3, Round.CEILING); // $33.34 + * + * @see {@link https://tc39.es/ecma402/#sec-intl.numberformat-internal-slots} + */ + +import { RoundingMode } from "./types" + +/** + * Rounding modes for money arithmetic operations. + * + * These modes determine how to handle values that fall exactly between + * two representable values (ties) and general rounding direction. + */ +export const Round = { + /** + * Round away from zero. + * - Positive: round toward +∞ + * - Negative: round toward -∞ + * + * @example + * 2.1 → 3, 2.5 → 3, 2.9 → 3 + * -2.1 → -3, -2.5 → -3, -2.9 → -3 + */ + UP: RoundingMode.EXPAND, + + /** + * Round toward zero (truncate). + * - Positive: round toward -∞ + * - Negative: round toward +∞ + * + * @example + * 2.1 → 2, 2.5 → 2, 2.9 → 2 + * -2.1 → -2, -2.5 → -2, -2.9 → -2 + */ + DOWN: RoundingMode.TRUNC, + + /** + * Round toward positive infinity. + * + * @example + * 2.1 → 3, 2.5 → 3, 2.9 → 3 + * -2.1 → -2, -2.5 → -2, -2.9 → -2 + */ + CEILING: RoundingMode.CEIL, + + /** + * Round toward negative infinity. + * + * @example + * 2.1 → 2, 2.5 → 2, 2.9 → 2 + * -2.1 → -3, -2.5 → -3, -2.9 → -3 + */ + FLOOR: RoundingMode.FLOOR, + + /** + * Round to nearest, ties away from zero. + * This is the most common "commercial" rounding mode. + * + * @example + * 2.4 → 2, 2.5 → 3, 2.6 → 3 + * -2.4 → -2, -2.5 → -3, -2.6 → -3 + */ + HALF_UP: RoundingMode.HALF_EXPAND, + + /** + * Round to nearest, ties toward zero. + * + * @example + * 2.4 → 2, 2.5 → 2, 2.6 → 3 + * -2.4 → -2, -2.5 → -2, -2.6 → -3 + */ + HALF_DOWN: RoundingMode.HALF_TRUNC, + + /** + * Round to nearest, ties toward even (banker's rounding). + * Reduces cumulative rounding error over many operations. + * + * @example + * 2.5 → 2, 3.5 → 4, 4.5 → 4, 5.5 → 6 + * -2.5 → -2, -3.5 → -4, -4.5 → -4, -5.5 → -6 + */ + HALF_EVEN: RoundingMode.HALF_EVEN, +} as const + +/** + * Type representing valid rounding modes for money operations. + */ +export type Round = (typeof Round)[keyof typeof Round] diff --git a/packages/cent/test/aggregation.test.ts b/packages/cent/test/aggregation.test.ts new file mode 100644 index 0000000..38fa011 --- /dev/null +++ b/packages/cent/test/aggregation.test.ts @@ -0,0 +1,455 @@ +import { describe, expect, it } from "@jest/globals" +import { + Money, + MoneyClass, + EmptyArrayError, + CurrencyMismatchError, + HALF_EXPAND, + USD, + EUR, + BTC, +} from "../src" + +describe("Static Aggregation Methods", () => { + describe("Money.zero()", () => { + it("creates zero USD", () => { + const zero = MoneyClass.zero("USD") + expect(zero.toString()).toBe("$0.00") + expect(zero.isZero()).toBe(true) + }) + + it("creates zero EUR", () => { + const zero = MoneyClass.zero("EUR") + expect(zero.toString()).toBe("€0.00") + expect(zero.isZero()).toBe(true) + }) + + it("creates zero BTC", () => { + const zero = MoneyClass.zero("BTC") + expect(zero.isZero()).toBe(true) + expect(zero.currency.code).toBe("BTC") + }) + + it("accepts Currency object", () => { + const zero = MoneyClass.zero(USD) + expect(zero.toString()).toBe("$0.00") + }) + + it("throws for unknown currency", () => { + expect(() => MoneyClass.zero("XYZ")).toThrow(/Unsupported currency/) + }) + + it("has correct decimals for each currency", () => { + const usd = MoneyClass.zero("USD") + const btc = MoneyClass.zero("BTC") + + expect(usd.balance.amount.decimals).toBe(2n) + expect(btc.balance.amount.decimals).toBe(8n) + }) + }) + + describe("Money.sum()", () => { + describe("basic functionality", () => { + it("sums an array of Money instances", () => { + const items = [Money("$10.00"), Money("$20.00"), Money("$30.00")] + const result = MoneyClass.sum(items) + expect(result.toString()).toBe("$60.00") + }) + + it("returns single item unchanged", () => { + const items = [Money("$42.50")] + const result = MoneyClass.sum(items) + expect(result.toString()).toBe("$42.50") + }) + + it("handles many items", () => { + const items = Array.from({ length: 100 }, () => Money("$1.00")) + const result = MoneyClass.sum(items) + expect(result.toString()).toBe("$100.00") + }) + + it("handles negative amounts", () => { + const items = [Money("$100.00"), Money("-$30.00"), Money("-$20.00")] + const result = MoneyClass.sum(items) + expect(result.toString()).toBe("$50.00") + }) + + it("handles decimal amounts", () => { + const items = [Money("$10.25"), Money("$20.50"), Money("$30.75")] + const result = MoneyClass.sum(items) + expect(result.toString()).toBe("$61.50") + }) + }) + + describe("empty array handling", () => { + it("throws EmptyArrayError for empty array", () => { + expect(() => MoneyClass.sum([])).toThrow(EmptyArrayError) + }) + + it("returns default value for empty array when provided", () => { + const defaultValue = MoneyClass.zero("USD") + const result = MoneyClass.sum([], defaultValue) + expect(result.toString()).toBe("$0.00") + }) + + it("returns custom default for empty array", () => { + const defaultValue = Money("$100.00") + const result = MoneyClass.sum([], defaultValue) + expect(result.toString()).toBe("$100.00") + }) + }) + + describe("currency validation", () => { + it("throws CurrencyMismatchError for mixed currencies", () => { + const items = [Money("$10.00"), Money("€20.00")] + expect(() => MoneyClass.sum(items)).toThrow(CurrencyMismatchError) + }) + + it("works with same currency different formats", () => { + const items = [Money("$10"), Money("10 USD"), Money("USD 10.00")] + const result = MoneyClass.sum(items) + expect(result.toString()).toBe("$30.00") + }) + }) + }) + + describe("Money.avg()", () => { + describe("basic functionality", () => { + it("calculates average of Money instances", () => { + const items = [Money("$10.00"), Money("$20.00"), Money("$30.00")] + const result = MoneyClass.avg(items, HALF_EXPAND) + expect(result.toString()).toBe("$20.00") + }) + + it("returns single item unchanged", () => { + const items = [Money("$42.50")] + const result = MoneyClass.avg(items, HALF_EXPAND) + expect(result.toString()).toBe("$42.50") + }) + + it("handles two items", () => { + const items = [Money("$10.00"), Money("$20.00")] + const result = MoneyClass.avg(items, HALF_EXPAND) + expect(result.toString()).toBe("$15.00") + }) + + it("handles non-exact averages with rounding", () => { + const items = [Money("$10.00"), Money("$20.00"), Money("$5.00")] + // Sum = $35, avg = $11.666... + const result = MoneyClass.avg(items, HALF_EXPAND) + expect(result.toString()).toBe("$11.67") + }) + }) + + describe("empty array handling", () => { + it("throws EmptyArrayError for empty array", () => { + expect(() => MoneyClass.avg([])).toThrow(EmptyArrayError) + }) + + it("error message mentions avg operation", () => { + try { + MoneyClass.avg([]) + expect.fail("Should have thrown") + } catch (e: unknown) { + expect((e as Error).message).toContain("avg") + } + }) + }) + + describe("currency validation", () => { + it("throws CurrencyMismatchError for mixed currencies", () => { + const items = [Money("$10.00"), Money("€20.00")] + expect(() => MoneyClass.avg(items)).toThrow(CurrencyMismatchError) + }) + }) + }) + + describe("Money.min()", () => { + describe("variadic form", () => { + it("finds minimum with variadic args", () => { + const result = MoneyClass.min( + Money("$30.00"), + Money("$10.00"), + Money("$20.00") + ) + expect(result.toString()).toBe("$10.00") + }) + + it("finds minimum with two args", () => { + const result = MoneyClass.min(Money("$50.00"), Money("$25.00")) + expect(result.toString()).toBe("$25.00") + }) + + it("returns single argument unchanged", () => { + const result = MoneyClass.min(Money("$42.50")) + expect(result.toString()).toBe("$42.50") + }) + }) + + describe("array form", () => { + it("finds minimum from array", () => { + const prices = [Money("$30.00"), Money("$10.00"), Money("$20.00")] + const result = MoneyClass.min(prices) + expect(result.toString()).toBe("$10.00") + }) + + it("handles single-element array", () => { + const result = MoneyClass.min([Money("$42.50")]) + expect(result.toString()).toBe("$42.50") + }) + }) + + describe("edge cases", () => { + it("handles negative amounts", () => { + const result = MoneyClass.min( + Money("$10.00"), + Money("-$5.00"), + Money("$0.00") + ) + expect(result.toString()).toBe("-$5.00") + }) + + it("handles equal amounts", () => { + const result = MoneyClass.min( + Money("$10.00"), + Money("$10.00"), + Money("$10.00") + ) + expect(result.toString()).toBe("$10.00") + }) + + it("handles many items", () => { + const prices = [ + Money("$50.00"), + Money("$25.00"), + Money("$75.00"), + Money("$5.00"), + Money("$100.00"), + ] + const result = MoneyClass.min(prices) + expect(result.toString()).toBe("$5.00") + }) + + it("throws EmptyArrayError for empty array", () => { + expect(() => MoneyClass.min([])).toThrow(EmptyArrayError) + }) + + it("throws EmptyArrayError for no args", () => { + expect(() => MoneyClass.min()).toThrow(EmptyArrayError) + }) + }) + + describe("currency validation", () => { + it("throws for mixed currencies (variadic)", () => { + expect(() => + MoneyClass.min(Money("$10.00"), Money("€20.00")) + ).toThrow(CurrencyMismatchError) + }) + + it("throws for mixed currencies (array)", () => { + expect(() => + MoneyClass.min([Money("$10.00"), Money("€20.00")]) + ).toThrow(CurrencyMismatchError) + }) + }) + }) + + describe("Money.max()", () => { + describe("variadic form", () => { + it("finds maximum with variadic args", () => { + const result = MoneyClass.max( + Money("$10.00"), + Money("$30.00"), + Money("$20.00") + ) + expect(result.toString()).toBe("$30.00") + }) + + it("finds maximum with two args", () => { + const result = MoneyClass.max(Money("$25.00"), Money("$50.00")) + expect(result.toString()).toBe("$50.00") + }) + + it("returns single argument unchanged", () => { + const result = MoneyClass.max(Money("$42.50")) + expect(result.toString()).toBe("$42.50") + }) + }) + + describe("array form", () => { + it("finds maximum from array", () => { + const prices = [Money("$10.00"), Money("$30.00"), Money("$20.00")] + const result = MoneyClass.max(prices) + expect(result.toString()).toBe("$30.00") + }) + + it("handles single-element array", () => { + const result = MoneyClass.max([Money("$42.50")]) + expect(result.toString()).toBe("$42.50") + }) + }) + + describe("edge cases", () => { + it("handles negative amounts", () => { + const result = MoneyClass.max( + Money("-$10.00"), + Money("-$5.00"), + Money("-$20.00") + ) + expect(result.toString()).toBe("-$5.00") + }) + + it("handles equal amounts", () => { + const result = MoneyClass.max( + Money("$10.00"), + Money("$10.00"), + Money("$10.00") + ) + expect(result.toString()).toBe("$10.00") + }) + + it("handles many items", () => { + const prices = [ + Money("$50.00"), + Money("$25.00"), + Money("$75.00"), + Money("$5.00"), + Money("$100.00"), + ] + const result = MoneyClass.max(prices) + expect(result.toString()).toBe("$100.00") + }) + + it("throws EmptyArrayError for empty array", () => { + expect(() => MoneyClass.max([])).toThrow(EmptyArrayError) + }) + + it("throws EmptyArrayError for no args", () => { + expect(() => MoneyClass.max()).toThrow(EmptyArrayError) + }) + }) + + describe("currency validation", () => { + it("throws for mixed currencies (variadic)", () => { + expect(() => + MoneyClass.max(Money("$10.00"), Money("€20.00")) + ).toThrow(CurrencyMismatchError) + }) + + it("throws for mixed currencies (array)", () => { + expect(() => + MoneyClass.max([Money("$10.00"), Money("€20.00")]) + ).toThrow(CurrencyMismatchError) + }) + }) + }) + + describe("practical use cases", () => { + it("calculates shopping cart total", () => { + const cart = [ + Money("$29.99"), + Money("$49.99"), + Money("$15.00"), + Money("$9.99"), + ] + const total = MoneyClass.sum(cart) + expect(total.toString()).toBe("$104.97") + }) + + it("finds cheapest option", () => { + const prices = [ + Money("$299.99"), + Money("$249.99"), + Money("$279.99"), + Money("$259.99"), + ] + const cheapest = MoneyClass.min(prices) + expect(cheapest.toString()).toBe("$249.99") + }) + + it("finds most expensive option", () => { + const prices = [ + Money("$299.99"), + Money("$249.99"), + Money("$379.99"), + Money("$259.99"), + ] + const mostExpensive = MoneyClass.max(prices) + expect(mostExpensive.toString()).toBe("$379.99") + }) + + it("calculates average price", () => { + const prices = [ + Money("$100.00"), + Money("$200.00"), + Money("$150.00"), + Money("$250.00"), + ] + const avg = MoneyClass.avg(prices, HALF_EXPAND) + expect(avg.toString()).toBe("$175.00") + }) + + it("handles empty cart with default", () => { + const emptyCart: ReturnType[] = [] + const total = MoneyClass.sum(emptyCart, MoneyClass.zero("USD")) + expect(total.toString()).toBe("$0.00") + }) + + it("processes transaction list", () => { + const transactions = [ + Money("$500.00"), // deposit + Money("-$50.00"), // withdrawal + Money("$200.00"), // deposit + Money("-$100.00"), // withdrawal + ] + const balance = MoneyClass.sum(transactions) + expect(balance.toString()).toBe("$550.00") + }) + + it("finds price range", () => { + const prices = [ + Money("$15.99"), + Money("$29.99"), + Money("$19.99"), + Money("$24.99"), + ] + const min = MoneyClass.min(prices) + const max = MoneyClass.max(prices) + + expect(min.toString()).toBe("$15.99") + expect(max.toString()).toBe("$29.99") + }) + }) + + describe("cryptocurrency aggregations", () => { + it("sums BTC amounts", () => { + const btcAmounts = [ + Money("0.1 BTC"), + Money("0.25 BTC"), + Money("0.15 BTC"), + ] + const total = MoneyClass.sum(btcAmounts) + expect(total.toString()).toBe("0.5 BTC") + }) + + it("finds min/max ETH", () => { + const ethPrices = [ + Money("1.5 ETH"), + Money("0.5 ETH"), + Money("2.0 ETH"), + ] + expect(MoneyClass.min(ethPrices).toString()).toBe("0.5 ETH") + expect(MoneyClass.max(ethPrices).toString()).toBe("2 ETH") + }) + + it("calculates average crypto amount", () => { + const amounts = [ + Money("1.0 BTC"), + Money("2.0 BTC"), + Money("3.0 BTC"), + ] + const avg = MoneyClass.avg(amounts, HALF_EXPAND) + expect(avg.toString()).toBe("2 BTC") + }) + }) +}) diff --git a/packages/cent/test/bounds.test.ts b/packages/cent/test/bounds.test.ts new file mode 100644 index 0000000..b421483 --- /dev/null +++ b/packages/cent/test/bounds.test.ts @@ -0,0 +1,381 @@ +import { describe, expect, it } from "@jest/globals" +import { + Money, + CurrencyMismatchError, + InvalidInputError, +} from "../src" + +describe("Clamp and Bounds Methods", () => { + describe("clamp()", () => { + describe("with Money instances", () => { + it("returns same value when within bounds", () => { + const result = Money("$50").clamp(Money("$0"), Money("$100")) + expect(result.toString()).toBe("$50.00") + }) + + it("returns min when below bounds", () => { + const result = Money("-$50").clamp(Money("$0"), Money("$100")) + expect(result.toString()).toBe("$0.00") + }) + + it("returns max when above bounds", () => { + const result = Money("$150").clamp(Money("$0"), Money("$100")) + expect(result.toString()).toBe("$100.00") + }) + + it("returns min when equal to min", () => { + const result = Money("$0").clamp(Money("$0"), Money("$100")) + expect(result.toString()).toBe("$0.00") + }) + + it("returns max when equal to max", () => { + const result = Money("$100").clamp(Money("$0"), Money("$100")) + expect(result.toString()).toBe("$100.00") + }) + + it("works with negative bounds", () => { + const result = Money("$50").clamp(Money("-$100"), Money("-$10")) + expect(result.toString()).toBe("-$10.00") + }) + + it("works when value is negative and within bounds", () => { + const result = Money("-$50").clamp(Money("-$100"), Money("$0")) + expect(result.toString()).toBe("-$50.00") + }) + }) + + describe("with string bounds", () => { + it("parses currency strings", () => { + const result = Money("$50").clamp("$0", "$100") + expect(result.toString()).toBe("$50.00") + }) + + it("clamps below min", () => { + const result = Money("-$50").clamp("$0", "$100") + expect(result.toString()).toBe("$0.00") + }) + + it("clamps above max", () => { + const result = Money("$150").clamp("$0", "$100") + expect(result.toString()).toBe("$100.00") + }) + + it("works with currency code format", () => { + const result = Money("50 EUR").clamp("0 EUR", "100 EUR") + expect(result.toString()).toBe("€50.00") + }) + }) + + describe("with number bounds", () => { + it("interprets numbers in same currency", () => { + const result = Money("$50").clamp(0, 100) + expect(result.toString()).toBe("$50.00") + }) + + it("clamps below min", () => { + const result = Money("-$50").clamp(0, 100) + expect(result.toString()).toBe("$0.00") + }) + + it("clamps above max", () => { + const result = Money("$150").clamp(0, 100) + expect(result.toString()).toBe("$100.00") + }) + + it("works with decimal numbers", () => { + const result = Money("$5.50").clamp(0, 10.25) + expect(result.toString()).toBe("$5.50") + }) + + it("works with negative number bounds", () => { + const result = Money("-$50").clamp(-100, -10) + expect(result.toString()).toBe("-$50.00") + }) + }) + + describe("with mixed bound types", () => { + it("accepts Money min and string max", () => { + const result = Money("$50").clamp(Money("$0"), "$100") + expect(result.toString()).toBe("$50.00") + }) + + it("accepts string min and Money max", () => { + const result = Money("$50").clamp("$0", Money("$100")) + expect(result.toString()).toBe("$50.00") + }) + + it("accepts number min and Money max", () => { + const result = Money("$50").clamp(0, Money("$100")) + expect(result.toString()).toBe("$50.00") + }) + + it("accepts Money min and number max", () => { + const result = Money("$50").clamp(Money("$0"), 100) + expect(result.toString()).toBe("$50.00") + }) + }) + + describe("error handling", () => { + it("throws when min > max", () => { + expect(() => Money("$50").clamp("$100", "$0")).toThrow(InvalidInputError) + }) + + it("error message includes bound values", () => { + try { + Money("$50").clamp("$100", "$0") + expect(true).toBe(false) + } catch (e) { + expect((e as Error).message).toContain("$100") + expect((e as Error).message).toContain("$0") + } + }) + + it("throws on currency mismatch with min", () => { + expect(() => Money("$50").clamp("€0", "$100")).toThrow( + CurrencyMismatchError + ) + }) + + it("throws on currency mismatch with max", () => { + expect(() => Money("$50").clamp("$0", "€100")).toThrow( + CurrencyMismatchError + ) + }) + }) + + describe("edge cases", () => { + it("works when min equals max", () => { + const result = Money("$50").clamp("$25", "$25") + expect(result.toString()).toBe("$25.00") + }) + + it("works with zero bounds", () => { + const result = Money("$50").clamp("$0", "$0") + expect(result.toString()).toBe("$0.00") + }) + + it("preserves precision", () => { + const result = Money("$50.123").clamp("$0", "$100") + expect(result.toString()).toBe("$50.12") + }) + + it("works with cryptocurrencies", () => { + const result = Money("0.5 BTC").clamp("0.1 BTC", "1 BTC") + expect(result.currency.code).toBe("BTC") + }) + }) + }) + + describe("atLeast()", () => { + describe("with Money instances", () => { + it("returns same value when above min", () => { + const result = Money("$50").atLeast(Money("$0")) + expect(result.toString()).toBe("$50.00") + }) + + it("returns min when below min", () => { + const result = Money("-$50").atLeast(Money("$0")) + expect(result.toString()).toBe("$0.00") + }) + + it("returns same value when equal to min", () => { + const result = Money("$50").atLeast(Money("$50")) + expect(result.toString()).toBe("$50.00") + }) + }) + + describe("with string bounds", () => { + it("parses currency strings", () => { + const result = Money("$50").atLeast("$0") + expect(result.toString()).toBe("$50.00") + }) + + it("raises to min", () => { + const result = Money("$25").atLeast("$50") + expect(result.toString()).toBe("$50.00") + }) + }) + + describe("with number bounds", () => { + it("interprets numbers in same currency", () => { + const result = Money("$50").atLeast(0) + expect(result.toString()).toBe("$50.00") + }) + + it("raises to min", () => { + const result = Money("-$50").atLeast(0) + expect(result.toString()).toBe("$0.00") + }) + + it("works with decimal numbers", () => { + const result = Money("$5").atLeast(10.50) + expect(result.toString()).toBe("$10.50") + }) + }) + + describe("error handling", () => { + it("throws on currency mismatch", () => { + expect(() => Money("$50").atLeast("€0")).toThrow(CurrencyMismatchError) + }) + }) + + describe("practical uses", () => { + it("ensures non-negative amounts", () => { + const amounts = [Money("$100"), Money("-$50"), Money("$0")] + const safe = amounts.map((m) => m.atLeast(0)) + + expect(safe[0].toString()).toBe("$100.00") + expect(safe[1].toString()).toBe("$0.00") + expect(safe[2].toString()).toBe("$0.00") + }) + + it("enforces minimum order amount", () => { + const minOrder = Money("$25") + const order = Money("$15").atLeast(minOrder) + expect(order.toString()).toBe("$25.00") + }) + }) + }) + + describe("atMost()", () => { + describe("with Money instances", () => { + it("returns same value when below max", () => { + const result = Money("$50").atMost(Money("$100")) + expect(result.toString()).toBe("$50.00") + }) + + it("returns max when above max", () => { + const result = Money("$150").atMost(Money("$100")) + expect(result.toString()).toBe("$100.00") + }) + + it("returns same value when equal to max", () => { + const result = Money("$100").atMost(Money("$100")) + expect(result.toString()).toBe("$100.00") + }) + }) + + describe("with string bounds", () => { + it("parses currency strings", () => { + const result = Money("$50").atMost("$100") + expect(result.toString()).toBe("$50.00") + }) + + it("caps at max", () => { + const result = Money("$150").atMost("$100") + expect(result.toString()).toBe("$100.00") + }) + }) + + describe("with number bounds", () => { + it("interprets numbers in same currency", () => { + const result = Money("$50").atMost(100) + expect(result.toString()).toBe("$50.00") + }) + + it("caps at max", () => { + const result = Money("$150").atMost(100) + expect(result.toString()).toBe("$100.00") + }) + + it("works with decimal numbers", () => { + const result = Money("$15").atMost(10.50) + expect(result.toString()).toBe("$10.50") + }) + }) + + describe("error handling", () => { + it("throws on currency mismatch", () => { + expect(() => Money("$50").atMost("€100")).toThrow(CurrencyMismatchError) + }) + }) + + describe("practical uses", () => { + it("caps withdrawal amounts", () => { + const maxWithdrawal = Money("$500") + const requested = Money("$750").atMost(maxWithdrawal) + expect(requested.toString()).toBe("$500.00") + }) + + it("enforces maximum discount", () => { + const maxDiscount = 100 + const calculatedDiscount = Money("$150").atMost(maxDiscount) + expect(calculatedDiscount.toString()).toBe("$100.00") + }) + }) + }) + + describe("chaining bounds methods", () => { + it("atLeast then atMost is equivalent to clamp", () => { + const value = Money("$150") + const chained = value.atLeast("$0").atMost("$100") + const clamped = value.clamp("$0", "$100") + + expect(chained.toString()).toBe(clamped.toString()) + }) + + it("atMost then atLeast is equivalent to clamp", () => { + const value = Money("-$50") + const chained = value.atMost("$100").atLeast("$0") + const clamped = value.clamp("$0", "$100") + + expect(chained.toString()).toBe(clamped.toString()) + }) + + it("chains with other Money methods", () => { + const result = Money("$100") + .multiply(2n) + .atMost("$150") + .add("$25") + + expect(result.toString()).toBe("$175.00") + }) + }) + + describe("practical use cases", () => { + it("validates payment within limits", () => { + const validatePayment = (amount: string) => { + return Money(amount) + .atLeast(1) // Minimum $1 + .atMost(10000) // Maximum $10,000 + } + + expect(validatePayment("$500").toString()).toBe("$500.00") + expect(validatePayment("$0.50").toString()).toBe("$1.00") + expect(validatePayment("$50000").toString()).toBe("$10,000.00") + }) + + it("calculates tip with bounds", () => { + const calculateTip = (bill: string, tipPercent: string) => { + const tip = Money(bill).multiply(tipPercent) + return tip.atLeast(1).atMost(100) // Min $1, max $100 tip + } + + expect(calculateTip("$50", "20%").toString()).toBe("$10.00") + expect(calculateTip("$5", "10%").toString()).toBe("$1.00") // Raised to min + expect(calculateTip("$1000", "20%").toString()).toBe("$100.00") // Capped at max + }) + + it("applies discount with floor", () => { + const applyDiscount = (price: string, discountPercent: string) => { + const discounted = Money(price).subtract(discountPercent) + return discounted.atLeast(0) // Never go negative + } + + expect(applyDiscount("$100", "20%").toString()).toBe("$80.00") + expect(applyDiscount("$10", "150%").toString()).toBe("$0.00") // Clamped to $0 + }) + + it("normalizes fees within range", () => { + const calculateFee = (amount: string, feePercent: string) => { + return Money(amount) + .multiply(feePercent) + .clamp("$0.50", "$50") // Min $0.50, max $50 fee + } + + expect(calculateFee("$10", "3%").toString()).toBe("$0.50") // Below min + expect(calculateFee("$100", "3%").toString()).toBe("$3.00") // Within range + expect(calculateFee("$5000", "3%").toString()).toBe("$50.00") // Above max + }) + }) +}) diff --git a/packages/cent/test/config.test.ts b/packages/cent/test/config.test.ts new file mode 100644 index 0000000..c23a60a --- /dev/null +++ b/packages/cent/test/config.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it, beforeEach } from "@jest/globals" +import { + configure, + getConfig, + getDefaultConfig, + resetConfig, + withConfig, +} from "../src/config" +import { HALF_EXPAND } from "../src/types" + +describe("Configuration System", () => { + beforeEach(() => { + resetConfig() + }) + + describe("getDefaultConfig", () => { + it("returns default configuration values", () => { + const defaults = getDefaultConfig() + + expect(defaults.numberInputMode).toBe("warn") + expect(defaults.precisionWarningThreshold).toBe(15) + expect(defaults.defaultRoundingMode).toBe("none") + expect(defaults.defaultCurrency).toBe("USD") + expect(defaults.defaultLocale).toBe("en-US") + expect(defaults.strictPrecision).toBe(false) + }) + + it("returns a copy, not the original", () => { + const defaults1 = getDefaultConfig() + const defaults2 = getDefaultConfig() + + expect(defaults1).not.toBe(defaults2) + expect(defaults1).toEqual(defaults2) + }) + }) + + describe("getConfig", () => { + it("returns current configuration", () => { + const config = getConfig() + + expect(config.numberInputMode).toBe("warn") + expect(config.defaultCurrency).toBe("USD") + }) + + it("returns a copy, not the original", () => { + const config1 = getConfig() + const config2 = getConfig() + + expect(config1).not.toBe(config2) + expect(config1).toEqual(config2) + }) + }) + + describe("configure", () => { + it("updates specific configuration options", () => { + configure({ numberInputMode: "error" }) + + const config = getConfig() + expect(config.numberInputMode).toBe("error") + // Other options remain at default + expect(config.defaultCurrency).toBe("USD") + }) + + it("supports partial configuration", () => { + configure({ strictPrecision: true }) + configure({ defaultCurrency: "EUR" }) + + const config = getConfig() + expect(config.strictPrecision).toBe(true) + expect(config.defaultCurrency).toBe("EUR") + }) + + it("overwrites previous values", () => { + configure({ numberInputMode: "error" }) + configure({ numberInputMode: "silent" }) + + expect(getConfig().numberInputMode).toBe("silent") + }) + + it("accepts rounding mode", () => { + configure({ defaultRoundingMode: HALF_EXPAND }) + + expect(getConfig().defaultRoundingMode).toBe(HALF_EXPAND) + }) + + it("accepts 'none' for rounding mode", () => { + configure({ defaultRoundingMode: "none" }) + + expect(getConfig().defaultRoundingMode).toBe("none") + }) + }) + + describe("resetConfig", () => { + it("restores default values", () => { + configure({ + numberInputMode: "error", + strictPrecision: true, + defaultCurrency: "EUR", + }) + + resetConfig() + + const config = getConfig() + expect(config.numberInputMode).toBe("warn") + expect(config.strictPrecision).toBe(false) + expect(config.defaultCurrency).toBe("USD") + }) + }) + + describe("withConfig", () => { + it("applies temporary configuration", () => { + const result = withConfig({ strictPrecision: true }, () => { + return getConfig().strictPrecision + }) + + expect(result).toBe(true) + }) + + it("restores previous configuration after execution", () => { + configure({ strictPrecision: false }) + + withConfig({ strictPrecision: true }, () => { + expect(getConfig().strictPrecision).toBe(true) + }) + + expect(getConfig().strictPrecision).toBe(false) + }) + + it("restores configuration even if function throws", () => { + configure({ strictPrecision: false }) + + expect(() => { + withConfig({ strictPrecision: true }, () => { + throw new Error("Test error") + }) + }).toThrow("Test error") + + expect(getConfig().strictPrecision).toBe(false) + }) + + it("returns the function's return value", () => { + const result = withConfig({ defaultCurrency: "EUR" }, () => { + return `Currency: ${getConfig().defaultCurrency}` + }) + + expect(result).toBe("Currency: EUR") + }) + + it("can be nested", () => { + configure({ defaultCurrency: "USD" }) + + withConfig({ defaultCurrency: "EUR" }, () => { + expect(getConfig().defaultCurrency).toBe("EUR") + + withConfig({ defaultCurrency: "GBP" }, () => { + expect(getConfig().defaultCurrency).toBe("GBP") + }) + + expect(getConfig().defaultCurrency).toBe("EUR") + }) + + expect(getConfig().defaultCurrency).toBe("USD") + }) + + it("only overrides specified options", () => { + configure({ + numberInputMode: "error", + defaultCurrency: "EUR", + }) + + withConfig({ strictPrecision: true }, () => { + const config = getConfig() + expect(config.strictPrecision).toBe(true) + expect(config.numberInputMode).toBe("error") + expect(config.defaultCurrency).toBe("EUR") + }) + }) + }) + + describe("environment-based configuration pattern", () => { + it("supports production-style configuration", () => { + // Simulate production environment + const isProd = true + + configure({ + numberInputMode: isProd ? "error" : "warn", + strictPrecision: isProd, + }) + + const config = getConfig() + expect(config.numberInputMode).toBe("error") + expect(config.strictPrecision).toBe(true) + }) + + it("supports development-style configuration", () => { + // Simulate development environment + const isProd = false + + configure({ + numberInputMode: isProd ? "error" : "warn", + strictPrecision: isProd, + }) + + const config = getConfig() + expect(config.numberInputMode).toBe("warn") + expect(config.strictPrecision).toBe(false) + }) + }) +}) diff --git a/packages/cent/test/convert.test.ts b/packages/cent/test/convert.test.ts index f9ac430..7e5108b 100644 --- a/packages/cent/test/convert.test.ts +++ b/packages/cent/test/convert.test.ts @@ -58,6 +58,6 @@ describe("Money.convert()", () => { const money = Money("€100") const price = new Price(Money("$50,000"), Money("1 BTC")) - expect(() => money.convert(price)).toThrow("Cannot convert EUR") + expect(() => money.convert(price)).toThrow(/different currencies/) }) }) diff --git a/packages/cent/test/errors.test.ts b/packages/cent/test/errors.test.ts new file mode 100644 index 0000000..384e2b9 --- /dev/null +++ b/packages/cent/test/errors.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it } from "@jest/globals" +import { + CentError, + CurrencyMismatchError, + DivisionError, + ErrorCode, + ExchangeRateError, + InvalidInputError, + ParseError, + PrecisionLossError, + ValidationError, +} from "../src/errors" + +describe("CentError", () => { + it("creates error with code and message", () => { + const error = new CentError({ + code: ErrorCode.PARSE_ERROR, + message: "Something went wrong", + }) + + expect(error.code).toBe(ErrorCode.PARSE_ERROR) + expect(error.message).toBe("Something went wrong") + expect(error.name).toBe("CentError") + expect(error).toBeInstanceOf(Error) + expect(error).toBeInstanceOf(CentError) + }) + + it("includes suggestion when provided", () => { + const error = new CentError({ + code: ErrorCode.INVALID_INPUT, + message: "Invalid input", + suggestion: "Try using a different format", + }) + + expect(error.suggestion).toBe("Try using a different format") + }) + + it("includes example when provided", () => { + const error = new CentError({ + code: ErrorCode.DIVISION_BY_ZERO, + message: "Cannot divide by zero", + example: "money.divide(2) // Works!", + }) + + expect(error.example).toBe("money.divide(2) // Works!") + }) + + it("includes originalCause when provided", () => { + const cause = new Error("Original error") + const error = new CentError({ + code: ErrorCode.PARSE_ERROR, + message: "Wrapper error", + cause, + }) + + expect(error.originalCause).toBe(cause) + }) + + it("generates detailed string with all info", () => { + const error = new CentError({ + code: ErrorCode.CURRENCY_MISMATCH, + message: "Currencies don't match", + suggestion: "Convert one currency first", + example: "money.convert(rate)", + }) + + const detailed = error.toDetailedString() + expect(detailed).toContain("CentError") + expect(detailed).toContain("CURRENCY_MISMATCH") + expect(detailed).toContain("Currencies don't match") + expect(detailed).toContain("Convert one currency first") + expect(detailed).toContain("money.convert(rate)") + }) +}) + +describe("ParseError", () => { + it("includes input that failed to parse", () => { + const error = new ParseError("$abc", "Invalid number format") + + expect(error.input).toBe("$abc") + expect(error.message).toBe("Invalid number format") + expect(error.code).toBe(ErrorCode.PARSE_ERROR) + expect(error.name).toBe("ParseError") + }) + + it("accepts custom error code", () => { + const error = new ParseError("xyz", "Unknown currency", { + code: ErrorCode.UNKNOWN_CURRENCY, + }) + + expect(error.code).toBe(ErrorCode.UNKNOWN_CURRENCY) + }) +}) + +describe("CurrencyMismatchError", () => { + it("includes expected and actual currencies", () => { + const error = new CurrencyMismatchError("USD", "EUR", "add") + + expect(error.expected).toBe("USD") + expect(error.actual).toBe("EUR") + expect(error.code).toBe(ErrorCode.CURRENCY_MISMATCH) + expect(error.name).toBe("CurrencyMismatchError") + expect(error.message).toContain("USD") + expect(error.message).toContain("EUR") + expect(error.message).toContain("add") + }) + + it("includes default suggestion", () => { + const error = new CurrencyMismatchError("USD", "EUR", "subtract") + + expect(error.suggestion).toContain("Convert") + }) + + it("accepts custom suggestion", () => { + const error = new CurrencyMismatchError("USD", "EUR", "compare", { + suggestion: "Use the same currency for comparison", + }) + + expect(error.suggestion).toBe("Use the same currency for comparison") + }) +}) + +describe("DivisionError", () => { + it("includes the divisor", () => { + const error = new DivisionError(0, "Cannot divide by zero") + + expect(error.divisor).toBe(0) + expect(error.code).toBe(ErrorCode.DIVISION_BY_ZERO) + expect(error.name).toBe("DivisionError") + }) + + it("handles bigint divisor", () => { + const error = new DivisionError(0n, "Cannot divide by zero") + + expect(error.divisor).toBe(0n) + }) + + it("handles string divisor", () => { + const error = new DivisionError("3", "Requires rounding", { + code: ErrorCode.DIVISION_REQUIRES_ROUNDING, + }) + + expect(error.divisor).toBe("3") + expect(error.code).toBe(ErrorCode.DIVISION_REQUIRES_ROUNDING) + }) +}) + +describe("InvalidInputError", () => { + it("creates with message and default code", () => { + const error = new InvalidInputError("Invalid ratio") + + expect(error.message).toBe("Invalid ratio") + expect(error.code).toBe(ErrorCode.INVALID_INPUT) + expect(error.name).toBe("InvalidInputError") + }) + + it("accepts custom code", () => { + const error = new InvalidInputError("Empty array", { + code: ErrorCode.EMPTY_ARRAY, + }) + + expect(error.code).toBe(ErrorCode.EMPTY_ARRAY) + }) +}) + +describe("ValidationError", () => { + it("creates with message", () => { + const error = new ValidationError("Invalid JSON") + + expect(error.message).toBe("Invalid JSON") + expect(error.code).toBe(ErrorCode.VALIDATION_ERROR) + expect(error.name).toBe("ValidationError") + }) + + it("includes validation issues", () => { + const error = new ValidationError("Validation failed", { + issues: [ + { path: "amount", message: "Required" }, + { path: "currency", message: "Invalid code" }, + ], + }) + + expect(error.issues).toHaveLength(2) + expect(error.issues?.[0].path).toBe("amount") + expect(error.issues?.[1].message).toBe("Invalid code") + }) +}) + +describe("PrecisionLossError", () => { + it("creates with message", () => { + const error = new PrecisionLossError("Would lose precision") + + expect(error.message).toBe("Would lose precision") + expect(error.code).toBe(ErrorCode.PRECISION_LOSS) + expect(error.name).toBe("PrecisionLossError") + }) +}) + +describe("ExchangeRateError", () => { + it("creates with message", () => { + const error = new ExchangeRateError("Invalid rate") + + expect(error.message).toBe("Invalid rate") + expect(error.code).toBe(ErrorCode.INVALID_EXCHANGE_RATE) + expect(error.name).toBe("ExchangeRateError") + }) + + it("accepts custom code", () => { + const error = new ExchangeRateError("Currencies don't match", { + code: ErrorCode.EXCHANGE_RATE_MISMATCH, + }) + + expect(error.code).toBe(ErrorCode.EXCHANGE_RATE_MISMATCH) + }) +}) + +describe("ErrorCode constants", () => { + it("exports all error codes", () => { + expect(ErrorCode.PARSE_ERROR).toBe("PARSE_ERROR") + expect(ErrorCode.INVALID_NUMBER_FORMAT).toBe("INVALID_NUMBER_FORMAT") + expect(ErrorCode.INVALID_MONEY_STRING).toBe("INVALID_MONEY_STRING") + expect(ErrorCode.UNKNOWN_CURRENCY).toBe("UNKNOWN_CURRENCY") + expect(ErrorCode.CURRENCY_MISMATCH).toBe("CURRENCY_MISMATCH") + expect(ErrorCode.INCOMPATIBLE_CURRENCIES).toBe("INCOMPATIBLE_CURRENCIES") + expect(ErrorCode.DIVISION_BY_ZERO).toBe("DIVISION_BY_ZERO") + expect(ErrorCode.DIVISION_REQUIRES_ROUNDING).toBe("DIVISION_REQUIRES_ROUNDING") + expect(ErrorCode.INVALID_DIVISOR).toBe("INVALID_DIVISOR") + expect(ErrorCode.PRECISION_LOSS).toBe("PRECISION_LOSS") + expect(ErrorCode.INVALID_PRECISION).toBe("INVALID_PRECISION") + expect(ErrorCode.INVALID_INPUT).toBe("INVALID_INPUT") + expect(ErrorCode.INVALID_RANGE).toBe("INVALID_RANGE") + expect(ErrorCode.INVALID_RATIO).toBe("INVALID_RATIO") + expect(ErrorCode.INVALID_JSON).toBe("INVALID_JSON") + expect(ErrorCode.EMPTY_ARRAY).toBe("EMPTY_ARRAY") + expect(ErrorCode.VALIDATION_ERROR).toBe("VALIDATION_ERROR") + expect(ErrorCode.INVALID_EXCHANGE_RATE).toBe("INVALID_EXCHANGE_RATE") + expect(ErrorCode.EXCHANGE_RATE_MISMATCH).toBe("EXCHANGE_RATE_MISMATCH") + }) +}) + +describe("Error inheritance", () => { + it("all error types extend CentError", () => { + const errors = [ + new ParseError("x", "msg"), + new CurrencyMismatchError("USD", "EUR", "add"), + new DivisionError(0, "msg"), + new InvalidInputError("msg"), + new ValidationError("msg"), + new PrecisionLossError("msg"), + new ExchangeRateError("msg"), + ] + + errors.forEach(error => { + expect(error).toBeInstanceOf(Error) + expect(error).toBeInstanceOf(CentError) + }) + }) + + it("errors can be caught by CentError type", () => { + const throwParse = () => { + throw new ParseError("x", "msg") + } + + expect(() => { + try { + throwParse() + } catch (e) { + if (e instanceof CentError) { + throw new Error(`Caught CentError with code: ${e.code}`) + } + throw e + } + }).toThrow("Caught CentError with code: PARSE_ERROR") + }) +}) diff --git a/packages/cent/test/fixed-point.test.ts b/packages/cent/test/fixed-point.test.ts index 5f15ec1..066fbb3 100644 --- a/packages/cent/test/fixed-point.test.ts +++ b/packages/cent/test/fixed-point.test.ts @@ -213,12 +213,12 @@ describe("FixedPointNumber", () => { it("should divide by FixedPoint with factors of 2 and 5 only", () => { const fp1 = new FixedPointNumber(100n, 1n) // 10.0 const fp2 = new FixedPointNumber(25n, 1n) // 2.5 - const result = fp1.divide(fp2) // 10.0 / 2.5 = 4.000 + const result = fp1.divide(fp2) // 10.0 / 2.5 = 4.0 - // 10.0 / 2.5: (100*10) / 25 * 10^2 = 1000 / 25 * 100 = 40 * 100 = 4000 - // Decimals: 1 + 1 + 2 = 4 (for 25 = 5^2) - expect(result.amount).toBe(4000n) // 4.0000 as 4000 with 4 decimals - expect(result.decimals).toBe(4n) // 1 + 1 + 2 for 25=5^2 + // 10.0 / 2.5 = 4.0 + // Decimals: this.decimals + neededFactors = 1 + 2 = 3 (for 25 = 5^2) + expect(result.amount).toBe(4000n) // 4.000 as 4000 with 3 decimals + expect(result.decimals).toBe(3n) }) it("should handle division resulting in decimal expansion", () => { @@ -1687,9 +1687,11 @@ describe("FixedPoint factory function", () => { describe("divide", () => { it("should divide by string decimal numbers (factors of 2 and 5)", () => { const result = fp100.divide("2.5") - expect(result.amount).toBe(400000n) // 40.00000 (higher precision) - expect(result.decimals).toBe(5n) - expect(result.toString()).toBe("4.00000") + // 100.00 / 2.5 = 40.0 + // Decimals: this.decimals + neededFactors = 2 + 2 = 4 (for 25 = 5^2) + expect(result.amount).toBe(400000n) // 40.0000 + expect(result.decimals).toBe(4n) + expect(result.toString()).toBe("40.0000") }) it("should divide by string integers", () => { diff --git a/packages/cent/test/money.test.ts b/packages/cent/test/money.test.ts index 49cf7b6..ad7975d 100644 --- a/packages/cent/test/money.test.ts +++ b/packages/cent/test/money.test.ts @@ -121,7 +121,7 @@ describe("Money", () => { const eurMoney = new Money(eurAmount) expect(() => usdMoney.add(eurMoney)).toThrow( - "Cannot add Money with different asset types", + /different currencies/, ) }) @@ -129,7 +129,7 @@ describe("Money", () => { const usdMoney = new Money(usdAmount) expect(() => usdMoney.add(eurAmount)).toThrow( - "Cannot add Money with different asset types", + /different currencies/, ) }) }) @@ -198,7 +198,7 @@ describe("Money", () => { const eurMoney = new Money(eurAmount) expect(() => usdMoney.subtract(eurMoney)).toThrow( - "Cannot subtract Money with different asset types", + /different currencies/, ) }) @@ -206,7 +206,7 @@ describe("Money", () => { const usdMoney = new Money(usdAmount) expect(() => usdMoney.subtract(eurAmount)).toThrow( - "Cannot subtract Money with different asset types", + /different currencies/, ) }) }) @@ -410,7 +410,7 @@ describe("Money", () => { const eurMoney = new Money(eurAmount) expect(() => usdMoney.lessThan(eurMoney)).toThrow( - "Cannot compare Money with different asset types", + /different currencies/, ) }) }) @@ -454,7 +454,7 @@ describe("Money", () => { const eurMoney = new Money(eurAmount) expect(() => usdMoney.lessThanOrEqual(eurMoney)).toThrow( - "Cannot compare Money with different asset types", + /different currencies/, ) }) }) @@ -506,7 +506,7 @@ describe("Money", () => { const eurMoney = new Money(eurAmount) expect(() => usdMoney.greaterThan(eurMoney)).toThrow( - "Cannot compare Money with different asset types", + /different currencies/, ) }) }) @@ -550,7 +550,7 @@ describe("Money", () => { const eurMoney = new Money(eurAmount) expect(() => usdMoney.greaterThanOrEqual(eurMoney)).toThrow( - "Cannot compare Money with different asset types", + /different currencies/, ) }) }) @@ -1331,7 +1331,7 @@ describe("Money", () => { const eurMoney = new Money(eurAmount) expect(() => usdMoney.max(eurMoney)).toThrow( - "Cannot compare Money with different asset types", + /different currencies/, ) }) @@ -1344,7 +1344,7 @@ describe("Money", () => { const eurMoney = new Money(eurAmount) expect(() => usdMoney1.max([usdMoney2, eurMoney])).toThrow( - "Cannot compare Money with different asset types", + /different currencies/, ) }) }) @@ -1402,7 +1402,7 @@ describe("Money", () => { const eurMoney = new Money(eurAmount) expect(() => usdMoney.min(eurMoney)).toThrow( - "Cannot compare Money with different asset types", + /different currencies/, ) }) @@ -1415,7 +1415,7 @@ describe("Money", () => { const eurMoney = new Money(eurAmount) expect(() => usdMoney1.min([usdMoney2, eurMoney])).toThrow( - "Cannot compare Money with different asset types", + /different currencies/, ) }) }) @@ -1835,7 +1835,7 @@ describe("Money", () => { const eurMoney = new Money(eurAmount) expect(() => usdMoney.compare(eurMoney)).toThrow( - "Cannot compare Money with different asset types", + /different currencies/, ) }) @@ -2788,7 +2788,7 @@ describe("Money", () => { }) it("should throw on currency mismatch", () => { - expect(() => usd100.add("€25.00")).toThrow("Currency mismatch") + expect(() => usd100.add("€25.00")).toThrow(/different currencies/) }) it("should handle negative string amounts", () => { @@ -2830,7 +2830,7 @@ describe("Money", () => { }) it("should throw on currency mismatch", () => { - expect(() => usd100.subtract("€25.00")).toThrow("Currency mismatch") + expect(() => usd100.subtract("€25.00")).toThrow(/different currencies/) }) }) @@ -2851,7 +2851,7 @@ describe("Money", () => { }) it("should throw on currency mismatch", () => { - expect(() => usd100.equals("€100.00")).toThrow("Currency mismatch") + expect(() => usd100.equals("€100.00")).toThrow(/different currencies/) }) }) @@ -2863,7 +2863,7 @@ describe("Money", () => { }) it("should throw on currency mismatch", () => { - expect(() => usd100.lessThan("€100.00")).toThrow("Currency mismatch") + expect(() => usd100.lessThan("€100.00")).toThrow(/different currencies/) }) }) @@ -2876,7 +2876,7 @@ describe("Money", () => { it("should throw on currency mismatch", () => { expect(() => usd100.lessThanOrEqual("€100.00")).toThrow( - "Currency mismatch", + /different currencies/, ) }) }) @@ -2889,7 +2889,7 @@ describe("Money", () => { }) it("should throw on currency mismatch", () => { - expect(() => usd100.greaterThan("€25.00")).toThrow("Currency mismatch") + expect(() => usd100.greaterThan("€25.00")).toThrow(/different currencies/) }) }) @@ -2902,7 +2902,7 @@ describe("Money", () => { it("should throw on currency mismatch", () => { expect(() => usd100.greaterThanOrEqual("€25.00")).toThrow( - "Currency mismatch", + /different currencies/, ) }) }) @@ -2924,7 +2924,7 @@ describe("Money", () => { }) it("should throw on currency mismatch", () => { - expect(() => usd100.max("€100.00")).toThrow("Currency mismatch") + expect(() => usd100.max("€100.00")).toThrow(/different currencies/) }) }) @@ -2945,7 +2945,7 @@ describe("Money", () => { }) it("should throw on currency mismatch", () => { - expect(() => usd100.min("€25.00")).toThrow("Currency mismatch") + expect(() => usd100.min("€25.00")).toThrow(/different currencies/) }) }) diff --git a/packages/cent/test/number-input.test.ts b/packages/cent/test/number-input.test.ts new file mode 100644 index 0000000..6019fb3 --- /dev/null +++ b/packages/cent/test/number-input.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, it, beforeEach, jest } from "@jest/globals" +import { Money, MoneyClass, USD, EUR, BTC, ETH, SOL } from "../src" +import { configure, resetConfig, withConfig } from "../src/config" + +describe("Number Input", () => { + beforeEach(() => { + resetConfig() + }) + + describe("Money(number, currency)", () => { + it("creates Money from number and currency string", () => { + const money = Money(100.50, "USD") + expect(money.toString()).toBe("$100.50") + }) + + it("creates Money from number and Currency object", () => { + const money = Money(100.50, USD) + expect(money.toString()).toBe("$100.50") + }) + + it("handles integer amounts", () => { + const money = Money(100, "USD") + expect(money.toString()).toBe("$100.00") + }) + + it("handles zero", () => { + const money = Money(0, "USD") + expect(money.toString()).toBe("$0.00") + }) + + it("handles negative numbers", () => { + const money = Money(-50.25, "USD") + expect(money.toString()).toBe("-$50.25") + }) + + it("throws without currency", () => { + // @ts-expect-error - testing runtime behavior + expect(() => Money(100)).toThrow(/Currency is required/) + }) + + it("throws for NaN", () => { + expect(() => Money(NaN, "USD")).toThrow(/Invalid number input/) + }) + + it("throws for Infinity", () => { + expect(() => Money(Infinity, "USD")).toThrow(/Invalid number input/) + }) + + it("throws for -Infinity", () => { + expect(() => Money(-Infinity, "USD")).toThrow(/Invalid number input/) + }) + }) + + describe("Money(bigint, currency)", () => { + it("creates Money from bigint minor units (USD cents)", () => { + const money = Money(10050n, "USD") + expect(money.toString()).toBe("$100.50") + }) + + it("creates Money from bigint minor units (BTC satoshis)", () => { + const money = Money(100000000n, "BTC") + expect(money.toString()).toBe("1 BTC") + }) + + it("handles zero", () => { + const money = Money(0n, "USD") + expect(money.toString()).toBe("$0.00") + }) + + it("handles negative amounts", () => { + const money = Money(-5025n, "USD") + expect(money.toString()).toBe("-$50.25") + }) + + it("throws without currency", () => { + // @ts-expect-error - testing runtime behavior + expect(() => Money(10050n)).toThrow(/Currency is required/) + }) + + it("handles large amounts precisely", () => { + const money = Money(123456789012345678901234567890n, "ETH") + // ETH has 18 decimals, so this is ~123456789012.345... ETH + expect(money.balance.amount.amount).toBe(123456789012345678901234567890n) + }) + }) + + describe("numberInputMode configuration", () => { + describe("'silent' mode", () => { + beforeEach(() => { + configure({ numberInputMode: "silent" }) + }) + + it("allows all numbers without warning", () => { + const consoleSpy = jest.spyOn(console, "warn").mockImplementation() + + // Non-safe integer + const money = Money(9007199254740993, "USD") + expect(money).toBeDefined() + expect(consoleSpy).not.toHaveBeenCalled() + + consoleSpy.mockRestore() + }) + }) + + describe("'warn' mode (default)", () => { + beforeEach(() => { + configure({ numberInputMode: "warn" }) + }) + + it("allows safe integers without warning", () => { + const consoleSpy = jest.spyOn(console, "warn").mockImplementation() + + const money = Money(100, "USD") + expect(money.toString()).toBe("$100.00") + expect(consoleSpy).not.toHaveBeenCalled() + + consoleSpy.mockRestore() + }) + + it("warns for non-safe integers", () => { + const consoleSpy = jest.spyOn(console, "warn").mockImplementation() + + // This number is beyond safe integer range + Money(9007199254740993, "USD") + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("may lose precision") + ) + + consoleSpy.mockRestore() + }) + }) + + describe("'error' mode", () => { + beforeEach(() => { + configure({ numberInputMode: "error" }) + }) + + it("allows safe integers", () => { + const money = Money(100, "USD") + expect(money.toString()).toBe("$100.00") + }) + + it("throws for non-safe integers", () => { + expect(() => Money(9007199254740993, "USD")).toThrow(/may lose precision/) + }) + }) + + describe("'never' mode", () => { + beforeEach(() => { + configure({ numberInputMode: "never" }) + }) + + it("throws for any number input", () => { + expect(() => Money(100, "USD")).toThrow(/Number inputs are not allowed/) + }) + + it("throws even for safe integers", () => { + expect(() => Money(0, "USD")).toThrow(/Number inputs are not allowed/) + }) + + it("suggests using strings instead", () => { + try { + Money(100.50, "USD") + expect.fail("Should have thrown") + } catch (e: unknown) { + expect((e as { suggestion?: string }).suggestion).toMatch(/Money\("100\.5 USD"\)/) + } + }) + + it("still allows string inputs", () => { + const money = Money("$100.50") + expect(money.toString()).toBe("$100.50") + }) + + it("still allows bigint inputs", () => { + const money = Money(10050n, "USD") + expect(money.toString()).toBe("$100.50") + }) + }) + + describe("withConfig for testing", () => { + it("temporarily overrides numberInputMode", () => { + configure({ numberInputMode: "silent" }) + + withConfig({ numberInputMode: "never" }, () => { + expect(() => Money(100, "USD")).toThrow(/Number inputs are not allowed/) + }) + + // Back to silent mode + const money = Money(100, "USD") + expect(money.toString()).toBe("$100.00") + }) + }) + }) + + describe("Money.fromSubUnits()", () => { + describe("Bitcoin sub-units", () => { + it("creates from satoshis", () => { + const money = MoneyClass.fromSubUnits(100000000n, "sat") + expect(money.toString()).toBe("1 BTC") + }) + + it("accepts 'satoshi' alias", () => { + const money = MoneyClass.fromSubUnits(100000000n, "satoshi") + expect(money.toString()).toBe("1 BTC") + }) + + it("accepts 'sats' alias", () => { + const money = MoneyClass.fromSubUnits(50000000n, "sats") + expect(money.toString()).toBe("0.5 BTC") + }) + + it("creates from millisatoshis", () => { + const money = MoneyClass.fromSubUnits(100000000000n, "msat") + expect(money.balance.amount.amount).toBe(100000000000n) + expect(money.balance.amount.decimals).toBe(11n) + }) + + it("accepts 'msats' alias", () => { + const money = MoneyClass.fromSubUnits(1000n, "msats") + expect(money.balance.amount.decimals).toBe(11n) + }) + }) + + describe("Ethereum sub-units", () => { + it("creates from wei", () => { + const money = MoneyClass.fromSubUnits(1000000000000000000n, "wei") + expect(money.toString()).toContain("1") + expect(money.currency.code).toBe("ETH") + }) + + it("creates from gwei", () => { + const money = MoneyClass.fromSubUnits(1000000000n, "gwei") + expect(money.balance.amount.decimals).toBe(9n) + expect(money.currency.code).toBe("ETH") + }) + + it("accepts 'shannon' alias for gwei", () => { + const money = MoneyClass.fromSubUnits(1000000000n, "shannon") + expect(money.balance.amount.decimals).toBe(9n) + }) + }) + + describe("Solana sub-units", () => { + it("creates from lamports", () => { + const money = MoneyClass.fromSubUnits(1000000000n, "lamport") + expect(money.currency.code).toBe("SOL") + expect(money.balance.amount.decimals).toBe(9n) + }) + + it("accepts 'lamports' alias", () => { + const money = MoneyClass.fromSubUnits(1000000000n, "lamports") + expect(money.currency.code).toBe("SOL") + }) + }) + + describe("Fiat sub-units", () => { + it("creates from cents", () => { + const money = MoneyClass.fromSubUnits(10050n, "cents") + expect(money.toString()).toBe("$100.50") + }) + + it("creates from pence", () => { + const money = MoneyClass.fromSubUnits(5025n, "pence") + expect(money.currency.code).toBe("GBP") + }) + }) + + describe("error handling", () => { + it("throws for unknown sub-unit", () => { + expect(() => MoneyClass.fromSubUnits(100n, "unknown")).toThrow(/Unknown sub-unit/) + }) + + it("suggests known sub-units in error", () => { + try { + MoneyClass.fromSubUnits(100n, "foo") + expect.fail("Should have thrown") + } catch (e: unknown) { + expect((e as { suggestion?: string }).suggestion).toMatch(/sat/) + } + }) + + it("is case insensitive", () => { + const money1 = MoneyClass.fromSubUnits(100n, "SAT") + const money2 = MoneyClass.fromSubUnits(100n, "Sat") + const money3 = MoneyClass.fromSubUnits(100n, "sat") + + expect(money1.balance.amount.amount).toBe(money2.balance.amount.amount) + expect(money2.balance.amount.amount).toBe(money3.balance.amount.amount) + }) + }) + }) +}) diff --git a/packages/cent/test/percentage.test.ts b/packages/cent/test/percentage.test.ts new file mode 100644 index 0000000..bc7fe81 --- /dev/null +++ b/packages/cent/test/percentage.test.ts @@ -0,0 +1,372 @@ +import { describe, expect, it } from "@jest/globals" +import { Money, HALF_EXPAND } from "../src" + +describe("Percentage Operations", () => { + describe("add() with percentage strings", () => { + it("adds a percentage to the amount", () => { + const price = Money("$100.00") + const withTax = price.add("8.25%") + expect(withTax.toString()).toBe("$108.25") + }) + + it("handles whole number percentages", () => { + const price = Money("$100.00") + expect(price.add("20%").toString()).toBe("$120.00") + expect(price.add("50%").toString()).toBe("$150.00") + expect(price.add("100%").toString()).toBe("$200.00") + }) + + it("handles decimal percentages", () => { + const price = Money("$100.00") + expect(price.add("8.25%").toString()).toBe("$108.25") + expect(price.add("0.5%").toString()).toBe("$100.50") + }) + + it("handles negative percentages (discount)", () => { + const price = Money("$100.00") + expect(price.add("-10%").toString()).toBe("$90.00") + }) + + it("supports 'percent' keyword", () => { + const price = Money("$100.00") + expect(price.add("20percent").toString()).toBe("$120.00") + expect(price.add("20 percent").toString()).toBe("$120.00") + }) + + it("supports percent sign with space", () => { + const price = Money("$100.00") + expect(price.add("20 %").toString()).toBe("$120.00") + }) + + it("accepts optional rounding mode", () => { + const price = Money("$100.00") + // 8.333...% of $100 = $8.333..., total = $108.33 (rounded) + const result = price.add("8.333%", HALF_EXPAND) + expect(result.toString()).toBe("$108.33") + }) + + it("still adds money values normally", () => { + const price = Money("$100.00") + expect(price.add("$10.00").toString()).toBe("$110.00") + expect(price.add(Money("$5.00")).toString()).toBe("$105.00") + }) + }) + + describe("subtract() with percentage strings", () => { + it("subtracts a percentage from the amount (discount)", () => { + const price = Money("$100.00") + const discounted = price.subtract("10%") + expect(discounted.toString()).toBe("$90.00") + }) + + it("handles various discount percentages", () => { + const price = Money("$100.00") + expect(price.subtract("25%").toString()).toBe("$75.00") + expect(price.subtract("50%").toString()).toBe("$50.00") + expect(price.subtract("100%").toString()).toBe("$0.00") + }) + + it("handles decimal percentages", () => { + const price = Money("$100.00") + expect(price.subtract("8.25%").toString()).toBe("$91.75") + }) + + it("handles negative percentages (increase)", () => { + const price = Money("$100.00") + expect(price.subtract("-10%").toString()).toBe("$110.00") + }) + + it("supports 'percent' keyword", () => { + const price = Money("$100.00") + expect(price.subtract("20percent").toString()).toBe("$80.00") + expect(price.subtract("20 percent").toString()).toBe("$80.00") + }) + + it("accepts optional rounding mode", () => { + const price = Money("$100.00") + const result = price.subtract("33.333%", HALF_EXPAND) + expect(result.toString()).toBe("$66.67") + }) + + it("still subtracts money values normally", () => { + const price = Money("$100.00") + expect(price.subtract("$10.00").toString()).toBe("$90.00") + expect(price.subtract(Money("$5.00")).toString()).toBe("$95.00") + }) + }) + + describe("multiply() with percentage strings", () => { + it("multiplies by a percentage", () => { + const price = Money("$100.00") + expect(price.multiply("50%").toString()).toBe("$50.00") + }) + + it("handles various percentages", () => { + const price = Money("$100.00") + expect(price.multiply("10%").toString()).toBe("$10.00") + expect(price.multiply("25%").toString()).toBe("$25.00") + expect(price.multiply("100%").toString()).toBe("$100.00") + expect(price.multiply("150%").toString()).toBe("$150.00") + expect(price.multiply("200%").toString()).toBe("$200.00") + }) + + it("handles decimal percentages", () => { + const price = Money("$100.00") + expect(price.multiply("8.25%").toString()).toBe("$8.25") + }) + + it("supports 'percent' keyword", () => { + const price = Money("$100.00") + expect(price.multiply("50percent").toString()).toBe("$50.00") + expect(price.multiply("50 percent").toString()).toBe("$50.00") + }) + + it("accepts optional rounding mode", () => { + const price = Money("$100.00") + const result = price.multiply("33.333%", HALF_EXPAND) + expect(result.toString()).toBe("$33.33") + }) + + it("still multiplies by decimal strings normally", () => { + const price = Money("$100.00") + expect(price.multiply("0.5").toString()).toBe("$50.00") + expect(price.multiply("1.5").toString()).toBe("$150.00") + }) + + it("still multiplies by bigint normally", () => { + const price = Money("$100.00") + expect(price.multiply(3n).toString()).toBe("$300.00") + }) + }) + + describe("extractPercent()", () => { + describe("with percentage string input", () => { + it("extracts VAT from total (21% VAT)", () => { + // $121 total with 21% VAT = $100 base + $21 VAT + const total = Money("$121.00") + const vat = total.extractPercent("21%", HALF_EXPAND) + expect(vat.toString()).toBe("$21.00") + }) + + it("extracts sales tax from total (8.25%)", () => { + // $108.25 total with 8.25% tax = $100 base + $8.25 tax + const total = Money("$108.25") + const tax = total.extractPercent("8.25%", HALF_EXPAND) + expect(tax.toString()).toBe("$8.25") + }) + + it("handles 20% VAT", () => { + // $120 total with 20% VAT = $100 base + $20 VAT + const total = Money("$120.00") + const vat = total.extractPercent("20%", HALF_EXPAND) + expect(vat.toString()).toBe("$20.00") + }) + + it("handles 10% tax", () => { + // $110 total with 10% tax = $100 base + $10 tax + const total = Money("$110.00") + const tax = total.extractPercent("10%", HALF_EXPAND) + expect(tax.toString()).toBe("$10.00") + }) + }) + + describe("with number input", () => { + it("treats number as percentage value", () => { + const total = Money("$121.00") + const vat = total.extractPercent(21, HALF_EXPAND) + expect(vat.toString()).toBe("$21.00") + }) + + it("handles decimal numbers", () => { + const total = Money("$108.25") + const tax = total.extractPercent(8.25, HALF_EXPAND) + expect(tax.toString()).toBe("$8.25") + }) + }) + + describe("with plain number string input", () => { + it("treats plain number string as percentage", () => { + const total = Money("$121.00") + const vat = total.extractPercent("21", HALF_EXPAND) + expect(vat.toString()).toBe("$21.00") + }) + }) + + describe("edge cases", () => { + it("handles 0% (returns zero)", () => { + const total = Money("$100.00") + const extracted = total.extractPercent("0%", HALF_EXPAND) + expect(extracted.toString()).toBe("$0.00") + }) + + it("handles amounts requiring rounding", () => { + // $100 / 1.07 = $93.4579... base, tax = $6.5420... + const total = Money("$100.00") + const tax = total.extractPercent("7%", HALF_EXPAND) + expect(tax.toString()).toBe("$6.54") + }) + }) + }) + + describe("removePercent()", () => { + describe("with percentage string input", () => { + it("removes VAT to get pre-tax price (21% VAT)", () => { + // $121 total with 21% VAT → $100 base + const total = Money("$121.00") + const base = total.removePercent("21%", HALF_EXPAND) + expect(base.toString()).toBe("$100.00") + }) + + it("removes sales tax (8.25%)", () => { + // $108.25 total with 8.25% tax → $100 base + const total = Money("$108.25") + const base = total.removePercent("8.25%", HALF_EXPAND) + expect(base.toString()).toBe("$100.00") + }) + + it("handles 20% VAT", () => { + const total = Money("$120.00") + const base = total.removePercent("20%", HALF_EXPAND) + expect(base.toString()).toBe("$100.00") + }) + + it("handles 10% tax", () => { + const total = Money("$110.00") + const base = total.removePercent("10%", HALF_EXPAND) + expect(base.toString()).toBe("$100.00") + }) + }) + + describe("with number input", () => { + it("treats number as percentage value", () => { + const total = Money("$121.00") + const base = total.removePercent(21, HALF_EXPAND) + expect(base.toString()).toBe("$100.00") + }) + + it("handles decimal numbers", () => { + const total = Money("$108.25") + const base = total.removePercent(8.25, HALF_EXPAND) + expect(base.toString()).toBe("$100.00") + }) + }) + + describe("with plain number string input", () => { + it("treats plain number string as percentage", () => { + const total = Money("$121.00") + const base = total.removePercent("21", HALF_EXPAND) + expect(base.toString()).toBe("$100.00") + }) + }) + + describe("edge cases", () => { + it("handles 0% (returns same amount)", () => { + const total = Money("$100.00") + const base = total.removePercent("0%", HALF_EXPAND) + expect(base.toString()).toBe("$100.00") + }) + + it("handles amounts requiring rounding", () => { + // $100 / 1.07 = $93.4579... + const total = Money("$100.00") + const base = total.removePercent("7%", HALF_EXPAND) + expect(base.toString()).toBe("$93.46") + }) + }) + }) + + describe("relationship between extractPercent and removePercent", () => { + it("extractPercent + removePercent = original total", () => { + const total = Money("$121.00") + const vat = total.extractPercent("21%", HALF_EXPAND) + const base = total.removePercent("21%", HALF_EXPAND) + + expect(base.add(vat).toString()).toBe("$121.00") + }) + + it("works with various percentages", () => { + const testCases = [ + { total: "$110.00", percent: "10%" }, + { total: "$120.00", percent: "20%" }, + { total: "$108.25", percent: "8.25%" }, + { total: "$119.00", percent: "19%" }, + ] + + for (const { total, percent } of testCases) { + const totalMoney = Money(total) + const extracted = totalMoney.extractPercent(percent, HALF_EXPAND) + const base = totalMoney.removePercent(percent, HALF_EXPAND) + + expect(base.add(extracted).toString()).toBe(total) + } + }) + }) + + describe("combined percentage operations", () => { + it("can calculate tax on a subtotal, then apply discount", () => { + const subtotal = Money("$100.00") + const withTax = subtotal.add("8%") // $108.00 + const discounted = withTax.subtract("10%") // $97.20 + + expect(withTax.toString()).toBe("$108.00") + expect(discounted.toString()).toBe("$97.20") + }) + + it("can calculate tip percentage", () => { + const bill = Money("$85.00") + const tip15 = bill.multiply("15%") // $12.75 + const tip20 = bill.multiply("20%") // $17.00 + + expect(tip15.toString()).toBe("$12.75") + expect(tip20.toString()).toBe("$17.00") + }) + + it("can calculate profit margin", () => { + const cost = Money("$80.00") + const salePrice = cost.add("25%") // $100.00 (25% markup) + + expect(salePrice.toString()).toBe("$100.00") + }) + + it("can apply multiple discounts", () => { + const original = Money("$100.00") + const firstDiscount = original.subtract("20%") // $80.00 + const secondDiscount = firstDiscount.subtract("10%") // $72.00 + + expect(firstDiscount.toString()).toBe("$80.00") + expect(secondDiscount.toString()).toBe("$72.00") + }) + }) + + describe("percentage parsing edge cases", () => { + it("handles whitespace variations", () => { + const price = Money("$100.00") + expect(price.add(" 20% ").toString()).toBe("$120.00") + expect(price.add("20 %").toString()).toBe("$120.00") + }) + + it("is case-insensitive for 'percent'", () => { + const price = Money("$100.00") + expect(price.add("20PERCENT").toString()).toBe("$120.00") + expect(price.add("20Percent").toString()).toBe("$120.00") + }) + + it("distinguishes percentage from currency amounts", () => { + const price = Money("$100.00") + // This is a percentage + expect(price.add("20%").toString()).toBe("$120.00") + // This is a dollar amount + expect(price.add("$20.00").toString()).toBe("$120.00") + }) + + it("handles very small percentages", () => { + const price = Money("$10000.00") + expect(price.multiply("0.01%").toString()).toBe("$1.00") + }) + + it("handles very large percentages", () => { + const price = Money("$100.00") + expect(price.multiply("500%").toString()).toBe("$500.00") + }) + }) +}) diff --git a/packages/cent/test/result.test.ts b/packages/cent/test/result.test.ts new file mode 100644 index 0000000..3bc7f42 --- /dev/null +++ b/packages/cent/test/result.test.ts @@ -0,0 +1,461 @@ +import { describe, expect, it } from "@jest/globals" +import { + Money, + MoneyClass, + Ok, + Err, + ok, + err, + ParseError, + InvalidInputError, + CentError, +} from "../src" +import type { Result } from "../src" + +describe("Result Type", () => { + describe("Ok class", () => { + it("creates an Ok with a value", () => { + const result = new Ok(42) + expect(result.ok).toBe(true) + expect(result.value).toBe(42) + }) + + it("map transforms the value", () => { + const result = new Ok(10).map((x) => x * 2) + expect(result.ok).toBe(true) + expect(result.value).toBe(20) + }) + + it("flatMap chains Result-returning functions", () => { + const result = new Ok(10).flatMap((x) => + x > 0 ? new Ok(x * 2) : new Err("negative") + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value).toBe(20) + } + }) + + it("unwrap returns the value", () => { + const result = new Ok(42) + expect(result.unwrap()).toBe(42) + }) + + it("unwrapOr returns the value (ignores default)", () => { + const result = new Ok(42) + expect(result.unwrapOr(0)).toBe(42) + }) + + it("unwrapOrElse returns the value (ignores function)", () => { + const result = new Ok(42) + expect(result.unwrapOrElse(() => 0)).toBe(42) + }) + + it("match calls the ok handler", () => { + const result = new Ok(42).match({ + ok: (v) => `Success: ${v}`, + err: (e) => `Error: ${e}`, + }) + expect(result).toBe("Success: 42") + }) + + it("isOk returns true", () => { + expect(new Ok(42).isOk()).toBe(true) + }) + + it("isErr returns false", () => { + expect(new Ok(42).isErr()).toBe(false) + }) + }) + + describe("Err class", () => { + it("creates an Err with an error", () => { + const result = new Err("something went wrong") + expect(result.ok).toBe(false) + expect(result.error).toBe("something went wrong") + }) + + it("map does not transform (returns same Err)", () => { + const original = new Err("error") + const result = original.map((x: number) => x * 2) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error).toBe("error") + } + }) + + it("flatMap does not transform (returns same Err)", () => { + const original = new Err("error") + const result = original.flatMap((x: number) => new Ok(x * 2)) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error).toBe("error") + } + }) + + it("unwrap throws the error", () => { + const error = new Error("oops") + const result = new Err(error) + expect(() => result.unwrap()).toThrow("oops") + }) + + it("unwrapOr returns the default value", () => { + const result = new Err("error") + expect(result.unwrapOr(42)).toBe(42) + }) + + it("unwrapOrElse calls the function", () => { + const result = new Err("error") + expect(result.unwrapOrElse(() => 42)).toBe(42) + }) + + it("match calls the err handler", () => { + const result = new Err("oops").match({ + ok: (v) => `Success: ${v}`, + err: (e) => `Error: ${e}`, + }) + expect(result).toBe("Error: oops") + }) + + it("isOk returns false", () => { + expect(new Err("error").isOk()).toBe(false) + }) + + it("isErr returns true", () => { + expect(new Err("error").isErr()).toBe(true) + }) + }) + + describe("factory functions", () => { + it("ok() creates an Ok", () => { + const result = ok(42) + expect(result).toBeInstanceOf(Ok) + expect(result.value).toBe(42) + }) + + it("err() creates an Err", () => { + const result = err("error") + expect(result).toBeInstanceOf(Err) + expect(result.error).toBe("error") + }) + }) + + describe("type narrowing", () => { + it("narrows type with ok property", () => { + const result: Result = ok(42) + + if (result.ok) { + // TypeScript should know result.value exists + expect(result.value).toBe(42) + } else { + // TypeScript should know result.error exists + expect(result.error).toBeDefined() + } + }) + + it("narrows type with isOk()", () => { + const result: Result = ok(42) + + if (result.isOk()) { + expect(result.value).toBe(42) + } + }) + + it("narrows type with isErr()", () => { + const result: Result = err("oops") + + if (result.isErr()) { + expect(result.error).toBe("oops") + } + }) + }) +}) + +describe("Money.parse()", () => { + describe("successful parsing", () => { + it("parses valid USD string", () => { + const result = MoneyClass.parse("$100.00") + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.toString()).toBe("$100.00") + } + }) + + it("parses valid EUR string", () => { + const result = MoneyClass.parse("€50.00") + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.toString()).toBe("€50.00") + } + }) + + it("parses currency code format", () => { + const result = MoneyClass.parse("100 USD") + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.toString()).toBe("$100.00") + } + }) + + it("parses BTC format", () => { + const result = MoneyClass.parse("1.5 BTC") + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.currency.code).toBe("BTC") + } + }) + }) + + describe("failed parsing", () => { + it("returns Err for invalid input", () => { + const result = MoneyClass.parse("not money") + expect(result.ok).toBe(false) + }) + + it("error has helpful message", () => { + const result = MoneyClass.parse("invalid") + if (!result.ok) { + expect(result.error).toBeInstanceOf(ParseError) + expect(result.error.message).toBeDefined() + } + }) + + it("returns Err for empty string", () => { + const result = MoneyClass.parse("") + expect(result.ok).toBe(false) + }) + }) + + describe("chaining methods", () => { + it("map transforms successful result", () => { + const result = MoneyClass.parse("$100.00") + .map((m) => m.multiply(2n)) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.toString()).toBe("$200.00") + } + }) + + it("map is no-op on error", () => { + const result = MoneyClass.parse("invalid") + .map((m) => m.multiply(2n)) + + expect(result.ok).toBe(false) + }) + + it("unwrapOr returns default on error", () => { + const money = MoneyClass.parse("invalid") + .unwrapOr(MoneyClass.zero("USD")) + + expect(money.toString()).toBe("$0.00") + }) + + it("match handles both cases", () => { + const successMessage = MoneyClass.parse("$100.00").match({ + ok: (m) => `Got: ${m.toString()}`, + err: (e) => `Error: ${e.message}`, + }) + expect(successMessage).toBe("Got: $100.00") + + const errorMessage = MoneyClass.parse("invalid").match({ + ok: (m) => `Got: ${m.toString()}`, + err: (e) => `Error: ${e.message}`, + }) + expect(errorMessage).toContain("Error:") + }) + }) +}) + +describe("Money.tryFrom()", () => { + describe("string input", () => { + it("creates Money from valid string", () => { + const result = MoneyClass.tryFrom("$100.00") + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.toString()).toBe("$100.00") + } + }) + + it("returns Err for invalid string", () => { + const result = MoneyClass.tryFrom("not money") + expect(result.ok).toBe(false) + }) + }) + + describe("number input", () => { + it("creates Money from number with currency", () => { + const result = MoneyClass.tryFrom(100, "USD") + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.toString()).toBe("$100.00") + } + }) + + it("returns Err when currency missing for number", () => { + const result = MoneyClass.tryFrom(100) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error.message).toContain("Currency is required") + } + }) + }) + + describe("bigint input", () => { + it("creates Money from bigint with currency", () => { + const result = MoneyClass.tryFrom(10050n, "USD") + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.toString()).toBe("$100.50") + } + }) + + it("returns Err when currency missing for bigint", () => { + const result = MoneyClass.tryFrom(10050n) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error.message).toContain("Currency is required") + } + }) + }) + + describe("JSON input", () => { + it("creates Money from JSON object", () => { + const result = MoneyClass.tryFrom({ + asset: { code: "USD", decimals: 2n, name: "US Dollar" }, + amount: { amount: 10000n, decimals: 2n }, + }) + expect(result.ok).toBe(true) + if (result.ok) { + // Verify the amount is correct + expect(result.value.balance.amount.amount).toBe(10000n) + expect(result.value.currency.code).toBe("USD") + } + }) + }) + + describe("error types", () => { + it("returns CentError for known error types", () => { + const result = MoneyClass.tryFrom(100) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error).toBeInstanceOf(CentError) + } + }) + + it("error has suggestion", () => { + const result = MoneyClass.tryFrom(100) + if (!result.ok) { + expect(result.error.suggestion).toBeDefined() + } + }) + }) + + describe("chaining methods", () => { + it("flatMap chains operations", () => { + const result = MoneyClass.tryFrom("$100.00") + .flatMap((m) => MoneyClass.tryFrom(m.add("$50.00").toString())) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.toString()).toBe("$150.00") + } + }) + + it("unwrapOrElse computes default lazily", () => { + let computed = false + const money = MoneyClass.tryFrom("invalid") + .unwrapOrElse(() => { + computed = true + return MoneyClass.zero("USD") + }) + + expect(computed).toBe(true) + expect(money.toString()).toBe("$0.00") + }) + + it("unwrapOrElse does not compute for success", () => { + let computed = false + const money = MoneyClass.tryFrom("$100.00") + .unwrapOrElse(() => { + computed = true + return MoneyClass.zero("USD") + }) + + expect(computed).toBe(false) + expect(money.toString()).toBe("$100.00") + }) + }) +}) + +describe("practical use cases", () => { + it("safely processes user input", () => { + const processInput = (input: string) => + MoneyClass.parse(input).match({ + ok: (money) => ({ success: true, amount: money.toString() }), + err: (error) => ({ success: false, error: error.message }), + }) + + expect(processInput("$100.00")).toEqual({ + success: true, + amount: "$100.00", + }) + + const errorResult = processInput("bad input") + expect(errorResult.success).toBe(false) + expect((errorResult as { error: string }).error).toBeDefined() + }) + + it("provides default values for invalid input", () => { + const inputs = ["$100.00", "invalid", "$50.00", "bad"] + const amounts = inputs.map((input) => + MoneyClass.parse(input).unwrapOr(MoneyClass.zero("USD")) + ) + + expect(amounts[0].toString()).toBe("$100.00") + expect(amounts[1].toString()).toBe("$0.00") + expect(amounts[2].toString()).toBe("$50.00") + expect(amounts[3].toString()).toBe("$0.00") + }) + + it("chains multiple operations safely", () => { + const calculateTotal = (priceStr: string, quantityStr: string) => + MoneyClass.parse(priceStr) + .flatMap((price) => { + const qty = parseInt(quantityStr, 10) + if (isNaN(qty) || qty <= 0) { + return err( + new InvalidInputError("Invalid quantity", { + suggestion: "Provide a positive integer quantity", + }) + ) + } + return ok(price.multiply(BigInt(qty))) + }) + + const result1 = calculateTotal("$25.00", "4") + expect(result1.ok).toBe(true) + if (result1.ok) { + expect(result1.value.toString()).toBe("$100.00") + } + + const result2 = calculateTotal("invalid", "4") + expect(result2.ok).toBe(false) + + const result3 = calculateTotal("$25.00", "bad") + expect(result3.ok).toBe(false) + }) + + it("collects valid amounts from mixed input", () => { + const inputs = ["$100.00", "bad", "€50.00", "invalid", "30 GBP"] + const validAmounts = inputs + .map((input) => MoneyClass.parse(input)) + .filter((result): result is Ok> => result.ok) + .map((result) => result.value) + + expect(validAmounts).toHaveLength(3) + expect(validAmounts[0].toString()).toBe("$100.00") + expect(validAmounts[1].toString()).toBe("€50.00") + expect(validAmounts[2].toString()).toBe("£30.00") + }) +}) diff --git a/packages/cent/test/rounding.test.ts b/packages/cent/test/rounding.test.ts new file mode 100644 index 0000000..ac2737a --- /dev/null +++ b/packages/cent/test/rounding.test.ts @@ -0,0 +1,318 @@ +import { describe, expect, it } from "@jest/globals" +import { Money, Round, USD, BTC } from "../src" + +describe("Round constants", () => { + it("exports all rounding modes", () => { + expect(Round.UP).toBe("expand") + expect(Round.DOWN).toBe("trunc") + expect(Round.CEILING).toBe("ceil") + expect(Round.FLOOR).toBe("floor") + expect(Round.HALF_UP).toBe("halfExpand") + expect(Round.HALF_DOWN).toBe("halfTrunc") + expect(Round.HALF_EVEN).toBe("halfEven") + }) +}) + +describe("Money.divide()", () => { + describe("exact division (factors of 2 and 5 only)", () => { + it("divides by 2 without rounding mode", () => { + const money = Money("$100.00") + const result = money.divide(2) + expect(result.toString()).toBe("$50.00") + }) + + it("divides by 5 without rounding mode", () => { + const money = Money("$100.00") + const result = money.divide(5) + expect(result.toString()).toBe("$20.00") + }) + + it("divides by 10 without rounding mode", () => { + const money = Money("$100.00") + const result = money.divide(10) + expect(result.toString()).toBe("$10.00") + }) + + it("divides by 4 (2*2) without rounding mode", () => { + const money = Money("$100.00") + const result = money.divide(4) + expect(result.toString()).toBe("$25.00") + }) + + it("divides by 25 (5*5) without rounding mode", () => { + const money = Money("$100.00") + const result = money.divide(25) + expect(result.toString()).toBe("$4.00") + }) + + it("divides by decimal string with only 2/5 factors", () => { + const money = Money("$100.00") + const result = money.divide("0.5") + expect(result.toString()).toBe("$200.00") + }) + }) + + describe("division requiring rounding", () => { + it("throws without rounding mode for divisor 3", () => { + const money = Money("$100.00") + expect(() => money.divide(3)).toThrow(/requires a rounding mode/) + }) + + it("throws without rounding mode for divisor 7", () => { + const money = Money("$100.00") + expect(() => money.divide(7)).toThrow(/requires a rounding mode/) + }) + + it("divides by 3 with HALF_UP rounding", () => { + const money = Money("$100.00") + const result = money.divide(3, Round.HALF_UP) + expect(result.toString()).toBe("$33.33") + }) + + it("divides by 3 with CEILING rounding", () => { + const money = Money("$100.00") + const result = money.divide(3, Round.CEILING) + expect(result.toString()).toBe("$33.34") + }) + + it("divides by 3 with FLOOR rounding", () => { + const money = Money("$100.00") + const result = money.divide(3, Round.FLOOR) + expect(result.toString()).toBe("$33.33") + }) + + it("divides by 7 with HALF_UP rounding", () => { + const money = Money("$100.00") + const result = money.divide(7, Round.HALF_UP) + expect(result.toString()).toBe("$14.29") + }) + + it("divides by 6 with HALF_EVEN (banker's rounding)", () => { + const money = Money("$100.00") + const result = money.divide(6, Round.HALF_EVEN) + // 100/6 = 16.666... → rounds to 16.67 + expect(result.toString()).toBe("$16.67") + }) + }) + + describe("negative divisors", () => { + it("handles negative bigint divisor", () => { + const money = Money("$100.00") + const result = money.divide(-2n) + expect(result.toString()).toBe("-$50.00") + }) + + it("handles negative number divisor with rounding", () => { + const money = Money("$100.00") + const result = money.divide(-3, Round.HALF_UP) + expect(result.toString()).toBe("-$33.33") + }) + }) + + describe("edge cases", () => { + it("throws on division by zero (number)", () => { + const money = Money("$100.00") + expect(() => money.divide(0)).toThrow(/Cannot divide by zero/) + }) + + it("throws on division by zero (bigint)", () => { + const money = Money("$100.00") + expect(() => money.divide(0n)).toThrow(/Cannot divide by zero/) + }) + + it("throws on division by zero (string)", () => { + const money = Money("$100.00") + expect(() => money.divide("0")).toThrow(/Cannot divide by zero/) + }) + + it("throws on division by Infinity", () => { + const money = Money("$100.00") + expect(() => money.divide(Infinity)).toThrow(/Cannot divide by Infinity/) + }) + + it("throws on division by NaN", () => { + const money = Money("$100.00") + expect(() => money.divide(NaN)).toThrow(/Cannot divide by Infinity or NaN/) + }) + + it("preserves currency precision for BTC", () => { + const money = Money("1 BTC") + const result = money.divide(3, Round.HALF_UP) + // BTC has 8 decimals, toString uses code format for BTC + expect(result.toString()).toBe("0.33333333 BTC") + }) + }) +}) + +describe("Money.round()", () => { + it("rounds to currency precision with default HALF_UP", () => { + const money = Money({ + asset: USD, + amount: { amount: 100125n, decimals: 3n }, + }) + const result = money.round() + expect(result.toString()).toBe("$100.13") + }) + + it("rounds with HALF_EVEN (banker's rounding)", () => { + const money = Money({ + asset: USD, + amount: { amount: 100125n, decimals: 3n }, + }) + const result = money.round(Round.HALF_EVEN) + expect(result.toString()).toBe("$100.12") + }) + + it("rounds with FLOOR", () => { + const money = Money({ + asset: USD, + amount: { amount: 100129n, decimals: 3n }, + }) + const result = money.round(Round.FLOOR) + expect(result.toString()).toBe("$100.12") + }) + + it("rounds with CEILING", () => { + const money = Money({ + asset: USD, + amount: { amount: 100121n, decimals: 3n }, + }) + const result = money.round(Round.CEILING) + expect(result.toString()).toBe("$100.13") + }) + + it("handles negative amounts", () => { + const money = Money({ + asset: USD, + amount: { amount: -100125n, decimals: 3n }, + }) + const result = money.round(Round.HALF_UP) + expect(result.toString()).toBe("-$100.13") + }) + + it("returns same value if already at precision", () => { + const money = Money("$100.12") + const result = money.round() + expect(result.toString()).toBe("$100.12") + }) +}) + +describe("Money.roundTo()", () => { + it("rounds to specified decimal places", () => { + const money = Money({ + asset: USD, + amount: { amount: 10012345n, decimals: 5n }, + }) + // Check internal representation, not string (toString uses currency decimals) + expect(money.roundTo(2).balance.amount.amount).toBe(10012n) + expect(money.roundTo(2).balance.amount.decimals).toBe(2n) + expect(money.roundTo(3).balance.amount.amount).toBe(100123n) + expect(money.roundTo(3).balance.amount.decimals).toBe(3n) + expect(money.roundTo(4).balance.amount.amount).toBe(1001235n) + expect(money.roundTo(4).balance.amount.decimals).toBe(4n) + }) + + it("rounds to 0 decimal places", () => { + const money = Money({ + asset: USD, + amount: { amount: 10050n, decimals: 2n }, + }) + const result = money.roundTo(0, Round.HALF_UP) + expect(result.balance.amount.amount).toBe(101n) + expect(result.balance.amount.decimals).toBe(0n) + }) + + it("throws for negative decimals", () => { + const money = Money("$100.00") + expect(() => money.roundTo(-1)).toThrow(/non-negative/) + }) + + it("pads with zeros when increasing precision", () => { + const money = Money("$100.12") + const result = money.roundTo(4) + expect(result.balance.amount.decimals).toBe(4n) + expect(result.balance.amount.amount).toBe(1001200n) + }) +}) + +describe("Money.multiply() with rounding", () => { + it("multiplies without rounding", () => { + const money = Money("$100.00") + const result = money.multiply("1.5") + expect(result.toString()).toBe("$150.00") + }) + + it("multiplies with rounding to currency precision", () => { + const money = Money("$100.00") + const result = money.multiply("0.333", Round.HALF_UP) + expect(result.toString()).toBe("$33.30") + }) + + it("multiplies by bigint", () => { + const money = Money("$100.00") + const result = money.multiply(3n) + expect(result.toString()).toBe("$300.00") + }) +}) + +describe("Rounding mode behaviors", () => { + // Test all rounding modes with a value that ends in .5 (tie case) + const createMoney = (amount: bigint) => + Money({ asset: USD, amount: { amount, decimals: 3n } }) + + describe("positive ties (.5)", () => { + it("HALF_UP rounds ties away from zero", () => { + // 2.505 -> 2.51 (ties away from zero) + expect(createMoney(2505n).roundTo(2, Round.HALF_UP).balance.amount.amount).toBe(251n) + }) + + it("HALF_DOWN rounds ties toward zero", () => { + // 2.505 -> 2.50 (ties toward zero) + expect(createMoney(2505n).roundTo(2, Round.HALF_DOWN).balance.amount.amount).toBe(250n) + }) + + it("HALF_EVEN rounds ties to even", () => { + // 2.505 -> 2.50 (5 is odd, round down to even) + expect(createMoney(2505n).roundTo(2, Round.HALF_EVEN).balance.amount.amount).toBe(250n) + // 2.515 -> 2.52 (1 is odd, round up to even) + expect(createMoney(2515n).roundTo(2, Round.HALF_EVEN).balance.amount.amount).toBe(252n) + }) + }) + + describe("negative ties (-.5)", () => { + it("HALF_UP rounds ties away from zero", () => { + // -2.505 -> -2.51 (ties away from zero) + expect(createMoney(-2505n).roundTo(2, Round.HALF_UP).balance.amount.amount).toBe(-251n) + }) + }) + + describe("non-tie cases", () => { + it("UP rounds away from zero", () => { + // 2.101 -> 2.11 (away from zero) + expect(createMoney(2101n).roundTo(2, Round.UP).balance.amount.amount).toBe(211n) + // -2.101 -> -2.11 (away from zero) + expect(createMoney(-2101n).roundTo(2, Round.UP).balance.amount.amount).toBe(-211n) + }) + + it("DOWN rounds toward zero", () => { + // 2.109 -> 2.10 (toward zero) + expect(createMoney(2109n).roundTo(2, Round.DOWN).balance.amount.amount).toBe(210n) + // -2.109 -> -2.10 (toward zero) + expect(createMoney(-2109n).roundTo(2, Round.DOWN).balance.amount.amount).toBe(-210n) + }) + + it("CEILING rounds toward positive infinity", () => { + // 2.101 -> 2.11 (toward +infinity) + expect(createMoney(2101n).roundTo(2, Round.CEILING).balance.amount.amount).toBe(211n) + // -2.109 -> -2.10 (toward +infinity) + expect(createMoney(-2109n).roundTo(2, Round.CEILING).balance.amount.amount).toBe(-210n) + }) + + it("FLOOR rounds toward negative infinity", () => { + // 2.109 -> 2.10 (toward -infinity) + expect(createMoney(2109n).roundTo(2, Round.FLOOR).balance.amount.amount).toBe(210n) + // -2.101 -> -2.11 (toward -infinity) + expect(createMoney(-2101n).roundTo(2, Round.FLOOR).balance.amount.amount).toBe(-211n) + }) + }) +}) diff --git a/packages/cent/test/type-guards.test.ts b/packages/cent/test/type-guards.test.ts new file mode 100644 index 0000000..4f15950 --- /dev/null +++ b/packages/cent/test/type-guards.test.ts @@ -0,0 +1,588 @@ +import { describe, expect, it } from "@jest/globals" +import { + Money, + MoneyClass, + ValidationError, +} from "../src" + +describe("Type Guards and Assertions", () => { + describe("Money.isMoney()", () => { + it("returns true for Money instances", () => { + const money = Money("$100") + expect(MoneyClass.isMoney(money)).toBe(true) + }) + + it("returns false for non-Money values", () => { + expect(MoneyClass.isMoney(null)).toBe(false) + expect(MoneyClass.isMoney(undefined)).toBe(false) + expect(MoneyClass.isMoney(100)).toBe(false) + expect(MoneyClass.isMoney("$100")).toBe(false) + expect(MoneyClass.isMoney({})).toBe(false) + expect(MoneyClass.isMoney({ amount: 100, currency: "USD" })).toBe(false) + }) + + it("returns true for any currency when no currency specified", () => { + expect(MoneyClass.isMoney(Money("$100"))).toBe(true) + expect(MoneyClass.isMoney(Money("€50"))).toBe(true) + expect(MoneyClass.isMoney(Money("1 BTC"))).toBe(true) + }) + + describe("with currency parameter", () => { + it("returns true when currency matches", () => { + expect(MoneyClass.isMoney(Money("$100"), "USD")).toBe(true) + expect(MoneyClass.isMoney(Money("€50"), "EUR")).toBe(true) + expect(MoneyClass.isMoney(Money("£30"), "GBP")).toBe(true) + }) + + it("returns false when currency does not match", () => { + expect(MoneyClass.isMoney(Money("$100"), "EUR")).toBe(false) + expect(MoneyClass.isMoney(Money("€50"), "USD")).toBe(false) + expect(MoneyClass.isMoney(Money("£30"), "USD")).toBe(false) + }) + + it("returns false for non-Money even with currency specified", () => { + expect(MoneyClass.isMoney("$100", "USD")).toBe(false) + expect(MoneyClass.isMoney(100, "USD")).toBe(false) + }) + }) + + it("works as type guard in conditionals", () => { + const values: unknown[] = [Money("$100"), 100, "$100", null] + const moneyValues = values.filter((v) => MoneyClass.isMoney(v)) + expect(moneyValues).toHaveLength(1) + }) + }) + + describe("Money.assertMoney()", () => { + it("does not throw for Money instances", () => { + const money = Money("$100") + expect(() => MoneyClass.assertMoney(money)).not.toThrow() + }) + + it("throws ValidationError for non-Money values", () => { + expect(() => MoneyClass.assertMoney(null)).toThrow(ValidationError) + expect(() => MoneyClass.assertMoney(undefined)).toThrow(ValidationError) + expect(() => MoneyClass.assertMoney(100)).toThrow(ValidationError) + expect(() => MoneyClass.assertMoney("$100")).toThrow(ValidationError) + expect(() => MoneyClass.assertMoney({})).toThrow(ValidationError) + }) + + it("includes type info in default error message", () => { + try { + MoneyClass.assertMoney(100) + expect(true).toBe(false) // Should not reach here + } catch (e) { + expect((e as Error).message).toContain("number") + } + }) + + it("uses custom message when provided", () => { + try { + MoneyClass.assertMoney(100, "Payment amount is invalid") + expect(true).toBe(false) + } catch (e) { + expect((e as Error).message).toBe("Payment amount is invalid") + } + }) + + it("error includes suggestion", () => { + try { + MoneyClass.assertMoney(100) + expect(true).toBe(false) + } catch (e) { + expect((e as ValidationError).suggestion).toContain("Money") + } + }) + + it("works as type assertion in functions", () => { + function processPayment(amount: unknown): string { + MoneyClass.assertMoney(amount) + // TypeScript should know amount is Money after assertion + return amount.toString() + } + + expect(processPayment(Money("$100"))).toBe("$100.00") + expect(() => processPayment("$100")).toThrow(ValidationError) + }) + }) + + describe("Money.assertPositive()", () => { + it("does not throw for positive amounts", () => { + expect(() => MoneyClass.assertPositive(Money("$100"))).not.toThrow() + expect(() => MoneyClass.assertPositive(Money("$0.01"))).not.toThrow() + }) + + it("throws ValidationError for zero", () => { + expect(() => MoneyClass.assertPositive(Money("$0"))).toThrow( + ValidationError + ) + }) + + it("throws ValidationError for negative amounts", () => { + expect(() => MoneyClass.assertPositive(Money("-$100"))).toThrow( + ValidationError + ) + }) + + it("uses custom message when provided", () => { + try { + MoneyClass.assertPositive(Money("$0"), "Amount must be positive") + expect(true).toBe(false) + } catch (e) { + expect((e as Error).message).toBe("Amount must be positive") + } + }) + + it("includes amount in default error message", () => { + try { + MoneyClass.assertPositive(Money("-$50")) + expect(true).toBe(false) + } catch (e) { + expect((e as Error).message).toContain("-$50") + } + }) + + it("error includes suggestion", () => { + try { + MoneyClass.assertPositive(Money("$0")) + expect(true).toBe(false) + } catch (e) { + expect((e as ValidationError).suggestion).toContain("greater than zero") + } + }) + }) + + describe("Money.assertNonNegative()", () => { + it("does not throw for positive amounts", () => { + expect(() => MoneyClass.assertNonNegative(Money("$100"))).not.toThrow() + }) + + it("does not throw for zero", () => { + expect(() => MoneyClass.assertNonNegative(Money("$0"))).not.toThrow() + }) + + it("throws ValidationError for negative amounts", () => { + expect(() => MoneyClass.assertNonNegative(Money("-$100"))).toThrow( + ValidationError + ) + expect(() => MoneyClass.assertNonNegative(Money("-$0.01"))).toThrow( + ValidationError + ) + }) + + it("uses custom message when provided", () => { + try { + MoneyClass.assertNonNegative(Money("-$50"), "Cannot be negative") + expect(true).toBe(false) + } catch (e) { + expect((e as Error).message).toBe("Cannot be negative") + } + }) + + it("includes amount in default error message", () => { + try { + MoneyClass.assertNonNegative(Money("-$50")) + expect(true).toBe(false) + } catch (e) { + expect((e as Error).message).toContain("-$50") + } + }) + + it("error includes suggestion", () => { + try { + MoneyClass.assertNonNegative(Money("-$50")) + expect(true).toBe(false) + } catch (e) { + expect((e as ValidationError).suggestion).toContain( + "greater than or equal to zero" + ) + } + }) + }) + + describe("Money.assertNonZero()", () => { + it("does not throw for positive amounts", () => { + expect(() => MoneyClass.assertNonZero(Money("$100"))).not.toThrow() + }) + + it("does not throw for negative amounts", () => { + expect(() => MoneyClass.assertNonZero(Money("-$100"))).not.toThrow() + }) + + it("throws ValidationError for zero", () => { + expect(() => MoneyClass.assertNonZero(Money("$0"))).toThrow( + ValidationError + ) + }) + + it("uses custom message when provided", () => { + try { + MoneyClass.assertNonZero(Money("$0"), "Amount cannot be zero") + expect(true).toBe(false) + } catch (e) { + expect((e as Error).message).toBe("Amount cannot be zero") + } + }) + + it("includes amount in default error message", () => { + try { + MoneyClass.assertNonZero(Money("$0")) + expect(true).toBe(false) + } catch (e) { + expect((e as Error).message).toContain("$0") + } + }) + + it("error includes suggestion", () => { + try { + MoneyClass.assertNonZero(Money("$0")) + expect(true).toBe(false) + } catch (e) { + expect((e as ValidationError).suggestion).toContain("non-zero") + } + }) + }) + + describe("money.validate()", () => { + describe("positive constraint", () => { + it("returns Ok for positive amount when positive: true", () => { + const result = Money("$100").validate({ positive: true }) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.toString()).toBe("$100.00") + } + }) + + it("returns Err for zero when positive: true", () => { + const result = Money("$0").validate({ positive: true }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error).toBeInstanceOf(ValidationError) + expect(result.error.message).toContain("positive") + } + }) + + it("returns Err for negative when positive: true", () => { + const result = Money("-$50").validate({ positive: true }) + expect(result.ok).toBe(false) + }) + }) + + describe("nonNegative constraint", () => { + it("returns Ok for positive amount when nonNegative: true", () => { + const result = Money("$100").validate({ nonNegative: true }) + expect(result.ok).toBe(true) + }) + + it("returns Ok for zero when nonNegative: true", () => { + const result = Money("$0").validate({ nonNegative: true }) + expect(result.ok).toBe(true) + }) + + it("returns Err for negative when nonNegative: true", () => { + const result = Money("-$50").validate({ nonNegative: true }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error).toBeInstanceOf(ValidationError) + expect(result.error.message).toContain("non-negative") + } + }) + }) + + describe("nonZero constraint", () => { + it("returns Ok for positive amount when nonZero: true", () => { + const result = Money("$100").validate({ nonZero: true }) + expect(result.ok).toBe(true) + }) + + it("returns Ok for negative amount when nonZero: true", () => { + const result = Money("-$50").validate({ nonZero: true }) + expect(result.ok).toBe(true) + }) + + it("returns Err for zero when nonZero: true", () => { + const result = Money("$0").validate({ nonZero: true }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error).toBeInstanceOf(ValidationError) + expect(result.error.message).toContain("non-zero") + } + }) + }) + + describe("min constraint", () => { + it("returns Ok when amount >= min (Money)", () => { + const result = Money("$100").validate({ min: Money("$50") }) + expect(result.ok).toBe(true) + }) + + it("returns Ok when amount equals min", () => { + const result = Money("$50").validate({ min: Money("$50") }) + expect(result.ok).toBe(true) + }) + + it("returns Err when amount < min", () => { + const result = Money("$25").validate({ min: Money("$50") }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error.message).toContain("less than minimum") + } + }) + + it("accepts min as string", () => { + const result = Money("$100").validate({ min: "$50" }) + expect(result.ok).toBe(true) + + const result2 = Money("$25").validate({ min: "$50" }) + expect(result2.ok).toBe(false) + }) + + it("accepts min as number", () => { + const result = Money("$100").validate({ min: 50 }) + expect(result.ok).toBe(true) + + const result2 = Money("$25").validate({ min: 50 }) + expect(result2.ok).toBe(false) + }) + }) + + describe("max constraint", () => { + it("returns Ok when amount <= max (Money)", () => { + const result = Money("$50").validate({ max: Money("$100") }) + expect(result.ok).toBe(true) + }) + + it("returns Ok when amount equals max", () => { + const result = Money("$100").validate({ max: Money("$100") }) + expect(result.ok).toBe(true) + }) + + it("returns Err when amount > max", () => { + const result = Money("$150").validate({ max: Money("$100") }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error.message).toContain("greater than maximum") + } + }) + + it("accepts max as string", () => { + const result = Money("$50").validate({ max: "$100" }) + expect(result.ok).toBe(true) + + const result2 = Money("$150").validate({ max: "$100" }) + expect(result2.ok).toBe(false) + }) + + it("accepts max as number", () => { + const result = Money("$50").validate({ max: 100 }) + expect(result.ok).toBe(true) + + const result2 = Money("$150").validate({ max: 100 }) + expect(result2.ok).toBe(false) + }) + }) + + describe("multiple constraints", () => { + it("validates all constraints together", () => { + const result = Money("$50").validate({ + min: Money("$10"), + max: Money("$100"), + positive: true, + nonZero: true, + }) + expect(result.ok).toBe(true) + }) + + it("fails on first violated constraint", () => { + // positive is checked before min/max + const result = Money("-$50").validate({ + min: Money("-$100"), + max: Money("$100"), + positive: true, + }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error.message).toContain("positive") + } + }) + + it("validates min and max range", () => { + const result = Money("$50").validate({ + min: "$25", + max: "$75", + }) + expect(result.ok).toBe(true) + + const belowMin = Money("$10").validate({ + min: "$25", + max: "$75", + }) + expect(belowMin.ok).toBe(false) + + const aboveMax = Money("$100").validate({ + min: "$25", + max: "$75", + }) + expect(aboveMax.ok).toBe(false) + }) + }) + + describe("chaining with Result methods", () => { + it("chains with map() on success", () => { + const result = Money("$100") + .validate({ positive: true }) + .map((m) => m.multiply(2n)) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.toString()).toBe("$200.00") + } + }) + + it("chains with map() on failure", () => { + const result = Money("-$100") + .validate({ positive: true }) + .map((m) => m.multiply(2n)) + + expect(result.ok).toBe(false) + }) + + it("uses match() for branching", () => { + const getMessage = (amount: string) => + Money(amount) + .validate({ min: "$50", max: "$100" }) + .match({ + ok: (m) => `Valid: ${m.toString()}`, + err: (e) => `Invalid: ${e.message}`, + }) + + expect(getMessage("$75")).toBe("Valid: $75.00") + expect(getMessage("$25")).toContain("Invalid:") + expect(getMessage("$150")).toContain("Invalid:") + }) + + it("uses unwrapOr() for defaults", () => { + const amount = Money("-$50") + .validate({ nonNegative: true }) + .unwrapOr(Money("$0")) + + expect(amount.toString()).toBe("$0.00") + }) + }) + + describe("error details", () => { + it("error includes code", () => { + const result = Money("$0").validate({ positive: true }) + if (!result.ok) { + expect(result.error.code).toBeDefined() + } + }) + + it("error includes suggestion", () => { + const result = Money("$0").validate({ positive: true }) + if (!result.ok) { + expect(result.error.suggestion).toBeDefined() + } + }) + + it("min/max errors include bounds in message", () => { + const result = Money("$25").validate({ min: "$50" }) + if (!result.ok) { + expect(result.error.message).toContain("$25") + expect(result.error.message).toContain("$50") + } + }) + }) + }) + + describe("practical use cases", () => { + it("validates payment amounts", () => { + function validatePayment(amount: unknown) { + if (!MoneyClass.isMoney(amount)) { + return { valid: false, error: "Not a Money instance" } + } + + const result = amount.validate({ + positive: true, + max: "$10000", + }) + + return result.match({ + ok: (m) => ({ valid: true, amount: m.toString() }), + err: (e) => ({ valid: false, error: e.message }), + }) + } + + expect(validatePayment(Money("$100"))).toEqual({ + valid: true, + amount: "$100.00", + }) + expect(validatePayment(Money("$0"))).toEqual({ + valid: false, + error: expect.stringContaining("positive"), + }) + expect(validatePayment(Money("$50000"))).toEqual({ + valid: false, + error: expect.stringContaining("maximum"), + }) + expect(validatePayment("$100")).toEqual({ + valid: false, + error: "Not a Money instance", + }) + }) + + it("type guards work with array filtering", () => { + const values: unknown[] = [ + Money("$100"), + "$50", + Money("€200"), + 100, + null, + Money("$25"), + ] + + const moneyValues = values.filter((v): v is ReturnType => + MoneyClass.isMoney(v) + ) + + expect(moneyValues).toHaveLength(3) + expect(moneyValues.every((m) => MoneyClass.isMoney(m))).toBe(true) + }) + + it("currency-specific filtering", () => { + const amounts = [ + Money("$100"), + Money("€200"), + Money("$50"), + Money("£30"), + Money("$25"), + ] + + const usdOnly = amounts.filter((m) => MoneyClass.isMoney(m, "USD")) + + expect(usdOnly).toHaveLength(3) + expect(usdOnly.every((m) => m.currency.code === "USD")).toBe(true) + }) + + it("assertion in processing pipeline", () => { + function processPayments(amounts: unknown[]): string[] { + return amounts.map((amount) => { + MoneyClass.assertMoney(amount) + MoneyClass.assertPositive(amount) + return amount.toString() + }) + } + + expect(processPayments([Money("$100"), Money("$50")])).toEqual([ + "$100.00", + "$50.00", + ]) + expect(() => + processPayments([Money("$100"), "not money"]) + ).toThrow(ValidationError) + expect(() => + processPayments([Money("$100"), Money("-$50")]) + ).toThrow(ValidationError) + }) + }) +})