@azghr/specie 0.1.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/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/dist/index.cjs +251 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +91 -0
- package/dist/index.d.ts +91 -0
- package/dist/index.js +222 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-07-22
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Complete Money implementation with integer-cents arithmetic
|
|
7
|
+
- Currency-aware formatting via Intl.NumberFormat
|
|
8
|
+
- Largest-remainder allocation algorithm for exact bill splitting
|
|
9
|
+
- Half-even (banker's), half-up, down, and up rounding modes
|
|
10
|
+
- Safe integer validation and overflow protection
|
|
11
|
+
- Currency mismatch protection with CurrencyMismatch error
|
|
12
|
+
- Comprehensive test suite with 54 tests covering all invariants and edge cases
|
|
13
|
+
- Runnable demo with printed evidence of package claims
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- fromDecimal now correctly uses getCurrencyFractionDigits instead of hardcoded value
|
|
17
|
+
- Negative decimal string parsing ("-10.50" → -1050 cents)
|
|
18
|
+
- Multiply overflow protection (caps at MAX_SAFE_INTEGER/MIN_SAFE_INTEGER)
|
|
19
|
+
|
|
20
|
+
### Documentation
|
|
21
|
+
- Complete README with API documentation, usage examples, and non-goals
|
|
22
|
+
- Seven-section demo showing float bug prevention, allocation, rounding, and formatting
|
|
23
|
+
- TypeScript examples for full type safety
|
|
24
|
+
|
|
25
|
+
## 0.1.0 (2026-07-22)
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
- Initial package scaffold with TypeScript, ESLint, and Vitest configuration
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 npmdevkit authors
|
|
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,127 @@
|
|
|
1
|
+
# specie
|
|
2
|
+
|
|
3
|
+
Tiny integer-cents money type — safe arithmetic, currency-aware rounding, Intl formatting. No bignum, no dependencies.
|
|
4
|
+
|
|
5
|
+
## The Problem
|
|
6
|
+
|
|
7
|
+
`0.1 + 0.2 !== 0.3`. Storing money as floats produces rounding errors and audit nightmares. Correct money is integer minor units + a currency + explicit rounding + locale formatting.
|
|
8
|
+
|
|
9
|
+
This is competitive space: dinero.js, big.js, and decimal.js cover arbitrary decimals. specie is worth building only because it stays radically smaller: integer minor units only, currency-aware default fractions via Intl, and a few explicit operations.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install @azghr/specie
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Use
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { money, fromDecimal } from "@azghr/specie";
|
|
21
|
+
|
|
22
|
+
// 10-second copy-paste
|
|
23
|
+
const price = money(1999, "USD"); // $19.99 as 1999 cents
|
|
24
|
+
price.add(money(100, "USD")).format(); // "$20.99"
|
|
25
|
+
|
|
26
|
+
// Realistic usage
|
|
27
|
+
const subtotal = fromDecimal("19.99", "USD");
|
|
28
|
+
const tax = subtotal.multiply(0.0825); // 8.25% tax
|
|
29
|
+
const total = subtotal.add(tax);
|
|
30
|
+
total.format(); // "$21.64"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## API
|
|
34
|
+
|
|
35
|
+
### `money(amount: number, currency: string): Money`
|
|
36
|
+
Create Money from integer minor units. `amount` must be a safe integer.
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
money(1999, "USD") // $19.99
|
|
40
|
+
money(500, "JPY") // ¥500
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### `fromDecimal(value: string, currency: string): Money`
|
|
44
|
+
Parse decimal string to Money. Never uses float parsing to avoid rounding errors.
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
fromDecimal("19.99", "USD") // 1999 cents
|
|
48
|
+
fromDecimal("-10.50", "USD") // -1050 cents
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### `Money.add(other: Money): Money`
|
|
52
|
+
Add another Money value (same currency only). Throws `CurrencyMismatch` on currency mismatch.
|
|
53
|
+
|
|
54
|
+
### `Money.subtract(other: Money): Money`
|
|
55
|
+
Subtract another Money value (same currency only).
|
|
56
|
+
|
|
57
|
+
### `Money.multiply(factor: number, rounding?: Rounding): Money`
|
|
58
|
+
Multiply by a factor with rounding. Default: `"half-even"` (banker's rounding).
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
money(1000, "USD").multiply(1.0825) // Apply 8.25% tax
|
|
62
|
+
money(100, "USD").multiply(1.5, "half-up") // Force ties up
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### `Money.allocate(ratios: readonly number[]): Money[]`
|
|
66
|
+
Distribute by ratios using largest-remainder method. Parts sum exactly to the original amount.
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
money(100, "USD").allocate([3, 2]) // [$60, $40] (not $59.99/$40.01)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### `Money.format(locale?: string): string`
|
|
73
|
+
Format using `Intl.NumberFormat`.
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
money(1999, "USD").format() // "$19.99"
|
|
77
|
+
money(123456, "JPY").format("ja-JP") // "¥123,456" (no decimals)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### `Money.compare(other: Money): -1 | 0 | 1`
|
|
81
|
+
Compare with another Money. Throws `CurrencyMismatch` on currency mismatch.
|
|
82
|
+
|
|
83
|
+
### `Money.equals(other: Money): boolean`
|
|
84
|
+
Check equality.
|
|
85
|
+
|
|
86
|
+
### `Money.isZero()`, `Money.isNegative()`, `Money.isPositive()`
|
|
87
|
+
Check amount sign.
|
|
88
|
+
|
|
89
|
+
### `Money.toJSON(): { amount: number; currency: string }`
|
|
90
|
+
Serialize to plain object.
|
|
91
|
+
|
|
92
|
+
### Type: `Rounding`
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
"half-up" | "half-even" | "down" | "up"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Class: `CurrencyMismatch`
|
|
99
|
+
|
|
100
|
+
Error thrown when operations mix different currencies.
|
|
101
|
+
|
|
102
|
+
## Non-Goals
|
|
103
|
+
|
|
104
|
+
Use dinero.js or decimal.js for:
|
|
105
|
+
- Arbitrary precision / values beyond `Number.MAX_SAFE_INTEGER`
|
|
106
|
+
- Multi-currency conversion / forex
|
|
107
|
+
- Historical rates
|
|
108
|
+
- Crypto amounts with 18 decimals
|
|
109
|
+
|
|
110
|
+
## TypeScript Note
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
import { money, fromDecimal } from "@azghr/specie";
|
|
114
|
+
|
|
115
|
+
// Full type safety
|
|
116
|
+
const price: Money = money(1999, "USD");
|
|
117
|
+
const total: Money = price.add(money(500, "USD"));
|
|
118
|
+
|
|
119
|
+
// Currency mismatch caught at compile time
|
|
120
|
+
const usd = money(100, "USD");
|
|
121
|
+
const eur = money(100, "EUR");
|
|
122
|
+
// usd.add(eur) // TypeScript: this works, runtime throws CurrencyMismatch
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
CurrencyMismatch: () => CurrencyMismatch,
|
|
24
|
+
default: () => money,
|
|
25
|
+
fromDecimal: () => fromDecimal,
|
|
26
|
+
money: () => money
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(index_exports);
|
|
29
|
+
|
|
30
|
+
// src/format.ts
|
|
31
|
+
var FRACTION_DIGITS = {
|
|
32
|
+
JPY: 0,
|
|
33
|
+
USD: 2,
|
|
34
|
+
EUR: 2,
|
|
35
|
+
GBP: 2,
|
|
36
|
+
CAD: 2,
|
|
37
|
+
AUD: 2,
|
|
38
|
+
CHF: 2,
|
|
39
|
+
CNY: 2,
|
|
40
|
+
INR: 2
|
|
41
|
+
};
|
|
42
|
+
function getCurrencyFractionDigits(currency) {
|
|
43
|
+
try {
|
|
44
|
+
const formatter = new Intl.NumberFormat("en-US", {
|
|
45
|
+
style: "currency",
|
|
46
|
+
currency
|
|
47
|
+
});
|
|
48
|
+
const resolved = formatter.resolvedOptions();
|
|
49
|
+
if (resolved.fractionDigits !== void 0) {
|
|
50
|
+
return resolved.fractionDigits;
|
|
51
|
+
}
|
|
52
|
+
if (resolved.minimumFractionDigits !== void 0) {
|
|
53
|
+
return resolved.minimumFractionDigits;
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
}
|
|
57
|
+
return FRACTION_DIGITS[currency] ?? 2;
|
|
58
|
+
}
|
|
59
|
+
function formatMoney(amount, currency, locale = "en-US") {
|
|
60
|
+
const fractionDigits = getCurrencyFractionDigits(currency);
|
|
61
|
+
const divisor = Math.pow(10, fractionDigits);
|
|
62
|
+
const majorAmount = amount / divisor;
|
|
63
|
+
const formatter = new Intl.NumberFormat(locale, {
|
|
64
|
+
style: "currency",
|
|
65
|
+
currency,
|
|
66
|
+
minimumFractionDigits: fractionDigits,
|
|
67
|
+
maximumFractionDigits: fractionDigits
|
|
68
|
+
});
|
|
69
|
+
return formatter.format(majorAmount);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/allocate.ts
|
|
73
|
+
function allocate(totalAmount, ratios) {
|
|
74
|
+
if (ratios.length === 0) {
|
|
75
|
+
throw new RangeError("Cannot allocate with empty ratios");
|
|
76
|
+
}
|
|
77
|
+
const totalRatio = ratios.reduce((sum, ratio) => sum + ratio, 0);
|
|
78
|
+
if (totalRatio <= 0) {
|
|
79
|
+
throw new RangeError("Ratios must sum to a positive value");
|
|
80
|
+
}
|
|
81
|
+
const allocations = [];
|
|
82
|
+
const remainders = [];
|
|
83
|
+
let distributed = 0;
|
|
84
|
+
for (let i = 0; i < ratios.length; i++) {
|
|
85
|
+
const ratio = ratios[i];
|
|
86
|
+
if (ratio === void 0) continue;
|
|
87
|
+
const exact = totalAmount * ratio / totalRatio;
|
|
88
|
+
const floored = Math.floor(exact);
|
|
89
|
+
allocations.push(floored);
|
|
90
|
+
remainders.push({ index: i, remainder: exact - floored });
|
|
91
|
+
distributed += floored;
|
|
92
|
+
}
|
|
93
|
+
const remainder = totalAmount - distributed;
|
|
94
|
+
const sortedByRemainder = remainders.map((r, index) => ({ ...r, originalIndex: index })).sort((a, b) => {
|
|
95
|
+
if (b.remainder !== a.remainder) {
|
|
96
|
+
return b.remainder - a.remainder;
|
|
97
|
+
}
|
|
98
|
+
return a.originalIndex - b.originalIndex;
|
|
99
|
+
});
|
|
100
|
+
for (let i = 0; i < remainder && i < sortedByRemainder.length; i++) {
|
|
101
|
+
const item = sortedByRemainder[i];
|
|
102
|
+
if (item) {
|
|
103
|
+
const idx = item.originalIndex;
|
|
104
|
+
allocations[idx] = (allocations[idx] ?? 0) + 1;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return allocations;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// src/errors.ts
|
|
111
|
+
var CurrencyMismatch = class extends Error {
|
|
112
|
+
constructor() {
|
|
113
|
+
super("Currency mismatch: operations require matching currencies");
|
|
114
|
+
this.name = "CurrencyMismatch";
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// src/money.ts
|
|
119
|
+
var MoneyImpl = class _MoneyImpl {
|
|
120
|
+
constructor(amount, currency) {
|
|
121
|
+
this.amount = amount;
|
|
122
|
+
this.currency = currency;
|
|
123
|
+
if (!Number.isSafeInteger(amount)) {
|
|
124
|
+
throw new RangeError(
|
|
125
|
+
`Amount must be a safe integer, got: ${String(amount)}`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
amount;
|
|
130
|
+
currency;
|
|
131
|
+
add(other) {
|
|
132
|
+
this.ensureSameCurrency(other);
|
|
133
|
+
return new _MoneyImpl(this.amount + other.amount, this.currency);
|
|
134
|
+
}
|
|
135
|
+
subtract(other) {
|
|
136
|
+
this.ensureSameCurrency(other);
|
|
137
|
+
return new _MoneyImpl(this.amount - other.amount, this.currency);
|
|
138
|
+
}
|
|
139
|
+
multiply(factor, rounding = "half-even") {
|
|
140
|
+
const exact = this.amount * factor;
|
|
141
|
+
const rounded = this.applyRounding(exact, rounding);
|
|
142
|
+
if (rounded > Number.MAX_SAFE_INTEGER) {
|
|
143
|
+
return new _MoneyImpl(Number.MAX_SAFE_INTEGER, this.currency);
|
|
144
|
+
}
|
|
145
|
+
if (rounded < Number.MIN_SAFE_INTEGER) {
|
|
146
|
+
return new _MoneyImpl(Number.MIN_SAFE_INTEGER, this.currency);
|
|
147
|
+
}
|
|
148
|
+
return new _MoneyImpl(rounded, this.currency);
|
|
149
|
+
}
|
|
150
|
+
allocate(ratios) {
|
|
151
|
+
const allocations = allocate(this.amount, ratios);
|
|
152
|
+
return allocations.map((amount) => new _MoneyImpl(amount, this.currency));
|
|
153
|
+
}
|
|
154
|
+
compare(other) {
|
|
155
|
+
this.ensureSameCurrency(other);
|
|
156
|
+
if (this.amount < other.amount) return -1;
|
|
157
|
+
if (this.amount > other.amount) return 1;
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
equals(other) {
|
|
161
|
+
return this.compare(other) === 0;
|
|
162
|
+
}
|
|
163
|
+
isZero() {
|
|
164
|
+
return this.amount === 0;
|
|
165
|
+
}
|
|
166
|
+
isNegative() {
|
|
167
|
+
return this.amount < 0;
|
|
168
|
+
}
|
|
169
|
+
isPositive() {
|
|
170
|
+
return this.amount > 0;
|
|
171
|
+
}
|
|
172
|
+
format(locale = "en-US") {
|
|
173
|
+
return formatMoney(this.amount, this.currency, locale);
|
|
174
|
+
}
|
|
175
|
+
toJSON() {
|
|
176
|
+
return { amount: this.amount, currency: this.currency };
|
|
177
|
+
}
|
|
178
|
+
ensureSameCurrency(other) {
|
|
179
|
+
if (this.currency !== other.currency) {
|
|
180
|
+
throw new CurrencyMismatch();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
applyRounding(value, mode) {
|
|
184
|
+
if (Number.isSafeInteger(value)) {
|
|
185
|
+
return value;
|
|
186
|
+
}
|
|
187
|
+
switch (mode) {
|
|
188
|
+
case "half-up": {
|
|
189
|
+
const fractional = value - Math.floor(value);
|
|
190
|
+
if (fractional === 0.5) {
|
|
191
|
+
return Math.ceil(value);
|
|
192
|
+
}
|
|
193
|
+
return Math.round(value);
|
|
194
|
+
}
|
|
195
|
+
case "half-even": {
|
|
196
|
+
const rounded = Math.round(value);
|
|
197
|
+
const isHalf = Math.abs(value - rounded) === 0.5;
|
|
198
|
+
if (isHalf) {
|
|
199
|
+
return Math.round(value / 2) * 2;
|
|
200
|
+
}
|
|
201
|
+
return rounded;
|
|
202
|
+
}
|
|
203
|
+
case "down":
|
|
204
|
+
return Math.floor(value);
|
|
205
|
+
case "up":
|
|
206
|
+
return Math.ceil(value);
|
|
207
|
+
default:
|
|
208
|
+
return Math.round(value);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
function money(amount, currency) {
|
|
213
|
+
return new MoneyImpl(amount, currency);
|
|
214
|
+
}
|
|
215
|
+
function fromDecimal(value, currency) {
|
|
216
|
+
const fractionDigits = getCurrencyFractionDigits(currency);
|
|
217
|
+
const parts = value.split(".");
|
|
218
|
+
if (parts.length > 2) {
|
|
219
|
+
throw new RangeError(`Invalid decimal format: ${value}`);
|
|
220
|
+
}
|
|
221
|
+
const integerPart = parts[0] || "0";
|
|
222
|
+
const fractionalPart = parts[1] || "";
|
|
223
|
+
const isNegative = integerPart.startsWith("-");
|
|
224
|
+
const absIntegerPart = isNegative ? integerPart.slice(1) : integerPart;
|
|
225
|
+
if (absIntegerPart === "0" && (fractionalPart === "" || fractionalPart === "0")) {
|
|
226
|
+
return money(0, currency);
|
|
227
|
+
}
|
|
228
|
+
if (fractionalPart.length > fractionDigits) {
|
|
229
|
+
const hasExtraDigits = fractionalPart.slice(fractionDigits).split("").some((d) => d !== "0");
|
|
230
|
+
if (hasExtraDigits) {
|
|
231
|
+
throw new RangeError(
|
|
232
|
+
`Too many fraction digits for ${currency}: ${value} (max ${String(fractionDigits)})`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const majorUnits = parseInt(absIntegerPart, 10);
|
|
237
|
+
let minorUnits = 0;
|
|
238
|
+
if (fractionalPart.length > 0) {
|
|
239
|
+
const padded = fractionalPart.padEnd(fractionDigits, "0");
|
|
240
|
+
minorUnits = parseInt(padded, 10);
|
|
241
|
+
}
|
|
242
|
+
const totalAmount = majorUnits * Math.pow(10, fractionDigits) + minorUnits;
|
|
243
|
+
return money(isNegative ? -totalAmount : totalAmount, currency);
|
|
244
|
+
}
|
|
245
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
246
|
+
0 && (module.exports = {
|
|
247
|
+
CurrencyMismatch,
|
|
248
|
+
fromDecimal,
|
|
249
|
+
money
|
|
250
|
+
});
|
|
251
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/format.ts","../src/allocate.ts","../src/errors.ts","../src/money.ts"],"sourcesContent":["/**\n * specie - Tiny integer-cents money type\n *\n * Safe arithmetic, currency-aware rounding, Intl formatting.\n * No bignum, no dependencies.\n */\n\n// Re-export everything from money.ts (main module)\nexport {\n money,\n fromDecimal,\n type Money,\n type Rounding,\n CurrencyMismatch,\n} from \"./money.js\";\n\n// Default export\nexport { money as default } from \"./money.js\";\n","/**\n * Currency fraction digits fallback table for common currencies.\n * Used when Intl.NumberFormat is not available or returns unexpected results.\n */\nconst FRACTION_DIGITS: Record<string, number> = {\n JPY: 0,\n USD: 2,\n EUR: 2,\n GBP: 2,\n CAD: 2,\n AUD: 2,\n CHF: 2,\n CNY: 2,\n INR: 2,\n};\n\n/**\n * Get the number of fraction digits for a currency code.\n * Uses Intl.NumberFormat if available, with fallback table.\n *\n * @param currency - ISO 4217 currency code\n * @returns Number of fraction digits (usually 0 or 2)\n */\nexport function getCurrencyFractionDigits(currency: string): number {\n try {\n // Try to get from Intl\n const formatter = new Intl.NumberFormat(\"en-US\", {\n style: \"currency\",\n currency,\n });\n\n const resolved =\n formatter.resolvedOptions() as Intl.ResolvedNumberFormatOptions & {\n fractionDigits?: number;\n minimumFractionDigits?: number;\n maximumFractionDigits?: number;\n };\n\n if (resolved.fractionDigits !== undefined) {\n return resolved.fractionDigits;\n }\n\n // Use minimum/maximum as fallback\n if (resolved.minimumFractionDigits !== undefined) {\n return resolved.minimumFractionDigits;\n }\n } catch {\n // Fall through to fallback table\n }\n\n // Use fallback table\n return FRACTION_DIGITS[currency] ?? 2;\n}\n\n/**\n * Format a money amount using Intl.NumberFormat.\n *\n * @param amount - Amount in minor units (e.g. cents)\n * @param currency - ISO 4217 currency code\n * @param locale - Locale string (default: \"en-US\")\n * @returns Formatted currency string\n */\nexport function formatMoney(\n amount: number,\n currency: string,\n locale: string = \"en-US\",\n): string {\n const fractionDigits = getCurrencyFractionDigits(currency);\n const divisor = Math.pow(10, fractionDigits);\n const majorAmount = amount / divisor;\n\n const formatter = new Intl.NumberFormat(locale, {\n style: \"currency\",\n currency,\n minimumFractionDigits: fractionDigits,\n maximumFractionDigits: fractionDigits,\n });\n\n return formatter.format(majorAmount);\n}\n","/**\n * Largest-remainder method for allocating money amounts by ratios.\n * Ensures that parts sum exactly to the original amount with no penny lost.\n *\n * @param totalAmount - Total amount in minor units to allocate\n * @param ratios - Array of ratios for allocation (must sum > 0)\n * @returns Array of allocated amounts that sum to totalAmount\n * @throws RangeError if ratios don't sum to positive value\n */\nexport function allocate(\n totalAmount: number,\n ratios: readonly number[],\n): number[] {\n if (ratios.length === 0) {\n throw new RangeError(\"Cannot allocate with empty ratios\");\n }\n\n const totalRatio = ratios.reduce((sum, ratio) => sum + ratio, 0);\n if (totalRatio <= 0) {\n throw new RangeError(\"Ratios must sum to a positive value\");\n }\n\n // Calculate initial floored allocations and remainders\n const allocations: number[] = [];\n const remainders: Array<{ index: number; remainder: number }> = [];\n let distributed = 0;\n\n for (let i = 0; i < ratios.length; i++) {\n const ratio = ratios[i];\n if (ratio === undefined) continue;\n\n const exact = (totalAmount * ratio) / totalRatio;\n const floored = Math.floor(exact);\n allocations.push(floored);\n remainders.push({ index: i, remainder: exact - floored });\n distributed += floored;\n }\n\n // Distribute remaining units to items with largest remainders\n const remainder = totalAmount - distributed;\n\n // Sort by remainder (descending) and give extra to items with largest remainders\n // Earlier items get priority when remainders are equal\n const sortedByRemainder = remainders\n .map((r, index) => ({ ...r, originalIndex: index }))\n .sort((a, b) => {\n if (b.remainder !== a.remainder) {\n return b.remainder - a.remainder; // Larger remainder first\n }\n return a.originalIndex - b.originalIndex; // Earlier index first for ties\n });\n\n for (let i = 0; i < remainder && i < sortedByRemainder.length; i++) {\n const item = sortedByRemainder[i];\n if (item) {\n const idx = item.originalIndex;\n allocations[idx] = (allocations[idx] ?? 0) + 1;\n }\n }\n\n return allocations;\n}\n","/**\n * Error thrown when operations are attempted on Money objects with different currencies.\n */\nexport class CurrencyMismatch extends Error {\n constructor() {\n super(\"Currency mismatch: operations require matching currencies\");\n this.name = \"CurrencyMismatch\";\n }\n}\n","import { formatMoney, getCurrencyFractionDigits } from \"./format.js\";\nimport { allocate } from \"./allocate.js\";\nimport { CurrencyMismatch } from \"./errors.js\";\n\n/**\n * Rounding modes for multiply operations.\n */\nexport type Rounding = \"half-up\" | \"half-even\" | \"down\" | \"up\";\n\n/**\n * Money value object - immutable representation of an amount in a specific currency.\n * Uses integer minor units (e.g. cents) to avoid floating-point rounding errors.\n */\nexport interface Money {\n /** Amount in minor units (e.g. cents for USD) - must be a safe integer */\n readonly amount: number;\n /** ISO 4217 currency code */\n readonly currency: string;\n\n /**\n * Add another Money value (same currency only).\n * @throws CurrencyMismatch if currencies differ\n */\n add(other: Money): Money;\n\n /**\n * Subtract another Money value (same currency only).\n * @throws CurrencyMismatch if currencies differ\n */\n subtract(other: Money): Money;\n\n /**\n * Multiply by a factor with optional rounding mode.\n * @param factor - Multiplication factor (can be fractional, e.g. 1.0825 for 8.25% tax)\n * @param rounding - Rounding mode (default: \"half-even\")\n */\n multiply(factor: number, rounding?: Rounding): Money;\n\n /**\n * Allocate amount by integer ratios using largest-remainder method.\n * Parts always sum exactly to the original amount.\n * @param ratios - Array of ratios (must sum > 0)\n * @throws RangeError if ratios don't sum to positive value\n */\n allocate(ratios: readonly number[]): Money[];\n\n /**\n * Compare with another Money value (same currency only).\n * @returns -1 if less, 0 if equal, 1 if greater\n * @throws CurrencyMismatch if currencies differ\n */\n compare(other: Money): -1 | 0 | 1;\n\n /**\n * Check equality with another Money value (same currency only).\n * @throws CurrencyMismatch if currencies differ\n */\n equals(other: Money): boolean;\n\n /** Check if amount is zero */\n isZero(): boolean;\n /** Check if amount is negative */\n isNegative(): boolean;\n /** Check if amount is positive */\n isPositive(): boolean;\n\n /**\n * Format using Intl.NumberFormat.\n * @param locale - Locale string (default: \"en-US\")\n */\n format(locale?: string): string;\n\n /** Serialize to plain object */\n toJSON(): { amount: number; currency: string };\n}\n\n/**\n * Create a Money implementation class.\n */\nclass MoneyImpl implements Money {\n constructor(\n public readonly amount: number,\n public readonly currency: string,\n ) {\n // Validate safe integer\n if (!Number.isSafeInteger(amount)) {\n throw new RangeError(\n `Amount must be a safe integer, got: ${String(amount)}`,\n );\n }\n }\n\n add(other: Money): Money {\n this.ensureSameCurrency(other);\n return new MoneyImpl(this.amount + other.amount, this.currency);\n }\n\n subtract(other: Money): Money {\n this.ensureSameCurrency(other);\n return new MoneyImpl(this.amount - other.amount, this.currency);\n }\n\n multiply(factor: number, rounding: Rounding = \"half-even\"): Money {\n const exact = this.amount * factor;\n const rounded = this.applyRounding(exact, rounding);\n\n // Check if result exceeds safe integer range before capping\n if (rounded > Number.MAX_SAFE_INTEGER) {\n return new MoneyImpl(Number.MAX_SAFE_INTEGER, this.currency);\n }\n if (rounded < Number.MIN_SAFE_INTEGER) {\n return new MoneyImpl(Number.MIN_SAFE_INTEGER, this.currency);\n }\n\n return new MoneyImpl(rounded, this.currency);\n }\n\n allocate(ratios: readonly number[]): Money[] {\n const allocations = allocate(this.amount, ratios);\n return allocations.map((amount) => new MoneyImpl(amount, this.currency));\n }\n\n compare(other: Money): -1 | 0 | 1 {\n this.ensureSameCurrency(other);\n if (this.amount < other.amount) return -1;\n if (this.amount > other.amount) return 1;\n return 0;\n }\n\n equals(other: Money): boolean {\n return this.compare(other) === 0;\n }\n\n isZero(): boolean {\n return this.amount === 0;\n }\n\n isNegative(): boolean {\n return this.amount < 0;\n }\n\n isPositive(): boolean {\n return this.amount > 0;\n }\n\n format(locale: string = \"en-US\"): string {\n return formatMoney(this.amount, this.currency, locale);\n }\n\n toJSON(): { amount: number; currency: string } {\n return { amount: this.amount, currency: this.currency };\n }\n\n private ensureSameCurrency(other: Money): void {\n if (this.currency !== other.currency) {\n throw new CurrencyMismatch();\n }\n }\n\n private applyRounding(value: number, mode: Rounding): number {\n if (Number.isSafeInteger(value)) {\n return value;\n }\n\n switch (mode) {\n case \"half-up\": {\n // Round to nearest, with ties going up\n const fractional = value - Math.floor(value);\n if (fractional === 0.5) {\n return Math.ceil(value);\n }\n return Math.round(value);\n }\n case \"half-even\": {\n // Banker's rounding: round to nearest even integer\n const rounded = Math.round(value);\n const isHalf = Math.abs(value - rounded) === 0.5;\n if (isHalf) {\n // Round to nearest even\n return Math.round(value / 2) * 2;\n }\n return rounded;\n }\n case \"down\":\n return Math.floor(value);\n case \"up\":\n return Math.ceil(value);\n default:\n return Math.round(value);\n }\n }\n}\n\n/**\n * Factory function to create Money objects.\n *\n * @param amount - Amount in minor units (e.g. cents for USD)\n * @param currency - ISO 4217 currency code\n * @throws RangeError if amount is not a safe integer\n */\nexport function money(amount: number, currency: string): Money {\n return new MoneyImpl(amount, currency);\n}\n\n/**\n * Create Money from a decimal string representation.\n * Parses a string like \"19.99\" to 1999 cents for USD.\n * Never uses float parsing to avoid reintroducing float errors.\n *\n * @param value - Decimal string (e.g. \"19.99\")\n * @param currency - ISO 4217 currency code\n * @throws RangeError if value has more fraction digits than currency allows\n */\nexport function fromDecimal(value: string, currency: string): Money {\n // Get fraction digits for currency (FIX: use the proper function)\n const fractionDigits = getCurrencyFractionDigits(currency);\n\n // Parse the string\n const parts = value.split(\".\");\n if (parts.length > 2) {\n throw new RangeError(`Invalid decimal format: ${value}`);\n }\n\n const integerPart = parts[0] || \"0\";\n const fractionalPart = parts[1] || \"\";\n\n // Handle negative numbers\n const isNegative = integerPart.startsWith(\"-\");\n const absIntegerPart = isNegative ? integerPart.slice(1) : integerPart;\n\n // Handle special case: \"-0\" or \"-0.\" should be 0\n if (\n absIntegerPart === \"0\" &&\n (fractionalPart === \"\" || fractionalPart === \"0\")\n ) {\n return money(0, currency);\n }\n\n // Validate fraction digits\n if (fractionalPart.length > fractionDigits) {\n // Check if it can be cleanly rounded\n const hasExtraDigits = fractionalPart\n .slice(fractionDigits)\n .split(\"\")\n .some((d) => d !== \"0\");\n if (hasExtraDigits) {\n throw new RangeError(\n `Too many fraction digits for ${currency}: ${value} (max ${String(fractionDigits)})`,\n );\n }\n }\n\n // Convert to integer minor units\n const majorUnits = parseInt(absIntegerPart, 10);\n let minorUnits = 0;\n\n if (fractionalPart.length > 0) {\n const padded = fractionalPart.padEnd(fractionDigits, \"0\");\n minorUnits = parseInt(padded, 10);\n }\n\n const totalAmount = majorUnits * Math.pow(10, fractionDigits) + minorUnits;\n return money(isNegative ? -totalAmount : totalAmount, currency);\n}\n\n// Re-export CurrencyMismatch for convenience\nexport { CurrencyMismatch };\n"],"mappings":"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","names":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error thrown when operations are attempted on Money objects with different currencies.
|
|
3
|
+
*/
|
|
4
|
+
declare class CurrencyMismatch extends Error {
|
|
5
|
+
constructor();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Rounding modes for multiply operations.
|
|
10
|
+
*/
|
|
11
|
+
type Rounding = "half-up" | "half-even" | "down" | "up";
|
|
12
|
+
/**
|
|
13
|
+
* Money value object - immutable representation of an amount in a specific currency.
|
|
14
|
+
* Uses integer minor units (e.g. cents) to avoid floating-point rounding errors.
|
|
15
|
+
*/
|
|
16
|
+
interface Money {
|
|
17
|
+
/** Amount in minor units (e.g. cents for USD) - must be a safe integer */
|
|
18
|
+
readonly amount: number;
|
|
19
|
+
/** ISO 4217 currency code */
|
|
20
|
+
readonly currency: string;
|
|
21
|
+
/**
|
|
22
|
+
* Add another Money value (same currency only).
|
|
23
|
+
* @throws CurrencyMismatch if currencies differ
|
|
24
|
+
*/
|
|
25
|
+
add(other: Money): Money;
|
|
26
|
+
/**
|
|
27
|
+
* Subtract another Money value (same currency only).
|
|
28
|
+
* @throws CurrencyMismatch if currencies differ
|
|
29
|
+
*/
|
|
30
|
+
subtract(other: Money): Money;
|
|
31
|
+
/**
|
|
32
|
+
* Multiply by a factor with optional rounding mode.
|
|
33
|
+
* @param factor - Multiplication factor (can be fractional, e.g. 1.0825 for 8.25% tax)
|
|
34
|
+
* @param rounding - Rounding mode (default: "half-even")
|
|
35
|
+
*/
|
|
36
|
+
multiply(factor: number, rounding?: Rounding): Money;
|
|
37
|
+
/**
|
|
38
|
+
* Allocate amount by integer ratios using largest-remainder method.
|
|
39
|
+
* Parts always sum exactly to the original amount.
|
|
40
|
+
* @param ratios - Array of ratios (must sum > 0)
|
|
41
|
+
* @throws RangeError if ratios don't sum to positive value
|
|
42
|
+
*/
|
|
43
|
+
allocate(ratios: readonly number[]): Money[];
|
|
44
|
+
/**
|
|
45
|
+
* Compare with another Money value (same currency only).
|
|
46
|
+
* @returns -1 if less, 0 if equal, 1 if greater
|
|
47
|
+
* @throws CurrencyMismatch if currencies differ
|
|
48
|
+
*/
|
|
49
|
+
compare(other: Money): -1 | 0 | 1;
|
|
50
|
+
/**
|
|
51
|
+
* Check equality with another Money value (same currency only).
|
|
52
|
+
* @throws CurrencyMismatch if currencies differ
|
|
53
|
+
*/
|
|
54
|
+
equals(other: Money): boolean;
|
|
55
|
+
/** Check if amount is zero */
|
|
56
|
+
isZero(): boolean;
|
|
57
|
+
/** Check if amount is negative */
|
|
58
|
+
isNegative(): boolean;
|
|
59
|
+
/** Check if amount is positive */
|
|
60
|
+
isPositive(): boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Format using Intl.NumberFormat.
|
|
63
|
+
* @param locale - Locale string (default: "en-US")
|
|
64
|
+
*/
|
|
65
|
+
format(locale?: string): string;
|
|
66
|
+
/** Serialize to plain object */
|
|
67
|
+
toJSON(): {
|
|
68
|
+
amount: number;
|
|
69
|
+
currency: string;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Factory function to create Money objects.
|
|
74
|
+
*
|
|
75
|
+
* @param amount - Amount in minor units (e.g. cents for USD)
|
|
76
|
+
* @param currency - ISO 4217 currency code
|
|
77
|
+
* @throws RangeError if amount is not a safe integer
|
|
78
|
+
*/
|
|
79
|
+
declare function money(amount: number, currency: string): Money;
|
|
80
|
+
/**
|
|
81
|
+
* Create Money from a decimal string representation.
|
|
82
|
+
* Parses a string like "19.99" to 1999 cents for USD.
|
|
83
|
+
* Never uses float parsing to avoid reintroducing float errors.
|
|
84
|
+
*
|
|
85
|
+
* @param value - Decimal string (e.g. "19.99")
|
|
86
|
+
* @param currency - ISO 4217 currency code
|
|
87
|
+
* @throws RangeError if value has more fraction digits than currency allows
|
|
88
|
+
*/
|
|
89
|
+
declare function fromDecimal(value: string, currency: string): Money;
|
|
90
|
+
|
|
91
|
+
export { CurrencyMismatch, type Money, type Rounding, money as default, fromDecimal, money };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error thrown when operations are attempted on Money objects with different currencies.
|
|
3
|
+
*/
|
|
4
|
+
declare class CurrencyMismatch extends Error {
|
|
5
|
+
constructor();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Rounding modes for multiply operations.
|
|
10
|
+
*/
|
|
11
|
+
type Rounding = "half-up" | "half-even" | "down" | "up";
|
|
12
|
+
/**
|
|
13
|
+
* Money value object - immutable representation of an amount in a specific currency.
|
|
14
|
+
* Uses integer minor units (e.g. cents) to avoid floating-point rounding errors.
|
|
15
|
+
*/
|
|
16
|
+
interface Money {
|
|
17
|
+
/** Amount in minor units (e.g. cents for USD) - must be a safe integer */
|
|
18
|
+
readonly amount: number;
|
|
19
|
+
/** ISO 4217 currency code */
|
|
20
|
+
readonly currency: string;
|
|
21
|
+
/**
|
|
22
|
+
* Add another Money value (same currency only).
|
|
23
|
+
* @throws CurrencyMismatch if currencies differ
|
|
24
|
+
*/
|
|
25
|
+
add(other: Money): Money;
|
|
26
|
+
/**
|
|
27
|
+
* Subtract another Money value (same currency only).
|
|
28
|
+
* @throws CurrencyMismatch if currencies differ
|
|
29
|
+
*/
|
|
30
|
+
subtract(other: Money): Money;
|
|
31
|
+
/**
|
|
32
|
+
* Multiply by a factor with optional rounding mode.
|
|
33
|
+
* @param factor - Multiplication factor (can be fractional, e.g. 1.0825 for 8.25% tax)
|
|
34
|
+
* @param rounding - Rounding mode (default: "half-even")
|
|
35
|
+
*/
|
|
36
|
+
multiply(factor: number, rounding?: Rounding): Money;
|
|
37
|
+
/**
|
|
38
|
+
* Allocate amount by integer ratios using largest-remainder method.
|
|
39
|
+
* Parts always sum exactly to the original amount.
|
|
40
|
+
* @param ratios - Array of ratios (must sum > 0)
|
|
41
|
+
* @throws RangeError if ratios don't sum to positive value
|
|
42
|
+
*/
|
|
43
|
+
allocate(ratios: readonly number[]): Money[];
|
|
44
|
+
/**
|
|
45
|
+
* Compare with another Money value (same currency only).
|
|
46
|
+
* @returns -1 if less, 0 if equal, 1 if greater
|
|
47
|
+
* @throws CurrencyMismatch if currencies differ
|
|
48
|
+
*/
|
|
49
|
+
compare(other: Money): -1 | 0 | 1;
|
|
50
|
+
/**
|
|
51
|
+
* Check equality with another Money value (same currency only).
|
|
52
|
+
* @throws CurrencyMismatch if currencies differ
|
|
53
|
+
*/
|
|
54
|
+
equals(other: Money): boolean;
|
|
55
|
+
/** Check if amount is zero */
|
|
56
|
+
isZero(): boolean;
|
|
57
|
+
/** Check if amount is negative */
|
|
58
|
+
isNegative(): boolean;
|
|
59
|
+
/** Check if amount is positive */
|
|
60
|
+
isPositive(): boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Format using Intl.NumberFormat.
|
|
63
|
+
* @param locale - Locale string (default: "en-US")
|
|
64
|
+
*/
|
|
65
|
+
format(locale?: string): string;
|
|
66
|
+
/** Serialize to plain object */
|
|
67
|
+
toJSON(): {
|
|
68
|
+
amount: number;
|
|
69
|
+
currency: string;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Factory function to create Money objects.
|
|
74
|
+
*
|
|
75
|
+
* @param amount - Amount in minor units (e.g. cents for USD)
|
|
76
|
+
* @param currency - ISO 4217 currency code
|
|
77
|
+
* @throws RangeError if amount is not a safe integer
|
|
78
|
+
*/
|
|
79
|
+
declare function money(amount: number, currency: string): Money;
|
|
80
|
+
/**
|
|
81
|
+
* Create Money from a decimal string representation.
|
|
82
|
+
* Parses a string like "19.99" to 1999 cents for USD.
|
|
83
|
+
* Never uses float parsing to avoid reintroducing float errors.
|
|
84
|
+
*
|
|
85
|
+
* @param value - Decimal string (e.g. "19.99")
|
|
86
|
+
* @param currency - ISO 4217 currency code
|
|
87
|
+
* @throws RangeError if value has more fraction digits than currency allows
|
|
88
|
+
*/
|
|
89
|
+
declare function fromDecimal(value: string, currency: string): Money;
|
|
90
|
+
|
|
91
|
+
export { CurrencyMismatch, type Money, type Rounding, money as default, fromDecimal, money };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// src/format.ts
|
|
2
|
+
var FRACTION_DIGITS = {
|
|
3
|
+
JPY: 0,
|
|
4
|
+
USD: 2,
|
|
5
|
+
EUR: 2,
|
|
6
|
+
GBP: 2,
|
|
7
|
+
CAD: 2,
|
|
8
|
+
AUD: 2,
|
|
9
|
+
CHF: 2,
|
|
10
|
+
CNY: 2,
|
|
11
|
+
INR: 2
|
|
12
|
+
};
|
|
13
|
+
function getCurrencyFractionDigits(currency) {
|
|
14
|
+
try {
|
|
15
|
+
const formatter = new Intl.NumberFormat("en-US", {
|
|
16
|
+
style: "currency",
|
|
17
|
+
currency
|
|
18
|
+
});
|
|
19
|
+
const resolved = formatter.resolvedOptions();
|
|
20
|
+
if (resolved.fractionDigits !== void 0) {
|
|
21
|
+
return resolved.fractionDigits;
|
|
22
|
+
}
|
|
23
|
+
if (resolved.minimumFractionDigits !== void 0) {
|
|
24
|
+
return resolved.minimumFractionDigits;
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
}
|
|
28
|
+
return FRACTION_DIGITS[currency] ?? 2;
|
|
29
|
+
}
|
|
30
|
+
function formatMoney(amount, currency, locale = "en-US") {
|
|
31
|
+
const fractionDigits = getCurrencyFractionDigits(currency);
|
|
32
|
+
const divisor = Math.pow(10, fractionDigits);
|
|
33
|
+
const majorAmount = amount / divisor;
|
|
34
|
+
const formatter = new Intl.NumberFormat(locale, {
|
|
35
|
+
style: "currency",
|
|
36
|
+
currency,
|
|
37
|
+
minimumFractionDigits: fractionDigits,
|
|
38
|
+
maximumFractionDigits: fractionDigits
|
|
39
|
+
});
|
|
40
|
+
return formatter.format(majorAmount);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/allocate.ts
|
|
44
|
+
function allocate(totalAmount, ratios) {
|
|
45
|
+
if (ratios.length === 0) {
|
|
46
|
+
throw new RangeError("Cannot allocate with empty ratios");
|
|
47
|
+
}
|
|
48
|
+
const totalRatio = ratios.reduce((sum, ratio) => sum + ratio, 0);
|
|
49
|
+
if (totalRatio <= 0) {
|
|
50
|
+
throw new RangeError("Ratios must sum to a positive value");
|
|
51
|
+
}
|
|
52
|
+
const allocations = [];
|
|
53
|
+
const remainders = [];
|
|
54
|
+
let distributed = 0;
|
|
55
|
+
for (let i = 0; i < ratios.length; i++) {
|
|
56
|
+
const ratio = ratios[i];
|
|
57
|
+
if (ratio === void 0) continue;
|
|
58
|
+
const exact = totalAmount * ratio / totalRatio;
|
|
59
|
+
const floored = Math.floor(exact);
|
|
60
|
+
allocations.push(floored);
|
|
61
|
+
remainders.push({ index: i, remainder: exact - floored });
|
|
62
|
+
distributed += floored;
|
|
63
|
+
}
|
|
64
|
+
const remainder = totalAmount - distributed;
|
|
65
|
+
const sortedByRemainder = remainders.map((r, index) => ({ ...r, originalIndex: index })).sort((a, b) => {
|
|
66
|
+
if (b.remainder !== a.remainder) {
|
|
67
|
+
return b.remainder - a.remainder;
|
|
68
|
+
}
|
|
69
|
+
return a.originalIndex - b.originalIndex;
|
|
70
|
+
});
|
|
71
|
+
for (let i = 0; i < remainder && i < sortedByRemainder.length; i++) {
|
|
72
|
+
const item = sortedByRemainder[i];
|
|
73
|
+
if (item) {
|
|
74
|
+
const idx = item.originalIndex;
|
|
75
|
+
allocations[idx] = (allocations[idx] ?? 0) + 1;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return allocations;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// src/errors.ts
|
|
82
|
+
var CurrencyMismatch = class extends Error {
|
|
83
|
+
constructor() {
|
|
84
|
+
super("Currency mismatch: operations require matching currencies");
|
|
85
|
+
this.name = "CurrencyMismatch";
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// src/money.ts
|
|
90
|
+
var MoneyImpl = class _MoneyImpl {
|
|
91
|
+
constructor(amount, currency) {
|
|
92
|
+
this.amount = amount;
|
|
93
|
+
this.currency = currency;
|
|
94
|
+
if (!Number.isSafeInteger(amount)) {
|
|
95
|
+
throw new RangeError(
|
|
96
|
+
`Amount must be a safe integer, got: ${String(amount)}`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
amount;
|
|
101
|
+
currency;
|
|
102
|
+
add(other) {
|
|
103
|
+
this.ensureSameCurrency(other);
|
|
104
|
+
return new _MoneyImpl(this.amount + other.amount, this.currency);
|
|
105
|
+
}
|
|
106
|
+
subtract(other) {
|
|
107
|
+
this.ensureSameCurrency(other);
|
|
108
|
+
return new _MoneyImpl(this.amount - other.amount, this.currency);
|
|
109
|
+
}
|
|
110
|
+
multiply(factor, rounding = "half-even") {
|
|
111
|
+
const exact = this.amount * factor;
|
|
112
|
+
const rounded = this.applyRounding(exact, rounding);
|
|
113
|
+
if (rounded > Number.MAX_SAFE_INTEGER) {
|
|
114
|
+
return new _MoneyImpl(Number.MAX_SAFE_INTEGER, this.currency);
|
|
115
|
+
}
|
|
116
|
+
if (rounded < Number.MIN_SAFE_INTEGER) {
|
|
117
|
+
return new _MoneyImpl(Number.MIN_SAFE_INTEGER, this.currency);
|
|
118
|
+
}
|
|
119
|
+
return new _MoneyImpl(rounded, this.currency);
|
|
120
|
+
}
|
|
121
|
+
allocate(ratios) {
|
|
122
|
+
const allocations = allocate(this.amount, ratios);
|
|
123
|
+
return allocations.map((amount) => new _MoneyImpl(amount, this.currency));
|
|
124
|
+
}
|
|
125
|
+
compare(other) {
|
|
126
|
+
this.ensureSameCurrency(other);
|
|
127
|
+
if (this.amount < other.amount) return -1;
|
|
128
|
+
if (this.amount > other.amount) return 1;
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
equals(other) {
|
|
132
|
+
return this.compare(other) === 0;
|
|
133
|
+
}
|
|
134
|
+
isZero() {
|
|
135
|
+
return this.amount === 0;
|
|
136
|
+
}
|
|
137
|
+
isNegative() {
|
|
138
|
+
return this.amount < 0;
|
|
139
|
+
}
|
|
140
|
+
isPositive() {
|
|
141
|
+
return this.amount > 0;
|
|
142
|
+
}
|
|
143
|
+
format(locale = "en-US") {
|
|
144
|
+
return formatMoney(this.amount, this.currency, locale);
|
|
145
|
+
}
|
|
146
|
+
toJSON() {
|
|
147
|
+
return { amount: this.amount, currency: this.currency };
|
|
148
|
+
}
|
|
149
|
+
ensureSameCurrency(other) {
|
|
150
|
+
if (this.currency !== other.currency) {
|
|
151
|
+
throw new CurrencyMismatch();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
applyRounding(value, mode) {
|
|
155
|
+
if (Number.isSafeInteger(value)) {
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
switch (mode) {
|
|
159
|
+
case "half-up": {
|
|
160
|
+
const fractional = value - Math.floor(value);
|
|
161
|
+
if (fractional === 0.5) {
|
|
162
|
+
return Math.ceil(value);
|
|
163
|
+
}
|
|
164
|
+
return Math.round(value);
|
|
165
|
+
}
|
|
166
|
+
case "half-even": {
|
|
167
|
+
const rounded = Math.round(value);
|
|
168
|
+
const isHalf = Math.abs(value - rounded) === 0.5;
|
|
169
|
+
if (isHalf) {
|
|
170
|
+
return Math.round(value / 2) * 2;
|
|
171
|
+
}
|
|
172
|
+
return rounded;
|
|
173
|
+
}
|
|
174
|
+
case "down":
|
|
175
|
+
return Math.floor(value);
|
|
176
|
+
case "up":
|
|
177
|
+
return Math.ceil(value);
|
|
178
|
+
default:
|
|
179
|
+
return Math.round(value);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
function money(amount, currency) {
|
|
184
|
+
return new MoneyImpl(amount, currency);
|
|
185
|
+
}
|
|
186
|
+
function fromDecimal(value, currency) {
|
|
187
|
+
const fractionDigits = getCurrencyFractionDigits(currency);
|
|
188
|
+
const parts = value.split(".");
|
|
189
|
+
if (parts.length > 2) {
|
|
190
|
+
throw new RangeError(`Invalid decimal format: ${value}`);
|
|
191
|
+
}
|
|
192
|
+
const integerPart = parts[0] || "0";
|
|
193
|
+
const fractionalPart = parts[1] || "";
|
|
194
|
+
const isNegative = integerPart.startsWith("-");
|
|
195
|
+
const absIntegerPart = isNegative ? integerPart.slice(1) : integerPart;
|
|
196
|
+
if (absIntegerPart === "0" && (fractionalPart === "" || fractionalPart === "0")) {
|
|
197
|
+
return money(0, currency);
|
|
198
|
+
}
|
|
199
|
+
if (fractionalPart.length > fractionDigits) {
|
|
200
|
+
const hasExtraDigits = fractionalPart.slice(fractionDigits).split("").some((d) => d !== "0");
|
|
201
|
+
if (hasExtraDigits) {
|
|
202
|
+
throw new RangeError(
|
|
203
|
+
`Too many fraction digits for ${currency}: ${value} (max ${String(fractionDigits)})`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const majorUnits = parseInt(absIntegerPart, 10);
|
|
208
|
+
let minorUnits = 0;
|
|
209
|
+
if (fractionalPart.length > 0) {
|
|
210
|
+
const padded = fractionalPart.padEnd(fractionDigits, "0");
|
|
211
|
+
minorUnits = parseInt(padded, 10);
|
|
212
|
+
}
|
|
213
|
+
const totalAmount = majorUnits * Math.pow(10, fractionDigits) + minorUnits;
|
|
214
|
+
return money(isNegative ? -totalAmount : totalAmount, currency);
|
|
215
|
+
}
|
|
216
|
+
export {
|
|
217
|
+
CurrencyMismatch,
|
|
218
|
+
money as default,
|
|
219
|
+
fromDecimal,
|
|
220
|
+
money
|
|
221
|
+
};
|
|
222
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/format.ts","../src/allocate.ts","../src/errors.ts","../src/money.ts"],"sourcesContent":["/**\n * Currency fraction digits fallback table for common currencies.\n * Used when Intl.NumberFormat is not available or returns unexpected results.\n */\nconst FRACTION_DIGITS: Record<string, number> = {\n JPY: 0,\n USD: 2,\n EUR: 2,\n GBP: 2,\n CAD: 2,\n AUD: 2,\n CHF: 2,\n CNY: 2,\n INR: 2,\n};\n\n/**\n * Get the number of fraction digits for a currency code.\n * Uses Intl.NumberFormat if available, with fallback table.\n *\n * @param currency - ISO 4217 currency code\n * @returns Number of fraction digits (usually 0 or 2)\n */\nexport function getCurrencyFractionDigits(currency: string): number {\n try {\n // Try to get from Intl\n const formatter = new Intl.NumberFormat(\"en-US\", {\n style: \"currency\",\n currency,\n });\n\n const resolved =\n formatter.resolvedOptions() as Intl.ResolvedNumberFormatOptions & {\n fractionDigits?: number;\n minimumFractionDigits?: number;\n maximumFractionDigits?: number;\n };\n\n if (resolved.fractionDigits !== undefined) {\n return resolved.fractionDigits;\n }\n\n // Use minimum/maximum as fallback\n if (resolved.minimumFractionDigits !== undefined) {\n return resolved.minimumFractionDigits;\n }\n } catch {\n // Fall through to fallback table\n }\n\n // Use fallback table\n return FRACTION_DIGITS[currency] ?? 2;\n}\n\n/**\n * Format a money amount using Intl.NumberFormat.\n *\n * @param amount - Amount in minor units (e.g. cents)\n * @param currency - ISO 4217 currency code\n * @param locale - Locale string (default: \"en-US\")\n * @returns Formatted currency string\n */\nexport function formatMoney(\n amount: number,\n currency: string,\n locale: string = \"en-US\",\n): string {\n const fractionDigits = getCurrencyFractionDigits(currency);\n const divisor = Math.pow(10, fractionDigits);\n const majorAmount = amount / divisor;\n\n const formatter = new Intl.NumberFormat(locale, {\n style: \"currency\",\n currency,\n minimumFractionDigits: fractionDigits,\n maximumFractionDigits: fractionDigits,\n });\n\n return formatter.format(majorAmount);\n}\n","/**\n * Largest-remainder method for allocating money amounts by ratios.\n * Ensures that parts sum exactly to the original amount with no penny lost.\n *\n * @param totalAmount - Total amount in minor units to allocate\n * @param ratios - Array of ratios for allocation (must sum > 0)\n * @returns Array of allocated amounts that sum to totalAmount\n * @throws RangeError if ratios don't sum to positive value\n */\nexport function allocate(\n totalAmount: number,\n ratios: readonly number[],\n): number[] {\n if (ratios.length === 0) {\n throw new RangeError(\"Cannot allocate with empty ratios\");\n }\n\n const totalRatio = ratios.reduce((sum, ratio) => sum + ratio, 0);\n if (totalRatio <= 0) {\n throw new RangeError(\"Ratios must sum to a positive value\");\n }\n\n // Calculate initial floored allocations and remainders\n const allocations: number[] = [];\n const remainders: Array<{ index: number; remainder: number }> = [];\n let distributed = 0;\n\n for (let i = 0; i < ratios.length; i++) {\n const ratio = ratios[i];\n if (ratio === undefined) continue;\n\n const exact = (totalAmount * ratio) / totalRatio;\n const floored = Math.floor(exact);\n allocations.push(floored);\n remainders.push({ index: i, remainder: exact - floored });\n distributed += floored;\n }\n\n // Distribute remaining units to items with largest remainders\n const remainder = totalAmount - distributed;\n\n // Sort by remainder (descending) and give extra to items with largest remainders\n // Earlier items get priority when remainders are equal\n const sortedByRemainder = remainders\n .map((r, index) => ({ ...r, originalIndex: index }))\n .sort((a, b) => {\n if (b.remainder !== a.remainder) {\n return b.remainder - a.remainder; // Larger remainder first\n }\n return a.originalIndex - b.originalIndex; // Earlier index first for ties\n });\n\n for (let i = 0; i < remainder && i < sortedByRemainder.length; i++) {\n const item = sortedByRemainder[i];\n if (item) {\n const idx = item.originalIndex;\n allocations[idx] = (allocations[idx] ?? 0) + 1;\n }\n }\n\n return allocations;\n}\n","/**\n * Error thrown when operations are attempted on Money objects with different currencies.\n */\nexport class CurrencyMismatch extends Error {\n constructor() {\n super(\"Currency mismatch: operations require matching currencies\");\n this.name = \"CurrencyMismatch\";\n }\n}\n","import { formatMoney, getCurrencyFractionDigits } from \"./format.js\";\nimport { allocate } from \"./allocate.js\";\nimport { CurrencyMismatch } from \"./errors.js\";\n\n/**\n * Rounding modes for multiply operations.\n */\nexport type Rounding = \"half-up\" | \"half-even\" | \"down\" | \"up\";\n\n/**\n * Money value object - immutable representation of an amount in a specific currency.\n * Uses integer minor units (e.g. cents) to avoid floating-point rounding errors.\n */\nexport interface Money {\n /** Amount in minor units (e.g. cents for USD) - must be a safe integer */\n readonly amount: number;\n /** ISO 4217 currency code */\n readonly currency: string;\n\n /**\n * Add another Money value (same currency only).\n * @throws CurrencyMismatch if currencies differ\n */\n add(other: Money): Money;\n\n /**\n * Subtract another Money value (same currency only).\n * @throws CurrencyMismatch if currencies differ\n */\n subtract(other: Money): Money;\n\n /**\n * Multiply by a factor with optional rounding mode.\n * @param factor - Multiplication factor (can be fractional, e.g. 1.0825 for 8.25% tax)\n * @param rounding - Rounding mode (default: \"half-even\")\n */\n multiply(factor: number, rounding?: Rounding): Money;\n\n /**\n * Allocate amount by integer ratios using largest-remainder method.\n * Parts always sum exactly to the original amount.\n * @param ratios - Array of ratios (must sum > 0)\n * @throws RangeError if ratios don't sum to positive value\n */\n allocate(ratios: readonly number[]): Money[];\n\n /**\n * Compare with another Money value (same currency only).\n * @returns -1 if less, 0 if equal, 1 if greater\n * @throws CurrencyMismatch if currencies differ\n */\n compare(other: Money): -1 | 0 | 1;\n\n /**\n * Check equality with another Money value (same currency only).\n * @throws CurrencyMismatch if currencies differ\n */\n equals(other: Money): boolean;\n\n /** Check if amount is zero */\n isZero(): boolean;\n /** Check if amount is negative */\n isNegative(): boolean;\n /** Check if amount is positive */\n isPositive(): boolean;\n\n /**\n * Format using Intl.NumberFormat.\n * @param locale - Locale string (default: \"en-US\")\n */\n format(locale?: string): string;\n\n /** Serialize to plain object */\n toJSON(): { amount: number; currency: string };\n}\n\n/**\n * Create a Money implementation class.\n */\nclass MoneyImpl implements Money {\n constructor(\n public readonly amount: number,\n public readonly currency: string,\n ) {\n // Validate safe integer\n if (!Number.isSafeInteger(amount)) {\n throw new RangeError(\n `Amount must be a safe integer, got: ${String(amount)}`,\n );\n }\n }\n\n add(other: Money): Money {\n this.ensureSameCurrency(other);\n return new MoneyImpl(this.amount + other.amount, this.currency);\n }\n\n subtract(other: Money): Money {\n this.ensureSameCurrency(other);\n return new MoneyImpl(this.amount - other.amount, this.currency);\n }\n\n multiply(factor: number, rounding: Rounding = \"half-even\"): Money {\n const exact = this.amount * factor;\n const rounded = this.applyRounding(exact, rounding);\n\n // Check if result exceeds safe integer range before capping\n if (rounded > Number.MAX_SAFE_INTEGER) {\n return new MoneyImpl(Number.MAX_SAFE_INTEGER, this.currency);\n }\n if (rounded < Number.MIN_SAFE_INTEGER) {\n return new MoneyImpl(Number.MIN_SAFE_INTEGER, this.currency);\n }\n\n return new MoneyImpl(rounded, this.currency);\n }\n\n allocate(ratios: readonly number[]): Money[] {\n const allocations = allocate(this.amount, ratios);\n return allocations.map((amount) => new MoneyImpl(amount, this.currency));\n }\n\n compare(other: Money): -1 | 0 | 1 {\n this.ensureSameCurrency(other);\n if (this.amount < other.amount) return -1;\n if (this.amount > other.amount) return 1;\n return 0;\n }\n\n equals(other: Money): boolean {\n return this.compare(other) === 0;\n }\n\n isZero(): boolean {\n return this.amount === 0;\n }\n\n isNegative(): boolean {\n return this.amount < 0;\n }\n\n isPositive(): boolean {\n return this.amount > 0;\n }\n\n format(locale: string = \"en-US\"): string {\n return formatMoney(this.amount, this.currency, locale);\n }\n\n toJSON(): { amount: number; currency: string } {\n return { amount: this.amount, currency: this.currency };\n }\n\n private ensureSameCurrency(other: Money): void {\n if (this.currency !== other.currency) {\n throw new CurrencyMismatch();\n }\n }\n\n private applyRounding(value: number, mode: Rounding): number {\n if (Number.isSafeInteger(value)) {\n return value;\n }\n\n switch (mode) {\n case \"half-up\": {\n // Round to nearest, with ties going up\n const fractional = value - Math.floor(value);\n if (fractional === 0.5) {\n return Math.ceil(value);\n }\n return Math.round(value);\n }\n case \"half-even\": {\n // Banker's rounding: round to nearest even integer\n const rounded = Math.round(value);\n const isHalf = Math.abs(value - rounded) === 0.5;\n if (isHalf) {\n // Round to nearest even\n return Math.round(value / 2) * 2;\n }\n return rounded;\n }\n case \"down\":\n return Math.floor(value);\n case \"up\":\n return Math.ceil(value);\n default:\n return Math.round(value);\n }\n }\n}\n\n/**\n * Factory function to create Money objects.\n *\n * @param amount - Amount in minor units (e.g. cents for USD)\n * @param currency - ISO 4217 currency code\n * @throws RangeError if amount is not a safe integer\n */\nexport function money(amount: number, currency: string): Money {\n return new MoneyImpl(amount, currency);\n}\n\n/**\n * Create Money from a decimal string representation.\n * Parses a string like \"19.99\" to 1999 cents for USD.\n * Never uses float parsing to avoid reintroducing float errors.\n *\n * @param value - Decimal string (e.g. \"19.99\")\n * @param currency - ISO 4217 currency code\n * @throws RangeError if value has more fraction digits than currency allows\n */\nexport function fromDecimal(value: string, currency: string): Money {\n // Get fraction digits for currency (FIX: use the proper function)\n const fractionDigits = getCurrencyFractionDigits(currency);\n\n // Parse the string\n const parts = value.split(\".\");\n if (parts.length > 2) {\n throw new RangeError(`Invalid decimal format: ${value}`);\n }\n\n const integerPart = parts[0] || \"0\";\n const fractionalPart = parts[1] || \"\";\n\n // Handle negative numbers\n const isNegative = integerPart.startsWith(\"-\");\n const absIntegerPart = isNegative ? integerPart.slice(1) : integerPart;\n\n // Handle special case: \"-0\" or \"-0.\" should be 0\n if (\n absIntegerPart === \"0\" &&\n (fractionalPart === \"\" || fractionalPart === \"0\")\n ) {\n return money(0, currency);\n }\n\n // Validate fraction digits\n if (fractionalPart.length > fractionDigits) {\n // Check if it can be cleanly rounded\n const hasExtraDigits = fractionalPart\n .slice(fractionDigits)\n .split(\"\")\n .some((d) => d !== \"0\");\n if (hasExtraDigits) {\n throw new RangeError(\n `Too many fraction digits for ${currency}: ${value} (max ${String(fractionDigits)})`,\n );\n }\n }\n\n // Convert to integer minor units\n const majorUnits = parseInt(absIntegerPart, 10);\n let minorUnits = 0;\n\n if (fractionalPart.length > 0) {\n const padded = fractionalPart.padEnd(fractionDigits, \"0\");\n minorUnits = parseInt(padded, 10);\n }\n\n const totalAmount = majorUnits * Math.pow(10, fractionDigits) + minorUnits;\n return money(isNegative ? -totalAmount : totalAmount, currency);\n}\n\n// Re-export CurrencyMismatch for convenience\nexport { CurrencyMismatch };\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@azghr/specie",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Tiny integer-cents money type — safe arithmetic, currency-aware rounding, Intl formatting. No bignum, no dependencies.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"main": "./dist/index.cjs",
|
|
9
|
+
"module": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js",
|
|
15
|
+
"require": "./dist/index.cjs"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE",
|
|
22
|
+
"CHANGELOG.md"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --sourcemap --clean",
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"test:watch": "vitest",
|
|
31
|
+
"typecheck": "tsc --noEmit",
|
|
32
|
+
"lint": "eslint src test examples",
|
|
33
|
+
"demo": "tsx examples/demo.ts",
|
|
34
|
+
"check": "npm run typecheck && npm run lint && npm run test && npm run build",
|
|
35
|
+
"prepublishOnly": "npm run check"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"money",
|
|
39
|
+
"currency",
|
|
40
|
+
"arithmetic",
|
|
41
|
+
"formatting",
|
|
42
|
+
"intl",
|
|
43
|
+
"cents",
|
|
44
|
+
"financial",
|
|
45
|
+
"decimal",
|
|
46
|
+
"rounding",
|
|
47
|
+
"allocation"
|
|
48
|
+
],
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@eslint/js": "^9.18.0",
|
|
51
|
+
"eslint": "^9.18.0",
|
|
52
|
+
"tsx": "^4.19.2",
|
|
53
|
+
"typescript": "^5.7.3",
|
|
54
|
+
"typescript-eslint": "^8.19.1",
|
|
55
|
+
"vitest": "^2.1.8",
|
|
56
|
+
"tsup": "^8.3.5"
|
|
57
|
+
}
|
|
58
|
+
}
|