@classytic/primitives 0.30.0 → 0.31.1

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/CHANGELOG.md CHANGED
@@ -3,7 +3,56 @@
3
3
  Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4
4
  adhering to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
- ## 0.30.0 — 2026-10-11
6
+ ## 0.31.1 — 2026-10-11
7
+
8
+ ### Fixed — `convertWithSnapshot` is exact (`./currency`)
9
+ - The conversion was computed in floating point (`minor / sourceFactor × rate × baseFactor`) and then
10
+ rounded, so a result that lands EXACTLY on a rounding boundary could round the wrong way. USD 5.30 at
11
+ 110.15 is exactly 583.795 BDT; the float product was 583.79499…, giving 58379 paisa instead of 58380.
12
+ Over 1..99,999 cents the float form was off by one minor unit for 134 amounts at 110.15, 1,455 at 117.35
13
+ and 1,379 at 122.35.
14
+ - The rate is now read as the decimal it prints as (`String(rate)`, exponent form included) and the
15
+ conversion is done in BigInt, rounded once. Signature and rounding modes are unchanged
16
+ (`half-away-from-zero` default, `floor`, `ceil`).
17
+ - What changes: only results that were wrong. Under `half-away-from-zero`, amounts whose exact value is a
18
+ half-way point now round away from zero (one minor unit up in magnitude). Under `floor` / `ceil`, amounts
19
+ whose exact value is an integer are no longer nudged one unit off it (USD 2.20 at 110.15 `ceil` was 24234,
20
+ now 24233). A negative amount that rounds to zero now returns `0`, not `-0`. Every other result is
21
+ identical (checked against the float form across ~10.9M amount × rate × currency-pair × mode cases).
22
+ - `convertWithSnapshot` now throws `TypeError` for a non-integer `source.amount` (a `Money` invariant that
23
+ `money()` already enforces), rather than converting a fractional minor unit.
24
+ - `reverseWithSnapshot` is unchanged.
25
+
26
+ ## 0.31.0 — 2026-10-11
27
+
28
+ ### Added — `./quantity`: the 6-decimal quantity grid for fractional stock and sale quantities
29
+ - `snapQuantity(qty, step?, mode)` (half-even by default), `isOnStep`, `compareQuantity`, `quantitiesEqual`,
30
+ `quantityToGrid`, `quantityStepToGrid`, `QUANTITY_SCALE` (1,000,000) and `QuantityError`
31
+ (`NOT_FINITE` / `INVALID_STEP` / `UNSAFE_QUANTITY`).
32
+ - `isOnQuantityGrid(value)`: STRICT, for input boundaries. `isOnStep` snaps first, so
33
+ `isOnStep(2.3500004, 0.001)` is true; `isOnQuantityGrid(2.3500004)` is false.
34
+
35
+ ### Added — `./usage-pricing`
36
+ - `priceUsage(rating, measured)` prices a measured amount against a `UsageRating` (per-unit, tiered, volume,
37
+ package; a started unit counts whole; breakdown included), plus `UsageMeasure` / `isUsageMeasure` and
38
+ `UsagePricingError`.
39
+
40
+ ### Added — the stock unit group on item facts
41
+ - `uomGroupRef` (optional) on `ItemStockSource`, `ProductFacet` and `ItemStockFacts`, and `unitOfMeasureDeclared`
42
+ (optional) on `ProductFacet` and `ItemStockFacts`. `resolveStockFacts` takes the Item's group, falls back to
43
+ the product's only when no Item exists, and marks an ungrouped Item under a grouped product
44
+ `unitOfMeasureDeclared` instead of borrowing the product's group. All fields optional: existing values of these
45
+ types still type-check.
46
+
47
+ ### Changed — `resolvePriceBasisTotal` accepts a fractional quantity
48
+ - A whole quantity is computed exactly as before, across the full safe-integer range (pinned by a regression
49
+ test against the previous formula).
50
+ - A fractional quantity on the 6-decimal grid (e.g. `2.35`) is priced exactly: price × quantity on the grid,
51
+ divided by the measured basis, rounded ONCE half-even. Previously it threw `INVALID_QUANTITY`.
52
+ - Still refused (`INVALID_QUANTITY`): a negative, non-finite or off-grid quantity (a 7th decimal), or a
53
+ fraction too large to scale exactly.
54
+
55
+ ## 0.30.0 — never published separately; shipped in 0.31.0
7
56
 
8
57
  ### Added — `./durable-events`: the declaration and the ONE kernel dispatch
9
58
  - `EventDurability`, `DurableEventSpec`, `FactExpectation`; `eventIdFor` / `durableEventMeta` (`${name}:v${version}:${occurrence}`); `requireDurableContext` + `NonDurablePublishError`; `UnmanagedSessionError`.
@@ -1,4 +1,4 @@
1
- import { n as Money } from "./money-C9iLWiWR.mjs";
1
+ import { n as Money } from "./money-BGPS_Ena.mjs";
2
2
  //#region src/money/bank-transaction.d.ts
3
3
  /**
4
4
  * The other side of a bank transaction. All fields optional — different
@@ -1,2 +1,2 @@
1
- import { A as FxRounding, B as reverseWithSnapshot, D as CURRENCY_PATTERN, E as CURRENCIES, F as currencyCode, I as isCurrencyCode, L as isFxSnapshot, M as InvalidCurrencyCodeError, N as MINOR_UNIT_FACTOR, O as CurrencyCode, P as convertWithSnapshot, R as isKnownCurrency, V as toCurrencyCode, j as FxSnapshot, k as CurrencyMismatchError, z as minorUnitFactor } from "./money-C9iLWiWR.mjs";
1
+ import { A as FxRounding, B as reverseWithSnapshot, D as CURRENCY_PATTERN, E as CURRENCIES, F as currencyCode, I as isCurrencyCode, L as isFxSnapshot, M as InvalidCurrencyCodeError, N as MINOR_UNIT_FACTOR, O as CurrencyCode, P as convertWithSnapshot, R as isKnownCurrency, V as toCurrencyCode, j as FxSnapshot, k as CurrencyMismatchError, z as minorUnitFactor } from "./money-BGPS_Ena.mjs";
2
2
  export { CURRENCIES, CURRENCY_PATTERN, CurrencyCode, CurrencyMismatchError, FxRounding, FxSnapshot, InvalidCurrencyCodeError, MINOR_UNIT_FACTOR, convertWithSnapshot, currencyCode, isCurrencyCode, isFxSnapshot, isKnownCurrency, minorUnitFactor, reverseWithSnapshot, toCurrencyCode };
package/dist/currency.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { t as roundedDiv } from "./rounded-div-BWJb98Ws.mjs";
1
2
  //#region src/money/currency.ts
2
3
  /**
3
4
  * Common ISO 4217 codes. Non-exhaustive — add more at the consumer level if
@@ -179,6 +180,34 @@ function roundMinor(raw, mode) {
179
180
  if (mode === "ceil") return Math.ceil(raw);
180
181
  return Math.round(Math.abs(raw)) * (raw < 0 ? -1 : 1);
181
182
  }
183
+ /** `numerator / denominator` (denominator > 0) landed exactly by `mode`. */
184
+ function divideMinor(numerator, denominator, mode) {
185
+ if (mode === "half-away-from-zero") return roundedDiv(numerator, denominator, "half-up");
186
+ const quotient = numerator / denominator;
187
+ if (numerator % denominator === 0n) return quotient;
188
+ if (mode === "floor") return numerator < 0n ? quotient - 1n : quotient;
189
+ return numerator < 0n ? quotient : quotient + 1n;
190
+ }
191
+ const DECIMAL_NUMBER = /^(-?)(\d+)(?:\.(\d+))?(?:e([+-]\d+))?$/;
192
+ /**
193
+ * A finite number as the exact decimal its shortest round-trip string names:
194
+ * `110.15` → `{ int: 11015n, scale: 2 }`, so `110.15` means 110.15, not the
195
+ * binary double nearest to it.
196
+ */
197
+ function decimalOf(value) {
198
+ const match = DECIMAL_NUMBER.exec(String(value));
199
+ if (match === null) throw new TypeError(`Cannot read ${value} as a decimal`);
200
+ const [, sign, whole, fraction = "", exponent = "0"] = match;
201
+ const digits = BigInt(`${sign}${whole}${fraction}`);
202
+ const power = Number(exponent) - fraction.length;
203
+ return power >= 0 ? {
204
+ int: digits * 10n ** BigInt(power),
205
+ scale: 0
206
+ } : {
207
+ int: digits,
208
+ scale: -power
209
+ };
210
+ }
182
211
  /**
183
212
  * Apply an {@link FxSnapshot} to a source-currency amount.
184
213
  *
@@ -198,12 +227,23 @@ function roundMinor(raw, mode) {
198
227
  * (USD has 2, JPY has 0). The conversion therefore goes
199
228
  * `minor → major → ×rate → base minor`, not a naive `amount × rate`, which is
200
229
  * off by 100× for any pair whose exponents differ.
230
+ *
231
+ * ## Exact, rounded once
232
+ *
233
+ * The rate is read as the decimal it prints as and the product is computed in
234
+ * BigInt: `minor × rate × baseFactor / sourceFactor`, rounded once. A float
235
+ * product misses exact half-way points (USD 5.30 × 110.15 = 583.795 BDT
236
+ * computes as 583.79499…), so it rounded to 58379 paisa instead of 58380.
201
237
  */
202
238
  function convertWithSnapshot(source, fx, options = {}) {
203
239
  if (source.currency !== fx.sourceCurrency) throw new CurrencyMismatchError(source.currency, fx.sourceCurrency);
204
240
  if (!Number.isFinite(fx.rate)) throw new TypeError(`FxSnapshot.rate must be finite, got ${fx.rate}`);
241
+ if (!Number.isInteger(source.amount)) throw new TypeError(`Money.amount must be an integer in minor units, got ${source.amount}`);
242
+ const rate = decimalOf(fx.rate);
243
+ const numerator = BigInt(source.amount) * rate.int * BigInt(minorUnitFactor(fx.baseCurrency));
244
+ const denominator = BigInt(minorUnitFactor(fx.sourceCurrency)) * 10n ** BigInt(rate.scale);
205
245
  return {
206
- amount: roundMinor(source.amount / minorUnitFactor(fx.sourceCurrency) * fx.rate * minorUnitFactor(fx.baseCurrency), options.rounding ?? "half-away-from-zero"),
246
+ amount: Number(divideMinor(numerator, denominator, options.rounding ?? "half-away-from-zero")),
207
247
  currency: currencyCode(fx.baseCurrency)
208
248
  };
209
249
  }
@@ -9,6 +9,8 @@ interface ItemStockSource {
9
9
  displayName: string;
10
10
  /** The canonical STOCK unit — what quants and moves are balanced in. */
11
11
  uom: string;
12
+ /** The stock unit's UoM group (`_id` or `code`); its base unit is `uom` and its rounding is the posting step. */
13
+ uomGroupRef?: string | undefined;
12
14
  trackingMode: TrackingMode;
13
15
  trackingPolicy?: ShelfLifePolicy | undefined;
14
16
  /**
@@ -29,6 +31,10 @@ interface ProductFacet {
29
31
  sku: string;
30
32
  displayName: string;
31
33
  uom?: string | undefined;
34
+ /** Fallback group for a product-only deployment; never read when an Item exists. */
35
+ uomGroupRef?: string | undefined;
36
+ /** The product declares a unit of measure, even if its group was lost on the way here. */
37
+ unitOfMeasureDeclared?: boolean | undefined;
32
38
  trackingMode?: TrackingMode | undefined;
33
39
  trackingPolicy?: ShelfLifePolicy | undefined;
34
40
  catchWeight?: boolean | undefined;
@@ -46,6 +52,14 @@ interface ItemStockFacts {
46
52
  trackingMode: TrackingMode;
47
53
  trackingPolicy?: ShelfLifePolicy;
48
54
  uom: string;
55
+ /** UoM group whose rounding a posting snaps to. Absent ⇒ the bare quantity grid. */
56
+ uomGroupRef?: string;
57
+ /**
58
+ * A source declared a unit of measure, so a missing `uomGroupRef` must be
59
+ * REFUSED by the stock engine, never read as "no rounding". Not set by `uom`
60
+ * alone: every Item has a `uom`.
61
+ */
62
+ unitOfMeasureDeclared?: boolean;
49
63
  catchWeight?: boolean;
50
64
  weightUom?: string;
51
65
  weight?: number;
@@ -64,6 +78,11 @@ interface ItemStockFacts {
64
78
  * `isActive` follows the ITEM when one exists: a product withdrawn from sale is
65
79
  * still stock on a shelf, whereas a retired item is not stockable at all. Taking
66
80
  * the product's flag would make unlisting a product silently orphan its stock.
81
+ *
82
+ * `uomGroupRef` follows the ITEM when one exists. A product group under an Item
83
+ * that declares none is never borrowed (its step is in a unit the Item may not
84
+ * count in): the facts carry `unitOfMeasureDeclared: true` and no group, so the
85
+ * stock engine refuses the posting until the Item declares its own.
67
86
  */
68
87
  declare function resolveStockFacts(input: {
69
88
  item?: ItemStockSource | null | undefined;
@@ -2,6 +2,7 @@
2
2
  /** Unit assumed when neither side declares one. A deployment-wide guess, and a bad one — set a real `uom` on the Item. */
3
3
  const FALLBACK_UOM = "unit";
4
4
  const defined = (key, value) => value === void 0 ? {} : { [key]: value };
5
+ const declaresGroup = (p) => !!p && (p.uomGroupRef !== void 0 || p.unitOfMeasureDeclared === true);
5
6
  /**
6
7
  * Merge an Item with its Product facet.
7
8
  *
@@ -13,6 +14,11 @@ const defined = (key, value) => value === void 0 ? {} : { [key]: value };
13
14
  * `isActive` follows the ITEM when one exists: a product withdrawn from sale is
14
15
  * still stock on a shelf, whereas a retired item is not stockable at all. Taking
15
16
  * the product's flag would make unlisting a product silently orphan its stock.
17
+ *
18
+ * `uomGroupRef` follows the ITEM when one exists. A product group under an Item
19
+ * that declares none is never borrowed (its step is in a unit the Item may not
20
+ * count in): the facts carry `unitOfMeasureDeclared: true` and no group, so the
21
+ * stock engine refuses the posting until the Item declares its own.
16
22
  */
17
23
  function resolveStockFacts(input) {
18
24
  const { item, product } = input;
@@ -24,6 +30,8 @@ function resolveStockFacts(input) {
24
30
  sku: p.sku,
25
31
  displayName: p.displayName,
26
32
  uom: p.uom ?? FALLBACK_UOM,
33
+ ...defined("uomGroupRef", p.uomGroupRef),
34
+ ...p.unitOfMeasureDeclared === true ? { unitOfMeasureDeclared: true } : {},
27
35
  trackingMode: p.trackingMode ?? "none",
28
36
  ...defined("trackingPolicy", p.trackingPolicy),
29
37
  ...defined("catchWeight", p.catchWeight),
@@ -39,6 +47,8 @@ function resolveStockFacts(input) {
39
47
  sku: product?.sku ?? item.sku,
40
48
  displayName: product?.displayName ?? item.displayName,
41
49
  uom: item.uom,
50
+ ...defined("uomGroupRef", item.uomGroupRef),
51
+ ...item.uomGroupRef === void 0 && declaresGroup(product) ? { unitOfMeasureDeclared: true } : {},
42
52
  trackingMode: item.trackingMode,
43
53
  ...defined("trackingPolicy", item.trackingPolicy),
44
54
  ...defined("catchWeight", item.catchWeight),
@@ -1,4 +1,4 @@
1
- import { n as Money } from "./money-C9iLWiWR.mjs";
1
+ import { n as Money } from "./money-BGPS_Ena.mjs";
2
2
  //#region src/money/monetization.d.ts
3
3
  /**
4
4
  * A map of currency code → {@link Money} for explicit per-currency pricing.
@@ -172,6 +172,13 @@ type FxRounding = 'half-away-from-zero' | 'floor' | 'ceil';
172
172
  * (USD has 2, JPY has 0). The conversion therefore goes
173
173
  * `minor → major → ×rate → base minor`, not a naive `amount × rate`, which is
174
174
  * off by 100× for any pair whose exponents differ.
175
+ *
176
+ * ## Exact, rounded once
177
+ *
178
+ * The rate is read as the decimal it prints as and the product is computed in
179
+ * BigInt: `minor × rate × baseFactor / sourceFactor`, rounded once. A float
180
+ * product misses exact half-way points (USD 5.30 × 110.15 = 583.795 BDT
181
+ * computes as 583.79499…), so it rounded to 58379 paisa instead of 58380.
175
182
  */
176
183
  declare function convertWithSnapshot(source: Money, fx: FxSnapshot, options?: {
177
184
  rounding?: FxRounding;
package/dist/money.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { C as CashDestination, S as CASH_DESTINATIONS, T as isCashDestination, _ as negateMoney, a as compareMoney, b as sumMoney, c as fromMajor, d as isPositiveMoney, f as isZeroMoney, g as multiplyMoney, h as money, i as addMoney, k as CurrencyMismatchError, l as isMoney, m as minorUnitsToMajor, n as Money, o as equalsMoney, p as majorToMinorUnits, r as absMoney, s as formatMinorUnits, t as MinorUnits, u as isNegativeMoney, v as percentOfMinor, w as cashDestinationLabel, x as toMajor, y as subtractMoney } from "./money-C9iLWiWR.mjs";
1
+ import { C as CashDestination, S as CASH_DESTINATIONS, T as isCashDestination, _ as negateMoney, a as compareMoney, b as sumMoney, c as fromMajor, d as isPositiveMoney, f as isZeroMoney, g as multiplyMoney, h as money, i as addMoney, k as CurrencyMismatchError, l as isMoney, m as minorUnitsToMajor, n as Money, o as equalsMoney, p as majorToMinorUnits, r as absMoney, s as formatMinorUnits, t as MinorUnits, u as isNegativeMoney, v as percentOfMinor, w as cashDestinationLabel, x as toMajor, y as subtractMoney } from "./money-BGPS_Ena.mjs";
2
2
  export { CASH_DESTINATIONS, type CashDestination, CurrencyMismatchError, MinorUnits, Money, absMoney, addMoney, cashDestinationLabel, compareMoney, equalsMoney, formatMinorUnits, fromMajor, isCashDestination, isMoney, isNegativeMoney, isPositiveMoney, isZeroMoney, majorToMinorUnits, minorUnitsToMajor, money, multiplyMoney, negateMoney, percentOfMinor, subtractMoney, sumMoney, toMajor };
@@ -1,4 +1,4 @@
1
- import { n as Money } from "./money-C9iLWiWR.mjs";
1
+ import { n as Money } from "./money-BGPS_Ena.mjs";
2
2
  import { PaymentMethodKind } from "./payment-method-kind.mjs";
3
3
  import { ProviderUnknownCause } from "./payment-gateway.mjs";
4
4
  //#region src/events/payment-events.d.ts
@@ -1,4 +1,4 @@
1
- import { n as Money } from "./money-C9iLWiWR.mjs";
1
+ import { n as Money } from "./money-BGPS_Ena.mjs";
2
2
  import { PaymentMethodKind } from "./payment-method-kind.mjs";
3
3
  //#region src/money/payment-gateway.d.ts
4
4
  /**
@@ -8,7 +8,7 @@ type MeasureDimension = 'mass' | 'duration' | 'volume' | 'length' | 'area';
8
8
  interface MeasuredPriceBasis {
9
9
  readonly kind: 'measured';
10
10
  readonly dimension: MeasureDimension;
11
- /** Integer unit carried by the line quantity, such as `gram` or `minute`. */
11
+ /** Unit carried by the line quantity, such as `gram` or `minute`; the quantity may be fractional. */
12
12
  readonly quantityUnit: string;
13
13
  /** Unit named by the quoted price, such as `kilogram` or `hour`. */
14
14
  readonly priceUnit: string;
@@ -25,7 +25,10 @@ declare function fixedPriceBasis(): FixedPriceBasis;
25
25
  declare function measuredPriceBasis(dimension: MeasureDimension, quantityUnit: string, priceUnit: string, quantityPerPriceUnit: number): MeasuredPriceBasis;
26
26
  /** Structural guard for database and wire boundaries. */
27
27
  declare function isPriceBasis(value: unknown): value is PriceBasis;
28
- /** Extend a minor-unit price using integer quantity and exact half-even rounding. */
28
+ /**
29
+ * Extend a minor-unit price across a non-negative quantity, exactly, on the 6-dp quantity grid,
30
+ * rounding once (half-even). A quantity off the grid throws.
31
+ */
29
32
  declare function resolvePriceBasisTotal(unitPriceMinor: number, quantity: number, basis?: PriceBasis): number;
30
33
  //#endregion
31
34
  export { FixedPriceBasis, MeasureDimension, MeasuredPriceBasis, PriceBasis, PriceBasisError, PriceBasisErrorCode, fixedPriceBasis, isPriceBasis, measuredPriceBasis, resolvePriceBasisTotal };
@@ -1,4 +1,5 @@
1
1
  import { t as roundedDiv } from "./rounded-div-BWJb98Ws.mjs";
2
+ import { QUANTITY_SCALE, isOnQuantityGrid, quantityToGrid } from "./quantity.mjs";
2
3
  //#region src/money/price-basis.ts
3
4
  var PriceBasisError = class extends Error {
4
5
  name = "PriceBasisError";
@@ -36,12 +37,17 @@ function isPriceBasis(value) {
36
37
  if (candidate.kind === "fixed") return true;
37
38
  return candidate.kind === "measured" && MEASURE_DIMENSIONS.has(candidate.dimension) && typeof candidate.quantityUnit === "string" && candidate.quantityUnit.trim() !== "" && typeof candidate.priceUnit === "string" && candidate.priceUnit.trim() !== "" && typeof candidate.quantityPerPriceUnit === "number" && Number.isSafeInteger(candidate.quantityPerPriceUnit) && candidate.quantityPerPriceUnit > 0;
38
39
  }
39
- /** Extend a minor-unit price using integer quantity and exact half-even rounding. */
40
+ /**
41
+ * Extend a minor-unit price across a non-negative quantity, exactly, on the 6-dp quantity grid,
42
+ * rounding once (half-even). A quantity off the grid throws.
43
+ */
40
44
  function resolvePriceBasisTotal(unitPriceMinor, quantity, basis = fixedPriceBasis()) {
41
45
  if (!Number.isSafeInteger(unitPriceMinor) || unitPriceMinor < 0) throw new PriceBasisError("INVALID_PRICE", "unitPriceMinor must be a non-negative safe integer");
42
- if (!Number.isSafeInteger(quantity) || quantity < 0) throw new PriceBasisError("INVALID_QUANTITY", "quantity must be a non-negative safe integer");
43
- const numerator = BigInt(unitPriceMinor) * BigInt(quantity);
44
- const denominator = basis.kind === "measured" ? BigInt(basis.quantityPerPriceUnit) : 1n;
46
+ if (!(quantity >= 0) || !(Number.isSafeInteger(quantity) || isOnQuantityGrid(quantity))) throw new PriceBasisError("INVALID_QUANTITY", `quantity ${quantity} must be a non-negative integer, or a fraction on the 1/${QUANTITY_SCALE} grid`);
47
+ const whole = Number.isSafeInteger(quantity);
48
+ const perPriceUnit = basis.kind === "measured" ? BigInt(basis.quantityPerPriceUnit) : 1n;
49
+ const numerator = BigInt(unitPriceMinor) * (whole ? BigInt(quantity) : quantityToGrid(quantity));
50
+ const denominator = whole ? perPriceUnit : perPriceUnit * BigInt(QUANTITY_SCALE);
45
51
  const rounded = roundedDiv(numerator, denominator, "half-even");
46
52
  const total = Number(rounded);
47
53
  if (!Number.isSafeInteger(total)) throw new PriceBasisError("UNSAFE_TOTAL", "resolved total exceeds Number.MAX_SAFE_INTEGER");
@@ -1,4 +1,4 @@
1
- import { n as Money } from "./money-C9iLWiWR.mjs";
1
+ import { n as Money } from "./money-BGPS_Ena.mjs";
2
2
  //#region src/money/proration.d.ts
3
3
  /**
4
4
  * How to count days within a period.
@@ -0,0 +1,38 @@
1
+ import { t as RoundingMode } from "./rounded-div-D7lyJXt8.mjs";
2
+ //#region src/inventory/quantity.d.ts
3
+ /**
4
+ * Grid units per stock unit. Equal to `unit-cost-rate`'s internal `QTY_SCALE`;
5
+ * `tests/unit/quantity.test.ts` pins the equality through `extendedAmount`.
6
+ */
7
+ declare const QUANTITY_SCALE = 1000000;
8
+ type QuantityErrorCode = 'NOT_FINITE' | 'INVALID_STEP' | 'UNSAFE_QUANTITY';
9
+ declare class QuantityError extends Error {
10
+ readonly name = "QuantityError";
11
+ readonly code: QuantityErrorCode;
12
+ constructor(code: QuantityErrorCode, message: string);
13
+ }
14
+ /** A quantity as a signed integer count of grid units (sign-symmetric half-even). */
15
+ declare function quantityToGrid(quantity: number): bigint;
16
+ /** The step in grid units. Refuses a non-positive step, one finer than the grid, or one off it. */
17
+ declare function quantityStepToGrid(step: number): bigint;
18
+ /**
19
+ * Round `quantity` to the nearest multiple of `step` (default: the bare grid).
20
+ *
21
+ * @example snapQuantity(10 - 2.35 - 2.35, 0.001) // 5.3
22
+ * @example snapQuantity(2.3505, 0.001) // 2.35 (half-even)
23
+ */
24
+ declare function snapQuantity(quantity: number, step?: number, mode?: RoundingMode): number;
25
+ /**
26
+ * Does `value` lie ON the grid (within float noise)? Strict, unlike {@link isOnStep}, which snaps
27
+ * first: `2.3500004` is off the grid here and on the 0.001 step there. Check this at an input
28
+ * boundary, where a 7th decimal is a caller error rather than float drift.
29
+ */
30
+ declare function isOnQuantityGrid(value: number): boolean;
31
+ /** Is `quantity` a whole multiple of `step`, within half a grid unit? */
32
+ declare function isOnStep(quantity: number, step: number): boolean;
33
+ /** Compare two quantities on the grid: equal when they snap to the same grid unit. */
34
+ declare function compareQuantity(a: number, b: number): -1 | 0 | 1;
35
+ /** `compareQuantity(a, b) === 0`. */
36
+ declare function quantitiesEqual(a: number, b: number): boolean;
37
+ //#endregion
38
+ export { QUANTITY_SCALE, QuantityError, QuantityErrorCode, type RoundingMode, compareQuantity, isOnQuantityGrid, isOnStep, quantitiesEqual, quantityStepToGrid, quantityToGrid, snapQuantity };
@@ -0,0 +1,88 @@
1
+ import { t as roundedDiv } from "./rounded-div-BWJb98Ws.mjs";
2
+ //#region src/inventory/quantity.ts
3
+ /**
4
+ * A decimal stock quantity snapped to its unit's rounding step, on the fixed
5
+ * {@link QUANTITY_SCALE} grid (6 dp — the grid `unit-cost-rate` extends money on).
6
+ *
7
+ * Snap at every write boundary: `10 - 2.35 - 2.35` is `5.300000000000001` in
8
+ * IEEE doubles, and a stored drift never comes back out.
9
+ *
10
+ * Rounding is half-even, the package's money mode: a quantity snap is a
11
+ * per-event normalisation, and half-up biases a running stock balance upward
12
+ * across thousands of weighings.
13
+ */
14
+ /**
15
+ * Grid units per stock unit. Equal to `unit-cost-rate`'s internal `QTY_SCALE`;
16
+ * `tests/unit/quantity.test.ts` pins the equality through `extendedAmount`.
17
+ */
18
+ const QUANTITY_SCALE = 1e6;
19
+ var QuantityError = class extends Error {
20
+ name = "QuantityError";
21
+ code;
22
+ constructor(code, message) {
23
+ super(message);
24
+ this.code = code;
25
+ }
26
+ };
27
+ /** A quantity as a signed integer count of grid units (sign-symmetric half-even). */
28
+ function quantityToGrid(quantity) {
29
+ if (!Number.isFinite(quantity)) throw new QuantityError("NOT_FINITE", `quantity must be finite, got ${quantity}`);
30
+ const scaled = Math.abs(quantity) * QUANTITY_SCALE;
31
+ if (!Number.isSafeInteger(Math.ceil(scaled))) throw new QuantityError("UNSAFE_QUANTITY", `quantity ${quantity} exceeds the precise grid range`);
32
+ const floor = Math.floor(scaled);
33
+ const frac = scaled - floor;
34
+ const up = frac > .5 || frac === .5 && floor % 2 === 1;
35
+ const magnitude = BigInt(up ? floor + 1 : floor);
36
+ return quantity < 0 ? -magnitude : magnitude;
37
+ }
38
+ function gridToNumber(grid) {
39
+ const value = Number(grid) / QUANTITY_SCALE;
40
+ return value === 0 ? 0 : value;
41
+ }
42
+ /** The step in grid units. Refuses a non-positive step, one finer than the grid, or one off it. */
43
+ function quantityStepToGrid(step) {
44
+ if (!Number.isFinite(step) || step <= 0) throw new QuantityError("INVALID_STEP", `rounding step must be a positive number, got ${step}`);
45
+ const grid = quantityToGrid(step);
46
+ const scaled = step * QUANTITY_SCALE;
47
+ if (grid === 0n || Math.abs(Number(grid) - scaled) > 1e-9 * Math.max(1, scaled)) throw new QuantityError("INVALID_STEP", `rounding step ${step} is not a multiple of 1/${QUANTITY_SCALE}`);
48
+ return grid;
49
+ }
50
+ /**
51
+ * Round `quantity` to the nearest multiple of `step` (default: the bare grid).
52
+ *
53
+ * @example snapQuantity(10 - 2.35 - 2.35, 0.001) // 5.3
54
+ * @example snapQuantity(2.3505, 0.001) // 2.35 (half-even)
55
+ */
56
+ function snapQuantity(quantity, step, mode = "half-even") {
57
+ const grid = quantityToGrid(quantity);
58
+ if (step === void 0) return gridToNumber(grid);
59
+ const stepGrid = quantityStepToGrid(step);
60
+ return gridToNumber(roundedDiv(grid, stepGrid, mode) * stepGrid);
61
+ }
62
+ /**
63
+ * Does `value` lie ON the grid (within float noise)? Strict, unlike {@link isOnStep}, which snaps
64
+ * first: `2.3500004` is off the grid here and on the 0.001 step there. Check this at an input
65
+ * boundary, where a 7th decimal is a caller error rather than float drift.
66
+ */
67
+ function isOnQuantityGrid(value) {
68
+ if (!Number.isFinite(value)) return false;
69
+ const scaled = value * QUANTITY_SCALE;
70
+ const grid = Math.round(scaled);
71
+ return Number.isSafeInteger(grid) && Math.abs(grid - scaled) <= 1e-9 * Math.max(1, Math.abs(scaled));
72
+ }
73
+ /** Is `quantity` a whole multiple of `step`, within half a grid unit? */
74
+ function isOnStep(quantity, step) {
75
+ return quantityToGrid(quantity) % quantityStepToGrid(step) === 0n;
76
+ }
77
+ /** Compare two quantities on the grid: equal when they snap to the same grid unit. */
78
+ function compareQuantity(a, b) {
79
+ const ga = quantityToGrid(a);
80
+ const gb = quantityToGrid(b);
81
+ return ga === gb ? 0 : ga < gb ? -1 : 1;
82
+ }
83
+ /** `compareQuantity(a, b) === 0`. */
84
+ function quantitiesEqual(a, b) {
85
+ return compareQuantity(a, b) === 0;
86
+ }
87
+ //#endregion
88
+ export { QUANTITY_SCALE, QuantityError, compareQuantity, isOnQuantityGrid, isOnStep, quantitiesEqual, quantityStepToGrid, quantityToGrid, snapQuantity };
@@ -0,0 +1,4 @@
1
+ //#region src/money/rounded-div.d.ts
2
+ type RoundingMode = 'half-even' | 'half-up';
3
+ //#endregion
4
+ export { RoundingMode as t };
@@ -1,7 +1,5 @@
1
- import { O as CurrencyCode, t as MinorUnits } from "./money-C9iLWiWR.mjs";
2
- //#region src/money/rounded-div.d.ts
3
- type RoundingMode = 'half-even' | 'half-up';
4
- //#endregion
1
+ import { O as CurrencyCode, t as MinorUnits } from "./money-BGPS_Ena.mjs";
2
+ import { t as RoundingMode } from "./rounded-div-D7lyJXt8.mjs";
5
3
  //#region src/money/unit-cost-rate.d.ts
6
4
  /** Fixed, ecosystem-wide rate scale: 6 decimal places below the minor unit. */
7
5
  declare const RATE_SCALE = 1000000;
@@ -1,5 +1,5 @@
1
- import { t as MinorUnits } from "./money-BBOARE5f.mjs";
2
1
  import { t as roundedDiv } from "./rounded-div-BWJb98Ws.mjs";
2
+ import { t as MinorUnits } from "./money-BBOARE5f.mjs";
3
3
  //#region src/money/unit-cost-rate.ts
4
4
  /** Fixed, ecosystem-wide rate scale: 6 decimal places below the minor unit. */
5
5
  const RATE_SCALE = 1e6;
@@ -0,0 +1,53 @@
1
+ import { n as Money } from "./money-BGPS_Ena.mjs";
2
+ import { UsageRating } from "./monetization.mjs";
3
+ //#region src/money/usage-pricing.d.ts
4
+ /** What ONE unit of a sale line measured: `{ value: 2.3, unit: 'kg' }`. `unit` must equal the rating's `unitLabel`. */
5
+ interface UsageMeasure {
6
+ readonly value: number;
7
+ readonly unit: string;
8
+ }
9
+ /** Structural guard for wire and database boundaries: a positive finite value and a non-blank unit. */
10
+ declare function isUsageMeasure(value: unknown): value is UsageMeasure;
11
+ type UsagePricingErrorCode = 'INVALID_MEASURE' | 'INVALID_INCLUDED_UNITS' | 'UNKNOWN_SCHEME' | 'MISSING_PER_UNIT' | 'MISSING_TIERS' | 'INVALID_TIER' | 'MISSING_PACKAGE' | 'INVALID_MONEY' | 'CURRENCY_MISMATCH' | 'EXCEEDS_RATE_TABLE' | 'UNSAFE_TOTAL';
12
+ declare class UsagePricingError extends Error {
13
+ readonly name = "UsagePricingError";
14
+ readonly code: UsagePricingErrorCode;
15
+ constructor(code: UsagePricingErrorCode, message: string);
16
+ }
17
+ /** One priced line of the breakdown. All money fields are integer minor units. */
18
+ interface UsageChargeLine {
19
+ /** Index into `rating.tiers` for `tiered` / `volume`; `null` for `per_unit` / `package`. */
20
+ readonly band: number | null;
21
+ /** Units priced on this line — PACKAGES under the `package` scheme. */
22
+ readonly quantity: number;
23
+ /** Rate applied per `quantity` (0 when the band is flat-only). */
24
+ readonly unitAmount: number;
25
+ /** Flat charge for landing in the band (0 when none). */
26
+ readonly flatAmount: number;
27
+ /** `quantity × unitAmount + flatAmount`. */
28
+ readonly amount: number;
29
+ }
30
+ /** The priced result and the facts that produced it — freeze this beside the price. */
31
+ interface UsageCharge {
32
+ readonly scheme: UsageRating['scheme'];
33
+ readonly measured: number;
34
+ readonly includedUnits: number;
35
+ /** Whole units charged after `includedUnits`, rounded up. */
36
+ readonly billableUnits: number;
37
+ /** Packages charged — present only under the `package` scheme. */
38
+ readonly packages?: number;
39
+ readonly lines: readonly UsageChargeLine[];
40
+ readonly amount: Money;
41
+ readonly unitLabel?: string;
42
+ }
43
+ /**
44
+ * Price `measured` units against `rating`.
45
+ *
46
+ * `tiered` (graduated) charges each band for the units that fall inside it, plus the band's
47
+ * `flatPrice` when any unit lands there. `volume` charges ALL units at the band the total lands
48
+ * in. A bounded final band refuses a larger amount (`EXCEEDS_RATE_TABLE`) — a courier that does
49
+ * not carry 40 kg parcels says so, it does not quote them at the last rate.
50
+ */
51
+ declare function priceUsage(rating: UsageRating, measured: number): UsageCharge;
52
+ //#endregion
53
+ export { UsageCharge, UsageChargeLine, UsageMeasure, UsagePricingError, UsagePricingErrorCode, isUsageMeasure, priceUsage };
@@ -0,0 +1,150 @@
1
+ //#region src/money/usage-pricing.ts
2
+ /** Structural guard for wire and database boundaries: a positive finite value and a non-blank unit. */
3
+ function isUsageMeasure(value) {
4
+ if (typeof value !== "object" || value === null) return false;
5
+ const candidate = value;
6
+ return typeof candidate.value === "number" && Number.isFinite(candidate.value) && candidate.value > 0 && typeof candidate.unit === "string" && candidate.unit.trim() !== "";
7
+ }
8
+ var UsagePricingError = class extends Error {
9
+ name = "UsagePricingError";
10
+ code;
11
+ constructor(code, message) {
12
+ super(message);
13
+ this.code = code;
14
+ }
15
+ };
16
+ /**
17
+ * Price `measured` units against `rating`.
18
+ *
19
+ * `tiered` (graduated) charges each band for the units that fall inside it, plus the band's
20
+ * `flatPrice` when any unit lands there. `volume` charges ALL units at the band the total lands
21
+ * in. A bounded final band refuses a larger amount (`EXCEEDS_RATE_TABLE`) — a courier that does
22
+ * not carry 40 kg parcels says so, it does not quote them at the last rate.
23
+ */
24
+ function priceUsage(rating, measured) {
25
+ if (typeof measured !== "number" || !Number.isFinite(measured) || measured < 0) throw new UsagePricingError("INVALID_MEASURE", `measured must be a finite number >= 0, got ${String(measured)}`);
26
+ const includedUnits = rating.includedUnits ?? 0;
27
+ if (typeof includedUnits !== "number" || !Number.isFinite(includedUnits) || includedUnits < 0) throw new UsagePricingError("INVALID_INCLUDED_UNITS", `includedUnits must be a finite number >= 0, got ${String(includedUnits)}`);
28
+ const over = Number((measured - includedUnits).toPrecision(12));
29
+ const billableUnits = over > 0 ? Math.ceil(over) : 0;
30
+ const base = {
31
+ scheme: rating.scheme,
32
+ measured,
33
+ includedUnits,
34
+ billableUnits,
35
+ ...rating.unitLabel !== void 0 ? { unitLabel: rating.unitLabel } : {}
36
+ };
37
+ switch (rating.scheme) {
38
+ case "per_unit": {
39
+ const rate = requireMoney(rating.perUnit, "MISSING_PER_UNIT", "per_unit needs perUnit");
40
+ const lines = billableUnits > 0 ? [line(null, billableUnits, rate.amount, 0)] : [];
41
+ return {
42
+ ...base,
43
+ lines,
44
+ amount: total(lines, rate.currency)
45
+ };
46
+ }
47
+ case "package": {
48
+ if (!Number.isSafeInteger(rating.packageSize) || (rating.packageSize ?? 0) <= 0) throw new UsagePricingError("MISSING_PACKAGE", "package needs packageSize as a positive integer");
49
+ const price = requireMoney(rating.packagePrice, "MISSING_PACKAGE", "package needs packagePrice");
50
+ const packages = Math.ceil(billableUnits / rating.packageSize);
51
+ const lines = packages > 0 ? [line(null, packages, price.amount, 0)] : [];
52
+ return {
53
+ ...base,
54
+ packages,
55
+ lines,
56
+ amount: total(lines, price.currency)
57
+ };
58
+ }
59
+ case "tiered":
60
+ case "volume": {
61
+ const { tiers, currency } = validTiers(rating.tiers);
62
+ const lines = rating.scheme === "tiered" ? graduated(tiers, billableUnits) : volume(tiers, billableUnits);
63
+ return {
64
+ ...base,
65
+ lines,
66
+ amount: total(lines, currency)
67
+ };
68
+ }
69
+ default: {
70
+ const unknownScheme = rating.scheme;
71
+ throw new UsagePricingError("UNKNOWN_SCHEME", `unknown usage scheme: ${String(unknownScheme)}`);
72
+ }
73
+ }
74
+ }
75
+ function requireMoney(value, code, message) {
76
+ if (!value) throw new UsagePricingError(code, message);
77
+ assertMoney(value);
78
+ return value;
79
+ }
80
+ function assertMoney(value) {
81
+ if (!Number.isSafeInteger(value.amount) || value.amount < 0 || typeof value.currency !== "string") throw new UsagePricingError("INVALID_MONEY", `a rate must be a non-negative integer minor amount with a currency, got ${JSON.stringify(value)}`);
82
+ }
83
+ function validTiers(tiers) {
84
+ if (!Array.isArray(tiers) || tiers.length === 0) throw new UsagePricingError("MISSING_TIERS", "tiered and volume schemes need at least one tier");
85
+ let currency;
86
+ let previous = 0;
87
+ tiers.forEach((tier, i) => {
88
+ if (tier.perUnit === void 0 && tier.flatPrice === void 0) throw new UsagePricingError("INVALID_TIER", `tier ${i} has neither perUnit nor flatPrice`);
89
+ for (const money of [tier.perUnit, tier.flatPrice]) {
90
+ if (money === void 0) continue;
91
+ assertMoney(money);
92
+ if (currency === void 0) currency = money.currency;
93
+ else if (money.currency !== currency) throw new UsagePricingError("CURRENCY_MISMATCH", `tier ${i} is in ${money.currency}, the table in ${currency}`);
94
+ }
95
+ if (tier.upTo === void 0) {
96
+ if (i !== tiers.length - 1) throw new UsagePricingError("INVALID_TIER", `tier ${i} is open-ended but is not the last tier`);
97
+ return;
98
+ }
99
+ if (!Number.isSafeInteger(tier.upTo) || tier.upTo <= previous) throw new UsagePricingError("INVALID_TIER", `tier ${i} upTo must be an integer above the previous bound ${previous}, got ${String(tier.upTo)}`);
100
+ previous = tier.upTo;
101
+ });
102
+ return {
103
+ tiers,
104
+ currency
105
+ };
106
+ }
107
+ function graduated(tiers, units) {
108
+ const lines = [];
109
+ let floor = 0;
110
+ for (let i = 0; i < tiers.length && floor < units; i++) {
111
+ const tier = tiers[i];
112
+ const ceiling = tier.upTo ?? Number.POSITIVE_INFINITY;
113
+ const inBand = Math.min(units, ceiling) - floor;
114
+ lines.push(line(i, inBand, tier.perUnit?.amount ?? 0, tier.flatPrice?.amount ?? 0));
115
+ floor = ceiling;
116
+ }
117
+ if (floor < units) throw exceeds(units, floor);
118
+ return lines;
119
+ }
120
+ function volume(tiers, units) {
121
+ if (units === 0) return [];
122
+ const band = tiers.findIndex((tier) => tier.upTo === void 0 || units <= tier.upTo);
123
+ if (band === -1) throw exceeds(units, tiers[tiers.length - 1]?.upTo ?? 0);
124
+ const tier = tiers[band];
125
+ return [line(band, units, tier.perUnit?.amount ?? 0, tier.flatPrice?.amount ?? 0)];
126
+ }
127
+ function exceeds(units, limit) {
128
+ return new UsagePricingError("EXCEEDS_RATE_TABLE", `${units} units exceed the rate table's last bound ${limit}`);
129
+ }
130
+ function line(band, quantity, unitAmount, flatAmount) {
131
+ const amount = quantity * unitAmount + flatAmount;
132
+ if (!Number.isSafeInteger(amount)) throw new UsagePricingError("UNSAFE_TOTAL", "usage charge exceeds Number.MAX_SAFE_INTEGER");
133
+ return {
134
+ band,
135
+ quantity,
136
+ unitAmount,
137
+ flatAmount,
138
+ amount
139
+ };
140
+ }
141
+ function total(lines, currency) {
142
+ const amount = lines.reduce((sum, l) => sum + l.amount, 0);
143
+ if (!Number.isSafeInteger(amount)) throw new UsagePricingError("UNSAFE_TOTAL", "usage charge exceeds Number.MAX_SAFE_INTEGER");
144
+ return {
145
+ amount,
146
+ currency
147
+ };
148
+ }
149
+ //#endregion
150
+ export { UsagePricingError, isUsageMeasure, priceUsage };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/primitives",
3
- "version": "0.30.0",
3
+ "version": "0.31.1",
4
4
  "description": "Framework-agnostic TypeScript primitives shared across Classytic packages — Money, events, pagination, tenant config, and other building blocks.",
5
5
  "author": "Classytic",
6
6
  "license": "MIT",
@@ -297,6 +297,16 @@
297
297
  "types": "./dist/signed-token.d.mts",
298
298
  "default": "./dist/signed-token.mjs"
299
299
  },
300
+ "./quantity": {
301
+ "@classytic/source": "./src/inventory/quantity.ts",
302
+ "types": "./dist/quantity.d.mts",
303
+ "default": "./dist/quantity.mjs"
304
+ },
305
+ "./usage-pricing": {
306
+ "@classytic/source": "./src/money/usage-pricing.ts",
307
+ "types": "./dist/usage-pricing.d.mts",
308
+ "default": "./dist/usage-pricing.mjs"
309
+ },
300
310
  "./item-stock": {
301
311
  "@classytic/source": "./src/inventory/item-stock.ts",
302
312
  "types": "./dist/item-stock.d.mts",