houdini/app/javascript/common/money.ts

355 lines
12 KiB
TypeScript
Raw Normal View History

2020-06-24 19:22:46 +00:00
// License: LGPL-3.0-or-later
// based upon https://github.com/davidkalosi/js-money
2020-09-14 17:25:15 +00:00
import BigNumber from 'bignumber.js';
2020-06-24 19:22:46 +00:00
2020-09-14 17:25:15 +00:00
/**
* Forces BigNumber to throw an error if it receives an invalid numerical value.
*/
function bigNumberDebug<T>(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);
2020-09-14 17:25:15 +00:00
}
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 };
2020-09-14 17:25:15 +00:00
export type Operand = number | Money | BigNumber | string;
export enum RoundingMode {
/** Rounds away from zero. */
Up = 0,
2020-06-24 19:22:46 +00:00
2020-09-14 17:25:15 +00:00
/** Rounds towards zero. */
Down,
2020-06-24 19:22:46 +00:00
2020-09-14 17:25:15 +00:00
/** Rounds towards Infinity. */
Ceil,
2020-06-24 19:22:46 +00:00
2020-09-14 17:25:15 +00:00
/** 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,
}
2020-06-24 19:22:46 +00:00
/**
* Represents a monetary amount. For safety, all Money objects are immutable. All of the functions in this class create a new Money object.
*
2020-09-14 17:25:15 +00:00
* 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` )
*
*
2020-06-24 19:22:46 +00:00
* To create a new Money object is to use the `fromCents` function.
* @export
* @class Money
*/
export class Money {
2020-09-14 17:25:15 +00:00
/**
* 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;
2020-09-14 17:25:15 +00:00
/**
* The currency of the monetary value, always in lower case.
*/
readonly currency: string;
protected constructor(readonly cents: number, currency: string) {
this.currency = currency.toLowerCase();
2020-09-14 17:25:15 +00:00
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);
}
/**
2020-09-14 17:25:15 +00:00
* 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;
2020-09-14 17:25:15 +00:00
/**
* 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;
2020-09-14 17:25:15 +00:00
/**
* 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;
/**
2020-09-14 17:25:15 +00:00
* 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 {
2020-09-14 17:25:15 +00:00
if (!currency && typeof amount === 'object' && !(amount instanceof BigNumber)) {
currency = amount.currency;
}
return new Money(coerceToBigNumber(amount, true).toNumber(), currency);
}
/**
2020-09-14 17:25:15 +00:00
* Adds the two objects together creating a new Money instance that holds the result of the operation.
*
2020-09-14 17:25:15 +00:00
* @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.
*/
2020-09-14 17:25:15 +00:00
add(other: Operand): Money {
2020-09-14 17:25:15 +00:00
const bigNumber = coerceToBigNumber(other, true);
assertSameCurrency(this, other);
2020-09-14 17:25:15 +00:00
return new Money(this.toBigNumberWithNoDecimals().plus(bigNumber).toNumber(), this.currency);
}
/**
2020-09-14 17:25:15 +00:00
* Compares another numerical value with this Money object.
*
2020-09-14 17:25:15 +00:00
* @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.
*/
2020-09-14 17:25:15 +00:00
compare(other: Operand): number {
2020-09-14 17:25:15 +00:00
const bigNumber = coerceToBigNumber(other);
assertSameCurrency(this, other);
2020-09-14 17:25:15 +00:00
return this.toBigNumberWithNoDecimals().comparedTo(bigNumber);
}
/**
2020-09-14 17:25:15 +00:00
* 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);
}
/**
2020-09-14 17:25:15 +00:00
* 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.
*
2020-09-14 17:25:15 +00:00
* @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.
*/
2020-09-14 17:25:15 +00:00
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.
*
2020-09-14 17:25:15 +00:00
* @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.
*/
2020-09-14 17:25:15 +00:00
greaterThanOrEqual(other: Operand): boolean {
assertSameCurrency(this, other);
return this.toBigNumberWithNoDecimals().isGreaterThanOrEqualTo(coerceToBigNumber(other));
}
2020-10-22 19:25:02 +00:00
/**
* Returns true if the amount is negative
*
*/
isNegative(): boolean {
2020-09-14 17:25:15 +00:00
return this.toBigNumberWithNoDecimals().isNegative();
}
/**
* Returns true if the amount is positive.
*
*/
isPositive(): boolean {
2020-09-14 17:25:15 +00:00
return this.toBigNumberWithNoDecimals().isPositive();
}
/**
* Returns true if the amount is zero.
*/
isZero(): boolean {
2020-09-14 17:25:15 +00:00
return this.toBigNumberWithNoDecimals().isZero();
}
/**
* Checks whether the value represented by this object is less than the other.
*
2020-09-14 17:25:15 +00:00
* @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.
*/
2020-09-14 17:25:15 +00:00
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.
*
2020-09-14 17:25:15 +00:00
* @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 {
2020-09-14 17:25:15 +00:00
assertSameCurrency(this, other);
return this.toBigNumberWithNoDecimals().isLessThanOrEqualTo(coerceToBigNumber(other));
}
/**
2020-09-14 17:25:15 +00:00
* 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));
2020-09-14 17:25:15 +00:00
return new Money(unrounded.decimalPlaces(0, roundingMode).toNumber(), this.currency);
}
/**
2020-09-14 17:25:15 +00:00
* 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);
2020-09-14 17:25:15 +00:00
return new Money(this.toBigNumberWithNoDecimals().minus(coerceToBigNumber(other, true)).toNumber(), this.currency);
}
2020-09-14 17:25:15 +00:00
/**
* Get the amount of the Money instance as a `BigNumber`.
*/
toBigNumber() : BigNumber {
return new BigNumber(this.cents);
}
/**
2020-09-14 17:25:15 +00:00
* Returns a serialized version of the instance.
*
* @returns {{amount: number, currency: string}}
*/
toJSON(): MoneyAsJson {
return {
cents: this.cents,
currency: this.currency,
};
}
2020-09-14 17:25:15 +00:00
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());
}
2020-06-24 19:22:46 +00:00
}