@classytic/primitives 0.30.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 +50 -1
- package/dist/bank-transaction.d.mts +1 -1
- package/dist/currency.d.mts +1 -1
- package/dist/currency.mjs +41 -1
- package/dist/item-facts.d.mts +19 -0
- package/dist/item-facts.mjs +10 -0
- package/dist/monetization.d.mts +1 -1
- package/dist/{money-C9iLWiWR.d.mts → money-BGPS_Ena.d.mts} +7 -0
- package/dist/money.d.mts +1 -1
- package/dist/payment-events.d.mts +1 -1
- package/dist/payment-gateway.d.mts +1 -1
- package/dist/price-basis.d.mts +5 -2
- package/dist/price-basis.mjs +10 -4
- package/dist/proration.d.mts +1 -1
- package/dist/quantity.d.mts +38 -0
- package/dist/quantity.mjs +88 -0
- package/dist/rounded-div-D7lyJXt8.d.mts +4 -0
- package/dist/unit-cost-rate.d.mts +2 -4
- package/dist/unit-cost-rate.mjs +1 -1
- package/dist/usage-pricing.d.mts +53 -0
- package/dist/usage-pricing.mjs +150 -0
- package/package.json +11 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,7 +3,56 @@
|
|
|
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.
|
|
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
|
+
|
|
26
|
+
## 0.31.0 — 2026-10-11
|
|
27
|
+
|
|
28
|
+
### Added — `./quantity`: the 6-decimal quantity grid for fractional stock and sale quantities
|
|
29
|
+
- `snapQuantity(qty, step?, mode)` (half-even by default), `isOnStep`, `compareQuantity`, `quantitiesEqual`,
|
|
30
|
+
`quantityToGrid`, `quantityStepToGrid`, `QUANTITY_SCALE` (1,000,000) and `QuantityError`
|
|
31
|
+
(`NOT_FINITE` / `INVALID_STEP` / `UNSAFE_QUANTITY`).
|
|
32
|
+
- `isOnQuantityGrid(value)`: STRICT, for input boundaries. `isOnStep` snaps first, so
|
|
33
|
+
`isOnStep(2.3500004, 0.001)` is true; `isOnQuantityGrid(2.3500004)` is false.
|
|
34
|
+
|
|
35
|
+
### Added — `./usage-pricing`
|
|
36
|
+
- `priceUsage(rating, measured)` prices a measured amount against a `UsageRating` (per-unit, tiered, volume,
|
|
37
|
+
package; a started unit counts whole; breakdown included), plus `UsageMeasure` / `isUsageMeasure` and
|
|
38
|
+
`UsagePricingError`.
|
|
39
|
+
|
|
40
|
+
### Added — the stock unit group on item facts
|
|
41
|
+
- `uomGroupRef` (optional) on `ItemStockSource`, `ProductFacet` and `ItemStockFacts`, and `unitOfMeasureDeclared`
|
|
42
|
+
(optional) on `ProductFacet` and `ItemStockFacts`. `resolveStockFacts` takes the Item's group, falls back to
|
|
43
|
+
the product's only when no Item exists, and marks an ungrouped Item under a grouped product
|
|
44
|
+
`unitOfMeasureDeclared` instead of borrowing the product's group. All fields optional: existing values of these
|
|
45
|
+
types still type-check.
|
|
46
|
+
|
|
47
|
+
### Changed — `resolvePriceBasisTotal` accepts a fractional quantity
|
|
48
|
+
- A whole quantity is computed exactly as before, across the full safe-integer range (pinned by a regression
|
|
49
|
+
test against the previous formula).
|
|
50
|
+
- A fractional quantity on the 6-decimal grid (e.g. `2.35`) is priced exactly: price × quantity on the grid,
|
|
51
|
+
divided by the measured basis, rounded ONCE half-even. Previously it threw `INVALID_QUANTITY`.
|
|
52
|
+
- Still refused (`INVALID_QUANTITY`): a negative, non-finite or off-grid quantity (a 7th decimal), or a
|
|
53
|
+
fraction too large to scale exactly.
|
|
54
|
+
|
|
55
|
+
## 0.30.0 — never published separately; shipped in 0.31.0
|
|
7
56
|
|
|
8
57
|
### Added — `./durable-events`: the declaration and the ONE kernel dispatch
|
|
9
58
|
- `EventDurability`, `DurableEventSpec`, `FactExpectation`; `eventIdFor` / `durableEventMeta` (`${name}:v${version}:${occurrence}`); `requireDurableContext` + `NonDurablePublishError`; `UnmanagedSessionError`.
|
package/dist/currency.d.mts
CHANGED
|
@@ -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-
|
|
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:
|
|
246
|
+
amount: Number(divideMinor(numerator, denominator, options.rounding ?? "half-away-from-zero")),
|
|
207
247
|
currency: currencyCode(fx.baseCurrency)
|
|
208
248
|
};
|
|
209
249
|
}
|
package/dist/item-facts.d.mts
CHANGED
|
@@ -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;
|
package/dist/item-facts.mjs
CHANGED
|
@@ -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),
|
package/dist/monetization.d.mts
CHANGED
|
@@ -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-
|
|
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-
|
|
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
|
package/dist/price-basis.d.mts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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 };
|
package/dist/price-basis.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
43
|
-
const
|
|
44
|
-
const
|
|
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");
|
package/dist/proration.d.mts
CHANGED
|
@@ -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 };
|
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import { O as CurrencyCode, t as MinorUnits } from "./money-
|
|
2
|
-
|
|
3
|
-
type RoundingMode = 'half-even' | 'half-up';
|
|
4
|
-
//#endregion
|
|
1
|
+
import { O as CurrencyCode, t as MinorUnits } from "./money-BGPS_Ena.mjs";
|
|
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;
|
package/dist/unit-cost-rate.mjs
CHANGED
|
@@ -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;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { n as Money } from "./money-BGPS_Ena.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.
|
|
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",
|
|
@@ -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",
|