@classytic/primitives 0.29.0 → 0.31.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 +43 -0
- package/dist/approval.d.mts +74 -6
- package/dist/approval.mjs +36 -2
- package/dist/durable-events.d.mts +83 -0
- package/dist/durable-events.mjs +115 -0
- package/dist/events.d.mts +6 -6
- package/dist/events.mjs +5 -6
- package/dist/item-facts.d.mts +19 -0
- package/dist/item-facts.mjs +10 -0
- package/dist/memory-outbox.d.mts +1 -0
- package/dist/memory-outbox.mjs +16 -1
- package/dist/monetization.d.mts +3 -1
- package/dist/monetization.mjs +7 -1
- package/dist/outbox.d.mts +26 -2
- package/dist/outbox.mjs +43 -1
- package/dist/price-basis.d.mts +5 -2
- package/dist/price-basis.mjs +10 -4
- package/dist/quantity.d.mts +38 -0
- package/dist/quantity.mjs +88 -0
- package/dist/rounded-div-D7lyJXt8.d.mts +4 -0
- package/dist/split-allocation.d.mts +20 -2
- package/dist/split-allocation.mjs +57 -1
- package/dist/unit-cost-rate.d.mts +1 -3
- package/dist/usage-pricing.d.mts +53 -0
- package/dist/usage-pricing.mjs +150 -0
- package/package.json +16 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,49 @@
|
|
|
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.31.0 — 2026-10-11
|
|
7
|
+
|
|
8
|
+
### Added — `./quantity`: the 6-decimal quantity grid for fractional stock and sale quantities
|
|
9
|
+
- `snapQuantity(qty, step?, mode)` (half-even by default), `isOnStep`, `compareQuantity`, `quantitiesEqual`,
|
|
10
|
+
`quantityToGrid`, `quantityStepToGrid`, `QUANTITY_SCALE` (1,000,000) and `QuantityError`
|
|
11
|
+
(`NOT_FINITE` / `INVALID_STEP` / `UNSAFE_QUANTITY`).
|
|
12
|
+
- `isOnQuantityGrid(value)`: STRICT, for input boundaries. `isOnStep` snaps first, so
|
|
13
|
+
`isOnStep(2.3500004, 0.001)` is true; `isOnQuantityGrid(2.3500004)` is false.
|
|
14
|
+
|
|
15
|
+
### Added — `./usage-pricing`
|
|
16
|
+
- `priceUsage(rating, measured)` prices a measured amount against a `UsageRating` (per-unit, tiered, volume,
|
|
17
|
+
package; a started unit counts whole; breakdown included), plus `UsageMeasure` / `isUsageMeasure` and
|
|
18
|
+
`UsagePricingError`.
|
|
19
|
+
|
|
20
|
+
### Added — the stock unit group on item facts
|
|
21
|
+
- `uomGroupRef` (optional) on `ItemStockSource`, `ProductFacet` and `ItemStockFacts`, and `unitOfMeasureDeclared`
|
|
22
|
+
(optional) on `ProductFacet` and `ItemStockFacts`. `resolveStockFacts` takes the Item's group, falls back to
|
|
23
|
+
the product's only when no Item exists, and marks an ungrouped Item under a grouped product
|
|
24
|
+
`unitOfMeasureDeclared` instead of borrowing the product's group. All fields optional: existing values of these
|
|
25
|
+
types still type-check.
|
|
26
|
+
|
|
27
|
+
### Changed — `resolvePriceBasisTotal` accepts a fractional quantity
|
|
28
|
+
- A whole quantity is computed exactly as before, across the full safe-integer range (pinned by a regression
|
|
29
|
+
test against the previous formula).
|
|
30
|
+
- A fractional quantity on the 6-decimal grid (e.g. `2.35`) is priced exactly: price × quantity on the grid,
|
|
31
|
+
divided by the measured basis, rounded ONCE half-even. Previously it threw `INVALID_QUANTITY`.
|
|
32
|
+
- Still refused (`INVALID_QUANTITY`): a negative, non-finite or off-grid quantity (a 7th decimal), or a
|
|
33
|
+
fraction too large to scale exactly.
|
|
34
|
+
|
|
35
|
+
## 0.30.0 — never published separately; shipped in 0.31.0
|
|
36
|
+
|
|
37
|
+
### Added — `./durable-events`: the declaration and the ONE kernel dispatch
|
|
38
|
+
- `EventDurability`, `DurableEventSpec`, `FactExpectation`; `eventIdFor` / `durableEventMeta` (`${name}:v${version}:${occurrence}`); `requireDurableContext` + `NonDurablePublishError`; `UnmanagedSessionError`.
|
|
39
|
+
- `dispatchDomainEvent(channels, unit, event, durability)` saves the outbox row in the session, defers to commit, and acknowledges after an inline publish.
|
|
40
|
+
- `flushDeferredEvents` delivers a committed queue and acknowledges every delivered row in ONE `acknowledgeMany`.
|
|
41
|
+
Pinned by `tests/unit/durable-events.test.ts`.
|
|
42
|
+
|
|
43
|
+
### Added — `./outbox`: `OutboxConflictError`, `outboxPayloadFingerprint`, optional `OutboxStore.acknowledgeMany`
|
|
44
|
+
|
|
45
|
+
### ⚠ Changed
|
|
46
|
+
- `createChildEvent` no longer inherits the parent's `idempotencyKey`. A child is its own occurrence; an inherited key made a store drop the second of two child types.
|
|
47
|
+
- `MemoryOutboxStore.save`: the same id with the same payload is a no-op, and a different payload throws `OutboxConflictError`.
|
|
48
|
+
|
|
6
49
|
## 0.29.0 — 2026-10-08
|
|
7
50
|
|
|
8
51
|
### Added — `./signed-token`: ES256 compact JWS over WebCrypto
|
package/dist/approval.d.mts
CHANGED
|
@@ -32,7 +32,9 @@
|
|
|
32
32
|
* isApproved(chain); // → true
|
|
33
33
|
*/
|
|
34
34
|
type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'skipped';
|
|
35
|
-
|
|
35
|
+
declare const CHAIN_ORDERS: readonly ["sequential", "parallel"];
|
|
36
|
+
type ChainOrder = (typeof CHAIN_ORDERS)[number];
|
|
37
|
+
declare const APPROVAL_THRESHOLD_OPS: readonly ["gt", "gte", "lt", "lte", "eq"];
|
|
36
38
|
interface Approver {
|
|
37
39
|
readonly id: string;
|
|
38
40
|
readonly name?: string;
|
|
@@ -43,6 +45,11 @@ interface ApprovalDecision {
|
|
|
43
45
|
readonly decision: 'approved' | 'rejected';
|
|
44
46
|
readonly note?: string;
|
|
45
47
|
readonly decidedAt: Date;
|
|
48
|
+
/**
|
|
49
|
+
* The org the approver acted from, when it differs from the document's (a cross-branch
|
|
50
|
+
* decision). The document's own org is the document's tenant field, so both are on the row.
|
|
51
|
+
*/
|
|
52
|
+
readonly actorOrganizationId?: string;
|
|
46
53
|
}
|
|
47
54
|
/**
|
|
48
55
|
* Optional threshold gate. The primitive does not evaluate this — it's
|
|
@@ -52,7 +59,7 @@ interface ApprovalDecision {
|
|
|
52
59
|
*/
|
|
53
60
|
interface ApprovalThreshold {
|
|
54
61
|
readonly field: string;
|
|
55
|
-
readonly op:
|
|
62
|
+
readonly op: (typeof APPROVAL_THRESHOLD_OPS)[number];
|
|
56
63
|
readonly value: number;
|
|
57
64
|
}
|
|
58
65
|
interface ApprovalStep {
|
|
@@ -86,9 +93,13 @@ interface ApprovalChain {
|
|
|
86
93
|
interface ApprovalRequest {
|
|
87
94
|
readonly by: string;
|
|
88
95
|
readonly at: Date;
|
|
89
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* What the approval covers. A later amount above `basis.amount` is not covered. `unit` states the
|
|
98
|
+
* amount is integer base-currency minor units; a stored amount without it is of unknown unit.
|
|
99
|
+
*/
|
|
90
100
|
readonly basis?: {
|
|
91
101
|
readonly amount?: number;
|
|
102
|
+
readonly unit?: 'minor';
|
|
92
103
|
readonly category?: string;
|
|
93
104
|
};
|
|
94
105
|
}
|
|
@@ -178,15 +189,72 @@ declare function attachRequest(chain: ApprovalChain, request: ApprovalRequest):
|
|
|
178
189
|
* For `parallel` chains: returns the first pending step (all pending steps
|
|
179
190
|
* are active simultaneously; host typically iterates with {@link pendingSteps}).
|
|
180
191
|
*/
|
|
181
|
-
declare function nextPendingStep
|
|
192
|
+
declare function nextPendingStep<S extends {
|
|
193
|
+
readonly status: ApprovalStatus;
|
|
194
|
+
}>(chain: {
|
|
195
|
+
readonly steps: readonly S[];
|
|
196
|
+
}): S | null;
|
|
182
197
|
/** All steps currently eligible to receive a decision. */
|
|
183
|
-
declare function pendingSteps
|
|
198
|
+
declare function pendingSteps<S extends {
|
|
199
|
+
readonly status: ApprovalStatus;
|
|
200
|
+
}>(chain: {
|
|
201
|
+
readonly order: ChainOrder;
|
|
202
|
+
readonly steps: readonly S[];
|
|
203
|
+
}): readonly S[];
|
|
204
|
+
/**
|
|
205
|
+
* The verbs every approval-bearing resource mounts at `POST /:id/action`.
|
|
206
|
+
* Servers (arc-approval) and clients (SDK, UI) both read these; never write the strings.
|
|
207
|
+
*/
|
|
208
|
+
declare const APPROVAL_ACTIONS: {
|
|
209
|
+
readonly submit: "submit_for_approval";
|
|
210
|
+
readonly decide: "decide";
|
|
211
|
+
};
|
|
212
|
+
type ApprovalActionName = (typeof APPROVAL_ACTIONS)[keyof typeof APPROVAL_ACTIONS];
|
|
213
|
+
/** Where a submit sources its chain: inline `chain`, the policy matrix, or inferred. */
|
|
214
|
+
declare const APPROVAL_CHAIN_SOURCES: readonly ["auto", "literal", "policy"];
|
|
215
|
+
type ApprovalChainSource = (typeof APPROVAL_CHAIN_SOURCES)[number];
|
|
216
|
+
declare const APPROVAL_DECISIONS: readonly ["approved", "rejected"];
|
|
217
|
+
type ApprovalDecisionKind = (typeof APPROVAL_DECISIONS)[number];
|
|
218
|
+
/** Longest decision note the server accepts. */
|
|
219
|
+
declare const APPROVAL_NOTE_MAX_LENGTH = 1000;
|
|
220
|
+
/** `submit_for_approval` body. */
|
|
221
|
+
type ApprovalSubmitBody = {
|
|
222
|
+
readonly source?: ApprovalChainSource;
|
|
223
|
+
readonly chain?: CreateChainInput;
|
|
224
|
+
};
|
|
225
|
+
/** `decide` body. The approver is the authenticated actor — never a body field. */
|
|
226
|
+
type ApprovalDecideBody = {
|
|
227
|
+
readonly stepId: string;
|
|
228
|
+
readonly decision: ApprovalDecisionKind;
|
|
229
|
+
readonly note?: string;
|
|
230
|
+
};
|
|
231
|
+
/** The chain fields {@link decidableStepIds} reads — satisfied by a stored chain and by its JSON form. */
|
|
232
|
+
interface DecidableChain {
|
|
233
|
+
readonly order: ChainOrder;
|
|
234
|
+
readonly status: ApprovalStatus;
|
|
235
|
+
readonly steps: readonly {
|
|
236
|
+
readonly id: string;
|
|
237
|
+
readonly status: ApprovalStatus;
|
|
238
|
+
readonly approvers: readonly {
|
|
239
|
+
readonly id: string;
|
|
240
|
+
}[];
|
|
241
|
+
readonly decisions: readonly {
|
|
242
|
+
readonly approverId: string;
|
|
243
|
+
}[];
|
|
244
|
+
}[];
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The steps `actorId` may decide now: active, naming the actor as an approver, not yet
|
|
248
|
+
* decided by them. The ONE answer to "may this actor decide" for server and UI alike.
|
|
249
|
+
*/
|
|
250
|
+
declare function decidableStepIds(chain: DecidableChain, actorId: string | null | undefined): string[];
|
|
184
251
|
interface DecisionInput {
|
|
185
252
|
readonly stepId: string;
|
|
186
253
|
readonly approverId: string;
|
|
187
254
|
readonly decision: 'approved' | 'rejected';
|
|
188
255
|
readonly note?: string;
|
|
189
256
|
readonly decidedAt?: Date;
|
|
257
|
+
readonly actorOrganizationId?: string;
|
|
190
258
|
}
|
|
191
259
|
/**
|
|
192
260
|
* Apply a decision to a step. Returns a new chain — original is not mutated.
|
|
@@ -266,4 +334,4 @@ declare function isPending(chain: ApprovalChain): boolean;
|
|
|
266
334
|
*/
|
|
267
335
|
declare function decisionCount(chain: ApprovalChain): number;
|
|
268
336
|
//#endregion
|
|
269
|
-
export { ApprovalChain, ApprovalDecision, ApprovalError, ApprovalErrorCode, ApprovalRequest, ApprovalStatus, ApprovalStep, ApprovalThreshold, Approver, AssertApprovedOptions, ChainOrder, CreateChainInput, CreateStepInput, DecisionInput, applyDecision, assertApproved, assertApprovedIfPresent, attachRequest, createChain, decisionCount, isApproved, isNotRequired, isPending, isRejected, nextPendingStep, notRequiredChain, pendingSteps, skipStep };
|
|
337
|
+
export { APPROVAL_ACTIONS, APPROVAL_CHAIN_SOURCES, APPROVAL_DECISIONS, APPROVAL_NOTE_MAX_LENGTH, APPROVAL_THRESHOLD_OPS, ApprovalActionName, ApprovalChain, ApprovalChainSource, ApprovalDecideBody, ApprovalDecision, ApprovalDecisionKind, ApprovalError, ApprovalErrorCode, ApprovalRequest, ApprovalStatus, ApprovalStep, ApprovalSubmitBody, ApprovalThreshold, Approver, AssertApprovedOptions, CHAIN_ORDERS, ChainOrder, CreateChainInput, CreateStepInput, DecidableChain, DecisionInput, applyDecision, assertApproved, assertApprovedIfPresent, attachRequest, createChain, decidableStepIds, decisionCount, isApproved, isNotRequired, isPending, isRejected, nextPendingStep, notRequiredChain, pendingSteps, skipStep };
|
package/dist/approval.mjs
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
//#region src/workflow/approval.ts
|
|
2
|
+
const CHAIN_ORDERS = ["sequential", "parallel"];
|
|
3
|
+
const APPROVAL_THRESHOLD_OPS = [
|
|
4
|
+
"gt",
|
|
5
|
+
"gte",
|
|
6
|
+
"lt",
|
|
7
|
+
"lte",
|
|
8
|
+
"eq"
|
|
9
|
+
];
|
|
2
10
|
var ApprovalError = class extends Error {
|
|
3
11
|
name = "ApprovalError";
|
|
4
12
|
code;
|
|
@@ -125,6 +133,31 @@ function pendingSteps(chain) {
|
|
|
125
133
|
return chain.steps.filter((s) => s.status === "pending");
|
|
126
134
|
}
|
|
127
135
|
/**
|
|
136
|
+
* The verbs every approval-bearing resource mounts at `POST /:id/action`.
|
|
137
|
+
* Servers (arc-approval) and clients (SDK, UI) both read these; never write the strings.
|
|
138
|
+
*/
|
|
139
|
+
const APPROVAL_ACTIONS = {
|
|
140
|
+
submit: "submit_for_approval",
|
|
141
|
+
decide: "decide"
|
|
142
|
+
};
|
|
143
|
+
/** Where a submit sources its chain: inline `chain`, the policy matrix, or inferred. */
|
|
144
|
+
const APPROVAL_CHAIN_SOURCES = [
|
|
145
|
+
"auto",
|
|
146
|
+
"literal",
|
|
147
|
+
"policy"
|
|
148
|
+
];
|
|
149
|
+
const APPROVAL_DECISIONS = ["approved", "rejected"];
|
|
150
|
+
/** Longest decision note the server accepts. */
|
|
151
|
+
const APPROVAL_NOTE_MAX_LENGTH = 1e3;
|
|
152
|
+
/**
|
|
153
|
+
* The steps `actorId` may decide now: active, naming the actor as an approver, not yet
|
|
154
|
+
* decided by them. The ONE answer to "may this actor decide" for server and UI alike.
|
|
155
|
+
*/
|
|
156
|
+
function decidableStepIds(chain, actorId) {
|
|
157
|
+
if (!actorId || chain.status !== "pending") return [];
|
|
158
|
+
return pendingSteps(chain).filter((s) => s.approvers.some((a) => a.id === actorId) && !s.decisions.some((d) => d.approverId === actorId)).map((s) => s.id);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
128
161
|
* Apply a decision to a step. Returns a new chain — original is not mutated.
|
|
129
162
|
* Throws if the step is unknown, not active, the approver isn't listed, or
|
|
130
163
|
* the approver has already decided.
|
|
@@ -142,7 +175,8 @@ function applyDecision(chain, input) {
|
|
|
142
175
|
approverId: input.approverId,
|
|
143
176
|
decision: input.decision,
|
|
144
177
|
...input.note !== void 0 ? { note: input.note } : {},
|
|
145
|
-
decidedAt: input.decidedAt ?? /* @__PURE__ */ new Date()
|
|
178
|
+
decidedAt: input.decidedAt ?? /* @__PURE__ */ new Date(),
|
|
179
|
+
...input.actorOrganizationId !== void 0 ? { actorOrganizationId: input.actorOrganizationId } : {}
|
|
146
180
|
};
|
|
147
181
|
const updatedStep = computeStepStatus({
|
|
148
182
|
...step,
|
|
@@ -271,4 +305,4 @@ function computeChainStatus(steps) {
|
|
|
271
305
|
return steps.every((s) => s.status === "approved" || s.status === "skipped") ? "approved" : "pending";
|
|
272
306
|
}
|
|
273
307
|
//#endregion
|
|
274
|
-
export { ApprovalError, applyDecision, assertApproved, assertApprovedIfPresent, attachRequest, createChain, decisionCount, isApproved, isNotRequired, isPending, isRejected, nextPendingStep, notRequiredChain, pendingSteps, skipStep };
|
|
308
|
+
export { APPROVAL_ACTIONS, APPROVAL_CHAIN_SOURCES, APPROVAL_DECISIONS, APPROVAL_NOTE_MAX_LENGTH, APPROVAL_THRESHOLD_OPS, ApprovalError, CHAIN_ORDERS, applyDecision, assertApproved, assertApprovedIfPresent, attachRequest, createChain, decidableStepIds, decisionCount, isApproved, isNotRequired, isPending, isRejected, nextPendingStep, notRequiredChain, pendingSteps, skipStep };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { DomainEvent, EventMeta, EventTransport } from "./events.mjs";
|
|
2
|
+
import { ErrorLogger } from "./event-infra.mjs";
|
|
3
|
+
import { OutboxStore } from "./outbox.mjs";
|
|
4
|
+
//#region src/events/durable.d.ts
|
|
5
|
+
/** Required on every definition; no default, so neither side escapes by omission. */
|
|
6
|
+
type EventDurability = 'ephemeral' | 'durable';
|
|
7
|
+
/**
|
|
8
|
+
* A fact a durable event must eventually produce — attached at composition by its CONSUMER.
|
|
9
|
+
* Members are methods so a typed definition stays assignable to an `unknown`-payload registry.
|
|
10
|
+
*/
|
|
11
|
+
interface FactExpectation<P> {
|
|
12
|
+
/** `'journal-entry'`, `'stock-move'`, … — matched to a mounted probe. */
|
|
13
|
+
readonly kind: string;
|
|
14
|
+
/** The consumer's own key function: the keys its fact rows carry for this event. */
|
|
15
|
+
keys(payload: P, meta: EventMeta): readonly string[];
|
|
16
|
+
readonly graceMinutes: number;
|
|
17
|
+
/** `'unknown'` is a finding, never "does not apply". */
|
|
18
|
+
applies?(payload: P): boolean | 'unknown';
|
|
19
|
+
}
|
|
20
|
+
type DurableEventSpec<P> = {
|
|
21
|
+
readonly durability: 'ephemeral';
|
|
22
|
+
} | {
|
|
23
|
+
readonly durability: 'durable';
|
|
24
|
+
/** Aggregate id plus step (`fulfillmentId`, `${paymentId}:captured`) — never the order alone. */
|
|
25
|
+
occurrence(payload: P): string;
|
|
26
|
+
readonly produces?: readonly FactExpectation<P>[];
|
|
27
|
+
};
|
|
28
|
+
/** Where a publish happens. `deliveryOf` is the parent event id, set around a relayed delivery. */
|
|
29
|
+
interface DurableContext {
|
|
30
|
+
readonly session?: unknown;
|
|
31
|
+
readonly outbox?: OutboxStore | undefined;
|
|
32
|
+
readonly deliveryOf?: string | undefined;
|
|
33
|
+
}
|
|
34
|
+
declare class NonDurablePublishError extends Error {
|
|
35
|
+
readonly name = "NonDurablePublishError";
|
|
36
|
+
readonly code = "events.non_durable_publish";
|
|
37
|
+
readonly eventType: string;
|
|
38
|
+
constructor(eventType: string, reason: string);
|
|
39
|
+
}
|
|
40
|
+
/** An open session with neither an outbox nor a post-commit queue: publishing leaks, dropping loses. */
|
|
41
|
+
declare class UnmanagedSessionError extends Error {
|
|
42
|
+
readonly name = "UnmanagedSessionError";
|
|
43
|
+
readonly code = "events.unmanaged_session";
|
|
44
|
+
constructor(eventType: string);
|
|
45
|
+
}
|
|
46
|
+
/** `${name}:v${version}:${occurrence}` — the same occurrence always yields the same id. */
|
|
47
|
+
declare function eventIdFor(name: string, version: number, occurrence: string): string;
|
|
48
|
+
/** The meta a durable event is created with: a fixed id, and the same value as its idempotency key. */
|
|
49
|
+
declare function durableEventMeta(name: string, version: number, occurrence: string): Required<Pick<EventMeta, 'id' | 'idempotencyKey'>>;
|
|
50
|
+
/** Throws unless a durable type is in a unit of work (session + transactional store) or a durable delivery. */
|
|
51
|
+
declare function requireDurableContext(def: {
|
|
52
|
+
readonly name: string;
|
|
53
|
+
readonly durability: EventDurability;
|
|
54
|
+
}, ctx: DurableContext): void;
|
|
55
|
+
interface DispatchChannels {
|
|
56
|
+
readonly outbox: OutboxStore | undefined;
|
|
57
|
+
readonly transport: EventTransport | undefined;
|
|
58
|
+
/** With an outbox: `'relay'` = the row is the delivery; `'inline'` (default) = publish live, then acknowledge. */
|
|
59
|
+
readonly dispatch?: 'inline' | 'relay' | undefined;
|
|
60
|
+
/** Prefix for log lines, e.g. the kernel name. */
|
|
61
|
+
readonly label: string;
|
|
62
|
+
readonly logger?: ErrorLogger | undefined;
|
|
63
|
+
}
|
|
64
|
+
interface DispatchUnit extends Omit<DurableContext, 'outbox'> {
|
|
65
|
+
/** The post-commit queue of an open unit of work; its owner calls {@link flushDeferredEvents}. */
|
|
66
|
+
readonly pending?: DomainEvent[] | undefined;
|
|
67
|
+
}
|
|
68
|
+
/** `published` and `relay` are delivered or owned; `publish_failed` leaves a pending row (or nothing, with no outbox). */
|
|
69
|
+
type DispatchOutcome = 'deferred' | 'committed' | 'relay' | 'published' | 'publish_failed' | 'no_transport';
|
|
70
|
+
/**
|
|
71
|
+
* Emit one domain event. The outbox save runs first, in the session, and its failure propagates.
|
|
72
|
+
* In a unit of work delivery waits for {@link flushDeferredEvents}; otherwise an inline publish is
|
|
73
|
+
* acknowledged so the relay does not deliver it again, and a failed one stays pending for the relay.
|
|
74
|
+
*/
|
|
75
|
+
declare function dispatchDomainEvent(channels: DispatchChannels, unit: DispatchUnit, event: DomainEvent, durability: EventDurability): Promise<DispatchOutcome>;
|
|
76
|
+
/**
|
|
77
|
+
* Deliver a committed unit of work's queue. Under `'relay'` the rows are the delivery and nothing
|
|
78
|
+
* is published. Returns each published event's outcome (`null` = delivered and acknowledged).
|
|
79
|
+
* Delivered rows are acknowledged with one `acknowledgeMany` when the store has it.
|
|
80
|
+
*/
|
|
81
|
+
declare function flushDeferredEvents(channels: DispatchChannels, queue: DomainEvent[]): Promise<ReadonlyMap<string, Error | null>>;
|
|
82
|
+
//#endregion
|
|
83
|
+
export { DispatchChannels, DispatchOutcome, DispatchUnit, DurableContext, DurableEventSpec, EventDurability, FactExpectation, NonDurablePublishError, UnmanagedSessionError, dispatchDomainEvent, durableEventMeta, eventIdFor, flushDeferredEvents, requireDurableContext };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
//#region src/events/durable.ts
|
|
2
|
+
var NonDurablePublishError = class extends Error {
|
|
3
|
+
name = "NonDurablePublishError";
|
|
4
|
+
code = "events.non_durable_publish";
|
|
5
|
+
eventType;
|
|
6
|
+
constructor(eventType, reason) {
|
|
7
|
+
super(`Durable event "${eventType}" cannot be published here: ${reason}.`);
|
|
8
|
+
this.eventType = eventType;
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
/** An open session with neither an outbox nor a post-commit queue: publishing leaks, dropping loses. */
|
|
12
|
+
var UnmanagedSessionError = class extends Error {
|
|
13
|
+
name = "UnmanagedSessionError";
|
|
14
|
+
code = "events.unmanaged_session";
|
|
15
|
+
constructor(eventType) {
|
|
16
|
+
super(`Event "${eventType}" was emitted inside a caller-owned session with no outbox and no post-commit queue. Wire an outbox or run the write in the engine unit of work.`);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
/** `${name}:v${version}:${occurrence}` — the same occurrence always yields the same id. */
|
|
20
|
+
function eventIdFor(name, version, occurrence) {
|
|
21
|
+
if (typeof name !== "string" || name.length === 0) throw new TypeError("eventIdFor: event name must be a non-empty string");
|
|
22
|
+
if (!Number.isInteger(version) || version < 1) throw new TypeError(`eventIdFor(${name}): version must be a positive integer, got ${String(version)}`);
|
|
23
|
+
if (typeof occurrence !== "string" || occurrence.length === 0) throw new TypeError(`eventIdFor(${name}): occurrence must be a non-empty string, got ${typeof occurrence === "string" ? "\"\"" : typeof occurrence}`);
|
|
24
|
+
return `${name}:v${version}:${occurrence}`;
|
|
25
|
+
}
|
|
26
|
+
/** The meta a durable event is created with: a fixed id, and the same value as its idempotency key. */
|
|
27
|
+
function durableEventMeta(name, version, occurrence) {
|
|
28
|
+
const id = eventIdFor(name, version, occurrence);
|
|
29
|
+
return {
|
|
30
|
+
id,
|
|
31
|
+
idempotencyKey: id
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** Throws unless a durable type is in a unit of work (session + transactional store) or a durable delivery. */
|
|
35
|
+
function requireDurableContext(def, ctx) {
|
|
36
|
+
if (def.durability === "ephemeral") return;
|
|
37
|
+
if (!ctx.outbox) throw new NonDurablePublishError(def.name, "no outbox is wired");
|
|
38
|
+
if (ctx.deliveryOf !== void 0) return;
|
|
39
|
+
if (ctx.session === void 0) throw new NonDurablePublishError(def.name, "no unit of work is open (no session) and this is not a durable delivery");
|
|
40
|
+
if (ctx.outbox.transactionalSave !== true) throw new NonDurablePublishError(def.name, "the outbox store does not declare transactionalSave");
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Emit one domain event. The outbox save runs first, in the session, and its failure propagates.
|
|
44
|
+
* In a unit of work delivery waits for {@link flushDeferredEvents}; otherwise an inline publish is
|
|
45
|
+
* acknowledged so the relay does not deliver it again, and a failed one stays pending for the relay.
|
|
46
|
+
*/
|
|
47
|
+
async function dispatchDomainEvent(channels, unit, event, durability) {
|
|
48
|
+
requireDurableContext({
|
|
49
|
+
name: event.type,
|
|
50
|
+
durability
|
|
51
|
+
}, {
|
|
52
|
+
session: unit.session,
|
|
53
|
+
outbox: channels.outbox,
|
|
54
|
+
deliveryOf: unit.deliveryOf
|
|
55
|
+
});
|
|
56
|
+
const log = channels.logger ?? console;
|
|
57
|
+
if (channels.outbox) await channels.outbox.save(event, unit.session === void 0 ? {} : { session: unit.session });
|
|
58
|
+
else if (unit.session !== void 0 && !unit.pending) throw new UnmanagedSessionError(event.type);
|
|
59
|
+
if (unit.pending) {
|
|
60
|
+
unit.pending.push(event);
|
|
61
|
+
return "deferred";
|
|
62
|
+
}
|
|
63
|
+
if (unit.session !== void 0) return "committed";
|
|
64
|
+
if (channels.outbox && channels.dispatch === "relay") return "relay";
|
|
65
|
+
if (!channels.transport) return "no_transport";
|
|
66
|
+
try {
|
|
67
|
+
await channels.transport.publish(event);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
log.error(`[${channels.label}] publish failed for ${event.type} ${event.meta.id}:`, err);
|
|
70
|
+
return "publish_failed";
|
|
71
|
+
}
|
|
72
|
+
await acknowledgeDelivered(channels, event, log);
|
|
73
|
+
return "published";
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Deliver a committed unit of work's queue. Under `'relay'` the rows are the delivery and nothing
|
|
77
|
+
* is published. Returns each published event's outcome (`null` = delivered and acknowledged).
|
|
78
|
+
* Delivered rows are acknowledged with one `acknowledgeMany` when the store has it.
|
|
79
|
+
*/
|
|
80
|
+
async function flushDeferredEvents(channels, queue) {
|
|
81
|
+
const outcomes = /* @__PURE__ */ new Map();
|
|
82
|
+
const events = queue.splice(0);
|
|
83
|
+
if (channels.outbox && channels.dispatch === "relay" || !channels.transport) return outcomes;
|
|
84
|
+
const log = channels.logger ?? console;
|
|
85
|
+
for (const event of events) {
|
|
86
|
+
try {
|
|
87
|
+
await channels.transport.publish(event);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
outcomes.set(event.meta.id, err instanceof Error ? err : new Error(String(err)));
|
|
90
|
+
log.error(`[${channels.label}] publish failed for ${event.type} ${event.meta.id}:`, err);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
outcomes.set(event.meta.id, null);
|
|
94
|
+
}
|
|
95
|
+
const delivered = events.filter((e) => outcomes.get(e.meta.id) === null);
|
|
96
|
+
const outbox = channels.outbox;
|
|
97
|
+
if (outbox?.acknowledgeMany && delivered.length > 0) try {
|
|
98
|
+
await outbox.acknowledgeMany(delivered.map((e) => e.meta.id));
|
|
99
|
+
} catch (err) {
|
|
100
|
+
log.error(`[${channels.label}] outbox acknowledgeMany failed for ${delivered.length} events:`, err);
|
|
101
|
+
}
|
|
102
|
+
else for (const event of delivered) await acknowledgeDelivered(channels, event, log);
|
|
103
|
+
return outcomes;
|
|
104
|
+
}
|
|
105
|
+
/** A failed acknowledge only risks a redelivery, which consumers absorb by id; it never fails the caller. */
|
|
106
|
+
async function acknowledgeDelivered(channels, event, log) {
|
|
107
|
+
if (!channels.outbox) return;
|
|
108
|
+
try {
|
|
109
|
+
await channels.outbox.acknowledge(event.meta.id);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
log.error(`[${channels.label}] outbox acknowledge failed for ${event.type} ${event.meta.id}:`, err);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
//#endregion
|
|
115
|
+
export { NonDurablePublishError, UnmanagedSessionError, dispatchDomainEvent, durableEventMeta, eventIdFor, flushDeferredEvents, requireDurableContext };
|
package/dist/events.d.mts
CHANGED
|
@@ -86,7 +86,7 @@ interface EventMeta {
|
|
|
86
86
|
* `meta.id` (which is fresh per emit — a retry would produce a new id).
|
|
87
87
|
*
|
|
88
88
|
* Typical sources: HTTP `Idempotency-Key` header, outbox `dedupeKey`, or
|
|
89
|
-
* `{aggregate.type}:{aggregate.id}:{action}`.
|
|
89
|
+
* `{aggregate.type}:{aggregate.id}:{action}`. NOT inherited by child events.
|
|
90
90
|
*/
|
|
91
91
|
idempotencyKey?: string;
|
|
92
92
|
/**
|
|
@@ -215,11 +215,11 @@ declare function createEvent<T>(type: string, payload: T, meta?: Partial<EventMe
|
|
|
215
215
|
* - `causationId` is set to the parent's `id` (direct cause)
|
|
216
216
|
* - `correlationId` is inherited from the parent if set, else falls back
|
|
217
217
|
* to the parent's `id` (root correlation)
|
|
218
|
-
* - `userId` / `organizationId` / `source`
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
* - `aggregate`
|
|
222
|
-
*
|
|
218
|
+
* - `userId` / `organizationId` / `source` are inherited when set on the
|
|
219
|
+
* parent so the chain stays scoped to the originating principal, tenant
|
|
220
|
+
* and emitter
|
|
221
|
+
* - `idempotencyKey` and `aggregate` are NOT inherited — a child is its own
|
|
222
|
+
* occurrence (see `@classytic/primitives/durable-events` `eventIdFor`)
|
|
223
223
|
*
|
|
224
224
|
* Caller-supplied `meta` wins over inherited fields — pass `{ userId: newActor }`
|
|
225
225
|
* to override when a subsystem acts on behalf of a different principal.
|
package/dist/events.mjs
CHANGED
|
@@ -22,11 +22,11 @@ function createEvent(type, payload, meta) {
|
|
|
22
22
|
* - `causationId` is set to the parent's `id` (direct cause)
|
|
23
23
|
* - `correlationId` is inherited from the parent if set, else falls back
|
|
24
24
|
* to the parent's `id` (root correlation)
|
|
25
|
-
* - `userId` / `organizationId` / `source`
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* - `aggregate`
|
|
29
|
-
*
|
|
25
|
+
* - `userId` / `organizationId` / `source` are inherited when set on the
|
|
26
|
+
* parent so the chain stays scoped to the originating principal, tenant
|
|
27
|
+
* and emitter
|
|
28
|
+
* - `idempotencyKey` and `aggregate` are NOT inherited — a child is its own
|
|
29
|
+
* occurrence (see `@classytic/primitives/durable-events` `eventIdFor`)
|
|
30
30
|
*
|
|
31
31
|
* Caller-supplied `meta` wins over inherited fields — pass `{ userId: newActor }`
|
|
32
32
|
* to override when a subsystem acts on behalf of a different principal.
|
|
@@ -51,7 +51,6 @@ function createChildEvent(parent, type, payload, meta) {
|
|
|
51
51
|
if (parent.meta.userId !== void 0) inherited.userId = parent.meta.userId;
|
|
52
52
|
if (parent.meta.organizationId !== void 0) inherited.organizationId = parent.meta.organizationId;
|
|
53
53
|
if (parent.meta.source !== void 0) inherited.source = parent.meta.source;
|
|
54
|
-
if (parent.meta.idempotencyKey !== void 0) inherited.idempotencyKey = parent.meta.idempotencyKey;
|
|
55
54
|
return {
|
|
56
55
|
type,
|
|
57
56
|
payload,
|
package/dist/item-facts.d.mts
CHANGED
|
@@ -9,6 +9,8 @@ interface ItemStockSource {
|
|
|
9
9
|
displayName: string;
|
|
10
10
|
/** The canonical STOCK unit — what quants and moves are balanced in. */
|
|
11
11
|
uom: string;
|
|
12
|
+
/** The stock unit's UoM group (`_id` or `code`); its base unit is `uom` and its rounding is the posting step. */
|
|
13
|
+
uomGroupRef?: string | undefined;
|
|
12
14
|
trackingMode: TrackingMode;
|
|
13
15
|
trackingPolicy?: ShelfLifePolicy | undefined;
|
|
14
16
|
/**
|
|
@@ -29,6 +31,10 @@ interface ProductFacet {
|
|
|
29
31
|
sku: string;
|
|
30
32
|
displayName: string;
|
|
31
33
|
uom?: string | undefined;
|
|
34
|
+
/** Fallback group for a product-only deployment; never read when an Item exists. */
|
|
35
|
+
uomGroupRef?: string | undefined;
|
|
36
|
+
/** The product declares a unit of measure, even if its group was lost on the way here. */
|
|
37
|
+
unitOfMeasureDeclared?: boolean | undefined;
|
|
32
38
|
trackingMode?: TrackingMode | undefined;
|
|
33
39
|
trackingPolicy?: ShelfLifePolicy | undefined;
|
|
34
40
|
catchWeight?: boolean | undefined;
|
|
@@ -46,6 +52,14 @@ interface ItemStockFacts {
|
|
|
46
52
|
trackingMode: TrackingMode;
|
|
47
53
|
trackingPolicy?: ShelfLifePolicy;
|
|
48
54
|
uom: string;
|
|
55
|
+
/** UoM group whose rounding a posting snaps to. Absent ⇒ the bare quantity grid. */
|
|
56
|
+
uomGroupRef?: string;
|
|
57
|
+
/**
|
|
58
|
+
* A source declared a unit of measure, so a missing `uomGroupRef` must be
|
|
59
|
+
* REFUSED by the stock engine, never read as "no rounding". Not set by `uom`
|
|
60
|
+
* alone: every Item has a `uom`.
|
|
61
|
+
*/
|
|
62
|
+
unitOfMeasureDeclared?: boolean;
|
|
49
63
|
catchWeight?: boolean;
|
|
50
64
|
weightUom?: string;
|
|
51
65
|
weight?: number;
|
|
@@ -64,6 +78,11 @@ interface ItemStockFacts {
|
|
|
64
78
|
* `isActive` follows the ITEM when one exists: a product withdrawn from sale is
|
|
65
79
|
* still stock on a shelf, whereas a retired item is not stockable at all. Taking
|
|
66
80
|
* the product's flag would make unlisting a product silently orphan its stock.
|
|
81
|
+
*
|
|
82
|
+
* `uomGroupRef` follows the ITEM when one exists. A product group under an Item
|
|
83
|
+
* that declares none is never borrowed (its step is in a unit the Item may not
|
|
84
|
+
* count in): the facts carry `unitOfMeasureDeclared: true` and no group, so the
|
|
85
|
+
* stock engine refuses the posting until the Item declares its own.
|
|
67
86
|
*/
|
|
68
87
|
declare function resolveStockFacts(input: {
|
|
69
88
|
item?: ItemStockSource | null | undefined;
|
package/dist/item-facts.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
/** Unit assumed when neither side declares one. A deployment-wide guess, and a bad one — set a real `uom` on the Item. */
|
|
3
3
|
const FALLBACK_UOM = "unit";
|
|
4
4
|
const defined = (key, value) => value === void 0 ? {} : { [key]: value };
|
|
5
|
+
const declaresGroup = (p) => !!p && (p.uomGroupRef !== void 0 || p.unitOfMeasureDeclared === true);
|
|
5
6
|
/**
|
|
6
7
|
* Merge an Item with its Product facet.
|
|
7
8
|
*
|
|
@@ -13,6 +14,11 @@ const defined = (key, value) => value === void 0 ? {} : { [key]: value };
|
|
|
13
14
|
* `isActive` follows the ITEM when one exists: a product withdrawn from sale is
|
|
14
15
|
* still stock on a shelf, whereas a retired item is not stockable at all. Taking
|
|
15
16
|
* the product's flag would make unlisting a product silently orphan its stock.
|
|
17
|
+
*
|
|
18
|
+
* `uomGroupRef` follows the ITEM when one exists. A product group under an Item
|
|
19
|
+
* that declares none is never borrowed (its step is in a unit the Item may not
|
|
20
|
+
* count in): the facts carry `unitOfMeasureDeclared: true` and no group, so the
|
|
21
|
+
* stock engine refuses the posting until the Item declares its own.
|
|
16
22
|
*/
|
|
17
23
|
function resolveStockFacts(input) {
|
|
18
24
|
const { item, product } = input;
|
|
@@ -24,6 +30,8 @@ function resolveStockFacts(input) {
|
|
|
24
30
|
sku: p.sku,
|
|
25
31
|
displayName: p.displayName,
|
|
26
32
|
uom: p.uom ?? FALLBACK_UOM,
|
|
33
|
+
...defined("uomGroupRef", p.uomGroupRef),
|
|
34
|
+
...p.unitOfMeasureDeclared === true ? { unitOfMeasureDeclared: true } : {},
|
|
27
35
|
trackingMode: p.trackingMode ?? "none",
|
|
28
36
|
...defined("trackingPolicy", p.trackingPolicy),
|
|
29
37
|
...defined("catchWeight", p.catchWeight),
|
|
@@ -39,6 +47,8 @@ function resolveStockFacts(input) {
|
|
|
39
47
|
sku: product?.sku ?? item.sku,
|
|
40
48
|
displayName: product?.displayName ?? item.displayName,
|
|
41
49
|
uom: item.uom,
|
|
50
|
+
...defined("uomGroupRef", item.uomGroupRef),
|
|
51
|
+
...item.uomGroupRef === void 0 && declaresGroup(product) ? { unitOfMeasureDeclared: true } : {},
|
|
42
52
|
trackingMode: item.trackingMode,
|
|
43
53
|
...defined("trackingPolicy", item.trackingPolicy),
|
|
44
54
|
...defined("catchWeight", item.catchWeight),
|
package/dist/memory-outbox.d.mts
CHANGED
|
@@ -48,6 +48,7 @@ declare class MemoryOutboxStore implements OutboxStore {
|
|
|
48
48
|
claimPending(options?: OutboxClaimOptions): Promise<DomainEvent[]>;
|
|
49
49
|
claimPendingFenced(options?: OutboxClaimOptions): Promise<OutboxClaimedEvent[]>;
|
|
50
50
|
acknowledge(eventId: string, options?: OutboxAcknowledgeOptions): Promise<void>;
|
|
51
|
+
acknowledgeMany(eventIds: readonly string[]): Promise<void>;
|
|
51
52
|
fail(eventId: string, error: OutboxErrorInfo, options?: OutboxFailOptions): Promise<void>;
|
|
52
53
|
getDeadLettered(limit: number): Promise<DeadLetteredEvent[]>;
|
|
53
54
|
purge(olderThanMs: number): Promise<number>;
|
package/dist/memory-outbox.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { InvalidOutboxEventError, OutboxOwnershipError } from "./outbox.mjs";
|
|
1
|
+
import { InvalidOutboxEventError, OutboxConflictError, OutboxOwnershipError, outboxPayloadFingerprint } from "./outbox.mjs";
|
|
2
2
|
//#region src/events/memory-outbox.ts
|
|
3
3
|
const DEFAULT_LEASE_MS = 3e4;
|
|
4
4
|
var MemoryOutboxStore = class {
|
|
@@ -29,6 +29,11 @@ var MemoryOutboxStore = class {
|
|
|
29
29
|
async save(event, options) {
|
|
30
30
|
if (!event?.type || typeof event.type !== "string") throw new InvalidOutboxEventError("event.type is required");
|
|
31
31
|
if (!event.meta?.id || typeof event.meta.id !== "string") throw new InvalidOutboxEventError("event.meta.id is required");
|
|
32
|
+
const existing = this.entries.find((e) => e.event.meta.id === event.meta.id);
|
|
33
|
+
if (existing) {
|
|
34
|
+
if (outboxPayloadFingerprint(existing.event) === outboxPayloadFingerprint(event)) return;
|
|
35
|
+
throw new OutboxConflictError(event.meta.id, event.type);
|
|
36
|
+
}
|
|
32
37
|
if (options?.dedupeKey) {
|
|
33
38
|
if (this.seenDedupeKeys.has(options.dedupeKey)) return;
|
|
34
39
|
this.seenDedupeKeys.add(options.dedupeKey);
|
|
@@ -90,6 +95,16 @@ var MemoryOutboxStore = class {
|
|
|
90
95
|
entry.deliveredAt = Date.now();
|
|
91
96
|
entry.leaseOwner = null;
|
|
92
97
|
}
|
|
98
|
+
async acknowledgeMany(eventIds) {
|
|
99
|
+
const ids = new Set(eventIds);
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
for (const entry of this.entries) {
|
|
102
|
+
if (!ids.has(entry.event.meta.id) || entry.status === "delivered") continue;
|
|
103
|
+
entry.status = "delivered";
|
|
104
|
+
entry.deliveredAt = now;
|
|
105
|
+
entry.leaseOwner = null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
93
108
|
async fail(eventId, error, options) {
|
|
94
109
|
const entry = this.entries.find((e) => e.event.meta.id === eventId);
|
|
95
110
|
if (!entry) return;
|
package/dist/monetization.d.mts
CHANGED
|
@@ -297,6 +297,8 @@ declare function unitPriceOf(m: Monetization): Money | null;
|
|
|
297
297
|
* `defaultPassOf` picked.
|
|
298
298
|
*/
|
|
299
299
|
declare function compareAtPriceOf(m: Monetization): Money | null;
|
|
300
|
+
/** On sale: a compare-at price above the selling price, same currency. The ONE on-sale predicate. */
|
|
301
|
+
declare function isOnSale(m: Monetization): boolean;
|
|
300
302
|
/**
|
|
301
303
|
* `@classytic/revenue`'s coarse wire enum. `purchase` is the paid-once case that
|
|
302
304
|
* the rest of the ecosystem calls `one_time`. Kept as its own type so revenue's
|
|
@@ -316,4 +318,4 @@ declare function toRevenueMonetizationType(kind: MonetizationKind): RevenueMonet
|
|
|
316
318
|
/** Revenue wire type → canonical kind (`purchase` → `one_time`). */
|
|
317
319
|
declare function fromRevenueMonetizationType(type: RevenueMonetizationType): MonetizationKind;
|
|
318
320
|
//#endregion
|
|
319
|
-
export { AccessPass, BundleMonetization, DurationUnit, FreeMonetization, MONETIZATION_KINDS, Monetization, MonetizationKind, MoneyByCurrency, OneTimeMonetization, OneTimePricing, PassMonetization, PriceTier, RevenueMonetizationType, SubscriptionMonetization, SubscriptionPlan, UPFRONT_COLLECTION_MODES, UpfrontCollectionMode, UsageMonetization, UsageRating, UsageTier, approxTermDays, compareAtPriceOf, defaultPassOf, fromRevenueMonetizationType, isBundleMonetization, isFreeMonetization, isMonetizationKind, isOneTimeMonetization, isPassMonetization, isSubscriptionMonetization, isUpfrontCollectionMode, isUsageMonetization, monetizationKindOf, toRevenueMonetizationType, unitPriceOf };
|
|
321
|
+
export { AccessPass, BundleMonetization, DurationUnit, FreeMonetization, MONETIZATION_KINDS, Monetization, MonetizationKind, MoneyByCurrency, OneTimeMonetization, OneTimePricing, PassMonetization, PriceTier, RevenueMonetizationType, SubscriptionMonetization, SubscriptionPlan, UPFRONT_COLLECTION_MODES, UpfrontCollectionMode, UsageMonetization, UsageRating, UsageTier, approxTermDays, compareAtPriceOf, defaultPassOf, fromRevenueMonetizationType, isBundleMonetization, isFreeMonetization, isMonetizationKind, isOnSale, isOneTimeMonetization, isPassMonetization, isSubscriptionMonetization, isUpfrontCollectionMode, isUsageMonetization, monetizationKindOf, toRevenueMonetizationType, unitPriceOf };
|
package/dist/monetization.mjs
CHANGED
|
@@ -164,6 +164,12 @@ function compareAtPriceOf(m) {
|
|
|
164
164
|
if (m.type === "pass") return defaultPassOf(m)?.compareAtPrice ?? null;
|
|
165
165
|
return null;
|
|
166
166
|
}
|
|
167
|
+
/** On sale: a compare-at price above the selling price, same currency. The ONE on-sale predicate. */
|
|
168
|
+
function isOnSale(m) {
|
|
169
|
+
const compareAt = compareAtPriceOf(m);
|
|
170
|
+
const price = unitPriceOf(m);
|
|
171
|
+
return compareAt !== null && price !== null && compareAt.currency === price.currency && compareAt.amount > price.amount;
|
|
172
|
+
}
|
|
167
173
|
/**
|
|
168
174
|
* Canonical kind → revenue wire type. Everything that is not `free` or
|
|
169
175
|
* `subscription` settles as a `purchase` in revenue's ledger vocabulary
|
|
@@ -184,4 +190,4 @@ function fromRevenueMonetizationType(type) {
|
|
|
184
190
|
return "one_time";
|
|
185
191
|
}
|
|
186
192
|
//#endregion
|
|
187
|
-
export { MONETIZATION_KINDS, UPFRONT_COLLECTION_MODES, approxTermDays, compareAtPriceOf, defaultPassOf, fromRevenueMonetizationType, isBundleMonetization, isFreeMonetization, isMonetizationKind, isOneTimeMonetization, isPassMonetization, isSubscriptionMonetization, isUpfrontCollectionMode, isUsageMonetization, monetizationKindOf, toRevenueMonetizationType, unitPriceOf };
|
|
193
|
+
export { MONETIZATION_KINDS, UPFRONT_COLLECTION_MODES, approxTermDays, compareAtPriceOf, defaultPassOf, fromRevenueMonetizationType, isBundleMonetization, isFreeMonetization, isMonetizationKind, isOnSale, isOneTimeMonetization, isPassMonetization, isSubscriptionMonetization, isUpfrontCollectionMode, isUsageMonetization, monetizationKindOf, toRevenueMonetizationType, unitPriceOf };
|