pesa

A JS money lib whose precision goes upto 11 (and beyond)

Usage no npm install needed!

<script type="module">
  import pesa from 'https://cdn.skypack.dev/pesa';
</script>

README

pesa logo

पेसा

A money handling library for JavaScript that can handle USD to VEF conversions for Jeff without breaking a sweat!


const money = pesa(135, 'USD').add(25).to('INR', 75);

money.round(2);
// '12000.00'

Why should I use this, when I can just do all of this with plain old JavaScript numbers?!

Because JavaScript numbers full of fun foibles such as these:

0.1 + 0.2;
// 0.30000000000000004

9007199254740992 + 1;
// 9007199254740992

Using them for financial transactions will most likely lead to technical bankruptcy [1].

(check this talk by Bartek Szopka to understand why JS numbers are like this)

pesa circumvents this by conducting scaled integer operations using JS BigInt instead of Number. This allows for arithmetic involving arbitrarily large numbers with unnecessarily high precision.

Index

show/hide
  1. Installation
  2. Usage
    1. Initialization
    2. Currency and Conversions
    3. Immutability
    4. Chaining
  3. Documentation
    1. Arithmetic Functions
    2. Comparison Functions
    3. Check Functions
    4. Other Calculation Functions
    5. Display
    6. Chainable Methods
    7. Non Chainable Methods
    8. Internals
  4. Additional Notes
    1. Storing The Value
    2. Non-Money Stuff
    3. Support
    4. Precision vs Display
    5. Why High Precision
    6. Implicit Conversions
    7. Rounding
    8. Use Cases
  5. Alternatives

Documentation Index

show/hide
  1. Arithmetic Functions
  2. Comparison Functions
  3. Check Functions
  4. Other Calculation Functions
  5. Display
  6. Chainable Methods
  7. Non Chainable Methods
  8. Internals - Numeric Representation - Conversion Rates

Installation

For npm users:

npm install pesa

For yarn users:

yarn add pesa

Index

Usage

pesa(200, 'USD').add(250, 'INR', 75).percent(50).round(3);
// '9475.000'

This section describes the usage in brief. For more details, check the Documentation section. For even more details, check the source code or raise an issue.

Index

Initialization

To create an initialize a money object you can either use the constructor function pesa:

import { pesa } from 'pesa';

const money = pesa(200, 'USD');
// OR
const money = pesa(200, options);

or the constructor function maker getMoneyMaker, this can be used if you don't want to set the options everytime you call pesa:

import { getMoneyMaker } from 'pesa';

const pesa = getMoneyMaker('USD');
// OR
const pesa = getMoneyMaker(options);

const money = pesa(200);

Options and Value

Options are optional, but currency has to be set before any conversions can take place.

interface Options {
  bankersRounding?: boolean; // Default: true, use bankers rounding instead of traditional rounding.
  currency?: string; // Default: '', Three letter alphabetical code in uppercase ('INR').
  precision?: number; // Default: 6, Integer between 0 and 20.
  display?: number; // Default: 3, Number of digits .round defaults to.
  wrapper?: Wrapper; // Default: (m) => m, Used to augment all returned money objects.
  rate?: RateSetting | RateSetting[]; // Default: [], Conversion rates
}

interface RateSetting {
  from: string;
  to: string;
  rate: string | number;
}

type Wrapper = (m: Money) => Money;

Value can be a string, number or a bigint. If value is not passed the value is set as 0.

type Value = string | number | bigint;

If bigint is passed then it doesn't undergo any conversion or scaling and is used to set the internal bigint.

pesa(235).internal;
// { bigint: 235000000n, precision: 6 }

pesa('235').internal;
// { bigint: 235000000n, precision: 6 }

pesa(235n).internal;
// { bigint: 235n, precision: 6 }

Wrapper is a function that can add additional properties to the returned object. One use case is Vue3 where everything is deeply converted into a Proxy, this is incompatible with pesa because of it's private variables and immutability.

So to remedy this you can pass markRaw as the wrapper function.

This will prevent the proxification of pesa objects. Which in the case of pesa shouldn't be required anyway because the underlying value is never changed.

Currency and Conversions

A numeric value isn't money unless a currency is assigned to it.

Setting Currency

Currency can be assigned any time before a conversion is applied.

// During initialization
const money = pesa(200, 'USD');

// After initialization
const money = pesa(200).currency('USD');

Setting Rates

To allow for conversion between two currencies, a conversion rate has to be set. This can be set before the operation or during the operation.

// Rate set before the operation
pesa(200).currency('USD').rate('INR', 75).add(2000, 'INR');

// Rate set during the operation
pesa(200).currency('USD').add(2000, 'INR', 0.013);

Conversion

The result of an operation will always have the currency on the left (USD in the above example). To convert to a currency:

// Rate set during the operation
money.to('INR', 75);

// Rate set before the operation
money.to('INR');

This returns a new Money object.

Does it provide conversion rates?

pesa doesn't provide or fetch conversion rates. This would cause dependencies on exchange rate APIs and async behaviour. There are a lot of exchange rate apis such as Coinbase, VAT Comply, European Central Bank, and others.

Immutability

The underlying value or currency of a Money object doesn't change after an operation.

const a = pesa(200, 'USD');
const b = pesa(125, 'INR').rate('USD', 0.013);
const c = a.add(b);

// Statements below will evaluate to true
a.float === 200;
b.float === 125;
c.float === 201.625;

a.getCurrency() === 'USD';
b.getCurrency() === 'INR';
c.getCurrency() === 'USD';

Chaining

Due to the following two points:

  1. All arithmetic operation (add, sub, mul and div), create a new Money object having the values that is the result of that operation.

  2. All setter methods (currency, rate), set the value of an internal parameter and return the calling Money object.

Methods can be chained and executed like so:

pesa(200)
  .add(22)
  .currency('USD')
  .sub(33)
  .rate('INR', 75)
  .mul(2, 'INR')
  .to('INR')
  .round(2);
// '377.99'

Index

Documentation

Calling the main function pesa returns an object of the Money class.

const money: Money = pesa(200, 'USD');

The rest of the documentation pertains to the methods and parameters of this class.

Arithmetic Functions

Operations that involve the value of two Money objects and return a new Money object as the result.

Function signature

[operationName](value: Input, currency?: string, rate?: number) : Money;

type Input = Money | number | string;

Example:

money = pesa(200, 'USD');

money.add(150).round();
// '350.000'

money.sub(150, 'INR', 0.013);
// '198.050'

Note: The rate argument here is from the currency given in the function to the calling objects currency. So in the above example rate of 0.013 is for converting 'INR' to 'USD'. The reason for this is to prevent precision loss due to reciprocal.

Arguments (arithmetic)

name description example
value This is compulsory. If input is a string then '_' or ',' can be used as digit separators, but decimal points should always be '.'. Scientific notation isn't supported. '200_000.00'
currency If value is a different currency from the calling object's currency then currency should be passed. Any arbitrary combination of 3 letters in uppercase can be a currency. 'VEF'
rate Required only if the calling object or value doesn't have the rate set. rate is the conversion rate between the calling object's currency and value's currency 450_000

Return (arithmetic)

All arightmetic operations return a new Money object that inherits the calling object's options, i.e. currency, precision and display.

Operations (arithmetic)

name description example
add addition, i.e. a + b pesa(33).add(36)
sub subtraction, i.e. a - b pesa(90).sub(21)
mul multiplication, i.e. a * b pesa(23).mul(3)
div division, i.e. a / b pesa(138).div(2)

Comparison Functions

Operations that compare two values and return a boolean.

Function Signature:

[operationName](value: Input, currency?: string, rate?: number) : boolean;

type Input = Money | number | string;

Example:

money = pesa(150, 'INR');

money.eq(2, 'USD', 75);
// true

money.lte(200);
// true

Note: The rate argument here is from the currency given in the function to the calling objects currency. So in the above example rate of 75 is for converting 'USD' to 'INR'. The reason for this is to prevent precision loss due to reciprocal.

Arguments (comparison)

See the Arguments table under the Arithmetic section.

Return

Boolean indicating the result of the comparison.

Operations (comparison)

name description example
eq checks if the two amounts are the same, i.e. === pesa(20).eq(20)
neq checks if the two amounts are not the same, i.e. !== pesa(20).neq(19)
gt checks if calling object amount is greater than passed amount, i.e. > pesa(20).gt(19)
lt checks if calling object amount is less than passed amount, i.e. < pesa(20).lt(21)
gte checks if calling object amount is greater than or equal to the passed amount, i.e. >= pesa(20).gte(19)
lte checks if calling object amount is less than or equal to passed amount, i.e. < pesa(20).lte(21)

Check Functions

Functions that return a boolean after evaluating the internal state.

Function signature:

[checkName](): boolean;

Example:

pesa(200, 'USD').isPositive();
// true

pesa(0, 'USD').isZero();
// true

pesa(-200, 'USD').isNegative();
// true

Operations (check)

name description
isPositive Returns true if underlying value is greater than zero.
isZero Returns true if underlying value is zero.
isNegative Returns true if underlying value is less than zero.

Other Calculation Functions

percent

Function that returns another Money object having a percent of the calling objects value.

Function signature

percent(value: number): Money;

Example

pesa(200, 'USD').percent(50).round(2);
// '100.00'

split

Function that splits the underlying value into given list of percentages or n equal parts and returns an array of Money objects.

The sum of values can exceed 100. Argument round is used to decide at what precision the sum of all returned will equate to the calling objects value. If round is not passed then the calling object's display value is used.

Function signature:

split(values: number | number[], round?: number): Money[];

Example:

pesa(200.99)
  .split([33, 66, 1])
  .map((m) => m.float);
// [66.327, 132.653, 2.01]

pesa(200.99)
  .split([33, 66, 1], 2)
  .map((m) => m.float);
// [66.33, 132.65, 2.01]

pesa(100)
  .split(3)
  .map((m) => m.float);
// [33.333, 33.333, 33.334]

abs

Returns a Money object having the the absolute value of the calling money object.

Function signature:

abs(): Money;

Example:

pesa(-2).abs().eq(2);
// true

pesa(2).abs().eq(2);
// true

neg

Returns a Money object having the the negated value of the calling money object.

Function signature:

neg(): Money;

Example:

pesa(-2).neg().round();
// '2.000'

pesa(2).neg().round();
// '-2.000'

Display

Functions and parameters used to display the Money object's value.

Does this support formatting?

Nope, but you can use the ECMAScript Internationalization API to handle formatting for you. The NumberFormat constructor can be configured to your needs then you can pass Money#float to it's format method.

const numberFormat = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
});
const money = pesa(2000, 'USD');

numberFormat.format(money.float);
// '$2,000.00'

float

Returns the JS Number form of the underlying value.

Function signature:

float: number;

Example:

pesa(200).float;
// 200

round

Rounds the underlying value and returns it, this function is not susceptible to JS number foibles. If to is not passed it uses the display amount.

Function Signature:

round(to?: number): string

Example:

pesa(200).round(2);
// '200.00'

store

Property that displays a precision intact string representation of the value.

pesa(200, { precision: 7 }).store;
// '200.0000000'

Chainable Methods

These methods are used to set some value and return the Money object itself so that other functions can be called on it.

currency

This is used to set the currency after initialization. Currency can be set only once so if currency has been set, this function will not change it.

Function signature:

currency(value: string): Money;

Example:

pesa(200).currency('INR');

rate

This is used to set a single or multiple conversion rates. If input is a string then it is assumed that the conversion rate is from the calling objects currency to the string input passed as the first parameter with a value of rate. You can be more explicit by passing an object.

Function signature:

rate(input: string | RateSetting | RateSetting[], rate?: Rate):

interface RateSetting {
  from: string;
  to: string;
  rate: string | number;
}

Example:

// string input
pesa(200, 'INR').rate('USD', 75);

// RateSetting input
pesa(200, 'INR').rate({ from: 'INR', to: 'USD', rate: 75 });

// RateSetting[] input
pesa(200, 'INR').rate([
  { from: 'INR', to: 'USD', rate: 75 },
  { from: 'USD', to: 'INR', rate: 0.013 },
]);

clip

Used to receive a copy of the calling object with the internal representation rounded off to the given place.

Function signature:

clip(to: string): Money;

Example:

const money = pesa(7500, 'INR').to('USD', 1 / 75);

money.round();
// '99.998'

const clipped = money.clip(2);
clipped.round();
// '100.000'

clipped.internal;
// { bigint: 100000000n, precision: 6 }

copy

Returns a copy of it self.

Function signature:

copy(): Money;

Example;

pesa(200).copy().round();
// '200.000'

Non Chainable Methods

These methods are used to retrieve some value from the Money object.

getCurrency

This will return the set currency of the money object. If nothing is set then '' is returned.

Function signature;

getCurrency(): string

Example:

pesa(200, 'INR').getCurrency();
// 'INR'

getConversionRate

This will return the stored conversion rate for the given arguments. If no conversion rate is found, it will throw an error. If conversion rate is stored for from A to B, fetching the conversion rate for from B to A will return the reciprocal unless it is explicitly set.

Function signature:

getConversionRate(from: string, to:string): string | number

Example

const money = pesa(200, 'INR').rate('USD', 75);

money.getConversionRate('INR', 'USD');
// 75

money.getConversionRate('USD', 'INR');
// 0.013333333333333334

hasConversionRate

Will return true if either conversion rates from A to B or from B to A is found.

Function signature:

hasConversionRate(to: string): boolean;

Example:

pesa(200, 'USD').rate('INR', 75).hasConversionRate('INR');
// true

Internals

These values can be used for debugging.

Note: altering the returned values won't change the values stored in the Money object, these are copies.

Numeric Representation

To view the internal numeric representation, the .internal attribute can be used.

pesa(201).internal;
// { bigint: 201000000n, precision: 6 }

Conversion Rates

To view the stored conversion rates, the .conversionRate attribute can be used.

pesa(200, 'USD').rate('INR', 75).conversionRate;
// Map(1) { 'USD-INR' => 75 }

The returned object is that of the javascript Map class, which has the following key format ${fromCurrency}-${toCurrency}. The value may be a string or number.

Index

Additional Notes

Additional notes pertaining to this lib.

Storing The Value

Since a Money constitutes of 2 values for the number: 1. the precision, and 2. the value. Storing this would require two cells, but this would be incredible stupid cause fractional numbers have already solved this with the decimal point.

We can use the store property to get a string representation where the mantissa length gives the precision irrespective of the significant digits.

pesa(0, { precision: 4 }).store;
// '0.0000'

You still have to deal with the currency though. Also if your db doesn't have a decimal type (I'm looking at you SQLite), then you'll have to store this as a string and cast it before operations.

Non-Money Stuff

Because of these two points:

  1. A number is considered as money only when a currency is attached to it.
  2. pesa allows deferring currency until conversion is required.

pesa can be used for non monetary operations for when high precision is required or you want to circumvent the foibles of JS number.

pesa(0.1).add(0.2).eq(0.3);
// true

pesa(9007199254740992).add(1).round(0);
// '9007199254740993'

Support

Since pesa is built for high precision and large numbers that can exceed Number.MAX_SAFE_INTEGER it is dependent on BigInt so if your environment doesn't support BigInt you will have to rely on some other library.

Check this chart for more info: BigInt Browser compatibility

Precision vs Display

A short note on the difference between the two in the context of pesa.

pesa logo

Essentially:

  • Precision refers to how many digits are stored after the decimal point.
  • Display refers to how many digits are shown after the decimal point when .round is called.

Ideally this number would be the same as the minor unit value of a currency. For example if currency is USD the minor is 2, the same for INR. The problem arises when the amount is to be multiplied with another amount having a fractional component such as in the case of conversion.

pesa(333, 'INR').to('USD', 0.013).float;
// 4.329

If in the above example precision were set to 2 it would mess up the calculations because 0.013 gets rounded to 0.01 since input precision is matched to internal precision.

pesa(333, { currency: 'INR', precision: 2 }).to('USD', 0.013).float;
// 3.33

So the number you want to adjust is display which doesn't mess up the internal representation.

const money = pesa(333, { currency: 'INR', display: 2 }).to('USD', 0.013);

money.round();
// '4.33'

money.float;
// 4.329

Why High Precision

The reason why you would want to conduct your operations in high precision is because: say you make 10,000 INR to USD conversions at 0.013 then that's a difference 10 USD that is added in due to rounding:

const oneConversion = pesa(333).mul(0.013).round(2);
// '4.33'

pesa(oneConversion).mul(10000).round(2);
// '43300.00'

as opposed to:

pesa(333).mul(0.013).mul(10000).round(2);
// '43290.00'

i.e. someone is losing money that they shouldn't be.

Now this can't be solved entirely for the same reason that 1/3 in base 10 is 0.3 with the 3 recurring ad infinitum, but in base 3 it would be 0.1. But this can be mitigated by using high precision. Which means that someone will always loose money they shouldn't be but we can control the extent to which they do.

Implicit Conversions

In pesa if you provide the conversion from A to B, the conversion rate from B to A is implied, i.e. the reciprocal of the former.

This means that chained conversions will cause value loss:

pesa(100, 'USD').round();
// '100.000'

pesa(100, 'USD').to('INR', 75).round();
// '7500.000'

pesa(100, 'USD').to('INR', 75).to('USD').round();
// '99.998'

There are two ways to alleviate this until there's a proper solution:

1. Increase precision or decrease display:

pesa(100, { currency: 'USD', precision: 7 }).to('INR', 75).to('USD').round();
// '100.000'

pesa(100, { currency: 'USD', display: 2 }).to('INR', 75).to('USD').round();
// '100.00'

in both the above cases the internal representation would represent a fractional value.

2. Use clip to maintain internally rounded values:

const clipped = pesa(100, 'USD').to('INR', 75).to('USD').clip(2);

clipped.internal;
// { bigint: 100000000n, precision: 6 }

clipped.round();
// '100.000'

Rounding

Rounding at mid-points is confusing af.

2.5; // Mid-point

2.49999; // Not mid-point
2.50001; // Not mid-point

The traditional rounding method always rounds up from the mid point.

pesa(2.5, { bankersRounding: false }).round(0);
// '3'

This is uneven because of right side bias on the number line, so to mitigate this bias we have bankers rounding which rounds to the closest even number. pesa uses bankers rounding by default:

pesa(0.5).round(0);
// '0'

pesa(1.5).round(0);
// '2'

pesa(2.5).round(0);
// '2'

pesa(3.5).round(0);
// '4'

Things get even more confusing when considering negative numbers. But if you remember that traditional rounding rounds up, i.e. towards positive infinity or to the right of the number line, not away from zero:

pesa(-2.5, { bankersRounding: false }).round(0);
// '-2'

and bankers rounding rounds to the closest even number which is always at a distance of 0.5 (if rounding to 0):

pesa(-2.5).round(0);
// '-2'

pesa(-3.5).round(0);
// '-4'

you will be less confused.

Finally, remember that bankers rounding kicks in only at the mid point, this depends on the precision:

pesa(2.51, { precision: 2 }).round(0);
// '3'

pesa(2.51, { precision: 1 }).round(0);
// '2'

due to precision loss, a non mid-point can become a mid-point and bankers algo will be used for rounding.

Use Cases

Hyper-realistic use cases designed to showcase the capabilities of pesa.

Disclaimer: all characters, places and events in this README.md are entirely fictional. Any resemblances to real characters, places, and events are purely coincidental.

Mega Jeff in Venezuela

Imagine Jeff was a million times as powerful, we can call him megaJeff, his bank being a whopping 200 quadrillion USD:

const megaJeff = pesa('200_000_000_000_000_000.18', 'USD');

(megaJeff is too powerful for JS numbers to handle accurately and hence we must rely on string for input.)

and decided to emigrate to Venezuela at the height of it's hyper-inflation, requiring him to convert his USD to VEF:

const megaJeffInVenezuela = megaJeff.to('VEF', 451_853.23);

this is a conversion that JS numbers can't handle without resorting to dirty tricks such as E notation.

200_000_000_000_000_000.18 * 451_853.23;
// 9.037064599999999e+22

Which is why you need pesa.

megaJeffInVenezuela.round(4);
// '90370646000000000081333.5814'

Alan the Seed Seller

Alan sells seeds. He lives in Venezuela. Alan doesn't trust his dirty government, they messed up his currency. So Alan conducts his dealings only in BTC. Compared to VEF, BTC is less volatile. Seeds are precious, Alan is parsimonious. Alan likes to record the flow of each seed:

const numberOfSeeds = 101_234_318;

const options = { currency: 'BTC', precision: 30 };
const costPerSeed = pesa('0.000000031032882086386885', options); // ~3 satoshis

Say he wants to calculate the total value of his seeds:

const totalvalue = costPerSeed.mul(numberOfSeeds);

totalValue.round(24);
// '3.141592653589793387119430'

if he were to rely on clumsy old JS number:

0.000000031032882086386885 * 101_234_318;
// 3.1415926535897936

he would end up loosing almost 30% of his very significant digits. Any one who deals in BTC knows that it is very bad to loose one's digits.

Say he wanted to find out the value of his seeds in USD, which at the time of writing has the conversion rate of 60951.60 from BTC.

totalValue.to('USD', 59379.3).round(30);
// '186545.572655304418471780769799000000'

were he to rely on JS number, he would end up with a number that—having lost more than 40% of it extremely significant digits—is completely detached from reality:

0.000000031032882086386885 * 101_234_318 * 59379.3;
// 186545.57265530445

Keep your significant digits, use pesa.

Note: hyperinflation is not a joke, if you or your country is experiencing hyperinflation please seek help.

Index

Alternatives

A few good alternatives to pesa that solve a similar problem.

Why a create another Money lib if these already exists?!

They either didn't use bigint (megaJeff sad) or had too verbose an API.

Credits

  1. These are Ankush's wise words of wisdom. These words instilled fear of the JS Number in me. Thank you Ankush.