@classytic/primitives 0.31.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,6 +3,26 @@
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.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
+
6
26
  ## 0.31.0 — 2026-10-11
7
27
 
8
28
  ### 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.
@@ -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.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",