@shirudo/ddd-kit 2.2.0 → 3.0.0-rc.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/README.md +204 -55
- package/dist/chunks/deep-equal-except.js +639 -0
- package/dist/chunks/deep-equal-except.js.map +1 -0
- package/dist/chunks/errors.d.ts +785 -0
- package/dist/chunks/errors.js +822 -0
- package/dist/chunks/errors.js.map +1 -0
- package/dist/chunks/ports.js +891 -0
- package/dist/chunks/ports.js.map +1 -0
- package/dist/chunks/snapshot-store.d.ts +2808 -0
- package/dist/chunks/utils.d.ts +110 -0
- package/dist/http.d.ts +64 -51
- package/dist/http.js +54 -20
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +2341 -2640
- package/dist/index.js +6073 -3915
- package/dist/index.js.map +1 -1
- package/dist/money.d.ts +376 -0
- package/dist/money.js +578 -0
- package/dist/money.js.map +1 -0
- package/dist/presentation.d.ts +86 -37
- package/dist/presentation.js +208 -39
- package/dist/presentation.js.map +1 -1
- package/dist/testing.d.ts +517 -335
- package/dist/testing.js +2396 -1184
- package/dist/testing.js.map +1 -1
- package/dist/utils.d.ts +2 -106
- package/dist/utils.js +2 -530
- package/package.json +35 -18
- package/dist/aggregate-DFi6HlEh.d.ts +0 -771
- package/dist/utils.js.map +0 -1
package/dist/money.js
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import { i as DomainError } from "./chunks/errors.js";
|
|
2
|
+
import { err, ok } from "@shirudo/result";
|
|
3
|
+
|
|
4
|
+
//#region src/money/errors.ts
|
|
5
|
+
/**
|
|
6
|
+
* Renders a value for a diagnostic message with a hard size ceiling:
|
|
7
|
+
* error messages must never carry attacker-sized payloads into logs.
|
|
8
|
+
* Total: never throws, whatever the value (JSON.stringify alone would
|
|
9
|
+
* crash on bigint and circular input, replacing the documented kit
|
|
10
|
+
* error with a raw TypeError). Module-internal, not part of the
|
|
11
|
+
* package entry.
|
|
12
|
+
*/
|
|
13
|
+
function describeValue(value) {
|
|
14
|
+
if (typeof value === "bigint") {
|
|
15
|
+
const digits = value.toString();
|
|
16
|
+
return digits.length > 48 ? `${digits.slice(0, 48)}... (truncated bigint)` : `${digits}n`;
|
|
17
|
+
}
|
|
18
|
+
if (typeof value === "string" && value.length > 48) return `${JSON.stringify(value.slice(0, 48))} (truncated, ${value.length} chars)`;
|
|
19
|
+
let rendered;
|
|
20
|
+
try {
|
|
21
|
+
rendered = JSON.stringify(value);
|
|
22
|
+
} catch {
|
|
23
|
+
rendered = void 0;
|
|
24
|
+
}
|
|
25
|
+
if (rendered === void 0) try {
|
|
26
|
+
rendered = String(value);
|
|
27
|
+
} catch {
|
|
28
|
+
rendered = `[unserializable ${typeof value}]`;
|
|
29
|
+
}
|
|
30
|
+
if (rendered.length > 64) return `${rendered.slice(0, 64)} (truncated)`;
|
|
31
|
+
return rendered;
|
|
32
|
+
}
|
|
33
|
+
var InvalidMoneyError = class extends DomainError {
|
|
34
|
+
constructor(message) {
|
|
35
|
+
super({
|
|
36
|
+
code: "INVALID_MONEY",
|
|
37
|
+
message
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
var MoneyCurrencyMismatchError = class extends DomainError {
|
|
42
|
+
constructor(left, right) {
|
|
43
|
+
super({
|
|
44
|
+
code: "MONEY_CURRENCY_MISMATCH",
|
|
45
|
+
message: `money operations require the same currency; got ${describeValue(left)} and ${describeValue(right)}`
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
var MoneyScaleMismatchError = class extends DomainError {
|
|
50
|
+
constructor(left, right) {
|
|
51
|
+
super({
|
|
52
|
+
code: "MONEY_SCALE_MISMATCH",
|
|
53
|
+
message: `money operations require the same scale; got ${left} and ${right} (rescaleMoney is the explicit conversion)`
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
var MoneyPrecisionLossError = class extends DomainError {
|
|
58
|
+
constructor(message) {
|
|
59
|
+
super({
|
|
60
|
+
code: "MONEY_PRECISION_LOSS",
|
|
61
|
+
message
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
var UnknownCurrencyError = class extends DomainError {
|
|
66
|
+
constructor(currency) {
|
|
67
|
+
super({
|
|
68
|
+
code: "UNKNOWN_CURRENCY",
|
|
69
|
+
message: `The currency scale resolver has no entry for ${describeValue(currency)}`
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/money/money.ts
|
|
76
|
+
const INTEGER_STRING = /^-?\d+$/;
|
|
77
|
+
/**
|
|
78
|
+
* Hard bounds, enforced at the single construction door so hostile
|
|
79
|
+
* input cannot buy unbounded CPU (BigInt conversion is superlinear in
|
|
80
|
+
* digit count) or memory (cache keys, padded strings, log echoes):
|
|
81
|
+
*
|
|
82
|
+
* - amounts: fewer than 97 digits (uint256 is 78 digits; headroom)
|
|
83
|
+
* - scale: at most 64 (ETH wei is 18)
|
|
84
|
+
* - currency: at most 32 characters (ISO 4217 needs 3)
|
|
85
|
+
*
|
|
86
|
+
* Everything past a bound is `INVALID_MONEY` by construction, and the
|
|
87
|
+
* DTO/parse boundaries reject oversized strings before converting them.
|
|
88
|
+
*/
|
|
89
|
+
const MONEY_AMOUNT_LIMIT = 10n ** 96n;
|
|
90
|
+
const MAX_MONEY_SCALE = 64;
|
|
91
|
+
const MAX_CURRENCY_LENGTH = 32;
|
|
92
|
+
const MAX_DTO_AMOUNT_LENGTH = 97;
|
|
93
|
+
function isValidAmount(amountMinor) {
|
|
94
|
+
return typeof amountMinor === "bigint" && amountMinor < MONEY_AMOUNT_LIMIT && amountMinor > -MONEY_AMOUNT_LIMIT;
|
|
95
|
+
}
|
|
96
|
+
function isValidCurrency(currency) {
|
|
97
|
+
return typeof currency === "string" && currency.length > 0 && currency.length <= MAX_CURRENCY_LENGTH && !/\s/.test(currency);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Scale guard for operations that must validate a TARGET scale before
|
|
101
|
+
* computing with it (10^scale on an unchecked value is an attack
|
|
102
|
+
* surface). Module-internal export, not part of the package entry.
|
|
103
|
+
*/
|
|
104
|
+
function assertValidScale(scale) {
|
|
105
|
+
if (!isValidScale(scale)) throw new InvalidMoneyError(`scale must be an integer between 0 and ${MAX_MONEY_SCALE}; got ${describeValue(scale)}`);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Currency counterpart to {@link assertValidScale}, for call sites
|
|
109
|
+
* that must reject a wiring-provided currency before any input work.
|
|
110
|
+
* Module-internal export, not part of the package entry.
|
|
111
|
+
*/
|
|
112
|
+
function assertValidCurrency(currency) {
|
|
113
|
+
if (!isValidCurrency(currency)) throw new InvalidMoneyError(`currency must be a non-empty string without whitespace, at most ${MAX_CURRENCY_LENGTH} characters; got ${describeValue(currency)}`);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Validates an unknown plain shape and mints a FRESH, frozen
|
|
117
|
+
* {@link Money} from it: the result shares no reference with the
|
|
118
|
+
* input, so later mutation of the input cannot reach domain state.
|
|
119
|
+
* The door for re-hydrating foreign minor-units data (rows already
|
|
120
|
+
* mapped by an ORM, caches, deserialized snapshots); wire strings go
|
|
121
|
+
* through `moneyFromDto` instead.
|
|
122
|
+
*/
|
|
123
|
+
function moneyFromUnknown(value) {
|
|
124
|
+
assertMoney(value);
|
|
125
|
+
return moneyOfMinor(value.amountMinor, value.currency, value.scale);
|
|
126
|
+
}
|
|
127
|
+
function isValidScale(scale) {
|
|
128
|
+
return typeof scale === "number" && Number.isSafeInteger(scale) && scale >= 0 && scale <= MAX_MONEY_SCALE;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Constructs a frozen {@link Money} from an exact minor-unit amount.
|
|
132
|
+
* The only door into the shape: rejects `number` amounts (floats have
|
|
133
|
+
* no place in stored money), invalid scales, and empty or
|
|
134
|
+
* whitespace-carrying currency codes with `InvalidMoneyError`.
|
|
135
|
+
*/
|
|
136
|
+
function moneyOfMinor(amountMinor, currency, scale) {
|
|
137
|
+
if (typeof amountMinor !== "bigint") throw new InvalidMoneyError(`amountMinor must be a bigint in minor units; got ${typeof amountMinor}`);
|
|
138
|
+
if (!isValidAmount(amountMinor)) throw new InvalidMoneyError("amountMinor must stay below 97 digits (uint256 fits with headroom)");
|
|
139
|
+
assertValidCurrency(currency);
|
|
140
|
+
assertValidScale(scale);
|
|
141
|
+
return Object.freeze({
|
|
142
|
+
amountMinor,
|
|
143
|
+
currency,
|
|
144
|
+
scale
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Validates a {@link MoneyDto} fresh off the wire and converts it to
|
|
149
|
+
* {@link Money}. Takes `unknown` on purpose: this IS the trust
|
|
150
|
+
* boundary, so callers never cast before validating. `amountMinor`
|
|
151
|
+
* must be a plain integer string (`/^-?\d+$/`); anything `Number()`
|
|
152
|
+
* would tolerate but bigint arithmetic cannot represent exactly
|
|
153
|
+
* ("1e5", "10.99", "0x10") is rejected with `InvalidMoneyError`.
|
|
154
|
+
*/
|
|
155
|
+
function moneyFromDto(dto) {
|
|
156
|
+
if (dto === null || typeof dto !== "object") throw new InvalidMoneyError(`MoneyDto must be an object; got ${dto === null ? "null" : typeof dto}`);
|
|
157
|
+
const { amountMinor, currency, scale } = dto;
|
|
158
|
+
if (typeof amountMinor !== "string" || amountMinor.length > MAX_DTO_AMOUNT_LENGTH || !INTEGER_STRING.test(amountMinor)) throw new InvalidMoneyError(`MoneyDto.amountMinor must be an integer string matching /^-?\\d+$/ with at most 96 digits; got ${describeValue(amountMinor)}`);
|
|
159
|
+
return moneyOfMinor(BigInt(amountMinor), currency, scale);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Converts {@link Money} to its JSON-safe wire shape. Guards the input
|
|
163
|
+
* so untyped callers cannot leak a number-amount object onto the wire.
|
|
164
|
+
*/
|
|
165
|
+
function moneyToDto(money) {
|
|
166
|
+
assertMoney(money);
|
|
167
|
+
return {
|
|
168
|
+
amountMinor: money.amountMinor.toString(),
|
|
169
|
+
currency: money.currency,
|
|
170
|
+
scale: money.scale
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Narrows an unknown value to the canonical {@link Money} shape. A
|
|
175
|
+
* CHECK, not a door: narrowing neither copies nor freezes, so an
|
|
176
|
+
* external alias can still mutate the underlying object after the
|
|
177
|
+
* check. For anything entering domain state, mint a fresh frozen
|
|
178
|
+
* value with {@link moneyFromUnknown} instead.
|
|
179
|
+
*/
|
|
180
|
+
function isMoney(value) {
|
|
181
|
+
if (value === null || typeof value !== "object") return false;
|
|
182
|
+
const candidate = value;
|
|
183
|
+
return isValidAmount(candidate.amountMinor) && isValidCurrency(candidate.currency) && isValidScale(candidate.scale);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Loud form of {@link isMoney} for boundary functions. Module-internal
|
|
187
|
+
* export, not part of the package entry.
|
|
188
|
+
*/
|
|
189
|
+
function assertMoney(value) {
|
|
190
|
+
if (!isMoney(value)) throw new InvalidMoneyError("expected a Money value ({ amountMinor: bigint, currency: string, scale: number })");
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* REPRESENTATION equality, deliberately: amount, currency, AND scale.
|
|
194
|
+
* `10.0` EUR at scale 1 and `10.00` EUR at scale 2 denote the same
|
|
195
|
+
* monetary value but are NOT equal here, because silently conflating
|
|
196
|
+
* scales is how precision bugs hide. For monetary-value comparison,
|
|
197
|
+
* align the scales explicitly first (lossless `rescaleMoney` upscales
|
|
198
|
+
* the coarser side) and then compare.
|
|
199
|
+
*/
|
|
200
|
+
function moneyEquals(a, b) {
|
|
201
|
+
return a.amountMinor === b.amountMinor && a.currency === b.currency && a.scale === b.scale;
|
|
202
|
+
}
|
|
203
|
+
/** True when the amount is exactly zero. */
|
|
204
|
+
function isZeroMoney(money) {
|
|
205
|
+
return money.amountMinor === 0n;
|
|
206
|
+
}
|
|
207
|
+
/** True when the amount is strictly greater than zero. */
|
|
208
|
+
function isPositiveMoney(money) {
|
|
209
|
+
return money.amountMinor > 0n;
|
|
210
|
+
}
|
|
211
|
+
/** True when the amount is strictly less than zero. */
|
|
212
|
+
function isNegativeMoney(money) {
|
|
213
|
+
return money.amountMinor < 0n;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Renders the exact decimal representation ("10.99", "-0.05", "10").
|
|
217
|
+
* The inverse of `parseMoneyInput` and the precision-safe input for
|
|
218
|
+
* display formatting; never feed the result back into arithmetic.
|
|
219
|
+
* Guards its input like the wire emitters do: a non-Money value fails
|
|
220
|
+
* loudly instead of rendering garbage.
|
|
221
|
+
*/
|
|
222
|
+
function moneyToDecimalString(money) {
|
|
223
|
+
assertMoney(money);
|
|
224
|
+
const negative = money.amountMinor < 0n;
|
|
225
|
+
const digits = (negative ? -money.amountMinor : money.amountMinor).toString();
|
|
226
|
+
const sign = negative ? "-" : "";
|
|
227
|
+
if (money.scale === 0) return sign + digits;
|
|
228
|
+
const padded = digits.padStart(money.scale + 1, "0");
|
|
229
|
+
return `${sign}${padded.slice(0, -money.scale)}.${padded.slice(-money.scale)}`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
//#endregion
|
|
233
|
+
//#region src/money/arithmetic.ts
|
|
234
|
+
/**
|
|
235
|
+
* Exact operations only. Everything here is closed (Money in, Money
|
|
236
|
+
* out) and cannot lose information, so no rounding decision exists to
|
|
237
|
+
* make. Anything that WOULD need one (multiplication, ratios, fees,
|
|
238
|
+
* division, lossy rescaling) plus everything distributive (allocation:
|
|
239
|
+
* splitting 10.00 EUR three ways must hand out 3.34 + 3.33 + 3.33, not
|
|
240
|
+
* round three times) and rate-based (FX) is deliberately NOT
|
|
241
|
+
* implemented by the kit: rounding timing, order, and remainder policy
|
|
242
|
+
* are domain policy, and a battle-tested calculation library should
|
|
243
|
+
* execute them at the use-case boundary (see the snapshot bridge). The
|
|
244
|
+
* kit NEVER rounds; even over-precise parse input is rejected instead
|
|
245
|
+
* of rounded.
|
|
246
|
+
*/
|
|
247
|
+
function assertSameUnit(a, b) {
|
|
248
|
+
if (a.currency !== b.currency) throw new MoneyCurrencyMismatchError(a.currency, b.currency);
|
|
249
|
+
if (a.scale !== b.scale) throw new MoneyScaleMismatchError(a.scale, b.scale);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Exact addition of same-currency, same-scale amounts. Mismatches
|
|
253
|
+
* throw (`MONEY_CURRENCY_MISMATCH` / `MONEY_SCALE_MISMATCH`); there is
|
|
254
|
+
* no implicit conversion of either. A result past the amount bound
|
|
255
|
+
* fails as `INVALID_MONEY` instead of wrapping.
|
|
256
|
+
*/
|
|
257
|
+
function addMoney(a, b) {
|
|
258
|
+
assertSameUnit(a, b);
|
|
259
|
+
return moneyOfMinor(a.amountMinor + b.amountMinor, a.currency, a.scale);
|
|
260
|
+
}
|
|
261
|
+
/** Exact subtraction under the same guards as {@link addMoney}. */
|
|
262
|
+
function subtractMoney(a, b) {
|
|
263
|
+
assertSameUnit(a, b);
|
|
264
|
+
return moneyOfMinor(a.amountMinor - b.amountMinor, a.currency, a.scale);
|
|
265
|
+
}
|
|
266
|
+
/** Exact sign flip; useful for ledger reversals and refunds. */
|
|
267
|
+
function negateMoney(money) {
|
|
268
|
+
return moneyOfMinor(-money.amountMinor, money.currency, money.scale);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Converts to another scale, LOSSLESSLY or not at all: upscaling and
|
|
272
|
+
* exact downscaling succeed; a downscale that would drop non-zero
|
|
273
|
+
* digits throws `MONEY_PRECISION_LOSS`. There is deliberately no
|
|
274
|
+
* rounding parameter; lossy conversions carry a rounding policy and
|
|
275
|
+
* belong to your calculation library. The intended use is aligning
|
|
276
|
+
* mixed-scale amounts for `addMoney`/`subtractMoney` by upscaling the
|
|
277
|
+
* coarser one.
|
|
278
|
+
*/
|
|
279
|
+
function rescaleMoney(money, scale) {
|
|
280
|
+
assertValidScale(scale);
|
|
281
|
+
if (scale === money.scale) return money;
|
|
282
|
+
if (scale > money.scale) return moneyOfMinor(money.amountMinor * 10n ** BigInt(scale - money.scale), money.currency, scale);
|
|
283
|
+
const factor = 10n ** BigInt(money.scale - scale);
|
|
284
|
+
if (money.amountMinor % factor !== 0n) throw new MoneyPrecisionLossError(`rescaling from scale ${money.scale} to ${scale} loses precision; lossy conversions belong to your calculation library, where the rounding policy is explicit`);
|
|
285
|
+
return moneyOfMinor(money.amountMinor / factor, money.currency, scale);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
//#endregion
|
|
289
|
+
//#region src/money/parse.ts
|
|
290
|
+
const DECIMAL_INPUT = /^(-?)(\d+)(?:\.(\d+))?$/;
|
|
291
|
+
const MAX_INPUT_LENGTH = 256;
|
|
292
|
+
/**
|
|
293
|
+
* Parses a plain decimal string ("10.99") into exact minor units,
|
|
294
|
+
* without ever touching floating point. This is the safe replacement
|
|
295
|
+
* for the classic bugs `Number(input) * 100` and `parseFloat`: no
|
|
296
|
+
* exponents, no `Infinity`, no locale separators, and NO rounding; the
|
|
297
|
+
* kit never rounds.
|
|
298
|
+
*
|
|
299
|
+
* EXACT OR REJECTED: missing fraction digits pad losslessly ("10.5" at
|
|
300
|
+
* scale 2 is 1050n) and all-zero excess digits are accepted ("10.990"
|
|
301
|
+
* at scale 2 is 1099n), but input that cannot be represented exactly
|
|
302
|
+
* at the target scale throws `MONEY_PRECISION_LOSS`. Whether "10.999"
|
|
303
|
+
* should be rejected or become 11.00 is a BUSINESS decision, not a
|
|
304
|
+
* parsing feature: put it in a domain-named policy function (a
|
|
305
|
+
* `normalizeQuotedPrice`, a `calculateVat`) that rounds via your
|
|
306
|
+
* calculation library and returns `Money`.
|
|
307
|
+
*
|
|
308
|
+
* The grammar is deliberately strict (`/^-?\d+(\.\d+)?$/`). Locale
|
|
309
|
+
* input ("10,99", grouping, currency signs) is a UI concern; normalize
|
|
310
|
+
* it to this grammar before calling.
|
|
311
|
+
*
|
|
312
|
+
* Takes `unknown` on purpose: this is the trust boundary for raw
|
|
313
|
+
* request values, so callers pass `req.body.amount` directly instead
|
|
314
|
+
* of coercing (`String([...])` silently joins arrays) or casting.
|
|
315
|
+
*/
|
|
316
|
+
function parseMoneyInput(input, options) {
|
|
317
|
+
const { currency, scale } = options;
|
|
318
|
+
assertValidScale(scale);
|
|
319
|
+
if (typeof input !== "string" || input.length > MAX_INPUT_LENGTH) throw new InvalidMoneyError(`money input must be a decimal string of at most ${MAX_INPUT_LENGTH} characters; got ${describeValue(input)}`);
|
|
320
|
+
const match = DECIMAL_INPUT.exec(input);
|
|
321
|
+
if (!match) throw new InvalidMoneyError(`money input must be a plain decimal string matching /^-?\\d+(\\.\\d+)?$/; got ${describeValue(input)}`);
|
|
322
|
+
const sign = match[1] ?? "";
|
|
323
|
+
const whole = match[2] ?? "";
|
|
324
|
+
const fraction = match[3] ?? "";
|
|
325
|
+
if (fraction.length <= scale) return moneyOfMinor(BigInt(sign + whole + fraction.padEnd(scale, "0")), currency, scale);
|
|
326
|
+
const excess = fraction.slice(scale);
|
|
327
|
+
if (/^0+$/.test(excess)) return moneyOfMinor(BigInt(sign + whole + fraction.slice(0, scale)), currency, scale);
|
|
328
|
+
throw new MoneyPrecisionLossError(`parsing ${describeValue(input)} at scale ${scale} would lose precision; accepting over-precise input is a business decision, round it in a domain-named policy function via your calculation library`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
//#endregion
|
|
332
|
+
//#region src/money/factory.ts
|
|
333
|
+
/**
|
|
334
|
+
* Binds a {@link CurrencyScaleResolver} once and returns construction
|
|
335
|
+
* helpers that no longer need an explicit scale per call.
|
|
336
|
+
*
|
|
337
|
+
* @example
|
|
338
|
+
* ```ts
|
|
339
|
+
* const money = createMoneyFactory({
|
|
340
|
+
* scaleFor: currencyScaleFromRecord({ EUR: 2, JPY: 0 }),
|
|
341
|
+
* });
|
|
342
|
+
* money.parse("10.99", "EUR"); // { amountMinor: 1099n, currency: "EUR", scale: 2 }
|
|
343
|
+
* ```
|
|
344
|
+
*/
|
|
345
|
+
function createMoneyFactory(options) {
|
|
346
|
+
const { scaleFor } = options;
|
|
347
|
+
const scaleOf = (currency) => {
|
|
348
|
+
const scale = scaleFor(currency);
|
|
349
|
+
if (scale === void 0) throw new UnknownCurrencyError(currency);
|
|
350
|
+
return scale;
|
|
351
|
+
};
|
|
352
|
+
return Object.freeze({
|
|
353
|
+
ofMinor: (amountMinor, currency) => moneyOfMinor(amountMinor, currency, scaleOf(currency)),
|
|
354
|
+
parse: (input, currency) => parseMoneyInput(input, {
|
|
355
|
+
currency,
|
|
356
|
+
scale: scaleOf(currency)
|
|
357
|
+
}),
|
|
358
|
+
zero: (currency) => moneyOfMinor(0n, currency, scaleOf(currency)),
|
|
359
|
+
scaleOf
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Resolver over a plain currency-to-scale record
|
|
364
|
+
* (`{ EUR: 2, JPY: 0 }`). The record is copied into a `Map` at
|
|
365
|
+
* creation, so later mutation of the input and hostile own keys have
|
|
366
|
+
* no effect.
|
|
367
|
+
*/
|
|
368
|
+
function currencyScaleFromRecord(record) {
|
|
369
|
+
const scales = new Map(Object.entries(record));
|
|
370
|
+
return (currency) => scales.get(currency);
|
|
371
|
+
}
|
|
372
|
+
const CANONICAL_ISO_CODE = /^[A-Z]{3}$/;
|
|
373
|
+
/**
|
|
374
|
+
* Resolver backed by the runtime's own currency data (ICU via
|
|
375
|
+
* `Intl.NumberFormat`), so no currency table ships with the kit or the
|
|
376
|
+
* consumer. Resolves ONLY canonical uppercase ISO 4217 codes: Intl
|
|
377
|
+
* itself would accept "eur", but a silent alias would let "eur"-Money
|
|
378
|
+
* and "EUR"-Money circulate side by side until an operation throws
|
|
379
|
+
* `MONEY_CURRENCY_MISMATCH`; here "eur" resolves to `undefined` and
|
|
380
|
+
* fails fast as `UNKNOWN_CURRENCY` at the factory.
|
|
381
|
+
*
|
|
382
|
+
* A CONVENIENCE, not an enterprise source of truth: ICU resolves
|
|
383
|
+
* well-formed but UNASSIGNED codes to its default of 2 rather than
|
|
384
|
+
* `undefined`, and the data shifts with the runtime's ICU version.
|
|
385
|
+
* Production money paths should pin a closed, versioned currency map
|
|
386
|
+
* (`currencyScaleFromRecord`) or a calculation library's versioned
|
|
387
|
+
* currency package; use this resolver for demos, prototypes, and
|
|
388
|
+
* internal tooling.
|
|
389
|
+
*/
|
|
390
|
+
function currencyScaleFromIntl() {
|
|
391
|
+
const cache = /* @__PURE__ */ new Map();
|
|
392
|
+
return (currency) => {
|
|
393
|
+
if (typeof currency !== "string" || !CANONICAL_ISO_CODE.test(currency)) return;
|
|
394
|
+
if (cache.has(currency)) return cache.get(currency);
|
|
395
|
+
let scale;
|
|
396
|
+
try {
|
|
397
|
+
scale = new Intl.NumberFormat("en", {
|
|
398
|
+
style: "currency",
|
|
399
|
+
currency
|
|
400
|
+
}).resolvedOptions().maximumFractionDigits;
|
|
401
|
+
} catch {
|
|
402
|
+
scale = void 0;
|
|
403
|
+
}
|
|
404
|
+
if (cache.size < CACHE_LIMIT$1) cache.set(currency, scale);
|
|
405
|
+
return scale;
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
const CACHE_LIMIT$1 = 1e3;
|
|
409
|
+
|
|
410
|
+
//#endregion
|
|
411
|
+
//#region src/money/format.ts
|
|
412
|
+
const CACHE_LIMIT = 1e3;
|
|
413
|
+
function formatterFor(locale, currency, scale) {
|
|
414
|
+
return new Intl.NumberFormat(locale, {
|
|
415
|
+
style: "currency",
|
|
416
|
+
currency,
|
|
417
|
+
minimumFractionDigits: scale,
|
|
418
|
+
maximumFractionDigits: scale
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
function formatDecimal(formatter, money) {
|
|
422
|
+
return formatter.format(moneyToDecimalString(money));
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Formats for display via `Intl.NumberFormat`, feeding the exact
|
|
426
|
+
* decimal string (never a float), with the money's own scale as the
|
|
427
|
+
* fraction-digit count. Presentation only: the output is
|
|
428
|
+
* locale-dependent text and must never flow back into parsing,
|
|
429
|
+
* storage, or arithmetic. Non-Money input fails loudly with
|
|
430
|
+
* `INVALID_MONEY` before Intl is touched.
|
|
431
|
+
*
|
|
432
|
+
* The currency must be well-formed for `Intl` (ISO alpha-3); for
|
|
433
|
+
* non-ISO codes, format `moneyToDecimalString(money)` yourself.
|
|
434
|
+
*/
|
|
435
|
+
function formatMoney(money, locale) {
|
|
436
|
+
assertMoney(money);
|
|
437
|
+
return formatDecimal(formatterFor(locale, money.currency, money.scale), money);
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Binds the locale once and caches one `Intl.NumberFormat` per
|
|
441
|
+
* currency/scale pair; constructing formatters is expensive, so use
|
|
442
|
+
* this over `formatMoney` anywhere hot (lists, tables, exports).
|
|
443
|
+
*/
|
|
444
|
+
function createMoneyFormatter(locale) {
|
|
445
|
+
const formatters = /* @__PURE__ */ new Map();
|
|
446
|
+
return (money) => {
|
|
447
|
+
assertMoney(money);
|
|
448
|
+
const key = `${money.currency}@${money.scale}`;
|
|
449
|
+
let formatter = formatters.get(key);
|
|
450
|
+
if (!formatter) {
|
|
451
|
+
formatter = formatterFor(locale, money.currency, money.scale);
|
|
452
|
+
if (formatters.size < CACHE_LIMIT) formatters.set(key, formatter);
|
|
453
|
+
}
|
|
454
|
+
return formatDecimal(formatter, money);
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
//#endregion
|
|
459
|
+
//#region src/money/snapshot.ts
|
|
460
|
+
function toScaleNumber(value, what) {
|
|
461
|
+
const scale = typeof value === "bigint" ? Number(value) : value;
|
|
462
|
+
if (typeof scale !== "number" || !Number.isSafeInteger(scale)) throw new InvalidMoneyError(`snapshot ${what} must be an integer; got ${describeValue(value)}`);
|
|
463
|
+
return scale;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Converts a calculation-library snapshot into canonical {@link Money}.
|
|
467
|
+
* The anti-corruption checks live here so they run exactly once, at
|
|
468
|
+
* the boundary:
|
|
469
|
+
*
|
|
470
|
+
* - non-decimal currencies are rejected (`base` other than 10; some
|
|
471
|
+
* library currency packages model MGA/MRU with base 5, and pre-1971
|
|
472
|
+
* GBP with a base array): their minor units do not map onto a
|
|
473
|
+
* power-of-ten scale
|
|
474
|
+
* - number amounts must be safe integers; fractional or beyond-2^53
|
|
475
|
+
* amounts are rejected instead of silently corrupted
|
|
476
|
+
* - bigint amounts pass through exactly
|
|
477
|
+
*
|
|
478
|
+
* Takes `unknown` on purpose: this is the trust boundary for foreign
|
|
479
|
+
* library data, so callers never cast before validating (the
|
|
480
|
+
* {@link MoneySnapshotLike} type documents the expected shape).
|
|
481
|
+
*/
|
|
482
|
+
function moneyFromSnapshot(snapshot) {
|
|
483
|
+
if (snapshot === null || typeof snapshot !== "object") throw new InvalidMoneyError(`money snapshot must be an object; got ${snapshot === null ? "null" : typeof snapshot}`);
|
|
484
|
+
const { amount, currency, scale } = snapshot;
|
|
485
|
+
const currencyObject = typeof currency === "string" ? { code: currency } : currency;
|
|
486
|
+
if (currencyObject === null || typeof currencyObject !== "object") throw new InvalidMoneyError(`snapshot currency must be a code or a currency object; got ${typeof currency}`);
|
|
487
|
+
const { base } = currencyObject;
|
|
488
|
+
if (base !== void 0 && base !== 10 && base !== 10n) throw new InvalidMoneyError(`only base-10 currencies map onto Money; got base ${describeValue(base)} for ${describeValue(currencyObject.code)}`);
|
|
489
|
+
const scaleSource = scale ?? currencyObject.exponent;
|
|
490
|
+
if (scaleSource === void 0) throw new InvalidMoneyError("money snapshot carries neither a scale nor a currency exponent");
|
|
491
|
+
return moneyOfMinor(toBigIntAmount(amount), currencyObject.code, toScaleNumber(scaleSource, "scale"));
|
|
492
|
+
}
|
|
493
|
+
function toBigIntAmount(amount) {
|
|
494
|
+
if (typeof amount === "bigint") return amount;
|
|
495
|
+
if (typeof amount === "number" && Number.isSafeInteger(amount)) return BigInt(amount);
|
|
496
|
+
throw new InvalidMoneyError(`snapshot amount must be a bigint or a safe integer; got ${describeValue(amount)}`);
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Converts {@link Money} into the snapshot shape the common
|
|
500
|
+
* calculation-library constructors accept. Number-based by design (the
|
|
501
|
+
* libraries' default calculators are), so amounts past
|
|
502
|
+
* `Number.MAX_SAFE_INTEGER` are rejected loudly; wire such amounts
|
|
503
|
+
* into a bigint calculator directly from `money.amountMinor` instead.
|
|
504
|
+
*/
|
|
505
|
+
function moneyToSnapshot(money) {
|
|
506
|
+
assertMoney(money);
|
|
507
|
+
const amount = Number(money.amountMinor);
|
|
508
|
+
if (!Number.isSafeInteger(amount)) throw new InvalidMoneyError(`amountMinor ${money.amountMinor} exceeds Number.MAX_SAFE_INTEGER; use your library's bigint calculator and pass amountMinor directly`);
|
|
509
|
+
return {
|
|
510
|
+
amount,
|
|
511
|
+
currency: {
|
|
512
|
+
code: money.currency,
|
|
513
|
+
base: 10,
|
|
514
|
+
exponent: money.scale
|
|
515
|
+
},
|
|
516
|
+
scale: money.scale
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
//#endregion
|
|
521
|
+
//#region src/money/try-parse.ts
|
|
522
|
+
/**
|
|
523
|
+
* Result-returning counterparts to the three money boundary parsers,
|
|
524
|
+
* for call sites that process many candidate values in one pass: a
|
|
525
|
+
* CSV import, a batch migration, a message replay. Per-row try/catch
|
|
526
|
+
* reads badly and, hand-rolled, usually catches too much; these
|
|
527
|
+
* wrappers apply the `result-vs-throw` guide's discipline instead.
|
|
528
|
+
*
|
|
529
|
+
* The contract, mirroring `voValidated`: only the parser's DOCUMENTED
|
|
530
|
+
* rejections of the INPUT become `Err`. Anything else, an assertion
|
|
531
|
+
* firing, a typo-ed helper, a genuine bug, keeps propagating as a
|
|
532
|
+
* throw, because a bug wrapped in `Err` is a bug silently counted as
|
|
533
|
+
* a bad input row. That includes the parse OPTIONS: a broken scale
|
|
534
|
+
* resolver is a bug in the caller's wiring, so it is validated
|
|
535
|
+
* eagerly and throws instead of marking every row bad. The `Err`
|
|
536
|
+
* types are exact per parser: the wire parsers reject only with
|
|
537
|
+
* `InvalidMoneyError`, while decimal-string parsing can additionally
|
|
538
|
+
* refuse over-precise input with `MoneyPrecisionLossError`.
|
|
539
|
+
*
|
|
540
|
+
* `Result`, `ok`, and `err` come from `@shirudo/result` (already a
|
|
541
|
+
* peer dependency); import them from there to work with the branches.
|
|
542
|
+
*/
|
|
543
|
+
function tryCatching(parse, isExpected) {
|
|
544
|
+
try {
|
|
545
|
+
return ok(parse());
|
|
546
|
+
} catch (error) {
|
|
547
|
+
if (isExpected(error)) return err(error);
|
|
548
|
+
throw error;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* {@link parseMoneyInput} as a `Result`: `Err` for the documented
|
|
553
|
+
* rejections (malformed input as `InvalidMoneyError`, over-precise
|
|
554
|
+
* input as `MoneyPrecisionLossError`), a throw for everything else.
|
|
555
|
+
*/
|
|
556
|
+
function tryParseMoneyInput(input, options) {
|
|
557
|
+
assertValidScale(options.scale);
|
|
558
|
+
assertValidCurrency(options.currency);
|
|
559
|
+
return tryCatching(() => parseMoneyInput(input, options), (error) => error instanceof InvalidMoneyError || error instanceof MoneyPrecisionLossError);
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* {@link moneyFromDto} as a `Result`: `Err` for the documented
|
|
563
|
+
* rejection (`InvalidMoneyError`), a throw for everything else.
|
|
564
|
+
*/
|
|
565
|
+
function tryMoneyFromDto(dto) {
|
|
566
|
+
return tryCatching(() => moneyFromDto(dto), (error) => error instanceof InvalidMoneyError);
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* {@link moneyFromSnapshot} as a `Result`: `Err` for the documented
|
|
570
|
+
* rejection (`InvalidMoneyError`), a throw for everything else.
|
|
571
|
+
*/
|
|
572
|
+
function tryMoneyFromSnapshot(snapshot) {
|
|
573
|
+
return tryCatching(() => moneyFromSnapshot(snapshot), (error) => error instanceof InvalidMoneyError);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
//#endregion
|
|
577
|
+
export { InvalidMoneyError, MoneyCurrencyMismatchError, MoneyPrecisionLossError, MoneyScaleMismatchError, UnknownCurrencyError, addMoney, createMoneyFactory, createMoneyFormatter, currencyScaleFromIntl, currencyScaleFromRecord, formatMoney, isMoney, isNegativeMoney, isPositiveMoney, isZeroMoney, moneyEquals, moneyFromDto, moneyFromSnapshot, moneyFromUnknown, moneyOfMinor, moneyToDecimalString, moneyToDto, moneyToSnapshot, negateMoney, parseMoneyInput, rescaleMoney, subtractMoney, tryMoneyFromDto, tryMoneyFromSnapshot, tryParseMoneyInput };
|
|
578
|
+
//# sourceMappingURL=money.js.map
|