@stll/money 0.3.1 → 0.4.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/README.md +1 -1
- package/dist/index.d.ts +61 -16
- package/dist/index.js +194 -11
- package/package.json +4 -5
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ The package keeps money in integer minor units, rejects fractional cents, and
|
|
|
6
6
|
separates totals by currency so incompatible amounts cannot be combined.
|
|
7
7
|
|
|
8
8
|
```ts
|
|
9
|
-
import {
|
|
9
|
+
import { cents, MoneyTotals } from "@stll/money";
|
|
10
10
|
|
|
11
11
|
const totals = new MoneyTotals();
|
|
12
12
|
totals.add("EUR", cents(1250));
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
/**
|
|
3
|
-
* The minor-unit brand and its constructors.
|
|
4
|
-
*
|
|
5
|
-
* Separate from `index.ts` because `format.ts` mints amounts too: the
|
|
6
|
-
* conversion helpers there return a `CentsAmount`, and a module the package
|
|
7
|
-
* entry re-exports cannot import back from that entry without a cycle.
|
|
8
|
-
*/
|
|
1
|
+
import { Result } from "better-result";
|
|
9
2
|
declare const __cents: unique symbol;
|
|
10
3
|
type CentsAmount = number & {
|
|
11
4
|
readonly [__cents]: "CentsAmount";
|
|
@@ -13,7 +6,7 @@ type CentsAmount = number & {
|
|
|
13
6
|
/**
|
|
14
7
|
* Construct a CentsAmount from a value already known to be in minor
|
|
15
8
|
* units. Use at boundaries where the input is validated as an integer
|
|
16
|
-
* minor-unit value (e.g. after
|
|
9
|
+
* minor-unit value (e.g. after `minorUnitAmountSchema(...)` or after
|
|
17
10
|
* scaling a typed major-unit amount by the currency's exponent).
|
|
18
11
|
*
|
|
19
12
|
* SAFE integer, not merely integer: `Number.isInteger` is true for 2^53 and
|
|
@@ -30,8 +23,6 @@ export declare const cents: (value: number) => CentsAmount;
|
|
|
30
23
|
* comment naming why the value is already a valid minor-unit integer.
|
|
31
24
|
*/
|
|
32
25
|
export declare const unsafeCents: (value: number) => CentsAmount;
|
|
33
|
-
//#endregion
|
|
34
|
-
//#region src/format.d.ts
|
|
35
26
|
/**
|
|
36
27
|
* How many minor units make a major one, for this currency: 100 for CZK, 1 for
|
|
37
28
|
* JPY, 1000 for KWD. It is a property of the currency and not of the reader, so
|
|
@@ -41,6 +32,55 @@ export declare const unsafeCents: (value: number) => CentsAmount;
|
|
|
41
32
|
* `?? 2` on its result could help, so the fallback has to wrap the call.
|
|
42
33
|
*/
|
|
43
34
|
export declare const currencyMinorUnitDigits: (currency: string) => number;
|
|
35
|
+
type LocaleAmountParseError = {
|
|
36
|
+
reason: "empty" | "syntax" | "ambiguous" | "precision" | "unsafe-integer" | "locale";
|
|
37
|
+
message: string;
|
|
38
|
+
};
|
|
39
|
+
type ParseLocaleMinorUnitsParams = {
|
|
40
|
+
amount: string;
|
|
41
|
+
currency: string;
|
|
42
|
+
locale: string;
|
|
43
|
+
};
|
|
44
|
+
type ParseLocaleDecimalParams = {
|
|
45
|
+
amount: string;
|
|
46
|
+
locale: string;
|
|
47
|
+
maximumFractionDigits?: number;
|
|
48
|
+
};
|
|
49
|
+
declare const localeInputTextBrand: unique symbol;
|
|
50
|
+
/** Text whose separators and digits belong to a person's formatting locale. */
|
|
51
|
+
type LocaleInputText = string & {
|
|
52
|
+
readonly [localeInputTextBrand]: "LocaleInputText";
|
|
53
|
+
};
|
|
54
|
+
/** The control a person typed into: an input or textarea element. */
|
|
55
|
+
type LocaleInputSource = {
|
|
56
|
+
readonly value: string;
|
|
57
|
+
readonly tagName: string;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* The text of the control a person typed into. It takes the element, not its
|
|
61
|
+
* string, so canonical stored text cannot be passed off as locale input; stored
|
|
62
|
+
* values reach a field only through the redisplay formatters below.
|
|
63
|
+
*/
|
|
64
|
+
export declare const localeInputText: (source: LocaleInputSource) => LocaleInputText;
|
|
65
|
+
/** The text of a cleared locale input. */
|
|
66
|
+
export declare const EMPTY_LOCALE_INPUT_TEXT: LocaleInputText;
|
|
67
|
+
/** Normalize a human-entered locale decimal without converting through a float. */
|
|
68
|
+
export declare const parseLocaleDecimal: ({ amount, locale, maximumFractionDigits }: ParseLocaleDecimalParams) => Result<string, LocaleAmountParseError>;
|
|
69
|
+
/** Parse human-entered major units according to their formatting locale. */
|
|
70
|
+
export declare const parseLocaleMinorUnits: ({ amount, currency, locale }: ParseLocaleMinorUnitsParams) => Result<CentsAmount, LocaleAmountParseError>;
|
|
71
|
+
type FormatLocaleMinorUnitsInputParams = {
|
|
72
|
+
amountCents: number;
|
|
73
|
+
currency: string;
|
|
74
|
+
locale: string;
|
|
75
|
+
};
|
|
76
|
+
type FormatLocaleDecimalInputParams = {
|
|
77
|
+
amount: string;
|
|
78
|
+
locale: string;
|
|
79
|
+
};
|
|
80
|
+
/** Render canonical decimal text using locale digits and its decimal mark. */
|
|
81
|
+
export declare const formatLocaleDecimalInput: ({ amount, locale }: FormatLocaleDecimalInputParams) => LocaleInputText;
|
|
82
|
+
/** Render stored minor units as locale input text that parses back unchanged. */
|
|
83
|
+
export declare const formatLocaleMinorUnitsInput: ({ amountCents, currency, locale }: FormatLocaleMinorUnitsInputParams) => LocaleInputText;
|
|
44
84
|
type ToMinorUnitsParams = {
|
|
45
85
|
/**
|
|
46
86
|
* The amount in major units: the decimal text a form holds, or a number.
|
|
@@ -84,6 +124,14 @@ type ToMajorUnitsParams = {
|
|
|
84
124
|
};
|
|
85
125
|
/** The inverse: a stored amount as the major-unit number a person reads. */
|
|
86
126
|
export declare const toMajorUnits: ({ amountCents, currency }: ToMajorUnitsParams) => number;
|
|
127
|
+
/**
|
|
128
|
+
* A stored amount as exact major-unit decimal text, with no grouping and a
|
|
129
|
+
* dot separator: 123456 EUR is "1234.56", 1500 JPY is "1500". The digits are
|
|
130
|
+
* moved, not divided, so a value near the safe-integer edge keeps every minor
|
|
131
|
+
* unit that `toMajorUnits` would round away. For machine readers (a prompt,
|
|
132
|
+
* an export), not for display; display goes through `formatMoneyCents`.
|
|
133
|
+
*/
|
|
134
|
+
export declare const toMajorUnitsText: ({ amountCents, currency }: ToMajorUnitsParams) => string;
|
|
87
135
|
type FormatMoneyCentsParams = {
|
|
88
136
|
amountCents: number;
|
|
89
137
|
currency: string;
|
|
@@ -103,8 +151,6 @@ type FormatMoneyCentsParams = {
|
|
|
103
151
|
* RangeError would take the whole board down with it.
|
|
104
152
|
*/
|
|
105
153
|
export declare const formatMoneyCents: ({ amountCents, currency, locale, fractionDigits }: FormatMoneyCentsParams) => string;
|
|
106
|
-
//#endregion
|
|
107
|
-
//#region src/index.d.ts
|
|
108
154
|
export type ProrateHourlyCentsInput = {
|
|
109
155
|
billedMinutes: number;
|
|
110
156
|
hourlyRateCents: CentsAmount;
|
|
@@ -156,7 +202,7 @@ type IsSingletonCurrency<C extends string> = string extends C ? never : [C] exte
|
|
|
156
202
|
* Both operands are further constrained by `IsSingletonCurrency` to reject
|
|
157
203
|
* any non-singleton currency type: the WIDE `CurrencyCents<string>`, and
|
|
158
204
|
* also a finite union such as `CurrencyCents<"USD" | "EUR">` (e.g. from a
|
|
159
|
-
* validator's `
|
|
205
|
+
* validator's `v.picklist`). A currency read back from a DB row or
|
|
160
206
|
* narrowed only to a finite set of allowed codes types as `string` or a
|
|
161
207
|
* union, not a single literal, so without this, `addCents(currencyCents(a,
|
|
162
208
|
* x), currencyCents(b, y))` would still typecheck even when `a`/`b` are
|
|
@@ -204,5 +250,4 @@ export declare class MoneyTotals {
|
|
|
204
250
|
*/
|
|
205
251
|
entries(): MoneyTotalsEntry[];
|
|
206
252
|
}
|
|
207
|
-
|
|
208
|
-
export type { CentsAmount, FormatMoneyCentsParams, ToMajorUnitsParams, ToMinorUnitsParams };
|
|
253
|
+
export type { CentsAmount, FormatLocaleDecimalInputParams, FormatLocaleMinorUnitsInputParams, FormatMoneyCentsParams, LocaleAmountParseError, LocaleInputSource, LocaleInputText, ParseLocaleDecimalParams, ParseLocaleMinorUnitsParams, ToMajorUnitsParams, ToMinorUnitsParams };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Result, panic } from "better-result";
|
|
2
|
-
|
|
2
|
+
// src/cents.ts
|
|
3
3
|
/**
|
|
4
4
|
* The minor-unit brand and its constructors.
|
|
5
5
|
*
|
|
@@ -12,7 +12,7 @@ const isMinorUnitAmount = (value) => Number.isSafeInteger(value);
|
|
|
12
12
|
/**
|
|
13
13
|
* Construct a CentsAmount from a value already known to be in minor
|
|
14
14
|
* units. Use at boundaries where the input is validated as an integer
|
|
15
|
-
* minor-unit value (e.g. after
|
|
15
|
+
* minor-unit value (e.g. after `minorUnitAmountSchema(...)` or after
|
|
16
16
|
* scaling a typed major-unit amount by the currency's exponent).
|
|
17
17
|
*
|
|
18
18
|
* SAFE integer, not merely integer: `Number.isInteger` is true for 2^53 and
|
|
@@ -32,8 +32,7 @@ const cents = (value) => {
|
|
|
32
32
|
* comment naming why the value is already a valid minor-unit integer.
|
|
33
33
|
*/
|
|
34
34
|
const unsafeCents = (value) => value;
|
|
35
|
-
|
|
36
|
-
//#region src/format.ts
|
|
35
|
+
// src/format.ts
|
|
37
36
|
/**
|
|
38
37
|
* Moving between a stored minor-unit amount and the major-unit number a
|
|
39
38
|
* person types, and rendering either as text.
|
|
@@ -72,6 +71,176 @@ const currencyMinorUnitDigits = (currency) => {
|
|
|
72
71
|
};
|
|
73
72
|
/** A decimal amount in major units: an optional sign, digits, an optional point. */
|
|
74
73
|
const DECIMAL_AMOUNT = /^(?<sign>[+-]?)(?<whole>\d*)(?:\.(?<fraction>\d*))?$/u;
|
|
74
|
+
const localeSeparators = (locale) => Result.try(() => {
|
|
75
|
+
const formatter = new Intl.NumberFormat(locale, { useGrouping: false });
|
|
76
|
+
const groupedParts = new Intl.NumberFormat(locale).formatToParts(1234567890123);
|
|
77
|
+
const digits = /* @__PURE__ */ new Map();
|
|
78
|
+
for (let digit = 0; digit <= 9; digit += 1) digits.set(formatter.format(digit), String(digit));
|
|
79
|
+
const integerGroups = groupedParts.filter((part) => part.type === "integer").map((part) => part.value.length);
|
|
80
|
+
return {
|
|
81
|
+
decimal: new Intl.NumberFormat(locale).formatToParts(12345.6).find((part) => part.type === "decimal")?.value ?? ".",
|
|
82
|
+
group: groupedParts.find((part) => part.type === "group")?.value,
|
|
83
|
+
primaryGroupSize: integerGroups.at(-1) ?? 3,
|
|
84
|
+
secondaryGroupSize: integerGroups.at(-2) ?? integerGroups.at(-1) ?? 3,
|
|
85
|
+
digits
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
const normalizeGroupedWhole = ({ whole, grouping, primaryGroupSize, secondaryGroupSize }) => {
|
|
89
|
+
const groups = whole.split(grouping);
|
|
90
|
+
const leadingGroup = groups.at(0) ?? "";
|
|
91
|
+
const trailingGroup = groups.at(-1) ?? "";
|
|
92
|
+
const middleGroups = groups.slice(1, -1);
|
|
93
|
+
if (!new RegExp(`^[0-9]{1,${secondaryGroupSize}}$`, "u").test(leadingGroup) || !new RegExp(`^[0-9]{${primaryGroupSize}}$`, "u").test(trailingGroup) || middleGroups.some((part) => !new RegExp(`^[0-9]{${secondaryGroupSize}}$`, "u").test(part))) return null;
|
|
94
|
+
return groups.join("");
|
|
95
|
+
};
|
|
96
|
+
const ASCII_DECIMAL_MARKS = [".", ","];
|
|
97
|
+
const normalizeInputSeparators = ({ unsigned, decimal, group, primaryGroupSize }) => {
|
|
98
|
+
const alternateDecimal = ASCII_DECIMAL_MARKS.filter((mark) => mark !== decimal).find((mark) => unsigned.includes(mark)) ?? (decimal === "," ? "." : ",");
|
|
99
|
+
const alternateParts = unsigned.split(alternateDecimal);
|
|
100
|
+
const hasLocaleDecimal = unsigned.includes(decimal);
|
|
101
|
+
const hasSingleAlternate = alternateParts.length === 2;
|
|
102
|
+
const alternateFractionLength = alternateParts.at(1)?.length;
|
|
103
|
+
if (!hasLocaleDecimal && hasSingleAlternate && alternateFractionLength === primaryGroupSize) return { status: "ambiguous" };
|
|
104
|
+
if (!hasLocaleDecimal && hasSingleAlternate && alternateFractionLength !== primaryGroupSize) return {
|
|
105
|
+
status: "normalized",
|
|
106
|
+
unsigned: unsigned.replace(alternateDecimal, () => decimal),
|
|
107
|
+
grouping: group
|
|
108
|
+
};
|
|
109
|
+
let grouping = group;
|
|
110
|
+
if (decimal === "," && unsigned.split(decimal).at(0)?.includes(".")) grouping = ".";
|
|
111
|
+
return {
|
|
112
|
+
status: "normalized",
|
|
113
|
+
unsigned,
|
|
114
|
+
grouping
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* The fraction kept at the allowed places, undefined when none is left, or
|
|
119
|
+
* null when a digit past them is not zero. Trailing zeros carry no value
|
|
120
|
+
* (100.00 yen is 100 yen); any other extra digit would need rounding.
|
|
121
|
+
*/
|
|
122
|
+
const exactFraction = (fraction, maximumFractionDigits) => {
|
|
123
|
+
if (fraction === void 0 || maximumFractionDigits === void 0 || fraction.length <= maximumFractionDigits) return fraction;
|
|
124
|
+
if (!/^0*$/u.test(fraction.slice(maximumFractionDigits))) return null;
|
|
125
|
+
const kept = fraction.slice(0, maximumFractionDigits);
|
|
126
|
+
return kept === "" ? void 0 : kept;
|
|
127
|
+
};
|
|
128
|
+
function brandLocaleInputText(value) {
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The text of the control a person typed into. It takes the element, not its
|
|
133
|
+
* string, so canonical stored text cannot be passed off as locale input; stored
|
|
134
|
+
* values reach a field only through the redisplay formatters below.
|
|
135
|
+
*/
|
|
136
|
+
const localeInputText = (source) => brandLocaleInputText(source.value);
|
|
137
|
+
/** The text of a cleared locale input. */
|
|
138
|
+
const EMPTY_LOCALE_INPUT_TEXT = brandLocaleInputText("");
|
|
139
|
+
/** Normalize a human-entered locale decimal without converting through a float. */
|
|
140
|
+
const parseLocaleDecimal = ({ amount, locale, maximumFractionDigits }) => {
|
|
141
|
+
const text = amount.trim();
|
|
142
|
+
if (text.length === 0) return Result.err({
|
|
143
|
+
reason: "empty",
|
|
144
|
+
message: "Amount is empty"
|
|
145
|
+
});
|
|
146
|
+
const separators = localeSeparators(locale);
|
|
147
|
+
if (separators.isErr()) return Result.err({
|
|
148
|
+
reason: "locale",
|
|
149
|
+
message: "Invalid locale"
|
|
150
|
+
});
|
|
151
|
+
const { decimal, group, primaryGroupSize, secondaryGroupSize, digits } = separators.value;
|
|
152
|
+
const whitespaceGroup = group !== void 0 && /^\s$/u.test(group);
|
|
153
|
+
let localizedDigits = "";
|
|
154
|
+
for (const character of text) localizedDigits += digits.get(character) ?? character;
|
|
155
|
+
const normalizedWhitespace = whitespaceGroup ? localizedDigits.replace(/[\u0020\u00a0\u202f]/gu, () => group) : localizedDigits;
|
|
156
|
+
const sign = /^[+-]/u.exec(normalizedWhitespace)?.at(0) ?? "";
|
|
157
|
+
const unsignedInput = sign ? normalizedWhitespace.slice(1) : normalizedWhitespace;
|
|
158
|
+
const normalizedSeparators = normalizeInputSeparators({
|
|
159
|
+
unsigned: unsignedInput,
|
|
160
|
+
decimal,
|
|
161
|
+
group,
|
|
162
|
+
primaryGroupSize
|
|
163
|
+
});
|
|
164
|
+
if (normalizedSeparators.status === "ambiguous") return Result.err({
|
|
165
|
+
reason: "ambiguous",
|
|
166
|
+
message: "Amount has an ambiguous separator"
|
|
167
|
+
});
|
|
168
|
+
const { unsigned, grouping } = normalizedSeparators;
|
|
169
|
+
const decimalParts = unsigned.split(decimal);
|
|
170
|
+
if (decimalParts.length > 2 || decimalParts.length === 1 && decimalParts.at(0)?.length === 0 || decimalParts.length === 2 && decimalParts.at(1)?.length === 0) return Result.err({
|
|
171
|
+
reason: "syntax",
|
|
172
|
+
message: "Invalid amount format"
|
|
173
|
+
});
|
|
174
|
+
const whole = decimalParts.at(0) || "0";
|
|
175
|
+
const fraction = decimalParts.at(1);
|
|
176
|
+
let wholeDigits = whole;
|
|
177
|
+
if (grouping !== void 0 && whole.includes(grouping)) {
|
|
178
|
+
const normalizedWhole = normalizeGroupedWhole({
|
|
179
|
+
whole,
|
|
180
|
+
grouping,
|
|
181
|
+
primaryGroupSize,
|
|
182
|
+
secondaryGroupSize
|
|
183
|
+
});
|
|
184
|
+
if (normalizedWhole === null) return Result.err({
|
|
185
|
+
reason: "syntax",
|
|
186
|
+
message: "Invalid amount grouping"
|
|
187
|
+
});
|
|
188
|
+
wholeDigits = normalizedWhole;
|
|
189
|
+
}
|
|
190
|
+
if (!/^[0-9]+$/u.test(wholeDigits) || fraction !== void 0 && !/^[0-9]+$/u.test(fraction)) return Result.err({
|
|
191
|
+
reason: "syntax",
|
|
192
|
+
message: "Invalid amount format"
|
|
193
|
+
});
|
|
194
|
+
const exact = exactFraction(fraction, maximumFractionDigits);
|
|
195
|
+
if (exact === null) return Result.err({
|
|
196
|
+
reason: "precision",
|
|
197
|
+
message: "Amount has too many fraction digits"
|
|
198
|
+
});
|
|
199
|
+
return Result.ok(`${sign}${wholeDigits}${exact === void 0 ? "" : `.${exact}`}`);
|
|
200
|
+
};
|
|
201
|
+
/** Parse human-entered major units according to their formatting locale. */
|
|
202
|
+
const parseLocaleMinorUnits = ({ amount, currency, locale }) => {
|
|
203
|
+
const normalized = parseLocaleDecimal({
|
|
204
|
+
amount,
|
|
205
|
+
locale,
|
|
206
|
+
maximumFractionDigits: currencyMinorUnitDigits(currency)
|
|
207
|
+
});
|
|
208
|
+
if (normalized.isErr()) return normalized;
|
|
209
|
+
const parsed = tryToMinorUnits({
|
|
210
|
+
amount: normalized.value,
|
|
211
|
+
currency
|
|
212
|
+
});
|
|
213
|
+
return parsed === null ? Result.err({
|
|
214
|
+
reason: "unsafe-integer",
|
|
215
|
+
message: "Amount exceeds the supported range"
|
|
216
|
+
}) : Result.ok(parsed);
|
|
217
|
+
};
|
|
218
|
+
const localizeAsciiDigits = (value, digits) => {
|
|
219
|
+
const localizedByAscii = new Map([...digits].map(([localized, ascii]) => [ascii, localized]));
|
|
220
|
+
return value.split("").map((character) => localizedByAscii.get(character) ?? character).join("");
|
|
221
|
+
};
|
|
222
|
+
/** Render canonical decimal text using locale digits and its decimal mark. */
|
|
223
|
+
const formatLocaleDecimalInput = ({ amount, locale }) => {
|
|
224
|
+
const groups = DECIMAL_AMOUNT.exec(amount)?.groups;
|
|
225
|
+
const whole = groups?.["whole"] ?? "";
|
|
226
|
+
const fraction = groups?.["fraction"];
|
|
227
|
+
if (groups === void 0 || `${whole}${fraction ?? ""}`.length === 0) return panic(`formatLocaleDecimalInput(${JSON.stringify(amount)}): not a canonical decimal`);
|
|
228
|
+
const separators = localeSeparators(locale).unwrap("formatLocaleDecimalInput: invalid locale");
|
|
229
|
+
const canonical = `${groups["sign"] ?? ""}${whole || "0"}${fraction === void 0 ? "" : `${separators.decimal}${fraction}`}`;
|
|
230
|
+
return brandLocaleInputText(localizeAsciiDigits(canonical, separators.digits));
|
|
231
|
+
};
|
|
232
|
+
/** Render stored minor units as locale input text that parses back unchanged. */
|
|
233
|
+
const formatLocaleMinorUnitsInput = ({ amountCents, currency, locale }) => {
|
|
234
|
+
const fractionDigits = currencyMinorUnitDigits(currency);
|
|
235
|
+
const sign = amountCents < 0 ? "-" : "";
|
|
236
|
+
const digits = String(Math.abs(amountCents)).padStart(fractionDigits + 1, "0");
|
|
237
|
+
const split = digits.length - fractionDigits;
|
|
238
|
+
const canonical = fractionDigits === 0 ? `${sign}${digits}` : `${sign}${digits.slice(0, split)}.${digits.slice(split)}`;
|
|
239
|
+
return formatLocaleDecimalInput({
|
|
240
|
+
amount: canonical,
|
|
241
|
+
locale
|
|
242
|
+
});
|
|
243
|
+
};
|
|
75
244
|
/** Scientific notation, which `String(1e21)` and `String(1e-7)` both produce. */
|
|
76
245
|
const EXPONENT_AMOUNT = /^(?<sign>[+-]?)(?<whole>\d+)(?:\.(?<fraction>\d+))?[eE](?<exponent>[+-]?\d+)$/u;
|
|
77
246
|
/**
|
|
@@ -163,6 +332,21 @@ const tryToMinorUnits = ({ amount, currency }) => {
|
|
|
163
332
|
/** The inverse: a stored amount as the major-unit number a person reads. */
|
|
164
333
|
const toMajorUnits = ({ amountCents, currency }) => amountCents / 10 ** currencyMinorUnitDigits(currency);
|
|
165
334
|
/**
|
|
335
|
+
* A stored amount as exact major-unit decimal text, with no grouping and a
|
|
336
|
+
* dot separator: 123456 EUR is "1234.56", 1500 JPY is "1500". The digits are
|
|
337
|
+
* moved, not divided, so a value near the safe-integer edge keeps every minor
|
|
338
|
+
* unit that `toMajorUnits` would round away. For machine readers (a prompt,
|
|
339
|
+
* an export), not for display; display goes through `formatMoneyCents`.
|
|
340
|
+
*/
|
|
341
|
+
const toMajorUnitsText = ({ amountCents, currency }) => {
|
|
342
|
+
const digits = currencyMinorUnitDigits(currency);
|
|
343
|
+
const magnitude = BigInt(Math.abs(amountCents)).toString();
|
|
344
|
+
const sign = amountCents < 0 ? "-" : "";
|
|
345
|
+
if (digits === 0) return `${sign}${magnitude}`;
|
|
346
|
+
const padded = magnitude.padStart(digits + 1, "0");
|
|
347
|
+
return `${sign}${padded.slice(0, -digits)}.${padded.slice(-digits)}`;
|
|
348
|
+
};
|
|
349
|
+
/**
|
|
166
350
|
* Money is stored in minor units, and how many of them make a major one is a
|
|
167
351
|
* property of the currency. Ask the currency rather than assuming a hundred.
|
|
168
352
|
*
|
|
@@ -200,8 +384,7 @@ const formatMoneyCents = ({ amountCents, currency, locale, fractionDigits }) =>
|
|
|
200
384
|
});
|
|
201
385
|
return formatted.isErr() ? `${negative ? "-" : ""}${whole}${digits === 0 ? "" : `.${fraction}`} ${currency}` : formatted.value;
|
|
202
386
|
};
|
|
203
|
-
|
|
204
|
-
//#region src/index.ts
|
|
387
|
+
// src/index.ts
|
|
205
388
|
/**
|
|
206
389
|
* Branded monetary amounts and minor-unit billing arithmetic.
|
|
207
390
|
*
|
|
@@ -212,8 +395,9 @@ const formatMoneyCents = ({ amountCents, currency, locale, fractionDigits }) =>
|
|
|
212
395
|
*
|
|
213
396
|
* The brand lives in this package, so it threads end to end:
|
|
214
397
|
* the same `CentsAmount` flows from a Drizzle column declared with
|
|
215
|
-
* `.$type<CentsAmount>()`, across the API boundary (
|
|
216
|
-
* brand from the handler's return type), into browser previews
|
|
398
|
+
* `.$type<CentsAmount>()`, across the API boundary (the API route types
|
|
399
|
+
* carry the brand from the handler's return type), into browser previews
|
|
400
|
+
* and back.
|
|
217
401
|
* A plain `number` is not assignable to `CentsAmount`; mint one with
|
|
218
402
|
* `cents()` after validating minor-unit input, or `unsafeCents()` at a
|
|
219
403
|
* documented boundary.
|
|
@@ -263,7 +447,7 @@ const currencyCents = (currency, amount) => {
|
|
|
263
447
|
* Both operands are further constrained by `IsSingletonCurrency` to reject
|
|
264
448
|
* any non-singleton currency type: the WIDE `CurrencyCents<string>`, and
|
|
265
449
|
* also a finite union such as `CurrencyCents<"USD" | "EUR">` (e.g. from a
|
|
266
|
-
* validator's `
|
|
450
|
+
* validator's `v.picklist`). A currency read back from a DB row or
|
|
267
451
|
* narrowed only to a finite set of allowed codes types as `string` or a
|
|
268
452
|
* union, not a single literal, so without this, `addCents(currencyCents(a,
|
|
269
453
|
* x), currencyCents(b, y))` would still typecheck even when `a`/`b` are
|
|
@@ -296,5 +480,4 @@ var MoneyTotals = class {
|
|
|
296
480
|
}));
|
|
297
481
|
}
|
|
298
482
|
};
|
|
299
|
-
|
|
300
|
-
export { MoneyTotals, addCents, applyMarkupCents, cents, currencyCents, currencyMinorUnitDigits, formatMoneyCents, prorateHourlyCents, timeEntryAmount, toMajorUnits, toMinorUnits, tryToMinorUnits, unsafeCents };
|
|
483
|
+
export { EMPTY_LOCALE_INPUT_TEXT, MoneyTotals, addCents, applyMarkupCents, cents, currencyCents, currencyMinorUnitDigits, formatLocaleDecimalInput, formatLocaleMinorUnitsInput, formatMoneyCents, localeInputText, parseLocaleDecimal, parseLocaleMinorUnits, prorateHourlyCents, timeEntryAmount, toMajorUnits, toMajorUnitsText, toMinorUnits, tryToMinorUnits, unsafeCents };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stll/money",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Branded minor-unit monetary amounts and currency-safe billing arithmetic.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"billing",
|
|
@@ -39,10 +39,9 @@
|
|
|
39
39
|
"build": "tsdown",
|
|
40
40
|
"pack:dry-run": "bun pm pack --dry-run",
|
|
41
41
|
"test": "bun test src",
|
|
42
|
-
"typecheck": "bun
|
|
42
|
+
"typecheck": "bun check --no-pretty --all --project=tsconfig.json",
|
|
43
43
|
"lint": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --report-unused-disable-directives-severity=error --type-aware --type-check packages/money",
|
|
44
44
|
"lint:fix": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --type-aware --fix packages/money",
|
|
45
|
-
"format": "bun ../../scripts/run-oxfmt.ts .",
|
|
46
45
|
"prepack": "bun run build"
|
|
47
46
|
},
|
|
48
47
|
"dependencies": {
|
|
@@ -50,8 +49,8 @@
|
|
|
50
49
|
},
|
|
51
50
|
"devDependencies": {
|
|
52
51
|
"@stll/typescript-config": "0.0.0",
|
|
53
|
-
"bun-types": "1.4.
|
|
54
|
-
"tsdown": "0.23.
|
|
52
|
+
"bun-types": "1.4.4",
|
|
53
|
+
"tsdown": "0.23.4"
|
|
55
54
|
},
|
|
56
55
|
"main": "./dist/index.js",
|
|
57
56
|
"types": "./dist/index.d.ts"
|