// License: LGPL-3.0-or-later // based upon https://github.com/davidkalosi/js-money import BigNumber from 'bignumber.js'; /** * Forces BigNumber to throw an error if it receives an invalid numerical value. */ function bigNumberDebug(func: () => T):T { const previousDebug = BigNumber.DEBUG; try { BigNumber.DEBUG = true; return func(); } finally { BigNumber.DEBUG = previousDebug; } } function assertSameCurrency(left: Money, right: Operand) { if (right instanceof Money && left.currency !== right.currency) throw new TypeError('Different currencies'); } function coerceToBigNumber(operand:unknown, mustBeInteger=false): BigNumber { let bigNumber = null; if (operand instanceof Money) { bigNumber = operand.toBigNumber(); } else if (operand instanceof BigNumber) { bigNumber = new BigNumber(operand); } else if (typeof operand === 'object') { //it's MoneyAsJson bigNumber = new BigNumber((operand as MoneyAsJson).cents); } else if(typeof operand === 'string') { bigNumberDebug(() => { bigNumber = new BigNumber(operand); }); } else if (typeof operand === 'number') { bigNumberDebug(() => { bigNumber = new BigNumber(operand); }); } else { throw new TypeError('Operand must be coercible to a BigNumber'); } if (mustBeInteger && !bigNumber.isInteger()) { throw new TypeError('Operand must be an integer'); } return bigNumber; } export type MoneyAsJson = { cents: number, currency: string }; type StringyMoneyAsJson = { cents:string, currency: string }; export type Operand = number | Money | BigNumber | string; export enum RoundingMode { /** Rounds away from zero. */ Up = 0, /** Rounds towards zero. */ Down, /** Rounds towards Infinity. */ Ceil, /** Rounds towards -Infinity. */ Floor, /** Rounds towards nearest neighbour. If equidistant, rounds away from zero . */ HalfUp, /** Rounds towards nearest neighbour. If equidistant, rounds towards zero. */ HalfDown, /** Rounds towards nearest neighbour. If equidistant, rounds towards even neighbour. */ HalfEven, /** Rounds towards nearest neighbour. If equidistant, rounds towards Infinity. */ HalfCeil, /** Rounds towards nearest neighbour. If equidistant, rounds towards -Infinity. */ HalfFloor, } /** * Represents a monetary amount. For safety, all Money objects are immutable. All of the functions in this class create a new Money object. * * Money always represents a whole number of the smallest monetary unit. It can never be fractional. Multiplication and division always rounds to * and integer (see the `RoundingMode` ) * * * To create a new Money object is to use the `fromCents` function. * @export * @class Money */ export class Money { /** * Create a `Money` object with the given number if smallest monetary units and the ISO currency. Another name for the `fromCents` function. * @static * @memberof Money */ static fromSMU = Money.fromCents; /** * The currency of the monetary value, always in lower case. */ readonly currency: string; protected constructor(readonly cents: number, currency: string) { this.currency = currency.toLowerCase(); const methodsToBind = [this.equals, this.add, this.subtract, this.multiply, this.divide, this.compare, this.greaterThan, this.greaterThanOrEqual, this.lessThan, this.lessThanOrEqual, this.isZero, this.isPositive, this.isNegative, this.toJSON]; methodsToBind.forEach((func) => Object.bind(func)); Object.freeze(this); } /** * Create a new money object * @param amount the value of Money as an object of type {amount:number, currency:string}. `amount` must be an integer * or `TypeError` will be thrown. */ static fromCents(amount: MoneyAsJson): Money; /** * Create a new money object * @param amount the value of Money as an object of type {amount:string, currency:string}. `amount` must contain an integer * or `TypeError` will be thrown. */ static fromCents(amount: StringyMoneyAsJson): Money; /** * Create a new money object * @param amount a Money object that will be replicated to create a new Money object */ static fromCents(amount: Money): Money; /** * Create a new money object. * @param amount an integer representing the amount of the new Money object. If you pass a non-integer, `TypeError` will be thrown. * @param currency the currency of the new Money object */ static fromCents(amount: number, currency: string): Money; /** * Create a new money object * @param amount a string containing the integer of the amount of the new Money object. If you pass a non-integer, `TypeError` will be thrown. * @param currency the currency of the new Money object */ static fromCents(amount: string, currency: string): Money; /** * * @param amount a BigNumber containing the integer of the amount of the new Money object. If you pass a non-integer, `TypeError` will be thrown. * @param currency the currency of the new Money object */ static fromCents(amount: BigNumber, currency: string): Money; /** * The overloaded function that allows all of the previous constructors */ static fromCents(amount: number | BigNumber | Money | MoneyAsJson | string | StringyMoneyAsJson, currency?: string): Money { if (!currency && typeof amount === 'object' && !(amount instanceof BigNumber)) { currency = amount.currency; } return new Money(coerceToBigNumber(amount, true).toNumber(), currency); } /** * Adds the two objects together creating a new Money instance that holds the result of the operation. * * @param other an object represented an integer value to add the current Money object. If it's a Money object, * the currency must match or `TypeError` will be thrown. If not, it must be an integer or `TypeError` is thrown. */ add(other: Operand): Money { const bigNumber = coerceToBigNumber(other, true); assertSameCurrency(this, other); return new Money(this.toBigNumberWithNoDecimals().plus(bigNumber).toNumber(), this.currency); } /** * Compares another numerical value with this Money object. * * @param other the numerical value to compare against this Money object. If `other` is a `Money` object, * the currencies must match or a `TypeError` is thrown. * @returns -1 if smaller than other, 0 if equal to other, 1 if greater than other. */ compare(other: Operand): number { const bigNumber = coerceToBigNumber(other); assertSameCurrency(this, other); return this.toBigNumberWithNoDecimals().comparedTo(bigNumber); } /** * Divides the object by the divisor returning a new Money instance that holds the result of the operation. * * @param divisor an object represented a numerical value to divide the current Money object by. If it's a Money object, * the currency must match or `TypeError` will be thrown. * @param roundingMode the rounding mode to use if the result of the division would otherwise lead to a non-integer Money value. By default, * we use the `RoundingMode.HalfUp` mode. */ divide(divisor: Operand, roundingMode: RoundingMode = RoundingMode.HalfUp): Money { assertSameCurrency(this, divisor); return new Money(this.toBigNumberWithNoDecimals(roundingMode).dividedBy(coerceToBigNumber(divisor)).toNumber(), this.currency); } /** * Returns true if the two instances of Money are equal, false otherwise. * * @param other an object represented a numerical value to compare to the current Money object to. If `other` is * a `Money` object, the currency must match for this to return true. */ equals(other: Operand): boolean { return this.toBigNumberWithNoDecimals().isEqualTo(coerceToBigNumber(other)) && (other instanceof Money ? this.currency === other.currency : false); } /** * Checks whether the value represented by this object is greater than the other. * * @param other an object represented a numerical value to compare to the current Money object to. If `other` * is a Money instance, the currency must match or `TypeError` will be thrown. */ greaterThan(other: Operand): boolean { assertSameCurrency(this, other); return this.toBigNumberWithNoDecimals().isGreaterThan(coerceToBigNumber(other)); } /** * Checks whether the value represented by this object is greater or equal to the other. * * @param other an object represented a numerical value to compare to the current Money object to. If `other` * is a Money instance, the currency must match or `TypeError` will be thrown. */ greaterThanOrEqual(other: Operand): boolean { assertSameCurrency(this, other); return this.toBigNumberWithNoDecimals().isGreaterThanOrEqualTo(coerceToBigNumber(other)); } /** * Returns true if the amount is negative * */ isNegative(): boolean { return this.toBigNumberWithNoDecimals().isNegative(); } /** * Returns true if the amount is positive. * */ isPositive(): boolean { return this.toBigNumberWithNoDecimals().isPositive(); } /** * Returns true if the amount is zero. */ isZero(): boolean { return this.toBigNumberWithNoDecimals().isZero(); } /** * Checks whether the value represented by this object is less than the other. * * @param other an object represented a numerical value to compare to the current Money object to. If `other` * is a Money instance, the currency must match or `TypeError` will be thrown. */ lessThan(other: Operand): boolean { assertSameCurrency(this, other); return this.toBigNumberWithNoDecimals().isLessThan(coerceToBigNumber(other)); } /** * Checks whether the value represented by this object is less than or equal to the other. * * @param other an object represented a numerical value to compare to the current Money object to. If `other` * is a Money instance, the currency must match or `TypeError` will be thrown. */ lessThanOrEqual(other: Money): boolean { assertSameCurrency(this, other); return this.toBigNumberWithNoDecimals().isLessThanOrEqualTo(coerceToBigNumber(other)); } /** * Multiplies the object by the multiplier returning a new Money instance that holds the result of the operation. * * @param multiplier an object represented a numerical value to multiply the current Money object by. If it's a Money object, * the currency must match or `TypeError` will be thrown. * @param roundingMode the rounding mode to use if the result of the multiplication would otherwise lead to a non-integer Money value. By default, * we use the `RoundingMode.HalfUp` mode. */ multiply(multiplier: Operand, roundingMode: RoundingMode = RoundingMode.HalfUp): Money { assertSameCurrency(this, multiplier); const unrounded = this.toBigNumberWithNoDecimals(roundingMode).multipliedBy(coerceToBigNumber(multiplier)); return new Money(unrounded.decimalPlaces(0, roundingMode).toNumber(), this.currency); } /** * Subtracts the two objects creating a new Money instance that holds the result of the operation. * * @param other an object represented an integer value to subtract from the current Money object. If it's a Money object, * the currency must match or `TypeError` will be thrown. If not, it must be an integer or `TypeError` is thrown. */ subtract(other: Operand): Money { assertSameCurrency(this, other); return new Money(this.toBigNumberWithNoDecimals().minus(coerceToBigNumber(other, true)).toNumber(), this.currency); } /** * Get the amount of the Money instance as a `BigNumber`. */ toBigNumber() : BigNumber { return new BigNumber(this.cents); } /** * Returns a serialized version of the instance. * * @returns {{amount: number, currency: string}} */ toJSON(): MoneyAsJson { return { cents: this.cents, currency: this.currency, }; } private toBigNumberWithNoDecimals(roundingMode?:RoundingMode) : BigNumber { const config:BigNumber.Config = { DECIMAL_PLACES: 0, }; if (roundingMode) { config.ROUNDING_MODE = roundingMode; } return new (BigNumber.clone(config))(this.toBigNumber()); } }