@classytic/primitives 0.28.0 → 0.30.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 +1217 -1119
- package/dist/approval.d.mts +98 -6
- package/dist/approval.mjs +61 -4
- package/dist/canonical.d.mts +6 -1
- package/dist/canonical.mjs +10 -4
- package/dist/durable-events.d.mts +83 -0
- package/dist/durable-events.mjs +115 -0
- package/dist/environment.d.mts +30 -1
- package/dist/environment.mjs +72 -1
- package/dist/events.d.mts +45 -7
- package/dist/events.mjs +174 -7
- package/dist/idempotency.d.mts +4 -0
- package/dist/idempotency.mjs +4 -0
- package/dist/item-stock.d.mts +43 -0
- package/dist/item-stock.mjs +67 -0
- package/dist/legal-form.d.mts +12 -0
- package/dist/legal-form.mjs +27 -0
- package/dist/line-settlement.d.mts +70 -0
- package/dist/line-settlement.mjs +79 -0
- package/dist/memory-outbox.d.mts +1 -0
- package/dist/memory-outbox.mjs +18 -2
- package/dist/monetization.d.mts +3 -1
- package/dist/monetization.mjs +7 -1
- package/dist/outbox.d.mts +46 -5
- package/dist/outbox.mjs +43 -1
- package/dist/payment-events.d.mts +6 -0
- package/dist/period.d.mts +22 -1
- package/dist/period.mjs +30 -1
- package/dist/phone.d.mts +13 -1
- package/dist/phone.mjs +21 -1
- package/dist/proration.d.mts +11 -1
- package/dist/proration.mjs +24 -1
- package/dist/signed-token.d.mts +54 -0
- package/dist/signed-token.mjs +139 -0
- package/dist/split-allocation.d.mts +20 -2
- package/dist/split-allocation.mjs +57 -1
- package/package.json +364 -285
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.
|
|
@@ -294,5 +294,43 @@ declare function createScopedEvent<T>(source: string, type: string, payload: T,
|
|
|
294
294
|
* - exact match otherwise
|
|
295
295
|
*/
|
|
296
296
|
declare function matchEventPattern(pattern: string, type: string): boolean;
|
|
297
|
+
/**
|
|
298
|
+
* Subscriptions indexed for dispatch — the same answer as scanning every pattern with
|
|
299
|
+
* `matchEventPattern` (same values, same order: first occurrence by pattern registration,
|
|
300
|
+
* deduped), in amortized O(1) per event instead of O(patterns).
|
|
301
|
+
*
|
|
302
|
+
* A wildcard can only match at a separator, so an event name has at most one candidate prefix
|
|
303
|
+
* per `.`/`:` — a lookup walks its own name, never the subscription table. Resolved lists are
|
|
304
|
+
* memoized per name (names are a small, closed set) and dropped on any add/remove.
|
|
305
|
+
*/
|
|
306
|
+
interface EventRouter<T> {
|
|
307
|
+
add(pattern: string, value: T): void;
|
|
308
|
+
/** `true` when the pair was registered. A pattern left with no values is dropped. */
|
|
309
|
+
remove(pattern: string, value: T): boolean;
|
|
310
|
+
/** Every value whose pattern matches `type` — registration order, each value once. */
|
|
311
|
+
route(type: string): readonly T[];
|
|
312
|
+
clear(): void;
|
|
313
|
+
/** Registered patterns, in registration order. */
|
|
314
|
+
patterns(): IterableIterator<[string, ReadonlySet<T>]>;
|
|
315
|
+
/** Number of registered patterns. */
|
|
316
|
+
readonly size: number;
|
|
317
|
+
}
|
|
318
|
+
declare function createEventRouter<T>(): EventRouter<T>;
|
|
319
|
+
/** One difference between two versions of an event's payload schema. */
|
|
320
|
+
interface EventSchemaChange {
|
|
321
|
+
/** Dotted path to the field (`''` = the payload root; `[]` = array items; `{}` = record values). */
|
|
322
|
+
readonly path: string;
|
|
323
|
+
readonly kind: 'added' | 'removed' | 'now-optional' | 'type-widened' | 'type-narrowed' | 'values-widened' | 'values-narrowed';
|
|
324
|
+
/** Would a consumer written against the OLD schema break on a payload valid under the NEW one? */
|
|
325
|
+
readonly breaking: boolean;
|
|
326
|
+
readonly detail: string;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* What changed between two versions of an event payload's JSON Schema, and whether each change
|
|
330
|
+
* breaks an existing CONSUMER (forward compatibility: the producer evolves, old consumers keep
|
|
331
|
+
* reading). Payloads are open for extension — adding a field never breaks, whatever
|
|
332
|
+
* `additionalProperties` says, because consumers ignore fields they do not know.
|
|
333
|
+
*/
|
|
334
|
+
declare function diffEventSchema(before: unknown, after: unknown, path?: string): EventSchemaChange[];
|
|
297
335
|
//#endregion
|
|
298
|
-
export { DeadLetteredEvent, DomainEvent, EventHandler, EventLogger, EventMeta, EventScopeContext, EventTransport, PublishManyResult, createChildEvent, createEvent, createScopedEvent, matchEventPattern, scopedEventMeta };
|
|
336
|
+
export { DeadLetteredEvent, DomainEvent, EventHandler, EventLogger, EventMeta, EventRouter, EventSchemaChange, EventScopeContext, EventTransport, PublishManyResult, createChildEvent, createEvent, createEventRouter, createScopedEvent, diffEventSchema, matchEventPattern, scopedEventMeta };
|
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,
|
|
@@ -127,5 +126,173 @@ function randomEventId() {
|
|
|
127
126
|
return (c === "x" ? r : r & 3 | 8).toString(16);
|
|
128
127
|
});
|
|
129
128
|
}
|
|
129
|
+
/** Past this many distinct names the memo is reset — dynamic names must not grow it without bound. */
|
|
130
|
+
const ROUTE_MEMO_LIMIT = 4096;
|
|
131
|
+
function createEventRouter() {
|
|
132
|
+
const byPattern = /* @__PURE__ */ new Map();
|
|
133
|
+
const order = /* @__PURE__ */ new Map();
|
|
134
|
+
let seq = 0;
|
|
135
|
+
const memo = /* @__PURE__ */ new Map();
|
|
136
|
+
const resolve = (type) => {
|
|
137
|
+
const hits = [];
|
|
138
|
+
if (byPattern.has("*")) hits.push("*");
|
|
139
|
+
if (byPattern.has(type)) hits.push(type);
|
|
140
|
+
for (let i = 0; i < type.length; i++) {
|
|
141
|
+
const c = type.charCodeAt(i);
|
|
142
|
+
if (c !== 46 && c !== 58) continue;
|
|
143
|
+
const wildcard = `${type.slice(0, i + 1)}*`;
|
|
144
|
+
if (wildcard !== type && byPattern.has(wildcard)) hits.push(wildcard);
|
|
145
|
+
}
|
|
146
|
+
if (hits.length > 1) hits.sort((a, b) => order.get(a) - order.get(b));
|
|
147
|
+
const seen = /* @__PURE__ */ new Set();
|
|
148
|
+
for (const pattern of hits) for (const v of byPattern.get(pattern)) seen.add(v);
|
|
149
|
+
return Object.freeze([...seen]);
|
|
150
|
+
};
|
|
151
|
+
return {
|
|
152
|
+
add(pattern, value) {
|
|
153
|
+
let set = byPattern.get(pattern);
|
|
154
|
+
if (!set) {
|
|
155
|
+
set = /* @__PURE__ */ new Set();
|
|
156
|
+
byPattern.set(pattern, set);
|
|
157
|
+
order.set(pattern, seq++);
|
|
158
|
+
}
|
|
159
|
+
if (!set.has(value)) {
|
|
160
|
+
set.add(value);
|
|
161
|
+
memo.clear();
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
remove(pattern, value) {
|
|
165
|
+
const set = byPattern.get(pattern);
|
|
166
|
+
if (!set?.delete(value)) return false;
|
|
167
|
+
if (set.size === 0) {
|
|
168
|
+
byPattern.delete(pattern);
|
|
169
|
+
order.delete(pattern);
|
|
170
|
+
}
|
|
171
|
+
memo.clear();
|
|
172
|
+
return true;
|
|
173
|
+
},
|
|
174
|
+
route(type) {
|
|
175
|
+
const cached = memo.get(type);
|
|
176
|
+
if (cached) return cached;
|
|
177
|
+
const resolved = resolve(type);
|
|
178
|
+
if (memo.size >= ROUTE_MEMO_LIMIT) memo.clear();
|
|
179
|
+
memo.set(type, resolved);
|
|
180
|
+
return resolved;
|
|
181
|
+
},
|
|
182
|
+
clear() {
|
|
183
|
+
byPattern.clear();
|
|
184
|
+
order.clear();
|
|
185
|
+
memo.clear();
|
|
186
|
+
},
|
|
187
|
+
patterns: () => byPattern.entries(),
|
|
188
|
+
get size() {
|
|
189
|
+
return byPattern.size;
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const jsonType = (v) => v === null ? "null" : Array.isArray(v) ? "array" : Number.isInteger(v) ? "integer" : typeof v;
|
|
194
|
+
/** The JSON types a node admits; `null` means unconstrained. */
|
|
195
|
+
function typesOf(node) {
|
|
196
|
+
const branches = node.anyOf ?? node.oneOf;
|
|
197
|
+
if (branches) {
|
|
198
|
+
const all = /* @__PURE__ */ new Set();
|
|
199
|
+
for (const b of branches) {
|
|
200
|
+
const t = typesOf(b);
|
|
201
|
+
if (t === null) return null;
|
|
202
|
+
for (const x of t) all.add(x);
|
|
203
|
+
}
|
|
204
|
+
return all;
|
|
205
|
+
}
|
|
206
|
+
if (node.const !== void 0) return /* @__PURE__ */ new Set([jsonType(node.const)]);
|
|
207
|
+
if (node.enum) return new Set(node.enum.map(jsonType));
|
|
208
|
+
if (node.type === void 0) return null;
|
|
209
|
+
return new Set([node.type].flat());
|
|
210
|
+
}
|
|
211
|
+
/** The finite values a node admits (`enum` / `const`), or `null` when open. */
|
|
212
|
+
function valuesOf(node) {
|
|
213
|
+
if (node.const !== void 0) return /* @__PURE__ */ new Set([JSON.stringify(node.const)]);
|
|
214
|
+
if (node.enum) return new Set(node.enum.map((v) => JSON.stringify(v)));
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
/** `integer` is a kind of `number`. */
|
|
218
|
+
const admits = (types, t) => types.has(t) || t === "integer" && types.has("number");
|
|
219
|
+
/**
|
|
220
|
+
* What changed between two versions of an event payload's JSON Schema, and whether each change
|
|
221
|
+
* breaks an existing CONSUMER (forward compatibility: the producer evolves, old consumers keep
|
|
222
|
+
* reading). Payloads are open for extension — adding a field never breaks, whatever
|
|
223
|
+
* `additionalProperties` says, because consumers ignore fields they do not know.
|
|
224
|
+
*/
|
|
225
|
+
function diffEventSchema(before, after, path = "") {
|
|
226
|
+
const a = before ?? {};
|
|
227
|
+
const b = after ?? {};
|
|
228
|
+
const changes = [];
|
|
229
|
+
const at = (field) => path ? `${path}.${field}` : field;
|
|
230
|
+
const ta = typesOf(a);
|
|
231
|
+
const tb = typesOf(b);
|
|
232
|
+
if (tb === null ? ta !== null : ta !== null && [...tb].some((t) => !admits(ta, t))) changes.push({
|
|
233
|
+
path,
|
|
234
|
+
kind: "type-widened",
|
|
235
|
+
breaking: true,
|
|
236
|
+
detail: `${ta ? [...ta].join("|") : "any"} → ${tb ? [...tb].join("|") : "any"}`
|
|
237
|
+
});
|
|
238
|
+
else if (ta !== null && tb !== null && [...ta].some((t) => !admits(tb, t))) changes.push({
|
|
239
|
+
path,
|
|
240
|
+
kind: "type-narrowed",
|
|
241
|
+
breaking: false,
|
|
242
|
+
detail: `${[...ta].join("|")} → ${[...tb].join("|")}`
|
|
243
|
+
});
|
|
244
|
+
const va = valuesOf(a);
|
|
245
|
+
const vb = valuesOf(b);
|
|
246
|
+
if (va !== null) {
|
|
247
|
+
const added = vb === null ? ["(any)"] : [...vb].filter((v) => !va.has(v));
|
|
248
|
+
const removed = vb === null ? [] : [...va].filter((v) => !vb.has(v));
|
|
249
|
+
if (added.length > 0) changes.push({
|
|
250
|
+
path,
|
|
251
|
+
kind: "values-widened",
|
|
252
|
+
breaking: true,
|
|
253
|
+
detail: `new values ${added.join(", ")}`
|
|
254
|
+
});
|
|
255
|
+
if (removed.length > 0) changes.push({
|
|
256
|
+
path,
|
|
257
|
+
kind: "values-narrowed",
|
|
258
|
+
breaking: false,
|
|
259
|
+
detail: `dropped ${removed.join(", ")}`
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
const pa = a.properties ?? {};
|
|
263
|
+
const pb = b.properties ?? {};
|
|
264
|
+
const ra = new Set(a.required ?? []);
|
|
265
|
+
const rb = new Set(b.required ?? []);
|
|
266
|
+
for (const field of Object.keys(pa)) {
|
|
267
|
+
if (!(field in pb)) {
|
|
268
|
+
const required = ra.has(field);
|
|
269
|
+
changes.push({
|
|
270
|
+
path: at(field),
|
|
271
|
+
kind: "removed",
|
|
272
|
+
breaking: required,
|
|
273
|
+
detail: required ? "required field removed" : "optional field removed"
|
|
274
|
+
});
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (ra.has(field) && !rb.has(field)) changes.push({
|
|
278
|
+
path: at(field),
|
|
279
|
+
kind: "now-optional",
|
|
280
|
+
breaking: true,
|
|
281
|
+
detail: "was required"
|
|
282
|
+
});
|
|
283
|
+
changes.push(...diffEventSchema(pa[field], pb[field], at(field)));
|
|
284
|
+
}
|
|
285
|
+
for (const field of Object.keys(pb)) if (!(field in pa)) changes.push({
|
|
286
|
+
path: at(field),
|
|
287
|
+
kind: "added",
|
|
288
|
+
breaking: false,
|
|
289
|
+
detail: rb.has(field) ? "required" : "optional"
|
|
290
|
+
});
|
|
291
|
+
if (a.items || b.items) changes.push(...diffEventSchema(a.items, b.items, `${path}[]`));
|
|
292
|
+
const aa = typeof a.additionalProperties === "object" ? a.additionalProperties : void 0;
|
|
293
|
+
const ba = typeof b.additionalProperties === "object" ? b.additionalProperties : void 0;
|
|
294
|
+
if (aa || ba) changes.push(...diffEventSchema(aa, ba, `${path}{}`));
|
|
295
|
+
return changes;
|
|
296
|
+
}
|
|
130
297
|
//#endregion
|
|
131
|
-
export { createChildEvent, createEvent, createScopedEvent, matchEventPattern, scopedEventMeta };
|
|
298
|
+
export { createChildEvent, createEvent, createEventRouter, createScopedEvent, diffEventSchema, matchEventPattern, scopedEventMeta };
|
package/dist/idempotency.d.mts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
//#region src/workflow/idempotency.d.ts
|
|
2
2
|
/**
|
|
3
|
+
* @deprecated Moved to `@classytic/repo-core/idempotency` — the claim contract plus its store
|
|
4
|
+
* port, `runIdempotent`, the reference store and the conformance suite, beside `./lock`. This
|
|
5
|
+
* copy has no consumers and is removed in the next minor.
|
|
6
|
+
*
|
|
3
7
|
* Idempotency claim + lease — the CONTRACT, not the store.
|
|
4
8
|
*
|
|
5
9
|
* ## Why this exists
|
package/dist/idempotency.mjs
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { canonicalDigest, canonicalJson } from "./canonical.mjs";
|
|
2
2
|
//#region src/workflow/idempotency.ts
|
|
3
3
|
/**
|
|
4
|
+
* @deprecated Moved to `@classytic/repo-core/idempotency` — the claim contract plus its store
|
|
5
|
+
* port, `runIdempotent`, the reference store and the conformance suite, beside `./lock`. This
|
|
6
|
+
* copy has no consumers and is removed in the next minor.
|
|
7
|
+
*
|
|
4
8
|
* Idempotency claim + lease — the CONTRACT, not the store.
|
|
5
9
|
*
|
|
6
10
|
* ## Why this exists
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { SkuFields, StockKey, StockKeySource } from "./item-identity.mjs";
|
|
2
|
+
//#region src/inventory/item-stock.d.ts
|
|
3
|
+
/** One stock row as the summary reads it. */
|
|
4
|
+
interface StockRow {
|
|
5
|
+
readonly quantity?: number;
|
|
6
|
+
/** Explicitly `false` deactivates the row. Absent means active. */
|
|
7
|
+
readonly isActive?: boolean;
|
|
8
|
+
/** Display cost of one unit, as the source reports it. */
|
|
9
|
+
readonly costPrice?: number;
|
|
10
|
+
/** On-hand value at the source's valuation, integer MINOR units; absent = unvalued. */
|
|
11
|
+
readonly valueMinor?: number;
|
|
12
|
+
/** Units at or below which the item reads as low (the item-level row's). */
|
|
13
|
+
readonly reorderPoint?: number;
|
|
14
|
+
}
|
|
15
|
+
/** What a reader shows for an item's stock in one place. */
|
|
16
|
+
interface ItemStock {
|
|
17
|
+
quantity: number;
|
|
18
|
+
inStock: boolean;
|
|
19
|
+
lowStock: boolean;
|
|
20
|
+
valueMinor?: number;
|
|
21
|
+
/** Per variant LABEL — a projection of the same rows, not a second source. */
|
|
22
|
+
variants?: Array<{
|
|
23
|
+
sku: string;
|
|
24
|
+
quantity: number;
|
|
25
|
+
costPrice?: number;
|
|
26
|
+
}>;
|
|
27
|
+
}
|
|
28
|
+
/** The item shape the summary reads: its id and its variants. */
|
|
29
|
+
interface StockableItem extends StockKeySource {
|
|
30
|
+
readonly variants?: ReadonlyArray<SkuFields & {
|
|
31
|
+
readonly sku: string;
|
|
32
|
+
readonly isActive?: boolean;
|
|
33
|
+
}> | null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The item's stock. `rowOf(null)` is the item-level row (filed under the item's own id);
|
|
37
|
+
* `rowOf(key)` the row filed under one variant's stock key.
|
|
38
|
+
*/
|
|
39
|
+
declare function itemStockOf(item: StockableItem, rowOf: (stockKey: StockKey | null) => StockRow | undefined, options: {
|
|
40
|
+
readonly defaultReorderPoint: number;
|
|
41
|
+
}): ItemStock;
|
|
42
|
+
//#endregion
|
|
43
|
+
export { ItemStock, StockRow, StockableItem, itemStockOf };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { stockKeyFor } from "./item-identity.mjs";
|
|
2
|
+
//#region src/inventory/item-stock.ts
|
|
3
|
+
/**
|
|
4
|
+
* An item's stock in one place (a branch), from the rows its stock source files under stock keys —
|
|
5
|
+
* the ONE summary every reader shows: the server's product reads and an offline till alike.
|
|
6
|
+
*
|
|
7
|
+
* itemStockOf(product, (key) => rows.get(key ?? productId), { defaultReorderPoint: 10 })
|
|
8
|
+
*
|
|
9
|
+
* Rows are keyed by STOCK KEY (`stockKeyFor`), and a row reached by two lookups is counted once —
|
|
10
|
+
* a sole variant's key IS the product id, so summing lookups instead of rows doubles it.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* The item's stock. `rowOf(null)` is the item-level row (filed under the item's own id);
|
|
14
|
+
* `rowOf(key)` the row filed under one variant's stock key.
|
|
15
|
+
*/
|
|
16
|
+
function itemStockOf(item, rowOf, options) {
|
|
17
|
+
const itemId = String(item._id);
|
|
18
|
+
const variants = item.variants ?? [];
|
|
19
|
+
const keyOf = (variant) => stockKeyFor({
|
|
20
|
+
_id: itemId,
|
|
21
|
+
variants
|
|
22
|
+
}, {
|
|
23
|
+
kind: "variant",
|
|
24
|
+
variant
|
|
25
|
+
});
|
|
26
|
+
const rows = /* @__PURE__ */ new Map();
|
|
27
|
+
const itemRow = rowOf(null);
|
|
28
|
+
if (itemRow) rows.set(itemId, itemRow);
|
|
29
|
+
for (const variant of variants) {
|
|
30
|
+
if (variant.isActive === false) continue;
|
|
31
|
+
const key = keyOf(variant);
|
|
32
|
+
const row = rowOf(key);
|
|
33
|
+
if (!row && rows.has(key)) continue;
|
|
34
|
+
rows.set(key, row);
|
|
35
|
+
}
|
|
36
|
+
let quantity = 0;
|
|
37
|
+
let valueMinor = 0;
|
|
38
|
+
for (const row of rows.values()) {
|
|
39
|
+
if (row?.isActive === false) continue;
|
|
40
|
+
const rowQuantity = row?.quantity ?? 0;
|
|
41
|
+
quantity += rowQuantity;
|
|
42
|
+
if (rowQuantity > 0) valueMinor = row?.valueMinor === void 0 || valueMinor === void 0 ? void 0 : valueMinor + row.valueMinor;
|
|
43
|
+
}
|
|
44
|
+
const variantStocks = [];
|
|
45
|
+
for (const variant of variants) {
|
|
46
|
+
if (variant.isActive === false) continue;
|
|
47
|
+
const key = keyOf(variant);
|
|
48
|
+
if (!rows.has(key)) continue;
|
|
49
|
+
const row = rows.get(key);
|
|
50
|
+
if (row?.isActive === false) continue;
|
|
51
|
+
variantStocks.push({
|
|
52
|
+
sku: variant.sku,
|
|
53
|
+
quantity: row?.quantity ?? 0,
|
|
54
|
+
...row?.costPrice !== void 0 ? { costPrice: row.costPrice } : {}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
const reorderPoint = itemRow?.reorderPoint ?? options.defaultReorderPoint;
|
|
58
|
+
return {
|
|
59
|
+
quantity,
|
|
60
|
+
inStock: quantity > 0,
|
|
61
|
+
lowStock: quantity > 0 && quantity <= reorderPoint,
|
|
62
|
+
...quantity > 0 && valueMinor !== void 0 ? { valueMinor } : {},
|
|
63
|
+
...variantStocks.length ? { variants: variantStocks } : {}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|
|
67
|
+
export { itemStockOf };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/identity/legal-form.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The legal form of a person or entity — what the law treats it as, for the reporting company and its
|
|
4
|
+
* counterparties alike. A sole proprietorship is an `individual`: the business has no legal personality
|
|
5
|
+
* of its own. Country packs decide what follows from a form (equity accounts, the year-end close, tax).
|
|
6
|
+
*/
|
|
7
|
+
declare const LEGAL_FORMS: readonly ["individual", "partnership", "company", "cooperative", "nonprofit", "government"];
|
|
8
|
+
type LegalForm = (typeof LEGAL_FORMS)[number];
|
|
9
|
+
declare const LEGAL_FORM_LABELS: Readonly<Record<LegalForm, string>>;
|
|
10
|
+
declare function isLegalForm(value: unknown): value is LegalForm;
|
|
11
|
+
//#endregion
|
|
12
|
+
export { LEGAL_FORMS, LEGAL_FORM_LABELS, LegalForm, isLegalForm };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
//#region src/identity/legal-form.ts
|
|
2
|
+
/**
|
|
3
|
+
* The legal form of a person or entity — what the law treats it as, for the reporting company and its
|
|
4
|
+
* counterparties alike. A sole proprietorship is an `individual`: the business has no legal personality
|
|
5
|
+
* of its own. Country packs decide what follows from a form (equity accounts, the year-end close, tax).
|
|
6
|
+
*/
|
|
7
|
+
const LEGAL_FORMS = [
|
|
8
|
+
"individual",
|
|
9
|
+
"partnership",
|
|
10
|
+
"company",
|
|
11
|
+
"cooperative",
|
|
12
|
+
"nonprofit",
|
|
13
|
+
"government"
|
|
14
|
+
];
|
|
15
|
+
const LEGAL_FORM_LABELS = {
|
|
16
|
+
individual: "Individual / sole proprietorship",
|
|
17
|
+
partnership: "Partnership firm",
|
|
18
|
+
company: "Company",
|
|
19
|
+
cooperative: "Cooperative society",
|
|
20
|
+
nonprofit: "Nonprofit / NGO / trust",
|
|
21
|
+
government: "Government body"
|
|
22
|
+
};
|
|
23
|
+
function isLegalForm(value) {
|
|
24
|
+
return typeof value === "string" && LEGAL_FORMS.includes(value);
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
27
|
+
export { LEGAL_FORMS, LEGAL_FORM_LABELS, isLegalForm };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Result } from "./result.mjs";
|
|
2
|
+
//#region src/inventory/line-settlement.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* A document line's quantity, split into OUTCOME buckets — the one definition
|
|
5
|
+
* of "how much of this line is still open".
|
|
6
|
+
*
|
|
7
|
+
* Every kernel that moves goods against a document asks this: an order line is
|
|
8
|
+
* fulfilled / returned / canceled, a transfer line is received / returned /
|
|
9
|
+
* written off, a purchase line is received / rejected. The buckets are the
|
|
10
|
+
* kernel's vocabulary; the arithmetic, the over-settlement guard and the state
|
|
11
|
+
* derivation are this file's, so no two kernels can answer differently.
|
|
12
|
+
*
|
|
13
|
+
* Quantities are whole units. A bucket that is absent counts as zero.
|
|
14
|
+
*/
|
|
15
|
+
interface SettlementLine<B extends string> {
|
|
16
|
+
/** What the line promised to move. */
|
|
17
|
+
readonly quantity: number;
|
|
18
|
+
/** What has been settled so far, per outcome. */
|
|
19
|
+
readonly settled: Readonly<Partial<Record<B, number>>>;
|
|
20
|
+
}
|
|
21
|
+
type SettlementState = "open" | "partial" | "settled";
|
|
22
|
+
/** Sum of every bucket. */
|
|
23
|
+
declare const settledTotal: <B extends string>(line: SettlementLine<B>) => number;
|
|
24
|
+
/**
|
|
25
|
+
* `quantity − Σ buckets`. NOT clamped at zero: a negative means the line was
|
|
26
|
+
* over-settled by data written outside {@link planSettlement}, and hiding it
|
|
27
|
+
* would erase the only signal that happened.
|
|
28
|
+
*/
|
|
29
|
+
declare const outstandingOf: <B extends string>(line: SettlementLine<B>) => number;
|
|
30
|
+
/** `open` — nothing settled; `settled` — nothing outstanding; otherwise `partial`. */
|
|
31
|
+
declare const settlementState: <B extends string>(line: SettlementLine<B>) => SettlementState;
|
|
32
|
+
/** Every line settled ⇒ `settled`; every line untouched ⇒ `open`; else `partial`. */
|
|
33
|
+
declare const documentSettlementState: <B extends string>(lines: ReadonlyArray<SettlementLine<B>>) => SettlementState;
|
|
34
|
+
/** `true` when every line is settled entirely through `bucket` (e.g. all received, nothing lost). */
|
|
35
|
+
declare const settledEntirelyIn: <B extends string>(lines: ReadonlyArray<SettlementLine<B>>, bucket: B) => boolean;
|
|
36
|
+
type SettlementRefusal = {
|
|
37
|
+
readonly code: "not_positive_integer";
|
|
38
|
+
readonly requested: number;
|
|
39
|
+
} | {
|
|
40
|
+
readonly code: "exceeds_outstanding";
|
|
41
|
+
readonly requested: number;
|
|
42
|
+
readonly outstanding: number;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Settle `quantity` more units into `bucket`, or refuse.
|
|
46
|
+
*
|
|
47
|
+
* Refuses a non-positive or fractional quantity and anything above what is
|
|
48
|
+
* outstanding — an over-settlement is an input error, never silently trimmed.
|
|
49
|
+
* Use {@link clampSettlement} only where the surface's CONTRACT is "take what fits".
|
|
50
|
+
*/
|
|
51
|
+
declare const planSettlement: <B extends string>(line: SettlementLine<B>, bucket: B, quantity: number) => Result<SettlementLine<B>, SettlementRefusal>;
|
|
52
|
+
/**
|
|
53
|
+
* Settle up to `requested` units into `bucket`, taking only what is outstanding.
|
|
54
|
+
* `applied` is what was actually settled; `0` leaves the line unchanged.
|
|
55
|
+
*/
|
|
56
|
+
declare const clampSettlement: <B extends string>(line: SettlementLine<B>, bucket: B, requested: number) => {
|
|
57
|
+
readonly line: SettlementLine<B>;
|
|
58
|
+
readonly applied: number;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* The share of a line-level amount (a value or a cost, integer MINOR units) owed by one
|
|
62
|
+
* settlement batch, given how many units the whole line had settled before and after it.
|
|
63
|
+
*
|
|
64
|
+
* `floor(total·after/q) − floor(total·before/q)` TELESCOPES: however a line is split into
|
|
65
|
+
* batches, and across every outcome bucket, the shares sum to exactly `total` once the line
|
|
66
|
+
* is fully settled — no remainder to chase, no batch-order dependence.
|
|
67
|
+
*/
|
|
68
|
+
declare const shareOfSettled: (total: number, quantity: number, settledBefore: number, settledAfter: number) => number;
|
|
69
|
+
//#endregion
|
|
70
|
+
export { SettlementLine, SettlementRefusal, SettlementState, clampSettlement, documentSettlementState, outstandingOf, planSettlement, settledEntirelyIn, settledTotal, settlementState, shareOfSettled };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { err, ok } from "./result.mjs";
|
|
2
|
+
//#region src/inventory/line-settlement.ts
|
|
3
|
+
/** Sum of every bucket. */
|
|
4
|
+
const settledTotal = (line) => Object.values(line.settled).reduce((sum, n) => sum + (n ?? 0), 0);
|
|
5
|
+
/**
|
|
6
|
+
* `quantity − Σ buckets`. NOT clamped at zero: a negative means the line was
|
|
7
|
+
* over-settled by data written outside {@link planSettlement}, and hiding it
|
|
8
|
+
* would erase the only signal that happened.
|
|
9
|
+
*/
|
|
10
|
+
const outstandingOf = (line) => line.quantity - settledTotal(line);
|
|
11
|
+
/** `open` — nothing settled; `settled` — nothing outstanding; otherwise `partial`. */
|
|
12
|
+
const settlementState = (line) => {
|
|
13
|
+
if (outstandingOf(line) <= 0) return "settled";
|
|
14
|
+
return settledTotal(line) === 0 ? "open" : "partial";
|
|
15
|
+
};
|
|
16
|
+
/** Every line settled ⇒ `settled`; every line untouched ⇒ `open`; else `partial`. */
|
|
17
|
+
const documentSettlementState = (lines) => {
|
|
18
|
+
const states = lines.map(settlementState);
|
|
19
|
+
if (states.every((s) => s === "settled")) return "settled";
|
|
20
|
+
if (states.every((s) => s === "open")) return "open";
|
|
21
|
+
return "partial";
|
|
22
|
+
};
|
|
23
|
+
/** `true` when every line is settled entirely through `bucket` (e.g. all received, nothing lost). */
|
|
24
|
+
const settledEntirelyIn = (lines, bucket) => lines.every((l) => (l.settled[bucket] ?? 0) >= l.quantity);
|
|
25
|
+
/**
|
|
26
|
+
* Settle `quantity` more units into `bucket`, or refuse.
|
|
27
|
+
*
|
|
28
|
+
* Refuses a non-positive or fractional quantity and anything above what is
|
|
29
|
+
* outstanding — an over-settlement is an input error, never silently trimmed.
|
|
30
|
+
* Use {@link clampSettlement} only where the surface's CONTRACT is "take what fits".
|
|
31
|
+
*/
|
|
32
|
+
const planSettlement = (line, bucket, quantity) => {
|
|
33
|
+
if (!Number.isInteger(quantity) || quantity <= 0) return err({
|
|
34
|
+
code: "not_positive_integer",
|
|
35
|
+
requested: quantity
|
|
36
|
+
});
|
|
37
|
+
const outstanding = outstandingOf(line);
|
|
38
|
+
if (quantity > outstanding) return err({
|
|
39
|
+
code: "exceeds_outstanding",
|
|
40
|
+
requested: quantity,
|
|
41
|
+
outstanding
|
|
42
|
+
});
|
|
43
|
+
return ok(withBucket(line, bucket, quantity));
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Settle up to `requested` units into `bucket`, taking only what is outstanding.
|
|
47
|
+
* `applied` is what was actually settled; `0` leaves the line unchanged.
|
|
48
|
+
*/
|
|
49
|
+
const clampSettlement = (line, bucket, requested) => {
|
|
50
|
+
const applied = Math.max(0, Math.min(Math.trunc(requested), outstandingOf(line)));
|
|
51
|
+
return {
|
|
52
|
+
line: applied > 0 ? withBucket(line, bucket, applied) : line,
|
|
53
|
+
applied
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* The share of a line-level amount (a value or a cost, integer MINOR units) owed by one
|
|
58
|
+
* settlement batch, given how many units the whole line had settled before and after it.
|
|
59
|
+
*
|
|
60
|
+
* `floor(total·after/q) − floor(total·before/q)` TELESCOPES: however a line is split into
|
|
61
|
+
* batches, and across every outcome bucket, the shares sum to exactly `total` once the line
|
|
62
|
+
* is fully settled — no remainder to chase, no batch-order dependence.
|
|
63
|
+
*/
|
|
64
|
+
const shareOfSettled = (total, quantity, settledBefore, settledAfter) => {
|
|
65
|
+
if (!Number.isInteger(total) || total < 0) throw new RangeError(`total must be a non-negative integer, got ${total}`);
|
|
66
|
+
if (!Number.isInteger(quantity) || quantity <= 0) throw new RangeError(`quantity must be a positive integer, got ${quantity}`);
|
|
67
|
+
if (!(0 <= settledBefore && settledBefore <= settledAfter && settledAfter <= quantity)) throw new RangeError(`need 0 <= before (${settledBefore}) <= after (${settledAfter}) <= quantity (${quantity})`);
|
|
68
|
+
const at = (n) => Number(BigInt(total) * BigInt(n) / BigInt(quantity));
|
|
69
|
+
return at(settledAfter) - at(settledBefore);
|
|
70
|
+
};
|
|
71
|
+
const withBucket = (line, bucket, quantity) => ({
|
|
72
|
+
quantity: line.quantity,
|
|
73
|
+
settled: {
|
|
74
|
+
...line.settled,
|
|
75
|
+
[bucket]: (line.settled[bucket] ?? 0) + quantity
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
//#endregion
|
|
79
|
+
export { clampSettlement, documentSettlementState, outstandingOf, planSettlement, settledEntirelyIn, settledTotal, settlementState, shareOfSettled };
|
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);
|
|
@@ -74,7 +79,8 @@ var MemoryOutboxStore = class {
|
|
|
74
79
|
entry.fenceToken = (entry.fenceToken ?? 0) + 1;
|
|
75
80
|
claimed.push({
|
|
76
81
|
event: entry.event,
|
|
77
|
-
fencingToken: entry.fenceToken
|
|
82
|
+
fencingToken: entry.fenceToken,
|
|
83
|
+
attempts: entry.attempts
|
|
78
84
|
});
|
|
79
85
|
}
|
|
80
86
|
return claimed;
|
|
@@ -89,6 +95,16 @@ var MemoryOutboxStore = class {
|
|
|
89
95
|
entry.deliveredAt = Date.now();
|
|
90
96
|
entry.leaseOwner = null;
|
|
91
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
|
+
}
|
|
92
108
|
async fail(eventId, error, options) {
|
|
93
109
|
const entry = this.entries.find((e) => e.event.meta.id === eventId);
|
|
94
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 };
|