@c9up/atom 0.1.3

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/src/Decimal.ts ADDED
@@ -0,0 +1,694 @@
1
+ import { formatDecimal, parseDecimal, pow10BigInt } from "./math.js";
2
+ import { nativeAtom } from "./native.js";
3
+
4
+ export type DecimalInput = string | number | bigint | Decimal;
5
+ export type RoundMode = "trunc" | "floor" | "ceil" | "half-up" | "half-even";
6
+
7
+ export interface DecimalScaled {
8
+ value: bigint;
9
+ scale: number;
10
+ }
11
+
12
+ export interface DivOptions {
13
+ precision?: number;
14
+ }
15
+
16
+ export interface PowOptions {
17
+ precision?: number;
18
+ }
19
+
20
+ export interface SqrtOptions {
21
+ precision?: number;
22
+ mode?: RoundMode;
23
+ }
24
+
25
+ export interface BetweenOptions {
26
+ inclusive?: boolean;
27
+ }
28
+
29
+ export interface QuantizeOptions {
30
+ mode?: RoundMode;
31
+ precision?: number;
32
+ }
33
+
34
+ export interface MedianOptions {
35
+ precision?: number;
36
+ }
37
+
38
+ export interface StddevOptions {
39
+ sample?: boolean;
40
+ precision?: number;
41
+ mode?: RoundMode;
42
+ }
43
+
44
+ export interface ToMinorUnitsOptions {
45
+ exact?: boolean;
46
+ mode?: RoundMode;
47
+ }
48
+
49
+ export class Decimal {
50
+ #value: string;
51
+
52
+ constructor(value: DecimalInput) {
53
+ this.#value = normalizeInput(value);
54
+ }
55
+
56
+ static from(value: DecimalInput): Decimal {
57
+ return new Decimal(value);
58
+ }
59
+
60
+ static zero(): Decimal {
61
+ return new Decimal("0");
62
+ }
63
+
64
+ static one(): Decimal {
65
+ return new Decimal("1");
66
+ }
67
+
68
+ static fromMinorUnits(
69
+ value: string | number | bigint,
70
+ scale: number,
71
+ ): Decimal {
72
+ assertScale(scale);
73
+ const minor = parseIntegerInput(value);
74
+ return fromIntScale(minor, scale);
75
+ }
76
+
77
+ static parseLocale(
78
+ value: string,
79
+ localesOrRosetta?: Intl.LocalesArgument | RosettaLike,
80
+ ): Decimal {
81
+ if (isRosettaLike(localesOrRosetta)) {
82
+ return new Decimal(normalizeViaRosetta(value, localesOrRosetta));
83
+ }
84
+ return new Decimal(normalizeLocaleNumber(value, localesOrRosetta));
85
+ }
86
+
87
+ plus(other: DecimalInput): Decimal {
88
+ const b = normalizeInput(other);
89
+ const result = nativeAtom().add(this.#value, b);
90
+ return new Decimal(result);
91
+ }
92
+
93
+ minus(other: DecimalInput): Decimal {
94
+ const b = normalizeInput(other);
95
+ const result = nativeAtom().sub(this.#value, b);
96
+ return new Decimal(result);
97
+ }
98
+
99
+ times(other: DecimalInput): Decimal {
100
+ const b = normalizeInput(other);
101
+ const result = nativeAtom().mul(this.#value, b);
102
+ return new Decimal(result);
103
+ }
104
+
105
+ div(other: DecimalInput, options: DivOptions = {}): Decimal {
106
+ const b = normalizeInput(other);
107
+ const precision = options.precision ?? 18;
108
+ assertScale(precision);
109
+ const result = nativeAtom().div(this.#value, b, precision);
110
+ return new Decimal(result);
111
+ }
112
+
113
+ mod(other: DecimalInput): Decimal {
114
+ const b = normalizeInput(other);
115
+ const result = nativeAtom().rem(this.#value, b);
116
+ return new Decimal(result);
117
+ }
118
+
119
+ pow(exp: number, options: PowOptions = {}): Decimal {
120
+ if (!Number.isInteger(exp)) {
121
+ throw new Error(`Invalid exponent: ${exp}`);
122
+ }
123
+ const precision = options.precision ?? 18;
124
+ assertScale(precision);
125
+ const result = nativeAtom().pow(this.#value, exp, precision);
126
+ return new Decimal(result);
127
+ }
128
+
129
+ sqrt(options: SqrtOptions = {}): Decimal {
130
+ const precision = options.precision ?? 18;
131
+ const mode = options.mode ?? "trunc";
132
+ assertScale(precision);
133
+ if (mode === "trunc") {
134
+ const result = nativeAtom().sqrt(this.#value, precision);
135
+ return new Decimal(result);
136
+ }
137
+ const withExtra = nativeAtom().sqrt(this.#value, precision + 1);
138
+ const parsed = parseDecimal(withExtra);
139
+ const rounded = roundIntScale(parsed.int, precision + 1, precision, mode);
140
+ return fromIntScale(rounded, precision);
141
+ }
142
+
143
+ cmp(other: DecimalInput): -1 | 0 | 1 {
144
+ const b = normalizeInput(other);
145
+ const result = nativeAtom().cmp(this.#value, b);
146
+ if (result < 0) return -1;
147
+ if (result > 0) return 1;
148
+ return 0;
149
+ }
150
+
151
+ eq(other: DecimalInput): boolean {
152
+ return this.cmp(other) === 0;
153
+ }
154
+
155
+ lt(other: DecimalInput): boolean {
156
+ return this.cmp(other) < 0;
157
+ }
158
+
159
+ lte(other: DecimalInput): boolean {
160
+ return this.cmp(other) <= 0;
161
+ }
162
+
163
+ gt(other: DecimalInput): boolean {
164
+ return this.cmp(other) > 0;
165
+ }
166
+
167
+ gte(other: DecimalInput): boolean {
168
+ return this.cmp(other) >= 0;
169
+ }
170
+
171
+ min(other: DecimalInput): Decimal {
172
+ return this.lte(other) ? this : new Decimal(other);
173
+ }
174
+
175
+ max(other: DecimalInput): Decimal {
176
+ return this.gte(other) ? this : new Decimal(other);
177
+ }
178
+
179
+ clamp(min: DecimalInput, max: DecimalInput): Decimal {
180
+ const minValue = new Decimal(min);
181
+ const maxValue = new Decimal(max);
182
+ if (minValue.gt(maxValue)) {
183
+ throw new Error("Invalid clamp range: min is greater than max");
184
+ }
185
+ if (this.lt(minValue)) return minValue;
186
+ if (this.gt(maxValue)) return maxValue;
187
+ return this;
188
+ }
189
+
190
+ between(
191
+ min: DecimalInput,
192
+ max: DecimalInput,
193
+ options: BetweenOptions = {},
194
+ ): boolean {
195
+ const { inclusive = true } = options;
196
+ const minValue = new Decimal(min);
197
+ const maxValue = new Decimal(max);
198
+ if (minValue.gt(maxValue)) {
199
+ throw new Error("Invalid between range: min is greater than max");
200
+ }
201
+ if (inclusive) {
202
+ return this.gte(minValue) && this.lte(maxValue);
203
+ }
204
+ return this.gt(minValue) && this.lt(maxValue);
205
+ }
206
+
207
+ abs(): Decimal {
208
+ return this.isNegative() ? this.neg() : this;
209
+ }
210
+
211
+ neg(): Decimal {
212
+ return this.isZero()
213
+ ? this
214
+ : new Decimal(
215
+ this.#value.startsWith("-")
216
+ ? this.#value.slice(1)
217
+ : `-${this.#value}`,
218
+ );
219
+ }
220
+
221
+ isZero(): boolean {
222
+ return this.#value === "0";
223
+ }
224
+
225
+ isPositive(): boolean {
226
+ return this.#value !== "0" && !this.#value.startsWith("-");
227
+ }
228
+
229
+ isNegative(): boolean {
230
+ return this.#value.startsWith("-");
231
+ }
232
+
233
+ isInteger(): boolean {
234
+ return parseDecimal(this.#value).scale === 0;
235
+ }
236
+
237
+ trunc(scale = 0): Decimal {
238
+ return this.toScale(scale, "trunc");
239
+ }
240
+
241
+ floor(scale = 0): Decimal {
242
+ return this.toScale(scale, "floor");
243
+ }
244
+
245
+ ceil(scale = 0): Decimal {
246
+ return this.toScale(scale, "ceil");
247
+ }
248
+
249
+ round(scale = 0, mode: RoundMode = "half-up"): Decimal {
250
+ return this.toScale(scale, mode);
251
+ }
252
+
253
+ /**
254
+ * Snap the value to the nearest multiple of `step`. Useful for rounding
255
+ * prices to the nearest cent (`.quantize('0.01')`), the nearest 5-cent
256
+ * increment, or any custom unit. The default rounding mode is `'half-up'`;
257
+ * pass `{ mode: 'half-even' }` for banker's rounding.
258
+ *
259
+ * new Decimal('1.234').quantize('0.01') // → Decimal('1.23')
260
+ * new Decimal('1.025').quantize('0.05') // → Decimal('1.05')
261
+ */
262
+ quantize(step: DecimalInput, options: QuantizeOptions = {}): Decimal {
263
+ const { mode = "half-up" } = options;
264
+ const stepValue = new Decimal(step);
265
+ if (!stepValue.gt(0)) {
266
+ throw new Error("Quantize step must be greater than zero");
267
+ }
268
+ const thisScale = this.toParts().scale;
269
+ const stepScale = stepValue.toParts().scale;
270
+ const precision =
271
+ options.precision ?? Math.max(18, thisScale + stepScale + 6);
272
+ const units = this.div(stepValue, { precision }).round(0, mode);
273
+ return units.times(stepValue);
274
+ }
275
+
276
+ toScale(scale = 0, mode: RoundMode = "trunc"): Decimal {
277
+ assertScale(scale);
278
+ const parsed = parseDecimal(this.#value);
279
+ return fromIntScale(
280
+ roundIntScale(parsed.int, parsed.scale, scale, mode),
281
+ scale,
282
+ );
283
+ }
284
+
285
+ toFixed(scale: number, mode: RoundMode = "trunc"): string {
286
+ assertScale(scale);
287
+ const parsed = parseDecimal(this.#value);
288
+ const roundedInt = roundIntScale(parsed.int, parsed.scale, scale, mode);
289
+ const negative = roundedInt < 0n;
290
+ const raw = (negative ? -roundedInt : roundedInt)
291
+ .toString()
292
+ .padStart(scale + 1, "0");
293
+ if (scale === 0) return negative ? `-${raw}` : raw;
294
+ const whole = raw.slice(0, raw.length - scale);
295
+ const frac = raw.slice(raw.length - scale);
296
+ const out = `${whole}.${frac}`;
297
+ return negative ? `-${out}` : out;
298
+ }
299
+
300
+ /**
301
+ * Convert to a minor-unit `bigint` representation — typically used for
302
+ * persisting prices to a database as integer cents (`scale: 2`).
303
+ *
304
+ * - `exact: true` (default) throws if the conversion would lose precision
305
+ * (e.g. `'1.234'.toMinorUnits(2)` errors because `0.004` can't be
306
+ * represented at scale 2 without rounding).
307
+ * - `exact: false` rounds using the requested `mode` (default `'trunc'`).
308
+ *
309
+ * new Decimal('19.99').toMinorUnits(2) // → 1999n
310
+ * new Decimal('1.234').toMinorUnits(2, { exact: false }) // → 123n (truncated)
311
+ */
312
+ toMinorUnits(scale: number, options: ToMinorUnitsOptions = {}): bigint {
313
+ assertScale(scale);
314
+ const { exact = true, mode = "trunc" } = options;
315
+ const parsed = parseDecimal(this.#value);
316
+ if (parsed.scale === scale) return parsed.int;
317
+ if (parsed.scale < scale) {
318
+ return parsed.int * pow10BigInt(scale - parsed.scale);
319
+ }
320
+
321
+ const drop = parsed.scale - scale;
322
+ const factor = pow10BigInt(drop);
323
+ const remainder = parsed.int % factor;
324
+ if (exact && remainder !== 0n) {
325
+ throw new Error(
326
+ `Cannot convert ${this.#value} to minor units at scale ${scale} without precision loss`,
327
+ );
328
+ }
329
+ return roundIntScale(parsed.int, parsed.scale, scale, mode);
330
+ }
331
+
332
+ /**
333
+ * Compute `this * rate / 100` — the percentage portion of the value.
334
+ *
335
+ * new Decimal('200').percent('15') // → Decimal('30')
336
+ */
337
+ percent(rate: DecimalInput, options: DivOptions = {}): Decimal {
338
+ return this.times(rate).div("100", options);
339
+ }
340
+
341
+ /**
342
+ * Add a percentage to the value: `this + (this * rate / 100)`. Handy for
343
+ * tax/markup calculations.
344
+ *
345
+ * new Decimal('100').applyPercent('20') // → Decimal('120')
346
+ */
347
+ applyPercent(rate: DecimalInput, options: DivOptions = {}): Decimal {
348
+ return this.plus(this.percent(rate, options));
349
+ }
350
+
351
+ /**
352
+ * Express this value as a percentage of `total`: `this / total * 100`.
353
+ *
354
+ * new Decimal('30').percentageOf('200') // → Decimal('15')
355
+ */
356
+ percentageOf(total: DecimalInput, options: DivOptions = {}): Decimal {
357
+ return this.div(total, options).times("100");
358
+ }
359
+
360
+ /**
361
+ * Distribute the value across N buckets according to integer ratios with
362
+ * **zero rounding loss**: the sum of the returned shares equals the
363
+ * original value exactly. Used for splitting money: `'10.00'.allocate([1, 1, 1])`
364
+ * returns `['3.34', '3.33', '3.33']`, not three `'3.33'` (which would
365
+ * lose a cent).
366
+ *
367
+ * The remainder pennies are distributed largest-remainder-first, with
368
+ * stable input order as the tiebreaker.
369
+ */
370
+ allocate(ratios: Array<string | number | bigint>): Decimal[] {
371
+ if (ratios.length === 0) {
372
+ throw new Error("Allocate requires at least one ratio");
373
+ }
374
+
375
+ const normalized = ratios.map((ratio) => {
376
+ const value = parseIntegerInput(ratio);
377
+ if (value < 0n) {
378
+ throw new Error(`Allocate ratios must be >= 0, got ${ratio}`);
379
+ }
380
+ return value;
381
+ });
382
+
383
+ const ratioTotal = normalized.reduce((acc, value) => acc + value, 0n);
384
+ if (ratioTotal <= 0n) {
385
+ throw new Error("Allocate requires at least one positive ratio");
386
+ }
387
+
388
+ const parsed = parseDecimal(this.#value);
389
+ const sign = parsed.int < 0n ? -1n : 1n;
390
+ const total = parsed.int < 0n ? -parsed.int : parsed.int;
391
+
392
+ const baseShares: bigint[] = [];
393
+ const remainders: Array<{ index: number; remainder: bigint }> = [];
394
+ let consumed = 0n;
395
+
396
+ for (let index = 0; index < normalized.length; index++) {
397
+ const ratio = normalized[index];
398
+ const weighted = total * ratio;
399
+ const share = weighted / ratioTotal;
400
+ const remainder = weighted % ratioTotal;
401
+ baseShares.push(share);
402
+ remainders.push({ index, remainder });
403
+ consumed += share;
404
+ }
405
+
406
+ let left = total - consumed;
407
+ remainders.sort((a, b) => {
408
+ if (a.remainder > b.remainder) return -1;
409
+ if (a.remainder < b.remainder) return 1;
410
+ return a.index - b.index;
411
+ });
412
+
413
+ let pointer = 0;
414
+ while (left > 0n) {
415
+ baseShares[remainders[pointer].index] += 1n;
416
+ left -= 1n;
417
+ pointer++;
418
+ if (pointer >= remainders.length) pointer = 0;
419
+ }
420
+
421
+ return baseShares.map((share) => fromIntScale(share * sign, parsed.scale));
422
+ }
423
+
424
+ toParts(): DecimalScaled {
425
+ const parsed = parseDecimal(this.#value);
426
+ return { value: parsed.int, scale: parsed.scale };
427
+ }
428
+
429
+ toString(): string {
430
+ return this.#value;
431
+ }
432
+
433
+ toJSON(): string {
434
+ return this.#value;
435
+ }
436
+
437
+ /**
438
+ * Convert to a JavaScript `number` — **lossy** for values with more than
439
+ * 15-16 significant digits. Use `toString()` / `toJSON()` for exact output.
440
+ * Provided for interop with APIs that expect a primitive number.
441
+ */
442
+ toNumber(): number {
443
+ return Number(this.#value);
444
+ }
445
+
446
+ /**
447
+ * Format the value as a localized string via `Intl.NumberFormat`.
448
+ *
449
+ * Unlike `toNumber()`, this path is **exact** — we route through the
450
+ * string-accepting overload of `Intl.NumberFormat.format` (ECMA-402
451
+ * stage-4, supported by every V8 since Node 20). A `Decimal` of
452
+ * `'9999999999999999.99'` formats correctly instead of rounding to
453
+ * `10000000000000000`, which is the whole reason Atom exists.
454
+ *
455
+ * For pure integers without a decimal point, we hand the value to
456
+ * `format` as a BigInt (which has been in the type system since ES2020).
457
+ * For fractional values, we use the runtime's string support via a
458
+ * typed extension interface — no `any` escape hatch.
459
+ */
460
+ toLocale(
461
+ localesOrRosetta?: Intl.LocalesArgument | RosettaLike,
462
+ options?: Intl.NumberFormatOptions,
463
+ ): string {
464
+ if (isRosettaLike(localesOrRosetta)) {
465
+ return localesOrRosetta.formatNumberString(this.#value, options);
466
+ }
467
+ const formatter = new Intl.NumberFormat(
468
+ localesOrRosetta,
469
+ options,
470
+ ) as StringFormatter;
471
+ if (this.isInteger()) {
472
+ return formatter.format(BigInt(this.#value));
473
+ }
474
+ return formatter.format(this.#value);
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Extension of the standard `Intl.NumberFormat` type to declare the
480
+ * string-accepting overload. ECMA-402 specifies `format` as accepting
481
+ * `number | bigint | string`; TypeScript's built-in lib only has
482
+ * `number | bigint`. Declaring this locally keeps the public API free of
483
+ * casts while remaining 100% runtime-compatible.
484
+ */
485
+ interface StringFormatter extends Intl.NumberFormat {
486
+ format(value: number | bigint | string): string;
487
+ }
488
+
489
+ function normalizeInput(value: DecimalInput): string {
490
+ if (value instanceof Decimal) {
491
+ return value.toString();
492
+ }
493
+ if (typeof value === "bigint") {
494
+ return value.toString();
495
+ }
496
+ if (typeof value === "number") {
497
+ if (!Number.isFinite(value)) {
498
+ throw new Error(`Invalid decimal: ${value}`);
499
+ }
500
+ return normalizeDecimalString(String(value));
501
+ }
502
+ return normalizeDecimalString(value);
503
+ }
504
+
505
+ function normalizeDecimalString(input: string): string {
506
+ const parsed = parseDecimal(input);
507
+ return formatDecimal(parsed.int, parsed.scale);
508
+ }
509
+
510
+ function parseIntegerInput(value: string | number | bigint): bigint {
511
+ if (typeof value === "bigint") return value;
512
+ if (typeof value === "number") {
513
+ if (!Number.isInteger(value)) {
514
+ throw new Error(`Invalid integer: ${value}`);
515
+ }
516
+ return BigInt(value);
517
+ }
518
+ const s = value.trim();
519
+ if (!/^[+-]?\d+$/.test(s)) {
520
+ throw new Error(`Invalid integer: ${value}`);
521
+ }
522
+ return BigInt(s);
523
+ }
524
+
525
+ function fromIntScale(int: bigint, scale: number): Decimal {
526
+ return new Decimal(formatDecimal(int, scale));
527
+ }
528
+
529
+ function assertScale(scale: number): void {
530
+ if (!Number.isInteger(scale) || scale < 0) {
531
+ throw new Error(`Invalid scale: ${scale}`);
532
+ }
533
+ }
534
+
535
+ function roundIntScale(
536
+ int: bigint,
537
+ sourceScale: number,
538
+ targetScale: number,
539
+ mode: RoundMode,
540
+ ): bigint {
541
+ if (targetScale >= sourceScale) {
542
+ return int * pow10BigInt(targetScale - sourceScale);
543
+ }
544
+
545
+ const drop = sourceScale - targetScale;
546
+ const factor = pow10BigInt(drop);
547
+ const q = int / factor;
548
+ const r = int % factor;
549
+ if (r === 0n) return q;
550
+
551
+ const absR = r < 0n ? -r : r;
552
+ const sign = int < 0n ? -1n : 1n;
553
+
554
+ switch (mode) {
555
+ case "trunc":
556
+ return q;
557
+ case "floor":
558
+ return int < 0n ? q - 1n : q;
559
+ case "ceil":
560
+ return int > 0n ? q + 1n : q;
561
+ case "half-up":
562
+ return absR * 2n >= factor ? q + sign : q;
563
+ case "half-even": {
564
+ const twice = absR * 2n;
565
+ if (twice < factor) return q;
566
+ if (twice > factor) return q + sign;
567
+ const isEven = (q < 0n ? -q : q) % 2n === 0n;
568
+ return isEven ? q : q + sign;
569
+ }
570
+ default:
571
+ // Defensive guard — the TypeScript type system already restricts `mode`
572
+ // to the `RoundMode` union, so this branch is unreachable under normal
573
+ // usage. We throw instead of silently returning the truncated result
574
+ // because a silent fallback would hide a bug: an upstream cast past the
575
+ // type system (`as RoundMode`) would lose precision without any signal.
576
+ throw new Error(`Unknown rounding mode: ${String(mode)}`);
577
+ }
578
+ }
579
+
580
+ /**
581
+ * Structural type matching `@c9up/rosetta`'s `Rosetta` and
582
+ * `RosettaLocale` surfaces. Atom never imports the Rosetta
583
+ * package directly — duck-typing keeps the integration optional
584
+ * and avoids a hard cross-package dependency.
585
+ */
586
+ export interface RosettaNumberFormatData {
587
+ decimal: string;
588
+ group: string;
589
+ minus: string;
590
+ plusSign: string;
591
+ }
592
+
593
+ export interface RosettaLike {
594
+ getNumberFormatData(): RosettaNumberFormatData;
595
+ formatNumberString(value: string, options?: Intl.NumberFormatOptions): string;
596
+ }
597
+
598
+ function isRosettaLike(arg: unknown): arg is RosettaLike {
599
+ return (
600
+ typeof arg === "object" &&
601
+ arg !== null &&
602
+ "getNumberFormatData" in arg &&
603
+ typeof (arg as RosettaLike).getNumberFormatData === "function" &&
604
+ "formatNumberString" in arg &&
605
+ typeof (arg as RosettaLike).formatNumberString === "function"
606
+ );
607
+ }
608
+
609
+ function normalizeViaRosetta(input: string, rosetta: RosettaLike): string {
610
+ const trimmed = input.trim();
611
+ if (!trimmed) {
612
+ throw new Error("Invalid localized decimal: empty string");
613
+ }
614
+ const raw = rosetta.getNumberFormatData();
615
+ // Guard: a malformed `RosettaLike` returning empty separators
616
+ // would produce regexes matching every position. Fall back to
617
+ // ASCII defaults rather than corrupting the input.
618
+ const data = {
619
+ decimal: raw.decimal || ".",
620
+ group: raw.group || ",",
621
+ minus: raw.minus || "-",
622
+ plusSign: raw.plusSign || "+",
623
+ };
624
+ let normalized = trimmed.replace(/\s| | /g, "");
625
+ normalized = normalized.replace(
626
+ new RegExp(escapeRegExp(data.group), "g"),
627
+ "",
628
+ );
629
+ normalized = normalized.replace(
630
+ new RegExp(escapeRegExp(data.decimal), "g"),
631
+ ".",
632
+ );
633
+ if (data.minus !== "-") {
634
+ normalized = normalized.replace(
635
+ new RegExp(escapeRegExp(data.minus), "g"),
636
+ "-",
637
+ );
638
+ }
639
+ normalized = normalized.replace(/[−﹣-]/g, "-");
640
+ // Substitute the locale's plus sign (e.g., U+FF0B `+`,
641
+ // U+FB29 `﬩`) to ASCII so the strict validator accepts it.
642
+ if (data.plusSign !== "+") {
643
+ normalized = normalized.replace(
644
+ new RegExp(escapeRegExp(data.plusSign), "g"),
645
+ "+",
646
+ );
647
+ }
648
+
649
+ if (/^\(.*\)$/.test(normalized)) {
650
+ normalized = `-${normalized.slice(1, -1)}`;
651
+ }
652
+
653
+ if (!/^[+-]?\d+(\.\d+)?$/.test(normalized)) {
654
+ throw new Error(`Invalid localized decimal: ${input}`);
655
+ }
656
+ return normalized;
657
+ }
658
+
659
+ function normalizeLocaleNumber(
660
+ input: string,
661
+ locales?: Intl.LocalesArgument,
662
+ ): string {
663
+ const trimmed = input.trim();
664
+ if (!trimmed) {
665
+ throw new Error("Invalid localized decimal: empty string");
666
+ }
667
+
668
+ const formatter = new Intl.NumberFormat(locales);
669
+ const parts = formatter.formatToParts(-12345.6);
670
+ const group = parts.find((part) => part.type === "group")?.value ?? ",";
671
+ const decimal = parts.find((part) => part.type === "decimal")?.value ?? ".";
672
+ const minus = parts.find((part) => part.type === "minusSign")?.value ?? "-";
673
+
674
+ let normalized = trimmed.replace(/\s|\u00A0|\u202F/g, "");
675
+ normalized = normalized.replace(new RegExp(escapeRegExp(group), "g"), "");
676
+ normalized = normalized.replace(new RegExp(escapeRegExp(decimal), "g"), ".");
677
+ if (minus !== "-") {
678
+ normalized = normalized.replace(new RegExp(escapeRegExp(minus), "g"), "-");
679
+ }
680
+ normalized = normalized.replace(/[−﹣-]/g, "-");
681
+
682
+ if (/^\(.*\)$/.test(normalized)) {
683
+ normalized = `-${normalized.slice(1, -1)}`;
684
+ }
685
+
686
+ if (!/^[+-]?\d+(\.\d+)?$/.test(normalized)) {
687
+ throw new Error(`Invalid localized decimal: ${input}`);
688
+ }
689
+ return normalized;
690
+ }
691
+
692
+ function escapeRegExp(value: string): string {
693
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
694
+ }