@classytic/primitives 0.11.0 → 0.14.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 +117 -0
- package/dist/event-infra.d.mts +79 -0
- package/dist/event-infra.mjs +109 -0
- package/dist/events.d.mts +60 -2
- package/dist/events.mjs +42 -1
- package/dist/money.d.mts +32 -1
- package/dist/money.mjs +44 -1
- package/dist/unit-cost-rate.d.mts +56 -0
- package/dist/unit-cost-rate.mjs +116 -0
- package/package.json +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,123 @@
|
|
|
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.14.0] - 2026-07-23
|
|
7
|
+
|
|
8
|
+
### Added — `/money`: scalar minor-unit helpers (ledger dialect)
|
|
9
|
+
|
|
10
|
+
- **`majorToMinorUnits(major, minorUnitDecimals = 2)` /
|
|
11
|
+
`minorUnitsToMajor(minor, minorUnitDecimals = 2)` /
|
|
12
|
+
`percentOfMinor(minor, ratePercent)` / `formatMinorUnits(minor,
|
|
13
|
+
minorUnitDecimals = 2)`** — the currency-neutral, exponent-based scalar
|
|
14
|
+
dialect used by the double-entry ledger family, extracted from
|
|
15
|
+
`@classytic/ledger/money` (`fromDecimal`/`toDecimal`/`percentage`/
|
|
16
|
+
`formatPlain`) so the math has ONE owner (PACKAGE_RULES §8.2).
|
|
17
|
+
Deliberately different rounding from `fromMajor`: raw IEEE `Math.round`
|
|
18
|
+
(half-up toward +∞, no `toPrecision` cleaning) — the ledger's
|
|
19
|
+
characterization-tested wire behavior (`majorToMinorUnits(1.005) === 100`
|
|
20
|
+
where `fromMajor(1.005,'USD').amount === 101`; `-10.5 → -10`).
|
|
21
|
+
`percentOfMinor` is the EXACT multiply-then-round form (handles QST
|
|
22
|
+
9.975 % without bps loss); hosts that snap rates to basis points (be-prod
|
|
23
|
+
`#shared/money`) layer that policy on top before calling.
|
|
24
|
+
|
|
25
|
+
### Added — `/event-infra`: shared in-process bus + memory outbox
|
|
26
|
+
|
|
27
|
+
- **`@classytic/primitives/event-infra`** — the dependency-free reference
|
|
28
|
+
event RUNTIME every kernel previously copy-pasted (26 drifted
|
|
29
|
+
`in-process-bus.ts` copies + 15 `outbox-store.ts` copies across
|
|
30
|
+
`commerce/packages/`, ≈1,600 lines). New subpath (not `/events` or
|
|
31
|
+
`/outbox`, which stay contract-only per the settled ownership rule; arc
|
|
32
|
+
keeps the DURABLE runtime — relay, `MongoOutboxStore`, backoff). Exports:
|
|
33
|
+
- `InProcessEventBus` (+ `createInProcessBus`, `InProcessEventBusOptions`,
|
|
34
|
+
`ErrorLogger`) — in-process fan-out `EventTransport`, structurally
|
|
35
|
+
identical to arc's `MemoryEventTransport`. Union of all kernel variants:
|
|
36
|
+
glob matching via `matchEventPattern`, Set-dedup across patterns,
|
|
37
|
+
per-handler error isolation with injectable logger (`logger: null` =
|
|
38
|
+
silent swallow, the cart dialect), `publishMany`, idempotent `close()`.
|
|
39
|
+
Options: `name` (`'in-process-<kernel>'`), `logLabel` for the error line.
|
|
40
|
+
- `MemoryOutboxStore` — the test/dev outbox (`save`/`getPending`/
|
|
41
|
+
`acknowledge`/`purge` + `all()`/`clear()` inspection helpers). Kernels
|
|
42
|
+
re-export it; production durability stays host-wired.
|
|
43
|
+
- Kernels keep their public class names as thin subclasses
|
|
44
|
+
(`class InProcessPosBus extends InProcessEventBus`), so no kernel API
|
|
45
|
+
changes — the bus swap is internal.
|
|
46
|
+
|
|
47
|
+
### Added — `/events`: standard context→meta mapping
|
|
48
|
+
|
|
49
|
+
- **`scopedEventMeta(ctx)` + `createScopedEvent(source, type, payload, ctx?,
|
|
50
|
+
meta?)` + `EventScopeContext`** — THE context→meta mapping (PACKAGE_RULES
|
|
51
|
+
§8.3: `organizationId` rides META) extracted from 25 drifted per-kernel
|
|
52
|
+
`events/helpers.ts` copies. `userId` ← `String(actorId)` falling back to
|
|
53
|
+
`actorRef` (the commerce dialect), `organizationId` ← stringified,
|
|
54
|
+
`correlationId` pass-through; absent fields omitted
|
|
55
|
+
(`exactOptionalPropertyTypes`-safe). Kernel helpers become one-liners:
|
|
56
|
+
`createPurchaseEvent = (t, p, ctx?, m?) =>
|
|
57
|
+
createScopedEvent('@classytic/purchase', t, p, ctx, m)`.
|
|
58
|
+
|
|
59
|
+
### Changed — `EventTransport.subscribe` is now optional
|
|
60
|
+
|
|
61
|
+
- **`/events` `EventTransport.subscribe?`** — publish-only transports (outbox
|
|
62
|
+
bridge adapters, host wrappers around a `publish(type, payload, meta)`
|
|
63
|
+
helper, fire-and-forget pipes) now conform without casting. Hosts were
|
|
64
|
+
writing `subscribe: undefined as unknown as EventTransport['subscribe']`
|
|
65
|
+
literals or `as unknown as EventTransport` widenings to inject them into
|
|
66
|
+
kernels that never call `subscribe` on the injected transport. Consumers
|
|
67
|
+
that need in-process delivery must guard for an absent `subscribe` — the
|
|
68
|
+
same "detect and fall back" contract `publishMany` and `deadLetter`
|
|
69
|
+
already carry (flow's `createSettlementListener` is the reference guard;
|
|
70
|
+
it has guarded `?.subscribe` since flow 0.5). Kernels subscribing on
|
|
71
|
+
their OWN in-process bus are unaffected — concrete buses still implement
|
|
72
|
+
`subscribe`, and a concrete method satisfies an optional member.
|
|
73
|
+
- Audited call sites before the change (`commerce/packages/*/src`,
|
|
74
|
+
`commerce/spine/packages/*/src`): the `.subscribe` uses on injected
|
|
75
|
+
transports are pass-through dispatcher methods hosts opt into (crm/party
|
|
76
|
+
`dispatch.ts`), an already-guarded listener (flow's
|
|
77
|
+
`createSettlementListener`), and invoice's audit bridge — which only
|
|
78
|
+
subscribes when `config.audit` is wired. Each of these sources picks up a
|
|
79
|
+
compile-time "possibly undefined" prompt on its NEXT rebuild against this
|
|
80
|
+
version, forcing the guard (or a documented throw) exactly where a
|
|
81
|
+
publish-only transport would have failed at runtime before. No published
|
|
82
|
+
dist behavior changes.
|
|
83
|
+
|
|
84
|
+
## [0.13.0] - 2026-07-22
|
|
85
|
+
|
|
86
|
+
### Added — `/unit-cost-rate`: exact per-unit monetary rates (bigint-safe)
|
|
87
|
+
|
|
88
|
+
- **`@classytic/primitives/unit-cost-rate`** — fractional per-unit monetary
|
|
89
|
+
rates stored as scaled integers (`scaledAmount = minorPerUnit × RATE_SCALE`,
|
|
90
|
+
`RATE_SCALE = 1_000_000`). Solves the WAC / FIFO valuation problem where
|
|
91
|
+
dividing a total by a fractional quantity yields a sub-minor-unit rate
|
|
92
|
+
(100 paisa ÷ 3 units = 33.333… paisa/unit) that must not lose precision.
|
|
93
|
+
Exports:
|
|
94
|
+
- `unitCostRate(minorPerUnit, currency)` — direct rate constructor
|
|
95
|
+
- `unitCostRateFromTotal(totalMinor, quantity, currency)` — WAC/FIFO
|
|
96
|
+
constructor; bigint-safe, QTY_SCALE grid for fractional quantities
|
|
97
|
+
- `extendedAmount(rate, qty, mode?)` — the ONE rate→total rounding boundary;
|
|
98
|
+
bigint multiplication to keep large amounts exact; default `'half-even'`
|
|
99
|
+
(banker's rounding), `'half-up'` available for legacy parity
|
|
100
|
+
- `rateMinorPerUnit(rate)` — display helper (fractional number, NOT for math)
|
|
101
|
+
- `isUnitCostRate(value)` — structural type guard
|
|
102
|
+
- `UnitCostRate<C>` interface, `UnitCostRateError`, `UnitCostRateErrorCode`,
|
|
103
|
+
`RoundingMode`, `RATE_SCALE`
|
|
104
|
+
- For distributing a top-down charge across lines with no lost minor unit,
|
|
105
|
+
compose with `allocate` from `@classytic/primitives/split-allocation`.
|
|
106
|
+
|
|
107
|
+
### Changed — `/outbox` is now the canonical contract owner
|
|
108
|
+
|
|
109
|
+
- **`@classytic/primitives/outbox` owns the transactional-outbox contract.**
|
|
110
|
+
Previously this file was documented as a mirror of `@classytic/arc`'s copy
|
|
111
|
+
("arc is source of truth, keep bit-identical") while arc's docs said the
|
|
112
|
+
opposite — a manual-sync drift risk. Settled: primitives owns pure
|
|
113
|
+
cross-package contracts (events + outbox); arc owns runtime behavior
|
|
114
|
+
(`EventOutbox` relay, stores, backoff, Fastify integration) and, from
|
|
115
|
+
arc 2.24+, imports + re-exports this contract instead of duplicating it.
|
|
116
|
+
- **Cross-package `instanceof` now works.** Because arc re-exports these exact
|
|
117
|
+
`OutboxOwnershipError` / `InvalidOutboxEventError` classes, a store built
|
|
118
|
+
against primitives throws the same class identity arc's relay catches.
|
|
119
|
+
Under the old duplication, arc's `instanceof` checks silently missed
|
|
120
|
+
primitives-thrown ownership errors.
|
|
121
|
+
- No export names or signatures changed — contract surface is identical.
|
|
122
|
+
|
|
6
123
|
## [0.11.0] - 2026-07-08
|
|
7
124
|
|
|
8
125
|
### Added — eKYC vocabulary + secure OTP primitive
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { DomainEvent, EventHandler, EventTransport, PublishManyResult } from "./events.mjs";
|
|
2
|
+
import { OutboxAcknowledgeOptions, OutboxStore, OutboxWriteOptions } from "./outbox.mjs";
|
|
3
|
+
|
|
4
|
+
//#region src/events/event-infra.d.ts
|
|
5
|
+
/** Minimal error-only logger — assignable from `console`, pino, fastify.log. */
|
|
6
|
+
interface ErrorLogger {
|
|
7
|
+
error(message: string, ...args: unknown[]): void;
|
|
8
|
+
}
|
|
9
|
+
interface InProcessEventBusOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Transport name (`EventTransport.name`). Kernels use
|
|
12
|
+
* `'in-process-<kernel>'`. Default: `'in-process'`.
|
|
13
|
+
*/
|
|
14
|
+
name?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Logger for per-handler error isolation (default: `console`).
|
|
17
|
+
* Pass `null` to swallow handler errors silently (cart's historical
|
|
18
|
+
* behavior — event emission never propagates OR logs).
|
|
19
|
+
*/
|
|
20
|
+
logger?: ErrorLogger | null;
|
|
21
|
+
/**
|
|
22
|
+
* Label used in the handler-error log line:
|
|
23
|
+
* `[<logLabel>] handler error for <event.type>:`. Default: `name`.
|
|
24
|
+
*/
|
|
25
|
+
logLabel?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* In-process fan-out `EventTransport` — structurally identical to arc's
|
|
29
|
+
* `MemoryEventTransport`, so the two are interchangeable.
|
|
30
|
+
*
|
|
31
|
+
* Semantics (the union of every kernel copy this replaces):
|
|
32
|
+
* - Glob matching via {@link matchEventPattern} (exact / `*` / `prefix.*`
|
|
33
|
+
* / `prefix:*`).
|
|
34
|
+
* - A handler subscribed under multiple matching patterns fires ONCE per
|
|
35
|
+
* event (Set-dedup — the majority variant across kernels).
|
|
36
|
+
* - Per-handler error isolation: one failing subscriber never crashes
|
|
37
|
+
* siblings; the failure is logged (or swallowed with `logger: null`).
|
|
38
|
+
* - `publishMany` batches per-event outcomes keyed by `meta.id`.
|
|
39
|
+
* - `close()` clears all subscriptions; publish after close is a no-op;
|
|
40
|
+
* close is idempotent. NOT durable — engines own it under the fleet
|
|
41
|
+
* `ownsBus` convention (PACKAGE_RULES §8.1).
|
|
42
|
+
*/
|
|
43
|
+
declare class InProcessEventBus implements EventTransport {
|
|
44
|
+
readonly name: string;
|
|
45
|
+
private handlers;
|
|
46
|
+
private readonly logger;
|
|
47
|
+
private readonly logLabel;
|
|
48
|
+
constructor(options?: InProcessEventBusOptions);
|
|
49
|
+
publish(event: DomainEvent): Promise<void>;
|
|
50
|
+
publishMany(events: readonly DomainEvent[]): Promise<PublishManyResult>;
|
|
51
|
+
subscribe(pattern: string, handler: EventHandler): Promise<() => void>;
|
|
52
|
+
close(): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
/** Factory form of {@link InProcessEventBus} for hosts that prefer functions. */
|
|
55
|
+
declare function createInProcessBus(options?: InProcessEventBusOptions): InProcessEventBus;
|
|
56
|
+
/**
|
|
57
|
+
* In-memory {@link OutboxStore} for tests and local dev. Not for production —
|
|
58
|
+
* hosts wire arc's `MongoOutboxStore`, a mongokit repo via
|
|
59
|
+
* `repositoryAsOutboxStore`, or any durable implementation.
|
|
60
|
+
*
|
|
61
|
+
* Implements the required trio (`save` / `getPending` / `acknowledge`) plus
|
|
62
|
+
* optional `purge`. Lease-based methods (`claimPending` / `fail` /
|
|
63
|
+
* `getDeadLettered`) are intentionally omitted — single-process tests don't
|
|
64
|
+
* need them, and kernels only call `save()` themselves. `all()` / `clear()`
|
|
65
|
+
* are test-inspection helpers (not part of the contract).
|
|
66
|
+
*/
|
|
67
|
+
declare class MemoryOutboxStore implements OutboxStore {
|
|
68
|
+
private events;
|
|
69
|
+
save(event: DomainEvent, _options?: OutboxWriteOptions): Promise<void>;
|
|
70
|
+
getPending(limit: number): Promise<DomainEvent[]>;
|
|
71
|
+
acknowledge(eventId: string, _options?: OutboxAcknowledgeOptions): Promise<void>;
|
|
72
|
+
purge(olderThanMs: number): Promise<number>;
|
|
73
|
+
/** Every saved event (acknowledged or not) — test inspection helper. */
|
|
74
|
+
all(): ReadonlyArray<DomainEvent>;
|
|
75
|
+
/** Reset the store — test helper. */
|
|
76
|
+
clear(): void;
|
|
77
|
+
}
|
|
78
|
+
//#endregion
|
|
79
|
+
export { ErrorLogger, InProcessEventBus, InProcessEventBusOptions, MemoryOutboxStore, createInProcessBus };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { matchEventPattern } from "./events.mjs";
|
|
2
|
+
//#region src/events/event-infra.ts
|
|
3
|
+
/**
|
|
4
|
+
* In-process fan-out `EventTransport` — structurally identical to arc's
|
|
5
|
+
* `MemoryEventTransport`, so the two are interchangeable.
|
|
6
|
+
*
|
|
7
|
+
* Semantics (the union of every kernel copy this replaces):
|
|
8
|
+
* - Glob matching via {@link matchEventPattern} (exact / `*` / `prefix.*`
|
|
9
|
+
* / `prefix:*`).
|
|
10
|
+
* - A handler subscribed under multiple matching patterns fires ONCE per
|
|
11
|
+
* event (Set-dedup — the majority variant across kernels).
|
|
12
|
+
* - Per-handler error isolation: one failing subscriber never crashes
|
|
13
|
+
* siblings; the failure is logged (or swallowed with `logger: null`).
|
|
14
|
+
* - `publishMany` batches per-event outcomes keyed by `meta.id`.
|
|
15
|
+
* - `close()` clears all subscriptions; publish after close is a no-op;
|
|
16
|
+
* close is idempotent. NOT durable — engines own it under the fleet
|
|
17
|
+
* `ownsBus` convention (PACKAGE_RULES §8.1).
|
|
18
|
+
*/
|
|
19
|
+
var InProcessEventBus = class {
|
|
20
|
+
name;
|
|
21
|
+
handlers = /* @__PURE__ */ new Map();
|
|
22
|
+
logger;
|
|
23
|
+
logLabel;
|
|
24
|
+
constructor(options = {}) {
|
|
25
|
+
this.name = options.name ?? "in-process";
|
|
26
|
+
this.logger = options.logger === null ? null : options.logger ?? console;
|
|
27
|
+
this.logLabel = options.logLabel ?? this.name;
|
|
28
|
+
}
|
|
29
|
+
async publish(event) {
|
|
30
|
+
const matched = /* @__PURE__ */ new Set();
|
|
31
|
+
for (const [pattern, set] of this.handlers.entries()) if (matchEventPattern(pattern, event.type)) for (const h of set) matched.add(h);
|
|
32
|
+
for (const handler of matched) try {
|
|
33
|
+
await handler(event);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
this.logger?.error(`[${this.logLabel}] handler error for ${event.type}:`, err);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async publishMany(events) {
|
|
39
|
+
const results = /* @__PURE__ */ new Map();
|
|
40
|
+
for (const event of events) try {
|
|
41
|
+
await this.publish(event);
|
|
42
|
+
results.set(event.meta.id, null);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
results.set(event.meta.id, err instanceof Error ? err : new Error(String(err)));
|
|
45
|
+
}
|
|
46
|
+
return results;
|
|
47
|
+
}
|
|
48
|
+
async subscribe(pattern, handler) {
|
|
49
|
+
let set = this.handlers.get(pattern);
|
|
50
|
+
if (!set) {
|
|
51
|
+
set = /* @__PURE__ */ new Set();
|
|
52
|
+
this.handlers.set(pattern, set);
|
|
53
|
+
}
|
|
54
|
+
set.add(handler);
|
|
55
|
+
return () => {
|
|
56
|
+
const s = this.handlers.get(pattern);
|
|
57
|
+
if (!s) return;
|
|
58
|
+
s.delete(handler);
|
|
59
|
+
if (s.size === 0) this.handlers.delete(pattern);
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
async close() {
|
|
63
|
+
this.handlers.clear();
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
/** Factory form of {@link InProcessEventBus} for hosts that prefer functions. */
|
|
67
|
+
function createInProcessBus(options) {
|
|
68
|
+
return new InProcessEventBus(options);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* In-memory {@link OutboxStore} for tests and local dev. Not for production —
|
|
72
|
+
* hosts wire arc's `MongoOutboxStore`, a mongokit repo via
|
|
73
|
+
* `repositoryAsOutboxStore`, or any durable implementation.
|
|
74
|
+
*
|
|
75
|
+
* Implements the required trio (`save` / `getPending` / `acknowledge`) plus
|
|
76
|
+
* optional `purge`. Lease-based methods (`claimPending` / `fail` /
|
|
77
|
+
* `getDeadLettered`) are intentionally omitted — single-process tests don't
|
|
78
|
+
* need them, and kernels only call `save()` themselves. `all()` / `clear()`
|
|
79
|
+
* are test-inspection helpers (not part of the contract).
|
|
80
|
+
*/
|
|
81
|
+
var MemoryOutboxStore = class {
|
|
82
|
+
events = [];
|
|
83
|
+
async save(event, _options) {
|
|
84
|
+
this.events.push({ event });
|
|
85
|
+
}
|
|
86
|
+
async getPending(limit) {
|
|
87
|
+
return this.events.filter((e) => !e.acknowledgedAt).slice(0, limit).map((e) => e.event);
|
|
88
|
+
}
|
|
89
|
+
async acknowledge(eventId, _options) {
|
|
90
|
+
const entry = this.events.find((e) => e.event.meta.id === eventId);
|
|
91
|
+
if (entry) entry.acknowledgedAt = /* @__PURE__ */ new Date();
|
|
92
|
+
}
|
|
93
|
+
async purge(olderThanMs) {
|
|
94
|
+
const cutoff = Date.now() - olderThanMs;
|
|
95
|
+
const before = this.events.length;
|
|
96
|
+
this.events = this.events.filter((e) => !e.acknowledgedAt || e.acknowledgedAt.getTime() >= cutoff);
|
|
97
|
+
return before - this.events.length;
|
|
98
|
+
}
|
|
99
|
+
/** Every saved event (acknowledged or not) — test inspection helper. */
|
|
100
|
+
all() {
|
|
101
|
+
return this.events.map((e) => e.event);
|
|
102
|
+
}
|
|
103
|
+
/** Reset the store — test helper. */
|
|
104
|
+
clear() {
|
|
105
|
+
this.events = [];
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
//#endregion
|
|
109
|
+
export { InProcessEventBus, MemoryOutboxStore, createInProcessBus };
|
package/dist/events.d.mts
CHANGED
|
@@ -179,8 +179,18 @@ interface EventTransport {
|
|
|
179
179
|
/**
|
|
180
180
|
* Subscribe to a glob pattern — exact match, `*`, or `resource.*`. Returns
|
|
181
181
|
* an unsubscribe function.
|
|
182
|
+
*
|
|
183
|
+
* Optional (0.13.0): publish-only transports — outbox bridge adapters,
|
|
184
|
+
* host wrappers around a `publish(type, payload, meta)` helper, fire-and-
|
|
185
|
+
* forget pipes into Kafka/webhooks — are legitimate `EventTransport`s.
|
|
186
|
+
* Consumers that need in-process delivery MUST guard (`transport.subscribe
|
|
187
|
+
* ? … : throw/fallback`) the same way they already do for the optional
|
|
188
|
+
* `publishMany` / `deadLetter` members (see flow's
|
|
189
|
+
* `ConsignmentService.createSettlementListener` for the reference guard).
|
|
190
|
+
* Kernels subscribing on their OWN in-process bus (which always implements
|
|
191
|
+
* `subscribe`) are unaffected.
|
|
182
192
|
*/
|
|
183
|
-
subscribe(pattern: string, handler: EventHandler): Promise<() => void>;
|
|
193
|
+
subscribe?(pattern: string, handler: EventHandler): Promise<() => void>;
|
|
184
194
|
/**
|
|
185
195
|
* Route a permanently-failed event to the transport's dead-letter sink
|
|
186
196
|
* (Kafka DLQ topic, SQS DLQ, Redis Stream `PEL` timeout handler, etc.).
|
|
@@ -227,6 +237,54 @@ declare function createEvent<T>(type: string, payload: T, meta?: Partial<EventMe
|
|
|
227
237
|
* // reserved.meta.userId === user.id (inherited)
|
|
228
238
|
*/
|
|
229
239
|
declare function createChildEvent<T>(parent: DomainEvent, type: string, payload: T, meta?: Partial<EventMeta>): DomainEvent<T>;
|
|
240
|
+
/**
|
|
241
|
+
* The context slice {@link createScopedEvent} maps into {@link EventMeta}.
|
|
242
|
+
*
|
|
243
|
+
* Structurally satisfied by primitives' `OperationContext` (actorId /
|
|
244
|
+
* organizationId as IdLike) AND by the commerce `actorRef` dialect used by
|
|
245
|
+
* cart/promo/crm — so every kernel context threads straight through.
|
|
246
|
+
*/
|
|
247
|
+
interface EventScopeContext {
|
|
248
|
+
/** Authenticated principal id (`OperationContext.actorId` shape). */
|
|
249
|
+
actorId?: string | number | {
|
|
250
|
+
toString(): string;
|
|
251
|
+
} | undefined;
|
|
252
|
+
/** Commerce actor-reference dialect (`user:123`, `guest:abc`). Used when `actorId` is absent. */
|
|
253
|
+
actorRef?: string | undefined;
|
|
254
|
+
/** Tenant / organization / branch scope — rides `meta.organizationId` (META, never payload). */
|
|
255
|
+
organizationId?: string | {
|
|
256
|
+
toString(): string;
|
|
257
|
+
} | undefined;
|
|
258
|
+
/** Correlation id threaded across the causal chain. */
|
|
259
|
+
correlationId?: string | undefined;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Map an operation context onto event META — THE standard context→meta
|
|
263
|
+
* mapping (PACKAGE_RULES §8.3: `organizationId` rides META). Extracted from
|
|
264
|
+
* 25 drifted per-kernel `events/helpers.ts` copies.
|
|
265
|
+
*
|
|
266
|
+
* - `userId` ← `String(ctx.actorId)`, falling back to `ctx.actorRef`
|
|
267
|
+
* - `organizationId` ← `String(ctx.organizationId)`
|
|
268
|
+
* - `correlationId` ← `ctx.correlationId`
|
|
269
|
+
*
|
|
270
|
+
* Absent fields are OMITTED (not set to `undefined`) so the result spreads
|
|
271
|
+
* cleanly under `exactOptionalPropertyTypes`.
|
|
272
|
+
*/
|
|
273
|
+
declare function scopedEventMeta(ctx?: EventScopeContext): Partial<EventMeta>;
|
|
274
|
+
/**
|
|
275
|
+
* Build a {@link DomainEvent} scoped to an emitting package and an operation
|
|
276
|
+
* context — the shared core behind every kernel's `createXEvent` helper.
|
|
277
|
+
*
|
|
278
|
+
* ```ts
|
|
279
|
+
* // purchase/src/events/helpers.ts becomes:
|
|
280
|
+
* export const createPurchaseEvent = <T>(type, payload, ctx?, meta?) =>
|
|
281
|
+
* createScopedEvent('@classytic/purchase', type, payload, ctx, meta);
|
|
282
|
+
* ```
|
|
283
|
+
*
|
|
284
|
+
* Precedence: defaults (id/timestamp) < `source` < context mapping < caller
|
|
285
|
+
* `meta` — identical to the hand-rolled helpers this replaces.
|
|
286
|
+
*/
|
|
287
|
+
declare function createScopedEvent<T>(source: string, type: string, payload: T, ctx?: EventScopeContext, meta?: Partial<EventMeta>): DomainEvent<T>;
|
|
230
288
|
/**
|
|
231
289
|
* Match an event name against a glob pattern. Implements the same rules as
|
|
232
290
|
* arc's `MemoryEventTransport`:
|
|
@@ -237,4 +295,4 @@ declare function createChildEvent<T>(parent: DomainEvent, type: string, payload:
|
|
|
237
295
|
*/
|
|
238
296
|
declare function matchEventPattern(pattern: string, type: string): boolean;
|
|
239
297
|
//#endregion
|
|
240
|
-
export { DeadLetteredEvent, DomainEvent, EventHandler, EventLogger, EventMeta, EventTransport, PublishManyResult, createChildEvent, createEvent, matchEventPattern };
|
|
298
|
+
export { DeadLetteredEvent, DomainEvent, EventHandler, EventLogger, EventMeta, EventScopeContext, EventTransport, PublishManyResult, createChildEvent, createEvent, createScopedEvent, matchEventPattern, scopedEventMeta };
|
package/dist/events.mjs
CHANGED
|
@@ -64,6 +64,47 @@ function createChildEvent(parent, type, payload, meta) {
|
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
66
|
/**
|
|
67
|
+
* Map an operation context onto event META — THE standard context→meta
|
|
68
|
+
* mapping (PACKAGE_RULES §8.3: `organizationId` rides META). Extracted from
|
|
69
|
+
* 25 drifted per-kernel `events/helpers.ts` copies.
|
|
70
|
+
*
|
|
71
|
+
* - `userId` ← `String(ctx.actorId)`, falling back to `ctx.actorRef`
|
|
72
|
+
* - `organizationId` ← `String(ctx.organizationId)`
|
|
73
|
+
* - `correlationId` ← `ctx.correlationId`
|
|
74
|
+
*
|
|
75
|
+
* Absent fields are OMITTED (not set to `undefined`) so the result spreads
|
|
76
|
+
* cleanly under `exactOptionalPropertyTypes`.
|
|
77
|
+
*/
|
|
78
|
+
function scopedEventMeta(ctx) {
|
|
79
|
+
if (!ctx) return {};
|
|
80
|
+
const meta = {};
|
|
81
|
+
const actor = ctx.actorId ?? ctx.actorRef;
|
|
82
|
+
if (actor !== void 0) meta.userId = String(actor);
|
|
83
|
+
if (ctx.organizationId !== void 0) meta.organizationId = String(ctx.organizationId);
|
|
84
|
+
if (ctx.correlationId !== void 0) meta.correlationId = ctx.correlationId;
|
|
85
|
+
return meta;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Build a {@link DomainEvent} scoped to an emitting package and an operation
|
|
89
|
+
* context — the shared core behind every kernel's `createXEvent` helper.
|
|
90
|
+
*
|
|
91
|
+
* ```ts
|
|
92
|
+
* // purchase/src/events/helpers.ts becomes:
|
|
93
|
+
* export const createPurchaseEvent = <T>(type, payload, ctx?, meta?) =>
|
|
94
|
+
* createScopedEvent('@classytic/purchase', type, payload, ctx, meta);
|
|
95
|
+
* ```
|
|
96
|
+
*
|
|
97
|
+
* Precedence: defaults (id/timestamp) < `source` < context mapping < caller
|
|
98
|
+
* `meta` — identical to the hand-rolled helpers this replaces.
|
|
99
|
+
*/
|
|
100
|
+
function createScopedEvent(source, type, payload, ctx, meta) {
|
|
101
|
+
return createEvent(type, payload, {
|
|
102
|
+
source,
|
|
103
|
+
...scopedEventMeta(ctx),
|
|
104
|
+
...meta
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
67
108
|
* Match an event name against a glob pattern. Implements the same rules as
|
|
68
109
|
* arc's `MemoryEventTransport`:
|
|
69
110
|
* - `*` matches anything
|
|
@@ -87,4 +128,4 @@ function randomEventId() {
|
|
|
87
128
|
});
|
|
88
129
|
}
|
|
89
130
|
//#endregion
|
|
90
|
-
export { createChildEvent, createEvent, matchEventPattern };
|
|
131
|
+
export { createChildEvent, createEvent, createScopedEvent, matchEventPattern, scopedEventMeta };
|
package/dist/money.d.mts
CHANGED
|
@@ -104,7 +104,38 @@ declare function isPositiveMoney(m: Money): boolean;
|
|
|
104
104
|
declare function isNegativeMoney(m: Money): boolean;
|
|
105
105
|
declare function negateMoney(m: Money): Money;
|
|
106
106
|
declare function absMoney(m: Money): Money;
|
|
107
|
+
/**
|
|
108
|
+
* Decimal major-unit number → integer minor units by decimal exponent.
|
|
109
|
+
* Rounding: raw IEEE `Math.round` — half-up toward +∞ (so `-10.5 → -10`),
|
|
110
|
+
* documented above. Throws when the result leaves the safe-integer range
|
|
111
|
+
* (also catches `NaN`/`Infinity` inputs).
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* majorToMinorUnits(10.5) // 1050
|
|
115
|
+
* majorToMinorUnits(1000, 0) // 1000 (JPY-style)
|
|
116
|
+
* majorToMinorUnits(1.005) // 100 — IEEE, see note above
|
|
117
|
+
*/
|
|
118
|
+
declare function majorToMinorUnits(major: number, minorUnitDecimals?: number): number;
|
|
119
|
+
/** Integer minor units → decimal major-unit number by decimal exponent: `1050 → 10.5`. */
|
|
120
|
+
declare function minorUnitsToMajor(minor: number, minorUnitDecimals?: number): number;
|
|
121
|
+
/**
|
|
122
|
+
* Percentage of an integer minor-unit amount → integer minor units.
|
|
123
|
+
* EXACT multiply-then-round: `Math.round((minor × ratePercent) / 100)` —
|
|
124
|
+
* one rounding point, half-up toward +∞. Handles genuinely fractional rates
|
|
125
|
+
* (QST 9.975 %) without precision loss: `percentOfMinor(20000, 9.975) === 1995`.
|
|
126
|
+
*
|
|
127
|
+
* Hosts whose domain guarantees at-most-2-decimal rates MAY snap the rate to
|
|
128
|
+
* integer basis points BEFORE calling (be-prod's `#shared/money` seam does) —
|
|
129
|
+
* that is host policy layered on top, not part of this primitive.
|
|
130
|
+
*/
|
|
131
|
+
declare function percentOfMinor(minor: number, ratePercent: number): number;
|
|
132
|
+
/**
|
|
133
|
+
* Integer minor units → plain decimal string (no currency symbol, no locale):
|
|
134
|
+
* `formatMinorUnits(10550) → "105.50"`, `formatMinorUnits(1000, 0) → "1000"`.
|
|
135
|
+
* For localized currency strings use `Intl.NumberFormat` at the display layer.
|
|
136
|
+
*/
|
|
137
|
+
declare function formatMinorUnits(minor: number, minorUnitDecimals?: number): string;
|
|
107
138
|
/** Structural type guard. */
|
|
108
139
|
declare function isMoney(value: unknown): value is Money;
|
|
109
140
|
//#endregion
|
|
110
|
-
export { CurrencyMismatchError, MinorUnits, Money, absMoney, addMoney, compareMoney, equalsMoney, fromMajor, isMoney, isNegativeMoney, isPositiveMoney, isZeroMoney, money, multiplyMoney, negateMoney, subtractMoney, sumMoney, toMajor };
|
|
141
|
+
export { CurrencyMismatchError, MinorUnits, Money, absMoney, addMoney, compareMoney, equalsMoney, formatMinorUnits, fromMajor, isMoney, isNegativeMoney, isPositiveMoney, isZeroMoney, majorToMinorUnits, minorUnitsToMajor, money, multiplyMoney, negateMoney, percentOfMinor, subtractMoney, sumMoney, toMajor };
|
package/dist/money.mjs
CHANGED
|
@@ -135,9 +135,52 @@ function negateMoney(m) {
|
|
|
135
135
|
function absMoney(m) {
|
|
136
136
|
return m.amount < 0 ? negateMoney(m) : m;
|
|
137
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Decimal major-unit number → integer minor units by decimal exponent.
|
|
140
|
+
* Rounding: raw IEEE `Math.round` — half-up toward +∞ (so `-10.5 → -10`),
|
|
141
|
+
* documented above. Throws when the result leaves the safe-integer range
|
|
142
|
+
* (also catches `NaN`/`Infinity` inputs).
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* majorToMinorUnits(10.5) // 1050
|
|
146
|
+
* majorToMinorUnits(1000, 0) // 1000 (JPY-style)
|
|
147
|
+
* majorToMinorUnits(1.005) // 100 — IEEE, see note above
|
|
148
|
+
*/
|
|
149
|
+
function majorToMinorUnits(major, minorUnitDecimals = 2) {
|
|
150
|
+
const factor = 10 ** minorUnitDecimals;
|
|
151
|
+
const minor = Math.round(major * factor);
|
|
152
|
+
if (!Number.isSafeInteger(minor)) throw new RangeError(`Amount ${major} exceeds safe integer limit when converted to minor units. Max safe amount: ${Number.MAX_SAFE_INTEGER / factor}`);
|
|
153
|
+
return minor;
|
|
154
|
+
}
|
|
155
|
+
/** Integer minor units → decimal major-unit number by decimal exponent: `1050 → 10.5`. */
|
|
156
|
+
function minorUnitsToMajor(minor, minorUnitDecimals = 2) {
|
|
157
|
+
return minor / 10 ** minorUnitDecimals;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Percentage of an integer minor-unit amount → integer minor units.
|
|
161
|
+
* EXACT multiply-then-round: `Math.round((minor × ratePercent) / 100)` —
|
|
162
|
+
* one rounding point, half-up toward +∞. Handles genuinely fractional rates
|
|
163
|
+
* (QST 9.975 %) without precision loss: `percentOfMinor(20000, 9.975) === 1995`.
|
|
164
|
+
*
|
|
165
|
+
* Hosts whose domain guarantees at-most-2-decimal rates MAY snap the rate to
|
|
166
|
+
* integer basis points BEFORE calling (be-prod's `#shared/money` seam does) —
|
|
167
|
+
* that is host policy layered on top, not part of this primitive.
|
|
168
|
+
*/
|
|
169
|
+
function percentOfMinor(minor, ratePercent) {
|
|
170
|
+
if (!Number.isFinite(minor) || !Number.isFinite(ratePercent)) throw new TypeError(`percentOfMinor received non-finite input: minor=${minor}, ratePercent=${ratePercent}`);
|
|
171
|
+
return Math.round(minor * ratePercent / 100);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Integer minor units → plain decimal string (no currency symbol, no locale):
|
|
175
|
+
* `formatMinorUnits(10550) → "105.50"`, `formatMinorUnits(1000, 0) → "1000"`.
|
|
176
|
+
* For localized currency strings use `Intl.NumberFormat` at the display layer.
|
|
177
|
+
*/
|
|
178
|
+
function formatMinorUnits(minor, minorUnitDecimals = 2) {
|
|
179
|
+
return minorUnitsToMajor(minor, minorUnitDecimals).toFixed(minorUnitDecimals);
|
|
180
|
+
}
|
|
138
181
|
/** Structural type guard. */
|
|
139
182
|
function isMoney(value) {
|
|
140
183
|
return typeof value === "object" && value !== null && typeof value.amount === "number" && Number.isInteger(value.amount) && typeof value.currency === "string";
|
|
141
184
|
}
|
|
142
185
|
//#endregion
|
|
143
|
-
export { CurrencyMismatchError, MinorUnits, absMoney, addMoney, compareMoney, equalsMoney, fromMajor, isMoney, isNegativeMoney, isPositiveMoney, isZeroMoney, money, multiplyMoney, negateMoney, subtractMoney, sumMoney, toMajor };
|
|
186
|
+
export { CurrencyMismatchError, MinorUnits, absMoney, addMoney, compareMoney, equalsMoney, formatMinorUnits, fromMajor, isMoney, isNegativeMoney, isPositiveMoney, isZeroMoney, majorToMinorUnits, minorUnitsToMajor, money, multiplyMoney, negateMoney, percentOfMinor, subtractMoney, sumMoney, toMajor };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { r as CurrencyCode } from "./currency-BCJnJBlb.mjs";
|
|
2
|
+
import { MinorUnits } from "./money.mjs";
|
|
3
|
+
|
|
4
|
+
//#region src/money/unit-cost-rate.d.ts
|
|
5
|
+
/** Fixed, ecosystem-wide rate scale: 6 decimal places below the minor unit. */
|
|
6
|
+
declare const RATE_SCALE = 1000000;
|
|
7
|
+
/** Half-to-even (banker's) is the default; half-up available for legacy parity. */
|
|
8
|
+
type RoundingMode = 'half-even' | 'half-up';
|
|
9
|
+
/**
|
|
10
|
+
* Minor units per unit, stored as an integer scaled by {@link RATE_SCALE}.
|
|
11
|
+
* `scale` is carried so a value is self-describing on the wire even if
|
|
12
|
+
* `RATE_SCALE` ever changes; it is always `RATE_SCALE` today.
|
|
13
|
+
*/
|
|
14
|
+
interface UnitCostRate<C extends string = string> {
|
|
15
|
+
/** Integer: minor units per unit × `scale`. */
|
|
16
|
+
readonly scaledAmount: number;
|
|
17
|
+
readonly currency: C | CurrencyCode | string;
|
|
18
|
+
/** Always {@link RATE_SCALE}. */
|
|
19
|
+
readonly scale: number;
|
|
20
|
+
}
|
|
21
|
+
type UnitCostRateErrorCode = 'NOT_FINITE' | 'NEGATIVE' | 'NOT_INTEGER' | 'UNSAFE_INTEGER' | 'NON_POSITIVE_QUANTITY';
|
|
22
|
+
declare class UnitCostRateError extends Error {
|
|
23
|
+
readonly name = "UnitCostRateError";
|
|
24
|
+
readonly code: UnitCostRateErrorCode;
|
|
25
|
+
constructor(code: UnitCostRateErrorCode, message: string);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Construct a rate from a (possibly fractional) minor-units-per-unit value.
|
|
29
|
+
*
|
|
30
|
+
* @example unitCostRate(33.3333, 'BDT') // { scaledAmount: 33_333_300, ... }
|
|
31
|
+
*/
|
|
32
|
+
declare function unitCostRate<C extends string = string>(minorPerUnit: number, currency: C): UnitCostRate<C>;
|
|
33
|
+
/**
|
|
34
|
+
* Weighted-average constructor: the rate implied by an integer minor-unit
|
|
35
|
+
* `totalMinor` spread over a (possibly fractional) `quantity`. This is the WAC
|
|
36
|
+
* / layer-average rate. Overflow-safe via bigint; quantity rounded to the
|
|
37
|
+
* {@link QTY_SCALE} grid.
|
|
38
|
+
*
|
|
39
|
+
* @example unitCostRateFromTotal(100, 3, 'BDT') // 33.333… paisa/unit
|
|
40
|
+
*/
|
|
41
|
+
declare function unitCostRateFromTotal<C extends string = string>(totalMinor: number, quantity: number, currency: C): UnitCostRate<C>;
|
|
42
|
+
/**
|
|
43
|
+
* The extended monetary amount for `quantity` units at `rate`, rounded to
|
|
44
|
+
* integer {@link MinorUnits} — the ONE rate→total rounding boundary.
|
|
45
|
+
*
|
|
46
|
+
* `round(quantity × scaledAmount / RATE_SCALE)`, computed in bigint so large
|
|
47
|
+
* amounts never lose precision. A fractional quantity is honoured to
|
|
48
|
+
* {@link QTY_SCALE} precision.
|
|
49
|
+
*/
|
|
50
|
+
declare function extendedAmount<C extends string = string>(rate: UnitCostRate<C>, quantity: number, mode?: RoundingMode): MinorUnits<C>;
|
|
51
|
+
/** The rate as a plain (fractional) minor-units-per-unit number — for display only. */
|
|
52
|
+
declare function rateMinorPerUnit(rate: UnitCostRate): number;
|
|
53
|
+
/** Structural guard. */
|
|
54
|
+
declare function isUnitCostRate(value: unknown): value is UnitCostRate;
|
|
55
|
+
//#endregion
|
|
56
|
+
export { RATE_SCALE, RoundingMode, UnitCostRate, UnitCostRateError, UnitCostRateErrorCode, extendedAmount, isUnitCostRate, rateMinorPerUnit, unitCostRate, unitCostRateFromTotal };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { MinorUnits } from "./money.mjs";
|
|
2
|
+
//#region src/money/unit-cost-rate.ts
|
|
3
|
+
/** Fixed, ecosystem-wide rate scale: 6 decimal places below the minor unit. */
|
|
4
|
+
const RATE_SCALE = 1e6;
|
|
5
|
+
/**
|
|
6
|
+
* Quantity scale used internally so a fractional quantity (e.g. `2.5`, a UoM
|
|
7
|
+
* split) participates in exact integer/bigint arithmetic. 6 dp of quantity
|
|
8
|
+
* precision — quantities finer than 1e-6 unit are rounded to this grid.
|
|
9
|
+
*/
|
|
10
|
+
const QTY_SCALE = 1e6;
|
|
11
|
+
const DEFAULT_ROUNDING = "half-even";
|
|
12
|
+
var UnitCostRateError = class extends Error {
|
|
13
|
+
name = "UnitCostRateError";
|
|
14
|
+
code;
|
|
15
|
+
constructor(code, message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.code = code;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
function assertSafe(scaled) {
|
|
21
|
+
if (!Number.isFinite(scaled)) throw new UnitCostRateError("NOT_FINITE", `unit cost rate is not finite: ${scaled}`);
|
|
22
|
+
if (!Number.isSafeInteger(scaled)) throw new UnitCostRateError("UNSAFE_INTEGER", `scaled unit cost ${scaled} exceeds Number.MAX_SAFE_INTEGER — value too large for this rate scale`);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Scale a (possibly fractional) quantity onto the {@link QTY_SCALE} grid as a
|
|
26
|
+
* bigint. Guards against a quantity so large that `quantity × QTY_SCALE` loses
|
|
27
|
+
* integer precision as a double BEFORE reaching bigint (≈ >9e9 units).
|
|
28
|
+
*/
|
|
29
|
+
function scaleQuantity(quantity) {
|
|
30
|
+
const q = Math.round(quantity * QTY_SCALE);
|
|
31
|
+
if (!Number.isSafeInteger(q)) throw new UnitCostRateError("UNSAFE_INTEGER", `quantity ${quantity} is too large to scale precisely — exceeds safe-integer range`);
|
|
32
|
+
return BigInt(q);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Construct a rate from a (possibly fractional) minor-units-per-unit value.
|
|
36
|
+
*
|
|
37
|
+
* @example unitCostRate(33.3333, 'BDT') // { scaledAmount: 33_333_300, ... }
|
|
38
|
+
*/
|
|
39
|
+
function unitCostRate(minorPerUnit, currency) {
|
|
40
|
+
if (!Number.isFinite(minorPerUnit)) throw new UnitCostRateError("NOT_FINITE", `minorPerUnit is not finite: ${minorPerUnit}`);
|
|
41
|
+
if (minorPerUnit < 0) throw new UnitCostRateError("NEGATIVE", `unit cost cannot be negative: ${minorPerUnit}`);
|
|
42
|
+
const scaledAmount = Math.round(minorPerUnit * RATE_SCALE);
|
|
43
|
+
assertSafe(scaledAmount);
|
|
44
|
+
return {
|
|
45
|
+
scaledAmount,
|
|
46
|
+
currency,
|
|
47
|
+
scale: RATE_SCALE
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Weighted-average constructor: the rate implied by an integer minor-unit
|
|
52
|
+
* `totalMinor` spread over a (possibly fractional) `quantity`. This is the WAC
|
|
53
|
+
* / layer-average rate. Overflow-safe via bigint; quantity rounded to the
|
|
54
|
+
* {@link QTY_SCALE} grid.
|
|
55
|
+
*
|
|
56
|
+
* @example unitCostRateFromTotal(100, 3, 'BDT') // 33.333… paisa/unit
|
|
57
|
+
*/
|
|
58
|
+
function unitCostRateFromTotal(totalMinor, quantity, currency) {
|
|
59
|
+
if (!Number.isInteger(totalMinor)) throw new UnitCostRateError("NOT_INTEGER", `totalMinor must be integer minor units: ${totalMinor}`);
|
|
60
|
+
if (!Number.isFinite(quantity) || quantity <= 0) throw new UnitCostRateError("NON_POSITIVE_QUANTITY", `quantity must be > 0, got ${quantity}`);
|
|
61
|
+
const qScaled = scaleQuantity(quantity);
|
|
62
|
+
const numerator = BigInt(totalMinor) * BigInt(RATE_SCALE) * BigInt(QTY_SCALE);
|
|
63
|
+
const scaled = Number(roundedDiv(numerator, qScaled, "half-even"));
|
|
64
|
+
assertSafe(scaled);
|
|
65
|
+
return {
|
|
66
|
+
scaledAmount: scaled,
|
|
67
|
+
currency,
|
|
68
|
+
scale: RATE_SCALE
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The extended monetary amount for `quantity` units at `rate`, rounded to
|
|
73
|
+
* integer {@link MinorUnits} — the ONE rate→total rounding boundary.
|
|
74
|
+
*
|
|
75
|
+
* `round(quantity × scaledAmount / RATE_SCALE)`, computed in bigint so large
|
|
76
|
+
* amounts never lose precision. A fractional quantity is honoured to
|
|
77
|
+
* {@link QTY_SCALE} precision.
|
|
78
|
+
*/
|
|
79
|
+
function extendedAmount(rate, quantity, mode = DEFAULT_ROUNDING) {
|
|
80
|
+
if (!Number.isFinite(quantity) || quantity < 0) throw new UnitCostRateError("NON_POSITIVE_QUANTITY", `quantity must be >= 0, got ${quantity}`);
|
|
81
|
+
const qScaled = scaleQuantity(quantity);
|
|
82
|
+
const numerator = BigInt(rate.scaledAmount) * qScaled;
|
|
83
|
+
const denominator = BigInt(rate.scale) * BigInt(QTY_SCALE);
|
|
84
|
+
const minor = Number(roundedDiv(numerator, denominator, mode));
|
|
85
|
+
if (!Number.isSafeInteger(minor)) throw new UnitCostRateError("UNSAFE_INTEGER", `extended amount ${minor} exceeds safe integer range`);
|
|
86
|
+
return MinorUnits(minor, rate.currency);
|
|
87
|
+
}
|
|
88
|
+
/** The rate as a plain (fractional) minor-units-per-unit number — for display only. */
|
|
89
|
+
function rateMinorPerUnit(rate) {
|
|
90
|
+
return rate.scaledAmount / rate.scale;
|
|
91
|
+
}
|
|
92
|
+
/** Structural guard. */
|
|
93
|
+
function isUnitCostRate(value) {
|
|
94
|
+
return typeof value === "object" && value !== null && Number.isInteger(value.scaledAmount) && typeof value.currency === "string" && typeof value.scale === "number";
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Rounded integer division of two bigints, `numerator / denominator`
|
|
98
|
+
* (`denominator > 0`), honouring the rounding mode at the exact half.
|
|
99
|
+
* Sign-safe for negative numerators.
|
|
100
|
+
*/
|
|
101
|
+
function roundedDiv(numerator, denominator, mode) {
|
|
102
|
+
const neg = numerator < 0n;
|
|
103
|
+
const n = neg ? -numerator : numerator;
|
|
104
|
+
const q = n / denominator;
|
|
105
|
+
const remainder = n - q * denominator;
|
|
106
|
+
if (remainder === 0n) return neg ? -q : q;
|
|
107
|
+
const twiceRem = remainder * 2n;
|
|
108
|
+
let up;
|
|
109
|
+
if (twiceRem > denominator) up = true;
|
|
110
|
+
else if (twiceRem < denominator) up = false;
|
|
111
|
+
else up = mode === "half-up" ? true : q % 2n !== 0n;
|
|
112
|
+
const result = up ? q + 1n : q;
|
|
113
|
+
return neg ? -result : result;
|
|
114
|
+
}
|
|
115
|
+
//#endregion
|
|
116
|
+
export { RATE_SCALE, UnitCostRateError, extendedAmount, isUnitCostRate, rateMinorPerUnit, unitCostRate, unitCostRateFromTotal };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/primitives",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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",
|
|
@@ -29,6 +29,10 @@
|
|
|
29
29
|
"types": "./dist/currency.d.mts",
|
|
30
30
|
"default": "./dist/currency.mjs"
|
|
31
31
|
},
|
|
32
|
+
"./unit-cost-rate": {
|
|
33
|
+
"types": "./dist/unit-cost-rate.d.mts",
|
|
34
|
+
"default": "./dist/unit-cost-rate.mjs"
|
|
35
|
+
},
|
|
32
36
|
"./address": {
|
|
33
37
|
"types": "./dist/address.d.mts",
|
|
34
38
|
"default": "./dist/address.mjs"
|
|
@@ -57,6 +61,10 @@
|
|
|
57
61
|
"types": "./dist/events.d.mts",
|
|
58
62
|
"default": "./dist/events.mjs"
|
|
59
63
|
},
|
|
64
|
+
"./event-infra": {
|
|
65
|
+
"types": "./dist/event-infra.d.mts",
|
|
66
|
+
"default": "./dist/event-infra.mjs"
|
|
67
|
+
},
|
|
60
68
|
"./outbox": {
|
|
61
69
|
"types": "./dist/outbox.d.mts",
|
|
62
70
|
"default": "./dist/outbox.mjs"
|