@stll/money 0.0.1-placeholder.0 → 0.2.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 +15 -0
- package/dist/index.d.ts +202 -0
- package/dist/index.js +274 -0
- package/package.json +55 -3
package/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# @stll/money
|
|
2
|
+
|
|
3
|
+
Branded minor-unit monetary values and exact billing arithmetic for Stella.
|
|
4
|
+
|
|
5
|
+
The package keeps money in integer minor units, rejects fractional cents, and
|
|
6
|
+
separates totals by currency so incompatible amounts cannot be combined.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { MoneyTotals, cents } from "@stll/money";
|
|
10
|
+
|
|
11
|
+
const totals = new MoneyTotals();
|
|
12
|
+
totals.add("EUR", cents(1250));
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Public API changes require a changeset.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
//#region src/cents.d.ts
|
|
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
|
+
*/
|
|
9
|
+
declare const __cents: unique symbol;
|
|
10
|
+
type CentsAmount = number & {
|
|
11
|
+
readonly [__cents]: "CentsAmount";
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Construct a CentsAmount from a value already known to be in minor
|
|
15
|
+
* units. Use at boundaries where the input is validated as an integer
|
|
16
|
+
* minor-unit value (e.g. after Elysia `tMinorUnitAmount(...)` or after
|
|
17
|
+
* scaling a typed major-unit amount by the currency's exponent).
|
|
18
|
+
*
|
|
19
|
+
* SAFE integer, not merely integer: `Number.isInteger` is true for 2^53 and
|
|
20
|
+
* everything above it, where the spacing between representable doubles is
|
|
21
|
+
* larger than one minor unit, so `x + 1 === x` and a total silently stops
|
|
22
|
+
* moving. An amount that far out is a caller defect rather than a runtime
|
|
23
|
+
* condition, like a fractional one, so it panics the same way.
|
|
24
|
+
*/
|
|
25
|
+
declare const cents: (value: number) => CentsAmount;
|
|
26
|
+
/**
|
|
27
|
+
* Escape hatch for code paths that genuinely need to attach the brand
|
|
28
|
+
* without a runtime check (test fixtures, generated code). Prefer
|
|
29
|
+
* `cents()` everywhere else; reach for this only with a `// SAFETY:`
|
|
30
|
+
* comment naming why the value is already a valid minor-unit integer.
|
|
31
|
+
*/
|
|
32
|
+
declare const unsafeCents: (value: number) => CentsAmount;
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/format.d.ts
|
|
35
|
+
/**
|
|
36
|
+
* How many minor units make a major one, for this currency: 100 for CZK, 1 for
|
|
37
|
+
* JPY, 1000 for KWD. It is a property of the currency and not of the reader, so
|
|
38
|
+
* the lookup is deliberately locale-independent.
|
|
39
|
+
*
|
|
40
|
+
* A malformed code makes the `Intl.NumberFormat` constructor throw, before any
|
|
41
|
+
* `?? 2` on its result could help, so the fallback has to wrap the call.
|
|
42
|
+
*/
|
|
43
|
+
declare const currencyMinorUnitDigits: (currency: string) => number;
|
|
44
|
+
type ToMinorUnitsParams = {
|
|
45
|
+
/**
|
|
46
|
+
* The amount in major units: the decimal text a form holds, or a number.
|
|
47
|
+
* Text is preferred where the caller has it, because it is what the person
|
|
48
|
+
* typed rather than the nearest double to it.
|
|
49
|
+
*/
|
|
50
|
+
amount: number | string;
|
|
51
|
+
currency: string;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* A typed major-unit amount as the minor units the currency actually counts:
|
|
55
|
+
* 12.5 USD is 1250, 1500 JPY is 1500, 12.5 KWD is 12500.
|
|
56
|
+
*
|
|
57
|
+
* The scaling is decimal, not a float multiply. `1.005 * 100` is
|
|
58
|
+
* 100.49999999999999 in binary floating point, so `Math.round` of it is 100
|
|
59
|
+
* and a $1.005 line item silently loses a cent; the same shortfall appears at
|
|
60
|
+
* a different decimal for every currency. Splitting the text on the point and
|
|
61
|
+
* moving the digits instead keeps the amount the person typed: the kept
|
|
62
|
+
* fraction digits are appended to the whole part, and the first digit dropped
|
|
63
|
+
* decides a half-up carry on the magnitude.
|
|
64
|
+
*
|
|
65
|
+
* Rounding is still the point -- a typed amount carries more places than the
|
|
66
|
+
* currency has -- and it happens on digits, exactly. Text this cannot parse,
|
|
67
|
+
* and a result outside the safe integer range, are caller defects: gate the
|
|
68
|
+
* text with `isDecimalAmount` first.
|
|
69
|
+
*/
|
|
70
|
+
declare const toMinorUnits: (params: ToMinorUnitsParams) => CentsAmount;
|
|
71
|
+
/**
|
|
72
|
+
* The same conversion for text nobody has vouched for yet: null when the text
|
|
73
|
+
* is not a decimal amount, and null when the scaled value would leave the safe
|
|
74
|
+
* integer range, where a total silently stops adding up.
|
|
75
|
+
*
|
|
76
|
+
* A form holds whatever was typed and has to be able to decline it. Everything
|
|
77
|
+
* past that gate calls `toMinorUnits`, which panics, because by then the
|
|
78
|
+
* decision has been made.
|
|
79
|
+
*/
|
|
80
|
+
declare const tryToMinorUnits: ({ amount, currency }: ToMinorUnitsParams) => CentsAmount | null;
|
|
81
|
+
type ToMajorUnitsParams = {
|
|
82
|
+
amountCents: number;
|
|
83
|
+
currency: string;
|
|
84
|
+
};
|
|
85
|
+
/** The inverse: a stored amount as the major-unit number a person reads. */
|
|
86
|
+
declare const toMajorUnits: ({ amountCents, currency }: ToMajorUnitsParams) => number;
|
|
87
|
+
type FormatMoneyCentsParams = {
|
|
88
|
+
amountCents: number;
|
|
89
|
+
currency: string;
|
|
90
|
+
locale: string;
|
|
91
|
+
/**
|
|
92
|
+
* Digits to show, minimum and maximum alike. Defaults to the currency's own
|
|
93
|
+
* exponent; pass 0 for a rounded summary that has no room for decimals.
|
|
94
|
+
*/
|
|
95
|
+
fractionDigits?: number;
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Money is stored in minor units, and how many of them make a major one is a
|
|
99
|
+
* property of the currency. Ask the currency rather than assuming a hundred.
|
|
100
|
+
*
|
|
101
|
+
* A code `Intl` rejects falls back to the amount beside the raw code: a column
|
|
102
|
+
* showing "15.00 A1C" is wrong-looking data, which is the truth, where a thrown
|
|
103
|
+
* RangeError would take the whole board down with it.
|
|
104
|
+
*/
|
|
105
|
+
declare const formatMoneyCents: ({ amountCents, currency, locale, fractionDigits }: FormatMoneyCentsParams) => string;
|
|
106
|
+
//#endregion
|
|
107
|
+
//#region src/index.d.ts
|
|
108
|
+
type ProrateHourlyCentsInput = {
|
|
109
|
+
billedMinutes: number;
|
|
110
|
+
hourlyRateCents: CentsAmount;
|
|
111
|
+
};
|
|
112
|
+
declare const prorateHourlyCents: ({ billedMinutes, hourlyRateCents }: ProrateHourlyCentsInput) => CentsAmount;
|
|
113
|
+
type ApplyMarkupCentsInput = {
|
|
114
|
+
amountCents: CentsAmount;
|
|
115
|
+
markupPercent: number;
|
|
116
|
+
};
|
|
117
|
+
declare const applyMarkupCents: ({ amountCents, markupPercent }: ApplyMarkupCentsInput) => CentsAmount;
|
|
118
|
+
declare const __currency: unique symbol;
|
|
119
|
+
/**
|
|
120
|
+
* A `CentsAmount` additionally branded with its ISO 4217-ish currency code
|
|
121
|
+
* `C`. This makes cross-currency addition a compile error instead of a
|
|
122
|
+
* runtime bug: `addCents` only accepts two `CurrencyCents` sharing the same
|
|
123
|
+
* `C`, so `addCents(usdAmount, eurAmount)` fails to typecheck rather than
|
|
124
|
+
* silently producing a meaningless sum.
|
|
125
|
+
*
|
|
126
|
+
* Mint one with `currencyCents()`; there is no unsafe escape hatch because
|
|
127
|
+
* the underlying `CentsAmount` validation (`cents()`) is cheap and the
|
|
128
|
+
* currency code is a plain string carried alongside it, so there is no
|
|
129
|
+
* boundary that needs to skip it.
|
|
130
|
+
*/
|
|
131
|
+
type CurrencyCents<C extends string = string> = CentsAmount & {
|
|
132
|
+
readonly [__currency]: C;
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* Construct a `CurrencyCents<C>` from a currency code and a minor-unit
|
|
136
|
+
* amount. The only producer of `CurrencyCents`; downstream code narrows `C`
|
|
137
|
+
* from the literal `currency` argument (e.g. `currencyCents("USD", 100)`
|
|
138
|
+
* infers `CurrencyCents<"USD">`).
|
|
139
|
+
*/
|
|
140
|
+
declare const currencyCents: <C extends string>(currency: C, amount: number) => CurrencyCents<C>;
|
|
141
|
+
type UnionToIntersection<U> = (U extends unknown ? (x: U) => void : never) extends ((x: infer I) => void) ? I : never;
|
|
142
|
+
type IsSingletonCurrency<C extends string> = string extends C ? never : [C] extends [UnionToIntersection<C>] ? C : never;
|
|
143
|
+
/**
|
|
144
|
+
* Add two `CurrencyCents` amounts of the SAME currency. The second
|
|
145
|
+
* parameter's currency `B` is constrained to `extends A`, so passing a
|
|
146
|
+
* different currency literal (e.g. `addCents(usdAmount, eurAmount)`) is a
|
|
147
|
+
* compile error, not a runtime bug — see `packages/money/src/index.test.ts`
|
|
148
|
+
* for the `@ts-expect-error` proof.
|
|
149
|
+
*
|
|
150
|
+
* Both operands are further constrained by `IsSingletonCurrency` to reject
|
|
151
|
+
* any non-singleton currency type: the WIDE `CurrencyCents<string>`, and
|
|
152
|
+
* also a finite union such as `CurrencyCents<"USD" | "EUR">` (e.g. from a
|
|
153
|
+
* validator's `t.UnionEnum`). A currency read back from a DB row or
|
|
154
|
+
* narrowed only to a finite set of allowed codes types as `string` or a
|
|
155
|
+
* union, not a single literal, so without this, `addCents(currencyCents(a,
|
|
156
|
+
* x), currencyCents(b, y))` would still typecheck even when `a`/`b` are
|
|
157
|
+
* both known only up to a union of currencies that could differ at
|
|
158
|
+
* runtime — the compile-time guarantee above only bites for a genuine
|
|
159
|
+
* single-literal currency type on both operands. Code that aggregates rows
|
|
160
|
+
* with a dynamic or union (non-singleton) currency must use `MoneyTotals`
|
|
161
|
+
* instead, which buckets by currency at runtime and is the runtime-correct
|
|
162
|
+
* tool for that case.
|
|
163
|
+
*/
|
|
164
|
+
declare const addCents: <A extends string, B extends A = A>(a: IsSingletonCurrency<A> extends never ? never : CurrencyCents<A>, b: IsSingletonCurrency<B> extends never ? never : CurrencyCents<B>) => CurrencyCents<A>;
|
|
165
|
+
/**
|
|
166
|
+
* Per-currency accumulator for aggregating money across rows that may carry
|
|
167
|
+
* different currencies (e.g. time entries across matters, expenses across
|
|
168
|
+
* clients). There is deliberately no method that returns a single combined
|
|
169
|
+
* number: the only way to read totals out is `entries()`, which groups by
|
|
170
|
+
* currency and sorts deterministically by currency code. This keeps
|
|
171
|
+
* cross-currency summation structurally unreachable through this package's
|
|
172
|
+
* API — callers must handle each currency's total explicitly.
|
|
173
|
+
*
|
|
174
|
+
* Division of responsibility with `addCents`: `addCents` gives a
|
|
175
|
+
* compile-time guarantee, but only when both operands carry a single
|
|
176
|
+
* string-literal currency type (`CurrencyCents<"USD">`); it rejects both
|
|
177
|
+
* the wide `CurrencyCents<string>` and a finite union such as
|
|
178
|
+
* `CurrencyCents<"USD" | "EUR">` outright. Any flow whose currency is
|
|
179
|
+
* dynamic or only known up to a union of allowed codes (read from a DB row,
|
|
180
|
+
* request body, etc.) cannot satisfy that singleton-literal constraint and
|
|
181
|
+
* must bucket through `MoneyTotals` instead, which enforces
|
|
182
|
+
* the same "never sum across currencies" invariant at runtime via the
|
|
183
|
+
* per-currency `Map`.
|
|
184
|
+
*/
|
|
185
|
+
type MoneyTotalsEntry = {
|
|
186
|
+
currency: string;
|
|
187
|
+
amountCents: CentsAmount;
|
|
188
|
+
};
|
|
189
|
+
declare class MoneyTotals {
|
|
190
|
+
#private;
|
|
191
|
+
/** Add `amountCents` to the running total for `currency`. */
|
|
192
|
+
add(currency: string, amountCents: CentsAmount): void;
|
|
193
|
+
/**
|
|
194
|
+
* Per-currency totals, sorted deterministically by currency code so
|
|
195
|
+
* output (PDF lines, API responses) does not depend on insertion order.
|
|
196
|
+
* Sorts by UTF-16 code unit (default `Array.sort`) rather than
|
|
197
|
+
* `localeCompare`, so ordering does not vary with the runtime's locale.
|
|
198
|
+
*/
|
|
199
|
+
entries(): MoneyTotalsEntry[];
|
|
200
|
+
}
|
|
201
|
+
//#endregion
|
|
202
|
+
export { ApplyMarkupCentsInput, type CentsAmount, CurrencyCents, type FormatMoneyCentsParams, MoneyTotals, MoneyTotalsEntry, ProrateHourlyCentsInput, type ToMajorUnitsParams, type ToMinorUnitsParams, addCents, applyMarkupCents, cents, currencyCents, currencyMinorUnitDigits, formatMoneyCents, prorateHourlyCents, toMajorUnits, toMinorUnits, tryToMinorUnits, unsafeCents };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { Result, panic } from "better-result";
|
|
2
|
+
//#region src/cents.ts
|
|
3
|
+
/**
|
|
4
|
+
* The minor-unit brand and its constructors.
|
|
5
|
+
*
|
|
6
|
+
* Separate from `index.ts` because `format.ts` mints amounts too: the
|
|
7
|
+
* conversion helpers there return a `CentsAmount`, and a module the package
|
|
8
|
+
* entry re-exports cannot import back from that entry without a cycle.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Construct a CentsAmount from a value already known to be in minor
|
|
12
|
+
* units. Use at boundaries where the input is validated as an integer
|
|
13
|
+
* minor-unit value (e.g. after Elysia `tMinorUnitAmount(...)` or after
|
|
14
|
+
* scaling a typed major-unit amount by the currency's exponent).
|
|
15
|
+
*
|
|
16
|
+
* SAFE integer, not merely integer: `Number.isInteger` is true for 2^53 and
|
|
17
|
+
* everything above it, where the spacing between representable doubles is
|
|
18
|
+
* larger than one minor unit, so `x + 1 === x` and a total silently stops
|
|
19
|
+
* moving. An amount that far out is a caller defect rather than a runtime
|
|
20
|
+
* condition, like a fractional one, so it panics the same way.
|
|
21
|
+
*/
|
|
22
|
+
const cents = (value) => {
|
|
23
|
+
if (!Number.isSafeInteger(value)) return panic(`cents(${value}): money values must be safe integer minor units`);
|
|
24
|
+
return value;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Escape hatch for code paths that genuinely need to attach the brand
|
|
28
|
+
* without a runtime check (test fixtures, generated code). Prefer
|
|
29
|
+
* `cents()` everywhere else; reach for this only with a `// SAFETY:`
|
|
30
|
+
* comment naming why the value is already a valid minor-unit integer.
|
|
31
|
+
*/
|
|
32
|
+
const unsafeCents = (value) => value;
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/format.ts
|
|
35
|
+
/**
|
|
36
|
+
* Moving between a stored minor-unit amount and the major-unit number a
|
|
37
|
+
* person types, and rendering either as text.
|
|
38
|
+
*
|
|
39
|
+
* The brand keeps the arithmetic honest; this keeps the scale honest. Both
|
|
40
|
+
* live here because "how many minor units make a major one" is a property of
|
|
41
|
+
* the currency, and every surface that touches money has to answer it the
|
|
42
|
+
* same way — a workspace column header, a billing form, an invoice line, an
|
|
43
|
+
* export row.
|
|
44
|
+
*
|
|
45
|
+
* The locale is always a parameter. A package cannot read the reader's
|
|
46
|
+
* formatting locale, and one that guessed would render a number differently
|
|
47
|
+
* from the app around it.
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* Two, the ISO 4217 default, used when a stored code is one `Intl` will not
|
|
51
|
+
* accept. Validation at the API boundary keeps those out, but a row written
|
|
52
|
+
* before that constraint existed must still render rather than throw.
|
|
53
|
+
*/
|
|
54
|
+
const DEFAULT_MINOR_UNIT_DIGITS = 2;
|
|
55
|
+
/**
|
|
56
|
+
* How many minor units make a major one, for this currency: 100 for CZK, 1 for
|
|
57
|
+
* JPY, 1000 for KWD. It is a property of the currency and not of the reader, so
|
|
58
|
+
* the lookup is deliberately locale-independent.
|
|
59
|
+
*
|
|
60
|
+
* A malformed code makes the `Intl.NumberFormat` constructor throw, before any
|
|
61
|
+
* `?? 2` on its result could help, so the fallback has to wrap the call.
|
|
62
|
+
*/
|
|
63
|
+
const currencyMinorUnitDigits = (currency) => {
|
|
64
|
+
const resolved = Result.try(() => new Intl.NumberFormat("en", {
|
|
65
|
+
style: "currency",
|
|
66
|
+
currency
|
|
67
|
+
}).resolvedOptions().maximumFractionDigits);
|
|
68
|
+
if (resolved.isErr()) return DEFAULT_MINOR_UNIT_DIGITS;
|
|
69
|
+
return resolved.value ?? DEFAULT_MINOR_UNIT_DIGITS;
|
|
70
|
+
};
|
|
71
|
+
/** A decimal amount in major units: an optional sign, digits, an optional point. */
|
|
72
|
+
const DECIMAL_AMOUNT = /^(?<sign>[+-]?)(?<whole>\d*)(?:\.(?<fraction>\d*))?$/u;
|
|
73
|
+
/** Scientific notation, which `String(1e21)` and `String(1e-7)` both produce. */
|
|
74
|
+
const EXPONENT_AMOUNT = /^(?<sign>[+-]?)(?<whole>\d+)(?:\.(?<fraction>\d+))?[eE](?<exponent>[+-]?\d+)$/u;
|
|
75
|
+
/**
|
|
76
|
+
* The widest exponent a finite double prints with (`5e-324`, `1.8e308`).
|
|
77
|
+
* Text past it names no amount any currency stores, and expanding it would
|
|
78
|
+
* mean materialising that many zeros.
|
|
79
|
+
*/
|
|
80
|
+
const MAX_EXPONENT_MAGNITUDE = 324;
|
|
81
|
+
const expandExponent = (text) => {
|
|
82
|
+
const groups = EXPONENT_AMOUNT.exec(text)?.groups;
|
|
83
|
+
if (!groups) return null;
|
|
84
|
+
const exponent = Number(groups["exponent"]);
|
|
85
|
+
if (Math.abs(exponent) > MAX_EXPONENT_MAGNITUDE) return null;
|
|
86
|
+
const sign = groups["sign"] ?? "";
|
|
87
|
+
const digits = `${groups["whole"] ?? ""}${groups["fraction"] ?? ""}`;
|
|
88
|
+
const point = (groups["whole"] ?? "").length + exponent;
|
|
89
|
+
if (point <= 0) return `${sign}0.${"0".repeat(-point)}${digits}`;
|
|
90
|
+
if (point >= digits.length) return `${sign}${digits}${"0".repeat(point - digits.length)}`;
|
|
91
|
+
return `${sign}${digits.slice(0, point)}.${digits.slice(point)}`;
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* The amount as decimal text, or null when it is not a decimal amount.
|
|
95
|
+
*
|
|
96
|
+
* A number is rendered by `String`, which produces the SHORTEST text that
|
|
97
|
+
* round-trips back to the same double. That is what makes the number path
|
|
98
|
+
* exact: the double nearest 1.005 prints as "1.005", which is the amount the
|
|
99
|
+
* person typed, where multiplying that double by 100 yields 100.49999999999999.
|
|
100
|
+
*/
|
|
101
|
+
const decimalText = (amount) => {
|
|
102
|
+
if (typeof amount === "number") {
|
|
103
|
+
if (!Number.isFinite(amount)) return null;
|
|
104
|
+
const rendered = String(amount);
|
|
105
|
+
return rendered.includes("e") ? expandExponent(rendered) : rendered;
|
|
106
|
+
}
|
|
107
|
+
const trimmed = amount.trim();
|
|
108
|
+
return /[eE]/u.test(trimmed) ? expandExponent(trimmed) : trimmed;
|
|
109
|
+
};
|
|
110
|
+
/** The digits of a decimal amount, or null when the text is not one. */
|
|
111
|
+
const amountDigits = (amount) => {
|
|
112
|
+
const text = decimalText(amount);
|
|
113
|
+
const groups = text === null ? void 0 : DECIMAL_AMOUNT.exec(text)?.groups;
|
|
114
|
+
if (groups === void 0) return null;
|
|
115
|
+
const whole = groups["whole"] ?? "";
|
|
116
|
+
const fraction = groups["fraction"] ?? "";
|
|
117
|
+
if (`${whole}${fraction}`.length === 0) return null;
|
|
118
|
+
return {
|
|
119
|
+
sign: groups["sign"] ?? "",
|
|
120
|
+
whole,
|
|
121
|
+
fraction
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* A typed major-unit amount as the minor units the currency actually counts:
|
|
126
|
+
* 12.5 USD is 1250, 1500 JPY is 1500, 12.5 KWD is 12500.
|
|
127
|
+
*
|
|
128
|
+
* The scaling is decimal, not a float multiply. `1.005 * 100` is
|
|
129
|
+
* 100.49999999999999 in binary floating point, so `Math.round` of it is 100
|
|
130
|
+
* and a $1.005 line item silently loses a cent; the same shortfall appears at
|
|
131
|
+
* a different decimal for every currency. Splitting the text on the point and
|
|
132
|
+
* moving the digits instead keeps the amount the person typed: the kept
|
|
133
|
+
* fraction digits are appended to the whole part, and the first digit dropped
|
|
134
|
+
* decides a half-up carry on the magnitude.
|
|
135
|
+
*
|
|
136
|
+
* Rounding is still the point -- a typed amount carries more places than the
|
|
137
|
+
* currency has -- and it happens on digits, exactly. Text this cannot parse,
|
|
138
|
+
* and a result outside the safe integer range, are caller defects: gate the
|
|
139
|
+
* text with `isDecimalAmount` first.
|
|
140
|
+
*/
|
|
141
|
+
const toMinorUnits = (params) => tryToMinorUnits(params) ?? panic(`toMinorUnits(${JSON.stringify(params.amount)}): not an amount ${params.currency} can store`);
|
|
142
|
+
/**
|
|
143
|
+
* The same conversion for text nobody has vouched for yet: null when the text
|
|
144
|
+
* is not a decimal amount, and null when the scaled value would leave the safe
|
|
145
|
+
* integer range, where a total silently stops adding up.
|
|
146
|
+
*
|
|
147
|
+
* A form holds whatever was typed and has to be able to decline it. Everything
|
|
148
|
+
* past that gate calls `toMinorUnits`, which panics, because by then the
|
|
149
|
+
* decision has been made.
|
|
150
|
+
*/
|
|
151
|
+
const tryToMinorUnits = ({ amount, currency }) => {
|
|
152
|
+
const parsed = amountDigits(amount);
|
|
153
|
+
if (parsed === null) return null;
|
|
154
|
+
const digits = currencyMinorUnitDigits(currency);
|
|
155
|
+
const kept = parsed.fraction.slice(0, digits).padEnd(digits, "0");
|
|
156
|
+
const dropped = parsed.fraction.charAt(digits);
|
|
157
|
+
const magnitude = BigInt(`${parsed.whole || "0"}${kept}`) + (dropped >= "5" ? 1n : 0n);
|
|
158
|
+
const value = Number(parsed.sign === "-" ? -magnitude : magnitude);
|
|
159
|
+
return Number.isSafeInteger(value) ? cents(value) : null;
|
|
160
|
+
};
|
|
161
|
+
/** The inverse: a stored amount as the major-unit number a person reads. */
|
|
162
|
+
const toMajorUnits = ({ amountCents, currency }) => amountCents / 10 ** currencyMinorUnitDigits(currency);
|
|
163
|
+
/**
|
|
164
|
+
* Money is stored in minor units, and how many of them make a major one is a
|
|
165
|
+
* property of the currency. Ask the currency rather than assuming a hundred.
|
|
166
|
+
*
|
|
167
|
+
* A code `Intl` rejects falls back to the amount beside the raw code: a column
|
|
168
|
+
* showing "15.00 A1C" is wrong-looking data, which is the truth, where a thrown
|
|
169
|
+
* RangeError would take the whole board down with it.
|
|
170
|
+
*/
|
|
171
|
+
const formatMoneyCents = ({ amountCents, currency, locale, fractionDigits }) => {
|
|
172
|
+
const major = toMajorUnits({
|
|
173
|
+
amountCents,
|
|
174
|
+
currency
|
|
175
|
+
});
|
|
176
|
+
const digits = fractionDigits ?? currencyMinorUnitDigits(currency);
|
|
177
|
+
const formatted = Result.try(() => new Intl.NumberFormat(locale, {
|
|
178
|
+
style: "currency",
|
|
179
|
+
currency,
|
|
180
|
+
minimumFractionDigits: digits,
|
|
181
|
+
maximumFractionDigits: digits
|
|
182
|
+
}).format(major));
|
|
183
|
+
return formatted.isErr() ? `${major.toFixed(digits)} ${currency}` : formatted.value;
|
|
184
|
+
};
|
|
185
|
+
//#endregion
|
|
186
|
+
//#region src/index.ts
|
|
187
|
+
/**
|
|
188
|
+
* Branded monetary amounts and minor-unit billing arithmetic.
|
|
189
|
+
*
|
|
190
|
+
* Money in this codebase is stored and computed in minor units
|
|
191
|
+
* ("cents" for USD/EUR, halere for CZK, etc.). The `CentsAmount`
|
|
192
|
+
* brand prevents the canonical 100x bug where a major-unit value
|
|
193
|
+
* (12.50 dollars) is silently mixed with minor-unit math (1250 cents).
|
|
194
|
+
*
|
|
195
|
+
* The brand lives in this package, so it threads end to end:
|
|
196
|
+
* the same `CentsAmount` flows from a Drizzle column declared with
|
|
197
|
+
* `.$type<CentsAmount>()`, across the API boundary (Eden infers the
|
|
198
|
+
* brand from the handler's return type), into browser previews and back.
|
|
199
|
+
* A plain `number` is not assignable to `CentsAmount`; mint one with
|
|
200
|
+
* `cents()` after validating minor-unit input, or `unsafeCents()` at a
|
|
201
|
+
* documented boundary.
|
|
202
|
+
*
|
|
203
|
+
* Currency-agnostic: the currency code lives alongside the amount in the
|
|
204
|
+
* schema (e.g. invoices.currency); pairing them is the call site's
|
|
205
|
+
* responsibility.
|
|
206
|
+
*/
|
|
207
|
+
const prorateHourlyCents = ({ billedMinutes, hourlyRateCents }) => {
|
|
208
|
+
assertNonNegativeInteger("billedMinutes", billedMinutes);
|
|
209
|
+
assertNonNegativeInteger("hourlyRateCents", hourlyRateCents);
|
|
210
|
+
return cents(Math.floor((billedMinutes * hourlyRateCents + 30) / 60));
|
|
211
|
+
};
|
|
212
|
+
const applyMarkupCents = ({ amountCents, markupPercent }) => {
|
|
213
|
+
assertNonNegativeInteger("amountCents", amountCents);
|
|
214
|
+
assertNonNegativeInteger("markupPercent", markupPercent);
|
|
215
|
+
return cents(Math.floor((amountCents * (100 + markupPercent) + 50) / 100));
|
|
216
|
+
};
|
|
217
|
+
function assertNonNegativeInteger(name, value) {
|
|
218
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) panic(`${name} must be a finite non-negative integer`);
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Construct a `CurrencyCents<C>` from a currency code and a minor-unit
|
|
222
|
+
* amount. The only producer of `CurrencyCents`; downstream code narrows `C`
|
|
223
|
+
* from the literal `currency` argument (e.g. `currencyCents("USD", 100)`
|
|
224
|
+
* infers `CurrencyCents<"USD">`).
|
|
225
|
+
*/
|
|
226
|
+
const currencyCents = (currency, amount) => {
|
|
227
|
+
if (!currency) return panic("currencyCents(): currency must be a non-empty code");
|
|
228
|
+
return cents(amount);
|
|
229
|
+
};
|
|
230
|
+
/**
|
|
231
|
+
* Add two `CurrencyCents` amounts of the SAME currency. The second
|
|
232
|
+
* parameter's currency `B` is constrained to `extends A`, so passing a
|
|
233
|
+
* different currency literal (e.g. `addCents(usdAmount, eurAmount)`) is a
|
|
234
|
+
* compile error, not a runtime bug — see `packages/money/src/index.test.ts`
|
|
235
|
+
* for the `@ts-expect-error` proof.
|
|
236
|
+
*
|
|
237
|
+
* Both operands are further constrained by `IsSingletonCurrency` to reject
|
|
238
|
+
* any non-singleton currency type: the WIDE `CurrencyCents<string>`, and
|
|
239
|
+
* also a finite union such as `CurrencyCents<"USD" | "EUR">` (e.g. from a
|
|
240
|
+
* validator's `t.UnionEnum`). A currency read back from a DB row or
|
|
241
|
+
* narrowed only to a finite set of allowed codes types as `string` or a
|
|
242
|
+
* union, not a single literal, so without this, `addCents(currencyCents(a,
|
|
243
|
+
* x), currencyCents(b, y))` would still typecheck even when `a`/`b` are
|
|
244
|
+
* both known only up to a union of currencies that could differ at
|
|
245
|
+
* runtime — the compile-time guarantee above only bites for a genuine
|
|
246
|
+
* single-literal currency type on both operands. Code that aggregates rows
|
|
247
|
+
* with a dynamic or union (non-singleton) currency must use `MoneyTotals`
|
|
248
|
+
* instead, which buckets by currency at runtime and is the runtime-correct
|
|
249
|
+
* tool for that case.
|
|
250
|
+
*/
|
|
251
|
+
const addCents = (a, b) => cents(a + b);
|
|
252
|
+
var MoneyTotals = class {
|
|
253
|
+
#totals = /* @__PURE__ */ new Map();
|
|
254
|
+
/** Add `amountCents` to the running total for `currency`. */
|
|
255
|
+
add(currency, amountCents) {
|
|
256
|
+
if (!currency) panic("MoneyTotals.add(): currency must be a non-empty code");
|
|
257
|
+
const running = this.#totals.get(currency) ?? cents(0);
|
|
258
|
+
this.#totals.set(currency, cents(running + amountCents));
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Per-currency totals, sorted deterministically by currency code so
|
|
262
|
+
* output (PDF lines, API responses) does not depend on insertion order.
|
|
263
|
+
* Sorts by UTF-16 code unit (default `Array.sort`) rather than
|
|
264
|
+
* `localeCompare`, so ordering does not vary with the runtime's locale.
|
|
265
|
+
*/
|
|
266
|
+
entries() {
|
|
267
|
+
return [...this.#totals.keys()].sort().map((currency) => ({
|
|
268
|
+
currency,
|
|
269
|
+
amountCents: this.#totals.get(currency) ?? cents(0)
|
|
270
|
+
}));
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
//#endregion
|
|
274
|
+
export { MoneyTotals, addCents, applyMarkupCents, cents, currencyCents, currencyMinorUnitDigits, formatMoneyCents, prorateHourlyCents, toMajorUnits, toMinorUnits, tryToMinorUnits, unsafeCents };
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stll/money",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Branded minor-unit monetary amounts and currency-safe billing arithmetic.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"billing",
|
|
7
|
+
"currency",
|
|
8
|
+
"minor-units",
|
|
9
|
+
"money",
|
|
10
|
+
"typescript"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/stella/stella/tree/main/packages/money",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/stella/stella/issues"
|
|
15
|
+
},
|
|
16
|
+
"license": "Apache-2.0",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/stella/stella.git",
|
|
20
|
+
"directory": "packages/money"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"clean": "git clean -xdf dist .cache .turbo node_modules",
|
|
39
|
+
"build": "tsdown",
|
|
40
|
+
"pack:dry-run": "bun pm pack --dry-run",
|
|
41
|
+
"test": "bun test src",
|
|
42
|
+
"typecheck": "bun ../../packages/scripts/src/tsc-native.ts --noEmit",
|
|
43
|
+
"lint": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --report-unused-disable-directives-severity=error --deny-warnings --type-aware packages/money",
|
|
44
|
+
"lint:fix": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --type-aware --fix packages/money",
|
|
45
|
+
"format": "bun ../../scripts/run-oxfmt.ts .",
|
|
46
|
+
"prepack": "bun run build"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"better-result": "3.0.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@stll/typescript-config": "0.0.0",
|
|
53
|
+
"bun-types": "1.4.2",
|
|
54
|
+
"tsdown": "0.22.14"
|
|
55
|
+
},
|
|
56
|
+
"main": "./dist/index.js",
|
|
57
|
+
"types": "./dist/index.d.ts"
|
|
6
58
|
}
|