@classytic/primitives 0.32.0 → 0.33.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,6 +3,15 @@
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.33.0 — 2026-10-14
7
+
8
+ ### Changed — `inventory/line-settlement` settles decimal quantities on the 6-dp grid
9
+ - `planSettlement`, `clampSettlement`, `settledTotal`, `outstandingOf`, `settledEntirelyIn` and `shareOfSettled` add, subtract and compare
10
+ on the grid (`addQuantity` / `subtractQuantity`), so 0.1 + 0.2 settles exactly 0.3 and a weighed line can be received in parts.
11
+ Whole-unit inputs behave as before.
12
+ - A non-positive or off-grid request is refused with code `not_positive_quantity` (was `not_positive_integer`; no in-repo consumer
13
+ branches on the old code).
14
+
6
15
  ## 0.32.0 — 2026-10-12
7
16
 
8
17
  ### Added — `./quantity`: the step and running-sum helpers every package was copying
@@ -10,7 +10,8 @@ import { Result } from "./result.mjs";
10
10
  * kernel's vocabulary; the arithmetic, the over-settlement guard and the state
11
11
  * derivation are this file's, so no two kernels can answer differently.
12
12
  *
13
- * Quantities are whole units. A bucket that is absent counts as zero.
13
+ * Quantities are decimals on the 6-dp grid (`./quantity`): whole units for a countable item, 2.35 for a weighed one.
14
+ * Every sum and comparison is exact on the grid. A bucket that is absent counts as zero.
14
15
  */
15
16
  interface SettlementLine<B extends string> {
16
17
  /** What the line promised to move. */
@@ -34,7 +35,7 @@ declare const documentSettlementState: <B extends string>(lines: ReadonlyArray<S
34
35
  /** `true` when every line is settled entirely through `bucket` (e.g. all received, nothing lost). */
35
36
  declare const settledEntirelyIn: <B extends string>(lines: ReadonlyArray<SettlementLine<B>>, bucket: B) => boolean;
36
37
  type SettlementRefusal = {
37
- readonly code: "not_positive_integer";
38
+ readonly code: "not_positive_quantity";
38
39
  readonly requested: number;
39
40
  } | {
40
41
  readonly code: "exceeds_outstanding";
@@ -44,7 +45,7 @@ type SettlementRefusal = {
44
45
  /**
45
46
  * Settle `quantity` more units into `bucket`, or refuse.
46
47
  *
47
- * Refuses a non-positive or fractional quantity and anything above what is
48
+ * Refuses a non-positive quantity, one off the 6-dp grid, and anything above what is
48
49
  * outstanding — an over-settlement is an input error, never silently trimmed.
49
50
  * Use {@link clampSettlement} only where the surface's CONTRACT is "take what fits".
50
51
  */
@@ -61,7 +62,7 @@ declare const clampSettlement: <B extends string>(line: SettlementLine<B>, bucke
61
62
  * The share of a line-level amount (a value or a cost, integer MINOR units) owed by one
62
63
  * settlement batch, given how many units the whole line had settled before and after it.
63
64
  *
64
- * `floor(total·after/q) − floor(total·before/q)` TELESCOPES: however a line is split into
65
+ * `floor(total·after/q) − floor(total·before/q)` (units on the 6-dp grid) TELESCOPES: however a line is split into
65
66
  * batches, and across every outcome bucket, the shares sum to exactly `total` once the line
66
67
  * is fully settled — no remainder to chase, no batch-order dependence.
67
68
  */
@@ -1,13 +1,14 @@
1
+ import { QUANTITY_SCALE, addQuantity, compareQuantity, isOnQuantityGrid, quantityToGrid, snapQuantity, subtractQuantity } from "./quantity.mjs";
1
2
  import { err, ok } from "./result.mjs";
2
3
  //#region src/inventory/line-settlement.ts
3
4
  /** Sum of every bucket. */
4
- const settledTotal = (line) => Object.values(line.settled).reduce((sum, n) => sum + (n ?? 0), 0);
5
+ const settledTotal = (line) => Object.values(line.settled).reduce((sum, n) => addQuantity(sum, n ?? 0), 0);
5
6
  /**
6
7
  * `quantity − Σ buckets`. NOT clamped at zero: a negative means the line was
7
8
  * over-settled by data written outside {@link planSettlement}, and hiding it
8
9
  * would erase the only signal that happened.
9
10
  */
10
- const outstandingOf = (line) => line.quantity - settledTotal(line);
11
+ const outstandingOf = (line) => subtractQuantity(line.quantity, settledTotal(line));
11
12
  /** `open` — nothing settled; `settled` — nothing outstanding; otherwise `partial`. */
12
13
  const settlementState = (line) => {
13
14
  if (outstandingOf(line) <= 0) return "settled";
@@ -21,21 +22,21 @@ const documentSettlementState = (lines) => {
21
22
  return "partial";
22
23
  };
23
24
  /** `true` when every line is settled entirely through `bucket` (e.g. all received, nothing lost). */
24
- const settledEntirelyIn = (lines, bucket) => lines.every((l) => (l.settled[bucket] ?? 0) >= l.quantity);
25
+ const settledEntirelyIn = (lines, bucket) => lines.every((l) => compareQuantity(l.settled[bucket] ?? 0, l.quantity) >= 0);
25
26
  /**
26
27
  * Settle `quantity` more units into `bucket`, or refuse.
27
28
  *
28
- * Refuses a non-positive or fractional quantity and anything above what is
29
+ * Refuses a non-positive quantity, one off the 6-dp grid, and anything above what is
29
30
  * outstanding — an over-settlement is an input error, never silently trimmed.
30
31
  * Use {@link clampSettlement} only where the surface's CONTRACT is "take what fits".
31
32
  */
32
33
  const planSettlement = (line, bucket, quantity) => {
33
- if (!Number.isInteger(quantity) || quantity <= 0) return err({
34
- code: "not_positive_integer",
34
+ if (!(quantity > 0) || !isOnQuantityGrid(quantity)) return err({
35
+ code: "not_positive_quantity",
35
36
  requested: quantity
36
37
  });
37
38
  const outstanding = outstandingOf(line);
38
- if (quantity > outstanding) return err({
39
+ if (compareQuantity(quantity, outstanding) > 0) return err({
39
40
  code: "exceeds_outstanding",
40
41
  requested: quantity,
41
42
  outstanding
@@ -47,7 +48,10 @@ const planSettlement = (line, bucket, quantity) => {
47
48
  * `applied` is what was actually settled; `0` leaves the line unchanged.
48
49
  */
49
50
  const clampSettlement = (line, bucket, requested) => {
50
- const applied = Math.max(0, Math.min(Math.trunc(requested), outstandingOf(line)));
51
+ const wanted = requested > 0 ? isOnQuantityGrid(requested) ? requested : Math.floor(requested * QUANTITY_SCALE) / QUANTITY_SCALE : 0;
52
+ const fits = snapQuantity(wanted);
53
+ const outstanding = outstandingOf(line);
54
+ const applied = compareQuantity(fits, outstanding) <= 0 ? fits : Math.max(0, outstanding);
51
55
  return {
52
56
  line: applied > 0 ? withBucket(line, bucket, applied) : line,
53
57
  applied
@@ -57,22 +61,23 @@ const clampSettlement = (line, bucket, requested) => {
57
61
  * The share of a line-level amount (a value or a cost, integer MINOR units) owed by one
58
62
  * settlement batch, given how many units the whole line had settled before and after it.
59
63
  *
60
- * `floor(total·after/q) − floor(total·before/q)` TELESCOPES: however a line is split into
64
+ * `floor(total·after/q) − floor(total·before/q)` (units on the 6-dp grid) TELESCOPES: however a line is split into
61
65
  * batches, and across every outcome bucket, the shares sum to exactly `total` once the line
62
66
  * is fully settled — no remainder to chase, no batch-order dependence.
63
67
  */
64
68
  const shareOfSettled = (total, quantity, settledBefore, settledAfter) => {
65
69
  if (!Number.isInteger(total) || total < 0) throw new RangeError(`total must be a non-negative integer, got ${total}`);
66
- if (!Number.isInteger(quantity) || quantity <= 0) throw new RangeError(`quantity must be a positive integer, got ${quantity}`);
67
- if (!(0 <= settledBefore && settledBefore <= settledAfter && settledAfter <= quantity)) throw new RangeError(`need 0 <= before (${settledBefore}) <= after (${settledAfter}) <= quantity (${quantity})`);
68
- const at = (n) => Number(BigInt(total) * BigInt(n) / BigInt(quantity));
70
+ if (!(quantity > 0) || !isOnQuantityGrid(quantity)) throw new RangeError(`quantity must be positive with at most 6 decimals, got ${quantity}`);
71
+ if (!(0 <= settledBefore && compareQuantity(settledBefore, settledAfter) <= 0 && compareQuantity(settledAfter, quantity) <= 0)) throw new RangeError(`need 0 <= before (${settledBefore}) <= after (${settledAfter}) <= quantity (${quantity})`);
72
+ const q = quantityToGrid(quantity);
73
+ const at = (n) => Number(BigInt(total) * quantityToGrid(n) / q);
69
74
  return at(settledAfter) - at(settledBefore);
70
75
  };
71
76
  const withBucket = (line, bucket, quantity) => ({
72
77
  quantity: line.quantity,
73
78
  settled: {
74
79
  ...line.settled,
75
- [bucket]: (line.settled[bucket] ?? 0) + quantity
80
+ [bucket]: addQuantity(line.settled[bucket] ?? 0, quantity)
76
81
  }
77
82
  });
78
83
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/primitives",
3
- "version": "0.32.0",
3
+ "version": "0.33.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",