@classytic/primitives 0.26.3 → 0.26.4

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,20 @@
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.26.4
7
+
8
+ ### Added — `./price-basis` subpath: measured pricing across physical dimensions
9
+
10
+ New `PriceBasis` type (`FixedPriceBasis | MeasuredPriceBasis`) models how a quoted price spans a sellable quantity — flat fixed or per-unit-of-measure (mass, duration, volume, length, area). Factory functions `fixedPriceBasis()` and `measuredPriceBasis(dimension, qtyUnit, priceUnit, qtyPerPriceUnit)` construct the two shapes; `isPriceBasis()` is a structural guard for DB/wire boundaries. `resolvePriceBasisTotal(unitPriceMinor, quantity, basis?)` converts a minor-unit price × integer quantity to a final minor-unit total using half-even rounding (`roundedDiv` internally) and throws `PriceBasisError` on unsafe inputs or overflow.
11
+
12
+ ### Added — `./unit-cost-rate` re-exports `RoundingMode`
13
+
14
+ `RoundingMode` (`'half-even' | 'half-up'`) is now defined in the new internal `rounded-div.ts` and re-exported from `unit-cost-rate`. No breaking change — existing consumers who import `RoundingMode` from `@classytic/primitives/unit-cost-rate` continue to work.
15
+
16
+ ### Changed — `validateAddress` / `isAddressValid` accept `Address` directly
17
+
18
+ Both functions now accept `Address | PartialAddress | null | undefined`, removing the need for a cast when validating a fully-typed value object.
19
+
6
20
  ## 0.26.3
7
21
 
8
22
  ### Added — `PaymentProviderPort.wantsRawBody`
@@ -298,9 +298,9 @@ interface AddressFieldError {
298
298
  * `' '` satisfies a naive truthiness check and then prints as a blank line on
299
299
  * a courier label. Required means a value, not a character.
300
300
  */
301
- declare function validateAddress(address: PartialAddress | null | undefined, format: AddressFormat): AddressFieldError[];
301
+ declare function validateAddress(address: Address | PartialAddress | null | undefined, format: AddressFormat): AddressFieldError[];
302
302
  /** Convenience for a submit gate. */
303
- declare function isAddressValid(address: PartialAddress | null | undefined, format: AddressFormat): boolean;
303
+ declare function isAddressValid(address: Address | PartialAddress | null | undefined, format: AddressFormat): boolean;
304
304
  /** A readable default message, in the country's own vocabulary. */
305
305
  declare function describeAddressError(error: AddressFieldError): string;
306
306
  //#endregion
package/dist/address.mjs CHANGED
@@ -162,7 +162,8 @@ function supportedCountries(registry) {
162
162
  */
163
163
  function validateAddress(address, format) {
164
164
  const errors = [];
165
- const value = (k) => String(address?.[k] ?? "").trim();
165
+ const values = address;
166
+ const value = (k) => String(values?.[k] ?? "").trim();
166
167
  /**
167
168
  * Canonical and extension fields are checked IDENTICALLY, keyed by
168
169
  * `fieldName`. A required Delivery Area is enforced exactly as a required
@@ -0,0 +1,31 @@
1
+ //#region src/money/price-basis.d.ts
2
+ /** How a quoted price extends across a sellable quantity. */
3
+ type PriceBasis = FixedPriceBasis | MeasuredPriceBasis;
4
+ interface FixedPriceBasis {
5
+ readonly kind: 'fixed';
6
+ }
7
+ type MeasureDimension = 'mass' | 'duration' | 'volume' | 'length' | 'area';
8
+ interface MeasuredPriceBasis {
9
+ readonly kind: 'measured';
10
+ readonly dimension: MeasureDimension;
11
+ /** Integer unit carried by the line quantity, such as `gram` or `minute`. */
12
+ readonly quantityUnit: string;
13
+ /** Unit named by the quoted price, such as `kilogram` or `hour`. */
14
+ readonly priceUnit: string;
15
+ /** Number of quantity units represented by one quoted price unit. */
16
+ readonly quantityPerPriceUnit: number;
17
+ }
18
+ type PriceBasisErrorCode = 'INVALID_PRICE' | 'INVALID_QUANTITY' | 'INVALID_DENOMINATOR' | 'INVALID_UNIT' | 'UNSAFE_TOTAL';
19
+ declare class PriceBasisError extends Error {
20
+ readonly name = "PriceBasisError";
21
+ readonly code: PriceBasisErrorCode;
22
+ constructor(code: PriceBasisErrorCode, message: string);
23
+ }
24
+ declare function fixedPriceBasis(): FixedPriceBasis;
25
+ declare function measuredPriceBasis(dimension: MeasureDimension, quantityUnit: string, priceUnit: string, quantityPerPriceUnit: number): MeasuredPriceBasis;
26
+ /** Structural guard for database and wire boundaries. */
27
+ declare function isPriceBasis(value: unknown): value is PriceBasis;
28
+ /** Extend a minor-unit price using integer quantity and exact half-even rounding. */
29
+ declare function resolvePriceBasisTotal(unitPriceMinor: number, quantity: number, basis?: PriceBasis): number;
30
+ //#endregion
31
+ export { FixedPriceBasis, MeasureDimension, MeasuredPriceBasis, PriceBasis, PriceBasisError, PriceBasisErrorCode, fixedPriceBasis, isPriceBasis, measuredPriceBasis, resolvePriceBasisTotal };
@@ -0,0 +1,51 @@
1
+ import { t as roundedDiv } from "./rounded-div-BWJb98Ws.mjs";
2
+ //#region src/money/price-basis.ts
3
+ var PriceBasisError = class extends Error {
4
+ name = "PriceBasisError";
5
+ code;
6
+ constructor(code, message) {
7
+ super(message);
8
+ this.code = code;
9
+ }
10
+ };
11
+ function fixedPriceBasis() {
12
+ return { kind: "fixed" };
13
+ }
14
+ function measuredPriceBasis(dimension, quantityUnit, priceUnit, quantityPerPriceUnit) {
15
+ if (quantityUnit.trim() === "" || priceUnit.trim() === "") throw new PriceBasisError("INVALID_UNIT", "quantityUnit and priceUnit must not be empty");
16
+ if (!Number.isSafeInteger(quantityPerPriceUnit) || quantityPerPriceUnit <= 0) throw new PriceBasisError("INVALID_DENOMINATOR", "quantityPerPriceUnit must be a positive safe integer");
17
+ return {
18
+ kind: "measured",
19
+ dimension,
20
+ quantityUnit,
21
+ priceUnit,
22
+ quantityPerPriceUnit
23
+ };
24
+ }
25
+ const MEASURE_DIMENSIONS = /* @__PURE__ */ new Set([
26
+ "mass",
27
+ "duration",
28
+ "volume",
29
+ "length",
30
+ "area"
31
+ ]);
32
+ /** Structural guard for database and wire boundaries. */
33
+ function isPriceBasis(value) {
34
+ if (typeof value !== "object" || value === null) return false;
35
+ const candidate = value;
36
+ if (candidate.kind === "fixed") return true;
37
+ 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
+ /** Extend a minor-unit price using integer quantity and exact half-even rounding. */
40
+ function resolvePriceBasisTotal(unitPriceMinor, quantity, basis = fixedPriceBasis()) {
41
+ 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 < 0) throw new PriceBasisError("INVALID_QUANTITY", "quantity must be a non-negative safe integer");
43
+ const numerator = BigInt(unitPriceMinor) * BigInt(quantity);
44
+ const denominator = basis.kind === "measured" ? BigInt(basis.quantityPerPriceUnit) : 1n;
45
+ const rounded = roundedDiv(numerator, denominator, "half-even");
46
+ const total = Number(rounded);
47
+ if (!Number.isSafeInteger(total)) throw new PriceBasisError("UNSAFE_TOTAL", "resolved total exceeds Number.MAX_SAFE_INTEGER");
48
+ return total;
49
+ }
50
+ //#endregion
51
+ export { PriceBasisError, fixedPriceBasis, isPriceBasis, measuredPriceBasis, resolvePriceBasisTotal };
@@ -0,0 +1,13 @@
1
+ //#region src/money/rounded-div.ts
2
+ /** Exact signed integer division rounded at the rational boundary. */
3
+ function roundedDiv(numerator, denominator, mode) {
4
+ if (denominator <= 0n) throw new RangeError("denominator must be positive");
5
+ const negative = numerator < 0n;
6
+ const magnitude = negative ? -numerator : numerator;
7
+ const quotient = magnitude / denominator;
8
+ const twiceRemainder = magnitude % denominator * 2n;
9
+ const rounded = twiceRemainder > denominator || twiceRemainder === denominator && (mode === "half-up" || quotient % 2n !== 0n) ? quotient + 1n : quotient;
10
+ return negative ? -rounded : rounded;
11
+ }
12
+ //#endregion
13
+ export { roundedDiv as t };
@@ -1,9 +1,10 @@
1
1
  import { t as MinorUnits, w as CurrencyCode } from "./money-CfAvfNiM.mjs";
2
+ //#region src/money/rounded-div.d.ts
3
+ type RoundingMode = 'half-even' | 'half-up';
4
+ //#endregion
2
5
  //#region src/money/unit-cost-rate.d.ts
3
6
  /** Fixed, ecosystem-wide rate scale: 6 decimal places below the minor unit. */
4
7
  declare const RATE_SCALE = 1000000;
5
- /** Half-to-even (banker's) is the default; half-up available for legacy parity. */
6
- type RoundingMode = 'half-even' | 'half-up';
7
8
  /**
8
9
  * Minor units per unit, stored as an integer scaled by {@link RATE_SCALE}.
9
10
  * `scale` is carried so a value is self-describing on the wire even if
@@ -67,5 +68,10 @@ declare function weightedAverageScaledRate(onHandQuantity: number, onHandScaledR
67
68
  declare function rateMinorPerUnit(rate: UnitCostRate): number;
68
69
  /** Structural guard. */
69
70
  declare function isUnitCostRate(value: unknown): value is UnitCostRate;
71
+ /**
72
+ * Rounded integer division of two bigints, `numerator / denominator`
73
+ * (`denominator > 0`), honouring the rounding mode at the exact half.
74
+ * Sign-safe for negative numerators.
75
+ */
70
76
  //#endregion
71
- export { RATE_SCALE, RoundingMode, UnitCostRate, UnitCostRateError, UnitCostRateErrorCode, extendedAmount, isUnitCostRate, rateMinorPerUnit, unitCostRate, unitCostRateFromTotal, weightedAverageScaledRate };
77
+ export { RATE_SCALE, type RoundingMode, UnitCostRate, UnitCostRateError, UnitCostRateErrorCode, extendedAmount, isUnitCostRate, rateMinorPerUnit, unitCostRate, unitCostRateFromTotal, weightedAverageScaledRate };
@@ -1,4 +1,5 @@
1
1
  import { MinorUnits } from "./money.mjs";
2
+ import { t as roundedDiv } from "./rounded-div-BWJb98Ws.mjs";
2
3
  //#region src/money/unit-cost-rate.ts
3
4
  /** Fixed, ecosystem-wide rate scale: 6 decimal places below the minor unit. */
4
5
  const RATE_SCALE = 1e6;
@@ -127,19 +128,5 @@ function isUnitCostRate(value) {
127
128
  * (`denominator > 0`), honouring the rounding mode at the exact half.
128
129
  * Sign-safe for negative numerators.
129
130
  */
130
- function roundedDiv(numerator, denominator, mode) {
131
- const neg = numerator < 0n;
132
- const n = neg ? -numerator : numerator;
133
- const q = n / denominator;
134
- const remainder = n - q * denominator;
135
- if (remainder === 0n) return neg ? -q : q;
136
- const twiceRem = remainder * 2n;
137
- let up;
138
- if (twiceRem > denominator) up = true;
139
- else if (twiceRem < denominator) up = false;
140
- else up = mode === "half-up" ? true : q % 2n !== 0n;
141
- const result = up ? q + 1n : q;
142
- return neg ? -result : result;
143
- }
144
131
  //#endregion
145
132
  export { RATE_SCALE, UnitCostRateError, extendedAmount, isUnitCostRate, rateMinorPerUnit, unitCostRate, unitCostRateFromTotal, weightedAverageScaledRate };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/primitives",
3
- "version": "0.26.3",
3
+ "version": "0.26.4",
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",
@@ -33,6 +33,10 @@
33
33
  "types": "./dist/unit-cost-rate.d.mts",
34
34
  "default": "./dist/unit-cost-rate.mjs"
35
35
  },
36
+ "./price-basis": {
37
+ "types": "./dist/price-basis.d.mts",
38
+ "default": "./dist/price-basis.mjs"
39
+ },
36
40
  "./address": {
37
41
  "types": "./dist/address.d.mts",
38
42
  "default": "./dist/address.mjs"