From 01aa0bc2230a3d8288a97d46f969602b4af1a1c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hask=C5=8D?= Date: Sat, 3 Oct 2026 01:22:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(money):=20=E2=9C=A8=20Add=20Money=20and=20?= =?UTF-8?q?Currency=20value=20objects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + docs/.vitepress/config.mts | 8 + docs/getting-started/introduction.md | 1 + docs/index.md | 1 + docs/reference/currency.md | 53 ++++++ docs/reference/index.md | 5 + docs/reference/money.md | 77 ++++++++ src/errors/CurrencyMismatchError.ts | 7 + src/errors/InvalidMoneyError.ts | 7 + src/errors/index.ts | 2 + src/value-objects/index.ts | 1 + src/value-objects/money/Currency.ts | 70 ++++++++ src/value-objects/money/Money.ts | 147 ++++++++++++++++ src/value-objects/money/index.ts | 2 + tests/value-objects/money/Currency.spec.ts | 47 +++++ tests/value-objects/money/Money.spec.ts | 194 +++++++++++++++++++++ 16 files changed, 623 insertions(+) create mode 100644 docs/reference/currency.md create mode 100644 docs/reference/money.md create mode 100644 src/errors/CurrencyMismatchError.ts create mode 100644 src/errors/InvalidMoneyError.ts create mode 100644 src/value-objects/money/Currency.ts create mode 100644 src/value-objects/money/Money.ts create mode 100644 src/value-objects/money/index.ts create mode 100644 tests/value-objects/money/Currency.spec.ts create mode 100644 tests/value-objects/money/Money.spec.ts diff --git a/README.md b/README.md index 03e3fd4..3b96832 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,7 @@ try { | Identifiers | `ShortId`, `UUID` | | Date & time | `Timestamp`, `CalendarDay`, `Hour`, `Duration`, `MonthOfYear` | | Coordinates | `Latitude`, `Longitude`, `Coordinates` | +| Money | `Money`, `Currency` | | Hashes | `MD5Hash`, `SHA256Hash`, `SHA512Hash` | | Media | `Media` | | Collections | `UniqueObjectArray` | diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index c15f41c..992a37a 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -68,6 +68,14 @@ const referenceSidebar = [ { text: 'ShortId', link: '/reference/short-id' }, ], }, + { + text: 'Money', + collapsed: true, + items: [ + { text: 'Money', link: '/reference/money' }, + { text: 'Currency', link: '/reference/currency' }, + ], + }, { text: 'Time', collapsed: true, diff --git a/docs/getting-started/introduction.md b/docs/getting-started/introduction.md index 1faeec2..1b5020b 100644 --- a/docs/getting-started/introduction.md +++ b/docs/getting-started/introduction.md @@ -35,6 +35,7 @@ amount.isGreaterThan(5); // true | IDs | `ShortId`, `UUID` | | Time | `Timestamp`, `CalendarDay`, `Day`, `DayOfWeek`, `Duration`, `Hour`, `Month`, `MonthOfYear`, `TimestampInterval`, `Year` | | Location | `Latitude`, `Longitude`, `Coordinates` | +| Money | `Money`, `Currency` | | Hashes | `Hash`, `MD5Hash`, `SHA256Hash`, `SHA512Hash` | | Media | `Media` | | Collections | `UniqueObjectArray` | diff --git a/docs/index.md b/docs/index.md index 1149f02..151d18a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -37,6 +37,7 @@ yarn add @haskou/value-objects | [Base objects](/reference/value-object) | `ValueObject`, `NullObject`, `Enum` | | [Strings and numbers](/reference/string-value-object) | `StringValueObject`, `Email`, `Color`, `Password`, `NumberValueObject`, `Integer`, `PositiveNumber` | | [Identifiers](/reference/uuid) | `UUID`, `ShortId` | +| [Money](/reference/money) | `Money`, `Currency` | | [Time](/reference/timestamp) | `Timestamp`, `TimestampInterval`, `CalendarDay`, `Duration`, `Hour`, `Day`, `Year`, `Month`, `MonthOfYear`, `DayOfWeek` | | [Coordinates](/reference/coordinates) | `Latitude`, `Longitude`, `Coordinates` | | [Hashes](/reference/hash) | `Hash`, `MD5Hash`, `SHA256Hash`, `SHA512Hash` | diff --git a/docs/reference/currency.md b/docs/reference/currency.md new file mode 100644 index 0000000..af3df35 --- /dev/null +++ b/docs/reference/currency.md @@ -0,0 +1,53 @@ +--- +title: Currency +description: ISO 4217 currency code with its minor unit digits. +--- + +# `Currency` + +ISO 4217 currency code with its minor unit digits. + +## Import + +```typescript +import { Currency } from '@haskou/value-objects'; +``` + +## Signature + +```typescript +class Currency extends Enum +``` + +## Constructor + +```typescript +constructor(value: string) +``` + +## Methods + +| Method | Description | +| --------------------------- | ---------------------------------------------------------------------- | +| `static fromSymbol(symbol)` | Resolves `$` (USD), `£`, `¥` (JPY), `€`, `₡`, `₩`, `₪`, `₹` and `₺`. | +| `getValues()` | Returns the supported ISO codes. | +| `getMinorUnitDigits()` | Returns the decimals of the minor unit (`2` for `EUR`, `0` for `JPY`). | + +## Throws + +- `ValueNotInEnumError` for unsupported codes. +- `InvalidFormatError` for unknown symbols. + +## Example + +```typescript +import { Currency } from '@haskou/value-objects'; + +new Currency('JPY').getMinorUnitDigits(); // 0 +Currency.fromSymbol('€').valueOf(); // 'EUR' +``` + +## Related + +- [Money](/reference/money) +- [Reference overview](/reference/) diff --git a/docs/reference/index.md b/docs/reference/index.md index a49c592..d706565 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -37,6 +37,11 @@ Each exported class has its own page. - [`Longitude`](/reference/longitude) - [`Coordinates`](/reference/coordinates) +## Money + +- [`Money`](/reference/money) +- [`Currency`](/reference/currency) + ## Time - [`CalendarDay`](/reference/calendar-day) diff --git a/docs/reference/money.md b/docs/reference/money.md new file mode 100644 index 0000000..708c1e1 --- /dev/null +++ b/docs/reference/money.md @@ -0,0 +1,77 @@ +--- +title: Money +description: Exact monetary amount with currency, stored as integer minor units. +--- + +# `Money` + +Exact monetary amount with currency. The amount is held as an integer number of minor units (cents), so arithmetic never accumulates floating point errors. + +## Import + +```typescript +import { Money } from '@haskou/value-objects'; +``` + +## Signature + +```typescript +class Money extends ValueObject +``` + +The canonical value is `" "`, for example `"19.99 EUR"` or `"1500 JPY"`. + +## Constructor + +```typescript +constructor(value: string | StringValueObject) +``` + +The constructor is strict: the number of decimals must equal the currency's minor unit digits (`19.99 EUR`, `1500 JPY`), and `-0.00` is rejected. Use `fromString()` for lenient input. + +## Methods + +| Method | Description | +| ---------------------------------------- | ---------------------------------------------------------------------------------- | +| `static fromMinorUnits(units, currency)` | Builds from an integer amount of minor units and a currency code or `Currency`. | +| `static fromString(text)` | Parses `20€`, `€20`, `20 €`, `$19.5`, `-€3.07`, `20.50 EUR`, `EUR 20` or `20 eur`. | +| `getCurrency()` | Returns the `Currency`. | +| `getMinorUnits()` | Returns the amount in minor units as an `Integer`. | +| `isZero()` | True when the amount is zero. | +| `isNegative()` | True when the amount is below zero. | +| `isGreaterThan(other)` | Compares amounts of the same currency. | +| `isLessThan(other)` | Compares amounts of the same currency. | +| `add(other)` | Returns the sum as a new `Money`. | +| `subtract(other)` | Returns the difference as a new `Money`. | +| `multiply(factor)` | Returns the product rounded to the nearest minor unit. | + +## Throws + +- `InvalidMoneyError` for malformed text, too many decimals, missing or duplicated currency, or amounts beyond ±2^53 minor units. +- `CurrencyMismatchError` when operating on two different currencies. +- `ValueNotInEnumError` for unsupported ISO codes. +- `InvalidFormatError` for unknown currency symbols. + +## Example + +```typescript +import { Money } from '@haskou/value-objects'; + +const price = Money.fromString('19.99 EUR'); + +price.multiply(3).valueOf(); // '59.97 EUR' +Money.fromString('0.10 EUR').add(Money.fromString('0.20 EUR')).valueOf(); // '0.30 EUR' +Money.fromMinorUnits(1500, 'JPY').valueOf(); // '1500 JPY' +``` + +## Notes + +- Symbols `$` and `¥` resolve to `USD` and `JPY`; use ISO codes (`20 CAD`) for any other dollar or yen currency. +- Locale separators (`1,50`) are rejected. +- Allocation, `>=`/`<=`, `negate`, `abs` and locale formatting are not provided. + +## Related + +- [Currency](/reference/currency) +- [Error handling](/guides/error-handling) +- [Reference overview](/reference/) diff --git a/src/errors/CurrencyMismatchError.ts b/src/errors/CurrencyMismatchError.ts new file mode 100644 index 0000000..02f45ff --- /dev/null +++ b/src/errors/CurrencyMismatchError.ts @@ -0,0 +1,7 @@ +import { DomainError } from './DomainError'; + +export class CurrencyMismatchError extends DomainError { + constructor(expected: string, received: string) { + super(`Currency mismatch: expected ${expected}, received ${received}`); + } +} diff --git a/src/errors/InvalidMoneyError.ts b/src/errors/InvalidMoneyError.ts new file mode 100644 index 0000000..1ceb30a --- /dev/null +++ b/src/errors/InvalidMoneyError.ts @@ -0,0 +1,7 @@ +import { DomainError } from './DomainError'; + +export class InvalidMoneyError extends DomainError { + constructor(value: unknown) { + super(`Invalid money ${String(value)}`); + } +} diff --git a/src/errors/index.ts b/src/errors/index.ts index 7800491..493fd83 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -1,4 +1,5 @@ export * from './BaseError'; +export * from './CurrencyMismatchError'; export * from './DomainError'; export * from './InvalidColorError'; export * from './InvalidDayError'; @@ -12,6 +13,7 @@ export * from './InvalidLatitudeError'; export * from './InvalidLongitudeError'; export * from './InvalidEmailError'; export * from './InvalidMinutesError'; +export * from './InvalidMoneyError'; export * from './InvalidNumberError'; export * from './InvalidPasswordError'; export * from './InvalidPositiveNumberError'; diff --git a/src/value-objects/index.ts b/src/value-objects/index.ts index fcee17b..c5c2069 100644 --- a/src/value-objects/index.ts +++ b/src/value-objects/index.ts @@ -2,6 +2,7 @@ export * from './coordinates'; export * from './hashes'; export * from './ids'; export * from './media'; +export * from './money'; export * from './time'; export * from './Color'; export * from './Email'; diff --git a/src/value-objects/money/Currency.ts b/src/value-objects/money/Currency.ts new file mode 100644 index 0000000..ba7e56c --- /dev/null +++ b/src/value-objects/money/Currency.ts @@ -0,0 +1,70 @@ +import { InvalidFormatError } from '../../errors/InvalidFormatError'; +import { assert } from '../../patterns/Assert'; +import { Enum } from '../Enum'; + +export class Currency extends Enum { + private static readonly MINOR_UNIT_DIGITS: Record = { + AED: 2, + ARS: 2, + AUD: 2, + BRL: 2, + CAD: 2, + CHF: 2, + CLP: 0, + CNY: 2, + COP: 2, + CRC: 2, + CZK: 2, + DKK: 2, + EUR: 2, + GBP: 2, + HKD: 2, + HUF: 2, + IDR: 2, + ILS: 2, + INR: 2, + ISK: 0, + JPY: 0, + KRW: 0, + KWD: 3, + MXN: 2, + NOK: 2, + NZD: 2, + PEN: 2, + PLN: 2, + RON: 2, + SEK: 2, + SGD: 2, + THB: 2, + TRY: 2, + USD: 2, + ZAR: 2, + }; + + private static readonly SYMBOLS: Record = { + $: 'USD', + '£': 'GBP', + '¥': 'JPY', + '₡': 'CRC', + '₩': 'KRW', + '₪': 'ILS', + '€': 'EUR', + '₹': 'INR', + '₺': 'TRY', + }; + + public static fromSymbol(symbol: string): Currency { + const code = Currency.SYMBOLS[symbol]; + assert(code, new InvalidFormatError(symbol)); + + return new Currency(code); + } + + public getValues(): string[] { + return Object.keys(Currency.MINOR_UNIT_DIGITS); + } + + public getMinorUnitDigits(): number { + return Currency.MINOR_UNIT_DIGITS[this.valueOf()]; + } +} diff --git a/src/value-objects/money/Money.ts b/src/value-objects/money/Money.ts new file mode 100644 index 0000000..9b3a1c5 --- /dev/null +++ b/src/value-objects/money/Money.ts @@ -0,0 +1,147 @@ +import { CurrencyMismatchError } from '../../errors/CurrencyMismatchError'; +import { InvalidMoneyError } from '../../errors/InvalidMoneyError'; +import { assert } from '../../patterns/Assert'; +import { Integer } from '../Integer'; +import { NullObject } from '../NullObject'; +import { NumberValueObject } from '../NumberValueObject'; +import { StringValueObject } from '../StringValueObject'; +import { ValueObject } from '../ValueObject'; +import { Currency } from './Currency'; + +export class Money extends ValueObject { + private static readonly FORMAT = /^(-?)(\d+)(?:\.(\d+))? ([A-Z]{3})$/; + private static readonly TEXT_FORMAT = + /^(-?)(?:([^\d\s.-]+)\s*)?(\d+(?:\.\d+)?)(?:\s*([^\d\s.-]+))?$/; + + private readonly currency!: Currency; + private readonly minorUnits!: Integer; + + public static fromMinorUnits( + minorUnits: number | NumberValueObject, + currency: string | Currency, + ): Money { + const units = new Integer(minorUnits.valueOf()); + assert(Number.isSafeInteger(units.valueOf()), new InvalidMoneyError(units)); + + const code = new Currency(currency.valueOf()); + const digits = code.getMinorUnitDigits(); + const padded = String(Math.abs(units.valueOf())).padStart(digits + 1, '0'); + const integer = padded.slice(0, padded.length - digits); + const fraction = + digits > 0 ? `.${padded.slice(padded.length - digits)}` : ''; + const sign = units.isLessThan(0) ? '-' : ''; + + return new Money(`${sign}${integer}${fraction} ${code}`); + } + + public static fromString(text: string): Money { + const match = Money.TEXT_FORMAT.exec(text.trim()); + assert(match, new InvalidMoneyError(text)); + + const [, sign, prefix, amount, suffix] = match; + assert(!prefix !== !suffix, new InvalidMoneyError(text)); + + const token = prefix ?? suffix; + const currency = /^[A-Za-z]{3}$/.test(token) + ? new Currency(token.toUpperCase()) + : Currency.fromSymbol(token); + const [integer, fraction = ''] = amount.split('.'); + const digits = currency.getMinorUnitDigits(); + assert(fraction.length <= digits, new InvalidMoneyError(text)); + + return Money.fromMinorUnits( + Number(`${sign}${integer}${fraction.padEnd(digits, '0')}`), + currency, + ); + } + + constructor(value: string | StringValueObject) { + super(value?.valueOf()); + + if (NullObject.isNullObject(this)) { + return this; + } + + const match = Money.FORMAT.exec(this.value); + assert(match, new InvalidMoneyError(this.value)); + + const [, sign, integer, fraction = '', code] = match; + const currency = new Currency(code); + const minorUnits = new Integer(Number(`${sign}${integer}${fraction}`)); + + assert( + fraction.length === currency.getMinorUnitDigits() && + Number.isSafeInteger(minorUnits.valueOf()) && + !(sign && minorUnits.isZero()), + new InvalidMoneyError(this.value), + ); + + Object.defineProperties(this, { + currency: { enumerable: false, value: currency }, + minorUnits: { enumerable: false, value: minorUnits }, + }); + } + + private ensureSameCurrency(other: Money): void { + assert( + this.currency.isEqual(other.currency), + new CurrencyMismatchError( + this.currency.valueOf(), + other.currency.valueOf(), + ), + ); + } + + public getCurrency(): Currency { + return this.currency; + } + + public getMinorUnits(): Integer { + return this.minorUnits; + } + + public isZero(): boolean { + return this.minorUnits.isZero(); + } + + public isNegative(): boolean { + return this.minorUnits.isLessThan(0); + } + + public isGreaterThan(other: Money): boolean { + this.ensureSameCurrency(other); + + return this.minorUnits.isGreaterThan(other.minorUnits); + } + + public isLessThan(other: Money): boolean { + this.ensureSameCurrency(other); + + return this.minorUnits.isLessThan(other.minorUnits); + } + + public add(other: Money): Money { + this.ensureSameCurrency(other); + + return Money.fromMinorUnits( + this.minorUnits.add(other.minorUnits), + this.currency, + ); + } + + public subtract(other: Money): Money { + this.ensureSameCurrency(other); + + return Money.fromMinorUnits( + this.minorUnits.subtract(other.minorUnits), + this.currency, + ); + } + + public multiply(factor: number): Money { + return Money.fromMinorUnits( + Math.round(this.minorUnits.valueOf() * factor), + this.currency, + ); + } +} diff --git a/src/value-objects/money/index.ts b/src/value-objects/money/index.ts new file mode 100644 index 0000000..41d7a7a --- /dev/null +++ b/src/value-objects/money/index.ts @@ -0,0 +1,2 @@ +export * from './Currency'; +export * from './Money'; diff --git a/tests/value-objects/money/Currency.spec.ts b/tests/value-objects/money/Currency.spec.ts new file mode 100644 index 0000000..f2e8d38 --- /dev/null +++ b/tests/value-objects/money/Currency.spec.ts @@ -0,0 +1,47 @@ +import { + Currency, + InvalidFormatError, + NullObject, + ValueNotInEnumError, +} from '../../../src'; + +describe('Currency', () => { + it('should return a NullObject when a Nullish is received', () => { + expect(NullObject.isNullObject(new Currency(null as never))).toBeTrue(); + }); + + it('should accept ISO codes and reject unknown ones', () => { + expect(new Currency('EUR').valueOf()).toBe('EUR'); + expect(() => new Currency('XXX')).toThrow(ValueNotInEnumError); + }); + + it.each([ + ['EUR', 2], + ['USD', 2], + ['JPY', 0], + ['CLP', 0], + ['CRC', 2], + ])('should expose the minor unit digits of %s', (code, digits) => { + expect(new Currency(code).getMinorUnitDigits()).toBe(digits); + }); + + it('should list its supported codes', () => { + expect(new Currency('EUR').getValues()).toEqual( + expect.arrayContaining(['EUR', 'USD', 'JPY']), + ); + }); + + it.each([ + ['€', 'EUR'], + ['$', 'USD'], + ['£', 'GBP'], + ['¥', 'JPY'], + ['₡', 'CRC'], + ])('should resolve the symbol %s to %s', (symbol, code) => { + expect(Currency.fromSymbol(symbol).valueOf()).toBe(code); + }); + + it('should reject unknown symbols', () => { + expect(() => Currency.fromSymbol('#')).toThrow(InvalidFormatError); + }); +}); diff --git a/tests/value-objects/money/Money.spec.ts b/tests/value-objects/money/Money.spec.ts new file mode 100644 index 0000000..c6256af --- /dev/null +++ b/tests/value-objects/money/Money.spec.ts @@ -0,0 +1,194 @@ +import { + CurrencyMismatchError, + Integer, + InvalidFormatError, + InvalidMoneyError, + Money, + NullObject, + NullObjectError, + ValueNotInEnumError, +} from '../../../src'; + +describe('Money', () => { + describe('constructor', () => { + it('should return a NullObject when a Nullish is received', () => { + const money = new Money(null as never); + + expect(NullObject.isNullObject(money)).toBeTrue(); + expect(() => money.add(money)).toThrow(NullObjectError); + }); + + it.each(['19.99 EUR', '0.00 USD', '-3.07 EUR', '1500 JPY'])( + 'should accept %s', + (text) => { + expect(new Money(text).valueOf()).toBe(text); + }, + ); + + it.each([ + '19.9 EUR', + '19.999 EUR', + '1500.0 JPY', + '19.99', + '19.99EUR', + '19.99 eur', + '-0.00 EUR', + '9007199254740993.00 EUR', + '', + ])('should reject %p', (text) => { + expect(() => new Money(text)).toThrow(InvalidMoneyError); + }); + + it('should reject unknown currencies', () => { + expect(() => new Money('1.00 ZZZ')).toThrow(ValueNotInEnumError); + }); + + it('should only store the canonical value', () => { + expect(Object.keys(new Money('19.99 EUR'))).toEqual(['value']); + }); + }); + + describe('fromMinorUnits', () => { + it.each([ + [1999, 'EUR', '19.99 EUR'], + [5, 'EUR', '0.05 EUR'], + [0, 'EUR', '0.00 EUR'], + [-307, 'EUR', '-3.07 EUR'], + [-5, 'USD', '-0.05 USD'], + [1500, 'JPY', '1500 JPY'], + ])('should build %p %s as %s', (units, currency, expected) => { + expect(Money.fromMinorUnits(units, currency).valueOf()).toBe(expected); + }); + + it('should accept value objects', () => { + expect(Money.fromMinorUnits(new Integer(100), 'EUR').valueOf()).toBe( + '1.00 EUR', + ); + }); + + it('should reject non integer and unsafe amounts', () => { + expect(() => Money.fromMinorUnits(1.5, 'EUR')).toThrow(); + expect(() => Money.fromMinorUnits(2 ** 53, 'EUR')).toThrow( + InvalidMoneyError, + ); + }); + }); + + describe('fromString', () => { + it.each([ + ['20€', '20.00 EUR'], + ['€20', '20.00 EUR'], + ['20 €', '20.00 EUR'], + ['$19.5', '19.50 USD'], + ['-€3.07', '-3.07 EUR'], + ['20.50 EUR', '20.50 EUR'], + ['EUR 20', '20.00 EUR'], + ['20 eur', '20.00 EUR'], + ['1500¥', '1500 JPY'], + [' £7 ', '7.00 GBP'], + ['₡20', '20.00 CRC'], + ])('should parse %p as %s', (text, expected) => { + expect(Money.fromString(text).valueOf()).toBe(expected); + }); + + it.each([ + '20', + '€', + '€20€', + '€20 EUR', + '19.999 EUR', + '1.5 JPY', + '1,50 EUR', + '', + ])('should reject %p', (text) => { + expect(() => Money.fromString(text)).toThrow(InvalidMoneyError); + }); + + it('should reject unknown symbols', () => { + expect(() => Money.fromString('#20')).toThrow(InvalidFormatError); + }); + }); + + describe('accessors', () => { + const money = Money.fromString('19.99 EUR'); + + it('should expose currency and minor units', () => { + expect(money.getCurrency().valueOf()).toBe('EUR'); + expect(money.getMinorUnits()).toBeInstanceOf(Integer); + expect(money.getMinorUnits().valueOf()).toBe(1999); + }); + + it('should detect zero and negative amounts', () => { + expect(Money.fromString('0 EUR').isZero()).toBeTrue(); + expect(money.isZero()).toBeFalse(); + expect(Money.fromString('-1 EUR').isNegative()).toBeTrue(); + expect(money.isNegative()).toBeFalse(); + }); + }); + + describe('arithmetic', () => { + it('should add without floating point errors', () => { + expect( + Money.fromString('0.10 EUR') + .add(Money.fromString('0.20 EUR')) + .valueOf(), + ).toBe('0.30 EUR'); + }); + + it('should subtract into negative amounts', () => { + expect( + Money.fromString('0.20 EUR') + .subtract(Money.fromString('0.50 EUR')) + .valueOf(), + ).toBe('-0.30 EUR'); + }); + + it('should multiply and round to the minor unit', () => { + expect(Money.fromString('19.99 EUR').multiply(3).valueOf()).toBe( + '59.97 EUR', + ); + expect(Money.fromString('0.05 EUR').multiply(0.5).valueOf()).toBe( + '0.03 EUR', + ); + }); + + it('should not mutate operands', () => { + const money = Money.fromString('1.00 EUR'); + + money.add(money); + + expect(money.valueOf()).toBe('1.00 EUR'); + }); + }); + + describe('comparison', () => { + const small = Money.fromString('1.00 EUR'); + const big = Money.fromString('2.00 EUR'); + + it('should compare amounts of the same currency', () => { + expect(big.isGreaterThan(small)).toBeTrue(); + expect(small.isGreaterThan(big)).toBeFalse(); + expect(small.isLessThan(big)).toBeTrue(); + expect(big.isLessThan(small)).toBeFalse(); + }); + + it('should be equal by amount and currency', () => { + expect(small.isEqual(Money.fromString('1 EUR'))).toBeTrue(); + expect(small.isEqual(Money.fromString('1 USD'))).toBeFalse(); + }); + }); + + describe('currency mismatch', () => { + const eur = Money.fromString('1 EUR'); + const usd = Money.fromString('1 USD'); + + it.each([ + ['add', () => eur.add(usd)], + ['subtract', () => eur.subtract(usd)], + ['isGreaterThan', () => eur.isGreaterThan(usd)], + ['isLessThan', () => eur.isLessThan(usd)], + ])('should throw on %s', (_name, operation) => { + expect(operation).toThrow(CurrencyMismatchError); + }); + }); +});