@classytic/primitives 0.32.0 → 0.34.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 +20 -0
- package/dist/address.d.mts +14 -1
- package/dist/address.mjs +25 -1
- package/dist/line-settlement.d.mts +5 -4
- package/dist/line-settlement.mjs +18 -13
- package/dist/signed-token.mjs +6 -1
- package/package.json +1 -1
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.34.0 — 2026-10-17
|
|
7
|
+
|
|
8
|
+
### Added — geo distance helpers (`./identity/address`)
|
|
9
|
+
|
|
10
|
+
- `distanceMeters(a, b)` — great-circle distance in metres (haversine, mean Earth radius 6,371,008.8 m; ~0.5% accurate: fine for geofences, not surveying). Throws `RangeError` on a non-finite or out-of-range WGS84 point.
|
|
11
|
+
- `isWithinRadius(point, center, radiusMeters, { accuracyMeters, mode })` — is a point inside a circle, optionally counting the device's GPS uncertainty: `strict` (default, anti-cheat) needs the whole uncertainty circle inside, `lenient` accepts any overlap. Pinned by `tests/unit/geo-distance.test.ts`.
|
|
12
|
+
|
|
13
|
+
### Fixed — a malformed signed token is `null`, not an exception (`./signed-token`)
|
|
14
|
+
|
|
15
|
+
- Reading a token whose base64url body has an impossible length (`len % 4 === 1`) made `atob` throw out of the verifier. It now returns `null`, like every other malformed token.
|
|
16
|
+
|
|
17
|
+
## 0.33.0 — 2026-10-14
|
|
18
|
+
|
|
19
|
+
### Changed — `inventory/line-settlement` settles decimal quantities on the 6-dp grid
|
|
20
|
+
- `planSettlement`, `clampSettlement`, `settledTotal`, `outstandingOf`, `settledEntirelyIn` and `shareOfSettled` add, subtract and compare
|
|
21
|
+
on the grid (`addQuantity` / `subtractQuantity`), so 0.1 + 0.2 settles exactly 0.3 and a weighed line can be received in parts.
|
|
22
|
+
Whole-unit inputs behave as before.
|
|
23
|
+
- A non-positive or off-grid request is refused with code `not_positive_quantity` (was `not_positive_integer`; no in-repo consumer
|
|
24
|
+
branches on the old code).
|
|
25
|
+
|
|
6
26
|
## 0.32.0 — 2026-10-12
|
|
7
27
|
|
|
8
28
|
### Added — `./quantity`: the step and running-sum helpers every package was copying
|
package/dist/address.d.mts
CHANGED
|
@@ -39,6 +39,19 @@ interface GeoJsonPoint {
|
|
|
39
39
|
}
|
|
40
40
|
declare function toGeoJsonPoint(p: GeoPoint): GeoJsonPoint;
|
|
41
41
|
declare function fromGeoJsonPoint(g: GeoJsonPoint): GeoPoint;
|
|
42
|
+
/** Great-circle distance in metres (haversine). Accurate to ~0.5% — fine for geofences, not for surveying. */
|
|
43
|
+
declare function distanceMeters(a: GeoPoint, b: GeoPoint): number;
|
|
44
|
+
interface WithinRadiusOptions {
|
|
45
|
+
/** GPS accuracy (radius of uncertainty) in metres, as reported by the device. */
|
|
46
|
+
accuracyMeters?: number;
|
|
47
|
+
/**
|
|
48
|
+
* How accuracy counts. `strict`: the whole uncertainty circle must be inside (anti-cheat default).
|
|
49
|
+
* `lenient`: any overlap counts. Ignored when `accuracyMeters` is absent.
|
|
50
|
+
*/
|
|
51
|
+
mode?: 'strict' | 'lenient';
|
|
52
|
+
}
|
|
53
|
+
/** Whether `point` lies within `radiusMeters` of `center`, optionally accounting for GPS accuracy. */
|
|
54
|
+
declare function isWithinRadius(point: GeoPoint, center: GeoPoint, radiusMeters: number, options?: WithinRadiusOptions): boolean;
|
|
42
55
|
/**
|
|
43
56
|
* How a country's postal address is CAPTURED — labels, order, requiredness, and
|
|
44
57
|
* which fields come from a subdivision taxonomy rather than free text.
|
|
@@ -304,4 +317,4 @@ declare function isAddressValid(address: Address | PartialAddress | null | undef
|
|
|
304
317
|
/** A readable default message, in the country's own vocabulary. */
|
|
305
318
|
declare function describeAddressError(error: AddressFieldError): string;
|
|
306
319
|
//#endregion
|
|
307
|
-
export { Address, AddressExtensionFieldSpec, AddressFieldError, AddressFieldInput, AddressFieldKey, AddressFieldSpec, AddressFormat, AddressFormatRegistry, AddressTaxonomyPort, AnyAddressFieldSpec, ContactAddress, GeoJsonPoint, GeoPoint, INTERNATIONAL_ADDRESS_FORMAT, PartialAddress, describeAddressError, fieldName, fieldSpec, fromGeoJsonPoint, isAddressValid, isExtensionField, requiredFieldNames, requiredKeys, resolveAddressFormat, supportedCountries, toGeoJsonPoint, validateAddress };
|
|
320
|
+
export { Address, AddressExtensionFieldSpec, AddressFieldError, AddressFieldInput, AddressFieldKey, AddressFieldSpec, AddressFormat, AddressFormatRegistry, AddressTaxonomyPort, AnyAddressFieldSpec, ContactAddress, GeoJsonPoint, GeoPoint, INTERNATIONAL_ADDRESS_FORMAT, PartialAddress, WithinRadiusOptions, describeAddressError, distanceMeters, fieldName, fieldSpec, fromGeoJsonPoint, isAddressValid, isExtensionField, isWithinRadius, requiredFieldNames, requiredKeys, resolveAddressFormat, supportedCountries, toGeoJsonPoint, validateAddress };
|
package/dist/address.mjs
CHANGED
|
@@ -12,6 +12,30 @@ function fromGeoJsonPoint(g) {
|
|
|
12
12
|
latitude
|
|
13
13
|
};
|
|
14
14
|
}
|
|
15
|
+
/** Mean Earth radius (IUGG), metres. */
|
|
16
|
+
const EARTH_RADIUS_M = 6371008.8;
|
|
17
|
+
const toRad = (deg) => deg * Math.PI / 180;
|
|
18
|
+
function assertValidPoint(p, label) {
|
|
19
|
+
if (!Number.isFinite(p.latitude) || !Number.isFinite(p.longitude) || Math.abs(p.latitude) > 90 || Math.abs(p.longitude) > 180) throw new RangeError(`${label} is not a valid WGS84 point`);
|
|
20
|
+
}
|
|
21
|
+
/** Great-circle distance in metres (haversine). Accurate to ~0.5% — fine for geofences, not for surveying. */
|
|
22
|
+
function distanceMeters(a, b) {
|
|
23
|
+
assertValidPoint(a, "a");
|
|
24
|
+
assertValidPoint(b, "b");
|
|
25
|
+
const dLat = toRad(b.latitude - a.latitude);
|
|
26
|
+
const dLon = toRad(b.longitude - a.longitude);
|
|
27
|
+
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.latitude)) * Math.cos(toRad(b.latitude)) * Math.sin(dLon / 2) ** 2;
|
|
28
|
+
return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(h)));
|
|
29
|
+
}
|
|
30
|
+
/** Whether `point` lies within `radiusMeters` of `center`, optionally accounting for GPS accuracy. */
|
|
31
|
+
function isWithinRadius(point, center, radiusMeters, options = {}) {
|
|
32
|
+
if (!Number.isFinite(radiusMeters) || radiusMeters < 0) throw new RangeError("radiusMeters must be >= 0");
|
|
33
|
+
const d = distanceMeters(point, center);
|
|
34
|
+
const acc = options.accuracyMeters;
|
|
35
|
+
if (acc === void 0) return d <= radiusMeters;
|
|
36
|
+
if (!Number.isFinite(acc) || acc < 0) throw new RangeError("accuracyMeters must be >= 0");
|
|
37
|
+
return (options.mode ?? "strict") === "strict" ? d + acc <= radiusMeters : d - acc <= radiusMeters;
|
|
38
|
+
}
|
|
15
39
|
/** The key a spec writes to, canonical or extension. */
|
|
16
40
|
function fieldName(spec) {
|
|
17
41
|
return "extension" in spec ? spec.name : spec.key;
|
|
@@ -203,4 +227,4 @@ function describeAddressError(error) {
|
|
|
203
227
|
}
|
|
204
228
|
}
|
|
205
229
|
//#endregion
|
|
206
|
-
export { INTERNATIONAL_ADDRESS_FORMAT, describeAddressError, fieldName, fieldSpec, fromGeoJsonPoint, isAddressValid, isExtensionField, requiredFieldNames, requiredKeys, resolveAddressFormat, supportedCountries, toGeoJsonPoint, validateAddress };
|
|
230
|
+
export { INTERNATIONAL_ADDRESS_FORMAT, describeAddressError, distanceMeters, fieldName, fieldSpec, fromGeoJsonPoint, isAddressValid, isExtensionField, isWithinRadius, requiredFieldNames, requiredKeys, resolveAddressFormat, supportedCountries, toGeoJsonPoint, validateAddress };
|
|
@@ -10,7 +10,8 @@ import { Result } from "./result.mjs";
|
|
|
10
10
|
* kernel's vocabulary; the arithmetic, the over-settlement guard and the state
|
|
11
11
|
* derivation are this file's, so no two kernels can answer differently.
|
|
12
12
|
*
|
|
13
|
-
* Quantities are whole units
|
|
13
|
+
* Quantities are decimals on the 6-dp grid (`./quantity`): whole units for a countable item, 2.35 for a weighed one.
|
|
14
|
+
* Every sum and comparison is exact on the grid. A bucket that is absent counts as zero.
|
|
14
15
|
*/
|
|
15
16
|
interface SettlementLine<B extends string> {
|
|
16
17
|
/** What the line promised to move. */
|
|
@@ -34,7 +35,7 @@ declare const documentSettlementState: <B extends string>(lines: ReadonlyArray<S
|
|
|
34
35
|
/** `true` when every line is settled entirely through `bucket` (e.g. all received, nothing lost). */
|
|
35
36
|
declare const settledEntirelyIn: <B extends string>(lines: ReadonlyArray<SettlementLine<B>>, bucket: B) => boolean;
|
|
36
37
|
type SettlementRefusal = {
|
|
37
|
-
readonly code: "
|
|
38
|
+
readonly code: "not_positive_quantity";
|
|
38
39
|
readonly requested: number;
|
|
39
40
|
} | {
|
|
40
41
|
readonly code: "exceeds_outstanding";
|
|
@@ -44,7 +45,7 @@ type SettlementRefusal = {
|
|
|
44
45
|
/**
|
|
45
46
|
* Settle `quantity` more units into `bucket`, or refuse.
|
|
46
47
|
*
|
|
47
|
-
* Refuses a non-positive
|
|
48
|
+
* Refuses a non-positive quantity, one off the 6-dp grid, and anything above what is
|
|
48
49
|
* outstanding — an over-settlement is an input error, never silently trimmed.
|
|
49
50
|
* Use {@link clampSettlement} only where the surface's CONTRACT is "take what fits".
|
|
50
51
|
*/
|
|
@@ -61,7 +62,7 @@ declare const clampSettlement: <B extends string>(line: SettlementLine<B>, bucke
|
|
|
61
62
|
* The share of a line-level amount (a value or a cost, integer MINOR units) owed by one
|
|
62
63
|
* settlement batch, given how many units the whole line had settled before and after it.
|
|
63
64
|
*
|
|
64
|
-
* `floor(total·after/q) − floor(total·before/q)` TELESCOPES: however a line is split into
|
|
65
|
+
* `floor(total·after/q) − floor(total·before/q)` (units on the 6-dp grid) TELESCOPES: however a line is split into
|
|
65
66
|
* batches, and across every outcome bucket, the shares sum to exactly `total` once the line
|
|
66
67
|
* is fully settled — no remainder to chase, no batch-order dependence.
|
|
67
68
|
*/
|
package/dist/line-settlement.mjs
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
+
import { QUANTITY_SCALE, addQuantity, compareQuantity, isOnQuantityGrid, quantityToGrid, snapQuantity, subtractQuantity } from "./quantity.mjs";
|
|
1
2
|
import { err, ok } from "./result.mjs";
|
|
2
3
|
//#region src/inventory/line-settlement.ts
|
|
3
4
|
/** Sum of every bucket. */
|
|
4
|
-
const settledTotal = (line) => Object.values(line.settled).reduce((sum, n) => sum
|
|
5
|
+
const settledTotal = (line) => Object.values(line.settled).reduce((sum, n) => addQuantity(sum, n ?? 0), 0);
|
|
5
6
|
/**
|
|
6
7
|
* `quantity − Σ buckets`. NOT clamped at zero: a negative means the line was
|
|
7
8
|
* over-settled by data written outside {@link planSettlement}, and hiding it
|
|
8
9
|
* would erase the only signal that happened.
|
|
9
10
|
*/
|
|
10
|
-
const outstandingOf = (line) => line.quantity
|
|
11
|
+
const outstandingOf = (line) => subtractQuantity(line.quantity, settledTotal(line));
|
|
11
12
|
/** `open` — nothing settled; `settled` — nothing outstanding; otherwise `partial`. */
|
|
12
13
|
const settlementState = (line) => {
|
|
13
14
|
if (outstandingOf(line) <= 0) return "settled";
|
|
@@ -21,21 +22,21 @@ const documentSettlementState = (lines) => {
|
|
|
21
22
|
return "partial";
|
|
22
23
|
};
|
|
23
24
|
/** `true` when every line is settled entirely through `bucket` (e.g. all received, nothing lost). */
|
|
24
|
-
const settledEntirelyIn = (lines, bucket) => lines.every((l) => (l.settled[bucket] ?? 0
|
|
25
|
+
const settledEntirelyIn = (lines, bucket) => lines.every((l) => compareQuantity(l.settled[bucket] ?? 0, l.quantity) >= 0);
|
|
25
26
|
/**
|
|
26
27
|
* Settle `quantity` more units into `bucket`, or refuse.
|
|
27
28
|
*
|
|
28
|
-
* Refuses a non-positive
|
|
29
|
+
* Refuses a non-positive quantity, one off the 6-dp grid, and anything above what is
|
|
29
30
|
* outstanding — an over-settlement is an input error, never silently trimmed.
|
|
30
31
|
* Use {@link clampSettlement} only where the surface's CONTRACT is "take what fits".
|
|
31
32
|
*/
|
|
32
33
|
const planSettlement = (line, bucket, quantity) => {
|
|
33
|
-
if (!
|
|
34
|
-
code: "
|
|
34
|
+
if (!(quantity > 0) || !isOnQuantityGrid(quantity)) return err({
|
|
35
|
+
code: "not_positive_quantity",
|
|
35
36
|
requested: quantity
|
|
36
37
|
});
|
|
37
38
|
const outstanding = outstandingOf(line);
|
|
38
|
-
if (quantity >
|
|
39
|
+
if (compareQuantity(quantity, outstanding) > 0) return err({
|
|
39
40
|
code: "exceeds_outstanding",
|
|
40
41
|
requested: quantity,
|
|
41
42
|
outstanding
|
|
@@ -47,7 +48,10 @@ const planSettlement = (line, bucket, quantity) => {
|
|
|
47
48
|
* `applied` is what was actually settled; `0` leaves the line unchanged.
|
|
48
49
|
*/
|
|
49
50
|
const clampSettlement = (line, bucket, requested) => {
|
|
50
|
-
const
|
|
51
|
+
const wanted = requested > 0 ? isOnQuantityGrid(requested) ? requested : Math.floor(requested * QUANTITY_SCALE) / QUANTITY_SCALE : 0;
|
|
52
|
+
const fits = snapQuantity(wanted);
|
|
53
|
+
const outstanding = outstandingOf(line);
|
|
54
|
+
const applied = compareQuantity(fits, outstanding) <= 0 ? fits : Math.max(0, outstanding);
|
|
51
55
|
return {
|
|
52
56
|
line: applied > 0 ? withBucket(line, bucket, applied) : line,
|
|
53
57
|
applied
|
|
@@ -57,22 +61,23 @@ const clampSettlement = (line, bucket, requested) => {
|
|
|
57
61
|
* The share of a line-level amount (a value or a cost, integer MINOR units) owed by one
|
|
58
62
|
* settlement batch, given how many units the whole line had settled before and after it.
|
|
59
63
|
*
|
|
60
|
-
* `floor(total·after/q) − floor(total·before/q)` TELESCOPES: however a line is split into
|
|
64
|
+
* `floor(total·after/q) − floor(total·before/q)` (units on the 6-dp grid) TELESCOPES: however a line is split into
|
|
61
65
|
* batches, and across every outcome bucket, the shares sum to exactly `total` once the line
|
|
62
66
|
* is fully settled — no remainder to chase, no batch-order dependence.
|
|
63
67
|
*/
|
|
64
68
|
const shareOfSettled = (total, quantity, settledBefore, settledAfter) => {
|
|
65
69
|
if (!Number.isInteger(total) || total < 0) throw new RangeError(`total must be a non-negative integer, got ${total}`);
|
|
66
|
-
if (!
|
|
67
|
-
if (!(0 <= settledBefore && settledBefore <=
|
|
68
|
-
const
|
|
70
|
+
if (!(quantity > 0) || !isOnQuantityGrid(quantity)) throw new RangeError(`quantity must be positive with at most 6 decimals, got ${quantity}`);
|
|
71
|
+
if (!(0 <= settledBefore && compareQuantity(settledBefore, settledAfter) <= 0 && compareQuantity(settledAfter, quantity) <= 0)) throw new RangeError(`need 0 <= before (${settledBefore}) <= after (${settledAfter}) <= quantity (${quantity})`);
|
|
72
|
+
const q = quantityToGrid(quantity);
|
|
73
|
+
const at = (n) => Number(BigInt(total) * quantityToGrid(n) / q);
|
|
69
74
|
return at(settledAfter) - at(settledBefore);
|
|
70
75
|
};
|
|
71
76
|
const withBucket = (line, bucket, quantity) => ({
|
|
72
77
|
quantity: line.quantity,
|
|
73
78
|
settled: {
|
|
74
79
|
...line.settled,
|
|
75
|
-
[bucket]: (line.settled[bucket] ?? 0
|
|
80
|
+
[bucket]: addQuantity(line.settled[bucket] ?? 0, quantity)
|
|
76
81
|
}
|
|
77
82
|
});
|
|
78
83
|
//#endregion
|
package/dist/signed-token.mjs
CHANGED
|
@@ -28,7 +28,12 @@ function toBase64Url(bytes) {
|
|
|
28
28
|
}
|
|
29
29
|
function fromBase64Url(text) {
|
|
30
30
|
if (!/^[A-Za-z0-9_-]*$/.test(text)) return null;
|
|
31
|
-
|
|
31
|
+
let binary;
|
|
32
|
+
try {
|
|
33
|
+
binary = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
32
37
|
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
|
33
38
|
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
34
39
|
return bytes;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/primitives",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.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",
|