@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/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