@classytic/primitives 0.21.0 → 0.23.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 +972 -920
- package/dist/approval.d.mts +59 -1
- package/dist/approval.mjs +60 -1
- package/dist/calendar-facts.d.mts +176 -0
- package/dist/calendar-facts.mjs +187 -0
- package/dist/environment.d.mts +8 -3
- package/dist/environment.mjs +8 -3
- package/dist/fault-attribution.d.mts +98 -0
- package/dist/fault-attribution.mjs +51 -0
- package/dist/memory-outbox.d.mts +5 -2
- package/dist/memory-outbox.mjs +9 -9
- package/dist/outbox.d.mts +28 -1
- package/dist/period.d.mts +25 -1
- package/dist/period.mjs +50 -1
- package/dist/unit-cost-rate.d.mts +18 -1
- package/dist/unit-cost-rate.mjs +30 -1
- package/package.json +9 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
//#region src/composition/fault-attribution.ts
|
|
2
|
+
/**
|
|
3
|
+
* Attribute fault across one or more reasons.
|
|
4
|
+
*
|
|
5
|
+
* Rules, in order:
|
|
6
|
+
*
|
|
7
|
+
* 1. **An explicit decision always wins.** A human or an upstream policy that
|
|
8
|
+
* said "the merchant covers this" outranks any reason lookup — a general
|
|
9
|
+
* default must never override a specific instruction.
|
|
10
|
+
* 2. **Merchant fault is sticky.** If ANY reason is the merchant's fault, the
|
|
11
|
+
* whole claim is: a customer returning three items, one of them defective,
|
|
12
|
+
* is not asked to part-pay the shipping. This is the single rule most likely
|
|
13
|
+
* to be re-implemented differently per call site, which is why it lives here.
|
|
14
|
+
* 3. **Carrier before customer.** A carrier-fault reason is not the customer's
|
|
15
|
+
* problem even though the merchant may recover it separately.
|
|
16
|
+
* 4. **Otherwise the reasons must AGREE.** Mixed or unmapped reasons yield
|
|
17
|
+
* `unknown` rather than a guess.
|
|
18
|
+
* 5. **No reasons at all is `unknown`**, never a party.
|
|
19
|
+
*/
|
|
20
|
+
function attributeFault(policy, reasons, explicit) {
|
|
21
|
+
if (explicit !== void 0) return explicit;
|
|
22
|
+
if (reasons.length === 0) return "unknown";
|
|
23
|
+
const parties = reasons.map((r) => policy[r] ?? "unknown");
|
|
24
|
+
if (parties.includes("merchant")) return "merchant";
|
|
25
|
+
if (parties.includes("carrier")) return "carrier";
|
|
26
|
+
const first = parties[0];
|
|
27
|
+
return parties.every((p) => p === first) ? first : "unknown";
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Does `party` bear the cost?
|
|
31
|
+
*
|
|
32
|
+
* `shared` bears it for every named party — a split is not an escape from
|
|
33
|
+
* paying. `unknown` bears nothing: an undecided claim must not auto-charge
|
|
34
|
+
* anyone, which is the whole reason `unknown` is modelled.
|
|
35
|
+
*/
|
|
36
|
+
function bearsCost(attributed, party) {
|
|
37
|
+
if (attributed === "unknown") return false;
|
|
38
|
+
if (attributed === "shared") return true;
|
|
39
|
+
return attributed === party;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* True when attribution is still open and a human (or a later signal) must
|
|
43
|
+
* decide. Use it to route a claim to review instead of letting it settle at a
|
|
44
|
+
* default — an `unknown` that silently behaves like `customer` is the defect
|
|
45
|
+
* this primitive exists to prevent.
|
|
46
|
+
*/
|
|
47
|
+
function needsFaultReview(attributed) {
|
|
48
|
+
return attributed === "unknown";
|
|
49
|
+
}
|
|
50
|
+
//#endregion
|
|
51
|
+
export { attributeFault, bearsCost, needsFaultReview };
|
package/dist/memory-outbox.d.mts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { DeadLetteredEvent, DomainEvent } from "./events.mjs";
|
|
2
|
-
import { OutboxAcknowledgeOptions, OutboxClaimOptions, OutboxErrorInfo, OutboxFailOptions, OutboxStatus, OutboxStore, OutboxWriteOptions } from "./outbox.mjs";
|
|
2
|
+
import { OutboxAcknowledgeOptions, OutboxClaimOptions, OutboxClaimedEvent, OutboxErrorInfo, OutboxFailOptions, OutboxStatus, OutboxStore, OutboxWriteOptions } from "./outbox.mjs";
|
|
3
3
|
//#region src/events/memory-outbox.d.ts
|
|
4
4
|
interface MemoryEntry {
|
|
5
5
|
event: DomainEvent;
|
|
6
|
-
status:
|
|
6
|
+
status: 'pending' | 'delivered' | 'dead_letter';
|
|
7
7
|
attempts: number;
|
|
8
8
|
visibleAt: number;
|
|
9
9
|
leaseOwner: string | null;
|
|
10
|
+
/** Fencing token — one per claim epoch of this event. */
|
|
11
|
+
fenceToken?: number;
|
|
10
12
|
leaseExpiresAt: number;
|
|
11
13
|
deliveredAt: number | null;
|
|
12
14
|
firstFailedAt: number | null;
|
|
@@ -44,6 +46,7 @@ declare class MemoryOutboxStore implements OutboxStore {
|
|
|
44
46
|
save(event: DomainEvent, options?: OutboxWriteOptions): Promise<void>;
|
|
45
47
|
getPending(limit: number): Promise<DomainEvent[]>;
|
|
46
48
|
claimPending(options?: OutboxClaimOptions): Promise<DomainEvent[]>;
|
|
49
|
+
claimPendingFenced(options?: OutboxClaimOptions): Promise<OutboxClaimedEvent[]>;
|
|
47
50
|
acknowledge(eventId: string, options?: OutboxAcknowledgeOptions): Promise<void>;
|
|
48
51
|
fail(eventId: string, error: OutboxErrorInfo, options?: OutboxFailOptions): Promise<void>;
|
|
49
52
|
getDeadLettered(limit: number): Promise<DeadLetteredEvent[]>;
|
package/dist/memory-outbox.mjs
CHANGED
|
@@ -1,13 +1,5 @@
|
|
|
1
1
|
import { InvalidOutboxEventError, OutboxOwnershipError } from "./outbox.mjs";
|
|
2
2
|
//#region src/events/memory-outbox.ts
|
|
3
|
-
/**
|
|
4
|
-
* In-memory outbox store — reference implementation for dev / testing.
|
|
5
|
-
*
|
|
6
|
-
* Supports the full `OutboxStore` capability set (claim/lease, fail/retry,
|
|
7
|
-
* dedupe, visibleAt). Production deployments should pass a `repository` to
|
|
8
|
-
* `EventOutbox` instead of using this; the memory store exists for unit
|
|
9
|
-
* tests and single-process dev where durable persistence is noise.
|
|
10
|
-
*/
|
|
11
3
|
const DEFAULT_LEASE_MS = 3e4;
|
|
12
4
|
var MemoryOutboxStore = class {
|
|
13
5
|
entries = [];
|
|
@@ -61,6 +53,9 @@ var MemoryOutboxStore = class {
|
|
|
61
53
|
return this.entries.filter((e) => e.status === "pending" && e.visibleAt <= now && (e.leaseOwner === null || e.leaseExpiresAt <= now)).slice(0, limit).map((e) => e.event);
|
|
62
54
|
}
|
|
63
55
|
async claimPending(options) {
|
|
56
|
+
return (await this.claimPendingFenced(options)).map((c) => c.event);
|
|
57
|
+
}
|
|
58
|
+
async claimPendingFenced(options) {
|
|
64
59
|
const now = Date.now();
|
|
65
60
|
const limit = options?.limit ?? 100;
|
|
66
61
|
const leaseMs = options?.leaseMs ?? DEFAULT_LEASE_MS;
|
|
@@ -76,7 +71,11 @@ var MemoryOutboxStore = class {
|
|
|
76
71
|
entry.leaseOwner = consumerId;
|
|
77
72
|
entry.leaseExpiresAt = now + leaseMs;
|
|
78
73
|
entry.attempts++;
|
|
79
|
-
|
|
74
|
+
entry.fenceToken = (entry.fenceToken ?? 0) + 1;
|
|
75
|
+
claimed.push({
|
|
76
|
+
event: entry.event,
|
|
77
|
+
fencingToken: entry.fenceToken
|
|
78
|
+
});
|
|
80
79
|
}
|
|
81
80
|
return claimed;
|
|
82
81
|
}
|
|
@@ -85,6 +84,7 @@ var MemoryOutboxStore = class {
|
|
|
85
84
|
if (!entry) return;
|
|
86
85
|
if (entry.status === "delivered") return;
|
|
87
86
|
if (options?.consumerId && entry.leaseOwner && entry.leaseOwner !== options.consumerId) throw new OutboxOwnershipError(eventId, options.consumerId, entry.leaseOwner);
|
|
87
|
+
if (options?.fencingToken !== void 0 && options.fencingToken !== entry.fenceToken) throw new OutboxOwnershipError(eventId, `token ${options.fencingToken}`, `token ${entry.fenceToken}`);
|
|
88
88
|
entry.status = "delivered";
|
|
89
89
|
entry.deliveredAt = Date.now();
|
|
90
90
|
entry.leaseOwner = null;
|
package/dist/outbox.d.mts
CHANGED
|
@@ -35,15 +35,35 @@ interface OutboxClaimOptions {
|
|
|
35
35
|
/** Only claim events of these types (optional filter). */
|
|
36
36
|
readonly types?: readonly string[];
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* A claim result carrying the FENCING TOKEN — returned by
|
|
40
|
+
* {@link OutboxStore.claimPendingFenced}. The token is minted by the STORE
|
|
41
|
+
* per claim epoch of that event: a re-claim after lease expiry mints a
|
|
42
|
+
* STRICTLY GREATER token, so a stale ex-holder presenting its old token to
|
|
43
|
+
* `acknowledge`/`fail` is rejected — the overlap window ownership checks
|
|
44
|
+
* narrow but cannot close (holder ids recur; epochs do not).
|
|
45
|
+
*/
|
|
46
|
+
interface OutboxClaimedEvent {
|
|
47
|
+
readonly event: DomainEvent;
|
|
48
|
+
readonly fencingToken: number;
|
|
49
|
+
}
|
|
38
50
|
/** Options for {@link OutboxStore.acknowledge}. */
|
|
39
51
|
interface OutboxAcknowledgeOptions {
|
|
40
52
|
/** Worker identifier — stores may enforce "only owner can ack". */
|
|
41
53
|
readonly consumerId?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Fencing token from {@link OutboxStore.claimPendingFenced}. Stores that
|
|
56
|
+
* fence MUST throw {@link OutboxOwnershipError} when it is not the event's
|
|
57
|
+
* CURRENT claim token.
|
|
58
|
+
*/
|
|
59
|
+
readonly fencingToken?: number;
|
|
42
60
|
}
|
|
43
61
|
/** Options for {@link OutboxStore.fail}. */
|
|
44
62
|
interface OutboxFailOptions {
|
|
45
63
|
/** Worker identifier — stores may enforce "only owner can fail". */
|
|
46
64
|
readonly consumerId?: string;
|
|
65
|
+
/** Fencing token — same contract as {@link OutboxAcknowledgeOptions.fencingToken}. */
|
|
66
|
+
readonly fencingToken?: number;
|
|
47
67
|
/** Schedule retry for a later time (implements backoff). */
|
|
48
68
|
readonly retryAt?: Date;
|
|
49
69
|
/** Move the event to dead-letter state (no further retries). */
|
|
@@ -213,6 +233,13 @@ interface OutboxStore {
|
|
|
213
233
|
* prefers this over `getPending`.
|
|
214
234
|
*/
|
|
215
235
|
claimPending?(options?: OutboxClaimOptions): Promise<DomainEvent[]>;
|
|
236
|
+
/**
|
|
237
|
+
* FENCED claim — same atomic semantics as `claimPending`, returning each
|
|
238
|
+
* event with a store-minted fencing token (see {@link OutboxClaimedEvent}).
|
|
239
|
+
* Additive and feature-detected, mirroring `LockAdapter.tryAcquireFenced`:
|
|
240
|
+
* a store that cannot mint monotonic per-event tokens omits this method.
|
|
241
|
+
*/
|
|
242
|
+
claimPendingFenced?(options?: OutboxClaimOptions): Promise<OutboxClaimedEvent[]>;
|
|
216
243
|
/**
|
|
217
244
|
* Mark event as successfully relayed.
|
|
218
245
|
*
|
|
@@ -291,4 +318,4 @@ interface OutboxStore {
|
|
|
291
318
|
oldestPendingAgeMs?(): Promise<number | null>;
|
|
292
319
|
}
|
|
293
320
|
//#endregion
|
|
294
|
-
export { InvalidOutboxEventError, OUTBOX_STATUSES, OutboxAcknowledgeOptions, OutboxClaimOptions, OutboxErrorInfo, OutboxFailOptions, OutboxFailureContext, OutboxFailureDecision, OutboxFailurePolicy, OutboxOwnershipError, OutboxStatus, OutboxStore, OutboxWriteOptions };
|
|
321
|
+
export { InvalidOutboxEventError, OUTBOX_STATUSES, OutboxAcknowledgeOptions, OutboxClaimOptions, OutboxClaimedEvent, OutboxErrorInfo, OutboxFailOptions, OutboxFailureContext, OutboxFailureDecision, OutboxFailurePolicy, OutboxOwnershipError, OutboxStatus, OutboxStore, OutboxWriteOptions };
|
package/dist/period.d.mts
CHANGED
|
@@ -160,6 +160,30 @@ declare function resolvePeriod(period: Period, timezone?: string): DateRange;
|
|
|
160
160
|
* For the `$lte` call sites that predate half-open ranges. Derived from the
|
|
161
161
|
* resolved end rather than `start + 24h - 1`, so it stays DST-exact.
|
|
162
162
|
*/
|
|
163
|
+
/**
|
|
164
|
+
* Shift a MONTH or QUARTER period by `n` of its own units (negative = back).
|
|
165
|
+
*
|
|
166
|
+
* The arithmetic a caller would otherwise write inline, and the reason this is
|
|
167
|
+
* here rather than in each of them: "two filing months ago" is a `Period`
|
|
168
|
+
* question, and the shapes reachable without it are both wrong in the same way.
|
|
169
|
+
*
|
|
170
|
+
* `new Date(Date.UTC(y, m - 1 - n, 1))` — a UTC instant used to answer a
|
|
171
|
+
* BUSINESS-calendar question. Six hours off in Asia/Dhaka at both ends; the
|
|
172
|
+
* five silent defects in this module's header are all that shape.
|
|
173
|
+
*
|
|
174
|
+
* hand-rolled integer math (`y * 12 + (m - 1) - n`) — correct, but it is a
|
|
175
|
+
* third copy of a rule the `Period` type already owns, and the year rollover
|
|
176
|
+
* is exactly where a copy goes wrong.
|
|
177
|
+
*
|
|
178
|
+
* NO Date is constructed and no zone is consulted: shifting a month is pure
|
|
179
|
+
* calendar arithmetic on `(year, month)`. The `timezone` rides along unchanged,
|
|
180
|
+
* so the shifted period still resolves against the calendar it was defined in —
|
|
181
|
+
* `resolvePeriod` / {@link resolveMonth} remain the only places instants appear.
|
|
182
|
+
*
|
|
183
|
+
* A YEAR period shifts by years; a `custom` (ad-hoc start/end) period has no
|
|
184
|
+
* unit to shift and throws rather than guessing one.
|
|
185
|
+
*/
|
|
186
|
+
declare function shiftPeriod(period: Period, n: number): Period;
|
|
163
187
|
declare function inclusiveEnd(range: DateRange): Date;
|
|
164
188
|
/**
|
|
165
189
|
* The period an INSTANT falls in, on the local calendar.
|
|
@@ -185,4 +209,4 @@ declare function parsePeriod(label: string, timezone?: string): Period;
|
|
|
185
209
|
*/
|
|
186
210
|
declare function formatPeriod(period: Period): string;
|
|
187
211
|
//#endregion
|
|
188
|
-
export { DateRange, DateRangeError, Period, PeriodError, PeriodErrorCode, PeriodGranularity, dayStart, formatPeriod, granularityOf, inclusiveEnd, isDateRange, isWithin, parsePeriod, periodOf, periodTimeZone, rangeDurationMs, rangesOverlap, resolveDateSpan, resolveDay, resolveMonth, resolvePeriod, resolveQuarter, resolveYear };
|
|
212
|
+
export { DateRange, DateRangeError, Period, PeriodError, PeriodErrorCode, PeriodGranularity, dayStart, formatPeriod, granularityOf, inclusiveEnd, isDateRange, isWithin, parsePeriod, periodOf, periodTimeZone, rangeDurationMs, rangesOverlap, resolveDateSpan, resolveDay, resolveMonth, resolvePeriod, resolveQuarter, resolveYear, shiftPeriod };
|
package/dist/period.mjs
CHANGED
|
@@ -261,6 +261,55 @@ function resolvePeriod(period, timezone) {
|
|
|
261
261
|
* For the `$lte` call sites that predate half-open ranges. Derived from the
|
|
262
262
|
* resolved end rather than `start + 24h - 1`, so it stays DST-exact.
|
|
263
263
|
*/
|
|
264
|
+
/**
|
|
265
|
+
* Shift a MONTH or QUARTER period by `n` of its own units (negative = back).
|
|
266
|
+
*
|
|
267
|
+
* The arithmetic a caller would otherwise write inline, and the reason this is
|
|
268
|
+
* here rather than in each of them: "two filing months ago" is a `Period`
|
|
269
|
+
* question, and the shapes reachable without it are both wrong in the same way.
|
|
270
|
+
*
|
|
271
|
+
* `new Date(Date.UTC(y, m - 1 - n, 1))` — a UTC instant used to answer a
|
|
272
|
+
* BUSINESS-calendar question. Six hours off in Asia/Dhaka at both ends; the
|
|
273
|
+
* five silent defects in this module's header are all that shape.
|
|
274
|
+
*
|
|
275
|
+
* hand-rolled integer math (`y * 12 + (m - 1) - n`) — correct, but it is a
|
|
276
|
+
* third copy of a rule the `Period` type already owns, and the year rollover
|
|
277
|
+
* is exactly where a copy goes wrong.
|
|
278
|
+
*
|
|
279
|
+
* NO Date is constructed and no zone is consulted: shifting a month is pure
|
|
280
|
+
* calendar arithmetic on `(year, month)`. The `timezone` rides along unchanged,
|
|
281
|
+
* so the shifted period still resolves against the calendar it was defined in —
|
|
282
|
+
* `resolvePeriod` / {@link resolveMonth} remain the only places instants appear.
|
|
283
|
+
*
|
|
284
|
+
* A YEAR period shifts by years; a `custom` (ad-hoc start/end) period has no
|
|
285
|
+
* unit to shift and throws rather than guessing one.
|
|
286
|
+
*/
|
|
287
|
+
function shiftPeriod(period, n) {
|
|
288
|
+
if (!Number.isInteger(n)) throw new PeriodError("INVALID_FIELD", `shiftPeriod expects an integer offset, received ${String(n)}.`);
|
|
289
|
+
const granularity = granularityOf(period);
|
|
290
|
+
const zone = period.timezone !== void 0 ? { timezone: period.timezone } : {};
|
|
291
|
+
if (granularity === "month") {
|
|
292
|
+
const zeroBased = period.year * 12 + (period.month - 1) + n;
|
|
293
|
+
return {
|
|
294
|
+
year: Math.floor(zeroBased / 12),
|
|
295
|
+
month: zeroBased % 12 + 1,
|
|
296
|
+
...zone
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
if (granularity === "quarter") {
|
|
300
|
+
const zeroBased = period.year * 4 + (period.quarter - 1) + n;
|
|
301
|
+
return {
|
|
302
|
+
year: Math.floor(zeroBased / 4),
|
|
303
|
+
quarter: zeroBased % 4 + 1,
|
|
304
|
+
...zone
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
if (granularity === "year") return {
|
|
308
|
+
year: period.year + n,
|
|
309
|
+
...zone
|
|
310
|
+
};
|
|
311
|
+
throw new PeriodError("AMBIGUOUS_PERIOD", "shiftPeriod has no unit to shift for an ad-hoc start/end period — shift the instants, or use a calendar period.");
|
|
312
|
+
}
|
|
264
313
|
function inclusiveEnd(range) {
|
|
265
314
|
assertUsableRange(range, "range");
|
|
266
315
|
return /* @__PURE__ */ new Date(range.end.getTime() - 1);
|
|
@@ -339,4 +388,4 @@ function formatPeriod(period) {
|
|
|
339
388
|
throw new PeriodError("INVALID_LABEL", "An ad-hoc Period (start + end) has no canonical label.");
|
|
340
389
|
}
|
|
341
390
|
//#endregion
|
|
342
|
-
export { DateRangeError, PeriodError, dayStart, formatPeriod, granularityOf, inclusiveEnd, isDateRange, isWithin, parsePeriod, periodOf, periodTimeZone, rangeDurationMs, rangesOverlap, resolveDateSpan, resolveDay, resolveMonth, resolvePeriod, resolveQuarter, resolveYear };
|
|
391
|
+
export { DateRangeError, PeriodError, dayStart, formatPeriod, granularityOf, inclusiveEnd, isDateRange, isWithin, parsePeriod, periodOf, periodTimeZone, rangeDurationMs, rangesOverlap, resolveDateSpan, resolveDay, resolveMonth, resolvePeriod, resolveQuarter, resolveYear, shiftPeriod };
|
|
@@ -46,9 +46,26 @@ declare function unitCostRateFromTotal<C extends string = string>(totalMinor: nu
|
|
|
46
46
|
* {@link QTY_SCALE} precision.
|
|
47
47
|
*/
|
|
48
48
|
declare function extendedAmount<C extends string = string>(rate: UnitCostRate<C>, quantity: number, mode?: RoundingMode): MinorUnits<C>;
|
|
49
|
+
/**
|
|
50
|
+
* Incremental weighted-average of two scaled rates — the moving-average (WAC)
|
|
51
|
+
* receipt step:
|
|
52
|
+
*
|
|
53
|
+
* newRate = (onHandQty × onHandRate + inQty × inRate) / (onHandQty + inQty)
|
|
54
|
+
*
|
|
55
|
+
* Both rates must share the same scale (ecosystem-wide {@link RATE_SCALE});
|
|
56
|
+
* the result is a scaled integer on that same scale. Bigint-exact with the
|
|
57
|
+
* quantity honoured to {@link QTY_SCALE} precision; rounds once, at the end.
|
|
58
|
+
*
|
|
59
|
+
* Degenerate-stock semantics (the moving-average standard): a zero or
|
|
60
|
+
* NEGATIVE on-hand contributes NOTHING to the average — the incoming rate
|
|
61
|
+
* RESETS the cost. Averaging against a negative quantity would let a
|
|
62
|
+
* catch-up receipt swing the rate arbitrarily far from any price ever paid.
|
|
63
|
+
* Outflows never call this function: an issue leaves the average unchanged.
|
|
64
|
+
*/
|
|
65
|
+
declare function weightedAverageScaledRate(onHandQuantity: number, onHandScaledRate: number, inQuantity: number, inScaledRate: number, mode?: RoundingMode): number;
|
|
49
66
|
/** The rate as a plain (fractional) minor-units-per-unit number — for display only. */
|
|
50
67
|
declare function rateMinorPerUnit(rate: UnitCostRate): number;
|
|
51
68
|
/** Structural guard. */
|
|
52
69
|
declare function isUnitCostRate(value: unknown): value is UnitCostRate;
|
|
53
70
|
//#endregion
|
|
54
|
-
export { RATE_SCALE, RoundingMode, UnitCostRate, UnitCostRateError, UnitCostRateErrorCode, extendedAmount, isUnitCostRate, rateMinorPerUnit, unitCostRate, unitCostRateFromTotal };
|
|
71
|
+
export { RATE_SCALE, RoundingMode, UnitCostRate, UnitCostRateError, UnitCostRateErrorCode, extendedAmount, isUnitCostRate, rateMinorPerUnit, unitCostRate, unitCostRateFromTotal, weightedAverageScaledRate };
|
package/dist/unit-cost-rate.mjs
CHANGED
|
@@ -85,6 +85,35 @@ function extendedAmount(rate, quantity, mode = DEFAULT_ROUNDING) {
|
|
|
85
85
|
if (!Number.isSafeInteger(minor)) throw new UnitCostRateError("UNSAFE_INTEGER", `extended amount ${minor} exceeds safe integer range`);
|
|
86
86
|
return MinorUnits(minor, rate.currency);
|
|
87
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Incremental weighted-average of two scaled rates — the moving-average (WAC)
|
|
90
|
+
* receipt step:
|
|
91
|
+
*
|
|
92
|
+
* newRate = (onHandQty × onHandRate + inQty × inRate) / (onHandQty + inQty)
|
|
93
|
+
*
|
|
94
|
+
* Both rates must share the same scale (ecosystem-wide {@link RATE_SCALE});
|
|
95
|
+
* the result is a scaled integer on that same scale. Bigint-exact with the
|
|
96
|
+
* quantity honoured to {@link QTY_SCALE} precision; rounds once, at the end.
|
|
97
|
+
*
|
|
98
|
+
* Degenerate-stock semantics (the moving-average standard): a zero or
|
|
99
|
+
* NEGATIVE on-hand contributes NOTHING to the average — the incoming rate
|
|
100
|
+
* RESETS the cost. Averaging against a negative quantity would let a
|
|
101
|
+
* catch-up receipt swing the rate arbitrarily far from any price ever paid.
|
|
102
|
+
* Outflows never call this function: an issue leaves the average unchanged.
|
|
103
|
+
*/
|
|
104
|
+
function weightedAverageScaledRate(onHandQuantity, onHandScaledRate, inQuantity, inScaledRate, mode = DEFAULT_ROUNDING) {
|
|
105
|
+
if (!Number.isFinite(inQuantity) || inQuantity <= 0) throw new UnitCostRateError("NON_POSITIVE_QUANTITY", `incoming quantity must be > 0, got ${inQuantity}`);
|
|
106
|
+
assertSafe(inScaledRate);
|
|
107
|
+
const oldQty = Number.isFinite(onHandQuantity) ? Math.max(onHandQuantity, 0) : 0;
|
|
108
|
+
if (oldQty === 0) return inScaledRate;
|
|
109
|
+
assertSafe(onHandScaledRate);
|
|
110
|
+
const oldQ = scaleQuantity(oldQty);
|
|
111
|
+
const inQ = scaleQuantity(inQuantity);
|
|
112
|
+
const numerator = oldQ * BigInt(onHandScaledRate) + inQ * BigInt(inScaledRate);
|
|
113
|
+
const scaled = Number(roundedDiv(numerator, oldQ + inQ, mode));
|
|
114
|
+
assertSafe(scaled);
|
|
115
|
+
return scaled;
|
|
116
|
+
}
|
|
88
117
|
/** The rate as a plain (fractional) minor-units-per-unit number — for display only. */
|
|
89
118
|
function rateMinorPerUnit(rate) {
|
|
90
119
|
return rate.scaledAmount / rate.scale;
|
|
@@ -113,4 +142,4 @@ function roundedDiv(numerator, denominator, mode) {
|
|
|
113
142
|
return neg ? -result : result;
|
|
114
143
|
}
|
|
115
144
|
//#endregion
|
|
116
|
-
export { RATE_SCALE, UnitCostRateError, extendedAmount, isUnitCostRate, rateMinorPerUnit, unitCostRate, unitCostRateFromTotal };
|
|
145
|
+
export { RATE_SCALE, UnitCostRateError, extendedAmount, isUnitCostRate, rateMinorPerUnit, unitCostRate, unitCostRateFromTotal, weightedAverageScaledRate };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/primitives",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.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",
|
|
@@ -45,6 +45,10 @@
|
|
|
45
45
|
"types": "./dist/calendar.d.mts",
|
|
46
46
|
"default": "./dist/calendar.mjs"
|
|
47
47
|
},
|
|
48
|
+
"./calendar-facts": {
|
|
49
|
+
"types": "./dist/calendar-facts.d.mts",
|
|
50
|
+
"default": "./dist/calendar-facts.mjs"
|
|
51
|
+
},
|
|
48
52
|
"./timezone": {
|
|
49
53
|
"types": "./dist/timezone.d.mts",
|
|
50
54
|
"default": "./dist/timezone.mjs"
|
|
@@ -53,6 +57,10 @@
|
|
|
53
57
|
"types": "./dist/reference.d.mts",
|
|
54
58
|
"default": "./dist/reference.mjs"
|
|
55
59
|
},
|
|
60
|
+
"./fault-attribution": {
|
|
61
|
+
"types": "./dist/fault-attribution.d.mts",
|
|
62
|
+
"default": "./dist/fault-attribution.mjs"
|
|
63
|
+
},
|
|
56
64
|
"./context": {
|
|
57
65
|
"types": "./dist/context.d.mts",
|
|
58
66
|
"default": "./dist/context.mjs"
|