@ultimat3/money 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/package.json +35 -0
- package/src/allocate.ts +91 -0
- package/src/arithmetic.ts +101 -0
- package/src/convert.ts +119 -0
- package/src/currency.ts +108 -0
- package/src/errors.ts +118 -0
- package/src/format.ts +97 -0
- package/src/index.ts +86 -0
- package/src/money.ts +105 -0
- package/src/rounding.ts +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 developerz.ai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# ๐ถ @ultimat3/money
|
|
2
|
+
|
|
3
|
+
**Golden rule: integer minor units, currency always attached, `Intl` at the edge.**
|
|
4
|
+
`0.1 + 0.2 !== 0.3`, so no amount is ever a float. `Money` is `{ minor, currency }` โ the
|
|
5
|
+
two travel together, and arithmetic across two currencies throws instead of guessing.
|
|
6
|
+
|
|
7
|
+
| Concern | Store | Format |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| Amount | integer minor units (`1299`) | `Intl.NumberFormat`, `style: 'currency'` |
|
|
10
|
+
| Currency | ISO-4217 code (`'EUR'`) | fraction digits derived from its exponent |
|
|
11
|
+
| Scale | never | `10 ** exponentOf(currency)` โ never a literal `/ 100` |
|
|
12
|
+
| FX rate | explicit argument + timestamp | recorded on the converted value |
|
|
13
|
+
|
|
14
|
+
## Use
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { add, allocate, formatMoney, fromDecimal, money } from '@ultimat3/money';
|
|
18
|
+
|
|
19
|
+
const price = fromDecimal('12.99', 'EUR'); // { minor: 1299, currency: 'EUR' }
|
|
20
|
+
const total = add(price, money(500, 'EUR')); // 1799
|
|
21
|
+
formatMoney(total, 'de-DE'); // "17,99 โฌ"
|
|
22
|
+
formatMoney(money(1200, 'JPY'), 'en-US'); // "ยฅ1,200" โ 0 decimals
|
|
23
|
+
formatMoney(money(1234, 'KWD'), 'en-US'); // "KWD 1.234" โ 3 decimals
|
|
24
|
+
add(price, money(500, 'USD')); // throws X_CURRENCY_MISMATCH
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Minor units are not always cents
|
|
28
|
+
|
|
29
|
+
`exponentOf()` is the single source of truth: USD/EUR 2, JPY/KRW/VND/ISK 0, KWD/BHD/OMR 3.
|
|
30
|
+
`fromDecimal` scales by it (`'1.234'` KWD โ 1234), `toDecimalString` reverses it, and
|
|
31
|
+
`formatMoney` sets the fraction digits from it. Hardcoding `/ 100` is a JPY bug and a KWD bug.
|
|
32
|
+
|
|
33
|
+
## Allocation
|
|
34
|
+
|
|
35
|
+
`allocate(money(100, 'USD'), 3)` โ `34, 33, 33`. Largest-remainder split: floor every part,
|
|
36
|
+
then hand out the leftover units one at a time, biggest fractional remainder first.
|
|
37
|
+
`round(100 / 3)` either loses a cent or invents one, and an invoice that does that fails
|
|
38
|
+
reconciliation forever. `allocateByRatios` does the same for revenue shares and line splits.
|
|
39
|
+
|
|
40
|
+
## Rounding is never implicit
|
|
41
|
+
|
|
42
|
+
`multiply(price, 0.19, 'half-up')` โ the mode is an argument because tax and interest rules
|
|
43
|
+
name one in law. `half-up`, `half-even` (banker's), `down`, `up`. The default is `half-up`
|
|
44
|
+
and it is stated, not inherited from `Math.round`.
|
|
45
|
+
|
|
46
|
+
## Conversion
|
|
47
|
+
|
|
48
|
+
No default rate provider ships. `convert(amount, to, rate)` takes the rate explicitly and
|
|
49
|
+
returns the source amount, the rate, and its timestamp alongside the result โ a finance
|
|
50
|
+
audit has to be able to reproduce the number. Implement `RateProvider` for a live feed;
|
|
51
|
+
`fixedRateProvider()` covers tests, seeds and manually agreed invoice rates.
|
|
52
|
+
|
|
53
|
+
## Errors
|
|
54
|
+
|
|
55
|
+
| Code | When |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `X_MONEY_NOT_INTEGER` | fractional minor units, or a decimal string more precise than the currency |
|
|
58
|
+
| `X_CURRENCY_UNKNOWN` | code not in the ISO-4217 table |
|
|
59
|
+
| `X_CURRENCY_MISMATCH` | arithmetic across two currencies |
|
|
60
|
+
| `X_ALLOCATION_INVALID` | bad part count, empty/negative/all-zero ratios, percentages โ 100 |
|
|
61
|
+
| `X_RATE_MISSING` | no rate for the pair โ never assumes parity |
|
|
62
|
+
|
|
63
|
+
## Why it exists
|
|
64
|
+
|
|
65
|
+
Every money bug in production is one of three things: a float, a missing currency, or a
|
|
66
|
+
lost cent in a split. This package makes all three unrepresentable rather than discouraged.
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ultimat3/money",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Integer minor units with an attached currency: arithmetic, allocation, rounding, Intl formatting",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/developerz-ai/ultimate.git",
|
|
10
|
+
"directory": "packages/money"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public",
|
|
14
|
+
"provenance": true
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.ts"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src",
|
|
21
|
+
"!src/**/*.test.ts",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"bun": ">=1.3.0"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
30
|
+
"test": "bun test"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@ultimat3/core": "1.0.0"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/allocate.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Largest-remainder allocation: split a total so the parts add back up to the total.
|
|
3
|
+
*
|
|
4
|
+
* The classic bug: 100 cents split three ways with `round(100 / 3)` gives 33/33/33 and
|
|
5
|
+
* loses a cent, or 34/34/34 and invents one. Invoices that do this fail reconciliation,
|
|
6
|
+
* and a revenue share that does it pays out the wrong amount forever. The fix is to
|
|
7
|
+
* floor every part, then hand the leftover minor units out one at a time, largest
|
|
8
|
+
* fractional remainder first โ deterministic, total-preserving, no floats in the result.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { assertSameCurrency } from './arithmetic';
|
|
12
|
+
import { allocationInvalid } from './errors';
|
|
13
|
+
import { type Money, money } from './money';
|
|
14
|
+
|
|
15
|
+
/** Split into `parts` equal shares. `allocate(money(100,'USD'), 3)` โ 34, 33, 33. */
|
|
16
|
+
export function allocate(amount: Money, parts: number): Money[] {
|
|
17
|
+
if (!Number.isSafeInteger(parts) || parts <= 0) {
|
|
18
|
+
throw allocationInvalid(`part count must be a positive integer, got ${String(parts)}`);
|
|
19
|
+
}
|
|
20
|
+
return allocateByRatios(amount, new Array<number>(parts).fill(1));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Split by weights. `allocateByRatios(money(1000,'USD'), [70, 20, 10])` โ 700, 200, 100;
|
|
25
|
+
* `[1, 1, 1]` over 100 โ 34, 33, 33. Weights need not sum to anything in particular.
|
|
26
|
+
*/
|
|
27
|
+
export function allocateByRatios(amount: Money, ratios: readonly number[]): Money[] {
|
|
28
|
+
if (ratios.length === 0) throw allocationInvalid('ratios must not be empty');
|
|
29
|
+
let total = 0;
|
|
30
|
+
for (const ratio of ratios) {
|
|
31
|
+
if (!Number.isFinite(ratio) || ratio < 0) {
|
|
32
|
+
throw allocationInvalid(`ratios must be finite and non-negative, got ${String(ratio)}`);
|
|
33
|
+
}
|
|
34
|
+
total += ratio;
|
|
35
|
+
}
|
|
36
|
+
if (total <= 0) throw allocationInvalid('ratios must not all be zero');
|
|
37
|
+
|
|
38
|
+
const sign = amount.minor < 0 ? -1 : 1;
|
|
39
|
+
const magnitude = Math.abs(amount.minor);
|
|
40
|
+
|
|
41
|
+
const floors: number[] = [];
|
|
42
|
+
const remainders: number[] = [];
|
|
43
|
+
let assigned = 0;
|
|
44
|
+
for (const ratio of ratios) {
|
|
45
|
+
const exact = (magnitude * ratio) / total;
|
|
46
|
+
const floor = Math.floor(exact);
|
|
47
|
+
floors.push(floor);
|
|
48
|
+
remainders.push(exact - floor);
|
|
49
|
+
assigned += floor;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Hand out the leftover units, largest remainder first; ties go to the earlier index
|
|
53
|
+
// so the same input always produces the same split (invoices must be reproducible).
|
|
54
|
+
let leftover = magnitude - assigned;
|
|
55
|
+
const order = remainders
|
|
56
|
+
.map((remainder, index) => ({ remainder, index }))
|
|
57
|
+
.sort((a, b) => b.remainder - a.remainder || a.index - b.index);
|
|
58
|
+
for (const { index } of order) {
|
|
59
|
+
if (leftover <= 0) break;
|
|
60
|
+
floors[index] = (floors[index] ?? 0) + 1;
|
|
61
|
+
leftover -= 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return floors.map((minor) => money(sign * minor, amount.currency));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Split into shares given as percentages that must sum to 100 โ the invoice-line case
|
|
69
|
+
* where a mis-typed percentage should be an error, not a silent re-normalization.
|
|
70
|
+
*/
|
|
71
|
+
export function allocateByPercentages(amount: Money, percentages: readonly number[]): Money[] {
|
|
72
|
+
const total = percentages.reduce((sum, percentage) => sum + percentage, 0);
|
|
73
|
+
if (Math.abs(total - 100) > 1e-9) {
|
|
74
|
+
throw allocationInvalid(`percentages must sum to 100, got ${String(total)}`);
|
|
75
|
+
}
|
|
76
|
+
return allocateByRatios(amount, percentages);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Guard for callers building their own splits: parts must reconstruct the whole. */
|
|
80
|
+
export function assertAllocationSums(amount: Money, parts: readonly Money[]): void {
|
|
81
|
+
let total = 0;
|
|
82
|
+
for (const part of parts) {
|
|
83
|
+
assertSameCurrency(amount, part);
|
|
84
|
+
total += part.minor;
|
|
85
|
+
}
|
|
86
|
+
if (total !== amount.minor) {
|
|
87
|
+
throw allocationInvalid(
|
|
88
|
+
`allocation of ${amount.currency} ${amount.minor} sums to ${total} โ ${amount.minor - total} minor unit(s) lost`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integer arithmetic that refuses to mix currencies.
|
|
3
|
+
* `add(usd, eur)` is not a rounding problem, it is a wrong answer โ so it throws.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { allocationInvalid, currencyMismatch, currencyRequired } from './errors';
|
|
7
|
+
import { type Money, money } from './money';
|
|
8
|
+
import { DEFAULT_ROUNDING, type RoundingMode, roundToInteger } from './rounding';
|
|
9
|
+
|
|
10
|
+
/** Throws `X_CURRENCY_MISMATCH` unless both operands carry the same currency. */
|
|
11
|
+
export function assertSameCurrency(left: Money, right: Money): string {
|
|
12
|
+
if (left.currency !== right.currency) throw currencyMismatch(left.currency, right.currency);
|
|
13
|
+
return left.currency;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function add(left: Money, right: Money): Money {
|
|
17
|
+
const currency = assertSameCurrency(left, right);
|
|
18
|
+
return money(left.minor + right.minor, currency);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function subtract(left: Money, right: Money): Money {
|
|
22
|
+
const currency = assertSameCurrency(left, right);
|
|
23
|
+
return money(left.minor - right.minor, currency);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Every addend must share one currency; an empty list needs an explicit currency. */
|
|
27
|
+
export function sum(amounts: readonly Money[], currency?: string): Money {
|
|
28
|
+
const base = amounts[0]?.currency ?? currency;
|
|
29
|
+
if (base === undefined) throw currencyRequired('sum([])');
|
|
30
|
+
return amounts.reduce((total, amount) => add(total, amount), money(0, base));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Scale by a plain number (a tax rate, a quantity, a percentage). The result is rounded
|
|
35
|
+
* to whole minor units with an explicit mode โ the default is stated, not implied.
|
|
36
|
+
*/
|
|
37
|
+
export function multiply(
|
|
38
|
+
amount: Money,
|
|
39
|
+
factor: number,
|
|
40
|
+
mode: RoundingMode = DEFAULT_ROUNDING,
|
|
41
|
+
): Money {
|
|
42
|
+
return money(roundToInteger(amount.minor * factor, mode), amount.currency);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Divide into a single share. Use `allocate` when the whole must be preserved โ
|
|
47
|
+
* `divide` alone loses the remainder by design.
|
|
48
|
+
*/
|
|
49
|
+
export function divide(
|
|
50
|
+
amount: Money,
|
|
51
|
+
divisor: number,
|
|
52
|
+
mode: RoundingMode = DEFAULT_ROUNDING,
|
|
53
|
+
): Money {
|
|
54
|
+
if (divisor === 0) {
|
|
55
|
+
throw allocationInvalid('cannot divide money by zero โ use allocate() to split a total');
|
|
56
|
+
}
|
|
57
|
+
return money(roundToInteger(amount.minor / divisor, mode), amount.currency);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function negate(amount: Money): Money {
|
|
61
|
+
return money(-amount.minor, amount.currency);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function absolute(amount: Money): Money {
|
|
65
|
+
return money(Math.abs(amount.minor), amount.currency);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `-1 | 0 | 1`, comparable currencies only. */
|
|
69
|
+
export function compare(left: Money, right: Money): -1 | 0 | 1 {
|
|
70
|
+
assertSameCurrency(left, right);
|
|
71
|
+
if (left.minor < right.minor) return -1;
|
|
72
|
+
return left.minor > right.minor ? 1 : 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function isZero(amount: Money): boolean {
|
|
76
|
+
return amount.minor === 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function isNegative(amount: Money): boolean {
|
|
80
|
+
return amount.minor < 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isPositive(amount: Money): boolean {
|
|
84
|
+
return amount.minor > 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function greaterThan(left: Money, right: Money): boolean {
|
|
88
|
+
return compare(left, right) === 1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function lessThan(left: Money, right: Money): boolean {
|
|
92
|
+
return compare(left, right) === -1;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function min(left: Money, right: Money): Money {
|
|
96
|
+
return compare(left, right) <= 0 ? left : right;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function max(left: Money, right: Money): Money {
|
|
100
|
+
return compare(left, right) >= 0 ? left : right;
|
|
101
|
+
}
|
package/src/convert.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Currency conversion with the rate as an explicit argument.
|
|
3
|
+
* There is no default rate provider: a wrong exchange rate is worse than a missing one,
|
|
4
|
+
* and every converted amount records which rate produced it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { assertCurrency, exponentOf } from './currency';
|
|
8
|
+
import { rateMissing } from './errors';
|
|
9
|
+
import { type Money, money } from './money';
|
|
10
|
+
import { DEFAULT_ROUNDING, type RoundingMode, roundToInteger } from './rounding';
|
|
11
|
+
|
|
12
|
+
export interface ExchangeRate {
|
|
13
|
+
from: string;
|
|
14
|
+
to: string;
|
|
15
|
+
/** Major units of `to` per one major unit of `from`. */
|
|
16
|
+
rate: number;
|
|
17
|
+
/** When the rate was observed โ part of the audit trail, not decoration. */
|
|
18
|
+
at: Date;
|
|
19
|
+
/** Where it came from: `ecb`, `openexchange`, `manual:invoice-4711`. */
|
|
20
|
+
source?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A converted amount carries its provenance so a finance audit can reproduce it. */
|
|
24
|
+
export interface ConvertedMoney {
|
|
25
|
+
amount: Money;
|
|
26
|
+
/** The untouched original โ never overwrite what the customer was charged. */
|
|
27
|
+
source: Money;
|
|
28
|
+
rate: number;
|
|
29
|
+
at: string;
|
|
30
|
+
provider?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ConvertOptions {
|
|
34
|
+
rounding?: RoundingMode;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `convert(money(1000,'USD'), 'EUR', { rate: 0.92, ... })`.
|
|
39
|
+
* Scales across differing minor-unit exponents (USD 2 โ JPY 0) instead of assuming both
|
|
40
|
+
* sides have cents.
|
|
41
|
+
*/
|
|
42
|
+
export function convert(
|
|
43
|
+
amount: Money,
|
|
44
|
+
to: string,
|
|
45
|
+
rate: ExchangeRate,
|
|
46
|
+
options: ConvertOptions = {},
|
|
47
|
+
): ConvertedMoney {
|
|
48
|
+
const target = assertCurrency(to);
|
|
49
|
+
if (rate.from !== amount.currency || rate.to !== target) {
|
|
50
|
+
throw rateMissing(amount.currency, target);
|
|
51
|
+
}
|
|
52
|
+
if (!Number.isFinite(rate.rate) || rate.rate <= 0) {
|
|
53
|
+
throw rateMissing(amount.currency, target);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const scale = 10 ** (exponentOf(target) - exponentOf(amount.currency));
|
|
57
|
+
const converted = roundToInteger(
|
|
58
|
+
amount.minor * rate.rate * scale,
|
|
59
|
+
options.rounding ?? DEFAULT_ROUNDING,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
amount: money(converted, target),
|
|
64
|
+
source: amount,
|
|
65
|
+
rate: rate.rate,
|
|
66
|
+
at: rate.at.toISOString(),
|
|
67
|
+
...(rate.source === undefined ? {} : { provider: rate.source }),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Rate lookup. Implemented by the app โ the framework ships no live provider. */
|
|
72
|
+
export interface RateProvider {
|
|
73
|
+
readonly name: string;
|
|
74
|
+
/** `at` requests a historical rate; providers that cannot honour it must return undefined. */
|
|
75
|
+
rateFor(from: string, to: string, at?: Date): Promise<ExchangeRate | undefined>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Convert through a provider; a missing pair throws `X_RATE_MISSING`, never guesses 1.0. */
|
|
79
|
+
export async function convertWith(
|
|
80
|
+
provider: RateProvider,
|
|
81
|
+
amount: Money,
|
|
82
|
+
to: string,
|
|
83
|
+
options: ConvertOptions & { at?: Date } = {},
|
|
84
|
+
): Promise<ConvertedMoney> {
|
|
85
|
+
const target = assertCurrency(to);
|
|
86
|
+
if (amount.currency === target) {
|
|
87
|
+
const at = (options.at ?? new Date(0)).toISOString();
|
|
88
|
+
return { amount, source: amount, rate: 1, at, provider: 'identity' };
|
|
89
|
+
}
|
|
90
|
+
const rate = await provider.rateFor(amount.currency, target, options.at);
|
|
91
|
+
if (rate === undefined) throw rateMissing(amount.currency, target);
|
|
92
|
+
return {
|
|
93
|
+
...convert(amount, target, rate, options),
|
|
94
|
+
provider: rate.source ?? provider.name,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Fixed-table provider for tests, seeds and manual invoice rates.
|
|
100
|
+
* Keys are `FROM/TO`; the inverse is derived so a table needs one direction only.
|
|
101
|
+
*/
|
|
102
|
+
export function fixedRateProvider(
|
|
103
|
+
rates: Readonly<Record<string, number>>,
|
|
104
|
+
at: Date,
|
|
105
|
+
name = 'fixed',
|
|
106
|
+
): RateProvider {
|
|
107
|
+
return {
|
|
108
|
+
name,
|
|
109
|
+
async rateFor(from: string, to: string): Promise<ExchangeRate | undefined> {
|
|
110
|
+
const direct = rates[`${from}/${to}`];
|
|
111
|
+
if (direct !== undefined) return { from, to, rate: direct, at, source: name };
|
|
112
|
+
const inverse = rates[`${to}/${from}`];
|
|
113
|
+
if (inverse !== undefined && inverse !== 0) {
|
|
114
|
+
return { from, to, rate: 1 / inverse, at, source: name };
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
package/src/currency.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ISO-4217 minor-unit exponent table. Every scale in the package derives from here โ
|
|
3
|
+
* a hardcoded `/ 100` is a bug in JPY (0 digits) and in KWD (3 digits).
|
|
4
|
+
* As of 2026-07.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { currencyUnknown } from './errors';
|
|
8
|
+
|
|
9
|
+
/** Uppercase ISO-4217 alphabetic code. */
|
|
10
|
+
export type CurrencyCode = string;
|
|
11
|
+
|
|
12
|
+
export interface CurrencyInfo {
|
|
13
|
+
code: CurrencyCode;
|
|
14
|
+
/** Number of decimal digits in the minor unit: USD 2, JPY 0, KWD 3. */
|
|
15
|
+
exponent: number;
|
|
16
|
+
name: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const TABLE: readonly CurrencyInfo[] = [
|
|
20
|
+
{ code: 'AED', exponent: 2, name: 'UAE Dirham' },
|
|
21
|
+
{ code: 'ARS', exponent: 2, name: 'Argentine Peso' },
|
|
22
|
+
{ code: 'AUD', exponent: 2, name: 'Australian Dollar' },
|
|
23
|
+
{ code: 'BGN', exponent: 2, name: 'Bulgarian Lev' },
|
|
24
|
+
{ code: 'BHD', exponent: 3, name: 'Bahraini Dinar' },
|
|
25
|
+
{ code: 'BRL', exponent: 2, name: 'Brazilian Real' },
|
|
26
|
+
{ code: 'CAD', exponent: 2, name: 'Canadian Dollar' },
|
|
27
|
+
{ code: 'CHF', exponent: 2, name: 'Swiss Franc' },
|
|
28
|
+
{ code: 'CLP', exponent: 0, name: 'Chilean Peso' },
|
|
29
|
+
{ code: 'CNY', exponent: 2, name: 'Yuan Renminbi' },
|
|
30
|
+
{ code: 'COP', exponent: 2, name: 'Colombian Peso' },
|
|
31
|
+
{ code: 'CZK', exponent: 2, name: 'Czech Koruna' },
|
|
32
|
+
{ code: 'DKK', exponent: 2, name: 'Danish Krone' },
|
|
33
|
+
{ code: 'EGP', exponent: 2, name: 'Egyptian Pound' },
|
|
34
|
+
{ code: 'EUR', exponent: 2, name: 'Euro' },
|
|
35
|
+
{ code: 'GBP', exponent: 2, name: 'Pound Sterling' },
|
|
36
|
+
{ code: 'HKD', exponent: 2, name: 'Hong Kong Dollar' },
|
|
37
|
+
{ code: 'HUF', exponent: 2, name: 'Forint' },
|
|
38
|
+
{ code: 'IDR', exponent: 2, name: 'Rupiah' },
|
|
39
|
+
{ code: 'ILS', exponent: 2, name: 'New Israeli Sheqel' },
|
|
40
|
+
{ code: 'INR', exponent: 2, name: 'Indian Rupee' },
|
|
41
|
+
{ code: 'ISK', exponent: 0, name: 'Iceland Krona' },
|
|
42
|
+
{ code: 'JOD', exponent: 3, name: 'Jordanian Dinar' },
|
|
43
|
+
{ code: 'JPY', exponent: 0, name: 'Yen' },
|
|
44
|
+
{ code: 'KES', exponent: 2, name: 'Kenyan Shilling' },
|
|
45
|
+
{ code: 'KRW', exponent: 0, name: 'Won' },
|
|
46
|
+
{ code: 'KWD', exponent: 3, name: 'Kuwaiti Dinar' },
|
|
47
|
+
{ code: 'MAD', exponent: 2, name: 'Moroccan Dirham' },
|
|
48
|
+
{ code: 'MXN', exponent: 2, name: 'Mexican Peso' },
|
|
49
|
+
{ code: 'MYR', exponent: 2, name: 'Malaysian Ringgit' },
|
|
50
|
+
{ code: 'NGN', exponent: 2, name: 'Naira' },
|
|
51
|
+
{ code: 'NOK', exponent: 2, name: 'Norwegian Krone' },
|
|
52
|
+
{ code: 'NZD', exponent: 2, name: 'New Zealand Dollar' },
|
|
53
|
+
{ code: 'OMR', exponent: 3, name: 'Rial Omani' },
|
|
54
|
+
{ code: 'PEN', exponent: 2, name: 'Sol' },
|
|
55
|
+
{ code: 'PHP', exponent: 2, name: 'Philippine Peso' },
|
|
56
|
+
{ code: 'PKR', exponent: 2, name: 'Pakistan Rupee' },
|
|
57
|
+
{ code: 'PLN', exponent: 2, name: 'Zloty' },
|
|
58
|
+
{ code: 'RON', exponent: 2, name: 'Romanian Leu' },
|
|
59
|
+
{ code: 'RSD', exponent: 2, name: 'Serbian Dinar' },
|
|
60
|
+
{ code: 'SAR', exponent: 2, name: 'Saudi Riyal' },
|
|
61
|
+
{ code: 'SEK', exponent: 2, name: 'Swedish Krona' },
|
|
62
|
+
{ code: 'SGD', exponent: 2, name: 'Singapore Dollar' },
|
|
63
|
+
{ code: 'THB', exponent: 2, name: 'Baht' },
|
|
64
|
+
{ code: 'TND', exponent: 3, name: 'Tunisian Dinar' },
|
|
65
|
+
{ code: 'TRY', exponent: 2, name: 'Turkish Lira' },
|
|
66
|
+
{ code: 'TWD', exponent: 2, name: 'New Taiwan Dollar' },
|
|
67
|
+
{ code: 'UAH', exponent: 2, name: 'Hryvnia' },
|
|
68
|
+
{ code: 'USD', exponent: 2, name: 'US Dollar' },
|
|
69
|
+
{ code: 'UYU', exponent: 2, name: 'Peso Uruguayo' },
|
|
70
|
+
{ code: 'VND', exponent: 0, name: 'Dong' },
|
|
71
|
+
{ code: 'XOF', exponent: 0, name: 'CFA Franc BCEAO' },
|
|
72
|
+
{ code: 'ZAR', exponent: 2, name: 'Rand' },
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
const BY_CODE: ReadonlyMap<string, CurrencyInfo> = new Map(
|
|
76
|
+
TABLE.map((info) => [info.code, info] as const),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
export const CURRENCIES: readonly CurrencyInfo[] = TABLE;
|
|
80
|
+
|
|
81
|
+
export function isValidCurrency(currency: string): boolean {
|
|
82
|
+
return BY_CODE.has(currency);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Loud lookup: an unknown code is a data bug, not a formatting quirk. */
|
|
86
|
+
export function currencyInfo(currency: string): CurrencyInfo {
|
|
87
|
+
const info = BY_CODE.get(currency);
|
|
88
|
+
if (info === undefined) throw currencyUnknown(currency);
|
|
89
|
+
return info;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function assertCurrency(currency: string): CurrencyCode {
|
|
93
|
+
return currencyInfo(currency).code;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Decimal digits in the minor unit. */
|
|
97
|
+
export function exponentOf(currency: string): number {
|
|
98
|
+
return currencyInfo(currency).exponent;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Minor units per major unit: 100 for EUR, 1 for JPY, 1000 for KWD. */
|
|
102
|
+
export function scaleOf(currency: string): number {
|
|
103
|
+
return 10 ** exponentOf(currency);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function currencyCodes(): CurrencyCode[] {
|
|
107
|
+
return TABLE.map((info) => info.code);
|
|
108
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The X_* error codes owned by @ultimat3/money.
|
|
3
|
+
* A money bug that throws is a bug you can fix; one that rounds is a bug you ship.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
7
|
+
|
|
8
|
+
export const MONEY_ERROR_CODES = [
|
|
9
|
+
'X_MONEY_NOT_INTEGER',
|
|
10
|
+
'X_CURRENCY_UNKNOWN',
|
|
11
|
+
'X_CURRENCY_MISMATCH',
|
|
12
|
+
'X_ALLOCATION_INVALID',
|
|
13
|
+
'X_RATE_MISSING',
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
export type MoneyErrorCode = (typeof MONEY_ERROR_CODES)[number];
|
|
17
|
+
|
|
18
|
+
export const MONEY_ERROR_TITLES: Readonly<Record<MoneyErrorCode, string>> = {
|
|
19
|
+
X_MONEY_NOT_INTEGER: 'a Money.minor value that is not an integer',
|
|
20
|
+
X_CURRENCY_UNKNOWN: 'currency code not in the currency table',
|
|
21
|
+
X_CURRENCY_MISMATCH: 'two Money values in different currencies',
|
|
22
|
+
X_ALLOCATION_INVALID: 'split ratios or part count are unusable',
|
|
23
|
+
X_RATE_MISSING: 'no FX rate for the pair',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Titles must be registered for `format()` to render the contract's first line. Every code above is
|
|
27
|
+
// owned here and none is borrowed, so the call is unconditional: a second package claiming one has
|
|
28
|
+
// to fail as X_ERROR_CODE_DUPLICATE, not quietly keep whichever title was registered first.
|
|
29
|
+
registerErrorCodes(
|
|
30
|
+
Object.fromEntries(Object.entries(MONEY_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
export class MoneyError extends UltimateError {
|
|
34
|
+
constructor(init: { code: MoneyErrorCode; cause: string; fix: string }) {
|
|
35
|
+
super({
|
|
36
|
+
code: init.code,
|
|
37
|
+
cause: init.cause,
|
|
38
|
+
fix: init.fix,
|
|
39
|
+
docs: `https://ultimate.dev/errors/${init.code}`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function moneyNotInteger(minor: number, currency: string): MoneyError {
|
|
45
|
+
return new MoneyError({
|
|
46
|
+
code: 'X_MONEY_NOT_INTEGER',
|
|
47
|
+
cause: `minor units must be a safe integer, got ${String(minor)} for ${currency}`,
|
|
48
|
+
fix: `use fromDecimal('${Number.isFinite(minor) ? minor : 0}', '${currency}') or round explicitly with multiply(m, factor, 'half-up')`,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A non-finite input to rounding is upstream float arithmetic that already lost the amount โ
|
|
54
|
+
* rounding it would invent a number, so it throws instead.
|
|
55
|
+
*/
|
|
56
|
+
export function notRoundable(value: number): MoneyError {
|
|
57
|
+
return new MoneyError({
|
|
58
|
+
code: 'X_MONEY_NOT_INTEGER',
|
|
59
|
+
cause: `cannot round a non-finite amount: ${String(value)}`,
|
|
60
|
+
fix: 'trace the amount back to its source โ a NaN or Infinity here means a division or a float multiply upstream; build amounts with fromDecimal(string, currency)',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function decimalTooPrecise(value: string, currency: string, exponent: number): MoneyError {
|
|
65
|
+
return new MoneyError({
|
|
66
|
+
code: 'X_MONEY_NOT_INTEGER',
|
|
67
|
+
cause: `"${value}" has more fraction digits than ${currency} has minor units (${exponent})`,
|
|
68
|
+
fix: `pass { rounding: 'half-up' } to fromDecimal to accept the loss of precision on purpose`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function decimalNotNumeric(value: string, currency: string): MoneyError {
|
|
73
|
+
return new MoneyError({
|
|
74
|
+
code: 'X_MONEY_NOT_INTEGER',
|
|
75
|
+
cause: `"${value}" is not a decimal amount โ no grouping separators, no exponent notation`,
|
|
76
|
+
fix: `pass a plain decimal string: fromDecimal('12.99', '${currency}')`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function currencyUnknown(currency: string): MoneyError {
|
|
81
|
+
return new MoneyError({
|
|
82
|
+
code: 'X_CURRENCY_UNKNOWN',
|
|
83
|
+
cause: `"${currency}" is not an ISO-4217 code in the currency table`,
|
|
84
|
+
fix: `x money add-currency ${currency.toUpperCase().slice(0, 3) || 'XXX'} --exponent 2`,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function currencyRequired(context: string): MoneyError {
|
|
89
|
+
return new MoneyError({
|
|
90
|
+
code: 'X_CURRENCY_UNKNOWN',
|
|
91
|
+
cause: `${context}: no currency to infer, and none was passed`,
|
|
92
|
+
fix: "pass the currency explicitly, e.g. sum([], 'EUR')",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function currencyMismatch(left: string, right: string): MoneyError {
|
|
97
|
+
return new MoneyError({
|
|
98
|
+
code: 'X_CURRENCY_MISMATCH',
|
|
99
|
+
cause: `refusing to combine ${left} and ${right} โ two currencies are not one number`,
|
|
100
|
+
fix: `convert(rightOperand, '${left}', rate) first, then combine`,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function allocationInvalid(cause: string): MoneyError {
|
|
105
|
+
return new MoneyError({
|
|
106
|
+
code: 'X_ALLOCATION_INVALID',
|
|
107
|
+
cause,
|
|
108
|
+
fix: 'pass a positive integer part count, or ratios that are finite, non-negative and not all zero',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function rateMissing(from: string, to: string): MoneyError {
|
|
113
|
+
return new MoneyError({
|
|
114
|
+
code: 'X_RATE_MISSING',
|
|
115
|
+
cause: `no exchange rate available for ${from}โ${to}`,
|
|
116
|
+
fix: 'register a RateProvider that covers this pair โ there is no default provider, because a wrong rate is worse than a missing one',
|
|
117
|
+
});
|
|
118
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Intl.NumberFormat` at the edge. Fraction digits come from the currency exponent, so
|
|
3
|
+
* JPY renders without decimals and KWD with three, without a per-locale special case.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { exponentOf } from './currency';
|
|
7
|
+
import { type Money, toDecimalNumber } from './money';
|
|
8
|
+
|
|
9
|
+
export interface FormatMoneyOptions {
|
|
10
|
+
/** How the currency appears: `โฌ1,299.00` / `EUR 1,299.00` / `1,299.00 euros`. */
|
|
11
|
+
display?: 'symbol' | 'narrowSymbol' | 'code' | 'name';
|
|
12
|
+
/** Accounting negatives: `(โฌ12.99)` instead of `-โฌ12.99`. */
|
|
13
|
+
accounting?: boolean;
|
|
14
|
+
/** Drop `.00` on whole amounts โ price lists, never invoices. */
|
|
15
|
+
trimZeroFraction?: boolean;
|
|
16
|
+
/** Force a digit count; defaults to the currency's minor-unit exponent. */
|
|
17
|
+
fractionDigits?: number;
|
|
18
|
+
/** `never` disables grouping separators. */
|
|
19
|
+
grouping?: 'auto' | 'never';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `formatMoney(money(129900,'EUR'), 'de-DE')` โ `1.299,00 โฌ`. */
|
|
23
|
+
export function formatMoney(
|
|
24
|
+
amount: Money,
|
|
25
|
+
locale: string,
|
|
26
|
+
options: FormatMoneyOptions = {},
|
|
27
|
+
): string {
|
|
28
|
+
const rendered = formatterFor(amount.currency, locale, options).format(
|
|
29
|
+
Math.abs(toDecimalNumber(amount)),
|
|
30
|
+
);
|
|
31
|
+
if (amount.minor < 0) {
|
|
32
|
+
return options.accounting === true ? `(${rendered})` : `-${rendered}`;
|
|
33
|
+
}
|
|
34
|
+
return rendered;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Parts, for UI that styles the symbol or the decimals differently (a smaller superscript
|
|
39
|
+
* cent, a muted currency code). Never re-split a formatted string with a regex.
|
|
40
|
+
*/
|
|
41
|
+
export function formatMoneyParts(
|
|
42
|
+
amount: Money,
|
|
43
|
+
locale: string,
|
|
44
|
+
options: FormatMoneyOptions = {},
|
|
45
|
+
): Intl.NumberFormatPart[] {
|
|
46
|
+
return formatterFor(amount.currency, locale, options).formatToParts(toDecimalNumber(amount));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The symbol alone, e.g. for an input prefix: `โฌ`, `ยฅ`, `KD`. */
|
|
50
|
+
export function currencySymbol(currency: string, locale: string): string {
|
|
51
|
+
const parts = formatterFor(currency, locale, { display: 'narrowSymbol' }).formatToParts(0);
|
|
52
|
+
return parts.find((part) => part.type === 'currency')?.value ?? currency;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Digits only, no symbol โ for editable inputs and CSV exports. */
|
|
56
|
+
export function formatMoneyDecimal(amount: Money, locale: string): string {
|
|
57
|
+
const digits = exponentOf(amount.currency);
|
|
58
|
+
return new Intl.NumberFormat(locale, {
|
|
59
|
+
style: 'decimal',
|
|
60
|
+
minimumFractionDigits: digits,
|
|
61
|
+
maximumFractionDigits: digits,
|
|
62
|
+
useGrouping: false,
|
|
63
|
+
}).format(toDecimalNumber(amount));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const cache = new Map<string, Intl.NumberFormat>();
|
|
67
|
+
|
|
68
|
+
function formatterFor(
|
|
69
|
+
currency: string,
|
|
70
|
+
locale: string,
|
|
71
|
+
options: FormatMoneyOptions,
|
|
72
|
+
): Intl.NumberFormat {
|
|
73
|
+
const exponent = exponentOf(currency);
|
|
74
|
+
const digits =
|
|
75
|
+
options.fractionDigits ?? (options.trimZeroFraction === true ? undefined : exponent);
|
|
76
|
+
const key = [
|
|
77
|
+
locale,
|
|
78
|
+
currency,
|
|
79
|
+
options.display ?? 'symbol',
|
|
80
|
+
digits ?? 'auto',
|
|
81
|
+
options.grouping ?? 'auto',
|
|
82
|
+
].join('|');
|
|
83
|
+
const cached = cache.get(key);
|
|
84
|
+
if (cached !== undefined) return cached;
|
|
85
|
+
|
|
86
|
+
const formatter = new Intl.NumberFormat(locale, {
|
|
87
|
+
style: 'currency',
|
|
88
|
+
currency,
|
|
89
|
+
currencyDisplay: options.display ?? 'symbol',
|
|
90
|
+
...(digits === undefined
|
|
91
|
+
? { minimumFractionDigits: 0, maximumFractionDigits: exponent }
|
|
92
|
+
: { minimumFractionDigits: digits, maximumFractionDigits: digits }),
|
|
93
|
+
...(options.grouping === 'never' ? { useGrouping: false } : {}),
|
|
94
|
+
});
|
|
95
|
+
cache.set(key, formatter);
|
|
96
|
+
return formatter;
|
|
97
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** Public surface of @ultimat3/money. Explicit exports only. */
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
allocate,
|
|
5
|
+
allocateByPercentages,
|
|
6
|
+
allocateByRatios,
|
|
7
|
+
assertAllocationSums,
|
|
8
|
+
} from './allocate';
|
|
9
|
+
export {
|
|
10
|
+
absolute,
|
|
11
|
+
add,
|
|
12
|
+
assertSameCurrency,
|
|
13
|
+
compare,
|
|
14
|
+
divide,
|
|
15
|
+
greaterThan,
|
|
16
|
+
isNegative,
|
|
17
|
+
isPositive,
|
|
18
|
+
isZero,
|
|
19
|
+
lessThan,
|
|
20
|
+
max,
|
|
21
|
+
min,
|
|
22
|
+
multiply,
|
|
23
|
+
negate,
|
|
24
|
+
subtract,
|
|
25
|
+
sum,
|
|
26
|
+
} from './arithmetic';
|
|
27
|
+
export {
|
|
28
|
+
type ConvertedMoney,
|
|
29
|
+
type ConvertOptions,
|
|
30
|
+
convert,
|
|
31
|
+
type ExchangeRate,
|
|
32
|
+
fixedRateProvider,
|
|
33
|
+
type RateProvider,
|
|
34
|
+
} from './convert';
|
|
35
|
+
export {
|
|
36
|
+
assertCurrency,
|
|
37
|
+
CURRENCIES,
|
|
38
|
+
type CurrencyCode,
|
|
39
|
+
type CurrencyInfo,
|
|
40
|
+
currencyCodes,
|
|
41
|
+
currencyInfo,
|
|
42
|
+
exponentOf,
|
|
43
|
+
isValidCurrency,
|
|
44
|
+
scaleOf,
|
|
45
|
+
} from './currency';
|
|
46
|
+
export {
|
|
47
|
+
allocationInvalid,
|
|
48
|
+
currencyMismatch,
|
|
49
|
+
currencyRequired,
|
|
50
|
+
currencyUnknown,
|
|
51
|
+
decimalNotNumeric,
|
|
52
|
+
decimalTooPrecise,
|
|
53
|
+
MONEY_ERROR_CODES,
|
|
54
|
+
MONEY_ERROR_TITLES,
|
|
55
|
+
MoneyError,
|
|
56
|
+
type MoneyErrorCode,
|
|
57
|
+
moneyNotInteger,
|
|
58
|
+
rateMissing,
|
|
59
|
+
} from './errors';
|
|
60
|
+
export {
|
|
61
|
+
currencySymbol,
|
|
62
|
+
type FormatMoneyOptions,
|
|
63
|
+
formatMoney,
|
|
64
|
+
formatMoneyDecimal,
|
|
65
|
+
formatMoneyParts,
|
|
66
|
+
} from './format';
|
|
67
|
+
export {
|
|
68
|
+
currencyOf,
|
|
69
|
+
equals,
|
|
70
|
+
type FromDecimalOptions,
|
|
71
|
+
formatMoneyDebug,
|
|
72
|
+
fromDecimal,
|
|
73
|
+
isMoney,
|
|
74
|
+
type Money,
|
|
75
|
+
money,
|
|
76
|
+
toDecimalNumber,
|
|
77
|
+
toDecimalString,
|
|
78
|
+
zero,
|
|
79
|
+
} from './money';
|
|
80
|
+
export {
|
|
81
|
+
DEFAULT_ROUNDING,
|
|
82
|
+
ROUNDING_MODES,
|
|
83
|
+
type RoundingMode,
|
|
84
|
+
roundToDigits,
|
|
85
|
+
roundToInteger,
|
|
86
|
+
} from './rounding';
|
package/src/money.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Money` value type: an integer count of minor units with its currency attached.
|
|
3
|
+
* There is no float anywhere in this package, and no amount without a currency.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { assertCurrency, type CurrencyCode, exponentOf, scaleOf } from './currency';
|
|
7
|
+
import { decimalNotNumeric, decimalTooPrecise, moneyNotInteger } from './errors';
|
|
8
|
+
import { type RoundingMode, roundToInteger } from './rounding';
|
|
9
|
+
|
|
10
|
+
/** `{ minor: 129900, currency: 'EUR' }` is โฌ1,299.00. Treat instances as immutable. */
|
|
11
|
+
export type Money = { minor: number; currency: string };
|
|
12
|
+
|
|
13
|
+
const DECIMAL = /^([+-])?(\d+)(?:\.(\d+))?$/;
|
|
14
|
+
|
|
15
|
+
/** The only constructor. Validates the currency and rejects fractional minor units. */
|
|
16
|
+
export function money(minor: number, currency: string): Money {
|
|
17
|
+
const code = assertCurrency(currency);
|
|
18
|
+
if (!Number.isSafeInteger(minor)) throw moneyNotInteger(minor, code);
|
|
19
|
+
return { minor, currency: code };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function zero(currency: string): Money {
|
|
23
|
+
return money(0, currency);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface FromDecimalOptions {
|
|
27
|
+
/** Required to accept a value with more precision than the currency has. */
|
|
28
|
+
rounding?: RoundingMode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse a decimal **string** โ never a float. `fromDecimal(12.99, 'EUR')` would already
|
|
33
|
+
* have lost the value before this function saw it, so the signature refuses it.
|
|
34
|
+
* `'12.99'` โ 1299 EUR ยท `'1200'` โ 1200 JPY ยท `'1.234'` โ 1234 KWD.
|
|
35
|
+
*/
|
|
36
|
+
export function fromDecimal(
|
|
37
|
+
value: string,
|
|
38
|
+
currency: string,
|
|
39
|
+
options: FromDecimalOptions = {},
|
|
40
|
+
): Money {
|
|
41
|
+
const code = assertCurrency(currency);
|
|
42
|
+
const match = DECIMAL.exec(value.trim());
|
|
43
|
+
const integerPart = match?.[2];
|
|
44
|
+
if (match === null || integerPart === undefined) {
|
|
45
|
+
throw decimalNotNumeric(value, code);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const exponent = exponentOf(code);
|
|
49
|
+
const negative = match[1] === '-';
|
|
50
|
+
const fractionPart = match[3] ?? '';
|
|
51
|
+
|
|
52
|
+
let minor: number;
|
|
53
|
+
if (fractionPart.length <= exponent) {
|
|
54
|
+
// Concatenating the digits *is* the minor-unit integer โ no multiply, no drift.
|
|
55
|
+
minor = Number(`${integerPart}${fractionPart.padEnd(exponent, '0')}`);
|
|
56
|
+
} else {
|
|
57
|
+
const mode = options.rounding;
|
|
58
|
+
if (mode === undefined) throw decimalTooPrecise(value, code, exponent);
|
|
59
|
+
const kept = Number(`${integerPart}${fractionPart.slice(0, exponent)}`);
|
|
60
|
+
const remainder = Number(`0.${fractionPart.slice(exponent)}`);
|
|
61
|
+
minor = roundToInteger(kept + remainder, mode);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!Number.isSafeInteger(minor)) throw moneyNotInteger(minor, code);
|
|
65
|
+
return { minor: negative ? -minor : minor, currency: code };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `1299 EUR` โ `'12.99'`; `1200 JPY` โ `'1200'`; `1234 KWD` โ `'1.234'`. */
|
|
69
|
+
export function toDecimalString(amount: Money): string {
|
|
70
|
+
const exponent = exponentOf(amount.currency);
|
|
71
|
+
const sign = amount.minor < 0 ? '-' : '';
|
|
72
|
+
const digits = Math.abs(amount.minor)
|
|
73
|
+
.toString()
|
|
74
|
+
.padStart(exponent + 1, '0');
|
|
75
|
+
if (exponent === 0) return `${sign}${digits}`;
|
|
76
|
+
return `${sign}${digits.slice(0, digits.length - exponent)}.${digits.slice(-exponent)}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Major units as a float. **Format-time only** โ the one place a division by the
|
|
81
|
+
* currency scale is legitimate, because `Intl.NumberFormat` takes a number.
|
|
82
|
+
*/
|
|
83
|
+
export function toDecimalNumber(amount: Money): number {
|
|
84
|
+
return amount.minor / scaleOf(amount.currency);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function isMoney(value: unknown): value is Money {
|
|
88
|
+
if (value === null || typeof value !== 'object') return false;
|
|
89
|
+
const candidate = value as { minor?: unknown; currency?: unknown };
|
|
90
|
+
return Number.isSafeInteger(candidate.minor) && typeof candidate.currency === 'string';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Same currency, same minor units. */
|
|
94
|
+
export function equals(left: Money, right: Money): boolean {
|
|
95
|
+
return left.currency === right.currency && left.minor === right.minor;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Stable serialization for logs, JSON columns and the manifest: `EUR 1299`. */
|
|
99
|
+
export function formatMoneyDebug(amount: Money): string {
|
|
100
|
+
return `${amount.currency} ${amount.minor}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function currencyOf(amount: Money): CurrencyCode {
|
|
104
|
+
return amount.currency;
|
|
105
|
+
}
|
package/src/rounding.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explicit rounding modes. Tax, interest and VAT rules each name a mode in law;
|
|
3
|
+
* whichever one `Math.round` happens to implement is not an answer.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { notRoundable } from './errors';
|
|
7
|
+
|
|
8
|
+
export type RoundingMode =
|
|
9
|
+
/** 0.5 away from zero โ the commercial default most invoicing rules specify. */
|
|
10
|
+
| 'half-up'
|
|
11
|
+
/** 0.5 to the nearest even โ banker's rounding, ISO 80000-1, avoids upward drift. */
|
|
12
|
+
| 'half-even'
|
|
13
|
+
/** Truncate toward zero โ never overcharge. */
|
|
14
|
+
| 'down'
|
|
15
|
+
/** Away from zero โ never undercharge. */
|
|
16
|
+
| 'up';
|
|
17
|
+
|
|
18
|
+
export const ROUNDING_MODES: readonly RoundingMode[] = ['half-up', 'half-even', 'down', 'up'];
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_ROUNDING: RoundingMode = 'half-up';
|
|
21
|
+
|
|
22
|
+
/** Round a fractional minor-unit amount to an integer number of minor units. */
|
|
23
|
+
export function roundToInteger(value: number, mode: RoundingMode = DEFAULT_ROUNDING): number {
|
|
24
|
+
if (!Number.isFinite(value)) throw notRoundable(value);
|
|
25
|
+
const sign = value < 0 ? -1 : 1;
|
|
26
|
+
const magnitude = Math.abs(value);
|
|
27
|
+
const floor = Math.floor(magnitude);
|
|
28
|
+
const fraction = magnitude - floor;
|
|
29
|
+
|
|
30
|
+
switch (mode) {
|
|
31
|
+
case 'down':
|
|
32
|
+
return sign * floor;
|
|
33
|
+
case 'up':
|
|
34
|
+
return sign * (fraction > 0 ? floor + 1 : floor);
|
|
35
|
+
case 'half-up':
|
|
36
|
+
return sign * (fraction >= 0.5 ? floor + 1 : floor);
|
|
37
|
+
case 'half-even': {
|
|
38
|
+
if (fraction > 0.5) return sign * (floor + 1);
|
|
39
|
+
if (fraction < 0.5) return sign * floor;
|
|
40
|
+
return sign * (floor % 2 === 0 ? floor : floor + 1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Round to `digits` decimal places, used when converting a decimal string whose
|
|
47
|
+
* precision exceeds the currency's minor unit.
|
|
48
|
+
*/
|
|
49
|
+
export function roundToDigits(
|
|
50
|
+
value: number,
|
|
51
|
+
digits: number,
|
|
52
|
+
mode: RoundingMode = DEFAULT_ROUNDING,
|
|
53
|
+
): number {
|
|
54
|
+
const factor = 10 ** digits;
|
|
55
|
+
return roundToInteger(value * factor, mode) / factor;
|
|
56
|
+
}
|