@classytic/primitives 0.31.0 → 0.32.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,35 @@
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.32.0 — 2026-10-12
7
+
8
+ ### Added — `./quantity`: the step and running-sum helpers every package was copying
9
+
10
+ Seven packages (order, cart, catalog, invoice, bd-tax, commerce-ui, pos-ui) each carried their own copy of the quantity grid; the pieces the grid did not yet offer are now here, so a consumer imports instead of copying.
11
+ - `isQuantityStep(step)` — a guard for a usable step (a positive number on the grid, at least one grid unit). True exactly when `quantityStepToGrid` accepts it, pinned by a property test. For untrusted input (a stored `quantityStep`, a request body), where a throw is the wrong answer.
12
+ - `isOnStepStrict(quantity, step)` — is the quantity ON the grid and a whole multiple of a valid step? STRICT and total: junk, an off-grid quantity or an invalid step is `false`, never a throw and never a snap. `isOnStep` is unchanged (it snaps first and throws on a bad step; its docs now say so and point here).
13
+ - `addQuantity(a, b)` / `subtractQuantity(a, b)` — arithmetic on the grid, so a running sum never drifts (`1.2 + 1.15` is `2.35`). Zero is `0`, never `-0`. A non-finite operand throws `QuantityError`, as everywhere in the grid.
14
+
15
+ ## 0.31.1 — 2026-10-11
16
+
17
+ ### Fixed — `convertWithSnapshot` is exact (`./currency`)
18
+ - The conversion was computed in floating point (`minor / sourceFactor × rate × baseFactor`) and then
19
+ rounded, so a result that lands EXACTLY on a rounding boundary could round the wrong way. USD 5.30 at
20
+ 110.15 is exactly 583.795 BDT; the float product was 583.79499…, giving 58379 paisa instead of 58380.
21
+ 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
22
+ and 1,379 at 122.35.
23
+ - The rate is now read as the decimal it prints as (`String(rate)`, exponent form included) and the
24
+ conversion is done in BigInt, rounded once. Signature and rounding modes are unchanged
25
+ (`half-away-from-zero` default, `floor`, `ceil`).
26
+ - What changes: only results that were wrong. Under `half-away-from-zero`, amounts whose exact value is a
27
+ half-way point now round away from zero (one minor unit up in magnitude). Under `floor` / `ceil`, amounts
28
+ whose exact value is an integer are no longer nudged one unit off it (USD 2.20 at 110.15 `ceil` was 24234,
29
+ now 24233). A negative amount that rounds to zero now returns `0`, not `-0`. Every other result is
30
+ identical (checked against the float form across ~10.9M amount × rate × currency-pair × mode cases).
31
+ - `convertWithSnapshot` now throws `TypeError` for a non-integer `source.amount` (a `Money` invariant that
32
+ `money()` already enforces), rather than converting a fractional minor unit.
33
+ - `reverseWithSnapshot` is unchanged.
34
+
6
35
  ## 0.31.0 — 2026-10-11
7
36
 
8
37
  ### Added — `./quantity`: the 6-decimal quantity grid for fractional stock and sale quantities
@@ -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
  }
@@ -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
  /**
@@ -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.
@@ -28,11 +28,32 @@ declare function snapQuantity(quantity: number, step?: number, mode?: RoundingMo
28
28
  * boundary, where a 7th decimal is a caller error rather than float drift.
29
29
  */
30
30
  declare function isOnQuantityGrid(value: number): boolean;
31
- /** Is `quantity` a whole multiple of `step`, within half a grid unit? */
31
+ /**
32
+ * Is `quantity` a whole multiple of `step`, within half a grid unit? Snaps `quantity` first and THROWS
33
+ * ({@link QuantityError}) on a non-finite quantity or an invalid step. Use {@link isOnStepStrict} where a
34
+ * malformed input must answer `false` instead.
35
+ */
32
36
  declare function isOnStep(quantity: number, step: number): boolean;
37
+ /**
38
+ * Is `step` a usable quantity step: a positive number on the grid, at least one grid unit? True exactly
39
+ * when {@link quantityStepToGrid} accepts it. A guard for untrusted input (a stored `quantityStep`, a
40
+ * request body) where a throw is the wrong answer.
41
+ */
42
+ declare function isQuantityStep(step: unknown): step is number;
43
+ /**
44
+ * Is `quantity` ON the grid and a whole multiple of a valid `step`? STRICT and total: an off-grid or
45
+ * non-finite quantity, or an invalid step, is `false` — never a throw and never a snap (`2.3500004`
46
+ * is off the 0.001 step here, on it for {@link isOnStep}). The check for an input boundary, such as a
47
+ * line quantity against the line's step.
48
+ */
49
+ declare function isOnStepStrict(quantity: number, step: number): boolean;
50
+ /** `a + b` on the grid, so a running sum never drifts (`1.2 + 1.15` is 2.35, not 2.3499…). */
51
+ declare function addQuantity(a: number, b: number): number;
52
+ /** `a − b` on the grid, so a running balance never drifts. */
53
+ declare function subtractQuantity(a: number, b: number): number;
33
54
  /** Compare two quantities on the grid: equal when they snap to the same grid unit. */
34
55
  declare function compareQuantity(a: number, b: number): -1 | 0 | 1;
35
56
  /** `compareQuantity(a, b) === 0`. */
36
57
  declare function quantitiesEqual(a: number, b: number): boolean;
37
58
  //#endregion
38
- export { QUANTITY_SCALE, QuantityError, QuantityErrorCode, type RoundingMode, compareQuantity, isOnQuantityGrid, isOnStep, quantitiesEqual, quantityStepToGrid, quantityToGrid, snapQuantity };
59
+ export { QUANTITY_SCALE, QuantityError, QuantityErrorCode, type RoundingMode, addQuantity, compareQuantity, isOnQuantityGrid, isOnStep, isOnStepStrict, isQuantityStep, quantitiesEqual, quantityStepToGrid, quantityToGrid, snapQuantity, subtractQuantity };
package/dist/quantity.mjs CHANGED
@@ -70,10 +70,39 @@ function isOnQuantityGrid(value) {
70
70
  const grid = Math.round(scaled);
71
71
  return Number.isSafeInteger(grid) && Math.abs(grid - scaled) <= 1e-9 * Math.max(1, Math.abs(scaled));
72
72
  }
73
- /** Is `quantity` a whole multiple of `step`, within half a grid unit? */
73
+ /**
74
+ * Is `quantity` a whole multiple of `step`, within half a grid unit? Snaps `quantity` first and THROWS
75
+ * ({@link QuantityError}) on a non-finite quantity or an invalid step. Use {@link isOnStepStrict} where a
76
+ * malformed input must answer `false` instead.
77
+ */
74
78
  function isOnStep(quantity, step) {
75
79
  return quantityToGrid(quantity) % quantityStepToGrid(step) === 0n;
76
80
  }
81
+ /**
82
+ * Is `step` a usable quantity step: a positive number on the grid, at least one grid unit? True exactly
83
+ * when {@link quantityStepToGrid} accepts it. A guard for untrusted input (a stored `quantityStep`, a
84
+ * request body) where a throw is the wrong answer.
85
+ */
86
+ function isQuantityStep(step) {
87
+ return typeof step === "number" && Number.isFinite(step) && step > 0 && isOnQuantityGrid(step) && quantityToGrid(step) > 0n;
88
+ }
89
+ /**
90
+ * Is `quantity` ON the grid and a whole multiple of a valid `step`? STRICT and total: an off-grid or
91
+ * non-finite quantity, or an invalid step, is `false` — never a throw and never a snap (`2.3500004`
92
+ * is off the 0.001 step here, on it for {@link isOnStep}). The check for an input boundary, such as a
93
+ * line quantity against the line's step.
94
+ */
95
+ function isOnStepStrict(quantity, step) {
96
+ return isOnQuantityGrid(quantity) && isQuantityStep(step) && isOnStep(quantity, step);
97
+ }
98
+ /** `a + b` on the grid, so a running sum never drifts (`1.2 + 1.15` is 2.35, not 2.3499…). */
99
+ function addQuantity(a, b) {
100
+ return gridToNumber(quantityToGrid(a) + quantityToGrid(b));
101
+ }
102
+ /** `a − b` on the grid, so a running balance never drifts. */
103
+ function subtractQuantity(a, b) {
104
+ return gridToNumber(quantityToGrid(a) - quantityToGrid(b));
105
+ }
77
106
  /** Compare two quantities on the grid: equal when they snap to the same grid unit. */
78
107
  function compareQuantity(a, b) {
79
108
  const ga = quantityToGrid(a);
@@ -85,4 +114,4 @@ function quantitiesEqual(a, b) {
85
114
  return compareQuantity(a, b) === 0;
86
115
  }
87
116
  //#endregion
88
- export { QUANTITY_SCALE, QuantityError, compareQuantity, isOnQuantityGrid, isOnStep, quantitiesEqual, quantityStepToGrid, quantityToGrid, snapQuantity };
117
+ export { QUANTITY_SCALE, QuantityError, addQuantity, compareQuantity, isOnQuantityGrid, isOnStep, isOnStepStrict, isQuantityStep, quantitiesEqual, quantityStepToGrid, quantityToGrid, snapQuantity, subtractQuantity };
@@ -1,4 +1,4 @@
1
- import { O as CurrencyCode, t as MinorUnits } from "./money-C9iLWiWR.mjs";
1
+ import { O as CurrencyCode, t as MinorUnits } from "./money-BGPS_Ena.mjs";
2
2
  import { t as RoundingMode } from "./rounded-div-D7lyJXt8.mjs";
3
3
  //#region src/money/unit-cost-rate.d.ts
4
4
  /** Fixed, ecosystem-wide rate scale: 6 decimal places below the minor unit. */
@@ -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;
@@ -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 { UsageRating } from "./monetization.mjs";
3
3
  //#region src/money/usage-pricing.d.ts
4
4
  /** What ONE unit of a sale line measured: `{ value: 2.3, unit: 'kg' }`. `unit` must equal the rating's `unitLabel`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/primitives",
3
- "version": "0.31.0",
3
+ "version": "0.32.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",