@c9up/atom 0.1.10 → 0.1.12
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/README.md +9 -1
- package/dist/Decimal.d.ts +27 -1
- package/dist/Decimal.d.ts.map +1 -1
- package/dist/Decimal.js +151 -96
- package/dist/Decimal.js.map +1 -1
- package/dist/Money.d.ts +15 -2
- package/dist/Money.d.ts.map +1 -1
- package/dist/Money.js +28 -3
- package/dist/Money.js.map +1 -1
- package/dist/atlas.d.ts +2 -5
- package/dist/atlas.d.ts.map +1 -1
- package/dist/atlas.js +2 -5
- package/dist/atlas.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +67 -33
- package/dist/index.js.map +1 -1
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +3 -2
- package/scripts/build-napi-types.mjs +4 -3
- package/scripts/generate-napi-types.mjs +9 -6
- package/src/Decimal.ts +222 -134
- package/src/Money.ts +44 -5
- package/src/atlas.ts +2 -5
- package/src/index.ts +75 -36
package/src/Decimal.ts
CHANGED
|
@@ -43,9 +43,16 @@ export interface BetweenOptions {
|
|
|
43
43
|
inclusive?: boolean;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* `quantize` takes a rounding mode and nothing else.
|
|
48
|
+
*
|
|
49
|
+
* It used to accept a `precision` too, which it validated and then ignored:
|
|
50
|
+
* `quantize('0.01', { precision: 4 })` answered `1.23`, the same as without it.
|
|
51
|
+
* There is nothing the option could have meant — the result's scale comes from
|
|
52
|
+
* the step, which is the whole point of quantizing to one.
|
|
53
|
+
*/
|
|
46
54
|
export interface QuantizeOptions {
|
|
47
55
|
mode?: RoundMode;
|
|
48
|
-
precision?: number;
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
export interface MedianOptions {
|
|
@@ -139,9 +146,13 @@ export class Decimal {
|
|
|
139
146
|
localesOrRosetta?: Intl.LocalesArgument | RosettaLike,
|
|
140
147
|
): Decimal {
|
|
141
148
|
if (isRosettaLike(localesOrRosetta)) {
|
|
142
|
-
return new Decimal(
|
|
149
|
+
return new Decimal(
|
|
150
|
+
normalizeLocalized(value, shapeFromRosetta(localesOrRosetta)),
|
|
151
|
+
);
|
|
143
152
|
}
|
|
144
|
-
return new Decimal(
|
|
153
|
+
return new Decimal(
|
|
154
|
+
normalizeLocalized(value, shapeFromIntl(localesOrRosetta)),
|
|
155
|
+
);
|
|
145
156
|
}
|
|
146
157
|
|
|
147
158
|
plus(other: DecimalInput): Decimal {
|
|
@@ -332,9 +343,6 @@ export class Decimal {
|
|
|
332
343
|
if (!stepValue.gt(0)) {
|
|
333
344
|
throw new Error("Quantize step must be greater than zero");
|
|
334
345
|
}
|
|
335
|
-
if (options.precision !== undefined) {
|
|
336
|
-
assertScale(options.precision);
|
|
337
|
-
}
|
|
338
346
|
const thisParts = parseDecimal(this.#value);
|
|
339
347
|
const stepParts = parseDecimal(stepValue.#value);
|
|
340
348
|
const numerator = thisParts.int * pow10BigInt(stepParts.scale);
|
|
@@ -466,8 +474,7 @@ export class Decimal {
|
|
|
466
474
|
const remainders: Array<{ index: number; remainder: bigint }> = [];
|
|
467
475
|
let consumed = 0n;
|
|
468
476
|
|
|
469
|
-
for (
|
|
470
|
-
const ratio = normalized[index];
|
|
477
|
+
for (const [index, ratio] of normalized.entries()) {
|
|
471
478
|
const weighted = total * ratio;
|
|
472
479
|
const share = weighted / ratioTotal;
|
|
473
480
|
const remainder = weighted % ratioTotal;
|
|
@@ -485,7 +492,13 @@ export class Decimal {
|
|
|
485
492
|
|
|
486
493
|
let pointer = 0;
|
|
487
494
|
while (left > 0n) {
|
|
488
|
-
|
|
495
|
+
// `remainders` has one entry per ratio and the pointer wraps inside
|
|
496
|
+
// it, so both reads land; naming them is what says so.
|
|
497
|
+
const target = remainders[pointer];
|
|
498
|
+
if (target === undefined) break;
|
|
499
|
+
const share = baseShares[target.index];
|
|
500
|
+
if (share === undefined) break;
|
|
501
|
+
baseShares[target.index] = share + 1n;
|
|
489
502
|
left -= 1n;
|
|
490
503
|
pointer++;
|
|
491
504
|
if (pointer >= remainders.length) pointer = 0;
|
|
@@ -505,10 +518,29 @@ export class Decimal {
|
|
|
505
518
|
};
|
|
506
519
|
}
|
|
507
520
|
|
|
521
|
+
/**
|
|
522
|
+
* The canonical decimal — trailing zeros trimmed. `'001.2300'` prints
|
|
523
|
+
* `'1.23'`, and `'100.0000000000'` prints `'100'`.
|
|
524
|
+
*
|
|
525
|
+
* **The scale belongs to your domain, not to the value.** To persist or to
|
|
526
|
+
* allocate, pass the scale your column declares — `toFixed(10)`,
|
|
527
|
+
* `toScale(10)` — never the one the value happens to be carrying. A scale
|
|
528
|
+
* read back off a value is not a promise:
|
|
529
|
+
*
|
|
530
|
+
* new Decimal('100.0000000000').toParts().scale // 10
|
|
531
|
+
* new Decimal('100.0000000000').plus('0').toParts() // scale 0
|
|
532
|
+
*
|
|
533
|
+
* Arithmetic does not carry a declared scale, so anything that has been
|
|
534
|
+
* through `plus` or `times` has whatever scale its VALUE needs. That
|
|
535
|
+
* matters because `allocate()` splits at the scale it finds: the same
|
|
536
|
+
* amount divides into `33 + 67` or `33.3333333333 + 66.6666666667`
|
|
537
|
+
* depending on a property this method deliberately does not show you.
|
|
538
|
+
*/
|
|
508
539
|
toString(): string {
|
|
509
540
|
return this.#value;
|
|
510
541
|
}
|
|
511
542
|
|
|
543
|
+
/** The canonical form — see {@link toString} on storing a scale. */
|
|
512
544
|
toJSON(): string {
|
|
513
545
|
return this.#value;
|
|
514
546
|
}
|
|
@@ -552,10 +584,7 @@ export class Decimal {
|
|
|
552
584
|
if (isRosettaLike(localesOrRosetta)) {
|
|
553
585
|
return localesOrRosetta.formatNumberString(this.#value, options);
|
|
554
586
|
}
|
|
555
|
-
const formatter =
|
|
556
|
-
localesOrRosetta,
|
|
557
|
-
options,
|
|
558
|
-
) as StringFormatter;
|
|
587
|
+
const formatter = stringFormatter(localesOrRosetta, options);
|
|
559
588
|
if (this.isInteger()) {
|
|
560
589
|
return formatter.format(BigInt(this.#value));
|
|
561
590
|
}
|
|
@@ -574,6 +603,20 @@ interface StringFormatter extends Intl.NumberFormat {
|
|
|
574
603
|
format(value: number | bigint | string): string;
|
|
575
604
|
}
|
|
576
605
|
|
|
606
|
+
/**
|
|
607
|
+
* A formatter typed with that overload.
|
|
608
|
+
*
|
|
609
|
+
* A method parameter is compared bivariantly, so the constructed formatter
|
|
610
|
+
* satisfies the wider signature on its own — the assertion that used to sit
|
|
611
|
+
* here was claiming something the compiler could check.
|
|
612
|
+
*/
|
|
613
|
+
function stringFormatter(
|
|
614
|
+
locales?: Intl.LocalesArgument,
|
|
615
|
+
options?: Intl.NumberFormatOptions,
|
|
616
|
+
): StringFormatter {
|
|
617
|
+
return new Intl.NumberFormat(locales, options);
|
|
618
|
+
}
|
|
619
|
+
|
|
577
620
|
function normalizeInput(value: DecimalInput): string {
|
|
578
621
|
if (value instanceof Decimal) {
|
|
579
622
|
return value.toString();
|
|
@@ -623,7 +666,7 @@ function decimalStringFromNumber(value: number): string {
|
|
|
623
666
|
|
|
624
667
|
const [coefficient, exponentRaw] = raw.toLowerCase().split("e");
|
|
625
668
|
const exponent = Number(exponentRaw);
|
|
626
|
-
if (!Number.isInteger(exponent)) {
|
|
669
|
+
if (coefficient === undefined || !Number.isInteger(exponent)) {
|
|
627
670
|
throw new Error(`Invalid decimal: ${value}`);
|
|
628
671
|
}
|
|
629
672
|
const negative = coefficient.startsWith("-");
|
|
@@ -631,7 +674,7 @@ function decimalStringFromNumber(value: number): string {
|
|
|
631
674
|
negative || coefficient.startsWith("+")
|
|
632
675
|
? coefficient.slice(1)
|
|
633
676
|
: coefficient;
|
|
634
|
-
const [wholeRaw, fracRaw = ""] = unsigned.split(".");
|
|
677
|
+
const [wholeRaw = "", fracRaw = ""] = unsigned.split(".");
|
|
635
678
|
const digits = `${wholeRaw}${fracRaw}`;
|
|
636
679
|
const decimalIndex = wholeRaw.length + exponent;
|
|
637
680
|
|
|
@@ -847,85 +890,177 @@ function isRosettaLike(arg: unknown): arg is RosettaLike {
|
|
|
847
890
|
typeof arg === "object" &&
|
|
848
891
|
arg !== null &&
|
|
849
892
|
"getNumberFormatData" in arg &&
|
|
850
|
-
typeof
|
|
893
|
+
typeof arg.getNumberFormatData === "function" &&
|
|
851
894
|
"formatNumberString" in arg &&
|
|
852
|
-
typeof
|
|
895
|
+
typeof arg.formatNumberString === "function"
|
|
853
896
|
);
|
|
854
897
|
}
|
|
855
898
|
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
899
|
+
/**
|
|
900
|
+
* Everything reading one locale's numbers takes.
|
|
901
|
+
*
|
|
902
|
+
* There is one normalizer, and both ways of naming a locale build one of
|
|
903
|
+
* these for it. Written as two normalizers, the Rosetta one carried a subset
|
|
904
|
+
* of the rules: it substituted the separators but never mapped the locale's
|
|
905
|
+
* digits and validated grouping against the 3-3 of en-US whatever the locale
|
|
906
|
+
* actually grouped by. `parseLocale('١٬٢٣٤٫٥٦', 'ar-EG')` and
|
|
907
|
+
* `parseLocale('١٬٢٣٤٫٥٦', rosetta)` therefore disagreed about the same locale
|
|
908
|
+
* — the second refused it — and so did every Indic locale, whose 3-2 grouping
|
|
909
|
+
* only the first one asked about.
|
|
910
|
+
*/
|
|
911
|
+
interface LocaleNumberShape {
|
|
912
|
+
decimal: string;
|
|
913
|
+
group: string;
|
|
914
|
+
minus: string;
|
|
915
|
+
plusSign: string;
|
|
916
|
+
/** The locale's digits, mapped back to ASCII. Empty when they are ASCII. */
|
|
917
|
+
digits: ReadonlyMap<string, string>;
|
|
918
|
+
grouping: GroupingSpec;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
interface GroupingSpec {
|
|
922
|
+
primary: number;
|
|
923
|
+
secondary: number;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** Format a decimal string the way one locale writes it. */
|
|
927
|
+
type FormatNumber = (
|
|
928
|
+
value: string,
|
|
929
|
+
options?: Intl.NumberFormatOptions,
|
|
930
|
+
) => string;
|
|
931
|
+
|
|
932
|
+
function shapeFromIntl(locales?: Intl.LocalesArgument): LocaleNumberShape {
|
|
933
|
+
const format: FormatNumber = (value, options) =>
|
|
934
|
+
stringFormatter(locales, options).format(value);
|
|
935
|
+
const parts = new Intl.NumberFormat(locales).formatToParts(-12345.6);
|
|
936
|
+
const signed = new Intl.NumberFormat(locales, {
|
|
937
|
+
signDisplay: "always",
|
|
938
|
+
}).formatToParts(1);
|
|
939
|
+
const group = parts.find((part) => part.type === "group")?.value ?? ",";
|
|
940
|
+
const digits = digitsOf(format);
|
|
941
|
+
return {
|
|
942
|
+
decimal: parts.find((part) => part.type === "decimal")?.value ?? ".",
|
|
943
|
+
group,
|
|
944
|
+
minus: parts.find((part) => part.type === "minusSign")?.value ?? "-",
|
|
945
|
+
plusSign: signed.find((part) => part.type === "plusSign")?.value ?? "+",
|
|
946
|
+
digits,
|
|
947
|
+
grouping: groupingOf(format, group, digits),
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* The same shape, read off a Rosetta.
|
|
953
|
+
*
|
|
954
|
+
* `getNumberFormatData` names the separators; the digits and the grouping come
|
|
955
|
+
* out of `formatNumberString`, which the same interface already declares — so
|
|
956
|
+
* the Rosetta path honours its fallback chain for all four without asking the
|
|
957
|
+
* caller for a locale tag it deliberately does not take.
|
|
958
|
+
*/
|
|
959
|
+
function shapeFromRosetta(rosetta: RosettaLike): LocaleNumberShape {
|
|
960
|
+
const format: FormatNumber = (value, options) =>
|
|
961
|
+
rosetta.formatNumberString(value, options);
|
|
861
962
|
const raw = rosetta.getNumberFormatData();
|
|
862
|
-
//
|
|
863
|
-
//
|
|
864
|
-
|
|
865
|
-
const
|
|
963
|
+
// A malformed `RosettaLike` returning empty separators would build regexes
|
|
964
|
+
// matching every position. Fall back to ASCII rather than corrupt the input.
|
|
965
|
+
const group = raw.group || ",";
|
|
966
|
+
const digits = digitsOf(format);
|
|
967
|
+
return {
|
|
866
968
|
decimal: raw.decimal || ".",
|
|
867
|
-
group
|
|
969
|
+
group,
|
|
868
970
|
minus: raw.minus || "-",
|
|
869
971
|
plusSign: raw.plusSign || "+",
|
|
972
|
+
digits,
|
|
973
|
+
grouping: groupingOf(format, group, digits),
|
|
870
974
|
};
|
|
871
|
-
|
|
872
|
-
if (data.minus !== "-") {
|
|
873
|
-
normalized = normalized.replace(
|
|
874
|
-
new RegExp(escapeRegExp(data.minus), "g"),
|
|
875
|
-
"-",
|
|
876
|
-
);
|
|
877
|
-
}
|
|
878
|
-
normalized = normalized.replace(/[−﹣-]/g, "-");
|
|
879
|
-
// Substitute the locale's plus sign (e.g., U+FF0B `+`,
|
|
880
|
-
// U+FB29 `﬩`) to ASCII so the strict validator accepts it.
|
|
881
|
-
if (data.plusSign !== "+") {
|
|
882
|
-
normalized = normalized.replace(
|
|
883
|
-
new RegExp(escapeRegExp(data.plusSign), "g"),
|
|
884
|
-
"+",
|
|
885
|
-
);
|
|
886
|
-
}
|
|
975
|
+
}
|
|
887
976
|
|
|
888
|
-
|
|
889
|
-
|
|
977
|
+
/**
|
|
978
|
+
* The locale's ten digits, read off the formatter rather than a table.
|
|
979
|
+
*
|
|
980
|
+
* One call names all ten in a known order, so a numbering system whose code
|
|
981
|
+
* points are not contiguous — or one the runtime added after this was written
|
|
982
|
+
* — needs nothing here.
|
|
983
|
+
*/
|
|
984
|
+
function digitsOf(format: FormatNumber): ReadonlyMap<string, string> {
|
|
985
|
+
const map = new Map<string, string>();
|
|
986
|
+
let formatted: string;
|
|
987
|
+
try {
|
|
988
|
+
formatted = format("9876543210", { useGrouping: false });
|
|
989
|
+
} catch {
|
|
990
|
+
return map;
|
|
890
991
|
}
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
normalized = normalized.replace(
|
|
898
|
-
new RegExp(escapeRegExp(data.decimal), "g"),
|
|
899
|
-
".",
|
|
900
|
-
);
|
|
901
|
-
|
|
902
|
-
if (!/^[+-]?\d+(\.\d+)?$/.test(normalized)) {
|
|
903
|
-
throw new Error(`Invalid localized decimal: ${input}`);
|
|
992
|
+
let value = 9;
|
|
993
|
+
for (const char of formatted) {
|
|
994
|
+
if (!map.has(char) && value >= 0) {
|
|
995
|
+
map.set(char, String(value));
|
|
996
|
+
value--;
|
|
997
|
+
}
|
|
904
998
|
}
|
|
905
|
-
return
|
|
999
|
+
return map;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* How wide the locale's groups are — 3-3 for `1,234,567`, 3-2 for the Indic
|
|
1004
|
+
* `12,34,567`. Measured on a number long enough to show both.
|
|
1005
|
+
*/
|
|
1006
|
+
function groupingOf(
|
|
1007
|
+
format: FormatNumber,
|
|
1008
|
+
group: string,
|
|
1009
|
+
digits: ReadonlyMap<string, string>,
|
|
1010
|
+
): GroupingSpec {
|
|
1011
|
+
const fallback: GroupingSpec = { primary: 3, secondary: 3 };
|
|
1012
|
+
let formatted: string;
|
|
1013
|
+
try {
|
|
1014
|
+
formatted = format("1234567890123");
|
|
1015
|
+
} catch {
|
|
1016
|
+
return fallback;
|
|
1017
|
+
}
|
|
1018
|
+
const segments = toAsciiDigits(formatted, digits).split(group);
|
|
1019
|
+
if (segments.length < 2) return fallback;
|
|
1020
|
+
// Code points, not UTF-16 units: a digit outside the BMP counts once.
|
|
1021
|
+
const lengths = segments.map((segment) => [...segment].length);
|
|
1022
|
+
const primary = lengths.at(-1) ?? 3;
|
|
1023
|
+
const secondary = lengths.at(-2) ?? primary;
|
|
1024
|
+
return { primary, secondary };
|
|
906
1025
|
}
|
|
907
1026
|
|
|
908
|
-
function
|
|
1027
|
+
function toAsciiDigits(
|
|
909
1028
|
input: string,
|
|
910
|
-
|
|
1029
|
+
digits: ReadonlyMap<string, string>,
|
|
911
1030
|
): string {
|
|
1031
|
+
if (digits.size === 0) return input;
|
|
1032
|
+
let out = "";
|
|
1033
|
+
for (const char of input) {
|
|
1034
|
+
out += digits.get(char) ?? char;
|
|
1035
|
+
}
|
|
1036
|
+
return out;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function normalizeLocalized(input: string, shape: LocaleNumberShape): string {
|
|
912
1040
|
const trimmed = input.trim();
|
|
913
1041
|
if (!trimmed) {
|
|
914
1042
|
throw new Error("Invalid localized decimal: empty string");
|
|
915
1043
|
}
|
|
916
1044
|
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
normalized = normalized.replace(new RegExp(escapeRegExp(minus), "g"), "-");
|
|
1045
|
+
let normalized = toAsciiDigits(trimmed, shape.digits);
|
|
1046
|
+
// `\s` already covers U+00A0 and U+202F, which is what the space-grouping
|
|
1047
|
+
// locales separate with.
|
|
1048
|
+
normalized = normalized.replace(/\s/g, "");
|
|
1049
|
+
if (shape.minus !== "-") {
|
|
1050
|
+
normalized = normalized.replace(
|
|
1051
|
+
new RegExp(escapeRegExp(shape.minus), "g"),
|
|
1052
|
+
"-",
|
|
1053
|
+
);
|
|
927
1054
|
}
|
|
928
1055
|
normalized = normalized.replace(/[−﹣-]/g, "-");
|
|
1056
|
+
// The locale's plus sign (e.g. U+FF0B `+`, U+FB29 `﬩`) becomes ASCII so the
|
|
1057
|
+
// strict validator below accepts it.
|
|
1058
|
+
if (shape.plusSign !== "+") {
|
|
1059
|
+
normalized = normalized.replace(
|
|
1060
|
+
new RegExp(escapeRegExp(shape.plusSign), "g"),
|
|
1061
|
+
"+",
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
929
1064
|
|
|
930
1065
|
if (/^\(.*\)$/.test(normalized)) {
|
|
931
1066
|
normalized = `-${normalized.slice(1, -1)}`;
|
|
@@ -933,12 +1068,18 @@ function normalizeLocaleNumber(
|
|
|
933
1068
|
|
|
934
1069
|
validateLocalizedSyntax(
|
|
935
1070
|
normalized,
|
|
936
|
-
group,
|
|
937
|
-
decimal,
|
|
938
|
-
|
|
1071
|
+
shape.group,
|
|
1072
|
+
shape.decimal,
|
|
1073
|
+
shape.grouping,
|
|
1074
|
+
);
|
|
1075
|
+
normalized = normalized.replace(
|
|
1076
|
+
new RegExp(escapeRegExp(shape.group), "g"),
|
|
1077
|
+
"",
|
|
1078
|
+
);
|
|
1079
|
+
normalized = normalized.replace(
|
|
1080
|
+
new RegExp(escapeRegExp(shape.decimal), "g"),
|
|
1081
|
+
".",
|
|
939
1082
|
);
|
|
940
|
-
normalized = normalized.replace(new RegExp(escapeRegExp(group), "g"), "");
|
|
941
|
-
normalized = normalized.replace(new RegExp(escapeRegExp(decimal), "g"), ".");
|
|
942
1083
|
|
|
943
1084
|
if (!/^[+-]?\d+(\.\d+)?$/.test(normalized)) {
|
|
944
1085
|
throw new Error(`Invalid localized decimal: ${input}`);
|
|
@@ -946,62 +1087,6 @@ function normalizeLocaleNumber(
|
|
|
946
1087
|
return normalized;
|
|
947
1088
|
}
|
|
948
1089
|
|
|
949
|
-
function normalizeLocaleDigits(
|
|
950
|
-
input: string,
|
|
951
|
-
locales?: Intl.LocalesArgument,
|
|
952
|
-
): string {
|
|
953
|
-
const digitMap = getLocaleDigitMap(locales);
|
|
954
|
-
let out = "";
|
|
955
|
-
for (const char of input) {
|
|
956
|
-
out += digitMap.get(char) ?? char;
|
|
957
|
-
}
|
|
958
|
-
return out;
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
function getLocaleDigitMap(
|
|
962
|
-
locales?: Intl.LocalesArgument,
|
|
963
|
-
): Map<string, string> {
|
|
964
|
-
const formatter = new Intl.NumberFormat(locales, { useGrouping: false });
|
|
965
|
-
const digits = formatter.format(9876543210);
|
|
966
|
-
const map = new Map<string, string>();
|
|
967
|
-
let value = 9;
|
|
968
|
-
for (const char of digits) {
|
|
969
|
-
if (!map.has(char) && value >= 0) {
|
|
970
|
-
map.set(char, String(value));
|
|
971
|
-
value--;
|
|
972
|
-
}
|
|
973
|
-
}
|
|
974
|
-
return map;
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
interface GroupingSpec {
|
|
978
|
-
primary: number;
|
|
979
|
-
secondary: number;
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
function getLocaleGrouping(locales?: Intl.LocalesArgument): GroupingSpec {
|
|
983
|
-
const parts = new Intl.NumberFormat(locales)
|
|
984
|
-
.formatToParts(1234567890123)
|
|
985
|
-
.filter((part) => part.type === "integer" || part.type === "group");
|
|
986
|
-
const lengths: number[] = [];
|
|
987
|
-
let current = 0;
|
|
988
|
-
for (const part of parts) {
|
|
989
|
-
if (part.type === "integer") {
|
|
990
|
-
current += [...part.value].length;
|
|
991
|
-
} else {
|
|
992
|
-
lengths.push(current);
|
|
993
|
-
current = 0;
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
lengths.push(current);
|
|
997
|
-
if (lengths.length < 2) {
|
|
998
|
-
return { primary: 3, secondary: 3 };
|
|
999
|
-
}
|
|
1000
|
-
const primary = lengths[lengths.length - 1];
|
|
1001
|
-
const secondary = lengths[lengths.length - 2] ?? primary;
|
|
1002
|
-
return { primary, secondary };
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
1090
|
function validateLocalizedSyntax(
|
|
1006
1091
|
value: string,
|
|
1007
1092
|
group: string,
|
|
@@ -1035,11 +1120,14 @@ function validateLocalizedSyntax(
|
|
|
1035
1120
|
}
|
|
1036
1121
|
for (let i = groups.length - 1, distance = 0; i >= 0; i--, distance++) {
|
|
1037
1122
|
const size = distance === 0 ? grouping.primary : grouping.secondary;
|
|
1123
|
+
const group = groups[i];
|
|
1124
|
+
if (group === undefined)
|
|
1125
|
+
throw new Error(`Invalid localized decimal: ${value}`);
|
|
1038
1126
|
if (i === 0) {
|
|
1039
|
-
if (
|
|
1127
|
+
if (group.length < 1 || group.length > size) {
|
|
1040
1128
|
throw new Error(`Invalid localized decimal: ${value}`);
|
|
1041
1129
|
}
|
|
1042
|
-
} else if (
|
|
1130
|
+
} else if (group.length !== size) {
|
|
1043
1131
|
throw new Error(`Invalid localized decimal: ${value}`);
|
|
1044
1132
|
}
|
|
1045
1133
|
}
|
package/src/Money.ts
CHANGED
|
@@ -7,18 +7,53 @@ export interface MoneyOptions {
|
|
|
7
7
|
mode?: RoundMode;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* What `times` and `div` take.
|
|
12
|
+
*
|
|
13
|
+
* The rounding mode, and only that. They used to declare the whole
|
|
14
|
+
* {@link MoneyOptions} and forward one field of it: `scale` and `exact` were
|
|
15
|
+
* overwritten on the way through, so `.times("1.5", { scale: 4 })` came back at
|
|
16
|
+
* the currency's scale and `.times("1.333", { exact: true })` rounded anyway.
|
|
17
|
+
* Neither could ever have been honoured — a multiplied amount of money is still
|
|
18
|
+
* money, and money is held at the scale its currency has.
|
|
19
|
+
*/
|
|
20
|
+
export interface MoneyRoundingOptions {
|
|
21
|
+
mode?: RoundMode;
|
|
22
|
+
}
|
|
23
|
+
|
|
10
24
|
export interface MoneyFormatOptions
|
|
11
25
|
extends Omit<Intl.NumberFormatOptions, "style" | "currency"> {
|
|
12
26
|
locale?: Intl.LocalesArgument;
|
|
13
27
|
}
|
|
14
28
|
|
|
29
|
+
/**
|
|
30
|
+
* ISO 4217 minor units, for every currency that does not have two.
|
|
31
|
+
*
|
|
32
|
+
* Only the exceptions are listed; anything absent is two, which is what the
|
|
33
|
+
* lookups below fall back to. Written as a partial list of the ones someone
|
|
34
|
+
* happened to think of, the fallback answered for the rest — and answered
|
|
35
|
+
* wrong. A `Money` built for ISK, whose króna has no subunit, was held at two
|
|
36
|
+
* decimals: `toMinorUnits()` returned 123400 for 1234 króna, a hundredfold
|
|
37
|
+
* error on its way into an integer column, `toString()` printed a fractional
|
|
38
|
+
* part the currency does not have, and `format()` asked Intl for two decimals
|
|
39
|
+
* it would otherwise have refused to print. JPY, three lines away in the same
|
|
40
|
+
* table, was correct — the difference between the two was which one someone
|
|
41
|
+
* had typed in.
|
|
42
|
+
*
|
|
43
|
+
* ISO is the source of truth here, not CLDR, and they disagree on one entry:
|
|
44
|
+
* ISO gives IQD three minor units, `Intl` prints zero. `format()` passes this
|
|
45
|
+
* scale to Intl explicitly, so the value keeps the fils ISO says it has.
|
|
46
|
+
*/
|
|
15
47
|
const ISO_MINOR_UNITS: Record<string, number> = Object.freeze({
|
|
16
48
|
BHD: 3,
|
|
49
|
+
BIF: 0,
|
|
17
50
|
CLF: 4,
|
|
18
51
|
CLP: 0,
|
|
19
52
|
DJF: 0,
|
|
20
|
-
|
|
21
|
-
|
|
53
|
+
GNF: 0,
|
|
54
|
+
IQD: 3,
|
|
55
|
+
ISK: 0,
|
|
56
|
+
JOD: 3,
|
|
22
57
|
JPY: 0,
|
|
23
58
|
KMF: 0,
|
|
24
59
|
KRW: 0,
|
|
@@ -26,9 +61,13 @@ const ISO_MINOR_UNITS: Record<string, number> = Object.freeze({
|
|
|
26
61
|
LYD: 3,
|
|
27
62
|
OMR: 3,
|
|
28
63
|
PYG: 0,
|
|
64
|
+
RWF: 0,
|
|
29
65
|
TND: 3,
|
|
30
|
-
|
|
66
|
+
UGX: 0,
|
|
67
|
+
UYI: 0,
|
|
68
|
+
UYW: 4,
|
|
31
69
|
VND: 0,
|
|
70
|
+
VUV: 0,
|
|
32
71
|
XAF: 0,
|
|
33
72
|
XOF: 0,
|
|
34
73
|
XPF: 0,
|
|
@@ -102,7 +141,7 @@ export class Money {
|
|
|
102
141
|
});
|
|
103
142
|
}
|
|
104
143
|
|
|
105
|
-
times(multiplier: DecimalInput, options:
|
|
144
|
+
times(multiplier: DecimalInput, options: MoneyRoundingOptions = {}): Money {
|
|
106
145
|
return new Money(this.#amount.times(multiplier), this.#currency, {
|
|
107
146
|
scale: this.#scale,
|
|
108
147
|
exact: false,
|
|
@@ -110,7 +149,7 @@ export class Money {
|
|
|
110
149
|
});
|
|
111
150
|
}
|
|
112
151
|
|
|
113
|
-
div(divisor: DecimalInput, options:
|
|
152
|
+
div(divisor: DecimalInput, options: MoneyRoundingOptions = {}): Money {
|
|
114
153
|
return new Money(this.#amount.div(divisor), this.#currency, {
|
|
115
154
|
scale: this.#scale,
|
|
116
155
|
exact: false,
|
package/src/atlas.ts
CHANGED
|
@@ -5,9 +5,8 @@
|
|
|
5
5
|
* column pipeline. Lives on a sub-export so the default `@c9up/atom` import
|
|
6
6
|
* surface stays adapter-free.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* boot-time wiring.
|
|
8
|
+
* The callbacks are baked into the entity definition: no global registry, no
|
|
9
|
+
* boot-time wiring, nothing to import in a provider.
|
|
11
10
|
*
|
|
12
11
|
* Usage:
|
|
13
12
|
*
|
|
@@ -19,8 +18,6 @@
|
|
|
19
18
|
* @PrimaryKey() id!: number
|
|
20
19
|
* @Column(decimalAtlasAdapter) balance!: Decimal | null
|
|
21
20
|
* }
|
|
22
|
-
*
|
|
23
|
-
* @implements Story 35.10
|
|
24
21
|
*/
|
|
25
22
|
|
|
26
23
|
import { Decimal, type RoundMode } from "./Decimal.js";
|