@classytic/primitives 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,7 +3,36 @@
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.0 — 2026-10-11
7
+
8
+ ### Added — `./quantity`: the 6-decimal quantity grid for fractional stock and sale quantities
9
+ - `snapQuantity(qty, step?, mode)` (half-even by default), `isOnStep`, `compareQuantity`, `quantitiesEqual`,
10
+ `quantityToGrid`, `quantityStepToGrid`, `QUANTITY_SCALE` (1,000,000) and `QuantityError`
11
+ (`NOT_FINITE` / `INVALID_STEP` / `UNSAFE_QUANTITY`).
12
+ - `isOnQuantityGrid(value)`: STRICT, for input boundaries. `isOnStep` snaps first, so
13
+ `isOnStep(2.3500004, 0.001)` is true; `isOnQuantityGrid(2.3500004)` is false.
14
+
15
+ ### Added — `./usage-pricing`
16
+ - `priceUsage(rating, measured)` prices a measured amount against a `UsageRating` (per-unit, tiered, volume,
17
+ package; a started unit counts whole; breakdown included), plus `UsageMeasure` / `isUsageMeasure` and
18
+ `UsagePricingError`.
19
+
20
+ ### Added — the stock unit group on item facts
21
+ - `uomGroupRef` (optional) on `ItemStockSource`, `ProductFacet` and `ItemStockFacts`, and `unitOfMeasureDeclared`
22
+ (optional) on `ProductFacet` and `ItemStockFacts`. `resolveStockFacts` takes the Item's group, falls back to
23
+ the product's only when no Item exists, and marks an ungrouped Item under a grouped product
24
+ `unitOfMeasureDeclared` instead of borrowing the product's group. All fields optional: existing values of these
25
+ types still type-check.
26
+
27
+ ### Changed — `resolvePriceBasisTotal` accepts a fractional quantity
28
+ - A whole quantity is computed exactly as before, across the full safe-integer range (pinned by a regression
29
+ test against the previous formula).
30
+ - A fractional quantity on the 6-decimal grid (e.g. `2.35`) is priced exactly: price × quantity on the grid,
31
+ divided by the measured basis, rounded ONCE half-even. Previously it threw `INVALID_QUANTITY`.
32
+ - Still refused (`INVALID_QUANTITY`): a negative, non-finite or off-grid quantity (a 7th decimal), or a
33
+ fraction too large to scale exactly.
34
+
35
+ ## 0.30.0 — never published separately; shipped in 0.31.0
7
36
 
8
37
  ### Added — `./durable-events`: the declaration and the ONE kernel dispatch
9
38
  - `EventDurability`, `DurableEventSpec`, `FactExpectation`; `eventIdFor` / `durableEventMeta` (`${name}:v${version}:${occurrence}`); `requireDurableContext` + `NonDurablePublishError`; `UnmanagedSessionError`.
@@ -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),
@@ -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");
@@ -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
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
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;
@@ -0,0 +1,53 @@
1
+ import { n as Money } from "./money-C9iLWiWR.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.0",
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",