@classytic/primitives 0.16.0 → 0.19.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.
@@ -0,0 +1,68 @@
1
+ import { StateMachine } from "./state-machine.mjs";
2
+
3
+ //#region src/workflow/state-diagram.d.ts
4
+ interface StateDiagramOptions<TStatus extends string> {
5
+ /**
6
+ * The state a new aggregate starts in. Drawn as `[*] --> initial`.
7
+ *
8
+ * NOT inferred: a machine's entry point is a domain fact the adjacency list does
9
+ * not carry. "The state with no inbound transitions" guesses wrong the moment a
10
+ * machine has a legal return path into its own start (a retry going
11
+ * `sent → pending`), and silently drawing the wrong entry point is worse than
12
+ * drawing none.
13
+ */
14
+ readonly initial?: TStatus;
15
+ /**
16
+ * Highlight one state — "you are here" for an operator looking at a record.
17
+ */
18
+ readonly current?: TStatus;
19
+ /** Diagram title, rendered above the graph. */
20
+ readonly title?: string;
21
+ /**
22
+ * Label a transition, keyed `"from->to"`. For naming the VERB that causes it
23
+ * (`"sent->acked": "device confirmed"`), which is the part a reader actually
24
+ * wants and the table cannot know.
25
+ */
26
+ readonly labels?: Readonly<Record<string, string>>;
27
+ /**
28
+ * Draw `terminal --> [*]` edges. Default true — a reader needs to see which
29
+ * states end the lifecycle. Turn off for a dense machine where the extra edges
30
+ * cost more than they explain.
31
+ */
32
+ readonly showTerminal?: boolean;
33
+ }
34
+ /**
35
+ * A `stateDiagram-v2` for this machine.
36
+ *
37
+ * Self-transitions are emitted as-is — mermaid draws them as a loop, and they are
38
+ * meaningful (`sent → sent` is the device-command re-lease after a lapsed lease),
39
+ * so suppressing them would hide a real rule.
40
+ */
41
+ declare function toMermaid<TStatus extends string>(machine: StateMachine<TStatus>, options?: StateDiagramOptions<TStatus>): string;
42
+ /** One row of `describeStateMachine` — a table a UI or a doc can render directly. */
43
+ interface StateDescription<TStatus extends string> {
44
+ readonly status: TStatus;
45
+ readonly targets: readonly TStatus[];
46
+ readonly sources: readonly TStatus[];
47
+ readonly terminal: boolean;
48
+ }
49
+ /**
50
+ * The machine as data, for a UI that wants a table rather than a picture — an
51
+ * action bar asking "which transitions are legal from where this record is now"
52
+ * reads `targets`, and never has to hardcode a list that drifts from the kernel.
53
+ */
54
+ declare function describeStateMachine<TStatus extends string>(machine: StateMachine<TStatus>): {
55
+ readonly name: string;
56
+ readonly states: readonly StateDescription<TStatus>[];
57
+ };
58
+ /**
59
+ * States that can never be reached from `initial`.
60
+ *
61
+ * A design smell worth a CI check: an unreachable state is either a transition
62
+ * somebody forgot to declare, or dead vocabulary nobody removed. Both are cheap to
63
+ * find here and expensive to notice in production, where the symptom is a record
64
+ * that can never leave the state it is stuck in.
65
+ */
66
+ declare function unreachableStates<TStatus extends string>(machine: StateMachine<TStatus>, initial: TStatus): readonly TStatus[];
67
+ //#endregion
68
+ export { StateDescription, StateDiagramOptions, describeStateMachine, toMermaid, unreachableStates };
@@ -0,0 +1,66 @@
1
+ //#region src/workflow/state-diagram.ts
2
+ /** Mermaid ids must be identifier-safe; domain statuses are usually snake_case. */
3
+ function safeId(status) {
4
+ const id = status.replace(/[^A-Za-z0-9_]/g, "_");
5
+ return /^[0-9]/.test(id) ? `s_${id}` : id;
6
+ }
7
+ /**
8
+ * A `stateDiagram-v2` for this machine.
9
+ *
10
+ * Self-transitions are emitted as-is — mermaid draws them as a loop, and they are
11
+ * meaningful (`sent → sent` is the device-command re-lease after a lapsed lease),
12
+ * so suppressing them would hide a real rule.
13
+ */
14
+ function toMermaid(machine, options = {}) {
15
+ const { initial, current, title, labels = {}, showTerminal = true } = options;
16
+ const lines = [];
17
+ if (title !== void 0) lines.push("---", `title: ${title}`, "---");
18
+ lines.push("stateDiagram-v2");
19
+ if (initial !== void 0) lines.push(` [*] --> ${safeId(initial)}`);
20
+ const states = Object.keys(machine.transitions).sort();
21
+ for (const from of states) for (const to of [...machine.validTargets(from)].sort()) {
22
+ const label = labels[`${from}->${to}`];
23
+ lines.push(` ${safeId(from)} --> ${safeId(to)}${label !== void 0 ? `: ${label}` : ""}`);
24
+ }
25
+ if (showTerminal) {
26
+ for (const state of states) if (machine.isTerminal(state)) lines.push(` ${safeId(state)} --> [*]`);
27
+ }
28
+ if (current !== void 0) lines.push(" classDef current fill:#2563eb,stroke:#1e40af,color:#fff,font-weight:bold", ` class ${safeId(current)} current`);
29
+ return lines.join("\n");
30
+ }
31
+ /**
32
+ * The machine as data, for a UI that wants a table rather than a picture — an
33
+ * action bar asking "which transitions are legal from where this record is now"
34
+ * reads `targets`, and never has to hardcode a list that drifts from the kernel.
35
+ */
36
+ function describeStateMachine(machine) {
37
+ const states = Object.keys(machine.transitions).sort();
38
+ return {
39
+ name: machine.name,
40
+ states: states.map((status) => ({
41
+ status,
42
+ targets: [...machine.validTargets(status)].sort(),
43
+ sources: [...machine.validSources(status)].sort(),
44
+ terminal: machine.isTerminal(status)
45
+ }))
46
+ };
47
+ }
48
+ /**
49
+ * States that can never be reached from `initial`.
50
+ *
51
+ * A design smell worth a CI check: an unreachable state is either a transition
52
+ * somebody forgot to declare, or dead vocabulary nobody removed. Both are cheap to
53
+ * find here and expensive to notice in production, where the symptom is a record
54
+ * that can never leave the state it is stuck in.
55
+ */
56
+ function unreachableStates(machine, initial) {
57
+ const seen = new Set([initial]);
58
+ const queue = [initial];
59
+ while (queue.length > 0) for (const next of machine.validTargets(queue.pop())) if (!seen.has(next)) {
60
+ seen.add(next);
61
+ queue.push(next);
62
+ }
63
+ return Object.keys(machine.transitions).filter((s) => !seen.has(s)).sort();
64
+ }
65
+ //#endregion
66
+ export { describeStateMachine, toMermaid, unreachableStates };
@@ -0,0 +1,65 @@
1
+ //#region src/identity/subject.d.ts
2
+ /**
3
+ * `SubjectRef` — WHO a record is about, polymorphically.
4
+ *
5
+ * The pairing `{ subjectModel, subjectRef }` appears across every package that
6
+ * has to talk about a person without owning them: an attendance punch, an access
7
+ * entitlement, a device credential, a loyalty balance. Each of those kernels
8
+ * deliberately keeps the model a plain `string`, because attendance serves
9
+ * employees, students and gym members and no kernel can know which.
10
+ *
11
+ * ## Why this is NOT `ExternalRef`
12
+ *
13
+ * `composition/reference.ts` already models `{ sourceId, sourceModel }` — the
14
+ * identical SHAPE. They are kept apart because they answer different questions and
15
+ * that difference is load-bearing:
16
+ *
17
+ * - `ExternalRef` — "this record CAME FROM there" (an Order, a Stripe charge, a
18
+ * Postgres invoice). Provenance. Used for reconciliation and cascades.
19
+ * - `SubjectRef` — "this record is ABOUT them". Identity. Used for authorization
20
+ * and lookup.
21
+ *
22
+ * A grant carries both at once and they mean different things: `originModel:
23
+ * 'Order'` (what paid) and `subjectModel: 'Party'` (who holds it). Collapsing them
24
+ * into one type would make that sentence unsayable, and renaming the persisted
25
+ * `subjectModel`/`subjectRef` fields to `sourceModel`/`sourceId` is the harmful
26
+ * rename — a migration across many collections that buys nothing.
27
+ *
28
+ * ## Why a shared type at all
29
+ *
30
+ * The pairing spans SERVER and CLIENT. A server writes it when a membership grant
31
+ * projects an entitlement; a web app writes it when staff bind a card. When the two
32
+ * disagree nothing throws — the synchronizer matches on both fields, so it finds
33
+ * ZERO rows, and freezing a membership silently stops touching the member's card.
34
+ * That bug shipped: a UI hardcoded `'Customer'` against a server writing `'Party'`.
35
+ * One shared type plus one shared constant per domain is the fix.
36
+ */
37
+ /** A polymorphic reference to the subject a record is about. */
38
+ interface SubjectRef {
39
+ /** Logical model / type name — e.g. 'Party', 'Employee', 'Student', 'ApiKey'. */
40
+ subjectModel: string;
41
+ /** Opaque identifier of the subject within that model. */
42
+ subjectRef: string;
43
+ }
44
+ /**
45
+ * Build a `SubjectRef`, or `null` when the id is absent/blank.
46
+ *
47
+ * Returning null rather than a ref containing `"undefined"` or `""` is the whole
48
+ * point: a malformed binding does not fail loudly, it silently matches nothing
49
+ * forever — an entitlement nobody holds, a credential no revoke can find.
50
+ */
51
+ declare function makeSubjectRef(subjectModel: string, id: string | number | null | undefined): SubjectRef | null;
52
+ /**
53
+ * The canonical `"Model:id"` string form.
54
+ *
55
+ * One formatter so log lines, dedupe keys and command payloads agree. Two
56
+ * hand-rolled variants (`Party:1` vs `party/1`) look equivalent until one is used
57
+ * as an idempotency key and the other as a lookup.
58
+ */
59
+ declare function formatSubjectRef(subject: SubjectRef): string;
60
+ /** Exact equality on both halves — never on the id alone. */
61
+ declare function subjectRefsEqual(a: SubjectRef | null, b: SubjectRef | null): boolean;
62
+ /** Structural guard for data crossing a wire. */
63
+ declare function isSubjectRef(value: unknown): value is SubjectRef;
64
+ //#endregion
65
+ export { SubjectRef, formatSubjectRef, isSubjectRef, makeSubjectRef, subjectRefsEqual };
@@ -0,0 +1,40 @@
1
+ //#region src/identity/subject.ts
2
+ /**
3
+ * Build a `SubjectRef`, or `null` when the id is absent/blank.
4
+ *
5
+ * Returning null rather than a ref containing `"undefined"` or `""` is the whole
6
+ * point: a malformed binding does not fail loudly, it silently matches nothing
7
+ * forever — an entitlement nobody holds, a credential no revoke can find.
8
+ */
9
+ function makeSubjectRef(subjectModel, id) {
10
+ const subjectRef = id === null || id === void 0 ? "" : String(id).trim();
11
+ const model = subjectModel.trim();
12
+ if (subjectRef.length === 0 || model.length === 0) return null;
13
+ return {
14
+ subjectModel: model,
15
+ subjectRef
16
+ };
17
+ }
18
+ /**
19
+ * The canonical `"Model:id"` string form.
20
+ *
21
+ * One formatter so log lines, dedupe keys and command payloads agree. Two
22
+ * hand-rolled variants (`Party:1` vs `party/1`) look equivalent until one is used
23
+ * as an idempotency key and the other as a lookup.
24
+ */
25
+ function formatSubjectRef(subject) {
26
+ return `${subject.subjectModel}:${subject.subjectRef}`;
27
+ }
28
+ /** Exact equality on both halves — never on the id alone. */
29
+ function subjectRefsEqual(a, b) {
30
+ if (!a || !b) return false;
31
+ return a.subjectModel === b.subjectModel && a.subjectRef === b.subjectRef;
32
+ }
33
+ /** Structural guard for data crossing a wire. */
34
+ function isSubjectRef(value) {
35
+ if (typeof value !== "object" || value === null) return false;
36
+ const v = value;
37
+ return typeof v.subjectModel === "string" && v.subjectModel.length > 0 && typeof v.subjectRef === "string" && v.subjectRef.length > 0;
38
+ }
39
+ //#endregion
40
+ export { formatSubjectRef, isSubjectRef, makeSubjectRef, subjectRefsEqual };
@@ -0,0 +1,151 @@
1
+ import { UtcOffsetMinutes } from "./calendar.mjs";
2
+ import { DateRange } from "./period.mjs";
3
+ import { StatusChangeEntry } from "./status-history.mjs";
4
+
5
+ //#region src/workflow/suspension.d.ts
6
+ /**
7
+ * One suspension, past or current.
8
+ *
9
+ * `endedAt` absent or `null` means STILL OPEN — counted up to the evaluation
10
+ * instant, because an open freeze is consuming allowance right now. Treating an
11
+ * open span as zero is how an indefinite pause looks free.
12
+ */
13
+ interface SuspensionSpan {
14
+ readonly startedAt: Date;
15
+ readonly endedAt?: Date | null;
16
+ readonly reason?: string;
17
+ }
18
+ /**
19
+ * The rulebook. Every field optional; an empty policy permits an unbounded pause,
20
+ * so a host that has not decided yet gets today's (lenient) behaviour rather than
21
+ * a surprise refusal.
22
+ */
23
+ interface SuspensionPolicy {
24
+ /** `false` refuses outright — many gyms allow a freeze only on medical grounds. */
25
+ readonly allowed?: boolean;
26
+ /** Total suspended days permitted per period. Omit for unlimited. */
27
+ readonly maxDaysPerPeriod?: number;
28
+ /** Length of the allowance period in months. Default 12. */
29
+ readonly periodMonths?: number;
30
+ /** Shortest permitted single pause — stops pause-every-weekend gaming. */
31
+ readonly minDays?: number;
32
+ /** Longest permitted single pause, independent of the period allowance. */
33
+ readonly maxDays?: number;
34
+ /** How many may be open at once. Default 1. */
35
+ readonly maxConcurrent?: number;
36
+ /** Days of advance notice required before a pause may start. */
37
+ readonly noticeDays?: number;
38
+ /**
39
+ * Does resuming push the access window out by the frozen duration?
40
+ *
41
+ * Carried here because it is the same decision, made by the same person, as the
42
+ * rest of the pause rules — but note this primitive only REPORTS it. Applying it
43
+ * is the entitlement kernel's job (`resume({ creditFrozenDays })`), because only
44
+ * that kernel owns the window.
45
+ */
46
+ readonly creditsWindow?: boolean;
47
+ }
48
+ type SuspensionRefusal = 'not_allowed' | 'allowance_exhausted' | 'below_minimum' | 'above_maximum' | 'already_suspended' | 'insufficient_notice';
49
+ interface SuspensionDecision {
50
+ readonly allowed: boolean;
51
+ readonly reason: SuspensionRefusal | 'approved';
52
+ /** Operator-facing explanation. Present on every refusal. */
53
+ readonly message?: string;
54
+ /**
55
+ * When the pause must end — `min(requested end, allowance cap)`.
56
+ *
57
+ * `null` only when the policy sets no bound at all. A sweep resumes on this
58
+ * instant regardless of whether the subject asks.
59
+ */
60
+ readonly autoResumeAt?: Date | null;
61
+ /** Days already consumed in the period containing the requested start. */
62
+ readonly daysUsedInPeriod: number;
63
+ /** Remaining allowance, or `null` when unlimited. */
64
+ readonly daysRemainingInPeriod: number | null;
65
+ }
66
+ interface SuspensionRequest {
67
+ readonly startsAt: Date;
68
+ /** Requested end. Omit for open-ended — the allowance then decides. */
69
+ readonly endsAt?: Date | null;
70
+ /** When the request is being made, for the notice check. Defaults to `startsAt`. */
71
+ readonly requestedAt?: Date;
72
+ }
73
+ interface SuspensionEvalOptions {
74
+ /** Minutes east of UTC for day boundaries — the BUSINESS timezone offset. */
75
+ readonly offsetMinutes?: UtcOffsetMinutes;
76
+ }
77
+ /** The allowance period containing `at`, as a half-open range. */
78
+ declare function allowancePeriod(at: Date, periodMonths?: number, offsetMinutes?: UtcOffsetMinutes): DateRange;
79
+ /**
80
+ * Whole days of suspension inside `period`.
81
+ *
82
+ * Counts the OVERLAP, not the spans that merely start inside — a freeze running
83
+ * 15 Dec → 15 Feb must consume only its December days against the period ending in
84
+ * December. Ignoring that double-counts a straddling pause in both periods and
85
+ * refuses a member who is within their allowance.
86
+ */
87
+ declare function suspendedDaysInPeriod(history: readonly SuspensionSpan[], period: DateRange, /** Now — bounds still-open spans. Defaults to the period end. */at?: Date): number;
88
+ /** Is any span still open at `at`? */
89
+ declare function openSpans(history: readonly SuspensionSpan[], at: Date): readonly SuspensionSpan[];
90
+ /**
91
+ * Decide a pause request, and say when it must end.
92
+ *
93
+ * Refusal precedence is deliberate — most categorical first, so the message names
94
+ * the reason a human would give:
95
+ * 1. `not_allowed` — the product has no freeze at all
96
+ * 2. `already_suspended` — concurrency
97
+ * 3. `insufficient_notice` — asked too late
98
+ * 4. `below_minimum` / `above_maximum` — this single pause is the wrong length
99
+ * 5. `allowance_exhausted` — nothing left in the period
100
+ */
101
+ declare function evaluateSuspension(request: SuspensionRequest, history: readonly SuspensionSpan[], policy?: SuspensionPolicy, options?: SuspensionEvalOptions): SuspensionDecision;
102
+ /**
103
+ * Is this open pause past the instant it should have ended?
104
+ *
105
+ * What an auto-resume sweep asks. Kept separate from `evaluateSuspension` because
106
+ * the sweep has an ALREADY-DECIDED `autoResumeAt` and must not re-derive it — the
107
+ * policy may have changed since the pause began, and silently re-deciding would
108
+ * move a date the member was told.
109
+ */
110
+ declare function dueForAutoResume(span: SuspensionSpan & {
111
+ readonly autoResumeAt?: Date | null;
112
+ }, at: Date): boolean;
113
+ /**
114
+ * Derive suspension spans from a status log — the seam that makes an allowance
115
+ * ENFORCEABLE rather than caller-supplied.
116
+ *
117
+ * ## Why this exists
118
+ *
119
+ * `evaluateSuspension` needs the history of every past pause. Aggregates do not
120
+ * carry that: they carry a single `suspendedAt` that each new freeze overwrites, so
121
+ * "60 days per year" is unenforceable — a member could freeze twelve times and the
122
+ * eleventh looks identical to the first.
123
+ *
124
+ * What they DO carry (or should, per `/status-history`) is an append-only status
125
+ * log. Every pause is already in there as a transition INTO the paused status, and
126
+ * every resume as the transition out. This turns that log into the spans the
127
+ * allowance maths needs, so nothing has to be denormalised twice and the two
128
+ * primitives compose instead of overlapping.
129
+ *
130
+ * ## Assumes the log is chronological
131
+ *
132
+ * `appendStatus` guarantees that by construction (it appends and computes
133
+ * `durationInPriorMs` from the previous element), so this does not re-sort — a sort
134
+ * here would silently paper over a corrupt log that the caller should be told
135
+ * about, and would cost O(n log n) on a hot read.
136
+ *
137
+ * @param pausedStatus the status that MEANS suspended (`'suspended'`, `'paused'`,
138
+ * `'on_hold'` — domains name it differently, so it is a parameter)
139
+ */
140
+ declare function spansFromStatusHistory<TStatus extends string>(history: readonly StatusChangeEntry<TStatus>[], pausedStatus: TStatus, options?: {
141
+ /**
142
+ * The aggregate's status NOW. When it is not the paused status, a trailing
143
+ * paused entry is treated as CLOSED — the log simply has not recorded the
144
+ * resume yet (a crash between the write and the append). Leaving it open would
145
+ * charge the member allowance for time they were not frozen.
146
+ */
147
+ readonly currentStatus?: TStatus | undefined; /** Closes a genuinely-open trailing span for counting. Defaults to open. */
148
+ readonly openEndedAt?: Date | undefined;
149
+ }): SuspensionSpan[];
150
+ //#endregion
151
+ export { SuspensionDecision, SuspensionEvalOptions, SuspensionPolicy, SuspensionRefusal, SuspensionRequest, SuspensionSpan, allowancePeriod, dueForAutoResume, evaluateSuspension, openSpans, spansFromStatusHistory, suspendedDaysInPeriod };
@@ -0,0 +1,194 @@
1
+ import { rangesOverlap } from "./period.mjs";
2
+ import { addMonths, startOfDay } from "./calendar.mjs";
3
+ //#region src/workflow/suspension.ts
4
+ /**
5
+ * Suspension policy — a time-bounded pause with a per-period allowance.
6
+ *
7
+ * ## What this is, and what it is not
8
+ *
9
+ * `/hold` already models a BLOCKING hold: a document cannot progress until someone
10
+ * resolves it (fraud review, customs detention). It has no duration, no allowance
11
+ * and no automatic end, because those are meaningless for a blocker.
12
+ *
13
+ * This models the other thing: a pause the subject is ENTITLED to take, bounded by
14
+ * an allowance, that ends by itself. Three unrelated kernels need exactly that:
15
+ *
16
+ * - a gym membership freeze — "60 days a year, minimum a week"
17
+ * - a subscription pause — the Netflix-style hold
18
+ * - unpaid leave — an annual cap, distinct from accrued paid leave
19
+ *
20
+ * (`hr/leave`'s balance is an accruing PERSISTED document — a different shape.
21
+ * This is pure computation over a history, so it stays a primitive.)
22
+ *
23
+ * ## `autoResumeAt` is the whole point
24
+ *
25
+ * Without a computed end, "pause" is an indefinite free hold. Someone freezes in
26
+ * February and the business quietly stops earning until an audit notices — which
27
+ * is a revenue hole, not a bug report, so nobody files it. `evaluateSuspension`
28
+ * always returns the instant the pause MUST end, so a sweep can resume it whether
29
+ * or not the subject ever comes back.
30
+ *
31
+ * ## Dates
32
+ *
33
+ * Day boundaries come from `/calendar`'s offset-based helpers and periods from
34
+ * `/period` — never server-local getters. A "60 days per year" allowance computed
35
+ * with `getMonth()` shifts with the deploy machine's `TZ`, so the same member would
36
+ * have a different balance in two regions. The offset is a CALLER decision
37
+ * (business timezone), which is why it is a parameter with no default beyond UTC.
38
+ *
39
+ * PURE: no clock reads beyond the `at` you pass, no I/O. The same evaluation runs
40
+ * on the server, in an admin preview, and in a test.
41
+ */
42
+ const MS_PER_DAY = 1440 * 60 * 1e3;
43
+ /** The allowance period containing `at`, as a half-open range. */
44
+ function allowancePeriod(at, periodMonths = 12, offsetMinutes = 0) {
45
+ return {
46
+ start: addMonths(startOfDay(at, offsetMinutes), -periodMonths),
47
+ end: at
48
+ };
49
+ }
50
+ /**
51
+ * Whole days of suspension inside `period`.
52
+ *
53
+ * Counts the OVERLAP, not the spans that merely start inside — a freeze running
54
+ * 15 Dec → 15 Feb must consume only its December days against the period ending in
55
+ * December. Ignoring that double-counts a straddling pause in both periods and
56
+ * refuses a member who is within their allowance.
57
+ */
58
+ function suspendedDaysInPeriod(history, period, at) {
59
+ const openEnd = at ?? period.end;
60
+ let ms = 0;
61
+ for (const span of history) {
62
+ const spanEnd = span.endedAt ?? openEnd;
63
+ if (spanEnd.getTime() <= span.startedAt.getTime()) continue;
64
+ const range = {
65
+ start: span.startedAt,
66
+ end: spanEnd
67
+ };
68
+ if (!rangesOverlap(range, period)) continue;
69
+ const from = Math.max(range.start.getTime(), period.start.getTime());
70
+ const to = Math.min(range.end.getTime(), period.end.getTime());
71
+ ms += Math.max(0, to - from);
72
+ }
73
+ return Math.round(ms / MS_PER_DAY);
74
+ }
75
+ /** Is any span still open at `at`? */
76
+ function openSpans(history, at) {
77
+ return history.filter((s) => (s.endedAt ?? null) === null && s.startedAt.getTime() <= at.getTime());
78
+ }
79
+ /**
80
+ * Decide a pause request, and say when it must end.
81
+ *
82
+ * Refusal precedence is deliberate — most categorical first, so the message names
83
+ * the reason a human would give:
84
+ * 1. `not_allowed` — the product has no freeze at all
85
+ * 2. `already_suspended` — concurrency
86
+ * 3. `insufficient_notice` — asked too late
87
+ * 4. `below_minimum` / `above_maximum` — this single pause is the wrong length
88
+ * 5. `allowance_exhausted` — nothing left in the period
89
+ */
90
+ function evaluateSuspension(request, history, policy = {}, options = {}) {
91
+ const offset = options.offsetMinutes ?? 0;
92
+ const periodMonths = policy.periodMonths ?? 12;
93
+ const daysUsed = suspendedDaysInPeriod(history, allowancePeriod(request.startsAt, periodMonths, offset), request.startsAt);
94
+ const cap = policy.maxDaysPerPeriod;
95
+ const remaining = cap === void 0 ? null : Math.max(0, cap - daysUsed);
96
+ const refuse = (reason, message) => ({
97
+ allowed: false,
98
+ reason,
99
+ message,
100
+ daysUsedInPeriod: daysUsed,
101
+ daysRemainingInPeriod: remaining
102
+ });
103
+ if (policy.allowed === false) return refuse("not_allowed", "Pausing is not available on this product.");
104
+ const maxConcurrent = policy.maxConcurrent ?? 1;
105
+ if (openSpans(history, request.startsAt).length >= maxConcurrent) return refuse("already_suspended", "There is already an active pause.");
106
+ if (policy.noticeDays !== void 0 && policy.noticeDays > 0) {
107
+ const requestedAt = request.requestedAt ?? request.startsAt;
108
+ const noticeGiven = Math.round((startOfDay(request.startsAt, offset).getTime() - startOfDay(requestedAt, offset).getTime()) / MS_PER_DAY);
109
+ if (noticeGiven < policy.noticeDays) return refuse("insufficient_notice", `${policy.noticeDays} day(s) notice required; ${noticeGiven} given.`);
110
+ }
111
+ const requestedDays = request.endsAt != null ? Math.round((request.endsAt.getTime() - request.startsAt.getTime()) / MS_PER_DAY) : null;
112
+ if (requestedDays !== null) {
113
+ if (requestedDays <= 0) return refuse("below_minimum", "A pause must end after it starts.");
114
+ if (policy.minDays !== void 0 && requestedDays < policy.minDays) return refuse("below_minimum", `The shortest pause is ${policy.minDays} day(s); ${requestedDays} requested.`);
115
+ if (policy.maxDays !== void 0 && requestedDays > policy.maxDays) return refuse("above_maximum", `The longest single pause is ${policy.maxDays} day(s); ${requestedDays} requested.`);
116
+ }
117
+ if (remaining !== null && remaining <= 0) return refuse("allowance_exhausted", `The ${periodMonths}-month allowance of ${cap} day(s) is used up.`);
118
+ const candidates = [];
119
+ if (request.endsAt != null) candidates.push(request.endsAt.getTime());
120
+ if (remaining !== null) candidates.push(request.startsAt.getTime() + remaining * MS_PER_DAY);
121
+ if (policy.maxDays !== void 0) candidates.push(request.startsAt.getTime() + policy.maxDays * MS_PER_DAY);
122
+ return {
123
+ allowed: true,
124
+ reason: "approved",
125
+ autoResumeAt: candidates.length > 0 ? new Date(Math.min(...candidates)) : null,
126
+ daysUsedInPeriod: daysUsed,
127
+ daysRemainingInPeriod: remaining
128
+ };
129
+ }
130
+ /**
131
+ * Is this open pause past the instant it should have ended?
132
+ *
133
+ * What an auto-resume sweep asks. Kept separate from `evaluateSuspension` because
134
+ * the sweep has an ALREADY-DECIDED `autoResumeAt` and must not re-derive it — the
135
+ * policy may have changed since the pause began, and silently re-deciding would
136
+ * move a date the member was told.
137
+ */
138
+ function dueForAutoResume(span, at) {
139
+ if ((span.endedAt ?? null) !== null) return false;
140
+ const due = span.autoResumeAt ?? null;
141
+ return due !== null && due.getTime() <= at.getTime();
142
+ }
143
+ /**
144
+ * Derive suspension spans from a status log — the seam that makes an allowance
145
+ * ENFORCEABLE rather than caller-supplied.
146
+ *
147
+ * ## Why this exists
148
+ *
149
+ * `evaluateSuspension` needs the history of every past pause. Aggregates do not
150
+ * carry that: they carry a single `suspendedAt` that each new freeze overwrites, so
151
+ * "60 days per year" is unenforceable — a member could freeze twelve times and the
152
+ * eleventh looks identical to the first.
153
+ *
154
+ * What they DO carry (or should, per `/status-history`) is an append-only status
155
+ * log. Every pause is already in there as a transition INTO the paused status, and
156
+ * every resume as the transition out. This turns that log into the spans the
157
+ * allowance maths needs, so nothing has to be denormalised twice and the two
158
+ * primitives compose instead of overlapping.
159
+ *
160
+ * ## Assumes the log is chronological
161
+ *
162
+ * `appendStatus` guarantees that by construction (it appends and computes
163
+ * `durationInPriorMs` from the previous element), so this does not re-sort — a sort
164
+ * here would silently paper over a corrupt log that the caller should be told
165
+ * about, and would cost O(n log n) on a hot read.
166
+ *
167
+ * @param pausedStatus the status that MEANS suspended (`'suspended'`, `'paused'`,
168
+ * `'on_hold'` — domains name it differently, so it is a parameter)
169
+ */
170
+ function spansFromStatusHistory(history, pausedStatus, options = {}) {
171
+ const spans = [];
172
+ for (let i = 0; i < history.length; i += 1) {
173
+ const entry = history[i];
174
+ if (entry.status !== pausedStatus) continue;
175
+ const next = history[i + 1];
176
+ if (next !== void 0) {
177
+ spans.push({
178
+ startedAt: entry.occurredAt,
179
+ endedAt: next.occurredAt,
180
+ ...entry.note !== void 0 ? { reason: entry.note } : {}
181
+ });
182
+ continue;
183
+ }
184
+ const stillPaused = options.currentStatus === void 0 || options.currentStatus === pausedStatus;
185
+ spans.push({
186
+ startedAt: entry.occurredAt,
187
+ endedAt: stillPaused ? options.openEndedAt ?? null : entry.occurredAt,
188
+ ...entry.note !== void 0 ? { reason: entry.note } : {}
189
+ });
190
+ }
191
+ return spans;
192
+ }
193
+ //#endregion
194
+ export { allowancePeriod, dueForAutoResume, evaluateSuspension, openSpans, spansFromStatusHistory, suspendedDaysInPeriod };