@classytic/primitives 0.11.0 → 0.13.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,45 @@
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.13.0] - 2026-07-22
7
+
8
+ ### Added — `/unit-cost-rate`: exact per-unit monetary rates (bigint-safe)
9
+
10
+ - **`@classytic/primitives/unit-cost-rate`** — fractional per-unit monetary
11
+ rates stored as scaled integers (`scaledAmount = minorPerUnit × RATE_SCALE`,
12
+ `RATE_SCALE = 1_000_000`). Solves the WAC / FIFO valuation problem where
13
+ dividing a total by a fractional quantity yields a sub-minor-unit rate
14
+ (100 paisa ÷ 3 units = 33.333… paisa/unit) that must not lose precision.
15
+ Exports:
16
+ - `unitCostRate(minorPerUnit, currency)` — direct rate constructor
17
+ - `unitCostRateFromTotal(totalMinor, quantity, currency)` — WAC/FIFO
18
+ constructor; bigint-safe, QTY_SCALE grid for fractional quantities
19
+ - `extendedAmount(rate, qty, mode?)` — the ONE rate→total rounding boundary;
20
+ bigint multiplication to keep large amounts exact; default `'half-even'`
21
+ (banker's rounding), `'half-up'` available for legacy parity
22
+ - `rateMinorPerUnit(rate)` — display helper (fractional number, NOT for math)
23
+ - `isUnitCostRate(value)` — structural type guard
24
+ - `UnitCostRate<C>` interface, `UnitCostRateError`, `UnitCostRateErrorCode`,
25
+ `RoundingMode`, `RATE_SCALE`
26
+ - For distributing a top-down charge across lines with no lost minor unit,
27
+ compose with `allocate` from `@classytic/primitives/split-allocation`.
28
+
29
+ ### Changed — `/outbox` is now the canonical contract owner
30
+
31
+ - **`@classytic/primitives/outbox` owns the transactional-outbox contract.**
32
+ Previously this file was documented as a mirror of `@classytic/arc`'s copy
33
+ ("arc is source of truth, keep bit-identical") while arc's docs said the
34
+ opposite — a manual-sync drift risk. Settled: primitives owns pure
35
+ cross-package contracts (events + outbox); arc owns runtime behavior
36
+ (`EventOutbox` relay, stores, backoff, Fastify integration) and, from
37
+ arc 2.24+, imports + re-exports this contract instead of duplicating it.
38
+ - **Cross-package `instanceof` now works.** Because arc re-exports these exact
39
+ `OutboxOwnershipError` / `InvalidOutboxEventError` classes, a store built
40
+ against primitives throws the same class identity arc's relay catches.
41
+ Under the old duplication, arc's `instanceof` checks silently missed
42
+ primitives-thrown ownership errors.
43
+ - No export names or signatures changed — contract surface is identical.
44
+
6
45
  ## [0.11.0] - 2026-07-08
7
46
 
8
47
  ### Added — eKYC vocabulary + secure OTP primitive
@@ -0,0 +1,56 @@
1
+ import { r as CurrencyCode } from "./currency-BCJnJBlb.mjs";
2
+ import { MinorUnits } from "./money.mjs";
3
+
4
+ //#region src/money/unit-cost-rate.d.ts
5
+ /** Fixed, ecosystem-wide rate scale: 6 decimal places below the minor unit. */
6
+ declare const RATE_SCALE = 1000000;
7
+ /** Half-to-even (banker's) is the default; half-up available for legacy parity. */
8
+ type RoundingMode = 'half-even' | 'half-up';
9
+ /**
10
+ * Minor units per unit, stored as an integer scaled by {@link RATE_SCALE}.
11
+ * `scale` is carried so a value is self-describing on the wire even if
12
+ * `RATE_SCALE` ever changes; it is always `RATE_SCALE` today.
13
+ */
14
+ interface UnitCostRate<C extends string = string> {
15
+ /** Integer: minor units per unit × `scale`. */
16
+ readonly scaledAmount: number;
17
+ readonly currency: C | CurrencyCode | string;
18
+ /** Always {@link RATE_SCALE}. */
19
+ readonly scale: number;
20
+ }
21
+ type UnitCostRateErrorCode = 'NOT_FINITE' | 'NEGATIVE' | 'NOT_INTEGER' | 'UNSAFE_INTEGER' | 'NON_POSITIVE_QUANTITY';
22
+ declare class UnitCostRateError extends Error {
23
+ readonly name = "UnitCostRateError";
24
+ readonly code: UnitCostRateErrorCode;
25
+ constructor(code: UnitCostRateErrorCode, message: string);
26
+ }
27
+ /**
28
+ * Construct a rate from a (possibly fractional) minor-units-per-unit value.
29
+ *
30
+ * @example unitCostRate(33.3333, 'BDT') // { scaledAmount: 33_333_300, ... }
31
+ */
32
+ declare function unitCostRate<C extends string = string>(minorPerUnit: number, currency: C): UnitCostRate<C>;
33
+ /**
34
+ * Weighted-average constructor: the rate implied by an integer minor-unit
35
+ * `totalMinor` spread over a (possibly fractional) `quantity`. This is the WAC
36
+ * / layer-average rate. Overflow-safe via bigint; quantity rounded to the
37
+ * {@link QTY_SCALE} grid.
38
+ *
39
+ * @example unitCostRateFromTotal(100, 3, 'BDT') // 33.333… paisa/unit
40
+ */
41
+ declare function unitCostRateFromTotal<C extends string = string>(totalMinor: number, quantity: number, currency: C): UnitCostRate<C>;
42
+ /**
43
+ * The extended monetary amount for `quantity` units at `rate`, rounded to
44
+ * integer {@link MinorUnits} — the ONE rate→total rounding boundary.
45
+ *
46
+ * `round(quantity × scaledAmount / RATE_SCALE)`, computed in bigint so large
47
+ * amounts never lose precision. A fractional quantity is honoured to
48
+ * {@link QTY_SCALE} precision.
49
+ */
50
+ declare function extendedAmount<C extends string = string>(rate: UnitCostRate<C>, quantity: number, mode?: RoundingMode): MinorUnits<C>;
51
+ /** The rate as a plain (fractional) minor-units-per-unit number — for display only. */
52
+ declare function rateMinorPerUnit(rate: UnitCostRate): number;
53
+ /** Structural guard. */
54
+ declare function isUnitCostRate(value: unknown): value is UnitCostRate;
55
+ //#endregion
56
+ export { RATE_SCALE, RoundingMode, UnitCostRate, UnitCostRateError, UnitCostRateErrorCode, extendedAmount, isUnitCostRate, rateMinorPerUnit, unitCostRate, unitCostRateFromTotal };
@@ -0,0 +1,116 @@
1
+ import { MinorUnits } from "./money.mjs";
2
+ //#region src/money/unit-cost-rate.ts
3
+ /** Fixed, ecosystem-wide rate scale: 6 decimal places below the minor unit. */
4
+ const RATE_SCALE = 1e6;
5
+ /**
6
+ * Quantity scale used internally so a fractional quantity (e.g. `2.5`, a UoM
7
+ * split) participates in exact integer/bigint arithmetic. 6 dp of quantity
8
+ * precision — quantities finer than 1e-6 unit are rounded to this grid.
9
+ */
10
+ const QTY_SCALE = 1e6;
11
+ const DEFAULT_ROUNDING = "half-even";
12
+ var UnitCostRateError = class extends Error {
13
+ name = "UnitCostRateError";
14
+ code;
15
+ constructor(code, message) {
16
+ super(message);
17
+ this.code = code;
18
+ }
19
+ };
20
+ function assertSafe(scaled) {
21
+ if (!Number.isFinite(scaled)) throw new UnitCostRateError("NOT_FINITE", `unit cost rate is not finite: ${scaled}`);
22
+ if (!Number.isSafeInteger(scaled)) throw new UnitCostRateError("UNSAFE_INTEGER", `scaled unit cost ${scaled} exceeds Number.MAX_SAFE_INTEGER — value too large for this rate scale`);
23
+ }
24
+ /**
25
+ * Scale a (possibly fractional) quantity onto the {@link QTY_SCALE} grid as a
26
+ * bigint. Guards against a quantity so large that `quantity × QTY_SCALE` loses
27
+ * integer precision as a double BEFORE reaching bigint (≈ >9e9 units).
28
+ */
29
+ function scaleQuantity(quantity) {
30
+ const q = Math.round(quantity * QTY_SCALE);
31
+ if (!Number.isSafeInteger(q)) throw new UnitCostRateError("UNSAFE_INTEGER", `quantity ${quantity} is too large to scale precisely — exceeds safe-integer range`);
32
+ return BigInt(q);
33
+ }
34
+ /**
35
+ * Construct a rate from a (possibly fractional) minor-units-per-unit value.
36
+ *
37
+ * @example unitCostRate(33.3333, 'BDT') // { scaledAmount: 33_333_300, ... }
38
+ */
39
+ function unitCostRate(minorPerUnit, currency) {
40
+ if (!Number.isFinite(minorPerUnit)) throw new UnitCostRateError("NOT_FINITE", `minorPerUnit is not finite: ${minorPerUnit}`);
41
+ if (minorPerUnit < 0) throw new UnitCostRateError("NEGATIVE", `unit cost cannot be negative: ${minorPerUnit}`);
42
+ const scaledAmount = Math.round(minorPerUnit * RATE_SCALE);
43
+ assertSafe(scaledAmount);
44
+ return {
45
+ scaledAmount,
46
+ currency,
47
+ scale: RATE_SCALE
48
+ };
49
+ }
50
+ /**
51
+ * Weighted-average constructor: the rate implied by an integer minor-unit
52
+ * `totalMinor` spread over a (possibly fractional) `quantity`. This is the WAC
53
+ * / layer-average rate. Overflow-safe via bigint; quantity rounded to the
54
+ * {@link QTY_SCALE} grid.
55
+ *
56
+ * @example unitCostRateFromTotal(100, 3, 'BDT') // 33.333… paisa/unit
57
+ */
58
+ function unitCostRateFromTotal(totalMinor, quantity, currency) {
59
+ if (!Number.isInteger(totalMinor)) throw new UnitCostRateError("NOT_INTEGER", `totalMinor must be integer minor units: ${totalMinor}`);
60
+ if (!Number.isFinite(quantity) || quantity <= 0) throw new UnitCostRateError("NON_POSITIVE_QUANTITY", `quantity must be > 0, got ${quantity}`);
61
+ const qScaled = scaleQuantity(quantity);
62
+ const numerator = BigInt(totalMinor) * BigInt(RATE_SCALE) * BigInt(QTY_SCALE);
63
+ const scaled = Number(roundedDiv(numerator, qScaled, "half-even"));
64
+ assertSafe(scaled);
65
+ return {
66
+ scaledAmount: scaled,
67
+ currency,
68
+ scale: RATE_SCALE
69
+ };
70
+ }
71
+ /**
72
+ * The extended monetary amount for `quantity` units at `rate`, rounded to
73
+ * integer {@link MinorUnits} — the ONE rate→total rounding boundary.
74
+ *
75
+ * `round(quantity × scaledAmount / RATE_SCALE)`, computed in bigint so large
76
+ * amounts never lose precision. A fractional quantity is honoured to
77
+ * {@link QTY_SCALE} precision.
78
+ */
79
+ function extendedAmount(rate, quantity, mode = DEFAULT_ROUNDING) {
80
+ if (!Number.isFinite(quantity) || quantity < 0) throw new UnitCostRateError("NON_POSITIVE_QUANTITY", `quantity must be >= 0, got ${quantity}`);
81
+ const qScaled = scaleQuantity(quantity);
82
+ const numerator = BigInt(rate.scaledAmount) * qScaled;
83
+ const denominator = BigInt(rate.scale) * BigInt(QTY_SCALE);
84
+ const minor = Number(roundedDiv(numerator, denominator, mode));
85
+ if (!Number.isSafeInteger(minor)) throw new UnitCostRateError("UNSAFE_INTEGER", `extended amount ${minor} exceeds safe integer range`);
86
+ return MinorUnits(minor, rate.currency);
87
+ }
88
+ /** The rate as a plain (fractional) minor-units-per-unit number — for display only. */
89
+ function rateMinorPerUnit(rate) {
90
+ return rate.scaledAmount / rate.scale;
91
+ }
92
+ /** Structural guard. */
93
+ function isUnitCostRate(value) {
94
+ return typeof value === "object" && value !== null && Number.isInteger(value.scaledAmount) && typeof value.currency === "string" && typeof value.scale === "number";
95
+ }
96
+ /**
97
+ * Rounded integer division of two bigints, `numerator / denominator`
98
+ * (`denominator > 0`), honouring the rounding mode at the exact half.
99
+ * Sign-safe for negative numerators.
100
+ */
101
+ function roundedDiv(numerator, denominator, mode) {
102
+ const neg = numerator < 0n;
103
+ const n = neg ? -numerator : numerator;
104
+ const q = n / denominator;
105
+ const remainder = n - q * denominator;
106
+ if (remainder === 0n) return neg ? -q : q;
107
+ const twiceRem = remainder * 2n;
108
+ let up;
109
+ if (twiceRem > denominator) up = true;
110
+ else if (twiceRem < denominator) up = false;
111
+ else up = mode === "half-up" ? true : q % 2n !== 0n;
112
+ const result = up ? q + 1n : q;
113
+ return neg ? -result : result;
114
+ }
115
+ //#endregion
116
+ export { RATE_SCALE, UnitCostRateError, extendedAmount, isUnitCostRate, rateMinorPerUnit, unitCostRate, unitCostRateFromTotal };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/primitives",
3
- "version": "0.11.0",
3
+ "version": "0.13.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",
@@ -29,6 +29,10 @@
29
29
  "types": "./dist/currency.d.mts",
30
30
  "default": "./dist/currency.mjs"
31
31
  },
32
+ "./unit-cost-rate": {
33
+ "types": "./dist/unit-cost-rate.d.mts",
34
+ "default": "./dist/unit-cost-rate.mjs"
35
+ },
32
36
  "./address": {
33
37
  "types": "./dist/address.d.mts",
34
38
  "default": "./dist/address.mjs"