Skip to content

Commit

Permalink
feat: provide better support for non-decimal currencies (#309)
Browse files Browse the repository at this point in the history
This PR changes the way Dinero.js does formatting, providing better
support for non-decimal currencies and large integers. Many changes are
going in this PR, as this touches several concepts of the library.

## Currencies now accept multi-bases

Currencies can have multiple bases. It's the case for the pre-decimal
pound sterling, the _livre tournois_ of the French Old Regime, or the
Harry Potter wizarding currency of Great Britain. Users creating custom
currencies (e.g., for games) may also want to create such currencies.

Until now, Dinero.js allowed you to support such currencies by providing
the number of units of the smallest subdivision to the biggest (e.g.,
240 pence in a pound) as the `base`. It meant that [`toUnit` could only
return a double representation of the
amount](https://v2.dinerojs.com/docs/api/formatting/to-unit), and that
[you'd have to write a lot of logic to properly format
it](https://v2.dinerojs.com/docs/guides/formatting-non-decimal-currencies#handling-currencies-with-multiple-subdivisions).

Accepting multiple-bases lets users express this complexity and enables
better formatting in the new `toUnits` function.

## `toUnit` is replaced with `toUnits`

The `toUnit` function currently returns a double, which is problematic
at several levels:

- IEEE 754 doubles are unreliable, which is why Dinero.js avoids
manipulating them altogether.
- It makes no sense to ever return a non-decimal currency in a decimal
representation.
- Large integers can't be accurately casted to `number`s, so this
operation can't work well with them.

The decimal representation (e.g., 10.5) is prevalent in systems using a
decimal currency, but this is only a human representation, and only
matters when you're displaying an amount on your site or app.
Programming-wise, this representation isn't particularly helpful, and
it's even limiting.

This PR removes `toUnit` and replaces it with `toUnits`, which returns
each subdivision as an integer (a `number`, a `bigint`, a `Big`,
[depending on what you use as the amount
type](https://v2.dinerojs.com/docs/guides/using-different-amount-types)),
in an array.

This accounts for decimal and non-decimal currencies, without forcing
the "human" representation of one over the other. Thanks to multi-bases,
the function handles the heavy lifting of distributing an amount into
the right amount of each of its subdivision (e.g., 267 pence is 1 pound,
2 shillings and 3 pence, `toUnits` returns `[1, 2, 3]`). Since `toUnits`
is now used by `toFormat` under the hood (instead of `toUnit`), it makes
formatting even easier.

## `toFormat` is replaced with `toDecimal`

Until now, `toFormat` was the catch-all formatting function for any
formatting need. It was also "only" a wrapper around `toUnit`, exposing
the right information in a transformer function for users to format and
display objects.

An intermediary design was to expose both the data from `toUnits` and a
`decimal` representation in `toFormat`. But ultimately, there's no point
in having a single function that exposes everything: it's
computationally more expensive and it does too many things.

Instead, this PR introduces `toDecimal`, a formatter function that
returns a stringified decimal representation. This is more reliable
because of the string format which prevents the system from converting
it to an inaccurate binary representation.

Another benefit is that the string representation retains the exponent,
meaning that $10.50, which used to be returned as `10.5` will now be
returned as `"10.50"`. If users need this as a double, despite the
potential lack of accuracy (e.g., to manipulate it or use it with the
Intl API), they can easily cast it with `Number` or `parseFloat`, but
that's their responsibility.

`toDecimal` is only meant to be used with decimal, single-base objects.
For multi-base or non-decimal needs, `toUnits` is recommended.

## `toUnits` and `toDecimal` take a transformer as its second parameter

```ts
declare function f<TAmount, TOutput>(d: Dinero<TAmount>, t: Transformer<TAmount, TOutput>): TOutput;
```

This makes it easier for users to customize the output based on the
exposed `value`:
- A tuple in `toUnits`
- A stringified double in `toDecimal`

```js
const formatted = toDecimal(d, ({ value, currency }) => `${currency.code} ${value}`);

// Instead of

const decimal = toDecimal(d);
const formatted = `${toSnapshot(d).currency.code} ${decimal}`;
```

## PR notes

**BREAKING CHANGE:** the `toUnit` and the `toFormat` functions were
removed.

fix #294

Co-authored-by: John Hooks <[email protected]>
  • Loading branch information
sarahdayan and johnhooks authored Dec 4, 2022
1 parent f130337 commit e7e9a19
Show file tree
Hide file tree
Showing 126 changed files with 3,142 additions and 1,183 deletions.
10 changes: 5 additions & 5 deletions bundlesize.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,23 @@
"files": [
{
"path": "packages/calculator-bigint/dist/umd/index.production.js",
"maxSize": "680 B"
"maxSize": "625 B"
},
{
"path": "packages/calculator-number/dist/umd/index.production.js",
"maxSize": "680 B"
"maxSize": "650 B"
},
{
"path": "packages/core/dist/umd/index.production.js",
"maxSize": "3.55 KB"
"maxSize": "4 KB"
},
{
"path": "packages/currencies/dist/umd/index.production.js",
"maxSize": "1.65 KB"
"maxSize": "1.5 KB"
},
{
"path": "packages/dinero.js/dist/umd/index.production.js",
"maxSize": "3.9 KB"
"maxSize": "4.75 KB"
}
]
}
12 changes: 6 additions & 6 deletions examples/cart-react/src/utils/format.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
import { toFormat, toSnapshot } from 'dinero.js';
import { toDecimal, toSnapshot } from 'dinero.js';

function intlFormat(locale, options = {}) {
return function formatter(dineroObject) {
function transformer({ amount, currency }) {
return amount.toLocaleString(locale, {
function transformer({ value, currency }) {
return Number(value).toLocaleString(locale, {
...options,
style: 'currency',
currency: currency.code,
});
}

return toFormat(dineroObject, transformer);
return toDecimal(dineroObject, transformer);
};
}

function formatDefault(dineroObject) {
return toFormat(dineroObject, ({ amount, currency }) => {
return `${currency.code} ${amount.toFixed(currency.exponent)}`;
return toDecimal(dineroObject, ({ value, currency }) => {
return `${currency.code} ${Number(value).toFixed(currency.exponent)}`;
});
}

Expand Down
12 changes: 6 additions & 6 deletions examples/cart-vue/src/utils/format.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
import { toFormat, toSnapshot } from 'dinero.js';
import { toDecimal, toSnapshot } from 'dinero.js';

function intlFormat(locale, options = {}) {
return function formatter(dineroObject) {
function transformer({ amount, currency }) {
return amount.toLocaleString(locale, {
function transformer({ value, currency }) {
return Number(value).toLocaleString(locale, {
...options,
style: 'currency',
currency: currency.code,
});
}

return toFormat(dineroObject, transformer);
return toDecimal(dineroObject, transformer);
};
}

function formatDefault(dineroObject) {
return toFormat(dineroObject, ({ amount, currency }) => {
return `${currency.code} ${amount.toFixed(currency.exponent)}`;
return toDecimal(dineroObject, ({ value, currency }) => {
return `${currency.code} ${Number(value).toFixed(currency.exponent)}`;
});
}

Expand Down
8 changes: 4 additions & 4 deletions examples/pricing-react/src/utils/format.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { hasSubUnits, toFormat, toSnapshot } from 'dinero.js';
import { hasSubUnits, toDecimal, toSnapshot } from 'dinero.js';

export function format(dineroObject) {
function transformer({ amount, currency }) {
function transformer({ value, currency }) {
const { scale } = toSnapshot(dineroObject);
const minimumFractionDigits = hasSubUnits(dineroObject) ? scale : 0;

return amount.toLocaleString('en-US', {
return Number(value).toLocaleString('en-US', {
style: 'currency',
currency: currency.code,
maximumFractionDigits: scale,
minimumFractionDigits,
});
}

return toFormat(dineroObject, transformer);
return toDecimal(dineroObject, transformer);
}
6 changes: 3 additions & 3 deletions examples/starter/main.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/* eslint-disable functional/no-expression-statement, no-console */
import { USD } from '@dinero.js/currencies';
import { dinero, toFormat, toSnapshot } from 'dinero.js';
import { dinero, toDecimal, toSnapshot } from 'dinero.js';

const transformer = (props) => `${props.currency.code} ${props.amount}`;
const transformer = ({ value, currency }) => `${currency.code} ${value}`;

const d = dinero({ amount: 999, currency: USD });

console.log('Snapshot:', toSnapshot(d));
console.log('Formatted:', toFormat(d, transformer));
console.log('Formatted:', toDecimal(d, transformer));
9 changes: 3 additions & 6 deletions packages/calculator-bigint/etc/calculator-bigint.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

import { BinaryOperation } from '@dinero.js/core';
import { ComparisonOperator } from '@dinero.js/core';
import { TransformOperation } from '@dinero.js/core';
import { UnaryOperation } from '@dinero.js/core';

// @public
Expand All @@ -15,15 +14,16 @@ export const add: BinaryOperation<bigint>;
// @public (undocumented)
export const calculator: {
add: BinaryOperation<bigint, bigint>;
compare: BinaryOperation<bigint, ComparisonOperator>;
compare: BinaryOperation<bigint,
ComparisonOperator
>;

This comment has been minimized.

Copy link
@johnhooks

johnhooks Dec 12, 2022

Author Contributor

@sarahdayan for some reason the api.md files were reformatted incorrectly in this commit. If you run the following:

rm -rf packages/*/lib
yarn build

Are the api.md modified from what was included in this commit?

decrement: UnaryOperation<bigint, bigint>;
increment: UnaryOperation<bigint, bigint>;
integerDivide: BinaryOperation<bigint, bigint>;
modulo: BinaryOperation<bigint, bigint>;
multiply: BinaryOperation<bigint, bigint>;
power: BinaryOperation<bigint, bigint>;
subtract: BinaryOperation<bigint, bigint>;
toNumber: TransformOperation<bigint, number>;
zero: typeof zero;
};

Expand Down Expand Up @@ -51,9 +51,6 @@ export const power: BinaryOperation<bigint>;
// @public
export const subtract: BinaryOperation<bigint>;

// @public
export const toNumber: TransformOperation<bigint, number>;

// @public
export function zero(): bigint;

Expand Down
7 changes: 0 additions & 7 deletions packages/calculator-bigint/src/api/__tests__/toNumber.test.ts

This file was deleted.

1 change: 0 additions & 1 deletion packages/calculator-bigint/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,4 @@ export * from './modulo';
export * from './multiply';
export * from './power';
export * from './subtract';
export * from './toNumber';
export * from './zero';
12 changes: 0 additions & 12 deletions packages/calculator-bigint/src/api/toNumber.ts

This file was deleted.

2 changes: 0 additions & 2 deletions packages/calculator-bigint/src/calculator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
multiply,
power,
subtract,
toNumber,
zero,
} from './api';

Expand All @@ -22,6 +21,5 @@ export const calculator = {
multiply,
power,
subtract,
toNumber,
zero,
};
9 changes: 3 additions & 6 deletions packages/calculator-number/etc/calculator-number.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

import { BinaryOperation } from '@dinero.js/core';
import { ComparisonOperator } from '@dinero.js/core';
import { TransformOperation } from '@dinero.js/core';
import { UnaryOperation } from '@dinero.js/core';

// @public
Expand All @@ -15,15 +14,16 @@ export const add: BinaryOperation<number>;
// @public (undocumented)
export const calculator: {
add: BinaryOperation<number, number>;
compare: BinaryOperation<number, ComparisonOperator>;
compare: BinaryOperation<number,
ComparisonOperator
>;
decrement: UnaryOperation<number, number>;
increment: UnaryOperation<number, number>;
integerDivide: BinaryOperation<number, number>;
modulo: BinaryOperation<number, number>;
multiply: BinaryOperation<number, number>;
power: BinaryOperation<number, number>;
subtract: BinaryOperation<number, number>;
toNumber: TransformOperation<number, number>;
zero: typeof zero;
};

Expand Down Expand Up @@ -51,9 +51,6 @@ export const power: BinaryOperation<number>;
// @public
export const subtract: BinaryOperation<number>;

// @public
export const toNumber: TransformOperation<number, number>;

// @public
export function zero(): number;

Expand Down
7 changes: 0 additions & 7 deletions packages/calculator-number/src/api/__tests__/toNumber.test.ts

This file was deleted.

1 change: 0 additions & 1 deletion packages/calculator-number/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,4 @@ export * from './modulo';
export * from './multiply';
export * from './power';
export * from './subtract';
export * from './toNumber';
export * from './zero';
10 changes: 0 additions & 10 deletions packages/calculator-number/src/api/toNumber.ts

This file was deleted.

2 changes: 0 additions & 2 deletions packages/calculator-number/src/calculator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
multiply,
power,
subtract,
toNumber,
zero,
} from './api';

Expand All @@ -22,6 +21,5 @@ export const calculator = {
multiply,
power,
subtract,
toNumber,
zero,
};
Loading

1 comment on commit e7e9a19

@vercel
Copy link

@vercel vercel bot commented on e7e9a19 Dec 4, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

dinerojs – ./

dinerojs-git-main-dinerojs.vercel.app
v2.dinerojs.com
dinerojs-dinerojs.vercel.app

Please sign in to comment.