@classytic/primitives 0.13.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 CHANGED
@@ -3,6 +3,84 @@
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
+
6
84
  ## [0.13.0] - 2026-07-22
7
85
 
8
86
  ### Added — `/unit-cost-rate`: exact per-unit monetary rates (bigint-safe)
@@ -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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/primitives",
3
- "version": "0.13.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",
@@ -61,6 +61,10 @@
61
61
  "types": "./dist/events.d.mts",
62
62
  "default": "./dist/events.mjs"
63
63
  },
64
+ "./event-infra": {
65
+ "types": "./dist/event-infra.d.mts",
66
+ "default": "./dist/event-infra.mjs"
67
+ },
64
68
  "./outbox": {
65
69
  "types": "./dist/outbox.d.mts",
66
70
  "default": "./dist/outbox.mjs"