@openlup/core 0.12.0 → 0.13.1

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,147 @@
1
+ import { declarationIssues, manifestShape, schemaObjectShape } from "./declarations.js";
2
+ import { schemaObjectKey } from "./schemaProbe.js";
3
+ export * from "./contracts.js";
4
+ export { schemaObjectKey, buildSchemaObjectProbe, createSchemaProbe } from "./schemaProbe.js";
5
+ /** @beta Evaluates supplied facts; never performs migration or effect work. */
6
+ export async function checkReadiness(input) {
7
+ const issues = declarationIssues(input);
8
+ let unknown = issues.some((i) => ["input", "candidate", "inventory", "loaded-package", "schemaProbe", "probeTimeoutMs"].includes(i.subject));
9
+ const add = (code, pkg, subject, fix, observation) => {
10
+ issues.push({ code, package: pkg, subject, fix, ...(observation ? { observation } : {}) });
11
+ };
12
+ const report = () => ({ state: unknown ? "unknown" : issues.length ? "unsatisfied" : "ready", candidate: input?.candidate,
13
+ loadedPackages: input?.loadedPackages ?? [], issues: issues.sort((a, b) => [a.code, a.package, a.subject, a.observation ?? ""].join("|").localeCompare([b.code, b.package, b.subject, b.observation ?? ""].join("|"))) });
14
+ if (!input || !Array.isArray(input.loadedPackages) || !Array.isArray(input.contributions))
15
+ return report();
16
+ const contributions = input.contributions.filter((c) => manifestShape.safeParse(c?.manifest).success && Array.isArray(c.handlers) && c.handlers.every((h) => h && typeof h.eventType === "string" && Number.isFinite(h.timeoutMs) && h.timeoutMs > 0) && Array.isArray(c.schedules) && c.schedules.every((s) => s && typeof s.id === "string" && typeof s.run === "function") && Array.isArray(c.routes) && c.routes.every((r) => r && typeof r.id === "string" && typeof r.handle === "function"));
17
+ const loaded = input.loadedPackages.filter((p) => p && typeof p.name === "string" && typeof p.version === "string");
18
+ const packages = new Map();
19
+ for (const p of loaded)
20
+ packages.set(p.name, (packages.get(p.name) ?? 0) + 1);
21
+ if (packages.get("@openlup/core") !== 1 || loaded.find((p) => p.name === "@openlup/core")?.kind !== "kernel")
22
+ add("OPENLUP_E_SET_MISMATCH", "@openlup/core", "kernel", "Observe exactly one core kernel.");
23
+ const set = loaded.find((p) => p.name === "@openlup/core")?.version;
24
+ const install = `Align the complete set: npm install ${[...new Set(loaded.map((p) => p.name))].sort().map((name) => `${name}@${set ?? "<set-version>"}`).join(" ")}`;
25
+ for (const [name, count] of packages)
26
+ if (count > 1)
27
+ add("OPENLUP_E_SET_MISMATCH", name, "inventory", "Observe each loaded package exactly once.");
28
+ for (const p of loaded) {
29
+ if (p.version !== set || (p.kind === "kernel" && p.name !== "@openlup/core"))
30
+ add("OPENLUP_E_SET_MISMATCH", p.name, "version", install);
31
+ if (p.kind !== "kernel" && contributions.filter((c) => c.manifest.name === p.name).length !== 1)
32
+ add("OPENLUP_E_SET_MISMATCH", p.name, "contribution", "Compose exactly one contribution for each loaded non-kernel package.");
33
+ }
34
+ const handlers = new Map();
35
+ const emitters = new Map();
36
+ const events = new Set();
37
+ for (const v of (Array.isArray(input.vocabulary) ? input.vocabulary : [])) {
38
+ if (v && typeof v.eventType === "string") {
39
+ if (events.has(v.eventType))
40
+ add("OPENLUP_E_EVENT_DUPLICATE", v.owner, v.eventType, "Declare each vocabulary identity once.");
41
+ events.add(v.eventType);
42
+ }
43
+ }
44
+ for (const c of contributions) {
45
+ const m = c.manifest;
46
+ const p = loaded.filter((p) => p.name === m.name);
47
+ if (p.length !== 1 || p[0]?.version !== m.version || p[0]?.kind !== m.kind)
48
+ add("OPENLUP_E_SET_MISMATCH", m.name, "manifest", "Match the contribution to its observed installed package.");
49
+ for (const type of m.emits) {
50
+ events.add(type);
51
+ if (m.kind !== "kernel")
52
+ emitters.set(type, [...(emitters.get(type) ?? []), m.name]);
53
+ }
54
+ const actual = c.handlers.map((h) => h?.eventType);
55
+ for (const type of new Set([...m.handles, ...actual])) {
56
+ if (m.handles.filter((x) => x === type).length !== 1 || actual.filter((x) => x === type).length !== 1)
57
+ add("OPENLUP_E_EVENT_DUPLICATE", m.name, type, "Match each declared consumer to exactly one composed handler descriptor.");
58
+ else
59
+ handlers.set(type, [...(handlers.get(type) ?? []), m.name]);
60
+ events.add(type);
61
+ }
62
+ for (const port of m.requiredPorts)
63
+ if (!isPort(input.bindings?.ports?.[`${m.name}:${port}`]))
64
+ add("OPENLUP_E_PORT_MISSING", m.name, port, "Bind the selected adapter port in composition.");
65
+ for (const env of m.env)
66
+ if (typeof input.bindings?.env?.[env] !== "string" || !input.bindings.env[env]?.trim())
67
+ add("OPENLUP_E_ENV_MISSING", m.name, env, "Supply the named environment variable without exposing its value.");
68
+ for (const id of new Set([...m.schedules.map((s) => s.id), ...c.schedules.map((s) => s?.id)])) {
69
+ const declarations = m.schedules.filter((s) => s.id === id), actual = c.schedules.filter((s) => s?.id === id);
70
+ if (declarations.length !== 1 || actual.length !== 1 || declarations[0]?.cadenceSeconds !== actual[0]?.cadenceSeconds || typeof actual[0]?.run !== "function" || input.bindings?.triggers?.[`${m.name}:${id}`] !== actual[0]?.run)
71
+ add("OPENLUP_E_SCHEDULE_UNBOUND", m.name, id, "Bind the actual schedule with its declared cadence to one host trigger.");
72
+ }
73
+ for (const id of new Set([...m.routes, ...c.routes.map((r) => r?.id)]))
74
+ if (m.routes.filter((r) => r === id).length !== 1 || c.routes.filter((r) => r?.id === id && typeof r.handle === "function").length !== 1 || input.bindings?.routes?.[`${m.name}:${id}`] !== c.routes.find((r) => r.id === id)?.handle)
75
+ add("OPENLUP_E_PORT_MISSING", m.name, id, "Bind the declared route to its actual host entry.");
76
+ }
77
+ for (const [type, owners] of [...emitters, ...handlers])
78
+ if (owners.length > 1)
79
+ add("OPENLUP_E_EVENT_DUPLICATE", owners.sort().join(","), type, "Use one non-kernel producer owner and one composed consumer per event.");
80
+ for (const type of events)
81
+ if (!handlers.has(type) && !(Array.isArray(input.exemptions) ? input.exemptions : []).some((e) => e && typeof e.reason === "string" && e.reason.trim() && typeof e.owner === "string" && typeof e.pattern === "string" && ["ignored", "dormant"].includes(e.state) && typeof e.prefix === "boolean" && (e.prefix ? type.startsWith(e.pattern) : type === e.pattern)))
82
+ add("OPENLUP_E_EVENT_UNHANDLED", "@openlup/core", type, "Register a handler or an explicit owned ignored/dormant declaration.");
83
+ for (const c of contributions) {
84
+ const req = c.manifest.requiredSchema;
85
+ if (!req)
86
+ continue;
87
+ if (issues.some((i) => ["schemaProbe", "probeTimeoutMs"].includes(i.subject))) {
88
+ unknown = true;
89
+ continue;
90
+ }
91
+ let observation;
92
+ try {
93
+ observation = await boundedObservation(input, req);
94
+ }
95
+ catch {
96
+ unknown = true;
97
+ add("OPENLUP_E_SCHEMA_BEHIND", c.manifest.name, req.version, `Observe the selected adapter and apply ${req.migration} through your migration chain.`, "probe_unavailable");
98
+ continue;
99
+ }
100
+ if (!validObservation(observation, req)) {
101
+ unknown = true;
102
+ add("OPENLUP_E_SCHEMA_BEHIND", c.manifest.name, req.version, "Return one valid observation for every requested schema object.", "probe_malformed_or_incomplete");
103
+ continue;
104
+ }
105
+ if (!observation.database || observation.database !== input.candidate?.database || observation.version === null) {
106
+ unknown = true;
107
+ add("OPENLUP_E_SCHEMA_BEHIND", c.manifest.name, req.version, "Observe the exact candidate database and applied migration identity.", "identity_or_version_unknown");
108
+ }
109
+ else if (observation.version !== req.version)
110
+ add("OPENLUP_E_SCHEMA_BEHIND", c.manifest.name, req.version, `Apply ${req.migration} and observe its version.`, "version_mismatch");
111
+ for (const { object, present } of observation.objects)
112
+ if (present !== true) {
113
+ unknown ||= present === null;
114
+ add("OPENLUP_E_SCHEMA_BEHIND", c.manifest.name, schemaObjectKey(object), `Apply ${req.migration} for the selected adapter.`, present === null ? "object_unknown" : "object_absent");
115
+ }
116
+ }
117
+ return report();
118
+ }
119
+ function validObservation(value, requirement) {
120
+ if (!value || typeof value !== "object")
121
+ return false;
122
+ const v = value;
123
+ if (typeof v.database !== "string" || !(v.version === null || typeof v.version === "string") || !Array.isArray(v.objects))
124
+ return false;
125
+ const requested = new Set(requirement.objects.map(schemaObjectKey)), seen = new Set();
126
+ for (const item of v.objects) {
127
+ if (!item || !schemaObjectShape.safeParse(item.object).success || ![true, false, null].includes(item.present))
128
+ return false;
129
+ const key = schemaObjectKey(item.object);
130
+ if (!requested.has(key) || seen.has(key))
131
+ return false;
132
+ seen.add(key);
133
+ }
134
+ return seen.size === requested.size;
135
+ }
136
+ async function boundedObservation(input, requirement) {
137
+ const timers = globalThis;
138
+ let timer;
139
+ try {
140
+ return await Promise.race([input.schemaProbe.observe(requirement), new Promise((_, reject) => { timer = timers.setTimeout(() => reject(new Error("probe_timeout")), input.probeTimeoutMs ?? 3000); })]);
141
+ }
142
+ finally {
143
+ if (timer !== undefined)
144
+ timers.clearTimeout(timer);
145
+ }
146
+ }
147
+ function isPort(value) { return typeof value === "function" || (value !== null && typeof value === "object" && !Array.isArray(value)); }
@@ -0,0 +1,13 @@
1
+ import type { SchemaObject, SqlExecutor, SchemaProbePort, RequiredSchema } from "./contracts.js";
2
+ /** @beta Identity distinguishes overloads and qualified columns. */
3
+ export declare function schemaObjectKey(object: SchemaObject): string;
4
+ /** @beta Parameterized catalog queries; this builder performs no I/O. */
5
+ export declare function buildSchemaObjectProbe(object: SchemaObject): {
6
+ text: string;
7
+ values: readonly unknown[];
8
+ };
9
+ /** @beta Explicit adapter. Host supplies independently observed database and migration identity. */
10
+ export declare function createSchemaProbe(executor: SqlExecutor, identity: (requirement: RequiredSchema) => Promise<{
11
+ database: string;
12
+ version: string | null;
13
+ }>): SchemaProbePort;
@@ -0,0 +1,25 @@
1
+ /** @beta Identity distinguishes overloads and qualified columns. */
2
+ export function schemaObjectKey(object) {
3
+ return `${object.kind}:${object.name}${object.kind === "function" ? `(${object.signature ?? ""})` : ""}`;
4
+ }
5
+ /** @beta Parameterized catalog queries; this builder performs no I/O. */
6
+ export function buildSchemaObjectProbe(object) {
7
+ if (object.kind === "function")
8
+ return { text: "select to_regprocedure($1)::oid is not null as present", values: [`${object.name}(${object.signature ?? ""})`] };
9
+ if (object.kind === "table")
10
+ return { text: "select exists (select 1 from pg_catalog.pg_class c where c.oid = to_regclass($1) and c.relkind in ('r','p')) as present", values: [object.name] };
11
+ const parts = object.name.split(".");
12
+ return { text: "select exists (select 1 from pg_catalog.pg_attribute where attrelid = to_regclass($1) and attname = $2 and attnum > 0 and not attisdropped) as present", values: [parts.slice(0, -1).join("."), parts.at(-1)] };
13
+ }
14
+ /** @beta Explicit adapter. Host supplies independently observed database and migration identity. */
15
+ export function createSchemaProbe(executor, identity) {
16
+ return { async observe(requirement) {
17
+ const observed = await identity(requirement);
18
+ const objects = await Promise.all(requirement.objects.map(async (object) => {
19
+ const query = buildSchemaObjectProbe(object);
20
+ const { rows } = await executor.query(query.text, query.values);
21
+ return { object, present: rows.length === 1 && typeof rows[0]?.present === "boolean" ? rows[0].present : null };
22
+ }));
23
+ return { ...observed, objects };
24
+ } };
25
+ }
@@ -0,0 +1,26 @@
1
+ /** @beta Structural Standard Schema v1 interface; no validator dependency required. */
2
+ export interface StandardSchemaV1<Input = unknown, Output = Input> {
3
+ readonly "~standard": {
4
+ readonly version: 1;
5
+ readonly vendor: string;
6
+ readonly validate: (value: unknown) => Result<Output> | Promise<Result<Output>>;
7
+ readonly types?: {
8
+ readonly input: Input;
9
+ readonly output: Output;
10
+ };
11
+ };
12
+ }
13
+ /** @beta */
14
+ export type Result<Output> = {
15
+ readonly value: Output;
16
+ readonly issues?: undefined;
17
+ } | {
18
+ readonly issues: readonly Issue[];
19
+ };
20
+ /** @beta */
21
+ export interface Issue {
22
+ readonly message: string;
23
+ readonly path?: readonly (PropertyKey | {
24
+ readonly key: PropertyKey;
25
+ })[];
26
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,235 @@
1
+ # Subscription engine
2
+
3
+ Owns: the pure subscription lifecycle kernel exported as `@openlup/core/subscription`.
4
+ Watches: `src/subscription/**`
5
+ Verified against: repository state `735d5d7` plus the lifecycle-parity change
6
+ (2026-09-26) — every exported name below was read out of the eight source files
7
+ in `src/subscription/`, the status matrix out of `subscriptionEngineLifecycle.ts`
8
+ and the managed SQL guard it mirrors, the
9
+ payment semantics out of `src/subscription/subscriptionEnginePayment.ts`, and
10
+ the shipped retry class plus fail-open boundary out of `cycleHardening.ts` and
11
+ its exact unit/parity tests.
12
+
13
+ All `src/subscription/` paths and unprefixed commands in this document are
14
+ relative to this package root.
15
+
16
+ `src/subscription/` is the engine. Nothing else in the platform decides when a
17
+ subscription's next cycle falls, whether an edit window is still open, or what a
18
+ recorded payment does to the schedule. A host application that re-exports these
19
+ functions is a caller, not a second engine — see **Consuming it from a host
20
+ application** at the end.
21
+
22
+ ## What this package owns
23
+
24
+ Eight files under `src/subscription/`, one barrel (`index.ts`), four groups of
25
+ exports.
26
+
27
+ | Group | File | Exports |
28
+ | --- | --- | --- |
29
+ | Cycle planning | `subscriptionEngineCore.ts` | `createInitialSubscriptionCheckoutModel`, `planSubscriptionCycle`, `isRenewalDue`, `findDueSubscriptions`, `isEditWindowOpen`, `editCutoffAt` |
30
+ | Lifecycle transitions | `subscriptionEngineLifecycle.ts` | `SUBSCRIPTION_STATUS_TRANSITIONS`, `canTransitionSubscriptionStatus`, `pauseSubscription`, `resumeSubscription`, `cancelSubscription`, `completeSubscription`, `reactivateSubscription`, `pauseSubscriptionForExpiredDunning`, `resumeSubscriptionFromExpiredDunning`, `RESTART_LEAD_DAYS`, `skipNextCycle`, `slideNextCycle`, `swapTemplateLine`, `addPermanentAddon` |
31
+ | Payment recording | `subscriptionEnginePayment.ts` | `recordPaymentSuccess`, `recordPaymentFailure` |
32
+ | Retry ladder | `cycleHardening.ts` | `DEFAULT_CYCLE_RETRY_CADENCE`, `nextRetryAttemptAt`, `shouldScheduleRetry`, `maxRetryAttempts`, `ladderTerminatedByClass`, `isDunningLadderExhausted` |
33
+
34
+ `subscriptionEngineTypes.ts` and `types.ts` carry the shapes
35
+ (`EngineSubscription`, `EngineCycle`, `EngineEvent`, the status unions, the
36
+ template snapshot) and `subscriptionEngineUtils.ts` the small pure helpers the
37
+ groups above share (`addCadenceDays`, `parseIso`, `validateTemplate`,
38
+ `isTerminalCycle`, `touchSubscription`, `makeEvent`, `success`, `failure`, …).
39
+
40
+ Every mutating function returns the same discriminated result rather than
41
+ throwing:
42
+
43
+ ```ts
44
+ type SubscriptionEngineResult<T> =
45
+ | { ok: true; value: T }
46
+ | { ok: false; error: { code: SubscriptionEngineErrorCode; message: string } };
47
+ ```
48
+
49
+ `SubscriptionEngineErrorCode` is a closed union of eleven neutral codes
50
+ (`invalid_timestamp`, `invalid_template`, `inactive_subscription`,
51
+ `edit_window_closed`, `line_not_found`, `duplicate_line`,
52
+ `invalid_slide_target`, `invalid_transition`, `terminal_cycle`,
53
+ `missing_payment_method`, `dunning_not_exhausted`). Refusals are
54
+ values; add a code rather than an exception.
55
+
56
+ Mutations also return the events they imply (`EngineEvent[]`), each already
57
+ carrying an `idempotencyKey` derived from the cycle's own
58
+ `engineIdempotencyKey`. The engine does not publish them — the host writes them
59
+ to whatever outbox it has, and the stable key is what makes that write
60
+ replay-safe.
61
+
62
+ ## No I/O, and no ambient clock
63
+
64
+ Two rules make `src/subscription/` portable, and both are mechanical rather than
65
+ aspirational.
66
+
67
+ 1. **No I/O.** No database handle, no HTTP client, no filesystem, no scheduler,
68
+ no settlement adapter. The only import that leaves `src/subscription/` is
69
+ the sibling `paymentFailureTaxonomyContracts` module, another pure kernel.
70
+ 2. **No ambient clock.** There is no `Date.now()` and no `new Date()` without an
71
+ argument anywhere in these eight files. Every function that needs the current
72
+ instant takes it as an ISO-8601 string parameter (`now`, `recordedAt`,
73
+ `failedAt`, `firstCycleAt`). `grep -rn "Date.now\|new Date()" src/subscription`
74
+ returns nothing, and it must keep returning nothing.
75
+
76
+ The consequence is that the same inputs always produce the same outputs, so the
77
+ whole engine is testable without a fake clock, a container, or a network.
78
+
79
+ `DEFAULT_TIMEZONE` here is `"UTC"`. The engine does not know which market it
80
+ serves; the composition root names the zone. A caller that has a market zone
81
+ should pass it explicitly rather than rely on the default.
82
+
83
+ ## The deterministic-clock contract on `recordPaymentSuccess`
84
+
85
+ `recordPaymentSuccess` takes **two** instants, and both are required:
86
+
87
+ ```ts
88
+ recordPaymentSuccess({ subscription, cycle, paidAt, recordedAt })
89
+ ```
90
+
91
+ - `paidAt` is when the money moved, as the settlement service reports it.
92
+ - `recordedAt` is when this deployment observed that fact. The caller supplies
93
+ it; the engine has no clock of its own to fall back on.
94
+
95
+ Lateness is measured to the **earlier of the two**. A settlement timestamp from
96
+ the future therefore cannot inflate the schedule shift and push the next charge
97
+ out past what the subscriber actually owes, and a delayed observation cannot
98
+ shorten it either. Both instants must parse, or the call answers
99
+ `invalid_timestamp`.
100
+
101
+ ## A late payment shifts the cycle; it never compresses it
102
+
103
+ This is the load-bearing rule of the whole engine, and the one most likely to be
104
+ "optimised" away by someone who reads it as defensive arithmetic.
105
+
106
+ The next cycle is computed from the **cycle's own** `scheduledAt` plus its
107
+ cadence — not from the moment the money arrived. A late payment must not silently
108
+ shorten the interval the subscriber bought. The whole days the payment was late
109
+ are then **added**:
110
+
111
+ ```
112
+ shiftedByDays = max(0, floor((min(paidAt, recordedAt) - cycle.scheduledAt) / 1 day))
113
+ nextCycleAt = max(subscription.nextCycleAt, onTimeNextCycleAt + shiftedByDays days)
114
+ ```
115
+
116
+ The outer `max` is a **monotonic clamp**: `nextCycleAt` may only ever move later,
117
+ never earlier. Without it a replayed webhook, an out-of-order settlement, or a
118
+ correction applied against an older cycle could compute an earlier instant and
119
+ pull a charge forward into a window the subscriber has already been billed for.
120
+ That is the one failure mode this rail must never have. A deployment whose
121
+ durable schedule enforces the same rule in SQL will spell the clamp `GREATEST`;
122
+ the two are one rule with two spellings, and they must move together.
123
+
124
+ `shiftedByDays` is reported on the emitted
125
+ `subscription.next_cycle_at_updated` event alongside `basedOnScheduledAt` and
126
+ `basedOnPaidAt`, so the shift is auditable after the fact rather than inferred.
127
+
128
+ A shift that pushes the instant past the representable timestamp range answers
129
+ `invalid_timestamp` rather than returning an invalid date.
130
+
131
+ ## The retry ladder terminates; it does not cap
132
+
133
+ `cycleHardening.ts` is the single authority on what a refused charge does next.
134
+ `nextRetryAttemptAt` answers the instant of the next attempt, or `null` when
135
+ there is none. `DEFAULT_CYCLE_RETRY_CADENCE` ships `backoffHours: [24, 72, 168]`
136
+ — one day, three days, seven days — frozen so nothing can mutate the shipped
137
+ ladder in place, and injectable so a deployment can publish its own.
138
+
139
+ Asking for the next slot **is** the decision. `null` means only that no next
140
+ retry is scheduled; it is a pause signal only after the rung budget is
141
+ exhausted. Class termination before exhaustion leaves the subscription active
142
+ and must not be treated as exhaustion. The function never answers "the last
143
+ slot again". Callers must not add their own
144
+ `attempt <= maxRetryAttempts()` guard — a second copy of the budget check is
145
+ exactly how two rails drift apart.
146
+
147
+ `terminatingFailureClasses` lets a refusal's *class*, rather than the rung it
148
+ reached, end the ladder. `DEFAULT_CYCLE_RETRY_CADENCE` ships exactly one member:
149
+ `["hard_do_not_retry"]`. That class says the issuer refused the instrument, not
150
+ only this charge, so no retry rung can change the answer; every other shipped
151
+ failure class keeps the full ladder.
152
+
153
+ `ladderTerminatedByClass` fails open at every configuration uncertainty. An
154
+ absent or unlisted class does not terminate. A class explicitly listed by a
155
+ custom cadence still does not terminate when the payment taxonomy does not
156
+ recognise it, or when `failureClassDecision` says retry remains allowed. Only a
157
+ listed, recognised class that the decision table already refuses can shorten
158
+ the ladder. The SQL retry rail publishes the same singleton, and the parity test
159
+ fails if those two spellings drift.
160
+
161
+ ## The status matrix, and the one non-payment pause
162
+
163
+ `SUBSCRIPTION_STATUS_TRANSITIONS` is the whole status matrix, frozen:
164
+
165
+ | From | To |
166
+ | --- | --- |
167
+ | `active` | `paused`, `cancelled`, `completed` |
168
+ | `paused` | `active`, `cancelled` |
169
+ | `cancelled` | `active` |
170
+ | `completed` | none; terminal |
171
+
172
+ It mirrors the managed SQL guard `public.subscription_guard_status_transition`
173
+ over the engine's four statuses. The guard's remaining edges belong to the
174
+ activation flow, which this engine never drives. `canTransitionSubscriptionStatus`
175
+ answers from the matrix and nothing else.
176
+
177
+ Each operation takes only its own edge. `resumeSubscription` leaves `paused`
178
+ only. `cancelled` -> `active` is the win-back, and only `reactivateSubscription`
179
+ takes it. It needs a stored payment method, because a win-back charges again,
180
+ and it restarts the schedule. The managed SQL `reactivate` action also clears
181
+ the end instant and cancellation reason, requires the customer's confirmation of
182
+ the charge timing, refuses while a cycle is locked and bumps the template
183
+ version. Those durable duties stay with the host.
184
+
185
+ **The sanctioned non-payment rule.** Nothing in this engine suspends an active
186
+ subscription for a reason its owner did not choose, except a renewal that
187
+ exhausted the retry ladder. `isDunningLadderExhausted(cycle, cadence?)` holds
188
+ only when the cycle is `payment_failed`, has no `nextRetryAt`, and its
189
+ `retryAttempt` is past `maxRetryAttempts(cadence)`. A deployment with its own
190
+ ladder passes the same cadence to `recordPaymentFailure` and to both dunning
191
+ functions, or they disagree about exhaustion. Both halves are required: a
192
+ class-terminated refusal on rung one also leaves nothing scheduled, and it must
193
+ leave the subscription active. `pauseSubscriptionForExpiredDunning` pauses an
194
+ active subscription when that predicate holds. It records `subscription.paused`
195
+ with reason `payment_failed_expired` and never touches `nextCycleAt`. It refuses
196
+ every other cycle as `dunning_not_exhausted`, and returns a subscription that is
197
+ not active unchanged with no event, so a replay is harmless.
198
+
199
+ `resumeSubscriptionFromExpiredDunning` is the customer's recovery. It needs a
200
+ paused subscription, the exhausted cycle and a payment method, stored or
201
+ supplied. The given cycle becomes `skipped` rather than being charged late.
202
+ Whether the method is chargeable unattended is the host's check, and so is
203
+ skipping any other uncollected cycle the host holds; the SQL boundary does both.
204
+ A subscription this rule paused must not go through plain `resumeSubscription`,
205
+ which neither skips the cycle nor moves a stale `nextCycleAt`.
206
+
207
+ Both restarts put `nextCycleAt` `RESTART_LEAD_DAYS` (two) days out, but never
208
+ earlier than the instant already stored. The monotonic clamp on
209
+ `recordPaymentSuccess` applies here too: a restart cannot pull a charge into a
210
+ window already paid for. The managed SQL restarts assign `now + 2 days` without
211
+ the clamp, so the two differ exactly when the stored instant is later than
212
+ that. Examples are a win-back of a subscription paid through a later date, or a
213
+ schedule skipped or slid while the ladder was still running. There the SQL side
214
+ charges earlier than the engine would.
215
+
216
+ ## Consuming it from a host application
217
+
218
+ A host application should import from the package entry point and keep its own
219
+ module a **re-export shim**:
220
+
221
+ ```ts
222
+ // host/src/subscription/subscriptionEnginePayment.ts
223
+ export { recordPaymentSuccess, recordPaymentFailure } from "@openlup/core/subscription";
224
+ ```
225
+
226
+ The shim exists so host-side call sites keep a stable local import path while the
227
+ engine lives here. The rule is one line long: **never add logic to a shim.**
228
+ A behaviour change belongs in `src/subscription/`, where it is covered by the
229
+ pure tests and shared by every consumer. A shim may narrow a type or require a
230
+ parameter the engine defaults (making a market timezone mandatory, for example),
231
+ and it must say so in a comment; anything beyond that is a second engine.
232
+
233
+ What the host still owns: persistence, the outbox write, settlement execution and
234
+ mandate storage, scheduling, notification copy, and the clock it passes in. This
235
+ package owns none of them and must never acquire one.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openlup/core",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "description": "Neutral D2C subscription and bundle commerce kernels; a development preview with no stable API.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -30,6 +30,7 @@
30
30
  "MAINTAINERS.md",
31
31
  "README.md",
32
32
  "SECURITY.md",
33
+ "docs/SUBSCRIPTION_ENGINE.md",
33
34
  "release-gates.json"
34
35
  ],
35
36
  "scripts": {
@@ -214,6 +215,36 @@
214
215
  "production": "./dist/testing.js",
215
216
  "import": "./dist/testing.js",
216
217
  "default": "./dist/testing.js"
218
+ },
219
+ "./outbox": {
220
+ "core-source": "./src/outbox/index.ts",
221
+ "types": "./dist/outbox/index.d.ts",
222
+ "module": "./dist/outbox/index.js",
223
+ "node": "./dist/outbox/index.js",
224
+ "development": "./dist/outbox/index.js",
225
+ "production": "./dist/outbox/index.js",
226
+ "import": "./dist/outbox/index.js",
227
+ "default": "./dist/outbox/index.js"
228
+ },
229
+ "./readiness": {
230
+ "core-source": "./src/readiness/index.ts",
231
+ "types": "./dist/readiness/index.d.ts",
232
+ "module": "./dist/readiness/index.js",
233
+ "node": "./dist/readiness/index.js",
234
+ "development": "./dist/readiness/index.js",
235
+ "production": "./dist/readiness/index.js",
236
+ "import": "./dist/readiness/index.js",
237
+ "default": "./dist/readiness/index.js"
238
+ },
239
+ "./standard-schema": {
240
+ "core-source": "./src/standard-schema/index.ts",
241
+ "types": "./dist/standard-schema/index.d.ts",
242
+ "module": "./dist/standard-schema/index.js",
243
+ "node": "./dist/standard-schema/index.js",
244
+ "development": "./dist/standard-schema/index.js",
245
+ "production": "./dist/standard-schema/index.js",
246
+ "import": "./dist/standard-schema/index.js",
247
+ "default": "./dist/standard-schema/index.js"
217
248
  }
218
249
  }
219
250
  }