README
CashMoney
CashMoney assists you in the handling of monetary values to ensure you avoid awkward rounding errors in your business logic.
This package started as a part of the MoneyPHP library, but has since diverged to utilise some different concepts. Most importantly, CashMoney differentiates between "precise money" and "rounded money".
"If I had a dime for every time I've seen someone use FLOAT to store currency, I'd have $999.997634" -- Bill Karwin
As mentioned by the MoneyPHP library, you shouldn't represent monetary values with floating point numbers. Instead, a dedicated money object should be used instead. This library uses a "big number" implementation (powered by bignumber.js to truly support numbers of artbitrary precision.
Features
- Precise money, to avoid rounding errors entirely
- Rounded money, for when you need to work with money in a similar manner to cash
- Money formatting using built-in JS Intl functionality
- Money parsing in multiple formats
- Money exchange (conversion rates must be supplied manually)
- JSON serialization
Install
With yarn:
$ yarn add @cashmoney/core
Or with npm:
$ npm add @cashmoney/core
Example usage
Money
There are two types of money: Precise and Rounded. Which one you want depends on what you plan to do with the relevant amount(s).
Precise Money
Precise Money objects accept values of any precision, as strings or numbers.
import {
PreciseMoney,
Currency,
PreciseMoneyFactory,
} from "@cashmoney/core";
const { AUD } = PreciseMoneyFactory;
const currency = new Currency("AUD");
const fiveAud = new PreciseMoney(5, currency);
const tenAud = fiveAud.add(fiveAud);
const sevenPointFiveAud = new PreciseMoney("7.5", currency);
// Alternatively:
const fiveAudAlt = AUD(5);
const tenAudAlt = AUD(10);
const sevenPointFiveAudAlt = AUD(7.5);
assert(tenAud.subtract(fiveAud).equals(fiveAud));
assert(tenAud.subtract(new PreciseMoney("2.5", currency)).equals(sevenPointFiveAud);
Rounded Money
Rounded Money objects require that the expected precision of the currency is supplied.
import {
RoundedMoney,
Currency,
RoundedMoneyFactory,
} from "@cashmoney/core";
// AUD uses 2 decimal places
const fiveAud = new RoundedMoney("5.00", 2, new Currency("AUD"));
const tenAud = fiveAud.add(fiveAud);
const [part1, part2, part3] = tenAud.allocate([1, 1, 1]);
assert(part1.equals(AUD("3.34")));
assert(part2.equals(AUD("3.33")));
assert(part3.equals(AUD("3.33")));
If you use the factory functions, they supply the precision for you.
import {
RoundedMoney,
Currency,
RoundedMoneyFactory,
} from "@cashmoney/core";
const { AUD } = RoundedMoneyFactory;
const fiveAud = new RoundedMoney("1.23", 2, new Currency("AUD"));
const fiveAudAlt = AUD("1.23");
assert(fiveAud.equals(fiveAudAlt));
Converting between Precise and Rounded
You can convert a Precise Money object to a Rounded Money object, but there is no automated translation going back the other way. This is supposed to be indicative of the idea that once you've disposed of the precision provided by the Precise Money class, you can never get it back.
The Precise Money class has a method for converting to Rounded Money objects.
import { PreciseMoney, RoundedMoney, Currency } from "@cashmoney/core";
const currency = new Currency("AUD");
const fivePreciseAud = new PreciseMoney(5, currency);
const fiveRoundedAud = fivePreciseAud.roundToDecimalPlaces(2);
const fiveRoundedAudAlt = new RoundedMoney(5, 2, currency);
assert(fiveRoundedAud.equals(fiveRoundedAudAlt));
However, this requires that you always know the precision of every currency you're dealing with, everywhere that you want to do this in your code. That doesn't scale very well, so there's a dedicated Money Rounder class you can use to generalise this process. The Money Rounder requires a currency list, which is discussed in the next section.
import {
PreciseMoneyFactory,
RoundedMoneyFactory,
CustomCurrencyList,
MoneyRounder,
} from "@cashmoney/core";
const { AUD: PAUD } = PreciseMoneyFactory;
const { AUD: RAUD } = RoundedMoneyFactory;
const currencies = new CustomCurrencyList({ AUD: 2 });
const rounder = new MoneyRounder(currencies);
const pMoney = PAUD("1.50");
const pMoneyMultiplied = pMoney.times(1.25); // equals "1.875"
const rMoney = rounder.round(pMoneyMultiplied);
assert(rMoney.equals(RAUD("1.88")));
CashMoney defaults to Banker's Rounding, which is actually "round half to even". You can change the rounding method used for any given rounding.
import { RoundingMode } from "@cashmoney/core";
const customRounder = new MoneyRounder(currencies, RoundingMode.ROUND_HALF_UP);
const rMoneyHalfUp = rounder.round(pMoneyMultiplied); // equals "1.88"
const rMoneyHalfDown = rounder.round(pMoneyMultiplied, RoundingMode.ROUND_HALF_DOWN); // equals "1.87"
Note that this is another departure from MoneyPHP, which simply defaults to "round half up".
The rounder can provide the delta between the original amount and the rounded amount. This can be useful for record-keeping purposes.
const [rMoney2, rDelta] = rounder.roundWithDelta(pMoneyMultiplied);
assert(rMoney2.equals(rMoney));
assert(rDelta.equals(PAUD("0.005")));
Currency Lists
In CashMoney, You can construct currency objects at any time with any currency code (as you've seen in the above examples). As a result, these currency objects don't contain any information about the currency itself. Most importantly, this means they can't tell you how many minor units a currency has (eg. AUD uses 2 minor units while JPY uses 0). Instead, this information is provided by Currency List objects.
ISO Currency List
Most of the time, you're probably going to want to use the ISO Currency List. This provides all the data you're going to need about pretty much every currency you're going to work with in most applications.
import { Currency, ISOCurrencyList } from "@cashmoney/core";
const AUD = new Currency("AUD");
const JPY = new CUrrency("JPY");
const isoCurrencyList = new ISOCurrencyList();
assert(isoCurrencyList.contains(AUD));
assert(isoCurrencyList.contains(JPY));
console.log(isoCurrencyList.nameFor(AUD)); // outputs 'Australian Dollar'
console.log(isoCurrencyList.subunitFor(AUD)); // outputs 2
console.log(isoCurrencyList.nameFor(JPY)); // outputs 'Yen'
console.log(isoCurrencyList.subunitFor(JPY)); // outputs 0
The ISO Currency List class doesn't pull its data out of thin air. Without
any additional configuration, instances of ISOCurrencyList won't return
any data at all. This is where the CashMoney ISO Currencies
package comes in.
$ yarn add @cashmoney/iso-currencies
import { ISOCurrencyList } from "@cashmoney/core";
import * as currencies from "@cashmoney/iso-currencies/resources/current";
ISOCurrencyList.registerCurrencies(currencies);
If you only need a few currencies, you can import them individually in a manner that should be friendly to tree-shaking.
import { ISOCurrencyList } from "@cashmoney/core";
import { AUD, JPY } from "@cashmoney/iso-currencies/resources/current";
ISOCurrencyList.registerCurrency("AUD", AUD);
ISOCurrencyList.registerCurrency("JPY", JPY);
See the readme for the CashMoney ISO Currencies package for more information about the data available in the package.
Custom Currency List
For various reasons it may not be practical to use the CashMoney ISO Currencies package. If you still require the services of a currency list, you can construct one manually with as few details as necessary. The next example should produce the same results as the previous example.
import { CustomCurrencyList } from "@cashmoney/core";
const currencyData = { AUD: 2, JPY: 2 };
const customCurrencyList = new CustomCurrencyList(currencyData);
Bitcoin Currency List
CashMoney supports Bitcoin too. The Bitcoin Currency List has hard-coded data about Bitcoin only.
import { Currency, BitcoinCurrencyList } from "@cashmoney/core";
const XBT = new Currency("XBT");
const EUR = new Currency("EUR");
const currencyList = new BitcoinCurrencyList();
assert(currencyList.contains(XBT));
assert(currencyList.contains(EUR) === false);
console.log(currencyList.nameFor(XBT)); // outputs 'Bitcoin'
console.log(currencyList.subunitFor(XBT)); // outputs 8
console.log(currencyList.nameFor(EUR)); // throws Error('EUR is not bitcoin and is not supported by this currency list.'
console.log(currencyList.subunitFor(EUR)); // throws Error('EUR is not bitcoin and is not supported by this currency list.'
Aggregate Currency List
What do you do when your application needs to support ISO Currencies, Bitcoin, and potentially other currencies too? You use the Aggregate currency list. It's accepted everywhere other currency lists are.
import {
AggregateCurrencyList,
BitcoinCurrencyList,
ISOCurrencyList,
} from "@cashmoney/core";
const currencyList = new AggregateCurrencyList(
new BitcoinCurrencyList(),
new ISOCurrencyList(),
new CustomCurrencyList({ ZZZ: 42 }),
);
Create your own currency list
It's pretty easy to make your own currency list class that integrates cleanly with
the rest of CashMoney. The only important thing to remember is that nameFor()
and subunitFor() must throw an Error for currencies that don't apply for
your custom list.
import { Currency, CurrencyList } from "@cashmoney/core";
class MyAppCurrency implements CurrencyList {
public contains(currency: Currnecy): boolean {
return currency.code === "123";
}
public nameFor(currency: Currency): string {
if (this.contains(currency) === false) {
throw new Error("Cannot handle non-app currencies.");
}
return "123 Currency";
}
public subunitFor(currency: Currency): number {
if (this.contains(currency) === false) {
throw new Error("Cannot handle non-app currencies.");
}
return 42;
}
public *[Symbol.iterator](): Generator<Currency> {
yield new Currency("123");
}
}
The Symbol.iterator method can return any iterator - it doesn't have to
be a generator.
Parsing
All parsers return instances of PreciseMoney.
ISO Code parser
The ISO Code parser parses strings of the form "AUD 1.23" and "AUD 100".
import { PreciseMoneyFactory, ISOCodeMoneyParser } from "@cashmoney/core";
const { AUD } = PreciseMoneyFactory;
const parser = new ISOCodeMoneyParser();
const fiveAud = parser.parse("AUD 5.00");
assert(fiveAud.equals(AUD(5)));
const fiftyAud = parser.parse("AUD 50");
assert(fiftyAud.equals(AUD(50)));
It can also handle strings formatted for different regions.
const fiveAudAlt = parser.parse("5.00 AUD");
assert(fiveAudAlt.equals(AUD(5)));
const fiftyAudAlt = parser.parse("5,00 AUD");
assert(fiftyAudAlt.equals(AUD(50)));
If you supply it with a list of valid currencies, it will validate the currency code it finds. An exception will be thrown if the currency code isn't in the list.
import {
ISOCodeMoneyParser,
CustomCurrencyList,
ISOCurrencyList,
} from "@cashmoney/core";
const customCurrencies = new CustomCurrencyList({ AUD: 2 });
const parser1 = new ISOCodeMoneyParser(currencies);
const twentyNzd = parser.parse("NZD 20"); // throws Error("Unknown currency code.")
const isoCurrencies = new ISOCurrencyList();
const parser2 = new ISOCodeMoneyParser(isoCurrencies);
const twentyZzz = parser.parse("ZZZ 20"); // throws Error("Unknown currency code.")
Symbol parser
The Symbol parser parses strings with symbol identifiers. You need to specify what each symbol means manually. Symbols cannot be longer than three characters in length.
import { Currency, SymbolMoneyParser } from "@cashmoney/core";
const symbolMapping = { "